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: 9/100
Findings: 8
Award: $2,689.77
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: IllIllI
Also found by: 0x52, 0xf15ers, 0xsanson, Bludya, BondiPestControl, Czar102, GimelSec, Kumpa, _Adam, berndartmueller, catchup, crispymangoes, eccentricexit, ellahi, hake, horsefacts, pedroais, peritoflores, reassor, shenwilly, shung, smiling_heretic, sseefried, throttle
8.1655 USDC - $8.17
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L119-L121 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L282-L286 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L124-L128
Since there is no cap on feeRate
, the owner can set any feeRate
he wants by calling setFee()
. And feeRate
is used in exercise()
to collect protocol fees. A malicious owner can monitor the transactions. When he notices a transaction involving a great amount of ETH, he can then launch a front-running attack. Set a 100% fee rate to steal all the ETHs.
exercise()
with a great amount of ETH. (e.g., 6765 ether)setFee()
to set a 100% fee rate before Bob’s transaction.There is no cap on feeRate
when calling setFee()
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L119-L121
function setFee(uint256 feeRate_) external onlyOwner { feeRate = feeRate_; }
exercise()
take all the ETHs sent from Bobhttps://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L282-L286
function exercise(uint256 optionId) external payable { … // collect protocol fee uint256 fee = 0; if (feeRate > 0) { fee = (msg.value * feeRate) / 1e18; protocolUnclaimedFees += fee; } … }
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L124-L128
function withdrawProtocolFees() external onlyOwner returns (uint256 amount) { amount = protocolUnclaimedFees; protocolUnclaimedFees = 0; payable(msg.sender).safeTransferETH(amount); }
None
Add a timelock on setFee()
or set a cap on feeRate
to remove the benefit of front-running.
#0 - outdoteth
2022-05-15T19:19:46Z
owner can change fee at any time; https://github.com/code-423n4/2022-05-cally-findings/issues/47 owner can set fee greater than 100%: https://github.com/code-423n4/2022-05-cally-findings/issues/48
621.8845 USDC - $621.88
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L158
Users can re-vault vault token
in this protocol. However, attackers can deceive buyers to pay high ETH but will not be able to get the underlying asset.
createVault
to vault a NFT which worth 100 ETH, with parameters:
dutchAuctionStartingStrikeIndex
is set to 0 (which strikeOptions
is 1 ETH)durationDays
, e.g. 255
daysvaultId 1
token, and Alice do another transaction: call buyOption(1)
to get a optionId 2
tokenvaultId 1
token with strike 89 ETH, and get a vaultId 3
tokenvaultId 3
token is 89 ETH, but the vaultId 3
token can get the NFT which worth 100 ETH, so Bob pays 89 ETH, calls buyOption(3)
, and exercise(4)
to get the vaultId 1
token. Then, Bob calls initiateWithdraw(1)
and waits for the optionId 2
token to expire (which durationDays
is set to 255
days in step 1).vaultId 1
token, then Alice quickly calls exercise(2)
. Finally, Alice just pays Bob 1 ETH, and gets the NFT back. Alice also gets 89 ETH which is paid by Bob from the vaultId 3
token.vim
Check the token
address should not be address(this)
in createVault
.
#0 - outdoteth
2022-05-15T20:58:12Z
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
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L224
If a user sends more ETH than the user has to, the contract just accepts it. The user will lose more ETH accidentally.
The buyOption
function in Cally.sol:
require(msg.value >= premium, "Incorrect ETH amount sent");
vim
Use ==
rather than >=
:
require(msg.value == premium, "Incorrect ETH amount sent");
#0 - outdoteth
2022-05-15T17:01:53Z
reference issue: https://github.com/code-423n4/2022-05-cally-findings/issues/84
🌟 Selected for report: hickuphh3
Also found by: BondiPestControl, GimelSec, VAD37, sseefried
We list 1 low-critical finding:
buyOption
buyOption
When users call buyOption
, it will be reverted if durationDays
is more than 195
.
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L238
238 vault.currentExpiration = uint32(block.timestamp) + (vault.durationDays * 1 days);
(vault.durationDays * 1 days)
will fit in uint24
(max 16777216). It will overflow if vault.durationDays
is more than 195 (195 * 86400 = 16848000).
Convert to uint32:
238 vault.currentExpiration = uint32(block.timestamp) + uint32(vault.durationDays) * 1 days;
#0 - outdoteth
2022-05-16T18:52:55Z
this can be bumped to high severity; (low) Users will not be able to call buyOption: https://github.com/code-423n4/2022-05-cally-findings/issues/16
16.9712 USDC - $16.97
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L119-L121 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L282-L286
The feeRate
is unlimited in Cally.sol
, leading to maliciously moving large taxes from this contract.
There is no cap on feeRate
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L119-L121
function setFee(uint256 feeRate_) external onlyOwner { feeRate = feeRate_; }
When collecting protocol fee, unlimited feeRate
could lead to maliciously obtaining large amounts of ETH from users
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L282-L286
function exercise(uint256 optionId) external payable { … // collect protocol fee uint256 fee = 0; if (feeRate > 0) { fee = (msg.value * feeRate) / 1e18; protocolUnclaimedFees += fee; } … }
None
There should be a reasonable cap on feeRate
. (e.g., 10%)
#0 - outdoteth
2022-05-15T19:19:00Z
owner can set fee greater than 100%: https://github.com/code-423n4/2022-05-cally-findings/issues/48
🌟 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
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L174 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L198-L200
Some ERC20 tokens have a fee on each transfer
. The protocol doesn’t handle the fee when transferring this kind of ERC20 tokens, leading to the inconsistent amount of token actually received in the contract. This will cause users to fail to exercise options due to not enough tokens in the protocol, or the protocol will lose more tokens to compensate users for exercising options.
In the createVault
function, the amount of token is recorded in tokenIdOrAmount
. However, if the ERC20(vault.token)
collects a fee on every transfer. The actual amount of token received is not equal to tokenIdOrAmount
. vault.tokenIdOrAmount
will record more amount of token than it received.
Vault memory vault = Vault({ tokenIdOrAmount: tokenIdOrAmount, ... }); ... // transfer the NFTs or ERC20s to the contract vault.tokenType == TokenType.ERC721 ? ERC721(vault.token).transferFrom(msg.sender, address(this), vault.tokenIdOrAmount) : ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount);
vim
Use balanceAfter - balanceBefore
rather than using amount
directly:
function retrieveTokens(address sender, uint256 amount) public { uint256 balanceBefore = deflationaryToken.balanceOf(address(this)); deflationaryToken.transferFrom(sender, address(this), amount); uint256 balanceAfter = deflationaryToken.balanceOf(address(this)); amount = balanceAfter.sub(balanceBefore); totalTokenTransferred += amount; }
Token Transfer Calculation Accuracy
in this article: https://blog.chain.link/defi-security-best-practices/
#0 - outdoteth
2022-05-15T17:16:20Z
reference issue: https://github.com/code-423n4/2022-05-cally-findings/issues/39
16.9712 USDC - $16.97
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L295 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L344
It doesn't check whether users will handle ERC721 received. If a user is a contract but doesn’t handle ERC721 received, the ERC721 token will be frozen when receiving ERC721 tokens.
vault.tokenType == TokenType.ERC721 ? ERC721(vault.token).transferFrom(address(this), msg.sender, vault.tokenIdOrAmount) : ERC20(vault.token).safeTransfer(msg.sender, vault.tokenIdOrAmount);
It doesn’t check whether the receiver has implemented the onERC721Received
function. If a user buys an option but the user doesn't handle ERC721 received, the ERC721(vault.token)
will be frozen after the user exercises the option.
vim
Use safeTransferFrom
rather than transferFrom
when transferring ERC721 tokens to users.
#0 - outdoteth
2022-05-15T20:48:35Z
use safeTransferFrom to prevent stuck NFTs: https://github.com/code-423n4/2022-05-cally-findings/issues/38
1535.5172 USDC - $1,535.52
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L158-L201 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L296 https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L345
When calling createVault()
, tokenType
could be different from token
’s type. If a user accidentally used the wrong tokenType
, it could lead to two different results.
If token
is an ERC20 token and the user uses TokenType.ERC721
as tokenType
. It is less harmful, since ERC721(vault.token).transferFrom(msg.sender, address(this), vault.tokenIdOrAmount)
still works when vault.token
is actually ERC20 token.
However, if token
is an ERC721 token and the user uses TokenType.ERC20
as tokenType
. When doing creatVault()
, ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount)
works fine. But when doing exercise()
or withdraw()
, ERC20(vault.token).safeTransfer(msg.sender, vault.tokenIdOrAmount);
doesn’t work since ERC721 doesn’t implement safeTransfer()
function. In consequence, the ERC721 token is frozen in the vault.
createVault()
does not confirm whether tokenType
and token
’s type are the same.
But the token can still be transferred into this contract. Since transferFrom()
is implemented in ERC20 and safeTransferFrom()
is implemented in ERC721
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L158-L201
function createVault( uint256 tokenIdOrAmount, address token, uint8 premiumIndex, uint8 durationDays, uint8 dutchAuctionStartingStrikeIndex, uint256 dutchAuctionReserveStrike, 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"); require(durationDays > 0, "durationDays too small"); require(tokenType == TokenType.ERC721 || tokenType == TokenType.ERC20, "Invalid token type"); Vault memory vault = 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 }); // vault index should always be odd vaultIndex += 2; vaultId = vaultIndex; _vaults[vaultId] = vault; // give msg.sender vault token _mint(msg.sender, vaultId); emit NewVault(vaultId, msg.sender, token); // transfer the NFTs or ERC20s to the contract vault.tokenType == TokenType.ERC721 ? ERC721(vault.token).transferFrom(msg.sender, address(this), vault.tokenIdOrAmount) : ERC20(vault.token).safeTransferFrom(msg.sender, address(this), vault.tokenIdOrAmount); }
However when doing exercise()
or withdraw()
, it always reverts since ERC721 doesn’t implement safeTransfer()
. The ERC721 token is frozen in the contract.
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L296
function exercise(uint256 optionId) external payable { … // transfer the NFTs or ERC20s to the exerciser vault.tokenType == TokenType.ERC721 ? ERC721(vault.token).transferFrom(address(this), msg.sender, vault.tokenIdOrAmount) : ERC20(vault.token).safeTransfer(msg.sender, vault.tokenIdOrAmount); }
https://github.com/code-423n4/2022-05-cally/blob/main/contracts/src/Cally.sol#L345
function withdraw(uint256 vaultId) external nonReentrant { … // transfer the NFTs or ERC20s back to the owner vault.tokenType == TokenType.ERC721 ? ERC721(vault.token).transferFrom(address(this), msg.sender, vault.tokenIdOrAmount) : ERC20(vault.token).safeTransfer(msg.sender, vault.tokenIdOrAmount); }
None
Confirm whether tokenType
and token
’s type are the same in createVault()
.
#0 - outdoteth
2022-05-16T10:20:39Z
#1 - HardlyDifficult
2022-05-24T23:14:09Z
There were a lot of reports recommending a similar change, but this is one of the few that points our a critical issue that could arise in the current state.
Although the issue only occurs when the original vault creator makes a user error, the fact that their NFT becomes unrecoverable makes this a Medium Risk concern.