CVE-2023-46247 (published 2023-12-13T20:15Z)

Vyper is a Pythonic Smart Contract Language for the Ethereum Virtual Machine (EVM). Contracts containing large arrays might underallocate the number of slots they need by 1. Prior to v0.3.8, the calculation to determine how many slots a storage variable needed used `math.ceil(type_.size_in_bytes / 32)`. The intermediate floating point step can produce a rounding error if there are enough bits set in the IEEE-754 mantissa. Roughly speaking, if `type_.size_in_bytes` is large (> 2**46), and slightly less than a power of 2, the calculation can overestimate how many slots are needed by 1. If `type_.size_in_bytes` is slightly more than a power of 2, the calculation can underestimate how many slots are needed by 1. This issue is patched in version 0.3.8.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N)

Weakness information:
cwe_id: CWE-682
cwe_name: Incorrect Calculation
description: The product performs a calculation that generates incorrect or unintended results that are later used in security-critical decisions or resource management.
extended_description: When product performs a security-critical calculation incorrectly, it might lead to incorrect resource allocations, incorrect privilege assignments, or failed comparisons among other things. Many of the direct results of an incorrect calculation can lead to even larger problems such as failed protection mechanisms or even arbitrary code execution.
         
cwe_id: CWE-193
cwe_name: Off-by-one Error
description: A product calculates or uses an incorrect maximum or minimum value that is 1 more, or 1 less, than the correct value.
extended_description: 
            
            
            
            
            
         
         

Fix information:
repo_url: https://github.com/vyperlang/vyper
commit_details: repo_url: https://github.com/vyperlang/vyper
author: Charles Cooper
author_date: 2023-04-28 17:40:15-07:00
msg: Merge pull request from GHSA-mgv8-gggw-mrg6

* fix: block storage allocator overflows

the storage allocator did not guard against overflow when no storage
layout override was provided. this could result in vulnerabilities like
the following:

```vyper
owner: public(address)
buffer: public(uint256[max_value(uint256)])

@external
def initialize():
    self.owner = msg.sender

@external
def foo(idx: uint256, data: uint256):
    self.buffer[idx] = data
```

while the get_element_ptr calculation for `self.buffer[idx]` is checked,
it is not checked in `mod_{2**256}` arithmetic, which can lead to
arithmetic wrapping back to the `owner` variable if the provided `idx`
is large enough.

* clean up allocator logic

also fix a bug where large allocations would use too much storage due to
floating point rounding precision

* add warning for large arrays

* add note about 2**64 behavior
repository: repo_url: https://github.com/vyperlang/vyper
repo_name: vyperlang/vyper
description: Pythonic Smart Contract Language for the EVM
repo_language: Python
owner: vyperlang
file_changes: filename: types.rst
change_type: ModificationType.MODIFY
diff: @@ -520,6 +520,9 @@ A two dimensional list can be declared with ``_name: _ValueType[inner_size][oute
     # Returning the value in row 0 column 4 (in this case 14)
     return exampleList2D[0][4]
 
+.. note::
+    Defining an array in storage whose size is significantly larger than ``2**64`` can result in security vulnerabilities due to risk of overflow.
+
 .. index:: !dynarrays
 
 Dynamic Arrays
@@ -561,6 +564,10 @@ Dynamic arrays represent bounded arrays whose length can be modified at runtime,
 
 In the ABI, they are represented as ``_Type[]``. For instance, ``DynArray[int128, 3]`` gets represented as ``int128[]``, and ``DynArray[DynArray[int128, 3], 3]`` gets represented as ``int128[][]``.
 
+.. note::
+    Defining a dynamic array in storage whose size is significantly larger than ``2**64`` can result in security vulnerabilities due to risk of overflow.
+
+
 .. _types-struct:
 
 Structs

programming_language: Markdown
filename: test_storage_layout_overrides.py
change_type: ModificationType.MODIFY
diff: @@ -95,6 +95,21 @@ symbol: public(String[32])"""
         )
 
 
+def test_overflow():
+    code = """
+x: uint256[2]
+    """
+
+    storage_layout_override = {"x": {"slot": 2**256 - 1, "type": "uint256[2]"}}
+
+    with pytest.raises(
+        StorageLayoutException, match=f"Invalid storage slot for var x, out of bounds: {2**256}\n"
+    ):
+        compile_code(
+            code, output_formats=["layout"], storage_layout_override=storage_layout_override
+        )
+
+
 def test_incomplete_overrides():
     code = """
 name: public(String[64])

programming_language: Python
filename: test_storage_slots.py
change_type: ModificationType.MODIFY
diff: @@ -1,3 +1,7 @@
+import pytest
+
+from vyper.exceptions import StorageLayoutException
+
 code = """
 
 struct StructOne:
@@ -97,3 +101,15 @@ def test_reentrancy_lock(get_contract):
     assert [c.foo(0, i) for i in range(3)] == [987, 654, 321]
     assert [c.foo(1, i) for i in range(3)] == [123, 456, 789]
     assert c.h(0) == 123456789
+
+
+def test_allocator_overflow(get_contract):
+    code = """
+x: uint256
+y: uint256[max_value(uint256)]
+    """
+    with pytest.raises(
+        StorageLayoutException,
+        match=f"Invalid storage slot for var y, tried to allocate slots 1 through {2**256}\n",
+    ):
+        get_contract(code)

programming_language: Python
filename: data_positions.py
change_type: ModificationType.MODIFY
diff: @@ -6,6 +6,7 @@ from vyper import ast as vy_ast
 from vyper.exceptions import StorageLayoutException
 from vyper.semantics.analysis.base import CodeOffset, StorageSlot
 from vyper.typing import StorageLayout
+from vyper.utils import ceil32
 
 
 def set_data_positions(
@@ -121,8 +122,7 @@ def set_storage_slots_with_overrides(
         # Expect to find this variable within the storage layout overrides
         if node.target.id in storage_layout_overrides:
             var_slot = storage_layout_overrides[node.target.id]["slot"]
-            # Calculate how many storage slots are required
-            storage_length = math.ceil(varinfo.typ.size_in_bytes / 32)
+            storage_length = varinfo.typ.storage_size_in_words
             # Ensure that all required storage slots are reserved, and prevents other variables
             # from using these slots
             reserved_slots.reserve_slot_range(var_slot, storage_length, node.target.id)
@@ -139,6 +139,21 @@ def set_storage_slots_with_overrides(
     return ret
 
 
+class SimpleStorageAllocator:
+    def __init__(self, starting_slot: int = 0):
+        self._slot = starting_slot
+
+    def allocate_slot(self, n, var_name):
+        ret = self._slot
+        if self._slot + n >= 2**256:
+            raise StorageLayoutException(
+                f"Invalid storage slot for var {var_name}, tried to allocate"
+                f" slots {self._slot} through {self._slot + n}"
+            )
+        self._slot += n
+        return ret
+
+
 def set_storage_slots(vyper_module: vy_ast.Module) -> StorageLayout:
     """
     Parse module-level Vyper AST to calculate the layout of storage variables.
@@ -146,7 +161,7 @@ def set_storage_slots(vyper_module: vy_ast.Module) -> StorageLayout:
     """
     # Allocate storage slots from 0
     # note storage is word-addressable, not byte-addressable
-    storage_slot = 0
+    allocator = SimpleStorageAllocator()
 
     ret: Dict[str, Dict] = {}
 
@@ -165,16 +180,17 @@ def set_storage_slots(vyper_module: vy_ast.Module) -> StorageLayout:
             type_.set_reentrancy_key_position(StorageSlot(_slot))
             continue
 
-        type_.set_reentrancy_key_position(StorageSlot(storage_slot))
+        # TODO use one byte - or bit - per reentrancy key
+        # requires either an extra SLOAD or caching the value of the
+        # location in memory at entrance
+        slot = allocator.allocate_slot(1, variable_name)
+
+        type_.set_reentrancy_key_position(StorageSlot(slot))
 
         # TODO this could have better typing but leave it untyped until
         # we nail down the format better
-        ret[variable_name] = {"type": "nonreentrant lock", "slot": storage_slot}
+        ret[variable_name] = {"type": "nonreentrant lock", "slot": slot}
 
-        # TODO use one byte - or bit - per reentrancy key
-        # requires either an extra SLOAD or caching the value of the
-        # location in memory at entrance
-        storage_slot += 1
 
     for node in vyper_module.get_children(vy_ast.VariableDecl):
         # skip non-storage variables
@@ -182,19 +198,20 @@ def set_storage_slots(vyper_module: vy_ast.Module) -> StorageLayout:
             continue
 
         varinfo = node.target._metadata["varinfo"]
-        varinfo.set_position(StorageSlot(storage_slot))
-
         type_ = varinfo.typ
 
-        # this could have better typing but leave it untyped until
-        # we understand the use case better
-        ret[node.target.id] = {"type": str(type_), "slot": storage_slot}
-
         # CMC 2021-07-23 note that HashMaps get assigned a slot here.
         # I'm not sure if it's safe to avoid allocating that slot
         # for HashMaps because downstream code might use the slot
         # ID as a salt.
-        storage_slot += math.ceil(type_.size_in_bytes / 32)
+        n_slots = type_.storage_size_in_words
+        slot = allocator.allocate_slot(n_slots, node.target.id)
+
+        varinfo.set_position(StorageSlot(slot))
+
+        # this could have better typing but leave it untyped until
+        # we understand the use case better
+        ret[node.target.id] = {"type": str(type_), "slot": slot}
 
     return ret
 
@@ -216,7 +233,7 @@ def set_code_offsets(vyper_module: vy_ast.Module) -> Dict:
         type_ = varinfo.typ
         varinfo.set_position(CodeOffset(offset))
 
-        len_ = math.ceil(type_.size_in_bytes / 32) * 32
+        len_ = ceil32(type_.size_in_bytes)
 
         # this could have better typing but leave it untyped until
         # we understand the use case better

programming_language: Python
filename: subscriptable.py
change_type: ModificationType.MODIFY
diff: @@ -103,6 +103,9 @@ class _SequenceT(_SubscriptableT):
         if not 0 < length < 2**256:
             raise InvalidType("Array length is invalid")
 
+        if length >= 2**64:
+            warnings.warn("Use of large arrays can be unsafe!")
+
         super().__init__(UINT256_T, value_type)
         self.length = length
 

programming_language: Python