はじめに
CVE-2021-3538 は https://github.com/satori/go.uuid についての脆弱性で、uuidが攻撃者にとって予測可能になる場合があるでというやつです
trackerへの登録はこれ https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-3538
仕事で使ってた人はCIなりが落ちに落ちまくって対応したとおもいます。僕もしばらく前にこいつに関する対応をすこしお手伝いしました。
当時はチラっと読んだだけでちゃんと見てなかったので、しっかり追いかけようという趣旨です。
問題の差分
Red Hat のバグトラッカー によると、このコミットで解決したよう
https://github.com/satori/go.uuid/commit/d91630c8510268e75203009fe7daf2b8e1d60c45
ただ、satori/go.uuidはメンテが止まってて該当コミットはmergeはされたけどrelease tagついてないので、他のやつを使いましょうということらしい。
で、このcommitを眺めてみると g.rand
(通常はcrypto/rand.Reader) からの読み出しを Read()
じゃなくて io.ReadFull()
で行うように変えただけにしか見えない!
あ〜〜〜〜そういえば Read()
って指定したバイト数読み取る保証無いんだっけ?これ見た感じ期待してるbyte数読み取れなくてエラーも返さないパターンで後半bitが0になっちゃって、結果random bitが減って予測可能になったみたいなオーラ感じるな〜〜
当時のissue
実際どういうやりとりされたか確認のため当時のissueもみてみた。
https://github.com/satori/go.uuid/issues/73
圧があって面白かったので冒頭の生成結果を引用
02524e6f-65a7-4cf5-8000-0000000000002 6e3ef1c8-0000-4000-8000-0000000000001 fa07f1e9-a8a0-427f-8103-e34e000000000 2cfa392b-71d5-4000-8000-000000000000 5f16db16-56ce-4000-8000-000000000000 bac73b37-aab3-498b-8000-000000000000 da5bd82d-4f98-4b51-8000-000000000000 eb245e52-535f-4274-8350-3a7200000000 f7510000-0000-4000-8000-000000000000 5936f955-286e-47ac-8000-000000000000 0a14da92-0000-4000-8000-000000000000 80730000-0000-4000-8000-000000000000 cd2b88a5-0000-4000-8000-000000000000
スゴ味があるっすね。6byteめと8byteめはversionとかvariantとか入ってるから4とか8になってるので、最悪なケースでは2byteしかrandom bit吸えてないっすね。こわ。
で、やっぱ Read()
は指定したバイト数読み取ることを保証してないのでやっぱそれだった
https://github.com/satori/go.uuid/issues/73#issuecomment-378573107
パッケージドキュメントにも
It returns the number of bytes read (0 <= n <= len(p))
とある
なんならReader側で一部のbyteしか読み取れないときはブロックしないで部分的なまま返すでみたいなことも書いてある。
Even if Read returns n < len(p), it may use all of p as scratch space during the call. If some data is available but not len(p) bytes, Read conventionally returns what is available instead of waiting for more.
ちゅーわけで、len(p)ぶんの読み取りを保証したいなら io.ReadFull()
を使うかそのwrapperである crypto/rand.Read() を使えよってことだな。
ちょっと深堀り: どういうケースでcrypto/randはlen(p)未満の読み取りとなるのか
ちょっときになったので調べてみる。
さっきのチケットではこういう再現コードが書かれていた
https://github.com/satori/go.uuid/issues/73#issuecomment-378497743
go func() { for { time.Sleep(1 * time.Second) for i := 0; i < 1000; i++ { u := uuid.Must(uuid.NewV4()).String() if strings.Contains(u, "000000000") { log.Fatal(u) } } } }()
短い期間でたくさん採番したいよ〜なケースかな
ちなみに似たようなコードを手元でかいて、短時間でcrypto/rand.Readerからガリガリbyte読み取りまくったけど僕の環境では再現しなかった。 /dev/urandom
の残エントロピーとか関係あるんだろうか?(普段つかってるPCで試したからエントロピーもりもりだとおもう)
再現しなかったので、コード読んで発生しそうなこと適当にまとめておく
実際crypto/rand.Readerはどこから読み取ってるかというと unix用の実装 では /dev/urandom
から読んでるっぽい。
エントロピー貯まるまでタイマー仕込むとかそういうのもやってるのか。えらい
Reader自身もちゃんとスレッドセーフなつくりだ。
r.f = bufio.NewReader(hideAgainReader{f})
ここで /dev/urandom
ファイルに色々挟んでdevReaderの中身に設定してるっぽい。それぞれのReaderについてコケる理由をまとめるか
hideAgainReader
hideAgainReader
はEAGAINを無視してそう
func (hr hideAgainReader) Read(p []byte) (n int, err error) { n, err = hr.r.Read(p) if err != nil && isEAGAIN != nil && isEAGAIN(err) { err = nil } return }
man みるかんじ、dev/urandomに対してのreadは基本EAGAIN返さないぽいオーラを感じるけどなんだこいつ?
とおもったけど チケット とか パッチ みて理解した。本来カーネルは返すべきじゃないけどVMとか向けの機能の文脈で返すこともあるっぽい。まあ例外的なケースの対応っぽいのであんまり気にしなくていいかなー
os.File
つぎにos.Fileのほうみてみる。
os.Fileの Read()
の中よむと、file descriptorがstreamで 1 << 30
より読み取ろうとすると押さえつけられるらしい。今回はそんなにでかくないので多分関係ない
https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/internal/poll/fd_unix.go;l=159-161;drc=cfd202c701d3c1fda740a8c3c725fbb704054591
if fd.IsStream && len(p) > maxRW { p = p[:maxRW] }
(ちなみにos.Openで作られたものは基本IsStream = trueのよう)
https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/os/file_unix.go;l=130;drc=cfd202c701d3c1fda740a8c3c725fbb704054591
最終的なreadの発行はここ
https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/internal/poll/fd_unix.go;l=163;drc=cfd202c701d3c1fda740a8c3c725fbb704054591
n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
func ignoringEINTRIO(fn func(fd int, p []byte) (int, error), fd int, p []byte) (int, error) { for { n, err := fn(fd, p) if err != syscall.EINTR { return n, err } } }
syscall.EINTR
によるエラーを無視しつつ、エラーがおきるまでbyteを読み続けるといった感じらしい。
man見た感じ /dev/urandom
は256byte以下のreadなら安全にさばけるけど、それを超えるとbufより少ないbyteしか読み取れなかったり、EINTR返ってくるらしい。EINTRは無視されてるから中途半端なbyte読み込みの事由にはならないけど、たくさん読みすぎちゃうとnが半端になる可能性あるよう
https://man7.org/linux/man-pages/man4/random.4.html
The O_NONBLOCK flag has no effect when opening /dev/urandom. When calling read(2) for the device /dev/urandom, reads of up to 256 bytes will return as many bytes as are requested and will not be interrupted by a signal handler. Reads with a buffer over this limit may return less than the requested number of bytes or fail with the error EINTR, if interrupted by a signal handler.
ほえーなるほどといった感じ。len(p)を返せない事由としては、やっぱ /dev/urandom
からほしかったぶん読み取れなかったとか、素直にそういう系が線として強そうだな。
たとえばエントロピープールが少なくなるとプールを種に疑似乱数吐いてふくらませる記憶があるから、その処理とぶつかるとか有り得そうな気がするけどあんま詳しくないんだよな...
ただ僕の環境では再現しなかったのでこれ以上の深追いは断念。
bufio.Reader
実装ざっとみた けど、こいつ単にバッファつきreadするだけっぽい。
でdefault buffersizeが 4096 とのこと https://cs.opensource.google/go/go/+/refs/tags/go1.18.3:src/bufio/bufio.go;drc=293d43e17eaae8ccb83364e401538d51c035b8a4;l=19
んでいくら4096byte読み取ろうとしても、そもそも大本のReaderがlen(p)以下を返しちゃったらそういう結果になるよね〜ってことか
コメントにもそんなことが書いてある
https://pkg.go.dev/bufio#Reader.Read
The bytes are taken from at most one Read on the underlying Reader, hence n may be less than len(p). To read exactly len(p) bytes, use io.ReadFull(b, p).
ふむ納得した!
しかも /dev/urandom
はさっきman見たとおり、256byte以上readされるとどうなっちゃうかわかんないよ〜〜〜といっているので、状況としてはこれでシバき回ると len(p)
以下返すのもさもありなんという感じだ
まあだから、基本的には /dev/urandom
が読み取りたいバイト数以下の値を返すことがあるよ!というのが大本かな
まとめ
CVE-2021-3538 を追いかけてみた
どういうCVEだったかというと、Goの io.Reader
の仕様に起因する crypto/rand.Reader
の扱い方に関する凡ミスで、こいつから読み取りたいときは crypto/rand.Read()
もしくは io.ReadFull()
を噛ませて使おうね!ってことだった。
io.Reader
はpで指定したバイトを全部読み取ってくれる保証はないので、ちゃんと読み取ってほしいときは io.ReadFull()
を使おう!半端な読み取り許容できるならちゃんとnを受け取ってどこまで使えるのか確認しような!