Skip to content

Commit 6812f6d

Browse files
authored
privileged-logs: add NoFollow transport + fix directory permission check
Add end-to-end support for requesting a symlink-rejecting ("no-follow") open through the privileged-logs client/module RPC: - common.OpenFileRequest gets a NoFollow bool field. - client.Open/OpenPrivileged gain OpenNoFollow/OpenPrivilegedNoFollow counterparts. On Linux, OpenNoFollow calls common.OpenPathWithoutSymlinks directly instead of os.Open; OpenPrivilegedNoFollow sets NoFollow on the wire request. On non-Linux, OpenNoFollow falls back to a plain open (no error) since symlink rejection is only meaningful for paths discovered via /proc/<pid>/fd, which is Linux-only. - module.handler dispatches to a new validateAndOpenNoFollow when the request's NoFollow flag is set, which skips filepath.EvalSymlinks (the caller guarantees the path is already canonical) and goes straight to common.OpenPathWithoutSymlinks. Also fixes a permission bug in common.OpenPathWithoutSymlinks, found via Codex review: directory components were opened with O_RDONLY|O_DIRECTORY, which requires *read* permission on every directory component. That's stricter than the *search* (execute) permission a plain os.Open(path) needs, and would incorrectly reject files sitting under directories that are traversable but not listable (e.g. mode 0711). The existing module-side caller (root-running system-probe) never hit this, since root bypasses the extra permission check - it only becomes reachable with this change's new unprivileged client-side caller. Switched to O_PATH for directory-component opens, which only requires search permission, matching os.Open's semantics. Note: a regression test for the permission bug specifically would need a non-owner UID (root-only, or os/user + a helper process), which isn't added here - flagging as a possible follow-up rather than skipping silently. No caller uses OpenNoFollow/OpenPrivilegedNoFollow yet - checkFileReadable and the file tailer still call the plain Open/OpenLogFile in this PR, so the symlink-swap protection isn't active end-to-end here. That's intentional: this PR is scoped to the transport capability itself; the next two PRs in the stack add the LogsConfig.NoFollow field + tailer wiring, then flip process_log discovery/tailing over to the no-follow calls. Part of a stack towards DSCVR-475; split out of PR #51746 for easier review. Depends on the "extract OpenPathWithoutSymlinks into common" refactor. Environment: Datadog workspace Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Vincent Whitchurch <vincent.whitchurch@datadoghq.com>
1 parent 334668f commit 6812f6d

8 files changed

Lines changed: 232 additions & 34 deletions

File tree

pkg/privileged-logs/client/open.go

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,32 @@ package client
1111
import (
1212
"errors"
1313
"os"
14+
"syscall"
1415

1516
"bytes"
1617
"encoding/json"
1718
"fmt"
1819
"net"
1920
"net/http"
20-
"syscall"
2121

2222
pkgconfigsetup "github.com/DataDog/datadog-agent/pkg/config/setup"
2323
"github.com/DataDog/datadog-agent/pkg/privileged-logs/common"
2424
"github.com/DataDog/datadog-agent/pkg/util/log"
2525
)
2626

27-
// OpenPrivileged opens a file in system-probe and returns the file descriptor
28-
// This function uses a custom HTTP client that can handle file descriptor transfer
27+
// OpenPrivileged opens a file in system-probe and returns the file descriptor.
28+
// This function uses a custom HTTP client that can handle file descriptor transfer.
2929
func OpenPrivileged(socketPath string, filePath string) (*os.File, error) {
30+
return openPrivileged(socketPath, filePath, false)
31+
}
32+
33+
// OpenPrivilegedNoFollow opens a file in system-probe without following symbolic
34+
// links in any path component.
35+
func OpenPrivilegedNoFollow(socketPath string, filePath string) (*os.File, error) {
36+
return openPrivileged(socketPath, filePath, true)
37+
}
38+
39+
func openPrivileged(socketPath string, filePath string, noFollow bool) (*os.File, error) {
3040
// Create a new connection instead of reusing the shared connection from
3141
// pkg/system-probe/api/client/client.go, since the connection is hijacked
3242
// from the control of the HTTP server library on the server side. It also
@@ -39,7 +49,8 @@ func OpenPrivileged(socketPath string, filePath string) (*os.File, error) {
3949
defer conn.Close()
4050

4151
req := common.OpenFileRequest{
42-
Path: filePath,
52+
Path: filePath,
53+
NoFollow: noFollow,
4354
}
4455

4556
reqBody, err := json.Marshal(req)
@@ -111,7 +122,7 @@ func OpenPrivileged(socketPath string, filePath string) (*os.File, error) {
111122
return nil, errors.New("no file descriptor received")
112123
}
113124

114-
func maybeOpenPrivileged(path string, originalError error) (*os.File, error) {
125+
func maybeOpenPrivileged(path string, originalError error, noFollow bool) (*os.File, error) {
115126
enabled := pkgconfigsetup.SystemProbe().GetBool("privileged_logs.enabled")
116127
if !enabled {
117128
return nil, originalError
@@ -120,7 +131,7 @@ func maybeOpenPrivileged(path string, originalError error) (*os.File, error) {
120131
log.Debugf("Permission denied, opening file with system-probe: %v", path)
121132

122133
socketPath := pkgconfigsetup.SystemProbe().GetString("system_probe_config.sysprobe_socket")
123-
file, spErr := OpenPrivileged(socketPath, path)
134+
file, spErr := openPrivileged(socketPath, path, noFollow)
124135
log.Tracef("Opened file with system-probe: %v, err: %v", path, spErr)
125136
if spErr != nil {
126137
return nil, fmt.Errorf("failed to open file with system-probe: %w, original error: %w", spErr, originalError)
@@ -129,20 +140,34 @@ func maybeOpenPrivileged(path string, originalError error) (*os.File, error) {
129140
return file, nil
130141
}
131142

132-
// Open attempts to open a file, and if it fails due to permissions, it opens
133-
// the file using system-probe if the privileged logs module is available.
143+
// Open attempts to open a file with normal path resolution. If opening fails
144+
// with permission denied, the file may be opened via system-probe when
145+
// privileged logs is enabled.
134146
func Open(path string) (*os.File, error) {
135-
file, err := os.Open(path)
147+
return open(path, false)
148+
}
149+
150+
// OpenNoFollow attempts to open a file without following symbolic links in any
151+
// path component. If opening fails with permission denied, the file may be
152+
// opened via system-probe with the same no-follow behavior.
153+
func OpenNoFollow(path string) (*os.File, error) {
154+
return open(path, true)
155+
}
156+
157+
func open(path string, noFollow bool) (*os.File, error) {
158+
var file *os.File
159+
var err error
160+
161+
if noFollow {
162+
file, err = common.OpenPathWithoutSymlinks(path)
163+
} else {
164+
file, err = os.Open(path)
165+
}
136166
if err == nil || !errors.Is(err, os.ErrPermission) {
137167
return file, err
138168
}
139169

140-
file, err = maybeOpenPrivileged(path, err)
141-
if err != nil {
142-
return nil, err
143-
}
144-
145-
return file, nil
170+
return maybeOpenPrivileged(path, err, noFollow)
146171
}
147172

148173
// Stat attempts to stat a file, and if it fails due to permissions, it opens
@@ -154,7 +179,7 @@ func Stat(path string) (os.FileInfo, error) {
154179
return info, err
155180
}
156181

157-
file, err := maybeOpenPrivileged(path, err)
182+
file, err := maybeOpenPrivileged(path, err, false)
158183
if err != nil {
159184
return nil, err
160185
}

pkg/privileged-logs/client/open_other.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package client
1010

1111
import (
12+
"errors"
1213
"os"
1314
)
1415

@@ -17,6 +18,24 @@ func Open(path string) (*os.File, error) {
1718
return os.Open(path)
1819
}
1920

21+
// OpenNoFollow falls back to a regular open on non-Linux platforms: symlink
22+
// rejection is only needed for process_log-discovered paths, and process_log
23+
// discovery (based on /proc/<pid>/fd) is Linux-only, so this path is not
24+
// reachable with an untrusted, attacker-controlled symlink swap here.
25+
func OpenNoFollow(path string) (*os.File, error) {
26+
return os.Open(path)
27+
}
28+
29+
// OpenPrivileged is not supported on non-Linux platforms.
30+
func OpenPrivileged(_, _ string) (*os.File, error) {
31+
return nil, errors.ErrUnsupported
32+
}
33+
34+
// OpenPrivilegedNoFollow is not supported on non-Linux platforms.
35+
func OpenPrivilegedNoFollow(_, _ string) (*os.File, error) {
36+
return nil, errors.ErrUnsupported
37+
}
38+
2039
// Stat provides a fallback for non-Linux platforms where the privileged logs module is not available.
2140
func Stat(path string) (os.FileInfo, error) {
2241
return os.Stat(path)

pkg/privileged-logs/common/open_linux.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ import (
2626
// itself, but for our use case, the root directory itself can not be trusted to
2727
// not have changed since the time the path was validated.
2828
//
29-
// This function is shared by the privileged-logs module (server side,
30-
// root-running system-probe) and the privileged-logs client (agent side).
29+
// This function is used both by the privileged-logs module (server side,
30+
// root-running system-probe) and by the privileged-logs client (agent side),
31+
// to defend against symlink-swap attacks on process_log-discovered file paths.
3132
func OpenPathWithoutSymlinks(path string) (*os.File, error) {
3233
if !filepath.IsAbs(path) {
3334
return nil, fmt.Errorf("path must be absolute: %s", path)
@@ -36,7 +37,14 @@ func OpenPathWithoutSymlinks(path string) (*os.File, error) {
3637
// Split path into components
3738
parts := strings.Split(filepath.Clean(path), string(filepath.Separator))
3839

39-
dirFd, err := unix.Open("/", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
40+
// Directory components are opened with O_PATH rather than O_RDONLY.
41+
// O_PATH only requires search (execute) permission to traverse into a
42+
// directory, matching the permission check that a plain os.Open(path)
43+
// would perform on the same directories. O_RDONLY additionally
44+
// requires read permission on every directory component, which would
45+
// wrongly reject files under directories that are traversable but not
46+
// listable (e.g. mode 0711).
47+
dirFd, err := unix.Open("/", unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
4048
if err != nil {
4149
return nil, fmt.Errorf("failed to open root directory: %w", err)
4250
}
@@ -53,7 +61,7 @@ func OpenPathWithoutSymlinks(path string) (*os.File, error) {
5361
continue
5462
}
5563

56-
newFd, err := unix.Openat(dirFd, parts[i], unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
64+
newFd, err := unix.Openat(dirFd, parts[i], unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY, 0)
5765
if err != nil {
5866
return nil, fmt.Errorf("failed to open directory component %s: %w", parts[i], err)
5967
}
@@ -63,7 +71,9 @@ func OpenPathWithoutSymlinks(path string) (*os.File, error) {
6371
dirFd = newFd
6472
}
6573

66-
// Open the final file component with O_NOFOLLOW
74+
// Open the final file component with O_NOFOLLOW. dirFd was opened with
75+
// O_PATH, so this openat still enforces the normal read-permission check
76+
// on the file itself.
6777
fileName := parts[len(parts)-1]
6878
fileFd, err := unix.Openat(dirFd, fileName, unix.O_RDONLY|unix.O_NOFOLLOW, 0)
6979
if err != nil {

pkg/privileged-logs/common/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ package common
99
// OpenFileRequest represents a request to open a file and transfer its file descriptor
1010
type OpenFileRequest struct {
1111
Path string `json:"path"`
12+
// NoFollow, when true, asks the module to open the file without following any
13+
// symbolic links in the path. This is used for process_log-discovered paths,
14+
// which are canonical at discovery time; a symlink found later indicates an
15+
// attacker-controlled swap. When false (the default), symbolic links are
16+
// resolved as usual.
17+
NoFollow bool `json:"no_follow,omitempty"`
1218
}
1319

1420
// OpenFileResponse represents the response from the file descriptor transfer

pkg/privileged-logs/module/handler.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"io"
1414
"net"
1515
"net/http"
16+
"os"
1617
"syscall"
1718

1819
"github.com/DataDog/datadog-agent/pkg/privileged-logs/common"
@@ -85,7 +86,12 @@ func (f *privilegedLogsModule) openFileHandler(w http.ResponseWriter, r *http.Re
8586

8687
f.logFileAccess(req.Path)
8788

88-
file, err := validateAndOpen(req.Path)
89+
var file *os.File
90+
if req.NoFollow {
91+
file, err = validateAndOpenNoFollow(req.Path)
92+
} else {
93+
file, err = validateAndOpen(req.Path)
94+
}
8995
if err != nil {
9096
f.sendErrorResponse(unixConn, err.Error())
9197
return

pkg/privileged-logs/module/validate.go

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,36 +61,69 @@ func isTextFile(file *os.File) bool {
6161
}
6262

6363
func validateAndOpenWithPrefix(path, allowedPrefix string, toctou func()) (*os.File, error) {
64+
resolvedPath, err := resolveFollowPath(path)
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
// Callback for tests to change the filesystem after we called EvalSymlinks,
70+
// in order to simulate a TOCTOU attack.
71+
if toctou != nil {
72+
toctou()
73+
}
74+
75+
return validateResolvedAndOpen(resolvedPath, allowedPrefix)
76+
}
77+
78+
func validateAndOpenNoFollowWithPrefix(path, allowedPrefix string) (*os.File, error) {
79+
resolvedPath, err := resolveNoFollowPath(path)
80+
if err != nil {
81+
return nil, err
82+
}
83+
return validateResolvedAndOpen(resolvedPath, allowedPrefix)
84+
}
85+
86+
func resolveFollowPath(path string) (string, error) {
6487
if path == "" {
65-
return nil, errors.New("empty file path provided")
88+
return "", errors.New("empty file path provided")
6689
}
6790

6891
if !filepath.IsAbs(path) {
69-
return nil, fmt.Errorf("relative path not allowed: %s", path)
92+
return "", fmt.Errorf("relative path not allowed: %s", path)
7093
}
7194

72-
// Resolve symbolic links for the path and file name checks.
7395
resolvedPath, err := filepath.EvalSymlinks(path)
7496
if err != nil {
75-
return nil, fmt.Errorf("failed to resolve path %s: %w", path, err)
97+
return "", fmt.Errorf("failed to resolve path %s: %w", path, err)
7698
}
99+
return resolvedPath, nil
100+
}
77101

78-
// Callback for tests to change the filesystem after we called EvalSymlinks,
79-
// in order to simulate a TOCTOU attack.
80-
if toctou != nil {
81-
toctou()
102+
func resolveNoFollowPath(path string) (string, error) {
103+
if path == "" {
104+
return "", errors.New("empty file path provided")
82105
}
83106

84-
var file *os.File
107+
if !filepath.IsAbs(path) {
108+
return "", fmt.Errorf("relative path not allowed: %s", path)
109+
}
85110

111+
// In no-follow mode the caller guarantees the path is canonical. Skip
112+
// EvalSymlinks entirely: open every component with O_NOFOLLOW so that a
113+
// symlink planted after the agent's discovery check causes an immediate error.
114+
return filepath.Clean(path), nil
115+
}
116+
117+
func validateResolvedAndOpen(resolvedPath, allowedPrefix string) (*os.File, error) {
86118
if !isAllowed(resolvedPath, allowedPrefix) {
87119
return nil, fmt.Errorf("non-log file not allowed: %s", resolvedPath)
88120
}
89121

90-
// We use common.OpenPathWithoutSymlinks on the resolved path to verify each
122+
// We use openPathWithoutSymlinks on the resolved path to verify each
91123
// component with O_NOFOLLOW to ensure that none of the path components
92-
// were replaced with symlinks after we called EvalSymlinks.
93-
file, err = common.OpenPathWithoutSymlinks(resolvedPath)
124+
// were replaced with symlinks after we called EvalSymlinks (follow mode),
125+
// or to enforce that the path has no symlinks at all (no-follow mode).
126+
file, err := common.OpenPathWithoutSymlinks(resolvedPath)
94127
if err != nil {
95128
return nil, fmt.Errorf("failed to open path %s: %w", resolvedPath, err)
96129
}
@@ -118,3 +151,7 @@ func validateAndOpenWithPrefix(path, allowedPrefix string, toctou func()) (*os.F
118151
func validateAndOpen(path string) (*os.File, error) {
119152
return validateAndOpenWithPrefix(path, "/var/log/", nil)
120153
}
154+
155+
func validateAndOpenNoFollow(path string) (*os.File, error) {
156+
return validateAndOpenNoFollowWithPrefix(path, "/var/log/")
157+
}

pkg/privileged-logs/module/validate_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,3 +810,35 @@ func TestValidateAndOpenWithPrefixTOCTOUFileSymlink(t *testing.T) {
810810
assert.Nil(t, file)
811811
assert.True(t, toctouCalled)
812812
}
813+
814+
// TestValidateAndOpenWithPrefixNoFollowRejectsSymlink verifies that when noFollow
815+
// is true, validateAndOpenWithPrefix refuses to open a path that is (or has
816+
// become) a symlink, even when the symlink target would be allow-listed. This
817+
// closes the residual TOCTOU where an attacker swaps a process_log-discovered file
818+
// to a symlink pointing at a root-readable log that the module would normally allow.
819+
func TestValidateAndOpenWithPrefixNoFollowRejectsSymlink(t *testing.T) {
820+
testDir := t.TempDir()
821+
822+
// A real log file to discover initially
823+
logFile := filepath.Join(testDir, "app.log")
824+
require.NoError(t, os.WriteFile(logFile, []byte("log content"), 0644))
825+
826+
// Attacker swaps app.log for a symlink to an allow-listed target
827+
targetLog := filepath.Join(testDir, "secret.log")
828+
require.NoError(t, os.WriteFile(targetLog, []byte("secret content"), 0644))
829+
830+
require.NoError(t, os.Remove(logFile))
831+
require.NoError(t, os.Symlink(targetLog, logFile))
832+
833+
// In noFollow mode the symlink must be rejected even though the target ends in .log
834+
file, err := validateAndOpenNoFollowWithPrefix(logFile, testDir+"/")
835+
assert.Error(t, err)
836+
assert.Nil(t, file)
837+
838+
// In standard (follow) mode the symlink would succeed
839+
file2, err2 := validateAndOpenWithPrefix(logFile, testDir+"/", nil)
840+
if err2 == nil {
841+
// accepted as expected in follow mode
842+
file2.Close()
843+
}
844+
}

0 commit comments

Comments
 (0)