vyper: Multi-byte identifier characters can silently change literal values
What fixes it
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
Summary
A multi-byte character, such as a non-ASCII identifier or a byte-order mark, shifts Vyper's source lookup for later literals on the same line. Crafted source can make numeric literals compile with values or types that are not the ones written, without a compiler warning.
Details
In vyper/ast/parse.py, line_index counts characters, but Python's col_offset counts UTF-8 bytes within the line. Each multi-byte character shifts later source slices on that line right by its extra bytes. visit_Num classifies literals and reconstructs hexadecimal, binary and decimal values from these slices. A replacement placed where the slice lands therefore becomes the compiled value, and a plain integer can become a hex literal.
Two triggers are reachable:
- Non-ASCII identifiers that NFKC-normalize to ASCII, such as
fi,fooor𝚏𝚘𝚘. - A byte-order mark at the start of the file, which shifts literals on line 1. It is invisible.
This shares its root cause with GHSA-wvx7-39qj-2g9v: the line table introduced in 0.4.1.
Proof of concept
The identifier uses mathematical monospace letters (4 bytes, 1 character each). They shift the lookup onto the address in the trailing comment:
import vyper
# The identifier uses MATHEMATICAL MONOSPACE letters (4 UTF-8 bytes each),
# which Python normalizes to the ASCII name TREASURY_ADDRESS_FOR_FEES.
source = """𝚃𝚁𝙴𝙰𝚂𝚄𝚁𝚈_𝙰𝙳𝙳𝚁𝙴𝚂𝚂_𝙵𝙾𝚁_𝙵𝙴𝙴𝚂: constant(address) = 0x000000000000000000000000000000000000dEaD # 0x00000000000000000000000000000000c0Ffee11
@external
@view
def treasury() -> address:
return TREASURY_ADDRESS_FOR_FEES
"""
# Same contract with an ASCII identifier
clean = source.replace(source.split(":")[0], "TREASURY_ADDRESS_FOR_FEES", 1)
# The getter's runtime bytecode matches an explicitly substituted contract.
substituted = clean.replace(
"0x000000000000000000000000000000000000dEaD",
"0x00000000000000000000000000000000c0Ffee11",
1,
)
def runtime(code):
return vyper.compile_code(code, output_formats=["bytecode_runtime"])["bytecode_runtime"]
assert runtime(source) == runtime(substituted)
assert runtime(source) != runtime(clean)Both assertions pass.
Impact
An attacker able to contribute source code can change an address, selector, byte sequence, integer or decimal constant. Identifier triggers can pass as ordinary letters in review; a byte-order mark is invisible.
Affected versions
| pip range | Notes | | ------------------------ | ------------------------------------------------------------------------------------------ | | >= 0.1.0b1, < 0.1.0b12 | Literals are read using Python's byte offsets. | | = 0.1.0b14 | A parser refactor overwrites the asttokens character offsets with Python's byte offsets. | | >= 0.4.1 | Affected through 0.5.0b1; also unpatched on the inspected master checkout (17e925db). |
0.1.0b12, 0.1.0b13 and 0.1.0b15 through 0.4.0 use character offsets from asttokens and are unaffected.
Remediation
Convert byte offsets to character offsets using each line's own text before slicing, and take literal values from the parser instead of source text. Until fixed, reject non-ASCII characters outside comments and docstrings, including a byte-order mark.
Text from GitHub Advisory Database
Affected versions 3
- vyper, pip>= 0.1.0b1, < 0.1.0b12GitHub
- vyper, pip= 0.1.0b14GitHub
- vyper, pip>= 0.4.1GitHub