On July 27, 2026, Apple shipped the 26.6 updates. The macOS security notes describe the issue this way:

APPLE SECURITY ADVISORYImpact: Connecting to a malicious NFS server may lead to kernel memory corruption
Description: A buffer overflow was addressed with improved bounds checking.
That is CVE-2026-28931, and my name appears alongside three other credited researchers.
The bug is one subtraction. A server sends the number zero, the client subtracts one from it in an unsigned 32-bit variable, and from that moment the bounds check protecting an eight-entry kernel array can never fail again. This is the full path from the first grep to the kernel panic, including the two days I spent triggering absolutely nothing.
Apple lists the same CVE in the macOS Tahoe 26.6, iOS 26.6 and iPadOS 26.6, tvOS 26.6, and watchOS 26.6 advisories.

Picking the target
Apple publishes the NFS client source at apple-oss-distributions/NFS. I worked from tag NFS-343.100.5, roughly 50,000 lines that ship as a single kext, com.apple.filesystems.nfs.
I picked it for one reason: it is parsing code that runs in the kernel on bytes supplied by a remote peer. Every reply from the server is a set of numbers the client has to decide whether to believe, and it makes those decisions in the most privileged context on the machine.
Before reading a line of it for bugs I spent the first pass on boring questions. Which tag is actually shipping on the Mac in front of me, which files handle wire input as opposed to local state, and what is reachable without already being privileged.
Reachability first, always
The query I actually ran was mechanical: find every value pulled off the wire and used as a count, a size, or an array index. In this codebase that means following nfsm_chain_get_32 and its siblings to every destination field, then asking what bounds each field.
Most candidates die at the reachability gate, and they should. A flaw that requires a privileged local action has narrower reach than one triggered by unsolicited network traffic.
The CREATE_SESSION reply parser still went near the top of the list because it handles server-controlled data in kernel context. Reaching it required a user or process with mount privilege to start an NFSv4.1 mount, which is an important constraint on the finding.
A clamp, and five lines later, none
In kext/nfs4_vnops.c the CREATE_SESSION parser pulls the back-channel attributes off the wire. Line 10298 reads ca_maxoperations into sp->ns_cbmaxops and immediately clamps it. Line 10302 reads ca_maxrequests into sp->ns_backslots and stores it verbatim.
Two values, from the same reply, in the same function, five lines apart. One guarded, one not. That asymmetry is the entire finding; everything after it is consequence.
The reason a gap like this survives review is that it looks finished. A reader sees a clamp in the neighbourhood and files the whole block as validated.
nfsm_chain_get_32(error, &nmrep, sp->ns_cbmaxops);
if (sp->ns_cbmaxops > NFS41_CBMAXOPS) {
sp->ns_cbmaxops = NFS41_CBMAXOPS;
}
nfsm_chain_get_32(error, &nmrep, sp->ns_backslots);
Zero minus one
ns_backslots is a uint32_t. When a callback arrives, kext/nfs_socket.c line 3259 computes the highest legal slot: max_slot = MIN(nmp->nm_session.ns_backslots, NFS41_CBSLOTS) - 1.
NFS41_CBSLOTS is 8, so the intended value is 7. But if the server answered ca_maxrequests = 0, then MIN(0, 8) is 0, and 0 - 1 in unsigned 32-bit arithmetic is 4294967295.
RFC 8881 says a server must not change the back-channel ca_maxrequests value offered by the client. The client offered eight; the malicious server replied with zero, and the implementation did not reject it.
A check that cannot fail is worse than no check, because everyone who reads the code sees it and stops worrying.
The guard that cannot fail
nfs41_sequence_cb_get in kext/nfs4_subs.c takes slotid and high_slot straight off the wire and checks them at lines 3786 and 3791: if (slotid > max_slot) then NFSERR_BADSLOT. Both operands are uint32_t.
With max_slot sitting at 0xFFFFFFFF there is no uint32_t that can exceed it. The check still executes on every single request; it just answers fine forever. Three lines later, at 3797: cbslot = &sp->ns_cbslots[slotid].
ns_cbslots is a fixed eight-entry array, 16 bytes per entry, 128 bytes total, at offset 0x140 inside the session struct. slotid is a full 32-bit value chosen by the server.
What one callback buys the attacker
Depending on the state-machine branch, a CB_COMPOUND can perform operations through three fields on that out-of-bounds slot: read and increment ncbs_seqid, write 1 into ncbs_inprog, or read ncbs_reply as an mbuf_t pointer, pass it to mbuf_free, and then null it.
Together, those branch-dependent accesses create relative read and write primitives at a 16-byte stride across 4294967296 possible slot values, about a 64 GiB offset window from the base of a 128-byte array. The follow-up evidence specifically demonstrated the ncbs_inprog write at chosen relative offsets.
The mbuf_free path is the interesting one. It turns the out-of-bounds pointer read into a potential invalid free. That is a possible route toward use-after-free exploitation, but the runtime result I demonstrated was a kernel panic, not a completed exploit chain.
The follow-up: proving the write
The first submission proved the panic and symbolicated the exact faulting load. A follow-up supplement then tested whether the predicted writes could be observed without relying only on source analysis.
In Run A the server probed 96 consecutive out-of-bounds slots with seqid zero. Every reply returned status zero. After an unmount and remount, Run B probed the same slots and every reply returned NFSERR_DELAY. The source state machine makes that transition specific: the first run set ncbs_inprog to 1, and the second run observed the changed state over the network.
Two additional panic runs used slotid 2048 and 1920. In both, the kernel's computed pointer exactly matched sp + 0x140 + slotid * 16, and the allocator report labeled the access out-of-bounds with high confidence.
The saved evidence did not capture kern.bootsessionuuid around both write and readback runs. Same-boot continuity is supported by measured uptime and adjacent client ports, but the supplement records that limitation explicitly.
Run A seqid=0 -> status 0 -> ncbs_inprog written to 1
Run B seqid=0 -> NFSERR_DELAY -> prior write observed
Writing a server that lies
Nothing off the shelf does this, so I wrote one: about 700 lines of Python, no dependencies, speaking RPC and NFSv4.1 correctly right up until it does not. It answers EXCHANGE_ID normally, answers CREATE_SESSION with back-channel ca_maxrequests = 0 and CREATE_SESSION4_FLAG_CONN_BACK_CHAN set so the same TCP connection becomes the back channel, then immediately sends a CB_COMPOUND down it.
The malicious part is one field: struct.pack(">I", 0xCCCCCCCC) for csa_slotid, with csa_highest_slotid matching. I chose 0xCCCCCCCC because it is unmistakable in a register dump.
The victim side is one command: sudo mount_nfs -o nfsvers=4.1,timeo=10,retrycnt=1 ATTACKER_IP:/exp /tmp/oob-test.
Two days of triggering nothing
The first runs did nothing at all. The mount completed, my callback went out on the wire, the client ignored it, and no amount of re-reading my own encoder explained why.
The answer was in nfs_socket.c line 2946: nfsm_assert(val == NFS4_CALLBACK_PROG_VERSION, EPROGMISMATCH). The callback program 0x40000000 carries its own version number, and that number is 1, not the 4 you reach for after thinking about NFSv4 for a week. Every callback I sent was being thrown away by the RPC dispatcher before it ever reached the vulnerable function.
This is what most of the work looks like. Not insight; one field at a time against the dispatch code until the mismatch shows itself.
The panic, and the fingerprints in the registers
With the version corrected, the client panicked within seconds of the mount and dropped a report in /Library/Logs/DiagnosticReports/. The register state is the part that turns a suspicion into evidence.
x3, x20 and x24 all held 0x00000000cccccccc: my slotid arriving in an argument register and staying live across the frame. x4 held 0x00000000ffffffff, which is exactly MIN(0, 8) - 1. x9 held 0x0000000cccccccc0, my slotid shifted left by four, which is the kernel multiplying my number by the 16-byte slot size and using it as an index.
ESR was 0x96000004, a data abort with a level 0 translation fault. FAR was 0x2badfe2d8eeddd84, and those 0x2bad top bits are not random.
x3: 0x00000000cccccccc
x4: 0x00000000ffffffff
x9: 0x0000000cccccccc0
x20: 0x00000000cccccccc
x24: 0x00000000cccccccc
esr: 0x0000000096000004
far: 0x2badfe2d8eeddd84
What 0x2bad was telling me
I pulled the kernelcache off the machine, unwrapped the Image4 container, decompressed the LZFSE payload and looked up the symbol. The panic PC resolved to _nfs41_sequence_cb_get + 0xE8, and disassembling around it showed the compiler's own hardening at work.
The sequence computes the array base, multiplies slotid by 16, then compares sign-extended and zero-extended address calculations. When the attacker-controlled index has its high bit set the two computations disagree, and the compiler deliberately poisons the pointer so the load faults into an unmapped region carrying a recognisable tag.
Two facts fell out of that disassembly for free. sizeof(nfsseq_cbslot) is 16, from the shift by 4, and offsetof(nfs_session, ns_cbslots) is 0x140, from the add. Both had been inferences from header layout until the instructions confirmed them.
add x8, x21, #0x140
mov x9, x20
ubfiz x9, x9, #4, #0x20
cmp x9, w9, sxtw
add x22, x8, w9, sxtw
add x16, x8, x9
movk x16, #0x2bad, lsl #48
csel x22, x22, x16, eq
ldr w8, [x22, #4]
The hardening is not the fix
It is worth being precise about what that poisoning does and does not do. It caught 0xCCCCCCCC because that value has its high bit set, so the two address computations disagreed and the pointer was deliberately made to fault.
A slotid without the high bit set does not take that path. The computation is then a plain in-range-looking addition, so the vulnerable out-of-bounds access remains possible. The panic I produced was the loud version of the bug, not proof of its maximum exploitability.
Two builds, one bug
Mid-engagement the test Mac moved from macOS 26.2 (build 25C56, xnu-12377.61.12) to macOS 26.4.1 (build 25E253, xnu-12377.101.15). I re-ran the unmodified proof of concept.
Different kext UUID, different KASLR slide, different PC, different offset inside the binary. Identical ESR, identical 0x2bad tag, identical x3, x4 and x9. The same logic defect survived a recompilation of the kext.
That is the step that moves a finding from it crashed once to something worth putting your name on.
From panic toward control
The panic is rung one of the ladder and it is the only rung I built. It is still a real outcome on its own: the tested macOS builds panicked after a privileged NFSv4.1 mount connected to the hostile export. In environments that use NFS, a shared server can still affect many systems whose administrators mount it.
A next research step would be aiming the corruption. An exploit developer could try to groom the kernel heap so that something worth corrupting lands at a predictable offset from ns_cbslots, then choose the slotid that lands on it. I did not build or validate that heap-grooming step.
Rung three would require converting the limited field accesses into a stronger primitive, such as controlled kernel read and write. I did not build or validate that conversion, so it remains an exploitation path, not a demonstrated result.
Why kernel bugs are priced the way they are
The App Sandbox, TCC prompts, System Integrity Protection and code-signing enforcement are not independent walls. They are rules that the kernel applies. Code execution in kernel context does not climb over them, it edits the rulebook.
I want to be exact about the boundary of my own work. I demonstrated the out-of-bounds primitive and the panic, not a full exploit chain, and I said so plainly in the report. Apple fixed the issue after receiving that evidence. Requiring proof of complete system compromise before remediation would ask researchers to finish a weapon before anyone listens.
The negative result I put in my own report
The whole impact story rests on the victim connecting first, so the sharpest question anyone could ask is whether a link can do it. macOS does understand nfs:// URLs.
I tested it. I opened an nfs:// URL through Finder and captured every packet the victim sent. It never touched port 2049. The capture contained repeated SYNs to port 111, the portmapper, because that path defaulted to NFSv3 and never reached the NFSv4.1 code containing the bug.
So I wrote in my own report that my tested link path did not reach the bug, and attached the capture. It lowered my own severity. Handing the limitation over before the vendor finds it is the difference between a report and a pitch, and credibility is the only thing in this field that compounds.
Where the AI helped, and where it would have lied
I ran this engagement with Claude reading alongside me. It is genuinely strong at the sweep: find every consumer of a value that arrives from the wire, trace it across a dozen files, tell me whether it is bounded between here and there, and show me the lines rather than a summary. The clamp asymmetry is exactly the shape a pattern sweep across 50,000 lines surfaces well.
What it cannot do is tell you a bug is real. The failure mode is not gibberish, it is plausibility: correct line numbers, a coherent data flow, and a code path that never actually executes. That output survives a quick review, which is precisely what makes it dangerous.
So the process assumes the assistant is wrong until proven otherwise. Nothing counts until a second pass, with no access to the first write-up, re-derives it cold. Every claim carries quoted source with file and line. Reachability is proven with a packet capture, not asserted. Every dead end goes into a ledger with the reason it fooled me, and that ledger is far longer than the findings folder.
Timeline
I reported it to Apple on May 10, 2026, with the hostile server, the packet captures, both panic logs, the symbolication, and the negative reachability result. Apple acknowledged within two days, and later confirmed they had reproduced it independently and were planning a fix.
The patch shipped on July 27, 2026 in macOS Tahoe 26.6, iOS 26.6, iPadOS 26.6, tvOS 26.6 and watchOS 26.6. Apple describes the fix as improved bounds checking for a buffer overflow reached by connecting to a malicious NFS server.
Three other researchers are credited on the same CVE. Once a bug class becomes visible, several careful people converge on the same code within the same few months, and nobody really owns a bug.

References
- Apple: About the security content of macOS Tahoe 26.6
- Apple: About the security content of iOS 26.6 and iPadOS 26.6
- Apple: About the security content of tvOS 26.6
- Apple: About the security content of watchOS 26.6
- CVE-2026-28931 record
- Apple NFS source, tag NFS-343.100.5
- RFC 8881: CREATE_SESSION and channel attributes
What I would take from this
Anything your fleet connects to by habit is pointed back at your fleet. A file server is normally modelled as an asset to protect; it is also a machine that talks to every laptop in the building, and each of those laptops runs kernel code deciding how much to believe it.
Unsigned subtraction is still one of the most productive bug patterns in systems software, forty years in. If your code does count - 1, the question is what happens when count is already zero, and the answer is almost never a compile error.
A check that cannot fail is worse than no check, because everyone who reads the code sees it and stops worrying. The bug here was ten lines. Everything else was process.
Glossary
- NFS:
- Network File System, a protocol that lets a computer use files stored on another machine.
- Kernel:
- The core of the operating system, with authority over memory, hardware and security controls.
- Unsigned integer:
- A whole-number type that cannot represent negative values. Subtracting one from zero wraps to its largest value.
- Out-of-bounds access:
- A read or write outside the memory reserved for an array or object.
- KASLR:
- Kernel Address Space Layout Randomization, which changes where kernel code and data are placed in memory.
This work was conducted against Apple's publicly released open-source code and reported privately through the Apple Security Bounty programme. It is published after the fix shipped. Nothing here concerns an unpatched issue.
