NSPredicate is a class in Apple's Foundation framework. Apple's documentation describes it as "a definition of logical conditions for constraining a search for a fetch or for in-memory filtering." Think of it as C#'s LINQ, specifically the where clause, or a lightweight SQL filter. You write things like:
That feels harmless. But NSPredicate is built on top of NSExpression, and NSExpression supports a much richer syntax than simple comparisons. Since OS X 10.5 (2007, the dawn of iOS), NSFunctionExpression has supported the FUNCTION keyword, which allows arbitrary method invocations on arbitrary objects:
There is also CAST, which grants reflection-based access to any Objective-C class:
Together, these keywords turn NSPredicate format strings into what Security Researcher CodeColorist called the eval() of Objective-C. It is a full scripting language embedded in what looks like a simple filter API. As Project Zero's writeup notes: "NSPredicates using the FUNCTION keyword are effectively Objective-C scripts."
Security researcher CodeColorist created a CTF challenge on a real iPhone XR requiring contestants to achieve code execution through a calculator app that evaluated NSExpression(format: userInput). The solution demonstrated that FUNCTION + CAST could call arbitrary methods, and that [CNFileServices dlsym::] combined with [NSInvocation invokeUsingIMP:] could bypass both ASLR and PAC to call any C function.
CodeColorist published the blog post and talk See No Eval: Runtime Dynamic Code Execution in Objective-C, publicly documenting that NSExpression and NSPredicate format strings are a code injection vector. The post identified the vulnerable API families (predicateWithFormat:, expressionWithFormat:, etc.) and noted that parameter binding remains safe. The post also described Apple's NSPredicateVisitor protocol as a potential defense, and warned that without proper validation, it could be an inter-process attack surface.
Citizen Lab revealed a zero-click iMessage exploit used by NSO Group's Pegasus to target a Saudi activist. Google Project Zero's analysis showed the sandbox escape relied entirely on logic bugs instead of memory corruption. The JBIG2 virtual machine's sole purpose was to deserialize and evaluate an NSFunctionExpression, which then sent a crafted NSPredicate via NSXPC to the unsandboxed CommCenter process. The trick was a PTSection containing a PTRow whose condition was an attacker-controlled NSPredicate. When deserialized, [PTRow initWithCoder:] called [self->condition allowEvaluation], and [PTSection _shouldEnableRow:] then called [row->condition evaluateWithObject:self->settings], finally executing arbitrary code in CommCenter.
In response, Apple added:
_NSPredicateUtilities were allowed in FUNCTION expressions.CAST("SomeClass", "Class") was forbidden.NSBundle, NSInvocation, NSCoder) that posed clear security risks.[CNFileServices dlsym::] was removed; a magic canary was added to NSInvocation.These were enforced by a global variable __predicateSecurityFlags but only for first-party Apple processes. Third-party apps had a near-empty denylist and were still fully exploitable.
Austin Emmitt at Trellix Advanced Research Center discovered that Apple's mitigations could be completely bypassed:
[NSValue getValue:] or later [NSString getCString:]), an attacker could perform an arbitrary write to overwrite __predicateSecurityFlags, set denylist lengths to zero, and restore NSPredicate to its fully unrestricted state. A single flag value controlled all security restrictions.NSPredicateVisitor to validate incoming predicates. These visitors check the expressionType property to decide whether additional scrutiny is needed (e.g., filtering out function expressions). But expressionType is read directly from serialized data, and the sender sets it. By setting all expressionType values to 0 (constant value), function expressions masquerade as harmless constants and bypass all validation.With these bypasses, Emmitt demonstrated code execution in coreduetd (root on macOS, accessing calendar/photos), appstored (installing arbitrary apps), OSLogService (reading sensitive logs), and SpringBoard on iPad, which can access camera, microphone, location, call history, and wipe the device. He also found a PAC bypass replacement for the removed dlsym gadget: +[DTCompanionControlServiceV2 dlsymFunc] invoked via -[RBStrokeAccumulator applyFunction:info:].
These were fixed in iOS 16.3 and macOS 13.2.
When untrusted input becomes part of the predicate string, the parser treats it as predicate syntax. An attacker can inject any predicate keyword to build a query as well as FUNCTION and CAST expressions to call arbitrary Objective-C methods in the target process. While looking for predicate injections, try to search for predicate-related symbols and strings.
Predicates are built using Objective-C method calls, so start by searching the symbol table for selector references:

In Swift binaries, the symbols are mangled. A hit like sym._$sSo11NSPredicateC10FoundationE6format_ABSSh_s7CVarArg_pdtcfC decodes to NSPredicate.init(format:). The U flag means it is imported from Foundation, confirming the app calls it.
Next, search for predicate query fragments in the binary's strings:

Fragmented strings like username == ' and ' AND password == ' appearing separately is a strong indicator. It means the predicate is being constructed dynamically by concatenating or interpolating user input between those fragments. A single static predicate string like username == %@ AND password == %@ is safe (that is parameter binding). Fragments split around user input are the vulnerability.
Use a disassembler/decompiler like radare2 to find which class and method constructs the predicate:
Once you identify the method, use a disassembler/decompiler like radare2 to disassemble it and trace how the predicate string is built.
Look for:
NSPredicate.init(format:) or predicateWithFormat:If user-controlled data reaches the predicate string through concatenation or interpolation before it hits the parser, you have found a predicate injection.
If the predicate is passed to evaluateWithObject: or filteredArrayUsingPredicate:, it gets executed against an object in the target process.
Objective-C and Swift code that concatenates untrusted input into predicate strings is essentially running an interpreter on attacker data. This is the same mistake as SQL injection: treating user input as part of a query language, not a value.
A defensive approach should include:
%@ substitution in predicateWithFormat: over string concatenation. This treats user input as a literal value, not predicate syntax.NSPredicate.predicateWithBlock: instead of string-based predicates where possible, since blocks don't parse any language.NSPredicate objects: any predicate received over XPC or from a file should be considered untrusted. Validate all expressionType fields independently, do not trust the deserialized values.
You can try our free lab Injecta Vault to learn how predicate injection vulnerabilities work.
Copyright © 2024 (function() { if (!window.opener) return; if (window.location.search.indexOf('msg=not-logged-in') !== -1) return; if (window.location.pathname.indexOf('signin') !== -1) return; function send(email, lwToken) { if (!email || email.indexOf('@') === -1) return; window.opener.postMessage({ type: 'mhl-lw-login', email: email.toLowerCase().trim(), lwToken: lwToken || '' }, '*'); setTimeout(function() { try { window.close(); } catch(e) {} }, 500); } function findToken() { // Scan localStorage for any JWT that contains an email — that's the LW user token try { var keys = Object.keys(localStorage); for (var i = 0; i < keys.length; i++) { var v = localStorage.getItem(keys[i]); if (!v || v.split('.').length !== 3) continue; try { var p = JSON.parse(atob(v.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); if (p && p.email) return { email: p.email, token: v }; } catch(e) {} } } catch(e) {} return null; } function tryGlobals() { var checks = [window.LW && window.LW.user, window.learner, window.__learner, window.lwUser, window.currentUser]; for (var i = 0; i < checks.length; i++) { if (checks[i] && checks[i].email) return checks[i].email; } return null; } function tryApis(done) { var endpoints = ['/api/v2/me', '/api/v2/learner', '/api/v2/learner/profile']; var i = 0; function next() { if (i >= endpoints.length) return done(null); fetch(endpoints[i++], { credentials: 'include' }) .then(function(r) { return r.ok ? r.json() : null; }) .then(function(d) { if (!d) return next(); var e = d.email || (d.data && d.data.email) || (d.user && d.user.email); e ? done(e) : next(); }).catch(next); } next(); } window.addEventListener('load', function() { // Best case: find token in localStorage (has email + is the LW JWT) var found = findToken(); if (found) return send(found.email, found.token); // Try JS globals var email = tryGlobals(); if (email) return send(email, ''); // Try LW's own same-origin API tryApis(function(email) { if (email) send(email, ''); }); }); })();
Socials