简介:一份面向CTF逆向方向安卓篇学习者的系统入门资料,适合CTF参赛者、移动应用安全测试人员,以及刚接触Android逆向的初学者。内容以APKToolBOX与jadx两款工具为主线,完整介绍APK反编译、Java字节码还原、MainActivity与FlagActivity定位等流程;从实际CTF赛题出发,演示通过主函数异或校验逻辑逐字节还原Flag的脚本方法,并延伸至Android应用安全测试中的漏洞发现与修复思路。资源包为单个PDF文档,容量仅18KB,轻量便于随时查阅,按Android应用逆向工程、工具链操作、安全测试和知识点总结等部分组织。目前已有473人学习,适合用来系统建立安卓逆向分析框架,并快速上手常用工具链。
1. CTF 逆向安卓入门:从 Java 层一路啃到 SO 层,五道题带你打通静态分析主路径
去年整理 CTF 逆向安卓篇的笔记时,我把一批典型的安卓逆向题按解法分了个类:纯 Java 层异或、双数组查表、SO 层 JNI、资源伪装、AES 算法识别、smali 补丁。这份 PDF 恰好把这几类都覆盖了,每个案例都附了反编译后的关键代码和还原脚本,对刚接触安卓逆向的人来说,是性价比很高的一份练手材料。它要解决的核心问题是:拿到一个 APK 后,入口在哪、数据怎么还原、算法怎么识别、改动后怎么回编译。适合正在刷 CTF 的入门选手,也适合做 Android 应用安全测试、需要快速定位关键逻辑的从业者。有个反直觉的结论先放在这里:很多题根本不需要动态调试,jadx 静态看 Java 层能解决八成问题,剩下的才轮到 IDA 和补丁。
2. Java 层静态分析主线:jadx 反编译、异或还原与双数组查表
2.1 从 onClick 回溯到 check 函数:异或比较是最常见的入口
安卓 CTF 最简单的一类题,逻辑全部写在 Java 层,反编译后直接在MainActivity里就能看到校验函数。用 APKToolBOX 自带的 jadx 打开 APK,找到MainActivity,会发现onClick调用了check(),而check()做的事情就是把用户输入的字符串逐字节与23异或,再和一个预置的字节数组s比较。核心代码等价于:
for (int i = 0; i < this.s.length; i++) { if (this.s[i] != (chars[i] ^ 23)) { return false; } } return true;这段逻辑的数学本质是:已知s[i] = chars[i] ^ 23,两边同时再异或一次23,就得到chars[i] = s[i] ^ 23。异或是自逆运算,这是 CTF 逆向里最基础也最高频的性质,后面好几个案例都在用它。所以还原脚本很简单:
s = [ 0x71, 0x7b, 0x76, 0x70, 0x6c, 0x5e, 0x63, 0x48, 0x26, 0x44, 0x48, 0x57, 0x59, 0x48, 0x24, 0x76, 0x64, 0x4e, 0x48, 0x57, 0x79, 0x53, 0x65, 0x27, 0x3e, 0x5e, 0x3e, 0x26, 0x6b, 0x73, 0x6a ] flag = ''.join(chr(b ^ 0x17) for b in s) print(flag)注意0x17就是十进制23,写成十六进制是为了和数组里的一串十六进制字节风格统一。跑出来是flag{It_1S_@N_3asY_@nDr0)I)1|d}。这里有个容易忽略的细节:jadx 反编译出来的字节数组是带(byte)强转的,直接复制进 Python 时要保证数值范围正确,后面第 5 章我专门讲这个坑。
2.2 双数组异或与偏移读取:DD Android Easy 的 FlagActivity
第二道题比第一道稍微绕一点,MainActivity里没直接给结果,而是把真正的校验逻辑放在了FlagActivity。jadx 打开后能看到两个大数组p和q,i()函数先把两个数组逐字节异或得到bArr,然后以bArr[0]作为起始偏移,从这个偏移位置往后一直读到字节0为止,中间的字符串就是 flag。伪代码如下:
byte[] bArr = new byte[p.length]; for (int i = 0; i < p.length; i++) { bArr[i] = (byte) (p[i] ^ q[i]); } byte b = bArr[0]; int i = 0; while (bArr[b + i] != (byte) 0) { i++; } byte[] result = new byte[i]; for (int j = 0; j < i; j++) { result[j] = bArr[b + j]; } return new String(result);注意这里容易踩一个理解上的坑:while循环是先判断bArr[b + i] != 0再让i++,也就是说循环结束后i的值就是有效字符的数量。还原脚本按这个语义写:
p = [-40, -62, 107, 66, -126, 103, # 完整数组从资源包里复制 -56, 77, 122, -107, -24, -127, 72, -63, -98, 64, -24, -5, -49, -26, 79, -70, -26, -81, 120, 25, 111, -100, -23, -9, 122, -35, 66, -50, -116, 3, -72, 102, -45, -85, 0, 126, -34, 62, 83, -34, 48, -111, 61, -9, -51, 114, 20, 81, -126, -18, 27, -115, -76, -116, -48, -118, -10, -102, -106, 113, -104, 98, -109, 74, 48, 47, -100, -88, 121, 22, -63, -32, -20, -41, -27, -20, -118, 100, -76, 70, -49, -39, -27, -106, -13, -108, 115, -87, -1, -22, -53, 21, -100, 124, -95, -40, 62, -69, 29, 56, -53, 85, -48, 25, 37, -78, 11, -110, -24, -120, -82, 6, -94, -101] q = [-57, -90, 53, -71, -117, 98, 62, 98, 101, -96, 36, 110, 77, -83, -121, 2, -48, 94, -106, -56, -49, -80, -1, 83, 75, 66, -44, 74, 2, -36, -42, -103, 6, -115, -40, 69, -107, 85, -78, -49, 54, 78, -26, 15, 98, -70, 8, -90, 94, -61, -84, 64, 112, 51, -29, -34, 126, -21, -126, -71, -31, -24, -60, -2, -81, 66, -84, 85, -91, 10, 84, 70, -8, -63, 26, 126, -76, -104, -123, -71, -126, -62, -23, 11, -39, 70, 14, 59, -101, -39, -124, 91, -109, 102, -49, 21, 105, 0, 37, -128, -57, 117, 110, -115, -86, 56, 25, -46, -55, 7, -125, 109, 76, 104, -15, 82, -53, 18, -28, -24] arr = [p[i] ^ q[i] for i in range(len(p))] start = arr[0] length = 0 while arr[start + length] != 0: length += 1 flag = ''.join(chr(arr[start + i]) for i in range(length)) print(flag)代码里的p[i] ^ q[i]用列表推导式一次算出全部异或结果,start取第一个元素作为偏移,length是有效字符数。跑出来的 flag 是DDCTF-3ad60811d87c4a2dba0ef651b2d93476@didichuxing.com。我提一个判断经验:看到两个等长字节数组做异或,再结合一个“取首元素当偏移”的读取逻辑,基本就是同一套套路,先异或、再偏移、再截断,三步走完。
2.3 jadx 的使用习惯:搜索、定位与反混淆
这两道题都不需要动态调试,纯静态就够。实际操作时我的习惯是:先用 jadx 打开 APK,直接看AndroidManifest.xml里activity的注册顺序,通常入口activity排在最前面,exported="true"加上带LAUNCHERintent-filter 的就是。然后顺着onClick找校验函数,再把校验函数里用到的数组复制出来。jadx 里有个很有用的功能是全局搜索字符串,比如搜flag、correct、You got,能快速定位校验成功的分支。这两个案例的代码都比较规整,没有混淆,属于典型的教学题;如果遇到混淆过的字段名,优先看方法调用关系而不是变量名,check这类方法名即使被混淆成a,它被onClick调用的事实不会变。
3. 当 Java 层不够用:SO 层 JNI 函数定位与 IDA 静态还原
3.1 从 jadx 看到 stringFromJNI,到解压 APK 找 so
第三道题DD Android Normal开始上强度。jadx 打开主函数,发现校验逻辑在stringFromJNI这个 native 方法里,MainActivity 只负责把返回值拿出来和用户输入做equals。看到 JNI 方法,就该意识到 Java 层已经到底了,下一步是解压 APK 找.so文件。常见做法是用unzip或直接改后缀名解压:
unzip DDCTF-Normal.apk -d ddctf_normal find ddctf_normal/lib -name "*.so"解压后关注lib/arm64-v8a目录,这道题的 so 就在DDCTF-Normal\lib\arm64-v8a下。选 ABI 目录有个原则:优先看最大的那个目录,通常是arm64-v8a;如果同时存在armeabi-v7a,可能需要两份都拖进 IDA,因为同一个 native 函数可能有两套实现。JNI 函数的命名规则是Java_包名_类名_方法名,下划线对应包名分隔符,看到Java_com_didictf_hellolibs_MainActivity_stringFromJNI就能直接在 IDA 的导出表里定位。
3.2 IDA 里读 ARM64 反汇编:xmmword 数据区的字符串还原
把 so 拖进 IDA,等自动分析完成后,在Exports窗口找到Java_com_didictf_hellolibs_MainActivity_stringFromJNI,双击进去看反汇编。这段代码的关键特征是:JNI 函数开头先做了一堆gpower循环,那是用来干扰静态分析的时间计算;真正干活的在函数后半段,连续的xmmword_A40、xmmword_A50一直到xmmword_AD0被加载到局部变量v15到v24,随后一个循环把byte_B96和byte_AE0两个地址区间的数据逐字节异或 22 次。不过真正有价值的不是这段异或,而是xmmword数据区本身——在 IDA 里双击任何一个 xmmword,再按R键把数据切换成字符显示,能直接看到可读字符串。这道题就是这么解出来的:数据区里明晃晃地写着 flag。
DDCTF-397a90a3267641658bbc975326700f4b@didichuxing.com经验是:native 函数里凡是出现xmmword连续加载,往往是把一段预置字符串或加密数据直接嵌在只读数据段,ReadStatusReg、GetTicks这类调用都是干扰项。验证方法也很简单,把拿到的 flag 填进模拟器里运行 APK,提示正确。顺便说一句,用 IDA 看 JNI 函数时,先看传给NewStringUTF或NewString的第二个参数,那里通常就是最终返回给 Java 层的字符串。
3.3 静态分析 SO 层时的几个关键点
分析 so 文件有一个容易忽略的前提:确保 IDA 的处理器类型选对了。arm64-v8a的 so 需要 IDA 识别成ARM Little-endian的ARMv8-A模式,拖进去时弹窗选错会直接导致反汇编不可读。另外,so 的动态符号表里不一定只有 JNI 导出函数,JNI_OnLoad也值得看一眼,有些 APK 会在这里做反调试或字符串解密。当函数体里看到GetTicks()和__android_log_print连用,基本可以判断是作者故意加的计算耗时噪音,直接跳过,别浪费时间分析那段循环。
4. 资源伪装与加密算法识别:从 src.jpg 查表到 AES/ECB 还原
4.1 FindPass:把图片字节当查表数据用
第四道题FindPass的入口在 jadx 里能看到两段关键代码:底部Flag==flag{Key}提示了校验分支,ekey来自R.string.fkey,字符串值是Tr43Fla92Ch4n93。真正的数据源藏在src.jpg里——代码把 APK 内嵌的这张图片逐字节读进数组cha,然后按ekey每个字符的 ASCII 值作为下标去cha里取数,偶数位置减temp2、奇数位置加temp2。原文档在这里有一段截断,我按逻辑补全后的还原脚本是这样:
# coding: utf-8 ekey = [ord(c) for c in 'Tr43Fla92Ch4n93'] cha = [] with open('src.txt', 'r') as f: data = f.read() # 图片被读成十六进制文本,每 3 个字符是一个字节 for i in range(0x400 * 3): cha.append(int(data[3 * i:3 * i + 2], 16)) flag = '' for i in range(len(ekey)): temp1 = ekey[i] temp2 = ekey[(i + 1) % len(ekey)] if i % 2 == 0: # 偶数位置减 temp2 flag += chr(cha[temp1] - temp2) else: # 奇数位置加 temp2 flag += chr(cha[temp1] + temp2) print(flag)脚本跑出来是Qv49CmZB2Df4jB-。这里两个参数值得解释:0x400 * 3对应src.jpg的前0x400个字节,每字节在文本里用两个十六进制字符表示,所以偏移步长是3个字符(两个十六进制数加一个空格);temp2我按最常见的相邻字符取值逻辑补成了ekey[i+1],如果跑出来是乱码,优先调整这里——原文档在这个位置的代码确实被截断了,你拿到完整版后可以直接核对原始脚本。这类“图片当数据源”的题,读字节时要注意图片文件头不是从0xFFD8开始的常规 JPEG,而是被作者整理过的文本格式,所以用int(..., 16)逐字节解析,不能直接读二进制。
4.2 Smali 案例:Base64 解码串进 AES/ECB/NoPadding
第五题是一道典型的算法识别题,给的是一个 smali 文件。题目的正解思路有两种:硬读 smali 语法,或者用Smali2JavaUI这类工具直接转 Java。我第二次刷这道题时用了后者,转换出的核心逻辑是:str2的值cGhyYWNrICBjdGYgMjAxNg==经过 Base64 解码后作为 AES 的 key,sSNnx1UKbYrA1+MOrdtDTA==解码后是密文,解密模式是AES/ECB/NoPadding。还原脚本:
from Crypto.Cipher import AES import base64 enc_data = base64.b64decode('sSNnx1UKbYrA1+MOrdtDTA==') key = base64.b64decode('cGhyYWNrICBjdGYgMjAxNg==') cipher = AES.new(key, AES.MODE_ECB) plain = cipher.decrypt(enc_data) print(plain)跑出来是PCTF{Sm4liRiver}。这里几个点值得说清楚:ECB 模式不需要 IV,所以代码里没有iv参数;NoPadding意味着密文长度必须是 16 的整数倍,这个密文正好满足;key 是 16 字节,正好是 AES-128。识别这类题的核心是看Cipher.getInstance里的字符串——AES/ECB/NoPadding三个斜杠分段,前面是算法、中间是模式、后面是填充方式,CTF 里出现频率极高。另外注意导入包的版本差异,pycryptodome库的AES.new第一个参数必须是字节串,字符串前不加b会直接报类型错误。
4.3 从题回归安全测试视角
做完 FindPass 和 AES 这道题,把视角拉高一点:这类资源伪装和算法硬编码的题目,对应到真实 Android 应用安全测试里就是两个常见弱点——敏感数据藏在应用资源里、加密密钥硬编码在代码中。用 jadx 全局搜索字符串、用 IDA 看.so 数据段、用 Base64 特征识别硬编码 key,都是平时做 ISO 安全测试项目时会用到的同一套手法。区别在于 CTF 里数据是故意放置的,真实应用里往往包了混淆和加固,但从静态分析路径看,入口逻辑没有本质差异。
5. 避坑与常见问题排查:逆向途中最容易翻车的五个点
5.1 jadx 打开 APK 报错或反编译不出代码
现象:APK 拖进 jadx 后左侧目录树是空的,或者某个类点开只有throw new RuntimeException("stub")。原因:APK 做了加固,真实逻辑在壳的 so 层,Java 层只剩壳的入口。解决:先看Application类是不是加固 SDK 的类名,再用脱壳工具(如 Frida 脱壳脚本)在运行时 dump 出 dex,把 dump 出来的 dex 再拖进 jadx。如果只是某个类显示 stub,可能是 jadx 对部分字节码解析失败,换 jadx-gui 的1.4.x版本或者改用Bytecode Viewer交叉验证。
5.2 Python 脚本跑出来是乱码或报语法错误
现象:脚本第一行print flag在 Python 3 下直接SyntaxError,改成print(flag)后输出一堆不可读字符。原因:原文档的脚本是 Python 2 风格,且字节数组在跨语言复制时符号位处理不一致。解决:统一用我前面给的列表推导式写法,先确认数组元素是int型;遇到乱码优先检查异或的键值是不是0x17,以及chr()里是否套了多余的取模运算。字节数组中类似(byte) 113这种写法,在 Python 里直接写113即可,不需要强转。
5.3 char 类型溢出导致查表越界
现象:FindPass 那类题,脚本跑出来的字符断断续续,中间某个位置直接报IndexError。原因:Java 里 char 是 16 位无符号,但byte是带符号的 -128 到 127;如果 ekey 的某个值加上或减去 temp2 后超出了 ASCII 可打印范围,chr()会输出奇怪字符,查表下标也可能落到数组外。解决:在加减之后对结果做一次范围钳制,或者先检查cha[temp1]本身是否落在图片文件长度的有效区间内。我之前在这道题上翻车就是因为没注意0x400只对应前 1024 字节,图片实际有效数据如果不足 1024 字节,就应该减小读取范围。
5.4 修改 smali 后安装失败或运行闪退
现象:用 APKToolBOX 回编译后生成的 APK,安装时提示“解析包错误”,或者安装成功但一打开就退出。原因:改了 smali 之后原签名失效,APKToolBOX 自动签名生成_Signed.apk,但如果你删掉了 META-INF 里的签名文件却又手动改了 dex,签名校验就会挂。解决:反编译后先删掉META-INF目录和unknown目录,改完 smali 用 APKToolBOX 的“回编译并签名”功能一次性完成,不要在回编译后再单独改 dex。如果模拟器上闪退,优先查logcat里的AndroidRuntime崩溃栈,多半是改动破坏了方法签名或寄存器数量。
5.5 模拟器架构不匹配导致 so 加载失败
现象:DD Android Normal 这类带 so 的 APK,在 x86 模拟器上运行时报dlopen failed: library "libxxx.so" not found。原因:APK 里只有arm64-v8a目录下的 so,x86 模拟器无法直接加载 ARM 指令集的库。解决:用 ARM 架构的模拟器镜像(如arm64-v8a的 Android 模拟器),或者用真机测试;只做静态分析时,直接在 IDA 里打开lib/arm64-v8a下的文件即可,不需要在模拟器里跑通。另一个办法是给模拟器装 ARM 转译层,但速度很慢,CTF 场景下不推荐。
6. 补丁式求解的最后一个技巧:把 setClickable 改成 0x1 拿 flag
第六道题“爬楼梯”没有算法分析的必要,因为 flag 已经存在软件里,只是按钮被禁用,点不到。思路从“还原数据”变成了“修改逻辑”。运行 APK 能看到第一个按钮可点,第二个按钮置灰,每次点击第一个按钮爬楼数加一。既然第二个按钮激活后才显示 flag,那就直接改 smali 把setClickable的入参从0x0改成0x1。
用 APKToolBOX 反编译后,在CFF_100\smali\com\ctf\test\ctf_100\MainActivity.smali里用文本编辑器找setClickable:
grep -n "setClickable" MainActivity.smali正常能看到两个调用点,第一个按钮的是0x1,第二个按钮的是0x0。改第二个调用点附近的赋值指令,原 smali 类似:
const/4 v1, 0x0 invoke-virtual {v0, v1}, Landroid/view/View;->setClickable(Z)V改成:
const/4 v1, 0x1 invoke-virtual {v0, v1}, Landroid/view/View;->setClickable(Z)V保存后回编译,APKToolBOX 会生成一个带_Signed后缀的签名 APK。安装到模拟器,第二个按钮变可点击,点下去直接出 flag。我记得第一次做这道题时走了弯路:我先去分析楼层数和按钮状态之间的逻辑关系,想找到那个控制变量然后 patch 数据,完全没用上——因为作者的校验分支就是按按钮的可点击状态走的,这是典型的“逻辑不在数据里、却在控件属性里”的题。从那以后我拿到 APK 的第一件事,是先扫一遍所有setClickable、setEnabled、setVisibility,看看有没有按钮或控件被故意禁用,可能隐藏 flag 就直接在持控件上了。这个习惯帮我省了不少时间,希望帮到你。
本文还有配套的精品资源,点击获取