diff --git a/pkg/sentry/kernel/BUILD b/pkg/sentry/kernel/BUILD index 20610f04a0..a35e838952 100644 --- a/pkg/sentry/kernel/BUILD +++ b/pkg/sentry/kernel/BUILD @@ -263,6 +263,7 @@ go_library( "cgroup_mutex.go", "cgroup_v2_mutex.go", "context.go", + "execve_sha256_cache.go", "fd_table.go", "fd_table_mutex.go", "fd_table_refs.go", @@ -439,6 +440,7 @@ go_test( name = "kernel_test", size = "small", srcs = [ + "execve_sha256_cache_test.go", "fd_table_test.go", "table_test.go", "task_test.go", diff --git a/pkg/sentry/kernel/execve_sha256_cache.go b/pkg/sentry/kernel/execve_sha256_cache.go new file mode 100644 index 0000000000..d2d5793c32 --- /dev/null +++ b/pkg/sentry/kernel/execve_sha256_cache.go @@ -0,0 +1,210 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kernel + +import ( + "container/list" + "crypto/sha256" + "io" + "sync" + + "gvisor.dev/gvisor/pkg/abi/linux" + "gvisor.dev/gvisor/pkg/log" + "gvisor.dev/gvisor/pkg/sentry/seccheck" + "gvisor.dev/gvisor/pkg/sentry/vfs" + "gvisor.dev/gvisor/pkg/usermem" +) + +const defaultMaxExecveSha256CacheEntries = 512 + +type execveSha256Key struct { + mountID uint64 + ino uint64 + size uint64 + mtimeSec int64 + mtimeNsec uint32 +} + +type execveSha256Entry struct { + key execveSha256Key + hash []byte +} + +type execveSha256Cache struct { + mu sync.Mutex + maxEntries int + entries map[execveSha256Key]*list.Element + lru *list.List +} + +var execveSha256CacheObj = execveSha256Cache{ + maxEntries: defaultMaxExecveSha256CacheEntries, + entries: make(map[execveSha256Key]*list.Element), + lru: list.New(), +} + +// Register seccheck.SessionOptionHandler in init() during early binary startup so that whenever +// seccheck.Create or seccheck.Delete runs across any trace session, it invokes this registered +// callback to adjust the kernel cache capacity (`SetMaxExecveSha256CacheEntries`). This inverted +// callback design avoids circular imports between `seccheck` and `kernel`. +func init() { + seccheck.SessionOptionHandler = func(conf *seccheck.SessionConfig) { + if val, ok := conf.Options["max_execve_sha256_cache_entries"]; ok { + if cnt, ok := val.(float64); ok { + SetMaxExecveSha256CacheEntries(int(cnt)) + } else if cnt, ok := val.(int); ok { + SetMaxExecveSha256CacheEntries(cnt) + } + } else { + SetMaxExecveSha256CacheEntries(defaultMaxExecveSha256CacheEntries) + } + } +} + +// SetMaxExecveSha256CacheEntries sets the maximum capacity of the execve SHA-256 cache. +// Setting it to <= 0 disables the cache and evicts existing entries. +func SetMaxExecveSha256CacheEntries(maxEntries int) { + execveSha256CacheObj.setMaxCacheEntries(maxEntries) +} + +// MaxExecveSha256CacheEntries returns the current maximum capacity of the execve SHA-256 cache. +func MaxExecveSha256CacheEntries() int { + return execveSha256CacheObj.maxCacheEntries() +} + +func (c *execveSha256Cache) maxCacheEntries() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.maxEntries +} + +func (c *execveSha256Cache) setMaxCacheEntries(maxEntries int) { + c.mu.Lock() + defer c.mu.Unlock() + c.maxEntries = maxEntries + for c.maxEntries >= 0 && c.lru.Len() > c.maxEntries { + back := c.lru.Back() + if back != nil { + c.lru.Remove(back) + entry := back.Value.(*execveSha256Entry) + delete(c.entries, entry.key) + } + } +} + +func (c *execveSha256Cache) lookup(key execveSha256Key) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.maxEntries <= 0 { + return nil, false + } + + if elem, ok := c.entries[key]; ok { + c.lru.MoveToFront(elem) + entry := elem.Value.(*execveSha256Entry) + return append([]byte(nil), entry.hash...), true + } + return nil, false +} + +func (c *execveSha256Cache) add(key execveSha256Key, hash []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.maxEntries <= 0 { + return + } + + hashCopy := append([]byte(nil), hash...) + if elem, ok := c.entries[key]; ok { + entry := elem.Value.(*execveSha256Entry) + entry.hash = hashCopy + c.lru.MoveToFront(elem) + return + } + + if c.lru.Len() >= c.maxEntries { + back := c.lru.Back() + if back != nil { + c.lru.Remove(back) + entry := back.Value.(*execveSha256Entry) + delete(c.entries, entry.key) + } + } + + entry := &execveSha256Entry{ + key: key, + hash: hashCopy, + } + elem := c.lru.PushFront(entry) + c.entries[key] = elem +} + +// resolveBinarySha256 returns the SHA-256 hash of the given executable with caching. +func resolveBinarySha256(t *Task, executable *vfs.FileDescription) []byte { + statOpts := vfs.StatOptions{ + Mask: linux.STATX_INO | linux.STATX_SIZE | linux.STATX_MTIME, + } + if stat, err := executable.Stat(t, statOpts); err == nil { + if stat.Mask&(linux.STATX_INO|linux.STATX_SIZE|linux.STATX_MTIME) == (linux.STATX_INO | linux.STATX_SIZE | linux.STATX_MTIME) { + mountID := uint64(0) + if mnt := executable.Mount(); mnt != nil { + mountID = mnt.ID + } + key := execveSha256Key{ + mountID: mountID, + ino: stat.Ino, + size: stat.Size, + mtimeSec: stat.Mtime.Sec, + mtimeNsec: stat.Mtime.Nsec, + } + if hash, ok := execveSha256CacheObj.lookup(key); ok { + return hash + } + hash := computeBinarySha256(t, executable) + if hash != nil { + execveSha256CacheObj.add(key, hash) + } + return hash + } + } + // If retrieving file metadata fails or incomplete attributes are returned, + // compute and return the SHA-256 hash without caching since we cannot + // construct a reliable cache invalidation key. + return computeBinarySha256(t, executable) +} + +func computeBinarySha256(t *Task, executable *vfs.FileDescription) []byte { + hash := sha256.New() + buf := make([]byte, 1024*1024) // Read 1MB at a time. + dest := usermem.BytesIOSequence(buf) + offset := int64(0) + + for { + if read, err := executable.PRead(t, dest, offset, vfs.ReadOptions{}); err == nil { + hash.Write(buf[0:read]) + offset += read + + } else if err == io.EOF { + hash.Write(buf[0:read]) + return hash.Sum(nil) + + } else { + log.Warningf("Failed to read executable for SHA-256 hash: %v", err) + return nil + } + } +} diff --git a/pkg/sentry/kernel/execve_sha256_cache_test.go b/pkg/sentry/kernel/execve_sha256_cache_test.go new file mode 100644 index 0000000000..8761366d5f --- /dev/null +++ b/pkg/sentry/kernel/execve_sha256_cache_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 The gVisor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kernel + +import ( + "bytes" + "container/list" + "testing" +) + +func TestExecveSha256Cache(t *testing.T) { + cache := &execveSha256Cache{ + maxEntries: defaultMaxExecveSha256CacheEntries, + entries: make(map[execveSha256Key]*list.Element), + lru: list.New(), + } + + key1 := execveSha256Key{mountID: 1, ino: 100, size: 1024, mtimeSec: 10, mtimeNsec: 5} + hash1 := []byte("0123456789abcdef0123456789abcdef") + + // 1. Initial get should miss. + if _, ok := cache.lookup(key1); ok { + t.Fatalf("expected cache miss on key1") + } + + // 2. Add and get should hit. + cache.add(key1, hash1) + got, ok := cache.lookup(key1) + if !ok || !bytes.Equal(got, hash1) { + t.Fatalf("expected hit with %s, got %s, ok: %v", hash1, got, ok) + } + + // 3. Verify returned slice is a copy. + got[0] = 'X' + got2, _ := cache.lookup(key1) + if bytes.Equal(got2, got) { + t.Fatalf("cache returned mutable reference") + } + + // 4. Different mtime should miss. + key1Modified := execveSha256Key{mountID: 1, ino: 100, size: 1024, mtimeSec: 11, mtimeNsec: 5} + if _, ok := cache.lookup(key1Modified); ok { + t.Fatalf("expected cache miss on key1Modified") + } + + // 5. Eviction test. + for i := 0; i < defaultMaxExecveSha256CacheEntries+10; i++ { + k := execveSha256Key{mountID: 2, ino: uint64(i), size: 100, mtimeSec: 1, mtimeNsec: 0} + cache.add(k, []byte("dummy_hash_slice_thirty_two_bt")) + } + + if len(cache.entries) > defaultMaxExecveSha256CacheEntries { + t.Fatalf("cache entries exceeded max size: %d > %d", len(cache.entries), defaultMaxExecveSha256CacheEntries) + } + if cache.lru.Len() > defaultMaxExecveSha256CacheEntries { + t.Fatalf("cache lru length exceeded max size: %d > %d", cache.lru.Len(), defaultMaxExecveSha256CacheEntries) + } + // Since key1 was added first and then 522 items were added, key1 should be evicted. + if _, ok := cache.lookup(key1); ok { + t.Fatalf("expected key1 to be evicted") + } +} + +func TestExecveSha256CacheConfigurable(t *testing.T) { + orig := MaxExecveSha256CacheEntries() + defer SetMaxExecveSha256CacheEntries(orig) + + // 1. Set to smaller size and verify capacity resizing + eviction. + SetMaxExecveSha256CacheEntries(3) + if got := MaxExecveSha256CacheEntries(); got != 3 { + t.Fatalf("MaxExecveSha256CacheEntries, want: 3, got: %d", got) + } + + for i := 0; i < 5; i++ { + k := execveSha256Key{mountID: 3, ino: uint64(i), size: 50, mtimeSec: 1, mtimeNsec: 0} + execveSha256CacheObj.add(k, []byte("dummy_hash_slice_thirty_two_bt")) + } + if len(execveSha256CacheObj.entries) > 3 || execveSha256CacheObj.lru.Len() > 3 { + t.Fatalf("cache exceeded configured maxEntries 3: entries=%d lru=%d", len(execveSha256CacheObj.entries), execveSha256CacheObj.lru.Len()) + } + + // 2. Shrink capacity to 1 dynamically while populated, verify excess is evicted instantly. + SetMaxExecveSha256CacheEntries(1) + if len(execveSha256CacheObj.entries) != 1 || execveSha256CacheObj.lru.Len() != 1 { + t.Fatalf("cache not shrunk to 1 when reconfigured: entries=%d lru=%d", len(execveSha256CacheObj.entries), execveSha256CacheObj.lru.Len()) + } + + // 3. Set to 0 (disabled), verify all elements cleared and additions skipped. + SetMaxExecveSha256CacheEntries(0) + if len(execveSha256CacheObj.entries) != 0 || execveSha256CacheObj.lru.Len() != 0 { + t.Fatalf("cache not cleared on disabled SetMaxExecveSha256CacheEntries(0)") + } + + k0 := execveSha256Key{mountID: 3, ino: 999, size: 50, mtimeSec: 1, mtimeNsec: 0} + execveSha256CacheObj.add(k0, []byte("dummy_hash_slice_thirty_two_bt")) + if _, ok := execveSha256CacheObj.lookup(k0); ok { + t.Fatalf("expected cache miss when maxEntries <= 0") + } +} diff --git a/pkg/sentry/kernel/task_exec.go b/pkg/sentry/kernel/task_exec.go index 23b0801296..05a6d94fa2 100644 --- a/pkg/sentry/kernel/task_exec.go +++ b/pkg/sentry/kernel/task_exec.go @@ -65,13 +65,9 @@ package kernel // """ import ( - "crypto/sha256" - "io" - "gvisor.dev/gvisor/pkg/abi/linux" "gvisor.dev/gvisor/pkg/cleanup" "gvisor.dev/gvisor/pkg/errors/linuxerr" - "gvisor.dev/gvisor/pkg/log" overlay "gvisor.dev/gvisor/pkg/sentry/fsimpl/overlay" "gvisor.dev/gvisor/pkg/sentry/kernel/auth" "gvisor.dev/gvisor/pkg/sentry/loader" @@ -79,7 +75,6 @@ import ( "gvisor.dev/gvisor/pkg/sentry/seccheck" "gvisor.dev/gvisor/pkg/sentry/vfs" "gvisor.dev/gvisor/pkg/syserr" - "gvisor.dev/gvisor/pkg/usermem" pb "gvisor.dev/gvisor/pkg/sentry/seccheck/points/points_go_proto" ) @@ -499,26 +494,7 @@ func getExecveSeccheckInfo(t *Task, argv, env []string, executable *vfs.FileDesc } if fields.Local.Contains(seccheck.FieldSentryExecveBinarySha256) { - hash := sha256.New() - buf := make([]byte, 1024*1024) // Read 1MB at a time. - dest := usermem.BytesIOSequence(buf) - offset := int64(0) - - for { - if read, err := executable.PRead(t, dest, offset, vfs.ReadOptions{}); err == nil { - hash.Write(buf[0:read]) - offset += read - - } else if err == io.EOF { - hash.Write(buf[0:read]) - info.BinarySha256 = hash.Sum(nil) - break - - } else { - log.Warningf("Failed to read executable for SHA-256 hash: %v", err) - break - } - } + info.BinarySha256 = resolveBinarySha256(t, executable) } } diff --git a/pkg/sentry/seccheck/config.go b/pkg/sentry/seccheck/config.go index 411f4569f9..7e36048a9f 100644 --- a/pkg/sentry/seccheck/config.go +++ b/pkg/sentry/seccheck/config.go @@ -39,6 +39,9 @@ var sessionCounter = metric.MustCreateNewUint64Metric("/trace/sessions_created", Description: "Counts the number of trace sessions created.", }) +// SessionOptionHandler is invoked whenever a session configuration is created or deleted. +var SessionOptionHandler func(conf *SessionConfig) + // SessionConfig describes a new session configuration. A session consists of a // set of points to be enabled and sinks where the points are sent to. type SessionConfig struct { @@ -55,6 +58,8 @@ type SessionConfig struct { IgnoreMissing bool `json:"ignore_missing,omitempty"` // Sinks are the sinks that will process the points enabled above. Sinks []SinkConfig `json:"sinks,omitempty"` + // Options holds session-level configuration options. + Options map[string]any `json:"options,omitempty"` } // PointConfig describes a point to be enabled in a given session. @@ -144,6 +149,9 @@ func Create(conf *SessionConfig, force bool) error { sessions[conf.Name] = state sessionCounter.Increment() + if SessionOptionHandler != nil { + SessionOptionHandler(conf) + } return nil } @@ -194,6 +202,11 @@ func deleteLocked(name string) error { session.clearSink() delete(sessions, name) + // Invoke SessionOptionHandler with an options-empty SessionConfig so dependent packages + // (e.g. kernel cache sizing) can cleanly reset session-scoped options to defaults upon teardown. + if SessionOptionHandler != nil { + SessionOptionHandler(&SessionConfig{Name: name}) + } return nil }