Staying safe with Safe
Safe multi-sig wallet is one of the core items in the Ethereum ecosystem. It’s been evolving and it’s not what it was in 2017: it develops into a flexible and modular system from relatively simple signature verification.
Since we audited Safe version 1.4.0, we realized the importance of Safe inside the Ethereum ecosystem and decided to research it continuously. We collected several tips and risks for Safe users and developers building projects around it. Here is the list of separate tips and risks and several valuable resources for those who decide to get deeper.
Fallback Handler msg.sender
Using msg.sender is very common in smart contracts. We use it for access control as a mappings key or encode it with other parameters to connect the data to the specific address. However, some design patterns may work in a less straightforward way. One of them is Safe’s FallbackHandler contract.
To extend the functionality of the Safe, we have to deploy a new separate contract called Fallback Handler. Why? Because Safe is a battle-tested singletone contract, changing it may be not the best idea. Instead, we deploy our proxy pointing to the Safe contract and our handler, where we can extend the functionality of our Safe any way we want.
How does it work? At first, we add the FallbackHandler contract address into the Safe. Then, we call the Safe address with the function from FallbackHandler. Because the function is not implemented inside the Safe contract, the call falls into fallback, where the call is forwarded to the FallbackHandler.
During this call trace, msg.sender inside the FallbackHandler function will carry the address of Safe, not the original sender. It’s important to keep it in mind because anyone can call FalbackHandler on behalf of Safe. For example, the simple access control condition that allows only Safe to call the contract will be useless. If we want to use the original sender’s address, we use the function _msgSender() instead, which is implemented inside the contract HandlerContext.
function _msgSender() internal pure returns (address sender) {
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
}
As we can see, the function extracts a specific part of the call data, which carries the original sender’s address. The process of storing the address inside the call data can be seen in the logic of the fallback function inside the contract FallbackManager.
// The msg.sender address is shifted to the left by 12 bytes to
remove the padding
// Then the address without padding is stored right after the
calldata
let senderPtr := allocate(20)
mstore(senderPtr, shl(96, caller()))
// Add 20 bytes for the address appended add the end
let success := call(gas(), handler, 0, calldataPtr,
add(calldatasize(), 20), 0, 0)
Broken guard
Guard is a smart contract that implements specific data validation logic. It usually contains two main functions (Safe version 1.5.0 adds guard for module transaction):
- checkTxBeforeExecution
- checkTxAfterExecution
These functions work as hooks. Guard can implement any data validation that will be performed before and after the transaction execution. The following code snippet simplifies Safe’s execTransaction function:
function execTransaction(transaction data) {
// ...
// Transaction data pre-processing
address guard = getGuard();
if (guard != address(0)) {
Guard(guard).checkTransaction(transaction data);
}
// ...
// Transaction execution
if (guard != address(0)) {
Guard(guard).checkAfterExecution(txHash, success);
}
}
The function setGuard() has to be called for setting a guard. This function is implemented in the contract GuardManager, which Safe inherits from. The function is protected by the authorized modifier, which allows only Safe self-call to be made, i.e. Safe executes a transaction that calls Safe itself.
A problem may appear when no recovery mechanism is considered or implemented before setting a guard. If the guard is set, and its code contains a bug, which results in reverting transactions, the Safe is bricked. All the setting functions are called via the Safe self-call, which cannot be performed if it always reverts due to the broken guard code.
Mitigation for such a scenario can be Module. A module is a separate contract with the privilege of executing a Safe transaction via its function executeTxFromModule(). To set a module, the function enableModule() from the contract ModuleManager has to be called. If a module is set before the broken guard, the module transaction can call setGuard() with a new working guard address. However, Suppose a module is not set before, and the guard is broken. In that case, there is no way to add a new module because the function enableModule() is protected by the modifier authorized.
In the new version 1.5.0, the guard call is also performed in the module transaction. The mitigation of broken guards became more tricky and almost impossible.
Powerful modules
Module is a separate contract that can perform a transaction on behalf of Safe. The power of Module transactions lies in the fact that no further signatures from owners are needed. Modules can perform CALL but also DELEGATECALL to an arbitrary address via the function ExecTransactionFromModule().
If a logic on a called address contains state-changing functions, it will change the state of the Safe contract. In extreme scenarios, a contract with selfdestruct can be called. In a less extreme but no less dangerous scenario, a contract can be written into the storage slots of Safe. For example, It can overwrite important slots with owner addresses, module addresses, or threshold numbers. This leads to the simple advice: always correctly audit modules before adding them and ensure the owners of modules are trusted.
Trusted Deployer
One scenario is where Safe owners may not notice a module is connected to the Safe. Inside the Safe Setup function, the function setupModules() is called, which performs a DELEGATECALL.
function setupModules(address to, bytes memory data) internal {
require(modules[SENTINEL_MODULES] == address(0), "GS100");
modules[SENTINEL_MODULES] = SENTINEL_MODULES;
if (to != address(0)) {
require(isContract(to), "GS002");
// Setup has to complete successfully or transaction fails.
require(execute(to, 0, data, Enum.Operation.DelegateCall, type(uint256).max), "GS000");
}
}
When an untrusted third-party deployer of the Safe contract decides to perform any malicious operation, he can easily do it via this DELEGATECALL. Deployer has an unlimited ability to perform any Safe operation during this initial setup phase because owners do not sign the setup call. Deployer can, for example, change storage slots of the Safe, approve tokens, or set up the module, which itself has unlimited power over the Safe in the future. You can learn more about the problem in this OpenZeppelin post.
To avoid this scenario, Safe owners should double-check the setup of the Safe, linked modules, values in storage slots, and, ideally, check the call trace of the setup process.
tx.origin == msg.sender
This pattern is still used in many NFT projects to protect against bots for minting NFTs. However, the pattern is not compatible with Account Abstraction. Smart accounts will have the ability to create a transaction. It means the tx.origin will be a contract, not an EOA. 2024 may be a year of Account Abstraction, so we should avoid using this pattern not to slow down the adoption process.
Extracting signatures from calldata
Anyone with signatures can execute a transaction. Signatures of Safe owners for a specific transaction are crafted off-chain and passed into the function as input parameters. Once there are enough signatures to pass the threshold, the Safe transaction will be executed. Who is the one who calls the function? It does not matter.
All the data, including signatures, are readable from a calldata of transactions in the mempool. Thus there is no limitation if anyone reads the calldata and decides to execute the transaction. For extracting value (MEV), we manage the order of separate transactions with a goal of profit. As there is no access control (having signatures and transaction data is access control), we can pick the Safe transaction from the mempool and include it in our atomic transaction (the execution is performed in the smart contract). This way of extracting value is even more potent than the classic one, as the extractor has more control over the state.
How to mitigate this potential danger? Use OnlyOwnerGuard, which allows only owners to call the execution function.
Personal Safe
Use your own 1-of-1 multisig Safe wallet. It has many security benefits, even over cold wallets. You can:
- rotate your private keys
- create your own recovery mechanism
- add new owners later for extra security
- update verification mechanism
- stay future-proof
And most importantly, your address will be the same.
2-step threshold increase
Safe contract (OwnerManager, which Safe inherits from) contains the function addOwnerWithThreshold(). The function adds a new owner address and increases the threshold simultaneously. There is nothing wrong with it unless you make a small mistake.
Let’s say you have a personal 1-of-1 Safe.
After some time, you decide to add a second owner (for example, the secondary cold wallet address) and increase the security by upgrading the threshold to 2-of-2. The mentioned function is more effective as it can do both steps simultaneously. But what will happen when you mistakenly put the wrong address and increase the threshold? Safe will be bricked. Forever.
A straightforward mitigation and more error-proof way is to add the owner by calling the function with the same threshold. Then, increase the threshold by using the newly added owner address. In this flow, you can be sure no mistakes appeared.
Transaction scanning and simulation
We can find the simulate() function inside the Safe codebase (SimulateTxAccessor contract). The function simulates Safe transaction execution. From the simulated transaction, we can extract call-trace, contract state changes, emitted events, balance changes, used gas, etc. All this information can give us more confidence before the real transaction execution. The functionality is integrated into the Safe UX via Tenderly, where we can simulate the transaction in one simple click.
This feature has been taken even further with the introduction of DeFirewall by Redefine. This crypto “firewall” scans transactions before they are signed and checks for potential risks. As a simple scenario, imagine a hacker controlling the website of your favorite DeFi project. When a wallet pops up with transaction data to sign, all looks as usual.
Unfortunately, data are almost unreadable, and we are used to signing without checking. Here is the time when DeFirewall does the job. It simulates the transaction and by using the pop-up window, it highlights maliciously looking events or state changes. As an example:
- balance of assets after the transaction goes to zero
- address of the recipient is a well-known hacker address
Phishing in the blockchain world caused losses of hundreds of millions of dollars $ and spotting a well-made phishing activity can be challenging even for cyber security professionals.
Originally published at https://ackeeblockchain.com on October 9, 2023.