Skip to content

Network Extension crashes (SIGABRT) in processRelatedFlow due to pauseVerdict comparison bug #846

@fjh658

Description

@fjh658

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

  1. Install LuLu 4.3.0
  2. Delete any existing rule for curl
  3. Run curl https://example.com (resolves to multiple IPs, e.g. 104.18.26.120 and 104.18.27.120)
  4. First alert appears → keep Rule Scope as "Remote Endpoint" (default), click Block or Allow
  5. Extension crashes immediately → launchd restarts a new extension process
  6. 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:

  1. Alert reply handler — resuming the original flow after user response
  2. Delivery failure fallback — allowing the flow when XPC delivery fails
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions