Platform: Code4rena
Start Date: 12/07/2022
Pot Size: $35,000 USDC
Total HM: 13
Participants: 78
Period: 3 days
Judge: 0xean
Total Solo HM: 6
Id: 135
League: ETH
Rank: 5/78
Findings: 3
Award: $2,386.06
π Selected for report: 1
π Solo Findings: 1
π Selected for report: 0x52
2234.6752 USDC - $2,234.68
Loss of all user funds
This exploit stems from a quirk in the way that exchange rate is tracked for matured positions. We first need to breakdown how interest is calculate for a matured position.
In L124 the yield for a matured position is calculated as the difference between the previous exchange ratio and the maturity rate. This counts on the fact the exchange rate of the underlying protocol never decreases, as it always set the previous exchange rate to the current exchange rate after yield is calculated regardless of whether it is mature or not. The assumption is that the current exchange rate will always be greater than or equal to the maturity exchange rate. If it is higher then L124 will revert due to underflow and if it is equal then L124 will return yield = 0. The issue comes in when this assumption is invalidated. This would happen were the underlying protocol to lose value (hacked, UST depeg, etc.). With the loss of value, the exchange rate would drop, allowing a user to repeatedly redeem their matured position until all funds from the affected protocol are drained.
Example: Imagine a yearn vault that takes USDC as the underlying token. It's current price per share is 1.25e6 (1.25 USDC per vault share). Swivel has a recently expired VaultTracker.sol for this yearn vault for which mature() is called, setting maturityRate = 1.25e6. Now let's imagine that one small strategy deployed by the vault is compromised and the vault loses 4% of it's USDC. Now the vault will return 1.2e6 as the price per share. When calling redeemInterest for the first time, vlt.exchangeRate will be updated to 1.2e6 in L132. The next time redeemInterest is called it will pay the difference between 1.25e6 (maturityRate) and 1.2e6 (vlt.exchangeRate). redeemInterest can be called repeatedly like this until all USDC deposited in the yearn vault by Swivel users has been drained.
Additionally the user in question would not even need to have an expired position before the loss of funds occurred. SplitUnderlying in Swivel.sol has no checks to keep a user from minting previously expired market. After the loss of funds occurred the malicious user could use SplitUnderlying to create nTokens for the expired market, then carry out the exploit
The impact of such an event could be decreased with changes. In splitUnderlying add:
require(block.timestamp < m)
This prevents nTokens from being created after expiration which dramatically reduces the ability to take advantage of the opportunity. As for redeemInterest, add the following line after L124:
vlt.notional = 0
This would clear the notional balance of the user when redeeming after maturity, making it impossible to call repeatedly and reduces the chances that any users have a notional balance to exploit it should an event like this happen.
#0 - JTraversa
2022-07-15T22:56:01Z
I'm unsure if this is a proper report / should be accepted given similar issues exist when you integrate nearly any DeFi primitive.
You have risks that your integrated protocols could break, but make assumptions that they will not while placing failsafes like pause mechanism in your protocol.
That said, the suggestions dont really solve anything, though we may end up taking them to heart?
If the issue is that funds are lost when integrated protocol funds are lost and an attacker can then abuse these methods then the solutions provided just make someone spend more on gas in whatever flash loan tx they use to drain the protocol or slightly reduce the likelihood than an attacker will be in the correct position.
#1 - bghughes
2022-08-03T14:18:53Z
Note that this builds on #79 and I believe it is a good issue. The warden lays out a clear case in which Vault funds can be drained if these conditions occur:
maturityRate
.I agree that integrations can break @JTraversa, but given that Swivel aims to be the place people can go to deposit vault tokens and get ZCB + yield tokens (including allowing 4626 vaults) this is an attack vector IMO. Moreover, Yearn uses some discrete strategies in which the price per share realistically could reduce or even be manipulated in atomicity in a flash loan attack (which may seem counterintuitive bc generally lending vaults are supposed to be "up only").
Confirming this as a High-Risk issue as an attack vector to drain all vault funds is present. Please let me know Sponsor if you have any questions.
#2 - robrobbins
2022-08-04T20:01:44Z
much of this is addressed with: https://github.com/Swivel-Finance/gost/pull/417 (cannot mint post maturity)
#3 - 0xean
2022-08-25T12:38:31Z
Downgrading to M as it requires external factors for this situation to present itself and the attack to occur.
π Selected for report: bin2chen
Also found by: 0x52, 0xDjango, 0xSky, Picodes, auditor0517, rokinot, ronnyx2017, scaraven
Complete loss of user funds
https://github.com/code-423n4/2022-07-swivel/blob/daf72892d8a8d6eaa43b9e7d1924ccb0e612ee3c/Marketplace/MarketPlace.sol#L156 In L156 and L164 marketplace.sol makes an external call to swivel.authRedeem, but Swivel.sol doesn't contain any function by that name. When calling a nonexistent function in solidity, the call will simply return immediately with no code execution. This will burn the users tokens and not send them any underlying tokens. It seems that it meant to call authRedeemZcToken instead.
Change L156 and L164 to: ISwivel(swivel).authRedeemZcToken(p, u, market.cTokenAddr, t, a);
#0 - JTraversa
2022-07-18T23:25:10Z
Duplicate of #39
#1 - bghughes
2022-08-03T14:02:30Z
Duplicate of #39
π Selected for report: joestakey
Also found by: 0x1f8b, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 8olidity, Avci, Bahurum, Bnke0x0, Chom, ElKu, Funen, GimelSec, JC, Junnon, Kaiziron, Meera, PaludoX0, Picodes, ReyAdmirado, Sm4rty, Soosh, Waze, _Adam, __141345__, ak1, aysha, benbaessler, bin2chen, c3phas, cccz, cryptphi, csanuragjain, defsec, exd0tpy, fatherOfBlocks, gogo, hake, hansfriese, itsmeSTYJ, jonatascm, kyteg, mektigboy, oyc_109, pashov, rbserver, rishabh, robee, rokinot, sach1r0, sashik_eth, scaraven, simon135, slywaters
44.4954 USDC - $44.50
https://github.com/code-423n4/2022-07-swivel/blob/daf72892d8a8d6eaa43b9e7d1924ccb0e612ee3c/Swivel/Swivel.sol#L111-L144 https://github.com/code-423n4/2022-07-swivel/blob/daf72892d8a8d6eaa43b9e7d1924ccb0e612ee3c/Swivel/Swivel.sol#L215-L236
User avoids protocol fees
The dependency arises because initiateVaultFillingZcTokenInitiate and exitZcTokenFillingZcTokenInitiate pay fees on the same amount but in notional and underlying respectively. Notional will likely carry a much lower value per unit because it represent the future yield on the underlying. Since it is relative we will assume for this example that each notional is worth 0.05e6 (0.05 USDC). This is based on 10% APR and 6 months from maturity (generous but reasonable).
principalFilled is calculated the same in both functions and the order would be the same regardless of function therefore we will arbitrarily set principalFilled to 1000e6 (1000 USDC).
initiateVaultFillingZcTokenInitiate use feenominator[2] which is set at 400 in the constructor.
fee = 1000e6 / 400 = 2.5e6
This fee is transferred as notional so we need to convert the fee paid into USDC for proper comparison.
2.5e6 * 0.05e6 / 1e6 = 0.125e6 (0.125 USDC)
exitZcTokenFillingZcTokenInitiate uses feenominator[1] which is set at 600 in the constructor
fee = 1000e6 / 600 = 1.66e6
This fee is transferred in USDC (underlying)
Using initiateVaultFillingZcTokenInitiate results in a fee that is over 10x cheaper (0.125 vs 1.66) compared to exitZcTokenFillingZcTokenInitiate.
The end result isn't completely the same though because in one case, the taker's position was closed and in the other the taker now has two positions instead of one (their initial ntoken position and the newly minted zCToken position), but this is easily resolved. Assume the taker has a position of 1000e6 nTokens that they wish to close. exitZcTokenFillingZcTokenInitiate would allow them to directly close their nToken position with the order. If they instead decided to use initiateVaultFillingZcTokenInitiate with the same order would result in the taker with a new 1000e6 zCToken position. This would perfectly match with the number of nTokens they wish to close. The taker could then call combineTokens to combine their nTokens and the newly minted zCTokens to exit their position. Using initiateVaultFillingZcTokenInitiate and combineTokens together gives the same end result as exitZcTokenFillingZcTokenInitiate while paying a fraction of the fee as demostrated above.
A similar exploit is also possible using initiateVaultFillingVaultExit and splitUnderlying instead of exitZcTokenFillingVaultExit.
The easiest solution would be to charge the fees for initiateVaultFillingZcTokenInitiate and initiateVaultFillingVaultExit in the underlying. If it is more desirable to charge the fee in notional, then the amount paid for each notional could be found by dividing the amount paid (a) by the amount of notional minted (principalFilled). The fee could then be converted into notional using this conversion rate and then charged as notional.
#0 - JTraversa
2022-07-15T22:23:30Z
Because we have a feenominator
array, with four different feenominator values, we can modulate the fees per order type to ensure that the value of fees is roughly equivalent.
That said, this is a heuristic, and the suggestion could be a way to be a bit more precise, though this is really an implementation detail that surrounds design more than anything (what if we WANT to have certain order types more attractive?)
#1 - bghughes
2022-07-31T18:35:16Z
I feel that this is a bad issue given that it seems impossible to benefit from fee calculation discrepancies in the context of Swivel.
I disagree with:
The dependency arises because initiateVaultFillingZcTokenInitiate and exitZcTokenFillingZcTokenInitiate pay fees on the same amount but in notional and underlying respectively. due to the consistent use of basing the fee off of
principalFilled
in the two cases the warden references: https://github.com/code-423n4/2022-07-swivel/blob/daf72892d8a8d6eaa43b9e7d1924ccb0e612ee3c/Swivel/Swivel.sol#L296 https://github.com/code-423n4/2022-07-swivel/blob/daf72892d8a8d6eaa43b9e7d1924ccb0e612ee3c/Swivel/Swivel.sol#L140
It seems to me that the fee is paid off the principal amount in each scenario and I find this warden's argument hard to follow. The one point the warden makes that seems informational and that @JTraversa is aware of is that the inconsistent use of denominator values in this fee math could cause unexpected issues or fee rates for the user.
Downgrading to Informational/QA as I don't think the path the warden described is possible to execute in practice.
#2 - bghughes
2022-07-31T18:41:41Z
Treating this as the wardens main QA report as they did not submit one - accidentally linked to another warden's QA above and deleted/updated comments to reflect the truth. This will act as 0x52's QA report
#3 - robrobbins
2022-08-29T22:56:14Z
puttin on a maybe cuz it's all a bit hazy... will circle back