sqc 0.4.84

Software Code Quality - CERT C compliance checker
# SQLite forum disclosure — os_kv.c kvvfsDecode heap overflow

Filed by: Brandon Arrendondo (brandon.arrendondo@bissell.com)
Date filed: 2026-06-14 (held for moderator approval)
Target: SQLite User Forum (sqlite.org/forum)
Status at filing: LIVE at trunk HEAD; Valgrind-confirmed; not found in a forum search (apparently unreported)
Artifacts: poc/kvvfs_overflow_poc.c, poc/kvvfs_valgrind.txt

---

## Title

Heap buffer overflow (write) in kvvfsDecode() in os_kv.c

---

## Body

Hello,

I believe there is a heap buffer overflow (out-of-bounds write) in
kvvfsDecode() in src/os_kv.c. It is present on current trunk.

## The defect

kvvfsDecode(const char *a, char *aOut, int nOut) decodes the text encoding
used by the kvvfs (key/value) VFS into aOut, which holds nOut bytes. It has
two decode branches. The zero-run (memset) branch is bounds-checked:

    if( j+n>nOut ) return -1;
    memset(&aOut[j], 0, n);
    j += n;

The hex-pair branch is not -- it writes to aOut[j] with no check that j is
still within nOut, inside a while(1) loop:

    }else{
      aOut[j] = c<<4;
      c = kvvfsHexValue[aIn[++i]];
      if( c<0 ) return -1 /* hex bytes are always in pairs */;
      aOut[j++] += c;
      i++;
    }

So the number of output bytes is governed only by how many hex pairs are in
the input, never by nOut.

## Why nOut and the payload are both attacker-influenced

kvvfsDecodeJournal() sizes the destination buffer from a length header that
is read from the front of the *same* journal text, then decodes the rest of
that text into it:

    while( (c = zTxt[i++])>='a' && c<='z' ){ n += (zTxt[i]-'a')*mult; mult*=26; }
    pFile->aJrnl = sqlite3_malloc64( n );
    pFile->nJrnl = n;
    n = kvvfsDecode(zTxt+i, pFile->aJrnl, pFile->nJrnl);

A journal text with a small length header followed by a longer run of hex
pairs therefore makes kvvfsDecode() write past the n-byte allocation, with
attacker-controlled length and contents. This is reachable whenever the
kvvfs backing store can contain data an attacker influences (the journal is
read back via the xRead path -> kvvfsReadJournal -> kvvfsDecodeJournal).

Note for anyone reproducing: kvvfs decodes UPPERCASE hex. kvvfsHexValue maps
0x41-0x46 ('A'-'F'); lowercase 'a'-'f' map to -1.

## Reproducer (Valgrind)

The table and function below are copied verbatim from src/os_kv.c. The driver
calls kvvfsDecode() exactly as kvvfsDecodeJournal() does, and also reproduces
the kvvfsDecodeJournal() header-parse + malloc(n) flow end to end.

----------------------------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* verbatim from src/os_kv.c */
static const signed char kvvfsHexValue[256] = {
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
   0, 1, 2, 3, 4, 5, 6, 7,  8, 9,-1,-1,-1,-1,-1,-1,
  -1,10,11,12,13,14,15,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1,
  -1,-1,-1,-1,-1,-1,-1,-1, -1,-1,-1,-1,-1,-1,-1,-1
};

int kvvfsDecode(const char *a, char *aOut, int nOut){
  int i, j, c;
  const unsigned char *aIn = (const unsigned char*)a;
  i = 0; j = 0;
  while( 1 ){
    c = kvvfsHexValue[aIn[i]];
    if( c<0 ){
      int n = 0, mult = 1;
      c = aIn[i];
      if( c==0 ) break;
      while( c>='a' && c<='z' ){ n += (c-'a')*mult; mult *= 26; c = aIn[++i]; }
      if( j+n>nOut ) return -1;
      memset(&aOut[j], 0, n); j += n;
      if( c==0 || mult==1 ) break;
    }else{
      aOut[j] = c<<4;
      c = kvvfsHexValue[aIn[++i]];
      if( c<0 ) return -1;
      aOut[j++] += c;
      i++;
    }
  }
  return j;
}

int main(void){
  /* exactly how kvvfsDecodeJournal calls it: kvvfsDecode(text, aJrnl, nJrnl) */
  int nOut = 4;
  char *aOut = malloc(nOut);
  int rc = kvvfsDecode("00112233445566778899AABBCCDDEEFF", aOut, nOut);
  printf("decoded %d bytes into a %d-byte buffer\n", rc, nOut);
  free(aOut);
  return 0;
}
----------------------------------------------------------------------

    $ gcc -O0 -g -o poc poc.c
    $ valgrind --leak-check=no ./poc
    ==...== Invalid write of size 1
    ==...==    at 0x...: kvvfsDecode (poc.c:NN)
    ==...==  Address 0x... is 0 bytes after a block of size 4 alloc'd
    ...
    decoded 16 bytes into a 4-byte buffer

The decode writes 16 bytes into the 4-byte allocation. Driving the full
kvvfsDecodeJournal() flow (a "small header + long payload" journal text)
reproduces the same overflow against the malloc'd journal buffer.

## Suggested fix

Add a bound to the hex-pair branch, mirroring the zero-run branch, e.g.:

    }else{
      if( j>=nOut ) return -1;
      aOut[j] = c<<4;
      c = kvvfsHexValue[aIn[++i]];
      if( c<0 ) return -1;
      aOut[j++] += c;
      i++;
    }

## Disclosure

This was found during a static-analysis study of the SQLite source and the
analysis/triage was assisted by an AI tool; I have manually verified the code
against current trunk and reproduced the overflow under Valgrind before
reporting. Happy to provide any further detail.

Thanks,
Brandon Arrendondo