Skip to content

1.2-maint: CVE-2026-62268 and other fixes#9916

Open
ThomasWaldmann wants to merge 7 commits into
borgbackup:1.2-maintfrom
ThomasWaldmann:1.2-fixes
Open

1.2-maint: CVE-2026-62268 and other fixes#9916
ThomasWaldmann wants to merge 7 commits into
borgbackup:1.2-maintfrom
ThomasWaldmann:1.2-fixes

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

No description provided.

@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.59459% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.99%. Comparing base (d2db21e) to head (93d9571).
⚠️ Report is 5 commits behind head on 1.2-maint.

Files with missing lines Patch % Lines
src/borg/archive.py 94.28% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##           1.2-maint    #9916      +/-   ##
=============================================
- Coverage      81.05%   80.99%   -0.07%     
=============================================
  Files             38       38              
  Lines          10854    10859       +5     
  Branches        1669     1674       +5     
=============================================
- Hits            8798     8795       -3     
- Misses          1516     1523       +7     
- Partials         540      541       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann ThomasWaldmann changed the title CVE-2026-62268 fixes - backport to 1.2-maint 1.2-maint: CVE-2026-62268 and other fixes Jul 19, 2026
@ThomasWaldmann
ThomasWaldmann marked this pull request as draft July 19, 2026 22:17
ThomasWaldmann and others added 7 commits July 20, 2026 15:26
…mbedded "..")

CVE-2026-62268 Fix 1/2

A maliciously crafted archive could contain a symlink (e.g. "evil" -> "/etc")
followed by an item below it ("evil/passwd"), or an item whose path embeds ".."
(e.g. "a/../../etc/passwd"). When extracting such items, borg followed the symlink
/ resolved the ".." in both the "remove existing file" step and the open()/mkdir()/
symlink() step, so it could delete or overwrite files outside the extraction
directory.

Note: sounds bad, but no big issue in practice: to create such a malicious archive,
the attacker would need repository access and (for encrypted or authenticated repos)
also the borg key and passphrase.

borg create never produces such archives (it does not follow symlinks and stores
normalized relative paths), so extract_item() now verifies, before any destructive
operation, that the item's parent path contains no ".." and no symlinked (or other
non-directory) component. Unsafe items are skipped with a warning (EXIT_WARNING)
and extraction continues. Verified-safe parent directories are cached per extraction
(Archive.safe_dirs) so each directory is lstat'd at most once.

Added regression tests for both attack vectors and a benign deep-tree extraction.

Also: skip redundant make_parent stat using safe_dirs cache

Performance optimization building on the parent-path security fix: the parent
path guard in extract_item already lstat's every existing parent directory and
records it in Archive.safe_dirs. make_parent therefore no longer needs to call
os.path.exists() for a parent that is already known to be a real directory - it
returns early on a safe_dirs hit, and caches directories it has to create (and
dest) so later items skip the stat too.

This roughly halves the per-item directory-metadata syscalls in the common case
(deep tree, shared parents) without changing behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CVE-2026-62268 Fix 2/2

The hardlink source (item.source) was used unvalidated as the os.link() target
during extraction. A maliciously crafted archive could set a source that traverses
a symlink (e.g. "evil/secret" with "evil" -> "/etc") or contains "..", causing
borg to hardlink an arbitrary external file (e.g. /etc/shadow) into the extracted
tree - an information disclosure, especially dangerous when restoring as root.

Note: sounds bad, but no big issue in practice: to create such a malicious archive,
the attacker would need repository access and (for encrypted or authenticated repos)
also the borg key and passphrase.

extract_helper now validates the hardlink source path with the same
Archive._check_safe_parent() check used for item paths, refusing unsafe ones with a
warning (and continuing with the rest of the archive). os.link() is now called with
follow_symlinks=False (where supported) so a symlinked final source component links
the symlink itself - a faithful restore - rather than the external file it targets.

Adds BackupHardlinkSourceError and regression tests for both the symlinked-source and
embedded-".." source vectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
process_file() ran process_file_chunks() inside `with backup_io('read')`.
That block is meant to guard reading the *source* file, but the source reads
are already guarded individually by backup_io_iter(). The outer wrapper also
caught add_chunk()'s (and maybe_checkpoint()'s) *repository* writes, so a
repository IO failure -- e.g. the repo running out of space -- was wrapped
into a per-file BackupOSError tagged "read". Borg then emitted misleading
"<path>: read: [Errno 28] No space left on device" warnings, pointlessly
retried the file, and only treated a critical repository error as a
non-critical per-file one, contrary to the BackupOSError docstring ("Any
unwrapped IO error is critical and aborts execution (for example repository
IO failure)").

In 1.4 the transactional repository still rolls the partial transaction back
on the eventual failure, so this is not data loss here (unlike borg2, where
it silently commits a corrupt archive). But the misclassification -- wrong
warning text and needless read-retries of a repository-full condition -- is
wrong regardless.

Drop the outer backup_io('read') wrapper. Source reads stay per-file
warnings (backup_io_iter is unchanged); repository OSErrors are now left
unwrapped and critical, aborting promptly with the correct error.

Verified on a space-limited macOS ramdisk: before, create emitted many
"read: [Errno 28]" retry warnings then rolled back; after, it aborts
immediately ("No space left on device, cleaning up partial transaction"),
commits no archive, and the repo stays consistent. Normal backups and
unreadable-source-file handling (per-file warning) are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bFWgq2KV6oYxmfH6Zqvp3
fix: resolve KeyError and memory leaks in LRUCache

- __setitem__: assign value before popping from _lru to avoid KeyError when exceeding capacity.
- clear(): clear the _lru list also to prevent stale keys causing KeyErrors during future evictions.

(cherry picked from commit 32cfddc)
- better check return value of fd.read(n) and reject if it returns more bytes than requested.
- avoid giving len<=0 to posix_fadvise(), which could drop the rest of the file from cache.
- buzhash: check for len == 0 edge case
- correctly Py_DECREF in cases of errors
- check for malloc/calloc failures

(cherry picked from commit 37f66f1)
- avoid buckets_length integer overflow on 32bit systems via huge num_buckets
- always initialize index-> min_empty and num_empty
- correctly free memory when header validation fails.
  this is a minor issue, because borg will terminate in that case anyway.
- make it possible to lookup in compacted hashtables
- deal safely with empty index: we must use num_buckets = 1 to avoid division
  by zero and sanity check in hashindex_read.
- reinitialize upper/lower limit and min_empty after compact
- fix size_idx / fit_size / grow_size / shrink_size (mind array bounds)
- deal with growing when already at max capacity
- hashindex_resize: replace num_entries assertion, rather return error
- BaseIndex.clear: always stay in valid state
  Do not free the old index before we successfully have allocated a new one.
  This is a minor issue as the Exception raised would terminate borg anyway.

(cherry picked from commit cd2f5a0)
The previous code performed allocations and buffer acquisitions before the
`try` block. If a later allocation or buffer acquisition failed, execution did
not enter the `finally` block, so resources acquired earlier in the setup path
could leak.

Move allocation and buffer acquisition into the guarded block, initialize raw
output pointers to `NULL`, and only call `PyMem_Free` or `PyBuffer_Release`
for resources that were actually acquired.

(cherry picked from commit 2d7d5f2)
@ThomasWaldmann
ThomasWaldmann marked this pull request as ready for review July 20, 2026 14:00
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.

1 participant