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

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

본 글은 강사가 제공한 인가된 실습 환경에서 진행한 내용을 정리한 것이다. 등장하는 계정은 실습용이며 실습 종료와 함께 폐기됐다. 공인 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
진단 메일에 uid=33(www-data) 과 sudo -l 결과가 그대로 찍혀 있다
진단 메일에 uid=33(www-data) 과 sudo -l 결과가 그대로 찍혀 있다

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.101.6.11 이상으로 업데이트. 알려진 취약점은 패치가 유일한 해결
사용자 입력이 세션 키에사용자가 보낸 값을 저장 위치나 키에 그대로 쓰지 않는다. 허용된 값만 통과
방화벽이 대소문자 고정 검사검사 전에 헤더 이름을 소문자로 통일한다. 규격상 대소문자를 구분하지 않는 값을 글자 그대로 비교하면 안 된다
방화벽 규칙이 서버 안에규칙 파일이 침해 시 그대로 읽힌다. 우회 방법을 알려주는 지도가 된다
www-data 에 NOPASSWD: find즉시 제거. 웹서버 계정에 sudo 권한을 주지 않는다
sudo 권한 전반명령 하나를 허용하기 전에 그 명령이 다른 명령을 실행할 수 있는지 확인. GTFOBins 대조

돌아보며

네 편을 쓰고 나니 공통점이 하나 보인다. 막힌 이유를 정확히 아는 것이 매번 다음 문이었다.

  • 1편 — 오라클이 흔들린 이유가 레이스인 줄 알았는데 내 프로세스였다
  • 2편 — 필터가 ../ 를 지운다는 걸 알고 나니 ....// 가 보였다
  • 2편 — 404 가 서버 탓인 줄 알았는데 브라우저가 주소를 바꾸고 있었다
  • 4편 — 안 터지는 게 패치인 줄 알았는데 발동 단계가 하나 빠져 있었다

네 번 다 “안 된다”에서 멈추지 않고 “왜 안 되는지”를 한 겹 더 판 지점에서 길이 나왔다. 그리고 네 번 중 세 번은 원인이 상대편이 아니라 내 쪽에 있었다.

그런데 root 를 얻고도 랩이 끝나지 않았다. /root 안의 스크립트 한 줄이 클라우드로 가는 문을 열어줬고, 거기서 개인정보 1,717건까지 이어졌다. 다음 편에서 마무리한다.

ATTACK CHAIN

Pasted image 20260906200814