Skip to content

fix: serialize cache values as JSON instead of pickle - #663

Open
scttfrdmn wants to merge 1 commit into
treeverse:mainfrom
scttfrdmn:fix/cache-json-serialization
Open

fix: serialize cache values as JSON instead of pickle#663
scttfrdmn wants to merge 1 commit into
treeverse:mainfrom
scttfrdmn:fix/cache-json-serialization

Conversation

@scttfrdmn

Copy link
Copy Markdown

Problem

diskcache pickles any value that is not a str, int, float, or bytes, and unpickles it on read. That makes the cache directory a code-execution surface: anything able to write into it can hand a poisoned payload to the next reader — CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v.

The CVE is unfixed upstream: 5.6.3 is the newest release and both proposed fixes (grantjenks/python-diskcache#358, #364) were declined. So a version floor is not available to us, and dvc-data is what puts three of its four cache call sites in the pickling path.

Which sites pickle is not obvious. Disk.store dispatches on exact types (type(value) is int), so bool is not covered by the int case:

Call site Value Stored as
hashfile/state.py links (inode, mtime) tuple pickle
hashfile/db/index.py True / False pickle (bool is not int)
index/serialize.py entry dict pickle
HashesCache JSON string raw

Keys are already pickle-free — dvc-data's keys are strings, which the base class stores raw.

Approach

dvc-data does not need pickle's expressiveness. Every value it caches is a dict, bool, tuple of numbers, or an already-JSON-encoded string, so JSON covers the whole domain with no code-execution primitive on read.

JSONDisk re-encodes only the values the base class would have pickled. str/int/float/bytes keep their raw storage, which is what leaves HashesCache untouched — it writes through the disk layer but reads back with raw SQL (WHERE ... and raw = 1), so re-encoding those would double-encode them.

Migrating existing caches

Entries written by an older dvc-data are recognised by content, not a schema version: a protocol-2+ pickle starts with the PROTO opcode 0x80, which no JSON encoding can begin with. A legacy entry is refused, evicted, and reported as a miss. All four caches live under tmp_dir and are regenerable, so that costs a recomputation, not data.

Reading a legacy entry evicts it, so the two places that iterate a Cache now materialize their keys and read via get() — otherwise .items() races the eviction and raises KeyError. That's a real bug I only found by exercising the Index and links paths rather than the cache in isolation. get_unused_links also compares the stored (inode, mtime) pair as a sequence, since JSON has no tuple type.

On the existing pickle tests

The declined #364 drew the objection that it "changed the existing tests to expect UnpicklingError… documents the regression but does not resolve it." This does not do that. test_pickle_protocol_error still asserts DiskError, because keys are still pickled and that path stays live; the protocol-interop test now asserts a successful read, because values no longer depend on the pickle protocol at all.

Verification

  • Full suite green: 241 passed (baseline 219 → +22 new tests).
  • Every new test confirmed to fail against unpatched src/ — 15 failed / 29 passed with src/ reverted and the tests kept. A security test that was never watched to fail proves nothing.
  • test_poisoned_entry_is_not_executed is an end-to-end exploit: injecting a __reduce__ payload into the cache DB executes it against stock diskcache and returns a miss here.
  • ruff check: all checks passed. ruff format --check: clean.
  • Large-value (file-backed, >min_file_size) path checked separately: round-trips, payload starts b'{', exactly one value file, no orphan.

Happy to split the migration handling into its own commit, or to gate the JSON disk behind a setting, if you'd prefer a smaller first step.

diskcache pickles any value that is not a str, int, float, or bytes, and
unpickles it on read. That makes the cache directory a code-execution
surface: anything able to write into it can hand a poisoned payload to
the next reader (CVE-2025-69872 / GHSA-w8v5-vhqr-4h9v).

The CVE is unfixed upstream -- 5.6.3 is the newest release and both
proposed fixes were declined -- so dvc-data cannot wait for a patched
diskcache. It also does not need pickle's expressiveness. Every value it
caches is a dict, bool, tuple of numbers, or an already-JSON-encoded
string, so JSON covers the whole domain with no code-execution primitive
on read.

Add a JSONDisk that re-encodes only the values the base class would have
pickled. str, int, float, and bytes keep their raw storage, which leaves
HashesCache -- it writes through the disk layer but reads back with raw
SQL -- untouched. Keys are also left to the base class, which stores
dvc-data's string keys raw and is therefore already pickle-free.

Entries written by an older dvc-data are recognised by content rather
than a schema version: a protocol-2+ pickle starts with the PROTO opcode
(0x80), which no JSON encoding can begin with. Such an entry is refused,
evicted, and reported as a miss. These caches all live under tmp_dir and
are regenerable, so that costs a recomputation rather than data.

Reading a legacy entry evicts it, so the two places that iterate a Cache
now materialize their keys and read via get(): ObjectDBIndex.dir_hashes
and State.get_unused_links. get_unused_links also compares the stored
(inode, mtime) pair as a sequence, since JSON has no tuple type.

The existing pickle-protocol tests are updated rather than weakened to
expect an error: DiskError stays reachable through pickled *keys*, and
the protocol-interop test now asserts a successful read, because values
no longer depend on the protocol at all.
@github-project-automation github-project-automation Bot moved this to Backlog in DVC Aug 3, 2026
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants