Web3 Security 101Part 4 of 12SmartAlert7 min read

Multisigs and Safes: owners, threshold, modules, delegatecall

How a Safe multisig works: owners, threshold, execTransaction, modules, guards and delegatecall. What it protects, what it does not, how to watch it.

*Part 4 of 12 in "Web3 Security 101", the last part of Block 1. In Part 2 we said the admin of a contract should not be one key. The usual answer is a multisig. Here is how the most common one works and where it can still fail.*

A multisig is a contract that holds assets and rights and acts only when enough people sign. In Part 2 we saw that an owner or admin is an address. If that address is a multisig, then stealing the key of one person is not enough. The most widely used multisig is Safe (formerly Gnosis Safe)1. The rest of this article is about Safe.

Owners and threshold

A Safe stores two things that matter for security2:

  • owners: a list of addresses, usually a mix of hardware wallets of different team members;
  • threshold: how many of them must sign. "3 of 5" means any three of five owners.

Safe exposes them as getOwners() and getThreshold(). Two rules of thumb follow from that:

  • The threshold must be high enough that stealing a couple of keys does nothing, and low enough that the team can still act when a person is on holiday.
  • Owner keys should be separate and held by separate people. A "3 of 5" where three keys sit on one laptop is "1 of 1".

Changes to this list go through the Safe itself, as a transaction signed by the current threshold. They emit AddedOwner(owner), RemovedOwner(owner) and ChangedThreshold(threshold). Anyone who gets enough signatures can add themselves, remove the others, or set the threshold to 1.

How a transaction runs

Owners sign a message off-chain, and then anyone sends it on-chain, by calling:

solidity
function execTransaction(
    address to, uint256 value, bytes calldata data,
    Enum.Operation operation,        // 0 = Call, 1 = DelegateCall
    uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
    address gasToken, address payable refundReceiver,
    bytes memory signatures
) public payable returns (bool success);

The Safe checks that the signatures cover the threshold, then performs the call. The person who sends execTransaction does not have to be an owner, only the signatures count. On success the Safe emits ExecutionSuccess(txHash, payment). If the inner call fails, the Safe may emit ExecutionFailure instead, depending on version and gas settings.

The operation field is the dangerous one.

delegatecall: the field that rewrites the Safe

With operation = 0 the Safe calls to as a normal call. With operation = 1 it runs the code at to in the Safe's own context with a delegatecall (Part 3). That code can write to the Safe's storage and move its funds, and it is not limited to what the owners thought they were approving.

A Safe is itself a proxy: a small contract that forwards calls to a singleton implementation, whose address sits in the first storage slot3. A delegatecall to a malicious contract can overwrite that slot. After that, the Safe at the same address runs the attacker's code, with all its assets and rights over other contracts. No AddedOwner, no ChangedThreshold, no event that says "the code changed".

That is what happened at Bybit on February 21, 2025. Signers approved a transaction that looked routine. It was a delegatecall that swapped the Safe's logic, and about $1.46B left4. The transaction was sent by an executor address that was not one of the three seen before on this Safe. Our earlier Ronin write-up mentions the timing: the loss came two and a half minutes after the first sign.

Modules and guards

Two optional extensions are worth knowing:

  • Modules are contracts that a Safe has enabled. A module can make the Safe execute transactions without owner signatures, through execTransactionFromModule. This is used for allowances, recurring payments and recovery. Enabling a module emits EnabledModule(module), and from that moment the module's rules replace the threshold for the actions it can do5.
  • A guard is a contract the Safe calls before and after every transaction, for example to forbid delegatecall or to cap transfers. It is set with setGuard and emits ChangedGuard(guard)6. A bad guard can also block every transaction, so it is a risk of its own.

A module is a back door by design. It is fine when you added it knowingly. When a module appears that nobody remembers adding, treat it like a new owner.

What a Safe does not protect

  • What the signers see is not always what they sign. At Bybit, the interface showed one thing and the transaction did another. A hardware wallet shows a hash and a few fields, so it can be hard to spot a delegatecall.
  • Compromised signers. Enough stolen keys, or enough tricked people, and the threshold is met.
  • Loss of the Safe's own structure. An attacker who replaces the logic or enables a module does not need your signatures any more.

After a theft in a Safe, you often have no lever left: the code that could pause or freeze may already be gone. That is why the early signals count.

How to catch it

What SmartAlert does today for a Safe7. When you add a Safe, it is detected by its events (AddedOwner, RemovedOwner, ChangedThreshold, ExecutionSuccess), and the matching templates are offered.

  1. Owners and threshold. safe-owners-changed (added or removed owners) and safe-threshold-changed, both critical. Recipe: Alert when Safe owners or threshold change.
  2. Modules and guards. safe-module-enabled fires on EnabledModule and ChangedGuard, whichever of the two your Safe's ABI has.
  3. Failed transactions. The safe-execution-failed template needs ExecutionFailure in the ABI. Recipe: Alert when a Safe transaction fails. Safe 1.3 and newer with safeTxGas = 0 reverts the whole execTransaction when the inner call fails, with error GS013, and emits no ExecutionFailure. The template does not see this. Instead, create the "share of failed transactions" rule with a minimum of 1 transaction and a threshold above 0 in a 15-minute window: any failed direct execTransaction fires it. At WazirX in July 2024, a failed execTransaction came about 7 minutes before the Safe's code was replaced, and then about $235M left.
  4. An executor you do not know. An "allowed senders" rule on ExecutionSuccess with the addresses of your owners and relayers. A transaction from any other address fires an alert. At Bybit the new executor was visible in the same block as the outflow. The Safe's own address cannot be in the list, the rule rejects it. A compromised owner who sends from the list will not trigger this rule.

What we cannot do today, honestly:

  • No ready rule on operation = 1. On a SafeL2 you can build one yourself: a rule on the SafeMultiSigTransaction event filtered by operation equal to 1. A Safe on Ethereum mainnet, like Bybit's, does not emit that field, so there the first signal is the changed implementation (masterCopy()), which comes too late.
  • No view check on the singleton. The Safe's masterCopy() is not in its ABI, so a contract-state rule cannot select it, and the product does not read the storage slot directly. The silent swap at Bybit would leave no event and no state rule to catch it.
  • DisabledModule has no template. Only enabling is covered.
  • Timing. On Ethereum the alert arrives 2.5 to 4 minutes after the block. If signing, sending and draining fit into that, the alert comes with the loss.

This ends Block 1. You now have the pieces: a contract's state and events, the doors, the code behind the address, and the Safe in front of it all.

Next, Part 5: Admin key compromise: IoTeX, PlayDapp, Zoth.

Sources

  1. 1.Safe documentation, What is Safe. ↩
  2. 2.Safe contracts, OwnerManager.sol and Safe.sol. ↩
  3. 3.Safe documentation, safe-smart-account repository (proxy and singleton). ↩
  4. 4.CertiK, Bybit incident technical analysis. ↩
  5. 5.Safe documentation, Modules. ↩
  6. 6.Safe documentation, Guards. ↩
  7. 7.SmartAlert, Safe templates and the failed-transaction and allowed-sender rules; WazirX timeline from the Rekt report. ↩

Try it free

An address is enough. The first 14 days get Pro limits, and rules are checked on history before they go live.