Research/sqlite-fts5-securedel
0-dayNo CVEHighPublic

SQLite FTS5 secure-delete PutVarint footer overrun

SQLite 3.53.4 FTS5 fts5DoSecureDelete rewrites a leaf footer with sqlite3Fts5PutVarint after nPg = iPgIdx. Hostile footer varints walk off Fts5Data. Leftover of YEONBA/trunk 9cd4f9e920. Labbed ASan WRITE of size 1. Not CVE-2026-11824. No CVE yet.

Name
SQLite FTS5 secure-delete PutVarint footer overrun
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
SQLite (Hwaci)
Affected
SQLite 3.53.4 FTS5 (b09c88c, SOURCE_ID 2026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc). Typical extra SQLITE_ENABLE_FTS5. Trunk 9cd4f9e920 is after this GA. CVE-2026-11824 szLeaf<4 is closed on this pin.
Published
09 Oct 2026
Updated
09 Oct 2026
Tags
0-day, sqlite, fts5, secure-delete, cwe-787, crash, leftover

Secure-delete still writes past the page

I am @abraxas_null. The proof of concept is on GitHub: abraxas/sqlite-fts5-securedel (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, yeonba-fts5corruptB-4.hexdb. Authorized lab only. It talks to loopback.

This is SQLite 3.53.4 (b09c88c14082339b66c7b7158d609a771e64ca69). Fossil SOURCE_ID 2026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc. No CVE yet. CWE-787. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). Local Crash. Typical extra SQLITE_ENABLE_FTS5. Not demonstrated RCE.

FTS5 has a mode called secure-delete. When it is on, a DELETE is supposed to punch the term out of the on-disk leaf instead of leaving a tombstone. The punch rewrites the page footer. The footer is a run of varints. The writer is sqlite3Fts5PutVarint. On 3.53.4 the cursor for those varints is nPg after someone assigned nPg = iPgIdx. The original blob length is gone. The decoded key offsets are not capped against it. Twenty bytes of padding is not a bound. The write walks off Fts5Data.

That sentence is a leftover of later trunk, not a new invention. Fossil 9cd4f9e920 / Git d9de6eedbe91 / branch-3.53 34171bee08 landed 2026-08-11 with the name YEONBA on it. 3.53.4 shipped 2026-07-24. The GA does not have the bound. The GA also does not have the crash test. Trunk fts5corruptB.test 4.0 is the hexdb plus DELETE FROM t WHERE rowid = 1. 3.53.4's copy of that file still ends at test 3.2.

This is not CVE-2026-11824. 11824 is szLeaf<4 in fts5LeafRead, fixed in 3.53.2, closed on this pin. I am going to keep repeating that, because the two bugs share a file, a feature flag, and a corrupt FTS5 index, and that is exactly how people merge them.

Why SQLite

I had just finished unpublished Crit/High hunts of HTTP servers and SQL servers that people actually run. Caddy v2.11.7 came back empty at this bar. lighttpd 1.4.85 Unix was the same kind of empty. OpenLiteSpeed 1.9.3 still had a leftover of its own 1.8.2.1 cookie 403. MySQL 26.7.0 default mysqld was clean; the leftovers lived in dump clients and authenticated SQL corners. Kafka Connect still loads FileStream on Windows. The pattern is boring until it is not: pin the latest GA, write the skip list, hunt leftovers of later trunk.

SQLite is the other database. It is not a daemon. It is a library that opens a file. sqlite.org/security.html is unusually honest about that. Untrusted SQL and untrusted .db are first-class inputs. Memory corruption, crash, or heap disclosure on those inputs is High. Local is in-scope. There is no GRANT. Whoever can make the host call sqlite3_open on their bytes already has a process.

I only wanted Critical or High. Medium stays on the floor unless it chains. load_extension off on default open is not a finding. TRUSTED_SCHEMA ON is the documented default, not a leftover. Superuser INSTALL PLUGIN is a different product. A CLI dot-command that runs .shell is an extra surface. FTS5 is a typical extra. The amalgamation fuzz flags turn it on. Android, iOS, browsers, and a lot of language bindings ship it.

Canonical tree is Fossil at https://sqlite.org/src. GitHub sqlite/sqlite is a mirror. I cloned the mirror at tag version-3.53.4 because that is how the rest of this desk pins. Public domain. Hwaci does not write CVEs on a schedule. Developers fix in hours on the forum and move the fossil. A leftover of later trunk versus latest GA is the whole hunt.

Pinning 3.53.4, not the draft

3.54.0 was still a draft when I pinned, planned 2026-10-15. Draft is not a release. Pinning a draft is how you "discover" a bug the next GA already closed. Latest GA is 3.53.4, 2026-07-24. Git b09c88c14082339b66c7b7158d609a771e64ca69. SOURCE_ID 2026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc. Official drop sqlite-src-3530400.zip. SHA3-256 of sqlite3.c is in the release notes if you want to argue with the zip.

3.53.4 itself is a maintenance patch for (mostly) AI-discovered bugs in 3.53.0-3.53.3. That is useful. It means the skip list is long, and it means a leftover that still fires here survived a season of automated review plus the 3.53.2 FTS5 wave.

I did not pin branch-3.53 HEAD. HEAD moves. I pinned the tag people ship.

The skip list is half the hunt

Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. On 3.53.4 the interesting FTS5 and library rows were already closed:

  • CVE-2026-11822 / CVE-2026-11824. FTS5 heap overflow when SQLITE_DBCONFIG_DEFENSIVE is off. Duplicate CVEs for the same bug. Fixed 3.53.2. This pin's fts5LeafRead rejects szLeaf<4 or szLeaf>nn.
  • CVE-2025-7709. FTS5 corrupt-index integer overflow. Fixed 3.50.3.
  • Vuln94-75 / Vuln35-16 and the 2026-06/07 FTS5 corrupt-record over-read wave. Fixed before this GA. fts5LeafRead at the old fts5SecureDeleteOverflow site. iStart>nPg is already a corrupt return.
  • CVE-2026-50812. Session NPD. Medium. Optional -DSQLITE_ENABLE_SESSION.
  • concat_ws / LOOKASIDE / zipfile CLI CVEs from 3.49-3.52. Closed.
  • CVE-2024-0232. JSON UAF. Fixed 3.43.2. JSON on this pin stayed inside JSON_MAX_DEPTH.
  • Ada Logics sqlite3_rsync stack smash (readPow2 x>16). Already in this pin (trunk 805f3acd, 2026-07-09).
  • JFrog "critical CVEs" 51296/51297/51300/51302/51303/51304. Unreproducible. SQLite and JFrog treat them as AI hallucinations. Not a skip of a real bug. A skip of a hallucination.

sqlite.org/cves.html was updated 2026-08-01. YEONBA, Winter, Michele, NewVuln4 sit on the bugs forum with no CVE and no 3.53.4 release. That is the leftover matrix, not the skip list.

Twenty surfaces, most of them empty

I split the tree. Parser/VDBE. btree/pager. WAL. FTS3. FTS5. JSON. vtab/ATTACH/pragma. trusted_schema/authorizer. shell/zipfile. sqlite3_rsync. session/RBU. deserialize/memdb. triggers/views/window. rtree/geopoly. WASM. 3.54 patch siblings. load_extension/VFS. C API. recover/misc extras. schema side effects.

Most of that is SQLite doing what 3.53.4 said it would do.

Default load_extension is off. sqlite3_load_extension denied. SQL cannot load a dylib without the C API enable. ATTACH of an attacker path is the documented default, not a leftover. sqlite_dbpage is CLI / compile extra. JSON/JSONB parse stays inside depth and payload checks. LOOKASIDE multiplies as i64. bind_blob rejects over SQLITE_LIMIT_LENGTH. deserialize of a correctly sized image does not smash the caller buffer. btree/pager unpublished Crit/High on this pin: NONE. WAL is a sibling leftover (page-size mismatch uninit tail), not this file. FTS3 NEAR overwrite is already clamped. rsync stack cap is in tree.

The default core library still has other High leftovers on this GA: COLLATE depth, CTE nest, VALUES UNION ALL error path, WAL page-size mismatch. Authorizer embedders still skip READ on FULL JOIN USING and on INSERT INTO dest SELECT * FROM src. Those are other posts. This post is FTS5.

FTS5 on this pin has two High leftovers of later trunk. G7 is the secure-delete footer. G8 is detail=none prefix-merge under-alloc in fts5MergeRowidLists. G8 wants USING fts5(..., detail=none) and a prefix MATCH. G7 wants a table that already used secure-delete and a DELETE/flush. They share a file. They do not share a trigger.

NewVuln4 (fts5StorageDecodeSizeArray ignore-return) is not on this pin. That decode-and-subtract block was added on 3.54.0-draft and fixed there. 3.53.4 fts5StorageContentlessDelete only looks up origin from %_docsize. Stats corruption on a draft is not a 3.53.4 heap write.

Winter's forum post (2026-08-17) verified 3.53.4 and claimed weaponization of the WAL uninit disclosure "when combined with a second, independent defect in FTS5 (a heap overflow fixed on trunk as check-in 9cd4f9e920 but still present in the shipped 3.53.4 release)". That FTS5 defect is this leftover. The WAL leftover is a different hunt. I did not chain them. I did not write a heap-grooming payload. Class stays Crash.

The closed parent, in the same function family

CVE-2026-11824 is the bug people will think this is. It is a leaf decoder. fts5LeafRead used to take szLeaf from two bytes at offset 2 and then do arithmetic that underflowed when szLeaf<4. Continuation pages inflated a remaining count into a heap write. 3.53.2 put a door on it:

C
static Fts5Data *fts5LeafRead(Fts5Index *p, i64 iRowid){
  Fts5Data *pRet = fts5DataRead(p, iRowid);
  if( pRet ){
    assert( pRet->szLeaf==0 );
    if( pRet->nn>=4 ){
      pRet->szLeaf = fts5GetU16(&pRet->p[2]);
    }
    if( pRet->szLeaf<4 || pRet->szLeaf>pRet->nn ){
      FTS5_CORRUPT_ROWID(p, iRowid);
      fts5DataRelease(pRet);
      pRet = 0;
    }
  }
  return pRet;
}

ext/fts5/fts5_index.c:925-938 on this pin. szLeaf in [4, nn]. The old integer underflow does not fire. I looked for a second szLeaf<4 sibling. fts5ChunkIterate uses fts5LeafRead for continuation pages. Other szLeaf- sites run on LeafRead pages or after iOff>szLeaf CORRUPT checks. No second demonstrated High underflow sibling of 11824 on this pin. The live FTS5 High leftovers are the secure-delete PutVarint path and the detail=none merge, not a second decoder.

That matters for the lab. If you plant a leaf with szLeaf<4, you are reproducing a closed CVE. The YEONBA hexdb keeps szLeaf in range. The overflow is the footer rewrite after a legal leaf read.

What fts5DoSecureDelete actually does

Secure-delete is not the FTS5 create default. The attack database already has %_config version 5 / secure-delete set. MATCH-only is not this sink. Trigger is DELETE or any write that flushes (fts5FlushSecureDelete -> fts5DoSecureDelete from fts5FlushOneHash). Crafted file does not need DEFENSIVE off. Shadow-table SQL to plant the footer is a second path when DEFENSIVE is off. The lab used a file.

fts5DataRead allocates sizeof(Fts5Data) aligned, plus the blob, plus FTS5_DATA_PADDING (20) at fts5_index.c:312,882. The padding exists so a corrupt record can be decoded without an immediate over-read. It is not a license to rewrite an unbounded footer into it.

fts5DoSecureDelete starts with aPg = pSeg->pLeaf->p and nPg = pSeg->pLeaf->nn. iPgIdx is szLeaf, the offset of the page footer. It copies the footer aside, memmoves the surviving body, then:

C
iPgIdx -= nShift;
nPg = iPgIdx;
fts5PutU16(&aPg[2], iPgIdx);

for(iIdx=0; iIdx<nIdx; /* no-op */){
  u32 iVal = 0;
  iIdx += fts5GetVarint32(&aIdx[iIdx], iVal);
  iKeyIn += iVal;
  if( iKeyIn!=iDelKeyOff ){
    int iKeyOut = (iKeyIn - (iKeyIn>iOff ? nShift : 0));
    nPg += sqlite3Fts5PutVarint(&aPg[nPg], iKeyOut - iPrevKeyOut);
    iPrevKeyOut = iKeyOut;
  }
}

ext/fts5/fts5_index.c:5514-5544. nPg is no longer the allocation length. It is the new footer start. iKeyIn is a 32-bit int. Footer iVal is u32 from on-disk varints. sqlite3Fts5PutVarint writes 1 to 9 bytes of a u64. A negative iKeyOut - iPrevKeyOut (signed wrap) becomes a 9-byte varint. Many such writes from a reduced nPg pass nn+20.

sqlite3Fts5PutVarint in fts5_varint.c:321 is the obvious sink. Small values write one or two bytes through p[0] / p[1]. Larger values call fts5PutVarint64 and write up to nine. ASan on this lab caught a WRITE of size 1. That is the first byte of a varint that no longer fits.

Later trunk keeps original nPg, uses i64 iKeyIn, and rejects iKeyIn>nPg before PutVarint. That is the whole fix. It is not in 3.53.4.

Building the lab around a public hexdb

I did not want a synthetic plant that accidentally tripped 11824. Trunk already had the crash test. fts5corruptB.test 4.0 is a hexdb plus DELETE FROM t WHERE rowid = 1, and the expectation after the fix is corruption, not a clean write. 3.53.4 cannot run that test because the test file is not on the tag. The hexdb is. I copied it.

The pack must ship lab/yeonba-fts5corruptB-4.hexdb. Decode is the sqlite hexdb format: size, pagesize 512, 5120 bytes, SQLite format 3. Host-side poc.py writes work/yeonba.db. The container is an ASan amalgamation of official sqlite-src-3530400.zip with -DSQLITE_ENABLE_FTS5. Compose project sqlite-fts5-securedel. Loopback 127.0.0.1:18860 is a dummy HTTP server so run.sh can wait-ready the same way the other labs do. The library has no network listener. sqlite3 runs via docker compose exec.

Pin check first: SELECT sqlite_version(); SELECT sqlite_source_id(); must be 3.53.4 and the SOURCE_ID suffix. Then SELECT count(*) FROM t (the hexdb answers 1). Then DELETE FROM t WHERE rowid = 1. ASan abort_on_error. rc 134.

Frames:

Plain text
#0 fts5PutVarint64 /src/sqlite-src-3530400/sqlite3.c
#1 sqlite3Fts5PutVarint
#2 fts5DoSecureDelete
#3 fts5FlushSecureDelete
#4 fts5FlushOneHash / fts5IndexFlush

WRITE of size 1. Witness SQLITE-FTS5-SECUREDEL-WITNESS. Last line:

Plain text
SUCCESS sqlite-fts5-securedel how=asan-heap-buffer-overflow-write-1-fts5DoSecureDelete db=yeonba-hexdb bytes=5120 SQLITE-FTS5-SECUREDEL-WITNESS

I also kept a fallback plant: live CREATE VIRTUAL TABLE t USING fts5(a, detail=column), INSERT INTO t(t, rank) VALUES('secure-delete', 1), some rows, optimize, then append 0xFF bytes onto %_data blocks with szLeaf still in range. That path is there so a missing hexdb fails loudly instead of silently skipping the sink. The SUCCESS line is the YEONBA hexdb. Do not drop the hexdb from the pack.

Replay:

Plain text
cd lab && ./run.sh

Or from a fresh clone:

Plain text
git clone https://github.com/abraxas/sqlite-fts5-securedel
cd sqlite-fts5-securedel/lab
./run.sh

Bind it to loopback. The loopback client is lab/run.sh with a banner on it. I am not going to print a sqlite3 one-liner you can paste at someone else's database file.

Wrong turns, in the order I made them

Treating CVE-2026-11824 as the leftover. Same directory. Same extra. Same class of corrupt FTS5 index. Closed two minors earlier. fts5LeafRead already holds. If your PoC is szLeaf=3, you are late.

MATCH instead of DELETE. The secure-delete punch is a flush path. SELECT ... MATCH reads leaves. It does not rewrite the footer through fts5DoSecureDelete. A MATCH-only lab on this hexdb is a clean count.

Planting a live table and expecting the YEONBA overflow. CREATE, INSERT, INSERT INTO t(t, rank) VALUES('secure-delete', 1), then glue 0xFF onto %_data. szLeaf stayed legal. sqlite reported corrupt, or it did not overflow. The public hexdb is the test for a reason. Use it.

Calling Winter's WAL-plus-FTS5 note RCE. The WAL leftover discloses uninitialized page-cache tail. This leftover is a heap write of encoded varints. Combining them in a forum sentence is not a write-what-where. I will not file Crash as RCE because someone used the word weaponization.

Pinning 3.54.0-draft because the fix is there. The fix being on trunk is the definition of leftover. The product people ship is 3.53.4.

Building ASan from a full Fossil checkout with Tcl. Official sqlite-src-3530400.zip, ./configure --disable-tcl, CFLAGS with -fsanitize=address -DSQLITE_ENABLE_FTS5, make sqlite3. The SOURCE_ID comes from that binary. If it does not match, you are not on this pin.

Expecting DEFENSIVE-off to be required. 11824 needed it. This path reads the leaf through fts5LeafRead and then writes. The hostile bytes are already in %_data. Opening the file is enough.

Dropping the hexdb because compose mounts it at /opt/lab. poc.py runs on the host. It reads yeonba-fts5corruptB-4.hexdb next to itself. The container mount is not the decoder. If the file is missing, the last line is FAIL sqlite-fts5-securedel hexdb-missing.

What an attacker can do

Hand the victim a crafted FTS5 database that already used secure-delete. The victim opens it in a process built with FTS5 (typical extra) and runs a DELETE, or any write that flushes a secure-delete, on a row the index still knows about. fts5DoSecureDelete writes footer varints past Fts5Data. The host process crashes. Under ASan the crash is a heap-buffer-overflow WRITE of size 1 in sqlite3Fts5PutVarint.

Reach is local. The library does not listen on a port. Whoever can make the application open the file already has that process. Apps that ingest untrusted .db (mobile backup restore, document formats that are sqlite under the hood, fuzzing harnesses, CI that opens customer dumps) are the victim model sqlite.org/security.html already named.

Not demonstrated RCE. Not a write-what-where with a chosen value in this pack. Not CVE-2026-11824. Not a MATCH-only crash. Integrity of the page cache is not the claim. Availability of the host process is.

The fix

Keep original nPg. Widen iKeyIn to i64. Reject iKeyIn>nPg before sqlite3Fts5PutVarint. That is Fossil 9cd4f9e920 / Git d9de6eedbe91 / branch-3.53 34171bee08 (2026-08-11, YEONBA). Cherry-pick it onto 3.53. Ship the trunk test 4.0 hexdb so the next GA cannot drop the bound without going red.

Until then, do not open untrusted FTS5 databases in a process you care about keeping up. secure-delete is not a reason to trust the footer. The footer is attacker-controlled the moment the file is.

References

SQLite FTS5 secure-delete PutVarint footer overrun · Abraxas Labs