当前位置: 首页 > 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抓包会误以为所有接口都走ProtobufAES但实际登录密码压根没走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抓包会误以为所有接口都走ProtobufAES但实际登录密码压根没走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......这种超长类名自动映射为可读的NetworkUtils、CryptoHelper等。而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)中的2是Base64.NO_WRAP标志位不是乱码。很多初学者看到2就懵其实这是Android Base64编码的常量。3.2 密钥提取静态数组a和b在哪为什么不能直接看smali密钥在静态数组a和b里但JADX里只显示a和b没显示值。这是因为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 method0x31t就是ASCII的10x32t是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我们做了三重验证长度验证AES-CBC要求明文长度是16字节整数倍PKCS5Padding会在末尾补N个字节N16-len%16。我们用已知明文{password:123456}长度22计算补位后应为32字节加密后密文Base64长度应为4432字节→256位→Base64编码后44字符。实测密文长度确实是44。IV验证CBC模式每次加密需要不同IV但某智网APP的IV是固定的b数组这说明它不追求语义安全只防明文分析。GCM模式必须用随机IV否则完全失效而这里IV固定排除GCM。服务端响应验证我们用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.so和liblog.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的地方右键sbox→Xrefs to→ 发现被sub_8A3C调用。点进sub_8A3CF5反编译看到核心逻辑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_idNone): self.device_id device_id or 9774d56d682e549c # 模拟器默认 self.java_key b1234567890abcdef self.java_iv bfedcba9876543210 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_id8a1b2c3d4e5f6789) 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.protosyntax 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 实操避坑三个血泪教训密钥时效性陷阱某智网在2021年6月更新了APP把salt_2021改成了salt_2021_v2但Java层密钥没变。很多团队以为“逆向一次永久可用”结果两周后脚本全挂。我们的解决方案是在脚本里加版本检测从APK的AndroidManifest.xml里读取android:versionName自动匹配salt字符串。Base64变种问题某智网在部分接口用了URL安全Base64-和_代替和/且不补。Python的base64.b64decode()会报错。解决方法是先标准化def safe_b64decode(s): s * (4 - len(s) % 4) # 补号 s s.replace(-, ).replace(_, /) # URL安全转标准 return base64.b64decode(s)多线程并发解密失败当批量解密1000条心跳数据时脚本偶尔卡死。排查发现是Crypto.Cipher.AES对象不是线程安全的。解决方案每个线程创建独立的cipher实例或用threading.local()缓存。最后再分享一个小技巧某智网的加密逻辑其实有“测试开关”。在APP的assets/config.json里有一个debug_crypto: true字段开启后所有加密函数会打印明文和密文到logcat。我们用adb logcat | grep CRYPTO就能实时看到加解密过程比逆向快十倍。这个开关在发布版里被删了但如果你有Debug版APK一定要先检查assets目录——很多“高难度”逆向其实早被开发者留了后门。
http://www.zskr.cn/news/1365729.html

相关文章:

  • 如何让魔兽争霸3在现代电脑上完美运行:终极优化指南
  • DouYinBot:抖音无水印视频解析与下载的终极解决方案
  • 企业级智能代码理解解决方案:自动化伪代码生成架构指南
  • Reloaded-II模组加载器:从依赖地狱到游戏强化的技术突围
  • 机器学习笔记本崩溃深度解析:高频错误类型、根因与实战避坑指南
  • 5分钟制作专业LRC歌词:零基础快速上手指南
  • AI写专著全攻略:AI专著写作工具助力,20万字专著快速成型!
  • 80386 微代码反汇编:规模庞大挑战多,竟发现隐藏安全漏洞?
  • 5分钟掌握猫抓浏览器扩展的终极指南:轻松捕获在线视频资源
  • .NET JIT编译原理与官方性能优化实践指南
  • AMD Ryzen终极调试工具:免费开源完整指南
  • QKeyMapper免费开源按键映射工具:5分钟从新手到高手
  • Windows 11硬件限制绕过完整教程:让老旧电脑也能升级新系统的终极方案
  • 3大核心功能解密:RePKG:释放你的Wallpaper Engine创意潜能
  • MacType终极指南:5个简单步骤让Windows字体渲染媲美macOS
  • 从电路设计到验证:KLayout 0.29.12如何重新定义版图编辑体验
  • 如何通过SMUDebugTool实现AMD Ryzen处理器的底层对话?
  • 原码与补码乘法符号位处理差异
  • 如何高效重置JetBrains IDE试用期:终极操作指南
  • 终极指南:如何用ZXPInstaller轻松安装Adobe插件,告别复杂操作
  • 百度网盘直链解析:告别限速,实现全速下载的终极方案
  • 免费Chrome插件:一键保存完整网页的终极解决方案
  • 抖音下载神器:3步搞定批量无水印下载,效率提升95%
  • 终极资源嗅探指南:猫抓浏览器扩展帮你轻松捕获网页媒体资源
  • 魔兽争霸3终极优化指南:使用WarcraftHelper解决画面拉伸与帧率限制
  • QMC音频解码器完全指南:如何快速将QQ音乐加密文件转换为MP3/FLAC格式
  • 抖音下载器完整指南:3分钟批量下载无水印视频和音乐
  • 别再死记硬背MFCC公式了!用Python手把手带你复现FBank/MFCC特征提取全流程
  • Unity接入讯飞语音Android失败的底层原因与四步修复法
  • Taotoken的API Key分级管理与审计日志功能实际应用感受