taise

Discourseで発見したCache Poisoning

Discourseに報告された脆弱性を眺めていたらCVE-2024-47773が目に止まったので調べているとその過程で悪用可能な問題を2件発見したのでDiscourseに報告しました。

CVE-2024-47773

zereさんによって報告されたこの脆弱性は、未認証状態でサイトに訪れた場合に利用されるアプリケーションキャッシュ(Annonymouse Cache)の機能を悪用したCache Poisoningでした。

CVE-2024-47773のパッチでCacheキーの生成にXHRリクエストかどうかの判定が追加されていました。

is_xhr = @env["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest") == 0 ? "t" : "f"
        @cache_key =
          +"ANON_CACHE_#{is_xhr}_#{@env["HTTP_ACCEPT"]}_#{@env[Rack::RACK_URL_SCHEME]}_#{@env["HTTP_HOST"]}#{@env["REQUEST_URI"]}"

実際、Discourseはpreload_jsonにより、XHRリクエストやGETリクエストかどうかレスポンスに差異が生じます。 https://github.com/discourse/discourse/blob/v3.3.2/app/controllers/application_controller.rb#L436-L450

  # If we are rendering HTML, preload the session data
  def preload_json
    # We don't preload JSON on xhr or JSON request
    return if request.xhr? || request.format.json?

    # if we are posting in makes no sense to preload
    return if request.method != "GET"

    # TODO should not be invoked on redirection so this should be further deferred
    preload_anonymous_data

    if current_user
      current_user.sync_notification_channel_position
      preload_current_user_data
    end
  end

同じURLに対するリクエストでも、XHRリクエストとナビゲーションリクエストでレスポンスに差異があるため、XHRリクエストのレスポンスをキャッシュさせることでCache Poisoning DoSを起こしていたことが分かります。

このコードを眺めていた際に、preload_jsonCVE-2024-47773のパッチでXHRリクエストの判定方法が異なることに気づきました。


CVE-2024-55948

CVE-2024-47773パッチでのXHRリクエスト判定

@env["HTTP_X_REQUESTED_WITH"]&.casecmp("XMLHttpRequest") == 0 ? "t" : "f"

preload_jsonでのXHRリクエスト判定

request.xhr?

Railsのxhr?メソッドの実装は以下の通りです。

rails/actionpack/lib/action_dispatch/http/request.rb

request.xhr?

# aka
def xml_http_request?
  /XMLHttpRequest/i.match?(get_header("HTTP_X_REQUESTED_WITH"))
end
alias :xhr? :xml_http_request?

ということで、X-Requested-With: xxxXMLHttpRequestのようなヘッダーを付与するとpreload_jsonではXHRリクエストとして判定されますが、Annonymouse Cacheの判定ではXHRリクエストではないとして扱われます。

DiscourseはXHRリクエスト用のレスポンスを返しますがAnonymouse CacheはこのヘッダーはナビゲーションリクエストとしてキャッシュしてしまうことでCache Poisoningが可能でした。


CVE-2024-55948

Annonymouse Cacheの実装では、cacheable?メソッドを使用してレスポンスをキャッシュするかどうかを決定します。

cacheable?メソッドの中でget?メソッドが呼ばれています。

https://github.com/discourse/discourse/blob/v3.3.2/lib/middleware/anonymous_cache.rb

      def cacheable?
        !!(
          GlobalSetting.anon_cache_store_threshold > 0 && !has_auth_cookie? && get? &&
            no_cache_bypass
        )
      end

get?の実装ではRackのREQUEST_METHOD環境変数の値を確認していました。

      def get?
        @env["REQUEST_METHOD"] == "GET"
      end

REQUEST_METHOD環境変数はX-HTTP-Method-Overrideヘッダを使うことでメソッドを上書きできるようになっています。

rack/lib/rack/method_override.rb

    def call(env)
      if allowed_methods.include?(env[REQUEST_METHOD])
        method = method_override(env)
        if HTTP_METHODS.include?(method)
          env[RACK_METHODOVERRIDE_ORIGINAL_METHOD] = env[REQUEST_METHOD]
          env[REQUEST_METHOD] = method
        end
      end

一方で、preload_jsonメソッドは、request.methodに基づいてpreloadするかどうかを決定します。 request.methodはメソッドオーバーライドの影響を受けず、元のHTTPリクエストメソッドを返します。 この挙動を悪用してCache Poisoningが可能でした。

POST /  HTTP/1.1
[...]
X-HTTP-Method-Override: GET
[...]