Spoofing Objective-C return values on Apple Silicon using LLDB
TL;DR: This article describes how LLDB can be used to mutate return values of Objective-C methods and functions
In my previous article, we covered various techniques for statically exploring the vast amount of private frameworks that macOS ships with, most of them written in Objective-C.
After studying these private frameworks through their exported interfaces and through a disassembler, an interesting technique to validate your assumptions is to fire LLDB (the LLVM debugger that ships with Xcode), fiddle with these private functions, and see how your changes affect the program under test.
In this article, we will focus on how to mutate Objective-C function return values using LLDB. Most Apple systems are on Apple Silicon at this point, so we won’t bother with Intel. Also, while we won’t touch on it here, this technique is also applicable to Swift.
Finally, keep in mind that if you want to attach LLDB to applications written by Apple, you will need to disable SIP (System Integrity Protection). However, don’t do this on your main machine! The easiest secure alternative is to provision a macOS VM using Parallels.
The credit from this post goes to Wojciech Reguła, from whom I also learnt this trick.
A review of Apple Silicon procedure calls
Before getting into it, we need to understand some basics about how functions are executed on the Apple Silicon chips.
Apple Silicon is based on ARM. More specifically, A7 and later chips are based on the 64-bit ARMv8 CPU ISA (Instruction Set Architecture). The 64-bit ARM architecture is often referred to as AArch64 (Arm Architecture 64-bit).
Like most other 64-bit ARM chips, Apple Silicon follows the
ARM Architecture Procedure Call Standard for AArch64
(AAPCS64). The specification for this procedure call process is
conveniently hosted on GitHub.
The specification (see 6.1.1
General-purpose Registers) states that 64-bit ARM introduces
31 general-purpose registers of which x0
to
x7
“are used to pass argument values into a
subroutine and to return result values from a function.”
Note that the specification does not mandate which of these
registers must be used to hold result values. By convention, ARM
implementations (including Apple) typically use the
x0
register for this purpose.
Apple does make some specific choices on their use of registers (
x18
andx29
), however these choices are not relevant for the purpose of this article.
For 64-bit ARM, the x0
general-purpose register
is a 64-bit register. If the result value of a function fits in
the register, it may be directly written. Otherwise, the
x0
register may be a pointer to a memory location
where the result is stored.
Example 1: Spoofing a scalar value in a method
Consider the following example Objective-C program. It
defines an Operations
class with a
multiply
method that, as its name implies, performs
a multiplication operation on two integers. The program
instantiates the Operations
class and invokes
multiply
with arguments 5 and 3:
#import <Foundation/Foundation.h>
@interface Operations : NSObject
- (int)multiply:(int)value by:(int)multiplier;
@end
@implementation Operations
- (int)multiply:(int)value by:(int)multiplier {
return value * multiplier;
}
@end
int main() {
{
@autoreleasepool *operations = [[Operations alloc] init];
Operations (@"Result: %i", [operations multiply:5 by:3]);
NSLog}
return EXIT_SUCCESS;
}
The output of this program, as you would expect, is 15:
$ clang example-1.m -o example-1 -framework Foundation
$ ./example-1
2023-11-22 10:45:17.367 example-1[20563:100228] Result: 15
Let’s attempt to change the result of the
multiply
method to an arbitrary value, like 1.
First, we will open the program using LLDB:
$ lldb ./example-1
Breakpoint 1: no locations (pending).
Breakpoint set in dummy target, will get copied into future targets.
(lldb) target create "./example-1"
Current executable set to '/Users/jviotti/Projects/playground/example-1' (arm64).
We will set a breakpoint on the multiply
method
of the Operations
class:
(lldb) breakpoint set --name "-[Operations multiply:by:]"
Breakpoint 2: where = example-1`-[Operations multiply:by:], address = 0x0000000100003e6c
Then, let’s execute the program so that it stops at our breakpoint:
(lldb) run
Process 21632 launched: '/Users/jviotti/Projects/playground/example-1' (arm64)
Process 21632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
frame #0: 0x0000000100003e6c example-1`-[Operations multiply:by:]
example-1`-[Operations multiply:by:]:
-> 0x100003e6c <+0>: sub sp, sp, #0x20
0x100003e70 <+4>: str x0, [sp, #0x18]
0x100003e74 <+8>: str x1, [sp, #0x10]
0x100003e78 <+12>: str w2, [sp, #0xc]
Target 0: (example-1) stopped.
Once in the multiply
method, we can instruct
LLDB to finish executing the current stack frame and stop after
returning using the finish
command. This will put
us at the right spot to change the x0
register and
affect the caller of the method:
(lldb) finish
Process 21632 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step out
frame #0: 0x0000000100003ed8 example-1`main + 68
example-1`main:
-> 0x100003ed8 <+68>: mov x9, sp
0x100003edc <+72>: mov x8, x0
0x100003ee0 <+76>: str x8, [x9]
0x100003ee4 <+80>: adrp x0, 1
Target 0: (example-1) stopped.
We can confirm that the x0
register holds the
expected return value, the integer 15, using
register read
:
(lldb) register read --format decimal $x0
x0 = 15
Let’s now change the value of register x0
to the
integer 1:
(lldb) register write $x0 0x1
Finally, we can let the program continue its execution, and confirm that the printed output is our spoofed value 1:
(lldb) continue
Process 21632 resuming
2023-11-22 10:58:46.288530-0400 example-1[21632:104596] Result: 1
Process 21632 exited with status = 0 (0x00000000)
Example 2: Spoofing an object value in a function
In the previous section, we explored spoofing scalar return
values like integers. In practice, most interesting Objective-C
methods and functions return NSObject
instances
allocated in the heap. Luckily, spoofing object return values is
just as easy.
Consider this example Objective-C program that defines a
greet
function that takes an NSString
name as an argument and returns an NSString
that we
can use to greet the person. The program will construct a
greeting for John Doe
and print the corresponding
string:
#import <Foundation/Foundation.h>
* greet(NSString *name) {
NSString return [NSString stringWithFormat:@"Hello %@", name];
}
int main() {
{
@autoreleasepool (@"Greeting: %@", greet(@"John Doe"));
NSLog}
return EXIT_SUCCESS;
}
The output of this program, as you would expect, is
Hello John Doe
:
$ clang main.m -o example-2 -framework Foundation
$ ./example-2
2023-11-22 11:07:27.101 example-2[25313:120317] Greeting: Hello John Doe
We will attempt to spoof the greet
function to
return my name, Juan Cruz Viotti
. As before, let’s
run this program with LLDB:
$ lldb ./example-2
Breakpoint 1: no locations (pending).
Breakpoint set in dummy target, will get copied into future targets.
(lldb) target create "./example-2"
Current executable set to '/Users/jviotti/Projects/playground/example-2' (arm64).
Then, we will set a breakpoint on the greet
function:
(lldb) breakpoint set --name greet
Breakpoint 2: where = example-2`greet, address = 0x0000000100003e9c
Just like before, we will execute the program (using
run
) and stop right after the function was executed
(using finish
):
(lldb) run
Process 26239 launched: '/Users/jviotti/Projects/playground/example-2' (arm64)
Process 26239 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
frame #0: 0x0000000100003e9c example-2`greet
example-2`greet:
-> 0x100003e9c <+0>: sub sp, sp, #0x20
0x100003ea0 <+4>: stp x29, x30, [sp, #0x10]
0x100003ea4 <+8>: add x29, sp, #0x10
0x100003ea8 <+12>: str x0, [sp, #0x8]
Target 0: (example-2) stopped.
(lldb) finish
Process 26239 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step out
frame #0: 0x0000000100003f04 example-2`main + 44
example-2`main:
-> 0x100003f04 <+44>: mov x8, sp
0x100003f08 <+48>: str x0, [x8]
0x100003f0c <+52>: adrp x0, 1
0x100003f10 <+56>: add x0, x0, #0x40 ; @"Greeting: %@"
Target 0: (example-2) stopped.
We can confirm that the x0
register holds the
expected return value as follows:
(lldb) expression --object-description -- $x0
Hello John Doe
Now here comes the interesting part. We can use the
expression
command to invoke arbitrary Objective-C
code, which may result in heap allocations. Therefore, we can
use this command to instantiate an NSString
of our
choosing and retrieve its address (in hexadecimal form):
(lldb) expression --format hex -- [NSString stringWithUTF8String:"Juan Cruz Viotti"]
(__NSCFString *) $2 = 0x000060000073c000 @"Juan Cruz Viotti"
In this case, I instantiated an NSString
with
the contents Juan Cruz Viotti
, and its address is
0x000060000073c000
. As you might expect, we can set
the x0
register to this address:
(lldb) register write $x0 0x000060000073c000
Finally, we can let the program resume execution and confirm that it prints our spoofed string:
(lldb) continue
Process 26239 resuming
2023-11-22 11:19:35.675241-0400 example-2[26239:124053] Greeting: Juan Cruz Viotti
Process 26239 exited with status = 0 (0x00000000)