Platform: Code4rena
Start Date: 13/01/2022
Pot Size: $75,000 USDC
Total HM: 9
Participants: 27
Period: 7 days
Judge: leastwood
Total Solo HM: 5
Id: 73
League: ETH
Rank: 2/27
Findings: 10
Award: $11,308.96
🌟 Selected for report: 1
🚀 Solo Findings: 2
🌟 Selected for report: WatchPug
Also found by: Ruhum, gzeon, harleythedog
20.7302 LPT - $768.68
2113.9229 USDC - $2,113.92
gzeon
migrateETH
does not send the withdrawn ETH to L2 causing fund to stuck in the L1Migrator
contract.
When migrateETH
is called, it would withdraw all ETH from bridgeMinter
, and then use sendTxToL2
create a L2 retryable ticket to call the L2Migrator
with _l1CallValue
set to msg.value
and _l2CallValue
set to the ETH balance withdrew.
function withdrawETHToL1Migrator() external onlyL1Migrator returns (uint256) { uint256 balance = address(this).balance; // call() should be safe from re-entrancy here because the L1Migrator and l1MigratorAddr are trusted (bool ok, ) = l1MigratorAddr.call.value(address(this).balance)(""); require(ok, "BridgeMinter#withdrawETHToL1Migrator: FAIL_CALL"); return balance; }
function migrateETH( uint256 _maxGas, uint256 _gasPriceBid, uint256 _maxSubmissionCost ) external payable whenNotPaused { uint256 amount = IBridgeMinter(bridgeMinterAddr) .withdrawETHToL1Migrator(); // Any ETH refunded to the L2 alias of this contract can be used for // other cross-chain txs sent by this contract. // The retryable ticket created will not be cancellable since this contract // currently does not support cross-chain txs to call ArbRetryableTx.cancel(). // Regarding the comment below on this contract receiving refunds: // msg.sender also cannot be the address to receive refunds as beneficiary because otherwise // msg.sender could cancel the ticket before it is executed on L2 to receive the L2 call value. sendTxToL2( l2MigratorAddr, address(this), // L2 alias of this contract will receive refunds msg.value, amount, _maxSubmissionCost, _maxGas, _gasPriceBid, "" ); }
The sendTxToL2
function create a L2 retryable ticket using _l1CallValue
as call value. The ETH in _l2CallValue
is not transfered to the inbox contract.
function sendTxToL2( address target, address from, uint256 _l1CallValue, uint256 _l2CallValue, uint256 maxSubmissionCost, uint256 maxGas, uint256 gasPriceBid, bytes memory data ) internal returns (uint256) { uint256 seqNum = inbox.createRetryableTicket{value: _l1CallValue}( target, _l2CallValue, maxSubmissionCost, from, from, maxGas, gasPriceBid, data ); emit TxToL2(from, target, seqNum, data); return seqNum; }
Since the _l2CallValue
(i.e. the ETH withdrew from bridgeMinter
) was never sent to the inbox, it would stuck in the L1 migrator contract and the L2 call would likely revert due to insufficient call value.
The call value should be _l1CallValue + _l2CallValue
#0 - yondonfu
2022-01-23T00:49:31Z
🌟 Selected for report: Ruhum
Also found by: gzeon, harleythedog
9.2134 LPT - $341.63
939.5213 USDC - $939.52
gzeon
Anyone can call migrateETH
and migrateLPT
in L1Migrator
with arbitrary _maxSubmissionCost
. For example when migrateLPT
is called, it would withdraw all LPT from bridgeMinter
, and then create a L2 retryable ticket to call the L2Migrator
using _maxSubmissionCost
supplied by the caller.
function migrateLPT( uint256 _maxGas, uint256 _gasPriceBid, uint256 _maxSubmissionCost ) external payable whenNotPaused { uint256 amount = IBridgeMinter(bridgeMinterAddr) .withdrawLPTToL1Migrator(); // Approve L1LPTGateway to pull tokens ApproveLike(tokenAddr).approve(l1LPTGatewayAddr, amount); // Trigger cross-chain transfer with L1LPTGateway which will pull and escrow tokens // Forward msg.value to outboundTransfer() to be used for cross-chain tx IL1LPTGateway(l1LPTGatewayAddr).outboundTransfer{value: msg.value}( tokenAddr, l2MigratorAddr, amount, _maxGas, _gasPriceBid, abi.encode(_maxSubmissionCost, "") ); }
If the caller supplied a very low (e.g. 0) _maxSubmissionCost
, the call would succeed on L1, but would fail the retryable ticket creation on L2.
https://developer.offchainlabs.com/docs/l1_l2_messages#important-note-about-base-submission-fee
If an L1 transaction underpays for a retryable ticket's base submission free, the retryable ticket creation on L2 simply fails. Given that this potentially breaks the atomicity of the L1 / L2 transactions, applications should avoid this scenario.
Since both migrateETH
and migrateLPT
are permissionless, an attacker can freeze the fund by calling the functions with _maxSubmissionCost=0.
Use ArbRetryableTx.getSubmissionPrice
provided by Arbitrum L1 contract instead of using user provided _maxSubmissionCost
.
#0 - yondonfu
2022-01-23T21:13:22Z
15.3557 LPT - $569.39
1565.8688 USDC - $1,565.87
gzeon
L1Migrator
can call withdrawETHToL1Migrator
to withdraw ETH from BridgeMinter
, but L1Migrator
does not have a payable receive function so the call will revert.
function withdrawETHToL1Migrator() external onlyL1Migrator returns (uint256) { uint256 balance = address(this).balance; // call() should be safe from re-entrancy here because the L1Migrator and l1MigratorAddr are trusted (bool ok, ) = l1MigratorAddr.call.value(address(this).balance)(""); require(ok, "BridgeMinter#withdrawETHToL1Migrator: FAIL_CALL"); return balance; }
Add a payable receive function
#0 - yondonfu
2022-01-23T00:51:58Z
🌟 Selected for report: gzeon
34.1238 LPT - $1,265.31
3479.7085 USDC - $3,479.71
gzeon
Fund can be lost if the L1 call value provided is insufficient to cover _maxSubmissionCost
, or stuck if insufficient to cover _maxSubmissionCost + (_maxGas * _gasPriceBid)
.
outboundTransfer
in L1LPTGateway
does not check if the call value is sufficient, if it is < _maxSubmissionCost
the retryable ticket creation will fail and fund is lost; if it is <_maxSubmissionCost + (_maxGas * _gasPriceBid)
the ticket would require manual execution.
function outboundTransfer( address _l1Token, address _to, uint256 _amount, uint256 _maxGas, uint256 _gasPriceBid, bytes calldata _data ) external payable override whenNotPaused returns (bytes memory) { require(_l1Token == l1Lpt, "TOKEN_NOT_LPT"); // nested scope to avoid stack too deep errors address from; uint256 seqNum; bytes memory extraData; { uint256 maxSubmissionCost; (from, maxSubmissionCost, extraData) = parseOutboundData(_data); require(extraData.length == 0, "CALL_HOOK_DATA_NOT_ALLOWED"); // transfer tokens to escrow TokenLike(_l1Token).transferFrom(from, l1LPTEscrow, _amount); bytes memory outboundCalldata = getOutboundCalldata( _l1Token, from, _to, _amount, extraData ); seqNum = sendTxToL2( l2Counterpart, from, maxSubmissionCost, _maxGas, _gasPriceBid, outboundCalldata ); } emit DepositInitiated(_l1Token, from, _to, seqNum, _amount); return abi.encode(seqNum); }
Add check similar to the one used in L1GatewayRouter
provided by Arbitrum team
uint256 expectedEth = _maxSubmissionCost + (_maxGas * _gasPriceBid); require(_maxSubmissionCost > 0, "NO_SUBMISSION_COST"); require(msg.value == expectedEth, "WRONG_ETH_VALUE");
#0 - yondonfu
2022-01-21T17:04:01Z
Labeled as disagree with severity because we think this is a 2 - Med finding. We think that the likelihood of this occurring is low because in almost all cases users should be interacting with this contract using an application that handles calculating the maxSubmissionCost properly which would prevent the reported issue. However, we do think that the impact if this occurs is high since LPT and ETH could be lost if the reported issue happens. Thus, we think 2 - Med is appropriate given that assets are not at direct risk, but there is a low probability path for assets to be lost.
#1 - 0xleastwood
2022-01-29T23:55:19Z
I agree, there is potential for unintentional loss of funds, however, the attack vector makes assumptions on how this might occur. Due to the unlikely nature, I agree that this should be a medium
severity issue..
#2 - yondonfu
2022-01-31T21:19:26Z
1.4926 LPT - $55.34
152.2025 USDC - $152.20
gzeon
Anyone can migrate address(0) since there is no check to make sure _l1Addr != address(0)
and recoverSigner
return address(0) if _sig.length == 0
, which allow anyone to submit migration tx with _l1Addr = address(0) and _sig = ""
function requireValidMigration( address _l1Addr, address _l2Addr, bytes32 _structHash, bytes memory _sig ) internal view { require( _l2Addr != address(0), "L1Migrator#requireValidMigration: INVALID_L2_ADDR" ); require( msg.sender == _l1Addr || recoverSigner(_structHash, _sig) == _l1Addr, "L1Migrator#requireValidMigration: FAIL_AUTH" ); } function recoverSigner(bytes32 _structHash, bytes memory _sig) internal view returns (address) { if (_sig.length == 0) { return address(0); } bytes32 hash = _hashTypedDataV4(_structHash); return ECDSA.recover(hash, _sig); }
require(_l1Addr != address(0))
#0 - yondonfu
2022-01-23T00:43:40Z
0.1473 LPT - $5.46
15.0198 USDC - $15.02
gzeon
Upgrade to Solidity ^0.8.4 bring gas saving and allow the use of custom errors to optimize gas usage. https://blog.soliditylang.org/2021/04/21/custom-errors/
#0 - yondonfu
2022-01-23T21:08:11Z
🌟 Selected for report: sirhashalot
Also found by: Dravee, Jujic, WatchPug, aga7hokakological, defsec, gzeon, p4st13r4, robee
0.0387 LPT - $1.43
3.9418 USDC - $3.94
gzeon
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition has been met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
e.g. ./contracts/L1/gateway/L1Migrator.sol:508 ./contracts/L1/gateway/L1Migrator.sol:513 ./contracts/L2/gateway/L2Migrator.sol:136 ./contracts/L2/gateway/L2Migrator.sol:184 ./contracts/L2/gateway/L2Migrator.sol:201 ./contracts/L2/gateway/L2Migrator.sol:221 ./contracts/L2/gateway/L2Migrator.sol:257 ./contracts/L2/gateway/L2Migrator.sol:269 ./contracts/L2/gateway/L2Migrator.sol:274 ./contracts/L2/pool/DelegatorPool.sol:39 ./contracts/L2/pool/DelegatorPool.sol:61
#0 - yondonfu
2022-01-23T21:07:23Z
0.0795 LPT - $2.95
8.1107 USDC - $8.11
gzeon
Some known to be safe math can be put in a unchecked block to save gas.
contracts/L2/gateway/L2LPTDataCache.sol:65
l2SupplyFromL1 -= _amount;
is safe since _amount <= l2SupplyFromL1
unchecked{ l2SupplyFromL1 -= _amount; }
#0 - yondonfu
2022-01-23T21:12:00Z
0.1473 LPT - $5.46
15.0198 USDC - $15.02
gzeon
(bool ok, ) = l1MigratorAddr.call.value(address(this).balance)("");
to
(bool ok, ) = l1MigratorAddr.call.value(balance)("");
#0 - yondonfu
2022-01-23T00:53:15Z