{"repro_id":"REPRO-2026-00221","version":7,"title":"Linux kernel FUSE readdir cache out-of-bounds write","repro_type":"security","status":"published","severity":"high","description":"A missing bounds check in the Linux kernel FUSE readdir cache allows a malicious FUSE server to overflow a page-cache page by 24 bytes. In `fs/fuse/readdir.c`, `fuse_add_dirent_to_cache()` computes the serialized directory-entry size from the server-controlled `namelen` field and copies it into a single page-cache page. The check `offset + reclen > PAGE_SIZE` only handles records that do not fit in the remaining space of the current page; it does not reject records larger than `PAGE_SIZE` itself. After the `FUSE_NAME_MAX` increase to `PATH_MAX-1` (4095) in Linux 6.16 (commit 27992ef80770d), a FUSE daemon can return a dirent with `namelen=4095`, producing a 4120-byte record that overflows a 4 KiB page by 24 bytes into the adjacent kernel page. This can be exploited for unprivileged local privilege escalation by corrupting the page-cache copy of `/etc/passwd`. Affected versions are reachable from v6.16 through v7.0-rc and stable branches before their respective fixes. Reproduction uses the public PoC at https://github.com/0xCyberstan/CVE-2026-31694-POC inside a QEMU/KVM VM running a vulnerable kernel.","root_cause":"# CVE-2026-31694 RCA Report\n\n## Summary\n\nCVE-2026-31694 is an out-of-bounds write in the Linux kernel FUSE readdir cache. A malicious userspace FUSE daemon can return a `FUSE_READDIR` entry with a server-controlled `namelen=4095`; the kernel serializes this as a 4120-byte directory entry and copies it into a single 4096-byte readdir cache page. The missing `reclen > PAGE_SIZE` validation lets the final 24 bytes spill into the next physical page. In this run, the vulnerability was reproduced end-to-end in a QEMU guest using the real Linux kernel/FUSE code path, and the exploit chain was extended beyond KASAN-visible corruption: an unprivileged uid 1000 process changed the page-cache contents of a root-owned, read-only `/etc/passwd` file to a passwordless-root line. A fixed `fuse.ko` negative control rejected the oversized dirent and left `/etc/passwd` unchanged.\n\n## Impact\n\n- **Package/component affected:** Linux kernel FUSE subsystem, specifically `fs/fuse/readdir.c` in the readdir cache path (`fuse_add_dirent_to_cache()`).\n- **Affected versions:** The issue is reachable after the FUSE name length increase to `PATH_MAX - 1` / 4095, reported for Linux v6.16 through v7.0-rc and affected stable branches before the fix.\n- **Risk level and consequences:** High. A local attacker able to run a malicious FUSE daemon can obtain a constrained but attacker-controlled 24-byte overwrite into an adjacent physical page. When groomed onto a page-cache page for a privileged file such as `/etc/passwd`, the primitive bypasses filesystem write permissions and can produce local privilege escalation semantics.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Unprivileged local privilege escalation via page-cache corruption of `/etc/passwd`.\n- **Reproduced impact from this run:** Full local privilege-escalation primitive. The script boots a non-sanitized kernel in QEMU, uses the real FUSE kernel path, drops the exploit process to uid/gid 1000 before attacker-controlled `READDIR` replies and target corruption, verifies direct writes to `/etc/passwd` fail with `Read-only file system`, then shows `/etc/passwd` page-cache contents changed from `root:x:0:0:root:/root:/bin/sh` to `root::0:0:x:.:`. Two vulnerable attempts succeeded, and two fixed negative-control attempts left `/etc/passwd` unchanged.\n- **Parity:** `full`.\n- **Not demonstrated:** The proof stops after demonstrating the root-level page-cache corruption effect. It does not spawn an interactive root shell, but the demonstrated primitive is the disclosed LPE mechanism: an unprivileged attacker changes the trusted cached contents of a root-owned passwd file to a passwordless-root entry without write permission.\n\n## Root Cause\n\n`fuse_add_dirent_to_cache()` computes a serialized FUSE dirent size from a FUSE-server-controlled name length and copies that many bytes into a single page-cache page. The vulnerable logic only checks whether the record fits in the remaining space of the current page and, if not, advances to a fresh page with offset zero. It does not reject a record whose serialized length is larger than one page.\n\nConceptually, the vulnerable flow is:\n\n```c\nsize_t reclen = FUSE_DIRENT_SIZE(dirent);   // 4120 for namelen=4095\n...\noffset = size & ~PAGE_MASK;\nif (offset + reclen > PAGE_SIZE) {\n    index++;\n    offset = 0;\n}\n...\nmemcpy(addr + offset, dirent, reclen);      // copies 4120 bytes into 4096-byte page\n```\n\nFor `namelen=4095`, `FUSE_DIRENT_ALIGN(24 + 4095)` is 4120. At `offset=0`, the kernel still performs `memcpy(..., 4120)` into a 4096-byte page, spilling 24 bytes into the next physical page. The exploit controls those trailing bytes and uses page allocator grooming to place the first page of `/etc/passwd` in the adjacent page frame.\n\nThe fix is to reject oversized dirents before adding them to the readdir cache, e.g. by adding a guard equivalent to:\n\n```c\nif (reclen > PAGE_SIZE)\n    return;\n```\n\nThe ticket identifies upstream fix commit `51a8de6c50bf947c8f534cd73da4c8f0a13e7bed` (`fuse: reject oversized dirents in page cache`). The reproduction script builds the vulnerable module without this guard and the fixed module with this guard as the negative control.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh` from the repository workspace, or run it with `PRUVA_ROOT=/path/to/bundle`.\n2. The script:\n   - Reuses the prepared Linux source/build cache when available.\n   - Builds a non-sanitized Linux kernel and vulnerable `fuse.ko` from the real source.\n   - Builds a fixed `fuse.ko` with the `reclen > PAGE_SIZE` guard.\n   - Builds `bundle/repro/fuse_passwd_lpe` from `bundle/repro/fuse_passwd_lpe.c`, a public-PoC-derived malicious FUSE daemon that speaks the real FUSE protocol.\n   - Creates a small active ext4 root filesystem containing a root-owned, mode `0444` `/etc/passwd`.\n   - Boots QEMU twice with the vulnerable module and twice with the fixed module.\n   - In each guest, root performs only setup/module loading; the exploit drops to uid/gid 1000 before attacker-controlled FUSE replies and `/etc/passwd` targeting.\n3. Expected evidence:\n   - Vulnerable attempts show `PAGE_CACHE_CORRUPTION_CONFIRMED uid=1000 target=/etc/passwd`, `Direct write check as uid 1000: Read-only file system`, and `INIT_AFTER=root::0:0:x:.:`.\n   - Fixed attempts show `INIT_RESULT_FIXED_REJECTED_OVERSIZED_DIRENT` and `INIT_AFTER=root:x:0:0:root:/root:/bin/sh` with no page-cache-corruption confirmation.\n\n## Evidence\n\nPrimary current-run artifacts:\n\n- `bundle/repro/reproduction_steps.sh` — self-contained reproduction script.\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest written by the script.\n- `bundle/repro/validation_verdict.json` — structured verdict written by the script.\n- `bundle/logs/reproduction_steps.log` — consolidated build and proof log.\n- `bundle/logs/qemu_lpe_vuln_attempt1.log`\n- `bundle/logs/qemu_lpe_vuln_attempt2.log`\n- `bundle/logs/qemu_lpe_fixed_attempt1.log`\n- `bundle/logs/qemu_lpe_fixed_attempt2.log`\n- `bundle/repro/fuse_passwd_lpe.c` and `bundle/repro/fuse_passwd_lpe` — malicious FUSE daemon/helper used in the guest.\n- `bundle/repro/fuse-nokasan-vuln.ko` and `bundle/repro/fuse-nokasan-fixed.ko` — vulnerable and fixed FUSE modules used for comparison.\n\nKey vulnerable evidence from the second verified run in `bundle/logs/reproduction_steps.log`:\n\n```text\nPrimary proof build is non-sanitized: CONFIG_KASAN is disabled\nINIT_ROLE=vuln\nINIT_KERNEL=6.18.18\nINIT_ROOTFS_ACTIVE=/dev/vda\nINIT_BEFORE=root:x:0:0:root:/root:/bin/sh\n[+] Running exploit logic as uid=1000 gid=1000 target=/etc/passwd\n[+] Direct write check as uid 1000: Read-only file system\n[+] Warmup: 3/5 (60%)\n[*] LPE 1/200 ... [+] CORRUPTED /etc/passwd first_line=root::0:0:x:.:\nHIT!\n[+] PAGE_CACHE_CORRUPTION_CONFIRMED uid=1000 target=/etc/passwd\nINIT_EXPLOIT_RC=0\nINIT_AFTER=root::0:0:x:.:\nINIT_RESULT_PAGE_CACHE_LPE_CONFIRMED\nVULNERABLE attempt 1: unprivileged page-cache corruption of /etc/passwd confirmed\n```\n\nThe second vulnerable attempt produced the same successful markers:\n\n```text\nINIT_ROLE=vuln\nINIT_BEFORE=root:x:0:0:root:/root:/bin/sh\n[+] Direct write check as uid 1000: Read-only file system\n[+] PAGE_CACHE_CORRUPTION_CONFIRMED uid=1000 target=/etc/passwd\nINIT_AFTER=root::0:0:x:.:\nINIT_RESULT_PAGE_CACHE_LPE_CONFIRMED\nVULNERABLE attempt 2: unprivileged page-cache corruption of /etc/passwd confirmed\n```\n\nFixed negative-control evidence:\n\n```text\nINIT_ROLE=fixed\nINIT_BEFORE=root:x:0:0:root:/root:/bin/sh\n[+] Running exploit logic as uid=1000 gid=1000 target=/etc/passwd\n[+] Direct write check as uid 1000: Read-only file system\n[+] Warmup: 0/5 (0%)\n[-] No warmup hits.\nINIT_EXPLOIT_RC=1\nINIT_AFTER=root:x:0:0:root:/root:/bin/sh\nINIT_RESULT_FIXED_REJECTED_OVERSIZED_DIRENT\nFIXED attempt 1: oversized dirent failed closed; /etc/passwd unchanged\n```\n\nThe second fixed attempt also failed closed and left `/etc/passwd` unchanged. The script summary was:\n\n```text\nVulnerable page-cache-corruption attempts: 2/2\nFixed negative-control attempts: 2/2\nCVE-2026-31694 CONFIRMED: non-sanitized vulnerable kernel permits uid 1000 to corrupt the page-cache contents of root-owned read-only /etc/passwd; fixed module blocks it.\n```\n\n## Recommendations / Next Steps\n\n- Apply the upstream fix that rejects dirents whose serialized length exceeds `PAGE_SIZE` before caching them.\n- Backport the `reclen > PAGE_SIZE` guard to all supported kernels that include the FUSE name length increase and have vulnerable readdir cache logic.\n- Add regression tests for FUSE `READDIR` replies with maximum name lengths, including records that exceed a page after alignment.\n- Consider hardening and auditing other page-cache or direct-map write paths where attacker-controlled data can cross a page boundary into an adjacent physical page.\n- Treat KASAN-only testing as insufficient for this exploit class: the primary exploit target is a page-cache page and the important observable impact is privileged file-content corruption in the kernel page cache.\n\n## Additional Notes\n\n- **Idempotency confirmation:** `bundle/repro/reproduction_steps.sh` was executed twice consecutively after the shell bug was fixed. Both runs exited 0 and produced the same 2/2 vulnerable and 2/2 fixed outcomes.\n- **Primary proof mode:** The proof is intentionally non-sanitized (`CONFIG_KASAN` disabled) and uses product/runtime kernel execution through QEMU and the real FUSE boundary.\n- **Privilege boundary:** The process opens `/dev/fuse` and mounts during privileged harness setup, then calls `setresgid(1000,1000,1000)` and `setresuid(1000,1000,1000)` before sending malicious FUSE `READDIR` replies and grooming `/etc/passwd`.\n- **Scope:** The proof demonstrates the disclosed LPE primitive by changing the trusted page-cache contents of root-owned `/etc/passwd`. It does not persist the change to disk or spawn an interactive root shell, to keep the run deterministic and self-contained.\n","cve_id":"CVE-2026-31694","source_url":"https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git","reproduced_at":"2026-07-03T18:44:47.877932+00:00","duration_secs":3785.0,"tool_calls":393,"handoffs":3,"total_cost_usd":18.515090270000005,"agent_costs":{"hypothesis_generator":0.0142516,"judge":0.09663425,"repro":15.719200290000003,"support":0.07574913,"vuln_variant":2.6092550000000005},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0142516},"judge":{"gpt-5.4-mini":0.09663425},"repro":{"accounts/fireworks/routers/glm-5p2-fast":7.78636029,"gpt-5.5":7.932839999999999},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.07574913},"vuln_variant":{"gpt-5.5":2.6092550000000005}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-03T18:44:49.384808+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/fix.patch","filename":"fix.patch","size":422,"category":"patch"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":9938,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15977,"category":"reproduction_script"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":10917,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":10781,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":1303,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":2519,"category":"other"},{"path":"bundle/repro/build_initramfs.sh","filename":"build_initramfs.sh","size":1970,"category":"other"},{"path":"bundle/repro/fuse-vuln.ko","filename":"fuse-vuln.ko","size":766792,"category":"other"},{"path":"bundle/repro/fuse-fixed.ko","filename":"fuse-fixed.ko","size":766728,"category":"other"},{"path":"bundle/repro/fuse_evil.c","filename":"fuse_evil.c","size":19507,"category":"other"},{"path":"bundle/repro/fuse_evil","filename":"fuse_evil","size":855576,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":876,"category":"other"},{"path":"bundle/repro/fuse_passwd_lpe.c","filename":"fuse_passwd_lpe.c","size":17410,"category":"other"},{"path":"bundle/repro/build_lpe_initramfs.sh","filename":"build_lpe_initramfs.sh","size":1896,"category":"other"},{"path":"bundle/repro/fuse-nokasan-vuln.ko","filename":"fuse-nokasan-vuln.ko","size":409272,"category":"other"},{"path":"bundle/repro/fuse-nokasan-fixed.ko","filename":"fuse-nokasan-fixed.ko","size":409448,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1060,"category":"other"},{"path":"bundle/repro/passwd.seed","filename":"passwd.seed","size":71,"category":"other"},{"path":"bundle/repro/init.rootfs","filename":"init.rootfs","size":925,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":10327,"category":"log"},{"path":"bundle/logs/qemu_vuln_attempt1.log","filename":"qemu_vuln_attempt1.log","size":30870,"category":"log"},{"path":"bundle/logs/qemu_vuln_attempt2.log","filename":"qemu_vuln_attempt2.log","size":30777,"category":"log"},{"path":"bundle/logs/qemu_vuln_attempt3.log","filename":"qemu_vuln_attempt3.log","size":31150,"category":"log"},{"path":"bundle/logs/qemu_vuln_attempt4.log","filename":"qemu_vuln_attempt4.log","size":30957,"category":"log"},{"path":"bundle/logs/qemu_vuln_attempt5.log","filename":"qemu_vuln_attempt5.log","size":30848,"category":"log"},{"path":"bundle/logs/qemu_fixed_attempt1.log","filename":"qemu_fixed_attempt1.log","size":25747,"category":"log"},{"path":"bundle/logs/qemu_fixed_attempt2.log","filename":"qemu_fixed_attempt2.log","size":303,"category":"log"},{"path":"bundle/logs/test_lpe_vuln.log","filename":"test_lpe_vuln.log","size":25315,"category":"log"},{"path":"bundle/logs/test_lpe_v709.log","filename":"test_lpe_v709.log","size":30143,"category":"log"},{"path":"bundle/logs/test_lpe_v709b.log","filename":"test_lpe_v709b.log","size":29804,"category":"log"},{"path":"bundle/logs/test_lpe_kasan_target.log","filename":"test_lpe_kasan_target.log","size":25761,"category":"log"},{"path":"bundle/logs/test_lpe_fixed.log","filename":"test_lpe_fixed.log","size":25621,"category":"log"},{"path":"bundle/logs/test_active_rootfs_vuln.log","filename":"test_active_rootfs_vuln.log","size":26228,"category":"log"},{"path":"bundle/logs/test_nokasan_vuln.log","filename":"test_nokasan_vuln.log","size":25710,"category":"log"},{"path":"bundle/logs/qemu_lpe_vuln_attempt1.log","filename":"qemu_lpe_vuln_attempt1.log","size":25959,"category":"log"},{"path":"bundle/logs/qemu_lpe_vuln_attempt2.log","filename":"qemu_lpe_vuln_attempt2.log","size":25955,"category":"log"},{"path":"bundle/logs/qemu_lpe_fixed_attempt1.log","filename":"qemu_lpe_fixed_attempt1.log","size":25510,"category":"log"},{"path":"bundle/logs/qemu_lpe_fixed_attempt2.log","filename":"qemu_lpe_fixed_attempt2.log","size":25509,"category":"log"},{"path":"bundle/logs/vuln_variant/readdirplus_variant.log","filename":"readdirplus_variant.log","size":6748,"category":"log"},{"path":"bundle/logs/vuln_variant/qemu_readdirplus_vuln.log","filename":"qemu_readdirplus_vuln.log","size":27019,"category":"log"},{"path":"bundle/logs/vuln_variant/qemu_readdirplus_fixed.log","filename":"qemu_readdirplus_fixed.log","size":26411,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":1085,"category":"other"},{"path":"bundle/logs/vuln_variant/artifact_hashes.txt","filename":"artifact_hashes.txt","size":494,"category":"other"},{"path":"bundle/vuln_variant/fuse_readdirplus_lpe.c","filename":"fuse_readdirplus_lpe.c","size":19054,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1081,"category":"other"},{"path":"bundle/vuln_variant/fuse-readdirplus-vuln.ko","filename":"fuse-readdirplus-vuln.ko","size":409272,"category":"other"},{"path":"bundle/vuln_variant/fuse-readdirplus-fixed.ko","filename":"fuse-readdirplus-fixed.ko","size":409448,"category":"other"},{"path":"bundle/vuln_variant/passwd.seed","filename":"passwd.seed","size":71,"category":"other"},{"path":"bundle/vuln_variant/init.readdirplus","filename":"init.readdirplus","size":995,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6899,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4174,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2490,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2085,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1307,"category":"other"}]}