Cylent Security

Cylent Security

8ksec SwizzleMeTimbers iOS CTF

Solving the 8ksec SwizzleMeTimbers iOS CTF

Description

SwizzleMeTimbers is a pirate-themed iOS app with a secret buried deep inside its view controller. A simple button reads “Unlock Treasure”, but it’s protected by a method that always returns false, unless you’re crafty enough to change its behavior at runtime.

Your mission

Use method swizzling to unlock the hidden flag.

Goal

Bypass the app’s logic using dynamic instrumentation tools (e.g., Frida or Objective-C runtime) to change the behavior of a function at runtime and trigger the correct flag path.

Restrictions

• You must perform runtime manipulation to change how the app behaves.

Flag Format

CTF{...}

Sharpen your cutlass and hook into Objective-C’s dark magic. ☠️

The App

This is the main screen seen after starting the app:

App main screen

When you tap the Unlock Treasure button, the following dialog is shown:

That aint the pirates path

Tracing the UI Button

I use the following script to show what's executed when a UIControl is activated (button clicked):

/*
    iOS UI Interaction Hook (UIControl & UIGestureRecognizer)
    ---------------------------------------------------------
    - Detects button clicks (UIControl)
    - Detects gesture taps (UIGestureRecognizer)
    - Prints Selector, Target, and UIEvent details
    - Calculates the File Offset for use in Binary Ninja/IDA/Ghidra
*/

// HELPER: Print formatted method information
function printMethodInfo(interactionType, target, selector, eventHandle) {
    // Safety check: if selector is missing, we can't identify the function
    if (!selector) return;

    // 1. Resolve Names
    var selectorName = ObjC.selectorAsString(selector);
    var targetObj = (target && !target.isNull()) ? new ObjC.Object(target) : null;
    var targetClassName = targetObj ? targetObj.$className : "nil (Responder Chain)";

    console.log("\n[+] " + interactionType + " Detected");
    console.log("--------------------------------------------------");
    console.log("  Selector: " + selectorName);
    console.log("  Target:   " + targetClassName);

    // 2. Resolve Event Details (if provided)
    if (eventHandle && !eventHandle.isNull()) {
        var event = new ObjC.Object(eventHandle);
        // .toString() on UIEvent returns the detailed description (timestamps, touches, etc.)
        console.log("  Event:    " + event.toString()); 
    } else if (interactionType === "UIControl Action") {
        console.log("  Event:    nil (Likely programmatic trigger)");
    }

    // 3. Calculate Static Analysis Offsets
    if (targetObj) {
        // Ask the target where the code for this selector lives in memory
        var implementation = targetObj.methodForSelector_(selector);
        
        // Find which binary module owns this address
        var module = Process.findModuleByAddress(implementation);
        
        if (module) {
            // Calculate offset: Runtime Address - Module Base Address
            var fileOffset = implementation.sub(module.base);
            
            console.log("\n  [Binary Location]");
            console.log("  |-- Module:  " + module.name);
            console.log("  |-- Address: " + implementation);
            console.log("  |-- Offset:  " + fileOffset + "  <-- GO HERE IN DISASSEMBLER");
        } else {
            console.log("\n  [Binary Location]");
            console.log("  |-- Address: " + implementation + " (Module not found)");
        }
    }
    console.log("--------------------------------------------------\n");
}

if (ObjC.available) {
    
    // --- HOOK 1: UIControl (Standard Buttons, Switches, Sliders) ---
    var UIControl = ObjC.classes.UIControl;
    
    if (UIControl) {
        var sendAction = UIControl["- sendAction:to:forEvent:"];
        if (sendAction) {
            Interceptor.attach(sendAction.implementation, {
                onEnter: function (args) {
                    // args[2] = Selector (SEL)
                    // args[3] = Target (id)
                    // args[4] = Event (UIEvent)
                    printMethodInfo("UIControl Action", args[3], args[2], args[4]);
                }
            });
        } else {
            console.log("[-] Warning: 'sendAction:to:forEvent:' not found on UIControl.");
        }
    }

    // --- HOOK 2: UIGestureRecognizer (Popups, Custom Views, Tap to Dismiss) ---
    var UIGestureRecognizer = ObjC.classes.UIGestureRecognizer;
    
    if (UIGestureRecognizer) {
        // We hook 'setState:' to detect when a gesture is recognized
        var setState = UIGestureRecognizer["- setState:"];
        
        if (setState) {
            Interceptor.attach(setState.implementation, {
                onEnter: function (args) {
                    // args[0] = Self (The Gesture Recognizer instance)
                    // args[2] = State (NSInteger)
                    
                    // State 3 corresponds to UIGestureRecognizerStateEnded / Recognized
                    var state = args[2].toInt32();
                    
                    if (state === 3) { 
                        var gesture = new ObjC.Object(args[0]);
                        
                        // Access the private '_targets' list which holds the destinations
                        // Note: This uses KVC (valueForKey) which is standard ObjC
                        var targets = gesture.valueForKey_("_targets");
                        
                        if (targets) {
                            var count = targets.count().valueOf();
                            for (var i = 0; i < count; i++) {
                                var internalTarget = targets.objectAtIndex_(i);
                                
                                // ACCESS PRIVATE IVARS SAFELY using $ivars
                                // 'internalTarget' is a private class (UIGestureRecognizerTarget)
                                // It holds the real destination (_target) and the method (_action)
                                var realTarget = internalTarget.$ivars["_target"]; 
                                var actionSel  = internalTarget.$ivars["_action"];
                                
                                if (realTarget && actionSel) {
                                    // Pass null for eventHandle since gestures don't pass a UIEvent object here
                                    printMethodInfo("Gesture Interaction", realTarget, actionSel, null);
                                }
                            }
                        }
                    }
                }
            });
        } else {
             console.log("[-] Warning: 'setState:' not found on UIGestureRecognizer.");
        }
    }

} else {
    console.log("[-] Error: Objective-C Runtime not available.");
}

Run the script:

frida -U -f com.8ksec.SwizzleMeTimbers.WBC6955H89 -l hook_UIControl.js

The output:

[+] UIControl Action Detected
--------------------------------------------------
  Selector: t4G0
  Target:   SwizzleMeTimbers.Q9V0
  Event:     timestamp: 382515 touches: {(
     type: Direct; phase: Ended; is pointer: NO; tap count: 1; force: 0.000; window: ; layer = >; responder: ; gestureRecognizers = ; layer = >; location in window: {427, 254.5}; previous location in window: {427, 254.5}; location in view: {90.5, 28}; previous location in view: {90.5, 28}
)}

  [Binary Location]
  |-- Module:  SwizzleMeTimbers.debug.dylib
  |-- Address: 0x10230a63c
  |-- Offset:  0x663c  <-- GO HERE IN DISASSEMBLER
--------------------------------------------------

I used frida-trace to trace all class methods of SwizzleMeTimbers.Q9V0 (from the "Target" line in the script output) to show me what was happening when I clicked the "Unlock Treasure" button:

frida-trace -U -m "-[SwizzleMeTimbers.Q9V0 *]" -f com.8ksec.SwizzleMeTimbers.WBC6955H89

The output:

Started tracing 6 functions. Web UI available at http://localhost:63487/
           /* TID 0x103 */
   595 ms  -[SwizzleMeTimbers.Q9V0 initWithNibName:0x0 bundle:0x0]
   596 ms  -[SwizzleMeTimbers.Q9V0 viewDidLoad]
 11066 ms  -[SwizzleMeTimbers.Q9V0 t4G0]
 11066 ms     | -[SwizzleMeTimbers.Q9V0 _9zB]

Next, I explore method _9zB. I edit the handler, which is shown in the frida-trace output:

-[SwizzleMeTimbers.Q9V0 _9zB]: Auto-generated handler at "__handlers__/SwizzleMeTimbers.Q9V0/_9zB.js"

Here's the auto-generated script:

/*
 * Auto-generated by Frida. Please modify to match the signature of -[SwizzleMeTimbers.Q9V0 _9zB].
 * This stub is currently auto-generated from manpages when available.
 *
 * For full API reference, see: https://frida.re/docs/javascript-api/
 */

defineHandler({
  onEnter(log, args, state) {
    log(`-[SwizzleMeTimbers.Q9V0 _9zB]`);
    }
  },

  onLeave(log, retval, state) {
    }
});

I edited the script to show the arguments and return value:

defineHandler({
  onEnter(log, args, state) {
    log(`-[SwizzleMeTimbers.Q9V0 _9zB]`);
    try {
      var arg1 = new ObjC.Object(args[2]);
      log(` Argument 1 (arg1): ${arg1.toString()}`);
    } catch (e) {
      log(` Argument 1 (arg1): ${args[2]}`);
    }
  },

  onLeave(log, retval, state) {
    try {
      var retObj = new ObjC.Object(retval);
      log(` Return Value: ${retObj.toString()}`);
    } catch (e) {
      log(` Return Value: ${retval}`);
    }
  }
});

I run frida-trace again and see Return Value: nil In the output. If I had formatted the script to print an integer, it would have printed a return value of 0 (zero).

I edit the script one more time and add retval.replace(ptr('0x1')); to the end of the onLeave function.

I click the button and get the flag (CTF{{Swizzle_mbers}}). Challenge solved!

Getting the flag