HackTheBox No Gadgets Writeup | Binary Exploitation CTF

Motasem Hamdan
10 min readNov 10, 2024

--

Introduction

In HackTheBox No Gadgets ,we have a classic buffer overflow but with a unique twist: commonly used gadgets like ret are absent. Instead, the user must leverage alternative gadgets, such as controlling strlen@GOT to rbp and using pop rdi ; main to achieve arbitrary writes into the writable section of the binary.

Using this capability, the user will overwrite the fgets gadget located in puts@GOT. This allows them to leak the Global Offset Table (GOT), providing a libc leak. With the libc leak, the user can construct a traditional ret2libc ROP chain, ultimately achieving remote code execution (RCE).

HackTheBox No Gadgets Description

In a world of mass shortages, even gadgets have gone missing. The remaining ones are protected by the gloating MEGAMIND, a once-sentient AI trapped in what remains of the NSA’s nuclear bunker. Retrieving these gadgets is a top priority, but by no means easy. Much rests on what you can get done here, hacker. One could say too much.

Source Code Analysis

We are given the source code below:

The application is straightforward: it prompts the user for input but reads an excessive amount of data onto the stack, resulting in a classic buffer overflow. It includes a mechanism to check the string length to detect potential overflows. If an overflow is identified, the program exits to prevent main from returning. Otherwise, it completes execution and returns normally.

void setup() { 
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
int main() {
char buf[0x80];
setup(); puts(BANNER);
puts("Welcome to No Gadgets, the ropping experience with absolutely no gadgets!");
printf("Data: ");
fgets(buf, 0x1337, stdin);
if (strlen(buf) > sizeof(buf)) {
puts("Woah buddy, you've entered so much data that you've reached the point of no return!");
exit(EXIT_FAILURE);
}
else {
puts("Pathetic, 'tis but a scratch!");
}
return 0;
}

If you run checksec, you will get the below output:

Arch:     amd64-64-little    
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)

In this scenario, we are working with fairly standard protections, but with two notable exceptions: the absence of a stack canary and Position-Independent Executable (PIE). The lack of PIE simplifies the overflow by making memory addresses predictable. Without the need for shellcode, this setup allows us to focus on leveraging the buffer overflow effectively using techniques like Return-Oriented Programming (ROP) or ret2libc.

Bypass strlen check

The first obstacle is the buffer overflow check, but luckily, it’s a weak implementation. The check relies on the string length, which is calculated as the number of bytes before a null terminator. However, fgets doesn’t prevent us from writing null bytes into the input. By placing a null byte at the beginning of the buffer, we can manipulate strlen to return a value less than 0x80, effectively bypassing the overflow check.

Gadgets Enumeration

Since the program is compiled for glibc versions 2.34 and above, some commonly used functions and gadgets are no longer available. For example, __libc_csu_init pop gadgets like pop rdi ; ret are missing, which prevents us from using the classic ret2plt attack to call puts on an address containing a libc symbol to obtain a leak.

Instead, we must use a different approach to leak libc, requiring alternative ROP gadgets. Among the gadgets identified by tools like ROPgadget, most are not very useful, except for leave ; ret. This gadget is often found at the end of functions (such as main in this case) and facilitates switching between stack frames. The leave instruction restores the old rbp (saved base pointer) from the stack, effectively adjusting the stack pointer (rsp) to the previous base pointer (rbp) and then performing a ret.

This behavior makes leave ; ret an invaluable tool for stack pivoting, allowing us to transition execution to a controlled stack frame, which is critical for crafting a successful ROP chain in this scenario.

mov rsp, rbp 
pop rbp

In this context, rbp points to the saved base pointer. When the leave instruction executes, it sets rsp to the value stored in rbp, effectively moving the stack pointer to the location of the saved base pointer.

The next instruction, ret, pops the value at the top of the stack (now pointed to by rsp) into rbp, restoring the base pointer of the previous stack frame. Following this, the ret instruction pops the next value (the return address) off the stack and jumps to that address, continuing execution from the previous context.

This sequence allows for stack pivoting in exploitation scenarios, where control over the saved base pointer and stack contents enables an attacker to redirect execution to crafted payloads or ROP chains. It’s particularly useful in scenarios where stack adjustments are necessary to transition between different controlled stack frames.

The rbp Register

The rbp register is indeed used to track the base of the current stack frame, making it easier to reference local variables and function arguments. When examining disassembled code, you can observe this in how rbp is set up and used within a function.

Here’s how it works in typical function prologues and epilogues:

Prologue:

  • push rbp: The current base pointer is saved onto the stack.
  • mov rbp, rsp: The stack pointer (rsp) is copied into rbp, establishing the base of the new stack frame.

At this point, rbp acts as a stable reference for accessing local variables (via negative offsets, e.g., rbp - 0x10) and function arguments (via positive offsets, e.g., rbp + 0x8).

Variable Access:

  • Variables and arguments are referenced relative to rbp. This is clear in assembly instructions like mov eax, [rbp-0x10], where a value is retrieved from a specific offset relative to the base pointer.

Epilogue:

  • mov rsp, rbp: The stack pointer is restored to the base of the current frame, effectively discarding any local variables.
  • pop rbp: The saved base pointer is popped back into rbp, restoring the stack frame of the caller.

This standard usage of rbp provides structure to stack frames, making it easier for compilers and debuggers to manage and analyze function calls and variable storage. In exploitation scenarios, manipulating rbp allows attackers to influence stack layout and control the flow of execution.

Creating The Exploit

In this scenario, the address of the buffer is relative to rbp, as would be other variables if they existed. During the buffer overflow, not only is the return address overwritten, but also the saved base pointer, which is subsequently loaded into rbp during the execution of the leave instruction. This gives us control over rbp. Additionally, we have a gadget that writes arbitrary data to the buffer at [rbp-0x80], allowing us to achieve arbitrary writes when combined with our ability to control rbp.

What to Overwrite?

Given that we don’t have a memory leak initially, we are restricted to modifying the binary’s writable memory. Fortunately, the binary uses Partial RELRO, meaning the GOT (Global Offset Table) is writable. This makes the GOT an excellent target for exploitation.

Targeting strlen@GOT

The strlen@GOT entry is particularly attractive for a few reasons:

  1. It’s a writable entry in the GOT, thanks to Partial RELRO.
  2. strlen takes a single argument—our controlled buffer—making it straightforward to exploit.

There are two viable approaches for overwriting strlen@GOT:

Redirect strlen@GOT to puts@PLT:

  • Overwrite strlen@GOT to point to puts@PLT.
  • Point the buffer (via rbp) to the address of a GOT entry (e.g., strlen@GOT).
  • This setup would call puts(strlen@GOT), leaking its address.

Redirect strlen@GOT to printf@PLT:

  • Overwrite strlen@GOT to point to printf@PLT.
  • Point the GOT entry of strlen@GOT to a controlled format string stored in the buffer.
  • This setup allows us to construct a format string exploit to leak memory.

Gaining a libc Leak

Once we’ve leaked an address from libc, resolving its base address is straightforward. With a known libc version, the offsets of standard functions like system, /bin/sh, or ROP gadgets (like pop rdi; ret) become accessible.

Performing the ret2libc

With the libc leak in hand:

  1. Use the pop rdi; ret gadget (available in libc) to set up the first argument for system.
  2. Pass the address of /bin/sh in libc as the argument.
  3. Chain the call to system to execute a shell.

This approach allows for a clean and effective exploitation path using the writable GOT, a libc leak, and a standard ret2libc payload.

Arbitrary Writes

To achieve an arbitrary write onto strlen@GOT, the key is leveraging the fgets gadget, which writes data to [rbp-0x80].

Step 1: Set rbp and rip in the Initial Overflow

In the initial overflow:

Overwrite rbp:

  • Set rbp to point to a location such that [rbp-0x80] aligns with the target address (strlen@GOT).
  • Specifically, rbp = &strlen@GOT + 0x80.

Overwrite rip:

  • Redirect rip to the address of the fgets gadget.

This setup ensures that when fgets executes, it writes input to [rbp-0x80], which effectively writes to strlen@GOT.

Step 2: Control Input to Overwrite strlen@GOT

When the program executes the fgets gadget:

  1. The user-provided input is written directly to [rbp-0x80], which corresponds to strlen@GOT based on the setup above.
  2. This allows you to overwrite the strlen@GOT entry with the address of a desired function, such as puts@PLT or printf@PLT.

Step 3: Prepare for the Next leave ; ret

After the fgets gadget, the program will continue through the function and eventually encounter another leave ; ret. To exploit this:

  1. The stack pointer (rsp) is updated to the value stored in rbp (set during the initial overflow).
  2. The new rbp and rip values are popped from the stack.

At this stage:

  • Use the 0x80-byte offset from your controlled input buffer to overwrite the next rbp and rip.
  • Set rbp to control the next stack frame and rip to point to a new gadget or desired code execution flow.

Step 4: Leaking libc

With strlen@GOT now overwritten (e.g., redirected to puts@PLT):

  1. Provide input that points to strlen@GOT.
  2. The program calls puts(strlen@GOT) or equivalent, leaking the libc address of strlen.

Resolve the libc base address using the known offset of strlen within the loaded libc version.

Step 5: Prepare a ret2libc Payload

After obtaining the libc leak:

  1. Use the next leave ; ret to transition to a new stack frame containing the ret2libc payload.
  2. Craft the payload to:
  • Use pop rdi; ret (available in libc) to set up the /bin/sh string argument.
  • Call system to execute a shell.

HOWEVER

When using the fgets gadget to overwrite strlen@GOT at [rbp-0x80], the leave ; ret sequence expects valid values for both:

  1. A saved rbp: This value will be popped into the rbp register.
  2. A return address (rip): This value will dictate where the program jumps after executing leave.

If these values are invalid or unaligned, the program will crash when the next leave ; ret executes. To avoid this, we must provide:

  • A fake saved rbp to set up the next stack frame.
  • A valid rip pointing to our next gadget or payload.

THEREFORE

Before writing to strlen@GOT, perform an initial write to 0x404080 (or any other writable location) to set up a fake stack frame.

Write to 0x404080:

  • Use the fgets gadget to write controlled values to 0x404080, which will act as our fake stack frame.

Include:

  • A fake rbp pointing to another controlled memory location.
  • A fake rip pointing to the address of the next gadget or function (e.g., main or another leave ; ret).

Set rbp for the Second Write:

  • During the second use of the fgets gadget, set rbp to 0x404080 + 0x80.
  • This aligns the fake stack frame you created with the expectations of the leave ; ret gadget.

Proceed with the Exploit:

  • After the second write, the program will hit leave ; ret again.
  • This time, it will use the fake stack frame at 0x404080, smoothly transitioning execution to the next stage of the exploit.

Complete Exploit Code

The script provided below effectively leaks the libc base address. Here’s how it works: we overwrite puts@PLT (which isn’t called directly). Instead, the setup redirects strlen@GOT to printf@PLT, chosen because it uses a significant amount of stack space. Once the GOT is overwritten, strlen points buf to puts@GOT, which now contains puts@PLT + 6.

When strlen is invoked, it triggers puts, resolving puts@GOT just in time for the leak. This allows buf to now point to puts@LIBC. Additionally, there’s no need to worry about the return value exceeding 0x80, as puts outputs the number of bytes printed—7 in this case (6 bytes for the leaked address and 1 byte for the newline).

#!/usr/bin/python3
from pwn import *
# Load the vulnerable binary and associated libc
binary = ELF("./vuln")
libc = ELF("./libc.so.6")
# Start the process
process_instance = binary.process()
# Define gadgets and addresses
leave_ret_gadget = binary.sym.main + 157
fgets_gadget = binary.sym.main + 68
high_address = 0x404800
# First payload: Trigger overflow and move RSP to a higher memory address
initial_payload = b"\x00".ljust(0x80, b"A") # Fill buffer
initial_payload += p64(high_address + 0x80) # Set RBP to controlled memory
initial_payload += p64(fgets_gadget) # Return to fgets gadget
# Send the payload
process_instance.sendlineafter(b"Data: ", initial_payload)
process_instance.recvline()
# Second payload: Prepare RBP and RIP, ensuring RSP is moved to prevent overflow
overflow_payload = b"\x00".ljust(0x80, b"A") # Fill buffer
overflow_payload += p64(0x404080 + 0x80) # New RBP address
overflow_payload += p64(fgets_gadget) # Return to fgets gadget
overflow_payload += p64(0x404000 + 0x80) # High memory address for RBP
overflow_payload += p64(fgets_gadget) # Return to fgets gadget again
# Send the second payload
process_instance.sendline(overflow_payload)
process_instance.recvline()
# Attach GDB for debugging
gdb.attach(process_instance)
# Third payload: Fake RBP and RIP setup
fake_rbp_rip = p64(0xdead) + p64(0xbeef) # Placeholder RBP and RIP
fake_rbp_rip = fake_rbp_rip.ljust(0x80, b"A") # Fill buffer
fake_rbp_rip += p64(high_address + 0x90) # Set controlled RBP
fake_rbp_rip += p64(leave_ret_gadget) # Return to leave_ret gadget
# Send the fake RBP/RIP payload
process_instance.sendline(fake_rbp_rip)
process_instance.recvline()
# Final payload: Overwrite function pointers for hijacking
overwrite_payload = p64(binary.plt.puts + 6) # Partial overwrite GOT
overwrite_payload += p64(binary.plt.puts) # Call puts
overwrite_payload += p64(binary.plt.printf + 6) # Call printf
overwrite_payload += p64(binary.plt.fgets + 6) # Call fgets
# Send the overwrite payload
process_instance.sendline(overwrite_payload)
# Leak libc address using puts
libc_leak = u64(process_instance.recv(6) + b"\x00\x00") # Receive leak
log.info(f"puts: {hex(libc_leak)}") # Log puts address
# Calculate libc base address
libc.address = libc_leak - libc.sym.puts
log.info(f"libc: {hex(libc.address)}") # Log libc base address
# Switch to interactive mode
process_instance.interactive()

You can also watch:

--

--

Motasem Hamdan

Motasem Hamdan is a content creator and swimmer who creates cyber security training videos and articles. https://www.youtube.com/@MotasemHamdan