世界を動かす技術を、日本語で。

Goのパーサーにおける予期しないセキュリティの落とし穴

概要

GoアプリケーションにおけるJSON、XML、YAMLパーサの予期せぬ挙動が、認証・認可バイパスや機密情報漏洩の脆弱性を引き起こす事例が多発。 パーサ設定ミスや設計ミスによる攻撃シナリオを具体例とともに解説。 Go標準ライブラリと主要YAMLライブラリの挙動差分にも注意が必要。 安全なパーサ設定方法と、Go標準ライブラリのセキュリティギャップを補う戦略を提案。 実際の脆弱性事例や検出ルールも紹介。

Goアプリケーションにおけるパーサの危険な落とし穴と攻撃シナリオ

  • 未検証データのパース による攻撃面の拡大

    • JSON、XML、YAMLパーサ の挙動を突いた攻撃事例多数
    • CVE-2020-16250(Hashicorp Vault認証バイパス)など、実際の脆弱性事例
  • 主な攻撃シナリオ

    • (Un)Marshaling unexpected data(意図しないデータの(逆)シリアライズ)
      • 本来非公開にすべきフィールドの露出・操作
    • Parser differentials(パーサ間の挙動差異)
      • サービス間でパース結果が異なることで認証・認可バイパス
    • Data format confusion(異なるデータ形式の混入)
      • 予想外の形式をパースし、意図しない動作や情報漏洩
  • パーサのセキュリティ状況一覧

    • JSON, XML, YAMLの各項目ごとに「安全」「デフォルトで危険」「安全設定不可」など分類
      • 例:重複キーはJSON/YAMLは「最後を採用」、XMLはサポート外
      • フィールド名の大文字・小文字無視なども挙動が異なる

Goにおけるパース処理の基礎

  • 標準ライブラリ: encoding/json, encoding/xml
  • YAML: サードパーティ製(yaml.v3が主流)
  • Marshal/Unmarshal による構造体⇔フォーマット変換
  • 構造体タグ でフィールドごとのパース挙動を制御
    • 例:json:"username_json_key,omitempty"

例:User構造体のパース

  • 構造体タグでキー名・オプション指定
  • Unmarshal時はタグで指定したキー名でのみ値がセットされる
  • io.Reader対応のストリームAPIも提供(HTTPリクエスト処理等で利用)

攻撃シナリオ1: 意図しないデータの(逆)シリアライズ

  • 一部フィールドのみタグ指定 時の落とし穴

    • タグ未指定フィールドも名前でパース可能
    • セキュリティ意識の低い実装で権限昇格などのリスク
  • タグの設定ミス例

    • json:"-,omitempty" と誤記:-キーで値がセットされてしまう
      • 実例:Flipt、langchaingoプロジェクトで発生(既に修正済み)
    • 正しい設定json:"-"のみで完全に(逆)シリアライズ対象外
  • omitemptyの誤用

    • json:"omitempty" と記述:フィールド名が omitempty となる
      • 例:Gitea、Kustomize等で発生(既に修正済み)
    • 正しくは json:",omitempty"
  • 検出ルール

    • Semgrepルールでコードベースの誤用を検出可能
      • 例:semgrep -c r/trailofbits.go.unmarshal-tag-is-dash

攻撃シナリオ2: パーサ間の挙動差異(Parser differentials)

  • マイクロサービス構成 での典型的な脆弱性

    • Proxy ServiceとAuthorization Serviceで異なるパーサ利用
    • 同一入力に対し、サービス間で解釈が異なり認可バイパス発生
  • 実例

    • CVE-2017-12635(Apache CouchDBの認可バイパス)
    • macOSサンドボックスエスケープ(XMLパーサ差異)
    • 0-click Zoom RCE(XMLパーサ差異)
    • GitLab SAML認証バイパス(XMLパーサ差異)
  • 重複キーの扱い

    • GoのJSONパーサは「最後の値」を採用

    • 他言語やパーサでは挙動が異なる場合があり、攻撃者が意図的に差異を突くことが可能

    • 例:

      • {"role": "user", "role": "admin"} Goのパーサでは role"admin" になる

パーサ設定の安全化と対策

  • 全フィールドに明示的なタグ付与 推奨

    • 意図しないフィールドの(逆)シリアライズ防止
  • タグ設定ミスの自動検出

    • Semgrep等の静的解析ツール活用
  • パーサ間の挙動差異を認識し、統一

    • クリティカルな認証・認可処理は同一パーサ・設定を使用
  • Go標準ライブラリの限界を認識

    • セキュリティギャップを補うための追加バリデーション実装
  • ドキュメントとテストの徹底

    • 仕様・挙動を明文化し、ユニットテストでパースの境界条件を網羅

このように、Goアプリケーションでのパーサ設定・運用には細心の注意が必要です。 小さなミスや設計上の盲点が、重大なセキュリティインシデントにつながる可能性があるため、 全ての開発者・セキュリティエンジニアは、パーサの仕様と落とし穴を正しく理解し、 安全な実装・運用を徹底してください。

Hackerたちの意見

これ、すごく面白かったけど、あのポリグロットなjson/yaml/xmlペイロードにはびっくりした!GoのデフォルトのXMLパーサーが、前後にゴミがあっても受け入れるなんて知らなかった。JSONはパースするのが簡単なフォーマットだと思ってたけど、現実はそうじゃないみたいだね。「重複キーがあったらどうする?」みたいな一見無害な条件に関する決定が、長い影響を及ぼすのは興味深い。

PythonのJSONパーサーを壊すための脱線: これが5年間うまくいってる。ドキュメントには無効な部分をパースするとRecursionErrorになるとは書いてない。JSONDecodeErrorとUnicodeDecodeErrorが指定されてる。(デフォルトでオフのキーに関するRecursionErrorの参照があるけど、オフでもこれを引き起こすことができる…) #!/bin/sh

Pythonは再帰制限に達する

再帰制限から4少ない数を供給すると

コールスタックにいくつかのオブジェクトがあることを意味すると思う

おそらく: main, print, json.loads, input.

n="$(python3 -c 'import math; import sys; sys.stdout.write(str(math.floor(sys.getrecursionlimit() - 4)))')" echo "N: $n"

明らかに無効だけど、ペアが一致しないとパースできない

JSONの文法は…部分的にパースされるのが得意じゃない。

left="$(yes [ | head -n "$n" | tr -d '\n')"

期待されるdecodeErrorで爆発する代わりに

RecursionErrorで爆発する

これは自然にメモリキャッシュを荒らす。

echo "$left" | python3 -c 'import json; print(json.loads(input()))'

Goプログラマーじゃない私から見ると、フィールドメタデータに文字列(構造体タグ)を使うのは、Rustのマクロ(コンパイル時にメタデータをパースする)やJavaのアノテーション(ランタイムで処理されるけど、少なくともオプションを分解するために文字列をパースする必要がない)に比べて、ちょっと逆行してるように見える。偶然のomitemptyや-は、実際には問題を引き起こさないかもしれないけど、奇妙さの良い例だね。

ある人には愚かさに見えるし、他の人には素晴らしさに見える。Goの80/20デザインの一例だね:20%の複雑さとコストで80%の機能を提供する。構造体タグは、使いやすい方法で重要なシナリオに対応してる。でも、アノテーションのように他のシナリオには対応しようとはしてない。機能タグでもないし、変数タグでもない。一般的なアノテーションでもない。構造体フィールド専用のアノテーションなんだ。アノテーションやマクロほど強力かって?もちろん、全然違うよ。実装や理解、使用が複雑かって?それも違う。80/20デザインだね。20%のコストで80%の機能。

構造体タグは、フィールドをマッピングするために必要なボイラープレートコードを大幅に減らしてくれる。理解すれば、本当に新しいアイデアだよ。

.NETプログラマーとして、メタデータの「stringly typed」な性質にはゾッとするけど、Goの選択にはずっと混乱してる。だから、あなたが言うように、.NETではJavaと同じように属性がある。例えば、

[JsonPropertyName("username")]
[JsonIgnore]

これはシンプルで明白だよ。JsonPropertyName属性はオーバーライドで、クラス全体の命名ポリシーを設定できる。デフォルトはcamelCaseで、kebab-caseやsnake_caseなどの代替もある。C#/.NETは、公開プロパティがデフォルトでシリアライズされ、プライベートプロパティはされないという利点があるから、公開したくないものを公開する可能性は低い。これは、Goのアプローチ、つまりPythonのように、プライベートとパブリックフィールドを決定するためにケースの規約を使うのとは対照的だね。(もし間違ってたら教えてね?)最初の例はまだ混乱してるけど、IsAdminをユーザーから取得したいなら、デシリアライズする必要があるし、そうでないならDTOに入れるべきじゃない。デシリアライズはちょっとした赤いニシンで、「このユーザーには管理者を作成する権利があるか?」という検証ステップが必要だと思う。ユーザークラスを持って、デシリアライズされたユーザー入力から直接プロパティを更新するのは変な感じがするけど、私は「エンタープライズプログラマー」として、すべての間にレイヤーを置きたいと思ってるだけかもしれない。

はい、これは多くの理由からひどいアイデアです、セキュリティだけじゃなくて。文字列の中に隠れた、定義があいまいで理解が不十分なDSLのようなものだよ。でも、使わなければいいだけだよ。マップにアンマーシャルして、必要なキーを選んで、検証を行ってから値を設定すればいい。同じことが公開時にも言えるけど、これらの理解が不十分な文字列キーに基づいてデフォルトで全てを公開するのではなく、公開するキーを明示的に定義したビューを持つ方が好きだな。

定期的にGoを使ってる者として言うけど、構造体のタグは本当に面倒だよ。複数の「アノテーション」を一つの構造体タグ文字列にまとめようとすると、さらに厄介になるし。こうなってる理由は、Goが哲学的にアノテーションやマクロのアイデアに非常に反対していて、明確な制御フローを重視してるからなんだ。これが私がこの言語を好きな理由の一つでもある。でも、そのせいでJSONやXMLのマッピングなど、アノテーションのいくつかの非常に便利なユースケースが使いにくくなってしまうんだ。Goでのコンパイル時マクロのアイデアは面白いけど、同時にプログラム内のGoの制御フローをデバッグしたり理解したりするのが簡単なのも、私がこの言語を好きな理由の一つだから、Goでのメタプログラミング能力が増えた結果、必然的に「魔法の」ウェブフレームワークが生まれる可能性を招きたくないな。だから、この結果を受け入れる準備はできてるかな。 :/

そもそも「create user」リクエストDTOに「IsAdmin」があるのは何で?例を見ると、データモデルの不適切な再利用を示してるみたい。こうした方が良くない?

type CreateUserRequest struct {
    Username string
    Password string
}
type UserView struct {
    Username string
    IsAdmin  boolean
}

DBの行に1:1でマッピングするモデルを1つだけ持つ必要はないよ。これはどの言語にも当てはまる。

1つのモデルだけでDBの行に1:1でマッピングする必要はない。これはどの言語にも当てはまる。 その理由の一つは、データを常にコピーするのを避けるため。効率の観点だけじゃなく、シンプルさの観点からもね。データを構造体に機械的に入れるライブラリがあって、その構造体からさらに別の構造体にデータを移すなら、そのライブラリの意味は何なの?結局、データを移動させるためのコードを書いてるだけだよ。私の仕事では、狭い構造体がたくさんあって、それが何とかライブラリに統合されてるのをよく見るけど、その間をコピーするためのサポートコードが大量に必要になる。結局、データを使って実際に役立つことはほとんどないんだ。もっと太い構造体を使って、変な「構造体埋め込み」ライブラリと統合しない方が、幸せになれると思う。

そうだね、これは悪いパターンだよ。かなりの数のプロダクションコードベースで見られると思う。

その通り。これはランタイムやメタファーの欠陥じゃなくて、アプリがプライベートデータをパブリックAPIで公開してるのが問題なんだ。これは実装の選択に関係なく起こるミスだよ。RESTfulインターフェースやCGIスクリプトでも同じ失敗ができる。せいぜい、シンプルなシリアライズライブラリ(Goのは確かに最高の一つだけど)が「データをそのまま送る」ことを誘惑するって言えるかもしれないけど、目を細めて見ても、これを「足元をすくう」って呼ぶのは無理があると思う。残りの見出しは100%ナンセンスだよ。これは「Go」や「パーサー」に関することじゃないからね。

Hacker Newsで議論の続きを見る