Uncovering a Subtle Bug in EVM Arithmetic: The Case of Negating Zero
2024-12-3 23:57:21 Author: fuzzinglabs.com(查看原文) 阅读量:0 收藏

The Case of 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<OpcodeSuccess, VMError> {
        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 != 0 to avoid divisions with 0
  • Return the MSB bit for divisor and dividend
  • If they are negative, negates the number in two’s complement
  • It performs the division between dividend and divisor
  • Checks if quotient is negative using a XOR between the MSB of dividend and divisor
  • If quotient is negative, it will negate the value
  • Push the final result

The vulnerable function here is negate :

				
					/// 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?

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)
}
				
			
				
					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.

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:

  1. !value results in U256::MAX (all bits set to 1).
  2. Adding 1 to U256::MAX causes an overflow, resulting in the panic we observed during fuzz testing.

The Patch: Resolving the Overflow but Missing the Root Cause

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:

  • 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 is 0.
  • Incorrect Output for Zero: The 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!

Get Your Free Security Quote!

Let’s work together to ensure your peace of mind.


文章来源: https://fuzzinglabs.com/evm-arithmetic-bug/
如有侵权请联系:admin#unsafe.sh