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, implementation0x57107D02C2b70e09aD77240dbDe7aD77fE91eA1c.0x95533e56f397152B0013A39586bC97309e9A00a7, implementation0x57107D02C2b70e09aD77240dbDe7aD77fE91eA1c.0xe8926aDbFADb5DA91CD56A7d5aCC31AA3FDF47E5, implementation0x2cFfaAf7885530e1C5A9684eBBe397d6f1DE48d8.
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 fromRequestedtoGoodStanding, 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
- The attacker deployed helper contract
0x44D4...22A3and used it to request credit from three deprecated Huma V1 pools. - Those
requestCredit()calls usedpreApproved=false, so the helper’s borrower records wereRequested, notApproved. - A separate activation transaction deployed
0xef8a...e1b2, which calledrefreshAccount(address)on all three pools for borrower0x44D4...22A3. refreshAccount()emittedBillRefreshedand left the helper’s borrower records inGoodStandingwith due date1778595509.- The attacker called the helper’s batch executor to invoke
drawdown()on all three pool proxies. - Each
drawdown()followed theGoodStandingreturn-drawdown branch and transferred residual USDC/USDC.e to the helper. - 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 contract0xef8a...e1b2:CREATE.0xef8a...e1b2->0x3EBc...6FcE:refreshAccount(address)(0xa3e35f36),CALL, borrower0x44D4...22A3.0x3EBc...6FcE->0x5710...A1c:refreshAccount(address)(0xa3e35f36),DELEGATECALL.
0xef8a...e1b2->0x9553...00a7:refreshAccount(address)(0xa3e35f36),CALL, borrower0x44D4...22A3.0x9553...00a7->0x5710...A1c:refreshAccount(address)(0xa3e35f36),DELEGATECALL.
0xef8a...e1b2->0xe892...7E5:refreshAccount(address)(0xa3e35f36),CALL, borrower0x44D4...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, value0.0x44D4...22A3->0x3EBc...6FcE:drawdown(uint256)(0xa079a4dd),CALL, amount82,315,571,143raw 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 USDC0x3c499...3359:transfer(address,uint256)(0xa9059cbb),CALL, to0x44D4...22A3, amount82,315.571143USDC.
0x44D4...22A3->0x9553...00a7:drawdown(uint256)(0xa079a4dd),CALL, amount17,290,759,830raw 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.e0x2791...4174:transfer(address,uint256)(0xa9059cbb),CALL, to0x44D4...22A3, amount17,290.759830USDC.e.
0x44D4...22A3->0xe892...7E5:drawdown(uint256)(0xa079a4dd),CALL, amount1,783,970,571raw 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.e0x2791...4174:transfer(address,uint256)(0xa9059cbb),CALL, to0x44D4...22A3, amount1,783.970571USDC.e.
0x44D4...22A3->0x44D4...22A3:sweepToken(address,address)(0x258836fe),CALL, native USDC to attacker EOA.- Helper calls
balanceOf(address)(0x70a08231) andtransfer(address,uint256)(0xa9059cbb) of82,315.571143USDC to0x13B44...0DaF.
- Helper calls
0x44D4...22A3->0x44D4...22A3:sweepToken(address,address)(0x258836fe),CALL, bridged USDC.e to attacker EOA.- Helper calls
balanceOf(address)(0x70a08231) andtransfer(address,uint256)(0xa9059cbb) of19,074.730401USDC.e to0x13B44...0DaF.
- Helper calls
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.571143native Polygon USDC (0x3c499c542cef5e3811e1192ce70d8cc03d5c3359) from pool0x3EBc...6FcE.19,074.730401bridged Polygon USDC.e (0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174) from pools0x9553...00a7and0xe892...7E5combined.
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, block86725277at2026-05-11 14:15:43 UTC, deployed0x44D4...22A3and emittedCreditInitiated(address,uint256,uint256,uint256,uint256,bool)(0x606a044e) from all three pools with finalpreApproved=false. - Immediately after the preparation transaction,
creditRecordMapping(0x44D4...22A3)at block86725277returned(0, 0, 0, 0, 0, 0, 10, 1)on all three pools; state1isRequested. - Activation transaction
0x7126ae1d8e8d1e0c0f1c598de16a035cf309d6cc556e73edc2847de2b5777e5e, block86725372at2026-05-11 14:18:29 UTC, emittedBillRefreshed(address,uint256,address)(0x5e06f3c1) from all three pools for borrower0x44D4...22A3, due date1778595509, andby=0xef8a13797b009228f6e4a25112ea114b7ba6e1b2. - Before the exploit,
creditRecordMapping(0x44D4...22A3)at block86725403returned state3(GoodStanding), due date1778595509, zero principal, andremainingPeriods=9on all three pools; noCreditApproved(0x41119754) logs were found between the request and exploit blocks. - Pre-exploit
creditRecordStaticMapping(0x44D4...22A3)returned credit limits of10,000,000USDC for0x3EBc...6FcE,60,000USDC.e for0x9553...00a7, and500,000USDC.e for0xe892...7E5, all above the exploited drawdown amounts. - Exploit receipt logs
0x27e,0x280, and0x282are ERC-20Transfer(address,address,uint256)events moving funds from the three pool proxies to0x44D4...22A3; logs0x284and0x285sweep those balances to0x13B44...0DaF. - Exploit receipt logs
0x27f,0x281, and0x283areDrawdownMade(address,uint256,uint256)(0x9746c659) events from each pool with borrower0x44D4...22A3and equal gross/net drawdown amounts, confirming no fee deduction in the exploited calls.