- Status: Accepted
- Date: 2026-07-08
- Deciders: Daniel Polo (maintainer / project architect)
- Related: ADR-0009 (the intrusive free-list layout, the
block_size >= sizeof(void*)+alignof(std::max_align_t)constraints, and the strict-aliasing-safe access idiom this ADR extends), ADR-0012 (the "defined, loud failure" stance and the accepted no-default-double-free-detection trade-off this hardens), ADR-0015 (the metadata-overhead budget kept intact when the gate is off), ADR-0020 (the three synchronization policies the hardening threads through), ADR-0022 (the growth chunks the stride change must respect), ADR-0025 (theInstrumentedPooldecorator that composes over the hardened pool), ADR-0037 (roadmap Milestone 9, which this item continues), ADR-0004 (the SemVer MINOR this lands under), issue #109, origin issue #105, spec §4.1 / §5.3
The pool's free list is intrusive (ADR-0009 §1): a free block stores the address of the next free block inside its own first sizeof(void*) bytes. That is what makes the metadata overhead of a free block zero (spec §3.2) — and it is also a classic memory-corruption exploitation primitive. A use-after-free write into a freed block silently overwrites a live next-link; a linear buffer overflow past block_size corrupts the following slot; a leaked free-list pointer is a ready-made write target. The default build detects none of this: it deliberately trades detection for speed and a zero-overhead free block, and it does not detect a double-free of an in-range block (ADR-0012, spec §5.3).
The rest of the repository holds an enterprise/security posture — a SECURITY.md, a sanitizer matrix (ADR-0005), Valgrind-clean verification (spec §6.2). The specification review (#105) flagged the gap: "no mention of pointer obfuscation, freed-block poisoning, or debug canaries." AddressSanitizer covers much of this on the platforms where it exists — but it is unavailable on MSVC (ADR-0005 §3), and it cannot instrument the pool's own sub-block boundaries: to ASan a pool is one large operator new region, so an overflow from one pool block into the next is invisible to its redzones.
We want a self-contained, opt-in hardening mode that works everywhere the library builds, catches the intrusive-free-list failure modes deterministically, demonstrates a real shipping allocator-hardening technique (glibc ≥ 2.32 safe-linking), and costs nothing — not a byte of footprint, not a cycle on the hot path — when it is off.
Four forces shape the design:
- Zero cost when off. The default build and the ADR-0014 benchmark numbers and the ADR-0015 overhead budget must be byte-for-byte and cycle-for-cycle unchanged. That rules out any runtime flag on the allocate/deallocate path; the knob must be compile-time.
- Don't disturb the frozen contract. ADR-0009 fixes
block_size >= sizeof(void*),block_sizea multiple ofalignof(std::max_align_t), and uniformalignof(std::max_align_t)alignment of every returned pointer. Hardening must preserve all three and must not shrink the user-visibleblock_size. - Uniform application. The transform must be applied at every free-list access site (init, push, pop, the debug walk) across all three thread-safety policies and across dynamic-growth chunks, or a single un-transformed access corrupts the list.
- Defined behaviour on detection. A detected violation is a bug in the consumer; the response must be a defined, loud failure (ADR-0012) — and it must be observable by a test without terminating the test process.
Add an opt-in, debug-only hardening mode behind a single compile-time knob, PBR_MEMORY_POOL_HARDENING (a CMake option, OFF by default; a harden CMake preset turns it on). When it is off the entire mechanism is preprocessed out and this ADR's code is a no-op. The mode adds three protections, all layered onto the existing intrusive free list.
Hardening reserves a trailing guard word (std::uint64_t) after each block's usable bytes. Rather than steal bytes from the user, the physical slot stride grows: stride = roundup(block_size + sizeof(guard), alignof(std::max_align_t)). Because block_size is already a multiple of alignof(std::max_align_t) (ADR-0009 §2), the stride becomes block_size + alignof(std::max_align_t) — the guard and its padding live in added storage, so:
- the user-visible
block_sizeand the ADR-0009 §5 alignment guarantee are unchanged (every slot start remainsalignof(std::max_align_t)-aligned); - the cost is memory footprint in the hardened build only — one extra
alignof(std::max_align_t)per slot.
A single slot_stride(block_size) helper is the identity (== block_size) when the knob is off, and every addressing site — initialize_free_list, block_in_chunk (range end + modulo), grow_pool, and memory_pool_create (including their size_t-overflow guards) — computes offsets from the stride, not from block_size.
On free, after the next-link is threaded into the first sizeof(void*) bytes, the remainder of the block ([sizeof(void*), block_size)) is filled with a recognizable byte pattern (0xDE). On the next allocation of that slot, the pattern is verified intact before the block is handed out; a broken pattern is a use-after-free write and is reported. (The link bytes themselves are not poisoned — they are protected by safe-linking, below. When block_size == sizeof(void*) the block is entirely the link and has no payload to poison — see Consequences.)
The trailing guard word is stamped GUARD_ALLOCATED when a block is vended and GUARD_FREED when it is returned. On free, the guard is validated first: still-GUARD_FREED ⇒ double-free; any value that is neither constant ⇒ a contiguous buffer overflow past block_size clobbered it. This is the "canary" the origin issue asked for — realized as one guard word living in the added stride (so, unlike a classic in-band canary, it costs no usable bytes and needs no separate sub-knob), and it makes double-free detection (the ADR-0012 gap) fall out for free.
The in-band next-link is stored XORed with a per-slot key derived from the slot's own address (ptr XOR (slot_addr >> 12)) — glibc's PROTECT_PTR/REVEAL_PTR transform, which is symmetric (protect == reveal). Two consequences: a next-link leaked out of band is not a directly usable address, and a corrupted stored value reveals to a misaligned pointer, which an alignment check on reveal catches as free-list corruption. Access stays strict-aliasing-safe: the value is still read through void* const* and written through void** (ADR-0009 §3), only the bits stored differ.
On any detected violation the library calls an installed HardeningViolationHandler (declared in the new pool_hardening.hpp, present only in hardened builds). The default handler prints a diagnostic to std::cerr and calls std::abort() — the ADR-0012 defined-loud-failure stance. set_hardening_violation_handler swaps it (thread-safe, via an atomic) and returns the previous one; passing nullptr restores the default. The handler is noexcept (it is called from the pool's noexcept (de)allocate path). A handler that returns — which the default never does — puts the pool on a best-effort path so a test can assert a violation without terminating the process; the tests install such a recording handler. Because the default aborts at the point of detection, the production path never continues past a violation.
The knob is defined PUBLIC on the CMake target: a hardened build changes both the on-disk free-list encoding (safe-linking) and the physical slot stride, so the library and every consumer / test linking it must agree on the value. A hardened build is therefore deliberately not memory-layout-compatible with a non-hardened one — never mix the two configurations. The public C ABI and C++ types are otherwise untouched; the default build is byte-for-byte unchanged. This is purely additive → SemVer MINOR (a v1.2.0 candidate, ADR-0004; roadmap item 9.2 under ADR-0037).
- A runtime flag instead of a compile-time knob. Rejected. A per-operation branch on the hot path violates force 1 (the zero-cost-when-off guarantee and the ADR-0014/0015 numbers). A compile-time gate produces identical codegen when off.
- Steal the guard/canary bytes from
block_size(a distinct "canary" sub-mode with its own knob, as #109 sketched). Rejected. Shrinking the usable block would change the ADR-0009 §2 block-size math and the user-visible contract, and it needed a second knob to manage the trade-off. Putting the guard in added stride keeps the user contract identical, costs no usable bytes, and folds overflow + double-free detection into one knob. - Head + tail canaries (two guard words). Rejected as unnecessary for the fixed-block model: a forward linear overflow past
block_sizeis the realistic case, one trailing guard catches it and doubles as the free/allocated state stamp, and a second word would only add footprint. (Underflow into the preceding slot is instead caught by that slot's poison/guard on its next cycle.) - Poison the whole block including the link bytes. Rejected: the first
sizeof(void*)bytes legitimately hold the (safe-linked) next-link while free, so they cannot also hold poison. Safe-linking guards those bytes instead; poison covers the rest. - Raw next-pointer with only poisoning (no safe-linking). Rejected. Safe-linking is the didactic centrepiece (a real, shipping glibc technique; force in ADR-0003's "exercise production-grade techniques and justify them" mandate), it makes a leaked link non-trivial to weaponize, and its alignment-on-reveal check gives cheap free-list-integrity detection that poisoning alone does not.
- Lean on AddressSanitizer only. Rejected as insufficient, not as competing: ASan is absent on MSVC (ADR-0005 §3) and cannot see inside the pool's own region (block-to-block overflow). This mode complements ASan and runs everywhere the library builds; the two are used together in CI.
- A fixed abort with no handler hook. Rejected. A swappable handler is what lets the test suite prove each violation is detected without killing the process, at zero cost to the production default (which still aborts).
Positive
- Deterministic detection of the three intrusive-free-list failure modes — use-after-free, forward buffer overflow past
block_size, and double-free — everywhere the library builds, including MSVC where ASan is unavailable. - The user-visible
block_sizeand the ADR-0009 alignment guarantee are unchanged; the default build and its benchmark/overhead numbers are byte-for-byte and cycle-for-cycle unaffected (verified by the existing zero-overhead check and the CIdebug/releasecells). - Demonstrates safe-linking end to end — the threat model, the
PROTECT_PTR/REVEAL_PTRtransform, and the alignment-on-reveal integrity check — in a shipping-quality reference, satisfying the #105 hardening ask and the ADR-0003 mandate. - Works with fixed and dynamic pools and all three thread-safety policies; composes under the
InstrumentedPooldecorator (ADR-0025).
Negative
- A hardened build is a separate configuration: incompatible on-disk layout, and one extra
alignof(std::max_align_t)of footprint per slot. This is stated explicitly and is the point (never mix configurations). - The hardening runs validation work on every allocate/deallocate — it is a debug/bug-finding mode, not a production speed path. The compile-time gate is what keeps that cost strictly opt-in.
- Poisoning covers
[sizeof(void*), block_size); a use-after-free write that lands only in the firstsizeof(void*)link bytes is caught by safe-linking's reveal check on the next pop rather than by poison. Whereblock_size == sizeof(void*)— reachable only wherealignof(std::max_align_t)equals the pointer size (e.g. MSVC x64, both 8) — the block is entirely the link and has no payload to poison, so use-after-free of such a slot is covered by safe-linking (and the guard word / double-free check) rather than by poison. Larger blocks carry poisoned payload bytes. This is an inherent property of the layout, documented rather than papered over: rejecting such block sizes was considered and rejected because it would breakTypedPool<T>/PoolAllocator<T>for pointer-sizedTunder hardening, and the ADR-0009 block-size contract stays identical in both configurations.
Testing / tooling / documentation (landing in the same PR)
pool_hardening.hpp— the violation-handler surface (get/set + theHARDENING_*kind constants), present only in hardened builds.pool_hardening_test.cpp— a dedicated doctest binary (CTestpool_hardening), gated likefree_list_iterator_testso it builds (with one placeholder case) in the default build, and under thehardenpreset asserts no-false-positive cycles plus deterministic detection of use-after-free, overflow, and double-free via a recording handler.CMakeLists.txt/CMakePresets.json— thePBR_MEMORY_POOL_HARDENINGoption (PUBLIC) and ahardenconfigure/build/test preset..github/workflows/ci.yml— ahardenmatrix cell on each Tier-1 platform so the hardened configuration is built and its tests run under the quality bar (the "clean in both configurations" acceptance criterion).docs/specs/01_spec_cpp_memory_pool.md— §4.1 gains a hardening note, §5.3 records that opt-in detection now exists, and §7 maps it to this ADR / §7.1 no longer defers it. (The spec is a translated source but is alreadystalefrom the prior spec PRs, so this edit adds no new i18n debt.)docs/doxygen/DoxyfilePREDEFINEDgainsPBR_MEMORY_POOL_HARDENING=1so the gated handler surface is documented on the API site.SECURITY.md— the scope note records the opt-in hardening build as a defense-in-depth / bug-finding aid.ROADMAP.md— item 9.2 flips to done.CHANGELOG.mdUnreleased— an Added entry.- Deferred to the
v1.2.0release PR to keep this feature PR off the translated docs surface (editing them trips thei18n-freshnessgate, and their rows are currentlytranslated): theREADME.mdsecurity-section refresh and Milestone-9 table row, plus anydocs/patterns/README.mdtouch. The release PR refreshes them and re-syncs thezh-Hans/jatranslations in one pass — the same M8.8 / ADR-0042 sequencing.
- glibc
mallocsafe-linking (PROTECT_PTR/REVEAL_PTR), introduced in glibc 2.32 — the XOR-with-(addr >> 12)transform this mirrors, and its alignment-on-reveal integrity check. - ADR-0009 — the intrusive free-list layout, the block-size/alignment constraints, and the strict-aliasing-safe access idiom.
- ADR-0012 — the defined-loud-failure stance and the default no-double-free-detection trade-off this hardens.
- ADR-0015 — the metadata-overhead budget kept intact when the gate is off.
SECURITY.md— the project's security policy and scope.