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: 2/12
Findings: 3
Award: $4,166.70
🌟 Selected for report: 3
🚀 Solo Findings: 1
🌟 Selected for report: GalloDaSballo
838.3068 USDC - $838.31
gpersoon
The SwappableYieldSource contract contains a function transferERC20 to transfer out tokens that are accidentally owned by the contract. It contains a check to prevent the transfer of the yield source token: require(address(erc20Token) != address(yieldSource), "SwappableYieldSource/yield-source-token-transfer-not-allowed");
However this check can be circumvented by doing the following:
The risk of this is low because normally there wouldn't be any yield source tokens in the SwappableYieldSource contract, because the are directly sent to the yield source. And in redeemToken, after having been retrieved from the yield source they are directly send to the msg.sender.
//https://github.com/pooltogether/swappable-yield-source/blob/main/contracts/SwappableYieldSource.sol#L323 function transferERC20(IERC20Upgradeable erc20Token, address to, uint256 amount) external onlyOwnerOrAssetManager returns (bool) { require(address(erc20Token) != address(yieldSource), "SwappableYieldSource/yield-source-token-transfer-not-allowed"); erc20Token.safeTransfer(to, amount); emit TransferredERC20(msg.sender, to, amount, erc20Token); return true; }
//https://github.com/pooltogether/swappable-yield-source/blob/main/contracts/SwappableYieldSource.sol#L268 function setYieldSource(IYieldSource _newYieldSource) external onlyOwnerOrAssetManager returns (bool) { _setYieldSource(_newYieldSource); return true; }
Store all (previous) yieldsources in an array/mapping and check neither of them is transferred in transferERC20.
#0 - PierrickGT
2021-08-11T21:02:56Z
We have decided to only keep the swapYieldSource
function to avoid these kind of scenarios:
https://github.com/pooltogether/swappable-yield-source/pull/4
The scenario described in this issue would work by setting up a new yield source with setYieldSource
and then call transferERC20
to move ERC20 token shares from the previous yield source to an EOA or Contract. This way, the malicious user could then call the redeemToken
function from the previous yield source to actually redeem funds.
That being said, the solution proposed doesn't seem efficient to avoid this problem and it is best to only allow the owner of the swappable yield source to set a new yield source and move funds in one transaction.
🌟 Selected for report: gpersoon
3104.8401 USDC - $3,104.84
gpersoon
The use of setYieldSource leaves the contract in a temporary inconsistent state because it changes the underlying yield source, but doesn't (yet) transfer the underlying balances, while the shares stay the same.
The function balanceOfToken will show the wrong results, because it is based on _sharesToToken, which uses yieldSource.balanceOfToken(address(this)), that isn't updated yet.
More importantly supplyTokenTo will give the wrong amount of shares back: First it supplies tokens to the yieldsource. Then is calls _mintShares, which calls _tokenToShares, which calculates the shares, using yieldSource.balanceOfToken(address(this)) This yieldSource.balanceOfToken(address(this)) only contains the just supplied tokens, but doesn't include the tokens from the previous YieldSource. So the wrong amount of shares is given back to the user; they will be given more shares than appropriate which means they can drain funds later on (once transferFunds has been done).
It is possible to make use of this problem in the following way:
// https://github.com/pooltogether/swappable-yield-source/blob/main/contracts/SwappableYieldSource.sol function setYieldSource(IYieldSource _newYieldSource) external onlyOwnerOrAssetManager returns (bool) { _setYieldSource(_newYieldSource);
function _setYieldSource(IYieldSource _newYieldSource) internal { .. yieldSource = _newYieldSource;
function supplyTokenTo(uint256 amount, address to) external override nonReentrant { .. yieldSource.supplyTokenTo(amount, address(this)); _mintShares(amount, to); }
function _mintShares(uint256 mintAmount, address to) internal { uint256 shares = _tokenToShares(mintAmount); require(shares > 0, "SwappableYieldSource/shares-gt-zero"); _mint(to, shares); }
function _tokenToShares(uint256 tokens) internal returns (uint256) { uint256 shares; uint256 _totalSupply = totalSupply(); .. uint256 exchangeMantissa = FixedPoint.calculateMantissa(_totalSupply, yieldSource.balanceOfToken(address(this))); // based on incomplete yieldSource.balanceOfToken(address(this)) shares = FixedPoint.multiplyUintByMantissa(tokens, exchangeMantissa);
function balanceOfToken(address addr) external override returns (uint256) { return _sharesToToken(balanceOf(addr)); }
function _sharesToToken(uint256 shares) internal returns (uint256) { uint256 tokens; uint256 _totalSupply = totalSupply(); .. uint256 exchangeMantissa = FixedPoint.calculateMantissa(yieldSource.balanceOfToken(address(this)), _totalSupply); // based on incomplete yieldSource.balanceOfToken(address(this)) tokens = FixedPoint.multiplyUintByMantissa(shares, exchangeMantissa);
Remove the function setYieldSource (e.g. only leave swapYieldSource) Or temporally disable actions like supplyTokenTo, redeemToken and balanceOfToken, after setYieldSource and until transferFunds has been done
#0 - PierrickGT
2021-08-11T16:41:55Z
PR: https://github.com/pooltogether/swappable-yield-source/pull/4
We've mitigated this issue by removing the transferFunds
and setYieldSource
external functions and making swapYieldSource
callable only by the owner that will be a multi sig wallet for governance pools.
83.8307 USDC - $83.83
gpersoon
The function _requireYieldSource of the contract SwappableYieldSource has a state variable: isInvalidYieldSource
You would expect isInvalidYieldSource == true would mean the yield source in invalid However in the source code isInvalidYieldSource == true mean the yield source is valid.
This is confusing for readers and future maintainers. Future maintainers could easily make a mistake and thus introduce vulnerabilities.
// https://github.com/pooltogether/swappable-yield-source/blob/main/contracts/SwappableYieldSource.sol#L74 function _requireYieldSource(IYieldSource _yieldSource) internal view { require(address(_yieldSource) != address(0), "SwappableYieldSource/yieldSource-not-zero-address"); (, bytes memory depositTokenAddressData) = address(_yieldSource).staticcall(abi.encode(_yieldSource.depositToken.selector)); bool isInvalidYieldSource; if (depositTokenAddressData.length > 0) { (address depositTokenAddress) = abi.decode(depositTokenAddressData, (address)); isInvalidYieldSource = depositTokenAddress != address(0); } require(isInvalidYieldSource, "SwappableYieldSource/invalid-yield-source"); }
Change isInvalidYieldSource to isValidYieldSource
#0 - PierrickGT
2021-08-12T15:35:39Z
139.7178 USDC - $139.72
gpersoon
The function initialize of SwappableYieldSource checks that the yield source is valid via _requireYieldSource. When you change the yield source (via swapYieldSource or setYieldSource), then the function _setYieldSource is called. However _setYieldSource doesn't explicitly check the yield source via _requireYieldSource.
The risk is low because there is an indirect check, by the following check, which only succeeds is depositToken is present in the new yield source: require(_newYieldSource.depositToken() == yieldSource.depositToken(), "SwappableYieldSource/different-deposit-token");
For maintenance purposes it is more logical to always call _requireYieldSource, especially if the check would be made more extensive in the future.
//https://github.com/pooltogether/swappable-yield-source/blob/main/contracts/SwappableYieldSource.sol#L98 function initialize( IYieldSource _yieldSource, uint8 _decimals, string calldata _symbol, string calldata _name, address _owner) public initializer returns (bool) { _requireYieldSource(_yieldSource);
function _requireYieldSource(IYieldSource _yieldSource) internal view { require(address(_yieldSource) != address(0), "SwappableYieldSource/yieldSource-not-zero-address"); (, bytes memory depositTokenAddressData) = address(_yieldSource).staticcall(abi.encode(_yieldSource.depositToken.selector)); bool isInvalidYieldSource; if (depositTokenAddressData.length > 0) { (address depositTokenAddress) = abi.decode(depositTokenAddressData, (address)); isInvalidYieldSource = depositTokenAddress != address(0); } require(isInvalidYieldSource, "SwappableYieldSource/invalid-yield-source"); }
function _setYieldSource(IYieldSource _newYieldSource) internal { _requireDifferentYieldSource(_newYieldSource); require(_newYieldSource.depositToken() == yieldSource.depositToken(), "SwappableYieldSource/different-deposit-token"); ...
function _requireDifferentYieldSource(IYieldSource _yieldSource) internal view { require(address(_yieldSource) != address(yieldSource), "SwappableYieldSource/same-yield-source"); }
Add the following statement to _setYieldSource: _requireYieldSource(_newYieldSource);
#0 - PierrickGT
2021-08-11T21:16:00Z
The _requireYieldSource
function is only used to verify that we setup the swappable yield source with an actual yield source.
As noted, we already check that depositToken()
exists in the new yield source, so it would be redundant to also add the _requireYieldSource
function to perform the same kind of comparaison.
#1 - 0xean
2021-08-24T17:15:14Z
Disagree with sponsor. The current implementation doesn't ensure a fully functional yield source is present.
#2 - PierrickGT
2021-08-30T23:24:49Z
As previously stated, this contract will be owned by governance who will vet any change of yield source before going through a vote.