1. 项目概述:为什么我们要动态Hook微信好友信息
在移动安全研究、自动化测试或者一些特定的业务场景(比如合规的社交数据分析、自动化客服工具开发)中,我们常常需要与微信这样的超级应用打交道。直接调用官方未公开的API几乎不可能,而通过逆向工程分析其内部数据结构,再借助运行时注入技术(Hook)来获取或修改数据,就成了一条“曲线救国”的必经之路。
这次我们的目标很明确:针对64位架构的微信客户端(版本号3.9.11.25),利用Frida这个强大的动态插桩工具,定位并Hook其内存中的好友信息结构。这不仅仅是获取一个昵称或微信号那么简单,更深层的价值在于理解一个成熟商业应用如何在内存中组织和管理其核心社交关系数据。通过这个过程,我们能学习到如何分析复杂的C++对象布局、如何处理多线程环境下的数据访问,以及如何编写稳定可靠的Frida脚本。无论你是移动安全研究员、逆向工程师,还是对底层机制充满好奇的开发者,这个实战过程都能提供宝贵的经验。
2. 环境准备与逆向分析基础
动手之前,我们需要一个稳固的“作战基地”。这个环境必须能同时支持对64位Android应用的分析和动态调试。
2.1 核心工具链搭建
首先,你需要一部已经获得Root权限的Android手机或模拟器。对于微信这样的应用,推荐使用真机,因为模拟器可能被检测。我使用的是Pixel 4,刷入了Magisk进行Root。确保你的设备上安装了目标版本的微信(3.9.11.25),并且其ABI是arm64-v8a。
在电脑端,我们需要以下工具:
- Frida:这是我们的核心武器。通过
pip install frida-tools安装客户端。同时,需要下载与手机CPU架构(arm64)及Frida-server版本对应的frida-server二进制文件,推送到手机并以后台进程运行。版本匹配是关键,不匹配会导致连接失败。 - 逆向分析工具:静态分析首推IDA Pro或Ghidra。IDA的交互性和插件生态更友好,Ghidra免费且反编译能力强大。动态调试则离不开Android Studio的
lldb或IDA Pro的debugger。我通常用IDA进行静态逆向寻找关键点,用Frida进行动态验证和快速迭代。 - 辅助工具:
adb(Android Debug Bridge)是必备的通信桥梁。objection(基于Frida的运行时移动安全评估工具)可以快速进行内存搜索和类枚举,在初步侦查时非常高效。
注意:所有操作请在你自己拥有完全控制权的设备和应用副本上进行。对他人设备或数据进行分析可能涉及法律风险。
2.2 目标微信的初步侦查
在开始逆向具体结构前,我们需要对目标应用有一个宏观了解。将微信APK解包,查看它的lib目录,确认存在libwechat*.so这样的原生库,并且是64位(arm64-v8a)。微信的核心逻辑,尤其是网络通信、数据加密和好友关系管理,很大一部分都封装在这些原生库里。
使用objection快速连接目标进程是一个很好的起点:
objection -g com.tencent.mm explore连接成功后,你可以使用命令如android hooking list classes来枚举所有Java类,或者memory list modules查看加载的所有原生库。对于好友信息,我们的重点很可能在libwechatcommon.so或libmmbase.so这类核心库中。
然而,直接在海量的类和函数中寻找目标如同大海捞针。我们需要更聪明的策略:寻找突破口。一个常见的方法是,通过监控与“联系人”或“用户信息”相关的UI操作(比如点击好友头像进入详情页),利用Frida的Stalker功能追踪执行流,或者Hook那些看起来负责数据获取的Java方法(如ContactInfo相关的类),然后回溯到其调用的Native函数。
3. 定位好友信息结构的关键思路
逆向工程就像侦探破案,需要线索和推理。我们不可能直接知道“好友信息结构”这个“罪犯”藏在哪里,但我们可以通过它留下的“痕迹”来追踪。
3.1 从Java层到Native层的回溯
微信的Android客户端是Java(Kotlin)与C++(Native)的混合体。UI和业务逻辑框架在Java层,而核心的数据模型和加密算法往往在Native层以实现更高的性能和安全性。因此,我们的Hook路径通常是:Java层入口 -> JNI调用 -> Native层数据结构。
首先,在Java层寻找可疑的类。通过分析微信的Java代码(可以使用jadx或bytecode-viewer反编译APK),可以搜索与联系人相关的类名,例如Contact、ChatRoom、RContact等。在版本3.9.11.25中,一个关键类可能是com.tencent.mm.storage.af或其相关类,它们常常负责联系人的存储和获取。
找到可能的类后,使用Frida Hook其关键方法,例如getDisplayName()、getUsername()等,打印出方法的输入参数和返回值,甚至打印出this对象的内存地址。这个内存地址指向的是一个Java对象,而该对象内部很可能持有一个指向Native层真正数据结构的指针(通常是一个long类型的字段)。
3.2 利用字符串与特征值进行内存扫描
如果从Java层回溯困难,另一种强有力的方法是直接攻击Native层。好友信息中必然包含一些已知的字符串,比如你自己的微信号、某个好友的固定昵称。我们可以利用Frida的Memory.scan()API在整个内存空间或某个特定so库的范围内搜索这个字符串。
例如,假设我知道我的微信号是“wxid_12345abcde”:
var module = Process.findModuleByName(“libwechatcommon.so”); if (module) { Memory.scan(module.base, module.size, “wxid_12345abcde”, { onMatch: function(address, size){ console.log(“[+] Found string at:” + address); // 接下来可以以这个地址为中心,前后解析内存,推测结构 }, onComplete: function(){ console.log(“Scan complete.”); } }); }找到字符串地址后,我们可以在其附近区域(比如前后偏移几百个字节)解析内存,查看哪些偏移量上存储了看起来像指针的值(64位地址通常对齐到8字节,且指向可读内存区域)、整型的Uin(用户唯一ID)或其它标志位。通过反复尝试和观察,可以逐步勾勒出这个内存块的结构。
3.3 分析关键Native函数
无论是通过Java层回溯找到了Native指针,还是通过内存扫描发现了疑似结构,最终我们都需要定位到操作这些数据的函数。这些函数可能是GetContactInfo(void *contact_struct)、ModifyContactRemark(void *contact_struct, const char *remark)等。
在IDA Pro中静态分析目标so库,寻找那些引用了你发现的特征字符串(如“nickname”、“remark”等)的函数,或者交叉引用(Xref)了很多数据访问操作的函数。结合动态调试,在Frida中Hook这些候选函数,打印其参数(第一个参数通常是this指针或结构体指针)和返回值,观察当你在微信中执行查看好友信息操作时,哪个函数被触发,其指针参数指向的内存内容是否与我们推测的结构吻合。
这个过程需要耐心和反复验证。我个人的心得是,将静态分析与动态Hook紧密结合。先用IDA进行粗略定位,再用Frida编写小脚本进行快速测试和筛选,效率最高。
4. 动态Hook与结构体解析实战
经过前期的侦查和定位,假设我们已经锁定了一个关键的Native函数nativeGetContactInfo(long jni_contact_obj),并且确信它的第一个参数(在ARM64上通常是X0寄存器或栈上传参,取决于调用约定)是一个指向核心联系人结构体的指针。
4.1 编写Frida Hook脚本
我们的Frida脚本将围绕这个函数展开。目标是:当该函数被调用时,拦截并解析其结构体参数。
// hook_wechat_contact.js Interceptor.attach(Module.findExportByName(“libwechatcommon.so”, “_Z20nativeGetContactInfoP7JNIEnv_P8_jobject”), { onEnter: function(args) { // args[0] 是 JNIEnv* // args[1] 是 jobject (Java层的联系人对象) // 但我们需要的是Native结构体指针,它可能作为另一个参数,或者从jobject中获取。 // 假设我们通过之前分析,知道真正的结构体指针是第三个参数(args[2]) this.contactPtr = args[2]; // 保存下来供onLeave使用 console.log(“[+] nativeGetContactInfo called. Potential struct ptr: ” + this.contactPtr); }, onLeave: function(retval) { // 函数执行后,结构体已被填充。现在来解析它。 var ptr = this.contactPtr; if (ptr.isNull()) return; // 开始解析内存。这需要基于逆向出的结构体布局。 // 假设我们推测的结构体如下(所有字段均为64位指针,指向字符串): // offset 0x0: pointer to wxid (微信号) // offset 0x8: pointer to nickname (昵称) // offset 0x10: pointer to remark (备注) // offset 0x18: uin (64位整数) // offset 0x20: some flags (32位整数) var wxidPtr = ptr.add(0x0).readPointer(); var nicknamePtr = ptr.add(0x8).readPointer(); var remarkPtr = ptr.add(0x10).readPointer(); var uin = ptr.add(0x18).readU64(); var flags = ptr.add(0x20).readU32(); var wxid = wxidPtr.isNull() ? “null” : wxidPtr.readUtf8String(); var nickname = nicknamePtr.isNull() ? “null” : nicknamePtr.readUtf8String(); var remark = remarkPtr.isNull() ? “null” : remarkPtr.readUtf8String(); console.log(“\n=== Parsed Contact Info ==="); console.log(“Struct Address: ” + ptr); console.log(“Wxid: ” + wxid); console.log(“Nickname: ” + nickname); console.log(“Remark: ” + remark); console.log(“Uin: ” + uin.toString()); console.log(“Flags: 0x” + flags.toString(16)); console.log(“=======================\n”); } });这个脚本是一个模板。实际的偏移量(0x0, 0x8…)需要你通过逆向分析来确认。如何确认?这就是下一节的重点。
4.2 结构体偏移量的逆向推导
确定结构体每个成员的偏移量是逆向中最精细的活儿。主要有两种方法:
- 静态分析IDA反汇编:在IDA中找到操作这个结构体的函数,看它的汇编代码。例如,如果看到一条指令
LDR X1, [X0, #0x10],然后将X1作为参数传递给strcmp之类的字符串函数,那么X0+0x10很可能就是一个指向字符串(如昵称)的指针。通过分析多条这样的指令,可以汇总出各个字段的偏移。 - 动态Frida内存漫游:这是更直观的方法。当我们Hook到函数并拿到结构体指针
ptr后,可以写一个循环,以ptr为起点,打印出其后一大段内存(比如256字节)的内容,每8字节作为一个潜在指针或数值来解读。
然后,你在微信UI上操作(比如查看不同好友的信息),观察每次Hook时,哪些偏移量上的数据会随着好友的不同而规律变化。变化的字符串很可能就是微信号、昵称等。不变的可能是标志位或函数指针表(vtable)。console.log(“Dumping memory around: ” + ptr); for (var i = 0; i < 32; i++) { // 32 * 8 = 256 bytes var offset = i * 8; var value = ptr.add(offset).readPointer(); // 尝试将值作为指针读取字符串,如果看起来像可读地址且内容像字符串,就打印 if (!value.isNull()) { try { var str = value.readUtf8String(); if (str && str.length < 100 && /^[\w\s\-\.@]+$/.test(str)) { // 简单过滤 console.log(“[Offset 0x” + offset.toString(16) + “] Ptr -> ‘“ + str + “’”); } } catch(e) {} } // 也打印原始数值 console.log(“[Offset 0x” + offset.toString(16) + “] Raw Qword: 0x” + ptr.add(offset).readU64().toString(16)); }
4.3 处理复杂嵌套结构与数组
真实的好友信息结构远比上面的示例复杂。它可能包含嵌套的子结构,比如一个指向“好友详情”子结构的指针,子结构里又有生日、地区、标签列表等。字段也可能是一个指针,指向一个std::vector或数组,用来存储多个标签。
对于嵌套结构,你需要逐层解析。例如,如果偏移0x30处是一个指向子结构的指针subPtr,那么你需要用同样的方法去分析subPtr指向的内存区域。
对于数组,常见的模式是:一个指针指向数组起始地址(dataPtr),附近可能还有一个表示数组大小的字段(size)。解析时需要循环读取:
var tagsPtr = ptr.add(0x40).readPointer(); var tagsCount = ptr.add(0x48).readU32(); console.log(“Tags count: ” + tagsCount); if (!tagsPtr.isNull() && tagsCount > 0 && tagsCount < 100) { // 防止异常值 for (var j = 0; j < tagsCount; j++) { var tagEntryPtr = tagsPtr.add(j * 8); // 假设每个元素是指针,占8字节 var tagStringPtr = tagEntryPtr.readPointer(); if (!tagStringPtr.isNull()) { console.log(“ Tag[” + j + “]: ” + tagStringPtr.readUtf8String()); } } }5. 脚本优化与稳定Hook策略
写一个能跑通的脚本只是第一步,要让它在复杂的真实环境中稳定、隐蔽地工作,还需要大量优化。
5.1 错误处理与内存安全
直接读取内存是危险的操作。如果偏移量计算错误,或者指针已失效,会导致脚本崩溃甚至目标进程崩溃。必须进行严格的检查。
function safeReadPointer(addr) { if (addr.isNull()) return null; // 检查地址是否可读(这是一个近似检查,并非绝对安全) try { // 尝试读取一个字节,如果不可读会抛出异常 addr.readU8(); return addr; } catch (e) { console.warn(“[!] Attempt to read from invalid address: ” + addr); return null; } } function safeReadUtf8String(ptr) { var safePtr = safeReadPointer(ptr); if (!safePtr) return “<invalid>”; try { // 限制读取长度,防止遇到非字符串数据无限读取 return safePtr.readUtf8String(256); // 最多读256字节 } catch (e) { return “<read_error>”; } }在onEnter和onLeave中广泛使用这些安全函数,能极大提升脚本的健壮性。
5.2 反调试与反Hook对抗
微信这类应用必然内置了多种反调试和反Hook机制。我们的Frida脚本可能会被检测到。常见的检测手段包括:
- 检查
frida-server相关端口:默认端口27042。 - 检查进程内存中的Frida特征字符串:如“frida”、“gum-js-loop”、“gmain”等。
- 检查
/proc/self/maps和/proc/self/task/.../status:查找Frida相关模块或线程名。 - ptrace自身:防止其他进程(如调试器)附加。
应对策略:
- 修改Frida默认端口:启动
frida-server时使用-l 0.0.0.0:8080指定其他端口。 - 使用定制化的Frida:编译修改了特征字符串的Frida版本。
- 在Hook早期拦截检测函数:主动Hook如
open、read、fgets等libc函数,当检测到路径或内容与/proc/self相关时,返回清理过的、不含Frida信息的内容。这是一个“猫鼠游戏”,需要不断研究新的对抗方法。 - 避免持久化Hook:对于一次性数据获取任务,脚本执行完毕后及时断开,减少暴露时间。
5.3 性能考量与批量处理
如果你需要Hook大量好友信息的获取,频繁的console.log和内存解析会严重影响微信性能,甚至导致卡顿或崩溃。优化方法包括:
- 条件过滤:只在获取特定好友(如通过
wxid判断)时才进行详细解析和打印。 - 缓存机制:将解析过的结构体地址和基本信息缓存起来,避免重复解析。
- 异步输出:将需要打印的信息先存入数组,然后在
setTimeout中统一输出,避免阻塞主线程。 - 最小化Hook范围:一旦找到所需数据,可以考虑及时
detach掉Hook,或者只Hook最源头、调用次数最少的那个函数。
6. 从结构解析到实际应用
成功Hook并解析出好友信息结构后,我们能做什么?这超出了纯逆向的范畴,进入了应用层面。
6.1 构建联系人信息导出工具
你可以编写一个完整的Frida脚本,主动触发微信的联系人列表加载(例如,通过Hook某个刷新函数,或者模拟UI操作),然后遍历内存中所有联系人结构体,将微信号、昵称、备注、标签等信息以JSON或CSV格式导出到手机存储或通过网络发送到服务器。这可以用于在本地备份复杂的社交关系网(注意合规性)。
关键在于如何“遍历”。你可能需要找到全局的联系人管理器类或结构,它持有一个联系人列表或映射表。通过Hook这个管理器的GetContact或AddContact等方法,可以逐步构建出完整的列表。
6.2 实时监控与自动化操作
基于Hook,可以实现实时监控。例如,监控ModifyContactRemark函数的调用,当备注被修改时(可能是用户自己改的,也可能是同步服务器下来的),立即记录日志,甚至触发其他自动化操作。
更进一步,可以结合自动化框架(如Auto.js),实现“当收到特定好友消息时,自动根据其备注中的标签进行回复或分类”。这需要将消息接收Hook和联系人信息Hook结合起来,形成一个简单的自动化机器人框架。
6.3 深入理解应用架构
本次实战最大的收获可能不是数据本身,而是对微信客户端架构的深刻理解。你会看到大型C++项目如何组织数据模型(可能使用了类似Protobuf的序列化,或是自定义的二进制格式),如何通过JNI与Java层交互,如何管理内存和生命周期。这些经验对于你进行其他应用的逆向,甚至对于自己设计高性能、高安全性的客户端架构,都有极高的参考价值。
7. 常见问题与排查实录
在实际操作中,你一定会遇到各种各样的问题。这里记录一些我踩过的坑和解决方案。
7.1 Frida连接失败或脚本不生效
- 症状:
frida-ps -U看不到进程,或者frida -U -f com.tencent.mm -l script.js无法注入。 - 排查:
- Root权限:确保手机已Root,且
frida-server以root用户运行(adb shell su -c ‘/data/local/tmp/frida-server &’)。 - 版本匹配:用
frida --version查看电脑端版本,用adb shell /data/local/tmp/frida-server --version查看手机端版本,必须一致。 - 端口冲突:检查
27042端口是否被占用。可以尝试重启frida-server或更换端口。 - 应用多进程:微信有多个进程(主进程、工具进程等)。你需要Hook的是主进程(通常包名就是
com.tencent.mm)。使用frida-ps -U确认正确的进程名。 - 反调试:如果连接后立刻断开或应用闪退,说明触发了反调试。需要先实施反反调试措施,比如在非调试模式下启动应用后再附加(
frida -U --no-pause -f com.tencent.mm),或者使用定制Frida。
- Root权限:确保手机已Root,且
7.2 Hook函数时找不到符号(undefined symbol)
- 症状:
Module.findExportByName返回null。 - 排查:
- 动态链接与静态链接:函数可能被静态链接到主二进制(
app_process)或其他库,而不是你猜测的so文件。用objection的memory list exports命令在所有模块中搜索函数名片段。 - C++名称修饰(Name Mangling):C++函数在符号表中存储的是修饰后的名字(如
_Z20nativeGetContactInfoP7JNIEnv_P8_jobject)。你需要使用这个修饰后的名字。在IDA的Exports窗口,或者用objdump -T ./libwechatcommon.so | grep Contact命令可以查看到。 - 函数地址偏移:如果实在没有导出符号,你可能需要通过基地址+偏移量的方式来Hook。在IDA中确定函数在
so文件中的偏移(RVA),然后在Frida中计算绝对地址:Module.findBaseAddress(‘libwechatcommon.so’).add(0x123456)。
- 动态链接与静态链接:函数可能被静态链接到主二进制(
7.3 解析内存时读到乱码或崩溃
- 症状:脚本能Hook,但读出的字符串是乱码,或者一读内存微信就崩溃。
- 排查:
- 编码问题:微信中的字符串不一定是UTF-8,可能是UTF-16LE(宽字符)。尝试使用
readUtf16String()代替readUtf8String()。 - 指针层级错误:你读取的8字节可能不是直接指向字符串的指针,而是指向另一个结构,该结构里才包含字符串指针。需要多解引用一次。仔细分析IDA中的内存访问指令(
LDR、LDP)。 - 内存生命周期:你Hook的函数执行完毕后,它使用的栈内存或临时分配的内存可能被释放。确保你在
onLeave中读取的数据是保存在堆上或全局变量中的持久化数据。在onEnter中保存的指针,到onLeave时可能已经失效。 - 内存保护:尝试读取没有读取权限的内存页会导致崩溃。使用
Memory.protect(address, size, ‘rwx’)临时修改权限(需小心)或直接避免读取该区域。
- 编码问题:微信中的字符串不一定是UTF-8,可能是UTF-16LE(宽字符)。尝试使用
7.4 数据不一致或字段对不上
- 症状:解析出的字段值看起来部分正确,部分错误,或者偏移量每次运行似乎有变化。
- 排查:
- 结构体版本差异:不同版本的微信,甚至不同场景下(如好友、群成员、公众号),可能使用相似但不同的结构体。确认你Hook的上下文。
- 继承与多态:C++对象可能有继承关系。基类子对象位于内存开头,你计算的偏移量可能只是派生类新增成员的偏移,需要加上基类的大小。在IDA中查看类的继承层次结构。
- 编译器优化:编译器可能会进行内存对齐(Alignment)、空基类优化(Empty Base Optimization)等,导致结构体布局与源代码不同。必须以实际生成的汇编和内存布局为准。
- 动态偏移:极少数情况下,结构体前面可能有一个可变长度的头部(如包含大小的字段)。这需要更复杂的解析逻辑。
这个过程是对耐心、细心和逻辑推理能力的极大考验。每一个问题的解决,都意味着你对目标系统的理解加深了一层。记住,没有一次逆向是一帆风顺的,不断假设、验证、修正,才是常态。最后,务必再次强调,所有技术都应在法律允许和道德规范的范围内使用,用于学习、研究和授权测试目的。