Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Invalid

Frequent State Update Exploitation Risk in Lending Protocol

Summary

The lending protocol allows anyone to call the updateState function, which updates the liquidity and usage indices by calculating interest based on small time intervals (e.g., 1 second). This functionality uses a Taylor series approximation for compound interest, which can lead to small discrepancies in the interest calculation when frequent state updates are triggered. Malicious actors could exploit this by frequently calling updateState, potentially gaining an unfair advantage in interest accrual over time, especially with large sums of money.

Vulnerability Details

The vulnerability stems from the ability of any user to call updateState arbitrarily, which updates the reserve's state, including the liquidity index and usage index. The calculations for interest, particularly compound interest, are sensitive to the frequency and precision of updates. The current code uses a Taylor series approximation for compound interest, which can cause small discrepancies when dealing with very small time intervals (e.g., 1 second).

updateState

function updateState() external {
ReserveLibrary.updateReserveState(reserve, rateData);
}

updateReserveState

function updateReserveState(ReserveData storage reserve,ReserveRateData storage rateData) internal {
updateReserveInterests(reserve, rateData);
}

updateReserveInterests

function updateReserveInterests(ReserveData storage reserve,ReserveRateData storage rateData) internal {
uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
return;
}
uint256 oldLiquidityIndex = reserve.liquidityIndex;
if (oldLiquidityIndex < 1) revert LiquidityIndexIsZero();
// Update liquidity index using linear interest
reserve.liquidityIndex = calculateLiquidityIndex(
rateData.currentLiquidityRate,
timeDelta,
reserve.liquidityIndex
);
// Update usage index (debt index) using compounded interest
reserve.usageIndex = calculateUsageIndex(
rateData.currentUsageRate,
timeDelta,
reserve.usageIndex
);
// Update the last update timestamp
reserve.lastUpdateTimestamp = uint40(block.timestamp);
emit ReserveInterestsUpdated(reserve.liquidityIndex, reserve.usageIndex);
}

In particular:

calculateLinearInterest

function calculateLinearInterest(uint256 rate, uint256 timeDelta, uint256 lastIndex) internal pure returns (uint256) {
uint256 cumulatedInterest = rate * timeDelta;
cumulatedInterest = cumulatedInterest / SECONDS_PER_YEAR;
return WadRayMath.RAY + cumulatedInterest;
}
function calculateLiquidityIndex(uint256 rate, uint256 timeDelta, uint256 lastIndex) internal pure returns (uint128) {
uint256 cumulatedInterest = calculateLinearInterest(rate, timeDelta, lastIndex);
return cumulatedInterest.rayMul(lastIndex).toUint128();
}

calculateCompoundedInterest

function calculateCompoundedInterest(uint256 rate,uint256 timeDelta) internal pure returns (uint256) {
if (timeDelta < 1) {
return WadRayMath.RAY; // Return 1.0 if no time passed
}
uint256 ratePerSecond = rate.rayDiv(SECONDS_PER_YEAR); // Convert annual rate to per-second
uint256 exponent = ratePerSecond.rayMul(timeDelta); // Calculate r*t
// Will use a taylor series expansion (7 terms)
return WadRayMath.rayExp(exponent); // Uses e^(r*t) for compound interest
}
function calculateUsageIndex(uint256 rate, uint256 timeDelta ,uint256 lastIndex) internal pure returns (uint128) {
uint256 interestFactor = calculateCompoundedInterest(rate, timeDelta);
return lastIndex.rayMul(interestFactor).toUint128();
}
  • For linear interest, the calculations are straightforward, but for compound interest, the Taylor series approximation introduces small errors.

  • Frequent updates over a large position (e.g., $100 million) can accumulate these small errors, resulting in significant financial advantages for malicious actors who spam the updateState function.

Impact

A malicious actor with a large position could repeatedly call the updateState function, compounding interest at a higher rate than intended, leading to unfair profits over time.

  • Protocol Instability: Frequent calls to update the state could cause excessive gas consumption, leading to higher operational costs and potential network congestion. This could also degrade the user experience for other participants.

  • Inaccurate Reserve State: If the reserve state is updated too frequently, it could cause the liquidity and usage indices to diverge from their intended values, impacting the overall protocol's economic balance.

PoC (Proof of Concept)

  1. A malicious actor deposits $100 million USDC into the protocol.

  2. They repeatedly call the updateState function every second (or near-frequently).

  3. Over time, this causes small but cumulative discrepancies in the compound interest calculation, resulting in the malicious actor accruing more interest than they should.

  4. After several hours or days, the actor accumulates a substantial advantage due to the manipulation of interest calculations, potentially draining funds or destabilizing the protocol.

PoC Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
contract LendingPoolTest is Test {
// Constants for ray math (27 decimals)
uint256 constant RAY = 10**27;
uint256 constant SECONDS_PER_YEAR = 365 days;
// Let's represent $100 million in terms of a stablecoin with 6 decimals (like USDC)
uint256 constant HUNDRED_MILLION_DOLLARS = 100_000_000 * 1e6;
struct ReserveData {
uint128 liquidityIndex;
uint128 usageIndex;
uint40 lastUpdateTimestamp;
}
struct ReserveRateData {
uint256 currentLiquidityRate;
uint256 currentUsageRate;
}
ReserveData public reserve;
ReserveRateData public rateData;
event ReserveInterestsUpdated(uint256 liquidityIndex, uint256 usageIndex);
function setUp() public {
// Initialize indices at 1.0
reserve.liquidityIndex = uint128(RAY);
reserve.usageIndex = uint128(RAY);
reserve.lastUpdateTimestamp = uint40(block.timestamp);
// Set 10% annual interest rate
rateData.currentLiquidityRate = (RAY * 10) / 100;
rateData.currentUsageRate = (RAY * 10) / 100;
}
// Helper functions remain the same as previous version
function calculateLinearInterest(uint256 rate, uint256 timeDelta, uint256 lastIndex) internal pure returns (uint256) {
uint256 cumulatedInterest = rate * timeDelta;
cumulatedInterest = cumulatedInterest / SECONDS_PER_YEAR;
return RAY + cumulatedInterest;
}
function rayMul(uint256 a, uint256 b) internal pure returns (uint256) {
return (a * b + RAY / 2) / RAY;
}
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 halfB = b / 2;
return (a * RAY + halfB) / b;
}
function rayExp(uint256 x) internal pure returns (uint256) {
uint256 result = RAY;
uint256 xi = x;
result += xi;
xi = rayMul(xi, x) / 2;
result += xi;
xi = rayMul(xi, x) / 3;
result += xi;
return result;
}
function calculateCompoundedInterest(uint256 rate, uint256 timeDelta) internal pure returns (uint256) {
if (timeDelta < 1) {
return RAY;
}
uint256 ratePerSecond = rayDiv(rate, SECONDS_PER_YEAR);
uint256 exponent = rayMul(ratePerSecond, timeDelta);
return rayExp(exponent);
}
function updateState() public {
uint256 timeDelta = block.timestamp - uint256(reserve.lastUpdateTimestamp);
if (timeDelta < 1) {
return;
}
uint256 newLiquidityIndex = calculateLinearInterest(
rateData.currentLiquidityRate,
timeDelta,
reserve.liquidityIndex
);
uint256 newUsageIndex = rayMul(
reserve.usageIndex,
calculateCompoundedInterest(rateData.currentUsageRate, timeDelta)
);
reserve.liquidityIndex = uint128(newLiquidityIndex);
reserve.usageIndex = uint128(newUsageIndex);
reserve.lastUpdateTimestamp = uint40(block.timestamp);
emit ReserveInterestsUpdated(newLiquidityIndex, newUsageIndex);
}
// Helper function to calculate USD value based on index difference
function calculateUSDValue(uint256 indexDiff) internal pure returns (uint256) {
// Convert ray difference to decimal and multiply by asset value
return (indexDiff * HUNDRED_MILLION_DOLLARS) / RAY;
}
function testInterestCalculationDiscrepancyLargeAmount() public {
// Scenario 1: One large update after 1 hour
uint256 startTime = block.timestamp;
skip(1 hours);
updateState();
uint256 singleUpdateLiquidityIndex = reserve.liquidityIndex;
uint256 singleUpdateUsageIndex = reserve.usageIndex;
// Reset state for Scenario 2
reserve.liquidityIndex = uint128(RAY);
reserve.usageIndex = uint128(RAY);
reserve.lastUpdateTimestamp = uint40(startTime);
// Scenario 2: Updates every second for 1 hour
for (uint i = 0; i < 3600; i++) {
skip(1);
updateState();
}
uint256 multipleUpdatesLiquidityIndex = reserve.liquidityIndex;
uint256 multipleUpdatesUsageIndex = reserve.usageIndex;
// Calculate differences
uint256 liquidityIndexDiff = singleUpdateLiquidityIndex > multipleUpdatesLiquidityIndex ?
singleUpdateLiquidityIndex - multipleUpdatesLiquidityIndex :
multipleUpdatesLiquidityIndex - singleUpdateLiquidityIndex;
uint256 usageIndexDiff = multipleUpdatesUsageIndex > singleUpdateUsageIndex ?
multipleUpdatesUsageIndex - singleUpdateUsageIndex :
singleUpdateUsageIndex - multipleUpdatesUsageIndex;
// Calculate USD impact
uint256 liquidityUSDImpact = calculateUSDValue(liquidityIndexDiff);
uint256 usageUSDImpact = calculateUSDValue(usageIndexDiff);
// Log all results
console.log("=== Results for $100M Asset Pool ===");
console.log("Single update liquidity index:", singleUpdateLiquidityIndex);
console.log("Multiple updates liquidity index:", multipleUpdatesLiquidityIndex);
console.log("Liquidity index difference (ray):", liquidityIndexDiff);
console.log("Liquidity impact in USD:", liquidityUSDImpact / 100);
console.log("\nSingle update usage index:", singleUpdateUsageIndex);
console.log("Multiple updates usage index:", multipleUpdatesUsageIndex);
console.log("Usage index difference (ray):", usageIndexDiff);
console.log("Usage impact in USD:", usageUSDImpact / 100);
// Assert the differences are within expected ranges
// Impact should be less than $1000
assertTrue((liquidityUSDImpact/100) < 1000, "Liquidity impact too high");
assertTrue((usageUSDImpact/100) < 1000, "Usage impact too high");
}
}

Run Test:

forge test -vv --match-test testInterestCalculationDiscrepancyLargeAmount

Result:

Ran 1 test for test/InterestCal.t.sol:LendingPoolTest
[FAIL: Liquidity impact too high] testInterestCalculationDiscrepancyLargeAmount() (gas: 38281118)
Logs:
=== Results for $100M Asset Pool ===
Single update liquidity index: 1000011415525114155251141552
Multiple updates liquidity index: 1000000003170979198376458650
Liquidity index difference (ray): 11412354134956874682902
Liquidity impact in USD: 11412354
Single update usage index: 1000011415590271510001292590
Multiple updates usage index: 1000022831310858721250282111
Usage index difference (ray): 11415720587211248989521
Usage impact in USD: 11415720
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 216.50ms (215.71ms CPU time)

Tools Used

Manual

Recommendations

  1. Limit the Frequency of Updates:

    • Introduce a mechanism to limit how often updateState can be called, such as a cooldown period between updates or a maximum number of calls per block. This would prevent malicious actors from spamming the function.

  2. Restrict Access to Authorized Entities:

    • Implement role-based access control (e.g., using OpenZeppelin's Ownable or AccessControl) to ensure that only authorized entities (such as protocol governance or liquidity pool managers) can call updateState.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!