Browse code

Mask Linux thermal interrupt info in /proc and /sys.

On Linux, mask "/proc/interrupts" and "/sys/devices/system/cpu/cpu<x>/thermal_throttle"
inside containers by default. Privileged containers or containers started
with --security-opt="systempaths=unconfined" are not affected.

Mitigates potential Thermal Side-Channel Vulnerability Exploit
(https://github.com/moby/moby/security/advisories/GHSA-6fw5-f8r9-fgfm).

Also: improve integration test TestCreateWithCustomMaskedPaths() to ensure
default masked paths don't apply to privileged containers.

Signed-off-by: Cesar Talledo <cesar.talledo@docker.com>

Cesar Talledo authored on 2025/01/08 07:55:21
Showing 6 changed files
... ...
@@ -258,6 +258,7 @@ func TestCreateWithCustomMaskedPaths(t *testing.T) {
258 258
 	apiClient := testEnv.APIClient()
259 259
 
260 260
 	testCases := []struct {
261
+		privileged  bool
261 262
 		maskedPaths []string
262 263
 		expected    []string
263 264
 	}{
... ...
@@ -273,6 +274,11 @@ func TestCreateWithCustomMaskedPaths(t *testing.T) {
273 273
 			maskedPaths: []string{"/proc/kcore", "/proc/keys"},
274 274
 			expected:    []string{"/proc/kcore", "/proc/keys"},
275 275
 		},
276
+		{
277
+			privileged:  true,
278
+			maskedPaths: nil,
279
+			expected:    nil,
280
+		},
276 281
 	}
277 282
 
278 283
 	checkInspect := func(t *testing.T, ctx context.Context, name string, expected []string) {
... ...
@@ -286,15 +292,20 @@ func TestCreateWithCustomMaskedPaths(t *testing.T) {
286 286
 		cfg, ok := inspectJSON["HostConfig"].(map[string]interface{})
287 287
 		assert.Check(t, is.Equal(true, ok), name)
288 288
 
289
-		maskedPaths, ok := cfg["MaskedPaths"].([]interface{})
290
-		assert.Check(t, is.Equal(true, ok), name)
289
+		if expected != nil {
290
+			maskedPaths, ok := cfg["MaskedPaths"].([]interface{})
291
+			assert.Check(t, is.Equal(true, ok), name)
291 292
 
292
-		mps := make([]string, 0, len(maskedPaths))
293
-		for _, mp := range maskedPaths {
294
-			mps = append(mps, mp.(string))
295
-		}
293
+			mps := make([]string, 0, len(maskedPaths))
294
+			for _, mp := range maskedPaths {
295
+				mps = append(mps, mp.(string))
296
+			}
296 297
 
297
-		assert.DeepEqual(t, expected, mps)
298
+			assert.DeepEqual(t, expected, mps)
299
+		} else {
300
+			_, ok := cfg["MaskedPaths"].([]interface{})
301
+			assert.Check(t, is.Equal(false, ok), name)
302
+		}
298 303
 	}
299 304
 
300 305
 	// TODO: This should be using subtests
... ...
@@ -305,7 +316,9 @@ func TestCreateWithCustomMaskedPaths(t *testing.T) {
305 305
 			Image: "busybox",
306 306
 			Cmd:   []string{"true"},
307 307
 		}
308
-		hc := container.HostConfig{}
308
+		hc := container.HostConfig{
309
+			Privileged: tc.privileged,
310
+		}
309 311
 		if tc.maskedPaths != nil {
310 312
 			hc.MaskedPaths = tc.maskedPaths
311 313
 		}
... ...
@@ -2,6 +2,7 @@ package platform
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"runtime"
5 6
 	"sync"
6 7
 
7 8
 	"github.com/containerd/log"
... ...
@@ -29,3 +30,22 @@ func Architecture() string {
29 29
 	})
30 30
 	return arch
31 31
 }
32
+
33
+// PossibleCPU returns the set of possible CPUs on the host (which is equal or
34
+// larger to the number of CPUs currently online). The returned set may be a
35
+// single CPU number ({0}), or a continuous range of CPU numbers ({0,1,2,3}), or
36
+// a non-continuous range of CPU numbers ({0,1,2,3,12,13,14,15}).
37
+func PossibleCPU() []int {
38
+	if ncpu := possibleCPUs(); ncpu != nil {
39
+		return ncpu
40
+	}
41
+
42
+	// Fallback in case possibleCPUs() fails.
43
+	var cpus []int
44
+	ncpu := runtime.NumCPU()
45
+	for i := 0; i <= ncpu; i++ {
46
+		cpus = append(cpus, i)
47
+	}
48
+
49
+	return cpus
50
+}
32 51
new file mode 100644
... ...
@@ -0,0 +1,55 @@
0
+package platform
1
+
2
+import (
3
+	"os"
4
+	"strconv"
5
+	"strings"
6
+	"sync"
7
+)
8
+
9
+// possibleCPUs returns the set of possible CPUs on the host (which is
10
+// equal or larger to the number of CPUs currently online). The returned
11
+// set may be a single number ({0}), or a continuous range ({0,1,2,3}), or
12
+// a non-continuous range ({0,1,2,3,12,13,14,15})
13
+//
14
+// Returns nil on errors. Assume CPUs are 0 -> runtime.NumCPU() in that case.
15
+var possibleCPUs = sync.OnceValue(func() []int {
16
+	data, err := os.ReadFile("/sys/devices/system/cpu/possible")
17
+	if err != nil {
18
+		return nil
19
+	}
20
+	content := strings.TrimSpace(string(data))
21
+	return parsePossibleCPUs(content)
22
+})
23
+
24
+func parsePossibleCPUs(content string) []int {
25
+	ranges := strings.Split(content, ",")
26
+
27
+	var cpus []int
28
+	for _, r := range ranges {
29
+		// Each entry is either a single number (e.g., "0") or a continuous range
30
+		// (e.g., "0-3").
31
+		if rStart, rEnd, ok := strings.Cut(r, "-"); !ok {
32
+			cpu, err := strconv.Atoi(rStart)
33
+			if err != nil {
34
+				return nil
35
+			}
36
+			cpus = append(cpus, cpu)
37
+		} else {
38
+			var start, end int
39
+			start, err := strconv.Atoi(rStart)
40
+			if err != nil {
41
+				return nil
42
+			}
43
+			end, err = strconv.Atoi(rEnd)
44
+			if err != nil {
45
+				return nil
46
+			}
47
+			for i := start; i <= end; i++ {
48
+				cpus = append(cpus, i)
49
+			}
50
+		}
51
+	}
52
+
53
+	return cpus
54
+}
0 55
new file mode 100644
... ...
@@ -0,0 +1,54 @@
0
+package platform
1
+
2
+import (
3
+	"testing"
4
+
5
+	"gotest.tools/v3/assert"
6
+	is "gotest.tools/v3/assert/cmp"
7
+)
8
+
9
+func TestParsePossibleCPUs(t *testing.T) {
10
+	tests := []struct {
11
+		name     string
12
+		input    string
13
+		expected []int
14
+	}{
15
+		{
16
+			name:     "Continuous Range",
17
+			input:    "0-3",
18
+			expected: []int{0, 1, 2, 3},
19
+		},
20
+		{
21
+			name:     "Non-Continuous Range",
22
+			input:    "0-2,4,6-7",
23
+			expected: []int{0, 1, 2, 4, 6, 7},
24
+		},
25
+		{
26
+			name:     "Single CPU",
27
+			input:    "5",
28
+			expected: []int{5},
29
+		},
30
+		{
31
+			name:     "Empty Input",
32
+			input:    "",
33
+			expected: nil,
34
+		},
35
+		{
36
+			name:     "Invalid Range",
37
+			input:    "0-2,invalid",
38
+			expected: nil,
39
+		},
40
+		{
41
+			name:     "Malformed Range",
42
+			input:    "0-2-3",
43
+			expected: nil,
44
+		},
45
+	}
46
+
47
+	for _, test := range tests {
48
+		t.Run(test.name, func(t *testing.T) {
49
+			result := parsePossibleCPUs(test.input)
50
+			assert.Assert(t, is.DeepEqual(result, test.expected), "Expected %v but got %v", test.expected, result)
51
+		})
52
+	}
53
+}
... ...
@@ -69,3 +69,8 @@ func NumProcs() uint32 {
69 69
 	_, _, _ = syscall.SyscallN(procGetSystemInfo.Addr(), uintptr(unsafe.Pointer(&sysinfo)))
70 70
 	return sysinfo.dwNumberOfProcessors
71 71
 }
72
+
73
+func possibleCPUs() []int {
74
+	// not implemented
75
+	return nil
76
+}
... ...
@@ -1,8 +1,12 @@
1 1
 package oci // import "github.com/docker/docker/oci"
2 2
 
3 3
 import (
4
+	"fmt"
5
+	"os"
4 6
 	"runtime"
7
+	"sync"
5 8
 
9
+	"github.com/docker/docker/internal/platform"
6 10
 	"github.com/docker/docker/oci/caps"
7 11
 	"github.com/opencontainers/runtime-spec/specs-go"
8 12
 )
... ...
@@ -102,19 +106,7 @@ func DefaultLinuxSpec() specs.Spec {
102 102
 			},
103 103
 		},
104 104
 		Linux: &specs.Linux{
105
-			MaskedPaths: []string{
106
-				"/proc/asound",
107
-				"/proc/acpi",
108
-				"/proc/kcore",
109
-				"/proc/keys",
110
-				"/proc/latency_stats",
111
-				"/proc/timer_list",
112
-				"/proc/timer_stats",
113
-				"/proc/sched_debug",
114
-				"/proc/scsi",
115
-				"/sys/firmware",
116
-				"/sys/devices/virtual/powercap",
117
-			},
105
+			MaskedPaths: defaultLinuxMaskedPaths(),
118 106
 			ReadonlyPaths: []string{
119 107
 				"/proc/bus",
120 108
 				"/proc/fs",
... ...
@@ -194,3 +186,33 @@ func DefaultLinuxSpec() specs.Spec {
194 194
 		},
195 195
 	}
196 196
 }
197
+
198
+// defaultLinuxMaskedPaths returns the default list of paths to mask in a Linux
199
+// container. The paths won't change while the docker daemon is running, so just
200
+// compute them once.
201
+var defaultLinuxMaskedPaths = sync.OnceValue(func() []string {
202
+	maskedPaths := []string{
203
+		"/proc/asound",
204
+		"/proc/acpi",
205
+		"/proc/interrupts", // https://github.com/moby/moby/security/advisories/GHSA-6fw5-f8r9-fgfm
206
+		"/proc/kcore",
207
+		"/proc/keys",
208
+		"/proc/latency_stats",
209
+		"/proc/timer_list",
210
+		"/proc/timer_stats",
211
+		"/proc/sched_debug",
212
+		"/proc/scsi",
213
+		"/sys/firmware",
214
+		"/sys/devices/virtual/powercap", // https://github.com/moby/moby/security/advisories/GHSA-jq35-85cj-fj4p
215
+	}
216
+
217
+	// https://github.com/moby/moby/security/advisories/GHSA-6fw5-f8r9-fgfm
218
+	cpus := platform.PossibleCPU()
219
+	for _, cpu := range cpus {
220
+		path := fmt.Sprintf("/sys/devices/system/cpu/cpu%d/thermal_throttle", cpu)
221
+		if _, err := os.Stat(path); err == nil {
222
+			maskedPaths = append(maskedPaths, path)
223
+		}
224
+	}
225
+	return maskedPaths
226
+})