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

`MondrianWallet2::payForTransaction` lacks access control, allowing a malicious actor to block a transaction by draining the contract prior to validation.

MondrianWallet2::payForTransaction lacks access control, allowing a malicious actor to block a transaction by draining the contract prior to validation.

Description: According to the ZKsync documentation, the payForTransaction function is meant to be called only by the Bootloader to collect fees necessary to execute transactions.

However, because an access control is missing in MondrianWallet2::payForTransaction anyone can call the function. There is also no check on how often the function is called.

This allows a malicious actor to observe the transaction in the mempool and use its data to repeatedly call payForTransaction. It results in moving funds from MondrianWallet2 to the ZKSync Bootloader.

@> function payForTransaction(bytes32, /*_txHash*/ bytes32, /*_suggestedSignedHash*/ Transaction memory _transaction)
external
payable
{

Impact: When funds are moved from the MondrianWallet2 to the ZKSync Bootloader, MondrianWallet2::validateTransaction will fail due to lack of funds. Also, when the bootloader itself eventually calls payForTransaction to retrieve funds, this function will fail.

In effect, the lack of access controls on MondrianWallet2::payForTransaction allows for any transaction to be blocked by a malicious user.

Please note that there is a refund of unused fees on ZKsync. As such, it is likely that MondrianWallet2 will eventually receive a refund of its fees. However, it is likely a refund will only happen after the transaction has been declined.

Proof of Concept:
Due to limits in the toolchain used (foundry) to test the ZKSync blockchain, it was not possible to obtain a fine grained understanding of how the bootloader goes through the life cycle of a 113 type transaction. It made it impossible to create a true Proof of Concept of this vulnerability. What follows is as close as possible approximation using foundry's standard test suite.

The sequence:

  1. Normal user A creates a transaction.

  2. Malicious user B observes the transaction.

  3. Malicious user B calls MondrianWallet2::payForTransaction until mondrianWallet2.balance < transaction.maxFeePerGas * transaction.gasLimit.

  4. The bootloader calls MondrianWallet::validateTransaction.

  5. MondrianWallet::validateTransaction fails because of lack of funds.

Proof of Concept

Place the following in ModrianWallet2Test.t.sol.

// You'll also need --system-mode=true to run this test
function testBlockTransactionByPayingForTransaction() public onlyZkSync {
// Prepare
uint256 FUNDS_MONDRIAN_WALLET = 1e16;
vm.deal(address(mondrianWallet), FUNDS_MONDRIAN_WALLET);
address THIRD_PARTY_ACCOUNT = makeAddr("3rdParty");
// create transaction
address dest = address(usdc);
uint256 value = 0;
bytes memory functionData = abi.encodeWithSelector(ERC20Mock.mint.selector, address(mondrianWallet), AMOUNT);
Transaction memory transaction = _createUnsignedTransaction(mondrianWallet.owner(), 113, dest, value, functionData);
transaction = _signTransaction(transaction);
// using information embedded in the Transaction struct, we can calculate how much fee will be paid for the transaction
// and, crucially, how many runs we need to move sufficient funds from the Mondrian Wallet to the Bootloader until mondrianWallet2.balance < transaction.maxFeePerGas * transaction.gasLimit.
uint256 feeAmountPerTransaction = transaction.maxFeePerGas * transaction.gasLimit;
uint256 runsNeeded = FUNDS_MONDRIAN_WALLET / feeAmountPerTransaction;
console2.log("runsNeeded to drain Mondrian Wallet:", runsNeeded);
// Act
// by calling payForTransaction a sufficient amount of times, the contract is drained.
vm.startPrank(THIRD_PARTY_ACCOUNT);
for (uint256 i; i < runsNeeded; i++) {
mondrianWallet.payForTransaction(EMPTY_BYTES32, EMPTY_BYTES32, transaction);
}
vm.stopPrank();
// Act & Assert
// When the bootloader calls validateTransaction, it fails: Not Enough Balance.
vm.prank(BOOTLOADER_FORMAL_ADDRESS);
vm.expectRevert(MondrianWallet2.MondrianWallet2__NotEnoughBalance.selector);
bytes4 magic = mondrianWallet.validateTransaction(EMPTY_BYTES32, EMPTY_BYTES32, transaction);
}

Recommended Mitigation: Add an access control to the MondrianWallet2::payForTransaction function, allowing only the bootloader to call the function.

function payForTransaction(bytes32, /*_txHash*/ bytes32, /*_suggestedSignedHash*/ Transaction memory _transaction)
external
payable
+ requireFromBootLoader
Updates

Lead Judging Commences

bube Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of access control in payForTransaction function

Support

FAQs

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