记一次安卓逆向的学习和实践

Grub

Grub

· 3 min read
Thumbnail

> 新手就是爱记录。

起因

某个长辈找到我,发给我一个 APK,让我尝试破解一下软件授权码。

Image

Target.APK(我应该早就想到这是个开源软件被盗卖的)

> 本次学习完全依赖 Google Gemini 3 Pro Preview

零:从 JADX 开始

直接用 JADX 打开 APK 文件,全局搜索“验证”,无果。说明此 APK 至少有一定程度的代码混淆。

res/layout 下挨个查看布局文件,试图找到和上图类似的布局(至少一个 Input 和三个连着的 Button),无果,这时我就已经有点奇怪了。

此时 Gemini 建议我看 Smali 代码,但是 Smali 代码看得我一头雾水,完全建立不起上图 UI 和 Smali 代码的关系。而且 Smali 代码的量极少,与整个 APP 对应不上。

一:ADB 参上

Gemini 认为此时我的关键任务在于,确定这个弹窗是一个固定的 Activity 还是 AlertDialog。

为解决上述任务,使用 Android SDK 中的 uiautomator 进行扫描,结果如下。

<?xml version='1.0' encoding='UTF-8' standalone='yes'?>
<hierarchy rotation="0">
  <node index="0" text="" resource-id="" class="android.widget.FrameLayout"
    package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false" checked="false"
    clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
    long-clickable="false" password="false" selected="false" bounds="[27,1005][1053,1449]"
    drawing-order="0" hint="">
    <node index="0" text="" resource-id="" class="android.widget.FrameLayout"
      package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false" checked="false"
      clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
      long-clickable="false" password="false" selected="false" bounds="[72,1050][1008,1404]"
      drawing-order="1" hint="">
      <node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout"
        package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false" checked="false"
        clickable="false" enabled="true" focusable="false" focused="false" scrollable="false"
        long-clickable="false" password="false" selected="false" bounds="[72,1050][1008,1404]"
        drawing-order="1" hint="">
        <node index="0" text="" resource-id="android:id/parentPanel"
          class="android.widget.LinearLayout" package="com.alibaba.android.rimet.zrhgz"
          content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
          focusable="false" focused="false" scrollable="false" long-clickable="false"
          password="false" selected="false" bounds="[72,1050][1008,1404]" drawing-order="1" hint="">
          <node index="0" text="" resource-id="android:id/customPanel"
            class="android.widget.FrameLayout" package="com.alibaba.android.rimet.zrhgz"
            content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
            focusable="false" focused="false" scrollable="false" long-clickable="false"
            password="false" selected="false" bounds="[72,1050][1008,1404]" drawing-order="3"
            hint="">
            <node index="0" text="" resource-id="android:id/custom"
              class="android.widget.FrameLayout" package="com.alibaba.android.rimet.zrhgz"
              content-desc="" checkable="false" checked="false" clickable="false" enabled="true"
              focusable="false" focused="false" scrollable="false" long-clickable="false"
              password="false" selected="false" bounds="[72,1050][1008,1404]" drawing-order="1"
              hint="">
              <node index="0" text="" resource-id="" class="android.widget.LinearLayout"
                package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                checked="false" clickable="false" enabled="true" focusable="false" focused="false"
                scrollable="false" long-clickable="false" password="false" selected="false"
                bounds="[72,1050][1008,1404]" drawing-order="1" hint="">
                <node index="0" text="欢迎使用" resource-id="" class="android.widget.TextView"
                  package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                  checked="false" clickable="false" enabled="true" focusable="false" focused="false"
                  scrollable="false" long-clickable="false" password="false" selected="false"
                  bounds="[105,1083][975,1151]" drawing-order="1" hint="" />
                <node index="1" text="请输入正版授权码!" resource-id="" class="android.widget.EditText"
                  package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                  checked="false" clickable="true" enabled="true" focusable="true" focused="true"
                  scrollable="false" long-clickable="true" password="false" selected="false"
                  bounds="[105,1167][975,1243]" drawing-order="2" hint="请输入正版授权码!" />
                <node index="2" text="" resource-id="" class="android.view.View"
                  package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                  checked="false" clickable="false" enabled="true" focusable="false" focused="false"
                  scrollable="false" long-clickable="false" password="false" selected="false"
                  bounds="[105,1243][975,1245]" drawing-order="3" hint="" />
                <node index="3" text="" resource-id="" class="android.widget.LinearLayout"
                  package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                  checked="false" clickable="false" enabled="true" focusable="false" focused="false"
                  scrollable="false" long-clickable="false" password="false" selected="false"
                  bounds="[105,1253][975,1388]" drawing-order="4" hint="">
                  <node index="0" text="粘贴" resource-id="" class="android.widget.Button"
                    package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                    checked="false" clickable="true" enabled="true" focusable="true" focused="false"
                    scrollable="false" long-clickable="false" password="false" selected="false"
                    bounds="[110,1253][390,1388]" drawing-order="1" hint="" />
                  <node index="1" text="解绑" resource-id="" class="android.widget.Button"
                    package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                    checked="false" clickable="true" enabled="true" focusable="true" focused="false"
                    scrollable="false" long-clickable="false" password="false" selected="false"
                    bounds="[400,1253][680,1388]" drawing-order="2" hint="" />
                  <node index="2" text="验证" resource-id="" class="android.widget.Button"
                    package="com.alibaba.android.rimet.zrhgz" content-desc="" checkable="false"
                    checked="false" clickable="true" enabled="true" focusable="true" focused="false"
                    scrollable="false" long-clickable="false" password="false" selected="false"
                    bounds="[690,1253][970,1388]" drawing-order="3" hint="" />
                </node>
              </node>
            </node>
          </node>
        </node>
      </node>
    </node>
  </node>
</hierarchy>

这是一个标准 AlertDialog 中嵌入了自定义 View。但是以上 XML 对逆向没有任何作用。因为它既没有 id,里面的文案(包括 AlertDialog 这个字段)都无法再 JADX 中全局搜索到有效结果。

此时 Gemini 推荐我使用 adb shell am profile 来获得一个调用栈,但是当我执行该命令时,出现报错:

Process not debuggable, and not profileable by shell: com.alibaba.android.rimet.zrhgz

这是因为这个 APK 在 AndroidManifest.xml 中没有开启 android:debuggable="true",因此无法调试。Gemini 认为解决方法有两种,第一种是在 AndroidManifest.xml 中添加 android:debuggable="true",并重新打包签名;第二种是通过 Root 权限来调试。首先尝试了第一种方法,使用 apktool 来进行重新打包的操作,但是一直报错(后面找到了这一问题的原因),无法解决,放弃了这种方法。随后尝试了第二种方法,我花了一些时间把自己的 OnePlus 8T 刷入了 Magisk,并通过 adb shell su 提权,但是仍然没有任何用处。

二:Frida 出场

在前述方法行不通后,Gemini 建议我尝试 Frida 进行 HOOK。Frida 是安卓逆向中的“核武器”。它的核心优势在于:它不需要 APK 是可调试的(debuggable),也不需要重打包。 只要你的手机/模拟器有 Root 权限,Frida 就可以直接注入到正在运行的进程中,实时修改逻辑。

首先利用 Frida,定位 setOnClickListener,脚本如下。

Java.perform(function () {
  console.log("[*] 开始监听 setOnClickListener...");

  // 获取 View 和 TextView 类
  var View = Java.use("android.view.View");
  var TextView = Java.use("android.widget.TextView");

  // Hook setOnClickListener 方法
  View.setOnClickListener.implementation = function (listener) {
    // 只有当 listener 不为空时才检测
    if (listener != null) {
      try {
        // 尝试把当前 View 转成 TextView (Button 也是 TextView 的子类)
        var currentView = Java.cast(this, TextView);
        // 获取按钮上的文字
        var text = currentView.getText().toString();

        // 检查文字里是否包含 "验证" (根据你之前的 XML,按钮文字是 "验证")
        if (text.indexOf("验证") !== -1) {
          console.log("=====================================");
          console.log("[+] 发现目标按钮!Text: " + text);
          // 打印监听器的类名!这就是你要找的类!
          console.log("[+] 监听器类名 (Listener Class): " + listener.$className);
          console.log("=====================================");
        }
      } catch (e) {
        // 如果不是 TextView,或者是图片按钮,这里会报错,忽略即可
      }
    }
    // 继续执行原有的 setOnClickListener,保证 APP 不崩溃
    this.setOnClickListener(listener);
  };
});

输出结果如下。

[+] 监听器类名 (Listener Class): com.example.applica.̙

值得注意的是,applica. 后面有一个非 ASCII 字符,这是代码混淆的结果。此外,JADX 依旧搜索不到这个类名。

此时 Gemini 认为,这个 APP 使用了“动态加载”技术(Dynamic Loading)或“加壳”保护。到这儿已经是二战转折点了。

Gemini 建议我继续使用 frida-dexdump,把这个 APP 在内存中的 DEX Dump 下来。我照做了,得到了五六十个 DEX 文件,一次性全部导入 JADX,仍然找不到想要的类名。

在我随便乱看 JADX 的时候,发现这个包存在一个 libjiagu.so 的链接库。必应一搜,之前所有的疑问都得到了回答:这个 APP 使用了 360 加固,也就是“加壳”了。

我简单检索了一下,想要完整的脱壳不是我这个水平能做完的事情。因此转而继续用 Frida HOOK,看看能不能得到一些更多的信息,或者跳过授权验证。

Gemini 给了我两个 Frida 脚本。首先是探测类名的脚本。

Java.perform(function() {
  console.log("[*] 开始枚举所有 ClassLoader...");

  // 这里的类名必须和你 JADX 里看到的一模一样,可以直接复制那个怪字
  // 如果你实在复制不了,下面会提供另一种按包名搜索的方法
  var targetClassName = "com.example.applica.̙"; 

  var found = false;

  Java.enumerateClassLoaders({
    onMatch: function(loader) {
      try {
        // 打印 Loader 的信息
        console.log("--------------------------------------------------");
        console.log("[*] 发现 Loader: " + loader);

        // 尝试在这个 Loader 的上下文中查找目标类
        // 我们暂时切换 Frida 的默认 Loader 到当前这个
        var oldLoader = Java.classFactory.loader;
        Java.classFactory.loader = loader;

        try {
          var targetClass = Java.use(targetClassName);
          console.log("    SUCCESS!!! >>> 在这个 Loader 里找到了目标类! <<<");
          console.log("    类对象: " + targetClass);
          found = true;

          // --- 既然找到了,就在这里测试一下它的方法 ---
          var methods = targetClass.class.getDeclaredMethods();
          console.log("    该类包含方法:");
          for(var i=0; i<methods.length; i++){
            console.log("    - " + methods[i].getName());
          }

        } catch (err) {
          // 这个 Loader 里没有,忽略错误
          // console.log("    (未找到)"); 
        } finally {
          // 恢复 Loader,以免影响后续遍历
          Java.classFactory.loader = oldLoader;
        }

      } catch (e) {
        console.log("Exception: " + e);
      }
    },
    onComplete: function() {
      console.log("--------------------------------------------------");
      console.log("[*] 枚举结束。");
      if (!found) {
        console.log("[!] 警告:所有 Loader 遍历完了都没找到。");
        console.log("可能的原本:");
        console.log("1. 类名写错了(那个怪字符复制不对)。");
        console.log("2. APP 还没运行到加载那个类的地方(请确保已经进入了输入授权码界面)。");
        console.log("3. 360 甚至隐藏了类名(极少见)。");
      }
    }
  });
});

这个脚本确实成功找到了要找的类名。

Gemini 认为 Button 触发事件的逻辑大致如下:

function onClick() {
  if (Verify(text.getText().toString())) {
    alertDialog.close();
  } else {
    ...
  }
}

因此第二个脚本是 HOOK 所有的 Boolean 返回值的函数,让这些函数全部返回 True。这个脚本使得 APP 的行为发生了变化,但并不是预期的结果。现在 Toast 提示“检测到 VPN,请关闭 VPN”,我猜测是不是盲目的把所有的 Boolean 函数都设置为 True 导致的,Gemini 赞同我的说法,给我了一个完善逻辑后的新脚本,但是依然不管用。而且 360 加固貌似是检测到了 Frida HOOK,后面几次测试一直在执行脚本超时。至此 Frida 彻底退场。

三:Xposed 解决

既然电脑辅助 Dump 没出路,转而尝试安卓的 Xposed 框架。在尝试了反射大师、FDEX2 等模块后,一个叫 Layout Inspect 的模块能够在我的 Android 16 环境里正常的运行。

这个模块直接对 APP 进行降维打击了,直接能把最上面这个 Activity 隐藏,授权验证也就绕过去了。

Image

但是还没完,我的目标是得到这个 APP 授权码的加密算法。正巧,Layout Inspect 也可以导出 DEX,抱着试一下的心态导入到 JADX 里面看一下。这个 DEX 里面真的有我想要的代码!

四:以 JADX 结束

com.example.applica下面,经过一番确认,能够锁定 ViewOnClickListenerC0097 就是“验证”按钮的监听器。

Image

直接看 onClick方法实现,毕竟是混淆过的汇编,转成 Java 代码读起来很反人类,但是很有意思。

public void onClick(View view) {
    String strM294 = C0101.m294(C0101.m283(C0098.m119(m117(this))));
    boolean zM153 = C0098.m153(strM294);
    int i4 = 1616;
    while (true) {
        i4 ^= 1633;
        switch (i4) {
            case 14:
            case UIMsg.k_event.V_WM_STREET_JUMP /* 49 */:
                i4 = zM153 ? 1709 : 1678;
            case 204:
                C0099.m178(C0099.m198(m115(this), C0102.m298(m116(), 0, C0099.f50 ^ (-974), 1832), 0));
                int i5 = 1740;
                while (true) {
                    i5 ^= 1757;
                    switch (i5) {
                        case 17:
                            i5 = 1771;
                            continue;
                        case 54:
                            break;
                    }
                }
                break;
            case 239:
                C0098.m123(strM294, m115(this));
                break;
        }
    }
    int iM262 = C0101.m262();
    int i6 = 1864;
    while (true) {
        i6 ^= 1881;
        switch (i6) {
            case 17:
                i6 = iM262 <= 0 ? 48736 : 48705;
            case 47384:
                break;
            case 47417:
                System.out.println(Long.valueOf(C0098.m138("BuR7fYXbe0pot")));
                break;
            case 47483:
        }
        return;
    }
}

比如说第 5 ~ 29 行的 while 循环,虽然是 while 循环,但其实更像是针对 i4构建的状态机,本质是却是由 if 主导的控制逻辑。

首先 strM294 就是 Input 的字符串,zM153 验证了 strM294 非空。i4 初始为 1616,第一次进循环先异或 1633,i4变为 49,执行 i4 = zM153 ? 1709 : 1678;。如果字符串为空,下一轮 i4 = 1709 ^ 1633 = 204,而 case 204 实际上是显示一个 Toast;如果字符串不为空,下一轮 i4 = 1678 ^ 1633 = 239,而 case 239 实际上是起了一个新的 Thread,运行的内容是 RunnableC0094

下面是在 Playground 里面打印了 Toast 的文本,这个 APP 的混淆其实就是按位与。

grub@thinkstation ~/w/javatest> cat Main.java
import java.io.*;

public class Main {
        public static String m298(short[] sArr, int i4, int i5, int i6) {
                char[] cArr = new char[i5];
                for (int i7 = 0; i7 < i5; i7++) {
                        cArr[i7] = (char) (sArr[i4 + i7] ^ i6);
                }
                return new String(cArr);
        }
        private static final short[] f46short = {-29473, -30533, 22093, 25760, 24683, 32553};
        public static void main(String[] args) {
                System.out.println(m298(f46short, 0, (-972) ^ (-974), 1832));
        }
}
grub@thinkstation ~/w/javatest> java Main.java
请输入授权码

下面是 RunnableC0094run() 方法,比较遗憾的是这个授权码最终是发送给服务器进行验证的,本地没有加密算法。不过能走到这儿也学了不少东西,收获挺多了。

public void run() {
    String strM148 = C0098.m148(m107(), 0, C0098.f49 ^ (-774), 1560);
    String strM233 = C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), m102()), C0100.m241(m107(), 5, C0100.f51 ^ (-832), 480)));
    String strM218 = C0100.m218(m104(this));
    String strM2332 = C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0098.m165(C0098.m127(C0100.m252()), C0098.m148(m107(), 23, 1, 3158), C0100.m220())), m108()))), strM218));
    Long l4 = new Long(C0101.m261() / (C0102.f53 ^ (-673)));
    StringBuffer stringBuffer = new StringBuffer();
    StringBuffer stringBuffer2 = new StringBuffer();
    StringBuffer stringBuffer3 = new StringBuffer();
    StringBuffer stringBuffer4 = new StringBuffer();
    StringBuffer stringBuffer5 = new StringBuffer();
    StringBuffer stringBufferM250 = C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0098.m148(m107(), 24, C0101.f52 ^ (-629), 1352)), m106(this))));
    String strM1482 = C0098.m148(m107(), 29, C0098.f49 ^ (-779), 807);
    StringBuffer stringBufferM2502 = C0100.m250(stringBuffer4, C0100.m233(C0100.m250(C0100.m250(stringBuffer5, C0100.m233(C0100.m250(stringBufferM250, strM1482))), strM218)));
    String strM298 = C0102.m298(m107(), 39, C0100.f51 ^ (-815), 1248);
    String strM194 = C0099.m194(C0100.m233(C0100.m250(C0100.m250(stringBuffer, C0100.m233(C0100.m250(C0100.m250(stringBuffer2, C0100.m233(C0099.m177(C0100.m250(stringBuffer3, C0100.m233(C0100.m250(stringBufferM2502, strM298))), l4))), C0098.m148(m107(), 42, 1, 1403)))), m108())));
    StringBuffer stringBuffer6 = new StringBuffer();
    StringBuffer stringBuffer7 = new StringBuffer();
    StringBuffer stringBuffer8 = new StringBuffer();
    StringBuffer stringBuffer9 = new StringBuffer();
    StringBuffer stringBuffer10 = new StringBuffer();
    StringBuffer stringBuffer11 = new StringBuffer();
    StringBuffer stringBuffer12 = new StringBuffer();
    StringBuffer stringBuffer13 = new StringBuffer();
    StringBuffer stringBuffer14 = new StringBuffer();
    String strM2982 = C0102.m298(m107(), 43, C0099.f50 ^ (-975), 1491);
    String strM2333 = C0100.m233(C0100.m250(C0100.m250(stringBuffer6, C0100.m233(C0100.m250(C0100.m250(stringBuffer7, C0100.m233(C0099.m177(C0100.m250(stringBuffer8, C0100.m233(C0100.m250(C0100.m250(stringBuffer9, C0100.m233(C0100.m250(C0100.m250(stringBuffer10, C0100.m233(C0100.m250(C0100.m250(stringBuffer11, C0100.m233(C0100.m250(C0100.m250(stringBuffer12, C0100.m233(C0100.m250(C0100.m250(stringBuffer13, C0100.m233(C0100.m250(C0100.m250(stringBuffer14, strM2982), m105()))), C0100.m241(m107(), 48, C0099.f50 ^ (-974), 688)))), m106(this)))), strM1482))), strM218))), strM298))), l4))), C0100.m241(m107(), 54, C0099.f50 ^ (-974), 1177)))), strM194));
    try {
        JSONObject jSONObject = new JSONObject(C0100.m236(C0098.m156(C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), strM233), strM2333))), strM2982))), m105())), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0098.m148(m107(), 60, C0101.f52 ^ (-629), 536)), C0100.m207(strM2333, m103(), strM148)))), C0098.m148(m107(), 65, C0098.f49 ^ (-776), 1356)))), strM2332))), m103(), strM148));
        String strM235 = C0100.m235(jSONObject, C0100.m241(m107(), 72, C0102.f53 ^ (-333), 766));
        String strM2352 = C0100.m235(jSONObject, C0101.m276(m107(), 76, C0098.f49 ^ (-772), 2519));
        boolean zM130 = C0098.m130(strM235, C0098.m148(m107(), 79, C0102.f53 ^ (-332), 483));
        int i4 = 1616;
        while (true) {
            i4 ^= 1633;
            switch (i4) {
                case 14:
                case UIMsg.k_event.V_WM_STREET_JUMP /* 49 */:
                    i4 = zM130 ? 1709 : 1678;
                case 204:
                    String strM2353 = C0100.m235(new JSONObject(strM2352), C0098.m148(m107(), 82, C0098.f49 ^ (-772), 932));
                    C0100.m247();
                    C0099.m178(C0099.m198(m104(this), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m233(C0100.m250(C0100.m250(new StringBuffer(), C0100.m241(m107(), 85, C0099.f50 ^ (-988), 446)), strM2353))), C0102.m298(m107(), 101, C0101.f52 ^ (-628), 355))), 1));
                    C0098.m129();
                    int i5 = 1740;
                    while (true) {
                        i5 ^= 1757;
                        switch (i5) {
                            case 17:
                                i5 = 1771;
                                continue;
                            case 54:
                                break;
                            default:
                                continue;
                        }
                    }
                case 239:
                    C0100.m247();
                    C0099.m178(C0099.m198(m104(this), strM2352, 1));
                    C0098.m129();
                    break;
            }
        }
        int i6 = 1864;
        while (true) {
            i6 ^= 1881;
            switch (i6) {
                case 17:
                    i6 = 48674;
                    break;
                case 47483:
                    return;
            }
        }
    } catch (JSONException e5) {
        C0100.m247();
        C0099.m178(C0099.m198(m104(this), C0101.m276(m107(), 103, C0098.f49 ^ (-776), 996), 1));
        C0098.m129();
    } catch (Exception e6) {
        C0099.m202(e6);
        int i7 = 48767;
        while (true) {
            i7 ^= 48784;
            switch (i7) {
                case 14:
                    return;
                case 239:
                    i7 = 48798;
                    break;
            }
        }
    }
}

后记:盗卖开源软件不得 House

开源项目地址:ZCShou/GoGoGo

软件设置里面作者的名字都不改,该说这 sb 盗狗是致敬原作者呢,还是嚣张至极呢?​

Grub

About Grub

全体目光向我看齐,我宣布个事——我是个傻逼!

Copyright © 2026 Unimx. All rights reserved.
Powered by Vercel