前几天在BUUCTF刷题,re分类里看到一道叫FindIt的题,下载下来是个APK,我第一反应是愣了一下——逆向题还能考安卓?后来想想,这其实挺常见的,APK本质就是安卓上的可执行程序,逆向它和逆向ELF、EXE本质上是一回事,只是入口和工具链不同。这道题难度不高,但流程很完整:从APK解包到JADX反编译,从定位MainActivity到还原算法,最后用Python脚本跑出flag,非常适合刚接触安卓逆向的人练手。
整道题做下来,你会发现它不需要上IDA,不需要动态调试,甚至连模拟器都可以不装,纯粹靠静态分析就能出答案。不过正是因为简单,反而能帮你把"APK逆向的标准步骤"走一遍,后面碰到加壳、混淆、Native层加密的题目时,你才知道哪些环节是绕不开的。这篇文章我就按实际做题的顺序,把每一步怎么想、怎么做、为什么这么做,完整记录下来。
1. 确认目标:一个APK怎么被分到了逆向题里
1.1 下载与文件识别:看到APK不要急着双击
从BUUCTF平台把题目附件下载下来,文件名就是findit.apk。很多人拿到APK习惯性想安装到手机上,但在逆向题里,这个习惯得改一改。APK本质是一个ZIP压缩包,里面装的是安卓的资源文件和Dalvik字节码,你要分析的是它的内部构造,而不是它的界面。
我习惯先跑一下file命令确认文件类型:
file findit.apk输出一般是:
findit.apk: Android application package确认是APK之后,可以再用unzip -l看一眼里面的文件结构。APK的标准结构大致是这样的:
classes.dex:核心的Dalvik字节码,Java层逻辑全在里面AndroidManifest.xml:全局配置,声明了Activity、权限、入口信息resources.arsc:资源索引表res/:各种资源文件(图片、布局、字符串)lib/:可能存在的Native库(.so文件),这题没有
在逆向题里,classes.dex是分析的重点,而AndroidManifest.xml则是找入口的地图。如果APK里出现了lib/armeabi-v7a之类的目录,说明题目可能涉及Native层逻辑,那就需要IDA出场了。FindIt这个APK我扫了一眼,没有lib目录,基本可以判断是纯Java层的简单题目。
1.2 工具链选择:这次只需要JADX就够了
做安卓逆向,工具链的选择直接决定效率。我习惯的优先级是这样:
- JADX / JADX-GUI:把
classes.dex直接反编译成可读的Java代码,是静态分析的首选工具,支持图形界面,操作直观 - apktool:用来解包和重打包APK,适合需要修改smali代码或者提取资源的场景
- Android Studio + 模拟器:用于动态调试、运行验证
- IDA Pro:分析
.so文件里的Native层汇编 - Frida:运行时Hook,适合动态验证和绕过检测
这道题连lib目录都没有,直接JADX就够了。JADX本质上是一个Java工具,所以前提是本地已经配置好JDK。下载JADX之后,Windows用户可以直接跑jadx-gui.bat,Mac/Linux用户跑jadx-gui脚本,打开GUI后把APK拖进去就行。
JADX会做两件事:一是把APK解包,二是把classes.dex反编译成Java源码并自动建立索引。打开之后左侧是完整的目录树,你可以像阅读一个普通Java工程一样浏览所有类文件。对FindIt这种没有加壳、没有混淆的题目,这个过程非常顺畅,基本不会出现反编译失败的情况。
2. 打开APK找入口:MainActivity的点击事件里藏着验证逻辑
2.1 从AndroidManifest确定主入口
JADX打开APK后,左侧目录树第一项就是AndroidManifest.xml,点开它,能看到这个APK注册了哪些组件。找入口的逻辑很简单:一个App启动时,系统会去寻找sentivity MAIN和LAUNCHER的那个Activity,也就是通常说的主Activity。
FindIt的Manifest里,主入口写得很清楚:
<activity android:name="com.example.findit.MainActivity"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity>所以目标锁定为com.example.findit.MainActivity。这个包名里直接带了findit,基本不会找错。在JADX左侧树里展开com.example.findit包,双击MainActivity,就能看到完整的Java代码。
这里有个小经验:遇到MainActivity之后,先不要急着读全部代码,而是先找onCreate方法里有没有setOnClickListener之类的回调注册。因为这类"让你输入flag然后验证"的题目,最后的判断逻辑几乎都写在按钮的点击事件回调里,直接跳到那里效率最高。
2.2 布局文件里的线索:一个按钮和一个输入框
MainActivity的onCreate方法里会有setContentView,对应的布局文件是res/layout/activity_main.xml。JADX也能直接查看资源文件,点开布局会发现里面就三个关键控件:
- 一个
EditText,用来输入flag - 一个
Button,触发验证 - 一个
TextView,显示结果或提示
这种结构在安卓逆向题里太经典了,几乎可以闭着眼睛判断:重点不在界面,而在按钮的回调方法里写了什么比较逻辑。代码里也确实出现了findViewById和setOnClickListener,点击事件里才是真正的验证流程。
2.3 onClick方法:验证逻辑的全貌
把MainActivity的源码整理一下,核心逻辑大概是这样的(我对ClassName和变量名做了整理,实际反编译出来的可能略有差别,但逻辑一致):
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final EditText editText = findViewById(R.id.edit); Button button = findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { char[] enc = new char[]{ 'c', 'i', '^', 'd', 'x', 'F', 'q', '\\', 'f', 'p', '\\', '^', '\\', 'C', 'r', 'k', 'k', 'v', '\\', 'c', 'i', '^', 'd', 'z' }; String flag = ""; for (int i = 0; i < enc.length; i++) { flag += (char) (enc[i] + 3); } String input = editText.getText().toString(); if (input.equals(flag)) { Toast.makeText(MainActivity.this, "Right!", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(MainActivity.this, "Wrong!", Toast.LENGTH_SHORT).show(); } } }); } }看到这段代码,题目的面目其实已经出来了:程序内部有一组乱码字符enc,循环里对每个字符的ASCII码加3,拼出一个字符串,然后拿它和输入框里的内容做比较。如果输入一致,就提示Right!。
到了这一步,问题就简化成了:把enc数组提取出来,逐个字符ASCII码加3,拼出来的那个字符串,就是我们要找的flag。剩下的事情纯粹是体力活。
3. 算法还原:为什么"+3"就是答案,以及怎么快速识别这类变换
3.1 先看循环逻辑:类型转换背后是ASCII码运算
很多人看到(char) (enc[i] + 3)这行代码会愣一下,其实拆开看就是三步:
enc[i]是char类型,Java里char参与算术运算时会自动转成int,也就是拿到的其实是这个字符的ASCII码- 在这个ASCII码上加3
(char)把计算结果重新转回字符
所以'c' + 3就是99 + 3 = 102,对应字符'f'。这个变换意味着什么?说明当初构造这个数组的时候,作者是把真正的flag逐字符取出来,每个字符的ASCII码减3,又存进数组里。现在我们要做的,就是逆回去:每个字符加3。
这种"先减后加"的移位操作,本质上是简单的凯撒加密,只是作用于ASCII码而不是字母表。做题的时候遇到过很多次,识别起来也很有规律:如果代码里出现charArr[i] + 数字、charArr[i] - 数字、charArr[i] ^ 数字之类的操作,并且后面跟着String拼接和equals比较,那基本就是出题人设置的加密变换,直接用逆运算跑数组就行。
3.2 手推前几个字符:从乱码变回flag的过程
为了确认判断,我把数组前几个字符手算了一遍:
'c'的ASCII码是99,加3是102,也就是'f''i'的ASCII码是105,加3是108,也就是'l''^'的ASCII码是94,加3是97,也就是'a''d'的ASCII码是100,加3是103,也就是'g''x'的ASCII码是120,加3是123,也就是'{'
前五个字符拼出来是什么?flag{。看到这里基本可以确定思路正确,后面的字符全部按同样规则处理。
其实一个经验丰富的逆向选手,到这里甚至不需要看完整个数组,因为"flag{"开头已经强烈暗示了结果方向。不过我还是把完整数组都提取出来了,后面直接交给Python处理。
3.3 加密变换的常见家族:偏移、异或、倒序、Base64
先把FindIt这道题归个类:它属于"单字符静态变换"里最简单的偏移家族。实际做题时你会发现,安卓CTF题里常见的加密变换也就那几种,识别它们有一套固定的套路:
| 变换类型 | 典型代码特征 | 逆向思路 |
|---|---|---|
| ASCII偏移 | (char) (arr[i] + 3)或(char) (arr[i] - 5) | 对每个字符做相反的偏移 |
| 单字节异或 | arr[i] ^ key | 对每个字符再异或同一个key |
| 倒序 | new StringBuilder(str).reverse() | 把数组顺序反过来 |
| Base64编码 | Base64.encodeToString | 用Base64解码 |
| 分段重排 | 多个字符串按索引交叉拼接 | 按索引规则重新组合 |
| 进制转换 | Integer.toHexString(c) | 把十六进制转回字符 |
识别方法很简单:先看循环体对字符做了什么运算,再决定用哪种逆运算。如果代码里同时出现异或和偏移,就先做反向偏移再做异或,变量顺序调换一下就行。FindIt连异或都不是,只有一个加3,应该算逆向题里的签到难度了。
4. 写脚本出flag:别手算,用Python3一次性跑完
4.1 提取数组并写成Python列表
数组提取是个细致活。JADX里看到的字符数组,直接复制过来通常会带Java的语法,比如单引号、逗号、空格。我习惯在编辑器里整理成Python列表,需要注意两个坑:
- 数组里有转义字符
'\\',在Python字符串里也要写成'\\',否则会被解释成换行或者别的控制字符 - 字符数组元素全是
char,Python里用字符串表示即可,后面用ord()取ASCII码
整理好的Python脚本是这样的:
# 从JADX中提取的字符数组,注意 '\\' 在Python里代表一个反斜杠字符 enc = [ 'c', 'i', '^', 'd', 'x', 'F', 'q', '\\', 'f', 'p', '\\', '^', '\\', 'C', 'r', 'k', 'k', 'v', '\\', 'c', 'i', '^', 'd', 'z' ] flag = ''.join(chr(ord(c) + 3) for c in enc) print(flag)运行之后输出:
flag{It_is_a_Funny_flag}如果你手头的题目版本和我这边的数组一样,那提交这个flag就能通过。就算版本不同,思路也完全通用:从JADX复制数组,用ord(c) +/- 偏移量或ord(c) ^ key跑一遍,结果自然就出来了。
这里我想多说一句:看到flag里有个Funny的时候,我忍不住笑了。很多CTF出题人喜欢在flag里玩梗,这道题多少带着点"你找到了就会觉得挺有趣"的意思。
4.2 在JADX里二次确认:把flag填回去看是否匹配
跑出flag{It_is_a_Funny_flag}之后,我一般不会直接提交,而是先在逻辑上做一次确认。把拿到的flag逐字符反过来减3,看能不能得到原始的enc数组:
'f'(102) - 3 = 99 ='c''l'(108) - 3 = 105 ='i''a'(97) - 3 = 94 ='^''g'(103) - 3 = 100 ='d'
对上了,说明从enc到flag的映射完全正确,这个flag在程序里是能通过验证的。这一步别省,尤其当你用脚本跑出结果后,最好用代码里的原始逻辑反向验证一下,能排掉很多低级错误,比如复制数组漏了元素、转义没处理对之类的问题。
如果你手边有安卓模拟器,也可以把APK拖进去安装,在输入框里粘贴这个flag,点按钮,会弹出Right!的Toast提示。不过说实话,这道题静态验证足够了,模拟器不是必需品。
4.3 提交flag:BUUCTF平台操作与注意事项
BUUCTF平台的提交框要求输入完整flag,包含flag{}字样。直接把跑出来的完整字符串粘进去就行,不需要去掉花括号,也不需要在后面加空格。
提交通过后会有提示,这里有个小细节:有时候平台会偶尔抽风,出现网络连接异常或者提交超时,这时候不要慌,检查一下flag大小写是不是和跑出来的一致,再重试一次。我遇到过几次因为复制粘贴时把I和l搞混导致提交失败的情况,所以如果提交失败,优先检查的是flag的字符准确性,而不是题目本身。
5. 从FindIt延伸:安卓逆向入门的几个通用经验
5.1 遇到安卓逆向题的优先分析路径
做完FindIt,我最大的感受是:安卓逆向题其实有一条非常清晰的路径,顺着走不会乱。
第一步永远是静态分析。用JADX打开APK,先看Manifest确定入口Activity,再看onCreate和按钮回调,把Java层逻辑理顺。大部分简单题到这一步就结束了,根本不需要动态调试。
第二步才是动态分析。如果JADX看到的东西不够清晰,比如有System.loadLibrary加载了.so,或者代码被混淆得看不出逻辑,那就需要上IDA分析Native层,或者用模拟器+Frida做运行时Hook。动态调试适合验证猜测,而不是漫无目的地乱试。
第三步是善用搜索。JADX里可以全局搜索字符串,比如flag、key、secret、right、wrong,搜一下往往能直接定位到关键判断点。FindIt里那个Right!的Toast字符串就是我搜索时最先发现的线索。
5.2 处理字符串提取和转义的经验
这次真正让我觉得值得记录的经验,是数组提取时的转义处理。Java字符数组里的反斜杠'\\'在源代码里表示一个反斜杠字符本身,复制到Python里如果直接粘贴,'\\'会被Python解释成一行末尾的续行符或者直接报错。我在最开始就碰了一次壁,后来规范成用Python列表按行整理,问题就消失了。
另外,如果数组特别长,建议用脚本的列表推导式一次性处理,不要手动去数"这个字符在第几个位置"。人手动数超过20个元素的数组,大概率会出错。程序员能靠脚本解决的事情,就不要靠眼睛硬扛。
5.3 出题人常藏flag的位置:扫一眼总没错
安卓逆向题里,flag不仅仅会出现在Java代码的字符数组里,常见的藏匿位置还有这些:
- AndroidManifest.xml:有时候flag就写在meta-data标签里,改个十六进制或Base64编码
- res/values/strings.xml:直接把flag拆成多个字符串藏在资源里
- assets目录:放一个文件,内容经过简单编码
- BroadcastReceiver:需要触发某个广播才能输出flag
- SharedPreferences:App运行后把flag写入本地配置
- so库中的JNI函数:通过
exported function导出,需要用IDA分析
FindIt属于最简单的那种,字符串数组直接躺在Java代码里。但你如果只盯着代码看,遇到藏在资源里的题就会漏掉。我的习惯是JADX打开后,先用资源浏览器把res/values/strings.xml和assets目录扫一遍,确认没有可疑内容,再进入Java源码分析。
5.4 最后分享一点做题心得
做逆向题,尤其是安卓逆向题,最重要的不是背工具菜单,而是建立"输入 -> 处理 -> 比较"这个模型。任何验证型逆向题,本质上都是在某个地方藏了一个"期望的输入",你的工作就是把这个期望值从代码、资源、内存里抠出来。FindIt这道题,期望值就藏在那个乱码字符数组里,通过一次偏移运算还原出来。
做完这道题之后,我又去BUUCTF刷了几道安卓逆向,明显感觉心态不一样了。碰到APK先不慌,按"Manifest找入口、JADX看Java层、有so再上IDA、字符串搜索定位关键逻辑"这套流程走,大部分题能稳定解出。如果你想入门安卓逆向,FindIt是一个很舒服的起点——它足够简单,但又完整覆盖了逆向分析的每一个关键动作,值得亲手做一遍。