google / google/go-containerregistry
π¨ Zero-Day: Incomplete SSRF mitigation in `pkg/v1/remote` β DNS hostname bypass and missing safe dialer
- Dominant language
- Go
- Stars
- 4k
- Forks
- 686
- Avg merge
- 2d 12h
- Merged PRs (30d)
- 26
Description
`github.com/google/go-containerregistry` v0.21.6 introduced SSRF guards in four locations inside `pkg/v1/remote` and `pkg/v1/remote/transport`. Every guard shares the same structural flaw: it inspects only **IP-address literals** in redirect targets and realm URLs, and does nothing for **DNS hostnames**. Because there is no SSRF-aware dialer, the actual TCP connection is established by the Go runtime with a fresh DNS lookup that happens after all guards have run.
The result is that the existing checks can be bypassed entirely by any redirect destination expressed as a DNS name β no race condition required. A malicious or compromised registry can redirect a client to the cloud instance-metadata service (AWS IMDS, GCP metadata API, Azure IMDS/Wire Server) or to any other internal-network resource, using nothing more than a DNS `A` record.
## Affected versions
| Range | Status |
|---|---|
| `< v0.21.6` | No SSRF protection at all |
| `= v0.21.6` (current release) | Partial SSRF protection present; bypass described in this report |
| `HEAD` (unreleased) | Same partial protection as v0.21.6 |
## Severity
**High β CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N β Base Score 8.2**
- **AV:N** β Delivered entirely over the network; attacker operates a registry.
- **AC:L** β DNS bypass requires only a standard A/AAAA record; no race condition is needed for the primary attack path.
- **PR:N** β No privileges in the victim's environment are required.
- **UI:R** β Victim must interact with the attacker-controlled registry (push or pull an image, which happens routinely in automated CI/CD pipelines).
- **S:C** β Scope changed; the attack reaches services beyond the container registry client itself.
- **C:H** β Cloud credentials (AWS access keys, GCP service account tokens, Azure managed-identity tokens) are exposed when IMDS is reachable.
- **I:L** β Blob data (potentially large) can be written to internal HTTP endpoints that accept PUT/PATCH.
**CWEs:** CWE-918 (SSRF), CWE-350 (Reliance on DNS resolution for a security-critical action), CWE-367 (TOCTOU race condition).
## Proof of concept
A self-contained, runnable PoC is available at https://gist.github.com/JLLeitschuh/71a050dc66477485dc19a56edc21df80. It requires no external infrastructure and runs entirely on localhost.
```
go test -v -run TestSSRF ./poc/
```
### Test 1: bearer-realm credential theft (`TestSSRFBearerRealmCredentialTheft`)
The test starts a fake "internal service" (simulating cloud IMDS) on a random localhost port, then starts a malicious registry that returns a `WWW-Authenticate` bearer challenge whose `realm` parameter is `http://localhost:/token` β a DNS hostname, not an IP literal. `validateRealmURL` calls `net.ParseIP("localhost")`, receives `nil`, and skips the guard body entirely.
Actual output from running the test against v0.21.6:
```
=== RUN TestSSRFBearerRealmCredentialTheft
[MALICIOUS REGISTRY] GET /v2/
[INTERNAL SERVICE] reached: GET /token?scope=repository%3Avictim%2Fimage%3Apull&service=malicious-registry.example.com
[MALICIOUS REGISTRY] GET /v2/victim/image/manifests/latest
[MALICIOUS REGISTRY] received Authorization header: Bearer SIMULATED-AWS-SESSION-TOKEN:AKIA00000000EXAMPLE:wJalrXUtnFEMI+EXAMPLE
[INTERNAL SERVICE] reached: GET /token?scope=...
[MALICIOUS REGISTRY] received Authorization header: Bearer SIMULATED-AWS-SESSION-TOKEN:AKIA00000000EXAMPLE:wJalrXUtnFEMI+EXAMPLE
PASS: internal service contacted 2 time(s) β SSRF confirmed
PASS: malicious registry received leaked credential:
Bearer SIMULATED-AWS-SESSION-TOKEN:AKIA00000000EXAMPLE:wJalrXUtnFEMI+EXAMPLE
β In a real attack this contains the cloud IMDS credential payload.
--- PASS: TestSSRFBearerRealmCredentialTheft (0.01s)
```
The exfiltration chain is visible in the log sequence:
1. Client hits the malicious registry β receives `realm="http://localhost:/token"`.
2. `validateRealmURL` calls `net.ParseIP("localhost")` β `nil` β guard skipped.
3. Client GETs the internal service to exchange for a bearer token.
4. Internal service returns its credential payload as `{"token": "..."}`.
5. Client sends `Authorization: Bearer ` to the malicious registry on the next request.
6. Attacker's registry receives and logs the IMDS response body as the bearer credential.
### Test 2: blob redirect data exfiltration (`TestSSRFBlobRedirectDataExfiltration`)
The test has the malicious registry return `302 Location: http://localhost:/secret-data` in response to a blob fetch. `checkRedirectSSRF` calls `net.ParseIP("localhost")` β `nil` β guard skipped. The client follows the redirect to the internal service.
```
=== RUN TestSSRFBlobRedirectDataExfiltration
[MALICIOUS REGISTRY] GET /v2/
[MALICIOUS REGISTRY] GET /v2/victim/image/manifests/latest
[MALICIOUS REGISTRY] GET /v2/victim/image/blobs/sha256:e3b0c44298fc...
[MALICIOUS REGISTRY] redirecting blob to internal service: http://localhost:54647/secret-data
[INTERNAL SERVICE] reached: GET /secret-data
PASS: internal service contacted 1 time(s) via blob redirect β SSRF confirmed
--- PASS: TestSSRFBlobRedirectDataExfiltration (0.00s)
```
## Vulnerability details
### Guard locations and shared flaw
Four functions contain the identical structural pattern:
```go
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsPrivate() || ip.IsUnspecified() {
return fmt.Errorf("SSRF protection: ...")
}
}
```
`net.ParseIP` returns `nil` for any DNS hostname. When it does, the entire body of the `if` is skipped and the request proceeds without restriction.
| File | Function | Guards |
|---|---|---|
| [`pkg/v1/remote/transport/bearer.go:154`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/transport/bearer.go#L154-L156) | `validateRealmURL` | `WWW-Authenticate` realm URLs |
| [`pkg/v1/remote/fetcher.go:101`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/fetcher.go#L101-L103) | `checkRedirectSSRF` | HTTP redirect targets during blob/manifest downloads |
| [`pkg/v1/remote/fetcher.go:383`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/fetcher.go#L383-L385) | `validateForeignURL` | Non-distributable / foreign layer URLs |
| [`pkg/v1/remote/write.go:186`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/write.go#L186-L188) | `nextLocation` | `Location` headers during blob uploads |
The code itself documents the known limitation β [`bearer.go:151-152`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/transport/bearer.go#L151-L152) says _"DNS-based SSRF is out of scope here; callers should apply network-level controls if needed"_ and [`fetcher.go:366-367`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/fetcher.go#L366-L367) says _"DNS-based SSRF is out of scope, matching transport.validateRealmURL."_ Network-level controls are not a viable alternative (see below).
### 1. DNS hostname bypass (primary β no race required)
Any redirect destination or realm URL expressed as a DNS name bypasses all four guards. The attacker needs only a standard DNS `A` record pointing to the target internal address. The PoC above demonstrates the complete exploit chain without any infrastructure beyond `go test`.
### 2. TOCTOU / DNS rebinding (secondary β race required)
Even if DNS hostname checking were added to the four guard functions, the absence of a safe dialer leaves a time-of-check/time-of-use window. The guard runs when the HTTP response is processed (check time) and evaluates the DNS name. The actual TCP connection is established later by the dialer (use time) with a fresh OS-level DNS lookup. An attacker who controls the authoritative nameserver for the redirect destination and sets TTL=1 can change the DNS record between check and use. A safe dialer β one that resolves the name, validates the resolved IP, and connects to that exact IP without re-resolving β closes both this attack and the primary DNS-bypass attack simultaneously. No such dialer exists in this codebase.
### 3. IPv6 transition mechanism bypass of `IsPrivate()`
For redirect destinations expressed as IPv6 literals, `net.IP.IsPrivate()` returns `false` for addresses that encode private IPv4 destinations via documented IPv6 transition mechanisms:
| IPv6 literal | Mechanism | Embedded IPv4 | `IsPrivate()` |
|---|---|---|---|
| `2002:0a00:0001::` | 6to4 (RFC 3056) | `10.0.0.1` | `false` |
| `64:ff9b::0a00:0001` | NAT64 well-known (RFC 6052) | `10.0.0.1` | `false` |
| `64:ff9b::a9fe:a9fe` | NAT64 well-known (RFC 6052) | `169.254.169.254` | `false` |
| `fec0::1` | Deprecated site-local (RFC 3879) | β | `false` |
| `100.64.1.1` | CGNAT (RFC 6598) | β | `false` |
These are IP literals, so `net.ParseIP` does parse them. The check runs but `IsPrivate()` returns `false`, allowing the request to proceed. This is the same class of issue documented in [golang/go#79925](https://github.com/golang/go/issues/79925).
### 4. HTTP clients constructed without `CheckRedirect`
Several `http.Client` instances are created without any `CheckRedirect` policy, meaning the Go HTTP client will silently follow 302/301/307 redirects to any address including private IP literals:
| Location | Used for |
|---|---|
| [`write.go:86`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/write.go#L86) (`makeDeleteClient`) | `DELETE` requests for manifest deletion |
| [`write.go:110`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/write.go#L110) / [`write.go:152`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/write.go#L152) (`makeWriter`) | Blob existence `HEAD` checks; scope refresh |
| [`check.go:53`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/check.go#L53) (`CheckPushPermission`) | Upload initiation and cancellation |
| [`transport/ping.go:62`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/transport/ping.go#L62) (`pingSingle`) | Initial `GET /v2/` registry ping |
The [partial fix in v0.21.6](https://github.com/google/go-containerregistry/commit/e5983f2a67ec46b76984ce6de85de08a44eee955) covered [`makeFetcher`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/fetcher.go#L73-L81) (the fetcher client) and the manual [`nextLocation`](https://github.com/google/go-containerregistry/blob/6849394e8a65e2da1177d5b5b00e903551ada1c9/pkg/v1/remote/write.go#L184-L193) path in `makeWriter`, but not the automatic redirect-following on these clients.
## Why network-level controls do not close this gap
The existing code comments acknowledge the DNS bypass and direct callers to "apply network-level controls." This is not a viable mitigation:
1. **Cloud IMDS is local to the instance.** `169.254.169.254` is a link-local address on the VM's own network interface. Blocking it at a security group or VPC firewall requires blocking the instance from its own metadata service, which breaks AWS SDK credential refresh, GCP ADC, Azure managed identity, and other platform-level operations.
2. **Azure Wire Server must not be blocked.** `168.63.129.16` handles DHCP lease renewal, VM health reporting, and Key Vault certificate delivery. Blocking it breaks Azure VM functionality.
3. **DNS filtering does not prevent rebinding.** Attacker-controlled authoritative nameservers can return a public IP on the first query (cached/allowed by the DNS firewall) and switch to a private IP on the next query after TTL expiry.
4. **Blanket egress filtering breaks legitimate use.** go-containerregistry is widely used to talk to internal registries. Blocking all private-IP egress also blocks legitimate operations the library is designed to support.
5. **The burden is unreasonable.** go-containerregistry is used transitively by `crane`, `ko`, `skopeo`, GitHub Actions container steps, Tekton pipelines, and many other tools. Operators of those tools have no way to know the library requires per-workload iptables rules to be safe.
## Recommended remediation
The correct fix is a **safe dialer** that replaces the default `net.Dialer` in `DefaultTransport` and in all `http.Client` instances created within the package:
```go
func newSSRFSafeDialer() func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, a := range addrs {
if isBlockedIP(a.IP) {
return nil, fmt.Errorf("SSRF protection: %s resolves to blocked address %s", host, a.IP)
}
}
// Connect using the already-resolved address β no second DNS lookup.
return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
}
}
```
Where `isBlockedIP` checks `IsLoopback()`, `IsLinkLocalUnicast()`, `IsLinkLocalMulticast()`, `IsPrivate()`, `IsUnspecified()`, **and** the IPv6 transition ranges (`2002::/16`, `64:ff9b::/96`, `64:ff9b:1::/48`, Teredo `2001::/32`, ISATAP detection, CGNAT `100.64.0.0/10`). This dialer should be applied to `DefaultTransport` to protect the default path, and to every `http.Client` constructed inside `makeFetcher`, `makeWriter`, `makeDeleteClient`, `CheckPushPermission`, and `pingSingle`. It should be opt-out rather than opt-in β callers who need to reach private registries should pass an explicit allowlist via an `Option`, not accept an insecure default.
The existing four guard functions can remain as defense-in-depth but should not be the primary protection.
Reference implementation: [`code.dny.dev/ssrf`](https://pkg.go.dev/code.dny.dev/ssrf).
## References
- Partial fix commit: [`e5983f2a`](https://github.com/google/go-containerregistry/commit/e5983f2a67ec46b76984ce6de85de08a44eee955)
- Upstream Go stdlib issue: [golang/go#79925](https://github.com/golang/go/issues/79925) (`net.IP.IsPrivate` misuse and IPv6 transition gaps, authored by the reporter)
- [CVE-2024-24790 / GO-2024-2887](https://pkg.go.dev/vuln/GO-2024-2887) β Go stdlib `netip.Is*` methods misclassified IPv4-mapped IPv6 (same root cause class)
- [`code.dny.dev/ssrf`](https://pkg.go.dev/code.dny.dev/ssrf) β IANA Special-Purpose-Registry-driven safe dialer for Go
## Timeline
- **2026-06-11** β Initial disclosure to Google Security via GitHub Security Advisory and Google VRP (bughunters.google.com) - https://issuetracker.google.com/issues/522788246
- **2026-06-11** β Initial disclosure via private GitHub Security Advisory - https://github.com/google/go-containerregistry/security/advisories/GHSA-qfxf-rcm3-cc77
- **2026-06-11** β Fix for identified vulnerability provided by me - https://github.com/google/go-containerregistry-ghsa-qfxf-rcm3-cc77/pull/1
- **2026-06-26** β Vulnerability partially identified and publicly disclosed by @Politas180 in this issue https://github.com/google/go-containerregistry/issues/2352
- **2026-06-26** β I informed the Google VRP Program that partial disclosure had occurred - https://issuetracker.google.com/issues/522788246
- **2026-07-07** β I followed up on the Google VRP Programing informing Google that "failing to bear back by July 14, 2026, full disclosure will occur" - https://issuetracker.google.com/issues/522788246
- **2026-09-01** β Incomplete fix pull request for #2352 opened by @locker95 - https://github.com/google/go-containerregistry/pull/2432
- **2026-09-02** β #2352 merged by @Subserial which closed #2352
- **2026-09-02** β Full disclosure of this vulnerability as an 0-day due to unresponsiveness by Google, per the [Open Source Security Foundation Vulnerability Disclosure Policy: Version 0.1.0](https://openssf.org/about/vulnerability-disclosure-policy/)
Contributor guide
Research direction
Start by reading validateRealmURL in pkg/v1/remote/transport/bearer.go, the three URL checks in pkg/v1/remote/fetcher.go and write.go, and the listed clients in write.go, check.go, and transport/ping.go. Run the PoC with go test -v -run TestSSRF ./poc/ and use its DNS-hostname cases as regression coverage. Done means the affected request paths no longer permit the documented SSRF scenarios.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, networking, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100