-
Notifications
You must be signed in to change notification settings - Fork 3.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(ctb): Fix finalizeWithdrawalTransaction
gas dos
#4954
Conversation
|
Current dependencies on/for this PR: This comment was auto-generated by Graphite. |
} | ||
|
||
// Revert back to the snapshot state (before the withdrawal was finalized). | ||
assertTrue(vm.revertTo(snapshotId)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this resets hot storage slots thats amazing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does 😄
The issue still remains with any storage slots that were warm before the snapshot due to deployment / proving happening in the same transaction, though. This means the slot for the withdrawalHash
within the provenWithdrawals
mapping is warm- same for the slot for the withdrawal's corresponding output in the L2OutputOracle
's l2Outputs
array, etc.
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## develop #4954 +/- ##
===========================================
- Coverage 40.90% 34.82% -6.08%
===========================================
Files 324 300 -24
Lines 19677 17277 -2400
Branches 770 770
===========================================
- Hits 8048 6016 -2032
+ Misses 11019 10878 -141
+ Partials 610 383 -227
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Hey @clabby! This PR has merge conflicts. Please fix them before continuing review. |
Just wanted to record here the alternative idea of only finalizing the withdrawal if the external call succeeds. It feels a bit off because there is also replay protection in the L1xDM, but it does more or less make the problem go away. |
Yeah, we discussed this yesterday I believe, thanks for shouting here- I much prefer this route, but it changes a few important assumptions about the behavior of the portal late in the game. Interested to hear @smartcontracts' opinion on this when he's back. |
4d661d1
to
673b346
Compare
…hdrawalTransaction`
VM.deal(address(OP), _defaultTx.value); | ||
|
||
(bool success, bytes memory returndata) = address(OP).call{ gas: gas }( | ||
abi.encodeWithSelector(OP.finalizeWithdrawalTransaction.selector, _defaultTx) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: abi.encodeCall
will give you type safety here
* @param _value The amount of ETH to send with the call. | ||
* @param _data The calldata to send with the call. | ||
*/ | ||
function _safeCall( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO this could probably be shoved into the SafeCall
library (maybe SafeCall.callWithMinGasLimit
or something and should be very easy to fuzz test. The idea is that the receiving call frame ALWAYS has at least _minGasLimit
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could then also use the same logic inside of CrossDomainMessenger
.
Hey @clabby! This PR has merge conflicts. Please fix them before continuing review. |
Closing in favor of #5017 |
Overview
Introduces a fix for an issue where users could DoS withdrawal transactions by exploiting one of two bugs in the
finalizeWithdrawalTransaction
function of theOptimismPortal
:GAS
opcode in the check for whether or not the callframe has enough gas remaining and theCALL
opcode invoked in theSafeCall
library itself (This figure is230
gas after these changes - See Fixes for how it was derived). If theminGasLimit
specified for the withdrawal transaction is within230
gas of the actual cost of execution, a malicious user can callfinalizeWithdrawalTransaction
with an amount of gas that passes the check, but causes the external call to receive less thanminGasLimit
gas.1/64
th of remaining gas may be forwarded to an external call. Even if the above issue isfixed, a critical problem remains:
minGasLimit
gas is always sent to the external call performed by theOptimismPortal
'sfinalizeWithdrawalTransaction
function.minGasLimit * 64 / 63
gas remaining per EIP-150.minGasLimit / 64 > 19770
(minGasLimit > 1_265_280
), the caller can specify an amount of gas forfinalizeWithdrawalTransaction
that passes the check, but sends less thanminGasLimit
to the external call due to the implicit truncation performed by the EVM.TODO
expectCall
variant)Invariants
finalizeWithdrawalTransaction
MUST ALWAYS be supplied at LEAST theminGasLimit
specified by the user who initiated the withdrawal on L2.minGasLimit
gas to the external call, it should ALWAYS revert prior to performing the call.The ugly
The following assumptions are made in this fix:
0.8.15
for theOptimismPortal
.optimizer_runs
configuration value infoundry.toml
MUST be999_999
._safeCall
function in theOptimismPortal
MUST not be changed without alteringGAS_CHECK_BUFFER
.If any of the above items are changed, the
GAS_CHECK_BUFFER
MUST be altered to account for the change in gas consumed between the min gas limit check and theCALL
opcode invoked within_safeCall
.Fixes
First, let's start with accounting for the gas consumed between the check for the min gas limit and the external call itself.
To find the amount of gas consumed in this window, we need the gas consumed between the
GAS
opcode invoked in the check for sufficient remaining gas and theCALL
opcode invoked by theSafeCall
library.Compiler settings:
999_999
0.8.15
Debugged Test Case:
test_finalizeWithdrawalTransaction_provenWithdrawalHash_succeeds
Pre EIP-150 fix
GAS
35618
GAS
opcode invoked in the checkGAS
35655
GAS
opcode invoked in the call toSafeCall.call
CALL
35848
CALL
opcode invoked inSafeCall.call
Post EIP-150 fix (see below)
GAS
35770
GAS
opcode invoked in the checkGAS
35808
GAS
opcode invoked in the call toSafeCall.call
CALL
36000
CALL
opcode invoked inSafeCall.call
Exactly
230
gas is consumed between the invocation of theGAS
opcode in the check and theCALL
opcode itself. We must account for this gas consumption when passing the gas toSafeCall.call
to ensure that at LEAST the min gas limit is forwarded to the call.In addition to considering the gas consumed between the check and the call itself, we must also consider another factor: EIP-150 specifies that ALL but 1/64th of remaining gas may be forwarded to a call.
We currently check that the callframe has
minGasLimit + 20_000
gas remaining a few operations prior to the call. With the above fix (considering the gas consumed between the check and the call), we can now guarantee that the callframe has at LEASTminGasLimit + 19770
gas remaining at the time of the call.Even with the above fix, for any situation where
minGasLimit / 64 > 19770
(minGasLimit > 1_265_280
), an attacker can submit afinalizeWithdrawalTransaction
call with an amount of gas that passes the required check, but at the time of the call, the gas passed is greater than63/64
ths of the gas remaining. The EVM will silently reduce the amount of gas to63/64
ths of the gas remaining in the callframe, causing the transaction to fail.Within the remaining gas check in
_safeCall
, we must account for the63/64
ths rule laid out by EIP-150:When we combine these two fixes together, we can make the following assertions:
(_minGasLimit + 20_000) * 64 / 63
gas remaining.((_minGasLimit + 20_000) * 64 / 63) - 230
gas remaining due to the above check and factoring in the gas consumed between the check and the call itself.SafeCall
lib's call will always pass at LEAST(((_minGasLimit + 20_000) * 64 / 63) - 38) - 19770
gas to the external call.(_minGasLimit + 20_000) * 64 / 63
gas remaining.GAS
opcode invocation within the parameters toSafeCall.call
, exactly38
gas has been consumed since the above check.gasleft()
is at LEAST(((_minGasLimit + 20_000) * 64 / 63) - 38)
here.FINALIZE_GAS_BUFFER - GAS_CHECK_BUFFER
from the above value, which brings us to(((_minGasLimit + 20_000) * 64 / 63) - 38) - 19770
.Because we know that the call will always receive
(((_minGasLimit + 20_000) * 64 / 63) - 38) - 19770
, we can solve the following inequality to show that the external call will always receive at least_minGasLimit
gas:This inequality holds true for all$\text{minGasLimit}$ values in the range $(-32096, \infty)$ , and because we're dealing with unsigned integers, $[0, \infty)$ .
Metadata
Fixes CLI-3386
Fixes CLI-3387