Hotcell: Sandboxing Active Storage After an AI-Discovered Rails Vulnerability

Open on YouTube ↗
Overview

Mike Dalessio, the newest member of the Rails Core team, had planned to give a Rails World talk about a Markdown API for Action Text. He described it as "conference driven development": get the talk accepted, then write the code before the conference. Instead he spent most of the summer reacting to security issues in Rails. That work felt more important and more urgent, so the talk became about security.

17 min read

His framing is simple. Humans have written a lot of software, and some of it has bugs, unexplored corner cases, or designs that are very trusting. AI has become very good at finding those problems, and Dalessio says security is "really chaotic right now." He calls himself a worrier, and he set up the talk to scare the audience first and then give them the tools to act. His message to anyone who thinks he worries too much: "you're not worried enough."

2:34

The CVE that started the summer

The summer began with a vulnerability Dalessio cites as CVE-2026-66066. When it came time to publish the advisory, he deliberately wrote it in the most opaque language he could and left out all the details. He was worried the flaw was severe enough that attackers would go after systems before teams had time to upgrade Rails, so he wanted to buy them that time.

The advisory had four sentences. It affected essentially every Rails version ever released and said that Active Storage did not disable libvips's untrusted loaders. He went through it sentence by sentence and used it to explain how Active Storage works.

Active Storage is the Rails subsystem for storing images and other blobs and for transforming them. The typical case: a user uploads a file, Active Storage saves it, and later the app wants a thumbnail for an activity feed, so Active Storage produces a smaller variant. The CVE concerns the transformation half. In the default Rails configuration, a vips transformer class calls into libvips.

4:28

Over a hundred libraries behind libvips

Libvips does not handle every format itself. It inspects the incoming file, decides which library should handle it, and routes it there. Some of those libraries are familiar, such as libpng and libjpeg. Others are less reliable; Dalessio pointed to ImageMagick and its history of security problems. Others are libraries most Rails developers have never heard of. His example was libmatio, which reads MATLAB files: "For all of your MATLAB Rails apps." Libvips knows about it, even if you don't.

When Dalessio installs libvips on the Ruby slim Docker image, the dependency list is more than 100 libraries. He told the audience there is no way all of those are secure and that vulnerabilities exist in some of them right now. Libvips does distinguish trusted from untrusted loaders, and ImageMagick and libmatio are marked untrusted. That is why the advisory mentioned untrusted libraries.

That points to the shape of the attack: upload an image crafted to trick one of those libraries. Who is vulnerable? Anyone using libvips who accepts image uploads from untrusted users. Since libvips is the Rails default and, as Dalessio put it, every user should be treated as untrusted, that means essentially every Rails app.

6:28

What an attacker could get

The exploit let an attacker read arbitrary files, including environment variables and application secrets. Image processing runs in the same container as the application, alongside the source code, config files, credentials, and environment. If secrets are injected through environment variables, they are visible to libmatio in this scenario. The trick was that the thumbnail returned to the attacker would contain those secrets.

The last sentence of the advisory described the real impact. With secrets in hand, an attacker could achieve remote code execution and move laterally: use a database secret to tamper with the database, use Redis credentials to tamper with the cache, and possibly reach corporate networks, depending on how secrets are set up. The issue scored 9.5 on CVSS, "about as bad as it gets."

Dalessio said the worst part was what happened after publication. Working only from the deliberately vague advisory, people reverse-engineered the attack in under an hour and published their results online. "I bought you 60 minutes of time to upgrade."

8:00

Seven small flaws, chained by agents

Dalessio stressed that there was no single big vulnerability. There were seven small issues that, he said, AI agents chained together from very little information:

  1. Direct uploads were enabled by default. Even apps that didn't use Active Storage exposed the route to attackers. That default has since been changed.
  2. Direct uploads never examine the bytes. The file goes from the browser straight to the blob store without passing through the Rails app, so Rails has to trust the content type the client reports. Lying about the content type was the way in. This is a performance trade-off, since downloading and re-analyzing every file would be costly.
  3. Signed variation keys. These make the attack easier to pull off but aren't a security problem in themselves, and Dalessio said Rails probably won't change them.
  4. Not calling libvips's "block untrusted" function. This was the actual fix in Rails. Dalessio noted that libvips shipped the secure behavior as an opt-in feature buried in a changelog.
  5. Libvips was fooled by a spoofed MATLAB header. This was the actual flaw. It was fixed, but it was small enough that no CVE was published.
  6. Libmatio said this was working as designed. Its maintainers' position was that untrusted files shouldn't be handed to libmatio in the first place.
  7. Known escalations from the secret key to remote command execution. The Rails security team knows several ways to turn a leaked secret key into remote command execution. Fixing them requires breaking changes, so, as Dalessio admitted, the team has been "dragging our feet." He called this the really bad link in the chain.

To drive it home, he showed a video of the exploit script pulling every secret out of a development app in real time. Once you know what to do, he said, it is very fast.

10:50

Why fixing one CVE isn't enough

Dalessio added two more data points. So far this year, 378 vulnerabilities have been published in libvips and its dependencies, with more reported every day. He also showed data from 37signals' HackerOne bug bounty program, where valid reports are "going parabolic."

That is the deeper problem. He could fix this one CVE, but the next zero-day in some image-processing library would put everyone through the same thing again. He frames the fix in terms of risk. The probability of another vulnerability in these libraries is very high and not likely to drop soon, so the only lever left is impact: how much damage can be done when the next one is found. The question for the second half of the talk was how to run Active Storage so that it doesn't matter if a vulnerable library processes an untrusted file. His answer, like most people's would be, was a sandbox.

12:36

Requirements for the sandbox

Dalessio presented the design as "five easy steps," starting with requirements. That list also became the prompt he gave his coding agent as they built the system together:

  • Drop-in replacement. Moving to the sandbox should be a configuration change, not a code change.
  • Least privilege. A very limited user, a filesystem that doesn't allow setuid, all Docker capabilities dropped, and no Rails privileges.
  • Hard limits on CPU time, memory, file size, number of files, and number of processes, to make an exploit as hard as possible to turn into damage.
  • No access to the app's filesystem or environment, so there are no secrets to exfiltrate.
  • No network interface at all, so a compromised process can't call out or move laterally.
  • Disposable workers. A process handles an image and is then killed, limiting the lifetime of any remote command execution.
  • A familiar deployment lifecycle, easy to deploy with Kamal or Kubernetes.
  • Extensibility. Active Storage is one thing built on top, but developers should be able to build their own operations, for example a secure place to unzip files.

The name comes from a real-world analogue. Dalessio showed a photo from Oak Ridge National Laboratory of a "hot cell," a large lead-shielded box for handling radioactive material. The window is filled with mineral oil so radiation can't escape, work is done with remote manipulators, and everything going in or out is tightly controlled. That is what he wanted for Active Storage, and naming it was step two.

15:13

Plugging into Rails

Rails already had the hooks he needed. Three config parameters each control one part of Active Storage:

  • Analyzers extract metadata, such as how many pages a PDF has.
  • Variant processors do transformations like rotating, cropping, and resizing.
  • Previewers generate thumbnails for non-image files such as PDFs or videos.

Because existing classes with existing APIs sit behind those settings, Hotcell can provide matching twin classes. Switching should be a config change, and switching back should be just as easy.

Hotcell is five gems: hotcell-core for shared pieces; a Hotcell client and server that define how work is done, how error codes are returned, and how the two sides communicate; and Active Storage client and server gems that subclass them to make the drop-in replacement possible.

16:49

Unix sockets and passing file descriptors

The open question was communication between client and server without a network interface. The answer is Unix sockets: a file on disk that processes can read and write, a common way to do interprocess communication, and not routable on a network.

The key detail, which Dalessio called "like magic," is that the sendmsg system call can pass open file descriptors between processes. When an image is uploaded, the Hotcell client in the Rails app opens the file and hands the descriptor to the Hotcell container. The container can't see the host filesystem, but it can use that one file because it was given the handle. All inputs and outputs work this way.

Step five, actually doing the work, is a supervisor/worker design like Active Job or Puma. Dalessio said it was straightforward and didn't dwell on it. The finished system, he said, is running in production at 37signals now. Having scared the audience, he told them they should run it too.

Rolling it out

After installing the gem, hotcell install creates three files in a hotcell subdirectory:

  • A Gemfile.
  • A Hotcell config file. It is plain Ruby running in a Ruby process, not a Rails process. It sets global limits, which he suggested tuning once things are running.
  • A third file used to configure the cell itself.

For deployment, the only thing shared between the app and the cell is a directory containing two Unix sockets. That directory is configured in the app and in the accessory configuration. The accessory configuration also sets volumes to match the application, Docker container limits, and capabilities, including network: none, which Dalessio pointed to as proof that the system needs no network.

On the Rails side, you replace whichever analyzer, variant processor, and previewer classes you use with their Hotcell twins, set the two socket properties, and you're done. In the cell's configuration you require classes explicitly, because it isn't a Rails app and Zeitwerk autoloading doesn't work there. This is also where per-operation limits go: the cell might have a 10-second deadline overall, but a specific operation could be capped at 2 seconds. For a vanilla Active Storage and Action Text app, he said, that's all it takes: configure Active Storage, configure Hotcell, deploy.

Observability, cost, and flexibility

The observability model follows from the trust model. The worker inside the container can't be trusted, because it might be compromised. The supervisor and the Rails app can be. So the Rails app logs Hotcell calls the way it logs database hits, the app periodically pulls metrics such as queue depth from the supervisor, and the supervisor writes JSON logs to stdout, including which children were killed and why. 37signals uses this for an internal Grafana dashboard, and alerting can be set up however a team prefers.

The overhead is about 8 milliseconds per call. Dalessio said that was lower than he expected, though maybe still higher than people would like. He thinks it can come down and believes the current cost is related to garbage collection. His view is that it's cheap insurance against getting "zeroed" the next time ImageMagick has a problem.

Other options include running multiple cells on different sockets with different queue depths, timeouts, and deadlines; operations with multiple input and output files; and bringing your own container in a corporate environment, with a conformance test that checks whether the container is configured correctly.

Writing custom operations

Custom operations borrow heavily from Active Job. An operation class has a name (his example was "extract text"), default limits that can be overridden, and a perform method that takes whatever arguments it needs. In the Rails app, a thin shim calls perform_in_hotcell, analogous to perform_later for jobs. Hotcell serializes the arguments, runs the operation in the cell, and returns the resulting file.

The one extra step is opening the input and output files before the call, because the cell needs live file descriptors. Dalessio expects that to be hidden inside whatever class wraps the operation.

Lessons from production

Rolling Hotcell out in HEY and Fizzy, both relatively modern apps using vanilla Active Storage, was "absolutely no problem." Basecamp was harder. Active Storage was extracted from Basecamp, which never went back and cleaned up, so Dalessio had to write many custom operations. Basecamp also uses attachments heavily; its users, he said, love sending each other memes.

There were a couple of outages along the way. He didn't go into detail, only that he learned from them and folded the lessons back into the library. He recommends budgeting time for tuning.

He considers Hotcell ready to use now but not yet 1.0. He is waiting for a Rails release, wants to add more observability features and some syntactic sugar, and may add Linux Landlock. He asked anyone with Landlock experience to talk to him. As he described it, Landlock is a kernel feature that lets a process ratchet down its own permissions permanently. For example, a process could give up the ability to fork and exec, and even an attacker who takes over the process couldn't get it back. He called this a "suspenders" layer on top of the existing protections.

25:07

From fear to a call to action

In closing, Dalessio said it's easy, and forgivable, to blame AI for this situation, but he wanted a more nuanced view. He sees this as a chaotic period that is trending in a good direction. The alternative to working through it is that "we all quit our jobs, turn off our computers, and become sheep farmers." He wants the Star Trek future, not the Terminator one that David had mentioned that morning.

He reframed the scary HackerOne chart as the system working as designed: people are finding vulnerabilities and reporting them responsibly. It's frustrating to deal with, but the number of vulnerabilities is finite, the current rate can't last, and eventually there will be a "last vulnerability party." He also corrected his earlier statistic: the 378 libvips-related vulnerabilities were not just found, they were fixed. Maintainers are fixing problems and shipping updates, and he takes that as reason for confidence.

He then gave three things the audience can do:

  • Ship agent skills with vulnerability reports. In response to the CVE, he published a repo extracted from the work he did to check 37signals' own apps. You can point an agent at it and at your application and ask whether you're vulnerable ("Probably") and whether you were exploited. The agent goes through your Active Storage database records to answer. If you need to rotate your secret key, you can point it at the Rails guides for help. He sees this as how the community gets through the current period.
  • Attack your own work. While building Hotcell, he ran an adversarial review on almost every commit. Jeremy helped with red teaming by setting agents loose to break in, and they found gaps. That doesn't make a library perfect, but it means fewer problems, and doing it across our own software gets everyone through the chaos faster.
  • Improve or replace existing systems. In his view, the only way through is for software to get better or be replaced, and Hotcell is his example of the latter.

He ended by asking people not to be too scared, but to be proactive and constructive, so that the future is "Star Trek, not Terminator."