解包 ionCube
摘要
一名安全研究人员在 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 本身
此时,我开始重新实现……
相似文章
@nebusecurity: GhostLock (CVE-2026-43499) 是一个15年历史的内核0day漏洞,我们在IonStack全链利用中使用了它。你周围的一切,如同……
GhostLock (CVE-2026-43499) 是一个15年历史的Linux内核0day漏洞,被用于IonStack全链利用,影响从物联网到桌面设备的所有Linux设备。Nebu Security赢得了92,337美元的漏洞赏金,并在GitHub上发布了利用代码。
@0x0SojalSec: AI Ghidra 和 Radare2:AI 驱动的逆向工程。能够反汇编、反编译、使用 YARA 扫描等的 AI 代理,以及…
Reversecore MCP 是一款企业级的 AI 驱动的逆向工程与安全分析工具,通过模型上下文协议(MCP)与 AI 助手集成,提供超过 50 种工具,用于静态/动态分析、恶意软件分析、漏洞研究等。
@vintcessun: 发现新思路:编码 agent 原来可以轻到这个程度。16MB 内存、0% 空闲 CPU、26MB 二进制——这数据在 agent 圈里有点离谱。 它用 Rust 硬怼,17k LoC 就把所有标准工具、权限系统、会话管理、MCP 都塞进去…
Zerostack 是一个用 Rust 编写的极简编码 agent,仅消耗 16MB 内存和 26MB 二进制,通过 feature gate 实现轻量化,支持多种提供商和工具。
逆向工程 Claude Web 的 MicroVM:揭秘 Anthropic 的隐藏应用托管平台
这篇博客文章详细介绍了对 Claude Code Web 运行时环境的逆向工程过程,揭示其使用了 Firecracker MicroVM,并发现了 Anthropic 未公开的应用托管平台。
Codex发现一个隐藏的HTTP/2炸弹
Codex发现了一个名为“HTTP/2炸弹”的远程拒绝服务漏洞,该漏洞针对主流Web服务器(包括nginx、Apache、IIS、Envoy、Pingora)中的HPACK压缩,通过将压缩炸弹与流量控制保持相结合,快速耗尽服务器内存。