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.
