-
-
Notifications
You must be signed in to change notification settings - Fork 906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
git.util.rmtree can change permissions outside tree on Unix (chmod traverses symlinks) #1738
Comments
Thank you for this, once again, incredibly thorough analysis that seems to leave nothing I can think of to chance. Reading |
Thanks! I'd say I've tried to characterize what is left to chance, in terms of the impact of the bug. But in terms of fixing it without introducing a breaking change, I do think the approach argued for here and merged in #1739 of doing the
I'm flattered to hear you say that. I did use a VM to run it. Ironically, I found that much easier than testing on macOS, even though macOS is far more common. In part this was because it was fairly late in the process that I realized I really should test on macOS, since my understanding of its manpages was not necessarily correct. (It happened to be correct, in this case; I'm still glad I checked.) But in part it was that I have no good way to run macOS, which in some way doesn't play well with VMs where the host runs on non-Apple hardware, or so I recall from the last time I looked into it. I ended up finding a cloud-based way. |
Oh, it's true! GitPython doesn't even run CI on MacOS (yet) - otherwise I would have suggested to just use these machines for testing. What I tend to do is to enable CI on a fork to be able to use it privately outside of a PR. Maybe one day some tests will also be run on MacOS, for completeness, and I'd expect them to just work the way they do on Linux. |
I've opened #1752 to add macOS CI test jobs. |
Overview
On Unix-like systems,
git.util.rmtree
will change permissions outside the tree being removed, under some circumstances. This occurs specifically withgit.util.rmtree
; it is not a flaw inherited fromshutil.rmtree
. It does not require a race condition, and the effect is easy to produce if one has control of the files in a directory thatgit.util.rmtree
will later be used to remove, though this must include control over file permissions (or related metadata) somewhere within that tree. It could happen unintentionally as well as intentionally, but I believe unintentional occurrence is uncommon, at least in uses ofgit.util.rmtree
from within GitPython.This happens because, when
git.util.rmtree
cannot delete a file, it callsos.chmod(path, stat.S_IWUSR)
on the file and tries again. Butos.chmod
dereferences symlinks when called that way on a Unix-like system. The effect is to change permissions on the target, which may be outside the tree. It adds write permissions, and removes some other permissions. Yet, while this seems like a case of CWE-59, I'm somewhat reluctant to consider this a security vulnerability, because it only adds permissions for the file's owner, who is already capable of usingchmod
to add them. But there are some other concerns, detailed below in "Is this a vulnerability?" This bug should not be confused with race conditions affectingrmtree
on some systems that can cause the wrong files to be deleted; this bug is about the wrong files having their permissions changed.I believe this is straightforward to fix, with no breaking changes. Although useful on Windows, on Unix-like systems that
os.chmod
call is unnecessary and, in cases where permissions do need to be changed to allow a file to be deleted, ineffective. So it can be run conditionally, on Windows only. Its ineffectiveness on Unix-like systems is thus a blessing in disguise, as otherwise making it run only on Windows could be a breaking change. I have proposed this fix, and regression tests, in #1739.The cause
On all operating systems,
git.util.rmtree
currently callsshutil.rmtree
with a callback that attempts to change a file's permissions to be writable and retry deletion:GitPython/git/util.py
Lines 208 to 214 in 74cc671
The intent of the
os.chmod
call is to make the file atpath
writable. The problem is that, on Unix-like systems, callingos.chmod
without passingfollow_symlinks=False
only changes the file atpath
when it is not a symbolic link. When the file atpath
is a symbolic link, it is dereferenced, and its (direct or indirect) target has its permissions changed instead.os.chmod
behaves this way on Unix-like systems because, on such systems, unlessfollow_symlinks=False
is passed,os.chmod
uses chmod(2), which follows symlinks:That link is to a Linux manpage, but this holds across most, if not all, Unix-like systems. The macOS chmod(2) manpage is less explicit about this, but as noted in "Steps to reproduce," I have verified this bug on macOS as well as GNU/Linux.
In contrast,
os.chmod
withfollow_symlink=False
, oros.lchmod
, use lchmod(2), and do not deference symlinks. However, they are not available on all systems. For example, Linux has no such system call.This bug does not affect Windows. Although Windows has symbolic links,
os.chmod
does not dereference them on Windows. See "Conceptual examination" below for details.Steps to reproduce
General approach
As detailed below in "Why this
os.chmod
call only helps on Windows", there are various scenarios in whichrmtree
will fail to delete a file on a Unix-like system, but the easiest to produce, which is also probably the most common in practice, is that the user lacks write permissions on the containing directory. If that directory contains a symbolic link to a file outside of it, then the failure to delete the directory will trigger anos.chmod
affecting that out-of-tree file, in the callbackgit.util.rmtree
passes toshutil.rmtree
. So the steps, in brief, are:git.util.rmtree
.chmod -w
on the directory.git.util.rmtree
.Script to reproduce the bug easily
More concretely, I have verified on Ubuntu 22.04.3 LTS and macOS 12.6.9 that the bug can be triggered by creating an executable script
perm.sh
with the contents (also in this gist, for convenience):Then run the script to see how attempting to delete
dir2
withgit.util.rmtree
changes the permissions ofdir1/file
, even thoughdir1/file
is not insidedir2
:git.util.rmtree
still failed with aPermissionError
, because the reason it can't deletedir2/symlink
is due to the permissions ondir2
itself, which it does not modify. But before it retries and fails, it has already changed the permissions of that symbolic link's target,dir1/file
, from 0444 to 0200.Conceptual examination
The above demonstrates the bug in GitPython itself, but I think it is also clarifying to see the behavior of
os.chmod
itself on various systems (or at least, I found this useful myself when investigating the bug).Unix-like systems - affected
On a Unix-like system (as above, I also tested this on Ubuntu 22.04.3 LTS and macOS 12.6.9), run:
The core concept can alternatively be demonstrated, on the same systems, by the chmod(3) command, which also uses chmod(2), by replacing the second part above with:
(One would replace
python3.12
with one's actual Python command, if different. This is not limited to Python 3.12.)Windows - unaffected
In contrast, on Windows 10 (with developer mode enabled), in PowerShell 7:
(That is on a system where Python was installed from the Windows Store. If Python was installed from an .exe file downloaded from python.org, then one must usually replace
python3.12
withpy -3.12
. As on Unix-like systems, one may change the version number as well.)Observe that
file
is still read-only, even after operating onsymlink
withos.chmod
. In contrast--though not shown above--changing"symlink"
to"file"
in the Python command does work, changing the displayed mode from-ar--
to-a---
(and allowingRemove-Item
to delete the file).Is this a vulnerability?
Disclosure considerations (click to expand, if interested)
My interpretation of the security policy is that it does not express a preference against opening a public bug report like this one in this situation, but I would be pleased to receive feedback in this area. The specific factors I considered in deciding to report the bug this way were that (a) it might be a security bug, but (b) this project's security policy does not request strict coordinated vulnerability disclosure, (c) I am unaware of a likely method to exploit it, (d) I have been, perhaps unwisely, developing and testing the fix on a public branch of my fork before thinking deeply about its security implications, so I may have, in effect, already disclosed the issue publicly, (e) I believe my fix and tests are complete, and by reporting publicly I can offer them in a way that is either faster or much more convenient to review than if I disclosed by email and waited to open a PR, (f) I may soon be unavailable to contribute for a few days or weeks, and if I take the time to research exploitability further, the effects of d would be exacerbated and the benefits of e diminished, and (g) the note in CVE-2023-40590 suggests to me that the security policy does not intend to discourage public disclosure that is based on such factors.
Outside of its tests, GitPython's only use of
git.util.rmtree
is to delete submodules. This sounds concerning, since cloning an untrusted repository, including its submodules, and doing git operations in it, without running code in it, should ideally be safe. Such a repository could certainly have arbitrary symbolic links in a submodule that are checked out into its working tree, including symlinks to absolute paths, or relative paths like../..
, that would cause upward traversal. Callingos.chmod
on such symlinks, in the waygit.util.rmtree
does, would change permissions on the target (if owned by the user running the process) to 0200, adding write permissions for the owning user, and removing all other permissions.This is assuming, however, that a failure during
rmtree
occurs. As discussed below, I believe this is hard for an attacker to produce deliberately on any affected system. Without it, the callback, and thus itsos.chmod
call, is never run.For the problem that write permissions are added, it is a major mitigation that they are added only for the owner. But this could nonetheless contribute to a loss of integrity if, for example, a non-robust script is employed that goes by access alone to mutate some files in a directory but not others.
I believe a larger concern is actually the permissions that are removed. In particular, if the untrusted symlink is to a directory outside the working tree, the read permission needed to list the directory and the executable permission needed to traverse into the directory are removed. Therefore, if an attacker who does not already have access to the system but controls the contents of an untrusted repository that is locally cloned with submodules can cause
shutil.rmtree
to fail when called bygit.util.rmtree
, then the attacker can carry out a denial of service attack.But I do not know of a way a malicious submodule author could deliberately bring this about, because:
git.util.rmtree
to fail to perform an operation and call its callback. But Windows is unaffected by this bug. I believe a failure of an operation attempted duringrmtree
is actually rare on Unix-like systems (outside of GitPython's own tests, some of which deliberately produce it). So simply waiting for it to happen may not be realistic.rmtree
would fail, but the dangerous symlink would not be reached. More importantly, git does not track directories themselves, so the file modes it stores do not directly create directories that cannot be traversed.chmod
,chattr
, orchflags
command, then it can easily be done. But running such commands from a malicious source is already unsafe with or without GitPython. It is probably easier for an attacker to fool a user into running them on a file inside a cloned repository than elsewhere, but then the attacker could urge the user to runchmod
directly on the cloned symlink.A secondary concern is that this could lead to denial of service against an application that uses GitPython but intends to expose only some of the capabilities of the user account running it. At least as I understand it, that was the chief concern in CVE-2023-41040 (#1638), which was considered a vulnerability. But in that case, there were specific, identifiable git operations an application could be requested to perform that would trigger an unexpected access to another part of the filesystem and resource exhaustion. In this case, I am unaware of such a condition. However, I admit such a problem might be discovered with further research.
Even if GitPython's use of
git.util.rmtree
to remove submodules is not to be considered vulnerable,git.util.rmtree
is public, being listed in__all__
. So it may be (or have been) a reasonable design choice for an application to make direct use of it, which some applications may be doing in an inadvertently vulnerable way. This would not be limited to situations involving submodules.Why this
os.chmod
call only helps on WindowsOn Windows, attempting to change a file's permissions to be writable and retrying deletion of the file can help, because of how file permissions and deletion interact on Windows: attempting to delete a read-only file will fail on Windows, even if the user could delete the file if it were not read-only. The
os.chmod
call ingit.util.rmtree
appears to have been introduced specifically to handle that situation on Windows.In contrast, on a Unix-like system, it probably can never help. On the one hand, the widespread belief that access to a file's containing directory, and never the file itself, is all that is relevant to the ability to delete a file on a Unix-like system... is actually wrong on some Unix-like systems. There are specific circumstances, on some Unix-like systems, where a user gaining write access to a file they cannot currently delete, with no other changes, would cause the user to be able to delete it. Nonetheless, the
os.chmod
call used ingit.util.rmtree
does not help in those situations either. Roughly speaking, on Unix-like systems:shutil.rmtree
and therefore is not passed to the callbackgit.util.rmtree
passesshutil.rmtree
.chattr
andchflags
. But this is separate from permissions, so adding write permissions to the file wouldn't help./tmp
but not delete other users' files there. But this is where things get tricky. On a minority of Unix-like systems, including some that continue in significant use today such as Solaris (and illumos systems such as OpenIndiana), having write access to the file suffices as an alternative to owning it (see chmod(2), as cited in the "Solaris 11" row of this table). I have verified this behavior on OpenIndiana 2023.10.The reason the
os.chmod
call ingit.util.rmtree
is nonetheless ineffective at allowing such a file to be deleted is that settingS_IWUSR
withos.chmod
on a Unix-like system only confers write permissions to the file's owner. But owning the file is already sufficient to allow deletion when one has write access on the containing sticky directory.I only say "probably" because of the possibility that some system may facilitate expressing strange access restrictions that would make deleting a file one owns contingent on having write access to it on a Unix-like system. I don't think ACLs would express this, but perhaps something like AppArmor could be used to achieve it. If this were done, I don't think whoever did it would expect GitPython or any other software to take special advantage of it, and I would be inclined to think that would still not make the removal of that
os.chmod
call on any Unix-like system a breaking change in GitPython.Alternative solutions
This last section covers why I believe skipping the
os.chmod
call when not running on Windows (as proposed in #1739) is better than the other possible solutions that I am aware of.I believe it would be worthwhile to skip the
os.chmod
call outside Windows even if this symlink-related bug were absent and the only problem with the call outside Windows were its ineffectiveness, because:git.util.rmtree
docstring explains the distinction betweenshutil.rmtree
andgit.util.rmtree
in terms of Windows behavior, theos.chmod
call does not appear to have been intended to have any important effect outside of Windows.However, even if having
git.util.rmtree
attempt to change file permissions on non-Windows systems were to be considered worthwhile, fixing thatos.chmod
call to do so safely would be nontrivial, because:os.chmod
withfollow_symlinks=False
is not supported on all systems. This includes popular Unix-like systems. For example,os.chmod in os.supports_follow_symlinks
evaluates toFalse
on my Ubuntu system (and other Linux-based systems).os.lchmod
function is likewise not present on all systems.pathlib
alternativesPath.chmod
withfollow_symlinks=False
andPath.lchmod
. These are also not available on all currently supported versions of Python.os.chmod
does not supportfollow_symlinks
may not have any atomic way to change permissions without dereferencing a symlink. So checking and then performing the operation risks the file being replaced by a symlink at the last moment. Even if the risk is small, the reward, in this case, is surely smaller.The other alternative that may seem appealing is to attempt to actually solve plausible causes of a
PermissionError
on a Unix-like system, by making the logic more expansive and complicated, changing write permissions on containing directories, adding executable permissions to directories during traversal to reach and delete files in them, unsetting immutable flags/attributes on systems that support those, and so forth. But:git.util.rmtree
doesn't seem to need to do this--as far as I can tell, the only known problems with it failing to delete files in actual GitPython use are on Windows. So based on complexity alone, it might not be worthwhile.git.util.rmtree
is seen to benefit from working around them, it would be difficult to know that doing so would really be an overall improvement.Such logic, if added, might somewhat resemble
TemporaryDirectory._rmtree
, but while I thinkTemporaryDirectory
with its cleanup logic is reasonable to use in GitPython's unit tests, I think any logic along those lines ingit.util.rmtree
might have to differ substantially from it to avoid security-related problems. I cite it not to recommend the technique, but instead as a rough guess at the lower bound of how much more code it might take to do it.The text was updated successfully, but these errors were encountered: