CAROL Reward Inflation Drain
Exploit Transactions
Victim Addresses
0x26fe408bbd7a490feb056da8e2d1e007938e5685Base0x0c477c729816228af3cb4e014cbf9412aa080b86Base0x4a0a76645941d8c7ba059940b3446228f0db8972BaseLoss Breakdown
Similar Incidents
MPRO Staking Proxy unwrapWETH Flash-Loan Exploit (Base)
31%Base DUCKVADER infinite mint + Uniswap drain
31%Veil01ETH forged-proof drain on Base
30%Aerodrome V6 Unauthorized Reward Harvest via Forged Depositor Assignment
29%SynapLogicErc20 Router Flash-Loan Over-Mint Exploit
29%USDC drain via unchecked Uniswap V3-style callback
29%Root Cause Analysis
CAROL Reward Inflation Drain
1. Incident Overview TL;DR
CAROLProtocol on Base was drained through a permissionless two-transaction sequence centered on tx 0x6462f5e358eb2c7769e6aa59ce43277be4799b297bc4c9503610443b9d56cc24 and tx 0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185c. The attacker EOA 0x5aa27d556f898846b9bad32f0cdba5b1f8bc3144 first created a normal staked position through helper contract 0xc4566ae957ad8dde4768bdd28cdc3695e4780b2c, then returned about 18.8 hours later with public flash liquidity, distorted the CAROL/WETH pair reserves, redeemed an inflated reward balance through the public sell() path, repaid every lender, and finished with 29.870407854734919470 ETH of net profit after gas.
The root cause is a flashloan-manipulable reward oracle inside CAROLProtocol. The protocol computes staking rewards from the live CAROL/WETH PancakePair reserves, so a temporary reserve spike immediately inflates userBalance(). The public sell() function then monetizes that inflated internal balance by minting fresh CAROL, swapping it for ETH, and burning protocol LP based on the same manipulated pool state. This is an ACT incident because every required step was permissionless: buy(), stake(), and sell() were public, and the liquidity came from public flash-loan venues.
2. Key Background
Three public protocol components matter:
CAROLProtocolat0x26fe408bbd7a490feb056da8e2d1e007938e5685CAROLTokenat0x4a0a76645941d8c7ba059940b3446228f0db8972- The CAROL/WETH PancakePair at
0x0c477c729816228af3cb4e014cbf9412aa080b86
CAROLToken is explicitly designed so that CAROLProtocol can mint reward tokens. The local token snapshot shows the mint gate:
function setMainContractAddress(address contractAddress) external onlyOwner {
require(mainContractAddress == address(0), "Main contract address already configured");
mainContractAddress = contractAddress;
}
function mint(address to, uint256 amount) public {
require(msg.sender == mainContractAddress, "Mint: only main contract can mint tokens");
_mint(to, amount);
}
Source: local CAROLToken snapshot in the collector artifacts.
The attacker's only prerequisite state was one publicly created staked bond. The setup trace shows the helper calling buy{value: 0.03 ETH} and stake{value: 0.039 ETH}(0), and the final bonds(helper, 0) read returns:
CAROLProtocol::bonds(helper, 0) -> (
amount = 30000000000000000,
creationTime = 1701281509,
freezePeriod = 2592000,
profitPercent = 3000,
stakeAmount = 23842175138116021874902,
stakeTime = 1701281509,
collectedTime = 1701281509,
collectedReward = 0,
stakingRewardLimit = 35763262707174032812353,
isClosed = false
)
That state is important because the later exploit does not require any privileged seed or hidden off-chain input. The helper simply needed an active bond that any user could create through the public interface.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is an economic attack caused by using a flashloan-manipulable AMM reserve as a live staking-reward oracle. CAROLProtocol reads the CAROL/WETH pair reserves directly, converts the WETH reserve into a global bonus percentage, and immediately applies that bonus inside both collect() and userBalance(). Because the source is an ordinary PancakePair reserve, an attacker can inflate it within one transaction by borrowing WETH and buying CAROL on the same pair. The reward accounting does not use a TWAP, delayed settlement, or any anti-flashloan mechanism, so the manipulated reserve instantly increases the helper's redeemable internal CAROL balance. Once the balance is inflated, the public sell() path turns that accounting error into real value by minting CAROL, swapping to ETH, removing protocol LP, and only partially restoring the pool. The invariant that fails is straightforward: transient reserve distortions in the liquidity pool must not change the amount of reward tokens that a staker can redeem for real ETH. The first code-level breakpoint is getLiquidityGlobalBonusPercent(), and the monetization breakpoint is sell().
4. Detailed Root Cause Analysis
The relevant CAROLProtocol logic is visible in the verified BaseScan source:
function collect(address userAddress) private {
...
tokensAmount = bond.stakeAmount
* (block.timestamp - bond.collectedTime)
* (
Constants.STAKING_REWARD_PERCENT
+ getLiquidityGlobalBonusPercent()
+ getHoldBonusPercent(userAddress)
+ getLiquidityBonusPercent(userAddress)
)
/ Constants.PERCENTS_DIVIDER
/ 1 days;
...
}
function userBalance(address userAddress) public view returns (uint256 balance) {
...
tokensAmount = bond.stakeAmount
* (block.timestamp - bond.collectedTime)
* (
Constants.STAKING_REWARD_PERCENT
+ getLiquidityGlobalBonusPercent()
+ getHoldBonusPercent(userAddress)
+ getLiquidityBonusPercent(userAddress)
)
/ Constants.PERCENTS_DIVIDER
/ 1 days;
...
}
function getTokenLiquidity() public view returns (uint256 liquidityETH, uint256 liquidityERC20) {
(liquidityETH, liquidityERC20, ) = IUniswapV2Pair(LP_TOKEN_ADDRESS).getReserves();
}
function getLiquidityGlobalBonusPercent() public view returns (uint256 bonusPercent) {
(uint256 liquidityETH, ) = getTokenLiquidity();
bonusPercent = liquidityETH
* Constants.GLOBAL_LIQUIDITY_BONUS_STEP_PERCENT
/ Constants.GLOBAL_LIQUIDITY_BONUS_STEP_ETH;
}
With GLOBAL_LIQUIDITY_BONUS_STEP_PERCENT = 10 and GLOBAL_LIQUIDITY_BONUS_STEP_ETH = 25 ether, the protocol effectively computes bonusPercent = liquidityETH * 10 / 25 ether. Before the exploit, the pair held 33.647459623314536802 WETH, which yields a global bonus of 13. After the flashloan buy, the pair held 3433.647459623314536802 WETH, which yields a global bonus of 1373. That is the exact reserve-based accounting jump that breaks the reward invariant.
The public monetization path is equally explicit:
function sell(uint256 tokensAmount) external {
require(userBalance(msg.sender) >= tokensAmount, "Sell: insufficient balance");
collect(msg.sender);
...
CAROLToken(TOKEN_ADDRESS).mint(address(this), tokensAmount);
...
uint256[] memory amounts = IUniswapV2Router01(UNISWAP_ROUTER_ADDRESS).swapExactTokensForETH(
tokensAmount, 0, path, msg.sender, block.timestamp + 5 minutes
);
uint256 ethAmount = amounts[1];
(uint256 ethReserved, ) = getTokenLiquidity();
uint256 liquidity = ERC20(LP_TOKEN_ADDRESS).totalSupply()
* ethAmount
* (Constants.PERCENTS_DIVIDER + PRICE_BALANCER_PERCENT)
/ Constants.PERCENTS_DIVIDER
/ ethReserved;
(, uint256 amountETH) = IUniswapV2Router01(UNISWAP_ROUTER_ADDRESS).removeLiquidityETH(
TOKEN_ADDRESS, liquidity, 0, 0, address(this), block.timestamp + 5 minutes
);
...
}
The exploit trace shows the reserve distortion and immediate reward inflation in one contiguous sequence:
PancakeRouter::swapExactTokensForTokens(3400000000000000000000, 0, [WETH, CAROL], helper, ...)
PancakePair::getReserves() -> 33647459623314536802 WETH, 13179262136524254527580123 CAROL
WETH9::transferFrom(helper, PancakePair, 3400000000000000000000)
PancakePair::swap(0, 13049793640399259421187109, helper, 0x)
emit Sync(reserve0: 3433647459623314536802, reserve1: 129468496124995106393014)
CAROLProtocol::userBalance(helper) -> 2927288563373604177630
CAROLProtocol::sell(2927288563373604177630)
Source: human-readable exploit trace collected for tx 0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185c.
The protocol therefore pays out against a value that is not economically real. Immediately before the pump, the helper's computed userBalance() was about 396.372519116643873722 CAROL. Immediately after the pump, it became 2927.288563373604177630 CAROL. Nothing about the helper's true stake quality changed; only the pair reserves changed. That is why the correct root cause is reward inflation from trusted spot reserves, not a flash-loan repayment issue or a privileged-access issue.
5. Adversary Flow Analysis
The adversary cluster consists of EOA 0x5aa27d556f898846b9bad32f0cdba5b1f8bc3144 and helper contract 0xc4566ae957ad8dde4768bdd28cdc3695e4780b2c.
- In tx
0x6462f5e358eb2c7769e6aa59ce43277be4799b297bc4c9503610443b9d56cc24at Base block7246081, the EOA funds the helper with0.07ETH. The helper callsCAROLProtocol::buy{value: 0.03 ETH}andCAROLProtocol::stake{value: 0.039 ETH}(0), leaving itself with one active staked bond. - In tx
0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185cat Base block7279800, the helper aggregates exactly3400WETH of public flash liquidity from Curve, Balancer, Kokonut, Uniswap V3, and Velodrome-adjacent liquidity. - The helper swaps the borrowed WETH for CAROL on the live CAROL/WETH PancakePair, pushing the reserve state from
33.647459623314536802 / 13179262136524254527580123to3433.647459623314536802 / 129468496124995106393014. - While the reserve-derived global bonus is still inflated, the helper calls
userBalance()and then repeatedly calls the publicsell()path. Each sell mints CAROL from the protocol, dumps CAROL into the pair for ETH, burns protocol LP, and only partly rebalances by buying CAROL back. - The helper then unwinds its temporary CAROL position, repays all lenders with fees, withdraws the remaining
29.922138759675517488WETH to native ETH, and transfers that ETH back to the attacker EOA.
The tail of the exploit trace shows the profit realization directly:
WETH9::balanceOf(helper) -> 29922138759675517488
WETH9::withdraw(29922138759675517488)
emit Withdrawal(src: helper, wad: 29922138759675517488)
EOA 0x5AA27D556f898846B9BaD32f0cDba5B1F8bC3144::fallback{value: 29922138759675517488}()
This sequence is permissionless end to end. The helper contract is attacker-deployed, but no privileged contract or privileged identity is required to realize the opportunity.
6. Impact & Losses
The measurable victim-side loss is a permanent depletion of 33.577157037700600138 WETH from the CAROL/WETH pair. The collector's balance-diff artifact records:
{
"token": "0x4200000000000000000000000000000000000006",
"holder": "0x0c477c729816228af3cb4e014cbf9412aa080b86",
"before": "33647459623314536802",
"after": "70302585613936664",
"delta": "-33577157037700600138"
}
The attacker EOA's native balance moved from 0.105248790473125342 ETH to 29.975656645208044812 ETH in the exploit transaction, for a net increase of 29.870407854734919470 ETH after gas. The helper withdrew 29.922138759675517488 WETH to ETH before forwarding it to the EOA; the difference between that transfer and the EOA's final gain is explained by gas. Beyond the WETH loss, the exploit also corrupted CAROLProtocol's accounting by minting and recycling large amounts of CAROL against manipulated state and by consuming protocol LP inventory that should have backed honest reward flows.
7. References
- Setup transaction:
https://basescan.org/tx/0x6462f5e358eb2c7769e6aa59ce43277be4799b297bc4c9503610443b9d56cc24 - Exploit transaction:
https://basescan.org/tx/0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185c - Verified CAROLProtocol source:
https://basescan.org/address/0x26fe408bbd7a490feb056da8e2d1e007938e5685#code - Local CAROLToken snapshot:
/workspace/session/artifacts/collector/seed/8453/0x4a0a76645941d8c7ba059940b3446228f0db8972/src/Contract.sol - Local PancakePair snapshot:
/workspace/session/artifacts/collector/seed/8453/0x0c477c729816228af3cb4e014cbf9412aa080b86/src/Contract.sol - Setup trace:
/workspace/session/artifacts/collector/seed/8453/0x6462f5e358eb2c7769e6aa59ce43277be4799b297bc4c9503610443b9d56cc24/trace.cast.log - Exploit trace:
/workspace/session/artifacts/collector/seed/8453/0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185c/trace.cast.log - Exploit balance diff:
/workspace/session/artifacts/collector/seed/8453/0xd962d397a7f8b3aadce1622e705b9e33b430e86e0d306d6fb8ccbc5957b4185c/balance_diff.json