Platform: Code4rena
Start Date: 16/01/2024
Pot Size: $80,000 USDC
Total HM: 37
Participants: 178
Period: 14 days
Judge: Picodes
Total Solo HM: 4
Id: 320
League: ETH
Rank: 45/178
Findings: 6
Award: $286.90
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 00xSEV
Also found by: Banditx0x, CongZhang-CertiK, J4X, Myrault, OMEN, jesjupyter, linmiaomiao, miaowu, n1punp
132.0806 USDC - $132.08
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/Pools.sol#L409-L419 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/price_feed/CoreSaltyFeed.sol#L32-L54
The Pool::getPoolReserves returns the reserve0
&& reserve1
in real-time, and no TWAP is provided in the contract. However, if the function is later used in CoreSaltyFeed::getPriceBTC or CoreSaltyFeed::getPriceETH or other contracts to feed price of tokens, a flashloan
attack could be performed before-head to manipulate the ratio and thus the price.
Consider the following scenario:
TOKEN-A
and USDT
.Ex
(Ex. Lending Pool) retrieves the TOKEN-A/USDT
ratio for price-feeding since there is no TWAP provided.Flashloan
and call Pool::swap
to buy TOKEN-A with USDT to increase the price of TOKEN-A
. Thus, he may call Ex
to borrow USDT with TOKEN-A
as the collateral. Since the price is manipulated, he may borrow much more from Ex
.VSCode, Manual
Flashloan
manipulation which is also implemented by UniswapUniswap
#0 - c4-judge
2024-02-03T10:20:03Z
Picodes marked the issue as duplicate of #609
#1 - c4-judge
2024-02-03T10:20:07Z
Picodes marked the issue as partial-50
#2 - Picodes
2024-02-19T11:04:14Z
This report doesn't explain how to use this within the aggregator
#3 - c4-judge
2024-02-21T16:20:41Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: falconhoof
Also found by: BiasedMerc, J4X, Rhaydden, cats, forgebyola, inzinko, jesjupyter, josephdara, zhaojie
79.2483 USDC - $79.25
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L174 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L138 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L272 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAOConfig.sol#L47-L53 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/Proposals.sol#L95
Malicious Users can fill _openBallotsForTokenWhitelisting
with customized/fake/malicious/low-valued tokens by front-running others calling proposeTokenWhitelisting
. Even though these ballots would not be approved and would be removed from _openBallotsForTokenWhitelisting
due to the help from the DAO community after ballot.ballotMinimumEndTime
(at least 10 days) has passed, this could prevent the normal call of proposeTokenWhitelisting
and prevent the DEX from working.
In the function Proposals::proposeTokenWhitelisting, there is no access control and anyone could propose his token
. If the _openBallotsForTokenWhitelisting
has space and token
satisfies the totalSupply
requirement, the token
is then added to the _openBallotsForTokenWhitelisting
.
function proposeTokenWhitelisting( IERC20 token, string calldata tokenIconURL, string calldata description ) external nonReentrant returns (uint256 _ballotID) { require( address(token) != address(0), "token cannot be address(0)" ); require( token.totalSupply() < type(uint112).max, "Token supply cannot exceed uint112.max" ); // 5 quadrillion max supply with 18 decimals of precision require( _openBallotsForTokenWhitelisting.length() < daoConfig.maxPendingTokensForWhitelisting(), "The maximum number of token whitelisting proposals are already pending" ); require( poolsConfig.numberOfWhitelistedPools() < poolsConfig.maximumWhitelistedPools(), "Maximum number of whitelisted pools already reached" ); require( ! poolsConfig.tokenHasBeenWhitelisted(token, exchangeConfig.wbtc(), exchangeConfig.weth()), "The token has already been whitelisted" ); string memory ballotName = string.concat("whitelist:", Strings.toHexString(address(token)) ); uint256 ballotID = _possiblyCreateProposal( ballotName, BallotType.WHITELIST_TOKEN, address(token), 0, tokenIconURL, description ); _openBallotsForTokenWhitelisting.add( ballotID ); return ballotID; }
If the user front-runs others with multiple qualified wallets and calls proposeTokenWhitelisting
, others won't be able to propose their tokens. And the _openBallotsForTokenWhitelisting
could be filled with customized/fake/malicious/low-valued tokens. Thus the DEX is unable to whitelist high-quality tokens.
For a proposal, When its ballot.ballotMinimumEndTime
has passed, consider two scenarios:
proposeTokenWhitelisting
.Even though, there's a restriction that the user could only have one active proposal
and the user should stake some SALT to propose
. However, considering what is required to attack with default setting, worst-case setting, and best-case setting, it is still highly likely that the attack could be conducted against the community.
totalStaked SALT
(requiredProposalPercentStakeTimes1000 = 500
), and the default maxPendingTokensForWhitelisting
is 5, it only requires 2.5% of totalStaked SALT
to perform the attack.
-In the worst case, it only requires 0.1% percentage of totalStaked SALT
, and the default maxPendingTokensForWhitelisting
is 3, it only requires 0.3% of totalStaked SALT
to perform the attack.
-In the best case, it requires 2% percentage of totalStaked SALT
, and the default maxPendingTokensForWhitelisting
is 12, it requires 24% of totalStaked SALT
to perform the attack.Manual, VSCode
To mitigate this issue, I think there are possibly a few ways that can help.
DoS
#0 - c4-judge
2024-02-02T09:31:17Z
Picodes marked the issue as duplicate of #991
#1 - c4-judge
2024-02-14T07:54:41Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-14T07:56:04Z
Picodes changed the severity to 2 (Med Risk)
8.7582 USDC - $8.76
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L77 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L68 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/ManagedWallet.sol#L49
The changing mechanism of ManagedWallet requires a proposal and confirmation. The value activeTimelock
is updated each time the receive
function is triggered. However, when a new proposeWallets
is rejected (for example, wrong address input by mistake), the activeTimelock
is reset to type(uint256).max
but the proposedMainWallet
and proposedConfirmationWallet
is not reset. So, the changeWallets
can't be called due to block.timestamp >= activeTimelock
and new proposal can't be called due to require( proposedMainWallet == address(0), "Cannot overwrite non-zero proposed mainWallet." );
. Thus the mechanism gets stuck.
Consider the following situation.
proposeWallets
.0.01 ETH
to the contract.changeWallets
.proposeWallets
.POC below:
function setUp() public { managedWallet = new ManagedWallet(alice,bob); vm.deal(alice, 10 ether); vm.deal(bob, 10 ether); } function test_walletBypass() public { vm.prank(alice); managedWallet.proposeWallets(zed,bob); vm.prank(bob); address(managedWallet).call{value : 0.01 ether}(""); console.log("Lock time = > ", managedWallet.activeTimelock()); vm.warp(block.timestamp + 31 days); vm.startPrank(zed); vm.expectRevert("Timelock not yet completed"); managedWallet.changeWallets(); vm.startPrank(alice); vm.expectRevert("Cannot overwrite non-zero proposed mainWallet."); managedWallet.proposeWallets(zed,zed); }
Foundry
Modify receive
function: when the proposal is rejected, reset proposedMainWallet
and proposedConfirmationWallet
to address(0)
.
DoS
#0 - c4-judge
2024-02-02T10:43:15Z
Picodes marked the issue as duplicate of #838
#1 - c4-judge
2024-02-17T18:22:50Z
Picodes marked the issue as satisfactory
🌟 Selected for report: neocrao
Also found by: 00xSEV, 0x11singh99, 0x3b, 0xAlix2, 0xRobocop, 0xSmartContractSamurai, 0xanmol, AgileJune, Drynooo, HALITUS, Imp, J4X, KHOROAMU, Kalyan-Singh, MSaptarshi, RootKit0xCE, The-Seraphs, agadzhalov, aman, ayden, cu5t0mpeo, erosjohn, ewah, jasonxiale, jesjupyter, juancito, klau5, memforvik, okolicodes, parrotAudits0, rudolph, t0x1c, wangxx2026, zhaojohnson
1.6255 USDC - $1.63
It's clear that the require
statement here is incorrect which should be require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
. By making reserves.reserve1 < PoolUtils.DUST and reserves.reserve0 >= PoolUtils.DUST
, the original check could be bypassed.
In the code comments, it says: "// Make sure that removing liquidity doesn't drive either of the reserves below DUST."
However, the require
statement incorrectly uses reserves.reserve0 >= PoolUtils.DUST
twice which violates the meaning of the DOC.
// Make sure that removing liquidity doesn't drive either of the reserves below DUST. // This is to ensure that ratios remain relatively constant even after a maximum withdrawal. require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve0 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
By making reserves.reserve1 < PoolUtils.DUST and reserves.reserve0 >= PoolUtils.DUST
, the original check could be bypassed.
Manual
Change the require
statement to
require((reserves.reserve0 >= PoolUtils.DUST) && (reserves.reserve1 >= PoolUtils.DUST), "Insufficient reserves after liquidity removal");
Uniswap
#0 - c4-judge
2024-02-01T23:12:16Z
Picodes marked the issue as duplicate of #647
#1 - c4-judge
2024-02-19T15:44:43Z
Picodes marked the issue as satisfactory
🌟 Selected for report: klau5
Also found by: 0xAsen, 0xCiphky, 0xbepresent, KingNFT, Topmark, Toshii, falconhoof, jasonxiale, jesjupyter, nonseodion, pkqs90
53.4926 USDC - $53.49
https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolStats.sol#L51-L55 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L200 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolsConfig.sol#L127-L130 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/pools/PoolStats.sol#L134-L140 https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/Upkeep.sol#L196
When a pool is unwhitelisted
, it is removed from _whitelist
, however when later the upkeep is performed by Upkeep::performUpkeep
, the _arbitrageProfits
information of all whitelisted pools will be cleared. But when the removed pool is later get whitelisted, its _arbitrageProfits
will remain unchanged which will cause inconsistency and misunderstanding when PoolStats::profitsForWhitelistedPools
is being called. Furthermore, since Upkeep
uses profitsForWhitelistedPools
to distribute rewards for pools, thus the pool will get more rewards than expected.
When PoolStats::clearProfitsForPools is being called, all whitelisted pools' _arbitrageProfits
will be cleared.
function clearProfitsForPools() external { require(msg.sender == address(exchangeConfig.upkeep()), "PoolStats.clearProfitsForPools is only callable from the Upkeep contract" ); bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); for( uint256 i = 0; i < poolIDs.length; i++ ) _arbitrageProfits[ poolIDs[i] ] = 0; }
However, if a pool is unwhitelisted
before the clearProfitsForPools
call, and added back to _whitelist
afterwards, its _arbitrageProfits
would remain to be the value before. Thus this is inconsistent with the design of performUpkeep
and could potentially cause misunderstanding and wrong statistics when PoolStats::profitsForWhitelistedPools
is being called to Look at the arbitrage that has been generated since the last performUpkeep and determine how much each of the pools contributed to those generated profits.
// Look at the arbitrage that has been generated since the last performUpkeep and determine how much each of the pools contributed to those generated profits. // Returns the profits for all of the current whitelisted pools function profitsForWhitelistedPools() external view returns (uint256[] memory _calculatedProfits) { bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); _calculatedProfits = new uint256[](poolIDs.length); _calculateArbitrageProfits( poolIDs, _calculatedProfits ); }
Furthermore, since Upkeep::step7 uses profitsForWhitelistedPools
to distribute rewards for pools, thus the pool will get more rewards than expected since his _arbitrageProfits
is never erased.
function step7() public onlySameContract { uint256[] memory profitsForPools = pools.profitsForWhitelistedPools(); bytes32[] memory poolIDs = poolsConfig.whitelistedPools(); saltRewards.performUpkeep(poolIDs, profitsForPools ); pools.clearProfitsForPools(); }
Manual
It would be proper to add a counter
for upkeep, and every time counter++
during upkeep, the previous counter won't be visited again.
ERC20
#0 - c4-judge
2024-02-03T09:12:41Z
Picodes marked the issue as duplicate of #752
#1 - c4-judge
2024-02-21T13:32:51Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2024-02-26T07:54:55Z
Picodes changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-02-26T07:59:48Z
This previously downgraded issue has been upgraded by Picodes
#4 - c4-judge
2024-02-28T10:23:13Z
Picodes marked the issue as not a duplicate
#5 - c4-judge
2024-02-28T10:24:14Z
Picodes marked the issue as duplicate of #752
🌟 Selected for report: juancito
Also found by: 0x3b, 0xBinChook, 0xCiphky, 0xHelium, 0xMango, 0xOmer, 0xRobocop, 0xSmartContractSamurai, 0xWaitress, 0xbepresent, 0xpiken, 7ashraf, Arz, Audinarey, Banditx0x, Bauchibred, Draiakoo, IceBear, J4X, Jorgect, Kaysoft, KingNFT, Rhaydden, Rolezn, The-Seraphs, Tigerfrake, Topmark, Tripathi, Udsen, ZanyBonzy, a3yip6, b0g0, chaduke, codeslide, csanuragjain, dharma09, djxploit, eta, ether_sky, grearlake, inzinko, jasonxiale, jesjupyter, josephdara, lanrebayode77, linmiaomiao, lsaudit, niroh, oakcobalt, peanuts, pina, sivanesh_808, slvDev, t0x1c, vnavascues, wangxx2026
11.6897 USDC - $11.69
countryIsExcluded
The function countryIsExcluded
only returns the excludedCountries[country]
, however, since this info could be retrieved directly from excludedCountries(country)
as excludedCountries is public. The function defined here is redundant. The same works for Proposals::ballotForID
, Proposals::lastUserVoteForBallot
and Proposals::votesCastForBallot
.
Linked Code: https://github.com/code-423n4/2024-01-salty/blob/53516c2cdfdfacb662cdea6417c52f23c94d5b5b/src/dao/DAO.sol#L384-L388