
AuditSecurityBug BountyContest
Bug Bounty Win: How We Placed Top 5 in the Cyfrin CodeHawks Contest
30 de abril de 2024•zealynx
Table of Contents
- Introduction
- Choosing the Right Bug Bounty Contest
- Smart Contract Audit Tools and Testing Methods: Our Process and Best Practices
- We Won $13k?!
- Challenges and What's Next
- Understanding the Contract:
- What is the problem with this function?
- What is the solution?
- Demonstrating the Problem with a POC
- Test Results
- Final Bug Bounty Reward
Introduction
In 2024 alone,over $2.2 billion was stolen in hacks and exploits. This is why Bug bounties have quickly become one of the most crucial and effective security measures in Web3. They do not take the place of delibrate smart contract security auditing / auditors. Rather they provide a cost-effective way for projects to identify and address vulnerabilities in smart contracts and other Web3 applications before malicious actors can exploit them.
At Zealynx, we like to participate in these bounties because they sharpen our skills, keep us updated with real-world attack vectors, and push us to stay ahead in blockchain security.
In this article, you will find a detailed explanation of how we detected a bug that earned us a substantial reward in the Beanstalk contest.
We will be discussing how we approached the code, the strategies we used to spot vulnerabilities, and the specific techniques that led to discovering the bug: Failure in Maintaining Gauge Points.
We will be discussing how we approached the code, the strategies we used to spot vulnerabilities, and the specific techniques that led to discovering the bug: Failure in Maintaining Gauge Points.
The goal of this article is to encourage you, the reader, to delve into as many contests as possible. Along the way, we’ll provide key resources, advice, and lessons learned from our experience.
Let’s get started.
Choosing the Right Bug Bounty Contest
Let’s share a bit more info about the situation.
Before we started the contest, we had just completed an intense fuzzing test campaign (Check the result in our GitHub Repo). This meant we had only 2-3 days to participate in the CodeHawks contest before diving into our next campaign.
The timing was an additional challenge but also a unique opportunity to showcase our skills. We jumped right in with an initial planning call and our signature Notion template, which we use for every smart contract auditing project. The goal was to efficiently organize and distribute tasks.
Moreover, there were multiple bug bounty reports and contests running at the same time, so we had to make a quick decision on which contest to prioritize. After evaluating the options, we chose the Beanstalk contest, as it seemed both interesting and aligned with our expertise in smart contract auditing. We wasted no time and got straight to work on identifying vulnerabilities.
Smart Contract Audit Tools and Testing Methods: Our Process and Best Practices
The project mainly used Hardhat, which was complex for testing, so we adapted it to Foundry. We used static analysis tools like Slitherin, Aderyn, Wake, and Olympix to get a first impression and study the weak points of the project.
Next, we began the manual analysis and testing campaign. During this phase, we analyzed each part of the contracts to understand the logic of each function. We verified if all invariants were met by conducting fuzz tests on each function. For this part, we used Foundry as our main tool.
Free Resource: check out our article "How to Write a Detector in Aderyn Step by Step" for more details on how to find bugs with Aderyn.
One of the fundamental pillars of our audits is the use of fuzzing tests. We use them because they allow us to explore countless possible scenarios that would take much longer with a manual method and would be much more complex. Of course, manual reviews are still necessary, and we use them to verify that everything works correctly.
We Won $13k?!
Before diving into the code (and the more technical aspect of this article), we would like to thank the CodeHawks team for their incredible work.
A few days before the final contest results were announced, we received an update showing a reward of 13,000 USDC. However, when we reached out to the Cyfrin team for confirmation, they kindly clarified that these weren’t the final results yet.
Read to the end to find out the final reward amount. 👇
Free Resource: Check out their latest website update - CodeHawks Update
Challenges and What's Next
Next, we will break down the contract and the function where we discovered the issue. You can either read it all or skip to the sections that interest you the most.
- Understanding the Contract
- What is the problem with this function?
- What is the solution?
- Demonstrating the Problem with a POC
- Test Results
- Final Reward
Understanding the Contract:
In this section, we will be breaking down the entire logic of the contract line by line.
1/*2 * SPDX-License-Identifier: MIT3 */45pragma solidity =0.7.6;6pragma experimental ABIEncoderV2;78import {SafeMath} from "@openzeppelin/contracts/math/SafeMath.sol";9import {LibGauge} from "contracts/libraries/LibGauge.sol";1011/**12 * @title GaugePointFacet13 * @author Brean14 * @notice Calculates the gaugePoints for whitelisted Silo LP tokens.15 */16contract GaugePointFacet {17 using SafeMath for uint256;1819 uint256 private constant ONE_POINT = 1e18;20 uint256 private constant MAX_GAUGE_POINTS = 1000e18;2122 uint256 private constant UPPER_THRESHOLD = 10001;23 uint256 private constant LOWER_THRESHOLD = 9999;24 uint256 private constant THRESHOLD_PRECISION = 10000;2526 /**27 * @notice DefaultGaugePointFunction28 * is the default function to calculate the gauge points29 * of an LP asset.30 *31 * @dev If % of deposited BDV is .01% within range of optimal,32 * keep gauge points the same.33 *34 * Cap gaugePoints to MAX_GAUGE_POINTS to avoid runaway gaugePoints.35 */36 function defaultGaugePointFunction(37 uint256 currentGaugePoints,38 uint256 optimalPercentDepositedBdv,39 uint256 percentOfDepositedBdv40 ) external pure returns (uint256 newGaugePoints) {41 if (42 percentOfDepositedBdv >43 optimalPercentDepositedBdv.mul(UPPER_THRESHOLD).div(THRESHOLD_PRECISION)44 ) {45 // gauge points cannot go below 0.46 if (currentGaugePoints <= ONE_POINT) return 0;47 newGaugePoints = currentGaugePoints.sub(ONE_POINT);48 } else if (49 percentOfDepositedBdv <50 optimalPercentDepositedBdv.mul(LOWER_THRESHOLD).div(THRESHOLD_PRECISION)51 ) {52 newGaugePoints = currentGaugePoints.add(ONE_POINT);5354 // Cap gaugePoints to MAX_GAUGE_POINTS if it exceeds.55 if (newGaugePoints > MAX_GAUGE_POINTS) return MAX_GAUGE_POINTS;56 }57 }58}
Explanation of the defaultGaugePointFunction
The
defaultGaugePointFunction
is used to adjust the gauge points of Liquidity Provider (LP) tokens based on the percentage of Base Deposited Value (BDV) deposited.Input Parameters
currentGaugePoints
: The current gauge points of the LP token.optimalPercentDepositedBdv
: The optimal percentage of BDV that should be deposited.percentOfDepositedBdv
: The current percentage of BDV that is deposited.
Constants Used
ONE_POINT
: Represents one gauge point (1e18
).MAX_GAUGE_POINTS
: The maximum number of gauge points allowed (1000e18
).UPPER_THRESHOLD
: Upper threshold (10001
, representing 100.01%).LOWER_THRESHOLD
: Lower threshold (9999
, representing 99.99%).THRESHOLD_PRECISION
: Threshold precision (10000
).
Function Flow
The function has two main conditional blocks (
if
statements) that determine how to adjust the gauge points based on the percentage of BDV deposited.Function Breakdown
Let's break down each block of the function to understand exactly what it does and when each part is executed.
1. Condition for Reducing Points
1if (2 percentOfDepositedBdv >3 optimalPercentDepositedBdv.mul(UPPER_THRESHOLD).div(THRESHOLD_PRECISION)4)
Explanation:
Compares
percentOfDepositedBdv
with optimalPercentDepositedBdv
adjusted by the UPPER_THRESHOLD
(100.01% of the optimal).Upper Threshold Calculation:
- Multiplies
optimalPercentDepositedBdv
byUPPER_THRESHOLD
(10001). - Divides the result by
THRESHOLD_PRECISION
(10000).
Example:
If
optimalPercentDepositedBdv
is 50:150 × 10001 / 10000 = 50.005
The condition is:
1If percentOfDepositedBdv > 50.005, then it is met.
Action:
1if (currentGaugePoints <= ONE_POINT) return 0;2newGaugePoints = currentGaugePoints.sub(ONE_POINT);
Explanation:
If
currentGaugePoints
is less than or equal to ONE_POINT
, it is set to 0
.Otherwise,
currentGaugePoints
is reduced by ONE_POINT
.2. Condition for Increasing Points
1else if (2 percentOfDepositedBdv <3 optimalPercentDepositedBdv.mul(LOWER_THRESHOLD).div(THRESHOLD_PRECISION)4)
Explanation:
Compares
percentOfDepositedBdv
with optimalPercentDepositedBdv
adjusted by the LOWER_THRESHOLD
(99.99% of the optimal).Lower Threshold Calculation:
- Multiplies
optimalPercentDepositedBdv
byLOWER_THRESHOLD
(9999). - Divides the result by
THRESHOLD_PRECISION
(10000).
Example:
If
optimalPercentDepositedBdv
is 50:[ 50 \times 9999 / 10000 = 49.995 ]
The condition is:
If
If
percentOfDepositedBdv < 49.995
, then it is met.Action:
1newGaugePoints = currentGaugePoints.add(ONE_POINT);2if (newGaugePoints > MAX_GAUGE_POINTS) return MAX_GAUGE_POINTS;
ONE_POINT
is added tocurrentGaugePoints
.- If
newGaugePoints
exceedsMAX_GAUGE_POINTS
, it is set toMAX_GAUGE_POINTS
.
What is the problem with this function?
The
defaultGaugePointFunction
has an issue in handling the adjustment of gauge points when the percentage of Base Deposited Value (BDV) deposited is exactly equal to the optimal percentage (optimalPercentDepositedBdv
). In this case, the function lacks an explicit condition to manage this scenario, which can result in unintended behavior.Detail of the Problem
-
Conditions Handle Inequality: The function has conditions to handle when
percentOfDepositedBdv
is greater thanoptimalPercentDepositedBdv
adjusted by theUPPER_THRESHOLD
, and when it is less thanoptimalPercentDepositedBdv
adjusted by theLOWER_THRESHOLD
. -
Equality Case Not Handled: If
percentOfDepositedBdv
is exactly equal tooptimalPercentDepositedBdv
, neither condition is met, potentially leading to an unintended adjustment of gauge points to 0 instead of maintaining their current value.
What is the solution?
To solve this problem, an explicit condition needs to be added to handle the case where
percentOfDepositedBdv
is equal to optimalPercentDepositedBdv
. This can be achieved by adding an else
clause that returns the currentGaugePoints
unchanged if none of the previous conditions are met.Implementation of the Solution
Add the following condition at the end of the function:
1else {2 return currentGaugePoints;3}
Demonstrating the Problem with a POC
To demonstrate the problem effectively, we wrote two tests: a simple test case and a fuzz test. These tests show that the
defaultGaugePointFunction
has a problem when the percentage of BDV deposited is exactly equal to the optimal percentage.Simple Test Case
This test specifically targets the scenario where
percentOfDepositedBdv
is equal to optimalPercentDepositedBdv
.1function testnew_GaugePointAdjustment() public {2 uint256 currentGaugePoints = 1189;3 uint256 optimalPercentDepositedBdv = 64;4 uint256 percentOfDepositedBdv = 64;56 uint256 newGaugePoints = gaugePointFacet.defaultGaugePointFunction(7 currentGaugePoints,8 optimalPercentDepositedBdv,9 percentOfDepositedBdv10 );1112 assertTrue(newGaugePoints <= MAX_GAUGE_POINTS, "New gauge points exceed the maximum allowed");13 assertEq(newGaugePoints, currentGaugePoints, "Gauge points adjustment does not match expected outcome");14}
Setup:
currentGaugePoints
is set to 1189.optimalPercentDepositedBdv
is set to 64.percentOfDepositedBdv
is set to 64.
Result Verification:
- Expected:
newGaugePoints
should equalcurrentGaugePoints
becausepercentOfDepositedBdv
equalsoptimalPercentDepositedBdv
. - Actual: Without the
else
condition,newGaugePoints
could be 0, which is incorrect.
Fuzz Test
This test uses a range of values to ensure robustness and checks for the correct adjustment of gauge points.
1function testGaugePointAdjustmentUnifiedFuzzing(2 uint256 currentGaugePoints,3 uint256 optimalPercentDepositedBdv,4 uint256 percentOfDepositedBdv5) public {6 currentGaugePoints = bound(currentGaugePoints, 1, MAX_GAUGE_POINTS - 1);7 optimalPercentDepositedBdv = bound(optimalPercentDepositedBdv, 1, 100);8 percentOfDepositedBdv = bound(percentOfDepositedBdv, 1, 100);910 uint256 expectedGaugePoints = currentGaugePoints;1112 if (percentOfDepositedBdv * THRESHOLD_PRECISION > optimalPercentDepositedBdv * UPPER_THRESHOLD) {13 expectedGaugePoints = currentGaugePoints > ONE_POINT ? currentGaugePoints - ONE_POINT : 0;14 } else if (percentOfDepositedBdv * THRESHOLD_PRECISION < optimalPercentDepositedBdv * LOWER_THRESHOLD) {15 expectedGaugePoints = currentGaugePoints + ONE_POINT <= MAX_GAUGE_POINTS ? currentGaugePoints + ONE_POINT : MAX_GAUGE_POINTS;16 }1718 uint256 newGaugePoints = gaugePointFacet.defaultGaugePointFunction(19 currentGaugePoints,20 optimalPercentDepositedBdv,21 percentOfDepositedBdv22 );2324 assertTrue(newGaugePoints <= MAX_GAUGE_POINTS, "New gauge points exceed the maximum allowed");25 assertEq(newGaugePoints, expectedGaugePoints, "Gauge points adjustment does not match expected outcome");26}
Expected Gauge Points Calculation:
-
Initially,
expectedGaugePoints
is set tocurrentGaugePoints
. -
Threshold Check:
- If
percentOfDepositedBdv
is above the upper threshold,expectedGaugePoints
is decreased byONE_POINT
or set to 0. - If
percentOfDepositedBdv
is below the lower threshold,expectedGaugePoints
is increased byONE_POINT
, capped atMAX_GAUGE_POINTS
.
- If
Assertions:
- Ensures
newGaugePoints
does not exceedMAX_GAUGE_POINTS
. - Verifies
newGaugePoints
matchesexpectedGaugePoints
.
Test Results
To run the tests correctly, follow these steps:
1export FORKING_RPC=https://eth-mainnet.g.alchemy.com/v2/{API}2forge test --mc GaugePointFacetTest --mt testGaugesssPointAdjustmentUnifiedFuzzing -vvv
1[FAIL. Reason: assertion failed; counterexample: calldata=0xdace5ffa0000000000000000000000000465ff2e9c9fd7f2b5a78cfaa0671d046c517d5d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 args=[25110567842437950750745261937466550563734912349 [2.511e46], 0, 1]] testGaugesssPointAdjustmentUnifiedFuzzing(uint256,uint256,uint256) (runs: 0, μ: 0, ~: 0)2Logs:3 Bound Result 5053089885144856827214 Bound Result 15 Bound Result 16 Error: Gauge points adjustment does not match expected outcome7 Error: a == b not satisfied [uint]8 Left: 09 Right: 5053089885144856827211011Traces:12 [30286] DefaultTestContract::testGaugesssPointAdjustmentUnifiedFuzzing(25110567842437950750745261937466550563734912349 [2.511e46], 0, 1)13 ├─ [0] console::log("Bound Result", 505308988514485682721 [5.053e20]) [staticcall]14 │ └─ ← [Stop]15 ├─ [0] console::log("Bound Result", 1) [staticcall]16 │ └─ ← [Stop]17 ├─ [0] console::log("Bound Result", 1) [staticcall]18 │ └─ ← [Stop]19 ├─ [826] GaugePointFacet::defaultGaugePointFunction(505308988514485682721 [5.053e20], 1, 1) [staticcall]20 │ └─ ← [Return] 021 ├─ emit log_named_string(key: "Error", val: "Gauge points adjustment does not match expected outcome")22 ├─ emit log(val: "Error: a == b not satisfied [uint]")23 ├─ emit log_named_uint(key: " Left", val: 0)24 ├─ emit log_named_uint(key: " Right", val: 505308988514485682721 [5.053e20])25 ├─ [0] VM::store(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 0x6661696c65640000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000001)26 │ └─ ← [Return]27 └─ ← [Stop]2829Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 9.52ms (3.61ms CPU time)
Final Bug Bounty Reward
Thank you for reading to the end (Or skipping here to know how much we received 😝).
In the end, we received a total of 8223.41 USDC. We had a great time working on it and the financial rewards were just one of the benefits.
Before we sign off, we encourage you to carefully study and research certain resources that are incredibly helpful for learning about smart contract auditing, fuzz testing, smart contract development, and the application of smart contract audit tools, among other things.
We have provided them in the table below.