ai 时代下如何更好的利用 ai 去逆向(TikTok X-Gorgon 分析)
引子
很多时候,逆向真正难的不是“看不懂汇编”,而是“信息太多”。
尤其是面对几百万行、几百 MB 的 trace 日志时,人工逐行翻几乎不现实。
这时候,AI 最适合扮演的角色不是“替你猜答案”,而是“帮你稳定地组织证据、缩小范围、验证猜想”。
这篇文章就是一个完整案例。
我们从 trace 中一个看起来很普通的固定字符串片段:
1
8404a0480000ee5058dbfb98bb0c48a807d688d9516d32e8dd0d一路追到了:
- 它并不是一个直接写死的常量字符串
- 它来自一块 26 字节原始缓冲区
- 这 26 字节里有一部分来自 query string 的 MD5 前 4 字节
- 另一部分来自时间戳和常量表
- 中间还经过了一段 RC4-like 的“魔改 RC4”变换
- 最终得到:
1
ee5058dbfb98bb0c48a807d688d9516d32e8dd0dai 在应对汇编这种,是有天然优势的,但是我们无法把几百万行的汇编乃至上千万汇编直接丢改 ai 去分析,这时我们应该一些文件搜索工具去处理,慢慢去引导。

我想借这个过程,展示一件很重要的事:
当你把 AI 放在“证据组织者”和“分析搭子”的位置上,而不是“拍脑袋给答案的工具”,它在逆向中会非常有价值。
分析目标
一开始我们拿到的是一段运行时 trace,其中有这样一段:
1
2
3
4
5
6
call func: free(0x6f68213970)
hexdump at address 0x6f68213970 with length 0x0:
6f68213970: 38 34 30 34 61 30 34 38 30 30 30 30 65 65 35 30 |8404a0480000ee50|
6f68213980: 35 38 64 62 66 62 39 38 62 62 30 63 34 38 61 38 |58dbfb98bb0c48a8|
6f68213990: 30 37 64 36 38 38 64 39 35 31 36 64 33 32 65 38 |07d688d9516d32e8|
6f682139a0: 64 64 30 64 |dd0d |这说明:
0x6f68213970是一块即将被free的堆内存- 里面存的是 ASCII 字符串
- 字符串前缀是:
1
8404a0480000ee50此时最自然的问题是:
- 这个字符串是哪里来的?
- 它是被直接拷贝进来的,还是运行时算出来的?
- 如果它是算出来的,参与计算的源数据是什么?
工具选择:先解决“大文件无法人工通读”的问题
这次分析使用的是:
trace.log- 自定义的大文件查看工具
large-text-viewer
先看文件规模:
1
2
3
File: /Users/jiangxia/Downloads/trace.log
Size: 724037309 bytes
Lines: 7303970也就是大约 690MB、730 万行。
这种文件如果直接用编辑器打开,体验会很差,人工搜索上下文也容易丢线索。
所以第一步不是“开始看汇编”,而是先把大文件检索能力建起来。
在这一步,AI 的作用其实很朴素:
- 帮你明确该搜什么地址
- 帮你决定上下文窗口抓多大
- 帮你把搜索结果串成因果链
第一步:先确认 8404... 不是立即数,而是堆字符串
从 trace 可以先确认一件事:
1
0x6f68213970不是一个寄存器里的常量,而是一块堆内存地址。
后续在日志里还能看到这块地址被分配、被挂到对象字段、被释放:
malloc(0x35) -> 0x6f68213970- 它被保存到对象字段里
- 最后
free(0x6f68213970)
这一步很重要,因为它会直接决定你的追踪方向:
- 如果它是立即数,你应该追寄存器和常量池
- 如果它是堆块,你应该追“谁申请的、谁写进去的、谁引用它”
于是搜索策略就变成了:
- 搜
0x6f68213970 - 搜保存它的对象字段地址
- 搜它的第一次写入
第二步:确认这个字符串不是“直接拷贝来的”,而是“十六进制编码后的结果”
继续往前看,很快能看到这个关键信息:
程序不是直接把这串文本写进 0x6f68213970 的,而是把一块原始数据做了 hex 编码。
举几个最直接的证据:
- 读到原始字节
0x84 - 高 4 位
8被查表映射成字符'8' - 低 4 位
4被查表映射成字符'4'
所以:
1
2
3
4
0x84 -> "84"
0x04 -> "04"
0xa0 -> "a0"
0x48 -> "48"最后前缀自然变成:
1
8404a048这一步的结论非常关键:
我们真正要追的不是字符串本身,而是那块“原始字节缓冲区”。
也就是说:
1
8404a0480000ee50...只是“最终表现形式”,真正的源头是另一块原始数据。
第三步:找到字符串对应的原始 26 字节缓冲区
继续往前追,最终锁定到:
1
0x6f482848a0这是一块 26 字节原始缓冲区。
在 hex 编码阶段,程序是逐字节读取它的内容的。
根据 trace 里的逐字节读取,我们可以把这 26 字节完整还原出来:
1
84 04 a0 48 00 00 ee 50 58 db fb 98 bb 0c 48 a8 07 d6 88 d9 51 6d 32 e8 dd 0d也就是说,真正的 raw buffer 是:
1
8404a0480000ee5058dbfb98bb0c48a807d688d9516d32e8dd0d然后它被转换成 ASCII hex 字符串,写入了 0x6f68213970。
到这里,我们已经完成了第一层拆解:
1
2
最终字符串
<- 来自 26 字节原始缓冲区但问题还在:
这 26 字节是怎么来的?
第四步:把 26 字节拆成“头部 6 字节 + 后 20 字节”
进一步分析发现,这 26 字节并不是一次性整体生成的,而是分两部分构造:
1. 头部 6 字节
1
84 04 a0 48 00 00这部分是单独写进去的。
其中:
8404a048
分别在不同位置写入
而:
1
00 00则在后续编码时被读取为 0。
2. 后 20 字节
1
ee 50 58 db fb 98 bb 0c 48 a8 07 d6 88 d9 51 6d 32 e8 dd 0d这部分来自另一块临时缓冲区,再经过进一步变换。
也就是说,真正值得继续追的是:
1
后 20 字节从哪里来第五步:找到 20 字节中间材料
继续回溯后,可以定位到一块 20 字节中间材料:
1
11 fd bf 14 00 00 00 00 00 00 00 00 20 05 00 05 69 ba a7 f5更准确地说,这 20 字节是由 3 段拼起来的:
1
2
3
11 fd bf 14 00 00 00 00 00 00 00 00
20 05 00 05
69 ba a7 f5于是分析目标就从“大字符串”一步步收缩成了:
11 fd bf 14是什么?20 05 00 05是什么?69 ba a7 f5是什么?
只要搞清这三段,就能解释后面的 20 字节是怎么变出来的。
第六步:11 fd bf 14 其实是 query string 的 MD5 前 4 字节
这一部分是整个分析过程里的第一个大突破。
我一开始只是从 trace 里看到:
- 有一块 MD5 风格的上下文
- 初始化常量非常眼熟
继续往下追,发现初始化时写进去的是:
1
2
3
4
67452301
efcdab89
98badcfe
10325476这正是经典 MD5 的初始状态。
对应的初始化 trace 也很典型,直接能看到 A/B/C/D 四个初值被压到栈上:
1
2
3
4
[libmetasec_ov.so] 0x6e68b6ee2c!0x14be2c str x9, [sp, #0x10]; x9=0x1032547698badcfe sp=0x6e5f0ba080 mem_w=0x6e5f0ba090
[libmetasec_ov.so] 0x6e68b6ee30!0x14be30 str q0, [sp]; q0=0xefcdab89674523010000000000000000 sp=0x6e5f0ba080 mem_w=0x6e5f0ba080
[libmetasec_ov.so] 0x6e68b6ee34!0x14be34 bl #0x6e68b6dafc
[libmetasec_ov.so] 0x6e68b6dafc!0x14aafc stp x22, x21, [sp, #-0x30]!; x22=0x6e5f0ba160 x21=0x6e5f0ba350 sp=0x6e5f0ba080 mem_w=0x6e5f0ba050于是顺着这条线继续往上找,最终发现被做摘要的并不是某个 4 字节值,而是一整段 query string:
1
version_code=2023302050&user_id=0&aid=1233&device_type=Pixel%206%20Pro&app_name=musical_ly&channel=googleplay&os_version=13&device_platform=android&update_version_code=2023302050&os_api=33&device_brand=google&version_name=33.2.5&manifest_version_code=2023302050&abi=arm64-v8a®ion=hk&sys_region=hk&cronet_version=996128d2_2024-01-12&ttnet_version=4.2.137.48-tiktok&use_store_region_cookie=1&tnc_src=1&delay=0更进一步,程序并不是对完整 URL 做摘要,而是:
1
2
3
4
完整 URL
-> 去掉前缀 "https://tnc16-platform-alisg.tiktokv.com/get_domains/v5/?"
-> 只保留 query string
-> 做 MD5对应的 trace 里其实能直接看到这一步,它把 ? 后面的参数段单独 memmove 出来了:
1
2
3
4
5
6
7
8
call func: memmove(0x6ff81d5350, 0x70081e6b69, 0x19a)
hexdump at address 0x70081e6b69 with length 0x19a:
70081e6b69: 76 65 72 73 69 6f 6e 5f 63 6f 64 65 3d 32 30 32 |version_code=202|
70081e6b79: 33 33 30 32 30 35 30 26 75 73 65 72 5f 69 64 3d |3302050&user_id=|
70081e6b89: 30 26 61 69 64 3d 31 32 33 33 26 64 65 76 69 63 |0&aid=1233&devic|
70081e6b99: 65 5f 74 79 70 65 3d 50 69 78 65 6c 25 32 30 36 |e_type=Pixel%206|
70081e6ba9: 25 32 30 50 72 6f 26 61 70 70 5f 6e 61 6d 65 3d |%20Pro&app_name=|
70081e6bb9: 6d 75 73 69 63 61 6c 5f 6c 79 26 63 68 61 6e 6e |musical_ly&chann|我后来直接在本地复算了一遍:
1
2
3
4
import hashlib
s = "version_code=2023302050&user_id=0&aid=1233&device_type=Pixel%206%20Pro&app_name=musical_ly&channel=googleplay&os_version=13&device_platform=android&update_version_code=2023302050&os_api=33&device_brand=google&version_name=33.2.5&manifest_version_code=2023302050&abi=arm64-v8a®ion=hk&sys_region=hk&cronet_version=996128d2_2024-01-12&ttnet_version=4.2.137.48-tiktok&use_store_region_cookie=1&tnc_src=1&delay=0"
print(hashlib.md5(s.encode()).hexdigest())结果是:
1
11fdbf14f6cba867acd068ead133befb完全匹配 trace 里导出的 16 字节摘要。
于是:
1
11 fd bf 14就不再是“神秘 4 字节”,而变成了:
MD5(query_string)的前 4 字节
这一步一确认,整个分析立刻从“乱猜状态”变成了“有明确语义的拼装过程”。
第七步:69 ba a7 f5 是秒级时间戳
这也是第二个非常大的突破。
一开始 trace 里只能看到:
1
69 ba a7 f5后来继续回溯,发现程序先算出来了一个更大的整数:
1
0x19d012017d1十进制是:
1
1773840373713然后通过一个乘 magic constant 的除法过程,把它变成了:
1
0x69baa7f5十进制是:
1
1773840373也就是:
1
1773840373713 // 1000所以这部分本质上就是:
毫秒时间戳除以 1000,得到秒级时间戳
换算成时间:
- UTC:
2026-03-18T13:26:13.713+00:00 - 上海时区:
2026-03-18T21:26:13.713+08:00
这就非常像一个请求时间戳字段了。
第八步:20 05 00 05 是查表得到的常量
第三段:
1
20 05 00 05一开始最难命名。
继续分析后可以确认:
- 它不是 hash
- 不是时间戳
- 不是 query string 直接某个字段
- 而是从一张全局表里读出来的一个 32 位值
寄存器里它对应的是:
1
0x05000520写进内存后按小端显示成:
1
20 05 00 05所以到这一层,我们至少已经能确定:
这是一个表驱动选出来的常量值
虽然从这份 trace 里还没完全给它命名成某个“业务字段”,但已经能明确它是“常量表选择结果”,不是用户输入。
第九步:中间 20 字节是如何变成 ee5058db... 的
现在 20 字节中间材料已经明确了:
1
11 fd bf 14 00 00 00 00 00 00 00 00 20 05 00 05 69 ba a7 f5但它不是直接拿去用的,而是先经过第一阶段,再经过第二阶段。
第十步:第一阶段不是标准 RC4,而是 RC4-like 的魔改变体
这是这次分析里最让我惊喜的一步。
一开始我只知道第一阶段输出是:
1
fd7f2a6ffaa7b441f3fa6de1a2ec827536f84f98但不知道它是怎么来的。
后来顺着 trace 往前看,发现:
- 有一块 256 字节区域
x27 - 先被初始化成:
1
S[i] = i- 随后有一个循环,每轮都会:
- 取
key[i & 7] - 更新
j - 更新
S
- 取
这已经非常像 RC4 的 KSA 了。
继续追之后,我把 key 也抠出来了:
1
4a 00 16 48 47 6c 00 a0也就是:
1
4a001648476c00a0但是,最后真正用 Python 去跑时,标准 RC4 算法是对不上的。
最后对上的版本是:
KSA 阶段
1
2
3
S[i] = i
j = (j + S[i] + key[i & 7]) & 0xff
S[i] = S[j]注意:
这里不是标准 RC4 的
swap(S[i], S[j]),而是只做单边写回。
PRGA 阶段
1
2
3
4
5
6
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
t = S[j]
S[i] = t
k = S[(t + t) & 0xff]
out = k ^ input_byte这就解释了为什么我把它叫:
RC4-like 单边更新变体
而不是标准 RC4。
从 trace 的单字节执行也能直接看到第一阶段确实在原地把 0x11 改成了 0xfd:
1
2
3
4
[libmetasec_ov.so] 0x6e68aa31b0!0x801b0 ldrb w11, [x24, x22]; w11=0x4b x24=0x6f482848a6 x22=0x0 mem_r=0x6f482848a6 -> w11=0x11
[libmetasec_ov.so] 0x6e68aa31c4!0x801c4 ldrb w10, [x27, x10]; w10=0x3a x27=0x6e5f0b9d60 x10=0x3a mem_r=0x6e5f0b9d9a -> w10=0xec
[libmetasec_ov.so] 0x6e68aa31c8!0x801c8 eor w10, w10, w11; w10=0xec w10=0xec w11=0x11 -> w10=0xfd
[libmetasec_ov.so] 0x6e68aa31cc!0x801cc strb w10, [x24, x22]; w10=0xfd x24=0x6f482848a6 x22=0x0 mem_w=0x6f482848a6第十一步:第二阶段是位运算变换
第一阶段得到的是:
1
fd7f2a6ffaa7b441f3fa6de1a2ec827536f84f98接着程序进入第二阶段,把这 20 字节继续变成:
1
ee5058dbfb98bb0c48a807d688d9516d32e8dd0d这一步我后来已经完整用 Python 复现了,前 19 个字节的统一公式是:
1
2
mixed = nibble_swap(cur) ^ next_byte
out = reverse_bits8(mixed) ^ 0xEB其中:
nibble_swap
把一个字节高低 4 bit 对调reverse_bits8
对一个字节做位反转
举第 1 个字节为例:
1
2
3
4
5
6
cur = 0xfd
nxt = 0x7f
nibble_swap(cur) = 0xdf
mixed = 0xdf ^ 0x7f = 0xa0
reverse_bits8(0xa0) = 0x05
0x05 ^ 0xEB = 0xEE所以第一个输出字节就是:
1
ee前 19 个字节都可以这样一步一步推出来。
第十二步:最终完整链路
现在我们终于可以把整条链路完整写出来:
输入种子
1
11fdbf1400000000000000002005000569baa7f5其中:
11fdbf14=MD5(query_string)前 4 字节20050005= 常量表选出来的 32 位常量69baa7f5= 秒级时间戳
第一阶段输出
1
fd7f2a6ffaa7b441f3fa6de1a2ec827536f84f98第二阶段输出
1
ee5058dbfb98bb0c48a807d688d9516d32e8dd0d拼接成最终 26 字节 raw buffer
1
2
84 04 a0 48 00 00
ee 50 58 db fb 98 bb 0c 48 a8 07 d6 88 d9 51 6d 32 e8 dd 0d也就是:
1
8404a0480000ee5058dbfb98bb0c48a807d688d9516d32e8dd0d再转成 hex 字符串
最终出现在 trace 里的就是:
1
8404a0480000ee5058dbfb98bb0c48a807d688d9516d32e8dd0d第十三步:把 AI 真正放在“分析搭子”的位置上
这次过程里,我觉得最值得分享的,不只是技术结果,而是和 AI 协作的方法。
我自己最大的体会是:
1. 不要让 AI 直接猜答案
如果一开始就问:
“这个值是什么算法算出来的?”
AI 很容易给出一些“听起来像那么回事”的回答,但缺少证据链。
更好的方式是:
- 先让它帮你拆问题
- 再让它帮你组织 trace 证据
- 最后让它帮你验证
2. 先收缩问题,再谈算法
这次如果一开始就盯着:
1
8404a0480000ee50...去猜,很容易陷入混乱。
真正有效的方式是:
1
2
3
4
5
字符串
-> 原始 26 字节
-> 20 字节中间材料
-> 3 段源头
-> 每一段各自解释每次只解决一个子问题。
3. 先证实“这一步是什么”,再去命名“它为什么这样设计”
比如:
- 先证实
11fd...确实是 MD5 前 4 字节 - 先证实
69baa7f5确实是秒级时间戳 - 先证实第一阶段确实能用 RC4-like 变体跑通
然后再去讨论:
- 为什么作者要这么设计
- 这种混淆有什么目的
这样思路不会乱。
4. 能写成脚本的,一定写成脚本
当我们把分析过程写成 Python 以后,事情就从:
“我大概看懂了”
变成:
“我可以输入 seed,稳定跑出 stage1 和最终值”
这是质变。
附:最终
它现在已经能做到:
- 从
seed直接算出第一阶段stage1 - 从
stage1直接算出最终 20 字节 - 拼出最终 26 字节 raw buffer
- 校验输出和 trace 一致
结语
这次逆向最有意思的地方,不在于“答案有多神秘”,而在于:
我们是怎么一步一步把一个看似随意的固定字符串,
1
8404a0480000ee50拆解成:
- query string 的 MD5 前 4 字节
- 一个常量表值
- 一个秒级时间戳
- 一段 RC4-like 魔改流变换
- 一段位运算混淆
最后把整个生成过程复现出来的。
如果让我总结这次与 AI 协作逆向的经验,我会说:
AI 最强的地方,不是替你“神乎其神地猜中”,
而是陪你把一个复杂问题拆成很多小问题,然后每一步都做成有证据、可验证、可复现的结论。
这才是它在逆向中最有价值的地方。
另文章也是 ai 写的,文字也毫无人机感。
文章参考链接
深入解析 unidbg 动态追踪 X-Gorgon 生成算法
工具链接
