cover_image

无特征动态混淆技术在 Android App 离散化方面的探索和思考

映客技术
2024年08月21日 02:31


〇 背景

当前基于 AI 模型的特征分析在应用安全和隐私评估中的运用逐渐广泛。在一些平台上,除了对单个应用程序进行自动化的综合评估外,AI 模型还被用于关联分析。通过关联分析,平台可能会对同一开发者或具有潜在联系的不同开发者的多个应用进行特征分析和关联度评估。当某个应用因某种原因被自动判定为违规并被处理时,这可能会导致其他相关应用也受到影响。

这种关联式的处理策略可能是为了提高审查效率,但在某些情况下,会导致没有被认定为违规的应用也受到连带处理。对于开发者而言,这种情况下可能没有有效的渠道进行进一步分析和处理,而通过长时间的申诉,往往也难以获得明确的复审结果。这种情况下,开发者可能会面临需要采取技术手段来增加各个应用之间的特征离散度,以减少无意间的关联影响,从而保障各个业务的正常开展。

本文从学术调研和技术储备角度,在相当有限的信息获取和部分推测前提下,对 Android 应用无特征离散化技术进行一定的探讨,实践和总结。结论仅供参考,内容可能有谬误或不当之处,还望批评指正。


一 App 特征提取技术现状和趋势

1.论文文献参考

提高 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 公用库检测方法慕志颖,李智虎,李晓宇西北工业大学PDGPDG (program dependency graph),通过识别高内聚代码,剥离三方SDK,提取关键业务特征

图片

2.技术方向小结

从以上几种典型的技术方向不难看出,目前主流的特征检测技术,核心的兴趣点和发展趋势都在于 函数调用关系,PDG 依赖图,变量特征矩阵,平台 API 序列,网络流量 等 App 运行期动态特征,目前常见的代码符号静态混淆措施对以上特征分析技术基本是无效的,因此,必须从动态混淆方向入手,在保证 App 正常功能完全不变和性能开销基本持平的标准下,对 App 各维度运行期特征进行全面修改和混淆。

静态混淆 VS 动态混淆

App 特征识别对抗
静态混淆
动态混淆
类名、函数名-
类数量,类函数签名,字段签名
字符串常量×
函数调用关系×
变量特征×
PDG×
平台 API 调用序列×
网络流量×
沙盒文件系统特征×

二 业界 App 混淆加固技术方案考察

我们已经确定主要的工作方向是动态混淆,那么下一步就是确定具体的技术方案。由于整体的工作流程是对现有代码(白盒)或者应用的APK、AAB包(黑盒)进行某种正向或逆向的转换,从而生成处理过的变体包,达到动态混淆的目的。从技术手段上讲,跟 App 的安全加固、虚拟化和审计系统所运用到的安全、逆向手段类似,那么我们先对目前行业内已知的加固技术做一个汇总,评估是否有技术复用性。

1.VirtualApp 虚拟化运行环境

是一款运行于Android系统的虚拟框架,允许在其中创建虚拟空间,并在这个虚拟空间中运行其他应用,并对该应用具有完全的控制能力。从整体能力上,类似于Android系统上的Docker,对应用的安装、启动、运行、文件存储等都进行了全方位的托管,对系统进行一系列 Hook 欺骗操作让系统认为已经正常安装了托管APP。并能够Hook App的一切行为。VA可以作为一个平台来托管多个APP,也可以用VA框架将一个现有的APK重打包,在VA内部原APK可作为一个完全加密的Bundle来管理,从而隐藏所有特征。

VA目前被广泛应用于APP多开、小游戏合集、手游加速器、手游租号、手游手柄免激活、VR程序移植、区块链、移动办公安全、军队政府数据隔离、手机模拟信息、脚本自动化、插件化开发、无感知热更新、云控等技术领域。

由于VA对Android系统有大量的侵入,使得VA虽然隐藏了原始APK,但是运用VA框架的App有非常明显的加壳特征。

图片

2.商业加固方案

以 DexGuard 为典型代表的商用安全加固方案,结合了 Dex 动态混淆、VMP 虚拟机、SO加壳,OLLVM混淆,和证书和完整性校验,防调试,防模拟器,反逆向分析,反Hook,Root检测等综合手段,对App进行特征隐藏和保护,主要目的是防止逆向分析和破解,保护核心代码逻辑,

国内的顶象、梆梆、360乐固等也采用了类似的技术。

其中 DexGuard 所申明并采用的大量静态、动态混淆技术,是我们可以参考借鉴的,包括不限于:名称混淆、局部代码随机虚拟化、控制流混淆、调用隐藏、算法混淆、Native 代码混淆、数据加密、日志调试信息移除

图片

3.对 App 植入 Hook 框架的审计系统

这类技术会利用一套完善的 Hook 框架,植入到现有 App 内部,能够拦截过滤和管控 APP 内部的一切行为,一般用于受控场景下的特殊用途。

此类技术属于非常机密的领域并且并不能用于 App 特征离散化

三 App 特征离散技术方案

1.有特征混淆 VS 无特征混淆

对于目前市面上几类典型加固和虚拟化措施的技术特性分析,我们发现其目的主要是对关键代码逻辑做加固和混淆,防止App被逆向破解分析,对于整体App的特征离散,并不是其主要目的。还有一个重要的问题是,这类加固和虚拟化技术,在对App提供保护的同时,自身也会产生非常明显的特征信息,类似于「加壳」,虽然壳内的代码是受高度保护的,但也明确暴露了代码是经过刻意掩饰的。我们将这类技术称为「有特征混淆」,而我们实际需要的是「无特征混淆」,即要在确保处理方式本身「无特征」的前提下,极大的提高 App 之间的特征离散度。所以,目前所有的商用方案并不能直接拿来使用,但 DexGuard 内部的很多处理思想和策略非常有借鉴意义。下面是一个或许不太恰当但能生动的说明我们核心意图的类比。


原文
有特征混淆
无特征混淆
人生苦短,活在当下5Lq655Sf6Ium55+t77yM5rS75Zyo5b2T5LiL

人脉千金难买易散

生活须享受寸光阴

苦海无边回头是岸

短暂时光不负遗憾

活泼诗心寻觅佳句

在心之中霞光万缕

当行百里亦得归宿

下笔风格流转如水

2.Android App 编译打包过程

我们先来分析 Android App 编译打包流程,然后确定一个合适的环节来进行混淆工作流植入。

a. 整体打包流程

图片

b.代码编译流程

图片


3.混淆工作流部署——正向 VS 逆向

从以上编译打包过程可以看出,我们有3个工作流介入点,可以进行动态混淆框架的实施,下面分别做简要介绍:

A.Java 代码编译前,正向,JavaPaser

此方法处于打包流程前,将工程源代码进行修改变换,达到动态混淆目的,再将转换后的源码进行编译打包,目前可行的技术方案是通过JavaPaser修改原始Java代码。JavaPaser作为一个非常强大的Java代码解析器,能够对Java代码从 AST 层面进行三方面的工作:1.语法树提取、分析 2.代码修改 3.自动代码生成,修改后的代码可能被编译过程优化。

图片

Parse

图片

Analyse

图片

Transform

图片

Generate

图片

B.Class 文件生成过程中,中间,ASM 字节码插桩

在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();
}

C.Dex 文件生成后,逆向,Smali

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 缺乏现成的语法分析和修改工具链,需要自行搭建语法分析和修改工具,总体来讲灵活度最大,技术难度相对较高。

APK 解包
apktool d APKs/xxx.apk -o dir

解包后文件结构,其中 smali 文件下就是按类名组织的 smali 反编译文件,如果 APK 是Multi-Dex结构,还会有对应的 smali_classes2、smali_classes3等文件夹

图片

APK 回包
apktool d APKs/xxx.apk -o dir
Smali 数据类型

图片

Smali 语法关键词

图片

寄存器

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为方法的第一个参数。

Smali 源文件样例解析
.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




D.三种方案特性对比

方案
阶段
接入方式
有现成工具链
灵活度
可控性
项目侵入(接入成本)
兼容性
开发成本(难度、工作量)
SmaliAPK/AAB生成后独立黑盒系统,项目无关,输入APK-处理-输出APK×高,可进行任意 Dex 汇编层修改高,任何修改都不会被优化掉无,低,一套独立方案对接所有项目高,兼容所有高级语音
JavaPaser编译打包前自定义Gradle插件,预处理脚本,集成到项目高,可任意修改 Java 代码中,代码可能被后续流程优化掉有,高,每个项目单独接入低,不兼容 kotlin,Java 兼容性跟版本相关
ASM编译打包过程中自定义Gradle插件,Transformer,集成到项目低,可进行有限修改,受编译流程控制中,代码可能被后续流程优化掉有,高,每个项目单独接入高,兼容 kotlin

经过以上对比,我们最终选择了灵活性,可控性,兼容性较高,项目接入成本低,但开发难度较高的 Smali 层方案。

4.可水平扩展的插件化混淆架构设计

动态混淆工作流程

梳理基于 Smali 层的逆向混淆流程如下:

  1. 对APK/AAB解包,释放出包内XML文件、资源文件、so文件、dex文件

  2. 将 dex 反编译为 smali

  3. 进行 multi-dex 扩容,解决65535指令限制问题

  4. 对 smali 运用一系列混淆策略插件,完成串联修改

  5. 重新编译 smali 文件为 dex 文件

  6. 对资源进行重新链接编译

  7. 打包输出新的 APK/AAB

  8. 对输出包进行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"

核心流程代码解析

  1. ObfuscatorManager 完成对所有混淆策略插件 Obfuscator 的扫描和加载

  2. Obfuscation 维护了混淆过程中的上下文,负责策略间上下文管理,解包,回包和其他环境准备工作

  3. 主处理函数 perform_obfuscation 串行调用混淆策略插件 Obfuscator,并将Obfuscation对象在插件间传递已管理上下文状态

  4. 其中回包,对齐,签名也作为三个特殊的混淆插件,需要配置在策略集合尾部

  5. 混淆完成后的临时文件和资源清理工作


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):        logger.critical('Unable to find application file "{0}"'.format(input_apk_path))        raise FileNotFoundError(            'Unable 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.name: ob.plugin_object for ob in manager.get_all_obfuscators()    }    obfuscator_name_to_function = {        ob.name: 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(                'There is no obfuscator named "{0}"'.format(obfuscator_name)            )        if obfuscator_name_to_obfuscator_object[obfuscator_name].is_adding_fields:            obfuscation.obfuscators_adding_fields += 1        if obfuscator_name_to_obfuscator_object[obfuscator_name].is_adding_methods:            obfuscation.obfuscators_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(                        "Running obfuscators ({0})".format(obfuscator_name)                    )                (obfuscator_name_to_function[obfuscator_name])(obfuscation)            except Exception as e:                logger.critical("Error 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     @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class ITrivialObfuscator(IBaseObfuscator):    @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class IRenameObfuscator(IBaseObfuscator):    @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class IEncryptionObfuscator(IBaseObfuscator):    @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class ICodeObfuscator(IBaseObfuscator):    @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class IResourcesObfuscator(IBaseObfuscator):    @abstractmethod    def obfuscate(self, obfuscation_info: Obfuscation):        raise NotImplementedError()  class IOtherObfuscator(IBaseObfuscator):    @abstractmethod    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):

5.混淆策略集概览

下面对已实现的几类混淆策略进行简要介绍,并通过开源项目clahh.apk进行效果演示,clahh 采用 Kotlin 语言开发,并且调用了大量Native so,能够较好的演示混淆效果和兼容性。为了阅读方便,我们将混淆前后的Smali代码利用Jadx反编译为Java代码来进行分析,实际的混淆操作是在smali层完成的。

a.Rename

Rename 属于静态混淆范畴,但是一套设计严谨灵活的静态混淆策略是实现后续动态混淆特性的基础,毕竟静态混淆是及格线

Rename 对类名、字段名、方法名进行改名混淆,所有改名操作会忽略Native方法,Virtual方法,会过滤已知SDK中的所有代码。改名操作均可应用黑白名单配置,通过--ignore-packages-file和-obfuscate-packages-file参数指定,可根据业务方实际需求进行定制化保护。

黑白名单可工作在3种模式:


  1. 黑名单模式,混淆黑名单外的所有类

  2. 白名单模式,只混淆白名单中的类

  3. 混合模式,混淆白名单中的类但排除黑名单中的子类


示例黑白名单配置,黑白名单混合模式,混淆包 com.github.kr329.clahh.common 和 com.github.kr329.clahh.core 下的所有名称,并排除 com.github.kr329.clahh.common.compat 子包


--obfuscate-packages-file
com.github.kr329.clahh.commoncom.github.kr329.clahh.core
--ignore-packages-file
com.github.kr329.clahh.common.compat
I.ClassRename 混淆类名

命令行参数:

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"
混淆效果:
commom 和 core下的 class 混淆后名称结构发生改变,common.compat包下的 class 保持不变:

图片


混淆前的 core.DataBinderMapperImpl

图片


混淆后,common 和 core 下的 class 被转移到了单独的父包xx.xx.xx.xx空间下,相关所有 class 被重命名,在整个App中的所有引用也一并修改,此外还会修改 manifest.xml 中的class引用:

图片

II.FieldRename 混淆字段名
修改字段名和 App 中的所有引用,效果跟 ClassRename 类似

命令行参数:

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"
III.MethodRename 混淆函数名
修改函数名和 App 中的所有引用,效果跟 ClassRename 类似

命令行参数:

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"
IV.FieldRename、 MethodRename 、ClassRename 联合使用
3个重命名策略联合使用以达到最大化混淆效果,需要注意的是,必须按照FieldRename、 MethodRename 、ClassRename的顺序来串联工作流,因为黑白名单是按包名规则配置,如果先执行ClassRename,导致包名发生改变后,后续黑白名单策略将会失效。再进一步,实际上,重命名策略应该放在其他所有策略之后。

命令行参数:

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"


混淆效果,以 SliceParcelableListBpBinder 为例:
混淆前

图片


混淆后,类名、方法名和字段名均发生了改变,并且防止了混淆 Override 方法:

图片


b.Encryption

加密策略集会将代码和资源进行加密处理,并插入运行期动态解密代码,每次加密采用随机秘钥。对混淆后的安装包进行静态分析时,所有静态内容特征会被隐藏。加密策略集属于静态混淆范畴,但运用到了部分动态混淆技术,值得注意的是,因为需要插入解密代码,加密策略应该放在所有混淆策略的最前面,以便被插入的代码会参与后续的动态混淆。目前已实现的加密策略如下:


策略
作用
原理
AssetEncryption对Asset资源文件进行加密
  1. 对AssetManager的访问进行扫描

  2. 提取目标文件进行加密,替换原始文件

  3. 改写文件读取代码,插入解密逻辑

ConstStringEncryption对代码中的常量字符串进行加密
  1. 提取代码中的常量字符串

  2. 对字符串加密,替换原始字符串

  3. 改成字符串读取代码,插入解密逻辑

ResStringEncryption对资源文件中的字符串进行加密
  1. 扫描资源字符串访问代码

  2. 建立 string_id 和 string_name 映射表

  3. 扫描 xml 文件,对string_name 进行加密存储

  4. 改写 string 访问代码,插入动态解密逻辑

LibEncryption对so文件进行加密
  1. 加密存储 so

    1. 提取 loadLibrary 调用

    2. 解析参数,提取so文件名

    3. 根据架构 扫描对应so真实路径

    4. 对so进行加密处理,并按照架构进行命名

    5. 将加密后的文件保存到 Assets 中

  2. 改写新的 so 加载逻辑

    1. 运行期从Assets解密对应架构的so到临时目录

    2. 调用 load 加载so文件

c.Code

Code 策略集会对代码进行一系列等效变体和改写,在保持代码运行逻辑不变的前提下,极大的改变程序执行时的动态特性,如函数调用堆栈,函数签名,变量个数,变量特征,PDG,控制流特征等,属于动态混淆范畴。经评估验证,目前已实现的策略集合能够有效的提高App运行时动态特征离散度,对目前应用商店已广泛运用的特征关联算法起到明显的对抗作用。

I.ArithmeticBranch

算术分支,在代码中插入无实际意义,但是需要调用 CPU 数值计算才能确定的控制流跳转分支,虽然该分支并不会被实际执行,但是会极大的干扰分支判断逻辑,改版控制流图和PDG。

算术分支插入前后对比:

图片

II.AdvancedReflection

高级反射,使用反射方式,改写部分高敏感 API ,起到隐藏调用特征的目的,还支持将调用栈中的部分方法调用改写为反射方式

反射改写对比:

图片


反射实现,这里的反射字符串在实际应用中会通过后续的字符串加密被进一步隐藏,这里为了演示没有做进一步加密:

图片

III.CallIndirection

调用间接化,采用方法提取技术,从一个方法的执行过程中,抽取出若干个子方法,并改写为子方法调用。此策略能够在不改变程序运行逻辑的前提下,极大的改变方法调用堆栈,方法签名,栈高度,类方法数等特征,是一项强有力的动态混淆技术。

经过方法提取后,方法数得到大幅度扩充:

图片

原始方法控制流被改写为大量子方法调用:

图片

IV.Goto

通过向代码中插入一系列 goto 语句,打乱原始控制流状态,通过精心设计的路由策略,保证原始代码逻辑不变。由于 Goto 语句会被jadx反编译时优化掉,这里不做演示,实际运行期是有效的。

V.MethodOverload

方法重载,开发中,通过对目标方法生成重载变体,变体方法包含原始方法的所有参数,并且插入随机新增参数,重载变体内部保持原始方法指令的同时,填充随机数量的算术代码。该策略能够有效的改变函数签名和调用栈特征。


已实现对方法生成若干个重载变体,后续对变体的调用,变体调用原始方法,以及参数顺序打乱,任意个变体间递归等特性尚在开发中

图片

VI.Reorder

代码重排,该策略会提取受控制流分支条件影响的代码块,对分支条件进行反写,从而对代码块结构进行重排,并通过插入随机跳转指令,进一步混淆控制流状态。

图片

6.高级混淆策略解析

下面我们对 Reorder 混淆策略进行简要分析,并对运用的关键技术和难点总结如下:

  1. 对 Smali 文件进行行遍历和原地读写编辑

  2. 利用状态机调度整个处理流程和当前所处的状态

  3. 利用一系列正则对代码特征进行识别,关键信息提取和改写,并辅助进行状态机变迁

  4. 通过行回溯,提取前序关联信息,如目标调用的参数寄存器值等

  5. 对新增代码进行缓存,以便随后的整块儿插入

  6. 实时统计插入的方法和调用数,并计算dex余量,确保不超过限制

  7. 对每一步混淆操作进行随机化离散,确保每次混淆结果不同

  8. 必要时对代码进行标记并进行多轮处理


代码截取:

#!/usr/bin/env python3 import loggingimport randomimport refrom typing import List from obfuscapk import obfuscator_categoryfrom obfuscapk import utilfrom 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」和「变量相似度矩阵」,结合深度学习技术,正在推动强抗离散识别技术的发展。面对日益严峻的技术挑战,应用离散化的发展方向究竟是持续对抗攻防,还是应该换一个角度,去推进关联分析的准确性和可解释性,为开发者提供一个健康透明的运营环境,是一个值得长期深入思考的问题。



继续滑动看下一个
映客技术
向上滑动看下一个