Skip to content

master: CVE-2026-62268 fixes#9919

Merged
ThomasWaldmann merged 2 commits into
borgbackup:masterfrom
ThomasWaldmann:cve-2026-62268-master
Jul 20, 2026
Merged

master: CVE-2026-62268 fixes#9919
ThomasWaldmann merged 2 commits into
borgbackup:masterfrom
ThomasWaldmann:cve-2026-62268-master

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

CVE-2026-62268 — port the extract security fixes to master (borg 2)

Ports the two 1.4-maint security fixes to master. Because borg 2's extract/hardlink architecture differs from 1.4, this is a re-implementation rather than a cherry-pick.

Upstream 1.4-maint commits:

  • 3e9ed6d1ad6d3531b39c07b8ef3ecf41fce4437f — refuse unsafe parent paths
  • 141888f2fabcd57a300547c2aa63212cb213924d — refuse hardlinks with an unsafe source path

Fix 1/2 — refuse unsafe parent paths (symlinked dir)

A crafted archive with a symlink (evil/etc) followed by an item below it (evil/passwd) made extract_item() follow the symlink in both the "remove existing file" step and the open()/mkdir()/symlink() step, so it could delete or overwrite files outside the extraction directory.

  • New Archive._check_safe_parent() verifies, before any destructive op, that an item's parent chain contains no .. and no symlinked (non-directory) component. Unsafe items are skipped with a warning; verified-safe dirs are cached in Archive.safe_dirs (each dir lstat'd at most once), and make_parent reuses that cache.
  • Adds BackupSymlinkParentError (mcode 108) and BackupPathTraversalError (mcode 109).

Differences from 1.4:

  • Archived paths are split on / (borg 2 always uses /, even on Windows), not os.sep.
  • Symlink targets live in item.target, not item.source.
  • The embedded-.. vector is already closed in borg 2 one layer earlier: Item.path is sanitized on encode (assert_sanitized_path) and decode (to_sanitized_path), so a .. path can neither be stored nor read back. The .. branch of _check_safe_parent is therefore defense-in-depth only; the test asserts the item-layer rejection instead.

Fix 2/2 — harden hardlinked-symlink restore

The 1.4 fix validated the attacker-controlled item.source used as the os.link() target and linked with follow_symlinks=False.

borg 2 has no item.source vector: extraction hardlinks are driven by hlid, and the os.link() target is always a path borg itself remembered (HardLinkManager, hlid → path) from a previously extracted item whose path already passed _check_safe_parent(). So there is nothing to validate and nothing raises BackupHardlinkSourceError — the class (mcode 110) is defined only to reserve the number for cross-branch parity with 1.4-maint.

The remaining borg 2 vector is a hardlinked symlink (symlink s/etc/shadow plus a contentless hardlink h sharing s's hlid). borg 2 already passed follow_symlinks=False, but unconditionally — on a platform where os.link ignores it, that raises NotImplementedError, which backup_io (OSError only) would not catch. This ports the 1.4 os.supports_follow_symlinks guard (fall back to a plain os.link()), plus a comment on why follow_symlinks=False is security-relevant.

Tests

  • test_extract_through_symlinked_parent — symlinked-parent vector is refused; out-of-tree victim untouched; symlink restored faithfully.
  • test_extract_path_with_embedded_dotdot_rejected_at_item_layer — documents that borg 2 rejects .. paths at the item layer.
  • test_extract_deep_tree_ok — the guard / safe_dirs cache doesn't break normal deep-tree extraction.
  • test_extract_hardlinked_symlink_does_not_leak_targeth is restored as a hardlink to the symlink inode, not to the external victim's inode. Verified to fail if follow_symlinks=False is removed.

All new tests pass; full local extract_cmd_test.py run: 33 passed, 5 skipped. black clean.

🤖 Generated with Claude Code

ThomasWaldmann and others added 2 commits July 20, 2026 22:57
CVE-2026-62268 Fix 1/2 (port to master / borg 2)

A maliciously crafted archive could contain a symlink (e.g. "evil" -> "/etc")
followed by an item below it ("evil/passwd"). When extracting such an item, borg
followed the symlink 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 symlinked (or other non-directory)
component. Unsafe items are skipped with a warning and extraction continues. Verified-
safe parent directories are cached per extraction (Archive.safe_dirs) so each directory
is lstat'd at most once.

Also: skip redundant make_parent stat using the safe_dirs cache.

Differences from the 1.4-maint fix:
- Archived paths are split on "/" (borg 2 always uses "/", even on Windows), not os.sep.
- Symlink targets live in item.target (not item.source).
- The embedded-".." vector is already blocked in borg 2 one layer earlier: Item.path is
  sanitized on encode (assert_sanitized_path) and decode (to_sanitized_path), so an item
  whose path contains ".." can neither be stored nor read back. The "../" branch of
  _check_safe_parent (BackupPathTraversalError) is therefore kept as defense-in-depth at
  the destructive-operation boundary but is unreachable via a crafted archive; the test
  asserts the item-layer rejection instead.

Adds BackupSymlinkParentError (mcode 108) and BackupPathTraversalError (mcode 109), and
regression tests for the symlinked-parent vector, the item-layer ".." rejection, and a
benign deep-tree extraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CVE-2026-62268 Fix 2/2 (port to master / borg 2)

The 1.4 fix validated the attacker-controlled hardlink source path (item.source)
before using it as the os.link() target, and linked with follow_symlinks=False so a
symlinked source component links the symlink itself rather than the external file it
points to (an information disclosure, especially dangerous when restoring as root).

borg 2 does not have the item.source vector: extraction hard links are driven by hlid,
and the os.link() target (link_target) is never an attacker-controlled archived path -
it is a path borg itself remembered (HardLinkManager, hlid -> path) from a previously
extracted item, whose own path already passed Archive._check_safe_parent() (Fix 1/2).
So there is nothing to validate there and borg 2 never raises BackupHardlinkSourceError;
the class (and mcode 110) is still defined but kept only reserved for cross-branch parity
with 1.4-maint.

The remaining borg 2 vector is a hardlinked symlink: a crafted archive with a symlink
"s" -> "/etc/shadow" plus a contentless hardlink "h" sharing s's hlid. Without care,
os.link(s, h) follows the symlink and hard links the external file into the extracted
tree. borg 2 already passed follow_symlinks=False here, but unconditionally - on a
platform where os.link ignores follow_symlinks that raises NotImplementedError, which
backup_io (OSError only) would not catch. Port the 1.4 supports_follow_symlinks guard so
we fall back to a plain os.link() there, and document why follow_symlinks=False matters.

Adds a regression test that crafts the hardlinked-symlink archive and asserts "h" is
restored as a hard link to the symlink inode (faithful restore), not to the external
victim file's inode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ThomasWaldmann ThomasWaldmann changed the title CVE-2026-62268: port extract security fixes to master (borg 2) master: CVE-2026-62268 fixes Jul 20, 2026
@ThomasWaldmann ThomasWaldmann added this to the 2.0.0b22 milestone Jul 20, 2026
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.93939% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.38%. Comparing base (629d440) to head (4f37fdf).
⚠️ Report is 9 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/archive.py 92.30% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9919      +/-   ##
==========================================
+ Coverage   85.27%   85.38%   +0.11%     
==========================================
  Files          93       93              
  Lines       15932    16012      +80     
  Branches     2435     2444       +9     
==========================================
+ Hits        13586    13672      +86     
+ Misses       1636     1632       -4     
+ Partials      710      708       -2     

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

@ThomasWaldmann
ThomasWaldmann merged commit 907ba36 into borgbackup:master Jul 20, 2026
19 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the cve-2026-62268-master branch July 20, 2026 22:22
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