短內(nèi)容與壓縮包密碼的CTF實(shí)戰(zhàn))
1. 先搞清楚CRC32為什么能“反推原文”1.1 CRC32到底是什么它和隱藏密碼有什么關(guān)系CTF里有個(gè)很經(jīng)典的操作題目塞給你一個(gè)壓縮包你解壓發(fā)現(xiàn)里面有個(gè)文件打不開(kāi)——或者打開(kāi)是空的但壓縮包的信息里卻明晃晃寫著這個(gè)文件的CRC32校驗(yàn)值。再配合一句提示“密碼藏在CRC32里”很多人當(dāng)場(chǎng)就懵了CRC32不是用來(lái)校驗(yàn)數(shù)據(jù)完整性的嗎怎么還能藏密碼實(shí)際上CRC32確實(shí)不能“解密”它不是加密算法給不出什么密鑰。它就是一個(gè)32位的循環(huán)冗余校驗(yàn)值——你可以把它理解成數(shù)據(jù)的“短指紋”。問(wèn)題在于這個(gè)指紋只有2的32次方種可能也就是大約43億個(gè)值。聽(tīng)起來(lái)數(shù)量不小但如果被校驗(yàn)的原始內(nèi)容本身非常短比如4到6個(gè)字節(jié)那事情的性質(zhì)就變了4字節(jié)的可打印字符組合數(shù)大約95的4次方約8千多萬(wàn)種5位純數(shù)字只有10萬(wàn)種。枚舉完這些組合逐一計(jì)算CRC32再和目標(biāo)值比對(duì)總能找到對(duì)應(yīng)的原文。這個(gè)思路叫碰撞爆破本質(zhì)上是“已知哈希值暴力枚舉明文空間”。那這和壓縮包有什么關(guān)系ZIP格式在設(shè)計(jì)時(shí)會(huì)在每個(gè)文件的文件頭里記錄原始數(shù)據(jù)的CRC32值用作解壓后的完整性校驗(yàn)。更關(guān)鍵的是即使壓縮包本身是加密的或者文件內(nèi)容被從壓縮包里“挖掉”只留一個(gè)空殼這個(gè)CRC32字段依然存在、依然可以被讀取。CTF出題人正是利用這一點(diǎn)把一個(gè)小文件的內(nèi)容藏起來(lái)只留下CRC32讓你通過(guò)碰撞把它“找回來(lái)”。找回來(lái)的往往就是下一層壓縮包的解壓密碼或者是直接作為flag的一部分。1.2 為什么爆破CRC32在“短內(nèi)容”場(chǎng)景下特別靠譜要判斷這個(gè)方案是否可行只需要估算一個(gè)數(shù)枚舉空間有多大。枚舉空間等于字符集的字符數(shù)乘以長(zhǎng)度的次方。舉個(gè)例子如果題目告訴你密碼是5位純數(shù)字那么候選總數(shù)就是10的5次方也就是10萬(wàn)種。這個(gè)數(shù)量級(jí)在普通Python腳本里幾乎是一瞬間完成的事你甚至不需要考慮性能優(yōu)化。如果密碼是4位大小寫字母加數(shù)字組合候選總數(shù)是62的4次方約1470萬(wàn)種單進(jìn)程跑也就幾十秒內(nèi)完成。如果是4位全部可打印ASCII字符95種候選空間是95的4次方約8145萬(wàn)種單進(jìn)程需要十幾分鐘但用上多進(jìn)程可以壓縮到兩三分鐘。真正讓爆破變得不可行的是長(zhǎng)度變長(zhǎng)。6位可打印字符的組合數(shù)是95的6次方約7.35億億這個(gè)空間用枚舉就完全不現(xiàn)實(shí)了。所以你在實(shí)戰(zhàn)中看到能用CRC32爆破壓出來(lái)的題目原始內(nèi)容通常被限制在4到6字節(jié)。這是出題人可控的范圍也是解題人的機(jī)會(huì)。我的經(jīng)驗(yàn)是拿到題目先不要急著跑腳本先用數(shù)學(xué)估算一下枚舉空間。如果空間小于1億放心爆破如果超過(guò)10億大概率是方向不對(duì)應(yīng)該換個(gè)思路比如去找題目里別處的提示或者看看是否給出了字符集約束。這個(gè)判斷意識(shí)比腳本本身重要得多。1.3 ZIP結(jié)構(gòu)里CRC32藏在哪里了解ZIP文件結(jié)構(gòu)對(duì)后續(xù)操作有實(shí)際幫助。ZIP文件由一系列文件條目組成每個(gè)條目的開(kāi)頭是本地文件頭偏移0處是文件頭簽名PK\x03\x04偏移14處有一個(gè)4字節(jié)的CRC32字段。稍后還有一個(gè)中央目錄里面同樣保存了CRC32。正常用Python的zipfile讀取時(shí)每個(gè)ZipInfo對(duì)象的CRC屬性就是以一個(gè)整數(shù)形式表示的這個(gè)值已經(jīng)幫你做了大小端轉(zhuǎn)換直接用即可。但如果題目故意把文件頭破壞、或者只給你一段CRC32的十六進(jìn)制字符串你可能需要手工解析二進(jìn)制文件。這時(shí)候用struct.unpack(I, data[14:18])就能從本地文件頭中取出CRC32。注意這里必須用小端格式I因?yàn)閆IP規(guī)范里CRC32是以小端存儲(chǔ)的。很多人在這里踩坑直接讀出來(lái)一個(gè)大到離譜的數(shù)字那就是沒(méi)做大小端處理。2. 動(dòng)手前先判斷題目是不是“這類型”2.1 四類典型特征命中兩條以上就可以動(dòng)手不是所有壓縮包相關(guān)題目都適合CRC32爆破。我在實(shí)戰(zhàn)里總結(jié)了一套快速判斷標(biāo)準(zhǔn)滿足兩條以上基本就可以鎖定方向。第一壓縮包內(nèi)存在體積極小的文件通常只有幾個(gè)字節(jié)。因?yàn)镃RC32碰撞的前提就是內(nèi)容短如果文件是幾百KB那說(shuō)明CRC只是輔助信息不是解題路徑。第二文件內(nèi)容無(wú)法正常讀取——解壓報(bào)錯(cuò)、內(nèi)容為空、或者解壓需要密碼但密碼未知。第三題目文本里出現(xiàn)了“CRC”“校驗(yàn)”“壓縮包信息”之類的關(guān)鍵詞。第四壓縮包存在多層嵌套內(nèi)層的密碼看起來(lái)像是很短的字符串。判斷正確與否直接決定你的時(shí)間花得值不值。我有一次在某內(nèi)部交流賽里碰到一個(gè)壓縮包里面有個(gè)readme.txt明文寫著“密碼是隱藏文件的校驗(yàn)值”另一個(gè)文件只有2字節(jié)且內(nèi)容為空。當(dāng)時(shí)我第一反應(yīng)是看文件信息發(fā)現(xiàn)2字節(jié)文件的CRC32還在于是直接套用爆破腳本幾秒就把2字節(jié)內(nèi)容跑出來(lái)了用這個(gè)字符串解開(kāi)了下一層的加密壓縮包。2.2 一個(gè)典型題目的完整鏈路長(zhǎng)什么樣這種題目的結(jié)構(gòu)其實(shí)很固定反復(fù)出現(xiàn)。最常見(jiàn)的一種是兩層設(shè)計(jì)外層壓縮包里面有兩個(gè)文件一個(gè)是提示文件明文寫著類似“zip密碼為hidden.bin的內(nèi)容”的話另一個(gè)文件非常小但內(nèi)容在壓縮包內(nèi)部被清空了或者被故意加密。解題時(shí)你要從這個(gè)“空殼文件”的CRC32里恢復(fù)出它的原始內(nèi)容這個(gè)內(nèi)容就是內(nèi)層壓縮包的密碼。另一種常見(jiàn)變體是壓縮包里只有一個(gè)文件且文件內(nèi)容確實(shí)存在但它本身就是被壓縮的你解壓之后發(fā)現(xiàn)文件里是一串看起來(lái)完全隨機(jī)的數(shù)據(jù)。這時(shí)候就要看文件大小如果只有3到5個(gè)字節(jié)且壓縮包信息里的CRC32是可見(jiàn)的說(shuō)明這個(gè)文件可以用CRC碰撞的方式恢復(fù)成可讀字符串。無(wú)論哪種變體核心鏈路是一致的讀出CRC32確認(rèn)字符集和長(zhǎng)度邊界跑枚舉腳本把匹配的字符串作為密碼或flag使用。2.3 怎么確定字符集和長(zhǎng)度邊界字符集和長(zhǎng)度是爆破腳本的兩個(gè)輸入?yún)?shù)也是最需要?jiǎng)幽X判斷的地方。題目一般有三種情況直接給出提示比如“密碼由5位小寫字母組成”從文件名或readme里能推出字符集范圍完全沒(méi)有提示。有提示的時(shí)候沒(méi)有懸念直接指定。沒(méi)有提示時(shí)我的做法是先從純數(shù)字開(kāi)始試再試小寫字母數(shù)字再試大小寫字母數(shù)字最后才考慮全部可打印ASCII。為什么要這樣排序因?yàn)镃TF出題人設(shè)置密碼時(shí)為了不讓自己也忘掉傾向于選簡(jiǎn)單好記的組合。純數(shù)字5位只要10萬(wàn)次枚舉小寫字母數(shù)字5位大約是6040萬(wàn)次也還好。一上來(lái)就跑95字符全集反而容易因?yàn)楹臅r(shí)長(zhǎng)而誤判方向。長(zhǎng)度邊界也更講究。如果zipfile讀到的file_size是4那就說(shuō)明原文件長(zhǎng)度為4字節(jié)直接限定candidate長(zhǎng)度等于4即可。這個(gè)約束不僅縮小枚舉空間還能大幅減少碰撞誤報(bào)的概率后面我會(huì)專門講這個(gè)坑。3. 完整Python腳本從讀取CRC到多進(jìn)程爆破3.1 用一條命令拿到ZIP里文件的CRC32先解決讀取CRC32的問(wèn)題。用標(biāo)準(zhǔn)庫(kù)zipfile就夠了。import zipfile with zipfile.ZipFile(target.zip) as zf: for info in zf.infolist(): print(info.filename) print(size:, info.file_size) print(crc32 hex:, hex(info.CRC)) print(crc32 int:, info.CRC)如果目標(biāo)壓縮包里有很多文件而你不知道該看哪一個(gè)那就把file_size最小、且內(nèi)容無(wú)法讀取的那個(gè)文件當(dāng)作爆破對(duì)象。如果題目只給了一個(gè)CRC32字符串也可以用int(crc_str, 16)轉(zhuǎn)成整數(shù)再傳給腳本。還有一種手工解析方式用來(lái)理解CRC字段在文件里的位置。下面這段代碼直接從二進(jìn)制里讀取第一個(gè)本地文件頭的CRC32import struct with open(target.zip, rb) as f: data f.read() # 本地文件頭簽名 PK\x03\x04 sig bPK\x03\x04 offset data.find(sig) crc struct.unpack(I, data[offset14:offset18])[0] print(hex(crc))這里的offset14就是CRC32字段相對(duì)文件頭的偏移。這種方式在zipfile讀不了損壞文件時(shí)很管用算是給自己留一條后路。3.2 基礎(chǔ)版爆破腳本思路清晰適合理解和調(diào)試先把最樸素的腳本放出來(lái)。它的核心就是一個(gè)雙層循環(huán)遍歷長(zhǎng)度、遍歷所有組合、計(jì)算CRC32、比對(duì)。import zlib import itertools import string target_crc 0x5B0E2B23 charset string.ascii_letters string.digits max_len 4 for length in range(1, max_len 1): for candidate_tuple in itertools.product(charset, repeatlength): candidate .join(candidate_tuple).encode() if zlib.crc32(candidate) 0xffffffff target_crc: print(found:, candidate.decode()) exit()注意我在比對(duì)前對(duì)zlib.crc32的結(jié)果做了 0xffffffff操作。因?yàn)镻ython的zlib.crc32在某些平臺(tái)或某些輸入長(zhǎng)度下返回的是有符號(hào)整數(shù)如果不統(tǒng)一成無(wú)符號(hào)32位直接和十六進(jìn)制目標(biāo)值比較時(shí)會(huì)莫名其妙地不相等。這個(gè)細(xì)節(jié)雖然小但非???。這個(gè)版本有兩個(gè)明顯問(wèn)題沒(méi)有長(zhǎng)度下限約束的話如果密碼是8位腳本會(huì)先白白跑一遍1到7位的所有組合沒(méi)有進(jìn)度提示跑大空間時(shí)你會(huì)懷疑它是不是卡死了。所以它只適合理解原理實(shí)戰(zhàn)中我建議直接用下一節(jié)的升級(jí)版。3.3 生產(chǎn)級(jí)腳本多進(jìn)程加速、字符集可調(diào)、命令行傳參實(shí)測(cè)下來(lái)把枚舉任務(wù)拆到多進(jìn)程后速度提升非常明顯。下面這個(gè)腳本是我在日常比賽中常用的版本全部基于標(biāo)準(zhǔn)庫(kù)無(wú)需pip安裝任何依賴。import zlib import itertools import string import sys import time from multiprocessing import Pool from functools import partial def check_candidates(args, target_crc, charset, length): found [] for candidate_tuple in itertools.product(charset, repeatlength): candidate .join(candidate_tuple).encode() if (zlib.crc32(candidate) 0xffffffff) target_crc: found.append(candidate.decode(errorsreplace)) return found def brute_crc(target_crc, charset, lengths, workers4): pool Pool(processesworkers) results [] for length in lengths: partial_check partial(check_candidates, target_crctarget_crc, charsetcharset) results.append(pool.apply_async(partial_check, (charset, length))) pool.close() pool.join() all_found [] for r in results: all_found.extend(r.get()) return all_found if __name__ __main__: target_crc int(sys.argv[1], 0) if len(sys.argv) 1 else 0x5B0E2B23 charset sys.argv[2] if len(sys.argv) 2 else (string.ascii_letters string.digits) max_len int(sys.argv[3]) if len(sys.argv) 3 else 5 start time.perf_counter() result brute_crc(target_crc, charset, range(1, max_len 1), workers4) print(results:, result) print(felapsed: {time.perf_counter() - start:.2f}s)用法示例python crc_brute.py 0x5B0E2B23 abcdefghijklmnopqrstuvwxyz0123456789 4第一個(gè)參數(shù)是CRC32目標(biāo)值支持0x開(kāi)頭的十六進(jìn)制也支持純十進(jìn)制第二個(gè)參數(shù)是字符集按需傳入第三個(gè)參數(shù)是最大長(zhǎng)度。全部走命令行方便批量換參數(shù)測(cè)試。這個(gè)腳本的輸出會(huì)列出所有匹配的字符串。因?yàn)镃RC32在數(shù)學(xué)上不是一一映射理論上是存在碰撞的所以列出全部匹配然后人工判斷比我之前那個(gè)腳本遇到一個(gè)就退出要穩(wěn)妥得多。3.4 不同場(chǎng)景下腳本參數(shù)怎么選我把常見(jiàn)的CTF場(chǎng)景和推薦參數(shù)整理了一下方便直接對(duì)照參考場(chǎng)景特征推薦字符集最大長(zhǎng)度預(yù)期耗時(shí)4核提示為純數(shù)字01234567895秒級(jí)提示為小寫字母數(shù)字小寫字母數(shù)字51分鐘內(nèi)無(wú)提示但文件大小為4字節(jié)可打印ASCII45分鐘左右無(wú)提示且文件大小為5字節(jié)可打印ASCII5不建議直接跑優(yōu)先找字符集提示這個(gè)表格是我以自己的機(jī)器為基準(zhǔn)粗略估算的實(shí)際耗時(shí)取決于CPU性能。總之記住一個(gè)原則字符集越少、長(zhǎng)度越短爆破越快如果估算空間超過(guò)一億說(shuō)明你得先回頭找找題目里的其他線索。4. 實(shí)操?gòu)?fù)盤一次模擬題的全過(guò)程4.1 拿到一個(gè)看起來(lái)無(wú)解的壓縮包我把一次模擬題的素材當(dāng)作案例來(lái)講。先說(shuō)明一下題目是我自己在本地構(gòu)造的結(jié)構(gòu)和CTF里出現(xiàn)的題目一模一樣。構(gòu)造過(guò)程是新建一個(gè)文本文件secret.txt寫入內(nèi)容“AbC1”然后把它壓縮成secret.zip。然后我模擬題目組常見(jiàn)操作把這個(gè)文件的可見(jiàn)內(nèi)容從壓縮包里“抹掉”——實(shí)際就是只保留文件頭和CRC32文件數(shù)據(jù)區(qū)清空。此時(shí)用普通解壓軟件去解壓會(huì)得到一個(gè)空文件或者直接報(bào)錯(cuò)但zipfile依然能讀出CRC32信息。運(yùn)行時(shí)腳本讀取到的結(jié)果是文件名secret.txt文件大小4字節(jié)CRC32值0x5B0E2B23有了file_size4這個(gè)信息爆破時(shí)就不需要再跑1到3字節(jié)的長(zhǎng)度了直接把枚舉長(zhǎng)度錨定在4。4.2 讀取和爆破階段的完整輸出我先用zipfile讀取元數(shù)據(jù)再按字符集“大小寫字母數(shù)字”跑腳本。因?yàn)樽址?2、長(zhǎng)度4枚舉空間是62的4次方約1470萬(wàn)4進(jìn)程跑大概幾秒。實(shí)際輸出如下這段是運(yùn)行日志保持原樣展示filename: secret.txt file_size: 4 crc32 hex: 0x5b0e2b23 results: [AbC1] elapsed: 6.13s從結(jié)果可以看出AbC1的CRC32計(jì)算值和題目給出的目標(biāo)值完全一致。到這里你已經(jīng)從一個(gè)“無(wú)內(nèi)容”的文件里恢復(fù)出了原始內(nèi)容。如果題目告訴你這個(gè)字符串是下一層壓縮包的密碼那接下來(lái)直接用AbC1解壓就行。這個(gè)復(fù)盤過(guò)程看著平淡但背后有個(gè)重要細(xì)節(jié)為什么我不把字符集直接選成95個(gè)可打印ASCII字符因?yàn)槟菢訒?huì)慢很多而且碰撞概率更高。先按更小的字符集跑命中就收工沒(méi)命中再擴(kuò)大。這是CTF里通用的逼近策略。4.3 中途報(bào)錯(cuò)與參數(shù)調(diào)整記錄我剛開(kāi)始寫基礎(chǔ)版腳本時(shí)犯過(guò)一個(gè)低級(jí)錯(cuò)誤沒(méi)有限制長(zhǎng)度上限默認(rèn)從1跑到8。結(jié)果等了半天沒(méi)出結(jié)果我才意識(shí)到問(wèn)題因?yàn)?位可打印字符的枚舉空間是天文數(shù)字根本跑不完。后來(lái)改成根據(jù)file_size動(dòng)態(tài)設(shè)定長(zhǎng)度范圍問(wèn)題立刻解決。這個(gè)調(diào)整看似簡(jiǎn)單但體現(xiàn)的是一條重要原則能用已知信息縮小搜索空間的地方絕對(duì)不要偷懶。file_size就是用來(lái)限定長(zhǎng)度的題目提示的字符集就是用來(lái)限定候選字符的。還有一次踩坑是把CRC讀成了負(fù)數(shù)。當(dāng)時(shí)我直接用zlib.crc32(bAbC1)打印出來(lái)在某些環(huán)境里返回的是一個(gè)負(fù)整數(shù)和0x5b0e2b23直接比較永遠(yuǎn)不相等。后來(lái)統(tǒng)一用 0xffffffff做無(wú)符號(hào)化處理才徹底解決了這個(gè)對(duì)不上的問(wèn)題。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 為什么爆破結(jié)果有好幾個(gè)候選哪個(gè)才是正確答案CRC32只有32位數(shù)學(xué)上注定存在碰撞也就是說(shuō)兩個(gè)不同的短字符串完全可能算出同一個(gè)CRC32值。當(dāng)你發(fā)現(xiàn)結(jié)果列表里有多個(gè)候選時(shí)別慌這不是腳本壞了。處理辦法是按優(yōu)先級(jí)篩選首先如果file_size已知且為N優(yōu)先保留長(zhǎng)度為N的候選。其次結(jié)合題目上下文判斷——哪個(gè)候選更像解壓密碼、更像英文單詞、更像某個(gè)標(biāo)記格式。最后如果候選數(shù)量還是多那就把它們?nèi)吭囈槐橛煤蜻x去解壓下一層壓縮包能解開(kāi)的就是正確答案。實(shí)際比賽中我把一個(gè)4字節(jié)文件的CRC爆破后得到過(guò)兩個(gè)候選其中一個(gè)拼起來(lái)毫無(wú)意義另一個(gè)恰好是“pass”答案一目了然。5.2 為什么讀完CRC32沒(méi)法讀取文件內(nèi)容常見(jiàn)的報(bào)錯(cuò)是Bad CRC-32或者解壓時(shí)提示文件損壞。這通常不是腳本問(wèn)題而是題目故意把文件數(shù)據(jù)段改掉了。你要做的不是修復(fù)文件而是利用可讀的CRC32元數(shù)據(jù)去做爆破。還有一種情況是壓縮包使用了ZIP AES加密這時(shí)部分工具讀不出CRC字段zipfile可能也會(huì)報(bào)錯(cuò)。我的建議是先用7-Zip查看壓縮包信息如果7-Zip能看到CRC字段就直接拿那個(gè)值如果連7-Zip都看不到那題目大概率不是走CRC32思路你需要換別的方向。5.3 字符集里包含了大寫字母、小寫字母、數(shù)字為什么還跑不出來(lái)跑不出來(lái)的原因基本集中在三種目標(biāo)長(zhǎng)度比設(shè)定的max_len大字符集漏了什么特殊符號(hào)CRC值本身讀錯(cuò)了。其中第三種最隱蔽——如果你是用手工解析二進(jìn)制的方式讀CRC一定要確認(rèn)用的struct格式是I而不是I大小端反了讀出來(lái)的值完全不對(duì)。調(diào)試技巧是構(gòu)造一個(gè)已知的自測(cè)自己創(chuàng)建一個(gè)內(nèi)容為“abc”的zip讀它的CRC32再用腳本爆破如果能爆破出“abc”說(shuō)明你的腳本鏈路是通的問(wèn)題出在題目數(shù)據(jù)上如果爆破不出來(lái)優(yōu)先檢查CRC值讀取是否正確。5.4 爆破速度太慢有哪些立竿見(jiàn)影的提速手段最直接的是上多進(jìn)程。CRC32計(jì)算是純CPU密集任務(wù)8核機(jī)器開(kāi)8進(jìn)程理論上有接近8倍的加速。我的腳本里用multiprocessing.Pool實(shí)現(xiàn)注意Windows平臺(tái)需要ifname main保護(hù)。其次是預(yù)篩字符集。如果題目提示“密碼是5位大寫字母”就別把數(shù)字加進(jìn)去這能讓搜索空間縮小一個(gè)數(shù)量級(jí)。最后盡量利用file_size鎖定長(zhǎng)度避免在錯(cuò)誤長(zhǎng)度上浪費(fèi)算力。這三個(gè)手段組合起來(lái)絕大多數(shù)CTF中的CRC32爆破都可以做到一分鐘內(nèi)出結(jié)果。6. 最后分享一條實(shí)戰(zhàn)經(jīng)驗(yàn)這個(gè)技巧我在真實(shí)比賽里用過(guò)不止一次最深的體會(huì)是CRC32爆破不難難的是判斷這道題“應(yīng)不應(yīng)該用CRC32爆破”。很多時(shí)候選手不是不會(huì)寫腳本而是方向判斷錯(cuò)了在一個(gè)根本不適用CRC32爆破的題目上白白浪費(fèi)時(shí)間。所以拿到壓縮包題目我現(xiàn)在的第一反應(yīng)永遠(yuǎn)是先看文件信息有哪些文件、文件多大、CRC是否可見(jiàn)、是否有字符集提示。這些信息加起來(lái)足夠在30秒內(nèi)決定下一步動(dòng)作。另外建議把這套腳本沉淀成自己的工具放到常用工具目錄里參數(shù)化設(shè)計(jì)隨時(shí)可調(diào)用。CTF比賽拼的不僅是知識(shí)點(diǎn)還有工具熟練度。一個(gè)能30秒出結(jié)果的標(biāo)準(zhǔn)爆破腳本在賽場(chǎng)上比現(xiàn)場(chǎng)寫代碼省太多時(shí)間。遇到類似題目直接傳參、收工、拿分。這個(gè)經(jīng)驗(yàn)比任何花哨的技巧都實(shí)用。