Core Contracts

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

Incorrect burn amount in the debt token.sol

Summary

The current implementation has multiple issues that could lead to incorrect debt accounting and potential economic vulnerabilitiy.
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/DebtToken.sol#L210

Vulnerability Details

  1. Wrong Amount Burning: The contract burns the raw amount instead of the scaled amount

// Current incorrect implementation
_burn(from, amount.toUint128()); // Burns raw amount
// Should be
_burn(from, amountScaled.toUint128()); // Should burn scaled amount
  1. Incorrect Index Usage: The function uses the lending pool's borrow index instead of the passed index parameter for balance increase calculation

// Current incorrect implementation
uint256 borrowIndex = ILendingPool(_reservePool).getNormalizedDebt();
balanceIncrease = userBalance.rayMul(borrowIndex) - userBalance.rayMul(_userState[from].index);
// Should be
balanceIncrease = userBalance.rayMul(index) - userBalance.rayMul(_userState[from].index);
  1. Wrong Return Values: The function returns incorrect values in its tuple

// Current incorrect return
return (amount, totalSupply(), amountScaled, balanceIncrease);
// Should return
return (amountScaled, totalSupply(), amount, balanceIncrease);

Proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DummyLendingPool {
uint256 private normalizedDebt;
constructor() {
normalizedDebt = 1e27; // Initial index (RAY)
}
function getNormalizedDebt() external view returns (uint256) {
return normalizedDebt;
}
function setNormalizedDebt(uint256 newDebt) external {
normalizedDebt = newDebt;
}
}
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("DebtToken Vulnerability PoC", function () {
let owner, user;
let dummyPool, debtToken;
const RAY = ethers.parseUnits("1", 27);
before(async function () {
[owner, user] = await ethers.getSigners();
// Deploy DummyLendingPool
const DummyLendingPool = await ethers.getContractFactory("DummyLendingPool");
dummyPool = await DummyLendingPool.deploy();
await dummyPool.deployed();
// Deploy DebtToken with owner as initialOwner
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
await debtToken.deployed();
// Set reserve pool to owner (acting as dummy pool caller)
// For this PoC we simulate reserve pool by setting it to owner (who can call onlyReservePool)
await debtToken.setReservePool(owner.address);
// Mint some debt tokens.
// Using initial index = RAY. Mint 100 tokens.
await debtToken.mint(owner.address, owner.address, ethers.parseEther("100"), RAY);
});
it("Should detect vulnerability in burn() return values", async function () {
// Simulate an increase in interest rate: new index = 1.2 * RAY
const newIndex = RAY.mul(120).div(100); // 1.2e27
// Update the dummy pool normalized debt (simulate interest accrual)
// Here we call dummyPool.setNormalizedDebt(newIndex) externally for record.
// (In production, DebtToken uses ILendingPool(getNormalizedDebt()).
// For our test, the reserve pool is owner so balanceOf uses dummyPool[not used],
// so we pass newIndex directly to burn.)
// Call burn with amount = 100 tokens.
// In a correct implementation, the scaled burn amount should be:
// amountScaled = 100 * RAY / newIndex = 100 / 1.2 (≈83 tokens) (rounded accordingly)
const tx = await debtToken.burn(owner.address, ethers.parseEther("100"), newIndex);
const receipt = await tx.wait();
const burnReturn = receipt.events
.filter(e => e.event === undefined) // burn() itself doesn’t emit an event for the tuple but we capture the return via callStatic below
.length; // dummy placeholder, so instead we call callStatic
// Instead, simulate callStatic to get the tuple result.
const returnTuple = await debtToken.callStatic.burn(owner.address, ethers.parseEther("100"), newIndex);
// Expected correct return: [amountScaled, newTotalSupply, rawAmount, balanceIncrease]
const rawAmount = ethers.parseEther("100");
const expectedScaledAmount = rawAmount.mul(RAY).div(newIndex);
// Vulnerable implementation would return: [rawAmount, newTotalSupply, expectedScaledAmount, balanceIncrease]
// Compare the first element with expectedScaledAmount.
if (returnTuple[0].eq(rawAmount)) {
console.log("Vulnerability detected: burn() returns raw amount as first element.");
} else {
console.log("No vulnerability: burn() returns scaled amount as first element.");
}
// For PoC, we assert that the first element is NOT equal to expectedScaledAmount.
expect(returnTuple[0]).to.not.equal(expectedScaledAmount);
});
});

Impact

  • The contract burns more tokens than it should (raw amount vs. scaled amount)

  • Incorrect interest accrual calculations

Recommendations

function burn(
address from,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256, uint256) {
if (from == address(0)) revert InvalidAddress();
if (amount == 0) {
return (0, totalSupply(), 0, 0);
}
uint256 userBalance = balanceOf(from);
uint256 balanceIncrease = 0;
if (_userState[from].index != 0 && _userState[from].index < index) {
balanceIncrease = userBalance.rayMul(index) - userBalance.rayMul(_userState[from].index);
}
_userState[from].index = index.toUint128();
if (amount > userBalance) {
amount = userBalance;
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
_burn(from, amountScaled.toUint128());
emit Burn(from, amountScaled, index);
return (amountScaled, totalSupply(), amount, balanceIncrease);
}

References

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
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.

DebtToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

DebtToken::burn returns items in the wrong order

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
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.

DebtToken::burn incorrectly burns amount (asset units) instead of amountScaled (token units), breaking token economics and interest-accrual mechanism

DebtToken::burn returns items in the wrong order

Support

FAQs

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

Give us feedback!