10/08/2026
Cybersecurity Insights
Custom Memory Management in HAProxy
As part of our collaboration with ANSSI, we had the opportunity to analyze the security of HAProxy (High Availability Proxy). A public report of this analysis has been published previously.
HAProxy is an open-source reverse proxy designed to be extremely performant in terms of speed and availability. Since it is entirely written in C for performance reasons, particular attention must be paid to security, especially regarding memory management. We therefore studied how HAProxy manages memory internally.
Interestingly, its approach is both secure and highly efficient in terms of performance.
HAProxy operates under a master-worker process model. The master process is launched with elevated privileges and is responsible for spawning, monitoring, and reloading worker processes. Each worker runs inside a chroot and drops its privileges. This architecture drastically reduces the impact of a successful attack.
Memory Management
Memory allocation is a complex problem. For a given allocation size, the goal is to find the appropriate memory region as quickly as possible, while maintaining a set of checks to ensure safety. This represents a delicate balance between security and performance. A naïve implementation can quickly become expensive in terms of both CPU and memory usage.
Modern heap allocators (such as ptmalloc) already include optimization and security mechanisms such as:
- Thread-local caches (tcache)
- Size-segregated bins for fast reuse of freed chunks
- Integrity checks on free lists
- Double-free detection
- Pointer mangling to mitigate exploitation
The HAProxy developers chose to reimplement a similar mechanism, tailored specifically to their needs, on top of standard libc mechanisms. This ensures that there are fewer calls to the libc allocator, and even fewer system calls, which results in significant time savings.
Several memory management strategies can be selected at compile time in HAProxy; the four available modes are illustrated in the figure below. The third mode corresponds to the classic behavior of a program, relying on libc functions and falling back to system calls when needed. The fourth mode uses only direct system calls. These last two modes are provided by HAProxy for debugging purposes.
The second mode implements a thread-local cache (not libc’s tcache, but HAProxy’s own implementation), which stores freed chunks in dedicated data structures managed internally by HAProxy.
We will focus on the first configuration, which, in addition to HAProxy’s thread-local cache, implements a cache shared across multiple threads. This mode fully leverages the custom memory management mechanisms developed by the HAProxy team.
Memory Pools
HAProxy introduces the concept of pools.
Each pool is represented by a pool_head structure that defines, among other things:
- The pool item size (size)
- The pool name (name)
- The entry point of the free list (cache)
The pool_head definition is as follows:
struct pool_head {
/* read-mostly part, purely configuration */
unsigned int limit; /* hard limit on the number of chunks */
unsigned int minavail; /* how many chunks are expected to be used */
unsigned int size; /* chunk size */
unsigned int flags; /* MEM_F_* */
unsigned int align; /* alignment size */
unsigned int users; /* number of pools sharing this zone */
unsigned int alloc_sz; /* allocated size (includes hidden fields) */
unsigned int sum_size; /* sum of all registered users' size */
struct list list; /* list of all known pools */
void *base_addr; /* allocation address, for free() */
char name[12]; /* name of the pool */
struct list regs; /* registrations: alt names for this pool */
/* heavily read-write part */
THREAD_ALIGN(64);
struct {
THREAD_ALIGN(64);
struct pool_item *free_list;
unsigned int allocated;
unsigned int used;
unsigned int needed_avg;
unsigned int failed;
} buckets[CONFIG_HAP_POOL_BUCKETS];
struct pool_cache_head cache[MAX_THREADS] THREAD_ALIGNED(64);
} __attribute__((aligned(64)));
struct pool_cache_head {
struct list list; /* head of objects in this pool */
unsigned int count; /* number of objects in this pool */
unsigned int tid; /* thread id, for debugging only */
struct pool_head *pool; /* assigned pool, for debugging only */
ulong fill_pattern; /* pattern used to fill the area on free */
} THREAD_ALIGNED(64);
struct list {
struct list *n; /* next */
struct list *p; /* prev */
};
Chunk and pool_item
In the standard libc allocator, the unit of memory it manages is called a chunk. In HAProxy, by contrast, the unit managed by a pool is called a pool_item. Each pool_item is a fixed-size block of pool->size bytes. In the rest of this analysis we will nonetheless keep calling them chunks: since we study HAProxy through the same lens as the libc allocator, at the abstraction level they are the same thing. When in active use, a chunk is treated as opaque application memory. When freed, its first bytes are reinterpreted as a pool_cache_item — without any copy, simply by casting the pointer:
struct pool_cache_item {
struct list by_pool; /* position in the per-pool per-thread free list */
struct list by_lru; /* position in the per-thread LRU eviction list */
};
This means the same memory region serves dual purposes: application data when allocated, and linked-list metadata when freed. The by_pool and by_lru fields — four pointers total, 32 bytes on a 64-bit system — are written at offset 0 of the freed block. This design is directly observable in GDB when inspecting free chunks.
Each thread owns a pool_cache_head, which is the beginning of the freed pool list. (As the tcache key in libc’s tcache)
To demonstrate the use of the memory pools, we consider the example of pool_head_buffer, used to manage struct buffer objects:
extern struct pool_head *pool_head_buffer;
/* Structure defining a buffer's head */
struct buffer {
size_t size; /* buffer size in bytes */
char *area; /* points to bytes */
size_t data; /* amount of data after head including wrapping */
size_t head; /* start offset of remaining data relative to area */
};
GDB can then be used to inspect the memory when such pools are used:
(gdb) p *(struct pool_head *)pool_head_buffer
$4 =
{
limit = 0, minavail = 4, size = 16384, flags = 3, align = 64, users = 1, alloc_sz = 16392,
sum_size = 16384, list = {n = 0x55555669bb60, p = 0x555556623120}, base_addr = 0x55555668aa20,
name = "buffer\000\000\000\000\000", regs = {n = 0x55555668a9e0, p = 0x55555668a9e0}, {},
buckets = {
{{}, free_list = 0x0, allocated = 0, used = 0, needed_avg = 0, failed = 0},
...
{{}, free_list = 0x0, allocated = 1, used = 1, needed_avg = 0, failed = 0}, {
...
},
cache = {
{list = {n = 0x555556713ec0, p = 0x555556717f00}, count = 2, tid = 0, pool = 0x55555668aa40, fill_pattern = 0},
{list = {n = 0x55555668bb00, p = 0x55555668bb00}, count = 0, tid = 1, pool = 0x55555668aa40, fill_pattern = 0},
{list = {n = 0x55555668bb40, p = 0x55555668bb40}, count = 0, tid = 2, pool = 0x55555668aa40, fill_pattern = 0},
{list = {n = 0x55555668bb80, p = 0x55555668bb80}, count = 0, tid = 3, pool = 0x55555668aa40, fill_pattern = 0},
...
}
}
From this output we can observe:
- Each thread has its own local cache, indexed by its tid in pool_head->cache[]: thread 0 has count = 2 free items, while threads 1–3 have empty caches (count = 0).
- Freed buffers are stored in a doubly-linked list: cache[0].list = {n = 0x555556713ec0, p = 0x555556717f00} gives the head and tail of thread 0’s free list for this pool.
Further investigating the cache variable demonstrates that this list of freed buffers is indeed circular (the following chain of « next » buffers can be observed while iterating through the ->n attributes of each item: 0x55555668bac0 -> 0x555556713ec0 -> 0x555556717f00 -> 0x55555668bac0). Each freed buffer is stored as a pool_item within the pool cache.
(gdb) p *(struct pool_cache_head *)pool_head_buffer->cache
$5 = {list = {n = 0x555556713ec0, p = 0x555556717f00}, count = 2, tid = 0, pool = 0x55555668aa40, fill_pattern = 0}
(gdb) p *(struct list *)pool_head_buffer->cache->list->n
$19 = {n = 0x555556717f00, p = 0x55555668bac0}
(gdb) p *(struct list *)pool_head_buffer->cache->list->n->n
$20 = {n = 0x55555668bac0, p = 0x555556713ec0}
(gdb) p *(struct list *)pool_head_buffer->cache->list->n->n->n
$21 = {n = 0x555556713ec0, p = 0x555556717f00}
(gdb) x/4gx 0x555556717f00
0x555556717f00: 0x000055555668bac0 0x0000555556713ec0
0x555556717f10: 0x0000555556194580 0x00005555566c1560
(gdb) x/4gx 0x55555668bac0
0x55555668bac0: 0x0000555556713ec0 0x0000555556717f00
0x55555668bad0: 0x0000000000000002 0x000055555668aa40
(gdb) x/4gx 0x555556713ec0
0x555556713ec0: 0x0000555556717f00 0x000055555668bac0
0x555556713ed0: 0x00005555566c1560 0x0000555556252150
In addition to the per-thread-per-pool lists, each thread maintains a per-thread list called pool_lru_head within the thread_ctx structure.
pool_lru_head is also a circular, doubly-linked list that chains all freed chunks belonging to a given thread, regardless of their associated pool.
This structure is used to implement an eviction policy for the thread-local cache: when space needs to be reclaimed, the eviction candidate is selected from this list. The least recently used chunk of the coldest structure is then moved from the local cache to the shared cache.
This mechanism further improves allocation efficiency and overall performance.
For ease of understanding, this figure illustrates the memory layout in a simplified scenario with a single thread and a single pool.
In practice, multiple threads and multiple pool_head structures coexist, and each pool_item belongs to two linked lists: one that associates it with its owning thread, and another that links it to its corresponding pool.
Allocation & Freeing Process
Thus, instead of directly calling malloc() or free(), HAProxy uses dedicated allocation functions that take the corresponding pool head as an argument. The standard malloc() and free() functions are only used as a last resort; otherwise, HAProxy handles memory management internally as much as possible.
pool_alloc
The allocation process first attempts to reuse memory from the thread-local cache. If no suitable chunk is available (good size, etc.), the local cache is refilled from the « shared pool ». When the shared pool cannot satisfy the request, the allocator ultimately falls back to the standard malloc() implementation to increase the size of the shared pool.
Let’s analyze the leaves of this graph to see whether any undefined behavior could arise from a design mistake. The classic allocation path (pool_get_from_os_noinc) ultimately relies on libc, so we will not analyze it here.
pool_refill_local_from_shared
Here, we are in the case where a matching chunk is available in the shared cache but not in the local (per-thread) one.
(The C code has been trimmed and simplified to ease comprehension.)
buckets, part of the pool_head struct, is a fixed-size array of doubly-linked lists of free chunks (pool_item):
struct pool_head {
...
struct {
struct pool_item *free_list;
...
} buckets[CONFIG_HAP_POOL_BUCKETS];
...
}
struct pool_item {
struct pool_item *next;
struct pool_item *down; // link to other items of the same cluster
};
void pool_refill_local_from_shared(struct pool_head *pool, struct pool_cache_head *pch)
{
struct pool_cache_item *item;
struct pool_item *ret, *down;
uint bucket;
uint count;
...
/* some checks ensure the chosen pool_item is really not busy */
bucket = random();
ret = _HA_ATOMIC_LOAD(&pool->buckets[bucket].free_list);
count = 0;
do {
while ((ret == POOL_BUSY || (ret == NULL && count++ < 1))) {
bucket = random();
ret = _HA_ATOMIC_LOAD(&pool->buckets[bucket].free_list);
}
if (ret == NULL)
return;
} while ((ret = _HA_ATOMIC_XCHG(&pool->buckets[bucket].free_list, POOL_BUSY)) == POOL_BUSY);
...
/* release the lock */
HA_ATOMIC_STORE(&pool->buckets[bucket].free_list, ret->next);
/* insert element by element the bucket free list in the local cache of the thread */
count = 0;
for (; ret; ret = down) {
down = ret->down;
item = (struct pool_cache_item *)ret;
LIST_INSERT(&pch->list, &item->by_pool);
LIST_INSERT(&th_ctx->pool_lru_head, &item->by_lru);
_HA_ATOMIC_INC(&pool->buckets[pool_pbucket(item)].used);
count++;
}
pch->count += count;
}
Two aspects of this function deserve clarification.
- The early return does not release any lock, and that is correct: at the point where if (ret == NULL) return; is reached, no atomic exchange has taken place yet. The « lock » in this mechanism is the POOL_BUSY sentinel value, which is only written into free_list by the _HA_ATOMIC_XCHG in the do-while Since this return is evaluated before that exchange, the thread holds no lock and there is nothing to release.
- The lock is released before the object is fully transferred, which is also safe: when HA_ATOMIC_STORE(&pool->buckets[bucket].free_list, ret->next) is called, the current thread already has exclusive ownership of ret — it was obtained by atomically swapping POOL_BUSY into free_list. The store writes back ret->next, not ret itself, so other threads can resume accessing the remainder of the list while the current thread safely processes ret in isolation. There is no race condition on the returned chunk.
Taking a chunk from the local cache (pool_get_from_cache)
Here, we are in the case where a chunk is available in the local cache.
static inline void *pool_get_from_cache(struct pool_head *pool, const void *caller)
{
struct pool_cache_item *item;
struct pool_cache_head *ph;
ph = &pool->cache[tid];
if (unlikely(LIST_ISEMPTY(&ph->list))) {
if (!(pool_debugging & POOL_DBG_NO_GLOBAL))
pool_refill_local_from_shared(pool, ph);
if (LIST_ISEMPTY(&ph->list))
return NULL;
}
/* allocate hottest objects first */
item = LIST_NEXT(&ph->list, typeof(item), by_pool);
...
LIST_DELETE(&item->by_pool);
LIST_DELETE(&item->by_lru);
...
ph->count--;
pool_cache_bytes -= pool->size;
pool_cache_count--;
return item;
}
- Allocation is done using LIST_NEXT, which retrieves the most recently freed object — the hottest entry in the per-pool list. This is intentional: reusing the most recently freed object maximizes the likelihood that its memory pages are still warm in the CPU cache, reducing TLB misses and improving allocation latency.
- If the local cache is empty, the function first attempts to refill it from the shared pool via pool_refill_local_from_shared before giving up and returning NULL. This two-level fallback avoids going directly to the OS allocator unless both caches are exhausted.
pool_free
Freed objects are first inserted into the thread-local cache. If the local cache is full, the freed chunk goes into the shared cache; if the shared cache is also full, the chunk is released back to libc.
Let’s also analyze the leaves of this graph:
void pool_put_to_cache(struct pool_head *pool, void *ptr, const void *caller)
{
struct pool_cache_item *item = (struct pool_cache_item *)ptr;
struct pool_cache_head *ph = &pool->cache[tid];
LIST_INSERT(&ph->list, &item->by_pool);
LIST_INSERT(&th_ctx->pool_lru_head, &item->by_lru);
ph->count++;
pool_cache_count++;
pool_cache_bytes += pool->size;
if (unlikely(pool_cache_bytes > global.tune.pool_cache_size * 3 / 4)) {
uint64_t mem_wait_start = 0;
if (unlikely(th_ctx->flags & TH_FL_TASK_PROFILING))
mem_wait_start = now_mono_time();
if (ph->count >= 16 + pool_cache_count / 8 + CONFIG_HAP_POOL_CLUSTER_SIZE)
pool_evict_from_local_cache(pool, 0);
if (pool_cache_bytes > global.tune.pool_cache_size)
pool_evict_from_local_caches();
if (unlikely(mem_wait_start))
th_ctx->mem_wait_total += now_mono_time() - mem_wait_start;
}
}
Several aspects of this function confirm that the fast path is both correct and cheap.
- The freed chunk is inserted at the head of both lists (by_pool and by_lru) with LIST_INSERT, so it becomes the hottest entry. This is the exact counterpart of pool_get_from_cache, which allocates from the head: the LIFO ordering keeps recently-touched memory at the front.
- The whole operation is lock-free and atomic-free because it only touches thread-local structures: ph = &pool->cache[tid] and th_ctx->pool_lru_head are both owned by the current thread. No other thread can observe or race on this insertion, which is why no POOL_BUSY sentinel or atomic exchange is needed here, unlike on the shared-pool path.
- Eviction is only considered when the cumulative cache size crosses 3/4 of pool_cache_size, and that branch is marked unlikely for compiler optimization.
- The two-level eviction is deliberately graduated: pool_evict_from_local_cache first trims only the current pool once it holds more than 16 + pool_cache_count / 8 + CONFIG_HAP_POOL_CLUSTER_SIZE items, and only if the process is still over its global budget does pool_evict_from_local_caches reclaim across every pool. Memory is reclaimed proactively without ever stalling a single free.
pool_evict_last_items
static void pool_evict_last_items(struct pool_head *pool, struct pool_cache_head *ph, uint count)
{
struct pool_cache_item *item;
struct pool_item *pi, *head = NULL;
void *caller = __builtin_return_address(0);
uint released = 0;
uint cluster = 0;
uint to_free_max;
uint bucket;
uint used;
BUG_ON(pool_debugging & POOL_DBG_NO_CACHE);
/* Note: this will be zero when global pools are disabled */
to_free_max = pool_releasable(pool);
while (released < count && !LIST_ISEMPTY(&ph->list)) {
item = LIST_PREV(&ph->list, typeof(item), by_pool);
BUG_ON(&item->by_pool == &ph->list);
...
LIST_DELETE(&item->by_pool);
LIST_DELETE(&item->by_lru);
bucket = pool_pbucket(item);
used = _HA_ATOMIC_SUB_FETCH(&pool->buckets[bucket].used, 1);
swrate_add_opportunistic(&pool->buckets[bucket].needed_avg, POOL_AVG_SAMPLES, used);
if (to_free_max > released || cluster) {
/* will never match when global pools are disabled */
pi = (struct pool_item *)item;
pi->next = NULL;
pi->down = head;
head = pi;
cluster++;
if (cluster >= CONFIG_HAP_POOL_CLUSTER_SIZE) {
/* enough to make a cluster */
pool_put_to_shared_cache(pool, head);
cluster = 0;
head = NULL;
}
} else {
/* does pool_free_nocache() with a known bucket */
_HA_ATOMIC_DEC(&pool->buckets[bucket].allocated);
pool_put_to_os_nodec(pool, item);
}
released++;
}
/* incomplete cluster left */
if (cluster)
pool_put_to_shared_cache(pool, head);
ph->count -= released;
pool_cache_count -= released;
pool_cache_bytes -= released * pool->size;
}
This eviction routine is the symmetric, « cold » side of allocation, and a few details show how it stays both safe and efficient.
- Eviction walks the list from its cold end: LIST_PREV selects the least-recently-used item, exactly mirroring the LIST_NEXT (hottest-first) policy of pool_get_from_cache. Allocating from the hot end while reclaiming from the cold end is what makes the per-thread cache behave as a true LRU.
- BUG_ON(&item->by_pool == &ph->list) guards against walking back onto the list head — that is, against releasing more items than the list actually holds. It turns any list-corruption or accounting desynchronization into an immediate, localized abort rather than a silent over-release.
- Cross-thread statistics are updated atomically (_HA_ATOMIC_SUB_FETCH on used, _HA_ATOMIC_DEC on allocated), so the shared accounting stays consistent even though several threads may evict concurrently.
- Chunks bound for the shared cache are batched into clusters of CONFIG_HAP_POOL_CLUSTER_SIZE before a single pool_put_to_shared_cache call, amortizing the cost of the cross-thread atomic publication. The trailing « incomplete cluster left » branch flushes any partial cluster, guaranteeing no chunk is leaked when the loop stops mid-batch.
- to_free_max = pool_releasable(pool) caps how many chunks may actually be handed back to the shared pool or the OS. Anything beyond that cap is returned to the OS one chunk at a time via the else branch, which prevents the cache from over-releasing memory that the thread is statistically likely to need again shortly.
void pool_put_to_os_nodec(struct pool_head *pool, void *ptr)
{
pool_free_area(ptr, pool->alloc_sz);
}
This is the terminal leaf of the free path, where a chunk finally leaves HAProxy’s control: pool_free_area leads to either a classic munmap or free.
No implementation flaws were identified in the allocation or deallocation algorithms.
Similarities with libc Heap Algorithms
The libc heap allocator follows a conceptually similar approach. The table below summarizes the correspondence between the two:
For simplicity, we focus only on the tcache mechanism.
The tcache is a fixed-size structure of 0x290 bytes, always allocated at the very start of the heap by glibc on the first call to malloc(). It stores per-size-class lists of freed chunks along with their counts.
To locate it, we identified the heap base address using /proc/<pid>/maps, which showed the heap starting at 0x555555559000. In this simple test case the tcache structure is the first heap allocation, so we examined that address directly:
(gdb) x/500gx 0x0000555555559000
0x555555559000: 0x0000000000000000 0x0000000000000291
0x555555559010: 0x0000000000000002 0x0000000000000000
0x555555559020: 0x0000000000000000 0x0000000000000000
0x555555559030: 0x0000000000000000 0x0000000000000000
0x555555559040: 0x0000000000000000 0x0000000000000000
0x555555559050: 0x0000000000000000 0x0000000000000000
0x555555559060: 0x0000000000000000 0x0000000000000000
0x555555559070: 0x0000000000000000 0x0000000000000000
0x555555559080: 0x0000000000000000 0x0000000000000000
0x555555559090: 0x000055555555c320 0x0000000000000000
0x5555555590a0: 0x0000000000000000 0x0000000000000000
0x5555555590b0: 0x0000000000000000 0x0000000000000000
0x5555555590c0: 0x0000000000000000 0x0000000000000000
0x5555555590d0: 0x0000000000000000 0x0000000000000000
...
0x55555555c310: 0x0000000000000000 0x0000000000000021
0x55555555c320: 0x000055555555c340 0x17a4511e8fc0838d
0x55555555c330: 0x0000000000000000 0x0000000000000021
0x55555555c340: 0x0000000000000000 0x17a4511e8fc0838d
0x55555555c350: 0x0000000000000000 0x000000000001da01
From this dump we can extract three important details:
- Two free chunks are available for the smallest size class: at 0x555555559010, the first entry of the counts[] array holds 0x0000000000000002 — value 2, meaning two chunks of that size class are currently free.
- A pointer references the first free chunk: at 0x555555559090, the first entry of entries[] holds 0x000055555555c320, pointing to the head of the free list for that size class.
- Chunks are singly linked together: at 0x55555555c320, the first word is 0x000055555555c340 — the (mangled) forward pointer to the next free chunk. At 0x55555555c340 the pointer is 0x0000000000000000, marking the end of the list. This structure is analogous to HAProxy’s pool free lists.
One major difference stands out: in the libc allocator, each thread has its own tcache with per-size-class lists. HAProxy goes one step further by interconnecting the two dimensions — each free chunk is linked both to its owning thread and to its pool head.
Security Analysis
Absence of Pointer Protection
Recall that when a chunk is freed, its first 32 bytes are reinterpreted as a pool_cache_item overlay containing two struct list pointers (by_pool and by_lru). These are the metadata that drive list navigation during allocation and eviction.
Unlike libc’s tcache — which mangles forward pointers via XOR with a per-thread secret — HAProxy’s by_pool and by_lru pointers are stored in plaintext at the start of each freed chunk, with no integrity checks. The following dump of a freed connection chunk confirms this:
(gdb) haproxy_pools connection
[*] Per-thread caches:
[Thread 0] cache @ 0x555556353f40 | count = 1
[0] pool_item @ 0x555556713ec0
[*] pool_lru_head:
[0] pool_item @ 0x555556714290
...
(gdb) x/10gx 0x555556713ec0
0x555556713ec0: 0x0000555556353f40 0x0000555556353f40 ← by_pool (n, p)
0x555556713ed0: 0x0000555556713ff0 0x00005555567140d0 ← by_lru (n, p)
0x555556713ee0: 0x0000000000000000 0x0000000000000000
0x555556713ef0: 0x00005555562521d0 0x00005555565eb600
0x555556713f00: 0x0000000000000000 0x0000555556713f08
If an arbitrary write primitive were available, corrupting these pointers would directly corrupt the free lists and could potentially lead to arbitrary memory reads or writes.
Turning a heap overflow or an arbitrary write into a reliable exploit at the pool level would require all three of the following conditions simultaneously:
- An arbitrary write or heap overflow primitive
- A reliable memory leak to identify the address of a freed chunk
- Precise corruption of the by_pool or by_lru list pointers
Even with these three prerequisites, building a reliable exploit is highly complex. Pool chunks are backed by large mmap’d regions with pool-specific alignment, making address prediction difficult without a precise leak. Pointer arithmetic over these lists also involves multi-threaded eviction, which introduces non-determinism that makes stable exploitation significantly harder.
Furthermore, all HAProxy worker processes run inside a chrooted environment with dropped privileges — as described in the introduction — which substantially limits the impact of any successful exploitation.
HAProxy’s custom memory management primarily focuses on performance optimization.
Performance
The standard libc allocator (ptmalloc) relies on mechanisms such as the tcache, which impose strict limits on:
- The number of size classes
- The number of chunks per list
Once these limits are exceeded, slower allocation paths are used, potentially triggering system calls. On top of that, multiple integrity checks slow down both allocation and deallocation.
In contrast, HAProxy adopts a more specialized approach:
- Each pool maintains a large, dedicated list for a specific object type
- The eviction mechanism is optimized through the pool_lru_head list
This significantly reduces allocator overhead and improves predictability. As a result, memory operations are faster and more deterministic.
Security
From a security perspective:
- Once freed, each object remains within a single abstraction type, reducing management complexity compared to libc, where multiple bin types coexist
- Objects are strongly typed by their pool
- Memory reuse follows stricter, pool-specific rules
- Built-in debugging features allow extensive runtime verification
However, it is important to note that HAProxy’s design philosophy prioritizes performance and sound engineering assumptions over defensive hardening against memory corruption vulnerabilities.
Conceptually, this reflects a different security model than libc’s allocator.
While libc includes numerous safeguards to protect against programmer mistakes — such as:
- Pointer mangling
- Double-free detection keys
- Extensive consistency checks
HAProxy assumes that its own codebase is free of such vulnerabilities for his allocator and therefore does not need to protect against them.
By avoiding security mechanisms such as pointer mangling and double-free checks, HAProxy significantly improves performance.
However, this also means that the overall security of the memory management system relies entirely on the absence of memory corruption bugs in the code.
Conclusion
Our analysis combined static code review with GDB-assisted dynamic inspection. We reviewed HAProxy’s pool allocator in detail — covering the allocation path (pool_alloc, pool_refill_local_from_shared, pool_get_from_cache), the deallocation path (pool_put_to_cache, pool_evict_last_items), and the underlying data structures (pool_head, pool_cache_item, pool_lru_head). No implementation flaws were found in these algorithms.
On the security side, we confirmed that pool metadata pointers (by_pool, by_lru) are stored unmangled and without integrity checks at the start of freed chunks. Exploiting this would require a heap overflow or arbitrary write, a memory leak, and precise pointer corruption — a high bar, further constrained by HAProxy’s chrooted worker architecture.
We did not discover any exploitable vulnerabilities. HAProxy’s memory management effectively balances performance and security: it deliberately trades some defensive hardening for performance, a reasonable assumption given the codebase’s maturity and the containment provided by the worker process model.