UPXアンパッカーによるパックドマルウェア解析 は何をしますか?
使用するタイミング
- 静的解析で高いエントロピーと最小限のインポートが確認され、バイナリがパックされていることを示す場合
- PEiD、Detect It Easy、PEStudioがUPXまたは他の既知のパッカーを特定した場合
- インポートテーブルにLoadLibraryとGetProcAddressのみが含まれている場合(パックドバイナリに典型的なランタイムインポート解決)
- GhidraやIDAで適切な逆アセンブルや逆コンパイルを行うために元のバイナリを復元する必要がある場合
- マルウェア作者がUPXマジックバイトやヘッダーを改変したため、自動UPX展開が失敗する場合
使用しない場合: カスタムパッカー、VMベースのプロテクター(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
- ネットワーク接続のない隔離された解析用VM
ワークフロー
ステップ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"改変されたUPXマジックをオフセット 0x{i:X} で発見: {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: デバッガによる手動展開
自動展開が完全に失敗した場合、動的展開を使用します:
Manual UPX Unpacking with x64dbg:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. パックされたサンプルをx64dbgにロード
2. エントリポイントまで実行(システムブレークポイント後F9)
3. UPX展開スタブのパターン:
a. PUSHAD(すべてのレジスタを保存)
b. 展開ループ(パックされたセクションを処理)
c. インポート解決(LoadLibrary/GetProcAddress呼び出し)
d. POPAD(レジスタを復元)
e. JMP to OEP(オリジナルエントリポイントへジャンプ)
4. PUSHAD後にESPにハードウェアブレークポイントを設定:
- PUSHAD後、ESPを右クリック -> ダンプで追跡
- [ESP]アドレスにアクセス時にハードウェアブレークポイントを設定
- 実行(F9)- OEPへのJMP前のPOPADで停止
5. OEPへのJMPに到達するまで前方にステップ(F7/F8)
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
主要概念
| 用語 | 定義 |
|---|---|
| パッキング | 実行可能コードを圧縮または暗号化してファイルサイズを減らし静的解析を妨げること。バイナリには実行時にコードを復元する展開スタブが含まれる |
| UPX | Ultimate Packer for eXecutables。マルウェア作者によく悪用されるオープンソースの実行ファイルパッカー。無料で効果的であるため |
| オリジナルエントリポイント (OEP) | パッキング前のマルウェアコードの実際の開始アドレス。展開スタブがコードを展開した後、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: 動的実行とブレークポイントベースのOEP検出による手動展開に使用されるオープンソースのWindowsデバッガ
- Scylla: メモリダンプ後のIAT再構築のためにx64dbgに統合されたインポート再構築ツール
- PE-bear: 展開後のPEヘッダーの検査と修復のためのPEファイルビューア兼エディタ
よくあるシナリオ
シナリオ: 改変されたUPXヘッダーを持つマルウェアの展開
コンテキスト: セクション名(UPX0、UPX1)からUPXパックと識別されたマルウェアサンプルだが、upx -d が「CantUnpackException: header corrupted」で失敗する。マルウェア作者がUPXマジックバイトを改変して自動展開を妨害している。
アプローチ:
- バイナリをhexエディタで開き、UPXヘッダー領域(通常はパックデータの末尾)を検索
- 改変されたマジックバイトを特定(例: 「UPX!」が「UPX\x00」に変更、または完全にゼロ化)
- Python修復スクリプトを使用して「UPX!」マジックを復元し、セクション名を修正
- 修復されたバイナリで
upx -dを再試行 - 修復が失敗した場合、x64dbgを使用した手動展開にフォールバック(PUSHAD -> ESPにHW BP -> POPAD -> JMP OEP)
- 展開されたバイナリが適切なインポートと妥当なエントロピー値を持つことを検証
- GhidraまたはIDAにインポートして完全な静的解析を実施
落とし穴:
- UPXが唯一のパッカーであると想定すること。バイナリは二重パック(UPX + カスタムレイヤー)されている可能性がある
- コピーではなく元のパックサンプルを改変してしまうこと
- 手動メモリダンプ後にインポートを再構築しないこと(IAT修正なしではダンプされたバイナリはクラッシュする)
- UPXパックされたPEセクションの後に追加されたオーバーレイデータをチェックし忘れること
出力形式
UNPACKING ANALYSIS REPORT
===========================
サンプル: 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に提出して検出率を改善