*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:
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 emitsEnabledModule(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
setGuardand emitsChangedGuard(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.
- Owners and threshold.
safe-owners-changed(added or removed owners) andsafe-threshold-changed, both critical. Recipe: Alert when Safe owners or threshold change. - Modules and guards.
safe-module-enabledfires onEnabledModuleandChangedGuard, whichever of the two your Safe's ABI has. - Failed transactions. The
safe-execution-failedtemplate needsExecutionFailurein the ABI. Recipe: Alert when a Safe transaction fails. Safe 1.3 and newer withsafeTxGas = 0reverts the wholeexecTransactionwhen the inner call fails, with errorGS013, and emits noExecutionFailure. 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 directexecTransactionfires it. At WazirX in July 2024, a failedexecTransactioncame about 7 minutes before the Safe's code was replaced, and then about $235M left. - An executor you do not know. An "allowed senders" rule on
ExecutionSuccesswith 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 theSafeMultiSigTransactionevent filtered byoperationequal 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. DisabledModulehas 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.Safe documentation, What is Safe. ↩
- 2.Safe contracts,
OwnerManager.solandSafe.sol. ↩ - 3.Safe documentation, safe-smart-account repository (proxy and singleton). ↩
- 4.CertiK, Bybit incident technical analysis. ↩
- 5.Safe documentation, Modules. ↩
- 6.Safe documentation, Guards. ↩
- 7.SmartAlert, Safe templates and the failed-transaction and allowed-sender rules; WazirX timeline from the Rekt report. ↩