
Executive Summary
ionCube is one of the oldest and most widely deployed commercial PHP encoders. Vendors ship it to protect intellectual property: the PHP source is compiled into an opaque, encrypted container that only executes when the proprietary ionCube Loader — a Zend engine extension — is installed on the target host. The original source never touches the disk, which is exactly what makes the format attractive for licensing enforcement and DRM, and exactly what makes it interesting to reverse engineers. This article walks through a complete, layer-by-layer teardown of the aarch64 Linux loader (version 8.5), from the outermost cryptographic wrapper all the way down to reconstructed PHP source code.
The teardown is organised as a set of nested “layers”. Layer 0 is a cryptographic onion: custom base64, a family of hand-rolled PRNGs used as stream ciphers, and a key-derivation routine with five different key sources. Layer 1 is a modified Zend virtual machine whose opcode handlers are XOR-encrypted and decrypted one dispatch at a time, with a deliberately confusing opline++ control-flow trick. Layer 2 sidesteps a full reimplementation by turning the loader itself into a disassembler through an LD_PRELOAD shim. Layer 3 lifts the recovered Zend bytecode back into readable PHP. A final “Layer 2½” shows an LLM, driven through a Binary Ninja MCP server, producing a fully static offline unpacker in minutes. The result is a repeatable pipeline that turns an encoded file back into source without ever running the protected script.
Introduction: what ionCube actually produces
When a developer runs the ionCube encoder over a PHP project, each file is replaced by a small bootstrap stub followed by a large encrypted payload. The stub is human-readable and always looks roughly the same: it checks whether the loader extension is present, prints an installation message if it is not, and then hands a base64 blob to the loader for execution. Everything meaningful — the actual program logic — lives inside that blob in a proprietary, encrypted serialization of Zend’s internal op_array structure. Below is the shape of a typical encoded file: a familiar guard-and-message stub, followed by the opaque payload.
<?php //00363
if(extension_loaded('ionCube Loader')){die('The file '.__FILE__." is corrupted.\n");}echo("\nScript error: the ".(($cli=(php_sapi_name()=='cli')) ?'ionCube':'<a href="https://www.ioncube.com">ionCube</a>')." Loader for PHP needs to be installed.\n\nThe ionCube Loader is the industry standard PHP extension for running protected PHP code,\nand can usually be added easily to a PHP installation.\n\nFor Loaders please visit".($cli?":\n\nhttps://get-loader.ioncube.com\n\nFor":' <a href="https://get-loader.ioncube.com">get-loader.ioncube.com</a> and for')." an instructional video please see".($cli?":\n\nhttp://ioncu.be/LV\n\n":' <a href="http://ioncu.be/LV">http://ioncu.be/LV</a> ')."\n\n");exit(199);
?>
HR+cPup6CO3h4OgtzdsaeyPLIV7+ChEhfWAVPg2yRDH7jGn9HuXhiaMXScVaAEH018eagWbweToJ
xqQhfuKZvGwoYGgj5ty936E7z/IToP3S+x0Z8S86FZIkLdhf7Aldcb0nanFvrEbdWvArTfYVHgdx
LuNxGm7saHJKmXLXnsPWRCo7NMin7frqNe21gjOHq6ZAr/5rUoNOklHgH0OYvBGHkMhQ2Vud785p
ev4jGQ0VMT6I1EFCg9dg8lh8Jhu7dMAE2AncIEb90853PNJ45BEzw0n0RgjRJymrR4inzTTJkq8e
d/9DyV80I/+mUW3y2j3nBCN+0MaQpicFKupoLISIUxwTZx8GJ8IQa5D5RDZshQGactgX2sbfness
FO04NXfnmR2c/4S61eZ+hYKf9rG5HhXXAVJ3lVDR6Z6INEREyYzRSiPO7ExhQgS8JK9OGzJhQysb
2rrgzVy18pZ/pyC70Gt9wiLbVhbuHpCJYDxavBlZ1BzyaYhdPIsMrn1uvHb1P4ha51ZCcBpunz+O
Gy3RItoViNCoWfFHGi5ctTTs3fpoqBvnyAZ5KJanwyOzuwK8OxebXVcjMAIpcF3b01Kz4KvX4JY5
xpc321Zp9Obtb33gnAiHULh1SbvmFTvrMA+/k4opeXi1DaXE/oQO5UIdn/1eAwiNyWAPyTKsin+p
NIt8fjlm7btsNYMmO9m9L91NEvOOLsw9/RIDJebvlIyYC1I7SUIREHxIAvtkhJgdpJW4Zm82Tyyp
5jTyTrj7tqiihFFzCBa79QyiVj3SlFmKNRlnm1Tpizxbg3ifZhbsq2KcTztbyJIPYoapWfsVEjb5
FMdfxsdq/ZsYGQIttdP1KwkafjMSf7ndHBATP2E+y1YFuOSZv2qhOjxUoMC6Z+/9GvBRmQq7Yhmp
inJ2gGVxmzlj3Im9XbuBjq3hlukehbIIM5OlYPDEp0uap6cGn074e865RZ9KHPk+8j6YVWfbK3yk
K+vQZU71nKn71U0ifTx9nFDTEov3BQzBuzP/ApGfGCuDiKFZtoSnnLO8vg0/eHtRaa0fR9fl9uYO
QiVtkglvfMPQTG0VK+kCJVLGr405iOwCl6v9g4uXGlv3eawl7rtKMObisOeuh9avnEilnTghbtoX
wpuc5yoKV7Ldl3JpmoKgSXRfxauxXqtUrsqBAXhTDL+kMbAORGogayJuBux/DKY2RPLzIex0FnFq
H76Q7JlCfi7oD7uHflcLCag6cUCFHn3UUUrHSkczAMITWqqtom6wqPc9wFgI9/pHzPL8f+qMPvja
68AnE9oLyJaaXuvXR8juDJlc1GgyJZjia3jEDn19ILbsJCpPCjQ4GMKNkNmwVmNjBsrev9+NVXP5
UPsDaP90W5Bdqg0WwOZ1oA/b6g7Sl5+LN48=
An ionCube-encoded PHP file: the readable bootstrap stub followed by the encrypted base64 payload. Source: original article.
The loader under analysis is a 2.3 MB shared object. Because it ships with symbols, a lot of internal machinery is easy to identify: LibTomCrypt primitives (AES, Anubis, Blowfish, CAST5, Twofish, several DES variants, SHA, MD5 and the Murmur hash family), a partial reimplementation of PHP’s Reflection API, a bespoke deserializer, and — most importantly — a custom PHP interpreter. The natural entry point for reversing is the exported zend_extension_entry symbol, which leads to the startup handlers where the decryption pipeline is wired up.
Layer 0: a cryptographic onion
The encoded file is not a single ciphertext but a set of nested blobs, each of which has to be peeled in sequence before the next becomes meaningful. Getting to the serialized op_array means understanding the container format, the transforms applied to it, how the decryption key is derived, and which pseudo-random generator produces the keystream.
1. Encoding and the container transforms
The payload begins life as base64, but with a custom alphabet ordered digits-first (0-9, then A-Z, then a-z, then +/) rather than the standard RFC 4648 ordering. Once decoded, the bytes pass through a chain of transforms. A magic value near the front selects both the byte-level format and the PRNG variant used to decrypt the next layer, so the loader can support several historical container generations from a single code path. This analysis focuses on the modern variant that carries the current opline format.
2. The op_array container
After base64 decoding you are left with a structural header followed by a payload body. The header is compact metadata: the length of the encrypted region, a method/format identifier that selects which decoder to run, and seed material used to initialise the keystream generator. Everything the loader needs in order to decrypt and rebuild the Zend structures is described by this header.
3. Deriving the key
The decryption key is produced by a dedicated routine (in this build at address 0x4446f0). ionCube supports five distinct sources for key material, which is what gives the format its DRM flexibility:
- A constant 16-byte value baked directly into the file, stored as four 32-bit words.
- A value pulled from a PHP variable at runtime.
- Material derived from the bytecode of a designated function: the loader actually executes that function and hashes the result, which doubles as an anti-tamper check.
- The contents of a file read from disk.
- A name-mangling scheme that derives key bytes from identifiers.
The crucial observation for offline analysis is that evaluation and unlicensed builds leave the four embedded key words set to zero. When that happens, the whole scheme collapses to a fixed constant key, and the payload can be decoded entirely offline — no license, no runtime environment, no loader required.
4. The PRNG zoo and the stream cipher
From the derived key, two 32-bit seeds are computed. The first comes from Jenkins’s one-at-a-time hash (joaat), the second from MurmurHash3-32 with the seed constant 0x1f. Both hashing steps hide a sign-extension gotcha: input bytes with the high bit set (≥ 0x80) are treated as negative signed chars, which you have to replicate exactly or the seeds come out wrong. Those two seeds drive a dual 16-bit multiply-with-carry (MWC) generator: two independent lanes advance with multipliers 18000 and 30345, and each output word is ror32(y, 16) + x. The keystream byte at position i is (prng.next() >> 8) & 0xff, XORed into the ciphertext. A compact Python reimplementation of that MWC generator and its XOR decryptor looks like this:
class MwcPrng:
def __init__(self, s0, s1):
self.x, self.y = s0, s1
def next(self):
self.y = ((self.y & 0xFFFF) * 30345 + (self.y >> 16)) & 0xFFFFFFFF
self.x = ((self.x & 0xFFFF) * 18000 + (self.x >> 16)) & 0xFFFFFFFF
return (((self.y >> 16) | (self.y << 16)) & 0xFFFFFFFF) + self.x & 0xFFFFFFFF
def decrypt(enc, key):
p = MwcPrng(jenkins(key), murmur3_32(key, 0x1f))
return bytes(c ^ ((p.next() >> 8) & 0xFF) for c in enc)
A Python reimplementation of the dual 16-bit MWC PRNG and the XOR stream-cipher decrypt step. Source: original article.
The MWC generator is only one of three PRNG variants, selected through a vtable at 0x51e068:
- id == 4: the dual 16-bit MWC described above.
- id == 5: a complementary-multiply-with-carry (CMWC) generator, seeded with the constants
0x1000,0x1001,0x12df35,0x1f123bb5and0x16a. - id == 6: textbook MT19937, immediately recognisable from the
0x9908b0dfconstant.
Each generator is wrapped behind a uniform function-pointer table — {seed, next_byte, next_byte_keyed, destroy, free} — with an optional auxiliary-key layer that XORs each next_byte() output with aux_key[i % len]. In practice the container and op_array decryption use the MWC/CMWC generators, string decryption uses MWC, and the opline decryption pipeline (the exported rjY routine) uses MT19937.
5. The runtime pipeline
The exported rjY function is the orchestrator. It creates a PRNG, seeds it with two 32-bit keys taken from the serialized op_array, resolves the decryption key through one of the five methods above, selects a decoder variant (seven exist — in the simplest case a plain XOR against the PRNG stream, in others AES-CTR combined with HMAC-like integrity constructs), and finally calls the op_array build/decode callback. Binary Ninja’s decompiler renders the decryption dispatcher roughly as follows — note the key resolution, the decoder-context creation, the size check against the expected plaintext length, and the elaborate error/cleanup ladder:
uint64_t ic_decrypt_oplines(struct ic_loader_ctx* ctx) {
struct ic_op_array_slot* job_owner = ctx->op_array_slot
int32_t error_state = ic_globals->error_state
struct ic_opline_job* job = job_owner->job
int64_t prng = ic_prng_create(6, ic_globals)
ic_prng_seed(prng, zx.q(job->prng_seed_lo), zx.q(job->prng_seed_hi))
void* aux_key = job->aux_key
if (aux_key != 0)
ic_prng_set_aux_key(prng, aux_key, job->aux_key_len)
void** ctx_backref = job->ctx_backref
*(job->op_array + 0x28) = prng
ctx->field_68 = 0
*ctx_backref = ctx
uint32_t is_encrypted = zx.d(job->is_encrypted)
ic_globals->error_state = job->saved_error_state
if (is_encrypted == 0)
goto not_encrypted
void* decrypted_code =
(*ic_membuf_allocator)->vtable->alloc(size: sx.q(job->decrypted_size))
void** ctx_backref_1 = job->ctx_backref
void* key
uint64_t key_len
void* const errmsg
if (zx.d(ic_resolve_decryption_key(job->params_hdr, ctx_backref_1[1],
zx.q(ctx_backref_1[2].d), job->op_array, job->key_material, &key, &key_len)) == 0)
if (get_error_code() == 0)
ic_globals->error_code = 1
errmsg = &no_decryption_key_available
goto report_error
struct ic_decrypt_params* params_hdr = job->params_hdr
struct ic_decoder_ctx* decoder_context = ic_create_decoder_context(
zx.q(params_hdr->method_id), zx.q(params_hdr->abort_flag))
int32_t result
if (decoder_context != 0)
int32_t real_decrypted_size = decoder_context->decode(self: decoder_context,
in: job->code_buf, in_len: job->input_size, key, key_len,
out: decrypted_code)
uint32_t decrypted_size = job->decrypted_size
if (real_decrypted_size != decrypted_size)
ic_globals->error_code = 3
void* x0_13 =
ic_get_static_string(&s_Error_during_decryption, decrypted_size)
ic_report_protected_script_error(job->script, job->op_array, x0_13)
_efree(ptr: job->code_buf)
job->is_encrypted = 0
uint32_t decrypted_size_1 = job->decrypted_size
job->code_buf = decrypted_code
job->input_size = decrypted_size_1
ic_free_decoder_context(decoder_context, decrypted_size_1)
_efree(ptr: key)
result = job->callback(ctx, job)
if (result != 0)
goto err
goto decoding_error
errmsg = &cannot_initialize_decryptor
ic_globals->error_code = 2
report_error:
void* x0_27 = ic_get_static_string(errmsg)
ic_report_protected_script_error(job->script, job->op_array, x0_27)
not_encrypted:
result = job->callback(ctx, job)
if (result == 0)
decoding_error:
ic_globals->error_code = 4
void* x0_19 = ic_get_static_string(&s_Decoding_error, ic_globals, 4)
ic_report_protected_script_error(job->script, job->op_array, x0_19)
ic_globals->error_state = error_state
ic_prng_destroy(prng)
if (ctx->field_8 == 0)
free_and_ret:
ic_free_opline_job(job)
_efree(ptr: job_owner)
return zx.q(result)
else
err:
ic_globals->error_state = error_state
ic_prng_destroy(prng)
if (ctx->field_8 == 0)
goto free_and_ret
if (*ctx->field_88 == 0)
ic_free_opline_job(job)
return zx.q(result)
}
Decompiled ionCube opline-decryption dispatcher, reconstructed in Binary Ninja. Source: original article.
6. Structures of the plaintext
Once decrypted, the serialized op_array has a header protected by an Adler-style checksum at offset 0x7c. The accumulator runs over the header bytes treated as signed chars — the same sign-extension subtlety as the hash seeds — and is compared against s1 | (s2 << 8). The header also carries the opcode count at offset 0x30 and a literal pool holding the constants, strings and numbers referenced by the program. That pool is reconstructed into a zval array, with special handling for zend_string objects and per-PHP-version quirks in how literals are laid out.
Layer 1: a Zend VM that isn’t quite Zend
Stock PHP uses a “threaded” interpreter: every zend_op carries a pointer to its handler function, and the executor jumps directly from one handler to the next. ionCube keeps this overall shape but modifies it in two ways that together frustrate naive analysis.
Opcodes are XOR-encrypted, decrypted per-dispatch
The handler pointer inside each zend_op is not stored in the clear. Instead it is XOR-encrypted, and decrypted at the moment of dispatch using a per-opline key table. The recovery formula is:
key_table = (*(base + 0x257080) -> +160)[ op_array->reserved_index ]
handler[i] = enc[i] ^ (int64_t)(int32_t)(key_table[i] * 0x01010101)
Per-dispatch handler decryption: each opline’s handler pointer is recovered from a key table indexed by the op_array’s reserved slot. Source: original article.
The decrypted pointer lands inside the loader’s private copy of the Zend VM. Recovering the original bytecode therefore means figuring out which ZEND_*_SPEC_*_HANDLER a given opline corresponds to; once that mapping is known, the base opcode and its operand kinds fall out. The handler cluster contains roughly a thousand specialised handlers plus about a hundred ZEND_*_WORKER tail-calls. The mapping was recovered by pointing Binary Ninja’s WARP at a symbol-bearing PHP interpreter, leaning on Binary Ninja’s undo support while iterating, and noticing that the loader’s pointer array matches the order of PHP’s labels[] array from zend_vm_execute.h. Because that ordering is version-specific, an exact PHP version match is required — recoverable by grepping the binary for the API and php version strings. Any opcodes not present in the sample were mapped manually.
The opline++ dispatch trick
In ordinary PHP, each handler is responsible for advancing the instruction pointer: sequential opcodes call ZEND_VM_NEXT_OPCODE() (which does opline++), while jumps call ZEND_VM_SET_OPCODE(target). ionCube collapses all of that into a loop that unconditionally increments the opline after every handler:
while (running) {
handler(opline); // handler no longer advances opline itself
opline++; // the loop always does this, even after a jump
}
The ionCube dispatch loop: handlers no longer advance the instruction pointer; the loop always does opline++. Source: original article.
For sequential ops this is equivalent to the original: the handler does nothing to the pointer and the loop’s opline++ steps forward by one. For jumps it is deliberately awkward: the handler sets opline = target, but the loop still runs opline++ afterward, overshooting to target + 1. ionCube compensates by storing every jump target as target − 1, which means converting a stored jump offset back into an opline index needs an extra −1. These off-by-ones are scattered throughout the format and are a persistent source of debugging pain.
Layer 2: the easiest disassembler is the loader itself
Reimplementing the entire op_array deserializer by hand is tedious and error-prone. A far cheaper approach is to let the loader do that work and simply snapshot the result. The trick is an LD_PRELOAD shim that interposes on the re-threaded executor entry point (internal_execute_ex). At the moment that function is first called, the full zend_op[] array already exists in memory but not a single opcode has executed yet — the perfect place to dump it.
The hook reads execute_data -> func -> op_array, and for each opline it decrypts the handler with the key-table formula, resolves the handler to a loader-relative offset, and best-effort resolves CONST operands (which are opline-relative zvals) into typed literals — ints, floats, strings, booleans and nulls. A SIGSEGV handler guards the speculative constant reads so a bad pointer never crashes the dump. It then prints one machine-readable row per opline and exits before any user PHP runs. No GDB scripting, no tedious reimplementation, and the technique is portable across loader versions and builds:
$ python3 ./ic_disasm.py -v ../test00.php
[icdis] step 1/3: disassembling ../test00.php (loader=ioncube_loader_lin_8.5.so, output=listing)
[icdis] step 2/3: running PHP under loader (LD_PRELOAD=ic_hook.so)
[icdis] parsed 13 oplines, 1 compiled-vars
[icdis] step 3/3: emitting listing output
=== test00.php :: {main} (13 oplines, decoded via LD_PRELOAD, 0 executed) ===
# line opcode op1 op2 result
0 2 ASSIGN_CV_CONST_RETVAL_UNUSED $x int:0 -
1 2 JMP - - -
2 3 ROPE_INIT_UNUSED_CONST - str:The number is: TMP3
3 3 ROPE_ADD_TMP_CV TMP3 $x TMP3
4 3 ROPE_END_TMP_CONST TMP3 str: <br> TMP2
5 3 ECHO_TMPVAR TMPVAR2 - -
6 2 PRE_INC_CV_RETVAL_UNUSED $x - -
7 2 IS_SMALLER_OR_EQUAL_TMPVARCV_CONST_JMPNZ TMPVARCV0 int:10 TMP6
8 2 JMPNZ_TMPVAR TMPVAR6 - -
9 6 INIT_FCALL_BY_NAME_CONST - - -
10 6 SEND_VAL_CONST str:Hello World - TMP0
11 6 DO_FCALL_BY_NAME_RETVAL_USED - - -
12 8 RETURN_CONST int:1 - -
$
Output of the LD_PRELOAD-based disassembler: a full opline listing decoded via the loader, with zero opcodes executed. Source: original article.
Layer 3: from bytecode to PHP code
A disassembly listing is useful, but the real goal is readable source. Nobody had written a Zend-bytecode-to-PHP decompiler for PHP 8, so the author wrote one. PHP bytecode is comparatively simple and can be lifted in a single pass. A recursive region decompiler walks the flat opline list and reconstructs control flow structurally: conditionals from a forward JMPZ/JMPNZ paired with a trailing JMP to the join point; loops from the rotated shape PHP emits (a guard jump to the condition, then body, condition, and a conditional back-edge); and foreach from the FE_RESET/FE_FETCH/FE_FREE triple. Anything irreducible degrades gracefully to goto and labels.
Distinguishing a for loop from a while loop is a nice subtlety. Because PHP compiles for(INIT; COND; INCR) BODY so that the INIT, COND and INCR opcodes all carry the source line of the for(...) header while the body carries the inner lines, the line numbers themselves become a signal. The structurer uses that signal to peel the trailing INCR back up to the header and reclaim the preceding INIT, producing a genuine for loop rather than a while with a manual counter.
It’s working!
Putting the pieces together end to end: start with an ordinary test script, encode it with the evaluation encoder, and then run the disassembler-plus-lifter over the encoded output. The original source, the encoder invocation, the resulting encoded file, and the recovered PHP all appear in a single session:
$ cat test.php
<?php
for ($x = 0; $x <= 10; $x++) {
echo "The number is: $x <br>";
}
printf("Hello World");
$./ioncube_encoder.sh -84 test.php -o target.php
$ cat target.php
<?php //00363
// IONCUBE ENCODER 15.0 EVALUATION
// THIS LICENSE MESSAGE IS ONLY ADDED BY THE EVALUATION ENCODER AND
// IS NOT PRESENT IN PRODUCTION ENCODED FILES
if(extension_loaded('ionCube Loader')){die('The file '.__FILE__." is corrupted.\n");}echo("\nScript error: the ".(($cli=(php_sapi_name()=='cli')) ?'ionCube':'<a href="https://www.ioncube.com">ionCube</a>')." Loader for PHP needs to be installed.\n\nThe ionCube Loader is the industry standard PHP extension for running protected PHP code,\nand can usually be added easily to a PHP installation.\n\nFor Loaders please visit".($cli?":\n\nhttps://get-loader.ioncube.com\n\nFor":' <a href="https://get-loader.ioncube.com">get-loader.ioncube.com</a> and for')." an instructional video please see".($cli?":\n\nhttp://ioncu.be/LV\n\n":' <a href="http://ioncu.be/LV">http://ioncu.be/LV</a> ')."\n\n");exit(199);
?>
HR+cPsgUCW14UOSt5IMd8+UKAIfC4rWlqAdOx9gySo0+Hy17VOK0czeAR+/cNGSwGHUiqWjEhauD
lswA86dsgfO+rczp9pQNqpO/eciV5aI/xQYyHbmDRRiSxx1K796KJ4PKiSNkMMD58fWawHRWlJx1
RTnzNcAlHCb9AkuN6bBJxFNmnoSQ4pygI50lCuSXXpllKih/Hg2JIyGq8QbslYVPDSPi3ka5JKKU
hD18THJAe8uH7tIEw8K/y8p1FPAHy9aFHVbacdosKa/vuqaOwciF0V6EKoMaokBDOhq8+r9pepcv
Z/igfsLYDIGiCrdLdRtpt+XGOOXqqJW56G681cjqDULWJ8MyoyjX7k/4GikL46axv1l91v7uqSvz
Af8h8Bfnl+GlaMOfWhKA2tSxhaS6jtX4ouNXxrRw2eXBy2dvjB5HgHmqAu0LC2GKAiY9pacUoX7m
6Ra9b4xUdkBNSmCnE4yYKwbhGRDkGaejsGqeUyFUejUp2eKsIRLzAFZYopC6Y8bKR9BCmfQfO7B3
imKq+ZuV/s6s0NSSJMkLuQ0MIhxJ9erGFmjcpGFc7y6zksUasFRpzdyaG17hJc++agzahuCGerD5
qSCeQVsaZRtUXjYTCIwj+tDZgDkOKySpCseYWX1Tat+ytK0x+q1WKM45jT0Z+knn7MCf0dVIIbAX
ZChP6ThYAHLy1grlWEO+n3vXfTxXXR3mNlJZeP7GodgcTGcBLr8rpEApbrxl+YAC1MKRUOMwCv96
/GHS1NS3zOgX0U9HBJTtuE8RtJF/mty/pQjUwvLSRyOYb5lvsEUrBWHs5HKP6Uw7LqGSIQXdfl9c
ylGSEpWOPIovwMPzq9kAd0iQPLbNagHSOfGsjTS7R2hk4diHIBCXXOFWGM6DngWBEDjDjYY5w7D9
Hvz/qvK/XXSL8uxa3q7Bhp6y3lphkVrRPn48k+siRjwCatD9x1uigSqvIniJnYlFUcYVrYHbj8z8
bx4JswGBuOZpr4zT7il22H7/c+cm0BbQdopHTIHsoOWVbsD0+pSUUk0QBVZUFwvqCYwAYAM/vzNy
5qgXhQid91VRvQHffBw6hwUJ9Ezi1gjkSpyp7olr4ClzOqCUwQxbzaRKkiwGkwTSsbjQDEmcAeHN
mrymkh7jUF8m4Jl+ZwFYSaeMNCRVgmZ6cfgczA9ijTmfkoOGd8+b1YxjE/Ba1Ris/K3nSUHZZtUx
ojXw4u8cNu6JTQbTgIcZ2aVJVrHW+5DGN1SIkWzEU64NUx+R90lTak89zqtBggUYsUCZ3Ds5i85w
O9TMNym5fzTSk5/TJC1PkIjIomkVP8EivvrHLuKLPeyDKwIIbsny++Wmwgjj0noJugo5ZfEJzD/q
zU71bZDz25istIdHDN/RnJu/fh6HZIO=
$ python3 ic_disasm.py target.php --lift
<?php
// lifted from test00.php (13 oplines) -- zend_lifter
for ($x = 0; $x <= 10; ++$x) {
echo 'The number is: ' . $x . ' <br>';
}
printf('Hello World');
return 1;
$
End-to-end demonstration: original PHP, the ionCube evaluation encoder, the encoded output, and the lifted source recovered from it. Source: original article.
The lifted program is not a rough approximation — it is a faithful reconstruction of the loop and the function call, down to the string concatenation and the trailing return 1 that PHP’s compiler inserts.
Layer 2½: LLM-powered static unpacking
The LD_PRELOAD approach is elegant, but it still runs the loader. Could the whole thing be done statically, with no loader in the loop at all? The bulk of the remaining work — translating the relevant assembly routines into Python — is mechanical, which makes it a good candidate for an LLM with tool access. Given a Binary Ninja MCP server to query, an LLM produced a working, fully static lifter in about ten minutes. The static path decodes the container completely offline (ic_extract → RLE + CMWC → inflate → op_array stream), then reproduces the MWC seed and opcode remap without ever loading the extension:
[icdis] step 1/3: static lift of ../test00.php (offline opcode remap)
[icdis] decoding ../test00.php fully offline (ic_extract -> RLE+CMWC -> inflate)
[icdis] container: 1052 bytes, CMWC seed=0xf29f2842 @0xe2
[icdis] RLE+CMWC -> 793 bytes -> inflate(-15) -> 794-byte op_array stream
[icdis] stream header: blob_len=709 s0=0x11d00ecb s1=0x34c36e92 key=01010101010101010101010101010101
[icdis] blob candidate @0x51 decrypts to a valid op_array (13 oplines)
[icdis] offline reader: dec=709 bytes, 13 oplines, blob@0x51, cv_names=['x']
[icdis] step 2/3: decoded dec + MWC seed fully offline (no loader)
[icdis] static K=0x365dcf, MWC seed=(0x11d00ecb,0x34c36e92), 13 oplines
[icdis] decoded oplines: 0:ASSIGN, 1:JMP, 2:ROPE_INIT, 3:ROPE_ADD, 4:ROPE_END, 5:ECHO, 6:PRE_INC, 7:IS_SMALLER_OR_EQUAL, 8:JMPNZ, 9:INIT_FCALL_BY_NAME, 10:SEND_VAL_EX, 11:DO_FCALL_BY_NAME, 12:RETURN
[icdis] dfloat2 @0x2599f8, 600 slots -> 591 decoded cached strings
[icdis] cached-string name consts (pool order): ['printf', 'printf']
[icdis] CONST classes: str=[1, 2, 6] int=[0, 3, 7] name=[4]
[icdis] string consts resolved: {1: 'str:The number is: ', 2: 'str: <br>', 6: 'str:Hello World'}
[icdis] int consts resolved: {0: 'int:0', 3: 'int:10', 7: 'int:1'}
[icdis] name consts resolved: {4: 'str:printf'}
[icdis] resolved 7 literals (offline dec parse)
[icdis] CV names: {0: 'x'}
[icdis] linked 1 smart-branch compare(s) to conditional jumps
[icdis] step 3/3: lifting 13 oplines to PHP (static)
<?php
// lifted from test00.php (13 oplines) -- static (offline opcode remap)
for ($x = 0; $x <= 10; ++$x) {
echo 'The number is: ' . $x . ' <br>';
}
printf('Hello World');
return 1;
[icdis] done
Fully static, offline unpacking: the container is decoded and the oplines lifted to PHP without running the ionCube loader. Source: original article.
Key Takeaways
- ionCube protection is a layered scheme — custom base64, a family of hand-rolled PRNGs used as stream ciphers, XOR-encrypted opcode handlers, and a serialized Zend
op_array— not a single strong cipher. - Evaluation and unlicensed builds zero out the embedded key words, collapsing key derivation to a fixed constant and making fully offline decoding possible.
- The modified Zend VM decrypts each opcode handler at dispatch time and uses an unconditional
opline++loop, with jump targets stored astarget − 1— a rich source of off-by-one confusion. - The cheapest disassembler is the loader itself: an
LD_PRELOADshim can snapshot the fully-built opline array before a single opcode executes. - PHP 8 bytecode is regular enough to lift back to readable source in a single pass, with source-line numbers disambiguating
forfromwhile. - An LLM driving a Binary Ninja MCP server turned the mechanical “assembly to Python” translation into a working static unpacker in minutes.
- Symbol-bearing binaries and reused open-source primitives (LibTomCrypt) dramatically accelerate reverse engineering.
Defensive Recommendations
The write-up closes with concrete hardening ideas aimed at the encoder’s authors — and, more broadly, at anyone designing software protection or DRM. They are a useful checklist for evaluating any code-protection product:
- Strip symbols. Shipping a loader with symbols made LibTomCrypt and the internal structures trivial to identify. Stripped binaries raise the cost of every subsequent step.
- Strengthen key derivation. Constant, zeroable key words are trivial to brute-force. Key material should be expensive to guess and never collapse to a fixed constant in any build.
- Use dynamic imports. Resolving library functions at runtime removes the GOT/PLT function names that hand an analyst a free map of the code.
- Obfuscate the key-derivation paths. The routines that assemble the key are exactly the code an attacker targets first; they deserve the heaviest obfuscation.
- Randomise the opcode array order. The handler array matching PHP’s
labels[]ordering is what makes opcode identification tractable — permuting it per build breaks that shortcut. - Do not trust libc time functions for DRM. Tools such as
libfaketimemake wall-clock-based license checks easy to defeat; time-based enforcement should never rely on the C library. - Assume the loader is the oracle. Any design that ships a local decryptor must assume an attacker can interpose on it (for example via
LD_PRELOAD) and snapshot decrypted state before execution.
Conclusion
Taken end to end, this was roughly a one-week project, and a pleasant one thanks to Binary Ninja: the API is clean, and while the decompiler and IR are not quite as polished out of the box as IDA’s, the interface makes fixing things painless rather than tedious. The final artefact is a small toolkit — an LD_PRELOAD hook, an offline reader, a keystream/seed reimplementation, a bytecode lifter, and supporting glue — that together turn an encoded ionCube file back into PHP. The project layout gives a sense of the moving parts involved:
$ tree -h
[ 418] .
├── [ 2.2K] ic_cached_strings.py
├── [ 4.8K] _iccapture.py
├── [ 106] icdis
│ ├── [ 3.5K] cli.py
│ ├── [ 513] config.py
│ ├── [ 2.7K] dynamic.py
│ ├── [ 1.1K] __init__.py
│ ├── [ 5.1K] model.py
│ └── [ 17K] static.py
├── [ 252] ic_disasm.py
├── [ 1.7K] ic_elf.py
├── [ 1.5K] ic_faketime.c
├── [ 69K] ic_faketime.so
├── [ 5.3K] ic_hook.c
├── [ 69K] ic_hook.so
├── [ 4.1K] ic_keystream.py
├── [ 53K] ic_nts_handler_names.py
├── [ 7.0K] ic_reader.py
├── [ 1.9K] _icseed.py
├── [ 22M] ioncube_loader_lin_8.5.so.bndb
├── [ 41K] NOTES.txt
└── [ 170] zlift
├── [ 17K] dataflow.py
├── [ 1.2K] driver.py
├── [ 6.1K] expr.py
├── [ 2.1K] handlers.py
├── [ 1.3K] __init__.py
├── [ 4.0K] model.py
├── [ 3.4K] stmt.py
├── [ 9.3K] structure.py
└── [ 2.6K] tables.py
3 directories, 29 files
$
The resulting toolkit: hook, offline reader, keystream/seed code, disassembler and the zlift bytecode-to-PHP lifter. Source: original article.
The broader lesson is familiar but worth restating: local software protection is, ultimately, an obfuscation exercise. Once the decryptor ships to the attacker, every layer can be peeled given enough patience — and modern tooling, including LLMs wired into a reverse-engineering platform, is steadily shrinking how much patience is required.
Original text: “Unpacking ionCube” by Julien (jvoisin) Voisin at Artificial truth (dustri.org), published under CC BY.


