From 3d4d9e784a0ae5ada949cdc7368e439218f49f14 Mon Sep 17 00:00:00 2001 From: Mahesh Keralapura Date: Thu, 2 Jul 2026 18:35:36 +0000 Subject: [PATCH] runsc: add --restrict-bind-to-loopback flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, bind(2) returns EACCES for any address that is not a loopback address, including INADDR_ANY (0.0.0.0) and IN6ADDR_ANY (::). Loopback classification is delegated to net.IP.IsLoopback() from the Go standard library. AF_UNIX and other address families are unaffected. Thread safety: RestrictBindToLoopback is written once during sandbox boot (loader.go:New) before any task goroutines are created, and is read-only thereafter — the same pattern used by AllowSUID and IOUringEnabled. The flag is only meaningful with sandbox networking (--network=sandbox). With host networking the sentry syscall handlers are not invoked for socket calls, so the check is a no-op. Tests: - runsc/config: TestRestrictBindToLoopback verifies flag round-trips through RegisterFlags/NewFromFlags/ToFlags. - test/syscalls/linux/restrict_bind_loopback_test: eight cases covering IPv4 INADDR_ANY (rejected), non-loopback unicast (rejected), 127.0.0.1 / 127.x.x.x (allowed), IPv6 IN6ADDR_ANY (rejected), ::1 (allowed), ::ffff:127.0.0.1 (allowed), and ::ffff:0.0.0.0 (rejected). Tests skip automatically when the flag is not active, so they are safe to run on plain Linux or gVisor without the flag. Assisted-by: Claude Sonnet 4.6 --- pkg/sentry/kernel/kernel.go | 4 + pkg/sentry/syscalls/linux/sys_socket.go | 27 +++ runsc/boot/loader.go | 9 +- runsc/config/config.go | 6 + runsc/config/config_test.go | 29 +++ runsc/config/flags.go | 1 + test/syscalls/linux/BUILD | 14 ++ test/syscalls/linux/restrict_bind_loopback.cc | 181 ++++++++++++++++++ 8 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 test/syscalls/linux/restrict_bind_loopback.cc diff --git a/pkg/sentry/kernel/kernel.go b/pkg/sentry/kernel/kernel.go index 5e9e82140d..40190a6442 100644 --- a/pkg/sentry/kernel/kernel.go +++ b/pkg/sentry/kernel/kernel.go @@ -433,6 +433,10 @@ type Kernel struct { // IOUringEnabled determines if io_uring is enabled. IOUringEnabled bool + // RestrictBindToLoopback, when true, causes bind(2) to return EACCES for + // any non-loopback address (including INADDR_ANY / IN6ADDR_ANY). + RestrictBindToLoopback bool + // MaxKeySetSize is the maximum number of keys in a key set. MaxKeySetSize atomicbitops.Int32 diff --git a/pkg/sentry/syscalls/linux/sys_socket.go b/pkg/sentry/syscalls/linux/sys_socket.go index 17b7c7ce3e..ae6444ec45 100644 --- a/pkg/sentry/syscalls/linux/sys_socket.go +++ b/pkg/sentry/syscalls/linux/sys_socket.go @@ -16,6 +16,7 @@ package linux import ( "fmt" + "net" "time" "golang.org/x/sys/unix" @@ -371,6 +372,32 @@ func Bind(t *kernel.Task, sysno uintptr, args arch.SyscallArguments) (uintptr, * return 0, nil, err } + // Enforce restrict-bind-to-loopback policy: reject any bind to a + // non-loopback address (including INADDR_ANY / IN6ADDR_ANY) so that + // sandboxed processes cannot inadvertently expose services on external + // interfaces. + if t.Kernel().RestrictBindToLoopback && len(a) >= 2 { + family := hostarch.ByteOrder.Uint16(a) + switch int(family) { + case linux.AF_INET: + if len(a) >= linux.SockAddrInetSize { + var sa linux.SockAddrInet + sa.UnmarshalUnsafe(a) + if !net.IP(sa.Addr[:]).IsLoopback() { + return 0, nil, linuxerr.EACCES + } + } + case linux.AF_INET6: + if len(a) >= linux.SockAddrInet6Size { + var sa linux.SockAddrInet6 + sa.UnmarshalUnsafe(a) + if !net.IP(sa.Addr[:]).IsLoopback() { + return 0, nil, linuxerr.EACCES + } + } + } + } + return 0, nil, s.Bind(t, a).ToError() } diff --git a/runsc/boot/loader.go b/runsc/boot/loader.go index c9988d11b2..e77e9e6365 100644 --- a/runsc/boot/loader.go +++ b/runsc/boot/loader.go @@ -634,10 +634,11 @@ func New(args Args) (*Loader, error) { } } l.k = &kernel.Kernel{ - Platform: p, - NvidiaDriverVersion: args.NvidiaDriverVersion, - AllowSUID: args.Conf.AllowSUID, - IOUringEnabled: args.Conf.IOUring, + Platform: p, + NvidiaDriverVersion: args.NvidiaDriverVersion, + AllowSUID: args.Conf.AllowSUID, + IOUringEnabled: args.Conf.IOUring, + RestrictBindToLoopback: args.Conf.RestrictBindToLoopback, } // Create memory file. diff --git a/runsc/config/config.go b/runsc/config/config.go index 7306dab4ad..48c25d7198 100644 --- a/runsc/config/config.go +++ b/runsc/config/config.go @@ -438,6 +438,12 @@ type Config struct { // and can be unpaused manually. PauseExternalNetworking bool `flag:"pause-external-networking"` + // RestrictBindToLoopback, when true, causes bind(2) to return EACCES for + // any address that is not a loopback address (127.x.x.x or ::1), including + // INADDR_ANY (0.0.0.0) and IN6ADDR_ANY (::). Only meaningful with sandbox + // networking. + RestrictBindToLoopback bool `flag:"restrict-bind-to-loopback"` + // AllowConnectedOnSave allows network connections to stay established on save. AllowConnectedOnSave bool `flag:"allow-connected-on-save"` diff --git a/runsc/config/config_test.go b/runsc/config/config_test.go index 18e4150dfc..98f4039347 100644 --- a/runsc/config/config_test.go +++ b/runsc/config/config_test.go @@ -179,6 +179,35 @@ func TestToFlagsFromManual(t *testing.T) { } } +// TestRestrictBindToLoopback verifies that the restrict-bind-to-loopback flag +// round-trips correctly through RegisterFlags/NewFromFlags/ToFlags. +func TestRestrictBindToLoopback(t *testing.T) { + testFlags := flag.NewFlagSet("test", flag.ContinueOnError) + RegisterFlags(testFlags) + if err := testFlags.Lookup("restrict-bind-to-loopback").Value.Set("true"); err != nil { + t.Fatalf("setting restrict-bind-to-loopback: %v", err) + } + c, err := NewFromFlags(testFlags) + if err != nil { + t.Fatal(err) + } + if !c.RestrictBindToLoopback { + t.Error("expected RestrictBindToLoopback=true after setting flag, got false") + } + + flags := c.ToFlags() + found := false + for _, f := range flags { + if f == "--restrict-bind-to-loopback=true" { + found = true + break + } + } + if !found { + t.Errorf("--restrict-bind-to-loopback=true not in ToFlags() output: %v", flags) + } +} + // TestInvalidFlags checks that enum flags fail when value is not in enum set. func TestInvalidFlags(t *testing.T) { for _, tc := range []struct { diff --git a/runsc/config/flags.go b/runsc/config/flags.go index ecd82ca15e..0f82496362 100644 --- a/runsc/config/flags.go +++ b/runsc/config/flags.go @@ -170,6 +170,7 @@ func RegisterFlags(flagSet *flag.FlagSet) { flagSet.Bool(flagReproduceNFTables, false, "Attempt to scrape and reproduce nftable rules inside the sandbox. Overrides reproduce-nat when true.") flagSet.Bool(flagNetDisconnectOK, true, "Indicates whether open network connections and open unix domain sockets should be disconnected upon save.") flagSet.Bool(flagPauseExternalNetworking, false, "Start the sandbox with external networking disabled. Only supported when using the sandbox network type. The network can be unpaused manually after the sandbox is running.") + flagSet.Bool("restrict-bind-to-loopback", false, "Reject bind(2) calls to non-loopback addresses (including INADDR_ANY/::). Sandboxed processes may only bind to 127.x.x.x or ::1. Only meaningful with sandbox networking.") flagSet.Bool(flagAllowConnectedOnSave, false, "Allow network connections to stay established on save.") // Flags that control sandbox runtime behavior: accelerator related. diff --git a/test/syscalls/linux/BUILD b/test/syscalls/linux/BUILD index 488ed5721d..05ded48373 100644 --- a/test/syscalls/linux/BUILD +++ b/test/syscalls/linux/BUILD @@ -2378,6 +2378,20 @@ cc_binary( ], ) +cc_binary( + name = "restrict_bind_loopback_test", + testonly = 1, + srcs = ["restrict_bind_loopback.cc"], + linkstatic = 1, + malloc = "//test/util:errno_safe_allocator", + deps = select_gtest() + [ + "//test/util:file_descriptor", + "//test/util:socket_util", + "//test/util:test_main", + "//test/util:test_util", + ], +) + cc_binary( name = "rename_test", testonly = 1, diff --git a/test/syscalls/linux/restrict_bind_loopback.cc b/test/syscalls/linux/restrict_bind_loopback.cc new file mode 100644 index 0000000000..4e60b981db --- /dev/null +++ b/test/syscalls/linux/restrict_bind_loopback.cc @@ -0,0 +1,181 @@ +// Copyright 2025 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. + +// Tests for the --restrict-bind-to-loopback runsc flag. When the flag is +// enabled, bind(2) must return EACCES for any address that is not a loopback +// address (127.x.x.x or ::1). These tests skip automatically when the flag is +// not active. + +#include +#include +#include + +#include "gtest/gtest.h" +#include "test/util/file_descriptor.h" +#include "test/util/socket_util.h" +#include "test/util/test_util.h" + +namespace gvisor { +namespace testing { + +namespace { + +// Detect whether --restrict-bind-to-loopback is active by probing a bind to +// INADDR_ANY. On a normal kernel or a gVisor instance without the flag, this +// bind will succeed (or fail with something other than EACCES). +bool RestrictBindToLoopbackEnabled() { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) return false; + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + int ret = + bind(sock, reinterpret_cast(&addr), sizeof(addr)); + int saved_errno = errno; + close(sock); + return ret < 0 && saved_errno == EACCES; +} + +// IPv4: binding to INADDR_ANY (0.0.0.0) must fail with EACCES. +TEST(RestrictBindLoopback, IPv4_INADDR_ANY_Rejected) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET, SOCK_STREAM, 0)); + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallFailsWithErrno(EACCES)); +} + +// IPv4: binding to a non-loopback unicast address must fail with EACCES. +TEST(RestrictBindLoopback, IPv4_NonLoopback_Rejected) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET, SOCK_STREAM, 0)); + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + inet_pton(AF_INET, "192.0.2.1", &addr.sin_addr); // TEST-NET-1, RFC 5737 + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallFailsWithErrno(EACCES)); +} + +// IPv4: binding to 127.0.0.1 must succeed. +TEST(RestrictBindLoopback, IPv4_Loopback_Allowed) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET, SOCK_STREAM, 0)); + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = 0; + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallSucceeds()); +} + +// IPv4: binding to 127.x.x.x (any loopback block address) must succeed. +TEST(RestrictBindLoopback, IPv4_LoopbackBlock_Allowed) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET, SOCK_STREAM, 0)); + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = 0; + inet_pton(AF_INET, "127.0.0.2", &addr.sin_addr); + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallSucceeds()); +} + +// IPv6: binding to IN6ADDR_ANY (::) must fail with EACCES. +TEST(RestrictBindLoopback, IPv6_IN6ADDR_ANY_Rejected) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET6, SOCK_STREAM, 0)); + struct sockaddr_in6 addr = {}; + addr.sin6_family = AF_INET6; + addr.sin6_addr = in6addr_any; + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallFailsWithErrno(EACCES)); +} + +// IPv6: binding to ::1 must succeed. +TEST(RestrictBindLoopback, IPv6_Loopback_Allowed) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET6, SOCK_STREAM, 0)); + struct sockaddr_in6 addr = {}; + addr.sin6_family = AF_INET6; + addr.sin6_port = 0; + addr.sin6_addr = in6addr_loopback; + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallSucceeds()); +} + +// IPv6: binding to ::ffff:127.0.0.1 (IPv4-mapped loopback) must succeed. +// Dual-stack sockets can bind to IPv4 addresses in mapped form; rejecting this +// would break applications that use IPv6 sockets for both IPv4 and IPv6. +TEST(RestrictBindLoopback, IPv6_IPv4MappedLoopback_Allowed) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET6, SOCK_STREAM, 0)); + struct sockaddr_in6 addr = {}; + addr.sin6_family = AF_INET6; + addr.sin6_port = 0; + // ::ffff:127.0.0.1 + inet_pton(AF_INET6, "::ffff:127.0.0.1", &addr.sin6_addr); + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallSucceeds()); +} + +// IPv6: binding to ::ffff:0.0.0.0 (IPv4-mapped INADDR_ANY) must fail. +TEST(RestrictBindLoopback, IPv6_IPv4MappedAny_Rejected) { + SKIP_IF(!RestrictBindToLoopbackEnabled()); + + FileDescriptor sock = + ASSERT_NO_ERRNO_AND_VALUE(Socket(AF_INET6, SOCK_STREAM, 0)); + struct sockaddr_in6 addr = {}; + addr.sin6_family = AF_INET6; + // ::ffff:0.0.0.0 + inet_pton(AF_INET6, "::ffff:0.0.0.0", &addr.sin6_addr); + EXPECT_THAT( + bind(sock.get(), reinterpret_cast(&addr), + sizeof(addr)), + SyscallFailsWithErrno(EACCES)); +} + +} // namespace + +} // namespace testing +} // namespace gvisor