skuukzky
文章15
标签0
分类2

文章分类

ai 时代下如何更好的利用 ai 去逆向(TikTok X-Gorgon 分析)

引子

很多时候,逆向真正难的不是“看不懂汇编”,而是“信息太多”。

尤其是面对几百万行、几百 MB 的 trace 日志时,人工逐行翻几乎不现实。
这时候,AI 最适合扮演的角色不是“替你猜答案”,而是“帮你稳定地组织证据、缩小范围、验证猜想”。

这篇文章就是一个完整案例。

我们从 trace 中一个看起来很普通的固定字符串片段:

1
8404a0480000ee5058dbfb98bb0c48a807d688d9516d32e8dd0d

一路追到了:

  1. 它并不是一个直接写死的常量字符串
  2. 它来自一块 26 字节原始缓冲区
  3. 这 26 字节里有一部分来自 query string 的 MD5 前 4 字节
  4. 另一部分来自时间戳和常量表
  5. 中间还经过了一段 RC4-like 的“魔改 RC4”变换
  6. 最终得到:
1
ee5058dbfb98bb0c48a807d688d9516d32e8dd0d

ai 在应对汇编这种,是有天然优势的,但是我们无法把几百万行的汇编乃至上千万汇编直接丢改 ai 去分析,这时我们应该一些文件搜索工具去处理,慢慢去引导。

image-20260320114945008

我想借这个过程,展示一件很重要的事:

当你把 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

此时最自然的问题是:

  1. 这个字符串是哪里来的?
  2. 它是被直接拷贝进来的,还是运行时算出来的?
  3. 如果它是算出来的,参与计算的源数据是什么?

工具选择:先解决“大文件无法人工通读”的问题

这次分析使用的是:

  • 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)

这一步很重要,因为它会直接决定你的追踪方向:

  • 如果它是立即数,你应该追寄存器和常量池
  • 如果它是堆块,你应该追“谁申请的、谁写进去的、谁引用它”

于是搜索策略就变成了:

  1. 0x6f68213970
  2. 搜保存它的对象字段地址
  3. 搜它的第一次写入

第二步:确认这个字符串不是“直接拷贝来的”,而是“十六进制编码后的结果”

继续往前看,很快能看到这个关键信息:

程序不是直接把这串文本写进 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

这部分是单独写进去的。

其中:

  • 84
  • 04
  • a0
  • 48

分别在不同位置写入

而:

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

于是分析目标就从“大字符串”一步步收缩成了:

  1. 11 fd bf 14 是什么?
  2. 20 05 00 05 是什么?
  3. 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&region=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&region=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 往前看,发现:

  1. 有一块 256 字节区域 x27
  2. 先被初始化成:
1
S[i] = i
  1. 随后有一个循环,每轮都会:
    • 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 和最终值”

这是质变。


附:最终

它现在已经能做到:

  1. seed 直接算出第一阶段 stage1
  2. stage1 直接算出最终 20 字节
  3. 拼出最终 26 字节 raw buffer
  4. 校验输出和 trace 一致

结语

这次逆向最有意思的地方,不在于“答案有多神秘”,而在于:

我们是怎么一步一步把一个看似随意的固定字符串,

1
8404a0480000ee50

拆解成:

  • query string 的 MD5 前 4 字节
  • 一个常量表值
  • 一个秒级时间戳
  • 一段 RC4-like 魔改流变换
  • 一段位运算混淆

最后把整个生成过程复现出来的。

如果让我总结这次与 AI 协作逆向的经验,我会说:

AI 最强的地方,不是替你“神乎其神地猜中”,
而是陪你把一个复杂问题拆成很多小问题,然后每一步都做成有证据、可验证、可复现的结论。

这才是它在逆向中最有价值的地方。
另文章也是 ai 写的,文字也毫无人机感。

文章参考链接

深入解析 unidbg 动态追踪 X-Gorgon 生成算法

[原创]unidbg 读写跟踪还原 X-Gorgon

[原创]tt x-gorgon 分析

工具链接

高性能 ARM64 执行 trace 可视化分析工具

GumTrace

large-text-viewer

本文作者:skuukzky
本文链接:https://lpy30m.github.io/skuukzky.github.io/2026/03/20/%E9%80%86%E5%90%91/ai%E6%97%B6%E4%BB%A3%E4%B8%8B%E5%A6%82%E4%BD%95%E6%9B%B4%E5%A5%BD%E7%9A%84%E5%88%A9%E7%94%A8ai%E5%8E%BB%E9%80%86%E5%90%91-TikTok-X-Gorgon-%E5%88%86%E6%9E%90/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可