YieldCore RWAVault Unauthorized Withdrawal
2026-4-27 23:59:13 Author: www.darknavy.org(查看原文) 阅读量:0 收藏

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.

Root Cause

Vulnerable Contract

  • RWAVault proxy: 0xb9c7c84a1aa0dd40b5b38aae815ad0cdd2e5f88a
  • Implementation: 0x317aa10528ff675ef4c358ea6a5b7b5494325733
  • Source status: verified on Etherscan
  • Attack vector: access_control

What ERC4626 Should Do

OpenZeppelin 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);
}

What YieldCore Actually Does

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:

  • no if (caller != owner) _spendAllowance(...)
  • no require(msg.sender == owner)
  • no role check restricting third-party withdrawals

That omission means any external caller can burn another depositor’s vault shares and route underlying USDC to any receiver they choose.

Secondary Observation

The same missing authorization check also appears in redeem(uint256,address,address). This transaction exploited withdraw(), but redeem() appears vulnerable for the same reason.

Attack Execution

High-Level Flow

  1. Attacker EOA 0x7137804200a073f616d92e87007f1f100100b56a called helper contract 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17 with selector 0x24998281.
  2. The helper contract called RWAVault.withdraw(assets, receiver, owner) eight times against the vault proxy 0xb9c7....
  3. Each vault call delegated into implementation 0x317aa105....
  4. For each victim owner, the vault first executed _claimRemainingInterest(owner), transferring accrued interest to the legitimate depositor.
  5. The vault then burned that victim’s shares and transferred the requested USDC principal to attacker contract 0x50c140... because receiver was attacker-controlled.
  6. After collecting 392,763.999994 USDC, the attacker contract approved Uniswap V2 router 0x7a250d... for 5,000 USDC and called swapExactTokensForETH(...).
  7. The router swapped 5,000 USDC for 2.195959995966763501 ETH and delivered it to 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97.
  8. The attacker contract transferred the remaining 387,763.999994 USDC to the original attacker EOA 0x7137804200a073f616d92e87007f1f100100b56a.

Trace Summary

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:

#OwnerRequested withdraw amount (USDC)Interest paid to owner (USDC)Principal sent to attacker (USDC)
10xc15d1f621480dd6b298a1aff41e63b67e32ed51b100150100
20x98269200d948896e09088482830f84454da13e9a1,200181,199.999999
30x5070faba9361046c30b3f1976a13c1cad09e8483270,0004,050269,999.999999
40xe9563779260c1b71e3c674534b8208666eec38b53,13647.0423213,136
50x5a0e2bd5311d066b022b88f5bf453cb8a5307fee6,38095.76,379.999999
60xc15d1f621480dd6b298a1aff41e63b67e32ed51b9,90009,899.999998
70x1acb62d792bf6aeca92c4fc79686c0d12c192faa2,04830.7301372,048
80xab2f2fefafc7ea9316c6538ad63ebb2082585ae6100,0001,50099,999.999999

Totals:

  • Principal drained to attacker contract: 392,763.999994 USDC
  • Accrued interest paid out to victims during the exploit: 5,891.472458 USDC
  • Total vault outflow: 398,655.472452 USDC

The repeated use of owner values different from msg.sender is the on-chain proof of the authorization failure.

Why the Authorization Failed

At the time of each exploit call:

  • msg.sender inside RWAVault was 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17
  • owner was a depositor chosen by the attacker
  • receiver was also 0x50c140c2f705fa9d0bd0f4f253bacf4087588d17

In 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.

Financial Impact

MetricValue
Total vault USDC outflow398,655.472452 USDC
Interest forced to depositors5,891.472458 USDC
Principal captured by attacker contract392,763.999994 USDC
USDC swapped on Uniswap V25,000 USDC
ETH output from swap2.195959995966763501 ETH
Final USDC sent to attacker EOA 0x71378042...387,763.999994 USDC
Gas used851,456
Effective gas price0.887584816 gwei
Gas cost0.000755739417092096 ETH

Using 1 USDC ~= $1, the total vault loss is approximately $398.7K, consistent with the external alert.

Evidence

  • The exploit transaction is a normal CALL, not a contract-creation transaction. No CREATE/CREATE2 appears in trace_callTracer.json; the helper contract was already deployed before this exploit tx.
  • The call trace shows eight distinct withdraw(uint256,address,address) calls from 0x50c140... to 0xb9c7..., each followed by a DELEGATECALL into implementation 0x317aa105....
  • Every exploit call uses attacker contract 0x50c140... as receiver and a different depositor as owner.
  • The verified source at src/vault/RWAVault.sol overrides both withdraw() and redeem() without the ERC4626 allowance check.
  • OpenZeppelin’s reference ERC4626 implementation does include _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

文章来源: https://www.darknavy.org/web3/exploits/yieldcore-rwavault-unauthorized-withdrawal/
如有侵权请联系:admin#unsafe.sh