Summary
The LuLu network extension (com.objective-see.lulu.extension) crashes with SIGABRT when a user responds to an alert with "Remote Endpoint" scope for a process with multiple concurrent connections. The crash is in processRelatedFlow: calling resumeFlow:withVerdict: with a pause verdict that should have been caught by a break. This causes the extension to restart, losing in-flight alert context — the second alert's rule is never saved.
Steps to Reproduce
- Install LuLu 4.3.0
- Delete any existing rule for
curl
- Run
curl https://example.com (resolves to multiple IPs, e.g. 104.18.26.120 and 104.18.27.120)
- First alert appears → keep Rule Scope as "Remote Endpoint" (default), click Block or Allow
- Extension crashes immediately → launchd restarts a new extension process
- Second alert may appear but its rule is never saved
Note: With "Process" scope the crash is not guaranteed — a process-wide rule matches all related flows, so processEvent returns allow/drop instead of pause. The crash can still occur if the related flow has been invalidated by the system during the wait, but it is intermittent rather than 100% reproducible.
Crash Log
Exception Type: EXC_CRASH (SIGABRT)
Termination: Abort trap: 6
Thread 1 Crashed (com.apple.NSXPCConnection.user...):
__exceptionPreprocess
objc_exception_throw
__63-[NEFilterDataExtensionProviderContext resumeFlow:withVerdict:]_block_invoke
-[NEFilterDataProvider resumeFlow:withVerdict:]
-[FilterDataProvider processRelatedFlow:]
__45-[FilterDataProvider alert:process:csChange:]_block_invoke
__36-[XPCUserClient deliverAlert:reply:]_block_invoke.1
__NSXPCCONNECTION_IS_CALLING_OUT_TO_REPLY_BLOCK__
Full crash reports attached: 4 crashes from the original LuLu 4.3.0 (com.objective-see.lulu.extension), all identical stack.
Unified Logging (ULS) correlation
Enable debug log persistence and query with:
sudo log config --mode "level:debug,persist:debug" --subsystem "com.objective-see.lulu"
# reproduce the crash, then:
log show --predicate 'subsystem == "com.objective-see.lulu"' --start '...' --debug --style compact
The crash timeline (LuLu subsystem logs, timestamps from actual reproduction):
22:06:18.002 (user) response: "block" for /usr/bin/curl → 104.18.26.120:443
22:06:18.002 adding rule: com.apple.curl → 104.18.26.120:443 (endpoint-specific)
22:06:18.009 saved rule to disk
22:06:18.009 removing alert from 'shown': com.apple.curl
22:06:18.009 processing 2 related flow(s) for com.apple.curl
22:06:18.009 no (saved) rule found for curl ← related flow to different IP, doesn't match
22:06:18.009 while signed by apple, curl is gray listed, so will alert
22:06:18.009 delivering alert ← processEvent returns pauseVerdict
← [pauseVerdict isEqual:verdict] returns NO, break doesn't fire
← resumeFlow called with pauseVerdict → SIGABRT
Separately, Apple's NetworkExtension framework logs (queried with process == "com.objective-see.lulu.extension") show flow invalidation warnings during the user wait period:
[com.apple.networkextension:] No current verdict available, cannot report flow closed
These indicate that paused flows' underlying TCP connections were closed by the OS before the user responded. This is relevant to Bug 2 below.
Root Cause
Two bugs in processRelatedFlow: (introduced in v4.3.0):
Bug 1: isEqual: comparison for pauseVerdict always fails
NEFilterNewFlowVerdict* pauseVerdict = [NEFilterNewFlowVerdict pauseVerdict];
// ...
if([pauseVerdict isEqual:verdict]) { // ← ALWAYS returns NO
break;
}
[self resumeFlow:flow withVerdict:verdict]; // ← called even for pause verdicts
NEFilterNewFlowVerdict does not override isEqual: (inherits NSObject pointer comparison). +[NEFilterNewFlowVerdict pauseVerdict] allocates a new instance on every call. So two different pauseVerdict instances are never equal via isEqual:.
Result: the break never fires → pause verdicts fall through to resumeFlow:withVerdict: → Apple's framework throws: "Verdict argument cannot be a pause verdict".
Verification (by reverse-engineering the NetworkExtension framework binary):
+[NEFilterNewFlowVerdict pauseVerdict]: calls objc_alloc + [super init] + sets internal _pause = 1 — creates a new instance every call, not a singleton
NEFilterVerdict and subclasses: no isEqual: implementation — inherits NSObject default (pointer comparison)
-[NEFilterDataExtensionProviderContext resumeFlow:withVerdict:]: checks verdict->_pause & 1 and throws NSException if set
Bug 2: Flows invalidated while waiting for user response
Related flows are paused via handleNewFlow: returning pauseVerdict. While the user decides on the first alert, the paused flows' underlying TCP connections may be closed by the OS or remote endpoint (as shown by the "No current verdict available" warnings in the ULS logs above).
When processRelatedFlow: later calls resumeFlow:withVerdict: on these invalidated flows, the framework throws an exception. Without @try/@catch, this crashes the extension. This bug affects both "Remote Endpoint" and "Process" scope, but is intermittent — it only triggers when the flow is invalidated before the user responds.
Suggested Fix
Fix 1: Shared pauseVerdict singleton
Cache a single pauseVerdict instance and use == pointer comparison:
static NEFilterNewFlowVerdict* PauseVerdict(void)
{
static NEFilterNewFlowVerdict* verdict = nil;
static dispatch_once_t onceToken = 0;
dispatch_once(&onceToken, ^{
verdict = [NEFilterNewFlowVerdict pauseVerdict];
});
return verdict;
}
Replace all verdict = [NEFilterNewFlowVerdict pauseVerdict] with verdict = PauseVerdict(), and change the comparison to:
if(verdict == PauseVerdict()) {
break;
}
Fix 2: @try/@catch around all resumeFlow:withVerdict: calls
@try {
[self resumeFlow:flow withVerdict:verdict];
}
@catch(NSException* exception) {
os_log_error(logHandle, "ERROR: failed to resume flow: %{public}@", exception);
}
Three call sites need protection:
- Alert reply handler — resuming the original flow after user response
- Delivery failure fallback — allowing the flow when XPC delivery fails
processRelatedFlow: loop — resuming related flows
Fix 3 (optional): Snapshot pattern for processRelatedFlow
Process related flows outside the @synchronized lock to avoid holding it during processEvent: (which may call addRelatedFlow: on the same lock via re-entrancy):
// take snapshot under lock
@synchronized(self.relatedFlows) {
snapshot = [flows mutableCopy];
}
// process outside lock
for(...) {
verdict = [self processEvent:flow];
if(verdict == PauseVerdict()) break;
@try { [self resumeFlow:flow withVerdict:verdict]; } @catch(...) { ... }
[completed addObject:flow];
}
// cleanup under lock
@synchronized(self.relatedFlows) {
[flows removeObjectsInArray:completed];
}
Impact by Rule Scope
| Rule Scope |
Crash |
Reason |
| Remote Endpoint (default) |
100% reproducible |
Endpoint-specific rule doesn't match related flows to other IPs → processEvent returns pauseVerdict → isEqual: fails → pauseVerdict passed to resumeFlow → "Verdict argument cannot be a pause verdict" |
| Process |
Intermittent |
Process-wide rule matches all related flows → processEvent returns allow/drop → resumeFlow succeeds if flow is still valid. Crashes only when flow has been invalidated by the system during user wait ("No current verdict available, cannot report flow closed") |
Consequences
- Extension crash → launchd restarts new extension process with no alert context
- Second alert's rule is never saved → users see repeated alerts that never persist
- Extension PID changes → App's XPC reply for in-flight alerts goes to the wrong process
Affected Versions
- v4.3.0 and later (introduced by commits
ead1dab + 44b1569)
- v4.2.1 and earlier are not affected (but have a flow leak — related flows are never resumed)
Environment
- macOS 26.3.1 (25D2128)
- LuLu 4.3.0
- Apple Silicon (Mac16,5)
crash.zip
Summary
The LuLu network extension (
com.objective-see.lulu.extension) crashes with SIGABRT when a user responds to an alert with "Remote Endpoint" scope for a process with multiple concurrent connections. The crash is inprocessRelatedFlow:callingresumeFlow:withVerdict:with a pause verdict that should have been caught by abreak. This causes the extension to restart, losing in-flight alert context — the second alert's rule is never saved.Steps to Reproduce
curlcurl https://example.com(resolves to multiple IPs, e.g. 104.18.26.120 and 104.18.27.120)Note: With "Process" scope the crash is not guaranteed — a process-wide rule matches all related flows, so
processEventreturns allow/drop instead of pause. The crash can still occur if the related flow has been invalidated by the system during the wait, but it is intermittent rather than 100% reproducible.Crash Log
Full crash reports attached: 4 crashes from the original LuLu 4.3.0 (
com.objective-see.lulu.extension), all identical stack.Unified Logging (ULS) correlation
Enable debug log persistence and query with:
The crash timeline (LuLu subsystem logs, timestamps from actual reproduction):
Separately, Apple's NetworkExtension framework logs (queried with
process == "com.objective-see.lulu.extension") show flow invalidation warnings during the user wait period:These indicate that paused flows' underlying TCP connections were closed by the OS before the user responded. This is relevant to Bug 2 below.
Root Cause
Two bugs in
processRelatedFlow:(introduced in v4.3.0):Bug 1:
isEqual:comparison for pauseVerdict always failsNEFilterNewFlowVerdictdoes not overrideisEqual:(inheritsNSObjectpointer comparison).+[NEFilterNewFlowVerdict pauseVerdict]allocates a new instance on every call. So two differentpauseVerdictinstances are never equal viaisEqual:.Result: the
breaknever fires → pause verdicts fall through toresumeFlow:withVerdict:→ Apple's framework throws: "Verdict argument cannot be a pause verdict".Verification (by reverse-engineering the NetworkExtension framework binary):
+[NEFilterNewFlowVerdict pauseVerdict]: callsobjc_alloc+[super init]+ sets internal_pause = 1— creates a new instance every call, not a singletonNEFilterVerdictand subclasses: noisEqual:implementation — inheritsNSObjectdefault (pointer comparison)-[NEFilterDataExtensionProviderContext resumeFlow:withVerdict:]: checksverdict->_pause & 1and throws NSException if setBug 2: Flows invalidated while waiting for user response
Related flows are paused via
handleNewFlow:returningpauseVerdict. While the user decides on the first alert, the paused flows' underlying TCP connections may be closed by the OS or remote endpoint (as shown by the "No current verdict available" warnings in the ULS logs above).When
processRelatedFlow:later callsresumeFlow:withVerdict:on these invalidated flows, the framework throws an exception. Without@try/@catch, this crashes the extension. This bug affects both "Remote Endpoint" and "Process" scope, but is intermittent — it only triggers when the flow is invalidated before the user responds.Suggested Fix
Fix 1: Shared pauseVerdict singleton
Cache a single
pauseVerdictinstance and use==pointer comparison:Replace all
verdict = [NEFilterNewFlowVerdict pauseVerdict]withverdict = PauseVerdict(), and change the comparison to:Fix 2:
@try/@catcharound allresumeFlow:withVerdict:callsThree call sites need protection:
processRelatedFlow:loop — resuming related flowsFix 3 (optional): Snapshot pattern for processRelatedFlow
Process related flows outside the
@synchronizedlock to avoid holding it duringprocessEvent:(which may calladdRelatedFlow:on the same lock via re-entrancy):Impact by Rule Scope
processEventreturns pauseVerdict →isEqual:fails → pauseVerdict passed toresumeFlow→ "Verdict argument cannot be a pause verdict"processEventreturns allow/drop →resumeFlowsucceeds if flow is still valid. Crashes only when flow has been invalidated by the system during user wait ("No current verdict available, cannot report flow closed")Consequences
Affected Versions
ead1dab+44b1569)Environment
crash.zip