再谈如何保护 App 不被破解

  • A+
所属分类:移动安全

对于个人开发者或者某些小企业开发者而言,App 被破解始终是一件让人非常难受的事情;我曾经也因为轻视了保护 App 的重要性而吃到了苦头。近来时常听到传闻某些 App 被破解而产生了一些很不愉快的事情,其实我曾经在 从“去除签名验证”说起 谈论过 App 保护的问题,今天我将从一个框架开发者(专业破解者?)的角度来进一步聊聊如何保护 App。


在继续讨论之前,我们必须承认:没有绝对的安全。如果对于一个攻击者来说,攻破你的系统所需要花费的成本比攻破之后获取的收益要高很多很多,那么就可以认为你的系统是相对安全的。具体来说,如果一个 Cracker 破解你的软件之后收益(如直接经济利益,个人满足感等等)比它破解所需要花费的时间和精力小很多,那么你的软件就是安全的。


一般来说,破解一个 App 通常的方式有两种:


  1. 直接修改 App 的安装包里面,通过静态插桩的方式修改代码,修改之后重新打包然后签名。这种方式的原理与太极·阴相同。

  2. 不直接修改 App 的安装包,而是在 App 运行起来之后,动态修改 App 的目标进程内存,从而修改应用的逻辑。这种方式的原理与一般的游戏修改器、Xposed 框架类似。


对于方法一来说,它的重点在于重新打包之后一定需要重新签名,因此攻防双方的焦点在于如何破解/保护签名。对于方法二来说,其重点在于进程中会加载不明来源的目标代码。


在 从“去除签名验证”说起 中我们谈论过签名相关的原理以及一些防护办法,那么,对于一个破解者来说,如何去绕过签名检测呢?或者换个方式说,如何保护签名?


获取应用签名最基础的方法是通过 PackageManager 的  getPackageInfo 方法返回的 PackageInfo.signatures / signinginfo。因此最简单的防护方式是通过此 API 在运行时获取自己应用的签名然后与自己的固有签名做比对,如果不一致则代表应用被修改。不过这种验证方式在当下很容易被绕过,比如 VirtualXposed 可以通过动态代理 package 这个 binder service,某些工具甚至提供了一件破除签名的功能。


既然系统的 API 已经被玩坏了,那么我们得找找别的办法。本质上讲,签名信息是存在于应用的安装包中的,系统的签名 API 不过是系统帮我们完成了签名信息解析的过程。我们可以自己去解析这个签名信息,从而自己去验证签名。简单来说,就是直接读取应用自己的安装包文件,通过签名的格式(V1/V2/V3) 去解析签名信息,然后计算签名。实际上很多应用,包括常见的 QQ 都用了这种方式做签名验证。不过很可惜,这种方式也有办法去破解和绕过。这个过程的重点在于,我们需要把安装包读取并解析,如果这个过程被干预,后续的信息就不可靠。比如,如果我们从 ApplicationInfo.sourceDir 去获取安装包路径,那破解者可以修改这个变量让指向一个破解前的安装包路径,这样你如何验证都是正确的。即使我们获取的安装包路径是对的,别人也可以干预「读」这个过程,偷梁换柱地让你以为自己在读正确的路径,实际上读到了被改过的内容。具体来说,可以拦截 openat 这个系统调用。


Java 层的 File 类 API 或者 NDK 中提供的文件接口,实际上最终用了 bionic libc 中的 open 函数,这个函数是系统调用 openat 的简单封装。如果我们用这些接口去读文件,那么只需要拦截 libc 中的 open 方法,就可以轻松破解。


既然这些接口都不靠谱,那么我们可能需要自己用系统调用跟内核打交道来读文件。具体来说,我们可能需要根据构架的不同,用汇编的方式去实现 open 系统调用,然后用我们自己的汇编代码来读文件。当然这个过程你完全可以 copy & paste Android 源码中的实现。做到了这一步是不是就天衣无缝了呢?


答案是否定的,因为这个过程也可以被修改。比如说,arm 构架下,系统调用的实现依赖 svc #0 这个指令,那么,别人可以搜索整个内存,在发现 svc #0 并且系统调用号是 openat 之后直接给你修改掉。


为了应对这种直接搜索内存然后 patch 的方式,我们可能需要做一些额外的工作,让它们不那么轻易地搜索到我们。比如说,我们不要直接把汇编代码编译进程序当函数调用,而是采用动态 mmap 的方法,在运行时分配内存填入代码,然后调用;这样可能会让搜索指令不那么容易。另外,我们也可以启动 native 子进程,在子进程中执行这部分操作;因为通常别人可能关注不到这个过程。


另外,如果你注意到的话,不论是 patch libc 还是 syscall,破解者通常需要去修改进程的可执行内存(代码段)因此,还有一个很重要的办法,是对进程的代码段做 CRC 校验,确保你 App 运行中的代码与静态安装包中的代码是一致的。


说到这里,关于签名保护/破解的整个过程就结束了。需要说明的是,就算实现了 mmap 自定义汇编 syscall 读取验证签名,我们还是有一些额外的工作要做:


一定不要把签名验证放在孤立的业务之中。什么意思呢?打个比方,你费了九牛二虎之力写了一堆 native 代码做验证,然后在 App 启动阶段调用 loadLibrary 加载 so 执行验证,失败就退出。那么,别人只要把 loadLibrary 这一句删掉一切努力就徒劳了。因此,验证逻辑一定不能是孤立的,比如说,如果你是一个音乐应用,那么你的核心业务逻辑就是播放音乐,你可以在播放音乐的时候,顺便做一下验证;如果别人破坏你的验证逻辑,很可能音乐就无法播放了,这就失去了破解的意义。


验证逻辑可以分散在各核心业务。还是拿音乐 App 举例,我们可以在下载音乐,播放音乐,听歌识曲等各项业务路径中进行验证。具体的方法也很简单,用编译器的 inline 功能,直接把验证的方法内联到各个调用处;这样就算别人破解的成本会成倍增加。


验证失败之后灵活处理。某些 App 有非常简单粗暴的方法,就是发现验证不通过就直接退出。实际上这个过程本身就成为了一个攻击点。别人可以通过拦截 exit 系统调用,顺藤摸瓜找到你的关键验证点。因此在验证失败时,不要简单粗暴直接退出,最好的办法是对自身运行环境做破坏让系统运行一段时间之后崩溃。举个简单的例子,你可以把某个 Context 修改为 null,这样一段时间之后系统肯定自己挂了。另外,这种破坏行为,你还可以掷个骰子,一会儿破坏这里一会破坏那里,让破解者摸不着头脑。(注意掷骰子可不要用随机数 API 噢,那样也是很明显的特征,你可以找个变量的地址对它求余)


对你的代码做混淆。Java 层的代码可以使用 proguard 等工具,然后自定义混淆字典,native 层的代码务必使用 ollvm 混淆。虽然混淆对于专业破解者的作用并不强,但是记住:只要提升破解成本就提升了安全性。另外,不要过于相信加固,这里并不是说加固不靠谱,而是说,即使你采用了加固,也不要让你的代码裸奔。另外,一定要尽可能把 native 代码中的不必要的符号去除,一旦有了符号,破解的成本会急剧降低。


不知不觉已经写了好多了,看来关于动态修改 App 的保护方案要放到下一次再聊了。但愿本文能带来启发和帮助,大家晚安!

本文始发于微信公众号(虚拟框架):再谈如何保护 App 不被破解

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: