Platform: Code4rena
Start Date: 04/05/2022
Pot Size: $50,000 DAI
Total HM: 24
Participants: 71
Period: 5 days
Judge: Justin Goro
Total Solo HM: 14
Id: 119
League: ETH
Rank: 5/71
Findings: 6
Award: $2,026.66
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: cccz
Also found by: 0x52, 0xYamiDancho, GimelSec, IllIllI, PPrieditis, WatchPug, csanuragjain, danb, gzeon, hickuphh3, horsefacts, hyh, kenzo, leastwood, reassor, unforgiven
63.9296 DAI - $63.93
Judge has assessed an item in Issue #285 as High risk. The relevant finding follows:
passThruGate() redirects to a beneficiary
only gate.ethCost
, requiring that msg.value >= gate.ethCost
. As there are no other ways to access native tokens held by this contract, the cumulative excess value, a sum of msg.value - gate.ethCost
, will be permanently frozen within the contract.
Only gate.ethCost
is now forwarded:
(bool sent, bytes memory data) = gate.beneficiary.call{value: gate.ethCost}("");
Pass-though the whole msg.value as the excess is not used:
(bool sent, bytes memory data) = gate.beneficiary.call{value: msg.value}("");
#0 - gititGoro
2022-06-23T02:46:00Z
duplicate of #48
🌟 Selected for report: MaratCerby
Also found by: CertoraInc, Ruhum, VAD37, berndartmueller, broccolirob, cryptphi, danb, gzeon, horsefacts, hyh, joestakey, leastwood, pedroais, peritoflores, throttle, wuwe1, z3s
19.1789 DAI - $19.18
The tree.tokenAddress
ERC20 can be a token that doesn't revert on failure (i.e. non-malicious, valid ERC20 with such specifics), while transfer
call without a result check is used for claiming funds.
The transfer is a part of the only way for a merkle-drop recipient to withdraw her tokens, so a situation when transfer didn't went through, but withdraw() didn't reverted means that recipient funds are frozen within the contract as accounting is updated as if the funds are sent.
As this is user permanent fund freeze case with the external assumptions, setting the severity to medium.
Unsafe transfer is used in withdraw():
IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal);
Some tokens do not revert on a failed transfer, returning false instead, which technically doesn't break ERC20 standard:
https://github.com/d-xo/weird-erc20#no-revert-on-failure
As withdraw() updates the accounting, the funds can be permanently frozen for a user:
// decrease allocation of coins tranche.currentCoins -= currentWithdrawal; // this makes sure coins don't get double withdrawn tranche.lastWithdrawalTime = block.timestamp; // update the tree balance so trees can't take each other's tokens MerkleTree storage tree = merkleTrees[treeIndex]; tree.tokenBalance -= currentWithdrawal;
Consider requiring tree.tokenAddress
transfer success:
require(IERC20(tree.tokenAddress).transfer(destination, currentWithdrawal), "Token transfer failed")
#0 - illuzen
2022-05-12T04:36:05Z
Duplicate of #22
🌟 Selected for report: MaratCerby
Also found by: 0x1337, 0x52, 0xYamiDancho, AuditsAreUS, CertoraInc, Dravee, GimelSec, IllIllI, PPrieditis, Ruhum, TrungOre, VAD37, berndartmueller, cccz, csanuragjain, defsec, hickuphh3, horsefacts, hyh, jayjonah8, kenzo, leastwood, mtz, p4st13r4, reassor, throttle, wuwe1, ych18
3.1753 DAI - $3.18
https://github.com/code-423n4/2022-05-factorydao/blob/db415804c06143d8af6880bc4cda7222e5463c0e/contracts/PermissionlessBasicPoolFactory.sol#L192-L198 https://github.com/code-423n4/2022-05-factorydao/blob/db415804c06143d8af6880bc4cda7222e5463c0e/contracts/PermissionlessBasicPoolFactory.sol#L233-L233
Griefing attack is possible if pool deposit token is a fee on transfer ERC20 as {deposit, withdraw} atomic call sequence is allowed with pre-fee token quantity being accounted as deposit amount.
Suppose F is a fee on transfer token and the pool with F as deposit token was created (as there are no restrictions and the system didn't declared the absence of support for such tokens).
A malicious user can deposit, then withdraw same amount of F she posses. The attacker incurs a transaction fee plus a transfer fee. The pool will pay a transfer fee as withdraw() will send the exactly same amount as user stated in deposit(), while actually received amount was less. An attacker can repeat this many times over, destroying up to all F funds on the pools balance.
There is no economic benefit for the attacker, but in some scenarios there can be incentives to destroy pool's balance even bearing the same cost on the attacker's side.
This is a griefing attack that have pool principal funds destruction impact with the external assumptions, so setting the severity to medium.
deposit() accounts exactly the amount requested as amount deposited (while it can be amount * (1 - fee)
):
Receipt storage receipt = pool.receipts[pool.numReceipts]; receipt.id = pool.numReceipts; receipt.amountDepositedWei = amount; receipt.timeDeposited = block.timestamp; receipt.owner = msg.sender; bool success = IERC20(pool.depositToken).transferFrom(msg.sender, address(this), amount);
withdraw() allows for immediate withdrawal of the same receipt.amountDepositedWei
:
function withdraw(uint poolId, uint receiptId) external { Pool storage pool = pools[poolId]; require(pool.id == poolId, 'Uninitialized pool'); Receipt storage receipt = pool.receipts[receiptId]; require(receipt.id == receiptId, 'Can only withdraw real receipts'); require(receipt.owner == msg.sender || block.timestamp > pool.endTime, 'Can only withdraw your own deposit'); require(receipt.timeWithdrawn == 0, 'Can only withdraw once per receipt');
success = success && IERC20(pool.depositToken).transfer(receipt.owner, receipt.amountDepositedWei);
Some ERC20 tokens incur/can incur the fee on transfers:
https://github.com/d-xo/weird-erc20#fee-on-transfer
If there are plans to support fee on transfer tokens then consider using balance difference approach for the deposit amount detection.
Otherwise, state in the documentation that such tokens aren't supported and warn on the corresponding attack vectors.
#0 - illuzen
2022-05-12T05:27:49Z
Duplicate #34
🌟 Selected for report: hyh
Reward tokens that do not allow for zero amount transfers can prevent user pool exit.
Now it is required that all reward amounts be successfully transferred to a receipt owner and the reward token amount isn't checked in the process.
If withdraw was called at the moment when some reward amount is zero (because either zero time passed or zero slope is set), the withdraw() will revert.
Say once such reward token is there (say with no malicious intent, as it's just a specifics of some valid tokens), user cannot withdraw immediately after deposit as no rewards accrued yet and this token transfer will revert the whole call even if it is one of the many.
As withdraw() the only way for a user to exit pool, her funds will be frozen within.
If slope is set to zero for such a token, either maliciously or mistakenly, the withdrawals are impossible for all the users.
As this is user fund freeze case with external assumptions, setting the severity to medium.
Some ERC20 tokens do not allow for zero amount transfers:
https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers
withdraw() iterates across the set of reward tokens, and requires all transfers to go through:
success = success && IERC20(pool.rewardTokens[i]).transfer(receipt.owner, transferAmount);
Once some is not ok, the whole call reverts. As it's the only way for a user to exit the pool, her funds are frozen until non-zero reward is obtained.
It might never happen as rewardsWeiPerSecondPerToken is allowed to be zero:
function addPool ( uint startTime, uint maxDeposit, uint[] memory rewardsWeiPerSecondPerToken, uint programLengthDays, address depositTokenAddress, address excessBeneficiary, address[] memory rewardTokenAddresses, bytes32 ipfsHash, bytes32 name ) external { Pool storage pool = pools[++numPools]; pool.id = numPools; pool.rewardsWeiPerSecondPerToken = rewardsWeiPerSecondPerToken; pool.startTime = startTime > block.timestamp ? startTime : block.timestamp; pool.endTime = pool.startTime + (programLengthDays * 1 days); pool.depositToken = depositTokenAddress; pool.excessBeneficiary = excessBeneficiary; pool.taxPerCapita = globalTaxPerCapita; require(rewardsWeiPerSecondPerToken.length == rewardTokenAddresses.length, 'Rewards and reward token arrays must be same length');
This way, when one of the reward tokens doesn't allow for zero transfers:
Consider controlling for zero amounts in reward transfer cycle:
for (uint i = 0; i < rewards.length; i++) { + if (rewards[i] > 0) { pool.rewardsWeiClaimed[i] += rewards[i]; pool.rewardFunding[i] -= rewards[i]; uint tax = (pool.taxPerCapita * rewards[i]) / 1000; uint transferAmount = rewards[i] - tax; taxes[poolId][i] += tax; success = success && IERC20(pool.rewardTokens[i]).transfer(receipt.owner, transferAmount); + } }
#0 - illuzen
2022-05-12T04:30:56Z
Duplicate #108
#1 - gititGoro
2022-06-15T00:16:29Z
Unmarking duplicate as FOT and revert-on-zero tokens are different. Consider this issue implicitly acknowledged as sponsor has communicated that badly implemented ERC20 tokens are allowed so long as they respect pool isolation. However, this bug is still a useful boundary condition to consider and so it will not be marked as invalid.
Once reward/deposit tokens decimals differ from 18 the calculations with a hard coded 1e18 will become grossly incorrect.
This will lead either to receiving no rewards: say deposit is USDC with decimals of 6, being divided by 1e18 it adds 1e-12 to the rewards calculations, making it close to zero.
When it is the opposite it's more severe as first reward takers will simply empty the pool.
Rewards calculation mixes up the reward and deposit token amounts as if both have decimals of 18:
rewardsLocal[i] = (secondsDiff * pool.rewardsWeiPerSecondPerToken[i] * receipt.amountDepositedWei) / 1e18;
getMaximumRewards also uses hard coded 1e18:
return pool.rewardsWeiPerSecondPerToken[rewardIndex] * pool.maximumDepositWei * (pool.endTime - pool.startTime) / 1e18;
Record and take into account reward and deposit token decimals.
#0 - illuzen
2022-05-12T06:00:28Z
Duplicate #47
#1 - gititGoro
2022-06-14T02:38:12Z
Reducing severity as rewards represent leakage, not deposits
🌟 Selected for report: horsefacts
Also found by: 0x1f8b, 0xYamiDancho, 0xf15ers, 0xkatana, ACai, AlleyCat, Bruhhh, Dravee, Funen, GimelSec, Hawkeye, IllIllI, MaratCerby, PPrieditis, Picodes, Ruhum, TerrierLover, VAD37, berndartmueller, csanuragjain, defsec, delfin454000, eccentricexit, ellahi, fatherOfBlocks, gzeon, hansfriese, hickuphh3, hyh, ilan, joestakey, juicy, kebabsec, oyc_109, rajatbeladiya, reassor, rfa, robee, samruna, simon135, sorrynotsorry, throttle
75.6564 DAI - $75.66
passThruGate() redirects to a beneficiary
only gate.ethCost
, requiring that msg.value >= gate.ethCost
. As there are no other ways to access native tokens held by this contract, the cumulative excess value, a sum of msg.value - gate.ethCost
, will be permanently frozen within the contract.
Only gate.ethCost
is now forwarded:
(bool sent, bytes memory data) = gate.beneficiary.call{value: gate.ethCost}("");
Pass-though the whole msg.value as the excess is not used:
(bool sent, bytes memory data) = gate.beneficiary.call{value: msg.value}("");
One step process offers no protection for the cases when ownership transfer is performed mistakenly or with any malicious intent.
Adding a modest complexity of an additional step and a delay is a low price to pay for having time to evaluate the change.
/// @notice Changing the owner key /// @dev Only current owner may do this /// @param newOwner the new address that will be owner, old address is no longer owner function setOwner(address newOwner) external ownerOnly { address oldOwner = _owner_; _owner_ = newOwner; emit OwnerUpdated(oldOwner, newOwner); }
/// @notice Change the management key /// @dev Only the current management key can change this /// @param newMgmt the new management key function setManagement(address newMgmt) external managementOnly { management = newMgmt; }
Consider utilizing two-step owner rights transferring process (proposition and acceptance in the separate actions) with a noticeable delay between the steps to enforce the transparency and stability of the system.
Functionality of the system will be unavailable if core config addresses be set to zero.
/// @notice Whoever deploys the contract sets the two privileged keys /// @param _mgmt key that will initially be both management and treeAdder constructor(address _mgmt) { management = _mgmt; treeAdder = _mgmt; }
/// @notice Deployer connects it to MerkleIdentity /// @param _gateMaster address of MerkleIdentity contract, which has exclusive right to call passThruGate constructor(address _gateMaster) { gateMaster = _gateMaster; }
/// @notice Whoever deploys the contract determines the name, symbol and owner. Minter should be MerkleIdentity contract /// @dev names are misspelled on purpose because we already have owners and _owner_ and _name and... /// @param ooner the owner of this contract /// @param minter address (MerkleIdentity contract) that can mint NFTs in this series /// @param nomen name of the NFT series /// @param symbowl symbol for the NFT series constructor(address ooner, address minter, string memory nomen, string memory symbowl) { _owner_ = ooner; // we set it here with no resetting allowed so we cannot commit to NFTs and then reset _minter = minter; _name = nomen; _symbol = symbowl; }
Consider adding zero address checks as operation mistakes mitigation tends to out-weight gas optimization in such core config one time setups.
#0 - illuzen
2022-05-12T09:19:39Z
all duplicates
#1 - KenzoAgada
2022-06-17T15:38:42Z
Issue 1 should be upgraded to high, duplicate of H-01 #48.
#2 - gititGoro
2022-06-23T02:46:38Z
Thanks, @KenzoAgada. Good catch.