n3k00n3 // labs blog post

[Writeup] Captain Nohook

Bypassing anti-reverse-Debugging protections in an iOS application.

Initial Observation

When opening the Captain Nohook application, the first screen appears normally. However, after clicking the button "Flag 'ere!", the application displays a warning message indicating that the device is non-compliant.

This behavior strongly suggests that the application performs anti-reverse-engineering checks before allowing access to the flag.

Because of that, the next step was to analyze the application's internal structure.

App funcionando após bypass
Fig.1 – Application Message.

Inspecting the Application Classes

By inspecting the application's classes it was possible to identify a structure related to FailedChecks.

App funcionando após bypass
Fig.2 – Classe.
App funcionando após bypass
Fig.3 – Class Methods.

This indicates that the application performs several runtime security validations, such as:

  • checking suspicious files
  • checking suspicious Objective-C classes
  • detecting reverse engineering tools
  • checking writable restricted directories
  • checking open ports
  • verifying symbolic links
  • detecting dynamic loader anomalies

These checks are commonly used in hardened iOS applications to prevent runtime instrumentation using tools like:

  • Frida
  • debuggers
  • dynamic patching frameworks

Observing Runtime Behavior with Frida-Tracee

Once interesting components were identified, the next step was to observe the application's runtime behavior using Frida-Trace.

The following command was used:


frida-trace -U -f "com.mobilehackinglab.Captain-Nohook" -i "*failedChecks*"

This attaches Frida to the application and automatically generates handlers for any functions containing the string failedChecks.

Frida then creates handler scripts that allow us to intercept and modify function behavior at runtime.

Intercepting the Security Check

The generated handler was modified to manipulate the return value of the reverse engineering check.


defineHandler({
  onEnter(log, args, state) {
    log('ReverseEngineeringToolsStatus being created...');
    state.passed = args[0].toInt32();
    log('passed: ' + (state.passed ? 'TRUE' : 'FALSE'));
  },

  onLeave(log, retval, state) {
    log('Original value: ' + (state.passed ? 'TRUE' : 'FALSE'));
    retval.replace(ptr("1"));
    log('New forced value: TRUE');
  }
});

This script forces the function to always return true, making the application believe that all security checks passed.

As a result:

  • the anti-debug protection is bypassed
  • the application no longer terminates

However, even after bypassing the protection, the flag was still not displayed.

Instead, the application shows the message:


Arrr, ye've got no hook, ye scallywag!
App shows a message
Fig.4 – ye scallywag.

This clearly suggests that the application expects some form of runtime hook to be present.

Searching for the Flag Function

The next step was searching for functions containing the word flag.

This led to the discovery of a method named:


whereIsflag
Flag methods
Fig.5 – Where is flag?.

To observe its execution, the following trace was used:


frida-trace -U -f "com.mobilehackinglab.Captain-Nohook" \
-i "*failedChecks*" \
-i "*whereIsflag*"

Even after tracing this method, the flag was still not visible in the UI.

Inspecting UILabel

Since this is an iOS application using UIKit, any text displayed on screen will likely pass through UILabel.

Therefore the following Objective-C method was traced:


frida-trace -U -f "com.mobilehackinglab.Captain-Nohook" \
-i "*failedChecks*" \
-i "*whereIsflag*" \
-m "-[UILabel setText:]"

This allows interception of every call where text is assigned to a label.

Logging UILabel Text

The generated handler was modified to log the text assigned to labels.


defineHandler({
  onEnter(log, args, state) {
    try {
      const label = new ObjC.Object(args[0]);
      const text  = new ObjC.Object(args[2]);
      log("UILabel(" + label + ") text -> " + text.toString());
    } catch (e) {
      log("UILabel setText (non-ObjC?): " + args[2]);
    }
  }
});

This revealed something interesting: the flag was being generated internally but never shown on screen.

Instead, the text was visible only in the Frida logs.

Flag no trace
Fig.6 – I got you but not in my screen.

Forcing the Flag to Appear

To make the flag visible in the interface, another handler was modified, this time targeting view visibility.


defineHandler({
  onEnter(log, args, state) {

    const view = new ObjC.Object(args[0]);

    if (view.$className === "UILabel") {
      log("Forcing UILabel visible: " + view);
      args[2] = ptr("0");
    }
  }
});

This forces the property:


hidden = NO

Which ensures the label containing the flag cannot be hidden by the application.

Final Result

After applying these modifications, the application finally displays the flag directly on the screen:

Flag no trace
Fig.7 – Got it!.

Conclusion

This challenge demonstrates how runtime instrumentation tools such as Frida can be used to:

  • bypass anti-reverse-engineering protections
  • intercept internal application logic
  • observe hidden UI behavior
  • manipulate runtime values

Sometimes the flag isn't hidden in memory — it's simply hidden from the interface.

Happy Hacking. 🏴‍☠️