Escaping ionCube with a PHP Extension + AI
How I built a C extension that hooks into the Zend engine at runtime, extract the opcode metadata from encrypted PHP files, and feeds it to an AI to reconstruct readable source code.
The Problem
IonCube encrypt PHP files into binary bytecode. The loader extension runs them inside its own VM. You get no source, no readable opcodes - just encrypted garbage.
The catch: ionCube still runs on top of the Zend engine. To execute a function,
it has to populate the standard zend_op_array structure class names, method names, argument names, local variable names, and
all the literal values the function uses.
Those live in plain Zend structures. That's the attack surface.
How the Hook Works
ARM_decoder is a PHP C extension. At request startup it replaces two Zend engine function pointers:
zend_compile_file intercepts compilation, captures the target filename.zend_execute_ex fires on every function call. Reads the zend_op_array after ionCube prepares it, dumps everything to a .txt file, then lets IonCube run normally./* PHP_RINIT - install both hooks */
orig_compile_file_ptr = zend_compile_file;
zend_compile_file = arm_compile_file;
orig_execute_ex_ptr = zend_execute_ex;
zend_execute_ex = arm_execute_ex;
What Gets Extracted
For every function that executes, a structured dump is written.
Example from RDCore::__construct - 147 literals, all readable:
| idx | type | value |
|---|---|---|
| [0] | STRING | "instance" |
| [1] | STRING | "ErrorHandler" |
| [2] | STRING | "init" |
| [3] | STRING | "/var/www/html/core" |
| [4] | STRING | "loadConfig" |
| [5] | STRING | "#^(.+)\\:[0-9]+$#" |
| [6] | LONG | 80 |
| [7] | TRUE | true |
| [8] | STRING | "Location: " |
| [9] | LONG | 302 |
Combined with variable names ($baseDir, $request, $core_dir_name)
and the argument list - the function body is mostly reconstructible without ever seeing an opcode.
Tutorial
Requirements
php8.2-devbuild-essential, autoconf1. Build the extension
cd /path/to/ARM_decoder_source
phpize8.2
./configure --enable-ARM_decoder \
--with-php-config=/usr/bin/php-config8.2
make -j$(nproc)
# output: modules/ARM_decoder.so
2. Decode a single file
php8.2 -n \
-d "zend_extension=.../ioncube_loader_lin_8.2.so" \
-d "extension=.../ARM_decoder.so" \
target_file.php
3. Decode all IonCube files in a web root
grep -rl --include="*.php" \
--exclude-dir=vendor \
"if(extension_loaded('ionCube Loader'))" /var/www/html \
| while read -r target_file; do
echo "Decoding: $target_file"
php8.2 -n \
-d "zend_extension=.../ioncube_loader_lin_8.2.so" \
-d "extension=.../ARM_decoder.so" \
"$target_file"
done
4. Combine and organize dumps
python3 combine_dumps.py \
/path/to/arm_decoder_output/opcodes \
/path/to/arm_decoder_output/combined
The Python script groups every dump by class and rebuilds a proper folder structure:
When IonCube strips the filename (source = "unknown"), the namespace itself becomes
the folder path. Profis\MyWebSite\builder\ai\AiClientMessage
becomes Profis/MyWebSite/builder/ai.txt.
AI Reconstruction
Feed the combined dumps into an AI with a system prompt explaining the format.
The AI maps literals -> function body, fn_flags -> visibility,
arg/var names -> exact signatures.
[LITERALS]
[0] STRING "instance"
[1] STRING "ErrorHandler"
[2] STRING "init"
[3] STRING "/var/www/html/core"
[VARS]
CV0 = $baseDir
CV1 = $mpublic function __construct(
$baseDir = null
) {
$m = ErrorHandler
::getInstance();
$m->init();
define('DIR_RD_BASE',
'/var/www/html/core');
}
Github Repo: escaping-ioncube
Gemini Output: Gemini COOKING
ionCube is designed to stop casual inspection not someone who controls the PHP runtime. The moment it loads a function for execution, Zend needs real metadata. There's no way around that. The AI is what makes processing hundreds of dumps practical same reasoning a human reverse engineer would use, just without the fatigue.