Platform: Code4rena
Start Date: 07/04/2022
Pot Size: $100,000 USDC
Total HM: 20
Participants: 62
Period: 7 days
Judge: LSDan
Total Solo HM: 11
Id: 107
League: ETH
Rank: 1/62
Findings: 5
Award: $17,759.44
π Selected for report: 3
π Solo Findings: 2
The yVault.deposit
function mints initial shares
equal to the deposited amount.
The deposit / withdraw functions also use the balance()
, which includes the contract balance token.balanceOf(address(this))
, to compute the shares.
It's possible to increase the share price to very high amounts, such that a depositor mints zero shares for their deposit. They lose all funds and they can then be stolen by the attacker when they redeem their fair share of the strategy TVL.
Assume the attacker is the first minter and balance() == 0
and token == USDC
. Some victim V wants to deposit 1000.0 USDC = 1e9 USDC
.
The attacker frontruns the transaction and does the following:
deposit(amount = 1)
. This amount is transferred and the attacker receives shares = _amount = 1
share. Then balance() == 1
.token
balance by directly transfering 1e9 USDC
to the controller such that balance() = token.balanceOf(this) = 1e9 + 1
._amountToDeposit = 1e9
. The shares computation is now shares = (_amount * supply) / balanceBefore = 1e9 * 1 / (1e9 + 1) = 0
. They contributed 1e9 USDC
to the strategy but receive zero shares.withdraw(1)
to burn their single share and receive the entire balance()
as they redeem 100% of the share total supply: backingTokens = (balance() * _shares) / supply = balance() * 1 / 1 = 2e9 + 1
totalSupply()
is back to zero and they can repeat this attack for the next depositor.The way UniswapV2 prevents this is by requiring a minimum deposit amount and sending 1000
initial shares to the zero address to make this attack more expensive.
The same mitigation can be done here.
Alternatively, scale the initial amount by a large value like 1e18
: if (totalSupply() == 0) _shares = _amountToDeposit * 1e18
.
#0 - spaghettieth
2022-04-12T19:50:06Z
Duplicate of #12
π Selected for report: cmichel
7883.8572 USDC - $7,883.86
In deposit
, the balance is cached and then a token.transferFrom
is triggered which can lead to exploits if the token
is a token that gives control to the sender, like ERC777 tokens.
Initial state: balance() = 1000
, shares supply = 1000
.
Depositing 1000 amount should mint 1000 supply, but one can split the 1000 amounts into two 500 deposits and use re-entrancy to profit.
deposit(500)
: balanceBefore = 1000
. Control is given to attacker ...deposit(500)
: balanceBefore = 1000
. shares = (_amount * supply) / balanceBefore = 500 * 1000 / 1000 = 500
shares are minted ...deposit(500)
continues with the mint: shares = (_amount * supply) / balanceBefore = 500 * 1500 / 1000 = 750
are minted.500 + 750 = 1250
shares via withdraw(1250)
, the attacker receives backingTokens = (balance() * _shares) / supply = 2000 * 1250 / 2250 = 1111.111111111
. The attacker makes a profit of 1111 - 1000 = 111
tokens.The safeTransferFrom
should be the last call in deposit
.
#0 - spaghettieth
2022-04-14T14:34:07Z
π Selected for report: cmichel
7883.8572 USDC - $7,883.86
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/farming/yVaultLPFarming.sol#L170 https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/yVault/yVault.sol#L108
The accruals in yVaultLPFarming
will fail if currentBalance < previousBalance
in _computeUpdate
.
currentBalance = vault.balanceOfJPEG() + jpeg.balanceOf(address(this)); uint256 newRewards = currentBalance - previousBalance;
No funds can be withdrawn anymore as the withdraw
functions first trigger an _update
.
The currentBalance < previousBalance
case can, for example, be triggerd by decreasing the vault.balanceOfJPEG()
due to calling yVault.setController
:
function setController(address _controller) public onlyOwner { // @audit can reduce balanceofJpeg which breaks other masterchef contract require(_controller != address(0), "INVALID_CONTROLLER"); controller = IController(_controller); } function balanceOfJPEG() external view returns (uint256) { // @audit new controller could return a smaller balance return controller.balanceOfJPEG(address(token)); }
Setting a new controller on a vault must be done very carefully and requires a migration.
#0 - spaghettieth
2022-04-12T19:24:58Z
Duplicate of #56
#1 - dmvt
2022-04-26T14:48:02Z
This is not a duplicate of #56. Though both of them deal with issues related to balanceOfJPEG, they describe different causes.
1064.3207 USDC - $1,064.32
The setDebtInterestApr
changes the debt interest rate without first accruing the debt.
This means that the new debt interest rate is applied retroactively to the unaccrued period on next accrue()
call.
It should never be applied retroactively to a previous time window as this is unfair & wrong. Borrowers can incur more debt than they should.
Call accrue()
first in setDebtInterestApr
before setting the new settings.debtInterestApr
.
#0 - spaghettieth
2022-04-14T14:24:30Z
Fixed in https://github.com/jpegd/core/pull/4
π Selected for report: Dravee
Also found by: 0x1f8b, 0xDjango, 0xkatana, AuditsAreUS, Cityscape, Foundation, Funen, Hawkeye, IllIllI, JC, JMukesh, Jujic, Kthere, PPrieditis, Picodes, Ruhum, TerrierLover, TrungOre, WatchPug, berndartmueller, catchup, cccz, cmichel, delfin454000, dy, ellahi, hickuphh3, horsefacts, hubble, hyh, ilan, jayjonah8, kebabsec, kenta, minhquanym, pauliax, rayn, reassor, rfa, robee, samruna
151.5077 USDC - $151.51
The owner of an insured position that has been liquidated can claim back their NFT without paying back the debt by calling closePosition
instead of repurchase
.
function closePosition(uint256 _nftIndex) external // @audit NFT is still valid (ownerOf(nft) == this) validNFTIndex(_nftIndex) { accrue(); // @audit owner is still original owner (depositor) require(msg.sender == positionOwner[_nftIndex], "unauthorized"); // @audit debt is zero as it's been repaid by liquidator require(_getDebtAmount(_nftIndex) == 0, "position_not_repaid"); positionOwner[_nftIndex] = address(0); delete positions[_nftIndex]; positionIndexes.remove(_nftIndex); // transfer nft back to owner if nft was deposited if (nftContract.ownerOf(_nftIndex) == address(this)) { nftContract.safeTransferFrom(address(this), msg.sender, _nftIndex); } emit PositionClosed(msg.sender, _nftIndex); }
The liquidate
function sets position.debtPortion = 0;
and does not clear the owner
which means all closePosition
checks pass.
This means that PUSD can be minted for free by:
claimExpiredInsuranceNFT
anymore)closePosition
Consider checking in closePosition
that the position has not been liquidated.
#0 - spaghettieth
2022-04-12T19:24:12Z
The debtPortion
field is cleared the debtPrincipal
field isn't, causing the _getDebtAmount
function to return the latter when calling closePosition
, causing the transaction to revert with reason position_not_repaid
. However it would be more correct if the transaction reverted with reason "liquidated".
#1 - spaghettieth
2022-04-14T14:33:21Z
#2 - dmvt
2022-04-26T14:43:35Z
Agree with the issue, but also with sponsor's view of the issue. The warden was incorrect in that the function would revert, but for the wrong stated reason. Accordingly, this is a QA issue, not a high severity one.
#3 - JeeberC4
2022-05-02T19:09:37Z
Generating QA Report as judge downgraded issue. Preserving original title: Can claim liquidated NFT collateral without repaying