CJ 레드팀 랩 모의해킹 (3)
- 01CJ 레드팀 랩 모의해킹 (1)
- 02CJ 레드팀 랩 모의해킹 (2)
- 03CJ 레드팀 랩 모의해킹 (3)
- 04CJ 레드팀 랩 모의해킹 (4)
- 05CJ 레드팀 랩 모의해킹 (5)
본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정·토큰은 실습용이며 실습 종료와 함께 폐기됐다. 공인 IP와 토큰은 마스킹했다.
2편에서 삭제된 커밋을 뒤져 다음 서버 주소를 찾아냈다. 이번엔 그 주소로 들어간다.
<STAGE2_IP>:30082 접속 → 게이트웨이만 보임
│
헤더 세 줄을 붙이니 내부 업로드 서비스가 열림
│
Struts 파일 업로드 취약점(CVE-2023-50164) → 서버에서 명령 실행
│
컨테이너 안 /tmp/p2f 에 배포용 저장소가 통째로 있었다
│
└──→ 지워진 파일과 태그 메시지에서 다음 서버 주소·계정
1. 문 앞에서 막혔다
주소를 열면 404만 나온다.
curl -si http://<STAGE2_IP>:30082/
HTTP/1.1 404 NOT FOUND
Server: openresty/1.25.3.2
openresty 는 nginx 계열 웹서버다. 즉 이건 목적지가 아니라 다른 곳으로 연결해 주는 중계 서버다. 뒤에 뭔가 있는데 그냥 두드려서는 안 열린다.
단서는 2편에서 이미 봤다. 저장소 이름이 nginx-config-relay 였고, README 에 배포 정보가 있었다. 그 저장소의 설정 파일들에 어떤 조건으로 요청을 넘겨주는지가 적혀 있었다.
Host: nginx-relay.internal.cj.local
X-Internal-Route: nginx-config-relay
X-Node-Secret: relay-legacy-2023
이 세 줄을 붙이니 응답이 달라졌다.
curl -si http://<STAGE2_IP>:30082/upload-1.0.0/ \
-H 'Host: nginx-relay.internal.cj.local' \
-H 'X-Internal-Route: nginx-config-relay' \
-H 'X-Node-Secret: relay-legacy-2023'
HTTP/1.1 200 OK
→ /upload-1.0.0/upload.action
헤더 세 줄이 곧 열쇠였다.
이게 왜 문제인가
이 중계 서버는 “이런 헤더를 달고 온 요청은 내부에서 온 것이니 통과시킨다” 는 방식으로 만들어져 있다.
문제는 헤더는 누구나 마음대로 적을 수 있다는 것이다. 브라우저가 자동으로 붙이는 것도 있지만, curl 로 보내면 아무 값이나 넣을 수 있다. 신분증을 검사하는 게 아니라 “저 내부 사람이에요” 라는 말만 듣고 들여보내는 셈이다.
게다가 그 “비밀 값”이 저장소에 적혀 있었다. 2편에서 토큰이 백업 파일에 있었던 것과 같은 종류의 실수다.
2. 대문자 하나로 뚫리는 업로드
들어가니 파일 업로드 페이지였다.
서비스 CJ Internal - File upload
주소 /upload-1.0.0/upload.action
프레임워크 Apache Struts 6.3.0.1
Apache Struts 는 자바 웹 프레임워크다. 그리고 6.3.0.1 은 알려진 취약점이 있는 버전이었다.
CVE-2023-50164 는 무엇인가
파일을 업로드할 때 서버는 보통 어디에 저장할지 스스로 정한다. 사용자가 경로를 고를 수 있으면 위험하기 때문이다.
그런데 이 버전의 Struts 에는 그 규칙에 구멍이 있었다.
폼의 정상 파일 항목 이름은 upload 였다. 그런데 첫 글자만 대문자로 바꿔서 Upload 로 보내면, Struts 가 그걸 다른 경로로 처리한다. 그러면서 uploadFileName 이라는 값으로 저장 위치를 지정할 수 있게 된다.
--BOUNDARY
Content-Disposition: form-data; name="Upload"; filename="probe.txt"
↑ 대문자 U
[파일 내용]
--BOUNDARY
Content-Disposition: form-data; name="uploadFileName"
upload-1.0.0/x.jsp ← 저장 위치를 내가 정한다
--BOUNDARY--
.jsp 는 자바 웹서버가 실행하는 파일이다. 그림이나 문서와 달리 서버가 그 안의 코드를 돌린다. 그러니 이 파일을 웹으로 접근 가능한 위치에 심고 열면, 그 안에 적은 명령이 서버에서 실행된다.
대문자 한 글자 차이로 “파일을 받는다”가 “코드를 심는다”로 바뀐다.
웹셸을 남기지 않았다
여기서 보통 하는 일은 웹셸을 올리는 것이다. 명령을 계속 받아 실행해 주는 파일이라, 한 번 심어두면 언제든 다시 들어올 수 있다.
그렇게 하지 않았다. 대신 정해진 명령 하나만 실행하고 스스로 지워지는 JSP 를 썼다.
이유는 두 가지다.
- 실습 환경이지만 다음 사람이 그 문으로 들어올 수 있다. 내가 만든 통로를 남기면 안 된다
- 교전 규칙상 지속적인 접근 수단(백도어)을 설치하는 것은 금지다
실제로 웹루트에 출처를 알 수 없는 JSP 파일이 이미 있었는데, 그건 손대지 않았다. 내가 만든 게 아니면 실행도 수정도 삭제도 하지 않는다. 남의 흔적을 지우면 나중에 무슨 일이 있었는지 아무도 모르게 된다.
3. 컨테이너 안에 저장소가 통째로 있었다
명령이 실행되니 안을 둘러봤다. /tmp 에 이런 게 있었다.
/tmp/p2f/ ← 배포용 Git 저장소
배포에 쓰던 저장소가 임시 폴더에 그대로 남아 있었다. 여기서 세 가지가 나왔다.
① 지운 파일이 히스토리에 남아 있었다
2편에서 본 것과 똑같은 일이다. 현재 폴더에는 없는 notes.txt 가 예전 커밋에는 그대로 있었다.
git log --all --diff-filter=D --name-only # 삭제된 파일 목록
git show <커밋>:notes.txt # 그 시절 내용 꺼내기
거기에 다음 서버 정보가 적혀 있었다.
서비스 이름 nginx-config-relay-mail
포트 30083
테스트 계정 testuser
비밀번호 179ad45c6ce2cb97cf1029e212046e81
② 비밀번호가 MD5 였다
179ad45c… 는 비밀번호 자체가 아니라 MD5 라는 방식으로 변환한 값이다. 원래는 되돌릴 수 없어야 한다.
그런데 MD5 는 너무 오래됐고 너무 빠르다. 이미 계산된 값이 인터넷에 통째로 공개돼 있어서, 흔한 비밀번호는 검색만 하면 나온다.
179ad45c6ce2cb97cf1029e212046e81 → testpass
“해시로 저장했으니 안전하다” 가 성립하지 않는 경우다. 1편의 bcrypt cost=4 와 같은 이야기 — 저장 방식이 아니라 그 방식의 강도가 방어력을 정한다.
③ 설정 파일에 토큰이 평문으로
.git/config 에 GitHub 토큰이 그대로 적혀 있었다. 2편의 백업 파일과 판박이다.
.git폴더는 저장소의 모든 것을 담고 있다. 배포할 때 이게 딸려 가면 소스 전체와 그동안의 이력, 그리고 설정에 적힌 비밀값까지 함께 나간다.
4. 주소는 두 조각으로 나뉘어 있었다
포트와 계정은 /tmp/p2f 에서 나왔는데, IP 는 없었다.
IP 는 2편에서 클론했던 원래 저장소의 태그 메시지에 있었다. v1.2.1-hotfix 라는 태그에 붙은 설명 문구였다.
git tag -n99
정리하면 이렇다.
IP 원본 저장소 태그 메시지 ← 2편에서 받아둔 저장소
포트 /tmp/p2f 의 README ← 이번에 얻은 컨테이너 안
계정 /tmp/p2f 의 삭제된 notes.txt
한 곳에서 다 나온 게 아니라 두 저장소에 나뉘어 있었다. 각각만 보면 반쪽이라 무시하고 지나가기 쉽다.
이어 붙여서 확인했다.
curl -si http://<STAGE3_IP>:30083/health
응답이 왔다. Stage 3 도착.
5. 정리하고 나왔다
떠나기 전에 흔적을 정리했다.
검증용 JSP 자기삭제되어 제거됨
로컬 생성 파일 제거됨
기존 JSP 파일 손대지 않음 (내가 만든 게 아님)
/tmp/p2f 읽기만 함. 저장소 내용 수정 없음
모의해킹은 들어가는 것만큼 나오는 것도 절차다. 내가 만든 것은 치우고, 남의 것은 건드리지 않고, 무엇을 했는지 기록으로 남긴다.
조치 권고
| 문제 | 어떻게 막나 |
|---|---|
| 헤더만으로 내부 판별 | 헤더는 누구나 위조한다. 서명된 토큰이나 상호 인증서로 확인 |
| 릴레이 비밀값이 저장소에 | 비밀값을 코드·설정에 두지 않는다. 이미 노출됐다면 즉시 교체 |
| Struts 6.3.0.1 | 6.3.0.2 이상으로 업데이트. 알려진 취약점은 패치가 유일한 해결 |
| 업로드 경로를 사용자가 지정 | 저장 위치와 파일명은 서버가 정한다. 사용자가 보낸 파일명은 버린다 |
| 업로드 폴더가 웹루트 안 | 실행 가능한 위치 밖으로 분리. 올라간 파일이 코드로 실행되면 안 된다 |
배포물에 .git 포함 | 배포 산출물에서 .git 제거. 소스와 이력과 설정이 통째로 나간다 |
| MD5 비밀번호 | bcrypt·argon2 같은 느린 방식으로. 빠른 해시는 방어가 안 된다 |
| 저장소에 인프라 주소 | 커밋 메시지·태그 메시지도 남는다. IP·포트·계정을 적지 않는다 |
돌아보며
이번 편에서 두 번 같은 일을 만났다. 지운 줄 알았는데 남아 있는 것.
2편은 GitHub 저장소의 삭제된 커밋이었고, 3편은 컨테이너 임시 폴더의 저장소였다. 둘 다 “배포하고 나면 없어지겠지” 하고 신경 쓰지 않은 자리였다.
그리고 마지막 주소는 두 곳에 쪼개져 있었다. 포트만 봤을 땐 쓸모없어 보였고 IP 만 봤을 때도 마찬가지였다. 각각을 버리지 않고 들고 있다가 붙였을 때 답이 됐다.
다음 편은 메일 서버다. 웹 방화벽이 있는데, 헤더 이름의 대소문자를 구분해서 검사하는 바람에 그대로 통과한다.
ATTACK CHAIN
