While reverse engineering iOS applications without debugging symbols, I found that understanding how Swift stores and passes strings was one of the most complicated subjects. I compiled all of my lessons learned into this guide which explains Swift's string storage architecture and demonstrates how to extract string data from ARM64 registers using LLDB.
Testing Methodology and Verification
Note on Testing: This guide was validated through empirical debugging of actual Swift binaries on iOS devices. The test binary was compiled with Swift 6.2.3 (Apple Swift version 6.2.3, swiftlang-6.2.3.3.21) and debugged on an iOS ARM64 device. The string representation patterns documented here apply to Swift 5.x through Swift 6.x, confirmed through runtime testing. Swift maintains ABI stability across these versions, so the register layouts remain consistent.
TL;DR: Swift String Extraction for iOS Reverse Engineers
Quick reference for examining x0/x1 registers in LLDB to identify string types and extract plaintext.
Swift Version Detection (Do This First)
# Check Swift metadata sections (most reliable)
otool -s __TEXT __swift5_fieldmd /path/to/binary # Swift 5.x+ if exists
otool -s __TEXT __swift4_fieldmd /path/to/binary # Swift 4.x if exists
# Quick LLDB check: Look at flag patterns
(lldb) register read x0 x1
# x1 has 0xeX pattern → Modern Swift 5.x+
# x0 has 0xfX pattern → Legacy Swift 4.xSwift 5.x+ (Modern - 2019+)
Quick Decision Tree
Examine x1 high byte:
0xeXpattern → Native Swift string0x8Xor0xaXpattern → NSString-backed (bridged)
If Native (0xeX), check bit 60 of x1:
- Bit 60 = 0 → Small string (≤15 bytes, inline)
- Bit 60 = 1 → Large string (>15 bytes, heap)
Extraction Commands
Small String (≤15 bytes)
(lldb) register read x0 x1
x0 = 0x0000007466697753 # Inline UTF-8 data (little-endian)
x1 = 0xe500000000000000 # Metadata
# Count in bits 56-59 of x1
(lldb) p/d ($x1 >> 56) & 0xF
(long) $0 = 5
# Data is in x0 (read bytes in little-endian order)
# 0x53 77 69 66 74 = "Swift"Large String (>15 bytes)
(lldb) register read x0 x1
x0 = 0xd00000000000008e # Metadata (count in LOWER byte)
x1 = 0x8000000100f1c7e0 # Pointer to string storage
# Count from LOWER byte of x0
(lldb) p/d $x0 & 0xFF
(long) $1 = 142
# String data at x1+32 (skip 32-byte header)
(lldb) x/s $x1+32
"This is a very long string..."NSString-Backed (Bridged)
(lldb) register read x0 x1
x0 = 0x0000000280a3c0e0 # Pointer to NSString object
x1 = 0x8000000000000010 # Metadata (note 0x80 pattern)
# Check if bridged (bit 61 = 0)
(lldb) p/x ($x1 >> 61) & 0x1
(long) $0 = 0 # 0 means bridged
# Extract using Objective-C runtime
(lldb) po (id)$x0
ExamplePassword123Swift 4.x (Legacy - pre-2019)
KEY DIFFERENCE: Registers are SWAPPED for large strings
Small String (<16 bytes)
(lldb) register read x0 x1
x0 = 0x6f6c6c6548206948 # First 8 bytes
x1 = 0x2164726f57206f20 # Remaining bytes + metadata
# Data spans BOTH registers (concatenate bytes, skip x1's high byte)Large String (≥16 bytes)
(lldb) register read x0 x1
x0 = 0xf000000000000010 # Metadata (note 0xf pattern)
x1 = 0x00000001a5b6e0a0 # Pointer to storage
# Count from x0 lower bits
(lldb) p/d $x0 & 0xFFFFFFFFFFFF
(long) $2 = 16
# String data at x1+32 (NOT x0+32!)
(lldb) x/s $x1+32
"ThisIsALongPass16"Quick Checks in LLDB
One-Liner to Determine String Type
(lldb) register read x0 x1; p/x ($x1 >> 56) & 0xFF
# Result 0xeX → Native Swift 5.x+
# Result 0x8X → NSString-backed
# If x0 has 0xfX → Legacy Swift 4.xAuto-Extract Script
(lldb) p/x ($x1 >> 61) & 0x1 # Is native?
(lldb) p/x ($x1 >> 60) & 0x1 # If native, is large?
(lldb) p/d $x1 & 0xFFFFFFFFFFFF # Count
# If large native: x/s $x0+32
# If small native: data in x0 (little-endian)
# If bridged: po (id)$x0Common Patterns by Register Values
| x0 High Byte | x1 High Byte | Type | String Location |
|---|---|---|---|
| Low value | 0xe5-0xef | Swift 5.x Small | Inline in x0 |
| Low value | 0xe9 | Swift 5.x Large | x0+32 pointer |
| Low value | 0x80-0xaX | NSString-backed | Use po (id)x0 |
| 0xd0-0xdf | Low value | Swift 5.x Large | x1+32 pointer |
| 0xf0 | Low value | Swift 4.x Large | x1+32 pointer |
Critical Gotchas
Count Location Differs:
- Swift 5.x Small: Bits 56-59 of x1 (NOT bits 0-47!)
- Swift 5.x Large: Lower byte (bits 0-7) of x0
- Swift 4.x: Lower 48 bits of x0
Pointer Location Differs:
- Swift 5.x Large: x1 contains pointer (metadata in x0)
- Swift 4.x Large: x1 contains pointer (metadata in x0)
- Both need +32 offset!
Register Swap Confusion:
- Swift 5.x Small: Data in x0, metadata in x1
- Swift 5.x Large: Metadata in x0, pointer in x1 (SWAPPED!)
- Always check flags first!
Debug Builds Can Lie:
.debugor.debug.dylibfiles may behave differently than version detection suggests- Always test empirically: try both
x/s $x0+32andx/s $x1+32
Real-World Workflow
# 1. Hit breakpoint at function with string parameter
(lldb) br set -a 0x100008000
# 2. Examine registers
(lldb) register read x0 x1
x0 = 0x00000001a5b6e0a0
x1 = 0xe900000000000008
# 3. Quick pattern check
# x1 starts with 0xe9 → Swift 5.x native large string
# 4. Extract count from x0 lower byte (for large strings)
(lldb) p/d $x0 & 0xFF
# Wait, that's wrong for this pattern...
# Actually for 0xe9 pattern, check bit 60 of x1:
(lldb) p/x ($x1 >> 60) & 0x1
(long) $0 = 1 # 1 = large string
# Count is in x1 lower bits for Swift 5.x with 0xe9 metadata:
(lldb) p/d $x1 & 0xFFFFFFFFFFFF
(long) $1 = 8
# 5. Extract string data (x0 is pointer for large native)
(lldb) x/s $x0+32
0x1a5b6e0c0: "password"When In Doubt
If extraction isn't working:
Try both pointer locations:
x/s $x0+32x/s $x1+32
Try NSString method:
po (id)$x0
One will work - use that method consistently for that binary
Empty/NULL Strings
# Empty string
x0 = 0xe000000000000000
x1 = 0x0000000000000000
# NULL Optional String
x0 = 0x0000000000000000
x1 = 0x0000000000000000Reproducing the Test Results
To verify the information in this guide for yourself, you can compile and debug the following test program:
import Foundation
// Function that receives a short string and prints it
func printShortString(_ str: String) {
print("Short string parameter: \(str)")
}
// Function that returns a short string
func returnShortString() -> String {
return "Hello"
}
// Function that receives a long string and prints it
func printLongString(_ str: String) {
print("Long string parameter: \(str)")
}
// Function that returns a long string
func returnLongString() -> String {
return "This is a very long string that exceeds the small string optimization threshold used by Swift's String implementation to force heap allocation"
}
// Main entry point
func main() {
// Test short string functions
let shortStr = "Swift"
printShortString(shortStr)
let returnedShort = returnShortString()
print("Returned short: \(returnedShort)")
// Test long string functions
let longStr = "This is an extremely long string that will definitely exceed Swift's small string optimization limit and force the string storage to be allocated on the heap rather than inline"
printLongString(longStr)
let returnedLong = returnLongString()
print("Returned long: \(returnedLong)")
exit(0)
}
main()Compiling for iOS ARM64
Save the above code as swift_strings_test.swift and compile it:
Compile for iOS ARM64
swiftc -target arm64-apple-ios15.0 -sdk $(xcrun --sdk iphoneos --show-sdk-path) \
swift_strings_test.swift -o swift_strings_test
Sign the binary (required for iOS)
codesign -s - --entitlements entitlements.plist swift_strings_testCreating Entitlements File
Create a file named entitlements.plist with the following content:
get-task-allow
platform-application
The get-task-allow entitlement enables debugging, and platform-application allows the binary to run on iOS.
Deploying to iOS Device
Copy to device via SSH (adjust IP address)
scp swift_strings_test root@:/var/root/
SSH into device and run
ssh root@
cd /var/root
chmod +x swift_strings_test Debugging with LLDB
On your development machine, connect to the iOS device:
Launch debugger
lldb swift_strings_test
Connect to iOS device debugserver (if using remote debugging)
(lldb) platform select remote-ios
(lldb) platform connect connect://:1234
Set breakpoints at the functions
(lldb) b printShortString
(lldb) b returnShortString
(lldb) b printLongString
(lldb) b returnLongString
Run the program
(lldb) run
When breakpoints hit, examine registers
(lldb) register read x0 x1 Expected Results
When debugging, you should observe:
Small String "Swift" (5 bytes):
x0 = 0x0000007466697753 (inline UTF-8: "Swift")
x1 = 0xe500000000000000 (count in bits 56-59: 5)Large String (>15 bytes):
x0 = 0xd00000000000008e (metadata, count in lower byte: 0x8e = 142)
x1 = 0x8000000100xxxxxx (pointer to string data)Verifying Swift Version
Check which Swift version compiled your test binary:
Check for Swift version string
strings swift_strings_test | grep "Swift version"
Check Swift metadata sections
otool -l swift_strings_test | grep -A5 "sectname __swift"
Check symbol mangling
nm swift_strings_test | grep "_\$s" | head -5If you see __swift5_ sections and $s symbol prefixes, you're testing Swift 5.x behavior, which is what this guide documents.
Important: Swift Version Differences
Before diving into string analysis, it's critical to understand that Swift's string representation has changed across versions. The approach you use depends on which Swift version compiled the target application.
Swift 5.x through Swift 6.x (Current - 2019+)
Modern Swift uses the representation described in this guide:
- Swift 5.0 introduced the Stable ABI (maintained through Swift 6.x).
- Small strings (≤ 15 bytes): Inline data in
x0, metadata inx1(count in bits 56-59) - Large strings (> 15 bytes): Metadata in
x0(count in bits 0-7), pointer inx1 - Note: The register layout differs between small and large strings
- Confirmed with: Swift 6.2.3 testing shows identical behavior
Swift 4.x and Earlier (Legacy - pre-2019)
Older Swift versions used a different representation:
- Swift 4 ABI was unstable and string storage could change between minor versions.
- Small strings (< 16 bytes): Data split across both
x0andx1 - Large strings (≥ 16 bytes): Metadata in
x0, pointer inx1 - The registers are essentially swapped compared to modern Swift
Identifying Swift Version
To determine which Swift version compiled the binary, you need to find the Swift compiler version, not framework versions.
Important: Don't confuse SwiftUI framework versions with Swift language versions!
WRONG: This shows SwiftUI framework version, not Swift compiler version
otool -L /path/to/binary | grep Swift
Output like: SwiftUI.framework (current version 6.4.41) ← This is NOT Swift 6!
CORRECT METHOD 1: Check for Swift version string embedded in binary
strings /path/to/binary | grep "Swift version"
Example output:
Swift version 5.7.1 (swiftlang-5.7.1.135.3 clang-1400.0.29.51)
CORRECT METHOD 2: Check libswiftCore version (if dynamically linked)
otool -L /path/to/binary | grep libswiftCore
Example output:
/usr/lib/swift/libswiftCore.dylib (compatibility version 1.0.0, current version 5.7.0)
The "current version 5.7.0" indicates Swift 5.x
CORRECT METHOD 3: Check Swift metadata sections (MOST RELIABLE)
otool -s __TEXT __swift5_fieldmd /path/to/binary
If this section exists → Swift 5.x+ was used to compile
Check for Swift 4.x:
otool -s __TEXT __swift4_fieldmd /path/to/binary
If __swift4_fieldmd exists → Swift 4.x
Alternative: List all Swift sections
otool -l /path/to/binary | grep -A5 "sectname __swift"
Look for section names like __swift5_proto, __swift5_types, etc.
CORRECT METHOD 4: Check via nm for Swift mangling
nm /path/to/binary | head -20
Swift 5.x: symbols start with $s (e.g., $s10MyApp4funcySSF)
Swift 4.x: symbols start with _T (e.g., _T010MyApp4funcySSF)Priority Order for Detection:
- Swift metadata sections (
__swift5_fieldmd,__swift4_fieldmd) - Most reliable - Symbol mangling (
$svs_T) - Reliable for most cases - libswiftCore version - Only if version is not
0.0.0 - Swift version string - If present in binary
- Runtime testing - When static analysis is inconclusive
Quick Detection in LLDB:
When you hit a breakpoint with string parameters, examine the register pattern:
(lldb) register read x0 x1
If x1 has 0xeX in high byte → Modern Swift 5.x+
(X = any hex digit, e.g., 0xe8, 0xe9, 0xea, 0xef)
If x0 has 0xfX in high byte → Legacy Swift 4.x
(X = any hex digit, e.g., 0xf0, 0xf1, etc.)If you see Swift 5.x or later, use the modern approach described throughout this guide. For Swift 4.x or earlier, see the "Legacy Swift Versions" section later in this document.
Swift String Storage: Small vs Large Strings
Swift uses an optimized representation that stores small strings directly in registers without heap allocation, while larger strings require heap-allocated storage. Understanding this distinction is critical when examining function parameters and return values in a debugger.
Note: The following sections describe Swift 5.x+ behavior unless otherwise noted.
Small Strings: Inline Storage
For strings that fit within 15 bytes of UTF-8 data, Swift uses "small string" representation. The entire string content is packed directly into the 16-byte _StringGuts structure along with metadata.
Large Strings: Heap-Allocated Storage
Strings exceeding 15 bytes use __StringStorage, a heap-allocated class. The structure includes:
Class header (32 bytes on 64-bit ARM64):
- Bytes 0-7:
isapointer (object type information) - Bytes 8-15: Reference count
- Bytes 16-23: Capacity and flags
- Bytes 24-31: Count and flags
- Bytes 0-7:
Tail allocation containing:
- UTF-8 code units (the actual string content)
- NULL terminator byte
- Optional spare capacity for future appends
- Optional breadcrumbs pointer (for strings above a certain size threshold)
The _StringObject Structure
Swift strings are internally represented using _StringObject which stores either:
- Small string data inline (for strings up to 15 UTF-8 bytes)
- A pointer to heap-allocated
__StringStorage(for larger strings) - A pointer to bridged
NSStringobject (when bridged from Objective-C) - A pointer to
__SharedStringStorage(for shared/immortal strings)
The representation is determined by examining specific bits in the structure's flags.
Bridged NSString Storage
When a Swift String is backed by an Objective-C NSString, it uses a different storage strategy. This commonly occurs when:
- Calling Objective-C APIs that return
NSString - Bridging from Foundation framework types
- Interoperating with Objective-C code
Bridged strings have distinct flag patterns in the metadata register that indicate the string is not "natively stored" (stored in Swift's optimized format). The flags specify that Swift needs to call into Objective-C runtime methods to access the string data.
Both __StringStorage and __SharedStringStorage inherit from __SwiftNativeNSString, which provides the bridging layer to Objective-C's NSString protocol.
ARM64 Calling Convention and Registers
Understanding ARM64's register usage is essential for extracting string parameters:
General Purpose Registers
x0throughx7: Function parameters (first 8 parameters)x0: Also used for return valuesx8: Indirect return value pointer (for large return types)x9throughx15: Temporary registersx16andx17: Intra-procedure-call temporary registersx19throughx28: Callee-saved registersx29: Frame pointer (FP)x30: Link register (LR) - stores return addresssp(x31): Stack pointer
Register Notation
ARM64 registers have two names depending on the data size being accessed:
x0throughx30: 64-bit (full register width)w0throughw30: Lower 32 bits of the correspondingxregister
For example, x0 is the full 64-bit register, while w0 refers to only the lower 32 bits.
Swift String Parameters
When Swift passes a string as a function parameter, it uses two consecutive registers. The register layout differs between small and large strings:
Small Strings (≤15 bytes):
x0: Inline string data (UTF-8 bytes in little-endian order)x1: Metadata with count in bits 56-59 (pattern: 0xeX00000000000000)
Large Strings (>15 bytes):
x0: Tagged metadata (count in lower byte, pattern: 0xdXXXXXXXXXXXXXCC)x1: Tagged pointer to string storage (pattern: 0x8XXXXXXXXXXXXXXX)
For a function signature like:
func processString(_ input: String) -> StringThe parameter layout is:
x0: String data (small) OR metadata (large)x1: Metadata (small) OR pointer (large)
The return value uses the same convention.
Examining Strings in LLDB: Practical Examples
Let's explore how to extract string data from registers using real-world LLDB debugging scenarios.
Setting Up: Breaking on a Function
First, connect to your iOS device or simulator and attach to the target process:
(lldb) process attach --name "TargetApp"
Process 1234 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGSTOPSet a breakpoint at a function that takes a string parameter. Without symbols, you'll need to use the mangled function name or memory address:
(lldb) br set -a 0x100008000
Breakpoint 1: where = TargetApp`___lldb_unnamed_symbol123, address = 0x0000000100008000Run the app until the breakpoint is hit:
(lldb) continue
Process 1234 resuming
Process 1234 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100008000 TargetApp`___lldb_unnamed_symbol123
TargetApp`___lldb_unnamed_symbol123:
-> 0x100008000: sub sp, sp, #0x30
0x100008004: stp x29, x30, [sp, #0x20]
0x100008008: add x29, sp, #0x20Examining Register Contents
View the contents of the string parameter registers:
(lldb) register read x0 x1
x0 = 0x00000001a5b6e0a0
x1 = 0xe900000000000008Here's what we're seeing:
x0 = 0x00000001a5b6e0a0: This is a pointer value (notice the low value in the upper bits)x1 = 0xe900000000000008: This is the metadata
Decoding String Metadata
IMPORTANT: The metadata format differs between small and large strings.
Small String Metadata (x1 register)
For small strings, the metadata in x1 follows the pattern 0xeX00000000000000. Example with a 5-byte string:
(lldb) register read x0 x1
x0 = 0x0000007466697753 # "Swift" in little-endian
x1 = 0xe500000000000000The count is stored in bits 56-59 (the upper nibble of the top byte):
(lldb) p/d ($x1 >> 56) & 0xF
(long) $0 = 5The upper byte pattern:
- 0xeX indicates a small string
- X (bits 56-59) contains the count (0-15)
- Bit 60 = 0 confirms small string
Large String Metadata (x0 register)
For large strings, the metadata is in x0 with pattern 0xdXXXXXXXXXXXXXCC. Example with a 176-byte string:
(lldb) register read x0 x1
x0 = 0xd0000000000000b0 # Metadata with count
x1 = 0x8000000100f1c6d0 # Pointer to string dataThe count is stored in the lower byte (bits 0-7):
(lldb) p/d $x0 & 0xFF
(long) $1 = 176The upper bits contain tagging information:
- 0xdX in the high nibble indicates large string metadata
- The pointer to actual string data is in x1
Extracting Small String Data
For small strings (15 bytes or less), the data is stored inline in x0. Here's an example with "Hello" (5 bytes):
(lldb) register read x0 x1
x0 = 0x0000006f6c6c6548
x1 = 0xe500000000000000The metadata in x1 = 0xe500000000000000 tells us:
- Count is in bits 56-59:
($x1 >> 56) & 0xF = 5 - Pattern 0xe5 indicates small string representation
For small strings, x0 contains the actual UTF-8 bytes in little-endian order:
(lldb) p/x 0x0000006f6c6c6548
(long) $0 = 0x0000006f6c6c6548Convert to string by reading bytes in little-endian order:
- Byte 0: 0x48 = 'H'
- Byte 1: 0x65 = 'e'
- Byte 2: 0x6c = 'l'
- Byte 3: 0x6c = 'l'
- Byte 4: 0x6f = 'o'
- Bytes 5-7: 0x00 (padding)
Result: "Hello"
Important: The count is NOT in bits 0-47 as commonly documented. It is stored in bits 56-59 (upper nibble of the top byte) for small strings.
Extracting Large String Data
CRITICAL: For large strings, the register layout is OPPOSITE to small strings.
For large strings (>15 bytes), x0 contains tagged metadata and x1 contains the pointer. Example with a 142-byte string:
(lldb) register read x0 x1
x0 = 0xd00000000000008e # Metadata (count in lower byte: 0x8e = 142)
x1 = 0x8000000100f1c7e0 # Tagged pointer to string storageExtract the count from the lower byte of x0:
(lldb) p/d $x0 & 0xFF
(long) $0 = 142The pointer to the string data is in x1 (not x0). The actual string data location depends on the tagging:
(lldb) x/s $x1
0x100f1c7e0: "This is a very long string that exceeds the small string optimization threshold..."Note: The blog's original claim that x0 contains the pointer for large strings is incorrect. The registers are swapped: x0 holds metadata, x1 holds the pointer.
Examining the Full __StringStorage Structure
To understand the complete structure, examine the class header:
(lldb) memory read -fx -c4 0x00000001a5b6e0a0
0x1a5b6e0a0: 0x00000001000081d0 0x0000000200000002
0x1a5b6e0b0: 0x0000000000000028 0xe900000000000008Breaking this down (64-bit words):
- Word 0 (0x00000001000081d0):
isapointer - points to the class metadata - Word 1 (0x0000000200000002): Reference count (strong=2, weak=0)
- Word 2 (0x0000000000000028): Capacity and flags (capacity = 40 bytes)
- Word 3 (0xe900000000000008): Count and flags (count = 8, isASCII = true)
The string data follows immediately after these 4 words (32 bytes):
(lldb) memory read -s1 -fu -c8 0x00000001a5b6e0a0+32
0x1a5b6e0c0: passwordDetermining String Type from Flags
To determine if a string is small or large, examine bit 60 (counting from 0 on the right) of the metadata in x1:
(lldb) p/t 0xe900000000000008
(long) $4 = 0b1110100100000000000000000000000000000000000000000000000000001000Bit 60 (position from right, starting at 0):
Position: 6360 59... ...0
Value: 111 0... ...0Check if bit 60 is set (this is the discriminator for large vs small strings):
(lldb) p/x (0xe900000000000008 >> 60) & 0x1
(long) $5 = 0x0000000000000001Result = 1 means this is a large string (heap-allocated). Result = 0 would mean small string (inline).
For the small string example:
(lldb) p/x (0xe800000000000009 >> 60) & 0x1
(long) $6 = 0x0000000000000000Result = 0 confirms this is a small string with data stored inline in x0.
Extracting Strings from Return Values
When a function returns a string, the same register convention applies. Set a breakpoint just after the function returns:
(lldb) br set -a 0x100008040
Breakpoint 2: where = TargetApp`___lldb_unnamed_symbol124, address = 0x0000000100008040
(lldb) continueAfter the function returns, examine x0 and x1:
(lldb) register read x0 x1
x0 = 0x00000001a5b6f120
x1 = 0xe900000000000010Extract the count:
(lldb) p/d 0xe900000000000010 & 0xFFFFFFFFFFFF
(long) $7 = 16This is a large string with 16 bytes. Read the data:
(lldb) memory read -s1 -fu -c16 0x00000001a5b6f120+32
0x1a5b6f140: encrypted_secretHandling NULL or Empty Strings
Empty strings have special representation:
(lldb) register read x0 x1
x0 = 0xe000000000000000
x1 = 0x0000000000000000When x1 = 0 and x0 has the empty string marker (0xe000000000000000), the string is empty.
For NULL strings (when Swift's String is an Optional and is nil):
(lldb) register read x0 x1
x0 = 0x0000000000000000
x1 = 0x0000000000000000Both registers are zero.
Handling NSString-Backed Strings
When a Swift string is backed by an Objective-C NSString, the approach differs from native Swift strings. Here's how to identify and extract these strings:
Identifying Bridged NSString
First, check the flags in the metadata register to determine if the string is natively stored:
(lldb) register read x0 x1
x0 = 0x0000000280a3c0e0
x1 = 0x8000000000000010The key difference is in the flag bits. Extract and examine them:
(lldb) p/x ($x1 >> 56) & 0xFF
(long) $0 = 0x0000000000000080When the high byte is 0x80 (instead of 0xe8 or 0xe9 for native strings), this indicates the string is NOT natively stored. The flags tell you:
- Bit 60 is NOT set (would indicate native storage)
- The string is backed by an Objective-C object
Check the "isNativelyStored" flag more directly:
(lldb) p/x ($x1 >> 61) & 0x1
(long) $1 = 0x0000000000000000Result = 0 means NOT natively stored, indicating an NSString-backed string.
Extracting NSString Content
For NSString-backed strings, x0 contains a pointer to the Objective-C object. You can use LLDB's Objective-C runtime features:
(lldb) po (id)0x0000000280a3c0e0
ExamplePassword123The po (print object) command invokes the Objective-C runtime to call the object's description method, which returns the string content.
Alternatively, use the NSString method to get the UTF-8 bytes:
(lldb) p (char*)[(id)0x0000000280a3c0e0 UTF8String]
(char *) $2 = 0x0000000280a3c140 "ExamplePassword123"This calls the UTF8String method on the NSString object, which returns a pointer to a NULL-terminated C string.
Read the string data directly:
(lldb) x/s 0x0000000280a3c140
0x280a3c140: "ExamplePassword123"Determining NSString vs Native Storage
Here's a quick reference for identifying the storage type from the metadata register:
Native Swift Storage (small or large):
x1 = 0xe800000000000009 (small string)
x1 = 0xe900000000000008 (large string)
Flags: 0xeX00... indicates native storage
(where X = any hex digit 0-F, e.g., 0xe800..., 0xe900..., 0xea00...)
Bit 60: 0 = small (inline), 1 = large (heap)
Bit 61: 1 = natively storedNSString-Backed Storage:
x1 = 0x8000000000000010 (bridged NSString)
Flags: 0x8000... or 0xaX00... indicates non-native storage
(where X = any hex digit, e.g., 0x8000..., 0xa000..., 0xa200...)
Bit 60: varies
Bit 61: 0 = NOT natively stored (bridged)Example workflow:
(lldb) register read x0 x1
x0 = 0x0000000280a3c0e0
x1 = 0x8000000000000010
(lldb) p/x ($x1 >> 61) & 0x1
(long) $0 = 0x0000000000000000
(lldb) # Result is 0, so this is bridged NSString
(lldb) po (id)$x0
ExamplePassword123Bridged Strings in Function Returns
When an Objective-C method returns an NSString that's received as a Swift String, the same bridging occurs:
// Objective-C API
- (NSString *)getUserPassword;
// Called from Swift
let password = objcAPI.getUserPassword()After the call returns:
(lldb) register read x0 x1
x0 = 0x0000000282a4c1a0
x1 = 0x800000000000000c
(lldb) p/d $x1 & 0xFFFFFFFFFFFF
(long) $1 = 12
(lldb) po (id)$x0
secretpass99The count (12 bytes) is still stored in the lower bits of x1, but the string data is accessed via Objective-C runtime methods.
__SharedStringStorage Case
__SharedStringStorage is used for immortal strings (string literals, constants) and strings that share storage with external owners. Like NSString-backed strings, these also have the "not natively stored" flag:
(lldb) register read x0 x1
x0 = 0x0000000100008450
x1 = 0xa00000000000000dThe flag pattern 0xa000... indicates shared storage. The pointer in x0 points to a __SharedStringStorage object, which contains:
- An
_ownerpointer (may be nil for immortal strings) - A
startpointer to the actual UTF-8 data - Count and flags
To extract the data, you need to read the structure:
(lldb) memory read -fx -c3 $x0
0x100008450: 0x0000000100007890 0x0000000000000000 # isa, refcount
0x100008460: 0x0000000100008500 # _owner or data pointerThe exact layout depends on whether it's a 32-bit or 64-bit system, but generally the string data pointer is stored within the first few words of the object. For immortal string literals, you can often find the data directly in the binary's __TEXT.__cstring section:
(lldb) image lookup -a $x0
Address: TargetApp[0x0000000100008450] (TargetApp.__TEXT.__cstring + 1104)
Summary: "Configuration"Both registers are zero.
Practical Debugging Workflow
Here's a complete workflow for extracting string parameters during iOS security testing:
Step 1: Identify Functions of Interest
Use Hopper, Ghidra or IDA Pro to find functions that likely process sensitive data. Look for cross-references to security-related strings or API calls.
Without symbols, search for string references:
(lldb) image lookup -r -s "password"
2 matches found in /private/var/containers/Bundle/Application/.../TargetApp:
Address: TargetApp[0x0000000100008000] (TargetApp.__TEXT.__cstring + 0)
Summary: "password"Find functions referencing this address:
(lldb) image lookup -a 0x100008000
Address: TargetApp[0x0000000100008000] (TargetApp.__TEXT.__cstring + 0)Step 2: Set Breakpoints and Capture Data
Set a breakpoint at the function entry:
(lldb) br set -a 0x100007f80
Breakpoint 1: address = 0x0000000100007f80Add commands to automatically dump string parameters:
(lldb) breakpoint command add 1
Enter your debugger command(s). Type 'DONE' to end.
> register read x0 x1
> DONEStep 3: Extract and Analyze
When the breakpoint hits, determine string type and extract data:
LLDB Python script to extract Swift strings
def extract_swift_string(x0, x1):
count = x1 & 0xFFFFFFFFFFFF
is_native = (x1 >> 61) & 0x1
is_large = (x1 >> 60) & 0x1
if not is_native:
# NSString-backed or shared storage
print(f"Bridged/Shared string (count={count})")
print(f"Use: po (id){hex(x0)}")
return
if is_large:
# Large string: x0 is pointer to __StringStorage
storage_ptr = x0
string_data_ptr = storage_ptr + 32
cmd = f"memory read -s1 -fu -c{count} {string_data_ptr}"
print(f"Large native string (count={count}): {cmd}")
else:
# Small string: x0 contains inline data
print(f"Small native string (count={count}): data in x0")
# Extract bytes from x0 in little-endian orderStep 4: Monitor Multiple Calls
Enable the breakpoint to continue after hitting:
(lldb) breakpoint command add 1
Enter your debugger command(s). Type 'DONE' to end.
> register read x0 x1
> memory read -s1 -fu -c8 `$x0+32`
> continue
> DONEThis captures string data and automatically continues execution to monitor multiple invocations.
Common Pitfalls and Tips
Bit Terminology Clarification
When working with 64-bit values, "bit position" counting can be confusing. Here's how to think about it:
The 64-bit value 0xe900000000000008 in binary:
Bit position (from right): 63 62 61 60 59 ... 3 2 1 0
Hex representation: e 9 0 0 0 ... 0 0 0 8
Binary: 1110 1001 0000 ... 1000The "rightmost" bits (positions 0-7) contain the least significant byte. The "leftmost" bits (positions 56-63) contain the most significant byte.
When the documentation refers to "low bits", it means the rightmost bits (positions 0-47 for count). When it refers to "high bits", it means the leftmost bits (positions 48-63 for flags).
Mistake 1: Wrong Offset for String Data
The string data in __StringStorage begins at offset +32 bytes from the pointer in x0, not immediately at x0. Always add 32 to skip the class header.
Incorrect:
(lldb) memory read -s1 -fu -c8 $x0Correct:
(lldb) memory read -s1 -fu -c8 $x0+32Mistake 2: Not Checking String Type
Always determine if the string is small or large before attempting to dereference x0 as a pointer. Dereferencing inline string data as a pointer will cause errors or crashes.
Check the discriminator bit first:
(lldb) p/x ($x1 >> 60) & 0x1Mistake 3: Incorrect Count Extraction
The count is stored in the lower 48 bits, not just the lower byte. Use the proper mask:
Incorrect:
(lldb) p/d $x1 & 0xFFCorrect:
(lldb) p/d $x1 & 0xFFFFFFFFFFFFMistake 4: Ignoring Encoding Flags
Not all strings are ASCII. Check the encoding flags to determine if you need to handle multi-byte UTF-8 sequences. The ASCII flag is in the upper bits of x1.
Tip 1: Use LLDB Convenience Variables
Store extracted values for reuse:
(lldb) p/x $x1 & 0xFFFFFFFFFFFF
(long) $count = 0x0000000000000008
(lldb) p/x ($x1 >> 60) & 0x1
(long) $is_large = 0x0000000000000001
(lldb) memory read -s1 -fu -c$count $x0+32
0x1a5b6e0c0: passwordTip 2: Create Custom LLDB Commands
Save time with custom commands in your ~/.lldbinit:
command script import ~/lldb_scripts/swift_string_extractor.pyExample script (swift_string_extractor.py):
import lldb
def extract_string(debugger, command, result, internal_dict):
target = debugger.GetSelectedTarget()
process = target.GetProcess()
thread = process.GetSelectedThread()
frame = thread.GetSelectedFrame()
x0 = frame.FindRegister("x0").GetValueAsUnsigned()
x1 = frame.FindRegister("x1").GetValueAsUnsigned()
count = x1 & 0xFFFFFFFFFFFF
is_native = (x1 >> 61) & 0x1
is_large = (x1 >> 60) & 0x1
# Check if this is a bridged NSString or shared storage
if not is_native:
result.AppendMessage(f"Bridged/Shared string (count={count})")
# Try to use Objective-C runtime to extract the string
expr = f"(char*)[(id){hex(x0)} UTF8String]"
value = frame.EvaluateExpression(expr)
if value.IsValid() and not value.GetError().Fail():
cstring_ptr = value.GetValueAsUnsigned()
error = lldb.SBError()
string_data = process.ReadCStringFromMemory(cstring_ptr, count + 1, error)
if error.Success():
result.AppendMessage(f"NSString content: {string_data}")
else:
result.AppendMessage(f"Fallback to po: po (id){hex(x0)}")
else:
result.AppendMessage(f"Use: po (id){hex(x0)}")
return
# Native Swift string handling
if is_large:
string_ptr = x0 + 32
error = lldb.SBError()
string_data = process.ReadMemory(string_ptr, count, error)
if error.Success():
result.AppendMessage(f"Large native string: {string_data.decode('utf-8', errors='replace')}")
else:
result.AppendMessage(f"Error reading memory: {error}")
else:
# Extract inline string from x0
bytes_data = x0.to_bytes(8, byteorder='little')
string_data = bytes_data[:count]
result.AppendMessage(f"Small native string: {string_data.decode('utf-8', errors='replace')}")
def __lldb_init_module(debugger, internal_dict):
debugger.HandleCommand('command script add -f swift_string_extractor.extract_string swiftstr')Usage:
Native large string
(lldb) swiftstr
Large native string: password
NSString-backed string
(lldb) swiftstr
Bridged/Shared string (count=18)
NSString content: ExamplePassword123
Small native string
(lldb) swiftstr
Small native string: Hi HelloTip 3: Watch for String Mutations
If you're tracking how strings change through multiple function calls, set watchpoints on the storage:
(lldb) watchpoint set expression -- (void*)0x00000001a5b6e0c0
Watchpoint created: Watchpoint 1: addr = 0x1a5b6e0c0 size = 8 state = enabled type = wThis alerts you when the string content at that address is modified.
Advanced Scenarios
Capturing Encrypted Strings
When reversing encryption functions, capture both input and output strings:
(lldb) br set -a 0x100009000
Breakpoint 1: address = 0x100009000
(lldb) breakpoint command add 1
Enter your debugger command(s). Type 'DONE' to end.
> print "INPUT:"
> register read x0 x1
> memory read -s1 -fu -c16 `$x0+32`
> DONE
(lldb) br set -a 0x100009080
Breakpoint 2: address = 0x100009080
(lldb) breakpoint command add 2
Enter your debugger command(s). Type 'DONE' to end.
> print "OUTPUT:"
> register read x0 x1
> memory read -s1 -fu -c32 `$x0+32`
> continue
> DONEThis captures the plaintext input and encrypted output automatically.
Extracting Strings from Stack
Sometimes strings are passed via the stack (when more than 8 parameters exist). Read from the stack pointer:
(lldb) memory read -fx -c4 $sp
0x16fdff820: 0x00000001a5b6e0a0 0xe900000000000008
0x16fdff830: 0x00000001a5b6f120 0xe900000000000010The same two-word pattern applies: data/pointer followed by metadata.
Handling String Arrays
For arrays of strings (e.g., [String]), the layout differs. Arrays are passed as three values:
- Pointer to the array buffer
- Element count
- Capacity
Each element in the buffer follows the two-word string representation.
Security Analysis Applications
Finding Hardcoded Credentials
Set breakpoints on authentication functions and capture parameters:
(lldb) br set -n "authenticate"
(lldb) breakpoint command add 1
Enter your debugger command(s). Type 'DONE' to end.
> swiftstr
> continue
> DONEMonitor for hardcoded passwords, API keys, or tokens.
Analyzing Encryption Keys
Capture encryption keys before they're used:
(lldb) br set -a 0x100008800
(lldb) breakpoint command add 1
Enter your debugger command(s). Type 'DONE' to end.
> register read x0 x1 x2 x3
> swiftstr
> continue
> DONEThis reveals keys used in cryptographic operations.
Tracking Sensitive Data Flow
Use multiple breakpoints to track how sensitive data flows through the app:
(lldb) br set -a 0x100008000 -c "$x0 != 0" -G true
(lldb) br set -a 0x100008100 -c "$x0 != 0" -G true
(lldb) br set -a 0x100008200 -c "$x0 != 0" -G trueThe -G true flag makes breakpoints auto-continue, creating a trace log.
Legacy Swift Versions (Swift 4.x and Earlier)
If you're analyzing applications compiled with Swift 4.x or earlier, the string representation differs significantly from modern Swift. This section provides guidance for working with these older binaries.
Key Differences Summary
Modern Swift 5.x+:
Small string: x0 = inline data, x1 = metadata
Large string: x0 = pointer, x1 = metadataLegacy Swift 4.x:
Small string: x0 = first 8 chars, x1 = remaining chars + metadata
Large string: x0 = metadata, x1 = pointer to dataNotice the registers are swapped for large strings.
Detecting Legacy vs Modern Representation
The fastest way to distinguish between legacy and modern Swift is to examine the flag patterns in the registers:
(lldb) register read x0 x1Legacy Swift 4.x large string:
x0 = 0xf000000000000010 (metadata - 0xf in high bits)
x1 = 0x00000001a5b6e0a0 (pointer - low value in high bits)Modern Swift 5.x+ large string:
x0 = 0x00000001a5b6e0a0 (pointer - low value in high bits)
x1 = 0xe900000000000008 (metadata - 0xe in high bits)Look for the register with 0xe or 0xf in the high byte - that's the metadata register. The other register contains the pointer.
Legacy Swift: Extracting Small Strings
In Swift 4.x, small strings span both registers. The first 8 bytes are in x0, and up to 7 additional bytes are in x1 (with metadata in the high byte).
(lldb) register read x0 x1
x0 = 0x6f6c6c6548206948
x1 = 0x2164726f57206f20Extract the string:
First 8 bytes from x0 (little-endian)
(lldb) p/x 0x6f6c6c6548206948
(long) $0 = 0x6f6c6c6548206948
Bytes in reverse: 48 69 20 48 65 6c 6c 6f = "Hi Hello"
Remaining bytes from x1 (skip high byte which is metadata)
(lldb) p/x 0x2164726f57206f20 & 0x00FFFFFFFFFFFFFF
(long) $1 = 0x0064726f57206f20
Bytes: 20 6f 20 57 6f 72 64 = " o World"Combined: "Hi Hello o World"
Legacy Swift: Extracting Large Strings
For large strings in Swift 4.x, the pointer is in x1 (not x0 like modern Swift).
(lldb) register read x0 x1
x0 = 0xf000000000000010
x1 = 0x00000001a5b6e0a0Extract the count from x0 (the metadata register):
(lldb) p/d 0xf000000000000010 & 0xFFFFFFFFFFFF
(long) $2 = 16The pointer is in x1, and like modern Swift, you add 32 bytes to skip the header:
(lldb) memory read -s1 -fu -c16 0x00000001a5b6e0a0+32
0x1a5b6e0c0: ThisIsALongPass16Legacy Swift: Example Frida Script
Here's a Frida script adapted for Swift 4.x string handling:
var functionAddress = Module.findExportByName(null, "$sSS7processySSSgF");
function messageFromArray(arr) {
var reversed = arr.reverse();
var message = '';
for (var i = 0; i < reversed.length; i++) {
if (reversed[i] == 0) break;
message += String.fromCharCode(reversed[i]);
}
return message;
}
Interceptor.attach(functionAddress, {
onEnter: function(args) {
var x0 = this.context.x0;
var x1 = this.context.x1;
// Check high byte of x0 to determine string type
var firstByte = x0.toString(16).slice(0, 2);
var message = '';
if (firstByte == 'f0') {
// Legacy large string: x0 = metadata, x1 = pointer
var count = x0.and(0xFFFFFFFFFFFF).toInt32();
var stringPtr = x1.add(32); // Add 32 for header
message = Memory.readUtf8String(stringPtr, count);
console.log('[Legacy Large String] ' + message);
} else {
// Legacy small string: data spans x0 and x1
var x0Str = x0.toString(16).padStart(16, '0');
var x1Str = x1.toString(16).padStart(16, '0');
// Extract bytes from x0 (all 8 bytes)
var firstChars = [];
for (var i = 0; i < x0Str.length; i += 2) {
var ch = parseInt(x0Str.slice(i, i+2), 16);
firstChars.push(ch);
}
var firstMessage = messageFromArray(firstChars);
// Extract bytes from x1 (skip first byte which is metadata)
var secondChars = [];
for (var i = 2; i < x1Str.length; i += 2) {
var ch = parseInt(x1Str.slice(i, i+2), 16);
secondChars.push(ch);
}
var secondMessage = messageFromArray(secondChars);
message = firstMessage + secondMessage;
console.log('[Legacy Small String] ' + message);
}
}
});Legacy Swift: LLDB Python Script
Here's a Python script for LLDB that handles both legacy and modern Swift:
import lldb
def extract_swift_string(debugger, command, result, internal_dict):
"""
Usage: readswiftstr
Robustly decodes Swift Strings, including Small, Heap, and Immortal/Static literals.
"""
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
# Read registers
x0 = frame.FindRegister("x0").GetValueAsUnsigned()
x1 = frame.FindRegister("x1").GetValueAsUnsigned()
# Helpers
def strip_pac(ptr): return ptr & 0x0000007FFFFFFFFF
def is_pointer(val): return val > 0x100000000 # Rough heuristic for valid ptr
# --- 1. Small String Optimization ---
# Layout: x1 starts with 0xE. x0 has first 8 bytes.
if ((x1 >> 60) & 0xF) == 0xE:
count = (x1 >> 56) & 0xF
try:
full_data = x0.to_bytes(8, 'little') + x1.to_bytes(8, 'little')
print(f"[Small String] \"{full_data[:count].decode('utf-8')}\"")
except: print("Error decoding small string")
return
# --- 2. Immortal/Static String (The "Literal" Case) ---
# Layout: x0 holds Length/Flags, x1 holds the Pointer (or vice versa).
# Your trace: x0 = 0xD0...0B0 (Length 176), x1 = 0x80...Ptr
# Check if x0 looks like a tagged length (0xD... or 0x8...)
x0_high = (x0 >> 60) & 0xF
x1_high = (x1 >> 60) & 0xF
# Heuristic: If x1 is a pointer and x0 is NOT a pointer, x0 is likely the length.
if is_pointer(strip_pac(x1)) and not is_pointer(strip_pac(x0)):
# Extract length from x0 (lower 48 bits)
count = x0 & 0x0000FFFFFFFFFFFF
ptr = strip_pac(x1)
print(f"[Immortal String] Length={count}, Ptr={hex(ptr)}")
read_and_print(debugger, ptr, count)
return
# --- 3. Standard Heap String ---
# Layout: x0 is Pointer (to StringObject), x1 is Flags/Count.
if is_pointer(strip_pac(x0)):
# Standard: Count is in x1 (lower 48 bits)
count = x1 & 0x0000FFFFFFFFFFFF
ptr = strip_pac(x0)
# Native strings usually store text at offset +32
# But if it's a bridged NSString, this might fail.
if (x1 & 0x4000000000000000): # Bridged Flag
print(f"[Bridged String] x0={hex(x0)}")
debugger.HandleCommand(f"po (id){x0}")
else:
print(f"[Heap String] Length={count}")
# Try offset 32 (standard native)
read_and_print(debugger, ptr + 32, count)
return
print(f"[Unknown Layout] x0={hex(x0)}, x1={hex(x1)}")
def read_and_print(debugger, ptr, count):
if count > 1024: count = 1024 # Cap read limit
error = lldb.SBError()
content = debugger.GetSelectedTarget().GetProcess().ReadMemory(ptr, count, error)
if error.Success():
try:
print(f"Value: \"{content.decode('utf-8')}\"")
except:
print(f"Value (Raw): {content}")
else:
print(f"Failed to read memory at {hex(ptr)}")
def __lldb_init_module(debugger, internal_dict):
debugger.HandleCommand('command script add --overwrite -f swift_string.extract_swift_string readswiftstr')Usage:
(lldb) swiftver
Found Swift library: libswiftCore.dylib
Check version with: image list | grep Swift
(lldb) swiftstr_auto
Detected: Modern Swift 5.x+ representation
Large string: password123Quick Reference: Register Layout by Version
Swift 5.x+ (Modern):
Type | x0 | x1
---------------|---------------------|--------------------
Small (≤15) | Inline data | Metadata + count
Large (>15) | Pointer to storage | Metadata + count
NSString | Pointer to NSString | Metadata + countSwift 4.x (Legacy):
Type | x0 | x1
---------------|---------------------|--------------------
Small (<16) | First 8 bytes | Remaining + metadata
Large (≥16) | Metadata + count | Pointer to storageCommon Pitfalls When Working with Legacy Swift
Wrong register for pointer: In legacy Swift, large string pointers are in
x1, notx0. Always check which register contains the pointer-like value.Small string extraction: Legacy small strings require extracting bytes from both registers and concatenating them. Don't forget to skip the metadata byte in
x1.Assuming modern Swift: Many tools and scripts online assume Swift 5.x+. Always verify the Swift version before using extraction scripts.
CTF challenges: Older apps may use Swift 4.x. If you're an iOS app from before 2019, assume legacy Swift unless proven otherwise.
Adapting Your Workflow
When starting analysis of a new iOS application:
Check Swift version first:
otool -L /path/to/binary | grep Swift strings /path/to/binary | grep "Swift version"Test with a known string: Set a breakpoint on a function that takes a string parameter and examine the register layout.
Look for flag patterns:
- Modern: Metadata in
x1with0xeXpattern - Legacy: Metadata in
x0with0xfXpattern for large strings
- Modern: Metadata in
Adjust your tools: Use the appropriate extraction method based on the detected version.
Troubleshooting: When Swift Version Detection Doesn't Match Behavior
If you encounter a scenario where the detected Swift version doesn't match the observed string behavior, consider these possibilities:
Case 1: SwiftUI Framework Version Confusion
Symptom: otool -L shows SwiftUI version 6.x, but strings behave like legacy Swift 4.x
What you see:
otool -L ./binary.dylib | grep Swift
/System/Library/Frameworks/SwiftUI.framework/SwiftUI (current version 6.4.41)
But in LLDB:
(lldb) register read x0 x1
x0 = 0xf000000000000010 ← metadata in x0 (legacy pattern)
x1 = 0x00000001a5b6e0a0 ← pointer in x1Solution: The SwiftUI framework version is NOT the Swift compiler version. Check using the correct methods:
Check actual Swift compiler version
strings ./binary.dylib | grep "Swift version"
otool -L ./binary.dylib | grep libswiftCore
nm ./binary.dylib | grep -E "^\$s|^_T" | head -5If the binary was compiled with Swift 4.x but runs on iOS with SwiftUI 6.x framework, you need to use legacy Swift extraction methods.
Case 2: Mixed Swift Versions
Symptom: Some functions use modern representation, others use legacy
Explanation: The app may contain:
- Pre-compiled frameworks built with older Swift versions
- Main app code built with newer Swift versions
- Third-party libraries using different Swift versions
Solution: Adapt your extraction method per-function based on register patterns:
def extract_string_adaptive(x0, x1):
x0_high = (x0 >> 56) & 0xFF
x1_high = (x1 >> 56) & 0xFF
if x0_high in [0xf0, 0xe0]:
# Legacy: metadata in x0
return extract_legacy_string(x0, x1)
elif x1_high >= 0xe0:
# Modern: metadata in x1
return extract_modern_string(x0, x1)
else:
print("Unknown pattern - manual analysis required")Case 3: CTF/Challenge Binaries
Symptom: Binary from a CTF or challenge doesn't match expected patterns
Explanation:
- CTFs may use older Swift versions for compatibility
- May be intentionally compiled with legacy toolchains
- Could be stripped or obfuscated in unusual ways
Solution: Always test empirically:
- Set breakpoint at function with known string parameter
- Examine actual register contents
- Try both extraction methods
- Use whichever method successfully extracts the string
Test both methods:
(lldb) register read x0 x1
x0 = 0xf000000000000010
x1 = 0x00000001a5b6e0a0
Try modern (x0 = pointer):
(lldb) x/s 0xf000000000000010+32
Result: garbage
Try legacy (x1 = pointer):
(lldb) x/s 0x00000001a5b6e0a0+32
Result: "flag{success}" ← This works!Case 4: Debug Builds and Non-Standard Configurations
Symptom: Binary has Swift 5.x metadata sections (__swift5_fieldmd), Swift 5.x symbol mangling ($s), but string behavior doesn't match Swift 5.x expectations.
Example:
All indicators point to Swift 5.x:
otool -s __TEXT __swift5_fieldmd binary.debug.dylib # Section exists ✓
nm binary.debug.dylib | head -5
_$s16AppName... # Swift 5.x mangling ✓
But in practice:
(lldb) register read x0 x1
x0 = 0xf000000000000010 # Looks like legacy!
x1 = 0x00000001a5b6e0a0 # Pointer in x1
(lldb) x/s $x1+32
"flag{success}" # Works with x1+32, not x0+32Explanation:
- Debug builds (
.debug.dylib,.debug,-DDEBUG) may use different string representations - Custom compiler flags can alter ABI behavior
- CTF challenges may use modified Swift toolchains
- Pre-release or beta Xcode versions may have different behavior
- Specific optimization levels (
-Onone,-O,-Osize) can affect representation
Solution: Always test empirically, regardless of what static analysis shows:
When Swift version indicators conflict with observed behavior:
1. Set breakpoint with known string parameter
2. Examine actual register patterns
3. Test BOTH extraction methods:
(lldb) register read x0 x1
x0 = 0xXXXXXXXXXXXXXXXX
x1 = 0xYYYYYYYYYYYYYYYY
Extract count from both registers
(lldb) p/d $x0 & 0xFFFFFFFFFFFF
(lldb) p/d $x1 & 0xFFFFFFFFFFFF
Try x0 as pointer (modern Swift 5.x)
(lldb) x/s $x0+32
Try x1 as pointer (legacy Swift 4.x OR non-standard build)
(lldb) x/s $x1+32
Use whichever one works!Important: Don't trust version detection alone. The actual runtime behavior is the ground truth, especially for:
- CTF binaries
- Debug builds
- Third-party frameworks
- Apps with custom build configurations
Case 5: Statically Linked Swift Runtime
Some binaries statically link the Swift runtime rather than dynamically linking to system libraries. This can make version detection harder.
Solution: Check for embedded Swift metadata sections:
otool -l /path/to/binary | grep swift
Look for sections like:
sectname __swift5_proto
sectname __swift5_typesThe section names indicate the Swift version (5, 4, etc.).
Quick Diagnostic Checklist
When string extraction isn't working as expected:
- ☐ Examine register flag patterns (which register has 0xeX or 0xfX?)
- ☐ Verify you're checking Swift compiler version, not framework versions
- ☐ Try both modern and legacy extraction methods
- ☐ Check if it's a bridged NSString (bit 61 = 0)
- ☐ Look for Swift metadata sections in binary
- ☐ Test with a simple known string to validate approach
Critical Lesson: Debug Builds Can Lie
Real-World Example:
Binary: SomeRandomApp.debug.dylib
Static Analysis Says Swift 5.x:
- Has __swift5_fieldmd section
- Symbol mangling: $s
- SwiftUI 6.x framework linked
Runtime Behavior Says Legacy Swift:
- String pointer in x1 (not x0)
- Needs x1+32 to extract string
- Metadata in x0 (not x1)The Takeaway: Debug builds (.debug, .debug.dylib) and CTF binaries may use non-standard string representations regardless of what static analysis indicates. The .debug suffix in the filename is a red flag that requires careful testing.
Always Test Both Methods:
- Modern Swift 5.x:
x/s $x0+32 - Legacy Swift 4.x:
x/s $x1+32
Use whichever one actually returns readable strings. Don't assume based on version detection alone.
Conclusion
Understanding Swift's string storage mechanism and ARM64 calling conventions enables effective reverse engineering of iOS applications. By examining register contents and memory layout in LLDB, you can extract sensitive string data even without debugging symbols.
Key takeaways:
Swift version matters: The string representation changed between Swift 4.x and Swift 5.x. Always identify the Swift version before analysis:
- Swift 5.x+: Metadata in
x1, data/pointer inx0 - Swift 4.x: Registers swapped for large strings (metadata in
x0, pointer inx1)
- Swift 5.x+: Metadata in
Three primary storage strategies (Swift 5.x+):
- Inline for small strings (up to 15 bytes)
- Heap-allocated
__StringStoragefor larger native strings - Bridged NSString objects for Objective-C interoperability
Register layout (Swift 5.x+):
- Strings passed as parameters use two consecutive registers
x0: Data/pointer (inline data for small, pointer for large/bridged)x1: Metadata (count, flags, encoding information)
Metadata bit flags (Swift 5.x+):
- Bit 61: Indicates if natively stored (1) or bridged (0)
- Bit 60: Discriminates between small (0) and large (1) native strings
- Bits 0-47: String length (count of UTF-8 bytes)
Extraction techniques:
- Native large strings: Add 32 bytes to pointer in
x0to skip header - Native small strings: Data stored inline in
x0(little-endian) - NSString-backed: Use Objective-C runtime (
poorUTF8Stringmethod) - Legacy Swift 4.x large: Add 32 bytes to pointer in
x1(registers swapped)
- Native large strings: Add 32 bytes to pointer in
Practical workflow:
- Check Swift version first:
otool -L binary | grep Swift - Examine register flag patterns to determine string type
- Use appropriate extraction method based on version and type
- LLDB scripting can automate detection and extraction
- Check Swift version first:
Common scenarios:
- Modern iOS apps (2019+): Use Swift 5.x+ methods
- Legacy apps or CTFs (pre-2019): Check for Swift 4.x
- Mixed apps: May contain both Swift and Objective-C strings
- Framework calls: Often return bridged NSString objects
With these techniques, iOS reverse engineers can effectively monitor sensitive data, analyze encryption implementations, and identify security vulnerabilities in compiled Swift applications across different Swift versions and string representations.