{"slug": "ruby-4-0-universal-rce-deserialization-gadget-chain-elttam", "title": "Ruby 4.0 Universal RCE Deserialization Gadget Chain - elttam", "summary": "On August 5, 2026, OpenAI disclosed that AI agents under evaluation escaped their sandboxes and took admin control of their cluster, partly by exploiting Ruby deserialization. Security firm elttam has released a new universal Ruby deserialization gadget chain that turns a single Marshal.load into command execution on Ruby 4.0.6, the latest release, and works unchanged back to Ruby 3.3. The chain uses new gadgets from untapped sources and old gadgets repurposed, building on prior research, and addresses the removal of previous gadgets in RubyGems commits.", "body_md": "On August 5, 2026, OpenAI [disclosed](https://www.youtube.com/watch?v=87DyyMV0kCY#t=25m) that a collective of AI agents under evaluation had broken out of their sandboxes and taken admin control of the cluster they were running on. It got there, in part, by exploiting Ruby deserialization to execute commands. That caught our attention, because in 2018 we published [the first universal RCE deserialization gadget chain for Ruby](https://www.elttam.com/blog/ruby-deserialization), built entirely from the standard library with no dependencies. That chain works only against Ruby versions up to 2.6.10, and the [most recent public chain](https://nastystereo.com/security/ruby-3.4-deserialization.html) only works up to 3.4-rc.\n\nThis post releases a new universal chain that turns a single `Marshal.load`\n\ninto command execution on Ruby 4.0.6, the most recent release at the time of writing, and works unchanged as far back as 3.3. The chain is built with new gadgets from untapped sources as well as old gadgets put to new use.\n\n[Serialization](https://en.wikipedia.org/wiki/Serialization) is the process of converting an object into a series of bytes which can then be transferred over a network or stored on the filesystem or in a database. These bytes include all the information required to reconstruct the original object. This reconstruction process is called deserialization. Each programming language typically has its own native serialization format and may refer to this process by a name other than serialization/deserialization. In the case of Ruby, the terms marshalling and unmarshalling are commonly used, and the operations are provided by `Marshal.dump`\n\nand `Marshal.load`\n\n.\n\nUniversal Ruby deserialization gadget chains begin in 2018, built on earlier research into application specific chains against Ruby on Rails, and that universal work then fed back into the application specific chains that came after it. Several of the milestones below supply pieces that this chain builds on.\n\nThe most recent public chain, published in late 2024, reached command execution on Ruby 3.4-rc with this payload:\n\nTen days after it was published, two commits landed in RubyGems that removed the gadgets it relied on, each citing the writeup as motivation. Both shipped in Ruby 3.4.0, which is why the chain works against the release candidate but not against the release.\n\nThe first commit, [62b49465f8](https://github.com/ruby/rubygems/commit/62b49465f8f770ebbee51d7cb4de7745cbb0d78c), is titled \"Improve type checking in marshal_load methods\" and notes that it \"Makes it harder to use those classes as gadgets\".\n\n`Gem::Version#marshal_load`\n\nhad passed the deserialized value straight to the constructor without validation, where `Gem::Version.correct?`\n\ncalls `to_s`\n\non it:\n\nThe second commit, [89ad04db86](https://github.com/ruby/rubygems/commit/89ad04db867dedb6ec9d78dd891ca69e2ef275a5), is titled \"Stop storing executable names in ivars\" and notes that it \"Removes usage of these classes as ACE gadgets\".\n\n`Gem::Source::Git`\n\nand `Gem::Resolver::GitSet`\n\nhad stored the git executable name in an instance variable, which Marshal restores directly and which was later handed to a process spawn:\n\nThe name is now read from the environment at the point of use, so there is no instance variable left to set.\n\nThese two commits broke `to_s_wrapper`\n\nand `exec_gadget`\n\n, but `Gem::SpecFetcher`\n\nand `call_url_and_create_folder`\n\nwere left alone and work in Ruby 4.0.\n\nThe chain opens with `Gem::SpecFetcher`\n\nnot because the class does any work, but because `Marshal.load`\n\nhas to resolve the constant, and resolving it fires the RubyGems autoload that requires the file defining it, which in turn requires files of its own, and so on. A bare Ruby process therefore starts with a small set of classes reachable by a chain and ends up, after a single constant reference, with a much larger one to pick gadgets from, including `Gem::URI::Generic`\n\n, `Gem::RequestSet::Lockfile`\n\nand `Gem::StubSpecification`\n\n, all of which the rest of this chain depends on.\n\nA suitable replacement for `exec_gadget`\n\nis supplied by `Gem::Specification.load`\n\n, where `Gem.open_file`\n\nresolves to `File.open`\n\n:\n\nThis method reads a file from disk and passes its contents directly to `eval`\n\n, so a chain that can control both the filename handed to `Gem::Specification.load`\n\nand the contents of that file ends up with arbitrary code execution.\n\nThe available set offers no flexible gadget of the form `@controlled.load(@also_controlled)`\n\n, but `Gem::StubSpecification`\n\nprovides an indirect route to `Gem::Specification.load(loaded_from)`\n\nby calling the `hash`\n\nmethod. This works because `loaded_from`\n\nis an `attr_accessor`\n\n, so its value is held in `@loaded_from`\n\nand can be set through deserialization:\n\nThat leaves the question of how `hash`\n\ngets called during deserialization.\n\nRuby invokes `hash`\n\non an object whenever it is used as a key in a `Hash`\n\n. `Marshal.load`\n\nreconstructs a hash by inserting its keys, so placing the crafted `Gem::StubSpecification`\n\nas a key somewhere in the payload is enough to have `hash`\n\ncalled.\n\nJava aficionados will recognise this.`HashMap.readObject`\n\ncalls`hashCode`\n\non every key it restores, which is the entry point for a large share of the chains in[ysoserial].\n\nThe trigger is not a niche `marshal_load`\n\noverride that a maintainer can quietly tighten, but the interaction between two fundamental features of the language, namely hashing an object and reconstructing a `Hash`\n\nduring deserialization. Removing it would mean changing the way core data structures behave, which is exactly the kind of tradeoff where a gadget can be cheap to use and expensive to forbid.\n\nBeing able to `eval`\n\nan arbitrary file on disk is only useful if the chain can also write attacker-controlled code to disk. Rather than build a new primitive for this, the chain reuses `call_url_and_create_folder`\n\n, which is one of the pieces of the 3.4-rc chain that the maintainers left untouched.\n\nIn that earlier chain the gadget created the directories that the command-execution gadget depended on, since `Gem::Source::Git`\n\nbegan by changing into one of those directories and would fail if it did not already exist. Here it is put to a different use: its URL-download functionality fetches attacker-hosted content and writes that content onto the filesystem at a predictable and typically writable path by way of directory traversal.\n\nThe 3.4 chain invoked `call_url_and_create_folder`\n\nthrough `to_s_wrapper`\n\n, which the type checking commit removed, so the gadget needs a new caller.\n\nIt also needs a caller that tolerates failure. The gadget expects the URL it fetches to hold a serialized object and raises when it does not, and what has to land on disk is Ruby source. A polyglot that is valid as both is not possible, because the Marshal header leaves no room for one. The download and the write happen before the parse, so the exception arrives after the useful work is done.\n\nRuby's own `Time`\n\ndeserialization provides both. `time_mload`\n\nvalidates the zone name inside `rb_rescue`\n\n, which discards any exception it raises:\n\n`time_mload`\n\nbacks `Time._load`\n\n, which `Marshal.load`\n\ncalls when rebuilding a `Time`\n\n. The zone name comes straight from the payload, so a crafted `Time`\n\nputs an arbitrary object into `validate_zone_name`\n\n. `StringValueCStr`\n\nthen calls `to_str`\n\non it rather than `to_s`\n\n.\n\n`Gem::URI::Generic`\n\ncloses that gap. Its `to_str`\n\nis an alias of `to_s`\n\n, and that method calls `to_s`\n\non the `@port`\n\nattribute:\n\nWrapping the download gadget in one turns the `to_str`\n\ncall into the `to_s`\n\ncall it needs:\n\nTwo gadgets died, two survived, and the survivors do a different job in this chain:\n\nThe 3.4 chain pointed `call_url_and_create_folder`\n\nat a real gemspec on rubygems.org. Any valid URL would have done, since only the directory created along the way was wanted. This time the file holds the Ruby code to be executed. The retrieved contents pass through `Gem::Util.inflate`\n\nbefore being written to disk, so the file has to be deflated first:\n\n`call_url_and_create_folder`\n\nsets the `@scheme`\n\nattribute to `s3`\n\nto reach the directory traversal in `@port`\n\n. The signed URL that `s3_uri_signer.rb`\n\nbuilds hardcodes `https://`\n\n, so the file must be served over HTTPS. The destination is controlled by `Gem::Source#fetch_spec`\n\n, which joins the cache dir with `Gem::MARSHAL_SPEC_DIR`\n\n(set to `quick/Marshal.4.8/`\n\n) and the name tuple's `spec_name`\n\nof `\"#{name}-#{version}.gemspec\"`\n\n, which becomes `name-.gemspec`\n\nbecause the `@name`\n\nattribute is set to `\"name\"`\n\nand the version is absent. The inflated copy lands at `/tmp/quick/Marshal.4.8/name-.gemspec`\n\n, which is the path `eval_file_gadget`\n\nis given.\n\nEvery other gadget in the chain is a plain object whose instance variables can be set with `allocate`\n\nand `instance_variable_set`\n\n, then handed to `Marshal.dump`\n\n. `Time`\n\nis not, because it defines `_dump`\n\nrather than being dumped field by field. `time_dump`\n\nwrites the real zone of the real `Time`\n\nobject it is given, so there is no way to make `Marshal.dump`\n\nemit a `Time`\n\nwhose zone is an arbitrary object. While Ruby will not dump such an object, this does not prevent `Marshal.load`\n\nfrom accepting one.\n\nOne way around the inability to dump a `Time`\n\nof the required shape is to dump a stand-in object of the same shape and patch the bytes afterwards. The generator builds an `Object`\n\ncarrying two instance variables, `@offset_placeholder`\n\nand `@zone_placeholder`\n\n, and then rewrites the object header and the two attribute names into the form `Time._load`\n\nexpects:\n\nThe replacement is a `TYPE_USERDEF`\n\n(`u`\n\n) entry for `Time`\n\nholding the eight byte packed time buffer, wrapped in a `TYPE_IVAR`\n\n(`I`\n\n) so that the `offset`\n\nand `zone`\n\nattributes ride along with it, exactly as a genuine `Marshal.dump(Time.now)`\n\nwould look. The `zone`\n\nvalue that follows in the stream is untouched and is still the gadget.\n\nThe patch is byte level surgery on a format with backreferences, so it is fragile in one specific way. Marshal writes each symbol once and emits a `TYPE_SYMLINK`\n\nfor every later use, and a symlink is an index into the symbols seen so far. Adding or removing a symbol definition before the patched region would shift every index after it and corrupt the rest of the stream. Both the search and the replacement therefore define exactly three symbols, so the table stays aligned.\n\nOne more detail is needed before the stream can be produced at all. The chain places the `Gem::StubSpecification`\n\ngadget as a `Hash`\n\nkey, and Ruby calls `hash`\n\non a key when the hash literal is evaluated, which would fire the gadget inside the generating process rather than the target. Stubbing the method out for the duration of generation avoids that:\n\nEvery gadget above plays one of two roles: getting the attacker's code onto disk, or reading it back and running it. Laid out as a single chain, the whole thing looks like this:\n\nRunning the generator emits the finished chain:\n\nTwo things have to be in place before it is loaded. The deflated payload from earlier must be served as `poc-id.rz`\n\nover HTTPS by the host named in `@host`\n\n, which is `example.com`\n\nhere and would be a reachable attacker controlled host in practice. The target must also be able to write to `/tmp`\n\n, though any writable directory would do if the traversal and the filename are changed together. Nothing else is required of the target: no gems beyond those loaded and available by default in Ruby, no application code, and no prior state on disk.\n\nRunning the generated payload against an empty Ruby process using the Docker image `ruby:4.0.6`\n\noutputs `uid=0(root) gid=0(root) groups=0(root)`\n\n, showing the `id`\n\nbinary was successfully executed:\n\nThe exception that follows is expected and harmless. `Gem::Specification.load`\n\nhas already passed the fetched source to `eval`\n\n, but that source ends with `puts`\n\n, so the value it hands back is `nil`\n\nrather than a gemspec. The method warns and returns `nil`\n\n, and `Gem::StubSpecification#hash`\n\nraises when it tries to read a name from it.\n\nIt is also avoidable, which matters if a stack trace in the logs or an aborted request is something you would rather not leave behind. `Gem::StubSpecification#hash`\n\nis `name.hash ^ version.hash ^ platform.hash`\n\n, and each of those three reads a field off whatever `Gem::Specification.load`\n\nreturned. Ending the evaluated source with a `Gem::Specification`\n\nis therefore enough for `Marshal.load`\n\nto return normally, with no warning and no exception:\n\nOne side effect is worth noting for anyone reproducing this: the evaluated source is left behind at `/tmp/quick/Marshal.4.8/name-.gemspec`\n\n.\n\nThe chain turns a single `Marshal.load`\n\ninto command execution on Ruby 4.0.6 and works unchanged as far back as 3.3. It needs no gems beyond those that ship with Ruby, no application code, and no prior state on disk. Outside the target process it needs only a reachable HTTPS host and a writable directory.\n\nLittle of it had to be built from scratch. The two commits that followed the 3.4 writeup removed the gadgets they named and left `call_url_and_create_folder`\n\nalone, so it is still here, doing a different job than it did before: fetching attacker-controlled bytes onto disk rather than creating a directory some other gadget depended on. Gadgets outlive the chains they are found in. When a chain stops working, the surviving gadgets can be recycled into the next chain.\n\nWhat is new is where the rest of the chain comes from. Every public Ruby chain until now has been built entirely out of methods written in Ruby, in the standard library or RubyGems, that a maintainer can tighten in a five-line diff, which is exactly what happened to `Gem::Version#marshal_load`\n\n. This one reaches below that. The failure-tolerant caller it needs is `time_mload`\n\n, which is C, and which throws away the exception the download gadget raises because `rb_rescue`\n\nwas there to keep a malformed zone name from breaking `Time`\n\ndeserialization. The trigger is C as well, and is not an override at all but the fact that a `Hash`\n\ncalls `hash`\n\non its keys while `Marshal.load`\n\nrebuilds it. Neither is a stray convenience that can be quietly deleted. Removing them means changing how `Time`\n\ndeserialization tolerates bad input and how core data structures behave, and neither is a change the language can realistically make.\n\nSo the advice does not change, but it is worth being precise about why. Removing gadgets raises the cost of writing a chain; it does not remove the capability, because the gadgets are spread across a library that is loaded into every Ruby process by default and are found faster than they are patched. `Marshal.load`\n\non untrusted input is command execution, on the current release, with no dependencies. Treat it that way and use a data-only format instead.\n\nThis post opened with a collective of AI agents that took admin control of a cluster, in part through Ruby deserialization. Whether they assembled a chain of their own or reused a published one, the assumption that no chain exists for the version in front of you was never a control, and it is no longer even a delay. If you came to this post wondering whether Ruby deserialization is still worth caring about in 2026, the agents that broke out of that cluster have already answered it: yes.\n\nUntil we deserialize again, ciao bella!", "url": "https://wpnews.pro/news/ruby-4-0-universal-rce-deserialization-gadget-chain-elttam", "canonical_source": "https://www.elttam.com/blog/ruby-4-0-universal-rce-deserialization-gadget-chain", "published_at": "2026-08-14 04:34:33+00:00", "updated_at": "2026-08-14 04:48:08.516961+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-safety", "ai-agents"], "entities": ["OpenAI", "elttam", "Ruby", "RubyGems", "Gem::Version", "Gem::Source::Git", "Gem::Resolver::GitSet", "Gem::SpecFetcher"], "alternates": {"html": "https://wpnews.pro/news/ruby-4-0-universal-rce-deserialization-gadget-chain-elttam", "markdown": "https://wpnews.pro/news/ruby-4-0-universal-rce-deserialization-gadget-chain-elttam.md", "text": "https://wpnews.pro/news/ruby-4-0-universal-rce-deserialization-gadget-chain-elttam.txt", "jsonld": "https://wpnews.pro/news/ruby-4-0-universal-rce-deserialization-gadget-chain-elttam.jsonld"}}