We do not have a reliable USD price for the recorded assets yet.
0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d90x97effb790f2fbb701d88f89db4521348a2b77be8EthereumOn 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.
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.
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.
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:
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.
The exploit is realized as a single attacker-crafted transaction on Ethereum mainnet:
0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9 (block 20434450, chainid 1).The adversary-related cluster consists of:
The high-level stages are:
Orchestrator deployment and setup
Helper-mediated claim and mint
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.claimContracts, CvxRewardDistributor calls cvxStaking.claimCvgCvxMultiple(_account) and accumulates the returned cvgClaimable into _totalCvgClaimable._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.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:
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.
The direct on-chain impact of the exploit is a one-time mint of the remaining staking CVG allocation to the adversary-related cluster:
Cvg contract at 0x97effb790f2fbb701d88f89db4521348a2b77be8).Because this amount corresponds to the remaining staking allocation under MAX_STAKING, the exploit exhausts the protocol’s staking mint capacity.
As a result:
mintStaking is effectively “spent” up to its cap.This impact is measurable and fully determined by the on-chain state differences and transaction traces around block 20434450.
artifacts/root_cause/seed/1/0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9/trace.cast.log, metadata.json.Cvg: source under artifacts/root_cause/seed/1/0x97effb790f2fbb701d88f89db4521348a2b77be8/src/Token/Cvg.sol.artifacts/root_cause/data_collector/iter_2/contract/1/0xbdbddc4bf67e9bc02706e4ce53bc14d6ce3038f1/source/.artifacts/root_cause/data_collector/iter_2/derived/cvg_control_tower_staking_bond_state.json.artifacts/root_cause/data_collector/iter_3/contract/1/0x47c69e8c909ce626af73c955a5e34a20b7c71f19/source/.claimMultipleStaking and _withdrawRewards in src/Staking/Convex/CvxRewardDistributor.sol.artifacts/root_cause/seed/1/0x636be30e58acce0629b2bf975b5c3133840cd7d41ffc3b903720c528f01c65d9/balance_diff.json.artifacts/root_cause/data_collector/iter_2/address/ and artifacts/root_cause/data_collector/iter_3/address/.artifacts/root_cause/data_collector/data_collection_summary.json.