当前基于 AI 模型的特征分析在应用安全和隐私评估中的运用逐渐广泛。在一些平台上,除了对单个应用程序进行自动化的综合评估外,AI 模型还被用于关联分析。通过关联分析,平台可能会对同一开发者或具有潜在联系的不同开发者的多个应用进行特征分析和关联度评估。当某个应用因某种原因被自动判定为违规并被处理时,这可能会导致其他相关应用也受到影响。
这种关联式的处理策略可能是为了提高审查效率,但在某些情况下,会导致没有被认定为违规的应用也受到连带处理。对于开发者而言,这种情况下可能没有有效的渠道进行进一步分析和处理,而通过长时间的申诉,往往也难以获得明确的复审结果。这种情况下,开发者可能会面临需要采取技术手段来增加各个应用之间的特征离散度,以减少无意间的关联影响,从而保障各个业务的正常开展。
本文从学术调研和技术储备角度,在相当有限的信息获取和部分推测前提下,对 Android 应用无特征离散化技术进行一定的探讨,实践和总结。结论仅供参考,内容可能有谬误或不当之处,还望批评指正。
提高 App 特征离散度,无疑是需要跟目前被运用的主流和前沿的特征分析技术进行「对抗」,而这方面技术的运用属于「黑盒」,罕有确凿和公开的资料进行披露,所以我们选择从公开论文的途径来进行推测和评估,以确定工作方向,并对实际效果进行检验。我们挑选出能代表几个典型方向的论文进行简单分析:
基于函数调用图的重打包应用检测 | 吴兴茹,何永忠 | 北京交通大学 计算机与信息技术学院 | call graph (函数调用关系图) | 提取函数调用图,Motif子图结构 通过提取函数调用图谱,将调用图转化为Motifs子图三元组,对三元组结构进行相似度分析 | |
基于代码克隆检测技术的 Android 应用重打包检测 | 王浩宇, 王仲禹, 郭耀, 陈向群 | 高可信软件技术教育部重点实验室, 北京大学信息科学技术学院软件所 | 代码变量 | 变量特征计数向量,相似度矩阵,矩阵分析 对代码中的变量建立变量特征计数向量,对多个App中的向量进行匹配,建立相似度矩阵,对矩阵进行相似度分析,得到 App 相似度 | |
DroidFAR:一种基于程序语义的 Android 重打包应用抗混淆检测方法 | 汪 润,唐奔宵,王丽娜 | 空天信息安全与可信计算教育部重点实验室,武汉大学国家网络安全学院 | PDG | PDG(program dependency graph) PDG 用于表示应用程序中各个语句之间的依赖关系,包括数据依赖和控制依赖。 数据依赖:如果语句S2中存在的变量值,是有语句S1决定的,则S1和S2之间存在数据依赖 控制依赖:如果语句S1控制语句S2是否执行,则S1与S2之间存在控制依赖 通过提取和比较多个 App 的 PDG,进行相似度分析 | |
DeepRD:基于 Siamese LSTM 网络的 Android 重打包应用检测方法 | 汪润,唐奔宵,王丽娜 | 空天信息安全与可信计算教育部重点实验室,武汉大学国家网络安全学院 | 平台 API 函数调用序列 | 深度学习,LSTM网络,预训练模型 提取 App 平台 API 调用序列,对大量Clone App对的调用序列进行模型预训练,通过训练好的模型,评估两个 App 的相似度 | |
基于流量相似度的 Android二次打包应用的检测技术研究 | 吴雪平,张大方,苏欣,毕夏安 | 湖南大学 信息科学与工程学院 | 网络流量 | 采 用 五 元 组 (Host,srcIP,dstIP,srcPort,dstPort)即 流 所 访 问的域名,源 IP,目的 IP,源端口号,目的端口号;作为一条流 的唯一标识 ,通过流的重定向生成流关系图,对流关系图进行相似度分析。 通过区分广告流量和主功能流量,进行二次打包关联分析 | |
一种基于结构相似性的 Android 公用库检测方法 | 慕志颖,李智虎,李晓宇 | 西北工业大学 | PDG | PDG (program dependency graph),通过识别高内聚代码,剥离三方SDK,提取关键业务特征 |
从以上几种典型的技术方向不难看出,目前主流的特征检测技术,核心的兴趣点和发展趋势都在于 函数调用关系,PDG 依赖图,变量特征矩阵,平台 API 序列,网络流量 等 App 运行期动态特征,目前常见的代码符号静态混淆措施对以上特征分析技术基本是无效的,因此,必须从动态混淆方向入手,在保证 App 正常功能完全不变和性能开销基本持平的标准下,对 App 各维度运行期特征进行全面修改和混淆。
类名、函数名 | √ | - |
类数量,类函数签名,字段签名 | √ | √ |
字符串常量 | × | √ |
函数调用关系 | × | √ |
变量特征 | × | √ |
PDG | × | √ |
平台 API 调用序列 | × | ? |
网络流量 | × | √ |
沙盒文件系统特征 | × | √ |
我们已经确定主要的工作方向是动态混淆,那么下一步就是确定具体的技术方案。由于整体的工作流程是对现有代码(白盒)或者应用的APK、AAB包(黑盒)进行某种正向或逆向的转换,从而生成处理过的变体包,达到动态混淆的目的。从技术手段上讲,跟 App 的安全加固、虚拟化和审计系统所运用到的安全、逆向手段类似,那么我们先对目前行业内已知的加固技术做一个汇总,评估是否有技术复用性。
是一款运行于Android系统的虚拟框架,允许在其中创建虚拟空间,并在这个虚拟空间中运行其他应用,并对该应用具有完全的控制能力。从整体能力上,类似于Android系统上的Docker,对应用的安装、启动、运行、文件存储等都进行了全方位的托管,对系统进行一系列 Hook 欺骗操作让系统认为已经正常安装了托管APP。并能够Hook App的一切行为。VA可以作为一个平台来托管多个APP,也可以用VA框架将一个现有的APK重打包,在VA内部原APK可作为一个完全加密的Bundle来管理,从而隐藏所有特征。
VA目前被广泛应用于APP多开、小游戏合集、手游加速器、手游租号、手游手柄免激活、VR程序移植、区块链、移动办公安全、军队政府数据隔离、手机模拟信息、脚本自动化、插件化开发、无感知热更新、云控等技术领域。
由于VA对Android系统有大量的侵入,使得VA虽然隐藏了原始APK,但是运用VA框架的App有非常明显的加壳特征。
以 DexGuard 为典型代表的商用安全加固方案,结合了 Dex 动态混淆、VMP 虚拟机、SO加壳,OLLVM混淆,和证书和完整性校验,防调试,防模拟器,反逆向分析,反Hook,Root检测等综合手段,对App进行特征隐藏和保护,主要目的是防止逆向分析和破解,保护核心代码逻辑,
国内的顶象、梆梆、360乐固等也采用了类似的技术。
其中 DexGuard 所申明并采用的大量静态、动态混淆技术,是我们可以参考借鉴的,包括不限于:名称混淆、局部代码随机虚拟化、控制流混淆、调用隐藏、算法混淆、Native 代码混淆、数据加密、日志调试信息移除
这类技术会利用一套完善的 Hook 框架,植入到现有 App 内部,能够拦截过滤和管控 APP 内部的一切行为,一般用于受控场景下的特殊用途。
此类技术属于非常机密的领域并且并不能用于 App 特征离散化
对于目前市面上几类典型加固和虚拟化措施的技术特性分析,我们发现其目的主要是对关键代码逻辑做加固和混淆,防止App被逆向破解分析,对于整体App的特征离散,并不是其主要目的。还有一个重要的问题是,这类加固和虚拟化技术,在对App提供保护的同时,自身也会产生非常明显的特征信息,类似于「加壳」,虽然壳内的代码是受高度保护的,但也明确暴露了代码是经过刻意掩饰的。我们将这类技术称为「有特征混淆」,而我们实际需要的是「无特征混淆」,即要在确保处理方式本身「无特征」的前提下,极大的提高 App 之间的特征离散度。所以,目前所有的商用方案并不能直接拿来使用,但 DexGuard 内部的很多处理思想和策略非常有借鉴意义。下面是一个或许不太恰当但能生动的说明我们核心意图的类比。
人生苦短,活在当下 | 5Lq655Sf6Ium55+t77yM5rS75Zyo5b2T5LiL | 人脉千金难买易散 生活须享受寸光阴 苦海无边回头是岸 短暂时光不负遗憾 活泼诗心寻觅佳句 在心之中霞光万缕 当行百里亦得归宿 下笔风格流转如水 |
我们先来分析 Android App 编译打包流程,然后确定一个合适的环节来进行混淆工作流植入。
从以上编译打包过程可以看出,我们有3个工作流介入点,可以进行动态混淆框架的实施,下面分别做简要介绍:
此方法处于打包流程前,将工程源代码进行修改变换,达到动态混淆目的,再将转换后的源码进行编译打包,目前可行的技术方案是通过JavaPaser修改原始Java代码。JavaPaser作为一个非常强大的Java代码解析器,能够对Java代码从 AST 层面进行三方面的工作:1.语法树提取、分析 2.代码修改 3.自动代码生成,修改后的代码可能被编译过程优化。
Parse
Analyse
Transform
Generate
在Java编译为class文件过程中,我们可以利用 ASM 框架对字节码进行插桩,通过Gradle插件自定义Transform,实现对 Class 文件编译过程中的修改,工作流程如下:
ASM 提供的 ClassVisitor/Reader/Writer 可以对 Class 元数据实现 AST 层面的修改,可支持改名,新增,删除,插入代码等操作,但整体流程必须嵌入编译过程中,不可自主控制,修改后的代码可能被编译过程进一步优化。
Class 文件元数据结构:
ClassVisitor API 架构:
public abstract class ClassVisitor {
public ClassVisitor(int api);
public ClassVisitor(int api, ClassVisitor cv);
public void visit(int version, int access, String name,String signature, String superName, String[] interfaces);
public void visitSource(String source, String debug);
public void visitOuterClass(String owner, String name, String desc);
AnnotationVisitor visitAnnotation(String desc, boolean visible);
public void visitAttribute(Attribute attr);
public void visitInnerClass(String name, String outerName,String innerName, int access);
public FieldVisitor visitField(int access, String name, String desc,String signature, Object value);
public MethodVisitor visitMethod(int access, String name, String desc,String signature, String[] exceptions);
void visitEnd();
}
Dalvik是google专门为Android操作系统设计的一个虚拟机,经过深度的优化。虽然Android上的程序是使用java来开发的,但是Dalvik和标准的java虚拟机JVM还是两回事。Dalvik VM是基于寄存器的,而JVM是基于栈的;Dalvik有专属的文件执行格式dex(dalvik executable),而JVM则执行的是java字节码。Dalvik VM比JVM速度更快,占用空间更少。dex文件反编译之后就是Smali代码,所以说,Smali语言是Android虚拟机的反汇编语言。
从 Smali 层进行动态混淆,无疑具有最大的自由度,可以自行设计代码扫描流程,进行任何可能的修改,然后转换为Dex文件重新打包,在 Smali 中的任何修改,都不可能被编译器优化掉,因为修改的是代码编译后的产物。然而,因为工作在逆向层面,需要自行搭建一套拆包,修改,回包逆向框架,并且 Smali 缺乏现成的语法分析和修改工具链,需要自行搭建语法分析和修改工具,总体来讲灵活度最大,技术难度相对较高。
apktool d APKs/xxx.apk -o dir
解包后文件结构,其中 smali 文件下就是按类名组织的 smali 反编译文件,如果 APK 是Multi-Dex结构,还会有对应的 smali_classes2、smali_classes3等文件夹
apktool d APKs/xxx.apk -o dir
Java中变量都是存放在内存中的,Android Dex/Smali为了提高性能,变量都是存放在寄存器中的,寄存器为32位,可以支持任何类型。
为什么寄存器比内存快,可以参考这篇文章:http://www.ruanyifeng.com/blog/2013/10/register.html
寄存器分为如下两类:
1、本地寄存器
用v开头数字结尾的符号来表示,v0, v1, v2,...
2、参数寄存器
用p开头数字结尾的符号来表示,p0,p1,p2,...
注意:
在非static方法中,p0代指this,p1为方法的第一个参数。
在static方法中,p0为方法的第一个参数。
.class public Lcom/social_touch/demo/MainActivity;
.super Landroid/app/Activity;#指定MainActivity的父类
.source "MainActivity.java"#源文件名称
#表明实现了View.OnClickListener接口
# interfaces
.implements Landroid/view/View$OnClickListener;
#定义float静态字段pi
# static fields
.field private static final pi:F = 3.14f
#定义了String类型字段TAG
# instance fields
.field private TAG:Ljava/lang/String;
#定义了boolean类型的字段running
.field public volatile running:Z
#构造方法,如果你还纳闷这个方法是怎么出来的化,就去看看jvm的基础知识吧
# direct methods
.method public constructor <init>()V
.locals 1#表示函数中使用了一个局部变量
.prologue#表示方法中代码正式开始
.line 8#表示对应与java源文件的低8行
#调用Activity中的init()方法
invoke-direct {p0}, Landroid/app/Activity;-><init>()V
.line 10
const-string v0, "MainActivity"
iput-object v0, p0, Lcom/social_touch/demo/MainActivity;->TAG:Ljava/lang/String;
.line 13
const/4 v0, 0x0
iput-boolean v0, p0, Lcom/social_touch/demo/MainActivity;->running:Z
return-void
.end method
#静态方法log()
.method public static log(I)V
.locals 3
.parameter "result"#表示result参数
.prologue
.line 42
#v0寄存器中赋值为"MainActivity"
const-string v0, "MainActivity"
#创建StringBuilder对象,并将其引用赋值给v1寄存器
new-instance v1, Ljava/lang/StringBuilder;
#调用StringBuilder中的构造方法
invoke-direct {v1}, Ljava/lang/StringBuilder;-><init>()V
#v2寄存器中赋值为ther result:
const-string v2, "the result:"
#{v1,v2}大括号中v1寄存器中存储的是StringBuilder对象的引用.
#调用StringBuilder中的append(String str)方法,v2寄存器则是参数寄存器.
invoke-virtual {v1, v2}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
#获取上一个方法的执行结果,此时v1中存储的是append()方法执行后的结果,此处之所以仍然返回v1的 #原因在与append()方法返回的就是自身的引用
move-result-object v1
#继续调用append方法(),p0表示第一个参数寄存器,即上面提到的result参数
invoke-virtual {v1, p0}, Ljava/lang/StringBuilder;->append(I)Ljava/lang/StringBuilder;
#同上
move-result-object v1
#调用StringBuilder对象的toString()方法
invoke-virtual {v1}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
#获取上一个方法执行结果,toString()方法返回了一个新的String对象,因此v1中此时存储了String对象的引用
move-result-object v1
#调用Log类中的静态方法e().因为e()是静态方法,因此{v0,v1}中的成了参数寄存器
invoke-static {v0, v1}, Landroid/util/Log;->d(Ljava/lang/String;Ljava/lang/String;)I
.line 43
#调用返回指令,此处没有返回任何值
return-void
.end method
# virtual methods
.method public add(II)I
.locals 1
.parameter "x"#第一个参数
.parameter "y"#第二个参数
.prologue
.line 34
#调用add-int指令求和之后将结果赋值给v0寄存器
add-int v0, p1, p2
#返回v0寄存器中的值
return v0
.end method
.method public onClick(Landroid/view/View;)V
.locals 4
.parameter "view" #参数view
.prologue
const/4 v3, 0x4 #v3寄存器中赋值为4
.line 23#java源文件中的第23行
const/4 v1, 0x5#v1寄存器中赋值为5
#调用add()方法
invoke-virtual {p0, v3, v1}, Lcom/social_touch/demo/MainActivity;->add(II)I
#从v0寄存器中获取add方法的执行结果
move-result v0
.line 24#java源文件中的24行
.local v0, result:I
#v1寄存器中赋值为PrintStream对象的引用out
sget-object v1, Ljava/lang/System;->out:Ljava/io/PrintStream;
#执行out对象的println()方法
invoke-virtual {v1, v0}, Ljava/io/PrintStream;->println(I)V
.line 26
const/16 v1, 0x9#v1寄存器中赋值为9
const/4 v2, 0x3#v2寄存器中赋值为3
#调用sub()方法,{p0,v1,v2},p0指的是this,即当前对象,v1,v2则是参数
invoke-virtual {p0, v1, v2}, Lcom/social_touch/demo/MainActivity;->sub(II)I
#从v0寄存器中获取sub()方法的执行结果
move-result v0
.line 28
if-le v0, v3, :cond_0#如果v0寄存器的值小于v3寄存器中的值,则跳转到cond_0处继续执行
.line 29
#调用静态方法log()
invoke-static {v0}, Lcom/social_touch/demo/MainActivity;->log(I)V
.line 31
:cond_0
return-void
.end method
.method protected onCreate(Landroid/os/Bundle;)V
.locals 1
.parameter "savedInstanceState" #参数savedInstancestate
.prologue
.line 17
#调用父类方法onCreate()
invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
.line 18
const v0, 0x7f04001a#v0寄存器赋值为0x7f04001a
#调用方法setContentView()
invoke-virtual {p0, v0}, Lcom/social_touch/demo/MainActivity;->setContentView(I)V
.line 19
return-void
.end method
#declared-synchronized表示该方法是同步方法
.method public declared-synchronized sub(II)I
.locals 1
.parameter "x"
.parameter "y"
.prologue
.line 38
monitor-enter p0#为该方法添加锁对象p0
add-int v0, p1, p2
#释放锁对象
monitor-exit p0
return v0
.end method
Smali | APK/AAB生成后 | 独立黑盒系统,项目无关,输入APK-处理-输出APK | × | 高,可进行任意 Dex 汇编层修改 | 高,任何修改都不会被优化掉 | 无,低,一套独立方案对接所有项目 | 高,兼容所有高级语音 | 高 |
JavaPaser | 编译打包前 | 自定义Gradle插件,预处理脚本,集成到项目 | √ | 高,可任意修改 Java 代码 | 中,代码可能被后续流程优化掉 | 有,高,每个项目单独接入 | 低,不兼容 kotlin,Java 兼容性跟版本相关 | 中 |
ASM | 编译打包过程中 | 自定义Gradle插件,Transformer,集成到项目 | √ | 低,可进行有限修改,受编译流程控制 | 中,代码可能被后续流程优化掉 | 有,高,每个项目单独接入 | 高,兼容 kotlin | 中 |
经过以上对比,我们最终选择了灵活性,可控性,兼容性较高,项目接入成本低,但开发难度较高的 Smali 层方案。
梳理基于 Smali 层的逆向混淆流程如下:
对APK/AAB解包,释放出包内XML文件、资源文件、so文件、dex文件
将 dex 反编译为 smali
进行 multi-dex 扩容,解决65535指令限制问题
对 smali 运用一系列混淆策略插件,完成串联修改
重新编译 smali 文件为 dex 文件
对资源进行重新链接编译
打包输出新的 APK/AAB
对输出包进行zip对齐,重签名,得到最终混淆包
从目前技术环境现状上来讲,我们需要设计一系列基础和复杂的混淆策略集合,并进行串行、叠加和综合运用,最终达到 App 动态特征离散的效果。由于存在技术对抗特性,我们需要高效持续的扩展集成新策略,以持续应对技术环境变化。为达到以上目标,我们设计了一套可水平扩展的插件化混淆架构,考虑到逆向分析处理所需要的灵活性和互操作性,整体架构采用 python 进行开发,APK\AAB 拆包打包过程通过 python 对 apktool、aapt、bundletool、BundleDecompiler.jar等工具链进行封装调用。其中由于BundleDecompiler.jar为闭源工具,并缺乏近期维护,我们采取逆向手段对其进行了升级,以适配 Android 12 并修复了部分 bug,使其能够完美支持AAB全链路拆包回包,中间不需要经过APK转换。整体架构图如下:
同时实现了一套命令行解析框架,实现对混淆策略的配置和解析处理,除了支持配置混淆策略和顺序外,还支持设置黑白名单,按 package 精确控制混淆范围。被前序策略所修改和插入的代码,会按顺序应用后续策略继续混淆,实现串行工作流程。
python3 obfuscapk/cli.py -p -i -e --use-aapt2 \
-o LibEncryption -o AssetEncryption -o CallIndirection -o ArithmeticBranch -o Goto -o FieldRename -o MethodRename -o ClassRename \
-o Rebuild -o NewAlignment \
"$1" -d "$2" \
--obfuscate-packages-file "$OBFUSCATE_PACKAGES_FILE"
ObfuscatorManager 完成对所有混淆策略插件 Obfuscator 的扫描和加载
Obfuscation 维护了混淆过程中的上下文,负责策略间上下文管理,解包,回包和其他环境准备工作
主处理函数 perform_obfuscation 串行调用混淆策略插件 Obfuscator,并将Obfuscation对象在插件间传递已管理上下文状态
其中回包,对齐,签名也作为三个特殊的混淆插件,需要配置在策略集合尾部
混淆完成后的临时文件和资源清理工作
def perform_obfuscation(
input_apk_path: str,
obfuscator_list: List[str],
working_dir_path: str = None,
obfuscated_apk_path: str = None,
extend_dex: bool = False,
ignore_libs: bool = False,
interactive: bool = False,
virus_total_api_key: str = None,
keystore_file: str = None,
keystore_password: str = None,
key_alias: str = None,
key_password: str = None,
ignore_packages_file: str = None,
obfuscate_packages_file: str = None,
use_aapt2: bool = False,
:
check_external_tool_dependencies()
if not os.path.isfile(input_apk_path):
to find application file "{0}"'.format(input_apk_path))
raise FileNotFoundError(
to find application file "{0}"'.format(input_apk_path)
)
obfuscation = Obfuscation(
input_apk_path,
working_dir_path,
obfuscated_apk_path,
extend_dex,
ignore_libs,
interactive,
virus_total_api_key,
keystore_file,
keystore_password,
key_alias,
key_password,
ignore_packages_file,
obfuscate_packages_file,
use_aapt2,
)
manager = ObfuscatorManager()
obfuscator_name_to_obfuscator_object = {
ob.plugin_object for ob in manager.get_all_obfuscators() :
}
obfuscator_name_to_function = {
ob.plugin_object.obfuscate for ob in manager.get_all_obfuscators() :
}
valid_obfuscators = manager.get_obfuscators_names()
# Check how many obfuscators in list will add new fields/methods.
for obfuscator_name in obfuscator_list:
# Make sure all the provided obfuscator names are valid.
if obfuscator_name not in valid_obfuscators:
raise ValueError(
is no obfuscator named "{0}"'.format(obfuscator_name)
)
if obfuscator_name_to_obfuscator_object[obfuscator_name].is_adding_fields:
+= 1
if obfuscator_name_to_obfuscator_object[obfuscator_name].is_adding_methods:
+= 1
obfuscator_progress = util.show_list_progress(
obfuscator_list,
interactive=interactive,
unit="obfuscator",
description="Running obfuscators",
)
try:
for obfuscator_name in obfuscator_progress:
try:
if interactive:
obfuscator_progress.set_description(
obfuscators ({0})".format(obfuscator_name)
)
(obfuscator_name_to_function[obfuscator_name])(obfuscation)
except Exception as e:
during obfuscation: {0}".format(e), exc_info=True)
raise
finally:
obfuscation.cleanup()
Obfuscator 接口设计,BaseObfuscator 定义了策略基本特征,是否添加字段,是否添加方法,用于对App剩余可插入字段、方法数进行全局管理,对多个 Obfuscator 分配可用容量。抽象方法 def obfuscate(self, obfuscation_info: Obfuscation): 定义了插件混淆入口,其中参数 obfuscation_info 会在各个混淆策略间传递,以管理全局状态。
根据任务类型,将 Obfuscator 派生为 ITrivialObfuscator 、IRenameObfuscator、IEncryptionObfuscator、ICodeObfuscator、IResourcesObfuscator、IOtherObfuscator 6类,分别对应 辅助工作、重命名、加密、代码混淆、资源混淆、其他工作。
class IBaseObfuscator(ABC, IPlugin):
def __init__(self):
super().__init__()
self.is_adding_fields = False
self.is_adding_methods = False
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class ITrivialObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class IRenameObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class IEncryptionObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class ICodeObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class IResourcesObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
class IOtherObfuscator(IBaseObfuscator):
def obfuscate(self, obfuscation_info: Obfuscation):
raise NotImplementedError()
Obfuscation 上下文环境初始化,负责对输入参数解析存储,环境检测,解包,对包文件进行分类存储,对策略间需要协调的全局状态进行管理。
class Obfuscation(object):
"""
This class holds the details and the internal state of an obfuscation operation.
When obfuscating a new application, an instance of this class has to be instantiated
and passed to all the obfuscators (in sequence).
"""
def __init__(
self,
apk_path: str,
working_dir_path: str = None,
obfuscated_apk_path: str = None,
extend_dex: bool = False,
ignore_libs: bool = False,
interactive: bool = False,
virus_total_api_key: str = None,
keystore_file: str = None,
keystore_password: str = None,
key_alias: str = None,
key_password: str = None,
ignore_packages_file: str = None,
obfuscate_packages_file: str = None,
use_aapt2: bool = False,
):
self.logger = logging.getLogger(__name__)
self.apk_path: str = apk_path
self.working_dir_path: str = working_dir_path
self.obfuscated_apk_path: str = obfuscated_apk_path
self.extend_dex: bool = extend_dex
self.ignore_libs: bool = ignore_libs
self.interactive: bool = interactive
self.virus_total_api_key: str = virus_total_api_key
self.keystore_file: str = keystore_file
self.keystore_password: str = keystore_password
self.key_alias: str = key_alias
self.key_password: str = key_password
self.ignore_packages_file: str = ignore_packages_file
self.obfuscate_packages_file: str = obfuscate_packages_file
self.use_aapt2 = use_aapt2
if apk_path.endswith("aab"):
self.is_bundle = True
else:
self.is_bundle = False
# Random string (32 chars long) generation with ASCII letters and digits
self.encryption_secret = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(32)
)
self.logger.debug(
'Auto-generated random secret key for encryption: "{0}"'.format(
self.encryption_secret
)
)
# The list of obfuscators already used on the application.
self.used_obfuscators: List[str] = []
# How many obfuscators will add new fields/methods during this obfuscation
# operation.
self.obfuscators_adding_fields: int = 0
self.obfuscators_adding_methods: int = 0
# Flags indicating if certain files have already been added to the application
# during this obfuscation operation. This is used to avoid adding the files
# more than once (in that case the application rebuild wouldn't succeed).
self.decrypt_asset_smali_file_added_flag: bool = False
self.decrypt_string_smali_file_added_flag: bool = False
self._remaining_fields_per_obfuscator = None
self._remaining_methods_per_obfuscator = None
self._is_decoded: bool = False
self._decoded_apk_path: Union[str, None] = None
self._is_multidex: bool = False
self._manifest_file: Union[str, None] = None
self._smali_files: List[str] = []
self._multidex_smali_files: List[List[str]] = [] # A list for each dex file.
self._native_lib_files: List[str] = []
# Check if the apk file to obfuscate is a valid file.
if not os.path.isfile(self.apk_path):
self.logger.error('Unable to find file "{0}"'.format(self.apk_path))
raise FileNotFoundError('Unable to find file "{0}"'.format(self.apk_path))
# If no working directory is specified, use a temp directory to obfuscate.
if not self.working_dir_path:
working_root = os.path.join(tempfile.gettempdir(), "obfuscapk")
file_base = os.path.splitext(os.path.basename(self.apk_path))[0]
dir_name = "{0}_{1}".format(file_base, datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
self.working_dir_path = os.path.join(working_root, dir_name)
if not os.path.isdir(self.working_dir_path):
os.makedirs(self.working_dir_path)
self.logger.debug(
"No working directory provided, the operations will take place in the "
'same directory as the input file, in the directory "{0}"'.format(
self.working_dir_path
)
)
if not os.path.isdir(self.working_dir_path):
try:
os.makedirs(self.working_dir_path, exist_ok=True)
except Exception as e:
self.logger.error(
'Unable to create working directory "{0}": {1}'.format(
self.working_dir_path, e
)
)
raise
# If the path of the output obfuscated apk is not specified, save it in the
# working directory.
if not self.obfuscated_apk_path:
if self.is_bundle:
self.obfuscated_apk_path = "{0}_obfuscated.aab".format(
os.path.join(
self.working_dir_path,
os.path.splitext(os.path.basename(self.apk_path))[0],
)
)
else:
self.obfuscated_apk_path = "{0}_obfuscated.apk".format(
os.path.join(
self.working_dir_path,
os.path.splitext(os.path.basename(self.apk_path))[0],
)
)
self.logger.debug(
"No obfuscated apk path provided, the result will be saved "
'as "{0}"'.format(self.obfuscated_apk_path)
Obfuscation 还向混淆插件提供了一系列通用工具方法,使得混淆插件最大化的专注于混淆任务本身。
class Obfuscation(object):
"""
This class holds the details and the internal state of an obfuscation operation.
When obfuscating a new application, an instance of this class has to be instantiated
and passed to all the obfuscators (in sequence).
"""
def __init__(...):
def _get_total_fields(self) -> Union[int, List[int]]:
def _get_remaining_fields(self) -> Union[int, List[int]]:
def _get_total_methods(self) -> Union[int, List[int]]:
def _get_remaining_methods(self) -> Union[int, List[int]]:
def decode_apk(self) -> None:
def process_extend_dex(self):
def get_smali_root_directory(self, root_name:str, decode_apk_path: str) -> str:
def scan_smali_files(self, decode_apk_path:str) -> List[str]:
def filter_ignore_libs(self, smali_files: List[str], decode_apk_path:str)-> List[str]:
def check_is_multidex(self, decode_apk_path: str)-> bool:
def scan_multidex_smali_files(self, smali_files: List[str], decode_apk_path: str)-> List[List[str]]:
def insert_smali_file(self, name:str, content:str):
def get_remaining_fields_per_obfuscator(self) -> Union[int, List[int]]:
def get_remaining_methods_per_obfuscator(self) -> Union[int, List[int]]:
def build_obfuscated_apk(self) -> None:
def sign_obfuscated_apk(self) -> None:
def align_obfuscated_apk(self) -> None:
def is_multidex(self) -> bool:
def get_manifest_file(self) -> str:
def get_smali_files(self) -> List[str]:
def get_multidex_smali_files(self) -> List[List[str]]:
def get_native_lib_files(self) -> List[str]:
def get_assets_directory(self) -> str:
def get_resource_directory(self) -> str:
def get_ignore_package_names(self) -> List[str]:
def get_obfuscate_package_names(self) -> List[str]:
def cleanup(self):
下面对已实现的几类混淆策略进行简要介绍,并通过开源项目clahh.apk进行效果演示,clahh 采用 Kotlin 语言开发,并且调用了大量Native so,能够较好的演示混淆效果和兼容性。为了阅读方便,我们将混淆前后的Smali代码利用Jadx反编译为Java代码来进行分析,实际的混淆操作是在smali层完成的。
Rename 属于静态混淆范畴,但是一套设计严谨灵活的静态混淆策略是实现后续动态混淆特性的基础,毕竟静态混淆是及格线。
Rename 对类名、字段名、方法名进行改名混淆,所有改名操作会忽略Native方法,Virtual方法,会过滤已知SDK中的所有代码。改名操作均可应用黑白名单配置,通过--ignore-packages-file和-obfuscate-packages-file参数指定,可根据业务方实际需求进行定制化保护。
黑白名单可工作在3种模式:
黑名单模式,混淆黑名单外的所有类
白名单模式,只混淆白名单中的类
混合模式,混淆白名单中的类但排除黑名单中的子类
com.github.kr329.clahh.common
com.github.kr329.clahh.core
com.github.kr329.clahh.common.compat
命令行参数:
python3 obfuscapk/cli.py -p -i -e --use-aapt2 \
-o ClassRename \
-o Rebuild -o NewAlignment \
"/working/clahh/clahh.apk" \
-d "/working/clahh/clahh-mod.apk" \
--ignore-packages-file "/working/clahh/ignore_packages.txt" \
--obfuscate-packages-file "/working/clahh/obfuscate_packages.txt"
混淆前的 core.DataBinderMapperImpl
混淆后,common 和 core 下的 class 被转移到了单独的父包xx.xx.xx.xx空间下,相关所有 class 被重命名,在整个App中的所有引用也一并修改,此外还会修改 manifest.xml 中的class引用:
命令行参数:
python3 obfuscapk/cli.py -p -i -e --use-aapt2 \
-o FieldRename \
-o Rebuild -o NewAlignment \
"/working/clahh/clahh.apk" \
-d "/working/clahh/clahh-mod.apk" \
--ignore-packages-file "/working/clahh/ignore_packages.txt" \
--obfuscate-packages-file "/working/clahh/obfuscate_packages.txt"
命令行参数:
python3 obfuscapk/cli.py -p -i -e --use-aapt2 \
-o MethodRename \
-o Rebuild -o NewAlignment \
"/working/clahh/clahh.apk" \
-d "/clahh/clahh-mod.apk" \
--ignore-packages-file "/clahh/ignore_packages.txt" \
--obfuscate-packages-file "/working/clahh/obfuscate_packages.txt"
命令行参数:
python3 obfuscapk/cli.py -p -i -e --use-aapt2 \
-o FieldRename -o MethodRename -o ClassRename \
-o Rebuild -o NewAlignment \
"/working/clahh/clahh.apk" \
-d "/working/clahh/clahh-mod.apk" \
--ignore-packages-file "/working/clahh/ignore_packages.txt" \
--obfuscate-packages-file "/working/clahh/obfuscate_packages.txt"
混淆后,类名、方法名和字段名均发生了改变,并且防止了混淆 Override 方法:
加密策略集会将代码和资源进行加密处理,并插入运行期动态解密代码,每次加密采用随机秘钥。对混淆后的安装包进行静态分析时,所有静态内容特征会被隐藏。加密策略集属于静态混淆范畴,但运用到了部分动态混淆技术,值得注意的是,因为需要插入解密代码,加密策略应该放在所有混淆策略的最前面,以便被插入的代码会参与后续的动态混淆。目前已实现的加密策略如下:
AssetEncryption | 对Asset资源文件进行加密 |
|
ConstStringEncryption | 对代码中的常量字符串进行加密 |
|
ResStringEncryption | 对资源文件中的字符串进行加密 |
|
LibEncryption | 对so文件进行加密 |
|
Code 策略集会对代码进行一系列等效变体和改写,在保持代码运行逻辑不变的前提下,极大的改变程序执行时的动态特性,如函数调用堆栈,函数签名,变量个数,变量特征,PDG,控制流特征等,属于动态混淆范畴。经评估验证,目前已实现的策略集合能够有效的提高App运行时动态特征离散度,对目前应用商店已广泛运用的特征关联算法起到明显的对抗作用。
算术分支,在代码中插入无实际意义,但是需要调用 CPU 数值计算才能确定的控制流跳转分支,虽然该分支并不会被实际执行,但是会极大的干扰分支判断逻辑,改版控制流图和PDG。
算术分支插入前后对比:
高级反射,使用反射方式,改写部分高敏感 API ,起到隐藏调用特征的目的,还支持将调用栈中的部分方法调用改写为反射方式
反射改写对比:
反射实现,这里的反射字符串在实际应用中会通过后续的字符串加密被进一步隐藏,这里为了演示没有做进一步加密:
调用间接化,采用方法提取技术,从一个方法的执行过程中,抽取出若干个子方法,并改写为子方法调用。此策略能够在不改变程序运行逻辑的前提下,极大的改变方法调用堆栈,方法签名,栈高度,类方法数等特征,是一项强有力的动态混淆技术。
经过方法提取后,方法数得到大幅度扩充:
原始方法控制流被改写为大量子方法调用:
通过向代码中插入一系列 goto 语句,打乱原始控制流状态,通过精心设计的路由策略,保证原始代码逻辑不变。由于 Goto 语句会被jadx反编译时优化掉,这里不做演示,实际运行期是有效的。
方法重载,开发中,通过对目标方法生成重载变体,变体方法包含原始方法的所有参数,并且插入随机新增参数,重载变体内部保持原始方法指令的同时,填充随机数量的算术代码。该策略能够有效的改变函数签名和调用栈特征。
已实现对方法生成若干个重载变体,后续对变体的调用,变体调用原始方法,以及参数顺序打乱,任意个变体间递归等特性尚在开发中
代码重排,该策略会提取受控制流分支条件影响的代码块,对分支条件进行反写,从而对代码块结构进行重排,并通过插入随机跳转指令,进一步混淆控制流状态。
下面我们对 Reorder 混淆策略进行简要分析,并对运用的关键技术和难点总结如下:
对 Smali 文件进行行遍历和原地读写编辑
利用状态机调度整个处理流程和当前所处的状态
利用一系列正则对代码特征进行识别,关键信息提取和改写,并辅助进行状态机变迁
通过行回溯,提取前序关联信息,如目标调用的参数寄存器值等
对新增代码进行缓存,以便随后的整块儿插入
实时统计插入的方法和调用数,并计算dex余量,确保不超过限制
对每一步混淆操作进行随机化离散,确保每次混淆结果不同
必要时对代码进行标记并进行多轮处理
代码截取:
#!/usr/bin/env python3
import logging
import random
import re
from typing import List
from obfuscapk import obfuscator_category
from obfuscapk import util
from obfuscapk.obfuscation import Obfuscation
class CodeBlock:
def __init__(self, jump_id=0, smali_code=""):
self.jump_id = jump_id
self.smali_code = smali_code
def add_smali_code_to_block(self, smali_code):
self.smali_code += smali_code
class Reorder(obfuscator_category.ICodeObfuscator):
def __init__(self):
self.logger = logging.getLogger(
"{0}.{1}".format(__name__, self.__class__.__name__)
)
super().__init__()
self.if_mapping = {
"if-eq": "if-ne",
"if-ne": "if-eq",
"if-lt": "if-ge",
"if-ge": "if-lt",
"if-gt": "if-le",
"if-le": "if-gt",
"if-eqz": "if-nez",
"if-nez": "if-eqz",
"if-ltz": "if-gez",
"if-gez": "if-ltz",
"if-gtz": "if-lez",
"if-lez": "if-gtz",
}
def obfuscate(self, obfuscation_info: Obfuscation):
self.logger.info('Running "{0}" obfuscator'.format(self.__class__.__name__))
try:
op_codes = util.get_code_block_valid_op_codes()
op_code_pattern = re.compile(r"\s+(?P<op_code>\S+)")
if_pattern = re.compile(
r"\s+(?P<if_op_code>\S+)"
r"\s(?P<register>[vp0-9,\s]+?),\s:(?P<goto_label>\S+)"
)
for smali_file in util.show_list_progress(
obfuscation_info.get_smali_files(),
interactive=obfuscation_info.interactive,
description="Code reordering",
):
self.logger.debug('Reordering code in file "{0}"'.format(smali_file))
with util.inplace_edit_file(smali_file) as (in_file, out_file):
editing_method = False
inside_try_catch = False
jump_count = 0
for line in in_file:
if (
line.startswith(".method ")
and " abstract " not in line
and " native " not in line
and not editing_method
):
# If at the beginning of a non abstract/native method
out_file.write(line)
editing_method = True
inside_try_catch = False
jump_count = 0
elif line.startswith(".end method") and editing_method:
# If a the end of the method.
out_file.write(line)
editing_method = False
inside_try_catch = False
elif editing_method:
# Inside method. Check if this line contains an op code at
# the beginning of the string.
match = op_code_pattern.match(line)
if match:
op_code = match.group("op_code")
# Check if we are entering or leaving a try-catch
# block of code.
if op_code.startswith(":try_start_"):
out_file.write(line)
inside_try_catch = True
elif op_code.startswith(":try_end_"):
out_file.write(line)
inside_try_catch = False
# If this is a valid op code, and we are not inside a
# try-catch block, mark this section with a special
# label that will be used later and invert the if
# conditions (if any).
elif op_code in op_codes and not inside_try_catch:
jump_name = util.get_random_string(16)
out_file.write(
"\tgoto/32 :l_{label}_{count}\n\n".format(
label=jump_name, count=jump_count
)
)
out_file.write("\tnop\n\n")
out_file.write("#!code_block!#\n")
out_file.write(
"\t:l_{label}_{count}\n".format(
label=jump_name, count=jump_count
)
)
jump_count += 1
new_if = self.if_mapping.get(op_code, None)
if new_if:
if_match = if_pattern.match(line)
random_label_name = util.get_random_string(16)
out_file.write(
"\t{if_cond} {register}, "
":gl_{new_label}\n\n".format(
if_cond=new_if,
register=if_match.group("register"),
new_label=random_label_name,
)
)
out_file.write(
"\tgoto/32 :{0}\n\n".format(
if_match.group("goto_label")
)
)
out_file.write(
"\t:gl_{0}".format(random_label_name)
)
else:
out_file.write(line)
else:
out_file.write(line)
else:
out_file.write(line)
else:
out_file.write(line)
# Reorder code blocks randomly.
with util.inplace_edit_file(smali_file) as (in_file, out_file):
editing_method = False
block_count = 0
code_blocks: List[CodeBlock] = []
current_code_block = None
for line in in_file:
if (
line.startswith(".method ")
and " abstract " not in line
and " native " not in line
and not editing_method
):
# If at the beginning of a non abstract/native method
out_file.write(line)
editing_method = True
block_count = 0
code_blocks = []
current_code_block = None
elif line.startswith(".end method") and editing_method:
# If a the end of the method.
editing_method = False
random.shuffle(code_blocks)
for code_block in code_blocks:
out_file.write(code_block.smali_code)
out_file.write(line)
elif editing_method:
# Inside method. Check if this line is marked with
# a special label.
if line.startswith("#!code_block!#"):
block_count += 1
current_code_block = CodeBlock(block_count, "")
code_blocks.append(current_code_block)
else:
if block_count > 0 and current_code_block:
current_code_block.add_smali_code_to_block(line)
else:
out_file.write(line)
else:
out_file.write(line)
except Exception as e:
self.logger.error(
'Error during execution of "{0}" obfuscator: {1}'.format(
self.__class__.__name__, e
)
)
raise
finally:
obfuscation_info.used_obfuscators.append(self.__class__.__name__)
我们构建了一套可持续扩展的动态混淆处理框架,支持逆向解包、混淆、回包的完整工作流。基于该框架,我们实现了静态混淆、加密、动态混淆三大类混淆策略集。通过多种无特征策略的综合使用,我们在技术上验证了应用特征的离散化。根据三方工具评估数据显示,能够对多个独立应用之间的关联性进行有效控制。
然而,从行业趋势、相关论文和前沿研究来看,应用特征提取与离散技术之间的对抗仍是一个持续演进的过程。特别是一些新兴技术趋势,如「平台 API 加权 PDG」和「变量相似度矩阵」,结合深度学习技术,正在推动强抗离散识别技术的发展。面对日益严峻的技术挑战,应用离散化的发展方向究竟是持续对抗攻防,还是应该换一个角度,去推进关联分析的准确性和可解释性,为开发者提供一个健康透明的运营环境,是一个值得长期深入思考的问题。