-
Notifications
You must be signed in to change notification settings - Fork 134
Gnosis Findings
Gnosis Safe is a smart contract which provides multisig authentication for accessing other contracts on the blockchain. It revolves around transactions, which wrap calls to external contracts and require the signature of multiple owners in order to be executed. Multiple signature validation schemes are supported, including ECSDA, a contract-builtin approval scheme, and EIP-1271. At the end of successful transactions, transaction initiator is refunded for the gas cost.
In this report we performed an extensive security audit of the contract. We found two security vulnerabilities. One of them is an instance of well-known re-entrancy attack. The other is an issue specific to GnosisSafe, with potential to be abused. Developers accepted both our issues.
In this section we analyzed for security issues the core GnosisSafe contract. The version audited is commit 14495428954366dcf812acfa11e54c81b186332d , compiled with Solidity v0.5.0. We only analyzed the core contract, without extensions, e.g. Proxy.sol acting as proxy for GnosisSafe.sol .
We used as reference an extensive list. of common security vulnerabilities and bugs in EVM and Solidity. In addition, we inspected the code for vulnerabilities not present in the list, with focus on external contract calls.
Next we present the list of discovered security issues, followed by our assessment of vulnerabilities.
The scope of the current engagement is the GnosisSafe contract without enabling any add-on modules. Specifically, this includes the following functions:
-
executeTransaction
ofGnosisSafe.sol
:- only for the case of
operation == CALL
and payment in Ether. - including
encodeTransactionData
,checkSignatures
, andhandlePayment
functions.
- only for the case of
-
changeMasterCopy
ofMasterCopy.sol
-
addOwner
,removeOwner
, andswapOwner
ofOwnerManager.sol
-
enableModule
, anddisableModule
ofModuleManager.sol
-
execTransactionFromModule
ofModuleManager.sol
- only for the case that
modules
is empty.
- only for the case that
The security assessment is limited in scope within the boundary of the Solidity contract only.
This report does not constitute legal or investment advice. The preparers of this report present it as an informational exercise documenting the due diligence involved in the secure development of the target contract only, and make no material claims or guarantees concerning the contract’s operation post-deployment. The preparers of this report assume no liability for any and all potential consequences of the deployment or use of this contract.
Smart contracts are still a nascent software arena, and their deployment and public offering carries substantial risk. This report makes no claims that its analysis is fully comprehensive, and recommends always seeking multiple opinions and audits.
The possibility of human error in the manual review process is very real, and we recommend seeking multiple independent opinions on any claims which impact a large number of funds.
To protect from reentrancy attacks, GnosisSafe uses storage field nonce
,
which is incremented during each transaction.
However, there are 3 external calls performed during a transaction,
which all have to be guarded from reentrancy.
Below is the code for execTransacion
, the main function of GnosisSafe:
function execTransaction(
address to,
uint256 value,
bytes calldata data,
Enum.Operation operation,
uint256 safeTxGas,
uint256 dataGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver,
bytes calldata signatures
)
external
returns (bool success)
{
uint256 startGas = gasleft();
bytes memory txHashData = encodeTransactionData(
to, value, data, operation, // Transaction info
safeTxGas, dataGas, gasPrice, gasToken, refundReceiver, // Payment info
nonce
);
require(checkSignatures(keccak256(txHashData), txHashData, signatures, true),
"Invalid signatures provided");
// Increase nonce and execute transaction.
nonce++;
require(gasleft() >= safeTxGas, "Not enough gas to execute safe transaction");
// If no safeTxGas has been set and the gasPrice is
// 0 we assume that all available gas can be used
success = execute(to, value, data, operation,
safeTxGas == 0 && gasPrice == 0 ? gasleft() : safeTxGas);
if (!success) {
emit ExecutionFailed(keccak256(txHashData));
}
// We transfer the calculated tx costs to the tx.origin
// to avoid sending it to intermediate contracts that have made calls
if (gasPrice > 0) {
handlePayment(startGas, dataGas, gasPrice, gasToken, refundReceiver);
}
}
The main external call managed by this transaction (hereafter referred as "payload") is performed in function
execute
.
After payload is executed, the original caller or another account specified in transaction data is refunded for gas cost in
handlePayment
.
Both these calls are performed after the nonce
is incremented.
Consequently, it is not possible to execute the same transaction multiple times
from within these calls.
However, there is one more external call possible inside
checkSignatures
phase, which calls an external contract
managed by an owner to validate the signature using
EIP-1271 signature validation mechanism:
function checkSignatures(bytes32 dataHash, bytes memory data,
bytes memory signatures, bool consumeHash)
public
returns (bool)
{
for (i = 0; i < threshold; i++) {
(v, r, s) = signatureSplit(signatures, i);
// If v is 0 then it is a contract signature
if (v == 0) {
// When handling contract signatures the address of the contract
// is encoded into r
currentOwner = address(uint256(r));
bytes memory contractSignature;
// solium-disable-next-line security/no-inline-assembly
assembly {
// The signature data for contract signatures is appended to the
// concatenated signatures and the offset is stored in s
contractSignature := add(add(signatures, s), 0x20)
}
if (!ISignatureValidator(currentOwner)
.isValidSignature(data, contractSignature)) {
return false;
}
} else
...
}
if (currentOwner <= lastOwner || owners[currentOwner] == address(0)) {
return false;
}
lastOwner = currentOwner;
}
return true;
}
This call is performed BEFORE nonce is incremented here, thus is not guarded for reentrancy.
An owner using EIP-1271 signature validation may use this vulnerability to run the same payload multiple times, although it was approved by other owners to run only once. The limit of how many times a transaction can run recursively is given by call gas and block gas limit, thus the malicious owner will call this transaction with a lot of gas allocated. The most likely beneficiary of this attack is the owner who initiated the transaction. Yet if a benign owner calls another malicious contract for the signature validation, the malicious contract can exploit it even if he is not an owner.
- Suppose we have a Gnosis safe managed by several owners, which controls access to an account that holds ERC20 tokens. At some point they agree to transfer X tokens from the safe to the personal account of owner 1.
Conditions required for this attack to be possible: (a). Owner 1 is a contract that uses EIP-1271 signature validation mechanism. (b). All other owners use either EIP-1271 or ECSDA signatures. (See this page for the 3 types of signature validation.)
-
Owner 1 generates the transaction data for this transfer and ensures that allocated gas is 10x required amount to complete the transaction.
-
Owner 1 requests signatures for this transaction from the other owners.
-
Owner 1 registers a malicious
ISignatureValidator
contract into his own account, that once invoked, will call the Gnosis Safe with the same call data as long as there is enough gas, then return true. -
Owner 1 generates a signature for the transaction, of type EIP-1271, e.g. it will call the
ISignatureValidator
. -
Owner 1 calls the Gnosis Safe with the transaction data and all the signatures.
-
During signature verification phase, Gnosis Safe invokes the malicious ISignatureValidator, that successfully calls the safe again with the same data, recursively, 9 more times.
-
In the end, Owner 1 receives into his account 10X the amount of tokens approved by the other owners.
Increment nonce
before calling checkSignatures
.
The account that initiated the transaction can consume large amounts of gas for free, unnoticed by other owners, and possibly receive a refund larger than the amount of gas consumed. This vulnerability is specific to GnosisSafe; it is not listed in our reference list of known vulnerabilities.
The attack is possible due to a combination of factors.
First, GnosisSafe emits a refund at the end of transaction, for the amount of gas consumed.
The target of the refund is either transaction initiator tx.origin
(by default) or some other account
given by transaction parameter refundReceiver
.
This currency of the refund may be either Ether by default, or some ERC20 token with a specified price per unit.
Refund token is given by transaction parameters gasPrice
, gasToken
.
All those transaction parameters must be signed by the required amount of owners, just like the payload.
The second factor is that gas allocated for the whole execTransaction
is not part of transaction data.
(Yet gas for individual external calls is, as we show below.)
This refund mechanism may in principle be abused, if the transaction initiator can spend a large amount of gas without the knowledge of other owners. The original owner may receive a benefit from such an abuse in case (1) refund is emitted in token, and (2) the gas price in token is much higher than market price of Ether in that token. The latter is plausible, for example because: (1) the gas price is outdated, (2) market price changed a lot since gas price was initially set, and (3) owners did not care to adjust the gas price because gas consumption was always small and thus irrelevant.
We again have to analyze the situation on all 3 external call sites.
For the payload external call, gas is limited by transaction parameter safeTxGas
.
This parameter must be set and validated by other owners when token refund is used, thus abuse is not possible.
For the external call that sends the refund in token, gas is limited to remaining gas for transaction minus 10000
source:
let success := call(sub(gas, 10000), token, 0, add(data, 0x20), mload(data), 0, 0)
This looks like a poor limit, but in order to be abused, the transaction initiator must have control over token account, which looks like an unlikely scenario.
The biggest concern is again in the call to ISingatureValidator
. This call is under the control of transaction initiator,
and the gas for it is not limited (see code for checkSignatures
).
Thus, the attacking owner may use a malicious ISignatureValidator
that consumes almost all allocated gas, in order to receive
a large refund. The amount of benefit the attacker my receive is limited by (1) block gas limit and (2) ratio between gasPrice
and market cost of the token. However, we should allow for the possibility that block gas limit will increase in future.
Thus this remains a valid vulnerability.
Considering the specific functionality of ISignatureValidator
, we recommend limiting the gas when calling ISignatureValidator
to a small predetermined value. Careful gas limits on external contract calls are a common security practice. For example when tokens are sent in Solidity through msg.sender.send(ethAmt)
, gas is automatically limited to 2300
(source).
execTransaction
does not reject the case of to
being the zero address 0x0
, which may lead to an internal transaction to the zero address, via the following function call sequence:
- https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/GnosisSafe.sol#L95
- https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/base/Executor.sol#L17
- https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/base/Executor.sol#L33
Unlike a regular transaction to the zero address, which creates a new account, an internal transaction to the zero address behaves the same as other transactions to non-zero addresses, i.e., sending the ether to the zero address account (which indeed exists: https://etherscan.io/address/0x0000000000000000000000000000000000000000) and executing the code associated to it (which is empty in this case).
Although it is the users' responsibility that ensures correctness of the transaction data, it is quite possible that a certain user may not be aware of the difference between the regular and internal transactions to the zero address, sending a transaction data to execTransaction
with to == 0x0
, expecting that it creates a new account. Since an internal transaction to the zero address mostly succeeds (note that it spends a small amount of gas, without needing to pay the G_newaccount
(25,000) fee since the zero-address account already exists), it may cause the ether stuck at 0x0, which could be serious when the user attaches a large amount of ether as a startup fund for the new account.
Modify execTransaction
to revert when to == address(0)
.
execTransaction
misses the contract existence check for the user transaction target, which may cause the loss of ether.
According to the Solidity document:
The low-level functions
call
,delegatecall
andstaticcall
returntrue
as their first return value if the called account is non-existent, as part of the design of EVM. Existence must be checked prior to calling if desired.
That is, if a client makes a mistake of providing a non-existing target address when preparing a user transaction, then the execute
function will silently return true with transferring the paid ether to the non-existing account, resulting in the loss of the ether.
However, it is not trivial to check the existence for a non-contract account.
In the short term, add a check for a contract account, e.g., requiring extcodesize(to) > 0
when data
is not empty and operation = Call
.
In the long term, differentiate the two types of user transactions, i.e., the external contract call transaction and the simple ether transfer transaction, and implement the contract existence check for the external contract call transaction. Regarding the ether transfer transaction, explicitly mention this limitation in the document of execTransaction
, and/or implement a certain conservative existence check at the client side to give a warning message if the given address seems to refer to a non-existing account.
changeMasterCopy
misses the contract account existence check for the new master copy address.
If the master copy is set to a non-contract account, then the Proxy fall-back function will silently return.
Implement the existence check, e.g., extcodesize(_masterCopy) > 0
.
There are several places where SafeMath is not used for the arithmetic operations.
-
https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/GnosisSafe.sol#L92
-
https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/GnosisSafe.sol#L139
-
https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/base/OwnerManager.sol#L62
-
https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/base/OwnerManager.sol#L79
-
https://github.com/gnosis/safe-contracts/blob/v0.1.0/contracts/base/OwnerManager.sol#L85
The following contract invariants are needed to rule out the possibility of overflow:
-
nonce
is small enough to avoid overflow innonce++
. -
threshold
is small enough to avoid overflow inthreshold * 65
. -
ownerCount >= 1
is small enough to avoid overflow inownerCount++
,ownerCount - 1
, andownerCount--
.
In the current GnosisSafe contract, it is practically reasonable to assume the above invariants, considering the resource limitation (such as gas), but this assessment should be repeated whenever the contract is updated.
Use SafeMath for all arithmetic operations.
The addOwnerWithThreshold
function allows to update threshold
, for which a race condition exists similarly to the ERC20 approve race condition.
A common usage scenario of addOwnerWithThreshold
is to add a new owner with increasing the threshold value (or at least keeping the value as is). It is very unlikely the case of decreasing the threshold value while adding a new owner. If there still exists such a use case, one can split the task into two transactions: adding a new owner, and decreasing threshold
, since there is little reason to perform two updates atomically.
The removeOwner
function also has the similar issue.
Suppose there are five owners with threshold = 3
. Suppose Alice proposes a transaction of addOwnerWithThreshold(o1,4)
, and immediately after that, Bob proposes a transaction of addOwnerWithThreshold(o2,5)
. If Bob's transaction is somehow approved before Alice's transaction, the final threshold
value will be 4, while it should be 5.
- Modify
addOwnerWithThreshold
to prevent from decreasingthreshold
. - Modify
removeOwner
to prevent from increasingthreshold
. - Make
changeThreshold
private, and add the safer alternatives, i.e.,increaseThreshold
anddecreaseThreshold
.
The signatureSplit
function does not check the index is within the bound of the signatures
sequence.
Although no out-of-bounds index is passed to the function in the current GnosisSafe contract, it is possible for a future implementation to make a mistake, passing an out-of-bound index.
Add the index bounds check, or explicitly mention the requirement in the document of signatureSplit
to prevent any future implementation from violating it.
checkSignatures
does not explicitly check if the signature encoding is valid.
The valid signature encoding should satisfy the following conditions:
- When
v
is 0 or 1, the ownerr
should be within the range ofaddress
. Otherwise, the higher bits are truncated. - When
v
is 0:- The offset
s
should be within the bound of thesignatures
buffer, i.e.,s + 32 <= signatures.length
. Otherwise, it will read some garbage value from the memory. - The dynamic signature data pointed by
s
should be well-formed:- The first 4 bytes should denote the size of the dynamic data, i.e.,
dynamic-data-size := mload(signatures + s + 32)
. Otherwise, it may try to read a large memory chunk, causing the out-of-gas exception. - The
signatures
buffer should be large enough to hold the dynamic data, i.e.,signatures.length >= s + 32 + dynamic-data-size
. Otherwise, it will read some garbage value from the memory.
- The first 4 bytes should denote the size of the dynamic data, i.e.,
- (Optional) Each dynamic data buffer should not be pointed by multiple signatures. Otherwise, the same dynamic data will be used to check the validity of different signatures.
- (Optional) Different dynamic data buffers should not be overlapped.
- The offset
For a reference, the following checks are inserted in the bytecode by the Solidity compiler for each bytes
-type argument.
1. CALLDATASIZE >= 4 ? // checks if the function signature is provided
2. CALLDATASIZE >= 4 + 32 * NUM_OF_ARGS // checks if the headers of all arguments are provided
3. .... // load static type arguments and checks the range
4. startLOC := CALLDATALOAD(4 + 32 * IDX) // suppose the bytes-type argument is given in the IDX-th position
5. startLOC <= 2^32 ?
6. startLOC + 4 + 32 <= CALLDATASIZE ? // checks if the length information is provided
7. dataLen := CALLDATALOAD(startLoc + 4)
8. startLoc + 4 + 32 + dataLen <= CALLDATASIZE ? // checks if the actual data buffer is provided
9. dataLen <= 2^32 ?
10. ... CALLDATACOPY(..., startLoc + 4 + 32, dataLen) ... // copy the data buffer to the memory
Implement the signature encoding validity check.
The operation
argument value must be with the range of Enum.Operation
, i.e., [0,2]
inclusive, and the Solidity compiler is supposed to generate the range check in the compiled bytecode. But it turns out that the range check does not appear in the execTransaction
function, but it appears only inside the execute
function. We have not found yet any exploit of this missing range check, but it could be potentially vulnerable and requires a careful examination whenever the new bytecode is generated.
Examine bytecode whenever the bytecode is updated.
An address argument value must be within the range of address
, i.e., [0, 2^160-1]
inclusive. Otherwise, the fist 96 (= 256 - 160) bits are silently truncated (with no exception). Thus, any client of the function that takes address arguments should check the validity of addresses before passing them to the function.
It may be considered to scan the isValidSignature
function whenever adding a new owner (in either the contract or the client side), to ensure that the function body contains no dangerous opcode.
Example:
- Scanner: https://github.com/ethereum/casper/blob/master/casper/contracts/purity_checker.py
- Usage (on-chain): https://github.com/ethereum/casper/blob/master/casper/contracts/simple_casper.v.py#L578
checkSignatures
checks only the first threshold
number of signatures.
Thus, the validity of the remaining signatures is not considered.
Also, the entire list of signatures is not required to be sorted, as long as the first threshold
number of signatures are locally sorted.
However, we have not found an attack exploiting this.
Another questionable behavior is in the case where there are threshold
valid signatures in total, but some of them at the beginning are invalid. Currently, checkSignatures
fails in this case.
A potential issue for this behavior is that a bad owner intentionally sends an invalid signature to veto the transaction. He can always veto if his address is the first (the smallest) among the owners. On the other hand, a good owner is hard to veto some bad transaction if his address is the last (the lartest) among the owners.
Is this intended?
According to the signature encoding scheme, a signature with 2 <= v <= 26
is not valid, but the code does not have an explicit check for the case, relying on ecrecover
to implicitly reject the case. It may be considered to have the explicit check for the robustness, if the additional gas cost is affordable, since we have not verified the underlying C implementation of secp256k1, and there might exist unknown zero-day vulnerabilities (especially for the unusual cases).
handlePayment
sends ether to receiver
(in case of gasToken == address(0)
):
where receiver
is non-zero, provided that tx.origin
is non-zero.
But, receiver
could be still a non-owned account, especially one of the precompiled (0x1 - 0x8) contract addresses.
Here receiver.send(amount)
will succeed even with the small gas stipend 2300 for some precompiled contracts (at least, for 0x2, 0x3, 0x4, and 0x6). Below is the gas cost for executing each precompiled contract.
Address | Contract | Gas Cost |
---|---|---|
0x1 | ECREC | 3000 |
0x2 | SHA256 | 60 + 12 * (byte-size-of-call-data) |
0x3 | RIP160 | 600 + 120 * (byte-size-of-call-data) |
0x4 | ID | 15 + 3 * (byte-size-of-call-data) |
0x5 | MODEXP | ... |
0x6 | ECADD | 500 |
0x7 | ECMUL | 40000 |
0x8 | ECPAIRING | 100000 + ... |
Although it is very unlikely, but if ownerCount
is corrupted (possibly due to the hash collision), ownerCount++
may have the overflow, resulting in ownerCount
being zero, provided that threshold == _threshold
. In the case of threshold != _threshold
, however, if ownerCount++
has the overflow, changeThreshold
will always revert since the following two requirements cannot be satisfied at the same time, where ownerCount
is zero:
// Validate that threshold is smaller than number of owners.
require(_threshold <= ownerCount, "Threshold cannot exceed owner count");
// There has to be at least one Safe owner.
require(_threshold >= 1, "Threshold needs to be greater than 0");
Considering the current max block gas limit (~8M) and the gas cost for the local memory usage (i.e., n^2/512 + 3n
for n
bytes), the size of signatures
(and other bytes
-type arguments) must be (much) less than 2^16 (i.e., 64KB).
Note that the bytecode generated by the Solidity compiler checks if a bytes
-type argument size is less than 2^32.
In this section we enumerate all attack vectors from our reference list of known attack vectors, and describe how we analyzed them in GnosisSafe. The numbering correspond to that in the reference list. Please consult the link above for details.
1. Re-entrancy vulnerability is present, as described in previous section.
2. Arithmetic over/undeflows. All +/-/*
operations in GnosisSafe are performed using SafeMath
library,
which reverts whenever an overflow/underflow occurs. Thus GnosisSafe does not have this issue.
3. Unexpected Ether. The default function in Proxy.sol
is payable, and Ether is used by GnosisSafe to emit refunds.
The contract does not have issues related to presence of a specific amount of ether.
4. Delegatecall.
The payload call performed by GnosisSafe may be not only the regular call
, but also a delegatecall
or create
.
The call type is managed by transaction parameter operation
, e.g. must be signed by other owners.
However, delegatecall
is a dangerous type of transaction that can alter the GnosisSafe persistent data in unexpected ways.
This danger is properly described in the GnosisSafe documentation.
An earlier security audit for GnosisSafe
recommends disabling delegatecall
and create
entirely unless there is an important use case for it.
As it currently stands, it depends on the GnosisSafe client application to properly communicate to the owners
the type of call performed, and the dangers involved.
This is outside the scope of the present audit.
5. Default Visibilities. All functions have the visibility explicitly declared, and only functions that must be
public/external
are declared as such. Thus no functions use the default public visibility.
6. Entropy Illusion. GnosisSafe does not try to simulate random events. Thus the issue is unrelated to GnosisSafe.
7. Delegating functionality to external contracts.
GnosisSafe uses the proxy pattern.
Each instantiation of the safe deploys only the lightweight Proxy.sol
contract, which delegates (via delegatecall
) almost all calls
to the proper GnosisSafe.sol
deployed in another account. This reduces the cost of instantiating the safe and allows
future upgrades.
The contract account can upgrade the implementation by calling GnosisSafe.changeMasterCopy()
with the address
where the updated GnosisSafe code is deployed.
This function can only be called from the proxy account, thus is secure.
This pattern presents a security issue when the address of the master cannot be inspected by the contract users,
and they have no way to audit its security.
In GnosisSafe, master copy can be publicly accessed via Proxy.implementation()
, so the issue is not present.
8. Short address/parameter attack.
The transaction payload in GnosisSafe is received via transaction parameter data
,
and then used without changes to initiate an external call.
Other external calls are performed using standard methods from Solidity, thus the call data has the correct format.
The issue is not present.
9. Unchecked CALL Return Values.
Solidity methods call()
and send()
do not revert when the external call reverts, instead they return false
.
Some smart contracts naively expect such calls to revert, leading to bugs and potentially security issues.
In GnosisSafe, the return value of all such calls is correctly checked.
10. Race Conditions / Front Running.
This vulnerability may be present in contracts in which the amount of some ether/token transfer
depends on a sequence of transactions. Thus, an attacker may gain an advantage by manipulating the order of transactions.
In GnosisSafe, all the data from which refund token and amount are computed is given as parameters to execTransaction
,
thus the issue is not present.
11. Denial of Service. Non-owners cannot alter the persistent state of this contract, or use it to call external contracts. Thus no external DoS attack is possible. In principle if an owner loses the private key to his contract and can no longer exercise his duties to sign transactions, this would result in some hindrance. However, the list of owners can always be edited from the contract account, thus it will be a temporary issue.
12. Block Timestamp manipulation. The contract does not use block timestamp.
13. Constructors with Care. Before Solidity v0.4.22
,
constructor name was the same as the
name of the contract. This posed the risk to introduce a dangerous bug if
between versions contract would be renamed but constructor would not.
GnosisSafe is compiled with Solidity v5.0
, where constructors are declared with keyword constructor
,
thus the issue is not present.
14. Uninitialised local storage variables. Not used in GnosisSafe.
15. Floating Points and Numerical Precision. Floating point numbers are not used in GnosisSafe.
16. Tx.Origin Authentication. In GnosisSafe tx.origin
is not used for authentication.
17. Constantinople gas issue The issue may appear only in contracts without explicit protection for re-entrancy. We already discussed re-entrancy on point 1.