Uncovering a Subtle Bug in Ethereum Virtual Machine (EVM)
The Case of Arithmetic Negating Zero
Our team at FuzzingLabs has been auditing the Ethereum Virtual Machine (EVM) implementation by LambdaClass. Throughout the audit, we’ve identified several vulnerabilities, but in this post, we’ll focus on one particularly interesting issue involving the SDIV operation and its handling of signed division.
This subtle bug exposes how seemingly straightforward operations, such as negating numbers in two’s complement arithmetic, can lead to overflow and unexpected results.
Fuzz Testing: Arithmetic Overflow in negate
During fuzz testing, we identified an overflow in the negate function when handling specific inputs in the SDIVoperation.
// SDIV operation
pub fn op_sdiv(
&mut self,
current_call_frame: &mut CallFrame,
) -> Result {
self.increase_consumed_gas(current_call_frame, gas_cost::SDIV)?;
let dividend = current_call_frame.stack.pop()?;
let divisor = current_call_frame.stack.pop()?;
if divisor.is_zero() {
current_call_frame.stack.push(U256::zero())?;
return Ok(OpcodeSuccess::Continue);
}
let dividend_is_negative = is_negative(dividend);
let divisor_is_negative = is_negative(divisor);
let dividend = if dividend_is_negative {
negate(dividend)
} else {
dividend
};
let divisor = if divisor_is_negative {
negate(divisor)
} else {
divisor
};
let Some(quotient) = dividend.checked_div(divisor) else {
current_call_frame.stack.push(U256::zero())?;
return Ok(OpcodeSuccess::Continue);
};
let quotient_is_negative = dividend_is_negative ^ divisor_is_negative;
let quotient = if quotient_is_negative {
negate(quotient)
} else {
quotient
};
current_call_frame.stack.push(quotient)?;
Ok(OpcodeSuccess::Continue)
}
Here is a quick explanation :
- Pop the divisor and the dividend from the stack and store them a
U256 - Checks if
divisor != 0to avoid divisions with 0 - Return the MSB bit for
divisoranddividend - If they are negative, negates the number in two’s complement
- It performs the division between
dividendanddivisor - Checks if
quotientis negative using a XOR between the MSB ofdividendanddivisor - If
quotientis negative, it will negate the value - Push the final result
/// Negates a number in two's complement
fn negate(value: U256) -> U256 {
!value + U256::one()
}
The issue arises because the function lacks proper checks during the addition. If the input to negate is U256::MAX (the maximum value for a 256-bit unsigned integer), adding 1 results in an overflow.
This vulnerability could be exploited because the SDIV opcode has a static gas cost. An attacker could reliably craft inputs to trigger the overflow, leading to a panic in the EVM and potentially disrupting its operation.
Step to reproduce
Payload
#[test]
fn test_sdiv_zero_dividend_and_negative_divisor() {
let mut vm = new_vm_with_bytecode(Bytes::copy_from_slice(&[
0x7F,
0xC5, 0xD2, 0x46, 0x01, 0x86, 0xF7, 0x23, 0x3C, 0x92, 0x7E, 0x7D, 0xB2, 0xDC, 0xC7, 0x03, 0xC0,
0xE5, 0x00, 0xB6, 0x53, 0xCA, 0x82, 0x27, 0x3B, 0x7B, 0xFA, 0xD8, 0x04, 0x5D, 0x85, 0xA4, 0x70,
0x5F,
0x05,
])).unwrap();
let mut current_call_frame = vm.call_frames.pop().unwrap();
vm.execute(&mut current_call_frame);
}
Disassembled payload
PUSH32 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
PUSH0
SDIV
Backtrace
---- tests::test_sdiv_zero_dividend_and_negative_divisor ----
thread 'tests::test_sdiv_zero_dividend_and_negative_divisor' panicked at /home/.../.cargo/registry/src/index.crates.io-6f17d22bba15001f/primitive-types-0.12.2/src/lib.rs:38:1:
arithmetic operation overflow
Patch for negate
To address the immediate overflow issue, the negate function was updated to use saturating_add, which prevents overflow:
/// Safely negates a number in two's complement
fn negate(value: U256) -> U256 {
let inverted = !value;
inverted.saturating_add(U256::one())
}
With this patch in place, the EVM no longer crashed during fuzz testing. However, this raised a critical question: Why was negate being called for a dividend of 0 in the first place?
Digging Deeper: Why Negate Zero?
The root cause of this issue lies in how the Lambdaclass EVM handles signed arithmetic using unsigned integers (U256). Let’s break it down.
In the problematic scenario, the SDIV operation starts with a negative divisor in the context of a signed 256-bit integer. The specific divisor value is:
89477152217924674838424037953991966239322087453347756267410168184682657981552
This value, when represented as a signed 256-bit integer, is flagged as negative because its most significant bit (MSB) is 1. Lambdaclass simulates signed arithmetic by relying on the is_negative function:
/// Shifts the value to the right by 255 bits and checks if the MSB is a 1
fn is_negative(value: U256) -> bool {
value.bit(255)
}
Binary representation
Part 0: 0b0111101111111010110110000000010001011101100001011010010001110000
Part 1: 0b1110010100000000101101100101001111001010100000100010011100111011
Part 2: 0b1001001001111110011111011011001011011100110001110000001111000000
Part 3: 0b1100010111010010010001100000000110000110111101110010001100111100 (Bit 255 is set to 1)
In two’s complement arithmetic:
- If the MSB is
1, the number is considered negative. - If the MSB is
0, the number is non-negative (positive or zero).
For the divisor value above, the MSB is 1, making it negative in the simulated signed 256-bit representation. This is consistent with how signed arithmetic works in the Ethereum Virtual Machine (EVM), as per the yellow paper.
How the Quotient Becomes Negative
The sign of the quotient in SDIV is determined using the XOR operation:
let quotient_is_negative = dividend_is_negative ^ divisor_is_negative;
In our case:
- The dividend is
0, which is flagged as non-negative (dividend_is_negative = false). - The divisor is flagged as negative (
divisor_is_negative = true).
The XOR (false ^ true) results in quotient_is_negative = true. Thus, the EVM considers the quotient as negative. However, no check is performed to see if the quotient is actually zero before marking it as negative.
Calling Negate on Zero
With the quotient flagged as negative, the EVM calls the negate function to compute its two’s complement. Normally, dividing zero by any non-zero number should result in zero, regardless of the divisor’s sign. However, the Lambdaclass EVM does not handle this edge case.
Here’s the problematic negate function before it was patched:
/// Negates a number in two's complement
fn negate(value: U256) -> U256 {
!value + U256::one()
}
When called with value = 0:
!valueresults inU256::MAX(all bits set to1).- Adding
1toU256::MAXcauses an overflow, resulting in the panic we observed during fuzz testing.
The Patch: Resolving the Overflow but Missing the Root Cause
Calling Negate on Zero
The patch resolves the immediate issue: an arithmetic overflow when calling negate(0). This prevents the EVM from panicking and ensures that no catastrophic behavior occurs when SDIV encounters this edge case.
What the Patch Misses
While the patch addresses the overflow, it does not resolve the root cause of the issue: the unnecessary invocation of negate for a quotient of 0.
Per the Ethereum yellow paper, the result of 0 / x (where x ≠ 0) must always be 0. However, due to how Lambdaclass’s SDIV implementation interacts with is_negative and negate, the following issues persist:
- Incorrect Logic for Zero Handling: When the divisor is negative (as per
is_negative), the quotient is incorrectly flagged as negative, even if the quotient itself is0. - Incorrect Output for Zero: The
negatefunction is invoked for the quotient (0), and while it avoids overflow, it does not return the correct result (0as expected). Instead, the operation performs unnecessary computations.
Step to reproduce
Step to reproduce
#[test]
fn test_sdiv_zero_dividend_and_negative_divisor() {
let mut vm = new_vm_with_bytecode(Bytes::copy_from_slice(&[
0x7F,
0xC5, 0xD2, 0x46, 0x01, 0x86, 0xF7, 0x23, 0x3C, 0x92, 0x7E, 0x7D, 0xB2, 0xDC, 0xC7, 0x03, 0xC0,
0xE5, 0x00, 0xB6, 0x53, 0xCA, 0x82, 0x27, 0x3B, 0x7B, 0xFA, 0xD8, 0x04, 0x5D, 0x85, 0xA4, 0x70,
0x5F,
0x05,
])).unwrap();
let mut current_call_frame = vm.call_frames.pop().unwrap();
vm.execute(&mut current_call_frame);
assert_eq!(current_call_frame.stack.pop().unwrap(), U256::zero());
}
Disassembled payload
PUSH32 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
PUSH0
SDIV
Backtrace
---- test_sdiv_zero_dividend_and_negative_divisor stdout ----
thread 'test_sdiv_zero_dividend_and_negative_divisor' panicked at crates/vm/levm/tests/edge_case_tests.rs:111:5:
assertion `left == right` failed
left: 115792089237316195423570985008687907853269984665640564039457584007913129639935
right: 0
To understand what is the value returned by our test, we need to understand what while happen when we call negate(0) :
!0 = -1 If we are using signed value, but in our case we are dealing with unsigned value U256 so it will return U256::MAX <=> 115792089237316195423570985008687907853269984665640564039457584007913129639935
Conclusion
The overflow triggered during fuzz testing provided a crucial entry point to uncover a deeper flaw in the implementation of SDIV. Without this panic, the underlying issue — the incorrect handling of signed division for a zero dividend — might have gone unnoticed for longer. This demonstrates the power of fuzz testing, as it allowed us to surface a bug that was not immediately evident.
While the initial overflow was addressed with a patch to the negate function, further investigation revealed that the core issue lay in how SDIV interprets and processes signed values. Specifically, the failure to properly handle the edge case of 0 / x exposed a deviation from the behavior described in the Ethereum yellow paper. This misimplementation highlights the importance of rigorously testing and aligning EVM operations with the standard specification.
It’s worth noting that differential fuzzing, where an EVM implementation is compared against a known-correct reference, would also have been effective in detecting this inconsistency. Such an approach could have flagged the mismatch between the Lambdaclass EVM’s SDIV implementation and the expected behavior without relying on an overflow-induced panic.
This case underscores the value of comprehensive testing methodologies, including both property-based fuzzing and diff fuzzing. Together, these techniques ensure not only that the EVM is crash-resistant but also that it faithfully adheres to the Ethereum yellow paper’s specifications.
About Us
Founded in 2021 and headquartered in Paris, FuzzingLabs is a cybersecurity startup specializing in vulnerability research, fuzzing, and blockchain security. We combine cutting-edge research with hands-on expertise to secure some of the most critical components in the blockchain ecosystem.
Get Your Free Security Quote!
Let’s work together to ensure your peace of mind.
LATEST ARTICLES


