Skip to content

S5: The full plane set, and the OSS-Fuzz replay #431

Open

Description

@tamnd

The rest of the plane set, and the OSS-Fuzz replay.

S1 shipped bounds and lifetime. This adds the type plane with its granule compression, the byte granular init plane with the padding rules, the epoch plane, restrict checking and sub object bounds. With those in place the checker covers every class in the taxonomy rather than the two easy ones.

The second half of the milestone is the OSS-Fuzz replay, which spec/safe-memory/12-corpus-and-evidence.md section 12.7 calls the highest expected value activity in the project relative to its cost. Replay every OSS-Fuzz corpus for every tier 1 project against a Tier D build. The inputs already exist and the harnesses already exist, so the cost is compute rather than engineering. The classes we add over ASan, which are post quarantine use after free, intra object overflow, uninitialized reads and type confusion, are exactly the ones those corpora have been exercising for years without a checker present that could see them.

What gets built

Exit criterion

The Juliet per row numbers of spec/safe-memory/14-verification.md section 14.6 published for every row and every tier, with missed cases enumerated by test id rather than summarized.

The OSS-Fuzz replay run over at least six tier 1 projects.

At least one upstream confirmed bug found by a class no existing tool covers. That is the criterion that actually matters here.

Activity

  1. added
    kind/milestoneA milestone from spec/17-milestones.md, tracked as one issue
    area/testingThe corpus, the fuzzers, the target ladder, and CI
    area/safetyThe run-time memory safety monitor, its planes, and its runtime
    on Sep 6, 2026
  2. tamnd commented on Sep 10, 2026

    @tamnd
    OwnerAuthor

    Filed #849 against this milestone, for the two biggest rows in the safety census on SQLite. Both are in discharge and both say a fact was thrown away because a call stood between the check that established it and the check that wanted it, 2194 bounds checks at 626 sites and 2177 liveness checks at 625.

    I priced the row before filing it, by marking every call NOFREE whatever it does, which is unsound and only there to get a number. It removes 1776 calls to __rucc_check_bounds and 1772 to __rucc_check_live from the object and 240016 bytes, which is seven and a half per cent of every check in there, and it leaves a residue of 68 at 35. So a good answer very nearly finishes the row.

    It lands here rather than on S4 because the cheap version does not work. Widening the never frees name table by 49 names moves the row by one check, since 1516 of the 1551 callees that fail to clear are defined inside the amalgamation itself and nearly all of them reach realloc eventually. What the row needs is the freed and escaping parameter summaries from section 7.5 plus the escape analysis to use them at the call site, which is the work this milestone is already carrying.

  3. tamnd commented on Sep 11, 2026

    @tamnd
    OwnerAuthor

    #902 is in, which is the runtime half of the first box. rucc_safe_rt::types holds what a byte was last stored through, which is the fact four of document 03's type classes and one of its spatial classes are decided by.

    It is C 6.5 rather than an approximation of it. A store records the type it stored through, an access asks whether what it is about to read agrees, a byte nothing has stored through agrees with everything, and both halves of the character rule are in one function, so the byte at a time copy idiom works rather than being a special case bolted on later. A memcpy carries the source's types to the destination, which is the rule that makes punning legal where C says it is and is also what catches copying a struct A over a struct B and reading it back as a B.

    The granule is eight bytes and the slot is four, with a side entry of eight identifiers for a granule whose bytes disagree. That is the 4/g + 4h of document 05 section 5.2.3, and eight is the number section 5.2.5 measured rather than the one that looks natural. On a sixty four bit target a pointer, a long and a double are all eight bytes, so a sixteen byte granule holds two distinct types and a structure alternating two of them disagrees in every granule it has:

    granule SQLite declarations heterogeneous bytes of plane per byte of program
    8 12.6% 1.00
    16 65% 2.84

    The budget is 1.25, so the choice is not close.

    Two decisions in it are worth recording because they are trades rather than readings of the spec.

    A granule that has once disagreed keeps its side entry for the life of the program, and a later store covering the whole of it writes one type over all eight bytes rather than folding the granule back up. That makes the allocator one fetch_add with no free list, no lock and no ordering to get right, which matters because this is the first structure on the plane side of the runtime that anything allocates at all, and the planes are deliberately unsynchronized today. The cost is one indirection on later reads of that granule and thirty two bytes that stay out. Reclaiming them wants the free list and the spin lock rucc_safe_rt::alloc already has, and that is not worth writing before a measurement says who folds granules back up often enough to care.

    A granule that has to disagree and cannot get an entry is recorded as untyped, which every access is permitted against, so running out of side table thins the plane's coverage and never makes it wrong. That is the direction document 09 section 9.1 already picks for bytes uninstrumented code wrote, and it is the only one available given that the false positive rate is a release blocking property.

    The class people ask about first is not in this plane and is free: reading a word that was never a pointer and dereferencing it is caught by the aux plane, since an aux slot whose payload was never a pointer holds the bottom capability and the first access through it fails. That is why document 04's plane table asks for a pointer slot only at Tier E.

    Nothing calls into it yet. The store that makes the judgement and the access that asks the question are the compiler's half, which is the new box under this one and the next PR.

  4. tamnd commented on Sep 11, 2026

    @tamnd
    OwnerAuthor

    #905 is in. The plane from #902 is now mapped over every region the monitor watches, and rucc_safe_rt::check has judgement J3 beside the three checks it already had, with the two judgements that record what J3 asks about: __rucc_check_type, __rucc_meta_type and __rucc_meta_type_copy.

    A region's reservation is four spans now, the lifetime plane, the type plane, the type plane's side table and then the region, which is one byte of plane per byte of region against document 05 section 5.2.3's budget of 1.25. The side table is one entry for every eight granules, which is the 12.6 percent heterogeneity section 5.2.5 measured on SQLite rounded the safe way. The reservation went from one and a half times the region to two and a half, and that is address space rather than memory, since the mapping is anonymous and a plane nobody touches costs no pages.

    An adopted region gets all three the same way, so a region this crate did not reserve is watched exactly as closely as one it did. A region that cannot have all of them is not taken on at all rather than taken on half watched.

    The thing worth recording out of this one is a rule I would have gone on to get wrong. A fresh instance has to forget what its bytes used to be, which is the other half of judgement J4 and is C's rule that allocated storage takes its effective type from the first store. Without it a block handed out again still says what the previous occupant stored through, and the new owner's first honest read is reported as type confusion. That is a false positive on ordinary code, which for this plane is the failure that matters, and it is not a corner case: the third test written here hit it immediately, because the allocator hands blocks straight back out.

    It is cleared over the whole block rather than the request. The bytes between the request and the end of the block are the allocator's rounding, they share granules with the request, and leaving them would turn an overflow inside a block into a type report instead of the bounds report it is.

    The 137 program safety suite is the check that matters here, since this moves where every region's lifetime plane lands, and it is unchanged at 46 refused and 60 silent with 0 accounting divergences.

    Next is the compiler's half, which is emitting meta_type on a store and check_type on an access. The IR already has both opcodes and the PlaneNode vocabulary from S0, and the runtime's vocabulary was written to line up with it: NoType, Character, PointerSlot(k) and Type(node) are the runtime's untyped, character, pointer byte and interned entries.

  5. tamnd commented on Sep 11, 2026

    @tamnd
    OwnerAuthor

    #914 is the prerequisite for the compiler's half of the type plane, and it turned out to be a piece of work of its own rather than a line in that change.

    PlaneNode::Type names a type with an aliasing node, which is the same node the optimizer's layer 3 walks. That is the right design and it is what section 9.1 asks for, since it means the plane's vocabulary is exactly the compiler's and a report can name a type in the spelling the source used. The problem was that the front end named no types at all: MemInfo.tbaa was None at every construction site in lowering, so there was no node for a meta_type to carry and nothing to intern one against.

    So #914 builds the tree. char is the root, every other scalar hangs directly under it, a signed type and its unsigned counterpart share a node, an enumeration shares with the integer type it is represented in, every object pointer is one node, and an aggregate gets none. That maps onto the runtime vocabulary I wrote in #902 exactly: interned(n) is a node, CHARACTER is the root, UNTYPED is the absence of one, and pointer_byte(k) is the byte of a pointer, which is the one part of the vocabulary the tree does not have an opinion about because it is about storage rather than about C types.

    The node is found by a canonical spelling of the type and never by the order the walk met it, which section 8.2 of the optimizer's alias document asks for so that LTO does not silently gain disambiguations when two modules are merged. That matters here for a second reason: a check_type compiled in one file and a meta_type compiled in another have to agree about what struct sqlite3 * is, and agreeing by name is the only way they can.

    What is left for the compiler's half is the emission itself, which is a store emitting meta_type with the node for the type it stored through, an access emitting check_type with the node for the type it is about to read, and a memcpy emitting meta_type_copy. All three opcodes exist, the verifier already insists they name a plane node, and rucc_safety::insert is where they go beside the bounds and lifetime checks it already emits. The interesting decisions left are which accesses are worth asking about at all, since asking about every one of them is the cost document 13 is about, and what a store through a character lvalue does, which the runtime already has both halves of.

    The 137 program safety suite is unchanged at 46 refused and 60 silent, and accounting is at 0 divergences, which is what says this moved nothing that was already working.

  6. tamnd commented on Sep 11, 2026

    @tamnd
    OwnerAuthor

    #923 is merged, which is the first half of the compiler's side of the type plane. A -fsafety build now records the type of every store it instruments, so the plane the runtime has been carrying since #902 finally gets written by real code rather than by tests.

    The entry a store records is a plane node rather than the aliasing node the front end attached in #914. Three of the things a byte can say are not types at all: it can be untyped, it can be a character, and it can be one byte of a pointer. A store through the root of the aliasing tree, which is the node named char, records the character entry, because 6.5 says a character store sets no effective type. A store that names no type records the untyped entry rather than recording nothing, so a plane never goes on describing memory that is no longer there. Untyped is compatible with everything, so the cost of that is a missed check and never a wrong answer.

    The number the runtime sees is FNV-1a 32 of the canonical spelling of the type, biased past the values that are not types. A per object counter would have been smaller, but the plane outlives the object that wrote it, and a counter makes int 3 in one translation unit and 7 in the next, which would refuse correct programs. The cost of the hash is a possible collision, which is again a missed check, and that a report cannot print int yet. The spelling stays recoverable because a hash of a spelling is a spelling the compiler still has.

    The question an access asks is deliberately left out of this PR and is now its own box. A check against a plane that only some of the writes maintain reports on programs that are correct, so the question has to wait until the writes are complete. The gap that blocks it is a copy: the runtime already exports __rucc_meta_type_copy, but there is no IR opcode for a copy to carry the source's effective type across, so a memcpy would silently drop the types it moved and the very next read would be refused. Union punning has to be answered too, since C permits reading a member other than the one last stored through.

    The refusal counts did not move, which is what a judgement that refuses nothing should do. The Linux run still says 46 refused, 60 silent and 0 divergences over 136 programs.

  7. 104 remaining items

  8. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3421 is merged. A pointer made below or past a local or a global is now refused where it is made, as J2, because the derivation check takes the capability the compiler builds for those objects instead of asking only the planes, which cover the heap. On Juliet at -O2, CWE-124 goes from 570 to 710 detected and CWE-127 from 494 to 710, every case not set aside, with no false positives in any CWE. The docs/JULIET.md numbers will be brought up to date in a follow up.

  9. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3426 is merged and gives row T9 a sweep. Under -fsafety-leaks, at detect or kernel, every live block nothing the program can still reach is reported at exit under its own banner. Juliet CWE-401 at -O2 goes from 0 to 576 of 698 detected with no false positives, and enforce is not run for that row because document 03 gives it nothing to enforce.

  10. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3432 is merged and closes #3272. A pointer to a local whose function has returned is now refused when it is read inside a wrapper, as with printLine(helperBad()) where the helper returns a buffer of its own, and when the helper was inlined at -O2 and its buffer became a slot of the caller. The wrappers now ask the frame witness, the caller passes on the capability of a returned local even when it checks nothing through it, and the inliner ends the lifetime of each marked local where the inlined copy returns.

    A full Juliet run at -O2 on the branch had 0 false positives at every tier, and every case built at every tier. CWE-562 goes from 0 to 2 of 2 at detect, enforce and kernel. Ten more CWE-843 good halves are now refused for reading a block scope local after its block, which is a real use after the end of its lifetime in both halves, so those cases are set aside. Two CWE-126 CWE170 variant 12 cases moved to missed at detect, because whether they are caught depends on a byte of the frame nothing writes. docs/JULIET.md gets the new numbers next.

  11. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3441 is merged and puts the run from #3432 into docs/JULIET.md: 7,311 detected, 863 missed, 480 set aside and 0 false positives out of 8,654 Juliet cases at -O2, at every tier. It was 6,050 detected and 2,203 missed in the run with #3329. What is left is 390 pointers that go through memory (#3269), 53 unterminated copies whose last byte is a zero nothing wrote and 240 reads of what nothing wrote (#3271), 76 wide string copies (#3268), 72 overruns that stay inside their struct (#3327), and 32 leaks whose block a global still points at, which stay counted as missed.

  12. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3446 is merged. A read of bytes that __builtin_alloca or a variable length array took and nothing wrote is now refused as Y6. The bytes are marked unwritten where they are taken, and __rucc_local_left forgets them behind each stackrestore and before each return. The -O2 case first went through quietly because the optimizer steps the fill loop's pointer through a block parameter, and the pass took any block call as letting the local go. It now follows addresses through block parameters. Juliet CWE-457 at -O2 goes from 300 to 408 of 540 detected at every tier, and CWE-126 from 423 to 425. The full -O2 run has 0 false positives and 0 build failures in every CWE and tier.

  13. tamnd commented on Oct 9, 2026

    @tamnd
    OwnerAuthor

    #3460 is merged. A local whose address is never taken lives in a register and has no bytes for the init plane, so a read of one that nothing wrote used to pass. The front end now keeps a flag next to each such local in its SSA form, false at the declaration and true after each assignment, and every read asks it. Where all paths agree the flag is a constant and the check goes away, so only reads that depend on the path cost a call. Juliet CWE-457 at -O2 goes from 408 to 498 of 540 at every tier, and the full Juliet run at -O2 has no false positives and nothing left unbuilt. The 42 cases still missed are all variants 63 and 64, which pass the address to a sink in another unit, and #3271 stays open for those.

  14. tamnd commented on Oct 13, 2026

    @tamnd
    OwnerAuthor

    #3610 is merged. A pointer to a local or a global that goes through a word of the stack or of a global, like the Juliet flow variants with a union, a static global or a pointer to a pointer, used to come back out with no capability, so the checks in the sink had nothing to go on. The runtime now keeps those capabilities in a small per thread table beside the stack plane, and at -O2 the hoisting and splitting of bounds checks reuse the loaded capability instead of building a wider one.

    At -O2 Juliet misses 241 under CWE-121 where it missed 406, and 82 under CWE-126 where it missed 94, still with no false positives at any tier. That brings the total down to 487 misses out of 8,654. The safety suite and the accounting check show no change elsewhere.

    What is left on #3269 is a pointer read out of memory and passed straight to memcpy or another wrapper, which the handover does not pass on yet. That is 213 cases, and I will look at it next.

  15. tamnd commented on Oct 13, 2026

    @tamnd
    OwnerAuthor

    #3617 is merged. A pointer to a local or a global that is read back out of a word of the stack or of a global and handed straight to a wrapper such as memcpy now brings its capability along, so the copy is judged against the object's bounds. The handover only does this for a call to a wrapper, so other calls cost nothing more.

    Juliet at -O2 goes from 487 misses to 343 with no false positives at any tier: CWE-121 from 241 to 121 and CWE-126 from 82 to 58. What is left of the #3269 bucket is variant 67, where the struct is passed by value and the pointer is copied out of it a word at a time (47 under CWE-121, 6 under CWE-126), and the 16 CWE-843 cases. The rest of the misses are #3268 (wide strings), #3327 (overrun inside a struct), #3271 (CWE170 and CWE-457 across files) and the leaks kept in a global.

  16. tamnd commented on Oct 14, 2026

    @tamnd
    OwnerAuthor

    #3625 is merged. A structure whose register holds exactly one pointer member now travels as a pointer, the way clang lowers it, so the safety handover hands the callee the bounds of what the member points at. The handover also looks through a store and the load straight behind it, which the monitor's own records keep alive where the optimizer would have forwarded them.

    Juliet at -O2 goes from 343 misses to 291 with no false positives at any tier: CWE-121 from 121 to 74 and CWE-126 from 58 to 53. No flow variant 67 case is missed now. What is left: #3268 wide strings (76), #3327 overrun inside a struct (72), #3271 CWE170 (53) and CWE-457 across files (42), leaks kept in a global (32), and the 16 CWE-843 cases on #3269.

  17. tamnd commented on Oct 14, 2026

    @tamnd
    OwnerAuthor

    #3663 is merged. A pointer to a local declared in a block that was stored into memory and read back after the block closed used to lose its capability at the load, because the runtime threw out the entry once the local was gone and then recovered bounds over everything. A block left while its function still runs keeps its witness serial, so the entry can only be that same local, and it is now handed on and refused by check_live as T4. Juliet at -O2: the 16 CWE-843 cases in the memory variants have both halves refused and are set aside with the rest of the family, CWE-843 is 0 missed, no false positives anywhere, and total misses go from 291 to 274.

  18. tamnd commented on Oct 14, 2026

    @tamnd
    OwnerAuthor

    #3667 is merged. The wide string functions are now interposed: seventeen rows from wcslen to wmemchr, each written like its narrow twin, with interpose! taking a unit so counts and terminators are measured in characters. A wcscpy into an alloca sized by strlen of a wide string is refused under tests/safety. Juliet at -O2 detects all 76 CWE135 cases under CWE-121 and CWE-122 with no false positives, which brings the misses from 274 to 198. What is left: 52 unterminated copies under CWE-126 and 42 cross-file reads under CWE-457 (#3271), 72 overruns that stay inside their struct (#3327), and 32 leaks kept in a global.

  19. tamnd commented on Oct 16, 2026

    @tamnd
    OwnerAuthor

    #3674 is merged. A call to memcpy, memmove, strcpy or any other interposed function that is handed the address of an array member is now judged against that member, which is what _FORTIFY_SOURCE does with __builtin_object_size(p, 1). The call site leaves the member's length in a thread local note for the wrapper, and a small constant copy that overruns its member stays a call rather than being expanded inline. A member that is not an array and a trailing array are left alone.

    Juliet at -O2 now has no misses under CWE-121 and CWE-122, 2,400 and 1,480 detected with no false positives at any tier. That takes the 72 struct overrun cases off, so the total is 8,032 detected and 126 missed. What is left is the 52 CWE170 cases and the 42 cross file CWE-457 reads in #3271, and the 32 leaks kept in a global.

  20. tamnd commented on Oct 16, 2026

    @tamnd
    OwnerAuthor

    #3706 is merged. Under -fsafety a local whose address leaves its function is now filled with 0xfe straight behind its alloca, the byte -ftrivial-auto-var-init=pattern uses, unless the program set -ftrivial-auto-var-init itself. A copy that never wrote a buffer's last byte used to stop on a zero an earlier frame left there, and now the %s walk runs to the end of the buffer and is refused. That detects all 52 CWE170 cases under CWE-126 at every tier, and 6 CWE-457 cases where an uninitialized pointer handed to another file is now refused when it is used. Every Juliet CWE was run at -O2 with no false positives, and the misses are down to 68: the 36 CWE-457 cross-file reads and the 32 leaks kept in a global.

  21. tamnd commented on Oct 16, 2026

    @tamnd
    OwnerAuthor

    #3708 is merged. The instrumented libraries job on main failed on zlib's gzread.c at -O2 with E0652, saying short-circuit broke the loop forest of gz_decomp. The forest was fine. It was kept through a merge and listed one block in a different place than a fresh build, and the -Zverify-each check compared the two with ==. It now compares them the order-free way the debug assertion already did, and every zlib source compiles with -O2 -Zverify-each again. A three block loop test covers it.

  22. tamnd commented on Oct 16, 2026

    @tamnd
    OwnerAuthor

    #3709 is merged. The elimination job's one problem was a block scope local written through in another file at -O2, which went unrefused. That loop is split where __rucc_extent says the object ends, and for a named object the answer came from the capability's bounds alone, so the unchecked half also skipped the liveness check. The runtime now gives a local whose frame returned or whose block closed no room, so the loop runs checked and is refused as J1. cargo xtask accounting is 208 programs with 0 problems now, and the safety suite is unchanged at 116 refused and 90 silent.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/safetyThe run-time memory safety monitor, its planes, and its runtimearea/testingThe corpus, the fuzzers, the target ladder, and CIkind/milestoneA milestone from spec/17-milestones.md, tracked as one issuepriority/p1Blocks a milestone

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions