Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Lessons from CVE-2025-22869: Memory Debugging a...

Lessons from CVE-2025-22869: Memory Debugging and OSS Vulnerability Reporting

Avatar for vvatanabe

vvatanabe

August 26, 2025
Tweet

More Decks by vvatanabe

Other Decks in Technology

Transcript

  1. きっかけ:メモリ急増 → OOM 対象:Git over SSH を提供する ECS Fargate タスク

    症状:散発的なメモリ急上昇(90%付近) 影響:冗長化により表面的には顕在化せず 発生頻度:月に数回/時刻や負荷条件は不定 ガッとスパイクする雰囲気 脆弱性の特定プロセス
  2. ログからの手がかり:PuTTY(パティ) バナー SSH 接続直後のバナー文字列(例 SSH-2.0-OpenSSH_9.4 )を収集 OOM 前後±30s のログを抽出 SSH-2.0-PuTTY_Release_*

    の長時間接続が高頻度で同時刻に観測 利用比率は低いはずのクライアントが、このタイミングだけ集中 → PuTTY系クライアントからの接続になにかヒントがあるのでは 脆弱性の特定プロセス
  3. メモリデバッグ(1) pprof でヒープを取得し、ホットスポットを特定: (pprof) tree Showing nodes accounting for 110.67MB,

    98.66% of 112.18MB total Dropped 16 nodes (cum <= 0.56MB) ----------------------------------------------------------+------------- flat flat% sum% cum cum% calls calls% + context ----------------------------------------------------------+------------- 110.67MB 100% | golang.org/x/crypto/ssh.(*channel).WriteExtended 110.67MB 98.66% 98.66% 110.67MB 98.66% | golang.org/x/crypto/ssh.(*channel).writePacket → 所見:メモリの ほぼ全て(98.66%)が writePacket 経路に集中 → コールパスを上から順に深ぼる 脆弱性の特定プロセス
  4. メモリデバッグ(2) ROUTINE ======================== golang.org/x/crypto/ssh.(*channel).Write in ***/ssh/channel.go 0 110.67MB (flat, cum)

    98.66% of Total . . 530: . . 531:func (ch *channel) Write(data []byte) (int, error) { . . 532: if !ch.decided { . . 533: return 0, errUndecided . . 534: } . 110.67MB 535: return ch.WriteExtended(data, 0) . . 536:} . . 537: . . 538:func (ch *channel) CloseWrite() error { . . 539: if !ch.decided { . . 540: return errUndecided → アプリからの Write は WriteExtended に委譲 → 実際のバッファ処理は下位(WriteExtended 以降)で発生 脆弱性の特定プロセス
  5. メモリデバッグ(3) ROUTINE ======================== golang.org/x/crypto/ssh.(*channel).WriteExtended in ***/ssh/channel.go 0 110.67MB (flat, cum)

    98.66% of Total . . 266: if extendedCode > 0 { . . 267: binary.BigEndian.PutUint32(packet[5:], uint32(extendedCode)) . . 268: } . . 269: binary.BigEndian.PutUint32(packet[headerLength-4:], uint32(len(todo))) . . 270: copy(packet[headerLength:], todo) . 110.67MB 271: if err = ch.writePacket(packet); err != nil { . . 272: return n, err . . 273: } . . 274: . . 275: n += len(todo) . . 276: data = data[len(todo):] → 実際の割当は ch.writePacket の先にある 脆弱性の特定プロセス
  6. メモリデバッグ(4) ROUTINE ======================== golang.org/x/crypto/ssh.(*channel).writePacket in ***/ssh/channel.go 110.67MB 110.67MB (flat, cum)

    98.66% of Total . . 210: if ch.sentClose { . . 211: ch.writeMu.Unlock() . . 212: return io.EOF . . 213: } . . 214: ch.sentClose = (packet[0] == msgChannelClose) 110.67MB 110.67MB 215: err := ch.mux.conn.writePacket(packet) . . 216: ch.writeMu.Unlock() . . 217: return err . . 218:} . . 219: . . 220:func (ch *channel) sendMessage(msg interface{}) error { → channel.writePacket → mux.conn.writePacket (トランスポート層) → トランスポート側( handshakeTransport.writePacket )でのメモリ割当が支配的 → ライブラリ内部 x/crypto/ssh の問題が濃厚に 脆弱性の特定プロセス
  7. コード読解:v0.34.0(修正前) 鍵交換完了まで送信できないため、一時キューに貯める実装: func (t *handshakeTransport) writePacket(p []byte) error { //

    省略... if t.sentInitMsg != nil { // ← 鍵交換進行中 cp := make([]byte, len(p)) // 書き込みバッファをコピー copy(cp, p) t.pendingPackets = append(t.pendingPackets, cp) // ← 無制限に伸びる return nil // 実送信されない } // 省略... → pendingPackets は、件数・バイト長とも上限なしの [][]byte → 鍵交換完了前に送信されたデータが pendingPackets に溜まり続けている 脆弱性の特定プロセス
  8. コード読解:v0.34.0(修正前) 鍵交換完了後に吐き出し、 len=0 だけリセット、 cap は残留: func (t *handshakeTransport) kexLoop()

    { // 省略... t.pendingPackets = t.pendingPackets[:0] // ← `len=0` だけリセット、`cap` は残留 // 省略... → 同じ配列を再利用でき再割り当てとGC負荷を削減できるメリットもあるが... → 同じ接続内で一度でも巨大化した cap は残り続ける → 再鍵交換で再び応答が遅れると、コード上の上限が無いため再度膨張しうる 脆弱性の特定プロセス
  9. なぜ「PuTTY 系」で膨張が顕著? 事象のトリガはクライアント実装差やネットワーク遅延の可能性がある。 根本原因は x/crypto/ssh 側の「鍵交換中キュー無制限」 という実装。 どのクライアントであっても、応答が遅れた場合に膨張しうる。 今回は PuTTY

    経路が露呈しやすかっただけ。 ただし、鍵交換の数百ms〜数s遅延で大きく影響するため、OpenSSHと比較してPuTTY の鍵交換の応答性能が若干低い可能性も考えられる 脆弱性の特定プロセス
  10. 改善案の方針 1. handshakeTransport に sync.Cond を導入 2. 最大許容サイズ(例: 10M )を定義

    3. 鍵交換中は退避前に合計サイズをチェック → 上限超過時は Cond.Wait() で待機 4. 鍵交換完了後に退避分を吐き出し → Broadcast() で待機goroutineを一斉解除 → 既存の順序保証の仕組みのまま待機・追記するため、送信順は崩れない セキュリティパッチの要点
  11. 改善案の実装(1) handshakeTransport に sync.Cond を導入 type handshakeTransport struct { //

    ... mu sync.Mutex + cond *sync.Cond pendingPackets [][]byte } func newHandshakeTransport(...) *handshakeTransport { t := &handshakeTransport{ /* ... */ } + t.cond = sync.NewCond(&t.mu) return t } セキュリティパッチの要点
  12. 改善案の実装(2) 最大許容サイズ(例: 10M )を定義 + const maxPendingPacketSize = 10 *

    1024 * 1024 // 10 M 注意: maxPendingPacketSize は運用環境に合わせて可変にする(例:2–16M) セキュリティパッチの要点
  13. 改善案の実装(3) 鍵交換中は退避前に合計サイズをチェック → 上限超過時は Cond.Wait() で待機 func (t *handshakeTransport) writePacket(p

    []byte) error { // 省略... if t.sentInitMsg != nil { // 鍵交換中 + total := 0 + for _, pkt := range t.pendingPackets { total += len(pkt) } + if total+len(p) > maxPendingPacketSize { + t.cond.Wait() // pendingPackets が空くまで待機 + } cp := append([]byte(nil), p...) t.pendingPackets = append(t.pendingPackets, cp) // 省略... 注意:合計サイズの再計算は O(n) セキュリティパッチの要点
  14. 改善案の実装(4) 鍵交換完了後の解放&通知 func (t *handshakeTransport) kexLoop() { // 省略... for

    _, p := range t.pendingPackets { t.writeError = t.pushPacket(p) if t.writeError != nil { break } } t.pendingPackets = t.pendingPackets[:0] + t.cond.Broadcast() // 待機中の writePacket を起床 // 省略... } セキュリティパッチの要点
  15. 答え合わせ:実際のコード x/crypt/ssh では maxPacket = 256K 。256K × 64 =

    16M が保留上限の目安。 + const maxPendingPackets = 64 補足:今回の「最大16M」の根拠は maxPacket (=256K) 。 // OpenSSH caps their maxPacket at 256kB so we choose to do // the same. maxPacket is also used to ensure that uint32 // length fields do not overflow, so it should remain well // below 4G. maxPacket = 256 * 1024 セキュリティパッチの要点
  16. 答え合わせ:実際のコード 上限件数なら len(slice) で O(1)。“十分に小さい近似値”で シンプル & 安全。 func (t

    *handshakeTransport) writePacket(p []byte) error { // 省略... if t.sentInitMsg != nil { // 鍵交換中 + if len(t.pendingPackets) < maxPendingPackets { + // Copy the packet so the writer can reuse the buffer. + cp := make([]byte, len(p)) + copy(cp, p) + t.pendingPackets = append(t.pendingPackets, cp) + return nil + } + for t.sentInitMsg != nil { + // Block and wait for KEX to complete or an error. + t.writeCond.Wait() セキュリティパッチの要点
  17. レポートフォーマット例(概要) 宛先/件名: [email protected] / “Vulnerability report …” 対象: golang.org/x/crypto/ssh (Target

    Version と 該当コードのURLを明記) 本文:メール本文に「Vulnerability」 (ヴァルネラビリティー)という単語を含める(ス パムフィルタ回避) トーン:客観・再現可能性重視、攻撃悪用の示唆は最小限 Goへ脆弱性報告の流れ
  18. レポートフォーマット例(本文構成) ## Overview - 事象の一文要約:鍵交換中のメモリ急増 / 再現条件(PuTTY 系クライアント && 大容量転送)

    - 問題の原因箇所(pendingPackets 無制限蓄積) - 追加情報提供の意思表示・協力の申し出 ## Impact DoS 可能性(メモリ枯渇)/ 非悪性ケースでも高負荷化の説明 ## Details - 原因の該当コード断片と参照リンク - 証拠:`pprof` のツリー出力(ヒープの支配箇所を数値で提示) ## Proposed Solutions (改善案) - 方針:上限 + sync.Cond によるバックプレッシャー - 手順:sync.Cond 追加 → 上限設定 → writePacket で待機 → 鍵交換完了でBroadcast - 利点/ 考慮点:メモリ抑制・順序保持/ブロッキング・デッドロック回避 Goへ脆弱性報告の流れ
  19. もし返信がない場合 7日以内に返信がない場合は、Go Securityチーム([email protected])まで再度連絡す る。 If you have not received a

    reply to your email within 7 days, please follow up with the Go Security team again at [email protected]. さらに、3日経っても報告に対する確認メールが届かない場合は、代替経路 BugHunters で報 告する。 If after 3 more days you have still not received an acknowledgement of your report, it is possible that your email might have been marked as spam. In that case, please file an issue here. Goへ脆弱性報告の流れ
  20. レポートタイムライン 日付 出来事 2024/11/02 [email protected] へ初回レポート 2024/11/11 7日以内の応答なし → リマインド

    2024/11/18 3日以内に応答なし → 代替経路 BugHunters で再投稿 → ACK 2024/12/15 メンテナサイドでセキュリティFixのコミット作成 2025/02/25 プライベートIssue作成 → 修正マージ → プライベートIssueクローズ 2025/02/26 CVE-2025-22869 公開(NVD / CVSS 7.5 HIGH) Goへ脆弱性報告の流れ
  21. ふりかえり(脆弱性報告) まず「脆弱性」かを判定: 無権限で再現可能/DoSに直結/影響範囲が広い ⇒ バグではなく脆弱性として扱う。 正しい窓口で報告: Go Security Policyに従い [email protected]

    に送る(公開IssueはNG) 。 件名と本文に “Vulnerability”: スパム回避・トリアージ明確化のため 件名/本文に明記。 レポート: 冒頭で対象と影響範囲を明示、本文は、概要、影響、再現手順、対処案を漏れなく。 フォローアップSLAを守る: 7日応答なし→再送、さらに3日で BugHunters 経由へエスカレーション。 ふりかえり
  22. ふりかえり(再発防止) 無制限バッファを作らない: const maxPendingPackets = 64 のようにコードで上限 バックプレッシャーを必ず掛ける: sync.Cond /

    バッファ付channel 原因調査は数字から入る: ログ・メトリクスで事実 → pprof で深掘り → 該当コードの理解 回帰テストは資産: 脆弱性が再現しないテストコードで再発防止 ふりかえり