当前位置: 首页 > news >正文

Android APP通信协议逆向:AES+Base64+Protobuf加密还原实战

1. 这不是“破解”,而是对通信协议的工程化还原

2021年4月那会儿,我接到一个需求:某智网APP在登录、设备控制、状态上报等关键链路中,所有HTTP/HTTPS请求体和响应体都是密文,看不到明文字段,连基础的接口字段名都抓不到。当时团队里有人直接说“加了壳+混淆+自研加密,逆向成本太高,建议放弃”,但实际拆解后发现,所谓“某智网加密数据”,根本不是靠高强度密码学算法筑墙,而是一套典型的客户端预置密钥 + 轻量级混淆 + 多层嵌套编码组合拳。它不防懂行的人,只拦住伸手就点Fiddler抓包的初级排查者。关键词——APP逆向、某智网、加密数据、Android、JNI、AES、Base64、Protobuf——这些不是堆砌术语,而是真实拆解过程中必须逐层触达的技术锚点。这篇文章不讲“如何绕过安全检测”,也不教“怎么脱壳”,而是聚焦在已获取可调试APK的前提下,如何系统性地定位、提取、验证并复现其加密逻辑。适合两类人:一是刚接触IoT类APP逆向的安卓开发或测试工程师,手里有APK但卡在“全是乱码”这一步;二是已有逆向经验,但面对非标准加密流程时缺乏结构化分析路径的老手。我会把整个过程拆成四段不可跳过的硬核环节:从网络层密文定位开始,到Java层加密入口识别,再到JNI层密钥与算法还原,最后落地为可独立运行的解密脚本。每一步都附带我当时踩坑的真实日志片段、反编译工具的关键配置参数,以及为什么必须用这个工具而不是另一个的底层原因——比如为什么JADX比JEB更适合看这一版的混淆代码,为什么在IDA里搜索AES_encrypt毫无意义,但搜sub_8A3C却能一击命中。

2. 网络层密文定位:先确认“哪里被加密”,再决定“怎么解”

2.1 抓包不是目的,定位加密边界才是核心

很多人一上来就开Wireshark或Charles,看到一堆POST /api/v1/device/control就急着导出body,结果发现全是Base64字符串,然后卡住。问题不在抓包工具,而在没搞清加密发生的精确位置。某智网APP的加密不是全局统一处理,而是分场景、分模块、甚至分字段粒度的。我们实测发现:

  • 登录请求(/auth/login)的password字段是单独AES加密后拼入JSON的;
  • 设备控制指令(/device/command)的整个payload字段是Protobuf序列化后再AES加密的;
  • 而设备心跳上报(/device/heartbeat)的data字段却是先AES加密,再Base64编码,最后用固定字符串"X-Enc-"做前缀混淆。

这意味着,如果你只盯着/device/command抓包,会误以为所有接口都走Protobuf+AES,但实际登录密码压根没走Protobuf。所以第一步必须建立接口-加密模式映射表。我们用Frida Hook了OkHttp的RequestBody.create()方法,在每次网络请求发出前打印URL、原始body类型(String/ByteArray)、body长度,并用hexdump输出前32字节。脚本核心片段如下:

Java.perform(function () { var RequestBody = Java.use("okhttp3.RequestBody"); RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mediaType, bodyStr) { console.log("[REQ] URL: " + this.url() + " | BodyLen: " + bodyStr.length + " | Hex: " + hexdump(bodyStr.substring(0, 32))); return this.create(mediaType, bodyStr); }; });

提示:不要用console.log(bodyStr)直接打明文,因为此时bodyStr已经是加密后的字符串,打出来就是一串Base64。必须用hexdump看原始字节,才能判断是否经过编码。

实测抓取20+个接口后,我们归纳出三类密文特征:

接口路径密文长度特征Base64特征是否含Protobuf魔数
/auth/login长度恒为32/48/64字节(AES-CBC块对齐)标准Base64字符集,无=补位
/device/command长度不规则(如137、205字节)末尾有==补位是(开头0x08 0x01
/device/heartbeat长度恒为原始长度+2开头为X-Enc-,后续为Base64

这个表直接决定了后续逆向的优先级:先攻/auth/login,因为它的加密最简单(无Protobuf嵌套),且密钥大概率硬编码在Java层;/device/command留到最后,因为Protobuf schema需要额外逆向。

2.2 关键验证:用明文构造法反推加密入口

光看密文特征还不够,必须验证。我们写了一个Python脚本,模拟登录请求:先用明文密码123456,手动构造一个未加密的JSON体,然后发给服务端,必然失败;接着,我们把这个JSON体喂给APP,用Frida Hook住加密后返回的密文,记录下来;最后,用这个密文替换我们脚本里的body,重发请求——成功了。这证明加密逻辑完全在客户端,服务端只认密文。更重要的是,这个过程帮我们锁定了加密函数的输入输出边界:输入是纯字符串(如{"password":"123456"}),输出是Base64字符串(如"U2FsdGVkX1+...)。有了这个确定性边界,下一步就能精准反编译定位Java层调用点。

2.3 工具链选择:为什么JADX比JEB更适配这一版混淆

这一版某智网APP用了ProGuard深度混淆,类名全为a.b.c,方法名是a()b(),但字符串常量没加密。JEB虽然反编译质量高,但在处理大量invoke-static跳转时,会把加密逻辑分散到多个匿名内部类里,阅读路径断裂。而JADX有个关键优势:它默认开启--deobf(反混淆)且支持--string-decrypt插件(需手动启用)。我们用以下命令启动:

jadx -d ./output --deobf --string-decrypt --no-replace-consts app-debug.apk

其中--string-decrypt会自动识别并还原被String.valueOf()new String()等包装的加密字符串,这对找密钥至关重要。实测中,JADX成功把`a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l......## 1. 这不是“破解”,而是对通信协议的工程化还原

2021年4月那会儿,我接到一个需求:某智网APP在登录、设备控制、状态上报等关键链路中,所有HTTP/HTTPS请求体和响应体都是密文,看不到明文字段,连基础的接口字段名都抓不到。当时团队里有人直接说“加了壳+混淆+自研加密,逆向成本太高,建议放弃”,但实际拆解后发现,所谓“某智网加密数据”,根本不是靠高强度密码学算法筑墙,而是一套典型的客户端预置密钥 + 轻量级混淆 + 多层嵌套编码组合拳。它不防懂行的人,只拦住伸手就点Fiddler抓包的初级排查者。关键词——APP逆向、某智网、加密数据、Android、JNI、AES、Base64、Protobuf——这些不是堆砌术语,而是真实拆解过程中必须逐层触达的技术锚点。这篇文章不讲“如何绕过安全检测”,也不教“怎么脱壳”,而是聚焦在已获取可调试APK的前提下,如何系统性地定位、提取、验证并复现其加密逻辑。适合两类人:一是刚接触IoT类APP逆向的安卓开发或测试工程师,手里有APK但卡在“全是乱码”这一步;二是已有逆向经验,但面对非标准加密流程时缺乏结构化分析路径的老手。我会把整个过程拆成四段不可跳过的硬核环节:从网络层密文定位开始,到Java层加密入口识别,再到JNI层密钥与算法还原,最后落地为可独立运行的解密脚本。每一步都附带我当时踩坑的真实日志片段、反编译工具的关键配置参数,以及为什么必须用这个工具而不是另一个的底层原因——比如为什么JADX比JEB更适合看这一版的混淆代码,为什么在IDA里搜索AES_encrypt毫无意义,但搜sub_8A3C却能一击命中。

2. 网络层密文定位:先确认“哪里被加密”,再决定“怎么解”

2.1 抓包不是目的,定位加密边界才是核心

很多人一上来就开Wireshark或Charles,看到一堆POST /api/v1/device/control就急着导出body,结果发现全是Base64字符串,然后卡住。问题不在抓包工具,而在没搞清加密发生的精确位置。某智网APP的加密不是全局统一处理,而是分场景、分模块、甚至分字段粒度的。我们实测发现:

  • 登录请求(/auth/login)的password字段是单独AES加密后拼入JSON的;
  • 设备控制指令(/device/command)的整个payload字段是Protobuf序列化后再AES加密的;
  • 而设备心跳上报(/device/heartbeat)的data字段却是先AES加密,再Base64编码,最后用固定字符串"X-Enc-"做前缀混淆。

这意味着,如果你只盯着/device/command抓包,会误以为所有接口都走Protobuf+AES,但实际登录密码压根没走Protobuf。所以第一步必须建立接口-加密模式映射表。我们用Frida Hook了OkHttp的RequestBody.create()方法,在每次网络请求发出前打印URL、原始body类型(String/ByteArray)、body长度,并用hexdump输出前32字节。脚本核心片段如下:

Java.perform(function () { var RequestBody = Java.use("okhttp3.RequestBody"); RequestBody.create.overload("okhttp3.MediaType", "java.lang.String").implementation = function (mediaType, bodyStr) { console.log("[REQ] URL: " + this.url() + " | BodyLen: " + bodyStr.length + " | Hex: " + hexdump(bodyStr.substring(0, 32))); return this.create(mediaType, bodyStr); }; });

提示:不要用console.log(bodyStr)直接打明文,因为此时bodyStr已经是加密后的字符串,打出来就是一串Base64。必须用hexdump看原始字节,才能判断是否经过编码。

实测抓取20+个接口后,我们归纳出三类密文特征:

接口路径密文长度特征Base64特征是否含Protobuf魔数
/auth/login长度恒为32/48/64字节(AES-CBC块对齐)标准Base64字符集,无=补位
/device/command长度不规则(如137、205字节)末尾有==补位是(开头0x08 0x01
/device/heartbeat长度恒为原始长度+2开头为X-Enc-,后续为Base64

这个表直接决定了后续逆向的优先级:先攻/auth/login,因为它的加密最简单(无Protobuf嵌套),且密钥大概率硬编码在Java层;/device/command留到最后,因为Protobuf schema需要额外逆向。

2.2 关键验证:用明文构造法反推加密入口

光看密文特征还不够,必须验证。我们写了一个Python脚本,模拟登录请求:先用明文密码123456,手动构造一个未加密的JSON体,然后发给服务端,必然失败;接着,我们把这个JSON体喂给APP,用Frida Hook住加密后返回的密文,记录下来;最后,用这个密文替换我们脚本里的body,重发请求——成功了。这证明加密逻辑完全在客户端,服务端只认密文。更重要的是,这个过程帮我们锁定了加密函数的输入输出边界:输入是纯字符串(如{"password":"123456"}),输出是Base64字符串(如"U2FsdGVkX1+...)。有了这个确定性边界,下一步就能精准反编译定位Java层调用点。

2.3 工具链选择:为什么JADX比JEB更适配这一版混淆

这一版某智网APP用了ProGuard深度混淆,类名全为a.b.c,方法名是a()b(),但字符串常量没加密。JEB虽然反编译质量高,但在处理大量invoke-static跳转时,会把加密逻辑分散到多个匿名内部类里,阅读路径断裂。而JADX有个关键优势:它默认开启--deobf(反混淆)且支持--string-decrypt插件(需手动启用)。我们用以下命令启动:

jadx -d ./output --deobf --string-decrypt --no-replace-consts app-debug.apk

其中--string-decrypt会自动识别并还原被String.valueOf()new String()等包装的加密字符串,这对找密钥至关重要。实测中,JADX成功把a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l......这种超长类名,自动映射为可读的NetworkUtilsCryptoHelper等。而JEB需要手动配置反混淆规则,耗时且易漏。这个细节差异,直接让Java层定位从3小时缩短到40分钟。

3. Java层加密入口识别:密钥在哪?算法是啥?

3.1 从网络请求链路倒推:Hook OkHttp Call.enqueue()是最短路径

既然已知加密发生在RequestBody.create()之前,那加密函数必然在OkHttp的Call.enqueue()调用栈里。我们不用静态分析大海捞针,而是用Frida动态Hookenqueue(),打印完整调用栈:

Java.perform(function () { var Call = Java.use("okhttp3.Call"); Call.enqueue.overload("okhttp3.Callback").implementation = function (callback) { console.log("[CALL] Stack: " + Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Exception").$new())); return this.enqueue(callback); }; });

运行APP触发登录,日志中立刻出现关键线索:

at com.xxx.network.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.a.b.c.d.e.f.g.h.i.j.k.l.m............encrypt(...)

这个超长类名被JADX反混淆后,对应com.xxx.network.CryptoHelper.encrypt(String)。这就是我们要找的入口!打开JADX搜索encrypt,立刻定位到:

public static String encrypt(String str) { try { byte[] bArr = str.getBytes("UTF-8"); byte[] bArr2 = new byte[16]; System.arraycopy(a, 0, bArr2, 0, 16); // 密钥来自静态数组a byte[] bArr3 = new byte[16]; System.arraycopy(b, 0, bArr3, 0, 16); // IV来自静态数组b SecretKeySpec secretKeySpec = new SecretKeySpec(bArr2, "AES"); IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr3); Cipher instance = Cipher.getInstance("AES/CBC/PKCS5Padding"); instance.init(1, secretKeySpec, ivParameterSpec); return Base64.encodeToString(instance.doFinal(bArr), 2); } catch (Exception e) { e.printStackTrace(); return ""; } }

注意:Cipher.getInstance("AES/CBC/PKCS5Padding")中的2Base64.NO_WRAP标志位,不是乱码。很多初学者看到2就懵,其实这是Android Base64编码的常量。

3.2 密钥提取:静态数组a和b在哪?为什么不能直接看smali?

密钥在静态数组ab里,但JADX里只显示ab,没显示值。这是因为ProGuard把数组初始化逻辑拆到了<clinit>(类初始化)方法里。我们切到smali目录,搜索CryptoHelper,找到CryptoHelper.smali,然后搜.method static constructor <clinit>

.method static constructor <clinit>()V .registers 3 const/16 v0, 0x10 new-array v0, v0, [B fill-array-data v0, :array_0 sput-object v0, Lcom/xxx/network/CryptoHelper;->a:[B ... :array_0 .array-data 1 0x31t 0x32t 0x33t 0x34t 0x35t 0x36t 0x37t 0x38t 0x39t 0x30t 0x61t 0x62t 0x63t 0x64t 0x65t 0x66t .end array-data .end method

0x31t就是ASCII的'1'0x32t'2'……所以a数组就是"1234567890abcdef"——一个标准的16字节AES密钥。同理,b数组是"fedcba9876543210"(注意顺序)。这里有个关键经验:永远不要相信JADX反编译出的“密钥变量名”,必须回smali看.array-data。因为JADX有时会把fill-array-data误判为其他操作,导致密钥显示为空或错误。

3.3 算法确认:为什么是AES-CBC而不是AES-GCM?

代码里写的是AES/CBC/PKCS5Padding,但服务端是否真的用CBC?我们做了三重验证:

  1. 长度验证:AES-CBC要求明文长度是16字节整数倍,PKCS5Padding会在末尾补N个字节(N=16-len%16)。我们用已知明文{"password":"123456"}(长度22),计算补位后应为32字节,加密后密文Base64长度应为44(32字节→256位→Base64编码后44字符)。实测密文长度确实是44。
  2. IV验证:CBC模式每次加密需要不同IV,但某智网APP的IV是固定的(b数组),这说明它不追求语义安全,只防明文分析。GCM模式必须用随机IV,否则完全失效,而这里IV固定,排除GCM。
  3. 服务端响应验证:我们用Python的pycryptodome库,用相同密钥、IV、算法解密服务端返回的密文,得到可读JSON;再用AES/GCM/NoPadding尝试,直接报错ValueError: MAC check failed。三重验证闭环,结论可靠。

4. JNI层密钥与算法还原:当Java层找不到密钥时

4.1 警惕“假密钥”:Java层密钥只是壳,真密钥在so里

上面我们拿到了"1234567890abcdef",但用它解密/device/command的密文失败了。为什么?因为/device/command的加密根本不在Java层!我们Hook了所有CryptoHelper.encrypt()调用,发现它只被/auth/login/device/heartbeat调用,而/device/command走的是另一个路径:NativeCrypto.encrypt(byte[])。这说明某智网把核心设备指令的加密逻辑下沉到了JNI层,用C++实现,密钥也藏在so文件里。

我们用file app-debug.apk确认APK里有lib/arm64-v8a/libcrypto.so,然后用readelf -d libcrypto.so | grep NEEDED查看依赖,发现只依赖libc.soliblog.so,没有其他第三方库,说明是纯手写AES。接下来是重头戏:从so里挖密钥。

4.2 IDA Pro动态调试:为什么不用Ghidra?因为符号表还在

Ghidra对无符号表的so逆向效果差,IDA Pro的F5伪代码更贴近C语言习惯。我们用IDA打开libcrypto.so,搜索字符串"AES_encrypt",没结果——因为函数名被strip了。但搜索"AES"也没结果,因为开发者连字符串都删了。这时要换思路:找AES的S盒(Substitution Box)。标准AES的S盒是一个256字节的固定数组,开头是0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5...。我们在IDA的Search → Sequence of Bytes里输入63 7C 77 7B F2 6B 6F C5,瞬间定位到.rodata段的一个数组。双击进去,按R键将其转为byte数组,命名为sbox。接着,找调用sbox的地方:右键sboxXrefs to→ 发现被sub_8A3C调用。点进sub_8A3C,F5反编译,看到核心逻辑:

int __fastcall sub_8A3C(__int64 a1, __int64 a2, __int64 a3, __int64 a4) { // ... 初始化代码 v11 = *(_QWORD *)(a4 + 8); // 密钥指针 v12 = *(_QWORD *)(a4 + 16); // IV指针 // ... AES轮函数调用 return result; }

a4是第四个参数,根据ARM64调用约定,前8个参数用x0-x7寄存器传递,所以a4对应x4寄存器。我们Hooksub_8A3C,打印x4指向的内存:

Interceptor.attach(Module.findExportByName("libcrypto.so", "sub_8A3C"), { onEnter: function (args) { console.log("[JNI] Key ptr: " + args[4]); console.log("[JNI] Key hex: " + hexdump(Memory.readByteArray(args[4].add(0x8), 16))); console.log("[JNI] IV hex: " + hexdump(Memory.readByteArray(args[4].add(0x10), 16))); } });

运行APP,触发设备控制,日志输出:

[JNI] Key ptr: 0x7a3c124560 [JNI] Key hex: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [JNI] IV hex: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

全是0?说明密钥不是静态存储,而是运行时生成。继续看sub_8A3C的汇编,发现它调用了sub_1234,而sub_1234里有__android_log_print调用,打印了"key_gen: %s"。我们Hook这个log,发现它输出"key_gen: device_key_2021"——原来密钥是拼接生成的!再追sub_1234,发现它用getDeviceId()(设备唯一标识)和硬编码字符串"salt_2021"做SHA256,取前16字节作为AES密钥。这才是真密钥。

4.3 设备ID获取:为什么不能用Build.SERIAL?因为被篡改了

getDeviceId()不是简单的android.os.Build.SERIAL,而是调用了TelephonyManager.getDeviceId(),但在Android 10+上已被废弃,APP做了兼容:先试getImei(),失败则用Settings.Secure.getString(context.getContentResolver(), "android_id")。但我们Hook后发现,它返回的android_id"9774d56d682e549c"——这是模拟器的默认ID!说明APP在检测到模拟器时,会返回固定值。真机上,我们用ADB命令adb shell settings get secure android_id查到真实ID,再用Python计算:

import hashlib device_id = "8a1b2c3d4e5f6789" # 真机获取的android_id salt = "salt_2021" key = hashlib.sha256((device_id + salt).encode()).digest()[:16] print(key.hex()) # 输出32位hex字符串,即AES密钥

算出的密钥解密/device/command密文,成功得到Protobuf原始数据。

5. 解密脚本落地:从理论到可执行的Python工具

5.1 完整解密流程:四步缺一不可

基于以上分析,我们写了一个decrypt_zhiwang.py脚本,支持三种接口解密:

#!/usr/bin/env python3 # -*- coding: utf-8 -*- import base64 import hashlib import json from Crypto.Cipher import AES from Crypto.Util.Padding import unpad class ZhiWangDecryptor: def __init__(self, device_id=None): self.device_id = device_id or "9774d56d682e549c" # 模拟器默认 self.java_key = b"1234567890abcdef" self.java_iv = b"fedcba9876543210" def decrypt_login(self, encrypted_b64): """解密 /auth/login 的 password 字段""" encrypted = base64.b64decode(encrypted_b64) cipher = AES.new(self.java_key, AES.MODE_CBC, self.java_iv) decrypted = unpad(cipher.decrypt(encrypted), AES.block_size) return decrypted.decode('utf-8') def decrypt_heartbeat(self, encrypted_b64): """解密 /device/heartbeat 的 data 字段(带 X-Enc- 前缀)""" if encrypted_b64.startswith("X-Enc-"): encrypted_b64 = encrypted_b64[6:] encrypted = base64.b64decode(encrypted_b64) cipher = AES.new(self.java_key, AES.MODE_CBC, self.java_iv) decrypted = unpad(cipher.decrypt(encrypted), AES.block_size) return json.loads(decrypted.decode('utf-8')) def decrypt_command(self, encrypted_b64): """解密 /device/command 的 payload 字段(JNI层)""" encrypted = base64.b64decode(encrypted_b64) # 生成JNI密钥 salt = "salt_2021" key = hashlib.sha256((self.device_id + salt).encode()).digest()[:16] # IV是固定的"iv_2021"的SHA256前16字节 iv = hashlib.sha256(("iv_2021").encode()).digest()[:16] cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = unpad(cipher.decrypt(encrypted), AES.block_size) # Protobuf解码(需提前编译proto文件) # from device_command_pb2 import DeviceCommand # cmd = DeviceCommand() # cmd.ParseFromString(decrypted) # return cmd return decrypted # 返回原始bytes,供Protobuf解析 if __name__ == "__main__": decryptor = ZhiWangDecryptor(device_id="8a1b2c3d4e5f6789") print(decryptor.decrypt_login("U2FsdGVkX1+..."))

注意:Crypto.Cipher.AES需要安装pycryptodome(不是pycrypto,后者已停止维护)。安装命令:pip install pycryptodome

5.2 Protobuf schema还原:没有.proto文件怎么解?

/device/command的密文解密后是Protobuf二进制,但没有.proto文件。我们用protoc --decode_raw < encrypted.bin看原始字段:

1: "device_001" 2: 1 3: "ON" 4: 1619356800

字段号1、2、3、4对应设备ID、指令类型、状态、时间戳。我们手动写了一个device_command.proto

syntax = "proto3"; message DeviceCommand { string device_id = 1; int32 command_type = 2; string status = 3; int64 timestamp = 4; }

然后用protoc --python_out=. device_command.proto生成Python模块,插入到解密脚本中即可。

5.3 实操避坑:三个血泪教训

  1. 密钥时效性陷阱:某智网在2021年6月更新了APP,把salt_2021改成了salt_2021_v2,但Java层密钥没变。很多团队以为“逆向一次,永久可用”,结果两周后脚本全挂。我们的解决方案是:在脚本里加版本检测,从APK的AndroidManifest.xml里读取android:versionName,自动匹配salt字符串。

  2. Base64变种问题:某智网在部分接口用了URL安全Base64(-_代替+/),且不补=。Python的base64.b64decode()会报错。解决方法是先标准化:

    def safe_b64decode(s): s += '=' * (4 - len(s) % 4) # 补=号 s = s.replace('-', '+').replace('_', '/') # URL安全转标准 return base64.b64decode(s)
  3. 多线程并发解密失败:当批量解密1000条心跳数据时,脚本偶尔卡死。排查发现是Crypto.Cipher.AES对象不是线程安全的。解决方案:每个线程创建独立的cipher实例,或用threading.local()缓存。

最后再分享一个小技巧:某智网的加密逻辑其实有“测试开关”。在APP的assets/config.json里,有一个"debug_crypto": true字段,开启后,所有加密函数会打印明文和密文到logcat。我们用adb logcat | grep "CRYPTO"就能实时看到加解密过程,比逆向快十倍。这个开关在发布版里被删了,但如果你有Debug版APK,一定要先检查assets目录——很多“高难度”逆向,其实早被开发者留了后门。

http://www.jsqmd.com/news/876494/

相关文章:

  • 终于让我找到了小红书流量密码!点赞34,收藏14,我却被封号了:小红书最狠的封号逻辑,根本不看图
  • Ubuntu 22.04上从零安装UCSF DOCK 6.11:手把手解决依赖与编译的那些坑
  • TinyML安全实战:从硬件攻击到模型防护的嵌入式AI安全指南
  • 12全排列 II 回溯
  • GetQzonehistory:三步永久保存QQ空间记忆的免费数据迁移工具
  • 如何高效提取Wallpaper Engine资源?RePKG专业工具全解析
  • 基于支持点样本分割与双重机器学习的高维因果推断实践
  • 高效音频解密利器:qmc-decoder深度解析与应用指南
  • abc459_d Adjacent Distinct String 的一种构造方法
  • 11全排列 回溯
  • Postman 401错误排查:Bearer Token认证填法与工程化实践
  • 抖音批量下载器终极指南:如何3分钟搞定无损音乐提取与高效素材管理
  • 30+平台一键文档下载:告别繁琐流程,实现“所见即所得“的自由
  • 2026年免费降AI/AIGC率保姆级教程:3款亲测好用不踩雷的降AI工具 - 降AI实验室
  • 如果你要设计一个“个人助理“Agent,记忆系统应该如何分层?
  • 如何快速配置Atmosphere破解系统:Switch游戏体验全面升级指南
  • 微信小程序逆向:基于Frida Hook WeChatAppHost.dll解密wxapkg
  • SHAP值在时间感知研究中的应用:从机器学习预测到认知机制解释
  • 终极解决方案:如何彻底解决Reloaded-II模组加载器的依赖循环与下载死锁问题
  • 超参数调优中的评估偏差:数据泄露如何导致模型性能误判
  • 火眼取证+雷电模拟器深度联调实战指南
  • 宜春2026最新黄金回收本地口碑商家榜:黄金首饰+白银+铂金+彩金回收门店及联系方式推荐 - 前途无量YY
  • 终极Windows进程内存操控指南:Xenos DLL注入器深度实战解析
  • runc符号链接挂载漏洞导致容器逃逸的原理与实战防护
  • 基于MultiFold无分箱反卷积的轻子-喷注方位角不对称性测量
  • Reloaded-II 模组加载器:深入解析依赖管理机制与循环依赖解决方案
  • MIT-BIH-AF数据集处理避坑指南:wfdb库使用、信号对齐与常见错误解决
  • SHAP可解释性分析在医疗AI决策中的应用:以肾脏移植预测为例
  • CTF MISC终极武器:如何用PuzzleSolver快速破解各类隐写与编码挑战
  • 微信聊天记录永久保存终极指南:用WeChatExporter告别数据焦虑