Platform: Code4rena
Start Date: 09/11/2021
Pot Size: $75,000 USDC
Total HM: 57
Participants: 27
Period: 7 days
Judge: alcueca
Total Solo HM: 49
Id: 52
League: ETH
Rank: 2/27
Findings: 10
Award: $11,903.37
π Selected for report: 15
π Solo Findings: 7
π Selected for report: TomFrenchBlockchain
Also found by: WatchPug
728.5837 USDC - $728.58
WatchPug
The current implementation of Vader protocol provides impermanent loss coverage calculated as below:
function calculateLoss( uint256 originalVader, uint256 originalAsset, uint256 releasedVader, uint256 releasedAsset ) public pure returns (uint256 loss) { // // TODO: Vader Formula Differs https://github.com/vetherasset/vaderprotocol-contracts/blob/main/contracts/Utils.sol#L347-L356 // // [(A0 * P1) + V0] uint256 originalValue = ((originalAsset * releasedVader) / releasedAsset) + originalVader; // [(A1 * P1) + V1] uint256 releasedValue = ((releasedAsset * releasedVader) / releasedAsset) + releasedVader; // [(A0 * P1) + V0] - [(A1 * P1) + V1] if (originalValue > releasedValue) loss = originalValue - releasedValue; }
An attacker may exploit this by adding liquidity and manipulating the price of the pool (with flash loans) to get IL coverage from the protocol.
Given:
The attacker can:
1M USDV
and 10 BTC
;200 BTC
, swap 20 BTC
to USDV, repeat for 10 times;189055 USDV
and 210 BTC
, current loss
: 630,891 USDV
;coveredLoss
= 630,891 * 30 / 365 = 52k USDV.#0 - SamSteinGG
2021-11-25T12:39:51Z
Duplicate #31
π Selected for report: WatchPug
1619.075 USDC - $1,619.07
WatchPug
uint256 totalLiquidityUnits = totalSupply; if (totalLiquidityUnits == 0) liquidity = nativeDeposit; // TODO: Contact ThorChain on proper approach
In the current implementation, the first liquidity
takes the nativeDeposit
amount and uses it directly.
However, since this number (totalLiquidityUnits
) will later be used for computing the liquidity
issued for future addLiquidity
using calculateLiquidityUnits
.
A malicious user can add liquidity with only 1 wei
USDV and making it nearly impossible for future users to add liquidity to the pool.
Uni v2 solved this problem by sending the first 1000 tokens to the zero address.
The same should work here, i.e., on first mint (totalLiquidityUnits == 0), lock some of the first minter's tokens by minting ~1% of the initial amount to the zero address instead of to the first minter.
#0 - SamSteinGG
2021-11-25T12:30:13Z
Duplicate of #24
#1 - alcueca
2021-12-11T05:08:04Z
Not a duplicate
π Selected for report: WatchPug
WatchPug
function mintFungible( IERC20 foreignAsset, uint256 nativeDeposit, uint256 foreignDeposit, address from, address to ) external override nonReentrant returns (uint256 liquidity) { IERC20Extended lp = wrapper.tokens(foreignAsset); require( lp != IERC20Extended(_ZERO_ADDRESS), "VaderPoolV2::mintFungible: Unsupported Token" ); (uint112 reserveNative, uint112 reserveForeign, ) = getReserves( foreignAsset ); // gas savings nativeAsset.safeTransferFrom(from, address(this), nativeDeposit); foreignAsset.safeTransferFrom(from, address(this), foreignDeposit); PairInfo storage pair = pairInfo[foreignAsset]; uint256 totalLiquidityUnits = pair.totalSupply; if (totalLiquidityUnits == 0) liquidity = nativeDeposit; else liquidity = VaderMath.calculateLiquidityUnits( nativeDeposit, reserveNative, foreignDeposit, reserveForeign, totalLiquidityUnits ); require( liquidity > 0, "VaderPoolV2::mintFungible: Insufficient Liquidity Provided" ); pair.totalSupply = totalLiquidityUnits + liquidity; _update( foreignAsset, reserveNative + nativeDeposit, reserveForeign + foreignDeposit, reserveNative, reserveForeign ); lp.mint(to, liquidity); emit Mint(from, to, nativeDeposit, foreignDeposit); }
Funds are transferred from the from
parameter, and the output tokens are transferred to the to
parameter, both passed by the caller without proper access control.
This issue allows anyone to call mintFungible()
and mintSynth()
and steal almost all their wallet balances for all the users who have approved the contract before.
#0 - SamSteinGG
2021-11-25T12:36:20Z
Duplicate #67
#1 - alcueca
2021-12-10T14:51:12Z
Not a duplicate.
#2 - SamSteinGG
2021-12-16T11:38:37Z
@alcueca Can you elaborate as to why it is not a duplicate?
π Selected for report: WatchPug
WatchPug
Given that mintSynth()
and burnSynth()
will issue and redeem assets based on the price of the pool (reserves), and they will create price impact based on the volume being minted and burnt.
However, the current implementation provides no parameter for slippage control, making them vulnerable to front-run attacks. Especially for transactions with rather large volumes.
Consider adding a minAmountOut
parameter.
π Selected for report: WatchPug
1619.075 USDC - $1,619.07
WatchPug
Per the document:
It also is capable of using liquidity units as collateral for synthetic assets, of which it will always have guaranteed redemption liquidity for.
However, in the current implementation, Synth
tokens are minted based on the calculation result. While nativeDeposit
be added to the reserve, reserveForeign
will remain unchanged, not deducted nor locked.
Making it possible for Synth
tokens to get over-minted.
100,000 USDV
and 1 BTC
;mintSynth()
with 100,000 USDV
, got 0.25 BTC vSynth
;200k USDV
and 1 BTC
.The 0.25 BTC vSynth
held by Bob is now backed by nothing and unable to be redeemed.
This also makes it possible for a sophisticated attacker to steal funds from the Vader pool.
The attacker may do the following in one transaction:
10 USDV
and 10,000 BTC
(flash loan);mintSynth()
with 10 USDV
, repeat for 10 times, got 1461 BTC vSynth
;1461 BTC vSynth
;burnSynth()
to steal USDV
from the pool.#0 - SamSteinGG
2021-12-27T10:15:29Z
Given that the codebase attempts to implement the Thorchain rust code in a one-to-one fashion, findings that relate to the mathematical accuracy of the codebase will only be accepted in one of the following cases:
While intuition is a valid ground for novel implementations, we have re-implemented a battle-tested implementation in another language and as such it is considered secure by design unless proven otherwise.
π Selected for report: WatchPug
1619.075 USDC - $1,619.07
WatchPug
The current design/implementation of Vader pool allows users to addLiquidity
using arbitrary amounts instead of a fixed ratio of amounts in comparison to Uni v2.
We believe this design is flawed and it essentially allows anyone to manipulate the price of the pool easily and create an arbitrage opportunity at the cost of all other liquidity providers.
An attacker can exploit this by adding liquidity in extreme amounts and drain the funds from the pool.
function mintFungible( IERC20 foreignAsset, uint256 nativeDeposit, uint256 foreignDeposit, address from, address to ) external override nonReentrant returns (uint256 liquidity) { IERC20Extended lp = wrapper.tokens(foreignAsset); require( lp != IERC20Extended(_ZERO_ADDRESS), "VaderPoolV2::mintFungible: Unsupported Token" ); (uint112 reserveNative, uint112 reserveForeign, ) = getReserves( foreignAsset ); // gas savings nativeAsset.safeTransferFrom(from, address(this), nativeDeposit); foreignAsset.safeTransferFrom(from, address(this), foreignDeposit); PairInfo storage pair = pairInfo[foreignAsset]; uint256 totalLiquidityUnits = pair.totalSupply; if (totalLiquidityUnits == 0) liquidity = nativeDeposit; else liquidity = VaderMath.calculateLiquidityUnits( nativeDeposit, reserveNative, foreignDeposit, reserveForeign, totalLiquidityUnits ); require( liquidity > 0, "VaderPoolV2::mintFungible: Insufficient Liquidity Provided" ); pair.totalSupply = totalLiquidityUnits + liquidity; _update( foreignAsset, reserveNative + nativeDeposit, reserveForeign + foreignDeposit, reserveNative, reserveForeign ); lp.mint(to, liquidity); emit Mint(from, to, nativeDeposit, foreignDeposit); }
Given:
100,000 USDV
and 1 BTC
;totalPoolUnits
is 100
.The attacker can do the following in one transaction:
100,000 USDV
and 0 BTC, get 50 liquidityUnits
, representing 1/3 shares of the pool;0.1 BTC
to USDV, repeat for 5 times; spent0.5 BTC
and got 62163.36 USDV
;45945.54 USDV
and 0.5 BTC
; profit for: 62163.36 + 45945.54 - 100000 = 8108.9 USDV.#0 - SamSteinGG
2021-11-25T12:37:02Z
This is the intended design of the Thorchain CLP model. Can the warden provide a tangible attack vector in the form of a test?
#1 - alcueca
2021-12-11T06:01:08Z
Sponsor is acknowledging the issue.
#2 - SamSteinGG
2021-12-16T11:37:51Z
@alcueca We do not acknowledge the issue. This is the intended design of the CLP model and the amount supplied for a trade is meant to be safeguarded off-chain. It is an inherent trait of the model.
π Selected for report: WatchPug
1619.075 USDC - $1,619.07
WatchPug
The current formula to calculate the amountOut
for a swap is:
function calculateSwap( uint256 amountIn, uint256 reserveIn, uint256 reserveOut ) public pure returns (uint256 amountOut) { // x * Y * X uint256 numerator = amountIn * reserveIn * reserveOut; // (x + X) ^ 2 uint256 denominator = pow(amountIn + reserveIn); amountOut = numerator / denominator; }
We believe the design (the formula) is wrong and it will result in unexpected and unfavorable outputs.
Specifically, if the amountIn
is larger than the reserveIn
, the amountOut
starts to decrease.
Given:
200,000 USDV
and 2 BTC
.2 BTC
for USDV, will get 50000 USDV
as output;2.1 BTC
for USDV, will only get 49970.25 USDV
as output;2.2 BTC
for USDV, will only get 49886.62 USDV
as output.For the same pool reserves, paying more for less output token is unexpected and unfavorable.
#0 - SamSteinGG
2021-11-25T12:37:25Z
This is the intended design of the Thorchain CLP model. Can the warden provide a tangible attack vector in the form of a test?
#1 - alcueca
2021-12-11T05:59:49Z
It is true that the effect will be surprising to the user, and the issue is acknowledged by the sponsor.
#2 - SamSteinGG
2021-12-16T11:35:53Z
@alcueca We do not acknowledge the issue. This is the intended design of the CLP model and the amount supplied for a trade is meant to be safeguarded off-chain. It is an inherent trait of the model.
π Selected for report: WatchPug
485.7225 USDC - $485.72
WatchPug
There are ERC20 tokens that charge fee for every transfer()
or transferFrom()
, E.g Vader
token.
In the current implementation, BasePoolV2.sol#mint()
assumes that the received amount is the same as the transfer amount, and uses it to calculate liquidity units.
function mint( IERC20 foreignAsset, uint256 nativeDeposit, uint256 foreignDeposit, address from, address to ) external override nonReentrant onlyRouter supportedToken(foreignAsset) returns (uint256 liquidity) { (uint112 reserveNative, uint112 reserveForeign, ) = getReserves( foreignAsset ); // gas savings nativeAsset.safeTransferFrom(from, address(this), nativeDeposit); foreignAsset.safeTransferFrom(from, address(this), foreignDeposit); PairInfo storage pair = pairInfo[foreignAsset]; uint256 totalLiquidityUnits = pair.totalSupply; if (totalLiquidityUnits == 0) liquidity = nativeDeposit; else liquidity = VaderMath.calculateLiquidityUnits( nativeDeposit, reserveNative, foreignDeposit, reserveForeign, totalLiquidityUnits ); require( liquidity > 0, "BasePoolV2::mint: Insufficient Liquidity Provided" ); uint256 id = positionId++; pair.totalSupply = totalLiquidityUnits + liquidity; _mint(to, id); positions[id] = Position( foreignAsset, block.timestamp, liquidity, nativeDeposit, foreignDeposit ); _update( foreignAsset, reserveNative + nativeDeposit, reserveForeign + foreignDeposit, reserveNative, reserveForeign ); emit Mint(from, to, nativeDeposit, foreignDeposit); emit PositionOpened(from, to, id, liquidity); }
Consider calling balanceOf()
to get the actual balances.
#0 - SamSteinGG
2021-11-25T12:32:16Z
Tokens with a fee on transfer or rebasing tokens are not meant to be supported by the protocol, hence why the tokens supported are voted on by the DAO.
#1 - alcueca
2021-12-11T07:24:13Z
Not stated in the documentation, therefore the issue is valid.
#2 - SamSteinGG
2021-12-16T11:42:00Z
A medium risk issue cannot constitute lack of documentation of a trait of the system. If the inclusion of tokens to the DEX was not privileged, the issue would be valid but it is not an open function and thus the scenario described would never unfold.
π Selected for report: WatchPug
WatchPug
Per the comment, the VaderRouterV2#addLiquidity()
is design For Uniswap V2 compliancy. However, the current implementation is not fully compatible with the Uni v2 router interface.
function addLiquidity( IERC20 tokenA, IERC20 tokenB, uint256 amountADesired, uint256 amountBDesired, uint256, // amountAMin = unused uint256, // amountBMin = unused address to, uint256 deadline ) external override returns (uint256 liquidity) {
See: https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02#addliquidity
π Selected for report: WatchPug
161.9075 USDC - $161.91
WatchPug
function rescue(IERC20 foreignAsset) external { uint256 foreignBalance = foreignAsset.balanceOf(address(this)); uint256 reserveForeign = pairInfo[foreignAsset].reserveForeign; uint256 unaccounted = foreignBalance - reserveForeign; foreignAsset.safeTransfer(msg.sender, unaccounted); }
The function rescue
may be able to transfer funds before reserveForeign
gets updated. It's safer to make it nonReentrant
.
#0 - SamSteinGG
2021-11-25T12:30:31Z
This is a no risk finding as it requires non standard erc 20 implementations to be voted as supported by the DAO.
#1 - alcueca
2021-12-11T07:25:25Z
The implementation of foreignAsset
isn't checked anywhere, the issue is valid.
#2 - SamSteinGG
2021-12-16T11:43:33Z
@alcueca There is no way to check the implementation of an EIP on-chain, therefore the stated rationale behind the rating is invalid.
π Selected for report: WatchPug
161.9075 USDC - $161.91
WatchPug
vester.amount -= uint192(vestedAmount);
Downcasting from uint256/int256 in Solidity does not revert on overflow. This can easily result in undesired exploitation or bugs.
Consider using SafeCast
library from OpenZeppelin.
https://docs.openzeppelin.com/contracts/4.x/api/utils#SafeCast
#0 - SamSteinGG
2021-11-25T12:32:45Z
type casting is safe as vestedAmount will at all times be lses than vester.amount which in turn fits within the uint192 limit.
#1 - alcueca
2021-12-11T06:09:42Z
Not necessarily, _getClaim
can increase the amount
parameter that is passed in, and return a value higher than uint192
.
#2 - SamSteinGG
2021-12-16T11:40:16Z
@alcueca The value is guaranteed to be less than the amount that is being vested, otherwise the logical constraints of vesting are broken. Can you elaborate how it can exceed the uint limit?
π Selected for report: WatchPug
161.9075 USDC - $161.91
WatchPug
Router#initialize()
will set reserve
which will later be used for reserve.reimburseImpermanentLoss()
.
However, there is no validation to make sure the asset managed by the reserve
contract matches the nativeAsset
on the Router.
This is an important validation. If let's say a reserve for a more valuable token Vader
is set for a Router for the stable coin USDV
.
Then Vader
will be wrongly used to reimburseImpermanentLoss
causing fund loss to the protocol.
function reimburseImpermanentLoss(address recipient, uint256 amount) external override { require( msg.sender == router, "VaderReserve::reimburseImpermanentLoss: Insufficient Priviledges" ); uint256 actualAmount = _min(reserve(), amount); vader.safeTransfer(recipient, actualAmount); emit LossCovered(recipient, amount, actualAmount); }
Validate the reserve.vader()
and make sure it matchs Router.nativeAsset
.
12.5116 USDC - $12.51
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:
if (originalValue > releasedValue) loss = originalValue - releasedValue;
loss = originalValue - releasedValue
will never underflow.
uint256 fee = calculateFee(); uint256 tax = (amount * fee) / _MAX_BASIS_POINTS; amount -= tax;
amount -= tax
will never underflow.
#0 - SamSteinGG
2021-11-20T06:53:46Z
Duplicate of #43
30.893 USDC - $30.89
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:
LinearVesting.sol#constructor()
GovernorAlpha.sol#veto()
#0 - SamSteinGG
2021-11-20T06:50:19Z
Duplicate of #94
9.0084 USDC - $9.01
WatchPug
Every reason string takes at least 32 bytes.
Use short reason strings that fits in 32 bytes or it will become more expensive.
Instances include:
function _onlyGuardian() private view { require( msg.sender == guardian, "GovernorAlpha::_onlyGuardian: only guardian can call" ); } // ensures only {timelock} is able to a particular function. function _onlyTimelock() private view { require( msg.sender == address(timelock), "GovernorAlpha::_onlyTimelock: only timelock can call" ); } // ensures only {council} is able to a particular function. function _onlyCouncil() private view { require( msg.sender == council, "GovernorAlpha::_onlyCouncil: only council can call" ); }
require( amount != 0, "Converter::convert: Non-Zero Conversion Amount Required" ); bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount)); require( !claimed[leaf] && proof.verify(root, leaf), "Converter::convert: Incorrect Proof Provided" );
#0 - SamSteinGG
2021-11-20T06:47:33Z
Duplicate of #104
π Selected for report: WatchPug
68.651 USDC - $68.65
WatchPug
Some storage variables include rewardsToken
and stakingToken
are unnecessary as they will never be changed.
Change to immutable
can save gas.
π Selected for report: WatchPug
68.651 USDC - $68.65
WatchPug
IERC20(tokenAddress).safeTransfer(owner, tokenAmount);
The recoverERC20()
function is onlyOwner
, therefore, at L162, owner
can be change to msg.sender
directly to avoid unnecessary storage read of owner
to save some gas.
Change to:
IERC20(tokenAddress).safeTransfer(msg.sender, tokenAmount);
π Selected for report: WatchPug
WatchPug
For public functions, the input parameters are copied to memory automatically which costs gas. If a function is only called externally, making its visibility as external will save gas because external functionsβ parameters are not copied into memory and are instead read from calldata directly.
For example:
π Selected for report: WatchPug
68.651 USDC - $68.65
WatchPug
for (uint256 i = 0; i < length; i++) { _queueOrRevert( proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], eta ); }
for (uint256 i = 0; i < length; i++) { timelock.executeTransaction{value: proposal.values[i]}( proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta ); }
for (uint256 i = 0; i < length; i++) { timelock.cancelTransaction( proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], proposal.eta ); }
External call to timelock
will be called each time in these for loops. They can be combined into batch external call by creating timelock.***Batch(...)
function and call that function instead.
#0 - SamSteinGG
2021-11-25T17:45:08Z
Agreed but we don't want to change the timelock contract as this is forked from Comp and max actions are 10.