Huma Finance V1 Deprecated Pools Requested-to-GoodStanding Credit Lifecycle Drain
On May 11, 2026 at 14:19:25 UTC, three deprecated Huma Finance V1 BaseCreditPool proxy deployments o 2026-5-11 00:0:40 Author: www.darknavy.org(查看原文) 阅读量:0 收藏

On May 11, 2026 at 14:19:25 UTC, three deprecated Huma Finance V1 BaseCreditPool proxy deployments on Polygon were drained by an attacker-controlled borrower contract. The exploit was a credit-lifecycle logic error with an access-control component: an open requestCredit(..., preApproved=false) path created Requested credit records, then an unrestricted refreshAccount(address) call advanced those unapproved records to GoodStanding. The attacker then used the GoodStanding return-drawdown branch of drawdown(uint256) to borrow the pools’ residual balances and sweep 82,315.571143 native USDC plus 19,074.730401 bridged USDC.e, about $101,390.30. This was not an Approved first-drawdown exploit; the exploit drawdowns are evidenced by calcCorrection(...) calls before distBorrowingAmount(...), matching the GoodStanding branch.

Root Cause

Vulnerable Contract

The vulnerable contracts are three Huma V1 BaseCreditPool pools behind TransparentUpgradeableProxy instances:

  • 0x3EBc1f0644A69c565957EF7cEb5AEafE94Eb6FcE, implementation 0x57107D02C2b70e09aD77240dbDe7aD77fE91eA1c.
  • 0x95533e56f397152B0013A39586bC97309e9A00a7, implementation 0x57107D02C2b70e09aD77240dbDe7aD77fE91eA1c.
  • 0xe8926aDbFADb5DA91CD56A7d5aCC31AA3FDF47E5, implementation 0x2cFfaAf7885530e1C5A9684eBBe397d6f1DE48d8.

The EIP-1967 implementation slots are recorded in proxy_checks.txt. Both implementation contracts are verified source; the relevant files are contracts/BaseCreditPool.sol and contracts/libraries/BaseStructs.sol. The alternate implementation at 0x2cFf...48d8 contains the same requestCredit, refreshAccount, _updateDueInfo, and drawdown logic relevant to this incident.

Vulnerable Function

Primary vulnerable state-transition function: refreshAccount(address borrower), selector 0xa3e35f36, in contracts/BaseCreditPool.sol.

Precondition-setting function: requestCredit(uint256 creditLimit,uint256 intervalInDays,uint256 numOfPayments), selector 0x6b568dad, in contracts/BaseCreditPool.sol.

Drain function: drawdown(uint256 borrowAmount), selector 0xa079a4dd, in contracts/BaseCreditPool.sol.

Vulnerable Code

function refreshAccount(address borrower)
    external
    virtual
    override
    returns (BS.CreditRecord memory cr)
{
    if (_creditRecordMapping[borrower].state != BS.CreditState.Defaulted) {
        if (isDefaultReady(borrower)) return _updateDueInfo(borrower, false, false); // <-- VULNERABILITY: callable by anyone for any non-defaulted borrower, including Requested records
        else return _updateDueInfo(borrower, false, true); // <-- VULNERABILITY: no check that borrower is Approved/GoodStanding before updating due info
    }
}

function requestCredit(
    uint256 creditLimit,
    uint256 intervalInDays,
    uint256 numOfPayments
) external virtual override {
    // Open access to the borrower. Data validation happens in _initiateCredit()
    _initiateCredit(
        msg.sender,
        creditLimit,
        _poolConfig.poolAprInBps(),
        intervalInDays,
        numOfPayments,
        false // <-- VULNERABILITY: open caller creates a Requested record with attacker-chosen credit terms, not an Approved record
    );
}

function _initiateCredit(
    address borrower,
    uint256 creditLimit,
    uint256 aprInBps,
    uint256 intervalInDays,
    uint256 remainingPeriods,
    bool preApproved
) internal virtual {
    if (remainingPeriods == 0) revert Errors.requestedCreditWithZeroDuration();
    _protocolAndPoolOn();
    BS.CreditRecord memory cr = _getCreditRecord(borrower);
    // ...
    _creditRecordStaticMapping[borrower] = BS.CreditRecordStatic({
        creditLimit: uint96(creditLimit),
        aprInBps: uint16(aprInBps),
        intervalInDays: uint16(intervalInDays),
        defaultAmount: uint96(0)
    });

    BS.CreditRecord memory ncr;
    ncr.remainingPeriods = uint16(remainingPeriods);

    if (preApproved) {
        ncr = _approveCredit(ncr);
        emit CreditApproved(borrower, creditLimit, intervalInDays, remainingPeriods, aprInBps);
    } else ncr.state = BS.CreditState.Requested; // <-- VULNERABILITY: Requested is later promotable by refreshAccount/_updateDueInfo

    _setCreditRecord(borrower, ncr);
    emit CreditInitiated(borrower, creditLimit, aprInBps, intervalInDays, remainingPeriods, preApproved);
}

function _updateDueInfo(
    address borrower,
    bool isFirstDrawdown,
    bool distributeChargesForLastCycle
) internal virtual returns (BS.CreditRecord memory cr) {
    cr = _getCreditRecord(borrower);
    if (isFirstDrawdown) cr.dueDate = 0;
    bool alreadyLate = cr.totalDue > 0 ? true : false;

    (uint256 periodsPassed, cr.feesAndInterestDue, cr.totalDue, cr.unbilledPrincipal, int96 newCharges) =
        _feeManager.getDueInfo(cr, _getCreditRecordStatic(borrower));

    if (periodsPassed > 0) {
        cr.correction = 0;
        // ...
        if (cr.dueDate > 0) cr.dueDate = uint64(cr.dueDate + periodsPassed * intervalInDays * SECONDS_IN_A_DAY);
        else cr.dueDate = uint64(block.timestamp + intervalInDays * SECONDS_IN_A_DAY);

        if (cr.remainingPeriods > periodsPassed) cr.remainingPeriods = uint16(cr.remainingPeriods - periodsPassed);
        else cr.remainingPeriods = 0;

        if (alreadyLate) cr.missedPeriods = uint16(cr.missedPeriods + periodsPassed);
        else cr.missedPeriods = 0;

        if (cr.missedPeriods > 0) {
            if (cr.state != BS.CreditState.Defaulted) cr.state = BS.CreditState.Delayed;
        } else cr.state = BS.CreditState.GoodStanding; // <-- VULNERABILITY: Requested can become GoodStanding without approval

        _setCreditRecord(borrower, cr);
        emit BillRefreshed(borrower, cr.dueDate, msg.sender);
    }
}

function _drawdown(
    address borrower,
    BS.CreditRecord memory cr,
    uint256 borrowAmount
) internal virtual returns (uint256) {
    if (cr.state == BS.CreditState.Approved) {
        // Flow for first drawdown. This branch was NOT used in the exploit.
        _creditRecordMapping[borrower].unbilledPrincipal = uint96(borrowAmount);
        cr = _updateDueInfo(borrower, true, true);
        cr.state = BS.CreditState.GoodStanding;
    } else {
        // Return drawdown flow
        if (block.timestamp > cr.dueDate) {
            cr = _updateDueInfo(borrower, false, true);
            if (cr.state != BS.CreditState.GoodStanding) revert Errors.creditLineNotInGoodStandingState();
        }

        if (borrowAmount > (_creditRecordStaticMapping[borrower].creditLimit - cr.unbilledPrincipal - (cr.totalDue - cr.feesAndInterestDue)))
            revert Errors.creditLineExceeded();
        if (cr.remainingPeriods == 0) revert Errors.creditExpiredDueToMaturity();

        cr.correction += int96(uint96(_calcCorrection(cr.dueDate, _creditRecordStaticMapping[borrower].aprInBps, borrowAmount))); // <-- VULNERABILITY: exploited GoodStanding branch accepts the promoted record
        cr.unbilledPrincipal = uint96(cr.unbilledPrincipal + borrowAmount);
    }

    _setCreditRecord(borrower, cr);
    (uint256 netAmountToBorrower, uint256 platformFees) = _feeManager.distBorrowingAmount(borrowAmount);
    if (platformFees > 0) distributeIncome(platformFees);
    _underlyingToken.safeTransfer(borrower, netAmountToBorrower); // <-- VULNERABILITY: transfers pool assets directly to attacker-controlled borrower
    return netAmountToBorrower;
}

BaseStructs.CreditState defines Deleted = 0, Requested = 1, Approved = 2, and GoodStanding = 3, so the pre-exploit state value 3 is GoodStanding, not Approved.

Why It’s Vulnerable

Expected behavior: a borrower-created Requested credit record should remain non-drawable until an authorized underwriting or evaluation-agent path explicitly approves it. Public account-refresh logic should update billing on already-active accounts, not convert unapproved requests into live GoodStanding credit lines.

Actual behavior: requestCredit() is open and stores attacker-chosen credit limits while setting preApproved=false, which creates Requested records. refreshAccount(address) is also open; it accepts any non-defaulted borrower and calls _updateDueInfo(), whose periodsPassed > 0 path sets cr.state = GoodStanding without checking that the previous state was Approved or already active. Once the attacker contract’s records were GoodStanding, drawdown() accepted them and used the return-drawdown branch, subject only to the stored credit limit and maturity checks.

Normal flow vs attack flow:

  • Normal flow: a borrower requests credit, an authorized approval path changes the line to Approved, and the borrower performs an initial drawdown that generates the first bill.
  • Attack flow: the attacker contract requested credit in deprecated pools, a separate activator contract called refreshAccount(address) for that borrower on each pool, _updateDueInfo() changed the records from Requested to GoodStanding, and the attacker then drew the residual stablecoin balances as a return drawdown.

This is classified as logic_error primary, with access_control secondary. The core logic error is the invalid Requested -> GoodStanding transition; the access-control weakness is that both borrower onboarding and account refresh remained externally callable on deprecated pools that still held funds.

Attack Execution

High-Level Flow

  1. The attacker deployed helper contract 0x44D4...22A3 and used it to request credit from three deprecated Huma V1 pools.
  2. Those requestCredit() calls used preApproved=false, so the helper’s borrower records were Requested, not Approved.
  3. A separate activation transaction deployed 0xef8a...e1b2, which called refreshAccount(address) on all three pools for borrower 0x44D4...22A3.
  4. refreshAccount() emitted BillRefreshed and left the helper’s borrower records in GoodStanding with due date 1778595509.
  5. The attacker called the helper’s batch executor to invoke drawdown() on all three pool proxies.
  6. Each drawdown() followed the GoodStanding return-drawdown branch and transferred residual USDC/USDC.e to the helper.
  7. The helper swept the native USDC and bridged USDC.e balances to the attacker EOA.

Detailed Call Trace

The activation transaction 0x7126ae1d8e8d1e0c0f1c598de16a035cf309d6cc556e73edc2847de2b5777e5e succeeded at block 86725372 (2026-05-11 14:18:29 UTC) and created 0xef8a13797b009228f6e4a25112ea114b7ba6e1b2:

  • 0x8bf40c...cf53 -> new contract 0xef8a...e1b2: CREATE.
    • 0xef8a...e1b2 -> 0x3EBc...6FcE: refreshAccount(address) (0xa3e35f36), CALL, borrower 0x44D4...22A3.
      • 0x3EBc...6FcE -> 0x5710...A1c: refreshAccount(address) (0xa3e35f36), DELEGATECALL.
    • 0xef8a...e1b2 -> 0x9553...00a7: refreshAccount(address) (0xa3e35f36), CALL, borrower 0x44D4...22A3.
      • 0x9553...00a7 -> 0x5710...A1c: refreshAccount(address) (0xa3e35f36), DELEGATECALL.
    • 0xef8a...e1b2 -> 0xe892...7E5: refreshAccount(address) (0xa3e35f36), CALL, borrower 0x44D4...22A3.
      • 0xe892...7E5 -> 0x2cFf...48d8: refreshAccount(address) (0xa3e35f36), DELEGATECALL.

The exploit transaction 0x7b8d641d76affcc029fd0e0f06ab81ad675b1da21ef79b82e1343016040ba359 succeeded at block 86725404 (2026-05-11 14:19:25 UTC) and has this trace-derived call flow:

  • 0x13B44e416e0f66359502E843AF2e1191f1260DaF -> 0x44D4a434aE1529106e4B801315E22721978022A3: executeCalls((address,uint256,bytes)[]) (0x1726fa81), CALL, value 0.
  • 0x44D4...22A3 -> 0x3EBc...6FcE: drawdown(uint256) (0xa079a4dd), CALL, amount 82,315,571,143 raw USDC.
    • 0x3EBc...6FcE -> 0x5710...A1c: drawdown(uint256) (0xa079a4dd), DELEGATECALL.
    • 0x3EBc...6FcE -> 0x03D8...5393: paused() (0x5c975abb), STATICCALL.
    • 0x3EBc...6FcE -> 0x989f...B6D0: calcCorrection(uint256,uint256,uint256) (0x3d112301), STATICCALL.
    • 0x3EBc...6FcE -> 0x989f...B6D0: distBorrowingAmount(uint256) (0x2a56916b), STATICCALL.
    • 0x3EBc...6FcE -> native USDC 0x3c499...3359: transfer(address,uint256) (0xa9059cbb), CALL, to 0x44D4...22A3, amount 82,315.571143 USDC.
  • 0x44D4...22A3 -> 0x9553...00a7: drawdown(uint256) (0xa079a4dd), CALL, amount 17,290,759,830 raw USDC.e.
    • 0x9553...00a7 -> 0x5710...A1c: drawdown(uint256) (0xa079a4dd), DELEGATECALL.
    • 0x9553...00a7 -> 0x03D8...5393: paused() (0x5c975abb), STATICCALL.
    • 0x9553...00a7 -> 0xC3bB...4Ea6: calcCorrection(uint256,uint256,uint256) (0x3d112301), STATICCALL.
    • 0x9553...00a7 -> 0xC3bB...4Ea6: distBorrowingAmount(uint256) (0x2a56916b), STATICCALL.
    • 0x9553...00a7 -> bridged USDC.e 0x2791...4174: transfer(address,uint256) (0xa9059cbb), CALL, to 0x44D4...22A3, amount 17,290.759830 USDC.e.
  • 0x44D4...22A3 -> 0xe892...7E5: drawdown(uint256) (0xa079a4dd), CALL, amount 1,783,970,571 raw USDC.e.
    • 0xe892...7E5 -> 0x2cFf...48d8: drawdown(uint256) (0xa079a4dd), DELEGATECALL.
    • 0xe892...7E5 -> 0x03D8...5393: paused() (0x5c975abb), STATICCALL.
    • 0xe892...7E5 -> 0x7eD4...fCd1: calcCorrection(uint256,uint256,uint256) (0x3d112301), STATICCALL.
    • 0xe892...7E5 -> 0x7eD4...fCd1: distBorrowingAmount(uint256) (0x2a56916b), STATICCALL.
    • 0xe892...7E5 -> bridged USDC.e 0x2791...4174: transfer(address,uint256) (0xa9059cbb), CALL, to 0x44D4...22A3, amount 1,783.970571 USDC.e.
  • 0x44D4...22A3 -> 0x44D4...22A3: sweepToken(address,address) (0x258836fe), CALL, native USDC to attacker EOA.
    • Helper calls balanceOf(address) (0x70a08231) and transfer(address,uint256) (0xa9059cbb) of 82,315.571143 USDC to 0x13B44...0DaF.
  • 0x44D4...22A3 -> 0x44D4...22A3: sweepToken(address,address) (0x258836fe), CALL, bridged USDC.e to attacker EOA.
    • Helper calls balanceOf(address) (0x70a08231) and transfer(address,uint256) (0xa9059cbb) of 19,074.730401 USDC.e to 0x13B44...0DaF.

The repeated calcCorrection(...) calls before distBorrowingAmount(...) are the trace signature of the GoodStanding return-drawdown branch. The Approved first-drawdown branch calls _updateDueInfo(..., true, true) instead and is not the path evidenced by the exploit trace.

Financial Impact

funds_flow.json is the primary accounting evidence. The attacker EOA gained:

  • 82,315.571143 native Polygon USDC (0x3c499c542cef5e3811e1192ce70d8cc03d5c3359) from pool 0x3EBc...6FcE.
  • 19,074.730401 bridged Polygon USDC.e (0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) from pools 0x9553...00a7 and 0xe892...7E5 combined.

Total stablecoin impact was approximately $101,390.301544 before gas. The pool-level net changes were -82,315.571143 native USDC for 0x3EBc...6FcE, -17,290.759830 USDC.e for 0x9553...00a7, and -1,783.970571 USDC.e for 0xe892...7E5. There is no flash loan, repayment leg, AMM swap, or oracle read in the exploit trace; the loss was a direct drawdown of residual pool stablecoins.

Evidence

  • Preparation transaction 0x0adf9953c4e2506ffd4526ceee962a9bb61c573eaef60f669605cca68d0ef5aa, block 86725277 at 2026-05-11 14:15:43 UTC, deployed 0x44D4...22A3 and emitted CreditInitiated(address,uint256,uint256,uint256,uint256,bool) (0x606a044e) from all three pools with final preApproved=false.
  • Immediately after the preparation transaction, creditRecordMapping(0x44D4...22A3) at block 86725277 returned (0, 0, 0, 0, 0, 0, 10, 1) on all three pools; state 1 is Requested.
  • Activation transaction 0x7126ae1d8e8d1e0c0f1c598de16a035cf309d6cc556e73edc2847de2b5777e5e, block 86725372 at 2026-05-11 14:18:29 UTC, emitted BillRefreshed(address,uint256,address) (0x5e06f3c1) from all three pools for borrower 0x44D4...22A3, due date 1778595509, and by=0xef8a13797b009228f6e4a25112ea114b7ba6e1b2.
  • Before the exploit, creditRecordMapping(0x44D4...22A3) at block 86725403 returned state 3 (GoodStanding), due date 1778595509, zero principal, and remainingPeriods=9 on all three pools; no CreditApproved (0x41119754) logs were found between the request and exploit blocks.
  • Pre-exploit creditRecordStaticMapping(0x44D4...22A3) returned credit limits of 10,000,000 USDC for 0x3EBc...6FcE, 60,000 USDC.e for 0x9553...00a7, and 500,000 USDC.e for 0xe892...7E5, all above the exploited drawdown amounts.
  • Exploit receipt logs 0x27e, 0x280, and 0x282 are ERC-20 Transfer(address,address,uint256) events moving funds from the three pool proxies to 0x44D4...22A3; logs 0x284 and 0x285 sweep those balances to 0x13B44...0DaF.
  • Exploit receipt logs 0x27f, 0x281, and 0x283 are DrawdownMade(address,uint256,uint256) (0x9746c659) events from each pool with borrower 0x44D4...22A3 and equal gross/net drawdown amounts, confirming no fee deduction in the exploited calls.

文章来源: https://www.darknavy.org/web3/exploits/huma-v1-deprecated-pools/
如有侵权请联系:admin#unsafe.sh