From 7cf6ea12f25279834f39d57024cff545a8fd1f86 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 21 Jul 2026 13:12:28 +1200 Subject: [PATCH 1/5] Reject non-native ABIs in ML seccomp BPF filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux seccomp filter matched only on seccomp_data.nr, so an x86_64 process could issue int 0x80 (i386) and hit number collisions — notably i386 socketcall (102) vs allowlisted x86_64 getuid (102). That opened a socket surface the policy intended to block (elastic/security#12621). Require seccomp_data.arch to match the native ABI (AUDIT_ARCH_X86_64 / AUDIT_ARCH_AARCH64) with an immediate deny on mismatch, before any nr allowlist matching. The arch check is self-contained so existing relative jump offsets in the nr allowlist are unchanged. Co-authored-by: Cursor --- include/seccomp/CSystemCallFilter.h | 3 +++ lib/seccomp/CSystemCallFilter_Linux.cc | 17 ++++++++++++++++- lib/seccomp/unittest/CSystemCallFilterTest.cc | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/include/seccomp/CSystemCallFilter.h b/include/seccomp/CSystemCallFilter.h index 17836bba9a..03c77086cd 100644 --- a/include/seccomp/CSystemCallFilter.h +++ b/include/seccomp/CSystemCallFilter.h @@ -31,6 +31,9 @@ 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. diff --git a/lib/seccomp/CSystemCallFilter_Linux.cc b/lib/seccomp/CSystemCallFilter_Linux.cc index 9d53971007..fe1cccb066 100644 --- a/lib/seccomp/CSystemCallFilter_Linux.cc +++ b/lib/seccomp/CSystemCallFilter_Linux.cc @@ -30,10 +30,25 @@ 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 +// Offsets into struct seccomp_data (linux/seccomp.h). const std::uint32_t SECCOMP_DATA_NR_OFFSET = 0x00; +const std::uint32_t SECCOMP_DATA_ARCH_OFFSET = 0x04; 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, SECCOMP_DATA_ARCH_OFFSET), +#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), diff --git a/lib/seccomp/unittest/CSystemCallFilterTest.cc b/lib/seccomp/unittest/CSystemCallFilterTest.cc index 56b6d66ade..db49d6f74f 100644 --- a/lib/seccomp/unittest/CSystemCallFilterTest.cc +++ b/lib/seccomp/unittest/CSystemCallFilterTest.cc @@ -188,6 +188,12 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) { // Install the filter ml::seccomp::CSystemCallFilter::installSystemCallFilter(); + // Native allowlisted calls must still work. Compat-ABI collisions + // (e.g. i386 socketcall via int 0x80 matching x86_64 getuid=102) are + // rejected by the seccomp_data.arch check at the start of the filter; + // that path is not exercised here because it requires issuing a foreign + // ABI syscall from the test process. + BOOST_REQUIRE_MESSAGE(systemCall() == false, "Calling std::system should fail"); // Operations that must function after seccomp is initialised From c4bfda203c03eeeb370fbbbf565bf9741797f7d2 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 21 Jul 2026 13:13:01 +1200 Subject: [PATCH 2/5] Add changelog for #3080 Co-authored-by: Cursor --- docs/changelog/3080.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/changelog/3080.yaml 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 From fb1d22e182a75cecf39922167ea549c70d3bd00d Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Tue, 21 Jul 2026 15:06:25 +1200 Subject: [PATCH 3/5] Address Copilot review on seccomp arch gate Use offsetof for seccomp_data field loads and add an x86_64-only int 0x80 denial test, soft-skipped when IA32 emulation is unavailable. Co-authored-by: Cursor --- lib/seccomp/CSystemCallFilter_Linux.cc | 9 +-- lib/seccomp/unittest/CSystemCallFilterTest.cc | 81 +++++++++++++++++-- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/lib/seccomp/CSystemCallFilter_Linux.cc b/lib/seccomp/CSystemCallFilter_Linux.cc index fe1cccb066..fd0f6192e0 100644 --- a/lib/seccomp/CSystemCallFilter_Linux.cc +++ b/lib/seccomp/CSystemCallFilter_Linux.cc @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -30,10 +31,6 @@ namespace { // The x64 ABI should fail these calls const std::uint32_t UPPER_NR_LIMIT = 0x3FFFFFFF; -// Offsets into struct seccomp_data (linux/seccomp.h). -const std::uint32_t SECCOMP_DATA_NR_OFFSET = 0x00; -const std::uint32_t SECCOMP_DATA_ARCH_OFFSET = 0x04; - 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 — @@ -41,7 +38,7 @@ const struct sock_filter FILTER[] = { // 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, SECCOMP_DATA_ARCH_OFFSET), + 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__) @@ -50,7 +47,7 @@ const struct sock_filter FILTER[] = { 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 db49d6f74f..10d0a6d5c5 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,54 @@ 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 \p 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; + // NOLINTNEXTLINE(hicpp-no-assembler) + asm volatile("int $0x80" : "=a"(ret) : "a"(nr) : "memory"); + 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; + // NOLINTNEXTLINE(hicpp-no-assembler) + asm volatile("int $0x80" : "=a"(ret) : "a"(nr) : "memory"); + return ret; +} +#endif // Linux && __x86_64__ } BOOST_AUTO_TEST_CASE(testSystemCallFilter) { @@ -185,14 +239,31 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) { // system call filters are applied BOOST_TEST_REQUIRE(systemCall()); +#if defined(Linux) && defined(__x86_64__) + // Soft-detect IA32 emulation: kernels without CONFIG_IA32_EMULATION + // (or with it disabled at runtime) fault on int $0x80 from a 64-bit + // process. Skip the compat-ABI assertion in that case rather than fail CI. + long i386GetuidResult{0}; + const bool i386EntryAvailable{tryI386Syscall(I386_NR_GETUID, i386GetuidResult)}; + if (i386EntryAvailable) { + BOOST_TEST_REQUIRE(i386GetuidResult >= 0); + 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(); - // Native allowlisted calls must still work. Compat-ABI collisions - // (e.g. i386 socketcall via int 0x80 matching x86_64 getuid=102) are - // rejected by the seccomp_data.arch check at the start of the filter; - // that path is not exercised here because it requires issuing a foreign - // ABI syscall from the test process. +#if defined(Linux) && defined(__x86_64__) + if (i386EntryAvailable) { + // Without the seccomp_data.arch gate, i386 socketcall (102) matches + // allowlisted x86_64 getuid (102) and is wrongly permitted. + // Without the arch gate this would be wrongly allowed as x86_64 getuid. + BOOST_REQUIRE_EQUAL(-EACCES, i386Syscall(I386_NR_SOCKETCALL)); + } +#endif BOOST_REQUIRE_MESSAGE(systemCall() == false, "Calling std::system should fail"); From bf4f3dea1c3d1a0b34ad0dcb416779fe31b83c56 Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Thu, 23 Jul 2026 11:54:27 +1200 Subject: [PATCH 4/5] [ML] Address remaining Copilot review comments on seccomp arch gate - Fix spelling in CSystemCallFilter.h doc comment (macOS, restrict/restricted). - Declare the 'cc' clobber on the int $0x80 i386-ABI probe asm, since the syscall entry path may modify the condition-code flags. Co-authored-by: Cursor --- include/seccomp/CSystemCallFilter.h | 6 +++--- lib/seccomp/unittest/CSystemCallFilterTest.cc | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/include/seccomp/CSystemCallFilter.h b/include/seccomp/CSystemCallFilter.h index 03c77086cd..5d5af4be9e 100644 --- a/include/seccomp/CSystemCallFilter.h +++ b/include/seccomp/CSystemCallFilter.h @@ -22,7 +22,7 @@ namespace seccomp { //! 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 +//! no other system calls are necessary and should be restricted to prevent //! malicious actions. //! //! IMPLEMENTATION DECISIONS:\n @@ -35,8 +35,8 @@ namespace seccomp { //! (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/unittest/CSystemCallFilterTest.cc b/lib/seccomp/unittest/CSystemCallFilterTest.cc index 10d0a6d5c5..7a89d9b018 100644 --- a/lib/seccomp/unittest/CSystemCallFilterTest.cc +++ b/lib/seccomp/unittest/CSystemCallFilterTest.cc @@ -198,8 +198,10 @@ bool tryI386Syscall(long nr, long& result) { bool available{false}; if (sigsetjmp(g_i386ProbeJmpBuf, 1) == 0) { long ret; + // "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) : "memory"); + asm volatile("int $0x80" : "=a"(ret) : "a"(nr) : "memory", "cc"); result = ret; available = true; } @@ -211,8 +213,10 @@ bool tryI386Syscall(long nr, long& result) { long i386Syscall(long nr) { long ret; + // "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) : "memory"); + asm volatile("int $0x80" : "=a"(ret) : "a"(nr) : "memory", "cc"); return ret; } #endif // Linux && __x86_64__ From e8c932b75046e63792d645e4db2fdef4a7d0224f Mon Sep 17 00:00:00 2001 From: Ed Savage Date: Thu, 23 Jul 2026 13:21:25 +1200 Subject: [PATCH 5/5] [ML] Address Copilot review on seccomp arch-gate test/docs - Use plain // comments (not Doxygen //! / \p) in the .cc test file. - Zero the i386 argument registers (ebx/ecx/edx/esi/edi) in the int $0x80 probes so they are deterministic and side-effect free in the failure mode. - Harden the IA32 detection: a compat entry that returns -ENOSYS (stubbed, not truly usable) now skips the assertion instead of failing. - Fix the run-on DESCRIPTION sentence in the header. Co-authored-by: Cursor --- include/seccomp/CSystemCallFilter.h | 8 ++-- lib/seccomp/unittest/CSystemCallFilterTest.cc | 46 +++++++++++++------ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/include/seccomp/CSystemCallFilter.h b/include/seccomp/CSystemCallFilter.h index 5d5af4be9e..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 restricted 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 diff --git a/lib/seccomp/unittest/CSystemCallFilterTest.cc b/lib/seccomp/unittest/CSystemCallFilterTest.cc index 7a89d9b018..6ef5d2dfbf 100644 --- a/lib/seccomp/unittest/CSystemCallFilterTest.cc +++ b/lib/seccomp/unittest/CSystemCallFilterTest.cc @@ -182,8 +182,8 @@ 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 \p result. +// 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; @@ -198,10 +198,17 @@ bool tryI386Syscall(long nr, long& result) { bool available{false}; if (sigsetjmp(g_i386ProbeJmpBuf, 1) == 0) { long ret; - // "cc" is clobbered because int $0x80 (and the kernel entry path) may - // modify the condition-code flags. + // 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) : "memory", "cc"); + asm volatile("int $0x80" + : "=a"(ret) + : "a"(nr), "b"(0), "c"(0), "d"(0), "S"(0), "D"(0) + : "memory", "cc"); result = ret; available = true; } @@ -213,10 +220,16 @@ bool tryI386Syscall(long nr, long& result) { long i386Syscall(long nr) { long ret; - // "cc" is clobbered because int $0x80 (and the kernel entry path) may - // modify the condition-code flags. + // 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) : "memory", "cc"); + 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__ @@ -244,13 +257,17 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) { BOOST_TEST_REQUIRE(systemCall()); #if defined(Linux) && defined(__x86_64__) - // Soft-detect IA32 emulation: kernels without CONFIG_IA32_EMULATION - // (or with it disabled at runtime) fault on int $0x80 from a 64-bit - // process. Skip the compat-ABI assertion in that case rather than fail CI. + // 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)}; - if (i386EntryAvailable) { - BOOST_TEST_REQUIRE(i386GetuidResult >= 0); + // 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"); @@ -261,10 +278,9 @@ BOOST_AUTO_TEST_CASE(testSystemCallFilter) { ml::seccomp::CSystemCallFilter::installSystemCallFilter(); #if defined(Linux) && defined(__x86_64__) - if (i386EntryAvailable) { + if (i386CompatUsable) { // Without the seccomp_data.arch gate, i386 socketcall (102) matches // allowlisted x86_64 getuid (102) and is wrongly permitted. - // Without the arch gate this would be wrongly allowed as x86_64 getuid. BOOST_REQUIRE_EQUAL(-EACCES, i386Syscall(I386_NR_SOCKETCALL)); } #endif