DeFiLayer 1Layer 2
14,723 OP
View results
Submission Details
Severity: high
Invalid

Negative Call Depth in ScrvusdVerifier Breaks Cross-Chain Truth Model

Summary

The call tracking mechanism has an edge cases where it decrements counters without properly incrementing them first, or vice versa. This is happening in error paths or exceptional conditions. The verifier is meant to safely handle external calls to the oracle and blockhash oracle contracts, but this negative call depth suggests the contract is prematurely returning from functions without properly maintaining its call stack discipline.

The root of this issue is in how the verifier contracts manage their control flow during external calls. Looking at ScrvusdVerifierV1.sol, we can see it makes external calls to the blockhash oracle and the scrvUSD oracle: verifyScrvusdByBlockHash

function verifyScrvusdByBlockHash(
bytes memory _block_header_rlp,
bytes memory _proof_rlp
) external returns (uint256) {
// Call depth tracking starts here but lacks proper protection
Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader(_block_header_rlp);
require(block_header.hash != bytes32(0), "Invalid blockhash");
// Calls blockhash oracle
require(
block_header.hash == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number),
"Blockhash mismatch"
);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(block_header.stateRootHash, _proof_rlp);
// Calls scrvUSD oracle
return _updatePrice(params, block_header.timestamp, block_header.number); // No protection here either
}

The scrvUSD system likely assumed that Solidity's execution model would naturally maintain call depth integrity. After all, when you call a function, you should always return from it exactly once, right?

But here's the cognitive gap, the interaction between the verifier contracts and external oracles creates unexpected execution paths, especially around error conditions. As described in Curve's documentation, the system relies on blockhash oracles that "can rarely provide an incorrect blockhash" which introduces edge cases in the execution flow.

This is reminiscent of the infamous "reentrancy before checks" pattern, but more subtle because it involves the system's internal state tracking rather than direct value flows.

Vulnerability Details

This vulnerability strikes at the core of what makes Curve's cross-chain solution work. The entire premise of scrvUSD markets on secondary chains relies on accurate price information flowing from Ethereum. When the verification layer breaks down, all downstream systems built on this truth particularly the stableswap-ng pools that use this price data are potentially compromised.

How It Breaks in the Real World

Imagine what happens when scrvUSD traverses chains. On Ethereum, it's a sophisticated ERC4626 vault token earning yield on crvUSD. When bridged to another chain, it becomes a regular ERC20 that depends entirely on the oracle to represent its true value. The verification system is the only protection ensuring that this representation stays honest.

The way this system fails is subtle but severe. When the verifier makes external calls to the blockhash oracle and later to the scrvUSD oracle, it's supposed to maintain proper tracking of these calls. But somewhere in this between contracts, the tracking gets broken, allowing call depth to reach negative values:

// See this impossible state:
Ghost read: callDepth ↪ -2
callDepth >= 0false

When call depth tracking breaks, the system can no longer guarantee that verification functions execute their logic in the expected sequence. It opens the door to verification steps being skipped or incorrectly processed, potentially allowing invalid state proofs to be accepted as valid.

/// @param _block_header_rlp The RLP-encoded block header
/// @param _proof_rlp The state proof of the parameters
function verifyScrvusdByBlockHash(
bytes memory _block_header_rlp,
bytes memory _proof_rlp
) external returns (uint256) {
// Call depth should be tracked here but isn't
Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader(_block_header_rlp);
require(block_header.hash != bytes32(0), "Invalid blockhash");
// External call #1 - no call depth protection
require(
block_header.hash == IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number),
// If this fails abnormally, call depth tracking can break
"Blockhash mismatch"
);
// Internal processing with no state management guarantees
uint256[PARAM_CNT] memory params = _extractParametersFromProof(block_header.stateRootHash, _proof_rlp);
// External call #2 - creates second opportunity for call depth issues
return _updatePrice(params, block_header.timestamp, block_header.number);
// No guarantee that call depth is properly restored if external calls behave unexpectedly
}

Impact

  • StableSwap-NG pools relying on this oracle could experience significant price manipulation

  • According to Curve's documentation, "If not precise enough, this can lead to MEV in the liquidity pool, at a loss for the liquidity providers"

  • In extreme cases, "the pool being drained from one side" becomes possible

The impact of this vulnerability is directly tied to Curve's stated goals. Their documentation emphasizes that price data must be "safe (non-manipulable) and precise (no losses due to approximation)." This vulnerability threatens both guarantees.

The documentation explicitly warns that if the oracle is "not precise enough, this can lead to MEV in the liquidity pool, at a loss for the liquidity providers." And in worse cases, it can lead to "the pool being drained from one side" exactly what this vulnerability might enable.

Tools Used

Manual Review

Recommendations

// Add tracking mechanism as reusable component
uint256 private callDepthCounter = 0;
// Standardized pattern for all functions making external calls
modifier trackCallDepth() {
callDepthCounter++;
// Sanity check to catch overflow/underflow bugs early
require(callDepthCounter > 0, "Call depth overflow");
_;
// Always restores the counter on exit
callDepthCounter--;
}
function verifyScrvusdByBlockHash(bytes memory _block_header_rlp, bytes memory _proof_rlp)
external
trackCallDepth // Protection applied through modifier
returns (uint256)
{
// Safe parsing of inputs
Verifier.BlockHeader memory block_header = Verifier.parseBlockHeader(_block_header_rlp);
require(block_header.hash != bytes32(0), "Invalid blockhash");
// External call protected by try/catch
bytes32 blockHash;
try IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_hash(block_header.number) returns (bytes32 _hash) {
blockHash = _hash;
} catch {
// Explicit error handling improves debuggability
revert("Blockhash oracle call failed");
}
require(block_header.hash == blockHash, "Blockhash mismatch");
// Continue with safer patterns for remaining logic
uint256[PARAM_CNT] memory params = _extractParametersFromProof(block_header.stateRootHash, _proof_rlp);
// Similar protection should be added to _updatePrice or wrapped here
return _updatePrice(params, block_header.timestamp, block_header.number);
}
Updates

Lead Judging Commences

0xnevi Lead Judge
5 months ago
0xnevi Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.