使用frida在A64下内存读写断点的简单实现
2023-10-21 19:5:21 Author: mp.weixin.qq.com(查看原文) 阅读量:7 收藏


很早之前看过一篇帖子,使用frida的API。

Process.setExceptionHandler(callback)

实现内存读写断点,但是由于mprotect函数只能对一整页的内存属性修改,并不能具体到具体地址,所以使用意义不大。之前逆向游戏的时候很久都没有找到核心代码,尝试实现这个功能。

整个脚本不涉及物理内存和内核,只依靠arm指令特性实现。


前期理论准备

1 Frida setExceptionHandler API

此函数本身有着异常类型type,发生异常的地址memory,以及context,它记录了当前所有寄存器的数据,最关键的是它允许我们修改寄存器数据和内存。


具体说明请自行查看frida的文档。

2 ARMv8 的寄存器

armv8架构下有着通用寄存器X0-X31,浮点/SIMD 寄存器V0-V31,程序计数器PC。X29,X30,X31有着特殊含义。

X29 / fp 帧指针
X30 / lr 链接寄存器
X31 / sp 栈指针

3 A64调用约定

参数1-参数8 分别保存到 X0~X7 寄存器中 ,剩下的参数从右往左依次入栈,被调用者实现栈平衡,返回值存放在 X0 中。


在BL或BLR的时候,将下一条指令的地址放入lr寄存器中,然后再跳转到目的地址。


而后被调用方需要保存fp和lr在栈中,在RET指令前从栈里取出。

4 ARMv8

Armv8架构有着两种指令集,AArch64和AArch32。


在AArch64运行状态下使用A64指令集,在AArch32运行状态下使用A32指令集。


A64指令集和A32 指令集是不兼容的,它们是两套完全不一样的指令集,它们的指令编码也是不一样的。


其A64指令集的指令宽度是32位等长。本脚本的读写断点功能实现只在A64下。

5 Load/Store指令

以下资料都来自:

ARMv8 Architecture Reference Manual

arm架构下对于内存的访问都基于Load/Store指令,并不能像X86一样任何指令都能直接访问内存,如:

mov eax,[test.exe+0x5b5c2]

它只能通过Load/Store系列的指令来访问和修改内存。

LDR X0,[SP,#0x10]

所以我们要想实现读写断点,只需要关注Load/Store系列的指令。

6 Load/Store 寻址模式

Load/Store下的寻址模式只有5种,在参考手册的C1.3.3。

分别是:

基址寻址

通过寄存器的地址直接读取数据。

LDR X0 , [X1] // x0 = *x1;

基址偏移寻址

寄存器基址加上偏移得到访问的地址,该指令并不会使X1的变动。

LDR X0, [X1,#8] //X0=*(X1+8)

预变址寻址

寄存器基址加上偏移得到访问的地址,该指令会先为SP赋值,然后再对该地址赋值,一般用在入栈操作上。

STR X0, [SP,#-0x20] //sp=sp-0x20 , *(sp-0x20)=X0

后变址寻址

先取得数据,再将SP的值更新,一般用在出栈操作上。

LDR X0 ,[SP],#0x20 //x0=*sp sp=sp+0x20

pc偏移寻址

原文如下:

Literal addressing means that the address is the value of the 64-bit program counter for this instruction plus
a 19-bit signed word offset. This means that it is a 4 byte aligned address within ±1MB of the address of this
instruction with no offset. Literal addressing can only be used for loads of at least 32 bits and for prefetch
instructions. The PC cannot be referenced using any other addressing modes. The syntax for labels is specific
to individual toolchains.

大概意思是,该指令的地址加上一个有符号的#imm19的偏移量,寻址范围为±1MB。

7 Load/Store指令与通用寄存器和浮点/SIMD 寄存器

用LDR (immediate)的Unsigned offset举例。

这是对于通用寄存器的:


这是对于SIMD寄存器的:



可以看到,两个指令前面0-21位都是一致的,前面这个LDR以size来判断取值大小是32位还是64位。后面的LDR(SIMD)用size和opc来判断取值大小为8,16,32,64,128位。而区分Rt是通用寄存器或是浮点寄存器的只是第26位。


构建

1 加载和存储

通过以上的理论知识,想象一下,对于Load和Store,除开pc偏移寻址,其他的寻址模式都是寄存器间接寻址,换句话说,
这些指令只要寄存器里的值不变,该指令在内存的任何地方进行加载和存储的结果都是一致的,包括浮点/SIMD 寄存器。

2 对于pc偏移寻址

在参考手册上C4.1.4的Load register (literal)

可以看到,整个A64使用pc偏移寻址的只有3个Load系列的指令,LDR(literal),LDRSW(literal),PRFM(literal).没有Store系列的指令。


这三个指令,只有opc(代表取值大小),V(取值的寄存器类型)是不一致的。
而imm19与Rt都是在变化的。


分割出来这三个指令很简单。

//获取当前code
var thiscode=ptr(details.context.pc).readU32()
//去掉后面24位,然后只需要24,25,27,28,29的值即0x3b
var opcode=((thiscode>>24)&0x3b)
//判断opcode是不是等于0x18
if(opcode==24){

这样就分割开了pc偏移寻址和其他Load指令。

如果是pc偏移寻址,
将要读取的数据事先存入事先准备好的一块内存里(最好是128位数据防止加载的是128位浮点数)。

//mempoint即为details.memory.address
//直接把里面的数据当成指针来读
mycode.add(0x128).writePointer(ptr(mempoint.readPointer()))
mycode.add(0x130).writePointer(ptr(mempoint.add(8).readPointer()))

3 代码编辑

还记得setExceptionHandler吗?它可以直接修改寄存器数据,包括PC
直接粘代码吧,没有多难。

const malloc = new NativeFunction(Module.findExportByName('libc.so', 'malloc'), 'pointer', ['size_t']);
const memset = new NativeFunction(Module.findExportByName('libc.so', 'memset'), 'pointer', ['pointer', 'size_t', 'int']);
const mprotect = new NativeFunction(Module.findExportByName('libc.so', 'mprotect'), 'int', ['pointer', 'size_t', 'int']);
const free = new NativeFunction(Module.findExportByName('libc.so', 'free'), 'void', ['pointer']);
function readwritebreak(addr, size, pattern){

var point1= addr-(addr%0x1000)
console.log("set memcpy break : ",ptr(point1))

const mycode = malloc(0x1000)
mprotect(mycode,0x1000,7)
memset(mycode,0x1000,0)

//构建code
mycode.add(0x4).writeU32(0xD10943FF)//SUB SP ,SP ,#0x250
mycode.add(0x8).writeU32(0xA90077E8)//STP X8,X29,[SP]
mycode.add(0xc).writeU32(0xA90107E0)//STP X0 ,X1 ,[SP,#0x10]
mycode.add(0x10).writeU32(0xA9020FE2)//STP X2,X3,[SP,#0x20]
mycode.add(0x14).writeU32(0x58000760)//LDR X0 , [mycode,#0x100]
mycode.add(0x18).writeU32(0x58000781)//LDR X1 , [mycode,#0x108]
mycode.add(0x1C).writeU32(0x580007A2)//LDR X2 , [mycode,#0x110]
mycode.add(0x20).writeU32(0x580007C3)//LDR X3 , [mycode,#0x118]
mycode.add(0x24).writeU32(0xD63F0060)//BLR X3
mycode.add(0x28).writeU32(0xA9420FE2)//LDP X2, X3,[SP,#0x20]
mycode.add(0x2C).writeU32(0xA94107E0)//LDP X0, X1,[SP,#0x10]
mycode.add(0x30).writeU32(0xA94077E8)//LDP X8, X29,[SP]
mycode.add(0x34).writeU32(0x910943FF)//ADD SP, SP, #0x250
mycode.add(0x38).writeU32(0x5800075E)//LDR X30, [mycode,#0x120]
mycode.add(0x3C).writeU32(0xD65F03C0)//RET

//将point1,0x1000,pattern放入mycode+0x100
mycode.add(0x100).writePointer(ptr(point1))
mycode.add(0x108).writeU64(0x1000)
mycode.add(0x110).writeU64(pattern)
//mprotect函数存入0x118
mycode.add(0x118).writePointer(ptr(mprotect))
//修改目标内存页属性
mprotect(ptr(point1),0x1000,pattern)

Process.setExceptionHandler(function(details){
if(details.type.indexOf("access-violation") >= 0){
var mempoint=ptr(details.memory.address)
//判断是否是由自己修改内存导致的异常
if(point1<=mempoint&&mempoint<point1+0x1000){
//是否命中我们关心的地址
var off=ptr(mempoint).sub(addr)
if(off>=0&&off<size){
console.warn("命中 :" ,ptr(addr)," pc pointer : ",details.address)
var module = Process.findModuleByAddress(ptr(details.context.pc));
console.warn("pc - - > ",module.name," -> ",ptr(details.context.pc).sub(module.base))
mprotect(ptr(point1),0x1000,7)
free(mycode)
//console.error('RegisterNatives called from:\n' +Thread.backtrace(details.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
console.warn("readwritebreak exit")
return true

}else{
console.log(details.memory.operation,"exce ;mpoint :",mempoint,";pc -> ",ptr(details.context.pc),"; opcode :",ptr(ptr(details.context.pc).readU32()),"\n")
//将内存页属性改回来
mprotect(ptr(point1),0x1000,7)

//将下一个code地址写入mycode+0x120处作为返回地址
mycode.add(0x120).writePointer(ptr(details.context.pc).add(4))

var thiscode=ptr(details.context.pc).readU32()
var opcode=((thiscode>>24)&0x3b)
//三个pc偏移寻址共同位
if(opcode==24){
//将需要读取的数据存入mycode+0x128处
mycode.add(0x128).writePointer(ptr(mempoint.readPointer()))
mycode.add(0x130).writePointer(ptr(mempoint.add(8).readPointer()))
//LDR Rn #pc+0x128
var n_code=(thiscode&0xFF00001F)|0x940
mycode.writeU32(n_code)
details.context.pc = mycode
}else{
//将当前code写入mycode
mycode.writeU32(ptr(details.context.pc).readU32())
//直接修改pc
details.context.pc = mycode
}
return true
}
}
return false
}
return false
})
}

可以看到代码量并没有多少,也非常简单。


解释一些可能会疑惑的地方:

1,为什么直接修改pc

更科学的方法应该是将mycode写入x16,然后将该条指令修改成BLR X16
但是这样做是有问题的,因为该内存页不止一个地方在访问,而且一条一样的指令也会因为寄存器数据不同而去访问不一样的地址,而我们每次捕获异常之后才会改动该code,所以下次执行跳转后执行的不一定是原先的code,这个问题也有办法解决,在我们的code里把它再该回去就行。

2 能不能使用Interceptor.attach(mycode+4)

这样做是可行的,具体思路是先在mycode+4里按存顺序4个nop和一个ret,

然后在 onEnter: function(args)把内存页修改回去。在修改pc前把lr也修改了,Interceptor.attach完成后是会将mycode的内存页属性改为‘r-x’,我们要重新修改回去。


没这样做是有两个方向的考虑:
1,过滤我们不关心的内存本身是会多次进入js虚拟机里的,这样做一个code得两次进入js虚拟机,速度应该很感人。
2,我们的目的不是争对莫个函数,而是具体的指令,所以不应该修改任何寄存器的值,attach本身会使用BLR X16跳转到frida.so里,而对于原本的X16 frida是直接丢弃掉的。

3SUB SP ,SP ,#0x250

有的函数会使用SP的负偏移存储数据,为保护栈设计。

4 为什么只保存X0,X1,X2 ,X3 ,X8 ,X29

应该将所有的寄存器全部保存,但是测试发现只有X8在call mprotect后被修改了,而X30最后我们必然会改动,懒得保存。


测试

首先使用ce找到我们关心的地址。

然后readwritebreak。

参数1是地址,2是长度,3要修改的属性(1读,2写)。

通过多次异常捕获后最终匹配到我们关心的地址。


后记

可以看到从触发异常再进入编写的代码最后返回,只有lr寄存器被修改了,只要符合A64的调用约定,修改它并不影响程序的运行,但是假设有什么逆天的代码用lr做局部变量,或者该段代码实现功能是pc劫持,那就只能崩溃了哈哈。


并没有过多的测试,或许还有我没有考虑到的地方存在bug,大佬们有空拿去测试测试呗。


还有一些问题我自己都还不知道,有大佬知道吗?
1,frida是如何改变pc的值的?我记得arm中是不支持直接对pc进行修改的。
2,A64指令中存在预加载指令PRFM,该指令会不会触发异常啊?

看雪ID:yezheyu

https://bbs.kanxue.com/user-home-840122.htm

*本文为看雪论坛优秀文章,由 yezheyu 原创,转载请注明来自看雪社区

# 往期推荐

1、IOFILE exploit入门

2、入门编译原理之前端体验

3、如何用纯猜的方式逆向喜马拉雅xm文件加密(wasm部分)

4、反恶意软件扫描接口(AMSI)如何帮助您防御恶意软件

5、sRDI — Shellcode反射式DLL注入技术

6、对APP的检测以及参数计算分析

球分享

球点赞

球在看


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458524214&idx=2&sn=1db6e35b95cbe0807f948ca060baa8c9&chksm=b18d2abc86faa3aa432dfcfae2762d730d907d133abce04f972c9f30164d5c22f40a8c104c37&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh