All incidents

CVG staking supply drain from reward-mint inflation bug

Share
Aug 01, 2024 14:59 UTCAttackLoss: 58,718,395.06 CVGManually checked1 exploit txWindow: Atomic

Root Cause Analysis

CVG staking supply drain from reward-mint inflation bug

1. Incident Overview TL;DR

On Ethereum mainnet at block 20434450 (0x137ce12), an unprivileged EOA 0x03560a9d7a2c391fb1a087c33650037ae30de3aa deployed an orchestrator contract 0xee45384d4861b6fb422dfa03fbdcc6e29d7beb69 and, within the same transaction 0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9, used a whitelisted helper proxy 0x2b083beaac310cc5e190b1d2507038ccb03e7606 to call CvxRewardDistributor.claimMultipleStaking. By supplying an attacker-controlled ICvxStakingPositionService implementation, the orchestrator caused CVG token 0x97effb790f2fbb701d88f89db4521348a2b77be8 to mint 58,718,395.056818121904518498 CVG, corresponding to the remaining staking allocation, directly to an adversary-controlled cluster of addresses. The core root cause is that CvxRewardDistributor.claimMultipleStaking trusts arbitrary staking service contracts and their reported cvgClaimable amounts without verifying them against CvgControlTowerV2.isStakingContract, while being reachable through a helper proxy that is itself marked as a staking contract. This unguarded helper claim path lets any attacker who can deploy contracts and send transactions mint essentially the entire remaining staking CVG supply in a single ACT-style transaction.

2. Key Background

Convergence (CVG) uses an ERC-20 token Cvg (address 0x97effb790f2fbb701d88f89db4521348a2b77be8) with its supply partitioned into multiple allocations, including a bond allocation and a staking allocation capped by MAX_BOND and MAX_STAKING constants. The token exposes two minting functions:

function mintBond(address account, uint256 amount) external {
    require(cvgControlTower.isBond(msg.sender), "NOT_BOND");
    uint256 newMintedBond = mintedBond + amount;
    require(newMintedBond <= MAX_BOND, "MAX_SUPPLY_BOND");
    mintedBond = newMintedBond;
    _mint(account, amount);
}

function mintStaking(address account, uint256 amount) external {
    require(cvgControlTower.isStakingContract(msg.sender), "NOT_STAKING");
    uint256 _mintedStaking = mintedStaking;
    require(_mintedStaking < MAX_STAKING, "MAX_SUPPLY_STAKING");
    uint256 newMintedStaking = _mintedStaking + amount;
    if (newMintedStaking > MAX_STAKING) {
        newMintedStaking = MAX_STAKING;
        amount = MAX_STAKING - _mintedStaking;
    }
    mintedStaking = newMintedStaking;
    _mint(account, amount);
}

These functions delegate access control to ICvgControlTower / CvgControlTowerV2, which maintains mappings isBond and isStakingContract that mark which contracts are allowed to mint from each allocation. A pre/post-state snapshot around the incident block shows that the helper proxy 0x2b083beaac310cc5e190b1d2507038ccb03e7606 has isStakingContract == true while the attacker’s orchestrator 0xee45384d4861b6fb422dfa03fbdcc6e29d7beb69 has isStakingContract == false:

{
  "label": "orchestrator_0xee45",
  "address": "0xee45384d4861b6fb422dfa03fbdcc6e29d7beb69",
  "mappings": {
    "isStakingContract": {
      "value_pre": "0x...0",
      "value_post": "0x...0"
    }
  }
},
{
  "label": "helper_proxy_0x2b08",
  "address": "0x2b083beaac310cc5e190b1d2507038ccb03e7606",
  "mappings": {
    "isStakingContract": {
      "value_pre": "0x...1",
      "value_post": "0x...1"
    }
  }
}

The helper proxy 0x2b08… is an OpenZeppelin TransparentUpgradeableProxy whose implementation slot resolves, both before and at the incident block, to 0x47c69e8c909ce626af73c955a5e34a20b7c71f19, where the CvxRewardDistributor contract is deployed. CvxRewardDistributor exposes two relevant functions: claimCvgCvxSimple, which can only be called by contracts marked as isStakingContract, and claimMultipleStaking, which aggregates rewards from an array of ICvxStakingPositionService contracts. While claimCvgCvxSimple checks cvgControlTower.isStakingContract(msg.sender), claimMultipleStaking only enforces a weak condition on _isConvert and otherwise trusts the provided claimContracts.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability arises because CvxRewardDistributor.claimMultipleStaking accepts an arbitrary list of ICvxStakingPositionService contracts and blindly trusts the cvgClaimable values they return, without verifying that those contracts are registered staking services or that the claimed amounts are consistent with on-chain staking state. The helper proxy 0x2b08… is marked as a staking contract in CvgControlTowerV2, so calls routed through it to CvxRewardDistributor satisfy the Cvg.mintStaking access control even when initiated by an unprivileged EOA via an attacker-deployed orchestrator. This creates a path where any attacker who can deploy a contract implementing ICvxStakingPositionService and call the helper proxy can cause CvxRewardDistributor to compute an arbitrarily large totalCvgClaimable and to mint that amount of CVG via Cvg.mintStaking, up to the remaining MAX_STAKING cap. The incident transaction instantiates this general vulnerability in an ACT-style exploit that drains essentially the entire remaining staking CVG allocation into an adversary-controlled cluster.

4. Detailed Root Cause Analysis

At a code level, the intended invariant is that the amount of CVG minted through mintStaking must be fully determined by protocol-governed staking contracts registered in CvgControlTowerV2.isStakingContract, based on honest staking positions. CvxRewardDistributor correctly applies this invariant in its simple path:

function claimCvgCvxSimple(
    address receiver,
    uint256 totalCvgClaimable,
    ICommonStruct.TokenAmount[] memory totalCvxRewardsClaimable,
    uint256 minCvgCvxAmountOut,
    bool isConvert
) external {
    require(cvgControlTower.isStakingContract(msg.sender), "NOT_STAKING");
    _withdrawRewards(receiver, totalCvgClaimable, totalCvxRewardsClaimable, minCvgCvxAmountOut, isConvert);
}

However, the multi-claim path omits any equivalent gating and delegates trust to arbitrary external contracts:

function claimMultipleStaking(
    ICvxStakingPositionService[] calldata claimContracts,
    address _account,
    uint256 _minCvgCvxAmountOut,
    bool _isConvert,
    uint256 cvxRewardCount
) external {
    require(claimContracts.length != 0, "NO_STAKING_SELECTED");
    if (_isConvert) {
        require(msg.sender == _account, "CANT_CONVERT_CVX_FOR_OTHER_USER");
    }
    uint256 _totalCvgClaimable;
    ICommonStruct.TokenAmount[] memory _totalCvxClaimable = new ICommonStruct.TokenAmount[](cvxRewardCount);
    for (uint256 stakingIndex; stakingIndex < claimContracts.length; ) {
        ICvxStakingPositionService cvxStaking = claimContracts[stakingIndex];
        (uint256 cvgClaimable, ICommonStruct.TokenAmount[] memory _cvxRewards) =
            cvxStaking.claimCvgCvxMultiple(_account);
        _totalCvgClaimable += cvgClaimable;
        // merge _cvxRewards into _totalCvxClaimable ...
        unchecked { ++stakingIndex; }
    }
    _withdrawRewards(_account, _totalCvgClaimable, _totalCvxClaimable, _minCvgCvxAmountOut, _isConvert);
}

The internal _withdrawRewards function then mints CVG according to the accumulated value:

function _withdrawRewards(
    address receiver,
    uint256 totalCvgClaimable,
    ICommonStruct.TokenAmount[] memory totalCvxRewardsClaimable,
    uint256 minCvgCvxAmountOut,
    bool isConvert
) internal {
    if (totalCvgClaimable > 0) {
        CVG.mintStaking(receiver, totalCvgClaimable);
    }
    // Convex rewards handling omitted
}

Because claimMultipleStaking is a public function and does not check cvgControlTower.isStakingContract(msg.sender), any caller that can reach it through the helper proxy can invoke it. The helper proxy 0x2b08… is configured in CvgControlTowerV2 as a staking contract, so the subsequent call CVG.mintStaking(receiver, totalCvgClaimable) satisfies the NOT_STAKING check even though the effective control over totalCvgClaimable resides with attacker-selected claimContracts. In the incident, the adversary deployed an ICvxStakingPositionService-compatible contract whose claimCvgCvxMultiple(_account) implementation returns an extremely large cvgClaimable value that is not backed by genuine staking positions. The orchestrator constructor then calls the helper proxy with a claimContracts array containing this malicious contract and with _isConvert = false, so that the only access-control condition inside claimMultipleStaking (the _isConvert guard) is bypassed. Given the remaining staking allocation at block 20434450, the value chosen by the attacker causes Cvg.mintStaking to clamp the mint to exactly the remaining MAX_STAKING capacity; the result is a deterministic mint of 58,718,395.056818121904518498 CVG to the adversary-controlled orchestrator and onward to target EOAs. This execution path is fully confirmed by the trace and storage evidence:

  • The seed transaction trace (trace.cast.log for 0x636be3…) shows the call chain 0x0356… → (create) 0xee45… → 0x2b08… (delegatecall) → CvxRewardDistributor.claimMultipleStaking → malicious ICvxStakingPositionService.claimCvgCvxMultiple → Cvg.mintStaking.
  • cvg_control_tower_staking_bond_state.json confirms that only the helper proxy, not the orchestrator, is registered as isStakingContract, so the orchestrator must route through the helper to reach mintStaking.
  • balance_diff.json shows the resulting CVG mint:
{
  "token": "0x97effb790f2fbb701d88f89db4521348a2b77be8",
  "holder": "0x004c167d27ada24305b76d80762997fa6eb8d9b2",
  "delta": "52846555551136309714066648",
  "contract_name": "Cvg"
},
{
  "token": "0x97effb790f2fbb701d88f89db4521348a2b77be8",
  "holder": "0xa7b0e924c2dbb9b4f576cce96ac80657e42c3e42",
  "delta": "5871839505681812190451850",
  "contract_name": "Cvg"
}

The sum of these deltas matches the 58,718,395.056818121904518498 CVG reported in the loss section and success predicate.

5. Adversary Flow Analysis

The exploit is realized as a single attacker-crafted transaction on Ethereum mainnet:

  • Seed / exploit transaction: 0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9 (block 20434450, chainid 1).

The adversary-related cluster consists of:

  • EOA 0x03560a9d7a2c391fb1a087c33650037ae30de3aa – unprivileged attacker EOA; funds the transaction and later receives crvFRAX LP tokens.
  • Contract 0xee45384d4861b6fb422dfa03fbdcc6e29d7beb69 – attacker orchestrator deployed in the exploit tx; contains constructor logic that performs the helper call.
  • EOA 0x004c167d27ada24305b76d80762997fa6eb8d9b2 – major recipient of freshly minted CVG.
  • EOA 0xa7b0e924c2dbb9b4f576cce96ac80657e42c3e42 – secondary recipient of freshly minted CVG and crvFRAX LP tokens.

The high-level stages are:

  1. Orchestrator deployment and setup

    • The seed tx is a type-0x2 contract-creation transaction from 0x0356… with zero ETH value.
    • Its calldata deploys orchestrator 0xee45… with exploit logic in the constructor.
    • Trace evidence shows the initial depth-1 execution corresponding to orchestrator bytecode, followed by the orchestrator issuing calls and delegatecalls.
  2. Helper-mediated claim and mint

    • Within the same transaction, the orchestrator invokes helper proxy 0x2b08…, targeting CvxRewardDistributor.claimMultipleStaking on implementation 0x47c6… and passing:
      • claimContracts: an array containing at least one attacker-controlled ICvxStakingPositionService contract whose claimCvgCvxMultiple(_account) returns an arbitrarily large cvgClaimable value.
      • _account: an address under adversary control (orchestrator or related EOA).
      • _minCvgCvxAmountOut: configured for Convex reward handling; not relevant to CVG minting.
      • _isConvert = false: so that the only additional access-control condition (msg.sender == _account when _isConvert is true) does not apply.
    • For each entry in claimContracts, CvxRewardDistributor calls cvxStaking.claimCvgCvxMultiple(_account) and accumulates the returned cvgClaimable into _totalCvgClaimable.
    • After iterating, _withdrawRewards is invoked, which calls CVG.mintStaking(_account, _totalCvgClaimable) from contract 0x47c6… (via the helper proxy), satisfying the isStakingContract check and minting CVG based solely on attacker-controlled values.
  3. Immediate post-mint routing

    • balance_diff.json for the seed tx shows that newly minted CVG is first credited to orchestrator 0xee45… and then split:
      • 52,846,555.551136309714066648 CVG to 0x004c…
      • 5,871,839.50568181219045185 CVG to 0xa7b0…
    • Additional ERC-20 transfer histories collected for CVG and the Curve LP token 0x3175df0976dfa876431c2e9ee6bc45b65d3473cc show:
      • crvFRAX LP tokens flowing from 0xa7b0… back to the attacker EOA 0x0356…;
      • CVG from 0x004c… and 0xa7b0… routed toward an external aggregator 0xe6b1de575e7e610889ea21024834e120f92033a3.
    • These flows establish a coherent value path from the protocol’s staking allocation to the adversary-related cluster.

Throughout this flow, the adversary uses only unprivileged EOAs and their own deployed contracts, interacting with public entrypoints on protocol-controlled contracts via a whitelisted helper proxy. This matches the ACT adversary model: any unprivileged actor with access to the same on-chain information could reproduce the strategy by deploying a compatible staking service contract and calling the same helper functions.

6. Impact & Losses

The direct on-chain impact of the exploit is a one-time mint of the remaining staking CVG allocation to the adversary-related cluster:

  • Token: CVG (Cvg contract at 0x97effb790f2fbb701d88f89db4521348a2b77be8).
  • Amount minted in exploit tx: 58,718,395.056818121904518498 CVG.
  • Recipients:
    • 52,846,555.551136309714066648 CVG to EOA 0x004c167d27ada24305b76d80762997fa6eb8d9b2.
    • 5,871,839.50568181219045185 CVG to EOA 0xa7b0e924c2dbb9b4f576cce96ac80657e42c3e42.

Because this amount corresponds to the remaining staking allocation under MAX_STAKING, the exploit exhausts the protocol’s staking mint capacity. As a result:

  • Honest current and future stakers can no longer receive CVG rewards through the intended staking path, since mintStaking is effectively “spent” up to its cap.
  • The adversary cluster gains control over a dominant share of the CVG that was supposed to be distributed over time to staking participants, concentrating supply and any associated governance or economic rights in a small set of externally controlled addresses.
  • The mint occurs without any offsetting burn or protocol-level compensation; balance diffs confirm a pure net increase in total CVG supply attributable to this single transaction.

This impact is measurable and fully determined by the on-chain state differences and transaction traces around block 20434450.

7. References

  • Seed exploit transaction:
    • Ethereum mainnet, chainid 1, tx 0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9 (block 20434450).
    • Trace and metadata: artifacts/root_cause/seed/1/0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9/trace.cast.log, metadata.json.
  • Token and control-tower contracts:
    • CVG token Cvg: source under artifacts/root_cause/seed/1/0x97effb790f2fbb701d88f89db4521348a2b77be8/src/Token/Cvg.sol.
    • CvgControlTowerV2 implementation: verified source under artifacts/root_cause/data_collector/iter_2/contract/1/0xbdbddc4bf67e9bc02706e4ce53bc14d6ce3038f1/source/.
    • Staking/bond mapping snapshot: artifacts/root_cause/data_collector/iter_2/derived/cvg_control_tower_staking_bond_state.json.
  • Helper and distributor contracts:
    • Helper proxy: 0x2b083beaac310cc5e190b1d2507038ccb03e7606.
    • Implementation (CvxRewardDistributor): 0x47c69e8c909ce626af73c955a5e34a20b7c71f19 with source under artifacts/root_cause/data_collector/iter_3/contract/1/0x47c69e8c909ce626af73c955a5e34a20b7c71f19/source/.
    • Key function: claimMultipleStaking and _withdrawRewards in src/Staking/Convex/CvxRewardDistributor.sol.
  • Adversary-related addresses:
    • Attacker EOA: 0x03560a9d7a2c391fb1a087c33650037ae30de3aa.
    • Orchestrator contract: 0xee45384d4861b6fb422dfa03fbdcc6e29d7beb69 (created in the exploit tx).
    • CVG recipient EOAs: 0x004c167d27ada24305b76d80762997fa6eb8d9b2 and 0xa7b0e924c2dbb9b4f576cce96ac80657e42c3e42.
  • Balance and transfer evidence:
    • ERC-20 balance diffs for the seed tx: artifacts/root_cause/seed/1/0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9/balance_diff.json.
    • ERC-20 tokentx histories for CVG and Curve LP tokens involving adversary-related addresses: artifacts/root_cause/data_collector/iter_2/address/ and artifacts/root_cause/data_collector/iter_3/address/.
  • Additional explorer/RPC context:
    • Contract verification and ABI metadata obtained via Etherscan v2 and QuickNode RPC, as summarized in artifacts/root_cause/data_collector/data_collection_summary.json.