Unpacking ionCube
Sat 01 August 2026 — download

It has been quite some time since I did some reversing, but with Binary Ninja's 10 year 35% sales, I thought that it would only be fair, given how much time I wasted spent in front of them, to actually pay for reverse engineering software once in my life. But now that I've coughed up $200, I needed something to reverse. This was thus a great opportunity to finish reversing ionCube and write an unpacker for it. For those not in the know, it's a commercial PHP encoder: feed it .php source code and it spits back an obfuscated file like this:

<?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=

The stub bails unless the ionCube loader, a zend_extension shipped as a native .so, is loaded in the PHP process. At runtime the loader decrypts the blob into Zend VM bytecode and executes it on its own statically-linked copy of the Zend VM. The original source never touches disk, but recovering it should be possible.

Since I'm running Asahi Linux, I looked at the aarch64 Linux loader ioncube_loader_lin_8.5.so (2b2dd97f8ef09bdabb4937a5b2e1505c8bfd9649106b2cc9295dd12428ae61c4), which can be freely downloaded here. I used ionCube's evaluation version to encrypt test files.

This blog post is going to be quite long, as ionCube is a bit complex, while simultaneously being short as not everything will be detailed or mentioned: my goal was to have fun with Binary Ninja and see how good it is compared to my usual tools suite, not to publish a generic ionCube unpacker.

The loader is a 2.3M binary, containing at least, a fuckton of cryptography-related functions (AES, Anubis, Blowfish, CAST5, Twofish, (3)DES, various SHA, MD5, Murmur, …) from LibTomCrypt (Anubis being the giveaway as basically nothing else ships it); a reimplementation of PHP's Reflection API, likely to prevent dynamic unpacking; a custom deserialization pipeline rebuilding a myriad of PHP things; a custom-ish PHP interpreter, some statically-linked libc stuff, … making it non-trivial. To find the entrypoint, since ionCube is a zend_extension, it exports a zend_extension_entry symbol that can be properly typed into the PHP structure. From there, the zend_startup_module can be found, and the MINIT/MSHUTDOWN/RINIT/RSHUTDOWN/MINFO handlers located, and we can start reversing from there.


Layer 0: A cryptographic onion

The encoded file is not one blob but several nested ones, meaning that we have to peel them one by one.

1. Encoding and the container transforms

The stub's payload is base64-encoded with a custom alphabet (0-9, then A-Z, then a-z, then +/), then run through a couple of transformations/containers down to a serialized opcode stream. A magic value at the front of the first container selects both the byte format and the PRNG kind used for decrypting the next layer. The loader supports several of them, but I only looked at the one supporting PHP modern opline format, as it's the one my samples used.

2. The op_array container

Parsing the base64-decoded stream yields a small structural header followed by the payload. The header carries the metadata the next stage needs: lengths, a method/format id, and some seed material.

3. Deriving the key

The payload's decryption key is produced by the exported (mgniyd) and lightly obfuscated function at 0x4446f0. Interestingly, ionCube supports five different per-file key sources:

  1. a constant 16-byte value baked into the file, stored as four u32 words;
  2. a value pulled from a PHP variable at runtime;
  3. key material derived from a function's bytecode, meaning that the loader will actually execute a designated function and hash the result, which is a fun anti-tamper trick;
  4. the contents of a file on disk;
  5. some name-mangling magic that I didn't bother with.

If none resolve, the loader bails with "no decryption key available". For unlicensed/evaluation files (the ones I looked at) the four embedded key words are all zero, so the constant path collapses to a constant key, making evaluation files fully decodable offline, yay.

4. The PRNG zoo and the stream cipher

From the key, two 32-bit seeds are computed:

  1. A Jenkins's one_at_a_time (joaat) hash, with the gotcha that the loader sign-extends each byte, so any byte >= 0x80 is treated as negative and a naïve port produces the wrong seed, which was a lot of fun to uncover.
  2. A MurmurHash3-32, with seed constant 0x1f.

Those two values are used to seed a PRNG, a dual 16-bit MWC one to be precise. Two independent lanes advance with multipliers 18000 and 30345, and each output word is ror32(y, 16) + x. The keystream byte for position i is the high byte of that word, (prng.next() >> 8) & 0xff, xored into the ciphertext. An ugly python implementation could look 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)

The section is called a "zoo" but we only talked about the MWC one, so here's the rest. Three generators hang off a small vtable built by 0x51e068:

  • id == 4: the dual-16-bit MWC described above;
  • id == 5: a CMWC, seeded with 0x1000, 0x1001, 0x12df35, 0x1f123bb5, 0x16a;
  • id == 6: textbook MT19937, given away by the 0x9908b0df constant.

Each core is wrapped behind the same {seed, next_byte, next_byte_keyed, destroy, free} function-pointer table, plus an optional aux_key layer that XORs next_byte() with aux_key[i % len] for a bit of extra keying. Which one you get depends on the layer: the per-file magic value picks MWC/CMWC for the container/op_array decrypt (the one I got), string decryption uses the MWC, and the rjY opline pipeline built by ic_prng_create(6), i.e. Mersenne Twister. Same XOR-a-keystream idea throughout, three different cores.

5. The runtime pipeline

Dynamically, all of the above is orchestrated by the exported rjY function, which is a bit complicated. I was pleasantly surprised by Binary Ninja's types handling and propagation, resulting in something quite readable after spending time annotating everything:

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)
}

The function boils down to:

  1. Creating a PRNG and seeding it with two 32-bit keys taken straight out of the serialized op_array
  2. Resolving the decryption key via one of the 5 available methods
  3. Picking one out of seven decoder variants depending on how the file was encrypted. In my case, it was a simple xor with the PRNG, but others are a tad more involved; I saw some code looking like AES-CTR and HMAC-like constructs.
  4. Calling the op_array build/decode callback.

6. Structures of the plaintext

The serialized op_array has a header guarded by an Adler-style checksum stored at +0x7c, with the accumulator running over the header bytes as signed chars (the same sign-extension gotcha again, isn't that great?) and compares against s1 | (s2 << 8), likely to catch dumb patching/bitflips attempts. There is also the opcode count at +0x30, and a literal pool, containing the constants/strings/numbers referenced by the php code, reconstructed into some kind of zval array, which it then processes depending on the PHP version, with some special handling for zend_string. At this point, we have the constants, but not the decoded opcodes stored behind the pool.


Layer 1: a Zend VM that isn't quite Zend

Stock PHP is a "threaded" interpreter: each zend_op carries a handler pointer, and the executor jumps from handler to handler. ionCube keeps that structure but twists it in two major ways.

Opcodes are xor-encrypted, decrypted per-dispatch

Each zend_op's handler field is stored encrypted. At dispatch the loader decrypts it with a per-opline key:

key_table  = (*(base + 0x257080) -> +160)[ op_array->reserved_index ]
handler[i] = enc[i] ^ (int64_t)(int32_t)(key_table[i] * 0x01010101)

Once decrypted, the handler pointer lands squarely inside the loader's own copy of the Zend VM. Now it's simply a matter of recovering which ZEND_*_SPEC_*_HANDLER an opline is. But once the map is recovered, it gives the base opcode and its operand kinds, which is enough to recover the bytecode. The handler cluster is contiguous in memory, and contains ~1000 specialized handlers, as well as ~100 ZEND_*_WORKER tail-calls. Recovering them was interesting.

I did a couple by hand (blessed be Binary Ninja's comprehensive undo support), then used WARP on a PHP interpreter compiled with symbols. Then I noticed that the data-structure containing pointers to every opcode had its fields in the same order as PHP's labels[] array in its zend_vm_execute.h file, yay! The only thing to be mindful of here is that you need the exact same PHP version as the one used by ionCube, which can be found with strings | grep -e 'API' -e 'php version' on the loader. Zend does some weird things, so not every single opcode is going to be there, but the rest can be done by hand quite rapidly.

The opline++ dispatch trick

This one took a whole afternoon to understand. In regular PHP there is no shared "advance" step, as every handler is responsible for moving opline itself: a sequential op ends with ZEND_VM_NEXT_OPCODE(), which is literally opline++, while a jump op ends with ZEND_VM_SET_OPCODE(target), writing the absolute target and continuing. So a jump lands exactly where it points, and the stored displacement is the plain distance to the target.

On the other hand, ionCube's re-threaded loader collapses that into one loop that does the increment unconditionally, for every op:

while (running) {
    handler(opline);   // handler no longer advances opline itself
    opline++;          // the loop always does this, even after a jump
}

Sequential ops are easy, as the handler does nothing, the loop's opline++ advances one instruction, same as before, but jump ops are the problem, with the handler setting opline = target, but then the loop still runs opline++, which would overshoot to target + 1. ionCube's jump handlers compensate by storing/setting the target as target − 1, meaning that converting the stored jump offset to an opline index needs an extra -1. It might not sound like much, but the off-by-ones everywhere were excessively annoying to debug.


Layer 2: the easiest disassembler is the loader itself

At this point, I started to reimplement the entire op_array deserializer, but this proved to be super-duper-tedious, so I (rapidly) gave up. Instead, I let the loader build the zend_op[] for me and snapshotted it right before execution via an LD_PRELOAD shim, interposing the re-threaded executor entry (internal_execute_ex). By the time it runs, the full opline array exists but not a single opcode was executed. From there, the hook:

  1. reads execute_data -> func -> op_array, grabs the opcode array and count;
  2. for each opline, decrypts the handler with the key-table formula above and resolves it to a loader-relative offset;
  3. best-effort resolves CONST operands (they're opline-relative zvals) into typed literals (int/float/ str/bool/null);
  4. prints one machine-readable OP row per opline, then _exit(0).

The output is a tab-separated dump:

$ 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                  -                      -
$

There is no horrible GDB scripting involved, no tedious reimplementation, and the PHP code is never executed. While this might sound elegant-ish, a SIGSEGV handler was used to make the speculative CONST reads safe because I couldn't be arsed to do something cleaner. This also has the advantage of being portable across versions/builds.


Layer 3: from bytecode to PHP code

Surprisingly, nobody has bothered with writing a Zend-bytecode to PHP source code lifter for PHP8, meaning I had to write one, sigh. Fortunately, PHP's bytecode is pretty straightforward, and should be liftable back to PHP in a single linear pass.

Since this whole exercise was pretty much an excuse to play with Binary Ninja, I tried to write an architecture plugin for my opcode dumper, but I gave up as it ended up being a broken pile of gross hacks. Maybe weird textual formats aren't the best thing to lift in Binary Ninja, who knows.

I threw the task at the friendly neighbourhood LLM, and got a working lifter, based on a simple recursive region decompiler over the flat opline list plus the jump graph, reconstructing conditions from a forward JMPZ/JMPNZ and the trailing JMP to the join point; loops from the rotated loop shape PHP emits, namely guard JMP to the condition, body, condition, conditional back-edge to the body top; foreach from FE_RESET / FE_FETCH / FE_FREE; and degrading to goto/labels for anything irreducible.

Distinguishing a for from a while uses a nifty trick: PHP compiles for (INIT; COND; INCR) BODY so that INIT, COND and INCR oplines all carry the for(...) header's source line number, while BODY carries the inner lines. That line signal lets the Structurer peel the trailing INCR off the body and reclaim the preceding INIT, producing a real for.


It's working!

$ 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;
$

Layer 2½: LLM-powered static unpacking

The loader-hook trick from the previous section is elegant, but it still runs the loader. Given how quickly the LLM wrote a lifter, I wondered if it was capable of producing a completely static unpacker given access to Binary Ninja's MCP server. Turns out, since it's mostly mechanical translate-this-assembly-routine-into-python work, not only did it produce a working static lifter, but it took only something like 10 minutes to do so. The future is here, and it's scary.

[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

Conclusion

This project took around one week of well-spent holidays, and was a nice opportunity to play with Binary Ninja. Its API is ridiculously clean and a joy to use, for example here is the script I used to decrypt ionCube's strings. While its decompiler/high-level IR isn't as refined/optimized out-of-the-box as IDA's, it's not that big of an issue as Binary Ninja's interface is pleasant to use, so fixing things isn't a chore.

To the ionCube team, who's making a solid product, I'd recommend:

  • Stripping more symbols, as they greatly helped the reversing process, including quickly identifying LibTomCrypt.
  • Using dynamic imports, so that the GOT/PLT isn't full of function names.
  • Obfuscating the key-derivation paths to make them non-trivial to follow.
  • Not trusting libc-provided time functions for DRM, as libfaketime is a thing :p

Artifacts are looking like this:

$ 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
$

And nope, I don't plan on releasing them :P