Malicious user can claim any amount of ``lpeth`` and stake that amount without depositing equivalent amount of ETH/WETH or other LRT's in a specific case.
howlbot-integration opened this issue · 5 comments
Lines of code
Vulnerability details
Impact
After you lock some amount of ETH/WETH or LRTs for certain LoopActivation period(max 120days), 7 days TIMELOCK and up to startClaimDate(can be months, years), you are finally allowed to claim equivalent amount of lpEth using the _claim() function.
function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
internal
returns (uint256 claimedAmount)
{
uint256 userStake = balances[msg.sender][_token];
if (userStake == 0) {
revert NothingToClaim();
}
if (_token == ETH) {
claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
balances[msg.sender][_token] = 0;
lpETH.safeTransfer(_receiver, claimedAmount);
} else {
uint256 userClaim = userStake * _percentage / 100;
_validateData(_token, userClaim, _exchange, _data);
balances[msg.sender][_token] = userStake - userClaim;
// At this point there should not be any ETH in the contract
// Swap token to ETH
_fillQuote(IERC20(_token), userClaim, _data);
// Convert swapped ETH to lpETH (1 to 1 conversion)
claimedAmount = address(this).balance;
lpETH.deposit{value: claimedAmount}(_receiver);
}
emit Claimed(msg.sender, _token, claimedAmount);
}Now, if you stake ETH/WETH, you are directly transferred lpETH in 1 to 1 conversion ratio. But for LRTs, first the LRT token needs to be swapped into ETH. It is done with _fillQuote() function. Before _fillQuote() function is executed inside _claim() function, It is assumed that there should not be any ETH in the contract. It is because the amount of lpETH minted for receiver is equal to amount of ETH this contract receives after _fillQuote() function is executed.
// At this point there should not be any ETH in the contract
// Swap token to ETH
_fillQuote(IERC20(_token), userClaim, _data);
// Convert swapped ETH to lpETH (1 to 1 conversion)
claimedAmount = address(this).balance;
lpETH.deposit{value: claimedAmount}(_receiver);Now, Consider this scenario:
- Alice is a malicious user who locks 0.001(any minimum) amount of any LRT token.
- Now, After some time
startClaimDateis reached and_claim()function can now be called. - Alice then deposits any amount of ETH(lets assume 100ETH) into the contract and calls the
_claim()function. - Inside the
_claim()function,_fillQuote()function executes and0.001LRT locked is converted to0.001ETH(lets assume). - Now, instead of Alice getting
0.001lpETH, she will get100.001lpETH asclaimedAmountis set toaddress(this).balance. - Alice instantly gets
100lpETH without even locking that amount.
Note: Alice can also call
claimAndStake()funciton to stake that amount and get even more rewards.
Alice can execute above hack anytime as there is no time-limit to claim.
Proof of Concept
Theoretical PoC is given above. Coded can't be provided as it requires a valid calldata for the swap of LRT from the exchanges and that calldata is validated and also _fillQuote() is called with same calldata.
Tools Used
Manual Analysis
Recommended Mitigation Steps
_fillQuote() function should return boughtETHAmount and that same boughETHAmount should be used by _claim() function.
_ function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+ function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns( uint256 boughtETHAmount) {
// Track our balance of the buyToken to determine how much we've bought.
_ uint256 boughtETHAmount = address(this).balance;
+ boughtETHAmount = address(this).balance;
require(_sellToken.approve(exchangeProxy, _amount));
(bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
if (!success) {
revert SwapCallFailed();
}
// Use our current buyToken balance to determine how much we've bought.
boughtETHAmount = address(this).balance - boughtETHAmount;
emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
} function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
internal
returns (uint256 claimedAmount)
{
uint256 userStake = balances[msg.sender][_token];
if (userStake == 0) {
revert NothingToClaim();
}
if (_token == ETH) {
claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
balances[msg.sender][_token] = 0;
lpETH.safeTransfer(_receiver, claimedAmount);
} else {
uint256 userClaim = userStake * _percentage / 100;
_validateData(_token, userClaim, _exchange, _data);
balances[msg.sender][_token] = userStake - userClaim;
// At this point there should not be any ETH in the contract
// Swap token to ETH
_ _fillQuote(IERC20(_token), userClaim, _data);
+ uint256 outputAmount = _fillQuote(IERC20(_token), userClaim, _data);
// Convert swapped ETH to lpETH (1 to 1 conversion)
_ claimedAmount = address(this).balance;
_ lpETH.deposit{value: claimedAmount}(_receiver);
+ lpETH.deposit{value: claimedAmount}(outputAmount);
}
emit Claimed(msg.sender, _token, outputAmount);
}Assessed type
Other
koolexcrypto marked the issue as partial-75
koolexcrypto marked the issue as full credit
koolexcrypto marked the issue as satisfactory