Sandclock contest - leastwood'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: 8/33

Findings: 6

Award: $2,578.49

🌟 Selected for report: 3

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: camden

Also found by: Ruhum, WatchPug, cccz, cmichel, danb, defsec, harleythedog, hyh, kenzo, leastwood, palina, pauliax, pmerkleplant, ye0lde

Labels

bug
duplicate
3 (High Risk)

Awards

90.0579 USDC - $90.06

External Links

Handle

leastwood

Vulnerability details

Impact

The strategy contract intends to take invested assets allocated by the strategy's vault and bridge these assets to the Anchor protocol to accrue interest. Deposit and redeem actions are broken into init and finish components. The init component must be initiated by a subset of restricted accounts.

NonUSTStrategy.sol allows the vault's underlying token to be set to something other than UST by converting the underlying token to and from UST depending on if the assets are being redeemed or deposited. For example, upon depositing invested assets, _swapUnderlyingToUst() is used to convert the underlying tokens to UST before bridging. _swapUstToUnderlying() performs the opposite swap action when redeeming bridged tokens.

Curve's exchange_underlying() function is utilised in both of the aforementioned swap functions. The last parameter enforces a minimum number of output tokens, however, in the case of NonUSTStrategy.sol, this value is always set to 0. Therefore, any user can sandwich attack token swaps by using a flash loan and calling finishDepositStable() or finishRedeemStable(). This forces the strategy to swap tokens at an unfair and manipulated price, extracting value from the protocol.

Proof of Concept

https://github.com/curvefi/curve-contract/blob/master/integrations.md#exchanging-underlying-tokens

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/strategy/NonUSTStrategy.sol#L74-L85

function _swapUnderlyingToUst() internal { uint256 underlyingBalance = _getUnderlyingBalance(); if (underlyingBalance > 0) { // slither-disable-next-line unused-return curvePool.exchange_underlying( underlyingI, ustI, underlyingBalance, 0 ); } }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/strategy/NonUSTStrategy.sol#L90-L96

function _swapUstToUnderlying() internal { uint256 ustBalance = _getUstBalance(); if (ustBalance > 0) { // slither-disable-next-line unused-return curvePool.exchange_underlying(ustI, underlyingI, ustBalance, 0); } }

Tools Used

Manual code review. Discussions with Ryuhei.

Consider adding necessary slippage checks when swapping between the underlying token and UST. This should be parsed as the min_dy parameter in exchange_underlying(). The affected functions include _swapUnderlyingToUst() and _swapUstToUnderlying().

#0 - naps62

2022-01-11T18:43:46Z

duplicate of #7

Findings Information

Awards

90.0579 USDC - $90.06

Labels

bug
duplicate
3 (High Risk)

External Links

Handle

leastwood

Vulnerability details

Impact

The vault contract aims to act as the interface by which users deposit and withdraw their assets. Upon depositing, users receive shares representing their claim on the vault's assets and any yield generated. Their position is minted as an NFT which is later burnt upon redeeming their deposited assets.

The _createDeposit() function queries totalShares() and totalUnderlyingMinusSponsored() to later calculate the amount of shares to mint in _createClaim(). There are two vulnerable external calls made which allow the user to reenter the deposit() function, namely:

  • depositors.mint() which uses _safeMint() (containing a _checkOnERC721Received() call to _msgSender()).
  • IIntegration(_claim.beneficiary).onDepositMinted() which is called if _isIntegration() is satisfied on _claim.beneficiary.

Each time the attacker reenters the deposit() function, the value for totalShares() is updated to reflect the newly minted shares, however, totalUnderlyingMinusSponsored() utilises an outdated amount as the _transferAndCheckUnderlying() is yet to be executed. As a result, _createClaim() will actually mint additional shares for the attacker if they break their deposit up into smaller amounts and reenter the function as often as possible. The attacker is able to enter the protocol at an improved exchange rate when compared to other users. This can be repeated again and again to continuously extract value from the protocol.

This issue also affects how shares are burnt when withdrawing deposits from the vault.

Proof of Concept

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L160-L163

function deposit(DepositParams calldata _params) external { _createDeposit(_params.amount, _params.lockedUntil, _params.claims); _transferAndCheckUnderlying(_msgSender(), _params.amount); }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L420-L452

function _createDeposit( uint256 _amount, uint256 _lockedUntil, ClaimParams[] calldata claims ) internal { if (_lockedUntil == 0) _lockedUntil = block.timestamp + minLockPeriod; else require( _lockedUntil >= block.timestamp + minLockPeriod, "Vault: lock time is too small" ); uint256 localTotalShares = totalShares(); uint256 localTotalUnderlying = totalUnderlyingMinusSponsored(); uint256 pct = 0; for (uint256 i = 0; i < claims.length; ++i) { ClaimParams memory data = claims[i]; _createClaim( _depositGroupIds.current(), _amount, _lockedUntil, data, localTotalShares, localTotalUnderlying ); pct += data.pct; } _depositGroupIds.increment(); require(pct.is100Perc(), "Vault: claims don't add up to 100%"); }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L454-L505

function _createClaim( uint256 _depositGroupId, uint256 _amount, uint256 _lockedUntil, ClaimParams memory _claim, uint256 _localTotalShares, uint256 _localTotalPrincipal ) internal { uint256 amount = _amount.percOf(_claim.pct); uint256 newShares = _computeShares( amount, _localTotalShares, _localTotalPrincipal ); uint256 claimerId = claimers.mint( _claim.beneficiary, amount, newShares ); uint256 tokenId = depositors.mint( _msgSender(), amount, claimerId, _lockedUntil ); if (_isIntegration(_claim.beneficiary)) { bytes4 ret = IIntegration(_claim.beneficiary).onDepositMinted( tokenId, newShares, _claim.data ); require( ret == IIntegration(_claim.beneficiary).onDepositMinted.selector ); } emit DepositMinted( tokenId, _depositGroupId, amount, newShares, _msgSender(), _claim.beneficiary, claimerId, _lockedUntil ); }

Tools Used

Manual code review. Discussions with the Sandclock team.

Consider adding reentrancy protections to deposit(), withdraw() and all other sensitive functions in Vault.sol. Care needs to be taken whenever the checks-effects pattern is not implemented to ensure state variables such as totalShares() and totalUnderlyingMinusSponsored() cannot be manipulated by an attacker.

#0 - r2moon

2022-01-11T15:45:22Z

Findings Information

🌟 Selected for report: danb

Also found by: ACai, WatchPug, cmichel, harleythedog, leastwood, palina, pedroais

Labels

bug
duplicate
2 (Med Risk)
sponsor disputed

Awards

105.9124 USDC - $105.91

External Links

Handle

leastwood

Vulnerability details

Impact

Any user is allowed to sponsor a vault by depositing tokens, thereby bootstrapping the vault's initial assets used in the strategy contract. A percentage of these assets are invested in the Anchor protocol by bridging UST from Ethereum to the Terra blockchain.

As other participants continue to deposit funds, it is expected that the sponsored amount will be diluted over time. However, a well-funded user could inhibit user experience by sponsoring a large token amount, then wait for a trusted account to call Vault.updateInvested() and subsequently withdraw their deposited amount, reducing the vault's underlying token balance. As a result, users will likely be unable to withdraw their assets until other users enter the vault.

This can be performed as a form of denial of service as users are potentially unable to withdraw due to insufficient token balance in the vault contract. This issue is of medium severity as the issue impacts protocol availability.

Proof of Concept

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L213-L223

function investableAmount() public view returns (uint256) { uint256 maxInvestableAssets = totalUnderlying().percOf(investPerc); uint256 alreadyInvested = strategy.investedAssets(); if (alreadyInvested >= maxInvestableAssets) { return 0; } else { return maxInvestableAssets - alreadyInvested; } }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L226-L238

function updateInvested() external requiresTrust { require(address(strategy) != address(0), "Vault: strategy is not set"); uint256 _investable = investableAmount(); if (_investable > 0) { underlying.safeTransfer(address(strategy), _investable); emit Invested(_investable); } strategy.doHardWork(); }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol#L244-L267

function sponsor(uint256 _amount, uint256 _lockedUntil) external override(IVaultSponsoring) { if (_lockedUntil == 0) _lockedUntil = block.timestamp + MIN_SPONSOR_LOCK_DURATION; else require( _lockedUntil >= block.timestamp + MIN_SPONSOR_LOCK_DURATION, "Vault: lock time is too small" ); uint256 tokenId = depositors.mint( _msgSender(), _amount, 0, _lockedUntil ); emit Sponsored(tokenId, _amount, _msgSender(), _lockedUntil); totalSponsored += _amount; _transferAndCheckUnderlying(_msgSender(), _amount); }

Tools Used

Manual code review.

A clean solution would involve withdrawing the amount needed for a vault withdrawal whenever the vault has insufficient funds at hand. However, withdrawing from Anchor protocol takes some time, so this is not a feasible solution.

Therefore, it may be worthwhile to consider implementing a buffer which prevents Vault.updateInvested() from utilising all of the vault's assets, keeping some amount for withdrawals.

#0 - naps62

2022-01-13T19:48:45Z

The description itself is by design. there are underlying issues involved, but they're more explicitly described in other issues (such as #157)

The mitigation steps themselves here suggest a solution would be to prevent updateInvested from utilising all the vault's assets and keeping some available for withdraw. That's precisely the use case we have for investPct, so I consider this mitigation as already being part of the current codebase

#1 - dmvt

2022-01-28T13:54:55Z

duplicate of #76

Findings Information

🌟 Selected for report: leastwood

Also found by: danb

Labels

bug
2 (Med Risk)
sponsor confirmed
sponsor strategy

Awards

797.1712 USDC - $797.17

External Links

Handle

leastwood

Vulnerability details

Impact

The investedAssets() function is implemented by the vault's strategy contracts as a way to express a vault's investments in terms of the underlying currency. While the implementation of this function in BaseStrategy.sol and NonUSTStrategy.sol is mostly correct. It does not account for the performance fee charged by the treasury as shown in finishRedeemStable().

Therefore, an attacker could avoid paying their fair share of the performance fee by withdrawing their assets before several calls to finishRedeemStable() are made and reenter the vault once the fee is charged.

Proof of Concept

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/strategy/BaseStrategy.sol#L180-L204

function finishRedeemStable(uint256 idx) public virtual { require(redeemOperations.length > idx, "not running"); Operation storage operation = redeemOperations[idx]; uint256 aUstBalance = _getAUstBalance() + pendingRedeems; uint256 originalUst = (convertedUst * operation.amount) / aUstBalance; uint256 ustBalanceBefore = _getUstBalance(); ethAnchorRouter.finishRedeemStable(operation.operator); uint256 redeemedAmount = _getUstBalance() - ustBalanceBefore; uint256 perfFee = redeemedAmount > originalUst ? (redeemedAmount - originalUst).percOf(perfFeePct) : 0; if (perfFee > 0) { ustToken.safeTransfer(treasury, perfFee); emit PerfFeeClaimed(perfFee); } convertedUst -= originalUst; pendingRedeems -= operation.amount; operation.operator = redeemOperations[redeemOperations.length - 1] .operator; operation.amount = redeemOperations[redeemOperations.length - 1].amount; redeemOperations.pop(); }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/strategy/BaseStrategy.sol#L263-L277

function investedAssets() external view virtual override(IStrategy) returns (uint256) { uint256 underlyingBalance = _getUnderlyingBalance() + pendingDeposits; uint256 aUstBalance = _getAUstBalance() + pendingRedeems; return underlyingBalance + ((exchangeRateFeeder.exchangeRateOf(address(aUstToken), true) * aUstBalance) / 1e18); }

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/strategy/NonUSTStrategy.sol#L120-L136

function investedAssets() external view override(BaseStrategy) returns (uint256) { uint256 underlyingBalance = _getUnderlyingBalance(); uint256 aUstBalance = _getAUstBalance() + pendingRedeems; uint256 ustAssets = ((exchangeRateFeeder.exchangeRateOf( address(aUstToken), true ) * aUstBalance) / 1e18) + pendingDeposits; return underlyingBalance + curvePool.get_dy_underlying(ustI, underlyingI, ustAssets); }

Tools Used

Manual code review. Discussions with the Sandclock team (mostly Ryuhei).

When calculating the investedAssets() amount (expressed in the underlying currency), consider calculating the expected performance fee to be charged if all the strategy's assets are withdrawn from the Anchor protocol. This should ensure that investedAssets() returns the most accurate amount, preventing users from gaming the protocol.

Findings Information

🌟 Selected for report: leastwood

Also found by: hickuphh3

Labels

bug
2 (Med Risk)
sponsor confirmed
sponsor vault
sponsor strategy

Awards

797.1712 USDC - $797.17

External Links

Handle

leastwood

Vulnerability details

Impact

The requiresTrust() modifier is used on the strategy, vault and factory contracts to prevent unauthorised accounts from calling restricted functions. Once an account is considered trusted, they are allowed to add and remove accounts by calling setIsTrusted() as they see fit.

However, if any single account has its private keys compromised or decides to become malicious on their own, they can remove all other trusted accounts from the isTrusted mapping. As a result, they are effectively able to take over the trusted group that controls all restricted functions in the parent contract.

Proof of Concept

abstract contract Trust { event UserTrustUpdated(address indexed user, bool trusted); mapping(address => bool) public isTrusted; constructor(address initialUser) { isTrusted[initialUser] = true; emit UserTrustUpdated(initialUser, true); } function setIsTrusted(address user, bool trusted) public virtual requiresTrust { isTrusted[user] = trusted; emit UserTrustUpdated(user, trusted); } modifier requiresTrust() { require(isTrusted[msg.sender], "UNTRUSTED"); _; } }

Tools Used

Manual code review.

Consider utilising Rari Capital's updated Auth.sol contract found here. This updated contract gives the owner account authority over its underlying trusted accounts, preventing any single account from taking over the trusted group. The owner account should point to a multisig managed by the Sandclock team or by a community DAO.

#0 - dmvt

2022-01-28T20:24:42Z

If this were to happen, funds would definitely be lost. Accordingly, this is a medium risk issue.

2 β€” Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

Findings Information

🌟 Selected for report: 0x1f8b

Also found by: hubble, leastwood, pedroais

Labels

bug
duplicate
1 (Low Risk)
sponsor vault

Awards

107.6181 USDC - $107.62

External Links

Handle

leastwood

Vulnerability details

Impact

The deposit() function allows any user to deposit the zero amount successfully. As a result, _depositGroupIds will increment and empty/redundant NFTs will be minted for the user. From a UI and state handling perspective, this may cause some unintended consequences.

Proof of Concept

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol

Tools Used

Manual code review.

Consider only allowing deposits where _params.amount != 0.

#0 - gabrielpoca

2022-01-14T00:07:18Z

Findings Information

🌟 Selected for report: leastwood

Labels

bug
1 (Low Risk)
disagree with severity
sponsor confirmed
sponsor vault

Awards

590.4972 USDC - $590.50

External Links

Handle

leastwood

Vulnerability details

Impact

The sponsor() function allows sponsors to parse the zero amount successfully. As a result, a deposit NFT will be minted which provides no functionality to the user.

Proof of Concept

https://github.com/code-423n4/2022-01-sandclock/blob/main/sandclock/contracts/Vault.sol

Tools Used

Manual code review.

Consider only allowing users to sponsor the protocol with a positive amount, i.e. _amount != 0.

#0 - naps62

2022-02-15T17:52:57Z

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