Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

One


Introduction


A long time ago, I saw a post that used the Frida API.

Process.setExceptionHandler(callback)

to implement memory read/write breakpoints. However, since the mprotect function can only modify the memory attributes of an entire page, it is not very useful for specific addresses. I had difficulty finding the core code while reverse engineering games, so I tried to implement this functionality.

The entire script does not involve physical memory and the kernel, relying solely on ARM instruction characteristics.

Two


Theoretical Preparation


1 Frida setExceptionHandler API

This function has an exception type ‘type’, the address where the exception occurred ‘memory’, and ‘context’, which records the data of all current registers. Most importantly, it allows us to modify register data and memory.

Please refer to the Frida documentation for more details.

2 ARMv8 Registers

The ARMv8 architecture has general-purpose registers X0-X31, floating-point/SIMD registers V0-V31, and the program counter PC. X29, X30, and X31 have special meanings.

X29 / fp Frame Pointer
X30 / lr Link Register
X31 / sp Stack Pointer

3 A64 Calling Conventions

Parameters 1-8 are saved in registers X0~X7, while the remaining parameters are pushed onto the stack from right to left. The callee maintains stack balance, and the return value is stored in X0.

During BL or BLR, the address of the next instruction is placed in the lr register before jumping to the target address.

Then, the callee needs to save fp and lr onto the stack and retrieve them before the RET instruction.

4 ARMv8

The ARMv8 architecture has two instruction sets, AArch64 and AArch32.

In AArch64 operational mode, the A64 instruction set is used, and in AArch32 operational mode, the A32 instruction set is used.

The A64 instruction set and the A32 instruction set are incompatible; they are two completely different instruction sets with different instruction encodings.

The instruction width of the A64 instruction set is 32 bits. The read/write breakpoint functionality implemented in this script only works under A64.

5 Load/Store Instructions

All information below is sourced from:

ARMv8 Architecture Reference Manual

Memory access in ARM architecture is based on Load/Store instructions; unlike X86, not every instruction can directly access memory, for example:

mov eax,[test.exe+0x5b5c2]

Memory can only be accessed and modified through Load/Store series instructions.

LDR  X0,[SP,#0x10]

Therefore, to implement read/write breakpoints, we only need to focus on the Load/Store series of instructions.

6 Load/Store Addressing Modes

There are only 5 addressing modes under Load/Store, as detailed in section C1.3.3 of the reference manual.

Base Addressing

Directly read data from the address of the register.

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

Base Offset Addressing

The access address is obtained by adding an offset to the base address in the register; this instruction does not change X1.

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

Pre-indexed Addressing

The access address is obtained by adding an offset to the base address in the register; this instruction first assigns a value to SP and then assigns a value to that address, usually used in stack operations.

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

Post-indexed Addressing

Data is obtained first, and then the value of SP is updated, usually used in pop operations.

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

PC-relative Addressing

The original text is as follows:

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.

Essentially, the instruction’s address plus a signed #imm19 offset means the addressing range is ±1MB.

7 Load/Store Instructions with General Purpose and Floating-Point/SIMD Registers

Using LDR (immediate) with Unsigned offset as an example.

For general-purpose registers:

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

For SIMD registers:

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

As can be seen, the first 0-21 bits of the two instructions are consistent; the first LDR determines the value size as 32 or 64 bits. The latter LDR(SIMD) uses size and opc to determine the value size as 8, 16, 32, 64, or 128 bits. The distinction of whether Rt is a general-purpose or floating-point register is only in the 26th bit.

Three


Building Code


1 Loading and Storing

Based on the theoretical knowledge above, imagine that for Load and Store, except for PC-relative addressing, all other addressing modes are register indirect addressing. In other words, as long as the value in the register remains unchanged, the result of loading and storing with that instruction in any memory location is consistent, including floating-point/SIMD registers.

2 Regarding PC-relative Addressing

In the reference manual section C4.1.4 for Load register (literal)Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64it can be seen that there are only three Load series instructions using PC-relative addressing in the entire A64, LDR(literal), LDRSW(literal), and PRFM(literal). There are no Store series instructions.

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

For these three instructions, only opc (representing value size) and V (register type) are inconsistent, while imm19 and Rt are variable.

It is straightforward to separate these three instructions.

// Get current code
var thiscode=ptr(details.context.pc).readU32()
// Remove the last 24 bits, then only need the values of 24, 25, 27, 28, 29, which is 0x3b
 var opcode=((thiscode>>24)&0x3b)
// Check if opcode equals 0x18
if(opcode==24){

This separates PC-relative addressing from other Load instructions.

If it is PC-relative addressing, the data to be read should be stored in a pre-prepared memory area (preferably 128 bits to prevent loading a 128-bit floating-point number).

// mempoint is details.memory.address
// Directly read the data inside as a pointer
mycode.add(0x128).writePointer(ptr(mempoint.readPointer()))
mycode.add(0x130).writePointer(ptr(mempoint.add(8).readPointer()))

3 Code Editing

Remember setExceptionHandler? It can directly modify register data, including PC — just paste the code, it’s not difficult.

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)

    // Build 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

    // Put point1, 0x1000, pattern into mycode + 0x100
    mycode.add(0x100).writePointer(ptr(point1))
    mycode.add(0x108).writeU64(0x1000)
    mycode.add(0x110).writeU64(pattern)
    // Store mprotect function at 0x118
    mycode.add(0x118).writePointer(ptr(mprotect))
    // Modify target memory page attributes
    mprotect(ptr(point1),0x1000,pattern)
   
    Process.setExceptionHandler(function(details){
        if(details.type.indexOf("access-violation") >= 0){
            var mempoint=ptr(details.memory.address)
            // Check if the exception is caused by our modified memory
            if(point1<=mempoint&&mempoint<point1+0x1000){
                // Check if we hit the address we care about
                var off=ptr(mempoint).sub(addr)
                if(off>=0&&off<size){
                    console.warn("Hit :" ,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")  
                    // Restore memory page attributes
                    mprotect(ptr(point1),0x1000,7)

                    // Write the next code address into mycode + 0x120 as the return address
                    mycode.add(0x120).writePointer(ptr(details.context.pc).add(4))

                    var thiscode=ptr(details.context.pc).readU32()
                    var opcode=((thiscode>>24)&amp;0x3b)
                    // Three common bits for PC-relative addressing
                    if(opcode==24){
                        // Store the data to be read into 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&amp;0xFF00001F)|0x940
                        mycode.writeU32(n_code)
                        details.context.pc = mycode
                    }else{
                       // Write current code into mycode
                        mycode.writeU32(ptr(details.context.pc).readU32())
                        // Directly modify pc
                        details.context.pc = mycode
                    }
                    return true
                }
            }
            return false
        }
        return false
    })
}

The code is quite small and very simple.

Let me explain some potentially confusing points:

1. Why directly modify PC?

A more scientific method would be to write mycode into x16 and change the instruction to BLR X16. However, this approach has issues because the memory page is accessed from multiple locations, and the same instruction will access different addresses due to different register data. Since we modify that code only after capturing an exception each time, the next execution jump may not execute the original code. This issue can also be resolved by reverting it in our code.

2. Can I use Interceptor.attach(mycode + 4)?

This is feasible; the specific idea is to first place four nops and a ret in mycode + 4, and then in onEnter: function(args) revert the memory page. Before modifying PC, we also modify lr. After Interceptor.attach, the memory page attributes of mycode will change to ‘r-x’, and we need to modify it back again.

The reason I didn’t do this is twofold: 1. Filtering out memory we don’t care about will cause multiple entries into the JS virtual machine, making it slower. 2. Our goal is not to target a specific function but specific instructions, so we shouldn’t modify any register values. The attach itself will jump to frida.so using BLR X16, and for the original X16, frida discards it directly.

3. SUB SP, SP, #0x250

Some functions use negative offsets from SP to store data for stack protection design.

4. Why only save X0, X1, X2, X3, X8, X29?

All registers should be saved, but tests found that only X8 was modified after calling mprotect, and X30 will inevitably be modified in the end, so I didn’t bother saving it.

Four


Testing


First, use CE to locate the addresses we care about.

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Then call readwritebreak.

Parameter 1 is the address, parameter 2 is the length, and parameter 3 is the attributes to modify (1 for read, 2 for write).

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

After multiple exception captures, we finally matched the addresses we care about.

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Five


Conclusion


As can be seen, from triggering an exception to entering the written code and finally returning, only the lr register was modified. As long as it meets A64 calling conventions, modifying it does not affect the program’s operation. However, if there is some outrageous code using lr as a local variable, or if the code’s functionality involves PC hijacking, it may crash, haha.

There hasn’t been extensive testing, and there may still be bugs I haven’t considered. If experts have time, feel free to test it out.

There are also some questions I still don’t know. Is there anyone who knows? 1. How does Frida change the value of PC? I remember that ARM does not support direct modification of PC. 2. Does the A64 instruction include the prefetch instruction PRFM, and will this instruction trigger an exception?

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Kanxue ID: yezheyu

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

*This article is an excellent post from the Kanxue forum, authored by yezheyu. Please acknowledge the source when reprinting from the Kanxue community.
Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

# Previous Recommendations

1. Introduction to IOFILE exploit

2. Front-end experience before entering compilation principles

3. How to reverse the encryption of Himalaya xm files purely by guessing (wasm part)

4. How the Anti-Malware Scan Interface (AMSI) helps you defend against malware

5. sRDI — Shellcode Reflective DLL Injection Technique

6. Detection of APP and parameter calculation analysis

Simple Implementation of Memory Read/Write Breakpoints Using Frida on A64

Leave a Comment