Reversing
Step 222. Reversing .NET and Python Binaries — Binaries That Aren’t Machine Code
Level 3 — Advanced Reversing | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: Step 221 (Keygen), Step 178 (Intro to Reversing). You must be able to read and write simple Python scripts.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime. Decompiling someone else’s program to extract its logic can be a license violation — today’s only target is scripts you write yourself.
- What you need: Python 3 on Windows (measured: 3.12.14) or python3 on WSL (measured: 3.12.3). dnSpy and PyInstaller-family tools are not installed and are shown as screen examples.
- Caution: "binaries that aren’t machine code" are dangerous precisely because they read too well — it means you can easily peek at someone else’s code, so this is an area where legal boundaries must be kept even more strictly.
The reversing so far has been a fight with machine code (x86-64 assembly). But not every executable in the world is machine code. .NET programs carry IL bytecode, and Python bundled with PyInstaller carries Python bytecode — and these restore to something close to source code when decompiled. Today’s first skill isn’t analysis but identification: you must first know "which world is this binary from?" before you can pull out the right tool.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain and identify the differences between native / .NET / Python binaries
- Disassemble a function into bytecode with Python’s
dismodule and read its constants - Measure hands-on that constants are exposed via
stringsin a.pycfile - Explain the internal structure of a PyInstaller exe (bootloader + archive)
- Know what dnSpy shows from a .NET binary (screen example)
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3.12 (measured: Windows 3.12.14) + WSL strings |
| Today’s commands | python -c "import dis; ...", python -m py_compile, strings |
| Concepts needed | Bytecode, IL (intermediate language), decompilation, .pyc structure, PyInstaller archives |
| Today’s deliverables | Bytecode disassembly results + a "why it reads so well" notes |
2-1. Three Worlds — Machine Code, IL, and Python Bytecode
Compiled programs split into three branches.
- Native (C/C++): machine code the CPU executes directly. Decompiling yields "pseudocode" — the restoration rate is low. The world of Steps 178~221.
- .NET (C#, etc.): stored as IL (Intermediate Language) bytecode, translated to machine code by the CLR at run time. Metadata like class and method names survives wholesale in the file, so dnSpy restores nearly perfect C#.
- Python (PyInstaller, etc.): packages Python bytecode (.pyc) inside an exe. Once extracted, it can be disassembled (dis) or decompiled (pycdc family).
The field’s first question isn’t "how do I read it" but "which world is it from." Get the identification wrong and every tool goes wrong — try to read .NET with machine-code tools (objdump) and you’ll only get an IL lump.
2-2. Python Bytecode and dis
Python doesn’t run source directly; it first compiles to bytecode, which a virtual machine interprets. The standard library’s dis module disassembles this bytecode into human-readable form — the lightest reversing tool there is, requiring nothing but Python itself.
In a bytecode listing, the reversing clue is LOAD_CONST — the constants (strings, numbers) a function references ride there verbatim. If a password hash, API key, or URL is a constant, it shows here.
2-3. The Structure of .pyc and PyInstaller
Python caches bytecode as .pyc files on import (the __pycache__ folder). The 16-byte header holds a magic number (a Python version identifier), with bytecode after it.
PyInstaller is a tool that bundles a Python program into a single exe. Its structure:
PyInstaller exe
├─ bootloader (the C program that actually runs)
└─ archive (a compressed collection of .pyc files + the Python interpreter DLL)
└─ at runtime the bootloader unpacks the archive and executes the bytecode
Sound familiar? It’s the same picture as Step 220’s packer structure (stub + compressed payload). So the analysis procedure resembles it too — extract the .pyc files from the archive with pyinstxtractor (unpacking), and restore source with a decompiler like pycdc.
2-4. Why It Reads So Well — Metadata and Symbols
A machine-code binary is translated with variable and function names gone. IL and Python bytecode, by contrast, keep names and structure in the file because the runtime (CLR, Python interpreter) needs the names. Decompilation works well not because the tools are smart, but because lots of information was never erased.
The defending side knows this too. That’s why .NET has ConfuserEx-family obfuscation tools — renaming everything meaningless and tangling the control flow. Handling obfuscation is Step 224’s topic; today’s goal is to feel in your bones the fact itself that "unobfuscated managed code reads at source level."
3. Follow Along
3-1. The Lab — Building the Analysis-Target Script
Become the author and build a "secret vault" script. Windows or WSL — anywhere with Python 3 works.
Input (vault222.py)
import hashlib
def check(pw: str) -> bool:
digest = hashlib.sha256(pw.encode()).hexdigest()
return digest == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
def main():
pw = input("password: ")
if check(pw):
print("correct! 문이 열립니다") # "correct! the door opens"
else:
print("wrong.")
if __name__ == "__main__":
main()
First, confirm normal behavior:
echo hello | python vault222.py
password: correct! 문이 열립니다
(Measured 2026-09-09, Windows Python 3.12.14. The message reads "correct! the door opens." For the record, that hash is sha256("hello") — a secret only we, who saw the source, know.)
3-2. Dissecting with dis — Constants in Plain Sight
Now forget the source and read "what this .py does" from bytecode alone. Assume the real-world situation where only the .pyc exists:
python -c "import dis, vault222; dis.dis(vault222.check)"
3 0 RESUME 0
4 2 LOAD_GLOBAL 1 (NULL + hashlib)
12 LOAD_ATTR 2 (sha256)
32 LOAD_FAST 0 (pw)
34 LOAD_ATTR 5 (NULL|self + encode)
54 CALL 0
62 CALL 1
70 LOAD_ATTR 7 (NULL|self + hexdigest)
90 CALL 0
98 STORE_FAST 1 (digest)
5 100 LOAD_FAST 1 (digest)
102 LOAD_CONST 1 ('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824')
104 COMPARE_OP 40 (==)
108 RETURN_VALUE
(Measured 2026-09-09, Python 3.12.)
How to read the output: the numbers on the left (3, 4, 5) are the original source’s line numbers — source position info survives in the bytecode too. Reading the instruction flow: load hashlib and pull out the sha256 attribute (line 4), call pw.encode(), call hexdigest(), store into digest. And line 5 — a 64-character hex string rides in plaintext on LOAD_CONST, compared with digest via ==.
The analyst’s conclusion: "a check function comparing against a sha256 hash. The comparison hash is baked in as a constant." Without source, the whole algorithm was read from bytecode alone. That the hash’s preimage is "hello" comes out with one dictionary attack (matching a list of hashes of common words) — sha256 is a fast hash, so it’s weak for password storage (remember Step 105’s hash lecture).
3-3. Measuring .pyc — The Cache File Shows Everything Too
Directly inspect the cache file made during the import process:
python -m py_compile vault222.py
ls __pycache__
vault222.cpython-312.pyc
(Measured 2026-09-09. The cpython-312 in the filename means "compiled by CPython 3.12.")
Point strings at this file (WSL):
strings vault222.cpython-312.pyc | grep -iE "2cf24|password|correct|sha256"
@2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824)
sha256
password: u
correct!
(Measured 2026-09-09.)
How to read it: a .pyc is binary, but the constant strings ride in it with no compression, no encryption. The hash, the names the function uses (sha256), even the prompt — all verbatim. Let’s check the header’s magic number too:
python -c "data = open('__pycache__/vault222.cpython-312.pyc','rb').read(16); print(' '.join(f'{b:02x}' for b in data))"
cb 0d 0d 0a 00 00 00 00 ae 29 a1 6a 6c 01 00 00
(Measured 2026-09-09. The first 4 bytes cb 0d 0d 0a are Python 3.12’s magic number. It differs per version, so when you receive an unfamiliar .pyc, you first identify which version produced it by its magic.)
3-4. If You Meet a PyInstaller exe — Procedure Example
Since this environment has neither PyInstaller nor extraction tools, the procedure is shown as an example. In the field, when you meet "an exe suspected to be Python":
# Output example — PyInstaller analysis procedure
1. Identify: search strings for traces like "PYINSTALLER", "_MEI..."
2. Extract: python pyinstxtractor.py sample.exe
→ .pyc files unpacked into a sample.exe_extracted/ folder
→ the main script usually has the original program's name
3. Check version: identify the Python version from the .pyc header's magic number (3-3's method)
4. Decompile: pycdc main.pyc (or decompyle3/uncompyle6 if the version matches)
5. Last resort when decompilation breaks: read the bytecode with dis (3-2's method)
How to read it: as seen in 2-3, the procedure resembles unpacking — peel the wrapping (extract), read the bytecode inside (decompile). The trap is step 5 — the pycdc family is imperfect on recent Python bytecode, and when decompilation breaks, dis as in 3-2 becomes the last line of defense. Bytecode is slow to read but always reads.
3-5. .NET Binaries and dnSpy — Screen Example
The .NET world’s standard tool is dnSpy (a Windows tool, so it’s absent here). The procedure:
# Screen example — dnSpy usage flow
1. Drag the target exe into dnSpy
2. The left tree unfolds assembly > namespace > classes "with their names intact"
3. Click the Main method and near-original C# is displayed:
private static void Main(string[] args)
{
Console.Write("serial: ");
string s = Console.ReadLine();
if (License.Check(s)) { Console.WriteLine("activated!"); }
...
}
4. Click License.Check and you jump straight to its definition — the verification logic reads
How to read it: it’s the 2-4 principle as-is — class and method names live on in the file, so the decompilation result isn’t "pseudocode" but effectively the original. Work we did in Step 221 by tearing apart a disassembly and back-calculating finishes in a few clicks on unobfuscated .NET. dnSpy even lets you edit and re-save (Edit Method).
3-6. Identification Practice — The Tool-Selection Table
Today’s real deliverable is putting this table in your head:
| Binary’s identity | First clue | Tool | Restoration level |
|---|---|---|---|
| Native (C/C++) | file says ELF/PE, strings shows syscalls | objdump, gdb, Ghidra | Pseudocode (low) |
| .NET | strings shows mscorlib, IL inside the PE | dnSpy / ILSpy | Near-original (high) |
| Python (PyInstaller) | strings shows PYINSTALLER traces | pyinstxtractor + pycdc/dis | Near-source (high) |
| Java | .jar = zip, .class files | JD-GUI / jadx | Near-original (high) |
4. Missions & Exercises
Mission — "Solve It with Bytecode Alone"
- Make vault222b.py by replacing vault222.py’s hash with the sha256 of a different word (pick the word yourself; compute the hash with
python -c "import hashlib; print(hashlib.sha256(b'word').hexdigest())") - Now forget the source — disassemble vault222b’s
checkfunction withdisand read the hash out of LOAD_CONST - Find the hash’s preimage word and pass the program (method: compute sha256 of candidate words and compare — a mini dictionary attack)
- Record: "at which LOAD_CONST I read the hash, and how I found the preimage"
Exercises
Exercise 1. Explain why .NET and Python binaries "read better" than native, from the perspective of information remaining in the file.
Exercise 2. Give one way a PyInstaller exe’s structure resembles Step 220’s packer structure, and one way it differs.
Exercise 3. In 3-2’s bytecode, what are the numbers on the left (3, 4, 5), and why do they help analysis?
Exercise 4. Name two alternatives to try when pycdc fails to decompile a .pyc.
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
[Build] compute the hash with the word "orange":
python -c "import hashlib; print(hashlib.sha256(b'orange').hexdigest())"
→ 9b1d9f4e9f9c3d68cf3c70b06e5a2b3b2fbe8fd6d95b4b8f2d95b0b90e04a1c9 (example — use your actual computed value)
[Disassemble] python -c "import dis, vault222b; dis.dis(vault222b.check)"
→ new hash found on the LOAD_CONST line (just before COMPARE_OP at line 5)
[Find preimage] run a candidate list:
for w in ["apple","banana","orange","grape"]:
if hashlib.sha256(w.encode()).hexdigest() == target: print(w)
→ orange found → echo orange | python vault222b.py → correct!
How to verify: ① did the LOAD_CONST value in the dis output match the mini dictionary attack’s target, ② did the found word actually print correct!? Reopening the source to check is only for final grading — during the solving process, forgetting it is the rule.
Exercise Answers
Answer 1. Because the runtime (CLR, Python interpreter) must look up classes, methods, and attributes by name at run time, those names (metadata) remain unerased in the file. Compilation to machine code replaces names with addresses, but compilation to bytecode/IL preserves names. Decompilers read this preserved names-and-structure to restore code close to the original.
Answer 2. Resemblance: both are a "small execution code + bundled payload" structure — like a packer’s stub, PyInstaller’s bootloader runs first and unpacks the contents to execute them. Difference: purpose. A packer wraps to hide or shrink (so the original doesn’t show in strings); PyInstaller bundles for distribution, so extracting yields the original bytecode as-is. There’s no intent to conceal, so there are no defenses.
Answer 3. They’re the original source’s line numbers — because debugging position info is stored alongside the bytecode. Thanks to that, an analyst knows "which source line this bytecode clump corresponds to" and uses it as a foothold for restoring the function’s structure (which line was one statement).
Answer 4. First, try a different decompiler — decompilers like decompyle3 and uncompyle6 support different Python versions, so check the version via the .pyc magic number and pick a matching tool. Second, give up decompilation and read the bytecode directly with dis (3-2). Decompilation may fail, but disassembly is always possible — slower, but never blocked.
Completion Criteria Checklist
- [ ] I can state the native/.NET/Python binary difference in one sentence each
- [ ] I disassembled a function with
disand read a constant from LOAD_CONST - [ ] I measured constants being exposed via strings in a .pyc
- [ ] I know the magic number’s position and meaning (version identification) in a .pyc header
- [ ] I can explain a PyInstaller exe’s structure (bootloader + archive)
- [ ] I know dnSpy’s screen layout (tree, decompile view) and that Edit Method exists
- [ ] Mission: I read the hash from bytecode alone, found its preimage, and passed
6. Common Pitfalls & Fixes
Wall 1. The dis output differs from the book
Symptom: instruction names differ — e.g., the book says CALL but older material shows CALL_FUNCTION.
Cause: Python bytecode changes per version. A big overhaul happened in 3.11 (the specializing interpreter), and 3.12’s instruction set differs (today’s measurements are 3.12-based).
Fix: don’t memorize instruction names — read roles: LOAD_… (load a value), CALL (a call), COMPARE_OP (a comparison), STORE_… (a store). Roles stay the same across versions.
Wall 2. pycdc’s decompilation comes out broken
Symptom: it cuts off midway or mixes in strange pseudocode.
Cause: the pycdc family doesn’t fully support recent Python (3.11+) bytecode. It’s the tool’s limit, not your mistake.
Fix: check the version via the .pyc magic (3-3) and pick a decompiler supporting that version. If that still fails, 3-2’s direct dis reading is the last line of defense.
Wall 3. I tore into an exe with objdump and only got incomprehensible code
Symptom: no main, just a strange data lump and runtime code.
Cause: the target isn’t native but a PyInstaller exe — what you’re seeing is only the bootloader, and the real logic is in .pyc files in the inner archive. You skipped identification and picked the wrong tool.
Fix: go back to 3-6’s table. Start with strings ./sample.exe | grep -i pyinstaller. Thirty seconds of identification saves hours of flailing.
Wall 4. A "magic mismatch" error from the decompiler
Symptom: an Unsupported magic-type error.
Cause: a .pyc is tied to the Python version that made it. Tools/interpreters of other versions refuse it.
Fix: read the first 4 header bytes (3-3 measurement: 3.12 is cb 0d 0d 0a) to identify the version, and prepare the same version’s Python or a decompiler supporting that version.
Wall 5. Lulled by "it reads well," you nearly cross a legal line
Symptom: you get curious about your company’s software or a purchased program’s logic — because dnSpy shows everything.
Cause: technical ease and legal permissibility are separate. Decompiling software whose license prohibits reverse engineering can be illegal.
Fix: build the habit of first checking whether the target is "something I made" or "something that permits analysis (practice crackmes, CTF problems, legitimate job duties like malware analysis)." Just because it opens easily doesn’t mean it may be opened.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Bytecode | The intermediate form Python source compiles to — interpreted by a virtual machine |
| dis | Python’s standard module — disassembles bytecode into human-readable form |
| IL (intermediate language) | .NET’s bytecode — metadata survives, so it decompiles almost perfectly |
| dnSpy | A .NET decompiler — shows C# with names intact, and editing works too |
| .pyc | Python bytecode cache — version magic in the header, constants in plaintext |
| PyInstaller | A tool bundling Python into an exe — bootloader + .pyc archive structure |
| Obfuscation | Techniques that block easy reading — name destruction, control-flow tangling |
Today’s Commands
| Command | What it does |
|---|---|
python -c "import dis, module; dis.dis(module.func)" |
Disassemble a function to bytecode — read constants (LOAD_CONST) |
python -m py_compile file.py |
Generate a .pyc (making an analysis target) |
strings file.pyc | grep pattern |
Catch plaintext constants from the bytecode cache |
| Read a .pyc’s 16-byte header | First 4 bytes = magic number → identify the Python version |
strings sample.exe | grep -i pyinstaller |
Identify a PyInstaller exe |
| pyinstxtractor → pycdc | Extract .pyc from an exe → decompile (procedure example) |
An Instinct More Important Than Commands
Today’s point isn’t a tool list but an order. Identification first, analysis second. When you receive a binary, first check which world it’s from with file and strings, then pull out that world’s tools. And when the world differs, the difficulty differs dramatically — an analysis that takes days in machine code finishes in minutes in managed code.
Lastly, remember that this "readability" is a double-edged sword. Programs you make read just as well to someone else. Baking a password hash in as a constant, writing an API key into source — things you read out in 5 minutes today. Look at code again with reversing-trained eyes, and the code you defend changes too.
Once every box is checked, Step 222 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.