Platform: Code4rena
Start Date: 20/10/2021
Pot Size: $30,000 ETH
Total HM: 5
Participants: 15
Period: 3 days
Judge: 0xean
Total Solo HM: 3
Id: 44
League: ETH
Rank: 1/15
Findings: 6
Award: $15,827.11
🌟 Selected for report: 6
🚀 Solo Findings: 2
🌟 Selected for report: WatchPug
8161.4022 USDC - $8,161.40
WatchPug
function fillZrxQuote( IERC20 zrxBuyTokenAddress, address payable zrxTo, bytes calldata zrxData, uint256 ethAmount ) internal returns (uint256, uint256) { uint256 originalERC20Balance = 0; if(!signifiesETHOrZero(address(zrxBuyTokenAddress))) { originalERC20Balance = zrxBuyTokenAddress.balanceOf(address(this)); } uint256 originalETHBalance = address(this).balance; (bool success,) = zrxTo.call{value: ethAmount}(zrxData);
A call to an arbitrary contract with custom calldata is made in fillZrxQuote()
, which means the contract can be an ERC20 token, and the calldata can be transferFrom
a previously approved user.
The wallet balances (for the amount up to the allowance limit) of the tokens that users approved to the contract can be stolen.
Given:
Swap.sol
;The attacker can:
TallySwap.swapByQuote( address(WETH), 0, address(WETH), 0, address(0), address(WETH), abi.encodeWithSignature( "transferFrom(address,address,uint256)", address(Alice), address(this), 1000 ether ) )
As a result, 1000 WETH will be stolen from Alice and sent to the attacker.
This PoC has been tested on a forking network.
Consider adding a whitelist for zrxTo
addresses.
🌟 Selected for report: WatchPug
Also found by: harleythedog
3672.631 USDC - $3,672.63
WatchPug
function fillZrxQuote( IERC20 zrxBuyTokenAddress, address payable zrxTo, bytes calldata zrxData, uint256 ethAmount ) internal returns (uint256, uint256) { uint256 originalERC20Balance = 0; if(!signifiesETHOrZero(address(zrxBuyTokenAddress))) { originalERC20Balance = zrxBuyTokenAddress.balanceOf(address(this)); } uint256 originalETHBalance = address(this).balance; (bool success,) = zrxTo.call{value: ethAmount}(zrxData); require(success, "Swap::fillZrxQuote: Failed to fill quote"); uint256 ethDelta = address(this).balance.subOrZero(originalETHBalance); uint256 erc20Delta; if(!signifiesETHOrZero(address(zrxBuyTokenAddress))) { erc20Delta = zrxBuyTokenAddress.balanceOf(address(this)).subOrZero(originalERC20Balance); require(erc20Delta > 0, "Swap::fillZrxQuote: Didn't receive bought token"); } else { require(ethDelta > 0, "Swap::fillZrxQuote: Didn't receive bought ETH"); } return (erc20Delta, ethDelta); }
When a user tries to swap unwrapped ETH to ERC20, even if there is a certain amount of ETH refunded, at L215, ethDelta
will always be 0
.
That's because originalETHBalance
already includes the msg.value
sent by the caller.
Let's say the ETH balance of the contract is 1 ETH
before the swap.
10 ETH
to USDC;originalETHBalance
will be 11 ETH
;1 ETH
of refund;ethDelta
will be 0
as the new balance is 2 ETH
and subOrZero(2, 11)
is 0
.Similarly, erc20Delta
is also computed wrong.
Consider a special case of a user trying to arbitrage from WBTC
to WBTC
, the originalERC20Balance
already includes the input amount, erc20Delta
will always be much lower than the actual delta amount.
For example, for an arb swap from 1 WBTC
to 1.1 WBTC
, the ethDelta
will be 0.1 WBTC
while it should be 1.1 WBTC
.
Consider subtracting the input amount from the originalBalance.
#0 - Shadowfiend
2021-11-04T16:49:58Z
This doesn't allow explicit stealing by an attacker, but does leak value. We would suggest a (2) severity on this.
#1 - 0xean
2021-11-05T23:59:31Z
This results in a user losing assets that they will never be able to recover. Per documentation
3 — High: Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals).
Lost assets are a high sev.
#2 - mhluongo
2021-11-29T19:51:21Z
Long after the fact, I've been able to reproduce the ethDelta
vuln but not the erc20Delta
issue... it appears there is a bug there, but it reverts rather than locking user funds.
WatchPug
Since the introduction of transfer()
, it has typically been recommended by the security community because it helps guard against reentrancy attacks. This guidance made sense under the assumption that gas costs wouldn’t change. It's now recommended that transfer() and send() be avoided, as gas costs can and will change and reentrancy guard is more commonly used.
Any smart contract that uses transfer()
is taking a hard dependency on gas costs by forwarding a fixed amount of gas: 2300.
It's recommended to stop using transfer()
and switch to using call()
instead.
payable(msg.sender).transfer(toTransfer);
Can be changed to:
(bool success, ) = msg.sender.call.value(toTransfer)(""); require(success, "Swap::swapByQuote: ETH transfer failed");
#0 - Shadowfiend
2021-11-04T15:58:25Z
Duplicate of #20.
🌟 Selected for report: WatchPug
2448.4207 USDC - $2,448.42
WatchPug
Based on the context and comments in the code, we assume that it's possible that there will be some leftover sell tokens, not only when users are selling unwrapped ETH but also for ERC20 tokens.
However, in the current implementation, only refunded ETH is returned (L158).
Because of this, the leftover tkoens may be left in the contract unintentionally.
if (boughtERC20Amount > 0) { // take the swap fee from the ERC20 proceeds and return the rest uint256 toTransfer = SWAP_FEE_DIVISOR.sub(swapFee).mul(boughtERC20Amount).div(SWAP_FEE_DIVISOR); IERC20(zrxBuyTokenAddress).safeTransfer(msg.sender, toTransfer); // return any refunded ETH payable(msg.sender).transfer(boughtETHAmount); emit SwappedTokens( zrxSellTokenAddress, zrxBuyTokenAddress, amountToSell, boughtERC20Amount, boughtERC20Amount.sub(toTransfer) ); } else { // take the swap fee from the ETH proceeds and return the rest. Note // that if any 0x protocol fee is refunded in ETH, it also suffers // the swap fee tax uint256 toTransfer = SWAP_FEE_DIVISOR.sub(swapFee).mul(boughtETHAmount).div(SWAP_FEE_DIVISOR); payable(msg.sender).transfer(toTransfer); emit SwappedTokens( zrxSellTokenAddress, zrxBuyTokenAddress, amountToSell, boughtETHAmount, boughtETHAmount.sub(toTransfer) ); }
#0 - Shadowfiend
2021-11-03T21:32:03Z
I believe the 0x API does in fact guarantee that we won't have any sell tokens left over, particularly since we intend to use RFQ-T for these quotes, but if not we will fix this... And we may make the change regardless to future-proof.
#1 - 0xean
2021-11-06T13:20:27Z
Downgrading to sev 2
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
As I believe this to be a "leak value" scenario.
🌟 Selected for report: WatchPug
816.1402 USDC - $816.14
WatchPug
emit SwappedTokens( zrxSellTokenAddress, zrxBuyTokenAddress, amountToSell, boughtETHAmount, boughtETHAmount.sub(toTransfer) );
amountToSell
will be 0 according to the comment: If selling unwrapped ETH via msg.value, this should be 0.
, therefore, msg.value
should be used instead.
Change to:
emit SwappedTokens( zrxSellTokenAddress, zrxBuyTokenAddress, msg.value, boughtETHAmount, boughtETHAmount.sub(toTransfer) );
148.7416 USDC - $148.74
WatchPug
Swap.sol#constructor()
validates owner_
but does not validate swapFee_
. While in setSwapFee()
, it does validates the input to make sure swapFee_ < SWAP_FEE_DIVISOR
.
constructor(address owner_, address payable feeRecipient_, uint256 swapFee_) { require(owner_ != address(0), "Swap::constructor: Owner must not be 0"); transferOwnership(owner_); feeRecipient = feeRecipient_; emit NewFeeRecipient(feeRecipient); swapFee = swapFee_; emit NewSwapFee(swapFee); }
Change to:
constructor(address owner_, address payable feeRecipient_, uint256 swapFee_) { require(owner_ != address(0), "Swap::constructor: Owner must not be 0"); require(swapFee_ < SWAP_FEE_DIVISOR, "Swap::constructor: Swap fee must not exceed 100%"); transferOwnership(owner_); feeRecipient = feeRecipient_; emit NewFeeRecipient(feeRecipient); swapFee = swapFee_; emit NewSwapFee(swapFee); }
🌟 Selected for report: harleythedog
WatchPug
FeesSwept
event parameterfeeRecipient.transfer(address(this).balance); emit FeesSwept(address(0), address(this).balance, feeRecipient);
address(this).balance
will always be 0 by at L258, as all of the balance has just been swept to feeRecipient
.
Change to:
uint ethBalance = address(this).balance; feeRecipient.transfer(ethBalance); emit FeesSwept(address(0), ethBalance, feeRecipient);
#0 - Shadowfiend
2021-11-04T15:55:31Z
Duplicate of #21.
35.9883 USDC - $35.99
WatchPug
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
function subOrZero(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a - b : 0; } function subOrZero(uint128 a, uint128 b) internal pure returns (uint128) { return a > b ? a - b : 0; } function subOrZero(uint64 a, uint64 b) internal pure returns (uint64) { return a > b ? a - b : 0; } function subOrZero(uint32 a, uint32 b) internal pure returns (uint32) { return a > b ? a - b : 0; }
#0 - Shadowfiend
2021-10-29T20:59:56Z
Duplicate of #43.
10.4942 USDC - $10.49
WatchPug
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
SafeMath
is no longer needed starting with Solidity 0.8. The compiler now has built in overflow checking.
#0 - Shadowfiend
2021-10-29T20:53:35Z
Duplicate of #42.
35.9883 USDC - $35.99
WatchPug
payable(msg.sender).transfer(boughtETHAmount);
Change to:
if (boughtETHAmount) { payable(msg.sender).transfer(boughtETHAmount); }
35.9883 USDC - $35.99
WatchPug
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Instances include:
function sweepFees( address[] calldata tokens ) external nonReentrant { require( feeRecipient != address(0), "Swap::withdrawAccruedFees: feeRecipient is not initialized" ); for (uint8 i = 0; i<tokens.length; i++) { ...
#0 - Shadowfiend
2021-11-04T15:55:08Z
Duplicate of #15.
#1 - 0xean
2021-11-06T12:04:56Z
duplicate of #74