<center> 2024年10月1日</center>
声明: 仅用于个人学习, 不提供成品, 如有侵权请私信联系删除
以正向开发的视角进行逆向, 全文较长, 按需学习, 跟着步骤小白也可以一步一步分析下去
仅能对app的本地验证会员的功能进行破解, 对需要在服务器验证会员并在服务器生成下发数据的操作无法破解
完成的内容有:
使用工具
分析过程
1. 查询是否加固
查询加固可以任选 ApkCheckPack/pkid/MT管理器 进行查询, 发现没有加固, 直接使用jadx分析代码.
2. 解决开屏广告的两种方法
方案① 模拟点击跳过
这是一个几乎通用的方法, 只需要分析少量代码就可以做到. app的广告页面要么是放在首个activity, 要么经由首个activity跳转, 所以分析开屏广告一定要先看首页. 而首页的是在 AndroidManifest.xml
中定义的
<application
android:name="com.youdao.note.YNoteApplication"
...>
<activity
android:name="com.youdao.note.activity2.NormalSplashActivity"
...>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
通过AndroidManifest.xml
可以看到NormalSplashActivity
就是首页,进去看
package com.youdao.note.activity2;
import com.alibaba.android.arouter.facade.annotation.Route;
import com.youdao.note.lib_router.AppRouter;
import com.youdao.note.splash.SplashActivity;
/* compiled from: Proguard */
@Route(path = AppRouter.NORMAL_SPLASH_PATH)
/* loaded from: classes4.dex */
public final class NormalSplashActivity extends SplashActivity {
}
竟然是空的, 那就看它的父类 SplashActivity
代码很长, 需要分析里面的关键方法
思路讲解: 这里面的代码很复杂, 有一些vip判断, 但是不知道有什么用, 可以先暂时不管, 完成目标--干掉开屏广告
package com.youdao.note.splash;
public class SplashActivity extends FragmentSafeActivity implements CancelAdapt, Runnable {
@Override
public void onCreate(Bundle bundle) {
...
initView();
showVideo();
...
}
public void onActivityResult(int i2, int i3, Intent intent) {
super.onActivityResult(i2, i3, intent);
if (i2 == 84 || i2 == 51) {
launchMainIfReady();
} else if (i2 == 112) {
showSplash();
}
}
public void afterSystemPermissionChecked(boolean z) {
if (needDataTransferForAndroid10()) {
startActivityForResult(new Intent(this, (Class<?>) DataTransferActivity.class), 112);
} else if (z) {
showSplash();
}
}
private void showSplash() {
...
showSplashAd();
...
}
@Override
public void run() {
YNoteLog.d(TAG, "过了3秒还没拉取到广告,关闭页面");
launchMainIfReady();
}
public synchronized void launchMainIfReady() {
if (this.mPendingCount.get() <= 0 && !this.isLaunchMainAlready && !this.mHadPaused) {
this.isLaunchMainAlready = true;
YNoteLog.d(TAG, "准备跳转,mRequestCode=" + this.mRequestCode + ",appCount=" + YNoteConfig.getAddCount());
int i2 = this.mRequestCode;
if (i2 == 273) {
finish();
return;
}
if (i2 != 272 && YNoteConfig.getAddCount() != 1) {
AppRouter.actionUri(this, this.mRequestCode, this.bundle, null);
finish();
}
YNoteLog.d(TAG, "准备跳转,devicePad =" + PadUtils.isPadModel());
if (PadUtils.isPadModel()) {
launchPadMain();
} else {
AppRouter.actionMain(this, this.mRequestCode, this.bundle, new a() { // from class: e.w.a.v0.j
@Override // h.x.b.a
public final Object invoke() {
SplashActivity.this.d();
return null;
}
});
}
}
}
public void showSplashAd() {
try {
if (!YNoteApplication.getInstance().isShowWarningDialogAlready() && !YNoteApplication.getInstance().isFullLicense()) {
YNoteLog.d(TAG, "不请求广告");
return;
}
...
YNoteLog.d(TAG, "请求广告");
AdManager.INSTANCE.loadSplashAd(this, clickIntercept.build(), this.adView, new AdvertListener.SplashAdListener() {
@Override // cn.flying.sdk.openadsdk.ad.AdvertListener.AdListener
public void onAdClicked(AdvertItem advertItem) {
...
}
@Override // cn.flying.sdk.openadsdk.ad.AdvertListener.AdListener
public void onAdDismiss(boolean z) {
...
SplashActivity.this.launchMainIfReady();
}
@Override // cn.flying.sdk.openadsdk.ad.AdvertListener.AdListener
public void onAdLoad(AdvertItem advertItem) {
...
}
@Override // cn.flying.sdk.openadsdk.ad.AdvertListener.BaseAdListener
public void onError(int i2, String str) {
YNoteLog.d(SplashActivity.TAG, "showSplashAd onError:" + str);
SplashActivity.this.mHandler.removeCallbacks(SplashActivity.this);
SplashActivity.this.launchMainIfReady();
}
});
} catch (Exception unused) {
YNoteLog.d(TAG, "showSplashAd failed");
if (this.mYNote.isFullLicense()) {
launchMainIfReady();
}
}
}
}
通过上面截取的部分代码可以知道,代码大部分的流程都是指向了一个方法launchMainIfReady()
,而这个方法就是点击跳过广告后执行的方法,所以我们可以在广告加载的过程中,或者直接在SplashActivity
类生成的时候就调用这个方法实现跳过广告. 以下是frida方法
function jumpAdSplash() {
var targetClass = "com.youdao.note.splash.SplashActivity";
Java.choose(targetClass, {
onMatch: function (instance) {
console.log("Found instance: " + instance);
//获取到实例对象后执行跳过广告的方法,这个方式更简单直观,但是还是会有加载广告的前置行为
instance.launchMainIfReady();
},
onComplete: function () {
}
});
}
⚠️注意: 不能直接调用finish()
方法,会导致app闪退
<br/>
方案② 禁止广告生成
上面那个方法实现了在广告没有用户没有看到广告之前就把广告关闭了,但广告其实还是加载了(浪费了流量?),这一步的目标就是不让广告加载
在进行这一步之前先思考一下需要做什么才能禁止广告加载,不外乎是:禁止广告初始化、把广告内容至空、禁止拉取广告、获取会员...
有了上面的思路就可以开始尝试分析代码了,在上一个方案中,我们确定了广告就是在初始页面进行加载的,那么广告内容会是在哪里生成呢? 根据加载广告的方式去看一下AdManager.INSTANCE.loadSplashAd()
,分析该类
package cn.flying.sdk.openadsdk.ad;
public final class AdManager implements AdvertManager {
@Override // cn.flying.sdk.openadsdk.ad.AdvertManager
public void loadSplashAd(LifecycleOwner lifecycleOwner, AdConfig adConfig, AdView adView, AdvertListener.SplashAdListener splashAdListener) {
s.f(lifecycleOwner, "lifecycleOwner");
verifyAdConfig(adConfig, adView);
s.d(adConfig);
s.d(adView);
l.d(LifecycleOwnerKt.getLifecycleScope(lifecycleOwner), null, null, new AdManager$loadSplashAd$1(adConfig, new SplashAdvert(lifecycleOwner, adConfig, adView, splashAdListener), splashAdListener, null), 3, null);
}
public static final class Builder {
...
public Builder(List list0, a a0, AdAppCallBack adAppCallBack0, boolean z, boolean z1) {
s.f(list0, "networkInterceptors");
s.f(a0, "userId");
super();
this.networkInterceptors = list0;
this.userId = a0;
this.adAppCallBack = adAppCallBack0;
this.isTestingModeEnable = z;
this.isYouDaoTestService = z1;
}
...
}
}
在该类中看到了一个Builder建造者模式,根据名称可以判断ad业务就是通过这个Builder方法来创建的,通过jadx查看该方法的用例
是在YNoteApplication
中进行调用的,而YNoteApplication
又是在AndroidManifest.xml
中自定义的Application,所以可以推断出,在Application中初始化广告业务,然后在需要的activity中调用这个单例AdManager
来生成ad内容
经过尝试,返回AdManager.Builder
为空或者new一个新对象的时候会闪退,所以不能通过禁止初始化的方法来完成
按逻辑来说可以在设置广告参数的时候让参数值不正确,实现找不到广告的目的. 这个逻辑入口在AdManager.doInit()
中,但我尝试了一番发现没有试出效果,所以选择在获取广告的时候找突破点
在分析AdManager
类的时候有几个方法jadx无法很好的解析,需要用jeb来看,最关键的是loadAdvert()
方法
AdManager.java
private final void loadAdvert(AdConfig adConfig0, AdvertParser advertParser0, BaseAdListener advertListener$BaseAdListener0) {
AdLogUtils.d("AdManager", "调用广告接口loadAdvert");
AdManager.loadAdvert.callback.1 adManager$loadAdvert$callback$10 = new AdManager.loadAdvert.callback.1(advertListener$BaseAdListener0, advertParser0, adConfig0);
if(adConfig0.getUseNewApi()) {
AdManager.loadAdvert.1 adManager$loadAdvert$10 = new AdManager.loadAdvert.1(adConfig0, adManager$loadAdvert$callback$10, null);
l.d(AdManager.managerScope, null, null, adManager$loadAdvert$10, 3, null);
return;
}
AdvertClient.getInstance().get().getAdvert(adConfig0.getSpaceId(), adConfig0.getOperationType().getType()).enqueue(adManager$loadAdvert$callback$10);
}
代码分析到这里可以判断这就是广告接入的入口了, 按照正常的逻辑只要我们将这个方法给屏蔽掉就可以实现 禁止拉取广告. 但我一顿实操下来发现, 通过jadx复制的frida代码片段并没有在加载广告的时候被执行, 而我使用 raptor_frida_android_trace.js 查看的时候确实是在广告开始前有调用这个方法的, 暂时不清楚具体的原因
在 loadAdvert()
中继续分析代码, 跳转到 AdvertClient
类查看, 这个代码很明显就是网络接口地址, 通过调用okhttp的网络接口实现网络请求
package cn.flying.sdk.openadsdk.api;
import cn.flying.sdk.openadsdk.bean.AdvertConfig;
import cn.flying.sdk.openadsdk.bean.AdvertListModel;
import cn.flying.sdk.openadsdk.http.ApiResponse;
import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Query;
/* compiled from: Proguard */
/* loaded from: classes.dex */
public interface AdvertService {
@GET("/api/open/advertOpen/getAdvertResources")
Call<ApiResponse<AdvertListModel>> getAdvert(@Query("advertSpaceCode") String str, @Query("operationType") String str2);
@GET("/fly-api/advert/config")
Call<ApiResponse<AdvertConfig>> getAdvertConfig();
@GET("/api/open/advertOpen/v2/getAdvertResources")
Call<ApiResponse<AdvertListModel>> getAdvertV2(@Query("advertSpaceCode") String str, @Query("operationType") String str2, @Query("banAdvertIds") String str3);
}
使用Reqable查看getAdvertResources接口的请求数据
这时候骚操作就来了, 因为广告业务是需要经过网络拉取的, 如果我将这个拉取接口的地址改变掉的话,自然就拉取不了广告, 直接向claude.ai询问hook所有网络请求的方法就可以实现了
me: 在Android中hook所有网络请求的方法
ai: 我们可以使用更底层的方法来hook所有的网络请求,而不仅限于OkHttp。在Android中,几乎所有的网络请求最终都会通过java.net. URL或java.net. Socket来实现。让我为您展示一个更通用的解决方案。
function hookHttp() {
// Hook URL类的openConnection方法
var URL = Java.use("java.net.URL");
URL.openConnection.overload().implementation = function() {
var url = this.toString();
if (shouldBlockUrl(url)) {
var invalidURL = URL.$new("https://yuu.com");
console.log("[URL] Redirecting to invalid domain", url);
return invalidURL.openConnection();
}
return this.openConnection.call(this);
};
// Hook HttpURLConnection类
var HttpURLConnection = Java.use("java.net.HttpURLConnection");
HttpURLConnection.connect.implementation = function() {
var url = this.getURL().toString();
if (shouldBlockUrl(url)) {
console.log("[HttpURLConnection] Blocked connection to:", url);
// 抛出异常或返回错误响应
var SocketException = Java.use("java.net.SocketException");
throw SocketException.$new("Connection blocked by security policy");
}
return this.connect.call(this);
};
// Hook Socket类
var Socket = Java.use("java.net.Socket");
Socket.$init.overload('java.lang.String', 'int').implementation = function(host, port) {
if (shouldBlockUrl(host)) {
console.log("[Socket] Blocked connection to:", host);
var SocketException = Java.use("java.net.SocketException");
throw SocketException.$new("Connection blocked by security policy");
}
return this.$init.call(this, host, port);
};
// Hook SSLSocket类
var SSLSocket = Java.use("javax.net.ssl.SSLSocket");
SSLSocket.$init.overload('java.lang.String', 'int').implementation = function(host, port) {
if (shouldBlockUrl(host)) {
console.log("[SSLSocket] Blocked connection to:" + host + ":" + port);
var SocketException = Java.use("java.net.SocketException");
throw SocketException.$new("Connection blocked by security policy");
}
return this.$init.call(this, host, port);
};
// Optional: Hook Apache HTTP Client if used
try {
var AbstractHttpClient = Java.use("org.apache.http.impl.client.AbstractHttpClient");
AbstractHttpClient.execute.overload('org.apache.http.HttpHost', 'org.apache.http.HttpRequest').implementation = function(host, request) {
var url = host.toURI();
if (shouldBlockUrl(url)) {
console.log("[Apache HTTP] Blocked request to:", url);
var IOException = Java.use("java.io.IOException");
throw IOException.$new("Connection blocked by security policy");
}
return this.execute.call(this, host, request);
};
} catch (e) {
console.log("Apache HTTP Client not found in app");
}
}
// URL检测方法
function shouldBlockUrl(url) {
var urlsToBlock = ["getAdvertResources", "/fly-api/advert/config", "bida"];
for (var i = 0; i < urlsToBlock.length; i++) {
if (url.indexOf(urlsToBlock[i]) !== -1) {
console.log(" Matched blocked URL pattern:", urlsToBlock[i]);
return true;
}
}
return false;
}
well, 经过验证, 这个代码确实改变了请求地址, 实现了禁止拉取广告的效果.
而且如果后续广告接口改变, 以及对上报手机数据、去除更新进行去除的话, 只需要在 urlsToBlock
中添加对应的关键字接口就可以实现屏蔽.
这个方法对于在java层请求网络的app来说应该是通杀的
3. 解决设备同步受限
如何去获取会员, 代码里面关于vip的值有很多, 不太好下手. 所以我决定通过一个必要的会员功能入手来分析会员逻辑. 而非会员只能登录两台设备, 超过两台必须强制退出一台设备这个功能就很适合
使用MT管理器或者 adb shell "dumpsys window | grep mCurrentFocus"
查看当前activity
可以看到是 com.youdao.note.deviceManager.LimitDeviceSyncActivity
类, 分析代码
package com.youdao.note.deviceManager;
public final class LimitDeviceSyncActivity extends LockableActivity {
/* loaded from: classes4.dex */
public static final class Companion {
public final void launch(YNoteActivity yNoteActivity, LimitDeviceListModel limitDeviceListModel) {
s.f(yNoteActivity, "activity");
Intent intent = new Intent(yNoteActivity, (Class<?>) LimitDeviceSyncActivity.class);
intent.putExtra(LimitDeviceSyncActivity.DEVICE_LIMIT, limitDeviceListModel);
yNoteActivity.startActivityForResult(intent, 132);
}
}
public void onResume() {
super.onResume();
if (VipStateManager.checkIsSenior()) {
setResult(-1);
SettingPrefHelper.setCheckDeviceLimitState(2);
finish();
}
}
}
一顿分析下来发现只需要关注两个方法, launch()
进入到该页面, onResume()
中的 VipStateManager.checkIsSenior()
判断是否退出该页面
在正向寻找 launch()
调用链过程中看不出具体的逻辑, 可以选择在其它设备上手动让该设备下线, 在这个过程中继续使用Reqable监听网络请求变化, 经过分析可以看到在别的设备执行该设备下线操作后, 服务器向我们发过来一个ecode值
根据图片上的网络数据在jadx代码中寻找值 = 2060
看到结果中有两个 ecode = 2060
似乎是目标, 这两个类的方法体一致
private void checkErrorForBroadcast(ServerException serverException) {
if (serverException == null) {
return;
}
int errorCode = serverException.getErrorCode();
int ecode = serverException.getEcode();
YNoteApplication yNoteApplication = YNoteApplication.getInstance();
String str = null;
if (errorCode == 207) {
str = "com.youdao.note.action.ACTION_ACCESS_DENIED";
} else if (ecode == 2061) {
str = BroadcastIntent.ACTION_REQUEST_DELETE;
} else if (ecode == 2060) {
str = BroadcastIntent.ACTION_REQUEST_OFFLINE;
}
if (str != null) {
yNoteApplication.sendBroadcast(new Intent(str));
}
YNoteLog.m34203d(TAG, "同步失败,ServerException:" + str);
}
结合上面的Companion.launch()
以及这一堆写得很糅合的代码分析,发现是用了广播事件所以代码关联性不大,大概的逻辑是接收到服务器发来的2060错误码之后,通过YNoteApplication
类发送Broadcast广播事件,在DeviceRequestReceiver
类和LoginDeviceReceiver
类中接收该事件执行显示设备同步受限页面以及退出登录的逻辑
其实不分析代码只看网络请求也能知道怎么修改,就是当ecode
为2060/2061的时候改变该值,使其不执行该事件就可以.但是如果已经执行过该事件的话,就需要同时修改LimitDeviceSyncActivity.onResume() -> VipStateManager.checkIsSenior()
判断,使其退出当前受限页面才行
function fuckOut() {
// 干掉要求下线的消息
let BaseServerException = Java.use("com.youdao.note.lib_core.network.entity.BaseServerException");
BaseServerException["getEcode"].implementation = function () {
let result = this["getEcode"]();
let newResult = result;
if (result > 2050) newResult = -999;
console.log(`BaseServerException.getEcode result=${result} newResult=${newResult}`);
return newResult;
};
// 退出受限页面
let VipStateManager = Java.use("com.youdao.note.seniorManager.VipStateManager");
VipStateManager["checkIsSenior"].implementation = function () {
let result = this["checkIsSenior"]();
console.log(`VipStateManager.checkIsSenior result=${result}`);
return true;
};
}
实现效果.
⚠️注意: 因为同步数据需要经过服务器, 即使能在app端去除设备限制, 但不能保证同步数据成功, 对这方面有需求的建议花钱入手会员
4. 实现本地的VIP功能
注意看上面最后一张的实际效果图, 其实在解决问题3的时候, 就已经有vip判断的逻辑浮出水面了, 就是 VipStateManager.checkIsSenior()
, 详细分析该类
package com.youdao.note.seniorManager;
public class VipStateManager {
public static boolean checkIsExpiredSenior(UserMeta userMeta) {
if (userMeta == null || userMeta.isSeniorAccount() || userMeta.getRenewYearDiscount() == 0) {
return false;
}
long lastRenewEndTime = userMeta.getLastRenewEndTime();
long currentTimeMillis = System.currentTimeMillis();
return currentTimeMillis >= AccountManager.VIP_THREE_DAY_TIME + lastRenewEndTime && currentTimeMillis <= lastRenewEndTime + 2592000000L;
}
public static boolean checkIsPapSeniorAccount() {
UserMeta userMeta = mDataSource.getUserMeta();
if (userMeta != null) {
return userMeta.isPapSenior();
}
return false;
}
public static boolean checkIsSenior() {
UserMeta userMeta = mDataSource.getUserMeta();
if (userMeta != null) {
return userMeta.isSeniorAccount();
}
return false;
}
}
由于我们没有会员账号进行网络分析比对, 所以只能尝试着在通过方法名称去间接判断, 不断试错来找出真正的答案. 继续分析实际存储的 UserMeta
类
package com.youdao.note.data;
public class UserMeta extends BaseData {
public long getLastRenewEndTime() {
return this.mLastRenewEndTime;
}
public boolean isPapSenior() {
int i2 = this.mPayType;
return (i2 & 1) == 1 || (i2 & 64) == 64;
}
public boolean isSeniorAccount() {
return this.isSeniorAccount;
}
public boolean checkIsSuperSenior() {
return this.mUserType >= 8;
}
public int getUserType() {
return this.mUserType;
}
}
这个时候问题就好解决了, 通过一顿尝试, 发现 isSeniorAccount()
是高级会员, checkIsSuperSenior()
是超级会员, getLastRenewEndTime()
是过期时间
以下是代码
function wtfVIP() {
// vip实际存储位置
let UserMeta = Java.use("com.youdao.note.data.UserMeta");
UserMeta["isSeniorAccount"].implementation = function() {
let result = this["isSeniorAccount"]();
console.log( `UserMeta.isSeniorAccount result=${result}` );
return true;
};
UserMeta["isPapSenior"].implementation = function() {
let result = this["isPapSenior"]();
console.log( `UserMeta.isPapSenior result=${result}` );
return true;
};
// vip过期时间
UserMeta["getLastRenewEndTime"].implementation = function() {
console.log( `UserMeta.getLastRenewEndTime is called` );
let result = this["getLastRenewEndTime"]();
let newResult = 956592001000;
console.log( `会员过期的有效期时间: UserMeta.getLastRenewEndTime result=${result} new=${newResult}` );
return newResult;
};
UserMeta["setUserType"].implementation = function(i2) {
console.log( ` 设置会员等级为8 UserMeta.setUserType is called: i2=${i2}` );
this["setUserType"](8);
};
}
<br>
结尾
以上便是从正向开发的角度一点点去摸索开发者的逻辑, 一步步完成破解的过程. 祝大家国庆节快乐~
为了避免不必要的麻烦, 完整的frida代码在附件
⚠️仅供大家入门学习, 请勿传播成品!
<br/><br/><br/><br/><br/><br/><br/>
彩蛋:
在查设备受限接口的时候, 发现有一个接口返回了 devUserWhiteList
白名单账号, 用的还是qq邮箱而不用网易邮箱, 而且开发不给vip还要程序员自己留后门, 不过好想让他内推我(doge)