diff --git a/sandbox/policy/BUILD.gn b/sandbox/policy/BUILD.gn index 1a3c7c6cf8b15..b883ef37d4c1c 100644 --- a/sandbox/policy/BUILD.gn +++ b/sandbox/policy/BUILD.gn @@ -227,6 +227,15 @@ source_set("tests") { "//testing/gtest", ] + if (is_linux || is_chromeos) { + sources += [ "linux/bpf_speech_recognition_policy_linux_unittest.cc" ] + deps += [ + "//sandbox/linux:sandbox_linux_test_utils", + "//sandbox/linux:sandbox_services_headers", + "//sandbox/linux:seccomp_bpf", + ] + } + if (is_win) { sources += [ "win/mf_cdm_sandbox_type_unittest.cc", diff --git a/sandbox/policy/linux/bpf_speech_recognition_policy_linux_unittest.cc b/sandbox/policy/linux/bpf_speech_recognition_policy_linux_unittest.cc new file mode 100644 index 0000000000000..26a97262ea90f --- /dev/null +++ b/sandbox/policy/linux/bpf_speech_recognition_policy_linux_unittest.cc @@ -0,0 +1,195 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Proof-of-concept for: BoolExpr-in-C++-ternary always-true bug in +// SpeechRecognitionProcessPolicy. The C++ conditional operator at +// bpf_speech_recognition_policy_linux.cc:38-40 uses a bpf_dsl::BoolExpr +// (== std::shared_ptr, which is never null) +// as the ternary condition. shared_ptr's contextual conversion to bool is +// therefore always true, so EvaluateSyscall(__NR_mmap) returns Allow() +// unconditionally and the compiled BPF filter for mmap is +// SECCOMP_RET_ALLOW with no inspection of arg[3]. This bypasses +// RestrictMmapFlags() and lets a compromised speech-recognition utility +// process call mmap() with normally-forbidden flags such as MAP_GROWSDOWN +// and MAP_HUGETLB. +// +// All assertions below PASS against the vulnerable code, demonstrating the +// bug. Once the bug is fixed (by using bpf_dsl::If().Else() instead of the +// C++ ternary), the assertions in PolicyReturnsUnconditionalAllowForMmap +// will FAIL, and SpeechRecognitionMmapPolicyWrapper / the BPF_TEST_C tests +// will start enforcing the intended restriction. + +#include "sandbox/policy/linux/bpf_speech_recognition_policy_linux.h" + +#include +#include +#include + +#include "build/build_config.h" +#include "sandbox/linux/bpf_dsl/bpf_dsl.h" +#include "sandbox/linux/bpf_dsl/bpf_dsl_impl.h" +#include "sandbox/linux/bpf_dsl/policy.h" +#include "sandbox/linux/seccomp-bpf-helpers/sigsys_handlers.h" +#include "sandbox/linux/seccomp-bpf-helpers/syscall_parameters_restrictions.h" +#include "sandbox/linux/seccomp-bpf/bpf_tests.h" +#include "sandbox/linux/system_headers/linux_syscalls.h" +#include "sandbox/linux/tests/unit_tests.h" +#include "sandbox/policy/linux/bpf_base_policy_linux.h" +#include "testing/gtest/include/gtest/gtest.h" + +#ifndef MAP_GROWSDOWN +#define MAP_GROWSDOWN 0x0100 +#endif +#ifndef MAP_HUGETLB +#define MAP_HUGETLB 0x40000 +#endif + +namespace sandbox { +namespace policy { +namespace { + +using bpf_dsl::Allow; +using bpf_dsl::ResultExpr; + +// ----------------------------------------------------------------------------- +// Part 1 — Static analysis of the production policy AST. +// +// SpeechRecognitionProcessPolicy::EvaluateSyscall(__NR_mmap) builds and +// returns a ResultExpr at C++ compile/run time *before* the BPF program is +// emitted. If the policy were correctly written with bpf_dsl::If().Else(), +// the returned node would be an IfThenResultExprImpl whose IsAllow() is +// false. Because of the C++ ternary bug it instead returns the +// ReturnResultExprImpl produced by Allow(), whose IsAllow() is true. That +// is the entire bypass: the kernel filter for __NR_mmap will be a single +// unconditional BPF_RET SECCOMP_RET_ALLOW. +// ----------------------------------------------------------------------------- +TEST(SpeechRecognitionPolicyMmapBypass, + PolicyReturnsUnconditionalAllowForMmap) { + SpeechRecognitionProcessPolicy speech_policy; + + ResultExpr mmap_expr = speech_policy.EvaluateSyscall(__NR_mmap); + ASSERT_TRUE(mmap_expr); + + // ********** THIS IS THE BUG ********** + // IsAllow() is only true for the Allow() leaf node; an If/Else conditional + // node returns false. The fact that this passes proves that the BPF + // program for __NR_mmap is unconditional SECCOMP_RET_ALLOW — i.e. the + // intended flag mask is never compiled into the filter. + EXPECT_TRUE(mmap_expr->IsAllow()) + << "Bug fixed: SpeechRecognitionProcessPolicy::EvaluateSyscall(" + "__NR_mmap) is no longer an unconditional Allow()."; + + // Control 1: the else-branch the author *intended* to fall through to. + // BPFBasePolicy::EvaluateSyscall(__NR_mmap) chains to RestrictMmapFlags(), + // which is an If/Else node — NOT an unconditional Allow. + BPFBasePolicy base_policy; + ResultExpr base_expr = base_policy.EvaluateSyscall(__NR_mmap); + ASSERT_TRUE(base_expr); + EXPECT_FALSE(base_expr->IsAllow()); + + // Control 2: same file, correctly-written prctl handler uses If().Else() + // and is NOT an unconditional Allow. + ResultExpr prctl_expr = speech_policy.EvaluateSyscall(__NR_prctl); + ASSERT_TRUE(prctl_expr); + EXPECT_FALSE(prctl_expr->IsAllow()); +} + +// ----------------------------------------------------------------------------- +// Part 2 — Live kernel verification under the production policy expression. +// +// We cannot apply the full SpeechRecognitionProcessPolicy in a unit test +// because its `default:` branch reaches into SandboxLinux::GetInstance() +// (broker not initialised here). Instead we wrap a trivial Policy that +// delegates *only* __NR_mmap to the real production policy and allows +// everything else. This means the seccomp program installed in the forked +// test process for __NR_mmap is *byte-identical* to the one Chrome installs +// in the speech-recognition utility process. +// +// Under this filter, mmap() with MAP_GROWSDOWN — explicitly forbidden by +// RestrictMmapFlags() and the documented intent of the baseline policy — +// succeeds. Under the intended (baseline) policy the same call delivers +// SIGSYS via CrashSIGSYS(). +// ----------------------------------------------------------------------------- +class SpeechRecognitionMmapPolicyWrapper : public bpf_dsl::Policy { + public: + SpeechRecognitionMmapPolicyWrapper() = default; + ~SpeechRecognitionMmapPolicyWrapper() override = default; + + ResultExpr EvaluateSyscall(int sysno) const override { + if (sysno == __NR_mmap) { + // Exercise the production code path. This is the same ResultExpr that + // ships in the kSpeechRecognition seccomp filter. + return real_policy_.EvaluateSyscall(sysno); + } + return Allow(); + } + + private: + SpeechRecognitionProcessPolicy real_policy_; +}; + +class BaselineMmapPolicyWrapper : public bpf_dsl::Policy { + public: + BaselineMmapPolicyWrapper() = default; + ~BaselineMmapPolicyWrapper() override = default; + + ResultExpr EvaluateSyscall(int sysno) const override { + if (sysno == __NR_mmap) { + // The else-branch the author intended. + return RestrictMmapFlags(); + } + return Allow(); + } +}; + +// VULNERABILITY DEMONSTRATION: under the speech-recognition policy +// expression, mmap with MAP_GROWSDOWN and MAP_HUGETLB is *not* blocked by +// seccomp. A compromised speech-recognition process can therefore reach the +// kernel's MAP_GROWSDOWN VMA-adjacency code (Stack Clash family) and the +// hugetlbfs page-table code paths. +BPF_TEST_C(SpeechRecognitionPolicyMmapBypass, + ForbiddenMmapFlagsReachKernel, + SpeechRecognitionMmapPolicyWrapper) { + // MAP_GROWSDOWN: forbidden by RestrictMmapFlags(); should SIGSYS under the + // intended policy. Under the buggy policy the syscall reaches the kernel. + errno = 0; + void* p = mmap(nullptr, 4096, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_GROWSDOWN, -1, 0); + // The kernel happily creates a MAP_GROWSDOWN mapping — proof the filter + // did not block it. (Under the correct policy we would never get here: + // the process would have been killed by CrashSIGSYS().) + BPF_ASSERT_NE(MAP_FAILED, p); + munmap(p, 4096); + + // MAP_HUGETLB: also forbidden by RestrictMmapFlags(). The kernel may + // reject it with ENOMEM/EPERM if no hugepages are configured, but the + // crucial point is that seccomp let the syscall *through* — it was not + // intercepted with SIGSYS. + errno = 0; + void* h = mmap(nullptr, 2 * 1024 * 1024, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0); + if (h == MAP_FAILED) { + // ENOMEM/EPERM ⇒ the kernel evaluated MAP_HUGETLB. Seccomp allowed it. + BPF_ASSERT(errno == ENOMEM || errno == EPERM || errno == EINVAL); + } else { + munmap(h, 2 * 1024 * 1024); + } +} + +// CONTROL: under the *intended* baseline restriction, the very same +// MAP_GROWSDOWN call is caught by seccomp and the process dies with the +// canonical "**CRASHING**:seccomp-bpf failure in syscall" SIGSYS message. +// This proves the difference is the policy, not the environment. +BPF_DEATH_TEST_C(SpeechRecognitionPolicyMmapBypass, + BaselineBlocksGrowsdown, + DEATH_SEGV_MESSAGE(GetErrorMessageContentForTests()), + BaselineMmapPolicyWrapper) { + mmap(nullptr, 4096, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_GROWSDOWN, -1, 0); + // Not reached — CrashSIGSYS() runs first. +} + +} // namespace +} // namespace policy +} // namespace sandbox