分析使用 UPX 加殼的惡意軟體
使用時機
- 靜態分析顯示高熵區段且匯入函式極少,表示二進位檔已加殼
- PEiD、Detect It Easy 或 PEStudio 識別出 UPX 或其他已知加殼器
- 匯入表中僅包含 LoadLibrary 和 GetProcAddress(加殼二進位檔典型的執行時期匯入解析)
- 需要還原原始二進位檔,以便在 Ghidra 或 IDA 中進行正確的反組譯與反編譯
- 自動 UPX 解壓縮失敗,因為惡意軟體作者修改了 UPX 魔術位元組或標頭
請勿使用於自訂加殼器、基於虛擬機的保護程式(Themida、VMProtect),或透過除錯進行動態脫殼更合適的樣本。
前置作業
- 已安裝 UPX(Ultimate Packer for eXecutables)(
apt install upx-ucl或從 https://upx.github.io/ 下載) - 用於識別加殼器的 Detect It Easy (DIE)
- Python 3.8+ 及
pefile函式庫,用於手動修復標頭 - 用於自動化工具失敗時手動脫殼的 x64dbg 或 x32dbg
- 用於檢查與修復 PE 標頭的 PE-bear 或 CFF Explorer
- 隔離的分析虛擬機,不具網路連線能力
工作流程
步驟 1:識別加殼器
判斷樣本是否已加殼並識別加殼器:
# 使用 Detect It Easy 檢查
diec suspect.exe
# 使用 UPX 檢查(僅測試不解壓)
upx -t suspect.exe
# 基於 Python 的熵值與加殼器檢測
python3 << 'PYEOF'
import pefile
import math
pe = pefile.PE("suspect.exe")
print("區段分析:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
raw = section.SizeOfRawData
virtual = section.Misc_VirtualSize
print(f" {name:8s} 熵值:{entropy:.2f} 原始:{raw:>8} 虛擬:{virtual:>8}")
# 檢查 UPX 區段名稱
section_names = [s.Name.decode().rstrip('\x00') for s in pe.sections]
if 'UPX0' in section_names or 'UPX1' in section_names:
print("\n[!] 偵測到 UPX 區段名稱")
elif '.upx' in [s.lower() for s in section_names]:
print("\n[!] 偵測到 UPX 變體區段名稱")
# 檢查匯入數量(加殼二進位檔通常非常少)
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
total_imports = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f"\n匯入總數:{total_imports}")
if total_imports < 10:
print("[!] 匯入極少 - 可能已加殼")
else:
print("\n[!] 無匯入目錄 - 高度加殼")
PYEOF
步驟 2:嘗試標準 UPX 解壓縮
嘗試內建的 UPX 解壓縮功能:
# 標準 UPX 解壓縮
upx -d suspect.exe -o unpacked.exe
# 如果 UPX 失敗並顯示 "not packed by UPX" 錯誤,表示標頭可能已被修改
# 輸出詳細資訊以進行除錯
upx -d suspect.exe -o unpacked.exe -v 2>&1
# 驗證解壓縮後的檔案
file unpacked.exe
diec unpacked.exe
步驟 3:修復修改過的 UPX 標頭
如果標準解壓縮失敗,修復被竄改的魔術位元組:
# 修復修改過的 UPX 標頭
import struct
with open("suspect.exe", "rb") as f:
data = bytearray(f.read())
# UPX 魔術位元組:"UPX!" (0x55505821)
# 惡意軟體作者常修改這些位元組以防止自動解壓縮
# 搜尋修改過的 UPX 簽名
upx_magic = b"UPX!"
modified_patterns = [b"UPX0", b"UPX\x00", b"\x00PX!", b"UPx!"]
# 尋找並還原區段名稱
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
num_sections = struct.unpack_from("<H", data, pe_offset + 6)[0]
section_table_offset = pe_offset + 0x18 + struct.unpack_from("<H", data, pe_offset + 0x14)[0]
print(f"PE 偏移量:0x{pe_offset:X}")
print(f"區段數量:{num_sections}")
print(f"區段表偏移量:0x{section_table_offset:X}")
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8]
print(f"區段 {i}:{name}")
# 還原二進位檔中的 UPX 魔術位元組
# 搜尋 UPX 標頭簽名位置(通常位於已壓縮資料的尾部)
for i in range(len(data) - 4):
if data[i:i+3] == b"UPX" and data[i+3] != ord("!"):
print(f"在偏移量 0x{i:X} 處找到修改過的 UPX 魔術位元組:{data[i:i+4]}")
data[i:i+4] = b"UPX!"
print(f"已還原為:UPX!")
# 如果區段名稱也被修改,一併還原
for i in range(num_sections):
offset = section_table_offset + (i * 40)
name = data[offset:offset+8].rstrip(b'\x00')
if name in [b"UPX0", b"UPX1", b"UPX2"]:
continue # 已正確
# 檢查常見的修改
if name.startswith(b"UP") or name.startswith(b"ux"):
original = f"UPX{i}".encode().ljust(8, b'\x00')
data[offset:offset+8] = original
print(f"已在偏移量 0x{offset:X} 處將區段名稱還原為 {original}")
with open("suspect_fixed.exe", "wb") as f:
f.write(data)
print("\n已寫入修復後的檔案。重新嘗試:upx -d suspect_fixed.exe -o unpacked.exe")
步驟 4:使用除錯器手動脫殼
當自動脫殼完全失敗時,使用動態脫殼:
使用 x64dbg 手動 UPX 脫殼:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 在 x64dbg 中載入加殼樣本
2. 執行到進入點(系統中斷點後按 F9)
3. UPX 脫殼 Stub 模式:
a. PUSHAD(儲存所有暫存器)
b. 解壓縮迴圈(處理已壓縮的區段)
c. 解析匯入(LoadLibrary/GetProcAddress 呼叫)
d. POPAD(還原暫存器)
e. JMP 到 OEP(原始進入點)
4. 在 PUSHAD 之後設定 ESP 的硬體中斷點:
- PUSHAD 之後,在暫存器中對 ESP 按右鍵 -> 在傾印中跟隨
- 在 [ESP] 位址上設定存取型硬體中斷點
- 執行 (F9) - 會在跳到 OEP 之前於 POPAD 處中斷
5. 向前逐步執行 (F7/F8) 直到抵達跳至 OEP 的 JMP 指令
6. 在 OEP 處:使用 Scylla 外掛傾印並修復匯入:
- 外掛 -> Scylla -> OEP = 目前 EIP
- 點擊 "IAT Autosearch" -> "Get Imports"
- 點擊 "Dump" 儲存脫殼後的二進位檔
- 點擊 "Fix Dump" 修復匯入表
步驟 5:驗證脫殼後的二進位檔
驗證脫殼後的樣本是否有效且完整:
# 驗證脫殼後的 PE 是否有效
python3 << 'PYEOF'
import pefile
pe = pefile.PE("unpacked.exe")
# 檢查區段是否正常
print("脫殼後區段分析:")
for section in pe.sections:
name = section.Name.decode().rstrip('\x00')
entropy = section.get_entropy()
print(f" {name:8s} 熵值:{entropy:.2f}")
# 驗證匯入是否已解析
print(f"\n匯入數量:")
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
dll = entry.dll.decode()
count = len(entry.imports)
print(f" {dll}: {count} 個函式")
total = sum(len(e.imports) for e in pe.DIRECTORY_ENTRY_IMPORT)
print(f" 總計:{total} 個匯入")
# 比較檔案大小
import os
packed_size = os.path.getsize("suspect.exe")
unpacked_size = os.path.getsize("unpacked.exe")
print(f"\n加殼前: {packed_size:>10} 位元組")
print(f"脫殼後: {unpacked_size:>10} 位元組")
print(f"比例: {unpacked_size/packed_size:.1f}x")
PYEOF
關鍵概念
| 術語 | 定義 |
|---|---|
| 加殼 | 壓縮或加密可執行程式碼以減少檔案大小並阻礙靜態分析;二進位檔包含一個在執行時還原程式碼的脫殼 Stub |
| UPX | Ultimate Packer for eXecutables;開放原始碼的可執行檔加殼器,因其免費且有效而常被惡意軟體作者濫用 |
| 原始進入點 (OEP) | 加殼前惡意軟體程式碼的真正起始位址;脫殼 Stub 解壓縮程式碼後會跳轉到 OEP |
| 匯入重建 | 在使用 Scylla 或 ImpRec 等工具將脫殼後的行程從記憶體傾印出來後,重建匯入位址表的過程 |
| PUSHAD/POPAD | x86 指令,用於儲存/還原所有通用暫存器;UPX 使用此模式在脫殼期間保留暫存器狀態 |
| 區段熵值 | PE 區段資料的隨機性度量;加殼區段的熵值 > 7.0,而一般程式碼區段平均為 5.0-6.5 |
| 魔術位元組 | 檔案中用於識別其格式的簽名位元組;UPX 使用 "UPX!",惡意軟體作者會修改此值以防止自動解壓縮 |
工具與系統
- UPX:開放原始碼的可執行檔加殼器,對於正確加殼的檔案具有內建的解壓縮功能
- Detect It Easy (DIE):加殼器、編譯器和連結器偵測工具,可識別 PE、ELF 和 Mach-O 檔案上的保護
- x64dbg/x32dbg:開放原始碼的 Windows 除錯器,用於透過動態執行和基於中斷點的 OEP 尋找來進行手動脫殼
- Scylla:與 x64dbg 整合的匯入重建工具,用於在記憶體傾印後重建 IAT
- PE-bear:PE 檔案檢視器與編輯器,用於在脫殼後檢查和修復 PE 標頭
常見情境
情境:脫殼具有修改過 UPX 標頭的惡意軟體
背景:一個惡意軟體樣本透過區段名稱 (UPX0, UPX1) 被識別為 UPX 加殼,但 upx -d 失敗並顯示 "CantUnpackException: header corrupted"。惡意軟體作者修改了 UPX 魔術位元組以防止自動解壓縮。
方法:
- 在十六進位編輯器中開啟二進位檔,搜尋 UPX 標頭區域(通常位於已壓縮資料的尾部)
- 識別被修改的魔術位元組(例如 "UPX!" 被改為 "UPX\x00" 或完全歸零)
- 使用 Python 修復腳本恢復 "UPX!" 魔術位元組並修正區段名稱
- 在修復後的二進位檔上重新嘗試
upx -d - 如果修復失敗,改用手動脫殼搭配 x64dbg(PUSHAD -> 在 ESP 設硬體中斷點 -> POPAD -> JMP OEP)
- 驗證脫殼後的二進位檔是否具有正確的匯入和合理的熵值
- 匯入 Ghidra 或 IDA 進行完整的靜態分析
注意陷阱:
- 假設 UPX 是唯一的加殼器;二進位檔可能經過雙重加殼(UPX + 自訂層)
- 修改原始的加殼樣本,而不是在副本上操作
- 在人工記憶體傾印後未重建匯入(沒有 IAT 修正的傾印二進位檔會崩潰)
- 忘記檢查附加在 UPX 加殼 PE 區段之後的重疊資料
輸出格式
脫殼分析報告
===========================
樣本: suspect.exe
SHA-256: e3b0c44298fc1c149afbf4c8996fb924...
加殼器: UPX 3.96(標頭已修改)
加殼二進位檔
區段: UPX0 (熵值:0.00) UPX1 (熵值:7.89) .rsrc (熵值:3.45)
匯入: 2 (kernel32.dll:LoadLibraryA, GetProcAddress)
檔案大小: 98,304 位元組
脫殼方法
方法: 標頭修復 + UPX -d
標頭修正: 在偏移量 0x1F000 處還原 UPX! 魔術位元組
指令: upx -d suspect_fixed.exe -o unpacked.exe
結果: 成功
脫殼二進位檔
區段: .text (熵值:6.21) .rdata (熵值:4.56) .data (熵值:3.12) .rsrc (熵值:3.45)
匯入: 147 (kernel32, user32, advapi32, wininet, ws2_32)
檔案大小: 245,760 位元組(2.5 倍膨脹)
OEP: 0x00401000
驗證
PE 有效: 是
匯入已解析: 是(跨 8 個 DLL,共 147 個函式)
可執行: 是(在沙箱中執行不會崩潰)
後續步驟
- 將 unpacked.exe 匯入 Ghidra 進行完整反組譯
- 對脫殼後的二進位檔執行 YARA 規則
- 將脫殼後的二進位檔提交至 VirusTotal 以獲得更好的偵測率