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: 2/133
Findings: 4
Award: $3,929.01
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: hyh
Also found by: hansfriese
3461.0776 USDC - $3,461.08
Some non-malicious ERC20 do not allow for zero amount transfers and order.baseAsset can be such an asset. Zero strike calls are valid and common enough derivative type. However, the zero strike calls with such baseAsset will not be able to be exercised, allowing maker to steal from the taker as a malicious maker can just wait for expiry and withdraw the assets, effectively collecting the premium for free. The premium of zero strike calls are usually substantial.
Marking this as high severity as in such cases malicious maker knowing this specifics can steal from taker the whole premium amount. I.e. such orders will be fully valid for a taker from all perspectives as inability to exercise is a peculiarity of the system which taker in the most cases will not know beforehand.
Currently system do not check the strike value, unconditionally attempting to transfer it:
} else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); }
As a part of call exercise logic:
function exercise(Order memory order, uint256[] calldata floorAssetTokenIds) public payable { ... if (order.isCall) { // -- exercising a call option // transfer strike from exerciser to putty // handle the case where the taker uses native ETH instead of WETH to pay the strike if (weth == order.baseAsset && msg.value > 0) { // check enough ETH was sent to cover the strike require(msg.value == order.strike, "Incorrect ETH amount sent"); // convert ETH to WETH // we convert the strike ETH to WETH so that the logic in withdraw() works // - because withdraw() assumes an ERC20 interface on the base asset. IWETH(weth).deposit{value: msg.value}(); } else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); } // transfer assets from putty to exerciser _transferERC20sOut(order.erc20Assets); _transferERC721sOut(order.erc721Assets); _transferFloorsOut(order.floorTokens, positionFloorAssetTokenIds[uint256(orderHash)]); }
Some tokens do not allow zero amount transfers:
https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers
This way for such a token and zero strike option the maker can create short call order, receive the premium:
if (weth == order.baseAsset && msg.value > 0) { // check enough ETH was sent to cover the premium require(msg.value == order.premium, "Incorrect ETH amount sent"); // convert ETH to WETH and send premium to maker // converting to WETH instead of forwarding native ETH to the maker has two benefits; // 1) active market makers will mostly be using WETH not native ETH // 2) attack surface for re-entrancy is reduced IWETH(weth).deposit{value: msg.value}(); IWETH(weth).transfer(order.maker, msg.value); } else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, order.maker, order.premium); }
Transfer in the assets:
// filling short call: transfer assets from maker to contract if (!order.isLong && order.isCall) { _transferERC20sIn(order.erc20Assets, order.maker); _transferERC721sIn(order.erc721Assets, order.maker); return positionId; }
And wait for expiration, knowing that all attempts to exercise will revert:
} else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); }
Then recover her assets:
// transfer assets from putty to owner if put is exercised or call is expired if ((order.isCall && !isExercised) || (!order.isCall && isExercised)) { _transferERC20sOut(order.erc20Assets); _transferERC721sOut(order.erc721Assets); // for call options the floor token ids are saved in the long position in fillOrder(), // and for put options the floor tokens ids are saved in the short position in exercise() uint256 floorPositionId = order.isCall ? longPositionId : uint256(orderHash); _transferFloorsOut(order.floorTokens, positionFloorAssetTokenIds[floorPositionId]); return; }
Consider checking that strike is positive before transfer in all the cases, for example:
} else { + if (order.strike > 0) { ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); + } }
#0 - GalloDaSballo
2022-07-05T02:04:19Z
Seems contingent on token implementation, however certain ERC20 do revert on 0 transfer and there would be no way to exercise the contract in that case
#1 - outdoteth
2022-07-08T13:27:26Z
Report: Cannot exercise call contract if strike is 0 and baseAsset reverts on 0 transfers
#2 - HickupHH3
2022-07-13T02:07:16Z
There is a pre-requisite for the ERC20 token to revert on 0 amount transfers. However, the warden raised a key point: zero strike calls are common, and their premium is substantial. The information asymmetry of the ERC20 token between the maker and taker is another aggravating factor.
#3 - outdoteth
2022-07-15T10:29:25Z
PR with fix: https://github.com/outdoteth/putty-v2/pull/3
🌟 Selected for report: kirk-baird
Also found by: 0xA5DF, Kenshin, cccz, chatch, csanuragjain, hansfriese, hyh, itsmeSTYJ, pedroais, sashik_eth, unforgiven, xiaoming90
41.8933 USDC - $41.89
https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L439-L442 https://github.com/code-423n4/2022-06-putty/blob/3b6b844bc39e897bd0bbb69897f2deff12dc3893/contracts/src/PuttyV2.sol#L632-L640
A malicious user can create an order that can be filled, but cannot be exercised by adding a precooked asset to a bundle that will be transferred to Putty contract, but will reject any outbound transfer, so exercise will not be possible. By adding such an asset to the bundle the attacker guarantees that the taker's premium will be received for free. Such asset will be one from a long list of valuable receivables and can easily go unnoticed.
Setting severity to be high as this opens up a way to use the system for direct stealing. Batch transfers not allowing for any flexibility in general introduce a substantial attack surface as any way to disturb any transfer provides an attack vector.
Currently transfers are always required to be completed fully and for the initial list of tokens, even if receiving party would like to omit some assets:
// transfer assets from putty to exerciser _transferERC20sOut(order.erc20Assets); _transferERC721sOut(order.erc721Assets); _transferFloorsOut(order.floorTokens, positionFloorAssetTokenIds[uint256(orderHash)]);
/** @notice Transfers an array of erc20 tokens to the msg.sender. @param assets The erc20 tokens and amounts to send. */ function _transferERC20sOut(ERC20Asset[] memory assets) internal { for (uint256 i = 0; i < assets.length; i++) { ERC20(assets[i].token).safeTransfer(msg.sender, assets[i].tokenAmount); } }
Add a way for exerciser to obtain only caller specified subset of order's assets.
I.e. add the asset list argument to exercise and withdraw functions and run the transfers across the list only.
Then the L439-L442 can read, for example:
// transfer assets from putty to exerciser - _transferERC20sOut(order.erc20Assets); - _transferERC721sOut(order.erc721Assets); - _transferFloorsOut(order.floorTokens, positionFloorAssetTokenIds[uint256(orderHash)]); + _transferERC20sOut(order.erc20Assets, erc20request); + _transferERC721sOut(order.erc721Assets, erc721request); + _transferFloorsOut(order.floorTokens, floorTokensRequest, positionFloorAssetTokenIds[uint256(orderHash)]);
#0 - GalloDaSballo
2022-07-05T01:15:17Z
This is equivalent to saying that a user can place an order for a malicious token, yes they can but why?
#1 - dmitriia
2022-07-05T09:08:43Z
It's not equvalent to a malicious token and exactly this is an issue. Lots of valid tokens revert in some cases and the system have it as an attack surface due to the rigid logic as malicious actors could be able to use this specifics
#2 - csanuragjain
2022-07-05T09:24:41Z
In this case if malicious user is hiding a precooked asset along with genuine asset then wont the malicious user need to lock in all those genuine assets as well (along with precooked asset) in the fill order call This means malicious user may get the premium but at same time will lose all those genuine assets
Without genuine assets victim wont fill this order. So in this case malicious user may loose more than gaining. Am I missing something?
#3 - dmitriia
2022-07-05T09:33:44Z
Malicious user will lock genuine assets on fillOrder, but will not lose them in 100% of cases as he just switch the malicious asset state where it revert all the transfers immediately after fillOrder, so exercise will not be possible and attacker's valuable assets are safe.
#4 - csanuragjain
2022-07-05T10:09:07Z
Ohh gotcha, nice trick
I think I understand the flow now:
#5 - dmitriia
2022-07-05T10:24:41Z
Yes, exactly. Assume there is a long list of valuable receivables and an attacker just includes an additional token there. The point is as the token can be blocked there is a big enough probability that taker will just ignore it (ok, I will not receive that one, but I don't need it), not understanding that this will give the attacker the ability to deny the whole exercise.
#6 - outdoteth
2022-07-07T13:34:57Z
Duplicate: Setting malicious or invalid erc721Assets, erc20Assets or floorTokens prevents the option from being exercised: https://github.com/code-423n4/2022-06-putty-findings/issues/50
🌟 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
When there are native funds attached to an order that doesn't use them, for example having eth == order.baseAsset
instead of weth
as an operational mistake, these funds will end up being frozen within the contract. I.e. now there are no checks for such cases and no mechanics to retrieve the corresponding funds.
Setting severity to medium as that user funds freeze conditional on a range of operational mistakes that have significant cumulative probability.
Now there are no checks for the case when msg.value isn't used:
} else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, address(this), order.strike); }
if (order.isLong) { ERC20(order.baseAsset).safeTransferFrom(order.maker, msg.sender, order.premium); }
} else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, order.maker, order.premium); }
As there are several places when msg.value can be utilized, consider introducing a flag to track whether it was used, for example:
bool nativeValueUsed; ... if (weth == order.baseAsset && msg.value > 0) { nativeValueUsed = true; // check enough ETH was sent to cover the premium ... require(nativeValueUsed || msg.value == 0, "Non-zero ETH amount");
#0 - outdoteth
2022-07-06T19:23:34Z
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: hyh
Also found by: Treasure-Seeker, csanuragjain, minhquanym
420.5209 USDC - $420.52
Zero and near zero strike calls are common derivative type. For such derivatives the system will not be receiving fees are the fee is now formulated as a fraction of order strike.
Also, it can be a problem for OTM call options, when the option itself is nearly worthless, while the fee will be substantial as strike will be big. Say 1k ETH BAYC call doesn't have much value, but the associated fee will be 10x of usual fee, i.e. substantial, while there is nothing to justify that.
Marking this as medium severity as that's a design specifics that can turn off or distort core system fee gathering.
Currently fee is linked to the order strike which makes it vary heavily for different types of orders, for example deep ITM and OTM calls:
// transfer strike to owner if put is expired or call is exercised if ((order.isCall && isExercised) || (!order.isCall && !isExercised)) { // send the fee to the admin/DAO if fee is greater than 0% uint256 feeAmount = 0; if (fee > 0) { feeAmount = (order.strike * fee) / 1000; ERC20(order.baseAsset).safeTransfer(owner(), feeAmount); } ERC20(order.baseAsset).safeTransfer(msg.sender, order.strike - feeAmount); return; }
Consider linking the fee to option premium as this is option value that cannot be easily manipulated and exactly corresponds to the trading volume of the system.
I.e. consider moving fee gathering to fillOrder:
// transfer premium to whoever is short from whomever is long if (order.isLong) { ERC20(order.baseAsset).safeTransferFrom(order.maker, msg.sender, order.premium); } else { // handle the case where the user uses native ETH instead of WETH to pay the premium if (weth == order.baseAsset && msg.value > 0) { // check enough ETH was sent to cover the premium require(msg.value == order.premium, "Incorrect ETH amount sent"); // convert ETH to WETH and send premium to maker // converting to WETH instead of forwarding native ETH to the maker has two benefits; // 1) active market makers will mostly be using WETH not native ETH // 2) attack surface for re-entrancy is reduced IWETH(weth).deposit{value: msg.value}(); IWETH(weth).transfer(order.maker, msg.value); } else { ERC20(order.baseAsset).safeTransferFrom(msg.sender, order.maker, order.premium); } }
#0 - GalloDaSballo
2022-07-05T01:46:23Z
Zero strike will indeed have a fee of 0
#1 - outdoteth
2022-07-08T13:32:00Z
Report: Charging fees on the strike amount instead of the premium amount can lead to disproportionate fees
#2 - outdoteth
2022-07-15T10:34:45Z
PR with fix: https://github.com/outdoteth/putty-v2/pull/4