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

_extractParametersFromProof

Summary

Vulnerability Details

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

Let’s walk through why the most obvious logic bug is in the function _extractParametersFromProof(...), specifically in how it computes the “storage slot” key that is passed to Verifier.extractSlotValueFromProof(...).


How it should work vs. how it’s written

1. Normal single-slot variables

A normal uint256 variable (e.g. uint256 total_debt;) that the compiler placed at slot N in storage has its value located directly at key = N in the Merkle-Patricia trie path. In other words, if you want to prove the value at slot 21, you pass 21 (RLP-encoded) into the state proof as the path. There is no hashing of 21—the slot number is simply used as is.

2. Mappings

A mapping like

mapping(address => uint256) public balanceOf;

sitting at storage slot M is a different story. Each entry in the mapping is stored at

keccak256(abi.encodePacked(key, M))

So for balanceOf(someAddress), the path is the 32-byte value

keccak256( bytes32(someAddress) . bytes32(uint256(M)) )

That is the only time you actually do a keccak256(...) of the slot plus the “key” (the address, or whatever the mapping key type is).


The bug in _extractParametersFromProof(...)

Look at the relevant code snippet:

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 the account proof (index 0)
Verifier.Account memory account = Verifier.extractAccountFromProof(
SCRVUSD_HASH,
stateRoot,
proofs[0].toList()
);
require(account.exists, "scrvUSD account does not exist");
// Extract each storage slot (indexes 1..PROOF_CNT-1)
uint256[PARAM_CNT] memory params;
for (uint256 i = 1; i < PROOF_CNT; i++) {
Verifier.SlotValue memory slotValue = Verifier.extractSlotValueFromProof(
keccak256(abi.encode(PARAM_SLOTS[i])),
account.storageRoot,
proofs[i].toList()
);
params[i - 1] = slotValue.value;
}
return params;
}

Notice how the code is doing:

keccak256(abi.encode(PARAM_SLOTS[i]))

for all slots (21, 22, 20, 38, 39, 40, plus the mapping slot).

  • But for a normal single-slot variable at, say, slot 21, the correct path in the MPT is simply 21 (RLP-encoded). We do not do keccak256(21).

  • Only for the mapping slot (e.g., balanceOf(address)) do we do keccak256(abi.encodePacked(key, slotNumber)).

By lumping everything together and unconditionally hashing PARAM_SLOTS[i], the code is incorrectly computing the MPT path for the normal single-slot variables. It will never find the correct proof for “slot 21” (or 22, 20, etc.) if it’s feeding in the hash of 21 to the Merkle trie path.

Hence, the function _extractParametersFromProof is where the logic is broken: it forces every slot index through a keccak256(...), even for variables that are not from a mapping.


Common symptom: “Why can’t we read the correct storage values?”

Because the MPT path is wrong for the single-slot variables, those proofs will fail or retrieve zero/garbage. It might look like “the proof is broken” or “the chain data is invalid,” but in reality, it is just that the code is using the wrong key in the Merkle-Patricia trie.


How to fix it

You need to separate out which slots are normal single-slot variables and which ones are mapping entries. For example:

  1. For total_debt at slot 21, you pass the raw integer 21 (RLP-encoded as a bytes path) into extractSlotValueFromProof(...).

  2. For balanceOf(self), you do:

    bytes32 balanceSlotKey = keccak256(abi.encodePacked(selfAddress, uint256(mappingSlot)));

    and pass that as the path.

That typically means you do something like:

if (slotIsAMappingEntry) {
// paramSlots[i] would be the *pre-hashed* key
// e.g. keccak256(abi.encodePacked(address, baseSlot))
slotKey = bytes32(paramSlots[i]);
} else {
// paramSlots[i] is just the raw slot number
// no hashing, so use it directly as the MPT path
slotKey = bytes32(paramSlots[i]);
}

And then call:

Verifier.extractSlotValueFromProof(slotKey, account.storageRoot, proofs[i].toList());

Bottom line

  • The short answer: The function _extractParametersFromProof has the logical bug. It unconditionally does

    keccak256(abi.encode(PARAM_SLOTS[i]))

    for all slots, which is wrong for normal single-slot variables.

  • If you only hashed the mapping entry slot (balanceOf(...)) but did not hash the plain integer slots (21, 22, etc.), then your proofs for each slot would match the actual storage layout.

Impact

Tools Used

Recommendations

Updates

Lead Judging Commences

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

[invalid] finding-storage-key-compute-wrong

See primary comments in issue #23

Support

FAQs

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