2026-04-28 · Loss: 398,655.47 USDC · Missing ERC4626 Allowance Check
On Ethereum mainnet, transaction 0x6b04344d5627df59d3bc645e7454f4605a90272852a91e435e370376643353b3 succeeded at block 24979316 on 2026-04-28T15:00:11Z. The attacker EOA 0x7137804200a073f616d92e87007f1f100100b56a called a predeployed helper contract 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17, which invoked withdraw(uint256,address,address) eight times against the YieldCore RWAVault proxy 0xb9c7c84a1aa0dd40b5b38aae815ad0cdd2e5f88a.
The exploit worked because YieldCore overrides ERC4626 withdraw() and redeem() but removes the standard _spendAllowance(owner, caller, shares) authorization check when msg.sender != owner. As a result, any caller can specify an arbitrary depositor as owner and direct the withdrawn assets to an arbitrary receiver. In this transaction, the attacker used themselves as receiver and drained 392,763.999994 USDC of principal from eight depositors. The vault also paid 5,891.472458 USDC of accrued interest to those same depositors during _claimRemainingInterest(owner), bringing the vault’s total USDC outflow to 398,655.472452.
0xb9c7c84a1aa0dd40b5b38aae815ad0cdd2e5f88a0x317aa10528ff675ef4c358ea6a5b7b5494325733access_controlOpenZeppelin ERC4626 routes withdraw() through _withdraw(_msgSender(), receiver, owner, assets, shares), and _withdraw() spends allowance whenever the caller is not the owner:
function withdraw(uint256 assets, address receiver, address owner) public virtual returns (uint256) {
uint256 maxAssets = maxWithdraw(owner);
if (assets > maxAssets) {
revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets);
}
uint256 shares = previewWithdraw(assets);
_withdraw(_msgSender(), receiver, owner, assets, shares);
return shares;
}
function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal virtual {
if (caller != owner) {
_spendAllowance(owner, caller, shares); // REQUIRED: blocks unauthorized third-party withdrawals
}
_burn(owner, shares);
SafeERC20.safeTransfer(IERC20(asset()), receiver, assets);
}
YieldCore reimplements withdraw() and redeem() directly and never checks whether the caller is authorized to act for owner:
function withdraw(uint256 assets, address receiver, address owner)
public
override(ERC4626, IERC4626)
nonReentrant
whenNotPaused
returns (uint256 shares)
{
// VULN: this override never checks whether msg.sender is owner
// or has allowance from owner before proceeding.
if (currentPhase != Phase.Matured && currentPhase != Phase.Defaulted) {
revert RWAErrors.InvalidPhase();
}
if (withdrawalStartTime == 0 || block.timestamp < withdrawalStartTime) {
revert RWAErrors.WithdrawalNotAvailable();
}
_claimRemainingInterest(owner); // attacker can force interest payout for an arbitrary owner
DepositInfo storage info = _depositInfos[owner];
if (info.shares == 0) revert RWAErrors.ZeroAmount();
uint256 grossValue = convertToAssets(info.shares);
uint256 userDebt = _userClaimedInterest[owner];
if (grossValue < userDebt) revert RWAErrors.InsufficientBalance();
uint256 netValue = grossValue - userDebt;
if (assets > netValue) {
assets = netValue;
}
if (assets == 0) revert RWAErrors.ZeroAmount();
shares = Math.mulDiv(assets, info.shares, netValue, Math.Rounding.Ceil);
if (shares == 0) revert RWAErrors.ZeroAmount();
uint256 debtToDeduct = Math.mulDiv(userDebt, shares, info.shares, Math.Rounding.Floor);
uint256 principalReduction = Math.mulDiv(info.principal, shares, info.shares, Math.Rounding.Floor);
info.principal -= principalReduction;
info.shares -= shares;
totalPrincipal -= principalReduction;
_userClaimedInterest[owner] -= debtToDeduct;
totalClaimedInterest -= debtToDeduct;
_burn(owner, shares); // VULN: burns victim owner's shares with no approval check
IERC20(asset()).safeTransfer(receiver, assets); // VULN: sends assets to attacker-controlled receiver
}
The standard ERC4626 safeguard is gone:
if (caller != owner) _spendAllowance(...)require(msg.sender == owner)That omission means any external caller can burn another depositor’s vault shares and route underlying USDC to any receiver they choose.
The same missing authorization check also appears in redeem(uint256,address,address). This transaction exploited withdraw(), but redeem() appears vulnerable for the same reason.
0x7137804200a073f616d92e87007f1f100100b56a called helper contract 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17 with selector 0x24998281.RWAVault.withdraw(assets, receiver, owner) eight times against the vault proxy 0xb9c7....0x317aa105....owner, the vault first executed _claimRemainingInterest(owner), transferring accrued interest to the legitimate depositor.0x50c140... because receiver was attacker-controlled.392,763.999994 USDC, the attacker contract approved Uniswap V2 router 0x7a250d... for 5,000 USDC and called swapExactTokensForETH(...).5,000 USDC for 2.195959995966763501 ETH and delivered it to 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97.387,763.999994 USDC to the original attacker EOA 0x7137804200a073f616d92e87007f1f100100b56a.The relevant call chain is:
0x71378042... -> 0x50c140c2... -> 0xb9c7c84a...(withdraw) -> DELEGATECALL 0x317aa105...(withdraw logic) -> USDC transfers
Then the monetization tail is:
0x50c140c2... -> USDC.approve(router, 5000e6) -> UniswapV2Router.swapExactTokensForETH(5000e6, 0, [USDC, WETH], 0x4838..., deadline) -> final USDC.transfer(attacker EOA, 387763.999994e6)
The helper contract targeted eight depositor addresses and always used itself as receiver:
| # | Owner | Requested withdraw amount (USDC) | Interest paid to owner (USDC) | Principal sent to attacker (USDC) |
|---|---|---|---|---|
| 1 | 0xc15d1f621480dd6b298a1aff41e63b67e32ed51b | 100 | 150 | 100 |
| 2 | 0x98269200d948896e09088482830f84454da13e9a | 1,200 | 18 | 1,199.999999 |
| 3 | 0x5070faba9361046c30b3f1976a13c1cad09e8483 | 270,000 | 4,050 | 269,999.999999 |
| 4 | 0xe9563779260c1b71e3c674534b8208666eec38b5 | 3,136 | 47.042321 | 3,136 |
| 5 | 0x5a0e2bd5311d066b022b88f5bf453cb8a5307fee | 6,380 | 95.7 | 6,379.999999 |
| 6 | 0xc15d1f621480dd6b298a1aff41e63b67e32ed51b | 9,900 | 0 | 9,899.999998 |
| 7 | 0x1acb62d792bf6aeca92c4fc79686c0d12c192faa | 2,048 | 30.730137 | 2,048 |
| 8 | 0xab2f2fefafc7ea9316c6538ad63ebb2082585ae6 | 100,000 | 1,500 | 99,999.999999 |
Totals:
392,763.999994 USDC5,891.472458 USDC398,655.472452 USDCThe repeated use of owner values different from msg.sender is the on-chain proof of the authorization failure.
At the time of each exploit call:
msg.sender inside RWAVault was 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17owner was a depositor chosen by the attackerreceiver was also 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17In a correct ERC4626 implementation, msg.sender != owner would force an allowance check and revert unless the depositor had explicitly approved the attacker contract to burn their shares. YieldCore never performs that check, so the helper contract can impersonate any depositor for withdrawal purposes.
The proxy structure does not mitigate the bug. The proxy 0xb9c7... delegates to 0x317aa1..., preserving msg.sender, so the implementation sees the attacker contract directly and still does not authorize it.
| Metric | Value |
|---|---|
| Total vault USDC outflow | 398,655.472452 USDC |
| Interest forced to depositors | 5,891.472458 USDC |
| Principal captured by attacker contract | 392,763.999994 USDC |
| USDC swapped on Uniswap V2 | 5,000 USDC |
| ETH output from swap | 2.195959995966763501 ETH |
Final USDC sent to attacker EOA 0x71378042... | 387,763.999994 USDC |
| Gas used | 851,456 |
| Effective gas price | 0.887584816 gwei |
| Gas cost | 0.000755739417092096 ETH |
Using 1 USDC ~= $1, the total vault loss is approximately $398.7K, consistent with the external alert.
CALL, not a contract-creation transaction. No CREATE/CREATE2 appears in trace_callTracer.json; the helper contract was already deployed before this exploit tx.withdraw(uint256,address,address) calls from 0x50c140... to 0xb9c7..., each followed by a DELEGATECALL into implementation 0x317aa105....0x50c140... as receiver and a different depositor as owner.src/vault/RWAVault.sol overrides both withdraw() and redeem() without the ERC4626 allowance check._spendAllowance(owner, caller, shares) in _withdraw() when caller != owner.funds_flow.json shows:392,763.999994 USDC gained by attacker contract 0x50c140...5,000 USDC approved and transferred into Uniswap V2 pair 0xb4e16d...387,763.999994 USDC forwarded to attacker EOA 0x71378042...2.195959995966763501 ETH sent by the router to 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97