Cylent Security

Cylent Security

Reverse Engineering Swift Strings in iOS Apps with LLDB

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.x

Swift 5.x+ (Modern - 2019+)

Quick Decision Tree

  1. Examine x1 high byte:

    • 0xeX pattern → Native Swift string
    • 0x8X or 0xaX pattern → NSString-backed (bridged)
  2. 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
ExamplePassword123

Swift 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.x

Auto-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)$x0

Common 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

  1. 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
  2. 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!
  3. 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!
  4. Debug Builds Can Lie:

    • .debug or .debug.dylib files may behave differently than version detection suggests
    • Always test empirically: try both x/s $x0+32 and x/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:

  1. Try both pointer locations:

    • x/s $x0+32
    • x/s $x1+32
  2. Try NSString method:

    • po (id)$x0
  3. 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 = 0x0000000000000000

Reproducing 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_test

Creating 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 -5

If 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 in x1 (count in bits 56-59)
  • Large strings (> 15 bytes): Metadata in x0 (count in bits 0-7), pointer in x1
  • 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 x0 and x1
  • Large strings (≥ 16 bytes): Metadata in x0, pointer in x1
  • 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:

  1. Swift metadata sections (__swift5_fieldmd, __swift4_fieldmd) - Most reliable
  2. Symbol mangling ($s vs _T) - Reliable for most cases
  3. libswiftCore version - Only if version is not 0.0.0
  4. Swift version string - If present in binary
  5. 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:

  1. Class header (32 bytes on 64-bit ARM64):

    • Bytes 0-7: isa pointer (object type information)
    • Bytes 8-15: Reference count
    • Bytes 16-23: Capacity and flags
    • Bytes 24-31: Count and flags
  2. 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 NSString object (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

  • x0 through x7: Function parameters (first 8 parameters)
  • x0: Also used for return values
  • x8: Indirect return value pointer (for large return types)
  • x9 through x15: Temporary registers
  • x16 and x17: Intra-procedure-call temporary registers
  • x19 through x28: Callee-saved registers
  • x29: Frame pointer (FP)
  • x30: Link register (LR) - stores return address
  • sp (x31): Stack pointer

Register Notation

ARM64 registers have two names depending on the data size being accessed:

  • x0 through x30: 64-bit (full register width)
  • w0 through w30: Lower 32 bits of the corresponding x register

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) -> String

The 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 SIGSTOP

Set 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 = 0x0000000100008000

Run 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, #0x20

Examining Register Contents

View the contents of the string parameter registers:

(lldb) register read x0 x1
      x0 = 0x00000001a5b6e0a0
      x1 = 0xe900000000000008

Here'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 = 0xe500000000000000

The count is stored in bits 56-59 (the upper nibble of the top byte):

(lldb) p/d ($x1 >> 56) & 0xF
(long) $0 = 5

The 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 data

The count is stored in the lower byte (bits 0-7):

(lldb) p/d $x0 & 0xFF
(long) $1 = 176

The 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 = 0xe500000000000000

The 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 = 0x0000006f6c6c6548

Convert 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 storage

Extract the count from the lower byte of x0:

(lldb) p/d $x0 & 0xFF
(long) $0 = 142

The 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 0xe900000000000008

Breaking this down (64-bit words):

  • Word 0 (0x00000001000081d0): isa pointer - 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: password

Determining 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 = 0b1110100100000000000000000000000000000000000000000000000000001000

Bit 60 (position from right, starting at 0):

Position: 6360 59...                                    ...0
Value:    111 0...                                      ...0

Check if bit 60 is set (this is the discriminator for large vs small strings):

(lldb) p/x (0xe900000000000008 >> 60) & 0x1
(long) $5 = 0x0000000000000001

Result = 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 = 0x0000000000000000

Result = 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) continue

After the function returns, examine x0 and x1:

(lldb) register read x0 x1
      x0 = 0x00000001a5b6f120
      x1 = 0xe900000000000010

Extract the count:

(lldb) p/d 0xe900000000000010 & 0xFFFFFFFFFFFF
(long) $7 = 16

This is a large string with 16 bytes. Read the data:

(lldb) memory read -s1 -fu -c16 0x00000001a5b6f120+32
0x1a5b6f140: encrypted_secret

Handling NULL or Empty Strings

Empty strings have special representation:

(lldb) register read x0 x1
      x0 = 0xe000000000000000
      x1 = 0x0000000000000000

When 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 = 0x0000000000000000

Both 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 = 0x8000000000000010

The key difference is in the flag bits. Extract and examine them:

(lldb) p/x ($x1 >> 56) & 0xFF
(long) $0 = 0x0000000000000080

When 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 = 0x0000000000000000

Result = 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
ExamplePassword123

The 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 stored

NSString-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
ExamplePassword123

Bridged 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
secretpass99

The 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 = 0xa00000000000000d

The flag pattern 0xa000... indicates shared storage. The pointer in x0 points to a __SharedStringStorage object, which contains:

  • An _owner pointer (may be nil for immortal strings)
  • A start pointer 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 pointer

The 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 = 0x0000000100007f80

Add commands to automatically dump string parameters:

(lldb) breakpoint command add 1
Enter your debugger command(s).  Type 'DONE' to end.
> register read x0 x1
> DONE

Step 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 order

Step 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
> DONE

This 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 ... 1000

The "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 $x0

Correct:

(lldb) memory read -s1 -fu -c8 $x0+32

Mistake 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) & 0x1

Mistake 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 & 0xFF

Correct:

(lldb) p/d $x1 & 0xFFFFFFFFFFFF

Mistake 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: password

Tip 2: Create Custom LLDB Commands

Save time with custom commands in your ~/.lldbinit:

command script import ~/lldb_scripts/swift_string_extractor.py

Example 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 Hello

Tip 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 = w

This 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
> DONE

This 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 0xe900000000000010

The 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
> DONE

Monitor 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
> DONE

This 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 true

The -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 = metadata

Legacy Swift 4.x:

Small string:  x0 = first 8 chars, x1 = remaining chars + metadata
Large string:  x0 = metadata,      x1 = pointer to data

Notice 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 x1

Legacy 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 = 0x2164726f57206f20

Extract 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 = 0x00000001a5b6e0a0

Extract the count from x0 (the metadata register):

(lldb) p/d 0xf000000000000010 & 0xFFFFFFFFFFFF
(long) $2 = 16

The 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: ThisIsALongPass16

Legacy 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: password123

Quick 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 + count

Swift 4.x (Legacy):

Type           | x0                  | x1
---------------|---------------------|--------------------
Small (<16)    | First 8 bytes       | Remaining + metadata
Large (≥16)    | Metadata + count    | Pointer to storage

Common Pitfalls When Working with Legacy Swift

  1. Wrong register for pointer: In legacy Swift, large string pointers are in x1, not x0. Always check which register contains the pointer-like value.

  2. 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.

  3. Assuming modern Swift: Many tools and scripts online assume Swift 5.x+. Always verify the Swift version before using extraction scripts.

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

  1. Check Swift version first:

    otool -L /path/to/binary | grep Swift
    strings /path/to/binary | grep "Swift version"
  2. Test with a known string: Set a breakpoint on a function that takes a string parameter and examine the register layout.

  3. Look for flag patterns:

    • Modern: Metadata in x1 with 0xeX pattern
    • Legacy: Metadata in x0 with 0xfX pattern for large strings
  4. 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 x1

Solution: 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 -5

If 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:

  1. Set breakpoint at function with known string parameter
  2. Examine actual register contents
  3. Try both extraction methods
  4. 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+32

Explanation:

  • 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_types

The section names indicate the Swift version (5, 4, etc.).

Quick Diagnostic Checklist

When string extraction isn't working as expected:

  1. ☐ Examine register flag patterns (which register has 0xeX or 0xfX?)
  2. ☐ Verify you're checking Swift compiler version, not framework versions
  3. ☐ Try both modern and legacy extraction methods
  4. ☐ Check if it's a bridged NSString (bit 61 = 0)
  5. ☐ Look for Swift metadata sections in binary
  6. ☐ 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:

  1. Modern Swift 5.x: x/s $x0+32
  2. 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:

  1. 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 in x0
    • Swift 4.x: Registers swapped for large strings (metadata in x0, pointer in x1)
  2. Three primary storage strategies (Swift 5.x+):

    • Inline for small strings (up to 15 bytes)
    • Heap-allocated __StringStorage for larger native strings
    • Bridged NSString objects for Objective-C interoperability
  3. 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)
  4. 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)
  5. Extraction techniques:

    • Native large strings: Add 32 bytes to pointer in x0 to skip header
    • Native small strings: Data stored inline in x0 (little-endian)
    • NSString-backed: Use Objective-C runtime (po or UTF8String method)
    • Legacy Swift 4.x large: Add 32 bytes to pointer in x1 (registers swapped)
  6. 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
  7. 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.