
CVE-2026-25243 的稳定 PoC(Redis RESTORE 双重释放 -> 远程代码执行)
Rocky Linux 8.10、aarch64、Redis 8.6.2、jemalloc 5.3.0 で検証済み。
リファレンス: https://www.zeroday.cloud/blog/redis-cve-2026-25243-deep-dive
TLDR; 安定したエクスプロイトで、さまざまな OS ディストリビューションとアーキテクチャで動作します。
これは何か? Redis におけるメモリ破損の脆弱性で、認証済みの攻撃者が Redis ユーザーとして任意のコマンドを実行できるようにします。この攻撃は現実世界で有効で、通常の Redis 操作であり管理者専用ではない、単一の RESTORE コマンドだけで成立します。このエクスプロイトは 1 秒未満で完全な RCE を実証します。
影響は? 認証済みの任意の Redis クライアントがトリガーでき、被害は全面的です: Redis プロセス内での任意コード実行 (コンテナ内では多くの場合 root で実行)。Redis 自体にパッチを当てない限り、緩和する方法はありません。
概要: どのように動作するのか? Redis には、バイナリデータのブロブを受け取り、それを Redis オブジェクトとして再構築するシリアライズ機能 (RESTORE) があります。ブロブの形式を 検証する コードと、それを デシリアライズする コードは、特定のシーケンスの解析方法について食い違いがあります — 攻撃者がヒープを破損させるために悪用するバグです。ヒープが破損すると、攻撃者は Redis プロセス内の任意のメモリアドレスを読み書きできるようになり、そこからサーバーの内部状態を乗っ取ってシェルコマンドを実行します。
実際のエクスプロイト技術: これは単純なクラッシュではありません。これはヒープエクスプロイトチェーンです: 破損 → オーバーラップ → 任意 R/W → 情報漏えい → server 構造体の発見 → 関数ポインタの乗っ取り → RCE。エクスプロイトは 9 つのステージで構成され、実行時に複数のアドレスをリークし、バイナリ構造を解析し、メモリエイリアシングを検出する必要があります。アーキテクチャ (x86-64、aarch64 など) を超えて動作する理由は、すべてのアドレスが推測ではなくターゲット自身からリークされることです。
CVE-2026-25243 は、単一の認証済み コマンドから到達可能な バグのペアです。 は攻撃者が制御する RDB ブロブをデシリアライズします。両方のバグは、ブロブを検査する と、それを実体化する の間のギャップに存在します。
RESTORERESTORE key ttl <serialized-value>バグ 1 — レガシー zipmap 変換 (CWE-415、このエクスプロイトが使用する経路)。 zipmap バリデータ (zipmapValidateIntegrity()) とコンバータ (zipmapNext()) は、冗長な長さエンコーディングについて食い違います。小さな長さ 4 は、正当に 5 バイトの長い形式 FE 04 00 00 00 で書くことができます。バリデータはあるバイト数を消費し、コンバータは別のバイト数を消費します — 4 バイトの解析同期ずれです。そのためコンバータは、検証された構造とは異なる構造を走査し、フィールドが辞書に挿入された後に lpSafeToAdd() が失敗し、クリーンアップパスがフィールドを 2 回解放します: 1 回は dictRelease() 経由、もう 1 回は sdsfree() 経由です。
バグ 2 — stream コンシューマー PEL のロード (CWE-415)。 rdbLoadStreamConsumersGroup() では、重複したエントリ ID を含むコンシューマー PEL により、2 回目の raxTryInsert() が失敗し、グループのグローバル PEL がまだ所有している streamNACK に対して streamFreeNACK() が呼び出されます。2 回解放されます。(--vuln-type stream で選択可能。)
どちらのバグも、攻撃者に「解放されつつ参照もされている」メモリチャンクを与えます — ヒープオーバーラップエクスプロイトの古典的な出発点です。
影響: 認証済みの Redis クライアント (管理者権限は不要、RESTORE は通常のデータコマンド) が、redis ユーザーとして任意コード実行を獲得します — デフォルトのコンテナイメージでは root です。
9 つのステージ。それぞれが弱いプリミティブをより強いものに変換します:
| ステージ | 獲得するプリミティブ | メカニズム |
|---|---|---|
| 0 | ターゲットプロファイル | INFO server / INFO memory → バージョン、アーキテクチャ、ディストリビューション、pid、実行ファイルパス、開始時刻、アロケータ |
| 1 | double free | 不正な zipmap (または stream) による RESTORE |
| 2 | メモリを共有する 2 つのキー | 解放されたチャンクにマーカーキーをスプレーし、エイリアシングを検出してから、一方のキーの SDS ヘッダーをその双子を介して上書きし、1 MB の「memview」に拡張 |
| 3 | 任意 R/W | memview 内で INCRBYFLOAT オブジェクトを見つけ、その ptr フィールドを乗っ取る: そのキーに対する GETRANGE/SETRANGE が任意のアドレスを読み書き可能に |
| 4 | イメージポインタ | redis-server イメージ内の値を探すためにヒープを逆方向にスキャン |
| 5 | &server | ELF ヘッダーまで下り、プログラムヘッダーを解析し、書き込み可能セグメントをダンプし、server.pid と照合 |
| 6 | メモリ内のペイロード | "/bin/sh", "-c", "<cmd>" と argv 配列を memview に書き込み |
| 7 | 乗っ取られた構造体 | server.executable、server.exec_argv、server.enable_debug_cmd を上書き |
| 8 | RCE | DEBUG CRASH-AND-RECOVER → restartServer() → execve(server.executable, server.exec_argv, environ) |
python3 exploit.py --host 127.0.0.1 --port 6379 \
--password mypassword --cmd 'id > /tmp/pwned123.txt'
確認:
cat /tmp/pwned123.txt
# uid=0(root) gid=0(root) groups=0(root)
当初の状態: エクスプロイトは x86-64 専用で、aarch64 ターゲットではステージ 3 で失敗しました。最終状態: aarch64 Rocky Linux 8.10 で 1 秒未満、116 の Redis コマンドによる完全な RCE。
a) 実行時ターゲットフィンガープリンティング (新規、ステージ 0)。 ターゲットについて何も推測しなくなりました。INFO server + INFO memory により、Redis バージョン、CPU アーキテクチャ (os: 行から)、ディストリビューション系 (gcc_version から推測)、アロケータ、そして最も重要な 3 つの検証アンカー (process_id、executable、正確な stat_starttime (server_time_usec/1e6 - uptime_in_seconds)) が得られます。以降のステージは推測ではなく、これらと照合します。
b) アーキテクチャに依存しないメモリレイアウト。 ハードコードされた 4 つの x86-64 定数 (BINARY_ADDR_MIN/MAX、HEAP_ADDR_MIN/MAX) は、x86_64、aarch64 (39 ビットと 48 ビットの両方の VA)、riscv64、ppc64le、s390x をカバーし、それぞれに ET_EXEC と ET_DYN の両方の配置を持つアーキテクチャ別テーブル (ARCH_PROFILES) に置き換えられ、リストにないものには幅広い汎用フォールバックも用意されています。これが実際にこのターゲットでエクスプロイトが失敗した理由です: リークされたポインタ 0x0000ffff8a5fdf32 は x86-64 の範囲チェックで拒否された、完全に正当な aarch64 の mmap アドレスでした。
c) コンセンサスベースのリーク検証 (ステージ 3)。 ハードコードされたヒープウィンドウを信頼する代わりに、スキャンは memview 内の構造的に有効な 1337.NNNNNN オブジェクトをすべて収集し、そのうち少なくとも 2 つが同じ memview ベースアドレス (ptr - offset_of_value) を導出することを要求します。実際には 502 個の候補が一致し、これはレンジテーブルでは提供できない証明です。確認されたポインタは、実行時にヒープウィンドウを校正します。また、形式検証は (ラウンドトリップコストの高い) 書き込み制御テストの前に移動されました。
d) ステージ 3 のスキャン境界 (バグ修正)。 スキャンはハードコードされた 10 MB まで実行されましたが、memview は 1 MB なので、末尾を越えて読み取り、空の応答を受け取り、AssertionError: Empty data from memview で中断しました。現在は memview の実際の STRLEN に制限され、1 ラウンドトリップあたり 64 KB ではなく 256 KB を読み取り、無意味だった 6×1 秒のリトライスリープループは廃止されました。
e) ステージ 5 を書き直し: ELF ガイド方式・クラッシュフリー (最重要)。 旧実装はイメージポインタから前方にスキャンし、アドレスをプローブして、ガベージ SDS ヘッダーが主張する長さだけ読み取っていました。このターゲットでは、読み取り専用セグメントの末尾から 0x715000 の未マップの穴へまっすぐ進み、サーバーを停止させました (getrangeCommand → memcpy 内で SIGSEGV)。盲目的なスキャンを安全にすることはできません。置き換えは決定的です:
7f 45 4c 46 02 であり、sdslen() は ptr[-1] からフラグバイトを取得するため、乗っ取ったオブジェクトを base+5 に向けると、e_ident[EI_CLASS]=0x02 がフラグバイト、つまり SDS_TYPE_16 になり、その長さは base+0 の uint16 = 0x457f (0x7f45 ビッグエンディアン) になります。正確に 17791 の STRLEN は、ELF シグネチャそのものです。バイナリのローカルコピーは不要です — ヘッダーはターゲット自身のメモリから読み出されます。PT_LOAD セグメントの正確なランタイム境界を取得します (PIE ターゲットの ET_DYN ロードバイアスを処理)。以降のすべての読み取りは実際のマッピングにクランプされるため、未マップ穴でのクラッシュは構造的に不可能になりました。.data/.bss 全体を読み取り可能にします。上書きされたバイトは保存され、復元されます。server.pid を INFO の pid と照合します — 正確な 8 バイトの等価性テスト — その後、server.executable をデリファレンスし、その文字列を INFO の executable と比較して確認します。旧コードは緩い 7 フィールドの形状ヒューリスティックを受け入れていました。構造体は現在、確実に特定されます。f) ステージ 4 の強化。 Lua バリデータは完全なアーキテクチャ別レンジリストを受け取り (0x400000 の非 PIE イメージと 0xaaaa… の PIE イメージの両方が認識されます)、校正済みのヒープウィンドウを除外します。1 つではなく複数の候補を返すため、不適切な選択は実行全体ではなくリトライで済みます。
g) ステージ 7 の自己検証。 enable_debug_cmd はハードコードされた stat_starttime - 0x3c で特定されていました。現在は、期待される stat_starttime 値が INFO から正確にわかっており (30 日ではなく 3 秒のウィンドウ)、構造体の読み取りウィンドウは 4 KB から 32 KB に拡大され (stat_starttime はオフセット 0x9e0 にあり、旧制限をはるかに超えています)、そして決定的なことに、各候補オフセットはライブオラクルで検証されます: バイトを設定し、DEBUG SET-ACTIVE-EXPIRE 1 を送信し、サーバーがそれを受け入れるかどうかを確認します。誤った推測は次の試行の前に復元されるため、フラグは推測ではなく任意のビルドで見つかります。-0x3c は依然として最初に試行され、8.6.2 (オフセット 0x9a4) で正しいことが確認されています。
h) 書き込みが実際に到達する (ステージ 5)。 setrangeCommand() は dbUnshareStringValue() を呼び出します。これは encoding == RAW && refcount == 1 でない限り値を複製します。乗っ取ったポインタを通した最初の書き込みの前にエンコーディングバイトがゼロ化されるため、書き込みはプライベートコピーではなくターゲットアドレスに到達します。
i) ペイロードの簡素化。 バックコネクト/リバースシェルの機構、ASCII バナー、追加されていた ;sleep 5 はすべて削除されました。ペイロードは正確に /bin/sh -c '<--cmd>' のみです。--cmd のデフォルトは id > /tmp/pwned123.txt です。
j) 速度。 ステージ 4 は 8 つではなく 3 つの候補を収集します。ステージ 5 は約 10^5 回のバイトプローブを約 40 回のバルク読み取りに置き換えます。ステージ 3 は 256 KB の読み取りを使用し、ローカル検証に失敗した候補のラウンドトリップをスキップします。チェーン全体: 116 コマンド、<1 秒。
結果: ターゲットコンテナ上の /tmp/pwned123.txt に uid=0(root) gid=0(root) groups=0(root)。
/bin/sh であり、これは POSIX と FHS が要求するものです。redis_version から選択されます (7.x と 8.x をサポート)。構造体フィールドのオフセット (executable=24、exec_argv=32) は LP64 ABI に従い、enable_debug_cmd はハードコードではなく実行時に発見・検証されます。デフォルトの zipmap 経路で 13/13 回の実行が成功 (5 回 + 8 回の連続実行) し、各実行は**≤1 秒**で完了しました。反復実行でのみ表面化した 3 つの問題が現在修正されています:
k) ステージ 0 の SAVE レース。 前回の実行 (または redis 自身) のバックグラウンドセーブがまだ実行中の場合、実行が ERR Background save already in progress で中断される可能性がありました。現在は SAVE を最大 15 秒間リトライし、それでも失敗した場合は中断する代わりにチェックポイントなしで実行を続行します。
l) ターゲット再起動中の再接続 (--connect-retries、デフォルト 10)。 失敗した試行はヒープを破損したままにするため、次の実行の FLUSHALL が汚染されたチャンクを解放し、サーバーを停止させます。サーバーは数秒後に再起動し、完全にエクスプロイト可能な状態になるため、ステージ 0 は失敗する代わりに再接続してリトライします。自らの検証エラー (未サポートのバージョン/アーキテクチャ) は決してリトライされません。これにより、ストレステスト中に約 3 回に 1 回の割合で見られた断続的な「stage 0 failed with an empty error」が解消されました。
m) 8.6.x での sizeof(streamNACK) の修正。 --vuln-type stream 経路は、構造体が 24 または 32 バイトであると想定されていたため、誤った jemalloc サイズクラスをスプレーしていました。8.6.2 では 64 バイトです (delivery_time、delivery_count、consumer、cgroup_ref_node、streamID id、pel_prev、pel_next)。正しいサイズにより、stream 経路は「key overlap not found」でステージ 2 で失敗する代わりに、ステージ 5 に到達します。
--vuln-type stream は 8.6.2 では信頼できません。 サイズ修正により、double-free、オーバーラップ、R/W プリミティブ、ELF 解析を通過しますが、その後キースペースが不安定になります: サーバーは setrangeCommand で NULL+8 の o->ptr を読み取る際に停止します。つまり、キールックアップが破損したオブジェクトを返します。解放する 64 バイトのチャンクは他のライブアロケーションと共有されるため、zipmap 経路よりもはるかに巻き添えが多くなります。13/13 の成功率を持つデフォルトの --vuln-type zipmap を使用してください。--random-heap-massage (最初に 10 万個のランダムキー) は成功しますが、常にではありません — スプレーされたヒープは、マーカーキーが置かれない場所に double-free されたチャンクを配置することがあります。再実行すれば成功します。