branch_indigo - Existing RecipientId Status can be Overwritten in DonationVoting Strategy, causing loss of allocation funds for accepted recipients #952
Existing RecipientId Status can be Overwritten in DonationVoting Strategy, causing loss of allocation funds for accepted recipients
In DonationVotingMerkleDistributionBaseStrategy.sol, a registered recipient status can be easily overwritten by a new recipient due to incorrect logic of current status retrieval and recipient counter update.
As a result, an existing recipientId status can be accidentally rewritten during a new recipient registration process. The existing recipientId will lose the allocation funds if their status has been accepted and then subsequently overwritten. This process can also be maliciously exploited to intentionally cause recipients to lose on allocation funds.
Vulnerability Detail
In DonationVotingMerkleDistributionBaseStrategy.sol, a recipient status is stored in bitmap and the correct fetching of their status depends on getting the correct index of bitmap. However, this is not implemented correctly in _registerRecipient().
_registerRecipient() allows both new registration of recipients and status updates of existing recipients. If the caller is a new recipient, their recipientId will be registered and their status will be Pending. If the caller is an existing recipient, their existing status can be updated from Accepted to Pending, or Rejected to Appealed. The way _registerRecipient() determines whether the caller is a new or existing recipient is to check their currentStatus in the bitmap, if None then it determines the call is a new registration, otherwise it determines the call from an existing register.
There are two problems at work here: (1) currentStatus will not be fetched correctly if there is at least one existing recipient before current registration; (2) When the first-ever recipient registers, their recipientsCounter will be '0', when combined with (1), will allow subsequent recipients to overwrite the first recipient's status.
Specifically, when a new caller registers, whether a RegistryAnchor is used or not, _getUintRecipientStatus() will be called to fetch their status, which under the hood calls _getStatusRowColumn(). This function directly accesses recipientToStatusIndexes mapping with _recipientId and this will return the default '0' as recipientIndex when the caller is new. This tells _getUnitRecipientStatus() to get the status from the bitmap at location '0'. When this is the first-ever registration, location '0' will return Status.None, this will correctly direct _regiterRecipient() to identify the call as a new registration and update recipientToStatusIndexes[recipientId] to '0' - the default value for recipientsCounter. And then set the status at location '0' to Status.Pending.
//DonationVotingMerkleDistributionBaseStrategy.sol
function _getUintRecipientStatus(address _recipientId) internal view returns (uint8 status) {
// Get the column index and current row
(, uint256 colIndex, uint256 currentRow) = _getStatusRowColumn(_recipientId);
// Get the status from the 'currentRow' shifting by the 'colIndex'
status = uint8((currentRow >> colIndex) & 15);
// Return the status
return status;
}
However, when the next caller calls to register. Since their recipientIdis new, recipientToStatusIndexes mapping will also return default '0' as their index which directs _getUnitRecipientStatus() will again look at the status at location '0' - now the first recipient's status. This is incorrect. And if the first recipient's status is still Status.Pending. The bitmap will not update. But if the first recipient's status has been upgraded to Status.Accepted by the pool manager. This will redirect _registerRecipient() to mistakenly identify the call as an existing registration and will change Status.Accepted to Status.Pending, effectively overwriting the previous recipient's status.
This mistake will disqualify the first recipient's funds allocation. As we see, in _allocate there is a check to ensure that only if a recipient's status is Accepted can they receive allocation funds. In DonationVotingMerkleDistributionDirectTransferStrategy.sol ,the actual funds transfer takes place in _afterAllocate() hook, which means when an accepted recipient will lose on allocation funds.
//DonationVotingMerkleDistributionBaseStrategy.sol
function _allocate(bytes memory _data, address _sender) internal virtual override onlyActiveAllocation {
...
// If the recipient status is not 'Accepted' this will revert, the recipient must be accepted through registration
if (Status(_getUintRecipientStatus(recipientId)) != Status.Accepted) {
revert RECIPIENT_ERROR(recipientId);
}
...
Here are (2) POC tests:
(1) Shows a previously accepted recipient status gets overwritten by a new registration.
Running 1 test for test/foundry/strategies/DonationVotingMerkleDistributionBase.t.sol:DonationVotingMerkleDistributionBaseMockTest
[PASS] test_register_second_time_overwrite_first_recipient_status() (gas: 314716)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 26.42ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
(2) Shows in the context of DonationVotingMerkleDistributionDirectTransferStrategy.sol, a previously accepted recipient's status is overwritten and loss of allocation funds. There are two tests - a success and a failure case for comparison.
//test/foundry/strategies/DonationVotingMerkleDistributionDirectTransferStrategy.t.sol
//Success case
function test_allocate_1st_recipient_allocation_succeed() public {
uint256 balanceBefore = recipientAddress().balance;
//1st recipient register and gets accepted
address recipientId = __register_accept_recipient();
IStrategy.Status recipientStatus = strategy.getRecipientStatus(recipientId);
assertEq(uint8(recipientStatus), uint8(IStrategy.Status.Accepted));
//randomAddress allocate 1e18 to 1st recipient
DonationVotingMerkleDistributionBaseStrategy.Permit2Data memory permit2Data =
DonationVotingMerkleDistributionBaseStrategy.Permit2Data({
permit: ISignatureTransfer.PermitTransferFrom({
permitted: ISignatureTransfer.TokenPermissions({token: NATIVE, amount: 1e18}),
nonce: 0,
deadline: allocationStartTime + 10000
}),
signature: ""
});
vm.warp(allocationStartTime + 1);
vm.deal(randomAddress(), 1e18);
vm.prank(randomAddress());
vm.expectEmit(false, false, false, true);
emit Allocated(recipientId, 1e18, NATIVE, randomAddress());
allo().allocate{value: 1e18}(poolId, abi.encode(recipientId, permit2Data));
uint256 balanceAfter = recipientAddress().balance;
//1st recipient received 1e18 allocation
assertEq(balanceAfter - balanceBefore, 1e18);
}
//Fail case
function test_allocate_1st_recipient_accepted_2nd_recipient_overwrrite_allocation_failed() public {
uint256 balanceBefore = recipientAddress().balance;
//1st recipient register and gets accepted
address recipientId = __register_accept_recipient();
IStrategy.Status recipientStatus = strategy.getRecipientStatus(recipientId);
assertEq(uint8(recipientStatus), uint8(IStrategy.Status.Accepted));
//2nd recipient register
address recipientId2 = __register_recipient2();
recipientStatus = strategy.getRecipientStatus(recipientId);
//1st recipient status was overwritten by 2nd recipient. status: accepted->pending.
assertEq(uint8(recipientStatus), uint8(IStrategy.Status.Pending));
//2nd recipient status was in fact the 1st recipient status- also pending.
IStrategy.Status recipientStatus2 = strategy.getRecipientStatus(recipientId2);
assertEq(uint8(recipientStatus), uint8(IStrategy.Status.Pending));
//randomAddress allocate 1e18 to 1st recipient but Reverted due to 1st recipient status was overwritten
DonationVotingMerkleDistributionBaseStrategy.Permit2Data memory permit2Data =
DonationVotingMerkleDistributionBaseStrategy.Permit2Data({
permit: ISignatureTransfer.PermitTransferFrom({
permitted: ISignatureTransfer.TokenPermissions({token: NATIVE, amount: 1e18}),
nonce: 0,
deadline: allocationStartTime + 10000
}),
signature: ""
});
vm.warp(allocationStartTime + 1);
vm.deal(randomAddress(), 1e18);
vm.prank(randomAddress());
//allocation reverted
vm.expectRevert(abi.encodePacked(RECIPIENT_ERROR.selector, uint256(uint160(recipientId))));
allo().allocate{value: 1e18}(poolId, abi.encode(recipientId, permit2Data));
}
Running 2 tests for test/foundry/strategies/DonationVotingMerkleDistributionDirectTransferStrategy.t.sol:DonationVotingMerkleDistributionDirectTransferStrategyTest
[PASS] test_allocate_1st_recipient_accepted_2nd_recipient_overwrrite_allocation_failed() (gas: 376623)
[PASS] test_allocate_1st_recipient_allocation_succeed() (gas: 303662)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 26.35ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
Impact
As detailed above, existing accepted recipients eligible for rewards will lose their allocation funds due to status overwritten by new registration. In addition, a malicious user can repeatedly register new recipients to intentionally overwrite the existing eligible recipient causing a loss of funds. I think this is high severity, due to the easy and cheap attack, and the damage can be done also accidentally causing loss of funds and unfair review and allocation process.
In _registerRecipient, consider increment recipientsCounter before assigning it to recipientToStatusIndexes such that the first valid index would be 1 instead of 0. This way '0' location in the bitmap will only be accessed for identifying new registrants.
branch_indigo
high
Existing RecipientId Status can be Overwritten in DonationVoting Strategy, causing loss of allocation funds for accepted recipients
In
DonationVotingMerkleDistributionBaseStrategy.sol
, a registered recipient status can be easily overwritten by a new recipient due to incorrect logic of current status retrieval and recipient counter update.As a result, an existing
recipientId
status can be accidentally rewritten during a new recipient registration process. The existingrecipientId
will lose the allocation funds if their status has been accepted and then subsequently overwritten. This process can also be maliciously exploited to intentionally cause recipients to lose on allocation funds.Vulnerability Detail
In
DonationVotingMerkleDistributionBaseStrategy.sol
, a recipient status is stored in bitmap and the correct fetching of their status depends on getting the correct index of bitmap. However, this is not implemented correctly in_registerRecipient()
._registerRecipient()
allows both new registration of recipients and status updates of existing recipients. If the caller is a new recipient, theirrecipientId
will be registered and their status will bePending
. If the caller is an existing recipient, their existing status can be updated fromAccepted
toPending
, orRejected
toAppealed
. The way_registerRecipient()
determines whether the caller is a new or existing recipient is to check theircurrentStatus
in the bitmap, ifNone
then it determines the call is a new registration, otherwise it determines the call from an existing register.There are two problems at work here: (1)
currentStatus
will not be fetched correctly if there is at least one existing recipient before current registration; (2) When the first-ever recipient registers, theirrecipientsCounter
will be '0', when combined with (1), will allow subsequent recipients to overwrite the first recipient's status.Specifically, when a new caller registers, whether a
RegistryAnchor
is used or not,_getUintRecipientStatus()
will be called to fetch their status, which under the hood calls_getStatusRowColumn()
. This function directly accessesrecipientToStatusIndexes
mapping with_recipientId
and this will return the default '0' asrecipientIndex
when the caller is new. This tells_getUnitRecipientStatus()
to get the status from the bitmap at location '0'. When this is the first-ever registration, location '0' will returnStatus.None
, this will correctly direct_regiterRecipient()
to identify the call as a new registration and updaterecipientToStatusIndexes[recipientId]
to '0' - the default value forrecipientsCounter
. And then set the status at location '0' toStatus.Pending
.However, when the next caller calls to register. Since their
recipientId
is new,recipientToStatusIndexes
mapping will also return default '0' as their index which directs_getUnitRecipientStatus()
will again look at the status at location '0' - now the first recipient's status. This is incorrect. And if the first recipient's status is stillStatus.Pending
. The bitmap will not update. But if the first recipient's status has been upgraded toStatus.Accepted
by the pool manager. This will redirect_registerRecipient()
to mistakenly identify the call as an existing registration and will changeStatus.Accepted
toStatus.Pending
, effectively overwriting the previous recipient's status.This mistake will disqualify the first recipient's funds allocation. As we see, in
_allocate
there is a check to ensure that only if a recipient's status isAccepted
can they receive allocation funds. InDonationVotingMerkleDistributionDirectTransferStrategy.sol
,the actual funds transfer takes place in_afterAllocate()
hook, which means when an accepted recipient will lose on allocation funds.Here are (2) POC tests: (1) Shows a previously accepted recipient status gets overwritten by a new registration.
(2) Shows in the context of
DonationVotingMerkleDistributionDirectTransferStrategy.sol
, a previously accepted recipient's status is overwritten and loss of allocation funds. There are two tests - a success and a failure case for comparison.Impact
As detailed above, existing accepted recipients eligible for rewards will lose their allocation funds due to status overwritten by new registration. In addition, a malicious user can repeatedly register new recipients to intentionally overwrite the existing eligible recipient causing a loss of funds. I think this is high severity, due to the easy and cheap attack, and the damage can be done also accidentally causing loss of funds and unfair review and allocation process.
Code Snippet
https://github.com/allo-protocol/allo-v2/blob/0b881ef4a0013d2809374c9ea69f4cf1288dfe62/contracts/strategies/donation-voting-merkle-base/DonationVotingMerkleDistributionBaseStrategy.sol#L580-L597
Tool used
Manual Review
Recommendation
In
_registerRecipient
, consider incrementrecipientsCounter
before assigning it torecipientToStatusIndexes
such that the first valid index would be 1 instead of 0. This way '0' location in the bitmap will only be accessed for identifying new registrants.Duplicate of #199