Vous êtes victime d’un incident de sécurité ? Contactez notre CERT

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 <size> 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}, {<No data fields>}, 
  buckets = {
      {{<No data fields>}, free_list = 0x0, allocated = 0, used = 0, needed_avg = 0, failed = 0},
	  ...
      {{<No data fields>}, 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:

pool_put_to_cache
				
					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 &&&nbsp;!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.
pool_put_to_os_nodec
				
					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:

Aspect
libc (ptmalloc)
HAProxy pools
OS memory acquisition
Large regions obtained via (s)brk and mmap
Large regions obtained via malloc(), which itself relies on (s)brk / mmap
Allocation / free API
malloc() / free()
pool_alloc() / pool_free()
Free chunk organization
Size-based lists (tcache, fastbins, smallbins, etc.)
Per-object-type pools, each with a per-thread cache and a shared bucket free list

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.

Exploitability Assessment

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.

Security and Performance Benefits

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.

Voir les derniers Cybersecurity Insights

3 août 2026
Autonomie stratégique, intelligence artificielle agentique, résilience, compétences, sobriété, réglementation : la troisième édition des Rencontres Numériques de Strasbourg a couvert […]
3 août 2026
Depuis 2022, l'Union européenne a profondément modifié sa posture dans le cyberespace. En attendant de pouvoir rivaliser industriellement avec Washington […]
15 juillet 2026
Face à la prolifération des plateformes de GRC, de gestion des risques, de conformité ou de pilotage cyber, la tentation […]
22 mai 2026
Domain and forest trusts are a well-known research topic. Rather than revisiting all of its aspects, the present article focuses […]
19 mai 2026
Pour de nombreuses entités publiques ou privées, la cybersécurité est encore un centre de coût. En particulier, les audits de […]
11 mai 2026
During the last months of 2025, our internal Security Evaluation Laboratory had the chance to conduct a security audit of […]
4 mai 2026
Alors que le Cyber Resilience Act entre dans sa phase d’application, celui-ci intègre une nouvelle obligation inédite et dimensionnante pour […]
8 avril 2026
Data protection : retrouvez les erreurs principales à éviter pour s'assurer du bon déroulement de votre projet DLP.
9 janvier 2026
Our Security Evaluation Laboratory performed a security audit of HAProxy. This audit was aimed at evaluating the security level of […]
22 décembre 2025
Recently, our team performed a security audit of SmallStep Certificates PKI. This audit was focused on the cryptographic aspects of […]

Nous vous souhaitons de joyeuses fêtes de fin d’année hautes en couleur et à l’année prochaine pour une année 2025 exaltante ! 🎉

🎁 Merci à tous pour votre participation au quiz de l’avent, nous contacterons le gagnant très prochainement.

🎅 Chez Almond, l’esprit festif des fêtes de fin d’année est arrivé en avance !

Nos collaborateurs ont profité d’une soirée chaleureuse et joyeuse dans l’un des restaurants les plus spectaculaires de Paris, Le Cirque avec un cocktail dinatoire, des surprises et un Secret Santa.

Et un peu plus de magie de Noël ? Almond a également ouvert ses portes aux familles de nos collaborateurs pour une après-midi conviviale autour de l’arbre de Noël. Les enfants ont été captivés par des contes enchantés, de 1001 contes Constance Felix et ont savouré un goûter délicieux avec des chocolats chauds préparés par les Empotés. Le Père Noël a distribué des coloriages géants et des cadeaux pour le plus grand bonheur des enfants 🎁

Jour 23 |

Jour 22 | Laquelle de ces menaces n’est pas un cryptoransomware ?

  • Réponse 1 : Lockbit3
  • Réponse 2 : Phobos
  • Réponse 3 : NotPetya
  • Réponse 4 : WannaCry

Laïus explicatif : Bien que NotPetya ressemble à un ransomware, il s’agit en réalité d’un wiper. Ce malware rend indisponible les fichiers de la victime, mais ne fournit aucun moyen de les déchiffrer, même après le paiement de la rançon. L’objectif principal de NotPetya n’est pas l’extorsion financière, mais la destruction de données.
En cas d’incident, voici les coordonnées de notre CERT : alerte@cwatch.almond.eu +33 (0)1 83 75 36 94

Jour 21 | Vous dialoguez via votre terminal avec un service distant et vous vous rendez compte qu'il contient un stack-based overflow. Vous cherchez à l'exploiter à l'aveugle et trouvez finalement l'offset de l'adresse de retour, après avoir contourné les éventuelles protections. Vous cherchez maintenant un stop gadget pour continuer votre exploitation. Quelle est son utilité :

  • Réponse 1 : interrompre à la demande le flux d’exécution du binaire distant le temps de l’exploitation
  • Réponse 2 : obtenir une exécution fiable et maîtrisée avec un comportement reproductible
  • Réponse 3 : pouvoir mettre en pause le binaire temporairement pendant l’envoi de la payload
  • Réponse 4 : pouvoir stopper proprement le binaire afin d’éviter un éventuel crash à la fin de l’exploitation

Laïus explicatif : L’exploitation se déroulant en aveugle, il est nécessaire de trouver une adresse permettant d’obtenir un comportement particulier et reproductible à chaque exécution, comme l’affichage du texte « Bye ». Si une telle adresse est trouvée, elle correspond au stop gadget. Il permettra donc de continuer l’exploitation et de valider ou invalider nos déductions lors de l’exécution du binaire.

Jour 20 | Le terme "spam" pour désigner les messages indésirables provient initialement

  • Réponse 1 : D’une marque de jambon en boîte
  • Réponse 2 : D’un acronyme signifiant « Stupid Pointless Annoying Messages »
  • Réponse 3 : D’un sketch des Monty Python
  • Réponse 4 : D’un code utilisé pendant la Seconde Guerre mondiale

Laïus explicatif : Ce mot, à l’origine un acronyme de : SPiced hAM (du jambon épicé en boîte vendue par une entreprise américaine), est repris en masse, pour brouiller la conversation, dans un sketch des Monty Python.

Jour 19 | L’acronyme PACS désigne  :

A. Un format permettant la visualisation des images dans l’imagerie médicale

B. Un système d’archivage et de communication d’images dans l’imagerie médicale

C. Un prestataire d’audit et de conseil en cybersécurité

D. Un pacte civil de solidarité

  • Réponse 1 : L’ensemble des réponses
  • Réponse 2 : Réponses C et D
  • Réponse 3 : Réponses B, C et D
  • Réponse 4 : Réponses A, C et D

Laïus explicatif :

Un PACS, dans le secteur de l’imagerie médicale, désigne effectivement un système (et non un format) signifiant « Picturing Archiving and Communication System » permettant de gérer les images médicales grâce à des fonctions d’archivage.

De plus, depuis septembre, l’ANSSI a publié un référentiel d’exigences qui permet aux commanditaires de prestations de sécurité de bénéficier de garanties sur les compétences des prestataires, sur le processus d’accompagnement et de conseil, ainsi que sur la sécurité des systèmes d’information associés. Ce référentiel vise à reconnaître officiellement les prestataires en tant que « Prestataires d’accompagnement et de conseil en sécurité ».
Enfin, en France, le PACS désigne aussi une forme d’union civile dénommée Pacs.

Jour 18 | En quelle année l'ANSSI prévoit de ne plus recommander l'utilisation de certains algorithmes de chiffrement classiques en raison de l'augmentation de la puissance de calcul des ordinateurs classiques et de la menace posée par les ordinateurs quantiques ?

  • Réponse 1 : 2026
  • Réponse 2 : 2030
  • Réponse 3 : 2035
  • Réponse 4 : 2050

Laïus explicatif : Dans son dernier avis sur la migration vers la cryptographie post quantique, paru en janvier 2024, l’ANSSI encourage tous les éditeurs à mettre en œuvre dès à présent une hybridation entre la cryptographie standard et la cryptographie post-quantique (pour les produits qui doivent protéger des informations après 2030) et recommande d’utiliser en priorité la cryptographie post-quantique à partir de 2030.  

Jour 17 | Quelle est la dernière course à laquelle j’ai participé ?

  • Réponse 1 : Le Vendée Globe
  • Réponse 2 : National Figaro 3 en équipage
  • Réponse 3 : La Solitaire du Figaro Paprec
  • Réponse 4 : Le Havre Allmercup

Laïus explicatif : Le National Figaro 2024 s’est déroulé du 4 au 6 octobre dernier à Lorient. Thomas et son équipe sont arrivés en 2e position ! Cette course clôture ainsi la saison 2024 sur le circuit Figaro. 

  • Réponse 1 : Aetheris

  • Réponse 2 : Venopie

  • Réponse 3 : Lumidus

  • Réponse 4 : Pandama

Laïus explicatif : Au sein de la plateforme d’attaque – défense M&NTIS, le scénario Pandama propose une kill chain dont l’impact, après compromission du contrôleur de domaine, permet de déployer, par GPO, une charge utile effaçant les données présentes sur les systèmes de fichiers du SI simulé.

Pour rappel, basé sur les technologies d’émulation d’adversaire et de Cyber Range, M&NTIS permet d’exécuter des campagnes d’attaques réalistes afin de challenger dans un environnement immersif les procédures et l’expertise des équipes SOC et CERT. M&NTIS répond ainsi aux enjeux d’amélioration continue de la défense.

Jour 15 | Quel type de menace ne fait pas parti de l’insider threat?

  • Réponse 1 : Malicious
  • Réponse 2 : Ransomware group
  • Réponse 3 : Negligent
  • Réponse 4 : Vendors

Laïus explicatif : Almond a proposé une étude sur la menace interne qui décrit chaque type d’insider. Les groupes de ransomware sont externes à l’entreprise mais peuvent recruter des employées pour récupérer des accès valides et compromettre l’entreprise. Retrouvez l’étude ici.

Jour 14 | Selon vous, quelle proportion des cyberattaques réussies sont liées à une erreur humaine ?

  • Réponse 1 : 40%

  • Réponse 2 : 100%

  • Réponse 3 : 70%

  • Réponse 4 : 90%

Laïus explicatif : 90% des cyberattaques trouvent leur origine dans une erreur humaine. L’erreur humaine en cybersécurité englobe toutes les actions, conscientes ou non, qui exposent les systèmes et les données à des menaces. Cela inclut des gestes apparemment innocents, comme le fait de :

  • Cliquer sur les liens malveillants
  • Utiliser des mots de passe faibles ou partagés
  • Partager des informations sensibles
  • Négliger la mise à jour des logiciels et systèmes
  • Commettre une erreur de configuration ou mal administrer les accès
  • Utiliser des clés USB non sécurisées ou prévenant de sources inconnues

Jour 13 | Almond & Amossys sont présents en France et à l’international pour garantir proximité et réactivité grâce à nos services 24/7. Dans quels pays se trouvent nos équipes ?

  • Réponse 1 : FRA – CHE – AUS – JPN

  • Réponse 2 : FRA – CAN – CHE – KOR

  • Réponse 3 : FRA – AUS – CAN – GBR

  • Réponse 4 : FRA – BEL – ITA – USA

Jour 12 | Challenge OSINT

Val Thorens

Laïus explicatif : Depuis plusieurs années consécutives, notre CSE organise des séjours à Val Thorens pour profiter des sports d’hiver. Que l’on aime dévaler les pistes de ski à toute allure, tenter l’aventure en prenant des cours d’initiation ou simplement déguster une raclette après une randonnée raquette et un passage à la piscine et au sauna, ce séjour est l’occasion de partager des moments convivaux avec ses collègues ! TIC, TAC, le prochain séjour ski approche à grands pas !

Jour 11 | Parmi ces propositions, quelle technique Mitre Atta&ck est la plus utilisée par les attaquants ?

  • Réponse 1 : OS Credential Dumping
  • Réponse 2 : Valid Account
  • Réponse 3 : Impair Defenses
  • Réponse 4 : Remote services

Laïus explicatif : L’achat ou la récupération de comptes valides sont de plus en plus commun. Certains cybercriminels appelés Initial Access Broker se spécialisent dans la compromission de victimes dans le but de récupérer des identifiants valides qui seront ensuite vendus à d’autres cybercriminels comme les groupes de ransomware.

Jour 10 | Parmi ces structures de données de la mémoire dans Windows, quelle est celle qui permet de lister les processus en cours d’exécution ?

  • Réponse 1 : EPROCESS
  • Réponse 2 : Kernel Debugger Data Block (KDBG)
  • Réponse 3 : Kernel Processor Control Region (KPCR)
  • Réponse 4 : Process Environment Block (PEB)

Laïus explicatif : La structure EPROCESS (Executive Process) est utilisée par Windows pour gérer chaque processus en cours d’exécution. Elle contient des informations essentielles comme l’identifiant du processus (PID), l’état, les threads associés, et d’autres données nécessaires au système pour suivre les processus actifs. En analysant les structures EPROCESS, on peut lister les processus actuellement en mémoire. Le PEB est lié à chaque processus de manière individuelle. Enfin le KPCR est nécessaire pour trouver l’adresse du KDB qui à son tour permettra de pointer vers le EPROCESS.  

Jour 9 | Quel est le problème si la suite cryptographique TLS_RSA_WITH_AES_256_CBC_SHA256 est utilisée avec l'extension encrypt_then_mac pour la sécurité d'une communication TLS ?

  • Réponse 1 : L’algorithme de chiffrement est trop faible

  • Réponse 2 : L’intégrité de la communication n’est pas assurée

  • Réponse 3 : Il n’y a pas la propriété de confidentialité persistante (Perfect Forward Secrecy)

  • Réponse 4 : Le serveur n’est pas correctement authentifié

Laïus explicatif : La bonne réponse est le manque de confidentialité persistante.

La suite TLS_RSA_WITH_AES_256_CBC_SHA256 utilise la clé publique RSA du serveur pour chiffrer le secret partagé utilisé pour sécuriser les échanges de la session TLS : en cas de compromission de la clé privée du serveur, l’ensemble des échanges des sessions passées peuvent être déchiffrés par un attaquant.
La confidentialité persistante (connue sous le nom de Perfect Forward Secrecy en anglais) consiste en l’utilisation d’un échange Diffie-Hellman éphémère pour négocier le secret partagé, sans utilisation de la clé RSA du serveur.

Jour 8 | Quel est l'avantage d'utiliser un outil de couverture de code lors d'une session de fuzzing ?

  • Réponse 1 : Réduire le temps de fuzzing en optimisant certaines instructions assembleur.

  • Réponse 2 : Utiliser la technique de « pré-chauffage » du harnais (« warming code attack »).

  • Réponse 3 : Pouvoir analyser facilement les sections de code atteintes par le fuzzer.

  • Réponse 4 : Ne pas prendre en compte les vulnérabilités de type use-after-free.

Laïus explicatif : Les outils de couverture de code (“code coverage” en anglais) permettent de savoir avec précision quelles lignes de code d’un programme qui ont réellement été exécutées. Lors d’une session de “fuzzing”, ces outils peuvent aider l’analyste à savoir si les fonctions ciblées ont été atteintes par le fuzzer. Cette technique a notamment été utilisée par un membre de l’équipe Offsec pour trouver une vulnérabilité dans une bibliothèque open-source (voir notre article de blog)

Jour 7 | Quelle est la principale éthique qui doit être prise en compte dans le développement de l’Intelligence Artificielle ?

  • Réponse 1 : L’équité et la non-discrimination

  • Réponse 2 : La transparence des algorithmes utilisés

  • Réponse 3 : La sécurité et la confidentialité des données

  • Réponse 4 : Toutes les réponses

Laïus explicatif : L’équité et la non-discrimination sont des principes fondamentaux dans le développement de l’IA. Les systèmes d’IA doivent être conçus pour éviter les biais et assurer qu’ils ne favorisent pas des groupes spécifiques au détriment d’autres, afin de garantir un traitement juste et égal pour tous les utilisateurs. La transparence des algorithmes est cruciale. Les utilisateurs doivent comprendre comment les décisions sont prises par l’IA, ce qui inclut la possibilité d’expliquer les résultats ou actions générés par un système d’intelligence artificielle, afin d’éviter des décisions opaques ou injustes. La sécurité et la confidentialité des données sont enfin des préoccupations majeures lorsque l’on développe des systèmes d’IA, car ces technologies peuvent collecter et traiter des informations sensibles, ce qui soulève des questions sur la protection des données personnelles et la vie privée.

Jour 6 | Selon vous, en moyenne combien de ransomware ont eu lieu par jour en 2023 dans le monde ?

  • Réponse 1 : 1 par jour

  • Réponse 2 : 100 par jour

  • Réponse 3 : 30 par jour

  • Réponse 4 : 12 par jour

Laïus explicatif : En moyenne 12 attaques ransomware ont été signalées par jour par des victimes dans le monde en 2023 selon les chiffres d’Almond. Pour plus d’informations, n’hésitez pas à consulter notre Threat Landscape.

Jour 5 | Challenge de stéganographie

Réponse : PASSI RGS, PASSI LPM, CESTI, ANJ, Cybersecurity made in Europe, PCI QSA Company et Swift

Etape 1 : Observer l’image, trouver 3 logos cachés (Cybersecurity made in Europe, PCI QSA Company & Swift) et une indication pour chercher dans les métadonnées du fichier. 

Etape 2 : Challenge de stéganographie

En lançant dans son terminal un des outils les plus courants, « binwalk », on trouve une image JPEG dans le PDF. En extrayant les données grâce au même outil et en renommant le fichier en .jpeg, on voit apparaitre une image cachée. Ensuite, en utilisant « steghide », on peut extraire le fichier avec le mot de passe « Almond ». Ce fichier contient une suite de caractère encodée en base64. En la déchiffrant, on obtient les quatre autres certifications : PASSI RGS, PASSI LPM, CESTI et ANJ. 

Jour 4 | Concernant les accompagnements de la nouvelle qualification PACS de l’ANSSI, sur la portée Sécurité des Architectures, quels sont les domaines qui font partie du périmètre possible d’un accompagnement ?

  • Réponse 1 : la sécurité réseau, l’authentification, et l’administration du SI

  • Réponse 2 : la sécurité réseau, la sécurité système, et les mécanismes de chiffrement

  • Réponse 3 : l’administration du SI, le cloisonnement, les sauvegardes, et la stratégie de détection/réponse

  • Réponse 4 : tous ces sujets et plus encore

  • Laïus explicatif : Le référentiel PACS, sur la portée Sécurité des Architectures, porte bien sur tous les sujets liés de près ou de loin aux infrastructures du SI. La liste n’est pas exhaustive et est à adapter à chaque prestation d’accompagnement suivant le périmètre d’intervention. Dans le référentiel, l’ANSSI propose une liste de sujets à adresser dans un rapport PACS page 28 et 29.

    https://cyber.gouv.fr/sites/default/files/document/PACS_referentiel-exigences_v1.0.pdf

Jour 3 | Quel référentiel permet la certification de produits de sécurité ?

  • Réponse 1 : NIS2

  • Réponse 2 : Critères Communs

  • Réponse 3 : PASSI

  • Réponse 4 : ISO27001

Laïus explicatif : Le schéma Critères Communs est un ensemble de normes et méthodologies permettant de cadrer les moyens utilisés pour évaluer, de manière impartiale, la sécurité d’un produit de sécurité (logiciel ou matériel). Ce schéma est reconnu internationalement au travers de plusieurs accords (SOG-IS, CCRA et prochainement EUCC).

Le référentiel PASSI permet la qualification, par l’ANSSI, des prestataires d’audit de la sécurité des SI. ISO27001 est la norme décrivant les bonnes pratiques à suivre dans la mise en place d’un SMSI. Enfin, NIS2 est une directive visant à harmoniser et à renforcer la cybersécurité du marché européen.

Jour 2 | Quel est l’artefact forensique qui permet de prouver une exécution d’un programme sous Windows ?

  • Réponse 1 : JumpList

  • Réponse 2 : ShimCache

  • Réponse 3 : $MFT

  • Réponse 4 : Prefetch

Laïus explicatif : Le Prefetch est un artefact spécifique à Windows qui optimise le chargement des programmes. Lorsqu’un programme est exécuté pour la première fois, Windows crée un fichier dans le dossier C:\Windows\Prefetch, qui contient des informations sur le programme et les ressources qu’il a utilisées. Ces fichiers incluent également des horodatages correspondant à la première et aux dernières exécutions. L’existence d’un fichier Prefetch (.pf) pour un programme est une preuve solide qu’il a été exécuté. C’est l’un des artefacts forensiques les plus fiables pour prouver l’exécution d’un programme.

Jour 1 | Quel texte européen permettra qu’à partir de fin 2027, tous les produits vendus dans l’UE et comprenant des composants numériques seront exempts de vulnérabilités et maintenus pendant tout leur cycle de vie ? #DigitalTrust

  • Réponse 1 : Le Cyber Security Act
  • Réponse 2 : Le Cyber Resilience Act
  • Réponse 3 : La Directive REC
  • Réponse 4 : La Directive NIS2 

Laïus explicatif : Le Cyber Resilience Act, qui a été publié ces derniers jours au Journal Officiel de l’Union Européenne est entré en vigueur le 10 décembre 2024. A compter de cette date, les fabricants et éditeurs doivent adapter leur processus pour pouvoir continuer à vendre des produits au sein de l’UE après le 10/12/2027.

EU Cyber Resilience Act | Shaping Europe’s digital future