吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 843|回复: 4
上一主题 下一主题
收起左侧

[原创] 【原创】从零开始解开微信 xlog 加密日志(启动篇):三层迷雾与密钥狩猎

  [复制链接]
跳转到指定楼层
楼主
ACL 发表于 2026-10-3 20:34 回帖奖励
本帖最后由 ACL 于 2026-10-3 20:39 编辑

声明:本文仅供学习交流;所有分析均在本人设备、本人账号上进行,请勿用于任何非法用途。

这是一份分析笔记的整理稿,按当时的分析顺序记录——从"想看一眼微信的日志"开始,到把 34MB 加密文件变成 9 万多行明文结束。中间猜错过两次方向,走进过三条死路;这些按原样保留,因为回头看,它们和最后的突破一样有价值。
所有偏移值针对微信 4.1.15.11(x64);换版本需按文末方法重定位。

0. 开始之前:这篇文章在讲什么

一句话:微信 PC 版把所有内部日志加密后写进 .xlog 文件,这篇文章记录我如何从零把这层加密完全解开。

预期读者:会点逆向工具(IDA / x64dbg / frida 至少用过其一)、懂基本 C++ 和 Windows 进程模型的同学。——所有背景会在文中按需补齐。

工具箱:IDA(静态反编译)、x64dbg(动态调试)、frida(运行时注入与 hook)、Python(脚本)。没有魔法,全是常规武器。

0.1 资料来源与可信度分级

逆向分析里"知道一件事"分三档,本文尽量把每个关键结论都推到第三档:

档位 含义 例子
① 道听途说 社区文章/论坛帖子说 "微信日志好像是 TEA 加密的"
② 权威出处 官方源码/文档明文写着 mars 仓库 log_crypt.cc 里的 TEA 实现
③ 一手实测 自己跑通验证 拿提取的密钥解开真实日志块,zlib 正常解压、正文可读

本文引用的资料:

资料 用途
Tencent/mars(微信官方开源的跨平台基础库) xlog 格式、加密算法、LogCrypt 类布局的权威出处——这是整个分析的基石,仓库 mars/xlog/crypt/ 目录下有全部加解密源码和官方 python 解码脚本 decode_mars_crypt_log_file.py
wustMeiming/XlogDecoder、mars-xlog-cli(社区解码工具,Java/Rust 各一) 佐证行业共识:拿到密钥就能解,难点在密钥——两个工具的用法都是"文件 + 密钥",谁都没解决密钥从哪来
weixin.1337 明文补丁(社区流传的 4.1.10.27 明文补丁) 字符串混淆破解结果的独立佐证(见 4.4 节)
微信 4.1.15.11 / 4.1.10.27 两个版本的二进制 全部实测结论的载体

你可能会问:既然有官方解码脚本,为什么不直接用?
官方脚本解决的是"有钥匙怎么解"——它需要 ECDH 私钥(在服务端我们拿不到)或现成的会话密钥。我要攻的是另一个问题:"钥匙从哪来"。这两件事的关系,相当于"有了门禁卡怎么刷卡"和"怎么搞到门禁卡"。

0.2 名词速查表

术语 一句话解释
xlog 微信日志库 mars 的日志组件,落盘 .xlog 文件
mars 微信开源的跨平台基础组件库(GitHub 可下载源码)
TEA Tiny Encryption Algorithm,一种 64 位分组的对称加密算法,标准实现 16 轮
ECDH 椭圆曲线 Diffie-Hellman 密钥协商,双方各出公私钥对,算出同一把共享密钥
zlib / zstd 两种常见的无损压缩算法
hook 运行时拦截/替换某个函数执行的技术,能看到函数的进出参数
frida 一个动态插桩框架,写几行 JavaScript 就能 hook 目标进程里的函数
.rdata PE 文件里的只读数据区,编译期常量(字符串、vtable)住在这里
RVA 相对虚拟地址。运行时真实地址 = 模块基址 + RVA。跨进程分析必须用 RVA 表述,因为基址每次启动都变
vtable C++ 虚函数表。有虚函数的类,对象首 8 字节存的就是 vtable 指针
epoch 我给"一次微信进程生命周期"起的简称。每次启动微信换一把新日志密钥,一个 epoch
mmap 内存映射文件。把磁盘文件映射进内存当数组访问

1. 起点:一个没有日志的排障现场

事情起于一次排障:我需要知道微信 PC 版在某一步内部到底干了什么。程序没有源码,日志是唯一可能的窗口——所以我做的第一件事不是开反汇编器,而是把"日志能不能看"这个问题彻底盘了一遍。

先试最便宜的:DebugView。 挂上一段时间,输出是空的。空的也要问为什么——我用 IDA 扫了 Weixin.dll 里 OutputDebugStringA/W 的所有调用点,发现它们全部落在 CRT 断言处理器和崩溃处理器的代码里,业务代码零引用。结论:业务日志根本不走这条通道,DebugView 空是正常的。

再试命令行开关。 逆向微信的命令行解析器,看到确实注册了 --log-level 和 --enable-console-log。实测 Weixin.exe --enable-console-log --log-level=0,提示符直接返回,一个字都没有。这一次我学乖了,没有立刻下结论,而是把开关的归属查清楚:同一批注册的开关里密密麻麻全是 cservice_event_*、wmpf-*——这是小程序框架(WMPF)子进程的参数表,跟主进程日志无关。再叠加两层:微信是 GUI 子系统程序,不继承父控制台;已有实例在运行时新进程走单实例转发直接退出。三重死因,"加参数看日志"这条路判死。

然后把进程的输出通道全部列出来。 OutputDebugString(死)、Qt 层日志(只覆盖 Qt 自身告警,与微信内核无关)、还有最后一条:mars xlog——内核和全部业务模块的全量日志都从这走。

跟着这条线翻到 %APPDATA%\Tencent\xwechat\log\,答案就躺在那:mm_xxx.xlog(mm1-n多开日志文件),单日 34MB。网络调度、CDN 下载、消息同步,全都在里面——只是用十六进制编辑器打开是这样的:

0000: 07 17 00 17 00 cf c7 00 00 d0 75 85 38 a8 cf 1c
0010: b1 64 0c 40 96 ab 6d b8 3d b6 53 4c 18 2b 95 72   ← 之后全高熵

头部之后全是高熵字节,典型加密形态。开头那 9 个字节(07 17 00 17 00 cf c7 00 00)结构感很强,我当时不知道它们是什么,先记在笔记里——几个小时后它们会自己开口说话。

至此问题从"微信有没有日志"变成了一个明确的技术问题:怎么解开这个文件。

2. 溯源:这日志库是谁写的

面对一个加密文件,我的第一反应不是开始逆向加密算法——是先问它是谁写的。理由很简单:如果它来自开源库,格式和算法就全是公开的,我用不着从二进制里挖。

怎么溯源?翻 Weixin.dll 的 .rdata 字符串区——IDA 里 Shift+F12 打开 Strings 窗口,这个 300MB 级的 DLL 有几十万条字符串,全翻不现实,得带着问题搜:一个日志库,谁会在什么场景下留下字符串? 断言(参数为空时打印方法签名)、错误自检(初始化完成时报告配置)、__FILE__ 宏(编译进 assert 的源文件名)、文件命名模板(printf 格式串)。按这些形态搜关键词(logger、appender、log、.xlog),命中的第一批货:

{!!! XLogger& XLogger::operator()(const char* _format, ...): _format == NULL !!!}   ← 类名+方法签名,最硬的指纹
ERROR!!! xlogger_appender Recursive calls!!!, count:%u                              ← 模块名 xlogger_appender
log appender mode:%d, use mmap:%d, compress mode:%d                                 ← 架构自白:模式/映射/压缩三维度
appender.cc                                                                         ← __FILE__ 宏残留的源文件名
_%04d%02d%02d.xlog                                                                  ← 日志文件命名规则

五条各司其职:第一条是最硬的指纹——类名加完整方法签名,拿 "XLogger" 一搜就落到 mars 仓库,"这是开源库"从我的断言变成读者可自行复核的事实;第二条交代了模块名,对应 mars 的 appender 组件(也就是第 3 节要 hook 的 Write 所在的那层);第三条是架构自白——它招供了模式/mmap/压缩这些可配置维度,后面解块头时会看到,magic 里的标志位标记的正是写块时这类开关的实际组合;第四条是 __FILE__ 宏残留——Release 构建通常剥掉 assert,但源文件名留了下来,证明这段代码由 appender.cc 编译而来,与 mars 源码树结构对上;第五条是闭环——第 1 节磁盘上看到的 mm_xxx.xlog 正是这段代码拼出来的名字,代码和实物对上了。

值得注意的细节:这些 mars 字符串在 .rdata 里是明文,直接搜得到;而微信自己业务代码的字符串(函数名、源文件名、日志模板)在 .rdata 里是混淆态、静态搜不到——混淆器只处理了微信自有源码,mars 作为链接进来的开源库没过这道工序。这个差别第 3 节会正面撞上:hook 到手的日志里,正文和文件名、函数名字段是一段段解不开的乱码。

指纹确凿。拿 "XLogger" 一检索,答案落定:mars,微信官方开源的跨平台基础组件库,xlog 是它里面的日志组件。确认身份后我没有急着去下源码——先在 IDA 里顺藤摸瓜:双击 Recursive calls 那条字符串,按 X 查交叉引用,跳进引用它的函数。

跳进来才发现这一步的收获比预期大:引用这条字符串的函数就是 XloggerAppender::Write 本身——日志进入加密层之前的最后一站,也就是第 3 节要 hook 的那个函数,一次跳转把溯源和 hook 点都锚定了。函数开头还能看到那条日志的触发机制:Write 通过 TLS 里的递归计数器(++TLS+8)防止自身递归——日志系统写日志引发的日志,超过 10 层就打这条 ERROR 并放弃。

身份锁定后,还有一步很多文章会跳过的反向核对:把这五条字符串在 mars 源码里逐条找到出处,证明不是我单方面的联想——

DLL 里翻到的字符串 mars 源码出处(master 分支)
{!!! XLogger& XLogger::operator()(const char* _format, ...): _format == NULL !!!} mars/comm/xlogger/xlogger.cc L124——XLogger::operator() 的参数空检查分支
ERROR!!! xlogger_appender Recursive calls!!!, count:%u mars/xlog/src/appender.cc L198——递归守卫日志
log appender mode:%d, use mmap:%d mars/xlog/src/appender.cc L407——XloggerAppender::Open 的自检日志
appender.cc mars/xlog/src/appender.cc 的 __FILE__ 宏展开
_%04d%02d%02d.xlog mars/xlog/src/appender.cc L436 __MakeLogFileNamePrefix 的日期后缀 + L80 #define LOG_EXT "xlog" 拼接

二进制里的五条货,源码里五处对应——指纹核对完毕,这不是"长得像",是同一个东西。

指纹既然对上了,接下来顺理成章:加密是 xlog 的一部分,那加密的设计就该写在这份源码里。去 GitHub 把源码拉下来,直奔 mars/xlog/crypt/ 目录——加密设计全部白纸黑字写在 log_crypt.cc 里:

打开 log_crypt.cc,TEA 与 ECDH 全是明摆着的——__TeaEncrypt 函数体第三行就是 TEA 算法的公开标准常量 const static uint32_t delta = 0x9e3779b9(黄金分割常量,TEA 论文的原设计,任何 TEA 实现里都有它——看到这个数就等于看到 TEA 的签名),往下是 16 轮循环体和 128 位密钥的四个 32 位分片;同文件里 uECC_shared_secret 算出共享密钥后 memcpy 进 tea_key_。一页代码读完:

分析后把上面代码抽象成流程:

appender_open(..., pubkey, ...)
        │
        ▼
进程内: 生成临时私钥 → ECDH(secp256k1, 服务端公钥, 临时私钥) → TEA 会话密钥
        │
        ▼
每条日志块: 压缩(微信 4.x 用 zlib)→ TEA(会话密钥) 加密 → 追加写盘
        │
        ▼
块头携带临时公钥(64B)——离线解密需要 ECDH 私钥(服务器持有)或内存中的会话密钥

以上是通读 mars 加密模块(log_crypt.cc、appender.cc)后梳理出的完整加密管线,每个环节都能在源码里指到行——

流程环节 源码实证(mars master)
初始化注入服务端公钥 appender.h L56 XLogConfig::pub_key_(旧版 API 直接传参,master 已收进配置结构体)
生成临时密钥对 log_crypt.cc L110 uECC_make_key(client_pubkey_, client_pri, uECC_secp256k1())
ECDH 算共享密钥 log_crypt.cc L115 uECC_shared_secret(svr_pubkey, client_pri, ecdh_key, uECC_secp256k1())
共享密钥 → TEA 密钥 log_crypt.cc L119 memcpy(tea_key_, ecdh_key, sizeof(tea_key_))
压缩 → TEA 逐块加密 log_crypt.cc L224-239 逐 8 字节块 __TeaEncrypt;appender.h L57 压缩模式默认 kZlib
块头携带 64B 临时公钥 log_crypt.h L72 char client_pubkey_[64];cc L161 写入块头

最后一行就是整个问题的形状:块头里的临时公钥人人可读,但离线解密需要 ECDH 另一半——服务端私钥,只有服务端持有。也就是说 mars 的设计目标本来就是"日志只在服务器可解",客户端自己是"只写不读"。任何第三方想在本地解开,只剩一条路:绕过 ECDH,直接去运行中的进程内存里捞那把 TEA 会话密钥。这个认识把后面几节的走向全定了。

再回头看第 1 节记下的那 9 个字节,逐字段全对上了:

07        magic = 0x07(bit0 crypt | bit1 zlib | bit2 async)
17 00     seq(块序号,十六进制 0x0017)
17        begin_hour(23 点)
00        end_hour
cf c7 00 00   长度

每条日志记录独立带头部,加密按块进行。这张"地图"贯穿后面所有工作。

仓库里甚至有一份官方 python 解码脚本 decode_mars_crypt_log_file.py——但读它的参数就明白指望不上:它要 ECDH 私钥(在服务端)。我猜,服务端可以解所有人的日志,用户自己不能。离线暴力破解也不现实:ECDH 临时密钥对每次会话重新生成,派生出的 TEA 密钥没有已知弱点可捡。

死路一条?不。注意上面那张图里的一行小字:密钥协商发生在"进程内"。TEA 会话密钥在加密每一块日志时都躺在进程内存里。这就是活路:

分岔口。此时摆着两条路:A. hook 日志写入函数,在加密前截获明文(实时,但拿不到历史);B. 从进程内存提取会话密钥,离线解密历史文件(可回溯,但钥匙还没找到)。两条不冲突,先做快的。

你可能会问:凭什么断定微信 4.x 用的就是开源版这套格式,而不是他们自己改过?
此刻只能说"待验证"——源码给的是②档可信度(权威出处)。这个结论要等第 6 节内存里的对象和源码逐字段对上、第 7 节解密器真解开几百个块,才升到③档。源码给方向,实测盖章。

3. 弯路先行:hook 拿到的正文是乱的

先走路 A。在 mars 源码里找 hook 点:日志进入加密层之前的最后一站是 XloggerAppender::Write,第三个参数就是格式化后的正文。在 Weixin.dll 里定位这个函数(prologue 特征 + "Recursive calls" 字符串引用双重确认),frida 挂上,先来一波试试水:

# test_xlog.py — hook XloggerAppender::Write,把正文原样吐出来
import time
import frida

JS = r'''
var Write = Process.findModuleByName("Weixin.dll").base.add(XloggerAppender::Write的RVA);
Interceptor.attach(Write, {
    onEnter: function (args) {
        try { send(args[2].readCString()); } catch (e) {}   // 第 3 参:格式化后的正文
    }
});
'''

pids = [p.pid for p in frida.get_local_device().enumerate_processes()
        if p.name == "Weixin.exe"]
if not pids:
    pass

script = frida.get_local_device().attach(pids[0]).create_script(JS)
script.on("message", lambda m, _: print(m.get("payload", m)))
script.load()

try:
    while True:
        time.sleep(0.5)
except KeyboardInterrupt:
    pass

跑起来,日志哗哗地出来了——然后我发现不对劲。正文长这样:

(乱码两侧那些显示不出来的字符是标记字节,值 ≥0x80,控制台编码画不出它们——具体是什么,拆开才见分晓。)

满屏几乎读不出一个词——唯一例外是最后一行嵌着的 0.14,一个版本号,原样可读。 这一个细节排除了加密:加密作用于整条日志,不会精确放过一个数字、专挑其余部分下手。这是编译期字符串混淆——源码里的静态字符串(日志模板就是大头)在构建时被加密成这副形态编进二进制,运行时原样流进日志;版本号这类运行时才拼进来的变量没过这道工序,所以是明文。让脚本继续跑下去,这层区分会越来越醒目:Weixin.exe、xplayer 这样的进程名,一串串计数和耗时,全从乱码的缝隙里原样淌出来。还有个更直白的细节就写在图里:同一段乱码在不同行一字不差地反复出现——同一个字符串,每次混淆的结果完全相同。

顺带一提,Write 的第二个参数 XLoggerInfo 结构体里装着 tag、源文件名、函数名——给脚本加几行,把这几个指针字段指到的内容也打出来:同样全是这副乱码。正文里的日志模板、结构体里的文件名函数名,都是同一批静态字符串——全在混淆之列。

这个结构是怎么读出来的?依据不在 mars——mars 是开源的,源码里根本没有字符串混淆这道工序(第 2 节已经看到,mars 自己的字符串在 .rdata 里是明文)。也用不着反编译:乱码是自描述的,依据就写在它自己身上。 把图里第一段按字节拆开形式如下:

A1 | 1F00 | B1 | C28AFC81F91CECAC...(共 62 个 hex 字符)| C1

盯住中间的 1F00:按大端读是 0x1F00 = 7936,荒谬;字节对调一下,0x001F = 31。而它右边不多不少趴着 62 个十六进制字符——62 个字符 = 31 字节。长度字段精确预言了载荷长度。再抽查图里的另外两段:1800 → 24,后面正好 48 个字符;1200 → 18,后面 36 个。段段严丝合缝,巧合出局。结构就此定案:

[M1≥0x80][4 hex = 长度(小端)][M2≥0x80][2×len hex = 载荷][M3≥0x80]   ← 混淆段
<raw 明文>                                                            ← 运行时拼的数字/变量

三个标记字节具体是 A1/B1/C1——控制台画不出来,把字节按十六进制打出来就一目了然。载荷还原成字节后仍不是明文:还隔着最后一道变换。什么变换?此刻只有嫌疑没有证据——字符串混淆的常见做法里,和一个固定密钥流做 XOR 是最流行的那种:逐字节等长、实现最便宜、编译期能无脑批量。先按这个嫌疑走,兑付要等 4.2 节的明密文配对。

这个结构本身还是个可检索的指纹——标记字节、4 位长度字段、规整的十六进制载荷,形态高度规整。写个扫描器沿 .rdata 整段清点这个模式:

# scan_pool.py — 沿 .rdata 清点 A1/B1/C1 混淆段
import struct

DLL = r".\Weixin.dll"
data = open(DLL, "rb").read()

# 解 PE 节表,定位 .rdata 的文件偏移与大小
e = struct.unpack_from("<I", data, 0x3C)[0]              # e_lfanew
nsec = struct.unpack_from("<H", data, e + 6)[0]
opt = struct.unpack_from("<H", data, e + 0x14)[0]
for i in range(nsec):
    off = e + 0x18 + opt + i * 40
    if data[off:off + 8].rstrip(b"\0") == b".rdata":
        vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
        blob = data[rawptr:rawptr + rawsize]
        break

# 模式:A1 [4hex 长度(小端)] B1 [2×len hex 载荷] C1
HEX = b"0123456789ABCDEF"
count, i, n = 0, 0, len(blob)
samples = []
while i < n - 8:
    if (blob[i] == 0xA1
            and all(c in HEX for c in blob[i+1:i+5])
            and blob[i+5] == 0xB1):
        ln = int(blob[i+3:i+5] + blob[i+1:i+3], 16)     # 长度小端:字节对调
        end = i + 6 + ln * 2
        if (0 < ln < 0x1000 and end < n
                and all(c in HEX for c in blob[i+6:end])
                and blob[end] == 0xC1):
            count += 1
            if len(samples) < 20:                        # 留前 20 条做样本
                samples.append((vaddr + i, blob[i+1:i+5], blob[i+6:end]))
            i = end + 1                                  # 命中则跳过整段
            continue
    i += 1

print(f".rdata 共 {rawsize:#x} 字节,命中混淆段 {count} 条\n")
for k, (rva, lenhex, payload) in enumerate(samples, 1):
    show = payload[:32].decode() + ("..." if len(payload) > 32 else "")
    print(f"#{k:<3} RVA 0x{rva:08X}  A1[{lenhex.decode()}]B1<{show}>C1")

跑出来:4 万余条。

样本里还有个耐人寻味的细节:D197EBCE… 这一串密文在 #1、#13~#16、#18~#20 共 8 条里一字不差地反复出现——长度各不相同、地址各不相同的段,共享同一个密文开头。最自然的解释:这些字符串的明文共享相同的前缀(C++ 代码里太常见了,比如同一批函数签名都以相同的返回类型开头),而且它们全部对齐到密钥流的同一个相位——否则密文不可能对得上。相位到底是多少、按什么规则定,光看样本答不出来;4.1 节的频率分析会给出答案:每段都从密钥流第 0 字节起跳。

函数签名、源文件名、日志模板,微信自有代码的字符串几乎全数在册。也就是说:微信在编译期给这 4 万多条字符串统一上了一道逐字节的混淆工序。而那把对应的钥匙,静态分析里遍寻无着——.rdata 里躺着的只有混淆后的载荷,解码函数也没有着落。这道工序留下的唯一线索,就是密文本身。

这层东西横在两条路的中间:

  • 路上(实时 hook):函数名/文件名全是乱码;
  • 路下(离线解密):就算解开了日志块,正文里还是这层乱码。

绕不过去,只能破解它。 另外还有个私心:4 万条字符串是整个微信二进制的"自我说明书",破解它们等于拿到全量符号表。

4. 破解那把 4 万条密文共用的钥匙:一次纯密码分析

钥匙还不知道多长,先给这个数留个悬念——4.4 节揭晓。第 3 节末尾把"逐字节 XOR 固定密钥流"定为头号嫌疑,这一节就按这个假设来打。流密码的经典弱点是已知明文攻击:拿到一段"密文 + 对应明文",逐位 XOR,密钥流本身就直接掉出来。问题只有一个——明文从哪来。我按三步走,每步都比上一步更接近;假设对不对,让结果说话(4.2 节会正式兑现)。

4.1 第一步:频率分析,投出一个模糊的影子

源代码字符串里最高频的字节是什么?空格(0x20)。由此可以做一个统计攻击:对密钥流的每个位置,统计"哪个候选密钥字节能让所有对齐到这个位置的密文字节都变成空格",每个位置投出一票。

这个方法噪声很大——空格不是每个位置都出现——但它给了我第一版密钥流草案,还顺带裁决了一个悬而未决的问题:密钥流按什么索引? 如果每段都从密钥流第 0 字节起跳(与字符串在文件里的地址无关),所有段的空格票就会凝聚到同一个候选上、投出清晰峰值;如果按地址或随机相位对齐,四万条字符串的选票会互相抵消、一片平坦。实测是前者——密钥流按段内偏移索引,每段从位置 0 开始,各段共享同一条流。第 3 节图里"不同段共享同一密文前缀"的悬念,到这里有了完整答案。这也是后续所有步骤的前提。

4.2 第二步:明密文配对——.rdata 送了一份大礼

草案错误太多,纯统计走不动了。我回到 .rdata 里继续翻字符串,然后撞见了决定性的事实:

同一批字符串,明文版和混淆版并存。

同一句日志模板,有的调用点用明文形态存,有的用混淆形态存。为什么?不清楚,大概是历史演进留下的。但对流密码这是致命伤——明文和密文躺在一起,等于免费发放已知明文。

方法顺理成章:按(长度,首两字节密文)把明文串和混淆串配对,逐位 XOR。前 116 字节的密钥流直接读了出来——不是统计猜测,是逐字节确定的。

配对能按长度对上,顺带把两件事一起坐实了:明文长度 = 载荷字节数,变换逐字节等长;以及第 3 节留下的嫌疑就此定罪——不同的字符串、不同的密文,逐位 XOR 后解出的竟然是同一条自洽的流,除了"XOR 同一密钥流"没有第二种解释。

4.3 第三步:trigram 语言模型,吃掉剩余部分(可借助 AI 完成)

116 字节之后,配对明文用光了。剩余位置换武器:语言模型。

用已经解出的那批字符串(凡是混淆段长度不超过已知密钥流前缀的串,此时都已还原——绝大多数是短串)训练一个三阶马尔可夫模型(用前两个字符预测第三个字符的分布),然后对每个未知密钥字节位置做约束求解:候选字节代入后,看解出的文本在这个语言模型下的得分。这里有个当时没预料到的坑:单看一个位置,经常有多个候选字节都能拼出"合法文本"——并列满分。这时上下文就发挥作用了:正确解不只让当前位置可读,还让它和前后字符组成的 trigram 概率更高。三阶模型把并列彻底撕开了。

撕开并列的算法本体长这样。完整脚本过长,这里抽核心:语料训练、逐位投票、margin 停机三件事都在,外围的 PE 解析、载荷提取、自检直接略过——

# corpus  = 已解出的干净明文串(bytes 列表,几万条)
# longs   = 比密钥流更长的载荷(bytes 列表)
# key     = 已知密钥流前缀(bytearray)
import collections, math

tri, bic, big, uni = (collections.Counter() for _ in range(4))
for s in corpus:
    for i in range(len(s) - 2):
        tri[(s[i], s[i+1], s[i+2])] += 1
        bic[(s[i], s[i+1])] += 1
        big[(s[i+1], s[i+2])] += 1
        uni[s[i]] += 1

V, tot = 96, sum(uni.values())              # 可打印 ASCII 的有效字母表
def logp(a, b, c):
    # 三阶 → 二阶 → 一阶逐层回退;不可打印字符一律重罚
    p3 = (tri.get((a, b, c), 0) + 0.1) / (bic.get((a, b), 0) + 0.1 * V)
    p2 = (big.get((b, c), 0) + 0.1) / (uni.get(b, 0) + 0.1 * V)
    p1 = (uni.get(c, 0) + 0.1) / (tot + 0.1 * V)
    if c < 0x20 or c >= 0x7f:
        p3 = p2 = p1 = 1e-4
    return math.log(0.6 * p3 + 0.3 * p2 + 0.1 * p1 + 1e-12)

# 逐位推进:第 pos 字节的 256 个候选,让所有长载荷联合投票
prefixes = [bytes(p[i] ^ key[i] for i in range(len(key))) for p in longs]
while any(len(p) > len(key) for p in longs):
    pos = len(key)
    scores = []
    for k in range(256):
        s = 0.0
        for p, pre in zip(longs, prefixes):
            if len(p) > pos:
                a = pre[pos-2] if pos >= 2 else 0x20
                s += logp(a, pre[pos-1], p[pos] ^ k)
        scores.append((s, k))
    scores.sort(reverse=True)
    (s0, k0), (s1, _) = scores[0], scores[1]
    if s0 - s1 < 3.0:                        # 领先优势太小:证据不足,停
        break
    key.append(k0)
    prefixes = [pre + bytes([p[pos] ^ k0]) if len(p) > pos else pre
                for p, pre in zip(longs, prefixes)]

每一票都是"这个候选字节让某条长载荷在当前位置拼出的 trigram 有多常见";几十条载荷同时投,正确字节的优势会滚雪球。最后一道保险是领先幅度(margin):第一名的得分甩不开第二名就说明证据不足,宁停勿猜。

这一步解到 397 字节。所有已有载荷全部干净解出。

顺带一提(兑现标题那句"可借助 AI 完成"):AI 在这一步的正确用法,不是把乱码丢给它猜——这套战果的本质是几万条载荷的联合统计投票,通用模型的语言直觉替代不了领域统计——而是让它代劳把"语料训练 + 逐位投票 + margin 停机"这套骨架写成脚本,你只管盯着它有没有把停机条件写对。

4.4 最后一个问题:密钥流到底多长?

397 字节之后没有出现任何新的约束,但我不知道是"流到头了"还是"我的载荷都不够长"。这个问题的答案决定了破解是否完整——每条长载荷的第 i 字节必然用密钥流第 i 字节加密,所以超长载荷会对密钥流的更深处投票。

手上 15.11 的长载荷用完了,我找来另一个版本 4.1.10.27 的 DLL。两个版本交叉验证还附赠一个重要发现:密钥流是硬编码常量,跨版本完全一致(这也意味着一次破解、全版本通用)。两版本的长载荷合并投票,607 字节之后不再出现任何新约束,且两个版本全部 4 万余条字符串解出来 100% 干净。

密钥流长度:607 字节。定案。

事后还拿到一份独立佐证。github上流传一个 4.1.10.27 的 x64dbg 明文补丁 weixin.1337(实测为不完整版)——它的来历本身就是个有趣的注脚:有人用调试器把微信里混淆的字符串逐条手工还原成明文,再打包成补丁文件,让打上补丁的微信直接"说人话"(仓库在这里,用法是 x64dbg 附加微信后导入补丁)。我用它和自己的破解结果对了一遍:18,487 条里 18,479 条逐字节一致,余下 8 条只差尾部换行。两个互不知情的来源——一个是我的统计破解,一个是别人的手工活——得出同一份答案,"解错了但凑巧能读"的概率基本归零。

诚实的瑕疵声明:607B 中 560B 之后只受 1~3 条载荷约束,实际日志段基本都在 607B 内,无感。

到这里,4 万条字符串全部还原——微信二进制的"自我说明书"到手。但我心里清楚这只是支线:主线那把 TEA 会话密钥,还不知在哪。

5. 回到主线:钥匙在哪——三条死路

回到方案 B。源码已经把钥匙的形状交代清楚了:密钥协商用 ECDH(secp256k1),会话密钥是共享密钥的前 16 字节,存在某个对象里。但对象在哪?

5.1 死路一:扫 TEA 特征常量

第一个想法最直接:TEA 算法有个著名的 delta 常量 0x9E3779B9,拿它当特征扫全文件。41 处命中——当时相当兴奋,感觉一把梭就能定位加密函数。

然后是逐区反汇编。一区、两区、三区……全军覆没:

  • 前两个区是 boost 的 hash_combine(移位混合 + 黄金比例,数学上同源);
  • 一个区是 double 运算的字节巧合;
  • 剩下的是 xxhash 家族。

41 个命中,一个真 TEA 都没有。 黄金比例这个数在计算机科学里太受欢迎了。

5.2 死路二:crypt.cc 加密函数

第二个想法顺着字符串走。.rdata 里有明文的 crypt.cc 和 encrypt_buf(assert 残留的参数名),顺着引用定位到一个形态很像的加密函数,x64dbg 下断点——不命中。再等,还是不命中。

想了一晚上才想明白错在哪:mars 是个多模块库,crypt.cc 属于网络传输加密(stn/comm 层),xlog 的加密在 log_crypt.cc——Release 编译把 assert 连带字符串一起剥离了,所以它在二进制里没有名字。我找到的是隔壁模块的同名兄弟。

这条死路给我的教训后来反复救命:静态定位的函数,必须运行时验证命中才算数。

5.3 死路三:appender+0x220 缓冲区

第三个想法回到对象层面。日志 appender 是个单例,通过一个全局锚点在内存里一跳可达。我把它逐字段摸了一遍布局——目录字符串、流名、公钥、各种计数器,然后注意到一个构造时 memset 0、大小 0x428B 的缓冲区。形态完美符合"密钥对候选区":清零起步、尺寸对得上 32B 私钥 + 32B 共享密钥的富余空间。

hexdump 出来——是日志文件路径字符串。

三个晚上,三条死路。晚上复盘的时候我把三张 hexdump 并排贴在笔记里,问了自己一个问题:这三条路有什么共同点?

答案让我坐直了:我一直在按"密钥应该长什么样"的想象去搜内存——搜 TEA 常量(想找算法)、搜函数名(想找加密点)、猜缓冲区(想找密钥对)。而我手里其实一直握着一样确定的东西,从第一天起就躺在文件头里。

6. 转折:我手里其实有公钥

回到第 2 节那张加密设计图,有两行字我此前只是扫过没细想:

  • 每个日志块的块头携带 64 字节临时公钥;
  • mars 源码的 LogCrypt 类里,tea_key 和 client_pubkey 相邻存在同一个对象里。

把它们连起来:块头里有公钥(我确定拥有的),公钥隔壁就是密钥(我想要的)。 那我为什么不直接搜公钥?!搜确定值,命中处邻域就是 LogCrypt 对象,密钥就躺在里面。

思路彻底反转,三步走:

第一步,从活文件拿活公钥。 注意一个细节:公钥每个 epoch 换一把,我要的是"当前正在用的那把",而不是文件里随便哪个历史块的。办法是利用 mmap——微信正在写的 xlog 文件被它自己映射在内存里,遍历进程内存中带 .xlog 路径的映射区,逐块走 73 字节块头(校验 magic=0x07)取 pubkey。当前会话的公钥 = 最新文件里文件偏移最大的块——天然不受拷贝时间差影响。

第二步,全堆扫这 64 字节。 拿公钥的二进制序列扫进程全部私有内存,3 处命中。

第三步,命中邻域 hexdump,对源码表。 三处命中里有一处,邻域逐字段对上了 log_crypt.h 的类定义:

class LogCrypt {
    virtual ~LogCrypt();        // +0x00  vtable
    uint16_t seq_;              // +0x08  当前块序号(实测与块头一致)
    uint32_t tea_key_[4];       // +0x0C  ★ 16B TEA 会话密钥
    char client_pubkey_[64];    // +0x1C  本 epoch 公钥(与块头逐字节一致)
    bool is_crypt_;             // +0x5C  加密开关(实测 01)
};

找了三个晚上的密钥,和公钥肩并肩躺在一起,就在 +0x0C。那一刻的心情不是狂喜,是想笑——三个晚上搜的是想象,答案一直明晃晃摆在文件头里。

你可能会问:凭什么信"命中处就是 LogCrypt",而不是恰好含有相同字节的无关数据?
四重独立证据:① 对象首字段的 vtable 落在 Weixin.dll 的只读数据区(堆对象指向模块 .rdata,C++ 虚类的标准形态);② seq_ 与该对象加密的最新块头序号一致;③ client_pubkey_ 与块头公钥逐字节一致;④ is_crypt_=1。四个证据全对上的"巧合",概率上不存在。

顺手把指针链也补齐——进程内取钥匙从此不用扫内存:

Weixin.dll 全局锚 → appender → +0x98 → +0x30 → LogCrypt
                                    (读 vtable 与已知值比对,防走错)

7. 解密器:一个下午写完,一次跑通

算法层没有任何要发明的东西,全部出自源码:TEA 标准 16 轮 ECB(8 字节分组;只加密 floor(len/8)*8,尾部零头明文附加,解密侧同样切齐);payload 是 zlib raw deflate(无 zlib 头,新版微信另有 zstd 变体,magic 0x0A~0x0D);解压后的正文再过一遍第 4 节的 607B 解码。三层流水线,一个下午写完。

验证从单块开始。 先写了个 oracle 小脚本:拿这 16 字节密钥解文件里的真实块,看 TEA 解密的结果能不能 zlib 解压。抽 4 块:4/4 解压 OK,正文 100% 可读。

# xlog_oracle.py — 拿内存里取到的 TEA 会话密钥,验证真实块能否解开
# 用法: python xlog_oracle.py <xlog文件> <tea_key 32位hex> <pubkey 128位hex> <解密输出路径> [607B密钥流文件]
#   tea_key: LogCrypt+0x0C 的 16 字节会话密钥(见第 6 节)
#   pubkey : 最新块头里的 64 字节临时公钥;传 'list' 则只列各 epoch 公钥与块数不解密
#   第 5 参可选: 607B 密钥流文件路径(第 4 节自己算出来);省略只解到第二层
import struct, zlib, sys

XLOG, PK_CUR, OUT = sys.argv[1], sys.argv[3].lower(), sys.argv[4]
TEA_KEY = b'' if PK_CUR == 'list' else bytes.fromhex(sys.argv[2])  # list 模式下 argv[2] 随便填

KEYSTREAM = sys.argv[5] if len(sys.argv) == 6 else ''   # TODO: 或写死路径,如 'keystream.bin'

def deobfuscate(raw):
    if not KEYSTREAM:
        return raw                                   # 没配密钥流:只解到第二层
    ks = open(KEYSTREAM, 'rb').read()
    # TODO: 按第 3 节的段结构切分,段内第 i 字节 XOR ks[i],raw 明文段原样保留
    #   陷阱:长串段会被 256B 缓冲拦腰截断(hex 恰 249 字符、无 C1),
    #         按偶数前缀解出前 124B——线索在本节末尾的「小尾巴」一段
    return raw                                       # TODO 未完成前:原样返回,别吞正文

def tea_decipher(v, k):                    # TEA-ECB 16 轮解密一个 8 字节块
    op = 0xffffffff
    v0, v1 = struct.unpack('=LL', v[0:8])
    k1, k2, k3, k4 = struct.unpack('=LLLL', k[0:16])
    delta, s = 0x9E3779B9, (0x9E3779B9 << 4) & op
    for _ in range(16):
        v1 = (v1 - (((v0 << 4) + k3) ^ (v0 + s) ^ ((v0 >> 5) + k4))) & op
        v0 = (v0 - (((v1 << 4) + k1) ^ (v1 + s) ^ ((v1 >> 5) + k2))) & op
        s = (s - delta) & op
    return struct.pack('=LL', v0, v1)

def tea_decrypt(v, k):                     # 只解 floor(len/8)*8,尾部零头明文附加
    num = len(v) // 8 * 8
    return b''.join(tea_decipher(v[i:i+8], k) for i in range(0, num, 8)) + v[num:]

def walk(data):                            # 73B 块头 + 载荷 + 00 结尾,逐块解析
    pos, blocks = 0, []
    while pos + 73 <= len(data):
        if data[pos] != 0x07:
            nxt = data.find(b'\x07', pos + 1)
            if nxt < 0: break
            pos = nxt; continue
        ln = struct.unpack('<I', data[pos+5:pos+9])[0]
        if pos + 73 + ln + 1 > len(data) or data[pos+73+ln] != 0x00:
            nxt = data.find(b'\x07', pos + 1)
            if nxt < 0: break
            pos = nxt; continue
        blocks.append({'off': pos, 'len': ln,
                       'pk': data[pos+9:pos+73].hex()})     # +9 起就是 64B 公钥
        pos += 73 + ln + 1
    return blocks

data = open(XLOG, 'rb').read()
blocks = walk(data)
if PK_CUR == 'list':                                   # 列各 epoch 公钥+块数 (* = 最新)
    cnt, last = {}, blocks[-1]['pk'] if blocks else ''
    for b in blocks:
        cnt[b['pk']] = cnt.get(b['pk'], 0) + 1
    for pk, n in cnt.items():
        print('%s%s  %d 块' % ('* ' if pk == last else '  ', pk, n))
    raise SystemExit(0)
tgt = [b for b in blocks if b['pk'] == PK_CUR]         # 只打当前 epoch 的块
if not tgt:
    raise SystemExit('[-] 目标 epoch 零块: pubkey 对不上? 先跑 list 模式看')
ok = 0
with open(OUT, 'wb') as f:
    for b in tgt:                                            # 当前 epoch 全部块
        payload = data[b['off']+73 : b['off']+73+b['len']]
        enc = b['len'] // 8 * 8
        dec = tea_decrypt(payload[:enc], TEA_KEY) + payload[enc:]
        try:
            raw = zlib.decompressobj(-zlib.MAX_WBITS).decompress(dec)   # raw deflate
            f.write(deobfuscate(raw))          # 第三层:配了 KEYSTREAM 才会真正解码
            ok += 1
        except Exception as e:
            print('[-] off=%#x  FAIL: %s' % (b['off'], e))
print('[+] %d/%d 块解开,明文已写入 %s' % (ok, len(tgt), OUT))

zlib 能解压就是铁证:密钥错一个 bit,解出来的是乱字节,deflate 的霍夫曼表立即崩——不存在"错了但碰巧能解"的侥幸。

不知道 pubkey 该传什么?第 3 参换成 list(tea_key 随便填个 0)就只做枚举不解密:从文件自身逐块头列出各 epoch 的公钥和块数,* 标的是最新 epoch——第 6 节从活进程 mmap 抓公钥是运行时路线,离线场景这一条就够。

这份代码解到第二层为止。第三层(607B 解码)故意留空(方法管够,白嫖免谈):密钥流文件路径的接口已经用命令行第 5 参留好,段结构第 3 节拆过了,解码是一行 XOR,但那 607 个字节是第 4 节一路推下来的战果——想看全明文,得自己把它算出来。

然后是全量:

验证 结果
13MB 样本拷贝 265 完整块:78 块全解 / 187 块旧 epoch 无钥匙 / 0 解析失败
微信持续写入中的活文件 289 完整块:102 块全解,解密至拷贝瞬间那一秒,共 96,970 行

全量跑完还剩个小尾巴:个别日志行里仍嵌着解不开的混淆段。换一份当日小样本(460KB、9 块、8 千余行)单独验证第三层并做精确统计:19577 条混淆段里 19269 条顺利还原,另有 308 条纹丝不动。逐条对账,它们长得分毫不差——载荷 hex 恰好 249 个字符、后面紧跟 ]、没有 C1、声明长度最小的也有 126 字节。249 是个奇数,而 hex 载荷按字节编码只可能是偶数;308 条全是同一个奇数,随机损坏出局,这是系统性的截断。算笔账就明白了:段头 A1+4hex+B1 共 6 字节,加上 249 个 hex 字符正好 255——一个 256 字节缓冲(255 字符 + 结尾 NUL)的满刻度。字符串参数写日志前要先过一道 256 字节的搬运缓冲,混淆段总长一旦超过 255 字节就被拦腰截断:C1 和尾部载荷从来没落到盘上。所以这几条不是解不开,是没写进来——文件里根本不存在那段信息。界限也干脆:字符串不超过 124 字节时段总长恰好 255,完整通过;超过就切。能救的是前 124 字节,按偶数前缀解出即可。这 308 条全是过长的 C++ 函数签名,散布在头像、会话、消息存储等十几个模块里(kernel::service::HeadImageService::GetContactHeadImage(const std::string &, task_queue::TaskScene, int, const ContactHeadSou ——掐在标识符中间),前缀足够辨认出身,解码器补一个截断分支就全收了(搞定)。

8. 效果日记:解开之后看到了什么

一行解出的日志长这样(脱敏):

[I][2026-10-01 14:46:51.831][5416, 4832][mars::smc][report_manager.cc:745,
 mars::smc::ReportManager::__HandleFile][KVDATAFLOW(file) ready to delete filename:...]

时间戳、进程/线程 ID、tag、源文件:行号、完整函数签名、正文。随手翻翻就能看到的:

  • 启动期全景:mmap 初始化、模块加载顺序——hook 手段永远抓不到 hook 挂载之前的部分,xlog 全有;
  • 消息发送链全貌:从发起到 cgi 请求再到服务器回包,完整时间线带耗时(实测一条消息 190ms 闭环);
  • 网络层内部:选网通道、防雪崩、频率限制——这些 DEBUG/VERBOSE 级日志在实时 hook 时默认被级别门拦住,但在 xlog 里全都落了盘;
  • 内部命名体系:cmdid、模块名、状态机字段名,反过来成为下一轮逆向分析的检索锚点。

最后做了一件收尾的对账:写脚本把 hook 日志和 xlog 解密结果做同进程同时段逐条对拍。结论:xlog 侧更全(含启动期),hook 独有条 99.3% 在 xlog 里有 ±5ms 同线程记录(差异只是时间戳取点不同)。日常取日志从此免 hook——hook 只在需要秒级实时盯流时用。

9. 残酷的边界:epoch 模型

解密器全量跑的时候,统计行里反复出现 [KEY-MISSING]——265 块里 187 块没钥匙。一开始我当 bug 排查,查下来发现这是设计上的死亡边界,而且逻辑链条自己就推得出来:

  • epoch = 一次微信会话。 每次启动,appender 重建 LogCrypt、重新生成 ECDH 密钥对,新 epoch 的块序号从 1 重计。一天重启五次,一个文件里就有五个 epoch——这解释了为什么同一文件里有 5 个不同的公钥;
  • 那旧 epoch 的钥匙还在吗? 推论:LogCrypt 随进程退出释放。验证:拿旧 epoch 的公钥全堆扫——零命中,释放后的内存被微信堆擦成统一填充字节。没有钥匙链,没有落盘备份;
  • 推论:会话期间没取到钥匙,该会话的日志永久不可解。

这不是理论。有一个活体案例我完整经历了一遍:mm1 流(微信双开实例的第二日志流)某天 16.8MB、330 个块,全部单一 epoch。写它的那个早晨进程早已退出——我拿该公钥扫了主进程和全部子进程共 300 多 MB 内存,零命中。330 块永久封死,连同此前 9 天的 mm1 历史。各流独立 ECDH 各流各钥(mm/mm1/setup/update/player/xplayer/radium 七流并存),漏一个是一个。

于是工具链的最终形态定型为一个单脚本:微信运行中跑一次,现场从内存抓密钥(全程异常包裹 + 就绪轮询)→ 内存中直接解密 → 明文落盘;密钥只在控制台闪现、永不写文件。微信退出后才想起解密的场景,留了参数用此前记下的密钥补解。

插曲:一次方向错误的基建
中间还走过一大段弯路——想做一个"伴生 DLL"让微信启动时自动加载、取完密钥自卸载,把 version.dll 代理转发劫持的完整方案(linker forwarder 转发表、二跳解析、自卸载安全性分析)都做完了。最后被反汇编实锤证伪:微信 15.x 的启动预加载用 LoadLibraryExW(name, NULL, 0x800) 强制 System32 搜索路径,exe 目录植入路线整体失效——不是选哪个 DLL 的问题,是这条路本身被焊死了。10.x 老版本有效,但那不是目标。这段弯路的产出只有一条方法论:研究 DLL 劫持,先反汇编目标 exe 的 delay 预加载调用点查 0x800 标志。

10. 上篇小结:三层迷雾的地图

回头看整个过程,.xlog 从磁盘到明文隔着三层,每层一把钥匙:

.xlog 文件(磁盘)
  │  ① 块级加密:TEA-16轮 ECB(密钥 = ECDH 会话密钥,内存 LogCrypt+0x0C)
  ▼
块 payload(压缩数据)
  │  ② 压缩:zlib raw deflate / zstd
  ▼
日志行(混合流)
  │  ③ 字符串混淆:混淆段 XOR 607B 密钥流 + raw 明文交替
  ▼
最终明文

三层各有一把钥匙:会话密钥靠内存取证(公钥定位法),密钥流靠已知明文攻击,压缩靠标准库。缺一层,输出都是乱码;三层齐了,34MB 沉默变成 96,970 行自白。

沉淀下来的方法论(下篇还会反复用到):

  • 先查开源——格式与算法设计可能是公开的,本文 TEA/ECDH/块结构全部出自 mars 源码,没有一行是猜的;
  • 搜确定值,不搜想象值——三个晚上搜想象中的密钥形态一无所获,公钥定位法十分钟见效;
  • 静态定位必须运行时验证——41 个特征命中和零个真函数,断点永不命中的同名兄弟函数;
  • 对象的布局就是指纹——四重独立证据对表才算实证;
  • 失败要找共同点——三条死路的共性(都在搜想象)直接指出了正确方向。

11. 下篇预告:从"看"到"做"

上篇的终点是 96,970 行明文,但把这些日志当一次性读物读完就浪费了——每一行的函数签名、模块名、状态机字段,拼起来就是微信内部命名体系的一张导航图。下篇就用这张图办一件实事:一键锁机。

故事线大致是这样:

  • 线索来自 xlog:锁定相关的日志行直接吐出函数签名与模块归属,IDA 按图索骥,不用在 300MB 的 DLL 里大海捞针;
  • 难点在"够得着":外部进程想驱动微信的内部函数,坑一个接一个——管理器对象没有全局锚点怎么定位、远程线程没注册 TLS 怎么活下来、哪些函数依赖协程环境碰不得;
  • 路线长这样:下篇以一个最简单的锁定实例入场,逐个拆解,方案终态:读进程内存校验链路 → 全堆 vtable 扫描定位管理器对象 → 远程线程直调入口;
  • 验收标准:锁机那一瞬间,微信的性能、稳定性、业务、乃至服务器侧,全部无感。

这个功能不大,但下篇会把它从头到尾走完一遍——看 xlog 怎么把 300MB 的 DLL 缩成一行日志。后续还有一串好玩实用的功能分析,想看哪个回帖吱一声。方法照给,死路照写;觉得有收获,评分就是动力。


本文所有密钥值均已隐去;示例偏移仅覆盖文中涉及的少数锚点。完整工具链不复现于文中,但每一步方法都可独立复现。


xlog_before_after.png (132.69 KB, 下载次数: 0)

xlog_before_after.png

scan_pool_result.png (91.94 KB, 下载次数: 0)

scan_pool_result.png

hook_route_a_garbled.png (36.24 KB, 下载次数: 0)

hook_route_a_garbled.png

log_crypt_tea.png (83.34 KB, 下载次数: 0)

log_crypt_tea.png

github_mars_xlog.png (48.75 KB, 下载次数: 0)

github_mars_xlog.png

ida_xref_decompile.png (190.96 KB, 下载次数: 0)

ida_xref_decompile.png

ida_strings_mars.png (110.4 KB, 下载次数: 0)

ida_strings_mars.png

github_mars_xlog.png (48.75 KB, 下载次数: 0)

github_mars_xlog.png

xlog_before_after.png (132.55 KB, 下载次数: 0)

xlog_before_after.png

scan_pool_result.png (92.63 KB, 下载次数: 0)

scan_pool_result.png

hook_route_a_garbled.png (36.1 KB, 下载次数: 0)

hook_route_a_garbled.png

log_crypt_tea.png (82.67 KB, 下载次数: 0)

log_crypt_tea.png

免费评分

参与人数 10吾爱币 +10 热心值 +9 收起 理由
pptx + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
helian147 + 1 + 1 热心回复!
zr2019 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
flashgetme + 1 + 1 用心讨论,共获提升!
flybird1015 + 1 + 1 用心讨论,共获提升!
laozhang4201 + 1 + 1 用心讨论,共获提升!
执手偕老 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
nasc + 1 + 1 年度好文!
萌新与小白 + 1 + 1 热心回复!
allspark + 1 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
iawyxkdn8 发表于 2026-10-4 09:05
大佬牛逼呀!
3#
zhiqingchun 发表于 2026-10-4 09:44
这篇写得真扎实!最有启发的是“搜确定值不搜想象值”这条——拿块头里的公钥去内存里反向定位 LogCrypt 对象这一步,相当于把已知信息用到极致,比我惯用的特征常量扫描高明得多。想请教两个细节:全堆扫描 64B 公钥时,有没有遇到过误命中的情况?另外 607B 密钥流跨版本一致这个结论,如果哪天微信换了混淆种子,下篇会顺带提下应对思路吗?期待下篇的实战。
4#
zr2019 发表于 2026-10-4 11:25
5#
nideke 发表于 2026-10-5 03:34
日志里面能否看到对方ip地址?
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-5 15:32

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表