Vyper has incorrect re-entrancy lock when key is empty string
Description
Vyper is a Pythonic Smart Contract Language for the Ethereum Virtual Machine (EVM). Starting in version 0.2.9 and prior to version 0.3.10, locks of the type @nonreentrant("") or @nonreentrant('') do not produce reentrancy checks at runtime. This issue is fixed in version 0.3.10. As a workaround, ensure the lock name is a non-empty string.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In Vyper versions 0.2.9 to 0.3.10, the `@nonreentrant("")` decorator fails to enforce reentrancy locks, allowing unsafe reentrant calls in smart contracts.
Vulnerability
Analysis
CVE-2023-42441 is a vulnerability in the Vyper smart contract language, affecting versions 0.2.9 through 0.3.10. The issue lies in the @nonreentrant decorator, which is designed to prevent reentrancy attacks by locking a function during execution. When the lock name is specified as an empty string ("" or ''), the decorator does not produce any reentrancy checks at runtime, effectively making the protection useless [1]. This was identified in the Vyper source code and fixed in version 0.3.10 [2].
Exploitation
An attacker can exploit this by crafting a contract that uses @nonreentrant("") on a function and then calling that function in a reentrant manner, such as via a fallback function that re-enters the same function. No special privileges are needed other than the ability to interact with the vulnerable contract. The root cause is that the compiler fails to correctly process an empty string key for the reentrancy lock, omitting the runtime check entirely [2].
Impact
Successful exploitation could allow an attacker to execute reentrant calls on functions meant to be protected, potentially leading to draining of funds, state manipulation, or other unintended behavior. The impact is limited to contracts compiled with vulnerable Vyper versions that use the empty string lock name [1].
Mitigation
The fix has been implemented in Vyper version 0.3.10 [1]. Developers should upgrade to this version or later. As a workaround, ensure that the lock name argument to @nonreentrant is always a non-empty string [1]. The vulnerability is tracked in the PyPI advisory database as PYSEC-2023-305 [4].
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.
| Package | Affected versions | Patched versions |
|---|---|---|
vyperPyPI | >= 0.2.9, < 0.3.10 | 0.3.10 |
Affected products
1Patches
10b740280c1e3fix: only allow valid identifiers to be nonreentrant keys (#3605)
9 files changed · +147 −126
tests/parser/exceptions/test_structure_exception.py+20 −3 modified@@ -56,9 +56,26 @@ def double_nonreentrant(): """, """ @external -@nonreentrant("B") -@nonreentrant("C") -def double_nonreentrant(): +@nonreentrant(" ") +def invalid_nonreentrant_key(): + pass + """, + """ +@external +@nonreentrant("") +def invalid_nonreentrant_key(): + pass + """, + """ +@external +@nonreentrant("123") +def invalid_nonreentrant_key(): + pass + """, + """ +@external +@nonreentrant("!123abcd") +def invalid_nonreentrant_key(): pass """, """
tests/parser/features/decorators/test_nonreentrant.py+2 −2 modified@@ -142,7 +142,7 @@ def set_callback(c: address): @external @payable -@nonreentrant('default') +@nonreentrant("lock") def protected_function(val: String[100], do_callback: bool) -> uint256: self.special_value = val _amount: uint256 = msg.value @@ -166,7 +166,7 @@ def unprotected_function(val: String[100], do_callback: bool): @external @payable -@nonreentrant('default') +@nonreentrant("lock") def __default__(): pass """
tests/parser/test_call_graph_stability.py+1 −1 modified@@ -6,8 +6,8 @@ from hypothesis import given, settings import vyper.ast as vy_ast +from vyper.ast.identifiers import RESERVED_KEYWORDS from vyper.compiler.phases import CompilerData -from vyper.semantics.namespace import RESERVED_KEYWORDS def _valid_identifier(attr):
tests/parser/types/test_identifier_naming.py+1 −1 modified@@ -1,10 +1,10 @@ import pytest from vyper.ast.folding import BUILTIN_CONSTANTS +from vyper.ast.identifiers import RESERVED_KEYWORDS from vyper.builtins.functions import BUILTIN_FUNCTIONS from vyper.codegen.expr import ENVIRONMENT_VARIABLES from vyper.exceptions import NamespaceCollision, StructureException, SyntaxException -from vyper.semantics.namespace import RESERVED_KEYWORDS from vyper.semantics.types.primitives import AddressT BUILTIN_CONSTANTS = set(BUILTIN_CONSTANTS.keys())
vyper/ast/identifiers.py+111 −0 added@@ -0,0 +1,111 @@ +import re + +from vyper.exceptions import StructureException + + +def validate_identifier(attr, ast_node=None): + if not re.match("^[_a-zA-Z][a-zA-Z0-9_]*$", attr): + raise StructureException(f"'{attr}' contains invalid character(s)", ast_node) + if attr.lower() in RESERVED_KEYWORDS: + raise StructureException(f"'{attr}' is a reserved keyword", ast_node) + + +# https://docs.python.org/3/reference/lexical_analysis.html#keywords +# note we don't technically need to block all python reserved keywords, +# but do it for hygiene +_PYTHON_RESERVED_KEYWORDS = { + "False", + "None", + "True", + "and", + "as", + "assert", + "async", + "await", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "nonlocal", + "not", + "or", + "pass", + "raise", + "return", + "try", + "while", + "with", + "yield", +} +_PYTHON_RESERVED_KEYWORDS = {s.lower() for s in _PYTHON_RESERVED_KEYWORDS} + +# Cannot be used for variable or member naming +RESERVED_KEYWORDS = _PYTHON_RESERVED_KEYWORDS | { + # decorators + "public", + "external", + "nonpayable", + "constant", + "immutable", + "transient", + "internal", + "payable", + "nonreentrant", + # "class" keywords + "interface", + "struct", + "event", + "enum", + # EVM operations + "unreachable", + # special functions (no name mangling) + "init", + "_init_", + "___init___", + "____init____", + "default", + "_default_", + "___default___", + "____default____", + # more control flow and special operations + "range", + # more special operations + "indexed", + # denominations + "ether", + "wei", + "finney", + "szabo", + "shannon", + "lovelace", + "ada", + "babbage", + "gwei", + "kwei", + "mwei", + "twei", + "pwei", + # sentinal constant values + # TODO remove when these are removed from the language + "zero_address", + "empty_bytes32", + "max_int128", + "min_int128", + "max_decimal", + "min_decimal", + "max_uint256", + "zero_wei", +}
vyper/exceptions.py+3 −1 modified@@ -54,7 +54,9 @@ def __init__(self, message="Error Message not found.", *items): # support older exceptions that don't annotate - remove this in the future! self.lineno, self.col_offset = items[0][:2] else: - self.annotations = items + # strip out None sources so that None can be passed as a valid + # annotation (in case it is only available optionally) + self.annotations = [k for k in items if k is not None] def with_annotation(self, *annotations): """
vyper/semantics/namespace.py+3 −116 modified@@ -1,12 +1,7 @@ import contextlib -import re - -from vyper.exceptions import ( - CompilerPanic, - NamespaceCollision, - StructureException, - UndeclaredDefinition, -) + +from vyper.ast.identifiers import validate_identifier +from vyper.exceptions import CompilerPanic, NamespaceCollision, UndeclaredDefinition from vyper.semantics.analysis.levenshtein_utils import get_levenshtein_error_suggestions @@ -121,111 +116,3 @@ def override_global_namespace(ns): finally: # unclobber _namespace = tmp - - -def validate_identifier(attr): - if not re.match("^[_a-zA-Z][a-zA-Z0-9_]*$", attr): - raise StructureException(f"'{attr}' contains invalid character(s)") - if attr.lower() in RESERVED_KEYWORDS: - raise StructureException(f"'{attr}' is a reserved keyword") - - -# https://docs.python.org/3/reference/lexical_analysis.html#keywords -# note we don't technically need to block all python reserved keywords, -# but do it for hygiene -_PYTHON_RESERVED_KEYWORDS = { - "False", - "None", - "True", - "and", - "as", - "assert", - "async", - "await", - "break", - "class", - "continue", - "def", - "del", - "elif", - "else", - "except", - "finally", - "for", - "from", - "global", - "if", - "import", - "in", - "is", - "lambda", - "nonlocal", - "not", - "or", - "pass", - "raise", - "return", - "try", - "while", - "with", - "yield", -} -_PYTHON_RESERVED_KEYWORDS = {s.lower() for s in _PYTHON_RESERVED_KEYWORDS} - -# Cannot be used for variable or member naming -RESERVED_KEYWORDS = _PYTHON_RESERVED_KEYWORDS | { - # decorators - "public", - "external", - "nonpayable", - "constant", - "immutable", - "transient", - "internal", - "payable", - "nonreentrant", - # "class" keywords - "interface", - "struct", - "event", - "enum", - # EVM operations - "unreachable", - # special functions (no name mangling) - "init", - "_init_", - "___init___", - "____init____", - "default", - "_default_", - "___default___", - "____default____", - # more control flow and special operations - "range", - # more special operations - "indexed", - # denominations - "ether", - "wei", - "finney", - "szabo", - "shannon", - "lovelace", - "ada", - "babbage", - "gwei", - "kwei", - "mwei", - "twei", - "pwei", - # sentinal constant values - # TODO remove when these are removed from the language - "zero_address", - "empty_bytes32", - "max_int128", - "min_int128", - "max_decimal", - "min_decimal", - "max_uint256", - "zero_wei", -}
vyper/semantics/types/base.py+1 −1 modified@@ -3,6 +3,7 @@ from vyper import ast as vy_ast from vyper.abi_types import ABIType +from vyper.ast.identifiers import validate_identifier from vyper.exceptions import ( CompilerPanic, InvalidLiteral, @@ -12,7 +13,6 @@ UnknownAttribute, ) from vyper.semantics.analysis.levenshtein_utils import get_levenshtein_error_suggestions -from vyper.semantics.namespace import validate_identifier # Some fake type with an overridden `compare_type` which accepts any RHS
vyper/semantics/types/function.py+5 −1 modified@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Tuple from vyper import ast as vy_ast +from vyper.ast.identifiers import validate_identifier from vyper.ast.validation import validate_call_args from vyper.exceptions import ( ArgumentException, @@ -220,7 +221,10 @@ def from_FunctionDef( msg = "Nonreentrant decorator disallowed on `__init__`" raise FunctionDeclarationException(msg, decorator) - kwargs["nonreentrant"] = decorator.args[0].value + nonreentrant_key = decorator.args[0].value + validate_identifier(nonreentrant_key, decorator.args[0]) + + kwargs["nonreentrant"] = nonreentrant_key elif isinstance(decorator, vy_ast.Name): if FunctionVisibility.is_valid_value(decorator.id):
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-3hg2-r75x-g69mghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-42441ghsaADVISORY
- github.com/pypa/advisory-database/tree/main/vulns/vyper/PYSEC-2023-305.yamlghsaWEB
- github.com/vyperlang/vyper/commit/0b740280c1e3c5528a20d47b29831948ddcc6d83ghsax_refsource_MISCWEB
- github.com/vyperlang/vyper/pull/3605ghsax_refsource_MISCWEB
- github.com/vyperlang/vyper/security/advisories/GHSA-3hg2-r75x-g69mghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.