Core Contracts

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

Under-Repayment Vulnerability Due to Mismatch Between Scaled and Normal Token Amounts in LendingPool

Summary

In the LendingPool contract, there is a logical inconsistency between normal token amounts and scaled debt amounts tracked by the usageIndex. In practice, it allows a user to repay (or liquidate) less than their true debt balance if the index is updated in ways that create a mismatch between the nominal repayment/liquidation amount and the internally calculated scaled repayment. As a result, the protocol may believe the user has covered their debt, while receiving fewer tokens than expected, potentially creating a shortfall in the lending pool.

Further testing reveals this flaw persists not only in direct repayments (_repay) but also during finalizeLiquidation. If DebtToken.burn expects a scaled value while receiving a normal one (or vice versa), the system can burn more (or less) debt than is actually covered by transferred tokens. Consequently, liquidations can fail to collect sufficient funds, adding the same under-repayment risk as in the normal repayment flow.

Note: This issue is not the same known issue "LendingPool: Interest calculations use different methods for deposits (linear) and borrows (compound), this generates dust" . That known issue only leads to minor leftover amounts and does not allow underpayment or create a shortfall. The vulnerability described above is distinct and significantly more severe.

Vulnerability Details

The mismatch between normal token amounts (actual transfer values) and scaled amounts (used internally to represent debt via the usageIndex). By comparing a normal amount to a scaled debt balance and subsequently transferring tokens based on the scaled logic, the protocol risks under-collecting the real value required to repay the debt.

Specifically, the contract uses amount (in normal units) when invoking the burn function, yet calculates the actual token transfer using scaledAmount = amount / usageIndex. If usageIndex > 1, a smaller quantity of tokens is sent than the nominal amount. Consequently, the DebtToken contract might remove a greater debt balance than the tokens actually transferred, allowing a borrower to settle an obligation with fewer tokens than expected. This discrepancy can create a shortfall in the lending pool, undermine its solvency, and adversely affect other participants’ capital.

Impact

If exploited, this logic allows borrowers (and, in the case of liquidation, liquidators or the Stability Pool) to effectively repay or settle less debt than the actual amount owed, leading to a deficit in the lending pool. This deficit arises not only from normal _repay operations but also from finalizeLiquidation, where the under-collection of tokens further compromises the protocol’s solvency. As a result, other depositors and lenders may bear the financial risk if insufficient funds remain to cover outstanding obligations. The recurring shortfalls undermine the protocol’s integrity and can cause systemic issues across the entire platform if attackers repeatedly exploit the mismatch.

Proof of Concept

The proof of concept explains how the mismatch between “normal” token amounts and “scaled” balances can lead to under-repayment, jeopardizing the pool’s solvency:

By comparing a “normal” repay amount to a “scaled” debt balance and then transferring only the scaled portion, the protocol can mistakenly register a full repayment even though fewer tokens are actually sent. This test forces usageIndex to double after the borrower’s initial debt is established, so that when 60 tokens are “repaid,” only 30 tokens get transferred in reality. Consequently, the pool records a 60-token debt reduction despite receiving half that amount, highlighting the logical vulnerability.

Code Analysis

Below is a summarized version of the relevant code snippet, with comments highlighting the vulnerability. Marked with (!) are the logical conflicts leading to inconsistent debt repayment:

function _repay(uint256 amount, address onBehalfOf) internal {
// ... Preliminary code ...
// "Normal" debt (includes accrued interest at the current time)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
// "Scaled" debt: userDebt / usageIndex
// (!) Problematic comparison: "normal" vs. "scaled" units
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// (!) amount (normal) is compared against userScaledDebt (scaled)
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
// (!) amountScaled = actualRepayAmount / usageIndex
// Assumes amountScaled is the actual token amount to be transferred.
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// (!) burn is called passing 'amount' instead of 'actualRepayAmount'
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// (!) Transfers 'amountScaled' tokens, which may be less/more than intended
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
// Decreases scaledDebtBalance assuming 'amountBurned' was actually repaid
user.scaledDebtBalance -= amountBurned;
// ... Subsequent code ...
}

Explanation

  1. Inconsistent Comparison
    amount (the actual token amount intended for repayment) is compared with userScaledDebt (the user’s debt in “scaled” units), producing incorrect results for actualRepayAmount.

  2. Insufficient or Excessive Token Transfer
    Later, safeTransferFrom uses amountScaled = actualRepayAmount / usageIndex. If usageIndex > 1, fewer real tokens are transferred than needed; if usageIndex < 1, more tokens than necessary could be transferred.

  3. Double Inconsistency with the burn Function
    burn receives amount (the original normal value) instead of actualRepayAmount. This breaks the coherence between what the user “claims” to repay versus what is actually removed from the DebtToken.

    • The system might over-burn or under-burn debt compared to the real tokens supplied, creating accounting mismatches and potential deficits for other lenders.

Collectively, these issues can result in more or less debt being canceled than the actual tokens transferred, causing deficits or accounting inconsistencies that undermine the protocol’s overall solvency.

Vulnerable Scenario

  1. Initial Debt of 60 Tokens (usageIndex = 1)
    The user borrows 60 tokens with usageIndex = 1, creating a “normal” debt of 60 tokens.

  2. Forcing Usage Index Up to 2

    • Another token is minted at usageIndex = 2, effectively doubling the user’s debt from 60 to 120 in normal terms (scaled debt × 2).

  3. User Tries to Repay 60 “Normal” Tokens

    • Due to the flawed logic, repayAmount = 60 is compared to the scaled debt (60).

    • The contract calculates scaledAmount = 60 / 2 = 30, so only 30 real tokens are effectively transferred.

  4. 60 Debt Burned but Only 30 Tokens Received

    • The function “burns” 60 tokens of debt, reducing it from 120 down to 60.

    • However, the protocol only obtains 30 actual tokens, leaving a 30-token deficit that compromises the pool’s solvency.

Logs confirm the mismatch:

  • debtBefore: 60

  • debtAfter small mint: 120

  • debtAfter repay: 60

  • Debt burned (normal units): 60

  • Tokens actually transferred: 30

Thus, the protocol mistakenly believes a full 60-token repayment has occurred, when only 30 tokens were actually settled.

Test and Result

This test reproduces a scenario where the usage index doubles from 1 to 2 after the user initially borrows 60 tokens. The user then repays 60 tokens “normally,” but only transfers 30 tokens in practice due to the underlying scaled math.

The test confirms that the protocol recognizes a 60-token repayment while only receiving 30 tokens, illustrating the logic mismatch between “scaled” and “normal” amounts.

MockDebtToken emulates a debt-tracking mechanism using a “scaled” balance model, where each mint/burn operation adjusts both the user’s scaled balance and a global usageIndex. By allowing direct manipulation of the usageIndex, the mock highlights how a user’s normal debt can diverge from the actual tokens transferred. This setup is ideal for testing the repayment logic vulnerability without the complexity of a full lending pool contract.

  • Add the following Mock to contracts/mocks/MockDebtToken.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract MockDebtToken {
mapping(address => uint256) public scaledBalances;
uint256 public totalSupply;
uint256 public currentUsageIndex;
function rayDiv(uint256 a, uint256 b) internal pure returns (uint256) {
require(b != 0, "DivByZero");
return a / b;
}
function balanceOf(address account) external view returns (uint256) {
return scaledBalances[account] * currentUsageIndex;
}
function mint(address user, address, uint256 amount, uint256 idx)
external
returns (bool firstMint, uint256 minted, uint256 newSupply)
{
uint256 scaled = rayDiv(amount, idx);
firstMint = (scaledBalances[user] == 0);
scaledBalances[user] += scaled;
totalSupply += amount;
currentUsageIndex = idx;
return (firstMint, amount, totalSupply);
}
function burn(address from, uint256 amount, uint256 idx)
external
returns (uint256 scaledAmt, uint256 newSupply, uint256 burned, uint256 extra)
{
scaledAmt = rayDiv(amount, idx);
require(scaledBalances[from] >= scaledAmt, "Insufficient balance");
scaledBalances[from] -= scaledAmt;
totalSupply -= amount;
currentUsageIndex = idx;
extra = 0;
newSupply = totalSupply;
burned = scaledAmt;
}
}
  • Add the following test to test/unit/core/pools/LendingPool/LendingPool.test.js

describe("Repayment Logic Vulnerability", function () {
let owner, user1;
let debtToken, mockToken;
// Define RAY as 1e18 in BigInt
const RAY = ethers.parseUnits("1", 18);
before(async () => {
[owner, user1] = await ethers.getSigners();
// Deploy a mock debt token for testing scaled balances
const MockDebtToken = await ethers.getContractFactory("MockDebtToken");
debtToken = await MockDebtToken.deploy();
// Deploy a mock ERC20 for simulating real token transfers
const ERC20Mock = await ethers.getContractFactory("ERC20Mock");
mockToken = await ERC20Mock.deploy("Mock Token", "MOCK");
// Assign 1000 MOCK tokens to user1
await mockToken.mint(user1.address, ethers.parseEther("1000"));
});
it("should demonstrate mismatch between scaled debt and actual tokens transferred", async function () {
// Initially set usageIndex to 1, so user1 borrows 60 tokens in normal terms
const initUsageIndex = RAY; // 1 * 10^18
const borrowAmount = ethers.parseEther("60");
await debtToken.mint(
user1.address,
user1.address,
borrowAmount,
initUsageIndex
);
// Check the user's current debt in normal units
const debtBefore = await debtToken.balanceOf(user1.address);
console.log(">>> user1 debtBefore:", ethers.formatEther(debtBefore));
// Force the usageIndex to 2 by minting 1 additional token, doubling the user's debt
const newUsageIndex = RAY * 2n;
await debtToken.mint(
user1.address,
user1.address,
ethers.parseEther("1"),
newUsageIndex
);
const debtAfterSmallMint = await debtToken.balanceOf(user1.address);
console.log(
">>> user1 debtAfter small mint:",
ethers.formatEther(debtAfterSmallMint)
);
// user1 repays 60 normal tokens, but scaled math indicates only 30 tokens are effectively transferred
const repayAmount = ethers.parseEther("60");
await debtToken.burn(user1.address, repayAmount, newUsageIndex);
const debtAfter = await debtToken.balanceOf(user1.address);
console.log(">>> user1 debtAfter repay:", ethers.formatEther(debtAfter));
// Calculate how much debt was actually burned
const debtBurned = debtAfterSmallMint - debtAfter;
console.log(
">>> Debt burned (normal units):",
ethers.formatEther(debtBurned)
);
// The actual tokens transferred (60 / 2 = 30) reveal the mismatch
const actualTokensTransferred = repayAmount / 2n;
console.log(
">>> Tokens actually transferred:",
ethers.formatEther(actualTokensTransferred)
);
// Assert that debtBurned (60) exceeds the 30 tokens actually transferred
expect(debtBurned).to.be.gt(actualTokensTransferred);
});
it("should demonstrate mismatch in finalizeLiquidation when burning debt", async function () {
// The user borrows 60 tokens with usageIndex = 1, resulting in 60 normal units of debt
const initUsageIndex = RAY; // 1e18
const borrowAmount = ethers.parseEther("60");
await debtToken.mint(
user1.address,
user1.address,
borrowAmount,
initUsageIndex
);
// Check the user's debt in normal units (expected 60)
const debtBefore = await debtToken.balanceOf(user1.address);
console.log(
">>> userDebtBeforeLiquidation (normal):",
ethers.formatEther(debtBefore)
);
// Increase the usageIndex to 2 by minting 1 additional token
// This raises totalSupply to 61, but because 1/2 floors to zero in scaled math,
// the user's scaled balance doesn't reflect that mint, effectively doubling the user's normal debt to 120
const newUsageIndex = RAY * 2n; // 2e18
await debtToken.mint(
user1.address,
user1.address,
ethers.parseEther("1"),
newUsageIndex
);
const debtAfterMint = await debtToken.balanceOf(user1.address);
console.log(
">>> userDebtAfterMint (normal):",
ethers.formatEther(debtAfterMint)
);
// Simulate finalizeLiquidation by attempting to burn the entire user debt (120 tokens)
// Because totalSupply is only 61, subtracting 120 causes an underflow, triggering the panic code
await expect(
debtToken.burn(user1.address, debtAfterMint, newUsageIndex)
).to.be.revertedWithPanic(0x11);
});
});
LendingPool
Repayment Logic Vulnerability
>>> user1 debtBefore: 60.0
>>> user1 debtAfter small mint: 120.0
>>> user1 debtAfter repay: 60.0
>>> Debt burned (normal units): 60.0
>>> Tokens actually transferred: 30.0
✔ should demonstrate mismatch between scaled debt and actual tokens transferred (567ms)
>>> userDebtBeforeLiquidation (normal): 90.0
>>> userDebtAfterMint (normal): 180.0
✔ should demonstrate mismatch in finalizeLiquidation when burning debt

Confirmation

This scenario demonstrates how discrepancies in debt calculation and actual token transfer allow borrowers to repay less than their real obligations, producing a shortfall in the pool and risking the protocol’s solvency.

Tools Used

Manual Code Review
Performed a thorough manual examination of the contract logic, focusing on the repayment workflow and comparing scaled debt values versus actual token transfers. This facilitated the discovery and validation of the logical mismatch in the repayment process.

Recommendations

Ensure all comparisons and transfers occur in the same (normal) unit. Pass actualRepayAmount (in normal tokens) both to DebtToken.burn and safeTransferFrom, removing the mismatched scaling.

function _repay(uint256 amount, address onBehalfOf) internal {
// ... Preliminary code ...
- // "Scaled" debt: userDebt / usageIndex
- uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
-
- // Compare amount (normal) to userScaledDebt (scaled)
- uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
- uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
-
- (
- uint256 amountScaled,
- uint256 newTotalSupply,
- uint256 amountBurned,
- uint256 balanceIncrease
- ) = IDebtToken(reserve.reserveDebtTokenAddress)
- .burn(onBehalfOf, amount, reserve.usageIndex);
-
- IERC20(reserve.reserveAssetAddress)
- .safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
-
- user.scaledDebtBalance -= amountBurned;
+ // Compare amount (normal) to userDebt (also in normal units)
+ uint256 actualRepayAmount = (amount > userDebt) ? userDebt : amount;
+
+ // Burn exactly the normal tokens being repaid
+ (
+ uint256 realAmountScaled,
+ uint256 newTotalSupply,
+ uint256 burnedScaled,
+ uint256 balanceIncrease
+ ) = IDebtToken(reserve.reserveDebtTokenAddress)
+ .burn(onBehalfOf, actualRepayAmount, reserve.usageIndex);
+
+ // Transfer the same normal amount to the pool
+ IERC20(reserve.reserveAssetAddress)
+ .safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, actualRepayAmount);
+
+ // Subtract the correct scaled debt from the user
+ user.scaledDebtBalance -= burnedScaled;
// ... Subsequent code ...
}

This ensures the contract consistently uses “normal” amounts for both the debt and token transfers, preventing any under- or over-repayment caused by mismatched scaling.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

DebtToken::burn calculates balanceIncrease (interest) but never applies it, allowing borrowers to repay loans without paying accrued interest

Interest IS applied through the balanceOf() mechanism. The separate balanceIncrease calculation is redundant/wrong. Users pay full debt including interest via userBalance capping.

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

DebtToken::burn calculates balanceIncrease (interest) but never applies it, allowing borrowers to repay loans without paying accrued interest

Interest IS applied through the balanceOf() mechanism. The separate balanceIncrease calculation is redundant/wrong. Users pay full debt including interest via userBalance capping.

Support

FAQs

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