Platform: Code4rena
Start Date: 10/05/2022
Pot Size: $50,000 USDC
Total HM: 13
Participants: 100
Period: 5 days
Judge: HardlyDifficult
Total Solo HM: 1
Id: 122
League: ETH
Rank: 14/100
Findings: 6
Award: $1,483.46
🌟 Selected for report: 0
🚀 Solo Findings: 0
621.8845 USDC - $621.88
createVault
does not require check to prevent user from setting Cally.sol
as token address. It is bad idea all around.
User can create vault using cally vault NFT or optionId NFT as auction reward. This open up many creative ways to exploit contract.
I did not found ways to exploit buyOption
, exercise
and withdraw
to steal optionId from burn address, or worst double withdrawal. But it doesn't mean someone else can figure it out.
The worst I have found is I can spam vault creation with just one NFT. The Cally.sol
will hold lots of its own vault NFT.
Unexpected use of contract = Medium severity.
Block user from create vault with cally address. It is easy to do so.
require(token != address(this), "Invalid token");
Deployed Rinkeby testnet contract
#0 - outdoteth
2022-05-15T13:10:18Z
Agree that even though no direct issue is given, this is still generally unexpected behaviour. It makes little sense to create vaults on vaults.
#1 - outdoteth
2022-05-15T20:57:50Z
creating a vault using a cally vault/option NFT as the asset can lead to phishing: https://github.com/code-423n4/2022-05-cally-findings/issues/224
🌟 Selected for report: BondiPestControl
Also found by: 0xf15ers, GimelSec, IllIllI, MadWookie, MiloTruck, Ruhum, VAD37, berndartmueller, cccz, csanuragjain, dipp, hake, horsefacts, jayjonah8, m9800, pedroais, throttle
31.6149 USDC - $31.61
Judge has assessed an item in Issue #182 as Medium risk. The relevant finding follows:
Link. require(msg.value >= premium) should be require(msg.value == premium) to prevent user send too much eth. Only beneficiary benefit from this. Contract should protect user from these nuance mistakes.
#0 - HardlyDifficult
2022-06-14T22:30:51Z
Dupe of #84
🌟 Selected for report: hickuphh3
Also found by: BondiPestControl, GimelSec, VAD37, sseefried
447.7568 USDC - $447.76
vault.durationDays
is uint8
. When multiply by 1 days
, it overflows when result value > type(uint24).max = 16777216
or 195 * 86400 = 16848000
.
No one can buyOption
for vault with duration >= 195 days
Forge fuzz test caught by accident
Add this code to CreateVault.t.sol
. Run with forge test --match-test Fuzz -vvv
function testItFuzzCreatesVault( uint8 premiumIndex, uint8 durationDays, uint8 dutchAuctionStartingStrikeIndex ) public { vm.assume(premiumIndex < 17); vm.assume(durationDays > 190); vm.assume(dutchAuctionStartingStrikeIndex < 19); uint256 vaultId = c.createVault( 1, address(bayc), premiumIndex, durationDays, dutchAuctionStartingStrikeIndex, 0, Cally.TokenType.ERC721 ); // assert Cally.Vault memory vault = c.vaults(vaultId); emit log_named_uint("days",durationDays); uint premium = c.getPremium(vaultId); // Other test does not run a full system. So add buyOption here show the problem when duration days > 195 uint256 optionId = c.buyOption{value: premium}(vaultId); assertEq(optionId, vaultId + 1, "Option ID should be 1 less than vault ID"); }
Explicit convert uint8 to uint32 before multiply solve the problem.
(vault.durationDays * 1 days)
with ( uint32(vault.durationDays) * 1 days)
The reason why it overflow at uint24
is unknown to me and it shouldn't happen.
To be safe, the project should add some system testing. With current unit testing, there might be some weird bugs. Hence, I only caught it by chance while testing something else.
#0 - outdoteth
2022-05-15T17:20:52Z
reference issue: https://github.com/code-423n4/2022-05-cally-findings/issues/16
🌟 Selected for report: 0x1337
Also found by: 0x52, 0xDjango, 0xsanson, BondiPestControl, BowTiedWardens, GimelSec, IllIllI, MaratCerby, MiloTruck, PPrieditis, TrungOre, VAD37, WatchPug, berndartmueller, cccz, dipp, hake, hickuphh3, horsefacts, hubble, minhquanym, reassor, shenwilly, smiling_heretic
10.8874 USDC - $10.89
Lots of meme token put fee on transfer. The receiving amount always lesser than what user input.
If any ERC20 token can show up on the website, user can buyOption
of token that cannot be withdrawed
User create vault with 1000 token. Cally mint vault as 1000 token But contract receive 900 token. Lost 10% fee on transfer.
Any user can still buyOption
on vault as normal. Vault beneficiary got premium fee.
When exercise, vault does not have enough token to transfer to buyer.
Either beneficiary or buyer have to add extra 11% original token to cover transfer fee. Otherwise, no one can withdraw this vault.
Either project explicit define whitelist token on website or support all token include meme coin. (Some mainstream token like USDT also have fee on transfer but currently at 0%)
I would recommend just flat out warning user from bidding on meme coin and ignore this. This would consider as user fault and not contract fault.
Otherwise, change these line to a simple version of this.
if(vault.tokenType == TokenType.ERC721 ) { ERC721(vault.token).transferFrom(msg.sender, address(this), vault.tokenIdOrAmount); } else { // This also prevent EOA set as token uint before = ERC20(vault.token).balanceOf(address(this)); ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount); uint afterTransfer = ERC20(vault.token).balanceOf(address(this)); uint diff = afterTransfer - before; // Note this is not Vault memory location but Vault storage pointer location // No need to set _vaults[vaultId] = vault; after transfer vault.tokenIdOrAmount = diff; }
#0 - outdoteth
2022-05-15T17:14:10Z
reference issue: https://github.com/code-423n4/2022-05-cally-findings/issues/39
🌟 Selected for report: hubble
Also found by: 0x1337, 0x1f8b, 0x4non, 0xDjango, 0xf15ers, 0xsanson, 242, Aits, AlleyCat, Bludya, BondiPestControl, BouSalman, BowTiedWardens, CertoraInc, Cityscape, Czar102, FSchmoede, Funen, Hawkeye, IllIllI, JDeryl, Kenshin, Kumpa, MaratCerby, MiloTruck, Picodes, Ruhum, TrungOre, VAD37, WatchPug, Waze, antonttc, bobirichman, catchup, cccz, cryptphi, csanuragjain, delfin454000, dipp, dirk_y, djxploit, eccentricexit, ellahi, fatherOfBlocks, hake, hansfriese, hickuphh3, horsefacts, hyh, jah, joestakey, mics, minhquanym, pedroais, pmerkleplant, radoslav11, reassor, rfa, robee, seanamani, shenwilly, shung, sikorico, sorrynotsorry, sseefried, z3s
322.3784 USDC - $322.38
If setFee set higher than 1e18. exercise
will underflow.
During live production, it is possible to accidentally set fee higher than it suppose to be.
Cap max fee 5%,10%,20%(20e17) would prevent any accident or malicious owner handling.
buyOption
premium requiredLink.
require(msg.value >= premium)
should be require(msg.value == premium)
to prevent user send too much eth. Only beneficiary benefit from this. Contract should protect user from these nuance mistakes.
withdraw
and exercise
already implement check and effect parttern. There is no risk of reentrancy with ERC721 safeTransferFrom
. This provides service for user who use special NFT wallet. Or simply prevent user from withdraw NFT to unsupported contract.
CreateVault
does not check if token have extCodeSize > 0
or not. User can create vault with EOA address as ERC20 token address (tokenType
= ERC721 throw error). Interact vault with buyOption
and exercise
work as normal. This happens due to external raw call
to EOA always return success. Interface ERC721 wrap address call does not.
Contract should prevent create accident vault with non-contract address.
#0 - outdoteth
2022-05-16T18:45:13Z
these can be bumped to medium severity: L-Missing SetMaxFee; https://github.com/code-423n4/2022-05-cally-findings/issues/48 L-Can send ETH more than buyOption premium required: https://github.com/code-423n4/2022-05-cally-findings/issues/84 N-Consider use ERC721 SafeTransferFrom instead of transferFrom; https://github.com/code-423n4/2022-05-cally-findings/issues/38
this can be bumped to high risk: L-Can create vault with EOA address as ERC20 token address: https://github.com/code-423n4/2022-05-cally-findings/issues/225
#1 - HardlyDifficult
2022-05-29T15:23:54Z
Although this report touches on some important changes, it's lacking detail about the potential risk / abuse that can result. Per the C4 guidance "part of auditing is demonstrating proper theory of how an issue could be exploited". Going to score this as a high quality QA report instead.
#2 - HickupHH3
2022-06-08T05:57:42Z
For L-Can send ETH more than buyOption premium required: "to prevent user send too much eth. Only beneficiary benefit from this. " seems sufficient to me for explaining the issue. How is the description different from #245 for instance? It is also inconsistent with the decision given for #56: "the more important point is they identified an issue"
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xf15ers, 0xkatana, 0xsanson, Bludya, BowTiedWardens, CertoraInc, Cityscape, DavidGialdi, FSchmoede, Fitraldys, Funen, Hawkeye, Kenshin, MadWookie, MaratCerby, MiloTruck, Picodes, RagePit, Tadashi, TerrierLover, TomFrenchBlockchain, VAD37, WatchPug, Waze, _Adam, antonttc, bobirichman, catchup, defsec, delfin454000, djxploit, ellahi, fatherOfBlocks, gzeon, hake, hansfriese, hickuphh3, horsefacts, ignacio, joestakey, jonatascm, mics, minhquanym, oyc_109, pmerkleplant, rfa, robee, rotcivegaf, samruna, shung, sikorico, simon135, z3s
48.9442 USDC - $48.94
!=0
better than >0
for 1 gasI am really not sure why everyone keep posting this tweet as proof.
But I test it and !=0
only save 1 gas with max optimization in version 0.8.13
.
Only work with require in createVault and not if comparision.
auctionStartTimeStamp
Inline these line
because cache uint32 auctionStartTimestamp
only use once with require check.
require(block.timestamp >= vault.currentExpiration, "Auction not started");
Use storage
memory pointer instead of memory
copy struct save quite lots of gas.
function buyOption(uint256 vaultId) external payable returns (uint256 optionId) { Vault storage vault = _vaults[vaultId]; //@gas use storage instead of memory // vaultId should always be odd require(vaultId % 2 != 0, "Not vault type"); // check vault exists require(ownerOf(vaultId) != address(0), "Vault does not exist"); // check that the vault still has the NFTs as collateral require(vault.isExercised == false, "Vault already exercised"); // check that the vault is not in withdrawing state require(vault.isWithdrawing == false, "Vault is being withdrawn"); // check enough eth was sent to cover premium uint256 premium = _getPremium(vault); //@gas boilerplate _internal func version with storage pointer could save gas // premiumOptions[vault.premiumIndex] require(msg.value >= premium, "Incorrect ETH amount sent"); //@audit-ok premium should be equal unnecessary send more than required // check option associated with the vault has expired require(block.timestamp >= vault.currentExpiration, "Auction not started"); //@gas inline // set new currentStrike vault.currentStrike = getDutchAuctionStrike( strikeOptions[vault.dutchAuctionStartingStrikeIndex], vault.currentExpiration + AUCTION_DURATION, //@gas inline startTimetamp vault.dutchAuctionReserveStrike ); // set new expiration vault.currentExpiration = uint32(block.timestamp) + (vault.durationDays * 1 days); // update the vault with the new option expiration and strike // _vaults[vaultId] = vault; //@gas replace with storage pointer // force transfer the vault's associated option from old owner to new owner // option id for a respective vault is always vaultId + 1 optionId = vaultId + 1; _forceTransfer(msg.sender, optionId); // increment vault beneficiary's unclaimed premiums address beneficiary = getVaultBeneficiary(vaultId); ethBalance[beneficiary] += msg.value; emit BoughtOption(optionId, msg.sender, vault.token); } function getPremium(uint256 vaultId) public view returns (uint256 premium) { Vault memory vault = _vaults[vaultId]; // @gas can calldata or storage pointer cheaper than memory copy? return premiumOptions[vault.premiumIndex]; } function _getPremium(Vault storage vault) internal view returns (uint256 premium) { return premiumOptions[vault.premiumIndex]; }
Only have to replace Vault memory
with Vault storage
here
Vault memory vault = _vaults[vaultId];
with Vault storage vault = _vaults[vaultId];
This does not save much gas and entirely style optional. Only report for completion.
function createVault( uint256 tokenIdOrAmount, address token, //@audit-ok does not check zero-address token uint8 premiumIndex, uint8 durationDays, //@audit-ok durationDays can be any. uint8 dutchAuctionStartingStrikeIndex, uint256 dutchAuctionReserveStrike, //@audit-ok what happen when reserve strike is 0? TokenType tokenType ) external returns (uint256 vaultId) { require(premiumIndex < premiumOptions.length, "Invalid premium index"); require(dutchAuctionStartingStrikeIndex < strikeOptions.length, "Invalid strike index"); require(dutchAuctionReserveStrike < strikeOptions[dutchAuctionStartingStrikeIndex], "Reserve strike too small"); //@audit-ok dutchAuctionReserveStrike cannot be max? could be <=? require(durationDays != 0, "durationDays too small"); //@gas use !0 instead of > 0 require(tokenType == TokenType.ERC721 || tokenType == TokenType.ERC20, "Invalid token type");//@gas can we use < number for enum require? // vault index should always be odd vaultIndex += 2; vaultId = vaultIndex; // @gas inline set Vault _vaults[vaultId] = Vault({ tokenIdOrAmount: tokenIdOrAmount, token: token, premiumIndex: premiumIndex, durationDays: durationDays, dutchAuctionStartingStrikeIndex: dutchAuctionStartingStrikeIndex, currentExpiration: uint32(block.timestamp), isExercised: false, isWithdrawing: false, tokenType: tokenType, currentStrike: 0, dutchAuctionReserveStrike: dutchAuctionReserveStrike }); // give msg.sender vault token _mint(msg.sender, vaultId); emit NewVault(vaultId, msg.sender, token); // transfer the NFTs or ERC20s to the contract //@gas use local variable instead of read from vault struct tokenType == TokenType.ERC721 ? ERC721(token).transferFrom(msg.sender, address(this), tokenIdOrAmount) //@audit vault.token can this address be cally address? : ERC20(token).safeTransferFrom(msg.sender, address(this), tokenIdOrAmount);// @audit we can transfer our own NFTvault to vault. And do it again in a loop. Hidden vault NFT behind several layer }
#0 - outdoteth
2022-05-16T20:21:50Z
high quality report