CVE-2023-31146 (published 2023-05-11T21:15Z)

Vyper is a Pythonic smart contract language for the Ethereum virtual machine. Prior to version 0.3.8, during codegen, the length word of a dynarray is written before the data, which can result in out-of-bounds array access in the case where the dynarray is on both the lhs and rhs of an assignment. The issue can cause data corruption across call frames. The expected behavior is to revert due to out-of-bounds array access. Version 0.3.8 contains a patch for this issue.

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

Weakness information:
cwe_id: CWE-787
cwe_name: Out-of-bounds Write
description: The product writes data past the end, or before the beginning, of the intended buffer.
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-05-11 13:30:00-07:00
msg: Merge pull request from GHSA-3p37-3636-q8wv

in dynarray_make_setter, the length is copied before the data. when the
dst and src arrays do not overlap, this is not a problem. however, when
the dst and src are the same dynarray, this can lead to a
store-before-load, leading any array bounds checks on the right hand
side to function incorrectly. here is an example:

```vyper
@external
def should_revert() -> DynArray[uint256,3]:
    a: DynArray[uint256, 3] = [1, 2, 3]
    a = empty(DynArray[uint256, 3])
    a = [self.a[0], self.a[1], self.a[2]]
    return a  # if bug: returns [1,2,3]
```

this commit moves the length store to after the data copy in
dynarray_make_setter. for hygiene, it also moves the length store to
after the data copy in several other routines. I left pop_dyn_array()
unchanged, because moving the routine does not actually perform any data
copy, it just writes the new length (and optionally returns a pointer to
the popped item).
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: test_dynamic_array.py
change_type: ModificationType.MODIFY
diff: @@ -1748,3 +1748,95 @@ def foo(i: uint256) -> {return_type}:
     return MY_CONSTANT[i]
     """
     assert_compile_failed(lambda: get_contract(code), TypeMismatch)
+
+
+dynarray_length_no_clobber_cases = [
+    # GHSA-3p37-3636-q8wv cases
+    """
+a: DynArray[uint256,3]
+
+@external
+def should_revert() -> DynArray[uint256,3]:
+    self.a = [1,2,3]
+    self.a = empty(DynArray[uint256,3])
+    self.a = [self.a[0], self.a[1], self.a[2]]
+
+    return self.a  # if bug: returns [1,2,3]
+    """,
+    """
+@external
+def should_revert() -> DynArray[uint256,3]:
+    self.a()
+    return self.b() # if bug: returns [1,2,3]
+
+@internal
+def a():
+    a: uint256 = 0
+    b: uint256 = 1
+    c: uint256 = 2
+    d: uint256 = 3
+
+@internal
+def b() -> DynArray[uint256,3]:
+    a: DynArray[uint256,3] = empty(DynArray[uint256,3])
+    a = [a[0],a[1],a[2]]
+    return a
+    """,
+    """
+a: DynArray[uint256,4]
+
+@external
+def should_revert() -> DynArray[uint256,4]:
+    self.a = [1,2,3]
+    self.a = empty(DynArray[uint256,4])
+    self.a = [4, self.a[0]]
+
+    return self.a  # if bug: return [4, 4]
+    """,
+    """
+@external
+def should_revert() -> DynArray[uint256,4]:
+    a: DynArray[uint256, 4] = [1,2,3]
+    a = []
+
+    a = [a.pop()]  # if bug: return [1]
+
+    return a
+    """,
+    """
+@external
+def should_revert():
+    c: DynArray[uint256, 1] = []
+    c.append(c[0])
+    """,
+    """
+@external
+def should_revert():
+    c: DynArray[uint256, 1] = [1]
+    c[0] = c.pop()
+    """,
+    """
+@external
+def should_revert():
+    c: DynArray[DynArray[uint256, 1], 2] = [[]]
+    c[0] = c.pop()
+    """,
+    """
+a: DynArray[String[65],2]
+
+@external
+def should_revert() -> DynArray[String[65], 2]:
+    self.a = ["hello", "world"]
+    self.a = []
+    self.a = [self.a[0], self.a[1]]
+
+    return self.a  # if bug: return ["hello", "world"]
+    """,
+]
+
+
+@pytest.mark.parametrize("code", dynarray_length_no_clobber_cases)
+def test_dynarray_length_no_clobber(get_contract, assert_tx_failed, code):
+    # check that length is not clobbered before dynarray data copy happens
+    c = get_contract(code)
+    assert_tx_failed(lambda: c.should_revert())

programming_language: Python
filename: core.py
change_type: ModificationType.MODIFY
diff: @@ -117,13 +117,15 @@ def make_byte_array_copier(dst, src):
             max_bytes = src.typ.maxlen
 
             ret = ["seq"]
+
+            dst_ = bytes_data_ptr(dst)
+            src_ = bytes_data_ptr(src)
+
+            ret.append(copy_bytes(dst_, src_, len_, max_bytes))
+
             # store length
             ret.append(STORE(dst, len_))
 
-            dst = bytes_data_ptr(dst)
-            src = bytes_data_ptr(src)
-
-            ret.append(copy_bytes(dst, src, len_, max_bytes))
             return b1.resolve(b2.resolve(ret))
 
 
@@ -148,25 +150,34 @@ def _dynarray_make_setter(dst, src):
     if src.value == "~empty":
         return IRnode.from_list(STORE(dst, 0))
 
+    # copy contents of src dynarray to dst.
+    # note that in case src and dst refer to the same dynarray,
+    # in order for get_element_ptr oob checks on the src dynarray
+    # to work, we need to wait until after the data is copied
+    # before we clobber the length word.
+
     if src.value == "multi":
         ret = ["seq"]
         # handle literals
 
-        # write the length word
-        store_length = STORE(dst, len(src.args))
-        ann = None
-        if src.annotation is not None:
-            ann = f"len({src.annotation})"
-        store_length = IRnode.from_list(store_length, annotation=ann)
-        ret.append(store_length)
-
+        # copy each item
         n_items = len(src.args)
+
         for i in range(n_items):
             k = IRnode.from_list(i, typ=UINT256_T)
             dst_i = get_element_ptr(dst, k, array_bounds_check=False)
             src_i = get_element_ptr(src, k, array_bounds_check=False)
             ret.append(make_setter(dst_i, src_i))
 
+        # write the length word after data is copied
+        store_length = STORE(dst, n_items)
+        ann = None
+        if src.annotation is not None:
+            ann = f"len({src.annotation})"
+        store_length = IRnode.from_list(store_length, annotation=ann)
+
+        ret.append(store_length)
+
         return ret
 
     with src.cache_when_complex("darray_src") as (b1, src):
@@ -190,8 +201,6 @@ def _dynarray_make_setter(dst, src):
         with get_dyn_array_count(src).cache_when_complex("darray_count") as (b2, count):
             ret = ["seq"]
 
-            ret.append(STORE(dst, count))
-
             if should_loop:
                 i = IRnode.from_list(_freshname("copy_darray_ix"), typ=UINT256_T)
 
@@ -213,6 +222,9 @@ def _dynarray_make_setter(dst, src):
                 dst_ = dynarray_data_ptr(dst)
                 ret.append(copy_bytes(dst_, src_, n_bytes, max_bytes))
 
+            # write the length word after data is copied
+            ret.append(STORE(dst, count))
+
             return b1.resolve(b2.resolve(ret))
 
 
@@ -336,12 +348,14 @@ def append_dyn_array(darray_node, elem_node):
         with len_.cache_when_complex("old_darray_len") as (b2, len_):
             assertion = ["assert", ["lt", len_, darray_node.typ.count]]
             ret.append(IRnode.from_list(assertion, error_msg=f"{darray_node.typ} bounds check"))
-            ret.append(STORE(darray_node, ["add", len_, 1]))
             # NOTE: typechecks elem_node
             # NOTE skip array bounds check bc we already asserted len two lines up
             ret.append(
                 make_setter(get_element_ptr(darray_node, len_, array_bounds_check=False), elem_node)
             )
+
+            # store new length
+            ret.append(STORE(darray_node, ["add", len_, 1]))
             return IRnode.from_list(b1.resolve(b2.resolve(ret)))
 
 
@@ -354,6 +368,7 @@ def pop_dyn_array(darray_node, return_popped_item):
         new_len = IRnode.from_list(["sub", old_len, 1], typ=UINT256_T)
 
         with new_len.cache_when_complex("new_len") as (b2, new_len):
+            # store new length
             ret.append(STORE(darray_node, new_len))
 
             # NOTE skip array bounds check bc we already asserted len two lines up
@@ -364,6 +379,7 @@ def pop_dyn_array(darray_node, return_popped_item):
                 location = popped_item.location
             else:
                 typ, location = None, None
+
             return IRnode.from_list(b1.resolve(b2.resolve(ret)), typ=typ, location=location)
 
 

programming_language: Python