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: 19/47
Findings: 2
Award: $213.33
š 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
213.3333 USDC - $213.33
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L172 https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L240
The PrelaunchPoints
contract allows a user to lock a very small amount of LRT tokens, and right before or during a claim, the user can transfer a large amount of ETH directly to the contract.
Originally, by design, lpETH tokens should be gained only from the contract by staking LRT tokens during the locking period and then claiming them after the locking period has ended.
However, using the method described above, a user will end up avoiding uncertainty and risks associated with the staking process and being able to claim as many LRT tokens as they want even after the locking period has ended. Even though the user isn't getting those lpETH tokens for free, the user is bypassing the staking process and avoiding the risks associated with it.
Following that, the documentation also states, "Deposits are active up to the lpETH contract and lpETHVault contract are set
" which is an invariant, that is broken here and further more confirms this finding.
The amounts are simplified for the sake of easier understanding
The following Foundry test can be added to test/PrelaunchPoints.t.sol
to demonstrate this finding:
Run the test using this command:
forge test --match-test "test_DepositAndStakeAfterTheClaimStartDate" -vv
function test_DepositAndStakeAfterTheClaimStartDate() public { uint256 lockAmount = 1; address user1 = vm.addr(1); lrt.mint(user1, lockAmount); vm.startPrank(user1); lrt.approve(address(prelaunchPoints), lockAmount); // Alice locks 1 LRT token prelaunchPoints.lock(address(lrt), lockAmount, referral); vm.stopPrank(); // Set Loop Contracts and Convert to lpETH prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault)); // Locking period ends vm.warp( prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1 ); prelaunchPoints.convertAllETH(); vm.warp(prelaunchPoints.startClaimDate() + 1); bytes memory data = abi.encodeWithSelector( 0x415565b0, address(lrt), ETH, ((lockAmount * 1) / 100) ); vm.deal(user1, 10); vm.prank(user1); // Alice sends 10 wei directly to the contract, AFTER the locking period has ended (bool success, ) = address(prelaunchPoints).call{value: 10}(""); uint256 temp = lpETH.balanceOf(address(user1)); console.log("Alice's lpETH tokens before: ", temp); vm.prank(user1); // Alice claims the staked amount and receives 10 lpETH tokens // Originally, Alice should have received 1 lpETH as she locked only 1 LRT token, but she received 10 lpETH tokens prelaunchPoints.claim( address(lrt), 1, PrelaunchPoints.Exchange.TransformERC20, data ); temp = lpETH.balanceOf(address(user1)); console.log("Alice's lpETH tokens after: ", temp); }
Manual Review
Disabling the transfer of ETH directly to the contract after the locking period or disabling the transfer of ETH directly as a whole are some possible solutions.
ETH-Transfer
#0 - c4-judge
2024-05-15T14:36:03Z
koolexcrypto marked the issue as duplicate of #6
#1 - c4-judge
2024-05-31T09:58:22Z
koolexcrypto marked the issue as duplicate of #33
#2 - c4-judge
2024-06-05T08:49:06Z
koolexcrypto marked the issue as partial-75
#3 - c4-judge
2024-06-05T09:55:53Z
koolexcrypto changed the severity to 3 (High Risk)
#4 - radeveth
2024-06-10T09:18:30Z
Hey @koolexcrypto!
I think this issue should be treated as a 100% duplicate of #33, since for example issue #26 describes the exact same exploit scenario as this issue and was selected as a 100% duplicate of issue #33.
#5 - koolexcrypto
2024-06-10T20:05:39Z
Hi @radeveth
The issue takes 75% credit due to the quality. For example, "staking LRT tokens during the locking period"
There is no staking of LRT tokens. It's only locking, staking is for lpETH which happens after claiming.
š Selected for report: pamprikrumplikas
Also found by: 0xnev, 0xrex, ParthMandale, Pechenite, SpicyMeatball, ZanyBonzy, caglankaan, chainchief, cheatc0d3, karsar, krisp, oualidpro, peanuts, popeye, slvDev
0 USDC - $0.00
Number | Issue |
---|---|
Gā1 | Owner can miss calling setLoopAddresses() on time |
Gā2 | Compromised owner can allow malicious tokens |
Number | Issue |
---|---|
Lā1 | ETH sent directly for a contract will not be locked forever |
Lā2 | Allow users to withdraw a portion of their locked tokens |
Lā3 | Implement a function for removing allowed tokens |
Lā4 | User can steal all of the locked ethers for himself |
setLoopAddresses()
on timeThe setLoopAddresses()
must be called before the loopActivation timestamp. If the owner forgets or misses to call this function on time, the lpETH
and lpETHVault
variables would be null and the whole contract's logic will be bricked.
function setLoopAddresses(address _loopAddress, address _vaultAddress) external onlyAuthorized ā onlyBeforeDate(loopActivation) { lpETH = ILpETH(_loopAddress); lpETHVault = ILpETHVault(_vaultAddress); loopActivation = uint32(block.timestamp); emit LoopAddressesUpdated(_loopAddress, _vaultAddress); }
The allowToken()
function allows the owner to add any token to the allowed tokens list. If the owner's account is compromised, the attacker can add a malicious token to the allowed tokens list and break contract's logic.
function allowToken(address _token) external onlyAuthorized { isTokenAllowed[_token] = true; }
According to the NatSpec comment of the receive()
function: "ETH sent to this contract directly will be locked forever." - all ethers sent directly to the contract should be locked forever. However, ETH sent directly to the contract won't be locked forever but will be distributed among users who have locked ETH.
If the contract receives ETH directly before convertAllETH()
is called, the ETH in the contract will be converted to lpETH tokens and distributed among users who have locked ETH. This means that the ETH sent directly to the contract will not be locked forever.
function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) { if (block.timestamp - loopActivation <= TIMELOCK) { revert LoopNotActivated(); } // deposits all the ETH to lpETH contract. Receives lpETH back ā uint256 totalBalance = address(this).balance; lpETH.deposit{value: totalBalance}(address(this)); // @audit modifies totalLpETH to be the balance of lpETH ā totalLpETH = lpETH.balanceOf(address(this)); // Claims of lpETH can start immediately after conversion. startClaimDate = uint32(block.timestamp); emit Converted(totalBalance, totalLpETH); }
and then inside the _claim()
function the lpETH tokens will be distributed among users
if (_token == ETH) { // @audit 'totalLpETH' will be modified and excess ETH will be distributed to users ā claimedAmount = userStake.mulDiv(totalLpETH, totalSupply); balances[msg.sender][_token] = 0; lpETH.safeTransfer(_receiver, claimedAmount); } else {
Currently there is only one withdraw()
function. It allows a user to withdraw all of his locked tokens, but there isn't a way to make a withdraw of specific amount of tokens.
This behavior exists in the claim functions, but is missing for withdraw which creates inconsistency and a discrepancy in the user experience.
// @audit there is no way to withdraw a specific amount of tokens function withdraw(address _token) external {
Currently there isn't a way to remove a token from the allowed tokens list. Consider implementing a function for removing allowed tokens.
There is a function for adding allowed tokens but not for removing them.
function allowToken(address _token) external onlyAuthorized { isTokenAllowed[_token] = true; }
According to the starter guide of 0x API, the exchange proxy contract should be provided with some wei, to cover fees..
The problem is that _fillQuote
function doesn't provide any fees to the exchange proxy. Which would result in a failed swap.
function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal { // Track our balance of the buyToken to determine how much we've bought. uint256 boughtETHAmount = address(this).balance; require(_sellToken.approve(exchangeProxy, _amount)); // @audit hardcoded value of 0 ā (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); }
#0 - CloudEllie
2024-05-11T17:25:01Z
#1 - 0xd4n1el
2024-05-13T17:32:03Z
Good report
#2 - c4-judge
2024-06-03T10:49:06Z
koolexcrypto marked the issue as grade-a