[PATCH v3 2/4] landlock: Fix handling of disconnected directories
Abhinav Saxena
xandfury at gmail.com
Fri Jul 25 03:54:22 UTC 2025
Hi!
Mickaël Salaün <mic at digikod.net> writes:
> On Thu, Jul 24, 2025 at 04:49:24PM +0200, Günther Noack wrote:
>> On Wed, Jul 23, 2025 at 11:01:42PM +0200, Mickaël Salaün wrote:
>> > On Tue, Jul 22, 2025 at 07:04:02PM +0100, Tingmao Wang wrote:
>> > > On the other hand, I’m still a bit uncertain about the domain check
>> > > semantics. While it would not cause a rename to be allowed if it is
>> > > otherwise not allowed by any rules on or above the mountpoint, this gets a
>> > > bit weird if we have a situation where renames are allowed on the
>> > > mountpoint or everywhere, but not read/writes, however read/writes are
>> > > allowed directly on a file, but the dir containing that file gets
>> > > disconnected so the sandboxed application can’t read or write to it.
>> > > (Maybe someone would set up such a policy where renames are allowed,
>> > > expecting Landlock to always prevent renames where additional permissions
>> > > would be exposed?)
>> > >
>> > > In the above situation, if the file is then moved to a connected
>> > > directory, it will become readable/writable again.
>> >
>> > We can generalize this issue to not only the end file but any component
>> > of the path: disconnected directories. In fact, the main issue is the
>> > potential inconsistency of access checks over time (e.g. between two
>> > renames). This could be exploited to bypass the security checks done
>> > for FS_REFER.
>> >
>> > I see two solutions:
>> >
>> > 1. *Always* walk down to the IS_ROOT directory, and then jump to the
>> > mount point. This makes it possible to have consistent access checks
>> > for renames and open/use. The first downside is that that would
>> > change the current behavior for bind mounts that could get more
>> > access rights (if the policy explicitly sets rights for the hidden
>> > directories). The second downside is that we’ll do more walk.
>> >
>> > 2. Return -EACCES (or -ENOENT) for actions involving disconnected
>> > directories, or renames of disconnected opened files. This second
>> > solution is simpler and safer but completely disables the use of
>> > disconnected directories and the rename of disconnected files for
>> > sandboxed processes.
>> >
>> > It would be much better to be able to handle opened directories as
>> > (object) capabilities, but that is not currently possible because of the
>> > way paths are handled by the VFS and LSM hooks.
>> >
>> > Tingmao, Günther, Jann, what do you think?
>>
>> I have to admit that so far, I still failed to wrap my head around the
>> full patch set and its possible corner cases. I hope I did not
>> misunderstand things all too badly below:
>>
>> As far as I understand the proposed patch, we are “checkpointing” the
>> intermediate results of the path walk at every mount point boundary,
>> and in the case where we run into a disconnected directory in one of
>> the nested mount points, we restore from the intermediate result at
>> the previous mount point directory and skip to the next mount point.
>
> Correct
>
>>
>> Visually speaking, if the layout is this (where “:” denotes a
>> mountpoint boundary between the mountpoints MP1, MP2, MP3):
>>
>> dirfd
>> |
>> : V :
>> : ham <— spam <— eggs <— x.txt
>> : (disconn.) :
>> : :
>> / <— foo <— bar <— baz :
>> : :
>> MP1 MP2 MP3
>>
>> When a process holds a reference to the “spam” directory, which is now
>> disconnected, and invokes openat(dirfd, “eggs/x.txt”, …), then we
>> would:
>>
>> * traverse x.txt
>> * traverse eggs (checkpointing the intermediate result) <-.
>> * traverse spam |
>> * traverse ham |
>> * discover that ham is disconnected: |
>> * restore the intermediate result from “eggs” ———’
>> * continue the walk at foo
>> * end up at the root
>>
>> So effectively, since the results from “spam” and “ham” are discarded,
>> we would traverse only the inodes in the outer and inner mountpoints
>> MP1 and MP3, but effectively return a result that looks like we did
>> not traverse MP2?
>
> We’d still check MP2’s inode, but otherwise yes.
>
I don’t know if it makes sense, but can access rights be cached as part
of the inode security blob? Although I am not sure if the LSM blob would
exist after unlinking.
But if it does, maybe during unlink, keep the cached rights for MP2,
and during openat():
1. Start at disconnected “spam” inode
2. Check spam->i_security->allowed_access <- Cached MP2 rights
3. Continue normal path walk with preserved access context
>>
>> Maybe (likely) I misread the code. :) It’s not clear to me what the
>> thinking behind this is. Also, if there was another directory in
>> between “spam” and “eggs” in MP2, wouldn’t we be missing the access
>> rights attached to this directory?
>
> Yes, we would ignore this access right because we don’t know that the
> path was resolved from spam.
>
>>
>>
>> Regarding the capability approach:
>>
>> I agree that a “capability” approach would be the better solution, but
>> it seems infeasible with the existing LSM hooks at the moment. I
>> would be in favor of it though.
>
> Yes, it would be a new feature with potential important changes.
>
> In the meantime, we still need a fix for disconnected directories, and
> this fix needs to be backported. That’s why the capability approach is
> not part of the two solutions. ;)
>
>>
>> To spell it out a bit more explicitly what that would mean in my mind:
>>
>> When a path is looked up relative to a dirfd, the path walk upwards
>> would terminate at the dirfd and use previously calculated access
>> rights stored in the associated struct file. These access rights
>> would be determined at the time of opening the dirfd, similar to how we
>> are already storing the “truncate” access right today for regular
>> files.
>>
>> (Remark: There might still be corner cases where we have to solve it
>> the hard way, if someone uses “..” together with a dirfd-relative
>> lookup.)
>
> Yep, real capabilities don’t have “..” in their design. On Linux (and
> Landlock), we need to properly handle “..”, which is challenging.
>
>>
>> I also looked at what it would take to change the LSM hooks to pass
>> the directory that the lookup was done relative to, but it seems that
>> this would have to be passed through a bunch of VFS callbacks as well,
>> which seems like a larger change. I would be curious whether that
>> would be deemed an acceptable change.
>>
>> —Günther
>>
>>
>> P.S. Related to relative directory lookups, there is some movement in
>> the BSDs as well to use dirfds as capabilities, by adding a flag to
>> open directories that enforces O_BENEATH on subsequent opens:
>>
>> * <https://undeadly.org/cgi?action=article;sid=20250529080623>
>> * <https://reviews.freebsd.org/D50371>
>>
>> (both found via <https://news.ycombinator.com/item?id=44575361>)
>>
>> If a dirfd had such a flag, that would get rid of the corner case
>> above.
>
> This would be nice but it would not solve the current issue because we
> cannot force all processes to use this flag (which breaks some use
> cases).
>
> FYI, Capsicum is a more complete implementation:
> <https://man.freebsd.org/cgi/man.cgi?query=capsicum&sektion=4>
> See the vfs.lookup_cap_dotdot sysctl too.
Also, my apologies, as this may be tangential to the current
conversation, but since object-based capabilities were mentioned, I had
some design ideas around it while working on the memfd feature [1]. I
don’t know if the design for object-based capabilities has been
internally formalized yet, but since we’re at this juncture, it would
make me glad if any of this is helpful in any way :)
If I understand things correctly, the domain currently applies to ALL
file operations via paths and persists until the process exits.
Therefore, with disconnected directories, once a path component is
unlinked, security policies can be bypassed, as access checks on
previously visible ancestors might get skipped.
Current Landlock Architecture:
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
Process -> Landlock Domain -> Access Decision
{Filesystem Rules, Network Rules, Scope Restrictions}
Path/Port Resolution + Domain Boundary Checks
Enhanced Architecture with Object Capabilities:
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
Process -> Enhanced Landlock Domain -> Access Decision
━━
━━
{Path Rules, Network Rules, (AND) {FD Capabilities}
Scope Restrictions} |
━━━━━━━━━━━━━━━
Per-FD Rights
━━━━━━━━━━━━━━━
Traditional Resolution (calculated)
Unlike SCOPE which provides coarse-grained blocking, object capabilities
provide with the facility to add domain specific fine-grained individual
FD operations. So that we have:
Child Domain = Parent Domain & New Restrictions
= {
path_rules: Parent.path_rules & Child.path_rules,
net_rules: Parent.net_rules & Child.net_rules,
scope: Parent.scope | Child.scope, /* Additive */
fd_caps: path_rules & net_rules & scope & Child.allowed_fd_operations
}
where the Child domain *must* be more restrictive than the parent. Here
is an example:
/* Example */
Parent Domain = {
path_rules: [“/var/www” -> READ_FILE|READ_DIR, “/var/log” -> WRITE_FILE],
net_rules: [“80” -> BIND_TCP, “443” -> BIND_TCP],
scope: [SIGNAL, ABSTRACT_UNIX],
/* Auto-derived FD capabilities */
fd_caps: {
3: READ_FILE, /* /var/www/index.html */
7: READ_DIR, /* /var/www directory */
12: WRITE_FILE, /* /var/log/access.log */
15: BIND_TCP, /* socket bound to port 80 */
20: READ_FILE|READ_DIR /* /var/www/images/ */
}
}
/* Child creates new domain with additional restrictions */
Child.new_restrictions = {
path_rules: ["/var/www" -> READ_FILE only], /* Remove READ_DIR */
net_rules: [], /* Remove all network */
scope: [SIGNAL, ABSTRACT_UNIX, MEMFD_EXEC], /* Add MEMFD restriction */
}
/* Child FD capabilities = Parent & Child restrictions */
Child.fd_caps = {
3: READ_FILE, /* READ_FILE & READ_FILE = READ_FILE */
7: 0, /* READ_DIR & READ_FILE = none (no access) */
12: WRITE_FILE, /* WRITE_FILE unchanged (not restricted) */
15: 0, /* BIND_TCP & none = none (network blocked) */
20: READ_FILE /* (READ_FILE|READ_DIR) & READ_FILE = READ_FILE */
}
API Design: Reusing Existing Flags
――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
/* Extended ruleset - reuse existing flags where possible */
struct landlock_ruleset_attr {
__u64 handled_access_fs; /* Existing: also applies to FDs */
__u64 handled_access_net; /* Existing: also applies to FDs */
__u64 scoped; /* Existing: domain boundaries */
__u64 handled_access_fd; /* NEW: FD-specific operations only */
};
/* New syscalls */
long landlock_set_fd_capability(int fd, __u64 access_rights, __u32 flags);
/* Reuse existing filesystem/network flags for FD operations */
landlock_set_fd_capability(file_fd, LANDLOCK_ACCESS_FS_READ_FILE, 0);
landlock_set_fd_capability(dir_fd, LANDLOCK_ACCESS_FS_READ_DIR, 0);
landlock_set_fd_capability(sock_fd, LANDLOCK_ACCESS_NET_BIND_TCP, 0);
`============'
With object capabilities, we assign access rights to file descriptors
directly, at open/alloc time, eliminating the need for path resolution
during future use.
This solves the core issue because:
• FDs remain valid even when disconnected, and
• Rights are bound to the object rather than its pathname.
Therefore, openat with dirfd should still work.
int dirfd = open(“/tmp/work", O_RDONLY); // Connected
unlink(”/tmp/work"); // Now disconnected
openat(dirfd, “file.txt”, O_RDONLY); // Still works, FD bound
Moreover, no path resolution is needed at a later stage and sandboxed
processes don’t bypass restrictions.
Would love to hear any feedback and thoughts on this.
Best,
Abhinav
[1] - <https://lore.kernel.org/all/20250719-memfd-exec-v1-0-0ef7feba5821@gmail.com/>
More information about the Linux-security-module-archive
mailing list