The parent note gave you the machine: a shadow byte per 8 application bytes, and one injected check per memory access. This page is the firing range . We will feed ASan every kind of access — inside a buffer, one byte past, deep inside a partial granule, freed memory, a wild jump, a global, a stack array, a zero-size allocation — and hand-compute whether the injected check fires. By the end you should be able to look at any C snippet and predict the verdict before running it .
Everything here reuses exactly one rule from the parent note . Let us pin it down so we never re-derive it. First, three names we will use constantly:
Shadow map — a big lookup table ASan keeps, one shadow byte for every 8 application bytes. Think of it as the cheat-sheet: one sticky note per 8 floor tiles.
Offset — a single fixed constant number that ASan adds when computing where a byte's shadow lives (on 64-bit Linux it is 0x7fff8000). Its only job is to push the shadow map to a corner of the address space where it never collides with real data. It is the same value for every access — never varies, never depends on the byte. We treat it as a known constant from here on.
Sh ( A ) — the function "give me the address of the shadow byte that covers application byte A ." It is defined below. The value stored at that address we call s . Keeping the two apart (address vs. value) is the whole trick to reading the check.
Notice the clean split: Sh is a function that returns an address ; s is a number (the shadow value). We never write s ( … ) again — Sh ( … ) locates, s is what we read.
Let me re-earn each symbol so a newcomer is never lost:
A — the numeric memory address you touch (a plain integer identifying a byte).
A ≫ 3 — "shift right by 3", which is integer division by 8 (2 3 = 8 ). It answers "which granule is this byte in?"
A & 7 — "bitwise-AND with 7", which is the remainder mod 8 . It answers "which of the 8 slots inside that granule?" We call it o , the offset. It ranges 0..7 .
s — the shadow value read from Sh ( A ) . s = 0 means all 8 slots are green (usable). s = k (for 1 ≤ k ≤ 7 ) means only slots 0.. k − 1 are green. s < 0 means the whole granule is red (redzone / freed).
The inequality ( o + N − 1 ) ≥ s asks: "does the last byte I touch (slot o + N − 1 ) reach the poisoned zone that starts at slot s ?" If s = 0 we skip it because s = 0 already failed — a fully-green granule can never report.
Every example below is just this formula, plugged in, plus one sanity re-read of the C.
We tabulate the classes of case ASan can meet. The goal: not one blank cell.
Cell
Case class
What is special
Covered by
C1
In-bounds access, full green granule (s = 0 )
must not fire
Ex 1
C2
Overflow into a full redzone (s < 0 )
classic heap overflow
Ex 2
C3
Partial granule (1 ≤ s ≤ 7 ), last valid byte
boundary that must not fire
Ex 3
C4
Partial granule, first poisoned byte
boundary that must fire
Ex 3
C5
Use-after-free (s < 0 , was green)
temporal, not spatial
Ex 4
C6
Multi-byte access (N > 1 ) straddling into poison, same granule
N matters, not just o
Ex 5
C7
Multi-byte access crossing a granule boundary
one access, two shadow checks
Ex 6
C8
Wild far pointer landing in another live object
the false-negative ASan misses
Ex 7
C9
Zero-size allocation malloc(0)
degenerate size, still has redzone
Ex 8
C10
Stack array overflow (compiler redzones)
not heap; use-after-return cousin
Ex 9
C11
Global buffer overflow + real-world word problem
globals get redzones too
Ex 10
C12
Exam twist: underflow (p[-1])
overflow on the low side
Ex 11
Eleven examples, twelve cells (Ex 3 knocks out C3 and C4 — the two sides of one boundary).
char * p = malloc ( 8 ); // exactly one granule
p [ 3 ] = 'x' ; // legal
Forecast: fire, or silent? Guess before reading.
Step 1 — What is s for this granule? malloc(8) gives 8 usable bytes = one full granule, so all 8 slots are green ⇒ s = 0 .
Why this step? The whole formula branches on s = 0 first; a green granule short-circuits to "no bug".
Step 2 — Apply the check. s = 0 , so the condition s != 0 && ... is already false . No report.
Why this step? We never even need o or N when the granule is fully green — the cheapest, most common path.
Verify: p[3] is index 3 of an 8-element buffer, 0 ≤ 3 ≤ 7 : genuinely in bounds. ASan silent — correct behaviour (no false positive).
char * p = malloc ( 8 );
p [ 8 ] = 'x' ; // one past the end
Forecast: which shadow value does byte 8 read, and does it fire?
In the figure, the eight green squares (labelled 0–7) are the legal bytes and share one shadow byte s = 0 ; the four striped magenta squares (8–11) are the redzone ASan padded after the buffer, and they share a shadow byte s < 0 . The orange arrow marks the write to square 8 — visibly the first square in the red region, which is exactly why the check will fire.
Step 1 — Locate byte 8. p[8] sits at address p + 8 . Since p starts a granule, p + 8 starts the next granule — the redzone. Reading Sh ( p + 8 ) gives a shadow value s < 0 (fully poisoned).
Why this step? We must know which granule the byte falls in before reading its shadow value.
Step 2 — Offset and check. o = ( p + 8 ) & 7 = 0 (redzone granule's first slot). N = 1 . Condition: s = 0 ✓ (it's negative) and ( 0 + 1 − 1 ) = 0 ≥ s . Since s < 0 , 0 ≥ s is true .
Why this step? Here o = 0 , yet the test still passes: whenever s is negative, any value of o + N − 1 (which is ≥ 0 ) is ≥ s . A poisoned granule reports for every slot, offset zero included.
Verify: ASan prints heap-buffer-overflow ... WRITE of size 1 at offset 8 of an 8-byte region. Matches: the access is exactly 1 byte beyond the allocation. Fires. ✓
char * p = malloc ( 5 ); // 5 usable bytes in one granule
p [ 4 ] = 'a' ; // last legal byte
p [ 5 ] = 'b' ; // first illegal byte
Forecast: the whole point of "one shadow byte per 8" is captured here. Guess both verdicts.
In the figure, slots 0–4 are green and slots 5–7 red, yet the violet bar underneath shows they are all described by one shadow byte holding the value s = 5 . The green arrow (p[4]) points at the last green slot; the orange arrow (p[5]) points at the first red slot — the boundary lives inside a single granule, which is the exact resolution one shadow byte gives you.
Step 1 — Shadow value. malloc(5) ⇒ slots 0..4 valid ⇒ s = 5 ("first 5 bytes valid").
Why this step? Partial granules are the only case where a positive s lives; the formula's middle branch.
Step 2 — Access p[4]. o = 4 , N = 1 . Check: s = 5 = 0 ✓, and ( 4 + 1 − 1 ) = 4 ≥ 5 ? No (4 < 5 ). No report. Legal. ✓ (cell C3)
Why this step? Slot 4 is the last green slot; the formula must let it through.
Step 3 — Access p[5]. o = 5 , N = 1 . Check: s = 5 = 0 ✓, and ( 5 + 1 − 1 ) = 5 ≥ 5 ? Yes . Report. ✓ (cell C4)
Why this step? Slot 5 is the first red slot inside the same granule — proving one byte of shadow really can distinguish valid from invalid within 8.
Verify: boundary is at index 5 = the allocation size. index < 5 ⇒ ok, index ≥ 5 ⇒ overflow. Both verdicts consistent. ✓
char * p = malloc ( 16 );
free (p);
p [ 0 ] = 1 ; // temporal bug
Forecast: slot 0 was green a moment ago. Does the check still fire?
Step 1 — What free does to the shadow. On free(p), ASan poisons the whole freed region: every granule's shadow value set negative. It also drops the block into a quarantine so malloc won't hand it back and repaint it green.
Why this step? Spatial rules alone would say "slot 0 is fine"; temporal safety needs free to change the map .
Step 2 — Apply the check to p[0]. o = 0 , N = 1 , s < 0 . Condition: s = 0 ✓, ( 0 ) ≥ s ✓ (since s < 0 ). Report.
Why this step? Same inequality as any redzone — freed memory simply is a redzone now.
Verify: ASan reports heap-use-after-free, and because it stored the free-site stack, it also prints where it was freed . The mechanism (repaint on free + quarantine) explains both facts. ✓ (This is why Valgrind Memcheck and ASan agree on UAF, but ASan is faster.)
char * p = malloc ( 6 ); // slots 0..5 green, s = 6
int x = * ( short* )(p + 5 ); // 2-byte read at offset 5
Forecast: offset 5 is still green (5 < 6). Trap: this is a 2-byte read. Does it fire?
Step 1 — Shadow value. malloc(6) ⇒ s = 6 (slots 0..5 valid).
Why this step? Establish the poison boundary at slot 6.
Step 2 — Offset and size. The read starts at p + 5 , so o = 5 , and short is 2 bytes so N = 2 . The last byte touched is slot o + N − 1 = 5 + 2 − 1 = 6 . Both bytes (slots 5 and 6) live in the same granule, so a single shadow check covers the whole access.
Why this step? For N > 1 it is the last byte, not the first, that decides. This is why the formula uses o + N − 1 , not o .
Step 3 — Check. s = 6 = 0 ✓, and 6 ≥ 6 ? Yes . Report.
Why this step? Slot 6 is the first poisoned slot; the 2-byte read reaches it even though its starting byte was legal.
Verify: the read spans slots 5–6; slot 6 is out of a 6-byte buffer. So a heap-buffer-overflow READ of size 2 is correct. A 1-byte read at offset 5 would have been silent (5 ≥ 6 is false) — the size genuinely matters. ✓
char * p = malloc ( 16 ); // slots 0..15 green across TWO granules
int x = * ( int* )(p + 6 ); // 4-byte read at offset 6 -> spans bytes 6,7,8,9
Forecast: bytes 6 and 7 are in the first granule, bytes 8 and 9 in the second . Our headline formula assumed "not crossing a granule boundary." What does ASan actually do?
Step 1 — See the crossing. Address p + 6 has o = 6 , N = 4 , so the last byte is slot o + N − 1 = 9 . But slot indices only run 0..7 within one granule; index 9 lives in the next granule. The access straddles the boundary between granule 0 (bytes 0–7) and granule 1 (bytes 8–15).
Why this step? The single-check formula is only valid inside one granule; we must recognise when it doesn't apply.
Step 2 — How ASan chains checks. The compiler does not use one check here. For an unaligned or boundary-crossing access it emits two checks: it validates the first byte (p + 6 ) against granule 0's shadow, and the last byte (p + 9 ) against granule 1's shadow. If either is poisoned, it reports.
Why this step? Checking only the endpoints is enough because everything between them is in one of the two granules already covered; this keeps the instrumentation to two loads instead of N .
Step 3 — Apply both checks. Granule 0: all green (malloc(16) is two full granules) ⇒ s 0 = 0 ⇒ first check silent. Granule 1: also fully green ⇒ s 1 = 0 ⇒ second check silent. No report — this read is legal (offsets 6–9 all inside a 16-byte buffer).
Why this step? Shows the chained mechanism producing the correct silence.
Step 4 — Make it fire. Change to malloc(9): now granule 1 is a partial granule with s 1 = 1 (only slot 8 valid). The last byte p + 9 has o = 1 in granule 1; check 1 ≥ 1 ? Yes ⇒ report. The first-byte check on granule 0 stays silent, but the second fires.
Why this step? Demonstrates that the endpoint that lands in poison is the one that catches the bug — exactly what chaining buys you.
Verify: with malloc(16), offsets 6–9 are all < 16 : legal, silent — correct. With malloc(9), offset 9 is out of a 9-byte buffer: heap-buffer-overflow READ of size 4 — correct. The two-check chain gives the right verdict in both. ✓
char * a = malloc ( 8 );
char * b = malloc ( 8 ); // happens to sit far away but still valid
a [ 100000 ] = 'z' ; // lands inside... something valid?
Forecast: ASan fires on a[8]. Will it fire on a[100000]?
The figure lays out the address space: the small magenta redzone right after a is finite , only a few dozen bytes wide. The violet arrow leaps clear over it and lands in b's green block far to the right. Because the byte it touches is green, the shadow check reads s = 0 and stays silent — the picture is the whole argument for why distance, not intent, decides.
Step 1 — Where does a+100000 land? Redzones are finite (a few dozen–few hundred bytes). A jump of 100000 sails clean past a's redzone and can land in the green memory of some other live object, the runtime's own bookkeeping, or an unmapped page.
Why this step? ASan validates the byte you touch, not the intent ; if that faraway byte is green, the check is happy.
Step 2 — Apply the check (green case). If it lands on a green granule, s = 0 ⇒ condition false ⇒ no report . ASan stays silent even though this is a catastrophic bug.
Why this step? This is the honest limit stated in the parent's mistake box: "ASan catches adjacent overflows, not arbitrary jumps."
Verify: contrast Ex 2 (a[8], offset 8, hits the adjacent redzone → fires) with this (a[100000], jumps over the redzone → may be silent). The difference is distance vs redzone width , not "is it a bug". Pair ASan with Fuzzing (libFuzzer / AFL) and Undefined Behaviour in C and C++ reasoning to reach and reason about such paths. ✓ (silent = the documented gap)
char * p = malloc ( 0 ); // legal in C: may return a unique non-NULL pointer
p [ 0 ] = 'q' ; // read/write the "zeroth" byte
Forecast: zero usable bytes — what shadow value, and does p[0] fire?
Step 1 — How ASan represents "0 usable bytes". A partial granule with k valid bytes stores the positive value s = k for 1 ≤ k ≤ 7 . Zero valid bytes is not one of those (k = 0 is not in 1..7 ); ASan instead marks the granule with a negative shadow value — the very same "fully poisoned" encoding used for redzones (s < 0 ). So there is no mysterious separate marker: "0 bytes usable" is represented identically to a redzone , s < 0 . (This is why the positive range starts at s = 1 , not s = 0 : s = 0 already means all 8 valid.)
Why this step? The reviewer's ambiguity is resolved here — "0 usable" and "redzone" share one representation, s < 0 .
Step 2 — Apply the check to p[0]. o = 0 , N = 1 , s < 0 . Condition: s = 0 ✓, ( 0 ) ≥ s ✓ (a non-negative number is always ≥ a negative one). Report.
Why this step? Touching byte 0 of a 0-byte region is an overflow of zero capacity — the smallest possible overflow, and ASan still catches it.
Verify: you asked for 0 bytes, you own 0 bytes; index 0 is already out of bounds. heap-buffer-overflow is the correct verdict. ✓
void f ( void ) {
char buf [ 5 ]; // stack, not heap
buf [ 5 ] = '!' ; // one past
}
Forecast: heap redzones come from the runtime's malloc. Where do stack redzones come from?
The figure draws the stack frame vertically: a magenta poisoned strip above buf, the green buf[0..4], and a magenta poisoned strip below it. The orange arrow shows buf[5] reaching just past the green into the lower redzone — the compiler put that red strip there at build time, which is the point the figure makes visible.
Step 1 — Who inserts the redzone? For stack arrays the compiler rewrites the frame at build time: it lays buf between two poisoned strips and emits code to poison/unpoison them on function entry/exit. See Stack vs Heap Memory Layout for why the mechanism differs by region, and Compiler Instrumentation and -fsanitize flags for the flag that turns it on.
Why this step? Same shadow model , different installer — the runtime can't intercept a stack "allocation" (it's just moving the stack pointer), so the compiler must do it.
Step 2 — Apply the check. buf[5] is offset o into the stack redzone granule after buf; s < 0 . Condition true ⇒ report stack-buffer-overflow.
Why this step? Once the shadow is poisoned, the check itself is identical to the heap case — one formula rules all regions.
Verify: buf has valid indices 0..4 ; index 5 is one past. stack-buffer-overflow WRITE of size 1. ✓ (The cousin bug, use-after-return , is the same idea across a function boundary.)
Word problem. A logging module has a fixed global name buffer of 16 bytes. A config parser copies a hostname into it with strcpy. The hostname string literal is "verylonghostname!!". How many bytes does strcpy write, how many overflow, and at which index does ASan first fire?
char hostname [ 16 ]; // global
strcpy (hostname, "verylonghostname!!" ); // copies until and including '\0'
Forecast: count the characters and the first bad write index before reading on.
Step 1 — Count the bytes copied. "verylonghostname!!" = 18 characters, plus the terminating '\0' ⇒ strcpy writes 19 bytes to a 16-byte buffer.
Why this step? strcpy copies until (and including) the NUL — the classic source of "off-by-the-whole-string" overflows.
Step 2 — First out-of-bounds write. Valid indices are 0..15 . The first illegal write is index 16 . Globals get compiler-installed redzones (like Ex 9's stack case), so the granule at offset 16 has s < 0 .
Why this step? We need the exact firing point, which is the write to the first redzone byte.
Step 3 — Apply the check. At index 16: it starts a new (redzone) granule, o = 0 , N = 1 , s < 0 ⇒ report global-buffer-overflow WRITE of size 1 at offset 16.
Why this step? Confirms the region label (global) and the offset the report will show.
Verify: overflow size = 19 − 16 = 3 bytes; first bad index = 16 ; number of legal bytes written = 16 (indices 0–15). All three numbers consistent. ✓
char * p = malloc ( 8 );
p [ - 1 ] = 'u' ; // one byte BEFORE the buffer
Forecast: everyone guards the top end. Does ASan also catch reaching below the buffer?
Step 1 — There's a redzone on both sides. The parent note poisons the strips before and after each buffer. So p-1 lands in the left redzone, and Sh ( p − 1 ) reads a value s < 0 .
Why this step? Underflow is just overflow with a negative index; the guarding is symmetric.
Step 2 — Apply the check. p-1 sits in the previous (redzone) granule, at some offset o with s < 0 . Condition: s = 0 ✓, ( o + 1 − 1 ) = o ≥ s ✓ (since s < 0 ≤ o ). Report.
Why this step? Confirms the same inequality catches the low-side redzone.
Verify: ASan reports heap-buffer-overflow ... WRITE of size 1 with a negative offset (e.g. offset − 1 of the region). The left redzone is what makes underflow detectable. ✓
Example
Cell
Verdict
1
C1
silent (correct)
2
C2
fire (heap overflow)
3
C3 / C4
silent / fire (boundary)
4
C5
fire (use-after-free)
5
C6
fire (multi-byte, same granule)
6
C7
silent then fire (boundary-crossing, two checks)
7
C8
silent (documented miss)
8
C9
fire (zero-size)
9
C10
fire (stack overflow)
10
C11
fire (global, offset 16)
11
C12
fire (underflow)
"Last byte, not first." For any access, feed slot o + N − 1 to the check. Green granule → never fires; poisoned → always fires; partial → the boundary is exactly the allocation size. And when the access straddles two granules, check both endpoints .
Recall Active recall
Compute the shadow value s and the verdict for each:
malloc(3); p[2] ::: s = 3 ; o + N − 1 = 2 ; 2 ≥ 3 ? No → silent (legal).
malloc(3); p[3] ::: s = 3 ; 3 ≥ 3 ? Yes → fire.
malloc(6); *(int*)(p+4) (4-byte read) ::: s = 6 ; last byte = 4 + 4 − 1 = 7 ≥ 6 ? Yes → fire.
malloc(8); p[100000] ::: may hit a green granule elsewhere → possibly silent (the gap).
malloc(16); *(int*)(p+6) (4-byte read, crosses boundary) ::: both endpoints in green granules → silent (legal).