Peeling Back the Layers of an Onion - Syscalls & ASM
Recently I wrote a blog post on a mobile application I was testing. The application implemented a protection that disallowed the user from accessing the main login and dashboard of the app. I racked my brain against the problem over a weekend and wrote two demo applications to try and replicate the protection I saw.
For one I noticed a few inconsistencies with this application. I got mixed results when I searched for the supervisor call using the /ask
command in r2frida. Sometimes the output would point to the app’s SVC instructions and other times I would get zero results back. When I did get results back it timed out the application and I would not get any results from my r2pipe script.
To replicate this issue, I decided to write an iOS application in a mix of Swift and Assembly using the access
system call. To understand what the access system call does read more here. At a basic level, it does the following:
Checks whether the calling process can access the file pathname.
What I decided to implement in the ASM code was to check known paths and binaries on a jailbroken device. These paths would not be present on a jailed device. Typically in C, our code would look something like the following:
[TRUNCATED]
int fd = access("/bin/bash", F_OK);
[TRUNCATED]
Then in ASM to call the access
syscall would look something like this:
[TRUNCATED]
mov x16, #33 // SYS_ACCESS (access)
svc #0
[TRUNCATED]
When using a system call we use the MOV
instruction along with the syscall number and register x16.
x16 and x17 - Intra-procedural Call Registers. Temporary registers for immediate values. They are also used for indirect function calls and PLT (Procedure Linkage Table) stubs. x16 is used as the system call number for the svc instruction in macOS.
Our system call number for access is 33 or #33 in our assembly code. You can reference the syscall header file but in r2frida you can search using the ask
command. I will talk about this later in approach 2.
[0x100dfa434]> ask? | grep access
0x80.284=access_extended
0x80.33=access
0x80.466=faccessat
access=0x80,33,0,
access_extended=0x80,284,0,
faccessat=0x80,466,0,
Layer 0x1: Function Level Bypass
My first approach is to modify the Swift checkForJailbreak
function then the return value of check_jailbreak
. It is simple enough to use the same approach in my other blog post. The function in the original code will look like this:
func checkForJailbreak() -> Bool {
return check_jailbreak() != 0
}
And in r2frida like this:
[0x102fc6434]> :iE~+jail
[TRUNCATED]
0x10481c000 f check_jailbreak
or
[0x102fc6434]> :iE~+jail
[TRUNCATED]
0x10481d51c f $s9svcCaller11ContentViewV17checkForJailbreakSbyF
We can approach this bypass in two ways. First locating the address of the function and then dynamically tracing it. This will allow us to observe the return value of the function. As I had mentioned prior in the first section of this post the access system call will take a file path value and store that into a int var. This var will then contain either a 1
or 0
. We can confirm this based on the Retval
in the terminal output.
[0x102fc6434]> :iE~+jail
[TRUNCATED]
0x10481d51c f $s9svcCaller11ContentViewV17checkForJailbreakSbyF
[0x102fc6434]> :dtf 0x10481d51c
true
[0x102fc6434]> :dc
INFO: resumed spawned process
[0x102fc6434]> [dtf onLeave][Wed Aug 28 2024 22:31:45 GMT-0700] 0x10481d51c@0x10481d51c - args: . Retval: 0x1
Second, we will want to dynamically instrument the function’s value from 0x1
to 0x0
, as seen below using :di0
. This will be the same for both functions I called out above.
[0x102fc6434]> :di0 `:iE~+check_jailbreak[0]`
[0x102fc6434]> :dc
Layer 0x2: ASM Instruction Bypass
My third approach is to step back and search for the responsible system call and modify the underlying MOV instructions. We will want to set our config evals for the iOS binary. This is so we can explicitly search specific assembly instructions.
.:e/
Then using the following one-liner in r2frida we can search and grep out the supervisor calls.
[0x104bca434]> /ai svc | grep svc
0x104bc8088 010000d4 svc 0
Once we find the appropriate address of the supervisor call we will want to use pd
along with -50
. This will allow us to backtrace and look for the system call in the assembly code, plus the associated MOV
instructions which will move the value of 1
into x0
. You can see each of these commented in the text block below after the CBZ
instructions.
[0x104bca434]> pd -50 @ 0x104bc8088
[TRUNCATED]
;-- sym.check_jailbreak:
0x104bc8000 fd7bbfa9 stp x29, x30, [sp, -0x10]!
0x104bc8004 fd030091 mov x29, sp
0x104bc8008 a0040010 adr x0, sym.msg_start ; 0x104bc809c
0x104bc800c 52130094 bl 0x104bccd54
0x104bc8010 80060010 adr x0, sym.paths ; 0x104bc80e0
0x104bc8014 610180d2 mov x1, 0xb
0x104bc8018 0a000094 bl sym.check_paths
0x104bc801c a0040050 adr x0, sym.msg_end ; 0x104bc80b2
0x104bc8020 4d130094 bl 0x104bccd54
┌─< 0x104bc8024 800000b4 cbz x0, sym.no_jailbreak
│ 0x104bc8028 200080d2 mov x0, 1 //instruction one
│ ;-- hit0_40:
│ 0x104bc802c fd7bc1a8 ldp x29, x30, [sp], 0x10
│ 0x104bc8030 c0035fd6 ret
│ ;-- sym.no_jailbreak:
│ ;-- hit0_41:
└─> 0x104bc8034 000080d2 mov x0, 0
;-- hit0_42:
0x104bc8038 fd7bc1a8 ldp x29, x30, [sp], 0x10
0x104bc803c c0035fd6 ret
;-- sym.check_paths:
0x104bc8040 f457bfa9 stp x20, x21, [sp, -0x10]!
0x104bc8044 f40300aa mov x20, x0
0x104bc8048 f50301aa mov x21, x1
;-- sym.check_next_path:
┌─> 0x104bc804c 808640f8 ldr x0, [x20], 8 ; 0xee ; 238
┌──< 0x104bc8050 000100b4 cbz x0, sym.all_paths_checked
│╎ 0x104bc8054 a0030070 adr x0, sym.msg_check_path ; 0x104bc80cb
│╎ 0x104bc8058 3f130094 bl 0x104bccd54
│╎ 0x104bc805c 08000094 bl sym.file_exists
│└─< 0x104bc8060 60ffffb4 cbz x0, sym.check_next_path
│ 0x104bc8064 200080d2 mov x0, 1 //instruction two
│ ;-- hit0_43:
│ 0x104bc8068 f457c1a8 ldp x20, x21, [sp], 0x10
│ 0x104bc806c c0035fd6 ret
│ ;-- sym.all_paths_checked:
│ ;-- hit0_44:
└──> 0x104bc8070 000080d2 mov x0, 0
;-- hit0_45:
0x104bc8074 f457c1a8 ldp x20, x21, [sp], 0x10
0x104bc8078 c0035fd6 ret
;-- sym.file_exists:
0x104bc807c fd7bbfa9 stp x29, x30, [sp, -0x10]!
0x104bc8080 fd030091 mov x29, sp
0x104bc8084 300480d2 mov x16, 0x21 ; '!'
Additionally, we can grep the MOV
instructions associated with the access system call using the following one-liner.
[0x104bca434]> pd -50 @ 0x104bc8088 | grep 'mov x0, 1'
│ 0x104bc8028 200080d2 mov x0, 1
│ 0x104bc8064 200080d2 mov x0, 1
To bypass this check we want to change the value of each mov x0, 1
to mov x0, 0
.
wa mov x0, 0 @ 0x104bc8028; wa mov x0, 0 @ 0x104bc8064
Lastly, our script will look something like this:
# file checker test
import r2pipe
import time
r = r2pipe.open("frida://spawn/usb//n0ps.svcCaller")
r.cmd(".:e/")
print("[x] Calculating SVC addresses")
# locate supervisor call
svcInst = r.cmd("/ai svc | grep svc").split()
address = svcInst[0]
# modify mov x0, 1 to mov x0, 0
output = r.cmd("pd -50 @ " + address + " | grep 'mov x0, 1'").split()
print(output)
r.cmd("wa mov x0, 0 @ " + output[1])
r.cmd("wa mov x0, 0 @ " + output[7])
# continue application
r.cmd(":dc")
time.sleep(10000)
Conclusion
In this post I wanted to display an alternative method for searching assembly instructions at runtime. Back tracing from a supervisor call and attempting to locate the MOV
instructions associated with the access system call. If you would like to test this out for yourself you can download the IPA and script here.
Resources
I wanted to include at least a few articles for the reader. Each of the links below are important to understanding the libSystem.dylib
library and the associated system calls on macOS / iOS.
https://gpanders.com/blog/exploring-mach-o-part-1/
https://developer.arm.com/documentation/102374/0101/System-calls?lang=en
https://eclecticlight.co/?s=assembly
https://www.amazon.com/64-Bit-Assembly-Language-Larry-Pyeatt/dp/0128192216