Driver GPU dan compiler shader internal (seperti Vulkan loader atau shader runtime WebGPU) kerap mengasumsikan bahwa bytecode SPIR-V yang diterima telah terformat sempurna. Mengirimkan bytecode SPIR-V mentah dari sumber yang tidak tepercaya—seperti aset modifikasi game, compute shader remote, atau input web—dapat memicu crash driver, out-of-bounds read/write, alokasi memori berlebih, hingga eksekusi kode acak pada level native.

Validasi pre-flight struktural pada boundary aplikasi mutlak diperlukan sebelum byte diserahkan ke API driver (seperti vkCreateShaderModule). Menggunakan Zig, validasi biner defensif dapat dibangun dengan zero-allocation, memanfaatkan keamanan slice bawaan, dan mengeliminasi risiko runtime panic.

Struktur Biner SPIR-V dan Vektor Serangan

File biner SPIR-V adalah aliran kata 32-bit (4-byte words). Lima kata pertama merupakan header biner standar:

  • Word 0 - Magic Number: Nilai tetap 0x07230203. Jika urutan byte terbaca terbalik (0x03022307), payload menggunakan endianness berbeda dari host.
  • Word 1 - Version: Versi spesifikasi (misalnya 0x00010500 untuk SPIR-V 1.5).
  • Word 2 - Generator Magic: Identifier toolchain pembuat shader.
  • Word 3 - Bound: Batas atas untuk seluruh ID SSA dalam modul. Setiap ID yang digunakan atau dideklarasikan harus memiliki nilai strictly < Bound.
  • Word 4 - Schema: Dipesan untuk format schema (selalu bernilai 0 pada SPIR-V standar).

Setelah offset kata ke-5, payload berisi deretan instruksi. Setiap instruksi diawali dengan satu kata 32-bit berkode ganda:

  • 16 bit rendah (word & 0xFFFF): Opcode instruksi (misal OpCapability, OpFunction).
  • 16 bit tinggi (word >> 16): Word Count (panjang instruksi dalam satuan kata 32-bit, termasuk kata header instruksi itu sendiri).

Titik Rentan (Attack Vectors)

  1. Zero Word Count DoS: Jika word_count == 0, loop parser naif yang menambahkan counter instruksi sebesar panjang kata akan mengalami infinite loop, membekukan thread compiler shader.
  2. Instruction Boundary Overrun: Nilai word_count yang lebih besar dari sisa buffer memori memicu out-of-bounds memory reading atau buffer overflow saat parser mencoba membaca operand berikutnya.
  3. Bound Exhaustion: Penyerang dapat menyetel Word 3 (Bound) ke nilai ekstrem seperti 0xFFFFFFFF. Jika downstream compiler mengalokasikan array referensi ID berdasarkan nilai Bound tanpa validasi batas atas, sistem akan kehabisan RAM (Out-Of-Memory DoS).

Implementasi Parser Defensif di Zig

Kode parser di bawah memverifikasi integritas struktural SPIR-V tanpa alokasi memori dinamis dan bebas panic. Parser memastikan alignment byte, validitas magic number, batasan resource, serta kepatuhan word count setiap instruksi terhadap ukuran buffer.

const std = @import("std");

pub const ValidationError = error{
    BufferTooSmall,
    InvalidAlignment,
    InvalidMagicNumber,
    InvalidWordCount,
    InstructionOutOfBounds,
    ResourceLimitExceeded,
};

pub const ValidationLimits = struct {
    max_instructions: usize = 100_000,
    max_id_bound: u32 = 0x0040_0000, // Maksimum 4 juta ID
};

pub const SpirvHeader = struct {
    endian: std.builtin.Endian,
    version: u32,
    generator: u32,
    bound: u32,
    schema: u32,
};

pub fn validateSpirv(bytes: []const u8, limits: ValidationLimits) ValidationError!SpirvHeader {
    // 1. Validasi ukuran minimal: Header SPIR-V adalah 5 kata 32-bit (20 byte)
    if (bytes.len < 20) {
        return ValidationError.BufferTooSmall;
    }

    // 2. Validasi alignment 4-byte
    if (bytes.len % 4 != 0) {
        return ValidationError.InvalidAlignment;
    }

    // 3. Deteksi Magic Number & Endianness
    const magic_raw = bytes[0..4].*;
    const endian: std.builtin.Endian = if (std.mem.eql(u8, &magic_raw, &[_]u8{ 0x03, 0x02, 0x23, 0x07 }))
        .little
    else if (std.mem.eql(u8, &magic_raw, &[_]u8{ 0x07, 0x23, 0x02, 0x03 }))
        .big
    else
        return ValidationError.InvalidMagicNumber;

    // Helper closure untuk membaca u32 sesuai endianness
    const readWord = struct {
        fn get(buf: []const u8, word_index: usize, e: std.builtin.Endian) u32 {
            const offset = word_index * 4;
            const slice = buf[offset .. offset + 4];
            return std.mem.readInt(u32, slice[0..4], e);
        }
    }.get;

    const version = readWord(bytes, 1, endian);
    const generator = readWord(bytes, 2, endian);
    const bound = readWord(bytes, 3, endian);
    const schema = readWord(bytes, 4, endian);

    // 4. Batasi ID Bound untuk mencegah alokasi memori berlebih di compiler
    if (bound == 0 or bound > limits.max_id_bound) {
        return ValidationError.ResourceLimitExceeded;
    }

    // 5. Validasi aliran instruksi
    const total_words = bytes.len / 4;
    var word_offset: usize = 5;
    var instruction_count: usize = 0;

    while (word_offset < total_words) {
        instruction_count += 1;
        if (instruction_count > limits.max_instructions) {
            return ValidationError.ResourceLimitExceeded;
        }

        const first_word = readWord(bytes, word_offset, endian);
        const word_count = @as(usize, @intCast(first_word >> 16));

        // Word count < 1 adalah pelanggaran spesifikasi (memicu infinite loop DoS)
        if (word_count == 0) {
            return ValidationError.InvalidWordCount;
        }

        // Cegah out-of-bounds truncation
        if (word_offset + word_count > total_words) {
            return ValidationError.InstructionOutOfBounds;
        }

        word_offset += word_count;
    }

    return SpirvHeader{
        .endian = endian,
        .version = version,
        .generator = generator,
        .bound = bound,
        .schema = schema,
    };
}

Pengujian Terhadap Serangan Binary Malformed

Pengujian unit berikut membuktikan bahwa parser berhasil menolak payload berbahaya tanpa memicu panic runtime.

test "Reject payload too small and invalid alignment" {
    const empty_data = [_]u8{};
    try std.testing.expectError(ValidationError.BufferTooSmall, validateSpirv(&empty_data, .{}));

    const unaligned = [_]u8{ 0x03, 0x02, 0x23, 0x07, 0x00 };
    try std.testing.expectError(ValidationError.InvalidAlignment, validateSpirv(&unaligned, .{}));
}

test "Reject zero word count DoS payload" {
    // Header valid (20 byte) + Instruksi ilegal (Word count = 0, Opcode = 1)
    var payload = [_]u8{
        0x03, 0x02, 0x23, 0x07, // Magic (Little Endian)
        0x00, 0x00, 0x01, 0x00, // Version 1.0
        0x00, 0x00, 0x00, 0x00, // Generator 0
        0x10, 0x00, 0x00, 0x00, // Bound 16
        0x00, 0x00, 0x00, 0x00, // Schema 0
        0x01, 0x00, 0x00, 0x00, // Opcode=1, WordCount=0 (Exploit!)
    };

    try std.testing.expectError(ValidationError.InvalidWordCount, validateSpirv(&payload, .{}));
}

test "Reject instruction out of bounds buffer overrun" {
    // Header valid (20 byte) + Instruksi mengklaim panjang 5 kata, tetapi buffer hanya punya 1 kata
    var payload = [_]u8{
        0x03, 0x02, 0x23, 0x07,
        0x00, 0x00, 0x01, 0x00,
        0x00, 0x00, 0x00, 0x00,
        0x10, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00,
        0x01, 0x00, 0x05, 0x00, // Opcode=1, WordCount=5
    };

    try std.testing.expectError(ValidationError.InstructionOutOfBounds, validateSpirv(&payload, .{}));
}

test "Reject excessive Bound allocation attack" {
    var payload = [_]u8{
        0x03, 0x02, 0x23, 0x07,
        0x00, 0x00, 0x01, 0x00,
        0x00, 0x00, 0x00, 0x00,
        0xFF, 0xFF, 0xFF, 0x7F, // Bound = 0x7FFFFFFF (Exceeds max limit)
        0x00, 0x00, 0x00, 0x00,
    };

    try std.testing.expectError(ValidationError.ResourceLimitExceeded, validateSpirv(&payload, .{}));
}

Trade-offs dan Integrasi Pipeline

Catatan: Parser ini memverifikasi integritas format biner, framing instruksi, dan batas alokasi. Validasi ini tidak menggantikan full semantic validation (misalnya validasi tipe SSA, kontrol alur blok cabang, atau validasi type-check SPIR-V lengkap seperti yang disediakan oleh spirv-val).

  • Pre-flight vs Full-validation: Mengompilasi dan mengintegrasikan pustaka C/C++ spirv-val (dari SPIRV-Tools) menambah dependensi build yang berat dan overhead runtime. Pre-flight checker di Zig ini mengeksekusi pemeriksaan batas level $O(N)$ kata dalam microsecond, memfilter payload rusak sebelum menyentuh driver GPU.
  • Zero Allocation: Fungsi validateSpirv tidak menggunakan std.mem.Allocator sama sekali, menjadikannya aman digunakan pada thread kritis atau sistem berdaya rendah.