VYPR
Low severityNVD Advisory· Published Jan 14, 2025· Updated Apr 24, 2025

Success of Certain Precompile Calls not Checked in Vyper

CVE-2025-21607

Description

Vyper is a Pythonic Smart Contract Language for the EVM. When the Vyper Compiler uses the precompiles EcRecover (0x1) and Identity (0x4), the success flag of the call is not checked. As a consequence an attacker can provide a specific amount of gas to make these calls fail but let the overall execution continue. Then the execution result can be incorrect. Based on EVM's rules, after the failed precompile the remaining code has only 1/64 of the pre-call-gas left (as 63/64 were forwarded and spent). Hence, only fairly simple executions can follow the failed precompile calls. Therefore, we found no significantly impacted real-world contracts. None the less an advisory has been made out of an abundance of caution. This issue is fixed in 0.4.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Vyper compiler fails to check success of EcRecover and Identity precompile calls, allowing gas manipulation to cause incorrect execution results; fixed in 0.4.1.

Description

The Vyper compiler, when generating bytecode for the ecrecover built-in (which uses the EcRecover precompile at address 0x01) and for memory copy operations using the Identity precompile (0x04), does not verify the success flag returned by these precompile calls [1][3]. This means that if the call fails — for example due to an out-of-gas (OOG) condition — the EVM execution continues with the return data uninitialized or containing stale values, rather than reverting the transaction [4].

Exploitation

An attacker can craft a transaction that supplies a specific amount of gas such that the precompile call fails (e.g., by providing exactly 3000 gas for ecrecover, which consumes that amount) but the calling contract still has enough gas to proceed [1]. Due to the EVM's 63/64ths gas forwarding rule, after the failed precompile the remaining code has only 1/64 of the pre-call gas left, limiting the complexity of subsequent operations. For ecrecover, the result becomes the zero address; for identity precompile, memory copy operations may produce incorrect data [1][4].

Impact

An attacker could force a contract to return incorrect values, such as an invalid recovered address from ecrecover or corrupted memory after a failed identity precompile call. However, because the remaining gas after the failed call is severely limited (at most 47 gas for ecrecover), only very simple follow-up code can execute, making exploitation unlikely in practice [1]. The Vyper team assessed no significantly impacted real-world contracts and issued the advisory out of an abundance of caution [1][3].

Mitigation

The vulnerability is fixed in Vyper version 0.4.1 [1][3]. The fix adds explicit assertions to check the success flag for both EcRecover and Identity precompile calls, ensuring that a failure reverts the entire call context [2][4]. Users should upgrade to 0.4.1 or later. No workaround is available for older versions.

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
vyperPyPI
>= 0

Affected products

1

Patches

1
7136eab0a254

fix[codegen]: fix assertions for certain precompiles (#4451)

https://github.com/vyperlang/vyperCharles CooperJan 20, 2025via ghsa
7 files changed · +257 5
  • tests/functional/builtins/codegen/test_ecrecover.py+41 1 modified
    @@ -1,7 +1,10 @@
    +import contextlib
    +
     from eth_account import Account
     from eth_account._utils.signing import to_bytes32
     
    -from tests.utils import ZERO_ADDRESS
    +from tests.utils import ZERO_ADDRESS, check_precompile_asserts
    +from vyper.compiler.settings import OptimizationLevel
     
     
     def test_ecrecover_test(get_contract):
    @@ -86,3 +89,40 @@ def test_ecrecover() -> bool:
         """
         c = get_contract(code)
         assert c.test_ecrecover() is True
    +
    +
    +def test_ecrecover_oog_handling(env, get_contract, tx_failed, optimize, experimental_codegen):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@external
    +@view
    +def do_ecrecover(hash: bytes32, v: uint256, r:uint256, s:uint256) -> address:
    +    return ecrecover(hash, v, r, s)
    +    """
    +    check_precompile_asserts(code)
    +
    +    c = get_contract(code)
    +
    +    h = b"\x35" * 32
    +    local_account = Account.from_key(b"\x46" * 32)
    +    sig = local_account.signHash(h)
    +    v, r, s = sig.v, sig.r, sig.s
    +
    +    assert c.do_ecrecover(h, v, r, s) == local_account.address
    +
    +    gas_used = env.last_result.gas_used
    +
    +    if optimize == OptimizationLevel.NONE and not experimental_codegen:
    +        # if optimizations are off, enough gas is used by the contract
    +        # that the gas provided to ecrecover (63/64ths rule) is enough
    +        # for it to succeed
    +        ctx = contextlib.nullcontext
    +    else:
    +        # in other cases, the gas forwarded is small enough for ecrecover
    +        # to fail with oog, which we handle by reverting.
    +        ctx = tx_failed
    +
    +    with ctx():
    +        # provide enough spare gas for the top-level call to not oog but
    +        # not enough for ecrecover to succeed
    +        c.do_ecrecover(h, v, r, s, gas=gas_used)
    
  • tests/functional/codegen/types/test_dynamic_array.py+59 1 modified
    @@ -1,10 +1,12 @@
    +import contextlib
     import itertools
     from typing import Any, Callable
     
     import pytest
     
    -from tests.utils import decimal_to_int
    +from tests.utils import check_precompile_asserts, decimal_to_int
     from vyper.compiler import compile_code
    +from vyper.evm.opcodes import version_check
     from vyper.exceptions import (
         ArgumentException,
         ArrayIndexException,
    @@ -1901,3 +1903,59 @@ def foo():
         c = get_contract(code)
         with tx_failed():
             c.foo()
    +
    +
    +def test_dynarray_copy_oog(env, get_contract, tx_failed):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +
    +@external
    +def foo(a: DynArray[uint256, 4000]) -> uint256:
    +    b: DynArray[uint256, 4000] = a
    +    return b[0]
    +    """
    +    check_precompile_asserts(code)
    +
    +    c = get_contract(code)
    +    dynarray = [2] * 4000
    +    assert c.foo(dynarray) == 2
    +
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(dynarray, gas=gas_used)
    +
    +
    +def test_dynarray_copy_oog2(env, get_contract, tx_failed):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@external
    +@view
    +def foo(x: String[1000000], y: String[1000000]) -> DynArray[String[1000000], 2]:
    +    z: DynArray[String[1000000], 2] = [x, y]
    +    # Some code
    +    return z
    +    """
    +    check_precompile_asserts(code)
    +
    +    c = get_contract(code)
    +    calldata0 = "a" * 10
    +    calldata1 = "b" * 1000000
    +    assert c.foo(calldata0, calldata1) == [calldata0, calldata1]
    +
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(calldata0, calldata1, gas=gas_used)
    
  • tests/functional/codegen/types/test_lists.py+75 1 modified
    @@ -1,8 +1,12 @@
    +import contextlib
     import itertools
     
     import pytest
     
    -from tests.utils import decimal_to_int
    +from tests.evm_backends.base_env import EvmError
    +from tests.utils import check_precompile_asserts, decimal_to_int
    +from vyper.compiler.settings import OptimizationLevel
    +from vyper.evm.opcodes import version_check
     from vyper.exceptions import ArrayIndexException, OverflowException, TypeMismatch
     
     
    @@ -848,3 +852,73 @@ def foo() -> {return_type}:
         return MY_CONSTANT[0][0]
         """
         assert_compile_failed(lambda: get_contract(code), TypeMismatch)
    +
    +
    +def test_array_copy_oog(env, get_contract, tx_failed, optimize, experimental_codegen, request):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@internal
    +def bar(x: uint256[3000]) -> uint256[3000]:
    +    a: uint256[3000] = x
    +    return a
    +
    +@external
    +def foo(x: uint256[3000]) -> uint256:
    +    s: uint256[3000] = self.bar(x)
    +    return s[0]
    +    """
    +    check_precompile_asserts(code)
    +
    +    if optimize == OptimizationLevel.NONE and not experimental_codegen:
    +        # fails in bytecode generation due to jumpdests too large
    +        with pytest.raises(AssertionError):
    +            get_contract(code)
    +        return
    +
    +    c = get_contract(code)
    +    array = [2] * 3000
    +    assert c.foo(array) == array[0]
    +
    +    # get the minimum gas for the contract complete execution
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(array, gas=gas_used)
    +
    +
    +def test_array_copy_oog2(env, get_contract, tx_failed, optimize, experimental_codegen, request):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@external
    +def foo(x: uint256[2500]) -> uint256:
    +    s: uint256[2500] = x
    +    t: uint256[2500] = s
    +    return t[0]
    +    """
    +    check_precompile_asserts(code)
    +
    +    if optimize == OptimizationLevel.NONE and not experimental_codegen:
    +        # fails in creating contract due to code too large
    +        with tx_failed(EvmError):
    +            get_contract(code)
    +        return
    +
    +    c = get_contract(code)
    +    array = [2] * 2500
    +    assert c.foo(array) == array[0]
    +
    +    # get the minimum gas for the contract complete execution
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(array, gas=gas_used)
    
  • tests/functional/codegen/types/test_string.py+58 0 modified
    @@ -1,5 +1,10 @@
    +import contextlib
    +
     import pytest
     
    +from tests.utils import check_precompile_asserts
    +from vyper.evm.opcodes import version_check
    +
     
     def test_string_return(get_contract):
         code = """
    @@ -359,3 +364,56 @@ def compare_var_storage_not_equal_false() -> bool:
         assert c.compare_var_storage_equal_false() is False
         assert c.compare_var_storage_not_equal_true() is True
         assert c.compare_var_storage_not_equal_false() is False
    +
    +
    +def test_string_copy_oog(env, get_contract, tx_failed):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@external
    +@view
    +def foo(x: String[1000000]) -> String[1000000]:
    +    return x
    +    """
    +    check_precompile_asserts(code)
    +
    +    c = get_contract(code)
    +    calldata = "a" * 1000000
    +    assert c.foo(calldata) == calldata
    +
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(calldata, gas=gas_used)
    +
    +
    +def test_string_copy_oog2(env, get_contract, tx_failed):
    +    # GHSA-vgf2-gvx8-xwc3
    +    code = """
    +@external
    +@view
    +def foo(x: String[1000000]) -> uint256:
    +    y: String[1000000] = x
    +    return len(y)
    +    """
    +    check_precompile_asserts(code)
    +
    +    c = get_contract(code)
    +    calldata = "a" * 1000000
    +    assert c.foo(calldata) == len(calldata)
    +
    +    gas_used = env.last_result.gas_used
    +    if version_check(begin="cancun"):
    +        ctx = contextlib.nullcontext
    +    else:
    +        ctx = tx_failed
    +
    +    with ctx():
    +        # depends on EVM version. pre-cancun, will revert due to checking
    +        # success flag from identity precompile.
    +        c.foo(calldata, gas=gas_used)
    
  • tests/utils.py+22 0 modified
    @@ -3,6 +3,7 @@
     import os
     
     from vyper import ast as vy_ast
    +from vyper.compiler.phases import CompilerData
     from vyper.semantics.analysis.constant_folding import constant_fold
     from vyper.utils import DECIMAL_EPSILON, round_towards_zero
     
    @@ -28,3 +29,24 @@ def parse_and_fold(source_code):
     def decimal_to_int(*args):
         s = decimal.Decimal(*args)
         return round_towards_zero(s / DECIMAL_EPSILON)
    +
    +
    +def check_precompile_asserts(source_code):
    +    # common sanity check for some tests, that calls to precompiles
    +    # are correctly wrapped in an assert.
    +
    +    compiler_data = CompilerData(source_code)
    +    deploy_ir = compiler_data.ir_nodes
    +    runtime_ir = compiler_data.ir_runtime
    +
    +    def _check(ir_node, parent=None):
    +        if ir_node.value == "staticcall":
    +            precompile_addr = ir_node.args[1]
    +            if isinstance(precompile_addr.value, int) and precompile_addr.value < 10:
    +                assert parent is not None and parent.value == "assert"
    +        for arg in ir_node.args:
    +            _check(arg, ir_node)
    +
    +    _check(deploy_ir)
    +    # technically runtime_ir is contained in deploy_ir, but check it anyways.
    +    _check(runtime_ir)
    
  • vyper/builtins/functions.py+1 1 modified
    @@ -781,7 +781,7 @@ def build_IR(self, expr, args, kwargs, context):
                     ["mstore", add_ofst(input_buf, 32), args[1]],
                     ["mstore", add_ofst(input_buf, 64), args[2]],
                     ["mstore", add_ofst(input_buf, 96), args[3]],
    -                ["staticcall", "gas", 1, input_buf, 128, output_buf, 32],
    +                ["assert", ["staticcall", "gas", 1, input_buf, 128, output_buf, 32]],
                     ["mload", output_buf],
                 ],
                 typ=AddressT(),
    
  • vyper/codegen/core.py+1 1 modified
    @@ -326,7 +326,7 @@ def copy_bytes(dst, src, length, length_bound):
                         copy_op = ["mcopy", dst, src, length]
                         gas_bound = _mcopy_gas_bound(length_bound)
                     else:
    -                    copy_op = ["staticcall", "gas", 4, src, length, dst, length]
    +                    copy_op = ["assert", ["staticcall", "gas", 4, src, length, dst, length]]
                         gas_bound = _identity_gas_bound(length_bound)
                 elif src.location == CALLDATA:
                     copy_op = ["calldatacopy", dst, src, length]
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.