Reverse engineer a swift binary
When you compile your high-level Swift code, it gets turned into raw machine instructions. As developers, we rarely look at this level, but it's a fascinating world for anyone interested in security, performance, or just pure curiosity.
In this post, we are going to write a tiny "login" program in Swift, disassemble it, and find the exact instruction that decides if our password is right or wrong.
Then, we are going to change that instruction to let us in with any password.
Part 1: The Swift Program
First, our highly secure, un-hackable login program, login.swift:
// login.swift
// Our login function with a very secret password
func login(pass: String) -> Bool {
pass == "right-password"
}
// Get the password from the command line
// Note: CommandLine.arguments[0] is the program name
// CommandLine.arguments[1] is our first argument
let result = login(pass: CommandLine.arguments[1])
// Print success or failure
print(result ? "success" : "failure")
Let's compile it and run it to make sure it works as expected.
$ swiftc login.swift -o login
# Test with the wrong password
$ ./login wrong
failure
# Test with the right password
$ ./login right-password
success
It works perfectly. Now, let's break it.
Part 2: Disassembly
We will use objdump to disassemble the executable, turning its machine code back into (semi) readable ARM64 assembly.
$ objdump -D login
This produces a lot of output, which can be intimidating. But we are looking for two specific functions:
_main: This is the main entry point of our script._$s5loginAA4passSbSS_tF: This is ourlogin(pass: String) -> Boolfunction after Swift "mangles" its name.
Part 3: Finding the Password in the Code
Let's look at our login function's assembly first (_$s5loginAA4passSbSS_tF). Tucked inside, we see it loading some data:
00000001000007a4 <_$s5loginAA4passSbSS_tF>:
...
1000007c8: 90000000 adrp x0, 0x100000000
1000007cc: 9124d000 add x0, x0, #0x934
...
1000007f8: 94000036 bl 0x1000008d0 ; This calls the string comparison function
...
100000810: 12000000 and w0, w0, #0x1 ; Result is 1 (true) or 0 (false)
...
10000081c: d65f03c0 ret ; Return
The add x0, x0, #0x934 instruction is loading the address of our hardcoded password. How do we know? If we look at the __TEXT,__cstring (string) section of the dump, we find this at that exact address:
0000000100000934 <__cstring+0x10>:
100000934: 68676972 ; "righ"
100000938: 61702d74 ; "t-pa"
10000093c: 6f777373 ; "sswo"
100000940: 0a006472 ; "rd"
The program literally has "right-password" stored inside it. This function compares the input password to this string and returns 1 (true) or 0 (false) in the w0 register.
Part 4: The Branch
Now, let's look at _main. This is where the decision to print "success" or "failure" is made, based on the return value from our login function.
0000000100000628 <_main>:
...
100000668: 9400004f bl 0x1000007a4 <_$s5loginAA4passSbSS_tF> ; CALLS OUR LOGIN FUNCTION
10000066c: aa0003e8 mov x8, x0 ; Moves the 1 or 0 result into register x8 (w8)
...
1000006bc: 39400108 ldrb w8, [x8] ; Loads the boolean result (1 or 0) into w8
1000006c0: 36000188 tbz w8, #0x0, 0x1000006f0 <_main+0xc8>
1000006c4: 14000001 b 0x1000006c8 <_main+0xa0>
This is it. This is the whole security model.
1000006c0: 36000188 tbz w8, #0x0, 0x1000006f0
tbz stands for "Test Bit and Branch if Zero". This instruction does the following:
Looks at register
w8(which holds our1for success or0for failure).Tests bit
#0of that register.If that bit is Zero (meaning the password was wrong), it branches (jumps) to address
0x1000006f0.
What is at 0x1000006f0? It is the code that loads and prints "failure".
If the bit is Not Zero (i.e., it's 1, meaning success), it doesn't branch. It just falls through to the next instruction at 0x1000006c4, which takes it to the code that prints "success".
Part 5: The Patch: Bypassing the Check
So, how do we bypass this? We just need to stop that conditional branch from ever happening. We can "neuter" the instruction by overwriting it with a nop (No-Operation) instruction.
The nop instruction in ARM64 is d503201f. It literally does nothing.
By replacing the tbz instruction with a nop, the code will always fall through to the "success" block, no matter what the password is.
Here is our plan:
Find the bytes: The
tbzinstruction is36000188. On a little-endian system (like Apple Silicon), this is stored in the file as88 01 00 36.Replace the bytes: The
nopinstruction isd503201f. This is stored as1f 20 03 d5.Use a hex editor (like
hexedit) to find88 01 00 36and replace it with1f 20 03 d5.
# 1. Open the file in the hex editor
$ hexedit login
# 2. Press Ctrl+S to search.
# Search for hex bytes: 88 01 00 36
# 3. Type over it with the new bytes: 1f 20 03 d5
# (The text will turn red)
# 4. Press Ctrl+X to exit, and 'y' to save.
Part 6: The Final Step (macOS Security)
If we try to run ./login now, it will hang or crash. We have modified the binary, which breaks its "code signature". macOS sees the file has been tampered with and refuses to run it.
We need to apply a new, ad-hoc signature to tell macOS we trust this new version.
$ codesign -f -s - login
The Result
Now, we run our patched app.
$ ./login anything-i-want
success
$ ./login blahblah
success
$ ./login 1234
success
We have successfully bypassed the password check by changing just 4 bytes in the compiled executable.
This is, of course, a very simple example, but it demonstrates the fundamentals of binary patching and reverse engineering. It also shows why hardcoding secrets in your client-side applications is never a good idea!