混淆加固技术的发展过程
从2012年开始,移动互联网进入快速发展阶段,带动了Android App的开发热潮,而这股热潮也推动了Android平台软件保护的发展。传统App加固技术,前后经历了四代技术变更,保护级别每一代都有所提升,但其固有的安全缺陷和兼容性问题始终未能得到解决。
第一代加固技术—动态加载
第一代Android加固技术用于保护应用的逻辑不被逆向与分析,最早普遍在恶意软件中使用,其主要基于Java虚拟机提供的动态加载技术。
第二代加固技术—不落地加载
相对第一代加固技术,第二代加固技术在APK修改方面已经完善,能做到对开发的零干扰。开发过程中不需要对应用做特殊处理,只需要在最终发布前进行保护即可。而为了实现这个零干扰的流程,Loader需要处理好Android的组件的生命周期。
第三代加固技术—指令抽离
由于第二代加固技术仅仅对文件级别进行加密,其带来的问题是内存中的Payload是连续的,可以被攻击者轻易获取。第三代加固技术对这部分进行了改进,将保护级别降到了函数级别。
第四代加固技术:指令转换/VMP
第三代加固技术在函数级别的保护,使用Android虚拟机内的解释器执行代码,带来可能被记录的缺陷,第四代加固技术使用自己的解释器来避免第三代的缺陷。而自定义的解释器无法对Android系统内的其他函数进行直接调用,必须使用JAVA的JNI接口进行调用。
常见的混淆加固技术甄别
代码混淆
在没有保护的情况下想要破解一个APP,或许你只需要使用apktool, dex2jar等工具,就可以清楚且完整的看到一款产品的原始代码逻辑,以及一定的逆向思维就可以搞定。而最先开始的也是最为常用的保护措施为代码保护。
谷歌自带的混淆器ProGuard为免费软件可以修改类名、方法名、字段名。
用处有两个,一个是符号混淆,把原本的XXXActivity改成a,让人不好猜到这个类的用处。另一个用处是压缩文件大小。
另外就是收费版DexGuard,混淆功能更加强大,比如支持字符串加密、花指令、资源加密等功能。发展到现在DexGuard的功能更加丰富,甚至还有一些运行时的防护,已经不是一个单纯的混淆器。
代码混淆替换类名:
Null混淆:
https://bbs.pediy.com/thread-247680.htm
Jadx打开效果图:
动态加载
将需要保护的代码单独编译出来,将其进行加密后在程序运行的过程中对其进行解密,并使用ClassLoader来动态的进行加载。这是PC上保护代码的常见套路,也是后来第一代壳的基础。
将加壳后的APK文件放到jadx中进行反编译,结果如下图所示,可以看到反编译后源程序APK的代码都被隐藏了起来,说明加壳是成功的。
Native代码
相对于java代码容易被反编译,使用NDK开发出来的原生C代码编译后生成的so库是一个二进制文件,这无疑增加了破解的难度。利用这个特性,可以将客户端敏感信息写在C代码中,增强应用的安全性。
对于Native的恶心流保护,最火也最有效的当属OLLVM了。至于其他Native保护有两种套路
1、破坏ELF文件结构,甚至定制linker加载自定义的SO。
2、加密代码段,运行时解密,也就是跟Dex动态加载类似。或者直接套用UPX之类的传统ELF壳。
至于其他 Native 保护,最开始基本上只有两种套路:
核心数据功能云端化
将重要的功能、数据,全部放到云端运算,客户端能只作展示用就只作展示用。嗯,这个勉强算是现在风控的基础。
资源文件保护
Android App的资源文件中存放了大量的应用UI界面图片、UI布局文件、隐私数据文件等,如何保障这些资源文件的安全性一直是开发者和应用安全人员重点关注的问题。在Android App中,资源主要分为assets资源和res资源两类。
1.assets文件夹是存放不进行编译加工的原生文件,即该文件夹里面的文件不会像xml,java文件被预编译,可以存放一些图片、html、js、css、证书等文件。
2.res资源则存放在App的res目录下,该类资源在App打包时大多会被编译,变成二进制文件,应用层代码通过resource id对该类资源进行访问。
参考Proguard Obfuscator方式,对APK中资源文件名使用简短无意义名称进行替换,给破解者制造困难,从而做到资源的相对安全;通过上面分析,我们可以看出通过修改AAPT在生成resources.arsc和*.ap_时把资源文件的名称进行替换,从而保护资源。 通过阅读AAPT编译资源的代码,我们发现修改AAPT在处理资源文件相关的源码是能够做到资源文件名的替换。
static status_t makeFileResources(Bundle* bundle, const sp<AaptAssets>& assets,
ResourceTable* table,
const sp<ResourceTypeSet>& set,
const char* resType)
{
String8 type8(resType);
String16 type16(resType);
bool hasErrors = false;
ResourceDirIterator it(set, String8(resType));
ssize_t res;
while ((res=it.next()) == NO_ERROR) {
if (bundle->getVerbose()) {
printf(” (new resource id %s from %s)\n”,
it.getBaseName().string(), it.getFile()->getPrintableSource().string());
}
String16 baseName(it.getBaseName());
const char16_t* str = baseName.string();
const char16_t* const end = str + baseName.size();
while (str < end) {
if (!((*str >= ‘a’ && *str <= ‘z’)
|| (*str >= ‘0’ && *str <= ‘9’)
|| *str == ‘_’ || *str == ‘.’)) {
fprintf(stderr, “%s: Invalid file name: must contain only [a-z0-9_.]\n”,
it.getPath().string());
hasErrors = true;
}
str++;
}
String8 resPath = it.getPath();
resPath.convertToResPath();
String8 obfuscationName;
String8 obfuscationPath = getObfuscationName(resPath, obfuscationName);
table->addEntry(SourcePos(it.getPath(), 0), String16(assets->getPackage()),
type16,
baseName, // String16(obfuscationName),
String16(obfuscationPath), // resPath
NULL,
&it.getParams());
assets->addResource(it.getLeafName(), obfuscationPath/*resPath*/, it.getFile(), type8);
}
return hasErrors ? UNKNOWN_ERROR : NO_ERROR;
}
上述代码是在ResourceTable和Assets中添加资源文件时, 对资源文件名称进行修改,这就能够做到资源文件名称的替换,这样通过使用修改过的AAPT编译资源并进行打包,下图是反编译后的截图:
反调试技术
反调试的目的是防止程序被第三方的调试器调式和分析。具体实施反调试的方法是:在程序启动过程中检查其是否被调试器附加、自身程序的父进程是否存在异常,以及进程列表中是否有正在运行的调试器等。
动态调试通过调试器来Hook软件、进而获取软件运行时的数据。可以在软件中添加检测调试器的代码,在检测到软件被调试器连接时中止软件的运行。
例如:
检测端口号,针对android_server这个端口号
void anti_serverport() {
const int bufsize=512;
char filename[bufsize];
char line[bufsize];
int pid =getpid();
sprintf(filename,”/proc/net/tcp”);
FILE* fd=fopen(filename,“r”);
if(fd!=NULL){
while(fgets(line,bufsize,fd)){
if (strncmp(line, “5D8A”, 4)==0){
int ret = kill(pid, SIGKILL);
}
}
}
fclose(fd);
}
检测调试进程的名字
void anti_processstatus(){
const int bufsize = 1024;
char filename[bufsize];
char line[bufsize];
char name[bufsize];
char nameline[bufsize];
int pid = getpid();
//先读取Tracepid的值
sprintf(filename, “/proc/%d/status”, pid);
FILE *fd=fopen(filename,“r”);
if(fd!=NULL){
while(fgets(line,bufsize,fd)){
if(strstr(line,“TracerPid”)!=NULL)
{
int statue =atoi(&line[10]);
if(statue!=0){
sprintf(name,”/proc/%d/cmdline”,statue);
FILE *fdname=fopen(name,“r”);
if(fdname!= NULL){
while(fgets(nameline,bufsize,fdname)){
if(strstr(nameline,“android_server”)!=NULL){
int ret=kill(pid,SIGKILL);
}
}
}
fclose(fdname);
}
}
}
}
fclose(fd);
}
运行环境检测
除了静态分析与动态调试,在分析软件时还可以使用动态分析技术,动态分析基于自定义的沙盒环境,通过HOOk系统中的所有关键API来输出程序运行时的动态信息。可以在运行时检测软件的运行环境,从而判断程序是否被第三方恶意使用或跟踪分析了。
模拟器检测
检测核心思想利用emulator和真机的区别。常用的实用方法包括TelephonyManager类、Build信息、特征文件、系统属性、基于差异化信息、基于硬件数据、基于应用层行为数据、基于cache行为和基于指令执行行为。
检测示例:
public static boolean isEmulatorAbsoluly() {
if (Build.PRODUCT.contains(“sdk”) ||
Build.PRODUCT.contains(“sdk_x86”) ||
Build.PRODUCT.contains(“sdk_google”) ||
Build.PRODUCT.contains(“Andy”) ||
Build.PRODUCT.contains(“Droid4X”) ||
Build.PRODUCT.contains(“nox”) ||
Build.PRODUCT.contains(“vbox86p”)) {
return true;
}
if (Build.MANUFACTURER.equals(“Genymotion”) ||
Build.MANUFACTURER.contains(“Andy”) ||
Build.MANUFACTURER.contains(“nox”) ||
Build.MANUFACTURER.contains(“TiantianVM”)) {
return true;
}
if (Build.BRAND.contains(“Andy”)) {
return true;
}
if (Build.DEVICE.contains(“Andy”) ||
Build.DEVICE.contains(“Droid4X”) ||
Build.DEVICE.contains(“nox”) ||
Build.DEVICE.contains(“vbox86p”)) {
return true;
}
if (Build.MODEL.contains(“Emulator”) ||
Build.MODEL.equals(“google_sdk”) ||
Build.MODEL.contains(“Droid4X”) ||
Build.MODEL.contains(“TiantianVM”) ||
Build.MODEL.contains(“Andy”) ||
Build.MODEL.equals(“Android SDK built for x86_64”) ||
Build.MODEL.equals(“Android SDK built for x86”)) {
return true;
}
if (Build.HARDWARE.equals(“vbox86”) ||
Build.HARDWARE.contains(“nox”) ||
Build.HARDWARE.contains(“ttVM_x86”)) {
return true;
}
if (Build.FINGERPRINT.contains(“generic/sdk/generic”) ||
Build.FINGERPRINT.contains(“generic_x86/sdk_x86/generic_x86”) ||
Build.FINGERPRINT.contains(“Andy”) ||
Build.FINGERPRINT.contains(“ttVM_Hdragon”) ||
Build.FINGERPRINT.contains(“generic/google_sdk/generic”) ||
Build.FINGERPRINT.contains(“vbox86p”) ||
Build.FINGERPRINT.contains(“generic/vbox86p/vbox86p”)) {
return true;
}
return false;
}
Root检测
仅具有常规功能的软件,其运行的环境可能不需要root权限。拥有root权限的设备,对系统有绝对的控制权,包括对APP中所有私有数据的访问及对系统API执行流程的篡改。对运行环境安全性要求较高的APP例如银行与金融理财类APP,如果运行在root后的设备上,意味着用户的财产安全将面临极高的风险,在这种情况下,应该检测系统是否运行与root后的设备上,并对特定的运行环境进行相应的安全处理。
检测原理:
1、已经root设备,会增加一些特殊包或文件,所以可以通过检测这些包(如Superuser.apk、检测su命令)、activity、文件是否存在来判断。
public static boolean checkSuperuserApk() {
try {
File file = new File(“/system/app/Superuser.apk”);
if (file.exists()) {
Log.i(LOG_TAG, “/system/app/Superuser.apk exist”);
return true;
}
} catch (Exception e) {
}
return false;
}
2、app检测是否可以执行在root下才能运行的命令。
3、检测busybox工具是否存在,关于busybox的知识google上一大堆,简单的说BusyBox 是很多标准Linux工具的一个单个可执行实现。BusyBox 包含了一些简单的工具,例如 cat 和 echo,还包含了一些更大、更复杂的工具,例如 grep、find、moun)
public static synchronized boolean checkBusybox() {
try {
Log.i(LOG_TAG, “to exec busybox df”);
String[] strCmd = new String[]{“busybox”, “df”};
ArrayList<String> execResult = executeCommand(strCmd);
if (execResult != null) {
Log.i(LOG_TAG, “execResult=” + execResult.toString());
return true;
} else {
Log.i(LOG_TAG, “execResult=null”);
return false;
}
} catch (Exception e) {
Log.i(LOG_TAG, “Unexpected error – Here is what I know: “
+ e.getMessage());
return false;
}
}
4、运行su命令
public static synchronized boolean checkGetRootAuth() {
Process process = null;
DataOutputStream os = null;
try {
Log.i(LOG_TAG, “to exec su”);
process = Runtime.getRuntime().exec(“su”);
os = new DataOutputStream(process.getOutputStream());
os.writeBytes(“exit\n”);
os.flush();
int exitValue = process.waitFor();
Log.i(LOG_TAG, “exitValue=” + exitValue);
if (exitValue == 0) {
return true;
} else {
return false;
}
} catch (Exception e) {
Log.i(LOG_TAG, “Unexpected error – Here is what I know: “
+ e.getMessage());
return false;
} finally {
try {
if (os != null) {
os.close();
}
process.destroy();
} catch (Exception e) {
e.printStackTrace();
}
}
}
5、检测Android 沙盒目录文件或文件夹读取权限(在Android系统中,有些目录是普通用户不能访问的,例如 /data、/system、/etc等;比如微信沙盒目录下的文件或文件夹权限是否正常)
public static synchronized boolean checkAccessRootData() {
try {
Log.i(LOG_TAG, “to write /data”);
String fileContent = “test_ok”;
Boolean writeFlag = writeFile(“/data/su_test”, fileContent);
if (writeFlag) {
Log.i(LOG_TAG, “write ok”);
} else {
Log.i(LOG_TAG, “write failed”);
}
Log.i(LOG_TAG, “to read /data”);
String strRead = readFile(“/data/su_test”);
Log.i(LOG_TAG, “strRead=” + strRead);
if (fileContent.equals(strRead)) {
return true;
} else {
return false;
}
} catch (Exception e) {
Log.i(LOG_TAG, “Unexpected error – Here is what I know: “
+ e.getMessage());
return false;
}
}
HOOK检测
所谓hook技术,就是通过一段代码(反射、代理)侵入到App启动过程中,在原本执行的代码前插入其它的功能。比如:通过hook技术,上传登陆页面的账号密码等。
对于主流hook框架(Xposed、frida、Cydia Substrate),通常有以下三种方式来检测一个App是否被hook:
1、安装目录中是否存在hook工具
private static boolean findHookAppName(Context context) {
PackageManager packageManager = context.getPackageManager();
List<ApplicationInfo> applicationInfoList = packageManager
.getInstalledApplications(PackageManager.GET_META_DATA);
for (ApplicationInfo applicationInfo : applicationInfoList) {
if (applicationInfo.packageName.equals(“de.robv.android.xposed.installer”)) {
Log.wtf(“HookDetection”, “Xposed found on the system.”);
return true;
}
if (applicationInfo.packageName.equals(“com.saurik.substrate”)) {
Log.wtf(“HookDetection”, “Substrate found on the system.”);
return true;
}
}
return false;
}
2、存储中是否存在hook安装文件
private static boolean findHookAppFile() {
try {
Set<String> libraries = new HashSet<String>();
String mapsFilename = “/proc/” + android.os.Process.myPid() + “/maps”;
BufferedReader reader = new BufferedReader(new FileReader(mapsFilename));
String line;
while ((line = reader.readLine()) != null) {
if (line.endsWith(“.so”) || line.endsWith(“.jar”)) {
int n = line.lastIndexOf(” “);
libraries.add(line.substring(n + 1));
}
}
reader.close();
for (String library : libraries) {
if (library.contains(“com.saurik.substrate”)) {
Log.wtf(“HookDetection”, “Substrate shared object found: ” + library);
return true;
}
if (library.contains(“XposedBridge.jar”)) {
Log.wtf(“HookDetection”, “Xposed JAR found: ” + library);
return true;
}
}
} catch (Exception e) {
Log.wtf(“HookDetection”, e.toString());
}
return false;
}
3、运行栈中是否存在hook相关类
private static boolean findHookStack() {
try {
throw new Exception(“findhook”);
} catch (Exception e) {
// 读取栈信息
// for(StackTraceElement stackTraceElement : e.getStackTrace()) {
// Log.wtf(“HookDetection”, stackTraceElement.getClassName() + “->”+
// stackTraceElement.getMethodName());
// }
int zygoteInitCallCount = 0;
for (StackTraceElement stackTraceElement : e.getStackTrace()) {
if (stackTraceElement.getClassName().equals(“com.android.internal.os.ZygoteInit”)) {
zygoteInitCallCount++;
if (zygoteInitCallCount == 2) {
Log.wtf(“HookDetection”, “Substrate is active on the device.”);
return true;
}
}
if (stackTraceElement.getClassName().equals(“com.saurik.substrate.MS$2”)
&& stackTraceElement.getMethodName().equals(“invoked”)) {
Log.wtf(“HookDetection”, “A method on the stack trace has been hooked using Substrate.”);
return true;
}
if (stackTraceElement.getClassName().equals(“de.robv.android.xposed.XposedBridge”)
&& stackTraceElement.getMethodName().equals(“main”)) {
Log.wtf(“HookDetection”, “Xposed is active on the device.”);
return true;
}
if (stackTraceElement.getClassName().equals(“de.robv.android.xposed.XposedBridge”)
&& stackTraceElement.getMethodName().equals(“handleHookedMethod”)) {
Log.wtf(“HookDetection”, “A method on the stack trace has been hooked using Xposed.”);
return true;
}
}
}
return false;
}
查壳脱壳工具的核心原理
壳的特征与侦查
壳的特征侦查技术分为两部分,分别是APK中DEX文件与so动态库使用的编译器识别规则,以及软件壳处理目标APK后留下的特征信息。
比较常见的加固厂商特征:
娜迦: libchaosvmp.so , libddog.solibfdog.so
爱加密:libexec.so, libexecmain.so
梆梆: libsecexe.so, libsecmain.so , libDexHelper.so
360:libprotectClass.so, libjiagu.so
通付盾:libegis.so
网秦:libnqshield.so
百度:libbaiduprotect.so
加壳技术发展
Dex的加固技术发展
1、 dex整体加固:文件加载和内存加载
2、 函数抽取:在函数粒度完成代码的保护
获取到保护的dex后,函数体的内容是无效的,注意这里说的是无效,而不是无意义。有的app加壳后函数亦然是有意义的,但不是我们想要的。
3、 VMP和DEX2C:Java函数Native化
获取到保护的dex后,函数的属性由Java属性变为native,典型的由数字的onCreate函数Native化。Dex2C则为对Java函数进行语义分析后生成C/C++代码,生成相应的so文件。
so加固的种类
1、基于init、init_array以及JNI_Onload函数的加壳
2、基于自定义linker的加壳
3、加壳技术的识别
脱壳方式
动态加载型壳:
动态加载型壳属于第一代壳。静态分析变得无从下手,攻击方还可以以上帝视角来进行动态分析。面对动态分析,原本最直接有效低成本的动态加载也变成最脆弱的一种保护方式。通常只需要附加进程做一个内存漫游搜索dex.035或者甚至直接看Segment名称就能在内存中找到动态加载的dex文件并dump下来,发展到现在,这个办法依然对大部分加固的一代保护有效。
代码抽取型壳:
代码抽取型壳属于第二代壳。它的主要特点是:即使DEX已经加载到内存中,也仍处于加密状态。
真正的代码数据并不与Dex的结构数据存储在一起,就算Dex被完整的扒下来,也无法看到真正的代码。这个保护真正杜绝的了一代保护的致命缺陷,同时也宣告手工脱壳的时代结束了。
Dexhunter 是所有二代壳脱壳机的鼻祖,原理是通过主动加载Dex中的所有类,然后Dump所有方法对应代码区的数据,并将其重建到被抽取之后的 Dex 之中。
此类主动加载脱壳机大概的流程是:
遍历Dex中的所有类 -> 模拟加载类的流程(例如调用 dvmFindClass 等系列函数) -> 解析内存中的数据 -> 在 Dex 文件中填充数据或者重建结构。
代码混淆壳:
代码混淆壳分为java级别的代码混淆和原生程序的代码混淆。而原生程序的代码混淆称之为第三代壳。
代码混淆壳在编译时改写了代码生成的指令,因此分析该程序时谈不上是脱壳,而是对原始指令的还原,或者说是对代码混淆的海源。针对Obfuscator-LLVM的混淆方式,主要的分析方法为指令替换混淆的还原、控制流平坦化混淆的还原、伪照控制流混淆的还原。
参考资料
https://juejin.cn/post/6844903733248131079
https://www.jianshu.com/p/137ca0a371d6
https://mp.weixin.qq.com/s/tI89U6eht0F_KrMJXooo1A
(完)