30598 - [SC - Low] Access Control Flaw in _burn Function Leads to ...

Submitted on May 1st 2024 at 19:08:15 UTC by @Limbooo for Boost | Alchemix

Report ID: #30598

Report type: Smart Contract

Report severity: Low

Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/VotingEscrow.sol

Impacts:

  • Contract fails to deliver promised returns, but doesn't lose value

  • Temporary freezing of NFTs

Description

Brief/Intro

The vulnerability in the VotingEscrow contract arises due to an access control flaw within the _burn function, which inappropriately handles approval resetting, requiring broader permissions than necessary. This flaw disrupts operations such as token merging and withdrawals, as transactions initiated by users who are only approved for specific token IDs (and not globally) revert unexpectedly. On mainnet, this could lead to operational disruptions, preventing users from consolidating voting power or accessing their funds post-lock period, thereby undermining user trust and the functionality of the contract. Such issues could significantly impact user engagement and the platform's overall reliability.

Vulnerability Details

Lines of Code

alchemix-v2-dao/src/VotingEscrow.sol #L1567[^1]

src/VotingEscrow.sol:
1558      function _burn(uint256 _tokenId, uint256 _value) internal {
..SNIP..
1566:         // Clear approval
1567:         approve(address(0), _tokenId);

In VotingEscrow.sol contracts, there are multiple functions that allow whether the msg.sender is approved for the given token ID, is an operator of the owner, or is the owner of the token by utilizing _isApprovedOrOwner function.

src/VotingEscrow.sol:
618      function merge(uint256 _from, uint256 _to) external {
..SNIP..
621:         require(_isApprovedOrOwner(msg.sender, _from), "not approved or owner");
622:         require(_isApprovedOrOwner(msg.sender, _to), "not approved or owner");
..SNIP..
714      function updateUnlockTime(uint256 _tokenId, uint256 _lockDuration, bool _maxLockEnabled) external nonreentrant {
715:         require(_isApprovedOrOwner(msg.sender, _tokenId), "not approved or owner");
..SNIP..
741      function withdraw(uint256 _tokenId) public nonreentrant {
742:         require(_isApprovedOrOwner(msg.sender, _tokenId), "not approved or owner");
..SNIP..
778      function startCooldown(uint256 _tokenId) external {
779:         require(_isApprovedOrOwner(msg.sender, _tokenId), "not approved or owner");
..SNIP..
826:     function _isApprovedOrOwner(address _spender, uint256 _tokenId) internal view returns (bool) {
827          address owner = idToOwner[_tokenId];
828          bool spenderIsOwner = owner == _spender;
829          bool spenderIsApproved = _spender == idToApprovals[_tokenId];
830          bool spenderIsApprovedForAll = (ownerToOperators[owner])[_spender];
831          return spenderIsOwner || spenderIsApproved || spenderIsApprovedForAll;
832      }
..SNIP..
928      function _transferFrom(address _from, address _to, uint256 _tokenId, address _sender) internal {
..SNIP..
931:         require(_isApprovedOrOwner(_sender, _tokenId));
932:         require(idToOwner[_tokenId] == _from, "from address is not owner");

However, two of these functions are in question in this report; merge and withdraw, since the _burn function is used.

The _burn function within the VotingEscrow contract is designed to remove tokens from circulation by clearing approvals and performing other state updates. However, due to the way access control is implemented, only the token owner or an entity approved for all user tokens can successfully execute this function without causing a revert. This is because the function internally calls approve(address(0), _tokenId), which checks for broader permissions than those granted to entities approved for a single token.

Impact Details

This results in operational disruptions for users who are legitimately authorized to perform actions (like merge and withdraw) that rely on _burn. They may encounter transaction reverts, leading to:

  • Inability to merge tokens for voting power consolidation or management.

  • Failed attempts to withdraw tokens post-lock period, leading to user dissatisfaction and potential disruption in planned economic activities within the platform.

  • Consider refactoring approval logic in _burn, Modify the internal logic of the _burn function to handle cases where the caller is only approved for the specific token ID being burned. This could involve bypassing the approval reset or adjusting the approval check to recognize and allow this scenario.

  • Consider standardizing the approval reset process to use _clearApproval, especially in contexts where specific token approval is sufficient. This would align the behavior across different contract functions and improve the reliability of operations involving token transfers, burns, or modifications.

References

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1567

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L501C1-L514C6

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L912C5-L919C6

Proof of Concept

Consider a scenario where a user has received specific approval for token ID 123 to facilitate a token merge or withdrawal. Under the current implementation, if this user attempts to initiate these actions, the transaction will revert during the _burn call due to the internal approve function not recognizing their limited approval as sufficient.

src/VotingEscrow.sol:
  501      function approve(address _approved, uint256 _tokenId) public {
..SNIP..
  507          // Check requirements
  508          bool senderIsOwner = (owner == msg.sender);
  509          bool senderIsApprovedForAll = (ownerToOperators[owner])[msg.sender];
  510:         require(senderIsOwner || senderIsApprovedForAll, "sender is not owner or approved");

This scenario can be replicated in a test environment to demonstrate the failure mechanism and its impact on contract usability.

Test Case (Foundry)

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;

import "./BaseTest.sol";

contract VotingEscrowPoC is BaseTest {
    uint256 internal constant THREE_WEEKS = 3 weeks;

    function setUp() public {
        setupContracts(block.timestamp);
    }

    // Withdraw reverts for token approved address
    function testWithdrawReversForTokenApprovedAddress() public {
        // Create address for Alice
        address alice = address(0x00001);

        // Create new token for Alice
        uint256 tokenId = createVeAlcx(alice, TOKEN_1, THREE_WEEKS, false);

        // alice start interacions
        hevm.startPrank(alice);

        // Reset the token status 
        voter.reset(tokenId);
        // Finish the epoch
        hevm.warp(newEpoch());
        voter.distribute();

        // Start cooldown once lock is expired
        veALCX.startCooldown(tokenId);
        // Go to the next epoch
        hevm.warp(newEpoch());

        // Now the token is withdrawable
        // Alice approve admin address on `tokenId`
        veALCX.approve(admin, tokenId);

        hevm.stopPrank();

        hevm.startPrank(admin);
        // Make sure admin is approved for alice token
        assertTrue(veALCX.isApprovedOrOwner(admin, tokenId));

        // Now if Admin try to withdraw, 
        // the call will revert with message that suggest the caller is not owner or approved.
        // While Admin is approved for the specific tokenId.
        hevm.expectRevert(abi.encodePacked("sender is not owner or approved"));
        veALCX.withdraw(tokenId);

        // This happen because the underline function `_burn` call `approve` with zero address to clear specific token approvals,
        // which revert if the caller not owner nor approved for all.
        hevm.expectRevert(abi.encodePacked("sender is not owner or approved"));
        veALCX.approve(address(0), tokenId);

        // However, Admin can use transferFrom function and then withdraw 
        veALCX.transferFrom(alice, admin, tokenId);
        veALCX.withdraw(tokenId);

        hevm.stopPrank();
    }
}

Test Output

alchemix-v2-dao main 1m38s
 make test_file_test
FOUNDRY_PROFILE=default forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/*** --match-path src/test/VotingEscrowPoC.t.sol --match-test testWithdrawReversForTokenApprovedAddress -vv
[⠊] Compiling...
No files changed, compilation skipped

Ran 1 test for src/test/VotingEscrowPoC.t.sol:VotingEscrowPoC
[PASS] testWithdrawReversForTokenApprovedAddress() (gas: 7657391)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 90.57s (77.12s CPU time)

Ran 1 test suite in 91.85s (90.57s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Last updated