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

Mismatched Storage Slots for Mappings: Inconsistent with Target Contract’s Actual Layout

Summary

Within the ScrvusdVerifierV1 / ScrvusdVerifierV2 contracts, several storage slot positions (e.g., entries in PARAM_SLOTS and the way balanceOf(self) is hashed) are hardcoded in an attempt to read data from a target contract (SCRVUSD). However, when comparing these assumptions to the real storage layout of the Vyper Yearn V3 Vault (hereafter referred to as “Vault”), it becomes apparent that the verifier’s calculations do not match the Vault’s actual storage assignments. As a result, the Verifier may read incorrect storage values whenever it tries to access balanceOf(self) or other fields. If an attacker exploits these discrepancies—or if downstream logic relies on such data without further checks—the result could be severe economic or operational risks.

Vulnerability Details

1. How balanceOf(self) Slots Are Computed in the Code

Within the ScrvusdVerifierV1 contract, the following crucial snippet (simplified) appears:

// ScrvusdVerifierV1.sol (excerpt)
uint256 constant PARAM_CNT = 2 + 5;
uint256 constant PROOF_CNT = 1 + PARAM_CNT;
// The contract hardcodes various storage slot positions:
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)
];

Notice how the last element, uint256(keccak256(abi.encode(18, SCRVUSD))), is assumed to represent the slot for “balanceOf(self).” During the read operation, it is also subjected to another keccak256(abi.encode(slotValue)), effectively creating a “double keccak” pattern typical of mapping(uint256 => mapping(address => uint256)) or similar structures.

2. Actual Layout of balance_of: HashMap[address, uint256] in the Vyper Contract

From the posted Yearn V3 Vault Vyper code (omitting unrelated parts):

balance_of: HashMap[address, uint256]

In Vyper (>=0.2.x), a single-layer mapping is stored as follows:

slot_for_balance_of = <the contract slot index where this HashMap is declared>
balance_of[someAddr] -> keccak256(encode(someAddr, slot_for_balance_of))

For instance, if balance_of happens to be the 7th allocated slot in the contract:

balance_of[someAddr] storage location = keccak256(abi.encode(someAddr, 7))

There is no keccak256(abi.encode(18, <address>)) step, and certainly not another nested keccak256(abi.encode(...)) on top of it.

3. Root Causes of the Mismatch

  • In ScrvusdVerifierV1, the derived slot for balanceOf(self) is calculated with keccak256(abi.encode(18, SCRVUSD)), followed by one more keccak256(abi.encode(...)).

  • In the actual Vyper contract, a single-layer balance_of[address] slot is keccak256(abi.encode(address, baseSlotIndex)), where baseSlotIndex depends on the variable’s declaration order (and is unlikely to be 18).

Because the slot derivations differ fundamentally, the Verifier is reading the wrong location (often defaulting to 0 or some unrelated data).

4. Other Hardcoded Slot Numbers Are Also Likely Off

The code also includes:

uint256(21), // total_debt
uint256(22), // total_idle
uint256(20), // totalSupply
// ...

If the intended targets are the Vyper Yearn V3 Vault’s total_debt, total_idle, and total_supply, it is highly unlikely that these fields map to slots 21, 22, or 20. The contract’s final layout depends on declaration order, inserted variables, mappings, etc. Consequently, more mismatches are inevitable.

Impact

  1. Reading Incorrect Parameters

    Downstream logic (e.g., _updatePrice, _update_profit_max_unlock_time) that uses these fetched values for valuation, liquidation, or reward settlements will operate on distorted data.

  2. Potential Exploitation by Attackers

    Armed with knowledge of these mismatches, an attacker might craft apparently “valid” state proofs that supply unrelated storage slot values, which the contract mistakenly treats as balanceOf(self). This can compromise pricing or operational logic.

  3. Broken Verification

    The system aims to use state proofs to confirm the Vault’s status at a given block. But because it fails to retrieve the correct storage values, any trust or security mechanism built on these proofs is severely undermined.

Proof of Concept

Step 1: Scenario Initialization

Alice and Bob participate in a DeFi protocol that relies on ScrvusdVerifierV1 to read the SCRVUSD contract’s “balanceOf(self)” in real time—perhaps to update a core oracle or settle certain operations.

A simplified verifier snippet might look like this:

contract ScrvusdVerifierV1 {
// Hardcoded storage slots
uint256[8] internal PARAM_SLOTS = [
// Omitted earlier slots...
uint256(keccak256(abi.encode(18, SCRVUSD))) // supposed balanceOf(self)
];
function verifyScrvusdByBlockHash(
bytes memory _block_header_rlp,
bytes memory _proof_rlp
) external {
// 1. Validate block header ...
// 2. Fetch storage slot from state proof:
// slotKey = keccak256(abi.encode(PARAM_SLOTS[7]));
// value = StateProofVerifier.extractSlotValueFromProof(slotKey, ...);
// 3. Interpret this 'value' as "balanceOf(self)"
// 4. Call external oracle, e.g. update_price
}
}

However, SCRVUSD is actually a Vyper Vault contract where balance_of: HashMap[address, uint256] is stored at keccak256(abi.encode(address, baseSlotIndex)), and baseSlotIndex is not 18—nor does it need a second nested keccak. Thus, ScrvusdVerifierV1 ends up reading a slot that doesn’t match the real balanceOf(self).

Step 2: Bob Forges the Storage Value

  • Bob, an adversary, recognizes that the Verifier’s calculation for “balanceOf(self)” is off.

  • Bob creates a forged or specialized state root (assuming he can manipulate “SCRVUSD” storage on a test or forked chain environment) such that the slot at keccak256(abi.encode(18, SCRVUSD)) has an enormous value (e.g., 10^30).

  • Bob then generates a “state root + Patricia trie proof (RLP format)” that allows ScrvusdVerifierV1 to read 10^30 from extractSlotValueFromProof.

Step 3: Bob Calls verifyScrvusdByBlockHash

ScrvusdVerifierV1(VERIFIER_ADDRESS).verifyScrvusdByBlockHash(
fakeBlockHeaderRlp, // Bob’s forged block header
fakeProofRlp // Bob’s forged storage proof
);
  • Because the contract does not confirm the correct codeHash or slot index—and may only do superficial checks on blockHash or chainIDScrvusdVerifierV1 incorrectly regards the proof as valid.

  • Internally, _extractParametersFromProof(...) calls extractSlotValueFromProof and retrieves the artificially large value (10^30) as the “balanceOf(self).”

Step 4: Misleading Downstream Price Updates

  • ScrvusdVerifierV1 then passes the “balanceOf(self) = 10^30” parameter to an external oracle via something like:

IScrvusdOracle(SCRVUSD_ORACLE).update_price(
params, // includes the fake balance
block.timestamp,
block.number
);
  • If the oracle does not cross-verify these values (e.g., by validating the contract codeHash or the plausibility of the reported balance), it will accept this fabricated data.

  • Subsequently, any pricing, liquidation, or reward logic is conducted with drastically incorrect figures, which could cause severe damage to normal users (e.g., Alice) by underpricing, overpricing, or miscalculating reserves.

Step 5: Result

  • Bob effectively tricks the system into assuming the vault holds far more tokens than reality, distorting the protocol’s liquidation or pricing mechanisms.

  • This entire exploit hinges on the verifier’s incorrect approach to deriving storage slots for mappings.

Tools Used

Manual Review

Recommendations

Match the Actual Storage Layout

  • In the Verifier, rely on the true compiled storage layout of the target Vyper contract, ensuring that the correct slot index and key-encoding approach are used.

  • For example, if balance_of is actually at slot 7:

    // Example:
    // - base slot for balance_of is 7
    // - to read balance_of[self], use keccak256(abi.encode(self, 7))
    // Verifier should reflect this exact derivation:
    keccak256(abi.encode(address, 7))
  • Use exactly one keccak invocation keccak256(abi.encode(key, slotIndex)), without extra nesting or substitutions like uint256(18, SCRVUSD).

Updates

Lead Judging Commences

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

[invalid] finding-ScrvusdVerifierV1-incorrect-storage-slot-balanceOf-compute

- Per sponsor comments, verified slot is vyper, solidity contract only verifies it. - Vyper computes storage slots different from solidity as seen [here](https://ethereum.stackexchange.com/questions/149311/storage-collision-in-vyper-hashmap)

Support

FAQs

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