吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 812|回复: 15
上一主题 下一主题
收起左侧

[Android 原创] 【零基础可复现】2026 春节 Android 中级题(Java→so→unidbg 全流程)

  [复制链接]
跳转到指定楼层
楼主
破解小白:) 发表于 2026-3-4 15:02 回帖奖励
本帖最后由 破解小白:) 于 2026-3-4 22:32 编辑

哈基米吾爱破解2026南北绿豆???!!!(零基础完整版)

PS:

  • 阅读时间约93min。本教程不是一篇传统意义上的write_up,而是面向完全零基础读者的教程。
  • 借助ida-mcp进行了部分静态分析,借助大模型进行了markdown排版与部分代码注释,核心内容为手动撰写。
  • 不要觉得代码太长就跳过去了,我都做了详细注释的>_<
  • 可以随时参考正己老师的详细教程吾爱破解安卓逆向入门教程《安卓逆向这档事》
  • Java与native部分章末均附有详细流程图,大家可以先看这一部分

0. 开始之前:你需要准备什么

0.1 需要的工具(按使用顺序)

工具 用途 下载地址 必需性
7-Zip 解压 APK(APK 本质是 ZIP) https://www.7-zip.org/ 可选
JADX-GUI 查看 Java 代码 https://github.com/skylot/jadx/releases 必需
IDA Pro 查看 native 代码(so 文件) https://down.52pojie.cn/Tools/Disassemblers/ 必需
Java 17+ 运行 unidbg https://adoptium.net/ 必需
Maven 构建 Java 项目 https://maven.apache.org/download.cgi 必需
Python 3.8+ 运行自动化脚本 https://www.python.org/downloads/ 必需

0.2 工具安装检查清单

在开始之前,在命令行(PowerShell 或 CMD)里运行这些命令,确保都能正常输出版本号:

# 检查 Java(应该显示 17 或更高版本)
java -version

# 检查 Maven(应该显示 3.6 或更高版本)
mvn -version

# 检查 Python(应该显示 3.8 或更高版本)
python --version

我的环境:


如果任何一个命令报错"找不到命令",请先安装对应工具并配置环境变量,详细安装步骤这里不再赘述。


1. 第一步:解压 APK,看看里面有什么

1.1 为什么要解压 APK

APK 文件本质上是一个 ZIP 压缩包,里面包含:

  • Java 代码(编译后的 .dex 文件)
  • Native 代码.so 文件,通常在 lib/ 目录)
  • 资源文件(图片、配置、数据包等,在 assets/res/ 目录)

我们需要先把它解压,才能用工具分析这些文件。

1.2 具体操作步骤

  1. 找到 APK 文件
    假设你的 APK 文件名是 app.apk,放在桌面上。
  2. 右键点击 APK 文件
    选择 7-Zip → 解压到 "app\"(注意:不是"解压到当前文件夹",那样会把文件散落一地)。
  3. 解压完成后,你会看到一个新文件夹 app/
    进入这个文件夹,你应该看到这样的结构:
app/
├── AndroidManifest.xml    # 应用配置文件
├── classes.dex            # Java 代码(编译后)
├── lib/                   # Native 库目录
│   ├── arm64-v8a/         # 64 位 ARM 架构
│   │   └── libhajimi.so   # 我们要分析的 native 库
│   └── armeabi-v7a/       # 32 位 ARM 架构(本题不用)
├── assets/                # 资源文件目录
│   └── hjm_pack.bin       # 关键数据包(后面会用到)
└── res/                   # 其他资源(图片、布局等)
  • 记住这个文件夹的路径
    例如:C:\Users\你的用户名\Desktop\app\
    后面会频繁用到这个路径。

2. 第二步:用 JADX 看 Java 层代码

2.1 为什么先看 Java 层

在 Android 应用里,Java 层负责:

  • 界面逻辑(按钮点击、文本输入)
  • 流程控制(什么时候调用 native 函数)
  • 参数准备(把用户输入传给 native)

如果你直接去看 native 代码(so 文件),你会不知道:

  • 这个函数什么时候被调用
  • 参数从哪里来
  • 返回值给了谁

所以正确顺序是:先看 Java 层找到调用链,再去 native 层看具体实现

2.2 打开 JADX-GUI 并加载 APK

  1. 下载 JADX
    https://github.com/skylot/jadx/releases 下载最新版的 jadx-gui-x.x.x.with-jre-win.zip
  2. 解压并运行
    解压后,双击 jadx-gui-x.x.x.exe(Windows)。
  3. 在 JADX 加载 APK
    在 JADX 窗口里,点击 File → Open files...,选择 app.apk。或者直接把 app.apk 从文件资源管理器拖到 JADX 窗口里。
  4. 反编译结果

    可以在左侧窗口清晰看到apk的包结构。

2.3 分析策略:双管齐下

拿到一个安卓逆向题目,分析 Java 层有两个经典的入手点,可以同时进行

  1. 查看 MainActivity:从应用入口顺着代码往下走,了解整体架构。
  2. 全局搜索关键字符串:直接搜索界面上可见的文字(比如"验证"),精准定位到关键代码。

这两条路最终会汇合到同一个地方——flag 的验证逻辑。

补充知识:这道题使用的是 Jetpack Compose 框架(而非传统的 XML 布局)来构建 UI。反编译 Compose 代码时会看到大量无意义的字母(如 C0712uF0.b),这是正常现象。Compose 编译器为了节省内存,会把相似的 UI 组件合并到同一个类里,用 switch 语句区分——后面你会反复看到这种模式。

2.4 路线一:从 MainActivity 入手

如何在 JADX 里找到 MainActivity?
  1. 在 JADX 左侧的包列表里,展开应用包名
    本题的包名是 com.zj.wuaipojie2026_2(你可以在 AndroidManifest.xml 里确认,通常有 LAUNCHER 标记)。
  2. 找到 MainActivity
    点开左侧列表的源代码标签,路径是:com.zj.wuaipojie2026_2 → MainActivity
  3. 双击打开,查看 onCreate 方法
原始代码片段截图

MainActivity.onCreate 完整代码(带注释)
package com.zj.wuaipojie2026_2;

import A.e;
import Q0.AbstractC0080d;
import a.AbstractC0154a;
import android.content.res.Resources;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import androidx.activity.l;  // l 是混淆后的类名,实际是 ComponentActivity
import androidx.compose.ui.platform.C0204f0;

public final class MainActivity extends l {

    @Override
    public final void onCreate(Bundle bundle) {
        super.onCreate(bundle);

        // 【前面这一大段都是在设置系统窗口、状态栏、沉浸式背景等,不用管】
        int i2 = m.f1921a;
        A a2 = A.f1870j;
        B b2 = new B(0, 0, a2);
        B b3 = new B(m.f1921a, m.f1922b, a2);
        View decorView = getWindow().getDecorView();
        // ... 窗口配置代码省略 ...

        // ★【关键点 1】准备主界面的 UI 内容
        // AbstractC0080d.f1133b 就是整个 App 主界面的入口!
        e eVar = AbstractC0080d.f1133b;

        // 中间这段是在寻找或创建一个叫做 ComposeView 的容器
        C0204f0 c0204f02 = new C0204f0(this);
        c0204f02.setParentCompositionContext(null);

        // ★【关键点 2】把界面内容塞进容器里
        c0204f02.setContent(eVar);

        // ... 绑定生命周期 ...

        // ★【关键点 3】将容器设置为当前 Activity 的主视图
        setContentView(c0204f02, AbstractC0154a.f1861a);
    }
}

这段代码告诉我们什么?

  • MainActivity 只是一个空壳,它真正的界面逻辑都藏在 AbstractC0080d.f1133b 这个变量背后。
  • 这是典型的 Jetpack Compose 架构:用代码"画"界面,而不是用 XML 布局文件。
  • 顺着 AbstractC0080d.f1133b 往下追,可以找到完整的 UI 逻辑。但 Compose 的反编译代码层层套娃、极度混淆,正向追踪效率不高。

所以我们换一个更高效的方式——直接搜索界面上可见的字符串

2.5 路线二:搜索"验证"字符串,定位按钮代码

为什么搜字符串?

你在 App 界面上能看到的文字(比如"验证 flag"、"验证"、"FLAG{...}"),大概率会以字符串常量的形式存在于代码中。搜到它们,就能瞬间定位到对应的代码位置。


在这道题目中,我们搜索“验证”字符串,因为它与按钮绑定,而按钮事件往往绑定着验证flag的核心逻辑。

具体操作
  1. 在 JADX 中按 Ctrl + Shift + F 打开全局文本搜索(也可以点击 Navigation → Text Search
  2. 输入 验证,回车搜索
  3. 在搜索结果中,找到 Q0.C0077a,双击跳转
C0077a 完整代码(带注释)
package Q0;

import androidx.compose.material3.F0;
import t.AbstractC0713v;
import t.C0681X;
import t.C0712u;

public final class C0077a extends f1.i implements e1.f {

    // ★ 静态变量:预先创建好的 UI "零件"
    // 构造参数 (3, 0) 中的第二个数字,就是后面 switch 的分支编号
    public static final C0077a f1120k = new C0077a(3, 0);  // → case 0:"验证"
    public static final C0077a f1121l = new C0077a(3, 1);  // → default:"取消"

    // 这个变量保存了构造时传入的第二个数字,决定走哪个 switch 分支
    public final int f1122j;

    public C0077a(int i2, int i3) {
        super(i2);
        this.f1122j = i3;  // ★ 保存分支编号
    }

    @Override
    public final Object J(Object obj, Object obj2, Object obj3) {
        S0.k kVar = S0.k.f1286a;
        // ★ 根据 f1122j 决定画哪个按钮文字
        switch (this.f1122j) {
            case 0:
                C0712u c0712u = (C0712u) obj2;
                int iIntValue = ((Number) obj3).intValue();
                f1.h.e((l.N) obj, "$this$TextButton");
                if ((iIntValue & 81) == 16 && c0712u.A()) {
                    c0712u.T(); // Compose 内部刷新逻辑,忽略
                } else {
                    C0681X c0681x = AbstractC0713v.f6691a;
                    // ★★★ 在屏幕上画出 "验证" 两个字!
                    F0.b("验证", null, 0L, 0L, null, null, null, 0L,
                         null, null, 0L, 0, false, 0, 0, null, null,
                         c0712u, 6, 0, 131070);
                }
                break;
            default:
                C0712u c0712u2 = (C0712u) obj2;
                int iIntValue2 = ((Number) obj3).intValue();
                f1.h.e((l.N) obj, "$this$TextButton");
                if ((iIntValue2 & 81) == 16 && c0712u2.A()) {
                    c0712u2.T();
                } else {
                    C0681X c0681x2 = AbstractC0713v.f6691a;
                    // ★ 画出 "取消" 两个字
                    F0.b("取消", null, 0L, 0L, null, null, null, 0L,
                         null, null, 0L, 0, false, 0, 0, null, null,
                         c0712u2, 6, 0, 131070);
                }
                break;
        }
        return kVar;
    }
}

这段代码背后的原理是什么?

这是 Compose 编译器的典型行为:它把"验证"按钮和"取消"按钮的文字绘制逻辑合并到了同一个类里,用 switch 分支区分。创建对象时传入的数字(01)保存在 f1122j 里,执行 J() 方法时就根据这个数字决定画哪个文字。

  • f1120k(构造参数 0)→ case 0 → 画 "验证"
  • f1121l(构造参数 1)→ default → 画 "取消"

但注意:这个类只负责画文字,不负责处理点击事件。我们需要用交叉引用功能追踪谁使用了这些零件。

2.6 交叉引用追踪:从按钮文字到点击事件

在 JADX 中,选中 f1120k,按 X(查找交叉引用 / Find Usage),然后逐级往上追踪。在 Compose 逆向中,这种"套娃式"追踪非常常见。

第一跳:AbstractC0079c(零件包装层)
package Q0;

public abstract class AbstractC0079c {
    // 把 "验证" 按钮文字零件 (f1120k) 打包成标准 Compose 组件
    public static final A.e f1128a = R.c.n(-1658915235, false, C0077a.f1120k);
    // 把 "取消" 按钮文字零件 (f1121l) 打包
    public static final A.e f1129b = R.c.n(-307530017, false, C0077a.f1121l);
    // 把 "验证 flag" 标题零件打包
    public static final A.e f1130c = R.c.n(1079854244, false, C0078b.f1123k);
    // 把 "FLAG{...}" 输入框占位符零件打包
    public static final A.e f1131d = R.c.n(1253103359, false, C0078b.f1124l);
}

这是 Compose 的"中间包装层"——把裸零件套上标准接口,方便传递。R.c.n() 中的整数参数是组件的唯一 ID,系统刷新界面时靠它来快速识别。

第二跳:Q0.C(验证按钮总装)

继续对 f1128aX 键查找交叉引用,找到调用它的地方:

package Q0;

import android.content.Context;
import t.C0712u;
import t.InterfaceC0680W;

public final class C extends f1.i implements e1.e {

    // 构造函数接收了大量"状态水桶"(Compose 的 MutableState)
    public final InterfaceC0603z f1040j;  // 协程作用域
    public final InterfaceC0680W f1041k;  // 用户输入文本
    public final InterfaceC0680W f1042l;  // 提示信息
    public final InterfaceC0680W f1043m;  // 加载状态
    public final InterfaceC0680W f1044n;  // pack 数据
    public final Context f1045o;           // 上下文
    public final InterfaceC0680W f1046p;  // 其他状态
    public final InterfaceC0680W f1047q;  // 其他状态

    // ... 构造函数省略 ...

    @Override
    public final Object N(Object obj, Object obj2) {
        C0712u c0712u = (C0712u) obj;
        if ((((Number) obj2).intValue() & 11) == 2 && c0712u.A()) {
            c0712u.T(); // Compose 刷新逻辑,忽略
        } else {
            // ★★★ 组装"验证"按钮
            // E.c 就是 Compose 的 TextButton 组件
            // 参数 1:new B(...) 是按钮的 onClick 点击事件!
            // 最后的 AbstractC0079c.f1128a 是按钮上显示的文字("验证")
            androidx.compose.material3.E.c(
                new B(this.f1040j, this.f1041k, this.f1042l, this.f1043m,
                      this.f1044n, this.f1045o, this.f1046p, this.f1047q),
                null, false, null, null, null, null, null, null,
                AbstractC0079c.f1128a,  // ← "验证" 文字,这个地方调用了我们之前的零件
                c0712u, 805306368, 510);
        }
        return S0.k.f1286a;
    }
}

找到了! 在 Compose 中,TextButton 的第一个参数就是 onClick 回调。这里的 new B(...) 就是点击"验证"按钮后执行的逻辑。下一步去看 Q0.B

2.7 "验证"按钮的点击事件:Q0.B

打开 Q0 包下的 B 类:

package Q0;

import android.content.Context;
import java.util.List;
import o1.InterfaceC0603z;
import t.InterfaceC0680W;

public final class B extends f1.i implements e1.a {

    public final InterfaceC0603z f1032j;   // 协程作用域
    public final InterfaceC0680W f1033k;   // ★ 用户输入的文本(状态水桶)
    public final InterfaceC0680W f1034l;   // ★ 提示信息
    public final InterfaceC0680W f1035m;   // 加载状态
    public final InterfaceC0680W f1036n;   // pack 数据
    public final Context f1037o;           // 上下文
    public final InterfaceC0680W f1038p;   // 其他状态
    public final InterfaceC0680W f1039q;   // 其他状态

    // ... 构造函数省略 ...

    @Override
    public final Object o() {
        List list = N.f1106a;  // 全局配置,忽略

        // ★ 第一步:检查输入是否为空
        // this.f1033k.getValue() 从"水桶"里取出用户输入的 FLAG 文本
        // n1.k.c0 是 Kotlin 的 isBlank() 方法
        boolean zC0 = n1.k.c0((String) this.f1033k.getValue());

        InterfaceC0680W interfaceC0680W = this.f1034l;  // 提示信息的水桶

        if (zC0) {
            // ★ 如果没输入内容,直接提示
            interfaceC0680W.setValue("请先输入 flag");
        } else {
            // ★ 如果有输入,开始验证流程

            // 禁用按钮,防止重复点击
            this.f1035m.setValue(Boolean.FALSE);

            // 显示"验证中..."
            interfaceC0680W.setValue("验证中...");

            // ★★★ 启动后台协程,把所有状态水桶打包交给 A 类去处理
            // o1.A.o() 就是 Kotlin 协程的 launch{}
            o1.A.o(this.f1032j, null, 0,
                new A(this.f1036n, this.f1037o, this.f1033k,
                      this.f1038p, this.f1039q, this.f1034l, null), 3);
        }
        return S0.k.f1286a;
    }
}

这段代码告诉我们什么?

  1. 用户输入为空时,直接提示"请先输入 flag",不进入验证。
  2. 输入不为空时,界面显示"验证中...",然后把所有状态数据打包交给了 Q0.A 在后台执行。

2.8 协程调度器:Q0.A

这里有一个坑,jadx直接双击A,跳转到的是o1.A类,因为这里两个A直接重名了。我们直接在软件里搜索类名Q0.A,跳转。Shift+Ctrl+F。


打开 Q0 包下的 A 类。由于 Kotlin 协程底层会被编译成复杂的状态机,jadx 很可能反编译失败,显示类似这样的错误:

Method dump skipped, instruction units count: 263
To view this dump change 'Code comments level' option to 'DEBUG'

解决方法:在 jadx 设置中勾选 Show inconsistent code(显示不一致的代码),或者使用Simple模式`,然后 jadx 会输出一段半 Java、半底层指令的"降级版"代码。这里我们使用Simple模式。可以直接在jadx下方切换,如下图箭头所示。

Q0.A.g() 降级模式代码(带注释)

这个方法是一个协程状态机,通过 this.f1024q(步骤编号)来控制执行流程。核心分为三步:

@Override
public final Object g(Object r13) throws Throwable {
    X0.a r02 = X0.a.f1576i;
    int r12 = this.f1024q;  // ★ 当前步骤编号
    // ...

    if (r12 == 0) goto L128;      // 步骤 0:读取加密文件
    if (r12 == 1) goto L125;      // 步骤 1:发起验证
    if (r12 != 2) goto L124;      // 步骤 2:处理结果

    // ============= 步骤 2(L142):处理底层返回的结果 =============
    R.c.Y(r13);

    // ★ 尝试把底层返回的字节数组解析成对象
    C0081e r132 = h1.a.S((byte[]) r13);

    if (r132 == null) goto L155;   // 解析失败 → Flag 不正确

    // 解析成功,更新界面状态...
    String r135 = "验证成功";       // ★ 验证通过!
    r03.setValue(r135);
    // ...

L155:
    r135 = "Flag 不正确";          // ★ 验证失败
    // ...

L159:
    // 异常处理
    Log.e("Hajimi", "verify flag failed", r137);
    r3.setValue("验证出错");

    // ============= 步骤 1(L125):发起底层验证 =============
L125:
    R.c.Y(r13);
    r9.setValue((byte[]) r13);      // 保存读到的字节数组
    byte[] r139 = (byte[]) r13;

    // ★★★ 创建 z 类,把加密字节数组和用户输入一起传给它
    z r10 = new z(r139, r8, null);
    this.f1024q = 2;                // 设置下一步为步骤 2
    r13 = o1.A.v(r92, r10, this);  // 执行 z(调用 NativeBridge),挂起等待
    // ...

    // ============= 步骤 0(L128):读取加密文件 =============
L128:
    R.c.Y(r13);
    byte[] r18 = (byte[]) r9.getValue();  // 先检查内存里有没有缓存

    if (r18 != null) goto L137;           // 有缓存,直接用

    // ★ 没有缓存,派 y 类去读取 assets/hjm_pack.bin
    y r112 = new y(r1311, null);
    this.f1024q = 1;                       // 设置下一步为步骤 1
    r13 = o1.A.v(r19, r112, this);        // 执行 y(读文件),挂起等待
    // ...
}

翻译成白话

  1. 步骤 0(首次执行):检查内存里有没有 hjm_pack.bin 的数据。如果没有,派 Q0.y 去读取。
  2. 步骤 1(文件读完后):创建 Q0.z,把字节数组和用户输入一起传给它,调用底层验证。
  3. 步骤 2(底层返回后):尝试解析返回值。成功显示"验证成功",失败显示"Flag 不正确",异常显示"验证出错"。

2.9 读取加密文件:Q0.y

A 的步骤 0 中,y 类负责读取文件:

package Q0;

import android.content.Context;
import java.io.InputStream;

public final class y extends Y0.i implements e1.e {

    public final Context f1216m;

    public y(Context context, W0.d dVar) {
        super(2, dVar);
        this.f1216m = context;
    }

    // ... N() 和 c() 方法省略 ...

    @Override
    public final Object g(Object obj) throws Throwable {
        R.c.Y(obj);

        // ★★★ 从 APK 的 assets 目录打开 hjm_pack.bin 文件
        InputStream inputStreamOpen = this.f1216m.getAssets().open("hjm_pack.bin");

        try {
            f1.h.b(inputStreamOpen);
            // ★ 把文件内容读成字节数组
            byte[] bArrJ = b0.g.J(inputStreamOpen);
            c0.d.p(inputStreamOpen, null);  // 关闭流
            return bArrJ;  // ★ 返回字节数组给 A 类
        } finally {
        }
    }
}

真相大白:传给底层验证函数的第一个参数 byte[],就是 APK 中 assets/hjm_pack.bin 文件的全部内容。这个文件在我们解压 APK 后的 app/assets/ 目录里。

2.10 最终移交底层:Q0.z

A 的步骤 1 中,z 类负责调用 native 方法:

package Q0;

import com.zj.wuaipojie2026_2.NativeBridge;
import java.util.List;
import t.InterfaceC0680W;

public final class z extends Y0.i implements e1.e {

    public final byte[] f1217m;          // ★ 加密的字节数组(来自 hjm_pack.bin)
    public final InterfaceC0680W f1218n;  // ★ 用户输入的状态水桶

    public z(byte[] bArr, InterfaceC0680W interfaceC0680W, W0.d dVar) {
        super(2, dVar);
        this.f1217m = bArr;
        this.f1218n = interfaceC0680W;
    }

    // ... N() 和 c() 方法省略 ...

    @Override
    public final Object g(Object obj) throws Throwable {
        R.c.Y(obj);

        // 获取 NativeBridge 单例
        NativeBridge nativeBridge = NativeBridge.INSTANCE;
        List list = N.f1106a;

        // ★★★ 终极调用!
        // 1. this.f1217m → hjm_pack.bin 的字节数组
        // 2. 从水桶取出用户输入,trim 去掉首尾空格
        // 3. 调用 NativeBridge.verifyAndDecrypt → 进入 libhajimi.so
        return nativeBridge.verifyAndDecrypt(
            this.f1217m,
            n1.k.j0((String) this.f1218n.getValue()).toString()
        );
    }
}

至此,Java 层的数据流终于抵达了终点:用户输入的 flag 字符串(经过 trim)和 hjm_pack.bin 的内容,一起被传入了 NativeBridge.verifyAndDecrypt

2.11 NativeBridge:通往底层的桥梁

最后来看 com.zj.wuaipojie2026_2.NativeBridge,这是 Java 和 C/C++ 之间的"翻译官":

package com.zj.wuaipojie2026_2;

import f1.h;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

public final class NativeBridge {

    public static final int SCORE_GOOD = 1;     // 音游评分常量
    public static final int SCORE_MISS = 0;
    public static final int SCORE_PERFECT = 2;
    public static final int ERR_CHEAT = -7;

    public static final NativeBridge INSTANCE = new NativeBridge();  // 单例

    static {
        // ★ 加载底层库 libhajimi.so("哈基米",出题人的致敬彩蛋)
        System.loadLibrary("hajimi");
    }

    private NativeBridge() { }

    // ========== native 方法声明(实现在 libhajimi.so 里) ==========

    // 方法 1:初始化游戏会话(传字节数组版本)
    private final native void startSessionBytes(long j2, byte[] bArr, int i2);

    // Java 封装:把 int[] 节拍数据转成小端序 byte[] 后调用 native
    public final void startSession(long j2, int[] iArr, int i2) {
        h.e(iArr, "beatMapMs");
        ByteBuffer byteBufferOrder = ByteBuffer.allocate(iArr.length * 4)
                                                .order(ByteOrder.LITTLE_ENDIAN);
        for (int i3 : iArr) {
            byteBufferOrder.putInt(i3);
        }
        byte[] bArrArray = byteBufferOrder.array();
        h.d(bArrArray, "array(...)");
        startSessionBytes(j2, bArrArray, i2);
    }

    // 方法 2:检查节奏(音游判定)
    public final native int checkRhythm(long j2, int i2, long j3, int i3);

    // 方法 3:更新经验值
    public final native long updateExp(int i2, int i3, long j2);

    // 方法 4:解密帧数据
    public final native byte[] decryptFrames(byte[] bArr, long j2);

    // 方法 5:设置调试绕过
    public final native void setDebugBypass(boolean z2);

    // ★★★ 方法 6:验证并解密(我们要分析的核心方法)
    public final native byte[] verifyAndDecrypt(byte[] bArr, String str);
}

这段代码告诉我们什么?

  1. native 库的名字是 hajimi,对应文件是 libhajimi.so
  2. 从常量和方法名可以看出,这个 App 实际上是一个隐藏的音乐节奏游戏SCORE_GOODSCORE_PERFECTcheckRhythmbeatMapMs)。
  3. startSession 方法揭示了一个重要细节:Java 层的 int[] 节拍数据会被转成小端序(Little-Endian)byte[] 传递给 native 层。
  4. 有 6 个 native 方法,我们主要关注 verifyAndDecrypt

2.12 Java 层分析小结

到这里,Java 层的调查完美收官。整个 flag 的调用逻辑已经闭环:

用户点击 "验证" 按钮
  ↓
Q0.B.o()  ← 检查输入不为空,显示"验证中..."
  ↓
Q0.A.g()  ← 协程调度器(状态机)
  ↓
Q0.y.g()  ← 读取 assets/hjm_pack.bin(加密数据)
  ↓
Q0.z.g()  ← 最终移交
  ↓
NativeBridge.verifyAndDecrypt(pack字节数组, trim后的用户输入)
                    ↓
            libhajimi.so(C/C++ 底层验证)
                    ↓
         返回 byte[]:成功可解析 → "验证成功"
                      解析失败   → "Flag 不正确"
                      抛出异常   → "验证出错"

关于核心包Q0 是混淆后的 UI 逻辑大本营(ABCDyz 等类原本可能叫 VerifyViewModelOnClickVerify 之类的名字)。com.zj.wuaipojie2026_2 是不能被混淆的主包,里面只有系统入口 MainActivity 和必须按名字对接 C/C++ 的 NativeBridge

我们现在知道了什么?

  1. 用户输入会被 trim(去掉首尾空格)。
  2. 验证需要两个输入:assets/hjm_pack.bin 的内容 + 用户输入的字符串。
  3. 真正的验证逻辑在 libhajimi.so 里的 verifyAndDecrypt 函数。
  4. 这个 App 是一个音乐节奏游戏,NativeBridge 中的 startSessioncheckRhythm 等方法会在游戏过程中初始化和更新全局状态。
  5. 详细流程图如下:

下一步要做什么?

去 IDA 里分析 libhajimi.so,看 verifyAndDecrypt 到底做了什么。


3. 第三步:用 IDA 看 Native 层代码

3.1 为什么要用 IDA

libhajimi.so 是编译后的机器码,不能直接阅读。IDA Pro 可以:

  • 把机器码反汇编成汇编代码
  • 进一步反编译成类似 C 的伪代码
  • 提供交叉引用、函数调用图等分析功能

3.2 打开 so 文件

  1. 启动 IDA Pro
    双击 IDA 的可执行文件(ida64.exeida.exe)。
  2. 选择要分析的文件
    点击 File → Open,导航到你解压 APK 后的目录:
    app/lib/arm64-v8a/libhajimi.soarm64-v8a与我们的主流手机架构一致,方便后续进入unidbg调试
  3. 选择处理器类型
    IDA 会弹出一个对话框,问你要用哪个处理器。
    保持默认选项 ARM64 (AArch64)(因为我们分析的是 64 位 ARM 架构)然后一路狂点OK。
  4. 等待分析完成
    IDA 会自动分析 so 文件,这个过程可能需要几秒到几分钟。
    当左下角显示 AU: idle 时,表示分析完成。

3.3 找到 JNI_OnLoad 函数

为什么要找 JNI_OnLoad?

在 Java 层,我们看到 NativeBridge 里有 6 个 native 方法。但在 so 文件里,这些方法的名字可能不是 verifyAndDecrypt,而是一串混淆后的地址。

JNI_OnLoad 是 JNI 库的初始化函数,它会告诉 JVM:"Java 方法 verifyAndDecrypt 对应 native 函数地址 0x12345"。这个过程叫动态注册

所以我们要先找到 JNI_OnLoad,从里面提取"Java 方法名 → native 函数地址"的映射表。

如何在 IDA 里找到 JNI_OnLoad?
  1. 打开函数窗口
    Shift + F3(或点击 View → Open subviews → Functions)。默认就是左侧那一列显示函数列表。
  2. 在函数列表里搜索
    在函数窗口,按 Ctrl + F,输入 JNI_OnLoad,按回车。
  3. 双击搜索结果
    IDA 会跳转到 JNI_OnLoad 函数的反汇编视图。
  4. 切换到伪代码视图
    F5(或右键 → Generate pseudocode)。
JNI_OnLoad 伪代码分析
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
  JavaVM v2; // x8 — JNI 函数表指针(*vm 解引用后得到 JNIInvokeInterface_)
  __int64 v4; // x0 — 保存 FindClass 返回的 jclass 引用
  _QWORD v5[2]; // [xsp+0h] [xbp-10h] BYREF — v5[0] 用来接收 JNIEnv*

  // ── 栈 canary 初始化(ARM64 标准 guard,防止栈溢出攻击)──
  v5[1] = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);

  v2 = *vm;   // 解引用 JavaVM**,得到 JNIInvokeInterface_ 函数表
  v5[0] = 0;  // 清零,确保 GetEnv 失败时 v5[0] 不是野指针

  // ── 第 1 步:通过 JavaVM->GetEnv 拿到当前线程的 JNIEnv* ──
  // 65542 = 0x10006 = JNI_VERSION_1_6;失败(线程未 attach)返回非零 → 直接返回 -1
  if ( v2->GetEnv(vm, (void **)v5, 65542) )
    return -1;

  // ── 第 2 步:调用 JNIEnv->FindClass,在虚拟机中查找目标类 ──
  // *(_QWORD *)v5[0]       = JNINativeInterface_ 函数表首地址
  // + 48LL                 = 偏移 0x30,对应 FindClass(第 6 个槽,每槽 8 字节)
  // 参数:JNI 内部类名,使用 '/' 分隔包名
  v4 = (*(__int64 (__fastcall **)(_QWORD, const char *))(*(_QWORD *)v5[0] + 48LL))(
         v5[0],
         "com/zj/wuaipojie2026_2/NativeBridge");  // 要注册 native 方法的 Java 类
  if ( !v4 )
    return -1;  // 类找不到(通常是 ClassLoader 问题),终止加载

  // ── 第 3 步:调用 JNIEnv->RegisterNatives,批量注册 native 方法 ──
  // + 1720LL               = 偏移 0x6B8,对应 RegisterNatives(第 215 个槽)
  // off_5E6F8              = JNINativeMethod[] 数组首地址(name / signature / fnPtr 三元组)
  // 6                      = 数组长度,即注册 6 个 native 方法
  // 返回非零表示注册失败(某个方法名/签名在类中找不到)
  if ( (*(unsigned int (__fastcall **)(_QWORD, __int64, char **, __int64))(*(_QWORD *)v5[0] + 1720LL))(
         v5[0],
         v4,
         off_5E6F8,   // → 见 3.4 节,映射表记录了 6 个 Java 方法 → native 函数的绑定
         6) )
  {
    return -1;
  }

  return 65542;  // 返回 JNI_VERSION_1_6,通知 JVM 该 so 支持的 JNI 版本
}

这段代码告诉我们什么?

  • 关键信息在 off_5E6F8 这个数组里。
  • 我们要去看这个数组的内容。

3.4 查看 native 方法映射表

如何跳转到 off_5E6F8?
  1. 在伪代码窗口里,把光标放在 off_5E6F8 这个词上
  2. Enter(或双击)
  3. IDA 会跳转到数据段,显示这个数组的内容
映射表数据分析

IDA 会显示类似这样的数据:

.data:000000000005E6F8 off_5E6F8       DCQ aStartsessionby     ; DATA XREF: LOAD:00000000000000F8↑o
.data:000000000005E6F8                                         ; JNI_OnLoad+74↑o
.data:000000000005E6F8                                         ; "startSessionBytes"
.data:000000000005E700                 DCQ aJBiV               ; "(J[BI)V"
.data:000000000005E708                 DCQ sub_247B0
.data:000000000005E710                 DCQ aCheckrhythm        ; "checkRhythm"
.data:000000000005E718                 DCQ aJijiI              ; "(JIJI)I"
.data:000000000005E720                 DCQ sub_24DA8
.data:000000000005E728                 DCQ aUpdateexp          ; "updateExp"
.data:000000000005E730                 DCQ aIijJ               ; "(IIJ)J"
.data:000000000005E738                 DCQ sub_24EA4
.data:000000000005E740                 DCQ aDecryptframes      ; "decryptFrames"
.data:000000000005E748                 DCQ aBjB                ; "([BJ)[B"
.data:000000000005E750                 DCQ sub_2541C
.data:000000000005E758                 DCQ aVerifyanddecry     ; "verifyAndDecrypt"
.data:000000000005E760                 DCQ aBljavaLangStri     ; "([BLjava/lang/String;)[B"
.data:000000000005E768                 DCQ sub_257DC
.data:000000000005E770                 DCQ aSetdebugbypass     ; "setDebugBypass"
.data:000000000005E778                 DCQ aZV                 ; "(Z)V"
.data:000000000005E780                 DCQ sub_25C90
如何理解这个数据结构?

这个数组是 JNINativeMethod 结构体的数组,每个结构体占 24 字节(3 个指针,每个 8 字节):

struct JNINativeMethod {
    const char* name;        // Java 方法名(8 字节指针)
    const char* signature;   // 方法签名(8 字节指针)
    void*       fnPtr;       // native 函数地址(8 字节指针)
};

所以我们可以把上面的数据按每 3 行分组:

Java 方法名 方法签名 Native 函数地址
startSessionBytes (J[BI)V sub_247B0 (0x247B0)
checkRhythm (JIJI)I sub_24DA8 (0x24DA8)
updateExp (IIJ)J sub_24EA4 (0x24EA4)
decryptFrames ([BJ)[B sub_2541C (0x2541C)
verifyAndDecrypt ([BLjava/lang/String;)[B sub_257DC (0x257DC)
setDebugBypass (Z)V sub_25C90 (0x25C90)

这个表格告诉我们什么?

  • Java 方法 verifyAndDecrypt 对应的 native 函数地址是 0x257DC
  • 在 IDA 里,这个函数被自动命名为 sub_257DC(sub 表示 subroutine,子程序)。

3.5 分析 verifyAndDecrypt 函数

如何跳转到这个函数?
  1. 在映射表数据窗口里,把光标放在 sub_257DC 这个词上
  2. Enter(或双击)
  3. IDA 会跳转到这个函数的反汇编视图
  4. F5 切换到伪代码视图
verifyAndDecrypt 完整伪代码(IDA 反编译)

下面是 IDA 对 sub_257DC(即 verifyAndDecrypt)的完整反编译结果。变量名是 IDA 自动生成的(v5v8 等),初看很晦涩,但不必逐行阅读——重要的是理解整体流程。

阅读建议:先大致浏览代码结构,然后跳到代码块后方的「逐段拆解」部分对照理解。

__int64 __fastcall sub_257DC(__int64 *a1, __int64 a2, __int64 a3, __int64 a4)
{
  __int64 v5; // x8
  int v8; // w0
  size_t v9; // x23
  unsigned int v10; // w21
  _DWORD *v11; // x0
  char *v12; // x25
  _DWORD *v13; // x19
  __int64 v14; // x8
  __int64 v15; // x0
  __int64 v16; // x22
  __int64 v18; // x0
  int v19; // w2
  int v20; // w3
  int v21; // w4
  int v22; // w5
  int v23; // w6
  int v24; // w7
  int v25; // w10
  __int64 v26; // x9
  __int64 v27; // x8
  unsigned __int64 v28; // x9
  unsigned __int64 v29; // x9
  unsigned __int64 v30; // x10
  __int128 v31; // t2
  int v32; // w26
  int v33; // w10
  int v34; // w24
  int v35; // w25
  int v36; // w8
  __int64 v37; // x0
  __int64 v38; // x26
  int v39; // w28
  char v40; // w24
  size_t v41; // x2
  unsigned int v42; // w0
  unsigned int v43; // w1
  int v44; // [xsp+0h] [xbp-40h]
  int v45[2]; // [xsp+8h] [xbp-38h] BYREF
  int v46[2]; // [xsp+10h] [xbp-30h]
  int v47[2]; // [xsp+18h] [xbp-28h]
  void *s2; // [xsp+20h] [xbp-20h] BYREF
  _BYTE *v49; // [xsp+28h] [xbp-18h]
  int v50; // [xsp+30h] [xbp-10h]
  void *v51; // [xsp+38h] [xbp-8h]
  __int64 vars0; // [xsp+40h] [xbp+0h]

  v51 = *(void **)(_ReadStatusReg(TPIDR_EL0) + 40);
  v5 = *a1;
  if ( a3 && a4 )
  {
    v8 = (*(__int64 (__fastcall **)(__int64 *, __int64))(v5 + 1368))(a1, a3);
    if ( v8 > 0 )
    {
      v9 = (unsigned int)v8;
      v10 = v8;
      v11 = (_DWORD *)operator new((unsigned int)v8);
      v12 = (char *)v11 + v9;
      v13 = v11;
      *(_QWORD *)v45 = v11;
      *(_QWORD *)v47 = (char *)v11 + v9;
      memset(v11, 0, v9);
      v14 = *a1;
      *(_QWORD *)v46 = v12;
      (*(void (__fastcall **)(__int64 *, __int64, _QWORD, _QWORD, _DWORD *))(v14 + 1600))(a1, a3, 0, v10, v13);
      if ( v10 <= 0x33 || *v13 != 827148872 )
        goto LABEL_6;
      v18 = sub_25EF8(a1);
      v25 = dword_5EA50 + HIDWORD(v18);
      v26 = dword_5EA4C | (unsigned int)v18;
      if ( dword_5EA50 + HIDWORD(v18) >= 12 )
        v25 = 12;
      dword_5EA4C |= v18;
      dword_5EA50 = v25;
      if ( v25 < 4 )
      {
        v27 = qword_5EA28;
        if ( byte_5EA54 != 1 )
        {
          v32 = 0;
          goto LABEL_20;
        }
      }
      else
      {
        v27 = qword_5EA28;
        byte_5EA54 = 1;
      }
      v28 = (v25 ^ (unsigned __int64)(v26 << 32) ^ 0x1A8CBC5B802E097CLL) - 0x61C8864680B583EBLL;
      v29 = 0x94D049BB133111EBLL
          * ((0xBF58476D1CE4E5B9LL * (v28 ^ (v28 >> 30))) ^ ((0xBF58476D1CE4E5B9LL * (v28 ^ (v28 >> 30))) >> 27));
      v30 = v29 ^ (v29 >> 31);
      if ( v30 )
      {
        *((_QWORD *)&v31 + 1) = v29 ^ (v29 >> 31);
        *(_QWORD *)&v31 = v29;
        v27 ^= (v31 >> 35) ^ v30;
      }
      v32 = 1;
LABEL_20:
      v33 = v13[2];
      qword_5EA30 = v27;
      if ( v33 )
      {
        v34 = v13[3];
        if ( v34 )
        {
          v35 = v13[4];
          if ( v35 )
          {
            v36 = v13[1];
            if ( v36 == 2 )
            {
              if ( (v32 | (unsigned __int8)byte_5EA40) & 1 | (byte_5EB88 != 0) )
              {
                if ( byte_5EB88 )
                  v42 = sub_2DCDC();
                else
                  v42 = qword_5EA38;
                if ( v32 )
                  v43 = v42 ^ 0xA5A5A5A5;
                else
                  v43 = v42;
                if ( (sub_2DDF8(
                        (int)v45,
                        v43,
                        v19,
                        v20,
                        v21,
                        v22,
                        v23,
                        v24,
                        v44,
                        v45[0],
                        v46[0],
                        v47[0],
                        (int)s2,
                        (int)v49,
                        v50,
                        v51,
                        vars0)
                    & 1) == 0 )
                {
LABEL_47:
                  v15 = (*(__int64 (__fastcall **)(__int64 *, _QWORD))(*a1 + 1408))(a1, 0);
                  goto LABEL_7;
                }
LABEL_29:
                v38 = (*(__int64 (__fastcall **)(__int64 *, __int64, _QWORD))(*a1 + 1352))(a1, a4, 0);
                if ( v38 )
                {
                  v39 = v35 * v34;
                  sub_2D46C((int)&s2, (unsigned int)(v35 * v34) >> 3);
                  v40 = sub_2E5FC(v38, v34, v35, s2, v49 - (_BYTE *)s2);
                  (*(void (__fastcall **)(__int64 *, __int64, __int64))(*a1 + 1360))(a1, a4, v38);
                  if ( (v40 & 1) != 0
                    && (unsigned int)v39 >= 8
                    && (v41 = (unsigned __int64)v39 >> 3, v41 + 52 <= v9)
                    && !memcmp(v13 + 13, s2, v41) )
                  {
                    v16 = (*(__int64 (__fastcall **)(__int64 *, _QWORD))(*a1 + 1408))(a1, v10);
                    (*(void (__fastcall **)(__int64 *, __int64, _QWORD, _QWORD, _DWORD *))(*a1 + 1664))(
                      a1,
                      v16,
                      0,
                      v10,
                      v13);
                  }
                  else
                  {
                    v16 = (*(__int64 (__fastcall **)(__int64 *, _QWORD))(*a1 + 1408))(a1, 0);
                  }
                  if ( s2 )
                  {
                    v49 = s2;
                    operator delete(s2);
                  }
                  goto LABEL_8;
                }
                goto LABEL_47;
              }
            }
            else if ( v36 == 1 )
            {
              if ( v32 )
                v37 = 1515870653;
              else
                v37 = 999;
              sub_2D4F0(v37, v13 + 6, v13 + 10, &s2);
              sub_2D678(v13 + 13, v9 - 52, &s2, v13 + 10);
              goto LABEL_29;
            }
          }
        }
      }
LABEL_6:
      v15 = (*(__int64 (__fastcall **)(__int64 *, _QWORD))(*a1 + 1408))(a1, 0);
LABEL_7:
      v16 = v15;
LABEL_8:
      operator delete(v13);
      return v16;
    }
    v5 = *a1;
  }
  return (*(__int64 (__fastcall **)(__int64 *, _QWORD))(v5 + 1408))(a1, 0);
}
逐段拆解

IDA 生成的变量名很晦涩(a1 v5 v13...),但只要掌握 JNI 函数偏移表,就能把关键操作一一还原。

伪代码表达式 对应的含义
*(v5 + 1368)(a1, a3) env->GetArrayLength(pack_bytes) — 获取 pack 字节数组长度
*(v5 + 1600)(a1, a3, 0, v10, v13) env->GetByteArrayRegion(pack, 0, len, buf) — 拷贝 pack 到 native 缓冲区
*(v5 + 1352)(a1, a4, 0) env->GetStringUTFChars(input_str, NULL) — 获取用户输入的字符串
*(v5 + 1408)(a1, v10) env->NewByteArray(len) — 创建返回给 Java 的字节数组
*(v5 + 1664)(a1, v16, 0, v10, v13) env->SetByteArrayRegion(result, 0, len, buf) — 写入结果数据

第一段:获取 pack 数据并校验头部

v8 = (*(v5 + 1368))(a1, a3);     // GetArrayLength → pack 长度
v13 = operator new(v8);           // 分配 native 缓冲区
(*(v14 + 1600))(a1, a3, 0, v10, v13); // GetByteArrayRegion → 拷贝数据
if ( v10 <= 0x33 || *v13 != 827148872 )  // 长度 > 51 且魔数 = 'HJM1'
    goto LABEL_6;                 // 校验失败 → 返回 null
  • v10 <= 0x33 → pack 长度必须 > 51(即至少有 52 字节的完整头部)
  • *v13 != 827148872827148872 = 0x314D4A48,即 "HJM1" 的小端序表示,这是文件格式的魔数校验

第二段:环境混入与状态更新

v18 = sub_25EF8(a1);              // ★ 环境指纹探测
v25 = dword_5EA50 + HIDWORD(v18);
v26 = dword_5EA4C | (unsigned int)v18;
// ... SplitMix64 派生 ...
v28 = (v25 ^ (v26 << 32) ^ 0x1A8CBC5B802E097CLL) - 0x61C8864680B583EBLL;
v29 = 0x94D049BB133111EBLL * ((0xBF58476D1CE4E5B9LL * (v28 ^ (v28 >> 30))) ^ ...);
v30 = v29 ^ (v29 >> 31);
if ( v30 ) { v27 ^= (v31 >> 35) ^ v30; }
qword_5EA30 = v27;               // ★ 更新全局 seed
  • sub_25EF8环境指纹探测器:它会访问 /proc/zoneinfo、读取设备属性(ro.build.fingerprint 等)、做 JNI 类存在性检测,最终返回一个混合指纹值
  • 返回值被混入 dword_5EA4Cdword_5EA50 两个全局累积状态
  • 接着用 SplitMix64 风格的运算从上述状态派生出新的 qword_5EA30
  • 这意味着 qword_5EA30 不是静态值——它在每次调用 verifyAndDecrypt 时都可能被更新

第三段:按 mode 分派

v36 = v13[1];  // header 偏移 4 → mode 字段
if ( v36 == 2 ) { ... }  // mode=2:解密+比较位图(本题走这条路)
if ( v36 == 1 ) { ... }  // mode=1:不同的处理逻辑

本题的 pack 文件 mode=2,进入最关键的分支。

第四段:获取 seed 并解密位图

if ( (v32 | (unsigned __int8)byte_5EA40) & 1 | (byte_5EB88 != 0) ) {
    if ( byte_5EB88 )            // debug bypass 标志
        v42 = sub_2DCDC();       // 从全局状态计算 seed
    else
        v42 = qword_5EA38;       // 使用存储的 seed
    if ( v32 )
        v43 = v42 ^ 0xA5A5A5A5;  // 额外 XOR
    else
        v43 = v42;
    sub_2DDF8(&pack_vec, v43, ...); // ★ 解密 pack 位图
}

三个要点:

  1. 状态门控:必须满足 (v32 | byte_5EA40) & 1byte_5EB88 != 0,否则直接返回 null。这就是「App 内验证需要先玩游戏才行」的根因
  2. seed 来源有两条路byte_5EB88(debug bypass 标志)决定走 sub_2DCDC() 还是 qword_5EA38
  3. 可选 XORv32 控制是否在 seed 上做 ^ 0xA5A5A5A5

第五段:渲染用户输入并比较位图

v38 = GetStringUTFChars(a1, a4, 0);   // 获取用户输入的文本
sub_2D46C(&s2, v39 >> 3);              // 分配位图缓冲区
v40 = sub_2E5FC(v38, v34, v35, s2, ...); // ★ 渲染文本为 1-bit 位图
...
memcmp(v13 + 13, s2, v41);             // ★ 比较 pack 位图与渲染位图
  • sub_2E5FC 负责将用户输入的字符串渲染成一幅 1-bit 位图
  • v13 + 13pack_ptr + 52(跳过 52 字节头部),指向 pack 中存储的位图(此时已被 sub_2DDF8 原地解密)
  • 如果两个位图完全相同(memcmp == 0),验证通过,返回 pack 数据;否则返回 null

这段代码告诉我们什么?

  1. 验证逻辑不是简单的字符串比较,而是:

    • 把用户输入渲染成位图
    • 把 pack 里的加密位图解密出来
    • 比较两个位图是否完全相同
  2. 关键函数有三个

    • sub_2DCDC:获取 seed(依赖全局状态)
    • sub_2DDF8:解密 pack 位图(需要正确的 seed)
    • sub_2E5FC:渲染文本为位图(需要字模数据)
  3. 为什么不能直接用 Python 复现?

    • 因为 sub_2DCDC 返回的 seed 依赖全局状态(qword_5EA30 等变量)
    • 这些状态是由 startSessionBytes 等函数在运行时设置的
    • 如果状态不对,解密出来的位图就是错的

3.6 查看全局状态变量

为什么要看全局变量?

从上面的分析我们知道,sub_2DCDC 会读取全局状态。我们需要找到这些变量,才能在 unidbg 里正确初始化它们。

如何找到全局变量?
  1. 在伪代码窗口里,双击 sub_2DCDC 跳转到这个函数
  2. F5 查看伪代码
__int64 sub_2DCDC()
{
  // 这个函数直接返回一个全局变量
  // qword_5EA30 是一个 64 位整数(8 字节)
  return qword_5EA30;
}
  1. 双击 qword_5EA30 跳转到这个变量的定义
.bss:000000000005EA30 qword_5EA30     DQ ?    ; 未初始化的全局变量

这告诉我们什么?

  • qword_5EA30 是一个全局变量,初始值未定义。
  • 它的偏移地址是 0x5EA30(相对于 so 文件起始位置)。
  • 我们需要找到哪个函数会设置这个变量。
如何找到设置这个变量的函数?
  1. 在 IDA 伪代码窗口里,Alt+T 搜索 qword_5EA30 ,右键点击,注意在伪代码窗口里Crtl+F无法搜索!!!
  2. 选择 Jump to xref to operand...(跳转到交叉引用)或者直接按X
  3. IDA 会列出所有读写这个变量的地方

    你会看到 sub_247B0(也就是 startSessionBytes,我们最初分析 JNI_OnLoad 的时候获得)会写入这个变量。以下是完整的 IDA 反编译伪代码:
__int64 __fastcall sub_247B0(__int64 a1, __int64 a2, __int64 a3, __int64 a4, int a5)
{
  int v9; // w0
  unsigned int v10; // w9
  unsigned int v11; // w9
  __int64 v12; // x27
  int8x16_t v13; // q2
  int8x16_t v14; // q3
  int8x16_t v15; // q4
  int8x16_t v16; // q5
  __int64 v17; // x9
  __int64 v18; // x0
  __int64 v19; // x22
  __int64 v20; // x14
  __int64 v21; // x13
  const char *v22; // x12
  int8x16_t *v23; // x13
  __int64 v24; // x10
  int8x16_t v25; // q6
  int8x16_t v26; // q7
  int32x4_t v27; // q18
  int32x4_t v28; // q19
  int32x4_t v29; // q2
  int8x16_t *v30; // x14
  const char *v31; // x12
  __int64 v32; // x13
  int8x16_t v33; // q18
  __int64 v34; // x10
  __int64 v35; // x9
  unsigned __int64 v36; // x11
  _DWORD *v37; // x10
  __int64 v38; // x11
  int v39; // w12
  __int64 v40; // x9
  __int64 v41; // x11
  void **v42; // x21
  __int64 v43; // x8
  __int64 v44; // x9
  __int64 v45; // x0
  __int64 v46; // x9
  int v47; // w10
  __int64 v48; // x8
  void **v49; // x23
  unsigned __int64 v50; // x9
  unsigned __int64 v51; // x10
  unsigned __int64 v52; // x9
  __int128 v53; // t2
  unsigned __int64 v54; // x9
  unsigned __int64 v55; // x10
  unsigned __int64 v56; // x9
  __int64 v57; // x14
  unsigned __int64 v58; // x12
  unsigned __int64 v59; // x9
  unsigned __int64 v60; // x11
  __int64 result; // x0
  unsigned __int64 v62; // [xsp+10h] [xbp-210h] BYREF
  int v63; // [xsp+18h] [xbp-208h]
  int v64; // [xsp+1Ch] [xbp-204h]
  __int64 v65; // [xsp+20h] [xbp-200h]
  __int64 v66; // [xsp+28h] [xbp-1F8h]
  __int64 v67; // [xsp+30h] [xbp-1F0h]
  __int64 v68; // [xsp+210h] [xbp-10h]
  int8x8x4_t v69; // 0:kr00_32.32
  int8x16x4_t v70; // 0:q4.16,16:q5.16,32:q6.16,48:q7.16

  v68 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
  v9 = (*(__int64 (__fastcall **)(__int64, __int64))(*(_QWORD *)a1 + 1368LL))(a1, a4);
  if ( v9 <= 0 )
    v10 = 0;
  else
    v10 = v9;
  v11 = v10 >> 2;
  if ( v11 >= 0x80 )
    v12 = 128;
  else
    v12 = v11;
  if ( !(_DWORD)v12 )
  {
    v18 = 0;
    dword_5EA18 = 0;
    v19 = a3;
    dword_5EA1C = a5;
    qword_5EA20 = a3;
    goto LABEL_26;
  }
  (*(void (__fastcall **)(__int64, __int64, _QWORD, _QWORD, unsigned __int64 *))(*(_QWORD *)a1 + 1600LL))(
    a1,
    a4,
    0,
    (unsigned int)(4 * v12),
    &v62);
  if ( (unsigned int)v12 >= 8 )
  {
    if ( (unsigned int)v12 >= 0x10 )
    {
      v17 = (unsigned __int8)v12 & 0xF0;
      v14.n128_u64[1] = 0xFFFFFF03FFFFFF02LL;
      v30 = (int8x16_t *)&unk_5E818;
      v31 = (const char *)&v62;
      v13.n128_u64[1] = 0xFFFFFF0BFFFFFF0ALL;
      v32 = v17;
      do
      {
        v70 = vld4q_s8(v31);
        v31 += 64;
        v32 -= 16;
        v33 = vqtbl1q_s8(v70.val[0], (int8x16_t)xmmword_14140);
        v30[2] = vorrq_s8(
                   vorrq_s8(
                     vshlq_n_s32(vqtbl1q_s8(v70.val[1], (int8x16_t)xmmword_14130), 8u),
                     vqtbl1q_s8(v70.val[0], (int8x16_t)xmmword_14130)),
                   vorrq_s8(
                     vshlq_n_s32(vqtbl1q_s8(v70.val[2], (int8x16_t)xmmword_14130), 0x10u),
                     vshlq_n_s32(vqtbl1q_s8(v70.val[3], (int8x16_t)xmmword_14130), 0x18u)));
        v30[3] = vorrq_s8(
                   vorrq_s8(
                     vshlq_n_s32(vqtbl1q_s8(v70.val[1], (int8x16_t)xmmword_14120), 8u),
                     vqtbl1q_s8(v70.val[0], (int8x16_t)xmmword_14120)),
                   vorrq_s8(
                     vshlq_n_s32(vqtbl1q_s8(v70.val[2], (int8x16_t)xmmword_14120), 0x10u),
                     vshlq_n_s32(vqtbl1q_s8(v70.val[3], (int8x16_t)xmmword_14120), 0x18u)));
        v70.val[0] = vorrq_s8(
                       vshlq_n_s32(vqtbl1q_s8(v70.val[1], (int8x16_t)xmmword_141A0), 8u),
                       vqtbl1q_s8(v70.val[0], (int8x16_t)xmmword_141A0));
        v16 = vorrq_s8(
                vorrq_s8(vshlq_n_s32(vqtbl1q_s8(v70.val[1], (int8x16_t)xmmword_14140), 8u), v33),
                vorrq_s8(
                  vshlq_n_s32(vqtbl1q_s8(v70.val[2], (int8x16_t)xmmword_14140), 0x10u),
                  vshlq_n_s32(vqtbl1q_s8(v70.val[3], (int8x16_t)xmmword_14140), 0x18u)));
        v15 = vorrq_s8(
                v70.val[0],
                vorrq_s8(
                  vshlq_n_s32(vqtbl1q_s8(v70.val[2], (int8x16_t)xmmword_141A0), 0x10u),
                  vshlq_n_s32(vqtbl1q_s8(v70.val[3], (int8x16_t)xmmword_141A0), 0x18u)));
        *v30 = v15;
        v30[1] = v16;
        v30 += 4;
      }
      while ( v32 );
      if ( v17 == v12 )
        goto LABEL_23;
      if ( (v12 & 8) == 0 )
        goto LABEL_21;
    }
    else
    {
      v17 = 0;
    }
    v20 = v17;
    v21 = 4 * v17;
    v17 = (unsigned __int8)v12 & 0xF8;
    v22 = (char *)&v62 + v21;
    v23 = (int8x16_t *)((char *)&unk_5E818 + v21);
    v24 = v20 - v17;
    do
    {
      v69 = vld4_s8(v22);
      v13.n128_u64[0] = v69.val[0].n64_u64[0];
      v14.n128_u64[0] = v69.val[1].n64_u64[0];
      v15.n128_u64[0] = v69.val[2].n64_u64[0];
      v16.n128_u64[0] = v69.val[3].n64_u64[0];
      v22 += 32;
      v25 = vqtbl1q_s8(v13, (int8x16_t)xmmword_14140);
      v24 += 8;
      v26 = vqtbl1q_s8(v13, (int8x16_t)xmmword_141A0);
      v27 = vqtbl1q_s8(v15, (int8x16_t)xmmword_14140);
      v28 = vqtbl1q_s8(v15, (int8x16_t)xmmword_141A0);
      v29 = vqtbl1q_s8(v16, (int8x16_t)xmmword_14140);
      v15 = vorrq_s8(vshlq_n_s32(vqtbl1q_s8(v14, (int8x16_t)xmmword_141A0), 8u), v26);
      v16 = vorrq_s8(vshlq_n_s32(v28, 0x10u), vshlq_n_s32(vqtbl1q_s8(v16, (int8x16_t)xmmword_141A0), 0x18u));
      v13 = vorrq_s8(
              vorrq_s8(vshlq_n_s32(vqtbl1q_s8(v14, (int8x16_t)xmmword_14140), 8u), v25),
              vorrq_s8(vshlq_n_s32(v27, 0x10u), vshlq_n_s32(v29, 0x18u)));
      v14 = vorrq_s8(v15, v16);
      *v23 = v14;
      v23[1] = v13;
      v23 += 2;
    }
    while ( v24 );
    if ( v17 == v12 )
      goto LABEL_23;
    goto LABEL_21;
  }
  v17 = 0;
LABEL_21:
  v34 = 4 * v17;
  v35 = v12 - v17;
  v36 = (unsigned __int64)&v62 + v34;
  v37 = (_DWORD *)((char *)&unk_5E818 + v34);
  v38 = v36 | 3;
  do
  {
    v39 = *(_DWORD *)(v38 - 3);
    --v35;
    v38 += 4;
    *v37++ = v39;
  }
  while ( v35 );
LABEL_23:
  v40 = 0;
  dword_5EA18 = v12;
  dword_5EA1C = a5;
  qword_5EA20 = a3;
  do
  {
    v41 = 4 * v40++;
    *(_DWORD *)((char *)&v62 + v41) = *(_DWORD *)((char *)&unk_5E818 + v41);
  }
  while ( v12 != v40 );
  v19 = a3;
  v18 = sub_25CA8(&v62, (unsigned int)(4 * v12), 0x1A8CBC5B802E097CLL);
LABEL_26:
  qword_5EA38 = 0;
  qword_5EA28 = v18;
  byte_5EA48 = 0;
  qword_5EA30 = v18;
  byte_5EA40 = 0;
  dword_5EA44 = 0;
  byte_5E7E0 = 0;
  dword_5EA4C = 0;
  dword_5EA50 = 0;
  byte_5EA54 = 0;
  if ( qword_5E808 )
  {
    v42 = (void **)qword_5E800;
    if ( qword_5E800 )
    {
      do
      {
        v49 = (void **)*v42;
        if ( ((_BYTE)v42[2] & 1) != 0 )
          operator delete(v42[4]);
        operator delete(v42);
        v42 = v49;
      }
      while ( v49 );
    }
    v43 = qword_5E7F8;
    qword_5E800 = 0;
    if ( qword_5E7F8 )
    {
      v44 = 0;
      do
        *(_QWORD *)(*((_QWORD *)&xmmword_5E7E8 + 1) + 8 * v44++) = 0;
      while ( v43 != v44 );
    }
    qword_5E808 = 0;
  }
  *(_QWORD *)&xmmword_5E7E8 = 0;
  qword_5EA58 = 0;
  qword_5EA60 = 0;
  v45 = sub_25EF8(a1);
  v46 = dword_5EA4C | (unsigned int)v45;
  if ( dword_5EA50 + HIDWORD(v45) >= 12 )
    v47 = 12;
  else
    v47 = dword_5EA50 + HIDWORD(v45);
  dword_5EA4C |= v45;
  dword_5EA50 = v47;
  if ( v47 < 4 )
  {
    v48 = qword_5EA28;
    if ( byte_5EA54 != 1 )
    {
      v52 = 0;
      goto LABEL_44;
    }
  }
  else
  {
    v48 = qword_5EA28;
    byte_5EA54 = 1;
  }
  v50 = (v47 ^ (unsigned __int64)(v46 << 32) ^ 0x1A8CBC5B802E097CLL) - 0x61C8864680B583EBLL;
  v51 = 0x94D049BB133111EBLL
      * ((0xBF58476D1CE4E5B9LL * (v50 ^ (v50 >> 30))) ^ ((0xBF58476D1CE4E5B9LL * (v50 ^ (v50 >> 30))) >> 27));
  v52 = v51 ^ (v51 >> 31);
  if ( v52 )
  {
    *((_QWORD *)&v53 + 1) = v51 ^ (v51 >> 31);
    *(_QWORD *)&v53 = v51;
    v48 ^= (v53 >> 35) ^ v52;
  }
LABEL_44:
  v54 = (v52 ^ ((unsigned __int64)(unsigned int)dword_5EA18 << 32) ^ v19 ^ 0x1A8CBC5B802E097CLL) - 0x61C8864680B583EBLL;
  dword_5EA98 = 0;
  v55 = 0xBF58476D1CE4E5B9LL
      * (((v48 ^ 0x1A8CBC5B802E097CLL) - 0x61C8864680B583EBLL)
       ^ (((v48 ^ 0x1A8CBC5B802E097CuLL) - 0x61C8864680B583EBLL) >> 30));
  qword_5EA30 = v48;
  dword_5EAA0 = 0;
  qword_5EA88 = 0;
  v56 = 0x94D049BB133111EBLL
      * ((0xBF58476D1CE4E5B9LL * (v54 ^ (v54 >> 30))) ^ ((0xBF58476D1CE4E5B9LL * (v54 ^ (v54 >> 30))) >> 27));
  v67 = 0;
  qword_5EA90 = (0x94D049BB133111EBLL * (v55 ^ (v55 >> 27))) ^ ((0x94D049BB133111EBLL * (v55 ^ (v55 >> 27))) >> 31);
  dword_5EA80 = 0;
  v57 = ((v56 ^ (v56 >> 31) ^ ((v56 ^ (v56 >> 31)) >> 32)) << 32) ^ 0x1A8CBC5B802E097CLL;
  v65 = 0;
  v66 = qword_5EA90;
  v58 = 0xBF58476D1CE4E5B9LL * ((v57 - 0x61C8864680B583EBLL) ^ ((unsigned __int64)(v57 - 0x61C8864680B583EBLL) >> 30));
  dword_5EA78 = v56 ^ (v56 >> 31) ^ ((v56 ^ (v56 >> 31)) >> 32);
  v63 = dword_5EA78;
  v64 = 0;
  v59 = 0x94D049BB133111EBLL
      * ((0xBF58476D1CE4E5B9LL
        * (((v57 ^ qword_5EA90) - 0x61C8864680B583EBLL)
         ^ (((v57 ^ (unsigned __int64)qword_5EA90) - 0x61C8864680B583EBLL) >> 30)))
       ^ ((0xBF58476D1CE4E5B9LL
         * (((v57 ^ qword_5EA90) - 0x61C8864680B583EBLL)
          ^ (((v57 ^ (unsigned __int64)qword_5EA90) - 0x61C8864680B583EBLL) >> 30))) >> 27));
  v60 = 0x94D049BB133111EBLL
      * ((0xBF58476D1CE4E5B9LL * ((v57 + 0x3C6EF372FE94F82ALL) ^ ((unsigned __int64)(v57 + 0x3C6EF372FE94F82ALL) >> 30)))
       ^ ((0xBF58476D1CE4E5B9LL * ((v57 + 0x3C6EF372FE94F82ALL) ^ ((unsigned __int64)(v57 + 0x3C6EF372FE94F82ALL) >> 30))) >> 27));
  qword_5EA68 = v59 ^ (v59 >> 31);
  v62 = v59 ^ (v59 >> 31);
  result = sub_2D248(
             &v62,
             40,
             (0x94D049BB133111EBLL * (v58 ^ (v58 >> 27))) ^ ((0x94D049BB133111EBLL * (v58 ^ (v58 >> 27))) >> 31),
             v60 ^ (v60 >> 31));
  qword_5EA70 = result;
  byte_5EAA4 = 1;
  return result;
}
逐段拆解

这段代码很长(大量 NEON SIMD 指令用于字节重排),但核心逻辑只有三步:

第一步:获取 beatMap 并存入全局缓冲区

v9 = GetArrayLength(a1, a4);     // 获取 beatMap 字节数组长度
v11 = v10 >> 2;                   // 元素个数 = 字节长度 / 4
if ( v11 >= 0x80 ) v12 = 128;    // 最多 128 个元素
GetByteArrayRegion(a1, a4, 0, 4*v12, &v62);  // 拷贝到栈上

中间大段的 NEON 指令(vld4q_s8vqtbl1q_s8vshlq_n_s32 等)是 ARM 的 SIMD 向量操作,功能是将 beatMap 字节做交错重排(deinterleave),结果写入全局数组 unk_5E818。这部分不影响我们的 unidbg 复现策略,可以跳过。

第二步:计算 seed 并初始化全局状态

v18 = sub_25CA8(&v62, 4 * v12, 0x1A8CBC5B802E097CLL);  // ★ hash 函数
qword_5EA38 = 0;
qword_5EA28 = v18;        // seed 备份
qword_5EA30 = v18;        // seed 主变量(后续 sub_2DCDC 读取的就是它)
byte_5EA40 = 0;
dword_5EA44 = 0;
// ... 以及一系列其他全局变量清零 ...
  • sub_25CA8 是一个 64-bit hash 函数,输入=beatMap 字节 + 常量 0x1A8CBC5B802E097C
  • 计算结果同时写入 qword_5EA28(备份)和 qword_5EA30(主变量)
  • 这就是我们在 unidbg 里需要复现的关键操作

第三步:环境混入 + 状态派生

v45 = sub_25EF8(a1);             // ★ 环境指纹(同 verifyAndDecrypt 里的那个)
v46 = dword_5EA4C | v45;
v47 = dword_5EA50 + HIDWORD(v45);
// ... SplitMix64 派生 ...
qword_5EA30 = v48;               // ★ 再次更新 qword_5EA30

最后还会调用 sub_2D248(SipHash 风格的 PRF)进一步派生更多全局状态(qword_5EA68qword_5EA70qword_5EA90 等),这些是游戏运行时的内部状态机。

这段代码告诉我们什么?

  1. qword_5EA30 是由 startSessionBytes 函数设置的。
  2. 它的值是通过 sub_25CA8 函数计算出来的。
  3. 计算需要三个输入:
    • beatMap 数据(从 Java 层传来)
    • beatMap 长度(4 * beat_count
    • 固定常量 0x1A8CBC5B802E097C

3.7 IDA 分析小结

到这里,我们已经完整理解了 native 层的逻辑:

verifyAndDecrypt (0x257DC)
  ↓
解析 pack header(获取 mode, width, height)
  ↓
如果 mode == 2:
  ↓
  sub_2DCDC (0x2DCDC) ← 读取 qword_5EA30
  ↓
  sub_2DDF8 (0x2DDF8) ← 解密 pack 位图
  ↓
  sub_2E5FC (0x2E5FC) ← 渲染用户输入为位图
  ↓
  memcmp ← 比较两个位图

我们现在知道了什么?

  1. 验证逻辑是"位图比较",不是"字符串比较"。
  2. 解密 pack 位图需要正确的 seed(存储在 qword_5EA30)。
  3. seed 是由 startSessionBytes 函数初始化的。
  4. 如果我们能拿到解密后的 pack 位图,就能获取正确的 flag。
  5. 详细流程图如下:

下一步要做什么?

用 unidbg 模拟执行 native 代码,拿到解密后的 pack 位图。


4. 第四步:为什么需要 unidbg

4.1 纯 Python 复现的困境

你可能会想:既然我们已经知道了算法流程,为什么不直接用 Python 写一遍?

问题 1:状态依赖太复杂
  • qword_5EA30 的初始化需要调用 sub_25CA8(一个复杂的 hash 函数)。
  • sub_25CA8 内部可能还调用了其他函数。
  • 手动用 Python 复现这些函数,工作量巨大且容易出错。
问题 2:中间状态难以验证
  • 即使你用 Python 写出了 sub_25CA8,你怎么知道写对了?
  • 如果中间某一步算错了,后面的结果全错,但你不知道错在哪里。
问题 3:反调试和环境检测
  • native 代码里有反调试逻辑(检测是否在调试器里运行)。
  • 有环境检测(检测是否在真实 Android 设备上)。
  • 纯 Python 无法绕过这些检测。

4.2 unidbg 的优势

unidbg 是一个基于 Unicorn 引擎的 Android 模拟器,它可以:

  1. 直接执行 native 代码:不需要手动复现算法,让原始代码自己跑。
  2. 模拟 Android 环境:提供 JNI 接口、系统调用等,让 native 代码以为自己在真实设备上。
  3. 可控的执行:你可以在任意位置打断点、查看内存、修改变量。

用 unidbg 的策略

  • 不追求"一次性算出 flag"。
  • 先追求"拿到解密后的 pack 位图"(中间态)。
  • 再用 Python 做 OCR 识别(简单任务)。

这样把复杂问题拆成两个简单问题,降低了失败风险。


5. 第五步:用 unidbg 获取解密后的位图

5.1 unidbg 项目结构

本地真实目录结构如下(APK 已解压在 app/,unidbg 项目在同级的 unidbg_runner/):

Arnold逆向教程/                          ← 项目根目录
├── app/                                 ← 第一步解压得到的 APK 目录(保持原位即可)
│   ├── assets/
│   │   └── hjm_pack.bin               ← 加密数据包(Runner.java 会自动找到)
│   └── lib/
│       └── arm64-v8a/
│           └── libhajimi.so             ← native 库(Runner.java 会自动找到)
├── unidbg_runner/                       ← unidbg Java 项目
│   ├── pom.xml                          ← Maven 配置文件
│   ├── unidbg_dump.bin                  ← 运行后自动生成在这里(解密结果)
│   └── src/
│       └── main/
│           └── java/
│               └── com/
│                   └── ctf/
│                       └── Runner.java  ← unidbg 主程序(包名 com.ctf)
├── dump_flag.py                         ← 运行 unidbg 的 Python 封装
└── solve_flag.py                        ← 提取位图并生成预览

关键点libhajimi.sohjm_pack.bin 不需要手动复制到 Java 项目里。Runner.java 内置了 locateExtractedApkDir() 方法,会自动在当前目录和父目录中递归搜索这两个文件,找到后直接使用。只要 unidbg_runner/app/ 放在同一父目录下,就能零配置运行。

第一步:用 Maven 创建项目骨架

Arnold逆向教程/ 目录下打开命令行,运行:

mvn archetype:generate -DgroupId=com.ctf -DartifactId=unidbg_runner -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

解释:让 Maven 自动生成一个包名为 com.ctf、项目名为 unidbg_runner 的标准 Java 工程骨架。

看到 BUILD SUCCESS 后,Maven 会生成如下结构:

unidbg_runner/
├── pom.xml
└── src/
    ├── main/
    │   └── java/
    │       └── com/
    │           └── ctf/
    │               └── App.java   ← Maven 自动生成的占位类,后面替换为 Runner.java
    └── test/                      ← 测试目录,CTF 中用不到,可忽略

第二步:将 App.java 替换为 Runner.java

src/main/java/com/ctf/App.java 删除(或直接覆盖),新建同路径的 Runner.java,内容见 5.3 节。无需创建 resources 目录,也无需复制任何 so 或 bin 文件。

5.2 pom.xml 配置文件

这个文件告诉 Maven 项目的依赖和构建配置。

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <!-- ===== 项目坐标(唯一标识这个项目) ===== -->
  <!-- groupId:组织/公司标识,CTF 项目随便填,不影响运行 -->
  <groupId>local</groupId>
  <!-- artifactId:项目名,Maven 命令和目录名都以此为准 -->
  <artifactId>unidbg-runner</artifactId>
  <!-- version:SNAPSHOT 表示仍在开发中的快照版本 -->
  <version>1.0-SNAPSHOT</version>

  <!-- ===== 全局属性(可在下方用 ${变量名} 引用) ===== -->
  <properties>
    <!-- 指定用 Java 17 编译源码;unidbg 0.9.8 要求 Java 11+,推荐 17 -->
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <!-- 源码和资源文件统一用 UTF-8,避免中文路径乱码 -->
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <!-- 统一管理 unidbg 版本号,改这一处即可同步升级所有相关依赖 -->
    <unidbg.version>0.9.8</unidbg.version>
  </properties>

  <!-- ===== 依赖声明(Maven 会自动从远程仓库下载这些 jar) ===== -->
  <dependencies>

    <!-- unidbg 核心:提供 Android 模拟器、Dalvik VM、JNI 环境 -->
    <dependency>
      <groupId>com.github.zhkl0228</groupId>
      <artifactId>unidbg-android</artifactId>
      <version>${unidbg.version}</version>
    </dependency>

    <!-- Dynarmic 后端:高性能 ARM 动态翻译引擎,大幅加快 native 代码执行速度 -->
    <!-- 不加这个也能跑,但会慢很多(回退到纯解释执行) -->
    <dependency>
      <groupId>com.github.zhkl0228</groupId>
      <artifactId>unidbg-dynarmic</artifactId>
      <version>${unidbg.version}</version>
    </dependency>

    <!-- 日志框架:让 unidbg 的内部日志能正常输出到控制台 -->
    <!-- 不加会报 "SLF4J: No SLF4J providers were found." 警告 -->
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-simple</artifactId>
      <version>2.0.13</version>
    </dependency>

  </dependencies>

  <!-- ===== 构建插件配置 ===== -->
  <build>
    <plugins>
      <!-- exec-maven-plugin:让 "mvn exec:java" 命令能直接运行指定的主类 -->
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.3.0</version>
        <configuration>
          <!-- 告诉插件入口类是哪个;包名 com.ctf 必须与 Runner.java 的 package 声明完全一致 -->
          <mainClass>com.ctf.Runner</mainClass>
        </configuration>
      </plugin>
    </plugins>
  </build>

</project>

5.3 Runner.java 主程序

下面是完整的 Runner.java。它的核心思路:不模拟完整的 startSessionBytes,而是只调用关键的子函数 + 手动写入全局变量,用最少的操作拿到解密后的 pack 位图。

注意:实际代码中的 locateExtractedApkDir() 方法会自动在当前目录和父目录下搜索 libhajimi.so,所以你不需要手动指定路径。

package com.ctf;

import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.DalvikModule;
import com.github.unidbg.linux.android.dvm.VM;
import com.github.unidbg.memory.Memory;
import com.github.unidbg.memory.MemoryBlock;
import com.github.unidbg.pointer.UnidbgPointer;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;

public class Runner {

    // ===== 关键函数在 so 中的偏移量(相对于加载基址)=====
    // 这些值通过 IDA 静态分析得到,代表每个函数从 so 文件 .text 段开头算起的字节偏移。
    // 运行时:函数真实地址 = 模拟器分配的 base 地址 + 这里的偏移量

    // sub_25CA8:接收节拍数组,计算并返回全局哈希常量 qword_5EA30
    private static final long OFF_sub_25CA8 = 0x25CA8;
    // sub_2DCDC:读取 qword_5EA30,生成解密用的随机种子 seed
    private static final long OFF_sub_2DCDC = 0x2DCDC;
    // sub_2DDF8:核心解密函数,用 seed 对内存中的 pack 数据进行就地解密
    private static final long OFF_sub_2DDF8 = 0x2DDF8;

    // ===== 关键全局变量在 so 中的偏移量 =====
    // dword_5EA18:存储节拍数组的元素个数(int,4 字节),sub_2DCDC 内部会读取
    private static final long OFF_dword_5EA18 = 0x5EA18;
    // qword_5EA30:存储由节拍数据计算出的核心哈希值(long,8 字节),sub_2DCDC / sub_2DDF8 均依赖它
    private static final long OFF_qword_5EA30 = 0x5EA30;

    public static void main(String[] args) throws Exception {
        // ===== 第一步:定位 APK 解压目录 =====
        // 优先解析命令行的 --appDir 参数;若未指定,则调用 locateExtractedApkDir() 自动搜索
        Path appDir = parseAppDirArg(args).orElseGet(() -> {
            try {
                return locateExtractedApkDir();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
        // 拼接出 so 库和加密 pack 的完整路径
        Path soPath = appDir.resolve(Path.of("lib", "arm64-v8a", "libhajimi.so")).normalize();
        Path packPath = appDir.resolve(Path.of("assets", "hjm_pack.bin")).normalize();
        // 解密结果写到当前目录下的 unidbg_dump.bin
        Path outPath = Path.of(".", "unidbg_dump.bin").normalize();

        // 提前检查文件是否存在,给出清晰错误提示,避免后续运行时报迷惑性错误
        if (!Files.isRegularFile(soPath)) {
            throw new FileNotFoundException("Missing native library: " + soPath.toAbsolutePath());
        }
        if (!Files.isRegularFile(packPath)) {
            throw new FileNotFoundException("Missing pack file: " + packPath.toAbsolutePath());
        }

        // ===== 第二步:创建 ARM64 模拟器实例 =====
        // for64Bit() 指定模拟 64 位 ARM 架构(对应 lib/arm64-v8a/ 目录下的 so)
        // setProcessName 设置模拟进程名与真实 APK 包名一致,
        //   防止 so 内的反调试逻辑(检测进程名)被触发
        AndroidEmulator emulator = AndroidEmulatorBuilder.for64Bit()
                .setProcessName("com.zj.wuaipojie2026_2")
                .build();

        // ===== 第三步:配置模拟器内存与系统库解析器 =====
        // Memory 接口负责管理整个模拟内存空间(so 加载、malloc、指针读写)
        Memory memory = emulator.getMemory();
        // AndroidResolver(30) = 模拟 Android 11(API 30)的系统共享库(libc.so 等)
        memory.setLibraryResolver(new AndroidResolver(30));

        // ===== 第四步:创建 Dalvik 虚拟机(提供 JNI 运行环境)=====
        // 传 null 表示不加载真实 dex,只创建最小化的 JNI 环境供 so 调用
        VM vm = emulator.createDalvikVM((File) null);
        vm.setVerbose(false); // 关闭 JNI 调用的逐行日志,保持输出简洁

        // ===== 第五步:加载 so 文件并触发初始化 =====
        // loadLibrary 把 so 映射进模拟内存,false = 不立即执行初始化(延迟到 callJNI_OnLoad)
        DalvikModule dm = vm.loadLibrary(soPath.toFile(), false);
        // callJNI_OnLoad 触发 so 的 JNI_OnLoad 函数:注册 JNI 方法、初始化全局静态变量等
        dm.callJNI_OnLoad(emulator);
        Module module = dm.getModule();
        long base = module.base; // so 被分配到的基址(每次运行由模拟器随机分配)
        System.out.println("[+] libhajimi.so base=0x" + Long.toHexString(base));

        // ===== 第六步:读取加密包,准备节拍数据 =====
        byte[] pack = Files.readAllBytes(packPath); // 将整个加密 bin 文件读入内存

        // beatMap 是节拍时间戳数组,由对 Java 层代码的逆向分析得出
        // 单位:毫秒,表示游戏中节拍出现的时刻
        int[] beatMap = new int[] { 0, 250, 500, 750 };
        // ARM 是小端序(Little-Endian),必须把 int[] 转为小端字节序才能被 so 正确读取
        byte[] beatBytes = toLittleEndianIntBytes(beatMap);

        // 在模拟器内存中分配空间并写入节拍字节数据
        MemoryBlock beatBlock = memory.malloc(beatBytes.length, true); // true = 分配时清零
        UnidbgPointer beatPtr = beatBlock.getPointer();                // 获取该内存块的指针
        beatPtr.write(0, beatBytes, 0, beatBytes.length);              // 把节拍数据写进去

        // ===== 第七步:调用 sub_25CA8,计算全局哈希 qword_5EA30 =====
        // 参数依次为:
        //   beatPtr              → 节拍数据数组的内存指针
        //   beatBytes.length     → 数组字节总长度
        //   0x1A8CBC5B802E097CL  → 硬编码初始哈希常量(通过逆向 so 中的魔数得出)
        // 返回值:计算得到的 64 位哈希,即全局变量 qword_5EA30 的目标值
        long qword5ea30 = module.callFunction(
                emulator,
                OFF_sub_25CA8,
                beatPtr,
                beatBytes.length,
                0x1A8CBC5B802E097CL).longValue();

        // ===== 第八步:手动写入全局变量(绕过完整的 Java 层初始化流程)=====
        // 正常运行时,这两个变量由 Java 层的 startSessionBytes() 函数初始化。
        // unidbg 不跑完整 Java 层,所以我们直接用指针把正确的值写进 so 的数据段。
        UnidbgPointer pDword5ea18 = UnidbgPointer.pointer(emulator, base + OFF_dword_5EA18);
        UnidbgPointer pQword5ea30 = UnidbgPointer.pointer(emulator, base + OFF_qword_5EA30);
        pDword5ea18.setInt(0, beatMap.length);   // 写入节拍数组元素个数(int)
        pQword5ea30.setLong(0, qword5ea30);      // 写入刚才计算出的哈希(long)
        System.out.println("[+] set dword_5EA18=" + beatMap.length + " qword_5EA30=0x" + Long.toHexString(qword5ea30));

        // ===== 第九步:调用 sub_2DCDC,生成解密种子 seed =====
        // 此函数内部读取 dword_5EA18 和 qword_5EA30,执行一段伪随机算法,返回 seed
        // seed 是后续解密的"钥匙",必须与加密时一致才能解出正确数据
        long seed = module.callFunction(emulator, OFF_sub_2DCDC).longValue();
        System.out.println("[+] seed(sub_2DCDC)=0x" + Long.toHexString(seed));

        // ===== 第十步:将 pack 数据写入模拟器内存,构造向量结构体 =====
        MemoryBlock dataBlock = memory.malloc(pack.length, true);
        UnidbgPointer dataPtr = dataBlock.getPointer();
        dataPtr.write(0, pack, 0, pack.length); // 把加密数据拷进模拟内存

        // sub_2DDF8 接收的不是裸指针,而是 C++ std::vector<uint8_t>& 风格的结构体:
        //   { uint8_t* begin;  (8 字节,数据起始地址)
        //     uint8_t* end;    (8 字节,数据结束地址) }
        // 分配 16 字节,手动填写 begin / end 指针
        MemoryBlock vecBlock = memory.malloc(16, true);
        UnidbgPointer vecPtr = vecBlock.getPointer();
        vecPtr.setLong(0, dataPtr.peer);               // begin = 数据起始地址
        vecPtr.setLong(8, dataPtr.peer + pack.length); // end   = 数据结束地址(左闭右开)

        // ===== 第十一步:调用 sub_2DDF8 就地解密 pack 数据 =====
        // 函数直接修改 vecPtr 指向的内存区域(就地解密,不另开缓冲区)
        // 返回值:1 = 解密成功,0 = 失败(通常是 seed 或全局变量有误)
        Number ok = module.callFunction(emulator, OFF_sub_2DDF8, vecPtr, seed);
        System.out.println("[+] sub_2DDF8 ret=" + ok);

        // ===== 第十二步:从模拟内存读回解密结果,写入磁盘文件 =====
        byte[] out = dataPtr.getByteArray(0, pack.length);
        Files.write(outPath, out);
        System.out.println("[+] wrote " + out.length + " bytes to: " + outPath.toAbsolutePath());

        // ===== 第十三步:释放内存块并关闭模拟器(防止资源泄漏)=====
        vecBlock.free();
        dataBlock.free();
        beatBlock.free();
        emulator.close();
    }

    /**
     * 将 int 数组按小端序(Little-Endian)转换为字节数组。
     *
     * <p>ARM 处理器使用小端序存储多字节整数:低位字节存放在低地址。
     * 例如整数 0x01020304 在小端内存中排列为:04 03 02 01。
     * 如果直接用 Java 默认的大端序写入,so 读出的值会完全错误,
     * 导致哈希计算结果不一致,最终解密失败。
     *
     * @Param ints 原始 int 数组(节拍时间戳,单位毫秒)
     * @Return 按小端序排列的等价字节数组
     */
    private static byte[] toLittleEndianIntBytes(int[] ints) {
        ByteBuffer bb = ByteBuffer.allocate(ints.length * 4).order(ByteOrder.LITTLE_ENDIAN);
        for (int v : ints) {
            bb.putInt(v);
        }
        return bb.array();
    }

    /**
     * 解析命令行参数,提取 --appDir 选项指定的路径。
     *
     * <p>用法示例:java Runner --appDir C:\Users\xxx\Desktop\app
     * 若命令行未提供此参数,返回 Optional.empty(),
     * 调用方随后会使用 locateExtractedApkDir() 自动搜索。
     *
     * @param args main 方法接收的命令行参数数组
     * @return 包含 APK 解压目录路径的 Optional;未指定时为 Optional.empty()
     */
    private static Optional<Path> parseAppDirArg(String[] args) {
        for (int i = 0; i < args.length; i++) {
            if ("--appDir".equals(args[i]) && i + 1 < args.length) {
                return Optional.of(Path.of(args[i + 1]).normalize());
            }
        }
        return Optional.empty();
    }

    /**
     * 自动在当前目录及父目录中递归搜索已解压的 APK 根目录。
     *
     * <p>判断标准:目录下必须同时存在:
     * <ul>
     *   <li>lib/arm64-v8a/libhajimi.so(native 库)</li>
     *   <li>assets/hjm_pack.bin(加密数据包)</li>
     * </ul>
     *
     * <p搜索深度上限为 8 层,避免遍历整个文件系统造成性能问题。
     * 搜索范围包含当前工作目录(unidbg_runner/)和其父目录(项目根目录),
     * 这样无论从哪个目录执行 mvn exec:java 都能正确找到文件。
     *
     * @return 找到的 APK 解压根目录的规范化路径
     * @throws FileNotFoundException 若两个搜索根均未找到符合条件的目录,
     *         提示用户通过 --appDir 手动指定
     */
    private static Path locateExtractedApkDir() throws IOException {
        Path cwd = Path.of("").toAbsolutePath().normalize();
        // 同时搜索当前目录和父目录,覆盖从不同工作目录启动的情况
        List<Path> searchRoots = List.of(cwd, cwd.getParent() != null ? cwd.getParent() : cwd);
        // 用路径中包含标准 lib 目录结构作为识别 APK 解压目录的特征
        String needle = File.separator + "lib" + File.separator + "arm64-v8a" + File.separator + "libhajimi.so";

        for (Path root : searchRoots) {
            if (root == null || !Files.isDirectory(root)) {
                continue;
            }

            // Files.find 递归遍历,返回第一个文件名匹配且路径符合特征的 so 文件
            Optional<Path> so = Files.find(
                    root,
                    8,          // 最大递归深度,防止无限遍历
                    (p, a) -> a.isRegularFile()
                            && "libhajimi.so".equals(p.getFileName().toString())
                            && p.toString().contains(needle))
                    .findFirst();

            if (so.isEmpty()) {
                continue;
            }

            // so 路径结构:<appDir>/lib/arm64-v8a/libhajimi.so
            // 连续向上三级(arm64-v8a → lib → appDir)即可得到 APK 解压根目录
            Path appDir = so.get().getParent().getParent().getParent();
            Path pack = appDir.resolve(Path.of("assets", "hjm_pack.bin"));
            // 必须同时存在 pack 文件,才认定这是完整的 APK 解压目录
            if (Files.isRegularFile(pack)) {
                return appDir.normalize();
            }
        }

        throw new FileNotFoundException(
                "Could not locate extracted APK dir. Pass it explicitly via: --appDir <path>");
    }
}

确实,配环境特别复杂,这里我用LLM直接帮我构建的环境。XD

5.4 运行 unidbg 脚本

第一步:安装依赖

unidbg_runner/ 目录下打开命令行(PowerShell 或 CMD),运行:

# 下载依赖并编译项目
mvn clean compile

你应该看到类似输出:

PS unidbg_runner> mvn clean compile  
[INFO] Scanning for projects...
[INFO] 
[INFO] ------------------------< local:unidbg-runner >-------------------------
[INFO] Building unidbg-runner 1.0-SNAPSHOT
[INFO]   from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 
[INFO] --- clean:3.2.0:clean (default-clean) @ unidbg-runner ---
[INFO] Deleting <项目目录>\unidbg_runner\target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ unidbg-runner ---
[INFO] skip non existing resourceDirectory <项目目录>\unidbg_runner\src\main\resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ unidbg-runner ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug target 17] to target\classes
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  1.130 s
[INFO] Finished at: 2026-03-03T22:12:50+08:00
[INFO] ------------------------------------------------------------------------

如果看到 BUILD SUCCESS,说明编译成功。

第二步:运行程序
# 运行主程序
mvn -q exec:java

参数说明:

  • -q:安静模式(只显示关键输出,不显示 Maven 的详细日志)
  • exec:java:执行 Java 程序(主类在 pom.xml 里配置过了)

你应该看到类似输出:

信息: [libhajimi.so]getpid symbol is missing before init relocationAddr=RW@0x4005d6f0[libhajimi.so]0x5d6f0
[+] libhajimi.so base=0x40000000
[+] set dword_5EA18=4 qword_5EA30=0x810a3647628f0212
[+] seed(sub_2DCDC)=0xd2c13a26769ce7e0
[+] sub_2DDF8 ret=1
[+] wrote 564 bytes to: <项目目录>\unidbg_runner\unidbg_dump.bin

关键检查点

  1. sub_2DDF8 ret=1:表示解密成功(如果是 0,说明失败)
  2. wrote 564 bytes:输出文件大小应该和输入一样(564 字节)
  3. 文件路径:确认 unidbg_dump.bin 已经生成

6. 第六步:用 Python 封装 unidbg 流程

6.1 为什么需要这个脚本

虽然我们已经有了 Runner.java,但每次运行都要:

  1. 打开命令行
  2. 切换到 unidbg_runner/ 目录
  3. 输入 mvn -q exec:java
  4. 检查输出文件

这个流程对新手不友好,容易出错。所以我们写一个 Python 脚本,把这些步骤自动化。

6.2 dump_flag.py 完整代码

实际脚本比较精简——验证输入文件头部、调用 Maven、验证输出文件头部,三步完成:

from __future__ import annotations

import argparse
import shutil
import struct
import subprocess
import sys
from pathlib import Path

# ===== 路径常量定义 =====
# ROOT:本脚本所在的目录(即项目根目录),用于推导所有相对路径
ROOT = Path(__file__).resolve().parent
# RUNNER_DIR:unidbg Java 项目的根目录,包含 pom.xml,是 mvn 命令的工作目录
RUNNER_DIR = ROOT / "unidbg_runner"
# DEFAULT_DUMP:unidbg Runner.java 执行后输出的解密文件路径
DEFAULT_DUMP = ROOT / "unidbg_dump.bin"
# PACK_PATH:从 APK 中提取的原始加密数据包路径
PACK_PATH = (
    ROOT
    / "app"
    / "assets"
    / "hjm_pack.bin"
)

def parse_hjm_header(buf: bytes) -> tuple[int, int, int, int, int]:
    """
    解析 HJM 自定义文件格式的头部,提取关键元数据。

    HJM 头部结构(共 52 字节,全部采用小端序):
      偏移  字节数  字段名        说明
      0     4       magic         魔数,固定为 0x314D4A48(ASCII 小端 = "HJM1")
      4     4       mode          数据模式(2 = 1-bit 单色位图)
      8     4       frame_count   帧数(本题为 1 帧)
      12    4       width         位图宽度(单位:像素)
      16    4       height        位图高度(单位:像素)
      20    32      (保留字段,此函数不解析)

    Args:
        buf: 文件的完整字节数据

    Returns:
        (magic, mode, frame_count, width, height) 五元组

    Raises:
        ValueError: 文件数据不足 52 字节
    """
    if len(buf) < 52:
        raise ValueError("file too small for HJM header")
    magic, mode, frame_count, width, height = struct.unpack_from("<5I", buf, 0)
    return magic, mode, frame_count, width, height

def run_unidbg() -> None:
    """
    在子进程中调用 Maven 执行 unidbg Runner.java,完成 so 加载与解密。

    执行流程:
      1. 检测 mvn 命令是否在系统 PATH 中(未找到则报错提示安装)
      2. 检验 RUNNER_DIR 目录是否存在
      3. 在 RUNNER_DIR 下执行:mvn -q -DskipTests exec:java
         - -q            : 静默模式,减少 Maven 构建日志输出
         - -DskipTests   : 跳过单元测试,提升执行速度
         - exec:java     : 运行 pom.xml 中 mainClass 配置的主类(com.ctf.Runner)
      4. 过滤输出,只打印包含 "[+]" 的关键信息行
      5. 若 Maven 返回非零退出码,输出完整 stderr 并抛出 RuntimeError

    注意:Runner.java 会自动搜索 libhajimi.so 和 hjm_pack.bin,
          此处无需手动传递文件路径。
    """
    mvn = shutil.which("mvn")  # 在系统 PATH 中查找 mvn 可执行文件
    if mvn is None:
        raise RuntimeError("`mvn` not found in PATH. Please install Maven first.")
    if not RUNNER_DIR.exists():
        raise RuntimeError(f"Runner directory not found: {RUNNER_DIR}")

    cmd = [mvn, "-q", "-DskipTests", "exec:java"]
    # capture_output=True 把 stdout/stderr 捕获到变量,而不打印到终端
    proc = subprocess.run(cmd, cwd=RUNNER_DIR, text=True, capture_output=True)

    # 只打印含 "[+]" 的关键输出行,过滤 Maven 的冗余日志
    out = proc.stdout.strip()
    if out:
        for line in out.splitlines():
            if "[+]" in line:
                print(line)

    # 构建失败时打印完整 stderr,帮助定位问题(编译错误、找不到类等)
    if proc.returncode != 0:
        sys.stderr.write(proc.stderr)
        raise RuntimeError(f"unidbg runner failed with code {proc.returncode}")

def main() -> None:
    """
    主流程:验证输入文件 → 运行 unidbg → 验证输出文件。

    三个步骤:
      步骤 1  解析并校验 hjm_pack.bin 的文件头(确认魔数正确)
      步骤 2  调用 run_unidbg() 执行解密(可通过 --skip-unidbg 跳过)
      步骤 3  解析并校验 unidbg_dump.bin 的文件头(确认解密后格式正确)

    命令行参数:
      --skip-unidbg   跳过 unidbg 执行,仅验证已有的 dump 文件(调试用)
      --dump PATH     指定 dump 输出文件路径(默认:./unidbg_dump.bin)

    正常完成时打印两行 header 信息供人工核查;
    任何步骤异常都会直接抛出对应类型的异常。
    """
    parser = argparse.ArgumentParser(
        description="Reproduce dump pipeline: assets/hjm_pack.bin -> unidbg_dump.bin"
    )
    parser.add_argument(
        "--skip-unidbg",
        action="store_true",
        help="Do not execute unidbg; only validate existing dump/header",
    )
    parser.add_argument(
        "--dump",
        type=Path,
        default=DEFAULT_DUMP,
        help="Expected dump output path (default: ./unidbg_dump.bin)",
    )
    args = parser.parse_args()

    # ===== 步骤 1:验证原始加密包的文件头 =====
    if not PACK_PATH.exists():
        raise FileNotFoundError(f"assets pack not found: {PACK_PATH}")
    pack = PACK_PATH.read_bytes()
    pmagic, pmode, pframes, pwidth, pheight = parse_hjm_header(pack)
    # 校验魔数:0x314D4A48 = 小端序的 "HJM1",确保文件格式正确
    if pmagic != 0x314D4A48:
        raise ValueError(f"Unexpected pack magic: 0x{pmagic:08x}")
    print(
        "pack_header: "
        f"mode={pmode} frame_count={pframes} width={pwidth} height={pheight} size={len(pack)}"
    )

    # ===== 步骤 2:运行 unidbg 执行解密(可通过 --skip-unidbg 跳过)=====
    if not args.skip_unidbg:
        run_unidbg()

    # ===== 步骤 3:验证解密后 dump 文件的文件头,确认解密成功 =====
    dump_path = args.dump.resolve()
    if not dump_path.exists():
        raise FileNotFoundError(f"dump not found: {dump_path}")

    dump = dump_path.read_bytes()
    dmagic, dmode, dframes, dwidth, dheight = parse_hjm_header(dump)
    # dump 的魔数应与原始 pack 完全相同,否则说明文件损坏或解密流程异常
    if dmagic != 0x314D4A48:
        raise ValueError(f"Unexpected dump magic: 0x{dmagic:08x}")
    # 位图数据大小 = (宽 × 高) ÷ 8(每像素 1 bit,8 个像素合为 1 字节)
    bitmap_len = (dwidth * dheight) // 8
    print(
        "dump_header: "
        f"mode={dmode} frame_count={dframes} width={dwidth} height={dheight} "
        f"bitmap_len={bitmap_len} size={len(dump)}"
    )
    print(f"dump_path={dump_path}")

if __name__ == "__main__":
    main()

6.3 运行 dump_flag.py

# 正常运行(会调用 unidbg)
python dump_flag.py

你应该看到类似输出:

pack_header: mode=2 frame_count=1 width=64 height=64 size=564
[+] libhajimi.so base=0x40000000
[+] set dword_5EA18=4 qword_5EA30=0x810a3647628f0212
[+] seed(sub_2DCDC)=0xd2c13a26769ce7e0
[+] sub_2DDF8 ret=1
[+] wrote 564 bytes to: <项目目录>\unidbg_runner\unidbg_dump.bin
dump_header: mode=2 frame_count=1 width=64 height=64 bitmap_len=512 size=564
dump_path=<项目目录>\unidbg_dump.bin

关键检查点

  1. pack_headerdump_headermode/frame_count/width/height 应该一致
  2. bitmap_len=512(64 * 64 / 8 = 512 字节)
  3. dump_path 指向正确的输出文件

7. 第七步:提取位图数据

7.1 为什么要单独提取位图

unidbg_dump.bin 包含完整的 HJM 文件结构(52 字节头部 + 512 字节位图)。


我们需要:

  1. 提取纯位图数据(512 字节)
  2. 转换成可视化格式(BMP 图像、ASCII 预览)
  3. 为后续 OCR 做准备

7.2 solve_flag.py 完整代码

本脚本输出的是标准 BMP 格式,可以直接用任何图片查看器打开:

from pathlib import Path
import argparse
import struct

def find_existing_path(candidates: list[Path]) -> Path:
    """
    从候选路径列表中,按优先级返回第一个实际存在于磁盘上的文件。

    设计意图:同一类文件可能位于不同路径(如命令行指定路径、默认路径),
    通过优先级列表让调用方统一处理,而无需关心具体路径细节。

    Args:
        candidates: 按优先级从高到低排列的路径列表

    Returns:
        列表中第一个 exists() 为 True 的 Path

    Raises:
        FileNotFoundError: 所有候选路径均不存在时抛出,并列出全部路径供排查
    """
    for p in candidates:
        if p.exists():
            return p
    raise FileNotFoundError(
        "No input file found. Expected one of: "
        + ", ".join(str(p) for p in candidates)
    )

def parse_header(buf: bytes) -> dict:
    """
    解析并严格校验 HJM 文件格式的头部,返回后续处理所需的元数据字典。

    HJM 文件整体布局:
      字节  0 - 51 :头部(固定 52 字节,小端序)
      字节 52 - 末尾:位图数据(1-bit 单色,高位优先 MSB first)

    头部各字段说明:
      偏移  大小  字段          说明
      0     4     magic        魔数,固定 0x314D4A48(小端 ASCII = "HJM1")
      4     4     mode         数据模式(2 = 1-bit 单色位图)
      8     4     frame_count  帧数(本题固定为 1)
      12    4     width        图像宽度(像素)
      16    4     height       图像高度(像素)
      20    32    (保留字段)

    Args:
        buf: 完整文件的字节数据

    Returns:
        包含以下键的字典:
          "mode"        数据模式
          "frame_count" 帧数
          "width"       宽(像素)
          "height"      高(像素)
          "bitmap_len"  位图字节数 = (width × height) ÷ 8
          "bitmap_off"  位图在文件中的起始字节偏移(固定为 52)

    Raises:
        ValueError: 文件过短、魔数不匹配、宽高为零、或位图超出文件范围
    """
    if len(buf) < 52:
        raise ValueError("Input is too short to contain HJM header")
    magic, mode, frame_count, width, height = struct.unpack_from("<5I", buf, 0)
    if magic != 0x314D4A48:  # 'HJM1' in little-endian
        raise ValueError(f"Bad magic: 0x{magic:08x}, expected HJM1")
    if width == 0 or height == 0:
        raise ValueError("Invalid width/height in header")
    # 1-bit 位图:每像素 1 bit,8 个像素合为 1 字节,向下整除
    bitmap_len = (width * height) // 8
    end = 52 + bitmap_len
    if end > len(buf):
        raise ValueError(
            f"Bitmap out of range: need {end} bytes, file has {len(buf)} bytes"
        )
    return {
        "mode": mode,
        "frame_count": frame_count,
        "width": width,
        "height": height,
        "bitmap_len": bitmap_len,
        "bitmap_off": 52,   # 头部固定 52 字节,位图紧随其后
    }

def bits_get(bitmap: bytes, width: int, x: int, y: int) -> int:
    """
    读取位图中坐标 (x, y) 处的像素值(高位优先 MSB first 格式)。

    存储格式图解(以宽度 8 的第一行为例):
      字节 0:bit7  bit6  bit5  bit4  bit3  bit2  bit1  bit0
               x=0   x=1   x=2   x=3   x=4   x=5   x=6   x=7

    计算步骤:
      1. 线性索引  idx = y × width + x   (把二维坐标展开为一维)
      2. 字节索引       = idx >> 3        (等价于 idx // 8)
      3. 位偏移         = 7 - (idx & 7)  (高位 = 最左边像素 = bit7)
      4. 提取该位       = (字节 >> 位偏移) & 1

    Args:
        bitmap: 原始 1-bit 位图字节序列
        width:  图像宽度(像素),用于计算行偏移
        x:      列坐标,0-based,从左到右
        y:      行坐标,0-based,从上到下

    Returns:
        0(白色/背景)或 1(黑色/前景字符笔画)
    """
    idx = y * width + x           # 展开为线性像素索引
    b = bitmap[idx >> 3]          # 定位到包含该像素的字节
    return (b >> (7 - (idx & 7))) & 1  # 提取对应比特位

def write_bmp(bitmap: bytes, width: int, height: int, out_path: Path) -> None:
    """
    将 1-bit MSB-first 位图数据写成标准单色 BMP 文件(1bpp)。

    BMP 文件结构(按字节顺序):
      1. BMP 文件头      14 字节  含魔数 "BM"、文件总大小、像素数据偏移量
      2. DIB 信息头      40 字节  含宽高、色深(1)、压缩方式(0=无压缩)
      3. 调色板           8 字节  2 种颜色,每种 4 字节(Blue/Green/Red/Reserved)
           索引 0 → 0xFFFFFF(白色,背景区域)
           索引 1 → 0x000000(黑色,字符笔画)
      4. 像素数据        每行字节数向上对齐到 4 的整数倍(BMP 规范强制要求)

    关于高度正负值:
      BMP 默认自底向上存储(文件中最先出现的行 = 图像最底行)。
      在 DIB 头中传入 -height(负数),切换为自顶向下顺序,
      与我们逐行读取 bitmap 的顺序完全一致,避免图像上下颠倒。

    Args:
        bitmap:   原始 1-bit 位图字节数据(MSB first 格式)
        width:    图像宽度(像素)
        height:   图像高度(像素)
        out_path: 输出 BMP 文件路径
    """
    # BMP 规范:每行字节数必须是 4 的倍数(不足则在行尾补 0x00 填充)
    dst_row_bytes = (width + 31) // 32 * 4

    pixel_data = bytearray()
    for y in range(height):
        # 构造当前行的像素字节,保持 MSB first 格式与 BMP 1bpp 规范一致
        row = bytearray((width + 7) // 8)
        for x in range(width):
            if bits_get(bitmap, width, x, y):
                # 像素为黑色(值 1):在对应字节的对应比特位置 1
                row[x // 8] |= (1 << (7 - (x % 8)))

        # 在行尾补 0x00,确保行长度满足 4 字节对齐
        row += b'\x00' * (dst_row_bytes - len(row))
        pixel_data.extend(row)

    image_size = len(pixel_data)
    # 像素数据在文件中的起始偏移 = BMP文件头(14) + DIB信息头(40) + 调色板(8)
    offset = 14 + 40 + 8
    file_size = offset + image_size

    # 1. 构造 BMP 文件头(14 字节)
    #    格式:签名(2B) + 文件总大小(4B) + 保留1(2B) + 保留2(2B) + 像素数据偏移(4B)
    bmp_header = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, offset)

    # 2. 构造 DIB 信息头(40 字节,BITMAPINFOHEADER 格式)
    #    各字段:头大小(4) + 宽(4) + 高(4,负数=自顶向下) + 色平面数(2)
    #            + 位深(2,1=单色) + 压缩方式(4,0=不压缩) + 像素数据大小(4)
    #            + X分辨率(4) + Y分辨率(4) + 调色板颜色数(4) + 重要颜色数(4)
    dib_header = struct.pack("<IiiHHIIiiII", 40, width, -height, 1, 1, 0, image_size, 0, 0, 2, 2)

    # 3. 构造调色板(每种颜色 4 字节顺序:Blue, Green, Red, Reserved)
    #    索引 0(背景)→ 纯白 0xFFFFFF;索引 1(前景)→ 纯黑 0x000000
    palette = b"\xff\xff\xff\x00" + b"\x00\x00\x00\x00"

    # 按顺序将各段写入文件
    with out_path.open("wb") as f:
        f.write(bmp_header)
        f.write(dib_header)
        f.write(palette)
        f.write(pixel_data)

def write_ascii_preview(bitmap: bytes, width: int, height: int, out_path: Path) -> None:
    """
    将 1-bit 位图转换为 ASCII 字符画并保存为 UTF-8 文本文件。

    字符映射规则:
      像素值 1(黑色/笔画) → '#'(视觉上较"重",突出字形)
      像素值 0(白色/空白) → '.'(视觉上较"轻",表示背景)

    用途:无需图像查看器,直接在终端或文本编辑器中查看 flag 字符内容,
    方便快速验证解密结果是否正确。

    Args:
        bitmap:   原始 1-bit 位图字节数据
        width:    图像宽度(像素)
        height:   图像高度(像素)
        out_path: 输出 .txt 文件路径
    """
    lines = []
    for y in range(height):
        row = []
        for x in range(width):
            row.append("#" if bits_get(bitmap, width, x, y) else ".")
        lines.append("".join(row))
    out_path.write_text("\n".join(lines), encoding="utf-8")

def main() -> None:
    """
    主流程:读取 HJM 格式文件 → 提取纯位图数据 → 输出三种格式文件。

    输出文件(由 --out-prefix 指定前缀,默认 bitmap_pre_ocr):
      <prefix>.bin   原始位图字节数据(可用 hex 编辑器查看裸字节)
      <prefix>.bmp   标准 1bpp BMP 图像(可直接用图片查看器打开)
      <prefix>.txt   ASCII 字符画预览(终端直接可读,无需任何工具)

    命令行参数:
      -i / --input       显式指定输入文件路径(默认自动搜索 unidbg_dump.bin)
      -o / --out-prefix  输出文件名前缀(默认:bitmap_pre_ocr)
    """
    parser = argparse.ArgumentParser(
        description="Extract pre-OCR bitmap from unidbg_dump.bin / hjm_pack.bin as BMP"
    )
    parser.add_argument(
        "-i",
        "--input",
        type=Path,
        default=None,
        help="Optional explicit input file path",
    )
    parser.add_argument(
        "-o",
        "--out-prefix",
        type=Path,
        default=Path("bitmap_pre_ocr"),
        help="Output prefix for .bin/.bmp/.txt",
    )
    args = parser.parse_args()

    # 构建候选路径列表(命令行指定的路径优先级最高,其次是默认 dump 文件)
    candidates = []
    if args.input is not None:
        candidates.append(args.input)
    candidates.extend(
        [
            Path("unidbg_dump.bin"),  # unidbg 解密后的 dump 文件(通常首选)
        ]
    )
    # 找到第一个实际存在的文件作为输入
    in_path = find_existing_path(candidates)
    buf = in_path.read_bytes()

    # 解析头部,获取图像尺寸和位图区域的偏移量
    hdr = parse_header(buf)
    off = hdr["bitmap_off"]          # 位图数据起始偏移(头部结束后,固定为 52)
    end = off + hdr["bitmap_len"]    # 位图数据结束偏移(不含此位置)
    bitmap = buf[off:end]            # 切片提取:仅保留纯位图字节,去掉文件头

    # 根据前缀生成各输出文件的完整路径
    raw_path = args.out_prefix.with_suffix(".bin")  # 原始位图字节
    bmp_path = args.out_prefix.with_suffix(".bmp")  # 标准 BMP 图像
    txt_path = args.out_prefix.with_suffix(".txt")  # ASCII 字符画

    # 依次写出三种格式
    raw_path.write_bytes(bitmap)                              # 直接写入原始字节
    write_bmp(bitmap, hdr["width"], hdr["height"], bmp_path) # 生成标准 BMP
    write_ascii_preview(bitmap, hdr["width"], hdr["height"], txt_path)  # 生成字符画

    # 打印摘要,便于确认路径和参数是否正确
    print(f"input={in_path}")
    print(
        "header: "
        f"mode={hdr['mode']} frame_count={hdr['frame_count']} "
        f"width={hdr['width']} height={hdr['height']} bitmap_len={hdr['bitmap_len']}"
    )
    print(f"bitmap_raw={raw_path.resolve()}")
    print(f"bitmap_bmp={bmp_path.resolve()}")
    print(f"bitmap_preview={txt_path.resolve()}")

if __name__ == "__main__":
    main()

7.3 运行 solve_flag.py

# 从 unidbg_dump.bin 提取位图
python solve_flag.py

你应该看到类似输出:

input=unidbg_dump.bin
header: mode=2 frame_count=1 width=64 height=64 bitmap_len=512
bitmap_raw=<项目目录>\bitmap_pre_ocr.bin
bitmap_bmp=<项目目录>\bitmap_pre_ocr.bmp
bitmap_preview=<项目目录>\bitmap_pre_ocr.txt

7.4 查看位图内容

方法 1:直接查看原始位图bitmap_pre_ocr.bmp

方法 2:用文本编辑器打开bitmap_pre_ocr.txt
................................................................
................................................................
(中间省略空白行)
................................................................
..#####.#......###...###....##..#...#...###.#...#.#...#..###....
..#.....#.....#...#.#...#...#...#...#....#..##.##.#...#.#...#...
..####..#.....#...#.#.......#...#####....#..#.#.#.#...#.#...#...
..#.....#.....#####.#.###..#....#...#....#..#...#.#.#.#.#####...
..#.....#.....#...#.#...#...#...#...#....#..#...#.#.#.#.#...#...
..#.....#.....#...#.#...#...#...#...#.#..#..#...#.##.##.#...#...
..#.....#####.#...#..###....##..#...#..##...#...#.#...#.#...#...
................................................................
..####....###..###...###...###...###..#...#.####..#.....####....
..#...#....#..#...#.#...#.#...#.#.....##..#.#...#.#.....#...#...
..#...#....#......#.#..##.....#.#.....#.#.#.#...#.#.....#...#...
..####.....#.....#..#.#.#....#..####..#..##.####..#.....#...#...
..#........#....#...##..#...#...#...#.#...#.#...#.#.....#...#...
..#.....#..#...#....#...#..#....#...#.#...#.#...#.#.....#...#...
..#......##...#####..###..#####..###..#...#.####..#####.####....
................................................................
..............................##................................
...............................#................................
...............................#................................
................................#...............................
...............................#................................
...............................#................................
..............................##................................
................................................................
(后面省略空白行)

最终 flagFLAG{HJMWAPJ2026NBLD}
哈基米吾爱破解2026南北绿豆???!!!


8. 完整复现流程总结

8.1 一次性运行所有步骤

# 第一步:生成解密后的 dump
python dump_flag.py

# 第二步:提取位图并生成预览
python solve_flag.py

# 第三步:查看 type bitmap_pre_ocr.txt 或者直接打开 bitmap_pre_ocr.bmp

8.2 预期输出检查清单

步骤 文件 大小 关键内容
1 unidbg_dump.bin 564 字节 sub_2DDF8 ret=1
2 bitmap_pre_ocr.bin 512 字节 原始位图数据
2 bitmap_pre_ocr.bmp ~574 字节 BMP 图像
2 bitmap_pre_ocr.txt ~4KB ASCII 预览
3 终端输出 - 可见的 flag 文本

8.3 如果某一步失败怎么办

问题 1:dump_flag.py 报错 "mvn not found"

原因:Maven 未安装或未配置环境变量。

解决方法

  1. 下载 Maven:https://maven.apache.org/download.cgi
  2. 解压到任意目录(例如 C:\apache-maven-3.9.5
  3. 添加到 PATH 环境变量:
    • 打开"系统属性" → "环境变量"
    • 在"系统变量"里找到 Path,点击"编辑"
    • 添加 C:\apache-maven-3.9.5\bin
  4. 重新打开命令行,运行 mvn -version 验证
问题 2:dump_flag.py 报错 "sub_2DDF8 ret=0"

原因:全局状态初始化不正确,导致解密失败。

可能的原因

  • beatMap 数据错误(检查 Runner.java 里的 int[] beatMap = {0, 250, 500, 750}
  • qword_5EA30 计算错误(检查 sub_25CA8 的参数)
  • 字节序错误(检查 ByteOrder.LITTLE_ENDIAN

调试方法

  1. Runner.java 里添加更多日志:

    System.out.println("[DEBUG] beatBytes=" + Arrays.toString(beatBytes));
    System.out.println("[DEBUG] qword5ea30=0x" + Long.toHexString(qword5ea30));
  2. 对比 IDA 里的预期值

问题 3:solve_flag.py 生成的 ASCII 预览全是乱码

原因unidbg_dump.bin 没有正确解密。

解决方法

  1. 检查 dump_flag.py 的输出,确认 sub_2DDF8 ret=1
  2. 如果返回值是 0,回到问题 2 的调试方法
  3. 如果返回值是 1 但位图还是乱码,可能是 sub_2DDF8 内部的解密逻辑依赖其他全局状态
问题 4:中文路径导致的编码问题

症状

  • Maven 报错 "Invalid byte sequence"
  • 文件找不到(明明存在)

解决方法

  1. 把 APK 解压目录重命名为纯英文(例如 app/
  2. 修改 Runner.javadump_flag.py 里的路径常量
  3. 或者在 pom.xml 里添加编码配置:

    <properties>
       <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
       <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

如果还有其他问题,去问 AI 吧,哈哈。


9. 疑难杂症——为什么「网页验证通过,但 App 验证失败」

9.1 现象描述

  • 在网页版验证平台输入 FLAG{HJMWAPJ2026NBLD},显示"正确"
  • 在 App 里输入同样的 flag,显示"Flag 不正确"或"验证出错"

9.2 根本原因:App 有额外的运行时状态检查

证据 1:verifyAndDecrypt 有状态门控

在 IDA 里,verifyAndDecrypt_native 的伪代码(简化版):

__int64 sub_257DC(JNIEnv *env, jobject this, jbyteArray pack, jstring input)
{
  // ... 前面的代码省略 ...

  // 关键行:状态门控
  // byte_5EA40 和 byte_5EB88 是全局状态标志
  if ( (v32 | (unsigned __int8)byte_5EA40) & 1 | (byte_5EB88 != 0) )
  {
    // 只有当状态标志满足条件时,才进入解密+验证逻辑
    // ...
  }
  else
  {
    // 如果状态不对,直接返回 null(验证失败)
    return 0LL;
  }
}

这意味着什么?

  • 即使你的 flag 文本正确,如果全局状态不对,验证也会失败。
  • 这些状态标志是由 startSessionBytescheckRhythmupdateExp 等函数在游戏过程中设置的。
证据 2:startSessionBytes 绑定在游戏生命周期

在 JADX 里,Q0.C.B.d0 方法(游戏状态更新回调):

# 获取当前时间戳(纳秒)
invoke-static {}, Landroid/os/SystemClock;->elapsedRealtimeNanos()J
move-result-wide v2

# 调用 startSession(初始化会话状态)
invoke-virtual {v4, v2, v3, v5, v6}, Lcom/zj/wuaipojie2026_2/NativeBridge;->startSession(J[II)V

这意味着什么?

  • startSession 不是在点击"验证"按钮时调用的,而是在游戏运行过程中调用的。
  • 如果你没有玩游戏,直接输入 flag,startSession 可能没有被调用,导致状态未初始化。
证据 3:checkRhythm 和 updateExp 持续更新状态

这两个函数会在游戏过程中被频繁调用,更新全局状态(例如 qword_5EA30)。

如果你跳过游戏直接验证,这些状态可能是错误的。

9.3 为什么网页验证能通过

网页版验证平台通常只做简单的字符串比较:

# 网页后端的验证逻辑(伪代码)
def verify_flag(user_input):
    correct_flag = "FLAG{HJMWAPJ2026NBLD}"
    return user_input.strip() == correct_flag

它不会检查 native 状态,所以只要文本正确就能通过。

9.4 小结

网页验证和 App 验证的区别

验证方式 检查内容 难度
网页 只检查 flag 文本 简单
App 检查 flag 文本 + native 运行时状态 复杂

对新手的建议

  • 如果只是为了学习逆向流程,拿到 flag 文本就够了(网页验证通过即可)。
  • 如果想完整通过 App 验证,需要理解游戏的完整状态机(这超出了本教程的范围)。

10. 常见问题 FAQ

Q1:为什么不直接用 Python 写一个 AES 解密脚本?

A:因为这题的解密不是简单的 AES,而是:

  1. 需要正确的 seed(依赖全局状态)
  2. seed 的计算涉及复杂的 hash 函数(sub_25CA8
  3. 手动用 Python 复现这些函数,工作量大且容易出错
  4. 当然,文末有我的失败Python实现,各位想挑战自己的大佬可以彻底完善这个功能

用 unidbg 可以直接执行原始 native 代码,避免手动复现算法。

Q2:为什么要拆成两个脚本(dump_flag.py 和 solve_flag.py)?

A:职责分离,降低复杂度。

  • dump_flag.py:负责调用 unidbg,生成 dump
  • solve_flag.py:负责提取位图,生成预览

如果合并成一个脚本,一旦出错,你不知道是 unidbg 的问题还是位图提取的问题。

Q3:这个方法能用在其他题目上吗?

A:核心思路可以复用,但具体实现要根据题目调整。

可复用的思路

  1. 先看 Java 层找调用链
  2. 再看 native 层找算法逻辑
  3. 用 unidbg 获取中间态(而不是一次性算出答案)
  4. 把复杂问题拆成简单问题

需要调整的部分

  • 函数偏移地址(每个 so 文件都不同)
  • 全局变量地址(每个 so 文件都不同)
  • 参数和返回值类型(每个函数都不同)

11. 学习路径建议

11.1 如果你是完全零基础

第一阶段:工具熟悉(1-2 周)
  1. 学会用 7-Zip 解压 APK
  2. 学会用 JADX 查看 Java 代码
  3. 学会用 IDA 查看 native 代码
  4. 学会用 Python 写简单脚本
第二阶段:跟着本教程实操(1 周)
  1. 完整走一遍本教程的所有步骤
  2. 遇到问题先看 FAQ,再搜索错误信息
  3. 把每一步的输出都保存下来(截图或文本)
第三阶段:尝试类似题目(2-4 周)
  1. 找其他 Android native 题目练习
  2. 尝试不看 writeup,自己分析
  3. 卡住时再参考本教程的思路

11.2 如果你有一定基础

直接实战

  1. 拿到一个新题目
  2. 按照本教程的"决策树"分析:
    • 先看 Java 层找调用链
    • 再看 native 层找算法
    • 判断是否需要 unidbg
  3. 遇到新问题时,回来查阅对应章节

11.3 推荐的学习资源

Android 逆向基础

unidbg 学习

IDA 使用

  • 《IDA Pro 权威指南》(第二版)
  • Hex-Rays 官方教程
  • IDA-MCP

12. 总结:这篇教程教会了你什么(你需要有的印象与全局思维)

12.1 技术层面

  1. 完整的 Android native 逆向流程

    • APK 解压 → JADX 分析 Java 层 → IDA 分析 native 层 → unidbg 模拟执行
  2. 关键技能

    • 如何从 MainActivity 开始追踪调用链
    • 如何在 IDA 里找 JNI_OnLoad 和动态注册的函数
    • 如何用 unidbg 模拟 native 函数执行
    • 如何处理全局状态依赖
  3. 工具使用

    • JADX:查看 Java 代码、搜索字符串、追踪引用
    • IDA:反编译 native 代码、查看全局变量、分析函数调用
    • unidbg:加载 so 文件、调用函数、读写内存

12.2 方法论层面

  1. "为什么"比"怎么做"更重要

    • 每一步都先解释为什么要这样做,再给出具体操作
    • 避免"照抄代码但不知道为什么"的陷阱
  2. 降维策略

    • 不追求一次性解决所有问题
    • 把复杂问题拆成简单问题(dump 阶段 + OCR 阶段)
    • 先拿到中间态,再逐步推进
  3. 可复现性

    • 每一步都有检查点(预期输出、文件大小、关键日志)
    • 失败时能快速定位问题(是 Maven 的问题?路径的问题?还是算法的问题?)

12.3 心态层面

  1. 逆向不是"猜答案"

    • 不是靠运气试出来的
    • 每一步都有证据支撑(IDA 截图、JADX 代码、执行日志)
  2. 工具是辅助,理解是核心

    • unidbg 能帮你执行代码,但不能帮你理解代码
    • 你要知道为什么调用这个函数,为什么传这些参数
  3. 遇到问题不要慌

    • 先看错误信息(是文件找不到?还是函数返回值不对?)
    • 再查 FAQ 或搜索引擎或者问 AI
    • 实在不行,回到上一个成功的检查点,重新开始

最后,祝你在逆向的道路上越走越远!再次致谢正己老师的详细教程与本次活动的技术支持~


附录:完整文件清单

project/
├── dump_flag.py                    # unidbg 流程自动化脚本
├── solve_flag.py                      # 位图提取脚本
├── unidbg_runner/                     # unidbg 项目目录
│   ├── pom.xml                        # Maven 配置
│   └── src/main/java/com/ctf/
│       └── Runner.java                # unidbg 主程序
├── app/     # APK 解压目录
│   ├── lib/arm64-v8a/libhajimi.so    # native 库
│   └── assets/hjm_pack.bin            # 数据包
├── unidbg_dump.bin                    # unidbg 输出(解密后的 dump)
├── bitmap_pre_ocr.bin                 # 原始位图数据
├── bitmap_pre_ocr.bmp                 # BMP 图像
└── bitmap_pre_ocr.txt                 # ASCII 预览(可直接看到 flag)

最终 flagFLAG{HJMWAPJ2026NBLD}

附录:solve_flag_failed.py 为何失败(IDA-MCP 辅助复盘)

TL;DR(一句话总结)

solve_flag_failed.py 失败不是“少调几个参数”,而是路径假设错误:脚本把 seed 当成纯函数计算,并默认走 debug-bypass 种子分支,但真实 verifyAndDecrypt_native 会先调用 sub_25EF8 混入环境指纹,再基于全局状态重算 qword_5EA30,同时 seed 选择依赖 byte_5EB88/byte_5EA54/dword_5EA4C/dword_5EA50/qword_5EA38 等状态。结果是:即使 AES-CTR 逻辑接近正确,输入状态不一致导致解出来的 bitmap 完全错位,OCR 再严格匹配就必失败。

这与 report.md/tutorial.md 的结论一致:本题关键是“状态机 + 环境混入 + 解包”,不是单纯密码学复刻


A.1 题内文档的「约束条件」总结(必要前提)

来自 report.md / tutorial.md 的关键结论:

  • verifyAndDecrypt_native 并不是简单比较文本,而是先解包位图、再渲染输入文本、最后 memcmp
  • sub_25EF8环境指纹混入器,会被 startSessionBytes_nativeverifyAndDecrypt_nativedecryptFrames_native 多处调用,影响全局状态。
  • setDebugBypass 仅设置全局 byte_5EB88,影响种子来源,但不会禁用所有状态依赖。
  • 推荐路径是 unidbg -> dump -> Python OCR,而不是纯 Python 复刻整个 native 状态机。

这些约束直接否定了“只要 port 了 AES 就行”的路线。


A.2 关键证据 1:verifyAndDecrypt_native 先混入环境,再重算 qword_5EA30

来自 IDA 反编译(verifyAndDecrypt_native @ 0x257dc):

v18 = sub_25EF8(a1);                     // 环境混入
v25 = dword_5EA50 + HIDWORD(v18);
v26 = dword_5EA4C | (unsigned int)v18;
...
dword_5EA4C |= v18;
dword_5EA50 = v25;
...
// 基于 v25/v26 重算 qword_5EA30
v28 = (v25 ^ (v26 << 32) ^ 0x1A8CBC5B802E097C) - 0x61C8864680B583EB;
v29 = 0x94D049BB133111EB * (0xBF58476D1CE4E5B9 * ...);
v30 = v29 ^ (v29 >> 31);
if (v30) { v27 ^= (v31 >> 35) ^ v30; }
qword_5EA30 = v27;

这说明 qword_5EA30 在 verify 阶段再次被改写,不是“只依赖 beatMap 的纯函数”。

solve_flag_failed.py 只做了:

qword_5ea28 = sub_25ca8(beat_bytes, 0x1A8CBC5B802E097C)
qword_5ea30 = qword_5ea28

这在真实路径上只相当于 startSession 初始态的一部分,缺失后续混入步骤


A.3 关键证据 2:seed 来源不是固定 sub_2DCDC,而是「状态分支」

依旧来自 verifyAndDecrypt_native @ 0x257dc

if ((v32 | (unsigned __int8)byte_5EA40) & 1 | (byte_5EB88 != 0)) {
    if (byte_5EB88)
        v48 = sub_2DCDC();          // 仅 debug-bypass 才走这条
    else
        v48 = qword_5EA38;          // 正常路径
    if (v32)
        v49 = v48 ^ 0xA5A5A5A5;     // 额外异或
    else
        v49 = v48;
    if ((unpack_mode2_bitmap(..., v49, ...) & 1) == 0) return null;
}

solve_flag_failed.py 直接假设:

seed = sub_2dcdc(qword_5ea30, dword_5ea18)

这等于默认开了 setDebugBypass(true),同时忽略了 qword_5EA38v32 的控制分支。只要 byte_5EB88 没设、或 v32 状态不同,seed 就必定错


A.4 关键证据 3:sub_25EF8 确实在做环境指纹

sub_25EF8 @ 0x25EF8 的反编译里出现大量环境探测:

v325 = access("/proc/zoneinfo", 4);
v326 = access("/sys/devices/system/cpu/online", 4);
...
__system_property_get("ro.build.fingerprint", ...);
__system_property_get("ro.product.model", ...);
__system_property_get("ro.product.device", ...);
__system_property_get("ro.hardware", ...);
__system_property_get("ro.product.brand", ...);
...
FindClass("java/lang/String");
GetMethodID(..., "length", "()I");
FindClass("no/such/Class");
...
return v332 | (unsigned int)v340 | (unsigned __int64)(v324 << 32);

这意味着:在不同环境(真机 / 模拟器 / unidbg / 本地 Python)里,sub_25EF8 的返回值几乎必然不同,进而影响 dword_5EA4C/dword_5EA50/qword_5EA30

脚本没有任何环境混入模拟,所以从 seed 开始链条就偏了。


A.5 关键证据 4:sub_2DDF8 的解包依赖 qword_5EA30 + seed

unpack_mode2_bitmap @ 0x2DDF8 明确依赖这两个全局/入参:

derive_mix16(a2, qword_5EA30);   // a2 = seed
...
v33 = hash64_mix(&v56, 32, 0x1357);
v34 = hash64_mix(&v56, 32, 0x2468);
...
// AES-CTR nonce 从 qword40/dword48 + chunk_index + w20(qword_5EA30) 拼装
WORD2(v56) ^= (unsigned __int16)v26 ^ WORD2(v26);
BYTE6(v56) ^= ((unsigned int)v26 ^ HIDWORD(v26)) >> 16;
BYTE7(v56) ^= ((unsigned int)v26 ^ HIDWORD(v26)) >> 24;
aes_ctr_xor_inplace(...)

这意味着:seed/qword_5EA30 任意一处错误都会导致 key/nonce 派生链路整体失配,AES-CTR 结果完全错位。


A.6 失败原因归因(对照 solve_flag_failed.py

核心问题不是 AES 细节,而是状态模型假设错误

  1. solve_flag_failed.py 只把 qword_5EA30 当成 “beatMap 的哈希值”。
    实际:qword_5EA30verifyAndDecrypt_native 内会根据 sub_25EF8 的环境反馈和全局状态重算。

  2. 脚本默认 seed = sub_2DCDC(...),等价于“开启 debug bypass”。
    实际:正常路径会用 qword_5EA38,并可能再异或 0xA5A5A5A5

  3. 脚本没有模拟 sub_25EF8 的环境混入。
    结果:dword_5EA4C/dword_5EA50/byte_5EA54 状态与真实环境不一致,导致 qword_5EA30seed 继续偏移。

  4. OCR 太严格。
    decode_key_from_bitmap 只接受完整 5x7 点阵的字模完美匹配,遇到任何“装饰/噪声/局部错位”都会 fail;而一旦 seed 错,位图噪声极高,OCR 肯定失败。


A.7 为什么 unidbg 路线成功(而 Python 复刻失败)

unidbg 解决了最关键的问题:让 native 自己处理状态与环境依赖

  • sub_25EF8 的环境探测在 unidbg 内执行,至少可得到一致输出。
  • qword_5EA30 / qword_5EA38 / byte_5EA54 等全局状态在 native 内保持一致。
  • unpack_mode2_bitmap 能稳定返回正确解包结果(ret=1),再交给 Python OCR。

这也正是 tutorial.md 强调的“先拿中间态,再 OCR”的路线。


附:本次复盘引用的关键文件(包含solve_flag_failed.py)


Arnold中级题全过程文件.zip (2.79 MB, 下载次数: 15)

免费评分

参与人数 11吾爱币 +13 热心值 +9 收起 理由
Coxxs + 3 + 1 谢谢@Thanks!
wuhuhu + 1 + 1 谢谢@Thanks!
24kcsplus + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
无名 + 3 + 1 可以,写的非常详细
fengbolee + 1 + 1 用心讨论,共获提升!
LeeCris + 1 我很赞同!
llurry + 1 + 1 我很赞同!
神奇的人鱼 + 1 + 1 热心回复!
江南小虫虫 + 1 用心讨论,共获提升!
leeexin + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
Command + 1 + 1 我很赞同!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
正己 发表于 2026-3-4 21:19
写得很详细,加个精华
沙发
怜渠客 发表于 2026-3-4 19:27
4#
Command 发表于 2026-3-4 21:33
? 我说为什么我unidbg模拟内存会出问题, 原来还有startSession
5#
 楼主| 破解小白:) 发表于 2026-3-4 21:48 |楼主
正己 发表于 2026-3-4 21:19
写得很详细,加个精华

哇,谢谢正己老师认可
6#
safang 发表于 2026-3-5 14:08
前来学习   谢谢楼主分享
7#
beta5 发表于 2026-3-5 14:45
不是有逆向工具么?
8#
De1taY 发表于 2026-3-5 16:11
非常详细,学到了,感谢分享
9#
ming06007 发表于 2026-3-5 16:25
很详细,学习以下!
10#
aqin5014 发表于 2026-3-5 16:33
学到了,感谢分享
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-3-10 05:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表