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.
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 :
U256
divisor != 0
to avoid divisions with 0divisor
and dividend
dividend
and divisor
quotient
is negative using a XOR between the MSB of dividend
and divisor
quotient
is negative, it will negate the value
/// 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.
#[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);
}
PUSH32 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
PUSH0
SDIV
---- 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
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?
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)
}
Part 0: 0b0111101111111010110110000000010001011101100001011010010001110000
Part 1: 0b1110010100000000101101100101001111001010100000100010011100111011
Part 2: 0b1001001001111110011111011011001011011100110001110000001111000000
Part 3: 0b1100010111010010010001100000000110000110111101110010001100111100 (Bit 255 is set to 1)
In two’s complement arithmetic:
1
, the number is considered negative.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.
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:
0
, which is flagged as non-negative (dividend_is_negative = false
).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.
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
:
!value
results in U256::MAX
(all bits set to 1
).1
to U256::MAX
causes an overflow, resulting in the panic we observed during fuzz testing.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.
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:
is_negative
), the quotient is incorrectly flagged as negative, even if the quotient itself is 0
.negate
function is invoked for the quotient (0
), and while it avoids overflow, it does not return the correct result (0
as expected). Instead, the operation performs unnecessary computations.
#[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());
}
PUSH32 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
PUSH0
SDIV
---- 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
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.
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.
Contact us for an audit or long term partnership!
Cookie | Duration | Description |
---|---|---|
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |