ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

CVE-2021-3538 をちゃんと読む

はじめに

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

パッケージドキュメントにも

https://pkg.go.dev/io#Reader

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自身もちゃんとスレッドセーフなつくりだ。

https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/crypto/rand/rand_unix.go;l=74;bpv=0;bpt=0

r.f = bufio.NewReader(hideAgainReader{f})

ここで /dev/urandom ファイルに色々挟んでdevReaderの中身に設定してるっぽい。それぞれのReaderについてコケる理由をまとめるか

hideAgainReader

hideAgainReader はEAGAINを無視してそう

https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/crypto/rand/rand_unix.go;l=88-94;bpv=0;bpt=1

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)

https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/internal/poll/fd_unix.go;l=792-799;drc=cfd202c701d3c1fda740a8c3c725fbb704054591

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を受け取ってどこまで使えるのか確認しような!