☰
Android逆向CTF找flag:从静态分析到动态验证还原AES密钥
2026/10/13 8:02:49 网站建设 项目流程

1. 拿到样本先别急着翻代码:静态侦察能省一半时间

如果你正在做Android逆向相关的CTF练习,大概率已经感受过这种情况:界面逻辑都翻完了,校验入口也找到了,可就是卡在找flag的最后一环——寻找秘钥。翻遍Java层只有一个调用,assets里躺着一堆二进制,越看越像key却又解不出任何明文。这篇算是我自己复盘的一个找flag实例,从拿到一个练习样本(下称模拟项目X.apk)开始,完整推演密钥从无到有、从静态分析到动态验证的整个过程。适合已经会基本jadx、apktool操作,但还没完整走通过“定位校验逻辑→锁定密钥来源→还原解密”全链路的人。

拿到一个要找flag的APK,大部分人第一反应是立刻反编译,然后在全局搜索框里敲“key”“secret”“flag”。这个动作不是不对,而是太早了。没有上下文支撑的字符串搜索,通常会把你带进一堆无关结果里。我更习惯先把整个样本当成一个“待梳理的系统”扫一遍,只花十分钟,后面少走半小时弯路。

这一阶段要做的不是找key,而是找“使用key的代码”。密钥不会自己凭空出现,它一定会作为某个加解密函数的参数被读取和传递。先定位到校验逻辑的主链路,再顺着数据流去找密钥来源,才是效率最高的路径。

1.1 入口界面与组件导出情况决定查找方向

用jadx打开模拟项目X.apk后,我第一眼看的是AndroidManifest.xml,而不是直接打开MainActivity。原因很简单:CTF题目的入口不一定是你表面上看到的那个Activity。有些样本会把flag校验逻辑放在一个Service或者BroadcastReceiver里,而manifest里exported="true"的组件就是外界可以直接触发的入口。

我需要确认三件事:

  1. 包名和版本信息,方便后续搜索和记录。
  2. application节点下注册了哪些Activity、Service、Receiver。
  3. 入口Activity的launchMode和exported状态。

很多新手会忽略这些信息,直接冲进MainActivity的onCreate里读代码。但往往MainActivity只是一个壳,真正干活的是被它调用的某个工具类,或者隐藏在另一个Activity里的校验逻辑。花两分钟看一下入口,能避免后面反复跳转。

另外还要留意权限申请。虽然CTF样本不太会真的把flag发到远端,但如果一个练习app申请了INTERNET权限而界面又没有任何网络功能,那里面大概率藏着点东西,可能是从远程拉取密钥片段,也可能只是制题人留下的陷阱。

1.2 安装包结构里最值得先看的两个位置:assets与lib

确定入口之后,我习惯先把jadx左侧的资源目录展开,重点看两个地方。

第一个是assets目录。这个目录下的文件不经过编译期资源校验,开发者可以任意放文本、二进制、配置文件,修改起来也方便,所以制题人特别喜欢把密文、密钥片段、所谓的“配置文件”藏在这里。不要凭文件名判断它是干什么的,config.json可能只是普通文本,flag.dat里面可能躺着真正的密文。

我记得有一次练习,样本的assets里只有两个文件:一个叫key_store.bin,一个叫readme.txt。readme.txt是正常说明,key_store.bin用记事本打开全是乱码,但用十六进制工具打开后,末尾一段字符串非常规律,明显是base64特征。那其实就是被隐藏的密钥片段之一。所以assets里的每个文件都要点开看一眼,最好再用十六进制工具确认,不要被“看起来是二进制”骗过。

第二个要留意的是lib目录。如果里面有.so文件,说明题目涉及native层逻辑,此时先记下so文件名和对应ABI目录,后面分析JNI函数时要用。如果整个安装包里没有任何so文件,那key的搜索范围就基本被压缩到Java层和资源层,问题会简单很多。很多人在这一步完全忽略so文件的存在,导致后面在Java层翻来翻去找不到key,其实密钥早就被藏在C/C++代码里了。

2. 秘钥的四种常见藏身处和对应查找手段

静态侦察做完后,心里要有一张“藏key位置地图”。Android样本里的密钥虽然可以玩出花来,但归纳到最后不过是几个固定位置。我整理了四种最常见的藏匿方式,按排查优先级排列如下:

藏匿位置典型特征主要查找手段
Java层常量变量名带key/secret,或混淆后没有名字的字符串jadx全局搜索关键词,查看加解密调用点附近的常量
资源文件strings.xml里的字符串、raw资源、assets数据文件逐个打开资源文件,用strings命令和十六进制工具检查
Smali拼接逻辑字段名被混淆成a/b/c,多个const-string和StringBuilder切到smali视图,搜索const-string与append调用
native层Java类中有native方法声明,lib目录下存在so用Ghidra/IDA查看JNI导出函数,strings命令扫so

这张表的价值在于,它告诉我一个道理:密钥不是自己出现的,而是某个函数从某处“取回来”的。顺着这个思路,搜索时就不会盲目。

2.1 XML与Java常量层:最直接但也最容易想当然的硬编码

最理想的情况当然是在Java代码里看到一个private static final String AES_KEY = "........",但现代CTF题目不会这么直接。更常见的是把一个看起来无害的字符串放在strings.xml里,或者在类字段里存一段十六进制字符,然后在某个方法里用它来构造密钥。

全局搜索时,我会用secret|key|flag|cipher|encode|decode|AES|DES|RSA这组关键词先扫一遍。但不是所有带key字样的变量都是密钥,有些是IV、salt,甚至是被加密后的密文。关键要看它有没有出现在加解密函数的参数位置上。判断核心很简单:谁被当成算法参数传进去,谁才是真正的密钥。

有一个很容易栽的坑是:变量名被混淆器改成a.b.c之后,表面完全看不出用途。这时候关键词搜索就没用了,必须依靠“算法调用点”来反向定位。先找到Cipher.getInstance、SecretKeySpec、AESUtil.decrypt这类调用,然后看它们的参数是从哪里来的,逐步向上追。

2.2 Smali字面量与字符串拼接:混淆掩盖不了密钥必须落在寄存器的事实

jadx会把smali反编译成Java伪代码,但遇到经过花指令或字符串拆分的代码时,Java视图往往会丢信息。我遇到过不止一次这样的情况:jadx的Java视图里一个方法只有一行注释,或者显示空方法体,但切到smali视图后,发现里面明明有复杂的字符串拼接逻辑。

常见的手法是把密钥拆成十几段,每段一个或两个字符,分散在const-string指令里,运行时用StringBuilder拼接。也有的会倒序拼接,先append后调用reverse。如果只盯着Java伪代码,看到的是new StringBuilder().append(...).reverse().toString()这种调用,很容易忽略其中藏着的key片段。

所以在看关键方法时,我会直接切到smali视图,搜索const-string指令,然后按寄存器编号顺序读一遍。字符串拼接的smali代码非常直白:每个const-string v0, "xxx"就是一段数据,每次invoke-virtual ... StringBuilder->append(...)就是一次连接。读这些指令,配合jadx的Java视图,基本能确定运行时生成的密钥内容。

2.3 native层与加密文件:静态分析的深水区

如果Java层和资源文件都没有明显的key,那就要准备进入native层了。先回到Java代码里找有没有System.loadLibrary("xxx")和native方法声明。一旦有,就用strings命令扫描对应so文件,搜索Java_前缀的导出函数名,再用Ghidra或IDA跟进这些函数。

native层的字符串并不总是以明文形式躺在文件的只读数据段里。有些样本会对字符串做逐字节异或,比如对每个字节异或0x5A,再存到数组中。这时候用strings看到的就不是可读文本,而是一堆乱码。不要急着认定“so里没有key”,要去看导出函数的反汇编,找其中有几个循环、哪些数组被参与运算,往往就能还原出真实的字符串。

assets里的数据文件同理。它可能是密文、密钥片段、也可能是二者的混合体。判断标准还是看Java层代码怎么读取它。如果代码里只是readLine后截取某些字段,那它多半是配置数据;如果是直接readFully然后丢给解密函数,那它就是密文或者密钥原材料。

3. 一个完整的找key追踪:从按钮点击到还原AES密钥

理论说再多,不如跟着一个具体样本走一遍。这里我用一个专门用于练习的CTF样本,称为模拟项目X.apk。它的界面很简单:一个输入框、一个“验证”按钮,随便输入一段内容,点击后提示“验证失败”。目标是找到并输入正确的flag。

整个追踪过程我拆成了三段,正好对应“定位入口→发现密钥片段→动态验证”。

3.1 从按钮点击事件追踪到校验函数的调用链

用jadx打开这个样本后,入口是com.ctf.practice.sample.MainActivity。在onCreate里找到按钮的点击监听器,核心逻辑只有几行:

public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { // ... this.btnCheck.setOnClickListener(v -> { String input = this.editText.getText().toString(); if (CryptoBox.check(input)) { Toast.makeText(this, "success", 0).show(); } else { Toast.makeText(this, "failed", 0).show(); } }); } }

得到CryptoBox.check这个关键调用后,马上跳到CryptoBox类查看实现:

public class CryptoBox { public static boolean check(String input) { byte[] cipherBytes = AssetReader.read("flag.dat"); byte[] plainBytes = AESUtil.decrypt(cipherBytes, buildKey(), "AES/ECB/PKCS5Padding"); return new String(plainBytes, StandardCharsets.UTF_8).trim().equals(input); } private static String buildKey() { String p1 = getResKey(); // 来自 strings.xml String p2 = readConfigPart(); // 来自 assets/config.ini String p3 = NativeLib.getKeyPart(3); return p1 + p2 + p3; } }

这里已经能看出几个重要信息:

  • 密文在assets/flag.dat中。
  • 密钥不是单一来源,而是由三个部分拼接而成。
  • 算法是AES的ECB模式,填充方式是PKCS5Padding。
  • 其中一部分来自native层,也就是说必须去分析so文件。

buildKey就是我们寻找秘钥的主战场。接下来要做的是分别追查getResKey、readConfigPart、NativeLib.getKeyPart这三个方法。

3.2 在资源、配置文件与so中分别挖出密钥片段

先用jadx看getResKey的实现,发现它返回的是R.string.config_key这个字符串资源。去strings.xml里搜config_key,得到:

<string name="config_key">8b3f1c7a</string>

这是一段8位十六进制字符串,长度为4字节。第一段密钥片段到手。

接着看readConfigPart。它的逻辑是读取assets/config.ini,找到key_store=这一行,截取等号后面的值:

[Main] key_store=9e5d2f6b4a13c7d0

第二段密钥片段是9e5d2f6b4a13c7d0,这是16位十六进制字符串,8字节。

最后是NativeLib.getKeyPart(3)。Java层的native方法声明如下:

public class NativeLib { public static native String getKeyPart(int index); }

对应的so文件是lib/arm64-v8a/libnative-crack.so。用Ghidra打开,找到导出函数Java_com_ctf_practice_sample_core_NativeLib_getKeyPart。反汇编里并没有一个直接的字符串常量,而是一段循环:从一个byte数组中逐字节异或后拼成字符串。手动追踪后得到ef96b81c,第三段密钥片段入手。

这里一定要提醒一句:拼接顺序不能只看Java伪代码。有些样本的buildKey方法在smali层会被重排,Java伪代码里的顺序未必是真实执行顺序。所以我又回到jadx的smali视图,确认getResKey、readConfigPart、getKeyPart三者的调用顺序确实是p1+p2+p3,没有倒序或穿插。

最终密钥为:

8b3f1c7a9e5d2f6b4a13c7d0ef96b81c

32位十六进制字符串,正好是AES-128的16字节密钥。

3.3 动态确认密钥无误,再进入解密脚本环节

静态分析得出的密钥不一定可靠,因为代码里可能还有其他变换没有暴露在伪代码中。我习惯用frida做一次动态验证,直接把真正运行时传入解密函数的key打印出来。这次用的frida脚本很简短:

Java.perform(function () { var CryptoBox = Java.use("com.ctf.practice.sample.core.CryptoBox"); CryptoBox.buildKey.implementation = function () { var k = this.buildKey(); console.log("[buildKey] -> " + k); return k; }; var AESUtil = Java.use("com.ctf.practice.sample.core.AESUtil"); AESUtil.decrypt.implementation = function (data, key, mode) { console.log("[AES data ] " + bytesToHex(data)); console.log("[AES key ] " + bytesToHex(key)); var result = this.decrypt(data, key, mode); console.log("[AES out ] " + bytesToHex(result)); return result; }; });

跑起来后,控制台输出的key和静态分析得到的8b3f1c7a9e5d2f6b4a13c7d0ef96b81c完全一致。此时可以放心进入解密阶段。

用Python写一个AES解密脚本,读取flag.dat并解密:

from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 key = bytes.fromhex("8b3f1c7a9e5d2f6b4a13c7d0ef96b81c") cipher = base64.b64decode(open("flag.dat", "rb").read().strip()) plain = unpad(AES.new(key, AES.MODE_ECB).decrypt(cipher), AES.block_size) print(plain.decode())

输出的明文是:

flag{follow_the_data_flow_to_find_key}

到这里,一次从按钮点击事件开始,最终还原出flag的完整流程就走通了。但我觉得这个样本最有价值的部分反而不是“找到了key”,而是它把密钥拆成三段、分别藏在资源、配置文件、native层的设计,恰好覆盖了前面说的所有常见藏匿位置。

4. 找到flag不是终点:解密验证与严谨输出

很多人跑到上一步就停手了,因为界面已经提示success,目标达成。但实际上,解密验证这一步才是最容易出小状况的地方。我见过不少人在一个“看上去正确的密钥”上反复折腾,最后才发现是填充、编码或拼接顺序的问题。

4.1 解密脚本要处理的填充、编码与换行陷阱

先说填充。Java代码里写AES/ECB/PKCS5Padding,在Python里对应的就是AES.MODE_ECB加Crypto.Util.Padding.unpad。如果你把模式写成CBC,或者没处理填充直接解密,轻则末尾多出一截不可见字符,重则直接报Padding is incorrect。

CBC模式下还要特别留意IV从哪里来。很多样本会在Java代码里把IV一起读出来,有可能藏在资源字符串、WebView的UA字段甚至系统属性里。如果是ECB模式则不需要IV,省事很多。

再说Base64。标准Base64和解码器处理不了某些变体。如果base64.b64decode抛异常,优先尝试base64.urlsafe_b64decode,再检查字符串末尾的=是否被去掉。有些样本会把Base64字符串写成带换行的格式,直接用read().strip()删除首尾空白即可。

最后是明文的可视化。如果解密出来的内容不是直接可读的ASCII,别急着认为密钥不对,先用十六进制输出看一眼。有可能明文是一个zip文件、一张图片或者一段gzip压缩流,这时候找flag的重点就转向“识别文件类型”而不是“盯着一堆乱码猜”。

错误现象可能原因处理建议
解密产物全是乱码算法模式或填充方式不匹配核对Cipher.getInstance参数,区分ECB/CBC与PKCS5/NoPadding
解密报padding异常密文被错误解码,或填充没有被去除尝试Base64变体,脚本里正确调用unpad
key看起来正确但解不出拼接顺序或编码方式搞错回到smali确认append顺序和hex/ASCII转换方式
明文末尾有杂字符填充字节或换行符没清理输出前rstrip不可见字符,再做trim比较

4.2 把找key思路沉淀成一套常规检查单

练多了之后,我总结了一套固定流程,每次遇到新的找flag样本都按这个顺序走,基本能稳定输出结果:

  1. 确定校验入口,无论入口在Activity还是Service,先找出最终参与比较的函数。
  2. 在入口函数中搜索加解密操作,重点看Cipher.getInstance、SecretKeySpec、Base64这些调用。
  3. 列出所有被引用到的密钥来源,包括字符串资源、assets文件、native方法。
  4. 在smali层确认密钥拼接顺序,不能只看Java伪代码。
  5. 用frida动态打印运行时实际使用的key和密文。
  6. 写脚本前明确算法模式、填充方式、编码格式。
  7. 解密后先看十六进制,再判断输出内容是纯文本还是文件流。

这七条不是严格的算法,而是排除法。每次卡住,回到“这个数据是谁产生的、谁在消费它”这两个问题上,基本都能找到下一步。

5. 我在这类CTF样本上反复踩过的几个坑和最终心得

最后一章不写教程了,聊聊我在实际练习中翻过的车。这些东西在常规文档里很少被提到,但对效率的影响非常大。

5.1 全局搜字符串反而什么都搜不到

第一次接触找flag样本时,我的第一反应就是全局搜索“key”。结果那个样本里光“key”这个词就出现了几十次,遍布各种无关类。真正有用的密钥藏在native方法里,而且是通过两个byte数组动态运算出来的,根本没有可搜索的明文字符串。那次浪费了将近一下午。

后来我学乖了:搜索只是辅助手段,切入点永远是“算法调用点”。先找Cipher.getInstance或AESUtil.decrypt这类和加密强相关的函数,再顺着参数向上追,比漫无目的搜字符串高效得多。

另外一个教训是:不要把变量名当成判断依据。一个叫key的字符串可能是密文,一个叫data的字符串反而可能是密钥。只有看它最终被哪个函数消费,才能确定它的真实身份。

5.2 工具视角的偏差:jadx显示不全与so乱码

jadx在解析某些混淆逻辑时,Java视图会显示空方法体或报错,但这不代表这个方法没有内容。切到smali视图后,逻辑往往是完整的。所以当你在Java层看不到关键逻辑时,先别急着换工具,试试smali模式。

so文件里的字符串乱码也骗过我一次。用strings扫描某个so文件,看到的全是不可读的字节序列,当时以为制题人做了加密,差点放弃。后来用Ghidra打开后发现,那段“乱码”其实是一段经过逐字节异或的字符串常量,循环还原后就是密钥。遇到so里的非可读字符串,正确做法是找引用它的汇编代码,而不是直接判断“没有字符串”。

还有一次,我用记事本直接打开一个assets下的配置文件,看到一堆乱码就觉得文件损坏了。后来用十六进制工具打开才发现,它根本不是文本文件,而是一段hex编码的密钥材料。从那以后,凡是assets里的陌生文件,我都默认先用十六进制工具检查,而不是用文本编辑器硬读。

做了这么多找flag实例之后,我最大的体会是:寻找秘钥并不是碰运气式的盲搜,而是一种带着数据流思维的追踪过程。密钥一定会在某个地方被读取、被拼接、被传给算法函数,你要做的只是顺着程序逻辑找到那个点。每次卡住,回到“是谁在用它、它从哪来”这两个问题上,基本都能重新走通。这个思路不只适用于CTF,做自己应用的加固审计、排查第三方SDK的加密逻辑时也一样好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询