背书匠APP逆向

背景

背书匠此前可以直接使用部分功能,后续版本中部分入口会跳转到会员页,因此这里记录一次完整的会员链路分析过程。

这次分析主要解决两个问题:

  1. APP 如何判断当前用户是否为会员。
  2. 页面中的会员截止时间是如何展示的。

本文档为私有实验笔记,保留完整的定位过程、验证过程和可复现代码。

分析目标

这次不直接从修改点入手,而是先把数据链路理清楚。目标拆分如下:

  1. 找到会员状态的来源。
  2. 找到会员状态的本地存储位置。
  3. 找到页面展示会员时间的逻辑。
  4. 通过 Hook 验证关键推断,并完成修改。

初步排查

样本做了 360 加固,因此第一步还是先脱壳,再做字符串和资源层搜索。

先搜索"终身会员":

在脱壳后的代码中没有直接找到关键逻辑,再继续在资源文件中搜索,也没有立刻定位到核心业务代码。

继续全局检索后,在 XML 中找到相关文本:

再围绕控件 ID tv_goods_name 继续追踪引用:

虽然只找到两处相关代码,但分析后发现这些位置主要负责页面展示,不涉及会员状态本身。这一步的价值在于排除了"直接从页面控件入手"的路线。

会员状态定位

既然 UI 文案路线无法直接命中关键逻辑,就转而搜索业务语义,例如 isVipgetVip

很快找到了更像核心判定点的位置:

继续查看调用关系:

可以确认,会员状态最终来自:

java 复制代码
resultBean.getData().isVip()

随后该值会被写入本地键值存储:

java 复制代码
k0.a("ur_ax_re_v_ic_ac", Boolean.valueOf(resultBean.getData().isVip()));

再回看模型定义,isVip() 对应字段 vip,而这个字段映射自服务端字段 mem

因此这里可以先得到一个明确结论:

java 复制代码
服务端字段 mem
-> 模型字段 vip
-> isVip()
-> 写入本地缓存

会员状态存储链路

既然已经拿到了关键 key,就继续围绕这个 key 追踪完整的读写流程。

搜索:

java 复制代码
"ur_ax_re_v_ic_ac"

可以看到它在多个位置被引用:

整理后,会员状态链路如下。

服务端返回

json 复制代码
{
  "mem": true/false
}

模型映射

java 复制代码
@SerializedName("mem")
private boolean vip;

业务取值

java 复制代码
resultBean.getData().isVip()

写入本地

java 复制代码
k0.a("ur_ax_re_v_ic_ac", ...)

业务统一读取入口

java 复制代码
MobileXUser.vip()

底层读取

java 复制代码
d1.b("ur_ax_re_v_ic_ac", false)

继续分析 d1 后,可以确认底层存储实现是 MMKV:

因此,会员状态本质上就是一个布尔值缓存,对应的关键 key 为:

java 复制代码
"ur_ax_re_v_ic_ac"

把整个流程展开后,可以写成两条链。

写入链

java 复制代码
resultBean.getData().isVip()
-> Boolean.valueOf(...)
-> k0.a("ur_ax_re_v_ic_ac", ...)
-> d1.f("ur_ax_re_v_ic_ac", ...)
-> MMKV.encode("ur_ax_re_v_ic_ac", true/false)

读取链

java 复制代码
MobileXUser.vip()
-> d1.b("ur_ax_re_v_ic_ac", false)
-> MMKV.decodeBool("ur_ax_re_v_ic_ac", false)
-> true/false

动态验证

静态分析只能证明"看起来像",要确认推断成立,还是需要在关键位置下日志。

这里我选择的观察点有四个:

  1. BaseConfigModel.isVip()
  2. k0.a(...)
  3. MobileXUser.vip()
  4. d1.b(...)

验证结果如下:

从日志可以确认,会员状态确实按照"模型取值 -> 写入缓存 -> 业务读取"的路径流转,而不是只在某个页面里做了一次临时判断。

会员状态 Hook 记录

下面这段代码用于完整观察写入链和读取链,主要用于确认定位是否正确。

java 复制代码
// ================= 写入链 =================
private static void WriteHook(ClassLoader classLoader) {
    // 1. 服务端模型 -> isVip()
    XposedHelpers.findAndHookMethod(
            "com.jpm.comx.bean.BaseConfigModel",
            classLoader,
            "isVip",
            new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    boolean result = (boolean) param.getResult();
                    Log.d(TAG, "[isVip] 返回值: " + result);
                }
            }
    );

    // 2. 写入本地缓存 k0.a(key, value)
    XposedHelpers.findAndHookMethod(
            "b9.k0",
            classLoader,
            "a",
            "java.lang.String",
            "java.io.Serializable",
            new XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                    String key = (String) param.args[0];
                    Object value = param.args[1];
                    if ("ur_ax_re_v_ic_ac".equals(key)) {
                        Log.d(TAG, "[k0.a] 写入会员状态 -> key: " + key + " value: " + value);
                    }
                }
            }
    );
}

// ================= 读取链 =================
private static void ReadHook(ClassLoader classLoader) {
    // 3. 业务层读取 MobileXUser.vip()
    XposedHelpers.findAndHookMethod(
            "com.jpm.comx.module.MobileXUser",
            classLoader,
            "vip",
            new XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                    Log.d(TAG, "[MobileXUser.vip] 调用");
                }

                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    boolean result = (boolean) param.getResult();
                    Log.d(TAG, "[MobileXUser.vip] 返回值: " + result);
                }
            }
    );

    // 4. 底层读取 d1.b(key, default)
    XposedHelpers.findAndHookMethod(
            "b9.d1",
            classLoader,
            "b",
            "java.lang.String",
            boolean.class,
            new XC_MethodHook() {
                @Override
                protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                    String key = (String) param.args[0];
                    boolean def = (boolean) param.args[1];
                    if ("ur_ax_re_v_ic_ac".equals(key)) {
                        Log.d(TAG, "[d1.b] 读取会员状态 -> key: " + key + " default: " + def);
                    }
                }

                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    String key = (String) param.args[0];
                    if ("ur_ax_re_v_ic_ac".equals(key)) {
                        boolean result = (boolean) param.getResult();
                        Log.d(TAG, "[d1.b] 返回值: " + result);
                    }
                }
            }
    );
}

如果要直接从写入链下手,可以在 isVip() 返回后强制修改结果:

java 复制代码
XposedHelpers.findAndHookMethod(
        "com.jpm.comx.bean.BaseConfigModel",
        classLoader,
        "isVip",
        new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                boolean result = (boolean) param.getResult();
                Log.d(TAG, "[isVip] 返回值: " + result);
                param.setResult(true);
            }
        }
);

日志如下:

页面效果如下:

到这里,会员状态已经能够正确显示,但会员截止时间仍然不正常,因此还需要继续追踪第二条链路。

会员时间定位

既然会员状态已经打通,但时间展示依旧异常,就说明"是否为会员"和"会员时间如何展示"不是同一个判断点。

先搜索"截止时间":

再根据控件 ID tv_vip_time 继续查找:

这里依旧没有直接命中关键逻辑,于是改为搜索最终展示词"永久"。

从代码逻辑看,页面展示过程大致如下:

  1. 读取 userBean.getMemberExpireDay()
  2. - 拆分成年、月、日
  3. 如果年份大于等于阈值,则显示"您的会员截止时间:永久"
  4. 否则拼接正常日期格式

这说明"永久会员"并不是由单独的 VIP 布尔值决定,而是由到期时间字段的年份判断决定。

会员时间链路

继续分析用户模型后,可以确认这里的关键入口是:

java 复制代码
getMemberExpireDay()

因此第二条链路可以整理为:

java 复制代码
UserBean.getMemberExpireDay()
-> 视图层读取
-> 解析年份
-> 显示"永久"或具体日期

到期时间 Hook 记录

下面的代码用于验证 getMemberExpireDay() 是否就是页面展示的核心来源,同时直接修改返回值观察页面变化。

java 复制代码
private static void UserBeanHook(ClassLoader classLoader) {

    XposedHelpers.findAndHookMethod(
            "com.jpm.comx.login.model.UserBean",
            classLoader,
            "getMemberExpireDay",
            new XC_MethodHook() {

                @Override
                protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                    // getter 不需要 before
                }

                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    try {
                        Object result = param.getResult();

                        Log.d(TAG, "====================");
                        Log.d(TAG, "[UserBean.getMemberExpireDay]");
                        Log.d(TAG, "返回值: " + result);
                        if (param.thisObject != null) {
                            Log.d(TAG, "对象: " + param.thisObject.toString());
                        }
                        param.setResult("2099-12-31");
                        Log.d(TAG, "====================");

                    } catch (Throwable e) {
                        Log.e(TAG, "getMemberExpireDay hook error", e);
                    }
                }
            }
    );
}

日志结果:

页面效果:

到这里可以确认,会员时间展示的关键点确实是 getMemberExpireDay()

最终结论

这次样本实际上包含两条相对独立的链路。

1. 会员判定链

java 复制代码
服务端 mem
-> 模型 vip
-> isVip()
-> MMKV 缓存
-> MobileXUser.vip()

2. 会员时间展示链

java 复制代码
getMemberExpireDay()
-> 日期解析
-> 年份阈值判断
-> 显示"永久"或具体时间

因此在这个样本里:

  1. "是否为会员"不是最终展示的全部条件。
  2. "会员截止时间"单独依赖一条日期链路。
  3. 只改 VIP 布尔值并不能完全解决界面展示问题。

关键记录

这次分析中最值得保留的几个稳定观察点如下:

  1. 服务端字段 mem
  2. 本地缓存 key ur_ax_re_v_ic_ac
  3. 统一读取入口 MobileXUser.vip()
  4. 时间字段 getMemberExpireDay()

如果后续要继续做跨版本适配,更好的方向不是继续跟某个固定方法名,而是把这些稳定特征抽出来,再结合 DexKit 做动态定位。

备注

"本项目为原创技术演示,源码托管于GitHub:链接,仅用于学习交流"

【免责声明】

本帖内容仅供学习交流,如涉及侵权或违反论坛规则,

请权利人或管理员联系本人(站内私信),核实后将立即删除。