Vulnerability Research

The First GC Run Turned Off the Light While the Second Kept Collecting — Reconstructing CVE-2026-53361

CVE-2026-53361 let a queued AF_UNIX garbage-collector rerun execute while gc_in_progress was false, so an overlapping SCM_RIGHTS MSG_PEEK could pass without invalidating the collector's socket-graph snapshot; this reconstruction follows the two-run workqueue schedule, the stale SCC decision, the worker-entry repair, the unusual stable-version map, and the evidence needed to patch and validate a running Linux fleet.

Hand-drawn technical cover: the first AF_UNIX garbage-collection shift leaves and switches off a work lamp while a second queued shift enters the same two-socket cleanup room; a MSG_PEEK clerk duplicates a brass file handle while the original envelope remains on the cycle.
In this article

Research basisSOSEC Security Research · Locally reviewed on July 17, 2026 against the Linux CNA JSON, the 2021 peek barrier, the 2024 workqueue and SCC series, the March and May 2026 fixes, official kernel documentation, four stable fixes, and current Debian and Ubuntu status pages

SourceLinux CNA / fixed Linux source objects / kernel workqueue documentation / Linux man-pages / vendor security trackers / SOSEC source review

1 The room looked idle because the previous shift switched off its lamp

Imagine an AF_UNIX cleanup room after midnight. One garbage-collection shift has finished walking a circular arrangement of socket queues. On its way out, that worker turns off the lamp marked gc_in_progress. A second shift has already been placed behind it on the same workqueue and now enters the room. The machinery is running, the socket graph is being judged, but the lamp stays dark. A clerk performing MSG_PEEK looks at that lamp, concludes that no collector is present, and passes without ringing the sequence bell.

That is the complete shape of CVE-2026-53361. It is not two garbage collectors simultaneously tearing through one graph. Linux workqueue serialization still makes the two callbacks run one after the other. The defect is subtler: one scheduling action wrote true for a future rerun, the first callback later erased that state, and the rerun did not write true again when it actually began. A Boolean meant to describe execution was owned by callers that could no longer describe every execution.

The dark lamp became security-relevant only because another protocol trusted it. In March 2026, Linux added unix_peek_fpl() and unix_peek_seq so an SCM_RIGHTS peek could invalidate AF_UNIX garbage collection while the collector compared file references across a strongly connected component. The fast path deliberately returned immediately when gc_in_progress was false. Two months later, Igor Ushakov reported that a real rerun could inhabit exactly that false state.

The May repair is only a three-line behavioral move. Commit d82ba05263c6 writes true at the beginning of unix_gc(), removes the scheduler's true write, and keeps the existing false write at worker completion. Small diffs often tempt short explanations, yet the line makes sense only after four histories meet: descriptor passing creates cycles; SCC analysis tries to collect them; peek silently changes file counts; workqueue permits a running work item to become pending for another pass.

The code and response can be stated early. The flaw is in net/unix/garbage.c: unix_schedule_gc() submits the work, unix_gc() is the mainline callback, older stable trees may use __unix_gc(), and unix_peek_fpl() is the peek coordination point. Mainline repair is d82ba05263c6; published fixed floors are 6.6.144, 6.12.95, 6.18.38, and 7.1, while 6.1 is affected from 6.1.141 without a fixed floor in the CNA snapshot. Permanent remediation is a vendor-supported fixed kernel followed by a reboot into the new boot. During the window, pausing untrusted workloads or applying existing policy to descriptor passing that is not needed can reduce opportunity; a firewall or an installed-but-unbooted package cannot replace the repair.

Each subsystem can look correct when reviewed alone. Workqueue serializes one work item, so there is no parallel callback to blame. The seqcount reader rejects a component whenever a relevant writer advances the sequence. scm_fp_dup() legitimately acquires references for the peeking process, and the SCC code legitimately purges components whose counts are explained entirely by internal edges. The failure appears only after the activity flag links those systems: workqueue creates an execution that the scheduler's earlier write no longer covers, and peek uses that uncovered interval as permission to stay silent. Reconstructing that composition is the reason this report begins with a dark lamp and then walks backward through descriptor ownership.

1.1 Six moments turn one true write into a false second run

Start with gc_in_progress == false and two callers, T2 and T3, entering unix_schedule_gc(). T2 reads false and passes the conditional. Before it writes anything, T3 also reads false and passes. Each caller has now committed to the body of an ordinary C if; neither will re-evaluate its condition after being preempted. The race does not require a torn read, an exotic weak-memory result, or a second copy of the work structure.

T2 resumes, executes WRITE_ONCE(gc_in_progress, true), and calls queue_work(system_dfl_wq, &unix_gc_work). The first callback starts and consumes the work item's pending state. T3 later resumes from inside the branch it passed earlier; the C branch decision is already made, so T3 does not reread the now-lit flag. It writes true again and calls queue_work() for the same work item while that callback is running. Because the pending state has been consumed, workqueue can accept the request and mark the running item pending for one later callback. If T3 arrived before the first callback began, its queue attempt would merely find pending work and the two-run failure would not form. That successful running-to-pending transition is the hinge of the story.

The first unix_gc() finishes its graph work and executes WRITE_ONCE(gc_in_progress, false). Nothing is wrong with that write when viewed from the first callback: its own work is complete. The queued callback then begins, but the old code has no true write in the worker. T3's earlier assignment has already been overwritten. From the second entry until its final cleanup, READ_ONCE(gc_in_progress) can report false while AF_UNIX GC is plainly active.

If an SCM_RIGHTS message is peeked during that interval, unix_peek_fpl() first verifies that the copied file list exists and contains AF_UNIX descriptors. It then reads the dark lamp. False causes an immediate return before raw_write_seqcount_barrier(&unix_peek_seq). The caller receives duplicated file references, but unix_scc_dead() sees no sequence change and may accept reference-count observations made on opposite sides of the peek.

The corrected ownership rule is precise: each callback that can inspect the graph must establish true on its own entry and clear it on its own exit. A narrow false gap between the first exit and second entry is harmless because no graph inspection occurs in that gap. Once the second callback crosses its entry, the lamp must remain on through the last SCC decision. This property is stronger and simpler than asking schedulers to predict how many callbacks one submission history will create.

The Linux CNA publishes essentially this three-thread schedule, identifies net/unix/garbage.c, and names the reliance in unix_peek_fpl(). It does not assign a CVSS vector or CWE in the source record, and it does not claim a public exploit or activity in the wild. Our reconstruction therefore treats the false worker state as confirmed, connects it to the public peek/SCC failure pattern as a source-based inference, and leaves stronger exploit outcomes unclaimed.

Those claim levels stay separate throughout the report. “Confirmed by the CVE fix” covers the false rerun and the skipped activity premise. “Confirmed by the direct predecessor” covers the A/B count sequence and alive-queue purge when a peek notification is missed. “Inferred by composition” covers that predecessor schedule landing inside this CVE's dark rerun. Vendor priority and possible service symptoms are operational context. Keeping the labels distinct lets a reader challenge one link without discarding the entire reconstruction, and it prevents an absent CVSS field from being silently replaced by an author's guess.

1.2 The evidence chain separates source behavior from running state

The Linux CNA JSON published July 4, 2026 anchors the public description and version ranges. A fixed sequence of commits anchors the control flow: the 2021 peek barrier cbcf01128d0a, the January 2024 workqueue conversion 8b90a9f819dc, the SCC series beginning with 3484f063172d, the March 2026 sequence protocol e5b31d988a41, and the May repair d82ba05263c6. The CNA, commits, kernel documentation, vendor pages, and booted-host evidence answer different questions and cannot substitute for one another.

The commit object dates the mainline repair to May 2026: Kuniyuki Iwashima authored it on May 1, and Jakub Kicinski committed it on May 4. July 1 stable backports and the July 4 CNA publication came later. Mixing those events creates a fictitious six-week unfixed interval and obscures which source state each vendor inherited.

The official workqueue documentation supplies the API premise. queue_work() returns false when work is already pending, but a currently executing work item can be made pending for another execution under the documented pending/running rules. That distinction is visible in the published schedule: T2 creates the running instance; T3 reaches queue_work() after the worker has consumed its pending state, so the same struct work can acquire a new pending run. flush_work() waits for the relevant work to finish, including the state the API promises at the call. Neither API promises that a Boolean stored by a scheduler remains paired with every later callback. The AF_UNIX code had created that extra promise by convention, then violated it.

Linux man-pages supply the user-visible premise. SCM_RIGHTS transfers references to open files through ancillary data on AF_UNIX sockets; MSG_PEEK returns queued data without removing that message from the receive queue. Source then supplies the hidden step: unix_peek_fds() calls scm_fp_dup(), increasing file references, and passes the duplicated list to unix_peek_fpl(). The message stays queued while the process gains another handle.

Vendor pages are dated operational lookups, not root-cause sources. As of July 17, Ubuntu marked the issue Medium and showed different results across generic, HWE, cloud, and appliance kernel flavors. Debian's tracker showed 6.12.95-1 fixed in trixie security while bookworm's 6.1.176-1 remained vulnerable. A single distribution label cannot compress those matrices; an asset record needs the exact source package, flavor, build, and booted kernel.

The table below separates what each evidence class can prove. It keeps narrative confidence honest: a commit establishes behavior, a CNA establishes published ranges, a package tracker establishes a product snapshot, and a local boot ID establishes what a machine is executing. None can silently substitute for the others. The same separation governs later statements about impact, detection, and remediation.

EvidenceWhat it establishesWhat it cannot establish aloneReview use
Linux CNA JSONPublished description, file, Git ranges, fixed floorsComplete exploit outcome or vendor package stateVersion and claim anchor
Fixed Git objectsControl flow, state ownership, commit datesWhat a custom binary is runningMechanism reconstruction
Kernel documentationWorkqueue and seqcount API contractsAF_UNIX caller correctnessConcurrency interpretation
Vendor trackerDated package/flavor statusEquivalent source in an unlisted buildDeployment lookup
Booted-host evidenceExecuting build, boot ID, configurationUpstream intent without source mappingFinal remediation proof
On mobile, scroll horizontally to compare evidence scope and the conclusions each source supports.

2 Before the worker arrives, file descriptors have already become a graph

AF_UNIX garbage collection exists because reference counting alone cannot reclaim every socket. Suppose process P sends socket A through socket B using SCM_RIGHTS, then sends B through A and closes its ordinary descriptors. Each queued message holds a reference to the other socket's file. Neither file count reaches zero, although no process can necessarily reach the pair. The receive queues have built a self-sustaining cycle that ordinary close semantics cannot recognize as garbage.

Linux names descriptors carried in unreceived AF_UNIX messages “in flight.” The file list records both total entries and how many refer to AF_UNIX sockets. When such a list joins a receive queue, unix_add_edges() and related helpers represent its relationships in the collector's graph. A socket file becomes a vertex; a queued descriptor creates an edge from the receiving socket context toward the transmitted socket. The graph records ownership facts that raw file counts cannot explain.

The collector does not want every cycle. A process may still hold A, a non-inflight socket may receive one member, or an edge may bridge to another component with an outside route. A circular queue is garbage only when its visible file references are fully accounted for by edges inside the same candidate group. This is why the code compares topology and counts, and why a silent new external reference can overturn a decision already made for one vertex.

The image of two socket rings and two envelopes is intentionally small. Real graphs can contain thousands of vertices, listening sockets, embryo sockets, multiple messages, and repeated descriptors. The two-node cycle preserves the essential arithmetic: A's queue holds B, B's queue holds A, and an outside hand may keep either alive. Remove that hand and the ring may be collectible; add a hand during analysis and yesterday's conclusion no longer describes today.

Follow one descriptor through the system and the graph stops looking abstract. The sender's integer selects an fd-table entry; ancillary-message handling pins the underlying file and stores it in an scm_fp_list. Once the skb is queued on the peer, that list owns the file even if the sender closes its integer immediately. AF_UNIX records the Unix-socket subset as graph relationships because those files can themselves lead back to queued receivers. A later ordinary receive transfers the capability into a new fd-table slot and consumes the message; a peek creates another pinned copy while leaving the queued owner intact. GC is deciding whether that last queue-owned web has any route back to a process, not whether two integer values happen to match.

2.1 An SCM_RIGHTS envelope carries a file reference, not an integer identity

User space places descriptor numbers in a control message, but those numbers only identify entries in the sender's fd table. Kernel processing resolves them into struct file references and packages them in struct scm_fp_list. The receiver will obtain new descriptor numbers selected for its own table. Treating the wire value as a transferable global identity misses the reference increment that makes garbage collection necessary and the duplicate created by a later peek.

A queued file list owns its references while the message remains in sk_receive_queue. Normal receive detaches the file list, installs descriptors, and consumes the message according to socket semantics. Destruction releases what was not installed. Until one of those events occurs, the message itself is an owner. AF_UNIX GC therefore cannot free a socket merely because every process closed the descriptor number it once used; a peer's unread ancillary message may still own the file.

The graph tracks only the AF_UNIX portion of a mixed file list. fpl->count_unix tells helpers whether there are Unix-socket descriptors worth representing as vertices and edges. Ordinary files in the same control message still have lifetime rules, but they do not create socket-to-socket graph cycles. This distinction later lets unix_peek_fpl() return early when a duplicated list contains no AF_UNIX file, avoiding sequence traffic for an irrelevant peek.

Edge direction matters. A descriptor sitting in a receiver's queue means the queued file can be obtained by receiving from that socket. The code stores predecessor and successor relationships so unix_vertex_dead() can ask whether a vertex has a route outside its current SCC. A drawing that swaps direction may still look circular, yet it would misstate which queue owns which reference and which external receiver can rescue a candidate.

User limits influence when GC is scheduled, not whether a particular edge is logically present. The consolidated code uses UNIX_INFLIGHT_SANE_USER, defined from SCM_MAX_FD * 8, to avoid expensive work for ordinary in-flight counts. Known cyclic components can trigger a flush path. These thresholds shape timing and exposure, but a low-count two-socket cycle remains the correct model for understanding the stale decision once a collection run is active.

File-system permissions on a named Unix socket protect that endpoint. They do not prohibit an untrusted local process from making its own anonymous socketpair(), building a cycle, and using ancillary messages when its sandbox permits those syscalls. Exposure analysis therefore starts with local execution trust, namespace sharing, seccomp and LSM policy, and workload use of descriptor passing. An Internet firewall has no view of this local ownership graph.

The scheduling threshold should not be mistaken for a safety threshold. UNIX_INFLIGHT_SANE_USER equals SCM_MAX_FD * 8 and lets ordinary low-pressure callers avoid an expensive collection request, while known cyclic SCCs can force waiting through flush_work(). Neither branch proves that a smaller graph cannot exist or that a two-socket graph is harmless. It only changes when the common worker is requested and whether a high-pressure caller waits. A laboratory trigger can deliberately move graph state and user pressure to the scheduling path; a fleet assessor should never infer “not affected” from seeing fewer than the threshold's number of descriptors in a snapshot.

Hand-drawn AF_UNIX reference graph: exactly two navy socket rings exchange one envelope in each direction, forming a closed descriptor cycle, while a separate hand holds an external file reference to the left socket.
Figure 1. The envelopes remain queue-owned references. The outside hand is the fact that makes this component alive; GC must never accept a snapshot that silently loses or gains that hand.

2.2 Tarjan groups the ring, then file counts decide whether the group can die

Commit 3484f063172d introduced Tarjan's strongly connected component algorithm to AF_UNIX GC in March 2024. Depth-first search assigns each vertex an index, follows edges, propagates a lower SCC index across back edges, and cuts a component when a root is finalized. Commit 4090fa373f0e completed the algorithm replacement: dead SCC queues are spliced into a hitlist, the graph lock is released, and queued skbs are purged outside the critical section.

A strongly connected component is a maximal group in which every vertex can reach every other vertex. That property identifies cycles, but it does not prove garbage. A single socket is also an SCC and may be acyclic; a multi-vertex SCC can remain live through an external file or an edge into another component. unix_scc_cyclic() distinguishes structural cycles, while unix_scc_dead() evaluates whether the component lacks a route the application can still use.

For each vertex, unix_vertex_dead() first walks its edges. A null successor means the descriptor can be received by a non-inflight socket; a successor with another scc_index means another component can receive it. Either observation makes the vertex live immediately. Only after every edge stays inside the same SCC does the function take the first edge, recover its predecessor socket, obtain total_ref with file_count(), and compare it with vertex->out_degree. Equality says every observed file reference can be explained by in-component queued edges. Any inequality is conservative evidence of ownership the current component does not account for.

The comparison is deliberately local to a vertex, yet the final decision covers the whole SCC. unix_scc_dead() iterates vertices in reverse and carries scc_dead forward. Once one vertex proves live, later vertices need no further file-count test, but the loop still performs its graph bookkeeping and the final sequence retry still runs. If every required check returns dead and no invalidating event occurs, unix_collect_skb() splices receive queues into the hitlist. The later call to __skb_queue_purge_reason() destroys those messages. One stale true for A can combine with a later true for B and convert a live pair into cleanup work.

Graph state also has a performance history. Commit 6b6f3c71fe56 consolidated two flags into UNIX_GRAPH_NOT_CYCLIC, UNIX_GRAPH_MAYBE_CYCLIC, and UNIX_GRAPH_CYCLIC. A graph known to have no cycles can skip GC. A changed graph needs the full SCC walk. A graph with known cyclic SCCs can use a faster path over preserved groupings. The CVE affects the validity signal around both full and fast dead-component checks.

Edges changing under normal descriptor traffic move that three-state machine. A graph proven acyclic can remain cheap until a new relationship makes cycles possible; a full walk can preserve known cyclic groupings for the next fast pass; collection can return the graph to a noncyclic state. The activity flag does not encode any of those topology states. It answers whether the callback is presently interpreting them. Conflating the two would create another false shortcut: a graph marked cyclic may sit idle, and a graph marked maybe-cyclic may be under active analysis. The May fix preserves this separation by changing only execution state.

Holding unix_gc_lock stabilizes the graph relationships that helpers protect with that lock. It does not automatically serialize every file-count change. MSG_PEEK duplicates references through a path designed to avoid taking the GC lock on every ordinary peek. The sequence protocol was the explicit bridge between that lockless event and the locked SCC calculation. Once the activity flag lied, the bridge could be skipped even while the graph walker depended on it.

3 Peek leaves the envelope in the tray and gives the reader another handle

MSG_PEEK is often summarized as “read without consuming.” For an ordinary byte stream, that sounds like a passive observation. Ancillary file descriptors make it an ownership event. AF_UNIX calls unix_peek_fds(), which executes scm_fp_dup(UNIXCB(skb).fp). The copied list holds additional struct file references for the caller while the original list remains attached to the queued skb. Nothing about the message's position reveals that the reference count just changed.

The extra reference is useful and expected. User space can inspect a message and later receive it; the peeked descriptors must remain valid while being installed into the caller's fd table. The difficulty is observational: GC may have already read A's file count and tentatively classified A, then the peek adds an outside owner before GC reaches B. A correct collector needs a way to reject the cross-time combination without forcing every peek through the entire graph lock.

This is not a new class of interaction. Commit cbcf01128d0a fixed GC versus MSG_PEEK in 2021 by incrementing the file count and taking and releasing unix_gc_lock as a lock barrier before descriptor installation. That forced old GC to occur wholly before or after the barrier. When the 2024 SCC algorithm changed graph invariants, commit 118f457da9ed removed the lock dance because the new topology rules appeared to make it unnecessary.

Igor Ushakov later demonstrated that file-count sampling still needed coordination. Commit e5b31d988a41, authored March 11 and merged March 12, 2026, introduced a lighter signal. Peek would notify GC only when AF_UNIX descriptors were present and a collection was active. GC would compare the notification sequence around one SCC and defer that component if a peek intervened. The design avoided a lock on the common inactive path and avoided an unbounded retry loop under abusive peeks.

The word “peek” hides two distinct clocks. The message clock does not advance: the skb stays at the same queue position and can still be received later. The ownership clock does advance: the duplicated file list pins files for installation into the caller. GC reads the second clock indirectly through file_count() while traversing a graph built from the first clock's queued messages. The sequence counter is the receipt connecting them. It does not copy topology or counts; it tells the collector that a component verdict assembled across that ownership change cannot be committed.

3.1 scm_fp_dup() changes the count that unix_vertex_dead() is measuring

Consider sk-A and sk-B in one SCC. Initially, A's file count is one because B's receive queue contains A. GC checks A: file_count(A) == out_degree(A), so A looks internally owned. The result is valid for that instant. The collector has not yet checked B, and no single lock freezes all future descriptor installation while it moves between vertices.

A user now peeks B's queued message and obtains A. scm_fp_dup() raises A's file count from one to two, but B's message still owns the original A reference. That new reference means A is live. If GC revisited A, equality would fail. The algorithm normally avoids revisiting every vertex because a sequence change tells unix_scc_dead() that the component-level snapshot is inconsistent and should be left for a later run.

Next, the user closes its ordinary descriptor for B. B's file count falls from two to one: one reference had been the open fd, the other is the B descriptor queued in A. GC then checks B and sees equality with B's out-degree. A was judged before the peek; B is judged after the close. Both individual reads can look internally consistent, yet their conjunction never described one moment in which the whole SCC was dead.

The sequence notification does not encode which socket was peeked or how many references changed. It needs only to prove that an event capable of invalidating the final refcount check occurred between the component's opening and closing observations. This is why a monotonically changing seqcount is a good fit. GC discards the attempted dead classification, leaves queues intact, and allows a later run to evaluate a new coherent interval.

The public reproducer behind the March commit says GC purged the receive queue of an alive socket. CVE-2026-53361 reopens the signaling hole during a false-state rerun. Connecting the two commits supports the following inference: if the published A/B ordering lands inside that rerun, the collector can again miss the invalidation and purge an alive queue. It does not prove that every missed signal reaches purge or that arbitrary memory corruption follows.

The close after peek is just as important as the duplicate. If A's count rises and every other ownership fact stays fixed, GC may see a mismatch on a later check and keep the component. The published failure makes the observations disagree in opposite directions: A becomes more live after A has already been checked, while B loses its open reference before B is checked. That is how two locally plausible equalities can coexist in the worker's notes. A reproducer that only loops MSG_PEEK without arranging the B close may create load and sequence traffic, but it has not recreated the stated A-before-peek, B-after-close snapshot.

Hand-drawn MSG_PEEK scene: one sealed descriptor envelope remains in an AF_UNIX receive tray with its brass handle, while a reader receives exactly one duplicate handle and the inspecting tongs do not remove the message.
Figure 2. Peek is not a dequeue. The receive queue keeps the original reference while the reader gains a duplicate, so the file count can change under an SCC decision without a visible queue move.

3.2 The sequence bell protects one SCC interval, but only when the activity lamp is truthful

unix_peek_fpl() begins with cheap relevance tests. A null file list or zero count_unix cannot disturb AF_UNIX graph counts, so the helper returns. Then it executes READ_ONCE(gc_in_progress). If false, no collector should be taking a snapshot and there is nothing to invalidate. If true, the helper takes a small private spinlock and calls raw_write_seqcount_barrier(&unix_peek_seq). The lock serializes concurrent peek-side writers to this raw seqcount; it is not the global GC lock and never protects the whole descriptor installation. The event being published is intentionally small: “a relevant peek crossed an active collection interval.”

On the reader side, unix_scc_dead() calls read_seqcount_begin(&unix_peek_seq) before walking the SCC. After vertex checks, read_seqcount_retry() compares the sequence. A change forces a false result, which in this function means “do not call this SCC dead.” The component remains available for another collection. GC does not need a log of which descriptor changed: any qualifying event within the bracket invalidates the all-dead answer. The protocol does not make a peek wait for a full graph scan, does not hold the peek lock across descriptor installation, and does not rerun Tarjan inside the same callback.

The raw barrier and seqcount rules address ordering around concurrent observations; they do not repair a semantically wrong activity predicate. The March protocol assumes that false means no read_seqcount_begin() to read_seqcount_retry() interval can be live. During the rerun, that premise is inverted: the reader is inside the interval while the prospective writer takes its inactive exit. Every later barrier can execute perfectly and GC still receives no event. The observed sequence remains equal at begin and retry, so the reader accepts the result for exactly the reason the protocol was designed to prevent. CVE-2026-53361 is not a failure of seqcount arithmetic. It is an upstream state flag making a legitimate writer disappear from the protocol.

READ_ONCE() and WRITE_ONCE() also need a disciplined interpretation. They prevent the compiler from inventing or merging certain accesses and make the intended shared scalar operations explicit. They do not turn “read false, write true, queue work” into one atomic transaction. T2 and T3 can both pass the test exactly as the commit message shows. Adding more access wrappers around the same ownership mistake would preserve the bad lifecycle.

The sequence design intentionally defers instead of repeatedly retrying inside unix_scc_dead(). An attacker or high-rate service can generate many peeks. A retry loop could keep the workqueue callback in the SCC forever and produce hung-task reports. Returning live for the current round trades immediate reclamation for safety and forward progress. Garbage can wait; a live receive queue cannot be reconstructed after an incorrect purge.

Deferral also contains the cost of interference. The worker completes bookkeeping for the round, releases locks, and lets ordinary descriptor activity progress; a later scheduling decision can revisit the component with fresh counts. This is not a promise that one future run will collect it, because another legitimate owner may still exist. It is a promise that an uncertain all-dead verdict will not cross into hitlist construction. The CVE breaks that promise by suppressing evidence of uncertainty, not by changing the conservative return value inside unix_scc_dead().

This dependency explains the historical split. The scheduler-owned flag defect began with the 2024 workqueue conversion. Before March 2026, the exact unix_peek_fpl() fast path did not depend on that flag. The dangerous composition arrived when the sequence protocol reused the flag as its activity oracle. A sound report therefore names both dates: dormant state-lifecycle flaw in 2024, direct peek coordination consequence in 2026.

4 One work item can run twice even though the scheduler rang the bell once per ticket

The AF_UNIX collector did not begin life as this workqueue callback. Before January 2024, heavy in-flight pressure could make multiple CPUs call GC and pile up behind the same spinlock. With more than 16,000 in-flight sockets, send paths could slow sharply while each invocation traversed the graph. Commit 8b90a9f819dc converted GC to struct work, allowing one CPU to perform the scan while other callers avoided wasting CPU on redundant blocked invocations.

That change solved a real performance problem and contains the design decision that later mattered. The commit deliberately moved WRITE_ONCE(gc_in_progress, true) before worker execution. Its message showed a waiting CPU that could miss flush_work() if a callback started before setting the flag. Setting true in the scheduler made the “work is starting or active” interval visible early enough for the waiting path.

The 2024 implementation had a scheduler function and an actual __unix_gc() callback. Later graph and state refactors renamed and consolidated those shapes. By the parent of d82ba05263c6, unix_schedule_gc() checked unix_graph_state, user pressure, and gc_in_progress, then queued unix_gc_work; unix_gc() performed full or fast SCC walking and wrote false at its common exit.

A Boolean written before a submission can safely describe that one pending execution only if submissions and callbacks remain one-to-one. Workqueue's ability to queue a running item breaks that hidden assumption. The crucial transition is not two pending copies: the worker has started, its pending bit has been consumed, and T3 can make the same work item pending again while its callback is still active. T3's true write belongs temporally to that requested rerun, but it happens before the first run's false write. The later false erases it. No data race detector needs to find a simultaneous unsynchronized byte collision; the values occur in a legal order with the wrong lifetime meaning.

Four nearby schedules do not produce this state, which is useful when designing a test. If T3 reads after T2 writes true, it skips the body. If T3 calls queue_work() while the item is still pending but not yet running, no additional callback is accepted. If T3 arrives after the first worker clears false, its own true write correctly covers the newly queued run under the old design. If no relevant peek lands during the dark rerun, the notification defect remains latent. The vulnerable schedule occupies the remaining slot: both callers commit to the false branch, the first run begins soon enough to consume pending state, T3 requeues it, and the first exit then overwrites T3's state.

4.1 To keep waiters from missing active work, the scheduler accidentally took ownership of worker state

The original commit explains its motivation using work_pending(&unix_gc_work) and gc_in_progress. If worker entry set true too late, a waiter could observe neither pending nor active and skip its flush while GC was already entering. Moving the write ahead of queue_work() closed that interval. At that time, the flag primarily coordinated heavy in-flight throttling and completion waiting; the sequence-sensitive peek consumer did not yet exist.

Ownership drift happened gradually. The flag began as a combined “scheduled or running” signal. The SCC algorithm changed how graph work was grouped. November 2025's 24fa77dad25c consolidated unix_schedule_gc() and wait_for_unix_gc(). March 2026 then treated false as proof that a peek could not intersect a live SCC analysis. No single refactor announced that the scheduler now vouched for every nanosecond of every possible callback.

State names can hide that expansion. gc_in_progress sounds like a direct property of the worker, yet the old true write occurred in code that had not begun collection and might merely arrange a rerun. The false write occurred in the actual worker. Start and finish therefore belonged to different actors. That asymmetric ownership is a productive review smell: if one context opens a state interval and another closes it, enumerate every path that can duplicate, defer, cancel, or rerun the closer.

queue_work()'s return value is useful for diagnostics but not a replacement fix. A caller can record whether it newly queued work, and a deterministic test can verify that the T3 call creates a rerun. Yet making the scheduler conditionally write true based on that return still writes the second interval too early, before the first interval's false. The callback remains the only point guaranteed to execute once for each actual graph-analysis pass.

flush_work() also does not restore the flag. It waits for work completion according to workqueue semantics. If the rerun executes with false, a flush may faithfully wait for the wrong-state callback to finish. Synchronization primitives can order flawed state machines without correcting them. Validation must observe the flag at both worker entries, not infer correctness from the fact that a waiter eventually returned.

The pre-fix scheduler combines three different decisions in a short function. It can return because unix_graph_state is known noncyclic; it can spare a user below the in-flight pressure threshold; and it can queue when the activity flag looks false. After that, a caller associated with known cyclic SCCs may flush. These gates explain why a naïve stress loop can run for hours without reaching the callback pair: it may never make the graph eligible, never exceed the relevant pressure path, or never obtain a successful running-item requeue. Deterministic checkpoints should assert each gate and record the Boolean returned by queue_work() before attempting the peek schedule.

Six-panel hand-drawn schedule: two scheduler workers independently see an unlit GC lamp, post two tickets for one work item, the first collector runs and switches the lamp off, then the queued second run enters while the lamp stays dark.
Figure 3. T2 and T3 race at the scheduler, but the two GC callbacks are sequential. The bug is the missing state start at the second callback, not simultaneous graph walking.

4.2 The second worker is real, the false idle signal is real, and the missed bell follows

At second entry, unix_gc() first takes unix_gc_lock. If unix_graph_state == UNIX_GRAPH_NOT_CYCLIC, it releases the lock and jumps to the common state-clear label. Otherwise it initializes a private hitlist and chooses unix_walk_scc_fast() for a known cyclic graph or unix_walk_scc() for a graph needing full grouping. Only after the walk does it release the graph lock, mark collected file lists dead, and purge hitlist skbs. Every meaningful branch occurs after the absent true write in the vulnerable parent, so a complete SCC decision and its queue-transfer preparation can unfold under the false activity signal.

The false state does not necessarily make the collector corrupt anything by itself. If no relevant peek occurs, or the graph is skipped, or each SCC is plainly live for another reason, the rerun may finish harmlessly. Exploitability depends on aligning a queued rerun, a component whose vertex counts can straddle an ownership change, a qualifying MSG_PEEK, and the subsequent close pattern. That is why logs can stay quiet across many affected machines without proving immunity.

When the alignment does occur, the failure path is direct. Peek duplicates a list with count_unix > 0. The helper reaches the activity test, sees false, and returns. The sequence notifier remains motionless. The worker finishes A and B checks, sees the same sequence at read_seqcount_retry(), and treats the component-level result as stable. The missing event does not leave an explicit kernel warning or network signature.

Production tracing needs restraint. Descriptor passing can carry sensitive process context and high event volume. Record counts, socket identities suitable for local correlation, namespace and cgroup identifiers, and timing; avoid copying user payloads or full file tables unless an incident and policy justify it. The cleanest enterprise control remains version evidence. Runtime probes are most valuable in a laboratory reproducer or during a focused anomaly investigation.

A minimal focused trace needs fewer events than a syscall transcript. Emit callback entry and ordinal, the entry flag value after the write point, callback exit, each accepted queue_work(), a relevant peek's activity read, sequence value before and after its notification, SCC begin and retry values, and hitlist admission. Capture the vulnerable parent and fixed kernel with the same schema. The expected difference is explicit: the parent can show ordinal two entering dark and a relevant peek leaving the sequence unchanged; the fixed build must relight before SCC begin and make that peek invalidate the verdict.

Hand-drawn false-idle rerun: one GC worker inspects a two-socket cycle beneath an unlit lamp while a MSG_PEEK clerk duplicates a handle and a four-bead sequence notifier remains motionless.
Figure 4. The collector is active, but the dark flag makes peek take its inactive fast path. With no sequence movement, the SCC reader cannot reject its cross-time counts.

5 A is judged before the peek, B after the close, and the pair never shared one reality

The strongest impact explanation comes from the commit immediately preceding the CVE fix, not from imaginative extrapolation. e5b31d988a41 records a reproducer in which GC purged an alive socket's receive queue. It names sk-A and sk-B, gives the file-count transitions, and explains why sequence invalidation is sufficient. CVE-2026-53361 says the activity flag can suppress that invalidation during a real rerun. Connecting those two facts yields a concrete failure pattern with clear evidentiary limits.

At the start, A and B form one SCC. A has been closed as an ordinary fd but remains receivable from B's queue. B still has an open descriptor and is also queued in A. GC begins its component check and reaches A first. The count it reads equals A's in-component out-degree. For that instant, the code has no evidence of an external owner, so the tentative result for A is dead.

The user peeks B and duplicates A, then closes B. A has gained a process-visible reference; B has lost one. When GC later evaluates B, its count can also equal its out-degree. The worker holds two true results drawn from incompatible moments. A correct sequence retry turns the component result into live/deferred. A missed sequence change lets the stale A result survive long enough to combine with the new B result.

If the SCC is accepted as dead, unix_collect_skb() visits each vertex, recovers the predecessor socket from its first edge, locks that receive queue, and splices queued skbs into the hitlist. Listening sockets receive special treatment: the helper walks their pending embryos and splices each embryo receive queue under its own lock. After releasing unix_gc_lock, the worker marks every collected scm_fp_list dead and calls __skb_queue_purge_reason(&hitlist, SKB_DROP_REASON_SOCKET_CLOSE). The user holding the peeked A handle is proof that the component was alive. Its queued messages have nonetheless entered a cleanup path intended for unreachable cycles.

The separation between selection and destruction makes a stale verdict hard to diagnose afterward. Queue splicing happens while the graph lock and per-queue locks establish the collector's view; actual skb release follows after the graph lock is gone. By the time a service notices that an expected ancillary message vanished, the scheduler read, sequence value, and two vertex counts may be long gone. The purge reason describes the cleanup path, not the decision's correctness. This is why the most useful laboratory assertion sits before unix_collect_skb(): a changed sequence must prevent a component from ever reaching the hitlist.

5.1 The stale SCC decision is a mixed photograph, not one bad counter read

No individual file_count() load needs to be numerically corrupted. A's old count can be accurate, and B's new count can be accurate. The error comes from presenting them as a simultaneous snapshot. This distinction guides debugging: searching only for torn counters or a missing atomic increment will miss the composition. The sequence bracket exists to validate the interval across all vertex reads, not to correct one malformed integer.

Fast and full walks reach the same component-level hazard through different preparation. The full Tarjan path groups vertices and calls unix_scc_dead(scc, false). The fast path iterates previously grouped SCCs and calls unix_scc_dead(scc, true). In both cases, the sequence begin/retry surrounds the vertex-dead checks. The false activity flag can silence the writer that both modes rely upon.

A sequence change causes unix_scc_dead() to return false even if every vertex check had returned true. In this API, false is conservative: do not collect now. The next GC round may see the reader's external A reference and keep the SCC, or later see that reference closed and legitimately collect it. Deferral is not a leak by definition; it is a deliberate choice to avoid purging under an unstable ownership view.

Forensics should preserve both topology and time. A crash dump that shows only the final file count cannot reconstruct which reference existed when A and B were checked. Useful debug output records SCC membership, each vertex's out-degree and total reference count, sequence value at begin and retry, worker-run ordinal, and peek/close timestamps. Those values let maintainers distinguish the CVE schedule from an unrelated reference leak or queue-destruction bug.

Run ordinal is the field most generic traces omit. Two callbacks share the same unix_gc_work address, and the second may execute on a different CPU after the first completes. Without an ordinal assigned at callback entry, an analyst can misread one exit and one later graph walk as a single long invocation or treat T3's queue request as a separate worker object. Correlating the work address, ordinal, flag value, and sequence bracket turns the dark interval into a falsifiable event: ordinal two entered, began SCC sampling, and accepted an unchanged sequence while a qualifying peek occurred.

Four-panel hand-drawn SCC decision: GC marks the first socket before a peek duplicates its handle, the second socket loses an outside handle before its later check, and both envelopes are wrongly pushed toward cleanup while the duplicated handle remains alive.
Figure 5. Each vertex count can be accurate when read. The invalid result comes from combining A-before-peek with B-after-close and missing the event that should discard the pair.

5.2 What the public record supports, and where responsible inference stops

Confirmed facts are narrow and important. A queued unix_gc() rerun can execute while gc_in_progress is false. unix_peek_fpl() relies on that flag before notifying GC. A qualifying peek can therefore pass without advancing unix_peek_seq. The direct predecessor commit shows that a missed peek invalidation in the A/B schedule can make GC purge an alive receive queue. Those statements require no invented attacker capability.

The public material does not demonstrate arbitrary kernel memory access, a stable local privilege escalation, container escape, or remote code execution. What source does support is an integrity violation in ownership accounting followed by an irreversible queue operation: messages classified as belonging to an unreachable SCC can be detached and destroyed while a reader still proves reachability. The immediate observation may be missing ancillary data, a peer seeing unexpected closure, a broker losing a queued handoff, or a service failing later when it uses a resource it expected to receive. Those are plausible consequence classes, not one guaranteed symptom. The available commits do not establish a general memory-corruption primitive, so incident severity must not be inflated by borrowing outcomes from unrelated lifetime flaws.

Reachability is local. An untrusted process needs the ability to create or use AF_UNIX sockets, transfer socket descriptors with SCM_RIGHTS, invoke MSG_PEEK, construct the cyclic ownership pattern, and produce the overlapping scheduling window. Containers share the host kernel, so a container workload with those capabilities can belong in the exposure set; this does not itself prove a container escape outcome.

Remote services may indirectly expose local descriptor brokers, but no generic network packet maps to this graph. A firewall signature cannot see an anonymous socketpair inside a process namespace. Detection products should not invent IP, domain, hash, or packet IOCs for a kernel lifecycle race. Relevant context is a CVE, functions, commits, kernel builds, socket activity, workqueue timing, cgroups, namespaces, and local process ownership.

Complex timing lowers repeatability without making the repair optional. The scheduler race needs T2 and T3 to pass the same false check, T3 to requeue running work, and peek/close to land inside the false rerun's component decision. High descriptor churn, multi-user workloads, test harnesses, and service brokers can increase opportunities. Quiet logs or a failed one-shot reproducer do not show that an affected build is safe.

There is no published evidence of exploitation in the wild in the reviewed source set. The report date, CNA text, vendor trackers, and commits provide no named campaign or public full exploit. Incident teams should still investigate unexplained local-service crashes on affected kernels, especially when descriptor-passing workloads were active. They should label the resulting confidence: affected build plus compatible symptoms is a lead, not automatic attribution to deliberate exploitation.

Attribution needs evidence beyond mechanism compatibility. A deliberate-trigger assessment would look for a process able to create the socket cycle, repeated ancillary sends and peeks near the failure, scheduling pressure compatible with the rerun, and an actor or workload explanation for that behavior. Even then, benign brokers and stress tests may produce similar primitives. The absence of network IOCs does not weaken the source-level vulnerability finding; it only means incident conclusions must be built from local execution, timing, and ownership evidence.

6 The fix moves the switch to the only place every run must enter

Commit d82ba05263c6 does not add another lock around the scheduler. Its diff inserts WRITE_ONCE(gc_in_progress, true) as the first state action in unix_gc(), before unix_gc_lock and before the graph-state test. It deletes the scheduler's true write and braces, leaving if (!READ_ONCE(gc_in_progress)) queue_work(...). The false write remains behind the skip_gc label, so both the ordinary walk and the no-cycle exit clear the interval. Each actual callback now authors its complete activity lifetime with one entry that every branch shares.

This placement handles the exact rerun. First callback enters and writes true; it later writes false. Second callback enters and immediately writes true again before any SCC work. A peek in the idle gap may skip the sequence because no collector is sampling counts. A peek after the second entry observes true and advances the sequence. The state no longer needs to survive from T3's scheduling moment across T1's first completion.

The fix also clarifies the flag's meaning. It no longer promises “some caller tried to queue GC.” It says “the callback is in its analysis lifetime.” Scheduler decisions can still read it as a useful suppression hint; correctness no longer depends on every scheduling attempt establishing a future state duration. This is a recurring concurrency principle: state that describes execution should begin in the executing context whenever callbacks can be coalesced or rerun.

WRITE_ONCE() remains appropriate because concurrent peek readers observe the scalar without taking unix_gc_lock. The sequence barriers provide the additional ordering needed by the peek/SCC protocol. The patch does not turn the Boolean into a mutex and does not serialize all unix_schedule_gc() callers. It repairs the lifetime that the existing protocol assumed while preserving the performance design of one work item.

The proof can be stated over three worker paths. On the no-cycle path, entry writes true, the graph-state test skips analysis, and the common label clears false; no snapshot occurs outside the interval. On the full path, true covers Tarjan grouping, every unix_scc_dead() bracket, hitlist selection, and the final clear. On the fast path, the same entry covers checks of preserved cyclic groups. A requeue can add another callback after any of them, but that callback repeats the proof from its own first line. No scheduler interleaving can delete a future entry write because the write has moved after the framework decides to execute.

6.1 Every callback lights its own interval, including a rerun queued while running

A source review should look for behavior, not one exact hunk. In mainline, the function is named unix_gc() and the true write sits before the spinlock. In older stable trees, the actual callback may be named __unix_gc(), while unix_gc() remains a scheduler/wait helper. The invariant is identical: every callback that reaches graph analysis establishes true at entry and clears false after its final decision.

Regression tests need to force the difficult path. Pause the first callback after it begins, let two scheduling threads establish a successful pending rerun, release the first callback, and instrument the second entry. Assert that the flag is true before either full or fast SCC walking. During both runs, perform relevant peeks and assert that unix_peek_seq changes. A test that merely calls the scheduler twice while work is idle may never exercise the overwrite.

Test the skip path too. If the second callback enters and finds UNIX_GRAPH_NOT_CYCLIC, it still writes true before the state check and false at skip_gc. That short interval should remain balanced. Early returns introduced by future refactors must not bypass the clear. Code review can make this visible with one entry write and a single common exit, or with structured cleanup that proves every branch.

Placing true before the spinlock is not a formatting detail. unix_peek_fpl() does not take the graph lock; it observes the activity state through READ_ONCE(gc_in_progress). If a callback begins, or even acquires unix_gc_lock, before it lights the flag, a peek can still read false in that window and skip the sequence notification. The patch makes true the callback's first state action: the lock protects the graph, while the flag describes the whole execution interval from the first graph-related action through the common exit.

Single-CPU and multi-CPU configurations reveal different weaknesses. A controlled single CPU makes pending/running transitions and preemption order reproducible. Multiple CPUs stress the original T2/T3 scheduler race and memory visibility. Both should record work item identity and callback ordinal. A test that passes only under one topology may have validated scheduling luck instead of the state property.

KCSAN can identify unexpected concurrent accesses, but the published order is a logical race that may use intentional READ_ONCE() and WRITE_ONCE() accesses without a conventional data-race report. Deterministic checkpoints and state assertions are stronger. KASAN can catch downstream lifetime damage if a bad SCC is purged, yet absence of KASAN output does not prove that a peek notification was delivered.

Patch-equivalence review should also resist symbol matching. An older stable tree may register __unix_gc as the work callback and leave unix_gc as a public scheduling helper; a vendor may carry additional tracing or throttling branches. Start from DECLARE_WORK(unix_gc_work, ...), follow the callback actually invoked by workqueue, and inspect its earliest graph-related action. Then enumerate every exit that reaches the false write. Only after that behavior is established should a maintainer compare hashes. This method accepts a legitimate backport with different context and rejects a cherry-pick that landed in an unused helper.

Four-panel hand-drawn repair: the same GC worker switches the lamp on at the start of each of two sequential runs, performs each socket-cycle inspection under light, and the two green activity intervals are separated only by an idle gap.
Figure 6. The repair does not require one unbroken true value across two callbacks. It requires each callback to establish true before its own graph work.

6.2 Stable trees keep an older scheduler write for throttling, and 6.1 has no published floor in this snapshot

The stable backports are not text-identical to mainline. Public patches 82c17e13d404, 591f1ac21742, and 0cfa78c05066 add true at the actual __unix_gc() entry while retaining an existing scheduler-side true write. Their backport note says the old write remains for wait_for_unix_gc() over-limit throttling. That older layout still needs an early signal so a sender above the in-flight threshold knows to wait, but the early write cannot stand in for callback truth. Two writes are acceptable there because worker entry now re-establishes the execution interval that peek needs. A mechanical audit that demands removal of every scheduler write would falsely reject these correct backports; the callback-entry invariant is the portable acceptance criterion.

The Linux CNA lists fixed floors of 6.6.144, 6.12.95, 6.18.38, and mainline 7.1. It also records stable introduction on 6.6 at 6.6.93 and a separate 6.1 affected start at 6.1.141. Those facts come from two version blocks with different defaults: one starts unaffected and enumerates backported affected islands; another starts affected for the mainline-derived range and carves out fixed floors. For 6.1, the snapshot gives a bare affected commit ceb8bd6c69c1 and no corresponding fixed floor. The semantic line ending before 6.2 describes that branch range; 6.2 is not a repair release, and ordinary numerical comparison cannot fill the missing endpoint.

Two product entries in the CNA explain the apparent overlap. One defaults to unaffected and enumerates older branches that acquired the behavior through backports. The other defaults to affected from mainline 6.9 and declares the maintained-line fixed floors. Reading only “introduced in 6.9” would miss affected 6.6.93 and 6.1.141 builds. Reading only a major/minor version would miss vendor kernels that backported either introduction or repair.

A reliable version algorithm asks four questions in order. Which source line produced this package? Did that line receive the vulnerable workqueue behavior, including through a vendor backport? Did it receive callback-entry repair or equivalent code? Is that package the kernel named by the current boot ID? Upstream floors answer the first three only for their published lines. Vendor changelogs and source trees answer modified products. Runtime inventory answers the fourth. Skipping any question creates familiar false results: 6.1 assumed safe because it predates 6.9, or a host marked fixed because the repaired package exists only on disk.

Debian illustrates the distinction. On July 17, bullseye was not affected because the vulnerable code was absent. Trixie security showed 6.12.95-1 fixed, while bookworm security 6.1.176-1 remained vulnerable in the tracker. Sid showed 7.1.3-1 fixed. Those package versions reflect Debian source state and can differ from upstream tag floors; the official tracker is the appropriate product lookup.

Ubuntu's page is even more granular. Generic linux for 26.04 and 24.04 was listed vulnerable, generic 22.04 not affected, while 22.04 HWE 6.8 and multiple cloud flavors had their own states. A fleet report should retain flavor names such as HWE, AWS, or Azure, then verify the running package. Collapsing all “Ubuntu 22.04” hosts into one status would erase the backport history that created this CVE's unusual map.

Hand-drawn five-track Linux version map: mainline 6.9 reaches 7.1, 6.18.y reaches 6.18.38, 6.12.y reaches 6.12.95, 6.6.93 reaches 6.6.144, and the red 6.1.141 track ends at a question mark without a repair gate.
Figure 7. Four fixed floors are explicit in the Linux CNA. The 6.1 line is explicitly affected from 6.1.141, but this July 4 snapshot does not publish a fixed floor for it.
LinePublished affected startPublished fixed floorAcceptance note
Mainline6.9 mapping7.1Verify d82ba05263c6 or equivalent callback-entry behavior
6.18.yMainline-derived affected code6.18.38Vendor build may carry an earlier equivalent backport
6.12.yMainline-derived affected code6.12.95Debian trixie security uses 6.12.95-1
6.6.y6.6.936.6.144Introduced by stable backport 328840c93bd6
6.1.y6.1.141Not listed by CNA snapshotRequire vendor or source-level confirmation; do not treat 6.2 as a fix
On mobile, scroll horizontally to compare affected starts, fixed floors, and source-level acceptance.

7 A quiet log cannot tell whether the worker's lamp lied

CVE-2026-53361 has no network IOC and no guaranteed crash signature. The flag can be false during a rerun without a bad peek occurring. A missed peek can leave a component live for unrelated reasons. A stale SCC decision can purge messages and surface later as a local service failure, an unexpected descriptor close, or a queue anomaly far from the scheduling window. Version evidence is therefore the fleet-wide detector; runtime evidence answers focused incident questions.

Begin with the executing kernel, not the package database. Capture uname -r, the build identity exposed by the vendor, package NEVRA or Debian version, boot ID, image provenance, and any live-patch state. Join those fields at collection time; copying a repository version into a host record later can silently turn installed state into running state. A fixed package beside an old booted kernel leaves the vulnerability active, while a vendor kernel with an older-looking release string may already carry an equivalent patch. Custom and appliance kernels require a source or vendor-attested behavior check: follow the registered work callback and confirm it sets gc_in_progress before graph state and SCC work.

Then map workloads that can exercise descriptor graphs. Inventory multi-user hosts, container nodes sharing one kernel, desktop portal systems, local RPC brokers, service activation, sandbox launchers, build workers, and agents that pass sockets or files. Examine seccomp and LSM policy for sendmsg, recvmsg, ancillary data, and MSG_PEEK. The goal is prioritization, not a claim that every listed product has a published trigger.

During an incident, preserve kernel logs, pstore, vmcore, KASAN/KCSAN output from comparable staging, service core dumps, task and cgroup identity, namespace mappings, and socket diagnostic snapshots. Search stacks for unix_gc, unix_scc_dead, unix_vertex_dead, unix_collect_skb, unix_peek_fpl, receive-queue destruction, and file-list release. A single function name is context; the worker/peek order is the mechanism.

Collect volatile evidence before rebooting a suspicious node, but keep that lane separate from fleet patching. A vmcore or trace may explain a compatible service loss; it is not needed to decide that an affected running build should be updated. Conversely, package installation records can close the exposure question only after a new boot, yet they cannot explain an earlier crash. Maintaining two clocks—incident time and remediation time—prevents post-update state from overwriting the code and workload context that existed when the symptom occurred.

7.1 Five evidence trays separate exposure, condition, consequence, and recovery

The first tray is boot state. Record which kernel actually started and whether the host rebooted after the fix. Bind that to node identity and placement history so investigators know which containers or users shared the kernel during the interval. Image version alone is insufficient when nodes can remain alive across multiple package rollouts or boot from an older fallback entry.

The second tray is source behavior. In a vendor tree, locate the callback registered in DECLARE_WORK or its equivalent; names alone can mislead because older backports split unix_gc() and __unix_gc() differently. Confirm a true write before unix_graph_state checks, a false write on all exits, and the preserved unix_peek_fpl() sequence path. Follow the actual function pointer from the work declaration instead of searching for one familiar hunk. Record the commit or patch provenance. A hash absent from a vendor history does not mean the behavior is absent; an advisory name does not prove equivalence either.

The third tray is workqueue timing. On an isolated debug kernel, record scheduler reads, queue_work() results, pending/running transitions, callback ordinals, and flag writes. Prove that a running work item becomes pending and that the next entry relights state. In production, collect this detail only under a scoped investigation because tracing a global default workqueue at high volume can distort timing and consume substantial storage.

The fourth tray is graph arithmetic. For a laboratory SCC, capture vertex identity, predecessor/successor relationships, out_degree, file_count(), sequence at begin, peek event, close event, sequence at retry, and hitlist membership. This data can show whether a dead result mixed time periods. Avoid emitting raw transferred file content. Numeric ownership and timing are enough to validate the concurrency contract.

The fifth tray is service recovery. After booting a fixed kernel, test systemd socket activation, container runtime control sockets, desktop portals, credential or secret brokers, and any application-specific fd handoff. Verify both ordinary receive and peek behavior, resource closure, and restart. A low-level patch that passes a synthetic race but breaks legitimate descriptor passing still needs operational correction before broad rollout.

Timestamp quality decides whether the trays can be joined. Workqueue probes use monotonic kernel time; service logs may use wall time; container logs may be buffered; a reboot resets some local counters. Record boot ID with every trace, retain clock-offset information, and convert only after collection. A peek observed at 12:00 in application logs cannot be placed inside an SCC bracket merely because a kernel warning has the same displayed second. Strong correlation comes from one trace clock or an explicit synchronization marker, then adds process, cgroup, namespace, socket, and work ordinal.

QuestionMinimum evidenceStrong evidenceDo not substitute
Was the host exposed?Running build and branch mappingEquivalent callback-entry source reviewInstalled package alone
Did the CVE condition occur?Worker active while flag falseSuccessful rerun plus skipped peek sequence eventGeneric AF_UNIX traffic
Did a stale SCC decision follow?Mixed vertex-count timingBegin/retry sequence and hitlist correlationOne final refcount snapshot
Was harm deliberate?Process and workload attributionReproducible trigger tied to actor activityAffected version plus crash
Is remediation complete?Fixed running kernel and boot IDRace regression and service handoff validationRepository or image update only
On mobile, scroll horizontally to compare evidence strength and common substitutes.

7.2 A deterministic regression makes the invisible ordering observable without harming production

Build the test around controllable checkpoints. Create an AF_UNIX descriptor cycle, make graph state eligible for collection, and pause the first callback after entry. Arrange T2 and T3 so both have passed the initial false read before either scheduling body completes. Let T2 queue and start the first run; let T3 successfully request the rerun while that callback remains active. This reproduces the published prerequisite without relying on scheduler luck.

Release the first callback and record its false write. At the second callback's first state checkpoint, assert true on a fixed kernel and observe false on the vulnerable parent. Hold the second run after read_seqcount_begin() and before the final retry, perform a qualifying peek, and verify that the sequence changes. Then execute the close step and confirm read_seqcount_retry() converts the tentative all-dead result into deferral. The test should fail independently if the rerun was never queued, if the peek carried no Unix socket, or if the checkpoint landed outside the SCC bracket. It can prove the repaired invariant before it ever asks the kernel to purge a live queue.

Add negative controls. Peek a message with no AF_UNIX descriptors and expect no sequence change. Peek while no GC callback is active and expect the inactive fast path. Run one ordinary GC without a queued rerun and confirm the flag interval. Mark the graph UNIX_GRAPH_NOT_CYCLIC and confirm balanced entry/exit state on the skip path. Controls distinguish a precise repair from a test harness that rings the sequence unconditionally.

Stress both unix_walk_scc() and unix_walk_scc_fast(). The first needs a changed graph in UNIX_GRAPH_MAYBE_CYCLIC; the second needs previously grouped UNIX_GRAPH_CYCLIC components. Exercise an SCC with an outside edge, one with a file-count mismatch, and the A/B peek-close schedule. Save the topology and all counts when an assertion fails so a future maintainer can tell which invariant regressed.

Run the test against every supported vendor backport, especially the oldest branch in service. Function names and throttling writes may differ, but the observable rule does not. Record compiler, configuration, CPU count, preemption model, sanitizer state, and commit identity. A stable backport that preserves scheduler true for waiting should still relight at __unix_gc() entry; the test must allow that extra write while rejecting a dark callback.

Hand-drawn evidence workbench: one Linux server connects to five trays for booted-kernel identity, source comparison, two-ticket workqueue timing, socket reference counts, and three local-service regression checks.
Figure 8. No single tray closes the case. Version, callback behavior, timing, ownership arithmetic, and service validation together make remediation auditable.

8 Patch the shared kernel, reboot every destination, and prove the second worker run stays visible

After reboot, run the descriptor-handoff service tests and the focused race regression in staging. Watch local-service stability during a canary period, then expand. Preserve previous kernel artifacts and evidence according to incident policy, but prevent automatic fallback into an affected image. Remediation is complete when every eligible destination runs fixed behavior, not when the first node accepts the package.

Rollout order should follow shared-kernel blast radius. Drain one representative node per flavor, preserve its pre-reboot identity, install and boot the candidate, prove the callback invariant, then return controlled workloads that exercise the node's real descriptor brokers. Expand only after both kernel and service checks pass. If a functional regression forces rollback, the fallback image must itself contain equivalent behavior; returning to the known affected kernel is an exposure decision that requires compensating workload controls and a new deadline, not an invisible operational reset.

8.1 A six-step response keeps version work and incident work from contaminating each other

  1. First, freeze a dated inventory. Record node, distribution, kernel flavor, source package, build ID, boot ID, architecture, workload trust class, container tenancy, and descriptor-passing dependencies. Attach the upstream or vendor range decision with its source. This snapshot prevents a later package update from rewriting what was exposed during the investigation window and preserves the population used for prioritization.

  2. Second, separate exposure from incident suspicion. Every affected running build enters the patch queue. Only machines with compatible crashes, queue loss, suspicious local processes, or race traces enter the incident lane. That separation lets operations remediate broadly without asserting compromise, while investigators preserve the smaller set of hosts whose evidence could answer whether the condition was exercised deliberately.

  3. Third, reduce opportunity only where policy allows. Pause untrusted multi-tenant scheduling, restrict creation of new workloads on affected nodes, and use existing sandbox rules to limit descriptor-passing syscalls for workloads that do not need them. Avoid global syscall blocks that break system services. These measures narrow the interval; they do not change the old worker's state ownership and must have an expiry tied to the reboot campaign.

  4. Fourth, deploy through canaries. Confirm the vendor package contains the callback-entry behavior, boot a representative node, run AF_UNIX service activation and broker tests, then execute the deterministic rerun test on an isolated equivalent kernel. Record both callback ordinals, the successful requeue, the second entry write, the sequence movement, and the deferred SCC result so a green test cannot come from missing the race. Watch for regression in container runtime, desktop portals, and local agents. Expand by kernel flavor because generic, HWE, cloud, and appliance streams can carry different source histories.

  5. Fifth, requalify capacity. Update base images, golden snapshots, PXE entries, rescue kernels, autoscaling templates, and migration targets. An updated active pool can still move workloads onto an old reserve node during failure. Admission and scheduler labels should be derived from executing-build evidence. Reboot debt belongs on the same dashboard as package compliance so installed-only machines cannot appear green.

  6. Sixth, close with artifacts. Retain the inventory, advisory mapping, source equivalence note, canary results, boot attestations, regression logs, service checks, and incident findings. State explicitly whether any false-worker interval or stale SCC consequence was observed. Record residual 6.1 uncertainty by vendor and owner. A closure package that preserves unknowns is stronger than a blanket claim that every unexplained crash was the CVE.

8.2 The durable lesson is to let execution own execution state

The first design lesson is lifecycle ownership. A scheduler can say that it requested work; only the callback can say that this execution has begun. When a framework can coalesce, defer, retry, or requeue a work item, state spanning actual execution should be opened and closed by that execution. If different contexts own the two ends, reviewers must model every framework transition between them.

The second lesson is that optimization predicates can become correctness predicates over time. gc_in_progress helped avoid redundant work and coordinate waits. unix_peek_fpl() later used it to decide whether a file-reference mutation needed publication. A flag that was “close enough” for scheduling became exact evidence for a snapshot protocol. Dependency reviews should revisit the producer's real invariant whenever a new consumer assigns stronger meaning.

The third lesson is to validate concurrency at the interval level. Atomic scalar accesses do not prove that a multi-step state machine describes reality. List the interval's opener, closer, observers, retries, early exits, and duplicate executions. Place assertions at callback entry and before final decisions. The T2/T3 sequence made the defect observable because it assigned every read and write to a run ordinal; a search for one unprotected assignment would have found intentional access wrappers and missed the legal ordering that extinguished the second interval.

The fourth lesson is conservative reclamation. If an event may invalidate reachability, defer collection. unix_scc_dead() correctly treats a changed sequence as live for the round. That choice prevents queue destruction at the cost of delayed garbage. Collectors for sockets, files, memory, and distributed leases share this priority: uncertainty about an outside owner must not be converted into irreversible cleanup.

The fifth lesson is operational: backports create non-monotonic version stories. A feature introduced in mainline 6.9 can appear in 6.6.93 and 6.1.141, while a later minor from another branch may be unaffected or separately fixed. Product package, flavor, source, and running boot form the unit of truth. Version comparisons without branch provenance are especially dangerous for long-lived kernels.

The final lesson returns to the room. The first shift may turn off its lamp because its own work is done. The next shift does not need the previous light to stay on; it must switch on its own. After d82ba05263c6, every unix_gc() callback does exactly that. Peek sees the collector, the sequence bell moves, an unstable SCC waits, and the living reader keeps its envelope.

Research record

9Evidence, objects, and sources

The material below preserves the identifiers and references used in this report.

9.1Research objects

Products, actors, techniques, affected objects, and control points discussed in the report.

CVECVE-2026-53361

Linux AF_UNIX workqueue rerun state race

Componentnet/unix/garbage.c

AF_UNIX descriptor-graph garbage collector

Functionunix_gc

Worker callback that must establish gc_in_progress on every entry

Functionunix_schedule_gc

Scheduler in which two callers can pass the false check

Functionunix_peek_fpl

MSG_PEEK sequence notification fast path

Fixd82ba05263c69fa2437fe93e4e561cc40f4c03af

Mainline worker-entry state repair

Introducer8b90a9f819dc2a06baae4ec1a64d875e53b824ec

Workqueue conversion that moved true into scheduling context

Prerequisitee5b31d988a41549037b8d8721a3c3cae893d8670

MSG_PEEK seqcount coordination that relies on the activity flag

Trigger contextSCM_RIGHTS + MSG_PEEK + queued unix_gc rerun

Local descriptor-cycle and scheduling conditions

9.2Event chronology

  1. AF_UNIX first serialized GC with peek

    Commit cbcf01128d0a used a GC-lock barrier after MSG_PEEK duplicated file references.

  2. GC became one work item

    Commit 8b90a9f819dc moved true into the scheduling path while converting collection to workqueue execution.

  3. Peek began invalidating SCC decisions with a seqcount

    Commit e5b31d988a41 added unix_peek_fpl and made active-GC truth part of the safety protocol.

  4. Every callback began establishing its own state

    Commit d82ba05263c6 moved the true write to unix_gc worker entry.

  5. The Linux CNA published CVE-2026-53361

    The record documented the three-thread schedule and four fixed upstream floors.

  6. SOSEC completed the source reconstruction

    SOSEC connected pinned source objects, version ranges, workqueue timing, vendor state, and repair validation as one evidence chain.

9.3Sources and material

  1. CVE.org: official CVE-2026-53361 recordhttps://www.cve.org/CVERecord?id=CVE-2026-53361
  2. CVEProject: raw Linux CNA JSONhttps://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/2026/53xxx/CVE-2026-53361.json
  3. Linux: mainline worker-entry fix d82ba05263c6https://git.kernel.org/linus/d82ba05263c69fa2437fe93e4e561cc40f4c03af
  4. Linux stable: fix 82c17e13d404https://git.kernel.org/stable/c/82c17e13d404f686e164590483fd6c1abaa675d0
  5. Linux stable: fix 591f1ac21742https://git.kernel.org/stable/c/591f1ac217428a6d2b32a8ac14aac0fab44f155a
  6. Linux stable: fix 0cfa78c05066https://git.kernel.org/stable/c/0cfa78c050662784fc8e3ab26dbfd1dc632b2082
  7. Linux: one-CPU workqueue conversion 8b90a9f819dchttps://git.kernel.org/linus/8b90a9f819dc2a06baae4ec1a64d875e53b824ec
  8. Linux: defer SCC when MSG_PEEK intervenes e5b31d988a41https://git.kernel.org/linus/e5b31d988a41549037b8d8721a3c3cae893d8670
  9. Linux: 2021 GC versus MSG_PEEK barrier cbcf01128d0ahttps://git.kernel.org/linus/cbcf01128d0a92e131bd09f1688fe032480b65ca
  10. Linux: Tarjan SCC detection 3484f063172dhttps://git.kernel.org/linus/3484f063172dd88776b062046d721d7c2ae1af7c
  11. Linux: AF_UNIX GC algorithm replacement 4090fa373f0ehttps://git.kernel.org/linus/4090fa373f0e763c43610853d2774b5979915959
  12. Linux: removal of the old peek lock dance 118f457da9edhttps://git.kernel.org/linus/118f457da9ed58a79e24b73c2ef0aa1987241f0e
  13. Linux: three-state GC graph simplification 6b6f3c71fe56https://git.kernel.org/linus/6b6f3c71fe568aa8ed3e16e9135d88a5f4fd3e84
  14. Linux: consolidated GC scheduling and waiting 24fa77dad25chttps://git.kernel.org/linus/24fa77dad25c2f55cc4615c09df2201ef72c66f4
  15. Linux documentation: workqueue API and requeue semanticshttps://www.kernel.org/doc/html/latest/core-api/workqueue.html
  16. Linux documentation: seqcount and seqlock ruleshttps://www.kernel.org/doc/html/latest/locking/seqlock.html
  17. Linux documentation: memory barrier wrappershttps://www.kernel.org/doc/html/latest/core-api/wrappers/memory-barriers.html
  18. Linux man-pages: AF_UNIX and SCM_RIGHTShttps://man7.org/linux/man-pages/man7/unix.7.html
  19. Linux man-pages: recv and MSG_PEEKhttps://man7.org/linux/man-pages/man2/recv.2.html
  20. Linux man-pages: ancillary control-message parsinghttps://man7.org/linux/man-pages/man3/cmsg.3.html
  21. Ubuntu: CVE-2026-53361 package and flavor statushttps://ubuntu.com/security/CVE-2026-53361
  22. Debian Security Tracker: CVE-2026-53361https://security-tracker.debian.org/tracker/CVE-2026-53361
  23. Debian: DSA-6381-1 Linux security updatehttps://www.debian.org/security/2026/dsa-6381
  24. Red Hat: CVE-2026-53361 product-status entry pointhttps://access.redhat.com/security/cve/CVE-2026-53361
  25. SUSE: CVE-2026-53361 product-status entry pointhttps://www.suse.com/security/cve/CVE-2026-53361.html
  26. Netdev patch: worker-entry state repair discussionhttps://patch.msgid.link/[email protected]
  27. Netdev patch: MSG_PEEK sequence protocol discussionhttps://patch.msgid.link/[email protected]
  28. Netdev series: one-CPU AF_UNIX GC work itemhttps://lore.kernel.org/r/[email protected]
  29. Netdev series: strongly connected component algorithmhttps://lore.kernel.org/r/[email protected]