互联网平台安全和风控是业务防御方和黑产进攻方在黑盒状态下的动态对抗博弈。终端风控使用的SDK 受限于其工作原理,必须嵌入业务的APP 应用或H5页面中,直接暴露在黑产眼前。黑产团伙中的技术人员通过逆向分析和修改SDK 采集的设备信息字段试探云端的防控策略,也可以制作工具针对性地伪造大量的虚假设备用于后续攻击活动。因此,风控技术人员需要对SDK 进行安全加固保护,保护其核心代码逻辑,提升黑产逆向分析的技术难度和消耗的时间成本。
从SDK 代码保护的防护效果来看,Android 相对防护效果较好,iOS 次之,而JS 的防护效果较差。
Android 的SDK 分为Java 代码实现和C 代码实现。Java 代码实现部分逆向难度较低,通过Dex 反编译工具直接得到可读性极高的源码。而C 代码实现的部分,逆向难度则相对较高。
iOS 的SDK 分为Object-C 代码实现和C 代码实现,由于苹果公司对APP Store 上架APP 的限制不支持代码动态加载和高强度的混淆,因此,其加固强度相对Android SDK而言要低一个级别。
JavaScript(以下简称JS)是一种解释型语言,暴露在H5页面的SDK 程序本身就是源代码状态。从理论上讲很多代码混淆保护技术(包括代码虚拟化保护技术)都适用于JS 语言。Google 的BotGuard 是业内公认在安全性、兼容性和性能方面综合表现优秀的方案。该方案运用了代码虚拟化保护技术,在浏览器中使用JS 构建了一个虚拟机执行核心代码。在细节上,它充分使用了字节码动态加密、反调试等各类传统二进制代码保护领域的技术手段。虽然如此,BotGuard 依然可以被破解。
在JS 代码保护领域中,asm.js 和WebAssembly 也是常被提及的方案,但是对于风控JS SDK 而言,浏览器生态环境的兼容性要求极高,适应性就不是很好。总体而言,在浏览器环境下需要考虑更多的兼容性从而加固保护强度,因此,现有的JS 代码保护强度相对APP 原生SDK 要低很多。
下面从JS 代码混淆技术开始,依次介绍JS SDK 和APP SDK 的代码保护技术方案。
代码混淆(obfuscation)是增加黑产静态分析难度而牺牲运行效率的一种技术方案。JS代码混淆是指通过逻辑变换算法等技术手段将受保护的代码转化为难以分析的等价代码的一种技术方案。“难以分析”是混淆的目的,“等价代码”则是要确保混淆后的代码与源代码功能表现保持一致。通俗来说,混淆代码P 就是把P 转换为P',使P'的行为与P 的行为一致,但是攻击者却很难从P'中分析获取信息。
对于混淆的分类,普遍以Collberg的理论为基础,分为布局混淆(layout obfuscation)、数据混淆(data obfuscation)、控制混淆(control obfuscation)和预防混淆(preventive obfuscation)4种类型。本节将按照以上分类依次介绍JS 的混淆方法。
布局混淆原是指删除或混淆与执行无关的辅助文本信息,增加攻击者阅读和理解代码的难度,具体到JS 就是指源代码中的注释文本、调试信息等。布局混淆也包括采用技术手段处理代码中的常量名、变量名、函数名等标识符,增加攻击者对代码理解的难度,具体的方式包括以下几个方面。
1.删除无效代码
这里所说的无效代码包括以下内容:
· 注释文本:详细的注释文本对用户促进代码理解意义重大,生产环境需要删除。
· 调试信息:调试代码在开发环境中对开发者调试Bug 有极大帮助,但是生产环境就没存在的必要了,具体的有console 对象调用,如console.log('test')、debugger、alert(调试用)。
· 无用函数和数据:在开发过程中,由于需求更改或重构无意遗留下来的内容,虽然未调用或使用,但是对攻击者来说可以猜测开发者的意图和思路。
· 缩进和换行符:由于JS 可以通过分号进行分句,所以删除所有的代码缩进和换行符增加了代码的阅读难度,这也能大大减少代码的体积。需要注意的是,与以上的删除项相比,该项是可逆的,即攻击者可以通过格式化工具恢复。
2.标识符重命名
标识符一般指常量名、变量名、函数名,标识符的字面意义有利于攻击者对代码的理解,所以需要将标识符变为无意义、难以阅读的名字,示例代码:
我们发现原本的变量名“name”变成了无意义的“a”,对于解释器来说,该变量名不影响代码执行,而且更改命名也不会增加内存消耗。标识符重命名是少数几种无明显副作用的混淆方法之一,下面介绍几种业内常见的变形方式:
· 单字母:如上述示例代码,变量名由单个英文字母组成,这种方法最为常见,因为这种形式在一定程度上可以缩减代码的体积。要特别注意这种命名方式最多只包含52个大小写字母,所以容易碰到在一个作用域链内标识符名不够用导致标识符碰撞的情况发生,这时需要扩展这种形式,如增加数字与字母组合“a1”,或者双字母组合“aa”等。
· 十六进制字符:以“_0x”开头随机十六进制数字结尾的形式,如“_0x465ab1”。这种形式的命名优点明显:形式相似,攻击者难以辨认。缺点是标识符太长容易造成代码的膨胀。
· 蛋形结构:将以上两种形式的优缺点进行中和,创造以大小写字母“O”、数字“0”、字母“Q”为基础、随机非零首位的组合,如“QO0O”,这类形式不好辨认、样本数量适中。
根据上面介绍的3种变形结构,我们再强调以下两点内容:
· 作用域链内标识符名不够用导致标识符碰撞的情况,并不是单字母特有的,即使像十六进制字符这种大样本的形式也有一定的碰撞概率,所以在实际的实现混淆标识符过程中都伴随着作用域链内的变量碰撞检查。
· 为了增加攻击者的分析难度,我们更希望以最少的名字重命名所有的标识符。换句话说,在同一个作用域链内要避免命名碰撞;在不同作用域链中标识符命名尽可能重复。如果能够分析出某标识符只在本作用域使用,那么同作用域链上都应该尽可能碰撞。
我们可以发现布局混淆相关的方法并不会影响源程序的执行过程、内存开销,甚至代码体积没有膨胀反而缩小了,最重要的是这种改变很容易保证混淆后的代码等价输出。
JavaScript有7种数据类型:数字(number)、字符串(string)、布尔值(boolean)、undefined、null、对象(object)和ECMAScript 第6版新定义的符号(symbols),其中对象类型包括数组(Array)、函数(Function)、正则(RegExp)和日期(Date)。这些数据类型是构成JavaScript 代码的基本元素,也是语义分析的重要根据,所以对数据进行混淆能够提升代码攻击者的分析难度。下面我们将针对不同的数据类型介绍一些常规的混淆手段,并且分析这些混淆手段的效果和引入的代价。
数字混淆主要是使用进制转换、数字拆解等方法对代码进行混淆保护。
1.进制转换
JavaScript 除常用的十进制表示形式外,还有二进制、八进制、十六进制表示形式,分别以“0b”、“0”和“0x”开头。虽然它们在程序中无论如何转换对于机器都是等值的,但是对于人类而言,除十进制外,其他进制都不易识别其具体数值,对于攻击者而言就难以根据不同进制的数值静态分析出代码的逻辑和运行流程。下面以十进制的数字233展示不同进制的表示形式,示例如下:
细心的读者可能已经联想到一个问题:浮点数是否也能用类似的进制方法进行混淆呢?答案是否定的,因为操作系统底层的存储其实并不存在小数,而十进制的小数形式只是迎合数学上的表达,大部分语言都不支持除十进制外的其他进制形式的小数表达式,该类方法只适合整数类型的数字,所以在进行代码转换前需要对数据类型进行校验。虽然浮点数不能用进制转换方法进行混淆,但是JavaScript 本身支持科学计数法“e”或“E”来表示浮点数,示例如下:
通过科学计数法我们同样能对浮点数进行一定程度的混淆,从而达到隐蔽的目的,该方法对部分整数(如10的倍数)同样有效,但是隐蔽效果不明显。
2.数学技巧
有时数字型的字面量在其代码环境中具有一定的规律或作用范围,而我们可以通过某些数学技巧将它的表现形式转换为一种更难分析攻击的新形式,程序中所有对该字面量的操作也都必须是对新的表现形式进行操作。这样描述似乎太过抽象,下面我们通过一段代码演示这个过程,示例如下:
通过观察可以发现i 作为一个数字型的变量,与其相关的代码并不复杂,单看这段代码还是很容易明白代码逻辑,那么可以设定一个新的变量i',其公式满足i'=a×i + b,a和b 都为常量,下面的代码即为当“a=8”及“b=3”时转换出的结果:
对比转换前后的代码,它们的语义等价,但是理解难度大不相同,这便是我们通过数学技巧获得的结果。在实践中,对于许多通过数学技巧混淆数字的算法来说,转换后的数据类型所能表示的值的范围往往会与原来的数据类型所能表示的值的范围不同。例如,当使用v=v×2 10 来替代整数v 时,v 原本的范围为[0,2 53 -1],但是混淆后v′可表示的范围变为0、1024、2048……,所以在分析、构造相应的数学公式时需要仔细分析相关代码环境,尽量避免引起因表示范围改变而导致的Bug。
3.数字拆解
对于数字而言,大多时候可以通过将字面量的数字以某种等价的公式拆分为表达式来提升代码的分析难度,例如:
我们通过勾股定理构造0值的表达式,如“25-16-9=0”,更容易理解的形式为“3×3+ 4×4= 5×5”,这类的数字拆解主要是为了隐藏原有值,而将“24× 60× 60”转换为“(30-6)× 6× 10× 5× 12”则主要是为了破坏其代表的具体语义,即“小时×分钟×秒数”。
数字拆解为表达式的方式会引发另外一个问题:运行拆分代码是否会降低运行效率?例如,在for 循环中“i < 100”变为“i<5×5×4”是否会计算100次“5×5×4”?答案是否定的,在浏览器引擎编译过程中都会对代码进行优化,以V8引擎为例,遇到以上代码时会触发一条“常量折叠”的优化策略,即在编译器里进行语法分析时,将常量表达式进行计算求值,并用求得的值来替换表达式,放入常量表。换句话说,在编译过程中“i < 5×5×4”会转换为“i < 100”以达到最佳性能。我们列举这样的优化策略并不意味着鼓励大家使用数字拆解这种混淆方式,毕竟也有优化策略无法涉及的情况,在实践过程中还需要读者自行测试。
布尔类型的取值范围比较固定且范围非常小,JavaScript 隐式类型强转机制也使得布尔值相对较为容易,混淆手段也多种多样,我们挑选几种比较典型的类型进行介绍。
1.类型转换
“类型转换”是指将一个值从一个类型隐式地转换到另一个类型的操作,如var a = 1+ ''运行后,a 变量会被赋值为字符串类型的“1”,可以利用这个特性将我们的布尔值隐藏起来。在具体的实现过程中,需要理解JavaScript 在强制转换boolean 值时遵循以下规则:
· 如果被强制转换为boolean,那么将成为false的值。
· 其他的一切值将变为true。
JavaScript语言规范给那些在强制转换为boolean值时将会变为false的值定义了一个明确的、小范围的列表,这个列表在ES5语言规范中定义的一个boolean抽象操作中可以找到。
· undefined
· null
· false
· +0、-0and NaN
· ""
这些值会被转为false,其他的值都会转为true。接下来的问题就是如何触发强转。触发强转有很多条件,为了避免代码膨胀,我们采用最简单的逻辑表达式“!”来触发,这样就能得到等价的布尔值。
· !undefined
· !null
· !0
· !NaN
· !""
· !{}
· ![]
· !void(0)
上面的示例在运行后都能得到等价的true 或false 的布尔值,用它们替换布尔值可以在一定程度上加大静态分析的难度。
2.构造随机数
因为布尔类型取值范围极小,所以我们可以利用乘法操作构造特定的随机数混淆布尔值。例如,可以设定能被3整除的整数表示true,能被5整除的整数表示false,那么就可以产生以下的构造函数:
当调用generateNumber(true)时就会生成一个含有因数3且不含有因数5的整数,而当调用generateNumber(false)时就会生成一个含有因数5且不含有因数3的整数。生成这样的特定随机数后,只需要将布尔值替换为一个条件表达式,就可以隐藏原有值。
我们在上面示例的基础上演示混淆后的具体代码:
观察上面的示例代码可以发现,如果要进行静态分析,那么攻击者不得不进行计算才能跟踪变量的真实值,而布尔值大多数代码应用场景在代码控制流中,这变相地将代码的控制流走向变得模糊,无疑增加了代码分析的难度。
字符串是最常见的常量数据,它往往包含一些重要的语义信息,如页面密码输入错误系统会提示用户“账户与密码不一致”,那么破解者只需要查找代码中“账户与密码不一致”的字符串,就能找出整个登录模块的校验、加密逻辑。有许多隐藏这类数据的技巧,可以把关键字符串(如加密密钥)分解成许多片段,并把它们分散在程序的各个角落;用一个常量对字符串进行异或操作;或者对字符串进行常规的加解密操作等。下面我们介绍几种较为复杂的字符串混淆手段。
1.Mealy 机
Mealy 机属于有限状态机的一种,它是基于当前状态和输入生成输出的有限状态自动机,这意味着它的状态图每条转移边都有输入和输出。与输出只依赖于机器当前状态的摩尔有限状态机不同,它的输出与当前状态和输入都有关,即次态=f(现状,输入),输出=f(现状,输入)。根据Mealy 机的特性,可以将字符串的每一个bit 位和当前状态输入,输出实际的字符串。下面通过Mealy 机对字符串“mimi”和“mila”进行混淆处理来举例如何使用:调用Mealy('01002')将会产生字符串“mimi”,调用Mealy('01102')将会产生字符串“mila”。
如图4.6所示为有限状态机的状态转换图及相应的next 表和out 表。
图4.6 有限状态机的状态转换图及相应的next 表和out 表
实现Mealy 机的方法有多种,其中最简单的就是直接查询next 表和out 表。
2.字符编码
JavaScript 允许直接使用码点表示Unicode 字符,写法是“反斜杠+u+码点”,如字母a 可以表示为“\u0061”,这对于机器来说没有区别,但是对于人类而言就无法直接阅读了,从而达到了需要的混淆效果。
我们可以将代码中的所有字符串转为Unicode,这样做只需要消耗少量的空间代价,对执行效率影响非常小。这种方法对于防御者而言也有一个致命的弱点——可以轻易地运用编译方法逆混淆,为了增加此种方法逆混淆的难度,可以在字符串编码时添加一些随机性来增大难度,如对字符串“abc”进行混淆时,我们随机选择其中一个或几个不编码字符,“abc”混淆得到的结果可以为“a\u0062\u0063”、“\u0061b\u0063”和“\u0061\u0062c”等,因为样本的多样化导致逆向混淆时判断难度的提升。
3.其他
由于字符串这一数据类型的灵活多变,其混淆方法种类繁多,如字符串拆分、字符串加密等,而且还可以衍生出多种混淆方法的复用,在这里就不逐一列举。
对于undefined 和null 来说,在大多数情况下,可以利用JavaScript 语言的特性进行混淆,如undefined 可以转为void 0或一个声明却未赋值的变量,这个比较简单,在此不再赘述。
控制混淆是对程序的控制流进行变换,更改程序中原有的控制流达到让代码非常难以阅读和理解的目的。控制混淆是混淆方法中效果相对较好的代码防护手段,它不同于数据混淆只是在形式上有对源代码有所更改,还会对源代码的结构产生一定影响,所以其混淆的风险也较高。
不透明谓词的概念源于数理逻辑,通过严格的逻辑推理证明某些复杂的表达式成立,而这些成立的表达式称为不透明谓词表达式,表达式成立的结果是已知的,而表达式结果表面上是不明显的,称为不透明。常见的经典表达式如下:
不透明谓词的模糊性和不透明性为代码保护提供了强有力的工具,其在代码混淆中有广泛且成熟的研究和具体用途,在这里只需要理解不透明谓词在嵌入代码前,谓词表达式的值已经确定。在一般情况下,我们会在分支语句和循环语句中使用布尔类型的不透明谓词表达式作为判断条件,从而改变程序的控制流向。我们用记号 P T 表示结果实际上总是为真的不透明谓词,用 P F 表示结果实际上总是为假的不透明谓词,用 P ? 表示结果真假不定的不透明谓词。如图4.7所示,假设目前有一段代码,由A和B两个代码块组成,图中展示了3种不透明谓词的使用方式。
图4.7 不透明谓词结构图
· 构造一个恒为真的不透明表达式 P T 作为判断表达式插入A、B之间,因为 P T 恒为真,所以分支语句一定会在A代码块执行完之后执行B代码块,并且改变了原有的代码结构。
· 构造一个恒为真的不透明表达式 P T 插入A、B之间,与上面一种不同的是,在另一个值为假的分支上添加一个存在Bug 的代码块。因为 P T 恒为真,所以存在Bug的分支永远不会执行,但是从攻击者的角度分析,他们并不清楚实际的代码逻辑,所以这会令代码难以理解。
· 构造一个真假不确定的不透明谓词表达式 P ? ,再构造一段与B代码块效果等同但形式不同的B'代码块,因为 P ? 真假不定,所以程序的执行流可能执行B代码块也可能执行B'代码块,但对于攻击者来说,却增加了1倍的阅读时间。
不透明谓词作为有效的代码保护策略,在理论上可以在代码的任何需要判断的位置插入,也可作为单独语句存在于代码的任何位置。虽然上述内容是以逻辑公式为例构造不透明谓词表达式的,但是在混淆JavaScript 代码的过程中也可以替换为具有JavaScript语言特色的表达式,如利用“Math.random() > 0.5”表达式等。然而,对于复杂化的不透明谓词也会给程序的性能带来额外的开销,降低程序运行的性能。因此尽量选择在程序的核心算法或容易受到攻击的位置注入不透明谓词以提高程序的安全性和阅读的可理解性,平衡由于不透明谓词带来的性能开销。
所谓的冗余代码是指与程序中的其他代码没有任何调用关系的代码,死代码是指在程序中永远不会被执行到的代码。将其插入程序中并不会对其造成任何影响,同时还可以增加破解者的阅读难度。插入代码的方法可以借助上文的不透明谓词。
控制流平坦化是指将程序的条件分支和循环语句组成的控制分支结构转化为单一的分发器结构,可以使用这种方法对代码中原有的控制流进行混淆,增加控制流的复杂度。在正常代码中,程序的条件分支和循环语句块可能通过串联、分层嵌套等形式形成复杂的控制结构,控制流图的分支或循环条件语句也随之形成复杂的关系。控制流平坦化正是将这些复杂的控制结构替换为单一的分发器结构的代码混淆方法。一个简单的分发器大多是由switch 语句组成的,图4.8就是一个简单地将一段代码流程替换为控制流平坦化后的结构。
图4.8 控制流平坦化结构图
根据图4.8可以发现,除起始代码A外,所有的代码块执行顺序都由主分发器控制,其余所有代码块都在同级。换句话说,攻击者在阅读代码时无法线性地阅读整个代码的运行逻辑和流程,必须按照主分发器的逻辑模拟代码运行的轨迹,从而在代码结构上提升了阅读代码的能力。
下面参照一个示例介绍控制流平坦化的具体实现过程,先展示一段代码混淆前后对比:
观察混淆前后的代码,可以发现这两段代码执行效果是等价的,但是它们的代码结构和复杂度已经完全不一样。通过控制流平坦化将代码的整个执行流程交由switch 语句来控制,代码的运行流程已经无法线性阅读,因为代码块之间的关系完全隐藏在分发器上下文的控制操作中。为了更加直观地对比两者的区别,可以通过观察两者的流程图进行对比,如图4.9所示。
图4.9 控制流平坦化流程图
通过对比控制流平坦化流程图可以清晰地观察到,代码由简单的由上至下的线性流程变为由以switch 构成的分发器为流程控制其余平行代码块的结构。而且在控制平坦化后的流程图中,我们还可以加入上文所述的死代码、废代码等其他代码块来增加对源代码的保护。
预防混淆与布局混淆、数据混淆及控制混淆等方法有极大的区别,它的目的不是通过混淆代码增加人们阅读代码的复杂度,而是提高现有的反混淆技术破解代码的难度或检测现有的反混淆器中存在的问题,并针对现有的反混淆器中的漏洞设计混淆算法,增加其破解代码的难度。
Proguard 是一款Java 语言的压缩器、优化器、混淆器,它能够检测并删除未使用的类、变量、方法和属性;分析并优化方法的字节码;将实际使用的类、变量、方法重命名为无意义的短名称,使字节码更小、更高效,并且更难进行逆向分析。混淆不会改变源代码逻辑,只会使反编译出来的代码不易阅读。Android 支持在编译过程中Proguard,通常为了安全考虑,release 版本的apk 都会开启Proguard 功能。在配置Proguard 规则时,需要保证最小keep 原则(尽可能多地混淆)。在默认情况下,会使用英文字母a、b、c 进行混淆。也可以通过-obfuscationdictionary/-classobfuscationdictionary/-packageobfuscationdictionary 配置字典,使用指定的字符集。如图4.10所示为Proguard 混淆前后代码可读性的差异。
图4.10 Proguard 混淆前后代码可读性的差异
C 语言没有现成的工具,需要自行编写替换脚本。比较简单的方法是对源代码进行正则匹配,还有一种方法——解析语法树。如图4.11所示为C 语言函数名的混淆,变量名本身在编译时就已经优化了。
图4.11 C 语言函数名与变量名替换
在进行APP 逆向静态分析时,研究人员往往会通过特定字符串定位代码执行的位置。如分析网络请求,先通过抓包找到URL,然后全局搜索域名快速定位网络请求相关的代码;当C 语言调用Java 的函数时,类名和方法名都需要使用字符串传入,通过字符串可以很快找到调用的函数;当进行AES 加密处理时,传入key、iv、分组方式都是字符串。因此为了加强APP 安全,可以编写专门的混淆脚本,在发布release 版本时对APP 内的字符串进行混淆。Java 和C 语言都可以做字符串混淆。
那么有没有更便捷的方式进行字符串混淆呢?答案是“有”。利用语法树和编译器代替原先的正则替换,省事且不容易出错。混淆工具出错带来的Bug 对于开发者而言是非常难排查的,因为源代码本身没有Bug。一种比较好的思路是用LLVM 自定义字符串混淆Pass,这种方式兼容性好且无须代码适配。此外也可以编写gradle 插件,在编译过程中操作Dex 字节码,对代码中的字符串进行混淆替换。
想要隐藏AES 加密过程中的key 和iv,还有一种方案叫白盒加密,原理是将key、iv、轮变换隐藏在矩阵中,感兴趣的读者可以自行研究。
Dex 加固即将需要保护的代码单独生成Dex,加密后保存在assets 目录下,在so 加载时解密jar并通过DexClassLoader加载到内存里。该方案的主要问题在于解密后的Dex会以文件形式存储在手机内存中,而且通过内存dump 的方式能够获取解密后的jar 包,而没有生成文件加载的方式存在很多兼容性的问题。
Dex 抽取指的是将Dex 字节码中的函数代码片段提取出来,生成一个方法结构体为空的Dex,并将代码片段保存在so 中。然后在函数运行时实时补全函数的代码。Dex 抽取有两种级别粒度的还原,即类级还原和函数级还原。类级还原是指在类加载时补全该类的全部函数;函数级还原是指在执行函数时才补全函数。目前,市面上主流的加固工具均能实现Dex 抽取的效果。这种加固方案能够防止通过内存dump 方式获取原始Dex,但是通过定制rom 与hook 特定函数的技术手段还是能够还原出原始Dex 的。
在实际对抗环境下,Dex 无论如何保护,都有方法还原原始的Dex,进而反编译得到Java 代码。而C 代码相对而言较难逆向。Java2c 是指将原有的Java 代码抽取出来,通过jni 在native 层反射实现。
流程如下:Dex→smali→抽取+native 化→生成so
抽取后的原始Java 函数,反编译出来是native 函数,在运行过程中也不会还原。Java2c 配合C 语言的代码混淆技术和字符串混淆技术,可以对Android 的Java 代码起到很好的保护效果。同时也不需要对原始Java 代码进行重写。如图4.12所示为加固处理前后反编译出的代码可读性差异。
图4.12 Java2c 加固前后对照
LLVM 是Low Level Virtual Machine 的缩写,其定位是一个比较底层的虚拟机。然而LLVM 本身并不是一个完整的编译器,LLVM 是一个编译器基础架构,把很多编译器需要的功能以可调用的模块形式实现出来并包装成库,其他编译器实现者可以根据自己的需要使用或扩展,主要聚焦于编译器后端功能,如代码生成、代码优化、JIT 等。
编译器前端和后端就是编译器经典的三段式设计中的组成,如图4.13所示。
图4.13 Java2c 加固前后对照编译器前端和后端三段式设计
LLVM 采用经典的三段式设计,如图4.14所示。前端可以使用不同的编译工具对代码文件做词法分析以形成抽象语法树AST,然后将分析好的代码转换成LLVM 的中间语言IR(Intermediate Representation);中间部分的优化器只对中间进行IR 操作,通过一系列的Pass 对IR 做优化;后端负责将优化好的IR 解释成对应平台的机器码。LLVM 的优点在于,中间语言IR 兼容性设计优良,不同的前端语言(C/C++或ObjC 等)最终都转换成同一种的IR;然后经过不同的后端编译器,编译成不同平台构架(x86/x64/arm/arm64等)的机器码。
图4.14 LLVM 转换IR
LLVM 与Clang 是C/C++编译器套件,整个LLVM 的框架包含了Clang,其关系如图4.15所示。因为Clang 是LLVM 的框架的一部分,是LLVM 的一个C/C++的前端。Clang 使用了LLVM 中的一些功能,目前,已知的主要是对中间格式代码的优化,或许还有一部分生成代码的功能。从源代码角度来讲,通过Clang 和LLVM 的源码位置可以看出,Clang 是基于LLVM 的一个工具。从功能的角度来讲,LLVM 可以认为是一个编译器的后端,而Clang 是一个编译器的前端,它们的关系就更加明了,一个编译器前端想要程序最终变成可执行文件,是缺少不了对编译器后端的支持的。
图4.15 Clang 和LLVM 的关系
与GCC 相比,Clang 具有如下优点:
· 编译速度快:在某些平台上,Clang 的编译速度要比GCC 的编译速度快(在Debug 模式下编译OC 的速度比GCC 快3倍)。
· 占用内存小:Clang 生成的AST 所占用的内存是GCC 的1/5左右。
· 模块化设计:Clang 采用基于库的模块化设计,易于IDE 集成及其他用途的重用。
· 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据(metadata),有利于调试和错误报告。
· 设计清晰简单:容易理解,易于扩展增强。
IR 可以理解为一种通用的中间语言,C 语言和C++语言可以对应成IR,对应关系可以是一对多,也可以是多对一。LLVM IR 有以下3种表示形式,其本质是等价的。
· text:便于阅读的文本格式,类似于汇编语言,拓展名为“.ll”。命令为“clang -S -emit-llvm main.m”。
· memory:内存编译器格式。
· bitcode:实时编译器格式,拓展名为“.bc”,命令为“clang -c -emit-llvm main.m”。
如图4.16所示为text 格式IR 的一个示例。
图4.16 text 格式IR 示例
IR 基本语法的注意事项如下:
· 注释以分号“;”开头。
· 全局标识符(函数和全局变量)以“@”开头,局部标识符(寄存器和结构体)以“%”开头。
· alloca 表示在当前函数栈帧中分配内存。
· 32bit 表示4个字节。
· align 表示内存对齐。
· store 表示写入数据。
· load 表示读取数据。
· call 表示调用函数。
· !表示metadata,保存源码调试信息。
· 其他IR 语法的注意事项参考LLVM 官网。
首先需要通过git 下载LLVM 和Clang,使用的命令如下:
虽然Clang 是LLVM 的子项目,但是它们的源码是分开的,因此我们需要将Clang存储在llvm/tools 目录下。下载完成后编译即可,使用的命令如下:
Obfuscator-LLVM 是由瑞士西北应用科技大学安全实验室于2010年6月发起的一个开源项目,该项目在LLVM 开源分支的基础上改造,能够通过代码混淆和防篡改,增加对逆向工程的难度,提供更强的安全性。OLLVM 的混淆操作就是在中间表示IR 层,通过编写Pass 来混淆IR,然后后端依据IR 生成的目标代码也就被混淆了。得益于LLVM的设计,OLLVM 适用LLVM 支持的所有语言(C、C++、Objective-C、Ada 和Fortran)和目标平台(x86、x86-64、PowerPC、PowerPC-64、ARM、Thumb、SPARC、Alpha、CellSPU、MIPS、MSP430、SystemZ 和XCore)。
整个项目包含了3个相对独立的LLVM Pass,每个Pass 实现了一种混淆方式,通过这些混淆手段,可以隐藏源代码或某一部分代码。
· Instructions Substitution (整数二进制操作,如加、减、逻辑运算的等效替换)。
· Bogus Control Flow(流程伪造)。
· Control Flow Flattening(流程平坦化)。
在实际使用中,需要针对自身实际情况配置OLLVM 三种混淆方式的混淆强度。一段比较简单的代码,经过高强度混淆,可能需要数十分钟甚至几小时,产生的可执行文件体积也会膨胀数百倍。此外,对于某些不重要的函数,可以通过设置attribute 注释不进行混淆。在实际使用中,应用开发厂商非常在意APP 的体积,因此,需要折中混淆强度和安全性。
代码虚拟化保护技术是一种比Dex 文件保护、Java2c 技术更强的安全防护技术,可以更有效地对抗黑产逆向工程或破解,避免造成核心技术和风控逻辑被泄密的问题。我们的终端风控SDK 使用了自主研发的TDVM 虚拟机实现自我保护。
TDVM 是基于Clang 编译器扩展实现的VM 虚拟机编译器,在C、C++、Object-C项目编译时对指定函数进行代码保护。依赖自定义的CPU,在编译过程中将代码翻译成自定义的CPU 指令。如“ADD X1X2#1”可以翻译成“XE #1R1R2”。该指令无法直接在CPU 上执行,必须利用对应的解释器执行指令。代码被虚拟化后,如果想要还原源代码,就需要通过反复调试理解自定义指令集的实际功能。因此,代码虚拟化极大地提高了安全性,增加了破解难度。如图4.17所示为虚拟机代码编译过程示意图。
图4.17 虚拟机代码编译过程示意图
TDVM 的逻辑流程如下:
· 使用源文件old.c 通过clang 编译生成old.bc 的IR 文件。
· IR 文件通过clang 编译生成old.o。
· old.bc 通过TDVMCC 提取全局变量和虚拟化的函数,生成vm.c 框架。
· 提取old.o 中的代码段和数据段,生成TDMODULE,并生成资源重定向表TDRELOCATION,填充进vm.c。
· 将vm.c 通过clang 编译生成vm.o。
· 如果是部分代码虚拟化,就需要将未虚拟化的函数从IR 文件中提取出来,生成new.bc。
· new.bc 通过clang 编译生成patch.o。
· vm.o 和patch.o 链接生成new.o,即虚拟化后的可执行文件。
TDVM 指令集完全参照arm 官方文档规范,定义了大部分常用指令,根据指令的类型可以分为如下几类:
· 数据处理指令(立即数):对数据进行加、减、乘、除等运算。
· 跳转和执行指令:根据条件判断后的结果跳转或执行函数调用指令。
· 加载和存储指令:加载数据或存储数据指令。
· 数据处理指令(寄存器):寄存器中的数据处理。
· 数据处理指令(向量寄存器和浮点数处理):主要进行向量寄存器或浮点数的处理。
· 其他指令:自定义CPU 指令包含以下元素。
栈空间:创建虚拟机时分配的栈,用来存储函数的参数或局部变量等。
寄存器信息:第一,通用寄存器;第二,浮点数寄存器,用来存储浮点数(目前不支持浮点数寄存器);第三,向量寄存器,用来储存由向量处理器运行SIMD(Single Instruction Multiple Data)指令所得到的数据;第四,pc 寄存器,当前程序运行的pc 值;第五,标志寄存器。相关定义的代码如下:
翻译模块是指将源代码用Clang 编译成IR 文件(中间语言),然后对IR 中的函数进行指令处理,将指令翻译成自定义CPU 的指令,再用新生成的指令替换原先的指令。在翻译过程中,要对原有指令中的操作数和资源等进行特殊处理。
在虚拟机中执行指令时,需要加载的字符串、常量数组或外部函数等统称为资源。例如,在执行bl strlen 时,需要根据当前pc 值找到对应的bl strlen 再调用函数值。同样,在arm64中需要加载某个字符串“ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn opqrstuvwxyz0123456789+/”时,我们需要在自定义的资源重定位表中定位到字符串的位置,然后加载到自定义的虚拟机中,这个过程称为资源重定位。
以base64中的base64_encode()函数为例进行虚拟化操作,源代码如图4.18所示。
图4.18 base64_encode()函数源代码
如图4.19所示为原始函数在IDA中的反编译效果,整个逻辑清晰明了。代码经过编译器优化和IDA反编译,实现方式有所变化,但是执行效果是完全一致的。
图4.19 base64_encode()函数反编译后的代码
我们将base64_encode()函数进行虚拟化后使用IDA反编译,效果如图4.20和图4.21所示,整个base64_encode()函数的源指令全部被隐藏了。
图4.20 base64_encode()虚拟化函数反编译后的代码
图4.21 base64_encode()虚拟化函数的汇编代码
我们将翻译后的自定义指令、常量、资源写到.data 段。vm_do_arm64对应的是虚拟机的解释器函数,用于解释执行自定义指令,如图4.22所示。
图4.22 自定义指令
解释模块的功能是根据虚拟化后的代码,在执行函数时解释自定义的指令,然后将指令执行的结果保存到自定义的CPU 寄存器、自定义的栈或内存中。整个过程不需要还原代码指令,极大地提升了代码保护的强度。
由于每个架构汇编指令都不一样,每个架构都要实现一套解释器,iOS 的runtime、bitcode 和Android 的jni 也需要单独处理,因此,解释器的实现工作量较大。目前,TDVM 实现了Android 的x86、armv7、arm64三套解释器和iOS 的armv7、arm64两套解释器。
例如,指令ADD X9, X8, #4表示的汇编指令含义是X9=X8+4(取出寄存器X8的值,再加上操作数4,然后将结果存储到X9寄存器)。
在对应的解释模块中,首先解释器要取出自定义CPU 的寄存器X8的值,其次再加上操作数4,最后将结果存储到自定义CPU 的X9寄存器中。
以MUL 指令为例,其实现代码如下:
通过阅读本节内容,读者可以发现,JS SDK 和APP SDK 的代码保护原理有很多相似之处,各种技术方案本质上都是围绕编译原理展开的。如果读者对代码保护感兴趣,则可以深入地学习编译原理相关知识。