fuser icon indicating copy to clipboard operation
fuser copied to clipboard

untangle default permissions and prevent a massive security loophole

Open rarensu opened this issue 4 months ago • 2 comments

Howdy,

I was running the CI tests on my local machine for practice when I encountered a problem that was very, very, very difficult to understand. I was getting allow other failed in CI testing, but when I attempted the exact same test myself, one line at a time on the command line, it passed. It took me about two days to understand why.

There is a serious deficiency in the crate documentation regarding default permissions.

Let's start with the basic facts. There are three documented layers of permissions:

  1. The kernel implements the minimal control paradigm (uid, gid), whose behavior can be switched on and off using MountOption::DefaultPermissions.
  2. The library implements essentially the same minimal concept of permissions (uid, gid), plus an additional concept ("root user is special") and the two concepts can be switched on and off using MountOption::AllowRoot and MountOption::AllowOther. The logic can be found in the the preamble of the dispatch() function.
  3. At the application level, we have the access() method that gives the application the freedom to define an arbitrary concept of permissions, including very custom concepts like "this file is only readable during business hours".

To start with, I feel that the crate inadequately explains the difference between the (1) and (2) permission layers. We can certainly improve on that. But hey, it's good to have these extra layers of security, right? Once the users understand their options, there should be a solution available for every case.

But they were all of them deceived, for another undocumented security feature was made: in the land of Mordor, in the fires of Mount Doom, the dark lord Sauron vibe coded, in secret, the open() function. One, simple, all-powerful key to bypass all three security layers and directly leak the contents of the filesystem to unauthorized third parties.

The default implementation of open() in the crate is to reply ok(0, 0). This is very strange and also, completely undocumented. Why is it replying Ok by default? doesn't that imply all files are always open? Nearly all of the other functions, including access(), reply Err(ENOSYS) (operation not supported), which is perfectly intuitive. So I decided to implement open() -> Err(ENOSYS) and in doing so, unknowingly accepted the Sauron's tainted gift, which slowly began to transform me into a creature of darkness and madness.

Essentially, here is how the hack works.

  1. User turns on AllowRoot mount option and nothing else. Since the user didn't request the Kernel's default permission concept, there is no reason to suspect that the kernel will attempt to manage permissions. It should defer to the library. The user believes security layer (2) from the fuser library will block other users from reaching the filesystem.
  2. The user has no need to implement access() or open(). The user leaves them unimplemented, which in my case, means Err(ENOSYS).
  3. User implements some "dummy directory attributes" with something like 755 in the Permissions field. Because it doesn't matter; that field will never be used for anything, right? The kernel isn't supposed to be enforcing permissions, and the library uses the SessionACL, not the file attributes.
  4. The kernel is curious about some file and hits it with a getattr(), which succeeds, because the library sees uid=0 and the user requested AllowRoot. Now the kernel has a copy of the file attributes. The kernel then hits the file with read() and gets a copy of the file contents. It caches both.
  5. The dark lord Sauron now attempts lookup() and read() on that file. What can the kernel do? access() is not implemented. open() is not implemented. How can the kernel determine what to do with the request? The kernel has only three options: it can either pass the read() request along to the library, (which is what we expect), it can CAST IT INTO THE FIRE, DESTROY IT, or it can whisper "No" and keep the request for itself.
  6. The FUSE kernel then IMPLICITLY enables default_permissions even though the user never requested DefaultPermissions, compares the request to the cached "dummy permissions" it previously obtained by legitimate means, and wrongfully returns a copy of the file contents from the page cache. The dark lord Sauron now rules middle earth.

The worst part is, the application has no idea any of this even happened. It was literally impossible for me to debug. My CI kept failing, because during the CI, the operations are coming in fast enough that the page cache remains alive between operations, thus enabling the hack. But there was no log messages of any kind. Furthermore, when I try to debug by putting the commands in one at at time, the page cache expires and the kernel goes back to behaving correctly, so I was not able to isolate the issue.

At the minimum, we must document the idea that if the user turns on AllowRoot, that is equivalent to also requesting DefaultPermissions, and therefore, the user SHOULD NOT provide "dummy permissions" to the kernel.

I propose three viable fixes to prevent this problem from re-occurring by accident.

  1. ~~The fuser crate library could workaround default permissions by masking all permission fields with 700 when the ACL is set to AllowRoot. This would cause the Kernel to mimic the library's concept of AllowRoot.~~ Upon further reflection, I don't like this idea.
  2. The library could enforce the requirement that the open() function must be properly implemented if AllowRoot is enabled. The presence of an open() function forces the kernel to respect the library's concept of permissions on read() requests. Or maybe it's the access() function that should be required to be implemented?
  3. We call up the linux kernel developers and kindly ask them to refrain from implicitly enabling default permissions.

rarensu avatar Aug 13 '25 18:08 rarensu

Ya, I recall this being kind of hacky, and am definitely open to improving it. Do you know how libfuse handles it? I think I copied this from libfuse, but may be misremembering and just hacked it in myself at some point

cberner avatar Aug 14 '25 02:08 cberner

https://github.com/libfuse/libfuse?tab=readme-ov-file#security-implications

Libfuse doesn't have a fix either. They recommend properly implemented file permissions or disabling attribute caching.

Update: disabling attribute caching works well. It forces the kernel to always do its permission logic using attributes that were obtained using the current request uid, because it can't have saved a attributes obtained using the root uid.

                    attr_valid: if attr_ttl_override {
                        0
                    } else {
                        ttl.as_secs()
                    },

rarensu avatar Aug 14 '25 14:08 rarensu