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

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」や「パーサー」に関することじゃないからね。

セキュリティの問題を除いても、データモデルをAPIに密接に結びつけるのは本当に良くないアイデアだよ。DBスキーマに変更があったら、APIに壊れる変更が出る可能性があるし。リクエストとレスポンスで別々の型が必要なら、それでいいと思うよ。

これはフィールドingのREST論文からの概念で、現在のRESTの意味にとって重要だよ。転送オブジェクトはストレージオブジェクトではないってこと。

うん!

こういう問題は簡単に避けられるって分かってるけど…やっとプロトコルバッファの魅力が見えてきたよ。プログラミング言語に関係なく、一貫したシリアライズ/デシリアライズの体験が保証されるっていう安心感がいいね。

それが本当ならいいんだけどね :-) でも、言語自体に関係する理由でそうじゃないんだ。例えば、C++の数字はJavaの数字とは違うし、Pythonの数字とも違う。JavaScriptの数字も同様だし、文字列も同じことが言える。

RPCスタイルのJSON APIをOpenAPI仕様に書いて、そこから構造体やルートハンドラーを生成することで、似たようなメリットが得られると思うよ。僕は大体のGoプロジェクトでそうしてるし。

でも、この記事はパーサーバグについてではないから、異なるデータフォーマットを使っても、そこで説明されているほとんどの問題からは逃れられないと思うよ。

記事には触れられてなかったけど、プライベートフィールドを小文字にしてプライベートにするのが明らかな方法じゃない?(Go開発者じゃないけど、ちょっと触ってみただけ) type User struct { Username string json:"username_json_key,omitempty" Password string json:"password" isAdmin bool } https://go.dev/play/p/1m-6hO93Xce

json:"-"をアノテーションすると、@JsonIgnoreと同じ意味になるよ。

それはうまくいくけど、他のパッケージからisAdminにアクセスできなくなるよ。

プライベートフィールドをプライベート(小文字)にするのが明らかな方法じゃない?それだと他のことに影響が出るかも。例えば、gormはプライベートフィールドを無視するから、UserをDBにシリアライズしたい時に不便だよね。

攻撃シナリオ2の場合、セキュアな設計でクライアントからのデータを認証サービスに転送する理由が分からない。これはむしろ壊れたベストプラクティスって感じだね。「パースして、バリデートしない」っていうのが論理的だと思うし、その後にパースしたデータを使って作業するべきだよ。[0]

これ見てみて: https://bsky.app/profile/filippo.abyssdomain.expert/post/3le... これは暗号の署名ラッピング攻撃についてだけど、ここにも当てはまるよ。

それに、DisallowUnknownFieldsを使うと、前方互換性や後方互換性のあるAPI変更を扱うのがかなり難しくなるってことも覚えておく価値があるよ。これは非常に一般的で、通常は望ましいパターンなんだから。

これが一般的な見解だけど、最近はこれが実はアンチパターンじゃないかと思い始めてる。過去にいくつかのケースで、追加フィールドがパースを壊さないことや、プログラムの主要な機能に影響しないことがあったけど、エッジケースで微妙な不正動作を引き起こすことがあった。例えば、実際には異なる値が、異なるフィールドが無視されることで等しいものとして扱われることとか。最近では、LLMがツールの呼び出し中に無視されたツール入力フィールドを幻覚して混乱するのを見たことがある。パースが正確でなければならないAPIを作ってみたいと思ってる。変更があるたびにAPIの新しいバージョンが必要になるように。実際にはそれほど難しくないと思うけど、レスポンスをダウングレードしたり古いバージョンを非推奨にするためのツールが必要かもしれないね。

クライアントを書くなら、これが問題になるかもね。サーバーを書くなら、ルールは一度有効だった入力は永遠に有効でなければならないから、フィールドを削除することはないと思う。DisallowUnknownFieldsの主な利点は、クライアントが間違ったり無駄なものを送ったときに気づきやすくなることだよ。

この説明がとても明確でわかりやすいのは素晴らしいね。もっと技術的な文章はこうあるべきだと思う。ところで、1つの文字列がXML、JSON、YAMLとして解析できるなんて驚きだよね。

こういう問題(特にパーサーの差異)があるから、encoding/xmlを使ったGoのSAML実装は信用しない方がいいよ。そもそもその用途のために設計されてないからね。僕は自分のSAML用に自分で書いたよ。(まあ、そもそもSAMLを使わない方がいいけど。)

問題はGoのパーサーではなく、処理層が検証層とは異なる入力を使っていることだね。 私たちはgosaml2(他のGo SAMLライブラリも)を修正して、認証されたバイトだけを処理するようにしたよ(元のXMLドキュメントは処理しない)。パッチはここで見れるよ: https://github.com/russellhaering/goxmldsig/commit/e1c8a5b89... https://github.com/russellhaering/gosaml2/commit/99574489327...

自分のSAML用に自作したよ。君のSAMLとXML署名の実装が見てみたいな。

これはビジネスロジックでDTOを再利用したり、異なるAPI間でDTOを共有する人たち向けだね。セキュリティのためにアンマーシャルしたくないフィールドを持つDTOを作るなんて考えたこともなかったよ。Goは「絶対に繰り返せ!」の言語なのに、これが二重に変だね。ちょっと愚痴を言うと、JS(TypeScriptでも)にはイライラする。これは避けられないからね。スプラットがさらに悪化させる。でも、異なるDTOとビジネスオブジェクトを使ってスプラットを使わない方がいいと思う。(...をリンタエラーにするのもありかも)

これ、TypeScriptでは全然避けられないことじゃないよ。アプリケーションの構造によるところが大きいけど、今や境界でデータをパースするためにzodとかを使うのが標準的なやり方になってる。ここは注意が必要かもしれないけど(例えば、zodの.strictを使うのを忘れないようにね)、絶対に避けられないわけじゃないよ。