文章总结: 本文是一篇面向零基础读者的Android逆向教程,以2026年春节安卓中级题为例,详细讲解了从Java层分析到Native层(so文件)再到使用unidbg动态调试的全流程。文章首先介绍了所需工具(如JADX-GUI、IDAPro等)及其安装配置,然后逐步引导读者解压APK、分析Java代码以找到核心验证逻辑,并最终定位到需要深入研究的native方法。文中包含大量代码注释和操作截图,旨在帮助初学者理解安卓应用的逆向分析过程。 综合评分: 85 文章分类: 移动安全,逆向分析,安全工具,渗透测试,技术标准
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
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 未安装或未配置环境变量。
解决方法:
- 下载 Maven:https://maven.apache.org/download.cgi
- 解压到任意目录(例如
C:\apache-maven-3.9.5) - 添加到 PATH 环境变量:
- 打开”系统属性” → “环境变量”
- 在”系统变量”里找到
Path,点击”编辑” - 添加
C:\apache-maven-3.9.5\bin
- 重新打开命令行,运行
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)
调试方法:
- 在
Runner.java里添加更多日志:
复制代码 隐藏代码
System.out.println("[DEBUG] beatBytes=" + Arrays.toString(beatBytes));
System.out.println("[DEBUG] qword5ea30=0x" + Long.toHexString(qword5ea30));
- 对比 IDA 里的预期值
问题 3:solve_flag.py 生成的 ASCII 预览全是乱码
原因:unidbg_dump.bin 没有正确解密。
解决方法:
- 检查
dump_flag.py的输出,确认sub_2DDF8 ret=1 - 如果返回值是 0,回到问题 2 的调试方法
- 如果返回值是 1 但位图还是乱码,可能是
sub_2DDF8内部的解密逻辑依赖其他全局状态
问题 4:中文路径导致的编码问题
症状:
- Maven 报错 “Invalid byte sequence”
- 文件找不到(明明存在)
解决方法:
- 把 APK 解压目录重命名为纯英文(例如
app/) - 修改
Runner.java和dump_flag.py里的路径常量 - 或者在
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(验证失败)
return0LL;
}
}
这意味着什么?
- 即使你的 flag 文本正确,如果全局状态不对,验证也会失败。
- 这些状态标志是由
startSessionBytes、checkRhythm、updateExp等函数在游戏过程中设置的。
证据 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 为什么网页验证能通过
网页版验证平台通常只做简单的字符串比较:
复制代码 隐藏代码
# 网页后端的验证逻辑(伪代码)
defverify_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,而是:
- 需要正确的 seed(依赖全局状态)
- seed 的计算涉及复杂的 hash 函数(
sub_25CA8) - 手动用 Python 复现这些函数,工作量大且容易出错
- 当然,文末有我的失败Python实现,各位想挑战自己的大佬可以彻底完善这个功能
用 unidbg 可以直接执行原始 native 代码,避免手动复现算法。
Q2:为什么要拆成两个脚本(dump_flag.py 和 solve_flag.py)?
A:职责分离,降低复杂度。
-
dump_flag.py:负责调用 unidbg,生成 dump
-
solve_flag.py:负责提取位图,生成预览
如果合并成一个脚本,一旦出错,你不知道是 unidbg 的问题还是位图提取的问题。
Q3:这个方法能用在其他题目上吗?
A:核心思路可以复用,但具体实现要根据题目调整。
可复用的思路:
- 先看 Java 层找调用链
- 再看 native 层找算法逻辑
- 用 unidbg 获取中间态(而不是一次性算出答案)
- 把复杂问题拆成简单问题
需要调整的部分:
- 函数偏移地址(每个 so 文件都不同)
- 全局变量地址(每个 so 文件都不同)
- 参数和返回值类型(每个函数都不同)
11. 学习路径建议
11.1 如果你是完全零基础
第一阶段:工具熟悉(1-2 周)
- 学会用 7-Zip 解压 APK
- 学会用 JADX 查看 Java 代码
- 学会用 IDA 查看 native 代码
- 学会用 Python 写简单脚本
第二阶段:跟着本教程实操(1 周)
- 完整走一遍本教程的所有步骤
- 遇到问题先看 FAQ,再搜索错误信息
- 把每一步的输出都保存下来(截图或文本)
第三阶段:尝试类似题目(2-4 周)
- 找其他 Android native 题目练习
- 尝试不看 writeup,自己分析
- 卡住时再参考本教程的思路
11.2 如果你有一定基础
直接实战:
- 拿到一个新题目
- 按照本教程的”决策树”分析:
- 先看 Java 层找调用链
- 再看 native 层找算法
- 判断是否需要 unidbg
- 遇到新问题时,回来查阅对应章节
11.3 推荐的学习资源
Android 逆向基础:
- 《Android 软件安全与逆向分析》(丰生强)
- 看雪论坛 Android 版块
- 吾爱破解论坛-正己-吾爱破解安卓逆向入门教程《安卓逆向这档事》
unidbg 学习:
- unidbg 官方文档:https://github.com/zhkl0228/unidbg
- unidbg 示例代码:https://github.com/zhkl0228/unidbg/tree/master/unidbg-android/src/test/java/com/github/unidbg/android
- 《安卓逆向这档事》第二十三课、黑盒魔法之Unidbg
IDA 使用:
- 《IDA Pro 权威指南》(第二版)
- Hex-Rays 官方教程
- IDA-MCP
12. 总结:这篇教程教会了你什么(你需要有的印象与全局思维)
12.1 技术层面
- 完整的 Android native 逆向流程:
- APK 解压 → JADX 分析 Java 层 → IDA 分析 native 层 → unidbg 模拟执行
- 关键技能:
- 如何从
MainActivity开始追踪调用链 - 如何在 IDA 里找
JNI_OnLoad和动态注册的函数 - 如何用 unidbg 模拟 native 函数执行
- 如何处理全局状态依赖
- 工具使用:
- JADX:查看 Java 代码、搜索字符串、追踪引用
- IDA:反编译 native 代码、查看全局变量、分析函数调用
- unidbg:加载 so 文件、调用函数、读写内存
12.2 方法论层面
- “为什么”比”怎么做”更重要:
- 每一步都先解释为什么要这样做,再给出具体操作
- 避免”照抄代码但不知道为什么”的陷阱
- 降维策略:
- 不追求一次性解决所有问题
- 把复杂问题拆成简单问题(dump 阶段 + OCR 阶段)
- 先拿到中间态,再逐步推进
- 可复现性:
- 每一步都有检查点(预期输出、文件大小、关键日志)
- 失败时能快速定位问题(是 Maven 的问题?路径的问题?还是算法的问题?)
12.3 心态层面
- 逆向不是”猜答案”:
- 不是靠运气试出来的
- 每一步都有证据支撑(IDA 截图、JADX 代码、执行日志)
- 工具是辅助,理解是核心:
- unidbg 能帮你执行代码,但不能帮你理解代码
- 你要知道为什么调用这个函数,为什么传这些参数
- 遇到问题不要慌:
- 先看错误信息(是文件找不到?还是函数返回值不对?)
- 再查 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)
最终 flag:FLAG{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_native、verifyAndDecrypt_native、decryptFrames_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 | (unsignedint)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_5EA38、v32 的控制分支。只要 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 | (unsignedint)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) ^= ((unsignedint)v26 ^ HIDWORD(v26)) >> 16;
BYTE7(v56) ^= ((unsignedint)v26 ^ HIDWORD(v26)) >> 24;
aes_ctr_xor_inplace(...)
这意味着:seed/qword_5EA30 任意一处错误都会导致 key/nonce 派生链路整体失配,AES-CTR 结果完全错位。
A.6 失败原因归因(对照 solve_flag_failed.py)
核心问题不是 AES 细节,而是状态模型假设错误。
solve_flag_failed.py只把qword_5EA30当成 “beatMap 的哈希值”。 实际:qword_5EA30在verifyAndDecrypt_native内会根据sub_25EF8的环境反馈和全局状态重算。- 脚本默认
seed = sub_2DCDC(...),等价于“开启 debug bypass”。 实际:正常路径会用qword_5EA38,并可能再异或0xA5A5A5A5。 - 脚本没有模拟
sub_25EF8的环境混入。 结果:dword_5EA4C/dword_5EA50/byte_5EA54状态与真实环境不一致,导致qword_5EA30与seed继续偏移。 - OCR 太严格。
decode_key_from_bitmap只接受完整 5×7 点阵的字模完美匹配,遇到任何“装饰/噪声/局部错位”都会 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)见左下角原文
-官方论坛
www.52pojie.cn
👆👆👆
公众号设置“星标”,您不会错过新的消息通知
如开放注册、精华文章和周边活动等公告
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:吾爱破解论坛 吾爱pojie 吾爱pojie《【零基础可复现】2026 春节 Android 中级题(Java→so→unidbg 全流程)》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论