Platform: Code4rena
Start Date: 29/06/2022
Pot Size: $50,000 USDC
Total HM: 20
Participants: 133
Period: 5 days
Judge: hickuphh3
Total Solo HM: 1
Id: 142
League: ETH
Rank: 19/133
Findings: 4
Award: $842.77
π Selected for report: 0
π Solo Findings: 0
π Selected for report: IllIllI
Also found by: 0x29A, 0xDjango, 0xc0ffEE, AmitN, BowTiedWardens, StErMi, auditor0517, berndartmueller, cccz, danb, dipp, dirk_y, hansfriese, horsefacts, hyh, kirk-baird, oyc_109, peritoflores, rfa, sseefried, swit, xiaoming90, zzzitron
5.5216 USDC - $5.52
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L228 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L240 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L436
When users call either fillOrder
or exercise
it is possible to send ETH to the contract accidentally. This happens when weth != order.baseAsset
but msg.value > 0
.
Also, the functions setFee
and setBaseURI
are marked as payable
which further allows ETH to be sent to the contract.
Once this excess ETH is in the contract there is no way to get it back out since there are no functions to recover it.
Let weth != order.baseAsset
but have msg.value > 0
When fillOrder
is called line 338 or line 360 can be executed.
When exercise
is called line 436 can be executed.
In all cases it is possible for msg.value > 0
to be true when these lines are executed. The order.baseAsset
tokens are transferred but excess ETH is also locked in the contract.
Also setBaseURI and setFee can lock ETH since they are marked payable
.
Change the logic in the relevant places in fillOrder
and exercise
to something like the following:
if (weth == order.baseAsset && msg.value > 0) { ... deposit weth and transfer } else { require(msg.value == 0, "ETH shouldn't be sent"); ... transfer base asset ... }
Fortunately, the expected behaviour of the Putty contract works in our favour for getting the ETH back out. Whenever ETH is sent as a substitute for order.baseAsset == weth
the expected behaviour is that the ETH will be deposited into the weth
contract and transferred to the contract. Thus, there should never be excess ETH in the Putty contract. Thus, any left-over ETH must have been put there by accident.
Simply add a function to withdraw it:
function withdrawExcessETH(address to) public onlyOwner { (bool success,) = to.call{value: address(this).balance}(""); require(success, "ETH not sent"); }
#0 - rotcivegaf
2022-07-04T23:45:00Z
Duplicate of #226 and #259
#1 - fatherGoose1
2022-07-05T00:37:09Z
Regarding "when weth != order.baseAsset but msg.value > 0", duplicate of #28
#2 - outdoteth
2022-07-06T19:26:22Z
Duplicate: Native ETH can be lost if itβs not utilised in exercise and fillOrder: https://github.com/code-423n4/2022-06-putty-findings/issues/226
π Selected for report: kirk-baird
Also found by: sseefried
622.994 USDC - $622.99
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L454 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L601 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/lib/solmate/src/utils/SafeTransferLib.sol#L51 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L454 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L638
This submission contains a link to a private fork of the contest repo. Please contact warden @sseefried for GitHub collaborator access.
A user can put in an order for a Long Put Option but -- either accidentally or not -- put an ERC721 asset in the erc20Assets
field of an order. Because ERC20 (via SafeTransferLib
) and ERC721 both implement the safeTransferFrom
function (and with the same signature), the ERC721 asset is successfully transferred to the contract when exercise
is called but fails to transfer out of the contract when withdraw
is called.
The impact is that the ERC721 asset becomes locked in the Putty contract.
tokenId = 42
into the erc20Assets
field i.e. Declare ERC20Asset erc20Asset
and set erc20Asset.token = <ERC721 token address>
and erc20Asset.tokenAmount = 42
(42
is also a valid amount).exercise
_transferERC20In
.safeTransferFrom
function from SafeTransferLib
. This will perform a call to function ERC721.safeTransferFrom
(which just so happens to have the same signature as ERC20.safeTransferFrom
).onERC721Received
function. Notice that there is no return value from ERC721.safeTransferFrom
.iszero(returndatasize())
is true!withdraw
.
_transferERC20sOut
.SafeTransferLib.safeTransfer
is called (not SafeTransferLib.safeTransferFrom
). This attempts to call ERC721.safeTransfer
which fails since this function doesn't exist. The result is that ERC721.safeTransfer
reverts with message TRANSFER_FAILED
and the ERC721 is locked in the Putty contract.An executable PoC can be found here.
Consider using OpenZeppelin's IERC165 contract to see if tokens passed in implement the ERC20 interface. Also see EIP165.
#0 - outdoteth
2022-07-07T13:46:04Z
Duplicate: If an ERC721 token is used in places where ERC20 assets are supposed to be used then ERC721 tokens can get stuck in withdraw() and exercise(): https://github.com/code-423n4/2022-06-putty-findings/issues/52
π Selected for report: Picodes
Also found by: 0xNineDec, 0xsanson, Metatron, antonttc, berndartmueller, catchup, dirk_y, sseefried, unforgiven
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L268 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L240
Function setFee
can be called at any time, including after fillOrder
has been called. This could be deemed unfair to the user in the Short Position, since they created the order at a particular point in time.
The impact is that they may end up paying more fees than they expected when they call withdraw
.
5
(i.e. 0.5%).fillOrder
is called on the order.setFee
increasing it to 30
(i.e. 3%)withdraw
(either when Call and Exercised, or when Put and not Exercised). They end up paying 3% in fees instead of 0.5%.Add a new mapping variable that records the fee
at the time fillOrder
is called.
mapping (uint256 => uint256) public positionFees;
Then make the following edits to fillOrder
and withdraw
.
Edit for function fillOrder
:
Order memory order, bytes calldata signature, uint256[] memory floorAssetTokenIds ) public payable returns (uint256 positionId) { ... positionExpirations[order.isLong ? uint256(orderHash) : positionId] = block.timestamp + order.duration; // Saves fee at at time fillOrder called positionFees[order.isLong ? positionId : uint256(orderHash)] = fee; ...
Edit for function withdraw
:
function withdraw(Order memory order) public { ... if ((order.isCall && isExercised) || (!order.isCall && !isExercised)) { // send the fee to the admin/DAO if fee is greater than 0% uint256 feeAmount = 0; uint256 positionFee = positionFees[uint256(orderHash)]; if (positionFee > 0) { feeAmount = (order.strike * positionFee) / 1000; ERC20(order.baseAsset).safeTransfer(owner(), feeAmount); } ERC20(order.baseAsset).safeTransfer(msg.sender, order.strike - feeAmount); return; } ...
#0 - outdoteth
2022-07-06T18:35:03Z
Duplicate: Admin can change fee at any time for existing orders: https://github.com/code-423n4/2022-06-putty-findings/issues/422
π Selected for report: xiaoming90
Also found by: 0x1f8b, 0x29A, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 0xSolus, 0xf15ers, 0xsanson, AmitN, Bnke0x0, BowTiedWardens, Chom, David_, ElKu, Funen, GalloDaSballo, GimelSec, Hawkeye, IllIllI, JC, JohnSmith, Kaiziron, Kenshin, Lambda, Limbooo, MadWookie, Metatron, MiloTruck, Nethermind, Picodes, ReyAdmirado, Sneakyninja0129, StErMi, TomJ, Treasure-Seeker, TrungOre, Waze, Yiko, _Adam, __141345__, antonttc, async, aysha, catchup, cccz, cryptphi, csanuragjain, danb, datapunk, defsec, delfin454000, dirk_y, doddle0x, durianSausage, exd0tpy, fatherOfBlocks, gogo, hake, hansfriese, horsefacts, hubble, itsmeSTYJ, joestakey, oyc_109, pedroais, peritoflores, rajatbeladiya, reassor, robee, rokinot, samruna, saneryee, sashik_eth, shenwilly, shung, simon135, sseefried, unforgiven, zer0dot, zzzitron
164.1296 USDC - $164.13
Consider adding blacklisting functionality for rebase and fee-on-transfer tokens. It will really help prevent user error.
Consider using a constant instead of 1000
as a magic number on line 499
Consider using a constant instead of 30
as a magic number on line 241
Functions setBaseURI
andsetFee
are both payable
when they don't appear to need to be. See my Medium Risk submission for how this can lead to ETH being locked in the contract.
Consider adding a blacklist
to order for greater ease of permissioning. If there is even a single whitelist entry then only those people are permissioned. Perhaps you just want to block just one address?
Rename setFee
to setFeeRate
. It's worth keeping those concepts distinct.
Change NewFee
to NewFeeRate
. I think of "fee" as the amount and "fee rate" as the rate the fee is calculated with.
I must commend you on adding line 719 to hashOrder
. This ensures that address(this)
is part of the input to the final hash. Without this, Replay Attacks would have been possible on a copy of the Putty contract.
PuttyV2.sol:679
"it's" -> "its"line 135 "Fee rate that is applied on exercise" -> "Fee rate that is applied on withdraw"
On lines 562-563 we have:
/** @notice Accepts a counter offer for an order. It fills the counter offer, and then cancels the original order that the counter offer was made for. .. */
But the cancel
comes first, and then the order is filled.
cancel
should fail if fillOrder
has been calledA user may falsely believe that they have cancelled an order even though it has already been filled with fillOrder
, since cancel
does not revert in this case.
Also a false CancelledOrder
event is emitted in this case.
fillOrder
is called on this ordercancel
and it succeeds, also emitting a CancelledOrder
event.Make the following changes to function cancel
:
function cancel(Order memory order) public { require(msg.sender == order.maker, "Not your order"); bytes32 orderHash = hashOrder(order); uint256 filledExpiry = positionExpirations[order.isLong ? uint256(orderHash) : uint256(hashOppositeOrder(order))]; require(filledExpiry == 0, "Order has already been filled"); // mark the order as cancelled cancelledOrders[orderHash] = true; emit CancelledOrder(orderHash, order); }
acceptCounterOffer
should check that order
is opposite of originalOrder
and that order.maker
is not the original order makerFunction acceptCounterOffer
will cancel
an original order and then call fillOrder
on the counter-offer order. However, for this to work properly the counter-offer order must be the opposite kind of order. e.g. a Long if the original was a Short or vice versa. Additionally the maker
of the counter-offer should not be the same as the maker
of the original order.
If the order
is the same kind as originalOrder
then caller of acceptCounterOffer
is putting themselves in the opposite position of where they wanted to be.
The impact is a potential for user error.
Let
fillOrder
If Bob had called fillOrder
then Alice would become the recipient of a Long Put Option.
Case 1: Order kind is still Long Put
Instead Bob provides a counter-offer but also makes it a Long Put. He changes the order.maker
to himself. Alice now calls acceptCounterOffer
.
Line 583 will call fillOrder
but it is being called by the original order maker. This is true because otherwise line 579 would revert.
This would be a very easy mistake for Alice to make if they weren't thinking carefully enough.
Unfortunately, she has now put herself in the Short Put position which is not what she desired and could be disadvantageous.
Case 2: originalOrder.maker == order.maker
If the original order maker is equal to the (counter-offer) order maker then when Alice calls fillOrder
she now owns both position NFTs which is not desired behaviour.
It does not disadvantage her to any great extent as she can simple exercise
and withdraw
and will only lose some fees in the process as she will receive both the strike price and assets back.
Add the following two lines to acceptCounterOffer
to prevent these kinds of user error.
require(originalOrder.isCall == order.isCall && originalOrder.isLong != order.isLong, "Counter-offer should be opposite"); require(originalOrder.maker != order.maker, "Counter-offer maker should be different");
#0 - outdoteth
2022-07-07T19:07:37Z
high quality report