Platform: Code4rena
Start Date: 08/06/2022
Pot Size: $115,000 USDC
Total HM: 26
Participants: 72
Period: 11 days
Judge: leastwood
Total Solo HM: 14
Id: 132
League: ETH
Rank: 3/72
Findings: 4
Award: $12,411.35
🌟 Selected for report: 2
🚀 Solo Findings: 1
function repayAavePortal( address _local, uint256 _backingAmount, uint256 _feeAmount, uint256 _maxIn, bytes32 _transferId ) external { uint256 totalAmount = _backingAmount + _feeAmount; // in adopted uint256 routerBalance = s.routerBalances[msg.sender][_local]; // in local // Sanity check: has that much to spend if (routerBalance < _maxIn) revert PortalFacet__repayAavePortal_insufficientFunds(); // Need to swap into adopted asset or asset that was backing the loan // The router will always be holding collateral in the local asset while the loaned asset // is the adopted asset // Swap for exact `totalRepayAmount` of adopted asset to repay aave (bool success, uint256 amountIn, address adopted) = AssetLogic.swapFromLocalAssetIfNeededForExactOut( _local, totalAmount, _maxIn ); if (!success) revert PortalFacet__repayAavePortal_swapFailed(); // decrement router balances unchecked { s.routerBalances[msg.sender][_local] -= amountIn; } // back loan _backLoan(_local, _backingAmount, _feeAmount, _transferId); }
function swapFromLocalAssetIfNeededForExactOut( address _asset, uint256 _amount, uint256 _maxIn ) internal returns ( bool, uint256, address ) { AppStorage storage s = LibConnextStorage.connextStorage(); // Get the token id (, bytes32 id) = s.tokenRegistry.getTokenId(_asset); // If the adopted asset is the local asset, no need to swap address adopted = s.canonicalToAdopted[id]; if (adopted == _asset) { return (true, _amount, _asset); } return _swapAssetOut(id, _asset, adopted, _amount, _maxIn); }
When the _local
is canonicalToAdopted
, AssetLogic.swapFromLocalAssetIfNeededForExactOut()
will return the totalAmount
as amountIn
, and totalAmount
is the total of two input values: _backingAmount + _feeAmount
.
At L91, it checks for routerBalance < _maxIn
, but there is no guarantee that _maxIn >= amountIn
.
As a result, when repayAavePortal()
is called with _backingAmount + _feeAmount > routerBalance
and _local
is an adopted asset, L108 will underflow, and the routerBalance
can be inflated to a very high number.
// Sanity check: amount can be deducted for the router if (routerBalance < _amount) revert RoutersFacet__removeRouterLiquidity_insufficientFunds();
The routerBalance
is used for sanity check and once it's been inflated, the router can withdraw possibly all the funds in the contract.
Generally, we suggest not to use unchecked
in critical storage updates
if (!success) revert PortalFacet__repayAavePortal_swapFailed(); // decrement router balances s.routerBalances[msg.sender][_local] -= amountIn; // back loan _backLoan(_local, _backingAmount, _feeAmount, _transferId); }
#0 - ecmendenhall
2022-06-20T04:47:34Z
Duplicate of #68
#1 - LayneHaber
2022-06-21T22:27:30Z
Closing in favor of #68
🌟 Selected for report: WatchPug
8660.4234 USDC - $8,660.42
function withdrawAdminFees(Swap storage self, address to) internal { IERC20[] memory pooledTokens = self.pooledTokens; for (uint256 i = 0; i < pooledTokens.length; i++) { IERC20 token = pooledTokens[i]; uint256 balance = self.adminFees[i]; if (balance != 0) { token.safeTransfer(to, balance); } } }
self.adminFees[i]
should be reset to 0 every time it's withdrawn. Otherwise, the adminFees
can be withdrawn multiple times.
The admin may just be unaware of this issue and casualty withdrawAdminFees()
from time to time, and rug all the users slowly.
Change to:
function withdrawAdminFees(Swap storage self, address to) internal { IERC20[] memory pooledTokens = self.pooledTokens; for (uint256 i = 0; i < pooledTokens.length; i++) { IERC20 token = pooledTokens[i]; uint256 balance = self.adminFees[i]; if (balance != 0) { self.adminFees[i] = 0; token.safeTransfer(to, balance); } } }
#0 - LayneHaber
2022-06-27T15:25:37Z
#1 - 0xleastwood
2022-08-05T21:42:38Z
Completely agree with the validity of this finding. Even if the admin was not malicious, the bug will still continue to withdraw additional fees which were not included as part of the swap calculations. LPs would lose considerable value as a result.
1169.1572 USDC - $1,169.16
For tokens with decimals larger than 18, many functions across the codebase will revert due to underflow.
function getPriceFromDex(address _tokenAddress) public view returns (uint256) { PriceInfo storage priceInfo = priceRecords[_tokenAddress]; if (priceInfo.active) { uint256 rawTokenAmount = IERC20Extended(priceInfo.token).balanceOf(priceInfo.lpToken); uint256 tokenDecimalDelta = 18 - uint256(IERC20Extended(priceInfo.token).decimals()); uint256 tokenAmount = rawTokenAmount.mul(10**tokenDecimalDelta); uint256 rawBaseTokenAmount = IERC20Extended(priceInfo.baseToken).balanceOf(priceInfo.lpToken); uint256 baseTokenDecimalDelta = 18 - uint256(IERC20Extended(priceInfo.baseToken).decimals()); uint256 baseTokenAmount = rawBaseTokenAmount.mul(10**baseTokenDecimalDelta); uint256 baseTokenPrice = getTokenPrice(priceInfo.baseToken); uint256 tokenPrice = baseTokenPrice.mul(baseTokenAmount).div(tokenAmount); return tokenPrice; } else { return 0; } }
precisionMultipliers[i] = 10**uint256(SwapUtils.POOL_PRECISION_DECIMALS - decimals[i]);
Chainlink feeds' with decimals > 18 are not supported neither:
function getPriceFromChainlink(address _tokenAddress) public view returns (uint256) { AggregatorV3Interface aggregator = aggregators[_tokenAddress]; if (address(aggregator) != address(0)) { (, int256 answer, , , ) = aggregator.latestRoundData(); // It's fine for price to be 0. We have two price feeds. if (answer == 0) { return 0; } // Extend the decimals to 1e18. uint256 retVal = uint256(answer); uint256 price = retVal.mul(10**(18 - uint256(aggregator.decimals()))); return price; } return 0; }
Consider checking if decimals > 18 and normalize the value by div the decimals difference.
#0 - ecmendenhall
2022-06-20T14:59:35Z
Duplicate of #39
#1 - ecmendenhall
2022-06-20T15:15:49Z
I gave this a :heart: along with https://github.com/code-423n4/2022-06-connext-findings/issues/61 because these findings both identified an additional location in the StableSwap
contract where the 18 decimal assumption is hardcoded.
#2 - jakekidd
2022-06-24T21:30:01Z
I gave this a ❤️ along with #61 because these findings both identified an additional location in the
StableSwap
contract where the 18 decimal assumption is hardcoded.
Marking as confirmed (and leaving issue open) for this reason. Would be great to merge both findings into 1 issue in the finalized audit.
#3 - jakekidd
2022-06-27T03:39:40Z
Assortment of findings across these three issues: https://github.com/code-423n4/2022-06-connext-findings/issues/39 https://github.com/code-423n4/2022-06-connext-findings/issues/61 https://github.com/code-423n4/2022-06-connext-findings/issues/204
#4 - jakekidd
2022-06-29T20:17:25Z
#5 - 0xleastwood
2022-08-13T23:32:41Z
Marking this as the primary issue because it highlights an active part of the codebase while other issues do not. initializeSwap
will not be compatible with any token with decimals
greater than 18
.
🌟 Selected for report: BowTiedWardens
Also found by: 0x1f8b, 0x29A, 0x52, 0xNazgul, 0xNineDec, 0xf15ers, 0xkatana, 0xmint, Chom, ElKu, Funen, IllIllI, JMukesh, Jujic, Kaiziron, Lambda, MiloTruck, Ruhum, SmartSek, SooYa, TerrierLover, TomJ, WatchPug, Waze, _Adam, asutorufos, auditor0517, bardamu, c3phas, catchup, cccz, ch13fd357r0y3r, cloudjunky, cmichel, cryptphi, csanuragjain, defsec, fatherOfBlocks, hansfriese, hyh, jayjonah8, joestakey, k, kenta, obtarian, oyc_109, robee, sach1r0, shenwilly, simon135, slywaters, sorrynotsorry, tintin, unforgiven, xiaoming90, zzzitron
243.4609 USDC - $243.46
latestRoundData
might return stale or incorrect resultsfunction getPriceFromChainlink(address _tokenAddress) public view returns (uint256) { AggregatorV3Interface aggregator = aggregators[_tokenAddress]; if (address(aggregator) != address(0)) { (, int256 answer, , , ) = aggregator.latestRoundData(); // It's fine for price to be 0. We have two price feeds. if (answer == 0) { return 0; } // Extend the decimals to 1e18. uint256 retVal = uint256(answer); uint256 price = retVal.mul(10**(18 - uint256(aggregator.decimals()))); return price; } return 0; }
On ConnextPriceOracle.sol
, we are using latestRoundData
, but there is no check if the return value indicates stale data. This could lead to stale prices according to the Chainlink documentation:
Consider adding missing checks for stale data.
For example:
(uint80 roundID, int256 answer, , uint256 timestamp, uint80 answeredInRound) = aggregator.latestRoundData(); require(answer > 0, "Chainlink price <= 0"); require(answeredInRound >= roundID, "Stale price"); require(timestamp != 0, "Round not complete");
#0 - ecmendenhall
2022-06-20T05:39:10Z
Duplicate of #190
#1 - jakekidd
2022-06-24T21:42:46Z