When to use / when not to use
- Use: PyInstaller single-file/directory exe, PyArmor-hardened samples, .pyc files, Nuitka/Cython compiled artifacts, Python malware packed samples
- Do not use: pure .py source obfuscation (base64/encoding wrappers) → go to [[re-script-deob]]; Python model weights (pkl/onnx) → [[re-ai-model]]
Tool preparation
Refer to [[re-analyze/platform-tips]]—unpacking/decompilation are static steps, no sandbox required; for dynamic extraction of plaintext ([[re-sandbox]]), enter the sandbox according to the highest principle.
python3 (basic runtime, required)
- Linux:
apt install python3/dnf install python3/pacman -S python - macOS:
brew install python(built into the system) - Windows: official installer or
choco install python - Verify:
python3 --version
pyinstxtractor (PyInstaller archive extraction)
- Multi-platform:
pip install pyinstxtractor-ngor GitHub scriptpython pyinstxtractor.py - Verify:
pyinstxtractor-ng <目标.exe>runs without errors (no standalone --help command)
PyArmor-Unpacker (PyArmor hardened unpacking)
- Multi-platform:
git clone https://github.com/Svenskithesource/PyArmor-Unpacker, choose one of the three methods according to the PyArmor version as per the README - Verify: the python scripts in the repository can run
pycdc / pycdas (pyc decompilation)
- Linux/macOS:
git clone https://github.com/zrax/pycdc && cd pycdc && cmake . && make - Windows: precompiled binary or compile under WSL
- Verify:
./pycdc --help
file (packer identification aid, generic)
- Built into each system (Linux binutils / macOS / Windows requires additional installation or skip)
- Verify:
file --version
Operation steps
Execute in order, archive the output of each step (path + sha256, see [[re-triage]]).
-
Identify the packer:
file sample.exe strings sample.exe | grep -iE 'PyInstaller|_MEIPASS|pyi-|PyArmor|pyarmor' | head- PyInstaller characteristics:
PyInstallerversion string,_MEItemporary directory name,pyi-prefix bootloader - PyArmor characteristics:
pyarmorruntime string - Nuitka/Cython: no pyc, pure compiled artifacts (
fileshows a normal executable, no Python runtime packaging characteristics) - Identification fails but Python relevance is confirmed → handle as suspicious PyInstaller
- PyInstaller characteristics:
-
PyInstaller unpacking:
- pip-installed users:
pyinstxtractor-ng sample.exe; GitHub script users:python pyinstxtractor.py sample.exe
python pyinstxtractor.py sample.exe # 输出 sample.exe_extracted/ 目录,含 PYZ-00.pyz(依赖归档)与主脚本 .pyc- Locate the main .pyc (name corresponds to the entry script); modules inside the PYZ are automatically extracted to
PYZ-00.pyz_extracted/; the module list can be viewed with archive_viewer.py
- pip-installed users:
-
PyArmor unpacking(when PyArmor is detected in step 1):
- Choose one of the three PyArmor-Unpacker methods according to the PyArmor version (judge the version from the README → corresponding method)
- The unpacked output is still pyc or source code, continue to the next step
-
pyc version identification:
python3 -c "import struct,sys; print(struct.unpack('<H', open('main.pyc','rb').read(2))[0])"- magic comparison (little-endian 2 bytes, common values; subject to the local MAGIC_NUMBER):
0d33=3.6、0d42=3.7、0d55=3.8、0d61=3.9、0d6f=3.10、0da7=3.11、0dcb=3.12- 3.13+: do not hardcode the value, convert according to the local MAGIC_NUMBER (
python3 -c "import importlib.util; print(hex(int.from_bytes(importlib.util.MAGIC_NUMBER[:2],'little')))")
- pyc header layout (unified 16 bytes in 3.7+): magic(4) + flags(4) + mtime(4) + size(4), the code object starts at offset 16; the low bit of flags = hash-based (PEP 552, mtime/size are meaningless); the pyc header flags/mtime/size extracted by PyInstaller are all 0 (zeroed out is normal, not corruption)
- The version determines the marshal format: starting from 3.14, marshal is upgraded to v5, and the internal layout of the code object differs from older versions—hand-written marshal parsing must be dispatched by magic version; tools such as pycdc lag in support for 3.13+, so prioritize using a matching version of python's
dis/marshalas a fallback - If the version matches the target, use the corresponding version of python or pycdc to decompile; if not, first install a matching version and try again
- magic comparison (little-endian 2 bytes, common values; subject to the local MAGIC_NUMBER):
-
Decompilation and cleanup:
pycdc main.pyc > main_decompiled.py # 或匹配版本 python 的 dis 模块- Clean up confusion code: delete fake functions/dead code (the unreferenced wrapper functions commonly seen in PyArmor), locate the core logic (encryption/network/exfiltration)
- Decompilation fails (version mismatch/obfuscation) → use
disfor bytecode-level analysis of key functions, or switch to [[re-binary-core]] for deep restoration
Cross-domain integration
- [[re-managed]] gateway: this skill's ownership (the "Python-packed sample" branch of the selection tree)
- [[re-script-deob]]: pure script obfuscation (no packing) scenarios
- [[re-malware]]: behavior verification and IOCs for malicious Python samples (switch to behavior analysis after unpacking)
- [[re-binary-core]]: deep pyc restoration / hybrid artifacts (embedded native modules [[re-format-elf]])
- Foundation [[re-triage]]: initial reconnaissance input for packer identification
Common pitfalls and traps
- pyc version mismatch causes direct decompilation failure: symptom—pycdc outputs garbled text or errors; cause—the magic version does not match the target; countermeasure—first perform the magic identification in step 4, and choose tools according to the version
- mtime/size of 0 in the pyc header is not corruption: symptom—the pyc extracted by PyInstaller reports errors/appears blank when opened with tools, leading to suspicion that unpacking failed; cause—PyInstaller zeroes mtime/size when writing the PYZ (the marshal body itself is unaffected); countermeasure—after identifying the version by magic, skip the 16-byte header and directly
marshal.loadsverify the code object, or hand it to pycdc - hash-based pyc(PEP 552): symptom—the flags field in the pyc header is non-zero (the low bit is 1), and mtime/size are a hash or placeholder; cause—the builder compiled using hash-checking mode; countermeasure—for identification, only look at magic and the low bit of flags; decompilation is unaffected (the code object is still a standard marshal stream)
- PyArmor version differences cause the unpacker to fail: symptom—PyArmor-Unpacker reports unsupported; cause—the PyArmor version is too new/too old; countermeasure—switch methods according to the version, or manually locate the runtime's encryption/decryption logic (switch to [[re-binary-core]])
- Cython/Nuitka have no pyc: symptom—no .pyc can be found; cause—compiled artifacts; countermeasure—analyze the artifacts directly ([[re-format-elf]] / [[re-ghidra]]), do not look for pyc
- Fake functions interfere with locating: symptom—after unpacking, the source code is full of unreferenced wrapper functions; cause—PyArmor's confusion code; countermeasure—first filter by call relationships (no caller means a candidate), then locate the core logic
- strings search for packer signatures is hidden by encryption: symptom—strings show no PyInstaller signatures but the behavior is Python; cause—string encryption/packer; countermeasure—judge by behavioral clues and file extensions, and if necessary observe from the dynamic side ([[re-sandbox]])
- The obfuscated sample as a whole is a three-piece set of "encrypted data + bootstrap shell + native decryption component": Symptom—statically, there is only one bootstrap call and one encrypted byte-string constant, no normal pyc can be found, and the function bytecode remains ciphertext in memory for a long time; Cause—the entire program is encrypted in the form of a marshal stream and embedded as a whole as a byte-string constant into the bootstrap call, the decryption key is derived from static data internal to the native extension delivered with the sample (the key material and recipe vary with the build), each function is decrypted in place only before execution and immediately re-encrypted in place after return, so the window during which plaintext exists is reduced to an extremely short time; the function constant table is also injected with native stub objects responsible for entry/exit/integrity checks, leaving only marker strings in the file, and the real objects can only be generated at runtime; Countermeasures—split the sample into the three-piece set and handle each separately; function-level plaintext must either be restored statically by reversing the encryption/decryption flow, or captured at runtime within the decryption window; note that the decrypted function body is still ordinary CPython bytecode, and there is no custom bytecode virtual machine—the fancy bytes that look like a VM are just encrypted standard bytecode
- Use "salt anchor + relative offset" to locate key material, do not hardcode absolute addresses: Symptom—when switching to a sample from another build version, all old key-location scripts fail; Cause—the key is derived by concatenating multiple pieces of static data and then taking a digest (typical concatenated pieces include salt, an embedded public key, a signed license description hash, and a piece of masked data that must first be restored), the masked region is XOR-restored using a fixed-length region and repeated-byte pad, while alignment padding in the file also appears as consecutive identical bytes, so once an all-zero candidate is mistaken for the pad, the wrong key is derived; the inputs participating in concatenation are also controlled by conditional branches (external key file, trial/official switch); Countermeasures—first search the raw bytes of the native extension for the visible salt anchor string, and calculate all other key material based on the anchor position using relative offsets, so the same set of code can be reused across multiple builds; exclude all-zero candidates when automatically finding the pad; determine which inputs are enabled according to flag bits; static restoration does not need to make startup-time integrity checks (such as public-key signature verification) pass, it is enough to reproduce the input concatenation method for key derivation from the branch logic
- The decrypted product is a standard marshal stream, but reconstructing CodeType has format quirks: Symptom—when parsing the decrypted byte stream using the conventional .pyc process, fields are misaligned and constants are garbled, and on some Python versions it crashes directly with an uncatchable exception; Cause—during serialization, the declared length of the constant tuple may be smaller than the actual number written (extra entries follow directly without a header), and private type byte ranges may also appear in the stream, representing native objects that can only be generated by the runtime in memory (the body cannot be read statically, and failing to remove placeholders will cause subsequent constant index misalignment), and putting ciphertext bytecode directly into disassembly or CodeType construction will crash the interpreter on some versions; Countermeasures—compile with one-off template code and then use .replace() to construct CodeType instead of calling the constructor directly (the constructor parameter order changes with versions), pass the local variable count explicitly; represent native objects with placeholders and remove them before construction; when parsing a single record fails, keep the fields already read (bytecode and constants come earlier and have usually already been obtained), and degrade overall to reporting each code object that was fully parsed rather than failing completely; on decryption failure, substitute template placeholder bytecode, and display ciphertext only in hexadecimal; since 3.11 the code object layout has changed, read the target version from the blob header and automatically switch to the matching version interpreter, rather than writing a separate set of parsing rules for each version
- To obtain plaintext dynamically, you must hit the window "after entering the stub and decrypting", and the import method also matters: Symptom — after hooking at runtime, no plaintext can be captured, or the callback crashes internally; Cause — every call to a protected function goes through three steps: enter stub decryption, execution, and exit stub re-encryption. The plaintext window is extremely short (short functions may leave immediately), so reading afterward fails to obtain it; the enter stub is externally a Python callable object (in the form of a built-in function object), which can be identified from the module constant table. The real native function pointer is hidden in the metadata structure of the C function object and can be read out via memory layout offsets; during the first import, triggering the same hook again inside the callback will nest and break the callback chain; running in the main manner will take another runtime path and bypass the discovery mechanism; Countermeasure — intercept the call executed after the module's first decryption, and read the enter stub pointer from the constants of the passed-in code object; on the first import, return a null pointer to abort, restart a round, and wait for the inline hook to be in place; for subsequent execution, import using the named module manner; locate the import slot by scanning the data segment for the resolved real function address pointer, rather than scanning the call site (the call site references the slot address, so direct scanning is prone to error)
- The obfuscator's internal format is not a documented compatible interface; conclusions must be annotated with the version: Symptom — the same set of restoration scripts fails when the version changes, and it is impossible to say which step broke; Cause — key derivation inputs, embedded marker positions, blob headers, function-level descriptors, native entry points, and marshal reconstruction behavior may all change with the version, and the tool is also tightly bound to the target interpreter version (the code object/marshal layout changes with the version); Countermeasure — annotate any conclusion with the exact version, and do not assume that tools can be reused across versions; re-verify each item one by one for every new version (key recipe, marker positions, header structure, descriptors, entry function, reconstruction behavior), and explicitly mark branches that have not been verified with real samples as untested; cross-validate static reconstruction and dynamic capture (the real decryption output at runtime) field by field against each other — dynamic can expose format errors in static inference (such as mistaking a native object for an ordinary constant, or a deviation in the declared length of a tuple), while static fills in dead paths that dynamic cannot reach (uncalled functions, unreached branches); handle morphological differences such as trial/formal and with/without an external key file according to flag bits and branch logic, and do not assume a single form