diff --git a/connection.go b/connection.go index ebb86b73..17b1a437 100644 --- a/connection.go +++ b/connection.go @@ -165,6 +165,11 @@ func (c *Connection) Init() error { c.protocol = initOp.Kernel } + if c.debugLogger != nil { + c.debugLogger.Printf("Kernel protocol version major: %v, minor: %v\n", initOp.Kernel.Major, initOp.Kernel.Minor) + c.debugLogger.Printf("Protocol version major: %v, minor: %v\n", c.protocol.Major, c.protocol.Minor) + } + cacheSymlinks := initOp.Flags&fusekernel.InitCacheSymlinks > 0 noOpenSupport := initOp.Flags&fusekernel.InitNoOpenSupport > 0 noOpendirSupport := initOp.Flags&fusekernel.InitNoOpendirSupport > 0 @@ -229,6 +234,14 @@ func (c *Connection) Init() error { } } + if c.cfg.EnableHandleKillpriv { + initOp.Flags |= fusekernel.InitHandleKillpriv + } + + if c.cfg.EnableHandleKillprivV2 { + initOp.Flags |= fusekernel.InitHandleKillprivV2 + } + return c.Reply(ctx, nil) } diff --git a/conversions.go b/conversions.go index a7c5a4fd..1451a050 100644 --- a/conversions.go +++ b/conversions.go @@ -120,6 +120,10 @@ func convertInMessage( to.Handle = &t } + if valid.KillSuidgid() { + to.KillSuidgid = true + } + case fusekernel.OpForget: type input fusekernel.ForgetIn in := (*input)(inMsg.Consume(unsafe.Sizeof(input{}))) @@ -236,7 +240,7 @@ func convertInMessage( } name = name[:i] - o = &fuseops.CreateFileOp{ + createOp := &fuseops.CreateFileOp{ Parent: fuseops.InodeID(inMsg.Header().Nodeid), Name: string(name), Mode: ConvertFileMode(in.Mode), @@ -248,6 +252,12 @@ func convertInMessage( OpenFlags: fusekernel.OpenFlags(in.Flags), } + if fusekernel.OpenRequestFlags(in.OpenFlags)&fusekernel.OpenKillSuidgid != 0 { + createOp.KillSuidgid = true + } + + o = createOp + case fusekernel.OpSymlink: // The message is "newName\0target\0". names := inMsg.ConsumeBytes(inMsg.Len()) @@ -358,7 +368,7 @@ func convertInMessage( return nil, errors.New("Corrupt OpOpen") } - o = &fuseops.OpenFileOp{ + openOp := &fuseops.OpenFileOp{ Inode: fuseops.InodeID(inMsg.Header().Nodeid), OpenFlags: fusekernel.OpenFlags(in.Flags), OpContext: fuseops.OpContext{ @@ -368,6 +378,12 @@ func convertInMessage( }, } + if fusekernel.OpenRequestFlags(in.OpenFlags)&fusekernel.OpenKillSuidgid != 0 { + openOp.KillSuidgid = true + } + + o = openOp + case fusekernel.OpOpendir: o = &fuseops.OpenDirOp{ Inode: fuseops.InodeID(inMsg.Header().Nodeid), @@ -503,7 +519,7 @@ func convertInMessage( return nil, errors.New("Corrupt OpWrite") } - o = &fuseops.WriteFileOp{ + writeOp := &fuseops.WriteFileOp{ Inode: fuseops.InodeID(inMsg.Header().Nodeid), Handle: fuseops.HandleID(in.Fh), Data: buf, @@ -515,6 +531,12 @@ func convertInMessage( }, } + if fusekernel.WriteFlags(in.WriteFlags)&fusekernel.WriteKillSuidgid != 0 { + writeOp.KillSuidgid = true + } + + o = writeOp + case fusekernel.OpFsync, fusekernel.OpFsyncdir: type input fusekernel.FsyncIn in := (*input)(inMsg.Consume(unsafe.Sizeof(input{}))) diff --git a/fuseops/ops.go b/fuseops/ops.go index 83e1e775..5d315198 100644 --- a/fuseops/ops.go +++ b/fuseops/ops.go @@ -174,6 +174,10 @@ type SetInodeAttributesOp struct { Atime *time.Time Mtime *time.Time + // When HANDLE_KILLPRIV_V2 is enabled, this indicates whether the kernel + // requests the filesystem to clear setuid/setgid bits. + KillSuidgid bool + // Set by the file system: the new attributes for the inode, and the time at // which they should expire. See notes on // ChildInodeEntry.AttributesExpiration for more. @@ -340,6 +344,10 @@ type CreateFileOp struct { Name string Mode os.FileMode + // When HANDLE_KILLPRIV_V2 is enabled, this indicates whether the kernel + // requests the filesystem to clear setuid/setgid bits when creating the file. + KillSuidgid bool + // Set by the file system: information about the inode that was created. // // The lookup count for the inode is implicitly incremented. See notes on @@ -689,6 +697,10 @@ type OpenFileOp struct { // to the FUSE daemon. OpenFlags fusekernel.OpenFlags + // When HANDLE_KILLPRIV_V2 is enabled, this indicates whether the kernel + // requests the filesystem to clear setuid/setgid bits when opening the file. + KillSuidgid bool + OpContext OpContext } @@ -795,7 +807,12 @@ type WriteFileOp struct { // be written, except on error (https://tinyurl.com/yuruk5tx). This appears // to be because it uses file mmapping machinery // (https://tinyurl.com/avxy3dvm) to write a page at a time. - Data []byte + Data []byte + + // When HANDLE_KILLPRIV_V2 is enabled, this indicates whether the kernel + // requests the filesystem to clear setuid/setgid bits during this write. + KillSuidgid bool + OpContext OpContext // If set, this function will be invoked after the operation response has been diff --git a/internal/fusekernel/fuse_kernel.go b/internal/fusekernel/fuse_kernel.go index 8c47b524..ef441985 100644 --- a/internal/fusekernel/fuse_kernel.go +++ b/internal/fusekernel/fuse_kernel.go @@ -103,9 +103,10 @@ const ( SetattrHandle SetattrValid = 1 << 6 // Linux only(?) - SetattrAtimeNow SetattrValid = 1 << 7 - SetattrMtimeNow SetattrValid = 1 << 8 - SetattrLockOwner SetattrValid = 1 << 9 // http://www.mail-archive.com/git-commits-head@vger.kernel.org/msg27852.html + SetattrAtimeNow SetattrValid = 1 << 7 + SetattrMtimeNow SetattrValid = 1 << 8 + SetattrLockOwner SetattrValid = 1 << 9 // http://www.mail-archive.com/git-commits-head@vger.kernel.org/msg27852.html + SetattrKillSuidgid SetattrValid = 1 << 11 // Clear setuid/setgid bits (used with HANDLE_KILLPRIV_V2) // OS X only SetattrCrtime SetattrValid = 1 << 28 @@ -114,20 +115,21 @@ const ( SetattrFlags SetattrValid = 1 << 31 ) -func (fl SetattrValid) Mode() bool { return fl&SetattrMode != 0 } -func (fl SetattrValid) Uid() bool { return fl&SetattrUid != 0 } -func (fl SetattrValid) Gid() bool { return fl&SetattrGid != 0 } -func (fl SetattrValid) Size() bool { return fl&SetattrSize != 0 } -func (fl SetattrValid) Atime() bool { return fl&SetattrAtime != 0 } -func (fl SetattrValid) Mtime() bool { return fl&SetattrMtime != 0 } -func (fl SetattrValid) Handle() bool { return fl&SetattrHandle != 0 } -func (fl SetattrValid) AtimeNow() bool { return fl&SetattrAtimeNow != 0 } -func (fl SetattrValid) MtimeNow() bool { return fl&SetattrMtimeNow != 0 } -func (fl SetattrValid) LockOwner() bool { return fl&SetattrLockOwner != 0 } -func (fl SetattrValid) Crtime() bool { return fl&SetattrCrtime != 0 } -func (fl SetattrValid) Chgtime() bool { return fl&SetattrChgtime != 0 } -func (fl SetattrValid) Bkuptime() bool { return fl&SetattrBkuptime != 0 } -func (fl SetattrValid) Flags() bool { return fl&SetattrFlags != 0 } +func (fl SetattrValid) Mode() bool { return fl&SetattrMode != 0 } +func (fl SetattrValid) Uid() bool { return fl&SetattrUid != 0 } +func (fl SetattrValid) Gid() bool { return fl&SetattrGid != 0 } +func (fl SetattrValid) Size() bool { return fl&SetattrSize != 0 } +func (fl SetattrValid) Atime() bool { return fl&SetattrAtime != 0 } +func (fl SetattrValid) Mtime() bool { return fl&SetattrMtime != 0 } +func (fl SetattrValid) Handle() bool { return fl&SetattrHandle != 0 } +func (fl SetattrValid) AtimeNow() bool { return fl&SetattrAtimeNow != 0 } +func (fl SetattrValid) MtimeNow() bool { return fl&SetattrMtimeNow != 0 } +func (fl SetattrValid) LockOwner() bool { return fl&SetattrLockOwner != 0 } +func (fl SetattrValid) KillSuidgid() bool { return fl&SetattrKillSuidgid != 0 } +func (fl SetattrValid) Crtime() bool { return fl&SetattrCrtime != 0 } +func (fl SetattrValid) Chgtime() bool { return fl&SetattrChgtime != 0 } +func (fl SetattrValid) Bkuptime() bool { return fl&SetattrBkuptime != 0 } +func (fl SetattrValid) Flags() bool { return fl&SetattrFlags != 0 } func (fl SetattrValid) String() string { return flagString(uint32(fl), setattrValidNames) @@ -144,6 +146,7 @@ var setattrValidNames = []flagName{ {uint32(SetattrAtimeNow), "SetattrAtimeNow"}, {uint32(SetattrMtimeNow), "SetattrMtimeNow"}, {uint32(SetattrLockOwner), "SetattrLockOwner"}, + {uint32(SetattrKillSuidgid), "SetattrKillSuidgid"}, {uint32(SetattrCrtime), "SetattrCrtime"}, {uint32(SetattrChgtime), "SetattrChgtime"}, {uint32(SetattrBkuptime), "SetattrBkuptime"}, @@ -274,9 +277,11 @@ const ( InitWritebackCache InitFlags = 1 << 16 InitNoOpenSupport InitFlags = 1 << 17 InitParallelDirOps InitFlags = 1 << 18 + InitHandleKillpriv InitFlags = 1 << 19 InitMaxPages InitFlags = 1 << 22 InitCacheSymlinks InitFlags = 1 << 23 InitNoOpendirSupport InitFlags = 1 << 24 + InitHandleKillprivV2 InitFlags = 1 << 28 InitCaseSensitive InitFlags = 1 << 29 // OS X only InitVolRename InitFlags = 1 << 30 // OS X only @@ -310,6 +315,8 @@ var initFlagNames = []flagName{ {uint32(InitNoOpenSupport), "InitNoOpenSupport"}, {uint32(InitCacheSymlinks), "InitCacheSymlinks"}, {uint32(InitNoOpendirSupport), "InitNoOpendirSupport"}, + {uint32(InitHandleKillpriv), "InitHandleKillpriv"}, + {uint32(InitHandleKillprivV2), "InitHandleKillprivV2"}, {uint32(InitCaseSensitive), "InitCaseSensitive"}, {uint32(InitVolRename), "InitVolRename"}, @@ -542,8 +549,8 @@ type setattrInCommon struct { } type OpenIn struct { - Flags uint32 - Unused uint32 + Flags uint32 // User-space O_RDONLY/O_WRONLY/etc flags + OpenFlags uint32 // Kernel FUSE_OPEN_* flags (e.g., FUSE_OPEN_KILL_SUIDGID) } type OpenOut struct { @@ -553,10 +560,29 @@ type OpenOut struct { } type CreateIn struct { - Flags uint32 - Mode uint32 - Umask uint32 - padding uint32 + Flags uint32 // User-space O_RDONLY/O_WRONLY/etc flags + Mode uint32 + Umask uint32 + OpenFlags uint32 // Kernel FUSE_OPEN_* flags (e.g., FUSE_OPEN_KILL_SUIDGID) +} + +// OpenRequestFlags are kernel-level flags sent from the kernel to the filesystem +// in OpenIn.OpenFlags or CreateIn.OpenFlags (not to be confused with the Flags +// field which contains user-space O_RDONLY/O_WRONLY/etc flags, or OpenResponseFlags +// which are returned by the filesystem in OpenOut). +type OpenRequestFlags uint32 + +const ( + // Clear setuid/setgid bits when opening file (used with HANDLE_KILLPRIV_V2) + OpenKillSuidgid OpenRequestFlags = 1 << 0 +) + +var openRequestFlagNames = []flagName{ + {uint32(OpenKillSuidgid), "OpenKillSuidgid"}, +} + +func (fl OpenRequestFlags) String() string { + return flagString(uint32(fl), openRequestFlagNames) } func CreateInSize(p Protocol) uintptr { @@ -645,14 +671,15 @@ type WriteOut struct { type WriteFlags uint32 const ( - WriteCache WriteFlags = 1 << 0 - // LockOwner field is valid. - WriteLockOwner WriteFlags = 1 << 1 + WriteCache WriteFlags = 1 << 0 + WriteLockOwner WriteFlags = 1 << 1 // LockOwner field is valid + WriteKillSuidgid WriteFlags = 1 << 2 // Clear setuid/setgid bits (used with HANDLE_KILLPRIV_V2) ) var writeFlagNames = []flagName{ {uint32(WriteCache), "WriteCache"}, {uint32(WriteLockOwner), "WriteLockOwner"}, + {uint32(WriteKillSuidgid), "WriteKillSuidgid"}, } func (fl WriteFlags) String() string { diff --git a/internal/fusekernel/killpriv_test.go b/internal/fusekernel/killpriv_test.go new file mode 100644 index 00000000..882d8fe8 --- /dev/null +++ b/internal/fusekernel/killpriv_test.go @@ -0,0 +1,240 @@ +package fusekernel + +import "testing" + +// TestKillPrivFlagValues verifies that our flag values match libfuse +// These values are from https://github.com/libfuse/libfuse/blob/master/include/fuse_kernel.h +func TestKillPrivFlagValues(t *testing.T) { + tests := []struct { + name string + flag uint32 + expected uint32 + }{ + // Init flags + {"InitHandleKillpriv", uint32(InitHandleKillpriv), 1 << 19}, + {"InitHandleKillprivV2", uint32(InitHandleKillprivV2), 1 << 28}, + + // Setattr flag + {"SetattrKillSuidgid", uint32(SetattrKillSuidgid), 1 << 11}, + + // Write flag + {"WriteKillSuidgid", uint32(WriteKillSuidgid), 1 << 2}, + + // Open/Create flag + {"OpenKillSuidgid", uint32(OpenKillSuidgid), 1 << 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.flag != tt.expected { + t.Errorf("%s = %d (0x%x), want %d (0x%x)", + tt.name, tt.flag, tt.flag, tt.expected, tt.expected) + } + }) + } +} + +// TestSetattrValidKillSuidgid tests the KillSuidgid helper method +func TestSetattrValidKillSuidgid(t *testing.T) { + tests := []struct { + name string + valid SetattrValid + want bool + }{ + { + name: "KillSuidgid set", + valid: SetattrKillSuidgid, + want: true, + }, + { + name: "KillSuidgid with other flags", + valid: SetattrKillSuidgid | SetattrMode | SetattrSize, + want: true, + }, + { + name: "KillSuidgid not set", + valid: SetattrMode | SetattrSize, + want: false, + }, + { + name: "No flags", + valid: 0, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.valid.KillSuidgid(); got != tt.want { + t.Errorf("SetattrValid.KillSuidgid() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestWriteFlagsKillSuidgid tests WriteFlags values +func TestWriteFlagsKillSuidgid(t *testing.T) { + tests := []struct { + name string + flags WriteFlags + want bool + }{ + { + name: "KillSuidgid set", + flags: WriteKillSuidgid, + want: true, + }, + { + name: "KillSuidgid with other flags", + flags: WriteKillSuidgid | WriteCache | WriteLockOwner, + want: true, + }, + { + name: "KillSuidgid not set", + flags: WriteCache | WriteLockOwner, + want: false, + }, + { + name: "No flags", + flags: 0, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (tt.flags & WriteKillSuidgid) != 0 + if got != tt.want { + t.Errorf("WriteFlags & WriteKillSuidgid = %v, want %v", got, tt.want) + } + }) + } +} + +// TestOpenRequestFlagsKillSuidgid tests OpenRequestFlags values +func TestOpenRequestFlagsKillSuidgid(t *testing.T) { + tests := []struct { + name string + flags OpenRequestFlags + want bool + }{ + { + name: "KillSuidgid set", + flags: OpenKillSuidgid, + want: true, + }, + { + name: "No flags", + flags: 0, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (tt.flags & OpenKillSuidgid) != 0 + if got != tt.want { + t.Errorf("OpenRequestFlags & OpenKillSuidgid = %v, want %v", got, tt.want) + } + }) + } +} + +// TestInitFlagsKillPriv tests InitFlags for KILLPRIV support +func TestInitFlagsKillPriv(t *testing.T) { + tests := []struct { + name string + flags InitFlags + hasV1 bool + hasV2 bool + }{ + { + name: "V1 only", + flags: InitHandleKillpriv, + hasV1: true, + hasV2: false, + }, + { + name: "V2 only", + flags: InitHandleKillprivV2, + hasV1: false, + hasV2: true, + }, + { + name: "Both V1 and V2", + flags: InitHandleKillpriv | InitHandleKillprivV2, + hasV1: true, + hasV2: true, + }, + { + name: "Neither", + flags: InitAsyncRead | InitFileOps, + hasV1: false, + hasV2: false, + }, + { + name: "No flags", + flags: 0, + hasV1: false, + hasV2: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotV1 := (tt.flags & InitHandleKillpriv) != 0 + gotV2 := (tt.flags & InitHandleKillprivV2) != 0 + + if gotV1 != tt.hasV1 { + t.Errorf("InitHandleKillpriv flag: got %v, want %v", gotV1, tt.hasV1) + } + if gotV2 != tt.hasV2 { + t.Errorf("InitHandleKillprivV2 flag: got %v, want %v", gotV2, tt.hasV2) + } + }) + } +} + +// TestKillPrivFlagStrings tests that flag names are properly registered +func TestKillPrivFlagStrings(t *testing.T) { + tests := []struct { + name string + stringer interface{ String() string } + wantSubstr string + }{ + { + name: "InitHandleKillpriv", + stringer: InitHandleKillpriv, + wantSubstr: "InitHandleKillpriv", + }, + { + name: "InitHandleKillprivV2", + stringer: InitHandleKillprivV2, + wantSubstr: "InitHandleKillprivV2", + }, + { + name: "SetattrKillSuidgid", + stringer: SetattrKillSuidgid, + wantSubstr: "SetattrKillSuidgid", + }, + { + name: "WriteKillSuidgid", + stringer: WriteKillSuidgid, + wantSubstr: "WriteKillSuidgid", + }, + { + name: "OpenKillSuidgid", + stringer: OpenKillSuidgid, + wantSubstr: "OpenKillSuidgid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.stringer.String() + if got != tt.wantSubstr { + t.Errorf("%s.String() = %q, want %q", tt.name, got, tt.wantSubstr) + } + }) + } +} diff --git a/mount_config.go b/mount_config.go index f95895ad..ec72ea5a 100644 --- a/mount_config.go +++ b/mount_config.go @@ -241,6 +241,37 @@ type MountConfig struct { // to always provide ReadFileOp.Dst. If the file system populates ReadFileOp.Data, // that data will be used for a vectored read, irrespective of this flag's value. UseVectoredRead bool + + // When enabled, the filesystem is responsible for clearing setuid/setgid bits + // when a file is written, truncated, or its owner is changed. + EnableHandleKillpriv bool + + // V2 of FUSE_HANDLE_KILLPRIV that provides Linux VFS-consistent behavior. + // The filesystem is responsible for clearing setuid/setgid bits and security + // capabilities when a file is written, truncated, or its owner is changed. + // + // Behavior (consistent with Linux VFS): + // - security.capability: always cleared on chown/write/truncate + // - setuid: always cleared on chown; on write/truncate only if caller lacks CAP_FSETID + // - setgid: always cleared on chown; on write/truncate only if caller lacks CAP_FSETID + // AND file has group execute permission + // + // When enabled, the kernel sets KillSuidgid flags on operations: + // - WriteFileOp: when caller lacks CAP_FSETID and file has privilege bits + // - SetInodeAttributesOp: always set (filesystem must check OpContext.Uid + // and file mode to decide whether to clear privilege bits) + // - CreateFileOp/OpenFileOp: when opening for write a file with setuid/setgid + // bits, or creating a file in a directory with setgid bit + // + // Note: KILLPRIV_V2 relies on WriteFileOp reaching the server to clear + // privilege bits. When writeback caching is enabled, writes are buffered in + // the kernel page cache and WriteFileOp may not be sent immediately (or at all + // until flush/fsync). Therefore, using EnableHandleKillprivV2 with writeback + // caching enabled is not recommended and may result in privilege bits not being + // cleared when they should be. + // + // Ref: https://github.com/torvalds/linux/commit/63f9909ff602082597849f684655e93336c50b11 + EnableHandleKillprivV2 bool } type FUSEImpl uint8 diff --git a/samples/killprivfs/killpriv_fs.go b/samples/killprivfs/killpriv_fs.go new file mode 100644 index 00000000..9ba528f0 --- /dev/null +++ b/samples/killprivfs/killpriv_fs.go @@ -0,0 +1,383 @@ +package killprivfs + +import ( + "context" + "os" + "sync" + "time" + + "github.com/jacobsa/fuse" + "github.com/jacobsa/fuse/fuseops" + "github.com/jacobsa/fuse/fuseutil" +) + +// KillPrivFS is a simple filesystem that tracks when KillSuidgid flags are received. +type KillPrivFS struct { + fuseutil.NotImplementedFileSystem + + mu sync.Mutex + createWithKillSuidgid bool + openWithKillSuidgid bool + writeWithKillSuidgid bool + setattrWithKillSuidgid bool + inodes map[uint64]*inodeInfo + nextInode uint64 +} + +type inodeInfo struct { + mode os.FileMode + parent uint64 + name string + children map[string]uint64 + data []byte // Per-inode data storage +} + +func NewKillPrivFS() *KillPrivFS { + fs := &KillPrivFS{ + inodes: make(map[uint64]*inodeInfo), + nextInode: 2, // Start after root (inode 1) + } + fs.inodes[1] = &inodeInfo{ + mode: os.ModeDir | 0755, + children: make(map[string]uint64), + } + return fs +} + +func (fs *KillPrivFS) GetFlags() (create, open, write, setattr bool) { + fs.mu.Lock() + defer fs.mu.Unlock() + return fs.createWithKillSuidgid, fs.openWithKillSuidgid, fs.writeWithKillSuidgid, fs.setattrWithKillSuidgid +} + +func (fs *KillPrivFS) ResetFlags() { + fs.mu.Lock() + defer fs.mu.Unlock() + fs.createWithKillSuidgid = false + fs.openWithKillSuidgid = false + fs.writeWithKillSuidgid = false + fs.setattrWithKillSuidgid = false +} + +// AddTestFile bypasses normal FUSE operations to create test files with specific mode bits. +func (fs *KillPrivFS) AddTestFile(name string, mode os.FileMode) fuseops.InodeID { + fs.mu.Lock() + defer fs.mu.Unlock() + + inodeID := fs.nextInode + fs.nextInode++ + + fs.inodes[inodeID] = &inodeInfo{ + mode: mode, + parent: 1, + name: name, + } + + fs.inodes[1].children[name] = inodeID + return fuseops.InodeID(inodeID) +} + +// AddTestDir bypasses normal FUSE operations to create test directories with specific mode bits. +func (fs *KillPrivFS) AddTestDir(name string, mode os.FileMode) fuseops.InodeID { + fs.mu.Lock() + defer fs.mu.Unlock() + + inodeID := fs.nextInode + fs.nextInode++ + + fs.inodes[inodeID] = &inodeInfo{ + mode: mode | os.ModeDir, + parent: 1, + name: name, + children: make(map[string]uint64), + } + + fs.inodes[1].children[name] = inodeID + return fuseops.InodeID(inodeID) +} + +func (fs *KillPrivFS) StatFS( + ctx context.Context, + op *fuseops.StatFSOp) error { + return nil +} + +func (fs *KillPrivFS) OpenDir( + ctx context.Context, + op *fuseops.OpenDirOp) error { + op.Handle = 1 + return nil +} + +func (fs *KillPrivFS) ReadDir( + ctx context.Context, + op *fuseops.ReadDirOp) error { + // Return empty directory listing for simplicity + return nil +} + +func (fs *KillPrivFS) GetInodeAttributes( + ctx context.Context, + op *fuseops.GetInodeAttributesOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + info, ok := fs.inodes[uint64(op.Inode)] + if !ok { + return fuse.ENOENT + } + + size := uint64(0) + if info.mode.IsRegular() { + size = uint64(len(info.data)) + } + + now := time.Now() + op.Attributes = fuseops.InodeAttributes{ + Mode: info.mode, + Nlink: 1, + Size: size, + Uid: 0, + Gid: 0, + Atime: now, + Mtime: now, + Ctime: now, + } + return nil +} + +func (fs *KillPrivFS) LookUpInode( + ctx context.Context, + op *fuseops.LookUpInodeOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + parentInfo, ok := fs.inodes[uint64(op.Parent)] + if !ok { + return fuse.ENOENT + } + + childInode, ok := parentInfo.children[op.Name] + if !ok { + return fuse.ENOENT + } + + childInfo := fs.inodes[childInode] + size := uint64(0) + if childInfo.mode.IsRegular() { + size = uint64(len(childInfo.data)) + } + + now := time.Now() + op.Entry.Child = fuseops.InodeID(childInode) + op.Entry.Attributes = fuseops.InodeAttributes{ + Mode: childInfo.mode, + Nlink: 1, + Size: size, + Uid: 0, + Gid: 0, + Atime: now, + Mtime: now, + Ctime: now, + } + return nil +} + +func (fs *KillPrivFS) MkDir( + ctx context.Context, + op *fuseops.MkDirOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + parentInfo, ok := fs.inodes[uint64(op.Parent)] + if !ok { + return fuse.ENOENT + } + + newInode := fs.nextInode + fs.nextInode++ + + fs.inodes[newInode] = &inodeInfo{ + mode: op.Mode | os.ModeDir, + parent: uint64(op.Parent), + name: op.Name, + children: make(map[string]uint64), + } + + parentInfo.children[op.Name] = newInode + + now := time.Now() + op.Entry.Child = fuseops.InodeID(newInode) + op.Entry.Attributes = fuseops.InodeAttributes{ + Mode: op.Mode | os.ModeDir, + Nlink: 1, + Uid: 0, + Gid: 0, + Atime: now, + Mtime: now, + Ctime: now, + } + return nil +} + +func (fs *KillPrivFS) CreateFile( + ctx context.Context, + op *fuseops.CreateFileOp) error { + fs.mu.Lock() + if op.KillSuidgid { + fs.createWithKillSuidgid = true + } + + parentInfo, ok := fs.inodes[uint64(op.Parent)] + if !ok { + fs.mu.Unlock() + return fuse.ENOENT + } + + newInode := fs.nextInode + fs.nextInode++ + + // Ensure mode has at least user read/write permissions + mode := op.Mode + if mode&0600 == 0 { + mode |= 0600 + } + + fs.inodes[newInode] = &inodeInfo{ + mode: mode, + parent: uint64(op.Parent), + name: op.Name, + } + + parentInfo.children[op.Name] = newInode + fs.mu.Unlock() + + now := time.Now() + op.Entry.Child = fuseops.InodeID(newInode) + op.Entry.Attributes = fuseops.InodeAttributes{ + Mode: mode, + Nlink: 1, + Size: 0, + Uid: 0, + Gid: 0, + Atime: now, + Mtime: now, + Ctime: now, + } + op.Handle = 1 + return nil +} + +func (fs *KillPrivFS) OpenFile( + ctx context.Context, + op *fuseops.OpenFileOp) error { + fs.mu.Lock() + if op.KillSuidgid { + fs.openWithKillSuidgid = true + } + fs.mu.Unlock() + + op.Handle = 1 + return nil +} + +func (fs *KillPrivFS) WriteFile( + ctx context.Context, + op *fuseops.WriteFileOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + if op.KillSuidgid { + fs.writeWithKillSuidgid = true + } + + info, ok := fs.inodes[uint64(op.Inode)] + if !ok { + return fuse.ENOENT + } + + if op.Offset+int64(len(op.Data)) > int64(len(info.data)) { + newSize := op.Offset + int64(len(op.Data)) + newData := make([]byte, newSize) + copy(newData, info.data) + info.data = newData + } + copy(info.data[op.Offset:], op.Data) + + return nil +} + +func (fs *KillPrivFS) SetInodeAttributes( + ctx context.Context, + op *fuseops.SetInodeAttributesOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + if op.KillSuidgid { + fs.setattrWithKillSuidgid = true + } + + info, ok := fs.inodes[uint64(op.Inode)] + if !ok { + return fuse.ENOENT + } + + if op.Mode != nil { + info.mode = *op.Mode + } + + if op.Size != nil { + if *op.Size < uint64(len(info.data)) { + info.data = info.data[:*op.Size] + } else if *op.Size > uint64(len(info.data)) { + newData := make([]byte, *op.Size) + copy(newData, info.data) + info.data = newData + } + } + + size := uint64(0) + if info.mode.IsRegular() { + size = uint64(len(info.data)) + } + + now := time.Now() + op.Attributes = fuseops.InodeAttributes{ + Mode: info.mode, + Nlink: 1, + Size: size, + Uid: 0, + Gid: 0, + Atime: now, + Mtime: now, + Ctime: now, + } + return nil +} + +func (fs *KillPrivFS) ReadFile( + ctx context.Context, + op *fuseops.ReadFileOp) error { + fs.mu.Lock() + defer fs.mu.Unlock() + + info, ok := fs.inodes[uint64(op.Inode)] + if !ok { + return fuse.ENOENT + } + + if op.Offset >= int64(len(info.data)) { + op.BytesRead = 0 + return nil + } + + n := copy(op.Dst, info.data[op.Offset:]) + op.BytesRead = n + return nil +} + +func (fs *KillPrivFS) ReleaseFileHandle( + ctx context.Context, + op *fuseops.ReleaseFileHandleOp) error { + return nil +} diff --git a/samples/killprivfs/killpriv_fs_test.go b/samples/killprivfs/killpriv_fs_test.go new file mode 100644 index 00000000..8f4fab28 --- /dev/null +++ b/samples/killprivfs/killpriv_fs_test.go @@ -0,0 +1,311 @@ +// Package killprivfs_test verifies FUSE HANDLE_KILLPRIV_V2 support. +// +// These tests verify that the kernel sets KillSuidgid flags on FUSE operations +// when appropriate. The tests create real setuid files and use privilege +// dropping to trigger the kernel behavior. +// +// Note: Actual behavior depends on kernel version (feature introduced in +// Linux 5.12), whether the filesystem advertises FUSE_HANDLE_KILLPRIV_V2, +// and user capabilities (CAP_FSETID). +package killprivfs_test + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "strconv" + "strings" + "syscall" + "testing" + + "github.com/jacobsa/fuse/fuseutil" + "github.com/jacobsa/fuse/samples" + "github.com/jacobsa/fuse/samples/killprivfs" + . "github.com/jacobsa/oglematchers" + . "github.com/jacobsa/ogletest" +) + +func TestKillPrivFS(t *testing.T) { RunTests(t) } + +// kernelSupportsKillprivV2 returns whether the kernel is >= 5.12 (when HANDLE_KILLPRIV_V2 was added). +func kernelSupportsKillprivV2() bool { + var uname syscall.Utsname + if err := syscall.Uname(&uname); err != nil { + return false + } + + // Convert release string to Go string (it's a [65]int8) + releaseBytes := make([]byte, 0, 65) + for _, b := range uname.Release { + if b == 0 { + break + } + releaseBytes = append(releaseBytes, byte(b)) + } + release := string(releaseBytes) + + // Parse version numbers (e.g., "5.12.0-generic" -> major=5, minor=12) + parts := strings.Split(release, ".") + if len(parts) < 2 { + return false + } + + major, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + + // HANDLE_KILLPRIV_V2 was added in Linux 5.12 + return major > 5 || (major == 5 && minor >= 12) +} + +//////////////////////////////////////////////////////////////////////// +// Boilerplate +//////////////////////////////////////////////////////////////////////// + +type KillPrivFSTest struct { + samples.SampleTest + fs *killprivfs.KillPrivFS +} + +func init() { RegisterTestSuite(&KillPrivFSTest{}) } + +func skipIfNotRoot(testName string) bool { + if syscall.Getuid() != 0 { + fmt.Printf("Skipping %s: requires root\n", testName) + return true + } + return false +} + +func skipIfKernelTooOld(testName string) bool { + if !kernelSupportsKillprivV2() { + fmt.Printf("Skipping %s: requires Linux kernel >= 5.12 for HANDLE_KILLPRIV_V2 support\n", testName) + return true + } + return false +} + +func runAsNobody(command string) error { + cmd := exec.Command("su", "-s", "/bin/sh", "nobody", "-c", command) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("command failed: %s", string(output)) + } + return nil +} + +func (t *KillPrivFSTest) SetUp(ti *TestInfo) { + t.fs = killprivfs.NewKillPrivFS() + t.Server = fuseutil.NewFileSystemServer(t.fs) + t.MountConfig.EnableHandleKillprivV2 = true + + // IMPORTANT: DisableWritebackCaching must be true for KILLPRIV_V2 to work correctly. + // With writeback caching enabled, the kernel buffers writes in the page cache and + // KillSuidgid flags don't reach the filesystem until much later (if at all). + t.MountConfig.DisableWritebackCaching = true + + // Allow other users to access the filesystem (required for privilege dropping tests) + if t.MountConfig.Options == nil { + t.MountConfig.Options = make(map[string]string) + } + t.MountConfig.Options["allow_other"] = "" + + t.SampleTest.SetUp(ti) + + err := os.Chmod(t.Dir, 0755) + AssertEq(nil, err, "Failed to chmod mount point") + + t.fs.ResetFlags() +} + +//////////////////////////////////////////////////////////////////////// +// Tests +//////////////////////////////////////////////////////////////////////// + +func (t *KillPrivFSTest) TestMountWithKillPrivV2() { + ExpectThat(t.Dir, Not(Equals(""))) +} + +func (t *KillPrivFSTest) TestCreateFileInSetgidDir() { + if skipIfNotRoot("TestCreateFileInSetgidDir") || skipIfKernelTooOld("TestCreateFileInSetgidDir") { + return + } + + // Shell > redirection uses O_CREAT|O_TRUNC (without O_EXCL), which triggers + // the kernel to set KillSuidgid when creating in a setgid directory + t.fs.AddTestDir("setgid_dir", 02777) + filePath := path.Join(t.Dir, "setgid_dir", "newfile.txt") + + err := runAsNobody("echo data > " + filePath) + AssertEq(nil, err) + + createFlag, _, _, _ := t.fs.GetFlags() + ExpectTrue(createFlag, "CreateKillSuidgid flag should be set when creating file in setgid directory with O_TRUNC") +} + +func (t *KillPrivFSTest) TestOpenWithTruncateSetuidFile() { + if skipIfNotRoot("TestOpenWithTruncateSetuidFile") || skipIfKernelTooOld("TestOpenWithTruncateSetuidFile") { + return + } + + // By default (without EnableAtomicTrunc), the kernel splits O_TRUNC into + // OpenFile + SetInodeAttributes(size=0). KillSuidgid is set on SetInodeAttributes. + t.fs.AddTestFile("setuid_open_test.txt", 04666) + filePath := path.Join(t.Dir, "setuid_open_test.txt") + + err := runAsNobody("echo data > " + filePath) + AssertEq(nil, err) + + _, _, _, setattrFlag := t.fs.GetFlags() + ExpectTrue(setattrFlag, "SetattrKillSuidgid flag should be set when truncating setuid file via O_TRUNC") +} + +func (t *KillPrivFSTest) TestWriteToSetuidFile() { + if skipIfNotRoot("TestWriteToSetuidFile") || skipIfKernelTooOld("TestWriteToSetuidFile") { + return + } + + t.fs.AddTestFile("setuid_test.txt", 04666) + filePath := path.Join(t.Dir, "setuid_test.txt") + + err := runAsNobody("echo 'test data' >> " + filePath) + AssertEq(nil, err) + + _, _, writeFlag, _ := t.fs.GetFlags() + ExpectTrue(writeFlag, "WriteKillSuidgid flag should be set when non-root writes to setuid file") +} + +func (t *KillPrivFSTest) TestSetuidBitWithChown() { + if skipIfNotRoot("TestSetuidBitWithChown") || skipIfKernelTooOld("TestSetuidBitWithChown") { + return + } + + // The kernel always sets KillSuidgid=true for setattr operations. + // The filesystem must check OpContext.Uid and file mode to decide if bits should clear. + t.fs.AddTestFile("chown_test.txt", 04755) + filePath := path.Join(t.Dir, "chown_test.txt") + + err := os.Chown(filePath, 1000, 1000) + AssertEq(nil, err) + + _, _, _, setattrFlag := t.fs.GetFlags() + ExpectTrue(setattrFlag, "SetattrKillSuidgid flag should be set for setattr (chown) operation") +} + +func (t *KillPrivFSTest) TestTruncateSetuidFile() { + if skipIfNotRoot("TestTruncateSetuidFile") || skipIfKernelTooOld("TestTruncateSetuidFile") { + return + } + + t.fs.AddTestFile("truncate_test.txt", 04666) + filePath := path.Join(t.Dir, "truncate_test.txt") + + err := runAsNobody("truncate -s 5 " + filePath) + AssertEq(nil, err) + + _, _, _, setattrFlag := t.fs.GetFlags() + ExpectTrue(setattrFlag, "SetattrKillSuidgid flag should be set when non-root truncates setuid file") +} + +func (t *KillPrivFSTest) TestNoKillSuidgidFlagsOnNormalOperations() { + createFlag, openFlag, writeFlag, setattrFlag := t.fs.GetFlags() + ExpectFalse(createFlag, "CreateKillSuidgid should be false initially") + ExpectFalse(openFlag, "OpenKillSuidgid should be false initially") + ExpectFalse(writeFlag, "WriteKillSuidgid should be false initially") + ExpectFalse(setattrFlag, "SetattrKillSuidgid should be false initially") +} + +func (t *KillPrivFSTest) TestChownNormalFile() { + if skipIfNotRoot("TestChownNormalFile") || skipIfKernelTooOld("TestChownNormalFile") { + return + } + + // The kernel sets KillSuidgid=true even on normal files (without privilege bits). + // The filesystem must check the file mode to decide if any action is needed. + filePath := path.Join(t.Dir, "normal_chown.txt") + + err := ioutil.WriteFile(filePath, []byte("test"), 0644) + AssertEq(nil, err) + + err = os.Chown(filePath, 1000, 1000) + AssertEq(nil, err) + + _, _, _, setattrFlag := t.fs.GetFlags() + ExpectTrue(setattrFlag, "SetattrKillSuidgid flag should be set for setattr (chown) operation") +} + +func (t *KillPrivFSTest) TestRootWriteToSetuidFile() { + if skipIfNotRoot("TestRootWriteToSetuidFile") { + return + } + + t.fs.AddTestFile("setuid_root_write.txt", 04755) + filePath := path.Join(t.Dir, "setuid_root_write.txt") + + f, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644) + AssertEq(nil, err) + _, err = f.Write([]byte("root write")) + AssertEq(nil, err) + f.Close() + + _, _, writeFlag, _ := t.fs.GetFlags() + ExpectFalse(writeFlag, "WriteKillSuidgid flag should NOT be set when root (with CAP_FSETID) writes to setuid file") +} + +//////////////////////////////////////////////////////////////////////// +// Atomic Trunc Tests +//////////////////////////////////////////////////////////////////////// + +type KillPrivFSAtomicTruncTest struct { + samples.SampleTest + fs *killprivfs.KillPrivFS +} + +func init() { RegisterTestSuite(&KillPrivFSAtomicTruncTest{}) } + +func (t *KillPrivFSAtomicTruncTest) SetUp(ti *TestInfo) { + t.fs = killprivfs.NewKillPrivFS() + t.Server = fuseutil.NewFileSystemServer(t.fs) + t.MountConfig.EnableHandleKillprivV2 = true + t.MountConfig.DisableWritebackCaching = true + + // EnableAtomicTrunc makes O_TRUNC send a single OpenFile operation with + // O_TRUNC flag set, instead of splitting into OpenFile + SetInodeAttributes. + t.MountConfig.EnableAtomicTrunc = true + + if t.MountConfig.Options == nil { + t.MountConfig.Options = make(map[string]string) + } + t.MountConfig.Options["allow_other"] = "" + + t.SampleTest.SetUp(ti) + + err := os.Chmod(t.Dir, 0755) + AssertEq(nil, err, "Failed to chmod mount point") + + t.fs.ResetFlags() +} + +func (t *KillPrivFSAtomicTruncTest) TestOpenWithTruncateSetuidFile_AtomicTrunc() { + if skipIfNotRoot("TestOpenWithTruncateSetuidFile_AtomicTrunc") || skipIfKernelTooOld("TestOpenWithTruncateSetuidFile_AtomicTrunc") { + return + } + + // With EnableAtomicTrunc, O_TRUNC is sent in a single OpenFile operation. + // KillSuidgid should be set on OpenFile, not SetInodeAttributes. + t.fs.AddTestFile("setuid_atomic_trunc.txt", 04666) + filePath := path.Join(t.Dir, "setuid_atomic_trunc.txt") + + err := runAsNobody("echo data > " + filePath) + AssertEq(nil, err) + + _, openFlag, _, _ := t.fs.GetFlags() + ExpectTrue(openFlag, "OpenKillSuidgid flag should be set when truncating setuid file with EnableAtomicTrunc") +}