CJ 레드팀 랩 모의해킹 (4)
- 01CJ 레드팀 랩 모의해킹 (1)
- 02CJ 레드팀 랩 모의해킹 (2)
- 03CJ 레드팀 랩 모의해킹 (3)
- 04CJ 레드팀 랩 모의해킹 (4)
- 05CJ 레드팀 랩 모의해킹 (5)
본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정은 실습용이며 실습 종료와 함께 폐기됐다. 공인 IP는 마스킹했다.
3편에서 얻은 주소로 들어가니 웹메일이 있었다. Roundcube 1.6.10.
이번 편은 앞의 세 편과 결이 다르다. 앞에서는 “안 보이던 걸 찾는” 이야기였다면, 여기는 알려진 취약점인데 설명대로 해도 안 터지는 이야기다.
얻은 계정으로 웹메일 로그인
│
알려진 취약점 시도 → 웹 방화벽이 403 으로 차단
│
헤더 이름을 소문자로 → 그대로 통과 (200)
│
그런데 통과해도 안 터진다 → 진짜 발동 조건을 실험으로 찾음
│
└──→ 명령 실행 → sudo 설정 허점 → root
1. 문은 열려 있었다
3편에서 지워진 파일 안에 계정이 있었다. testuser / testpass.

버전을 확인하니 Roundcube 1.6.10 이었다. 이 버전에는 알려진 취약점이 있다 — CVE-2025-49113.
2. 이 취약점은 무엇인가
이름이 역직렬화(deserialization) 취약점이다. 말이 어려우니 나눠 보자.
프로그램은 데이터를 파일이나 세션에 저장할 때 글자로 바꿔서 넣는다. 이걸 직렬화라고 한다. 꺼낼 때는 다시 원래 형태로 되돌린다. 그게 역직렬화다.
[프로그램 안의 데이터] ──직렬화──→ "글자로 바뀐 것" 저장
[프로그램 안의 데이터] ←역직렬화── "글자로 바뀐 것" 꺼냄
문제는 이거다. 되돌릴 때 그 글자를 그대로 믿는다. 그래서 내가 그 글자를 조작해 넣어두면, 프로그램은 내가 만든 가짜를 진짜인 줄 알고 되살린다.
이 경우 되살아나는 것은 Crypt_GPG_Engine 이라는 부품이다. 이 부품은 역할을 마치고 사라질 때 청소 작업으로 명령을 하나 실행한다. 그 명령의 내용을 내가 정할 수 있다면?
가짜 부품을 세션에 심음
→ 서버가 세션을 읽으며 되살림
→ 다 쓰고 사라질 때 청소 명령 실행
→ 그 명령이 내가 적은 것
내가 심은 문장이 서버에서 실행된다.
3. 방화벽이 막았다 — 대소문자 하나로 지났다
공개된 재현 코드를 그대로 돌렸다. 403 이 돌아왔다.
앞단에 웹 방화벽(WAF) 이 있었다. 들어오는 요청을 미리 검사해서 수상하면 막는 장치다.
3편에서 얻은 컨테이너 안에 그 방화벽의 규칙 파일이 있었다. 읽어보니 이렇게 검사하고 있었다.
# 파일 업로드 요청에서 이 문구를 정확히 찾아
Content-Disposition: form-data; name="_file[]"; filename="여기"
# "여기" 안에 O:숫자: 패턴이 있으면 차단
O:16: 같은 패턴이 바로 아까 말한 “가짜 부품을 글자로 바꾼 것” 의 시작 표시다. 그걸 잡겠다는 뜻이다.
그런데 검사가 대문자 고정이었다
규칙은 Content-Disposition 이라고 대문자로 시작하는 형태만 찾고 있었다.
문제는 HTTP 규격상 헤더 이름은 대소문자를 구분하지 않는다. content-disposition 이라고 소문자로 보내도 서버는 똑같이 알아듣는다.
Content-Disposition → 방화벽이 찾는 문구 → 차단
content-disposition → 방화벽이 못 찾음 → 통과
그런데 PHP 는 똑같이 처리
방화벽과 서버가 같은 것을 다르게 읽었다. 검사하는 쪽은 글자 그대로 찾았고, 처리하는 쪽은 규격대로 대소문자를 무시했다.
소문자로 바꿔 보냈다. 403 이 200 으로 바뀌었다.
이건 2편의
....//와 같은 종류다. 막는 규칙이 실제 동작 방식과 어긋나 있으면, 그 틈이 곧 통로가 된다.
4. 통과했는데도 안 터졌다
여기서부터가 이 편에서 제일 오래 걸린 부분이다.
방화벽은 지났는데 아무 일도 일어나지 않았다. 공개된 재현 코드들은 하나같이 “파일을 올리면 그것만으로 실행된다” 고 설명하고 있었다. 그런데 안 됐다.
처음엔 이미 고쳐진 서버인가 싶었다. 하지만 버전은 분명 취약한 1.6.10 이었다.
실험으로 조건을 좁혔다
추측을 멈추고 하나씩 바꿔가며 재봤다. 판정 기준은 응답 시간으로 잡았다.
명령 자리에 sleep 8 을 넣으면, 실행됐을 때만 응답이 8초 넘게 걸린다. 화면에 결과가 안 나와도 시간으로는 알 수 있다. (1편에서 COPY PROGRAM sleep 5 로 쓴 것과 같은 방법이다.)
업로드 1회 → 응답 즉시 실행 안 됨
업로드 2회 → 응답 8.12초 실행됨
한 번이 아니라 두 번 올려야 터졌다.
왜 두 번인가
이유는 세션을 저장할 때와 읽을 때 방식이 다르기 때문이었다.
저장할 때 PHP 기본 방식으로 기록
읽을 때 Roundcube 가 만든 자체 방식으로 해석 ← 여기가 다르다
같은 글자를 두 방식이 다르게 끊어 읽는다. 그래서 저장하는 순간에는 아무 일도 안 일어나고, 나중에 그 세션을 다시 읽을 때 내가 심은 가짜 부품이 되살아난다.
그 “다시 읽기”가 일어나는 시점이 두 번째 업로드였다.
1차 업로드 가짜 부품이 세션에 저장됨 (아직 조용함)
2차 업로드 서버가 세션을 다시 읽음 ← 여기서 발동
공개 자료가 틀린 건 아니었다. 어디서 발동하는지를 안 적었을 뿐이다. 그래서 그대로 따라 하면 1차 업로드에서 끝나고, 아무 일도 안 일어난 채 “패치됐나 보다” 하고 접게 된다.
여기서 배운 것: 공개된 재현 코드가 안 될 때 “막혔나 보다” 로 결론짓기 전에, 어느 단계까지 갔는지를 재봐야 한다. 이 경우 방화벽은 이미 지났고, 발동 조건 하나가 빠져 있었을 뿐이다.
5. 결과를 어떻게 받아오나
명령은 실행되는데 결과가 화면에 안 나왔다. 실행 통로가 원래 출력을 돌려주지 않는 구조였기 때문이다.
그래서 우회했다. 명령 결과를 웹으로 열 수 있는 위치에 파일로 떨어뜨리고, 그 주소를 브라우저로 여는 방식이다.
명령 실행 → 결과를 웹 폴더에 파일로 저장 → 그 주소를 GET 으로 읽음
1편에서 /tmp 에 파일로 떨어뜨려 되읽은 것과 같은 발상이다. 출력이 안 보이면 볼 수 있는 곳에 옮겨 두면 된다.
이때 실행 주체는 www-data 였다. 웹서버가 쓰는 계정이라 권한이 낮다. root 는 아직 멀었다.
6. find 하나로 root
권한을 올릴 방법을 찾다가 sudo -l 을 봤다. 이 계정이 비밀번호 없이 실행할 수 있는 것의 목록이다.
User www-data may run: (ALL) NOPASSWD: /usr/bin/find

find 는 파일을 찾는 평범한 명령이다. 위험해 보이지 않는다. 그래서 이런 설정이 만들어진다.
문제는 find 에 -exec 라는 기능이 있다는 것이다. 찾은 파일마다 명령을 실행해 주는 옵션인데, 거기에 아무 명령이나 넣을 수 있다.
sudo -n /usr/bin/find /etc/hostname -maxdepth 0 -exec /bin/bash -c 'id' \;
uid=0(root)
find 를 root 로 실행할 수 있으면, -exec 에 붙인 것도 root 로 실행된다. 파일 검색 권한 하나가 곧 전체 권한이 되는 셈이다.
이런 식으로 악용 가능한 명령들은 GTFOBins 라는 목록에 정리돼 있다.
find,vim,less,tar처럼 흔한 명령들이 잔뜩 올라 있다. sudo 설정을 만들 때 한 번 찾아보면 좋다.
root 도달. 랩의 이 구간이 끝났다.
조치 권고
| 문제 | 어떻게 막나 |
|---|---|
| Roundcube 1.6.10 | 1.6.11 이상으로 업데이트. 알려진 취약점은 패치가 유일한 해결 |
| 사용자 입력이 세션 키에 | 사용자가 보낸 값을 저장 위치나 키에 그대로 쓰지 않는다. 허용된 값만 통과 |
| 방화벽이 대소문자 고정 검사 | 검사 전에 헤더 이름을 소문자로 통일한다. 규격상 대소문자를 구분하지 않는 값을 글자 그대로 비교하면 안 된다 |
| 방화벽 규칙이 서버 안에 | 규칙 파일이 침해 시 그대로 읽힌다. 우회 방법을 알려주는 지도가 된다 |
www-data 에 NOPASSWD: find | 즉시 제거. 웹서버 계정에 sudo 권한을 주지 않는다 |
| sudo 권한 전반 | 명령 하나를 허용하기 전에 그 명령이 다른 명령을 실행할 수 있는지 확인. GTFOBins 대조 |
돌아보며
네 편을 쓰고 나니 공통점이 하나 보인다. 막힌 이유를 정확히 아는 것이 매번 다음 문이었다.
- 1편 — 오라클이 흔들린 이유가 레이스인 줄 알았는데 내 프로세스였다
- 2편 — 필터가
../를 지운다는 걸 알고 나니....//가 보였다 - 2편 — 404 가 서버 탓인 줄 알았는데 브라우저가 주소를 바꾸고 있었다
- 4편 — 안 터지는 게 패치인 줄 알았는데 발동 단계가 하나 빠져 있었다
네 번 다 “안 된다”에서 멈추지 않고 “왜 안 되는지”를 한 겹 더 판 지점에서 길이 나왔다. 그리고 네 번 중 세 번은 원인이 상대편이 아니라 내 쪽에 있었다.
그런데 root 를 얻고도 랩이 끝나지 않았다. /root 안의 스크립트 한 줄이 클라우드로 가는 문을 열어줬고, 거기서 개인정보 1,717건까지 이어졌다. 다음 편에서 마무리한다.
ATTACK CHAIN
