Attackathon _ Fuel Network 33045 - [Smart Contract - Low] Compiler Dead Code Elimination inconsisten
Submitted on Wed Jul 10 2024 03:34:53 GMT-0400 (Atlantic Standard Time) by @LonelySloth for Attackathon | Fuel Network
Report ID: #33045
Report type: Smart Contract
Report severity: Low
Target: https://github.com/FuelLabs/sway/tree/v0.61.2
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Permanent freezing of funds
Missing arithmetic checks in release bytecode.
Inconsistent test/release behavior leading to untestable code.
Description
Brief/Intro
The DCE module of the compiler/optimizer is responsible for eliminating code that can't be reached or whose resulting computed values are never used -- thus saving bytecode size and gas costs. However the DCE incorrectly identifies arithmetic operations as not producing side-effects, while in fact they can produce reverts. This leads to inconsistent behavior between test/release bytecode, with certain implicit assertions not being included in release. Consequently, contracts will have unexpected behavior that can plausibly lead to loss of funds.
Vulnerability Details
By default Sway and the underlying FuelVM perform overflow (and division by zero) checks on all arithmetic operations, resulting in a panic in case the operation overflows or results in division by zero.
That means in practice, the statement
Should be equivalent to:
While this is indeed always the case for the types u8
, u16
, u32
(which are implemented in Sway using assert
), it's not always the case for the native type u64
.
The reason is the optimizer's DCE (Dead Code Eliminator) treatment of arithmetic operations. The source file instruction.rs
in the compiler's sway-ir
module defines the operations that are deemed to have side-effects:
All binary operations are listed as not having side effects -- that includes all arithmetic operations. The fact is that arithmetic operation do have side-effects, as they can cause a revert. Thus the above code is incorrect, which will lead to many inconsistencies.
As the Optimizer understands arithmetic functions to have no side effects, it freely removes such operations from the code whenever it finds the result of the operation isn't used, or the code can't be reached.
What exactly counts as not used or unreachable depends on the exact parameters used when running the compiler, and various hard to predict aspects of the contract -- often leading to changes in one part of the code resulting in changed behavior in a different part.
For example, a call to testf(..)
in the contract below will revert with no optimization:
Running the test using forc test
will result in the test failing.
However, running the test using forc test --release
will result in the test passing.
Even worse, introducing another function to the contract, without adding any modification to the original code, can change the behavior. This modified contract reverts both in test and release mode.
That obviously means also that removing one of the functions from the code can make the overflow check in the other being removed from the release bytecode, introducing a bug in code that hasn't been changed. That shouldn't be possible.
The explanation is that when the optimizer can statically check that the function check_n_log_diff
is only ever called with a false
last argument, it eliminates the log
operations as unreachable, and consequently (in the next pass), removes the arithmetic operation as well, as it's result isn't used. However, when there are calls to the function using both true
and false
arguments, the optimizer can't tell if the log
operation will be performed, and thus doesn't eliminate the arithmetic operation and its overflow check -- thus changing the behavior of the function for both cases.
As it is common practice to attempt to optimize code by removing redundant checks -- and the expectation that every arithmetic operation will revert in case of an overflow -- I believe chances of something like the above happening in a real-world complex code base is extremely likely.
Besides that, the fact that the issue is likely to be missed in tests (as contracts behave differently when compiled for test, and testing in release usually isn't part of the ), and that the behavior is unexpected, inconsistent between integer types, and in fact unpredictable -- makes it almost impossible for developers to guard against such problems.
Comparing to other languages
To further demonstrate why the optimizer behavior is dangerous and shouldn't happen (ever) I would like to make a quick comparison with Solidity and Rust -- which are the main sources of inspiration for Sway.
Solidity: In Solidity arithmetic overflow checks are implemented by the compiler by adding conditionals and
revert
instructions. That means the optimizer never eliminates them, asrevert
is an instruction with side effects. The same is true in Sway for overflows in non-native types (u8, u16 etc) -- that's how all types are supposed to behave. Note that division by zero in Sway can be eliminated by the DCE even in the non-native types.Rust: While Rust (typically) does have overflow checks that are removed in release mode, the significance of these assertions are the exact opposite of what we have in Sway -- in Rust the overflows are never supposed to happen in production, and are included in test to help catch bugs early, while in Sway the panics are an essential part of the functionality and security of production code.
Impact Details
The issue reported results in release bytecode that has undocumented, unexpected, and unpredictable behavior under realistic scenarios -- causing the smart contract to fail to revert in situation when it should.
As reverting is the main way contracts prevent unauthorized operations, and redundant checks are typically avoided to save gas -- deployment of a contract with such unpredictable behavior is likely to result in theft of funds (for example an attacker withdrawing more tokens than it should be able to) or freezing of funds (by setting some state to an invalid value).
In the PoC area I include a little contract to showcase a scenario of loss of funds, as well as numerous test cases to demonstrate the inconsistency of revert behavior between various pieces of code.
Note that, as with previous submissions of language bugs, it is also highly likely such a bug would be used to create contracts with secret back doors that can't be detected in normal audits or testing.
Recommendation
Arithmetic operations should be treated as having side effects by the optimizer (as they actually do have side effects).
A thorough review of all IR operations should be performed to check if any other side effect is missing.
Proof of concept
Proof of Concept
For all my tests I'm using the following configuration (fuelup show
):
Real-world-like exploitable scenario
The following contract should not allow a user to withdraw more than their "funds" balance. However, running the test with forc test --release
shows it actually doesn't revert in release mode.
While of course this isn't an actual real-world contract, I think the sort of operations seen here are extremely likely to appear in real world as thousands of apps are developed and deployed. Note that the contract itself doesn't have a vulnerability, and it's only when removing the function that isn't used anymore that it becomes vulnerable due to the optimizer bug.
Testing different (and surprising) arithmetic operations
Some of the following tests will fail while others will pass. Can you guess which ones? I highly doubt DeFi application developers will be able to.
Actual result forc test
:
Actual result forc test --release
:
Last updated