Ethereum Foundation

EF Eip7251 audit report

Ethereum Foundation

SUMMARY

Critical 0
High 0
Medium 1
Low 0
Advisory 3
Total: 4

ABSTRACT

Dedaub was commissioned to perform a security audit of the system contracts for EIP7251. In total one medium severity Denial of Service related issue was found in addition to three advisory issues, two of which are related to potential gas optimisations.

BACKGROUND

Currently, Ethereum caps each validator’s effective balance at 32 ETH, with this also being the minimum number of ETH required to participate as a validator. In practice, this means a few things:

  • Accrued rewards do not “compound” with your staked ETH
  • Users wishing to operate validators must have a multiple of 32 ETH, leaving smaller operators with “under-utilized” ETH
  • Increased network traffic, as larger entities are forced to split their stake across multiple validators, despite in many cases co-locating validators on the same machine.
  • This also causes wasted computation, increasing the environmental footprint of the network

EIP-7251 addresses these issues by raising the maximum effective balance a single validator can have, whilst still retaining the 32 ETH minimum for validator activation. To facilitate large operators consolidating their staking operations the EIP introduces consolidation requests, which allows one validator to transfer its staked ETH to another validator. Without providing this mechanism validators would be forced to take one of their validators offline and deposit into their second validator, which en-masse could have potentially caused issues for the network.

SETTING & CAVEATS

This audit report mainly covers the contracts of the sys-asm repository at commit 1d679164e03d73dc7f9a5331b67fd51e7032b104, as well as the code, pseudocode, and specifications in the EIP (considering the EIP definition at commit 28ee5e8562a1d4437032718dab1b8fa5b031bda4 of the ethereum/EIPs repository).

2 auditors worked on the codebase for 4 days (each) on the following contracts:

src//
├── consolidations/
│   ├── ctor.eas
│   └── main.eas
└── common/
    └── fake_expo.eas

Throughout the audit the auditors reviewed the code at numerous levels of abstraction, including the Geas code itself, the disassembled bytecode, as well as the three-address-code (TAC) and decompilation produced by our decompiler. This was done to ensure both human and programmatic understanding of the code, as well as isolating any potential bugs introduced by Geas.

The audit was primarily focused on the security of the smart contracts, and their adherence to the given specification. How the rest of the EIP is implemented into the protocol is outside of the scope of this audit, however, the auditors did attempt to identify potential cross-cutting issues based on the code in scope.

CONTRACT LOGIC OVERVIEW

The contract’s logic is divided into two main flows based on the caller’s address: a “system” path and a “user” path. If the caller is the system address (0xfffffffffffffffffffffffffffffffffffffffe) the contract runs the system path; otherwise, it runs the user path.

On the user path, if no call data and no value are provided, the contract returns the current request fee. If valid call data and sufficient ETH are sent, the contract adds the user’s request to a queue. The required ETH is determined by an exponential function based on the number of excess requests (i.e., those beyond the target). Each successfully added request increments a counter tracking the total requests for the current block.

On the system path, the contract dequeues up to MAX_CONSOLIDATION_REQUESTS _PER_BLOCK requests (the maximum is currently set at 1). It should be noted that removing requests from the queue does not delete their used storage slots. After processing these, it resets the request counter and updates the “excess” value, which records how many requests exceeded the target (specified by the TARGET_CONSOLIDATION_REQUESTS_PER_BLOCK parameter, currently set at 1). Finally, the contract returns the collected requests.

Fee Calculation

Submitting a new request is associated with a fee, which is computed based on the number of excess requests over the contract’s target. The motivation behind this fee is DoS protection, as given in EIP-7002, which implements the same mechanism.

Specifically, the fee calculation approximates the following formula: MIN_WITHDRAWAL_REQUEST_FEE * e**(excess / WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION)

with the current values of WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION and MIN_WITHDRAWAL_REQUEST_FEE being 17 and 1 respectively.

The number of excess requests is updated once per block, during the system call.

The following table presents the fee cost in ETH, for different excess request numbers:

**# Excess Requests**

Fee (ETH)

0

1.00e-18

50

1.80e-17

100

3.57e-16

150

6.78e-15

200

1.29e-13

250

2.44e-12

300

4.61e-11

350

8.73e-10

400

1.65e-08

450

3.13e-07

500

5.93e-06

550

0.0001123830375

600

0.002128277913

650

0.04030500032

700

0.7632918597

750

14.4559702

800

273.7625199

850

5184.695758

Decompiled Contract

The annotated decompiled code expresses the entirety of the contract’s logic:

User Path

require(uint256.max != excess_request_count);
// fake_exponential starts here
v11 = v12 = 17;
v13 = v14 = 1;
v15 = v16 = 0;
while (v11 > 0) {
    v15 += v11;
    v11 = excess_request_count * v11 / (v13 * 17);

    v13 += 1;
}
// fake_exponential ends here, result is "v15 / 17"
if (96 == msg.data.length) {
    // add_consolidation_request starts here
    require(msg.value >= v15 / 17);
    request_count += 1;
    // stores msg.sender (left padded)
    STORAGE[4 + 4 * queue_tail_index] = msg.sender;

    // stores first 32 bytes of source_pubkey
    STORAGE[5 + 4 * queue_tail_index] = calldata_0_32;

    // stores last 16 bytes of source_pubkey, first 16 bytes of target_pubkey
    STORAGE[6 + 4 * queue_tail_index] = calldata_32_64;

    // stores last 32 bytes of target_pubkey
    STORAGE[7 + 4 * queue_tail_index] = calldata_64_96;

    // stores msg.sender (20 bytes) to memory at offset 0
    MEM[0] = msg.sender << 96;
    // copies the 2 public keys (96 bytes) to memory at offset 20
    CALLDATACOPY(20, 0, 96);
    log MEM[0:116] // logs [msg.sender ++ calldata_0_96];

    queue_tail_index += 1;
    exit;
    // add_consolidation_request ends here
} else {
    // fee_getter starts here
    require(!msg.data.length);
    require(!msg.value);
    return v15 / 17;
    // fee_getter ends here
}

System Path

v0 = v1 = queue_tail_index - queue_head_index;

if (1 <= v1) {
    v0 = 1;
}

v3 = 0;
while (v3 != v0) {
    // copies source_address (20 bytes, unpadded) into memory
    MEM[116 * v3] = STORAGE[4 + 4 * (v3 + queue_head_index)] << 96;

    // copies first 32 bytes of source_pubkey into memory
    MEM[20 + 116 * v3] = STORAGE[5 + 4 * (v3 + queue_head_index)];

    // copies last 16 bytes of source_pubkey, first 16 bytes of target_pubkey into memory
    MEM[52 + 116 * v3] = STORAGE[6 + 4 * (v3 + queue_head_index)];

    // copies last 32 bytes of target_pubkey into memory
    MEM[84 + 116 * v3] = STORAGE[7 + 4 * (v3 + queue_head_index)];

    v3 += 1;
}
if (queue_tail_index == queue_head_index + v0) {
    queue_head_index = 0;
    queue_tail_index = 0;
} else {
    queue_head_index = queue_head_index + v0;
}
// dequeue_consolidation_requests ends here
// update_excess_consolidation_requests starts here
v5 = v6 = excess_request_count;
if (uint256.max == v6) {
    v5 = 0;
}
if (request_count + v5 > 1) {
    v8 = request_count + v5 - 1;

} else {
    v8 = 0;
}

excess_request_count = v8;
// update_excess_consolidation_requests ends here
// reset_consolidation_requests_count starts here
request_count = 0;
// reset_consolidation_requests_count ends here
return MEM[0:116 * v3];

Diagrams

To assist with the understanding of the contract we have produced a series of diagrams, the following outlines the storage layout of the contract and the layout of requests in storage.

We have also produced a fully annotated control flow diagram for the contract, which can be seen below. A full interactive diagram can be found here: https://link.excalidraw.com/readonly/fzRqgsPx3k5lCj1EEQwq

USER LEVEL CONSIDERATIONS

The fee required to submit a new consolidation request is dynamic and can potentially change between transaction submission and inclusion. As the overpaid fees are not returned by the contract, EIP authors suggest submitting requests via a wrapper contract that first fetches the required fee and then submits a request with the acquired fee. However, as the fee change can be drastic, we suggest contracts tasked with initiating consolidation requests are called with an additional parameter containing the maximum fee the contract wants to pay to submit a request.

Therefore, we suggest editing the example provided in the EIP text to the following:

function addConsolidation(
    bytes memory srcPubkey, bytes memory targetPubkey, uint256 maxFee
) private {
    assert(srcPubkey.length == 48);
    assert(targetPubkey.length == 48);

    // Read current fee from the contract.
    (bool readOK, bytes memory feeData) =
      ConsolidationsContract.staticcall('');
    if (!readOK) {
        revert('reading fee failed');
    }
    uint256 fee = uint256(bytes32(feeData));
    require(fee <= maxFee, "Maximum fee exceeded!")

    // Add the request.
    bytes memory callData = bytes.concat(srcPubkey, targetPubkey);
    (bool writeOK,) = ConsolidationsContract.call{value: fee}(callData);
    if (!writeOK) {
        revert('adding request failed');
    }
}

VULNERABILITIES & FUNCTIONAL ISSUES

This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:

Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.

CRITICAL SEVERITY

    [No critical severity issues]

HIGH SEVERITY

[No critical high issues]

MEDIUM SEVERITY

Fee update logic can lead to DoS

Medium | Status: OPEN

Given that the number of excess requests is updated once per block (at the end of its processing), it allows a user to submit multiple requests for the same fee without experiencing the rate-limiting effects of the fee calculation function.

Taking this to the extreme an attacker could utilise an entire block to fill the contract with “bogus” requests for the sole purpose of driving up the fee for the following blocks. This would result in the contract being unusable until the excess came down to a reasonable level (a few hours of “downtime”). This attack can easily be mitigated by updating the number of excess requests every time a new request is submitted, instead of once per block.

Although maintaining the attack will be costly and complex, we believe opportunities to DoS the contract for a few hours will be frequent and, given the simple mitigation, believe it should be fixed.

Attack specifics:

To optimize the gas consumption of the attack the 96 bytes of call data provided will all be zeros. This will mean that each new request will only write an additional storage slot, containing the caller’s address. Additionally, if the current queue tail points to slots that have been written previously, the attack will end up freeing storage space, further increasing our attack’s efficiency.

To demonstrate this we consider 2 scenarios:

  • When writing on a part of the queue that has not been written it is possible to create 780 requests in a single transaction. This will mean that, without any additional requests, the excess will return to its previous position after 2.6 hours.
  • If all written storage slots have been used before, the number of requests that can fit in a single transaction rises to 1650, introducing a 5.5 hour delay.

Looking at the earlier table containing the fee values for different numbers of excess requests one can see that, if the attack is performed given favorable conditions (e.g starting with around 500 excess requests), the system will be unusable due to extremely high fees for the majority of the added delay.

LOW SEVERITY

    [No low severity issues]

OTHER / ADVISORY ISSUES

This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.

Contract’s storage footprint is unbounded

Advisory | Status: INFO

The separation of the system’s maximum and target request parameters allows the system to temporarily process more requests than the system’s target. Consistent loads over the maximum will lead to ever-increasing fees, eventually leading to the system’s queue catching up, at which point queue pointers are reset and past storage slots are reused.

However, this does not hold in our system due to the use of the same value for both MAX_CONSOLIDATION_REQUESTS_PER_BLOCK and TARGET_CONSOLIDATION_ REQUESTS_PER_BLOCK parameters. As a result, the contract can extend its queue indefinitely without any adverse effects.

Enabling the system to process more requests by increasing its MAX_CONSOLIDATION_REQUESTS_PER_BLOCK value will allow it to catch up to the queue’s tail, and then reset the head and tail pointers, facilitating storage space reuse.

Fee calculation can be optimized

Advisory | Status: INFO

Although our earlier suggestion for the fee calculation was to update the number of excess requests at each request submission, if that change is not made the contracts can be optimized by assigning the calculated fee value once per block, instead of calculating it twice per request submission. The rationale behind this is that any action performed on the system side is “free”, and pre-calculating the fee acts like memoization for the users.

Constant operation can be optimized away

Advisory | Status: INFO

The multiplication at the beginning of the “fake exponentiation” function, is one between 2 constants and could be optimized away.

As seen in the following geas snippet the multiplication is between the WITHDRAWAL_REQUEST_FEE_UPDATE_FRACTION and MIN_WITHDRAWAL_REQUEST_FEE constant parameters and could be optimized away:

;; fake exponentiation
;; input stack = [factor, numerator, denominator]

dup3    	;; [denom, factor, numer, denom]
mul     	;; [accum, numer, denom]
...

DISCLAIMER

The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.

ABOUT DEDAUB

Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub’s auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.