Reliq Finance Disclosed Report

numberOfDays Field Corruption in borrowMore()

Created date
Dec 22 2025

Target

https://github.com/hackenproof-public/reliq-protocol

Vulnerability Details

Summary

The borrowMore() function in ReliqHYPE.sol incorrectly overwrites the numberOfDays field with the remaining loan tenure instead of preserving the original loan duration. This causes permanent data corruption that cascades through subsequent operations like extendLoan(), leading to incorrect tenure calculations and loss of historical loan data.

Vulnerability Details

Root Cause

The borrowMore() function calculates the remaining loan tenure and then incorrectly stores this value in the numberOfDays field, overwriting the original loan duration:

// Line 373-374: Calculate remaining tenure
uint256 nextMidnight = getMidnightTimestamp(block.timestamp);
uint256 newBorrowTenure = (endDate - nextMidnight) / 1 days;

// Lines 401-406: BUG - overwrites original numberOfDays with remaining days
Loans[msg.sender] = Loan({
    collateral: netUserCollateral,
    borrowed: netUserBorrow,
    endDate: endDate,
    numberOfDays: newBorrowTenure  // Should preserve original, not overwrite
});

The Problem

The numberOfDays field is meant to track the original loan duration for historical and accounting purposes. However, borrowMore() replaces it with the remaining days until maturity, causing:

  1. Loss of historical data: The original loan duration is permanently lost
  2. Incorrect calculations: Functions that read numberOfDays now get the wrong value
  3. Cascading corruption: Repeated calls to borrowMore() compound the problem
  4. extendLoan() impact: The function uses the corrupted value for tenure validation

Vulnerable Code Path

  1. User creates a loan for N days (e.g., 180 days)
    • Loans[user].numberOfDays = 180
  2. Time passes (e.g., 90 days)
  3. User calls borrowMore()
    • newBorrowTenure = (endDate - nextMidnight) / 1 days = 90
    • Loans[user].numberOfDays = 90 (CORRUPTED - was 180)
  4. Original duration (180) is permanently lost

extendLoan() Dependency

The extendLoan() function reads and updates numberOfDays:

// Line 586: Reads the potentially corrupted value
uint256 _loanTenure = Loans[msg.sender].numberOfDays;

// Line 606: Updates based on corrupted value
Loans[msg.sender].numberOfDays = _loanTenure + numberOfDays;

If borrowMore() was called before extendLoan(), the tenure tracking will be incorrect.

Impact

1. Data Corruption

  • Historical data loss: Original loan parameters cannot be recovered
  • Audit trail broken: On-chain records no longer reflect actual loan history
  • Accounting inconsistencies: Total tenure calculations become unreliable

2. Functional Impact on extendLoan()

  • If a user borrows for 100 days, waits 50 days, calls borrowMore(), then extends:
    • numberOfDays is corrupted from 100 to 50
    • Extending by 200 days: total becomes 250 instead of expected 300
    • User loses track of actual total loan duration

3. Compounding Corruption

  • Multiple borrowMore() calls progressively corrupt the data
  • Example: 365 days → 315 → 215 → 65 (300-day data loss)
  • Each call further degrades data integrity

4. Smart Contract Fails to Deliver Promised Returns

  • Users and protocol lose accurate tracking of loan tenures
  • Incorrect total tenure calculations affect risk management
  • Protocol cannot reliably track cumulative loan durations

Why This Matters

While this might seem like a minor data inconsistency, it has real implications:

  1. Protocol analytics: Cannot accurately track historical loan durations
  2. Risk management: Incorrect total tenure calculations
  3. User trust: On-chain data doesn't match actual loan history
  4. Future features: Any feature relying on numberOfDays will have corrupted data
  5. Audit trails: Forensic analysis becomes unreliable

Difficulty to Exploit

This is not an active attack vector but rather a functional correctness issue that:

  • Affects every user who calls borrowMore()
  • Cannot be prevented by users
  • Accumulates over time
  • Corrupts protocol state silently

Validation steps

Proof of Concept

Test File Location

test/POC/M01_NumberOfDaysCorruption.t.sol

Running the POC

forge test --match-path test/POC/M01_NumberOfDaysCorruption.t.sol -vv

POC Code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "forge-std/Test.sol";
import {ReliqHYPE} from "../../src/ReliqHYPE.sol";
import {InterestManager} from "../../src/InterestManager.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor() ERC20("Mock Backing Token", "MBK") {}

    function mint(address to, uint256 amt) external {
        _mint(to, amt);
    }
}

contract POC_M01_NumberOfDaysCorruption is Test {
    ReliqHYPE reHYPE;
    InterestManager interestManager;
    MockERC20 backing;

    address owner = address(this);
    address treasury = makeAddr("treasury");
    address user = makeAddr("user");

    function setUp() public {
        backing = new MockERC20();
        reHYPE = new ReliqHYPE(IERC20(address(backing)));
        interestManager = new InterestManager(1000);

        reHYPE.setTreasuryAddress(treasury);
        reHYPE.setInterestManager(address(interestManager));

        backing.mint(owner, 1_000_000 ether);
        backing.approve(address(reHYPE), type(uint256).max);

        backing.mint(user, 1_000_000 ether);
        vm.prank(user);
        backing.approve(address(reHYPE), type(uint256).max);

        reHYPE.setStart(100_000 ether, 1000 ether);
        reHYPE.setMaxMintable(10_000_000 ether);

        reHYPE.buy(owner, 500_000 ether);

        vm.prank(user);
        reHYPE.buy(user, 100_000 ether);
    }

    function test_NumberOfDaysFieldCorruption() public {
        vm.startPrank(user);

        reHYPE.borrow(10_000 ether, 90);

        (,, uint256 endDate, uint256 initialNumberOfDays) = reHYPE.Loans(user);

        assertEq(initialNumberOfDays, 90, "Initial loan should be 90 days");

        vm.warp(block.timestamp + 30 days);

        uint256 nextMidnight = reHYPE.getMidnightTimestamp(block.timestamp);
        uint256 remainingDays = (endDate - nextMidnight) / 1 days;

        reHYPE.borrowMore(5_000 ether);

        (,, uint256 newEndDate, uint256 corruptedNumberOfDays) = reHYPE.Loans(user);

        assertEq(newEndDate, endDate, "End date should remain the same");
        assertEq(corruptedNumberOfDays, remainingDays, "numberOfDays was overwritten with remaining days");
        assertLt(corruptedNumberOfDays, initialNumberOfDays, "numberOfDays is now smaller than original");
        assertEq(corruptedNumberOfDays, 60, "numberOfDays should be ~60 days remaining");

        vm.stopPrank();
    }

    function test_MaximumImpact_DataCorruptionInExtendLoan() public {
        vm.startPrank(user);

        reHYPE.borrow(10_000 ether, 100);

        (,,, uint256 originalTenure) = reHYPE.Loans(user);
        assertEq(originalTenure, 100, "Original tenure is 100 days");

        vm.warp(block.timestamp + 50 days);

        reHYPE.borrowMore(5_000 ether);

        (,,, uint256 corruptedTenure) = reHYPE.Loans(user);

        assertLt(corruptedTenure, originalTenure, "Tenure was corrupted");
        assertEq(corruptedTenure, 50, "Corrupted tenure is ~50 days");

        uint256 extensionDays = 200;
        reHYPE.extendLoan(extensionDays);

        (,,, uint256 finalNumberOfDays) = reHYPE.Loans(user);

        uint256 expectedTotalTenure = originalTenure + extensionDays;
        uint256 actualTotalTenure = finalNumberOfDays;

        assertEq(actualTotalTenure, corruptedTenure + extensionDays, "extendLoan uses corrupted value");
        assertLt(actualTotalTenure, expectedTotalTenure, "Total tenure calculation is wrong");
        assertEq(actualTotalTenure, 250, "Wrong tenure: 50 + 200 = 250 instead of 100 + 200 = 300");

        vm.stopPrank();
    }

    function test_RepeatedBorrowMoreCompoundsCorruption() public {
        vm.startPrank(user);

        reHYPE.borrow(10_000 ether, 365);

        (,,, uint256 tenure0) = reHYPE.Loans(user);
        assertEq(tenure0, 365, "Initial: 365 days");

        vm.warp(block.timestamp + 50 days);
        reHYPE.borrowMore(1_000 ether);
        (,,, uint256 tenure1) = reHYPE.Loans(user);
        assertEq(tenure1, 315, "After 50 days: ~315 remaining");

        vm.warp(block.timestamp + 100 days);
        reHYPE.borrowMore(1_000 ether);
        (,,, uint256 tenure2) = reHYPE.Loans(user);
        assertEq(tenure2, 215, "After another 100 days: ~215 remaining");

        vm.warp(block.timestamp + 150 days);
        reHYPE.borrowMore(1_000 ether);
        (,,, uint256 tenure3) = reHYPE.Loans(user);
        assertEq(tenure3, 65, "After another 150 days: ~65 remaining");

        assertEq(tenure0, 365, "Started with 365 days");
        assertEq(tenure3, 65, "Ended with 65 days in record");

        vm.stopPrank();
    }

    function test_HistoricalDataLoss() public {
        vm.startPrank(user);

        reHYPE.borrow(50_000 ether, 180);

        (uint256 collateral1, uint256 borrowed1, uint256 endDate1, uint256 originalDays) = reHYPE.Loans(user);

        assertEq(originalDays, 180, "Original loan was 180 days");

        vm.warp(block.timestamp + 90 days);

        reHYPE.borrowMore(25_000 ether);

        (uint256 collateral2, uint256 borrowed2, uint256 endDate2, uint256 newDays) = reHYPE.Loans(user);

        assertEq(endDate2, endDate1, "End date unchanged");
        assertGt(collateral2, collateral1, "Collateral increased");
        assertGt(borrowed2, borrowed1, "Borrowed increased");
        assertLt(newDays, originalDays, "Historical tenure data lost");
        assertEq(newDays, 90, "Only ~90 days remaining recorded");

        vm.stopPrank();
    }
}

Recommended Mitigation

Option 1: Do Not Modify numberOfDays

The numberOfDays field should remain unchanged after the initial loan creation:

function borrowMore(uint256 amountToBorrow) public nonReentrant {
    // ... existing code ...

    uint256 netUserCollateral = userCollateral + collateralDeficit;
    uint256 netUserBorrow = userBorrowed + effectiveNewBorrow;

    Loans[msg.sender] = Loan({
        collateral: netUserCollateral,
        borrowed: netUserBorrow,
        endDate: endDate,
        numberOfDays: Loans[msg.sender].numberOfDays  // Preserve original
    });

    // ... rest of function ...
}

Option 2: Track Both Original and Remaining

If both values are needed, add a separate field:

struct Loan {
    uint256 collateral;
    uint256 borrowed;
    uint256 endDate;
    uint256 numberOfDays;           // Original duration
    uint256 remainingDaysAtUpdate;  // Last calculated remaining
}

Option 3: Remove numberOfDays from borrowMore()

Since the loan endDate already tracks maturity, the numberOfDays update is unnecessary:

function borrowMore(uint256 amountToBorrow) public nonReentrant {
    // ... existing code ...

    // Only update fields that actually change
    Loans[msg.sender].collateral = netUserCollateral;
    Loans[msg.sender].borrowed = netUserBorrow;
    // Do NOT touch numberOfDays or endDate

    // ... rest of function ...
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Low
Bounty$50
Visibilitypartially
VulnerabilityBlockchain
Participants (4)
company admin
author