Intended chain (six links)
| # |
From → To |
Class |
Location |
| 1 |
unauth → recon |
IDOR / user enumeration |
public/api/user.php |
| 2 |
recon → service acct |
Auth bypass via PHP type juggling (magic hash) |
public/api/token.php |
| 3 |
service → admin |
Second-order blind SQLi |
write public/api/views.php, sink public/api/views.php (GET) |
| 4 |
admin → API key |
Path traversal (recursive ../ strip + decode-after-filter) |
public/admin/report.php |
| 5 |
admin → internal reach |
SSRF to a loopback-only service |
public/admin/probe.php |
| 6 |
internal → RCE |
Filtered OS command injection |
internal/diag.php (127.0.0.1:9077) |
Stage 1 — IDOR user enumeration
/api/user.php?id= returns any user’s card by numeric id with no authorisation. The
service account svc_monitor is deliberately not listed on /team.php
(which shows humans only), so enumerating ids 1…N on this API is the only way to
learn it exists (role: service). The decoy /api/profile.php looks similar but is
correctly scoped to the caller’s own session (no id parameter to tamper with).
Stage 2 — magic-hash auth bypass
token.php compares md5($pass) to the stored password_md5 with loose ==.
svc_monitor’s seeded password 240610708 has md5 0e462097… (all-digit “0e”
form). Any password whose md5 is also 0e-form — e.g. QNKCDZO → 0e830400… —
makes PHP compare both as float 0, so == is true. Verified the spray lands on
only svc_monitor; admin and the decoy svc_backup (ordinary hash) reject it.
Stage 3 — second-order blind SQLi → admin session token
POST /api/views.php stores a saved view (name, severity) through a prepared
statement — the write path is safe, and this is where students expect (and fail to
find) the injection. GET /api/views.php then reads each stored view back and folds
the stored severity straight into a count query:
$sql = "SELECT COUNT(*) c FROM alerts WHERE severity = '" . $v['severity'] . "'";
So the injection is second order: stored safely, fired later in a different code
path. The count is a blind boolean oracle (PDO is SILENT — a broken query yields 0).
Store a payload, then read it back:
severity = zzz' OR (SELECT substr(token,i,1) FROM sessions WHERE user_id=1)='c
zzz matches no alerts, so the count is 8 (all rows) when the char matches and 0
otherwise. Extract the admin session token — now 64 hex chars — a character at a
time, set cookie vs=<token> → admin. The old first-order sink /api/alerts.php is
now a prepared decoy (the dashboard still points students at it).
Stage 4 — path traversal → .env → API key
report.php strips ../ recursively (so ....// no longer survives), then runs
its own urldecode() on the result. Encode the traversal twice: ?f=..%252f.env
→ the app decodes once to ..%2f.env (no literal ../ for the filter to catch) →
its urldecode() turns %2f into / → ../.env, read from app/reports/ up into
app/.env, yielding VANTAGE_API_KEY=….
Stage 5 — SSRF → the loopback diagnostics collector
The v2 diagnostics collector is not on the public web server. It is an internal
service bound to 127.0.0.1:9077 (shown in the admin console’s Internal
services registry). admin/probe.php fetches an arbitrary URL server-side (“probe a
host’s health endpoint”), so point it at the collector:
url = http://127.0.0.1:9077/diag.php?key=<VANTAGE_API_KEY>&tool=ping&target=<inject>
The collector takes the API key as a query param (it is loopback-only), so the
GET-based SSRF carries it.
Stage 6 — filtered OS command injection → RCE
internal/diag.php runs shell_exec("$tool $target 2>&1"). diag_filter rejects
space, ; & | ` $( ${ ( ) { } < > \ " ' ! * ? ~ # and the words
cat tac nc ncat bash zsh exec eval /bin /etc /dev wget curl python perl ruby php base64 xxd env proc. Bypass with a newline (%0a) as the command separator and
a tab (%09) for spaces, using a non-blocked reader:
target = 127.0.0.1%0ahead%09/var/www/local.txt
local.txt (/var/www/local.txt, www-data-readable) comes back in the JSON
output field. The filter deliberately blocks every common reverse-shell primitive
(nc, bash, python, /bin, redirection, pipes, $, backticks), so the intended
proof of host RCE is arbitrary command execution + reading local.txt, not a
connect-back shell.