Summary
The DebtToken.sol
contract mints excess debt tokens across multiple borrows from the lending pool contract LendingPool.sol
resulting in incorrect total debt, usage rate and liquidity rate.
Vulnerability Details
The mint()
function in DebtToken.sol
before minting new tokens, calculates the user's scaled balance wrongly thus resulting in a wrong balanceIncrease
.
As seen in the mint()
function below,
* @notice Mints debt tokens to a user
* @param user The address initiating the mint
* @param onBehalfOf The recipient of the debt tokens
* @param amount The amount to mint (in underlying asset units)
* @param index The usage index at the time of minting
* @return A tuple containing:
* - bool: True if the previous balance was zero
* - uint256: The amount of scaled tokens minted
* - uint256: The new total supply after minting
*/
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
uint256 scaledBalance = balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}
the user's scaled balance scaledBalance
is calculated as follows
uint256 scaledBalance = balanceOf(onBehalfOf);
the balanceOf()
function from DebitToken.sol
is ivoked and it returns the user's balance adjusting for the current debt index or usuageIndex as shown below.
* @notice Returns the scaled debt balance of the user
* @param account The address of the user
* @return The user's debt balance (scaled by the usage index)
*/
function balanceOf(address account) public view override(ERC20, IERC20) returns (uint256) {
uint256 scaledBalance = super.balanceOf(account);
return scaledBalance.rayMul(ILendingPool(_reservePool).getNormalizedDebt());
}
the balanceIncrease
as calculated in the mint()
function applies the current debt index once more to the user's balance snapshot thus inflating the increase in balance as follows
balanceIncrease = balance*(cur_index)*(cur_index) - balance*(cur_index)*(prev_index)
as seen in the code below
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
the amountToMint
value minted by _mint()
-> _update()
(internal overridden) in DebtToken.sol
is thus in excess of what it should be.
The totalSupply()
calculated is thus wrong and it reflects in the totalDebt
in the LendingPool.sol
as shown below
(in mint()
in DebtToken.sol
)
return (scaledBalance == 0, amountToMint, totalSupply());
(in borrow()
in LendingPool.sol
)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
(in updateInterestRatesAndLiquidity
in ReserveLibrary.sol
)
uint256 utilizationRate = calculateUtilizationRate(reserve.totalLiquidity, reserve.totalUsage);
(in calculateUtilizationRate
in ReserveLibrary.sol
)
uint256 utilizationRate = totalDebt.rayDiv(totalLiquidity + totalDebt).toUint128();
(in updateInterestRatesAndLiquidity
in ReserveLibrary.sol
)
rateData.currentUsageRate = calculateBorrowRate(
rateData.primeRate,
rateData.baseRate,
rateData.optimalRate,
rateData.maxRate,
rateData.optimalUtilizationRate,
utilizationRate
);
rateData.currentLiquidityRate = calculateLiquidityRate(
utilizationRate,
rateData.currentUsageRate,
rateData.protocolFeeRate,
totalDebt
);
PoC:
The following test performs a double mint with an index of 1.6 RAY between the
two mints. The initial mint is of 100 ETH and the second mint is also of 100 underlying asset units, the user's total balance should be 320 ETH but the
mint()
function mints a total of 356 ETH so there is a excess of 36 ETH minted which is wrong.
Copy the following in DebtToken.test-h1.js
and
run the command npx hardhat test test/unit/core/tokens/DebtToken.test-h1.js
import { expect } from "chai";
import hre from "hardhat";
const { ethers, network } = hre;
describe("DebtToken", function () {
this.timeout(50000);
let DebtToken;
let debtToken;
let owner;
let user1;
let user2;
let addrs;
let MockLendingPool;
let mockLendingPool;
let mockLendingPoolSigner;
const RAY = ethers.getBigInt("1000000000000000000000000000");
beforeEach(async function () {
[owner, user1, user2, ...addrs] = await ethers.getSigners();
const MockLendingPoolFactory = await ethers.getContractFactory("MockLendingPoolDebtToken", owner);
mockLendingPool = await MockLendingPoolFactory.deploy();
await mockLendingPool.waitForDeployment();
const mockLendingPoolAddress = await mockLendingPool.getAddress();
await mockLendingPool.setNormalizedDebt(RAY);
const DebtTokenFactory = await ethers.getContractFactory("DebtToken", owner);
debtToken = await DebtTokenFactory.deploy("DebtToken", "DT", owner.address);
await debtToken.waitForDeployment();
const debtTokenAddress = await debtToken.getAddress();
await debtToken.setReservePool(mockLendingPoolAddress);
mockLendingPoolSigner = await ethers.getImpersonatedSigner(mockLendingPoolAddress);
await owner.sendTransaction({
to: mockLendingPoolAddress,
value: ethers.parseEther("10.0")
});
await network.provider.send("hardhat_setNextBlockBaseFeePerGas", ["0x0"]);
await network.provider.send("hardhat_setBalance", [
mockLendingPoolAddress,
"0x56BC75E2D63100000"
]);
});
describe.only("Double Mint functionality", function() {
beforeEach(async function() {
});
it("should allow ReservePool to mint debt tokens", async function () {
const mintAmount = ethers.parseEther("100");
let index = RAY;
console.log("Attempting to mint", mintAmount.toString(), "tokens with index", index.toString());
let tx = await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, index);
let receipt = await tx.wait();
console.log("Mint transaction successful, gas used:", receipt.gasUsed.toString());
let balance = await debtToken.balanceOf(user1.address);
console.log("User balance after mint:", balance.toString());
expect(balance).to.equal(mintAmount);
const RAY_1_6 = ethers.getBigInt("1600000000000000000000000000");
await mockLendingPool.setNormalizedDebt(RAY_1_6);
index = ethers.parseUnits("1.6", 27);
console.log("Attempting to mint additional", mintAmount.toString(), "tokens with index", index.toString());
tx = await debtToken.connect(mockLendingPoolSigner).mint(user1.address, user1.address, mintAmount, index);
receipt = await tx.wait();
console.log("Mint transaction successful, gas used:", receipt.gasUsed.toString());
balance = await debtToken.balanceOf(user1.address);
console.log("User balance after 2nd mint:", balance.toString());
expect(balance).to.equal(ethers.getBigInt("320000000000000000000"));
});
});
});
Impact
The user is made liable for excess debt than what it actually should be and the total debt wrongly reflects in Lending Pool in terms of the usuage rate and liquidity rate.
Tools Used
Manual Review
Recommended Mitigation
Fix the scaledBalance
of the user before minting by calling super.balanceOf()
instead of balanceOf()
as shown below.
function mint(
address user,
address onBehalfOf,
uint256 amount,
uint256 index
) external override onlyReservePool returns (bool, uint256, uint256) {
if (user == address(0) || onBehalfOf == address(0)) revert InvalidAddress();
if (amount == 0) {
return (false, 0, totalSupply());
}
uint256 amountScaled = amount.rayDiv(index);
if (amountScaled == 0) revert InvalidAmount();
-- uint256 scaledBalance = balanceOf(onBehalfOf);
++ uint256 scaledBalance = super.balanceOf(onBehalfOf);
bool isFirstMint = scaledBalance == 0;
uint256 balanceIncrease = 0;
if (_userState[onBehalfOf].index != 0 && _userState[onBehalfOf].index < index) {
balanceIncrease = scaledBalance.rayMul(index) - scaledBalance.rayMul(_userState[onBehalfOf].index);
}
_userState[onBehalfOf].index = index.toUint128();
uint256 amountToMint = amount + balanceIncrease;
_mint(onBehalfOf, amountToMint.toUint128());
emit Transfer(address(0), onBehalfOf, amountToMint);
emit Mint(user, onBehalfOf, amountToMint, balanceIncrease, index);
return (scaledBalance == 0, amountToMint, totalSupply());
}