Summary
A critical flaw exists in the debt repayment logic where the protocol miscalculates user debt values by using scaled balances without accounting for accrued interest. This prevents users from fully repaying debts.
Technical Background
Scaled Debt: User debt is stored as a "scaled" value that remains constant while interest accrues via an increasing usageIndex.
Actual Debt: Calculated as scaledDebt * usageIndex. As time passes, usageIndex grows exponentially, making the actual debt larger.
Vulnerability Details
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L398C1-L399C1
uint256 userDebt = IDebtToken(...).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
Issue
-
Incorrect Debt Retrieval
The code uses IDebtToken(...).balanceOf() to get the user's debt. This returns the scaled debt (principal without interest), not the actual owed amount.
-
Improper Interest Application
The calculation userScaledDebt = userDebt.rayDiv(reserve.usageIndex) further reduces the debt value by dividing instead of multiplying by the index, artificially lowering the perceived debt.
Proof of Concept:
// Flawed logic in _repay():
uint256 userDebt = IDebtToken(...).balanceOf(onBehalfOf); // Returns scaled balance
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex); // Wrong: Division instead of multiplication
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount; // Caps repayment incorrectly
After 10% interest accrual:
Actual Debt: 1,000 USDC * 1.1 = 1,100 USDC
Code Thinks Debt Is: 1,000 / 1.1 ≈ 909 USDC
Coded POC
Mocked lending pool
pragma solidity ^0.8.19;
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import { WadRayMath } from "../../contracts/libraries/math/WadRayMath.sol";
contract LendingPoolMock is Ownable, ILendingPool {
using WadRayMath for uint256;
DebtToken public debtToken;
uint256 public usageIndex = 10**27;
mapping(address => uint256) public scaledDebt;
constructor(address _debtToken) Ownable(msg.sender) {
debtToken = DebtToken(_debtToken);
}
function setUsageIndex(uint256 newIndex) external onlyOwner {
usageIndex = newIndex;
}
function borrow(uint256 amount) external override {
scaledDebt[msg.sender] += amount;
debtToken.mint(msg.sender, msg.sender, amount, usageIndex);
}
function repay(uint256 amount) external override {
_repay(amount, msg.sender);
}
function _repay(uint256 amount, address onBehalfOf) internal {
uint256 userDebt = scaledDebt[onBehalfOf] * usageIndex;
uint256 actualRepay = amount > userDebt ? userDebt : amount;
uint256 scaledRepay = actualRepay / usageIndex;
debtToken.burn(onBehalfOf, scaledRepay, usageIndex);
scaledDebt[onBehalfOf] -= scaledRepay;
}
function getUserDebt(address user) external view returns (uint256) {
return scaledDebt[user] * usageIndex;
}
function deposit(uint256 ) external override { }
function withdraw(uint256 ) external override { }
function depositNFT(uint256 ) external override { }
function withdrawNFT(uint256 ) external override { }
function updateState() external override { }
function initiateLiquidation(address ) external override { }
function closeLiquidation() external override { }
function finalizeLiquidation(address ) external override { }
function setParameter(OwnerParameter , uint256 ) external override { }
function setPrimeRate(uint256 ) external override { }
function setProtocolFeeRate(uint256 ) external override { }
function setStabilityPool(address ) external override { }
function transferAccruedDust(address , uint256 ) external override { }
}
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { BigNumber } = require("ethers");
const RAY = BigNumber.from("1000000000000000000000000000");
describe("Repay Issue PoC", function () {
let owner, borrower, other;
let lendingPool, debtToken;
beforeEach(async function () {
[owner, borrower, other] = await ethers.getSigners();
const DebtToken = await ethers.getContractFactory("DebtToken", owner);
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
await debtToken.deployed();
const LendingPool = await ethers.getContractFactory("LendingPoolMock", owner);
lendingPool = await LendingPool.deploy(debtToken.address);
await lendingPool.deployed();
await debtToken.setReservePool(lendingPool.address);
});
it("should under-repay debt due to miscalculation", async function () {
const borrowAmount = ethers.parseEther("1000");
await lendingPool.connect(borrower).borrow(borrowAmount);
let debtBefore = await lendingPool.getUserDebt(borrower.address);
expect(debtBefore).to.equal(borrowAmount);
const newUsageIndex = RAY.mul(110).div(100);
await lendingPool.setUsageIndex(newUsageIndex);
let actualDebt = (await lendingPool.getUserDebt(borrower.address));
expect(actualDebt).to.equal(borrowAmount.mul(110).div(100));
const repayAmount = ethers.utils.parseEther("1100");
await lendingPool.connect(borrower).repay(repayAmount);
let remainingDebt = await lendingPool.getUserDebt(borrower.address);
expect(remainingDebt).to.be.gt(ethers.utils.parseEther("190"));
expect(remainingDebt).to.be.lt(ethers.utils.parseEther("200"));
});
it("should fully repay debt with correct calculation", async function () {
const borrowAmount = ethers.parseEther("1000");
await lendingPool.connect(borrower).borrow(borrowAmount);
let debtBefore = await lendingPool.getUserDebt(borrower.address);
expect(debtBefore).to.equal(borrowAmount);
const newUsageIndex = RAY.mul(110).div(100);
await lendingPool.setUsageIndex(newUsageIndex);
let actualDebt = await lendingPool.getUserDebt(borrower.address);
expect(actualDebt).to.equal(borrowAmount.mul(110).div(100));
const repayAmount = actualDebt;
await lendingPool.connect(borrower).repay(repayAmount);
let remainingDebt = await lendingPool.getUserDebt(borrower.address);
expect(remainingDebt).to.equal(0);
});
});
Impact
Debts are systematically underpaid, creating bad debt.
Liquidators cannot fully recover owed amounts, disincentivizing liquidations.
Direct Fund Loss: Attackers exploit this to borrow funds and repay less than owed, stealing the difference.
Recommendations
function _repay(uint256 amount, address onBehalfOf) internal {
uint256 actualDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 actualRepayAmount = amount > actualDebt ? actualDebt : amount;
uint256 scaledRepayAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
IDebtToken(reserve.reserveDebtTokenAddress).burn(
onBehalfOf,
scaledRepayAmount,
reserve.usageIndex
);
user.scaledDebtBalance -= scaledRepayAmount;
}