好友
阅读权限 10
听众
最后登录 1970-1-1
本帖最后由 Tartaglia 于 2026-6-18 18:52 编辑
工具准备
jadx— 反编译 APK,看 Java 源码
MT管理器, 改 smali + 回编译签名
codex或者其他ai(因为他的类里面有r8混淆)
如图,apk是没有加固的,直接使用jadx进行分析
如图,怎么去定位这个页面呢?可以使用mt管理器的activity记录功能去查看,最终定位到的类是top.niunaijun.blackboxa.view.main.WelcomeActivity,使用jadx对这个类进行分析他的逻辑
public final class WelcomeActivity extends v4 {
// ===== 卡密 / QQ 验证专用字段 =====
public na B; // QQ验证轮询定时器(na 是 Runnable,每1秒跑一次)
public String C = ""; // 卡密流程的 sessionId(服务端返回)
public String D = ""; // 暂存用户名,等 QQ 验证通过后继续登录
public String E = ""; // 暂存密码
public String F = ""; // QQ 绑定 token,展示给用户去 QQ 复制
// ===== 页面状态 =====
public lr0 z = lr0.a; // lr0.a=登录页, lr0.b=卡密注册页
public final Handler A = new Handler(Looper.getMainLooper()); // 主线程 Handler
// ===== SESSION_INVALID 广播带来的消息 =====
public String G = ""; // 来自 bc.d() 发送的广播中的 message
}
onCreate 里匿名类用的是 switch 分发
//按钮是两段式 — 第一次点击切页面,第二次点击才提交
// 登录按钮 (case 1)
if (welcomeActivity.z != lr0.a) {
// 当前在卡密页 → 先切回登录页
welcomeActivity.x(lr0.a);
} else {
// 已经在登录页 → 执行登录
welcomeActivity.B(false); // false = 登录模式
}
// 卡密按钮 (case 2)
if (welcomeActivity.z != lr0.a) {
// 当前在卡密页 → 提交卡密注册
welcomeActivity.B(true); // true = 卡密注册模式
} else {
// 当前在登录页 → 先切到卡密页
welcomeActivity.x(lr0.b);
}
主要看B这个方法,B只做本地校验,真正的认证在 kr0 线程里
public final void B(boolean z) {
// ① 读取三个输入框
String username = trim(getText(v().m)); // 账号框
String password = trim(getText(v().i)); // 密码框
String cardCode = trim(getText(v().d)); // 卡密框(仅在卡密模式下使用)
String error = null;
if (z) {
// ===== 卡密注册模式 =====
// Step 1: 校验账号格式 (64 = 账号长度限制)
error = fc.b(64, username, getString(R.string.xxx));
if (error == null) {
// Step 2: 检查用户名是否合法(调用 fc.c.b 正则校验)
if (!fc.c.b(username)) {
error = "registration-username-invalid";
}
}
if (error != null && error.equals("registration-username-invalid")) {
error = getString(R.string.bbox_username_invalid); // "用户名格式不正确"
}
// Step 3: 校验密码长度 (128 = 密码/卡密长度限制)
if (error == null) {
error = fc.b(128, password, getString(R.string.xxx));
}
// Step 4: 校验卡密长度
if (error == null) {
error = fc.b(128, cardCode, getString(R.string.xxx));
}
} else {
// ===== 普通登录模式:只校验账号 =====
error = fc.b(64, username, getString(R.string.xxx));
}
// ② 处理结果
if (error != null) {
v().l.setText(error); // 在提示区域显示错误
} else {
w(true); // 显示"加载中…",禁用按钮
// ★ 核心:启动子线程发网络请求
new Thread(new kr0(this, z, username, password, cardCode)).start();
}
}
kr0.run() 网络认证分支
ak.t()和ak.C()是设备指纹函数。wb是网络层。看到activity.runOnUiThread()就知道请求是在子线程,回调在主线程,这是典型 Android 异步模式
// kr0 实现了 Runnable
public final class kr0 implements Runnable {
WelcomeActivity a; // WelcomeActivity 引用
boolean b; // true=卡密, false=登录
String c; // username
String d; // password
String e; // cardCode
public void run() {
wb client = new wb(); // HTTP 客户端
String deviceId = ak.t(activity); // 设备 ID 指纹
String deviceModel = ak.C(); // 设备型号
try {
if (this.b) {
// ===== 卡密注册:wb.v() = POST 提交卡密 =====
zc result = client.v(username, password, cardCode,
deviceId, deviceModel);
// 回到主线程 → 进入 QQ 验证流程
activity.runOnUiThread(() ->
activity.z(result, username, password));
} else {
// ===== 普通登录:wb.j() = POST 登录 =====
pc result = client.j(username, password,
deviceId, deviceModel);
// 回到主线程 → 保存 session,进主页
activity.runOnUiThread(() ->
activity.u(result));
}
} catch (Throwable t) {
// 网络异常包装
activity.runOnUiThread(() -> { /* 显示错误 */ });
}
}
}
卡密注册不是直接完成的。服务端先返回zc对象,activity.z(zc)把 token 显示出来让用户复制,
然后启动na定时器每 1 秒轮询服务端检查 QQ 验证状态,验证通过后自动调activity.u()进主页,代码如下
public class zc { // BboxQqRegistrationStart
String a; // sessionId
String b; // bindToken ← 这就是 QQ 绑定 token
long c; // expiresAt(token 有效期)
String d; // status
String e; // qqGroupNumber
String f; // command(发给 QQ 机器人的指令)
boolean g; // privateChatAllowed
String h; // message(提示信息)
}
不管走登录还是卡密,最终都回调这个方法,卡密的认证逻辑大概就是这个样子
public void u(pc pcVar) {
SharedPreferences sp = getSharedPreferences("bbox_session", 0);
sp.edit()
.putString("token", pcVar.a) // 登录凭证
.putString("username", pcVar.b.b) // 用户名
.putString("tester_type", pcVar.b.c) // 用户类型
.putString("tester_label", pcVar.b.d) // 用户标签
.putLong("expires_at", pcVar.b.f) // 到期时间(服务端下发)
.apply();
bc.g(this); // ← 启动风控引擎
bc.e(getApplicationContext());
startActivity(new Intent(this, MainActivity.class));
finish();
}
风控分析
从这个top.niunaijun.blackboxa.view\.main.WelcomeActivity类里的,
在u方法里有bbox\_session,这个方法的特征很明显:它在登录成功后把服务端返回的数据写入本地 session,pcVar就是登录返回结果,
里面包含 token、用户名、测试类型、到期时间等
public final void u(pc pcVar) {
SharedPreferences sharedPreferences = getApplicationContext().getSharedPreferences("bbox_session", 0);
py.g(sharedPreferences, "prefs(...)");
SharedPreferences.Editor editorEdit = sharedPreferences.edit();
editorEdit.putString("token", pcVar.a);
vb vbVar = pcVar.b;
editorEdit.putString("username", vbVar.b);
editorEdit.putString("tester_type", vbVar.c);
editorEdit.putString("tester_label", vbVar.d);
editorEdit.putLong("expires_at", vbVar.f);
editorEdit.apply();
//启动后台轮询,同时安排到期检查
bc.g(this);
Context applicationContext = getApplicationContext();
py.g(applicationContext, "getApplicationContext(...)");
//刷新会话到期定时器
bc.e(applicationContext);
startActivity(new Intent(this, (Class<?>) MainActivity.class));
finish();
}
跳转进bc这个类里面,发现他是App 的风控引擎,纯静态工具类。结构很清晰
public abstract class bc {
static Handler a = new Handler(Looper.getMainLooper());
static AtomicBoolean d = new AtomicBoolean(false); // 防重复启动
static long f = 30000; // 30秒轮询间隔
static long g = 60000; // 60秒默认间隔
// 封禁关键词(英文)
static Set k = {"ban","banned","blocked","disabled","disable",
"forbidden","suspended","deleted","deactivated",
"revoked","inactive","blacklisted"};
// 封禁关键词(中英文)
static Set l = {"banned","blocked","disabled","forbidden",
"suspended","blacklist","封","禁用","停用","冻结","黑名单"};
}
bc类里的g方法是风控的总开关
public static void g(ContextWrapper ctx) {
e(ctx.getApplicationContext()); // 先检查到期时间
if (d.compareAndSet(false, true)) { // 只启动一次
a.postDelayed(new b5(ctx, 2), 0L); // 启动轮询
}
}
e方法是封禁判断
public static void b(Context ctx, vb vbVar) {
// 检查1:到期了吗?
long expiresAt = vbVar.f;
if (expiresAt > 0 && expiresAt <= System.currentTimeMillis()) {
throw new ac(ctx.getString(R.string.bbox_session_invalid));
}
// 检查2:账号状态命中封禁关键词了吗?
String status = vbVar.e.trim().toLowerCase(Locale.ROOT);
if (status.isEmpty()) return; // 空状态 = 安全
if (k.contains(status)) {
throw new ac(ctx.getString(R.string.bbox_session_invalid));
}
for (String keyword : l) {
if (status.contains(keyword)) {
throw new ac(ctx.getString(R.string.bbox_session_invalid));
}
}
}
f方法是网络层拦截,HTTP 401/403/426 一律视为 session 失效 风控的决策权在服务器
public static boolean f(Throwable th) {
return (th instanceof ac) // 自定义异常
|| ((th instanceof xb) // 网络异常
&& Set.of(401, 403, 426).contains(((xb)th).a)); // HTTP 状态码
}
a和c方法是失效处理
static void a(Context ctx) {
sw.h(ctx); // 清理 bbox_session
u7.b(); // 重新初始化
md.g(ctx);
}
static void d(Context ctx, String msg) {
// 发送 App 内广播
ctx.sendBroadcast(new Intent("top.niunaijun.blackboxa.auth.SESSION_INVALID")
.setPackage(ctx.getPackageName())
.putExtra("message", msg));
// 弹提示或跳回登录页
a.post(new zb(ctx, msg, 0));
}
b5是一个 Runnable,a=2 时做风控轮询:它调wb.d()去/auth/me拉取最新账号状态,然后调bc.b()做本地判断,如果没挂就用bc.f间隔(30秒)再次调度自己
代跑识别
WelcomeActivity.onCreate这个地方,标记处出现的ak.t和ak.c,点击ak.t跳转过去
ak.t这里是一个设备指纹的生成逻辑
public static String t(Context context) {
return cg.d0(
ea.n0(new String[]{
Settings.Secure.getString(context.getContentResolver(), "android_id"),
// ↑ Android 设备唯一 ID,恢复出厂才会变
Build.BRAND,
// ↑ 品牌,如 "Xiaomi"
Build.MODEL
// ↑ 型号,如 "23049RAD8C"
}),
":", // 分隔符
null,
62
);
}
ak.c是用于UI展示,给用户看的
public static String C() {
return fj0.U0(
cg.d0(
ea.h0(new String[]{
Build.MANUFACTURER, // 制造商,如 "Xiaomi"
Build.MODEL // 型号,如 "23049RAD8C"
}),
" ", // 分隔符
null,
62
)
).toString();
}
从b5.run找到wb
// b5 implements Runnable
public final class b5 implements Runnable {
int a; // switch 分发标记: 2=风控轮询, 3=到期触发, 等等
Context b; // ApplicationContext
public b5(Context context, int type) {
this.a = type;
this.b = context;
}
public final void run() {
int type = this.a;
Context context = this.b;
switch (type) {
// ===== case 2: 风控轮询 Guard Poll =====
// smali 对应: :pswitch_38 (第 57 行起)
case 2:
try {
// ── smali 第 62-63 行 ──
wb client = new wb();
// ↑ ★ 点 wb 跳转到 wb 类
// ── smali 第 67 行: invoke-virtual {v0}, Lwb;->d()Lyb; ──
yb serverConfig = client.d(); // GET /auth/manifest
sw.K(context, serverConfig); // 保存服务端配置到本地
// ── 检查强制关停 (维护模式) ──
// smali 第 74 行: iget-boolean v12, v11, Lyb;->k:Z
if (serverConfig.k) {
String msg = serverConfig.l;
if (msg.isEmpty()) {
msg = getString(R.string.xxx);
}
bc.a(context); // 清 session
bc.d(context, msg); // 发 SESSION_INVALID 广播
return;
}
// ── 检查协议版本 ──
// smali 第 127-128 行: getLong("accepted_user_agreement_version")
long acceptedAgreement = sp.getLong("accepted_user_agreement_version", 0);
long acceptedDisclaimer = sp.getLong("accepted_disclaimer_version", 0);
if (acceptedAgreement < serverConfig.e
|| acceptedDisclaimer < serverConfig.h) {
// 发 LEGAL_UPDATED 广播
return;
}
// ── 更新轮询间隔 ──
// smali 第 193-198 行: sput-wide v4, Lbc;->f:J
bc.f = clamp(serverConfig.i, 5, 300) * 1000L;
bc.g = max(15, serverConfig.j) * 1000L;
// ── 读 token ──
String token = sw.P(context); // bbox_session → "token"
if (token.isEmpty()) return;
// ── 检查到期 ──
if (bc.c(context)) return;
// ── ★ 核心:GET /auth/me ──
// smali 第 238-241 行:
// const-string v5, "GET"
// const-string v7, "/auth/me"
// invoke-virtual {v0, v8, v5, v7, v4}, Lwb;->x(...)
JSONObject json = client.x(
null, // body
"GET", // method
"/auth/me", // path
token // token
);
// ↑ ★ 点 wb.x 跳转到 wb.x() 方法
// wb.x() 第一行:
// k(this.b + path, method, token)
// ↑ ★ 点 k 跳转到 wb.k() → 看到硬编码请求头
// ── 解析响应 ──
JSONObject accountJson = wb.g(json);
vb account = wb.p(accountJson);
// ── 检查封禁 ──
// smali 第 250 行: invoke-static {v9, v0}, Lbc;->b(...)
bc.b(context, account);
// ── 更新本地 ──
sw.J(context, account);
bc.e(context);
// ── 循环 ──
// smali 第 391-396 行: postDelayed(new b5(context, 2), bc.f)
bc.a.postDelayed(new b5(context, 2), bc.f);
} catch (Throwable t) {
// smali 第 318-320 行: log "guard poll failed"
BposedLogger.e("BboxGuard", "guard poll failed", t);
if (bc.f(t)) { // ac异常 || HTTP 401/403/426
bc.a(context);
bc.d(context, errorMsg);
}
}
return;
}
}
}
点击wb跳转在他的k方法里面看到
public static HttpURLConnection k(String url, String method, String token) {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod(method);
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("X-Client-Variant", "bbox"); // ← App 标识
conn.setRequestProperty("X-App-Version", "3.3.5"); // ← 版本号
conn.setRequestProperty("X-App-Version-Code", "338"); // ← 版本代码
if (!token.isEmpty()) {
conn.setRequestProperty("Authorization", "Bearer " + token); // ← 用户身份
}
return conn;
}
后台定位
跳转到bc.g(this)里面后,g方法里面能看到一个if判断,这个if是风控轮询的“一次性启动锁”,防止登录成功、App 初始化等多个入口重复启动同一个后台轮询任务
public static void g(ContextWrapper contextWrapper) {
Context applicationContext = contextWrapper.getApplicationContext();
py.e(applicationContext);
e(applicationContext);
if (d.compareAndSet(false, true)) {
// 这里对应风控/账号状态轮询分支,追踪这
a.postDelayed(new b5(applicationContext, 2), 0L);
}
}
点击b5跳转,在run方法里b5 实现了 Runnable,通过 this.a 做 switch 分发
public final void run() {
int type = this.a;
Context context = this.b;
switch (type) {
// ===== case 2:风控轮询 Guard Poll =====
case 2:
try {
// ① 创建 HTTP 客户端,拉取服务端配置
wb client = new wb();
// ↑ new wb() 的无参构造里:
// this.b = "https://www.daoges.cn/api"
// 所有 API 请求都拼到这个 baseUrl 后面
yb serverConfig = client.d(); // GET /auth/manifest (服务端配置)
sw.K(context, serverConfig); // 保存公告、协议等到本地
// ② 检查是否维护模式
if (serverConfig.k) { // yb.k = maintenance 标志
String msg = serverConfig.l;
if (msg.isEmpty()) {
msg = getString(R.string.xxx); // "服务器维护中"
}
bc.a(context); // 清 session
bc.d(context, msg); // 发 SESSION_INVALID 广播
return;
}
// ③ 检查用户协议/免责声明是否更新
long acceptedAgreement = sp.getLong("accepted_user_agreement_version", 0);
long acceptedDisclaimer = sp.getLong("accepted_disclaimer_version", 0);
if (acceptedAgreement < serverConfig.e // e = 用户协议版本
|| acceptedDisclaimer < serverConfig.h) { // h = 免责声明版本
// 发广播通知用户重新阅读协议
Intent intent = new Intent("top.niunaijun.blackboxa.auth.LEGAL_UPDATED");
intent.setPackage(context.getPackageName());
intent.putExtra("message", "用户协议或免责声明已更新,请重新阅读并同意。");
context.sendBroadcast(intent);
return;
}
// ④ 更新轮询间隔(服务端可动态调整)
bc.f = Math.max(5, Math.min(serverConfig.i, 300)) * 1000L; // 轮询间隔 5~300秒
bc.g = Math.max(15, serverConfig.j) * 1000L; // 日志上报间隔
// ⑤ 读本地 token
String token = sw.P(context); // 从 bbox_session 读 token
if (token.isEmpty()) return; // 没登录就跳过
// ⑥ 检查本地到期时间
if (bc.c(context)) return; // 已到期 → 提前退出(bc.c 里会清理+广播)
// ★ ⑦ 核心:GET /auth/me 查账号状态
JSONObject json = client.x(
null, // request body(GET 不需要)
"GET", // HTTP 方法
"/auth/me", // ★ API 路径
token // Bearer token 放 header
);
// ↓ wb.x() 内部:
// k(baseUrl + "/auth/me", "GET", token)
// = k("https://www.daoges.cn/api/auth/me", "GET", "Bearer xxx")
// ⑧ 解析响应 → vb 对象
JSONObject accountJson = wb.g(json); // 从 data/account/user/profile 中提取
vb account = wb.p(accountJson); // 解析出 username/status/expires_at 等
// ⑨ ★ 检查是否被封禁/到期
bc.b(context, account); // 如果 status 命中封禁关键词 → throw ac
// ⑩ 更新本地 session(服务端可能改了 expires_at)
sw.J(context, account); // 更新 bbox_session 里的账号信息
bc.e(context); // 重新设置到期定时炸弹
// ⑪ 定时日志上报(每 g 秒一次)
long now = System.currentTimeMillis();
if (now - bc.h > bc.g // 距上次超过间隔
&& bc.e.compareAndSet(false, true)) { // 没在上报中
bc.h = now;
bc.c.execute(new zb(token, context)); // 线程池执行日志上报
}
} catch (Throwable t) {
// ⑫ 轮询失败:记日志 + 判断是否登录失效
BposedLogger.e("BboxGuard", "guard poll failed", t);
if (bc.f(t)) { // bc.f() 判断: ac异常 || HTTP 401/403/426
// 登录失效 → 清理 + 踢人
String errorMsg = t.getMessage();
if (errorMsg == null) {
errorMsg = getString(R.string.bbox_session_invalid);
}
bc.a(context); // 清理 session
bc.d(context, errorMsg); // 发 SESSION_INVALID 广播
}
}
// ⑬ 不管成功还是失败,继续轮询
bc.a.postDelayed(
new b5(context, 2), // 创建新的 b5(2) 任务
bc.f // 延迟 bc.f 毫秒(默认 30 秒)
);
return;
}
}
可以看到都是https\://[www.daoges.cn/这个域名头的,我自己尝试的时候是靠抓包抓到他的服务器后台的但是开发者把后台关掉了,
在](http://www.daoges.cn/这个域名头的,我自己尝试的时候是靠抓包抓到他的服务器后台的但是开发者把后台关掉了,在)[https://www.daoges.cn/的后面加上admin,
最终得到的后台网址是](https://www.daoges.cn/)[https://www.daoges.cn/admin,访问这个网址就能看到他的风控管理系统后台了](https://www.daoges.cn/admin)
总的分析下了能看出,作者的卡密认证和登录认证逻辑都在服务器,你的校园跑模拟步频和路线绘制功能都在本地,这个软件是没有签名校验的,我们直接对他的smali代码进行修改就能够去除他的风控和卡密认证
卡密和登录验证去除
打开mt管理器,搜索top.niunaijun.blackboxa.view.main.WelcomeActivity这个类,按照类名搜索,找到它的onCreate方法,把里面的smali替换成如下代码
invoke-super {p0, p1}, Lv4;->onCreate(Landroid/os/Bundle;)V
invoke-virtual {p0}, Landroid/app/Activity;->getApplicationContext()Landroid/content/Context;
move-result-object v0
const-string v1, "bbox_session"
const/4 v2, 0x0
invoke-virtual {v0, v1, v2}, Landroid/content/Context;->getSharedPreferences(Ljava/lang/String;I)Landroid/content/SharedPreferences;
move-result-object v0
invoke-interface {v0}, Landroid/content/SharedPreferences;->edit()Landroid/content/SharedPreferences$Editor;
move-result-object v0
const-string v1, "token"
const-string v2, "bypass"
invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences$Editor;->putString(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;
const-string v1, "username"
const-string v2, "bypass"
invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences$Editor;->putString(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;
const-string v1, "tester_type"
const-string v2, "user"
invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences$Editor;->putString(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;
const-string v1, "tester_label"
const-string v2, "user"
invoke-interface {v0, v1, v2}, Landroid/content/SharedPreferences$Editor;->putString(Ljava/lang/String;Ljava/lang/String;)Landroid/content/SharedPreferences$Editor;
const-string v1, "expires_at"
const-wide v2, 0x40b5e07e5280L
invoke-interface {v0, v1, v2, v3}, Landroid/content/SharedPreferences$Editor;->putLong(Ljava/lang/String;J)Landroid/content/SharedPreferences$Editor;
invoke-interface {v0}, Landroid/content/SharedPreferences$Editor;->apply()V
new-instance v0, Landroid/content/Intent;
move-object v1, p0
const-class v2, Ltop/niunaijun/blackboxa/view/main/MainActivity;
invoke-direct {v0, v1, v2}, Landroid/content/Intent;-><init>(Landroid/content/Context;Ljava/lang/Class;)V
invoke-virtual {p0, v0}, Landroid/app/Activity;->startActivity(Landroid/content/Intent;)V
invoke-virtual {p0}, Landroid/app/Activity;->finish()V
top.niunaijun.blackboxa.view.main.bc这个类里面找到b方法和g方法,把里面的代码清空如图
wb类里的x方法,把里面的samli代码替换成下面的代码
new-instance p0, Lorg/json/JSONObject;
invoke-direct {p0}, Lorg/json/JSONObject;-><init>()V
免费评分
查看全部评分