✅Gnosis Unsafe
Codebase walkthrough
Safe.sol is a gnosis-safe-like contract. It works like a multi-sig wallet, initially there are three owners:
0x1337
0xdead
0xdeadbeef
and the safe holds 10000e18 grey token. The goal is to steal all the funds from the safe. And you know that no one knows the private key to these 3 addresses, so there must be a bug in the contract itself.
To make a withdraw tx, you first propose a tx by aclling queueTransaction()
. Your tx struct data and ECDSA signature v,r,s will be stored in a mapping called queueHashToTimestamp
. To actually execute the tx, you then call executeTransaction()
. This function checks if you provide at least 3 signatures, then verifies signatures with ecrecover
. In the end it transfers asset to an address (_to
) specified in your tx struct:
There is also a function named vetoTransaction()
but it is not used anywhere. Therefore let’s just ignore it.
Bug description
The ecrecover bug
The bug is in executeTransaction()
, signature verification logic specifically. This is a well-known bug of ecrecover
:
The if check here isn’t sufficient, you must also check transaction.signer ≠ address(0)
, otherwise attacker can set transaction.signer = address(0)
, which corresponds to the case when ecrecover
fails.
This bug is very easy to spot, but there is a very interesting question that I researched in the past: why does ecrecover
return 0 upon failure? Here is how Claude 3.5 answers this question: https://poe.com/s/f7V2ErI3Yoz2n3gvhL6Q
The ABI-reencoding bug
There is one more subtle technical bug in this code:
Before we execute the tx, transaction.signer
must be set to address(0). But that will influence the abi.encode() computation, so queueHash
will be a brand new hash and queueHashToTimestamp[queueHash]
will be 0.
It turns out there is an ABI-reencoding bug in Solidity <0.8.16:
TL;DR: If the struct contains a dynamic type such as string
or bytes
, the second packing (abi.encode family) will treat the first entry of this struct as 0. The root cause is aggresive array cleanup.
In the context of this chall, the first packing happens when we send the tx struct as a function argument:
The flow goes into queueTransaction()
and triggers the second packing: abi.encode()
Because of the bug, transaction.signer
is set to 0 when computing the encoding, therefore queueHash
is the same as in solvePart2, even though we modified transaction.signer
here:
PoC
In part 1 we propose a withdraw tx by calling queueTransaction()
, signer is set to one of the owners, everything is legit. Wait 1 minute to for this block to finalize on chain, then in a future block we modify signer to be address(0) and send modified tx struct to executeTransaction()
.
Last updated