a slot must be validated before it is dereferenced

ISHIKAWA: el_val_t carries integers AND tagged heap pointers, so "is this a
pointer" is undecidable without checking first. That check was a CONVENTION
every author had to know rather than a GATE they had to pass through, and
looks_like_heap_obj was static -- so every sibling translation unit re-derived
it.

MEASURED, across the five existing tags
  geom_of        looks_like_heap_obj   full guard      correct
  mfld_of        looks_like_heap_obj   full guard      correct
  el_bin_lookup  (uintptr_t)p < 4096   floor only      reads 8 bytes BACKWARD
  el_input_len   s ? ... : 0           NULL only       strlen's an integer

  sha256_hex(50000)  ->  exit 139, SIGSEGV, compiled clean

PREDICTIONS AND RESULTS
  P1  looks_like_heap_obj is static, not exported     TRUE
  P2  each tagged type re-derives the check           TRUE
  P3  at least one is missing guard components        TRUE (two are)
  P6  sha256_hex(<int>) reads out of bounds           TRUE
  P8  routing el_bin_lookup through the gate fixes it FALSE
  P9  the legitimate hash is unchanged                TRUE
  P11 fixpoint and suites hold                        TRUE

P8 IS THE USEFUL FAILURE. Guarding the tagged lookup changed nothing --
looks_like_heap_obj(49992) correctly returns 0, el_bin_lookup bails, and then
el_input_len falls through to strlen() on address 50000. The FALLBACK was the
hazard, not the tagged path. A NULL check does not establish that a slot is a
pointer. I would have shipped the wrong fix and called it verified.

A MEASUREMENT DEFECT, fourth today: my first run of the crash reported exit=0,
because $? read head's exit through a pipe rather than the program's. I nearly
recorded a segfault as a clean run. Same shape as grepping only parser.el and
searching by variable name instead of by operation.

AND I PROVED THE HAZARD FROM THE INSIDE. Sixty seconds after diagnosing
`let s: String = 42` as an arbitrary-read primitive, I wrote the identical
defect into el_await -- dereferencing ->magic off an unvalidated slot -- and
only then found the runtime had already made it twice.

el_tagged() is now exported in el_runtime.h. Anything that dereferences a slot
without passing through it is the defect.

105/105 native, 42/42 integration across eight harnesses, fixpoint ok.
This commit is contained in:
bigmerge
2026-08-17 10:49:49 -05:00
parent cb7289f065
commit 9a6c161ba9
3 changed files with 67 additions and 3 deletions
+31 -3
View File
@@ -712,6 +712,20 @@ el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
* happen to look like aligned heap pointers are exceedingly unlikely to land
* on a page whose first 4 bytes match either magic. */
/* el_tagged — THE gate for "is this slot a heap object carrying this tag".
*
* el_val_t carries both integers and tagged heap pointers, so deciding which
* requires checking the value BEFORE dereferencing it. That check was a
* convention every author had to know rather than a gate they had to pass
* through, and the result is measurable: geom_of and mfld_of call
* looks_like_heap_obj and are correct; el_bin_lookup checked only a 4096 floor
* -- no alignment, no small-int, no negative -- and reads EIGHT BYTES BACKWARD
* from the pointer. sha256_hex(50000) therefore compiled clean and segfaulted.
*
* Exported, so the engram siblings stop re-deriving it. Anything that
* dereferences a slot without passing through here is the defect. */
int el_tagged(el_val_t v, uint32_t magic);
static int looks_like_heap_obj(el_val_t v) {
if (v == 0) return 0;
int64_t s = (int64_t)v;
@@ -722,6 +736,12 @@ static int looks_like_heap_obj(el_val_t v) {
return 1;
}
int el_tagged(el_val_t v, uint32_t magic) {
if (!looks_like_heap_obj(v)) return 0;
return *(const uint32_t*)(uintptr_t)v == magic;
}
void el_retain(el_val_t v) {
if (!looks_like_heap_obj(v)) return;
ElHeader* h = (ElHeader*)(uintptr_t)v;
@@ -18272,8 +18292,11 @@ static int el_bin_lookup(const void* p, size_t* out_len) {
/* Avoid reading off the front of a page on tiny pointers (e.g. NULs
* passed in as int-cast values). 4096 is a safe lower bound on any
* platform we target. */
if ((uintptr_t)p < 4096) return 0;
const el_bin_hdr_t* hdr = (const el_bin_hdr_t*)((const char*)p - sizeof(el_bin_hdr_t));
/* Reads BACKWARD, so the HEADER address is what must be validated -- and a
* 4096 floor alone let sha256_hex(50000) through to a SIGSEGV. */
const char* hp = (const char*)p - sizeof(el_bin_hdr_t);
if (!looks_like_heap_obj((el_val_t)(uintptr_t)hp)) return 0;
const el_bin_hdr_t* hdr = (const el_bin_hdr_t*)hp;
if (hdr->magic != EL_MAGIC_BIN) return 0;
*out_len = hdr->length;
return 1;
@@ -18283,7 +18306,12 @@ static int el_bin_lookup(const void* p, size_t* out_len) {
static size_t el_input_len(const char* s) {
size_t n;
if (el_bin_lookup(s, &n)) return n;
return s ? strlen(s) : 0;
/* The FALLBACK is the hazard, not the tagged lookup. A NULL check does not
* establish that a slot is a pointer: el_val_t carries integers too, so
* strlen() on `sha256_hex(50000)` walks address 50000. Guarding the tagged
* path alone left this untouched and the SIGSEGV unchanged -- measured. */
if (!looks_like_heap_obj((el_val_t)(uintptr_t)s)) return 0;
return strlen(s);
}
/* ─── SHA-256 (Brad Conte / public domain) ──────────────────────────────── */
+1
View File
@@ -888,6 +888,7 @@ el_val_t engram_age_field_catchup(void);
el_val_t engram_chrono_persist_tick(void);
el_val_t engram_chrono_tick(void);
el_val_t engram_boundary_beat(el_val_t op_name, el_val_t construct);
int el_tagged(el_val_t v, uint32_t magic); /* the gate: validate a slot BEFORE dereferencing it */
el_val_t el_seam_run(el_val_t fn_name, el_val_t phase, el_val_t result); /* runtime construct seam */
el_val_t el_seam_wrap(el_val_t fn_name, el_val_t (*body)(void*), void* env); /* runtime invocation control */ /* API-reshape decorator-seam auto-emit; construct = the decorator that caused the beat */
el_val_t engram_self_anchor_capture(void);