CTF安卓逆向入门:jadx定位与frida验证还原Java校验

📅 发布时间:2026/10/20 23:09:47
CTF安卓逆向入门:jadx定位与frida验证还原Java校验
进入移动安全这块之后我给自己定了个规矩每个平台上的题目都按题号从易到难往下刷每刷完一道就把思考过程沉淀成笔记。这是第七十七篇记录的是某 CTF 平台 mobile 区的第一道题 app1。题目本身不复杂它的核心就是一个 Java 层的输入校验但我觉得拿它当“逆向分析入门地图”非常合适——从拿到一个 APK、到找到校验函数、再到还原出目标字符串整个链路是完整的。如果你也刚从 x86 的逆向切到 Android或者对着一堆 jadx 反编译结果不知道先点哪里这篇的思路可以直接照搬。1. 思路拆解拿到 APK 之后先别急着反编译1.1 先观察运行行为再做技术判断我见过不少朋友拿到 app1 之后的第一反应是把 APK 拖进 jadx然后面对几十个类、几百个方法发懵。这个顺序其实可以优化一下。正确做法应该是先把 APK 装进模拟器亲手点一遍。用adb install app1.apk装完之后打开应用界面上一般就是一个输入框、一个按钮、一行用于显示结果的文本。这时候可以先试着输入几个字符串比如admin、123456、随便一串字母观察反馈。app1 这类入门题错误提示往往非常直白会在界面上显示一行Wrong或者It is wrong甚至弹出 Toast。这一段操作看起来很朴素却是后续定位代码的关键。原因很简单你看到的错误提示文字一定会在反编译代码里以字符串常量的形式存在。哪怕 APK 里类名被混淆成a.b.c字符串通常是变不了的。所以只要记住这个提示文案后续用它在 jadx 里做全局搜索就能直接跳到校验逻辑附近省掉大把时间。1.2 档案化收集 APK 的基础信息在打开 jadx 之前我还习惯先对 APK 做一次“档案化”的信息收集工具就是file、apktool再加上人眼扫一遍解包后的目录结构。file app1.apk unzip -l app1.apk | head -50 apktool d app1.apk -o app1_srcfile是为了确认这是一个标准 APK/ZIP 包unzip -l可以直接看包内有没有可疑资源apktool d解包之后重点看三样东西第一是AndroidManifest.xml。这里能拿到包名、应用入口 Activity、申请了哪些权限。包名后面写 frida 脚本要用入口 Activity 决定了你要分析的主类是谁。如果权限里有INTERNET你要留个心眼说明 flag 可能是联网校验的app1 里没看到这类权限基本能断定是本地校验。第二是lib目录。如果里面有lib/armeabi-v7a或者lib/arm64-v8a下的.so文件说明存在 native 层逻辑可能后续要上 IDA。app1 解包后只有常见目录没有额外 so 文件意味着整个题目大概率是纯 Java 层jadx 能还原得很完整。第三是assets目录。有时题目会把加密后的 dex 或者关键数据藏在这里解包时肉眼扫一眼能少踩很多坑。1.3 静态优先、动态兜底的分析路线我这次用的分析路线很明确先静态定位再动态验证最后脚本还原。这也是我最推荐新手养成的习惯。为什么要静态优先因为 Java 层代码经过 jadx 反编译之后调用关系、字符串、常量数组基本都是可见的。静态分析能直接告诉你“校验函数在哪、算法大概是什么”这种精度是纯黑盒试输入给不了的。但静态分析有个风险人眼读代码可能把调用顺序读错或者漏掉监听器里先做了一次trim()、toUpperCase()之类的预处理。这时候动态分析就派上用场了。用 frida hook 住关键函数直接观察“传进去的值是什么、返回的值是什么”一秒就能验证你的假设对不对。所以整体路线就是拿行为信息当索引用反编译代码当地图最后用动态 hook 当测量仪。2. 核心定位jadx 反编译和关键代码阅读2.1 反编译与关键词搜索定位静态分析阶段我用的工具是 jadx。直接启动图形界面把 app1.apk 拖进去它会自动完成反编译和资源解析。命令行的方式也可以但入门阶段图形界面更直观jadx -d jadx_out app1.apk打开之后先不要漫无目的地翻类。第一步应该是按CtrlShiftF打开全局搜索输入记忆里的错误提示字符串比如wrong、error、invalid。app1 的反编译结果里这个字符串会直接带你跳到使用它的代码位置通常就是MainActivity的某个方法里。如果搜索wrong没有结果也别慌可以换flag、key、input、check、verify、equals这些词再试。还有一个非常高效的关键词是getText()它是读取输入框内容的必经入口顺着它往上找调用者很快就能锁定按钮的点击监听器。我实际在 jdax 里看到的核心结构大体是这样一种模式代码细节我做了脱敏替换算法形态保持一致public class MainActivity extends Activity { private EditText editText; private TextView textView; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); editText findViewById(R.id.input); textView findViewById(R.id.result); Button button findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { String input editText.getText().toString(); if (check(input)) { textView.setText(Correct!); } else { textView.setText(Wrong!); } } }); } private boolean check(String userInput) { byte[] data userInput.getBytes(); byte[] target new byte[]{ 0x24, 0x27, 0x26, 0x21, // 后面还有若干字节本质是一个写死的目标数组 }; if (data.length ! target.length) { return false; } for (int i 0; i data.length; i) { data[i] (byte) ((data[i] ^ 0x2A) 1); } return MessageDigest.isEqual(data, target); } }注意上面这些数字是我为了不直接变成答案搬运而做了扰动的示例不是原题的真实常量。但它已经足以说明 app1 这类题的典型结构了用户输入经过一个可逆的逐字节变换然后和一个写死的字节数组做比较。2.2 阅读核心校验逻辑拿到核心代码之后不要急着写脚本先一行一行看懂它在做什么。首先是getBytes()。这个方法默认使用平台字符集在 Android 上通常是 UTF-8。输入内容是纯 ASCII 的时候没问题一旦输入中文字符会被编码成多个字节长度直接对不上 target所以这类题目默认输入就是英文字符或可打印符号这个信息在后面写脚本时也用得上。再看target数组。它是逆向分析的锚点就像一把锁我们要找的是能打开这把锁的钥匙。它在代码里是一个写死的byte[]你不需要知道它为什么是这些值只需要知道它是“正确输入变换后的结果”。然后是变换逻辑这里的例子是data[i] (byte) ((data[i] ^ 0x2A) 1)。正向操作是异或一个常数再加 1。异或是位运算它的逆运算还是异或加法 1 的逆运算是减 1。所以还原公式就是input[i] (target[i] - 1) ^ 0x2A。最后是MessageDigest.isEqual(data, target)。这个函数和Arrays.equals在结果上基本等价区别是它的比较过程是恒定时间的通常用于安全校验比如数字签名比对。对解题来说这就是一个判断“是否相同”的开关不影响算法还原。有一种情况要额外注意如果代码里出现了toUpperCase()、trim()、substring这类预处理还原脚本里必须把同样的操作加回去否则你算出来的字符串和实际输入的格式对不上应用还是会提示错误。2.3 用 Python 把逆向算法还原出来Java 层看明白之后写还原脚本只是几分钟的事。我把示例逻辑对应的 Python 脚本放在下面target [ 0x24, 0x27, 0x26, 0x21, # 从 jadx 里按顺序抄下完整数组 ] result [] for b in target: v ((b - 1) 0xFF) ^ 0x2A result.append(v) # 先看十六进制再尝试解码成字符串 print( .join(f{x:02x} for x in result)) flag bytes(result).decode(utf-8, errorsreplace) print(flag)这里有一个新手极其容易踩的坑Java 的byte是有符号的取值范围是 -128 到 127。jadx 在展示target数组时如果把某个字节写成了0xFF实际代码里会显示成-1。而 Python 的整数没有这个符号概念。所以从 jadx 抄数据到 Python 时所有字节都要用 0xFF统一成 0 到 255 之间的无符号值再参与运算。另外运算顺序要严格按照逆向的相反顺序来。正向是“异或再加”逆向就必须“先减再异或”。这跟解栈一样先进后出顺序搞反了结果全错。脚本里((b - 1) 0xFF) ^ 0x2A的括号不要省-和^的优先级容易让人混淆。如果脚本输出的字符串里前几位是flag{恭喜你已经拿到了答案。但我在笔记里习惯把最后一段输出打码留给你自己跑因为刷题的价值在于亲手跑通这个过程。3. 动态验证frida 让假设变成结论3.1 环境准备模拟器、frida-server 与端口静态脚本跑出了候选字符串按理说可以直接去应用里试了。但我还是习惯先做一步动态验证确认我对check函数的理解没错。用到的工具是 frida。环境准备分三步。第一步模拟器里最好有一个可用 root 的系统镜像Android 9 或 Android 10 的经典镜像都行因为老 APK 在这些版本上兼容性更好。第二步下载与本机 frida 版本一致的 frida-server注意架构通过adb shell getprop ro.product.cpu.abi查看模拟器架构。第三步推送并启动adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server adb forward tcp:27042 tcp:27042启动之后用frida-ps -U能看到模拟器进程列表就说明环境通了。3.2 hook 关键函数观察输入与输出我写的验证脚本非常简单核心就是给check方法挂一个代理打印每一次调用的入参和返回值Java.perform(function () { var MainActivity Java.use(com.ctf.app1.MainActivity); MainActivity.check.implementation function (str) { console.log([*] input: str); var ret this.check(str); console.log([*] result: ret); return ret; }; });运行命令frida -U -f com.ctf.app1 -l solve_hook.js启动后frida 会自动拉起应用进入交互模式。输入%resume让应用继续运行然后切到模拟器在输入框里随便敲几个字符点击按钮。回到 frida 控制台就能看到类似这样的输出[*] input: admin [*] result: false如果result变成 true说明此刻输入的字符串就是正确 flag。这个验证最大的价值在于它直接证明了静态分析找到的check确实是实际调用的校验逻辑没有找错方法也没有漏掉前置处理。如果连input都没有打印出来那就要回头检查包名和方法名是不是写错了或者应用是否真的调用了这个类的方法。可以顺手再 hook 一下android.widget.EditText.getText()这条链路一定会在点击时被触发。3.3 把还原结果输入应用完成闭环验证通过后就把 Python 脚本算出来的字符串完整地填进输入框。这里有几个细节值得注意。一是别在字符串首尾留下多余空格。有些题目在比较之前没有做trim()多一个空格都会导致长度不一致直接判断失败。二是如果应用校验的是“输入后点击按钮”的交互那就老老实实走一次人工点击不要只依赖 hook 脚本里的自动调用。如果填进去之后界面显示Correct!那整个分析闭环就结束了。从行为观察到静态定位再到脚本还原和动态验证这条路径就是 app1 的完整解法流程。3.4 动态验证中容易忽略的细节我在实际调试中遇到过几次 frida 注入失败的情况最常见的表象是frida -U -f之后应用没有自动启动或者控制台一直卡住。排查顺序一般是先看 frida-server 有没有被杀有的模拟器会限制 /data/local/tmp 下的进程运行重启模拟器后再来一次。再看 SELinux 是不是 enforcing 模式可以临时执行adb shell setenforce 0关掉避免 frida-server 无法附加进程。最后检查 frida 和 frida-server 版本是否匹配版本号差太多就会出现“连接被重置”之类的报错。还有一个思路层面的建议入门题里你完全可以直接改check的返回值为true来绕过校验拿个“成功界面”。但我还是建议老老实实做算法还原因为那道变换逻辑才是这道题真正想让你练的东西。4. 踩坑实录与问题排查速查4.1 静态分析和动态结果不一致时怎么办有一种情况非常打击人你按静态代码写好了脚本字符串看起来也对但 frida hook 之后发现check压根没被调用或者调用的是另一个重载方法。这时候不要怀疑自己眼瞎大概率是漏掉了某些入口逻辑。app1 这题还算厚道入口就在MainActivity。但复杂一点的题目会有多个 Activity或者点击事件实际绑定在了一个自定义 View 上。排查手段还是老一套往上追监听器。你在 jadx 里看到setOnClickListener的地方往往是校验逻辑的真正入口。如果那里还有Intent传参、SharedPreferences 读取之类的操作也得一并纳入分析范围。4.2 字节符号与编码造成脚本“看起来没算错”这是我最想让新手记住的一个坑。Java 里byte是有符号的(byte) 0xA0打印出来会是负数 -96。你从 jadx 复制数组到 Python 时如果照抄了负数后续位运算结果很容易飘。最简单有效的处理方式抄写数组时统一转成0xFF格式脚本里每个元素都 0xFF。另外如果你算出来的字符串前面部分正常、后面出现乱码不要先怀疑算法先用十六进制打印中间结果把每一段和 Java 里的target数组逐字节对上定位是哪个字节开始偏的。4.3 如果题目换成加固/混淆版本虽然 app1 是纯 Java 无加固但练习的时候最好有个心理准备真正的商业 app 或者稍难一点的赛题往往不是这么友好。如果 jadx 打开之后只看到壳的入口业务代码全都不见那就不是“读代码”的问题了而是“先脱壳”的问题。判断是否加壳可以从 AndroidManifest 里看出端倪如果应用自带的类很少却有一堆com.xxx.shell、com.secneo之类的类名或者 assets 下躺着加密的 dex 文件那就基本坐实了。这时候要么先落下壳要么在内存里 dump 出真正运行的 dex再交给 jadx 分析。frida 在这类高难题目里依然是主力工具可以用它遍历已加载的类找到真实业务代码。4.4 常见问题速查表症状可能原因处理思路全局搜索不到错误提示字符串字符串被加密或者提示来自 native 层改用动态 hook 观察输入输出或检查 so 文件还原脚本输出乱码Java 有符号字节、编码问题统一 0xFF先打印十六进制逐段核对frida hook 后没有日志输出包名/方法名不匹配或方法未被调用hookEditText.getText()向上逆向定位入口输入看起来正确但提示错误算法存在预处理如trim()、大小写转换回到 jadx 查看比对前的完整调用链反编译代码异常精简APK 加壳或参数抽取先用脱壳工具或内存 dump再重新反编译4.5 写在笔记末尾的几点经验app1 这道题做完我的最大体会是逆向分析最值钱的不是那个flag而是思路的顺序。从错误提示的字符串入手比从类名和方法名入手高效太多静态分析定位逻辑比瞎猜输入高效太多动态 hook 验证假设比反复试错高效太多。我一般会把这类 Java 层校验的题目称为“套模板题”看入口、找 check、还原变换。但每一次的具体实现都可能有小变体所以笔记里记录的重点永远是思考路径而不是死记答案。希望这篇 mobile 区 app1 的记录能帮你少走一点弯路。