Every iOS RASP demo opens with the same thing: detect Frida. Name the loaded image, hash the library, probe the default port 27042. Pass that, you’re done. Ship it. Production.
In 2026 that’s not enough, and it wasn’t enough in 2024 either. Frida is the most famous injection toolchain, not the only one. A serious adversary doing token theft, in-app purchase fraud, or coordinated abuse on your app isn’t going to politely use the named tool the entire mobile-security industry calibrates against. They’ll use the ones that don’t show up in the canonical detection list.
Here’s what the long tail actually looks like, and how a layered detector catches what name-pattern matching doesn’t.
What “Frida-only” detection gets wrong
The canonical Frida check is four probes:
dlopen("/usr/lib/FridaGadget.dylib", RTLD_NOLOAD)returns non-null- A loaded image’s
mh_filenamecontainsfrida(case-insensitive) - TCP
connect(127.0.0.1, 27042)succeeds (the default Frida server port) - The
tmp/frida-serverortmp/re.frida.serverfile exists
Together those catch ~80% of frida-on-jailbreak setups. They catch ~0% of:
- Theos hooks (
%hookdirectives compiled into a.dylibthat gets injected via Cydia Substrate, libhooker, or ElleKit on rootless jailbreaks). The dylib name is whatever the attacker chose; the loaded image has no string the canonical check pattern-matches. - dyld interposing (
__attribute__((section("__DATA,__interpose")))in a custom.dylib). The Mach-O loader replaces target symbols at load time, no runtime API call to detect from inside your process, because the rerouting happens before your code runs. - ObjC method swizzling done from a malicious
.dylib. The selector→IMP table mutation leaves no string artifact the name check matches; your only signal is structural (the IMP for-[NSPasteboard stringForType:]no longer points inside the Foundation framework). DYLD_INSERT_LIBRARIESinjection on a debugger-attached process. The Frida-detection list doesn’t even look at the dyld environment, because Apple’s hardened-runtime should block it, but a determined attacker can disable hardened runtime via re-signing.
If your detection layer only knows about Frida, you’re effectively running a tripwire that only catches the bypasses that didn’t know to step around it.
Layer 1, Image-table walk, no name patterns
Forget string contains "frida". Iterate every loaded Mach-O image and check what it is, not what it’s called:
import MachO
func suspiciousImages() -> [String] {
var matches: [String] = []
let count = _dyld_image_count()
for i in 0..<count {
guard let cname = _dyld_get_image_name(i) else { continue }
let path = String(cString: cname)
// Heuristics that DON'T rely on the dylib's name:
// (a) loaded from a path no Apple-shipped framework lives in
// (b) writable by the user (not on the signed system volume)
// (c) the binary's __TEXT __cstring section contains
// "MSHookFunction", "ZN12substrate", or other
// Substrate/Theos-style symbols
// (d) no team identifier on its embedded code signature
if isOutsideAppleVolume(path) && hasSubstrateStringTable(path) {
matches.append(path)
}
}
return matches
}
isOutsideAppleVolume checks whether the path is under /var/jb, /var/containers/Bundle/Application/<other-app>/, or any rootless-jailbreak marker directory. hasSubstrateStringTable does a memory-mapped scan of the dylib’s __cstring segment for the runtime function names that Theos/Substrate/ellekit-hooked binaries inevitably include (MSHookFunction, MSGetImageByName, MSHookMessageEx, the C++-mangled forms _Z14MSHookFunction, and so on).
This catches the modern bypass kits, Dopamine’s userland modules, ElleKit’s substitute libraries, libhooker’s hooks, that the name-pattern check sees as just another nameless .dylib.
Layer 2, Image-count delta from a baseline
Capture the image count at SDK init time and compare on every gated operation:
final class ImageCountBaseline {
static let captured: UInt32 = _dyld_image_count()
}
func didImageCountDelta() -> Bool {
// Apple frameworks lazy-load. iOS itself may bring in 3-6 frameworks
// post-launch (NetworkExtension, Vision, MapKit-on-demand). A delta
// up to ~10 is normal; > 30 since launch is suspicious.
let now = _dyld_image_count()
let delta = Int(now) - Int(ImageCountBaseline.captured)
return delta > 30
}
This is your safety net for injection that happens after your detection runs, a Theos hook that lazy-injects to evade an early scan still bumps the count. False positives exist (large apps with many on-demand frameworks), but threshold-tuning + correlating with other signals removes them.
Layer 3, Method-IMP integrity for hot selectors
Pick the 5-10 selectors a token-theft attacker would target, URLSession:dataTaskWithRequest:, -[NSPasteboard stringForType:], evaluatePolicy:localizedReason:reply:, -[NSURLRequest URL], +[NSJSONSerialization JSONObjectWithData:options:error:], and verify their IMPs live inside the appropriate framework’s __TEXT segment at the time you care.
func selectorIMPInsideExpectedFramework(_ sel: Selector,
class cls: AnyClass,
expectedSegmentName: String) -> Bool {
guard let method = class_getInstanceMethod(cls, sel) else { return true }
let imp = method_getImplementation(method)
var info = Dl_info()
guard dladdr(unsafeBitCast(imp, to: UnsafeRawPointer.self), &info) != 0 else {
return false
}
let dli_fname = String(cString: info.dli_fname)
return dli_fname.contains(expectedSegmentName)
}
If +[NSURLSession dataTaskWithRequest:]’s IMP is in /System/Library/Frameworks/CFNetwork.framework/CFNetwork, normal. If it’s in /var/jb/Library/MobileSubstrate/DynamicLibraries/SomeHook.dylib, swizzled.
This layer specifically catches the ObjC swizzling pathway that the Frida-only check is blind to.
Layer 4, dyld interposing footprint
Interposed symbols leave a structural artifact: the resolved address for a function like read, write, connect no longer matches the canonical libSystem entry. Compare:
import Darwin
func connectIsInterposed() -> Bool {
var info = Dl_info()
guard dladdr(unsafeBitCast(Darwin.connect, to: UnsafeRawPointer.self), &info) != 0 else {
return false
}
let path = String(cString: info.dli_fname)
return !path.contains("libsystem_kernel") && !path.contains("/usr/lib/system/")
}
The interposer redirects the symbol; the dynamic linker records the new resolution as living in the injected dylib, not libSystem. A network-stack adversary who interposes connect to silently route traffic through a proxy is now visible from inside the process.
Layer 5, Hardened-runtime self-check
Confirm your own binary still has the hardened-runtime flag set at runtime. An attacker who re-signs your IPA to disable hardened-runtime (so they can DYLD_INSERT_LIBRARIES your process) leaves a visible signal: the executable’s load commands include LC_CODE_SIGNATURE but the cryptographic flags field is wrong.
const struct mach_header_64 *header = _dyld_get_image_header(0);
// Walk LC_CODE_SIGNATURE, parse the SuperBlob, check the cs_flags field.
// If kSecCodeSignatureRuntime (0x10000) is clear but you compiled with
// hardened-runtime, somebody resigned you.
This is the layer that catches “everything else”, an attacker who avoided every other detection by re-signing the IPA itself shows up here.
Why the fall-through matters
Each layer alone has a false-positive rate. A user with 47 customizations on Dopamine might trip layer 2 on legitimate use. A debug-build TestFlight ticket might fail layer 5. The point is fusion: a process that fails two or more layers simultaneously, on a real customer’s device, is overwhelmingly an adversary, not noise.
The right wiring is a typed SecurityReport that catalogs which layers fired, lets your app’s policy code decide what to do (refuse a session, gate a feature, require step-up, log to your SIEM), and emits a single structured signal, not five different ad-hoc booleans scattered through your codebase.
That’s what RuntimeGuard SDK’s SecurityEngine does. It bundles all five layers above (and a handful of additional probes that don’t fit neatly in a single blog post, App Attest cross-checking, MOH timestamp drift, library validation enforcement), aggregates them into a typed report, maps them to the injection vectors catalogd in the RuntimeGuard threat model, and ships with the privacy-manifest declarations the iOS 17+ App Store review process requires for the required-reason filesystem and clipboard APIs each probe touches.
Frida-only checks were table stakes in 2020. In 2026, the toolchain has long-tail’d. Your detection should too.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.