During the past week (2026/04/20 - 2026/04/26), BlockSec detected and analyzed eight attack incidents, with total estimated losses of ~$7.04M. The table below summarizes these incidents, and detailed analyses for each case are provided in the following subsections.
| Date | Incident | Type | Estimated Loss |
|---|---|---|---|
| 2026/04/19* | Custom Rebalancer Contract | Arbitrary Call | ~$64K |
| 2026/04/20 | REVLoans (Juicebox) | Improper Validation | ~$50.7K |
| 2026/04/22 | Volo Vault / Navi | Key Compromise | ~$3.5M |
| 2026/04/22 | Kipseli Router | Improper Validation | ~$72.35K |
| 2026/04/23 | GiddyDefi | Incomplete Signature Validation | ~$1.3M |
| 2026/04/25 | Purrlend | Key Compromise | ~$1.5M |
| 2026/04/26 | SingularityFinance | Oracle Misconfiguration | ~$413K |
| 2026/04/26 | Scallop | Accounting Flaw | ~$142.7K |
*The Custom Rebalancer Contract incident was not covered in last week’s report and is included here for completeness.
Weekly Highlight: GiddyDefi
The attacker did not crack the signature, did not use a flash loan, and did not manipulate any price. They replayed a legitimate signature with its unsigned fields swapped for their own contract. "Use your own signature against you" is the cleanest demonstration of how partial EIP-712 coverage turns a valid signature into a generic permit.
On April 23, 2026, GiddyVaultV3 on Ethereum was exploited for approximately $1.3M. The signature scheme covered only SwapInfo.data, leaving aggregator, fromToken, toToken, and amount outside the EIP-712 hash, so a valid signature could be replayed with those fields tampered. The attacker pointed aggregator at a malicious contract and fromToken at the strategy's LP token, draining approximately $1.3M.
Background
GiddyVaultV3 (0x5f0a...4318) is a yield farming vault contract where users deposit and withdraw funds via deposit() and withdraw(). Every operation must carry a VaultAuth authorization struct signed by the backend, which includes an EIP-712 signature and a SwapInfo[ ] array describing the token swap routes. When executing a swap, the contract calls GiddyLibraryV3.executeSwap(), which performs a forceApprove on swap.fromToken granting allowance to swap.aggregator, then executes the swap via aggregator.call(swap.data). The strategy contract subsequently manages the funds according to its configured strategy.
EIP-712 is a standard for signing structured off-chain data: the protocol that consumes the signature reconstructs the same struct on-chain, hashes it under an agreed domain separator, and recovers the signer's address. The security of any EIP-712 flow therefore depends on the on-chain hash covering every field whose value affects execution. In Giddy's design, the backend signs a VaultAuth that contains both the user's intent and the routing instructions for any required swaps, and _validateAuthorization() reconstructs that struct to verify the signature before the strategy is allowed to move funds.
Vulnerability Analysis
The vulnerability lies in the _validateAuthorization() function of GiddyVaultV3. When constructing the signed payload, only the data field of each SwapInfo is hashed; aggregator, fromToken, toToken, and amount are all excluded from the signature. This means anyone in possession of a valid signature can freely replace the remaining fields of SwapInfo while still passing signature verification.
Each excluded field is a separate lever the signature leaves free: aggregator becomes both the spender and the call target via forceApprove and aggregator.call(swap.data); fromToken selects which strategy asset is approved; amount sets the allowance ceiling; toToken only feeds a returnAmount > 0 check. The signed data does not constrain any of these because none of these targets are referenced inside it.

Attack Analysis
The following analysis is based on the transaction 0x5edb66...5482e5.
-
Step 1: The attacker obtained a legitimately backend-authorized
VaultAuthsignature from on-chain, keeping thedatafield intact. Because every priordeposit()orwithdraw()call broadcasts the fullVaultAuthpayload on-chain, any historical transaction was a free source of a reusable signature; the attacker only needed one whosedatafield was suitable for the intended swap call. -
Step 2: Using the obtained signature, the attacker kept the original
signature,nonce, anddataunchanged while tampering with the remaining fields.fromTokenwas set to the LP Token held by the strategy contract (a real asset), so theforceApprovegranted allowance over a token the protocol actually held.aggregatorwas replaced with the attacker's malicious contract, so both the approval and the subsequentaggregator.call()were directed at attacker-owned code. Since these fields were outside the signature validation scope,_validateAuthorization()accepted the tampered struct unchanged. To bypass the finalrequire(returnAmount > 0, "SWAP_NO_TOKENS_RECEIVED")check, the malicious aggregator implemented a mint function that minted fake tokens back to the protocol, satisfying the check without performing any real swap.


- Step 3: Since the malicious aggregator had been granted approval in step 2, the attacker called
transferFromto move the vault's LP tokens directly into the malicious aggregator, completing the theft. This step lay outside the protocol's protected execution path entirely; by the timeexecuteSwap()returned, the allowance had already been written and the post-call balance check had already passed, so the protocol had no further opportunity to intervene.

Conclusion
The root cause of this attack was incomplete EIP-712 signature coverage. The core fields of SwapInfo that directly govern fund flow were left unprotected, allowing the attacker to substitute the swap route and aggregator address while presenting a valid signature. Developers integrating external aggregators should:
-
Ensure EIP-712 signatures cover all fields that affect execution outcomes, including
aggregator,fromToken,toToken, andamount. -
Enforce an aggregator whitelist to prevent calls to unaudited external contracts.
-
Restrict
toTokento expected base tokens to prevent fake tokens from bypassing balance checks.
More broadly, EIP-712 in any approval-then-call architecture must hash every field that influences the resulting on-chain state, not just the user-facing intent. Whenever a backend signature is the sole gatekeeper between user-supplied parameters and a privileged contract action, every parameter that flows into that action (call target, asset, amount, recipient) must sit inside the signed struct. Treating data as a proxy for the call's identity is a category error: the call's identity is the tuple of all its parameters, and any parameter left outside the signature is, by definition, controlled by whoever submits the transaction.
Best Security Auditor for Web3
Validate design, code, and business logic before launch
More Incidents This Week
Custom Rebalancer Contract
On April 19, 2026, an sAVAX rebalancer contract on Avalanche was exploited to draw approximately $64K (~7,000 WAVAX) from a user's Aave V3 credit delegation. A public function ran an arbitrary target.call(data) while still holding the user's delegation, so the attacker could invoke Aave's borrow() with onBehalfOf set to the victim. A whitehat bot frontran the exploit and recovered the funds before any withdrawal.
Background
The rebalancer contract (0x7a7b...a8c9) exposes a function b2a13230() designed to rebalance a user's leveraged position on Aave. The function operates on behalf of the user via Aave V3 credit delegation: the user grants the rebalancer permission to borrow on their behalf, and the rebalancer combines those borrowings with user-supplied funds to adjust the position (e.g., a borrow + supply workflow).
Vulnerability Analysis
The root cause is that b2a13230() includes a step target.call(data) whose target and calldata are both caller-controlled. This call runs while the contract is still operating under the user's Aave V3 credit delegation, so any logic invoked during that step inherits the user's borrowing power. There is no allowlist of permitted targets and no shape constraint on the calldata, so the call can invoke any contract method, including Aave's borrow() with onBehalfOf set to the user.

Attack Analysis
The following analysis is based on the transaction: 0xaaa1b2...35001b.
-
Step 1: The attacker performed a flashloan for an amount of
sAVAXandUSDC. Then supplied the borrowedUSDCinto Aave V3 via the rebalancer contract to establish sufficient collateral for borrowing. Meanwhile, the borrowedsAVAXwas transferred directly to the rebalancer contract to prepare for the subsequent supply step after borrowing. -
Step 2: The attacker invoked the function
b2a13230(). The function first performed a normal borrow operation, then reached the arbitrary call section. At this point, the attacker crafted the call to directly invoke Aave V3'sborrow()function withonBehalfOfset to the victim address. Since the victim had granted credit delegation to the rebalancer contract, the borrow succeeded. The borrowedWAVAXwas transferred into the rebalancer contract.

- Step 3: The attacker invoked the function
b2a13230()again, this time using the rebalancer to borrowWAVAXon behalf of themselves. The contract then used the previously borrowedWAVAX(originating from the victim's position) to supply into the attacker's position and repay, allowing the attacker to extract profit.
Conclusion
The defect is the combination of an arbitrary external call inside a privileged context that holds delegated credit. Either layer alone would be safe: a constrained external call cannot misuse the delegation, and an arbitrary call without delegation cannot move the user's funds. Contracts that hold credit delegation should never expose an arbitrary external call; if such calls are necessary, targets must be pinned to an allowlist and calldata shape-checked.
REVLoans (Juicebox)
On April 20, 2026, REVLoans, a borrowing extension on Juicebox, was exploited on Ethereum for approximately $50.7K. borrowFrom() accepted a caller-supplied accounting source without verifying it was registered with the protocol; a forged 36-decimal context triggered a same-currency shortcut that mis-rescaled balances by 1e18. Two transactions, one to seed the inflated accounting entry and one to borrow against the legitimate pool at the inflated share price, drained 21.77 ETH.
Background
Juicebox is a hybrid fundraising and lending protocol on Ethereum. Each project has its own ERC20 share token (referred to here as REV) and a treasury split across one or more terminals, where a terminal is the contract that physically custodies a subset of the project's assets and acts as the user-facing entry/exit point. One project can have multiple terminals registered against it in JBDirectory, and every (terminal, project, token) triple carries a JBAccountingContext declaring the (decimals, currency) used for that token's bookkeeping inside that terminal. REV is therefore a claim on the union of surpluses across all terminals of the project, not a claim against any single terminal.
A user can deposit an asset into a terminal in exchange for freshly minted REV, or redeem REV at a terminal for a proportional share of its surplus (modulo a configurable cash-out tax that leaves some value behind for remaining holders). REVLoans (0x2db6...1846), a separate contract layered on top, adds a borrow facility: a user burns REV as collateral and draws a loan against one of the project's terminals, with the loan repayable later in exchange for re-minting the collateral. The borrow amount is priced with the exact same math as a redemption, so a borrow is economically equivalent to cashing out the same collateral.
REV's share price is (totalSurplus + totalBorrowed) / (REV.totalSupply + totalCollateral). Including totalBorrowed in the numerator keeps borrow/repay price-neutral; it also means an inflated totalBorrowed directly raises the share price and lets a small collateral cash out disproportionately.
Vulnerability Analysis
The root cause is unverified input on the source parameter. borrowFrom() accepts a caller-supplied REVLoanSource source (a struct with fields .terminal and .token) without checking that this pair is registered for the given revnetId. Both fields flow directly into the cash-out math, so the accounting context returned by source.terminal is fully caller-controlled. When that context's currency field matches the destination terminal's, the protocol takes a same-currency shortcut, skips the price oracle, and treats the supplied decimals and balance figures as authoritative.

The unvalidated source is then written into _loanSourcesOf[revnetId] and totalBorrowedFrom[revnetId][source.terminal][source.token] by _addTo(), which likewise performs no registration check.

Once (source, revnetId) is in the bookkeeping, _borrowableAmountFrom() is the function that translates a borrow request into a payable amount. It builds surplus = totalSurplus + totalBorrowed from _totalBorrowedFrom(), then passes that surplus into JBCashOuts.cashOutFrom() together with the caller's collateral count and the share supply.

The decimal bug lives one level deeper, in _totalBorrowedFrom(). It iterates _loanSourcesOf and folds each entry via mulDiv(tokensLoaned, 10**decimals, pricePerUnit). On the same-currency path, pricePerUnit = 10**decimals (the target's 18-decimal precision), so the formula reduces to tokensLoaned unchanged, and a balance stored under 36-decimal accounting lands in the 18-decimal ETH sum 1e18 times too large.

The amplification happens in cashOutFrom(). base = mulDiv(surplus, cashOutCount, totalSupply): with surplus dominated by inflated totalBorrowed, even a tiny cashOutCount (collateral) maps to a disproportionately large payout.

Attack Analysis
The attack uses two transactions. The first pollutes REVLoans's bookkeeping: 0xc46cb7...dead1f. The second drains the pool against a legitimate terminal: 0x9adbd6...a8f938.
- Step 1: The attacker called
borrowFrom()with bothterminalandtokenin the loan source pointing to a fake contract, posting a small amount ofREVas collateral. REVLoans did not check whether the supplied terminal is registered for the revnet, nor whether the token is recognized by it.

- Step 2: REVLoans queried the fake terminal for an accounting context, which returned a forged
(decimals=36, currency=ETH-code(61166)). Because the source and target currencies matched, REVLoans took a same-currency shortcut and skipped the price oracle, then ran the cash-out math over the legitimate terminals' realETHsurpluses re-expressed in the attacker's 36-decimal target unit, inflating the figure by 1e18.

- Step 3: REVLoans registered
(fake terminal, fake token)into_loanSourcesOfand wrote the inflated figure intototalBorrowedFrom. The fake terminal "paid out" by simply confirming receipt; no realETHmoved. The first transaction ended withtotalBorrowedmanipulated upward and only the smallREVcollateral burned.

- Step 4: The attacker called
borrowFrom()again, this time passing the legitimateETHterminal as the loan source and a tinyREVcollateral. The cash-out math ran in real 18-decimalETHunits.

- Step 5: While computing
totalBorrowed, REVLoans iterated_loanSourcesOfand hit the entry from step 3. Because that entry'scurrencystill matchedETH, the same-currency shortcut fired again and the 36-decimal stored balance was folded into the 18-decimalETHsum 1e18 times too large.totalBorrowedwas now dominated by fake debt and the share-price numerator was massively inflated.

- Step 6: The cash-out math returned a borrow amount sized to the inflated numerator, which the attacker had pre-tuned to sit just below the legitimate terminal's real surplus. The legitimate terminal paid it out, draining nearly the entire pool to the attacker.

Conclusion
The root cause is two compounding gaps: (terminal, token) pairs are accepted without checking revnet registration, and the same-currency shortcut folds source balances into the destination sum without renormalizing for decimal differences. Either gap alone would be less dangerous; together they let a caller inject an arbitrary totalBorrowedFrom entry and cash it out at face value. Mitigation: validate (terminal, token) against the revnet's registered terminals, and renormalize balances by the source's stored decimal scale before folding.
Volo Vault
On April 22, 2026, Volo, a yield vault on Sui that earns lending yield by routing user deposits into the Navi lending protocol, lost approximately $3.5M after the operator private key was leaked. The vault contract had no code-level bug; the attacker simply ran the legitimate operator path with stolen credentials and drained Volo's Navi deposits.
Background
Volo is the user-facing vault (0xcd86...27fefa); Navi is the underlying lending protocol. The vault holds a Navi AccountCap (a Sui capability object that authorizes withdrawals from Volo's Navi account) and delegates strategy moves to an operator role. To deposit or withdraw on Navi, the operator calls start_op_with_bag_v2() to lift the AccountCap from the vault into a temporary bag, then deposit_with_account_cap() / withdraw_with_account_cap_v2() use that cap to move funds.
Vulnerability Analysis
The root cause is an operational/key-custody failure rather than a contract-level vulnerability. The Volo strategy path delegates withdrawal authority to whoever holds the operator private key: start_op_with_bag_v2() performs only two checks (assert_operator_not_freezed(operation, cap) and assert_single_vault_operator_paired(operation, vault.vault_id(), cap)), both of which only verify that the supplied capability is the registered operator. withdraw_with_account_cap_v2() then accepts any caller that can present the lifted AccountCap. Anyone in possession of the operator private key can therefore execute the same path that legitimate operations use, indistinguishably.

Attack Analysis
The following analysis is based on the transaction AQw9wM...3RUS.
- Step 1: The attacker called
start_op_with_bag_v2on@volosui/volo-vault::operationwith the leaked operator key, lifting the NaviAccountCapinto a temporary bag.

-
Step 2: The attacker used
bag::removeto extract theAccountCapfrom the temporary bag. -
Step 3: The attacker called
withdraw_with_account_cap_v2on@navi-protocol/lending::incentive_v3with the extractedAccountCap, pulling Volo's deposits off Navi.

- Step 4: The attacker used
bag::addto put theAccountCapback, closed the operation, and transferred the funds out.
Conclusion
The defect is structural: one operator key, full withdrawal authority, no second check. Three changes reduce the damage from a key compromise. Splitting the operator role into a multisig or threshold scheme means a leaked key cannot authorize a withdrawal on its own. Adding a time-lock on outgoing withdrawals gives abnormal calls a contestable window before settlement. Scoping the operator's powers to deposit-and-rebalance only, with user-facing withdrawals routed through a separate path, prevents the operator role from reaching user funds at all.
Kipseli Router
On April 22, 2026, the Kipseli Router on Base was exploited for approximately $72.35K. The router uses a quote returned by an external USDC-only quoter as the raw output-token transfer amount, without verifying that the output token equals the quote token. An attacker swapped 0.04 WETH for cbBTC on a path the quoter does not actually support, receiving the quoter's USDC-scaled return value (92,610,395) as raw cbBTC units (≈0.926 cbBTC).
Background
Kipseli Router (0x579f...9a07) is a swap execution contract backed by an external quoting system. The contract is not open-sourced; the analysis below is based on its decompiled bytecode, which is why function names appear as 4-byte selectors (0xcce096f3(), 0x592(), 0xd88()). Rather than computing swap prices directly from on-chain AMM pools, it queries the quoter for an output amount (amountOut) and then executes the token transfer based on that value. In normal operation, the user sends tokenIn to the protocol wallet, and the router pulls tokenOut from the same wallet and forwards it to the recipient. The protocol is configured with a single QUOTE_TOKEN, and the quoting logic is denominated in USDC using 6-decimal accounting; the system is only designed to support USDC-denominated quotes.
Vulnerability Analysis
The defect spans two layers that compound. On the router side, function 0xcce096f3() retrieves a quote v0 via the quoter function 0x592() and passes it unchanged into 0xd88() as tokenOut.transferFrom(_wallet, receiver, v0). The router never checks that tokenOut equals the protocol's QUOTE_TOKEN, so a USDC-scaled value (6-decimal precision) is transferred as if it were a cbBTC quantity (8-decimal precision). On the quoter side, the underlying PropAMM AMM is designed exclusively for token-to-USDC pairs but accepts unsupported routing paths (WETH → cbBTC) without reverting, silently ignoring tokenIn and returning a USDC-scaled value as if the swap were valid.

Attack Analysis
The following analysis is based on the transaction 0x96edee...3db3bb.
- Step 1: The attacker called the router with
tokenIn=WETHandtokenOut=cbBTC. The underlying AMM did not support this path but did not revert, and the quoter0x592()returned aUSDC-scaled value of 92,610,395 (≈92.61USDC).

- Step 2: The router used that value directly as the
cbBTCtransfer amount. 0.04WETH(≈$95) flowed in viatransferFrom; 92,610,395 rawcbBTCunits (≈0.926cbBTC, ≈$72.35K) flowed out from the protocol wallet to the attacker.

Conclusion
The exploit lands because two assumptions are unchecked on either side of the quoter call. The quoter assumes its output is consumed in its own USDC 6-decimal frame; the router assumes whatever the quoter returns is denominated in the requested tokenOut. Either fix removes the bug:
-
On the router: assert
tokenOut == QUOTE_TOKEN, or convert theUSDC-scaled quote intotokenOutunits via an oracle before transfer. -
On the quoter: revert on routing paths whose tokens are not registered for the supported pair set, instead of silently returning a
USDC-scaled fallback.
Purrlend
On April 25, 2026, Purrlend, a lending protocol on HyperLiquid and MegaETH, lost approximately $1.5M after a private key compromise. The attacker took over the bridge role and minted unbacked pTokens (Purrlend's Aave-like receipt tokens), then used those pTokens as collateral to borrow real assets out of the pool.
Background
Purrlend (0x81d5...a702) is a lending protocol with an Aave-like accounting model. When users supply assets into the protocol, they receive corresponding pTokens, similar to Aave's aTokens, which represent their supplied position and can be used as collateral for borrowing other assets.
The protocol also includes privileged roles, including pool admin, risk admin, and bridge. The bridge role is intended for cross-chain accounting: it can mint pTokens to mirror deposits that happened on a counterpart chain. The other admin roles modify risk parameters and configure borrowable assets.
Vulnerability Analysis
The immediate trigger was a privileged key compromise: the attacker obtained the keys controlling Purrlend's admin and bridge roles. A contract-level design flaw amplified the leak: the bridge role's pToken mint path is not anchored to any verifiable proof of cross-chain escrow. The function lets a caller with the bridge role issue pTokens to any address, in any amount, without checking that a corresponding deposit occurred on the source chain. Anywhere else in the protocol, pTokens are treated as valid collateral, and the borrow path does not re-check backing at borrow time. So an unauthorized bridge-role mint translates directly into borrowing power, with no second gate between minting and asset withdrawal.
Attack Analysis
The following analysis is based on the transaction 0xb96cff...dbbf24 on MegaETH.
- Step 1: The attacker, holding compromised privileged keys, used a
MultiSendCallOnlybatch viaGnosisSafeProxyto set themselves aspool admin,risk admin,bridge, andemergency adminthroughACLManager, then enabledWETHas a borrowable asset and set itsBorrowCapto 200.

-
Step 2: Acting as the bridge, the attacker minted a large amount of
pTokensto their own address. The bridge mint path performed no verification of cross-chain escrow, so the newpTokenshad no underlying assets backing them. -
Step 3: The attacker used the unbacked
pTokensas collateral. Because the borrow path treats anypTokenbalance as a valid supply position without re-checking backing, the collateral check passed andWETHwas borrowed out of the pool.
Conclusion
This was a private key compromise amplified by a contract-level design flaw. The leaked keys gave the attacker only the bridge role's intended authority, but that authority included unconstrained pToken minting, which translates directly into borrowable collateral. Each layer can be tightened independently. On the operational layer, split the bridge role into a multisig or threshold scheme so a single key leak cannot exercise it. On the contract layer, require the bridge mint to carry a verifiable proof of escrow (e.g., a message commitment from a trusted cross-chain verifier) and revert when no proof is supplied. Verifying the proof at mint time is the more durable fix because it removes the reliance on key custody altogether.
SingularityFinance
On April 26, 2026, the dynBaseUSDCv3 vault of SingularityFinance on Base lost approximately $413K. The vault was configured with an invalid Uniswap V3 fee tier (42, which does not exist in V3), so every non-USDC asset's price oracle resolved to a non-existent pool. The pricing function returned 0 silently instead of reverting, the vault valued its non-USDC reserves at zero, and an attacker minted nearly the entire share supply by depositing a tiny amount of USDC, then redeemed for the actual underlying assets.
Background
The dynBaseUSDCv3 vault (0x67b9...4dcd) holds multiple yield-bearing tokens and prices non-USDC reserves through Uniswap V3. The pricing helper getPrice(base, fee, quote, amount) resolves the (base, quote, fee) tuple to a Uniswap V3 pool via the factory, then reads the TWAP from that pool. The vault's totalAssets() aggregates the priced reserves; share minting and redemption ratios are derived from this total.
Vulnerability Analysis
The defect is in the early-return branch of getPrice(). When IUniswapV3Factory.getPool(base, quote, fee) returns address(0) (no pool exists for the supplied fee tier), the function falls through and returns its zero-initialized price variable instead of reverting. The vault was deployed with fee=42, which is not one of Uniswap V3's supported tiers (500/3000/10000), so every non-USDC token's lookup hits this branch. totalAssets() therefore sums to roughly the vault's USDC balance only, while the actual yield-bearing tokens contribute zero. Mint and redeem ratios that depend on totalAssets() are computed against this near-zero denominator.

Attack Analysis
The following analysis is based on the transaction 0x00b949...8d3732.
-
Step 1: The attacker flash-loaned approximately 100K
USDC. -
Step 2: The attacker deposited the
USDCinto the vault. BecausetotalAssets()only counted theUSDCbalance, the vault valued itself at roughly the deposit amount and the attacker received nearly 100% of the share supply. -
Step 3: The attacker redeemed the shares, which distributes the underlying reserves proportionally to share ownership. The attacker received a large fraction of every yield-bearing token the vault held.
-
Step 4: The attacker repaid the flash loan and kept the drained yield-bearing tokens as profit.
Conclusion
Two checks were missing. The deployment did not validate fee=42 against Uniswap V3's supported tiers (500/3000/10000); getPrice() returned 0 on a missing pool instead of reverting. Either fix is sufficient: validate oracle parameters at config time, or revert on getPool() == address(0). As defense-in-depth, share-mint logic should sanity-check totalAssets() against an external reference before accepting deposits.
Scallop
On April 26, 2026, Scallop's staking-rewards program on Sui lost approximately $142.7K. The function that updates a user's accrued rewards did not verify that the rewards-tracking object passed in matched the user's account, letting an attacker pull a fictitious points balance from an abandoned, long-dormant rewards-tracking object and redeem it against the legitimate rewards pool until the balance was drained.
Background
Scallop is a lending protocol on Sui. On top of its lending product, Scallop runs a spool program: users deposit a single asset into Scallop's market to receive MarketCoin<T> (the lending receipt; for SUI deposits this is MarketCoin<SUI>, the on-chain representation of "sSUI"), then stake that MarketCoin into a Spool to earn protocol points over time, which they later redeem against a paired RewardsPool for actual reward tokens. Each Spool is a Sui shared object that tracks a global per-share index; each user holds a personal SpoolAccount recording their staked balance and accrued points.
Vulnerability Analysis
The defect is in spool::user::update_points: the function does not assert account.spool_id == object::id(spool) (nor account.stake_type == spool.stake_type). Sibling entries stake, unstake, and redeem_rewards all perform that binding check at entry; only update_points skips it. Without the check, spool_account::accrue_points computes account.points += stake * (spool.index − account.index) / 1e9 against any Spool passed in, treating its index as if it were this account's own reward stream.

The path becomes exploitable because Sui never garbage-collects shared objects: an abandoned Scallop Spool whose stakes has decayed to dust keeps accruing reward share (per-period increment 1e9 * reward / stakes), so its index cumulatively increases over time and can reach arbitrarily large values. With the binding check missing, update_points can use this inflated index to write a huge points delta into any account. The polluted points then redeem 1:1 against the target spool's RewardsPool, because the account is legitimately bound to that target spool and redeem_rewards's own binding check passes.
Attack Analysis
The following analysis is based on the transaction 6WNDjC...NfVL.
-
Step 1: With 0.2
SUIas bait, the attacker mintedMarketCoin<SUI>, then callednew_spool_account+stakeagainst the target spool to create a legitimately boundSpoolAccountwithaccount.spool_id = target_spool. -
Step 2: The attacker called
update_points<MarketCoin<SUI>>(donor_spool, account, clock)withdonor_spoolset to an abandonedSpool. The donor'sindex(≈8.91e14) was written into the account aspoints:points = stake * (8.91e14 − 1.19e9) / 1e9 ≈ 1.62e14. -
Step 3: The attacker called
redeem_rewards<MarketCoin<SUI>, SUI>(target_spool, target_rp, account). The binding assertions accepted the target-bound account, the inner re-accrue early-returned, and the pollutedpointswere converted at the rewards pool's 1:1 rate up to its balance:rewards = 150,098,061,595,978rawSUI. -
Step 4: The attacker called
unstakeandredeemto recover the 0.2-SUIbait, thenTransferObjectsto move everything out.
Conclusion
The fix is to add the same assert!(account.spool_id == object::id(spool)) check at the entry of update_points that stake, unstake, and redeem_rewards already perform. As defense-in-depth, the protocol could also cap the index delta accepted by a single accrue_points call (reject deltas larger than a configured ceiling), so that even if the binding check were bypassed again in the future, no single call could credit an account with a points quantity disproportionate to its actual stake duration.
About BlockSec
BlockSec is a full-stack blockchain security and crypto compliance provider. We build products and services that help customers to perform code audit (including smart contracts, blockchain and wallets), intercept attacks in real time, analyze incidents, trace illicit funds, and meet AML/CFT obligations, across the full lifecycle of protocols and platforms.
BlockSec has published multiple blockchain security papers in prestigious conferences, reported several zero-day attacks of DeFi applications, blocked multiple hacks to rescue more than 20 million dollars, and secured billions of cryptocurrencies.
-
Official website: https://blocksec.com/
-
Official Twitter account: https://twitter.com/BlockSecTeam



