reaper walkthrough (& your next challenge)
how a missing withdrawal validation led to a $1.7mm hack
great work last week, my friends. you’re starting to earn my trust. maybe we will be able to save the ecosystem together after all, hm?
how to hack reaper
let’s walk through last week’s challenge.
the vault holding the assets was ReaperVaultV2.sol
, so we’ll start our search there. it’s a long contract, but one way to speed up a first pass is to focus on the most dangerous function.
in this case, we’re trying to steal the DAI, so let’s search the document for when DAI is transferred with IERC20Metadata(asset).safeTransfer
.
we end up with 4 results:
deposit
: DAI is transferred from you to the contractmint
: DAI is transferred from you to the contract_withdraw
: DAI is transferred from the contract to youreport
: transfer DAI into or out of the contract, for strategies
so, the two places we might be able to steal DAI are from _withdraw()
or report()
.
let’s look more closely at report()
report()
starts with the following check:
maybe we could add our own strategy to exploit this? but if we look at addStrategy()
it’s gated by _atLeastRole()
, which confirms that only the admin is able to add strategies.
what about _withdraw()
?
the _withdraw()
function has four inputs: assets, shares, receiver, and owner.
first, it burns shares from the owner.
then, if assets are greater than the balance, it cashes out strategies.
finally, it transfers assets to the receiver.
but wait a minute. there are no validations? there doesn’t seem to be any check whether you are the owner, or have the owner’s approval to do this.
_withdraw()
is an internal function, so surely those validations are in the external withdraw()
function that calls it.
nope. no validations. any user can withdraw any other user’s assets.
sidenote: how could this be possible?
the reaper team wrote up this doc about the exploit, in which the explain how it happened.
After our security team pen-tested and secured our multi-strategy vaults, we decided to change the interface to abide by ERC-4626 standards. After the implementation was complete, we did not follow through with our security audit again and executed a typical code review. We should have respected this as a significant breaking change and put it through another internal audit.
and, even more importantly:
We confidently moved into production without 3rd party audits to back us up. The contracts were still undergoing their external audit [when we deployed them].
so there it is. we can all miss things sometimes. but they made major changes without reviewing internally, and then deployed them while the code was still being audited.
ouch.
the exploit
so we know we can withdraw funds from any user, simply by calling withdraw()
with their address at the owner, and ours as the receiver.
but how do we know which addresses own tokens?
many people who completed this challenge (like this great walkthrough by afeli.eth) just went on ftmscan.com and looked manually at the folks who made deposits. that works fine. there are no style points here.
but to make sure i was able to save as much of the vault’s funds as possible, i wrote a little script to parse all event logs for token transfers and tally up the balances of all the users:
const ethers = require('ethers'); | |
const STARTING_BLOCK = 41918655; | |
const FINAL_BLOCK = 44000000; | |
async function main() { | |
const provider = new ethers.providers.JsonRpcProvider("https://rpc.ankr.com/fantom/"); | |
let balances = {}; | |
for (let i = STARTING_BLOCK; i < FINAL_BLOCK;) { | |
console.log(`Block ${i}: (${Math.floor((i - STARTING_BLOCK) / (FINAL_BLOCK - STARTING_BLOCK) * 100)}%)`) | |
filter = { | |
address: "0x77dc33dC0278d21398cb9b16CbFf99c1B712a87A", | |
topics: [ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" ], | |
fromBlock: i, | |
toBlock: i + 2500 | |
}; | |
try { | |
const logs = await provider.getLogs(filter); | |
for (log of logs) { | |
let from = "0x" + log.topics[1].slice(-40); | |
let to = "0x" + log.topics[2].slice(-40); | |
let amt = parseFloat(ethers.utils.formatEther(ethers.BigNumber.from(log.data))); | |
balances[from] = balances[from] ? balances[from] - amt : -amt; | |
balances[to] = balances[to] ? balances[to] + amt : amt; | |
} | |
i += 2500; | |
} catch (err) { | |
console.log(err) | |
} | |
} | |
console.log("-----------------------") | |
sorted = Object.keys(balances).sort((a,b) => balances[a] - balances[b]) | |
for (addr of sorted) { | |
console.log(`${ethers.utils.getAddress(addr)}: ${balances[addr]}`) | |
} | |
} | |
main(); |
once that script is complete, i can grab the 3 users with a quantity of tokens worth stealing and add them to my test.
we run the script and…
your next assignment
now that you’ve proven yourself, it’s time to graduate to something a little more advanced.
a new token called schnoodle was deployed on mainnet last summer. all seemed fine, until 6/18, when the uniswap pool for the SNOOD-ETH pair was drained of all its ETH.
can you go back and save > 100 ETH?