当前位置: 首页 > news >正文

Toast 弹窗的排查历程

在这里插入图片描述

1. 背景

有一个游戏包被商店拘审了

拘审理由是:游戏启动后,toast 弹出显示 i m holmes~ 字样,并跳转到快应用

在这里插入图片描述

很明显的是 toast 提示:i m holmes(有意思,我是福尔摩斯🐶)

在这里插入图片描述

测试:这个问题你排查下,比较紧急,下午需要解决完(此时是上午🌞)

  • 你没有游戏工程源码,你只有一个 APK 安装包,如何排查定位 toast 显示问题
  • 测试告诉你,复现条件是:关闭开发者选项 + 关闭 USB 调试,启动游戏

2. 排查

2.1 Jadx 静态分析

A 技术:(撇了一眼)这很简单啊,使用 jadx 反编译安装包,搜索字符串 i m holmes~,就知道是哪里弹出了,再把相关代码删除掉就好了

B 技术: 打开 jadx,把安装包拖到里面反编译,搜索字符串… … 没有发现这个字符串啊

A 技术:难道字符串被加密了?

B 技术: 不排除这个可能,如果是加密那可太难搞了,先不考虑这种情况

A 技术:会不会这个字符串是在 assets 目录下的 jar、dex 文件里面

B 技术: assets 目录下确实有 jar 文件,并且文件后缀不是 jar 的,经过简单的重命名,使用 jaxd 反编译也搜索不到字符串 holmes

A 技术:这是 unity 游戏,让 unity 游戏技术在工程全局搜索看下有没有这个字符串

B 技术: 技术说也没有搜到字符串 holmes

A 技术:奇怪了!这个字符串会在哪里

初步排查未果。只能本地复现看看到底是怎么个事,再做进一步排查

2.2 Try 本地复现

上面测试说过,复现步骤是:关闭开发者选项 + 关闭 USB 调试,启动游戏

A 技术:我按照你的复现步骤,在我这个手机怎么没有复现到(此时我的设备连着电脑,USB 连接)

C 测试: 不能吧(他拿着手机走过来),你看我这手机就能复现

测试拿过我的设备,拔掉数据断开 USB 连接,确认关闭开发者选项、确认关闭 USB 调试,启动游戏

C 测试: 能复现啊,你的设备也能复现啊

A 技术:(一脸懵逼)测试没有操作什么,只是确认拔了数据线连接,确认开关关闭就复现了;难道和连接数据线也有关系?插上数据线连接设备又不能复现了

果然,现在复现步骤变成了:

  • 关闭开发者选项
  • 关闭 USB 调试
  • 断开设备数据线连接(非充电状态)

我又想到了,设备连接数据线意味着设备处于充电状态,这个充电状态很重要,后面突破的关键

2.3 Hook 吐司显示

A 技术: 使用 Frida hook Toast 显示,拿到调用堆栈,不就知道是哪里的代码调用了

B 技术: 复现条件是设备不能连接 USB,Frida hook 需要连接设备才能使用😓

A 技术:那你可以 hook 复现问题的条件,条件成立了才会显示 toast

结合 Deepseek 我们很快知道如何使用 Frida hook 上述的复现步骤条件

2.3.1 Hook 开发者选项 & USB 调试

  • 开发者选项:development_settings_enabled
  • USB 调试:adb_enabled

其实做风控的基本都会用到设备的这些信息,用于上报或控制配置下发

所以我们更清晰地知道:被风控的设备是不能复现问题(弹出 toast),我们知道把当前设备设置为非风险设备(既打破风控条件)才能复现问题

function hookUsbDevEnable() {
  let dev_enable = "development_settings_enabled"
  let usb_enable = "adb_enabled"
  let Global = Java.use("android.provider.Settings$Global");

  Global["getInt"].overload('android.content.ContentResolver', 'java.lang.String', 'int')
    .implementation = function (cr, name, def) {
      console.log(`Global 重载一:name=${name}, def=${def}`);
		
      var ans = this["getInt"](cr, name, def);
      //ans = 1 表示开启
      //ans = 0 表示关闭
      if (name === dev_enable) {
        console.log(`\t是否开启 开发者选项=${ans}`);
        printStack()
      } else if (name === usb_enable) {
        console.log(`\t是否开启 USB 调试=${ans}`);
        printStack()
      }
      return ans
    };


  Global["getInt"].overload('android.content.ContentResolver', 'java.lang.String')
    .implementation = function (cr, name) {
      console.log(`Global 重载二: name=${name}`);

      var ans = this["getInt"](cr, name);
      //ans = 1 表示开启
      //ans = 0 表示关闭
      if (name === dev_enable) {
        console.log(`\t是否开启 开发者选项=${ans}`);
        printStack()
      } else if (name === usb_enable) {
        console.log(`\t是否开启 USB 调试=${ans}`);
        printStack()
      }
      return ans
    };


  let Secure = Java.use("android.provider.Settings$Secure");
  Secure["getInt"].overload('android.content.ContentResolver', 'java.lang.String', 'int')
    .implementation = function (cr, name, def) {
      console.log(`Secure 重载一:name=${name}, def=${def}`);

      var ans = this["getInt"](cr, name, def);
      //ans = 1 表示开启
      //ans = 0 表示关闭
      if (name === dev_enable) {
        console.log(`\t是否开启 开发者选项=${ans}`);
        printStack()
      } else if (name === usb_enable) {
        console.log(`\t是否开启 USB 调试=${ans}`);
        printStack()
      } else {
        console.log(`\tans=${ans}`);
      }

      return ans
    };


  Secure["getInt"].overload('android.content.ContentResolver', 'java.lang.String')
    .implementation = function (cr, name) {
      console.log(`Secure 重载二: name=${name}`);

      var ans = this["getInt"](cr, name);
      //ans = 1 表示开启
      //ans = 0 //表示关闭
      if (name === dev_enable) {
        console.log(`\t是否开启 开发者选项=${ans}`);
        printStack()
      } else if (name === usb_enable) {
        console.log(`\t是否开启 USB 调试=${ans}`);
        printStack()
      }
      return ans
    };
}

2.3.2 Hook 充电状态

    /**
     * Extra for {@link android.content.Intent#ACTION_BATTERY_CHANGED}:
     * integer indicating whether the device is plugged in to a power
     * source; 0 means it is on battery, other constants are different
     * types of power sources.
     */
    public static final String EXTRA_PLUGGED = "plugged";

	// 设备充电状态
    // values for "status" field in the ACTION_BATTERY_CHANGED Intent
    public static final int BATTERY_STATUS_UNKNOWN = Constants.BATTERY_STATUS_UNKNOWN;
    public static final int BATTERY_STATUS_CHARGING = Constants.BATTERY_STATUS_CHARGING;
    public static final int BATTERY_STATUS_DISCHARGING = Constants.BATTERY_STATUS_DISCHARGING;
    public static final int BATTERY_STATUS_NOT_CHARGING = Constants.BATTERY_STATUS_NOT_CHARGING;
    public static final int BATTERY_STATUS_FULL = Constants.BATTERY_STATUS_FULL;
function hookCharging() {
  var Intent = Java.use('android.content.Intent')
  Intent.getIntExtra.implementation = function (key, def) {
    var ans = this.getIntExtra(key, def);
    if (key == "status") {
      //ans = -1 //表示非充电状态
      console.log('\t充电:status================  ' + ans)
      printStack()
    } else if (key == "plugged") {
      //ans = -1 //表示非充电状态
      console.log('\t充电:ans plugged================  ' + ans)
      printStack()
    }
    return ans
  }
}

hook Toast

function hookToastShow() {
  var Toast = Java.use('android.widget.Toast')
  Toast.show.implementation = function () {
    console.log('Toastshow ================  ')
    printStack()
    this.show();
  }
}

打印当前堆栈

function printStack() {
	console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
}

如何执行上述的 hook 脚本,Frida 安装和使用自行搜索,执行下面命令加载脚本并应用到执行的应用

frida -U -f 包名 -l .\a.js

//a.js
Java.perform(function x() {
    console.log('重新加载脚本');

    hookUsbDevEnable()

    hookCharging()

    hookToastShow()

    hookFileCreate()

    hookDexLoader()

    hookClassLoader()
})

脚本一执行,修改 hook 方法返回值:关闭开发者选项 + 关闭 USB 调试 + 修改充电状态

能在设备连接数据线情况下复现问题,拿到了 toast 显示的堆栈信息

toast 显示时,输出堆栈:

Toastshow ================  
java.lang.Throwable
        at android.widget.Toast.show(Native Method)
        at holmes.bridge.core.AcceptorImpl.onTactful(AcceptorImpl.java:30)

2.4 Dex 动态加载

堆栈输出了,以为在 jadx 直接能搜索到这个类holmes.bridge.core.AcceptorImpl

又愣了一下,连holmes这个包名都没有,更别说这个类了,那代码到底在哪里执行的?

使用 root 手机查看应用目录,发现了 classex.dex

2.4.1 classes.dex

在路径下面目录发现了一个 dex 文件:

  • /data/data/包名/files/walk/classex.dex
  • /data/user/0/包名/files/walk/classex.dex

在这里插入图片描述

导出文件查看,果然 Toast 代码在这里执行的

在这里插入图片描述

2.4.2 hook Dex Loader

能够知道这个 dex 是在哪里加载的呢?,hook 看到没有明显有用的日志,未果

function hookDexLoader() {
    // .overload('[Ljava.nio.ByteBuffer;', 'java.lang.String', 'java.lang.ClassLoader')
    // .overload('java.lang.String', 'java.io.File', 'java.lang.String', 'java.lang.ClassLoader')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;')
    // .overload('java.lang.String', 'java.io.File', 'java.lang.String', 'java.lang.ClassLoader', 'boolean')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;', 'boolean')

    const DexClassLoader = Java.use("dalvik.system.DexClassLoader");

    DexClassLoader.$init.overload("java.lang.String", "java.lang.String", "java.lang.String", "java.lang.ClassLoader")
        .implementation = function (dexPath, optimizedDirectory, librarySearchPath, parent) {
            console.log(`DexClassLoader: dexPath=${dexPath}, 
                optimizedDirectory=${optimizedDirectory},
                 librarySearchPath=${librarySearchPath},
                  parent=${parent}`);

            this.$init(dexPath, optimizedDirectory, librarySearchPath, parent);
        };


    const BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");
    BaseDexClassLoader.$init.overload('[Ljava.nio.ByteBuffer;', 'java.lang.String', 'java.lang.ClassLoader')
        .implementation = function (dexFiles, librarySearchPath, parent) {
            console.log(`DexClassLoader: dexFiles=${dexFiles}, 
                 librarySearchPath=${librarySearchPath},
                  parent=${parent}`);

            this.$init(dexFiles, librarySearchPath, parent);
        };

    BaseDexClassLoader.$init.overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;', 'boolean')
        .implementation = function (dexPath, librarySearchPath, parent, sharedLibraryLoaders, sharedLibraryLoadersAfter, isTrusted) {
            console.log(`DexClassLoader: dexPath=${dexPath}, 
                 librarySearchPath=${librarySearchPath},
                  parent=${parent},
                  sharedLibraryLoaders=${sharedLibraryLoaders},
                  sharedLibraryLoadersAfter=${sharedLibraryLoadersAfter},
                  isTrusted=${isTrusted}`);

            this.$init(dexPath, librarySearchPath, parent, sharedLibraryLoaders, sharedLibraryLoadersAfter, isTrusted);
        };
}

2.4.3 hook Class Loader

hook AcceptorImpl 类加载,执行脚本却只有一点点日志:

hookClassLoader: 找到加载的类 = holmes/bridge/core/AcceptorImpl
java.lang.Throwable
        at java.lang.ClassLoader.loadClass(Native Method)
function hookClassLoader() {
    const ClassLoader = Java.use("java.lang.ClassLoader");

    ClassLoader.loadClass.overload("java.lang.String").implementation = function (name) {
        if (name.includes("holmes")) {
            console.log("hookClassLoader: 找到加载的类 = " + name);
            printStack()

        }
        return this.loadClass(name);
    };
}

2.4.4 hook 文件创建

那么该 classex.dex 文件是如何存在的呢?网络下载吗?

hook 文件创建,也没有看到更多有用的堆栈,不知道是在哪里保存的

堆栈这么少,可能是在 native 下载保存?

创建文件 2 filename=/data/user/0/com.dz.tcsdj.honor/files/walk/bridge
找到 walk 路径
java.lang.Throwable
        at java.io.File.<init>(Native Method)
function hookFileCreate() {
  var File = Java.use('java.io.File')
  let initMethodName = '$init';
  let overloadedMethods = File[initMethodName].overloads;

  for (const constructionMethod of overloadedMethods) {
    constructionMethod.implementation = function () {
      let result = this[initMethodName].apply(this, arguments);
      let arg0 = arguments[0];

      //本次我们只处理第一个参数是 string 类型的构造函数
      if (typeof arg0 === 'string' && arg0 !== undefined && arg0.length == 1) {
        console.log('创建文件 1 filename=' + arg0)
        if (arg0.includes("walk")) {
          console.log('找到 walk 路径')
          printStack()
        }
      }

      if (arguments.length == 2) {
        let arg1 = arguments[1];
        if (typeof arg0 === 'string' && typeof arg1 === 'string') {
          let filename = arg0 + arg1;
          console.log('创建文件 2 filename=' + filename)
          if (filename.includes("walk")) {
            console.log('找到 walk 路径')
            printStack()
          }
        }
      }

      return result;
    }
  }
}

后续稍微跟踪了下,基本可以确定是在 native 动态下载、保存到应用目录,然后加载

由于 native 技术不行,就到此为止了

2.5 完整代码

Java.perform(function x() {
    console.log('重新加载脚本');

    hookUsbDevEnable()

    hookCharging()

    hookToastShow()

    hookFileCreate()

    hookDexLoader()

    hookClassLoader()
})

function hookToastShow() {
    var Toast = Java.use('android.widget.Toast')
    Toast.show.implementation = function () {
        console.log('Toastshow ================  ')
        this.show();
    }
}

function hookCharging() {
    var Intent = Java.use('android.content.Intent')
    Intent.getIntExtra.implementation = function (key, def) {
        var ans = this.getIntExtra(key, def);
        if (key == "status") {
            //表示非充电状态,调试时打开注释
            ans = -1
            console.log('\t充电:status================  ' + ans)
        } else if (key == "plugged") {
            //表示非充电状态,调试时打开注释
            ans = -1
            console.log('\t充电:ans plugged================  ' + ans)
        }
        return ans
    }
}

function hookUsbDevEnable() {
    let dev_enable = "development_settings_enabled"
    let usb_enable = "adb_enabled"
    let Global = Java.use("android.provider.Settings$Global");

    Global["getInt"].overload('android.content.ContentResolver', 'java.lang.String', 'int')
        .implementation = function (cr, name, def) {
            console.log(`Global 重载一:name=${name}, def=${def}`);

            var ans = this["getInt"](cr, name, def);
            if (name === dev_enable) {
                //表示开启开发者选项,调试时打开注释
                ans = 0
                console.log(`\t是否开启 开发者选项=${ans}`);
            } else if (name === usb_enable) {
                //表示开启 USB 调试,调试时打开注释
                ans = 0
                console.log(`\t是否开启 USB 调试=${ans}`);
            }
            return ans
        };


    Global["getInt"].overload('android.content.ContentResolver', 'java.lang.String')
        .implementation = function (cr, name) {
            console.log(`Global 重载二: name=${name}`);

            var ans = this["getInt"](cr, name);
            if (name === dev_enable) {
                //表示开启开发者选项,调试时打开注释
                ans = 0
                console.log(`\t是否开启 开发者选项=${ans}`);
            } else if (name === usb_enable) {
                //表示开启 USB 调试,调试时打开注释
                ans = 0
                console.log(`\t是否开启 USB 调试=${ans}`);
            }
            return ans
        };


    let Secure = Java.use("android.provider.Settings$Secure");
    Secure["getInt"].overload('android.content.ContentResolver', 'java.lang.String', 'int')
        .implementation = function (cr, name, def) {
            console.log(`Secure 重载一:name=${name}, def=${def}`);

            var ans = this["getInt"](cr, name, def);
            if (name === dev_enable) {
                //表示开启开发者选项,调试时打开注释
                ans = 0
                console.log(`\t是否开启 开发者选项=${ans}`);
            } else if (name === usb_enable) {
                //表示开启 USB 调试,调试时打开注释
                ans = 0
                console.log(`\t是否开启 USB 调试=${ans}`);
            }

            return ans
        };


    Secure["getInt"].overload('android.content.ContentResolver', 'java.lang.String')
        .implementation = function (cr, name) {
            console.log(`Secure 重载二: name=${name}`);

            var ans = this["getInt"](cr, name);
            if (name === dev_enable) {
                //表示开启开发者选项,调试时打开注释
                ans = 0
                console.log(`\t是否开启 开发者选项=${ans}`);
            } else if (name === usb_enable) {
                //表示开启 USB 调试,调试时打开注释
                ans = 0
                console.log(`\t是否开启 USB 调试=${ans}`);
            }
            return ans
        };
}

function hookClassLoader() {
    const ClassLoader = Java.use("java.lang.ClassLoader");

    ClassLoader.loadClass.overload("java.lang.String").implementation = function (name) {
        if (name.includes("holmes")) {
            console.log("hookClassLoader: 找到加载的类 = " + name);
            printStack()

        }
        return this.loadClass(name);
    };
}

function hookDexLoader() {
    // .overload('[Ljava.nio.ByteBuffer;', 'java.lang.String', 'java.lang.ClassLoader')
    // .overload('java.lang.String', 'java.io.File', 'java.lang.String', 'java.lang.ClassLoader')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;')
    // .overload('java.lang.String', 'java.io.File', 'java.lang.String', 'java.lang.ClassLoader', 'boolean')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;')
    // .overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;', 'boolean')

    const DexClassLoader = Java.use("dalvik.system.DexClassLoader");

    DexClassLoader.$init.overload("java.lang.String", "java.lang.String", "java.lang.String", "java.lang.ClassLoader")
        .implementation = function (dexPath, optimizedDirectory, librarySearchPath, parent) {
            console.log(`DexClassLoader: dexPath=${dexPath}, 
                optimizedDirectory=${optimizedDirectory},
                 librarySearchPath=${librarySearchPath},
                  parent=${parent}`);

            this.$init(dexPath, optimizedDirectory, librarySearchPath, parent);
        };


    const BaseDexClassLoader = Java.use("dalvik.system.BaseDexClassLoader");
    BaseDexClassLoader.$init.overload('[Ljava.nio.ByteBuffer;', 'java.lang.String', 'java.lang.ClassLoader')
        .implementation = function (dexFiles, librarySearchPath, parent) {
            console.log(`DexClassLoader: dexFiles=${dexFiles}, 
                 librarySearchPath=${librarySearchPath},
                  parent=${parent}`);

            this.$init(dexFiles, librarySearchPath, parent);
        };

    BaseDexClassLoader.$init.overload('java.lang.String', 'java.lang.String', 'java.lang.ClassLoader', '[Ljava.lang.ClassLoader;', '[Ljava.lang.ClassLoader;', 'boolean')
        .implementation = function (dexPath, librarySearchPath, parent, sharedLibraryLoaders, sharedLibraryLoadersAfter, isTrusted) {
            console.log(`DexClassLoader: dexPath=${dexPath}, 
                 librarySearchPath=${librarySearchPath},
                  parent=${parent},
                  sharedLibraryLoaders=${sharedLibraryLoaders},
                  sharedLibraryLoadersAfter=${sharedLibraryLoadersAfter},
                  isTrusted=${isTrusted}`);

            this.$init(dexPath, librarySearchPath, parent, sharedLibraryLoaders, sharedLibraryLoadersAfter, isTrusted);
        };
}

function hookFileCreate() {
    var File = Java.use('java.io.File')
    let initMethodName = '$init';
    let overloadedMethods = File[initMethodName].overloads;

    for (const constructionMethod of overloadedMethods) {
        constructionMethod.implementation = function () {
            let result = this[initMethodName].apply(this, arguments);
            let arg0 = arguments[0];

            //本次我们只处理第一个参数是 string 类型的构造函数
            if (typeof arg0 === 'string' && arg0 !== undefined && arg0.length == 1) {
                console.log('创建文件 1 filename=' + arg0)
                if (arg0.includes("walk")) {
                    console.log('找到 walk 路径')
                    printStack()
                }
            }

            if (arguments.length == 2) {
                let arg1 = arguments[1];
                if (typeof arg0 === 'string' && typeof arg1 === 'string') {
                    let filename = arg0 + arg1;
                    console.log('创建文件 2 filename=' + filename)
                    if (filename.includes("walk")) {
                        console.log('找到 walk 路径')
                        printStack()
                    }
                }
            }

            return result;
        }
    }
}

function printStack() {
    console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
}

相关文章:

  • 【MySQL 一 数据库基础】深入解析 MySQL 的索引
  • 第三届通信网络与机器学习国际学术会议(CNML 2025)
  • 讲解一下SpringBoot的RPC连接
  • 机器学习 - 特征学习(表示学习)
  • 【OJ项目】深入剖析题目接口控制器:功能、实现与应用
  • 【计算机毕业设计】Spring Boot教师人事档案管理系统功能说明
  • Python爬虫框架 - 实际项目(拿到可以直接用)
  • 中望CAD c#二次开发 ——VS环境配置
  • 【Getting Started】-数据结构介绍-Introduction to Data Structures
  • 异步加载和协程+Unity特殊文件夹
  • PySpark查询Dataframe中包含乱码的数据记录的方法
  • React Native之React整理(一)
  • K8s组件
  • 「软件设计模式」建造者模式(Builder)
  • Java--IO流详解(下)--相互转换(含Properties详解)
  • 强化 CSS 样式优先级的多种方法
  • Linux基础20-C语言篇之流程控制Ⅰ【入门级】
  • 利用Python和SQLite进行数据处理与优化——从数据库操作到高级数据压缩
  • CMake技术细节:递归搜索目录添加源文件
  • 【C语言】C语言 停车场管理系统的设计与实现(源码)【独一无二】
  • 湖南省邵阳市副市长仇珂静主动向组织交代问题,接受审查调查
  • 体坛联播|双杀阿森纳,巴黎晋级欧冠决赛对阵国际米兰
  • 谢晖不再担任中超长春亚泰队主教练:战绩不佳主动请辞
  • 央行:增加科技创新和技术改造再贷款额度3000亿元
  • 从黄土高原到黄浦江畔,澄城樱桃品牌推介会明日在上海举办
  • 印巴战火LIVE|巴基斯坦多地遭印度导弹袭击,巴总理称“有权作出适当回应”