ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CTF Android逆向:静态分析定位XOR秘钥的完整流程

CTF Android逆向:静态分析定位XOR秘钥的完整流程 打CTF的朋友应该都有同感Android逆向题里十道有八道最后都在找flag而这个flag本质上就是藏在应用里的某个秘钥。这个系列写到第十篇正好用一道典型题目把“找秘钥”的完整流程走一遍从静态反编译、定位校验函数到逆推加密算法再到最后的动态确认。适合已经会用jadx或JEB做基础反编译、但还没完整独立解出过一道题的朋友作为进阶练手。我会把整个思路拆开讲也会把实际操作中容易踩的坑一并写出来方便你照着复现。1. 从CTF找flag到Android逆向这类题目到底在考什么1.1 一个“找flag”题目背后的真实意图CTF里的逆向题规则其实特别简单给你一个APKAPK里藏着一段固定字符串一般是flag{...}这种格式你把它找出来提交就得分。听起来像寻宝游戏但实际上出题人不会让你轻松搜到明文他会用各种方式把这个“秘钥”藏起来藏在资源文件里、藏在加密后的字节数组里、藏在so动态库里甚至藏在远程接口返回的数据里。你这个“逆向者”要做的事就是反过来把这段秘钥从代码逻辑里“抠”出来。这一点特别像真实开发里的密钥管理问题——很多App会把密钥硬编码在客户端攻击者只要反编译就能拿到。所以练好这套找flag的本事不光是打比赛对做移动安全加固、评估自家App的密钥泄露风险同样有直接帮助。很多新手拿到题目就开始翻来翻去像无头苍蝇一样找字符串这是最没效率的。先想清楚一个核心问题程序里一定存在一个“校验点”也就是把用户输入或者某个数据跟真正的flag做比较的地方。只要你找到了这个比较逻辑就等于找到了秘钥的藏身处。所以整道题的解题锚点始终是“找比较逻辑”而不是漫无目的地搜字符串。1.2 秘钥在真实App里一般藏在哪从我在实际题目和真实App里见过的情况看秘钥的藏身位置大概有这么几类按难度排个序明文直接写在Java代码或XML里一搜就出属于送分题。藏在SharedPreferences、数据库或者本地日志里需要模拟登录或触发某个逻辑才能看到。做了简单编码比如Base64、Hex肉眼看上去不是明文解码即得。做了对称加密或异或处理秘钥本身是硬编码的字节数组需要还原算法。藏在native层的so库里通过JNI调用返回需要动IDA或Ghidra。从服务端接口动态下发客户端本地没有秘钥这种最难往往还要配合抓包。理解这个分布非常重要。如果你面对一道题第一件事就是判断它属于哪一层才能选择合适的工具和思路。后面我写的实例会重点落在“硬编码字节数组异或运算”这一层这是目前CTF里出现频率最高、也最适合练基本功的模型。2. 静态分析前的准备与工具选型2.1 我常用的工具组合工欲善其事必先利其器。我的标配大概是这样工具用途什么时候用jadx将DEX反编译成可读的Java代码支持全局搜索首选打开APK第一件事JEB更强的反编译与调试能力对付复杂混淆jadx看不动或者需要调试时APKTool解包资源、查看AndroidManifest、Smali修改需要看资源文件或改包重打包时Ghidra / IDA分析so库遇到native层算法时Python脚本辅助还原加密算法任何需要跑一遍算法的时候真机或模拟器动态验证最后确认flag、观察交互逻辑jadx是我用得最多的它把APK拖进去就能自动反编译左边项目结构、右边代码还有全局搜索框。对于大多数CTF题有jadx就够了。其他工具不要急着装一堆先用最简单的路径试不行再升级。2.2 为什么先做静态分析而不是直接上动态调试很多新手一上来就想动态调试结果被各种检测搞得头大。我的习惯是绝大多数情况下先做静态分析原因有二。第一静态分析没有运行环境限制。你不需要装模拟器、不需要处理签名校验、不需要担心代码被“反调试”干扰直接看代码本身。对于CTF里的大多数题目flag就藏在代码逻辑里静态完全能解决。第二静态分析能帮你建立全局地图。你先把整个工程的类结构、入口、关键方法浏览一遍知道这个App大概是干嘛的再往下钻定位会快得多。反过来一上来就动态调试往往连从哪下断点都不知道。打个比方找flag就像在一栋楼里找一间房间。静态分析是看整栋楼的结构图纸动态调试是挨个房间用听诊器听声音。图纸能直接标出房间位置何必用笨办法。等你从图纸上锁定了大概区域再用动态调试做精确验证这样效率最高。3. 核心实操从APK中定位flag藏身之处3.1 反编译后第一步全局搜索flag特征字符串我之前拿一道典型的训练题findflag.apk来演示。这个APK安装后打开就是一个输入框和“校验”按钮你输入字符串点击校验它会告诉你对不对。把APK拖进jadx等它解析完。拿到这种“输入校验”型的题目我一般先在搜索框里搜flag然后是check、Verify、Key、Secret这些关键词。正常情况下会看到类似这样的东西public class MainActivity extends AppCompatActivity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); } private void onCheckClick(String input) { if (CheckUtil.check(input)) { Toast.makeText(this, 恭喜你flag正确, Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, 再试一次, Toast.LENGTH_SHORT).show(); } } }看到CheckUtil.check(input)这一行核心目标就出现了CheckUtil这个类里的check方法就是整个App的校验入口。这里有个经验如果搜flag搜不到不要慌后面我会专门说搜不到字符串的几种原因和处理办法。但只要有“比对”这个动作就一定能顺藤摸瓜找到秘钥。3.2 定位到校验函数后拆解XOR加密逻辑直接跟进CheckUtil看代码我看到的逻辑大概是这样public class CheckUtil { private static final byte[] ENC_DATA new byte[]{ 0x25, 0x38, 0x27, 0x24, 0x2F, 0x1E, 0x73, 0x26, 0x19, 0x72, 0x27, 0x19, 0x10, 0x64, 0x19, 0x06, 0x35, 0x35, 0x3A, 0x29 }; private static final String XOR_KEY CTF; public static boolean check(String input) { String flag decode(); return flag.equals(input); } private static String decode() { byte[] keyBytes XOR_KEY.getBytes(); byte[] result new byte[ENC_DATA.length]; for (int i 0; i ENC_DATA.length; i) { result[i] (byte) (ENC_DATA[i] ^ keyBytes[i % keyBytes.length]); } return new String(result); } }这里就有意思了。check函数做的事情是把decode()的结果和用户输入做比较。也就是说decode()还原出来的字符串就是flag就是那个藏在APK里的秘钥。现在的问题变成了decode()输出了什么看一下逻辑它把ENC_DATA这个字节数组跟XOR_KEY字符串“CTF”的字节值按位异或。异或运算有个很好的性质a ^ b c那么a c ^ b。现在ENC_DATA就是cXOR_KEY就是b我们只需要逐字节异或回去就能得到原字符串a。这个操作用脚本跑结果最直观具体我在下一节演示。3.3 用Python脚本还原秘钥的完整过程既然算法已经看懂就别在脑子里硬算了。我通常直接写一段Python脚本把密文、密钥、算法都原样搬进去跑一遍出结果。脚本写出来跟下面差不多enc [ 0x25, 0x38, 0x27, 0x24, 0x2F, 0x1E, 0x73, 0x26, 0x19, 0x72, 0x27, 0x19, 0x10, 0x64, 0x19, 0x06, 0x35, 0x35, 0x3A, 0x29 ] key bCTF flag bytes(enc[i] ^ key[i % len(key)] for i in range(len(enc))) print(flag.decode())跑完之后输出是flag{X0r_1s_S0_Easy}这就是藏在APK里的秘钥也就是这道题的flag。这里有两个小经验想分享。第一写还原脚本的时候最好把密文数组、密钥、算法参数都参数化用变量保存不要写死在print里。因为实际逆向中你可能要反复调参比如密钥长度不确定、算法里还有偏移值参数化之后改起来非常方便。第二不要因为结果简单就跳过代码理解这一步。你能跑出flag是因为你已经看懂了它原来的校验逻辑而不是靠运气把字节刷出来。这中间的差距就是“会做题”和“真懂逆向”的区别。3.4 动态确认flag输入框验证和调试器调用脚本跑出了flag但这道题还没算完全结束。我们最好在模拟器或真机上动态确认一下避免出现那种“脚本结果看着对但程序实际校验另有玄机”的情况。最简单的确认方式把跑出来的flag{X0r_1s_S0_Easy}输入到APK的输入框里点击校验看到“恭喜你flag正确”的提示一遍过。这一步相当于把这个秘钥放到真实环境里过了一遍比较逻辑是最直接的验证。如果不想走输入框也可以在jadx里打断点让程序运行到CheckUtil.check之前直接在调试器里调用CheckUtil.decode()检查返回的字符串是不是同一个。这种方式更贴近真实逆向工作中的动态验证思路。我个人的习惯是两种情况都试一下因为有些题目会在输入之后再做一次trim或者大小写转换你只盯着还原算法可能会漏掉这些细节。4. 进阶当秘钥被藏进native层之后怎么挖4.1 为什么越来越多的出题人把秘钥放进so库Java层的代码再怎么混淆最终都会被jadx还原成有一定可读性的Java代码对出题人来说还是不够“安全”。所以现在越来越多的CTF题会把关键的秘钥生成逻辑放进native层的.so库里通过JNI提供给Java层调用。为什么要这样做一方面SO文件是编译后的机器码不像DEX那样有清晰的类和方法结构还原难度明显高另一方面真实的商业App也大量使用这种方案来保护核心密钥和算法所以这道题学到的思路在实战中同样用得上。遇到这类题目前面的思路依然通用先从Java层找到调用native方法的入口然后定位到具体的.so库再进库里逆算法。整个链路就是“Java方法声明 - loadLibrary - native导出函数 - 逆向还原”。4.2 定位JNI函数和导出符号的思路假设你在jadx里看到了这样一段代码public class NativeHelper { static { System.loadLibrary(nativecheck); } public static native String getKey(); }然后在某个check方法里调用了NativeHelper.getKey()作为比对基准。那我们需要分析的就变成了libnativecheck.so。先把APK解包用APKTool或直接改后缀解压都行到lib/目录下找到对应架构的.so文件。用Ghidra或IDA加载它先看导出表。JNI函数通常有一个明显的命名规则一般是Java_包名_类名_方法名所以很容易定位到入口函数比如Java_com_example_NativeHelper_getKey。找到入口之后就能开始逆向了。这里有个技巧JNI函数的前两个参数是JNIEnv*和jobject它们是固定的“环境参数”对算法本身没有贡献分析时可以把关注点放在这两个参数之后的实际操作上。4.3 用Ghidra静态还原算法时的几个观察点在Ghidra里打开so文件找到带Java_前缀的导出函数之后我一般会按这几步来做第一先看函数里有没有明显的字符串。虽然出题人可能会把flag字符串做编码混淆但也有很多题只是把字符串藏在so里直接能在字符串列表里看到flag{...}。第二找循环和异或特征。几乎所有的对称加密、简单编码在汇编层面都会体现为“循环异或/加法查表”这种结构。看到一堆XOR指令和一个循环变量基本可以断定这里有一个还原算法跟你在Java层看到的XOR逻辑是一回事。第三注意有没有查表操作。Base64就是一种典型的查表编码如果so里出现一个64字节的常量表并且代码里有charAt(table[i])类似的索引动作那大概率就是Base64直接把表导出来解码即可。第四如果静态分析太绕可以在自己的测试环境里动态调一下。在CTF训练样本上调用NativeHelper.getKey()观察它的返回结果配合Hook工具直接拿到输出再去反推算法。注意这种动态方式在正规CTF赛题范围内完全没问题也是很多选手在比赛中会用到的常规手段。5. 常见问题与排查技巧实录5.1 反编译出来全是乱码、类名被混淆怎么办很多题目为了提高难度会给Java代码做混淆类名变成a.b.c方法名变成a()、b()一开始看确实头大。我的经验是不要试图通过名字去找目标而是通过“调用关系”找目标。既然校验动作在Java层必然存在而且必然会被某个入口调用那么你可以从启动流程往下追MainActivity.onCreate里注册的点击事件点击事件里调用了什么一层层追下去总能找到校验逻辑。混淆只改了名字改不了代码的执行顺序和逻辑结构。另一个常见情况是字符串整体被拆成了字符数组或者用StringBuilder一段段拼接。搜索flag搜不到是正常的。遇到这种还是回到“找比较逻辑”的老路子上找到比较点再看它跟谁比、怎么比。5.2 搜不到flag字符串的几种可能性如果在jadx里搜flag、{这些关键词一无所获按照我踩过的坑原因基本在这几类现象可能原因排查方向Java层完全搜不到字符串被拆分或编码盯着校验点看别追字符串代码里有一大段Base64常量flag被Base64后硬编码直接解码试试字节数组或者大量数字常量用XOR、AES等方式加密找密钥和算法还原assets目录里有多余文件flag藏在资源文件里用APKTool解包看assets有so库存在flag可能由native生成去so里找导出函数和算法很多时候搜索不到不是题难而是你搜索的方向不对。比如有的出题人会把flag做主处理之后拆成几段再通过字符串拼接组合起来。这种就一定不能直接搜flag{要找到Final拼接的调用点才能在运行时拿到完整结果。5.3 本地跑通脚本但flag验证还是失败这种情况我也遇到过好多次明明脚本里还原出的字符串看起来合理但提交就是不对。排查顺序给我自己的建议是先检查多字节字符问题。比如有的flag里包含非ASCII字符或者解码出来的字符串带了额外换行符、空格。用repr()输出看看是不是有不可见字符别用直接print的结果。然后检查算法是否完整还原。出题人可能不只做了一层XOR比如先Base64再XOR你只解了最后一层结果自然不对。这时候回到代码里把decode()整个流程跟完看有没有多一步位移、多一次查表。最后如果算法看起来完全对但结果仍然不对试试大小写和格式。CTF的flag通常大小写敏感且必须包含flag{}外壳但有些题会拿掉外壳或者要求你写flag{}之外的内容。具体看题目标题和要求。实在不行动态调试点断在比较函数处直接看它运行时拿到的比较基准值是什么这是最不会骗人的结果。5.4 常见问题速查表问题快捷解决思路jadx解析卡住APK太大或加了壳先尝试JEB或者在线反编译服务或先解包看结构类名全是a、b、c放弃名字语义按调用关系追踪必要时动态调试Java层找不到关键字符串转向assets、so、native方法、资源文件还原结果像乱码多半还差一层解码检查Base64、Hex或UTF-16在真机验证提示flag错误检查输入框有没有自动trim确认是否有隐藏前后空格so库分析太复杂先用动态Hook确认输出再用Ghidra对照静态逻辑这张表是我自己排查时用的快捷键。当然真正的现场总会冒出一些新问题但只要你始终记得“找比较点、还原比较基准、动态验证”这条主链路基本不会卡太久。6. 写在最后的实操体会这道题做完之后我个人的体会是Android逆向找flag这件事本质上不是在跟代码较劲而是在跟出题人的思路较劲。出题人把秘钥藏在算法里你把它还原出来整个过程跟安全评估中审计客户端密钥保护能力非常相似。所以每解一道题建议你都反过来想想“如果我来写这个校验逻辑我会怎么藏”这样比单纯刷题收获大得多。再分享一个我自己的小习惯每次解完一道题我会把APK、反编译出的关键代码、还原脚本整理到一个按题目名的文件夹里同时把解题思路写成一个十几行的markdown笔记记录搜索关键词、校验点位置、还原算法、踩过的坑。时间久了翻回去看这些笔记比任何教程都有用因为它们是带着你的现场思考过程的。最后想说找秘钥这个技能并不高深多做几道题、多拆几个真实的App样本很快就能形成肌肉记忆。还是那句话先静态定位、再还原算法、最后动态验证——把这条链路走顺大部分CTF逆向题就都难不住你了。
返回列表