CVE-2021-21300: Improper Link Resolution Before File Access ('Link Following') (CWE-59)
value: Git is an open-source distributed revision control system. In affected versions of Git a specially crafted repository that contains symbolic links as well as files using a clean/smudge filter such as Git LFS, may cause just-checked out script to be executed while cloning onto a case-insensitive file system such as NTFS, HFS+ or APFS (i.e. the default file systems on Windows and macOS). Note that clean/smudge filters have to be configured for that. Git for Windows configures Git LFS by default, and is therefore vulnerable. The problem has been patched in the versions published on Tuesday, March 9th, 2021. As a workaound, if symbolic link support is disabled in Git (e.g. via `git config --global core.symlinks false`), the described attack won't work. Likewise, if no clean/smudge filters such as Git LFS are configured globally (i.e. _before_ cloning), the attack is foiled. As always, it is best to avoid cloning repositories from untrusted sources. The earliest impacted version is 2.14.2. The fix versions are: 2.30.1, 2.29.3, 2.28.1, 2.27.1, 2.26.3, 2.25.5, 2.24.4, 2.23.4, 2.22.5, 2.21.4, 2.20.5, 2.19.6, 2.18.5, 2.17.62.17.6.
CWE description: The product attempts to access a file based on the filename, but it does not properly prevent that filename from identifying a link or shortcut that resolves to an unintended resource.
CVSS v3 base score: 7.5
Published: 2021-03-09T20:15Z
Repository: https://github.com/git/git
Language: C
Files changed: cache.h, compat/mingw.c, git-compat-util.h, symlinks.c, t/t0021-conversion.sh, t/t0021/rot13-filter.pl, t/t2006-checkout-index-basic.sh
Commit date: 2021-02-12 15:47:02 +0100
Commit message: checkout: fix bug that makes checkout follow symlinks in leading path

Before checking out a file, we have to confirm that all of its leading
components are real existing directories. And to reduce the number of
lstat() calls in this process, we cache the last leading path known to
contain only directories. However, when a path collision occurs (e.g.
when checking out case-sensitive files in case-insensitive file
systems), a cached path might have its file type changed on disk,
leaving the cache on an invalid state. Normally, this doesn't bring
any bad consequences as we usually check out files in index order, and
therefore, by the time the cached path becomes outdated, we no longer
need it anyway (because all files in that directory would have already
been written).

But, there are some users of the checkout machinery that do not always
follow the index order. In particular: checkout-index writes the paths
in the same order that they appear on the CLI (or stdin); and the
delayed checkout feature -- used when a long-running filter process
replies with "status=delayed" -- postpones the checkout of some entries,
thus modifying the checkout order.

When we have to check out an out-of-order entry and the lstat() cache is
invalid (due to a previous path collision), checkout_entry() may end up
using the invalid data and thrusting that the leading components are
real directories when, in reality, they are not. In the best case
scenario, where the directory was replaced by a regular file, the user
will get an error: "fatal: unable to create file 'foo/bar': Not a
directory". But if the directory was replaced by a symlink, checkout
could actually end up following the symlink and writing the file at a
wrong place, even outside the repository. Since delayed checkout is
affected by this bug, it could be used by an attacker to write
arbitrary files during the clone of a maliciously crafted repository.

Some candidate solutions considered were to disable the lstat() cache
during unordered checkouts or sort the entries before passing them to
the checkout machinery. But both ideas include some performance penalty
and they don't future-proof the code against new unordered use cases.

Instead, we now manually reset the lstat cache whenever we successfully
remove a directory. Note: We are not even checking whether the directory
was the same as the lstat cache points to because we might face a
scenario where the paths refer to the same location but differ due to
case folding, precomposed UTF-8 issues, or the presence of `..`
components in the path. Two regression tests, with case-collisions and
utf8-collisions, are also added for both checkout-index and delayed
checkout.

Note: to make the previously mentioned clone attack unfeasible, it would
be sufficient to reset the lstat cache only after the remove_subtree()
call inside checkout_entry(). This is the place where we would remove a
directory whose path collides with the path of another entry that we are
currently trying to check out (possibly a symlink). However, in the
interest of a thorough fix that does not leave Git open to
similar-but-not-identical attack vectors, we decided to intercept
all `rmdir()` calls in one fell swoop.

This addresses CVE-2021-21300.

Co-authored-by: Johannes Schindelin <johannes.schindelin@gmx.de>
Signed-off-by: Matheus Tavares <matheus.bernardino@usp.br>

Diff: diff --git a/cache.h b/cache.h
index 0323853c99e..c5305939710 100644
--- a/cache.h
+++ b/cache.h
@@ -1567,10 +1567,11 @@ static inline void cache_def_clear(struct cache_def *cache)
 
 extern int has_symlink_leading_path(const char *name, int len);
 extern int threaded_has_symlink_leading_path(struct cache_def *, const char *, int);
 extern int check_leading_path(const char *name, int len);
 extern int has_dirs_only_path(const char *name, int len, int prefix_len);
+extern void invalidate_lstat_cache(void);
 extern void schedule_dir_for_removal(const char *name, int len);
 extern void remove_scheduled_dirs(void);
 
 extern struct alternate_object_database {
 	struct alternate_object_database *next;
diff --git a/compat/mingw.c b/compat/mingw.c
index b047e216609..0c414d08b69 100644
--- a/compat/mingw.c
+++ b/compat/mingw.c
@@ -281,10 +281,12 @@ int mingw_rmdir(const char *pathname)
 	}
 	while (ret == -1 && errno == EACCES && is_file_in_use_error(GetLastError()) &&
 	       ask_yes_no_if_possible("Deletion of directory '%s' failed. "
 			"Should I try again?", pathname))
 	       ret = _wrmdir(wpathname);
+	if (!ret)
+		invalidate_lstat_cache();
 	return ret;
 }
 
 static inline int needs_hiding(const char *path)
 {
diff --git a/git-compat-util.h b/git-compat-util.h
index 37277494f9b..6230f9aaf37 100644
--- a/git-compat-util.h
+++ b/git-compat-util.h
@@ -336,10 +336,15 @@ typedef uintmax_t timestamp_t;
 #endif
 #ifndef _PATH_DEFPATH
 #define _PATH_DEFPATH "/usr/local/bin:/usr/bin:/bin"
 #endif
 
+int lstat_cache_aware_rmdir(const char *path);
+#if !defined(__MINGW32__) && !defined(_MSC_VER)
+#define rmdir lstat_cache_aware_rmdir
+#endif
+
 #ifndef has_dos_drive_prefix
 static inline int git_has_dos_drive_prefix(const char *path)
 {
 	return 0;
 }
diff --git a/symlinks.c b/symlinks.c
index 5261e8cf499..53b770be081 100644
--- a/symlinks.c
+++ b/symlinks.c
@@ -265,10 +265,17 @@ int has_dirs_only_path(const char *name, int len, int prefix_len)
  * 'prefix_len', thus we then allow for symlinks in the prefix part as
  * long as those points to real existing directories.
  */
 static int threaded_has_dirs_only_path(struct cache_def *cache, const char *name, int len, int prefix_len)
 {
+	/*
+	 * Note: this function is used by the checkout machinery, which also
+	 * takes care to properly reset the cache when it performs an operation
+	 * that would leave the cache outdated. If this function starts caching
+	 * anything else besides FL_DIR, remember to also invalidate the cache
+	 * when creating or deleting paths that might be in the cache.
+	 */
 	return lstat_cache(cache, name, len,
 			   FL_DIR|FL_FULLPATH, prefix_len) &
 		FL_DIR;
 }
 
@@ -319,5 +326,22 @@ void schedule_dir_for_removal(const char *name, int len)
 
 void remove_scheduled_dirs(void)
 {
 	do_remove_scheduled_dirs(0);
 }
+
+void invalidate_lstat_cache(void)
+{
+	reset_lstat_cache(&default_cache);
+}
+
+#undef rmdir
+int lstat_cache_aware_rmdir(const char *path)
+{
+	/* Any change in this function must be made also in `mingw_rmdir()` */
+	int ret = rmdir(path);
+
+	if (!ret)
+		invalidate_lstat_cache();
+
+	return ret;
+}
diff --git a/t/t0021-conversion.sh b/t/t0021-conversion.sh
index 46f8e583c37..8ff917fca6d 100755
--- a/t/t0021-conversion.sh
+++ b/t/t0021-conversion.sh
@@ -815,6 +815,51 @@ test_expect_success PERL 'invalid file in delayed checkout' '
 	rm -rf repo-cloned &&
 	test_must_fail git clone repo repo-cloned 2>git-stderr.log &&
 	grep "error: external filter .* signaled that .unfiltered. is now available although it has not been delayed earlier" git-stderr.log
 '
 
+for mode in 'case' 'utf-8'
+do
+	case "$mode" in
+	case)	dir='A' symlink='a' mode_prereq='CASE_INSENSITIVE_FS' ;;
+	utf-8)
+		dir=$(printf "\141\314\210") symlink=$(printf "\303\244")
+		mode_prereq='UTF8_NFD_TO_NFC' ;;
+	esac
+
+	test_expect_success PERL,SYMLINKS,$mode_prereq \
+	"delayed checkout with $mode-collision don't write to the wrong place" '
+		test_config_global filter.delay.process \
+			"\"$TEST_ROOT/rot13-filter.pl\" --always-delay delayed.log clean smudge delay" &&
+		test_config_global filter.delay.required true &&
+
+		git init $mode-collision &&
+		(
+			cd $mode-collision &&
+			mkdir target-dir &&
+
+			empty_oid=$(printf "" | git hash-object -w --stdin) &&
+			symlink_oid=$(printf "%s" "$PWD/target-dir" | git hash-object -w --stdin) &&
+			attr_oid=$(echo "$dir/z filter=delay" | git hash-object -w --stdin) &&
+
+			cat >objs <<-EOF &&
+			100644 blob $empty_oid	$dir/x
+			100644 blob $empty_oid	$dir/y
+			100644 blob $empty_oid	$dir/z
+			120000 blob $symlink_oid	$symlink
+			100644 blob $attr_oid	.gitattributes
+			EOF
+
+			git update-index --index-info <objs &&
+			git commit -m "test commit"
+		) &&
+
+		git clone $mode-collision $mode-collision-cloned &&
+		# Make sure z was really delayed
+		grep "IN: smudge $dir/z .* \\[DELAYED\\]" $mode-collision-cloned/delayed.log &&
+
+		# Should not create $dir/z at $symlink/z
+		test_path_is_missing $mode-collision/target-dir/z
+	'
+done
+
 test_done
diff --git a/t/t0021/rot13-filter.pl b/t/t0021/rot13-filter.pl
index 470107248eb..007f2d78ea5 100644
--- a/t/t0021/rot13-filter.pl
+++ b/t/t0021/rot13-filter.pl
@@ -1,12 +1,18 @@
 #
 # Example implementation for the Git filter protocol version 2
 # See Documentation/gitattributes.txt, section "Filter Protocol"
 #
-# The first argument defines a debug log file that the script write to.
-# All remaining arguments define a list of supported protocol
-# capabilities ("clean", "smudge", etc).
+# Usage: rot13-filter.pl [--always-delay] <log path> <capabilities>
+#
+# Log path defines a debug log file that the script writes to. The
+# subsequent arguments define a list of supported protocol capabilities
+# ("clean", "smudge", etc).
+#
+# When --always-delay is given all pathnames with the "can-delay" flag
+# that don't appear on the list bellow are delayed with a count of 1
+# (see more below).
 #
 # This implementation supports special test cases:
 # (1) If data with the pathname "clean-write-fail.r" is processed with
 #     a "clean" operation then the write operation will die.
 # (2) If data with the pathname "smudge-write-fail.r" is processed with
@@ -51,10 +57,17 @@ sub gitperllib {
 use warnings;
 use IO::File;
 use Git::Packet;
 
 my $MAX_PACKET_CONTENT_SIZE = 65516;
+
+my $always_delay = 0;
+if ( $ARGV[0] eq '--always-delay' ) {
+	$always_delay = 1;
+	shift @ARGV;
+}
+
 my $log_file                = shift @ARGV;
 my @capabilities            = @ARGV;
 
 open my $debug, ">>", $log_file or die "cannot open log file: $!";
 
@@ -132,10 +145,12 @@ sub rot13 {
 		my ( $done, $buffer ) = packet_txt_read();
 		while ( $buffer ne '' ) {
 			if ( $buffer eq "can-delay=1" ) {
 				if ( exists $DELAY{$pathname} and $DELAY{$pathname}{"requested"} == 0 ) {
 					$DELAY{$pathname}{"requested"} = 1;
+				} elsif ( !exists $DELAY{$pathname} and $always_delay ) {
+					$DELAY{$pathname} = { "requested" => 1, "count" => 1 };
 				}
 			} else {
 				die "Unknown message '$buffer'";
 			}
 
diff --git a/t/t2006-checkout-index-basic.sh b/t/t2006-checkout-index-basic.sh
index 57cbdfe9bce..19aada33a33 100755
--- a/t/t2006-checkout-index-basic.sh
+++ b/t/t2006-checkout-index-basic.sh
@@ -19,6 +19,52 @@ test_expect_success 'checkout-index -h in broken repository' '
 		test_expect_code 129 git checkout-index -h >usage 2>&1
 	) &&
 	test_i18ngrep "[Uu]sage" broken/usage
 '
 
+for mode in 'case' 'utf-8'
+do
+	case "$mode" in
+	case)	dir='A' symlink='a' mode_prereq='CASE_INSENSITIVE_FS' ;;
+	utf-8)
+		dir=$(printf "\141\314\210") symlink=$(printf "\303\244")
+		mode_prereq='UTF8_NFD_TO_NFC' ;;
+	esac
+
+	test_expect_success SYMLINKS,$mode_prereq \
+	"checkout-index with $mode-collision don't write to the wrong place" '
+		git init $mode-collision &&
+		(
+			cd $mode-collision &&
+			mkdir target-dir &&
+
+			empty_obj_hex=$(git hash-object -w --stdin </dev/null) &&
+			symlink_hex=$(printf "%s" "$PWD/target-dir" | git hash-object -w --stdin) &&
+
+			cat >objs <<-EOF &&
+			100644 blob ${empty_obj_hex}	${dir}/x
+			100644 blob ${empty_obj_hex}	${dir}/y
+			100644 blob ${empty_obj_hex}	${dir}/z
+			120000 blob ${symlink_hex}	${symlink}
+			EOF
+
+			git update-index --index-info <objs &&
+
+			# Note: the order is important here to exercise the
+			# case where the file at ${dir} has its type changed by
+			# the time Git tries to check out ${dir}/z.
+			#
+			# Also, we use core.precomposeUnicode=false because we
+			# want Git to treat the UTF-8 paths transparently on
+			# Mac OS, matching what is in the index.
+			#
+			git -c core.precomposeUnicode=false checkout-index -f \
+				${dir}/x ${dir}/y ${symlink} ${dir}/z &&
+
+			# Should not create ${dir}/z at ${symlink}/z
+			test_path_is_missing target-dir/z
+
+		)
+	'
+done
+
 test_done

Vulnerable code: # The first argument defines a debug log file that the script write to.
# All remaining arguments define a list of supported protocol
# capabilities ("clean", "smudge", etc).
Fixed code: extern void invalidate_lstat_cache(void);
	if (!ret)
		invalidate_lstat_cache();
int lstat_cache_aware_rmdir(const char *path);
#if !defined(__MINGW32__) && !defined(_MSC_VER)
#define rmdir lstat_cache_aware_rmdir
#endif

	/*
	 * Note: this function is used by the checkout machinery, which also
	 * takes care to properly reset the cache when it performs an operation
	 * that would leave the cache outdated. If this function starts caching
	 * anything else besides FL_DIR, remember to also invalidate the cache
	 * when creating or deleting paths that might be in the cache.
	 */

void invalidate_lstat_cache(void)
{
	reset_lstat_cache(&default_cache);
}

#undef rmdir
int lstat_cache_aware_rmdir(const char *path)
{
	/* Any change in this function must be made also in `mingw_rmdir()` */
	int ret = rmdir(path);

	if (!ret)
		invalidate_lstat_cache();

	return ret;
}
for mode in 'case' 'utf-8'
do
	case "$mode" in
	case)	dir='A' symlink='a' mode_prereq='CASE_INSENSITIVE_FS' ;;
	utf-8)
		dir=$(printf "\141\314\210") symlink=$(printf "\303\244")
		mode_prereq='UTF8_NFD_TO_NFC' ;;
	esac

	test_expect_success PERL,SYMLINKS,$mode_prereq \
	"delayed checkout with $mode-collision don't write to the wrong place" '
		test_config_global filter.delay.process \
			"\"$TEST_ROOT/rot13-filter.pl\" --always-delay delayed.log clean smudge delay" &&
		test_config_global filter.delay.required true &&

		git init $mode-collision &&
		(
			cd $mode-collision &&
			mkdir target-dir &&

			empty_oid=$(printf "" | git hash-object -w --stdin) &&
			symlink_oid=$(printf "%s" "$PWD/target-dir" | git hash-object -w --stdin) &&
			attr_oid=$(echo "$dir/z filter=delay" | git hash-object -w --stdin) &&

			cat >objs <<-EOF &&
			100644 blob $empty_oid	$dir/x
			100644 blob $empty_oid	$dir/y
			100644 blob $empty_oid	$dir/z
			120000 blob $symlink_oid	$symlink
			100644 blob $attr_oid	.gitattributes
			EOF

			git update-index --index-info <objs &&
			git commit -m "test commit"
		) &&

		git clone $mode-collision $mode-collision-cloned &&
		# Make sure z was really delayed
		grep "IN: smudge $dir/z .* \\[DELAYED\\]" $mode-collision-cloned/delayed.log &&

		# Should not create $dir/z at $symlink/z
		test_path_is_missing $mode-collision/target-dir/z
	'
done

# Usage: rot13-filter.pl [--always-delay] <log path> <capabilities>
#
# Log path defines a debug log file that the script writes to. The
# subsequent arguments define a list of supported protocol capabilities
# ("clean", "smudge", etc).
#
# When --always-delay is given all pathnames with the "can-delay" flag
# that don't appear on the list bellow are delayed with a count of 1
# (see more below).

my $always_delay = 0;
if ( $ARGV[0] eq '--always-delay' ) {
	$always_delay = 1;
	shift @ARGV;
}

				} elsif ( !exists $DELAY{$pathname} and $always_delay ) {
					$DELAY{$pathname} = { "requested" => 1, "count" => 1 };
for mode in 'case' 'utf-8'
do
	case "$mode" in
	case)	dir='A' symlink='a' mode_prereq='CASE_INSENSITIVE_FS' ;;
	utf-8)
		dir=$(printf "\141\314\210") symlink=$(printf "\303\244")
		mode_prereq='UTF8_NFD_TO_NFC' ;;
	esac

	test_expect_success SYMLINKS,$mode_prereq \
	"checkout-index with $mode-collision don't write to the wrong place" '
		git init $mode-collision &&
		(
			cd $mode-collision &&
			mkdir target-dir &&

			empty_obj_hex=$(git hash-object -w --stdin </dev/null) &&
			symlink_hex=$(printf "%s" "$PWD/target-dir" | git hash-object -w --stdin) &&

			cat >objs <<-EOF &&
			100644 blob ${empty_obj_hex}	${dir}/x
			100644 blob ${empty_obj_hex}	${dir}/y
			100644 blob ${empty_obj_hex}	${dir}/z
			120000 blob ${symlink_hex}	${symlink}
			EOF

			git update-index --index-info <objs &&

			# Note: the order is important here to exercise the
			# case where the file at ${dir} has its type changed by
			# the time Git tries to check out ${dir}/z.
			#
			# Also, we use core.precomposeUnicode=false because we
			# want Git to treat the UTF-8 paths transparently on
			# Mac OS, matching what is in the index.
			#
			git -c core.precomposeUnicode=false checkout-index -f \
				${dir}/x ${dir}/y ${symlink} ${dir}/z &&

			# Should not create ${dir}/z at ${symlink}/z
			test_path_is_missing target-dir/z

		)
	'
done