Rebuilding PHP from opcodes: a practical walkthrough
Compiled PHP is a thing
When people think “PHP”, they picture a .php script interpreted on the fly. In reality, PHP goes through a full compilation pipeline before executing anything:
source.php → Lexer → Parser → AST → Compiler → Opcodes → Zend VM
The source file is first tokenised, then parsed into an abstract syntax tree (AST), then compiled into opcodes, low-level instructions for the Zend virtual machine. This is exactly the same principle as Java bytecode or .NET’s MSIL.
OPcache, PHP’s standard extension, stores these opcodes in shared memory to avoid recompiling on every request. Some tools go further: they serialise the entire compiled representation into binary files, making it possible to distribute PHP without shipping the source code.
These compiled binaries are more common than you’d think. Some commercial software vendors ship exclusively in this form.
What’s inside a compiled op_array
Every function, method, or top-level block in a compiled PHP file is represented by a zend_op_array, a structure that carries everything the Zend VM needs to execute that code. The diagram below shows the main fields and what each one lets us recover:
From source to op_array, and back
PHP has no official “compile to binary” command. But every time PHP runs a script, it compiles it internally into op_arrays, and OPcache can persist them to disk. The opcache.file_cache directive tells OPcache to write compiled .bin files:
php -d opcache.enable_cli=1 \
-d opcache.file_cache=/tmp/compiled \
-d opcache.file_cache_only=1 \
-r 'opcache_compile_file("script.php");'
This produces a binary file containing the serialised op_arrays. It is the exact same representation that OPcache keeps in shared memory in production, and that the Zend VM actually executes. Any tool that captures op_arrays at this stage, whether for caching, ahead-of-time compilation, or binary distribution, works from the same internal format.
To inspect what’s inside an op_array, two tools exist. phpdbg, PHP’s built-in debugger, can dump the compiled opcodes of any file with -p*:
phpdbg -n -p* script.php
The -n flag disables php.ini (and therefore OPcache), so you get the raw compiler output with no optimisation. For this script:
<?php
$config = "production";
$data = json_decode(file_get_contents("config.json"), true);
if ($data["database"]) {
connect($data["host"]);
}
phpdbg produces:
$_main:
; (lines=17, args=0, vars=2, tmps=7)
; script.php:1-7
0000 ASSIGN CV0($config) string("production")
0001 INIT_FCALL 2 112 string("json_decode")
0002 INIT_FCALL 1 96 string("file_get_contents")
0003 SEND_VAL string("config.json") 1
0004 V3 = DO_ICALL
0005 SEND_VAR V3 1
0006 SEND_VAL bool(true) 2
0007 V4 = DO_ICALL
0008 ASSIGN CV1($data) V4
0009 T6 = FETCH_DIM_R CV1($data) string("database")
0010 JMPZ T6 0016
0011 INIT_FCALL_BY_NAME 1 string("connect")
0012 CHECK_FUNC_ARG 1
0013 V7 = FETCH_DIM_FUNC_ARG CV1($data) string("host")
0014 SEND_FUNC_ARG V7 1
0015 DO_FCALL_BY_NAME
0016 RETURN int(1)
The header tells us this op_array has 2 compiled variables and 7 temporaries. Each line is one opcode with its operands: CV0($config) = first local variable, V3 = a VAR-type temporary, string("...") = a literal from the constant table.
The second tool is OPcache’s debug dump. By setting opcache.opt_debug_level, you can dump opcodes at different stages of the optimisation pipeline. The value 0x10000 corresponds to ZEND_DUMP_BEFORE_OPTIMIZER, the raw compiler output, before any optimisation:
php -d opcache.enable_cli=1 \
-d opcache.opt_debug_level=0x10000 \
-r 'opcache_compile_file("script.php");'
This produces identical output to phpdbg -n -p*. Both show the same pre-optimisation op_arrays. Compare with 0x20000 (ZEND_DUMP_AFTER_OPTIMIZER) to see what the optimizer changes: temporary variable slots get compacted, some opcodes get specialised. For decompilation, the pre-optimisation form is preferable: it matches the compiler’s original output, before OPcache has a chance to transform it.
This output, or a structured equivalent parsed from a compiled binary, is the decompiler’s starting point.
Turning opcodes back into PHP: three iterations
Iteration 1: The linear walker
The first version of the decompiler processes opcodes sequentially, one by one. The idea is straightforward: each opcode maps to a Python handler that either produces an expression (stored in a temp dictionary) or emits a complete PHP statement.
temps = {}
for op in opcodes:
if op.code == 'ASSIGN':
temps[op.result] = f"{op.op1} = {resolve(op.op2)}"
emit(temps[op.result] + ";")
elif op.code == 'INIT_FCALL':
push_call(op.op2) # push function name
elif op.code == 'SEND_VAL':
current_call().add_arg(resolve(op.op1))
elif op.code == 'DO_ICALL':
call = pop_call()
expr = f"{call.name}({', '.join(call.args)})"
if op.result:
temps[op.result] = expr
else:
emit(expr + ";")
Function calls are a good example of the difficulty: PHP compiles json_decode(file_get_contents("x"), true) into 7 separate opcodes. You need to reconstruct the nested call stack: INIT_FCALL opens a context, each SEND_* adds an argument, DO_ICALL closes it. And these calls nest: one function’s argument can be another function’s result.
This first version handles assignments, arithmetic operations, array/property access, new, return, and function calls. It produces valid PHP for linear code, but as soon as there’s an if or a loop, you get raw JMPZ and JMP opcodes in the output.
Iteration 2: Control flow reconstruction
This is the hardest part, by far. You need to analyse the jumps (JMP, JMPZ, JMPNZ) before decompilation to identify control structures.
The analysis runs in several passes:
foreach loops are the easiest to detect. PHP always compiles them the same way:
FE_RESET_R $array → end_opline
... body ...
FE_FETCH_R start_opline → $value
JMP → body_opline
FE_FREE $array
The FE_RESET + FE_FETCH + backward JMP pattern is unique, no ambiguity.
while loops are recognised by a JMPNZ that jumps backward (to the start of the body). The JMP before the body jumps to the condition:
JMP → condition
... body ...
condition: JMPNZ expr → body
if/else: a JMPZ whose target is forward creates an if block. If the opcode just before that target is an unconditional JMP, it’s an if/else. The JMP skips over the else block.
switch/case is the most complex. PHP compiles it as a comparison chain:
CASE $var "value1" ~tmp
JMPNZ ~tmp → case1_body
CASE $var "value2" ~tmp
JMPNZ ~tmp → case2_body
JMP → default_or_end
You need to group these chains by switch variable, find the convergence point (where all break statements land), and classify each block. In PHP 8+, match() expressions produce a similar pattern but with IS_IDENTICAL instead of CASE.
The result of this phase is an annotation map: for each opcode index, we know whether to emit if (, } else {, while (, }, etc. The linear pass from iteration 1 consults this map at every opcode.
Iteration 3: The details that matter
With control structures in place, the bulk of the work is done. But the details are numerous:
- Closures: the
DECLARE_LAMBDA_FUNCTIONopcode references a nested op_array. The decompiler processes it recursively, reconstructs the signature (function($x) use ($y)) fromBIND_STATICopcodes, and inserts the indented body - The
@operator: PHP compiles it asBEGIN_SILENCE/END_SILENCEaround the expression. We maintain a depth counter and prefix the expression with@ - Ternary:
$x ? $a : $bcompiles toJMPZ+ twoQM_ASSIGNseparated by aJMP. We detect this pattern and synthesise the expression into a single line ??(null coalesce): the dedicatedCOALESCEopcode followed byQM_ASSIGNproduces$x ?? $default- Default values:
RECV_INITopcodes carry the default value encoded as AST nodes in the op_array data. You need to decode AST nodes (class constants, binary expressions, magic constants…) to reconstructfunction foo($x = SomeClass::DEFAULT + 1) - Temporary aliasing: some compilers reuse temp slots in non-trivial ways. A
DO_FCALLwrites its result to#5, but the nextASSIGNreads from#3, which was never written to. You need a positional alias map to resolve these cases
Validation
With all that assembled, we had a prototype version of the decompiler. Now came the hardest part: validating, debugging, iterating.
Validating a decompiler is harder than it sounds. You can’t diff source files: the decompiled output and the original may differ cosmetically while remaining functionally identical. The question that matters is not “does the code look the same?” but “does it compile to the same opcodes?”.
Our validation method is round-trip opcode comparison: take the decompiled PHP, recompile it with ZEND_DUMP_BEFORE_OPTIMIZER as described above, and compare the resulting opcode sequence against the original, instruction by instruction. Both sides are at the same compilation stage (raw compiler output, before any optimisation), so the comparison is meaningful.
Results
The decompiled output produces semantically identical opcode sequences on all tested files. The decompiled code may look syntactically different from the original (different whitespace, reordered blocks, a while expressed as a for), but what matters is that the Zend VM would execute exactly the same instruction sequence. That is what the round-trip proves.
The tool is fully static: no code is executed, no sandbox needed. It processes a corpus of ~800 compiled files in under a second.
The control flow reconstruction accounts for roughly half of the implementation, which is unsurprising since that is where the edge cases are concentrated. One notable design choice: the decompiler goes directly from opcodes to PHP source, without building an intermediate AST. Unlike traditional decompilation pipelines (JVM, .NET) that go opcodes → CFG → SSA → AST → source, the richness of PHP opcodes makes this unnecessary. There is no obfuscation to undo, just standard op_arrays to reconstruct.
This article described the high-level approach. The source code is available on request. If you are a security researcher auditing compiled PHP distributions, or a developer needing to interoperate with software shipped exclusively as compiled binaries, reach out.
Takeaways
PHP opcodes are a rich format. Compared to the JVM or .NET’s CIL, where local variable names live in optional debug symbols, the Zend VM embeds them directly in the bytecode alongside every literal and function signature. Compared to WebAssembly, which strips nearly all source-level metadata by design, PHP’s op_arrays feel almost like annotated source code. This richness is what makes a direct opcode-to-PHP reconstruction possible, without needing to go through an intermediate AST as traditional decompilers do for Java or .NET bytecode.
“Compiled” PHP is not protected PHP, it is transformed PHP. And the transformation is reversible.