Platform: Code4rena
Start Date: 21/04/2022
Pot Size: $100,000 USDC
Total HM: 18
Participants: 60
Period: 7 days
Judge: gzeon
Total Solo HM: 10
Id: 112
League: ETH
Rank: 3/60
Findings: 7
Award: $12,502.17
🌟 Selected for report: 3
🚀 Solo Findings: 3
🌟 Selected for report: Dravee
Also found by: IllIllI, MaratCerby, UnusualTurtle, WatchPug, antonttc, berndartmueller, cccz, danb, horsefacts, hyh, pauliax, rayn, wuwe1
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.
LiquidityPool.redeem()
LiquidityPool.redeem()
L564 will call _rebalanceVault(redeemUnderlying);
LiquidityPool._rebalanceVault()
L754 will call _doTransferOut(payable(address(vault)), excessDeposits);
_doTransferOut(address payable to, uint256 amount)
L30 will call to.transfer(amount);
. And _doTransferOut(payable(address(vault)), excessDeposits);
will call vault with 2300 gas limitvault
is Upgradeable
receive() external payable
will use delegatecall to call the implementation contractaddress.transfer()
, which is 2300, therefore, the tx will revert.LiquidityPool.redeem()
LiquidityPool.redeem()
L567 will call _doTransferOut(payable(msg.sender), redeemUnderlying);
_doTransferOut(address payable to, uint256 amount)
L30 will call to.transfer(amount);
. And _doTransferOut(payable(msg.sender), redeemUnderlying);
will call msg.sender
with 2300 gas limitmsg.sender
is a smart contract, it's highly likely the 2300 gas limit is not enough, therefore the tx will revert.VaultReserve.withdraw()
may revert
VaultReserve.withdraw()
L81 will call payable(msg.sender).transfer(amount);
payable(msg.sender).transfer(amount);
will call msg.sender
with 2300 gas limitmsg.sender
aka Vault (as VaultReserve.withdraw()
is onlyVault
)vault
is Upgradeable
receive() external payable
will use delegatecall to call the implementation contract, However, cold DELEGATECALL requires 2600 of gas, exceeds the gas limit of address.transfer()
, which is 2300, therefore, the tx will revert.It's recommended to stop using transfer()
and switch to using call()
instead.
Consider using OpenZeppelin's AddressUpgradeable#sendValue()
.
function _doTransferOut(address payable to, uint256 amount) internal override { to.transfer(amount); }
Can be changed to:
function _doTransferOut(address payable to, uint256 amount) internal override { AddressUpgradeable.sendValue(to, amount); }
function withdraw(address token, uint256 amount) external override onlyVault returns (bool) { require(canWithdraw(msg.sender), Error.RESERVE_ACCESS_EXCEEDED); uint256 accountBalance = _balances[msg.sender][token]; require(accountBalance >= amount, Error.INSUFFICIENT_BALANCE); _balances[msg.sender][token] -= amount; _lastWithdrawal[msg.sender] = block.timestamp; if (token == address(0)) { payable(msg.sender).transfer(amount); } else { IERC20(token).safeTransfer(msg.sender, amount); } emit Withdraw(msg.sender, token, amount); return true; }
Can be changed to:
function withdraw(address token, uint256 amount) external override onlyVault returns (bool) { require(canWithdraw(msg.sender), Error.RESERVE_ACCESS_EXCEEDED); uint256 accountBalance = _balances[msg.sender][token]; require(accountBalance >= amount, Error.INSUFFICIENT_BALANCE); _balances[msg.sender][token] -= amount; _lastWithdrawal[msg.sender] = block.timestamp; if (token == address(0)) { AddressUpgradeable.sendValue(payable(msg.sender), amount); } else { IERC20(token).safeTransfer(msg.sender, amount); } emit Withdraw(msg.sender, token, amount); return true; }
#0 - chase-manning
2022-04-28T11:38:16Z
Duplicate of #52
🌟 Selected for report: cccz
Also found by: 0x1f8b, 0xDjango, 0xkatana, Dravee, IllIllI, WatchPug, berndartmueller, defsec, horsefacts, hyh, kenta, rayn, reassor, sorrynotsorry
58.8714 USDC - $58.87
function _ethPrice() private view returns (int256) { (, int256 answer, , , ) = _ethOracle.latestRoundData(); return answer; }
On ChainlinkUsdWrapper.sol
, we are using latestRoundData
, but there is no check if the return value indicates stale data. This could lead to stale prices according to the Chainlink documentation:
Consider adding missing checks for stale data.
For example:
(uint80 roundID, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = _ethOracle.latestRoundData(); require(answer > 0, "Chainlink price <= 0"); require(answeredInRound >= roundID, "Stale price"); require(updatedAt != 0, "Round not complete");
#0 - gzeoneth
2022-05-07T20:56:44Z
Duplicate of #17
#1 - chase-manning
2022-05-11T14:57:18Z
703.5062 USDC - $703.51
In swapAllForWeth()
and swapAllWethForToken
, when using tokens with decimals larger than 18, the txs will revert due to underflow at _decimalMultiplier
.
function _decimalMultiplier(address token_) internal view returns (uint256) { return 10**(18 - IERC20Full(token_).decimals()); }
#0 - chase-manning
2022-04-28T12:01:09Z
Duplicate of #49
🌟 Selected for report: WatchPug
3860.1163 USDC - $3,860.12
https://github.com/compound-finance/compound-protocol/blob/v2.8.1/contracts/CEther.sol#L44-L47
https://github.com/compound-finance/compound-protocol/blob/v2.8.1/contracts/CEther.sol#L44-L47
function mint() external payable { (uint err,) = mintInternal(msg.value); requireNoError(err, "mint failed"); }
mint()
for native cToken (CEther
) does not have any parameters, as the Function Selector
is based on the function name with the parenthesised list of parameter types
, when you add a nonexisting parameter
, the Function Selector
will be incorrect.
function mint(uint256 mintAmount) external payable virtual returns (uint256);
The current implementation uses the same CToken
interface for both CEther
and CErc20
in topUp()
, and function mint(uint256 mintAmount)
is a nonexisting function for CEther
.
As a result, the native token topUp()
always revert.
CToken ctoken = cTokenRegistry.fetchCToken(underlying); uint256 initialTokens = ctoken.balanceOf(address(this)); address addr = account.addr(); if (repayDebt) { amount -= _repayAnyDebt(addr, underlying, amount, ctoken); if (amount == 0) return true; } uint256 err; if (underlying == address(0)) { err = ctoken.mint{value: amount}(amount); }
See also:
#0 - chase-manning
2022-04-28T11:31:52Z
Duplicate of #125
#1 - gzeoneth
2022-05-07T20:51:25Z
Not a duplicate.
#2 - samwerner
2022-05-27T13:44:36Z
🌟 Selected for report: WatchPug
3860.1163 USDC - $3,860.12
function repayBorrowBehalf(address borrower, uint256 repayAmount) external payable returns (uint256);
repayBorrowBehalf()
for native cToken (CEther
) will return nothing, while the current CEthInterface
interface defines the returns as (uint256)
.
As a result, ether.repayBorrowBehalf()
will always revert
CEther cether = CEther(address(ctoken)); err = cether.repayBorrowBehalf{value: debt}(account);
Ref:
method | CEther | CErc20 |
---|---|---|
mint() | revert | error code |
redeem() | error code | error code |
repayBorrow() | revert | error code |
repayBorrowBehalf() | revert | error code |
🌟 Selected for report: WatchPug
3860.1163 USDC - $3,860.12
function mint() external payable returns (uint256);
mint()
for native cToken (CEther
) will return nothing, while the current CEthInterface
interface defines the returns as (uint256)
.
In the current implementation, the interface for CToken
is used for both CEther
and CErc20
.
As a result, the transaction will revert with the error: function returned an unexpected amount of data
when topUp()
with the native token (ETH).
CToken ctoken = cTokenRegistry.fetchCToken(underlying); uint256 initialTokens = ctoken.balanceOf(address(this)); address addr = account.addr(); if (repayDebt) { amount -= _repayAnyDebt(addr, underlying, amount, ctoken); if (amount == 0) return true; } uint256 err; if (underlying == address(0)) { err = ctoken.mint{value: amount}(amount); }
Ref:
method | CEther | CErc20 |
---|---|---|
mint() | revert | error code |
redeem() | error code | error code |
repayBorrow() | revert | error code |
repayBorrowBehalf() | revert | error code |
🌟 Selected for report: joestakey
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xkatana, 0xmint, Dravee, Funen, IllIllI, MaratCerby, NoamYakov, Tadashi, TerrierLover, Tomio, WatchPug, catchup, defsec, fatherOfBlocks, hake, horsefacts, kenta, oyc_109, pauliax, rayn, rfa, robee, saian, securerodd, simon135, slywaters, sorrynotsorry, tin537, z3s
89.3504 USDC - $89.35
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
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:
CompoundHandler.sol#_getAccountBorrowsAndSupply()
CTokenRegistry.sol#_updateCTokenMapping()
function unstakeFor( address src, address dst, uint256 amount ) public override returns (bool) { ILiquidityPool pool = controller.addressProvider().getPoolForToken(token); uint256 allowance_ = _allowances[src][msg.sender]; require( src == msg.sender || allowance_ >= amount || address(pool) == msg.sender, Error.UNAUTHORIZED_ACCESS ); require(balances[src] >= amount, Error.INSUFFICIENT_BALANCE); address lpGauge = currentAddresses[_LP_GAUGE]; if (lpGauge != address(0)) { ILpGauge(lpGauge).userCheckpoint(src); } uint256 oldBal = IERC20(token).balanceOf(address(this)); if (src != dst) { pool.handleLpTokenTransfer(src, dst, amount); } IERC20(token).safeTransfer(dst, amount); uint256 unstaked = oldBal - IERC20(token).balanceOf(address(this)); if (src != msg.sender && allowance_ != type(uint256).max && address(pool) != msg.sender) { // update allowance _allowances[src][msg.sender] -= unstaked; } // ... }
L388 can use allowance_ - unstaked
directly to avoid unnecessary storage read of _allowances[src][msg.sender]
to save some gas.
Change to:
function unstakeFor( address src, address dst, uint256 amount ) public override returns (bool) { ILiquidityPool pool = controller.addressProvider().getPoolForToken(token); uint256 allowance_ = _allowances[src][msg.sender]; require( src == msg.sender || allowance_ >= amount || address(pool) == msg.sender, Error.UNAUTHORIZED_ACCESS ); require(balances[src] >= amount, Error.INSUFFICIENT_BALANCE); address lpGauge = currentAddresses[_LP_GAUGE]; if (lpGauge != address(0)) { ILpGauge(lpGauge).userCheckpoint(src); } uint256 oldBal = IERC20(token).balanceOf(address(this)); if (src != dst) { pool.handleLpTokenTransfer(src, dst, amount); } IERC20(token).safeTransfer(dst, amount); uint256 unstaked = oldBal - IERC20(token).balanceOf(address(this)); if (src != msg.sender && allowance_ != type(uint256).max && address(pool) != msg.sender) { // update allowance _allowances[src][msg.sender] = allowance_ - unstaked; } // ... }
address public token;
function initialize(address _token) external override initializer { token = _token; }
Considering that token
will never change, changing it to immutable variable instead of storage variable can save gas.
uint
variables to 0
is redundantSetting uint
variables to 0
is redundant as they default to 0
.
uint256 currentFeeRatio = 0;
for (uint256 i = 0; i < assets.length; i++)