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

Incorrect Storage Slot for balance_of_self in ScrvusdVerifierV1 Causing Catastrophic Price Distortion

Summary

In ScrvusdVerifierV1 , The storage slot for balance_of_self, defined as BALANCE_OF_SELF_SLOT = 18, does not correspond to the actual slot of balanceOf[address(this)] in the scrvUSD vault contract (Yearn V3 Vault at 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367). Instead of extracting the vault’s self-balance from the correct mapping slot (keccak256(abi.encode(address(this), 7))), it retrieves the role_manager address from slot 18, misinterpreting it as a uint256 value. This error propagates to the ScrvusdOracleV2, inflating _unlocked_shares and causing extreme price distortions in the v2 price mode.

Vulnerability Details

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/verifiers/ScrvusdVerifierV1.sol#L32

https://github.com/CodeHawks-Contests/2025-03-curve/blob/198820f0c30d5080f75073243677ff716429dbfd/contracts/scrvusd/verifiers/ScrvusdVerifierV1.sol#L83

Root Cause:

  • In ScrvusdVerifierV1, BALANCE_OF_SELF_SLOT = 18 is used to fetch balance_of_self

// Common constants
address constant SCRVUSD = 0x0655977FEb2f289A4aB78af67BAB0d17aAb84367;
bytes32 constant SCRVUSD_HASH = keccak256(abi.encodePacked(SCRVUSD));
// Storage slots of parameters
uint256[PROOF_CNT] internal PARAM_SLOTS = [
uint256(0), // filler for account proof
uint256(21), // total_debt
uint256(22), // total_idle
uint256(20), // totalSupply
uint256(38), // full_profit_unlock_date
uint256(39), // profit_unlocking_rate
uint256(40), // last_profit_update
uint256(keccak256(abi.encode(18, SCRVUSD))) // balanceOf(self)
];
/// @dev Extract parameters from the state proof using the given state root.
function _extractParametersFromProof(
bytes32 stateRoot,
bytes memory proofRlp
) internal view returns (uint256[PARAM_CNT] memory) {
RLPReader.RLPItem[] memory proofs = proofRlp.toRlpItem().toList();
require(proofs.length == PROOF_CNT, "Invalid number of proofs");
// Extract account proof
Verifier.Account memory account = Verifier.extractAccountFromProof(
SCRVUSD_HASH,
stateRoot,
proofs[0].toList()
);
require(account.exists, "scrvUSD account does not exist");
// Extract slot values
uint256[PARAM_CNT] memory params;
for (uint256 i = 1; i < PROOF_CNT; i++) {
Verifier.SlotValue memory slot = Verifier.extractSlotValueFromProof(
keccak256(abi.encode(PARAM_SLOTS[i])),
account.storageRoot,
proofs[i].toList()
);
// Slots might not exist, but typically we just read them.
params[i - 1] = slot.value;
}
return params;
}

The scrvUSD vault’s storage layout (Yearn V3 Vault, Vyper 0.3.7) shows:

  • balance_of: HashMap[address, uint256] at slot 7.

  • balanceOf[address(this)] at keccak256(abi.encode(SCRVUSD, 7)).

  • Slot 18 is role_manager: public(address), an unrelated variable.

  • Using slot 18 extracts the role_manager address (e.g., 0x1234...) as a uint256, typically a massive value (e.g., 1.23e40), instead of the vault’s balance.

This will occurs on every call to _extractParametersFromProof when fetching balance_of_self, invoked by verifyScrvusdByBlockHash or verifyScrvusdByStateRoot, which then calls update_price in ScrvusdOracle.

The Intended Behavior Is Suppose to be:

  • balance_of_self should reflect balanceOf[address(this)] from the vault, representing shares held by the vault itself (locked profits). This value adjusts _unlocked_shares to account for profit unlocking, ensuring _total_supply accurately reflects circulating shares for price calculations.

  • But The Actual Behavior:

    • Slot 18 (role_manager) is fetched instead, typically an address like 0x1234567890abcdef.... As a uint256, this is a massive number (e.g., 0x1234... ≈ 1.23e40).

    • Post-full_profit_unlock_date, _unlocked_shares = balance_of_self becomes this inflated value, causing _total_supply to underflow or approach zero, hyperinflating the price in _raw_price.

  • Example:

    • Vault state: total_supply = 1e21, total_idle = 1e18, total_debt = 0, balance_of_self = 2e20 (correct), full_profit_unlock_date passed.

    • The Correct way:

      • _unlocked_shares = 2e20.

      • _total_supply = 1e21 - 2e20 = 8e20.

      • Price = 1e18 * 1e18 // 8e20 = 1.25e15 (1.25 scrvUSD per share).

    • But The Actual (assume role_manager = 0x1234567890abcdef1234567890abcdef12345678):

      • balance_of_self = 0x1234567890abcdef1234567890abcdef12345678 ≈ 1.23e40.

      • _unlocked_shares = 1.23e40.

      • _total_supply = 1e21 - 1.23e40 underflows to near-zero (e.g., 1 due to Vyper’s uint256 bounds).

      • Price = 1e18 * 1e18 // 1 ≈ 1e36 (an absurd 1e18 scrvUSD per share).

Impact

  • balance_of_self is orders of magnitude larger than intended, causing _unlocked_shares to dominate _total_supply, reducing it to negligible values or triggering underflow artifacts.

Tools Used

Manual Review, Tenderly

Recommendations

  • Update BALANCE_OF_SELF_SLOT to the correct mapping base slot (7) and adjust extraction logic

  • Verify all PARAM_SLOTS against the vault’s layout

Updates

Lead Judging Commences

0xnevi Lead Judge
6 months ago
0xnevi Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

[invalid] finding-incorrect-storage-slot-retrieval

See comments in primary finding in 239

Support

FAQs

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