Repository navigation
[P2][security][needs-validation] Embedded SQLite scan opens a path decoded from the scanned file's name (SQLAlchemy 2.1.x) #2095
Description
Source: security-audit skill, run data-boar-run-1 (profile quick, source-only: no target code was executed), snapshot origin/main @ 7f354a34, 2026-10-18. Each trace below was found by a hunter agent and then re-read in the snapshot by an independent verifier. Status: needs validation — the source trace holds, the runtime result is not observed. Please start with the bounded local test in the plan; if it reproduces, turn it into a regression test and fix, otherwise close with the evidence.
Embedded SQLite scan opens a path decoded from the scanned file's name, bypassing scan-root containment
For every *.sqlite, *.sqlite3 or *.db file under a filesystem target (scan_sqlite_as_db defaults to true and .db/.sqlite/.sqlite3 are in the default extensions), FilesystemConnector builds the SQLAlchemy URL as an f-string of the raw resolved path: create_engine(f"sqlite:///{file_path.resolve()}"). In SQLAlchemy 2.1.3 (the version pinned in requirements.txt:2439) engine/url.py _parse_url runs unquote() on username, password and database (url.py:907 in a 2.1.3 copy outside the target), and splits the query at the first '?'. The pysqlite dialect then calls os.path.abspath on the decoded database. A principal who can create files in the scanned tree can name a file e.g. '..%2F..%2Fvar%2Flib%2Fapp%2Fstate.db'. The connector then opens /root/../../var/lib/app/state.db, which abspath normalises to a location outside the scan root, instead of the yielded file. The traversal needs only '%2F' and a '.db'/'.sqlite' suffix, not '?'. The default pysqlite engine opens read-write with create, so a missing path whose parent exists is created as an empty file with the scanner's privileges. An existing SQLite database could have a hot journal rolled back or a WAL checkpointed. Table.column names and sensitivity of an out-of-root database would be written to findings and reports, which the operator reads. This is not a read channel for the attacker unless they can see reports. Version caveat checked in source: SQLAlchemy 1.4.50, 2.0.46 and 2.0.50 do NOT unquote the database component, and pyproject.toml:102 allows sqlalchemy>=2.0.50, so installs outside the pinned lockfile are not affected by the %2F traversal. A bare '?' only truncates the path to a prefix of the real path, so it stays inside the root and cannot escape it. Share connectors (SMB, WebDAV, SharePoint) pass random temp names (e.g. sharepoint_connector.py:243-245) and are not affected. The NFS connector inherits the local path. No already-filed issue (#2077, #2078, #2079, #2086, #2087, #2088, #2089) covers this root cause.
Root cause. The scanned file's name is concatenated into a SQLAlchemy URL string instead of being passed as a structured value (URL.create("sqlite", database=str(path)), or a quoted sqlite3 file: URI with mode=ro). URL percent-decoding (SQLAlchemy 2.1.x) and '?' query splitting therefore re-interpret attacker-controlled name characters after iter_scan_files has already approved the literal path.
Trace.
connectors/filesystem_connector.py:823(entrypoint) — iter_scan_files yields file names controlled by whoever can write into the scanned tree. Linux file names may contain '%' and '?'.config/loader.py:681(propagation) — scan_sqlite_as_db defaults to True, so the SQLite branch is on unless an operator disables it. The default extension list includes .sqlite, .sqlite3 and .db.connectors/filesystem_connector.py:853(propagation) — Only the suffix is checked against SQLITE_EXTENSIONS (line 684). A name such as '..%2F..%2Fx.db' has suffix '.db' and passes.connectors/filesystem_connector.py:854(propagation) — file_path, the yielded and containment-checked path, is passed on unchanged.connectors/filesystem_connector.py:609(sink) — create_engine(f"sqlite:///{file_path.resolve()}") followed by inspect(engine) and engine.connect() opens the URL-decoded, abspath-normalised path, which can lie outside the scan root.
What blocks confirmation.
- Source-only run: no target or SQLAlchemy code may be executed, so the actual opened path (and whether an empty file is created) for a name like '..%2Foutside%2FX.db' has not been observed. It is derived from reading SQLAlchemy 2.1.3 engine/url.py and dialects/sqlite/pysqlite.py.
- The unquote-of-database behaviour exists only in SQLAlchemy 2.1.x. It is absent in 2.0.46 and 2.0.50 and in 1.4.50, which were read. Which version a given deployment actually runs (lockfile 2.1.3 versus pyproject floor >=2.0.50) is decisive for exposure and is a deployment fact.
Bounded local test (dummy data, sandbox, no network). In a scratch directory with dummy data only, and with no network, create root/ containing a decoy file literally named '..%2Foutside%2Ftarget.db' and a dummy SQLite db outside/target.db. With SQLAlchemy 2.1.3 installed, run only sqlalchemy.engine.make_url(f"sqlite:///{decoy.resolve()}") and print .database to confirm it equals root/../outside/target.db. Then run a stub FilesystemConnector, or _scan_sqlite_file_as_db directly, with a stub scanner against root and trace openat with strace to see which path sqlite3 opens. Repeat with a missing target (parent dir exists) to see whether an empty file is created. Repeat with SQLAlchemy 2.0.50 to confirm no traversal there. Expected fix check: after building the engine with URL.create('sqlite', database=str(path)) and mode=ro (or a quoted file: URI), the decoy opens only itself.
Owner-observed check. Owner check without sending traffic: confirm which sqlalchemy version the deployed environment runs (pip show sqlalchemy). Search scanned trees for file names containing '%2F' or '%2f' ending in .db/.sqlite/.sqlite3. Search findings and reports for file_name or path values containing '%2F'. Use filesystem audit (auditd or inotify) to see whether the scanner account opened .db paths outside the configured target roots.
Suggested direction (recommendation). Build the engine with URL.create('sqlite', database=str(path)) (or a quoted file: URI) and open it read-only (mode=ro/query_only), so URL decoding never re-interprets the name. Exposure depends on the SQLAlchemy version (2.1.x unquotes, 2.0.50 does not; the lockfile pins 2.1.3).
Activity
FabioLeitao commentedon Oct 20, 2026
Resolvida pela PR #2114 (merge 87f1fe0). Commit cb9c8f4. Teste: test_sqlite_scan_opens_filename_with_percent_encoding em tests/test_sampling_dedup_before_cap.py.