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: 4/71
Findings: 3
Award: $2,298.51
π 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
If user pay extra ether
for minting NFT
, then those extra ethers
will be locked in FixedPricePassThruGate
forever. because passThruGate()
of FixedPricePassThruGate
transfer only NFT
cost to gate.beneficiary
and don't return extra amount in msg.value
to buyer
or gate.beneficiary
and that extra amount will be locked in contract forever because there is no functionality in FixedPricePassThruGate
to withdraw them.
This is passThruGate()
of FixedPricePassThruGate
code:
function passThruGate(uint index, address) override external payable { Gate memory gate = gates[index]; require(msg.value >= gate.ethCost, 'Please send more ETH'); // pass thru ether if (msg.value > 0) { // use .call so we can send to contracts, for example gnosis safe, re-entrance is not a threat here (bool sent, bytes memory data) = gate.beneficiary.call{value: gate.ethCost}(""); require(sent, 'ETH transfer failed'); } }
In the begging of the function it checks that require(msg.value >= gate.ethCost, 'Please send more ETH')
, so the function can be called with extra msg.value
and It transfer gate.ethCost
ether
to gate.beneficiary
and don't send remaining 'msg.value' to buyer
or anywhere and those ethers
will be locked in contract forever.
Also in withdraw()
of MerkleIdentity
we have this line:
IPriceGate(tree.priceGateAddress).passThruGate{value: msg.value}(tree.priceIndex, msg.sender);
which calls priceGate
with all the amount user payed, so if user pay extra ether
then that extra amount will be lost forever.
VIM
change passThruGate
to be like this:
(bool sent, bytes memory data) = gate.beneficiary.call{value: msg.value}("");
#0 - illuzen
2022-05-12T05:27:31Z
Duplicate #49
#1 - gititGoro
2022-06-14T02:55:42Z
Duplicate of #48
π Selected for report: unforgiven
1759.5116 DAI - $1,759.51
In getRewards()
of PermissionlessBasicPoolFactory
contract, there is a check to see that receipt is initialized receipt, but the condition used by code will be true for receiptId
equal 0
. because receiptId==0
is not initilized for any pool and the value of pools[poolId].receipts[0].id
will be 0
so the condition receipt.id == receiptId
will be passed on getRewards()
. Any function that depends on getRewards()
to check that if receptId
has deposited fund, can be fooled. right now this bug has no direct money loss, but this function doesn't work as it suppose too.
This is getRewards()
code:
function getRewards(uint poolId, uint receiptId) public view returns (uint[] memory) { Pool storage pool = pools[poolId]; Receipt memory receipt = pool.receipts[receiptId]; require(pool.id == poolId, 'Uninitialized pool'); require(receipt.id == receiptId, 'Uninitialized receipt'); uint nowish = block.timestamp; if (nowish > pool.endTime) { nowish = pool.endTime; } uint secondsDiff = nowish - receipt.timeDeposited; uint[] memory rewardsLocal = new uint[](pool.rewardsWeiPerSecondPerToken.length); for (uint i = 0; i < pool.rewardsWeiPerSecondPerToken.length; i++) { rewardsLocal[i] = (secondsDiff * pool.rewardsWeiPerSecondPerToken[i] * receipt.amountDepositedWei) / 1e18; } return rewardsLocal; }
if the value of receiptId
set as 0
then even so receiptId==0
is not initialized but this line:
require(receipt.id == receiptId, 'Uninitialized receipt');
will be passed, because, receipts start from number 1
and pool.receipts[0]
will have zero value for his fields. This is the code in deposit()
which is responsible for creating receipt objects.
pool.totalDepositsWei += amount; pool.numReceipts++; Receipt storage receipt = pool.receipts[pool.numReceipts]; receipt.id = pool.numReceipts; receipt.amountDepositedWei = amount; receipt.timeDeposited = block.timestamp; receipt.owner = msg.sender;
as you can see pool.numReceipts++
and pool.receipts[pool.numReceipts]
increase numReceipts
and use it as receipts index. so receipnts will start from index 1
.
This bug will cause that getRewards(poolId, 0)
return 0
instead of reverting. any function that depend on reverting of getRewards()
for uninitialized receipts can be excploited by sending receipntId
as 0
. this function can be inside this contract or other contracts. (withdraw
use getRewards
and we will see that we can create WithdrawalOccurred
event for receiptsId
as 0)
VIM
If you want to start from index 1
then add this line too to ensure receipntId
is not 0
too:
require(receiptId > 0, 'Uninitialized receipt');
or we could check for uninitialized receipnts with owner
field as non-zero.
#0 - illuzen
2022-05-11T14:47:08Z
Technically valid, but it is a no-op, nothing bad happens either on withdraw or on getRewards. There are no functions that depend on getRewards reverting, but yes it would be cleaner if we do not allow 0 here.
π Selected for report: Picodes
Also found by: pedroais, unforgiven
In MerkleDropFactory
there is a variable withdrawn[recipient][treeIndex] = hasUserWithdrawnAirdrop
which specify that if some recipient
received his tokens in treeIndex
. but because code don't store leaf data or amount data in this variable, so if there were two leaf with same destination
address, then user can only withdraw one of them and the remaining user fund(if deposited in contract) will be locked in MerkleDropFactory
forever. and because withdraw()
can be called by anyone, attacker can call withdraw()
with leaf that has small amount
and make user to get low amount of tokens (if one of the user's leafs in the tree are created by mistake and has 0
amount)
This is withdrawn()
code:
function withdraw(uint treeIndex, address destination, uint value, bytes32[] memory proof) public { // no withdrawing from uninitialized merkle trees require(treeIndex <= numTrees, "Provided merkle index doesn't exist"); // no withdrawing same airdrop twice require(!withdrawn[destination][treeIndex], "You have already withdrawn your entitled token."); // compute merkle leaf, this is first element of proof bytes32 leaf = keccak256(abi.encode(destination, value)); // storage because we edit MerkleTree storage tree = merkleTrees[treeIndex]; // this calls to MerkleLib, will return false if recursive hashes do not end in merkle root require(tree.merkleRoot.verifyProof(leaf, proof), "The proof could not be verified."); // close re-entrance gate, prevent double claims withdrawn[destination][treeIndex] = true; // update struct tree.tokenBalance -= value; tree.spentTokens += value; // transfer the tokens // NOTE: if the token contract is malicious this call could re-enter this function // which will fail because withdrawn will be set to true require(IERC20(tree.tokenAddress).transfer(destination, value), "ERC20 transfer failed"); emit WithdrawalOccurred(treeIndex, destination, value); }
As you can see in withdraw
contract set withdrawn[destination][treeIndex] = true
and also in the beginning it checks that: require(!withdrawn[destination][treeIndex], "You have already withdrawn your entitled token.")
.
So if there were two leaf with same destination
value (for example one of them is created by mistake with amount=0
) then attacker can do this steps:
withdraw()
with one of the destination
's leafs info, that has lower amount
(could be zero if that leaf added there by mistake). withdrawn[destination][treeIndex] = true
for that destination
.destination
can't call withdraw()
to get his other leaf (real one) tokens because contract will check and see that this user has already withdrawn his funds.creator
deposited that tokens in MerkleDropFactory
then they will be locked forever.VIM
contract should keep track of every leaf withdraws, and don't assume that for each destination we only have one leaf.
#0 - illuzen
2022-05-12T05:20:57Z
This is addressed in a comment in MerkleResistor, but should be more visible.
Duplicate #148
#1 - itsmetechjay
2022-05-12T19:05:39Z
Re-closing as duplicate.
#2 - gititGoro
2022-06-15T00:59:37Z
Reducing severity.