CVE-2024-32481 (Python, published 2024-04-25T17:15Z)
Description: value: Vyper is a pythonic Smart Contract Language for the Ethereum virtual machine. Starting in version 0.3.8 and prior to version 0.4.0b1, when looping over a `range` of the form `range(start, start + N)`, if `start` is negative, the execution will always revert. This issue is caused by an incorrect assertion inserted by the code generation of the range `stmt.parse_For_range()`. The issue arises when `start` is signed, instead of using `sle`, `le` is used and `start` is interpreted as an unsigned integer for the comparison. If it is a negative number, its 255th bit is set to `1` and is hence interpreted as a very large unsigned integer making the assertion always fail. Any contract having a `range(start, start + N)` where `start` is a signed integer with the possibility for `start` to be negative is affected. If a call goes through the loop while supplying a negative `start` the execution will revert. Version 0.4.0b1 fixes the issue.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/vyperlang/vyper
Files changed:
docs/control-structures.rst
tests/functional/codegen/features/iteration/test_for_in_list.py
tests/functional/codegen/features/iteration/test_for_range.py
tests/functional/codegen/integration/test_crowdfund.py
tests/functional/syntax/exceptions/test_invalid_literal_exception.py
tests/functional/syntax/test_for_range.py
vyper/codegen/ir_node.py
vyper/codegen/stmt.py
vyper/exceptions.py
vyper/semantics/analysis/local.py
Commit message: feat: allow `range(x, y, bound=N)` (#3679)

- allow range where both start and end arguments are variables, so long
  as a bound is supplied

- ban range expressions of the form `range(x, x + N)` since the new form
  is cleaner and supersedes it.

- also do a bit of refactoring of the codegen for range

---------

Co-authored-by: Charles Cooper <cooper.charles.m@gmail.com>

Vulnerable code:
    for i in range(a, a + N):

``a`` is a variable with an integer type and ``N`` is a literal integer greater than zero.  ``i`` begins as ``a`` and increments by one until it is equal to ``a + N``. If ``a + N`` would overflow, execution will revert.
    """
@pytest.mark.parametrize("code", BAD_CODE)
def test_bad_code(assert_compile_failed, get_contract, code):
    err = StructureException
    if not isinstance(code, str):
        code, err = code
    for i in range(frm, frm + 101):
# test that we can get to the upper range of an integer
    for i in range(x, x + 1):
        if i == max_value({typ}):

    for i in range(x, x + 2):
        if i == max_value({typ}):

    for i in range(x, x+2):
    for i in range(ind, ind + 30):
    for i in range(ind, ind + 30):
def foo(x: int128):
    y: int128 = 7
    for i in range(x, x + y):
        pass
    """,
    """
@external
from vyper.exceptions import StructureException
    for i in range(1,2,bound=2):
    for i in range(x,x+1,bound=2):
@pytest.mark.parametrize("bad_code", fail_list)
def test_range_fail(bad_code):
    with pytest.raises(bad_code[1]):
        compiler.compile_code(bad_code[0])
    for i in range(x, x + 10):
@pytest.mark.parametrize("good_code", valid_list)
    def is_literal(self):
    def is_pointer(self):
    def _check_valid_range_constant(self, arg_ast_node):
        with self.context.range_scope():
            arg_expr = Expr.parse_value_expr(arg_ast_node, self.context)
        return arg_expr

    def _get_range_const_value(self, arg_ast_node):
        arg_expr = self._check_valid_range_constant(arg_ast_node)
        return arg_expr.value

        arg0 = self.stmt.iter.args[0]
        num_of_args = len(self.stmt.iter.args)

        kwargs = {
            s.arg: Expr.parse_value_expr(s.value, self.context)
            for s in self.stmt.iter.keywords or []
        }

        # Type 1 for, e.g. for i in range(10): ...
        if num_of_args == 1:
            n = Expr.parse_value_expr(arg0, self.context)
            start = IRnode.from_list(0, typ=iter_typ)
            rounds = n
            rounds_bound = kwargs.get("bound", rounds)

        # Type 2 for, e.g. for i in range(100, 110): ...
        elif self._check_valid_range_constant(self.stmt.iter.args[1]).is_literal:
            arg0_val = self._get_range_const_value(arg0)
            arg1_val = self._get_range_const_value(self.stmt.iter.args[1])
            start = IRnode.from_list(arg0_val, typ=iter_typ)
            rounds = IRnode.from_list(arg1_val - arg0_val, typ=iter_typ)
            rounds_bound = rounds
        # Type 3 for, e.g. for i in range(x, x + 10): ...
        else:
            arg1 = self.stmt.iter.args[1]
            rounds = self._get_range_const_value(arg1.right)
            _, hi = start.typ.int_bounds
            start = clamp("le", start, hi + 1 - rounds)
        bound = rounds_bound if isinstance(rounds_bound, int) else rounds_bound.value
        if bound < 1:
            return
            the exception occured. Source annotations are generated in the order
    InvalidLiteral,
            range_ = node.iter
            validate_call_args(range_, (1, 2), kwargs=["bound"])

            args = range_.args
            kwargs = {s.arg: s.value for s in range_.keywords or []}
            if len(args) == 1:
                # range(CONSTANT)
                n = args[0]
                bound = kwargs.pop("bound", None)
                validate_expected_type(n, IntegerT.any())

                if bound is None:
                    if not isinstance(n, vy_ast.Num):
                        raise StateAccessViolation("Value must be a literal", n)
                    if n.value <= 0:
                        raise StructureException("For loop must have at least 1 iteration", args[0])
                    type_list = get_possible_types_from_node(n)

                else:
                    if not isinstance(bound, vy_ast.Num):
                        raise StateAccessViolation("bound must be a literal", bound)
                    if bound.value <= 0:
                        raise StructureException("bound must be at least 1", args[0])
                    type_list = get_common_types(n, bound)

            else:
                if range_.keywords:
                    raise StructureException(
                        "Keyword arguments are not supported for `range(N, M)` and"
                        "`range(x, x + N)` expressions",
                        range_.keywords[0],
                    )

                validate_expected_type(args[0], IntegerT.any())
                type_list = get_common_types(*args)
                if not isinstance(args[0], vy_ast.Constant):
                    # range(x, x + CONSTANT)
                    if not isinstance(args[1], vy_ast.BinOp) or not isinstance(
                        args[1].op, vy_ast.Add
                    ):
                        raise StructureException(
                            "Second element must be the first element plus a literal value", args[0]
                        )
                    if not vy_ast.compare_nodes(args[0], args[1].left):
                        raise StructureException(
                            "First and second variable must be the same", args[1].left
                        )
                    if not isinstance(args[1].right, vy_ast.Int):
                        raise InvalidLiteral("Literal must be an integer", args[1].right)
                    if args[1].right.value < 1:
                        raise StructureException(
                            f"For loop has invalid number of iterations ({args[1].right.value}),"
                            " the value must be greater than zero",
                            args[1].right,
                        )
                else:
                    # range(CONSTANT, CONSTANT)
                    if not isinstance(args[1], vy_ast.Int):
                        raise InvalidType("Value must be a literal integer", args[1])
                    validate_expected_type(args[1], IntegerT.any())
                    if args[0].value >= args[1].value:
                        raise StructureException("Second value must be > first value", args[1])

                if not type_list:
                    raise TypeMismatch("Iterator values are of different types", node.iter)
                        for n in node.body:
                            self.visit(n)

Fixed code:
Finally, it is possible to use ``range`` with runtime `start` and `stop` values as long as a constant `bound` value is provided.
In this case, Vyper checks at runtime that `end - start <= bound`.
``N`` must be a compile-time constant.

    for i in range(start, end, bound=N):
import re
    (
        """
        StateAccessViolation,
    ),
BAD_CODE = [code if isinstance(code, tuple) else (code, StructureException) for code in BAD_CODE]
for_code_regex = re.compile(r"for .+ in (.*):")
bad_code_names = [
    f"{i} {for_code_regex.search(code).group(1)}" for i, (code, _) in enumerate(BAD_CODE)
]

@pytest.mark.parametrize("code,err", BAD_CODE, ids=bad_code_names)
def test_bad_code(assert_compile_failed, get_contract, code, err):
def test_range_bound_constant_end(get_contract, tx_failed):
    code = """
@external
def repeat(n: uint256) -> uint256:
    x: uint256 = 0
    for i in range(n, 7, bound=6):
        x += i + 1
    return x
    """
    c = get_contract(code)
    for n in range(1, 5):
        assert c.repeat(n) == sum(i + 1 for i in range(n, 7))

    # check assertion for `start <= end`
    with tx_failed():
        c.repeat(8)
    # check assertion for `start + bound <= end`
    with tx_failed():
        c.repeat(0)


def test_range_bound_two_args(get_contract, tx_failed):
    code = """
@external
def repeat(n: uint256) -> uint256:
    x: uint256 = 0
    for i in range(1, n, bound=6):
        x += i + 1
    return x
    """
    c = get_contract(code)
    for n in range(1, 8):
        assert c.repeat(n) == sum(i + 1 for i in range(1, n))

    # check assertion for `start <= end`
    with tx_failed():
        c.repeat(0)

    # check codegen inserts assertion for `start + bound <= end`
    with tx_failed():
        c.repeat(8)


def test_range_bound_two_runtime_args(get_contract, tx_failed):
    code = """
@external
def repeat(start: uint256, end: uint256) -> uint256:
    x: uint256 = 0
    for i in range(start, end, bound=6):
        x += i
    return x
    """
    c = get_contract(code)
    for n in range(0, 7):
        assert c.repeat(0, n) == sum(range(0, n))
        assert c.repeat(n, n * 2) == sum(range(n, n * 2))

    # check assertion for `start <= end`
    with tx_failed():
        c.repeat(1, 0)
    with tx_failed():
        c.repeat(7, 0)
    with tx_failed():
        c.repeat(8, 7)

    # check codegen inserts assertion for `start + bound <= end`
    with tx_failed():
        c.repeat(0, 7)
    with tx_failed():
        c.repeat(14, 21)


def test_range_overflow(get_contract, tx_failed):
    code = """
@external
def get_last(start: uint256, end: uint256) -> uint256:
    x: uint256 = 0
    for i in range(start, end, bound=6):
        x = i
    return x
    """
    c = get_contract(code)
    UINT_MAX = 2**256 - 1
    assert c.get_last(UINT_MAX, UINT_MAX) == 0  # initial value of x

    for n in range(1, 6):
        assert c.get_last(UINT_MAX - n, UINT_MAX) == UINT_MAX - 1

    # check for `start + bound <= end`, overflow cases
    for n in range(1, 7):
        with tx_failed():
            c.get_last(UINT_MAX - n, 0)
        with tx_failed():
            c.get_last(UINT_MAX, UINT_MAX - n)


    for i in range(frm, frm + 101, bound=101):
    """
    Check that we can get to the upper range of an integer.
    Note that to avoid overflow in the bounds check for range(),
    we need to calculate i+1 inside the loop.
    """
    for i in range(x - 1, x, bound=1):
        if i + 1 == max_value({typ}):
    for i in range(x - 1, x + 1, bound=2):
        if i + 1 == max_value({typ}):
    for i in range(x, x + 2, bound=2):
    for i in range(ind, ind + 30, bound=30):
    for i in range(ind, ind + 30, bound=30):
import re

from vyper.exceptions import ArgumentException, StateAccessViolation, StructureException
        "Invalid syntax for loop iterator",
        "a[1]",
    ),
    (
        """
@external
def foo():
    x: uint256 = 100
    for _ in range(10, bound=x):
        pass
    """,
        StateAccessViolation,
        "Bound must be a literal",
        "x",
    ),
    (
        """
@external
def foo():
    for _ in range(10, 20, bound=5):
        pass
    """,
        StructureException,
        "Please remove the `bound=` kwarg when using range with constants",
        "5",
    ),
    (
        """
@external
def foo():
    for _ in range(10, 20, bound=0):
        pass
    """,
        StructureException,
        "Bound must be at least 1",
        "0",
    ),
    (
        """
@external
def bar():
    x:uint256 = 1
    for i in range(x,x+1,bound=2,extra=3):
        pass
    """,
        ArgumentException,
        "Invalid keyword argument 'extra'",
        "extra=3",
    for i in range(0):
        "End must be greater than start",
        "0",
    ),
    (
        """
@external
def bar():
    x:uint256 = 1
    for i in range(x):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x",
    ),
    (
        """
@external
def bar():
    x:uint256 = 1
    for i in range(0, x):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x",
    ),
    (
        """
@external
def repeat(n: uint256) -> uint256:
    for i in range(0, n * 10):
        pass
    return n
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "n * 10",
    for i in range(0, x + 1):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x + 1",
    ),
    (
        """
@external
def bar():
    for i in range(2, 1):
        "End must be greater than start",
        "1",
    ),
    (
        """
@external
def bar():
    x:uint256 = 1
    for i in range(x, x):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x",
    ),
    (
        """
@external
def foo():
    x: int128 = 5
    for i in range(x, x + 10):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x",
    ),
    (
        """
@external
def repeat(n: uint256) -> uint256:
    for i in range(n, 6):
        pass
    return x
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "n",
    ),
    (
        """
@external
def foo(x: int128):
    y: int128 = 7
    for i in range(x, x + y):
        pass
    """,
        StateAccessViolation,
        "Value must be a literal integer, unless a bound is specified",
        "x",
for_code_regex = re.compile(r"for .+ in (.*):")
fail_test_names = [
    (
        f"{i:02d}: {for_code_regex.search(code).group(1)}"  # type: ignore[union-attr]
        f" raises {type(err).__name__}"
    )
    for i, (code, err, msg, src) in enumerate(fail_list)
]

@pytest.mark.parametrize("bad_code,error_type,message,source_code", fail_list, ids=fail_test_names)
def test_range_fail(bad_code, error_type, message, source_code):
    with pytest.raises(error_type) as exc_info:
        compiler.compile_code(bad_code)
    assert message == exc_info.value.message
    assert source_code == exc_info.value.args[1].node_source_code
    for i in range(1, x, bound=4):
        pass
    """,
    """
@external
def foo():
    x: int128 = 5
    for i in range(x, bound=4):
        pass
    """,
    """
@external
def foo():
    x: int128 = 5
    for i in range(0, x, bound=4):
valid_test_names = [
    f"{i} {for_code_regex.search(code).group(1)}"  # type: ignore[union-attr]
    for i, code in enumerate(valid_list)
]

@pytest.mark.parametrize("good_code", valid_list, ids=valid_test_names)
    def is_literal(self) -> bool:
    def int_value(self) -> int:
        assert isinstance(self.value, int)
        return self.value

    def is_pointer(self) -> bool:
        for_iter: vy_ast.Call = self.stmt.iter
        args_len = len(for_iter.args)
        if args_len == 1:
            arg0, arg1 = (IRnode.from_list(0, typ=iter_typ), for_iter.args[0])
        elif args_len == 2:
            arg0, arg1 = for_iter.args
        else:  # pragma: nocover
            raise TypeCheckFailure("unreachable: bad # of arguments to range()")
        with self.context.range_scope():
            end = Expr.parse_value_expr(arg1, self.context)
            kwargs = {
                s.arg: Expr.parse_value_expr(s.value, self.context) for s in for_iter.keywords
            }

        if "bound" in kwargs:
            with end.cache_when_complex("end") as (b1, end):
                # note: the check for rounds<=rounds_bound happens in asm
                # generation for `repeat`.
                clamped_start = clamp("le", start, end)
                rounds = b1.resolve(IRnode.from_list(["sub", end, clamped_start]))
            rounds_bound = kwargs.pop("bound").int_value()
        else:
            rounds = end.int_value() - start.int_value()
        assert len(kwargs) == 0  # sanity check stray keywords

        if rounds_bound < 1:  # pragma: nocover
            raise TypeCheckFailure("unreachable: unchecked 0 bound")
            the exception occurred. Source annotations are generated in the order
            type_list = _analyse_range_call(node.iter)
                        for stmt in node.body:
                            self.visit(stmt)


def _analyse_range_call(node: vy_ast.Call) -> list[VyperType]:
    """
    Check that the arguments to a range() call are valid.
    :param node: call to range()
    :return: None
    """
    validate_call_args(node, (1, 2), kwargs=["bound"])
    kwargs = {s.arg: s.value for s in node.keywords or []}
    start, end = (vy_ast.Int(value=0), node.args[0]) if len(node.args) == 1 else node.args

    all_args = (start, end, *kwargs.values())
    for arg1 in all_args:
        validate_expected_type(arg1, IntegerT.any())

    type_list = get_common_types(*all_args)
    if not type_list:
        raise TypeMismatch("Iterator values are of different types", node)

    if "bound" in kwargs:
        bound = kwargs["bound"]
        if not isinstance(bound, vy_ast.Num):
            raise StateAccessViolation("Bound must be a literal", bound)
        if bound.value <= 0:
            raise StructureException("Bound must be at least 1", bound)
        if isinstance(start, vy_ast.Num) and isinstance(end, vy_ast.Num):
            error = "Please remove the `bound=` kwarg when using range with constants"
            raise StructureException(error, bound)
    else:
        for arg in (start, end):
            if not isinstance(arg, vy_ast.Num):
                error = "Value must be a literal integer, unless a bound is specified"
                raise StateAccessViolation(error, arg)
        if end.value <= start.value:
            raise StructureException("End must be greater than start", end)

    return type_list