Title: Unpacking ionCube
Date: 2026-08-01 00:25

It has been quite some time since I did some reversing, but with [Binary
Ninja's 10 year 35%
sales](https://binary.ninja/2026/07/22/10-years-of-binary-ninja.html), I
thought
that it would only be fair, given how much time I <s>wasted</s> 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](https://ioncube.com/) 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
<?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](https://asahilinux.org/),
I looked at the aarch64 Linux loader `ioncube_loader_lin_8.5.so`
([2b2dd97f8ef09bdabb4937a5b2e1505c8bfd9649106b2cc9295dd12428ae61c4](https://www.virustotal.com/gui/file/2b2dd97f8ef09bdabb4937a5b2e1505c8bfd9649106b2cc9295dd12428ae61c4)),
which can be freely downloaded [here](https://www.ioncube.com/loaders.php). I
used [ionCube's evaluation version](https://www.ioncube.com/eval_linux) 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](https://github.com/libtom/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](https://en.wikipedia.org/wiki/Jenkins_hash_function),
   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](https://en.wikipedia.org/wiki/MurmurHash),
   with seed constant `0x1f`.

Those two values are used to seed a PRNG, a dual 16-bit
[MWC](https://en.wikipedia.org/wiki/Multiply-with-carry_pseudorandom_number_generator)
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:

```python
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:

```C
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](https://docs.binary.ninja/guide/warp.html) 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]({static}/files/ic_hook.c), 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 `zval`s) 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!

```console
$ 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.

```console
[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]({static}/files/bn_decrypt_strings.py) 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`](https://github.com/wolfcw/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
