Skip to main content

Command Palette

Search for a command to run...

Reverse engineer a swift binary

Updated
5 min read

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:

  1. _main: This is the main entry point of our script.

  2. _$s5loginAA4passSbSS_tF: This is our login(pass: String) -> Bool function 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:

  1. Looks at register w8 (which holds our 1 for success or 0 for failure).

  2. Tests bit #0 of that register.

  3. 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:

  1. Find the bytes: The tbz instruction is 36000188. On a little-endian system (like Apple Silicon), this is stored in the file as 88 01 00 36.

  2. Replace the bytes: The nop instruction is d503201f. This is stored as 1f 20 03 d5.

  3. Use a hex editor (like hexedit) to find 88 01 00 36 and replace it with 1f 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!