Summary
Current implementation of setting merkle root is unsafe because it allows owner to change the root any time (before the vesting duration is over) which could potentially invalidate in-progress claims.
Vulnerability Details
In the VestedAirdrop::set_merkle_root
function, the owner can change the merkle root at any time:
def set_merkle_root(merkle_root: bytes32):
"""
@notice This function is used to set the merkle root
@param merkle_root bytes32, the new merkle root
@dev This function can only be called by the owner
"""
self.onlyOwner()
self.merkle_root = merkle_root
log MerkleRootUpdated(merkle_root)
With this implementation, there is no mechanism to prevent malicious or erroneous updates to the merkle root which may invalidate legitimate claims and allow unauthorized claims.
Proof of Concept
Here is a test that presents a case where updating the merkle root can invalidate in-progress claims:
def test_merkle_root_update_vulnerability(self):
"""
Demonstrates how updating merkle root can invalidate legitimate claims
"""
self.airdrop.claim(self.user1, self.amount, self.proof, sender=self.user1)
initial_claim = self.token.balanceOf(self.user1)
assert initial_claim >= (self.amount * 31) // 100
malicious_root = bytes.fromhex(
"1234567890123456789012345678901234567890123456789012345678901234"
)
owner = self.airdrop.owner()
with boa.env.prank(owner):
self.airdrop.set_merkle_root(malicious_root)
vesting_end = self.airdrop.vesting_end_time()
warp(vesting_end + 1)
with boa.reverts("Invalid proof"):
self.airdrop.claim(self.user1, self.amount, self.proof, sender=self.user1)
final_balance = self.token.balanceOf(self.user1)
assert final_balance == initial_claim
assert (
final_balance < self.amount
)
Impact
While there are legitimate reasons for having the functionality to be able to reset the merkle root (such as contract resuability) the current implementation has no protection for already claimed amounts and has no timelock or governance controls. A premature or malicious reset of the merkle root can cause users to lose their existing proofs and as a result they cannot claim their remaining tokens.
Tools Used
Manual inspection, copilot and moccasin.
Recommendations
Implementing a timelock or "no updates after claims start" mechanism that respects the vesting duration would make the contract more secure:
# ...existing code...
# Add state variables
merkle_update_timelock: public(uint256)
TIMELOCK_DELAY: constant(uint256) = 7 * 24 * 60 * 60
proposed_merkle_root: public(bytes32)
has_claims: public(bool)
@external
def propose_merkle_root(new_root: bytes32):
"""
@notice Propose a new merkle root with timelock
"""
self.onlyOwner()
assert not self.has_claims, "Cannot update after claims started"
self.proposed_merkle_root = new_root
self.merkle_update_timelock = block.timestamp + TIMELOCK_DELAY
@external
def execute_merkle_root_update():
"""
@notice Execute the proposed merkle root update after timelock
"""
self.onlyOwner()
assert block.timestamp >= self.merkle_update_timelock, "Timelock not expired"
assert self.proposed_merkle_root != empty(bytes32), "No proposed root"
assert not self.has_claims, "Cannot update after claims started"
self.merkle_root = self.proposed_merkle_root
self.proposed_merkle_root = empty(bytes32)
log MerkleRootUpdated(self.merkle_root)
@external
def claim(user: address, total_amount: uint256, proof: DynArray[bytes32, 20]) -> bool:
# ...existing claim code...
self.has_claims = True
# ...rest of existing code...
This improved version requires a timelock delay, prevents premature updates and still allows contract reuse for new distribution phases. Additional protection where partial-claim history can be stored and verified could be a nice improvement so that a root update does not wipe out users' partial progress.