Skip to content

Fix use-after-free and double-free in kmem_vasprintf() (SSV-26896) - #115

Draft
datacore-senthil wants to merge 2 commits into
rel-10psp22from
SSV-26896-fix
Draft

Fix use-after-free and double-free in kmem_vasprintf() (SSV-26896)#115
datacore-senthil wants to merge 2 commits into
rel-10psp22from
SSV-26896-fix

Conversation

@datacore-senthil

Copy link
Copy Markdown
Collaborator

Fixes a kernel use-after-free plus double-free that corrupted the shared kmem/vmem freelists and produced bugchecks in unrelated subsystems (nvlist and ABD teardown) long after the fact. Introduced by the SSV-26770 CodeQL remediation; found by root-causing a cluster of ZFSin crash dumps, one of which was captured under Driver Verifier.

Root cause

kmem_vasprintf() used the "measure with a NULL destination, then allocate that much" idiom. The SSV-26770 work replaced the underlying _vsnprintf(NULL, 0, ...) - which returns the true, unbounded required length - with zfs_vsnprintf(NULL, 0, ...), which routes to zfs_vscprintf(). Kernel mode has no linkable way to measure a format without writing it (_vscprintf is declared by the WDK but not exported by the kernel-mode CRT import lib; _vsnprintf_s rejects count == 0), so zfs_vscprintf() measures by formatting into a bounded 1024-byte scratch buffer and therefore reports a capped 1023 for anything longer.

Sizing an allocation from that capped length under-allocates, which made a previously unreachable error path in kmem_vasprintf() reachable for the first time: for any format whose output is >= 1024 characters, kmem_alloc(size + 1) is too small, the real write returns -1, and the error path ran

kmem_free(ptr, size);	/* allocated size + 1: mismatched size */
r = -1;			/* r is discarded; ptr is NOT cleared */
...
return (ptr);		/* returns the freed pointer */

so the caller received a dangling pointer into freed memory. Before the remediation this path was dead code: the measurement was exact, so the write always returned exactly size and the condition was never true.

The principal caller is log_internal() in module/zfs/spa_history.c, which does

msg = kmem_vasprintf(fmt, adx);
fnvlist_add_string(nvl, ZPOOL_HIST_INT_STR, msg);  /* UAF read */
kmem_strfree(msg);                                /* double free */

i.e. it reads freed memory into the pool-history nvlist and then frees the block a second time, with a size derived from whatever string happens to occupy it. That is reached from spa_history_log_internal(), which logs nearly every pool operation, so the resulting freelist corruption surfaced later as "bad free" panics in vmem_hash_delete() and as nvlists and ABDs containing another owner's data.

Fix

kmem_vasprintf() now grows a scratch buffer and retries the real write until the whole string fits, with no measurement pass, so the freed size always matches the allocated size and it never returns a freed pointer. It still returns a buffer allocated at exactly strlen() + 1: callers free these with kmem_strfree(), which computes the size as strlen(str) + 1, so anything else (for example returning the grown power-of-two buffer directly) would reintroduce the same mismatched-free bug at all ~25 kmem_asprintf() call sites. kmem_asprintf() now delegates to kmem_vasprintf(). Both keep their existing "never returns NULL" contract, accepting a truncated but valid string at a 64 KB ceiling.

__dprintf() had the same dependency on an exact measurement, so it is rebuilt on the now-safe helpers instead of hand-computing offsets into a single buffer. That also removes a second off-by-one, missed by an earlier review, in the prefix write: snprintf(buf, size + 1, ...) told it one more byte than buf actually had. The allocation length is recorded before the trailing-newline strip edits the string in place, since strlen() + 1 afterwards is a byte short of what was allocated.

The comment on zfs_vscprintf() in types.h asserted that a capped measurement was harmless because a later write would truncate identically. That reasoning is what produced this bug - it holds only when the measured length is not used to size the buffer. Replaced with an explicit warning that the value is capped and must never size an allocation.

Audited for the same patterns: the only remaining buf==NULL/size==0 callers are under module/os/freebsd, which is not part of this driver. The vendored zlib's equivalent size==0 branch is unreachable (its callers always pass sizeof(buf)). No caller modifies a kmem_asprintf() result before kmem_strfree(). The ~23 sites that consume an snprintf() return value are unaffected, as both the old _snprintf and the new _vsnprintf_s(_TRUNCATE) return -1 on truncation, and none of them can reach the exact-fit boundary where the two differ.

Not yet build-verified or re-scanned; pushed for review and testing.

Motivation and Context

Description

How Has This Been Tested?

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Performance enhancement (non-breaking change which improves efficiency)
  • Code cleanup (non-breaking change which makes code smaller or more readable)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Library ABI change (libzfs, libzfs_core, libnvpair, libuutil and libzfsbootenv)
  • Documentation (a change to man pages or other documentation)

Checklist:

Fixes a kernel use-after-free plus double-free that corrupted the shared
kmem/vmem freelists and produced bugchecks in unrelated subsystems
(nvlist and ABD teardown) long after the fact. Introduced by the
SSV-26770 CodeQL remediation; found by root-causing a cluster of ZFSin
crash dumps, one of which was captured under Driver Verifier.

Root cause

kmem_vasprintf() used the "measure with a NULL destination, then
allocate that much" idiom. The SSV-26770 work replaced the underlying
_vsnprintf(NULL, 0, ...) - which returns the true, unbounded required
length - with zfs_vsnprintf(NULL, 0, ...), which routes to
zfs_vscprintf(). Kernel mode has no linkable way to measure a format
without writing it (_vscprintf is declared by the WDK but not exported
by the kernel-mode CRT import lib; _vsnprintf_s rejects count == 0), so
zfs_vscprintf() measures by formatting into a bounded 1024-byte scratch
buffer and therefore reports a capped 1023 for anything longer.

Sizing an allocation from that capped length under-allocates, which made
a previously unreachable error path in kmem_vasprintf() reachable for
the first time: for any format whose output is >= 1024 characters,
kmem_alloc(size + 1) is too small, the real write returns -1, and the
error path ran

	kmem_free(ptr, size);	/* allocated size + 1: mismatched size */
	r = -1;			/* r is discarded; ptr is NOT cleared */
	...
	return (ptr);		/* returns the freed pointer */

so the caller received a dangling pointer into freed memory. Before the
remediation this path was dead code: the measurement was exact, so the
write always returned exactly size and the condition was never true.

The principal caller is log_internal() in module/zfs/spa_history.c,
which does

	msg = kmem_vasprintf(fmt, adx);
	fnvlist_add_string(nvl, ZPOOL_HIST_INT_STR, msg);  /* UAF read */
	kmem_strfree(msg);                                /* double free */

i.e. it reads freed memory into the pool-history nvlist and then frees
the block a second time, with a size derived from whatever string
happens to occupy it. That is reached from spa_history_log_internal(),
which logs nearly every pool operation, so the resulting freelist
corruption surfaced later as "bad free" panics in vmem_hash_delete()
and as nvlists and ABDs containing another owner's data.

Fix

kmem_vasprintf() now grows a scratch buffer and retries the real write
until the whole string fits, with no measurement pass, so the freed size
always matches the allocated size and it never returns a freed pointer.
It still returns a buffer allocated at exactly strlen() + 1: callers
free these with kmem_strfree(), which computes the size as
strlen(str) + 1, so anything else (for example returning the grown
power-of-two buffer directly) would reintroduce the same mismatched-free
bug at all ~25 kmem_asprintf() call sites. kmem_asprintf() now delegates
to kmem_vasprintf(). Both keep their existing "never returns NULL"
contract, accepting a truncated but valid string at a 64 KB ceiling.

__dprintf() had the same dependency on an exact measurement, so it is
rebuilt on the now-safe helpers instead of hand-computing offsets into a
single buffer. That also removes a second off-by-one, missed by an
earlier review, in the prefix write: snprintf(buf, size + 1, ...) told
it one more byte than buf actually had. The allocation length is
recorded before the trailing-newline strip edits the string in place,
since strlen() + 1 afterwards is a byte short of what was allocated.

The comment on zfs_vscprintf() in types.h asserted that a capped
measurement was harmless because a later write would truncate
identically. That reasoning is what produced this bug - it holds only
when the measured length is not used to size the buffer. Replaced with
an explicit warning that the value is capped and must never size an
allocation.

Audited for the same patterns: the only remaining buf==NULL/size==0
callers are under module/os/freebsd, which is not part of this driver.
The vendored zlib's equivalent size==0 branch is unreachable (its
callers always pass sizeof(buf)). No caller modifies a kmem_asprintf()
result before kmem_strfree(). The ~23 sites that consume an snprintf()
return value are unaffected, as both the old _snprintf and the new
_vsnprintf_s(_TRUNCATE) return -1 on truncation, and none of them can
reach the exact-fit boundary where the two differ.

Not yet build-verified or re-scanned; pushed for review and testing.
The previous commit fixed a real use-after-free in kmem_vasprintf(), but also
rewrote __dprintf() to build its message with kmem_vasprintf()/kmem_asprintf().
That second change was not required by the fix and should not have been bundled
with it.

__dprintf() is reachable from inside the kmem and vmem allocators themselves -
spl-kmem.c calls dprintf() in 22 places and spl-vmem.c in 54, including from
kmem_error() - so it must stay to a single bounded allocation. The rewrite
turned one kmem_alloc/kmem_free pair into roughly six to eight allocator
operations per debug message (two helper calls, each with a grow-and-retry loop
and an exact-size copy), in the most reentrancy-sensitive path in the driver,
in exchange for untruncated debug text. That is the wrong trade.

__dprintf() is therefore reverted to its original single-allocation structure,
keeping only the two genuine off-by-one corrections. The substantive delta
against the long-shipping code is now three lines:

  - snprintf(buf, size + 1, ...)             -> snprintf(buf, size, ...)
  - zfs_vsnprintf(buf + i, size - i + 1, ..) -> zfs_vsnprintf(buf + i, size - i, ..)
  - i = snprintf(...)                        -> i = (int)strlen(buf)

buf holds exactly `size` bytes, so both writes now get the true remaining
capacity rather than one byte more. The third change closes a latent hazard
present in the original: on truncation _vsnprintf_s returns -1, and buf + (-1)
is a wild pointer. A truncating write still null-terminates, so strlen() is
always the real prefix length and always leaves size - i >= 1, with no branch
and no failure mode. kmem_alloc(size)/kmem_free(buf, size) symmetry is restored.

An alternative - making __dprintf allocation-free with a fixed stack buffer and
a per-thread recursion guard - was considered and rejected: it is more new code
in the same dangerous path, and it was motivated by a theory that does not hold
up (below). __zfs_dbgmsg() still does kmem_zalloc, a pre-existing reentrancy
that predates this work and is deliberately left alone.

Theory withdrawn: an earlier analysis claimed this reentrancy caused the
KMERR_BADCACHE crashes seen after the previous commit. That is not established.
Reentrancy into the allocator produces deadlock, not a wrong-size free, and the
original __dprintf allocated too without crashing. The rewrite is reverted
because it was unjustified in a dangerous path, not because it was proven
guilty. The cause of those crashes remains unidentified.

kmem_vasprintf()/kmem_asprintf() are deliberately left exactly as committed
previously - that fix is the one part of this work whose mechanism was traced
end to end and is not in question.

The zfs_vscprintf() comment in types.h previously stated a blanket prohibition
on using its capped result to size an allocation. __dprintf legitimately does
exactly that, so an absolute rule the code contradicts is worse than none. It
now states the real rule: sizing an allocation from a capped measurement is
safe if and only if every write into the buffer is bounded by the buffer's real
size and the free passes the size that was allocated. __dprintf satisfies both;
kmem_vasprintf violated both, which is why it corrupted the heap.

Also adds contrib/windows/docs/ZFSin-kmem-corruption-investigation.md, a
consolidated record of the whole investigation: every crash analysed, what was
proven versus suspected, the hypotheses that were disproven (so they are not
retried), the reference facts that were expensive to establish, and a WinDbg
cookbook.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant