3-of-5. 4-of-7. 7-of-10. everyone checks the threshold. due diligence done, right?
the threshold is one of six things that determine whether a Gnosis Safe actually protects funds. most people only check the first one.
the threshold sets the minimum number of owner signatures required to execute a transaction through execTransaction(). if threshold is 3 and you have 2 signatures, the call reverts. simple.
# check threshold and owners
cast call <SAFE> "getThreshold()(uint256)"
cast call <SAFE> "getOwners()(address[])"
this is the part everyone understands. now here's everything else.
this is the biggest blind spot in multisig security.
Safe modules are contracts authorized to call execTransactionFromModule(). a module can execute any transaction from the safe without a single owner signature. the threshold is irrelevant. the module has its own authority.
# if this returns anything other than an empty array, investigate
cast call <SAFE> "getModulesPaginated(address,uint256)(address[],address)" \
0x0000000000000000000000000000000000000001 10
modules are legitimate โ timelocks, recovery mechanisms, automation. but a malicious or compromised module is a full bypass of every threshold. your 7-of-10 means nothing if a module can move funds independently.
a guard contract implements checkTransaction() and checkAfterExecution(). it adds validation on top of the threshold โ restricting destinations, limiting values, blocking certain operations.
the guard address lives at a specific storage slot. if it's 0x00, there's no guard. no additional checks beyond threshold + signatures.
# guard storage slot (keccak256("guard_manager.guard.address"))
cast storage <SAFE> \
0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8
# 0x000...000 = no guard installed
a guard can enforce things like "no transfers above X without delay" or "no delegatecalls ever." without one, the threshold alone decides what's valid. it usually isn't enough.
the fallback handler processes any call the Safe doesn't recognize โ EIP-1271 signature validation, token callbacks (onERC721Received, onERC1155Received), anything routed through the fallback() function.
# fallback handler slot (keccak256("fallback_manager.handler.address"))
cast storage <SAFE> \
0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5
a compromised fallback handler can intercept signature validation, manipulate token reception logic, or introduce re-entrancy vectors. it runs in the context of calls to the safe, not calls from it โ a different attack surface than modules, but equally dangerous.
this is how bybit lost $1.46B.
execTransaction takes an operation parameter. 0 is a regular call. 1 is a delegatecall โ the target contract's code executes in the safe's storage context. the target can rewrite ownership, change the threshold, drain every token. all in one transaction.
the signers approved what appeared to be a routine transfer. the transaction payload contained operation: 1. the target contract ran DELEGATECALL in the safe's context, replacing the implementation with an attacker-controlled contract. the threshold was met. every signature was valid. it just didn't matter โ the signers didn't understand what they were signing.
# execTransaction signature โ note the uint8 (operation) parameter
# execTransaction(address to, uint256 value, bytes data, uint8 operation,
# uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
# address gasToken, address refundReceiver, bytes signatures)
#
# operation: 0 = Call, 1 = DelegateCall
# if your guard doesn't block operation=1, any threshold-meeting
# set of signers can delegatecall arbitrary code
without a guard that explicitly blocks operation=1, a threshold-meeting quorum of signers โ whether compromised, socially engineered, or just not reading the payload โ can execute a delegatecall that rewrites the entire safe.
not on-chain, but worth more than everything above.
ronin bridge ran 5-of-9. four keys belonged to sky mavis. a fifth was an allowlisted key from a gas-free rpc arrangement in november 2021 โ never revoked. one compromised developer machine yielded 5 of 9 signatures. the threshold was fine. the independence was not.
you can't verify this with cast. but you can check whether owner addresses share a common funding source, were deployed by the same address, or interact with the same contracts. same deployer funding multiple "independent" signers is a signal.
# check if owners share a funding origin
cast call <SAFE> "getOwners()(address[])"
# for each owner, trace the first inbound ETH transfer
# common source โ likely same organization
five things to verify beyond the threshold. i run these on every safe i audit โ usually takes about as long as waiting for my coffee on hawthorne.
| # | check | command | red flag |
|---|---|---|---|
| 1 | modules | getModulesPaginated() | any unknown contract |
| 2 | guard | storage slot read | 0x00 = no guard |
| 3 | fallback | storage slot read | unverified contract |
| 4 | delegatecall | guard config / tx history | no delegatecall restriction |
| 5 | independence | owner funding trace | common deployer/funder |
the full check in one shot:
SAFE="<SAFE_ADDRESS>"
# threshold + owners
cast call $SAFE "getThreshold()(uint256)"
cast call $SAFE "getOwners()(address[])"
# modules (bypass threshold entirely)
cast call $SAFE "getModulesPaginated(address,uint256)(address[],address)" \
0x0000000000000000000000000000000000000001 10
# guard (additional validation layer)
cast storage $SAFE \
0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8
# fallback handler (processes unknown calls)
cast storage $SAFE \
0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5
the threshold is the door lock. modules are the window you forgot to close. the guard is the deadbolt you never installed. the fallback handler is the mail slot big enough to reach through. and delegatecall is the landlord's master key โ works even when every lock is engaged.
check all five. or don't, and hope nobody else does either.
typed out during a mass transit delay that lasted longer than most timelocks. โ 0xrivet