knqyf263's blog

自分のためのメモとして残しておくためのブログ。

至高のGoプラグイン用ツールを作った

最近YouTuberのリュウジの料理を毎日作っているので至高とか無限とか言いがちですが個人の感想です。万人にとって美味しい料理はないように、万人にとって至高のツールは存在しません(何の話?)。ちなみに公開してすぐバグを見つけてしまったので全然至高じゃありませんでした。

要約

Goでプラグイン機構を実現するためのツールを作りました。Protocol Buffersのスキーマからコードを自動生成するので簡単にプラグイン機構を実現可能です。内部的にはWebAssembly(Wasm)を使っています。最近はWasmはブラウザ外での利活用が進んでおり、今回のツールもブラウザは一切関係ないです。Wasmはサンドボックス内で実行されるためファイルアクセスやネットワークアクセスはホストが許可しなければプラグインは行えずセキュリティ的に安全ですし、異なるアーキテクチャ用に複数バイナリを用意しなくてもプラグイン用にWasmバイナリを1つだけ作れば良いので配布も簡単です。これらの特徴から、サードパーティ製のコードを動かす必要のあるプラグイン用途に向いている気がしたので作りました。

github.com

概要

Goでプラグインを実現する方法はいくつかありますが、どれも一長一短でこれだ!というものがないのが現状かと思います。分かりやすく比較を載せてくださっている記事があったので置いておきます。

zenn.dev

Goには plugin パッケージがありますが自由度が高すぎてシグネチャのズレが容易に生じてしまいますし、あまり実例も少ないです。

上のブログ内でも言及されていますが、サードパーティ製で一番普及しているのはHashicorpにより開発されている hashicorp/go-plugin だと思います。Terraform等のOSSで採用されているため実例も多いです。内部はプラグインのバイナリがサーバとして動きツール側がクライアントとしてRPCで通信するアーキテクチャとなっています。プラグイン作りたいと思った人は100%知っていると思います。gRPCに対応しておりProtocol Buffersのprotoファイルを書けばクライアントとサーバ用のコードが自動生成できるため開発体験が良いです。ただし、プラグインと言ってもただの実行可能なバイナリなのでGoでプラグインを書く場合は環境ごとに異なるバイナリを配布する必要がありますし、単にバイナリを実行するという関係上セキュリティ的にはかなりよろしくないです。あと個人的にはprotobuf-goprotobuf-gen-go-grpc で生成されたコードをプラグインで使えるようにするためのおまじないが必要で、書きっぷりが直感的じゃないなと感じています(これは自分がアホなだけの可能性もあります)。

もっともらしい理由を書いてきましたが、一言でいうと自分の美学に合わないというのが一番の理由です。趣味のOSS開発の動機なんかそんな感じでいいと思います。競合の調査とか真面目にやってるとどれも凄そうに見えて永遠に作り始められませんし、自分のツールこそが最高!(お前がそう思うんならそうなんだろう お前ん中ではな)と思っておけば良いと思います。

ということで別の方法を探していて、WebAssembly (Wasm) が使えないかと考えました。1年以上構想を温めてきたのですがようやく納得行くアーキテクチャを思いついたので作りました。それが以下のツールです。

github.com

マスコットは妻にさっと描いてもらいました。名前は go-penguin 略してゴーペンくんです(適当)。

gRPCのようにProtocol Buffersの定義ファイルを渡すとWasm用のSDKを生成します。プラグイン側はそのSDKに従ってGoのinterfaceを実装するだけでプラグインが作れるようになっています。ホスト側もSDKが生成されるのでそれをimportしていくつか関数を呼び出すだけです。hashicorp/go-pluginと異なりプラグイン用のコード生成も行うためプラグイン開発者の負担はかなり低くなります。またSDKが自動生成されるため、Wasmに関する知識がなくても扱えるのも利点です。

プラグインはGoで書けるのですが、GoがWASI(ブラウザ外でWasmを使うためのSystem Interface)に対応していない関係でビルドはTinyGoで行う必要があります。TinyGoはGoに比べると機能が不足しており例えば encoding/json などが使えないのですが、その辺はHost Functionsを使ってある程度解消可能です。Host Functionsの使い方は後述します。

ただWasmは最近2.0のドラフトが出たばかりですし、WASIも2年前に snapshot_preview1が出てからまだ1.0に到達していませんし、現時点で安定的に使えるかと言うとまだ微妙な感じではありますが今後に期待というところです。

特徴

knqyf263/go-plugin の特徴として以下のものがあります。

  • Goのインタフェースを実装するだけで良い
    • SDKが自動生成されるため、Wasmを意識せずにGoのインタフェースを実装するだけでプラグインが作れる
  • セキュア
    • Wasmはメモリセーフかつサンドボックス上で実行されるため安全に実行できる
    • プラグインがpanicを起こしてもホスト側でハンドル可能
  • ポータブル

他にも色々あるのですが、そちらはREADMEを見ていただければと思います。

使い方

以下で使い方の説明をしていきますが、基本的に全てREADMEに書いてある内容なので直接GitHub見てもらったほうが早いかもしれません。サンプルコードもGitHubにいくつか置いています。

まず最初に開発フローについて説明し、その後に具体的な使い方を見ていきます。

流れ

  1. まずプラグイン用のインタフェースをProtocol Buffersの定義ファイルに書きます
  2. go-plugin を使ってGoのSDKを生成します
  3. 2でGoの interface が生成されるのでプラグインでそれを実装します
  4. TinyGoを使ってプラグインをWasmにコンパイルします
  5. ホスト側でWasmを読み込んで定義したインタフェースに従って関数を呼び出します

事前準備

まず必要なツールをインストールします。go-pluginprotocプラグインとして動作するため、事前に protoc をインストールしておく必要があります。そして go-plugin のバイナリは以下でダウンロード可能です。適当にパスの通った場所に置いてください。

github.com

あとはTinyGoも必要なのでインストールします。

インタフェースの定義

Protocol Buffersと同じフォーマットを使います。.proto ファイルを書いたことがない人は以下のドキュメントを読むと良いです。extensionsとか一部の機能を除けば基本的な機能はシンプルなのですぐ理解できると思います。

developers.google.com

大まかに言うと messageservice で成り立っており、 message が型を、 service がインタフェースを定義します。例を見てみます。

syntax = "proto3";
package greeting;

option go_package = "github.com/knqyf263/go-plugin/examples/helloworld/greeting";

// The greeting service definition.
// go:plugin type=plugin version=1
service Greeter {
  // Sends a greeting
  rpc SayHello(GreetRequest) returns (GreetReply) {}
}

// The request message containing the user's name.
message GreetRequest {
  string name = 1;
}

// The reply message containing the greetings
message GreetReply {
  string message = 1;
}

Greeter というサービスが SayHello という関数を持っています。 rpc と言っていますが、 go-plugin ではRPC通信は行わず、単にプラグインとのインタフェースを定義するためだけに使っています。

SayHello の引数は GreetRequest で戻り値は GreetReply になっています。それぞれはその下で message として定義されています。どちらもstringの値を一つ持つだけです。

ちなみに // go:plugin で始まる行が go-plugin 用のserviceであることを示すプラグマとなっています。これを書かないとスルーされます。特に type=pluginプラグイン用のインタフェースであることを示すために必ず必要です。

go_package は自分のプロジェクトのパッケージを指定します。

SDKの生成

上でprotoファイルが出来たら次にGoのコードを生成します。protoc が$PATH内の protoc-gen-go-plugin を自動で見つける仕組みになっているので、必ずパスの通った場所に protoc-gen-go-plugin を置いてください。

$ protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative greeting.proto

成功すれば greet.pb.go, greet_host.pb.go, greet_plugin.pb.go, greet_vtproto.pb.go の4つのファイルが生成されているはずです。

プラグインの実装

以下のようなGoの interface が生成されています。

type Greeter interface {
    SayHello(context.Context, GreetRequest) (GreetReply, error)
}

プラグインはこれを実装してstructをRegister関数経由で登録するだけで終わりです。例えば以下のように実装します。

//go:build tinygo.wasm

package main

import (
    "context"

    "github.com/path/to/your/greeting"
)

// main is required for TinyGo to compile to Wasm.
func main() {
    greeting.RegisterGreeter(MyPlugin{})

}

type MyPlugin struct{}

func (m MyPlugin) SayHello(ctx context.Context, request greeting.GreetRequest) (greeting.GreetReply, error) {
    return greeting.GreetReply{
        Message: "Hello, " + request.GetName(),
    }, nil
}

もうプラグイン用のWasmとしてビルドできる状態です。簡単ですね。画期的です。SDKを自動生成するアプローチだからこういうことが可能になっています。

$ tinygo build -o plugin.wasm -scheduler=none -target=wasi --no-debug plugin.go

ターゲットをWASIにしている関係でバイナリがそこそこ大きいです。WASIの機能が不要な場合に -target=wasm で何とかやれないかなーという野望もありますが、現状はWASI必須です。

ホストの実装

ホスト側のサンプルコードを載せます。

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/path/to/your/greeting"
)

func main() {
    ctx := context.Background()
 
    // Initialize a plugin loader
    p, err := greeting.NewGreeterPlugin(ctx, greeting.GreeterPluginOption{})
    if err != nil {...}

    // Load a plugin
    plugin, err := p.Load(ctx, "path/to/plugin.wasm")
    if err != nil {...}

    // Call SayHello
    reply, err := plugin.SayHello(ctx, greeting.GreetRequest{Name: "go-plugin"})
    if err != nil {...}
 
    // Display the reply
    fmt.Println(reply.GetMessage())
}

コード生成したパッケージをimportすると greeting.NewGreeterPlugin() などは自動生成されていて使える状態になっています。これはただのローダなので、その後に Load(ctx, "/path/to/plugin.wasm") で実際のプラグインを読み込んでいます。ローディングが終わればもう SayHello が呼べる状態になっています。今回は SayHello の戻り値を単に標準出力に表示しています。

実行

通常通り上のホスト用ファイルを実行します。

$ go run main.go
Hello, go-plugin

ということで無事にプラグインから返されたメッセージを表示できました。ほぼ同様のことを行うサンプルコードは以下に置いてあります。

go-plugin/examples/helloworld at 1ebeeca373affc319802989c0fe6304f014861c4 · knqyf263/go-plugin · GitHub

発展

基本的な機能は上で説明したのですが、いくつか発展的な内容についても書いておきます。

Host Functions

プラグイン側のコードはTinyGoでビルドすることになるのですが、一部機能が不足しており使えない標準パッケージがあります。

tinygo.org

例えばJSONの操作に使う encoding/json に対応していません。そのような場合は gjsoneasyjson などTinyGoでも動作可能なサードパーティ製のライブラリを使う必要があります。

また、Wasmはセキュリティを考慮して作られているためファイルアクセスなどが制限されています。WASIによってファイルシステムへのアクセスは限定的に可能になっていますが(後述します)、ネットワークアクセスなどは現在の go-plugin ではできません。これはTinyGoの制約ではなくWasmの制約です。また、Wasmランタイムとして利用しているwazeroで対応していないWASIの関数というのもあります。以下のwazeroのドキュメントにも書かれていますが、WASIの仕様がまだpreviewでいまいち固まりきっていないのが理由のようです。

wazero.io

そういった場合の方法としてhost functionを使う方法があります。これは名前の通り関数をホスト側で定義し、それをプラグインが呼び出せるようにする仕組みです。ホスト側はGoでコンパイルされるので encoding/json なども利用可能です。他にもプラグイン側にロギング用の関数やHTTP通信するための関数を提供したり、といったことが可能です。

host functionの定義は簡単で、上でプラグイン用のインタフェースを定義したprotoファイル内に以下のようにserviceを書きます。 //go:plugin type=host がhost functionを意味するので必須です。引数と戻り値のmessageも定義する必要がありますが今回は省略しています。GitHub/examples で残りも見られます。

// go:plugin type=host
service HostFunctions {
  // Sends a HTTP GET request
  rpc HttpGet(HttpGetRequest) returns (HttpGetResponse) {}
}

今回はプラグイン側で実行できないHTTPリクエストを定義しています。そして再度 protoc でコード生成すると今度は以下の interface も生成されます。

type HostFunctions interface {
    HttpGet(context.Context, HttpGetRequest) (HttpGetResponse, error)
}

このインタフェースを実装します。

// myHostFunctions implements HostFunctions
type myHostFunctions struct{}

// HttpGet is embedded into the plugin and can be called by the plugin.
func (myHostFunctions) HttpGet(ctx context.Context, request greeting.HttpGetRequest) (greeting.HttpGetResponse, error) {
    ...
}

protoファイル内にhost functionが定義されていると、 Load() でそのインタフェースを受け取れるようになっています。上で定義した myHostFunctions を渡せばOKです。

greetingPlugin, err := p.Load(ctx, "plugin/plugin.wasm", myHostFunctions{})

ちなみに今回は雑にGETリクエストを送れる関数をexportしましたが、実際にプライベートIPアドレスには送れないようにするなどの制約を行うべきです。何でもかんでも便利関数をexportすると、せっかくWasmでサンドボックスにしているのにガバガバになってしまいます。

ファイルアクセス

前述したようにプラグインはローカルのファイルにはデフォルトではアクセスできないのですが、ホスト側が明示的に許可することでプラグイン側でもファイルへのアクセスが可能になります。

プラグインローダの初期化時にホスト側から fs.FS を渡すことができます。特定のファイルでも良いですしディレクトリでも良いです。

publicDirFS := os.DirFS("./public")
p, err := cat.NewFileCatPlugin(ctx, cat.FileCatPluginOption{
    FS:     publicDirFS,         // Loaded plugins can access only files that the host allows.
})

サンプルコードは以下です。

go-plugin/examples/wasi at main · knqyf263/go-plugin · GitHub

その他

何でprotobufのextensions使わないの?とかTinyGo以外の多言語対応は?とか、その他細かい諸々はREADMEを参照してください。

苦労した点

WebAssemblyはintやfloatぐらいしか引数や戻り値として使えません。より複雑な型を扱うにはどうするかというと、シリアライズしてメモリに書き込みWasm側でデシリアライズするのが一般的です。この辺については前回ブログを書いたので参照してください。

knqyf263.hatenablog.com

Wasmのコードを書いていて気付いたのは、上のシリアライズ・デシリアライズ処理を頻繁に書くということです。じゃあこれを一般化して自動生成できるようにすれば幸せなんじゃないか?と思ったのが go-plugin の最初の動機です。

そこでシリアライズのフォーマットとして選んだのがProtocol Buffersになります。gRPCの普及により慣れ親しんでいる人が多いですし、スキーマドリブンの開発はやはり体験が良いためです。ですが、Wasm用途で広く使われるTinyGoはProtocol Buffersに対応していません(2022/08/29時点)。

github.com

そのため、protobuf-go で生成したコードはTinyGoではコンパイルできません。ここでprotobufの利用は一旦諦めたのですが、どうしてもスキーマドリブンの開発体験を諦めきれずもう少し中を見ることにしました。実際にコンパイルして試したところreflectパッケージの利用が問題のようでした。つまりreflectionを使わずにシリアライズ・デシリアライズできれば動くんじゃないかと考え実装を始めました。そうしたら vtprotobuf を発見しました。このツールはreflectionを使わずにmarshal/unmarshalするコードを生成してくれるのですが、protobuf-goとの併用が必須でprotobuf-goがreflectを使ってしまうため結局コンパイルできません。ならばprotobuf-goをベースにreflectを使わずに再実装すれば動くんじゃないかと思い、protobuf-goとvtprotobufを魔合体して改修して作ったのが go-plugin です。思いつきで始めたものの何とか動くようになって安心しました。

その副産物として、1バイナリで全てのコード生成を行えるようになりました。通常はprotobuf-goなど複数のプラグインを組み合わせてコード生成を行うため色々と事前準備が面倒ですが、 protoc-gen-go-plugin 一つだけprotocプラグインをインストールすればOKなので比較的楽です。

ですが今度はwell-known typesが動かないという問題に遭遇しました。protobufはデフォルトだとintやstringなどのプリミティブ型しか提供していませんが、Googleがtimestamp型などのよく使われる便利なmessageを提供してくれています。

developers.google.com

これは事前にビルドされたものがprotobuf-goのリポジトリに置かれています。コード生成されたときにこれらのパッケージをimportしています。

github.com

これらがreflectを使っているせいで動かないという状態でした。そこで一旦諦めたのですが、well-known typesはよく使われるものだし対応しないわけにもいかないよな...と考え直した結果、自分で各well-known typesのコードを再生成することにしました。TinyGoでコンパイルできる型が以下に定義されています。 import "google/protobuf/timestamp.proto"; とかするとGoogleではなく独自定義のstructが使われるようになっています。何も知らないとprotobuf-goのパッケージじゃないので驚くかもしれませんが、こういう事情があって泣く泣くreplaceしています。

github.com

まだ完全に移行が終わってないのでいくつか関数が足りなかったりしますが、最低限必要なものは動くんじゃないかなという気がします。以下にwell-known typesを使ったサンプルも置いてあります。

go-plugin/examples/known-types at main · knqyf263/go-plugin · GitHub

1年前の長期休暇で何か勉強したいなと思ってRust(初学)でWebAssembly(初学)ランタイムを書くという無謀なことをしたところ案の定作り終わらなかったのですが、手応えを得たのでプラグインのアイディアを思いつきました。ただWasm周りの処理をプラグイン開発者にそのまま書かせるのはしんどすぎるしSDKはどうする?シリアライズはどうする?とか色々悩んでいたら気付いたら1年経っていたという感じです。

他にも乗り越えるべき壁がたくさんあったのですが、寝不足でブログ書くの疲れてきたので一旦ここまでにします。

まとめ

僕の考えた最強のGoプラグイン用ツールの紹介でした。WasmやTinyGoによる制約があるのでどんな要件でも満たせるかというと怪しいのですが、多くのケースで使えるプラグイン用ツールに仕上がったのではないかと思います。WebAssembly 2.0のドラフトも出ましたし、今後より便利になっていくと思います。

久々に0から趣味でOSSを作ったので疲れました。最近は自分用にライブラリやツールを作ることが多かったのでドキュメントとか適当でしたが、やはり人に使ってもらおうとするとドキュメントも必要だし労力が大きいです。疲労で目の痙攣が起こりましたしOSSは身体に悪いです。

なかなか良いアーキテクチャが思いつかず苦労したのですが、その分良いものになったと自負しています。最近時間ない中で睡眠を削ったりして割と頑張ったので広く使ってもらえると良いなと思っていますが、世界中誰にも認められなくても自分だけは自分を褒められる完成度になりました(バグは多分まだたくさんあると思いますが)。誰かに認めてもらおうというモチベーションだと個人OSSは続かないので、少なくとも自分だけは幸せだからヨシ!という気持ちです。一方で知らない誰かと共同でものを作っていくのがOSSの楽しさの一つだとも思っているので、使ってもらうための努力としてドキュメントはちゃんと書いておこう、というダブルスタンダードでやっています。

GoのWasmランタイム上でZigで作ったWasmモジュールを動かす

Zigのコミッタの方から有益なフィードバックを頂いたり、Wasmランタイムのメンテナからコメントしてもらったり、Twitterでプロ開発者に助けてもらったり、と良い話が多かったのでそのへんの話も含めつつメモとして残しておきます。

最初に断っておきますがWasmにもZigにも特に詳しくないです。

概要

自分の開発しているOSSWebAssemblyによるプラグイン機能に対応したのですが*1、Wasmは仕様が小さく関数の引数や戻り値もi32/i64/f32/f64だけで頑張るみたいな世界なので直接ユーザに生のWasm用コードを書いてもらうのは利便性の面で厳しいです。*2

そうなると各言語ごとにSDKを用意する必要があるわけですが、自分の開発しているOSSがGoで書かれていることもあり最初はTinyGoに対応しました。GoだとWASI対応していないのでGo+WASIをやりたい場合は必然的にTinyGoになるかと思われます。TinyGoだとWasmもシュッとビルドできて便利なのですが、Goの標準パッケージで対応されていないものがそこそこあって(v0.25.0時点)何も考えずに普通にGoを書くときの気持ちでいるとビルドできないことが多いです。

tinygo.org

上のドキュメントを見てもらえれば分かりますが encoding/json などが使えなかったりします。あと自分が開発していた時はTinyGo v0.23.0だったためGoのジェネリクスに対応しておらず、適当にパッケージをimportすると死ぬということが多発しました。さらにエラーだとジェネリクスが原因というのがぱっと分からずしばらくハマったりしました。v0.24.0でジェネリクス対応されたようなので今はもう少し楽になっているとは思いますし開発も盛んなのでどんどん便利になっていくと思いますが、自分の用途ではそこまでリッチな機能は不要だったのでWasm用に何か仕様の小さな言語はないかなと探していました。

そこで最近話題になっていたZigを試してみたという記事です。

ziglang.org

Zigがどういう言語かというのは最近日本語でも記事が多く出ているので割愛しますが、1日頑張って読めば終えられるぐらいチュートリアルが短いです。

ziglearn.org

Zig + Wasmの記事はいくつか見つけたのですが大体Hello worldに留まっていました。ちょうど自分がZigを書き始めようとしたタイミングでCloudflareのブログも公開されたので「これは!」と思って中を見たらHello worldだったのでく〜〜〜となってました。

blog.cloudflare.com

挨拶されすぎて世界も困惑してますよ!!!ということでもう少し実践的な内容を自分でやってみたのでメモです。

実装

GoのWasmランタイム

Wasmを動かすためのランタイムとしてはWasmerWasmtime が有名ですが、どちらもRustで書かれています。上述したように自分のOSSはGoで書かれているためGoで使えるランタイムを探していました。Go用の Wasmer Go なども見つけたのですが、CGOを使っており絶対にCGOを使わないポリシーを持つ自分のOSSでは採用できませんでした。

そうしてしばらく探していたところTetrateにより開発されているwazeroが見つかりました。

github.com

CGOを使わずにPure Goで書かれています。さらに凄いことに依存ライブラリも0なので go.mod/go.sum が空です。まさに探していたプロジェクトなので導入を決めました。まだバージョン1.0.0に到達していないので破壊的変更が入ることもありますが、ソースコードもそこまで巨大ではないので自分で読んで何とか出来るサイズなのも嬉しいです。日本人の方( @mathetakeさん )が開発しています。世界に貢献していて凄いですね。READMEにも書かれていますが、元々個人で開発されていたOSSを会社に移しています。

TinyGoのExample

wazeroにはTinyGoとRustのサンプルが付いています。

github.com

今回はTinyGoのサンプルを見てみます。ただコードは変更される可能性があるので基本的にはGitHubを参考にしてください。

ランタイムの初期化

詳細はコードを直接見てもらったほうが早いですが、呼び出す側は以下のようにランタイムを初期化します。

// Create a new WebAssembly Runtime.
r := wazero.NewRuntimeWithConfig(wazero.NewRuntimeConfig().
    // Enable WebAssembly 2.0 support, which is required for TinyGo 0.24+.
    WithWasmCore2())
defer r.Close(ctx) // This closes everything this Runtime created.

以下のように logString というGoの関数を log という名前でWasm側にexportすることも出来ます。なおエラー処理はブログ内では全て省略しています。

// Instantiate a Go-defined module named "env" that exports a function to
// log to the console.
_, err := r.NewModuleBuilder("env").
    ExportFunction("log", logString).
    Instantiate(ctx, r)
}

関数の実行

あとはコンパイルされたWasmモジュールを受け取ってexportされた関数を取得できます。以下では greet という名前でWasm側からexportされている関数を呼び出せるように取得しています。

mod, err := r.InstantiateModuleFromBinary(ctx, greetWasm)
if err != nil {...}

// Get references to WebAssembly functions we'll use in this example.
greet := mod.ExportedFunction("greet")

あとはこの greet を呼び出すわけですが、引数としてstringを渡したいとします。Wasmは関数の引数として直接文字列を受け取ることは出来ないので、Linear Memory (または単にMemory)と呼ばれる任意のバイト列を書込み可能な領域に値を書き込んでそのポインタとサイズを渡します。

// Instead of an arbitrary memory offset, use TinyGo's allocator. Notice
// there is nothing string-specific in this allocation function. The same
// function could be used to pass binary serialized data to Wasm.
results, err := malloc.Call(ctx, nameSize)
if err != nil {...}

namePtr := results[0]
// This pointer is managed by TinyGo, but TinyGo is unaware of external usage.
// So, we have to free it when finished
defer free.Call(ctx, namePtr)

// The pointer is a linear memory offset, which is where we write the name.
if !mod.Memory().Write(ctx, uint32(namePtr), []byte(name)) {...}

コメントに書いてありますが、Wasmを呼び出す側が適当にメモリを確保するよりはTinyGoのメモリアロケータを使うほうが良いようです。自分でメモリを管理するのは大変ですし合理的な感じがします。実際TinyGoは mallocfree をexportしてくれているのでこれを使えるのですが、ドキュメントに記載されていない闇関数とのことです。全然関係ないですが遊戯王ど真ん中世代なので高橋和希先生の訃報は悲しかったです。

github.com

何にせよ上の例では malloc にサイズを渡してメモリを確保し、そのポインタにバイト列(ここでは文字列)を書き込んでいます。あとはそのポインタとサイズを greet に渡すだけです。

// Now, we can call "greet", which reads the string we wrote to memory!
_, err = greet.Call(ctx, namePtr, nameSize)

次にこのポインタとサイズを受け取るWasm側を見てみます。

//export greet
func _greet(ptr, size uint32) {
    name := ptrToString(ptr, size)
    greet(name)
}

ptrToString を呼んでstringに変換してそれをgreetという関数で表示しています。greet内は単にホスト側のロギング関数を呼んでいるだけなので、 ptrToString の中を見てみます。

// ptrToString returns a string from WebAssembly compatible numeric types
// representing its pointer and length.
func ptrToString(ptr uint32, size uint32) string {
    // Get a slice view of the underlying bytes in the stream. We use SliceHeader, not StringHeader
    // as it allows us to fix the capacity to what was allocated.
    return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
        Data: uintptr(ptr),
        Len:  uintptr(size), // Tinygo requires these as uintptrs even if they are int fields.
        Cap:  uintptr(size), // ^^ See https://github.com/tinygo-org/tinygo/issues/1284
    }))
}

Goのsliceはdata, len, capで構成されているので受け取ったポインタとサイズをそれらに代入しています。ちなみにLenとCapはintで定義されているにも関わらず uintptr を渡しているのでGoだとビルドできません。逆にTinyGoでWasmビルドする時は uintptr じゃないとダメでした。

今回はstringを渡しましたが、適当なオブジェクトをシリアライズしてバイト列にして渡す際も同様にポインタとサイズを渡すだけなので、この方法で任意の型を渡せます。受け取る側はデシリアライズすればOKです。

別の渡し方

上の例では

  • ホスト側から malloc を呼び出す
  • その領域に値を書き込む
  • Wasm側にポインタとサイズを渡す

という順序で処理を行いましたが、

  • Wasm側でメモリを確保する
  • ホスト側の関数にそのポインタを渡す
  • ホスト側で値を入れて返す

という方法も使われるようです。GitHubに転がっているサンプルをいくつか見てるとこっちのほうが多い感じもします。以下はProxy-Wasm SDK Goの例です。

var raw *byte
var size int
st := internal.ProxyDequeueSharedQueue(queueID, &raw, &size)
if st != internal.StatusOK {
    return nil, internal.StatusToError(st)
}
return internal.RawBytePtrToByteSlice(raw, size), nil

proxy-wasm-go-sdk/hostcall.go at bc850cd5e90fe2984b12e025e2614dba37ac46ff · tetratelabs/proxy-wasm-go-sdk · GitHub

この例では rawsize のポインタをホスト側に渡し、値を入れて返してもらったものを []byte に変換しています。

事前に長さを知った上で malloc を呼び出してメモリ確保できるので前者の方法の方が効率的に見えるのですが、あまりこの辺の差は分かってないです。

Zigの実装

ではこのTinyGoのサンプルを参考にZigで実装してみます。

ちなみにZig初心者のくせにせっかく作ったしwazeroにPR出しちゃおう、と無邪気に投げたところZigのコミッタの方がフィードバックを下さり、自分の書いたものより圧倒的に良くなりました。初心者のクソコードをレビューしてくださるなんて感謝しかないです。こういう事が起こるので、初心者だし...と恐れず行動してみるのが大事です。OSSメンテナのレビューの手間を増やすなという意見もあるかもしれませんが、別にダメならメンテナはクローズするだけなので申し訳ないとか思わずに飛び込んでみて良いと思っている派です。

github.com

注意点としてはWASIではないという点です。上のPR内のコメントを見てもらえれば分かるのですが、WASIだとまた色々と異なるのでいずれPRを出そうと思います。いくつかハマりどころがあるので元気があればブログも書きます。

malloc

自分の見た限りZigでは malloc 等はexportされていなさそうなので自分で定義します。 exportfn の前に付けるとexport出来るようです。

pub export fn malloc(length: usize) ?[*]u8 {
    const memory = allocator.alloc(u8, length) catch return null;
    return memory.ptr;
}

この allocatorstd.heap.page_allocator を使っているのですが、targetがWasmの場合は WasmPageAllocator に切り替わるように見えます。Zig歴4日目なので嘘かもしれません。ziglearnを読んでた時間も含めると6日目ぐらいです。

zig/heap.zig at ae16c1d0835b517cb858cb9615888cdab1e351c5 · ziglang/zig · GitHub

つまり allocator で適当にメモリを確保すればWasmのMemoryをいい感じに使ってくれるので、普通のZigプログラムを書くのと同じノリで行けるのかなと理解しています。ただZig歴4日目なので(略)

ちなみにこの memory.ptr を返すのが大事で、memory 変数自体は恐らくスタックに確保されるので &memory のようにしてしまうと関数を抜けたタイミングでmemory変数は消されてしまって不安定な挙動になります。不安定と言ったのは -O ReleaseSafe とかだと動くのに -O Debug にするとpanicになっていたためです。Debugの方が積極的にスタックを消すのでしょうか。ちなみにこれは困っていたら@mattn_jpさんが助けてくださいました。

以前マスタケさんにZigのProxy-Wasm SDKを教えてもらっていたのでコードを読んでいて、何で memory.ptr にしてるんだろうと思ったのですがZig歴1日目だったので適当に &memory にしてガチャガチャやっていて死んでいました。以下はProxy-Wasm Zig SDKの例です。

pub export fn proxy_on_memory_allocate(size: usize) [*]u8 {
    const memory = allocator.alloc(u8, size) catch unreachable;
    return memory.ptr;
}

proxy-wasm-zig-sdk/memory.zig at dedb518a58bc766e092aa52254c1f7aef73caddd · mathetake/proxy-wasm-zig-sdk · GitHub

このサンプルもマスタケさんが書いたもので、自分がZigの調査を始めた時に教えて下さいました。「まさかZigのSDKも...?」「あるよ」とHEROのバーのマスターを思い出しました。*3

また、自分のオリジナルのコードは以下のように @ptrToInt を使ってusizeを返していました。

pub export fn malloc(size: usize) usize {
    var memory = allocator.alloc(u8, size) catch unreachable;
    return @ptrToInt(memory.ptr);
}

ただポインタはただのメモリオフセットでしかないから [*]u8 を戻り値にすれば良いと教えていただき @ptrToInt を使わない形になりました。Wasmには [*]u8 という型はないので(裏側ではusizeと同じだとしても)コンパイル時に怒られるのかなと思っていたのですが、Wasmをターゲットにしても特に問題なしでした。

free

以下のようになります。

pub export fn free(buf: [*]u8, length: usize) void {
    allocator.free(buf[0..length]);
}

こちらも最初はusizeを受け取って @intToPtr していたのですが、 [*]u8 で受け取ってslicingすれば良いと教えてもらって今の形になりました。

さらに以前は [*]u8free に渡せばいいかと思ったのですが怒られました。

/usr/local/Cellar/zig/0.9.1_1/lib/zig/std/mem.zig:2755:9: error: expected []T or *[_]T, passed [*]u8
        @compileError("expected []T or *[_]T, passed " ++ @typeName(sliceType));

ptrToString

最初は先程のTinyGoの例と同様、ポインタとサイズを受け取って []u8 に変換していました。以下は自分のオリジナルコードです。

pub fn ptrToString(ptr: u32, size: u32) []u8 {
    var s: []u8 = undefined;
    s.ptr = @intToPtr([*]u8, ptr);
    s.len = size;
    return s;
}

ただ、こちらも上のfreeの例同様、 [*]u8 を引数にしてしまえばslicingだけでstring( []u8 )になるので不要と教わりました。修正後は以下になります。

pub export fn greet(message: [*]const u8, size: u32) void {
    const name = _greeting(message[0..size]) catch unreachable;
    _greet(name);
}

messageの型が [*]const u8 になっていて、それを message[0..size] のようにslicingすることで []u8 に変換しています。とてもシンプルに書けて最高です。

stringToPtr

上の ptrToString とは逆でZig内の文字列をポインタとサイズに変換してホスト側に返します。

// stringToPtr returns a pointer and size pair for the given string in a way
// compatible with WebAssembly numeric types.
pub fn stringToPtr(s: []const u8) u64 {
    const p: u64 = @ptrToInt(s.ptr);
    return p << 32 | s.len;
}

u64を返している理由ですが、WebAssembly 1.0では一つしか戻り値を返せないためです。

In the current version of WebAssembly, at most one value is allowed as a result. However, this may be generalized to sequences of values in future versions.

Types — WebAssembly 1.0

WebAssembly 2.0では複数の戻り値も返せるはずですが、まだDraftですし今回は1.0準拠で行きます。

Result types classify the result of executing instructions or functions, which is a sequence of values, written with brackets.

Types — WebAssembly 2.0 (Draft 2022-08-04)

ポインタとサイズの2つを返したいのに返せないので、u64に2つのu32を入れて返しています。ちなみに上の例で一旦u64の変数に入れてからじゃないと << 32 は動きませんでした。

@ptrToInt(s.ptr) << 32 | s.len;

上のように書くと以下のようなエラーが出ます。

./src/main.zig:68:32: error: integer value 32 cannot be coerced to type 'u5'
    return @ptrToInt(s.ptr) << 32 | s.len;

ビットシフトしたい変数の型により制限を受けるようです。バグのような感じがしますが、とりあえず一旦大きい型を持つ変数に入れてあげたら動きました。

github.com

呼び出し側

基本的にTinyGoで見たサンプルコードと同じで動きます。

_, err = greet.Call(ctx, namePtr, nameSize)

サンプル

ここまで歯抜けで説明してきたので何の話をしているか意味不明だったかもしれないのですが、コード全体を見れば普通に理解できると思います。上のPRを取り込んでもらったので以下でコード全体を読むことが出来ます。

github.com

まとめ

GoのWasmランタイムであるwazero上で、Zigで書いたコードをWasmにコンパイルして動かしてみました。単なるHello worldレベルではなくメモリを確保してsliceをGo<=>Wasmで相互にやり取りするというところまで確かめました。上述したように任意のオブジェクトをシリアライズすればやり取りできるので大体のことは何とかなります。ここまで出来ると大分実用的な気がします。

正直Zigは始めたばかりですしWasmも特別詳しいわけでもないのでエアプなブログを書いている気がしますが、せっかくZigのコミッタやWasmランタイムのメンテナ達からレビューしてもらったので初学者の悪戦苦闘の記録として公開しておきます。Twitterでプロ開発者に助けてもらったりもしたので、このサンプルを書くためだけに多くの人に手助けしてもらっています。まさかり投げられそうで怖いですが、オープンに活動すると誰かが助けてくれたりして色々捗るのでおすすめです。

Zigは始めたばかりなのに思ったよりハマりどころが少なくて良かったです。言語仕様がコンパクトなところも気に入っています。以下は精神年齢が小学生で止まっているのですぐ漫画の話をしてしまう図です。

*1:実際には既にプラグインは別の機構があったので苦肉の策でモジュールと呼んでます

*2:WebAssembly 2.0のドラフトでもその辺は変わっていなさそうに見えますが、ちゃんと追ってないので今度ちゃんと読みます

*3:今の若者は知らなさそう https://ciatr.jp/topics/44857

cargo-auditableで依存するcrateをバイナリに埋め込む

概要

cargo-auditable というものがあるというのをGitHubで教えてもらいました。 github.com

これを使ってRustプロジェクトをビルドすると依存するcrateの情報をバイナリに埋め込むことが出来ます。ちなみにGoでは既に以前からやっており、詳細はこちらのブログに書いています。

knqyf263.hatenablog.com

cargo-auditable の使い方はREADMEをそのままパクると以下のようになっています。

# Install the tooling
cargo install cargo-auditable rust-audit-info
# Open your Rust project
cd your_project
# Build your project with dependency lists embedded in the binaries
cargo auditable build --release
# Recover the dependency info from the compiled binary
rust-audit-info target/release/your-project

cargo auditable で埋め込み、 rust-audit-info で情報を表示できます。まだCargo本体には入っていないようですが、既にGitHub上で提案はされています。ただ議論は長引いているのでどうなるかは分かりません。脆弱性スキャナの開発者としてはぜひ取り込まれて欲しいと思っています。 github.com

また、フォーマットもまだ定まっていないようなので今後変更される可能性があります。

詳細

では内部がどうなっているかを見ていきます。自分の携わるプロジェクトはGoで開発しているため上の rust-audit-info をimportして使うことは出来ず、内部挙動を理解してGoで再実装する必要があります(実際にはMicrosoftがライブラリを提供してくれていたのでそれを使った)。

事前準備

Linuxでテストしたいので適当にコンテナを立ち上げます。

$ docker run --rm --name rust rust:1.62-buster bash

上の手順で cargo-auditablerust-audit-info をインストールします。

root@09c9e76c6781:/# cargo install cargo-auditable rust-audit-info
    Updating crates.io index
  Downloaded cargo-auditable v0.4.0
...
    Finished release [optimized] target(s) in 1m 06s
  Installing /usr/local/cargo/bin/rust-audit-info
   Installed package `rust-audit-info v0.4.0` (executable `rust-audit-info`)
     Summary Successfully installed cargo-auditable, rust-audit-info!

プロジェクトのビルド

次に適当なプロジェクトを cargo-auditable でビルドします。今回はRustで書かれたlsであるexaをビルドしました。

root@09c9e76c6781:/# git clone https://github.com/ogham/exa
root@09c9e76c6781:/# cd exa
root@09c9e76c6781:/app# cargo auditable build --release
info: syncing channel updates for '1.56.1-x86_64-unknown-linux-gnu'
info: latest update on 2021-11-01, rust version 1.56.1 (59eed8a2a 2021-11-01)
info: downloading component 'cargo'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: installing component 'cargo'
info: installing component 'rust-std'
info: installing component 'rustc'
  Downloaded datetime v0.5.2
...
    Compiling git2 v0.13.20
      Finished release [optimized] target(s) in 3m 08s

.dep-v0 section

このバイナリのどこに保存されているのかという話ですが、 rust-auditable のドキュメントには以下のように書かれています。

It is not yet stabilized, so we do not have extensive docs or a JSON schema. However, these Rust data structures map to JSON one-to-one and are extensively commented. The JSON is Zlib-compressed and placed in a linker section named .dep-v0.

.dep-v0 というセクションにZlibで圧縮されて保存されているようです。

コードを見ると確かにそんな雰囲気です。

    let section = file.add_section(
        file.segment_name(StandardSegment::Data).to_vec(),
        b".dep-v0".to_vec(),
        SectionKind::ReadOnlyData,
    );

https://github.com/rust-secure-code/cargo-auditable/blob/b1764c9f038cdd3d4a74837f39ab3ebb0e77ca5e/cargo-auditable/src/object_file.rs#L22-L26

ということで readelf でセクションヘッダを見てみます。

root@09c9e76c6781:/app# readelf -S target/release/exa
There are 41 section headers, starting at offset 0x306aa8:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         00000000000002e0  000002e0
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             00000000000002fc  000002fc
       0000000000000020  0000000000000000   A       0     0     4
  [ 3] .note.gnu.build-i NOTE             000000000000031c  0000031c
       0000000000000024  0000000000000000   A       0     0     4
  ...
  [26] .data             PROGBITS         000000000015f000  0015e000
       00000000000038b8  0000000000000000  WA       0     0     32
  [27] .bss              NOBITS           00000000001628c0  001618b8
       00000000000007a8  0000000000000000  WA       0     0     32
  [28] .comment          PROGBITS         0000000000000000  001618b8
       000000000000001c  0000000000000001  MS       0     0     1
  [29] .dep-v0           PROGBITS         0000000000000000  001618d4
       0000000000000360  0000000000000000           0     0     1
  ...

長いので省略しましたが、 [29] に dep-v0 があることが確認できます。ということでdumpしてみます。

root@09c9e76c6781:/app# objcopy --dump-section .dep-v0=dep target/release/exa

解凍

dep というファイルにdumsしましたが、zlibで圧縮されているため解凍するために適当なコマンド(今回は zlib-flate )を入れます。

root@513914fa49f6:/app# apt update -y && apt -y install qpdf

そして解凍します。

root@513914fa49f6:/app# zlib-flate -uncompress < dep
{"packages":[{"name":"ansi_term","version":"0.12.1","source":"crates.io","dependencies":[38]},{"name":"bitflags","version":"1.2.1","source":"crates.io","features":["default"]},{"name":"byteorder","version":"1.4.3","source":"crates.io","features":["default","std"]},{"name":"cc","version":"1.0.67","source":"crates.io","kind":"build","dependencies":[12],"features":["jobserver","parallel"]},{"name":"cfg-if","version":"1.0.0","source":"crates.io"},{"name":"datetime","version":"0.5.2","source":"crates.io","dependencies":[14,17,23,26,38],"features":["format","locale","pad"]},{"name":"exa","version":"0.10.1","source":"local","dependencies":[0,5,8,9,13,14,17,18,20,21,22,27,28,29,34,36,41],"features":["default","git","git2"]},{"name":"form_urlencoded","version":"1.0.1","source":"crates.io","dependencies":[19,24]},{"name":"git2","version":"0.13.20","source":"crates.io","dependencies":[1,14,15,18,35]},{"name":"glob","version":"0.3.0","source":"crates.io"},{"name":"hermit-abi","version":"0.1.18","source":"crates.io","dependencies":[14],"features":["default"]},{"name":"idna","version":"0.2.3","source":"crates.io","dependencies":[19,32,33]},{"name":"jobserver","version":"0.1.22","source":"crates.io","kind":"build","dependencies":[14]},{"name":"lazy_static","version":"1.4.0","source":"crates.io"},{"name":"libc","version":"0.2.93","source":"crates.io","features":["default","std"]},{"name":"libgit2-sys","version":"0.12.21+1.1.0","source":"crates.io","dependencies":[3,14,16,25]},{"name":"libz-sys","version":"1.1.2","source":"crates.io","dependencies":[3,14,25,37],"features":["libc"]},{"name":"locale","version":"0.2.2","source":"crates.io","dependencies":[14]},{"name":"log","version":"0.4.14","source":"crates.io","dependencies":[4]},{"name":"matches","version":"0.1.8","source":"crates.io"},{"name":"natord","version":"1.0.9","source":"crates.io"},{"name":"num_cpus","version":"1.13.0","source":"crates.io","dependencies":[10,14]},{"name":"number_prefix","version":"0.4.0","source":"crates.io","features":["default","std"]},{"name":"pad","version":"0.1.6","source":"crates.io","dependencies":[34]},{"name":"percent-encoding","version":"2.1.0","source":"crates.io"},{"name":"pkg-config","version":"0.3.19","source":"crates.io","kind":"build"},{"name":"redox_syscall","version":"0.1.57","source":"crates.io"},{"name":"scoped_threadpool","version":"0.1.9","source":"crates.io"},{"name":"term_grid","version":"0.2.0","source":"crates.io","dependencies":[34]},{"name":"terminal_size","version":"0.1.16","source":"crates.io","dependencies":[14,38]},{"name":"tinyvec","version":"1.2.0","source":"crates.io","dependencies":[31],"features":["alloc","default","tinyvec_macros"]},{"name":"tinyvec_macros","version":"0.1.0","source":"crates.io"},{"name":"unicode-bidi","version":"0.3.5","source":"crates.io","dependencies":[19],"features":["default"]},{"name":"unicode-normalization","version":"0.1.17","source":"crates.io","dependencies":[30],"features":["default","std"]},{"name":"unicode-width","version":"0.1.8","source":"crates.io","features":["default"]},{"name":"url","version":"2.2.1","source":"crates.io","dependencies":[7,11,19,24]},{"name":"users","version":"0.11.0","source":"crates.io","dependencies":[14,18],"features":["cache","default","log","logging","mock"]},{"name":"vcpkg","version":"0.2.12","source":"crates.io","kind":"build"},{"name":"winapi","version":"0.3.9","source":"crates.io","dependencies":[39,40],"features":["consoleapi","errhandlingapi","fileapi","handleapi","minwindef","processenv","sysinfoapi","winbase","wincon","winnt"]},{"name":"winapi-i686-pc-windows-gnu","version":"0.4.0","source":"crates.io"},{"name":"winapi-x86_64-pc-windows-gnu","version":"0.4.0","source":"crates.io"},{"name":"zoneinfo_compiled","version":"0.5.1","source":"crates.io","dependencies":[2,5]}]}

ということで取り出せました。GoはビルドしたGoのバージョンも入れるためにポインタを保持していたのでパースが若干面倒でしたが、Rustの方は依存crateだけなのでかなりシンプルですね。Goはgo.sumのような形式で入っていましたがRustはJSONなのでパースも簡単です。

そして特に嬉しいのが "source": "local" が含まれており、そのバイナリのバージョンも分かる点です。 exa の例だとバージョンが0.10.1であることが分かります。

    {
      "name": "exa",
      "version": "0.10.1",
      "source": "local",
      "dependencies": [
        0,
        5,
        8,
        9,
        13,
        14,
        17,
        18,
        20,
        21,
        22,
        27,
        28,
        29,
        34,
        36,
        41
      ],
      "features": [
        "default",
        "git",
        "git2"
      ]
    },

Goでは現在これが出来ません。例えばGrafanaのバイナリを見つけてもGrafanaのバージョンは分からず、Grafanaの依存しているライブラリのバージョンだけ取れるという状態です。

あと個人的には dependencies が入っているのが好きです。ちゃんと仕様を見ていないですが見た感じではこのリストにおけるindexが保存されており、dependency graphが作れそうです。ただ現在はrootがどれか分からないのでうまくgraphが作れず困っていると伝えたら足してくれることになりました。

github.com

余談

この情報があると何が嬉しいか?というとバイナリだけあれば脆弱性スキャンが出来ることです。あとはバグが見つかった場合に影響するかなどもバイナリだけあれば簡単に調査出来ます。 現在自分の開発しているTrivyというOSSでは既にGoのバイナリスキャンには対応しており、バイナリだけ渡されれば依存を勝手に取得してスキャンできます。特にコンテナイメージ内に go.modgo.sum を置くのは一般的ではないため、バイナリだけでスキャンできるというのは有用でした。

knqyf263.hatenablog.com

同様にRustも Cargo.tomlCargo.lock をイメージ内に置くのはレアケースであるためバイナリに依存ライブラリの情報を埋め込んでくれると助かります。Goはデフォルトで埋め込んでくれるのでRustも早くそうなってほしいです。

ちなみに cargo-auditableMicrosoftの人から教えてもらいました。教えてもらうどころか対応するPRまで貰いました。

github.com

Azure Defender for Cloudの中で使っているため、Microsoftの方々は割と頻繁にPRをくれます。

docs.microsoft.com

以前書いたようにGoバイナリに関する情報もGitHub上で教えてもらいましたし、何も知らないのにコミュニティの有志に支えてもらって何とかやれているなと感じる日々です。このブログでも何度も言っていることではありますが、こういう助け合いがOSSの楽しいところだなと思います。

最近話題のSBOMについてもCycloneDXの対応してよ、と3年前には来てました(当然自分は知らなかったのでIssueきっかけで調べた)。

github.com

あと全然関係ないですが、READMEに書いてある objcopy -O binary --only-section=.dep-v0 target/release/hello-auditable /dev/stdout だとdump出来なかったです。

困ったときのStack Overflow先生によると以下のようにセクションに特定のフラグが立ってない場合は無視されるようです。

objcopy will not copy out sections that are flagged neither loaded ("load") nor allocated ("alloc"). A comment in the source claims that "The contents of such a section are not meaningful in the binary format."

stackoverflow.com

--dump-section なら動きましたがこの辺詳しい解説など見つけられなかったので詳細が気になっています。以下を読んでいましたが、本当にここが該当箇所なのか...?となった上にさっさとRustバイナリスキャンを実装しよう、となって放置しました。

sourceware.org Git - binutils-gdb.git/blob - bfd/binary.c

一応修正のPRを出したらマージされました。

github.com

まとめ

全Rustユーザに cargo auditable を使って欲しい。

CVE-2022-32224(Railsの脆弱性)を試す

前回の記事は割と濃い味付けでしたが、今回は薄味です。 脆弱性自体は簡単なやつなのですが、調べている過程でRuby 3.1からYAMLのパースが安全になったことを知ったのでその共有がてら書きました。最近はあまりRubyを触る機会がなかったのでリハビリを兼ねて触っているところもあり、間違いがあれば教えて下さい。

要約

RubyYAML.load (正確には Psych.load )をユーザ入力など信頼できない値に対して実行するのは危険でした。

Do not use YAML to load untrusted data. Doing so is unsafe and could allow malicious input to execute arbitrary code inside your application. Please see doc/security.rdoc for more information.

Module: YAML (Ruby 3.1.0)

YAMLのタグとして !ruby/object:Foo のように指定されたクラス等を復元するため、いわゆる「安全でないデシリアライゼージョン」になり、結果として任意コード実行に繋がる可能性があります。これを防ぐために YAML.safe_load が提供されていたのですが、Ruby 3.1からは YAML.load のデフォルトが YAML.safe_load に変更されました。つまりデフォルトで安全になりました。 safe_load では任意オブジェクトの読み込みが無効化され、デフォルトで許可された複数のクラスと明示的に指定されたクラスのみが復元可能になります。

今後はRuby 3.1を使って安全にYAMLを読み込んでいきましょう。

背景

先日、Ruby on Rails(Active Record)の脆弱性(CVE-2022-32224)が公開されました。

discuss.rubyonrails.org

Active RecordではDBのカラムに serialize 属性が指定できるらしく、これはDBにシリアライズしたオブジェクトを保存可能にするものですが、保存された値をデシリアライズする時に YAML.unsafe_load が使われているので危険という脆弱性でした。

なぜオブジェクトのデシリアライズが危険かというのは以前もブログに書いたので省略します。Ruby固有の話ではなくJavaPHPだったり他の言語でも悪用されています。

knqyf263.hatenablog.com

さらに言うとRailsにおけるYAMLのデシリアライゼーションによる脆弱性も何ら新しいものではなく、CVE-2013-0156などは騒がれていたような記憶があります。

www.youtube.com

ざっくり言うとデシリアライズされるオブジェクトをうまく組み立てると(Gadget Chain)、任意コード実行(RCE)に繋がります。つまり今回の脆弱性としてはDB上に細工した値を入れておくと、その値を読み出す時にデシリアライズされRCEに繋がりうるということです。細工した値をRails経由で正当にDBに入れるのは難しそうなので、SQLインジェクションと組み合わせたりが必要になりそうです。実際上のアナウンスでも攻撃者がDBを操作できる必要があると書かれています。

上記の前提条件を考慮すると緊急性が高いようには見えませんが、一応触っておこうということで試しました。

RubyYAML.load

まずはRubyYAML.load を使ってデシリアライゼーションでRCEを試してみます。正確には YAML.loadRubyのPsychモジュールによる Psych.load です。PsychはRubyYAMLライブラリのバックエンド実装になってます。

RubyのデシリアライゼーションのためのGadget Chainはネット上にあちこちに転がっているので調べてみてください。一応今回のブログではYAMLそのものは貼らないでおきます。

www.elttam.com

staaldraad.github.io

staaldraad.github.io

適当に持ってきたYAMLを手元に untrusted.yaml として保存します。あとはそれを読み込むだけです。

$ ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))'
...(略)...
sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
/usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `system': no implicit conversion of nil into String (TypeError)
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `write'
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:466:in `<<'
        from /usr/local/lib/ruby/3.0.0/rubygems/request_set.rb:388:in `resolve'
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:460:in `write'
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:466:in `<<'
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:321:in `LOG'
        from /usr/local/lib/ruby/3.0.0/net/protocol.rb:154:in `read'
        from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_header.rb:101:in `from'
        from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_reader.rb:59:in `each'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `map'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `as_list'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:260:in `to_s'
        from -e:1:in `puts'
        from -e:1:in `puts'
        from -e:1:in `<main>'

エラーは出てしまってますが、今回の例では id が実行されているのが分かります。と普通に動いたかのように言いましたが、何も考えずにRuby 3.1.2を持ってきたら動きませんでした。

$ ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))'
/usr/local/lib/ruby/3.1.0/psych/class_loader.rb:99:in `find': Tried to load unspecified class: Gem::Installer (Psych::DisallowedClass)
        from /usr/local/lib/ruby/3.1.0/psych/class_loader.rb:28:in `load'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:424:in `resolve_class'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:213:in `visit_Psych_Nodes_Mapping'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `block in register_empty'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `each'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:338:in `register_empty'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:146:in `visit_Psych_Nodes_Sequence'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:318:in `visit_Psych_Nodes_Document'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:30:in `visit'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/visitor.rb:6:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych/visitors/to_ruby.rb:35:in `accept'
        from /usr/local/lib/ruby/3.1.0/psych.rb:335:in `safe_load'
        from /usr/local/lib/ruby/3.1.0/psych.rb:370:in `load'
        from -e:1:in `<main>'

エラーを見ると許可されてないクラスをロードしようとしていると怒られており、スタックトレースを見ると safe_load が呼ばれていることが分かります。何で?!と思って調べたら以下のブログを見つけました。

www.ctrl.blog

Psych 4.0からは Psych.loadsafe_load になり、以前と同じ挙動は unsafe_load となっていました。そしてRuby 3.1からはPsych 4.0が使われるようになったため、Ruby 3.1では上記のように簡単にRCEをしようとしてもうまくいきません。デフォルトで安全になっています。この辺はあとで調べたら日本語でも説明されていました。

techlife.cookpad.com

破壊的変更なのに大丈夫なのかな...と思ったら、やはり非互換になるので大変そうでした。

secret-garden.hatenablog.com

Railsでも unsafe_loadsafe_load に変えた影響で動かなくなったというIssueが上がっていました。

github.com

あちこちが壊れるにも関わらず安全になる変更を加えるのは英断かと思います。破壊的変更を加えるのは胃が痛くなるはずです。

全然関係ないですが、Rubyはセマンティックバージョニングなのかと思っていたので、マイナーバージョンで後方互換性がなくなるのは少し驚きました。ただドキュメントに明確に

MINOR: クリスマスごとに増加する。 API レベルでの非互換がありえる。

と記載されているので何もおかしいことはなく、独自のバージョニングのようです。

www.ruby-lang.org

脱線しましたがRailsの話に戻ります。

Railsのデシリアライゼーションを試す

ooooooo-q さんが既に検証をGitHub上に上げてくださっていたのでこちらを使います。

GitHub - ooooooo-q/cve-2022-32224-rails

準備

どうやらRailsでは明示的に unsafe_load を呼んでいるようなのでRubyのバージョンによらず脆弱性の影響を受けるはずですが、一応Ruby 3.0を使っておきます。

$ git clone https://github.com/ooooooo-q/cve-2022-32224-rails.git
$ cd cve-2022-32224-rails
$ docker run --rm --name ruby -it -v $PWD:/app --workdir /app ruby:3.0 bash
root@ba9b4e50ef92:/app# bundle install
root@ba9b4e50ef92:/app# bin/rails db:migrate

モデルは以下のようになっています。

class User < ApplicationRecord
  serialize :values, Array
end

serialize をつけることでserialized objectをDBに保存可能になります。 class_name_or_coder のところは ArrayHash などが選択可能です。

api.rubyonrails.org

クラスの復元

ここも上記リポジトリの例を丸パクリさせてもらいました。まずオブジェクトを保存してみます。

root@ba9b4e50ef92:/app# bundle exec rails c
Loading development environment (Rails 7.0.3)
irb(main):001:0> gemspec = Gem::Specification.new("test")
=>
Gem::Specification.new do |s|
...
irb(main):002:0> user = User.create!(values: [gemspec])
   (5.4ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  User Create (19.2ms)  INSERT INTO "users" ("values", "created_at", "updated_at") VALUES (?, ?, ?)  [["values", "---\n- !ruby/object:Gem::Specification\n  name: test\n  version: \n  platform: ruby\n  authors: []\n  autorequire: \n  bindir: bin\n  cert_chain: []\n  date: 2022-07-21 00:00:00.000000000 Z\n  dependencies: []\n  description: \n  email: \n  executables: []\n  extensions: []\n  extra_rdoc_files: []\n  files: []\n  homepage: \n  licenses: []\n  metadata: {}\n  post_install_message: \n  rdoc_options: []\n  require_paths:\n  - lib\n  required_ruby_version: !ruby/object:Gem::Requirement\n    requirements:\n    - &1\n      - \">=\"\n      - !ruby/object:Gem::Version\n        version: '0'\n  required_rubygems_version: !ruby/object:Gem::Requirement\n    requirements:\n    - *1\n  requirements: []\n  rubygems_version: 3.2.33\n  signing_key: \n  specification_version: 4\n  summary: \n  test_files: []\n"], ["created_at", "2022-07-21 09:45:23.953983"], ["updated_at", "2022-07-21 09:45:23.953983"]]
  TRANSACTION (13.0ms)  commit transaction
=>
#<User:0x00007f4b7cc343c0
...

このYAMLは以下のようになっています。

---
- !ruby/object:Gem::Specification
  name: test
  version:
  platform: ruby
  authors: []
  autorequire:
  bindir: bin
  cert_chain: []
  date: 2022-07-21 00:00:00.000000000 Z
  dependencies: []
  description:
  email:
  executables: []
  extensions: []
  extra_rdoc_files: []
  files: []
  homepage:
  licenses: []
  metadata: {}
  post_install_message:
  rdoc_options: []
  require_paths:
  - lib
  required_ruby_version: !ruby/object:Gem::Requirement
    requirements:
    - &1
      - \">=\"
      - !ruby/object:Gem::Version
        version: 0
  required_rubygems_version: !ruby/object:Gem::Requirement
    requirements:
    - *1
  requirements: []
  rubygems_version: 3.3.7
  signing_key:
  specification_version: 4
  summary:
  test_files: []

最初の !ruby/object... のところ以外は普通にkey:valueとして値が入っているだけな感じです。

ではこれを復元します。

irb(main):003:0> User.last.values
  User Load (5.8ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
=>
[Gem::Specification.new do |s|
   s.name = "test"
   s.version = nil
   s.installed_by_version = Gem::Version.new("0")
   s.date = Time.utc(2022, 7, 21)
   s.require_paths = ["lib"]
   s.rubygems_version = "3.2.33"
   s.specification_version = 4
   s.summary = nil
   end]

確かにデシリアライズされ元のクラスが復元されています。

任意コード実行

SQLiteに入って細工したYAMLを入れます。 values に上で使ったYAMLをそのまま入れるだけです。

$ sqlite3 db/development.sqlite3
sqlite> INSERT INTO users ("values", "created_at", "updated_at") VALUES ("省略", TIME(), TIME());

ではDBから値を取り出します。

irb(main):001:0> puts User.last.values
  User Load (4.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
...
/usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:459:in `write': undefined method `call' for nil:NilClass (NoMethodError)

うまくいきませんでした。問題の切り分けのためにもう一度Ruby単体で読み込んでみます。

root@ba9b4e50ef92:/app# ruby -e 'require "yaml"; puts YAML.load(File.read("untrusted.yaml"))'
#<Gem::Installer:0x0000557dfd92af38>
#<Gem::SpecFetcher:0x0000557dfd9caee8>
/usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:459:in `write': undefined method `call' for nil:NilClass (NoMethodError)
        from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:465:in `<<'
        from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:321:in `LOG'
        from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:154:in `read'
        from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_header.rb:101:in `from'
        from /usr/local/lib/ruby/3.0.0/rubygems/package/tar_reader.rb:59:in `each'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `map'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:189:in `as_list'
        from /usr/local/lib/ruby/3.0.0/rubygems/requirement.rb:260:in `to_s'
        from -e:1:in `puts'
        from -e:1:in `puts'
        from -e:1:in `<main>'

さっきまでうまくいっていたのに急に失敗するようになりました。先程までのスタックトレースと比べると

from /usr/local/lib/ruby/3.0.0/net/protocol.rb:154:in `read'

だった部分が

from /usr/local/bundle/gems/net-protocol-0.1.3/lib/net/protocol.rb:154:in `read'

に変わっています。net-protocol(の新しいバージョン?)がインストールされているとそちらが使われるような雰囲気です。ここはちゃんと調べてません。適当にnet-protocolのバージョンを下げてみます。

root@ba9b4e50ef92:/app# cat Gemfile | grep net-protocol
gem "net-protocol", "0.1.1"
$ bundle install

そしてもう一度試します。

root@ba9b4e50ef92:/app# bundle exec rails c
Loading development environment (Rails 7.0.3)
irb(main):001:0> User.last.values
  User Load (7.5ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT ?  [["LIMIT", 1]]
sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
(Object doesn't support #inspect)

今度は id が実行されています。ということで検証終わりです。上で動かなかったのは単にGadget Chainの問題なので頑張れば動く気もしますが、本当にRCE可能であることの検証をしたかっただけなのでこれ以上は深追いしません。

まとめ

Rubyで信頼できないYAMLYAML.load による読み込みは危ない、YAML.safe_loadSafeYAMLを使いましょうと長いこと言われていたかと思いますが、Ruby 3.1からデフォルトで安全になったので世界がセキュアになったねという話でした。

Dirty Pipe(CVE-2022-0847)の発見経緯が面白かった

最初に断っておくと今回は万人向けの記事ではないです。面白かったので自分が忘れないようにまとめているだけです。

本記事の位置付け

Dirty Pipe(CVE-2022-0847)三部作の最後です。ダークナイト三部作で言うとダークナイト ライジングにあたります。ダーティとダークって似てませんか。

  1. spliceを使って高速・省メモリでGzipからZIPを作る
  2. 20分で分かるDirty Pipe(CVE-2022-0847)
  3. Dirty Pipe(CVE-2022-0847)の発見経緯が面白かった(本記事)

上の1, 2を前提知識として要求します。二作品目であるダークナイトが一番面白いところも同じですね(?)

ダークナイトと比べたダークナイト ライジングの評価を思い出しながらこの記事を読んでもらえると優しい気持ちになれると思います。

はじめに

以前Linuxカーネル脆弱性であるDirty Pipe(CVE-2022-0847)の解説記事を書きました。

knqyf263.hatenablog.com

この記事はなるべく概要を掴んでもらおうと思って書いた記事で、本当は書きたかったことをかなり省略しています。特に報告者のブログに書いてあった発見経緯は個人的に面白かったのですが、その辺はまるっと省きました。今回はその辺を全部書き記しておこうという趣旨です。本家ブログの内容そのままならそっち読めばいいとなるのですが、理解するのに少し時間がかかったのと不明点を報告者にメールして教えてもらったので自分の言葉でまとめ直しておきます。頭を整理するために図も書いています。

発見経緯

以下が報告者によるブログです。自分が間違ったことを言っている可能性もあるので正確な内容はこちらをご確認ください。

dirtypipe.cm4all.com

CRCのエラー

報告者の会社ではHTTPのアクセスログを提供しているのですが、顧客からgzipファイルが壊れているという報告があったそうです。サーバを見ると確かにCRCエラーが起きているファイルがありました。その時は原因も分からなかったため手動でCRCを直して終わりにしたそうです。

しかし、数ヶ月経ち再び同じ問題が報告されます。ファイルの中身は正しいのにファイルの最後にあるCRCだけが壊れている状態です。CRC-32はgzipファイルにおいてはtrailerと呼ばれるフッタに含まれているのですが、その部分が壊れていました。図にすると以下です。

この問題は繰り返し起きたため報告者は調査することにしました。

HTTPアクセスログ

ここで少し脱線し、まずシステム構成を見てみます。このサービスでは各WebサーバがHTTPリクエストのログをUDPでログサーバに送っています。

そしてこれらを夜間に日付ごとにまとめてzlibで圧縮します。ここでzlibと言っているのですが、zlibフォーマットのことだとするとgzipとはヘッダなどのフォーマットが異なるので、文脈的にはzlibプログラムを使ってgzipに圧縮したという意味かなと勝手に推測しています。ここは細かい話なので聞かなかったですしあまり関係ないので気にしなくて良いです。

その結果、以下のように日付単位のgzipファイルが作成されます。実際にはさらにWebサーバ単位のようですが重要ではないので省略します。2022/01/01から2022/01/31までのログは以下のようになります。ちなみに上で壊れていたと報告があったのはこれら日付単位のgzipファイルのうちの1つです。例えば2022年1月31日のアクセスログである 20220131.gzip が壊れるなど。

さらに、このサービスは一ヶ月単位でのログファイルのダウンロードを提供しています。もし2022/01のログファイルをまとめて1ファイルとしてダウンロードしたい場合に 20220101.gzip から 20220131.gzip を一旦全て解凍してさらに1ファイルに圧縮するのはCPUもメモリも食いますし大変です。そこでどうしているかというと、複数gzipファイルを連結してもgzipファイルとして有効であるという性質を利用し、ただ各gzipファイルを連結しています。

$ cat 20220101.log.gz 20220102.log.gz 20220103.log.gz > 202201.log.gz

以下のようになります。 202201.gzip は単に 20220101.gzip から 20220131.gzip をくっつけただけになっています。

しかしWindowsユーザはgzipファイルを解凍できません(標準機能ではという意味だと思います)。そこでWindowsユーザでも解凍可能なZIPファイルとして1ヶ月ごとのアクセスログを提供しています。gzipファイルは単に日付ごとのgzipファイルを連結すれば済んだが、ZIPファイルはどうするのか?という疑問が湧きますが、その点について以前ブログを書きました。

knqyf263.hatenablog.com

一言で言うとgzipファイルもheaderとtrailerさえ外してしまえばDeflate圧縮したファイルなので、ZIPのヘッダとフッタさえうまくつけてしまえばgzipファイルの解凍不要でZIPが生成可能という点に着目してzero-copyでZIPファイルの生成を実現しています。以下はgzipの図ですが、真ん中の圧縮部分だけ取り出します。

なおZIPのフッタはセントラルディレクトリと呼ばれています。

壊れたgzipのtrailerを見てみる

gzipファイルの末尾は正しくは以下のようになります。

000005f0  81 d6 94 39 8a 05 b0 ed  e9 c0 fd 07 00 00 ff ff
00000600  03 00 9c 12 0b f5 f7 4a  00 00

特に最後の8バイトがtrailerで、0xf50b129cCRC-32で0x00004af7が非圧縮時のファイルサイズです。今回だと0x00004af7 = 19191 bytesになります。

壊れているログファイルは以下のようでした。

000005f0  81 d6 94 39 8a 05 b0 ed  e9 c0 fd 07 00 00 ff ff
00000600  03 00 50 4b 01 02 1e 03  14 00

ファイルサイズは0x0014031e = 1.3 MBになってしまい(実際は上に書いたように19KB)、CRC-32も0x02014b50となり間違っています。そこで壊れたファイルを複数比較して調査したところ、驚くべきことに全てのtrailerが同じ値になっていました。通常単にCRC-32が壊れている場合は様々な値を取りうるため、たまたま計算が間違ったわけではなさそうなことが分かります。

調査した結果、この8バイトはZIPファイルのセントラルディレクトリエントリのヘッダであることが分かりました。分からんと諦めずに調査してZIPファイルのヘッダっぽいとたどり着くのがまず凄いですね。法則性があると分かれば突き止めるのがそこまで難しいとは思わないですが、何の手がかりもない状態だと諦めてしまいそうです。

50 4b 01 02 1e 03 14 00

0x504b = PK が各ZIPヘッダの始まりを意味し、特に0x0102はセントラルディレクトリのヘッダを意味します。この辺の詳細は上のブログに書いてあるので見てください。他にも0x0304はローカルファイルヘッダという他のZIPヘッダを意味したりします。

en.wikipedia.org

しかしZIPのセントラルディレクトリファイルヘッダは最低でも46バイトあるのに、壊れたファイルには8バイトだけ含まれ、あとは含まれていません。そして壊れたファイルの末尾に毎回ZIPのヘッダが書き込まれるというのは偶然とは考えられません。そこで圧縮のために使っているzlibやその他ライブラリを確認したとのことですが、このようなヘッダを書き込む処理は見つけられなかったそうです。ブログではサラッと言ってますが、まずここが凄くない?と思いました。関連するライブラリのコード読んでZIPのヘッダを生成しないことを確かめるだけでも相当な労力だと感じました。

1つだけZIPヘッダを書き込む処理に心当たりがあったそうです。上で説明したように1ヶ月単位でログファイルを提供する機能です。ですが、ここでは単にgzipファイルを読み込んでZIPファイルを生成するだけです。この処理はgzipファイルを生成するユーザと異なり、read権限はあるもののwrite権限は持っていません。つまりこの処理がgzipファイルに書き込んでしまうなどということは起こりうるはずがないです。

壊れたファイルの法則性

ハードディスク全体をスキャンした壊れたファイルを探したところ、各月の最終日のファイルが壊れている傾向があることを突き止めました。つまり2021/11/30のログだったり2021/12/31のログです。

ハードウェアの故障やRAMの故障を疑ったようですが、明らかに法則性があることからハードウェア関連の問題ではないと考えました。

月次ログファイルの生成

やはり月次のログファイルをZIPにする処理が怪しいと考えます。しかし再びになりますがこのプロセスは各gzipファイルをreadしているだけです。write権限は持っていないですし、そもそもread処理しか行いません。以下は各gzipファイルのheader/trailerを外し中身のDeflate圧縮したファイルのみを取り出している処理を図示したものです。実際には各ファイルの中身の前後にZIPのheaderなどがつくのですが、分かりやすさのため図では省略しています。詳細はgzipからzipを生成する方法のブログを参照してください。

図にあるように 202201.zip を作る際に最後にZIPセントラルディレクトリヘッダを書き込みますが、 20220131.gzip には一切触りません。読み込んだだけです。

ですが他の可能性を潰していった結果、ZIPのセントラルディレクトリファイルヘッダに関連するような怪しい処理はこのプロセスしかありません。ここで先程の法則性を思い出すと月の最後のファイルが壊れる傾向にありました。月次のログアーカイブ生成処理は月の初めのgzipファイルから読み取り、最後の日付のgzipファイルが最後に処理されます。つまり2022/01の月次アーカイブであれば、20220101.gzipから処理され20220131.gzipが最後に処理されます。上の図でも20220131.gzipの圧縮部分とZIPセントラルディレクトリヘッダが隣接しているのが分かります。

ダークナイトを読んでくれた方は隣接しているということからどのようにしてこの破損が起きたか既に分かったと思います。

Linuxカーネルのバグの可能性

ありとあらゆる可能性を潰していった結果、残った可能性はLinuxカーネルのバグでした。Linuxカーネルは安定しておりデータ破損の原因をLinuxカーネルに求めるのは通常難しいですが、今回に限ってはそれ以外考えられないと思ったそうです。

そこでバグの再現のため簡単な2つのCプログラムを書きました。1つ目(writer)は単にAAAAAをファイルに出力するプログラムです。

#include <unistd.h>
int main(int argc, char **argv) {
  for (;;) write(1, "AAAAA", 5);
}
// ./writer >foo

そして2つ目(splicer)はファイルを splice() を使ってパイプに書き込み、さらにBBBBBという文字列をパイプに書き込みます。spliceって何なの?という人はspliceについて書いたブログを参照ください。

#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
int main(int argc, char **argv) {
  for (;;) {
    splice(0, 0, 1, 0, 2, 0);
    write(1, "BBBBB", 5);
  }
}
// ./splicer <foo |cat >/dev/null

良い感じに図にできなかったのですが、一応こんなイメージです。

splicerはfooというファイルを splice() を使って読み出しパイプに書き込んでいますが、あくまで読み込み処理であってfooには一切書き込みません。そしてあとは"BBBBB"という文字列をパイプに書き込んでいますが、これもfooというファイルとは何の関係もない処理です。ですが、このプログラムを実行するとファイルfooにBBBBBの文字列が現れ始めました。つまりこれでLinuxカーネルのバグであることが判明しました。splicerがfooへの書き込み権限がない場合でもBBBBBがfooに書き込まれます。

バグ混入の歴史

細かいのでスキップしても良いです。書くか悩んだのですが一応簡単に書いておきます。

git bisectを使って調べたところ以下のコミットからこの挙動をするようになったことが分かりました。

github.com

パイプに書き込むだけで何でファイルに書き込まれてしまうの?という点については 20分で分かるDirty Pipe(CVE-2022-0847) - knqyf263's blog を参照してください。以下はこちらの内容を理解済みの前提で書いています。

起こってはいけないパイプ内でのマージが起きてしまうというのが問題なわけですが、歴史について補足すると元々は struct pipe_buf_operationscan_merge というflagを持っており、 splice() システムコールを導入した以下のコミットで can_merge=0 (つまりマージできない)がセットされました。

github.com

その後 can_merge が廃止されたと思いきや(pipe: stop using ->can_merge · torvalds/linux@01e7187 · GitHub)、結局 PIPE_BUF_FLAG_CAN_MERGE が導入されたり( pipe: merge anon_pipe_buf*_ops · torvalds/linux@f6dd975 · GitHub)と紆余曲折あったようです。Linuxカーネルの開発でもこんな風に実装がコロコロ変わるんだなと思うと面白いわけですが、結局何が問題だったのでしょうか?PIPE_BUF_FLAG_CAN_MERGE が導入される数年前、pipe_buffer を作る関数が作られました( new iov_iter flavour: pipe-backed · torvalds/linux@241699c · GitHub)。この際、pipe_buffer のflagsの初期化を忘れてしまい、任意のflagsでページキャッシュ参照を作れるようになっていました。これ自体バグではあるものの、この時は大したflagが存在していなかったので問題なかったのですが、その後上述したように PIPE_BUF_FLAG_CAN_MERGE が導入されこのバグは今回の脆弱性へと繋がりました。

要約すると最初は小さいバグだったのが、その後の修正によって致命的になったということで合わせ技一本という感じがします。それぞれのコミットをした開発者も異なります。これはLinuxカーネルのような大きなソフトウェアだと難しい問題であると感じます。自分が直接修正していないところでそんな問題が起きるとは、という感じじゃないでしょうか。

ログ破損の原因

以上のことを把握した上でログ破損の原因を考えると理解できます。どうやってgzipファイルからZIPファイルを作っているかを思い出します。まずZIPのローカルファイルヘッダをパイプに書き込み、次に splice()gzipの圧縮部分のみをパイプに書き込みます。"圧縮部分のみ"というのがポイントなのですが、それは後述します。その後、data descriptorをパイプに書き込み1ファイル分の処理が完了します。そうすると以下のようにパイプに直接writeした場合の pipe_buffer はマージ可能で splice() した場合の pipe_buffer はマージ不可になります。

これを続けていくとリングバッファが一周します。この際、たまたま過去の pipe_buffer のflagsがマージ可能にセットされていた場合、本来マージしてはいけない splice() による pipe_buffer もマージ可能な状態になってしまいます。

2022/01/31のgzipファイル(20220131.gzip)のページキャッシュへの参照がある状態でZIPのセントラルディレクトリヘッダをパイプに書き込もうとすると誤って20220131.gzipに書き込まれてしまいます。

これだけ聞くともっと頻繁にファイルの破損が起きるのではないかと感じる人もいるかも知れませんが、その理由も説明されています。上で20220131.gzipに書き込まれると言いましたが、実際にはページキャッシュに書き込まれるだけでファイルには書き戻されません。ファイルに書き込まれるためにはそのページがdirtyであるとマークを付けられる必要がありますが、今回は正規の処理ではない方法で書き込まれているためdirty扱いにならず、ページがキャッシュから開放されたりrebootなどするとページキャッシュへの変更は消えます。

つまり偶然他のプロセスによってdirtyな変更が行われている場合のみ、このページキャッシュへの変更はファイルに書き戻され永続化されます。このdirtyな処理が行われる可能性が低いため、ファイルの破損確率は低くなっていました。ただしメモリ内ではもっと頻繁に破損していたと思われます。

実はこのdirtyな処理がどういうものなのか想像ができず報告者に質問しました。というのも20220131.gzipへの追記が発生した場合は末尾の破損にならないですし、ログの日次アーカイブファイルという性質上、作成後に更新がされるとも考えにくいですし、どのような処理が行われるのか想像できなかったためです。そこで頂いた回答としては、20220131.gzipがページキャッシュ上に書き込まれてからディスク上のファイルに書き込まれるまでの間に月次ログアーカイブの生成リクエストが来た場合には書き戻しが起こりうるというものでした。確かにdirtyになってから書き戻されるまではデフォルトだとvm.dirty_expire_centisecs=500で5秒程度あるので、20220131.gzipが作られてから5秒以内にこの破損が起きれば元からdirtyページになっているので書き戻される可能性がありそうです。

そしてもう一つの疑問も同時に解消されました。それは、リングバッファが一周してからはずっとこの問題が起きるはずで、ZIPセントラルディレクトリヘッダでなくても月の途中のgzipファイルもdata descriptorが書き込まれてしまうことがあるんじゃないの?というものです。以下の例のように2021/01/15のgzipファイルがspliceされて、そのあとにdata descriptor書いたら上書きしちゃわない?という。

ここからは自分の推測ですが、この破損は普通に起きていたのではないかと思います。ただし、上のようにページをdirtyにしてくれる処理がまず起きないので書き戻されなかった。2022/01の月次アーカイブを2022/02/01 00:00:01 とかにリクエストした場合、2022/01/31のgzipファイルは出来たてほやほやの可能性があり、dirtyになり得ます。しかし、2022/01/15は大分前に作られたファイルで誰もdirtyにしてくれないので書き戻されない。

8バイトの謎

また、何で破損が毎回8バイトなの?というのも理解できます。実際にはページに全てのZIPヘッダが書き込まれます。しかし正当な書き込みではないためファイルのサイズは増えません。ですが今回の処理では最後の8バイトはspliceする必要がなかったため元から最後8バイト以外をspliceでページキャッシュに載せていました。最後8バイト不要だったのは、gzipファイルからZIPファイルを作る際にgzipのtrailerが不要なためです。これは不安だったので報告者の方に質問したのですが、trailerの8バイトで合ってると確認してくださいました。

ファイルの最後までspliceしていたらこの破損は起きなかったわけで、なかなかピンポイントのバグでした。splice()でファイルからパイプに書き込みつつ、かつファイル全体をsplice()せず、パイプバッファを良い感じにマージ可能な状態にしつつ、という条件が揃わなければ再現しません。よく見つかったなと思いますし、きちんと原因追求するプロセスも面白かったです。

PoCの制約

三部作を読んでくれた人はもう理解していると思いますが、報告者のブログにもあるようにこの脆弱性を使った攻撃にはいくつかの制約があります。何かうまく訳せないので気になる人は本家ブログを見てください。

  1. ファイルへのread権限があること
    • これは splice() は正規の処理として行う必要があるため、read権限がないとsplice()が失敗してしまうためです。
  2. オフセットをページの境界線には指定できない
    • ページ上に最低1バイトは splice() で載せる必要があるため
    • 例えばオフセットを0バイトにしようとしてもマージ元のファイルがページキャッシュ上に載らない
  3. 書き込みはページの境界を超えられない
    • ページが一杯になったら別のパイプバッファ用のページが作られてしまうため
  4. ファイルはリサイズできない
    • パイプバッファのページ管理がページキャッシュにどのぐらいデータが追加されたことを伝えないため

まとめ

ダークナイト ライジングも結構好きです

セキュリティツールの評価は難しい

前から思ってたことをちょっと書かずにいられなくなったのでポエムを書きました。

背景

お前誰だよってなるかもしれないので書いておくと、Trivyという脆弱性スキャナーのメンテナをやっています。

github.com

とある有名な方による以下のツイートがありました。

簡単に言うとTrivyがAlpine Linuxで検知するべき脆弱性を検知していない(false negative)という話です。つまりバグっているという指摘なわけですが、結論から言うと実際には彼の勘違いでTrivyのバグではありませんでした。正直GitHubに来るfalse positive/negativeに関するIssueの9割近くは報告者の勘違いなので(一般論ではなくTrivyの場合です)まずはちゃんと原因を把握しようと思ったのですが、その前にすでにAlpineのセキュリティチームの方が反論してくれていました。

こうやって開発者である自分以外の方が反論してくれるの嬉しいなと思う一方で(元ツイートは言葉が強かったので削除されたようです)、知名度のある方ですらセキュリティツールの評価をするのは難しいなと改めて思ったので、なぜ難しいと思うのかについてまとめます。上のツイートをきっかけに突然思い立って書いてるので何か書き忘れているところがあると思います。その場合は追記します。

問題

一応説明しやすいように自分の開発しているスキャナーを例として使っていますが、ログ解析だったりファイアウォールだったり他のセキュリティツールでも当てはまることは多いと思います。開発者の立場で書いていますが、以下の内容はセキュリティツールを評価する立場の時に役立つかもしれないということで書いています。

あと、評価をより一層難しくしている原因としてDevSecOpsなどが進みセキュリティツールの評価をセキュリティ組織以外が行うケースが増えたこともあるのではないかと考えています。以前は既に経験や知識のあるセキュリティエンジニアが評価を行っていたが、今ではDevやOps側の人間が行うため経験や知識が不足している可能性があると推測しています。

検知している方が正しいように見えがち

これはツールの比較をする時に起こりがちです。実際、脆弱性スキャナーのメンテナを始めてから数え切れない程のツール比較の報告をもらっています。つい先週も別のツールではLog4Shellを検知するのにTrivyは検知しないんだけどバグってんじゃないの?という報告をもらいました。実際にはそのツールの実装がバグっており、"検知しない"が正解でした。それを証明するためにそのツールのデバッグまでしており、何で無償で他のツールのバグ直さないといけないんだ...と思いましたが、ここで重要なのは検知しないツールのほうが間違っているように見られがちということです。

上で紹介したツイートの件も同じです。別のスキャナーでは検知されたため、それを正と信じTrivyの間違いだと思い込みました。"known issues in Alpine images"と、完全に既知のものだと思い込んでいます。検知したスキャナーも検知していないスキャナーも立場は同じはずです。結果が違うのであればどちらが正しいのかを考えるべきなのですが、検知している方が正しく見えてしまうという罠です。

数え切れないほどそういう報告を見てきたのでその一例ではあるのですが、自分は個人的にこの方を以前から色々なOSSのメンテナーをしている人として知っていて、ツイートも見ていたのでそういう人でもこの罠に引っかかるんだなというのはちょっと驚きました。あと自分も詳細を知りたかったのでTwitterでリプライ送ったのですがスルーされて悲しかったです。

Alpineのセキュリティチームもこういう誤った報告に頭を悩ませているようです。自分も毎日そういった報告を受けているので気持ちが痛いほどよく分かります(ECRもTrivyにならないかな...)

もちろん正しい報告もありますし、そういった報告によって改善されてきているのでバグ報告は大歓迎なのですが、一度「本当に検知している方が正しいのだろうか?」という視点を持ってもらえると良いかと思います。

ちなみに間違いは誰にでもあるので、今回の件を批判したいという意図は全く無いです。教訓として

  • やっぱりこの罠には陥りやすいよね
  • バグだと思い込んで公で発信する前に一度立ち止まって確認すると良いよね

という2点があるというだけです。「あれ、これバグかな?」となったらIssueを起票してバグであることを確かめてから発信してもらえると誤解を招かなくて良いなと思います。

条件を揃えるのが難しい

複数スキャナーを比較する際の別の問題として、条件を揃えるのが難しいというのがあります。一例として、スキャナーによってデフォルトのオプションが異なります。例えばとあるスキャナーはCriticalな脆弱性のみデフォルトで表示するかもしれません。一方で他のスキャナーは低い深刻度の脆弱性も全て表示するかもしれません。そうすると、何となく後者のほうがたくさん検知しているため良さそうに見えます。実際には最初のスキャナーもオプションを付けて低い深刻度の脆弱性を表示したら総数は同じになるにもかかわらずです。

Trivyでは上の理由から悩ましい葛藤があります。現在、デフォルトではパッチがない脆弱性であっても表示するようになっています。これは組織によってはパッチがないものでも設定変更やファイアウォールで対応したいかもしれませんし、ゼロデイのような場合はパッチが来る前であっても検知してほしい場合があるためです。ですが、実際にはパッチがなければactionableではないため非表示にしたいという組織が多いです。そういった理由から本当は非表示をデフォルトにしたかったのですが、そうなると表示がデフォルトのスキャナーに比べて検知数が少なく見えます。もちろんちゃんとした人なら分かってくれるのですが、世の中はそこの判断が出来ない人が非常に多いです。誰かが条件を揃えずに総数を比較したブログを公開するとそれに従って使うツールが選ばれます。

この判断は真の意味でユーザに寄り添っていなくて本当に辛いところなのですが、会社として開発している以上は多くの人に使ってもらう必要があり、泣く泣くデフォルト表示の実装になっています。ツールが無名だった頃は血の涙を流しながらやっていましたが、既にある程度知名度を獲得したので将来的には変えたいと思っています。

環境の再現が難しい

複数ツールを比較する時に、そのツールを使った環境を揃えるのが難しい場合があります。例えばネットワークトラフィックに対して異常検知するIDSのようなツールの場合は比較するツールそれぞれに全く同じトラフィックを流さないといけませんが、その量が膨大だったりしたら保存も大変ですし実環境でしか検知されない問題だったら実環境でどう流すのかを考えなければなりません。ネットワーク機器の設定が間違っていて一部のパケットだけうまく一部のツールにだけ転送されていないといったことも起きます。

ホストマシン上で何かしらのセキュリティツールを実行する場合は、そのホストの状態が変わってしまうことで検知結果が変わってしまうこともあります。さっきまでポートが空いていたのに閉じてしまったり、新しくプロセスが立ち上がったり、状態は刻一刻と変化していきます。

同じ環境で検証したつもりが実は微妙に異なっていて、それが異なる結果を生んでいたということはよくあります。

こちらは@m_mizutaniさんのツイートをきっかけに過去の辛さを思い出したので追記しました。

検知数が多い方が良さそうに見える

これは上の話とも関連しますが、検知数が多いほうが良さそうに見えます。この辺は以前も少しブログで触れたのですが、脆弱性検知においては完全に正しく検知できないという状況が存在します。その場合にノイズにならないようにということで検知しないようにするのか、過検知を含むかもしれないけど検知してしまうのか、というのは未だに自分も答えを持っていません。こういったトレードオフは(セキュリティに限らずですが)よく出てきます。

ただし、評価の面から言うと検知数が多いほうがよく見えてしまいます。そうすると例えfalse positveを多く含んだとしても検知数を増やすほうが評価されてしまう問題が起きます。もちろん言い分としてはfalse negativeを減らせるということになります。しかしfalse positveが100件増えてfalse negativeが1件減らせるとなれば正解率(Accuracy)を考えると明らかに好ましくありません。好ましくないというのは自分の主観であってそこの判断は組織がするべきですが、言いたいことはそこまで考えてツールを評価する人は稀ということです。

もちろん1件でも見落として致命的な問題につながったら困るからAccuracyは低くても構わないと考える人もいるかも知れませんが、セキュリティツールが頻繁にアラートをあげてかつそのほとんどが嘘だった場合やがて誰も信じなくなります。そう考えるとオプションとして過検知多めがあっても良いですが、個人的にはデフォルトオプションではアラートは正確であってほしいと思います。そういう判断軸でTrivyは実装しているのですが、数が少なく見えるから多く検知できるように実装してくれという圧を受けることは多いです。

こういったトレードオフに対してツールがどういうアプローチを取っているのか、というのは上の「条件を揃えるのが難しい」とも通ずる話ですが、正しく理解して判断するのは難しいです。

余談ですが、一方でスキャン対象の場合は検知数が少ないほうが正義になりがちです。もちろん少ないほうが良いのですが内訳は考えたほうが良いです。例えば脆弱性スキャンの例で言うとDebianとAlpineの脆弱性数の比較というのがよく行われ、Alpineの方が少ないからAlpineの方が良いと評価されたりします。ですが実際にはDebianの方ではパッチ未提供のものもセキュリティアドバイザリを出していて、Alpineでは出していません。つまりセキュリティアドバイザリの質としてはDebianの方が優れているということになります。にもかかわらずそこまでは踏み込まず単純に数で比較されてしまい、自分の作ったツールによって誤った判断がされているのを悲しい気持ちで見ています。

正解かどうかの判断が難しい

例えば脆弱性が検知された時に、これって検知されるべきなの?という判断はかなり知識が要求されます。そのため他のツールとの比較に走ってしまう気持ちはわかります。例えばRed Hatなどのディストリビューションではbackport fixが存在し、上流の修正バージョンとは異なります。そのためupstreamのバージョンと比較してしまい、自分のバージョンは古いのに検知されない!というバグ報告を受けることは多いです。

また、バージョンを比較するというのも実際には容易ではなくディストリビューションによってロジックが異なります。人間が目で見て判断が難しい場合も存在します。

他にも推移的依存がどう解決されるのか?なども難しいです。例えばAがCの1.0に依存していてBがCの2.0に依存している場合にnpmではバージョンはどう解決されるの?とかまで考えて日頃npmを使っている人は多くないと思います。これはもちろんパッケージマネージャによって実装が異なります。

npm.github.io

もっと言うとテスト用のライブラリの脆弱性は本番にデプロイされないという理由で検知しないようにしてたりするのですが、この辺りも何で検知されないんだと言われることが多いです。

つまり、アラートが出た時にそれが本当に正しいのかを判断することがユーザ側にできないことがあります。その逆も然りでこれは検知されるべきだ!というバグ報告もあります。上で述べたようにDevSecOpsやシフトレフトが進みセキュリティツールを使うのは必ずしもセキュリティに詳しい人間ではないため、この辺は本当に難しいなと思います。

また、この評価の難しさから検知数の大小で比較されてしまう問題に繋がっている気がします。

カバー範囲の正確な見極めが難しい

これまた脆弱性スキャナーの例なのですが、例えばJava対応をしている時に「JARに対応しています」という表面的な文言だけで判断してしまうことがあります。ですが実際にはUber JARには対応してるの?とかShaded JARには対応してるの?とか詳細まで踏み込んで比較する必要があります。しかし、関連技術を深く理解していないとそういった比較は行なえません。

Log4Shellの時に書かれた比較ブログで良い例があるので共有しておきます。これも単純化してしまうと「全てのスキャナーがLog4Shellを検知可能です」ということになります。

www.rezilion.com

@58_158_177_102さんのツイートを見てその辺りを思い出したので追記しました。

検知されないほうが嬉しい

これはツールを比較せずに単体で評価する場合の話ですが、基本的にはみんなセキュリティアラートとか見たくないです。つまり、検知されないと安全だと信じ込んでしまいます。

アラートが出た場合はまだ正当性を確認するためのアクションを起こせますが、アラートが出ない場合は「おや、本当は検知されるべきなのに」と気付くのは非常に難しいです。これもまた正しく評価されていないことになります。検知されてない問題があるかもしれないから正しく評価できてないなと気付けた場合でも、とりあえず比較するかーとなって今度は上の問題に繋がります。

まとめ

セキュリティツールの評価は非常に難しいです。特にこうすると良いという案はなくて難しいよね〜と言ってるだけなのでポエムです。ただ、誤った評価をする前に上のように誤解しやすいポイントを思い出してもらえると違う視点で見ることが出来るかもしれません。

日々「お前のツールバグってるぞ!」と言われ続け「いえ正しいです」と返すのは結構精神的に来るものがあるのですが、その中に改善に繋がるものもあるのでIssue上げるのやめろとか言うつもりはないですしむしろ歓迎です。ただ、「こっちのツールで検知したのにお前のツールバグってるぞ!」と思い込んで主張するよりは「こっちのツールでは検知したけどこれってどっちが正しいの?」みたいなスタンスのほうが嬉しいな〜〜〜〜〜と思ったり思わなかったりです。

20分で分かるDirty Pipe(CVE-2022-0847)

極限まで詳細を省けば何とか20分で雰囲気だけでも伝えられるんじゃないかと思って書きました。書き終えてから見返したら多分無理なので誇大広告となったことを深くお詫び申し上げます。

背景

先日Dirty PipeというLinuxカーネル脆弱性が公表されました。

dirtypipe.cm4all.com

Linuxのパイプに関する脆弱性なのですが、仕組みは意外とシンプルでぎりぎりブログでも伝わるかもしれないと思ったので自分の理解を書きました。あといつも細かく書きすぎて長くなるので、今回は雰囲気だけでも伝わるようにとにかく説明を簡略化し、ふわっとした概要だけでも理解してもらえるように頑張りました。その結果、若干正確性に欠ける部分があるかもしれませんがお許しください。細かい部分はまた別の記事でまとめます。もっと正確に細かく書きたいという衝動を抑え、とにかく20分以内に「あーそういうことね完全に理解した(わかってない)」というところまで持っていくことを目指しています。

なお、自分は特にLinuxカーネル開発者でもないですし、一部は簡略化のせいとかではなく普通に間違っている可能性もあります。その点は念頭に置いて読んでください。また、何か間違いがあれば修正するのでLinuxカーネルに詳しい方たちからのご指摘もお待ちしております。

概要

脆弱性の影響

まず、この脆弱性はread-onlyのファイルに対して書き込みができてしまうというものです。例えばrootしか書き込みできない /root/.ssh/authorized_keys だったり /etc/passwd だったりを一般ユーザから変更することでrootになりすますことが出来ます。つまり権限昇格の脆弱性ということになります。

PoCは上のブログで公開されているのですが、Linuxカーネルのバージョンが5.8以降でかつ修正バージョンである5.16.11, 5.15.25, 5.10.102以前の場合は簡単に刺さります。昔Dirty Cow(CVE-2016-5195)という似た脆弱性がありましたが、Dirty Pipeはレースコンディションを引き起こす必要もなくシビアなタイミングを求められたり運に左右されるものではないため、遥かに攻撃成功率が高いです。

$ ./exploit /etc/passwd 1 ootz:

上のPoCをビルドして、ファイル名とオフセットを指定してその後に書き込みたいデータを指定するだけで、指定したオフセットからデータを書き込んでくれます。上の場合だと /etc/passwd のrootユーザのパスワードを消し去っています。ファイルの長さは変えられないためrootユーザをrootzに変えていますがidは依然として0です。この攻撃の後に su rootz とするとスーパーユーザになり攻撃は完了です。

read-onlyな任意のファイルの上書きが出来るので、攻撃のパスは多数あります。

ページキャッシュやsplice

最初に初歩的な説明をしますが、Linuxカーネル脆弱性が気になるような人は知っている話なので飛ばして良いです。

Linuxにおけるメモリ管理のおさらいですが、CPUによるメモリ管理の最小単位はページと呼ばれています。通常は4KBです。アプリケーションがメモリを要求した場合、必要なサイズ分のページが確保されます。ファイルの入出力も同様にページです。ファイルからデータを読み込みたい場合、カーネルは最初に4KB単位でディスクからカーネルのメモリ上にコピーします。これはページキャッシュと呼ばれています。そして次にユーザ空間にコピーされます。キャッシュと呼ばれていることから分かるように、次に同じファイルにアクセスした際に不要なディスクI/Oを避けるためにページキャッシュはしばらくの間残ります。カーネルが不要と判断したり別の用途に使う場合に開放されます。次回以降のファイルアクセスはメモリから読み込まれるので高速になります。書き込みの場合は一定のタイミングでディスクに同期されます。mmapなどは今回の説明の本筋から離れるので説明しません。

次に、 splice() というシステムコールについて説明します。これはデータ転送をしたい場合の片方がパイプだった時にユーザ空間を介さず直接ファイルやソケットやパイプにカーネルがデータを転送してくれるものです。zero-copyと呼ばれるやつです。詳しくはぐぐったら色々出てくるので割愛しますが、入力側がパイプでも良いし出力がパイプでも良いですし両方パイプでも良いです。とにかく片方はパイプである必要があります。似たシステムコールとして sendfile とかもありますが、その辺もググってください。

splice() については前回の記事でも触れているのであわせてご覧ください。

knqyf263.hatenablog.com

パイプ

パイプは知ってると思いますがプロセス間の入出力を繋げる仕組みです。以下のようなやつです。

$ command1 | command2

以下で単にパイプと言うときは無名パイプの前提で話しています。ソースコードを見ましたが名前付きパイプの場合はちょっと違う雰囲気だったので、以下の説明が名前付きパイプにも当てはまるかどうかは分からないです。

パイプでは片方がデータを送り、片方がそのデータを受け取ります。これは内部的にはリングバッファで実装されています。リングバッファも調べれば出てくるので説明しませんが、一時的にデータを貯めておくバッファ領域の終端と先端が連結されていて循環的に利用できるものです。内部の実装的には pipe_buffer というstructで実装されています。 pipe_inode_infobufs というフィールドを持ちリングバッファを管理しています。

一部古いですが分かりやすい図があったので引用しておきます。

f:id:knqyf263:20220310225307p:plain
https://lwn.net/Articles/118052/ より引用

pipe_buffer はページへの参照を持っていることが分かります。このパイプバッファはデフォルトで16個あります。pipe_buffer は1ページに対応していることを考えると16ページと言えます。ページ1つが4KBなのでパイプは64KBまでデータをバッファに保持できます。パイプから読み出されれば開放されていきますが、開放される前にそれを超える分を書き込むとブロックされます。パイプに初めてデータが書き込まれるとページが一つ確保され、 pipe_buffer はそのページへの参照を持ちます。

仮に最初1バイト書き込み、次にまた1バイトを書き込むとページはどのように確保されるのでしょうか?上で引用したブログに面白いことが書いてありました。

lwn.net

元々パイプバッファはページ1つのみだったようで、それでは4KBしかなくてブロックされやすいし色々と捗らないということでLinusLinux 2.6.11でリングバッファに書き直したようです。その際、一度確保したページは使い回されず逐一ページを確保する実装になっていました。つまり1バイト書き込んだあと1バイト書くとページがもう一つ確保されます。1バイトずつ16回書き込むだけで16ページを使い切ってしまいます。最悪の場合は16バイトでパイプバッファが詰まります。

言葉だと分かりにくいのでイメージ図を書きましたが、絵を書くセンスが無いので逆に分かりにくいかもしれません。せっかく16個もバッファがあるのに各バッファは1バイトずつしか埋められてないという図です。正確にはバッファはページへの参照なので、4KBもあるページのうち1バイトしか使われていません。16個書くの面倒だったので6個になってますが脳内で16個だと思ってください。

f:id:knqyf263:20220311080206p:plain

1バイトずつ書き込む場合の指摘に対して、Linusは「そんなことをするべきではないし、そういうことをする人間はパフォーマンスを期待するべきではない」と主張していたようですが、後々一般的な用途で問題となるケースが見つかったようで各ページを使い切るように実装し直したそうです。

改善後のイメージは以下です。1バイトずつ書き込んでも最初の pipe_buffer が参照するページに足されていきます。

f:id:knqyf263:20220311080509p:plain

そして4KB使い切ったら次のページが確保されてそちらが使われていきます。つまり今のLinuxでは同じページが使い回されるということです。別の言い方をするとページに対してあとから書き込まれたデータがマージされていくためメモリ使用量の観点で効率的です。ここまでは妥当な改善かなと思いますし特に難しくはないと思います。

では splice() を使ってパイプにデータを入れた場合はどうなるか見てみます。例としてファイルからパイプに転送する場合を考えてみます。この場合、カーネルはまずデータをページキャッシュに読み込みます。そして pipe_buffer からそのページキャッシュ内のページを参照するようにします。こうすることでユーザ空間へのコピーなしにパイプへファイルのデータを転送することが出来ます。

一応自分のイメージを図にしてみました。これはファイルのデータをパイプ経由で直接ソケットに転送している例です。splice() を発行するとカーネルがファイルをページキャッシュに読み込み、パイプバッファはそのページへの参照を保持します。パイプからデータを読み込む側も splice() を使うことで直接そのページを参照するため、ユーザ空間にコピーせずページキャッシュへの参照を引き回すだけでデータを転送できます。

f:id:knqyf263:20220311075414p:plain

上の図だと急に登場人物が増えて分かりにくいとは思いますが、正直あまり脆弱性の理解の上では必要ないので忘れてもらっても大丈夫です。中でもパイプバッファだけに注目すると以下になります。ディスクからページキャッシュにデータが読み込まれて、そのページを参照しています。ページキャッシュから再度通常のページにコピーして参照するといったことはしていないので効率的です。

f:id:knqyf263:20220311094507p:plain

この時、パイプに単にデータを連続して入れた場合とは異なり、splice()の後にパイプにデータを入れても splice() によって作られたページは更新されません。そのページはページキャッシュによって管理されているものでパイプによって管理されているものではないためです。例えば

  1. パイプにデータを入れる
  2. splice()を使ってファイルからパイプにデータを入れる
  3. パイプにデータを入れる

の順番で処理を行う場合を考えます。その場合のイメージ図は以下です。

f:id:knqyf263:20220310231906p:plain

1つめのページ(page 1)を使い切っていないのに splice() によって新たな pipe_buffer が作られページキャッシュが参照されます。そしてその後に追加されるデータはページキャッシュにはマージされず新たなページ(page 2)に足されます。

などと偉そうに言いましたが、2つめのページが作られるのかは少し自信がないです。データの順番は保つ必要があるので普通に実装するとこうなるはずですが、Linuxがめっちゃ省メモリを頑張っている場合は splice() 後に足したデータが1ページ目にマージされる可能性も0ではないのかなと思いました。pipe_buffer のフィールド的にそのような実装にはなっていなさそうですが。

linux/pipe_fs_i.h at v5.8 · torvalds/linux · GitHub

ですがここではそれはあまり問題ではなくて、あとからパイプに書き込んだデータは splice() によって確保されたページには絶対にマージされないということが重要です。ページキャッシュに書き込まれたデータはファイルに書き戻されてしまうため、勝手にページキャッシュに書き込んだら大問題です。

重要なことなのでもう一度言いますが、パイプに書き込んだデータをページキャッシュにマージしてはいけません。新しくページを作ってそこに入れるべきです。もしマージをしてしまうとただパイプにデータを入れただけなのにファイルが更新されるという珍現象が起きます。

ということで既に分かったと思いますが、Linuxカーネルがパイプに書き込まれたデータを間違ってページキャッシュにマージしてしまうというのが今回の脆弱性です。その結果、 splice() でただreadされただけのファイルがカーネルによって編集されてしまいます。任意のファイルが編集可能なわけではなくて、正規の手順で splice() を発行するためにreadの権限は少なくとも持っている必要があります。

マージの可否

マージされてはいけないものがマージされてしまうというのが本質なわけですが、ではどうやってそのパイプバッファにマージするべきかどうかを判断しているのでしょうか?それは、 pipe_buffer の中に flags というフィールドがあり、そちらを使っています。

linux/pipe_fs_i.h at v5.8 · torvalds/linux · GitHub

ちなみに1つ目の page がページへの参照を保持しています。この flags の中に PIPE_BUF_FLAG_CAN_MERGE のビットがセットされているとカーネルはマージ可能と判断します。

f:id:knqyf263:20220311095434p:plain

普通にパイプにデータを入れていくと上で説明したとおりページはマージ可能になります。PIPE_BUF_FLAG_CAN_MERGE だと少し長いので下図では can_merge=true と表現しています(実際カーネルの過去の実装ではcan_mergeのフィールドを持ってた)。ページにデータがマージされていくので、4KB使い切ったら次のページが作られ...という流れでパイプバッファは使われていきます。

f:id:knqyf263:20220311083512p:plain

リングバッファなので一周したら再び先頭に戻ります。処理としては以下の辺りです。

linux/iov_iter.c at v5.8 · torvalds/linux · GitHub

i_head & p_mask をしてるので、一周したら最初の pipe_buffer が再利用されます。 p_mask は少し上の実装を見れば分かりますがリングバッファのサイズです。もちろん読み込まれたあとじゃないと再利用はされません。読み込みが終わっていない場合はブロックします。しかしこの1つ目の pipe_buffer は使用済みのものなので、新たに正しいページへの参照などを入れてあげる必要があります。

buf->ops = &page_cache_pipe_buf_ops;
get_page(page);
buf->page = page;
buf->offset = offset;
buf->len = bytes;

この辺の処理です。確かに新たなページへの参照などを書き込んでいます。

f:id:knqyf263:20220311084609p:plain

ここでよく見ると flags の初期化を忘れていることに気付きます。つまり使用済みのページ1のときの flags の値が残ったままになります。この初期化忘れが深刻な脆弱性につながっています。修正は当然 flags を初期化するだけなので一行です。

buf->flags = 0;

もしこの flagsPIPE_BUF_FLAG_CAN_MERGE がセットされていた場合、本来はマージできないはずのページを can_merge=true にすることが出来てしまいます。

先程 splice() を使った例ではマージしてはならないという話をしました。ではここで splice() を使うとどうなるか?初期化を忘れた flags が残ってしまい、ページキャッシュへの参照にも関わらず can_merge=true な状態になってしまいます。

f:id:knqyf263:20220311085150p:plain

この状態でパイプに foo を書き込むと、本来ページキャッシュにはマージできないので新たなページに書き込まれるはずが、誤ってページキャッシュに書き込まれてしまいます。もちろんそのページが4KBを使い切っておらずfooを書き込む余裕がある場合に限ります。

f:id:knqyf263:20220311085418p:plain

このページキャッシュは他のプロセスがそのファイルを読み込もうとしたら使われるので、末尾にfooと入ったデータが返されます。上でページキャッシュに書き込むとディスクに同期されると言いましたが、このようにして追加されたデータは明らかに通常の方法ではない方法でページキャッシュに追加されているのでページがdirtyと判断されず、実際にはディスクには書き戻されません。ページキャッシュに書き込みがあるとdirtyというマークが付けられ、そのあとまとめてdirtyなページをディスクに書き戻します。

そのため、例えば再起動したりページキャッシュが開放されたりするとfooは消え去ります。ですが、それまでは別プロセスはディスクを読みに行かずページキャッシュのデータを使うため、fooはあるものとして扱われます。以下の図でディスク上のファイルにはfooは書き込まれていないことに注目してください。

f:id:knqyf263:20220311085825p:plain

ということで無事にread-onlyのファイルに指定した値を書き込むことに(永続化はされないけど)成功しました。偶然別のプロセスがそのページをdirtyにしてくれるとfooも一緒に永続化されます。

splice() はオフセットやファイルから読み込むサイズを指定できるので、うまく指定することでデータの書き込む位置も制御できます。つまり splice() で先頭から10バイト目までページキャッシュに乗せておけばfooは11バイト目から書き込まれます。

f:id:knqyf263:20220311100734p:plain

もっと言うとカーネルがファイル位置との対応付けをやってくれるので10バイト目"だけ" splice() でページキャッシュに乗せれば十分です。後続のデータは11バイト目以降として扱われます。

下準備

パイプの中の flagsPIPE_BUF_FLAG_CAN_MERGE ビットを事前に全て立てておく必要があります。これは簡単で4KBのデータを16回パイプに入れるだけです。何度も説明していますが普通にパイプにデータを入れえると can_merge=true になります。PoCでは以下の部分です。一応パイプバッファが16個じゃない場合もあるので、ちゃんと fnctl でパイプのサイズを最初に計算しています。

const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];

/* fill the pipe completely; each pipe_buffer will now have
   the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
    unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
    write(p[1], buffer, n);
    r -= n;
}

ただ入れるだけだとパイプが詰まってしまうので、パイプからデータを読み込みます。このデータは特に使わないので捨てます。

/* drain the pipe, freeing all pipe_buffer instances (but
   leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
    unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
    read(p[0], buffer, n);
    r -= n;
}

準備はこれだけです。これでリングを一周したので、次に使うバッファは自動的に PIPE_BUF_FLAG_CAN_MERGE が誤ってセットされた状態になります。あとは splice() を呼んでファイルからページキャッシュにデータをロードして、

ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);

データをパイプに書き込むと

nbytes = write(p[1], data, data_size);

ページは新たに作成されずページキャッシュにマージされ、あたかもファイルが上書きされたかのような状態になります。

攻撃手順

改めてまとめると以下です。

  1. まずはパイプバッファのリングをデータで満たす
    • この時、通常のページなので PIPE_BUF_FLAG_CAN_MERGE がセットされる
  2. パイプからデータを全て読み出す
    • パイプは空になったが、各 pipe_bufferflags には PIPE_BUF_FLAG_CAN_MERGE がセットされたまま
  3. splice() を使ってファイルをページキャッシュに読み込みつつ pipe_buffer からそのページを参照する
    • この時、flagsPIPE_BUF_FLAG_CAN_MERGE が存在してはいけないが、初期化を忘れているため上の1でセットした flags がそのまま残る
  4. パイプに適当なデータを書き込む
    • 新たなページが作成され pipe_buffer はそちらを参照するべきだが、3で作った pipe_buffer はマージ可能と言っているため参照先であるページキャッシュにデータを書き込む
  5. 別プロセスがこのファイルを読み込もうとした場合、改ざんされたページキャッシュが参照されるため攻撃者の入れたデータがファイルの中身として使われる

f:id:knqyf263:20220311101903p:plain

この説明を読んでからPoCを読むと簡単に感じるのではないでしょうか(願望)。元のブログを読んでもらえば分かると思いますが、元のブログに比べると図を入れたことでかなり分かりやすくなったのでないかと思います(願望)。

この辺まで理解していれば自分でPoCを書くのも容易だなということで自分でも書きました。オリジナルのCによるPoCをGoに移植しただけですが、なるべくGoで提供されているAPI経由で出来るように挑戦しました。試しましたがちゃんとGoでも刺さります。

github.com

まとめ

ということで駆け足で説明しました。本当はページの境界を超えるようなデータは書き込めないとか、たまたまページがdirtyと判定されるケースとか、報告者に直接質問して得た細かい諸々も書きたかったのですがグッとこらえました。そもそも報告者はどうやってこの脆弱性見つけたの?とか細かい話はまとめて別の記事に書きます。