嵺`:從exe提取pyc到Python源碼還原)
簡介PyInstaller提取器pyinstxtractor是面向逆向分析與安全研究的腳本用于從PyInstaller打包的Windows可執(zhí)行程序中提取內(nèi)部字節(jié)碼適合開發(fā)者、取證人員和安全競賽玩家。工具支持Python 2.x與3.x環(huán)境兼容PyInstaller 2.0到4.2等常見版本會(huì)自動(dòng)修復(fù)字節(jié)碼文件頭便于后續(xù)反編譯還原邏輯建議在生成該程序時(shí)所用的同一版本環(huán)境中運(yùn)行。使用時(shí)僅需傳入待分析程序的路徑即可自動(dòng)掃描并解出打包內(nèi)容無需復(fù)雜配置。壓縮包共3個(gè)文件含核心提取腳本、說明文檔與許可證整體僅約18KB輕量實(shí)用可直接放入個(gè)人逆向工具箱隨取隨用。目前已有830人學(xué)習(xí)下載通過這份資源可拿到完整可運(yùn)行的提取工具與配套說明理解打包結(jié)構(gòu)快速完成拆包和字節(jié)碼提取對(duì)入門程序逆向、惡意樣本分析及安全競賽練習(xí)很有幫助。1. 拿到一個(gè)沒有源碼的 PyInstaller 程序先別急著裝反編譯器做 python 逆向工程的人大概率遇到過這個(gè)場景某個(gè)工具只發(fā)布了 exe沒有源碼但你需要搞清楚它的接口邏輯、硬編碼的密鑰或者某個(gè)算法細(xì)節(jié)。網(wǎng)上大多數(shù)教程一上來就讓你裝 uncompyle6 然后對(duì)著 exe 反編譯結(jié)果要么報(bào)錯(cuò)要么得到一堆亂碼。問題通常不是反編譯器不行而是你跳過了最關(guān)鍵的中間一步把 PyInstaller 打包產(chǎn)物里的 pyc 文件先完整、無損地提取出來。pyinstxtractor 就是干這件事的——它能把 PyInstaller 生成的 exe 拆開還原出內(nèi)部的 pyc 文件之后你才能用 uncompyle6、decompyle3 或 pycdc 去恢復(fù) Python 源碼。適合剛接觸 python 反編譯的新手也適合需要批量處理多個(gè)樣本的老手。2. 拆開 PyInstaller 的殼CArchive、TOC 與提取器的工作方式2.1 PyInstaller 打包產(chǎn)物長什么樣要理解 pyinstxtractor 為什么重要得先知道 PyInstaller 把源碼藏在了哪里。PyInstaller 打包后的 exe 不是一個(gè)純 PE 文件它的尾部附加了一個(gè)自定義格式的歸檔段官方叫 CArchive。這個(gè)歸檔里存放了 Python 解釋器動(dòng)態(tài)庫、依賴的 pyd/dll、資源文件以及最重要的——所有被 import 的模塊編譯后的 pyc 字節(jié)碼。CArchive 的結(jié)構(gòu)不復(fù)雜但很關(guān)鍵文件開頭是一個(gè)四字節(jié)的 MAGIC 值通常是MEI\014\013\012\013\016接著是 TOCTable of Contents偏移量TOC 里記錄了每個(gè)條目在歸檔段中的偏移、壓縮標(biāo)志、長度和名字。pyinstxtractor 的核心工作就是解析這份 TOC把每一個(gè)條目按原樣切出來寫成獨(dú)立的文件。值得注意的一個(gè)細(xì)節(jié)是PyInstaller 從 5.x 開始調(diào)整了部分打包參數(shù)導(dǎo)致 CArchive 的段布局和舊版有細(xì)微差異。pyinstxtractor 能兼容到目前主流版本是因?yàn)樗灰蕾囉簿幋a的偏移而是順著 MAGIC 找到 TOC 后一步步解析條目。這也是它比盲目用binwalk或手動(dòng)搜pyc頭更可靠的原因。2.2 提取器怎么處理 pyc 的頭部pyc 文件在 Python 3.7 之前帶有一個(gè) 16 字節(jié)的文件頭Magic Number 時(shí)間戳 源文件大小但從 Python 3.7 開始頭被精簡為 12 字節(jié)且不再保存源文件大小。pyinstxtractor 在提取時(shí)會(huì)把 pyc 原始頭部完整保留下來這一點(diǎn)非常關(guān)鍵——某些提取器會(huì)自作主張重寫 pyc 頭導(dǎo)致后續(xù)反編譯工具無法識(shí)別。我拆過的樣本里大概有三分之一會(huì)出現(xiàn)“pyc 提取成功但反編譯失敗”的情況原因十有八九就是這個(gè)頭沒對(duì)齊。pyinstxtractor 的處理方式是將每個(gè) pyc 條目按原始字節(jié)直接寫入不做任何加工。所以提取完的 pyc 頭是什么樣PyInstaller 打包時(shí)就是什么樣。如果你后面反編譯時(shí)發(fā)現(xiàn)版本信息對(duì)不上需要手動(dòng)修正的也是這個(gè)頭部。2.3 提取流程的階段拆解pyinstxtractor 的執(zhí)行過程可以拆成四個(gè)階段定位 CArchive 段掃描整個(gè) exe 文件找到末尾的 MAGIC 標(biāo)記。解析 TOC讀取每個(gè)條目的偏移、大小、名稱。導(dǎo)出條目將 TOC 中的每個(gè)條目存為單獨(dú)文件遇到.pyz之類的嵌套歸檔會(huì)遞歸處理。輸出結(jié)構(gòu)在主輸出目錄下生成pyz_extracted子目錄、struct目錄和根目錄下的入口 pyc。這里最容易踩的坑是主程序入口腳本不一定和 exe 同名。PyInstaller 在打包時(shí)會(huì)把入口腳本編譯成pyi_rth_*.pyc或直接以腳本名命名的 pyc 放在歸檔根目錄而不是藏在 pyz 里。很多新手打開輸出目錄后看到一堆 pyc 不知道哪個(gè)才是入口直接對(duì)著pyz_extracted里的模塊一頓反編譯結(jié)果拼不出完整邏輯。這個(gè)后面會(huì)細(xì)說。3. 從 exe 到 pyc一次完整的提取實(shí)戰(zhàn)3.1 環(huán)境準(zhǔn)備與基礎(chǔ)命令pyinstxtractor 本身是單文件腳本不依賴第三方庫這一點(diǎn)對(duì)分析環(huán)境很友好。我把常見做法列一下先準(zhǔn)備一個(gè)干凈的 Python 3.8 環(huán)境然后用命令行直接執(zhí)行。# 假設(shè)目標(biāo) exe 是 target.exe與 pyinstxtractor.py 放在同一目錄 python pyinstxtractor.py target.exe # 輸出會(huì)創(chuàng)建 ./target.exe_extracted 目錄 # 如果遇到權(quán)限問題換管理員/root 執(zhí)行執(zhí)行過程會(huì)打印解析 CArchive 的日志包括找到多少條目、是否檢測到 Python 版本 magic 等。執(zhí)行完檢查輸出目錄ls -la target.exe_extracted/ # 預(yù)期看到pyz_extracted/ struct/ 以及若干 .pyc 和 .pyz 文件如果目錄里沒有pyz_extracted說明這個(gè) exe 的打包方式比較特殊但入口 pyc 仍然會(huì)放在根目錄。解析日志里出現(xiàn)的“Magic number”指的是 PyInstaller 歸檔自身的 MAGIC不是 Python 字節(jié)碼的 Magic別混淆。3.2 快速定位入口腳本入口腳本是恢復(fù)業(yè)務(wù)邏輯的關(guān)鍵。我一般用兩步快速定位它先看輸出目錄里和 exe 同名的 pyc再看pyi_rth_開頭的文件名的調(diào)用關(guān)系。# 在輸出根目錄查找候選入口 pyc ls target.exe_extracted/*.pyc # 找到候選后用 Python 內(nèi)置的 dis 模塊看它依賴了哪些模塊 python -m dis target.exe_extracted/target.pyc 2/dev/null | head -50pyi_rth_文件是 PyInstaller 的運(yùn)行時(shí)鉤子不是業(yè)務(wù)入口但能暴露 import 線索。真正的入口通常滿足兩個(gè)特征文件在歸檔根目錄而不在pyz_extracted里且它 import 的模塊在pyz_extracted里能找到。順著這個(gè)調(diào)用鏈往回推比盲目反編譯所有 pyc 快得多。3.3 處理 pyz 嵌套歸檔pyz_extracted目錄里裝的是 PyInstaller 內(nèi)置模塊歸檔解包后的產(chǎn)物如果你發(fā)現(xiàn)里面的 pyc 也是打包態(tài)就用同一個(gè)提取器再跑一次# 對(duì)嵌套的 pyz 再做一次遞歸提取 python pyinstxtractor.py target.exe_extracted/archive.pyz輸出會(huì)生成archive.pyz_extracted目錄然后繼續(xù)檢查。遞歸提取的次數(shù)通常不會(huì)超過兩層但極少數(shù)用 PyInstaller 6.x 打包并開啟壓縮的樣本可能要多跑一輪。每次遞歸提取都應(yīng)該保留上一輪輸出因?yàn)槟承┙徊嬉眯枰悸窂讲拍軐?duì)得上。4. 把 pyc 還原成源碼反編譯器選型、頭部修正與參數(shù)調(diào)整4.1 三個(gè)反編譯器的真實(shí)差異拿到 pyc 之后反編譯器的選擇直接決定你看到的是可讀源碼還是滿屏LOAD_CONST。我實(shí)際用下來三個(gè)主流工具各有偏向工具支持的 Python 版本輸出質(zhì)量典型毛病uncompyle62.7 / 3.5-3.8高接近原始源碼3.8 以上直接不識(shí)別3.7 偶爾誤判decompyle33.7-3.8中高對(duì) 3.7 優(yōu)化過3.8 支持有限pycdc3.4-3.10中覆蓋率優(yōu)先條件表達(dá)式、生成器有時(shí)還原失真選擇邏輯并不復(fù)雜先判斷 pyc 的 Python 版本再選工具。Python 3.7 優(yōu)先 decompyle33.8 先用 uncompyle6 嘗試不行再換 pycdc。3.9 以上基本只有 pycdc 能啃但輸出可讀性會(huì)打折。4.2 版本判斷與頭部修正反編譯前必須先確認(rèn) pyc 的 Python 版本。方法很簡單讀 pyc 前四個(gè)字節(jié)的 Magic Number和 CPython 源碼里的importlib/util.py對(duì)照。我知道一個(gè)更快的方式用xdis庫直接識(shí)別。pip install xdis python -m xdis.disasm target.pyc | head -20輸出里會(huì)明確顯示Python version: 3.7這種信息。如果 pyinstxtractor 提取出來的 pyc 頭缺了前四字節(jié)舊版 PyInstaller 偶爾干這事uncompyle6 會(huì)報(bào)Bad magic number。修正方法是用同版本 Python 編一個(gè)空 pyc把正確頭部覆蓋過去。# 用 Python 3.7 生成一個(gè)空 pyc 并取出它的頭部 import py_compile, struct py_compile.compile(empty.py, cfileempty.pyc) with open(empty.pyc, rb) as f: header f.read(16) # 用這個(gè)頭部去覆蓋損壞的 pyc with open(target_fixed.pyc, wb) as f: f.write(header open(target.pyc, rb).read()[16:])這段邏輯是讀取同版本空 pyc 的前 16 字節(jié)把目標(biāo) pyc 從第 16 字節(jié)開始的內(nèi)容接在后面。注意 Python 3.7 之后頭部是 12 字節(jié)3.6 及以前是 16 字節(jié)覆蓋前先確認(rèn)版本否則會(huì)把代碼對(duì)象首字節(jié)吞掉。4.3 實(shí)際反編譯操作與失敗切換對(duì)某個(gè) pyc 的反編譯我習(xí)慣先跑 uncompyle6報(bào)錯(cuò)立刻切 pycdc不做無謂重試# 第一優(yōu)先uncompyle6 uncompyle6 target_fixed.pyc target.py # 失敗時(shí)切 pycdc pycdc target_fixed.pyc target_pycdc.pypycdc 是 C 寫的單二進(jìn)制不需要安裝 Python 包但輸出里經(jīng)常帶# decompile failed或者把try/except還原成if結(jié)構(gòu)閱讀時(shí)要有心理準(zhǔn)備。對(duì)比兩個(gè)輸出文件可以交叉確認(rèn)哪些代碼塊還原可信。另外提一句反編譯出的源碼大概率不是 100% 可運(yùn)行的。PyInstaller 打包時(shí)編譯器優(yōu)化級(jí)別通常是-O或-OOdocstring 和斷言會(huì)被剝離變量名保持原名但注釋全丟。你要的是邏輯還原不是重新打包能跑別追求一字不差。5. 從提取到反編譯的常見翻車現(xiàn)場六個(gè)典型坑與排除方法5.1 提取成功但反編譯報(bào) ParserError現(xiàn)象pyinstxtractor 正常跑完pyc 也出來了但 uncompyle6 一執(zhí)行就報(bào)ParserError或者輸出幾百行后中斷。原因九成情況是 pyc 的 Python 版本和反編譯器支持范圍不匹配。比如 pyc 是 3.10 編譯的uncompyle6 根本不認(rèn)識(shí)這種字節(jié)碼格式。解決先用xdis確認(rèn)版本再按對(duì)應(yīng)規(guī)則換工具或者補(bǔ)頭。我在一個(gè)樣本上遇到過 3.8.5 和 3.8.10 之間的字節(jié)碼差異導(dǎo)致 pycdc 還原失敗最后換decompyle3過的所以版本要精確到小版本。5.2 提取出的 pyc 只有空殼現(xiàn)象反編譯成功但源碼是pass、...或者return None函數(shù)體丟了。原因PyInstaller 打包時(shí)如果開了--strip或者源文件本身被代碼混淆器比如 PyArmor處理過反編譯器拿不到有效的棧幀信息。解決這類 pyc 用任何常規(guī)反編譯器都救不回來。如果確認(rèn)是 PyArmor 加密只有兩條路找運(yùn)行內(nèi)存中的明文或者在入口處 hook 反序列化點(diǎn)。如果是--strip導(dǎo)致?lián)Q回非 strip 打包的版本再試。5.3 輸出目錄里找不到入口 pyc現(xiàn)象目錄里全是pyi_rth_*.pyc找不到和 exe 同名甚至相關(guān)的文件。原因入口腳本可能被 PyInstaller 編進(jìn)了pyz里或者被改名成了__main__.pyc。解決檢查根目錄下的__main__.pyc和pyz_extracted/__main__.pyc入口腳本被 PyInstaller 編譯后默認(rèn)命名為__main__。用dis看它的 import 列表就能找到業(yè)務(wù)模塊的位置。5.4 反編譯結(jié)果語法錯(cuò)誤多到?jīng)]法看現(xiàn)象還原出來的代碼結(jié)構(gòu)混亂for變whilewith變 try/finally。原因pycdc 對(duì)控制流的還原能力有限尤其是continue/break嵌套和異常處理。解決這類樣本沒有銀彈。我的習(xí)慣是切 uncompyle6 或 decompyle3 再出一份結(jié)果然后手動(dòng)比對(duì)優(yōu)先采信后者的輸出。pycdc 的結(jié)果用于理解大框架不行就靠 xdis 看字節(jié)碼人工推邏輯。5.5 exe 內(nèi)嵌資源文件提取不全現(xiàn)象輸出目錄里能看到 pyc但 ui 文件、配置文件、圖片資源缺失。原因PyInstaller 的 CArchive 里包含非 Python 資源pyinstxtractor 默認(rèn)只解出 pyc 相關(guān)條目資源需要手動(dòng)從struct目錄或歸檔原始數(shù)據(jù)里撈。解決先用pyi-archive_viewerPyInstaller 自帶工具瀏覽歸檔內(nèi)容找到資源條目后用extract命令導(dǎo)出。這種方法適合打包時(shí)用了--add-data的場景。5.6 Python 版本跨平臺(tái)導(dǎo)致亂碼現(xiàn)象在 Windows 上用某個(gè)提取器跑出來的 pyc拿到 Linux 上反編譯中文注釋全變亂碼。原因不是編碼問題是 pyc 頭里的 magic 在不同平臺(tái)有不同值跨平臺(tái)后反編譯器誤判版本。解決提取后立刻看 pyc 頭部 magic不要跨機(jī)器搬運(yùn)后才驗(yàn)證。如果是跨平臺(tái)分析在目標(biāo)平臺(tái)重新跑 pyinstxtractor保證 magic 和平臺(tái)一致。6. 進(jìn)階玩法批量提取與自動(dòng)化反編譯流水線當(dāng)你需要分析一批同源樣本時(shí)挨個(gè)手動(dòng)跑命令效率太低。我把這套流程做成了一個(gè)批量腳本核心是對(duì)每個(gè) exe 執(zhí)行提取然后遍歷所有 pyc依次用 uncompyle6 和 pycdc 嘗試輸出成功率和失敗報(bào)告。import os import subprocess import sys from pathlib import Path def extract_and_decompile(exe_path, output_dir): exe_path Path(exe_path) extract_dir exe_path.parent / f{exe_path.stem}_extracted # 階段1提取 subprocess.run([sys.executable, pyinstxtractor.py, str(exe_path)], cwdexe_path.parent, checkTrue) # 階段2遍歷 pyc 并反編譯 report {} for pyc in extract_dir.rglob(*.pyc): src pyc.with_suffix(.py) # 首選 uncompyle6 ret subprocess.run([uncompyle6, -o, str(src), str(pyc)], capture_outputTrue) if ret.returncode ! 0: # 失敗切 pycdc ret subprocess.run([pycdc, str(pyc), -o, str(src)], capture_outputTrue) report[pyc.name] pycdc if ret.returncode 0 else failed else: report[pyc.name] uncompyle6 return report這個(gè)腳本的容錯(cuò)邏輯是uncompyle6 返回非零就切 pycdc兩者都失敗才標(biāo)記 failed。跑完后打印報(bào)告優(yōu)先看標(biāo)記failed的 pyc它們往往是混淆或新版本字節(jié)碼。腳本里的rglob(*.pyc)會(huì)遍歷嵌套目錄所以不用手動(dòng)處理遞歸提取。批量模式下還需要注意輸出目錄清理問題重復(fù)提取同一 exe 會(huì)覆蓋舊目錄建議每次運(yùn)行前刪掉上次的_extracted文件夾避免新舊文件混在一起影響判斷。最后一個(gè)習(xí)慣每次提取完我都會(huì)手動(dòng)打開入口 pyc 看一眼第一行 import確認(rèn)它依賴的模塊是否都在pyz_extracted里。這套流程跑多了之后某次遇到一個(gè)加殼樣本入口 pyc 的 import 指向一個(gè)不存在的模塊順藤摸瓜才發(fā)現(xiàn)它引用了外部 pyd而那個(gè) pyd 才是真正的加密核心光靠反編譯 pyc 根本還原不出來。從那以后我每次拆 PyInstaller 程序都強(qiáng)制走一遍“提取 → 確認(rèn)入口 → 核對(duì)依賴 → 再反編譯”的流程不跳過任何一步希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取