【Android安全- X-Perseus AI初窥】此文章归类为:Android安全。
这个文档用于按时间顺序记录 X-Perseus 的逆向过程。
当前分析不是直接对原始 trace 文本逐行做,而是先把执行日志转换进 DuckDB,再围绕统一的 step 号做联查。
这里最重要的三张表是:
| 表名 | 作用 | 关键字段 | 说明 |
|---|---|---|---|
instructions |
指令主表 | step, address, offset, instruction, semantic |
每个 step 对应一条执行指令 |
registers |
寄存器快照表 | step, reg_name, reg_value, timing |
同一 step 下会记录执行前后的寄存器值,timing 分为 before/after |
memory |
内存访问表 | step, mem_addr, mem_value, mem_type, size |
记录该 step 触发的读写内存行为 |
三张表之间的关系非常简单:
instructions.step = registers.step = memory.step
所以后文里反复出现的分析方式,本质上都是:
stepstep 向前或向后回溯读这份文档时,可以把它理解成下面这个映射:
一条原始执行日志
-> 归并成一个全局 step
-> 该 step 下拆成:
指令信息(instructions)
寄存器快照(registers)
内存访问(memory)
后文如果提到:
instructions + registers(before)instructions + registers(after) + memorymemory 表里的历史 write还有两个查询习惯需要先说明:
offset 在 SQL 里建议写成 "offset",避免和保留字冲突。semantic 字段,因为它已经把寄存器/内存结果解析成了可搜索文本。这一节只记录第一次把 X-Perseus 从最终 7 神输出里准确摘出来,并建立首版拷贝链的过程。此时的目标还不是解释算法,而是先把“字符串在哪、从哪搬过来”这两个问题钉死。
先在最终输出缓冲区里确认 X-Perseus 参数的准确位置,再建立最早一版“它从哪里拷贝过来”的上游链路。
最开始不是从单独的 X-Perseus 字符串入手,而是先重建最终整块参数输出缓冲区:
uv run python scripts/query_trace_db.py mem-reconstruct 13501921 0x40642000 0x40642500 --write-limit 0
当时直接确认到的基础信息是:
0x40642000135019210x40642000 - 0x40642500X-Perseus,而是最终拼接好的整块参数输出X-Argus、X-Gorgon、X-Helios、X-Khronos、X-Ladon、X-Medusa、X-Neptune、X-Perseuswrite当时重建结果里最关键的 hexdump 片段是:
40642000 58 2d 41 72 67 75 73 0d 0a 4a 61 57 42 61 51 3d |X-Argus..JaWBaQ=|
40642010 3d 0d 0a 58 2d 47 6f 72 67 6f 6e 0d 0a 38 34 30 |=..X-Gorgon..840|
...
406421f0 65 70 74 75 6e 65 0d 0a 2d 31 31 7c 35 30 3a 35 |eptune..-11|50:5|
40642200 31 3a 35 39 0d 0a 58 2d 50 65 72 73 65 75 73 0d |1:59..X-Perseus.|
40642210 0a 4c 70 4c 4c 4c 46 6a 4b 2b 32 4e 6a 63 70 4a |.LpLLLFjK+2NjcpJ|
重建 0x40642000 后,先看到的不是单独一个参数,而是一整块已经拼接好的最终输出,其中包含:
X-ArgusX-GorgonX-HeliosX-KhronosX-LadonX-MedusaX-NeptuneX-Perseus在这块总输出里,X-Perseus 的定位结果是:
0x406422060x406422110x406424f4740 字节当时摘出的 X-Perseus 区间 hexdump 开头是:
40642200 31 3a 35 39 0d 0a 58 2d 50 65 72 73 65 75 73 0d |1:59..X-Perseus.|
40642210 0a 4c 70 4c 4c 4c 46 6a 4b 2b 32 4e 6a 63 70 4a |.LpLLLFjK+2NjcpJ|
40642220 71 4c 4f 53 31 48 6c 49 6f 72 41 75 44 6f 66 57 |qLOS1HlIorAuDofW|
40642230 71 31 61 4c 2f 70 6f 51 53 49 34 73 2b 74 66 2b |q1aL/poQSI4s+tf+|
并且当时已经直接得到完整参数值:
LpLLLFjK+2NjcpJqLOS1HlIorAuDofWq1aL/poQSI4s+tf+gq+8Mbb+vUoDRJ3Fs7W5ZQ3aXT9zqBzHC84DhCMkAJn5iCQuiEZvDPZbtlwHPuOPXZBoqAs1jHOfpEMXZ+oC0tJZu6PBrgVonka6qZk5ZL0rmJiHRMmUKNTdXU4898AC5squ6Vscm4QzITlCH1LVRpLZk4NbK+Vkm615gppA3I0Xy0I3joroLsPFXO2ynGAzdUflnSFWgEv5PJTzXbgnHmlI6C5fi8yrdbPOpWxU4ftBXoH8AVx6yIuRWqW7OBR9qq1K2XKbxM0iDMKd0KafUxooDPFm8EcaLzV0WlVSqqA2JqjTRTL08j0bqHRom54563/64EDxopjfE/48sXCuSrFOBAjYLKF5BaoXgLcqQHrrxWCYafdwlY6GgUKSFe3rnD0aOHxN2RY5qD2ZGAs7Kuy1xbNBiJxrI3wRiKugx+m97izex/zlBwVQjtg5/NPtJWY6t0Tu9XG3/Bq+Liz32oKIv9PUOphulUEjbrfYy9Lhlcs1l6Ik91rFnURmhIMuqBvdZDnRCVknREVppeY44lVIlI2ISk/ldrgstpD/1MvhmbNXBXF3p3d7af79f+yq3FXjVI16C3l+D1/nKeRRjmdBdg8ZXayXk9kcreGqP72fGGNcfmxaAgMCsUZgnGgGGgAlBPGI94BXBKeXEIrHny5nHNgD/y7okXwIyCOqSW3H0xfUdsX8=
在完成最终输出定位后,最早的直觉是:
0x40642000 只是最后的总输出缓冲区。X-Perseus 很可能先在某个中间缓冲区里生成,再被拼接/拷贝进总输出。0x40642206 这个偏移很关键,因为它正好是 X-Perseus 在最终大缓冲区中的起始位置。基于这个偏移,最早建立的链路猜测是:
0x408be000X-Perseus 子区间来自 0x406258000x40624400随后沿着 0x40642000 逆着 trace 看调用参数和内存流向,做了三类验证。
第一类是整块最终拷贝:
step 13501708x0 = 0x40642000,x1 = 0x408be000,x2 = 0x4f70x408be000 拷贝到 0x40642000第二类是子区间偏移验证:
step 13501424x0 = 0x408be206,x1 = 0x40625800,x2 = 0x2f10x206 正好等于 X-Perseus 在最终大缓冲区中的起始偏移,所以最早就把 0x40625800 -> 0x408be206 识别成 X-Perseus 子区间搬运链第三类是前缀源块验证:
step 13501392x0 = 0x408be000,x1 = 0x40624400,x2 = 0x2060x40624400 和最终拼接缓冲区的前缀区联系起来同时又用 mem-trace 做了交叉验证:
0x40642000 <- 0x408be0000x40642210 <- 0x408be2100x406424e7 <- 0x408be4e7当时按 trace 得到的最早版拷贝链证据是:
step 13501708:
x0 = 0x40642000
x1 = 0x408be000
x2 = 0x4f7
step 13501392:
x0 = 0x408be000
x1 = 0x40624400
x2 = 0x206
step 13501424:
x0 = 0x408be206
x1 = 0x40625800
x2 = 0x2f1
这里 0x206 很关键,因为:
0x40642000 + 0x206 = 0x406422060x408be000 + 0x206 = 0x408be206也就是说,最开始就是用这个偏移把 X-Perseus 在最终输出里的位置,和 0x408be206 这段子区间连起来的。
到这一步,最早稳定下来的定位结果有两部分。
第一部分是参数定位本身:
X-Perseus 在最终总输出缓冲区中的准确范围第二部分是最初版本的上游链路:
0x40624400 --(0x206 bytes)--> 0x408be000
0x40625800 --(0x2f1 bytes)--> 0x408be206
0x408be000 --(0x4f7 bytes)--> 0x40642000
这一步虽然还没进入算法细节,但已经完成了两个关键工作:
X-Perseus 从整块输出里准确摘出来这一步后面有一个重要修正。
最初我们一度把 0x40625800 看得太“靠前”了,像是在把它当作 X-Perseus 的主要上游结果区。但后续用户补充并确认:
0x40624c00这带来两个修正:
0x40642000 <- 0x408be000 <- 0x40625800 这条链并没有错,但它只是后段搬运/拼接链,不是最早的成品生成链。0x40625800、0x408be206、0x408be000 更像中间缓冲区;真正应该优先继续追的是:0x40624c00 -> 0x40625800 / 0x408be206 / 0x40642000
这个修正当时依赖的关键事实是:
0x40624c00step 13433848 重建 0x40624c00 可以直接得到完整 X-Perseus 字符串step 13500742 调用时,已经把 x1 = 0x40624c00 传给外部函数,参数为:step 13500742:
x0 = 0x4062540b
x1 = 0x40624c00
x2 = 0x2e4
target = 0x40281600 -> br x17 -> 0x4046c300
也正是因为这一段外部函数体没有被当前 trace 展开,所以当时出现了一个重要方法论修正:
0x40625800 当成最早成品区bl -> 跳板 -> br x17 这类外部路径时,不能只靠内存溯源结果本身,必须同时分析调用前参数在把 0x40625800 修正为“中间缓冲区”之后,Step 1 实际上还多完成了一件事:确认了真正的成品字符串区是 0x40624c00。
这仍然属于“参数定位”而不是“算法拆解”,因为这里解决的问题依然是:
X-Perseus 字符串最早稳定落在哪块内存当时支撑这个判断的直接证据有三组。
第一组证据是重建结果本身:
uv run python scripts/query_trace_db.py mem-reconstruct 13433848 0x40624c00 0x40624ee5
在这个 step 重建 0x40624c00 后,可以直接得到完整 X-Perseus 字符串,而不是某种二进制中间态。这一点和 0x40624400 那种主体输入块的形态完全不同。
第二组证据是调用参数:
step 13500742:
x0 = 0x4062540b
x1 = 0x40624c00
x2 = 0x2e4
target = 0x40281600 -> br x17 -> 0x4046c300
这说明在后续外部函数调用前,0x40624c00 已经作为源地址放进 x1,长度 0x2e4 也与成品字符串长度一致。换句话说,这一步看到的不是“正在生成字符串”,而是“已经拿着成品字符串去做下一步处理”。
第三组证据是生成方式:
0x40624c00 不是通过一次性整块 memcpy 得到strb w10, [x8, x9] 逐字节写入offset 0x1e865c 和 offset 0x1f9d10ldrb 从自定义字符表地址取单字节字符这带来一个当时很重要的定位结论:
0x40624c00
= 已经完成字符映射后的成品字符串区
0x40625800 / 0x408be206 / 0x408be000
= 后续搬运、拼接、封装过程中的中间缓冲区
也就是说,Step 1 最终不只是把 X-Perseus 在最终大缓冲区中的位置找出来,还把“最早可确认的成品字符串落点”一起钉死了。
Step 1 本身不是算法分析,而是“定位 + 切片 + 首版链路建立”。因此这里给出的 Python 更偏向于把最终输出里 X-Perseus 摘出来,并把首版拷贝链表达成结构化数据:
from dataclasses import dataclass
@dataclass(frozen=True)
class PerseusSlice:
header_addr: int
value_addr: int
value_end: int
value: bytes
def locate_x_perseus_from_final_output(
full_output: bytes,
base_addr: int = 0x40642000,
) -> PerseusSlice:
marker = b"X-Perseus\r\n"
header_off = full_output.index(marker)
value_off = header_off + len(marker)
value_end_off = full_output.index(b"\r\n", value_off)
return PerseusSlice(
header_addr=base_addr + header_off,
value_addr=base_addr + value_off,
value_end=base_addr + value_end_off - 1,
value=full_output[value_off:value_end_off],
)
def first_copy_chain() -> list[tuple[int, int, int]]:
"""
返回 Step 1 最早稳定下来的首版搬运链:
(src, dst, size)
"""
return [
(0x40624400, 0x408BE000, 0x206),
(0x40625800, 0x408BE206, 0x2F1),
(0x408BE000, 0x40642000, 0x4F7),
]
Step 1 最终固定下来的内容有四点:
参数定位已经明确:
X-Perseus 头部从 0x40642206 开始0x40642211 开始,到 0x406424f4 结束740 字节当时能直接从 trace 看见的,是一条后段搬运链:
0x40625800 -> 0x408be206 -> 0x40642000
以及与前缀区相关的:
0x40624400 -> 0x408be000
真正的成品字符串区也已经定位出来:
0x40624c00 可以直接重建出完整 X-Perseusx1 被传给外部函数后续修正也在这一步埋下了边界:
0x40624c00在已经定位到成品字符串区 0x40624c00 之后,下一步要判断这串 740 字节文本到底是什么编码产物。这里的目标不是继续追更早输入,而是先回答两个问题:
0x40624c00 这串文本是不是 Base64 类编码Step 2 的起点不再是最终输出缓冲区,而是已经确认的成品字符串区 0x40624c00。
当时最直接的观察对象就是它的开头:
LpLLLFjK+2NjcpJqLOS1HlIorAuDofWq...
第一眼看上去,它的字符集非常像 Base64:
+、/740=但它又明显不是标准 Base64 输出,因为标准 Base64 里更常见的开头不会是这种 LpLLLF... 形态,而且同样输入如果按标准表编码,开头字符并不对应这些结果。
因此当时的起点判断是:
base + index 去某个固定字符表取字节strb 写入 0x40624c00围绕 0x40624c00 的逐字节写入继续看 trace 后,很快出现了几组关键现象。
第一组现象是写出方式:
0x40624c00 不是一次性 memcpy 出来的offset 0x1e865c 和 offset 0x1f9d10strb w10, [x8, x9] 这种逐字节写回第二组现象是字符来源:
ldrbL 命中 0x4065e7adp 命中 0x4065e7bd把开头四个字符抽出来看,证据链是:
| 输出地址 | 字符 | 关键 step | 查表地址 | 索引 |
|---|---|---|---|---|
0x40624c00 |
L |
13305555 |
0x4065e7ad |
0x00 |
0x40624c01 |
p |
13305623 -> 13305697 |
0x4065e7bd |
0x10 |
0x40624c02 |
L |
13305799 |
0x4065e7ad |
0x00 |
0x40624c03 |
L |
13305466 |
0x4065e7ad |
0x00 |
这说明至少开头四个字符已经满足:
输出字符 = 字符表[某个 6-bit 索引]
第三组现象是索引模式本身很像标准 Base64。
当时拿 step 13305695 附近的输入字节去对照,看到 0x40639280 开头是:
01 00 00 00 13 77 95 46 0d
其中前 3 个字节 01 00 00 如果按标准 Base64 分组拆成 6-bit 索引,得到的正好是:
[0, 16, 0, 0]
而这 4 个索引映射出来的字符正好就是:
0 -> L
16 -> p
0 -> L
0 -> L
也就是开头的 LpLL。
到这一步,当时的猜测基本收敛成了两条:
X-Perseus 最后一层编码,分组规则仍然是标准 Base64。换句话说,当时最可能的模型已经变成:
输入字节流
-> 标准 Base64 的 6-bit 分组
-> 不走标准字符表
-> 改走一张自定义 64 字符表
-> 逐字节写到 0x40624c00
如果这个猜测成立,那么继续向前应该还能看到:
554 bytes -> 740 chars0x40624c00随后做了三层验证。
第一层验证是开头分组逐组对齐。
把 0x40639280 开头几组输入按 3 字节切开后,前 5 组结果分别是:
| 分组 | 输入字节 | 标准 Base64 索引 | 实际输出 |
|---|---|---|---|
| 0 | 01 00 00 |
[0, 16, 0, 0] |
LpLL |
| 1 | 00 13 77 |
[0, 1, 13, 55] |
LFjK |
| 2 | 95 46 0d |
[37, 20, 24, 13] |
+2Nj |
| 3 | 25 0e 59 |
[9, 16, 57, 25] |
cpJq |
| 4 | 00 21 a2 |
[0, 2, 6, 34] |
LOS1 |
这一步的意义很直接:
第二层验证是自定义字符表本身。
后续把当前样本中用到的字符表整理出来,得到:
LFOH7gSlYcbaijkWpfR62ICGNqesUBxVMm1/Q+0ZrhwnAz9TD4yv8udK3JXt5EoP
这张表和标准 Base64 字符表:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
的关系很明确:
64第三层验证是完整输入与完整输出的闭合。
当时已经把输入缓冲区定位到:
0x406392800x406394a9554 字节而成品字符串区是:
0x40624c00740 字节随后对 0x40639280..0x406394a9 整段输入做完整编码校验,结果是:
0x40624c00 中的完整 X-Perseus 完全一致到这一步,Step 2 实际上完成了三件关键事。
第一,最后一层编码算法已经识别出来:
不是标准 Base64 文本
而是“标准 Base64 分组 + 自定义 64 字符表”
第二,输入缓冲区已经定位出来:
0x40639280554 字节0x40624c00740 字节第三,继续向前追的入口也明确了:
X-Perseus,下一步就不该再盯着 0x40624c000x40639280 这块 Base64 输入缓冲区是如何形成的也就是说,Step 2 的结束点是:
0x40639280
--(标准 Base64 分组 + 自定义字符表)-->
0x40624c00
Step 2 对应的 Python 可以直接写成一个最小可运行的“魔改 Base64”编码器。它保留标准 Base64 的 3-byte -> 4-index 分组,只替换索引到字符的映射表:
CUSTOM_B64_TABLE = b"LFOH7gSlYcbaijkWpfR62ICGNqesUBxVMm1/Q+0ZrhwnAz9TD4yv8udK3JXt5EoP"
def encode_perseus_custom_base64(data: bytes) -> str:
out: list[str] = []
for i in range(0, len(data), 3):
chunk = data[i:i + 3]
pad = 3 - len(chunk)
chunk = chunk + b"\x00" * pad
word = (chunk[0] << 16) | (chunk[1] << 8) | chunk[2]
idx = [
(word >> 18) & 0x3F,
(word >> 12) & 0x3F,
(word >> 6) & 0x3F,
word & 0x3F,
]
chars = [chr(CUSTOM_B64_TABLE[j]) for j in idx]
for j in range(1, pad + 1):
chars[-j] = "="
out.extend(chars)
return "".join(out)
assert encode_perseus_custom_base64(bytes.fromhex("01 00 00")) == "LpLL"
这一步里也有一个很重要的修正。
最初如果只看字符串长相,很容易把它理解成:
但 Step 2 做完之后,这两种模糊说法都需要收敛成更精确的表述:
它不是“完全不同于 Base64 的编码”。
对后续逆向来说,真正有价值的入口不是“继续找标准 Base64 函数”。
0x40639280 的来源Step 2 最终固定下来的内容有四点:
0x40624c00 的成品字符串确实是 Base64 类编码产物,但不是标准字符表。输入字节流
-> 标准 Base64 6-bit 分组
-> 自定义 64 字符表映射
-> 逐字节写到 0x40624c00
LFOH7gSlYcbaijkWpfR62ICGNqesUBxVMm1/Q+0ZrhwnAz9TD4yv8udK3JXt5EoP
0x40639280 是下一步最该分析的对象Step 2 已经确认:
0x40639280
--(标准 Base64 分组 + 自定义字符表)-->
0x40624c00
因此 Step 3 的目标就变成了:
这一节的起点是:既然 0x40624c00 的开头 LpLL 已经能对上 0x40639280 的前 3 个字节,那么下一步就不再只看开头几组,而是重建整段输入缓冲区,看看它的完整形态。
当时最先固定下来的边界是:
0x406392800x406394a90x406394aa554 字节(0x22a)也就是说,从这一步开始,0x40639280..0x406394a9 被整体视为:
X-Perseus 最后一层字符串编码的完整输入字节流
把整段输入缓冲区重建出来之后,最明显的观察不是“它像一段随机连续二进制”,而是它内部其实分成了几段角色不同的区域。
先看开头 32 字节:
40639280 01 00 00 00 13 77 95 46 0d 25 0e 59 00 21 a2 0c
40639290 75 7e a2 cd 70 f9 13 d9 88 b0 23 43 e9 06 57 16
这段数据很快暴露出一个结构特征:
0x40639280..0x40639288:像固定头部和分隔字节0x40639289..0x40639295:像一小段固定中间字段0x40639296..0x406394a9:后面是一大段连续主体当时按偏移整理出来的结构是:
| 偏移 | 地址 | 内容角色 |
|---|---|---|
+0x00 |
0x40639280 |
固定头部 8 字节 |
+0x08 |
0x40639288 |
分隔字节 0x0d |
+0x09 .. +0x15 |
0x40639289..0x40639295 |
固定中间字段 13 字节 |
+0x16 起 |
0x40639296..0x406394a9 |
主体输入区 |
这里还有一个非常关键的观察:
0x40639296 上来的前两个 64 位值,正好是:0x06e94323b088d9130x5d664519ede516570x40624400、0x40624408 对上也就是说,这时已经能感觉到:
0x40639280 不是纯固定常量为了让这一节可直接阅读,完整输入缓冲区的 hexdump 也贴在这里:
40639280 01 00 00 00 13 77 95 46 0d 25 0e 59 00 21 a2 0c |.....w.F.%.Y.!..|
40639290 75 7e a2 cd 70 f9 13 d9 88 b0 23 43 e9 06 57 16 |u~..p.....#C..W.|
406392a0 e5 ed 19 45 66 5d 20 28 a9 73 73 ec 12 e7 80 5b |...Ef] (.ss....[|
406392b0 10 ff 27 93 82 fa be eb 59 76 d0 d6 d3 1c 29 5a |..'.....Yv....)Z|
406392c0 03 ac e6 bf 0c 5a 4d 4c f6 7c f0 fe 72 bb 1e a0 |.....ZML.|..r...|
406392d0 ff d4 2f fa 9d df 99 b1 b8 8d 0c 24 50 f6 0e a7 |../........$P...|
406392e0 97 e5 a6 ef 99 f5 4f f7 68 15 ff ab 38 b4 d9 9c |......O.h...8...|
406392f0 ef 27 02 6a 21 e4 c0 d2 82 17 37 62 fd ba 73 1d |.'.j!.....7b..s.|
40639300 2e d2 c5 bc 6d 9d 53 7d b2 61 c6 4b 55 bc 75 83 |....m.S}.a.KU.u.|
40639310 88 07 d2 40 09 ce c5 82 b7 95 f3 a1 4e 2f 05 41 |...@........N/.A|
40639320 0b 38 56 6e b2 99 5e 0d fa 8f 80 6f f0 7a 09 4c |.8Vn..^....o.z.L|
40639330 ab 5e cb 76 71 11 eb 18 13 c5 f7 3f 3f e6 fb 7a |.^.vq......??..z|
40639340 28 5a c3 84 75 53 5b c4 4c d3 2a 36 2b f0 90 3d |(Z..uS[.L.*6+..=|
40639350 e7 31 47 b7 7a f8 3d 2c 7d e4 f2 57 54 8f 64 f1 |.1G.z.=,}..WT.d.|
40639360 02 75 2b 99 66 2d d4 eb 72 9e 82 63 30 83 7d a6 |.u+.f-..r..c0.}.|
40639370 dc b4 5c 7b ef b0 fc 18 74 f4 92 c0 b5 f9 8f 1d |..\\{....t.......|
40639380 f1 99 66 c5 39 64 db d2 bc 09 b4 36 62 99 0d 2f |..f.9d.....6b../|
40639390 a1 f3 1f 13 e2 34 f1 f7 07 be 40 d4 7d 8f 1d 1b |.....4....@.}...|
406393a0 e9 6d 46 a0 10 9d b0 d2 00 dc 1f 1d 2f ee 85 00 |.mF........./...|
406393b0 96 64 0e 8a 1e 3d 62 0b 47 6a 87 21 35 c5 73 71 |.d...=b.Gj.!5.sq|
406393c0 81 6b 8a 2b c2 62 c2 0d e6 14 48 8f 19 c1 49 d7 |.k.+.b....H...I.|
406393d0 b1 b1 37 d7 28 9e 29 87 4c e5 ea 15 e2 a4 8c df |..7.(.).L.......|
406393e0 51 5e 96 1b 84 32 d6 9e 8e d1 dd a9 f9 0d ec 5f |Q^...2........._|
406393f0 23 63 fe f9 3c 84 fb 9a fd 6e e9 7e 23 75 99 40 |#c..<....n.~#u.@|
40639400 32 de 14 fb 75 73 bb f7 02 42 9d 47 73 d3 4a a1 |2...us...B.Gs.J.|
40639410 12 32 b8 0a 47 25 b8 87 4d 53 ae 8a 80 6b 71 28 |.2..G%..MS...kq(|
40639420 69 56 0d 59 77 3d a7 c2 b4 96 7c ea d2 f5 f4 10 |iV.Yw=....|.....|
40639430 68 8c 71 1d f5 47 55 45 46 3a 31 f6 a0 56 fb 43 |h.q..GUEF:1..V.C|
40639440 08 e2 83 3a 61 29 8e 9d e8 1e 10 e3 61 0b 44 4b |...:a)......a.DK|
40639450 91 97 26 78 07 a3 5f 56 24 d6 e0 79 70 8a 3a f7 |..&x.._V$..yp.:.|
40639460 69 24 8d 87 67 76 17 49 fa 2f 2e 8e b8 e2 68 69 |i$..gv.I./....hi|
40639470 76 7f 11 44 57 5d 82 51 85 e2 ec 16 05 9b 72 71 |v..DW].Q......rq|
40639480 6b 5c 55 d7 16 c1 dd fd 75 6e c5 de 9d dd ae bd |k\\U.....un......|
40639490 56 80 eb cb ca c3 60 5c 23 c8 4f 8e ea a5 72 58 |V.....`\\#.O...rX|
406394a0 17 36 6f ad 00 00 00 00 00 00 |.6o.......|
基于这份结构,当时的猜测开始收敛成下面这个模型:
0x40639280..0x406394a9
= 固定头部
+ 固定中间字段
+ 一整块更早生成的二进制主体
如果这个模型成立,那么继续看 trace 时应该能看到两类不同来源:
0x40639296 之后的大块主体,应该从某个更早缓冲区整体搬进来。也就是说,这一步的关键不再是“它是不是 Base64 输入”,而是“这块输入是不是由多个来源拼出来的”。
随后做了三类验证。
第一类验证是完整编码闭合。
把 0x40639280..0x406394a9 整段作为输入,用 Step 2 已确认的“标准 Base64 分组 + 自定义字符表”去编码,结果与 0x40624c00 中的 740 字节成品字符串完全一致。
这一步的意义是:
554 bytes -> 740 chars 全部闭合0x40639280 可以被稳定视为“完整输入缓冲区”,而不是局部样本第二类验证是来源分层。
当时把输入区按来源整理后,得到的分层结果是:
| 输入区间 | 长度 | 来源判断 |
|---|---|---|
0x40639280..0x40639288 |
9 字节 |
固定头部 + 分隔字节 |
0x40639289..0x40639295 |
13 字节 |
固定中间字段 |
0x40639296..0x406394a9 |
0x214 字节 |
主体输入块 |
其中最关键的是第三段,因为它对应了后续真正的算法主体。
这里还有一条很适合直接写进文档的来源细节:
0x40639289..0x40639295 这 13 字节固定中间字段,不是主体算法算出来的x1 = 0x40abf443第三类验证是主体搬运调用本身。
在 step 13300144,可以直接看到一条把主体块搬进输入缓冲区的调用:
step 13300144:
x0 = 0x40639296
x1 = 0x40624400
x2 = 0x214
target = 0x401e3040
这条调用的含义非常直接:
0x40624400 开始取 0x214 字节0x40639296 开始的位置这就把两层关系第一次明确连起来了:
0x40624400..0x40624613
--(整块搬运 0x214 bytes)-->
0x40639296..0x406394a9
Step 3 对应的 Python 更适合写成“输入缓冲区组装器”。因为这一节的核心发现就是:完整输入并不是单源,而是由头部、中间字段和主体三段拼起来的。
PERSEUS_INPUT_HEAD_9 = bytes.fromhex(
"01 00 00 00 13 77 95 46 0d"
)
PERSEUS_INPUT_MID_13 = bytes.fromhex(
"25 0e 59 00 21 a2 0c 75 7e a2 cd 70 f9"
)
def build_perseus_base64_input(body_preobf_final: bytes) -> bytes:
assert len(body_preobf_final) == 0x214
return PERSEUS_INPUT_HEAD_9 + PERSEUS_INPUT_MID_13 + body_preobf_final
def split_perseus_base64_input(buf: bytes) -> tuple[bytes, bytes, bytes]:
assert len(buf) == 0x22A
head = buf[:0x09]
middle = buf[0x09:0x16]
body = buf[0x16:]
return head, middle, body
到这一步,Step 3 固定下来的结论主要有三条。
第一,完整 Base64 输入缓冲区已经明确:
0x40639280..0x406394a9554 字节0x40624c00 的 740 字节成品字符串第二,这块输入不是单源数据,而是分层拼出来的:
固定头部/分隔字节
+ 固定中间字段
+ 主体输入块
第三,主体输入块的上游入口已经明确切到:
0x40624400
因为从 0x40639296 往前追,第一条最稳定、最明确的连接就是 step 13300144 这次整块搬运。
这一步里最重要的修正是:不能把 0x40639280 整段都当成“同一种来源”的数据。
如果只看它最终被整体编码的样子,很容易误以为:
但 Step 3 做完之后,这种理解必须拆开:
0x40639280..0x40639295 并不是主体算法本身。
真正需要继续深挖的,是从 0x40639296 开始的 0x214 字节主体。
0x40624400 直接相连Step 3 最终固定下来的内容有四点:
0x40639280..0x406394a9
它不是单一来源,而是由三段角色不同的数据组成:
主体输入块的稳定上游入口已经明确:
0x40624400..0x40624613
-> 0x40639296..0x406394a9
0x40624400”。从 Step 3 开始,真正需要解释的对象已经收缩到:
0x40624400..0x40624613
这段 0x214 字节就是后面搬到 0x40639296 的主体输入块。问题在于,继续往前追以后会发现:
addmvnorr所以 Step 4 的任务不再是“继续平铺直叙地沿时间往前追”,而是要把这块主体区按层次拆开,回答下面几个问题:
这一步的起点仍然是 Step 3 已经确认的稳定入口:
step 13300144:
x0 = 0x40639296
x1 = 0x40624400
x2 = 0x214
也就是说,只要解释清楚 0x40624400..0x40624613 这 0x214 字节,最后一层 Base64 输入主体就解释清楚了。
在这一阶段,文档里开始统一给这块区域起一个名字:
BODY_PREOBF = 0x40624400..0x40624613
这里的含义不是“它在 trace 里天然就叫这个名字”,而是为了后续讨论方便,把“Base64 主体输入对应的二进制原像缓冲区”统一记作 BODY_PREOBF。
不过这里要先把一个容易混淆的点说清楚:
| 名称 | 含义 |
|---|---|
BODY_PREOBF_FINAL |
严格指 step 13300144 时、真正参与搬运到 0x40639296 的运行时最终主体层 |
BODY_PREOBF |
分析别名;在 BODY_PREOBF_FINAL 基础上,把开头前 16 字节恢复成位混淆前原值,方便读懂前两组特殊处理 |
后面 Step 4 默认主要使用的是 分析别名 BODY_PREOBF,因为这样读者在同一份 hexdump 上就能同时看懂:
到这里之后,如果还按单线时间写,会很快失去可读性。原因有三个:
同一地址区间上叠了多层状态。
0x40624400 既有最终值 0x93,也有更早的 0xb9,再往前还有 0xa0。有些层是真实落地缓冲区,有些层只是为了分析引入的视图。
APPLE 就不是 trace 中直接存在的真实层,而是为了继续向前追来源而人为恢复出的分析视图。这部分已经天然分成了几层稳定对象:
BODY_PREOBF_FINALBODY_PREOBF_EARLY1717.1因此从 Step 4 开始,虽然总体仍然属于“逆向过程记录”,但这一步内部改按层次组织。
BODY_PREOBF_FINAL先固定最后真正参与 Base64 编码的主体层。
这一层的基本事实是:
0x40624400..0x406246130x214 = 532 字节step 13300144 作为最终基线0x40639296,成为 Base64 主体输入区这层最重要的意义不是“已经知道它怎么来的”,而是:
这一层一开始最明显的特征有两个:
前两组 64 位值很特殊。
0x40624400 和 0x40624408 与后面主体块的写入风格明显不同。整段并不表现得像静态常量。
orr、add、mvn 这类计算指令上。这说明:
BODY_PREOBF_FINAL 不是从某个静态地址整块拷来的为了让后面的分层讨论有一个可以直接对照的内存基线,这里先把“运行时最终层”和“分析别名层”都说明清楚。
运行时最终层 BODY_PREOBF_FINAL 的开头 16 字节,实际是:
40624400 13 d9 88 b0 23 43 e9 06 57 16 e5 ed 19 45 66 5d |....#C..W....Ef]|
而为了分析方便,下面这份默认对照 hexdump 使用的是分析别名 BODY_PREOBF:
0x40624410..0x40624613 保持运行时最终层不变40624400 93 f9 8a f0 27 43 e9 16 57 36 e7 ed 19 45 66 5d |....'C..W6...Ef]|
40624410 20 28 a9 73 73 ec 12 e7 80 5b 10 ff 27 93 82 fa | (.ss....[..'...|
40624420 be eb 59 76 d0 d6 d3 1c 29 5a 03 ac e6 bf 0c 5a |..Yv....)Z.....Z|
40624430 4d 4c f6 7c f0 fe 72 bb 1e a0 ff d4 2f fa 9d df |ML.|..r...../...|
40624440 99 b1 b8 8d 0c 24 50 f6 0e a7 97 e5 a6 ef 99 f5 |.....$P.........|
40624450 4f f7 68 15 ff ab 38 b4 d9 9c ef 27 02 6a 21 e4 |O.h...8....'.j!.|
40624460 c0 d2 82 17 37 62 fd ba 73 1d 2e d2 c5 bc 6d 9d |....7b..s.....m.|
40624470 53 7d b2 61 c6 4b 55 bc 75 83 88 07 d2 40 09 ce |S}.a.KU.u....@..|
40624480 c5 82 b7 95 f3 a1 4e 2f 05 41 0b 38 56 6e b2 99 |......N/.A.8Vn..|
40624490 5e 0d fa 8f 80 6f f0 7a 09 4c ab 5e cb 76 71 11 |^....o.z.L.^.vq.|
406244a0 eb 18 13 c5 f7 3f 3f e6 fb 7a 28 5a c3 84 75 53 |.....??..z(Z..uS|
406244b0 5b c4 4c d3 2a 36 2b f0 90 3d e7 31 47 b7 7a f8 |[.L.*6+..=.1G.z.|
406244c0 3d 2c 7d e4 f2 57 54 8f 64 f1 02 75 2b 99 66 2d |=,}..WT.d..u+.f-|
406244d0 d4 eb 72 9e 82 63 30 83 7d a6 dc b4 5c 7b ef b0 |..r..c0.}...\{..|
406244e0 fc 18 74 f4 92 c0 b5 f9 8f 1d f1 99 66 c5 39 64 |..t.........f.9d|
406244f0 db d2 bc 09 b4 36 62 99 0d 2f a1 f3 1f 13 e2 34 |.....6b../.....4|
40624500 f1 f7 07 be 40 d4 7d 8f 1d 1b e9 6d 46 a0 10 9d |....@.}....mF...|
40624510 b0 d2 00 dc 1f 1d 2f ee 85 00 96 64 0e 8a 1e 3d |....../....d...=|
40624520 62 0b 47 6a 87 21 35 c5 73 71 81 6b 8a 2b c2 62 |b.Gj.!5.sq.k.+.b|
40624530 c2 0d e6 14 48 8f 19 c1 49 d7 b1 b1 37 d7 28 9e |....H...I...7.(.|
40624540 29 87 4c e5 ea 15 e2 a4 8c df 51 5e 96 1b 84 32 |).L.......Q^...2|
40624550 d6 9e 8e d1 dd a9 f9 0d ec 5f 23 63 fe f9 3c 84 |........._#c..<.|
40624560 fb 9a fd 6e e9 7e 23 75 99 40 32 de 14 fb 75 73 |...n.~#u.@2...us|
40624570 bb f7 02 42 9d 47 73 d3 4a a1 12 32 b8 0a 47 25 |...B.Gs.J..2..G%|
40624580 b8 87 4d 53 ae 8a 80 6b 71 28 69 56 0d 59 77 3d |..MS...kq(iV.Yw=|
40624590 a7 c2 b4 96 7c ea d2 f5 f4 10 68 8c 71 1d f5 47 |....|.....h.q..G|
406245a0 55 45 46 3a 31 f6 a0 56 fb 43 08 e2 83 3a 61 29 |UEF:1..V.C...:a)|
406245b0 8e 9d e8 1e 10 e3 61 0b 44 4b 91 97 26 78 07 a3 |......a.DK..&x..|
406245c0 5f 56 24 d6 e0 79 70 8a 3a f7 69 24 8d 87 67 76 |_V$..yp.:.i$..gv|
406245d0 17 49 fa 2f 2e 8e b8 e2 68 69 76 7f 11 44 57 5d |.I./....hiv..DW]|
406245e0 82 51 85 e2 ec 16 05 9b 72 71 6b 5c 55 d7 16 c1 |.Q......rqk\U...|
406245f0 dd fd 75 6e c5 de 9d dd ae bd 56 80 eb cb ca c3 |..un......V.....|
40624600 60 5c 23 c8 4f 8e ea a5 72 58 26 46 3f 80 e6 79 |`\\#.O...rX&F?..y|
40624610 17 36 6f ad |.6o.|
BODY_PREOBF_EARLY -> BODY_PREOBF_FINAL继续观察整段 0x40624400..0x40624613 的写入历史后,首先分离出来的是“早期整段主体”和“后续块级修补”这两层。
关键发现是:
step 13256739 时,这整段 0x214 字节其实已经全部有值532/532 个字节最后一次写入都来自 STRBBODY_PREOBF_FINAL 逐字节比较,仍然有 62 个字节差异这意味着一个很重要的修正:
不是一开始就直接生成了 BODY_PREOBF_FINAL
而是先有一份完整的早期主体
后面再叠加块级修补,才得到最终主体
于是这一层被拆成:
| 层名 | 含义 |
|---|---|
BODY_PREOBF_EARLY |
更早已经整段成形、以 STRB 为主的主体基线 |
BODY_PREOBF_FINAL |
在早期主体之上继续修补后的最终主体 |
而这些后续修补最稳定的特征是:
13257838..1329337019 个 8-byte 对齐块的最终写入来自 STR0xbfff07a0orr x10, x10, x11这又把 19 个块继续拆成了两类:
2 个块:特殊处理块17 个块:统一块级 patch 流水线在 19 个块里,最早被单独闭合的是前两组 64 位值,也就是:
0x406244000x40624408这两组之所以先被单独拿出来,是因为它们的行为明显不同于后面主体:
最终确认下来的事实是:
| 分组 | 位混淆前原值 | 控制字节 | 位混淆后值 |
|---|---|---|---|
| 第 1 组 | 0x16e94327f08af993 |
0x06 |
0x06e94323b088d913 |
| 第 2 组 | 0x5d664519ede73657 |
0x43 |
0x5d664519ede51657 |
这里最重要的不是把每个 helper 细节都铺开,而是先固定它的层角色:
前两组 64 位值
= 先按字节形成原值
-> 再由控制字节驱动做表式位补丁
-> 写回到 BODY_PREOBF_FINAL 的开头 16 字节
这一层解释了为什么:
为了让这一层更直观,先把“位混淆前原始 16 字节”直接贴出来:
40624400 93 f9 8a f0 27 43 e9 16 57 36 e7 ed 19 45 66 5d |....'C..W6...Ef]|
对应的小端 64 位值就是:
0x40624400 -> 0x16e94327f08af993
0x40624408 -> 0x5d664519ede73657
而真正经过两次特殊位混淆之后,进入 Base64 主体输入的前两组值会变成:
0x40624400 -> 0x06e94323b088d913
0x40624408 -> 0x5d664519ede51657
这一层对应的 Python 可以直接写成两组特殊块的位补丁函数:
def obfuscate_group1(value: int) -> int:
# control_byte = 0x06
result = value
result |= (1 << 40)
result &= ~(1 << 17)
result |= (1 << 51)
result &= ~(1 << 7)
result &= ~(1 << 30)
result &= ~(1 << 13)
result &= ~(1 << 60)
result &= ~(1 << 34)
return result & 0xFFFFFFFFFFFFFFFF
def obfuscate_group2(value: int) -> int:
# control_byte = 0x43
result = value
result |= (1 << 40)
result &= ~(1 << 17)
result |= (1 << 60)
result &= ~(1 << 13)
result |= (1 << 30)
result &= ~(1 << 7)
result &= ~(1 << 51)
result &= ~(1 << 34)
return result & 0xFFFFFFFFFFFFFFFF
assert obfuscate_group1(0x16E94327F08AF993) == 0x06E94323B088D913
assert obfuscate_group2(0x5D664519EDE73657) == 0x5D664519EDE51657
17.1 -> 17 -> BODY_PREOBF_FINAL把前两组特殊位混淆放到一边后,接下来最重要的一层,是整段字节级主体在更早阶段的演化。
这里最终分离出了两个关键中间层:
| 层名 | 含义 |
|---|---|
17.1 |
更早的字节级主体基线 |
17 |
在 17.1 基础上再做统一递推后的结果 |
这两层的关系之所以重要,是因为后来已经能把它们之间的整层变换写成统一算法。
当前可稳定确认的结论是:
17.1 -> 17 并不是零散字节 patchoffset 0x02 .. 0x212,存在同一套统一递推offset 0x01 是特殊边界补丁offset 0x213 是特殊 xor 收尾也就是说,这层已经可以表达为:
输入:
17.1 整段旧状态
算法:
byte[0] 直接沿用
byte[1] 单独补丁
byte[2..0x212] 统一递推
byte[0x213] = old[-1] ^ byte[-2]
输出:
17
这一步的价值在于:
为了先让读者看到这条链里更靠后的那一层,下面先贴 17 的完整 hexdump。它非常重要,因为后面的首字节最终补丁就是在这层基础上完成的,而 BODY_PREOBF_FINAL 也几乎可以视为“17 再叠一层首字节最终补丁”:
40624400 b9 f9 8a f0 27 43 e9 16 57 36 e7 ed 19 45 66 5d |....'C..W6...Ef]|
40624410 a0 28 a9 73 73 ed 1a f7 00 7b 12 ff 27 92 8a fa |.(.ss....{..'...|
40624420 3e cb 5b 36 d0 d7 db 1c 29 7a 03 ac e6 bf 0c 5a |>.[6....)z.....Z|
40624430 cd 6c f4 7c f4 ff 7a bb 1e 80 fd 94 2b fa 95 df |.l.|..z.....+...|
40624440 19 b1 ba cd 08 25 50 f6 8e a7 97 a5 a2 ef 91 f5 |.....%P.........|
40624450 cf f7 68 15 ff ab 30 a4 59 bc ed 67 06 6b 21 e4 |..h...0.Y..g.k!.|
40624460 c0 d2 82 57 37 62 f5 aa 73 1d 2c d2 c1 bc 6d 9d |...W7b..s.,...m.|
40624470 53 7d b2 61 c6 4a 55 bc 75 83 8a 47 d2 40 09 de |S}.a.JU.u..G.@..|
40624480 c5 82 b7 d5 f3 a1 46 3f 85 61 0b 38 56 6e ba 99 |......F?.a.8Vn..|
40624490 5e 0d f8 8f 80 6f f0 6a 09 4c ab 5e cb 76 71 11 |^....o.j.L.^.vq.|
406244a0 eb 18 13 c5 f7 3f 3f e6 fb 7a 28 5a c3 84 75 53 |.....??..z(Z..uS|
406244b0 5b c4 4c d3 2a 36 2b f0 90 3d e7 31 47 b7 7a f8 |[.L.*6+..=.1G.z.|
406244c0 3d 2c 7d e4 f2 57 54 8f 64 f1 02 75 2b 99 66 2d |=,}..WT.d..u+.f-|
406244d0 d4 eb 72 9e 82 63 30 83 7d a6 dc b4 5c 7b ef b0 |..r..c0.}...\{..|
406244e0 fc 18 74 f4 92 c0 b5 f9 8f 1d f1 99 66 c5 39 64 |..t.........f.9d|
406244f0 db d2 bc 09 b4 36 62 99 0d 2f a1 f3 1f 13 e2 34 |.....6b../.....4|
40624500 f1 f7 07 be 40 d4 7d 8f 1d 1b e9 6d 46 a0 10 9d |....@.}....mF...|
40624510 b0 d2 00 dc 1f 1d 2f ee 85 00 96 64 0e 8a 1e 3d |....../....d...=|
40624520 62 0b 47 6a 87 21 35 c5 73 71 81 6b 8a 2b c2 62 |b.Gj.!5.sq.k.+.b|
40624530 c2 0d e6 14 48 8f 19 c1 49 d7 b1 b1 37 d7 28 9e |....H...I...7.(.|
40624540 29 87 4c e5 ea 15 e2 a4 8c df 51 5e 96 1b 84 32 |).L.......Q^...2|
40624550 d6 9e 8e d1 dd a9 f9 0d ec 5f 23 63 fe f9 3c 84 |........._#c..<.|
40624560 fb 9a fd 6e e9 7e 23 75 99 40 32 de 14 fb 75 73 |...n.~#u.@2...us|
40624570 bb f7 02 42 9d 47 73 d3 4a a1 12 32 b8 0a 47 25 |...B.Gs.J..2..G%|
40624580 b8 87 4d 53 ae 8a 80 6b 71 28 69 56 0d 59 77 3d |..MS...kq(iV.Yw=|
40624590 a7 c2 b4 96 7c ea d2 f5 f4 10 68 8c 71 1d f5 47 |....|.....h.q..G|
406245a0 55 45 46 3a 31 f6 a0 56 fb 43 08 e2 83 3a 61 29 |UEF:1..V.C...:a)|
406245b0 8e 9d e8 1e 10 e3 61 0b 44 4b 91 97 26 78 07 a3 |......a.DK..&x..|
406245c0 5f 56 24 d6 e0 79 70 8a 3a f7 69 24 8d 87 67 76 |_V$..yp.:.i$..gv|
406245d0 17 49 fa 2f 2e 8e b8 e2 68 69 76 7f 11 44 57 5d |.I./....hiv..DW]|
406245e0 82 51 85 e2 ec 16 05 9b 72 71 6b 5c 55 d7 16 c1 |.Q......rqk\\U...|
406245f0 dd fd 75 6e c5 de 9d dd ae bd 56 80 eb cb ca c3 |..un......V.....|
40624600 60 5c 23 c8 4f 8e ea a5 72 58 26 46 3f 80 e6 79 |`\\#.O...rX&F?..y|
40624610 17 36 6f ad |.6o.|
而再往前一层、作为统一递推输入的 17.1,其尾部旧状态是:
4062460c 58 33 1c 32 55 07 24 c2 |X3.2U.$.|
也就是:
17.1[0x20c..0x213] = 58 33 1c 32 55 07 24 c217[0x20c..0x213] = 3f 80 e6 79 17 36 6f ad这组对照很关键,因为它直接对应了后面尾部统一递推和最后一个字节 xor 收尾的分析。
17 的首尾边界补丁在 17 这一层里,真正最特殊的边界是首字节和最后一个字节。
尾字节 0xad 的规律先被单独闭合:
0xad = 0xc2 ^ 0x6f
也就是:
0x40624613 最后不是简单加法,而是用上一阶段尾字节与相邻最终字节做 xor而首字节则更特别,因为它不是直接来自 17.1 -> 17 这一层,而是更晚又叠了一次最终补丁。
当前稳定结论是:
17 层的首字节旧值是 0xb90x40624401..0x40624613 共 0x213 个字节做完整 rolling sum,得到 0x10a530x400x10a93 = 0x10a53 + 0x40
0x93 = low8(0x10a93)
这就解释了为什么最终 BODY_PREOBF_FINAL[0] = 0x93,而更早层里却会看到 0xb9 和 0xa0。
如果只看边界区,最值得直接对照的是这几个窗口:
17 尾部:
4062460c 3f 80 e6 79 17 36 6f ad |?..y.6o.|
更早一层 17.1 的对应旧尾部:
4062460c 58 33 1c 32 55 07 24 c2 |X3.2U.$.|
首字节最终补丁后,真正进入最终主体层的开头:
40624400 93 f9 8a f0 27 43 e9 16 |....'C..|
这也是为什么 Step 4 必须把“整段递推层”和“最终首字节补丁层”拆开写。
这一层对应的 Python 可以直接分成三部分:
17.1 -> 17 的整层递推xor 收尾def calc_layer17_body_full(prev_full: int, prev2_byte: int, old_byte: int, idx: int) -> int:
flag = ((prev_full & 0xE0) >> 5) & 0xFFFFFFFF
base = (prev_full << 3) & 0xFFFFFFFF
inv1 = (~(base | flag)) & 0xFFFFFFFF
tmp2 = (~(inv1 | ((~idx) & 0xFFFFFFFF))) & 0xFFFFFFFF
inv2 = (~(base | (idx | flag))) & 0xFFFFFFFF
state = (~(inv2 | tmp2)) & 0xFFFFFFFF
mixed = state ^ (prev2_byte & 0xFF)
temp = (~mixed) & 0xFFFFFFFF
return (temp + (old_byte & 0xFF)) & 0xFFFFFFFF
def calc_layer17_second_byte_patch(first_byte: int, last_old_byte: int) -> int:
return ((((~first_byte) & 0xFF) ^ (last_old_byte & 0xFF)) | 0x01) & 0xFF
def calc_layer17_from_layer17_1(layer17_1: bytes) -> bytes:
if len(layer17_1) < 3:
raise ValueError("layer17_1 buffer too short")
out = bytearray(len(layer17_1))
out[0] = layer17_1[0]
prev_full = (
(layer17_1[1] & 0xFF)
+ calc_layer17_second_byte_patch(layer17_1[0], layer17_1[-1])
) & 0xFFFFFFFF
out[1] = prev_full & 0xFF
for idx in range(2, len(layer17_1) - 1):
prev_full = calc_layer17_body_full(
prev_full=prev_full,
prev2_byte=out[idx - 2],
old_byte=layer17_1[idx],
idx=idx,
)
out[idx] = prev_full & 0xFF
out[-1] = (layer17_1[-1] ^ out[-2]) & 0xFF
return bytes(out)
def calc_first_byte_mask_patch(previous_head: int, second_byte: int) -> int:
return ((~previous_head) & second_byte) & 0xFF
def calc_first_byte_rolling_sum_from_layer17(layer17: bytes) -> int:
acc = 0
for b in layer17[1:]:
acc = (acc + (b & 0xFF)) & 0xFFFFFFFF
return acc
def calc_first_byte_from_full_layer17(layer17: bytes) -> int:
rolling_sum = calc_first_byte_rolling_sum_from_layer17(layer17)
final_value = (rolling_sum + calc_first_byte_mask_patch(layer17[0], layer17[1])) & 0xFFFFFFFF
return final_value & 0xFF
def calc_last_byte_final_patch(previous_last_byte: int, previous_byte: int) -> int:
return (previous_last_byte ^ previous_byte) & 0xFF
Step 4 最终把 0x40624400 这一大块复杂主体整理成了一个更清晰的主体层框架:
17.1
-> 17
-> BODY_PREOBF_EARLY
-> BODY_PREOBF_FINAL
-> 0x40639296
这里并不是说每一层都通过同一种算法相连,而是说:
APPLE -> ROOT530 这条更上游链,适合单独拆到下一步来讲这一步里最大的修正,是放弃把 0x40624400 当成“单层缓冲区”来理解。
最初如果不分层,很容易把所有现象混成一团:
xor但 Step 4 之后,这些现象被重新归位了:
也正因为做了这个修正,后面才有可能继续稳定往上游写。
Step 4 最终固定下来的内容有四点:
0x40624400..0x40624613 必须按层次理解,不能再当单层缓冲区。BODY_PREOBF_FINAL 是最后真正进入 Base64 主体输入的稳定基线。BODY_PREOBF_EARLY1717.117.1 -> 17 已经能写成统一递推,首尾边界补丁也已经闭合;更上游的 APPLE -> ROOT530 关系放到 Step 5 单独展开。APPLE 视图与 ROOT530 上游静态源Step 4 已经把主体区内部的大框架拆出来了,但还剩下一个更上游的问题:
17.1 自己是怎么来的17.1 -> 17 更统一的模板也就是说,Step 5 的目标是把下面这条更上游链单独讲清楚:
ROOT530
-> APPLE
-> 17.1
继续往前追时,最先遇到的问题是:
17.1 的首字节 0xb9 已经带有后续补丁痕迹17.1 继续向前分析,会把更早来源和后补丁混在一起于是当时先做了一个很小、但很关键的分析修正:
APPLE = 17.1 with byte[0] restored from 0xb9 to 0xa0
也就是说:
0xb9 恢复成更早的 0xa017.1 完全一致这一步的意义不是“伪造一层不存在的数据”,而是把首字节上的后补丁噪音先剥掉,好继续观察更上游是否存在统一模板。
定义出 APPLE 之后,最先看到的现象是:它并不是只在个别字节上有规律,而是很快出现了一套可以连续扩展的统一模式。
APPLE 的完整 hexdump 如下:
40624400 a0 74 ff 9f 31 10 25 22 a9 dc d4 f0 9e 6e a4 d7 |.t..1.%".....n..|
40624410 3d 72 9d ea 9a eb 25 22 be 6a d4 f0 19 6e 38 d4 |=r....%".j...n8.|
40624420 bc f6 9e 69 9f 6d 24 4b 3d f7 d4 f6 31 76 42 4b |...i.m$K=...1vBK|
40624430 bc 72 91 75 18 ee b8 d7 be f3 1e e9 91 eb 58 49 |.r.u..........XI|
40624440 45 08 91 f5 99 ee b8 d7 3e 73 91 f6 99 ed 25 22 |E.......>s....%"|
40624450 3e d2 8b fd 94 6b 25 22 a7 f4 d4 f0 91 76 25 22 |>....k%".....v%"|
40624460 27 56 b7 fd 94 ee 38 55 3c 76 1e ea 98 6e b8 56 |'V....8U<v...n.V|
40624470 45 f4 7d fd 94 6d 38 54 3e f2 9e f4 9f ed b8 56 |E.}..m8T>......V|
40624480 45 f4 0b 92 91 71 bf 54 bd fc 10 eb 9d 76 66 ce |E....q.T.....vf.|
40624490 45 08 9d e9 91 8e 5e ea 45 08 9d e9 91 14 58 74 |E.....^.E.....Xt|
406244a0 45 08 9d e9 91 1f e8 48 9c 0b ab eb 1a 6e bf d4 |E......H.....n..|
406244b0 bb fd 1c e9 91 6e 59 c9 a5 0b ab eb 1a 6e bf d4 |.....nY......n..|
406244c0 bb fd 1c e9 91 0e e3 c2 45 57 24 a0 91 7b 90 93 |........EW$..{..|
406244d0 b4 46 cc 4a d5 c3 80 b9 72 58 6f 50 02 07 49 8c |.F.J....rXoP..I.|
406244e0 87 cf 53 4d ca 46 28 84 22 8a 7f 13 38 0d e0 48 |..SM.F(."...8..H|
406244f0 c6 1e 7c ce b5 90 56 6c 64 38 30 cd e2 0a 5c 30 |..|...Vld80...\0|
40624500 35 b2 54 8b 37 8e 5e c8 27 8a b9 cd d5 f3 5e cd |5.T.7.^.'.....^.|
40624510 9d dc 35 9e 12 4f 11 62 c6 dc 36 14 b8 94 63 a3 |..5..O.b..6...c.|
40624520 3a 1b 60 7d b8 95 de 75 a7 e9 54 c2 81 3e c0 75 |:.`}...u..T..>.u|
40624530 a4 53 7f 1e bb f3 1c 32 79 8a 7f 13 38 0d e0 48 |.S.....2y...8..H|
40624540 c6 1e a4 8c 12 0d e7 ea 1c 69 8a 7d 86 c3 85 a3 |.........i.}....|
40624550 1c 74 ff 8b 32 14 c0 3f b6 93 70 81 63 c3 ac cc |.t..2..?..p.c...|
40624560 74 d5 4b 85 d4 c3 a0 76 7a 11 24 99 bd 0f 1b b3 |t.K....vz.$.....|
40624570 5a d7 79 d7 02 23 45 7f e0 9a 50 7d b8 95 de 75 |Z.y..#E...P}...u|
40624580 a7 e9 54 c2 02 2e fd 75 c5 92 24 3f 65 0d c1 ab |..T....u..$?e...|
40624590 b6 54 d8 8b 11 cb 90 e1 da dc 57 55 02 b8 fd 75 |.T........WU...u|
406245a0 c5 92 24 0f 65 0d c1 ab b6 64 54 8b 37 8e 5e c8 |..$.e....dT.7.^.|
406245b0 27 8a b9 88 bd 0f 1b 6b c6 dc 36 14 b8 94 63 a3 |'......k..6...c.|
406245c0 3a ef 54 8b 37 8e 5e c8 27 8a b9 9c 12 0d e7 ea |:.T.7.^.'.......|
406245d0 1c 69 8a 7d 86 1a 45 7f e0 e2 70 81 63 6f 45 7f |.i.}..E...p.coE.|
406245e0 e0 fa 70 81 63 77 c0 75 a4 53 7f 1e bb f3 1c 4a |..p.cw.u.S.....J|
406245f0 c6 dc 36 14 b8 94 63 a3 3a 0f 10 75 9a ee 16 26 |..6...c.:..u...&|
40624600 35 1e a4 0f b1 46 28 84 42 98 e1 a9 58 33 1c 32 |5....F(.B...X3.2|
40624610 55 07 24 c2 |U.$.|
从直观上看,这一层和 17.1 的区别其实很小:
0xb9 -> 0xa0但从 trace 回溯上看,APPLE[1..] 却开始落入一套比 17.1 -> 17 更统一的公式里。
到这一步,当时的猜测收敛成:
APPLE 不是又一层“随便定义出来的中间值”,而是确实更接近上游真实来源的一层分析视图。APPLE[1..] 很可能共享统一模板,不再需要像 Step 4 那样分头尾分别解释。APPLE 提供 root_i。随后做了三轮验证。
第一轮是小样本扩展。
最开始先验证 APPLE[1..15],很快得到统一关系:
root_i
-> state_i
-> seed_i
-> full_i = seed_i ^ mask_i
-> byte_i = low8(full_i)
这里最关键的不是每个中间变量名字,而是:
15 个字节已经能用同一套桥接公式解释第二轮是范围扩展。
在 APPLE[1..15] 成立之后,继续扩到 APPLE[1..31],结果仍然全部命中。再继续程序化验证,最终得到:
APPLE[1..0x212]530 字节也就是说,到这里已经不是“样本看起来像有规律”,而是:
APPLE[1..0x212]
全部遵循同一套 root_i -> state_i -> seed_i -> byte_i 模板
第三轮是 root 源窗口定位。
当 APPLE[1..0x212] 整段公式跑通之后,更上游 root 源窗口也被固定下来了:
ROOT530 = 0x40639001..0x40639212
它的映射关系是:
root_addr(i) = 0x40639213 - i
也就是说:
APPLE[1] 读取 0x40639212APPLE[0x212] 读取 0x40639001530 个字节恰好一一对应 APPLE[1..0x212]为了让这一步能被直接阅读,ROOT530 的完整 hexdump 也放在这里:
40639001 01 18 c2 97 08 a0 b3 30 97 0d 38 d6 df 60 62 08 |.......0..8..`b.|
40639011 02 18 02 1a 04 33 30 28 39 2a 08 21 6e 6f 74 74 |.....30(9*.!nott|
40639021 65 30 21 32 08 21 6e 73 74 73 65 74 21 38 fd 40 |e0!2.!nstset!8.@|
40639031 7a 40 fd 88 7a 48 fd 50 7a 50 fd 58 7a 5a 08 6f |z@..zH.PzP.XzZ.o|
40639041 6e 6f 74 73 65 60 21 21 08 21 6e 6f 74 73 65 68 |notse`!!.!notseh|
40639051 21 6a 08 21 6e 6f 74 74 65 74 21 70 fd 78 7a 21 |!j.!nottet!p.xz!|
40639061 08 21 6e 6f 74 73 65 80 21 85 01 f0 23 88 c9 f0 |.!notse.!...#...|
40639071 01 f0 23 90 c9 95 01 74 23 98 c9 9d 01 f0 23 a0 |..#....t#.....#.|
40639081 c9 a5 01 f0 23 a8 c9 f0 01 f0 23 b0 c9 b2 01 6e |....#.....#....n|
40639091 21 6e 6f 74 73 65 74 01 b8 01 fd 88 7a c0 01 7a |!notset.....z..z|
406390a1 88 7a c8 01 fd 88 7a fd 01 fd 88 7a e0 01 ac a6 |.z....z....z....|
406390b1 ea a6 84 67 e8 01 fd f0 7a f2 01 08 21 6e 6f 65 |...g....z...!noe|
406390c1 73 65 f8 21 fa 01 08 6f 6e 6f 74 73 65 80 21 08 |se.!...onotse.!.|
406390d1 02 08 21 6e 6f 74 73 21 88 21 8a 02 08 21 6e 73 |..!nots!.!...!ns|
406390e1 74 73 65 90 21 92 02 6e 21 6e 6f 74 73 65 98 02 |tse.!..n!notse..|
406390f1 9a 02 08 21 6e 6f 74 74 65 a0 21 a2 02 08 21 74 |...!notte.!...!t|
40639101 6f 74 73 65 a8 21 aa 21 08 21 6e 6f 74 73 65 b0 |otse.!.!.!notse.|
40639111 c0 b0 02 fd 1d 7a c0 f8 f8 cb e4 a6 84 68 6a 70 |.....z.......hjp|
40639121 72 08 21 6e 6f 74 73 74 74 21 78 d6 df 02 90 ba |r.!notstt!x.....|
40639131 ba aa ae e6 12 98 01 94 94 8d 98 0d a0 01 aa d1 |................|
40639141 d1 46 05 c0 01 10 20 ca 01 7b 22 63 6d 72 22 3a |.F.... ..{"cmr":|
40639151 31 36 37 37 37 32 31 36 2c 22 63 6d 72 32 22 3a |16777216,"cmr2":|
40639161 31 36 37 37 37 32 31 36 2c 22 75 6e 5f 68 22 3a |16777216,"un_h":|
40639171 30 2c 22 76 70 6e 22 3a 30 2c 22 73 74 73 22 3a |0,"vpn":0,"sts":|
40639181 30 2c 22 6b 64 22 3a 36 39 34 33 36 37 2c 22 66 |0,"kd":694367,"f|
40639191 6b 64 22 3a 31 35 36 34 36 30 30 36 30 34 2c 22 |kd":1564600604,"|
406391a1 70 64 22 3a 31 32 34 38 35 39 34 34 30 33 2c 22 |pd":1248594403,"|
406391b1 64 79 6e 22 3a 22 22 2c 22 64 6f 22 3a 30 2c 22 |dyn":"","do":0,"|
406391c1 6c 70 30 22 3a 35 32 30 38 33 30 39 31 33 32 32 |lp0":52083091322|
406391d1 38 2c 22 6c 70 31 22 3a 35 32 31 39 31 33 35 31 |8,"lp1":52191351|
406391e1 38 31 35 30 2c 22 61 30 22 3a 32 30 38 34 36 39 |8150,"a0":208469|
406391f1 36 38 35 37 30 32 33 2c 22 61 31 22 3a 31 30 38 |6857023,"a1":108|
40639201 30 31 32 39 39 32 38 2c 22 74 6b 22 3a 66 61 6c |0129928,"tk":fal|
40639211 73 65 |se|
为了把之前主文档里没展开的 protobuf 前缀也明确补出来,这里再把 ROOT530 按可读性拆成两段:
| 分段 | 地址范围 | 长度 | 说明 |
|---|---|---|---|
ROOT530_PROTO_PREFIX |
0x40639001..0x40639148 |
0x148 = 328 字节 |
前半段 protobuf/二进制字段区 |
ROOT530_JSON_TAIL |
0x40639149..0x40639212 |
0x0ca = 202 字节 |
后半段可直接读出的 JSON 参数串 |
这一段也解释了为什么前面会说“ROOT530 前半段像 protobuf、后半段像 JSON”:
这一步的核心观察非常关键:
ROOT530 不是继续从 trace 里的动态写入拼出来的cmr、kd、pd、lp0、lp1、a0、a1、tk这说明主体区终于不再只是在“算法层”里打转,而是第一次和真实业务参数连接起来了。
Step 5 对应的 Python,就是把 APPLE 的统一桥接公式和 ROOT530 的逆序取值关系整理出来:
APPLE_MASK_BY_MOD8 = {
0: 0x00,
1: 0x05,
2: 0x15,
3: 0x3A,
4: 0x8C,
5: 0x27,
6: 0x0B,
7: 0x05,
}
APPLE_ADDEND_BY_MOD8 = {
0: 0x75,
1: 0xBE,
2: 0x9C,
3: 0x3C,
4: 0x61,
5: 0x5F,
6: 0xB2,
7: 0x02,
}
def calc_root_addr_for_apple(offset: int) -> int:
assert 1 <= offset <= 0x212
return 0x40639213 - offset
def calc_apple_root(root530: bytes, offset: int) -> int:
assert len(root530) == 0x212
return root530[-offset]
def calc_apple_state_from_root(root_i: int, mask_i: int, addend_i: int) -> int:
step1 = (root_i << 4) & 0xFFFFFFFF
step2 = (step1 + ((root_i >> 4) & 0xFF)) & 0xFFFFFFFF
step3 = (step2 + (mask_i & 0xFF)) & 0xFFFFFFFF
return (step3 ^ (addend_i & 0xFF)) & 0xFFFFFFFF
def calc_apple_seed(state_i: int, addend_i: int) -> int:
neg_state = (~state_i) & 0xFFFFFFFF
mixed = ((neg_state << 3) & 0xFFFFFFFF) | ((neg_state & 0xE0) >> 5)
return (~((mixed + (addend_i & 0xFF)) & 0xFFFFFFFF)) & 0xFFFFFFFF
def calc_apple_byte_from_root530(root530: bytes, offset: int) -> int:
root_i = calc_apple_root(root530, offset)
mask_i = APPLE_MASK_BY_MOD8[offset & 7]
addend_i = APPLE_ADDEND_BY_MOD8[offset & 7]
state_i = calc_apple_state_from_root(root_i, mask_i, addend_i)
seed_i = calc_apple_seed(state_i, addend_i)
return (seed_i ^ mask_i) & 0xFF
Step 5 最终把更上游这条链固定成了:
ROOT530
-> APPLE
-> 17.1
其中最关键的结论有两个:
APPLE 是继续向前追来源时非常有价值的分析视图ROOT530 是一段连续的静态源窗口,而不是另一块运行时现算缓冲区这一步里最重要的修正有两个。
APPLE 不是 trace 中真实存在的一层。
ROOT530 也不是“又一个运行时生成层”。
这两个修正都很重要,因为如果把它们误当成“运行时实际缓冲区层”,后面的推理就会混乱。
Step 5 最终固定下来的内容有四点:
APPLE 是从 17.1 去掉首字节补丁噪音后得到的分析视图。APPLE[1..0x212] 已经能被统一公式整体解释,不再只是局部样本成立。ROOT530 = 0x40639001..0x40639212 已被确认为 APPLE[1..0x212] 的完整 root 源窗口。ROOT530 不是一团“缺失的 protobuf 字节”,而是一段连续静态窗口:前半段是 protobuf/二进制前缀,后半段是可直接读出的 JSON 参数串。更多【Android安全- X-Perseus AI初窥】相关视频教程:www.yxfzedu.com