Category: Deep Dives

Long form technical deep dives into one mechanism at a time: cloud, kernel, IoT, and privacy internals.

  • Node.js Deserialization: When a Serializer Revives Functions

    Node.js Deserialization: When a Serializer Revives Functions

    JavaScript objects are easy to turn into text with JSON.stringify and back with JSON.parse, so most developers assume every serializer in the ecosystem is just as safe. That assumption is where node.js deserialization bugs come from. A handful of serialization libraries go beyond JSON and can revive function bodies from the text they parse. When the input is attacker controlled, reviving a function means running attacker code on load.

    How node.js deserialization can run attacker code

    JSON has no way to represent a function. It stores strings, numbers, booleans, null, arrays, and objects, and that limit is a safety feature. Some libraries added functions back in so you could serialize a richer object, including its methods. To do that, they write the function source into the text, and on the way back they turn that source into a live function again.

    The part that makes this a code execution bug is how some of these libraries revive a function. The classic unsafe pattern is a serialized value that holds a function expression, which the library wraps and evaluates. A well known shape is the immediately invoked function expression, a function that calls itself the moment it is created:

    {"rce":"_$$ND_FUNC$$_function(){ require('child_process').exec('id'); }()"}

    The trailing () is the trick. When the library rebuilds this value, it evaluates the function source, and because the function invokes itself right away, the body runs during deserialization. The marker at the front is how one popular serialize library tags a value as a function; the details differ between libraries, but the shape is the same.

    JSON cannot describe a function, and that limit is the safety. A library that adds functions back has turned its parser into an evaluator.

    A vulnerable endpoint

    Picture an invented service, Acme Tasks, that stores a task object in a cookie and rebuilds it on each request using a function reviving deserializer:

    const serialize = require('node-serialize');
    
    app.get('/dashboard', (req, res) => {
      const task = serialize.unserialize(req.cookies.task);  // revives functions
      res.send(renderDashboard(task));
    });

    An attacker sets the task cookie to the payload above. On the next request the server calls unserialize, the embedded function runs itself, and the attacker has command execution. Nothing in the route looks wrong. The sink is the deserializer, and the route just feeds it untrusted bytes. To see why a loader that rebuilds behavior is always the risk, read the primer on what is insecure deserialization, and for how attackers compose small trusted pieces into a working exploit, see what is a deserialization gadget chain.

    Why this is the same bug as in other languages

    The surface looks different from a .NET or Python case, but the root cause is identical: the serialized format is allowed to carry behavior, not just data, and the loader faithfully rebuilds that behavior. In PHP, an unserialize call rebuilds objects whose lifecycle methods then fire, which is covered in php object injection. In Node the behavior is even more direct, because the payload can carry a function outright. Once you have seen two or three of these, the lesson is about the feature, a format that encodes code, not about any one language, which is why it sits in the wider injection and input category.

    The fix: parse data, do not revive behavior

    The rule is simple. For anything that crosses a trust boundary, use a deserializer that can only produce data.

    • Use JSON.parse for untrusted input. It cannot produce a function, so there is no path from the text to execution. If your data is plain values and structures, JSON is all you need.
    • Do not use a function reviving serializer on data from users. Libraries that serialize and revive functions are fine for trusted, internal data you fully control, and dangerous the moment the input can be influenced from outside.
    • Validate the parsed object. Even after JSON.parse, check that the shape matches what you expect with a schema validator. This stops a different class of problem, unexpected fields and types, though it is not what stops code execution. The code execution is stopped by using a parser that cannot build functions in the first place.

    The corrected Acme Tasks route reads like this:

    app.get('/dashboard', (req, res) => {
      let task;
      try {
        task = JSON.parse(req.cookies.task);   // data only, no functions
      } catch (e) {
        return res.status(400).send('bad task');
      }
      res.send(renderDashboard(validateTask(task)));
    });

    Now the worst a cookie can carry is malformed JSON, which the try block catches, or a well formed object with wrong values, which validateTask rejects. There is no function marker the parser will honor, so the immediately invoked function trick has nothing to latch onto.

    A note on eval and its relatives

    The same danger hides in plain sight whenever untrusted text reaches eval, the Function constructor, or vm module calls. A function reviving serializer is just one library that wraps these under a friendly name. If you are hunting for this bug, treat every one of those as a possible sink, not only named serialization libraries.

    How to find it in a codebase

    • Search for serializer calls that are not JSON.parse, such as unserialize, and for libraries whose docs mention serializing functions.
    • Grep for eval(, new Function(, and vm.runInContext, then trace their input backward.
    • For each sink, follow the argument back to its source. Can it come from a request body, a query string, a cookie, a header, or a message on a queue? If yes, it is a live finding.
    • Check your dependencies, since a package you trust may call a function reviving deserializer on data you forwarded to it.

    The hard part is never spotting the function of unserialize on one line. It is proving that an attacker controlled value, from a cookie or a body, actually reaches it through the middleware and helpers in between. That source to sink reasoning about untrusted bytes arriving at an unsafe loader is exactly what UnboundCompute is built to do. Read more on our about page.

    Frequently asked questions

    How can deserializing a JavaScript object run code?

    JSON cannot represent a function, and that limit is a safety feature. Some serialization libraries add functions back by writing the function source into the text and turning it into a live function on the way back. If the serialized value holds a function that invokes itself, the body runs during deserialization, so an attacker who controls the input gets command execution.

    What is the immediately invoked function trick in these payloads?

    The payload stores a function expression followed by a pair of parentheses, so the function calls itself the moment it is created. When the library revives the value it evaluates the source, and the trailing parentheses make the body run right away. A marker at the front tells the library to treat the string as a function rather than plain text.

    Is JSON.parse safe for untrusted input?

    Yes, for the code execution risk. JSON.parse can only produce strings, numbers, booleans, null, arrays, and objects, so there is no path from the text to a running function. You should still validate the parsed object against a schema to reject unexpected fields and types, but that is a separate concern from stopping code execution.

    Where else does this bug hide in Node.js?

    Anywhere untrusted text reaches eval, the Function constructor, or vm module calls. A function reviving serializer is just one library that wraps those under a friendly name. When hunting for the bug, treat every such call as a possible sink, trace its input backward, and check dependencies that may call a function reviving deserializer on data you forwarded.


    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.

  • Ruby YAML Deserialization: When YAML.load Builds Arbitrary Objects

    Ruby YAML Deserialization: When YAML.load Builds Arbitrary Objects

    Ruby yaml deserialization is the trap that catches people who think YAML is just a config format. The phrase ruby yaml deserialization points at Psych, the YAML library bundled with Ruby, and at one default that does more than parse text. The default loader reads special tags in a YAML document and uses them to build live Ruby objects. Feed it a document an attacker wrote, and you are rebuilding objects of their choosing.

    This is the same shape as other native format bugs. If object rebuilding from a byte or text stream is new, read the insecure deserialization primer, and the deserialization gadget chain hub covers how single objects become code execution.

    What ruby yaml deserialization really parses

    YAML looks like plain key and value data, and for most documents it is. But YAML supports tags that name a type, and Psych maps some of those tags to Ruby classes. When the loader sees a tag like !ruby/object:SomeClass, the default behavior is to create an instance of that class and set its instance variables from the document.

    --- !ruby/object:Account
    balance: 100
    owner: alice

    Loading that with the default loader does not give you a hash. It gives you an Account object with balance and owner already set. The attacker picks the class and the values, so the decision about what gets built moves out of your code and into their document.

    The difference between load and safe_load

    Psych exposes two doors, and the names matter.

    • YAML.load with its historical default builds arbitrary objects from tags. On untrusted input, that is the bug.
    • YAML.safe_load parses only plain data types by default: strings, numbers, booleans, arrays, hashes, and nil. It refuses object tags unless you explicitly allow a class.

    Here is the contrast in one place:

    require "yaml"
    
    # dangerous on untrusted input
    obj = YAML.load(untrusted)
    
    # safe: plain data only, object tags rejected
    obj = YAML.safe_load(untrusted)
    
    # safe with a narrow exception
    obj = YAML.safe_load(untrusted, permitted_classes: [Date])

    The permitted_classes option is the allowlist. It lets a small set of known types through and rejects everything else, so a document cannot smuggle in a class you never meant to build. Recent Ruby versions made YAML.load behave like the safe loader by default, but plenty of running code still calls the old unsafe form or pins an older version, so the bug is far from gone.

    Why the classic framework YAML RCE keeps coming back

    Once arbitrary objects are in play, the rest is a gadget chain. The attacker writes a YAML document whose tags build a sequence of objects from classes already loaded in the app, arranged so that a method firing on one reaches a dangerous call in another. This is the shape behind the well known framework YAML remote code execution reports over the years. The Java ecosystem has the exact same pattern, covered in snakeyaml deserialization rce, and the chain building craft carries across languages.

    YAML that can name a class is not a config format any more. It is a program that tells your app which objects to build.

    Ruby’s other native loader has the identical risk with a binary face instead of a text one. See ruby marshal deserialization for the same bug through Marshal.load.

    Where untrusted YAML sneaks in

    YAML feels like something only a developer edits, which is exactly why the input paths get missed:

    • A config or import feature that accepts a YAML file uploaded by a user.
    • An API that takes a YAML body because it was convenient.
    • Cached or queued data stored as YAML and loaded by a worker.
    • Webhook payloads or integration settings pasted in as YAML.

    In an invented app called Acme Notes, a settings importer might do prefs = YAML.load(params[:file].read). That one line turns an upload into object construction under attacker control.

    How to fix ruby yaml deserialization

    • Always use YAML.safe_load on untrusted YAML. This is the rule. It parses plain data and refuses object tags, which removes the thing a chain needs to start.
    • Allowlist classes only when you truly need them. If a document must carry a Date or a symbol, pass permitted_classes with that exact short list, and nothing broader.
    • Do not call the unsafe YAML.load on input you did not produce. Audit for it directly, and treat config files that users can supply as untrusted.
    • Prefer JSON for data you receive. JSON.parse has no concept of object tags, so it cannot build a class at all. When you only need data, a format that only carries data is the smaller target.

    For more bugs where parsing turns into code execution, browse the injection and input category.

    This flaw survives because the dangerous loader looks like a harmless config parse, and the gadgets live in gems you did not write. UnboundCompute reasons about whether attacker controlled YAML can actually reach a YAML.load and build a chain of objects, rather than matching a fixed list of payloads. Read how that assumption testing works on our about page.

    Frequently asked questions

    Why is YAML.load dangerous in Ruby?

    Psych, the YAML library bundled with Ruby, maps certain YAML tags to Ruby classes. The historical default loader sees a tag like !ruby/object:SomeClass and builds a live instance with its instance variables set from the document. On attacker written YAML that means the attacker chooses which objects get built, which is how a gadget chain starts.

    What is the difference between YAML.load and YAML.safe_load?

    YAML.safe_load parses only plain data types by default, such as strings, numbers, booleans, arrays, hashes, and nil, and refuses object tags. The older default YAML.load builds arbitrary objects from tags. Always use safe_load on any YAML a user can influence.

    How do I allow a specific class with safe_load?

    Pass the permitted_classes option with a short, exact list, for example YAML.safe_load(input, permitted_classes: [Date]). That lets only the named types through and rejects every other object tag, so a document cannot smuggle in a class you never meant to build.

    Is newer Ruby safe from YAML deserialization by default?

    Recent Ruby versions made YAML.load behave like the safe loader by default, which helps. But plenty of running code still calls the old unsafe form or pins an older version, so the bug is far from gone. Use safe_load explicitly rather than relying on the version default.


    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.

  • Ruby Marshal Deserialization: Why Marshal.load on Untrusted Data Is RCE

    Ruby Marshal Deserialization: Why Marshal.load on Untrusted Data Is RCE

    Ruby marshal deserialization is the Ruby version of a bug that shows up in every language with a native object format. The phrase ruby marshal deserialization points at one method, Marshal.load, and one mistake: calling it on bytes that came from outside your program. When you do, Ruby rebuilds whatever objects those bytes describe, and that is the opening a gadget chain needs.

    If the idea of rebuilding objects from a byte stream is new, read the insecure deserialization primer first. To see how single objects get chained into code execution, the deserialization gadget chain overview is the hub for this whole topic.

    What ruby marshal deserialization does

    Marshal is Ruby’s built in binary format for objects. Marshal.dump turns an object into a byte string, and Marshal.load turns it back. It preserves the class, the instance variables, and the structure, which is the point: it is meant to round trip real Ruby objects, not plain data.

    data = Marshal.dump({ user: "alice", role: "member" })
    obj  = Marshal.load(data)   # fine when data is yours

    The trouble starts when data comes from a client. Marshal does not only rebuild hashes and strings. It rebuilds objects of any class loaded in the process, setting their instance variables to whatever the byte stream says. The attacker, not your code, decides which classes appear and what they hold.

    Why rebuilt objects lead to code execution

    Rebuilding an object is not harmless, because Ruby objects carry behavior and some of that behavior runs at predictable moments. A gadget chain strings together methods that already exist in Rails, in a gem, or in the standard library. The attacker supplies the instance variables that decide which objects sit at each link, and one of the final links reaches a call like system or an eval buried in a library method.

    The structure is the same as in other languages. Ruby Marshal plays the role that pickle plays in Python, and the comparison is worth reading: see python pickle rce for the same bug with a different native format. The work of building the chain itself is covered in finding deserialization gadget chains.

    Marshal was built to move your own objects between processes. The bug is handing it bytes that someone else chose.

    Where the untrusted bytes get in

    Marshal looks safe in a code review because the dangerous call is short and the data source is often a few layers away. Common paths:

    • A cookie or session store that keeps marshalled objects and loads them on each request.
    • A cache layer, such as a file or memory store, that an attacker can write to.
    • A background job queue whose payloads are marshalled and later loaded by a worker.
    • An API endpoint that accepts a marshalled blob for convenience.

    Consider a cache wrapper in an invented app called Acme Notes:

    def fetch(key)
      raw = @store.get(key)
      raw ? Marshal.load(raw) : nil
    end

    If an attacker can influence what lands in @store under that key, for example through a second bug that writes cache entries, then this innocent looking fetch becomes the sink. The Marshal.load rebuilds their objects and the chain fires.

    How to spot it in a code review

    The call itself is easy to grep for, but a raw search for Marshal.load tells you nothing about whether the bytes are trusted. What matters is the path the data took to get there. Two questions decide the risk:

    • Where did these bytes come from? Trace the argument backward. If it started as a cookie, a request body, a cache entry, or a queue payload, it is attacker reachable and the call is a real sink. If it only ever holds bytes your own process dumped a moment ago, it is fine.
    • Can an attacker write to the store in between? A value that looks internal, like a cache key, becomes untrusted the moment a second bug lets someone write to that store. The loader does not change, but the trust of its input does.

    That second case is why a simple allowlist of safe call sites ages badly. A Marshal.load that was safe last year turns dangerous when a new feature starts writing user data into the same cache. The answer is to follow the data, not the method name.

    How to fix ruby marshal deserialization

    There is no safe mode flag for Marshal. Unlike some formats, it has no option to forbid arbitrary classes, so you cannot make Marshal.load safe on untrusted input. The fix is to not use it there.

    • Never call Marshal.load on data a user can influence. This is the rule. Treat cookies, request bodies, cache entries an attacker can write, and queue payloads as untrusted.
    • Use a data only format instead. For structured data, parse JSON with JSON.parse. It returns plain hashes, arrays, strings, and numbers, with no way to instantiate an arbitrary class, so there is no object to start a chain.
      require "json"
      obj = JSON.parse(raw)   # plain data, no class rebuilding
    • Stop marshalling session data. Configure the session store to serialize as JSON rather than Marshal, so a tampered cookie cannot smuggle objects.
    • Sign any blob that must round trip. If a value has to leave and return, attach an HMAC and verify it before you touch the bytes. If the signature fails, the data never reaches a loader.

    YAML in Ruby has the same trap with a friendlier face, and the default loader there builds arbitrary objects too. That bug, and its safe loader, are covered in ruby yaml deserialization.

    For more bugs where input handling turns against the app, browse the injection and input category.

    This flaw hides because the risky call is one method and the gadgets live in gems you did not write. UnboundCompute reasons about whether untrusted bytes can actually reach a Marshal.load and drive a chain of object behavior, rather than flagging every call it sees. Read how that assumption testing works on our about page.

    Frequently asked questions

    Why is Marshal.load on untrusted data dangerous?

    Marshal is Ruby’s native object format, so Marshal.load does not just rebuild hashes and strings. It rebuilds objects of any class loaded in the process and sets their instance variables to whatever the byte stream says. An attacker who controls those bytes chooses which objects appear, which is the opening a gadget chain needs.

    Can I make Marshal.load safe with an option?

    No. Marshal has no mode that forbids arbitrary classes, so there is no safe way to run Marshal.load on data a user can influence. The only fix is to not use Marshal for untrusted input and to parse a data only format such as JSON instead.

    Where does untrusted data reach Marshal.load in real apps?

    Common paths are session stores and cookies that keep marshalled objects, cache layers an attacker can write to, background job queues whose payloads are marshalled, and API endpoints that accept a marshalled blob. The load call often sits a few layers away from where the bytes first entered.

    How is Ruby Marshal related to Python pickle?

    They are the same class of bug in different languages. Both are native object formats that rebuild arbitrary objects from a byte stream, so calling the loader on attacker data gives a gadget chain a place to start. The defense is the same: use a data only format and never load native serialized data you did not produce.


    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.

  • Phar Deserialization: Object Injection With No unserialize() Call

    Phar Deserialization: Object Injection With No unserialize() Call

    Phar deserialization is the PHP bug that fires with no unserialize() call anywhere in sight. When people study phar deserialization, the surprise is always the same: object injection happens because a file function touched a path, not because the code asked to rebuild an object. The phar:// stream wrapper does the rebuilding quietly, using metadata baked into a PHP archive the attacker planted earlier.

    This is a sibling of plain php object injection, and it rides the same gadget machinery. If the mechanics of rebuilt objects and magic methods are new to you, start with the insecure deserialization primer and the deserialization gadget chain hub, then come back.

    What phar deserialization is and why it hides

    A Phar is a PHP archive, PHP’s version of a self contained package. Every Phar carries a metadata field, and that field is stored in serialized PHP form. Here is the catch. When any filesystem function is given a path that begins with the phar:// wrapper, PHP opens the archive and calls unserialize() on that metadata to read it. The call is inside the engine, so it never appears in the application source.

    That means a line as ordinary as this can be the sink:

    if (file_exists($_GET['path'])) {
        // ...
    }

    If an attacker sets path to phar://uploads/avatar.jpg/x, and they earlier uploaded a crafted Phar disguised as an image, then file_exists() triggers the metadata unserialize. Whatever objects the metadata describes get built, and their magic methods run.

    The file functions that reach the stream wrapper

    A long list of functions accept stream wrapped paths, and most developers never think of them as dangerous. Any of these can start the process when handed a phar:// path:

    • file_exists(), is_file(), is_dir(), and the rest of the stat family.
    • file_get_contents(), fopen(), copy(), and unlink().
    • getimagesize(), which image upload code calls all the time.
    • Even include and require when the path is built from user input.

    The attacker needs two things: a place to drop a file whose bytes form a valid Phar, and a file function that will later read a path they influence. Image uploads make both easy, because a Phar can be prefixed with image bytes and still parse, so it passes a naive content check and sits on disk as avatar.jpg.

    How phar deserialization turns into code execution

    Once the metadata is unserialized, the attack is identical to ordinary object injection. The metadata describes an object of a class that is already loaded, its magic method fires, and that method kicks off a chain that you never wrote to accept outside input. The finding of those chains is its own craft, covered in finding deserialization gadget chains.

    Here is a minimal view of building the malicious archive, for defenders who want to understand the shape of the payload:

    $p = new Phar('evil.phar');
    $p->startBuffering();
    $p->addFromString('x', 'stub');
    $p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
    $p->setMetadata(new Logger());   // an object with a dangerous __destruct
    $p->stopBuffering();

    The GIF89a prefix makes the file look like an image to a quick check. The real work is in setMetadata(), which stores the serialized gadget object. When a victim app later does file_exists('phar://.../evil.phar/x'), that object is rebuilt and its __destruct() runs.

    The dangerous call is not in your code. It is in a file function you trusted, the instant it was handed a path that started with phar.

    Spotting it in a codebase

    Grep will not save you here, because there is no unserialize() to find. The real question is a data flow one: can user input reach the path argument of a file function? Look for:

    • Paths built from request data: $_GET, $_POST, route parameters, or headers passed into file calls.
    • Upload handlers that keep the original file on disk and then stat or read it by name.
    • Image libraries fed a filename that came from the client.

    Any of these lets an attacker swap in a phar:// path and point it at a file they control.

    How to fix phar deserialization

    The fixes work at two levels: stop the wrapper, and stop attacker files from ever being reachable.

    • Validate the scheme of user paths. Reject any path that contains phar://, or better, allowlist the exact schemes you expect and refuse the rest. A simple stripos($path, 'phar://') !== false check, applied before the file call, blocks the common case.
    • Do not feed user input into file functions. Resolve uploads by a server generated id, never by a client supplied name or path.
    • Store uploads outside the web root and rename them. Strip the extension, give the file a random name, and serve it through a handler that sets the content type. An archive that cannot be addressed by a predictable path is hard to target.
    • Keep gadget classes out of reach. The same defenses that harden plain object injection apply, since the payload is still a serialized object that needs a usable class to build a chain. Prefer a data only format for anything you control.

    For more bugs where trusted input handling turns against the app, see the injection and input category.

    Phar deserialization is hard to catch by pattern because the sink is invisible and the source is a file path that looks harmless. UnboundCompute reasons about whether attacker input can actually reach a file function with a phar:// path and drive an object chain, rather than scanning for a literal call that is not there. Read how that assumption testing works on our about page.

    Frequently asked questions

    How does phar deserialization work without a call to unserialize?

    Every PHP archive stores a metadata field in serialized form. When a filesystem function is handed a path that begins with the phar:// stream wrapper, the PHP engine opens the archive and unserializes that metadata on its own. The call lives inside the engine, so the application source shows only a normal file function.

    Which PHP functions can trigger a phar deserialization?

    Many file functions accept stream wrapped paths, including file_exists, is_file, file_get_contents, fopen, copy, unlink, and getimagesize, plus include and require when the path comes from user input. Any of them can start the metadata unserialize if an attacker supplies a phar:// path.

    How do attackers plant the malicious Phar?

    A Phar can be prefixed with image bytes such as a GIF header and still parse as a valid archive, so it passes a naive upload check and lands on disk as something like avatar.jpg. Later, a file function that reads a user supplied path can be pointed at it with a phar:// prefix to trigger the payload.

    What is the fix for phar deserialization?

    Reject user paths that contain phar://, or allowlist only the schemes you expect. Do not build file paths from client input, resolve uploads by a server generated id instead, and store uploads outside the web root with random names. These steps keep attacker files unreachable and keep the wrapper from firing.


    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.

  • PHP Object Injection: When unserialize() Builds the Attacker’s Objects

    PHP Object Injection: When unserialize() Builds the Attacker’s Objects

    PHP object injection happens the moment an application calls unserialize() on data an attacker can shape. When you search for php object injection, what you are really asking about is this handoff: a serialized string from a cookie, a hidden form field, or an API body gets turned back into live PHP objects. The attacker never types a line of code. They choose which classes get built and what values the properties hold, and the methods PHP runs on its own do the rest.

    This is one flavor of insecure deserialization, and it is the one PHP developers meet first. To see how single objects get strung together into a full exploit, read the deserialization gadget chain overview once you finish here.

    What php object injection actually is

    PHP can flatten an object into a string with serialize() and rebuild it with unserialize(). The serialized form is plain text that lists the class name, each property name, and each value. Here is a small user object:

    O:4:"User":1:{s:4:"name";s:5:"alice";}

    Read it as: an object of class User with one property, name, set to the string alice. Now picture an app that stores this in a cookie and calls unserialize($_COOKIE['session']) on every request. The attacker controls that cookie. They can hand back a serialized string that names a different class and sets any property to any value they like. PHP will build that object. That is the whole bug.

    The magic methods that fire on their own

    Building an object would be harmless if nothing ran. The problem is that PHP calls certain methods automatically at set points in an object’s life. These are the magic methods, and three of them matter most for this bug:

    • __wakeup() runs the instant an object is rebuilt by unserialize().
    • __destruct() runs when the object is thrown away at the end of the request.
    • __toString() runs when the object is used where a string is expected, such as in a log line or an echo.

    The attacker does not need to write these methods. They only need a class, already loaded somewhere in the app or its libraries, whose magic method does something useful. Consider a logging helper that ships with a project:

    class Logger {
        public $logfile;
        public $data;
        public function __destruct() {
            file_put_contents($this->logfile, $this->data);
        }
    }

    On its own this class is fine. But if an attacker can make unserialize() build a Logger with logfile set to shell.php and data set to a small block of PHP, then the end of the request writes a working web shell to disk. No call to eval, no call to system, just a file write that the author never meant to expose to user input.

    Composing a POP chain out of classes already loaded

    One class with a tidy magic method is a gift. Real targets are rarely that kind. So attackers build a chain. The technique is called property oriented programming, and the idea is to reuse method calls that already exist in the codebase instead of injecting new code.

    It starts with a magic method, say a __destruct() that calls $this->handler->close(). The attacker sets handler to an object of a second class whose close() method does something more interesting, which in turn calls a method on a third object, and so on. Each step is legal code. The attacker only supplies the property values that decide which objects sit at each link. String enough together and the final step reaches a dangerous call. This is exactly the shape covered in pop chains and property oriented programming, and the role of the automatic triggers is spelled out in magic methods in deserialization attacks.

    The attacker never ships code. They ship a data structure that tells objects you already trust to call each other in an order you never intended.

    Because the gadgets live in libraries and framework code, the same chain often works across many apps that share a dependency. A serialized payload built for one project can land on another that pulls in the same package.

    Where the untrusted data sneaks in

    The sink is always unserialize(), but the source hides in ordinary places:

    • Session or preference data kept in a cookie and deserialized on each request.
    • A hidden field in a form that the server round trips through unserialize().
    • An API that accepts a serialized blob because an older client sent one.
    • Cache entries or message queue payloads that an attacker can write to.

    A second path needs no visible unserialize() call at all. The phar:// stream wrapper triggers deserialization of archive metadata when a filesystem function touches an attacker path. That route gets its own treatment in phar deserialization.

    How to fix php object injection

    The fixes are direct, and they stack.

    • Do not call unserialize() on anything a user can influence. This is the rule that closes the bug. If the data came from a client, treat it as hostile.
    • Use a data only format instead. For structured data, json_encode() and json_decode() move plain arrays and values with no class names and no magic methods. JSON cannot build a Logger, so it cannot start a chain.
    • If you must accept serialized PHP, allowlist the classes. Since PHP 7, unserialize() takes an options array:
      $data = unserialize($input, ['allowed_classes' => false]);

      Passing false forbids every class, so the result holds only plain values. If you truly need a few types, name them instead: ['allowed_classes' => ['SafeDTO']].

    • Sign the data if it has to make a round trip. Attach an HMAC that the server checks before it deserializes. If the signature fails, the input never reaches the sink.

    For more bugs that turn trusted input handling against an app, browse the injection and input category.

    This is the kind of flaw that hides behind clean looking code, because the dangerous call is one line and the gadgets live in libraries you did not write. UnboundCompute reasons about whether untrusted input can actually reach an unserialize() call and fire a chain of magic methods, rather than matching a fixed pattern. Read how that works on our about page.

    Frequently asked questions

    What causes PHP object injection?

    It happens when an application calls unserialize() on data a user can control, such as a cookie or form field. PHP rebuilds whatever classes the serialized string names and sets their properties to attacker chosen values. If any rebuilt class has a magic method that does something useful, the attacker gets to steer it.

    Which PHP magic methods are used in these attacks?

    The common ones are __wakeup(), which runs when an object is rebuilt, __destruct(), which runs when the object is discarded at the end of the request, and __toString(), which runs when the object is used as a string. Attackers pick classes whose magic methods reach a dangerous call, then chain them into a POP chain.

    How do I prevent PHP object injection?

    Do not call unserialize() on any input a user can influence. Use json_decode() for structured data instead, since JSON carries no class names. If you must accept serialized PHP, pass allowed_classes set to false so no objects are built, and sign round tripped data with an HMAC the server checks first.

    Is json_decode safe from object injection?

    Yes for this bug. json_decode() produces plain arrays and scalar values and cannot instantiate arbitrary classes, so it cannot fire a magic method or start a gadget chain. That is why moving session and preference data to JSON closes the object injection path that unserialize() opens.


    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.

  • PyYAML Deserialization: Why yaml.load Runs Code

    PyYAML Deserialization: Why yaml.load Runs Code

    YAML looks like a friendly config format, so it is easy to forget that loading it can build real Python objects. Pyyaml deserialization with the default loader does exactly that. If you call yaml.load on text that a user controls, a crafted document can construct objects and call into your code at load time. The fix is small and it is the point of this whole post: use yaml.safe_load.

    How pyyaml deserialization turns text into objects

    YAML has a feature called tags. A tag tells the loader what kind of thing a node is. Most of the time tags are invisible and you just get strings, numbers, lists, and maps. But PyYAML also ships tags that map to Python types. When the full loader sees one of those, it does not return data. It constructs the named object.

    The dangerous tag family starts with !!python/. With the default loader these tags let a document name a Python object, name a callable, and even pass arguments to it. That last part is the whole problem. A document that can call a function with arguments is a document that can run code.

    What a malicious document looks like

    Picture an invented app, Acme Deploy, that reads a pipeline config uploaded by a user and loads it with the default loader:

    import yaml
    config = yaml.load(uploaded_text)   # default loader, unsafe

    An attacker uploads this instead of a normal config:

    steps: !!python/object/apply:os.system
      args: ["id"]

    The !!python/object/apply tag tells the loader to call os.system with the argument "id". The call happens during yaml.load, before your code ever inspects config. As with other loaders of this kind, validating the result afterward does nothing, because the command already ran while the document was being built.

    A tag that names a callable plus its arguments is not configuration. It is a function call written in YAML.

    Real payloads chain these tags to reach a useful sink, importing a module, building an object, then applying a method, which is the same gadget building idea described in what is a deserialization gadget chain. If you are new to this whole class of bug, start with the primer on what is insecure deserialization, which explains why a loader that reconstructs types is a sink no matter the language.

    load versus safe_load

    The difference between the two calls is the set of tags each one understands.

    • yaml.load with the default loader understands the full tag set, including the !!python/ tags that build objects and call callables. On untrusted input this is unsafe.
    • yaml.safe_load understands only the standard YAML tags. It returns plain Python data: dicts, lists, strings, numbers, booleans, and null. It will not construct arbitrary objects and it will not call a function. Given the malicious document above, it raises an error about an unknown tag instead of running the command.

    Newer PyYAML versions made yaml.load warn or require an explicit Loader argument, which nudged people toward safer choices. But plenty of code still passes Loader=yaml.FullLoader or the old unsafe loader, and a loader argument does not help if you pick a loader that still honors the Python tags. The only loader you should point at untrusted input is the safe one.

    The fix: always safe_load untrusted YAML

    The corrected version of the Acme Deploy endpoint is one word different:

    import yaml
    config = yaml.safe_load(uploaded_text)   # only plain data

    Now the uploaded text can only produce ordinary data structures. The !!python/object/apply tag is no longer recognized, so the document that tried to call os.system fails to load and you handle that error like any other bad input. You still validate the shape of the config, check required keys, and reject values out of range, but none of that is about stopping code execution anymore, because the loader can no longer execute anything.

    Make it the default, not a reminder

    • Standardize on safe_load everywhere and treat any call to the unsafe loader as a finding in review. A rule that says “remember to use safe_load” fails the first time someone forgets. A rule that says “the unsafe loader is banned” does not.
    • Check your dependencies. A library you pull in may call the unsafe loader on data that reaches it from your request. Your own code being clean is not enough if a parser you depend on is not.
    • Do not reintroduce the tags. If you register custom constructors, make sure they cannot build callables or import modules. A custom tag that instantiates a class with user supplied arguments is the same bug wearing a different name.

    Why the same shape appears in other YAML libraries

    This is not a Python only quirk. Any YAML library that maps tags to native types has the same design tension, safety versus the convenience of reviving typed objects from a document. The Java ecosystem has the same story, where a YAML parser configured to build arbitrary types from tags becomes a code execution sink on untrusted input. We cover that sibling case in snakeyaml deserialization rce. The load time callbacks that make these objects dangerous, across languages, are the special methods described in magic methods in deserialization attacks. Seeing the pattern once in Python and once in Java makes it obvious it is about the feature, not the language, which is why it sits in the broader injection and input category.

    How to find it in a codebase

    • Grep for yaml.load( and flag every call that does not use safe_load. Include Loader=yaml.Loader, FullLoader, and UnsafeLoader.
    • For each hit, trace the first argument backward. Can the text come from a request body, an uploaded file, a webhook, or a config fetched over the network? If yes, it is a live risk.
    • Look for custom constructors registered with add_constructor that build objects from document values.
    • Audit third party libraries that parse YAML you pass them.

    Spotting the word yaml.load is the easy half. The hard half is proving that attacker controlled text actually reaches that loader through the routes an app really uses. That source to sink reasoning about untrusted bytes arriving at an unsafe loader is exactly what UnboundCompute is built to do. Read more on our about page.

    Frequently asked questions

    Why is yaml.load with the default loader dangerous?

    YAML tags tell the loader what kind of thing a node is, and PyYAML ships tags in the !!python/ family that map to Python types. With the default loader a document can name a callable and pass it arguments, so a tag like !!python/object/apply:os.system runs a command during the load, before your code ever inspects the result.

    What is the difference between yaml.load and yaml.safe_load?

    yaml.load with the default loader understands the full tag set, including the Python tags that build objects and call callables. yaml.safe_load understands only standard YAML tags and returns plain data: dicts, lists, strings, numbers, booleans, and null. Given a malicious document, safe_load raises an unknown tag error instead of running anything.

    Does passing a Loader argument make yaml.load safe?

    Only if you pick the safe loader. Newer PyYAML versions require an explicit Loader, but FullLoader and the unsafe loader still honor dangerous constructs, so the argument alone does not protect you. Point safe_load at untrusted input, and treat any other loader on such input as a finding.

    Can custom YAML constructors reintroduce the bug?

    Yes. If you register a custom tag with add_constructor that instantiates a class from user supplied values, or that can import a module or build a callable, you have recreated the same code execution sink under a different name. Keep custom constructors to plain data and never let them build callables.


    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.

  • Python Pickle Deserialization Is Code Execution

    Python Pickle Deserialization Is Code Execution

    If your code calls pickle.loads on bytes that came from a user, a cache, a queue, or a file upload, you have a remote code execution bug. Python pickle deserialization is not a data format in the way JSON is. It is a small program format, and loading a pickle runs that program. This post shows exactly how a crafted pickle turns a load into a command, and what to use instead.

    How python pickle deserialization runs code

    Pickle was built to save and restore Python objects, including ones that cannot be rebuilt by copying fields. To handle those, the format lets an object describe how to reconstruct itself. That description is a callable plus its arguments, and the unpickler calls it. The hook is the __reduce__ method.

    When you pickle an object, pickle may call its __reduce__ and store what it returns: a function to call and a tuple of arguments. When you unpickle, the loader reads that pair and calls the function with those arguments. Nothing checks that the function is harmless. An attacker writing the pickle by hand just names a dangerous callable.

    A tiny malicious pickle

    Here is the whole trick in a few lines. Picture an invented service, Acme Sync, that stores a user preference object as a pickle:

    import os, pickle
    
    class Exploit:
        def __reduce__(self):
            return (os.system, ("id",))
    
    payload = pickle.dumps(Exploit())
    # later, on the server:
    pickle.loads(payload)   # runs: os.system("id")

    The __reduce__ method returns os.system and the argument "id". When the server loads the payload, the unpickler calls os.system("id"). Swap "id" for anything and you see the problem. The attacker did not need a bug in your logic. The load itself is the sink.

    Unpickling untrusted data is not parsing. It is handing the sender a function call inside your process.

    The real payloads do not stop at one command. They build a chain of objects whose reconstruction steps line up to reach a useful callable, sometimes importing a module first, then calling into it. The technique of stringing together pieces that are each harmless alone is the same idea behind any gadget chain. For the general mechanism, read what is a deserialization gadget chain, and the __reduce__ hook itself is one of the special methods covered in magic methods in deserialization attacks.

    Why this surprises people

    Pickle looks like a serializer, and serializers feel safe because we think of them as reading data. The mistake is treating pickle as if it only carries values. It carries instructions. The docs say this plainly, with a warning at the top of the module that you should never unpickle data from an untrusted source. That warning is easy to miss when pickle is hidden inside something else.

    Common places it hides:

    • Caches and sessions. Some caching libraries pickle values by default. If the cache backend or a session cookie can be influenced by a user, the load is exposed.
    • Task queues. A worker that pickles job arguments will unpickle whatever lands on the queue. If the queue can be written to from outside, each job is a payload.
    • Machine learning model files. Many model formats are pickles under the hood. Loading a model someone sent you runs their code. Treat a downloaded model like a downloaded executable.
    • Inter process messages. Passing pickles between services over a socket trusts every byte on that socket.

    In each case the fix is the same: find out whether the bytes can originate outside your trust boundary. If they can, pickle is the wrong tool. To understand why the whole class of bug keeps reappearing across languages, the primer on what is insecure deserialization is the place to start.

    The fix: do not unpickle untrusted input

    There is no safe flag that makes pickle.loads accept untrusted bytes. The advice you sometimes see, to subclass the unpickler and block certain globals with find_class, narrows the attack surface but is hard to get right and easy to bypass, because the set of dangerous callables is large and changes with your dependencies. Treat allow listing as a last resort for data you cannot move off pickle, not as a general fix.

    The real fix is to change the format:

    • Use JSON for plain data. json.loads produces dicts, lists, strings, and numbers. It cannot construct arbitrary Python objects and it cannot call a function. If your data is records and values, JSON is enough.
    • Use a schema format such as Protocol Buffers or MessagePack with a defined message type when you need speed or compact size. These map bytes onto fields you declared, not onto callables.
    • If you must move Python objects, sign them. Produce the bytes on a trusted side, attach a message authentication code with a secret key, and verify that code before loading. If the signature does not match, you never call the loader. This does not make pickle safe against a trusted insider, but it stops an outsider from injecting a payload.

    The JSON version of the earlier service looks like this:

    import json
    
    prefs = json.loads(raw_bytes)   # only data, no code runs
    theme = prefs.get("theme", "light")

    Now the worst an attacker can do with the body is send malformed JSON, which raises an error you can catch, or send unexpected values, which you validate like any other input. There is no callable for them to name.

    A migration note

    If an existing system already stores pickles, do not flip the reader to JSON and hope. Old data will not parse. Version your stored format, write new records as JSON, and keep a guarded reader for old pickle records only while they still exist, ideally behind the signature check above. Plan to expire the old records so the pickle reader can be deleted. Object injection shows up in many ecosystems the same way, so it helps to read across the injection and input category rather than treating this as a Python only quirk.

    How to find it in a codebase

    • Grep for pickle.load, pickle.loads, cPickle, and joblib.load.
    • For each hit, trace the argument backward. Can those bytes come from a request, a cookie, a queue, an uploaded file, or a third party model? If yes, it is a live finding.
    • Check your caching and session configuration for a pickle serializer you did not choose on purpose.

    Finding this bug is not about spotting the word pickle. It is about proving that untrusted bytes actually reach the loader, through caches, queues, and helpers that hide the path. That source to sink reasoning over how an app moves data is exactly the kind of work UnboundCompute is built to do. Read more on our about page.

    Frequently asked questions

    Why does unpickling untrusted data run code?

    Pickle is a program format, not a plain data format. An object can define a __reduce__ method that returns a callable and its arguments, and the unpickler calls that callable when it loads the object. Nothing checks that the callable is harmless, so an attacker who writes the pickle can name something like os.system and have it run during the load.

    What does the __reduce__ protocol actually do?

    When an object is pickled, pickle may call its __reduce__ and store the function plus the tuple of arguments it returns. When you unpickle, the loader reads that pair and calls the function with those arguments to rebuild the object. A crafted class returns a dangerous function instead, so the reconstruction step becomes a command.

    Can I make pickle safe with a custom unpickler?

    Subclassing the unpickler and blocking globals in find_class narrows the surface but is hard to get right and easy to bypass, because the set of dangerous callables is large and shifts with your dependencies. Treat allow listing as a last resort for data you cannot move off pickle, not as a general fix.

    What should I use instead of pickle for untrusted input?

    Use JSON for plain data, since json.loads only produces dicts, lists, strings, and numbers and cannot call a function. Use a schema format like Protocol Buffers or MessagePack when you need speed. If you must move Python objects, produce them on a trusted side and verify a message authentication code before loading.


    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.

  • TypeNameHandling in Json.NET and RCE risk

    TypeNameHandling in Json.NET and RCE risk

    TypeNameHandling is a setting in Json.NET, the widely used .NET JSON library also known as Newtonsoft.Json, that writes and reads a type name inside the JSON. When it is set to All or Auto, the JSON carries a $type field that tells the parser which .NET class to build. If the JSON comes from a user, that setting lets the user choose the class your server constructs, which is the starting point for a gadget in .NET.

    What TypeNameHandling does

    Json.NET turns JSON into .NET objects. By default it uses the type you ask for. TypeNameHandling changes that. When a property is an interface, an object, or a base class, the serializer needs a way to record the real type so it can rebuild it later. So it embeds the full .NET type name and assembly in a $type field.

    {
      "$type": "Acme.Notes.Attachment, Acme.Notes",
      "Name": "report.pdf",
      "Size": 2048
    }
    

    The values that matter:

    • TypeNameHandling.None is the safe default. No $type is written or read.
    • TypeNameHandling.All writes $type on every object and reads it back.
    • TypeNameHandling.Auto writes it when the declared type and the real type differ, and reads whatever $type arrives.

    With All or Auto, the $type string is an instruction. The parser loads that type and builds it. If the request body is attacker controlled, so is the type.

    The moment the JSON names the class, the client is choosing what your process constructs. The $type field is not a label, it is a command.

    How TypeNameHandling leads to a gadget in .NET

    Choosing the type is only the opening move. Json.NET builds the object by running its constructor and setting its properties from the JSON. An attacker looks for a type already loaded in the application whose construction or property setters do something useful, such as starting a process, writing a file, or loading an assembly from a path they give. Point $type at that class, supply the property values, and the act of deserializing runs the behavior.

    The attacker writes no new code. They name a class your app already carries and let Json.NET build it with values they choose. Because the assembly name rides along in the same field, the reach is as wide as everything loaded in the process, which is why a single typed endpoint can be enough.

    The danger mirrors what BinaryFormatter does in the same runtime, covered in dotnet BinaryFormatter RCE. The sequence of reachable classes that ends in code execution is a deserialization gadget chain, and the underlying reason any of this is possible is in the insecure deserialization primer. No working chain appears here.

    Spotting TypeNameHandling risk

    • Search for the setting. Grep for TypeNameHandling and flag any use of .All or .Auto. These are the values that read $type from input.
    • Check JsonSerializerSettings. The setting often sits in a shared settings object passed to JsonConvert.DeserializeObject. Follow that settings object to every call that uses it.
    • Trace the JSON source. Typed settings on internal, trusted data is one thing. The same settings on a request body, a cookie, or a message from a queue is the risk.
    • Watch for $type in traffic. Seeing that field arrive in user supplied JSON tells you the endpoint is type aware.

    How to fix TypeNameHandling

    • Set TypeNameHandling.None on anything that reads untrusted JSON. This is the core fix. With None, the $type field is ignored and the client cannot name a class.
    • Deserialize into concrete types. Map the body into a specific class you define with named properties, not into object or a broad interface, so the type is fixed by your code.

    If you genuinely need to round trip polymorphic types, do not accept raw type names. Bind the deserializer to a strict SerializationBinder that maps a short, known set of names to types and throws on anything else:

    class AllowlistBinder : ISerializationBinder
    {
        public Type BindToType(string assemblyName, string typeName)
        {
            if (typeName == "Attachment") return typeof(Acme.Notes.Attachment);
            if (typeName == "Comment")    return typeof(Acme.Notes.Comment);
            throw new JsonSerializationException("type not allowed");
        }
        public void BindToName(Type t, out string asm, out string name)
        { asm = null; name = t.Name; }
    }
    
    • Default to deny. The binder says yes to a named list and throws on everything else. A blocklist of bad types does not hold, because new gadget types keep appearing.
    • Keep dependencies patched. Fewer old libraries on the runtime means fewer classes an attacker can reach through $type.

    Why TypeNameHandling rewards understanding the app

    A fixed payload list will not find this. The type that matters depends on what is loaded in that particular .NET process. You find it by noticing that a serializer reads $type, then asking whether untrusted input reaches it and which classes are present to be named. That is the same reasoning as ViewState deserialization elsewhere in the .NET stack. For the broader family, see the injection and input category.

    This is the kind of flaw an autonomous researcher that reasons about whether untrusted input can reach a type resolving deserializer is built to find. Read how we think about it on our about page.

    Frequently asked questions

    What does TypeNameHandling do in Json.NET?

    TypeNameHandling controls whether Json.NET writes and reads a $type field that names the .NET class to build. With All or Auto it reads that name from the JSON and constructs the type. With None, the default, $type is ignored.

    Why are TypeNameHandling.All and Auto risky?

    When the $type field is honored and the JSON is attacker controlled, the attacker chooses which .NET class the server builds. If a loaded type does something dangerous while constructing or setting properties, such as starting a process or loading an assembly, that becomes the start of a gadget chain.

    What is the safe setting for TypeNameHandling?

    Use TypeNameHandling.None on anything that reads untrusted JSON, and deserialize into concrete classes you define. If you must round trip polymorphic types, bind a strict SerializationBinder that maps a known set of names to types and throws on anything else.

    Does a SerializationBinder fully fix the problem?

    A binder helps only if it works as an allowlist, mapping a small set of expected names and throwing on the rest. A binder that tries to block known bad types will fall behind as new gadget types appear, so default to deny and name only what you expect.


    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.

  • Why BinaryFormatter Deserialization Is Unsafe by Design

    Why BinaryFormatter Deserialization Is Unsafe by Design

    The binaryformatter deserialization problem is one of the clearest examples of a feature that is unsafe by design. BinaryFormatter.Deserialize takes a stream of bytes and rebuilds a full graph of .NET objects from it, including the types named inside that stream. If an attacker controls those bytes, they steer which types get created and how, and that is enough to run code on your server.

    Why binaryformatter deserialization is dangerous

    A serializer has one job: turn objects into bytes and back again. The trouble is that BinaryFormatter trusts the incoming bytes to tell it which types to build. The stream does not just carry data, it carries type names and field values. When the formatter reads the stream, it looks up each named type, allocates an instance, and fills in the fields, all before your own code sees the result.

    This means the decision about what objects to construct is made by whoever wrote the bytes. For data your app produced and stored somewhere safe, that is fine. For data that arrived from a user, a queue, a cookie, or an upload, the sender now controls object construction inside your process.

    The callbacks that turn data into execution

    Reconstructing an object is not passive. The .NET serialization system fires several hooks as it rebuilds a graph, and each one runs real code on the attacker’s behalf:

    • A type can implement ISerializable and run logic in its deserialization constructor.
    • A type can implement IDeserializationCallback, whose OnDeserialization method runs once the graph is complete.
    • A type can carry an [OnDeserialized] method that the formatter calls automatically.
    • A type can implement IObjectReference, which lets the reconstructed object replace itself with a different object entirely.

    None of these need your cooperation. They are part of the contract the formatter honors. So the attacker picks types that are already present in your app or its libraries, arranges their fields, and lets the callbacks fire. The types they pick are the links in a chain that ends at a method which executes a command, writes a file, or starts a process.

    The attacker does not send you code. They send you a recipe made of classes you already trust, and the formatter follows it.

    What a malicious payload looks like in shape

    You do not need the exact bytes to understand the idea. Picture a vulnerable endpoint in an invented app called Acme Reports that accepts a saved filter as a base64 blob:

    POST /reports/apply-filter
    Content-Type: application/x-www-form-urlencoded
    
    filter=AAEAAAD/////AQAAAAAAAAAM... (base64 of a serialized graph)

    The server side does the unsafe step:

    var bytes = Convert.FromBase64String(form["filter"]);
    using var ms = new MemoryStream(bytes);
    var formatter = new BinaryFormatter();
    var filter = (ReportFilter)formatter.Deserialize(ms);  // attacker controls the graph

    The cast to ReportFilter happens last. By the time that line runs, the formatter has already built every object in the stream and fired every callback. The cast failing does not save you, because the damage is done during Deserialize, not after it. This is why the fix is never to validate the result. You have to stop the loader from running on untrusted bytes in the first place. If you want the deeper mechanics of how those trusted classes get strung together, see what is a deserialization gadget chain, and for the class of bug in general, start with what is insecure deserialization.

    The same pattern shows up in ViewState

    BinaryFormatter style object graphs also ride inside other .NET features. The classic case is a misconfigured ViewState field that deserializes attacker supplied data with a weak or missing integrity key. The loader is different but the sink is the same: untrusted bytes become a reconstructed object graph. We cover that path on its own in dotnet viewstate deserialization. The gadget building technique, picking setter and callback behavior to reach a dangerous method, is the same idea described in pop chains and property oriented programming.

    Why the platform deprecated it

    Microsoft did not patch BinaryFormatter to make it safe, because there is no safe version of a feature whose whole purpose is to build arbitrary types from a stream. Instead the platform moved to turn it off. BinaryFormatter is marked obsolete, its use throws warnings you are told to treat as errors, and in recent .NET releases the methods are disabled by default and throw at run time unless you go out of your way to re enable them. The official guidance is blunt: do not call it on data you did not produce and protect yourself.

    That deprecation is the strongest signal you will get from a platform. The right reading is not “find the flag to turn it back on.” It is “remove this and move to a serializer that does not reconstruct arbitrary types.”

    The fix: a contract based serializer

    The safe replacement is a serializer that only reads and writes the fields you declare, and never instantiates a type named by the input. These are often called contract based serializers because you, not the bytes, decide the shape.

    • Prefer System.Text.Json for most data. It maps JSON onto types you specify. It will not build a type just because a string in the payload asks for it, as long as you avoid polymorphic settings that reintroduce type names.
    • Use DataContractSerializer or XmlSerializer with a fixed set of known types when you need richer object models. You pass the allowed types in, so the input cannot name something else.
    • Do not swap in another format that embeds type names. The danger is type information in the payload, not the choice of binary versus text. A JSON serializer configured to honor a type hint field has the same problem.

    Concretely, the earlier endpoint becomes:

    var filter = JsonSerializer.Deserialize<ReportFilter>(form["filter"]);

    Now the payload can only fill fields that exist on ReportFilter. There is no path for the input to pick a different type or trigger a serialization callback on a class you never meant to expose. If a value is out of range, you validate it like any other input. This is deserialization as data parsing, not as object construction, and that difference is the whole point. Object injection in other ecosystems works the same way, which is why it helps to read across languages, for example injection and input as a category.

    A short checklist

    • Search your code and dependencies for BinaryFormatter, SoapFormatter, NetDataContractSerializer, and LosFormatter. Treat each hit as a finding, not a style note.
    • Confirm whether the input to each one can come from outside the app. If it can, it is a live risk.
    • Replace the call with a contract based serializer and a declared type. Validate the decoded object as normal data.
    • Make sure ViewState and any custom cookie or cache format are not quietly doing the same thing.

    Finding this bug by hand means tracing untrusted bytes all the way to an unsafe loader, across a controller, a helper, and a cast that happens too late to matter. That source to sink reasoning over how an application actually wires its inputs is exactly what UnboundCompute is built to do. You can read more on our about page.

    Frequently asked questions

    Why is BinaryFormatter.Deserialize unsafe on untrusted data?

    The byte stream it reads carries type names and field values, so the sender decides which .NET types get built and how. As the formatter reconstructs the object graph it fires callbacks like the deserialization constructor, OnDeserialization, and OnDeserialized, which run real code. An attacker strings together classes you already trust so those callbacks reach a method that executes a command.

    Does casting the result to my expected type protect me?

    No. The formatter builds every object in the stream and fires every callback during the Deserialize call. The cast to your expected type happens after that, so it runs too late to stop anything. The only safe fix is to keep the loader from running on untrusted bytes at all.

    Why did Microsoft deprecate BinaryFormatter instead of fixing it?

    There is no safe version of a feature whose purpose is to build arbitrary types named by the input. So the platform marked it obsolete, made its use raise errors, and disabled the methods by default in recent .NET releases so they throw at run time. The guidance is to remove it, not to re enable it.

    What should I use instead of BinaryFormatter?

    Use a contract based serializer that only reads the fields you declare and never instantiates a type named by the input. System.Text.Json works for most data, and DataContractSerializer or XmlSerializer with a fixed set of known types covers richer models. Avoid any configuration that honors a type hint in the payload, since that brings the same risk back.


    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.

  • ViewState deserialization and forged __VIEWSTATE

    ViewState deserialization and forged __VIEWSTATE

    ViewState deserialization is how classic ASP.NET Web Forms rebuilds page state on the server. The browser sends back a hidden field called __VIEWSTATE, and the server turns that string back into .NET objects. When the protection around that field is off or its key has leaked, an attacker can forge a __VIEWSTATE value that deserializes into a gadget, and the page that was meant to remember a text box ends up running attacker code.

    What ViewState is and how it travels

    ASP.NET Web Forms keeps the state of a page, the values of its controls, between requests. It serializes that state, base64 encodes it, and puts it in a hidden form field:

    <input type="hidden" name="__VIEWSTATE"
           value="/wEPDwUKLTEyMzQ1Njc4OQ9kFgICAw9kFgI..." />
    

    On the next postback the browser sends that field straight back. The server base64 decodes it and deserializes it into the object graph that describes the page. That deserialization step is the whole story. If an attacker can make the server deserialize bytes they chose, they are back to the object building problem at the heart of the insecure deserialization primer.

    What normally keeps ViewState deserialization safe

    ViewState is not meant to be attacker controllable, and ASP.NET ships two protections:

    • A message authentication code, the ViewState MAC. The server signs the ViewState with a secret key and checks that signature on the way back in. Edit one byte without the key and the check fails, so the server refuses to deserialize it. This is controlled by the EnableViewStateMac setting.
    • The machine key. The signing, and optional encryption, use keys configured as machineKey. Those keys are the secret that makes forgery hard.

    When both are in place and the key is secret, a forged ViewState is rejected before any object is built. The danger appears when that is not true.

    ViewState is only safe while its key is secret. The signature is the one thing standing between a hidden form field and your deserializer.

    How forged ViewState deserialization turns into code execution

    Two broken conditions open the door.

    • MAC disabled. If EnableViewStateMac is off, the server deserializes whatever arrives with no signature check. An attacker crafts a serialized object graph, base64 encodes it, and sends it as __VIEWSTATE. The server builds it with no questions asked.
    • Leaked or weak machine key. If the key is committed to source control, copied from a sample config, or reused across many servers, an attacker who learns it can sign their own forged ViewState so the MAC check passes.

    Either way the attacker controls the bytes going into the deserializer. From there they point it at a class already present in the .NET runtime whose construction does something dangerous, the same class of gadget used in dotnet BinaryFormatter RCE and selected by TypeNameHandling. Chained, those classes form a deserialization gadget chain that ends in remote code execution. No forged value is shown here, on purpose.

    How to spot the risk

    • Check EnableViewStateMac. Search config and page directives for EnableViewStateMac="false". Turning the MAC off was never safe, and modern frameworks do not let you, but legacy apps still carry it.
    • Audit the machine key. Look for a machineKey with hardcoded validationKey and decryptionKey values in web.config, especially the same values across environments or copied from a tutorial.
    • Look for leaked configs. A web.config in source control, in a backup, or reachable through a file read bug hands over the key.
    • Note the framework. Classic Web Forms is where this lives. Newer ASP.NET Core does not use ViewState at all.

    How to fix ViewState deserialization

    • Keep the MAC and validation on. Never set EnableViewStateMac to false. Let the server sign and verify every ViewState.
    • Protect and rotate the machine key. Generate unique keys per application, keep them out of source control, store them as secrets, and rotate them if you suspect exposure.
    • Encrypt as well as sign. Set ViewState to be encrypted so its contents are not even readable, which removes another source of leaked information.
    • Minimize what ViewState holds. The less page state you round trip through the client, the smaller the target.
    • Move to a framework without ViewState. Modern .NET web apps use anti forgery tokens and server held state instead of serializing object graphs into a hidden field. If you are building new, you avoid the whole category.

    Why ViewState deserialization rewards understanding the app

    A scanner throwing generic payloads will miss this, because success depends on whether the MAC is on and whether the specific key is known. You find it by reasoning about the protection around the field, not by guessing. That is assumption testing. For the wider family of input that crosses a trust boundary, see the injection and input category.

    This is the kind of flaw an autonomous researcher that reasons about whether untrusted input can reach a type resolving deserializer is built to find. You can read how we think about it on our about page.

    Frequently asked questions

    What is ViewState deserialization?

    ASP.NET Web Forms saves page state by serializing it, base64 encoding it, and putting it in the hidden __VIEWSTATE field. On the next postback the server decodes and deserializes that field back into .NET objects. That deserialization step is where forged input becomes dangerous.

    How can an attacker abuse __VIEWSTATE?

    If the ViewState MAC is disabled or the machine key has leaked, an attacker can craft or sign a __VIEWSTATE value that the server deserializes. By pointing it at a class already loaded in the runtime, they can start a gadget chain that can end in remote code execution.

    What protects ViewState normally?

    Two things. The ViewState MAC signs the field with a secret key and verifies it on each postback, so edits without the key are rejected. The machine key is that secret, and optional encryption hides the contents. With both in place and the key kept secret, forged ViewState is refused before any object is built.

    How do I fix ViewState deserialization risk?

    Keep EnableViewStateMac on, never disable it, and protect the machine key by generating unique keys, keeping them out of source control, and rotating them if exposed. Encrypt ViewState, keep it small, and prefer a modern framework that does not serialize page state into a hidden field.


    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.