Anti-Disassembly on ARM64

点个star,谢谢老铁
GitHub - AppleReer/Anti-Disassembly-On-Arm64: User inline asm to anti-disassembly on arm64

Anti-Disassembly on ARM64

在ARM64平台,使用内联汇编对抗反汇编器的技巧。

先来一段DCB/DCW/DWD/DCQ

ARM文档如是说

The DCB directive allocates one or more bytes of memory, and defines the initial runtime contents of the memory.

DCB(DCW/DCD/DCQ同理)伪指令开辟一个字节或者多个字节的内存,并且定义了内存的初始值。

B = byte,W= word (2bytes),D = dword(4bytes),Q = qword(8bytes)

并且ARM很贴心的给了一个正常示例:

C_string   DCB  "C_string",0

很通俗的理解就是DCQ是汇编语言里为了方便(字符串)常量定义和赋值的指令。既然是数据定义,那么指令出现在可读可写的数据段才更合理。正常人和正常的编译器是不会把DCQ放到只读的代码段的,但是不怎么正常的逆向(安全)工程师会这样做。既然我们知道DCQ是一段数据,那么我们就可以用内联汇编来构造一段DCQ。直接用.long后边跟上任意四字节,我这里就写了两个123456780x12345678,写0xdeadbeef也可以。用Xcode new一个demo,并把下边的代码贴到demo里。

static __attribute__((always_inline)) void DCQ_Demo() {
#ifdef __arm64__
    __asm__(
            ".long 0x12345678\n"
            ".long 12345678\n" 
            );
#endif
}

clean,build,product扔到IDA里。熟悉的DCQ来了,这两个.long把后边的解析直接带跑偏了,IDA不解析了。

再来看看动态的Xcode,第一个.long 0x12345678被解析成了正常的汇编代码。第二个12345678无法识别,因此注释了unknow opcode。先别着急往下看,大家可以猜想一下放开断点之后会发生什么?

第一个0x12345678被解析成了正常的汇编代码。实际执行过程中改变了w24寄存器的值,由于上下文都没引用到w24,所以在这段程序里这行代码没有产生任何负面效果。再来看第二个12345678也就是unknown opcode。cpu执行到这行,由于无法识别这段代码,所以直接抛出异常,程序崩溃了。

小结:DCQ是一条正常的汇编伪指令,用来声明内存并赋初始值。代码段(可读可执行,不可写)的DCQ可以用来声明数据。生成的垃圾指令无法被IDA正常解析也无法被xcode识别执行。结合其他指令可以用来做代码混淆。

B指令+DCQ

第一段我们已经知道了DCQ是什么,并且可以用内联汇编构造出DCQ。但是DCQ本质上是一段数据(指令),能被正常解析成指令的话,运行时会产生不可预知的效果,不能被解析成指令的话,cpu直接抛出异常。

如何能构造出DCQ又能让程序正常运行呢? 可以用B指令,“跨过”那两条不能被正常执行的指令。这样DCQ迷惑了反汇编器,B指令又跨过了这些错误的指令。

用内联汇编怎么写B指令呢?

来看一下ARM文档B指令

B

Branch causes an unconditional branch to a label at a PC-relative offset, with a hint that this is not a subroutine call or return.

蹩脚翻译:

  1. B是无条件跳转,那啥是有条件?请君自学
  2. B是相对跳转,既然相对了,那么参照物是啥? PC-relative .
  3. B跳转不是调用子函数,所以没return。意思是不像BL,跳过去会把LR变了。

写给菜鸟,大佬跳过:

PC是program counter,程序计数器。每条指令执行完会+1,增加一个单位,也就是四字节。

众所周知操作系统加载程序会带上ASLR,也就是说每次程序加载地址都不一样,同样一段代码每次执行的PC值都不一样。但这并不会影响到B这种相对地址跳转。我们只要把相对地址固定好就行。

0x100000000 b 0xc # 当前PC = 0x100000000, 所以B的目的地址 = 0x100000000 + 0xc 也就是直接跳到C那里
0x100000004 A
0x100000008 B
0x10000000c C
0x100000010 D
0x100000014 E

所以用B跨过了那些迷惑反汇编器的指令。下边代码可以被插入到程序的任何地方。因为仅仅是多了一条b,没有对寄存器的占用,所以不会对程序逻辑产生任何影响。B之间的0x12345678可以被任意替换,填充的长度也可以被任意替换。B后边的操作数 = (填充代码的长度+1) * 4 。比如下边这条,填充了两条,所以B后边的操作数就是(2 + 1)* 4 = 12也就是0xc。

static __attribute__((always_inline)) void B_DCQ_Demo() {
#ifdef __arm64__
    __asm__(
            "b 0xc\n"
            ".long 12345678\n"
            ".long 12345678\n"
            );
#endif
}

再看IDA

细心的你也许会发现,同样是两条.long ,为啥图一后边的代码都是DCQ,而这张图上却仅仅有两条呢?以下是我的猜测。

反汇编器的扫描策略大致可以分为两种:线性扫描和递归下降扫描(flow-oriented) 。线性扫描当然很好理解,就是逐条的扫。但是线性扫描对指令长度是有要求的,固定长度的扫下来才不会出错(说实话我觉得对于ARM64这种4指令固定长度的,线性扫描真的挺合适的) 。 Intel x86指令是变长的,ARM32也能切到16bit的thumb模式,上述平台线性扫描就不适用。就像考试抄袭学霸的答题卡,一旦抄错位一个,后边的就都错了。

所以主流的反汇编器都使用flow-oriented的扫描模式。所以我们可以先看一下图二,反汇编进行到B指令,程序流产生了分叉,所以IDA选择从B的目的地址接着扫。最后中间被跨过去的部分就没被扫到,也就比较原始的形式留在那里了。那么图一呢? 扫到了错误的指令之后,IDA直接停止了对当前流的扫描,直接返回到上个分叉的地方继续扫,那么上个分叉的地方是哪里? 如果猜测正确的话,上个分叉的地方是下一个函数。

以上仅为猜测,也不是本文重点,希望不要误导。当然技多不压身,多了解一点反编译原理更有助于我们写出对抗代码。

虚假控制流+DCQ

DCQ迷惑了IDA但运行时会让程序崩溃, B跨过去可以规避问题,但却让迷惑效果打折。除了B有没有更好的办法? 当然有,把DCQ放到永远都不会被执行的分支里。也就是所谓的虚假控制流BogonControlFlow。

先来看一下如何构造虚假控制流。构造虚假控制流是一个非常常用的技巧。有各种各样花式技巧。构造虚假控制流最需要考虑的是,构造出来的谓词不要被聪明的编译器给优化掉(比如常量折叠之类的)。 举几个常见的例子,x是整数 (x+1)(x+2) % 2 一定等于 0,同理 (x+1)(x+2)(x+3) % 3也一定等于0。把上述一定成立的结论取个反,就成了恒为假的条件。

下面示例用勾股数来构造一个虚假控制流。勾三股四弦五,那咱搞个弦六,够虚假了吧。开方运算sqrt在math.h里,不会被编译器优化。代码里的变量名比较随便,如果想在生产写这种代码,记得把abc换成不容易撞车的变量名。

static __attribute__((always_inline)) void BogonControlFlow_DCQ_Demo() {
#ifdef __arm64__
    int a = 3;
    int b = 4;
    int c = 6;
    if (c < sqrt(a*a+b*b)) {
        __asm__(
                ".long 12345678\n"
                ".long 12345678\n"
                );
    }
#endif
}

把脏指令扔到了虚假控制流里,迷惑了IDA,左侧的函数列表里甚至都看不到viewDidLoad的函数名了。右侧的汇编页面也变红了,没法F5了。

B+虚假控制流+DCQ

有了虚假控制流和DCQ的加持,可以构造出混淆case了。在虚假控制流里,可以随意折腾任何指令。这次把B指令也加上,直接B到脏指令上。虽然IDA看起来与上文无异,但是我们可以把这个case拓展一下,变成另外一种混淆即堆栈不平衡。

static __attribute__((always_inline)) void B_BogonControlFlow_DCQ_Demo() {
#ifdef __arm64__
    int a = 3;
    int b = 4;
    int c = 6;
    if (c < sqrt(a*a+b*b)) {
        __asm__(
                "b 0x4\n"
                ".long 12345678\n"
                );
    }
#endif
}

B+虚假控制流+堆栈不平衡

static __attribute__((always_inline)) void B_BogonControlFlow_ADD_SP() {
#ifdef __arm64__
    int a = 3;
    int b = 4;
    int c = 6;
    if (c < sqrt(a*a+b*b)) {
        __asm__(
                "b 0x4\n"
                "add sp,sp,#0x100\n"
                "add sp,sp,#0x100\n"
                );
    }
#endif
}

程序运行在栈上,栈从上往下生长(满递减,高地址向低地址生长。表述不同,其实都一个意思)。所以开辟空间就是减sub sp sp 0x1234, 回收空间就是加add sp sp 0x1234.开辟和回收的空间一定相等。如果不相等会怎样? 上边在虚假控制流里把sp加了一些,所以IDA分析的时候,直接导致了堆栈不平衡,没法F5了。

用BR实现间接跳转

核心思想:把要跳转的地址藏到BR后边的寄存器里。因为IDA是静态反汇编器。反汇编过程中不会计算

先看官方解释

BR

Branch to Register branches unconditionally to an address in a register, with a hint that this is not a subroutine return.

直接上代码,把要跳转的地址藏到寄存器里。静态分析无法获取寄存器的运行时的值,所以会让分析停下来。

最关键的是,如何能在br之前获取到紧接着br的一条地址。同样先用地址无关的ADR指令,把紧接着br指令的地址算出来,并把地址“藏”到x8寄存器里,直接用br跳过去。这样就实现了最简单的间接跳转。

static __attribute__((always_inline)) void BR_2_X8() {
#ifdef __arm64__
    __asm__(
            "mov x8,#0x1\n"
            "adr x9, #0x10\n"
            "mul x8, x9, x8\n"
            ".long 0x12345678\n"
            "br x8\n"
            );
#endif
}

RET TO SELF

这是一个比较有趣的技巧。我把它命名成ret to self。前文已经说过IDA是面向流的扫描方式,所以如果程序里如果不出现任何流(也就是不出现任何跳转指令B,BR,BL,BLR等)。那么IDA会一直线性扫描到函数结尾。换句话说,我们构造一种case,让IDA线性的扫描到ret以为函数已经结束。

直接看代码吧。第一条,用adr计算出了紧跟着ret指令后一条的pc地址。第二条,把这个地址放到x30寄存器里。为什么要这么做?

static __attribute__((always_inline)) void RET_2_SELF() {
#ifdef __arm64__    
    __asm__(           
         "adr x8,#0xc\n"           
         "mov x30,x8\n"            
         "ret\n" );
#endif
}

来看一下RET指令

RET

Return from subroutine branches unconditionally to an address in a register, with a hint that this is a subroutine return.

在a函数里调用了b,b在return的时候发生了什么? 当然是返回到a函数的调用处的下一条。调用处下一条的地址存在哪里?当然是LR寄存器里。LR寄存器是什么?当然是x30了。

所以ret指令有一条“等价”写法 ==> mov pc lr

再看上面的代码就很明显了,ret之后实际是跳到了自己后面继续执行。所以叫ret to self没毛病把。

再看IDA,成功被骗。IDA没扫到任何流,线性的撞到了ret上,所以以为函数已经结束了。F5之后得到一个空函数。

最后

谢谢收看。点个赞/star。

上述代码仅为基本型的Demo演示。可以灵活组合使用。切勿直接复制粘贴在生产环境使用。寄存器污染了,程序崩溃了,是会被开除的!

12 个赞

涨知识了 :grinning:

这种方式我之前也实践过,当时主要想用来保护解释器,这种花指令的混淆好像强c就能让ida继续解析,arm指令长度固定所以这里可能存在缺陷。不知道大佬有没有其他方面的想法。最近在做基于AST的源码混淆方案这块也想做进去

1 个赞

没被解析的DCQ可以C,但是函数开头结尾定位不准确的C不了即使能C也没法F5,br间接跳转只能靠动态trace。还有虚假控制流里的那些,需要精确地识别然后nop才算过掉。

1 个赞

这是我之前看到的x86平台上的anti-disa的一些技巧。 主要利用的变长指令特点。
anti-disas.pdf (585.9 KB)

ARM64上的比较少,网上我只搜到一篇,用的是PLD(preload)指令。我感觉用处不大。

这里面的很多思路最早期版本的私有光用过,比如说花 + BCF.

总的来说就是,对抗效果小于编译器侧实现的维护成本

1 个赞

ollvm、光、Armariris 的混淆都是基于IR 层实现的,基于 AST 的混淆,能做到语言无关吗?还是只针对特定语言混淆?

哪里做个人感觉都一样,JS也有很多基于AST的混淆方案,看相做到什么程度,背景是我们自己有SDK需要做加固,早期我们也用的OLLVM,但是app需要支持bitcode的时候就出现问题了,并且业务方必须要开启bitcode,找了很多思路去做bitcode的支持,有从苹果下载开源的clang版本拿来编,但是那个版本太老了只支持到ios9,10以后的很多新特性没法支持,后来考虑用vm的方式,解释器用苹果的clang编译,但是这种方式问题也比较多,最大的就是稳定性问题和对浮点计算不支持,使用非常不友好。后来也尝试过用苹果开源的llvm去尝试看能不能适配苹果的clang,但是都失败了,提交到appstore会被打回来。再结合上ollvm这种模式在前端开发上不是很友好比较难推广,比如说对于一般的开发者而言由于丢掉了中间过程,不熟悉的同学定位问题就会比较困难,需要找上下文动态调试去做crash问题定位,对于一些短函数没法支持(basicblock只有一块),还有就是包体积膨胀的问题 最早我也是从这种改源码+内联的方式做的 花指令,平坦化,虚假控制流的 所以后来就想从AST或者IR着手去做一些简单的混淆 由于clang的存在,AST其实是语法无关的,需要适配的就是一些复杂功能需要适配上层语言特性,内联汇编的话还要考虑架构 开发的时候比较不友好,目前还在探索阶段,但是字符串混淆,控制流平坦化,虚假控制流这些操作都是可以实现的

原理老哥也遇到了bitcode支持的问题,我之前也是用ollvm 去加固sdk,加固完集成sdk的app不能开启bitcode了,之前调研过一些加固厂商,好像只有顶象加固完可以支持bitcode,不知道他们怎么做的 。

如果不用高级API的话可以用https://github.com/apple-oss-distributions/clang/tree/clang-800.0.42.1 这个,苹果开源的clang,两个bitcode是一致且向后兼容的

可以请教下张总他们,他们应该研究比我多,我只是兼职做这个TAT

说的是这个吧,https://github.com/javascript-obfuscator/javascript-obfuscator
真羡慕脚本语言,纯字符串解释执行还能玩出来这么多花活来。。

1 个赞

之前打CTF,web题经常搞一些花活在里面,比如利用这种隐藏字符翻转字符串,骚得很。

2 个赞

当然不能, 因为AST本身就是语言相关的

是可以上线的。 整套流水线里有至少三处标记可以被苹果拿来识别是不是用的Apple原版编译器,可以研究一下是不是哪里没Patch完整

2 个赞

楼主您好,请问:在“ 用BR实现间接跳转”章节中,汇编代码 mul x8, x9, x8 表达的是什么意思?
我查了 ARM 的手册了解到“ mul”指的是乘法,例子上应该是 x8 = x9 * x8,我不理解的是为什么需要“x9 需要乘 x8”?在计算乘法的过程中 x8 应该是 0x1,这样做乘法有意义吗?或者说目的是为了混淆?

论坛上的一个例子,随意贴过来的,希望不要误解。
:grinning_face_with_smiling_eyes: 理解原理可以随意发挥。

2 个赞

有一种可能是不能用mov x8,x9这样的指令,猜测哈,好像是内联汇编对寄存器操作有限制

1 个赞

你好张哥,有事想咨询一下您,您无法私信请问怎么能联系到您

这也太牛逼了吧。