VYPR
High severityNVD Advisory· Published May 11, 2023· Updated Jan 24, 2025

Vyper vulnerable to OOB DynArray access when array is on both LHS and RHS of an assignment

CVE-2023-31146

Description

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.

AI Insight

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

Vyper prior to 0.3.8 mishandles dynarray length word during codegen, causing out-of-bounds access and cross-frame data corruption when the array appears on both sides of an assignment.

Vulnerability description

In Vyper versions before 0.3.8, the compiler's code generation (codegen) incorrectly writes the length word of a dynamic array (dynarray) *before* copying the array data. This ordering flaw can lead to an out-of-bounds array read or write when the same dynarray appears on both the left-hand side (lhs) and the right-hand side (rhs) of an assignment statement [1][2]. The root cause lies in _dynarray_make_setter, which originally stored the new length into the destination array pointer before iterating over elements to copy them [2].

Exploitation

To trigger the bug, an attacker would need to craft a Vyper smart contract that assigns a dynarray to itself (e.g., self.dynarray = self.dynarray) or where the source and destination alias the same underlying storage or memory region. No special privileges are required beyond normal contract deployment; the vulnerability is exercised during the contract's execution when such an assignment is performed. Because the length word is overwritten first, the subsequent copy loop uses the *new* (potentially larger) length to read from the source, which may already have been partially overwritten—causing data corruption that spans across call frames [1][2].

Impact

Successful exploitation results in corrupted memory state, potentially allowing an attacker to overwrite adjacent contract storage slots or influence the execution of other functions. The Vyper team notes that the expected behavior should be a revert due to out-of-bounds array bounds checking; instead, the bug can silently corrupt data [1]. This could lead to unexpected contract behavior, state manipulation, or loss of funds, depending on the contract's logic.

Mitigation

The issue is fixed in Vyper version 0.3.8. The patch, introduced in commit 4f8289a, moves the length word store to *after* the data copy is complete, ensuring that the source array's length remains valid during the copy loop [2]. Developers should upgrade to 0.3.8 or later. No workaround is documented for earlier versions; contracts that perform self-assignment of dynarrays may be vulnerable until upgraded.

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.3.80.3.8

Affected products

1

Patches

1
4f8289a81206

Merge pull request from GHSA-3p37-3636-q8wv

https://github.com/vyperlang/vyperCharles CooperMay 11, 2023via ghsa
2 files changed · +123 15
  • tests/parser/types/test_dynamic_array.py+92 0 modified
    @@ -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())
    
  • vyper/codegen/core.py+31 15 modified
    @@ -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)
     
     
    

Vulnerability mechanics

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

References

5

News mentions

0

No linked articles in our index yet.