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

这次分析主要解决两个问题:
- APP 如何判断当前用户是否为会员。
- 页面中的会员截止时间是如何展示的。
本文档为私有实验笔记,保留完整的定位过程、验证过程和可复现代码。
分析目标
这次不直接从修改点入手,而是先把数据链路理清楚。目标拆分如下:
- 找到会员状态的来源。
- 找到会员状态的本地存储位置。
- 找到页面展示会员时间的逻辑。
- 通过 Hook 验证关键推断,并完成修改。
初步排查
样本做了 360 加固,因此第一步还是先脱壳,再做字符串和资源层搜索。
先搜索"终身会员":

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

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

虽然只找到两处相关代码,但分析后发现这些位置主要负责页面展示,不涉及会员状态本身。这一步的价值在于排除了"直接从页面控件入手"的路线。
会员状态定位
既然 UI 文案路线无法直接命中关键逻辑,就转而搜索业务语义,例如 isVip、getVip。
很快找到了更像核心判定点的位置:


继续查看调用关系:


可以确认,会员状态最终来自:
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
动态验证
静态分析只能证明"看起来像",要确认推断成立,还是需要在关键位置下日志。
这里我选择的观察点有四个:
BaseConfigModel.isVip()k0.a(...)MobileXUser.vip()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 继续查找:

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


从代码逻辑看,页面展示过程大致如下:
- 读取
userBean.getMemberExpireDay() - 按
-拆分成年、月、日 - 如果年份大于等于阈值,则显示"您的会员截止时间:永久"
- 否则拼接正常日期格式
这说明"永久会员"并不是由单独的 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()
-> 日期解析
-> 年份阈值判断
-> 显示"永久"或具体时间
因此在这个样本里:
- "是否为会员"不是最终展示的全部条件。
- "会员截止时间"单独依赖一条日期链路。
- 只改 VIP 布尔值并不能完全解决界面展示问题。
关键记录
这次分析中最值得保留的几个稳定观察点如下:
- 服务端字段
mem - 本地缓存 key
ur_ax_re_v_ic_ac - 统一读取入口
MobileXUser.vip() - 时间字段
getMemberExpireDay()
如果后续要继续做跨版本适配,更好的方向不是继续跟某个固定方法名,而是把这些稳定特征抽出来,再结合 DexKit 做动态定位。
备注
"本项目为原创技术演示,源码托管于GitHub:链接,仅用于学习交流"
【免责声明】
本帖内容仅供学习交流,如涉及侵权或违反论坛规则,
请权利人或管理员联系本人(站内私信),核实后将立即删除。