Skip to content

起点小说 App 端分析笔记

创建日期: 2025/06/17

一、起点客户端请求逻辑

1. Header 字段

字段:

  • borgus
  • cecelia
  • cookie
    • ywguid
    • appId
    • ...
    • QDInfo
  • gorgon
  • ibex
  • qdinfo
  • qdsign
  • tstamp

其中每次请求改变的值有:

  • borgus
  • cecelia
  • cookie
    • QDInfo
  • ibex
  • qdinfo
  • qdsign
  • tstamp

具体暂时不分析, 可能有用的参考资料:

注: 经过分析, 这些 header 字段应该主要是 com.qidian.QDReader.component.util.FockUtiladdRetrofitH 函数添加的

2. 主要接口一览

2.1 获取书籍基础信息

功能: 查询某本书的详情。

URL

GET https://druidv6.if.qidian.com/argus/api/v3/bookdetail/lookfor

Query 参数:

字段 类型 必选 含义
bookId int 起点书籍唯一 ID
isOutBook int 是否为外部导入的书 (0/1)

示例 param:

{
    "bookId": 1234567,
    "isOutBook": 0,
}

2.2 获取未购买章节列表

功能: 拉取当前用户未购买章节及章节卡信息。

URL

POST https://druidv6.if.qidian.com/argus/api/v2/subscription/getunboughtchapterlist

Body 参数:

字段 类型 必选 含义
bookId int 书籍 ID
pageSize int 每页大小, 默认 99999
pageIndex int 页码, 从 1 开始

示例 Body:

{
    "bookId": 1234567,
    "pageSize": 99999,
    "pageIndex": 1
}

2.3 获取已购买章节 ID 列表

功能: 获取用户已购买的所有章节 ID, 便于本地校验缓存。

URL

GET https://druidv6.if.qidian.com/argus/api/v3/chapterlist/chapterlist

Query 参数:

字段 类型 必选 含义
bookId int 书籍 ID
timeStamp long 毫秒时间戳
requestSource int 来源标识, 0=App 等
md5Signature string MD5
extendchapterIds string 扩展查询章节 ID 列表

md5Signature 计算说明:

将本地已存在的章节 ID 与对应的卷 ID 按阅读顺序用竖线拼接, 得到形如:

cid1|vcode1|cid2|vcode2|...|cidN|vcodeN

对该字符串取 MD5, 结果即为 md5Signature

示例 param:

{
    "bookId": 1234567,
    "timeStamp": 1750000000000,
    "requestSource": 0,
    "md5Signature": "5f4dcc3b5aa765d61d8327deb882cf99",
    "extendchapterIds": "2345678,3456789"
}

2.4 获取彩蛋章节列表

功能: 获取主线章节后面附带的「彩蛋」章节列表。

URL

GET https://druidv6.if.qidian.com/argus/api/v1/midpage/book/chapters

Query 参数: bookId

示例 param:

{
    "bookId": 1234567,
}

示例返回:

{
    "Data": {
        "Chapters": [
            {
                "ChapterId": 12345678,
                "MidpageList": [
                    {"MidpageId": 8888, "MidpageName":"彩蛋1","UpdateTime":1686868686868},
                    {"MidpageId": 9999, "MidpageName":"彩蛋2","UpdateTime":1686868687878}
                ]
            }
        ]
    }
}

字段说明

  • MidpageListUpdateTime 为 UTC 毫秒。
  • 可据此在本地拼接阅读顺序。

2.5 获取彩蛋章节内容

功能: 获取「彩蛋」章节内容。

URL

GET https://druidv6.if.qidian.com/argus/api/v3/midpage/pageinfo

Query 参数:

字段 类型 必选 含义
bookId int 书籍 ID
chapterId int 章节 ID
needAdv int 默认 0

示例 param:

{
    "bookId": 1234567,
    "chapterId": 12345678,
    "needAdv": 0,
}

2.6 下载章节内容

VIP 章节下载

URL

POST https://druidv6.if.qidian.com/argus/api/v4/bookcontent/getvipcontent

Body:

字段 含义
b bookId
c chapterId
ui 不确定
b-string 加密包标识

示例 Body:

{
    "b-string": "",
    "b": 1234567,
    "c": 555555,
    "ui": 0,
}
安全下载

URL

GET https://druidv6.if.qidian.com/argus/api/v2/bookcontent/safegetcontent

Query: bookId, chapterId

示例 param:

{
    "bookId": 1234567,
    "chapterId": 555555,
}
批量下载

URL

POST https://druidv6.if.qidian.com/argus/newapi/v1/bookcontent/getcontentbatch

Body:

{
    "b":1234567,
    "c":"555,222,333,444,666,888",
    "useImei":0
}

返回包含 DownloadUrlKeyMd5Size, 需后续 GET COS 链接下载 ZIP, 然后解包。

注意: DownloadUrl 是加密状态需要使用 Fockunlock 进行解密

相关函数 (点击展开)
// 调用:
unLockContent = qDChapterBatchDownloadLoader.unLockContent(qDChapterBatchDownloadLoader.getBookId(), key, downloadUrl);
`qDChapterBatchDownloadLoader.unLockContent`:
public final String unLockContent(long j10, String str, String str2) {
    if (str2 == null || str2.length() == 0) {
        return "";
    }
    String valueOf = String.valueOf(j10);
    String str3 = "BatchChapterCos_" + j10 + "_" + str;
    FockUtil fockUtil = FockUtil.INSTANCE;
    Fock.FockResult unlock = fockUtil.unlock(str2, valueOf, str3);
    if (unlock.status == Fock.FockResult.STATUS_EMPTY_USER_KEY) {
        Fock.setup(we.d.X());
        unlock = fockUtil.unlock(str2, valueOf, str3);
    }
    if (unlock.status != 0) {
        return "";
    }
    byte[] bArr = unlock.data;
    kotlin.jvm.internal.o.d(bArr, "unlockResult.data");
    Charset UTF_8 = StandardCharsets.UTF_8;
    kotlin.jvm.internal.o.d(UTF_8, "UTF_8");
    return new String(bArr, UTF_8);
}

2.7 获取书籍封面

支持两种图片格式: WebP 和 JPEG。

请将以下 URL 中的占位符替换为实际值:

  • {book_id}: 书籍的唯一 ID
  • {width}: 封面宽度, 单位为像素, 可选值: 90150180300600

WebP 格式

https://bookcover.yuewen.com/qdbimg/349573/{book_id}/{width}.webp

JPEG 格式

https://bookcover.yuewen.com/qdbimg/349573/{book_id}/{width}
使用示例

获取 book_id = 123456, 宽度为 300px 的 WebP 封面:

https://bookcover.yuewen.com/qdbimg/349573/123456/300.webp

获取同一书籍的 JPEG 封面:

https://bookcover.yuewen.com/qdbimg/349573/123456/300

二、*.qd 文件结构与内容解析

1. 文件目录结构

*.qd 文件主要用于存储起点 App 的本地缓存数据, 安卓端位于以下路径:

/data/media/0/Android/data/com.qidian.QDReader/files/QDReader/book/{user_id}/

/sdcard/Android/data/com.qidian.QDReader/files/QDReader/book/{user_id}/

/storage/emulated/0/Android/data/com.qidian.QDReader/files/QDReader/book/{user_id}/

该目录结构如下所示:

{user_id}/
│
├── {book_id}.qd               # 书籍的元信息
│
└── {book_id}/                 # 子目录, 按章节存储内容
    ├── {chap_id}.qd           # 章节的数据文件
    ├── {chap_id}.qd
    └── ...

以下分析基于示例代码:

from pathlib import Path
from pprint import pprint

book_id = "123456"
metadata_path = Path.cwd() / "data" / f"{book_id}.qd"
chap_dir = Path.cwd() / "data" / book_id

2. {book_id}.qd 内容解析

2.1 文件标头识别 (Header)

读取前 16 个字节用于识别文件格式:

with open(metadata_path, "rb") as f:
    header = f.read(16)
    print(header)

输出:

b'SQLite format 3\x00'

文件为 SQLite 3 格式数据库

2.2 数据库结构查看

通过 SQLite3 库查询数据库中包含的表:

import sqlite3

conn = sqlite3.connect(metadata_path)
query = "SELECT name FROM sqlite_master WHERE type='table';"

cursor = conn.cursor()
cursor.execute(query)
tables = cursor.fetchall()
pprint(tables)

conn.close()

输出示例:

[('chapter',),
 ('volume',),
 ('bookmark',),
 ('sqlite_sequence',),
 ('new_markline',),
 ('chapterExtraInfo',)]

2.3 表数据采样

获取各表中前几条数据作为示例:

conn = sqlite3.connect(metadata_path)
cursor = conn.cursor()

table_previews = {}

for (table_name,) in tables:
    cursor.execute(f"SELECT * FROM {table_name} LIMIT 2;")
    rows = cursor.fetchall()
    table_previews[table_name] = rows

conn.close()

pprint(table_previews)

3. {chap_id}.qd 内容解析

声明: 本文为笔者首次尝试进行 Android 应用的逆向分析, 相关方法和思路均基于个人现阶段理解, 主要目的在于探索学习。部分手段可能存在更优或更规范的实现方式, 欢迎指正与交流。

所用工具与依赖

用于分析和提取章节 .qd 文件内容, 涉及以下工具与库:

逆向分析工具
  • jadx: 用于反编译 APK, 提取 Java 层逻辑
  • Ghidra: 静态分析原生库 (如 .so 文件)
  • IDA Pro: 反汇编工具, 用于静态分析本地代码
  • Android Platform Tools (adb): 用于连接调试设备
  • Frida: 动态插桩与函数 Hook, 辅助定位或调用加密逻辑
Python 依赖
  • frida==16.7.19frida-tools==13.7.1
    • 版本 17.x 及以上目前暂时存在 Java 环境问题, 需要手动处理
    • 相关讨论见: Java is not defiend
  • pycryptodome
    • 加解密算法库

3.1 ADB 连接设备

通过 ADB 与目标设备建立连接:

adb connect <device-ip>

可使用 adb devices 验证连接状态

3.2 配置 Frida

检查设备架构

使用以下命令确认设备架构:

adb shell getprop ro.product.cpu.abi

常见返回值包括:

  • x86
  • x86_64
  • arm64-v8a

根据架构与所用 Frida Python 版本, 从 Frida Releases 下载对应的 frida-server, 例如:

frida-server-16.7.19-android-x86.xz
安装与授权

解压后将可执行文件推送到设备 (这里解压后重命名为 frida-server-16.7.19)

adb push frida-server-16.7.19 /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server-16.7.19"

启动服务 (需要 root 权限):

adb shell
su
/data/local/tmp/frida-server-16.7.19 &
验证连接

在电脑终端使用以下命令列出设备进程, 确认 Frida 服务启动成功:

frida-ps -U

若成功, 可看到设备进程列表, 其中起点对应为:

  • 起点读书
  • com.qidian.QDReader

3.3 获取原生日志

为便于后续排查信息, 这里全量导出 logcat 日志:

adb logcat > logs.txt

后续配合关键字筛查

3.4 使用 Frida Hook 日志函数

说明:

下述方法仅用于演示如何通过 Hook 日志接口来获取 App 的原始日志

在后续的实际分析流程中并未使用到该方法, 这里只是提出一种可选的调试手段, 便于在必要时辅助排查部分流程。

在后续分析中, 通过 jadx 反编译发现该应用采用了腾讯开源的 Mars 框架中的 xlog 模块进行日志输出。

为了捕获应用在加密前输出的原始调试信息, 可使用 Frida 编写脚本, Hook Xlog 类中的日志打印方法, 实现实时日志拦截:

`hook_xlog.js` (点击展开)
Java.perform(function () {
    const Xlog = Java.use("com.tencent.mars.xlog.Xlog");

    function hookLog(level, method) {
        if (!Xlog[method]) {
            console.log("Method not found:", method);
            return;
        }

        Xlog[method].overload('java.lang.String', 'java.lang.String', 'java.lang.String', 'int', 'int', 'long', 'long', 'java.lang.String')
            .implementation = function (tag, filename, funcname, line, pid, tid, maintid, msg) {
                const fullMsg = `[${level}][${tag}] ${msg}`;
                console.log(fullMsg);
                return this[method](tag, filename, funcname, line, pid, tid, maintid, msg);
            };
    }

    hookLog("V", "logV");
    hookLog("D", "logD");
    hookLog("I", "logI");
    hookLog("W", "logW");
    hookLog("E", "logE");
});

执行 Hook:

frida -U -n 起点读书 -l hook_xlog.js

3.5 解密逻辑分析

APK 解包后, 可使用 jadx 反编译并定位到 com/qidian/QDReader/component/bll 包中的核心方法

核心解密函数 (点击展开)
public static ChapterContentItem H(long j10, ChapterItem chapterItem) throws Throwable {
    long j11;
    byte[] bArr;
    JSONObject jSONObject;
    String strEncode;
    long jCurrentTimeMillis = System.currentTimeMillis();
    ChapterContentItem chapterContentItem = new ChapterContentItem();
    String strF = F(j10, chapterItem);
    File file = new File(strF);
    d dVarW = W(file, chapterItem);
    if (dVarW == null) {
        h0("OKR_chapter_vip_null", String.valueOf(j10), String.valueOf(chapterItem.ChapterId), String.valueOf(-20076), "chapter data is null", "");
        chapterContentItem.setErrorCode(-20076);
        return chapterContentItem;
    }
    byte[][] bArr2 = dVarW.f18975search;
    if (bArr2 == null || bArr2.length < 2) {
        g0("OKR_chapter_vip_empty", j10, chapterItem, dVarW);
        chapterContentItem.setErrorCode(-20076);
        return chapterContentItem;
    }
    byte[] bArr3 = bArr2[0];
    byte[] bArr4 = bArr2[1];
    byte[] bArr5 = bArr2[2];
    byte[] bArr6 = bArr2[3];
    byte[] bArr7 = bArr2[4];
    long J = J(bArr3);
    if (J != 0 && J < vi.judian.e().f()) {
        chapterContentItem.setErrorCode(-20067);
        return chapterContentItem;
    }
    byte[] bArrX = x(bArr4, j10, chapterItem.ChapterId);
    if (bArrX == null) {
        if (file.exists()) {
            String strM = com.qidian.common.lib.util.m.m(file);
            strEncode = !TextUtils.isEmpty(strM) ? URLEncoder.encode(strM) : "";
        } else {
            strEncode = "file_not_found_" + strF;
        }
        d5.cihai.p(new AutoTrackerItem.Builder().setPn("OKR_chapter_vip_error").setDt("1103").setPdid(String.valueOf(j10)).setDid(String.valueOf(chapterItem.ChapterId)).setPdt(String.valueOf(strEncode.length())).setEx1(String.valueOf(QDUserManager.getInstance().k())).setEx2(QDUserManager.getInstance().s()).setEx3(QDUserManager.getInstance().t()).setEx4(we.d.I().d()).setEx5(strEncode).setAbtest("true").setKeyword("v2").buildCol());
        chapterContentItem.setErrorCode(-20068);
        return chapterContentItem;
    }
    String strW = w(bArrX);
    JSONObject jSONObjectQ = Q(strW);
    if (jSONObjectQ != null) {
        strW = jSONObjectQ.optString("content");
        int iOptInt = jSONObjectQ.optInt("type");
        int iOptInt2 = jSONObjectQ.optInt("code");
        String strOptString = jSONObjectQ.optString("msg");
        j11 = jCurrentTimeMillis;
        bArr = bArr7;
        e0.f18516search.a(j10, chapterItem.ChapterId, new e0.search(jSONObjectQ.optLong("idExpire"), jSONObjectQ.optInt("wt")));
        if (iOptInt2 != 0) {
            chapterContentItem.setErrorCode(iOptInt2);
            chapterContentItem.setErrorMessage(strOptString);
            return chapterContentItem;
        }
        if (TextUtils.isEmpty(strW)) {
            chapterContentItem.setErrorCode(-20088);
            return chapterContentItem;
        }
        String str = j10 + "_" + chapterItem.ChapterId;
        if (iOptInt == LockType.FOCK.getType()) {
            String strValueOf = String.valueOf(chapterItem.ChapterId);
            FockUtil fockUtil = FockUtil.INSTANCE;
            boolean zIsHasKey = fockUtil.isHasKey();
            Fock.FockResult fockResultUnlock = fockUtil.unlock(strW, strValueOf, str);
            if (fockResultUnlock.status == Fock.FockResult.STATUS_EMPTY_USER_KEY) {
                Fock.setup(we.d.X());
                fockResultUnlock = fockUtil.unlock(strW, strValueOf, str);
            }
            Fock.FockResult fockResult = fockResultUnlock;
            Logger.e("FockUtil: chapter_id:" + chapterItem.ChapterId + ",result:" + fockResult.status);
            if (fockResult.status != 0) {
                fockUtil.report(j10, chapterItem, fockResult, zIsHasKey);
            }
            int i10 = fockResult.status;
            if (i10 != 0) {
                if (i10 == -2) {
                    chapterContentItem.setErrorCode(-20079);
                    d5.cihai.p(new AutoTrackerItem.Builder().setPn("OKR_LoadChapterFailed_qimeiChanged").setEx1(String.valueOf(j10)).setEx2(String.valueOf(chapterItem.ChapterId)).buildCol());
                    return chapterContentItem;
                }
                if (i10 == Fock.FockResult.STATUS_EMPTY_USER_KEY) {
                    chapterContentItem.setErrorCode(-20082);
                    return chapterContentItem;
                }
                chapterContentItem.setErrorCode(-20080);
                return chapterContentItem;
            }
            strW = new String(fockResult.data, StandardCharsets.UTF_8);
        } else if (iOptInt != LockType.DEFAULT.getType()) {
            chapterContentItem.setErrorCode(-20081);
            return chapterContentItem;
        }
    } else {
        j11 = jCurrentTimeMillis;
        bArr = bArr7;
    }
    String str2 = strW;
    ArrayList<BlockInfo> arrayList = null;
    if (bArr5 != null) {
        try {
            jSONObject = new JSONObject(w(bArr5));
        } catch (Exception e10) {
            e10.printStackTrace();
        }
    } else {
        jSONObject = null;
    }
    chapterContentItem.setChapterContent(str2);
    chapterContentItem.setOriginalChapterContent(str2);
    chapterContentItem.setAuthorContent(jSONObject);
    if (bArr != null) {
        try {
            String strW2 = w(bArr);
            if (strW2 != null) {
                arrayList = (ArrayList) new Gson().j(strW2, new b().getType());
            }
        } catch (Exception e11) {
            e11.printStackTrace();
        }
        chapterContentItem.setBlockInfos(arrayList);
    }
    if (we.d.k0()) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("Vip章节 内容读取 chapterId:");
        stringBuffer.append(chapterItem.ChapterId);
        stringBuffer.append(" chapterName:");
        stringBuffer.append(chapterItem.ChapterName);
        stringBuffer.append(" 读取, 耗时:");
        stringBuffer.append(System.currentTimeMillis() - j11);
        stringBuffer.append("毫秒");
        Logger.d("QDReader", stringBuffer.toString());
    }
    return chapterContentItem;
}

解密流程可概括为:

  1. 文件分段读取: 使用 W(file, ...) 按小端格式读取 5 段数据
  2. 第一阶段解密: 将 part1 传入 x(...) 调用 native 接口 a.b.b(...), 返回 JSON 字符串
  3. JSON 解析: 提取 contenttypecodemsg 等字段
  4. VIP 章节二次解密: type == FOCK 时, 调用 FockUtil.unlock(...) 解密
  5. 附加数据块处理
3.5.1 W 函数 -- 分段读取逻辑

W 函数的核心思路是在文件输入流上依次读取 5 段数据, 每段前 4 字节表示该段的长度 (little-endian), 随后读取对应长度的原始字节。

FileInputStream fileInputStream;
byte[] bArr;
byte[] bArr2;
byte[] bArr3;
byte[] bArr4;
byte[] bArr5;
fileInputStream = new FileInputStream(file);
int iR = com.qidian.common.lib.util.m.r(fileInputStream);
bArr2 = new byte[iR];
fileInputStream.read(bArr2, 0, iR);
int iR2 = com.qidian.common.lib.util.m.r(fileInputStream);
bArr3 = new byte[iR2];
fileInputStream.read(bArr3, 0, iR2);
int iR3 = com.qidian.common.lib.util.m.r(fileInputStream);
bArr4 = new byte[iR3];
fileInputStream.read(bArr4, 0, iR3);
int iR4 = com.qidian.common.lib.util.m.r(fileInputStream);
bArr = new byte[iR4];
fileInputStream.read(bArr, 0, iR4);
int iR5 = com.qidian.common.lib.util.m.r(fileInputStream);
bArr5 = new byte[iR5];
fileInputStream.read(bArr5, 0, iR5);
dVar.f18975search = new byte[][]{bArr2, bArr3, bArr4, bArr, bArr5};

其中, com.qidian.common.lib.util.m.r 用于读取 4 字节长度并转换为 int:

public static int r(InputStream inputStream) throws IOException {
    byte[] bArr = new byte[4];
    inputStream.read(bArr);
    ByteBuffer byteBufferWrap = ByteBuffer.wrap(bArr);
    byteBufferWrap.order(ByteOrder.LITTLE_ENDIAN);
    return byteBufferWrap.getInt();
}

由此可得文件整体格式:

[len0][data0]
[len1][data1]
[len2][data2]
[len3][data3]
[len4][data4]
Python 等价实现

在 Python 中可用 BytesIO 对相同逻辑进行复现:

from io import BytesIO
from pathlib import Path

path = Path("xxx.qd")
with path.open('rb') as f:
    buf = BytesIO(f.read())

def read_chunk():
    # 先读 4 字节 little-endian length
    raw = buf.read(4)
    if len(raw) < 4:
        raise IOError("文件结构不完整")
    length = int.from_bytes(raw, byteorder='little')
    # 再读对应长度
    return buf.read(length)

chunk0 = read_chunk()
chunk1 = read_chunk()
chunk2 = read_chunk()
chunk3 = read_chunk()
chunk4 = read_chunk()
3.5.2 x 函数 -- Native 解密流程

点击查看 x 函数, 发现该方法主要调用本地 JNI 接口完成解密, 例如:

// private static byte[] x(byte[] bArr, long j10, long j11)
// ...
byte[] bArrB2 = a.b.b(j10, j11, bArr, QDUserManager.getInstance().k(), we.d.I().d());
if (bArrB2 != null) {
    return bArrB2;
}

a/b.java 中发现是 native 函数:

public class b {
    static {
        try {
            System.loadLibrary("load-jni");
        } catch (Exception e10) {
            e10.printStackTrace();
        } catch (UnsatisfiedLinkError e11) {
            e11.printStackTrace();
        }
    }

    public static native byte[] b(long j10, long j11, byte[] bArr, long j12, String str);

    // ...
}

使用 Ghidra (或 IDA) 对 libload-jni.so 进行反汇编分析后. 可快速定位到对应的 native 方法实现 Java_a_b_b:

Java_a_b_b 实现片段 (点击展开)
jobject __fastcall Java_a_b_b(
    JNIEnv *env,
    jobject clazz,
    jlong bookIdLong,
    jlong chapterIdLong,
    jbyteArray dataArray,
    jlong userIdLong,
    jstring imei)
{
  char *bookIdCStr; // x19
  char *chapterIdCStr; // x21
  const char *userIdCStr; // x23
  _OWORD *secBuffer1; // x27
  _OWORD *secBuffer2; // x28
  _OWORD *sha2KeyBufArea; // x0
  const char *sha2KeyCStr; // x25
  jclass clsAB_1; // x0
  void *clsAB_ref1; // x26
  const char *imeiCStr; // x24
  char *ptrAfterFirstConcat; // x24
  char *ptrAfterChapterConcat; // x0
  struct _jmethodID *mid_s; // x24
  jobject jSha1Key1; // x26
  const char *sha1Key1CStr; // x24
  jstring jSec2Str; // x24
  struct _jmethodID *mid_m; // x26
  jstring jUserIdJStr; // x0
  void *clsAB_ref2; // x1
  jobject jSha1Key2; // x22
  const char *sha1Key2CStr; // x24
  jstring jSha2KeyJStr; // x26
  struct _jmethodID *mid_d; // x22
  jobject intermediateData1; // x27
  jobject finalData; // x22
  jstring jSec1Str; // [xsp+0h] [xbp-30h]
  jstring jSha1Key1FinalStr; // [xsp+0h] [xbp-30h]
  const char *imeiCStrRef; // [xsp+8h] [xbp-28h]
  jclass clsAB_3; // [xsp+10h] [xbp-20h]
  jclass clsAB_2; // [xsp+18h] [xbp-18h]
  void *jUserIdJStrRef; // [xsp+18h] [xbp-18h]

  bookIdCStr = (char *)malloc(0x40u);
  *(_OWORD *)bookIdCStr = 0u;
  *((_OWORD *)bookIdCStr + 1) = 0u;
  *((_OWORD *)bookIdCStr + 2) = 0u;
  *((_OWORD *)bookIdCStr + 3) = 0u;
  sub_1230((__int64)bookIdCStr, 64LL, (__int64)"%lld", bookIdLong);

  chapterIdCStr = (char *)malloc(0x40u);
  *(_OWORD *)chapterIdCStr = 0u;
  *((_OWORD *)chapterIdCStr + 1) = 0u;
  *((_OWORD *)chapterIdCStr + 2) = 0u;
  *((_OWORD *)chapterIdCStr + 3) = 0u;
  sub_1230((__int64)chapterIdCStr, 64LL, (__int64)"%lld", chapterIdLong);

  userIdCStr = (const char *)malloc(0x40u);
  *(_OWORD *)userIdCStr = 0u;
  *((_OWORD *)userIdCStr + 1) = 0u;
  *((_OWORD *)userIdCStr + 2) = 0u;
  *((_OWORD *)userIdCStr + 3) = 0u;
  sub_1230((__int64)userIdCStr, 64LL, (__int64)"%lld", userIdLong);

  secBuffer1 = malloc(0xFFu);
  *secBuffer1 = 0u;
  secBuffer1[1] = 0u;
  secBuffer1[2] = 0u;
  secBuffer1[3] = 0u;
  secBuffer1[4] = 0u;
  secBuffer1[5] = 0u;
  secBuffer1[6] = 0u;
  secBuffer1[7] = 0u;
  secBuffer1[8] = 0u;
  secBuffer1[9] = 0u;
  secBuffer1[10] = 0u;
  secBuffer1[11] = 0u;
  secBuffer1[12] = 0u;
  secBuffer1[13] = 0u;
  secBuffer1[14] = 0u;
  *(_OWORD *)((char *)secBuffer1 + 239) = 0u;

  secBuffer2 = malloc(0xFFu);
  *secBuffer2 = 0u;
  secBuffer2[1] = 0u;
  secBuffer2[2] = 0u;
  secBuffer2[3] = 0u;
  secBuffer2[4] = 0u;
  secBuffer2[5] = 0u;
  secBuffer2[6] = 0u;
  secBuffer2[7] = 0u;
  secBuffer2[8] = 0u;
  secBuffer2[9] = 0u;
  secBuffer2[10] = 0u;
  secBuffer2[11] = 0u;
  secBuffer2[12] = 0u;
  secBuffer2[13] = 0u;
  secBuffer2[14] = 0u;
  *(_OWORD *)((char *)secBuffer2 + 239) = 0u;

  sha2KeyBufArea = malloc(0xFFu);
  *sha2KeyBufArea = 0u;
  sha2KeyBufArea[1] = 0u;
  sha2KeyBufArea[2] = 0u;
  sha2KeyBufArea[3] = 0u;
  sha2KeyBufArea[4] = 0u;
  sha2KeyBufArea[5] = 0u;
  sha2KeyBufArea[6] = 0u;
  sha2KeyBufArea[7] = 0u;
  sha2KeyBufArea[8] = 0u;
  sha2KeyBufArea[9] = 0u;
  sha2KeyBufArea[10] = 0u;
  sha2KeyBufArea[11] = 0u;
  sha2KeyBufArea[12] = 0u;
  sha2KeyBufArea[13] = 0u;
  sha2KeyBufArea[14] = 0u;
  *(_OWORD *)((char *)sha2KeyBufArea + 239) = 0u;

  sha2KeyCStr = (const char *)sha2KeyBufArea;

  clsAB_1 = (*env)->FindClass(env, "a/b");
  if ( !clsAB_1 )
    return 0LL;
  clsAB_ref1 = clsAB_1;

  clsAB_2 = (*env)->FindClass(env, "a/b");
  if ( !clsAB_2 )
    return 0LL;

  clsAB_3 = (*env)->FindClass(env, "a/b");
  if ( !clsAB_3 )
    return 0LL;

  __android_log_print(3, "QDReader_Jni", "JNI:0");
  imeiCStr = (*env)->GetStringUTFChars(env, imei, 0LL);
  __android_log_print(
    3,
    "QDReader_Jni",
    "bookid: %s,chapterid: %s,userid: %s,imei: %s",
    bookIdCStr,
    chapterIdCStr,
    userIdCStr,
    imeiCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:1");

  __strcpy_chk(secBuffer1, userIdCStr, 255LL);
  imeiCStrRef = imeiCStr;
  ptrAfterFirstConcat = (char *)__strcat_chk(secBuffer1, imeiCStr, 255LL);
  ptrAfterChapterConcat = strcat(ptrAfterFirstConcat, chapterIdCStr);
  strcpy(&ptrAfterFirstConcat[strlen(ptrAfterChapterConcat)], "2EEE1433A152E84B3756301D8FA3E69A");
  __android_log_print(3, "QDReader_Jni", "JNI:2");

  jSec1Str = (*env)->NewStringUTF(env, secBuffer1);
  __android_log_print(3, "QDReader_Jni", "JNI:3");

  mid_s = (*env)->GetStaticMethodID(env, clsAB_ref1, "s", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;");
  __android_log_print(3, "QDReader_Jni", "JNI:4");
  if ( !mid_s )
    return 0LL;
  __android_log_print(3, "QDReader_Jni", "sha1id:%d", mid_s);
  __android_log_print(3, "QDReader_Jni", "JNI:5");

  jSha1Key1 = (*env)->CallStaticObjectMethod(env, clsAB_ref1, mid_s, imei, jSec1Str);
  (*env)->ReleaseStringUTFChars(env, jSec1Str, (const char *)secBuffer1);
  __android_log_print(3, "QDReader_Jni", "JNI:6");

  sha1Key1CStr = (*env)->GetStringUTFChars(env, jSha1Key1, 0LL);
  __android_log_print(3, "QDReader_Jni", "sha1key1 = %s", sha1Key1CStr);
  __android_log_print(3, "QDReader_Jni", "JNI:7");

  if ( strlen(sha1Key1CStr) >= 0x18uLL )
  {
    memset(gShaKeyBuf, 0, sizeof(gShaKeyBuf));
    strncpy(gShaKeyBuf, sha1Key1CStr, 0x18u);
  }
  __android_log_print(3, "QDReader_Jni", "JNI:8 sha1key2:%s sha1key1:%s", gShaKeyBuf, sha1Key1CStr);
  (*env)->ReleaseStringUTFChars(env, jSha1Key1, sha1Key1CStr);

  jSha1Key1FinalStr = (*env)->NewStringUTF(env, gShaKeyBuf);
  __android_log_print(3, "QDReader_Jni", "JNI:9");

  __strcpy_chk(secBuffer2, gShaKeyBuf, 255LL);
  __strcat_chk(secBuffer2, imeiCStrRef, 255LL);
  __android_log_print(3, "QDReader_Jni", "JNI:10");

  jSec2Str = (*env)->NewStringUTF(env, secBuffer2);
  __android_log_print(3, "QDReader_Jni", "JNI:11");

  mid_m = (*env)->GetStaticMethodID(env, clsAB_2, "m", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;");
  __android_log_print(3, "QDReader_Jni", "JNI:12");
  if ( !mid_m )
    return 0LL;
  __android_log_print(3, "QDReader_Jni", "JNI:13");

  jUserIdJStr = (*env)->NewStringUTF(env, userIdCStr);
  clsAB_ref2 = clsAB_2;
  jUserIdJStrRef = jUserIdJStr;
  jSha1Key2 = (*env)->CallStaticObjectMethod(env, clsAB_ref2, mid_m);
  (*env)->ReleaseStringUTFChars(env, jSec2Str, (const char *)secBuffer2);
  __android_log_print(3, "QDReader_Jni", "JNI:14");

  sha1Key2CStr = (*env)->GetStringUTFChars(env, jSha1Key2, 0LL);
  __android_log_print(3, "QDReader_Jni", "JNI:15");

  if ( strlen(sha1Key2CStr) < 0x19uLL )
  {
    __strcpy_chk(sha2KeyCStr, sha1Key2CStr, 255LL);
  }
  else
  {
    sha2KeyCStr = gShaKeyBuf;
    memset(gShaKeyBuf, 0, sizeof(gShaKeyBuf));
    strncpy(gShaKeyBuf, sha1Key2CStr, 0x18u);
  }
  (*env)->ReleaseStringUTFChars(env, jSha1Key2, sha1Key2CStr);
  __android_log_print(3, "QDReader_Jni", "JNI:16");

  jSha2KeyJStr = (*env)->NewStringUTF(env, sha2KeyCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:17");

  mid_d = (*env)->GetStaticMethodID(env, clsAB_3, "d", "([BLjava/lang/String;)[B");
  __android_log_print(3, "QDReader_Jni", "JNI:18");
  if ( !mid_d )
    return 0LL;
  __android_log_print(3, "QDReader_Jni", "JNI:19");

  intermediateData1 = (*env)->CallStaticObjectMethod(env, clsAB_3, mid_d, dataArray, jSha2KeyJStr);
  __android_log_print(3, "QDReader_Jni", "JNI:20");

  finalData = (*env)->CallStaticObjectMethod(env, clsAB_3, mid_d, intermediateData1, jSha1Key1FinalStr);
  __android_log_print(3, "QDReader_Jni", "JNI:21");

  (*env)->ReleaseStringUTFChars(env, imei, imeiCStrRef);
  __android_log_print(3, "QDReader_Jni", "JNI:22 %s", gShaKeyBuf);
  __android_log_print(3, "QDReader_Jni", "JNI:23");

  (*env)->ReleaseStringUTFChars(env, jSha2KeyJStr, sha2KeyCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:24");

  (*env)->ReleaseStringUTFChars(env, jUserIdJStrRef, userIdCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:25");

  free(bookIdCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:26");

  free(chapterIdCStr);
  __android_log_print(3, "QDReader_Jni", "JNI:27");

  return finalData;
}

package a:

package a;

import android.content.Context;
import android.util.Base64;
import bf.cihai;
import com.qidian.QDReader.autotracker.bean.AutoTrackerItem;
import com.qidian.common.lib.Logger;
import com.qidian.common.lib.util.q0;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class b {
    private static final String CHARSET_ASCII = "ascii";
    private static final String MAC_SHA1_NAME = "HmacSHA1";

    static {
        try {
            System.loadLibrary("load-jni");
        } catch (Exception e10) {
            e10.printStackTrace();
        } catch (UnsatisfiedLinkError e11) {
            e11.printStackTrace();
        }
    }

    public static native byte[] b(long j10, long j11, byte[] bArr, long j12, String str);

    public static native void c(Context context);

    public static byte[] d(byte[] bArr, String str) {
        try {
            return cihai.search(bArr, str);
        } catch (Exception e10) {
            Logger.exception(e10);
            return null;
        }
    }

    private static String encryptToSHA1(String str, String str2) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec(str.getBytes(CHARSET_ASCII), MAC_SHA1_NAME);
        Mac mac = Mac.getInstance(MAC_SHA1_NAME);
        mac.init(secretKeySpec);
        return new String(Base64.encode(mac.doFinal(str2.getBytes(CHARSET_ASCII)), 0));
    }

    public static String m(String str, String str2) {
        String str3;
        try {
            str3 = bf.a.judian(str, str2);
        } catch (NoSuchAlgorithmException e10) {
            e = e10;
            str3 = null;
        }
        try {
            if (str3.length() != 24) {
                d5.cihai.p(new AutoTrackerItem.Builder().setPn("OKR_b_m").setEx1(String.valueOf(str3.length())).setEx2(str + "/" + str2 + "/" + str3).buildCol());
            }
        } catch (NoSuchAlgorithmException e11) {
            e = e11;
            Logger.exception(e);
            return str3;
        }
        return str3;
    }

    public static String s(String str, String str2) {
        String str3;
        try {
            str3 = encryptToSHA1(str, str2);
            try {
                if (str3.length() >= 24) {
                    return str3;
                }
                d5.cihai.p(new AutoTrackerItem.Builder().setPn("OKR_b_s").setEx1(String.valueOf(str3.length())).setEx2(str + "/" + str2 + "/" + str3).buildCol());
                return q0.m(str3, 24, (char) 0);
            } catch (Exception e10) {
                e = e10;
                Logger.exception(e);
                return str3;
            }
        } catch (Exception e11) {
            e = e11;
            str3 = null;
        }
    }
}

q0.m:

public static String m(String str, int i10, char c10) {
    StringBuilder sb = new StringBuilder();
    sb.append(str);
    int length = i10 - str.length();
    for (int i11 = 0; i11 < length; i11++) {
        sb.append(c10);
    }
    return sb.toString();
}

package bf:

package bf;

import com.qidian.QDReader.qmethod.pandoraex.monitor.c;
import com.qidian.common.lib.Logger;
import java.nio.charset.StandardCharsets;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

public class cihai {

    /* renamed from: search, reason: collision with root package name */
    private static final String f1806search = "bf.cihai";

    public static String cihai(String str, String str2) throws Exception {
        IvParameterSpec ivParameterSpec = new IvParameterSpec(new byte[8]);
        byte[] bytes = str2.getBytes("UTF-8");
        if (bytes.length == 16) {
            byte[] bArr = new byte[24];
            System.arraycopy(bytes, 0, bArr, 0, 16);
            System.arraycopy(bytes, 0, bArr, 16, 8);
            bytes = bArr;
        }
        SecretKeySpec secretKeySpec = new SecretKeySpec(bytes, "DESede");
        Cipher cipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
        if (cipher == null) {
            return "";
        }
        cipher.init(1, secretKeySpec, ivParameterSpec);
        return search.judian(c.search(cipher, str.getBytes()));
    }

    public static String judian(byte[] bArr, String str) throws Exception {
        IvParameterSpec ivParameterSpec = new IvParameterSpec("01234567".getBytes(StandardCharsets.UTF_8));
        byte[] bytes = str.getBytes("UTF-8");
        if (bytes.length == 16) {
            byte[] bArr2 = new byte[24];
            System.arraycopy(bytes, 0, bArr2, 0, 16);
            System.arraycopy(bytes, 0, bArr2, 16, 8);
            bytes = bArr2;
        }
        SecretKeySpec secretKeySpec = new SecretKeySpec(bytes, "DESede");
        Cipher cipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
        if (cipher == null) {
            return "";
        }
        cipher.init(1, secretKeySpec, ivParameterSpec);
        return search.judian(c.search(cipher, bArr));
    }

    public static byte[] search(byte[] bArr, String str) throws Exception {
        if (bArr != null && str != null) {
            IvParameterSpec ivParameterSpec = new IvParameterSpec(new byte[8]);
            byte[] bytes = str.getBytes("UTF-8");
            if (bytes != null && bytes.length >= 1) {
                if (bytes.length == 16) {
                    byte[] bArr2 = new byte[24];
                    System.arraycopy(bytes, 0, bArr2, 0, 16);
                    System.arraycopy(bytes, 0, bArr2, 16, 8);
                    bytes = bArr2;
                }
                Cipher cipher = Cipher.getInstance("DESede/CBC/PKCS5Padding");
                if (cipher == null) {
                    Logger.e(f1806search, "cipher is null");
                    return null;
                }
                cipher.init(2, new SecretKeySpec(bytes, "DESede"), ivParameterSpec);
                try {
                    return c.search(cipher, bArr);
                } catch (Exception e10) {
                    e10.printStackTrace();
                    int length = bArr.length;
                    Logger.e(f1806search, "decryptDES失败:" + str + "," + length);
                    return null;
                }
            }
            Logger.e(f1806search, "keyBytes is illegal");
        }
        return null;
    }
}
package bf;

import com.tencent.qcloud.core.util.IOUtils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class a {

    /* renamed from: search, reason: collision with root package name */
    private static char[] f1805search = {'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', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', IOUtils.DIR_SEPARATOR_UNIX};

    public static String a(String str) throws Exception {
        byte[] digest = MessageDigest.getInstance("MD5").digest(str.getBytes("UTF-8"));
        StringBuilder sb = new StringBuilder(digest.length * 2);
        for (byte b10 : digest) {
            int i10 = b10 & 255;
            if (i10 < 16) {
                sb.append("0");
            }
            sb.append(Integer.toHexString(i10));
        }
        return sb.toString();
    }

    private static byte[] b(byte[] bArr) throws NoSuchAlgorithmException {
        MessageDigest messageDigest = MessageDigest.getInstance("MD5");
        messageDigest.update(bArr);
        return messageDigest.digest();
    }

    private static byte[] cihai(byte[] bArr, byte[] bArr2) throws NoSuchAlgorithmException {
        byte[] bArr3 = new byte[64];
        byte[] bArr4 = new byte[64];
        for (int i10 = 0; i10 < 64; i10++) {
            bArr3[i10] = 54;
            bArr4[i10] = 92;
        }
        byte[] bArr5 = new byte[64];
        if (bArr.length > 64) {
            bArr = b(bArr);
        }
        for (int i11 = 0; i11 < bArr.length; i11++) {
            bArr5[i11] = bArr[i11];
        }
        if (bArr.length < 64) {
            for (int length = bArr.length; length < 64; length++) {
                bArr5[length] = 0;
            }
        }
        byte[] bArr6 = new byte[64];
        for (int i12 = 0; i12 < 64; i12++) {
            bArr6[i12] = (byte) (bArr5[i12] ^ bArr3[i12]);
        }
        byte[] bArr7 = new byte[bArr2.length + 64];
        for (int i13 = 0; i13 < 64; i13++) {
            bArr7[i13] = bArr6[i13];
        }
        for (int i14 = 0; i14 < bArr2.length; i14++) {
            bArr7[i14 + 64] = bArr2[i14];
        }
        byte[] b10 = b(bArr7);
        byte[] bArr8 = new byte[64];
        for (int i15 = 0; i15 < 64; i15++) {
            bArr8[i15] = (byte) (bArr5[i15] ^ bArr4[i15]);
        }
        byte[] bArr9 = new byte[b10.length + 64];
        for (int i16 = 0; i16 < 64; i16++) {
            bArr9[i16] = bArr8[i16];
        }
        for (int i17 = 0; i17 < b10.length; i17++) {
            bArr9[i17 + 64] = b10[i17];
        }
        return b(bArr9);
    }

    public static String judian(String str, String str2) throws NoSuchAlgorithmException {
        return search(cihai(str.getBytes(), str2.getBytes()));
    }

    public static String search(byte[] bArr) {
        StringBuffer stringBuffer = new StringBuffer();
        int length = bArr.length;
        int i10 = 0;
        while (true) {
            if (i10 >= length) {
                break;
            }
            int i11 = i10 + 1;
            int i12 = bArr[i10] & 255;
            if (i11 == length) {
                stringBuffer.append(f1805search[i12 >>> 2]);
                stringBuffer.append(f1805search[(i12 & 3) << 4]);
                stringBuffer.append("==");
                break;
            }
            int i13 = i11 + 1;
            int i14 = bArr[i11] & 255;
            if (i13 == length) {
                stringBuffer.append(f1805search[i12 >>> 2]);
                stringBuffer.append(f1805search[((i12 & 3) << 4) | ((i14 & 240) >>> 4)]);
                stringBuffer.append(f1805search[(i14 & 15) << 2]);
                stringBuffer.append("=");
                break;
            }
            int i15 = i13 + 1;
            int i16 = bArr[i13] & 255;
            stringBuffer.append(f1805search[i12 >>> 2]);
            stringBuffer.append(f1805search[((i12 & 3) << 4) | ((i14 & 240) >>> 4)]);
            stringBuffer.append(f1805search[((i14 & 15) << 2) | ((i16 & 192) >>> 6)]);
            stringBuffer.append(f1805search[i16 & 63]);
            i10 = i15;
        }
        return stringBuffer.toString();
    }
}

package com.qidian.QDReader.qmethod.pandoraex.monitor:

package com.qidian.QDReader.qmethod.pandoraex.monitor;

import j$.util.concurrent.ConcurrentHashMap;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;

public class c {

    /* renamed from: search, reason: collision with root package name */
    private static ConcurrentHashMap<String, byte[]> f26010search = new ConcurrentHashMap<>();

    public static void a(byte[] bArr, byte[] bArr2) {
        f26010search.put(judian(bArr), bArr2);
    }

    public static byte[] cihai(byte[] bArr) {
        byte[] bArr2 = null;
        while (bArr != null) {
            bArr = f26010search.get(judian(bArr));
            if (bArr != null) {
                bArr2 = bArr;
            }
        }
        return bArr2;
    }

    public static String judian(byte[] bArr) {
        try {
            byte[] digest = MessageDigest.getInstance("MD5").digest(bArr);
            StringBuilder sb = new StringBuilder();
            for (byte b10 : digest) {
                sb.append(String.format("%02x", Byte.valueOf(b10)));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e10) {
            throw new RuntimeException("MD5 algorithm is not available", e10);
        }
    }

    public static byte[] search(Cipher cipher, byte[] bArr) throws IllegalBlockSizeException, BadPaddingException {
        byte[] doFinal = cipher.doFinal(bArr);
        if (com.qidian.QDReader.qmethod.pandoraex.core.ext.netcap.g.a()) {
            a(doFinal, bArr);
        }
        return doFinal;
    }
}

在函数中意外发现该方法内部大量调用了 __android_log_print 打印中间变量 (如解密参数 / 密钥等)。

这使得不需要额外 Hook 函数, 也可以直接通过 logcat 查看解密过程中涉及的关键信息。

于是回头检查之前导出的 logs.txt, 使用关键词快速定位到相关日志内容:

06-01 12:00:00.000  1234  4321 D QDReader_Jni: bookid: ****,chapterid: ***,userid: **,imei: ****
...
06-01 12:00:00.000  1234  4321 D QDReader_Jni: sha1id:****
...
06-01 12:00:00.000  1234  4321 D QDReader_Jni: sha1key1 = ****base64-key****
...
06-01 12:00:00.000  1234  4321 D QDReader_Jni: JNI:8 sha1key2:****key2**** sha1key1:****key1****
...
06-01 12:00:00.000  1234  4321 D QDReader_Jni: JNI:22 ****

结合日志输出与 Ghidra 中对 Java_a_b_b 的分析. 可以还原出等价的 Python 解密逻辑如下:

import hashlib
import hmac
from base64 import b64encode
from Crypto.Cipher import DES3
from Crypto.Util.Padding import unpad

_MAGIC = "2EEE1433A152E84B3756301D8FA3E69A"

def _pad_to_24(s: str) -> str:
    """
    Ensure the given string is exactly 24 characters long.

    :param s: Input string.
    :return: 24-character string.
    """
    if len(s) >= 24:
        return s[:24]
    return s + ("\x00" * (24 - len(s)))

def _hmac_sha1(key: str, data: str) -> str:
    """
    Compute HMAC-SHA1 over `data` using `key`, Base64-encode the result,
    then truncate/pad to 24 characters.

    :param key: ASCII key string.
    :param data: ASCII data string.
    :return: 24-character Base64 HMAC-SHA1 string.
    """
    digest = hmac.new(key.encode(), data.encode(), hashlib.sha1).digest()
    b64 = b64encode(digest).decode()
    return _pad_to_24(b64)

def _hmac_md5(key: str, data: str) -> str:
    """
    Compute HMAC-MD5 over `data` using `key`, Base64-encode the result,
    then truncate/pad to 24 characters.

    :param key: UTF-8 key string.
    :param data: UTF-8 data string.
    :return: 24-character Base64 HMAC-MD5 string.
    """
    digest = hmac.new(key.encode(), data.encode(), hashlib.md5).digest()
    b64 = b64encode(digest).decode()
    return _pad_to_24(b64)

def _des3_decrypt(data: bytes, secret: str) -> bytes:
    """
    Decrypt `data` using 3DES (DES-EDE/CBC/PKCS5Padding) with a zero IV.
    If `secret` is 16 bytes long, its first 8 bytes are appended to make 24.

    :param data: Ciphertext bytes.
    :param secret: Key string (will be .encode('utf-8')).
    :return: Plaintext bytes (PKCS#5 padding removed).
    """
    key_bytes = secret.encode()
    if len(key_bytes) == 16:
        key_bytes += key_bytes[:8]
    cipher = DES3.new(key_bytes, DES3.MODE_CBC, iv=b'\x00' * 8)
    decrypted = cipher.decrypt(data)
    return unpad(decrypted, block_size=8)

def decrypt_content(
    cid: str,
    data: bytes,
    uid: str,
    imei: str,
) -> str:
    """
    Perform the two-stage DES3 decryption matching the native `b(...)`:

      1. sec1 = uid + imei + cid + _MAGIC
      2. sha1_key1 = HMAC-SHA1_Base64(imei, sec1) -> 24 chars
      3. sec2 = sha1_key1 + imei
      4. sha1_key2 = HMAC-MD5_Base64(uid, sec2) -> 24 chars
      5. step1 = DES3_CBC_DECRYPT(data, key=sha1_key2)
      6. step2 = DES3_CBC_DECRYPT(step1,   key=sha1_key1)
      7. UTF-8 decode step2 (ignore errors)

    :param cid: Chapter ID string.
    :param data: Encrypted bytes to decrypt.
    :param uid: User ID string.
    :param imei: Device IMEI string.
    :return: Decrypted plaintext as a UTF-8 string.
    """
    # 1) build the two "sec" buffers and derive two keys
    sec1 = uid + imei + cid + _MAGIC
    sha1_key1 = _hmac_sha1(imei, sec1)
    sec2 = sha1_key1 + imei
    sha1_key2 = _hmac_md5(uid, sec2)

    # 2) two-pass 3DES decryption
    step1 = _des3_decrypt(data, sha1_key2)
    step2 = _des3_decrypt(step1, sha1_key1)

    return step2.decode("utf-8", errors="ignore")  # or "replace"
3.5.3 fockUtil.unlock 函数

在分析 unlock 函数时, 发现这又是一个 native 函数:

`com.yuewen.fock` (点击展开)
package com.yuewen.fock;

public class Fock {
    public static class FockResult {
        public static int STATUS_BAD_DATA = -1;
        public static int STATUS_EMPTY_USER_KEY = -3;
        public static int STATUS_MISSING_KEY_POOL = -2;
        public static int STATUS_SUCCESS;
        public final byte[] data;
        public final int dataSize;
        public final int status;

        public FockResult(int i10, byte[] bArr, int i11) {
            this.status = i10;
            this.data = bArr;
            this.dataSize = i11;
        }
    }

    static {
        System.loadLibrary("fock");
        ignoreBlockPattern = Pattern.compile(ignoreBlockPatternString);
    }

    public static void addKeys(String str, String str2) {
        byte[] bArrDecode = Base64.decode(str, 0);
        ReentrantLock reentrantLock = lock;
        reentrantLock.lock();
        try {
            ak(bArrDecode, bArrDecode.length, str2.getBytes());
            reentrantLock.unlock();
        } catch (Throwable th2) {
            lock.unlock();
            throw th2;
        }
    }
    private static native void ak(byte[] bArr, int i10, byte[] bArr2);

    public static void setup(String str) {
        ReentrantLock reentrantLock = lock;
        reentrantLock.lock();
        try {
            it(str.getBytes(), str.length());
            reentrantLock.unlock();
        } catch (Throwable th2) {
            lock.unlock();
            throw th2;
        }
    }
    private static native int it(byte[] bArr, int i10);

    public static FockResult unlock(String str, String str2, String str3) {
        byte[] bArrDecode = Base64.decode(str, 0);
        ReentrantLock reentrantLock = lock;
        reentrantLock.lock();
        try {
            FockResult fockResultUksf = uksf(bArrDecode, bArrDecode.length, str2.getBytes(), str2.length(), str3.getBytes());
            reentrantLock.unlock();
            return fockResultUksf;
        } catch (Throwable th2) {
            lock.unlock();
            throw th2;
        }
    }
    private static native FockResult uksf(byte[] bArr, int i10, byte[] bArr2, int i11, byte[] bArr3);
}

方案 1. 使用 Frida 对相关方法进行 Hook

说明: 此处演示的 Hook 方法为了方便展示, 假设目标类已在运行时加载完毕。

如果实际使用, 请确保 App 已进入能触发 Fock 等类初始化的页面, 例如打开任意 VIP 章节, 以确保目标类和方法 (如 Fock 等) 已加载并初始化, 否则 Hook 将无法生效。

`hook_fock.js` (点击展开)
rpc.exports = {
    unlock: function (arg1, arg2, arg3) {
        return new Promise(function (resolve, reject) {
            Java.perform(function () {
                try {
                    var TargetClass = Java.use('com.yuewen.fock.Fock');
                    var StringClass = Java.use('java.lang.String');
                    var CharsetClass = Java.use('java.nio.charset.Charset');

                    var result = TargetClass.unlock(arg1, arg2, arg3);
                    var status = result.status.value;

                    var utf8Charset = CharsetClass.forName("UTF-8");
                    var javaStr = StringClass.$new(result.data.value, utf8Charset);
                    var contentStr = javaStr.toString();

                    resolve({
                        status: status,
                        content: contentStr
                    });
                } catch (e) {
                    resolve({
                        status: -999,
                        error: e.toString()
                    });
                }
            });
        });
    }
};

至此即可在 Python 端通过 Frida RPC 同步调用 unlock 方法, 示例如下

import frida

def test():
    book_id = "1111111111"
    chap_id = "2222222222"
    content = "xxx..." # 需解密的加密内容

    device = frida.get_device("your.device.ip.address")
    # 也可使用 frida.get_usb_device()
    session = device.attach("起点读书")

    with open("hook_fock.js", "r", encoding="utf-8") as f:
        jscode = f.read()

    script = session.create_script(jscode)
    script.load()

    print("[*] Script loaded. Starting unlock tasks...")
    key_1 = chap_id
    key_2 = f"{book_id}_{chap_id}"
    # 同步调用 Frida RPC
    raw = script.exports_sync.unlock(content, key_1, key_2)
    print(raw)

if __name__ == "__main__":
    test()

方案 2. 逆向并实现

TODO: 暂未完成