SQLite 3.53.4 sqlite3_rsync, SSH filename command injection RCE
SQLite 3.53.4 sqlite3_rsync quotes USER@HOST:PATH for local /bin/sh -c. OpenSSH then space-joins the remote argv without those quotes. A semicolon in PATH runs as the SSH remote UID. Util, not libsqlite3. Ada Logics readPow2 stack smash is closed. Labbed echo witness. No CVE yet.
- Name
- SQLite 3.53.4 sqlite3_rsync, SSH filename command injection RCE
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- SQLite (Hwaci)
- Affected
- SQLite 3.53.4 sqlite3_rsync util (b09c88c, SOURCE_ID 2026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc). Leftover of later trunk encoded-filenames. Victim runs the util (UI:R). Not the default library.
- Published
- 09 Oct 2026
- Updated
- 09 Oct 2026
- Tags
- 0-day, sqlite, sqlite3_rsync, rce, cwe-78, command-injection, ssh
The quotes were for the wrong shell
The proof of concept is on GitHub: abraxas/sqlite-rsync-ssh (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py, fake_ssh.py, entrypoint.sh. Authorized lab only. It talks to loopback.
This is SQLite 3.53.4 (b09c88c14082339b66c7b7158d609a771e64ca69) sqlite3_rsync. SOURCE_ID 2026-07-24 19:02:57 bf7c7f30031888f4e796e429ab3978879485813aaca6f641c7b33e4e09459bcc. No CVE yet. CWE-78. 8.8 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H). Remote, UI:R. Util, not libsqlite3. Ada Logics readPow2 x>16 stack smash is closed on this pin.
sqlite3_rsync is the documented way to copy a live SQLite database over SSH. One argument is local. The other is USER@HOST:PATH. The util builds a shell string, feeds it to /bin/sh -c, and that shell runs ssh. append_escaped_arg wraps PATH in single quotes so the local shell will not split it. OpenSSH then concatenates every argument after the destination into one remote command string. The quotes are already gone. The remote login shell sees ;echo ... as a second command. That is the leftover.
Why SQLite
I had just finished unpublished Crit/High hunts of HTTP servers. 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 the 1.8.2.1 litespeed_role= 403. The next tree was not another origin. It was the database that those origins keep pointing at.
SQLite is public domain, latest GA 3.53.4, canonical tree at sqlite.org/src. GitHub is a Fossil mirror. The security model is honest: untrusted SQL and untrusted .db (plus sibling -wal) are first-class inputs. Memory corruption on those inputs is High. load_extension is off on default open. The amalgamation is the product most people embed. The extras are the CLI, FTS, rtree, session, WASM, and sqlite3_rsync.
I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. A finding on this hunt needed a leftover of a later trunk or 3.54 guard that still fires on 3.53.4, or a High crash / privilege leftover / RCE the 3.53.2 FTS5 wave did not close.
sqlite3_rsync is the interesting extra for a remote class. The library does not listen. The util does SSH. Same victim model as a backup client pointed at a hostile origin: the operator types the command, the other side chooses PATH.
Pinning 3.53.4
Latest GA. Not an RC. Not draft 3.54.0 (planned 2026-10-15). Tag version-3.53.4, Git b09c88c14082339b66c7b7158d609a771e64ca69, 2026-07-24. Fossil SOURCE_ID is the string sqlite3_rsync --version prints. That is the pin.
3.53.4 is the AI-bug maintenance wave on 3.53.0-3.53.3. If a leftover of later trunk still fires here, it is not a stale minor. If Ada Logics already closed the rsync stack smash on 2026-07-09, fifteen days before this GA, that smash is skip-list, not this post.
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 ones were already closed:
- CVE-2026-11822 / CVE-2026-11824 FTS5 heap overflow when defensive is off. Duplicate CVEs. Fixed 3.53.2.
fts5LeafReadszLeaf<4is in tree. - CVE-2026-50812 session
sqlite3changeset_apply_v3NPD. Medium. Fixed 3.53.2. Optional-DSQLITE_ENABLE_SESSION. - CVE-2025-3277 / CVE-2025-29087
concat_wsinteger overflow. 3.49.1. - CVE-2025-29088 LOOKASIDE OOB. 3.49.1.
setupLookasidei64 clamp is in tree. - CVE-2025-7709 FTS5 corrupt-index overflow. 3.50.3.
- CVE-2025-70873 zipfile CLI OOB. 3.52.0. CLI extra.
- CVE-2024-0232 JSON UAF. 3.43.2. Subtype walk is the Fossil parent of this tag.
- WAL-reset corruption (Tailscale race). 3.51.3 / 3.53.0. Closed.
- Ada Logics / Anthropic
sqlite3_rsyncreplica stack smash, forum e2dfadf51209f12b.readPow2used to accept exponents through 31. ORIGIN_PAGE read a leftover page size intochar buf[65536]. Fixed trunk805f3acd2026-07-09, cherry-picked to branch-3.53 asb6ab126399the same day. This pin capsx>16(1<<16 == 65536 ==sizeof buf), finalizes live statements incloseDb, zerospInsacross renegotiate, and readsp->szPage. Closed. Not this leftover.
JFrog's 2026 SQLite "critical CVE" set is unreproducible. SQLite treated it as hallucination. I left it.
May 26 2026 c84d596b6d / Git ce2f503 "malicious command-line arguments" is in this pin. That is the local /bin/sh -c half of append_escaped_arg. It is not the remote-sshd half. Trunk hex-encode isFilename==2 / --encoded-filenames landed 2026-10-07 as Git b0db2db / Fossil eaec2f7f (ANT-2026-31X9HVRR), after this GA. That is the leftover.
Twenty surfaces, one remote util RCE
I split the tree. Parser / VDBE. B-tree / pager. WAL. FTS3. FTS5. JSON. vtab / ATTACH / pragma. trusted_schema / authorizer. CLI / 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, or leftovers that are not this post.
Default load_extension stays off. JSON UAF class is closed. sqlite_log() from a hostile schema is log injection, Medium, declined backport. WASM kvvfs is extra. Windows winmain.c WorstFit for rsync is a same-day sibling of the hex-encode fix and is not this Unix SSH sink. IPv6 hostSeparator first-colon split is availability, not injection. --ssh PATH and --exe PATH are operator argv. Local option injection is out of bar: the operator already runs the binary.
Default library unpublished Crit/High on this pin is not NONE. COLLATE depth, CTE nest, and VALUES UNION ALL still crash the host on untrusted SQL. WAL page-size mismatch still discloses a pcache tail. Authorizer embedders still skip READ on FULL JOIN USING and on xferOptimization INSERT SELECT. FTS5 / FTS3 / rtree still have post-July crash and UAF leftovers. Session still has a same-name nCol heap write. The CLI still has go / / terminator and .recover schema dot-command. Those are other packs. This one is G12: the rsync util, SSH filename, remote UI:R, command injection.
writable_schema=ON in the replica is intended page copy. Origin cannot send SQL text. The wire is ORIGIN_BEGIN / ORIGIN_PAGE / ORIGIN_TXN. After COMMIT the replica is a copy of origin. That is the documented feature, not this leftover.
Two shells, one quoting function
Remote ORIGIN, victim pulls, local process is replicaSide:
for(iRetry=0; 1 /*exit-via-break*/; iRetry++){
sqlite3_str *pStr = sqlite3_str_new(0);
append_escaped_arg(pStr, zSsh, 1);
if( iPort>0 ) sqlite3_str_appendf(pStr, " -p %d", iPort);
sqlite3_str_appendf(pStr, " -e none");
append_escaped_arg(pStr, ctx.zOrigin, 1);
if( iRetry ) add_path_argument(pStr);
append_escaped_arg(pStr, zExe, 1);
append_escaped_arg(pStr, "--origin", 0);
/* ... */
append_escaped_arg(pStr, zDiv, 1);
append_escaped_arg(pStr, file_tail(ctx.zReplica), 1);
zCmd = sqlite3_str_finish(pStr);
if( popen2(zCmd, &ctx.pIn, &ctx.pOut, &childPid, 0) ){tool/sqlite3_rsync.c:2266-2297. Remote REPLICA is the same pattern with file_tail(ctx.zOrigin) and zDiv at :2339-2340.
hostSeparator splits on the first : with no / or \\ before it. USER@HOST:PATH becomes destination USER@HOST and PATH as a later argv. popen2(..., bDirect=0) execs /bin/sh -c zCmd:
if( bDirect ){
execl(zCmd, zCmd, (char*)0);
}else{
execl("/bin/sh", "/bin/sh", "-c", zCmd, (char*)0);
}Unix quoting in append_escaped_arg (:510-535): if the name has no ', wrap in '...'. If it has ', backslash specials. ; $ ` | & ( ) are aSafeChar=1 (escape), not 2 (illegal). Control characters including newline and tab are 2 and are not appended. Callers ignore the non-zero return, so the vector is metacharacters that are quoted locally, not NULs.
The local shell therefore sees:
ssh -e none 'lab@host' 'sqlite3_rsync' --origin ';echo WITNESS>/work/sandbox/pwned.txt' 'replica.db'Those quotes are for /bin/sh -c. They are not on the wire to sshd. OpenSSH ssh.c space-joins every argument after the destination. The remote login shell sees:
PATH=$HOME/bin:... sqlite3_rsync --origin ;echo SQLITE-RSYNC-SSH-WITNESS>/work/sandbox/pwned.txt replica.db--origin is now a flag with no filename. ;echo is a new command. replica.db is echo's extra argument, which is why the witness file ends with replica.db. Forum traces of this class already showed bash: line 1:. That is the remote login shell, not the local one.
Trunk fix after this GA: isFilename==2 hex-encodes using A-P digits for names sent to the peer sqlite3_rsync. Peer decode_filename() plus --encoded-filenames N. Call sites become append_escaped_arg(pStr, zDiv, 2) and append_escaped_arg(pStr, file_tail(...), 2). USER@HOST and --ssh stay isFilename=1 (local sh only). 3.53.4 does not have that.
Fake OpenSSH, not a real sshd
A real sshd on loopback would prove "SSH works." It would not prove argv join. Python subprocess list form would prove "the util spawned ssh." It would not prove what the remote shell parsed. The lab ships fake_ssh.py: an OpenSSH-shaped ssh(1) stand-in that parses -e none (and optional -p), takes the destination, space-joins the rest, logs argv and the reconstructed command, and runs that string under a jailed sh -c in /work/sandbox. No sockets. No real sshd. No reverse shell.
The Dockerfile builds sqlite3_rsync from the official sqlite-src-3530400.zip (./configure && make sqlite3_rsync) and refuses to proceed unless --version contains 2026-07-24 19:02:57. Compose project sqlite-rsync-ssh. Published 127.0.0.1:18910 is a Python http.server over /work so the host can fetch the witness without another docker exec. Bind it to loopback.
Control origin is lab@host:/work/sandbox/origin.db. That PATH has no metacharacters. Remote command is a single sqlite3_rsync --origin ... replica.db. No witness file. Inject origin is lab@host:;echo SQLITE-RSYNC-SSH-WITNESS>/work/sandbox/pwned.txt. Same binary, same fake ssh, one extra semicolon.
The lab
Control remote argv:
PATH=$HOME/bin:/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:$PATH sqlite3_rsync --origin /work/sandbox/origin.db replica.dbNo unquoted metacharacter. No pwned.txt.
Inject ssh argv after dest:
PATH=$HOME/bin:/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:$PATH
sqlite3_rsync
--origin
;echo SQLITE-RSYNC-SSH-WITNESS>/work/sandbox/pwned.txt
replica.dbJoined remote command had unquoted ;echo. Destination lab@host. pwned.txt was SQLITE-RSYNC-SSH-WITNESS replica.db. Loopback GET /sandbox/pwned.txt returned the same bytes. Remote stderr was missing ORIGIN database filename, which is sqlite3_rsync noticing that --origin lost its path after the semicolon split. The echo still ran. That is the oracle.
Last line:
SUCCESS sqlite-rsync-ssh how=openssh-argv-join dest=lab@host remote-has-semicolon=yes SQLITE-RSYNC-SSH-WITNESSReplay:
cd lab && ./run.shOr from a fresh clone:
git clone https://github.com/abraxas/sqlite-rsync-ssh
cd sqlite-rsync-ssh/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it.
Wrong turns, in the order I made them
Pinning draft 3.54.0. Calendar said mid-October. The tag was not a release. 3.53.4 is latest GA. Hex-encode is on trunk after this pin. That is the definition of leftover.
Treating Ada Logics stack smash as still open. Forum e2dfadf51209f12b is a High crash class on unfixed trees. readPow2 on this pin is x>16. ORIGIN_PAGE reads p->szPage into buf[65536]. ASan WRITE past the frame is closed. Re-filing it is a skip-list miss. This lab is SSH filename injection only.
Calling this libsqlite3 RCE. Default sqlite3.c does not ship this protocol. main.mk builds sqlite3_rsync from tool/sqlite3_rsync.c plus sqlite3.c with SQLITE_ENABLE_DBPAGE_VTAB. Embedders of the amalgamation are not this victim. Operators who installed the util and typed USER@HOST:PATH are.
Labbing a reverse shell. Theatre. The oracle is echo WITNESS into a file under /work/sandbox. If the leftover needs a connect-back to look High, it was already High: the remote UID ran an extra command.
Talking to a real sshd. Then you are labbing sshd, keys, AcceptEnv, and whether sqlite3_rsync is on the remote PATH. The leftover is argv join after dest. Fake OpenSSH that does the same join is the experiment. --ssh /opt/lab/fake-ssh is a documented util flag.
Treating May 26 local-sh quoting as the leftover. aSafeChar plus Fossil append_escaped_arg is in this pin. It is why ; is quoted for /bin/sh -c instead of splitting locally. The remote half never got those quotes. The 2026-10-07 hex-encode is the remote half.
Hoping control characters would be the vector. Newline and tab are aSafeChar=2. They are not appended. Call sites ignore the error, so the filename is silently truncated, which is a different bug and not this High. The vector is metacharacters that are legal and quoted: ;, $(), backticks, |.
Scoring it Local because the victim types the command. git and rsync are Remote UI:R when the other side is hostile. CVSS UI:R is that class. Unauthenticated-on-the-wire it is not. The operator has to run sqlite3_rsync. PATH can still come from a copied command, a script, a ticket, a docs snippet, or a hostname they do not control.
Expecting the dummy peer filename to be the only sink. zDiv (the PATH after the colon) is appended with isFilename=1. file_tail of the local replica is also appended. Either side can carry metacharacters. The lab used PATH because that is the argument an origin owner chooses.
Putting WordPress or a hosted SQLite protocol in the compose so the leftover "looks remote." The util is the product. Witness in a jailed sh -c is the oracle. A web wrapper around sqlite3_rsync is a different paper.
What an attacker can do
Get the victim to run sqlite3_rsync ORIGIN REPLICA where one side is USER@HOST:PATH and PATH contains ;, $(), backticks, or |. Documented usage. sqlite3_rsync installed on both ends, also documented.
Local quoting wraps PATH for /bin/sh -c. OpenSSH concatenates argv for the remote login shell without those quotes. The extra command runs as the SSH remote UID. Integrity, confidentiality, availability of that account. Not a crash. Not a file write of database pages. Command injection.
Victim must run the util (UI:R). This is not unauthenticated RCE in libsqlite3. It is not the Ada Logics stack smash. It is not Windows WorstFit. It is not writable_schema. Apps that never ship sqlite3_rsync are out of scope.
I am not going to print a pasteable USER@HOST:;rm for someone else's replica. The missing hex-encode on names sent to the peer is the useful part. If you own the hosts, run the proof of concept against loopback.
The fix
Hex-encode filenames sent across SSH the way trunk b0db2db (2026-10-07, ANT-2026-31X9HVRR) does: isFilename==2, A-P digits, peer --encoded-filenames plus decode_filename(). USER@HOST and --ssh stay local-sh quoting.
Until then, do not put shell metacharacters in PATH. Do not take "the util quoted it" as a reason the remote shell is safe. Local single quotes are a /bin/sh -c feature. They are not an SSH feature.
Check append_escaped_arg return values so illegal names fail closed instead of truncating. That is a sibling, not this High.
References
- Proof of concept: abraxas/sqlite-rsync-ssh · sqlite-rsync-ssh-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py · fake_ssh.py · entrypoint.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- Pin: github.com/sqlite/sqlite tree version-3.53.4 (
b09c88c14082339b66c7b7158d609a771e64ca69) · canonical sqlite.org/src - tool/sqlite3_rsync.c
append_escaped_arg,popen2, remote ORIGIN/REPLICA SSH argv - sqlite.org/rsync.html
- Ada Logics stack smash (closed on this pin): forum e2dfadf51209f12b
- Trunk hex-encode: Git
b0db2db/ Fossil eaec2f7f (2026-10-07, ANT-2026-31X9HVRR) - CWE-78 · 8.8 High