NULL-pointer dereference in hammer2_freemap_adjust() when the freemap leaf is absent β mount-time panic from crafted image
| Field | Value |
|---|---|
| ID | DF-2651 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-476 NULL Pointer Dereference |
| File | sys/vfs/hammer2/hammer2_freemap.c |
| Lines | 1013-1021 (create path :1037 proves NULL was meant to be handled) |
| Area | vfs |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | hammer2 |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
hammer2_freemap_adjust() tests chain->error at freemap.c:1021 before
ruling out chain == NULL. The NULL guard at :1016
(chain == NULL && how != DORECOVER) is structurally dead because how
is KKASSERTed == HAMMER2_FREEMAP_DORECOVER at :972 (the only live mode;
the free paths are #if 0). When the on-disk freemap topology has no
FREEMAP_LEAF covering the bref's 1GB region (crafted image with blanked
freemap_blockset), the mount-time recovery scan (vfsops.c:2234/:2325)
calls adjust(DORECOVER), the lookup returns NULL, and the kernel
page-faults reading NULL+0x170 (offsetof(hammer2_chain_t, error)).
INVARIANTS-independent β no assertion is involved.
Threat model & preconditions
Crafted hammer2 image (blanked volume-header freemap_blockset + mirror_tids above freemap_tid); any mount (root, vfs.usermount=1, auto-mounter) takes a deterministic fatal trap 12 during mount. Ceiling is DoS (page 0 unmapped on amd64). The dedup re-registration path (chain.c:1627) reaches the same deref.
Proof of concept
VERIFIED on the guest (findings/poc/DF-2651/): forge_2651.py (blanks
freemap_blockset, bumps sroot+testvol bref mirror_tids 0x11β0x20,
CHECK_NONE ancestors, volhdr CRC32C recompute) β vnconfig + mount β
Fatal trap 12 ... fault virtual address = 0x170 ... Stopped at
hammer2_freemap_adjust+0xce: movl 0x170(%rax),%r9d. Baseline panic on
stock #0; patched kernel #1 mounts the same image cleanly. No uid=0
route β NULL+const read on unmapped page 0.
Recommended fix
--- a/sys/vfs/hammer2/hammer2_freemap.c
+++ b/sys/vfs/hammer2/hammer2_freemap.c
@@ -1013,13 +1013,17 @@
/*
* Stop early if we are trying to free something but no leaf exists.
*/
- if (chain == NULL && how != HAMMER2_FREEMAP_DORECOVER) {
- kprintf("hammer2_freemap_adjust: %016jx: no chain\n",
- (intmax_t)bref->data_off);
- goto done;
- }
- if (chain->error) {
+ /*
+ * DF-2651: chain can legitimately be NULL here (missing freemap
+ * leaf); only DORECOVER creates it further down.
+ */
+ if (chain == NULL) {
+ if (how != HAMMER2_FREEMAP_DORECOVER) {
+ kprintf("hammer2_freemap_adjust: %016jx: no chain\n",
+ (intmax_t)bref->data_off);
+ goto done;
+ }
+ } else if (chain->error) {
kprintf("hammer2_freemap_adjust: %016jx: error %s\n",
(only test chain->error after ruling out NULL so DORECOVER falls through to the existing chain_create at :1037)
Timeline
- 2026-08-29 Discovered during pass-2 audit of hammer2_freemap.c (GLM 5.3); verified reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2651 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| forge_2651.py | β | 2.5 KB | view raw | |
| h2common.py | β | 3.8 KB | view raw | |
| base2651.img.gz | β | 2.1 MB | β download | |
| forge2651.img.gz | β | 2.1 MB | β download | |
| panic.txt | β | 754 B | view raw | |
| panic2651_full.txt | β | 2.8 KB | view raw | |
| run.log | β | 1.1 KB | view raw | |
| env.txt | β | 195 B | view raw | |
| fix.diff | β | 896 B | view raw | |
| fix_all_three_findings.diff | β | 2.2 KB | view raw | |
| fix_build.log | β | 254 B | view raw | |
| fix_run.log | β | 773 B | view raw | |
| build.sh | β | 299 B | view raw | |
| run.sh | β | 551 B | view raw | |
| verdict.json | β | 4.3 KB | view raw |
Fix verification
fixedfix.diff reorders the NULL handling in hammer2_freemap_adjust(): on kernel #1 the exact PoC image that fatal-faulted (movl 0x170(%rax)) on #0 mounts cleanly, files are created/read, unmount is clean, guest stays up. Baseline panic gone; no regression on clean images.
fix_run.log (MOUNT_OK + probe file round-trip on #1); fix_build.log (nativekernel BUILD_RC=0, installkernel INSTALL_RC=0, uname #1); panic.txt for the #0 baseline
Confirmed kernel references
Detail
Exploit chain
crafted hammer2 image (blanked freemap_blockset + bumped mirror_tids + recomputed volhdr CRC32C) -> victim mounts (root / vfs.usermount=1 / automounter) -> hammer2_recovery_scan -> hammer2_freemap_adjust(DORECOVER) -> chain==NULL -> read chain->error at freemap.c:1021 -> kernel page fault -> panic (DoS)
Evidence (decisive lines)
["panic.txt: 'Stopped at hammer2_freemap_adjust+0xce: movl 0x170(%rax),%r9d', fault VA 0x170 == offsetof(hammer2_chain_t, error)", 'run.log: full fatal-trap block from serial console; guest down', 'forge_2651.py: the three edits (blank freemap_blockset, mtid 0x11->0x20 on sroot+testvol brefs, CHECK_NONE ancestors, volhdr CRC recompute)', 'fix_run.log: patched kernel #1 mounts the same image with MOUNT_OK and stays up']
PoC changes
Forge built on DF-2650 tooling (h2common.py). Two iterations needed: first run mounted cleanly because only the in-sroot-block testvol bref was bumped - recovery never recursed since the volhdr sroot_blockset bref (the recursion gate) still had mirror_tid 0x11; fixed by bumping the volhdr sroot bref too. Guest /mnt/h2t had to pre-exist before vnconfig (set -e aborted runs otherwise).
Verified recommended fix
In hammer2_freemap_adjust(), only test chain->error after ruling out chain == NULL; let DORECOVER fall through to the existing chain_create at :1037.
Verdict
REPRODUCED on stock INVARIANTS kernel #0: mounting a crafted hammer2 image whose volume header freemap_blockset is blanked (no FREEMAP_LEAF for any 1GB region) and whose sroot/testvol brefs carry mirror_tid > freemap_tid makes the mount-time recovery scan call hammer2_freemap_adjust(DORECOVER) (vfsops.c:2234); the freemap lookup returns NULL, the guard at freemap.c:1016 is structurally dead (how is KKASSERTed == DORECOVER at :972), and the unconditional 'if (chain->error)' at freemap.c:1021 reads NULL+0x170 -> Fatal trap 12, guest down at db>. INVARIANTS-independent (no assertion involved). Ceiling is panic/DoS from crafted-image mount (page 0 unmapped on amd64); not escalatable to code execution. Fixed by reordering the NULL handling so DORECOVER falls through to the chain_create at :1037; validated on rebuilt kernel #1: same image mounts cleanly, files usable, guest stays up.
No comments yet.