From 6a3347fc0bb3c67191be26e7a7c5b45c7f560716 Mon Sep 17 00:00:00 2001 From: Joseph Viscomi Date: Thu, 21 May 2026 08:51:36 -0400 Subject: [PATCH] Add Python 3.14 support Python 3.14 reshuffles the bytecode in three ways that touch Atheris's instrumentation logic: * New non-popping conditional jumps `JUMP_IF_TRUE` / `JUMP_IF_FALSE`, relative-forward. * `LOAD_SMALL_INT` is now used to push small-integer literals; previously every literal flowed through `LOAD_CONST`. * Several existing opcodes were renamed or removed (e.g. `RETURN_CONST`, `LOAD_METHOD`, `BUILD_CONST_KEY_MAP`); none of which Atheris referenced by name, so no change is required there. Source changes: * Allow `PYTHON_VERSION == (3, 14)` past the version gate; update the error message and the supported-versions docstring. * Categorize the two new conditional jumps under `CONDITIONAL_JUMPS` and `HAVE_REL_REFERENCE` so offset rewriting handles them. * Introduce `CONST_LOADS` and use it in the constant-compare instrumentation path so `LOAD_SMALL_INT` is recognized as a constant push alongside `LOAD_CONST`. Test change to `test_extended_arg` (minimal scope): * Replace `for inst in original_code.co_code: assertNotEqual(dis.opname[inst], "EXTENDED_ARG")` with iteration over `dis.get_instructions(code)`. The previous loop indexed `dis.opname` with raw `co_code` bytes, which include argument bytes; that worked on Python 3.11-3.13 by luck (no argument byte coincided with the EXTENDED_ARG opcode value) but produces a false positive on Python 3.14, where a `COMPARE_OP` argument byte equals the new EXTENDED_ARG opcode number (69). `dis.get_instructions` is the documented correct API for decoding bytecode. * Drop one `pass` from the fixture (253 to 252). The function was calibrated so the original bytecode stayed below the EXTENDED_ARG threshold (~256-byte jumps); Python 3.14's slightly more verbose bytecode pushed 253 passes past that threshold, which would defeat the test's "original lacks EXTENDED_ARG; patching inserts one" invariant. At 252 passes the invariant holds identically on both Python 3.13 and 3.14 (verified empirically). The test's intent and both assertions are otherwise unchanged. Infrastructure: * README, CI matrix, and the manylinux Dockerfile now include 3.14. Validated against the full Atheris test suite on both Python 3.13.12 and 3.14.2: 149 / 149 tests pass on each. The lone pyinstaller_coverage_test failure on both versions is unrelated to 3.14, it requires PyInstaller to be installed (handled by `run_tests.sh` in CI). Closes #105 --- .github/workflows/builds.yaml | 2 +- README.md | 2 +- deployment/Dockerfile | 4 +++- src/instrument_bytecode.py | 3 ++- src/instrument_bytecode_test.py | 13 +++++++------ src/version_dependent.py | 26 ++++++++++++++++++++++++-- 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/workflows/builds.yaml b/.github/workflows/builds.yaml index 71d09361..147d877a 100644 --- a/.github/workflows/builds.yaml +++ b/.github/workflows/builds.yaml @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] needs: [ruff] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 11235353..1d0f2a71 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Atheris is a coverage-guided Python fuzzing engine. It supports fuzzing of Pytho ## Installation Instructions -Atheris supports Linux (32- and 64-bit) and Mac OS X, Python versions 3.11-3.13. +Atheris supports Linux (32- and 64-bit) and Mac OS X, Python versions 3.11-3.14. Versions 3.10 and below are not supported in the current source version, but remain accessible in PyPI. diff --git a/deployment/Dockerfile b/deployment/Dockerfile index 7653f57c..9dd7c315 100644 --- a/deployment/Dockerfile +++ b/deployment/Dockerfile @@ -39,7 +39,8 @@ RUN set -e -x -v; \ RUN set -e -x -v; \ /opt/python/cp311-cp311/bin/python3 -m pip install setuptools && \ /opt/python/cp312-cp312/bin/python3 -m pip install setuptools && \ - /opt/python/cp313-cp313/bin/python3 -m pip install setuptools; + /opt/python/cp313-cp313/bin/python3 -m pip install setuptools && \ + /opt/python/cp314-cp314/bin/python3 -m pip install setuptools; WORKDIR /atheris @@ -47,5 +48,6 @@ CMD export LIBFUZZER_LIB="/root/llvm-project/build/lib/clang/$(ls /root/llvm-pro /opt/python/cp311-cp311/bin/python3 setup.py bdist_wheel -d /tmp/dist && \ /opt/python/cp312-cp312/bin/python3 setup.py bdist_wheel -d /tmp/dist && \ /opt/python/cp313-cp313/bin/python3 setup.py bdist_wheel -d /tmp/dist && \ + /opt/python/cp314-cp314/bin/python3 setup.py bdist_wheel -d /tmp/dist && \ ( cd /tmp/dist && find /tmp/dist/* | xargs -I{} auditwheel repair --plat manylinux2014_x86_64 {} ) && \ mkdir -p /atheris/dist && cp /tmp/dist/wheelhouse/* /atheris/dist/ diff --git a/src/instrument_bytecode.py b/src/instrument_bytecode.py index e672d721..91bb89e4 100644 --- a/src/instrument_bytecode.py +++ b/src/instrument_bytecode.py @@ -33,6 +33,7 @@ from .version_dependent import CALLABLE_STACK_ENTRIES from .version_dependent import CMP_OP_SHIFT_AMOUNT from .version_dependent import CONDITIONAL_JUMPS +from .version_dependent import CONST_LOADS from .version_dependent import ExceptionTable from .version_dependent import ExceptionTableEntry from .version_dependent import generate_exceptiontable @@ -810,7 +811,7 @@ def trace_data_flow(self) -> None: seen_consts = [] for c, instr in enumerate(self.instructions): - if instr.mnemonic == "LOAD_CONST": + if instr.mnemonic in CONST_LOADS: seen_consts.append(stack_size) elif instr.mnemonic == "COMPARE_OP" and ( instr.arg >> CMP_OP_SHIFT_AMOUNT diff --git a/src/instrument_bytecode_test.py b/src/instrument_bytecode_test.py index 7a769f78..7c754057 100644 --- a/src/instrument_bytecode_test.py +++ b/src/instrument_bytecode_test.py @@ -617,16 +617,17 @@ def near_extended_arg(x, y): pass pass pass - pass return x return y original_code = near_extended_arg.__code__ # Ensure that the original code does not contain any EXTENDED_ARG - # instructions. - for inst in original_code.co_code: - self.assertNotEqual(dis.opname[inst], "EXTENDED_ARG") + # instructions. `dis.get_instructions` decodes properly so argument + # bytes that happen to equal the EXTENDED_ARG opcode value are not + # mistaken for opcodes. + for instr in dis.get_instructions(original_code): + self.assertNotEqual(instr.opname, "EXTENDED_ARG") patched_code = instrument_bytecode.patch_code( original_code, trace_dataflow=True @@ -635,8 +636,8 @@ def near_extended_arg(x, y): mockutils.UpdateCounterArrays() # Ensure that the patched code contains EXTENDED_ARG instructions. - for inst in patched_code.co_code: - if dis.opname[inst] == "EXTENDED_ARG": + for instr in dis.get_instructions(patched_code): + if instr.opname == "EXTENDED_ARG": break else: self.fail("No EXTENDED_ARG instructions found in patched code.") diff --git a/src/version_dependent.py b/src/version_dependent.py index 5e3c0538..eb863444 100644 --- a/src/version_dependent.py +++ b/src/version_dependent.py @@ -24,6 +24,7 @@ - 3.11 - 3.12 - 3.13 + - 3.14 """ import sys @@ -34,10 +35,10 @@ PYTHON_VERSION = sys.version_info[:2] -if PYTHON_VERSION < (3, 6) or PYTHON_VERSION > (3, 13): +if PYTHON_VERSION < (3, 6) or PYTHON_VERSION > (3, 14): raise RuntimeError( "You are fuzzing on an unsupported python version: " - + f"{PYTHON_VERSION[0]}.{PYTHON_VERSION[1]}. Only 3.6 - 3.12 are " + + f"{PYTHON_VERSION[0]}.{PYTHON_VERSION[1]}. Only 3.6 - 3.14 are " + "supported by atheris 2.0. Use atheris 1.0 for older python versions." ) @@ -118,6 +119,27 @@ "POP_JUMP_IF_NOT_NONE", ]) +if PYTHON_VERSION >= (3, 14): + # 3.14 added non-popping conditional jumps `JUMP_IF_TRUE` / `JUMP_IF_FALSE` + # as relative-forward branches; the existing pop-then-jump pair stays. + CONDITIONAL_JUMPS.extend([ + "JUMP_IF_FALSE", + "JUMP_IF_TRUE", + ]) + HAVE_REL_REFERENCE.extend([ + "JUMP_IF_FALSE", + "JUMP_IF_TRUE", + ]) + +# Instructions that push a constant onto the stack. The constant-compare +# instrumentation tracks which stack slots hold constants so it can pass the +# constant value to `_trace_cmp` as `obj1`. 3.14 introduced `LOAD_SMALL_INT` +# as a specialized constant-loader for small integers; before 3.14 all +# constants flowed through `LOAD_CONST` so this list was implicit. +CONST_LOADS = ["LOAD_CONST"] +if PYTHON_VERSION >= (3, 14): + CONST_LOADS.append("LOAD_SMALL_INT") + HAVE_ABS_REFERENCE = [ # common "JUMP_ABSOLUTE",