Platform: Code4rena
Start Date: 01/05/2024
Pot Size: $12,100 USDC
Total HM: 1
Participants: 47
Period: 7 days
Judge: Koolex
Id: 371
League: ETH
Rank: 3/47
Findings: 1
Award: $386.08
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xnev
Also found by: 0x04bytes, 0xBugSlayer, 0xJoyBoy03, 0xSecuri, 0xrex, Bigsam, DMoore, Evo, Greed, Kirkeelee, Krace, Pechenite, Rhaydden, SBSecurity, Sajjad, TheFabled, Topmark, XDZIBECX, ZanyBonzy, _karanel, bbl4de, btk, d3e4, gumgumzum, nfmelendez, novamanbg, petarP1998, samuraii77, sandy, shaflow2, sldtyenj12, web3er, y4y, yovchev_yoan
284.4444 USDC - $284.44
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:
startClaimDate
is reached and _claim()
function can now be called._claim()
function._claim()
function, _fillQuote()
function executes and 0.001
LRT locked is converted to 0.001
ETH(lets assume).0.001
lpETH, she will get 100.001
lpETH as claimedAmount
is set to address(this).balance
.100
lpETH 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.
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.
Manual Analysis
_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); }
Other
#0 - c4-judge
2024-05-15T14:26:29Z
koolexcrypto marked the issue as duplicate of #6
#1 - c4-judge
2024-05-15T14:27:28Z
koolexcrypto marked the issue as partial-75
#2 - c4-judge
2024-05-31T09:58:21Z
koolexcrypto marked the issue as duplicate of #33
#3 - c4-judge
2024-06-05T08:50:47Z
koolexcrypto marked the issue as full credit
#4 - c4-judge
2024-06-05T09:55:19Z
koolexcrypto marked the issue as satisfactory