Skip to content
All posts
revrevised July 17, 20266 min read

Reversing managed code: .NET, Java, and Python bytecode

When the binary is not machine code but bytecode with names attached, the job stops being disassembly and becomes reading. Decompiling .NET and Java back to source, disassembling .pyc, and what obfuscators actually take away.

Not every reversing challenge is a stripped ELF. A large share ship bytecode for a managed runtime - .NET IL, JVM class files, Python .pyc - and those are a categorically easier problem, because bytecode carries type information and, unless someone removed them, the original names.

The practical consequence: do not open a managed binary in a disassembler. Decompile it and read the source. The skill is knowing which runtime you have, which tool reads it, and what to do when an obfuscator has been over it first. Reading a binary covers the native case; this is the other half.

Identifying the runtime

SignatureRuntimeOpen it with
PE file, but Mono/.NET or #Strings in strings.NET assemblyILSpy, dnSpyEx, dotPeek
PK\x03\x04 and a META-INF/ entryJAR - a zip of class filesJD-GUI, CFR, Procyon, Fernflower
\xca\xfe\xba\xbeA single JVM class fileThe same, or javap -c
\x03\xf3\x0d\x0a or similar 4-byte magic, then a timestampPython .pycdecompyle3, uncompyle6, pycdc
A large ELF/PE with PyInstaller or _MEIPASS in stringsA frozen Python apppyinstxtractor, then the .pyc path
dex\n035\x00Android DEXSee taking an Android app apart
\x00asmWebAssemblySee reversing WebAssembly
file gets most of these. The .NET one is the trap: it is a valid PE and file will tell you it is a Windows executable, which sends people to IDA for no reason.

.NET

A .NET assembly is a PE wrapper around IL plus a metadata table that records every type, method, field and parameter name. Decompilers reconstruct C# that is usually close to what was compiled, including generics and LINQ.

  • ILSpy or dnSpyEx to read. dnSpyEx additionally lets you *edit* a method and save the assembly, which is often the fastest solve: find the if (input == flag) and invert it, or make the check print the expected value.
  • dnSpyEx also debugs. Set a breakpoint on the comparison and read the argument. For a challenge that computes the flag rather than storing it, this is quicker than following the maths.
  • Resources matter. .resources streams and embedded files hold connection strings, keys, and sometimes a second assembly that is loaded at runtime with Assembly.Load. Extract and check them.
  • Watch for a native stub. Some challenges ship a small native loader that decrypts an embedded assembly. strings will show almost nothing; dump the assembly from memory once it is loaded instead.

.NET obfuscators

ConfuserEx, Dotfuscator, Eazfuscator and SmartAssembly all rename symbols to unprintable or homoglyph strings, encrypt string constants, and add control-flow flattening. de4dot reverses a great deal of this automatically, and it also *identifies* the obfuscator, which tells you what to expect. Run it first; what survives is the actual challenge.

Java and the JVM

A JAR is a zip. Unzip it, look at META-INF/MANIFEST.MF for the Main-Class, and decompile from there. Class files keep parameter and field names when compiled with debug info, which is the default for most build setups.

unzip -o chal.jar -d chal/
cat chal/META-INF/MANIFEST.MF

# CFR handles modern language features better than JD-GUI.
java -jar cfr.jar chal.jar --outputdir src/

# When the decompiler chokes, drop to bytecode. It always works.
javap -c -p -constants chal/Main.class
javap -c is the fallback that never fails. A decompiler can be defeated by deliberately malformed bytecode; the disassembler cannot, because the JVM has to be able to read it too.
  • Reflection hides the call graph. Class.forName(x).getMethod(y).invoke(...) means the interesting name is a string, not a symbol. Grep the constant pool for class-name-shaped strings.
  • Check for a custom ClassLoader. A challenge that decrypts classes at load time will have one, and dumping from a running JVM (with an agent, or by hooking defineClass) is the answer.
  • Serialized objects in resources or over a socket connect to deserialization gadget chains.
  • Obfuscators: ProGuard renames but does not encrypt, so structure survives and only names are lost. Allatori and Zelix add string encryption and flow obfuscation, and the same run-the-decryptor trick applies.

Python bytecode

A .pyc is a small header - magic, flags, timestamp, size - followed by a marshalled code object. The magic identifies the exact CPython version, and using a decompiler built for a different version is the usual reason decompilation fails.

# Version first, from the 4-byte magic.
xxd -l 16 chal.pyc

# Decompilation. Try in this order.
decompyle3 chal.pyc          # 3.7-3.8, best output when it works
uncompyle6 chal.pyc          # 2.x-3.8
pycdc chal.pyc               # C++, covers newer versions, cruder output

# Always available: disassemble instead of decompiling.
python3 -c "
import dis, marshal, sys
data = open('chal.pyc','rb').read()
dis.dis(marshal.loads(data[16:]))"
The header is 8 bytes on Python 3.6 and earlier, 12 on 3.3-3.6, and 16 from 3.7 onward. If marshal.loads fails, you have the offset wrong, not the file.
  • A missing or wrong magic is a common challenge trick. Take the magic from a .pyc you compile yourself on the right version and splice it in.
  • PyInstaller and py2exe bundles: pyinstxtractor.py unpacks the archive into .pyc files. The entry point loses its magic header during extraction and you must restore it before decompiling.
  • Bytecode tampering defeats decompilers cheaply - an inserted dead jump or a modified co_consts breaks the reconstruction while the code still runs. Read dis output instead; it is verbose but never wrong.
  • Nuitka is different in kind: it compiles Python to C, so the result is a native binary and none of this applies. The tell is Nuitka in the strings and an enormous binary.

What obfuscation actually removes

It is worth being clear about the limit, because it decides how much effort to spend. Obfuscation can remove names, encrypt constants, and flatten control flow. It cannot remove semantics: the runtime has to execute the code, so the code has to be there, and anything the program can compute you can also compute by running it.

  1. Deobfuscate mechanically first - de4dot, or the equivalent for the runtime. Never do by hand what a tool has already automated.
  2. Recover strings by execution, not analysis. Call the decryptor, or breakpoint after it and read memory.
  3. Use the debugger the runtime gives you. Managed runtimes ship real debuggers; a breakpoint on the comparison beats reversing the comparison.
  4. Patch rather than solve where the objective allows it. Editing a branch in dnSpy is legitimate and fast.
  5. Drop to the disassembler when the decompiler lies. Decompiler output is a reconstruction and can be wrong; bytecode cannot.

The order for any managed binary

  1. file, then check the magic bytes yourself - file mislabels .NET assemblies as ordinary PEs.
  2. Unpack any container: unzip the JAR, extract the PyInstaller archive, dump the embedded assembly.
  3. Run the deobfuscator for that runtime before reading anything.
  4. Decompile. Read Main, the entry point, or the module-level code, and follow the input.
  5. If decompilation fails, disassemble - javap -c, dis, or the IL view.
  6. Use the runtime's debugger to read the values rather than deriving them.
  7. Patch only when the objective is a check rather than a value.