指令修改与指令删除(基于 AArch64)

近况

呜呼!天气也是越来越冷了,不知不觉也快到年末了。我在回顾一些技术分享的时候,刚好看到几个大厂们常“玩”的概念,指令修改指令擦除。 比如抖音基础技术里面就有介绍到,抖音在进行一些性能优化的时候,通过一些手段进行了指令集的修改,从而达到了一些优化的目的。

image.png

针对架构指令级别的“核武器”

在认识指令修改与指令擦除之前,我们来一段非常简单的C代码:

void callee(int num){
    __android_log_print(ANDROID_LOG_ERROR, "mooner", "%s %d", " 我是callee num is",num);
}

void caller(){
    callee(1);
}

当我调用caller的时候,会传递一个数值1作为参数调用callee函数,从而打印了本次的一个数值。这段代码即是没有学过C语言的同学,应该都能够看懂。

那么我们来反思一下,这一串代码,背后的逻辑是什么?我们都知道Android可以运行在ARM架构与X86等架构平台之上,手机上CPU也基本是ARM架构。那么CPU会认识C语言吗?显然不会,它不认识Java,也不认识C语言。因此运行在CPU上的,一定是CPU认识的指令集。ARM架构中有很多架构分类,每个架构也有着其运行的状态,比如ARMv8 上面有着64位运行状态AArch64 与 32位运行状态AArch32

image.png

通常,我们会在APP项目中gradle配置支持的架构,其实意义就在这里

image.png

当然,现在市面上大部分的手机CPU架构,基本都是基于ARMv8架构,运行在64位运行状态,即我们写的C语言或者Android本身的so,会被翻译成符合A64的指令集。

那么,我们写的caller函数,会被编译器翻译成符合A64指令集的汇编代码,我们可以通过objdump这个ndk工具,帮助我们去查看。

objdump -d xxx.so 

objdump工具包伴随着NDK的下载就带有

image.png

我们可以通过把caller函数打包成so,最后查看编译的指令集如下:

image.png

需要注意的是,不同的指令集中,生成的指令代码也不一样,比如上图抖音ppt中,就是thumb指令集,即满足aarch32状态的指令集。而我们的是满足aarch64状态的64位指令集,也符合大部分手机。

那么怎么看汇编代码呢?首先我们要建立一个前提,上面一行代码对应着指令,指令由操作码+若干的操作数组成

image.png

比如常见的stp指令,其实就是把两个寄存器异常压入操作数中所给的地址中,比如

image.png

在aarch64状态中X代表着一个64位寄存器,W也代表着同一个寄存器,只是它只有32位被使用。在A64中,一共有31个通用寄存器,他们会根据一个调用规范(AAPSC64),各司其职。

image.png

理解了这些基本概念之后,大家就可以去找ARM的文档,查看各个指令的含义了,下面我们来简单解析我们的caller函数的汇编代码的含义

解析caller函数汇编代码

image.png

  1. stp x29,x30 [sp,#-16]! :这条指令的操作码是stp,含义就是存储x29,x30 的内容到sp-16(压栈)的地方(这里面!代表着先计算,有兴趣的小伙伴可以多了解一下A64的调用,我这里就不再细节讲了)

  2. mov x29,sp:就是把当前sp的值赋值给x29,即FP寄存器,这样我们后续做FP回溯拿堆栈的时候,就会带上了,这也是为什么aarch64状态能够用fp回溯,而aarch32默认不行的原因了(默认可以不遵守fp寄存器的调用规范)

  3. mov w0,#0x1 :接着就是把1这个是数,赋值给w0寄存器(x0寄存器同一个,只是它使用了32位),还记得我们调用callee函数吗,传递的数值正是1,又根据上图我们看到的AAPSC64调用规范,第一个参数是不是由X0(W0)寄存器负责存放呀?没错,这里是不是就对上了。

  4. bl 6460callee@plt :这里就是跳转到了callee函数的got表地址,callee函数会通过got/plt那一套解析,最终在got表找到真正的callee函数(got/plt hook的知识有说到噢)

  5. ldp x29,x30,[sp],#16 :bl指令会把下一条指令PC值保存到LR寄存器中;即执行完callee@plt会回到下一条指令,下一条就是ldp这一条指令。这里就是跟stp一一对应,把sp的数值依次赋值给x29与x30,然后sp = sp+16,其实就是函数结束了,要把栈空间给腾回来。 6.ret :结束调用,其实相当于 b LR 寄存器这个指令一样,跳转到LR寄存器的内容地址以执行下一条指令

至此,我们就结束了整个函数的汇编代码分析,当然,里面涉及很多细节我们还是没有说的,大家可以多参考官网指令集进行更多了解,见AArch64官网

指令修改

上文中,我们针对caller函数的汇编代码进行了分析,下面我们来进行指令修。我们不改动caller函数源代码情况下,把传入callee的参数从1变成2,这究竟要怎么做呢?

很多情况下,我们都不能修改到源代码,因此这个例子是有实际工程意义的,比如修改某个参数为固定某个值避免crash等等,在性能优化中都会用到。

我们从上文分析了caller函数的汇编代码,我们发现,caller调用callee函数进行参数赋值时,整数通过W0寄存器进行复制的,内容就是1。

void callee(int num){
    __android_log_print(ANDROID_LOG_ERROR, "mooner", "%s %d", " 我是callee num is",num);
}

如果我们能够修改这个参数为2,是不是就实现了我们的目的!我们只需要把mov w0,#0x1 变成 mov w0,#0x2 即可,前面的指令保持不变。

那么我们怎么应用呢?一个函数的指针,所指向的内容,就是具体的汇编指令,指令以16进行的形式存储,我们看看修改mov指令后的16进程代码是什么,然后赋值过去就可以了,这里有一个小工具,大家可以编写汇编代码然后转换为16进制

汇编转换16进制

使用后我们就拿到了符合ARM64的指令码,这个时候我们只需要替换前面12字节的数据就可以了

替换前:

image.png

image.png

替换后:

image.png

替换的时候,我们可以通过memcpy的方式,把我们的汇编代码替换原本的汇编代码,值得注意的是,默认代码段是不可写的,因此我们需要调用mprotect赋予可读可写权限(按照页为单位),同时修改完成之后,还别忘了清除指令缓存

image.png

这个时候我们调用以下代码,在手机中可以看到:

caller();
hook();
caller();

很棒,调用callee的函数输入就变成了2,符合我们的预期。

图片

这个就是指令修改的一个小实现,实际情况下,我们可以通过这种方式,去修改我们想要的指令。inline hook 相关其实也是指令修改的实现之一,只不过它属于嵌入跳转指令。修改指令的前提是我们要对指令集足够了解。

指令删除

学习完指令修改之后,我们学习指令删除就比较轻松了。比如我们想要调用caller函数的时候,不去调用callee函数,那么我们怎么实现呢?

还是回到caller函数的汇编代码,我们看到跳转callee函数的代码,其实是通过bl 6460callee@plt 的方式去实现的,因此这个案例中,想要不去执行callee函数,我们把bl指令删除即可。当然,这里需要注意的是,因为调用callee函数是没有副作用的,即没有修改到其他依赖,因此我们可以直接把这条指令删除,实际情况下,我们还要考虑多种情况,比如bl指令下一条指令是否收到影响等等。

这里比较简单,我们只需要删除bl指令或者在bl指令替换成NOP指令,就能够完成我们的目的了,同样的,我们写上汇编代码,只需要替换bl指令为NOP指令(1F2003D5)。

void hook(){
    uintptr_t pv = (uintptr_t)caller;
    uintptr_t pu = (pv | (PAGE_SIZE - 1)) + 1u;
    uintptr_t pd = (pv & ~(PAGE_SIZE - 1));
    mprotect((void *) pd, pv + 8u >= pu ? PAGE_SIZE * 2u : PAGE_SIZE,
             PROT_READ | PROT_WRITE | PROT_EXEC);

    memcpy((void *const) caller, "\xFD\x7B\xBF\xA9\xFD\x03\x00\x91\x20\x00\x80\x52\x1F\x20\x03\xD5\xFD\x7B\xC1\xA8\xC0\x03\x5F\xD6", 24);
    __builtin___clear_cache((void *)PAGE_START(pv), (void *)PAGE_END(pv));
}

调用hook函数:

caller();
hook();
caller();

__android_log_print(ANDROID_LOG_ERROR, "mooner", "%s", " caller 完成");

图片

当然,本例子中可以这么简单,因为caller和callee函数足够简单,实际复杂任务上,还有考虑进行指令的补齐等等操作,我们可以具体案例具体分析~

总结

通过本文,我们了解到了指令修改与指令删除的基本实现,这些方式在性能优化中或者解决一些疑难杂症时有着奇效,因此也出现在很多国内大厂的方案中。我们了解了这些之后,才能更好的读懂分享出来的方案,从而落地到自己项目当中!

作者:Pika
链接:https://juejin.cn/post/7315269336427085865
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值