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

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

본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 개인정보는 전부 실습용 합성 데이터이며 실제 인물의 정보가 아니다(아래 6장에서 확인 방법을 적었다). 공인 IP·AWS 계정번호·키는 마스킹했다.

4편에서 메일 서버의 root 를 얻었다. 보통 여기가 끝이다. 서버 하나를 완전히 장악했으니까.

그런데 서버가 끝이 아니었다. root 만 읽을 수 있는 스크립트 하나가 클라우드로 가는 문을 열어줬고, 거기서부터가 진짜였다.

root 권한으로 /root 안을 봄 → 백업 스크립트에 AWS 키가 적혀 있음
    │
그 키로 클라우드 진입 → 권한이 사다리처럼 연결돼 있었다
    │
사다리를 타고 S3 도착 → 설정 파일 안에 또 다른 키
    │
그 키로 비밀 저장소 열람 → 회원 DB 위치와 접근 권한
    │
    └──→ 회원 1,001건 · 그리고 키 없이도 열리던 고객 716건

1. root 가 읽는 파일에 키가 있었다

root 가 되면 제일 먼저 볼 곳은 다른 사용자가 못 읽던 자리다. /root 폴더가 대표적이다.

ls -la /root/

백업 스크립트가 하나 있었다.

# /root/backup-to-cloud.sh
export AWS_ACCESS_KEY_ID="AKIA3PDS……MASKED"
export AWS_SECRET_ACCESS_KEY="……MASKED……"
aws s3 sync /var/www/html/logs s3://cj-internal-backup/webmail/

AWS 키가 평문으로 적혀 있었다. 로그를 클라우드에 백업하려고 만든 스크립트인데, 그 안에 계정 열쇠가 그대로 들어 있었던 것이다.

가짜가 하나 섞여 있었다

같은 폴더에 자격증명처럼 보이는 파일이 하나 더 있었다.

{ "aws_access_key_id": "AKIAIOSFODNN7EXAMPLE",
  "note": "not a real key" }

AKIAIOSFODNN7EXAMPLE 은 AWS 공식 문서에 나오는 예제 키다. 실제로는 아무 데도 안 열린다. 랩이 심어둔 미끼였다.

이런 건 실제 진단에서도 자주 만난다. 키처럼 보이는 것을 다 진짜로 여기면 시간을 버린다. 그래서 쓰기 전에 확인부터 했다.

aws sts get-caller-identity
arn:aws:iam::<ACCOUNT_ID>:user/secu-wave-user

내가 지금 누구인지 알려주는 명령이다. 응답이 왔으니 이 키는 살아 있다.


2. 권한이 사다리처럼 연결돼 있었다

들어간 계정 secu-wave-user 는 권한이 거의 없었다. S3 를 보려 해도 거부됐다.

그런데 AWS 에는 역할 바꾸기(AssumeRole)라는 기능이 있다. 잠깐 다른 역할의 권한을 빌려 쓰는 것이다. 회사에서 평소엔 일반 직원이지만 특정 작업 때만 관리자 카드를 빌리는 것과 비슷하다.

이게 되려면 두 조건이 맞아야 한다.

① 빌려줄 역할이  "저 사람은 빌려가도 된다" 고 허락해야 하고
② 내 쪽에도      "역할을 빌릴 수 있다" 는 권한이 있어야 한다

권한 구조를 뜯어보니 이 조건이 연달아 맞아떨어져 있었다.

secu-wave-user  →  Role-SW-Dev  →  Role-SW-DataAccess  →  S3 버킷 3개

한 칸씩 빌려 타고 올라가면 마지막에 S3 가 나온다. 사다리 한 칸만 뚫려도 꼭대기까지 간다는 뜻이다. 노출된 키 하나가 딱 그 한 칸이었다.

개별 조회는 막혔는데 전체 덤프는 열렸다

권한 구조를 알아내는 과정에서 재미있는 일이 있었다. 역할 하나하나의 정책을 물어보면 거부됐다.

aws iam get-role-policy --role-name Role-SW-Dev   # Deny

그런데 계정 전체 권한 정보를 한 번에 받아오는 명령은 열려 있었다.

aws iam get-account-authorization-details          # 성공

하나씩 물으면 막고, 통째로 달라면 준 것이다. 권한 설정이 명령 단위로 붙어 있어서, 같은 정보를 얻는 다른 경로를 막지 못한 경우다.

여기서 배운 것: 하나가 막혔다고 그 정보를 못 얻는 건 아니다. 같은 것을 알려주는 다른 명령이 있는지 봐야 한다.


3. 설정 파일 안에 또 다른 키

사다리를 타고 S3 에 도착했다. 버킷이 세 개 있었다.

secuwave-backup-artifacts
secuwave-snakegallery-pjt
secuwave-infra-sys          ← 여기

처음엔 이름이 그럴듯한 파일부터 열어봤다. credentials, secret, key 같은 단어가 들어간 것들. 아무것도 안 나왔다.

그래서 방식을 바꿨다. 파일 이름을 보고 고르는 대신, 전부 받아서 키 패턴으로 훑었다.

grep -r "AKIA" .

AWS 접근 키는 전부 AKIA 로 시작한다. 그러니 이름이 뭐든 상관없이 잡힌다.

나왔다. 그런데 위치가 뜻밖이었다.

# secuwave-infra-sys/ssm/parameter/backup-config.yaml
config_reader:
  aws_access_key_id: "AKIA3PDS……MASKED"
  aws_secret_access_key: "……MASKED……"

backup-config.yaml. 아무 특징 없는 설정 파일 이름이다. 이름만 보고 골랐으면 절대 안 열어봤을 파일이다.

진짜인지 어떻게 알았나

미끼에 한 번 걸릴 뻔했으니 이번엔 근거를 봤다.

1편에서 얻은 키   AKIA3PDSAQE6 …
이번에 찾은 키    AKIA3PDSAQE6 …
                  ↑ 앞부분이 같다

AWS 키의 앞부분은 발급한 계정마다 공통이다. 앞이 같다는 건 같은 계정에서 발급된 진짜 키라는 뜻이다. 예제 키(AKIAIOSFODNN7EXAMPLE)와는 확연히 다르다.

이번엔 역할을 빌리는 게 아니라 그 키 자체가 다른 사용자의 신분이었다.

secu-wave-user   →  (사다리) →  S3
secu-wave-conf   ←  방금 찾은 키가 곧 이 사람

4. 비밀 보관소가 평문이었다

새 신분 secu-wave-conf 로 뭘 할 수 있는지 봤다. SSM 파라미터 스토어가 열렸다.

AWS 에서 설정값이나 비밀번호를 보관하는 곳이다. 암호화해서 넣는 기능(SecureString)이 있는데, 여기는 그냥 평문으로 넣어뒀다.

/prod/db/password          Pr0d-R3ad……MASKED
/prod/app/secret-key       Sup3r-S3……MASKED
/prod/dynamodb/table-name  secuwave-members
/prod/dynamodb/access-note "conf 로 회원 DB 직접 읽기 가능"

마지막 줄을 보자. 누군가 메모를 남겨뒀다. “conf 계정으로 회원 DB를 직접 읽을 수 있다” 고.

1편의 robots.txt 주석, 2편의 dump-config 안내문과 정확히 같은 종류다. 운영 편의로 남긴 메모가 매번 다음 단계를 알려줬다.


5. 회원 1,001건

메모가 알려준 대로 그 테이블을 읽었다.

aws dynamodb scan --table-name secuwave-members

1,001건이 나왔다. 이름, 이메일, 전화번호, 주소, 비밀번호 해시.

그리고 그 안에 플래그 레코드가 하나 섞여 있었다.

FLAG{ssm_plaintext_to_pii_exfiltration}

“SSM 평문에서 개인정보 유출까지”. 랩이 의도한 경로를 그대로 밟았다는 확인이다.


6. 그런데 열쇠가 필요 없는 문이 하나 더 있었다

여기까지가 정해진 경로였다. 그런데 S3 에서 받아둔 파일 중에 앱 설정 파일이 하나 있었다.

// config.js
identityPoolId: "ap-northeast-2:befd020a-……"

Cognito Identity Pool ID 다. 웹·앱이 AWS 자원을 쓸 때 임시 자격증명을 받아오는 창구인데, 이 ID 는 원래 공개돼도 되는 값이다. 브라우저에 그대로 실려 나가니까.

로그인 안 한 사람(게스트)에게도 임시 자격증명을 주는 설정이 있어서 시도해 봤다.

aws cognito-identity get-id --identity-pool-id <POOL_ID> --no-sign-request
aws cognito-identity get-credentials-for-identity ... --no-sign-request

--no-sign-request 는 아무 자격증명 없이 보낸다는 뜻이다. 그런데 자격증명이 발급됐다.

arn:aws:sts::<ACCOUNT_ID>:assumed-role/CognitoUnauth-secuwave/...

그 게스트 역할로 뭘 할 수 있는지 봤더니, 고객 테이블을 읽는 권한이 붙어 있었다.

aws dynamodb scan --table-name secuwave-customers

716건. 이름·이메일·전화·주소, 그리고 사업자등록번호까지.

이쪽이 훨씬 위험하다

앞의 1,001건은 네 편에 걸친 침투 끝에 얻은 것이다. FTP 자격증명, SQL 인젝션, 두 번의 원격 코드 실행, 권한 상승, 그리고 키 두 개.

716건은 그중 아무것도 필요 없었다.

회원 1,001건   서버 4대 침투 + root + 키 2개  →  긴 사슬 끝
고객   716건   웹페이지에 공개된 ID 하나      →  누구나

서버를 한 대도 뚫지 않고, 로그인도 하지 않고, 공개된 값 하나만으로 열린다. 설정 실수 하나가 침투 전체보다 위험했던 셈이다.


합성 데이터 확인

개인정보를 다루는 실습이라 실제 사람의 정보가 아님을 먼저 확인했다.

이메일 도메인이 전부 존재하지 않는 것들이고, 건수가 거의 균등하게 나뉘어 있었다.

143  testmail.local     143  noservice.net    143  mockmail.org
143  labmail.dev        143  fakepost.kr      143  demobox.co.kr
142  nulldomain.kr

사람이 자연스럽게 가입하면 이런 분포가 안 나온다. 생성기가 만든 데이터라는 표시다. 이름도 실제 한국 이름이 아닌 음절 조합이었고, 주소는 형식만 유효한 값이었다.

실제 진단에서 진짜 개인정보를 만나면 거기서 멈추고 보고하는 게 원칙이다. 건수만 세고 내용은 보지 않는다. 이번엔 합성 데이터임을 확인하고 진행했다.


7. 여기서 더는 못 갔다

성공한 경로만 적으면 절반이다. 보고서에는 “여기는 제대로 막혀 있었다” 도 들어가야 한다. 안 그러면 읽는 쪽에서 안 해본 것과 해봤는데 막힌 것을 구분할 수 없다.

계속 밀어봤지만 안 열린 것들이다.

노렸던 것왜 안 됐나
직원 정보 테이블특정 역할 하나만 접근 가능. 그 역할은 Lambda 서비스만 빌릴 수 있게 신뢰 설정
API 게이트웨이 /admin/*리소스 정책에 명시적 거부. 익명·정상 인증·서버 내부에서 보내도 전부 차단
배포용 S3 버킷EC2 인스턴스 역할 전용. 이름 추측도 실패
EC2 인스턴스 목록·IP권한 거부 + 컨테이너에서 메타데이터 주소 차단
컨테이너 → 호스트 탈출격리가 제대로 돼 있었다. 도커 소켓·특수 권한·호스트 마운트 없음
Cognito 인증 역할클라이언트에 시크릿이 걸려 있고, 역할에 붙은 권한이 0개
상위 관리 계정다른 계정의 root 만 신뢰. 하위 계정에서는 원리적으로 불가

“명시적 거부” 가 두 번 나온다. AWS 권한에는 허용을 안 주는 것과 거부를 못 박는 것이 따로 있는데, 뒤엣것이 훨씬 강하다. 나중에 누가 실수로 허용을 하나 더 붙여줘도 거부가 이긴다.

앞 장들과 대비가 선명하다. 뚫린 곳은 “이 정도는 괜찮겠지”로 열어둔 자리였고, 안 뚫린 곳은 거부를 명시해둔 자리였다.

헛다리도 있었다

시간은 오히려 이쪽이 더 먹었다.

남의 계정 버킷을 우리 것으로 착각했다. S3 버킷 이름은 전 세계에서 하나뿐이라, stage4 같은 이름을 넣으면 “이미 있음” 반응이 온다. 순간 찾은 줄 안다. 그런데 그건 지구 어딘가의 남이 쓰는 버킷이다.

구분법이 있다. 접근 거부 메시지의 길이다.

우리 계정 버킷   User: arn:aws:sts::…/… is not authorized to perform:
                 s3:GetObject because no identity-based policy allows…
                                                       ← 길고 구체적
남의 계정 버킷   Access Denied                          ← 짧고 끝

내 계정 안의 리소스라면 AWS가 왜 안 되는지까지 설명해준다. 남의 것이면 그냥 “안 됨”이다. 에러 메시지의 친절함이 소유 여부를 알려준다.

나머지 셋도 적어둔다.

  • backup-credentials.json — 열어보니 전부 AWS 공식 문서의 예제 키. 파일 안에 "not a real key" 라고 적혀 있었다
  • /tmp 의 flag_*, ctf_* 파일 — 같은 서버를 쓰는 다른 수강생이 남긴 것
  • 메일 서버의 stage1·flag·next 계정 — 인증이 꺼져 있어 아무 이름으로 접속하면 그 순간 만들어지는 설정이었다. 즉 내가 접속해봐서 생긴 빈 껍데기

마지막 건이 제일 위험하다. 내가 방금 만든 것을 발견물로 착각할 수 있다. 열거 도구를 돌린 직후에 특히 그렇다.


조치 권고

문제어떻게 막나
스크립트에 AWS 키 하드코딩장기 키를 쓰지 않는다. 인스턴스 역할로 대체하면 키 자체가 필요 없다
역할 빌리기가 연쇄로 연결사다리를 끊는다. 각 역할이 누구에게 열려 있는지 재검토하고 불필요한 연결 제거
개별 조회는 막고 전체 덤프는 허용같은 정보를 주는 모든 경로를 함께 막는다. 명령 하나만 막는 건 통제가 아니다
S3 설정 파일에 키 평문비밀값을 설정 파일에 두지 않는다. 이미 올라간 것은 폐기·교체
SSM 에 평문 저장SecureString 으로 암호화 저장. 읽을 수 있는 주체도 최소화
힌트가 되는 운영 메모“conf 로 회원 DB 읽기 가능” 같은 메모를 남기지 않는다
Cognito 게스트에 DB 읽기 권한최우선. 게스트 역할에서 테이블 조회 권한 즉시 제거
개인정보 저장애플리케이션 단계 암호화 + 접근 기록 남기기

돌아보며 — 다섯 편을 닫으며

이 시리즈를 관통하는 게 하나 있다. 매번 다음 문을 열어준 것은 취약점이 아니라 누군가 남긴 메모였다.

  • 1편 robots.txt 주석이 숨은 경로를
  • 1편 backup-policy.txt 가 숨김 파일 이름을
  • 2편 dump-config 안내문이 백업 위치를
  • 3편 커밋 메시지와 태그가 다음 서버 주소를
  • 5편 SSM access-note 가 회원 DB 접근 권한을

전부 나쁜 뜻 없이 적힌 것들이다. 다음에 볼 사람을 위한 배려, 잊지 않으려는 메모, 임시로 적어둔 설명. 그게 그대로 침입자에게는 안내판이 됐다.

그리고 마지막 갈래가 가장 무겁다. 716건은 이 모든 사슬과 무관하게 열려 있었다. 네 편에 걸친 침투를 하나도 안 해도 됐다. 체크박스 하나 잘못 켜둔 것으로 충분했다.

침투 실력보다 설정 하나가 더 큰 구멍이었다.


이 랩을 푸는 내내 가장 오래 걸린 건 공격이 아니라 “왜 안 되는지”를 확인하는 일이었다. 7장이 그 기록이다.

그런데 이 작업의 상당 부분은 AI에게 맡겨서 진행했고, AI가 그 확인을 스스로 하지 않는다는 게 다섯 편 내내 드러났다. 그럴듯한 설명이 하나 만들어지면 거기서 멈춘다.

그게 다음 프로젝트의 출발점이 됐다. 어디서 어떻게 무너졌는지는 AI 혼자 랩을 풀게 뒀더니에 실행 기록으로 정리했다.

AWS 구조도

Pasted image 20260906200841

Pasted image 20260906200905