Skip to content

Inserting a BLOB corrupts the source Buffer and crashes Node.js #202

@richardm90

Description

@richardm90

When inserting a Buffer into a BLOB, BINARY, or VARBINARY column via bindParameters (1-D), bindParametersSync, or the legacy 2-D bindParam, the driver silently overwrites the caller's input Buffer after SQLExecute. For larger payloads the overwrite also overruns the caller's allocation and the Node.js process dies with SIGSEGV / SIGILL.

This affects any application that keeps a reference to a Buffer it just inserted (common — e.g. computing a checksum, retrying on failure, logging) and any application inserting a BLOB at roughly 1 MiB or above.

  • Node.js version: v22.22.1
  • idb-connector version: 1.2.19
  • IBM i version: 5.7

To Reproduce

Minimal standalone script using idb-connector. Creates a BLOBREPRO table in the user's default schema and inserts BLOBs of increasing size, printing the first 16 bytes of the caller's Buffer before and after each INSERT.

const { dbconn, dbstmt } = require('idb-connector');

const TABLE = 'BLOBREPRO';

const SIZES = [
  ['8 B', 8], ['32 B', 32], ['128 B', 128], ['512 B', 512],
  ['1 KiB', 1024], ['64 KiB', 65536], ['1 MiB', 1048576],
  ['2 MiB', 2097152], ['4 MiB', 4194304], ['8 MiB', 8388608],
];

function execParam(conn, sql, params) {
  return new Promise((resolve, reject) => {
    const s = new dbstmt(conn);
    s.prepare(sql, (err) => {
      if (err) { s.close(); return reject(err); }
      s.bindParameters(params, (err) => {
        if (err) { s.close(); return reject(err); }
        s.execute((_out, err) => {
          s.close();
          if (err) return reject(err);
          resolve();
        });
      });
    });
  });
}

async function main() {
  const conn = new dbconn();
  conn.conn('*LOCAL');

  const setup = new dbstmt(conn);
  setup.execSync(`CREATE OR REPLACE TABLE ${TABLE} (ID INTEGER NOT NULL, DATA BLOB(256M))`);
  setup.close();

  try {
    for (const [label, size] of SIZES) {
      const payload = Buffer.alloc(size, 0xAB);
      const before = payload.subarray(0, 16).toString('hex');
      await execParam(conn, `DELETE FROM ${TABLE} WHERE ID = ? WITH NONE`, [1]);
      await execParam(conn, `INSERT INTO ${TABLE} (ID, DATA) VALUES (?, ?) WITH NONE`, [1, payload]);
      const after = payload.subarray(0, 16).toString('hex');
      const verdict = after === before ? 'unchanged' : 'MUTATED';
      console.log(`${label}: before=${before}  after=${after}  [${verdict}]`);
    }
  } finally {
    try { conn.disconn(); } catch (_) {}
    try { conn.close(); }  catch (_) {}
  }
}

main().catch((err) => { console.error(err); process.exit(1); });

Output (representative run)

8 B: before=abababababababab  after=0030333033333330  [MUTATED]
32 B: before=abababababababababababababababab  after=00303330333333303333333333333330  [MUTATED]
128 B: before=abababababababababababababababab  after=00000001807296c00000000000000060  [MUTATED]
512 B: before=abababababababababababababababab  after=000000018072bbc00000000000000060  [MUTATED]
1 KiB: before=abababababababababababababababab  after=000000018072e4600000000000000060  [MUTATED]
64 KiB: before=abababababababababababababababab  after=00000000000000000000000000000000  [MUTATED]
1 MiB: before=abababababababababababababababab  after=00000000000000000000000000000000  [MUTATED]
Illegal instruction (core dumped)

Determinism across five runs: mutation occurs at every size reached — never observed a clean "unchanged" row. The process always dies between 64 KiB and 2 MiB. The exact crash point (immediately after 64 KiB vs. after 1 MiB) and the signal (SIGSEGV ~3/5, SIGILL ~2/5) vary run-to-run depending on heap layout, but both symptoms are reliably present.

The after values are not random garbage: 0030333033333330 is EBCDIC-encoded digits (CLI length-indicator metadata), and 00000001807… are live 64-bit PPC heap pointers from the current process. CLI is writing its output area through the caller's Buffer.

Root Cause

In src/db2ia/dbstmt.cc, the BLOB/BINARY/VARBINARY branches of bindParams set param[i].buf directly to the result of buffer.Data() — a pointer into V8-owned heap — then pass that pointer to SQLBindParameter with BufferLength = 0:

  • 1-D bindParameters path: lines ~2722–2745
  • 2-D legacy bindParam path: lines ~2653–2663

Because 1-D bindParameters also binds every parameter as SQL_PARAM_INPUT_OUTPUT (line ~2673), CLI treats the caller's Buffer as an output location and writes back through it after execute. CLI does not honour BufferLength for binary types, so the write-back extent is bounded only by the declared column size — which for a BLOB(256M) column is vastly larger than any realistic input Buffer.

The CHAR / CLOB branches at lines ~2747–2790 already handle this correctly: they calloc an owned buffer sized to max(paramSize, inputLen) and strcpy the input in. The binary branches do not, and that is the defect.

Secondary consequence: because param[i].buf is set to a non-malloc'd pointer, the subsequent free(param[i].buf) in freeSp() (line ~2527) is undefined behaviour — a latent corruption source on top of the write-back bug.

Affects any caller binding a Buffer to a binary SQL type:

  • bindParameters(...) / bindParametersSync(...) (1-D) — BLOB, BINARY, VARBINARY
  • bindParam(...) / bindParamSync(...) (2-D) — with [buffer, IN, BLOB] or [buffer, IN, BINARY]

Downstream projects (idb-pconnector and anything using it) inherit the bug.

Proposed approach

Copy the caller's Buffer into an owned allocation sized for the declared column before binding, matching the CHAR/CLOB pattern. Happy to send a PR once this issue is triaged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions