优雅地破解5ri46YGT5piT游戏的实名制+内购

查看 210|回复 11
作者:侃遍天下无二人   
锦鲤镇楼

话说好久没玩过滑雪大冒险了,之前在B站上看到一个滑了两亿分的视频于是心痒就去官网上把游戏下回来了,结果一安装发现居然还要登录,我就先注册个账号,登录后发现居然还要实名认证!

这怎么能忍,明明是个单机游戏啊,我这就想办法把认证搞掉!
实名认证
首先用身份信息完成认证,提交后再用账号登录,Toast提示登录成功

这个地方可以成为一个很好的突破口,但在此之前我还想把手机接上USB调试,看看他们会不会把什么有用的消息用Log打出来。于是我们清除数据,重启游戏,重新登录,并留意Logcat上的信息:

啊这,就很明显了,他们真把所有关键信息都打出来了,而且还有统一的tag前缀,所以只要这么一搜游戏相关的日志就全出来了,我调试自己的app都没这么方便。
于是,将app拖进jadx,搜索 "login begin ...",对应的java代码如下:
    /* JADX INFO: Access modifiers changed from: private */
    public void login() {
        YLog.i("login begin ...");
        EditText editText = this.et_username;
        if (editText != null && editText.getText() != null) {
            String username = this.et_username.getText().toString();
            EditText editText2 = this.et_password;
            if (editText2 != null && editText2.getText() != null) {
                String password = this.et_password.getText().toString();
                if (!TextUtils.isEmpty(username) && !TextUtils.isEmpty(password)) {
                    UIUtils.showLoadingDialog(this.activity);
                    Yodo1OpsUserCenter.getInstance().userCenterGameUserLogin(username, password, Yodo1OPSBuilder.getInstance().getGameAppKey(), Yodo1OPSBuilder.getInstance().getGameRegionCode(), Yodo1Builder.getInstance().getChannelCode(), new Yodo1OpsCallback() { // from class: com.yodo1.sdk.account.Yodo1LoginFragment.7
                        home.php?mod=space&uid=1892347 // com.yodo1.android.ops.utils.Yodo1OpsCallback
                        public void onResult(Yodo1OpsCallback.ResultCode resultCode, String msg) {
                            UIUtils.hideLoadingDialog();
                            YLog.i("login, code = " + resultCode + ", msg = " + msg);
                            if (resultCode == Yodo1OpsCallback.ResultCode.Success) {
                                Yodo1SharedPreferences.put(Yodo1LoginFragment.this.activity, Yodo1AccountActivity.KEY_USERNAME, Yodo1LoginFragment.this.et_username.getText().toString());
                                Yodo1SharedPreferences.put(Yodo1LoginFragment.this.activity, Yodo1AccountActivity.KEY_PASSWORD, Yodo1LoginFragment.this.et_password.getText().toString());
                                Yodo1LoginFragment.this.activity.showToast(RR.stringTo(Yodo1LoginFragment.this.activity, "yodo1_string_account_msg_login_suc"));
                                try {
                                    JSONObject jsonObj = new JSONObject(msg);
                                    int errorCode = jsonObj.optInt(Yodo1HttpKeys.KEY_ERRORCODE);
                                    if (errorCode == 0) {
                                        JSONObject jsonData = new JSONObject(jsonObj.optString("data"));
                                        String isRealName = jsonData.optString("isRealName");
                                        String isOLRealName = jsonData.optString("isOLRealName");
                                        YLog.d("login  是否完成实名制: " + isRealName);
                                        YLog.d("login  是否完成联网实名制: " + isOLRealName);
                                        Yodo1LoginFragment.this.activity.callback(1, msg);
                                        return;
                                    }
                                    return;
                                } catch (Exception e) {
                                    YLog.d("json解析异常 " + e.getMessage() + "   " + e.getCause());
                                    return;
                                }
                            }
                            try {
                                JSONObject jsonObject = new JSONObject(msg);
                                String errorCode_var = jsonObject.optString(Yodo1HttpKeys.KEY_ERRORCODE);
                                int error_code = Integer.parseInt(errorCode_var);
                                if (error_code != 1011) {
                                    Yodo1LoginFragment.this.activity.showToast(RR.stringTo(Yodo1LoginFragment.this.activity, "yodo1_string_account_msg_login_failed"));
                                } else {
                                    Yodo1LoginFragment.this.activity.showToast(RR.stringTo(Yodo1LoginFragment.this.activity, "yodo1_string_account_msg_login_failed_account"));
                                }
                            } catch (Exception e2) {
                                e2.printStackTrace();
                                Yodo1LoginFragment.this.activity.showToast(RR.stringTo(Yodo1LoginFragment.this.activity, "yodo1_string_account_msg_login_failed"));
                            }
                        }
                    });
                    return;
                }
                return;
            }
            ToastUtils.showToast(this.activity, RR.stringTo(this.activity, "yodo1_string_account_msg_notnull"));
            return;
        }
        ToastUtils.showToast(this.activity, RR.stringTo(this.activity, "yodo1_string_account_msg_notnull"));
    }
意思应该很明显了,这里就是处理登录请求的地方,onResult中的
YLog.i("login, code = " + resultCode + ", msg = " + msg);
在控制台给我们打出了正确登录后的响应结果(信息已打码,但不影响使用)
login, code = 成功, msg = {"error_code":"0","error":"","data":{"uid":"1","yid":"1","isRealName":0,"isOLRealName":0,"isnewyaccount":0,"token":"null","isnewuser":0}}
所以我们只要按照控制台上的提示给resultCode和msg重新赋值应该就行了吧。不过在此之前我们要对app重新签名,然后再测试一遍。测试结果表明除了控制台会多出签名不对信息将不被统计外,游戏本身并无异常,正好我也不想让他们统计啥东西,所以过签名校验就省了。
现在开始改代码,注意jadx上提示onResult来自com.yodo1.sdk.account.Yodo1LoginFragment.7 ,因此要在MT管理器里修改com.yodo1.sdk.account.Yodo1LoginFragment$7

在代码中搜索success,定位到
        .line 177
    sget-object v1, Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;->Success:Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;
    const-string v2, "error_code"
    if-ne p1, v1, :cond_df
结合上下文可以知道这几句对应
if (resultCode == Yodo1OpsCallback.ResultCode.Success) {...}
因此只要先把resultCode赋值为Yodo1OpsCallback.ResultCode.Success即可让判断始终为true,只需在对应的smail中增加一行
        .line 177
    sget-object v1, Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;->Success:Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;
        # 新增下面一行
        sget-object p1, Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;->Success:Lcom/yodo1/android/ops/utils/Yodo1OpsCallback$ResultCode;
    const-string v2, "error_code"
    if-ne p1, v1, :cond_df
同时,我们在函数开头用赋值语句直接写死msg
        const-string p2,"{\"error_code\":\"0\",\"error\":\"\",\"data\":{\"uid\":\"1\",\"yid\":\"1\",\"isRealName\":0,\"isOLRealName\":0,\"isnewyaccount\":0,\"token\":\"null\",\"isnewuser\":0}}"
修改完毕后反编译的java代码如下:
public void onResult(Yodo1OpsCallback.ResultCode resultCode, String msg) {
        UIUtils.hideLoadingDialog();
        YLog.i("login, code = " + resultCode + ", msg = {\"error_code\":\"0\",\"error\":\"\",\"data\":{\"uid\":\"1\",\"yid\":\"1\",\"isRealName\":0,\"isOLRealName\":0,\"isnewyaccount\":0,\"token\":\"null\",\"isnewuser\":0}}");
        Yodo1OpsCallback.ResultCode resultCode2 = Yodo1OpsCallback.ResultCode.Success;
        Yodo1OpsCallback.ResultCode resultCode3 = Yodo1OpsCallback.ResultCode.Success;
        if (resultCode3 == resultCode2) {...}
回编译重新安装后,在登录界面输入任意信息,即可登录成功,但会弹出实名认证窗口(我在写教程之前是直接弹对话框提示网络连接已断开的)

不过在模拟器上测试的时候,如果给游戏断网,登录后就能直接进入游戏,如果在手机上断网,依然会弹出实名认证框,但允许按游客身份体验了。点击游客体验后,会提示网络连接已断开,请稍后重试:

与此同时,注意到控制台的输出:

搜索[Yodo1AntiAddiction] [CNAntiAddictionHelper] , call online.,发现如下代码:
public void online(final AntiNetCallback callback) {
        YLog.i("[Yodo1AntiAddiction] [CNAntiAddictionHelper] , call online. ");
        boolean systemSwitch = false;
        try {
            systemSwitch = RulesManager.getInstance().getRules().isSwitchStatus();
        } catch (MissingAntiAddictionRulesException e) {
            YLog.e(TAG, ", checkNeedAntiAddiction error, get rule miss, " + e.getMessage());
        }
        if (!systemSwitch) {
            YLog.i("[Yodo1AntiAddiction] [CNAntiAddictionHelper] online, anti switchStatus = false, return");
            callback.onResult(200, "");
        } else if (UserDataManager.getInstance().getUserData().isOnline()) {
            YLog.i("[Yodo1AntiAddiction] [CNAntiAddictionHelper] , call online, player is online, not-repeated");
            callback.onResult(200, "");
        } else if (UserDataManager.getInstance().getUserData() == null || TextUtils.isEmpty(UserDataManager.getInstance().getUserData().getYid())) {
            YLog.w("[Yodo1AntiAddiction] [CNAntiAddictionHelper] , call online, yid is null, player not login");
            callback.onResult(-1, "");
        } else {
            UserDataManager.getInstance().getUserData().setSessionId("");
            final CNUserBehaviour behaviour = new CNUserBehaviour();
            behaviour.setBehaviorType(CNUserBehaviour.CNBehaviorType.Online);
            behaviour.setDeviceId(AppSettingManager.getInstance().getDeviceId());
            behaviour.setHappenTimestamp(TimeClock.getNowTime());
            behaviour.setSessionId(UserDataManager.getInstance().getUserData().getSessionId());
            behaviour.setPlayerType(UserDataManager.getInstance().getUserData().getPlayerType());
            behaviour.setYid(UserDataManager.getInstance().getUserData().getYid());
            behaviour.setUid(UserDataManager.getInstance().getUserData().getUid());
            behaviour.setGameVersion(AppSettingManager.getInstance().getGameVersion());
            behaviour.setSdkVersion(AppSettingManager.getInstance().getSdkVersion());
            reportBeforeOfflineBehaviour(behaviour.getUid(), behaviour.getYid(), new AntiNetCallback() { // from class: com.yodo1.anti.helper.CNAntiAddictionHelper.1
                @Override // com.yodo1.anti.callback.AntiNetCallback
                public void onResult(int codeBefore, String responseBefore) {
                    if (codeBefore == 200) {
                        CNAntiAddictionHelper.this.reportUserBehaviour(behaviour, new AntiNetCallback() { // from class: com.yodo1.anti.helper.CNAntiAddictionHelper.1.1
                            @Override // com.yodo1.anti.callback.AntiNetCallback
                            public void onResult(int code, String response) {
                                YLog.d("[Yodo1AntiAddiction] [CNAntiAddictionHelper] user online, reportBehaviour code = " + code + ", resp = " + response);
                                if (code == 200) {
                                    try {
                                        JSONObject obj = new JSONObject(response);
                                        JSONObject data = obj.optJSONObject("data");
                                        if (data == null) {
                                            callback.onResult(code, "上线请求失败,账户异常");
                                            YLog.d("[Yodo1AntiAddiction] [CNAntiAddictionHelper] , user online, reportBehaviour error, sessionId = null");
                                        } else {
                                            String sessionId = data.optString(AntiDBSchema.AntiCNUserBehaviour.Cols.SESSION_ID);
                                            UserDataManager.getInstance().getUserData().setSessionId(sessionId);
                                            UserDataManager.getInstance().getUserData().setOnlineStatus(true);
                                            YLog.d("[Yodo1AntiAddiction] [CNAntiAddictionHelper] , user online, reportBehaviour successful, sessionId = " + UserDataManager.getInstance().getUserData().getSessionId());
                                            callback.onResult(code, "");
                                        }
                                        return;
                                    } catch (Exception e2) {
                                        YLog.e(CNAntiAddictionHelper.TAG, e2);
                                        callback.onResult(code, "上线请求失败,账户异常");
                                        return;
                                    }
                                }
                                callback.onResult(code, "上线请求失败,请检查网络情况");
                            }
                        });
                        return;
                    }
                    YLog.e("[Yodo1AntiAddiction] [CNAntiAddictionHelper] online, reportBeforeOfflineBehaviour is error, code = " + codeBefore + ", resp = " + responseBefore);
                    callback.onResult(codeBefore, "上线请求失败,请检查网络情况");
                }
            });
        }
    }
结合代码中的字符串,可以得知这是用户上线之前要调用的函数,事实上它附近还有个下线之前要调用的函数offline。
查看回调函数接口的定义:
public interface AntiNetCallback {
    public static final int CODE_FAILED = -1;
    public static final int CODE_NET_ERROR = -100;
    public static final int CODE_SUCCESS = 200;
    public static final int CODE_USER_FAILED = -2;
    void onResult(int i, String str);
}
可以发现只要传入的code是200就表示认证通过,第二个字符串可以传空串,因此我们直接把整个方法清空,替换为(注意寄存器不要少于4个)
.method public online(Lcom/yodo1/anti/callback/AntiNetCallback;)V
    .registers 4
    const/16 v1, 0xc8
    const-string v2, ""
    .line 65
    invoke-interface {p1, v1, v2}, Lcom/yodo1/anti/callback/AntiNetCallback;->onResult(ILjava/lang/String;)V
    return-void
.end method
改完后,游戏是可以进入了,但在付费购买道具时却提示“不能支付,请先完成实名认证”。

好吧,看来暴力修改online方法是不能完全奏效的。
仔细看看online方法的代码,我们似乎漏了些什么:
systemSwitch = RulesManager.getInstance().getRules().isSwitchStatus();
if (!systemSwitch) {
    YLog.i("[Yodo1AntiAddiction] [CNAntiAddictionHelper] online, anti switchStatus = false, return");
    callback.onResult(200, "");
}
这处看着很像规则开关,似乎只要把实名认证规则关了就可以绕开认证!
.method public isSwitchStatus()Z
        .registers 2
    .line 53
    const/4 v0,0
    return v0
.end method
修改后再回编译打包安装,果然可以进入支付系统了:

内购破解
结合涛涛的研究成果(其实我破解的时候也发现了,不想写了就直接用他的吧),只要在合适的位置将code赋值为1就能无条件支付成功。
不够优雅
至此我们已经完成了实名绕过和内购破解,但还不够优雅,我们还需要在点击开始游戏后象征性地点下登录按钮才能进入游戏。
此外,在支付时我们需要点击关闭按钮来完成支付,选支付宝系统会无响应,选微信支付微信会在前台闪一下,提示签名不对(没安装则是游戏直接告知你没有微信客户端),然后游戏提示购买成功。
去掉登录框
怎样才能更优雅一些呢?首先点击开始游戏后,不要弹登录框了,直接进入
仔细观察前文的login方法,可以发现登录成功后,要调用回调函数通知调用方,而login方法所在的类恰好是展示登录框的类
Yodo1LoginFragment.this.activity.callback(1, msg);
这个类通过onCreateView创建登录界面:
        public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(RR.layout(this.activity, "yodo1_games_layout_login"), (ViewGroup) null);
        initView(view);
        return view;
    }
因此我们直接在onCreateView的开头添加代码,调用回调函数即可,msg按先前的内容直接写死。
最终的java代码如下,smail大家自己写:
        @Override // android.app.Fragment
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        this.activity.callback(1, "{\"error_code\":\"0\",\"error\":\"\",\"data\":{\"uid\":\"1\",\"yid\":\"1\",\"isRealName\":0,\"isOLRealName\":0,\"isnewyaccount\":0,\"token\":\"null\",\"isnewuser\":0}}");
        View view = inflater.inflate(RR.layout(this.activity, "yodo1_games_layout_login"), (ViewGroup) null);
        initView(view);
        return view;
    }
这样便可以在创建出登录框之前通知调用者直接进入下一步了,自然也就没有登录框了。
支付方式:零元购
先上效果图:


这里简单写下改法:
首先搜索"微信支付",可以定位到枚举类PayType,将它改为零元购后,支付界面上的字并未发生变化,接下来去资源中搜索"微信支付",发现id为yodo1_string_cashier_paytype_name_wechat的字符串,将其修改为"零元购",支付界面的文字发生变化。
然后要想办法把微信支付放到第一个,不然每次还得手动选。
其实在破解支付的过程中你就会发现支付弹窗在是com.yodo1.android.sdk.view.Yodo1PayActivity初始化并创建的,在其onCreate方法里进行修改即可,关键代码反编译后如下,目的是让函数无条件使用你创建的支付方式列表:
int[] iArr = this.arr_payTypes;
if (iArr == null || iArr != null || iArr.length == 0) {
    this.arr_payTypes = new int[2];
    this.arr_payTypes[0] = PayType.wechat.val();
    this.arr_payTypes[1] = PayType.carriers.val();
}
其中PayType.carriers为话费支付,由于业务已下线,即使设置了也不会展示出来。
在未安装微信时,游戏会给出"支付失败,微信客户端未安装”的提示,将其修改为你喜欢的话即可。
根据字符串提示,可以定位到
if (!api.isWXAppInstalled()) {
    String wechat_noinstall = RR.stringTo(activity, "yodo1_string_message_pay_wechat_noinstall");
    ToastUtils.showToast(activity, wechat_noinstall);
    if (callback != null) {
        callback.onResult(0, 0, wechat_noinstall);
    }
    YLog.e("ChannelAdapterWECHAT errorCode:0,errorMsg:" + wechat_noinstall);
    return;
}
我们让isWXAppInstalled方法始终返回false就可以防止游戏调用手机上的微信了,具体改法与前面的相同,这里就不再赘述。
show time
芜湖,终于写完了,下面请大家一起欣赏如何在滑雪大冒险2中速通全收集与全成就吧(手动狗头)
https://www.bilibili.com/video/BV1H24y1Y7Va/
(如果无法成功复现,可以下载我保存的原版app重试:https://wwpv.lanzoue.com/ivgP00kzwnqh 不提供成品)

在这里, 插入图片

侃遍天下无二人
OP
  


莫问刀 发表于 2023-1-16 10:47
明白了, 以后有这样的业务,就必须使用用户id,每次校验是否服务器中有实名,至于购买道具,应该支付 ...

然而老板赚了钱又不会给程序员买跑车,所以程序员会怎么实现完全看他的心情,心情要是不好,把日志全给你打出来,密钥也告诉你,顺便把jks文件也打包到apk里面,然后再跑到给github,把源码公开了
侃遍天下无二人
OP
  


云的彼岸918 发表于 2023-1-15 23:10
楼主能不能研究下小熊油耗这款app?希望能指点迷津

c5.h.s() 赋值为 true应该就可以了,依据如下:
[Java] 纯文本查看 复制代码            if (!hVar.s()) {
                r1 = hVar.j() > 0 ? 1 : 0;
                MXTipDialog mXTipDialog = new MXTipDialog(this);
                MXTipDialog.setMessage$default(mXTipDialog, r1 != 0 ? "您的会员资格已过期,暂不能使用会员专属皮肤" : "您还不是会员,暂不能使用会员专属皮肤", null, null, null, 14, null);
                MXTipBaseDialog.setActionBtn$default(mXTipDialog, r1 != 0 ? "会员续费" : "免费获得VIP", false, null, null, new b(), 14, null);
                mXTipDialog.show();
                return;
            }
签名校验的事我没管,如果有自己想办法解决
派大星丶   

辛苦了, 感谢分享, 虽然没学会,
雾都孤尔   

教程很详细,但还得自己操作试试。感谢分享。
stone989   

膜拜大佬,谢谢分享
lcg888   

有点东西厉害了
QZYabl   

膜拜大佬
Batman623   

支持作者,万分感谢分享!
zgb13005677110   

辛苦了, 感谢分享, 虽然没学会,
您需要登录后才可以回帖 登录 | 立即注册

返回顶部