Introduction
Quackify is an iOS lab from MobileHackingLab where you exploit a deserialization vulnerability to unlock premium features of an app and gain access to a flag.
Methodology
- Explore the application
- Reverse the binary
- Enable debug logging
- Identify the vulnerability
- Where is the flag?!
- Final thoughts
Explore the application
The app consists of a few components:
- Player screen
- Premium license activator
- Premium key activator
When you open the app, you are greeted with the home screen that contains the player.

After tapping the play button, you will notice that it starts playing audio from a remote server.

After playing the audio for 10 seconds, an upgrade dialog appears that allows you to upgrade to premium features by either specifying a remote license file or by entering a license key.


For this challenge, we are going to focus on loading a license file remotely to activate premium features.
Reverse the binary
After reversing the binary using Ghidra we notice a few interesting observations.
There are only a few classes available

There are a lot of functions available, but they all seem to be obfuscated somehow with names like FUN_10000*

The string resources are available, but they seem to be partially linked and Ghidra canβt show you where they are being used.

The source code performs a lot of string concatenation of items in memory to build up resources to use, which is very difficult to read.

Checking for debug symbols also yields no results, which explains why Ghidra could not reverse some of the items properly.
objdump --syms Payload/quackify.app/quackify | grep " d " | grep "swift"
# No results
It seems as if this binary was intentionally hardened to make it more difficult to reverse engineer it.
Enable debug logging
In order to figure out what the app does, we need some visibility on what is happening when we perform certain actions.
Fortunately for us, the app seems to contain a bunch of Swift print statements, which means we can write a hook to read these.


I was not able to hook the print function in Swift directly because of the way that Swift handles strings.
Upon further research, I found a way to hook another function that gets executed as part of the Swift internal print function.
console.log("[*] Hooking fwrite for Swift.print output...");
const fwrite = Module.findExportByName("libswiftCore.dylib", "fwrite");
Interceptor.attach(fwrite, {
onEnter(args) {
try {
const buf = args[0];
const size = args[1].toInt32();
const nmemb = args[2].toInt32();
const totalBytes = size * nmemb;
if (totalBytes <= 0) return;
const output = buf.readUtf8String(totalBytes);
if (output !== null && output.length > 0) {
console.log("[Swift.print] " + output.replace(/\n$/, ""));
}
} catch (e) {
console.log("[Swift.print] <decode error: " + e + ">");
}
},
});
And running the app using Frida with this hook:
frida -U -f "com.mobilehackinglab.quackify" -l swift-print-hook.js
Spawning `com.mobilehackinglab.quackify`...
[*] Hooking fwrite for Swift.print output...
Spawned `com.mobilehackinglab.quackify`. Resuming main thread!
[iPhone::com.mobilehackinglab.quackify ]-> [Swift.print] π Checking license at: /var/mobile/Containers/Data/Application/3AD037A1-A392-4ABC-B450-1FD9D4EC2823/Documents/premium.lic
[Swift.print]
[Swift.print] π« License file not found
[Swift.print]
Now that we have some visibility of what is happening, we can play around with the app a bit to test a few things.
Identify the vulnerability
Letβs start off by setting up a server to host our license file.
I am going to use Flask for this, but you can also use the Python built-in http.server module if you prefer - or anything else for that matter.
We create a dummy file:
echo "TESTING1234" > license.lic
Setup a Flask server that can serve the file:
# license_server.py
from flask import Flask, send_file
app = Flask(__name__)
@app.route("/license.lic")
def download():
try:
return send_file("./license.lic", mimetype="application/x-plist")
except Exception as e:
return str(e)
Then we start the server to host the file:
flask --app license_server run --host=0.0.0.0 --port=9090
Serving Flask app 'license_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
The next step is to open the app, play the trial clip, and point the license server URL to the hosted server:

It was able to download the file, but obviously, since it was not in the correct format, it was not able to parse it:
[Swift.print] π Attempting to fetch license from: http://192.168.2.5:9090/license.lic
[Swift.print]
[Swift.print] β
License file downloaded (12 bytes)
[Swift.print]
[Swift.print] π Attempting to deserialize license file...
[Swift.print]
[Swift.print] π Remote license processing result: License file processed but returned nil
[Swift.print]
[Swift.print] β
License processed: License file processed but returned nil
Letβs explore what the license file needs to look like and how it could potentially work.
We know that this is an NSCoder deserialization challenge, so it would be good to figure out what that is and how it works.
Googling nscoder deserialization challenge
has a great article as the first result: Swift deserialization security primer
By reading the article, there is a bit more clarity about what needs to be done.
We will probably need to create a file that will deserialize into a class used by the app.
Letβs dump the Swift classes to see if we can find such a class (or you can also use Ghidra to check):
ipsw swift-dump Payload/quackify.app/quackify --demangle
...
class quackify.License: NSObject {
/* fields */
var userType: String
var isValid: Swift.Bool
var extractedFlag: String?
/* methods */
// <stripped> func userType.getter
// <stripped> func userType.setter
// <stripped> func userType.modify
// <stripped> func isValid.getter
// <stripped> func isValid.setter
// <stripped> func isValid.modify
// <stripped> func extractedFlag.getter
// <stripped> func extractedFlag.setter
// <stripped> func extractedFlag.modify
// <stripped> static func init
// <stripped> static func init
func sub_100017068 // method (instance)
}
...
There is a lot of output, but this one part is interesting: itβs a class that resembles a license with fields isValid
, userType
and also extractedFlag
.
If we look at the quackify.License
file in Ghidra we can see some functions being used to initialize it, which contains some references to NSCoder-type functions we saw earlier in the article.


After spending a considerable amount of time reading through various source files on Ghidra and identifying the calls happening, we can now try to create a license file to use.
What we need to do to achieve this:
- Create a license file that mimics the actual class in the binary
- Write this license file to disk in a format that the app expects
- Host this license file and download it using the app
From reading the article earlier, we know what the license class format needs to look like, what functions it should implement, and also that it will be written as a binary plist file.
The idea is to export a file that the iOS app will be able to import and deserialize into a class it expects.
We start off with the license class, initialize it, and then write it disk:
@objc(License)
class License: NSObject, NSCoding {
var userType: String
var isValid: Bool
var extractedFlag: String?
init(userType: String, isValid: Bool, extractedFlag: String?) {
self.userType = userType
self.isValid = isValid
self.extractedFlag = extractedFlag
}
required init?(coder aDecoder: NSCoder) {
self.userType = aDecoder.decodeObject(forKey: "userType") as? String ?? ""
self.isValid = aDecoder.decodeBool(forKey: "isValid")
self.extractedFlag = aDecoder.decodeObject(forKey: "extractedFlag") as? String
}
func encode(with aCoder: NSCoder) {
aCoder.encode(userType, forKey: "userType")
aCoder.encode(isValid, forKey: "isValid")
aCoder.encode(extractedFlag, forKey: "extractedFlag")
}
}
Then we initialize the class and write it to disk:
let license = License(userType: "Test", isValid: false, extractedFlag: nil)
let currentDir = FileManager.default.currentDirectoryPath
let licensePath = URL(fileURLWithPath: currentDir).appendingPathComponent("license.lic")
do {
let licenseData = try NSKeyedArchiver.archivedData(withRootObject: license, requiringSecureCoding: false)
try licenseData.write(to: licensePath)
print("License object serialized to: \(licensePath.path)")
} catch {
print("Failed to serialize license: \(error)")
}
Then we run it using swift:
swift license_generator.swift
License object serialized to: <YOUR_PATH>/license.lic
And we can print it out to confirm the structure and content:
plistutil -p license.lic
{
"$version": 100000,
"$archiver": "NSKeyedArchiver",
"$top": {
"root": CF$UID:1
},
"$objects": [
"$null",
{
"isValid": false,
"$class": CF$UID:3,
"extractedFlag": CF$UID:0,
"userType": CF$UID:2
},
"Test",
{
"$classname": "License",
"$classes": [
"License",
"NSObject"
]
}
]
}
From the above snippet, we can see that the userType is set to Test
and the isValid is set to false
. Since we did not specify the extractedFlag there is no value for it.
Now letβs use the app to download this license and see if it accepts it. We can monitor the console logs in Frida to see what the app does when it downloads the license:
[Swift.print] π Attempting to fetch license from: http://192.168.2.5:9090/license.lic
[Swift.print]
[Swift.print] β
License file downloaded (258 bytes)
[Swift.print]
[Swift.print] π Attempting to deserialize license file...
[Swift.print]
[Swift.print] β Could not read premium license file
[Swift.print]
[Swift.print] π License deserialized: userType=Test, isValid=false
[Swift.print]
[Swift.print] π Remote license processing result: License processed but user is not premium
[Swift.print]
[Swift.print] β
License processed: License processed but user is not premium
The good news is that the license was processed; the bad news is that the information in the license was not valid. We need to dig through the code a bit to figure out what values it expects.
If we continue to read through the initWithCoder function in the quackify.License file we get to this snippet:
puVar1 = (undefined8 *)(puVar7 + _TtC8quackify7License::userType);
uVar20 = puVar1[1];
*puVar1 = 0x6d75696d657270;
puVar1[1] = 0xe700000000000000;
_swift_bridgeObjectRelease(uVar20);
puVar7[_TtC8quackify7License::isValid] = 1;
It seems to be assigning default values to the class:
-
userType: the value of
0x6d75696d657270
is assigned, when decoded into a string it ismuimerp
and when that value is reversedpremium
-
isValid: the value of
1
is assigned, which also evaluates totrue
Letβs try these default values to see if they work:
plistutil -p license.lic
{
"$version": 100000,
"$archiver": "NSKeyedArchiver",
"$top": {
"root": CF$UID:1
},
"$objects": [
"$null",
{
"isValid": true,
"$class": CF$UID:3,
"extractedFlag": CF$UID:0,
"userType": CF$UID:2
},
"premium",
{
"$classname": "License",
"$classes": [
"License",
"NSObject"
]
}
]
}
[Swift.print] β
License file downloaded (261 bytes)
[Swift.print]
[Swift.print] π Attempting to deserialize license file...
[Swift.print]
[Swift.print] β Could not read premium license file
[Swift.print]
[Swift.print] π License deserialized: userType=premium, isValid=true
[Swift.print]
[Swift.print] π Remote license processing result: PREMIUM ACTIVATED - Welcome premium user!
[Swift.print]
[Swift.print] β
License processed: Premium license activated!
[Swift.print]

Success! We have activated the premium features of the application. But wait, where are we supposed to get the flag from?
Where is the flag?!
This section is only applicable if you are using your own device to test with one of the first versions of the Quackify IPA file.
There is a bug in the IPA file where the flag is not located in the directory where the app is looking for it.
This bug has been fixed in the lab virtual environment, so you should have a flag there if you just followed the guide up until now.
You can use objection to explore the filesystem of the app on your own device.
If you look at the structure, you will notice a file premium-license.txt
located in the app bundle directory:
objection -g 3314 explore
Using USB device `iPhone`
Agent injected and responds ok!
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] # env
Name Path
----------------- --------------------------------------------------------------------------------------------
BundlePath /private/var/containers/Bundle/Application/9A3E50E9-0F29-4013-8D03-7D3754306B73/quackify.app
CachesDirectory /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Library/Caches
DocumentDirectory /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents
LibraryDirectory /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Library
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] # cd /private/var/containers/Bundle/Application/9A3E50E9-0F29-4013-8D03-7D3754306B73/quackify.app
/private/var/containers/Bundle/Application/9A3E50E9-0F29-4013-8D03-7D3754306B73/quackify.app
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] # file cat premium-license.txt
Downloading /private/var/containers/Bundle/Application/9A3E50E9-0F29-4013-8D03-7D3754306B73/quackify.app/premium-license.txt to /var/folders/mg/640pytks019clhq6jphlr4w40000gn/T/tmpi02nwm46.file
Streaming file from device...
Writing bytes to destination...
Successfully downloaded /private/var/containers/Bundle/Application/9A3E50E9-0F29-4013-8D03-7D3754306B73/quackify.app/premium-license.txt to /var/folders/mg/640pytks019clhq6jphlr4w40000gn/T/tmpi02nwm46.file
====
MHC{dummy}====
Then we can use a Frida hook to see what the app is trying to open and what the path of the file is:
console.log("[*] iOS file access monitoring started");
// Hook NSString file reads
var NSString = ObjC.classes.NSString;
var stringSelectors = [
"stringWithContentsOfFile:encoding:error:",
"stringWithContentsOfFile:usedEncoding:error:",
"initWithContentsOfFile:encoding:error:",
"initWithContentsOfFile:usedEncoding:error:",
];
stringSelectors.forEach(function (selName) {
try {
var sel = NSString[selName];
if (sel) {
Interceptor.attach(sel.implementation, {
onEnter: function (args) {
var filePath = new ObjC.Object(args[2]).toString();
console.log("[NSString read] " + selName + " -> " + filePath);
},
});
}
} catch (e) {
console.log("[!] Failed to hook " + selName + ": " + e);
}
});
// Hook NSData file reads
var NSData = ObjC.classes.NSData;
var dataSelectors = [
"dataWithContentsOfFile:",
"initWithContentsOfFile:",
"dataWithContentsOfURL:",
"initWithContentsOfURL:",
];
dataSelectors.forEach(function (selName) {
try {
var sel = NSData[selName];
if (sel) {
Interceptor.attach(sel.implementation, {
onEnter: function (args) {
var filePath = new ObjC.Object(args[2]).toString();
console.log("[NSData read] " + selName + " -> " + filePath);
},
});
}
} catch (e) {
console.log("[!] Failed to hook " + selName + ": " + e);
}
});
// Hook low-level POSIX open/read
var open = Module.findExportByName(null, "open");
if (open) {
Interceptor.attach(open, {
onEnter: function (args) {
var path = Memory.readUtf8String(args[0]);
console.log("[open] " + path);
},
});
}
var read = Module.findExportByName(null, "read");
if (read) {
Interceptor.attach(read, {
onEnter: function (args) {
var fd = args[0].toInt32();
var buf = args[1];
var size = args[2].toInt32();
console.log("[read] fd=" + fd + " size=" + size);
},
});
}
[Swift.print] π Checking license at: /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium.lic
[Swift.print]
[Swift.print] π Attempting to deserialize license file...
[Swift.print]
[open] /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium.lic
[read] fd=9 size=261
[open] /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium-license.txt
[Swift.print] β Could not read premium license file
[Swift.print]
[Swift.print] π License deserialized: userType=premium, isValid=true
[Swift.print]
As you can see, it is trying to read the premium-license.txt file from the Documents
directory when the file is actually located in the app Bundle
directory.
Luckily this is an easy fix for us; you can use objection to copy the premium-license.txt.
file from the Bundle
directory to the Documents
directory, reset the app data, and download the license file again.
cd /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents
/var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] # file upload premium-license.txt
Uploading premium-license.txt to /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium-license.txt
Reading source file...
Sending file to device for writing...
Uploaded: /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium-license.txt
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] #
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] # file cat premium-license.txt
Downloading /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium-license.txt to /var/folders/mg/640pytks019clhq6jphlr4w40000gn/T/tmp0b_x_is6.file
Streaming file from device...
Writing bytes to destination...
Successfully downloaded /var/mobile/Containers/Data/Application/54CCE88E-7A38-498E-A268-71AF1DBB2604/Documents/premium-license.txt to /var/folders/mg/640pytks019clhq6jphlr4w40000gn/T/tmp0b_x_is6.file
====
MHC{dummy}====
com.mobilehackinglab.quackify on (iPhone: 18.5) [usb] #
After reinstalling the app, copying the premium-license.txt file to the correct directory, and importing the license again, we have a success message in the app.

It is important to note that this is the local TEST
flag on your own device; if you import your license file on the lab machine, you will receive the REAL
flag to submit.
Final thoughts
I initially solved this lab quite early on, but because of the file bug and the flag not being displayed, I thought I was doing something wrong and was not seeing the bigger picture.
At some point I wanted to find a write-up to get some hints about what I was doing wrong, but then I realized that there was no write-up available yet because the challenge had not been solved yet.
I learned a lot from it, especially some nice Frida hooking techniques that I have not had access to before - checking file access and also finally seeing some Swift print output.
Thanks again to MobileHackingLab for the great lab and also for assisting me after hours on Discord with the questions I had about