吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 1051|回复: 17
收起左侧

[Android 原创] 某刺猬小说逆向分析

[复制链接]
Walletshine 发表于 2026-8-11 16:15

某 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_cmdget_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_cdnauth_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

说明:

  1. account  做 form URL 编码;
  2. chapter_id 不进入这个 MD5;
  3. 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_cmdget_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 负责捕获真实数据测试。三者闭环后,算法才算真正被还原!

免费评分

参与人数 5吾爱币 +5 热心值 +4 收起 理由
2765245531 + 1 + 1 用心讨论,共获提升!
0721God + 1 + 1 西红柿小说的也可以吗?
hml123 + 1 用心讨论,共获提升!
lsb2pojie + 1 + 1 热心回复!
阿巴阿巴阿巴 + 1 + 1 goo

查看全部评分

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

天天心悦 发表于 2026-8-13 17:48
那signatures 的两半来自哪里?
第一半:a90f3731745f1c30ee77cb13fc00005a
启动时 Md5Encode 收到 APK 证书的 DER 十六进制字符串,动态输入开头类似:30820271308201daa003020102...
Md5Encode 在该路径中对证书字符串做了特定裁剪:substr(1, len - 2)
之后 MD5 得到:a90f3731745f1c30ee77cb13fc00005a


signaturesKey是对的,但生成步骤感觉不对,让gemini分析libcwmhttps.so,CenterDataAPI::GetInfo和CenterDataAPI::Md5Encode 根据这两个方法的伪代码也是给出了相同的结论。
但实际测试,不同的apk版本的apk证书生成的都不一样。

AI的解释:
1. 重新解析 GetInfo 流程(误区揭秘)
在 CenterDataAPI::GetInfo 中,代码做了如下操作:

C
// 1. 获取应用签名(PackageInfo.signatures)
v23 = CallObjectMethod(..., "toCharsString");
s_5 = CenterDataAPI::jstringToChar(a1, a2, v23);

// 2. 对签名串计算 MD5,结果写入 v34/v35
CenterDataAPI::Md5Encode(&v34, a1, v32);

// 3. 把计算出来的 MD5 赋值给 a1 + 216 偏移
*(_OWORD *)(a1 + 216) = v34;
*(_QWORD *)(a1 + 232) = v35;

// 4. 重点来了!设置标志位:
*(_BYTE *)(a1 + 240) = 1;
这段代码看似把 MD5 结果写到了 a1 + 216,但这很可能只是一个签名校验/防篡改比对标志(Check Value),或者这只是 GetInfo 被调用时的初值。


生成SIGNATURE_KEY测试代码:
[PHP] 纯文本查看 复制代码
$signatureKey = getSignatureKeyFromRsaFile('D:/tool/apk反编译/KUANGXIA-2.9.353.RSA');
// $signatureKey = getSignatureKeyFromRsaFile('D:/tool/apk反编译/KUANGXIA-2.9.335.RSA');
echo "计算得到的 signatureKey 为: " . $signatureKey . "\n";


function getSignatureKeyFromRsaFile(string $rsaFilePath): string
{
    // 1. 读取 RSA 二进制文件并转为 Hex 字符串
    $binaryData = file_get_contents($rsaFilePath);
    $hexData = bin2hex($binaryData);
    // $hexData = strtolower(bin2hex($binaryData));

    // 2. 寻找 DER 证书的 3082 头
    $pos = strpos($hexData, '3082');
    $toCharsString = ($pos !== false) ? substr($hexData, $pos) : $hexData;

    // 3. 还原 C++ Md5Encode 裁剪首尾 1 个字符逻辑: substr(1, len - 2)
    $trimmed = substr($toCharsString, 1, strlen($toCharsString) - 2);
    // $trimmed = $toCharsString;

    // 4. 计算最终的 signatureKey
    return md5($trimmed);
}

fwsfhlsw 发表于 2026-8-11 20:06
云朵上de流年 发表于 2026-8-11 21:06
牛逼PLUS,我还想找某书某旗的逆向,结果先看到了这个
CKsir 发表于 2026-8-11 22:05
挺好,也算是了解了解这些小说平台的解析,hh
tqx5201 发表于 2026-8-11 23:00
教程都这么长,怎么学呀
wwb66668 发表于 2026-8-11 23:01
感谢楼主的分享
laiwen666 发表于 2026-8-12 00:16
感谢分享教程
schoolyears 发表于 2026-8-12 00:18
这些平台为毛不服务端验证
xyslazio 发表于 2026-8-12 00:29
感谢楼主分享教程
abc023119 发表于 2026-8-12 10:06
太深奥了 官网 发一下 试试  感谢
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

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

GMT+8, 2026-8-21 07:08

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

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