Unpacking ionCube

Lobsters Hottest News

Summary

A security researcher reverse-engineers ionCube's commercial PHP encoder/loader using Binary Ninja on aarch64 Linux, working toward a source-recovering unpacker.

<p><a href="https://lobste.rs/s/ohc3cr/unpacking_ioncube">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 08/12/26, 08:36 AM

# Unpacking ionCube Source: [https://dustri.org/b/unpacking-ioncube.html](https://dustri.org/b/unpacking-ioncube.html) 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 Iwastedspent 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 //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: ``` 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 processed 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](https://dustri.org/b/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\! ``` $ 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](https://dustri.org/b/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`\. - Strengthen the key derivation, as it's currently trivial to bruteforce it, no matter where the key material is coming from\. - 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\. - Randomize the order of the opcodes array\. - 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

Similar Articles

Codex Discovered a Hidden HTTP/2 Bomb

Lobsters Hottest

Codex discovered a remote denial-of-service exploit dubbed 'HTTP/2 Bomb' that targets HPACK compression in major web servers (nginx, Apache, IIS, Envoy, Pingora), chaining a compression bomb with flow-control hold to exhaust server memory quickly.