Harden low-level VM bytecode validation and constant sizing - #565
Open
nevercodecorrect wants to merge 1 commit into
Open
Harden low-level VM bytecode validation and constant sizing#565nevercodecorrect wants to merge 1 commit into
nevercodecorrect wants to merge 1 commit into
Conversation
check_program() validated register indexes and most signature characters but
missed several ways for crafted bytecode to reach unsafe VM paths: the ('i','l')
type exemption let an 8-byte opcode write a 4-byte register; any opcode could
store into a read-only input or constant register; a non-final instruction could
write a reduction program's single-element output; OP_COPY_SS could copy from a
register other than the one the output dtype was sized from, or into a string
temporary whose item size is 0; the guard for an operand held in an extended
instruction word was unreachable, so it was read out of bounds; and an embedded
NUL byte in the signature silently shortened fullsig, desynchronising it from
the register map.
Validation also ran after NumExpr_init had already installed the program on the
object, so a rejected program stayed runnable, and re-initialising an object
freed register buffers that a concurrent run() was still using. Validate before
installing, accept only the first successful __init__, and refuse to run an
object whose __init__ never completed.
Size replicated constant storage with checked, non-truncating arithmetic instead
of 32-bit int that could wrap before the constants were copied in.
Finally, stop dereferencing zero-length string operands in stringcmp() and give
an empty operand the correct ordering, so "a < b''" matches NumPy.
Adds regression tests for each case and registers them in suite().
nevercodecorrect
force-pushed
the
harden-vm-bytecode-validation
branch
from
August 3, 2026 14:42
4786a8f to
3ae345a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR hardens the low-level
interpreter.NumExprbytecode boundary so malformed programs are rejected during construction instead of reaching unsafe VM paths.Validation changes in
check_program():fullsigdescribes exactly1 + n_inputs + n_constants + n_tempsregisters, so an embedded NUL byte cannot desynchronize it frommem[];Lifetime and arithmetic changes:
NumExprobjects are single-initialization — a second__init__on a built object raises instead of freeing register buffers that a concurrentrun()may still be using. A failed__init__installs nothing and can be retried;run()refuses to execute an object whose__init__never completed (NumExpr.__new__(NumExpr)), which previously readprogram[-4];One behavior fix outside the validator:
stringcmp()no longer dereferences a zero-length operand, and an emptyoperand now sorts before a non-empty one, so
a < b''anda <= b''agreewith NumPy.
The generated bytecode path is unchanged. The stricter checks affect only
malformed or non-canonical programs supplied directly to the low-level
interpreter constructor.
Why
check_program()validated register indexes and most signature characters, but several gaps allowed crafted bytecode to produce width-confused loads/stores, writes into read-only iterator buffers, unchecked string copies, block-sized writes into a single-element reduction accumulator, and an out-of-bounds operand read.Separately, constant storage was calculated with 32-bit arithmetic, allowing the allocation size to wrap before constants were copied into it, and the validator ran after the program had already been installed on the object, so a rejected program stayed runnable.
The string-copy restriction preserves the invariant already used by output dtype sizing (
interpreter.cpp,retsig == 's'branch): the compiler emitscopy_ssfrom register 1 to register 0.Below Are POCs of the bugs
Reproducers (before the patch)
All eleven cases below were run against
master(7031844) built with-fsanitize=address, CPython 3.12, NumPy 2.5.1, x86-64 Linux. Each is self-contained;import numpy as npandfrom numexpr import interpreter as Iare assumed.1. int32/int64 register-width confusion — heap overflow (write, 4096 bytes)
An
'i'temporary ismalloc(BLOCK_SIZE1 * 4); an'l'opcode writes((long long *)dest)[0..1023]= 8192 bytes into it.2.
OP_COPY_SSelement-size confusion — heap overflow (write, 4096 bytes)The output string size is derived from register 1, but the copy source was register 2. Both length and contents can be attacker-controlled.
3. 32-bit
rawmemsizeoverflow — heap overflow (write, 2 MB)The constant sizes sum to 2²², so
sum * BLOCK_SIZE1wraps to 0 in a 32-bitint. The overflow fires at construction, before anyrun().4. Dead bounds check — out-of-bounds read at validation time
The fourth operand lives at
program[pc+5]; the guard testedpc + 1 >= prog_len, which the loop bounds make permanently false.5. Store into a read-only input register — heap overflow (write)
NumPy sizes the read buffer of an
NPY_ITER_READONLYoperand smaller than the VM block. The same store also reachesth_worker(numexpr/module.cpp) in the threaded engine.6. Reduction output written by a non-final instruction — heap overflow (write)
A full reduction allocates the output as one element, but an ordinary opcode targeting register 0 writes a whole
BLOCK_SIZE1block — up to 8 KiB of attacker-chosen doubles past the end.7. Rejected
__init__still installs its program — every check above bypassedSame ASAN report as case 5.
NumExpr_initinstalled the program before callingcheck_program(), so raising from__init__did not undo the installation.8. Concurrent
__init__duringrun()— heap corruption / use-after-freerun_interpreter()capturesparams.mem = self->memand releases the GIL;REPLACE_MEMthenPyMem_Del()s those buffers underneath the running interpreter. The same race was also observed asheap-use-after-free READ ... th_worker numexpr/module.cppwith the free attributed toNumExpr_init.9. String temporary — read from a zero-length allocation
Constructs and runs.
size_from_char('s') == 0, so the temporary ismalloc(BLOCK_SIZE1 * 0)andstringcmp()'sif (maxlen2 == 0) return *s1 != null;dereferences it. Not ASAN-visible on this toolchain (a 1-byte read at amalloc(0)pointer is not flagged — confirmed with a standalone C test), but it is out of bounds per the C abstract machine and reads uninitialized memory.numexpr_object.cppalready documents the assumption that "there are no string temporaries"; this makes it true.10. Embedded NUL desynchronizes
fullsigfrom the register mapConstructs.
fullsigis built withPyBytes_FromFormat("%c%s%s%s", ...), whose%sstops at the first NUL, so every register index past the NUL is described by the wrong signature character — defeating the type and temporaries checks. It is currently unexploitable only by accident (typecode_from_char('\ 0')fails later, sorun()always errors first), i.e., the safety of the validator rested on an unstated invariant.11. Ordering against an empty string disagrees with NumPy (correctness, not
memory safety)
stringcmp()returned+1("s1 > s2") whens1was the empty operand.After the patch, cases 1–10 are rejected at construction (or, for case 8, at the second
__init__) and case 11 matches NumPy exactly.