Format binary level retro era DOS/EGA (seperti engine Commander Keen atau Wolfenstein 3D) memetakan array integer langsung ke memory buffer. Pendekatan naive fread langsung ke struct rentan terhadap arbitrary memory corruption jika file level dimanipulasi: integer overflow pada dimensi memicu alokasi heap yang terlalu kecil, diikuti buffer overflow atau out-of-bounds write.

Artikel ini membahas mitigasi eksploitasi binary parser tilemap di C++ modern menggunakan bounds checking ketat, verifikasi magic header, dan safe slice view berbasis std::span.

Vektor Kerentanan Parser Binary Tradisional

Parser retro umumnya mengandalkan layout flat binary berikut:

  • Magic Bytes (4 bytes): Identifier format file (contoh: MAP1).
  • Lebar & Tinggi (2x uint16_t): Dimensi grid.
  • Layer Count (uint16_t): Jumlah plane (misal: background, foreground, collision).
  • Payload Data: Raw tile IDs (array uint16_t berukuran width * height * layers).

Tiga celah utama pada arsitektur parser lama:

  1. Integer Overflow saat Perhitungan Ukuran: Mengalikan width * height * layers * sizeof(uint16_t) dengan tipe integer 16-bit atau 32-bit dapat wrap around ke nilai kecil. Buffer dialokasikan terlalu kecil, namun loop parsing membaca data melebihi batas buffer (Heap-based Buffer Overflow).
  2. Missing Payload Bounds Validation: Nilai dimensi di-header menyatakan 1.000.000 tile, namun payload file berakhir di 100 byte. Melakukan read tanpa mencocokkan sisa ukuran buffer file memicu Out-of-Bounds Read atau Segmentation Fault.
  3. Unchecked Coordinate Lookup: Fungsi get_tile(x, y) menggunakan rumus y * width + x tanpa asersi batas koordinat, menyebabkan arbitrary memory read/write pada runtime rendering.

Arsitektur Hardening Parser C++

Gunakan pendekatan zero-copy aman via std::span (tersedia sejak C++20). Raw byte stream divalidasi secara defensif sebelum instansiasi objek map.

#pragma once
#include <cstdint>
#include <vector>
#include <optional>
#include <span>
#include <cstring>
#include <limits>

struct MapHeader {
    uint32_t magic;
    uint16_t width;
    uint16_t height;
    uint16_t layers;
};

constexpr uint32_t EXPECTED_MAGIC = 0x3150414D; // ASCII 'MAP1' dalam Little-Endian
constexpr uint16_t MAX_DIMENSION = 4096;        // Batas wajar ukuran map
constexpr uint16_t MAX_LAYERS = 8;

class Tilemap {
public:
    uint16_t width = 0;
    uint16_t height = 0;
    uint16_t layers = 0;
    std::vector<uint16_t> tiles;

    [[nodiscard]] std::optional<uint16_t> get_tile(size_t layer, size_t x, size_t y) const noexcept {
        if (layer >= layers || x >= width || y >= height) {
            return std::nullopt; // Hard bounds check
        }
        size_t index = (layer * width * height) + (y * width) + x;
        return tiles[index];
    }
};

Implementasi Parser Defensif

Parser menerima span memory immutable (misalnya data hasil mapping file via mmap atau buffer dari disk). Setiap segmen divalidasi secara berurutan:

std::optional<Tilemap> parse_tilemap(std::span<const uint8_t> raw_data) {
    // 1. Verifikasi ukuran minimal untuk header
    if (raw_data.size() < sizeof(MapHeader)) {
        return std::nullopt;
    }

    MapHeader header;
    std::memcpy(&header, raw_data.data(), sizeof(MapHeader));

    // 2. Verifikasi magic header
    if (header.magic != EXPECTED_MAGIC) {
        return std::nullopt;
    }

    // 3. Sanity check dimensi mencegah integer overflow
    if (header.width == 0 || header.height == 0 || header.layers == 0) {
        return std::nullopt;
    }
    if (header.width > MAX_DIMENSION || header.height > MAX_DIMENSION || header.layers > MAX_LAYERS) {
        return std::nullopt;
    }

    // Hitung total tile menggunakan uint64_t untuk eliminasi risiko overflow
    uint64_t total_tiles = static_cast<uint64_t>(header.width) * 
                           static_cast<uint64_t>(header.height) * 
                           static_cast<uint64_t>(header.layers);

    uint64_t payload_bytes = total_tiles * sizeof(uint16_t);

    // 4. Verifikasi sisa ukuran payload buffer
    uint64_t available_payload = raw_data.size() - sizeof(MapHeader);
    if (available_payload < payload_bytes) {
        return std::nullopt;
    }

    // 5. Inisialisasi Tilemap dan salin payload
    Tilemap map;
    map.width = header.width;
    map.height = header.height;
    map.layers = header.layers;
    map.tiles.resize(total_tiles);

    const uint8_t* payload_ptr = raw_data.data() + sizeof(MapHeader);
    std::memcpy(map.tiles.data(), payload_ptr, payload_bytes);

    return map;
}

Unit Testing: Verifikasi Input Malformed

Gunakan pengujian assert sederhana untuk memverifikasi parser menolak korupsi byte, dimensi manipulatif, dan buffer truncation.

#include <cassert>
#include <iostream>

void run_tests() {
    // Test 1: Buffer kosong
    assert(!parse_tilemap({}));

    // Test 2: Magic bytes salah
    uint8_t bad_magic[] = { 'B', 'A', 'D', '!', 0x0A, 0x00, 0x0A, 0x00, 0x01, 0x00 };
    assert(!parse_tilemap(bad_magic));

    // Test 3: Integer overflow pada dimensi (payload truncated)
    // Map 100x100 1 layer butuh 10.000 * 2 = 20.000 bytes payload
    MapHeader fake_header = { EXPECTED_MAGIC, 100, 100, 1 };
    std::vector<uint8_t> truncated_buf(sizeof(MapHeader) + 50); // Hanya sedia 50 bytes
    std::memcpy(truncated_buf.data(), &fake_header, sizeof(MapHeader));
    assert(!parse_tilemap(truncated_buf));

    // Test 4: Valid payload & bounds checking
    MapHeader valid_header = { EXPECTED_MAGIC, 2, 2, 1 }; // 4 tiles = 8 bytes
    std::vector<uint8_t> valid_buf(sizeof(MapHeader) + 8);
    std::memcpy(valid_buf.data(), &valid_header, sizeof(MapHeader));
    uint16_t sample_tiles[4] = { 10, 20, 30, 40 };
    std::memcpy(valid_buf.data() + sizeof(MapHeader), sample_tiles, 8);

    auto result = parse_tilemap(valid_buf);
    assert(result.has_value());
    assert(result->get_tile(0, 0, 0) == 10);
    assert(result->get_tile(0, 1, 1) == 40);
    assert(!result->get_tile(0, 2, 0).has_value()); // Out-of-bounds X
    assert(!result->get_tile(1, 0, 0).has_value()); // Out-of-bounds layer

    std::cout << "All defensive checks passed.\n";
}

int main() {
    run_tests();
    return 0;
}

Analisis Trade-Off

Penerapan dynamic check overhead-nya minimal dibandingkan risiko memory corruption:

  • Runtime Cost: get_tile() menambahkan operasi percabangan (branching) per lookup. Dalam hot rendering loop, mitigasi ini dapat dioptimasi dengan iterator pointer langsung setelah loop luar memvalidasi viewport window.
  • Memory Cost: Pengecekan uint64_t mencegah alokasi memory tak terduga (OOM DoS attack) sebelum buffer heap dipesan oleh std::vector::resize.
  • Portabilitas Format: Format binary berbasis struct langsung mengabaikan isu endianness jika engine ditargetkan untuk CPU multi-architecture. Gunakan fungsi utility seperti std::byteswap (C++23) jika menargetkan format antar arsitektur berlainan.