解包 ionCube

Lobsters Hottest 新闻

摘要

一名安全研究人员在 aarch64 Linux 上使用 Binary Ninja 对 ionCube 的商业 PHP 编码器/加载器进行逆向工程,致力于实现一个可恢复源代码的解包器。

<p><a href="https://lobste.rs/s/ohc3cr/unpacking_ioncube">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/12 08:36

# 解包 ionCube:https://dustri.org/b/unpacking-ioncube.html 距离我上次做逆向已经有一段时间了,但趁着 Binary Ninja 十周年 35% 折扣(https://binary.ninja/2026/07/22/10-years-of-binary-ninja.html)的机会,我想,既然我在它们前面浪费了那么多时间,这辈子至少应该为逆向工程软件付一次钱。但现在我掏了 200 美元,总得找点东西来逆向。于是这成了一个绝佳的机会,可以完成对 ionCube(https://ioncube.com/)的逆向并为其编写一个解包器。 给不了解的朋友说明一下:这是一个商业 PHP 加密器:给它喂 `.php` 源代码,它吐出一个混淆后的文件,就像这样: ``` ionCube')." 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":' get-loader.ioncube.com and for')." an instructional video please see".($cli?":\n\nhttp://ioncu.be/LV\n\n":' http://ioncu.be/LV ')."\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= ``` 这个桩代码(stub)只有在 PHP 进程中加载了 ionCube *loader*——一个以原生 `.so` 形式发布的 `zend_extension`——时才会继续执行。运行时,loader 将这段 blob 解密成 Zend VM 字节码,并在其自身静态链接的 Zend VM 副本上执行。原始源代码永远不会落到磁盘上,但恢复它应该是可行的。 由于我运行的是 Asahi Linux(https://asahilinux.org/),我看了 aarch64 Linux 版 loader `ioncube_loader_lin_8.5.so`(2b2dd97f8ef09bdabb4937a5b2e1505c8bfd9649106b2cc9295dd12428ae61c4(https://www.virustotal.com/gui/file/2b2dd97f8ef09bdabb4937a5b2e1505c8bfd9649106b2cc9295dd12428ae61c4)),可以在这里(https://www.ioncube.com/loaders.php)免费下载。我用 ionCube 的评估版(https://www.ioncube.com/eval_linux)来加密测试文件。 这篇博客文章会相当长,因为 ionCube 有点复杂,同时又会比较短,因为不是所有细节都会被详述或提到:我的目标是用 Binary Ninja 找点乐子,看看它与我常用工具套件相比表现如何,而不是发布一个通用的 ionCube 解包器。 loader 是一个 2.3M 的二进制文件,里面至少有:大量与密码学相关的函数(AES、Anubis、Blowfish、CAST5、Twofish、(3)DES、各种 SHA、MD5、Murmur……),来自 LibTomCrypt(https://github.com/libtom/libtomcrypt)(Anubis 是最好的线索,因为基本上没有别的东西会带它);一套重新实现的 PHP Reflection API,很可能是为了防止动态解包;一个自定义反序列化管道,重建了大量 PHP 对象;一个自定义风格的 PHP 解释器;一些静态链接的 libc 代码……这使得它相当不简单。 为了找到入口点,由于 ionCube 是一个 `zend_extension`,它导出了一个 `zend_extension_entry` 符号,可以将其正确类型化为 PHP 结构体。从那里可以找到 `zend_startup_module`,并定位 `MINIT` / `MSHUTDOWN` / `RINIT` / `RSHUTDOWN` / `MINFO` 处理器,然后我们就可以从那里开始逆向。 --- ## 第 0 层:密码学洋葱 加密后的文件不是一个 blob,而是多个嵌套的 blob,这意味着我们必须一层一层地剥开。 ### 1. 编码与容器变换 桩代码(stub)的负载是 base64 编码的,使用自定义字母表(`0-9`,然后 `A-Z`,然后 `a-z`,然后 `+/`),再经过若干变换/容器,最终到达一个序列化的操作码(opcode)流。第一个容器开头的一个魔数(magic value)同时选择用于解密下一层的字节*格式*和 PRNG 种类。loader 支持好几种,但我只看了支持现代 PHP opline 格式的那一种,因为我的样本用的就是它。 ### 2. `op_array` 容器 解析 base64 解码后的流会得到一个小的结构头,后面跟着负载。头部携带下一阶段需要的元数据:长度、一个方法/格式 ID,以及一些种子材料。 ### 3. 派生密钥 负载的解密密钥由导出的(`mgniyd`)且轻度混淆的函数 `0x4446f0` 生成。有趣的是,ionCube 支持五种不同的每文件密钥来源: 1. 一个固定在文件中的 16 字节常量,以四个 `u32` 字存储; 2. 运行时从 PHP 变量中取出的值; 3. 从某个函数的字节码派生的密钥材料,也就是说 loader 实际上会执行一个指定函数并对结果做哈希,这是一个很有趣的反篡改技巧; 4. 磁盘上某个文件的内容; 5. 某种名称混淆魔法,我没有细究。 如果这些都无法解析,loader 会以“no decryption key available”(没有可用的解密密钥)退出。对于未授权/评估版文件(我分析的那些),嵌入的四个密钥字全为零,因此常量路径退化为常量密钥,使得评估版文件完全可以离线解码,太棒了。 ### 4. PRNG 动物园与流密码 从密钥计算出两个 32 位种子: 1. 一个 Jenkins 的 one_at_a_time(joaat)哈希(https://en.wikipedia.org/wiki/Jenkins_hash_function),坑在于 loader 对每个字节做了符号扩展,所以任何 `>= 0x80` 的字节都被当作负数处理,朴素的移植会产生错误的种子,发现这一点非常有趣。 2. 一个 MurmurHash3-32(https://en.wikipedia.org/wiki/MurmurHash),种子常量为 `0x1f`。 这两个值用于播种一个 PRNG,准确地说是一个双 16 位 MWC(https://en.wikipedia.org/wiki/Multiply-with-carry_pseudorandom_number_generator)。两条独立的通道分别以乘数 `18000` 和 `30345` 推进,每个输出字是 `ror32(y, 16) + x`。位置 `i` 的密钥流字节是该字的高字节 `(prng.next() >> 8) & 0xff`,与密文异或。 一个丑陋的 Python 实现大概长这样: ```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) ``` 这一节叫“动物园”,但刚才我们只讲了 MWC 那种,所以补充一下其余的。三个生成器挂在一个由 `0x51e068` 构建的小型虚表(vtable)后面: - `id == 4`:上述双 16 位 **MWC**; - `id == 5`:一个 **CMWC**,种子为 `0x1000, 0x1001, 0x12df35, 0x1f123bb5, 0x16a`; - `id == 6`:教科书式的 **MT19937**,从 `0x9908b0df` 常量就能看出来。 每个核心都包在同一个 `{seed, next_byte, next_byte_keyed, destroy, free}` 函数指针表后面,外加一个可选的 `aux_key` 层,将 `next_byte()` 与 `aux_key[i % len]` 异或,以增加一点额外的密钥化。具体用哪一个取决于层:每文件的魔数选择用于容器/`op_array` 解密的 MWC/CMWC(我遇到的那种),字符串解密使用 MWC,而由 `ic_prng_create(6)` 构建的 `rjY` opline 管道使用 Mersenne Twister。整个过程中都是同样的“异或密钥流”思路,只是三种不同的核心。 ### 5. 运行时管道 动态地看,以上所有内容都由导出的 `rjY` 函数编排,这个函数有点复杂。Binary Ninja 的类型处理和传播让我感到非常惊喜,在花时间标注所有内容后,结果相当可读: ```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) } } 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) } ``` 这个函数归结为: 1. 创建一个 PRNG,并用直接从序列化 `op_array` 中取出的两个 32 位密钥为其播种; 2. 通过五种可用方法之一解析解密密钥; 3. 根据文件的加密方式,从七种解码器变体中选择一种。在我的例子中,它只是用 PRNG 做简单异或,但其他变体要复杂一些;我看到一些代码看起来像 AES-CTR 和类似 HMAC 的结构。 4. 调用 `op_array` 构建/解码回调。 ### 6. 明文的结构 序列化的 `op_array` 有一个头部,受 `+0x7c` 处的一个 Adler 风格校验和保护,累加器在头部字节上以*有符号*字符运行(又是同样的符号扩展坑,是不是很爽?),并与 `s1 | (s2 << 8)` 比较,很可能是为了捕捉愚蠢的补丁/位翻转尝试。在 `+0x30` 处还有操作码数量,以及一个字面量池(literal pool),包含 PHP 代码引用的常量/字符串/数字,被重建为某种 `zval` 数组,然后根据 PHP 版本处理,并对 `zend_string` 有一些特殊处理。此时我们有了常量,但还没有池后面存储的解码后的操作码。 --- ## 第 1 层:一个不完全是 Zend 的 Zend VM 标准 PHP 是一个“线程化”(threaded)解释器:每个 `zend_op` 都携带一个 `handler` 指针,执行器在 handler 之间跳转。ionCube 保留了这种结构,但在两个主要方面做了扭曲。 ### 操作码是异或加密的,每次分发时解密 每个 `zend_op` 的 handler 字段都以加密形式存储。在分发时,loader 用一个每-opline 的密钥将其解密: ```c key_table = (*(base + 0x257080) -> +160)[ op_array->reserved_index ] handler[i] = enc[i] ^ (int64_t)(int32_t)(key_table[i] * 0x01010101) ``` 一旦解密,handler 指针就正好落在 loader 自己那份 Zend VM 副本内部。现在的问题只是恢复某个 opline 对应哪个 `ZEND_*_SPEC_*_HANDLER`。但一旦恢复了映射,就能得到基础操作码及其操作数种类,这足以恢复字节码。 handler 簇在内存中是连续的,包含约 1000 个特化 handler,以及约 100 个 `ZEND_*_WORKER` 尾调用。恢复它们*相当有趣*。我手工做了一些(Binary Ninja 全面的撤销支持真是太好了),然后在一个带符号编译的 PHP 解释器上使用了 WARP(https://docs.binary.ninja/guide/warp.html)。随后我注意到,包含每个操作码指针的数据结构,其字段顺序与 PHP 的 `zend_vm_execute.h` 文件中的 `labels[]` 数组相同,太好了!这里唯一需要注意的是,你需要使用与 ionCube 完全相同的 PHP 版本,这个可以通过在 loader 上运行 `strings | grep -e 'API' -e 'php version'` 找到。Zend 会做一些奇怪的事情,所以并非每个操作码都会出现在那里,但其余部分可以很快手工完成。 ### `opline++` 分发技巧 这一个花了我整整一个下午才理解。在常规 PHP 中,没有一个共享的“前进”步骤,每个 handler 都负责自己移动 `opline`:顺序操作码以 `ZEND_VM_NEXT_OPCODE()` 结束,这实际上就是 `opline++`,而跳转操作码以 `ZEND_VM_SET_OPCODE(target)` 结束,写入绝对目标并继续。因此跳转会精确落在它指向的位置,存储的位移就是到目标的普通距离。 另一方面,ionCube 的重新线程化 loader 将其压缩成一个循环,对每个操作码无条件执行递增: ```c while (running) { handler(opline); // handler 不再自己推进 opline opline++; // 即使跳转后,循环也总是执行这个 } ``` 顺序操作码很容易,因为 handler 什么也不做,循环的 `opline++` 前进一条指令,与之前相同。但跳转操作码是个问题:handler 设置 `opline = target`,但之后循环仍然执行 `opline++`,这会过冲到 `target + 1`。ionCube 的跳转 handler 通过将目标存储/设置为 `target − 1` 来补偿,这意味着将存储的跳转偏移转换为 opline 索引时需要一个额外的 `-1`。听起来可能不算什么,但到处都是 off-by-one 错误,调试起来极其烦人。 --- ## 第 2 层:最简单的反汇编器就是 loader 本身 此时,我开始重新实现……

相似文章

Codex发现一个隐藏的HTTP/2炸弹

Lobsters Hottest

Codex发现了一个名为“HTTP/2炸弹”的远程拒绝服务漏洞,该漏洞针对主流Web服务器(包括nginx、Apache、IIS、Envoy、Pingora)中的HPACK压缩,通过将压缩炸弹与流量控制保持相结合,快速耗尽服务器内存。