Platform: Code4rena
Start Date: 29/07/2021
Pot Size: $20,000 USDC
Total HM: 8
Participants: 12
Period: 3 days
Judge: LSDan
Total Solo HM: 2
Id: 24
League: ETH
Rank: 3/12
Findings: 5
Award: $3,249.19
🌟 Selected for report: 8
🚀 Solo Findings: 0
565.8571 USDC - $565.86
hickuphh3
_depositToken.safeTransferFrom(address(this), msg.sender, redeemableBalance);
can be optimized to use the safeTransfer
method instead, since it bypasses allowance checks and effects.
Update to _depositToken.safeTransfer(msg.sender, redeemableBalance);
#0 - PierrickGT
2021-08-12T16:04:37Z
#1 - 0xean
2021-08-24T16:37:52Z
duplicate of #61 , which clearly states the severity.
1397.178 USDC - $1,397.18
hickuphh3
transferFunds()
will transfer funds from a specified yield source _yieldSource
to the current yield source set in the contract _currentYieldSource
. However, it fails to check that the deposit tokens are the same. If the specified yield source's assets are of a higher valuation, then a malicious owner or asset manager will be able to exploit and pocket the difference.
Assumptions:
_yieldSource
has a deposit token of WETH (18 decimals)_currentYieldSource
has a deposit token of DAI (18 decimals)Attacker does the following:
transferFunds(_yieldSource, 100 * 1e18)
_requireDifferentYieldSource()
passes_transferFunds(_yieldSource, 100 * 1e18)
is called
_yieldSource.redeemToken(_amount);
→ This will transfer 100 WETH out of the _yieldSource
into the contractuint256 currentBalance = IERC20Upgradeable(_yieldSource.depositToken()).balanceOf(address(this));
→ This will equate to ≥ 100 WETH.require(_amount <= currentBalance, "SwappableYieldSource/transfer-amount-different");
is true since both are 100 * 1e18
_currentYieldSource.supplyTokenTo(currentBalance, address(this));
→ This supplies the transferred 100 DAI from step 1 to the current yield sourcetransferERC20(WETH, attackerAddress, 100 * 1e18)
to withdraw 100 WETH out of the contract to the attacker's desired address._requireDifferentYieldSource()
should also verify that the yield sources' deposit token addresses are the same.
function _requireDifferentYieldSource(IYieldSource _yieldSource) internal view { require(address(_yieldSource) != address(yieldSource), "SwappableYieldSource/same-yield-source"); require(_newYieldSource.depositToken() == yieldSource.depositToken(), "SwappableYieldSource/different-deposit-token"); }
#0 - PierrickGT
2021-08-11T22:41:54Z
This exploit was indeed possible when we had the transferFunds
function but now that we have removed it and funds can only be moved by swapYieldSource()
, this exploit is no longer possible since we check for the same depositToken
in _setYieldSource()
.
https://github.com/pooltogether/swappable-yield-source/pull/4
#1 - 0xean
2021-08-24T16:45:56Z
Upgrading to 3 considering the potential for loss of funds
🌟 Selected for report: tensors
Also found by: GalloDaSballo, cmichel, hickuphh3
169.7571 USDC - $169.76
hickuphh3
While not necessary, it would be good practice to zero out the allowance given to the current yield source when swapping out for a new one.
function _setYieldSource(IYieldSource _newYieldSource) internal { ... IERC20Upgradeable(yieldSource.depositToken()).safeApprove(address(yieldSource), 0); yieldSource = _newYieldSource; IERC20Upgradeable(_newYieldSource.depositToken()).safeApprove(address(_newYieldSource), type(uint256).max); emit SwappableYieldSourceSet(_newYieldSource); }
#0 - PierrickGT
2021-08-06T16:44:02Z
83.8307 USDC - $83.83
hickuphh3
The logic of _requireYieldSource()
is fine, but the variable isInvalidYieldSource
within the function does the opposite of its intended purpose. The lines of interest are below.
isInvalidYieldSource = depositTokenAddress != address(0); require(isInvalidYieldSource, "SwappableYieldSource/invalid-yield-source");
The intention is to check that depositTokenAddress
is non-null, which makes the yield source valid (not invalid like the variable name suggests).
Change isInvalidYieldSource
to isValidYieldSource
.
#0 - PierrickGT
2021-08-06T15:51:51Z
139.7178 USDC - $139.72
hickuphh3
The FundsTransferred()
event in _transferFunds()
will report a smaller amount than expected if currentBalance > _amount
.
This would affect applications utilizing event logs like subgraphs.
Update the event emission to emit FundsTransferred(_yieldSource, currentBalance);
#0 - PierrickGT
2021-08-11T20:24:23Z
This issue has been fixed in the following PR: https://github.com/pooltogether/swappable-yield-source/pull/4
🌟 Selected for report: hickuphh3
310.484 USDC - $310.48
hickuphh3
setYieldSource()
changes the current yield source to a new yield source. It has similar functionality as swapYieldSource()
, except that it doesn't transfer deposited funds from the current to the new one. However, it fails to check that it does not have any remaining deposited funds in the current yield source before the transfer.
It is highly recommended for this check to be in place so that funds aren't forgotten / unintentionally lost.
Add a require check
require(yieldSource.balanceOfToken(address(this)); == 0, "SwappableYieldSource/existing-funds-in-current-yield-source")
**before calling _setYieldSource()
#0 - PierrickGT
2021-08-11T20:28:55Z
We have decided to remove setYieldSource
and transferFunds
functions. When using swapYieldSource
to change of yield source, all funds from the old yield source will be moved to the new yield source in one transaction.
https://github.com/pooltogether/swappable-yield-source/pull/4
🌟 Selected for report: hickuphh3
Also found by: 0xRajeev, GalloDaSballo, cmichel, pauliax
26.3992 USDC - $26.40
hickuphh3
Assuming that depositToken
of a yield source doesn't change, it would make sense to save its value as a storage variable in the contract as well, so that an external call to yieldSource
to retrieve it can be avoided whenever it is needed.
Define address public override depositToken;
or IERC20Upgradeable public depositToken;
which gets initialized in the initialize()
function. The nice thing is that it also doesn't need to be updated when swapping sources because a requirement is that the new yield source must have the same deposit token.
As an optimization, since the _requireYieldSource()
function already retrieves the depositToken
address, it can return it so that its value need not be externally retrieved again in the initialize()
function.
The depositToken()
function can be removed if the former suggestion is implemented (ie. address public override depositToken
).
Then, yieldSource.depositToken()
can be replaced with depositToken
where applicable (with appropriate casting).
A part of the former implementation is provided below.
address public override depositToken; function initialize(...) { address depositTokenAddress = _requireYieldSource(_yieldSource); yieldSource = _yieldSource; depositToken = depositTokenAddress; ... IERC20Upgradeable(depositTokenAddress).safeApprove(address(_yieldSource), type(uint256).max); } function _requireYieldSource(IYieldSource _yieldSource) internal view returns (address depositTokenAddress) { ... (depositTokenAddress) = abi.decode(depositTokenAddressData, (address)); } // function depositToken() can be removed // yieldSource.depositToken() can be replaced with depositToken in other functions // Example: _setYieldSource function _setYieldSource(IYieldSource _newYieldSource) internal { _requireDifferentYieldSource(_newYieldSource); // Commented out check below should be shifted to inside _requireDifferentYieldSource() // Optimization: it can also return depositToken to avoid another SLOAD // similar to _requireYieldSource() above // require(_newYieldSource.depositToken() == depositToken, "SwappableYieldSource/different-deposit-token"); yieldSource = _newYieldSource; IERC20Upgradeable(depositToken).safeApprove(address(_newYieldSource), type(uint256).max); emit SwappableYieldSourceSet(_newYieldSource); }
#0 - PierrickGT
2021-08-12T23:38:42Z
🌟 Selected for report: maplesyrup
26.3992 USDC - $26.40
hickuphh3
approveMax()
and depositToken()
are not called internally in the contract. Making these methods external
would help reduce execution gas costs.
#0 - PierrickGT
2021-08-06T16:14:43Z
36.6656 USDC - $36.67
hickuphh3
The immutable mAsset
is assigned to the immutable savings
contract. Hence, we can avoid an external function call to the savings contract in the approveMax
function by replacing it with mAsset
.
function approveMax() public { mAsset.safeApprove(address(savings), type(uint256).max); emit ApprovedMax(msg.sender); }
#0 - PierrickGT
2021-08-13T16:19:54Z
Fixed in the following commit: https://github.com/pooltogether/pooltogether-mstable/pull/3/commits/f771d46a4fe96622758602d842aeb243fd58d6dd
🌟 Selected for report: hickuphh3
201.183 USDC - $201.18
hickuphh3
The number of solc runs used for contract compilation is 200. This number can be bumped significantly to produce more gas efficient code (max value of 2**32-1
).
More information can be found in the solidity docs.
In hardhat.config.ts
, increase solc runs significantly. Contract sizes and thus deployment costs will increase, but functions will cost less gas to execute.
#0 - PierrickGT
2021-08-11T22:29:14Z
200 is the default value, not sure what would be the real gain of bumping it and since no value is proposed by the warden, this recommended mitigation isn't concrete and applicable.
#1 - 0xean
2021-08-24T17:31:45Z
Disagree, warden has provided exact instructions on how to increase the value.
#2 - asselstine
2021-08-30T19:18:20Z
This issue is really hand-wavy. Based on the warden's logic, this "issue" applies to any contract that isn't compiled with runs set to 2**32-1
, which is absurd.
The number of runs is a balance between contract size and runtime efficiency. The warden has done zero analysis in this respect, and simply hand-waved "do more".
This isn't specific enough to be useful. Saying "do 2**32-1
runs" isn't helpful for us, and likely inaccurate.
🌟 Selected for report: hickuphh3
201.183 USDC - $201.18
hickuphh3
Since mAsset
is immutable and is initialized to a valid token address, a redundant extcodesize check can be avoided, just like how UniswapV3 does it.
function mAssetBalance() private view returns (uint256) { (bool success, bytes memory data) = mAsset.staticcall(abi.encodeWithSelector(IERC20.balanceOf.selector, address(this))); require(success && data.length >= 32); return abi.decode(data, (uint256)); } // Replace lines mAsset.balanceOf(address(this)) with mAssetBalance() function redeemToken(uint256 mAssetAmount) external override nonReentrant returns (uint256 mAssetsActual) { uint256 mAssetBalanceBefore = mAssetBalance(); uint256 creditsBurned = savings.redeemUnderlying(mAssetAmount); imBalances[msg.sender] -= creditsBurned; uint256 mAssetBalanceAfter = mAssetBalance(); mAssetsActual = mAssetBalanceAfter - mAssetBalanceBefore; mAsset.safeTransfer(msg.sender, mAssetsActual); emit Redeemed(msg.sender, mAssetAmount, mAssetsActual); }
#0 - PierrickGT
2021-08-16T15:18:39Z
This optimization seems overkill. How much gas would we save by implementing this kind of function? It's a trade of between gas consumption and the ease of maintenance of our code, so I prefer to keep a code that will be easier to maintain in the future than introduce some complexity that may not bring much in term of gas saving.
90.5323 USDC - $90.53
hickuphh3
Revert messages tend to be long, like SwappableYieldSource/yield-source-token-transfer-not-allowed
and SwappableYieldSource/yieldSource-not-zero-address
.
Reducing these messages to < 32 bytes will help save on deployment gas.
Consider shortening the messages. One suggestion is to use error codes, like PT_001
, PT_002
etc (similar to hardhat error codes). Alternatively, find ways to abbreviate error messages such that they are suggestive enough to make debugging easy, but are short as well.
#0 - PierrickGT
2021-08-11T22:23:05Z
We prefer to use readable error message instead of error codes, gas cost in deployments being less important than gas cost to call functions in the contract.