Bug 73878

Summary: get_runtime_dir(strict=False): insecure use of /tmp
Product: PyXDG Reporter: Andrew Starr-Bochicchio <a.starr.b>
Component: PyXDGAssignee: Thomas Kluyver <thomas>
Status: RESOLVED FIXED QA Contact:
Severity: normal    
Priority: medium CC: jwilk
Version: unspecified   
Hardware: Other   
OS: All   
Whiteboard:
i915 platform: i915 features:

Description Andrew Starr-Bochicchio 2014-01-21 14:23:02 UTC
Version: 0.25

xdg.BaseDirectory.get_runtime_dir(strict=False) is prone to symlink attacks. A malicious local user could do the following:

1) Create symlink /tmp/pyxdg-runtime-dir-fallback-victim, pointing to a directory owned by the victim, say /home/victim.

2) Wait until the victim calls get_runtime_dir(strict=False), which succeeds and returns "/tmp/pyxdg-runtime-dir-fallback-victim".

3) Switch the symlink to point to a directory of their choice.

Originally reported in Debian: http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=736247
Comment 1 Thomas Kluyver 2014-01-22 00:09:11 UTC
That's true. Would any attacks remain possible if the code did this:

if e.errno == errno.EEXIST:
    # Already exists - set 700 permissions again.
    import stat
    perms700 = stat.S_IRUSR|stat.S_IWUSR|stat.S_IXUSR
    uid, gid = os.getuid(), os.getgid()
    if os.path.islink(fallback):
        os.lchown(fallback, uid, gid)
        os.lchmod(fallback, perms700)
    os.chown(fallback, uid, gid
    os.chmod(fallback, perms700)

That ensures that the symlink as well as the directory is accessible only to the user who is running this, so it should be impossible for another user to replace it. Setting this on the symlink before the directory prevents pointing it to a different directory after the code thinks the directory is OK.

I should probably also check that the fallback *is* a directory, but I don't think that's a security issue.
Comment 2 Andrew Starr-Bochicchio 2014-01-22 01:11:52 UTC
I wonder if instead of resetting the permissions/owner, it might be better to check them first and try to recreate the directory completely if they are wrong?

Thanks!
Comment 3 Thomas Kluyver 2014-01-22 02:13:47 UTC
I'd be wary of deleting and recreating the directory in case something is legitimately using it but the permissions have got broken. But can you see an attack that that would prevent but resetting the permissions wouldn't?
Comment 4 Jakub Wilk 2014-01-23 19:12:18 UTC
The code in comment 1 is racy. To win the race attacker has to:

1) Create /tmp/pyxdg-runtime-dir-fallback-victim as a regular directory.

2) Between the call to os.path.islink (which returns false) and the call to os.chown replace /tmp/pyxdg-runtime-dir-fallback-victim with a symlink to a victim's. file.

Oh, and os.lchmod() doesn't exist on Linux. :-)
Comment 5 Thomas Kluyver 2014-01-23 20:17:23 UTC
I'm not sure it would be practical to exploit a hole like that, but I think we can avoid it by using a stat call to collect all the info we need in one go (so long as stat itself is atomic).

How about this:

if e.errno == errno.EEXIST:
    st = os.lstat(fallback)
    if not stat.S_ISDIR(st.st_mode):
        # If it's not a directory, remake it
        # This catches it being a symlink
        os.unlink(fallback)
        os.mkdir(fallback, 0o700)

    else:
        uid = os.getuid()
        if st.st_uid != uid:
            # gid doesn't matter, because we set user-only access
            os.chown(fallback, uid, -1)
        if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
            os.chmod(stat.S_IRWXU)

This would mean that you couldn't intentionally use a symlink for this directory, but since it's created specifically by and for PyXDG, that doesn't seem a big issue.
Comment 6 Thomas Kluyver 2014-01-23 20:27:39 UTC
No, that's still susceptible to the same race condition, isn't it. I'll think some more about it.
Comment 7 Thomas Kluyver 2014-01-23 21:33:36 UTC
OK, trying again, and replacing more of the function:

create = False
try:
    st = os.lstat(fallback)
except FileNotFoundError:
    create = True
else:
    if not stat.S_ISDIR(st.st_mode):
        os.unlink(fallback)
        create = True
    elif (st.st_uid != uid) \
      or (st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
        os.rmdir(fallback)
        create = True


if create:
    os.mkdir(fallback, 0o700)

This is subject to denial of service attacks, if something exists we can't remove, or if there is no file when lstat is called, but one is made before mkdir is called. I don't see any way around that given that we want the directory to be a stable location.

Is there any way to make the code above succeed but do something unexpected?
Comment 8 Jakub Wilk 2014-01-23 22:01:50 UTC
From the security perspective, the code in comment 7 looks good to me (except for the already-mentioned possiblity of DoS).

The code will have to be adapted to work with older Python versions: there's no FileNotFoundError in Python < 3.3.

Minor style nitpick: "(stat.S_IRWXG | stat.S_IRWXO)" makes my head hurt; I'd write "0o077" instead.

If you want to avoid DoS, here's a way to do it:
1) Create temporary directory with mkdtemp(). Store the directory name somewhere in $HOME, say $XDG_DATA_HOME/pyxdg/runtime-dir.
2) Next time the function is called, use the directory stored in $XDG_DATA_HOME/pyxdg/runtime-dir if possible (i.e. when it passes the same security checks as in comment 7). If it's not possible, goto 1.
Comment 9 Thomas Kluyver 2014-01-23 23:19:22 UTC
Fix committed: https://github.com/takluyver/pyxdg/commit/bd999c1c3fe7ee5f30ede2cf704cf03e400347b4

I'm inclined to keep the predictable location of the fallback directory for now, rather than preventing DoS attacks, but I'm open to more discussion of that.

Now I need to try to get into my fd.o repo. Hopefully I've got that SSH key around somewhere...

Use of freedesktop.org services, including Bugzilla, is subject to our Code of Conduct. How we collect and use information is described in our Privacy Policy.