某 Android 阅读 APP 章节协议逆向笔记
本文章中所有内容仅供学习交流使用,严禁用于商业用途和非法用途,由此产生的一切后果均与作者无关。若有侵权,请联系删除!
0.环境准备
梆梆加固脱修;针对没有ROOT真机,我采用某厂商ARM服务器,Docker+redroid11,Frida Server,目标APP且测试账号已登录
adb connect <test-server>:5555
adb shell pm path com.xxx.novel
versionName = 2.9.365
versionCode = 290365
1.确认目标
抓包分析得到获取章节的请求路径:
chapter/get_chapter_cmd
chapter/get_cpt_ifm
解析得到TXT地址,zlib/gzip解压
2.静态DEX分析
直接字符串搜索get_chapter_cmd、get_cpt_ifm没有结果,它们可能通过 native 库加密请求
我们翻一下so,发现一个可疑目标libcwmhttps.so
findstr /S /M /I "cwmhttps" *.java
定位到NetUtils.java
public native String track(Context context, int i2, String str, int i3);
public native String trackF(Context context, int i2, String str, int i3, String str2);
public native String trackS(Context context, String str, String str2);
则检查 track 方法在哪里被调用,以及请求如何构造。
findstr /S /M /I "NetUtils"
找到两种网络路径:
getC(int i2, Map params): 通过 native NetUtils.track() 请求(主要 API,带 i2 作为接口编号)
getWithTag(str, params) / post() / getGzip(str, params) : 通过 OkHttp + ParseKsy().decrypt() 解密
ParseKsy 是 Java 层的解密类(用于 OkHttp 返回)
track() native 方法: 接口编号 i2 + params JSON,返回已解密的 JSON(直接解析 code)
接着看 ParseKsy 解密类,以及正文下载的具体 Task 类。
public ParseKsy() throws Exception {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
messageDigest.update(Constants.get1().getBytes("UTF-8"));
byte[] bArr = new byte[32];
System.arraycopy(messageDigest.digest(), 0, bArr, 0, 32);
this.cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
this.key = new SecretKeySpec(bArr, "AES");
this.spec = getIV();
}
接着定位Constants.get1(),到Constants.java
public static String get1() {
if (TextUtils.isEmpty(s123)) {
try {
String str = App.getContext().getResources().getString(R.string.sort_of_type) + App.getContext().getPackageManager().getApplicationInfo(App.getContext().getPackageName(), 128).metaData.getString(PushDataUtil.KEY_APP_KEY) + b.b("VWxjZHRBSVdsWkdsQnI=");
s123 = str;
return str;
} catch (Exception e2) {
e2.printStackTrace();
}
}
return s123;
}
Constants.get1() 是动态构造的 key:R.string.sort_of_type + meta-data KEY_APP_KEY + b.b("VWxjZHRBSVdsWkdsQnI=")
接下来的目标:找正文下载的 Task 类,确认 get_chapter_cmd/get_cpt_ifm 接口编号。
findstr /S /M "DownloadUtil"
下载逻辑在 ReaderActivity4 中
这个java很大,找到 GetBookCMDTask - 这就是获取章节 cmd 的 Task。接着定位到GetBookCMDTask.java
protected void doHttpRequest(Object... objArr) {
HashMap hashMap = new HashMap();
hashMap.put(a.c("Y2hhcHRlcl9pZA=="), objArr[0].toString());
getC(MediaPlayer.MEDIA_PLAYER_OPTION_VIDEO_SAVED_HOST_TIME, hashMap);
}
接着寻找MediaPlayer.MEDIA_PLAYER_OPTION_VIDEO_SAVED_HOST_TIME的值
MediaPlayer.java 中可找到:
public static final int MEDIA_PLAYER_OPTION_VIDEO_SAVED_HOST_TIME = 264;
接口编号 264 = get_chapter_cmd。回到ReaderActivity4,找到com.bbs.css.a.a(...) ,是解密函数(两个参数:密文+key)。现在看 AnonymousClass57(GetBookCMDTask 成功回调)如何处理返回值并调用下一个接口 get_cpt_ifm
分析一下,GetBookCMDTask : 传 chapter_id → 返回 data.command (这是解密 key string)
GetBookContentDetailTask (接口=get_cpt_ifm): 传 chapter_id, command → 返回 data.chapter_info,含 txt_content(加密正文)、use_cdn、auth_access
解密正文:
若 use_cdn==1: GzipTask 下载 txt_url 内容 → BuyDownThread.saveText(value, chapter_info, chapterId, bookId, command)
否则: b.aa(txt_content, command) 解密 → com.bbs.css.a.b(text, command) 加水印
接下来找方法就行了,不多赘述,
/sources/com/bbs/css/a.java
看方法体:
public static String a(String str, String str2) throws Exception {
Cipher cipher = Cipher.getInstance(
c("QUVTL0NCQy9QS0NTN1BhZGRpbmc=")
);
IvParameterSpec iv = new IvParameterSpec(new byte[16]);
MessageDigest digest = MessageDigest.getInstance(
c("U0hBLTI1Ng==")
);
digest.update(str2.getBytes(c("dXRmLTg=")));
byte[] key = new byte[32];
System.arraycopy(digest.digest(), 0, key, 0, 32);
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(key, c("QUVT")),
iv
);
return new String(
cipher.doFinal(
Base64.decode(str.getBytes(c("dXRmLTg=")), 0)
),
c("dXRmLTg=")
);
}
辅助函数 c() 只是 Base64 解字符串常量:
QUVTL0NCQy9QS0NTN1BhZGRpbmc= → AES/CBC/PKCS7Padding
U0hBLTI1Ng== → SHA-256
dXRmLTg= → utf-8
QUVT → AES
所以非 CDN 正文算法:
ciphertext = Base64Decode(txt_content)
key = SHA256(UTF8(command))
iv = 16 zero bytes
plaintext = AES-256-CBC-Decrypt(ciphertext, key, iv)
plaintext = PKCS#7-Unpad(plaintext)
text = UTF8Decode(plaintext)
到这里,DEX 已经解决:
- 登录参数来源;
- 章节两阶段业务顺序;
command 的用途;
use_cdn 分支;
- 非 CDN 正文 AES;
- CDN 解压;
但仍缺少:
- 264/259 最终路径映射
rand_str、p的算法
- 响应为什么是 Base64
- 响应 AES key
这些都在 libcwmhttps.so。
3.解决反调试
App.java 中存在:
Debug.isDebuggerConnected()
App.isUnderTraced()
isUnderTraced() 读取:
/proc/<pid>/status
并检查:TracerPid
SafeGuardThread 约每 100ms 检查一次,一旦发现调试器就退出。
Frida 绕过:
Java.perform(function () {
var App = Java.use("com.xxxx.App");
App.isUnderTraced.implementation = function () {
return false;
};
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
return false;
};
});
so里有个libmsaoaidsec.so,老生常谈了,用 no-op callback 替换 msaoaidsec 的线程函数即可
4.Hook初探
根据我们第二步分析的dex,可写出hook代码:
Java.perform(function () {
send({ tag: 'LOG', msg: 'Java.perform entered OK' });
try {
var TAG = '';
function sendLog(msg) { send({ tag: 'LOG', msg: '' + msg }); }
var Log = Java.use('android.util.Log');
var Exception = Java.use('java.lang.Exception');
var JSONObject = Java.use('org.json.JSONObject');
function stack(n) {
var s = Log.getStackTraceString(Exception.$new());
return s.split('\n').slice(0, n || 25).join('\n');
}
function b2h(bytes) {
if (!bytes) return 'null';
var max = 512, len = Math.min(bytes.length, max), s = '';
for (var i = 0; i < len; i++) { s += ('0' + (bytes[i] & 0xff).toString(16)).slice(-2); }
if (bytes.length > max) s += '...(' + bytes.length + ')';
return s;
}
function map2str(m) {
if (!m) return 'null';
try {
var it = m.keySet().iterator();
var obj = {};
while (it.hasNext()) {
var k = it.next().toString();
var v = m.get(k);
obj[k] = v === null ? null : v.toString();
}
return JSON.stringify(obj);
} catch (e) { return 'mapErr:' + e; }
}
sendLog(TAG + ' hook_core2 attached');
// ===== 0. Bypass anti-debug: isUnderTraced + isDebuggerConnected =====
try {
var App = Java.use('com.xxxx.novel.App');
App.isUnderTraced.implementation = function () { return false; };
sendLog('[+] bypassed App.isUnderTraced');
} catch (e) { sendLog('[-] App.isUnderTraced err: ' + e); }
try {
var DebugCls = Java.use('android.os.Debug');
DebugCls.isDebuggerConnected.implementation = function () { return false; };
sendLog('[+] bypassed Debug.isDebuggerConnected');
} catch (e) { sendLog('[-] Debug err: ' + e); }
// ===== 1. ParseKsy 加解密 =====
try {
var ParseKsy = Java.use('com.xxxx.novel.utils.ParseKsy');
try { sendLog('\n[KEY] ParseKsy.SEED_16_CHARACTER = ' + ParseKsy.SEED_16_CHARACTER.value); } catch (e) {}
ParseKsy.decrypt.implementation = function (input) {
var ret = this.decrypt(input);
sendLog('\n[ParseKsy.decrypt] IN=' + input);
sendLog('[ParseKsy.decrypt] OUT=' + ret);
try {
sendLog('[ParseKsy.decrypt] key=' + this.key.value + ' spec=' + this.spec.value);
var iv = this.getIV();
sendLog('[ParseKsy.decrypt] IV=' + iv);
} catch (e) {}
sendLog('[STACK]\n' + stack(20));
return ret;
};
ParseKsy.encrypt.implementation = function (input) {
var ret = this.encrypt(input);
sendLog('\n[ParseKsy.encrypt] IN=' + input);
sendLog('[ParseKsy.encrypt] OUT=' + ret);
return ret;
};
sendLog('[+] hooked ParseKsy');
} catch (e) { sendLog('[-] ParseKsy err: ' + e); }
try {
var P1 = Java.use('com.xxxx.novel.utils.ParseKsy1');
P1.encryptData.implementation = function (i) { var r = this.encryptData(i); sendLog('\n[ParseKsy1.encryptData] IN=' + i + ' OUT=' + r); return r; };
} catch (e) {}
try {
var P2 = Java.use('com.xxxx.novel.utils.ParseKsy2');
P2.encode.implementation = function (i) { var r = this.encode(i); sendLog('\n[ParseKsy2.encode] IN=' + i + ' OUT=' + r); return r; };
} catch (e) {}
// ===== 2. BaseTaskNew — Map 可读 =====
try {
var BaseTaskNew = Java.use('com.xxxx.novel.task.newtask.BaseTaskNew');
BaseTaskNew.post.implementation = function (url, params) {
sendLog('\n[BaseTaskNew.post] url=' + url);
sendLog('[BaseTaskNew.post] params=' + map2str(params));
return this.post(url, params);
};
BaseTaskNew.getGzip.overload('java.lang.String', 'java.util.Map').implementation = function (url, params) {
sendLog('\n[BaseTaskNew.getGzip] url=' + url);
sendLog('[BaseTaskNew.getGzip] params=' + map2str(params));
return this.getGzip(url, params);
};
BaseTaskNew.getWithTag.implementation = function (url, params) {
sendLog('\n[BaseTaskNew.getWithTag] url=' + url);
sendLog('[BaseTaskNew.getWithTag] params=' + map2str(params));
return this.getWithTag(url, params);
};
BaseTaskNew.getC.implementation = function (a, params) {
sendLog('\n[BaseTaskNew.getC] a=' + a + ' params=' + map2str(params));
return this.getC(a, params);
};
BaseTaskNew.getCS.implementation = function (s, params) {
sendLog('\n[BaseTaskNew.getCS] s=' + s + ' params=' + map2str(params));
return this.getCS(s, params);
};
BaseTaskNew.setCommonParams.implementation = function (params) {
sendLog('\n[BaseTaskNew.setCommonParams] params=' + map2str(params));
return this.setCommonParams(params);
};
BaseTaskNew.unCompress.implementation = function (s) {
var ret = this.unCompress(s);
sendLog('\n[BaseTaskNew.unCompress] IN(' + (s ? s.length : 0) + ')=' + (s ? s.substring(0, 300) : s));
sendLog('[BaseTaskNew.unCompress] OUT(' + (ret ? ret.length : 0) + ')=' + (ret ? ret.substring(0, 400) : ret));
return ret;
};
BaseTaskNew.unCompress1.implementation = function (b) {
var ret = this.unCompress1(b);
sendLog('\n[BaseTaskNew.unCompress1] IN(' + (b ? b.length : 0) + ')=' + b2h(b));
sendLog('[BaseTaskNew.unCompress1] OUT(' + (ret ? ret.length : 0) + ')=' + (ret ? ret.substring(0, 400) : ret));
return ret;
};
sendLog('[+] hooked BaseTaskNew');
} catch (e) { sendLog('[-] BaseTaskNew err: ' + e); }
// ===== 2b. CompressionUtils =====
try {
var CU = Java.use('com.xxxx.novel.task.newtask.CompressionUtils');
var ms = CU.class.getDeclaredMethods();
for (var i = 0; i < ms.length; i++) {
var mn = ms[i].getName();
(function (name) {
try {
CU[name].overloads.forEach(function (ov) {
ov.implementation = function () {
sendLog('\n[CompressionUtils.' + name + '] called');
var ret = ov.apply(this, arguments);
try {
if (ret && typeof ret === 'string') sendLog('[CompressionUtils.' + name + '] ret(' + ret.length + ')=' + ret.substring(0, 400));
} catch (e) {}
return ret;
};
});
} catch (e) {}
})(mn);
}
sendLog('[+] hooked CompressionUtils');
} catch (e) { sendLog('[-] CompressionUtils err: ' + e); }
// ===== 3. OkHttp RealCall — 完整请求 + 响应 =====
try {
var RealCall = Java.use('okhttp3.internal.connection.RealCall');
var Buffer = Java.use('okio.Buffer');
RealCall.execute.implementation = function () {
var req = this.request();
var url = req.url().toString();
if (url.indexOf('chapter') >= 0 || url.indexOf('happybooker') >= 0 ||
url.indexOf('kuangxiangit') >= 0 || url.indexOf('.txt') >= 0 ||
url.indexOf('cpt') >= 0 || url.indexOf('hbooker') >= 0) {
sendLog('\n[>>>OkHttp.execute] ' + req.method() + ' ' + url);
try { sendLog('[OkHttp.headers]\n' + req.headers().toString()); } catch (e) {}
try {
var body = req.body();
if (body) { var buf = Buffer.$new(); body.writeTo(buf); sendLog('[OkHttp.body] ' + buf.readUtf8()); }
} catch (e) {}
}
var resp = this.execute();
// 捕获响应体(针对chapter/txt)
if (url.indexOf('chapter') >= 0 || url.indexOf('.txt') >= 0 || url.indexOf('cpt') >= 0) {
try {
sendLog('[OkHttp.resp-code] ' + resp.code());
var peek = resp.peekBody(1048576);
var rb = peek.bytes();
sendLog('[OkHttp.resp-body(' + rb.length + ')] ' + b2h(rb).substring(0, 1024));
} catch (e) { sendLog('[OkHttp.resp-err] ' + e); }
}
return resp;
};
sendLog('[+] hooked RealCall.execute');
} catch (e) { sendLog('[-] RealCall err: ' + e); }
// ===== 4. Cipher =====
try {
var Cipher = Java.use('javax.crypto.Cipher');
Cipher.doFinal.overload('[B').implementation = function (input) {
var ret = this.doFinal(input);
if (input.length > 32) {
var alg = ''; try { alg = this.getAlgorithm(); } catch (e) {}
sendLog('\n[Cipher.doFinal] alg=' + alg + ' in=' + input.length + ' out=' + ret.length);
sendLog('[Cipher-IN] ' + b2h(input));
try {
var s = Java.use('java.lang.String').$new(ret);
if (/[\x20-\x7e]/.test(s.substring(0, 4))) sendLog('[Cipher-OUT-str] ' + s.substring(0, 400));
else sendLog('[Cipher-OUT-hex] ' + b2h(ret));
} catch (e) { sendLog('[Cipher-OUT-hex] ' + b2h(ret)); }
}
return ret;
};
sendLog('[+] hooked Cipher.doFinal');
} catch (e) {}
// ===== 4b. NetUtils.track / trackS — capture native response (plaintext JSON) =====
try {
var NetUtils = Java.use('com.xxxx.novel.utils.NetUtils');
NetUtils.track.overload('android.content.Context', 'int', 'java.lang.String', 'int').implementation = function (ctx, code, json, userType) {
var ret = this.track(ctx, code, json, userType);
if (code == 264 || code == 259) {
sendLog('\n[TRACK] code=' + code + ' req=' + json);
sendLog('[TRACK RESP] code=' + code + ' resp=' + (ret ? ret.substring(0, 2000) : 'null'));
}
return ret;
};
sendLog('[+] hooked NetUtils.track');
} catch (e) { sendLog('[-] NetUtils.track err: ' + e); }
// ===== 5. b.aa / b.a decrypt (chapter content) =====
try {
var bCls = Java.use('com.xxxx.novel.activity.booklist.ada.b');
bCls.aa.implementation = function (str, cmd) {
var ret = this.aa(str, cmd);
sendLog('\n[b.aa decrypt] cmd=' + cmd);
sendLog('[b.aa IN] cipherLen=' + (str ? str.length : 0));
sendLog('[b.aa OUT] plain(' + (ret ? ret.length : 0) + ')=' + (ret ? ret.substring(0, 400) : 'null'));
return ret;
};
bCls.a.implementation = function (str) {
var ret = this.a(str);
sendLog('\n[b.a decrypt] IN(' + (str ? str.length : 0) + ') OUT(' + (ret ? ret.length : 0) + ')=' + (ret ? ret.substring(0, 200) : 'null'));
return ret;
};
sendLog('[+] hooked b.aa/b.a');
} catch (e) { sendLog('[-] bCls err: ' + e); }
sendLog(TAG + ' 请打开一本书阅读正文!');
} catch (e) { send({ tag: 'LOG', msg: 'Java.perform FATAL: ' + e + '\n' + (e.stack || '') }); }
});
调试没啥问题
5.深入so层(一)签名算法
前面已经知道核心内容在libcwmhttps.so,先逆向 rand_str
静态分析定位到随机字符串构造逻辑,关键 RVA:
0x8250c random_device / mt19937 初始化相关
0x8260c 字符表引用
0x74b0c 16 次随机字符选择
0x74e90 拼接时间戳
0x74ec8 MD5
0x74f10 截取前 12 字符
0x74f28 追加秒
0x74f84 追加分钟
字符表:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
伪代码:
random16 = random 16 chars from alphabet
now = decimal Unix timestamp
prefix = MD5(random16 + now).hex()[0:12]
rand_str = prefix + two_digit(second) + two_digit(minute)
动态日志中 Md5Encode 紧邻请求的输入类似:
ioZQBvpVy4CBQbZd1786259068
可以拆成:
ioZQBvpVy4CBQbZd → 16 位随机串
1786259068 → Unix 秒
随后最终 body 中出现:
rand_str=04280dcaa5574125
最后四位对应:
秒 + 分钟
Python:
import hashlib
import secrets
import string
import time
ALPHABET = string.digits + string.ascii_uppercase + string.ascii_lowercase
def make_rand_str(now=None):
now = int(time.time()) if now is None else int(now)
random16 = "".join(secrets.choice(ALPHABET) for _ in range(16))
prefix = hashlib.md5(
(random16 + str(now)).encode("ascii")
).hexdigest()[:12]
tm = time.localtime(now)
return f"{prefix}{tm.tm_sec:02d}{tm.tm_min:02d}"
接着看签名 p
签名核心函数位于:
CenterDataAPI::post1 RVA 0x6da7c
反汇编关键区间:
0x6e148 ~ 0x6e274 构造签名原文
0x6e284 引用 CkMxWNB666
0x6e2e0 ~ 0x6e30c 对固定 secret 做 MD5
0x6e32c 拼接 secret MD5
0x6e380 ~ 0x6e3ac 对签名原文做 MD5
0x6e408 ~ 0x6e414 调用 getSha256
0x6e420 引用 "p="
0x6e424 以后 将 p 追加到最终 body
字符串地址对应:
0x51e2e "account="
0x4d43c "&app_version="
0x50d59 "&rand_str="
0x5030a "&signatures="
0x4f348 "p="
0x51952 "CkMxWNB666"
其中 std::string append/copy 的调用大量落在内部 thunk,例如:
0xe8de0
0xe8df0
0xe8e30
0xe8e40
0xe8e50
0xe8e60
0xe8e70
单看这些调用很难直接恢复每个临时对象,因此下一步用静态地址,然后动态读取栈上 C++ string的办法。
反汇编显示在:
RVA 0x6e380
即将把 sp + 0xf0 对应字符串送入 MD5 上下文。
在该地址 hook:
var address = cwm.base.add(0x6e380);
Interceptor.attach(address, {
onEnter: function () {
send({
tag: "signing_preimage",
data: readCppString(this.context.sp.add(0xf0))
});
}
});
抓到完整原文:
account=xxx...&app_version=2.9.365&rand_str=54084cd2066ec947&signatures=a90f3731745f1c30ee77cb13fc00005af86e236a670008e46a6e5da67e4c9c4f
说明:
account 做 form URL 编码;
chapter_id 不进入这个 MD5;
signatures 是 64 个 hex 字符,它由两个 32 字符 MD5 hex 直接拼接,中间没有分隔符。
那signatures 的两半来自哪里?
第一半:a90f3731745f1c30ee77cb13fc00005a
启动时 Md5Encode 收到 APK 证书的 DER 十六进制字符串,动态输入开头类似:30820271308201daa003020102...
Md5Encode 在该路径中对证书字符串做了特定裁剪:substr(1, len - 2)
之后 MD5 得到:a90f3731745f1c30ee77cb13fc00005a
第二半:f86e236a670008e46a6e5da67e4c9c4f
即:MD5("CkMxWNB666").hex()
所以:
signatures = certificate_md5 + MD5("CkMxWNB666").hex()
有个小细节,getSha256 名字具有误导性
符号:
CenterDataAPI::getSha256
RVA 0x75218
名字看起来像普通 SHA-256,反汇编却显示调用:
HMAC(EVP_sha256, key, data, ...)
所以它实际是:
HMAC-SHA256 + Base64 + URL encode
最终实现:
import base64
import hashlib
import hmac
import urllib.parse
CERTIFICATE_MD5 = "a90f3731745f1c30ee77cb13fc00005a"
SECRET_MD5 = hashlib.md5(b"CkMxWNB666").hexdigest()
SIGNATURES_VALUE = CERTIFICATE_MD5 + SECRET_MD5
def make_signature(account, app_version, rand_str):
encoded_account = urllib.parse.quote_plus(account, safe="")
preimage = (
f"account={encoded_account}"
f"&app_version={app_version}"
f"&rand_str={rand_str}"
f"&signatures={SIGNATURES_VALUE}"
)
message = hashlib.md5(
preimage.encode("utf-8")
).hexdigest().encode("ascii")
digest = hmac.new(
CERTIFICATE_MD5.encode("ascii"),
message,
hashlib.sha256,
).digest()
return base64.b64encode(digest).decode("ascii")
6.深入so层(二)响应解密算法
关键 RVA:
0x8113c aes_256_cbc_decode
0x81308 decrypt
0x81680 Base64 decode
aes_256_cbc_decode 的逻辑:
根据 mode 选 secret
→ SHA256(secret) 得 32 字节 key
→ IV 清零
→ 调 decrypt
两套 secret:
mode == 1:
sD6doAOcW7hm7iaeK6UlcdtAIWlZGlBr
其他 mode:
zG2nSeEfSHfvTCHy5LCcqtBbQehKNLXn
decrypt 内部:
0x81340 Base64 decode
0x81350 AES_set_decrypt_key(..., 256)
0x813d8 AES_cbc_encrypt(..., AES_DECRYPT)
0x81494 开始 PKCS#7 校验/移除
接下来动态确认章节接口的 mode
在:
module.base + 0x8113c
读取第三个参数:
Interceptor.attach(module.base.add(0x8113c), {
onEnter: function (args) {
send({
tag: "response_decode",
data: {
mode: args[2].toInt32(),
input_prefix: readCppString(args[1]).slice(0, 80)
}
});
}
});
get_chapter_cmd、get_cpt_ifm 均观察到:mode = 1
所以章节主接口响应算法确定为:
ciphertext = Base64Decode(HTTP body)
key = SHA256(UTF8("sD6doAOcW7hm7iaeK6UlcdtAIWlZGlBr"))
iv = 16 zero bytes
plaintext = AES-256-CBC-Decrypt(ciphertext, key, iv)
JSON = UTF8(PKCS7-Unpad(plaintext))
实现:
import base64
import hashlib
import json
from Crypto.Cipher import AES
RESPONSE_SECRET = "sD6doAOcW7hm7iaeK6UlcdtAIWlZGlBr"
def decrypt_response(body):
ciphertext = base64.b64decode(body, validate=True)
key = hashlib.sha256(RESPONSE_SECRET.encode("ascii")).digest()
padded = AES.new(
key,
AES.MODE_CBC,
bytes(16),
).decrypt(ciphertext)
pad = padded[-1]
if not 1 <= pad <= 16:
raise ValueError("invalid padding")
if padded[-pad:] != bytes([pad]) * pad:
raise ValueError("invalid PKCS#7 padding")
return json.loads(padded[:-pad].decode("utf-8"))
结语
完整过程可以压缩成:
DEX 定位 NetUtils、登录态、阅读器和正文解密
↓
确认 Java 只传命令号和 JSON
↓
SO strings 锁定 URL、路径、签名和密码学符号
↓
spawn + 反调试 hook
↓
postHttpsRequest 捕获真实 URL 和最终表单
↓
ARM64 反汇编恢复签名构造结构
↓
处理 JNI 参数和 libc++ std::string SSO
↓
在 MD5/HMAC 边界找到真实输入
↓
在 AES 边界确认响应 mode 和密钥选择
↓
用 Python 得到最终算法
DEX 负责解释业务语义,SO 负责解释协议实现,动态 hook 负责捕获真实数据测试。三者闭环后,算法才算真正被还原!