GMO Flatt Security Blog

株式会社GMO Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

GMO Flatt Security株式会社の公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

【RubyKaigi 2025クイズ解説】GitLabの事例に学ぶ、Webアプリケーションの重大な脆弱性

GMO Flatt Securityの大崎です。RubyKaigi 2025の弊社ブースで「バグバウンティクイズ」に回答いただいた皆様ありがとうございました。 私はブースには立ちませんでしたが、今回作問を担当しました。各脆弱性の解説ブログとして本記事を執筆させていただきます。

ブースでも主に紹介させていただいたセキュリティ診断AIエージェント「Takumi」に関しては以下のバナーより詳細をご覧ください。

Takumi ウェイトリスト登録受付中 先着順でご案内

バグバウンティクイズ in RubyKaigi 2025とは?

言わずと知れたRuby製アプリケーション・GitLabはバグバウンティプログラムを運用していて、その中で2024年に報告された脆弱性に支払った年間報奨金総額はなんと100万ドル(約1億5000万円)以上でした。GitLab程優秀なRubyist達によって開発されていそうなプロダクトであっても、脆弱性対策は避けては通れないということです。

報告された脆弱性はセキュリティリリースによって公開されます。筆者は趣味でレポート・リリースを追いかけており、「開発者にとって脆弱性は他人事ではない」ことがわかりやすく伝わればと思い、RubyKaigiのブース企画としてこれらの脆弱性をテーマにしたクイズを制作しました。

RubyKaigi 2025における弊社ブースの様子

※クイズとその選択肢はブースでのわかりやすさのため簡略化した部分もあり完全ではない部分もあります、ご了承ください。また「考えることで答えを当てられる」ことを必ずしも意図していません。

1日目の問題

$35,000の報奨金が支払われた、アカウント乗っ取りの脆弱性は、どの機能の追加がきっかけだったでしょうか?

正解を見るにはこちらをクリック 正解:B(サブメールアドレスによるパスワードリセット)

題材とした脆弱性

項目 概要
CVSSスコア 10.0
CVE番号 CVE-2023-7028
報奨金額 $35,000(約525万円)
HackerOne レポート https://hackerone.com/reports/2293343
GitLab Issue gitlab-org/gitlab#436084
レポート提出日 2023年12月20日
GitLab公式の修正リリース情報 GitLab 16.7.2 Critical Security Release
発見者 asterion04

解説

この問題は、2023年に報告され、2024年の最初のセキュリティリリースで修正された、Account Takeover via Password Reset without user interactions(ユーザーの操作を必要としないパスワードリセットによるアカウント乗っ取り)の脆弱性を題材としています。

パスワードリセット機能において、本来はメールアドレスを「文字列形式」でのみ受け付けるべきでしたが、実際には「配列形式」での入力も許容されていました。そのため、攻撃者が被害者のメールアドレスと自分のメールアドレスを配列形式で送信すると、本来は被害者だけに送信されるべきパスワードリセットリンクが攻撃者にも送信されました。その結果、攻撃者は被害者のパスワードを自由に変更でき、アカウントを乗っ取ることができました。

脆弱性の再現手順

  1. GitLabの「Forgot Your Password?」リンクをクリック.
  2. 被害者のメールアドレスを入力し、Burp Suiteでリクエストをインターセプト.
  3. インターセプトしたリクエストをJSON形式に変換.
  4. JSONのemailフィールドを文字列ではなく、以下のような配列に変更.
"user": {
  "email": [
    "[email protected]",
    "[email protected]"
  ]
}

この操作により、被害者と攻撃者の両方に被害者のパスワードリセットリンクが送信され、攻撃者が被害者のパスワードを変更しアカウントを乗っ取れる状態となっていました。上記の再現手順は、レポートに記載された内容です。

脆弱性の原因となった機能の詳細

GitLabが「認証済みのサブメールアドレスからパスワードリセットを可能にする」ために導入した機能に起因します。以下のコードがその実装です。

以下のような実装が追加されました。

# app/models/concerns/recoverable_by_any_email.rb
module RecoverableByAnyEmail
  extend ActiveSupport::Concern

  class_methods do
    def send_reset_password_instructions(attributes = {})
      return super unless Feature.enabled?(:password_reset_any_verified_email)

      email = attributes.delete(:email)
      super unless email

      recoverable = by_email_with_errors(email)
      recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted?
      recoverable
    end

    private

    def by_email_with_errors(email)
      record = find_by_any_email(email, confirmed: true) || new
      record.errors.add(:email, :invalid) unless record.persisted?
      record
    end
  end

  def send_reset_password_instructions(opts = {})
    return super() unless Feature.enabled?(:password_reset_any_verified_email)

    token = set_reset_password_token
    send_reset_password_instructions_notification(token, opts)

    token
  end

  private

  def send_reset_password_instructions_notification(token, opts = {})
    return super(token) unless Feature.enabled?(:password_reset_any_verified_email)

    send_devise_notification(:reset_password_instructions, token, opts)
  end
end

詳細なコードフロー解説

(1) ユーザーがパスワードリセットフォームからリクエストを送信すると、DeviseのPasswordsControllerの create 関数が実行されます。

def create
  self.resource = resource_class.send_reset_password_instructions(resource_params)
  yield resource if block_given?
  if successfully_sent?(resource)
    respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
  else
    respond_with(resource)
  end
end

(2) GitLab側でオーバーライドされたsend_reset_password_instructions関数が呼ばれ、リクエストで指定したメールアドレスを取得します。

    def send_reset_password_instructions(attributes = {})
      return super unless Feature.enabled?(:password_reset_any_verified_email)

      email = attributes.delete(:email)
      super unless email

      recoverable = by_email_with_errors(email)

(3) 次に、by_email_with_errors 関数が呼び出され、メールアドレスに紐づくユーザーを検索します。

def by_email_with_errors(email)
  record = find_by_any_email(email, confirmed: true) || new
  record.errors.add(:email, :invalid) unless record.persisted?
  record
end

(4) find_by_any_email 関数から by_any_email 関数を呼び出しメールアドレスに紐づくユーザーを検索します。このとき、引数として指定されるemailは文字列または配列形式を許容していました。

配列で複数のメールアドレスが指定された場合、takeメソッドにより、配列内の最初に一致した有効なメールアドレスに紐づくユーザーが取得します。

# app/models/user.rb
def find_by_any_email(email, confirmed: false)
  return unless email

  by_any_email(email, confirmed: confirmed).take
end

def by_any_email(emails, confirmed: false)
  from_users = by_user_email(emails)
  from_users = from_users.confirmed if confirmed

  from_emails = by_emails(emails).merge(Email.confirmed)
  from_emails = from_emails.confirmed if confirmed

  items = [from_users, from_emails]
  user_ids = Gitlab::PrivateCommitEmail.user_ids_for_emails(Array(emails).map(&:downcase))
  items << where(id: user_ids) if user_ids.present?

  from_union(items)
end

(5) 取得したユーザーに対し、パスワードリセットのメールが送信されます。この際、メールの送信先として、リクエストで指定されたメールアドレス(配列形式)がそのまま送信オプションとして渡されます。

send_reset_password_instructions 関数は内部的にRailsのActionMailerを呼び出していたため、ActionMailerの仕様により、メールの送信先に配列を指定することが許可されていました。その結果、配列で指定されたメールアドレスすべて(攻撃者および被害者)にパスワードリセットリンクが送信されてしまいました。

ActionMailerの仕様:https://apidock.com/rails/ActionMailer/Base/mail

recoverable.send_reset_password_instructions(to: email) if recoverable&.persisted?

まとめ

この脆弱性は以下の2つの問題が組み合わさって発生しました。

  1. 入力値のチェックが不十分だったこと
    本来は1つのメールアドレス(文字列)のみを受け付けるべきでしたが、配列形式で送信された場合にもエラーとならず、配列内の最初のメールアドレスを使ってユーザーを特定してしまう挙動となっていました。

  2. 宛先として配列をそのまま使ってメールを送信したこと
    パスワードリセット処理にはRailsのActionMailerが使用されていましたが、ActionMailerは宛先に配列を指定すると、その全てのアドレスへメールを送信する仕様になっています。このため、リクエストで指定されたメールアドレスの配列がそのままメール送信の宛先として使われ、本来のユーザーだけでなく攻撃者にもパスワードリセットリンクが届いてしまいました。

これら2つの問題により、攻撃者が被害者のパスワードリセットリンクを不正に入手し、アカウントの乗っ取りが可能となってしまいました。

攻撃の流れ

  1. 攻撃者が被害者のメールアドレスと自分のメールアドレスを配列で送信
  2. GitLabが配列形式のメールアドレスをそのまま受け入れ、処理
  3. GitLabは配列内の最初のメールアドレス(被害者のアドレス)に紐付いたユーザーを特定
  4. 被害者宛のパスワードリセットリンクをメール送信(ActionMailerが配列宛先を許容しているため、攻撃者にも同じリンクを送信)
  5. 攻撃者も被害者と同じパスワードリセットリンクを受け取る
  6. 攻撃者がパスワードを変更して被害者のアカウントを乗っ取る

学び

  • ユーザー入力のバリデーションが重要
    • 文字列を期待するパラメーターに配列や予期しないデータ形式の値を許容しない必要がある
  • Deviseなど認証に関する外部ライブラリの拡張は、特に慎重に行う
    • Deviseをはじめとする認証関連ライブラリの機能を拡張する際には、思わぬセキュリティ問題を引き起こすことがある

2日目の問題

GitLabのSAML認証における任意のユーザーになりすましが可能な脆弱性は、どのような原因によるものでしょうか?

正解を見るにはこちらをクリック 正解:C(ライブラリの0-day脆弱性(未発見の脆弱性))

題材とした脆弱性

項目 概要
CVE番号 CVE-2024-45409
CVSSスコア 9.8
報奨金額 不明(CVSSスコアより数百万円と推測される)
GitLab公式の修正リリース情報 GitLab Critical Security Release
GitLab Issue gitlab-org/gitlab#486565
発見者 ahacker1

解説

この問題は、GitLabで使用されているRuby SAML gemに存在した0-day脆弱性(CVE-2024-45409)を題材としています。

GitLabがSAML認証に利用しているRuby SAMLライブラリにおいて、SAMLレスポンスの検証をする際に、本来は署名済みの正規のDigest値を参照すべきところ、攻撃者が挿入した偽のDigest値を誤って参照する不具合が存在しました。この問題により、攻撃者はSAMLレスポンス内のAssertionを改ざんした上で、改ざんしたレスポンスに対応する偽のDigest値を署名対象外に挿入することで、検証をバイパスし、任意のユーザーのアカウントを乗っ取ることが可能でした。

なお、攻撃者がこの脆弱性を悪用するためには、なりすましたいユーザーを識別する属性(NameIDやメールアドレスなど)を推測または事前に知っている必要があります。

脆弱性の原因となった機能の詳細

この脆弱性は、Ruby SAMLライブラリにおいて、SAMLレスポンスからdigest値を取得する際に誤ったXPathクエリが使用されたことが原因です。

脆弱なコード

encoded_digest_value = REXML::XPath.first(ref, "//ds:DigestValue", { "ds" => DSIG })
  • XPathのセレクタ //ds:DigestValue は、XMLドキュメント内のどこでも一致するという広い条件が使用されていました。

  • XPath.firstは、最初に見つかった要素のみを取得するため、攻撃者が署名対象外の場所(例:<samlp:Extensions> 内)に偽のDigestValueを挿入すると、その値が署名検証に使われてしまいました。

このXPathの不適切な使用により、攻撃者はSAMLレスポンスの検証を回避し、任意のユーザーとしてログインすることが可能となりました。

脆弱性の再現手順

  1. 正規のSAMLレスポンスを取得 自身のアカウントを使って認証を行い、IdPから送信される正規のSAMLレスポンスを取得します。

  2. SAMLレスポンスを改ざんする 取得したSAMLレスポンスのうち、<saml:Assertion>内のユーザー情報を管理者に書き換えます。
    さらに、改ざん後の<saml:Assertion>を元に新しくDigest値を計算し、その偽のDigest値を署名対象外の領域(例:<samlp:Extensions>セクション)に挿入します。

  3. 改ざんしたレスポンスを送信 改ざんされたレスポンスをGitLabに送信する。

  4. 署名検証のバイパス XPathの脆弱性により、署名検証時に本来参照されるべき署名済みのDigest値ではなく、攻撃者が挿入した偽のDigest値が参照され、署名検証が誤って成功します。

さらに詳しい技術的解説については、以下の記事をご参照ください。

Ruby SAML CVE-2024-45409: 技術的詳細解説

上記の再現手順はこの記事を参考に作成しました。

まとめ

Ruby-SAMLライブラリにおいて、攻撃者が署名対象外に挿入した偽のDigest値を、レスポンスの改ざん検証時に誤って参照してしまう問題がありました。この問題により、攻撃者はSAMLレスポンスを改ざんし、検証をバイパスして任意のユーザーになりすますことが可能でした。

学び

ソフトウェアサプライチェーンという言葉で語られることもありますが、自前で実装した部分だけでなく、使用しているライブラリも脆弱性の原因になり得ます。慎重なライブラリ選定に加えて、使用しているライブラリに脆弱性が発見された時はすぐにアップデートできるような管理体制を整えておくことが重要です。

なお、GMO Flatt Securityが提供するセキュリティ診断AIエージェント「Takumi」であれば、採用予定のOSSの脆弱性診断を依頼したり、ライブラリのセキュリティアドバイザリが自社のコードベースに影響があるかの調査を依頼できます。

3日目の問題

Slackとのインテグレーションに脆弱性がありました。どのような影響がある脆弱性だったでしょうか?

正解を見るにはこちらをクリック 正解:A(Slackで /gitlab <project> deploy... 等の任意のコマンドを実行できる)

題材とした脆弱性

項目 概要
CVE番号 CVE-2023-5356
CVSSスコア 7.3(High)
報奨金額 不明
HackerOneレポート N/A
レポート提出日 2023年12月20日
GitLab公式の修正リリース情報 GitLab 16.7.2 Critical Security Release
GitLab Issue #427154
発見者 yvvdwf

解説

この問題は、GitLabに報告された「Attacker can abuse Slack/Mattermost integrations to execute slash commands as another user(攻撃者がSlack/Mattermostインテグレーションを悪用し、別のユーザーとして任意のスラッシュコマンドを実行できる脆弱性)」を題材としています。

この脆弱性を悪用することで、攻撃者は本来Slackから送信されるHTTPリクエストを偽装し、被害者のSlackユーザーになりすましてGitLab上でスラッシュコマンドを実行することが可能でした。

その結果、攻撃者が指定したプロジェクトにおいて被害者の権限でworkflowが実行され、被害者の権限を持つ CI_JOB_TOKEN が発行されます。 このトークンを利用して、攻撃者は、被害者のアクセス権限のあるパブリックプロジェクトに対して被害者の権限で、不正なコード実行やデプロイを行える可能性がありました。 ※ なお、公式レポートには「被害者の権限で workflow を実行できる」とのみ記載されており、パブリックプロジェクトに対する具体的な影響までは明記されていません。

脆弱性の原因となった機能の詳細

GitLabのSlackインテグレーションでは、Legacy(旧式)のスラッシュコマンド連携方式が使用されていました。 この方式では、Slack側が署名によるリクエストの正当性検証をサポートしていなかったため、GitLab側では受信したリクエストに含まれるトークンが、プロジェクトに設定されているトークンと一致するかどうかのみを検証していました。

そのため、攻撃者が有効なトークンと任意の user_id および team_id を指定することで、他のSlackユーザーになりすまし、スラッシュコマンドを実行することが可能でした。

SlackからGitLabに送られるHTTPリクエストの例:

POST /api/v4/projects/:project_id/services/slack_slash_commands/trigger HTTP/1.1
Content-Type: application/x-www-form-urlencoded

token=SLACK_WORKSPACE_TOKEN&team_id=T123456&user_id=U123456&text=deploy staging to production
  • token: GitLabプロジェクトとSlackワークスペースの連携設定時にSlackが発行するトークン
  • team_id: Slackワークスペースの一意な識別子
  • user_id: Slackワークスペース内のユーザーの一意な識別子
  • text: GitLabで実行するスラッシュコマンド

以下のコードの実装となっていました。

module Integrations
  class BaseSlashCommands < Integration
    attribute :category, default: 'chat'

    def valid_token?(token)
      self.respond_to?(:token) &&
        self.token.present? &&
        ActiveSupport::SecurityUtils.secure_compare(token, self.token)
    end
    ...

    def trigger(params)
      return unless valid_token?(params[:token])

      chat_user = find_chat_user(params)
      user = chat_user&.user

      if user
        unless user.can?(:use_slash_commands)
          return Gitlab::SlashCommands::Presenters::Access.new.deactivated if user.deactivated?

          return Gitlab::SlashCommands::Presenters::Access.new.access_denied(project)
        end

        Gitlab::SlashCommands::Command.new(project, chat_user, params).execute
      else
        url = authorize_chat_name_url(params)
        Gitlab::SlashCommands::Presenters::Access.new(url).authorize
      end

(1) トークンの検証 (valid_token?)

リクエストに含まれるトークンが、GitLabプロジェクトに設定されたトークンと一致するかどうかを検証。

(2) GitLabユーザーの特定 (find_chat_user(params))

リクエストに含まれる team_iduser_id を使用して、GitLabの対応するユーザーを特定。

したがって、上記の実装では、攻撃者が有効なトークンを入手し、さらに正規ユーザーのuser_idとteam_idを取得することで、任意のSlackユーザーになりすまして、そのユーザーの権限でworkflowを実行できる脆弱性が存在していました。また、これらのSlackユーザー情報は、インターネット上から比較的簡単に取得できる可能性があり、攻撃成立の可能性は低くないとレポートで指摘されています。

攻撃再現手順

以下の再現手順はGitLabのissueに記載された内容です。

被害者側の準備

(V-1) GitLabでプロジェクトを作成(例: victim/project-a

(V-2) Slackワークスペースとの連携を設定し、次のスラッシュコマンドで動作を確認

/gitlab victim/project-a issue new issue-title

(V-3) 攻撃者を同じSlackワークスペースに招待する

攻撃者側の操作

(A-1) 招待されたSlackワークスペースに参加し、被害者のプロフィールURLから以下を取得

  • team_id(例: T05TGJXXXX
  • user_id(例: U05TX1JXXXX

(A-2) GitLabで新規プロジェクトを作成し、被害者ユーザーをMaintainerとして追加する。

(A-3) 作成したプロジェクトのルートに、以下の.gitlab-ci.ymlを設置する。

 test:  
  script:  
    - echo hi ${GITLAB_USER_LOGIN}  
    - echo run $CHAT_INPUT  
    - bash -xc "$CHAT_INPUT"

(A-4) GitLabのプロジェクト設定からMattermostスラッシュコマンドを有効化し、トークンを設定する(例: abcdef

(A-5) ターミナルで次のcurlコマンドを実行する(PROJECT_IDはGitLabプロジェクトのIDを、team_iduser_idは取得した値を使用)

curl -X POST https://gitlab.com/api/v4/projects/{PROJECT_ID}/services/mattermost_slash_commands/trigger \
--data 'token=abcdef&user_id=U05TX1JXXXX&team_id=T05TGJXXXX&text=run test date'

(A-6) GitLabのCI/CDパイプラインを確認すると、被害者ユーザーの権限でパイプラインが実行されていることを確認できる。

GitLabによる修正

GitLabはこの問題への対策として、スラッシュコマンドの初回実行時にユーザー自身がブラウザ経由で明示的に認証を行う仕組みを導入しました。この認証プロセスでSlackのユーザー情報(user_id、team_id)とプロジェクトのトークンを紐付けてデータベースに保存し、その後スラッシュコマンドが実行されるたびに、リクエストに含まれるプロジェクトトークンとSlackユーザー情報に紐づくトークンが一致するかをリクエストの度に検証するように修正されました。

これにより、攻撃者が単にトークンのみを不正に入手したとしても、Slackユーザー情報(user_id、team_id)との紐付けが検証されるため、不正なスラッシュコマンドの実行を防ぐことが可能になりました。

ただし、SlackのLegacyスラッシュコマンドは署名によるリクエストの正当性の検証をサポートしていないため、Slackワークスペースの管理者などの内部の悪意あるユーザーがトークンおよび紐付けられたSlackユーザー情報を両方取得した場合は、リクエストを偽装して不正なスラッシュコマンドを実行できる可能性があると考えられます。ただし、このような攻撃が成立するには、攻撃対象ユーザーが初回に明示的に認証し、紐付けを許可した特定のSlackワークスペース内でなければならず、攻撃条件は限定的であると考えられます。

まとめ

この脆弱性は、以下の2つの問題が組み合わさって発生しました。

  • Legacy(旧式)のスラッシュコマンドが署名付きリクエストをサポートしていなかったこと
    • GitLabがSlackからのリクエストを検証する際、トークンの一致確認のみを行っており、リクエストが本当にSlackから送られたものであることを正しく検証できていませんでした。
  • ユーザー情報(user_idやteam_id)の整合性検証が不足していたこと
    • 攻撃者は、自分で作成したGitLabプロジェクトに任意のトークンを設定し、その後Slack上の他ユーザーのuser_id・team_idを不正に利用することで、別のユーザーになりすましてCIパイプラインを不正にトリガーできる状態でした。

学び

  • 外部サービスとの連携時は検証方法を確認する
    • 外部サービスが提供するリクエストの検証方法を理解し、適切に使用することが大切
  • 古いインテグレーションは積極的にアップデートする
    • 旧式のインテグレーションはセキュリティ問題を含むことがあるため、なるべく早く新しいバージョンへ移行しましょう。
  • 複数の認証要素間の整合性を確認する
    • トークンの一致確認だけに頼らず、関連する識別情報(user_idやteam_idなど)と整合性を同時に検証することが大切

終わりに

以上、GitLabのバグバウンティレポート、セキュリティリリースを追いかけている筆者による脆弱性紹介でした。Rubyistの皆様やそれ以外の皆様にも、セキュアなアプリケーション開発の参考になれば幸いです。

RubyKaigi 2025のブース、および記事冒頭でもお伝えしたように、GMO Flatt Securityはセキュリティ診断AIエージェント「Takumi」を提供しています。月額7万円(税別)で脆弱性をソースコード解析により探したり、コードベースの調査を依頼できます。

2025年4月現在、ウェイトリストに登録いただいた皆様に順次ご利用案内を差し上げておりますので、ご興味のある方はぜひウェイトリストにご登録ください。

Takumi ウェイトリスト登録受付中 先着順でご案内

※リスト登録のみでサービスの利用開始や課金が自動的に発生することはありません。

「Takumi」以外にも、以下のようなサービスを提供し、ソフトウェアプロダクトの開発組織のセキュリティをサポートしています。ご興味のある方はお気軽にお問い合わせください。

また、GMO Flatt Security はセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Xのフォローをぜひお願いします!

ここまでお読みいただきありがとうございました。