Difficulty: 🟢 Apprentice
Goal:
- 🔍 Log in as
wiener/peter— go to My account — observe the URL is?id=wienerand the page contains a password change form with the current password pre-filled in a masked<input type="password">field - ✏️ Change
?id=wiener→?id=administrator— the IDOR gives you the admin's account page — the pre-filled password field contains the administrator's actual password in the HTML source - 🔑 Read the password from the response → log in as
administrator→ go to the admin panel → deletecarlos→ lab solved!
🧠 Concept Recap
IDOR with password disclosure chains two mistakes into a critical vulnerability. The first is the familiar IDOR: the account page uses
?id=to decide whose account to show, with no ownership check. The second is a dangerous UI pattern: the password change form pre-fills the current password into a masked<input type="password">field — as a convenience so the user doesn't have to re-type it. This seems harmless because the field is visually masked in the browser. But the password value is in the raw HTML — fully readable in the page source or Burp's response view. Combine both mistakes and any user can read any other user's plaintext password by changing one URL parameter.
📊 The Three IDOR Variants in This Series — What's Stolen
════════════════════════════════════════════════════════════════════
🔬 LAB 5 vs LAB 10 vs LAB 11 — IDOR COMPARISON
════════════════════════════════════════════════════════════════════
IDOR Ownership What's Severity
Surface Check Exposed
─────────────────────────────────────────────────
Lab 5 : ?id= ❌ None — API key 🟠 High
username 200 always
Lab 10 : ?id= ⚠️ Partial— API key 🟠 High
username 302 issued
but body
leaked
Lab 11 : ?id= ❌ None — Plaintext 🔴 Critical
username 200 always password
════════════════════════════════════════════════════════════════════
Why Lab 11 is the most severe:
→ Lab 5/10: steal an API key → access that user's API
→ Lab 11: steal the plaintext password → log in AS the user → full account control
→ If the target is an administrator: full site control, ability to delete users
The horizontal escalation (wiener → administrator) immediately becomes vertical:
→ Reading administrator's password = vertical privilege escalation
→ From regular user to admin with one parameter change
→ This is the "horizontal to vertical" escalation patternThe full attack flow:
Log in as wiener → /my-account?id=wiener
→ Page shows: username, email, password change form
→ Password change form: <input type="password" value="peter">
→ The current password is IN the HTML — just visually masked
Change: ?id=wiener → ?id=administrator
→ IDOR: server fetches administrator's record with no ownership check
→ Page renders with admin's data:
<input type="password" value="[admin-password-here]">
→ Raw HTML has the password - fully visible in Burp response
Copy password from response
→ Log in as administrator
→ Navigate to /admin → delete carlos → lab solved ✅🛠️ Step-by-Step Attack
🔧 Step 1 — Log In and Inspect Your Own Account Page
- 🌐 Click "Access the lab"
- 🔌 Ensure Burp Suite is running and proxying traffic
- 🖱️ Click "My account" → log in with
wiener/peter - 🖱️ Observe the URL:
https://YOUR-LAB-ID.web-security-academy.net/my-account?id=wiener5. 🖱️ On the page, find the password change form — notice the current password field appears filled (masked as ••••••)
Key observations on your own account page:
→ URL: ?id=wiener — same IDOR surface as Labs 5 and 10
→ Password field: visually masked, but the VALUE is in the HTML
In Burp → Proxy → HTTP history → find GET /my-account?id=wiener
Look at the Response body - search for "password":
<input required type="password" name="password" value="peter">
Confirmed:
→ The current password "peter" is in the HTML as a plaintext value attribute
→ The browser masks it visually (<input type="password"> hides the text)
→ But the value is fully readable in the source and in Burp's response view
→ This is the second vulnerability - pre-filling passwords in HTML🔧 Step 2 — Send to Repeater and Change the id Parameter
- 🖱️ In Burp → Proxy → HTTP history → find
GET /my-account?id=wiener - 🖱️ Right-click → "Send to Repeater"
- 🖱️ In Repeater, change
id=wiener→id=administrator:
GET /my-account?id=administrator HTTP/1.1
Host: YOUR-LAB-ID.web-security-academy.net
Cookie: session=WIENER-SESSION4. 🖱️ Click "Send"
Expected result:
→ 200 OK — the administrator's account page renders with no error
→ No ownership check fires — the server fetches whichever user ?id= specifies
→ The page is populated with administrator's data🔧 Step 3 — Find the Administrator's Password in the Response
- 🖱️ In Burp Repeater → Response tab → use
Ctrl+Fto search forpassword
You will find:
<input required type="password" name="password" value="[administrator-password]">
The value attribute contains the administrator's actual current password in plaintext.
Why it's there:
→ The account page pre-fills the password change form with the current password
→ This "convenience" feature is dangerous because:
1. The password is placed in the HTML as a value attribute
2. The <input type="password"> only masks the visual display
3. Anyone who can view the source - or intercept the response - reads the password
4. Combined with IDOR: ANY user can request ANY user's account page
5. Result: any user can read any other user's password2. 🖱️ Copy the administrator's password from the value="..." attribute
🔧 Step 4 — Log In as Administrator
- 🖱️ Log out of wiener's account (or open a new tab)
- 🖱️ Navigate to
/login→ log in with:
- Username:
administrator - Password: (the value you copied from the response)
Result:
→ Logged in as administrator ✅
→ Admin panel link now visible in the navigation🔧 Step 5 — Delete Carlos
- 🖱️ Navigate to the admin panel (
/admin) - 🖱️ Find
carlosin the user list → click "Delete"
Result:
→ carlos is deleted ✅
→ Lab solved! 🎉🎉 Lab Solved!
✅ Observed ?id= IDOR surface on /my-account
✅ Confirmed own password pre-filled in HTML value attribute (not just visually masked)
✅ Changed ?id=wiener → ?id=administrator → admin's page rendered with no check
✅ Found administrator's password in <input type="password" value="...">
✅ Logged in as administrator → deleted carlos
✅ Lab complete!🔗 Complete Attack Chain
1. Log in as wiener → /my-account?id=wiener → inspect own page
→ Find: <input type="password" value="peter"> in HTML source
2. Repeater: change ?id=wiener → ?id=administrator → Send
→ 200 OK - admin's page rendered, no ownership check
3. Response: <input type="password" value="[admin-password]">
→ Copy the password value
4. Log in as administrator with copied password
5. Admin panel → delete carlos → lab solved🧠 Deep Understanding
Why <input type="password"> doesn't protect the value
<input type="password"> only affects the BROWSER'S RENDERING:
→ Characters are masked visually: "peter" displays as "•••••"
→ The purpose: shoulder-surfing protection (stop people behind you reading it)
What it does NOT do:
→ It does NOT encrypt the value
→ It does NOT prevent the value from being in the HTML
→ It does NOT stop the browser DevTools from showing the value
→ It does NOT stop Burp/curl from reading the response
Three ways anyone can read it:
1. Right-click → "Inspect element" → see value="peter" in the DOM
2. Ctrl+U (View Source) → find value="peter" in the raw HTML
3. Burp Repeater → read the raw response → find value="peter"
The masking is purely cosmetic - it's a UI feature, not a security feature.
Never put plaintext passwords into HTML value attributes, masked or not.Horizontal → Vertical: the escalation chain
This lab demonstrates "Horizontal to Vertical Privilege Escalation":
Step 1 - Horizontal escalation:
→ wiener reads administrator's account page (via IDOR)
→ wiener and administrator are "peers" in the sense that both are registered users
→ Accessing administrator's account is horizontal escalation
Step 2 - Vertical escalation:
→ wiener uses the stolen password to log IN as administrator
→ administrator has admin privileges (can delete users, access /admin)
→ Now wiener effectively has admin access → this is vertical escalation
The IDOR enabled the horizontal step.
The password disclosure enabled the vertical step.
Neither alone is as severe as the chain.
This pattern appears in real engagements:
→ Find IDOR on account pages → steal password → log in → become admin
→ Or: find IDOR → steal email → use "forgot password" with that email → account takeoverThe "convenience feature" danger
Why do developers pre-fill the password field?
→ UX convenience: user already knows their password, why make them type it again?
→ Common on "change password" forms that require: current password + new password
→ Developer thinks: "I'll just pre-fill the current password — saves a step"
Why this is catastrophic:
→ The password must travel from the server to the browser in the HTML
→ It is in the HTML response as plaintext
→ Any IDOR that exposes the account page exposes the password too
→ Even without IDOR: a shared computer, browser autocomplete, or XSS can steal it
Secure alternatives:
→ Do NOT pre-fill the current password field - leave it empty
→ Require the user to type their current password manually
→ The current password field exists to verify the user knows their own password
→ If the server pre-fills it, that verification becomes meaningless
→ The field is just cosmetic at that point - any attacker can remove it from the requestComparing the three IDOR labs — escalating consequences
Lab 5 — API key theft:
→ Attacker steals carlos's API key
→ Can use the API as carlos
→ carlos can revoke the API key → attacker loses access
→ Relatively limited impact if API key is rotatable
Lab 10 - API key in redirect:
→ Same impact as Lab 5
→ Added lesson: partial fixes can still leak data
Lab 11 - Password theft:
→ Attacker steals administrator's plaintext password
→ Can log in as administrator permanently (until password changed)
→ If target reuses the password elsewhere → multiple accounts at risk
→ Attacker can change the password → lock out the real admin
→ Full site control: delete users, read all data, change all settings
→ Password theft is significantly more durable than API key theft
Impact chain:
Lab 5 (Low-Medium) < Lab 10 (Low-Medium) < Lab 11 (Critical)🐛 Troubleshooting
Problem: ?id=administrator returns a different user's page or 404
→ Try the exact string "administrator" (all lowercase, no spaces)
→ This lab uses usernames as IDs — "administrator" is the admin's username
→ If 404: confirm the lab loaded fresh and administrator account exists
Problem: Can't find the password in the response
→ In Burp Repeater → Response tab → use Ctrl+F → search: value="
→ Or search: type="password"
→ The input should look like: <input type="password" name="password" value="...">
→ Alternatively: right-click the rendered page → "View Page Source" → Ctrl+F → "password"
Problem: I can see the field but value="" is empty
→ This means the server is NOT pre-filling it for admin - unlikely given the lab design
→ Ensure you're looking at ?id=administrator response, not ?id=wiener
→ Try using "Show response in browser" in Repeater → right-click → Inspect → find the input
Problem: Log in as administrator fails with the copied password
→ Copy the value carefully - no extra quote characters from the HTML attribute
→ The value attribute is: value="PASSWORD_HERE" - copy only PASSWORD_HERE
→ Passwords are case-sensitive - copy exactly including any special characters
Problem: Admin panel doesn't show "Delete" for carlos
→ Ensure you're logged in as administrator (check the nav - should say "Admin panel")
→ Navigate to /admin directly to confirm admin access
→ carlos may already be deleted if the lab was previously used - reset the lab💬 In One Line
🔑 The account page used ?id= to decide whose data to show (IDOR), and the password change form pre-filled the current password into a masked HTML input — readable in the response source — so changing ?id=administrator handed over the admin's plaintext password and full site control. That's IDOR with password disclosure — two UI mistakes chained into horizontal-to-vertical privilege escalation.
🔒 How to Fix It
// Priority 1 — NEVER pre-fill plaintext passwords in HTML
// The password field should always be empty — the user types it manually
// ❌ BAD - password pre-filled in HTML (plaintext in response):
res.render('account', {
username: user.username,
email: user.email,
password: user.password // ← sent to browser in HTML
});
// Template: <input type="password" name="password" value="<%= password %>">
// ✅ GOOD - password field left empty:
res.render('account', {
username: user.username,
email: user.email
// password NOT sent to browser at all
});
// Template: <input type="password" name="password" value="">
// User types their current password when they want to change it
// Priority 2 — Fix the IDOR: derive user identity from session, not ?id= parameter
// ❌ BAD - trusts client-supplied id:
app.get('/my-account', requireLogin, (req, res) => {
const user = db.getUserByUsername(req.query.id); // attacker controls this
res.render('account', { user });
});
// ✅ GOOD - identity from server-controlled session:
app.get('/my-account', requireLogin, (req, res) => {
const user = db.getUserByUsername(req.session.username); // server controls this
res.render('account', { user });
// No ?id= parameter at all - no IDOR surface
});
// Priority 3 — If ?id= must exist (e.g., for admin viewing other accounts),
// enforce ownership check before rendering
app.get('/my-account', requireLogin, (req, res) => {
const requestedId = req.query.id;
const sessionUser = req.session.username;
if (requestedId !== sessionUser && req.session.role !== 'admin') {
return res.status(403).send('Forbidden');
}
const user = db.getUserByUsername(requestedId);
// Even if admin views another account: STILL don't send password in response
res.render('account', { username: user.username, email: user.email });
// password field always empty in the template
});
// Priority 4 — Never store or transmit plaintext passwords
// Passwords should be hashed (bcrypt/argon2) — the plaintext should not exist
// If the DB only has a hash, there's nothing to leak even if IDOR exists
// Secure password storage:
const bcrypt = require('bcrypt');
async function createUser(username, password) {
const hash = await bcrypt.hash(password, 12); // store hash only
db.insert('users', { username, password_hash: hash });
// plaintext 'password' is never stored → can never be returned in HTML
}
// At login:
async function verifyPassword(username, attemptedPassword) {
const user = db.getUser(username);
return bcrypt.compare(attemptedPassword, user.password_hash);
// compare hash → never need to reveal original password
}👏 If this helped you — clap it up (you can clap up to 50 times!)
🔔 Follow for more writeups — dropping soon
🔗 Share with your pentest team
💬 Drop a comment