Skip to content

noun: fixes u3r_bytes read past a direct atom - #1091

Merged
pkova merged 5 commits into
developfrom
ml/u3r-bytes
Sep 21, 2026
Merged

pkova merged 5 commits into
developfrom
ml/u3r-bytes

Conversation

@matthew-levan

Copy link
Copy Markdown
Contributor

Closes #1090.

Found while reviewing #970, but this is not vere64-specific — it reproduces on develop as-is.

The bug

pkg/noun/retrieve.c, u3r_bytes(), direct-atom branch:

if ( _(u3a_is_cat(d)) ) {
  c3_w e_w = d >> (c3_min(a_w, 4) << 3);

When a_w >= 4 the clamp makes the shift count exactly 32, the width of c3_w — undefined behaviour. x86-64 and arm64 both mask the shift count, so it evaluates to d >> 0 and the caller gets the atom's own bytes where it asked for bytes past the end, which should read as zero.

Confirmed with a throwaway probe before fixing:

c3_y out_y[16];
u3_atom a = 0x45464748;              // a direct atom, 4 significant bytes
memset(out_y, 0xff, sizeof(out_y));
u3r_bytes(4, 8, out_y, a);           // entirely past the atom

want 0000000000000000, observed 4847464500000000.

_leer_cut() in pkg/noun/jets/e/leer.c is a live caller of the affected shape (u3r_bytes(pos_w, len_w, ...) with a nonzero offset), so this is reachable, not just theoretical.

The fix

Rewritten so there is no shift at all. A direct atom is its own byte buffer, so both cases share one copy-and-zero-fill path — the bug becomes structurally impossible rather than patched. The hav_w ternary is load-bearing: len_w - a_w underflows when a_w >= len_w.

The collapse

u3r_words and u3r_chubs then become casts, leaving one implementation instead of three.

This narrows the accepted range, and I'd rather flag it than have a reviewer find it: b_w << 3 overflows c3_w at b_w >= 2^29, where u3r_words previously accepted a word count up to 2^32. That is a 4GB destination buffer and no caller comes anywhere near it. If that is objectionable, the principled fix is making u3r_bytes's length parameters c3_z, which is a signature change with a long call-site tail and belongs in its own PR.

u3r_bytes_alloc

Third commit, separable if a reviewer disagrees. It allocated len_w bytes but asked for a_w + len_w, overrunning the destination by a_w. Every caller in the tree passes a_w == 0 (verified by grep), so it is latent rather than live.

Tests

_test_bytes, _test_words and _test_chubs in pkg/noun/retrieve_tests.c, following the existing _test_mug idiom. For both a direct and an indirect atom: offset 0 at exactly the available length; offset 0 past the available length (tail must zero-fill); a nonzero offset straddling the end; an offset entirely past the end (the regression case above); and zero length (must not write anything). The u3r_chubs cases use an atom of three words, so the final chub is half-populated and must zero-extend.

Reads past the end return zeros only if the memory beyond the atom happens to be zero, which it is on a freshly mapped page — so a broken implementation can pass by luck. The tests dirty the relevant allocator size classes first (64 atoms per class filled with 0xff, then freed).

Verified the tests have teeth by mutating the fix:

  • dropping the hav_w ternary → fail (i) dog past (2)
  • restoring the original shift-based direct branch → fail (d) cat past (1), showing have: 48 47 46 45 ... against want: 00 00 00 00 ...

Verification

u3r_bytes is widely used — u3r_bytes_all, the hashing jets (shax, blake, keccak, sha1, ripe, chacha, argon2, ed_*), aes_siv and urwasm all route through it — so I ran the whole C suite, not just retrieve-test. All pass:

zig build retrieve-test jets-test nock-test serial-test bytestream-test equality-test \
          ur-test ent-test palloc-test hashtable-test hamt-test ames-test mesa-test \
          newt-test vere-noun-test unix-test pact-test

Also grepped for callers that might have depended on the old past-the-end behaviour. The nonzero-offset callers are leer.c, urwasm.c and ames.c; all three want the documented zero-fill, which is what the fix restores.

Notes

  • No u3a_word_bytes / u3a_word_bytes_shift — those are vere64 additions and do not exist on develop. Uses 4 and << 2, matching the surrounding code.
  • The little-endian assumption is pre-existing; the XX: assumes little-endian comments are kept where the casts happen.

In the direct-atom branch, the shift count [c3_min(a_w, 4) << 3] is
exactly 32 whenever [a_w >= 4] -- the width of c3_w, so the shift is
undefined behaviour. x86-64 and arm64 both mask the shift count, so it
evaluates to [d >> 0] and a caller asking for bytes past the end of the
atom gets the atom's own bytes instead of the zeros it should read.

Rewrites the function so there is no shift at all. A direct atom is its
own byte buffer, so both cases share a single copy-and-zero-fill path,
which makes the bug structurally impossible rather than patching the
shift.

Adds _test_bytes, _test_words and _test_chubs, covering, for a direct
and an indirect atom: an exact-length read, an overlong read, a read
straddling the end, a read entirely past the end, and a zero-length
read. Past-the-end reads return zeros only if the memory beyond the
atom happens to be zero, so the tests dirty the relevant allocator size
classes with freed 0xff-filled atoms first.
Now that u3r_bytes handles a direct atom as a plain byte buffer, both
word- and chub-granular reads are just u3r_bytes with the offset and
length scaled, leaving one copy-and-zero-fill implementation instead of
three.

Note that this narrows the accepted range: [b_w << 3] overflows c3_w at
[b_w >= 2^29], where u3r_words previously accepted a word count up to
2^32. That is a 4GB destination buffer and no caller comes close.
The allocation is [len_w] bytes but the read asked for [a_w + len_w],
overrunning it by [a_w] whenever the offset is nonzero. Every caller in
the tree passes a zero offset, so this is latent rather than live.
@matthew-levan
matthew-levan requested a review from a team as a code owner September 1, 2026 21:22
@pkova
pkova merged commit 9439c45 into develop Sep 21, 2026
4 checks passed
@pkova
pkova deleted the ml/u3r-bytes branch September 21, 2026 16:12
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.

u3r_bytes returns the wrong bytes reading a direct atom past its word width

2 participants