Cover photo

Gnosis Safe Internals — Part 1 — SafeProxy

The recent Bybit hack was a wake-up call, reminding everyone that security is never guaranteed and good practices are essential. A hardware wallet is crucial for protecting private keys from malware, but it’s not enough if we blindly sign whatever data our computer presents.

This blog series explores the Gnosis Safe wallet and how it works, giving readers the chance to use it directly on-chain without relying on a front-end.

If you want to follow along and experiment, I highly recommend installing Foundry. It provides powerful commands like cast and chisel, which we’ll be using extensively.

Installation is straightforward, but refer to the official manual for full details:

curl -L https://foundry.paradigm.xyz | bash

We’ll also be using the Gnosis Chain. You can set up a public RPC by exporting an environment variable:

export ETH_RPC_URL=https://rpc.gnosischain.com

The first key concept is that a Gnosis Safe wallet is simply a proxy contract. It’s a minimal contract used only for storing data while borrowing code from other smart contracts. This design enables upgrades and enhances modularity.

There are several design patterns in Solidity for proxy contracts such as the Transparent Proxy Pattern, Proxy Upgrade Pattern, EIP-1967 for storage collision, or the Diamond Pattern.

The approach used by Gnosis Safe can be observed from the SafeProxy.sol contract: https://github.com/safe-global/safe-smart-account/blob/main/contracts/proxies/SafeProxy.sol

The first storage slot stores the address of a singleton contract, which handles the execution of code.

// Singleton always needs to be first declared variable, to ensure that it is at the same location in the contracts to which calls are delegated.
// To reduce deployment costs this variable is internal and needs to be retrieved via `getStorageAt`
address internal singleton;

Let’s investigate this Safe wallet deployed on Gnosis: 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D.

The singleton contract’s address is stored in an internal variable called singleton. Fortunately, the cast command can retrieve this storage for us.

$ cast storage 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D 0
0x00000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762

In the EVM, storage slots are 256 bits (32 bytes), while addresses are only 160 bits (20 bytes). To extract the address, we simply take the last 160 bits, which gives us 0x29fcb43b46531bca003ddc8fcb67ffe91900c762 as the singleton’s address. This is indeed a SafeL2 contract.

Gnosis Safe implementation has two contracts: Safe.sol and SafeL2.sol. The SafeL2 contract is identical to the Safe contract but also emits events on transaction execution. Since L2s are generally cheaper, the extra gas cost is acceptable. In this article, we’ll focus on the Safe contract.

We know that the address of a Gnosis Safe wallet corresponds to the proxy contract’s address. We also know how to retrieve the address of the implementation contract, which contains the code to be executed. But how does the proxy actually execute the code?

This is achieved through the fallback() function of the proxy contract, combined with the use of delegate calls.

In EVM smart contracts, a fallback() function is a special function that is triggered when a contract receives a transaction or call that doesn’t match any of its defined functions.

For example, if we attempt to call the transfer() function from an ERC-20 contract, but our contract doesn’t implement it, the fallback() function will be triggered. Assuming we have a fallback() function defined, of course.

If we try to call the approve() function and it’s also not implemented in our contract, the fallback() function will be triggered once again. It acts as a catch-all for any unmatched function calls.

The second concept to understand is a delegatecall(). It s a low-level function that allows a contract to execute code in the context of another contract. It typically borrows code from another smart contract, but all storage modifications are made in the calling contract: the proxy contract.

We have the two essentials components of a proxy smart contract. A catch all fallback() function and a way to borrow code from another smart contract.

From SafeProxy.sol on line 32:

/// @dev Fallback function forwards all transactions and returns all received return data.
fallback() external payable {
    // Note that this assembly block is **intentionally** not marked as memory-safe. First of all, it isn't memory
    // safe to begin with, and turning this into memory-safe assembly would just make it less gas efficient.
    // Additionally, we noticed that converting this to memory-safe assembly had no affect on optimizations of other
    // contracts (as it always gets compiled alone in its own compilation unit anyway).
    /* solhint-disable no-inline-assembly */
    assembly {
        let _singleton := sload(0)
        // 0xa619486e == bytes4(keccak256("masterCopy()")). The value is right padded to 32-bytes with 0s
        if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
            // We mask the singleton address when handling the `masterCopy()` call to ensure that it is correctly
            // ABI-encoded. We do this by shifting the address left by 96 bits (or 12 bytes) and then storing it in
            // memory with a 12 byte offset from where the return data starts. Note that we **intentionally** only
            // do this for the `masterCopy()` call, since the EVM `DELEGATECALL` opcode ignores the most-significant
            // 12 bytes from the address, so we do not need to make sure the top bytes are cleared when proxying
            // calls to the `singleton`. This saves us a tiny amount of gas per proxied call.
            mstore(0x0c, shl(96, _singleton))
            return(0, 0x20)
        }
        calldatacopy(0, 0, calldatasize())
        let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        if iszero(success) {
            revert(0, returndatasize())
        }
        return(0, returndatasize())
    }
    /* solhint-enable no-inline-assembly */
}

This code is written in YUL, an EVM assembly language. It’s a bit harder to read than plain Solidity, but we’ll break it down and explain it line by line.

  1. First, on line 40, the code loads storage slot 0, where the address of the singleton is stored. We will skip the optimisation.

  2. On line 52, it copies the calldata from the transaction into memory slot 0.

  3. Then, on line 53, it calls the contract pointed to by the singleton address, using the copied calldata in memory slot 0. It’s a delegate call, so any storage changes occur in this contract.

  4. On line 54, the returned data from the delegatecall() is copied back into memory slot 0.

  5. Finally, on line 56, the transaction is reverted if the call didn’t succeed.

Understand this, the contract address stored in the singleton variable on storage slot 0 is crucial. It typically stores the address of the latest implementation from Gnosis Safe, and can be updated to a newer version.

However, if it’s changed to a malicious contract, anything could happen to the safe wallet, including the loss of all assets protected by the smart contract. Unfortunately, this is exactly what the Bybit team signed. A harmful update to the singleton variable, pointing it to a malicious drainer contract.

That’s all for today! In the next article, we’ll explore how Safe wallets manage multiple owners and how we can retrieve the owner list directly on-chain.