Treat a size-zero allocation as a real, inaccessible block - #853
Merged
Conversation
shaobo-he
force-pushed
the
malloc-zero
branch
5 times, most recently
from
August 26, 2026 20:23
6ecba5a to
cb08455
Compare
We adopt the SV-COMP rule -- "malloc and alloca always return a valid pointer, i.e., the memory allocation never fails" -- which conforms to the C standard: C11 7.22.3 leaves malloc(0) implementation-defined, a null pointer or "as if the size were some nonzero value, except that the returned pointer shall not be used to access an object", and the rule selects the second behavior. SMACK's three memory models each did something else. The default, no-reuse-impls, only assigned the result inside `if (n > 0)`, so for a size-zero request the out-parameter was never assigned at all and the program continued with an arbitrary pointer: it could alias a live object, so a store through it clobbered that object and a free of it was reported as invalid. The other two returned a null pointer. In the reuse model that also made the second of two malloc(0) calls unsatisfiable (the result is 0 both times, but the contract requires !old($Alloc[p])), and everything after it was verified vacuously. The aws-c-common SV-COMP harnesses reach this through bounded_malloc(cursor->len), bounded_malloc(cap) and bounded_malloc(list->current_size) with unconstrained sizes. The n > 0 guards are what gave a size-zero request that treatment, so they go: every clause of every $$alloc now holds unconditionally. For a positive size nothing changes -- each clause said exactly this under the guard -- and for a size-zero request the procedure reserves address space as if the size were one while recording size zero, so any access is an invalid dereference under --check=memory-safety and free() is valid. Two facts have to be stated that the guarded text could not: $CurrAddr advances strictly, without which a second size-zero request could return the same address; and $base(p) == p, which the range quantifier over [p, p + n) cannot give when that range is empty and which $free requires. The reuse model's disjointness clause is unchanged -- its strict < already places a size-zero block outside every live block. $malloc counts the block, so --check=memleak reports it when it leaks and stays balanced when it is freed. alloca, calloc and realloc go through the same procedure, and the comment on realloc in stdlib.c works through what the rule means for realloc(NULL, n) and realloc(p, 0). The two malloc_nondet tests assumed a nonzero size with a comment saying malloc(0) "can return anything"; the assumption is really about the p[x - 1] access and is now stated that way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reuse and no-reuse memory-safety models specify $free by contract, and every postcondition about $Alloc was guarded by p != 0. For free(NULL), which C11 7.22.3.3 defines as a no-op, nothing constrained $Alloc after the call, so the verifier could drop a live block's entry and report the next free of that block as invalid. free(NULL) and the realloc(NULL, n) idiom both hit this; so did every free of a malloc(0) result, because those models used to return NULL for it. With the frame, a legal program that frees a null pointer and then a live block verifies under all three models. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 27, 2026
Open
An object whose type is sized but empty -- a zero-length array, an empty
struct -- was recorded with its type's alignment as its size, so an access
into it verified:
int g[0];
int main(void) { g[0] = 1; return 0; }
reports no errors under --check=memory-safety in every memory model. The
emitted Boogie says `const {:allocSize 0} g: ref;` and then `$galloc(g, 8)`.
globalDecl computes one number and puts it to two jobs: reserving address
space so that distinct globals get distinct addresses, and recording the
object's size for memory-safety checking. Those part company for an empty
object, which still needs an address of its own but has no byte that may be
accessed, so the fallback that kept the reserved space nonzero was also
inflating the size. Reserve the type's alignment as before and record the
size as it is. An external declaration, whose definition we cannot see, and
an unsized type keep their placeholders, so only a genuinely empty object
changes.
$galloc then needs the fact its range quantifier cannot give when that range
is empty, exactly as $$alloc did: $base(base_addr) == base_addr.
Over 407 test programs x 2 memory models, the emitted Boogie changes in
exactly two ways: the new $galloc fact, one line in each memory-safety
program, and $galloc(g, 8) becoming $galloc(g, 0) for the zero-length global
in the new regression. Nothing else moves.
Fixes #854.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
malloc(0)returned an arbitrary pointer under SMACK's default memory model. This fixes it in all three models, in both the plain and the--check=memory-safetybuilds, and closes a second hole the memory-safetyfreehad for null arguments.The rule
This adopts the SV-COMP rule — "malloc and alloca always return a valid pointer, i.e., the memory allocation never fails" (rules, "Definitions") — which conforms to the C standard: C11 §7.22.3 ¶1 makes a size-zero allocation implementation-defined, "either a null pointer is returned, or the behavior is as if the size were some nonzero value, except that the returned pointer shall not be used to access an object", and the rule selects the second behavior. That is also what glibc does.
What SMACK did
malloc(0)beforeno-reuse-impls(default)pinsideif (n > 0)freeof it is an invalid freereuseNULL, and$Alloc[0] := truemalloc(0)is unsatisfiable (p == 0again but!old($Alloc[p])), so everything after it verifies vacuously —assert(p != 0)andassert(p == 0)both "verify"no-reuseNULLfreeof it hit the hole belowThe aws-c-common SV-COMP harnesses reach this through
bounded_malloc(cursor->len),bounded_malloc(cap)andbounded_malloc(list->current_size)with unconstrained sizes.The fix
The
n > 0guards are what gave a size-zero request that treatment, so they go: every clause of every$$allocnow holds unconditionally. For a positive size nothing changes — each clause said exactly this under the guard. For a size-zero request the procedure reserves address space as if the size were one while recording size zero, so any access is an invalid dereference under--check=memory-safetyandfreeis valid.Two facts have to be stated that the guarded text could not:
$CurrAddradvances strictly.$CurrAddr - n >= ppermits$CurrAddr == pwhenn == 0, so a second request could return the same address.$base(p) == p. The range quantifier over[p, p + n)is empty forn == 0, and$freerequires it.The reuse model's disjointness clause is unchanged from develop — its strict
<already places a size-zero block outside every live block, and!old($Alloc[p])gives distinctness independently.$malloccounts the block, so--check=memleakreports it when it leaks and stays balanced when it is freed.alloca,callocandreallocgo through the same procedure.An earlier revision of this branch expressed the same rule as
max(n, 1)inside the contract terms. It is equivalent, but theif-expression is expensive for the bit-vector solvers, so the guard-free form above replaced it — see Cost below.free(NULL). Thereuseandno-reusememory-safety$freeis spec-only, and every postcondition about$Allocwas guarded byp != 0, so afterfree(NULL)— a no-op per C11 §7.22.3.3 — nothing constrained the allocation map and the nextfree(q)of a live block failed its precondition.free(NULL)and therealloc(NULL, n)idiom triggered it directly; everyfreeof amalloc(0)result did too. A frame for thep == 0case closes it.Globals with no bytes (#854). The same conflation existed for globals, and the third commit fixes it.
globalDeclcomputed one number and used it both to reserve address space, so that distinct globals get distinct addresses, and to record the object's size — which for an empty object are different numbers. A zero-length array got its type's alignment as its size, soint g[0]; g[0] = 1;verified under--check=memory-safetyin every model. The reservation keeps the alignment, the recorded size is now the real one, and$gallocgains the same$base(base_addr) == base_addrthat$$allocneeded. An external declaration and an unsized type keep their placeholders, so only a genuinely empty object changes.realloc(p, 0).reallocis modelled asfree+malloc, so the rule decides both boundary cases and neither is special-cased.realloc(NULL, n)ismalloc(n).realloc(p, 0)freespand returns a size-zero block that must itself be freed, so using the call as a spelling offreeis a leak — glibc instead frees and returns null, and C23 makes the call undefined. The comment onreallocinstdlib.cworks this through, and two tests pin both verdicts.Tests
Every row is checked under all three memory models with Boogie and Corral. Develop's verdicts are per model,
no-reuse-impls / reuse / no-reuse.basic/malloc_zero.c— non-null, pairwise distinctbasic/malloc_zero_fail.c— assertsp == 0memory-safety/malloc_zero.c— free is validmalloc_zero_deref_fail,_read_fail,_wide_deref_fail— byte write, read,intwritemalloc_zero_memset_fail,_memcpy_in_fail,_memcpy_out_fail— one byte via a library callmalloc_zero_no_access.c— zero-lengthmemset/memcpy,p + 0, comparisoncalloc_zero_deref_fail,alloca_zero_deref_fail(VLA of length 0),realloc_zero_deref_failrealloc_grow_zero.c— a zero block grown to 4 bytes, then usedrealloc_zero_leak_fail.c—realloc(p, 0)used asfree(--check=memleak)realloc_zero_freed.c— the results ofrealloc(p, 0)andrealloc(NULL, 0)freedmalloc_zero_leak_fail.c(--check=memleak)malloc_zero_freed.c(--check=memleak;malloc(0)andcalloc(0, 4)freed)free_null_then_free.c—free(NULL),realloc(NULL, n), then frees of live blocksglobal_zero_deref_fail.c— store into a zero-length global (#854)global_zero_no_access.c— its neighbours stay accessiblebasic/global_zero.c— a zero-length global's address is distinctWhere develop already says "error" on a dereference row, it is the null-pointer models and the arbitrary-pointer model failing for different reasons; this branch fails them because the block has no bytes. Develop's own
free_null.cis unchanged. The twomalloc_nondettests assumed a nonzero size with a comment thatmalloc(0)"can return anything"; the assumption is about thep[x - 1]access and is now stated that way.Cost
The
max(n, 1)revision was measurably slower for the bit-vector solvers on programs that never callmalloc(0); the guard-free form recovers most of it. Sequential, interleaved, on an idle machine:max(n,1)revisionbasic/list.c --pointer-encoding=bit-vector --mem-mod no-reuse --unroll=4(3 reps)memory-safety/array_free1.c --integer-encoding=bit-vector --mem-mod reuse --unroll=6 --check=memory-safety(2 reps)All verify in every cell. The residual 9–18 % over develop is the cost of modelling a size-zero allocation at all, where develop did nothing. Under the default encoding the earlier measurements showed parity (
data/two_arrays.c28.9 / 30.5 s vs 31.1 / 31.2 s;array_free1.c --unroll=614.2 / 11.9 s vs 11.1 / 13.0 s).Verification
Measured on this formulation:
test/c/memory-safety, exhaustive (all three memory models × Boogie + Corral): 276 passed, 0 failed, 0 timeouts, including the twelve new tests.$gallocfact or$galloc(g, 8)becoming$galloc(g, 0)for the zero-length global in the new regression. Nothing else moves.Measured on the
max(n, 1)revision of the same change, which produces identical verdicts on all 54 configurations of that table:--pthread, every stdlib allocator, two SV-COMP sweeps, adversarial soundness) found nothing attributable to the change.Found along the way, not fixed here
All pre-existing on develop and unaffected by this change:
reallocdoes not preserve the old block's contents (C11 §7.22.3.5 ¶2), so legal programs are reported buggy. Noted in the newstdlib.ccomment.valid-memsafety.prptovalid-derefonly, sovalid-freeandvalid-memtrackbugs are never reported; eight expected-false tasks verify because of it.freeof stack memory passes the heap check — allocas draw from the same$CurrAddrstream, soint x; free(&x)verifies undervalid-free.callocmay return NULL whilemallocandallocanever fail, which is inconsistent with the rule adopted here.strlen/strcpyare unchecked externals without--strings;--pthreadwithreuse/no-reuseis rejected by Corral ("Ensures has a shared global").aws_array_list_*harness is vacuous:assume_abort_if_not(aws_array_list_is_bounded(&list, 9223372036854775808U, 2))is unsatisfiable because the literal2^63renders as the signed representative.🤖 Generated with Claude Code