简介:这是一份面向CTF竞赛选手与移动安全初学者的Android逆向实战资料,聚焦APK反编译与安全测试场景,适合具备一定Java与Android基础、希望入门移动逆向的中级学习者。压缩包内仅含1个PDF文档,体积约18KB,内容以图文与代码片段形式组织,便于快速查阅与对照练习。文档围绕Android应用逆向工程展开,系统讲解APKToolBOX与jadx两款工具的使用方法,演示如何将APK反编译为Java源码并分析应用内部逻辑。案例部分以Android Easy与DD Android Easy两道典型赛题为主线,完整呈现从定位MainActivity、阅读check函数到编写异或解密脚本还原flag的全过程,并延伸至FlagActivity的字节数组分析与漏洞检测思路,帮助读者掌握反编译、代码审计与flag提取的通用方法。目前已有473人学习,适合作为CTF安卓逆向入门的实操参考。
1. 从一份 CTF 逆向安卓篇 PDF 说起:为什么静态分析仍是 Android 逆向的起手式
很多人第一次接触 CTF 安卓逆向,第一反应是上模拟器、开动态调试、挂 Frida 脚本。但真到了比赛环境里,模拟器检测、反调试、签名校验三座大山一压,动态方案经常直接翻车。反倒是老老实实把 APK 拖进 jadx 看 Java 层逻辑,再对 .so 文件上 IDA 做静态分析,往往能在十几分钟内拿到 flag。这份《CTF 逆向安卓篇》PDF 就是沿着这条静态分析主线走的,它用五个难度递进的题目——Androideasy、DD Android Easy、DD Android Normal、FindPass、Smali、爬楼梯——把 APKToolBOX 反编译、jadx 看源码、IDA 看 native 层、Smali 改字节码这几件事串成了一条完整的实操链路。适合刚入门 ctf 的新手照着复现,也适合做 app 逆向的从业者拿来当静态分析的流程参考。下面我按题目难度从低到高拆一遍,重点讲清楚每一步为什么这么做、参数怎么定、哪里容易踩坑。
2. Androideasy 与 DD Android Easy:异或逻辑的两种典型写法
2.1 用 jadx 定位 MainActivity 里的 check 函数
拿到 APK 的第一步永远是看 Java 层。把 APK 拖进 APKToolBOX 里的 jadx,等反编译完成后,在搜索框里搜MainActivity,直接跳到主 Activity。Androideasy 这道题的核心逻辑全在check()函数里,代码结构非常干净:
public boolean check() { byte[] chars = this.editText.getText().toString().getBytes(); if (chars.length != this.s.length) { return false; } int i = 0; while (i < chars.length) { if (this.s[i] != (chars[i] ^ 23)) { return false; } i++; } return true; }逻辑说明:用户输入被转成字节数组chars,长度必须和硬编码数组s一致,然后逐字节做chars[i] ^ 23,结果必须等于s[i]。异或运算是可逆的,所以s[i] ^ 23就是原始输入字节。参数上唯一需要注意的是23是十进制,写脚本时用0x17等价,别写成字符串"23"。
对应的解题脚本:
s = [113, 123, 118, 112, 108, 94, 99, 72, 38, 68, 72, 87, 89, 72, 36, 118, 100, 78, 72, 87, 121, 83, 101, 39, 62, 94, 62, 38, 107, 115, 106] flag = '' for i in s: flag += chr(i ^ 0x17) print(flag)跑出来是flag{It_1S_@N_3asY_@nDr0)I)1|d}。这类题在 ctf 入门阶段出现频率极高,核心就是识别异或、取反、加减常量这三种单字节变换。
2.2 DD Android Easy:数组异或后按首字节偏移取串
第二道 DD Android Easy 的套路稍微绕一点。jadx 里定位到FlagActivity,关键函数是i():
private String i() { int i; int i2 = 0; byte[] bArr = new byte[p.length]; for (i = 0; i < p.length; i++) { bArr[i] = (byte) (p[i] ^ q[i]); } byte b = bArr[0]; i = 0; while (bArr[b + i] != (byte) 0) { i++; } byte[] bArr2 = new byte[i]; while (i2 < i) { bArr2[i2] = bArr[b + i2]; i2++; } return new String(bArr2); }逻辑说明分两段:第一段把p和q两个等长数组逐字节异或,得到中间数组bArr;第二段把bArr[0]当作偏移量b,从bArr[b]开始往后读,直到遇到0x00为止,这段子串就是 flag。参数上要注意 Java 的byte是有符号的,范围 -128 到 127,Python 里做异或前得先转成无符号或者用& 0xFF处理,否则负数异或结果会出错。
脚本写法:
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] arr1 = [] for i in range(len(p)): arr1.append((p[i] ^ q[i]) & 0xFF) # 关键:& 0xFF 处理有符号字节 k = arr1[0] i1 = 0 while arr1[k + i1] != 0: i1 += 1 flag = '' for i in range(i1): flag += chr(arr1[k + i]) print(flag)结果是DDCTF-3ad60811d87c4a2dba0ef651b2d93476@didichuxing.com。这道题的价值在于让你意识到:Java 反编译出来的数组经常带负数,Python 脚本里不做& 0xFF就会得到一堆乱码,这是新手最容易翻车的地方。
3. DD Android Normal 与 FindPass:native 层与资源文件的交叉分析
3.1 从 Java 层 JNI 声明追到 .so 文件
DD Android Normal 的 Java 层逻辑很简单,主函数里判断用户输入是否等于stringFromJNI()的返回值。jadx 里看到这个方法是 native 声明,来自hello-libs这个库。这时候 Java 层已经到头了,必须往下走。
操作步骤:先把 APK 当 zip 解压,进入lib/arm64-v8a/目录,找到对应的.so文件,拖进 IDA。IDA 加载时架构选 ARM64,加载完成后在函数列表里搜Java_com_didictf_hellolibs_MainActivity_stringFromJNI,直接定位到目标函数。
函数体里有一段关键逻辑:
v15 = xmmword_A40; v16 = xmmword_A50; // ... 省略中间赋值 v24 = xmmword_AD0; do { *(&v25 + v6) = byte_B96[v6 + 160] ^ byte_AE0[v6 + 160]; ++v6; } while (v6 != 22); v7 = -1; v8 = &v15 + (v15 >> 1); v9 = -2; do { v10 = *v8++; ++v9; ++v7; } while (v10);逻辑说明:v15到v24是一连串从数据段加载的大块数据,后面又对byte_B96和byte_AE0两个数组做了 22 字节的异或。v15 >> 1这个操作暗示v15的低位存了一个偏移量,&v15 + (v15 >> 1)就是从这个偏移处开始读字符串。在 IDA 里双击xmmword_A40跳到数据段,按R键把数据转成字符视图,flag 就直接以明文形式藏在里面。
提示:IDA 里看数据段时,如果按 R 没反应,先确认光标在数据上而不是代码上,另外有些版本需要先按 D 把数据定义成 byte 数组再按 R。
这道题的关键认知是:native 层的字符串不一定加密,很多时候只是被拆成几段放在数据段里,异或只是障眼法。在模拟器里跑一遍程序,把读出来的 flag 提交验证即可,最终 flag 是DDCTF-397a90a3267641658bbc975326700f4b@didichuxing.com。
3.2 FindPass:字符串搜索定位 ekey 与 src.jpg 字节变换
FindPass 这道题的入口在 jadx 里看主函数,底部有Flag==flag{Key}这样的判断,往上追会发现ekey来自R.string.fkey,而fkey在strings.xml里。这里 jadx 只能看到资源 ID,看不到具体字符串值,所以需要换工具。
操作步骤:把 APK 拖进 Android Killer,用它的全局字符串搜索功能搜fkey,在string.xml里找到值为Tr43Fla92Ch4n93。回到 jadx 看后续逻辑:程序读取src.jpg的每个字节到数组cha,然后对ekey每个字节做操作——偶数位置减temp2,奇数位置加temp2。这里temp2的具体值需要从反编译代码里确认,通常是某个固定常量。
关键坑点:Java 的char类型范围是 -128 到 127,Python 里做加减后必须做范围限制,否则索引会越界。脚本如下:
# coding:utf-8 ekey = [ord(i) for i in 'Tr43Fla92Ch4n93'] cha = [] f = open('src.txt', 'r') data = f.read() for i in range(0x400 * 3): cha.append(int(data[3 * i:3 * i + 2], 16)) flag = '' for i in range(len(ekey)): # 注意 char 类型是 -128 到 127 if i % 2 == 0: idx = (ekey[i] - temp2) & 0xFF else: idx = (ekey[i] + temp2) & 0xFF flag += chr(cha[idx]) print(flag)参数说明:src.txt是用十六进制编辑器打开src.jpg后复制出来的十六进制数据,每字节占 3 个字符(两个十六进制位加一个分隔符),所以切片步长是 3。0x400 * 3对应 1024 字节的读取范围,具体数值以实际文件大小为准。最终 flag 是Qv49CmZB2Df4jB。
这道题教的是:当 Java 层逻辑涉及资源文件时,必须把资源文件单独提取出来做字节级分析,不能只盯着代码看。
4. Smali 与爬楼梯:字节码层修改与重打包签名
4.1 Smali 转 Java 看 AES 解密逻辑
Smali 这道题给的是一个.smali文件,不是完整 APK。硬读 smali 语法当然可以,但效率太低。常见做法是用 Smali2JavaUI 这类工具直接把 smali 转回 Java 代码,转换后核心逻辑一目了然:
public class Crackme { private String str2 = "cGhyYWNrICBjdGYgMjAxNg=="; public Crackme() { GetFlag("sSNnx1UKbYrA1+MOrdtDTA=="); } private String GetFlag(String p1) { byte[] content = Base64.decode(p1.getBytes(), 0x0); String kk = new String(Base64.decode(str2.getBytes(), 0x0)); System.out.println(decrypt(content, kk)); return null; } private String decrypt(byte[] p1, String p2) { try { byte[] keyStr = p2.getBytes(); SecretKeySpec key = new SecretKeySpec(keyStr, "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); cipher.init(0x2, key); byte[] result = cipher.doFinal(p1); return null; } catch (Exception e) { e.printStackTrace(); } return null; } }逻辑说明:str2做 Base64 解码后得到 AES 密钥,sSNnx1UKbYrA1+MOrdtDTA==做 Base64 解码后得到密文,加密模式是AES/ECB/NoPadding。ECB 模式不需要 IV,直接解密即可。参数上注意NoPadding意味着密文长度必须是 16 的整数倍,这里刚好满足。
解密脚本:
from Crypto.Cipher import AES import base64 a = base64.b64decode('sSNnx1UKbYrA1+MOrdtDTA==') b = base64.b64decode('cGhyYWNrICBjdGYgMjAxNg==') x = AES.new(b, AES.MODE_ECB) print(x.decrypt(a))结果是PCTF{Sm4liRiver}。这道题的意义在于:smali 只是中间表示,能转 Java 就转 Java,别跟字节码死磕。
4.2 爬楼梯:改 setClickable 标志位绕过逻辑判断
爬楼梯这道题不走算法分析路线,走的是软件破解路线。运行 APK 后发现第二个按钮不可点击,每点一次第一个按钮楼层数加一,说明 flag 藏在第二个按钮的响应逻辑里。思路很直接:把第二个按钮的setClickable标志从0x0改成0x1。
操作步骤:
# 1. 用 APKToolBOX 反编译 APK # 2. 进入 smali 目录,删除 unknow 文件夹(与签名校验有关) # 路径:CFF_100\smali\com\ctf\test\ctf_100 # 3. 用记事本打开 MainActivity.smali,搜索 setClickable # 找到两处调用,第一处参数是 0x1,第二处是 0x0 # 把第二处的 0x0 改成 0x1,保存 # 4. 用 APKToolBOX 回编译,生成新 APK # 5. 对新 APK 签名,运行签名后的版本逻辑说明:setClickable的第一个参数是 View 对象,第二个参数是布尔标志。0x1表示可点击,0x0表示不可点击。改完之后回编译,APKToolBOX 会自动生成签名版本(如CFF_100(1)_Signed.apk),安装运行后点第二个按钮就能拿到 flag。
注意:删除
unknow文件夹是因为它包含原始签名信息,修改 smali 后原签名必然失效,留着反而会导致回编译报错。回编译后必须重新签名,否则安装会提示INSTALL_PARSE_FAILED_NO_CERTIFICATES。
5. 避坑与常见问题排查
5.1 jadx 反编译出来代码不全或报错
现象:jadx 打开某些 APK 后,部分类显示// decompilation error或者方法体为空。原因是这些 APK 用了较新的混淆方案或者 dex 格式版本较高,jadx 的默认反编译器处理不了。解决办法是换用 jadx-gui 的最新版本,或者在设置里把反编译器从jadx切到Procyon,后者对复杂控制流的还原能力更强。如果还是不行,就用 APKToolBOX 先反编译成 smali,再用 Smali2JavaUI 转 Java。
5.2 Python 异或脚本结果全是乱码
现象:按 Java 代码逻辑写了异或脚本,跑出来一堆不可打印字符。原因九成是 Java 的byte有符号,负数在 Python 里异或会得到完全不同的结果。解决办法是在异或前对每个字节做& 0xFF转无符号,或者用struct.unpack('b', ...)显式按有符号字节处理。这个坑在 DD Android Easy 那道题里体现得最明显。
5.3 IDA 加载 .so 后找不到 JNI 函数
现象:把.so拖进 IDA,函数列表里搜不到Java_开头的函数。原因通常是架构选错了,APK 里可能有armeabi-v7a、arm64-v8a、x86多个版本,你加载的那个可能不是程序实际调用的。解决办法是先看lib目录下有哪些架构,优先选arm64-v8a,如果 IDA 加载后函数名被 stripped,就用JNI_OnLoad或者导出表里的Java_前缀来定位。
5.4 回编译 APK 后安装失败
现象:改完 smali 回编译,安装时提示签名不一致或解析失败。原因是修改后的 APK 签名和原签名不匹配,Android 系统拒绝安装。解决办法是回编译后必须用jarsigner或 APKToolBOX 自带的签名功能重新签名,签名时用的 keystore 可以是自己生成的调试证书。另外记得删除原始的META-INF目录,否则新旧签名文件冲突。
5.5 AES 解密报 Padding 错误
现象:Smali 那道题里用AES/ECB/NoPadding解密时,如果密文长度不是 16 的整数倍,会直接抛异常。原因是NoPadding模式要求输入长度严格对齐块大小。解决办法是先用len(ciphertext) % 16检查一下,如果不是 0,说明 Base64 解码环节出了问题,检查原始字符串有没有被截断或者包含换行符。
6. 从静态分析到批量处理:把重复劳动脚本化
做完这五道题你会发现,真正耗时的不是分析逻辑,而是重复的机械操作:提取数组、转十六进制、处理有符号字节、跑异或。我一般会把这些步骤固化成一个 Python 模板,下次遇到同类题直接改数组和运算符号就行。比如下面这个通用异或解密函数,支持单字节和多字节密钥:
def xor_decrypt(data, key): """ data: 密文字节列表或 bytes key: 单字节 int 或 bytes 密钥 返回: 解密后的 bytes """ if isinstance(key, int): key = bytes([key]) result = bytearray() for i, b in enumerate(data): result.append(b ^ key[i % len(key)]) return bytes(result) # 用法示例:Androideasy s = [113, 123, 118, 112, 108, 94, 99, 72, 38, 68, 72, 87, 89, 72, 36, 118, 100, 78, 72, 87, 121, 83, 101, 39, 62, 94, 62, 38, 107, 115, 106] print(xor_decrypt(s, 0x17).decode())参数说明:data可以是 list 或 bytes,函数内部统一按字节处理;key传 int 时自动转单字节,传 bytes 时按循环密钥处理。这个模板覆盖了单字节异或、循环异或、多字节密钥异或三种场景,CTF 里八成以上的异或题都能直接套。
再进阶一步,如果你经常做 app 逆向,可以把 jadx 的命令行版本集成到脚本里,实现「反编译 → 搜索关键字 → 提取数组 → 自动解密」的半自动化流程。jadx 命令行用法:
jadx -d output_dir target.apk # -d 指定输出目录 # 反编译完成后用 grep 搜关键函数名 grep -rn "check\|verify\|decrypt" output_dir/sources/这样一套下来,简单题基本可以做到五分钟内出 flag。但要注意,自动化只能处理模式固定的题,遇到控制流平坦化或者 native 层复杂运算,还是得回到 IDA 里逐行看汇编。从那以后我每次拿到新 APK,都强制自己先跑一遍 jadx 全局搜索xor、base64、AES、native这几个关键词,把 Java 层能拿的信息先拿干净,再决定要不要往 .so 里钻。这个习惯帮我省掉了至少一半的无用动态调试时间。希望帮到你。
本文还有配套的精品资源,点击获取