Skip to content
Instructors: {{ course.instructors }}

Full Memory Safety

Checking that each memory access stays within a live object: how bounds and lifetime metadata enforce the property, and what those checks cost.

Builds on  Partial Memory Safety Prereq  Pointers and bounds, the heap, what an inline reference monitor is
Part A · The property itself

Enforcing, not deterring

01 · The property

What memory safety asks for

Lecture 4 described defenses against the exploitation of memory errors {{ cite.eternalwar }}. A canary may detect an overwrite when the function returns; ASLR makes useful addresses harder to predict. Neither establishes that every memory access is valid. This lecture examines checks that stop an access if it falls outside the pointer's permitted bounds or if the allocation has ended.

Stated as obligations on a program, memory safety is three things, and it is worth reading them as three rather than one:

01 Pointers are created and derived by permitted operations. Examples include allocation, taking an object's address with &, and deriving a pointer from an existing one. The monitor must preserve the associated access rights; interpreting an arbitrary integer as an address does not grant permission to access that memory.
02 Every access through a pointer stays inside the object that pointer was created for. Spatially, within the allocated range. Temporally, while that allocation is still alive. Those are the two halves this lecture takes in turn, and they are exactly the two bug classes of lectures 1 and 2.
03 Distinct live allocations occupy disjoint storage. Here, an allocation means a separate region reserved for an object. Subobjects, such as a structure's members, occupy storage within their enclosing object. A pointer to a member may therefore need narrower bounds than a pointer to the whole allocation.

How it gets enforced

The same machinery as Part B of lecture 4: a compiler pass, or a binary rewriter when there is no source, inserts metadata describing what each pointer is allowed to touch, and inline monitors that consult it. Every scheme below is an inline reference monitor. What changes is the policy, and the policy here is far stricter than CFI: not “is this jump in the graph” but “is this byte inside this object, right now”.

Which brings back the assumption that made IRMs uncomfortable in the first place, and it is worth saying out loud before any of the designs, because all of them rest on it:

The metadata is reachable only by the monitor. It lives in the same address space as the program being checked, which is also the address space the attacker is writing into. Every soundness claim on this page is really a claim of the form “sound, provided the metadata is intact”, and the thing it needs protecting from is the very error it exists to detect. Section 08 is where that stops being a footnote and becomes the reason the two halves have to ship together.

02 · Where the check goes

Static or dynamic

A property can be established before the program runs or while it runs, and the choice is a spectrum rather than a switch.

Statically checked

The compiler establishes the property for every execution covered by its model. No runtime check is needed for that property. A system relying entirely on static checking must reject programs it cannot prove safe, including some correct programs.

Dynamically checked

Checks run before the relevant operations. An invalid access is stopped when execution reaches it. Accepting a program does not establish that all its executions will succeed. Checks and metadata maintenance add runtime cost; optimization can remove checks proved redundant.

An ordinary C pointer representation does not carry explicit bounds or lifetime metadata. Compilers can still prove some accesses safe from the surrounding code, but arbitrary aliases, casts and separate compilation make complete static enforcement difficult. The systems below add and propagate the missing metadata.

Static and dynamic techniques can be combined in either language. Rust, the next lecture's subject, makes ownership and borrowing part of its type system. That enables many lifetime guarantees at compile time, while some bounds and shared-access checks still run dynamically.

Part B · Spatial safety

Every pointer knows its range

03 · The metadata question

Where the bounds live

Spatial safety needs one fact at every access: the range this pointer is allowed to touch. Associate a [start, end) with each pointer, or with each object, and check before every dereference. The idea is a sentence. The engineering is entirely in the next question, which is where you put those two numbers.

It matters more than it sounds, because a C program does not politely hold a pointer still. It increments it, casts it, stores it in a struct, passes it to a library you did not compile and gets it back. Wherever the bounds live, they have to survive all of that. There are three places to put them:

The pointer holds
{{ metaPointer }}
Stored elsewhere
{{ metaSide }}

{{ metaWhere }}

Compatibility{{ metaCompat }}
Check cost{{ metaCost }}
Done by{{ metaWho }}

The representation affects compatibility, memory use and lookup cost. A base-and-bound check compares the entire access with a permitted range, but obtaining and propagating that range can be more expensive than the comparisons themselves.

CCured uses widened pointers where its analysis requires them {{ cite.ccured }}. Jones and Kelly record object ranges in a splay tree {{ cite.jonespkelly }}. Baggy bounds checking rounds and aligns allocations to simplify object-bound lookup {{ cite.baggy }}. SoftBound instead associates bounds with individual pointers and keeps that metadata separately; it does not require Baggy's allocation strategy.

04 · Spatial safety, in full

Fat pointers

The name comes from the first option above: conceptually, every pointer gets fatter, because it now carries a base and a bound as well as an address. SoftBound {{ cite.softbound }} is the design worth studying, and it is the one that keeps the semantics of a fat pointer while declining to make the pointer itself any wider. Its bounds live in a disjoint table, so the program's memory layout is byte for byte what it was, and a pointer is still one word.

These five rules describe how bounds are created, propagated and checked. The last distinguishes allocation bounds from optional subobject bounds:

Rule {{ ruleNum }} of 5

{{ ruleTitle }}

{{ ruleCode }}

{{ ruleBody }}

{{ ruleWhy }}

SoftBound places the bounds check at the memory access. This permits a valid one-past pointer to exist without being dereferenced. It does not make arbitrary out-of-range arithmetic legal C or C++: the language permits arithmetic within an array and one position past its end, subject to its pointer rules {{ cite.pointerarith }}.

05 · The consequence worth seeing

Casts, and the bounds that refuse to move

Lecture 1B introduced bad-casting. The C++ example below illustrates one possible consequence: accessing a field beyond the allocated object. Assume four-byte integers and the illustrated layout, with Base occupying four bytes and Derived eight. In the comments, B is the allocation's byte address; B+4 means four bytes later.

struct Base { int x; };
struct Derived : Base { int y; };

void foo(Base *b) {
    Derived *d = static_cast<Derived*>(b); // invalid for a plain Base
    // A bounds-propagating monitor retains [B, B+4).
    d->y = 0; // attempted write [B+4, B+8): outside those bounds
}

int main() {
    Base *b = new Base(); // allocation range [B, B+4)
    foo(b);
    delete b;
}

This program has undefined behavior under C++ rules, so an ordinary build has no promised outcome. The attempted write illustrates what a bounds monitor can detect if it instruments that access: it exceeds the original allocation. This is an illustration of the bounds principle, not a guarantee about a particular C++ compiler's handling of invalid downcasts. A wrong-type access that stays within the permitted range can pass a bounds check; spatial safety is not general type safety.

Allocation bounds or subobject bounds?

The slides use a valid upcast to illustrate a separate policy choice. For the same simplified layout, a pointer to the base subobject could carry either that subobject's range or the whole allocation's range:

Derived *d = new Derived(); // allocation range [B, B+8)
Base *b = d;               // valid upcast; no new allocation
// Policy question: [B, B+4) or [B, B+8)?
delete d;
[B, B+4) · Subobject

Restricts accesses to the base subobject. A system choosing this policy also needs a sound way to handle a legitimate downcast back to the enclosing object.

[B, B+8) · Allocation

Retains the enclosing allocation's bounds through the round trip. The bounds check alone does not enforce the base subobject's boundary.

Both ranges stay within the allocation, but they enforce different boundaries. In the original SoftBound design for C, pointer casts propagate the existing bounds. Optional narrowing occurs when deriving pointers to structure fields, not simply because a cast names a smaller type {{ cite.softbound }}.

06 · The balance

What fat pointers settle, and what they break

Settled
  • Spatial safety under the enforced bounds policy, assuming complete instrumentation and intact metadata. Subobject protection requires suitably narrow bounds.
  • Out-of-range accesses resulting from casts are checked too. Wrong-type accesses within bounds need additional protection.
  • In SoftBound's disjoint form, object layouts do not change. A struct has the same size and the same offsets it always had.
Broken, or paid for
  • Bounds checks on accesses not proved safe, plus metadata propagation and storage. Section 09 measures the resulting overhead.
  • Widening pointers changes representations and can break interfaces to code expecting ordinary pointers.
  • Uninstrumented libraries need compatible interfaces or wrappers. Preserving layout alone does not establish whole-program safety.

Two compatibility questions must be separated. Literal fat pointers change pointer sizes and layouts of structures containing them. SoftBound's disjoint metadata preserves those layouts, but instrumented calls still need to pass metadata. A library boundary therefore needs attention even when the pointer remains one word.

Spatial checks alone do not establish that an allocation is still alive. A stale pointer can still name bytes within its recorded bounds. Detecting that access requires lifetime information as well.

Part C · Temporal safety

And the object is still there

07 · The obvious idea

Nullifying freed pointers

Temporal safety requires tracking the lifetime of an allocation. A common defensive practice addresses one pointer at a time: after freeing through a pointer, set that variable to NULL.

free(NULL) is defined to do nothing, so clearing the variable prevents a second free through that variable. Dereferencing NULL is still undefined behavior in C; it often faults on systems that leave the zero page unmapped, but that is not a language guarantee.

In both examples below, assume SIZE is positive and allocation succeeds.

Partial fix: prevents a second free through p

char *p = malloc(SIZE);
if (abrt) {
    free(p);
    p = NULL;                // clear this pointer variable
}
// If abrt is true: free(NULL), which does nothing.
// Otherwise: the allocation is freed for the first time.
free(p);

Failure through an alias

Now add an alias, r = p, before the conditional, and change the final operation from free(p) to free(r). Clearing p does not change r:

char *p = malloc(SIZE);
char *r = p;                 // another pointer to the same allocation
if (abrt) {
    free(p);
    p = NULL;                // p is cleared; r is left dangling
}
// If abrt is true: double free through the stale alias.
// Otherwise: the allocation is freed for the first time.
free(r);

Nullification is per-pointer. Temporal safety is per-object. The assignment p = NULL changes only p; it does not find or clear other pointers to the allocation. Such aliases can also be stored in structure fields and containers, or passed as function arguments. Clearing one pointer therefore does not establish complete temporal safety.

This is exactly why nullification, done properly, is a research problem rather than a style rule. Doing it properly means nulling every pointer to a dying object, which means keeping a reverse map from each object to all the pointers that currently reference it, maintained at runtime, in a program that has millions of both {{ cite.dangnull }}. Lecture 2 raised that as an aside. Here it is the reason the next design does something completely different.

08 · Temporal safety, in full

Lock and key

CETS {{ cite.cets }} associates an allocation with a fresh key and a lock cell holding that key. Each pointer's metadata contains the key and the lock address. Before an access, compare the pointer's key with the lock. An instrumented free validates the pointer and invalidates the lock. Lock cells are managed separately so the check can still read them after the object's storage has been freed.

The model assumes keys are not reissued. A finite implementation must avoid reusing a key that a stale pointer could still hold. Step through the mechanism below; the final step shows an alternative continuation after the free.

Step {{ keyNum }} of 6 {{ keyAction }}
Pointer {{ p.name }}
{{ p.detail }}
Lock cell at L
{{ keyLockValue }}
{{ keyCheck }}
{{ keyVerdict }}

In step 2, the instrumented pointer copy propagates both the key and the lock address. Every alias therefore checks the same lock cell. Freeing invalidates that cell without finding or modifying the aliases themselves. The system still needs to propagate metadata correctly on pointer copies; it avoids maintaining a reverse list of all aliases.

Keys are never reissued, which handles the case lecture 2's double-free lab lives in. When the allocator hands the same chunk to a new object, that object gets its own fresh key, so a stale pointer holding the old key matches nothing. Reuse of the memory does not restore the right to use it.

The guarantee depends on the model's assumptions. CETS requires spatial safety and correct metadata propagation so that program writes cannot forge keys or lock addresses. Its original prototype is single-threaded. With concurrent deallocation, another thread can free an object between a successful check and the access, so additional synchronization is required {{ cite.cets }}. The next lecture returns to this distinction.

09 · The bill

What the checks cost

The 2010 CETS evaluation measured the following average runtime overheads on 17 SPEC benchmarks {{ cite.cets }}. An overhead of +116% means 2.16 times the baseline execution time. These are experimental results for that implementation and workload, not a fixed price for memory safety.

{{ c.name }} {{ c.pct }}

{{ c.what }}

The original 2009 SoftBound paper separately reported +67% for full spatial checking across its 23 benchmarks {{ cite.softbound }}. That figure uses a different experiment from the +83% spatial-only result above. Metadata propagation, extra memory traffic, allocation bookkeeping and remaining access checks all contribute to the cost. Static optimizations can eliminate redundant checks.

The 2024 study in lecture slide 31 uses LLVM 12 and SPEC CPU 2017, with different results {{ cite.revisited }}. Its table reports the following percentage overheads; the geometric mean combines runtime ratios, while the arithmetic mean averages their percentage overheads:

2024 evaluation · SPEC CPU 2017 · overhead relative to baseline
ConfigurationGeometric meanArithmetic mean
SoftBound+CETS+139.6%+195.9%
With subobject checks+161.1%+232.4%

The published artifact is available for reproducing the study; its README states that it is not maintained {{ cite.artifact }}. The two studies illustrate both runtime cost and the engineering work required to support a compiler and its libraries.

The trade-off involves coverage, runtime cost, compatibility and maintenance. Deployment varies by platform and application. A language that checks ownership and borrowing can establish many lifetime properties statically, while retaining runtime checks where necessary. That is where the course goes next.

End of section

Full memory safety, in brief

  • The model tracks permitted pointer operations, bounds and lifetimes. Distinct live allocations are disjoint; subobjects occupy ranges within them.
  • Compiler-inserted inline monitors check accesses using metadata that application code must not be able to corrupt.
  • Bounds can live with pointers, with objects, or in separate tables. Representation affects lookup cost, memory use and compatibility.
  • SoftBound checks the accessed range and preserves bounds through casts. Subobject protection needs suitable narrowing; bounds checks do not establish type safety.
  • Nullifying a freed pointer is per-pointer, and temporal safety is per-object, so a single alias defeats it. Lock-and-key anchors the check to the object, which is why one write on free invalidates every alias at once.
  • Temporal guarantees require spatial safety, intact metadata and correct propagation. Concurrent deallocation also requires synchronization.
  • The combined system reported +116% overhead in the 2010 experiment. The 2024 study uses different workloads and configurations; compare results within their stated context.

{{ course.notes }} · Computer Security

10 · Sources

References

[{{ r.n }}] {{ r.cite }} ↗ {{ r.linkLabel }}