diff --git a/docs/changelog/3080.yaml b/docs/changelog/3080.yaml new file mode 100644 index 0000000000..f79dbf39d8 --- /dev/null +++ b/docs/changelog/3080.yaml @@ -0,0 +1,5 @@ +area: Machine Learning +issues: [] +pr: 3080 +summary: Reject non-native ABIs in ML seccomp filter (socketcall/getuid collision) +type: bug diff --git a/include/seccomp/CSystemCallFilter.h b/include/seccomp/CSystemCallFilter.h index 17836bba9a..9855d27002 100644 --- a/include/seccomp/CSystemCallFilter.h +++ b/include/seccomp/CSystemCallFilter.h @@ -20,10 +20,10 @@ namespace seccomp { //! Installs secure computing modes for Linux, macOS and Windows //! //! DESCRIPTION:\n -//! ML processes require a subset of system calls to function correctly. -//! These are create a named pipe, connect to a named pipe, read and write -//! no other system calls are necessary and should be resticted to prevent -//! malicious actions. +//! ML processes require a subset of system calls to function correctly: +//! creating a named pipe, connecting to a named pipe, and reading and +//! writing. No other system calls are necessary, so the rest should be +//! restricted to prevent malicious actions. //! //! IMPLEMENTATION DECISIONS:\n //! Implementations are platform specific more details can be found in the @@ -31,9 +31,12 @@ namespace seccomp { //! //! Linux: //! Seccomp BPF is used to restrict system calls on kernels since 3.5. +//! The filter first requires seccomp_data.arch to match the native ABI +//! (rejecting compat ABIs such as i386 int 0x80 on x86_64, which would +//! otherwise collide with allowlisted syscall numbers). //! -//! macOs: -//! The sandbox facility is used to restict access to system resources. +//! macOS: +//! The sandbox facility is used to restrict access to system resources. //! //! Windows: //! Job Objects prevent the process spawning another. diff --git a/lib/seccomp/CSystemCallFilter_Linux.cc b/lib/seccomp/CSystemCallFilter_Linux.cc index 9d53971007..fd0f6192e0 100644 --- a/lib/seccomp/CSystemCallFilter_Linux.cc +++ b/lib/seccomp/CSystemCallFilter_Linux.cc @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -30,12 +31,23 @@ namespace { // The x64 ABI should fail these calls const std::uint32_t UPPER_NR_LIMIT = 0x3FFFFFFF; -// Offset to the nr field in struct seccomp_data -const std::uint32_t SECCOMP_DATA_NR_OFFSET = 0x00; - const struct sock_filter FILTER[] = { + // Reject non-native ABIs before matching syscall numbers. Without this, + // an x86_64 process can issue int 0x80 (i386) and hit number collisions — + // e.g. i386 socketcall (102) matches the allowlisted x86_64 getuid (102). + // See elastic/security#12621 / HackerOne report on ML seccomp bypass. + // This prefix is self-contained (immediate RET on mismatch) so the relative + // jump offsets in the nr allowlist below are unchanged. + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), +#ifdef __x86_64__ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0), +#elif defined(__aarch64__) + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0), +#endif + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EACCES & SECCOMP_RET_DATA)), + // Load the system call number into accumulator - BPF_STMT(BPF_LD | BPF_W | BPF_ABS, SECCOMP_DATA_NR_OFFSET), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), #ifdef __x86_64__ // The statx, rseq and clone3 syscalls won't be defined on a RHEL/CentOS 7 build diff --git a/lib/seccomp/unittest/CSystemCallFilterTest.cc b/lib/seccomp/unittest/CSystemCallFilterTest.cc index 56b6d66ade..6ef5d2dfbf 100644 --- a/lib/seccomp/unittest/CSystemCallFilterTest.cc +++ b/lib/seccomp/unittest/CSystemCallFilterTest.cc @@ -28,9 +28,15 @@ #include #include +#include #include #include +#if defined(Linux) && defined(__x86_64__) +#include +#include +#endif + BOOST_AUTO_TEST_SUITE(CSystemCallFilterTest) namespace { @@ -162,6 +168,71 @@ bool versionIsBefore3_5(int major, int minor) { return false; } #endif + +#if defined(Linux) && defined(__x86_64__) +// i386 ABI numbers from asm/unistd_32.h. Not available as macros when +// compiling a pure x86_64 translation unit, so hard-code the ones we need. +constexpr long I386_NR_GETUID{24}; +// Collides with allowlisted x86_64 getuid (102) — the HackerOne #12621 case. +constexpr long I386_NR_SOCKETCALL{102}; + +sigjmp_buf g_i386ProbeJmpBuf; + +void i386ProbeSignalHandler(int /*sig*/) { + siglongjmp(g_i386ProbeJmpBuf, 1); +} + +// Issue a 32-bit ABI syscall via int $0x80. Returns false if the kernel +// lacks IA32 emulation (fault), otherwise writes the syscall result to result. +bool tryI386Syscall(long nr, long& result) { + struct sigaction faultHandler {}; + faultHandler.sa_handler = &i386ProbeSignalHandler; + sigemptyset(&faultHandler.sa_mask); + faultHandler.sa_flags = 0; + + struct sigaction previousSegv {}; + struct sigaction previousIll {}; + BOOST_TEST_REQUIRE(sigaction(SIGSEGV, &faultHandler, &previousSegv) == 0); + BOOST_TEST_REQUIRE(sigaction(SIGILL, &faultHandler, &previousIll) == 0); + + bool available{false}; + if (sigsetjmp(g_i386ProbeJmpBuf, 1) == 0) { + long ret; + // The i386 ABI passes arguments in ebx/ecx/edx/esi/edi; zero them so the + // probe is deterministic and side-effect free if the syscall is ever + // (wrongly) permitted rather than denied. "cc" is clobbered because + // int $0x80 (and the kernel entry path) may modify the condition-code + // flags. (ebp is the rarely-used 6th arg; none of the probed calls use + // it, and it has no simple GCC constraint.) + // NOLINTNEXTLINE(hicpp-no-assembler) + asm volatile("int $0x80" + : "=a"(ret) + : "a"(nr), "b"(0), "c"(0), "d"(0), "S"(0), "D"(0) + : "memory", "cc"); + result = ret; + available = true; + } + + BOOST_TEST_REQUIRE(sigaction(SIGSEGV, &previousSegv, nullptr) == 0); + BOOST_TEST_REQUIRE(sigaction(SIGILL, &previousIll, nullptr) == 0); + return available; +} + +long i386Syscall(long nr) { + long ret; + // Zero the i386 argument registers (ebx/ecx/edx/esi/edi) so the call is + // deterministic and side-effect free in the failure mode this test guards + // against (the syscall being permitted instead of denied). "cc" is + // clobbered because int $0x80 (and the kernel entry path) may modify the + // condition-code flags. + // NOLINTNEXTLINE(hicpp-no-assembler) + asm volatile("int $0x80" + : "=a"(ret) + : "a"(nr), "b"(0), "c"(0), "d"(0), "S"(0), "D"(0) + : "memory", "cc"); + return ret; +} +#endif // Linux && __x86_64__ } BOOST_AUTO_TEST_CASE(testSystemCallFilter) { @@ -185,9 +256,35 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) { // system call filters are applied BOOST_TEST_REQUIRE(systemCall()); +#if defined(Linux) && defined(__x86_64__) + // Soft-detect a *usable* IA32 compat entry. Kernels without + // CONFIG_IA32_EMULATION (or with it disabled at runtime) fault on int $0x80 + // from a 64-bit process; some configurations instead stub it to return + // -ENOSYS. In either case there is no compat ABI to reject, so skip the + // assertion rather than fail CI. + long i386GetuidResult{0}; + const bool i386EntryAvailable{tryI386Syscall(I386_NR_GETUID, i386GetuidResult)}; + // i386 getuid cannot fail, so a non-negative result confirms the call was + // actually serviced by the 32-bit compat table (rather than stubbed out). + const bool i386CompatUsable{i386EntryAvailable && i386GetuidResult >= 0}; + if (i386CompatUsable) { + LOG_INFO(<< "i386 syscall entry available; will assert arch denial after filter install"); + } else { + LOG_INFO(<< "i386 syscall entry unavailable; skipping compat-ABI seccomp assertion"); + } +#endif + // Install the filter ml::seccomp::CSystemCallFilter::installSystemCallFilter(); +#if defined(Linux) && defined(__x86_64__) + if (i386CompatUsable) { + // Without the seccomp_data.arch gate, i386 socketcall (102) matches + // allowlisted x86_64 getuid (102) and is wrongly permitted. + BOOST_REQUIRE_EQUAL(-EACCES, i386Syscall(I386_NR_SOCKETCALL)); + } +#endif + BOOST_REQUIRE_MESSAGE(systemCall() == false, "Calling std::system should fail"); // Operations that must function after seccomp is initialised