CJ 레드팀 랩 모의해킹 (3)

🛡️ 강사 제공 인가된 실습 환경에서 수행

본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정·토큰은 실습용이며 실습 종료와 함께 폐기됐다. 공인 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.16.3.0.2 이상으로 업데이트. 알려진 취약점은 패치가 유일한 해결
업로드 경로를 사용자가 지정저장 위치와 파일명은 서버가 정한다. 사용자가 보낸 파일명은 버린다
업로드 폴더가 웹루트 안실행 가능한 위치 밖으로 분리. 올라간 파일이 코드로 실행되면 안 된다
배포물에 .git 포함배포 산출물에서 .git 제거. 소스와 이력과 설정이 통째로 나간다
MD5 비밀번호bcrypt·argon2 같은 느린 방식으로. 빠른 해시는 방어가 안 된다
저장소에 인프라 주소커밋 메시지·태그 메시지도 남는다. IP·포트·계정을 적지 않는다

돌아보며

이번 편에서 두 번 같은 일을 만났다. 지운 줄 알았는데 남아 있는 것.

2편은 GitHub 저장소의 삭제된 커밋이었고, 3편은 컨테이너 임시 폴더의 저장소였다. 둘 다 “배포하고 나면 없어지겠지” 하고 신경 쓰지 않은 자리였다.

그리고 마지막 주소는 두 곳에 쪼개져 있었다. 포트만 봤을 땐 쓸모없어 보였고 IP 만 봤을 때도 마찬가지였다. 각각을 버리지 않고 들고 있다가 붙였을 때 답이 됐다.

다음 편은 메일 서버다. 웹 방화벽이 있는데, 헤더 이름의 대소문자를 구분해서 검사하는 바람에 그대로 통과한다.

ATTACK CHAIN

Pasted image 20260906200736