Calculated from recorded token losses using historical USD prices at the incident time.
0xd4bbf439d3eab5155ca7c0537e583088fb4cfce8BSC0x2b4f87d9d0a32a04b2d045fd5927cb57bedb076eBSC0xb43410a271c71f2fda2d1ed9c1165e924be159d9BSCOn BNB Smart Chain, an unprivileged attacker exploited VSafeVaultWBNB by depositing during a same-transaction markdown of an external Alpaca vault price input and then redeeming after that markdown recovered. In transaction 0xa00def91954ba9f1a1320ef582420d41ca886d417d996362bf3ac3fe2bfb9006 at block 7223030, the attacker used Alpaca's public work() borrow path to temporarily reduce Alpaca vault totalToken(), which immediately reduced the value that VSafeVaultWBNB used to price new shares. That let the attacker mint 399202185555517193534 VSafe shares for a 273813109335418386629 wei WBNB deposit, even though the pre-attack vault state implied a fair mint of only 258908614066732413743 shares. In follow-up transaction 0xa2351f51fb9ea0a0a1b50336a549ac688be6d5eb375284a2de3e5e8381e64f4a at block 7223033, the attacker redeemed those inflated shares for 421468915706717659510 wei WBNB.
The root cause is an attack-class accounting flaw in the VSafe and Alpaca integration. VSafeVaultWBNB prices deposits from balance(), and that balance depends on VSafeVaultController.balanceOf(), which in turn trusts StrategyAlpacaWBNB.balanceOfPool(). values its Alpaca vault shares using the current Alpaca vault , which is derived from mutable state. Because Alpaca's public path can temporarily reduce during an in-flight borrow and restore it before transaction end, the attacker can mint against a transient markdown and later redeem against the restored value.
StrategyAlpacaWBNB.balanceOfPool()pricePerFullSharetotalToken()/totalSupply()work()totalToken()VSafeVaultWBNB is a WBNB-denominated vault that issues ERC20 shares to depositors. Its economic pool value is not just the WBNB held directly in the vault. Instead, balance() adds on-vault WBNB to the controller-reported balances of external strategies:
function balance() public view override returns (uint256 _balance) {
_balance = basedToken.balanceOf(address(this)).add(IController(controller).balanceOf()).sub(pendingCompound());
}
The controller aggregates strategy marks dynamically:
function balanceOf() public view override returns (uint256 _totalBal) {
for (uint256 _sid = 0; _sid < strategyLength; _sid++) {
_totalBal = _totalBal.add(IStrategy(strategies[_sid].strategy).balanceOf());
}
}
One of those strategies is StrategyAlpacaWBNB at 0xb43410a271c71f2fda2d1ed9c1165e924be159d9. It values its position using the current Alpaca vault share price:
function balanceOfPool() public view override returns (uint256) {
uint256 pricePerFullShare = getAlpacaVaultPricePerFullShare();
(uint256 vaultBalance, , , ) = IAlpacaFairLaunch(alpacaFarm).userInfo(poolFarmId, address(this));
vaultBalance = vaultBalance.add(IERC20(alpacaVault).balanceOf(address(this)));
return vaultBalance.mul(pricePerFullShare).div(1e18);
}
The relevant Alpaca vault is 0xd7d069493685a581d27824fc46eda46b7efc0063. Its totalToken() is computed from current on-vault WBNB plus outstanding debt:
function totalToken() public view override returns (uint256) {
return IERC20(token).balanceOf(address(this)).add(vaultDebtVal).sub(reservePool);
}
That same Alpaca vault exposes a public work() entrypoint:
function work(uint256 id, address worker, uint256 principalAmount, uint256 loan, uint256 maxReturn, bytes calldata data)
external payable onlyEOA transferTokenToVault(principalAmount) accrue(principalAmount) nonReentrant
Inside work(), the vault transfers principalAmount + loan out to the worker and only later accounts for what comes back. This means IERC20(token).balanceOf(address(this)) and therefore totalToken() can be materially lower during the in-flight borrow than after repayment. Because VSafe reads the strategy mark live, that temporary Alpaca markdown becomes a temporary VSafe deposit-pricing markdown.
The vulnerability is that VSafeVaultWBNB mints shares against a manipulable intra-transaction mark rather than a manipulation-resistant pool value. The decisive mint-time operation is uint256 _pool = balance(); inside depositFor, followed by _shares = (_amount.mul(totalSupply())).div(_pool); inside _deposit. If _pool is temporarily reduced, identical deposits mint more shares.
That is exactly what the attacker induced. The public Alpaca work() path temporarily lowered Alpaca vault totalToken() while a large borrow was open. StrategyAlpacaWBNB.balanceOfPool() converted that lower totalToken() into a lower pricePerFullShare, which lowered VSafeVaultController.balanceOf(), which lowered VSafeVaultWBNB.balance() at the instant depositFor snapped _pool. The share calculation then over-minted the attacker. Once the borrow was repaid and Alpaca totalToken() recovered, those inflated VSafe shares could be redeemed against the restored pool value.
The violated invariant is straightforward: a deposit of amount WBNB should mint approximately amount * totalSupply / economic_pool_value shares, and economic_pool_value should not be reducible by reversible same-transaction accounting noise. The code-level breakpoint is the use of live balance() in depositFor and _deposit while that balance depends on an external vault mark that is mutable within the caller's own transaction.
The vulnerable mint path is implemented in VSafeVaultWBNB:
function depositFor(
address _account,
address _to,
uint256 _amount,
uint256 _min_mint_amount
) public override checkContract(_account) _non_reentrant_ returns (uint256 _mint_amount) {
if (controller != address(0)) {
IController(controller).beforeDeposit();
}
uint256 _pool = balance();
require(totalDepositCap == 0 || _pool <= totalDepositCap, ">totalDepositCap");
_mint_amount = _deposit(_account, _to, _pool, _amount);
require(_mint_amount >= _min_mint_amount, "slippage");
}
function _deposit(
address _account,
address _mintTo,
uint256 _pool,
uint256 _amount
) internal returns (uint256 _shares) {
basedToken.safeTransferFrom(_account, address(this), _amount);
earn();
uint256 _after = balance();
_amount = _after.sub(_pool);
if (totalSupply() == 0) {
_shares = _amount;
} else {
_shares = (_amount.mul(totalSupply())).div(_pool);
}
_mint(_mintTo, _shares);
}
At pre-state block 7223029, the collected evidence and the validator's independent fork run agree on the key baseline numbers: Alpaca vault totalToken() was 805330636385668305893420, VSafeVaultWBNB.balance() was 87301417363680380302713, and VSafeVaultWBNB.totalSupply() was 82549330930694381432886. On that baseline, a 273813109335418386629 wei deposit implies a fair mint of:
273813109335418386629 * 82549330930694381432886 / 87301417363680380302713
= 258908614066732413743 shares
The seed trace for transaction 0xa00def... shows the attacker opening a large Alpaca borrow through work(). While the borrow is open, the trace records Alpaca vault totalToken() returning 406037424823916094392996, roughly half of the pre-attack value. The same trace then records VSafeVaultWBNB.balance() returning 56620654369411798543831 at mint time. Immediately afterward, the trace emits the VSafe share mint:
emit Transfer(from: 0x0000000000000000000000000000000000000000,
to: 0xCB36b1ee0Af68Dce5578a487fF2Da81282512233,
value: 399202185555517193534)
That is an over-mint of 140293571488784779791 shares relative to the pre-attack fair mint. The seed balance-diff artifact independently confirms that attacker EOA 0xcb36... went from zero to 399202185555517193534 VSafe shares in the same transaction.
This incident is an ACT opportunity. The adversary needed only public, permissionless components:
work() path at 0xd7d069493685a581d27824fc46eda46b7efc0063.0x7af938f0efdd98dc513109f6a7e85106d26e16c4 and 0xe38ebfe8f314dcad61d5adcb29c1a26f41bed0be) used to route the borrow.0xd4bbf439d3eab5155ca7c0537e583088fb4cfce8.No privileged role, protocol-owned key, or attacker-specific on-chain artifact was required for exploitability. The public pre-state before block 7223030 already contained the necessary VSafe strategy exposure to the Alpaca vault and the necessary public borrow/deposit interfaces.
The attack proceeded in two adversary-crafted transactions:
0xa00def91954ba9f1a1320ef582420d41ca886d417d996362bf3ac3fe2bfb9006 at block 7223030.
The attacker EOA 0xcb36... called Alpaca work() with the public worker 0x7af938... and strategy proxy 0xe38ebf.... During that call, Alpaca's in-flight borrow reduced totalToken(), which in turn reduced StrategyAlpacaWBNB.balanceOfPool(), VSafeVaultController.balanceOf(), and finally VSafeVaultWBNB.balance(). The attacker then deposited 273813109335418386629 wei WBNB into VSafe while the markdown was active and received 399202185555517193534 shares.
0xa2351f51fb9ea0a0a1b50336a549ac688be6d5eb375284a2de3e5e8381e64f4a at block 7223033.
After the Alpaca borrow state had been restored, the attacker redeemed the inflated VSafe share position. The balance-diff artifact for this transaction shows the attacker share balance going from 399202185555517193534 to 0.
The technical breakpoint is the first depositFor share calculation under the manipulated _pool. The economic breakpoint is the follow-up redemption, where the inflated share balance is converted back into WBNB after the external PPS recovers.
The adversary cluster consists of EOA 0xcb36b1ee0af68dce5578a487ff2da81282512233 and helper contract 0x4269e4090ff9dfc99d8846eb0d42e67f01c3ac8b. The EOA funded and invoked the public transaction path, while the helper contract coordinated the temporary market manipulation and the vault interactions.
The attacker first deployed the helper contract in transaction 0xbebb65ffc99ad062ebf694174f50b5775d309b46d0999443a72b79977ee4ca79 at block 6813131. That helper was later used in the seed exploit transaction to coordinate the public worker path and the VSafe deposit. The collected decompilation and disassembly support the conclusion that the helper was attacker-controlled orchestration rather than a privileged protocol component.
In the seed transaction, the attacker used Alpaca's work() flow to open a large borrow and transiently reduce Alpaca vault totalToken(). Because StrategyAlpacaWBNB marked its holdings to the current Alpaca PPS, this markdown propagated directly into VSafe deposit pricing. The attacker deposited into VSafe precisely during that window. The same transaction repaid the Alpaca borrow before completion, so the markdown existed only long enough to distort VSafe's share mint formula.
In the follow-up redemption transaction, the attacker withdrew the newly inflated VSafe share balance after the external PPS had normalized. The validator's independent fork run reproduced the same sequence with a fresh attacker address and locally deployed helper, confirming that the flow is permissionless and not dependent on real attacker identities.
The direct loss is borne by VSafeVaultWBNB depositors, because the vault issued too many shares for the attacker's deposit and later honored those shares against the restored pool value. The analysis attributes total loss of 147655806371299272881 wei WBNB, which is 147.655806371299272881 WBNB with 18 decimals.
The root cause file's profit accounting uses the attacker's exploit-specific position economics: 273813109335418386629 wei WBNB entered the underpriced VSafe mint, and 421468915706717659510 wei WBNB came back on redemption. After subtracting gas in the reference-asset accounting, the reported net reference-asset delta is 147646596996299272881 wei. The important invariant for impact is that value moved from the VSafe depositor pool to the attacker, not that Alpaca was left insolvent. Alpaca's temporary borrow was repaid within the same transaction; the lasting loss sits on VSafe's side of the integration.
0xa00def91954ba9f1a1320ef582420d41ca886d417d996362bf3ac3fe2bfb9006 at block 7223030, including metadata, balance diff, and full trace.0xa2351f51fb9ea0a0a1b50336a549ac688be6d5eb375284a2de3e5e8381e64f4a at block 7223033, including balance diff and full trace.VSafeVaultWBNB source at 0xd4bbf439d3eab5155ca7c0537e583088fb4cfce8, especially balance(), depositFor(), _deposit(), and withdraw().VSafeVaultController source at 0x2b4f87d9d0a32a04b2d045fd5927cb57bedb076e, especially balanceOf() and getBestStrategy().StrategyAlpacaWBNB source at 0xb43410a271c71f2fda2d1ed9c1165e924be159d9, especially balanceOfPool().0x8db1d72467dc02a0974e52d131736438f3c34d61, especially totalToken() and work().0x4269e4090ff9dfc99d8846eb0d42e67f01c3ac8b, including decompilation and disassembly artifacts.