diff --git a/Documentation/admin-guide/LSM/landlock.rst b/Documentation/admin-guide/LSM/landlock.rst index 314052bbeb0a..2998552bb730 100644 --- a/Documentation/admin-guide/LSM/landlock.rst +++ b/Documentation/admin-guide/LSM/landlock.rst @@ -1,12 +1,13 @@ .. SPDX-License-Identifier: GPL-2.0 .. Copyright © 2025 Microsoft Corporation +.. Copyright © 2026 Cloudflare, Inc. ================================ Landlock: system-wide management ================================ :Author: Mickaël Salaün -:Date: June 2026 +:Date: August 2026 Landlock can leverage the audit framework to log events. @@ -52,6 +53,7 @@ AUDIT_LANDLOCK_ACCESS - fs.refer (ABI 2+) - fs.truncate (ABI 3+) - fs.ioctl_dev (ABI 5+) + - fs.resolve_unix (ABI 9+) **net.*** - Network access rights (ABI 4+): - net.bind_tcp - TCP port binding was denied @@ -181,11 +183,113 @@ filters to limit noise with two complementary ways: programs, - or with audit rules (see :manpage:`auditctl(8)`). +Tracepoints +=========== + +Landlock also provides tracepoints as an alternative to audit for +debugging and observability. Tracepoints fire unconditionally, +independent of audit configuration, ``audit_enabled``, and domain log +flags. This makes them suitable for always-on monitoring with eBPF or +for ad-hoc debugging with ``trace-pipe``. + +See Documentation/trace/events-landlock.rst for the complete event +reference: the full event list, how to enable events and read their +output, the field formats, the ``check_rule`` interpretation guide, +worked event samples, ftrace filtering, and eBPF access. + +.. _landlock_observability: + +When to use tracing vs audit +----------------------------- + +Audit and tracing both help diagnose Landlock policy issues: + +**Audit** records denied accesses with the blockers, domain, and object +identification (path, port). Audit is the standard Linux mechanism for +security events, with a stable record format that is well established +and already supported by log management systems, SIEM platforms, and EDR +solutions. Audit is always active when the kernel is built with audit +support, filtered by the Landlock log flags to reduce noise in +production, and designed for long-term security monitoring and +compliance. + +**Tracing** provides deeper introspection for policy debugging. In +addition to denied accesses, trace events cover the complete lifecycle +of Landlock objects (rulesets, domains) and intermediate rule matching +during access checks. Trace events are disabled by default (zero +overhead) and fire unconditionally. eBPF +programs attached to trace events can access the full kernel context +(ruleset rules, domain hierarchy, process credentials) via BTF, enabling +richer analysis than the flat fields in audit records. For example, an +eBPF-based live monitoring tool can correlate creation, rule-addition, +and denial events to build a real-time view of all active Landlock +domains and their policies. However, BTF-based access depends on +internal kernel struct layouts which have no stability guarantee. CO-RE +(Compile Once, Run Everywhere) provides best-effort field relocation. +The ftrace printk format is also not a stable ABI, but is +self-describing via the per-event ``format`` file, allowing tools to +adapt dynamically. + +Observability guarantees and limitations +----------------------------------------- + +Both audit records and trace events are emitted for every denied access, +with these exceptions: + +- **Landlock log flags** (audit only): ``LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF``, + ``LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON``, and + ``LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF`` control which denials + generate audit records. Trace events fire regardless of these flags. + +- **NOAUDIT hooks**: Some LSM hooks suppress logging for speculative + permission probes (e.g., reading ``/proc//status`` uses + ``PTRACE_MODE_NOAUDIT``). When NOAUDIT is set, neither audit records + nor trace events are emitted, and the denial is not counted in + ``denials``. The denial is still enforced. This avoids performance + overhead and noise from speculative probes that test permissions + without performing an actual access. + +- **Audit rate limiting**: The audit subsystem may silently drop records + when the audit queue is full. Trace events are not rate-limited. + +- **Tracepoint disabled**: When a trace event is disabled (the default + state), the tracepoint is a no-op with zero overhead. + +When both audit and tracing are active, every denial emits a trace event, +and a denial that the domain's log policy selects additionally produces an +audit record (subject to the Landlock log flags). The ``denials`` count in +``free_domain`` events is incremented for every denial regardless of the +log flags, so it can exceed the number of audit records (which the log +flags and audit-side rate-limiting or exclude rules may suppress). + +.. _landlock_observability_security: + +Observability security considerations +--------------------------------------- + +Both audit records and trace events expose information about all +Landlock-sandboxed processes on the system, including filesystem paths +being accessed, network ports, and process identities. System +administrators must ensure that access to audit logs (controlled by the +audit subsystem configuration) and to trace events (requiring +``CAP_SYS_ADMIN`` or ``CAP_BPF`` + ``CAP_PERFMON``) is restricted to +trusted users. + +eBPF programs attached to Landlock trace events have access to the full +kernel context of each event (ruleset rules, domain hierarchy, process +credentials) via BTF, exposing sensitive state about every sandboxed +process. Restrict this access to trusted users, as for the audit logs. + +Audit logs and kernel trace events require elevated privileges and are +system-wide; they are not designed for per-sandbox unprivileged +monitoring. + Additional documentation ======================== * `Linux Audit Documentation`_ * Documentation/userspace-api/landlock.rst +* Documentation/trace/events-landlock.rst * Documentation/security/landlock.rst * https://landlock.io diff --git a/Documentation/security/landlock.rst b/Documentation/security/landlock.rst index c5186526e76f..2d6e1076484e 100644 --- a/Documentation/security/landlock.rst +++ b/Documentation/security/landlock.rst @@ -1,13 +1,14 @@ .. SPDX-License-Identifier: GPL-2.0 .. Copyright © 2017-2020 Mickaël Salaün .. Copyright © 2019-2020 ANSSI +.. Copyright © 2026 Cloudflare, Inc. ================================== Landlock LSM: kernel documentation ================================== :Author: Mickaël Salaün -:Date: March 2026 +:Date: August 2026 Landlock's goal is to create scoped access-control (i.e. sandboxing). To harden a whole system, this feature should be available to any process, @@ -177,11 +178,46 @@ makes the reasoning much easier and helps avoid pitfalls. .. kernel-doc:: security/landlock/domain.h :identifiers: +Denial logging +============== + +Access denials are logged through two independent channels: audit +records and tracepoints. Both are managed by the common denial +framework in ``log.c``, compiled under ``CONFIG_SECURITY_LANDLOCK_LOG`` +(automatically selected by ``CONFIG_AUDIT`` or ``CONFIG_TRACEPOINTS``). + +Audit records respect audit configuration, the domain's Landlock log +flags, and ``LANDLOCK_LOG_DISABLED``. Tracepoints fire unconditionally, +independent of these settings. The denial counter (``num_denials``) is +always incremented regardless of logging configuration. + +Each denial tracepoint carries a ``logged`` field reporting the +audit-logging verdict: whether the denial would be written to the audit +log if audit were configured and active. This verdict is the same +whether or not the kernel is built with audit support, so a +tracepoints-only build reports the selection audit would make. A quiet +rule (``LANDLOCK_ADD_RULE_QUIET`` with the access in the ``quiet_*`` +fields of ``struct landlock_ruleset_attr``) suppresses logging by +setting ``logged=0`` the same way. + +See Documentation/admin-guide/LSM/landlock.rst for audit record format, +tracepoint usage, and filtering examples. + +.. kernel-doc:: security/landlock/log.h + :identifiers: + +Trace events +------------ + +See Documentation/trace/events-landlock.rst for trace event usage and format +details; the full event reference lives there and is not duplicated here. + Additional documentation ======================== * Documentation/userspace-api/landlock.rst * Documentation/admin-guide/LSM/landlock.rst +* Documentation/trace/events-landlock.rst * https://landlock.io .. Links diff --git a/Documentation/trace/events-landlock.rst b/Documentation/trace/events-landlock.rst new file mode 100644 index 000000000000..af9267cca47d --- /dev/null +++ b/Documentation/trace/events-landlock.rst @@ -0,0 +1,326 @@ +.. SPDX-License-Identifier: GPL-2.0 +.. Copyright © 2026 Cloudflare, Inc. + +===================== +Landlock Trace Events +===================== + +:Author: Mickaël Salaün +:Date: August 2026 + +Landlock emits trace events for sandbox lifecycle operations and access +denials. These events can be consumed by ftrace (for human-readable +trace output and filtering) and by eBPF programs (for programmatic +introspection via BTF). + +User space documentation can be found here: +Documentation/userspace-api/landlock.rst + +.. warning:: + + Landlock trace events, like audit records, expose sensitive + information about all sandboxed processes on the system. See + :ref:`landlock_observability_security` for security considerations + and privilege requirements. + +Event overview +============== + +Landlock trace events are organized in four categories: + +**Syscall events** are emitted during Landlock system calls: + +- ``landlock_create_ruleset``: a new ruleset is created +- ``landlock_add_rule_fs``: a filesystem rule is added to a ruleset +- ``landlock_add_rule_net``: a network port rule is added to a ruleset +- ``landlock_create_domain``: a new domain is created from a ruleset +- ``landlock_enforce_domain``: a domain is enforced on a thread + +**Denial events** are emitted when an access is denied: + +- ``landlock_deny_access_fs``: filesystem access denied +- ``landlock_deny_access_net``: network access denied +- ``landlock_deny_ptrace``: ptrace access denied +- ``landlock_deny_scope_signal``: signal delivery denied +- ``landlock_deny_scope_abstract_unix_socket``: abstract unix socket + access denied + +**Rule evaluation events** are emitted during rule matching: + +- ``landlock_check_rule_fs``: a filesystem rule is evaluated +- ``landlock_check_rule_net``: a network port rule is evaluated + +**Lifecycle events**: + +- ``landlock_free_domain``: a domain is freed +- ``landlock_free_ruleset``: a ruleset is freed + +Enabling events +=============== + +Enable all Landlock events:: + + echo 1 > /sys/kernel/tracing/events/landlock/enable + +Enable a specific event:: + + echo 1 > /sys/kernel/tracing/events/landlock/landlock_deny_access_fs/enable + +Read the trace output:: + + cat /sys/kernel/tracing/trace_pipe + +Event samples +============= + +A fully unprivileged program is sandboxed so that it can still run (its +binary and shared libraries stay readable) and write only ``/tmp``, then +it is denied reading ``/etc/passwd``, which lies outside its read-only +set. ``/etc/passwd`` is world-readable, so the denial comes solely from +Landlock, not from regular file permissions:: + + $ cd /sys/kernel/tracing/events/landlock/ + $ echo 1 | tee landlock_{create_ruleset,create_domain,enforce_domain,deny_access_fs,free_domain}/enable >/dev/null + $ LC_ALL=C LL_FS_RO=/usr:/lib:/lib64:/bin:/etc/ld.so.cache LL_FS_RW=/tmp \ + ./sandboxer cat /etc/passwd + $ cat /sys/kernel/tracing/trace_pipe + cat-127 [...] landlock_create_ruleset: ruleset=195cc6b76.0 handled_fs=execute|write_file|read_file|read_dir|remove_dir|remove_file|make_char|make_dir|make_reg|make_sock|make_fifo|make_block|make_sym|refer|truncate|ioctl_dev|resolve_unix handled_net= scoped= + cat-127 [...] landlock_create_domain: domain=195cc6b7c parent=0 ruleset=195cc6b76.6 + cat-127 [...] landlock_enforce_domain: domain=195cc6b7c complete=1 process_wide=1 no_new_privs=1 + cat-127 [...] landlock_deny_access_fs: domain=195cc6b7c same_exec=0 logged=0 blockers=read_file dev=0:17 ino=5901179 path=/etc/passwd + kworker/0:1-11 [...] landlock_free_domain: domain=195cc6b7c denials=1 + +The ``[...]`` replaces the ftrace CPU, flags, and timestamp columns. The +first four events share the ``cat`` command name and PID because the +sandboxer replaces itself with ``cat`` via ``execve()`` before the +denial, and ftrace resolves a recorded PID to its latest command name. +``landlock_free_domain`` fires later from a kworker thread, so it carries +that thread's name instead. + +Here ``logged=0`` shows that audit would not record this cross-execution +denial under the default flags, yet the ``deny_access_fs`` event still +appears. + +Differences from audit records +============================== + +Tracepoints and audit records both log Landlock denials, but differ +in some field formats: + +- **Paths**: Most filesystem tracepoints resolve the path with + ``d_absolute_path()`` (namespace-independent absolute paths), while + mount-topology denials that carry only a dentry use ``dentry_path_raw()``. + Audit uses ``d_path()`` (relative to the process's chroot). A resolution + failure is reported as ````, ````, or ````. + Path-based tracepoint output is deterministic regardless of the tracer's + mount namespace. + +- **Device names**: Tracepoints use numeric ``dev=:``. + Audit uses string ``dev=""``. Numeric format is more precise + for machine parsing. + +- **Denied access field**: The ``deny_access_fs`` and ``deny_access_net`` + tracepoints use the ``blockers=`` field name (same as audit). Both + render the blocked access rights as names: audit prefixes the category + and separates with commas (e.g., ``blockers=fs.read_file``), while the + tracepoints omit the category (carried by the event name) and separate + with ``|`` (e.g., ``blockers=read_file``). Scope and ptrace + tracepoints omit ``blockers`` because the event name identifies the + denial type. + +- **Scope and ptrace target names**: Tracepoints use role-specific field + names (``tracee_pid``, ``target_pid``, ``peer_pid``) that reflect the + semantic of each event. Audit uses generic names (``opid``, ``ocomm``) + because the audit log format is not event-type-specific. + +- **Process name**: The ptrace and signal denial tracepoints include the + role-prefixed ``tracee_comm=`` and ``target_comm=`` labels in the + printk output for stateless consumers (each matches its sibling + ``tracee_pid=``/``target_pid=`` field). eBPF consumers can read + ``comm`` directly from the task_struct via BTF. The ``comm`` value is + treated as untrusted input and escaped in the trace text output so it + cannot inject field separators or control characters. + +- **Other party's domain**: A scope or ptrace denial compares the + subject's denying domain (``domain=``, always the enforcing domain and + never the current task) with the other party's domain, so these + tracepoints also report the other party's domain as a scalar ID: + ``tracee_domain=`` (ptrace), ``target_domain=`` (signal), and + ``peer_domain=`` (abstract unix socket). It is ``0`` when the other + party is unsandboxed, and otherwise a domain ID that a consumer resolves + against the ``landlock_create_ruleset`` and ``landlock_create_domain`` + events it recorded. Because a scope or ptrace verdict is decided by + comparing the two domains, resolving both the subject ``domain=`` and + this other-party ID against those lifecycle events lets a consumer + verify or reproduce the verdict by redoing the same two-domain + comparison, rather than only noting which boundary was crossed. Audit + records do not carry the other party's domain. + +Ruleset versioning +================== + +Syscall events include a ruleset version (``ruleset=.``) +that tracks the number of rules added to the ruleset. The version is +incremented on each ``landlock_add_rule()`` call and frozen at +``landlock_restrict_self()`` time. This enables trace consumers to +correlate a domain with the exact set of rules it was created from. + +Domain enforcement +================== + +The whole-process-enforced guarantee (``complete=1 && process_wide=1``) +is the observable outcome of a successful +``landlock_restrict_self(..., LANDLOCK_RESTRICT_SELF_TSYNC)``; see the +thread synchronization section of +Documentation/userspace-api/landlock.rst. + +The Landlock events and the generic syscall tracepoints are +complementary: the Landlock events expose the *semantic effect* of an +operation (the domain, its scope, the resulting ``no_new_privs`` state), +while ``raw_syscalls:sys_enter``/``sys_exit`` (or the per-syscall +``syscalls:sys_{enter,exit}_landlock_*`` under +``CONFIG_FTRACE_SYSCALLS``) expose the *raw API* -- the exact +``landlock_restrict_self()`` flags, arguments, and return value. +Correlate them by thread; a ``LANDLOCK_RESTRICT_SELF_TSYNC`` operation +also enforces the domain on the sibling threads, whose +``landlock_enforce_domain`` events fire in each sibling's own context +rather than the caller's, so correlate those to the syscall by domain ID. + +Interpreting check_rule events +============================== + +The ``check_rule_fs`` and ``check_rule_net`` events expose the per-layer +rule evaluation, which is useful for understanding *why* a specific +access is allowed or denied. + +.. warning:: + + These events fire on the access-check hot path, once per matching rule + per check. On a busy sandboxed workload this can be very high + frequency. Enable them only for targeted debugging, ideally combined + with an ftrace filter (for example on ``ino`` or ``domain_id``), and + expect tracing overhead while they are enabled. + +Two output fields carry the evaluation: + +- ``access_request=`` is the set of access rights being evaluated against the + rule, rendered as ``|``-separated names. For most checks this is the + access the operation requested. For filesystem ``rename`` and ``link`` + double-checks it is the domain's full handled mask, because those + operations re-evaluate every handled right. + +- ``grants=`` is a per-layer breakdown of the requested rights that this + rule grants, in the form ``{,,...}``: + + - The braces wrap one comma-separated group per domain layer, ordered + from the outermost (least nested) sandbox layer to the innermost. + - Each group lists the requested rights the rule grants at that layer, + joined by ``|``. + - An empty group (for example the middle layer in + ``{read_file,,read_file}``) means the rule grants none of the + requested rights at that layer. + +A Landlock domain allows an access only when, for every requested right, +every layer that handles that right has at least one matching rule +granting it. A single ``check_rule`` event therefore shows one rule's +contribution, not the final decision: + +- If a right appears in every layer's group, this rule alone is + sufficient to allow that right. +- If a right is missing from some layer's group, that layer must grant it + through another matching rule, or the right is denied and appears in the + ``blockers=`` field of the corresponding ``deny_access`` event. + +To reconstruct the decision for an object, aggregate the ``grants=`` +groups of all ``check_rule`` events emitted for that object during the +check. + +.. note:: + + Because a verdict requires aggregating ``grants=`` across all matching + rules of one access check, a stateless ftrace filter on a single + ``check_rule`` event cannot distinguish an allowed access from a + denied one. + +For example, a program sandboxed with read and execute access to the +whole filesystem reads ``/etc/passwd``; both the ``execve()`` and the +read match the rule covering ``/`` (inode 2), so ``check_rule_fs`` fires +with the requested rights intersected against what that rule grants. +The ``access_request=`` mask includes ``truncate`` because the file-open hook +evaluates that optional right alongside the required access, but the +rule does not grant it, so ``truncate`` never appears in ``grants=``:: + + cat-127 [...] landlock_check_rule_fs: domain=1e40cb56f access_request=execute|read_file|truncate dev=0:17 ino=2 grants={execute|read_file} + cat-127 [...] landlock_check_rule_fs: domain=1e40cb56f access_request=read_file|truncate dev=0:17 ino=2 grants={read_file} + +The ``[...]`` replaces the ftrace CPU, flags, and timestamp columns. A +single ``grants=`` group means the enforcing domain has one layer. With +two nested sandboxes that each grant the same rights, the rule spans both +layers, so ``grants=`` has one group per layer:: + + cat-128 [...] landlock_check_rule_fs: domain=184788b52 access_request=execute|read_file|truncate dev=0:17 ino=2 grants={execute|read_file,execute|read_file} + cat-128 [...] landlock_check_rule_fs: domain=184788b52 access_request=read_file|truncate dev=0:17 ino=2 grants={read_file,read_file} + +eBPF access +=========== + +eBPF programs attached via ``BPF_RAW_TRACEPOINT`` can access the +tracepoint arguments directly through BTF. The arguments include both +standard kernel objects and Landlock-internal objects: + +- Standard kernel objects (``struct task_struct``, ``struct sock``, + ``struct path``, ``struct dentry``) can be used with existing BPF + helpers. +- Landlock-internal objects (``struct landlock_domain``, + ``struct landlock_ruleset``, ``struct landlock_rule``, + ``struct landlock_hierarchy``) can be read via ``BPF_CORE_READ``. + Internal struct layouts may change between kernel versions; use CO-RE + for field relocation. + +A stateful eBPF program observes the full event stream and maintains +per-domain state in BPF maps: + +1. On ``landlock_create_domain``: record the domain ID and parent (the + per-domain Landlock log flags are not event fields; read them from + ``struct landlock_hierarchy`` via BTF if needed). +2. On ``landlock_enforce_domain``: record the sandboxed thread under the + ``domain=`` key (join to the ``create_domain`` recorded in step 1), + building the per-domain thread set; filter ``complete==1`` for a + one-event-per-operation summary. +3. On ``landlock_deny_access_*``: look up the domain, decide whether + to count, alert, or ignore the denial based on custom policy. +4. On ``landlock_free_domain``: clean up the per-domain state, log + final statistics. + +This approach requires no kernel modification and no Landlock-specific +BPF helpers. The Landlock IDs serve as correlation keys across events. + +Audit filtering equivalence +=========================== + +The ``logged`` field reflects the domain's log policy but not the global +``audit_enabled`` toggle, so it does not change when audit is turned on +or off. When audit is enabled, ``logged==1`` selects the denials the +domain submits to audit (audit-side rate-limiting and exclude rules may +still drop some), so a stateless ftrace filter can select them:: + + # Show only denials that audit would also log: + echo 'logged==1' > \ + /sys/kernel/tracing/events/landlock/landlock_deny_access_fs/filter + +Event reference +=============== + +.. kernel-doc:: include/trace/events/landlock.h + :doc: Landlock trace events + +.. kernel-doc:: include/trace/events/landlock.h + :internal: + +Additional documentation +======================== + +* Documentation/userspace-api/landlock.rst +* Documentation/admin-guide/LSM/landlock.rst +* Documentation/security/landlock.rst +* https://landlock.io diff --git a/Documentation/trace/index.rst b/Documentation/trace/index.rst index 5d9bf4694d5d..f4058e8e92e3 100644 --- a/Documentation/trace/index.rst +++ b/Documentation/trace/index.rst @@ -54,6 +54,7 @@ applications. events-power events-nmi events-msr + events-landlock events-pci events-pci-controller boottime-trace diff --git a/Documentation/userspace-api/landlock.rst b/Documentation/userspace-api/landlock.rst index 5a63d4476c1c..84cb7bf6b3ed 100644 --- a/Documentation/userspace-api/landlock.rst +++ b/Documentation/userspace-api/landlock.rst @@ -8,7 +8,7 @@ Landlock: unprivileged access control ===================================== :Author: Mickaël Salaün -:Date: June 2026 +:Date: August 2026 The goal of Landlock is to enable restriction of ambient rights (e.g. global filesystem or network access) for a set of processes. Because Landlock @@ -250,7 +250,8 @@ similar backwards compatibility check is needed for the restrict flags __u32 restrict_flags = LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON | - LANDLOCK_RESTRICT_SELF_TSYNC; + LANDLOCK_RESTRICT_SELF_TSYNC | + LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; switch (abi) { case 1 ... 6: /* Removes logging flags for ABI < 7 */ @@ -269,16 +270,37 @@ similar backwards compatibility check is needed for the restrict flags * children (and not for all threads, including parents and siblings). */ restrict_flags &= ~LANDLOCK_RESTRICT_SELF_TSYNC; + __attribute__((fallthrough)); + case 8 ... 10: + /* Removes no new privs flag for ABI < 11 */ + restrict_flags &= ~LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; } The next step is to restrict the current thread from gaining more privileges -(e.g. through a SUID binary). We now have a ruleset with the first rule -allowing read and execute access to ``/usr`` while denying all other handled -accesses for the filesystem, and two more rules allowing DNS queries. +(e.g. through a SUID binary). For unprivileged processes, setting the +no_new_privs attribute is required by Landlock. + +Processes with ``CAP_SYS_ADMIN`` in their namespace can enforce a ruleset +without setting no_new_privs, but leaving no_new_privs unset is risky even +when Landlock does not require this attribute: sandboxed processes could +still execute set-user-ID, set-group-ID or file-capability binaries, which +would then run with elevated privileges while being restricted by a Landlock +domain they may not expect, making them potential confused deputies. +no_new_privs should only be left unset if such a privilege transition is +expected. + +We now have a ruleset with the first rule allowing read and execute access to +``/usr`` while denying all other handled accesses for the filesystem, and two +more rules allowing DNS queries. .. code-block:: c - if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { + /* + * If the ABI > 10, we can tie setting no_new_privs with successful ruleset + * enforcement and skip the manual prctl(PR_SET_NO_NEW_PRIVS, ...) call. + */ + if (!(restrict_flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS) && + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("Failed to restrict privileges"); close(ruleset_fd); return 1; @@ -556,6 +578,9 @@ in the running kernel. .. kernel-doc:: security/landlock/errata/abi-1.h :doc: erratum_3 +.. kernel-doc:: security/landlock/errata/abi-1.h + :doc: erratum_4 + How to check for errata ~~~~~~~~~~~~~~~~~~~~~~~ @@ -737,6 +762,8 @@ Starting with the Landlock ABI version 6, it is possible to restrict :manpage:`signal(7)` sending by setting ``LANDLOCK_SCOPE_SIGNAL`` to the ``scoped`` ruleset attribute. +.. _landlock_log_flags: + Logging (ABI < 7) ----------------- @@ -744,8 +771,13 @@ Starting with the Landlock ABI version 7, it is possible to control logging of Landlock audit events with the ``LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF``, ``LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON``, and ``LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF`` flags passed to -sys_landlock_restrict_self(). See Documentation/admin-guide/LSM/landlock.rst -for more details on audit. +sys_landlock_restrict_self(). These flags control audit record generation. +Landlock tracepoints are not affected by these flags and always fire when +enabled, providing an alternative observability channel for debugging and +monitoring. See Documentation/admin-guide/LSM/landlock.rst for more +details on audit and tracepoints, and +Documentation/trace/events-landlock.rst for the complete trace event +reference. Thread synchronization (ABI < 8) -------------------------------- @@ -789,6 +821,19 @@ when at least one sys_landlock_add_rule() call is made for it with the ``LANDLOCK_ADD_RULE_QUIET`` flag, additional add-rule calls for the same object without this flag do not clear it. +no_new_privs flag (ABI < 11) +---------------------------- + +Starting with the Landlock ABI version 11, sys_landlock_restrict_self() +accepts the ``LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS`` flag, which sets the +no_new_privs attribute of the calling thread only once the enforcement of +the ruleset succeeded: no_new_privs is set if and only if the call +succeeds. This removes the need for a prior :manpage:`prctl(2)` +``PR_SET_NO_NEW_PRIVS`` call (or ``CAP_SYS_ADMIN`` use). When combined +with ``LANDLOCK_RESTRICT_SELF_TSYNC``, no_new_privs is set on all threads +of the process. As explained in the tutorial above, leaving no_new_privs +unset is risky even when Landlock does not require it. + .. _kernel_support: Kernel support @@ -887,6 +932,7 @@ Additional documentation ======================== * Documentation/admin-guide/LSM/landlock.rst +* Documentation/trace/events-landlock.rst * Documentation/security/landlock.rst * https://landlock.io diff --git a/MAINTAINERS b/MAINTAINERS index 8e4f4b4e6cd1..335d20584754 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -14638,8 +14638,11 @@ W: https://landlock.io T: git https://git.kernel.org/pub/scm/linux/kernel/git/mic/linux.git F: Documentation/admin-guide/LSM/landlock.rst F: Documentation/security/landlock.rst +F: Documentation/trace/events-landlock.rst F: Documentation/userspace-api/landlock.rst F: fs/ioctl.c +F: include/linux/landlock.h +F: include/trace/events/landlock.h F: include/uapi/linux/landlock.h F: samples/landlock/ F: security/landlock/ diff --git a/include/linux/landlock.h b/include/linux/landlock.h new file mode 100644 index 000000000000..004cbd0b9298 --- /dev/null +++ b/include/linux/landlock.h @@ -0,0 +1,56 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * Landlock - Public types and definitions + * + * Copyright © 2016-2026 Mickaël Salaün + * Copyright © 2026 Cloudflare, Inc. + */ + +#ifndef _LINUX_LANDLOCK_H +#define _LINUX_LANDLOCK_H + +#include +#include + +/* + * Access-right and scope names, shared between the audit records (get_blocker() + * in security/landlock/audit.c) and the trace events + * (include/trace/events/landlock.h). A consumer defines + * _LANDLOCK_NAME_ENTRY(mask, name) before expanding a list and undefines it + * afterwards: audit maps each entry to a "[bit] = name" slot for O(1) lookup, + * the trace events map it to a __print_flags() { mask, name } pair. The bit + * value lives only in the LANDLOCK_* UAPI constant each entry references. + * Names are unprefixed; audit prepends the "fs."/"net."/"scope." category. + */ +#define _LANDLOCK_ACCESS_FS_NAMES \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_EXECUTE, "execute"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_WRITE_FILE, "write_file"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_READ_FILE, "read_file"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_READ_DIR, "read_dir"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_REMOVE_DIR, "remove_dir"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_REMOVE_FILE, "remove_file"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_CHAR, "make_char"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_DIR, "make_dir"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_REG, "make_reg"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_SOCK, "make_sock"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_FIFO, "make_fifo"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_BLOCK, "make_block"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_MAKE_SYM, "make_sym"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_REFER, "refer"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_TRUNCATE, "truncate"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_IOCTL_DEV, "ioctl_dev"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_FS_RESOLVE_UNIX, "resolve_unix") + +#define _LANDLOCK_ACCESS_NET_NAMES \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_NET_BIND_TCP, "bind_tcp"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_NET_CONNECT_TCP, "connect_tcp"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_NET_BIND_UDP, "bind_udp"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP, \ + "connect_send_udp") + +#define _LANDLOCK_SCOPE_NAMES \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, \ + "abstract_unix_socket"), \ + _LANDLOCK_NAME_ENTRY(LANDLOCK_SCOPE_SIGNAL, "signal") + +#endif /* _LINUX_LANDLOCK_H */ diff --git a/include/trace/events/landlock.h b/include/trace/events/landlock.h new file mode 100644 index 000000000000..f82588f6f90e --- /dev/null +++ b/include/trace/events/landlock.h @@ -0,0 +1,965 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * Copyright © 2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. + */ + +#undef TRACE_SYSTEM +#define TRACE_SYSTEM landlock + +#if !defined(_TRACE_LANDLOCK_H) || defined(TRACE_HEADER_MULTI_READ) +#define _TRACE_LANDLOCK_H + +#include +#include +#include +#include +#include +#include + +struct dentry; +struct landlock_domain; +struct landlock_hierarchy; +struct landlock_rule; +struct landlock_ruleset; +struct path; +struct sock; +struct task_struct; + +#ifdef CREATE_TRACE_POINTS + +/* + * Escapes @len bytes of an untrusted string into the trace sequence @p so it + * cannot inject field separators or control characters into the ftrace text + * output, and can be unambiguously recovered. Called from the TP_printk() of + * the tracepoints that expose paths and process names. @len is passed by the + * caller (rather than derived with strlen()) so a name that is not + * NUL-terminated or carries embedded NUL bytes (an abstract socket name) is + * escaped in full instead of being truncated at the first NUL. + * + * Return: a pointer into @p's buffer, or NULL if @src is NULL or the buffer is + * exhausted (normal when the trace buffer is full). + */ +static inline const char * +__trace_print_untrusted_str(struct trace_seq *p, const char *src, size_t len) +{ + int escaped_size; + char *buf; + size_t buf_size = seq_buf_get_buf(&p->seq, &buf); + const char *ret = trace_seq_buffer_ptr(p); + + /* Buffer exhaustion is normal when the trace buffer is full. */ + if (!src || buf_size == 0) + return NULL; + + escaped_size = + string_escape_mem(src, len, buf, buf_size, + ESCAPE_SPACE | ESCAPE_SPECIAL | ESCAPE_NAP | + ESCAPE_APPEND | ESCAPE_OCTAL, + " ='\"\\"); + if (unlikely(escaped_size >= buf_size)) { + /* We need some room for the final '\0'. */ + seq_buf_set_overflow(&p->seq); + p->full = 1; + return NULL; + } + seq_buf_commit(&p->seq, escaped_size); + trace_seq_putc(p, 0); + return ret; +} + +/* + * Fills the dense per-domain-layer array layers (one access mask per layer, + * indexed by level - 1) from rule's sparse layer stack, keeping only the + * requested rights (access_request). Layers with no matching rule entry get + * a zero mask. Shared by the check_rule_fs and check_rule_net events. + * + * rule->layers is sorted by ascending level, with levels in the domain's + * [1, num_layers] range (see landlock_merge_ruleset()), so every entry maps + * to a slot. A leftover entry would be a malformed rule; the zero-filled + * slots keep the output and the array bounds safe regardless. + */ +static inline void +__trace_landlock_fill_layers(access_mask_t *const layers, + const size_t num_layers, + const struct landlock_rule *const rule, + const access_mask_t access_request) +{ + size_t i = 0; + + for (size_t level = 1; level <= num_layers; level++) { + access_mask_t grants = 0; + + if (i < rule->num_layers && level == rule->layers[i].level) { + grants = rule->layers[i].access & access_request; + i++; + } + layers[level - 1] = grants; + } + + /* A leftover entry means an out-of-range or unsorted rule level. */ + WARN_ON_ONCE(i < rule->num_layers); +} + +/* + * Renders the dense per-domain-layer access array as symbolic flag names for + * the grants field: layers wrapped in "{}", flags within a layer joined by + * "|", layers separated by ",", an empty layer rendered as nothing. + * Open-codes the flag walk because trace_print_flags_seq() NUL-terminates per + * call and so cannot be chained into a single field. The shared names table + * covers every access right, so masked bits are always named. Returns the + * trace_seq position like __print_flags(). + */ +static inline const char *__trace_landlock_print_layers( + struct trace_seq *p, const access_mask_t *const layers, + const size_t num_layers, const struct trace_print_flags *const names, + const size_t names_size) +{ + const char *const ret = trace_seq_buffer_ptr(p); + + trace_seq_putc(p, '{'); + for (size_t i = 0; i < num_layers; i++) { + access_mask_t mask = layers[i]; + bool first = true; + + if (i) + trace_seq_putc(p, ','); + for (size_t j = 0; mask && j < names_size; j++) { + if ((mask & names[j].mask) != names[j].mask) + continue; + if (!first) + trace_seq_putc(p, '|'); + trace_seq_puts(p, names[j].name); + mask &= ~names[j].mask; + first = false; + } + } + trace_seq_putc(p, '}'); + trace_seq_putc(p, 0); + return ret; +} + +#endif /* CREATE_TRACE_POINTS */ + +/* clang-format off */ + +/* Maps a shared _LANDLOCK_*_NAMES entry to a __print_flags() pair. */ +#define _LANDLOCK_NAME_ENTRY(mask, name) { mask, name } + +/** + * DOC: Landlock trace events + * + * These guarantees and constraints hold for every Landlock tracepoint. + * A new tracepoint must uphold them, and an eBPF consumer can rely on + * them. + * + * Decision context + * ~~~~~~~~~~~~~~~~ + * + * A denial event, together with the lifecycle events, exposes the full + * set of inputs the verdict consumed, so a consumer that tracked domain + * creation (landlock_create_ruleset, landlock_create_domain) can verify + * or reproduce the Landlock decision rather than merely observe it + * happened. In who/what/why terms: who is the denying domain (the domain + * field, always the subject that enforced the policy, never the current + * task), what is the operation and its object, and why is every other + * input the verdict weighed. + * + * Lifecycle consistency + * ~~~~~~~~~~~~~~~~~~~~~~ + * + * Lifecycle events are balanced: a creation event always has a matching + * deallocation event and vice versa, so an eBPF program can model object + * lifetimes from the trace stream without reconciliation logic. A creation + * event fires while the object is still private to the calling thread + * (landlock_create_ruleset fires before the ruleset's file descriptor is + * installed, so it cannot race a concurrent :manpage:`close(2)`); if fd + * installation later fails and the ruleset is freed, free_ruleset still + * fires, keeping the pair balanced. The domain pair (create_domain and + * free_domain) is balanced the same way: create_domain fires when the + * domain is created (under the ruleset lock, before thread-sync), and + * free_domain fires when it is freed. A rare thread-sync failure aborts + * the just-created domain, which then emits both events (its creation, then + * an immediate free). Denial events fire only for denials that actually + * happen. + * + * Pointer access + * ~~~~~~~~~~~~~~ + * + * All pointer arguments in TP_PROTO are guaranteed non-NULL by the + * caller, but pointers reached through them may still be NULL (e.g., + * hierarchy->parent at a root domain) and must be checked. eBPF programs + * read these pointers via BTF for richer introspection than the + * TP_STRUCT__entry fields, which serve TP_printk display only. + * + * Mutable object pointers are passed while the caller holds the object's + * lock, so TP_fast_assign and a BTF reader see the exact object the event + * reports, a snapshot no concurrent writer can change: add_rule holds the + * modified ruleset's lock, and create_domain holds the ruleset lock across + * the emission (before the thread-sync wait) so the inspected ruleset is + * the one merged into the domain. Objects immutable at the emission site + * (a domain after creation, a hierarchy at its last reference) need no + * lock. A few values that no held lock protects are a best-effort + * lockless snapshot instead: a task's comm, and the deny_access_net struct + * sock (whose network hook holds no socket lock), matching how the sched + * and signal trace events sample comm. + * + * Field encoding + * ~~~~~~~~~~~~~~ + * + * Fields that mirror the Landlock UAPI use the same C types and endianness + * (e.g. network ports are __u64 in host endianness, like + * landlock_net_port_attr.port). Per-event details, such as where a value + * is byte-swapped, live in the field's own kdoc. + * + * Rule-check fields + * ~~~~~~~~~~~~~~~~~ + * + * The check_rule events fire during an access check, once per matching + * rule, before the final allow-or-deny verdict. They share domain (the + * enforcing domain being evaluated), access_request (the access mask being + * checked), and rule (the matching rule, with per-layer access masks). + * + * Denial fields + * ~~~~~~~~~~~~~ + * + * Every denial event shares three fields. domain is the ID of the + * innermost domain that blocked the access. same_exec tells whether the + * current task is the same executable that entered that domain. logged is + * the domain's audit-logging decision for this denial (its log_status is + * enabled and the per-execution flag selected by same_exec is set); a + * stateless ftrace filter can select the denials the domain submits to + * audit with logged==1, without reconstructing it from the per-execution + * log flags. Denial events order their fields as domain, same_exec, + * logged, then blockers (deny_access events only), then the type-specific + * object fields, then any variable-length field. + * + * Relational referents + * ~~~~~~~~~~~~~~~~~~~~~ + * + * A scope or ptrace verdict compares two domains, so the other party's + * domain is part of the decision context. It is exposed as a scalar + * domain ID (0 when that party is unsandboxed): target_domain (signal), + * peer_domain (abstract unix socket), tracee_domain (ptrace). With both + * IDs in the stream, a consumer that tracked domain creation can relate + * the two parties without kernel-internal state. The ID is a scalar + * snapshot, not a live domain pointer that could dangle: an optional + * relational referent is a scalar (0 sentinel), not a nullable pointer. + */ + +/* + * Prints a per-layer access mask array (the dynamic array @array) as symbolic + * flag names using the shared @flag_names list (a _LANDLOCK_*_NAMES macro). + * Stays outside CREATE_TRACE_POINTS: TP_printk is expanded in the print-output + * pass where that macro is undefined. + */ +#define __print_landlock_layers(array, flag_names...) \ + ({ \ + static const struct trace_print_flags __layer_names[] = { \ + flag_names \ + }; \ + __trace_landlock_print_layers( \ + p, __get_dynamic_array(array), \ + __get_dynamic_array_len(array) / \ + sizeof(access_mask_t), \ + __layer_names, ARRAY_SIZE(__layer_names)); \ + }) + +/** + * landlock_create_ruleset - New ruleset created + * + * @ruleset: Newly created ruleset (never NULL); not yet shared via an fd, + * so no lock is needed. + * + * Emitted by sys_landlock_create_ruleset() while the new ruleset is still + * private to the calling thread, before its file descriptor is installed, + * so it cannot race a concurrent :manpage:`close(2)`. Balanced by a + * matching landlock_free_ruleset event. + */ +TRACE_EVENT(landlock_create_ruleset, + + TP_PROTO(const struct landlock_ruleset *ruleset), + + TP_ARGS(ruleset), + + TP_STRUCT__entry( + __field( __u64, ruleset_id ) + __field( __u32, ruleset_version ) + __field( access_mask_t, handled_fs ) + __field( access_mask_t, handled_net ) + __field( access_mask_t, scoped ) + ), + + TP_fast_assign( + __entry->ruleset_id = ruleset->id; + __entry->ruleset_version = ruleset->version; + __entry->handled_fs = ruleset->handled_masks.fs; + __entry->handled_net = ruleset->handled_masks.net; + __entry->scoped = ruleset->handled_masks.scope; + ), + + TP_printk("ruleset=%llx.%u handled_fs=%s handled_net=%s scoped=%s", + __entry->ruleset_id, __entry->ruleset_version, + __print_flags(__entry->handled_fs, "|", _LANDLOCK_ACCESS_FS_NAMES), + __print_flags(__entry->handled_net, "|", _LANDLOCK_ACCESS_NET_NAMES), + __print_flags(__entry->scoped, "|", _LANDLOCK_SCOPE_NAMES)) +); + +/** + * landlock_free_ruleset - Ruleset freed + * + * @ruleset: Ruleset being freed (never NULL); at its last reference, so no + * lock is needed. + * + * Emitted when a ruleset's last reference is dropped (typically when + * the creating process closes the ruleset file descriptor). Fires even + * when file-descriptor installation failed after creation, keeping the + * create/free pair balanced. + */ +TRACE_EVENT(landlock_free_ruleset, + + TP_PROTO(const struct landlock_ruleset *ruleset), + + TP_ARGS(ruleset), + + TP_STRUCT__entry( + __field( __u64, ruleset_id ) + __field( __u32, ruleset_version ) + ), + + TP_fast_assign( + __entry->ruleset_id = ruleset->id; + __entry->ruleset_version = ruleset->version; + ), + + TP_printk("ruleset=%llx.%u", + __entry->ruleset_id, __entry->ruleset_version) +); + +/** + * landlock_add_rule_fs - Filesystem rule added to a ruleset + * + * @ruleset: Source ruleset (never NULL). + * @access_rights: Effective access mask stored in the rule, not the raw + * sys_landlock_add_rule() argument (unhandled rights + * added). + * @path: Filesystem path for the rule (never NULL). + * @pathname: Resolved absolute path string (never NULL; error placeholder + * on resolution failure). + * + * Emitted by sys_landlock_add_rule() under the modified ruleset's lock, so + * the reported ruleset is a stable snapshot that no concurrent writer can + * change. + */ +TRACE_EVENT(landlock_add_rule_fs, + + TP_PROTO(const struct landlock_ruleset *ruleset, + access_mask_t access_rights, const struct path *path, + const char *pathname), + + TP_ARGS(ruleset, access_rights, path, pathname), + + TP_STRUCT__entry( + __field( __u64, ruleset_id ) + __field( __u32, ruleset_version ) + __field( access_mask_t, access_rights ) + __field( dev_t, dev ) + __field( ino_t, ino ) + __string( pathname, pathname ) + ), + + TP_fast_assign( + lockdep_assert_held(&ruleset->lock); + __entry->ruleset_id = ruleset->id; + __entry->ruleset_version = ruleset->version; + __entry->access_rights = access_rights; + __entry->dev = path->dentry->d_sb->s_dev; + /* + * The inode number may not be the user-visible one, + * but it will be the same used by audit. + */ + __entry->ino = d_backing_inode(path->dentry)->i_ino; + __assign_str(pathname); + ), + + TP_printk("ruleset=%llx.%u access_rights=%s dev=%u:%u ino=%lu path=%s", + __entry->ruleset_id, __entry->ruleset_version, + __print_flags(__entry->access_rights, "|", _LANDLOCK_ACCESS_FS_NAMES), + MAJOR(__entry->dev), MINOR(__entry->dev), __entry->ino, + __trace_print_untrusted_str(p, __get_str(pathname), + __get_dynamic_array_len(pathname) - 1)) +); + +/** + * landlock_add_rule_net - Network port rule added to a ruleset + * + * @ruleset: Source ruleset (never NULL). + * @access_rights: Effective access mask stored in the rule, not the raw + * sys_landlock_add_rule() argument (unhandled rights + * added). + * @port: Network port, the landlock_net_port_attr.port UAPI value + * forwarded directly. + * + * Emitted by sys_landlock_add_rule() under the modified ruleset's lock, so + * the reported ruleset is a stable snapshot that no concurrent writer can + * change. + */ +TRACE_EVENT(landlock_add_rule_net, + + TP_PROTO(const struct landlock_ruleset *ruleset, + access_mask_t access_rights, __u64 port), + + TP_ARGS(ruleset, access_rights, port), + + TP_STRUCT__entry( + __field( __u64, ruleset_id ) + __field( __u32, ruleset_version ) + __field( access_mask_t, access_rights ) + __field( __u64, port ) + ), + + TP_fast_assign( + lockdep_assert_held(&ruleset->lock); + __entry->ruleset_id = ruleset->id; + __entry->ruleset_version = ruleset->version; + __entry->access_rights = access_rights; + __entry->port = port; + ), + + TP_printk("ruleset=%llx.%u access_rights=%s port=%llu", + __entry->ruleset_id, __entry->ruleset_version, + __print_flags(__entry->access_rights, "|", _LANDLOCK_ACCESS_NET_NAMES), + __entry->port) +); + +/** + * landlock_create_domain - New domain created + * + * @domain: Newly created domain (never NULL, immutable after creation). + * @domain->hierarchy->id is its unique ID, shared with the + * landlock_enforce_domain and landlock_free_domain events; + * @domain->hierarchy->details holds the requesting process. + * @ruleset: Source ruleset frozen into the domain (never NULL). The + * ruleset lock is held across the emission, so a BPF program + * reading it via BTF sees the exact merged ruleset; + * @ruleset->id / @ruleset->version identify it. + * + * Emitted by sys_landlock_restrict_self() once, in the requesting + * thread's context, right after the merge and before thread-sync. The + * flags-only path (ruleset_fd == -1) creates no domain and does not + * emit this event. Paired with the per-thread landlock_enforce_domain + * (join on @domain->hierarchy->id) and balanced by a matching + * landlock_free_domain event. + */ +TRACE_EVENT(landlock_create_domain, + + TP_PROTO(const struct landlock_domain *domain, + const struct landlock_ruleset *ruleset), + + TP_ARGS(domain, ruleset), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( __u64, parent_id ) + __field( __u64, ruleset_id ) + __field( __u32, ruleset_version ) + ), + + TP_fast_assign( + lockdep_assert_held(&ruleset->lock); + __entry->domain_id = domain->hierarchy->id; + __entry->parent_id = domain->hierarchy->parent ? + domain->hierarchy->parent->id : 0; + __entry->ruleset_id = ruleset->id; + __entry->ruleset_version = ruleset->version; + ), + + TP_printk("domain=%llx parent=%llx ruleset=%llx.%u", + __entry->domain_id, __entry->parent_id, + __entry->ruleset_id, __entry->ruleset_version) +); + +/** + * landlock_enforce_domain - Domain enforced on a thread + * + * @domain: Domain now enforced on the current thread (never NULL, + * immutable; read locklessly). Correlate to + * landlock_create_domain via @domain->hierarchy->id for the + * source ruleset and requesting thread, or read + * @domain->hierarchy->details for the requesting process. + * @complete: Set on the single event that concludes the operation, after + * all its other enforcements; filter on it for one event per + * operation. + * @process_wide: The enforcement covers every eligible (non-exiting) + * thread of the process: set when the caller used + * %LANDLOCK_RESTRICT_SELF_TSYNC or the process is + * single-threaded. A lone thread whose group still + * holds a zombie leader is not counted single-threaded, + * so process_wide == 0 never proves the opposite. + * @no_new_privs: The enforcing thread's no_new_privs state at + * enforcement time: 1 if set (by a prior + * :manpage:`prctl(2)` %PR_SET_NO_NEW_PRIVS or by + * %LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS), 0 if the domain + * was enforced with %CAP_SYS_ADMIN instead. + * + * Emitted for each thread sys_landlock_restrict_self() enforces the + * domain on, in that thread's own context, right after its + * commit_creds(), so it fires only once the thread is irreversibly + * enforcing the domain (aborted operations emit none). Not + * balanced; every enforcement falls between the domain's + * landlock_create_domain and landlock_free_domain events. + * + * @complete == 1 && @process_wide == 1 means the whole process is + * sandboxed by @domain, durably (Landlock domains are monotonic and + * inherited on :manpage:`clone(2)`). + */ +TRACE_EVENT(landlock_enforce_domain, + + TP_PROTO(const struct landlock_domain *domain, bool complete, + bool process_wide, bool no_new_privs), + + TP_ARGS(domain, complete, process_wide, no_new_privs), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, complete ) + __field( bool, process_wide ) + __field( bool, no_new_privs ) + ), + + TP_fast_assign( + __entry->domain_id = domain->hierarchy->id; + __entry->complete = complete; + __entry->process_wide = process_wide; + __entry->no_new_privs = no_new_privs; + ), + + TP_printk("domain=%llx complete=%d process_wide=%d no_new_privs=%d", + __entry->domain_id, __entry->complete, __entry->process_wide, + __entry->no_new_privs) +); + +/** + * landlock_free_domain - Domain freed + * + * @hierarchy: Hierarchy node being freed (never NULL). + * + * Emitted when the hierarchy node's last reference is dropped: its + * refcount reaches zero after all child domains have released their + * parent reference. A committed domain is + * freed from a kworker via landlock_put_domain_deferred() (the credential + * free path runs in RCU context, where sleeping is forbidden), so the + * current task is not the sandboxed task that triggered the free. Balanced + * by a matching landlock_create_domain event. + */ +TRACE_EVENT(landlock_free_domain, + + TP_PROTO(const struct landlock_hierarchy *hierarchy), + + TP_ARGS(hierarchy), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( __u64, denials ) + ), + + TP_fast_assign( + __entry->domain_id = hierarchy->id; + __entry->denials = atomic64_read(&hierarchy->num_denials); + ), + + TP_printk("domain=%llx denials=%llu", + __entry->domain_id, __entry->denials) +); + +/** + * landlock_check_rule_fs - Filesystem rule evaluated during access check + * + * @domain: Enforcing domain (never NULL). + * @rule: Matching rule with per-layer access masks (never NULL). + * @access_request: Access mask evaluated against the rule (the domain's + * handled mask during rename/link double-checks). + * @dentry: Filesystem dentry being checked (never NULL). + * + * Emitted for each rule that matches during a filesystem access check. + * The grants array shows the requested rights the rule grants at each + * domain layer. See Documentation/trace/events-landlock.rst for how to + * interpret it. + */ +TRACE_EVENT(landlock_check_rule_fs, + + TP_PROTO(const struct landlock_domain *domain, + const struct landlock_rule *rule, + access_mask_t access_request, const struct dentry *dentry), + + TP_ARGS(domain, rule, access_request, dentry), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( access_mask_t, access_request ) + __field( dev_t, dev ) + __field( ino_t, ino ) + __dynamic_array(access_mask_t, grants, + domain->num_layers) + ), + + TP_fast_assign( + __entry->domain_id = domain->hierarchy->id; + __entry->access_request = access_request; + __entry->dev = dentry->d_sb->s_dev; + __entry->ino = d_backing_inode(dentry)->i_ino; + + __trace_landlock_fill_layers(__get_dynamic_array(grants), + __get_dynamic_array_len(grants) / + sizeof(access_mask_t), + rule, access_request); + ), + + TP_printk("domain=%llx access_request=%s dev=%u:%u ino=%lu grants=%s", + __entry->domain_id, + __print_flags(__entry->access_request, "|", _LANDLOCK_ACCESS_FS_NAMES), + MAJOR(__entry->dev), MINOR(__entry->dev), __entry->ino, + __print_landlock_layers(grants, _LANDLOCK_ACCESS_FS_NAMES)) +); + +/** + * landlock_check_rule_net - Network port rule evaluated during access check + * + * @domain: Enforcing domain (never NULL). + * @rule: Matching rule with per-layer access masks (never NULL). + * @access_request: Access mask being requested. + * @port: Network port being checked (host endianness). + * + * Emitted for each rule that matches during a network access check. The + * grants array shows the requested rights the rule grants at each domain + * layer. See Documentation/trace/events-landlock.rst for how to + * interpret it. + */ +TRACE_EVENT(landlock_check_rule_net, + + TP_PROTO(const struct landlock_domain *domain, + const struct landlock_rule *rule, + access_mask_t access_request, __u64 port), + + TP_ARGS(domain, rule, access_request, port), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( access_mask_t, access_request ) + __field( __u64, port ) + __dynamic_array(access_mask_t, grants, + domain->num_layers) + ), + + TP_fast_assign( + __entry->domain_id = domain->hierarchy->id; + __entry->access_request = access_request; + __entry->port = port; + + __trace_landlock_fill_layers(__get_dynamic_array(grants), + __get_dynamic_array_len(grants) / + sizeof(access_mask_t), + rule, access_request); + ), + + TP_printk("domain=%llx access_request=%s port=%llu grants=%s", + __entry->domain_id, + __print_flags(__entry->access_request, "|", _LANDLOCK_ACCESS_NET_NAMES), + __entry->port, + __print_landlock_layers(grants, _LANDLOCK_ACCESS_NET_NAMES)) +); + +/** + * landlock_deny_access_fs - Filesystem access denied + * + * @hierarchy: Denying domain's hierarchy node (never NULL); its id is the + * domain field. + * @same_exec: Whether the current task entered the denying domain itself. + * @logged: The domain's audit-logging decision for this denial. + * @blockers: Access mask that was blocked (zero for a mount-topology + * change, whose only blocker is the operation itself). + * @path: Filesystem path that was denied (never NULL). + * @pathname: Resolved path string (never NULL; an error placeholder on + * resolution failure). + * + * Emitted when a Landlock domain denies a filesystem access. + */ +TRACE_EVENT(landlock_deny_access_fs, + + TP_PROTO(const struct landlock_hierarchy *hierarchy, bool same_exec, + bool logged, access_mask_t blockers, const struct path *path, + const char *pathname), + + TP_ARGS(hierarchy, same_exec, logged, blockers, path, pathname), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, same_exec ) + __field( bool, logged ) + __field( access_mask_t, blockers ) + __field( dev_t, dev ) + __field( ino_t, ino ) + __string( pathname, pathname ) + ), + + TP_fast_assign( + const struct inode *inode = d_backing_inode(path->dentry); + + __entry->domain_id = hierarchy->id; + __entry->same_exec = same_exec; + __entry->logged = logged; + __entry->blockers = blockers; + __entry->dev = path->dentry->d_sb->s_dev; + /* + * A negative dentry has no backing inode, so mirror the + * guard in dump_common_audit_data() and report inode 0. + */ + __entry->ino = inode ? inode->i_ino : 0; + __assign_str(pathname); + ), + + TP_printk("domain=%llx same_exec=%d logged=%d blockers=%s dev=%u:%u ino=%lu path=%s", + __entry->domain_id, __entry->same_exec, __entry->logged, + __print_flags(__entry->blockers, "|", _LANDLOCK_ACCESS_FS_NAMES), + MAJOR(__entry->dev), MINOR(__entry->dev), __entry->ino, + __trace_print_untrusted_str(p, __get_str(pathname), + __get_dynamic_array_len(pathname) - 1)) +); + +/** + * landlock_deny_access_net - Network access denied + * + * @hierarchy: Denying domain's hierarchy node (never NULL); its id is the + * domain field. + * @same_exec: Whether the current task entered the denying domain itself. + * @logged: The domain's audit-logging decision for this denial. + * @blockers: Access mask that was blocked. + * @sk: Socket object (never NULL), read without a socket lock, so its + * fields are a best-effort snapshot. The denied endpoint is not + * available: the hook runs before :manpage:`bind(2)` / + * :manpage:`connect(2)` sets the socket addresses. + * @sport: Source port in host endianness, set for bind denials (zero for + * an autobind/ephemeral port); zero for connect and send denials. + * @dport: Destination port in host endianness, set for connect and send + * denials; zero for bind denials, and also zero for a UDP send to + * an AF_UNSPEC address on an IPv6 socket (indistinguishable from a + * real destination port 0). The bind-vs-connect direction is + * given by @blockers, not by which port is set. + * + * Emitted when a Landlock domain denies a network operation. + * + * The port fields are converted from the socket's network byte order to + * host endianness before emitting. + */ +TRACE_EVENT(landlock_deny_access_net, + + TP_PROTO(const struct landlock_hierarchy *hierarchy, bool same_exec, + bool logged, access_mask_t blockers, const struct sock *sk, + __u64 sport, __u64 dport), + + TP_ARGS(hierarchy, same_exec, logged, blockers, sk, sport, dport), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, same_exec ) + __field( bool, logged ) + __field( access_mask_t, blockers ) + __field( __u64, sport ) + __field( __u64, dport ) + ), + + TP_fast_assign( + __entry->domain_id = hierarchy->id; + __entry->same_exec = same_exec; + __entry->logged = logged; + __entry->blockers = blockers; + __entry->sport = sport; + __entry->dport = dport; + ), + + TP_printk("domain=%llx same_exec=%d logged=%d blockers=%s sport=%llu dport=%llu", + __entry->domain_id, __entry->same_exec, __entry->logged, + __print_flags(__entry->blockers, "|", _LANDLOCK_ACCESS_NET_NAMES), + __entry->sport, __entry->dport) +); + +/** + * landlock_deny_ptrace - Ptrace access denied by a Landlock domain + * + * @hierarchy: Denying domain's hierarchy node (never NULL); its id is the + * domain field. + * @same_exec: Whether the current task entered the denying domain itself. + * @logged: The domain's audit-logging decision for this denial. + * @tracee_domain_id: The tracee's Landlock domain ID, or 0 if the tracee + * is unsandboxed. + * @tracee: The target task ptrace acted on (never NULL). tracee_pid is + * the init-namespace TGID (like audit's opid). + * + * Emitted when a Landlock domain denies a ptrace operation. + */ +TRACE_EVENT(landlock_deny_ptrace, + + TP_PROTO(const struct landlock_hierarchy *hierarchy, bool same_exec, + bool logged, u64 tracee_domain_id, + const struct task_struct *tracee), + + TP_ARGS(hierarchy, same_exec, logged, tracee_domain_id, tracee), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, same_exec ) + __field( bool, logged ) + __field( __u64, tracee_domain_id) + __field( pid_t, tracee_pid ) + __string( tracee_comm, tracee->comm ) + ), + + TP_fast_assign( + __entry->domain_id = hierarchy->id; + __entry->same_exec = same_exec; + __entry->logged = logged; + __entry->tracee_domain_id = tracee_domain_id; + __entry->tracee_pid = task_tgid_nr((struct task_struct *)tracee); + __assign_str(tracee_comm); + ), + + TP_printk("domain=%llx same_exec=%d logged=%d tracee_domain=%llx tracee_pid=%d tracee_comm=%s", + __entry->domain_id, __entry->same_exec, __entry->logged, + __entry->tracee_domain_id, __entry->tracee_pid, + __trace_print_untrusted_str(p, __get_str(tracee_comm), + __get_dynamic_array_len(tracee_comm) - 1)) +); + +/** + * landlock_deny_scope_signal - Signal delivery denied by + * LANDLOCK_SCOPE_SIGNAL + * + * @hierarchy: Denying domain's hierarchy node (never NULL); its id is the + * domain field. + * @same_exec: Whether the current task entered the denying domain itself. + * @logged: The domain's audit-logging decision for this denial. + * @target_domain_id: The target's Landlock domain ID, or 0 if the target + * is unsandboxed. + * @target: The task the signal was aimed at (never NULL). target_pid is + * the init-namespace TGID (like audit's opid). + * + * Emitted when a Landlock domain denies signal delivery to a scoped-out + * target. + */ +TRACE_EVENT(landlock_deny_scope_signal, + + TP_PROTO(const struct landlock_hierarchy *hierarchy, bool same_exec, + bool logged, u64 target_domain_id, + const struct task_struct *target), + + TP_ARGS(hierarchy, same_exec, logged, target_domain_id, target), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, same_exec ) + __field( bool, logged ) + __field( __u64, target_domain_id) + __field( pid_t, target_pid ) + __string( target_comm, target->comm ) + ), + + TP_fast_assign( + __entry->domain_id = hierarchy->id; + __entry->same_exec = same_exec; + __entry->logged = logged; + __entry->target_domain_id = target_domain_id; + __entry->target_pid = task_tgid_nr((struct task_struct *)target); + __assign_str(target_comm); + ), + + TP_printk("domain=%llx same_exec=%d logged=%d target_domain=%llx target_pid=%d target_comm=%s", + __entry->domain_id, __entry->same_exec, __entry->logged, + __entry->target_domain_id, __entry->target_pid, + __trace_print_untrusted_str(p, __get_str(target_comm), + __get_dynamic_array_len(target_comm) - 1)) +); + +/** + * landlock_deny_scope_abstract_unix_socket - Abstract unix socket access + * denied by LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET + * + * @hierarchy: Denying domain's hierarchy node (never NULL); its id is the + * domain field. + * @same_exec: Whether the current task entered the denying domain itself. + * @logged: The domain's audit-logging decision for this denial. + * @peer_domain_id: The peer's Landlock domain ID, or 0 if the peer is + * unsandboxed. + * @peer: Peer socket (never NULL). peer_pid is best-effort: it is 0 for + * a datagram peer (no SO_PEERCRED), so sun_path is the reliable + * peer identifier. + * + * Emitted when a Landlock domain denies access to a scoped-out abstract + * unix socket. + */ +TRACE_EVENT(landlock_deny_scope_abstract_unix_socket, + + TP_PROTO(const struct landlock_hierarchy *hierarchy, bool same_exec, + bool logged, u64 peer_domain_id, const struct sock *peer), + + TP_ARGS(hierarchy, same_exec, logged, peer_domain_id, peer), + + TP_STRUCT__entry( + __field( __u64, domain_id ) + __field( bool, same_exec ) + __field( bool, logged ) + __field( __u64, peer_domain_id ) + __field( pid_t, peer_pid ) + /* + * Abstract socket names are untrusted binary data from + * user space. Use __string_len because abstract names + * are not NUL-terminated; their length is determined by + * addr->len. unix_sk(peer)->addr is stable here because + * the caller (hook_unix_stream_connect or + * hook_unix_may_send) holds unix_state_lock(peer). + */ + __string_len( sun_path, + unix_sk(peer)->addr ? + unix_sk(peer)->addr->name->sun_path + 1 : + "", + unix_sk(peer)->addr ? + unix_sk(peer)->addr->len - + offsetof(struct sockaddr_un, + sun_path) - 1 : + 0) + ), + + TP_fast_assign( + struct pid *peer_pid; + + lockdep_assert_held(&unix_sk(peer)->lock); + __entry->domain_id = hierarchy->id; + __entry->same_exec = same_exec; + __entry->logged = logged; + __entry->peer_domain_id = peer_domain_id; + /* + * Best-effort (0 for a datagram peer). sk_peer_pid is + * canonically guarded by sk->sk_peer_lock, but the target + * peer's peercred is set once and not updated concurrently in + * these hooks, so this READ_ONCE() is safe; sun_path is the + * reliable identifier. + */ + peer_pid = READ_ONCE(peer->sk_peer_pid); + __entry->peer_pid = peer_pid ? pid_nr(peer_pid) : 0; + __assign_str(sun_path); + ), + + TP_printk("domain=%llx same_exec=%d logged=%d peer_domain=%llx peer_pid=%d sun_path=%s", + __entry->domain_id, __entry->same_exec, __entry->logged, + __entry->peer_domain_id, __entry->peer_pid, + __trace_print_untrusted_str(p, __get_str(sun_path), + __get_dynamic_array_len(sun_path) - 1)) +); + +#undef _LANDLOCK_NAME_ENTRY + +#endif /* _TRACE_LANDLOCK_H */ + +/* This part must be outside protection */ +#include + +/* clang-format on */ diff --git a/include/uapi/linux/landlock.h b/include/uapi/linux/landlock.h index 7ffe2ef127ee..cceda3b3b961 100644 --- a/include/uapi/linux/landlock.h +++ b/include/uapi/linux/landlock.h @@ -191,12 +191,25 @@ struct landlock_ruleset_attr { * * If the calling thread is running with no_new_privs, this operation * enables no_new_privs on the sibling threads as well. + * + * The following flag ties the no_new_privs attribute to the ruleset + * enforcement: + * + * %LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS + * Sets the no_new_privs attribute of the calling thread only once the + * enforcement of the ruleset succeeded: no_new_privs is set if and only + * if sys_landlock_restrict_self() succeeds. This removes the need for a + * prior :manpage:`prctl(2)` ``PR_SET_NO_NEW_PRIVS`` call (or + * %CAP_SYS_ADMIN use). This flag requires a ruleset. When + * combined with %LANDLOCK_RESTRICT_SELF_TSYNC, no_new_privs is set on the + * sibling threads as well. */ /* clang-format off */ #define LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF (1U << 0) #define LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON (1U << 1) #define LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF (1U << 2) #define LANDLOCK_RESTRICT_SELF_TSYNC (1U << 3) +#define LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS (1U << 4) /* clang-format on */ /** @@ -315,16 +328,16 @@ struct landlock_net_port_attr { * :manpage:`connect(2)` as well as calls to :manpage:`sendmsg(2)` with an * explicit recipient address. * - * This access right only applies to connections to UNIX server sockets which + * This access right applies only to connections to UNIX server sockets which * were created outside of the newly created Landlock domain (e.g. from within * a parent domain or from an unrestricted process). Newly created UNIX * servers within the same Landlock domain continue to be accessible. In this * regard, %LANDLOCK_ACCESS_FS_RESOLVE_UNIX has the same semantics as the * ``LANDLOCK_SCOPE_*`` flags. * - * If a resolve attempt is denied, the operation returns an ``EACCES`` error, - * in line with other filesystem access rights (but different to denials for - * abstract UNIX domain sockets). + * If a resolution attempt is denied, the operation returns an ``EACCES`` + * error, in line with other filesystem access rights (but different to + * denials for abstract UNIX domain sockets). * * This access right is available since the ninth version of the Landlock ABI. * @@ -351,6 +364,7 @@ struct landlock_net_port_attr { * device. * - %LANDLOCK_ACCESS_FS_MAKE_DIR: Create (or rename) a directory. * - %LANDLOCK_ACCESS_FS_MAKE_REG: Create (or rename or link) a regular file. + * This also guards the creation of whiteout objects as used in OverlayFS. * - %LANDLOCK_ACCESS_FS_MAKE_SOCK: Create (or rename or link) a UNIX domain * socket. * - %LANDLOCK_ACCESS_FS_MAKE_FIFO: Create (or rename or link) a named pipe. diff --git a/samples/landlock/sandboxer.c b/samples/landlock/sandboxer.c index ac71019e6212..030583273f3f 100644 --- a/samples/landlock/sandboxer.c +++ b/samples/landlock/sandboxer.c @@ -369,7 +369,7 @@ static int add_quiet_access(const char *const env_var, return 0; } -#define LANDLOCK_ABI_LAST 10 +#define LANDLOCK_ABI_LAST 11 #define XSTR(s) #s #define STR(s) XSTR(s) @@ -453,8 +453,9 @@ int main(const int argc, char *const argv[], char *const *const envp) .quiet_scoped = 0, }; bool quiet_supported = true; - int supported_restrict_flags = LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON; - int set_restrict_flags = 0; + int supported_restrict_flags = LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON | + LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; + int set_restrict_flags = LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; if (argc < 2) { fprintf(stderr, help, argv[0]); @@ -545,6 +546,12 @@ int main(const int argc, char *const argv[], char *const *const envp) LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP); /* Removes quiet flags for ABI < 10 later on. */ quiet_supported = false; + __attribute__((fallthrough)); + case 10: + /* Removes LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS for ABI < 11 */ + supported_restrict_flags &= + ~LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; + set_restrict_flags &= ~LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; /* Must be printed for any ABI < LANDLOCK_ABI_LAST. */ fprintf(stderr, @@ -673,7 +680,8 @@ int main(const int argc, char *const argv[], char *const *const envp) } } - if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { + if (!(set_restrict_flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS) && + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("Failed to restrict privileges"); goto err_close_ruleset; } diff --git a/security/landlock/Kconfig b/security/landlock/Kconfig index 3f1493402052..7aeac29160e8 100644 --- a/security/landlock/Kconfig +++ b/security/landlock/Kconfig @@ -21,6 +21,11 @@ config SECURITY_LANDLOCK you should also prepend "landlock," to the content of CONFIG_LSM to enable Landlock at boot time. +config SECURITY_LANDLOCK_LOG + bool + depends on SECURITY_LANDLOCK + default y if AUDIT || TRACEPOINTS + config SECURITY_LANDLOCK_KUNIT_TEST bool "KUnit tests for Landlock" if !KUNIT_ALL_TESTS depends on KUNIT=y diff --git a/security/landlock/Makefile b/security/landlock/Makefile index ffa7646d99f3..2711f4876939 100644 --- a/security/landlock/Makefile +++ b/security/landlock/Makefile @@ -8,11 +8,15 @@ landlock-y := \ cred.o \ task.o \ fs.o \ - tsync.o + tsync.o \ + domain.o landlock-$(CONFIG_INET) += net.o -landlock-$(CONFIG_AUDIT) += \ +landlock-$(CONFIG_SECURITY_LANDLOCK_LOG) += \ id.o \ - audit.o \ - domain.o + log.o + +landlock-$(CONFIG_AUDIT) += audit.o + +landlock-$(CONFIG_TRACEPOINTS) += trace.o diff --git a/security/landlock/access.h b/security/landlock/access.h index d926078bf0a5..bbbb41f41147 100644 --- a/security/landlock/access.h +++ b/security/landlock/access.h @@ -19,7 +19,7 @@ /* * All access rights that are denied by default whether they are handled or not - * by a ruleset/layer. This must be ORed with all ruleset->access_masks[] + * by a ruleset/layer. This must be ORed with all domain->handled_masks[] * entries when we need to get the absolute handled access masks, see * landlock_upgrade_handled_access_masks(). */ @@ -74,13 +74,13 @@ struct layer_mask { * @access: The unfulfilled access rights for this layer. */ access_mask_t access : LANDLOCK_NUM_ACCESS_MAX; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /** * @quiet: Whether we have encountered a rule with the quiet flag for * this layer. Used to control logging. */ access_mask_t quiet : 1; -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } __packed __aligned(sizeof(access_mask_t)); /* diff --git a/security/landlock/audit.c b/security/landlock/audit.c index 50536c568526..e02963834e48 100644 --- a/security/landlock/audit.c +++ b/security/landlock/audit.c @@ -5,9 +5,9 @@ * Copyright © 2023-2025 Microsoft Corporation */ -#include #include #include +#include #include #include #include @@ -18,40 +18,30 @@ #include "cred.h" #include "domain.h" #include "limits.h" -#include "ruleset.h" +#include "log.h" -static const char *const fs_access_strings[] = { - [BIT_INDEX(LANDLOCK_ACCESS_FS_EXECUTE)] = "fs.execute", - [BIT_INDEX(LANDLOCK_ACCESS_FS_WRITE_FILE)] = "fs.write_file", - [BIT_INDEX(LANDLOCK_ACCESS_FS_READ_FILE)] = "fs.read_file", - [BIT_INDEX(LANDLOCK_ACCESS_FS_READ_DIR)] = "fs.read_dir", - [BIT_INDEX(LANDLOCK_ACCESS_FS_REMOVE_DIR)] = "fs.remove_dir", - [BIT_INDEX(LANDLOCK_ACCESS_FS_REMOVE_FILE)] = "fs.remove_file", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_CHAR)] = "fs.make_char", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_DIR)] = "fs.make_dir", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_REG)] = "fs.make_reg", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_SOCK)] = "fs.make_sock", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_FIFO)] = "fs.make_fifo", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_BLOCK)] = "fs.make_block", - [BIT_INDEX(LANDLOCK_ACCESS_FS_MAKE_SYM)] = "fs.make_sym", - [BIT_INDEX(LANDLOCK_ACCESS_FS_REFER)] = "fs.refer", - [BIT_INDEX(LANDLOCK_ACCESS_FS_TRUNCATE)] = "fs.truncate", - [BIT_INDEX(LANDLOCK_ACCESS_FS_IOCTL_DEV)] = "fs.ioctl_dev", - [BIT_INDEX(LANDLOCK_ACCESS_FS_RESOLVE_UNIX)] = "fs.resolve_unix", -}; +/* + * Access-right and scope names are built from the lists shared with the trace + * events (see ). The designated initializer places each name + * at its bit index, so the lookup stays O(1) and does not depend on the entry + * order. log_blockers() adds the "fs."/"net."/"scope." category prefix. + */ +#define _LANDLOCK_NAME_ENTRY(mask, name) [BIT_INDEX(mask)] = name + +static const char *const fs_access_strings[] = { _LANDLOCK_ACCESS_FS_NAMES }; static_assert(ARRAY_SIZE(fs_access_strings) == LANDLOCK_NUM_ACCESS_FS); -static const char *const net_access_strings[] = { - [BIT_INDEX(LANDLOCK_ACCESS_NET_BIND_TCP)] = "net.bind_tcp", - [BIT_INDEX(LANDLOCK_ACCESS_NET_CONNECT_TCP)] = "net.connect_tcp", - [BIT_INDEX(LANDLOCK_ACCESS_NET_BIND_UDP)] = "net.bind_udp", - [BIT_INDEX(LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP)] = - "net.connect_send_udp", -}; +static const char *const net_access_strings[] = { _LANDLOCK_ACCESS_NET_NAMES }; static_assert(ARRAY_SIZE(net_access_strings) == LANDLOCK_NUM_ACCESS_NET); +static const char *const scope_strings[] = { _LANDLOCK_SCOPE_NAMES }; + +static_assert(ARRAY_SIZE(scope_strings) == LANDLOCK_NUM_SCOPE); + +#undef _LANDLOCK_NAME_ENTRY + static __attribute_const__ const char * get_blocker(const enum landlock_request_type type, const unsigned long access_bit) @@ -63,7 +53,7 @@ get_blocker(const enum landlock_request_type type, case LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY: WARN_ON_ONCE(access_bit != -1); - return "fs.change_topology"; + return "change_topology"; case LANDLOCK_REQUEST_FS_ACCESS: if (WARN_ON_ONCE(access_bit >= ARRAY_SIZE(fs_access_strings))) @@ -77,32 +67,63 @@ get_blocker(const enum landlock_request_type type, case LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET: WARN_ON_ONCE(access_bit != -1); - return "scope.abstract_unix_socket"; + return scope_strings[BIT_INDEX( + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET)]; case LANDLOCK_REQUEST_SCOPE_SIGNAL: WARN_ON_ONCE(access_bit != -1); - return "scope.signal"; + return scope_strings[BIT_INDEX(LANDLOCK_SCOPE_SIGNAL)]; } WARN_ON_ONCE(1); return "unknown"; } +/* + * Returns the audit category prefix prepended to the unprefixed blocker name + * returned by get_blocker() (filesystem and network access rights, + * change_topology, and scopes). The ptrace blocker is standalone and carries + * its full name in get_blocker(), so it uses no prefix. + */ +static __attribute_const__ const char * +blocker_prefix(const enum landlock_request_type type) +{ + switch (type) { + case LANDLOCK_REQUEST_PTRACE: + return ""; + + case LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY: + case LANDLOCK_REQUEST_FS_ACCESS: + return "fs."; + + case LANDLOCK_REQUEST_NET_ACCESS: + return "net."; + + case LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET: + case LANDLOCK_REQUEST_SCOPE_SIGNAL: + return "scope."; + } + + WARN_ON_ONCE(1); + return ""; +} + static void log_blockers(struct audit_buffer *const ab, const enum landlock_request_type type, const access_mask_t access) { const unsigned long access_mask = access; + const char *const prefix = blocker_prefix(type); unsigned long access_bit; bool is_first = true; for_each_set_bit(access_bit, &access_mask, BITS_PER_TYPE(access)) { - audit_log_format(ab, "%s%s", is_first ? "" : ",", + audit_log_format(ab, "%s%s%s", is_first ? "" : ",", prefix, get_blocker(type, access_bit)); is_first = false; } if (is_first) - audit_log_format(ab, "%s", get_blocker(type, -1)); + audit_log_format(ab, "%s%s", prefix, get_blocker(type, -1)); } static void log_domain(struct landlock_hierarchy *const hierarchy) @@ -137,526 +158,32 @@ static void log_domain(struct landlock_hierarchy *const hierarchy) WRITE_ONCE(hierarchy->log_status, LANDLOCK_LOG_RECORDED); } -static struct landlock_hierarchy * -get_hierarchy(const struct landlock_ruleset *const domain, const size_t layer) -{ - struct landlock_hierarchy *hierarchy = domain->hierarchy; - ssize_t i; - - if (WARN_ON_ONCE(layer >= domain->num_layers)) - return hierarchy; - - for (i = domain->num_layers - 1; i > layer; i--) { - if (WARN_ON_ONCE(!hierarchy->parent)) - break; - - hierarchy = hierarchy->parent; - } - - return hierarchy; -} - -#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST - -static void test_get_hierarchy(struct kunit *const test) -{ - struct landlock_hierarchy dom0_hierarchy = { - .id = 10, - }; - struct landlock_hierarchy dom1_hierarchy = { - .parent = &dom0_hierarchy, - .id = 20, - }; - struct landlock_hierarchy dom2_hierarchy = { - .parent = &dom1_hierarchy, - .id = 30, - }; - struct landlock_ruleset dom2 = { - .hierarchy = &dom2_hierarchy, - .num_layers = 3, - }; - - KUNIT_EXPECT_EQ(test, 10, get_hierarchy(&dom2, 0)->id); - KUNIT_EXPECT_EQ(test, 20, get_hierarchy(&dom2, 1)->id); - KUNIT_EXPECT_EQ(test, 30, get_hierarchy(&dom2, 2)->id); - /* KUNIT_EXPECT_EQ(test, 30, get_hierarchy(&dom2, -1)->id); */ -} - -#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ - -/* Get the youngest layer that denied the access_request. */ -static size_t get_denied_layer(const struct landlock_ruleset *const domain, - access_mask_t *const access_request, - const struct layer_masks *masks) -{ - for (ssize_t i = ARRAY_SIZE(masks->layers) - 1; i >= 0; i--) { - if (masks->layers[i].access & *access_request) { - *access_request &= masks->layers[i].access; - return i; - } - } - - /* Not found - fall back to default values */ - *access_request = 0; - return domain->num_layers - 1; -} - -#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST - -static void test_get_denied_layer(struct kunit *const test) -{ - const struct landlock_ruleset dom = { - .num_layers = 5, - }; - const struct layer_masks masks = { - .layers[0].access = LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_READ_DIR, - .layers[1].access = LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR, - .layers[2].access = LANDLOCK_ACCESS_FS_REMOVE_DIR, - }; - access_mask_t access; - - access = LANDLOCK_ACCESS_FS_EXECUTE; - KUNIT_EXPECT_EQ(test, 0, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_EXECUTE); - - access = LANDLOCK_ACCESS_FS_READ_FILE; - KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_FILE); - - access = LANDLOCK_ACCESS_FS_READ_DIR; - KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_DIR); - - access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; - KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR); - - access = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_DIR; - KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_DIR); - - access = LANDLOCK_ACCESS_FS_WRITE_FILE; - KUNIT_EXPECT_EQ(test, 4, get_denied_layer(&dom, &access, &masks)); - KUNIT_EXPECT_EQ(test, access, 0); -} - -#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ - -static size_t -get_layer_from_deny_masks(access_mask_t *const access_request, - const access_mask_t all_existing_optional_access, - const deny_masks_t deny_masks, - optional_access_t quiet_optional_accesses, - bool *quiet) -{ - const unsigned long access_opt = all_existing_optional_access; - const unsigned long access_req = *access_request; - access_mask_t missing = 0; - size_t youngest_layer = 0; - size_t access_index = 0; - unsigned long access_bit; - bool should_quiet = false; - - /* This will require change with new object types. */ - WARN_ON_ONCE(access_opt != _LANDLOCK_ACCESS_FS_OPTIONAL); - - for_each_set_bit(access_bit, &access_opt, - BITS_PER_TYPE(access_mask_t)) { - if (access_req & BIT(access_bit)) { - const size_t layer = - (deny_masks >> - (access_index * - HWEIGHT(LANDLOCK_MAX_NUM_LAYERS - 1))) & - (LANDLOCK_MAX_NUM_LAYERS - 1); - const bool layer_has_quiet = - !!(quiet_optional_accesses & BIT(access_index)); - - if (layer > youngest_layer) { - youngest_layer = layer; - missing = BIT(access_bit); - should_quiet = layer_has_quiet; - } else if (layer == youngest_layer) { - missing |= BIT(access_bit); - /* - * Whether the layer has rules with quiet flag - * covering the file accessed does not depend on - * the access, and so the following - * WARN_ON_ONCE() should not fail. - */ - WARN_ON_ONCE(should_quiet && !layer_has_quiet); - should_quiet = layer_has_quiet; - } - } - access_index++; - } - - *access_request = missing; - *quiet = should_quiet; - return youngest_layer; -} - -#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST - -static void test_get_layer_from_deny_masks(struct kunit *const test) -{ - deny_masks_t deny_mask; - access_mask_t access; - optional_access_t quiet_optional_accesses; - bool quiet; - - /* truncate:0 ioctl_dev:2 */ - deny_mask = 0x20; - quiet_optional_accesses = 0; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 0, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - /* layer denying truncate: quiet, ioctl: not quiet */ - quiet_optional_accesses = 0b01; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 0, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, true); - - access = LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - /* Reverse order - truncate:2 ioctl_dev:0 */ - deny_mask = 0x02; - quiet_optional_accesses = 0; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 0, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - /* layer denying truncate: quiet, ioctl: not quiet */ - quiet_optional_accesses = 0b01; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, true); - - access = LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 0, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, true); - - /* layer denying truncate: not quiet, ioctl: quiet */ - quiet_optional_accesses = 0b10; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 0, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, true); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 2, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - /* truncate:15 ioctl_dev:15 */ - deny_mask = 0xff; - quiet_optional_accesses = 0; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 15, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, false); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 15, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, - LANDLOCK_ACCESS_FS_TRUNCATE | - LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, false); - - /* Both quiet (same layer so quietness must be the same) */ - quiet_optional_accesses = 0b11; - - access = LANDLOCK_ACCESS_FS_TRUNCATE; - KUNIT_EXPECT_EQ(test, 15, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); - KUNIT_EXPECT_EQ(test, quiet, true); - - access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; - KUNIT_EXPECT_EQ(test, 15, - get_layer_from_deny_masks( - &access, _LANDLOCK_ACCESS_FS_OPTIONAL, - deny_mask, quiet_optional_accesses, &quiet)); - KUNIT_EXPECT_EQ(test, access, - LANDLOCK_ACCESS_FS_TRUNCATE | - LANDLOCK_ACCESS_FS_IOCTL_DEV); - KUNIT_EXPECT_EQ(test, quiet, true); -} - -#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ - -static bool is_valid_request(const struct landlock_request *const request) -{ - if (WARN_ON_ONCE(request->layer_plus_one > LANDLOCK_MAX_NUM_LAYERS)) - return false; - - if (WARN_ON_ONCE(!(!!request->layer_plus_one ^ !!request->access))) - return false; - - if (request->access) { - if (WARN_ON_ONCE(!(!!request->layer_masks ^ - !!request->all_existing_optional_access))) - return false; - } else { - if (WARN_ON_ONCE(request->layer_masks || - request->all_existing_optional_access)) - return false; - } - - if (request->deny_masks) { - if (WARN_ON_ONCE(!request->all_existing_optional_access)) - return false; - static_assert(sizeof(request->all_existing_optional_access) == - sizeof(u32)); - if (WARN_ON_ONCE( - request->quiet_optional_accesses >= - BIT(hweight32( - request->all_existing_optional_access)))) - return false; - } - - return true; -} - -static access_mask_t -pick_access_mask_for_request_type(const enum landlock_request_type type, - const struct access_masks access_masks) -{ - switch (type) { - case LANDLOCK_REQUEST_FS_ACCESS: - return access_masks.fs; - case LANDLOCK_REQUEST_NET_ACCESS: - return access_masks.net; - default: - WARN_ONCE(1, "Invalid request type %d passed to %s", type, - __func__); - return 0; - } -} - /** - * landlock_log_denial - Create audit records related to a denial + * landlock_audit_denial - Create an audit record for a denied access request * - * @subject: The Landlock subject's credential denying an action. * @request: Detail of the user space request. + * @youngest_denied: The youngest hierarchy node that denied the access. + * @missing: The set of denied access rights. + * @logged: Whether the denial is selected for logging, as computed by + * landlock_log_denial() (domain policy and quiet rules). + * + * Emits the record when audit is enabled and the denial is selected for + * logging. */ -void landlock_log_denial(const struct landlock_cred_security *const subject, - const struct landlock_request *const request) +void landlock_audit_denial(const struct landlock_request *const request, + struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool logged) { struct audit_buffer *ab; - struct landlock_hierarchy *youngest_denied; - size_t youngest_layer; - access_mask_t missing; - bool object_quiet_flag = false, quiet_applicable_to_access = false; - - if (WARN_ON_ONCE(!subject || !subject->domain || - !subject->domain->hierarchy || !request)) - return; - - if (!is_valid_request(request)) - return; - - missing = request->access; - if (missing) { - /* Gets the nearest domain that denies the request. */ - if (request->layer_masks) { - youngest_layer = get_denied_layer(subject->domain, - &missing, - request->layer_masks); - object_quiet_flag = - request->layer_masks->layers[youngest_layer] - .quiet; - } else { - youngest_layer = get_layer_from_deny_masks( - &missing, _LANDLOCK_ACCESS_FS_OPTIONAL, - request->deny_masks, - request->quiet_optional_accesses, - &object_quiet_flag); - } - youngest_denied = - get_hierarchy(subject->domain, youngest_layer); - } else { - youngest_layer = request->layer_plus_one - 1; - youngest_denied = - get_hierarchy(subject->domain, youngest_layer); - } - - if (READ_ONCE(youngest_denied->log_status) == LANDLOCK_LOG_DISABLED) - return; - - /* - * Consistently keeps track of the number of denied access requests - * even if audit is currently disabled, or if audit rules currently - * exclude this record type, or if landlock_restrict_self(2)'s flags - * quiet logs. - */ - atomic64_inc(&youngest_denied->num_denials); if (!audit_enabled) return; - /* Checks if the current exec was restricting itself. */ - if (subject->domain_exec & BIT(youngest_layer)) { - /* Ignores denials for the same execution. */ - if (!youngest_denied->log_same_exec) - return; - } else { - /* Ignores denials after a new execution. */ - if (!youngest_denied->log_new_exec) - return; - } - /* - * Checks if the object is marked quiet by the layer that denied the - * request. If it's a different layer that marked it as quiet, but that - * layer is not the one that denied the request, we should still audit - * log the denial. + * Skips denials the domain's policy or a quiet rule excludes from + * logging (folded into @logged by landlock_log_denial()). */ - if (object_quiet_flag) { - /* - * We now check if the denied requests are all covered by the - * layer's quiet access bits. - */ - const access_mask_t quiet_mask = - pick_access_mask_for_request_type( - request->type, youngest_denied->quiet_masks); - - quiet_applicable_to_access = (quiet_mask & missing) == missing; - } else { - /* - * Either the object is not quiet, or this is a scope request. - * We check request->type to distinguish between the two cases. - */ - const access_mask_t quiet_mask = - youngest_denied->quiet_masks.scope; - - switch (request->type) { - case LANDLOCK_REQUEST_SCOPE_SIGNAL: - quiet_applicable_to_access = - !!(quiet_mask & LANDLOCK_SCOPE_SIGNAL); - break; - case LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET: - quiet_applicable_to_access = - !!(quiet_mask & - LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET); - break; - /* - * Leave LANDLOCK_REQUEST_PTRACE and - * LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY unhandled for now - they - * are never quiet. - */ - default: - break; - } - } - - if (quiet_applicable_to_access) + if (!logged) return; /* Uses consistent allocation flags wrt common_lsm_audit(). */ @@ -675,23 +202,19 @@ void landlock_log_denial(const struct landlock_cred_security *const subject, } /** - * landlock_log_drop_domain - Create an audit record on domain deallocation + * landlock_audit_free_domain - Create an audit record on domain deallocation * * @hierarchy: The domain's hierarchy being deallocated. * * Only domains which previously appeared in the audit logs are logged again. * This is useful to know when a domain will never show again in the audit log. * - * Called in a work queue scheduled by landlock_put_ruleset_deferred() called - * by hook_cred_free(). + * Called from landlock_log_free_domain(). */ -void landlock_log_drop_domain(const struct landlock_hierarchy *const hierarchy) +void landlock_audit_free_domain(const struct landlock_hierarchy *const hierarchy) { struct audit_buffer *ab; - if (WARN_ON_ONCE(!hierarchy)) - return; - if (!audit_enabled) return; @@ -712,23 +235,3 @@ void landlock_log_drop_domain(const struct landlock_hierarchy *const hierarchy) hierarchy->id, atomic64_read(&hierarchy->num_denials)); audit_log_end(ab); } - -#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST - -static struct kunit_case test_cases[] = { - /* clang-format off */ - KUNIT_CASE(test_get_hierarchy), - KUNIT_CASE(test_get_denied_layer), - KUNIT_CASE(test_get_layer_from_deny_masks), - {} - /* clang-format on */ -}; - -static struct kunit_suite test_suite = { - .name = "landlock_audit", - .test_cases = test_cases, -}; - -kunit_test_suite(test_suite); - -#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ diff --git a/security/landlock/audit.h b/security/landlock/audit.h index 620f8a24291d..14a514065e08 100644 --- a/security/landlock/audit.h +++ b/security/landlock/audit.h @@ -8,66 +8,33 @@ #ifndef _SECURITY_LANDLOCK_AUDIT_H #define _SECURITY_LANDLOCK_AUDIT_H -#include -#include +#include #include "access.h" -#include "cred.h" -enum landlock_request_type { - LANDLOCK_REQUEST_PTRACE = 1, - LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY, - LANDLOCK_REQUEST_FS_ACCESS, - LANDLOCK_REQUEST_NET_ACCESS, - LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET, - LANDLOCK_REQUEST_SCOPE_SIGNAL, -}; - -/* - * We should be careful to only use a variable of this type for - * landlock_log_denial(). This way, the compiler can remove it entirely if - * CONFIG_AUDIT is not set. - */ -struct landlock_request { - /* Mandatory fields. */ - enum landlock_request_type type; - struct common_audit_data audit; - - /** - * layer_plus_one: First layer level that denies the request + 1. The - * extra one is useful to detect uninitialized field. - */ - size_t layer_plus_one; - - /* Required field for configurable access control. */ - access_mask_t access; - - /* Required fields for requests with layer masks. */ - const struct layer_masks *layer_masks; - - /* Required fields for requests with deny masks. */ - const access_mask_t all_existing_optional_access; - deny_masks_t deny_masks; - optional_access_t quiet_optional_accesses; -}; +struct landlock_hierarchy; +struct landlock_request; #ifdef CONFIG_AUDIT -void landlock_log_drop_domain(const struct landlock_hierarchy *const hierarchy); +void landlock_audit_denial(const struct landlock_request *const request, + struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool logged); -void landlock_log_denial(const struct landlock_cred_security *const subject, - const struct landlock_request *const request); +void landlock_audit_free_domain( + const struct landlock_hierarchy *const hierarchy); #else /* CONFIG_AUDIT */ static inline void -landlock_log_drop_domain(const struct landlock_hierarchy *const hierarchy) +landlock_audit_denial(const struct landlock_request *const request, + struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool logged) { } static inline void -landlock_log_denial(const struct landlock_cred_security *const subject, - const struct landlock_request *const request) +landlock_audit_free_domain(const struct landlock_hierarchy *const hierarchy) { } diff --git a/security/landlock/cred.c b/security/landlock/cred.c index cc419de75cd6..03449c26247e 100644 --- a/security/landlock/cred.c +++ b/security/landlock/cred.c @@ -22,7 +22,7 @@ static void hook_cred_transfer(struct cred *const new, const struct landlock_cred_security *const old_llcred = landlock_cred(old); - landlock_get_ruleset(old_llcred->domain); + landlock_get_domain(old_llcred->domain); *landlock_cred(new) = *old_llcred; } @@ -35,13 +35,13 @@ static int hook_cred_prepare(struct cred *const new, static void hook_cred_free(struct cred *const cred) { - struct landlock_ruleset *const dom = landlock_cred(cred)->domain; + struct landlock_domain *const dom = landlock_cred(cred)->domain; if (dom) - landlock_put_ruleset_deferred(dom); + landlock_put_domain_deferred(dom); } -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG static int hook_bprm_creds_for_exec(struct linux_binprm *const bprm) { @@ -50,16 +50,16 @@ static int hook_bprm_creds_for_exec(struct linux_binprm *const bprm) return 0; } -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ static struct security_hook_list landlock_hooks[] __ro_after_init = { LSM_HOOK_INIT(cred_prepare, hook_cred_prepare), LSM_HOOK_INIT(cred_transfer, hook_cred_transfer), LSM_HOOK_INIT(cred_free, hook_cred_free), -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG LSM_HOOK_INIT(bprm_creds_for_exec, hook_bprm_creds_for_exec), -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ }; __init void landlock_add_cred_hooks(void) diff --git a/security/landlock/cred.h b/security/landlock/cred.h index f287c56b5fd4..a5ff9957949a 100644 --- a/security/landlock/cred.h +++ b/security/landlock/cred.h @@ -16,6 +16,7 @@ #include #include "access.h" +#include "domain.h" #include "limits.h" #include "ruleset.h" #include "setup.h" @@ -31,11 +32,11 @@ */ struct landlock_cred_security { /** - * @domain: Immutable ruleset enforced on a task. + * @domain: Immutable domain enforced on a task. */ - struct landlock_ruleset *domain; + struct landlock_domain *domain; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /** * @domain_exec: Bitmask identifying the domain layers that were enforced by * the current task's executed file (i.e. no new execve(2) since @@ -49,17 +50,17 @@ struct landlock_cred_security { * not require a current domain. */ u8 log_subdomains_off : 1; -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } __packed; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /* Makes sure all layer executions can be stored. */ static_assert(BITS_PER_TYPE(typeof_member(struct landlock_cred_security, domain_exec)) >= LANDLOCK_MAX_NUM_LAYERS); -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ static inline struct landlock_cred_security * landlock_cred(const struct cred *cred) @@ -70,22 +71,20 @@ landlock_cred(const struct cred *cred) static inline void landlock_cred_copy(struct landlock_cred_security *dst, const struct landlock_cred_security *src) { - landlock_put_ruleset(dst->domain); + landlock_put_domain(dst->domain); *dst = *src; - landlock_get_ruleset(src->domain); + landlock_get_domain(src->domain); } -static inline struct landlock_ruleset *landlock_get_current_domain(void) +static inline struct landlock_domain *landlock_get_current_domain(void) { return landlock_cred(current_cred())->domain; } -/* - * The call needs to come from an RCU read-side critical section. - */ -static inline const struct landlock_ruleset * +/* The call needs to come from an RCU read-side critical section. */ +static inline const struct landlock_domain * landlock_get_task_domain(const struct task_struct *const task) { return landlock_cred(__task_cred(task))->domain; @@ -126,7 +125,7 @@ landlock_get_applicable_subject(const struct cred *const cred, const union access_masks_all masks_all = { .masks = masks, }; - const struct landlock_ruleset *domain; + const struct landlock_domain *domain; ssize_t layer_level; if (!cred) @@ -139,7 +138,7 @@ landlock_get_applicable_subject(const struct cred *const cred, for (layer_level = domain->num_layers - 1; layer_level >= 0; layer_level--) { union access_masks_all layer = { - .masks = domain->access_masks[layer_level], + .masks = domain->handled_masks[layer_level], }; if (layer.all & masks_all.all) { diff --git a/security/landlock/domain.c b/security/landlock/domain.c index 9a8355fccd26..93c7104fd6b2 100644 --- a/security/landlock/domain.c +++ b/security/landlock/domain.c @@ -5,26 +5,486 @@ * Copyright © 2016-2020 Mickaël Salaün * Copyright © 2018-2020 ANSSI * Copyright © 2024-2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. */ #include #include #include +#include #include +#include #include +#include #include +#include +#include #include #include +#include +#include #include #include +#include #include +#include #include "access.h" #include "common.h" #include "domain.h" #include "id.h" +#include "limits.h" +#include "ruleset.h" -#ifdef CONFIG_AUDIT +static void build_check_domain(void) +{ + const struct landlock_domain domain = { + .num_layers = ~0, + }; + + BUILD_BUG_ON(domain.num_layers < LANDLOCK_MAX_NUM_LAYERS); +} + +static struct landlock_domain *create_domain(const u32 num_layers) +{ + struct landlock_domain *new_domain; + + build_check_domain(); + new_domain = kzalloc_flex(*new_domain, handled_masks, num_layers, + GFP_KERNEL_ACCOUNT); + if (!new_domain) + return ERR_PTR(-ENOMEM); + + refcount_set(&new_domain->usage, 1); + new_domain->rules.root_inode = RB_ROOT; + +#if IS_ENABLED(CONFIG_INET) + new_domain->rules.root_net_port = RB_ROOT; +#endif /* IS_ENABLED(CONFIG_INET) */ + + new_domain->num_layers = num_layers; + return new_domain; +} + +static void free_domain(struct landlock_domain *const domain) +{ + might_sleep(); + landlock_free_rules(&domain->rules); + landlock_put_hierarchy(domain->hierarchy); + kfree(domain); +} + +void landlock_put_domain(struct landlock_domain *const domain) +{ + might_sleep(); + if (domain && refcount_dec_and_test(&domain->usage)) + free_domain(domain); +} + +static void free_domain_work(struct work_struct *const work) +{ + struct landlock_domain *domain; + + domain = container_of(work, struct landlock_domain, work_free); + free_domain(domain); +} + +void landlock_put_domain_deferred(struct landlock_domain *const domain) +{ + if (domain && refcount_dec_and_test(&domain->usage)) { + INIT_WORK(&domain->work_free, free_domain_work); + schedule_work(&domain->work_free); + } +} + +/* The returned access has the same lifetime as the domain. */ +static const struct landlock_rule * +find_rule(const struct landlock_domain *const domain, + const struct landlock_id id) +{ + const struct rb_root *root; + const struct rb_node *node; + + root = landlock_get_rule_root((struct landlock_rules *)&domain->rules, + id.type); + if (IS_ERR(root)) + return NULL; + node = root->rb_node; + + while (node) { + struct landlock_rule *this = + rb_entry(node, struct landlock_rule, node); + + if (this->key.data == id.key.data) + return this; + if (this->key.data < id.key.data) + node = node->rb_right; + else + node = node->rb_left; + } + return NULL; +} + +/** + * landlock_unmask_layers - Remove the access rights in @masks which are + * granted by a matching rule + * + * Looks up the rule matching @id in @domain, then updates the set of + * (per-layer) unfulfilled access rights @masks so that all the access rights + * granted by that rule are removed (because they are now fulfilled). + * + * @domain: The Landlock domain to search for a matching rule. + * @id: Identifier for the rule target (e.g. inode, port). + * @masks: A matrix of unfulfilled access rights for each layer. + * @matched_rule: Optional output for the matched rule (for tracing); set to + * the matching rule when non-NULL, unchanged otherwise. + * + * Return: True if the request is allowed (i.e. the access rights granted all + * remaining unfulfilled access rights and masks has no leftover set bits). + */ +bool landlock_unmask_layers(const struct landlock_domain *const domain, + const struct landlock_id id, + struct layer_masks *masks, + const struct landlock_rule **matched_rule) +{ + const struct landlock_rule *rule; + + if (!masks) + return true; + + rule = find_rule(domain, id); + if (!rule) + return false; + + if (matched_rule) + *matched_rule = rule; + + /* + * An access is granted if, for each policy layer, at least one rule + * encountered on the pathwalk grants the requested access, regardless + * of its position in the layer stack. We must then check the remaining + * layers for each inode, from the first added layer to the last one. + * When there are multiple requested accesses, for each policy layer, + * the full set of requested accesses may not be granted by only one + * rule, but by the union (binary OR) of multiple rules. For example, + * /a/b + /a grants /a/b . + * + * This function is called once per matching rule during the pathwalk, + * progressively clearing bits in @masks. The overall access decision + * is per-layer: access is granted iff masks->layers[l].access == 0 for + * all layers l. When two independent mechanisms can each grant access + * within a layer (e.g. a path rule OR a scope exception), the + * composition must evaluate per-layer: FOR-ALL l (A(l) OR B(l)), not + * (FOR-ALL l A(l)) OR (FOR-ALL l B(l)), to prevent bypass when + * different layers grant via different mechanisms. + */ + for (size_t i = 0; i < rule->num_layers; i++) { + const struct landlock_layer *const layer = &rule->layers[i]; + + /* Clear the bits where the layer in the rule grants access. */ + masks->layers[layer->level - 1].access &= ~layer->access; + +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + /* Collect rule flags for each layer. */ + if (layer->flags.quiet) + masks->layers[layer->level - 1].quiet = true; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + } + + for (size_t i = 0; i < ARRAY_SIZE(masks->layers); i++) { + if (masks->layers[i].access) + return false; + } + return true; +} + +typedef access_mask_t +get_access_mask_t(const struct landlock_domain *const domain, + const u16 layer_level); + +/** + * landlock_init_layer_masks - Initialize layer masks from an access request + * + * Populates @masks such that for each access right in @access_request, the bits + * for all the layers are set where this access right is handled. Rule flags + * are also zeroed. + * + * @domain: The domain that defines the current restrictions. + * @access_request: The requested access rights to check. + * @masks: Layer access masks to populate. + * @key_type: The key type to switch between access masks of different types. + * + * Return: An access mask where each access right bit is set which is handled in + * any of the active layers in @domain. + */ +access_mask_t +landlock_init_layer_masks(const struct landlock_domain *const domain, + const access_mask_t access_request, + struct layer_masks *const masks, + const enum landlock_key_type key_type) +{ + access_mask_t handled_accesses = 0; + get_access_mask_t *get_access_mask; + + switch (key_type) { + case LANDLOCK_KEY_INODE: + get_access_mask = landlock_get_fs_access_mask; + break; + +#if IS_ENABLED(CONFIG_INET) + case LANDLOCK_KEY_NET_PORT: + get_access_mask = landlock_get_net_access_mask; + break; +#endif /* IS_ENABLED(CONFIG_INET) */ + + default: + WARN_ON_ONCE(1); + return 0; + } + + /* An empty access request can happen because of O_WRONLY | O_RDWR. */ + if (!access_request) + return 0; + + for (size_t i = 0; i < domain->num_layers; i++) { + const access_mask_t handled = get_access_mask(domain, i); + + masks->layers[i].access = access_request & handled; + handled_accesses |= masks->layers[i].access; +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + masks->layers[i].quiet = false; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + } + for (size_t i = domain->num_layers; i < ARRAY_SIZE(masks->layers); + i++) { + masks->layers[i].access = 0; +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + masks->layers[i].quiet = false; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + } + + return handled_accesses; +} + +static int merge_tree(struct landlock_domain *const dst, + struct landlock_ruleset *const src, + const enum landlock_key_type key_type) +{ + struct landlock_rule *walker_rule, *next_rule; + struct rb_root *src_root; + int err = 0; + + might_sleep(); + lockdep_assert_held(&src->lock); + + src_root = landlock_get_rule_root(&src->rules, key_type); + if (IS_ERR(src_root)) + return PTR_ERR(src_root); + + /* Merges the @src tree. */ + rbtree_postorder_for_each_entry_safe(walker_rule, next_rule, src_root, + node) { + struct landlock_layer layers[] = { { + .level = dst->num_layers, + } }; + const struct landlock_id id = { + .key = walker_rule->key, + .type = key_type, + }; + + if (WARN_ON_ONCE(walker_rule->num_layers != 1)) + return -EINVAL; + + if (WARN_ON_ONCE(walker_rule->layers[0].level != 0)) + return -EINVAL; + + layers[0].access = walker_rule->layers[0].access; + layers[0].flags = walker_rule->layers[0].flags; + + err = landlock_store_rule(&dst->rules, id, &layers, + ARRAY_SIZE(layers)); + if (err) + return err; + } + return err; +} + +static int merge_ruleset(struct landlock_domain *const dst, + struct landlock_ruleset *const src) +{ + int err = 0; + + might_sleep(); + /* Should already be checked by landlock_merge_ruleset() */ + if (WARN_ON_ONCE(!src)) + return 0; + /* Only merge into a domain. */ + if (WARN_ON_ONCE(!dst || !dst->hierarchy)) + return -EINVAL; + + lockdep_assert_held(&src->lock); + + /* Stacks the new layer. */ + if (WARN_ON_ONCE(dst->num_layers < 1)) + return -EINVAL; + + dst->handled_masks[dst->num_layers - 1] = + landlock_upgrade_handled_access_masks(src->handled_masks); + + /* Merges the @src inode tree. */ + err = merge_tree(dst, src, LANDLOCK_KEY_INODE); + if (err) + return err; + +#if IS_ENABLED(CONFIG_INET) + /* Merges the @src network port tree. */ + err = merge_tree(dst, src, LANDLOCK_KEY_NET_PORT); + if (err) + return err; +#endif /* IS_ENABLED(CONFIG_INET) */ + + return 0; +} + +static int inherit_tree(struct landlock_domain *const parent, + struct landlock_domain *const child, + const enum landlock_key_type key_type) +{ + struct landlock_rule *walker_rule, *next_rule; + struct rb_root *parent_root; + int err = 0; + + might_sleep(); + + parent_root = landlock_get_rule_root(&parent->rules, key_type); + if (IS_ERR(parent_root)) + return PTR_ERR(parent_root); + + /* Copies the @parent inode or network tree. */ + rbtree_postorder_for_each_entry_safe(walker_rule, next_rule, + parent_root, node) { + const struct landlock_id id = { + .key = walker_rule->key, + .type = key_type, + }; + + err = landlock_store_rule(&child->rules, id, + &walker_rule->layers, + walker_rule->num_layers); + if (err) + return err; + } + return err; +} + +static int inherit_ruleset(struct landlock_domain *const parent, + struct landlock_domain *const child) +{ + int err = 0; + + might_sleep(); + if (!parent) + return 0; + + /* Copies the @parent inode tree. */ + err = inherit_tree(parent, child, LANDLOCK_KEY_INODE); + if (err) + return err; + +#if IS_ENABLED(CONFIG_INET) + /* Copies the @parent network port tree. */ + err = inherit_tree(parent, child, LANDLOCK_KEY_NET_PORT); + if (err) + return err; +#endif /* IS_ENABLED(CONFIG_INET) */ + + if (WARN_ON_ONCE(child->num_layers <= parent->num_layers)) + return -EINVAL; + + /* + * Copies the parent layer stack and leaves a space for the new layer. + */ + memcpy(child->handled_masks, parent->handled_masks, + flex_array_size(parent, handled_masks, parent->num_layers)); + + if (WARN_ON_ONCE(!parent->hierarchy)) + return -EINVAL; + + landlock_get_hierarchy(parent->hierarchy); + child->hierarchy->parent = parent->hierarchy; + + return 0; +} + +/** + * landlock_merge_ruleset - Merge a ruleset with a domain + * + * @parent: Parent domain. + * @ruleset: New ruleset to be merged. + * + * The current task is requesting to be restricted. The subjective credentials + * must not be in an overridden state. cf. landlock_init_hierarchy_log(). + * + * The caller must hold @ruleset->lock. + * + * Return: A new domain merging @parent and @ruleset on success, or ERR_PTR() on + * failure. If @parent is NULL, the new domain duplicates @ruleset. + */ +struct landlock_domain * +landlock_merge_ruleset(struct landlock_domain *const parent, + struct landlock_ruleset *const ruleset) +{ + struct landlock_domain *new_dom __free(landlock_put_domain) = NULL; + u32 num_layers; + int err; + + might_sleep(); + lockdep_assert_held(&ruleset->lock); + if (WARN_ON_ONCE(!ruleset)) + return ERR_PTR(-EINVAL); + + if (parent) { + if (parent->num_layers >= LANDLOCK_MAX_NUM_LAYERS) + return ERR_PTR(-E2BIG); + num_layers = parent->num_layers + 1; + } else { + num_layers = 1; + } + + /* Creates a new domain... */ + new_dom = create_domain(num_layers); + if (IS_ERR(new_dom)) + return new_dom; + + new_dom->hierarchy = + kzalloc_obj(*new_dom->hierarchy, GFP_KERNEL_ACCOUNT); + if (!new_dom->hierarchy) + return ERR_PTR(-ENOMEM); + + refcount_set(&new_dom->hierarchy->usage, 1); + + /* ...as a child of @parent... */ + err = inherit_ruleset(parent, new_dom); + if (err) + return ERR_PTR(err); + + /* ...and including @ruleset. */ + err = merge_ruleset(new_dom, ruleset); + if (err) + return ERR_PTR(err); + + err = landlock_init_hierarchy_log(new_dom->hierarchy); + if (err) + return ERR_PTR(err); + +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + new_dom->hierarchy->quiet_masks = ruleset->quiet_masks; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + + return no_free_ptr(new_dom); +} + +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /** * get_current_exe - Get the current's executable path, if any @@ -128,7 +588,13 @@ int landlock_init_hierarchy_log(struct landlock_hierarchy *const hierarchy) hierarchy->details = details; hierarchy->id = landlock_get_id_range(1); - hierarchy->log_status = LANDLOCK_LOG_PENDING; + /* + * The hierarchy is born unobservable: landlock_restrict_self() moves it + * out of LANDLOCK_LOG_UNCOMMITTED once it has emitted the creation + * event, so the matching free_domain event fires for it and not for a + * hierarchy whose creation was never observed. + */ + hierarchy->log_status = LANDLOCK_LOG_UNCOMMITTED; hierarchy->log_same_exec = true; hierarchy->log_new_exec = false; atomic64_set(&hierarchy->num_denials, 0); @@ -306,4 +772,4 @@ kunit_test_suite(test_suite); #endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ diff --git a/security/landlock/domain.h b/security/landlock/domain.h index 2a1660e3dea7..5ce2f91488d5 100644 --- a/security/landlock/domain.h +++ b/security/landlock/domain.h @@ -5,11 +5,13 @@ * Copyright © 2016-2020 Mickaël Salaün * Copyright © 2018-2020 ANSSI * Copyright © 2024-2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. */ #ifndef _SECURITY_LANDLOCK_DOMAIN_H #define _SECURITY_LANDLOCK_DOMAIN_H +#include #include #include #include @@ -17,12 +19,28 @@ #include #include #include +#include #include "access.h" -#include "audit.h" +#include "log.h" +#include "ruleset.h" enum landlock_log_status { - LANDLOCK_LOG_PENDING = 0, + /* + * Hierarchy whose creation event has not been emitted, so it is not yet + * observable from user space. A hierarchy is born in this state (the + * zero value, so a partially initialized hierarchy defaults to "not + * observable") and leaves it when landlock_restrict_self() emits its + * creation event, right after the merge and before the thread-sync + * wait. No trace free_domain event (and no audit deallocation record) + * fires while a hierarchy is in this state, so a hierarchy that never + * became observable (e.g. its initialization failed) is freed silently. + * A domain aborted by a thread-sync failure already emitted its + * creation event, so it is no longer UNCOMMITTED and does fire + * free_domain. + */ + LANDLOCK_LOG_UNCOMMITTED = 0, + LANDLOCK_LOG_PENDING, LANDLOCK_LOG_RECORDED, LANDLOCK_LOG_DISABLED, }; @@ -81,7 +99,7 @@ struct landlock_hierarchy { */ refcount_t usage; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /** * @log_status: Whether this domain should be logged or not. Because * concurrent log entries may be created at the same time, it is still @@ -116,10 +134,10 @@ struct landlock_hierarchy { * logged) if the related object is marked as quiet. */ struct access_masks quiet_masks; -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ }; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG deny_masks_t landlock_get_deny_masks(const access_mask_t all_existing_optional_access, @@ -142,7 +160,7 @@ landlock_free_hierarchy_details(struct landlock_hierarchy *const hierarchy) kfree(hierarchy->details); } -#else /* CONFIG_AUDIT */ +#else /* CONFIG_SECURITY_LANDLOCK_LOG */ static inline int landlock_init_hierarchy_log(struct landlock_hierarchy *const hierarchy) @@ -155,7 +173,7 @@ landlock_free_hierarchy_details(struct landlock_hierarchy *const hierarchy) { } -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ static inline void landlock_get_hierarchy(struct landlock_hierarchy *const hierarchy) @@ -169,11 +187,140 @@ static inline void landlock_put_hierarchy(struct landlock_hierarchy *hierarchy) while (hierarchy && refcount_dec_and_test(&hierarchy->usage)) { const struct landlock_hierarchy *const freeme = hierarchy; - landlock_log_drop_domain(hierarchy); + landlock_log_free_domain(hierarchy); landlock_free_hierarchy_details(hierarchy); hierarchy = hierarchy->parent; kfree(freeme); } } +/** + * struct landlock_domain - Immutable Landlock domain + * + * A domain is created from a ruleset by landlock_merge_ruleset() and enforced + * on a task. Once created, its rules and access masks are immutable. Unlike + * &struct landlock_ruleset, a domain has no lock field. + */ +struct landlock_domain { + /** + * @rules: Red-black tree storage for rules. + */ + struct landlock_rules rules; + /** + * @hierarchy: Enables hierarchy identification even when a parent + * domain vanishes. This is needed for the ptrace and scope + * restrictions. + */ + struct landlock_hierarchy *hierarchy; + union { + /** + * @work_free: Enables to free a domain within a lockless + * section. This is only used by landlock_put_domain_deferred() + * when @usage reaches zero. The fields @usage, @num_layers and + * @handled_masks are then unused. + */ + struct work_struct work_free; + struct { + /** + * @usage: Number of credentials referencing this + * domain. + */ + refcount_t usage; + /** + * @num_layers: Number of layers that are used in this + * domain. This enables to check that all the layers + * allow an access request. + */ + u32 num_layers; + /** + * @handled_masks: Contains the subset of filesystem and + * network actions that are restricted by a domain. A + * domain saves all layers of merged rulesets in a stack + * (FAM), starting from the first layer to the last one. + * These layers are used when merging rulesets, for user + * space backward compatibility (i.e. future-proof), and + * to properly handle merged rulesets without + * overlapping access rights. These layers are set once + * and never changed for the lifetime of the domain. + */ + struct access_masks handled_masks[]; + }; + }; +}; + +static inline access_mask_t +landlock_get_fs_access_mask(const struct landlock_domain *const domain, + const u16 layer_level) +{ + /* Handles all initially denied by default access rights. */ + return domain->handled_masks[layer_level].fs | + _LANDLOCK_ACCESS_FS_INITIALLY_DENIED; +} + +static inline access_mask_t +landlock_get_net_access_mask(const struct landlock_domain *const domain, + const u16 layer_level) +{ + return domain->handled_masks[layer_level].net; +} + +static inline access_mask_t +landlock_get_scope_mask(const struct landlock_domain *const domain, + const u16 layer_level) +{ + return domain->handled_masks[layer_level].scope; +} + +/** + * landlock_union_access_masks - Return all access rights handled in the + * domain + * + * @domain: Landlock domain + * + * Return: An access_masks result of the OR of all the domain's access masks. + */ +static inline struct access_masks +landlock_union_access_masks(const struct landlock_domain *const domain) +{ + union access_masks_all matches = {}; + size_t layer_level; + + for (layer_level = 0; layer_level < domain->num_layers; layer_level++) { + union access_masks_all layer = { + .masks = domain->handled_masks[layer_level], + }; + + matches.all |= layer.all; + } + + return matches.masks; +} + +void landlock_put_domain(struct landlock_domain *const domain); +void landlock_put_domain_deferred(struct landlock_domain *const domain); + +DEFINE_FREE(landlock_put_domain, struct landlock_domain *, + if (!IS_ERR_OR_NULL(_T)) landlock_put_domain(_T)) + +struct landlock_domain * +landlock_merge_ruleset(struct landlock_domain *const parent, + struct landlock_ruleset *const ruleset); + +bool landlock_unmask_layers(const struct landlock_domain *const domain, + const struct landlock_id id, + struct layer_masks *masks, + const struct landlock_rule **matched_rule); + +access_mask_t +landlock_init_layer_masks(const struct landlock_domain *const domain, + const access_mask_t access_request, + struct layer_masks *masks, + const enum landlock_key_type key_type); + +static inline void landlock_get_domain(struct landlock_domain *const domain) +{ + if (domain) + refcount_inc(&domain->usage); +} + #endif /* _SECURITY_LANDLOCK_DOMAIN_H */ diff --git a/security/landlock/errata/abi-1.h b/security/landlock/errata/abi-1.h index 3f099555f059..315ea7e0fe50 100644 --- a/security/landlock/errata/abi-1.h +++ b/security/landlock/errata/abi-1.h @@ -22,3 +22,26 @@ * from their original mount points. */ LANDLOCK_ERRATUM(3) + +/** + * DOC: erratum_4 + * + * Erratum 4: Creation of whiteout objects + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * This fix changes the access rights required for the creation of whiteout + * objects through :manpage:`mknod(2)`, :manpage:`renameat2(2)`, or + * :manpage:`link(2)`. Creating whiteout objects is now guarded by + * ``LANDLOCK_ACCESS_FS_MAKE_REG`` instead of ``LANDLOCK_ACCESS_FS_MAKE_CHAR``. + * + * Whiteout objects are used in OverlayFS to mark the absence of a file in an + * upper file system. Despite being created with ``S_IFCHR``, whiteout objects + * do not count as character devices. + * + * Impact: + * + * Sandboxed programs that create OverlayFS whiteouts (such as fuse-overlayfs) + * now require ``LANDLOCK_ACCESS_FS_MAKE_REG`` instead of + * ``LANDLOCK_ACCESS_FS_MAKE_CHAR``. + */ +LANDLOCK_ERRATUM(4) diff --git a/security/landlock/fs.c b/security/landlock/fs.c index f7e5e4ef9eac..30aa6ce13590 100644 --- a/security/landlock/fs.c +++ b/security/landlock/fs.c @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -42,16 +43,18 @@ #include #include "access.h" -#include "audit.h" #include "common.h" #include "cred.h" #include "domain.h" #include "fs.h" #include "limits.h" +#include "log.h" #include "object.h" #include "ruleset.h" #include "setup.h" +#include + /* Underlying object management */ static void release_inode(struct landlock_object *const object) @@ -336,18 +339,34 @@ int landlock_append_fs_rule(struct landlock_ruleset *const ruleset, if (!d_is_dir(path->dentry) && !access_mask_subset(access_rights, ACCESS_FILE)) return -EINVAL; - if (WARN_ON_ONCE(ruleset->num_layers != 1)) - return -EINVAL; /* Transforms relative access rights to absolute ones. */ access_rights |= LANDLOCK_MASK_ACCESS_FS & - ~landlock_get_fs_access_mask(ruleset, 0); + ~(ruleset->handled_masks.fs | + _LANDLOCK_ACCESS_FS_INITIALLY_DENIED); id.key.object = get_inode_object(d_backing_inode(path->dentry)); if (IS_ERR(id.key.object)) return PTR_ERR(id.key.object); mutex_lock(&ruleset->lock); err = landlock_insert_rule(ruleset, id, access_rights, flags); + + /* + * Emit after the rule insertion succeeds, so every event corresponds to + * a rule that is actually in the ruleset. The ruleset lock is still + * held for BTF consistency (enforced by lockdep_assert_held in + * TP_fast_assign). + */ + if (!err && trace_landlock_add_rule_fs_enabled()) { + char *buffer __free(__putname) = __getname(); + const char *pathname = + buffer ? resolve_path_for_trace(path, buffer) : + ""; + + trace_landlock_add_rule_fs(ruleset, access_rights, path, + pathname); + } mutex_unlock(&ruleset->lock); + /* * No need to check for an error because landlock_insert_rule() * increments the refcount for the new object if needed. @@ -358,31 +377,55 @@ int landlock_append_fs_rule(struct landlock_ruleset *const ruleset, /* Access-control management */ -/* - * The lifetime of the returned rule is tied to @domain. +/** + * get_inode_id - Look up the Landlock object for a dentry + * @dentry: The dentry to look up. + * @id: Filled with the inode's Landlock object pointer on success. * - * Returns NULL if no rule is found or if @dentry is negative. + * Extracts the Landlock object pointer from @dentry's inode security blob and + * stores it in @id for use as a rule-tree lookup key. + * + * When this returns false (negative dentry or no Landlock object), no rule can + * match this inode, so landlock_unmask_layers() need not be called. Callers + * that gate landlock_unmask_layers() on this function must handle the NULL + * masks case independently, since the !masks-returns-true early-return in + * landlock_unmask_layers() will not be reached. See the allowed_parent2 + * initialization in is_access_to_paths_allowed(). + * + * Return: True if a Landlock object exists for @dentry, false otherwise. */ -static const struct landlock_rule * -find_rule(const struct landlock_ruleset *const domain, - const struct dentry *const dentry) +static bool get_inode_id(const struct dentry *const dentry, + struct landlock_id *id) { - const struct landlock_rule *rule; - const struct inode *inode; - struct landlock_id id = { - .type = LANDLOCK_KEY_INODE, - }; - /* Ignores nonexistent leafs. */ if (d_is_negative(dentry)) - return NULL; + return false; - inode = d_backing_inode(dentry); - rcu_read_lock(); - id.key.object = rcu_dereference(landlock_inode(inode)->object); - rule = landlock_find_rule(domain, id); - rcu_read_unlock(); - return rule; + /* + * rcu_access_pointer() is sufficient: the pointer is used only as a + * numeric comparison key for rule lookup, not dereferenced. The object + * cannot be freed while the domain exists because the domain's rule + * tree holds its own reference to it. + */ + id->key.object = rcu_access_pointer( + landlock_inode(d_backing_inode(dentry))->object); + return !!id->key.object; +} + +static bool unmask_layers_fs(const struct landlock_domain *const domain, + const struct landlock_id id, + const access_mask_t access_request, + struct layer_masks *masks, + const struct dentry *const dentry) +{ + const struct landlock_rule *rule = NULL; + bool ret; + + ret = landlock_unmask_layers(domain, id, masks, &rule); + if (rule) + trace_landlock_check_rule_fs(domain, rule, access_request, + dentry); + return ret; } /* @@ -749,7 +792,7 @@ static void test_is_eacces_with_write(struct kunit *const test) * Return: True if the access request is granted, false otherwise. */ static bool -is_access_to_paths_allowed(const struct landlock_ruleset *const domain, +is_access_to_paths_allowed(const struct landlock_domain *const domain, const struct path *const path, const access_mask_t access_request_parent1, struct layer_masks *layer_masks_parent1, @@ -763,6 +806,9 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, bool allowed_parent1 = false, allowed_parent2 = false, is_dom_check, child1_is_directory = true, child2_is_directory = true; struct path walker_path; + struct landlock_id id = { + .type = LANDLOCK_KEY_INODE, + }; access_mask_t access_masked_parent1, access_masked_parent2; struct layer_masks _layer_masks_child1, _layer_masks_child2; struct layer_masks *layer_masks_child1 = NULL, @@ -802,28 +848,46 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, /* For a simple request, only check for requested accesses. */ access_masked_parent1 = access_request_parent1; access_masked_parent2 = access_request_parent2; + /* + * Simple requests have no parent2 to check, so parent2 is + * trivially allowed. This must be set explicitly because the + * get_inode_id() gate in the pathwalk loop may prevent + * landlock_unmask_layers() from being called (which would + * otherwise return true for NULL masks as a side effect). + */ + allowed_parent2 = true; is_dom_check = false; } if (unlikely(dentry_child1)) { - /* - * Get the layer masks for the child dentries for use by domain - * check later. - */ - if (landlock_init_layer_masks(domain, LANDLOCK_MASK_ACCESS_FS, - &_layer_masks_child1, - LANDLOCK_KEY_INODE)) - landlock_unmask_layers(find_rule(domain, dentry_child1), - &_layer_masks_child1); + struct landlock_id id = { + .type = LANDLOCK_KEY_INODE, + }; + access_mask_t handled; + + handled = landlock_init_layer_masks(domain, + LANDLOCK_MASK_ACCESS_FS, + &_layer_masks_child1, + LANDLOCK_KEY_INODE); + if (handled && get_inode_id(dentry_child1, &id)) + unmask_layers_fs(domain, id, handled, + &_layer_masks_child1, dentry_child1); layer_masks_child1 = &_layer_masks_child1; child1_is_directory = d_is_dir(dentry_child1); } if (unlikely(dentry_child2)) { - if (landlock_init_layer_masks(domain, LANDLOCK_MASK_ACCESS_FS, - &_layer_masks_child2, - LANDLOCK_KEY_INODE)) - landlock_unmask_layers(find_rule(domain, dentry_child2), - &_layer_masks_child2); + struct landlock_id id = { + .type = LANDLOCK_KEY_INODE, + }; + access_mask_t handled; + + handled = landlock_init_layer_masks(domain, + LANDLOCK_MASK_ACCESS_FS, + &_layer_masks_child2, + LANDLOCK_KEY_INODE); + if (handled && get_inode_id(dentry_child2, &id)) + unmask_layers_fs(domain, id, handled, + &_layer_masks_child2, dentry_child2); layer_masks_child2 = &_layer_masks_child2; child2_is_directory = d_is_dir(dentry_child2); } @@ -835,8 +899,6 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, * restriction. */ while (true) { - const struct landlock_rule *rule; - /* * If at least all accesses allowed on the destination are * already allowed on the source, respectively if there is at @@ -877,13 +939,20 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, break; } - rule = find_rule(domain, walker_path.dentry); - allowed_parent1 = - allowed_parent1 || - landlock_unmask_layers(rule, layer_masks_parent1); - allowed_parent2 = - allowed_parent2 || - landlock_unmask_layers(rule, layer_masks_parent2); + if (get_inode_id(walker_path.dentry, &id)) { + allowed_parent1 = + allowed_parent1 || + unmask_layers_fs(domain, id, + access_masked_parent1, + layer_masks_parent1, + walker_path.dentry); + allowed_parent2 = + allowed_parent2 || + unmask_layers_fs(domain, id, + access_masked_parent2, + layer_masks_parent2, + walker_path.dentry); + } /* Stops when a rule from each layer grants access. */ if (allowed_parent1 && allowed_parent2) @@ -933,10 +1002,11 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, path_put(&walker_path); /* - * Check CONFIG_AUDIT to enable elision of log_request_parent* and - * associated caller's stack variables thanks to dead code elimination. + * Check CONFIG_SECURITY_LANDLOCK_LOG to enable elision of + * log_request_parent* and associated caller's stack variables thanks to + * dead code elimination. */ -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG if (!allowed_parent1 && log_request_parent1) { log_request_parent1->type = LANDLOCK_REQUEST_FS_ACCESS; log_request_parent1->audit.type = LSM_AUDIT_DATA_PATH; @@ -952,7 +1022,7 @@ is_access_to_paths_allowed(const struct landlock_ruleset *const domain, log_request_parent2->access = access_masked_parent2; log_request_parent2->layer_masks = layer_masks_parent2; } -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ return allowed_parent1 && allowed_parent2; } @@ -983,7 +1053,8 @@ static int current_check_access_path(const struct path *const path, return -EACCES; } -static __attribute_const__ access_mask_t get_mode_access(const umode_t mode) +static __attribute_const__ access_mask_t get_mode_access(const umode_t mode, + const dev_t dev) { switch (mode & S_IFMT) { case S_IFLNK: @@ -991,6 +1062,9 @@ static __attribute_const__ access_mask_t get_mode_access(const umode_t mode) case S_IFDIR: return LANDLOCK_ACCESS_FS_MAKE_DIR; case S_IFCHR: + /* Whiteout objects are guarded with MAKE_REG. */ + if (dev == WHITEOUT_DEV) + return LANDLOCK_ACCESS_FS_MAKE_REG; return LANDLOCK_ACCESS_FS_MAKE_CHAR; case S_IFBLK: return LANDLOCK_ACCESS_FS_MAKE_BLOCK; @@ -1007,6 +1081,13 @@ static __attribute_const__ access_mask_t get_mode_access(const umode_t mode) } } +static access_mask_t get_dentry_access(const struct dentry *const dentry) +{ + const struct inode *const inode = d_backing_inode(dentry); + + return get_mode_access(inode->i_mode, inode->i_rdev); +} + static access_mask_t maybe_remove(const struct dentry *const dentry) { if (d_is_negative(dentry)) @@ -1039,29 +1120,36 @@ static access_mask_t maybe_remove(const struct dentry *const dentry) * Return: True if all the domain access rights are allowed for @dir, false if * the walk reached @mnt_root. */ -static bool collect_domain_accesses(const struct landlock_ruleset *const domain, +static bool collect_domain_accesses(const struct landlock_domain *const domain, const struct dentry *const mnt_root, struct dentry *dir, struct layer_masks *layer_masks_dom) { bool ret = false; + access_mask_t access_masked_dom; if (WARN_ON_ONCE(!domain || !mnt_root || !dir || !layer_masks_dom)) return true; if (is_nouser_or_private(dir)) return true; - if (!landlock_init_layer_masks(domain, LANDLOCK_MASK_ACCESS_FS, - layer_masks_dom, LANDLOCK_KEY_INODE)) + access_masked_dom = + landlock_init_layer_masks(domain, LANDLOCK_MASK_ACCESS_FS, + layer_masks_dom, LANDLOCK_KEY_INODE); + if (!access_masked_dom) return true; dget(dir); while (true) { struct dentry *parent_dentry; + struct landlock_id id = { + .type = LANDLOCK_KEY_INODE, + }; /* Gets all layers allowing all domain accesses. */ - if (landlock_unmask_layers(find_rule(domain, dir), - layer_masks_dom)) { + if (get_inode_id(dir, &id) && + unmask_layers_fs(domain, id, access_masked_dom, + layer_masks_dom, dir)) { /* * Stops when all handled accesses are allowed by at * least one rule in each layer. @@ -1093,6 +1181,7 @@ static bool collect_domain_accesses(const struct landlock_ruleset *const domain, * @new_dentry: Destination file or directory. * @removable: Sets to true if it is a rename operation. * @exchange: Sets to true if it is a rename operation with RENAME_EXCHANGE. + * @whiteout: Sets to true if it is a rename operation with RENAME_WHITEOUT. * * Because of its unprivileged constraints, Landlock relies on file hierarchies * (and not only inodes) to tie access rights to files. Being able to link or @@ -1140,7 +1229,8 @@ static bool collect_domain_accesses(const struct landlock_ruleset *const domain, static int current_check_refer_path(struct dentry *const old_dentry, const struct path *const new_dir, struct dentry *const new_dentry, - const bool removable, const bool exchange) + const bool removable, const bool exchange, + const bool whiteout) { const struct landlock_cred_security *const subject = landlock_get_applicable_subject(current_cred(), any_fs, NULL); @@ -1159,18 +1249,25 @@ static int current_check_refer_path(struct dentry *const old_dentry, if (exchange) { if (unlikely(d_is_negative(new_dentry))) return -ENOENT; - access_request_parent1 = - get_mode_access(d_backing_inode(new_dentry)->i_mode); + access_request_parent1 = get_dentry_access(new_dentry); } else { access_request_parent1 = 0; } - access_request_parent2 = - get_mode_access(d_backing_inode(old_dentry)->i_mode); + access_request_parent2 = get_dentry_access(old_dentry); if (removable) { access_request_parent1 |= maybe_remove(old_dentry); access_request_parent2 |= maybe_remove(new_dentry); } + /* + * In case of renameat2(2) with RENAME_WHITEOUT, a whiteout object is + * created in the source location, so we require an additional access + * right there. + */ + if (whiteout) + access_request_parent1 |= + get_mode_access(S_IFCHR | WHITEOUT_MODE, WHITEOUT_DEV); + /* The mount points are the same for old and new paths, cf. EXDEV. */ if (old_dentry->d_parent == new_dir->dentry) { /* @@ -1520,7 +1617,7 @@ static int hook_path_link(struct dentry *const old_dentry, struct dentry *const new_dentry) { return current_check_refer_path(old_dentry, new_dir, new_dentry, false, - false); + false, false); } static int hook_path_rename(const struct path *const old_dir, @@ -1531,7 +1628,8 @@ static int hook_path_rename(const struct path *const old_dir, { /* old_dir refers to old_dentry->d_parent and new_dir->mnt */ return current_check_refer_path(old_dentry, new_dir, new_dentry, true, - !!(flags & RENAME_EXCHANGE)); + !!(flags & RENAME_EXCHANGE), + !!(flags & RENAME_WHITEOUT)); } static int hook_path_mkdir(const struct path *const dir, @@ -1544,7 +1642,8 @@ static int hook_path_mknod(const struct path *const dir, struct dentry *const dentry, const umode_t mode, const unsigned int dev) { - return current_check_access_path(dir, get_mode_access(mode)); + return current_check_access_path( + dir, get_mode_access(mode, new_decode_dev(dev))); } static int hook_path_symlink(const struct path *const dir, @@ -1589,8 +1688,8 @@ static int hook_path_truncate(const struct path *const path) * @masks: Layer access masks to unmask * @access: Access bits that control scoping */ -static void unmask_scoped_access(const struct landlock_ruleset *const client, - const struct landlock_ruleset *const server, +static void unmask_scoped_access(const struct landlock_domain *const client, + const struct landlock_domain *const server, struct layer_masks *const masks, const access_mask_t access) { @@ -1644,7 +1743,7 @@ static void unmask_scoped_access(const struct landlock_ruleset *const client, static int hook_unix_find(const struct path *const path, struct sock *other, int flags) { - const struct landlock_ruleset *dom_other; + const struct landlock_domain *dom_other; const struct landlock_cred_security *subject; struct layer_masks layer_masks; struct landlock_request request = {}; @@ -1802,14 +1901,14 @@ static int hook_file_open(struct file *const file) * file access rights in the opened struct file. */ landlock_file(file)->allowed_access = allowed_access; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG landlock_file(file)->deny_masks = landlock_get_deny_masks( _LANDLOCK_ACCESS_FS_OPTIONAL, optional_access, &layer_masks); landlock_file(file)->quiet_optional_accesses = landlock_get_quiet_optional_accesses( _LANDLOCK_ACCESS_FS_OPTIONAL, landlock_file(file)->deny_masks, &layer_masks); -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ if (access_mask_subset(open_access_request, allowed_access)) return 0; @@ -1843,10 +1942,10 @@ static int hook_file_truncate(struct file *const file) }, .all_existing_optional_access = _LANDLOCK_ACCESS_FS_OPTIONAL, .access = LANDLOCK_ACCESS_FS_TRUNCATE, -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG .deny_masks = landlock_file(file)->deny_masks, .quiet_optional_accesses = landlock_file(file)->quiet_optional_accesses, -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ }); return -EACCES; } @@ -1883,10 +1982,10 @@ static int hook_file_ioctl_common(const struct file *const file, }, .all_existing_optional_access = _LANDLOCK_ACCESS_FS_OPTIONAL, .access = LANDLOCK_ACCESS_FS_IOCTL_DEV, -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG .deny_masks = landlock_file(file)->deny_masks, .quiet_optional_accesses = landlock_file(file)->quiet_optional_accesses, -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ }); return -EACCES; } @@ -1939,7 +2038,7 @@ static bool control_current_fowner(struct fown_struct *const fown) static void hook_file_set_fowner(struct file *file) { - struct landlock_ruleset *prev_dom; + struct landlock_domain *prev_dom; struct landlock_cred_security fown_subject = {}; struct pid *prev_tg, *fown_tg = NULL; size_t fown_layer = 0; @@ -1952,7 +2051,7 @@ static void hook_file_set_fowner(struct file *file) landlock_get_applicable_subject( current_cred(), signal_scope, &fown_layer); if (new_subject) { - landlock_get_ruleset(new_subject->domain); + landlock_get_domain(new_subject->domain); fown_subject = *new_subject; fown_tg = get_pid(task_tgid(current)); } @@ -1962,19 +2061,19 @@ static void hook_file_set_fowner(struct file *file) prev_tg = landlock_file(file)->fown_tg; landlock_file(file)->fown_subject = fown_subject; landlock_file(file)->fown_tg = fown_tg; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG landlock_file(file)->fown_layer = fown_layer; -#endif /* CONFIG_AUDIT*/ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ /* May be called in an RCU read-side critical section. */ - landlock_put_ruleset_deferred(prev_dom); + landlock_put_domain_deferred(prev_dom); put_pid(prev_tg); } static void hook_file_free_security(struct file *file) { put_pid(landlock_file(file)->fown_tg); - landlock_put_ruleset_deferred(landlock_file(file)->fown_subject.domain); + landlock_put_domain_deferred(landlock_file(file)->fown_subject.domain); } static struct security_hook_list landlock_hooks[] __ro_after_init = { diff --git a/security/landlock/fs.h b/security/landlock/fs.h index b4421d9df68f..50b3ddb8ac1c 100644 --- a/security/landlock/fs.h +++ b/security/landlock/fs.h @@ -11,6 +11,7 @@ #define _SECURITY_LANDLOCK_FS_H #include +#include #include #include #include @@ -20,6 +21,8 @@ #include "ruleset.h" #include "setup.h" +DEFINE_FREE(__putname, char *, if (_T) __putname(_T)) + /** * struct landlock_inode_security - Inode security blob * @@ -57,7 +60,7 @@ struct landlock_file_security { */ access_mask_t allowed_access; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /** * @deny_masks: Domain layer levels that deny an optional access (see * _LANDLOCK_ACCESS_FS_OPTIONAL). @@ -75,7 +78,7 @@ struct landlock_file_security { * LANDLOCK_SCOPE_SIGNAL. */ u8 fown_layer; -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ /** * @fown_subject: Landlock credential of the task that set the PID that @@ -97,7 +100,7 @@ struct landlock_file_security { struct pid *fown_tg; }; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG /* Makes sure all layers can be identified. */ /* clang-format off */ @@ -113,7 +116,7 @@ static_assert(BITS_PER_TYPE(typeof_member(struct landlock_file_security, quiet_optional_accesses)) >= HWEIGHT(_LANDLOCK_ACCESS_FS_OPTIONAL)); -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ /** * struct landlock_superblock_security - Superblock security blob @@ -153,4 +156,33 @@ int landlock_append_fs_rule(struct landlock_ruleset *const ruleset, const struct path *const path, access_mask_t access_hierarchy, const u32 flags); +/** + * resolve_path_for_trace - Resolve a path for tracepoint display + * + * @path: The path to resolve. + * @buf: A buffer of at least PATH_MAX bytes for the resolved path. + * + * Uses d_absolute_path() to produce a namespace-independent absolute path, + * unlike d_path() which resolves relative to the process's chroot. This + * ensures trace output is deterministic regardless of the tracer's mount + * namespace. + * + * Return: A pointer into @buf with the resolved path, or an error string + * ("", ""). + */ +static inline const char *resolve_path_for_trace(const struct path *path, + char *buf) +{ + const char *p; + + p = d_absolute_path(path, buf, PATH_MAX); + if (!IS_ERR_OR_NULL(p)) + return p; + + if (PTR_ERR(p) == -ENAMETOOLONG) + return ""; + + return ""; +} + #endif /* _SECURITY_LANDLOCK_FS_H */ diff --git a/security/landlock/id.h b/security/landlock/id.h index 45dcfb9e9a8b..2a43c2b523a8 100644 --- a/security/landlock/id.h +++ b/security/landlock/id.h @@ -8,18 +8,18 @@ #ifndef _SECURITY_LANDLOCK_ID_H #define _SECURITY_LANDLOCK_ID_H -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG void __init landlock_init_id(void); u64 landlock_get_id_range(size_t number_of_ids); -#else /* CONFIG_AUDIT */ +#else /* CONFIG_SECURITY_LANDLOCK_LOG */ static inline void __init landlock_init_id(void) { } -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ #endif /* _SECURITY_LANDLOCK_ID_H */ diff --git a/security/landlock/limits.h b/security/landlock/limits.h index 08d5f2f6d321..1a7c5fb8f6fd 100644 --- a/security/landlock/limits.h +++ b/security/landlock/limits.h @@ -34,7 +34,7 @@ #define LANDLOCK_NUM_ACCESS_MAX \ MAX(MAX(LANDLOCK_NUM_ACCESS_FS, LANDLOCK_NUM_ACCESS_NET), LANDLOCK_NUM_SCOPE) -#define LANDLOCK_LAST_RESTRICT_SELF LANDLOCK_RESTRICT_SELF_TSYNC +#define LANDLOCK_LAST_RESTRICT_SELF LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS #define LANDLOCK_MASK_RESTRICT_SELF ((LANDLOCK_LAST_RESTRICT_SELF << 1) - 1) /* clang-format on */ diff --git a/security/landlock/log.c b/security/landlock/log.c new file mode 100644 index 000000000000..a8578a6f2ce9 --- /dev/null +++ b/security/landlock/log.c @@ -0,0 +1,587 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Landlock - Log helpers + * + * Copyright © 2023-2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. + */ + +#include +#include +#include + +#include "access.h" +#include "audit.h" +#include "common.h" +#include "cred.h" +#include "domain.h" +#include "limits.h" +#include "log.h" +#include "ruleset.h" +#include "trace.h" + +static struct landlock_hierarchy * +get_hierarchy(const struct landlock_domain *const domain, const size_t layer) +{ + struct landlock_hierarchy *hierarchy = domain->hierarchy; + ssize_t i; + + if (WARN_ON_ONCE(layer >= domain->num_layers)) + return hierarchy; + + for (i = domain->num_layers - 1; i > layer; i--) { + if (WARN_ON_ONCE(!hierarchy->parent)) + break; + + hierarchy = hierarchy->parent; + } + + return hierarchy; +} + +#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST + +static void test_get_hierarchy(struct kunit *const test) +{ + struct landlock_hierarchy dom0_hierarchy = { + .id = 10, + }; + struct landlock_hierarchy dom1_hierarchy = { + .parent = &dom0_hierarchy, + .id = 20, + }; + struct landlock_hierarchy dom2_hierarchy = { + .parent = &dom1_hierarchy, + .id = 30, + }; + struct landlock_domain dom2 = { + .hierarchy = &dom2_hierarchy, + .num_layers = 3, + }; + + KUNIT_EXPECT_EQ(test, 10, get_hierarchy(&dom2, 0)->id); + KUNIT_EXPECT_EQ(test, 20, get_hierarchy(&dom2, 1)->id); + KUNIT_EXPECT_EQ(test, 30, get_hierarchy(&dom2, 2)->id); + /* KUNIT_EXPECT_EQ(test, 30, get_hierarchy(&dom2, -1)->id); */ +} + +#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ + +/* Get the youngest layer that denied the access_request. */ +static size_t get_denied_layer(const struct landlock_domain *const domain, + access_mask_t *const access_request, + const struct layer_masks *masks) +{ + for (ssize_t i = ARRAY_SIZE(masks->layers) - 1; i >= 0; i--) { + if (masks->layers[i].access & *access_request) { + *access_request &= masks->layers[i].access; + return i; + } + } + + /* Not found - fall back to default values */ + *access_request = 0; + return domain->num_layers - 1; +} + +#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST + +static void test_get_denied_layer(struct kunit *const test) +{ + const struct landlock_domain dom = { + .num_layers = 5, + }; + const struct layer_masks masks = { + .layers[0].access = LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_DIR, + .layers[1].access = LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR, + .layers[2].access = LANDLOCK_ACCESS_FS_REMOVE_DIR, + }; + access_mask_t access; + + access = LANDLOCK_ACCESS_FS_EXECUTE; + KUNIT_EXPECT_EQ(test, 0, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_EXECUTE); + + access = LANDLOCK_ACCESS_FS_READ_FILE; + KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_FILE); + + access = LANDLOCK_ACCESS_FS_READ_DIR; + KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_DIR); + + access = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, + LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR); + + access = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_DIR; + KUNIT_EXPECT_EQ(test, 1, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_READ_DIR); + + access = LANDLOCK_ACCESS_FS_WRITE_FILE; + KUNIT_EXPECT_EQ(test, 4, get_denied_layer(&dom, &access, &masks)); + KUNIT_EXPECT_EQ(test, access, 0); +} + +#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ + +static size_t +get_layer_from_deny_masks(access_mask_t *const access_request, + const access_mask_t all_existing_optional_access, + const deny_masks_t deny_masks, + optional_access_t quiet_optional_accesses, + bool *quiet) +{ + const unsigned long access_opt = all_existing_optional_access; + const unsigned long access_req = *access_request; + access_mask_t missing = 0; + size_t youngest_layer = 0; + size_t access_index = 0; + unsigned long access_bit; + bool should_quiet = false; + + /* This will require change with new object types. */ + WARN_ON_ONCE(access_opt != _LANDLOCK_ACCESS_FS_OPTIONAL); + + for_each_set_bit(access_bit, &access_opt, + BITS_PER_TYPE(access_mask_t)) { + if (access_req & BIT(access_bit)) { + const size_t layer = + (deny_masks >> + (access_index * + HWEIGHT(LANDLOCK_MAX_NUM_LAYERS - 1))) & + (LANDLOCK_MAX_NUM_LAYERS - 1); + const bool layer_has_quiet = + !!(quiet_optional_accesses & BIT(access_index)); + + if (layer > youngest_layer) { + youngest_layer = layer; + missing = BIT(access_bit); + should_quiet = layer_has_quiet; + } else if (layer == youngest_layer) { + missing |= BIT(access_bit); + /* + * Whether the layer has rules with quiet flag + * covering the file accessed does not depend on + * the access, and so the following + * WARN_ON_ONCE() should not fail. + */ + WARN_ON_ONCE(should_quiet && !layer_has_quiet); + should_quiet = layer_has_quiet; + } + } + access_index++; + } + + *access_request = missing; + *quiet = should_quiet; + return youngest_layer; +} + +#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST + +static void test_get_layer_from_deny_masks(struct kunit *const test) +{ + deny_masks_t deny_mask; + access_mask_t access; + optional_access_t quiet_optional_accesses; + bool quiet; + + /* truncate:0 ioctl_dev:2 */ + deny_mask = 0x20; + quiet_optional_accesses = 0; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 0, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + /* layer denying truncate: quiet, ioctl: not quiet */ + quiet_optional_accesses = 0b01; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 0, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, true); + + access = LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + /* Reverse order - truncate:2 ioctl_dev:0 */ + deny_mask = 0x02; + quiet_optional_accesses = 0; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 0, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + /* layer denying truncate: quiet, ioctl: not quiet */ + quiet_optional_accesses = 0b01; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, true); + + access = LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 0, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, true); + + /* layer denying truncate: not quiet, ioctl: quiet */ + quiet_optional_accesses = 0b10; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 0, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, true); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 2, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + /* truncate:15 ioctl_dev:15 */ + deny_mask = 0xff; + quiet_optional_accesses = 0; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 15, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, false); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 15, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, + LANDLOCK_ACCESS_FS_TRUNCATE | + LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, false); + + /* Both quiet (same layer so quietness must be the same) */ + quiet_optional_accesses = 0b11; + + access = LANDLOCK_ACCESS_FS_TRUNCATE; + KUNIT_EXPECT_EQ(test, 15, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, LANDLOCK_ACCESS_FS_TRUNCATE); + KUNIT_EXPECT_EQ(test, quiet, true); + + access = LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; + KUNIT_EXPECT_EQ(test, 15, + get_layer_from_deny_masks( + &access, _LANDLOCK_ACCESS_FS_OPTIONAL, + deny_mask, quiet_optional_accesses, &quiet)); + KUNIT_EXPECT_EQ(test, access, + LANDLOCK_ACCESS_FS_TRUNCATE | + LANDLOCK_ACCESS_FS_IOCTL_DEV); + KUNIT_EXPECT_EQ(test, quiet, true); +} + +#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ + +static bool is_valid_request(const struct landlock_request *const request) +{ + if (WARN_ON_ONCE(request->layer_plus_one > LANDLOCK_MAX_NUM_LAYERS)) + return false; + + if (WARN_ON_ONCE(!(!!request->layer_plus_one ^ !!request->access))) + return false; + + if (request->access) { + if (WARN_ON_ONCE(!(!!request->layer_masks ^ + !!request->all_existing_optional_access))) + return false; + } else { + if (WARN_ON_ONCE(request->layer_masks || + request->all_existing_optional_access)) + return false; + } + + if (request->deny_masks) { + if (WARN_ON_ONCE(!request->all_existing_optional_access)) + return false; + static_assert(sizeof(request->all_existing_optional_access) == + sizeof(u32)); + if (WARN_ON_ONCE( + request->quiet_optional_accesses >= + BIT(hweight32( + request->all_existing_optional_access)))) + return false; + } + + return true; +} + +static access_mask_t +pick_access_mask_for_request_type(const enum landlock_request_type type, + const struct access_masks access_masks) +{ + switch (type) { + case LANDLOCK_REQUEST_FS_ACCESS: + return access_masks.fs; + case LANDLOCK_REQUEST_NET_ACCESS: + return access_masks.net; + default: + WARN_ONCE(1, "Invalid request type %d passed to %s", type, + __func__); + return 0; + } +} + +/* + * Whether a quiet rule silences the denial: the rule must cover the whole + * denied access in the layer that denied it (a quiet rule in a non-denying + * layer does not suppress the denial). + */ +static bool +is_denial_quieted(const struct landlock_request *const request, + const struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool object_quiet_flag) +{ + if (object_quiet_flag) { + const access_mask_t quiet_mask = + pick_access_mask_for_request_type( + request->type, youngest_denied->quiet_masks); + + return (quiet_mask & missing) == missing; + } + + /* + * Either the object is not quiet, or this is a scope request. We check + * request->type to distinguish between the two cases. + */ + switch (request->type) { + case LANDLOCK_REQUEST_SCOPE_SIGNAL: + return !!(youngest_denied->quiet_masks.scope & + LANDLOCK_SCOPE_SIGNAL); + case LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET: + return !!(youngest_denied->quiet_masks.scope & + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET); + /* + * Leave LANDLOCK_REQUEST_PTRACE and LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY + * unhandled for now - they are never quiet. + */ + default: + return false; + } +} + +/* + * Computes whether a denial from youngest_denied is selected for logging by the + * domain's policy: its logging must not be disabled (by both per-execution + * flags being off, or by an ancestor's + * LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF), the per-execution flag matching + * same_exec must be set, and no quiet rule may cover the denied access. + * landlock_log_denial() computes this once and passes it to + * landlock_audit_denial(), which additionally requires audit_enabled. + */ +static bool +is_denial_logged(const struct landlock_request *const request, + const struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool same_exec, + const bool object_quiet_flag) +{ + if (READ_ONCE(youngest_denied->log_status) == LANDLOCK_LOG_DISABLED) + return false; + + if (!(same_exec ? youngest_denied->log_same_exec : + youngest_denied->log_new_exec)) + return false; + + return !is_denial_quieted(request, youngest_denied, missing, + object_quiet_flag); +} + +/** + * landlock_log_denial - Log a denied access + * + * @subject: The Landlock subject's credential denying an action. + * @request: Detail of the user space request. + */ +void landlock_log_denial(const struct landlock_cred_security *const subject, + const struct landlock_request *const request) +{ + struct landlock_hierarchy *youngest_denied; + size_t youngest_layer; + access_mask_t missing; + bool object_quiet_flag = false; + + if (WARN_ON_ONCE(!subject || !subject->domain || + !subject->domain->hierarchy || !request)) + return; + + if (!is_valid_request(request)) + return; + + missing = request->access; + if (missing) { + /* Gets the nearest domain that denies the request. */ + if (request->layer_masks) { + youngest_layer = get_denied_layer(subject->domain, + &missing, + request->layer_masks); + object_quiet_flag = + request->layer_masks->layers[youngest_layer] + .quiet; + } else { + youngest_layer = get_layer_from_deny_masks( + &missing, _LANDLOCK_ACCESS_FS_OPTIONAL, + request->deny_masks, + request->quiet_optional_accesses, + &object_quiet_flag); + } + youngest_denied = + get_hierarchy(subject->domain, youngest_layer); + } else { + youngest_layer = request->layer_plus_one - 1; + youngest_denied = + get_hierarchy(subject->domain, youngest_layer); + } + + const bool same_exec = !!(subject->domain_exec & BIT(youngest_layer)); + const bool logged = is_denial_logged(request, youngest_denied, missing, + same_exec, object_quiet_flag); + + /* + * Consistently keeps track of the number of denied access requests even + * if audit is currently disabled, or if audit rules currently exclude + * this record type, or if landlock_restrict_self(2)'s flags quiet logs. + */ + atomic64_inc(&youngest_denied->num_denials); + + landlock_trace_denial(request, youngest_denied, missing, same_exec, + logged); + landlock_audit_denial(request, youngest_denied, missing, logged); +} + +/** + * landlock_log_free_domain - Log domain deallocation + * + * @hierarchy: The domain's hierarchy being deallocated. + * + * Called from landlock_put_domain_deferred() (via a work queue scheduled by + * hook_cred_free()) or directly from landlock_put_domain(). + */ +void landlock_log_free_domain(const struct landlock_hierarchy *const hierarchy) +{ + if (WARN_ON_ONCE(!hierarchy)) + return; + + landlock_trace_free_domain(hierarchy); + landlock_audit_free_domain(hierarchy); +} + +#ifdef CONFIG_SECURITY_LANDLOCK_KUNIT_TEST + +static struct kunit_case test_cases[] = { + /* clang-format off */ + KUNIT_CASE(test_get_hierarchy), + KUNIT_CASE(test_get_denied_layer), + KUNIT_CASE(test_get_layer_from_deny_masks), + {} + /* clang-format on */ +}; + +static struct kunit_suite test_suite = { + .name = "landlock_log", + .test_cases = test_cases, +}; + +kunit_test_suite(test_suite); + +#endif /* CONFIG_SECURITY_LANDLOCK_KUNIT_TEST */ diff --git a/security/landlock/log.h b/security/landlock/log.h new file mode 100644 index 000000000000..e0a6e44f3ddd --- /dev/null +++ b/security/landlock/log.h @@ -0,0 +1,86 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * Landlock - Log helpers + * + * Copyright © 2023-2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. + */ + +#ifndef _SECURITY_LANDLOCK_LOG_H +#define _SECURITY_LANDLOCK_LOG_H + +#include + +#include "access.h" + +struct landlock_cred_security; +struct landlock_hierarchy; + +enum landlock_request_type { + LANDLOCK_REQUEST_PTRACE = 1, + LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY, + LANDLOCK_REQUEST_FS_ACCESS, + LANDLOCK_REQUEST_NET_ACCESS, + LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET, + LANDLOCK_REQUEST_SCOPE_SIGNAL, +}; + +/* + * We should be careful to only use a variable of this type for + * landlock_log_denial(). This way, the compiler can remove it entirely if + * CONFIG_SECURITY_LANDLOCK_LOG is not set. + */ +struct landlock_request { + /* Mandatory fields. */ + enum landlock_request_type type; + struct common_audit_data audit; + + /** + * layer_plus_one: First layer level that denies the request + 1. The + * extra one is useful to detect uninitialized field. + */ + size_t layer_plus_one; + + /* Required field for configurable access control. */ + access_mask_t access; + + /* Required fields for requests with layer masks. */ + const struct layer_masks *layer_masks; + + /* Required fields for requests with deny masks. */ + const access_mask_t all_existing_optional_access; + deny_masks_t deny_masks; + optional_access_t quiet_optional_accesses; + + /* + * Other-party domain ID for a relational (scope/ptrace) denial, or 0 if + * that party is unsandboxed. An ID, not a pointer: the other task can + * replace its credential and free the domain it referenced. Trace path + * only; audit ignores it. + */ + u64 other_domain_id; +}; + +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + +void landlock_log_free_domain(const struct landlock_hierarchy *const hierarchy); + +void landlock_log_denial(const struct landlock_cred_security *const subject, + const struct landlock_request *const request); + +#else /* CONFIG_SECURITY_LANDLOCK_LOG */ + +static inline void +landlock_log_free_domain(const struct landlock_hierarchy *const hierarchy) +{ +} + +static inline void +landlock_log_denial(const struct landlock_cred_security *const subject, + const struct landlock_request *const request) +{ +} + +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + +#endif /* _SECURITY_LANDLOCK_LOG_H */ diff --git a/security/landlock/net.c b/security/landlock/net.c index 46c17116fcf4..8f2aaac54b33 100644 --- a/security/landlock/net.c +++ b/security/landlock/net.c @@ -12,13 +12,16 @@ #include #include -#include "audit.h" #include "common.h" #include "cred.h" +#include "domain.h" #include "limits.h" +#include "log.h" #include "net.h" #include "ruleset.h" +#include + int landlock_append_net_rule(struct landlock_ruleset *const ruleset, const u16 port, access_mask_t access_rights, const u32 flags) @@ -32,16 +35,40 @@ int landlock_append_net_rule(struct landlock_ruleset *const ruleset, BUILD_BUG_ON(sizeof(port) > sizeof(id.key.data)); /* Transforms relative access rights to absolute ones. */ - access_rights |= LANDLOCK_MASK_ACCESS_NET & - ~landlock_get_net_access_mask(ruleset, 0); + access_rights |= LANDLOCK_MASK_ACCESS_NET & ~ruleset->handled_masks.net; mutex_lock(&ruleset->lock); err = landlock_insert_rule(ruleset, id, access_rights, flags); + + /* + * Emit after the rule insertion succeeds, so every event corresponds to + * a rule that is actually in the ruleset. The ruleset lock is still + * held for BTF consistency (enforced by lockdep_assert_held in + * TP_fast_assign). + */ + if (!err) + trace_landlock_add_rule_net(ruleset, access_rights, port); mutex_unlock(&ruleset->lock); return err; } +static bool unmask_layers_net(const struct landlock_domain *const domain, + const struct landlock_id id, + struct layer_masks *masks, + access_mask_t access_request) +{ + const struct landlock_rule *rule = NULL; + bool ret; + + ret = landlock_unmask_layers(domain, id, masks, &rule); + if (rule) + trace_landlock_check_rule_net( + domain, rule, access_request, + ntohs((__force __be16)id.key.data)); + return ret; +} + static int current_check_access_socket(struct socket *const sock, struct sockaddr *const address, const int addrlen, @@ -51,7 +78,6 @@ static int current_check_access_socket(struct socket *const sock, unsigned short sock_family; __be16 port; struct layer_masks layer_masks = {}; - const struct landlock_rule *rule; struct landlock_id id = { .type = LANDLOCK_KEY_NET_PORT, }; @@ -237,14 +263,14 @@ static int current_check_access_socket(struct socket *const sock, id.key.data = (__force uintptr_t)port; BUILD_BUG_ON(sizeof(port) > sizeof(id.key.data)); - rule = landlock_find_rule(subject->domain, id); access_request = landlock_init_layer_masks(subject->domain, access_request, &layer_masks, LANDLOCK_KEY_NET_PORT); if (!access_request) return 0; - if (landlock_unmask_layers(rule, &layer_masks)) + if (unmask_layers_net(subject->domain, id, &layer_masks, + access_request)) return 0; audit_net.family = address->sa_family; diff --git a/security/landlock/ruleset.c b/security/landlock/ruleset.c index 4dd09ea22c84..0d07707523cd 100644 --- a/security/landlock/ruleset.c +++ b/security/landlock/ruleset.c @@ -4,6 +4,7 @@ * * Copyright © 2016-2020 Mickaël Salaün * Copyright © 2018-2020 ANSSI + * Copyright © 2026 Cloudflare, Inc. */ #include @@ -20,39 +21,15 @@ #include #include #include -#include #include #include "access.h" -#include "domain.h" +#include "id.h" #include "limits.h" #include "object.h" #include "ruleset.h" -static struct landlock_ruleset *create_ruleset(const u32 num_layers) -{ - struct landlock_ruleset *new_ruleset; - - new_ruleset = kzalloc_flex(*new_ruleset, access_masks, num_layers, - GFP_KERNEL_ACCOUNT); - if (!new_ruleset) - return ERR_PTR(-ENOMEM); - refcount_set(&new_ruleset->usage, 1); - mutex_init(&new_ruleset->lock); - new_ruleset->root_inode = RB_ROOT; - -#if IS_ENABLED(CONFIG_INET) - new_ruleset->root_net_port = RB_ROOT; -#endif /* IS_ENABLED(CONFIG_INET) */ - - new_ruleset->num_layers = num_layers; - /* - * hierarchy = NULL - * num_rules = 0 - * access_masks[] = 0 - */ - return new_ruleset; -} +#include struct landlock_ruleset * landlock_create_ruleset(const access_mask_t fs_access_mask, @@ -64,15 +41,44 @@ landlock_create_ruleset(const access_mask_t fs_access_mask, /* Informs about useless ruleset. */ if (!fs_access_mask && !net_access_mask && !scope_mask) return ERR_PTR(-ENOMSG); - new_ruleset = create_ruleset(1); - if (IS_ERR(new_ruleset)) - return new_ruleset; - if (fs_access_mask) - landlock_add_fs_access_mask(new_ruleset, fs_access_mask, 0); - if (net_access_mask) - landlock_add_net_access_mask(new_ruleset, net_access_mask, 0); - if (scope_mask) - landlock_add_scope_mask(new_ruleset, scope_mask, 0); + + new_ruleset = kzalloc_obj(*new_ruleset, GFP_KERNEL_ACCOUNT); + if (!new_ruleset) + return ERR_PTR(-ENOMEM); + + refcount_set(&new_ruleset->usage, 1); + mutex_init(&new_ruleset->lock); + new_ruleset->rules.root_inode = RB_ROOT; + +#if IS_ENABLED(CONFIG_INET) + new_ruleset->rules.root_net_port = RB_ROOT; +#endif /* IS_ENABLED(CONFIG_INET) */ + +#ifdef CONFIG_TRACEPOINTS + new_ruleset->id = landlock_get_id_range(1); +#endif /* CONFIG_TRACEPOINTS */ + + /* Should already be checked in landlock_create_ruleset(). */ + if (fs_access_mask) { + const access_mask_t mask = fs_access_mask & + LANDLOCK_MASK_ACCESS_FS; + + WARN_ON_ONCE(fs_access_mask != mask); + new_ruleset->handled_masks.fs |= mask; + } + if (net_access_mask) { + const access_mask_t mask = net_access_mask & + LANDLOCK_MASK_ACCESS_NET; + + WARN_ON_ONCE(net_access_mask != mask); + new_ruleset->handled_masks.net |= mask; + } + if (scope_mask) { + const access_mask_t mask = scope_mask & LANDLOCK_MASK_SCOPE; + + WARN_ON_ONCE(scope_mask != mask); + new_ruleset->handled_masks.scope |= mask; + } return new_ruleset; } @@ -129,7 +135,7 @@ create_rule(const struct landlock_id id, return ERR_PTR(-ENOMEM); RB_CLEAR_NODE(&new_rule->node); if (is_object_pointer(id.type)) { - /* This should have been caught by insert_rule(). */ + /* This should have been caught by landlock_store_rule(). */ WARN_ON_ONCE(!id.key.object); landlock_get_object(id.key.object); } @@ -145,24 +151,6 @@ create_rule(const struct landlock_id id, return new_rule; } -static struct rb_root *get_root(struct landlock_ruleset *const ruleset, - const enum landlock_key_type key_type) -{ - switch (key_type) { - case LANDLOCK_KEY_INODE: - return &ruleset->root_inode; - -#if IS_ENABLED(CONFIG_INET) - case LANDLOCK_KEY_NET_PORT: - return &ruleset->root_net_port; -#endif /* IS_ENABLED(CONFIG_INET) */ - - default: - WARN_ON_ONCE(1); - return ERR_PTR(-EINVAL); - } -} - static void free_rule(struct landlock_rule *const rule, const enum landlock_key_type key_type) { @@ -176,19 +164,20 @@ static void free_rule(struct landlock_rule *const rule, static void build_check_ruleset(void) { - const struct landlock_ruleset ruleset = { + const struct landlock_rules rules = { .num_rules = ~0, - .num_layers = ~0, }; - BUILD_BUG_ON(ruleset.num_rules < LANDLOCK_MAX_NUM_RULES); - BUILD_BUG_ON(ruleset.num_layers < LANDLOCK_MAX_NUM_LAYERS); + BUILD_BUG_ON(rules.num_rules < LANDLOCK_MAX_NUM_RULES); } /** - * insert_rule - Create and insert a rule in a ruleset + * landlock_store_rule - Create and insert a rule into the rule storage * - * @ruleset: The ruleset to be updated. + * @rules: The rule storage to be updated. The caller is responsible for + * any required locking. For rulesets, this means holding + * &landlock_ruleset.lock. For domains under construction, no lock is + * needed because the domain is not yet visible to other tasks. * @id: The ID to build the new rule with. The underlying kernel object, if * any, must be held by the caller. * @layers: One or multiple layers to be copied into the new rule. @@ -196,19 +185,19 @@ static void build_check_ruleset(void) * * When user space requests to add a new rule to a ruleset, @layers only * contains one entry and this entry is not assigned to any level. In this - * case, the new rule will extend @ruleset, similarly to a boolean OR between + * case, the new rule will extend @rules, similarly to a boolean OR between * access rights. * * When merging a ruleset in a domain, or copying a domain, @layers will be - * added to @ruleset as new constraints, similarly to a boolean AND between - * access rights. + * added to @rules as new constraints, similarly to a boolean AND between access + * rights. * * Return: 0 on success, -errno on failure. */ -static int insert_rule(struct landlock_ruleset *const ruleset, - const struct landlock_id id, - const struct landlock_layer (*layers)[], - const size_t num_layers) +int landlock_store_rule(struct landlock_rules *const rules, + const struct landlock_id id, + const struct landlock_layer (*layers)[], + const size_t num_layers) { struct rb_node **walker_node; struct rb_node *parent_node = NULL; @@ -216,14 +205,13 @@ static int insert_rule(struct landlock_ruleset *const ruleset, struct rb_root *root; might_sleep(); - lockdep_assert_held(&ruleset->lock); if (WARN_ON_ONCE(!layers)) return -ENOENT; if (is_object_pointer(id.type) && WARN_ON_ONCE(!id.key.object)) return -ENOENT; - root = get_root(ruleset, id.type); + root = landlock_get_rule_root(rules, id.type); if (IS_ERR(root)) return PTR_ERR(root); @@ -249,7 +237,7 @@ static int insert_rule(struct landlock_ruleset *const ruleset, if ((*layers)[0].level == 0) { /* * Extends access rights when the request comes from - * landlock_add_rule(2), i.e. @ruleset is not a domain. + * landlock_add_rule(2), i.e. @rules is not a domain. */ if (WARN_ON_ONCE(this->num_layers != 1)) return -EINVAL; @@ -278,14 +266,14 @@ static int insert_rule(struct landlock_ruleset *const ruleset, /* There is no match for @id. */ build_check_ruleset(); - if (ruleset->num_rules >= LANDLOCK_MAX_NUM_RULES) + if (rules->num_rules >= LANDLOCK_MAX_NUM_RULES) return -E2BIG; new_rule = create_rule(id, layers, num_layers, NULL); if (IS_ERR(new_rule)) return PTR_ERR(new_rule); rb_link_node(&new_rule->node, parent_node, walker_node); rb_insert_color(&new_rule->node, root); - ruleset->num_rules++; + rules->num_rules++; return 0; } @@ -311,197 +299,50 @@ int landlock_insert_rule(struct landlock_ruleset *const ruleset, { struct landlock_layer layers[] = { { .access = access, - /* When @level is zero, insert_rule() extends @ruleset. */ + /* + * When @level is zero, landlock_store_rule() extends @ruleset. + */ .level = 0, .flags = { .quiet = !!(flags & LANDLOCK_ADD_RULE_QUIET), }, } }; + int err; build_check_layer(); - return insert_rule(ruleset, id, &layers, ARRAY_SIZE(layers)); -} + lockdep_assert_held(&ruleset->lock); + err = landlock_store_rule(&ruleset->rules, id, &layers, + ARRAY_SIZE(layers)); -static int merge_tree(struct landlock_ruleset *const dst, - struct landlock_ruleset *const src, - const enum landlock_key_type key_type) -{ - struct landlock_rule *walker_rule, *next_rule; - struct rb_root *src_root; - int err = 0; +#ifdef CONFIG_TRACEPOINTS + if (!err) + ruleset->version++; +#endif /* CONFIG_TRACEPOINTS */ - might_sleep(); - lockdep_assert_held(&dst->lock); - lockdep_assert_held(&src->lock); - - src_root = get_root(src, key_type); - if (IS_ERR(src_root)) - return PTR_ERR(src_root); - - /* Merges the @src tree. */ - rbtree_postorder_for_each_entry_safe(walker_rule, next_rule, src_root, - node) { - struct landlock_layer layers[] = { { - .level = dst->num_layers, - } }; - const struct landlock_id id = { - .key = walker_rule->key, - .type = key_type, - }; - - if (WARN_ON_ONCE(walker_rule->num_layers != 1)) - return -EINVAL; - - if (WARN_ON_ONCE(walker_rule->layers[0].level != 0)) - return -EINVAL; - - layers[0].access = walker_rule->layers[0].access; - layers[0].flags = walker_rule->layers[0].flags; - - err = insert_rule(dst, id, &layers, ARRAY_SIZE(layers)); - if (err) - return err; - } return err; } -static int merge_ruleset(struct landlock_ruleset *const dst, - struct landlock_ruleset *const src) -{ - int err = 0; - - might_sleep(); - /* Should already be checked by landlock_merge_ruleset() */ - if (WARN_ON_ONCE(!src)) - return 0; - /* Only merge into a domain. */ - if (WARN_ON_ONCE(!dst || !dst->hierarchy)) - return -EINVAL; - - /* Locks @dst first because we are its only owner. */ - mutex_lock(&dst->lock); - mutex_lock_nested(&src->lock, SINGLE_DEPTH_NESTING); - - /* Stacks the new layer. */ - if (WARN_ON_ONCE(src->num_layers != 1 || dst->num_layers < 1)) { - err = -EINVAL; - goto out_unlock; - } - dst->access_masks[dst->num_layers - 1] = - landlock_upgrade_handled_access_masks(src->access_masks[0]); - - /* Merges the @src inode tree. */ - err = merge_tree(dst, src, LANDLOCK_KEY_INODE); - if (err) - goto out_unlock; - -#if IS_ENABLED(CONFIG_INET) - /* Merges the @src network port tree. */ - err = merge_tree(dst, src, LANDLOCK_KEY_NET_PORT); - if (err) - goto out_unlock; -#endif /* IS_ENABLED(CONFIG_INET) */ - -out_unlock: - mutex_unlock(&src->lock); - mutex_unlock(&dst->lock); - return err; -} - -static int inherit_tree(struct landlock_ruleset *const parent, - struct landlock_ruleset *const child, - const enum landlock_key_type key_type) -{ - struct landlock_rule *walker_rule, *next_rule; - struct rb_root *parent_root; - int err = 0; - - might_sleep(); - lockdep_assert_held(&parent->lock); - lockdep_assert_held(&child->lock); - - parent_root = get_root(parent, key_type); - if (IS_ERR(parent_root)) - return PTR_ERR(parent_root); - - /* Copies the @parent inode or network tree. */ - rbtree_postorder_for_each_entry_safe(walker_rule, next_rule, - parent_root, node) { - const struct landlock_id id = { - .key = walker_rule->key, - .type = key_type, - }; - - err = insert_rule(child, id, &walker_rule->layers, - walker_rule->num_layers); - if (err) - return err; - } - return err; -} - -static int inherit_ruleset(struct landlock_ruleset *const parent, - struct landlock_ruleset *const child) -{ - int err = 0; - - might_sleep(); - if (!parent) - return 0; - - /* Locks @child first because we are its only owner. */ - mutex_lock(&child->lock); - mutex_lock_nested(&parent->lock, SINGLE_DEPTH_NESTING); - - /* Copies the @parent inode tree. */ - err = inherit_tree(parent, child, LANDLOCK_KEY_INODE); - if (err) - goto out_unlock; - -#if IS_ENABLED(CONFIG_INET) - /* Copies the @parent network port tree. */ - err = inherit_tree(parent, child, LANDLOCK_KEY_NET_PORT); - if (err) - goto out_unlock; -#endif /* IS_ENABLED(CONFIG_INET) */ - - if (WARN_ON_ONCE(child->num_layers <= parent->num_layers)) { - err = -EINVAL; - goto out_unlock; - } - /* Copies the parent layer stack and leaves a space for the new layer. */ - memcpy(child->access_masks, parent->access_masks, - flex_array_size(parent, access_masks, parent->num_layers)); - - if (WARN_ON_ONCE(!parent->hierarchy)) { - err = -EINVAL; - goto out_unlock; - } - landlock_get_hierarchy(parent->hierarchy); - child->hierarchy->parent = parent->hierarchy; - -out_unlock: - mutex_unlock(&parent->lock); - mutex_unlock(&child->lock); - return err; -} - -static void free_ruleset(struct landlock_ruleset *const ruleset) +void landlock_free_rules(struct landlock_rules *const rules) { struct landlock_rule *freeme, *next; might_sleep(); - rbtree_postorder_for_each_entry_safe(freeme, next, &ruleset->root_inode, + rbtree_postorder_for_each_entry_safe(freeme, next, &rules->root_inode, node) free_rule(freeme, LANDLOCK_KEY_INODE); #if IS_ENABLED(CONFIG_INET) rbtree_postorder_for_each_entry_safe(freeme, next, - &ruleset->root_net_port, node) + &rules->root_net_port, node) free_rule(freeme, LANDLOCK_KEY_NET_PORT); #endif /* IS_ENABLED(CONFIG_INET) */ +} - landlock_put_hierarchy(ruleset->hierarchy); +static void free_ruleset(struct landlock_ruleset *const ruleset) +{ + might_sleep(); + trace_landlock_free_ruleset(ruleset); + landlock_free_rules(&ruleset->rules); kfree(ruleset); } @@ -511,234 +352,3 @@ void landlock_put_ruleset(struct landlock_ruleset *const ruleset) if (ruleset && refcount_dec_and_test(&ruleset->usage)) free_ruleset(ruleset); } - -static void free_ruleset_work(struct work_struct *const work) -{ - struct landlock_ruleset *ruleset; - - ruleset = container_of(work, struct landlock_ruleset, work_free); - free_ruleset(ruleset); -} - -/* Only called by hook_cred_free(). */ -void landlock_put_ruleset_deferred(struct landlock_ruleset *const ruleset) -{ - if (ruleset && refcount_dec_and_test(&ruleset->usage)) { - INIT_WORK(&ruleset->work_free, free_ruleset_work); - schedule_work(&ruleset->work_free); - } -} - -/** - * landlock_merge_ruleset - Merge a ruleset with a domain - * - * @parent: Parent domain. - * @ruleset: New ruleset to be merged. - * - * The current task is requesting to be restricted. The subjective credentials - * must not be in an overridden state. cf. landlock_init_hierarchy_log(). - * - * Return: A new domain merging @parent and @ruleset on success, or ERR_PTR() - * on failure. If @parent is NULL, the new domain duplicates @ruleset. - */ -struct landlock_ruleset * -landlock_merge_ruleset(struct landlock_ruleset *const parent, - struct landlock_ruleset *const ruleset) -{ - struct landlock_ruleset *new_dom __free(landlock_put_ruleset) = NULL; - u32 num_layers; - int err; - - might_sleep(); - if (WARN_ON_ONCE(!ruleset || parent == ruleset)) - return ERR_PTR(-EINVAL); - - if (parent) { - if (parent->num_layers >= LANDLOCK_MAX_NUM_LAYERS) - return ERR_PTR(-E2BIG); - num_layers = parent->num_layers + 1; - } else { - num_layers = 1; - } - - /* Creates a new domain... */ - new_dom = create_ruleset(num_layers); - if (IS_ERR(new_dom)) - return new_dom; - - new_dom->hierarchy = - kzalloc_obj(*new_dom->hierarchy, GFP_KERNEL_ACCOUNT); - if (!new_dom->hierarchy) - return ERR_PTR(-ENOMEM); - - refcount_set(&new_dom->hierarchy->usage, 1); - - /* ...as a child of @parent... */ - err = inherit_ruleset(parent, new_dom); - if (err) - return ERR_PTR(err); - - /* ...and including @ruleset. */ - err = merge_ruleset(new_dom, ruleset); - if (err) - return ERR_PTR(err); - - err = landlock_init_hierarchy_log(new_dom->hierarchy); - if (err) - return ERR_PTR(err); - -#ifdef CONFIG_AUDIT - new_dom->hierarchy->quiet_masks = ruleset->quiet_masks; -#endif /* CONFIG_AUDIT */ - - return no_free_ptr(new_dom); -} - -/* - * The returned access has the same lifetime as @ruleset. - */ -const struct landlock_rule * -landlock_find_rule(const struct landlock_ruleset *const ruleset, - const struct landlock_id id) -{ - const struct rb_root *root; - const struct rb_node *node; - - root = get_root((struct landlock_ruleset *)ruleset, id.type); - if (IS_ERR(root)) - return NULL; - node = root->rb_node; - - while (node) { - struct landlock_rule *this = - rb_entry(node, struct landlock_rule, node); - - if (this->key.data == id.key.data) - return this; - if (this->key.data < id.key.data) - node = node->rb_right; - else - node = node->rb_left; - } - return NULL; -} - -/** - * landlock_unmask_layers - Remove the access rights in @masks - * which are granted in @rule - * - * Updates the set of (per-layer) unfulfilled access rights @masks - * so that all the access rights granted in @rule are removed from it - * (because they are now fulfilled). - * - * @rule: A rule that grants a set of access rights for each layer - * @masks: A matrix of unfulfilled access rights for each layer - * - * Return: True if the request is allowed (i.e. the access rights granted all - * remaining unfulfilled access rights and masks has no leftover set bits). - */ -bool landlock_unmask_layers(const struct landlock_rule *const rule, - struct layer_masks *masks) -{ - if (!masks) - return true; - if (!rule) - return false; - - /* - * An access is granted if, for each policy layer, at least one rule - * encountered on the pathwalk grants the requested access, - * regardless of its position in the layer stack. We must then check - * the remaining layers for each inode, from the first added layer to - * the last one. When there is multiple requested accesses, for each - * policy layer, the full set of requested accesses may not be granted - * by only one rule, but by the union (binary OR) of multiple rules. - * E.g. /a/b + /a => /a/b - */ - for (size_t i = 0; i < rule->num_layers; i++) { - const struct landlock_layer *const layer = &rule->layers[i]; - - /* Clear the bits where the layer in the rule grants access. */ - masks->layers[layer->level - 1].access &= ~layer->access; - -#ifdef CONFIG_AUDIT - /* Collect rule flags for each layer. */ - if (layer->flags.quiet) - masks->layers[layer->level - 1].quiet = true; -#endif /* CONFIG_AUDIT */ - } - - for (size_t i = 0; i < ARRAY_SIZE(masks->layers); i++) { - if (masks->layers[i].access) - return false; - } - return true; -} - -typedef access_mask_t -get_access_mask_t(const struct landlock_ruleset *const ruleset, - const u16 layer_level); - -/** - * landlock_init_layer_masks - Initialize layer masks from an access request - * - * Populates @masks such that for each access right in @access_request, the bits - * for all the layers are set where this access right is handled. Rule flags - * are also zeroed. - * - * @domain: The domain that defines the current restrictions. - * @access_request: The requested access rights to check. - * @masks: Layer access masks to populate. - * @key_type: The key type to switch between access masks of different types. - * - * Return: An access mask where each access right bit is set which is handled - * in any of the active layers in @domain. - */ -access_mask_t -landlock_init_layer_masks(const struct landlock_ruleset *const domain, - const access_mask_t access_request, - struct layer_masks *const masks, - const enum landlock_key_type key_type) -{ - access_mask_t handled_accesses = 0; - get_access_mask_t *get_access_mask; - - switch (key_type) { - case LANDLOCK_KEY_INODE: - get_access_mask = landlock_get_fs_access_mask; - break; - -#if IS_ENABLED(CONFIG_INET) - case LANDLOCK_KEY_NET_PORT: - get_access_mask = landlock_get_net_access_mask; - break; -#endif /* IS_ENABLED(CONFIG_INET) */ - - default: - WARN_ON_ONCE(1); - return 0; - } - - /* An empty access request can happen because of O_WRONLY | O_RDWR. */ - if (!access_request) - return 0; - - for (size_t i = 0; i < domain->num_layers; i++) { - const access_mask_t handled = get_access_mask(domain, i); - - masks->layers[i].access = access_request & handled; - handled_accesses |= masks->layers[i].access; -#ifdef CONFIG_AUDIT - masks->layers[i].quiet = false; -#endif /* CONFIG_AUDIT */ - } - for (size_t i = domain->num_layers; i < ARRAY_SIZE(masks->layers); - i++) { - masks->layers[i].access = 0; -#ifdef CONFIG_AUDIT - masks->layers[i].quiet = false; -#endif /* CONFIG_AUDIT */ - } - - return handled_accesses; -} diff --git a/security/landlock/ruleset.h b/security/landlock/ruleset.h index 0437adf17428..b536fa0425b7 100644 --- a/security/landlock/ruleset.h +++ b/security/landlock/ruleset.h @@ -4,6 +4,7 @@ * * Copyright © 2016-2020 Mickaël Salaün * Copyright © 2018-2020 ANSSI + * Copyright © 2026 Cloudflare, Inc. */ #ifndef _SECURITY_LANDLOCK_RULESET_H @@ -14,14 +15,11 @@ #include #include #include -#include #include "access.h" #include "limits.h" #include "object.h" -struct landlock_hierarchy; - /** * struct landlock_layer - Access rights for a given layer */ @@ -68,13 +66,12 @@ union landlock_key { */ enum landlock_key_type { /** - * @LANDLOCK_KEY_INODE: Type of &landlock_ruleset.root_inode's node - * keys. + * @LANDLOCK_KEY_INODE: Type of &landlock_rules.root_inode's node keys. */ LANDLOCK_KEY_INODE = 1, /** - * @LANDLOCK_KEY_NET_PORT: Type of &landlock_ruleset.root_net_port's - * node keys. + * @LANDLOCK_KEY_NET_PORT: Type of &landlock_rules.root_net_port's node + * keys. */ LANDLOCK_KEY_NET_PORT, }; @@ -121,6 +118,33 @@ struct landlock_rule { struct landlock_layer layers[] __counted_by(num_layers); }; +/** + * struct landlock_rules - Red-black tree storage for Landlock rules + * + * This structure holds the rule trees shared by both rulesets and domains. + */ +struct landlock_rules { + /** + * @root_inode: Root of a red-black tree containing &struct + * landlock_rule nodes with inode object. Immutable for domains. + */ + struct rb_root root_inode; + +#if IS_ENABLED(CONFIG_INET) + /** + * @root_net_port: Root of a red-black tree containing &struct + * landlock_rule nodes with network port. Immutable for domains. + */ + struct rb_root root_net_port; +#endif /* IS_ENABLED(CONFIG_INET) */ + + /** + * @num_rules: Number of non-overlapping (i.e. not for the same object) + * rules in this tree storage. + */ + u32 num_rules; +}; + /** * struct landlock_ruleset - Landlock ruleset * @@ -129,81 +153,44 @@ struct landlock_rule { */ struct landlock_ruleset { /** - * @root_inode: Root of a red-black tree containing &struct - * landlock_rule nodes with inode object. Once a ruleset is tied to a - * process (i.e. as a domain), this tree is immutable until @usage - * reaches zero. + * @rules: Red-black tree storage for rules. */ - struct rb_root root_inode; - -#if IS_ENABLED(CONFIG_INET) + struct landlock_rules rules; /** - * @root_net_port: Root of a red-black tree containing &struct - * landlock_rule nodes with network port. Once a ruleset is tied to a - * process (i.e. as a domain), this tree is immutable until @usage - * reaches zero. + * @lock: Protects against concurrent modifications of @rules, if @usage + * is greater than zero. */ - struct rb_root root_net_port; -#endif /* IS_ENABLED(CONFIG_INET) */ + struct mutex lock; + /** + * @usage: Number of file descriptors referencing this ruleset. + */ + refcount_t usage; + +#ifdef CONFIG_TRACEPOINTS + /** + * @version: Counter incremented on each successful + * landlock_add_rule(2), including when it only extends an existing + * rule's access rights. Used by tracepoints to correlate a domain with + * the exact ruleset state it was created from. Protected by @lock. + */ + u32 version; + /** + * @id: Unique identifier for this ruleset, used for tracing. + */ + u64 id; +#endif /* CONFIG_TRACEPOINTS */ /** - * @hierarchy: Enables hierarchy identification even when a parent - * domain vanishes. This is needed for the ptrace protection. + * @quiet_masks: Stores the quiet flags for an unmerged ruleset. For a + * merged domain, this is stored in each layer's struct + * landlock_hierarchy instead. */ - struct landlock_hierarchy *hierarchy; - union { - /** - * @work_free: Enables to free a ruleset within a lockless - * section. This is only used by - * landlock_put_ruleset_deferred() when @usage reaches zero. - * The fields @lock, @usage, @num_rules, @num_layers, - * @quiet_masks and @access_masks are then unused. - */ - struct work_struct work_free; - struct { - /** - * @lock: Protects against concurrent modifications of - * @root, if @usage is greater than zero. - */ - struct mutex lock; - /** - * @usage: Number of processes (i.e. domains) or file - * descriptors referencing this ruleset. - */ - refcount_t usage; - /** - * @num_rules: Number of non-overlapping (i.e. not for - * the same object) rules in this ruleset. - */ - u32 num_rules; - /** - * @num_layers: Number of layers that are used in this - * ruleset. This enables to check that all the layers - * allow an access request. A value of 0 identifies a - * non-merged ruleset (i.e. not a domain). - */ - u32 num_layers; - /** - * @quiet_masks: Stores the quiet flags for an unmerged - * ruleset. For a merged domain, this is stored in each - * layer's struct landlock_hierarchy instead. - */ - struct access_masks quiet_masks; - /** - * @access_masks: Contains the subset of filesystem and - * network actions that are restricted by a ruleset. - * A domain saves all layers of merged rulesets in a - * stack (FAM), starting from the first layer to the - * last one. These layers are used when merging - * rulesets, for user space backward compatibility - * (i.e. future-proof), and to properly handle merged - * rulesets without overlapping access rights. These - * layers are set once and never changed for the - * lifetime of the ruleset. - */ - struct access_masks access_masks[]; - }; - }; + struct access_masks quiet_masks; + /** + * @handled_masks: Contains the subset of filesystem and network actions + * that are handled by this ruleset. + */ + struct access_masks handled_masks; }; struct landlock_ruleset * @@ -212,7 +199,6 @@ landlock_create_ruleset(const access_mask_t access_mask_fs, const access_mask_t scope_mask); void landlock_put_ruleset(struct landlock_ruleset *const ruleset); -void landlock_put_ruleset_deferred(struct landlock_ruleset *const ruleset); DEFINE_FREE(landlock_put_ruleset, struct landlock_ruleset *, if (!IS_ERR_OR_NULL(_T)) landlock_put_ruleset(_T)) @@ -221,13 +207,39 @@ int landlock_insert_rule(struct landlock_ruleset *const ruleset, const struct landlock_id id, const access_mask_t access, const u32 flags); -struct landlock_ruleset * -landlock_merge_ruleset(struct landlock_ruleset *const parent, - struct landlock_ruleset *const ruleset); +int landlock_store_rule(struct landlock_rules *const rules, + const struct landlock_id id, + const struct landlock_layer (*layers)[], + const size_t num_layers); -const struct landlock_rule * -landlock_find_rule(const struct landlock_ruleset *const ruleset, - const struct landlock_id id); +void landlock_free_rules(struct landlock_rules *const rules); + +/** + * landlock_get_rule_root - Get the root of a rule tree by key type + * + * @rules: The rules storage to look up. + * @key_type: The type of key to select the tree for. + * + * Return: A pointer to the rb_root, or ERR_PTR(-EINVAL) on unknown type. + */ +static inline struct rb_root * +landlock_get_rule_root(struct landlock_rules *const rules, + const enum landlock_key_type key_type) +{ + switch (key_type) { + case LANDLOCK_KEY_INODE: + return &rules->root_inode; + +#if IS_ENABLED(CONFIG_INET) + case LANDLOCK_KEY_NET_PORT: + return &rules->root_net_port; +#endif /* IS_ENABLED(CONFIG_INET) */ + + default: + WARN_ON_ONCE(1); + return ERR_PTR(-EINVAL); + } +} static inline void landlock_get_ruleset(struct landlock_ruleset *const ruleset) { @@ -235,96 +247,4 @@ static inline void landlock_get_ruleset(struct landlock_ruleset *const ruleset) refcount_inc(&ruleset->usage); } -/** - * landlock_union_access_masks - Return all access rights handled in the - * domain - * - * @domain: Landlock ruleset (used as a domain) - * - * Return: An access_masks result of the OR of all the domain's access masks. - */ -static inline struct access_masks -landlock_union_access_masks(const struct landlock_ruleset *const domain) -{ - union access_masks_all matches = {}; - size_t layer_level; - - for (layer_level = 0; layer_level < domain->num_layers; layer_level++) { - union access_masks_all layer = { - .masks = domain->access_masks[layer_level], - }; - - matches.all |= layer.all; - } - - return matches.masks; -} - -static inline void -landlock_add_fs_access_mask(struct landlock_ruleset *const ruleset, - const access_mask_t fs_access_mask, - const u16 layer_level) -{ - access_mask_t fs_mask = fs_access_mask & LANDLOCK_MASK_ACCESS_FS; - - /* Should already be checked in sys_landlock_create_ruleset(). */ - WARN_ON_ONCE(fs_access_mask != fs_mask); - ruleset->access_masks[layer_level].fs |= fs_mask; -} - -static inline void -landlock_add_net_access_mask(struct landlock_ruleset *const ruleset, - const access_mask_t net_access_mask, - const u16 layer_level) -{ - access_mask_t net_mask = net_access_mask & LANDLOCK_MASK_ACCESS_NET; - - /* Should already be checked in sys_landlock_create_ruleset(). */ - WARN_ON_ONCE(net_access_mask != net_mask); - ruleset->access_masks[layer_level].net |= net_mask; -} - -static inline void -landlock_add_scope_mask(struct landlock_ruleset *const ruleset, - const access_mask_t scope_mask, const u16 layer_level) -{ - access_mask_t mask = scope_mask & LANDLOCK_MASK_SCOPE; - - /* Should already be checked in sys_landlock_create_ruleset(). */ - WARN_ON_ONCE(scope_mask != mask); - ruleset->access_masks[layer_level].scope |= mask; -} - -static inline access_mask_t -landlock_get_fs_access_mask(const struct landlock_ruleset *const ruleset, - const u16 layer_level) -{ - /* Handles all initially denied by default access rights. */ - return ruleset->access_masks[layer_level].fs | - _LANDLOCK_ACCESS_FS_INITIALLY_DENIED; -} - -static inline access_mask_t -landlock_get_net_access_mask(const struct landlock_ruleset *const ruleset, - const u16 layer_level) -{ - return ruleset->access_masks[layer_level].net; -} - -static inline access_mask_t -landlock_get_scope_mask(const struct landlock_ruleset *const ruleset, - const u16 layer_level) -{ - return ruleset->access_masks[layer_level].scope; -} - -bool landlock_unmask_layers(const struct landlock_rule *const rule, - struct layer_masks *masks); - -access_mask_t -landlock_init_layer_masks(const struct landlock_ruleset *const domain, - const access_mask_t access_request, - struct layer_masks *masks, - const enum landlock_key_type key_type); - #endif /* _SECURITY_LANDLOCK_RULESET_H */ diff --git a/security/landlock/syscalls.c b/security/landlock/syscalls.c index 36b02892c62f..1d02d57f4c48 100644 --- a/security/landlock/syscalls.c +++ b/security/landlock/syscalls.c @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,8 @@ #include "setup.h" #include "tsync.h" +#include + static bool is_initialized(void) { if (likely(landlock_initialized)) @@ -169,7 +172,7 @@ static const struct file_operations ruleset_fops = { * If the change involves a fix that requires userspace awareness, also update * the errata documentation in Documentation/userspace-api/landlock.rst . */ -const int landlock_abi_version = 10; +const int landlock_abi_version = 11; /** * sys_landlock_create_ruleset - Create a new ruleset @@ -281,6 +284,15 @@ SYSCALL_DEFINE3(landlock_create_ruleset, ruleset->quiet_masks.net = ruleset_attr.quiet_access_net; ruleset->quiet_masks.scope = ruleset_attr.quiet_scoped; + /* + * Emits before anon_inode_getfd() installs the file descriptor, while + * the ruleset is still private to this thread: no lock is needed, and + * the event cannot race a concurrent close() freeing the ruleset under + * the tracepoint's BTF read. This is the last point at which the + * ruleset is guaranteed alive and unshared. + */ + trace_landlock_create_ruleset(ruleset); + /* Creates anonymous FD referring to the ruleset. */ ruleset_fd = anon_inode_getfd("[landlock-ruleset]", &ruleset_fops, ruleset, O_RDWR | O_CLOEXEC); @@ -308,8 +320,6 @@ static struct landlock_ruleset *get_ruleset_from_fd(const int fd, if (!(fd_file(ruleset_f)->f_mode & mode)) return ERR_PTR(-EPERM); ruleset = fd_file(ruleset_f)->private_data; - if (WARN_ON_ONCE(ruleset->num_layers != 1)) - return ERR_PTR(-EINVAL); landlock_get_ruleset(ruleset); return ruleset; } @@ -367,7 +377,7 @@ static int add_rule_path_beneath(struct landlock_ruleset *const ruleset, return -ENOMSG; /* Checks that allowed_access matches the @ruleset constraints. */ - mask = ruleset->access_masks[0].fs; + mask = ruleset->handled_masks.fs; if ((path_beneath_attr.allowed_access | mask) != mask) return -EINVAL; @@ -408,7 +418,7 @@ static int add_rule_net_port(struct landlock_ruleset *ruleset, return -ENOMSG; /* Checks that allowed_access matches the @ruleset constraints. */ - mask = landlock_get_net_access_mask(ruleset, 0); + mask = ruleset->handled_masks.net; if ((net_port_attr.allowed_access | mask) != mask) return -EINVAL; @@ -502,21 +512,28 @@ SYSCALL_DEFINE4(landlock_add_rule, const int, ruleset_fd, * - %LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON * - %LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF * - %LANDLOCK_RESTRICT_SELF_TSYNC + * - %LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS * * This system call enforces a Landlock ruleset on the current thread. * Enforcing a ruleset requires that the task has %CAP_SYS_ADMIN in its * namespace or is running with no_new_privs. This avoids scenarios where * unprivileged tasks can affect the behavior of privileged children. * + * With %LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS, the no_new_privs attribute of the + * calling thread is set only once the enforcement of the ruleset succeeded, + * which fulfills the above requirement: no_new_privs is set if and only if the + * call succeeds. + * * Return: 0 on success, or -errno on failure. Possible returned errors are: * * - %EOPNOTSUPP: Landlock is supported by the kernel but disabled at boot time; * - %EINVAL: @flags contains an unknown bit. * - %EBADF: @ruleset_fd is not a file descriptor for the current thread; * - %EBADFD: @ruleset_fd is not a ruleset file descriptor; - * - %EPERM: @ruleset_fd has no read access to the underlying ruleset, or the - * current thread is not running with no_new_privs, or it doesn't have - * %CAP_SYS_ADMIN in its namespace. + * - %EPERM: @ruleset_fd has no read access to the underlying ruleset, or + * %LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS is not set while the current thread + * is not running with no_new_privs and doesn't have %CAP_SYS_ADMIN in its + * namespace. * - %E2BIG: The maximum number of stacked rulesets is reached for the current * thread. * @@ -527,26 +544,30 @@ SYSCALL_DEFINE2(landlock_restrict_self, const int, ruleset_fd, const __u32, flags) { struct landlock_ruleset *ruleset __free(landlock_put_ruleset) = NULL; + struct landlock_domain *new_dom = NULL; struct cred *new_cred; struct landlock_cred_security *new_llcred; + bool process_wide; bool __maybe_unused log_same_exec, log_new_exec, log_subdomains, prev_log_subdomains; if (!is_initialized()) return -EOPNOTSUPP; - /* - * Similar checks as for seccomp(2), except that an -EPERM may be - * returned. - */ - if (!task_no_new_privs(current) && - !ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN)) - return -EPERM; - if ((flags | LANDLOCK_MASK_RESTRICT_SELF) != LANDLOCK_MASK_RESTRICT_SELF) return -EINVAL; + /* + * Similar checks as for seccomp(2), except that an -EPERM may be + * returned. LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS fulfills this + * requirement. + */ + if (!(flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS) && + !task_no_new_privs(current) && + !ns_capable_noaudit(current_user_ns(), CAP_SYS_ADMIN)) + return -EPERM; + /* Translates "off" flag to boolean. */ log_same_exec = !(flags & LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF); /* Translates "on" flag to boolean. */ @@ -576,11 +597,11 @@ SYSCALL_DEFINE2(landlock_restrict_self, const int, ruleset_fd, const __u32, new_llcred = landlock_cred(new_cred); -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG prev_log_subdomains = !new_llcred->log_subdomains_off; new_llcred->log_subdomains_off = !prev_log_subdomains || !log_subdomains; -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ /* * The only case when a ruleset may not be set is if @@ -595,37 +616,91 @@ SYSCALL_DEFINE2(landlock_restrict_self, const int, ruleset_fd, const __u32, * manipulating the current credentials because they are * dedicated per thread. */ - struct landlock_ruleset *const new_dom = - landlock_merge_ruleset(new_llcred->domain, ruleset); + mutex_lock(&ruleset->lock); + new_dom = landlock_merge_ruleset(new_llcred->domain, ruleset); if (IS_ERR(new_dom)) { + mutex_unlock(&ruleset->lock); abort_creds(new_cred); return PTR_ERR(new_dom); } + /* + * Emits the domain-creation event while @ruleset->lock is still + * held, right after the merge, so an eBPF program attached to + * the tracepoint reads the exact ruleset that was merged into + * the domain: a consistent snapshot that a concurrent + * landlock_add_rule() (which holds the same lock) cannot + * modify. + * + * This must come before the thread-sync wait below. Holding + * @ruleset->lock across landlock_restrict_sibling_threads() + * would hang: a sibling thread blocked in landlock_add_rule() + * on the same @ruleset->lock cannot run the task_work that + * thread-sync waits for (the lock wait is uninterruptible). + * Emitting here keeps the lock off the thread-sync path. + * + * The trade-off is that the event fires for a domain that a + * later (rare) thread-sync failure aborts. That path emits the + * matching free_domain event so the create/free pair stays + * balanced (see the thread-sync error path below). + */ + trace_landlock_create_domain(new_dom, ruleset); + mutex_unlock(&ruleset->lock); -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG new_dom->hierarchy->log_same_exec = log_same_exec; new_dom->hierarchy->log_new_exec = log_new_exec; + /* + * The creation event fired above, so move the domain out of + * LANDLOCK_LOG_UNCOMMITTED: its free_domain event must fire + * too, even if a thread-sync failure aborts it below. Audit + * logging may still be disabled (DISABLED); tracing observes it + * anyway. + */ if ((!log_same_exec && !log_new_exec) || !prev_log_subdomains) new_dom->hierarchy->log_status = LANDLOCK_LOG_DISABLED; -#endif /* CONFIG_AUDIT */ + else + new_dom->hierarchy->log_status = LANDLOCK_LOG_PENDING; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ /* Replaces the old (prepared) domain. */ - landlock_put_ruleset(new_llcred->domain); + landlock_put_domain(new_llcred->domain); new_llcred->domain = new_dom; -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG new_llcred->domain_exec |= BIT(new_dom->num_layers - 1); -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } if (flags & LANDLOCK_RESTRICT_SELF_TSYNC) { const int err = landlock_restrict_sibling_threads( - current_cred(), new_cred); + current_cred(), new_cred, flags); if (err) { + /* + * Thread-sync failed (rare), so the new domain is + * aborted instead of committed. Its creation event + * already fired above, so the imminent free must emit + * the matching free_domain event to keep the + * create/free pair balanced; no special log_status is + * set here. + */ abort_creds(new_cred); return err; } } - return commit_creds(new_cred); + /* Sets no_new_privs past the last point of failure. */ + if (flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS) + task_set_no_new_privs(current); + + /* Whole process: thread-sync swept siblings, or single-threaded. */ + process_wide = (flags & LANDLOCK_RESTRICT_SELF_TSYNC) || + get_nr_threads(current) == 1; + commit_creds(new_cred); + + /* The caller commits last, so its event concludes the operation. */ + if (ruleset) + trace_landlock_enforce_domain(new_dom, true, process_wide, + task_no_new_privs(current)); + + return 0; } diff --git a/security/landlock/task.c b/security/landlock/task.c index 360d226d0f51..4491ce31ae04 100644 --- a/security/landlock/task.c +++ b/security/landlock/task.c @@ -20,11 +20,11 @@ #include #include -#include "audit.h" #include "common.h" #include "cred.h" #include "domain.h" #include "fs.h" +#include "log.h" #include "ruleset.h" #include "setup.h" #include "task.h" @@ -41,8 +41,8 @@ * Return: True if @parent is an ancestor of or equal to @child, false * otherwise. */ -static bool domain_scope_le(const struct landlock_ruleset *const parent, - const struct landlock_ruleset *const child) +static bool domain_scope_le(const struct landlock_domain *const parent, + const struct landlock_domain *const child) { const struct landlock_hierarchy *walker; @@ -63,8 +63,8 @@ static bool domain_scope_le(const struct landlock_ruleset *const parent, return false; } -static int domain_ptrace(const struct landlock_ruleset *const parent, - const struct landlock_ruleset *const child) +static int domain_ptrace(const struct landlock_domain *const parent, + const struct landlock_domain *const child) { if (domain_scope_le(parent, child)) return 0; @@ -88,6 +88,7 @@ static int hook_ptrace_access_check(struct task_struct *const child, const unsigned int mode) { const struct landlock_cred_security *parent_subject; + u64 tracee_domain_id = 0; int err; /* Quick return for non-landlocked tasks. */ @@ -96,9 +97,13 @@ static int hook_ptrace_access_check(struct task_struct *const child, return 0; scoped_guard(rcu) { - const struct landlock_ruleset *const child_dom = + const struct landlock_domain *const child_dom = landlock_get_task_domain(child); err = domain_ptrace(parent_subject->domain, child_dom); +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + if (child_dom) + tracee_domain_id = child_dom->hierarchy->id; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } if (!err) @@ -116,6 +121,7 @@ static int hook_ptrace_access_check(struct task_struct *const child, .u.tsk = child, }, .layer_plus_one = parent_subject->domain->num_layers, + .other_domain_id = tracee_domain_id, }); return err; @@ -135,7 +141,8 @@ static int hook_ptrace_access_check(struct task_struct *const child, static int hook_ptrace_traceme(struct task_struct *const parent) { const struct landlock_cred_security *parent_subject; - const struct landlock_ruleset *child_dom; + const struct landlock_domain *child_dom; + u64 tracee_domain_id = 0; int err; child_dom = landlock_get_current_domain(); @@ -147,6 +154,12 @@ static int hook_ptrace_traceme(struct task_struct *const parent) if (!err) return 0; +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + /* The tracee is the current task; its domain is stable here. */ + if (child_dom) + tracee_domain_id = child_dom->hierarchy->id; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + /* * For the ptrace_traceme case, we log the domain which is the cause of * the denial, which means the parent domain instead of the current @@ -161,6 +174,7 @@ static int hook_ptrace_traceme(struct task_struct *const parent) .u.tsk = current, }, .layer_plus_one = parent_subject->domain->num_layers, + .other_domain_id = tracee_domain_id, }); return err; } @@ -176,8 +190,8 @@ static int hook_ptrace_traceme(struct task_struct *const parent) * Return: True if @server is in a different domain from @client and @client * is scoped to access @server (i.e. access should be denied), false otherwise. */ -static bool domain_is_scoped(const struct landlock_ruleset *const client, - const struct landlock_ruleset *const server, +static bool domain_is_scoped(const struct landlock_domain *const client, + const struct landlock_domain *const server, access_mask_t scope) { int client_layer, server_layer; @@ -236,13 +250,28 @@ static bool domain_is_scoped(const struct landlock_ruleset *const client, } static bool sock_is_scoped(struct sock *const other, - const struct landlock_ruleset *const domain) + const struct landlock_domain *const domain, + u64 *const peer_domain_id) { - const struct landlock_ruleset *dom_other; + const struct landlock_domain *dom_other; /* The credentials will not change. */ lockdep_assert_held(&unix_sk(other)->lock); + + /* + * A live kernel socket (e.g. from sock_create_kern()) has no backing + * file, hence no Landlock domain, so treat it as unscoped. The + * sk_socket check only guards that dereference; sk_socket is NULL + * solely for a dead peer, which the caller already excludes under the + * held lock, so no separate SOCK_DEAD check is needed. + */ + if (unlikely(!other->sk_socket || !other->sk_socket->file)) + return false; + dom_other = landlock_cred(other->sk_socket->file->f_cred)->domain; +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + *peer_domain_id = dom_other ? dom_other->hierarchy->id : 0; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ return domain_is_scoped(domain, dom_other, LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET); } @@ -270,6 +299,7 @@ static int hook_unix_stream_connect(struct sock *const sock, struct sock *const newsk) { size_t handle_layer; + u64 peer_domain_id = 0; const struct landlock_cred_security *const subject = landlock_get_applicable_subject(current_cred(), unix_scope, &handle_layer); @@ -281,7 +311,7 @@ static int hook_unix_stream_connect(struct sock *const sock, if (!is_abstract_socket(other)) return 0; - if (!sock_is_scoped(other, subject->domain)) + if (!sock_is_scoped(other, subject->domain, &peer_domain_id)) return 0; landlock_log_denial(subject, &(struct landlock_request) { @@ -293,6 +323,7 @@ static int hook_unix_stream_connect(struct sock *const sock, }, }, .layer_plus_one = handle_layer + 1, + .other_domain_id = peer_domain_id, }); return -EPERM; } @@ -301,6 +332,7 @@ static int hook_unix_may_send(struct socket *const sock, struct socket *const other) { size_t handle_layer; + u64 peer_domain_id = 0; const struct landlock_cred_security *const subject = landlock_get_applicable_subject(current_cred(), unix_scope, &handle_layer); @@ -318,7 +350,7 @@ static int hook_unix_may_send(struct socket *const sock, if (!is_abstract_socket(other->sk)) return 0; - if (!sock_is_scoped(other->sk, subject->domain)) + if (!sock_is_scoped(other->sk, subject->domain, &peer_domain_id)) return 0; landlock_log_denial(subject, &(struct landlock_request) { @@ -330,6 +362,7 @@ static int hook_unix_may_send(struct socket *const sock, }, }, .layer_plus_one = handle_layer + 1, + .other_domain_id = peer_domain_id, }); return -EPERM; } @@ -344,6 +377,7 @@ static int hook_task_kill(struct task_struct *const p, { bool is_scoped; size_t handle_layer; + u64 target_domain_id = 0; const struct landlock_cred_security *subject; if (!cred) { @@ -370,9 +404,15 @@ static int hook_task_kill(struct task_struct *const p, return 0; scoped_guard(rcu) { - is_scoped = domain_is_scoped(subject->domain, - landlock_get_task_domain(p), + const struct landlock_domain *const other = + landlock_get_task_domain(p); + + is_scoped = domain_is_scoped(subject->domain, other, signal_scope.scope); +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + if (other) + target_domain_id = other->hierarchy->id; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } if (!is_scoped) @@ -385,6 +425,7 @@ static int hook_task_kill(struct task_struct *const p, .u.tsk = p, }, .layer_plus_one = handle_layer + 1, + .other_domain_id = target_domain_id, }); return -EPERM; } @@ -394,6 +435,7 @@ static int hook_file_send_sigiotask(struct task_struct *tsk, { const struct landlock_cred_security *subject; bool is_scoped = false; + u64 target_domain_id = 0; /* Lock already held by send_sigio() and send_sigurg(). */ lockdep_assert_held(&fown->lock); @@ -421,9 +463,15 @@ static int hook_file_send_sigiotask(struct task_struct *tsk, return 0; scoped_guard(rcu) { - is_scoped = domain_is_scoped(subject->domain, - landlock_get_task_domain(tsk), + const struct landlock_domain *const other = + landlock_get_task_domain(tsk); + + is_scoped = domain_is_scoped(subject->domain, other, signal_scope.scope); +#ifdef CONFIG_SECURITY_LANDLOCK_LOG + if (other) + target_domain_id = other->hierarchy->id; +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ } if (!is_scoped) @@ -435,9 +483,10 @@ static int hook_file_send_sigiotask(struct task_struct *tsk, .type = LSM_AUDIT_DATA_TASK, .u.tsk = tsk, }, -#ifdef CONFIG_AUDIT +#ifdef CONFIG_SECURITY_LANDLOCK_LOG .layer_plus_one = landlock_file(fown->file)->fown_layer + 1, -#endif /* CONFIG_AUDIT */ +#endif /* CONFIG_SECURITY_LANDLOCK_LOG */ + .other_domain_id = target_domain_id, }); return -EPERM; } diff --git a/security/landlock/trace.c b/security/landlock/trace.c new file mode 100644 index 000000000000..2ea7aac8d75d --- /dev/null +++ b/security/landlock/trace.c @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-2.0-only +/* + * Landlock - Tracepoint helpers + * + * Copyright © 2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. + */ + +#include +#include +#include +#include +#include +#include + +#include "access.h" +#include "domain.h" +#include "fs.h" +#include "log.h" +#include "ruleset.h" +#include "trace.h" + +/* + * Generates the tracepoint definitions in this translation unit. The trace + * event header dereferences the traced objects in TP_fast_assign, so the full + * struct definitions (e.g. ruleset.h, domain.h) must be included before it. + */ +#define CREATE_TRACE_POINTS +#include + +/** + * landlock_trace_free_domain - Emit a tracepoint on domain deallocation + * + * @hierarchy: The domain's hierarchy being deallocated. + * + * Fires only for a hierarchy whose creation event was emitted, i.e. one that + * left LANDLOCK_LOG_UNCOMMITTED in landlock_restrict_self(). This keeps the + * create/free pair balanced: a hierarchy that never became observable is freed + * silently, while a domain that landlock_restrict_self() created and a + * thread-sync failure then aborted still fires free_domain, because its + * creation event already fired. + * + * Called from landlock_log_free_domain(). + */ +void landlock_trace_free_domain(const struct landlock_hierarchy *const hierarchy) +{ + /* + * The log_status read is a correctness guard (keep the create/free pair + * balanced), not a cost guard, so this cold path needs no + * trace_..._enabled() check: the tracepoint is a static-branch no-op + * when disabled. The denial path guards trace_..._enabled() instead + * because it does expensive __getname()/path work before emitting. + */ + if (READ_ONCE(hierarchy->log_status) != LANDLOCK_LOG_UNCOMMITTED) + trace_landlock_free_domain(hierarchy); +} + +/** + * landlock_trace_denial - Emit a tracepoint for a denied access request + * + * @request: Detail of the user space request. + * @youngest_denied: The youngest hierarchy node that denied the access. + * @missing: The set of denied access rights. + * @same_exec: Whether the current task is the same executable that called + * landlock_restrict_self() for the denying domain, as computed + * by landlock_log_denial(). + * @logged: Whether the domain's policy selects this denial for logging, as + * computed by landlock_log_denial(). + * + * Emits the tracepoint matching @request->type when its event is enabled. + * Unlike audit, fires regardless of @logged; the value is recorded in the event + * so consumers can filter on it. + * + * Called from landlock_log_denial(). + */ +void landlock_trace_denial( + const struct landlock_request *const request, + const struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool same_exec, const bool logged) +{ + switch (request->type) { + case LANDLOCK_REQUEST_FS_ACCESS: + case LANDLOCK_REQUEST_FS_CHANGE_TOPOLOGY: + if (trace_landlock_deny_access_fs_enabled()) { + char *buf __free(__putname) = __getname(); + struct path dentry_path; + const char *pathname; + const struct path *path = NULL; + + /* + * Selects the path from the audit data type, as + * dump_common_audit_data() does. A FS_ACCESS denial + * carries a file (hook_file_truncate) or an ioctl op + * (hook_file_ioctl) rather than a path; + * FS_CHANGE_TOPOLOGY carries a path or a bare dentry. + * Reading the wrong union member would dereference + * garbage, so every reachable type is handled here. + */ + switch (request->audit.type) { + case LSM_AUDIT_DATA_FILE: + path = &request->audit.u.file->f_path; + break; + case LSM_AUDIT_DATA_IOCTL_OP: + path = &request->audit.u.op->path; + break; + case LSM_AUDIT_DATA_DENTRY: + /* + * Build a path on the stack with the real + * dentry so TP_fast_assign can extract dev and + * ino; the mnt field is unused there. + */ + dentry_path = (struct path){ + .dentry = request->audit.u.dentry, + }; + path = &dentry_path; + break; + case LSM_AUDIT_DATA_PATH: + path = &request->audit.u.path; + break; + default: + WARN_ONCE(1, + "Unhandled Landlock FS audit type %d", + request->audit.type); + break; + } + + if (!path) + break; + + if (!buf) { + pathname = ""; + } else if (request->audit.type == + LSM_AUDIT_DATA_DENTRY) { + /* No vfsmount: render the dentry path alone. */ + pathname = dentry_path_raw( + request->audit.u.dentry, buf, PATH_MAX); + if (IS_ERR(pathname)) + pathname = + PTR_ERR(pathname) == + -ENAMETOOLONG ? + "" : + ""; + } else { + pathname = resolve_path_for_trace(path, buf); + } + + trace_landlock_deny_access_fs(youngest_denied, + same_exec, logged, + missing, path, pathname); + } + break; + case LANDLOCK_REQUEST_NET_ACCESS: + if (trace_landlock_deny_access_net_enabled()) + trace_landlock_deny_access_net( + youngest_denied, same_exec, logged, missing, + request->audit.u.net->sk, + ntohs(request->audit.u.net->sport), + ntohs(request->audit.u.net->dport)); + break; + case LANDLOCK_REQUEST_PTRACE: + if (trace_landlock_deny_ptrace_enabled()) + trace_landlock_deny_ptrace(youngest_denied, same_exec, + logged, + request->other_domain_id, + request->audit.u.tsk); + break; + case LANDLOCK_REQUEST_SCOPE_SIGNAL: + if (trace_landlock_deny_scope_signal_enabled()) + trace_landlock_deny_scope_signal( + youngest_denied, same_exec, logged, + request->other_domain_id, request->audit.u.tsk); + break; + case LANDLOCK_REQUEST_SCOPE_ABSTRACT_UNIX_SOCKET: + if (trace_landlock_deny_scope_abstract_unix_socket_enabled()) + trace_landlock_deny_scope_abstract_unix_socket( + youngest_denied, same_exec, logged, + request->other_domain_id, + request->audit.u.net->sk); + break; + default: + WARN_ONCE(1, "Unhandled Landlock request type %d", + request->type); + break; + } +} diff --git a/security/landlock/trace.h b/security/landlock/trace.h new file mode 100644 index 000000000000..7be98e748855 --- /dev/null +++ b/security/landlock/trace.h @@ -0,0 +1,44 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * Landlock - Tracepoint helpers + * + * Copyright © 2025 Microsoft Corporation + * Copyright © 2026 Cloudflare, Inc. + */ + +#ifndef _SECURITY_LANDLOCK_TRACE_H +#define _SECURITY_LANDLOCK_TRACE_H + +#include "access.h" + +struct landlock_hierarchy; +struct landlock_request; + +#ifdef CONFIG_TRACEPOINTS + +void landlock_trace_free_domain( + const struct landlock_hierarchy *const hierarchy); + +void landlock_trace_denial( + const struct landlock_request *const request, + const struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool same_exec, const bool logged); + +#else /* CONFIG_TRACEPOINTS */ + +static inline void +landlock_trace_free_domain(const struct landlock_hierarchy *const hierarchy) +{ +} + +static inline void +landlock_trace_denial(const struct landlock_request *const request, + const struct landlock_hierarchy *const youngest_denied, + const access_mask_t missing, const bool same_exec, + const bool logged) +{ +} + +#endif /* CONFIG_TRACEPOINTS */ + +#endif /* _SECURITY_LANDLOCK_TRACE_H */ diff --git a/security/landlock/tsync.c b/security/landlock/tsync.c index c5730bbd9ed3..cfa73a2a67ca 100644 --- a/security/landlock/tsync.c +++ b/security/landlock/tsync.c @@ -17,10 +17,13 @@ #include #include #include +#include #include "cred.h" #include "tsync.h" +#include + /* * Shared state between multiple threads which are enforcing Landlock rulesets * in lockstep with each other. @@ -78,6 +81,8 @@ struct tsync_work { */ static void restrict_one_thread(struct tsync_shared_context *ctx) { + const struct landlock_domain *new_dom = + landlock_cred(ctx->new_cred)->domain; int err; struct cred *cred = NULL; @@ -146,6 +151,18 @@ static void restrict_one_thread(struct tsync_shared_context *ctx) commit_creds(cred); + /* + * Emitted strictly after commit_creds() and before the out: label, so + * it fires only for a thread now enforcing new_dom, and every + * non-concluding (complete == false) event happens-before the + * operation's single concluding one. Skipped on the flags-only path, + * where old_cred and new_cred carry the same domain. A sibling never + * concludes the operation and its enforcement is always process-wide. + */ + if (new_dom != landlock_cred(ctx->old_cred)->domain) + trace_landlock_enforce_domain(new_dom, false, true, + task_no_new_privs(current)); + out: /* Notify the calling thread once all threads are done */ if (atomic_dec_return(&ctx->num_unfinished) == 0) @@ -466,7 +483,8 @@ static void cancel_tsync_works(const struct tsync_works *works, * restrict_sibling_threads - enables a Landlock policy for all sibling threads */ int landlock_restrict_sibling_threads(const struct cred *old_cred, - const struct cred *new_cred) + const struct cred *new_cred, + const u32 restrict_flags) { int err; struct tsync_shared_context shared_ctx; @@ -481,7 +499,9 @@ int landlock_restrict_sibling_threads(const struct cred *old_cred, init_completion(&shared_ctx.all_finished); shared_ctx.old_cred = old_cred; shared_ctx.new_cred = new_cred; - shared_ctx.set_no_new_privs = task_no_new_privs(current); + shared_ctx.set_no_new_privs = + (restrict_flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS) || + task_no_new_privs(current); /* * Serialize concurrent TSYNC operations to prevent deadlocks when diff --git a/security/landlock/tsync.h b/security/landlock/tsync.h index ef86bb61c2f6..2ae4f938ca00 100644 --- a/security/landlock/tsync.h +++ b/security/landlock/tsync.h @@ -9,8 +9,10 @@ #define _SECURITY_LANDLOCK_TSYNC_H #include +#include int landlock_restrict_sibling_threads(const struct cred *old_cred, - const struct cred *new_cred); + const struct cred *new_cred, + u32 restrict_flags); #endif /* _SECURITY_LANDLOCK_TSYNC_H */ diff --git a/tools/testing/selftests/landlock/audit.h b/tools/testing/selftests/landlock/audit.h index f45fdef35681..d428ce802f49 100644 --- a/tools/testing/selftests/landlock/audit.h +++ b/tools/testing/selftests/landlock/audit.h @@ -214,41 +214,6 @@ static int audit_set_status(int fd, __u32 key, __u32 val) return audit_request(fd, &msg, NULL); } -/* Returns a pointer to the last filled character of @dst, which is `\0`. */ -static __maybe_unused char *regex_escape(const char *const src, char *dst, - size_t dst_size) -{ - char *d = dst; - - for (const char *s = src; *s; s++) { - switch (*s) { - case '$': - case '*': - case '.': - case '[': - case '\\': - case ']': - case '^': - if (d >= dst + dst_size - 2) - return (char *)-ENOMEM; - - *d++ = '\\'; - *d++ = *s; - break; - default: - if (d >= dst + dst_size - 1) - return (char *)-ENOMEM; - - *d++ = *s; - } - } - if (d >= dst + dst_size - 1) - return (char *)-ENOMEM; - - *d = '\0'; - return d; -} - /* * @domain_id: The domain ID extracted from the audit message (if the first part * of @pattern is REGEX_LANDLOCK_PREFIX). It is set to 0 if the domain ID is diff --git a/tools/testing/selftests/landlock/base_test.c b/tools/testing/selftests/landlock/base_test.c index cbd3c1669951..d20ab8f0862c 100644 --- a/tools/testing/selftests/landlock/base_test.c +++ b/tools/testing/selftests/landlock/base_test.c @@ -76,7 +76,7 @@ TEST(abi_version) const struct landlock_ruleset_attr ruleset_attr = { .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE, }; - ASSERT_EQ(10, landlock_create_ruleset(NULL, 0, + ASSERT_EQ(11, landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)); ASSERT_EQ(-1, landlock_create_ruleset(&ruleset_attr, 0, @@ -255,12 +255,24 @@ TEST(restrict_self_checks_ordering) /* Checks unprivileged enforcement without no_new_privs. */ drop_caps(_metadata); + /* + * The flags validity is checked before the no_new_privs / + * CAP_SYS_ADMIN requirement. + */ ASSERT_EQ(-1, landlock_restrict_self(-1, -1)); - ASSERT_EQ(EPERM, errno); + ASSERT_EQ(EINVAL, errno); ASSERT_EQ(-1, landlock_restrict_self(-1, 0)); ASSERT_EQ(EPERM, errno); ASSERT_EQ(-1, landlock_restrict_self(ruleset_fd, 0)); ASSERT_EQ(EPERM, errno); + /* + * LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS fulfills the no_new_privs / + * CAP_SYS_ADMIN requirement but requires a ruleset, so the FD is + * checked next. + */ + ASSERT_EQ(-1, landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + ASSERT_EQ(EBADF, errno); ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)); @@ -277,6 +289,41 @@ TEST(restrict_self_checks_ordering) ASSERT_EQ(0, close(ruleset_fd)); } +TEST(restrict_self_max_layers) +{ + const struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_EXECUTE, + }; + struct landlock_path_beneath_attr path_beneath_attr = { + .allowed_access = LANDLOCK_ACCESS_FS_EXECUTE, + .parent_fd = -1, + }; + const int ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + ASSERT_LE(0, ruleset_fd); + + path_beneath_attr.parent_fd = + open("/tmp", O_PATH | O_NOFOLLOW | O_DIRECTORY | O_CLOEXEC); + ASSERT_LE(0, path_beneath_attr.parent_fd); + ASSERT_EQ(0, landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath_attr, 0)); + ASSERT_EQ(0, close(path_beneath_attr.parent_fd)); + + /* Enforces the maximum number of allowed layers. */ + for (int i = 0; i < LANDLOCK_MAX_NUM_LAYERS; i++) + ASSERT_EQ(0, landlock_restrict_self(ruleset_fd, 0)); + + /* Enforces one too many rulesets. */ + drop_caps(_metadata); + ASSERT_EQ(-1, landlock_restrict_self( + ruleset_fd, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + ASSERT_EQ(E2BIG, errno); + + /* Checks that the failed call did not set no_new_privs. */ + ASSERT_EQ(0, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + ASSERT_EQ(0, close(ruleset_fd)); +} + TEST(restrict_self_fd) { int fd; @@ -288,7 +335,7 @@ TEST(restrict_self_fd) EXPECT_EQ(EBADFD, errno); } -TEST(restrict_self_fd_logging_flags) +TEST(restrict_self_fd_flags) { int fd; @@ -302,11 +349,16 @@ TEST(restrict_self_fd_logging_flags) EXPECT_EQ(-1, landlock_restrict_self( fd, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF)); EXPECT_EQ(EBADFD, errno); + + /* LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS requires a ruleset FD. */ + EXPECT_EQ(-1, landlock_restrict_self( + fd, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + EXPECT_EQ(EBADFD, errno); } -TEST(restrict_self_logging_flags) +TEST(restrict_self_flags) { - const __u32 last_flag = LANDLOCK_RESTRICT_SELF_TSYNC; + const __u32 last_flag = LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS; /* Tests invalid flag combinations. */ @@ -349,6 +401,17 @@ TEST(restrict_self_logging_flags) LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON)); EXPECT_EQ(EBADF, errno); + /* LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS requires a ruleset FD. */ + + EXPECT_EQ(-1, landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + EXPECT_EQ(EBADF, errno); + + EXPECT_EQ(-1, landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF | + LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + EXPECT_EQ(EBADF, errno); + /* Tests with an invalid ruleset_fd. */ EXPECT_EQ(-1, landlock_restrict_self( @@ -359,6 +422,37 @@ TEST(restrict_self_logging_flags) -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF)); } +TEST(restrict_self_no_new_privs) +{ + const struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE, + }; + const int ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + + ASSERT_LE(0, ruleset_fd); + + /* + * The calling thread does not need CAP_SYS_ADMIN nor an explicit + * prctl(2) PR_SET_NO_NEW_PRIVS call. + */ + drop_caps(_metadata); + ASSERT_EQ(0, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + + /* Checks that a failed call does not set no_new_privs. */ + EXPECT_EQ(-1, landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + EXPECT_EQ(EBADF, errno); + EXPECT_EQ(0, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + + /* Checks that a successful call sets no_new_privs. */ + ASSERT_EQ(0, landlock_restrict_self( + ruleset_fd, LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)); + EXPECT_EQ(1, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + + EXPECT_EQ(0, close(ruleset_fd)); +} + TEST(ruleset_fd_io) { struct landlock_ruleset_attr ruleset_attr = { diff --git a/tools/testing/selftests/landlock/common.h b/tools/testing/selftests/landlock/common.h index 7206d5105d66..c5124de68a51 100644 --- a/tools/testing/selftests/landlock/common.h +++ b/tools/testing/selftests/landlock/common.h @@ -253,3 +253,50 @@ static void __maybe_unused set_unix_address(struct service_fixture *const srv, srv->unix_addr_len = SUN_LEN(&srv->unix_addr); srv->unix_addr.sun_path[0] = '\0'; } + +/** + * regex_escape - Escape BRE metacharacters in a string + * + * @src: Source string to escape. + * @dst: Destination buffer for the escaped string. + * @dst_size: Size of the destination buffer. + * + * Escapes characters that have special meaning in POSIX Basic Regular + * Expressions: $ * . [ \ ] ^ + * + * Returns a pointer to the NUL terminator in @dst (cursor-style API for + * chaining), or (char *)-ENOMEM if the buffer is too small. + */ +static __maybe_unused char *regex_escape(const char *const src, char *dst, + size_t dst_size) +{ + char *d = dst; + + for (const char *s = src; *s; s++) { + switch (*s) { + case '$': + case '*': + case '.': + case '[': + case '\\': + case ']': + case '^': + if (d >= dst + dst_size - 2) + return (char *)-ENOMEM; + + *d++ = '\\'; + *d++ = *s; + break; + default: + if (d >= dst + dst_size - 1) + return (char *)-ENOMEM; + + *d++ = *s; + } + } + if (d >= dst + dst_size - 1) + return (char *)-ENOMEM; + + *d = '\0'; + return d; +} diff --git a/tools/testing/selftests/landlock/config b/tools/testing/selftests/landlock/config index 8fe9b461b1fd..d86321936fd8 100644 --- a/tools/testing/selftests/landlock/config +++ b/tools/testing/selftests/landlock/config @@ -2,6 +2,8 @@ CONFIG_AF_UNIX_OOB=y CONFIG_AUDIT=y CONFIG_CGROUPS=y CONFIG_CGROUP_SCHED=y +CONFIG_ENABLE_DEFAULT_TRACERS=y +CONFIG_FTRACE=y CONFIG_INET=y CONFIG_IPV6=y CONFIG_KEYS=y diff --git a/tools/testing/selftests/landlock/fs_test.c b/tools/testing/selftests/landlock/fs_test.c index 86e08aa6e0a7..18dbdb99aeba 100644 --- a/tools/testing/selftests/landlock/fs_test.c +++ b/tools/testing/selftests/landlock/fs_test.c @@ -44,6 +44,9 @@ #include "audit.h" #include "common.h" +#include "trace.h" + +#define TRACE_TASK "fs_test" #ifndef renameat2 int renameat2(int olddirfd, const char *oldpath, int newdirfd, @@ -2247,6 +2250,177 @@ TEST_F_FORK(layout1, rename_file) RENAME_EXCHANGE)); } +TEST_F_FORK(layout1, rename_whiteout_denied) +{ + /* The affected file is a FIFO. */ + ASSERT_EQ(0, unlink(file1_s3d3)); + ASSERT_EQ(0, mknod(file1_s3d3, S_IFIFO | 0600, 0)); + + /* Deny MAKE_REG, but allow MAKE_FIFO. */ + enforce_fs(_metadata, LANDLOCK_ACCESS_FS_MAKE_REG, NULL); + + /* + * Try to rename a file with RENAME_WHITEOUT. + * file1_s3d3 is in dir_s3d2 (tmpfs), so it supports RENAME_WHITEOUT. + * Denied, because whiteout creation is guarded with MAKE_REG. + */ + EXPECT_EQ(-1, renameat2(AT_FDCWD, file1_s3d3, AT_FDCWD, + TMP_DIR "/s3d1/s3d2/s3d3/f2", RENAME_WHITEOUT)); + EXPECT_EQ(EACCES, errno); +} + +static bool is_whiteout(const char *const path) +{ + struct stat st; + + if (stat(path, &st) == -1) + return false; + + return S_ISCHR(st.st_mode) && st.st_rdev == makedev(0, 0); +} + +static bool is_fifo(const char *const path) +{ + struct stat st; + + return stat(path, &st) == 0 && S_ISFIFO(st.st_mode); +} + +static bool is_missing(const char *const path) +{ + struct stat st; + + return stat(path, &st) == -1 && errno == ENOENT; +} + +TEST_F_FORK(layout1, rename_whiteout_allowed) +{ + const struct rule rules[] = { + { + .path = dir_s3d3, + .access = LANDLOCK_ACCESS_FS_MAKE_REG, + }, + {}, + }; + + /* The affected file is a FIFO. */ + ASSERT_EQ(0, unlink(file1_s3d3)); + ASSERT_EQ(0, mknod(file1_s3d3, S_IFIFO | 0600, 0)); + + /* Allow MAKE_REG below dir_s3d3. */ + enforce_fs(_metadata, LANDLOCK_ACCESS_FS_MAKE_REG, rules); + + /* + * Rename a file with RENAME_WHITEOUT within the same directory. + * Allowed, because MAKE_REG is granted for the whiteout object which + * gets created in the source location. + */ + EXPECT_EQ(0, renameat2(AT_FDCWD, file1_s3d3, AT_FDCWD, + TMP_DIR "/s3d1/s3d2/s3d3/f2", RENAME_WHITEOUT)); + + /* A whiteout object took the place of the moved FIFO. */ + EXPECT_TRUE(is_whiteout(file1_s3d3)); + EXPECT_TRUE(is_fifo(TMP_DIR "/s3d1/s3d2/s3d3/f2")); +} + +TEST_F_FORK(layout1, rename_whiteout_reparenting) +{ + const struct rule rules[] = { + { + .path = dir_s3d2, + .access = LANDLOCK_ACCESS_FS_REFER, + }, + { + .path = dir_s3d3, + .access = LANDLOCK_ACCESS_FS_MAKE_REG, + }, + {}, + }; + + /* The moved files are FIFOs. */ + ASSERT_EQ(0, unlink(file1_s3d3)); + ASSERT_EQ(0, mknod(file1_s3d3, S_IFIFO | 0600, 0)); + ASSERT_EQ(0, unlink(file1_s3d4)); + ASSERT_EQ(0, mknod(file1_s3d4, S_IFIFO | 0600, 0)); + + /* Allow REFER below dir_s3d2, but MAKE_REG only below dir_s3d3. */ + enforce_fs(_metadata, + LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_REFER, + rules); + + /* + * The whiteout object is created in the source directory: Moving the + * FIFO out of dir_s3d4 is denied because MAKE_REG is not granted + * there, even though it is granted in the destination directory + * dir_s3d3. + */ + EXPECT_EQ(-1, renameat2(AT_FDCWD, file1_s3d4, AT_FDCWD, + TMP_DIR "/s3d1/s3d2/s3d3/f2", RENAME_WHITEOUT)); + EXPECT_EQ(EACCES, errno); + + /* + * Moving the FIFO out of dir_s3d3 is allowed, because MAKE_REG is + * granted there for the created whiteout object. + */ + EXPECT_EQ(0, renameat2(AT_FDCWD, file1_s3d3, AT_FDCWD, + TMP_DIR "/s3d1/s3d2/s3d4/f2", RENAME_WHITEOUT)); + + /* A whiteout object took the place of the moved FIFO. */ + EXPECT_TRUE(is_whiteout(file1_s3d3)); + EXPECT_TRUE(is_fifo(TMP_DIR "/s3d1/s3d2/s3d4/f2")); +} + +TEST_F_FORK(layout1, rename_whiteout_exchange) +{ + const char *const whiteout_s3d3 = TMP_DIR "/s3d1/s3d2/s3d3/f2"; + const struct rule rules[] = { + { + .path = dir_s3d2, + .access = LANDLOCK_ACCESS_FS_REFER, + }, + { + .path = dir_s3d3, + .access = LANDLOCK_ACCESS_FS_MAKE_REG, + }, + {}, + }; + + /* The exchanged files are FIFOs and an existing whiteout object. */ + ASSERT_EQ(0, unlink(file1_s3d3)); + ASSERT_EQ(0, mknod(file1_s3d3, S_IFIFO | 0600, 0)); + ASSERT_EQ(0, mknod(whiteout_s3d3, S_IFCHR | 0600, makedev(0, 0))); + ASSERT_EQ(0, unlink(file1_s3d4)); + ASSERT_EQ(0, mknod(file1_s3d4, S_IFIFO | 0600, 0)); + + /* Allow REFER below dir_s3d2, but MAKE_REG only below dir_s3d3. */ + enforce_fs(_metadata, + LANDLOCK_ACCESS_FS_MAKE_REG | LANDLOCK_ACCESS_FS_REFER, + rules); + + /* + * With RENAME_EXCHANGE, the whiteout object moves into the source + * directory of the rename: Exchanging the FIFO in dir_s3d4 with the + * whiteout object is denied because MAKE_REG is not granted in + * dir_s3d4, even though it is granted in the whiteout object's own + * directory dir_s3d3. + */ + EXPECT_EQ(-1, renameat2(AT_FDCWD, file1_s3d4, AT_FDCWD, whiteout_s3d3, + RENAME_EXCHANGE)); + EXPECT_EQ(EACCES, errno); + + /* + * Exchanging the FIFO in dir_s3d3 with the whiteout object is + * allowed, because MAKE_REG is granted in the directory into which + * the whiteout object moves. + */ + EXPECT_EQ(0, renameat2(AT_FDCWD, file1_s3d3, AT_FDCWD, whiteout_s3d3, + RENAME_EXCHANGE)); + + /* The FIFO and the whiteout object swapped places. */ + EXPECT_TRUE(is_whiteout(file1_s3d3)); + EXPECT_TRUE(is_fifo(whiteout_s3d3)); +} + TEST_F_FORK(layout1, rename_dir) { const struct rule rules[] = { @@ -3270,6 +3444,18 @@ TEST_F_FORK(layout1, make_char) makedev(1, 3)); } +TEST_F_FORK(layout1, make_whiteout) +{ + /* + * Creates a whiteout object (creation guarded by MAKE_REG). + * + * Contrary to the other character devices, this does not require + * CAP_MKNOD, cf. vfs_mknod(). + */ + test_make_file(_metadata, LANDLOCK_ACCESS_FS_MAKE_REG, S_IFCHR, + makedev(0, 0)); +} + TEST_F_FORK(layout1, make_block) { /* Creates a /dev/loop0 device. */ @@ -6459,6 +6645,8 @@ static const char lower_fo1[] = LOWER_DATA "/fo1"; static const char lower_do1[] = LOWER_DATA "/do1"; static const char lower_do1_fo2[] = LOWER_DATA "/do1/fo2"; static const char lower_do1_fl3[] = LOWER_DATA "/do1/fl3"; +/* lower_pl1 is a FIFO and is deliberately not in the lists below. */ +static const char lower_pl1[] = LOWER_DATA "/pl1"; static const char (*lower_base_files[])[] = { &lower_fl1, @@ -6508,6 +6696,8 @@ static const char (*upper_sub_files[])[] = { #define MERGE_BASE TMP_DIR "/merge" #define MERGE_DATA MERGE_BASE "/data" static const char merge_fl1[] = MERGE_DATA "/fl1"; +/* merge_pl1 is a FIFO and is deliberately not in the lists below. */ +static const char merge_pl1[] = MERGE_DATA "/pl1"; static const char merge_dl1[] = MERGE_DATA "/dl1"; static const char merge_dl1_fl2[] = MERGE_DATA "/dl1/fl2"; static const char merge_fu1[] = MERGE_DATA "/fu1"; @@ -6548,7 +6738,8 @@ static const char (*merge_sub_files[])[] = { * │   │   ├── fl3 * │   │   └── fo2 * │   ├── fl1 - * │   └── fo1 + * │   ├── fo1 + * │   └── pl1 [FIFO] * ├── merge * │   └── data * │   ├── dl1 @@ -6561,7 +6752,8 @@ static const char (*merge_sub_files[])[] = { * │   │   └── fu2 * │   ├── fl1 * │   ├── fo1 - * │   └── fu1 + * │   ├── fu1 + * │   └── pl1 [FIFO] * └── upper * ├── data * │   ├── do1 @@ -6599,6 +6791,7 @@ FIXTURE_SETUP(layout2_overlay) create_file(_metadata, lower_fo1); create_file(_metadata, lower_do1_fo2); create_file(_metadata, lower_do1_fl3); + ASSERT_EQ(0, mknod(lower_pl1, S_IFIFO | 0600, 0)); create_directory(_metadata, UPPER_BASE); set_cap(_metadata, CAP_SYS_ADMIN); @@ -6631,6 +6824,7 @@ FIXTURE_TEARDOWN_PARENT(layout2_overlay) EXPECT_EQ(0, remove_path(lower_fl1)); EXPECT_EQ(0, remove_path(lower_do1_fo2)); EXPECT_EQ(0, remove_path(lower_fo1)); + EXPECT_EQ(0, remove_path(lower_pl1)); /* umount(LOWER_BASE)) is handled by namespace lifetime. */ EXPECT_EQ(0, remove_path(LOWER_BASE)); @@ -6927,7 +7121,7 @@ TEST_F_FORK(layout2_overlay, same_content_different_file) ASSERT_EQ(0, test_open(path_entry, O_RDWR)); } - /* Only allowes access to the merge hierarchy. */ + /* Only allows access to the merge hierarchy. */ enforce_fs(_metadata, ACCESS_RW, layer5_merge_only); /* Checks new accesses on lower layer. */ @@ -6951,6 +7145,43 @@ TEST_F_FORK(layout2_overlay, same_content_different_file) } } +TEST_F_FORK(layout2_overlay, rename_in_overlay_without_make_reg) +{ + const char *const merge_pl1_renamed = MERGE_DATA "/pl1_renamed"; + + if (self->skip_test) + SKIP(return, "overlayfs is not supported (test)"); + + /* + * merge_pl1 is a FIFO which only exists in the lower layer. Before + * the rename, the upper layer has no entry under this name. + */ + ASSERT_TRUE(is_fifo(merge_pl1)); + ASSERT_TRUE(is_missing(UPPER_DATA "/pl1")); + + /* MAKE_REG is restricted, but MAKE_FIFO is not. */ + enforce_fs(_metadata, LANDLOCK_ACCESS_FS_MAKE_REG, NULL); + + /* + * Rename the FIFO through OverlayFS. merge_pl1 originates from the + * lower layer, so this triggers a copy-up and creates the whiteout in + * the upper layer to hide the lower layer FIFO file. Even though + * MAKE_REG is restricted, the rename on the OverlayFS works. + */ + EXPECT_EQ(0, rename(merge_pl1, merge_pl1_renamed)); + + /* Check that the rename worked. */ + EXPECT_TRUE(is_fifo(merge_pl1_renamed)); + EXPECT_TRUE(is_missing(merge_pl1)); + + /* + * Check that the whiteout object was created on the underlying "upper" + * filesystem during the rename. This is OK because the whiteout object + * was created by OverlayFS, not by the calling task. + */ + EXPECT_TRUE(is_whiteout(UPPER_DATA "/pl1")); +} + FIXTURE(layout3_fs) { bool has_created_dir; @@ -7436,7 +7667,7 @@ TEST_F(audit_layout1, make_char) enforce_fs(_metadata, ACCESS_ALL, NULL); - EXPECT_EQ(-1, mknod(file1_s1d3, S_IFCHR | 0644, 0)); + EXPECT_EQ(-1, mknod(file1_s1d3, S_IFCHR | 0644, makedev(7, 0))); EXPECT_EQ(EACCES, errno); EXPECT_EQ(0, matches_log_fs(_metadata, self->audit_fd, "fs\\.make_char", dir_s1d3)); @@ -7446,6 +7677,25 @@ TEST_F(audit_layout1, make_char) EXPECT_EQ(1, records.domain); } +TEST_F(audit_layout1, make_whiteout) +{ + struct audit_records records; + + EXPECT_EQ(0, unlink(file1_s1d3)); + + enforce_fs(_metadata, ACCESS_ALL, NULL); + + /* Whiteout creation is denied and logged as fs.make_reg. */ + EXPECT_EQ(-1, mknod(file1_s1d3, S_IFCHR | 0644, makedev(0, 0))); + EXPECT_EQ(EACCES, errno); + EXPECT_EQ(0, matches_log_fs(_metadata, self->audit_fd, "fs\\.make_reg", + dir_s1d3)); + + EXPECT_EQ(0, audit_count_records(self->audit_fd, &records)); + EXPECT_EQ(0, records.access); + EXPECT_EQ(1, records.domain); +} + TEST_F(audit_layout1, make_dir) { struct audit_records records; @@ -10189,4 +10439,484 @@ TEST_F(audit_quiet_rename, quiet_behind_mountpoint_disconnected) ASSERT_EQ(0, records.access); } +/* clang-format off */ +FIXTURE(trace_layout1) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_layout1) +{ + struct stat st; + + /* + * Check tracefs availability before creating the layout, following the + * layout3_fs pattern: skip before any layout creation to avoid leaving + * stale TMP_DIR on skip. + */ + if (stat(TRACEFS_LANDLOCK_DIR, &st)) { + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + /* Isolate tracefs state (PID filter, event enables). */ + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + clear_cap(_metadata, CAP_SYS_ADMIN); + + prepare_layout(_metadata); + create_layout1(_metadata); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_fixture_setup()); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + ASSERT_EQ(0, tracefs_set_pid_filter(getpid())); + clear_cap(_metadata, CAP_DAC_OVERRIDE); +} + +FIXTURE_TEARDOWN_PARENT(trace_layout1) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_DAC_OVERRIDE); + tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false); + tracefs_clear_pid_filter(); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + remove_layout1(_metadata); + cleanup_layout(_metadata); +} + +/* + * Verifies that check_rule_fs events include correct field values: domain, dev, + * ino, access_request, and grants. All values are verified against stat() of + * the rule path on a deterministic tmpfs layout. + */ +TEST_F(trace_layout1, check_rule_fs_fields) +{ + struct stat dir_stat; + char expected_dev[32]; + char expected_ino[32]; + char *buf; + char field[64]; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + ASSERT_EQ(0, stat(dir_s1d1, &dir_stat)); + snprintf(expected_dev, sizeof(expected_dev), "%u:%u", + major(dir_stat.st_dev), minor(dir_stat.st_dev)); + snprintf(expected_ino, sizeof(expected_ino), "%lu", dir_stat.st_ino); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + sandbox_child_fs_access(_metadata, dir_s1d1, + LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, dir_s1d1); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK))) + { + TH_LOG("Expected 1 check_rule_fs event\n%s", buf); + } + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "dev", field, sizeof(field))); + EXPECT_STREQ(expected_dev, field) + { + TH_LOG("Expected dev=%s, got %s", expected_dev, field); + } + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "ino", field, sizeof(field))); + EXPECT_STREQ(expected_ino, field) + { + TH_LOG("Expected ino=%s, got %s", expected_ino, field); + } + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "access_request", field, + sizeof(field))); + EXPECT_STREQ("read_dir", field) + { + TH_LOG("Expected access_request=read_dir, got %s", field); + } + + /* + * The domain handles only READ_DIR, so the rule carries the + * unhandled-rights padding; intersecting with the request leaves just + * the requested read_dir (no padding, no hex). + */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "grants", field, sizeof(field))); + EXPECT_STREQ("{read_dir}", field) + { + TH_LOG("Expected grants={read_dir}, got %s", field); + } + + free(buf); +} + +/* + * Verifies check_rule_fs behavior with multiple rules. With rules at s1d1 and + * s1d2 (a child of s1d1), accessing s1d2 produces only 1 event because the + * pathwalk short-circuits after the first rule fully unmasks the single layer. + */ +TEST_F(trace_layout1, check_rule_fs_multiple_rules) +{ + pid_t pid; + int status; + char *buf; + int count; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) + _exit(1); + close(path_beneath.parent_fd); + + path_beneath.parent_fd = + open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) + _exit(1); + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_NE(NULL, buf); + + /* + * Only 1 check_rule_fs event: the rule on dir_s1d2 fully unmasked the + * single layer, so the pathwalk short-circuits before reaching the + * dir_s1d1 rule. + */ + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_EQ(1, count) + { + TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count, + buf); + } + + free(buf); +} + +/* + * Verifies the grants array is intersected with the request: a handled, + * granted, but unrequested right (execute) is filtered out, leaving only the + * requested read_dir. + */ +TEST_F(trace_layout1, check_rule_fs_request_subset) +{ + char *buf; + char field[64]; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + /* + * Handle and grant READ_DIR|EXECUTE; the open only requests read_dir. + */ + sandbox_child_fs_access( + _metadata, dir_s1d1, + LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE, + LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE, + dir_s1d1); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_NE(NULL, buf); + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "access_request", field, + sizeof(field))); + EXPECT_STREQ("read_dir", field); + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "grants", field, sizeof(field))); + EXPECT_STREQ("{read_dir}", field); + + free(buf); +} + +/* + * Verifies that the optional TRUNCATE access right, which hook_file_open() + * speculatively evaluates on every open, appears in the access_request= and + * grants= fields. Opening file1_s1d1 read-only needs only read_file, but the + * open hook also evaluates truncate; the domain handles and the rule grants + * both, so the event reports access_request=read_file|truncate and + * grants={read_file|truncate}, and the open is allowed. + */ +TEST_F(trace_layout1, check_rule_fs_optional_access) +{ + pid_t pid; + int status; + char *buf; + char field[64]; + int count; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_TRUNCATE, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_TRUNCATE, + }; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) + _exit(1); + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + /* Read-only open needs only read_file; truncate is optional. */ + fd = open(file1_s1d1, O_RDONLY | O_CLOEXEC); + if (fd < 0) + _exit(1); + close(fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + /* The open is allowed: the required read_file is granted. */ + EXPECT_EQ(0, WEXITSTATUS(status)); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_NE(NULL, buf); + + /* The rule at dir_s1d1 matches when opening file1_s1d1. */ + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_EQ(1, count) + { + TH_LOG("Expected 1 check_rule_fs event, got %d\n%s", count, + buf); + } + + /* The open hook adds the optional truncate to the request. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "access_request", field, + sizeof(field))); + EXPECT_STREQ("read_file|truncate", field); + + /* The rule grants both, so truncate appears in the grants array. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "grants", field, sizeof(field))); + EXPECT_STREQ("{read_file|truncate}", field); + + free(buf); +} + +/* + * Verifies that check_rule_fs fires for a rule that matches the inode even when + * it grants none of the requested rights, so the grants set is empty. Landlock + * cannot know a rule ignores the request before reading it, so the event is + * still emitted (grants={}), which lets a tracer see that the rule matched. + * The domain handles READ_DIR|EXECUTE, dir_s1d2 grants only EXECUTE and its + * parent dir_s1d1 grants only READ_DIR. Reading dir_s1d2 (requesting read_dir) + * first matches the dir_s1d2 rule, which grants nothing requested (grants={}); + * walking up to dir_s1d1 then grants read_dir (grants={read_dir}) and allows + * the access. + */ +TEST_F(trace_layout1, check_rule_fs_empty_grant) +{ + pid_t pid; + int status; + char *buf; + int count; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_EXECUTE, + }; + struct landlock_path_beneath_attr path_beneath = {}; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + /* Parent dir_s1d1 grants only READ_DIR. */ + path_beneath.allowed_access = LANDLOCK_ACCESS_FS_READ_DIR; + path_beneath.parent_fd = + open(dir_s1d1, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) + _exit(1); + close(path_beneath.parent_fd); + + /* Child dir_s1d2 grants only EXECUTE. */ + path_beneath.allowed_access = LANDLOCK_ACCESS_FS_EXECUTE; + path_beneath.parent_fd = + open(dir_s1d2, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) + _exit(1); + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + fd = open(dir_s1d2, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) + _exit(1); + close(fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + set_cap(_metadata, CAP_DAC_OVERRIDE); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_DAC_OVERRIDE); + ASSERT_NE(NULL, buf); + + /* + * dir_s1d2 (grants nothing requested) then dir_s1d1 (grants read_dir). + */ + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_EQ(2, count) + { + TH_LOG("Expected 2 check_rule_fs events, got %d\n%s", count, + buf); + } + + /* The dir_s1d2 rule matches the inode but grants none of read_dir. */ + EXPECT_EQ( + 1, + tracefs_count_matches( + buf, + TRACE_PREFIX( + TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ " + "access_request=read_dir " + "dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ " + "grants={}$")) + { + TH_LOG("Expected a grants={} event\n%s", buf); + } + + /* Walking up to dir_s1d1 grants the requested read_dir. */ + EXPECT_EQ( + 1, + tracefs_count_matches( + buf, + TRACE_PREFIX( + TRACE_TASK) "landlock_check_rule_fs: domain=[0-9a-f]\\+ " + "access_request=read_dir " + "dev=[0-9]\\+:[0-9]\\+ ino=[0-9]\\+ " + "grants={read_dir}$")) + { + TH_LOG("Expected a grants={read_dir} event\n%s", buf); + } + + free(buf); +} + TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/net_test.c b/tools/testing/selftests/landlock/net_test.c index be2eb88092fb..a18761e0fd82 100644 --- a/tools/testing/selftests/landlock/net_test.c +++ b/tools/testing/selftests/landlock/net_test.c @@ -10,11 +10,12 @@ #include #include #include -#include #include +#include #include #include #include +#include #include #include #include @@ -22,6 +23,9 @@ #include "audit.h" #include "common.h" +#include "trace.h" + +#define TRACE_TASK "net_test" const short sock_port_start = (1 << 10); @@ -3285,4 +3289,588 @@ TEST_F(audit, sendmsg) EXPECT_EQ(0, close(sock_fd)); } +/* Trace tests */ + +/* clang-format off */ +FIXTURE(trace_net) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_net) +{ + int ret; + + /* Isolate the network namespace so the bound port cannot collide. */ + setup_loopback(_metadata); + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_net) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* + * Baseline: verifies that without Landlock, the bind succeeds and no + * deny_access_net trace event fires. + */ +/* clang-format off */ +FIXTURE_VARIANT(trace_net) +{ + /* clang-format on */ + bool sandbox; + int bind_port_offset; /* 0 = allowed port, 1 = denied port */ + int expect_denied; +}; + +/* Unsandboxed: no Landlock, bind should succeed with no events. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_net, unsandboxed) { + /* clang-format on */ + .sandbox = false, + .bind_port_offset = 0, + .expect_denied = 0, +}; + +/* Denied: sandboxed, bind to port not in ruleset. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_net, bind_denied) { + /* clang-format on */ + .sandbox = true, + .bind_port_offset = 1, + .expect_denied = 1, +}; + +/* Allowed: sandboxed, bind to port in ruleset. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_net, bind_allowed) { + /* clang-format on */ + .sandbox = true, + .bind_port_offset = 0, + .expect_denied = 0, +}; + +TEST_F(trace_net, deny_access_net_bind) +{ + char *buf; + int count, status; + pid_t child; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + ASSERT_EQ(0, tracefs_clear_buf()); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + int sock_fd; + + if (variant->sandbox) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_net = + LANDLOCK_ACCESS_NET_BIND_TCP, + }; + struct landlock_net_port_attr port_attr = { + .allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP, + .port = sock_port_start, + }; + int ruleset_fd; + + ruleset_fd = landlock_create_ruleset( + &ruleset_attr, sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + if (landlock_add_rule(ruleset_fd, + LANDLOCK_RULE_NET_PORT, + &port_attr, 0)) { + close(ruleset_fd); + _exit(1); + } + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + } + + sock_fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (sock_fd < 0) + _exit(1); + + addr.sin_port = + htons(sock_port_start + variant->bind_port_offset); + if (variant->expect_denied) { + /* Bind should be denied. */ + if (bind(sock_fd, (struct sockaddr *)&addr, + sizeof(addr)) == 0) { + close(sock_fd); + _exit(2); + } + if (errno != EACCES) { + close(sock_fd); + _exit(3); + } + } else { + /* Bind should succeed. */ + if (bind(sock_fd, (struct sockaddr *)&addr, + sizeof(addr))) { + close(sock_fd); + _exit(2); + } + } + close(sock_fd); + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK)); + if (variant->expect_denied) { + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_access_net event, got %d\n%s", + count, buf); + } + } else { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_access_net events, " + "got %d\n%s", + count, buf); + } + } + + free(buf); +} + +/* + * Anchors the denial fields shared by every deny_access_net event so a field + * test proves more than sport/dport: the denying domain, the same-exec bit, the + * audit-logging verdict, and the blocked access all stay populated. + */ +static void +expect_net_deny_common_fields(struct __test_metadata *const _metadata, + const char *const buf) +{ + char field[64]; + + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "domain", field, sizeof(field))); + EXPECT_STRNE("0", field); + + /* Same exec that restricted itself, no exec in between. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "same_exec", field, sizeof(field))); + EXPECT_STREQ("1", field); + + /* Default flags, same exec: audit would log this denial. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "logged", field, sizeof(field))); + EXPECT_STREQ("1", field); + + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "blockers", field, sizeof(field))); + EXPECT_STRNE("", field); +} + +/* Connect and field-check tests use a separate fixture without variants. */ + +/* clang-format off */ +FIXTURE(trace_net_connect) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_net_connect) +{ + int ret; + + /* Isolate the network namespace so the bound port cannot collide. */ + setup_loopback(_metadata); + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_net_connect) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_net_connect) { + /* clang-format on */ + /* handled_access_net, also the access allowed on the base port. */ + __u64 handled; + /* Bind the allowed base port before the denied operation. */ + bool bind_base_first; + /* Denied operation on the next port: connect (true) or bind (false). */ + bool deny_connect; +}; + +/* clang-format off */ + +/* Denied connect(): sport=0, dport=. */ +FIXTURE_VARIANT_ADD(trace_net_connect, connect_denied) { + .handled = LANDLOCK_ACCESS_NET_CONNECT_TCP, + .bind_base_first = false, + .deny_connect = true, +}; + +/* Denied bind(): sport=, dport=0. */ +FIXTURE_VARIANT_ADD(trace_net_connect, bind_fields) { + .handled = LANDLOCK_ACCESS_NET_BIND_TCP, + .bind_base_first = false, + .deny_connect = false, +}; + +/* Denied connect() after an allowed bind(): the connect fields (sport=0). */ +FIXTURE_VARIANT_ADD(trace_net_connect, connect_after_bind) { + .handled = LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP, + .bind_base_first = true, + .deny_connect = true, +}; + +/* clang-format on */ + +/* + * A denied TCP bind(2) or connect(2) emits one deny_access_net event. The port + * is reported in the field matching the denied operation, in host endianness + * (the UAPI landlock_net_port_attr.port convention): a connect denial reports + * sport=0 dport=, a bind denial reports sport= dport=0, so a + * byte-order or field-swap bug is caught. A prior allowed bind + * (connect_after_bind) does not change the connect denial's fields. + */ +TEST_F(trace_net_connect, deny_access_net) +{ + pid_t child; + int status; + char *buf; + char field[64], expected[16]; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_net = variant->handled, + }; + struct landlock_net_port_attr port_attr = { + .allowed_access = variant->handled, + .port = sock_port_start, + }; + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + int ruleset_fd, sock_fd, optval = 1, ret; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_NET_PORT, + &port_attr, 0)) { + close(ruleset_fd); + _exit(1); + } + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + sock_fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (sock_fd < 0) + _exit(1); + + /* Bind the allowed base port first (succeeds, no event). */ + if (variant->bind_base_first) { + setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &optval, + sizeof(optval)); + addr.sin_port = htons(sock_port_start); + if (bind(sock_fd, (struct sockaddr *)&addr, + sizeof(addr))) { + close(sock_fd); + _exit(1); + } + } + + /* Denied operation on the next port. */ + addr.sin_port = htons(sock_port_start + 1); + if (variant->deny_connect) + ret = connect(sock_fd, (struct sockaddr *)&addr, + sizeof(addr)); + else + ret = bind(sock_fd, (struct sockaddr *)&addr, + sizeof(addr)); + if (ret == 0) { + close(sock_fd); + _exit(2); + } + if (errno != EACCES) { + close(sock_fd); + _exit(3); + } + close(sock_fd); + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, tracefs_count_matches(buf, + REGEX_DENY_ACCESS_NET(TRACE_TASK))); + + expect_net_deny_common_fields(_metadata, buf); + + /* + * The denied operation's port field carries the port; the other is 0. + */ + snprintf(expected, sizeof(expected), "%llu", + (unsigned long long)(sock_port_start + 1)); + + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "sport", field, sizeof(field))); + EXPECT_STREQ(variant->deny_connect ? "0" : expected, field); + + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_NET(TRACE_TASK), + "dport", field, sizeof(field))); + EXPECT_STREQ(variant->deny_connect ? expected : "0", field); + + free(buf); +} + +/* Field verification for the check_rule_net event on an allowed access. */ + +/* clang-format off */ +FIXTURE(trace_net_check_rule) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_net_check_rule) +{ + int ret; + + /* Isolate the network namespace so the bound port cannot collide. */ + setup_loopback(_metadata); + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_net_check_rule) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_CHECK_RULE_NET_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* + * Verifies that an allowed bind matching a net-port rule emits exactly one + * landlock_check_rule_net event with the enforcing domain, the requested + * access, the checked port (host endianness), and the per-layer grants. The + * whole event is anchored to exact values so a revert of the check_rule_net + * emit (or a byte-order or field-plumbing regression) fails the test. + */ +TEST_F(trace_net_check_rule, check_rule_net_fields) +{ + pid_t child; + int status; + char *buf; + char field[64], expected[16]; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP, + }; + struct landlock_net_port_attr port_attr = { + .allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP, + .port = sock_port_start, + }; + struct sockaddr_in addr = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + }; + int ruleset_fd, sock_fd; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_NET_PORT, + &port_attr, 0)) { + close(ruleset_fd); + _exit(1); + } + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + /* Bind to the allowed port: succeeds and matches the rule. */ + sock_fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (sock_fd < 0) + _exit(1); + + addr.sin_port = htons(sock_port_start); + if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr))) { + close(sock_fd); + _exit(2); + } + close(sock_fd); + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* A single-layer domain matching one port rule emits one event. */ + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CHECK_RULE_NET(TRACE_TASK))) + { + TH_LOG("Expected 1 check_rule_net event\n%s", buf); + } + + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_CHECK_RULE_NET(TRACE_TASK), + "domain", field, sizeof(field))); + EXPECT_STRNE("0", field); + + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_CHECK_RULE_NET(TRACE_TASK), + "access_request", field, sizeof(field))); + EXPECT_STREQ("bind_tcp", field); + + /* + * The port is reported in host endianness (UAPI convention), so on + * little-endian htons(sock_port_start) would print a different value: + * the exact match also catches byte-order regressions. + */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_CHECK_RULE_NET(TRACE_TASK), + "port", field, sizeof(field))); + snprintf(expected, sizeof(expected), "%llu", + (unsigned long long)sock_port_start); + EXPECT_STREQ(expected, field); + + /* One layer that fully grants the request: grants={bind_tcp}. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_CHECK_RULE_NET(TRACE_TASK), + "grants", field, sizeof(field))); + EXPECT_STREQ("{bind_tcp}", field); + + free(buf); +} + +/* + * IPv6 network trace tests are intentionally elided. IPv6 hook dispatch uses + * the same current_check_access_socket() code path as IPv4, validated by the + * audit tests in this file. The trace events use the same blockers/sport/dport + * fields regardless of address family. + */ + TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/ptrace_test.c b/tools/testing/selftests/landlock/ptrace_test.c index 4f64c90583cd..2644445a9d02 100644 --- a/tools/testing/selftests/landlock/ptrace_test.c +++ b/tools/testing/selftests/landlock/ptrace_test.c @@ -11,7 +11,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -20,6 +22,7 @@ #include "audit.h" #include "common.h" +#include "trace.h" /* Copied from security/yama/yama_lsm.c */ #define YAMA_SCOPE_DISABLED 0 @@ -430,4 +433,403 @@ TEST_F(audit, trace) EXPECT_EQ(0, records.domain); } +/* Trace tests */ + +/* clang-format off */ +FIXTURE(trace_ptrace) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_ptrace) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_PTRACE_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_ptrace) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_PTRACE_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_ptrace) +{ + /* clang-format on */ + bool sandbox; + bool sandbox_target; + int expect_denied; +}; + +/* Denied: sandboxed child ptraces unsandboxed parent (tracee_domain=0). */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace, denied) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = false, + .expect_denied = 1, +}; + +/* + * Denied: sandboxed child ptraces a sandboxed parent, so the tracee is in a + * domain and tracee_domain= is non-zero. + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace, denied_scoped_target) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = true, + .expect_denied = 1, +}; + +/* Allowed: unsandboxed child uses PTRACE_TRACEME. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace, allowed) { + /* clang-format on */ + .sandbox = false, + .sandbox_target = false, + .expect_denied = 0, +}; + +TEST_F(trace_ptrace, deny_ptrace) +{ + char *buf, field[64], expected_pid[16]; + int count, status; + pid_t child, parent; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + parent = getpid(); + + /* + * Set a known comm so the denied variant can verify both the trace line + * task name and the tracee_comm= field. + */ + prctl(PR_SET_NAME, "ll_trace_test"); + + /* + * For the non-zero tracee_domain case, sandbox the parent (the tracee) + * before forking. The child inherits that domain and adds its own + * layer, so the child (tracer) is not an ancestor of the tracee and the + * ptrace is still denied, with tracee_domain= naming the parent's + * domain. + */ + if (variant->sandbox_target) + create_domain(_metadata); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + if (variant->sandbox) { + struct landlock_ruleset_attr ruleset_attr = { + .scoped = LANDLOCK_SCOPE_SIGNAL, + }; + int ruleset_fd; + + /* + * Any scope creates a domain. Ptrace denial checks + * domain ancestry, not specific flags. + */ + ruleset_fd = landlock_create_ruleset( + &ruleset_attr, sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + /* PTRACE_ATTACH on unsandboxed parent: denied. */ + if (ptrace(PTRACE_ATTACH, parent, NULL, NULL) == 0) { + ptrace(PTRACE_DETACH, parent, NULL, NULL); + _exit(2); + } + if (errno != EPERM) + _exit(3); + } else { + /* No sandbox: ptrace should succeed. */ + if (ptrace(PTRACE_TRACEME) != 0) + _exit(1); + } + + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_PTRACE("ll_trace_test")); + if (variant->expect_denied) { + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_ptrace event, got %d\n%s", count, + buf); + } + + /* Verify tracee_pid is the parent's TGID. */ + snprintf(expected_pid, sizeof(expected_pid), "%d", parent); + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_PTRACE("ll_trace_test"), + "tracee_pid", field, sizeof(field))); + EXPECT_STREQ(expected_pid, field); + + /* Verify tracee_comm matches prctl(PR_SET_NAME). */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_PTRACE("ll_trace_test"), + "tracee_comm", field, sizeof(field))); + EXPECT_STREQ("ll_trace_test", field); + + /* + * Verify tracee_domain: 0 when the tracee is unsandboxed, + * non-zero when the tracee is in a domain. + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_PTRACE("ll_trace_test"), + "tracee_domain", field, sizeof(field))); + EXPECT_EQ(variant->sandbox_target, strcmp("0", field) != 0) + { + TH_LOG("Unexpected tracee_domain=%s", field); + } + } else { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_ptrace events, got %d\n%s", + count, buf); + } + } + + free(buf); +} + +/* clang-format off */ +FIXTURE(trace_ptrace_traceme) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_ptrace_traceme) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_PTRACE_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_ptrace_traceme) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_PTRACE_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_ptrace_traceme) +{ + /* clang-format on */ + bool sandbox_tracer; + bool sandbox_tracee; + int expect_denied; +}; + +/* + * Denied: a sandboxed tracer cannot trace the unsandboxed child that asked to + * be traced with PTRACE_TRACEME (tracee_domain=0). + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace_traceme, denied) { + /* clang-format on */ + .sandbox_tracer = true, + .sandbox_tracee = false, + .expect_denied = 1, +}; + +/* + * Denied: a sandboxed child in its own domain asks to be traced by a tracer in + * an unrelated domain, so the tracee is in a domain and tracee_domain= is + * non-zero. + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace_traceme, denied_scoped_tracee) { + /* clang-format on */ + .sandbox_tracer = true, + .sandbox_tracee = true, + .expect_denied = 1, +}; + +/* Allowed: unsandboxed child uses PTRACE_TRACEME with an unsandboxed tracer. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_ptrace_traceme, allowed) { + /* clang-format on */ + .sandbox_tracer = false, + .sandbox_tracee = false, + .expect_denied = 0, +}; + +TEST_F(trace_ptrace_traceme, deny_ptrace) +{ + char *buf, field[64], expected_pid[16]; + int count, status, sync_pipe[2]; + pid_t child; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + /* + * Set a known comm so the denied variant can verify both the trace line + * task name and the tracee_comm= field. The tracee is the current + * (child) task for PTRACE_TRACEME, so the child inherits this name. + */ + prctl(PR_SET_NAME, "ll_trace_test"); + + ASSERT_EQ(0, pipe2(sync_pipe, O_CLOEXEC)); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + char c; + + close(sync_pipe[1]); + + /* + * The tracee is the current task; for the non-zero + * tracee_domain case it sandboxes itself in its own domain, + * unrelated to the tracer's domain, so PTRACE_TRACEME is still + * denied and tracee_domain= names the child's own domain. + */ + if (variant->sandbox_tracee) + create_domain(_metadata); + + /* Waits for the tracer (parent) to enter its domain, if any. */ + if (read(sync_pipe[0], &c, 1) != 1) + _exit(1); + close(sync_pipe[0]); + + if (variant->expect_denied) { + if (ptrace(PTRACE_TRACEME) == 0) + _exit(2); + if (errno != EPERM) + _exit(3); + } else { + if (ptrace(PTRACE_TRACEME) != 0) + _exit(4); + /* Lets the tracer reap the trace-stop and detach. */ + raise(SIGSTOP); + } + + _exit(0); + } + + close(sync_pipe[0]); + + /* + * For a denial, the proposed tracer must be in a domain that is not an + * ancestor of the tracee's domain. Sandboxing the parent after the + * fork gives it a domain unrelated to the child. + */ + if (variant->sandbox_tracer) + create_domain(_metadata); + + /* Signals the child that the tracer is in its domain, if any. */ + ASSERT_EQ(1, write(sync_pipe[1], ".", 1)); + close(sync_pipe[1]); + + if (!variant->expect_denied) { + /* PTRACE_TRACEME succeeded: reap the SIGSTOP and detach. */ + ASSERT_EQ(child, waitpid(child, &status, WUNTRACED)); + ASSERT_TRUE(WIFSTOPPED(status)); + ASSERT_EQ(0, ptrace(PTRACE_DETACH, child, NULL, 0)); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_PTRACE("ll_trace_test")); + if (variant->expect_denied) { + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_ptrace event, got %d\n%s", count, + buf); + } + + /* Verify tracee_pid is the child's TGID (the traced task). */ + snprintf(expected_pid, sizeof(expected_pid), "%d", child); + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_PTRACE("ll_trace_test"), + "tracee_pid", field, sizeof(field))); + EXPECT_STREQ(expected_pid, field); + + /* + * Verify tracee_domain: 0 when the tracee is unsandboxed, + * non-zero when the tracee is in a domain. + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_PTRACE("ll_trace_test"), + "tracee_domain", field, sizeof(field))); + EXPECT_EQ(variant->sandbox_tracee, strcmp("0", field) != 0) + { + TH_LOG("Unexpected tracee_domain=%s", field); + } + } else { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_ptrace events, got %d\n%s", + count, buf); + } + } + + free(buf); +} + TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/scoped_abstract_unix_test.c b/tools/testing/selftests/landlock/scoped_abstract_unix_test.c index 40fc82fbf01d..6dbe863ea571 100644 --- a/tools/testing/selftests/landlock/scoped_abstract_unix_test.c +++ b/tools/testing/selftests/landlock/scoped_abstract_unix_test.c @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,9 @@ #include "audit.h" #include "common.h" #include "scoped_common.h" +#include "trace.h" + +#define TRACE_TASK "scoped_abstract" /* Number of pending connections queue to be hold. */ const short backlog = 10; @@ -1205,4 +1209,264 @@ TEST(self_connect) _metadata->exit_code = KSFT_FAIL; } +/* Trace tests */ + +/* clang-format off */ +FIXTURE(trace_unix) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_unix) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event( + TRACEFS_DENY_SCOPE_ABSTRACT_UNIX_SOCKET_ENABLE, + true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_unix) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_SCOPE_ABSTRACT_UNIX_SOCKET_ENABLE, + false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_unix) { + /* clang-format on */ + int sock_type; /* SOCK_STREAM (connect) or SOCK_DGRAM (sendto). */ + bool sandbox; + bool sandbox_target; /* Peer owned by a domain: peer_domain != 0. */ + int expect_denied; +}; + +/* clang-format off */ + +/* Stream: sandboxed client connect() to an unsandboxed peer (peer_domain=0). */ +FIXTURE_VARIANT_ADD(trace_unix, stream_denied) { + .sock_type = SOCK_STREAM, .sandbox = true, + .sandbox_target = false, .expect_denied = 1, +}; + +/* Stream: peer socket owned by a domain, so peer_domain != 0. */ +FIXTURE_VARIANT_ADD(trace_unix, stream_denied_scoped_peer) { + .sock_type = SOCK_STREAM, .sandbox = true, + .sandbox_target = true, .expect_denied = 1, +}; + +/* Stream: unsandboxed client, connect() succeeds, no event. */ +FIXTURE_VARIANT_ADD(trace_unix, stream_allowed) { + .sock_type = SOCK_STREAM, .sandbox = false, + .sandbox_target = false, .expect_denied = 0, +}; + +/* Datagram: sandboxed client sendto() an unsandboxed peer (peer_domain=0). */ +FIXTURE_VARIANT_ADD(trace_unix, dgram_denied) { + .sock_type = SOCK_DGRAM, .sandbox = true, + .sandbox_target = false, .expect_denied = 1, +}; + +/* Datagram: peer socket owned by a domain, so peer_domain != 0. */ +FIXTURE_VARIANT_ADD(trace_unix, dgram_denied_scoped_peer) { + .sock_type = SOCK_DGRAM, .sandbox = true, + .sandbox_target = true, .expect_denied = 1, +}; + +/* Datagram: unsandboxed client, sendto() succeeds, no event. */ +FIXTURE_VARIANT_ADD(trace_unix, dgram_allowed) { + .sock_type = SOCK_DGRAM, .sandbox = false, + .sandbox_target = false, .expect_denied = 0, +}; + +/* clang-format on */ + +/* + * A sandboxed thread reaching an abstract unix socket peer through connect(2) + * (stream) or sendto(2) (datagram) is denied and emits + * landlock_deny_scope_abstract_unix_socket. The abstract name is crafted with + * a space and an embedded NUL followed by an "END" marker to check the + * tracepoint escaping and its length handling (a raw space would break the + * sun_path field regex; strlen() would truncate at the NUL and drop "END"). + * peer_pid is only meaningful for a stream peer (a datagram peer has no + * SO_PEERCRED), so it is asserted only there. + */ +TEST_F(trace_unix, deny_scope_unix) +{ + struct sockaddr_un addr = { + .sun_family = AF_UNIX, + }; + char *buf, field[128], expected_pid[16]; + int server_fd, count, status, name_len, addr_len; + pid_t child; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + /* + * For the non-zero peer_domain case, sandbox the parent before it + * creates the server socket, so the socket carries the parent's domain + * and peer_domain= is non-zero. + */ + if (variant->sandbox_target) + create_scoped_domain(_metadata, + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET); + + server_fd = socket(AF_UNIX, variant->sock_type | SOCK_CLOEXEC, 0); + ASSERT_LE(0, server_fd); + + addr.sun_path[0] = '\0'; + name_len = snprintf(addr.sun_path + 1, sizeof(addr.sun_path) - 1, + "landlock_trace_test_%d ", getpid()); + addr.sun_path[1 + name_len] = '\0'; + memcpy(addr.sun_path + 1 + name_len + 1, "END", 3); + addr_len = + offsetof(struct sockaddr_un, sun_path) + 1 + name_len + 1 + 3; + + ASSERT_EQ(0, bind(server_fd, (struct sockaddr *)&addr, addr_len)); + if (variant->sock_type == SOCK_STREAM) + ASSERT_EQ(0, listen(server_fd, 1)); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + int client_fd, ret; + + if (variant->sandbox) { + struct landlock_ruleset_attr ruleset_attr = { + .scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + }; + int ruleset_fd; + + ruleset_fd = landlock_create_ruleset( + &ruleset_attr, sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + } + + client_fd = + socket(AF_UNIX, variant->sock_type | SOCK_CLOEXEC, 0); + if (client_fd < 0) + _exit(1); + + if (variant->sock_type == SOCK_STREAM) + ret = connect(client_fd, (struct sockaddr *)&addr, + addr_len); + else + ret = sendto(client_fd, ".", 1, 0, + (struct sockaddr *)&addr, addr_len); + + if (variant->sandbox) { + /* Reaching the peer should be denied. */ + if (ret != -1 || errno != EPERM) { + close(client_fd); + _exit(2); + } + } else { + /* No sandbox: stream connect() == 0, sendto() == 1. */ + int ok = variant->sock_type == SOCK_STREAM ? 0 : 1; + + if (ret != ok) { + close(client_fd); + _exit(2); + } + } + close(client_fd); + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + close(server_fd); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches( + buf, REGEX_DENY_SCOPE_ABSTRACT_UNIX_SOCKET(TRACE_TASK)); + if (!variant->expect_denied) { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_scope events, got %d\n%s", + count, buf); + } + free(buf); + return; + } + + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_scope_abstract_unix_socket event, " + "got %d\n%s", + count, buf); + } + + /* + * sun_path is escaped: a raw space would break this field's [^ ]*$ + * regex, so a successful extract proves the space was escaped, and its + * full length is honored: the "END" marker after the embedded NUL must + * survive (strlen() would truncate it at the NUL). + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, + REGEX_DENY_SCOPE_ABSTRACT_UNIX_SOCKET(TRACE_TASK), + "sun_path", field, sizeof(field))); + EXPECT_NE(NULL, strstr(field, "END")) + { + TH_LOG("sun_path truncated or unescaped: %s", field); + } + + /* peer_pid is the parent's PID for a stream peer (0 for datagram). */ + if (variant->sock_type == SOCK_STREAM) { + snprintf(expected_pid, sizeof(expected_pid), "%d", getpid()); + ASSERT_EQ(0, tracefs_extract_field( + buf, + REGEX_DENY_SCOPE_ABSTRACT_UNIX_SOCKET( + TRACE_TASK), + "peer_pid", field, sizeof(field))); + EXPECT_STREQ(expected_pid, field); + } + + /* peer_domain: 0 when the peer is unsandboxed, non-zero otherwise. */ + ASSERT_EQ(0, tracefs_extract_field( + buf, + REGEX_DENY_SCOPE_ABSTRACT_UNIX_SOCKET(TRACE_TASK), + "peer_domain", field, sizeof(field))); + EXPECT_EQ(variant->sandbox_target, strcmp("0", field) != 0) + { + TH_LOG("Unexpected peer_domain=%s", field); + } + + free(buf); +} + TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/scoped_signal_test.c b/tools/testing/selftests/landlock/scoped_signal_test.c index 2d37d0c06c06..259cdcc8aa5c 100644 --- a/tools/testing/selftests/landlock/scoped_signal_test.c +++ b/tools/testing/selftests/landlock/scoped_signal_test.c @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -18,6 +20,9 @@ #include "common.h" #include "scoped_common.h" +#include "trace.h" + +#define TRACE_TASK "scoped_signal_t" /* This variable is used for handling several signals. */ static volatile sig_atomic_t is_signaled; @@ -762,4 +767,403 @@ TEST(sigio_to_pgid_self) EXPECT_EQ(0, close(trigger[1])); } +/* Trace tests */ + +/* clang-format off */ +FIXTURE(trace_signal) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_signal) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_SCOPE_SIGNAL_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_signal) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_SCOPE_SIGNAL_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_signal) +{ + /* clang-format on */ + bool sandbox; + bool sandbox_target; + int expect_denied; +}; + +/* Denied: sandboxed child signals unsandboxed parent (target_domain=0). */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_signal, denied) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = false, + .expect_denied = 1, +}; + +/* + * Denied: sandboxed child signals a sandboxed parent, so the target is in a + * domain and target_domain= is non-zero. + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_signal, denied_scoped_target) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = true, + .expect_denied = 1, +}; + +/* Allowed: unsandboxed child signals unsandboxed parent. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_signal, allowed) { + /* clang-format on */ + .sandbox = false, + .sandbox_target = false, + .expect_denied = 0, +}; + +TEST_F(trace_signal, deny_scope_signal) +{ + char *buf, field[64], expected_pid[16]; + int count, status; + pid_t child; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + /* + * For the non-zero target_domain case, sandbox the parent (the signal + * target) before forking. The child inherits that domain and adds its + * own scoped layer, so the signal is still denied and target_domain= + * names the parent's domain. + */ + if (variant->sandbox_target) + create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL); + + child = fork(); + ASSERT_LE(0, child); + + if (child == 0) { + if (variant->sandbox) { + struct landlock_ruleset_attr ruleset_attr = { + .scoped = LANDLOCK_SCOPE_SIGNAL, + }; + int ruleset_fd; + + ruleset_fd = landlock_create_ruleset( + &ruleset_attr, sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + } + + if (variant->sandbox) { + /* Signal to unsandboxed parent should be denied. */ + if (kill(getppid(), 0) == 0) + _exit(2); + if (errno != EPERM) + _exit(3); + } else { + /* No sandbox: kill should succeed. */ + if (kill(getppid(), 0) != 0) + _exit(1); + } + + _exit(0); + } + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_SCOPE_SIGNAL(TRACE_TASK)); + if (variant->expect_denied) { + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_scope_signal event, got %d\n%s", + count, buf); + } + + /* Verify target_pid is the parent's PID. */ + snprintf(expected_pid, sizeof(expected_pid), "%d", getpid()); + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_SCOPE_SIGNAL(TRACE_TASK), + "target_pid", field, sizeof(field))); + EXPECT_STREQ(expected_pid, field); + + /* + * Verify target_domain: 0 when the target is unsandboxed, + * non-zero when the target is in a domain. + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_SCOPE_SIGNAL(TRACE_TASK), + "target_domain", field, sizeof(field))); + EXPECT_EQ(variant->sandbox_target, strcmp("0", field) != 0) + { + TH_LOG("Unexpected target_domain=%s", field); + } + } else { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_scope_signal events, " + "got %d\n%s", + count, buf); + } + } + + free(buf); +} + +/* + * Trace test for the asynchronous SIGIO/SIGURG delivery path + * (hook_file_send_sigiotask), which reaches the same landlock_deny_scope_signal + * tracepoint as a synchronous kill(2) but through fcntl(F_SETOWN). + */ + +/* clang-format off */ +FIXTURE(trace_fown) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_fown) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_SCOPE_SIGNAL_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_fown) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_DENY_SCOPE_SIGNAL_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_fown) +{ + /* clang-format on */ + bool sandbox; + bool sandbox_target; + int expect_denied; +}; + +/* + * Denied: a sandboxed file owner's SIGURG is delivered to an unsandboxed target + * process (target_domain=0). + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_fown, denied) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = false, + .expect_denied = 1, +}; + +/* + * Denied: the SIGURG target sandboxes itself in its own domain, so the target + * is in a domain and target_domain= is non-zero. + */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_fown, denied_scoped_target) { + /* clang-format on */ + .sandbox = true, + .sandbox_target = true, + .expect_denied = 1, +}; + +/* Allowed: an unsandboxed file owner delivers SIGURG. */ +/* clang-format off */ +FIXTURE_VARIANT_ADD(trace_fown, allowed) { + /* clang-format on */ + .sandbox = false, + .sandbox_target = false, + .expect_denied = 0, +}; + +TEST_F(trace_fown, deny_scope_fown) +{ + int server_socket, recv_socket; + struct service_fixture server_address; + char buffer_parent, field[64], *buf; + int status, count; + int pipe_parent[2], pipe_child[2]; + pid_t child; + + if (!self->tracefs_ok) + SKIP(return, "tracefs not available"); + + memset(&server_address, 0, sizeof(server_address)); + set_unix_address(&server_address, 0); + + ASSERT_EQ(0, pipe2(pipe_parent, O_CLOEXEC)); + ASSERT_EQ(0, pipe2(pipe_child, O_CLOEXEC)); + + child = fork(); + ASSERT_LE(0, child); + if (child == 0) { + int client_socket; + char buffer_child; + + EXPECT_EQ(0, close(pipe_parent[1])); + EXPECT_EQ(0, close(pipe_child[0])); + + ASSERT_EQ(0, setup_signal_handler(SIGURG)); + client_socket = socket(AF_UNIX, SOCK_STREAM, 0); + ASSERT_LE(0, client_socket); + + /* + * The SIGURG target is this child; for the non-zero + * target_domain case it sandboxes itself in its own domain, + * unrelated to the file owner's domain. + */ + if (variant->sandbox_target) + create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL); + + /* Waits for the parent to listen. */ + ASSERT_EQ(1, read(pipe_parent[0], &buffer_child, 1)); + ASSERT_EQ(0, connect(client_socket, &server_address.unix_addr, + server_address.unix_addr_len)); + + /* + * Waits for the parent to accept the connection, sandbox + * itself, and call fcntl(F_SETOWN). + */ + ASSERT_EQ(1, read(pipe_parent[0], &buffer_child, 1)); + /* Triggers the asynchronous SIGURG to this file owner. */ + ASSERT_EQ(1, send(client_socket, ".", 1, MSG_OOB)); + EXPECT_EQ(0, close(client_socket)); + ASSERT_EQ(1, write(pipe_child[1], ".", 1)); + EXPECT_EQ(0, close(pipe_child[1])); + + _exit(0); + return; + } + EXPECT_EQ(0, close(pipe_parent[0])); + EXPECT_EQ(0, close(pipe_child[1])); + + server_socket = socket(AF_UNIX, SOCK_STREAM, 0); + ASSERT_LE(0, server_socket); + ASSERT_EQ(0, bind(server_socket, &server_address.unix_addr, + server_address.unix_addr_len)); + ASSERT_EQ(0, listen(server_socket, backlog)); + ASSERT_EQ(1, write(pipe_parent[1], ".", 1)); + + recv_socket = accept(server_socket, NULL, NULL); + ASSERT_LE(0, recv_socket); + + /* + * The file owner is the denying subject; its domain is captured at + * fcntl(F_SETOWN) time, so sandbox it before setting the owner. + */ + if (variant->sandbox) + create_scoped_domain(_metadata, LANDLOCK_SCOPE_SIGNAL); + + /* + * Sets the child to receive SIGURG for MSG_OOB. This uncommon use is a + * valid attack scenario which also simplifies this test. + */ + ASSERT_EQ(0, fcntl(recv_socket, F_SETOWN, child)); + + ASSERT_EQ(1, write(pipe_parent[1], ".", 1)); + + /* Waits for the child to send MSG_OOB. */ + ASSERT_EQ(1, read(pipe_child[0], &buffer_parent, 1)); + EXPECT_EQ(0, close(pipe_child[0])); + ASSERT_EQ(1, recv(recv_socket, &buffer_parent, 1, MSG_OOB)); + EXPECT_EQ(0, close(recv_socket)); + EXPECT_EQ(0, close(server_socket)); + + ASSERT_EQ(child, waitpid(child, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_SCOPE_SIGNAL(TRACE_TASK)); + if (variant->expect_denied) { + EXPECT_EQ(variant->expect_denied, count) + { + TH_LOG("Expected deny_scope_signal event, got %d\n%s", + count, buf); + } + + /* + * Verify target_domain: 0 when the target is unsandboxed, + * non-zero when the target is in a domain. + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_SCOPE_SIGNAL(TRACE_TASK), + "target_domain", field, sizeof(field))); + EXPECT_EQ(variant->sandbox_target, strcmp("0", field) != 0) + { + TH_LOG("Unexpected target_domain=%s", field); + } + } else { + EXPECT_EQ(0, count) + { + TH_LOG("Expected 0 deny_scope_signal events, " + "got %d\n%s", + count, buf); + } + } + + free(buf); +} + TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/trace.h b/tools/testing/selftests/landlock/trace.h new file mode 100644 index 000000000000..ba0c5e92001f --- /dev/null +++ b/tools/testing/selftests/landlock/trace.h @@ -0,0 +1,639 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * Landlock trace test helpers + * + * Copyright © 2026 Cloudflare, Inc. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "kselftest_harness.h" + +#define TRACEFS_ROOT "/sys/kernel/tracing" +#define TRACEFS_LANDLOCK_DIR TRACEFS_ROOT "/events/landlock" +#define TRACEFS_CREATE_RULESET_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_create_ruleset/enable" +#define TRACEFS_CREATE_DOMAIN_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_create_domain/enable" +#define TRACEFS_ENFORCE_DOMAIN_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_enforce_domain/enable" +#define TRACEFS_ADD_RULE_FS_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_add_rule_fs/enable" +#define TRACEFS_ADD_RULE_NET_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_add_rule_net/enable" +#define TRACEFS_CHECK_RULE_FS_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_check_rule_fs/enable" +#define TRACEFS_CHECK_RULE_NET_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_check_rule_net/enable" +#define TRACEFS_DENY_ACCESS_FS_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_deny_access_fs/enable" +#define TRACEFS_DENY_ACCESS_NET_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_deny_access_net/enable" +#define TRACEFS_DENY_PTRACE_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_deny_ptrace/enable" +#define TRACEFS_DENY_SCOPE_SIGNAL_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_deny_scope_signal/enable" +#define TRACEFS_DENY_SCOPE_ABSTRACT_UNIX_SOCKET_ENABLE \ + TRACEFS_LANDLOCK_DIR \ + "/landlock_deny_scope_abstract_unix_socket/enable" +#define TRACEFS_FREE_DOMAIN_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_free_domain/enable" +#define TRACEFS_FREE_RULESET_ENABLE \ + TRACEFS_LANDLOCK_DIR "/landlock_free_ruleset/enable" +#define TRACEFS_TRACE TRACEFS_ROOT "/trace" +#define TRACEFS_SET_EVENT_PID TRACEFS_ROOT "/set_event_pid" +#define TRACEFS_OPTIONS_EVENT_FORK TRACEFS_ROOT "/options/event-fork" + +#define TRACE_BUFFER_SIZE (64 * 1024) + +/* + * Trace line prefix: matches the ftrace "trace" file format. Format: " + * - [] : " + * + * The task parameter must be a string literal truncated to 15 chars + * (TASK_COMM_LEN - 1), matching what the kernel stores in task->comm. The + * pattern accepts either the expected task name or "<...>" because the ftrace + * comm cache may evict short-lived processes (e.g., forked children that exit + * before the trace buffer is read). + * + * No unescaped '.' in any REGEX macro; literal dots use '\\.'. + */ +#define TRACE_PREFIX(task) \ + "^ *\\(<\\.\\.\\.>" \ + "\\|" task "\\)" \ + "-[0-9]\\+ *\\[[0-9]\\+\\] [^ ]\\+ \\+[0-9]\\+\\.[0-9]\\+: " + +/* + * Task name for events emitted by kworker threads (e.g., free_domain fires from + * a work queue, not from the test process). + */ +#define KWORKER_TASK "kworker/[0-9]\\+:[0-9]\\+" + +#define REGEX_ADD_RULE_FS(task) \ + TRACE_PREFIX(task) \ + "landlock_add_rule_fs: " \ + "ruleset=[0-9a-f]\\+\\.[0-9]\\+ " \ + "access_rights=[a-z_|]* " \ + "dev=[0-9]\\+:[0-9]\\+ " \ + "ino=[0-9]\\+ " \ + "path=[^ ]\\+$" + +#define REGEX_ADD_RULE_NET(task) \ + TRACE_PREFIX(task) \ + "landlock_add_rule_net: " \ + "ruleset=[0-9a-f]\\+\\.[0-9]\\+ " \ + "access_rights=[a-z_|]* " \ + "port=[0-9]\\+$" + +#define REGEX_CREATE_RULESET(task) \ + TRACE_PREFIX(task) \ + "landlock_create_ruleset: " \ + "ruleset=[0-9a-f]\\+\\.[0-9]\\+ " \ + "handled_fs=[a-z_|]* " \ + "handled_net=[a-z_|]* " \ + "scoped=[a-z_|]*$" + +#define REGEX_CREATE_DOMAIN(task) \ + TRACE_PREFIX(task) \ + "landlock_create_domain: " \ + "domain=[0-9a-f]\\+ " \ + "parent=[0-9a-f]\\+ " \ + "ruleset=[0-9a-f]\\+\\.[0-9]\\+$" + +#define REGEX_CHECK_RULE_FS(task) \ + TRACE_PREFIX(task) \ + "landlock_check_rule_fs: " \ + "domain=[0-9a-f]\\+ " \ + "access_request=[a-z_|]* " \ + "dev=[0-9]\\+:[0-9]\\+ " \ + "ino=[0-9]\\+ " \ + "grants={[a-z_|,]*}$" + +#define REGEX_CHECK_RULE_NET(task) \ + TRACE_PREFIX(task) \ + "landlock_check_rule_net: " \ + "domain=[0-9a-f]\\+ " \ + "access_request=[a-z_|]* " \ + "port=[0-9]\\+ " \ + "grants={[a-z_|,]*}$" + +#define REGEX_DENY_ACCESS_FS(task) \ + TRACE_PREFIX(task) \ + "landlock_deny_access_fs: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=[01] " \ + "blockers=[a-z_|]* " \ + "dev=[0-9]\\+:[0-9]\\+ " \ + "ino=[0-9]\\+ " \ + "path=[^ ]*$" + +#define REGEX_DENY_ACCESS_NET(task) \ + TRACE_PREFIX(task) \ + "landlock_deny_access_net: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=[01] " \ + "blockers=[a-z_|]* " \ + "sport=[0-9]\\+ " \ + "dport=[0-9]\\+$" + +#define REGEX_DENY_PTRACE(task) \ + TRACE_PREFIX(task) \ + "landlock_deny_ptrace: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=[01] " \ + "tracee_domain=[0-9a-f]\\+ " \ + "tracee_pid=[0-9]\\+ " \ + "tracee_comm=[^ ]*$" + +#define REGEX_DENY_SCOPE_SIGNAL(task) \ + TRACE_PREFIX(task) \ + "landlock_deny_scope_signal: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=[01] " \ + "target_domain=[0-9a-f]\\+ " \ + "target_pid=[0-9]\\+ " \ + "target_comm=[^ ]*$" + +#define REGEX_DENY_SCOPE_ABSTRACT_UNIX_SOCKET(task) \ + TRACE_PREFIX(task) \ + "landlock_deny_scope_abstract_unix_socket: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=[01] " \ + "peer_domain=[0-9a-f]\\+ " \ + "peer_pid=[0-9]\\+ " \ + "sun_path=[^ ]*$" + +#define REGEX_FREE_DOMAIN(task) \ + TRACE_PREFIX(task) \ + "landlock_free_domain: " \ + "domain=[0-9a-f]\\+ " \ + "denials=[0-9]\\+$" + +#define REGEX_FREE_RULESET(task) \ + TRACE_PREFIX(task) \ + "landlock_free_ruleset: " \ + "ruleset=[0-9a-f]\\+\\.[0-9]\\+$" + +static int __maybe_unused tracefs_write(const char *path, const char *value) +{ + int fd; + ssize_t ret; + size_t len = strlen(value); + + fd = open(path, O_WRONLY | O_TRUNC | O_CLOEXEC); + if (fd < 0) + return -errno; + + ret = write(fd, value, len); + close(fd); + if (ret < 0) + return -errno; + if ((size_t)ret != len) + return -EIO; + + return 0; +} + +static int __maybe_unused tracefs_write_int(const char *path, int value) +{ + char buf[32]; + + snprintf(buf, sizeof(buf), "%d", value); + return tracefs_write(path, buf); +} + +static int __maybe_unused tracefs_setup(void) +{ + struct stat st; + + /* Mount tracefs if not already mounted. */ + if (stat(TRACEFS_ROOT, &st) != 0) { + int ret = mount("tracefs", TRACEFS_ROOT, "tracefs", 0, NULL); + + if (ret) + return -errno; + } + + /* Verify landlock events are available. */ + if (stat(TRACEFS_LANDLOCK_DIR, &st) != 0) + return -ENOENT; + + return 0; +} + +/* + * Set up PID-based event filtering so only events from the current process and + * its children are recorded. This is analogous to audit's AUDIT_EXE filter: it + * prevents events from unrelated processes from polluting the trace buffer. + */ +static int __maybe_unused tracefs_set_pid_filter(pid_t pid) +{ + int ret; + + /* Enable event-fork so children inherit the PID filter. */ + ret = tracefs_write(TRACEFS_OPTIONS_EVENT_FORK, "1"); + if (ret) + return ret; + + return tracefs_write_int(TRACEFS_SET_EVENT_PID, pid); +} + +/* Clear the PID filter to stop filtering by PID. */ +static int __maybe_unused tracefs_clear_pid_filter(void) +{ + return tracefs_write(TRACEFS_SET_EVENT_PID, ""); +} + +static int __maybe_unused tracefs_enable_event(const char *enable_path, + bool enable) +{ + return tracefs_write(enable_path, enable ? "1" : "0"); +} + +static int __maybe_unused tracefs_clear(void) +{ + return tracefs_write(TRACEFS_TRACE, ""); +} + +/* + * Reads the trace buffer content into a newly allocated buffer. The caller is + * responsible for freeing the returned buffer. Returns NULL on error. + */ +static char __maybe_unused *tracefs_read_trace(void) +{ + char *buf; + int fd; + ssize_t total = 0, ret; + + buf = malloc(TRACE_BUFFER_SIZE); + if (!buf) + return NULL; + + fd = open(TRACEFS_TRACE, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + free(buf); + return NULL; + } + + while (total < TRACE_BUFFER_SIZE - 1) { + ret = read(fd, buf + total, TRACE_BUFFER_SIZE - 1 - total); + if (ret <= 0) + break; + total += ret; + } + close(fd); + buf[total] = '\0'; + return buf; +} + +/* Counts the number of lines in @buf matching the basic regex @pattern. */ +static int __maybe_unused tracefs_count_matches(const char *buf, + const char *pattern) +{ + regex_t regex; + int count = 0; + const char *line, *end; + + if (regcomp(®ex, pattern, 0) != 0) + return -EINVAL; + + line = buf; + while (*line) { + end = strchr(line, '\n'); + if (!end) + end = line + strlen(line); + + /* Create a temporary NUL-terminated line. */ + size_t len = end - line; + char *tmp = malloc(len + 1); + + if (tmp) { + memcpy(tmp, line, len); + tmp[len] = '\0'; + if (regexec(®ex, tmp, 0, NULL, 0) == 0) + count++; + free(tmp); + } + + if (*end == '\n') + line = end + 1; + else + break; + } + + regfree(®ex); + return count; +} + +/* + * Extracts the value of a named field from a trace line in @buf. Searches for + * the first line matching @line_pattern, then extracts the value after + * "@field_name=" into @out. Stops at space or newline. + * + * Returns 0 on success, -ENOENT if no match. + */ +static int __maybe_unused tracefs_extract_field(const char *buf, + const char *line_pattern, + const char *field_name, + char *out, size_t out_size) +{ + regex_t regex; + const char *line, *end; + + if (regcomp(®ex, line_pattern, 0) != 0) + return -EINVAL; + + line = buf; + while (*line) { + end = strchr(line, '\n'); + if (!end) + end = line + strlen(line); + + size_t len = end - line; + char *tmp = malloc(len + 1); + + if (tmp) { + const char *field, *val_start; + size_t field_len, val_len; + + memcpy(tmp, line, len); + tmp[len] = '\0'; + + if (regexec(®ex, tmp, 0, NULL, 0) != 0) { + free(tmp); + goto next; + } + + /* + * Find "field_name=" in the line, ensuring a word + * boundary before the field name to avoid substring + * matches (e.g., "port" in "sport"). + */ + field_len = strlen(field_name); + field = tmp; + while ((field = strstr(field, field_name))) { + if (field[field_len] == '=' && + (field == tmp || field[-1] == ' ')) + break; + field++; + } + if (!field) { + free(tmp); + regfree(®ex); + return -ENOENT; + } + + val_start = field + field_len + 1; + val_len = 0; + while (val_start[val_len] && + val_start[val_len] != ' ' && + val_start[val_len] != '\n') + val_len++; + + if (val_len >= out_size) + val_len = out_size - 1; + memcpy(out, val_start, val_len); + out[val_len] = '\0'; + + free(tmp); + regfree(®ex); + return 0; + } +next: + if (*end == '\n') + line = end + 1; + else + break; + } + + regfree(®ex); + return -ENOENT; +} + +/* + * Common fixture setup for trace tests. Mounts tracefs if needed and sets a + * PID filter. The caller must create a mount namespace first + * (unshare(CLONE_NEWNS) + mount(MS_REC | MS_PRIVATE)) to isolate the tracefs + * mount; the trace buffer, per-event enable flags, and PID filter are global + * kernel state, scoped to the test by the PID filter. + * + * Returns 0 on success, -errno on failure (caller should SKIP). + */ +static int __maybe_unused tracefs_fixture_setup(void) +{ + int ret; + + ret = tracefs_setup(); + if (ret) + return ret; + + return tracefs_set_pid_filter(getpid()); +} + +static void __maybe_unused tracefs_fixture_teardown(void) +{ + tracefs_clear_pid_filter(); +} + +/* + * Temporarily raises CAP_SYS_ADMIN effective capability, calls @func, then + * drops the capability. Returns the value from @func, or -EPERM if the + * capability manipulation fails. + */ +static int __maybe_unused tracefs_priv_call(int (*func)(void)) +{ + const cap_value_t admin = CAP_SYS_ADMIN; + cap_t cap_p; + int ret; + + cap_p = cap_get_proc(); + if (!cap_p) + return -EPERM; + + if (cap_set_flag(cap_p, CAP_EFFECTIVE, 1, &admin, CAP_SET) || + cap_set_proc(cap_p)) { + cap_free(cap_p); + return -EPERM; + } + + ret = func(); + + cap_set_flag(cap_p, CAP_EFFECTIVE, 1, &admin, CAP_CLEAR); + cap_set_proc(cap_p); + cap_free(cap_p); + return ret; +} + +/* Read the trace buffer with elevated privileges. Returns NULL on failure. */ +static char __maybe_unused *tracefs_read_buf(void) +{ + /* Cannot use tracefs_priv_call() because the return type is char *. */ + cap_t cap_p; + char *buf; + const cap_value_t admin = CAP_SYS_ADMIN; + + cap_p = cap_get_proc(); + if (!cap_p) + return NULL; + + if (cap_set_flag(cap_p, CAP_EFFECTIVE, 1, &admin, CAP_SET) || + cap_set_proc(cap_p)) { + cap_free(cap_p); + return NULL; + } + + buf = tracefs_read_trace(); + + cap_set_flag(cap_p, CAP_EFFECTIVE, 1, &admin, CAP_CLEAR); + cap_set_proc(cap_p); + cap_free(cap_p); + return buf; +} + +/* Clear the trace buffer with elevated privileges. Returns 0 on success. */ +static int __maybe_unused tracefs_clear_buf(void) +{ + return tracefs_priv_call(tracefs_clear); +} + +/* + * Forks a child that creates a Landlock sandbox and performs an FS access. The + * parent waits for the child, then reads the trace buffer. + * + * Requires common.h and wrappers.h to be included before trace.h. + */ +static void __maybe_unused sandbox_child_fs_access( + struct __test_metadata *const _metadata, const char *rule_path, + __u64 handled_access, __u64 allowed_access, const char *access_path) +{ + pid_t pid; + int status; + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = handled_access, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = allowed_access, + }; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open(rule_path, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) { + close(ruleset_fd); + _exit(1); + } + + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) { + close(path_beneath.parent_fd); + close(ruleset_fd); + _exit(1); + } + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + fd = open(access_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); +} + +/* + * Forks a child that creates a Landlock sandbox allowing execute+read_dir for + * /usr and execute-only for ".", then execs ./true. The true binary opens "." + * on startup, triggering a read_dir denial with same_exec=0. The parent waits + * for the child to exit. + */ +static void __maybe_unused sandbox_child_exec_true( + struct __test_metadata *const _metadata, __u32 restrict_flags) +{ + pid_t pid; + int status; + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR | + LANDLOCK_ACCESS_FS_EXECUTE, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_EXECUTE | + LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd >= 0) { + landlock_add_rule(ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0); + close(path_beneath.parent_fd); + } + + path_beneath.allowed_access = LANDLOCK_ACCESS_FS_EXECUTE; + path_beneath.parent_fd = + open(".", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd >= 0) { + landlock_add_rule(ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0); + close(path_beneath.parent_fd); + } + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, restrict_flags)) + _exit(1); + close(ruleset_fd); + + execl("./true", "./true", NULL); + _exit(1); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); +} diff --git a/tools/testing/selftests/landlock/trace_fs_test.c b/tools/testing/selftests/landlock/trace_fs_test.c new file mode 100644 index 000000000000..5220f6a4bee1 --- /dev/null +++ b/tools/testing/selftests/landlock/trace_fs_test.c @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * Landlock tests - Filesystem tracepoints + * + * Copyright © 2026 Cloudflare, Inc. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common.h" +#include "trace.h" + +#define TRACE_TASK "trace_fs_test" + +/* + * Like REGEX_DENY_ACCESS_FS(), but pins the logged field to a specific value + * ("0" or "1") so a test can tell a suppressed (quiet) denial from a logged + * one. The tracepoint fires for every denial; logged carries the audit + * verdict. + */ +#define REGEX_DENY_ACCESS_FS_LOGGED(task, log) \ + TRACE_PREFIX(task) \ + "landlock_deny_access_fs: " \ + "domain=[0-9a-f]\\+ " \ + "same_exec=[01] " \ + "logged=" log " " \ + "blockers=[a-z_|]* " \ + "dev=[0-9]\\+:[0-9]\\+ " \ + "ino=[0-9]\\+ " \ + "path=[^ ]*$" + +/* clang-format off */ +FIXTURE(trace_fs) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_fs) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_fs) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false); + tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false); + tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* + * Baseline: verifies that without Landlock, the operation succeeds and no + * check_rule or deny_access trace events fire. + */ +TEST_F(trace_fs, unsandboxed) +{ + char *buf; + int count, status, fd; + pid_t pid; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + /* + * No sandbox: verify that a normal FS access does not produce + * Landlock trace events. + */ + fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_EQ(0, count); + count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK)); + EXPECT_EQ(0, count); + + free(buf); +} + +/* + * Verifies that adding a filesystem rule emits a landlock_add_rule_fs trace + * event with the expected path and field values: ruleset ID is non-zero, + * access_rights is non-zero, and path matches. + */ +TEST_F(trace_fs, add_rule_fs) +{ + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_WRITE_FILE | + LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE, + }; + char *buf, field_buf[64]; + int ruleset_fd, count; + + ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + ASSERT_LE(0, ruleset_fd); + + path_beneath.parent_fd = open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + ASSERT_LE(0, path_beneath.parent_fd); + + ASSERT_EQ(0, landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)); + ASSERT_EQ(0, close(path_beneath.parent_fd)); + ASSERT_EQ(0, close(ruleset_fd)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_ADD_RULE_FS(TRACE_TASK)); + EXPECT_EQ(1, count) + { + TH_LOG("Expected 1 add_rule_fs event, got %d\n%s", count, buf); + } + + /* Ruleset ID should be non-zero. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK), + "ruleset", field_buf, + sizeof(field_buf))); + EXPECT_STRNE("0", field_buf); + + /* Access rights should be non-zero. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK), + "access_rights", field_buf, + sizeof(field_buf))); + EXPECT_STRNE("", field_buf); + + /* Path should be /usr. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_ADD_RULE_FS(TRACE_TASK), + "path", field_buf, sizeof(field_buf))); + EXPECT_STREQ("/usr", field_buf); + + free(buf); +} + +/* + * Verifies that an allowed access emits check_rule events (rule matched during + * pathwalk) but does NOT emit deny_access events (no denial). + */ +TEST_F(trace_fs, allowed_access) +{ + char *buf, field_buf[64]; + int count; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* Rule allows READ_DIR for /usr, access /usr which is allowed. */ + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, "/usr"); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_LE(1, count); + + /* Single-layer grants array, intersected with the request. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "grants", field_buf, + sizeof(field_buf))); + EXPECT_STREQ("{read_dir}", field_buf); + + count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK)); + EXPECT_EQ(0, count); + + free(buf); +} + +/* + * Verifies that accessing a path whose access type is not in the handled set + * does not emit landlock_check_rule events. The ruleset handles READ_FILE, but + * the directory open checks READ_DIR which is unhandled; Landlock has no + * opinion and no rule evaluation occurs. + */ +TEST_F(trace_fs, check_rule_unhandled) +{ + char *buf; + int count; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* Handles READ_FILE only; READ_DIR is unhandled. */ + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_FILE, + LANDLOCK_ACCESS_FS_READ_FILE, "/tmp"); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* No check_rule events because READ_DIR is not in the handled set. */ + count = tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_EQ(0, count); + + free(buf); +} + +/* + * Verifies that nested domains (child sandboxed under a parent domain) emit + * check_rule events from both layers and produce a deny_access event when the + * inner domain's rule does not cover the access. + */ +TEST_F(trace_fs, check_rule_nested) +{ + char *buf, field_buf[64], *comma; + size_t first_len, second_len; + int count_rule, count_access, status; + pid_t pid; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd, fd; + + /* First layer: allow /usr. */ + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) { + close(ruleset_fd); + _exit(1); + } + + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) { + close(path_beneath.parent_fd); + close(ruleset_fd); + _exit(1); + } + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + /* Second layer: also allow /usr. */ + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) { + close(ruleset_fd); + _exit(1); + } + + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)) { + close(path_beneath.parent_fd); + close(ruleset_fd); + _exit(1); + } + close(path_beneath.parent_fd); + + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + /* Access /usr which is allowed by both layers. */ + fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + + /* Access /tmp which has no rule in either layer. */ + fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count_rule = + tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + EXPECT_LE(1, count_rule); + + /* + * Both layers have the same rule, so the grants array must have two + * identical symbolic entries, e.g. {read_dir,read_dir}. + */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "grants", field_buf, + sizeof(field_buf))); + comma = strchr(field_buf, ','); + EXPECT_NE(0, !!comma); + if (comma) { + /* + * Verify both entries are identical: compare the substring + * before the comma with the substring after it (stripping the + * braces). + */ + first_len = comma - field_buf - 1; + second_len = strlen(comma + 1) - 1; + EXPECT_EQ(first_len, second_len); + EXPECT_EQ(0, strncmp(field_buf + 1, comma + 1, first_len)); + } + + count_access = + tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK)); + EXPECT_LE(1, count_access); + + free(buf); +} + +/* + * Verifies that a denied FS access emits a landlock_deny_access_fs trace event + * with the blocked access and path. + */ +TEST_F(trace_fs, deny_access_fs_denied) +{ + char *buf; + int count; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* + * Rule allows READ_DIR for /usr, but access /tmp which has no rule. + * READ_DIR access to /tmp is denied by absence and should emit a + * deny_access_fs event. + */ + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, "/tmp"); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK)); + EXPECT_LE(1, count); + + free(buf); +} + +/* + * A denied FS access covered by a quiet rule (LANDLOCK_ADD_RULE_QUIET with the + * access listed in quiet_access_fs) still emits a landlock_deny_access_fs + * event, but with logged=0, the same audit-logging verdict audit would apply to + * suppress the record. + */ +TEST_F(trace_fs, deny_access_fs_quiet) +{ + char *buf, field[64]; + pid_t pid; + int status; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + if (pid == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + .quiet_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = 0, + }; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + /* Marks /tmp quiet without granting any access. */ + path_beneath.parent_fd = + open("/tmp", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) { + close(ruleset_fd); + _exit(1); + } + if (landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, LANDLOCK_ADD_RULE_QUIET)) { + close(path_beneath.parent_fd); + close(ruleset_fd); + _exit(1); + } + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) { + close(ruleset_fd); + _exit(1); + } + close(ruleset_fd); + + /* Denied READ_DIR on the quiet /tmp: suppressed, logged=0. */ + fd = open("/tmp", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + _exit(0); + } + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* The event fires with the suppressed verdict. */ + EXPECT_LE(1, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED( + TRACE_TASK, "0"))); + /* The quiet rule must not leave the denial logged. */ + EXPECT_EQ(0, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS_LOGGED( + TRACE_TASK, "1"))); + + /* + * Quiet suppresses only the logged verdict: the rest of the denial + * event stays populated (non-zero domain, non-empty blockers). + */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"), + "domain", field, sizeof(field))); + EXPECT_STRNE("0", field); + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_ACCESS_FS_LOGGED(TRACE_TASK, "0"), + "blockers", field, sizeof(field))); + EXPECT_STRNE("", field); + + free(buf); +} + +TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/trace_test.c b/tools/testing/selftests/landlock/trace_test.c new file mode 100644 index 000000000000..afdaf8511b3a --- /dev/null +++ b/tools/testing/selftests/landlock/trace_test.c @@ -0,0 +1,1620 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * Landlock tests - Tracepoints + * + * Copyright © 2026 Cloudflare, Inc. + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common.h" +#include "trace.h" + +#define TRACE_TASK "trace_test" + +/* clang-format off */ +FIXTURE(trace) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, true)); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_FREE_DOMAIN_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_FREE_RULESET_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace) +{ + if (!self->tracefs_ok) + return; + + /* Disables landlock events and clears PID filter. */ + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, false); + tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, false); + tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, false); + tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false); + tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, false); + tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false); + tracefs_enable_event(TRACEFS_CHECK_RULE_NET_ENABLE, false); + tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, false); + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, false); + tracefs_enable_event(TRACEFS_FREE_DOMAIN_ENABLE, false); + tracefs_enable_event(TRACEFS_FREE_RULESET_ENABLE, false); + tracefs_clear_pid_filter(); + clear_cap(_metadata, CAP_SYS_ADMIN); + + /* + * The mount namespace is cleaned up automatically when the test process + * (harness child) exits. + */ +} + +/* + * Verifies that no trace events are emitted when the tracepoints are disabled. + */ +TEST_F(trace, no_trace_when_disabled) +{ + char *buf; + + /* Disable all landlock events. */ + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, false)); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_FS_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ADD_RULE_NET_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CHECK_RULE_FS_ENABLE, false)); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_CHECK_RULE_NET_ENABLE, false)); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_ACCESS_FS_ENABLE, false)); + ASSERT_EQ(0, + tracefs_enable_event(TRACEFS_DENY_ACCESS_NET_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_PTRACE_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_DENY_SCOPE_SIGNAL_ENABLE, + false)); + ASSERT_EQ(0, tracefs_enable_event( + TRACEFS_DENY_SCOPE_ABSTRACT_UNIX_SOCKET_ENABLE, + false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_FREE_DOMAIN_ENABLE, false)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_FREE_RULESET_ENABLE, false)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); + + /* + * Trigger both allowed and denied accesses to verify neither check_rule + * nor check_access events fire when disabled. + */ + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, "/tmp"); + + /* Read trace buffer and verify no landlock events at all. */ + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(0, tracefs_count_matches(buf, "landlock_")) + { + TH_LOG("Expected 0 landlock events when disabled\n%s", buf); + } + + free(buf); +} + +/* + * Verifies that landlock_create_ruleset emits a trace event with the correct + * handled access masks. + */ +TEST_F(trace, create_ruleset) +{ + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE, + .handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP, + }; + int ruleset_fd; + char *buf, *dot; + char field[64]; + + ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + ASSERT_LE(0, ruleset_fd); + ASSERT_EQ(0, close(ruleset_fd)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CREATE_RULESET(TRACE_TASK))) + { + TH_LOG("Expected 1 create_ruleset event\n%s", buf); + } + + /* Verify handled_fs matches what we requested. */ + EXPECT_EQ(0, + tracefs_extract_field(buf, REGEX_CREATE_RULESET(TRACE_TASK), + "handled_fs", field, sizeof(field))); + EXPECT_STREQ("read_file", field); + + /* Verify handled_net matches. */ + EXPECT_EQ(0, + tracefs_extract_field(buf, REGEX_CREATE_RULESET(TRACE_TASK), + "handled_net", field, sizeof(field))); + EXPECT_STREQ("bind_tcp", field); + + /* Verify version is 0 at creation (no rules added yet). */ + EXPECT_EQ(0, + tracefs_extract_field(buf, REGEX_CREATE_RULESET(TRACE_TASK), + "ruleset", field, sizeof(field))); + /* Format is .; version is after the dot. */ + dot = strchr(field, '.'); + ASSERT_NE(0, !!dot); + EXPECT_STREQ("0", dot + 1); + + free(buf); +} + +/* + * Verifies that the ruleset version increments with each add_rule call and that + * create_domain records the correct version. + */ +TEST_F(trace, ruleset_version) +{ + pid_t pid; + int status; + char *buf; + const char *dot; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + /* First rule: version becomes 1. */ + path_beneath.parent_fd = + open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0); + close(path_beneath.parent_fd); + + /* Second rule: version becomes 2. */ + path_beneath.parent_fd = + open("/tmp", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0); + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* Verify create_ruleset has version=0. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_CREATE_RULESET(TRACE_TASK), + "ruleset", field, sizeof(field))); + dot = strchr(field, '.'); + ASSERT_NE(0, !!dot); + EXPECT_STREQ("0", dot + 1); + + /* Verify 2 add_rule_fs events were emitted. */ + EXPECT_EQ(2, tracefs_count_matches(buf, REGEX_ADD_RULE_FS(TRACE_TASK))) + { + TH_LOG("Expected 2 add_rule_fs events\n%s", buf); + } + + /* + * Verify create_domain records version=2 (after 2 add_rule calls). The + * ruleset field format is .. + */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "ruleset", field, sizeof(field))); + dot = strchr(field, '.'); + ASSERT_NE(0, !!dot); + EXPECT_STREQ("2", dot + 1); + + free(buf); +} + +/* + * Verifies that landlock_create_domain emits a trace event linking the ruleset + * ID to the new domain ID. + */ +TEST_F(trace, create_domain) +{ + pid_t pid; + int status, check_count; + char *buf; + char parent_id[64], domain_id[64], check_domain[64]; + + /* Clear before the sandboxed child. */ + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd, fd; + + ruleset_fd = landlock_create_ruleset(&ruleset_attr, + sizeof(ruleset_attr), 0); + if (ruleset_fd < 0) + _exit(1); + + path_beneath.parent_fd = + open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (path_beneath.parent_fd < 0) + _exit(1); + + landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0); + close(path_beneath.parent_fd); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + /* Trigger a check_rule to verify domain_id correlation. */ + fd = open("/usr", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) + close(fd); + + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* Verify create_domain event exists. */ + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))) + { + TH_LOG("Expected 1 create_domain event\n%s", buf); + } + + /* Extract the domain ID from create_domain. */ + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "domain", domain_id, + sizeof(domain_id))); + + /* Verify domain ID is non-zero. */ + EXPECT_NE(0, strcmp(domain_id, "0")); + + /* Verify parent=0 (first restriction, no prior domain). */ + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "parent", parent_id, + sizeof(parent_id))); + EXPECT_STREQ("0", parent_id); + + /* + * Verify the same domain ID appears in the check_rule event, confirming + * end-to-end correlation. + */ + check_count = + tracefs_count_matches(buf, REGEX_CHECK_RULE_FS(TRACE_TASK)); + ASSERT_LE(1, check_count) + { + TH_LOG("Expected check_rule_fs events\n%s", buf); + } + + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_CHECK_RULE_FS(TRACE_TASK), + "domain", check_domain, + sizeof(check_domain))); + EXPECT_STREQ(domain_id, check_domain); + + free(buf); +} + +/* Builds a rule-less scope-based ruleset; returns the fd or -1. */ +static int build_enforce_ruleset(void) +{ + const struct landlock_ruleset_attr attr = { + .scoped = LANDLOCK_SCOPE_SIGNAL, + }; + + return landlock_create_ruleset(&attr, sizeof(attr), 0); +} + +/* + * Verifies that nested landlock_restrict_self calls produce trace events with + * correct parent domain IDs: the second create_domain's parent should be the + * first domain's ID. + */ +TEST_F(trace, create_domain_nested) +{ + pid_t pid; + int status; + char *buf; + const char *after_first; + char first_domain[64], first_parent[64], second_parent[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + int ruleset_fd; + + /* First restriction. */ + ruleset_fd = build_enforce_ruleset(); + if (ruleset_fd < 0) + _exit(1); + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + /* Second restriction (nested). */ + ruleset_fd = build_enforce_ruleset(); + if (ruleset_fd < 0) + _exit(1); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* Should have 2 create_domain events. */ + EXPECT_EQ(2, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))) + { + TH_LOG("Expected 2 create_domain events\n%s", buf); + } + + /* + * Extract domain and parent from each create_domain event. The first + * event (parent=0) is the outer domain; the second (parent!=0) is the + * nested domain whose parent should match the first domain's ID. + */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "domain", first_domain, + sizeof(first_domain))); + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "parent", first_parent, + sizeof(first_parent))); + EXPECT_STREQ("0", first_parent); + + /* + * Find the second create_domain by scanning past the first. + * tracefs_extract_field returns the first match, so search in the + * buffer after the first event. + * + * Skip past the first create_domain line. tracefs_extract_field matches + * the first line that matches the regex, so passing the buffer after + * the first matching line gives us the second event. + */ + after_first = strstr(buf, "landlock_create_domain:"); + ASSERT_NE(NULL, after_first); + after_first = strchr(after_first, '\n'); + ASSERT_NE(NULL, after_first); + + ASSERT_EQ(0, tracefs_extract_field( + after_first + 1, REGEX_CREATE_DOMAIN(TRACE_TASK), + "parent", second_parent, sizeof(second_parent))); + + /* The second domain's parent should be the first domain's ID. */ + EXPECT_STREQ(first_domain, second_parent); + + free(buf); +} + +/* + * Verifies that landlock_add_rule does not emit a trace event when the syscall + * fails (e.g., invalid ruleset fd). + */ +TEST_F(trace, add_rule_invalid_fd) +{ + struct landlock_path_beneath_attr path_beneath = { + .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE, + }; + char *buf; + + path_beneath.parent_fd = open("/usr", O_PATH | O_DIRECTORY | O_CLOEXEC); + ASSERT_LE(0, path_beneath.parent_fd); + + /* Invalid ruleset fd (-1). */ + ASSERT_EQ(-1, landlock_add_rule(-1, LANDLOCK_RULE_PATH_BENEATH, + &path_beneath, 0)); + ASSERT_EQ(0, close(path_beneath.parent_fd)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(0, tracefs_count_matches(buf, REGEX_ADD_RULE_FS(TRACE_TASK))) + { + TH_LOG("No add_rule_fs event expected on invalid fd\n%s", buf); + } + + free(buf); +} + +/* + * Verifies that landlock_create_domain does not emit a trace event when the + * syscall fails (e.g., invalid ruleset fd or unknown flags). + */ +TEST_F(trace, create_domain_invalid) +{ + int ruleset_fd; + char *buf; + + ruleset_fd = build_enforce_ruleset(); + ASSERT_LE(0, ruleset_fd); + + /* Clear the trace buffer after create_ruleset event. */ + ASSERT_EQ(0, tracefs_clear_buf()); + + /* Invalid fd. */ + ASSERT_EQ(-1, landlock_restrict_self(-1, 0)); + + /* Unknown flags. */ + ASSERT_EQ(-1, landlock_restrict_self(ruleset_fd, -1)); + + ASSERT_EQ(0, close(ruleset_fd)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(0, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))) + { + TH_LOG("No create_domain event expected on error\n%s", buf); + } + + free(buf); +} + +/* + * Verifies that trace_landlock_free_domain fires when a domain is deallocated, + * with the correct denials count. + */ +TEST_F(trace, free_domain) +{ + char *buf; + int count; + char denials_field[32]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* + * The domain is freed via a work queue (kworker), so the free_domain + * trace event is emitted from a different PID. Clear the PID filter + * BEFORE the child exits, so the kworker event passes the filter when + * it fires. + */ + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_clear_pid_filter(); + clear_cap(_metadata, CAP_SYS_ADMIN); + + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, "/tmp"); + + /* + * Wait for the deferred deallocation work to run. The domain is freed + * asynchronously from a kworker; poll until the event appears or a + * timeout is reached. + */ + for (int retry = 0; retry < 10; retry++) { + usleep(100000); + + set_cap(_metadata, CAP_SYS_ADMIN); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_NE(NULL, buf); + + count = tracefs_count_matches(buf, + REGEX_FREE_DOMAIN(KWORKER_TASK)); + if (count >= 1) + break; + free(buf); + buf = NULL; + } + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, tracefs_set_pid_filter(getpid())); + clear_cap(_metadata, CAP_SYS_ADMIN); + + ASSERT_NE(NULL, buf); + EXPECT_LE(1, count) + { + TH_LOG("Expected free_domain event, got %d\n%s", count, buf); + } + + /* Verify denials count matches the single denial we triggered. */ + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_FREE_DOMAIN(KWORKER_TASK), + "denials", denials_field, + sizeof(denials_field))); + EXPECT_STREQ("1", denials_field); + + free(buf); +} + +/* + * Verifies that deny_access_fs includes the enriched fields: same_exec and + * logged. + */ +TEST_F(trace, deny_access_fs_fields) +{ + char *buf; + char field_buf[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* Trigger a denial: rule for /usr, access /tmp. */ + sandbox_child_fs_access(_metadata, "/usr", LANDLOCK_ACCESS_FS_READ_DIR, + LANDLOCK_ACCESS_FS_READ_DIR, "/tmp"); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* Verify the enriched fields are present and have valid values. */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), "same_exec", + field_buf, sizeof(field_buf))); + /* Child is the same exec that restricted itself. */ + EXPECT_STREQ("1", field_buf); + + /* Same exec with default flags: audit would log this denial. */ + ASSERT_EQ(0, tracefs_extract_field( + buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), "logged", + field_buf, sizeof(field_buf))); + EXPECT_STREQ("1", field_buf); + + free(buf); +} + +/* + * Verifies that same_exec is 1 (true) for denials from the same executable that + * called landlock_restrict_self(). + */ +TEST_F(trace, same_exec_before_exec) +{ + pid_t pid; + int status; + char *buf; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd, dir_fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + /* No rules: all read_dir access is denied. */ + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + close(ruleset_fd); + + /* Trigger denial without exec (same executable). */ + dir_fd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd >= 0) + close(dir_fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* Should have at least one deny_access_fs denial. */ + EXPECT_LE(1, + tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK))); + + /* Verify same_exec=1 (same executable, no exec). */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), + "same_exec", field, sizeof(field))); + EXPECT_STREQ("1", field); + + /* Same exec with default flags: audit would log this denial. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), + "logged", field, sizeof(field))); + EXPECT_STREQ("1", field); + + free(buf); +} + +/* + * Verifies that same_exec is 0 (false) for denials from a process that has + * exec'd a new binary after landlock_restrict_self(). The sandboxed child + * exec's true which opens "." and triggers a read_dir denial. Covers the + * "trace-only" visibility condition: with same_exec=0 and the default + * log_new_exec=0, audit suppresses the denial (logged=0) but the trace event + * still fires. + */ +TEST_F(trace, same_exec_after_exec) +{ + char *buf; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + sandbox_child_exec_true(_metadata, 0); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_LE(1, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS("true"))); + + /* Verify same_exec=0 (different executable after exec). */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS("true"), + "same_exec", field, sizeof(field))); + EXPECT_STREQ("0", field); + + /* + * same_exec=0 with default log_new_exec=0: audit suppresses (logged=0). + */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS("true"), + "logged", field, sizeof(field))); + EXPECT_STREQ("0", field); + + free(buf); +} + +/* + * Verifies that LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF suppresses logging + * (logged=0) for a denial from the same executable. + */ +TEST_F(trace, log_flags_same_exec_off) +{ + pid_t pid; + int status; + char *buf; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd, dir_fd; + + ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (ruleset_fd < 0) + _exit(1); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self( + ruleset_fd, + LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF)) + _exit(1); + close(ruleset_fd); + + dir_fd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd >= 0) + close(dir_fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_LE(1, + tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK))); + + /* Same-exec denial with LOG_SAME_EXEC_OFF: audit suppresses it. */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), + "logged", field, sizeof(field))); + EXPECT_STREQ("0", field); + + free(buf); +} + +/* + * Verifies that LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON causes a post-exec + * denial to be logged (logged=1). The child exec's true so that the denial + * comes from a new executable (same_exec=0). + */ +TEST_F(trace, log_flags_new_exec_on) +{ + char *buf; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + sandbox_child_exec_true(_metadata, + LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_LE(1, tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS("true"))); + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS("true"), + "same_exec", field, sizeof(field))); + EXPECT_STREQ("0", field); + + /* LOG_NEW_EXEC_ON: the post-exec denial (same_exec=0) is logged. */ + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS("true"), + "logged", field, sizeof(field))); + EXPECT_STREQ("1", field); + + free(buf); +} + +/* + * Verifies that denials suppressed by audit log flags are still counted in + * num_denials. The child restricts itself with default flags (log_same_exec=1, + * log_new_exec=0), then execs true which attempts to read a denied directory. + * After exec, same_exec=0 and log_new_exec=0, so audit suppresses the denial. + * But the trace event fires unconditionally and free_domain must report the + * correct denials count. + */ +TEST_F(trace, non_audit_visible_denial_counting) +{ + char *buf = NULL; + char denials_field[32]; + int count; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, tracefs_clear()); + tracefs_clear_pid_filter(); + clear_cap(_metadata, CAP_SYS_ADMIN); + + sandbox_child_exec_true(_metadata, 0); + + /* Wait for free_domain event with retry. */ + for (int retry = 0; retry < 10; retry++) { + usleep(100000); + + set_cap(_metadata, CAP_SYS_ADMIN); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_SYS_ADMIN); + if (!buf) + break; + + count = tracefs_count_matches(buf, + REGEX_FREE_DOMAIN(KWORKER_TASK)); + if (count >= 1) + break; + free(buf); + buf = NULL; + } + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, tracefs_set_pid_filter(getpid())); + clear_cap(_metadata, CAP_SYS_ADMIN); + + /* + * The denial happened after exec (same_exec=0), so audit would suppress + * it. But num_denials counts all denials regardless. + */ + ASSERT_NE(NULL, buf) + { + TH_LOG("free_domain event not found after 10 retries"); + } + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_FREE_DOMAIN(KWORKER_TASK), + "denials", denials_field, + sizeof(denials_field))); + EXPECT_STREQ("1", denials_field); + + free(buf); +} + +/* + * Verifies that landlock_add_rule_net emits a trace event with the correct port + * and allowed access mask fields. + */ +TEST_F(trace, add_rule_net_fields) +{ + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP, + }; + struct landlock_net_port_attr net_port = { + .allowed_access = LANDLOCK_ACCESS_NET_BIND_TCP, + .port = 8080, + }; + int ruleset_fd; + char *buf; + char field[64]; + + ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + ASSERT_LE(0, ruleset_fd); + + ASSERT_EQ(0, tracefs_clear_buf()); + + ASSERT_EQ(0, landlock_add_rule(ruleset_fd, LANDLOCK_RULE_NET_PORT, + &net_port, 0)); + close(ruleset_fd); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, tracefs_count_matches(buf, REGEX_ADD_RULE_NET(TRACE_TASK))) + { + TH_LOG("Expected 1 add_rule_net event\n%s", buf); + } + + /* + * Verify the port is in host endianness, matching the UAPI convention + * (landlock_net_port_attr.port). On little-endian, htons(8080) is + * 36895, so this comparison catches byte-order bugs. + */ + EXPECT_EQ(0, tracefs_extract_field(buf, REGEX_ADD_RULE_NET(TRACE_TASK), + "port", field, sizeof(field))); + EXPECT_STREQ("8080", field); + /* + * The allowed mask is the absolute value after transformation: the + * user-requested BIND_TCP plus all unhandled access rights (the other + * net access bits are unhandled because the ruleset only handles + * BIND_TCP). + */ + EXPECT_EQ(0, + tracefs_extract_field(buf, REGEX_ADD_RULE_NET(TRACE_TASK), + "access_rights", field, sizeof(field))); + EXPECT_STREQ("bind_tcp|connect_tcp|bind_udp|connect_send_udp", field); + + free(buf); +} + +/* + * Verifies that LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF suppresses audit + * logging for child domains (logged=0) even though the child's own + * per-execution flags are the defaults, while the trace event still fires + * (tracing is unconditional). The parent creates a domain with + * LOG_SUBDOMAINS_OFF, then the child creates a sub-domain and triggers a + * denial. + */ +TEST_F(trace, log_flags_subdomains_off) +{ + pid_t pid; + int status; + char *buf; + char field[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + + if (pid == 0) { + struct landlock_ruleset_attr attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int parent_fd, child_fd, dir_fd; + + /* Parent domain with LOG_SUBDOMAINS_OFF. */ + parent_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (parent_fd < 0) + _exit(1); + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self( + parent_fd, + LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF)) + _exit(1); + close(parent_fd); + + /* Child sub-domain with default flags. */ + child_fd = landlock_create_ruleset(&attr, sizeof(attr), 0); + if (child_fd < 0) + _exit(1); + + if (landlock_restrict_self(child_fd, 0)) + _exit(1); + close(child_fd); + + /* Trigger a denial from the child domain. */ + dir_fd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd >= 0) + close(dir_fd); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + /* + * Trace fires unconditionally even though audit is disabled for the + * child domain (parent had LOG_SUBDOMAINS_OFF). + */ + EXPECT_LE(1, + tracefs_count_matches(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK))) + { + TH_LOG("Expected deny_access_fs event despite " + "LOG_SUBDOMAINS_OFF\n%s", + buf); + } + + /* + * The child's per-execution flags default to logging, but the + * ancestor's LOG_SUBDOMAINS_OFF disables it, so audit suppresses this + * denial (logged=0). This is exactly the case the single logged field + * captures and the raw per-execution flags could not. + */ + ASSERT_EQ(0, + tracefs_extract_field(buf, REGEX_DENY_ACCESS_FS(TRACE_TASK), + "logged", field, sizeof(field))); + EXPECT_STREQ("0", field); + + free(buf); +} + +/* Verifies that landlock_free_ruleset fires when a ruleset FD is closed. */ +TEST_F(trace, free_ruleset_on_close) +{ + struct landlock_ruleset_attr ruleset_attr = { + .handled_access_fs = LANDLOCK_ACCESS_FS_READ_DIR, + }; + int ruleset_fd; + char *buf; + + ruleset_fd = + landlock_create_ruleset(&ruleset_attr, sizeof(ruleset_attr), 0); + ASSERT_LE(0, ruleset_fd); + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* Closing the FD should trigger free_ruleset. */ + close(ruleset_fd); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, tracefs_count_matches(buf, REGEX_FREE_RULESET(TRACE_TASK))) + { + TH_LOG("Expected 1 free_ruleset event\n%s", buf); + } + + free(buf); +} + +/* + * Counts landlock_enforce_domain lines, filtered by @domain (NULL matches any), + * @complete and @process_wide (a negative value matches any). Builds the + * anchored regex dynamically so a single helper covers every field assertion. + */ +static int count_enforce_matches(const char *buf, const char *domain, + int complete, int process_wide, + int no_new_privs) +{ + char pattern[512], dom[80], comp[8], pw[8], nnp[8]; + + if (domain) + snprintf(dom, sizeof(dom), "%s", domain); + else + snprintf(dom, sizeof(dom), "[0-9a-f]\\+"); + if (complete < 0) + snprintf(comp, sizeof(comp), "[01]"); + else + snprintf(comp, sizeof(comp), "%d", complete); + if (process_wide < 0) + snprintf(pw, sizeof(pw), "[01]"); + else + snprintf(pw, sizeof(pw), "%d", process_wide); + if (no_new_privs < 0) + snprintf(nnp, sizeof(nnp), "[01]"); + else + snprintf(nnp, sizeof(nnp), "%d", no_new_privs); + + snprintf(pattern, sizeof(pattern), + TRACE_PREFIX(TRACE_TASK) "landlock_enforce_domain: " + "domain=%s " + "complete=%s process_wide=%s " + "no_new_privs=%s$", + dom, comp, pw, nnp); + return tracefs_count_matches(buf, pattern); +} + +/* Idle sibling: waits on the barrier so it is a live thread, then sleeps. */ +static void *enforce_idle(void *arg) +{ + pthread_barrier_t *barrier = arg; + + pthread_barrier_wait(barrier); + while (true) + sleep(1); + return NULL; +} + +/* + * Child body: spawns @nthreads idle siblings (barrier-synchronized so they are + * live when the syscall runs), then enforces a domain with @flags. Returns 0 + * on success; the process exits afterwards, reaping the siblings. + */ +static int child_enforce(int nthreads, __u32 flags) +{ + pthread_t threads[8]; + pthread_barrier_t barrier; + int ruleset_fd, i; + + if (nthreads > 0) { + if (pthread_barrier_init(&barrier, NULL, nthreads + 1)) + return 1; + for (i = 0; i < nthreads; i++) + if (pthread_create(&threads[i], NULL, enforce_idle, + &barrier)) + return 1; + pthread_barrier_wait(&barrier); + } + + ruleset_fd = build_enforce_ruleset(); + if (ruleset_fd < 0) + return 1; + + /* + * LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS sets no_new_privs itself, so skip + * the prctl() to exercise that path; otherwise Landlock requires + * no_new_privs up front. + */ + if (!(flags & LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS)) + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, flags)) + return 1; + close(ruleset_fd); + return 0; +} + +/* + * Runs in a spawned thread after the group leader called pthread_exit(). The + * leader lingers as an un-reaped zombie, so get_nr_threads() still counts it + * and this non-leader is not the only thread; enforcing here therefore reports + * process_wide=0. + */ +static void *enforce_nonleader(void *arg) +{ + int ruleset_fd; + + ruleset_fd = build_enforce_ruleset(); + if (ruleset_fd < 0) + _exit(1); + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self(ruleset_fd, 0)) + _exit(1); + _exit(0); +} + +/* + * Collapses the enforce_domain field cases into one parametrized test. Each + * variant runs child_enforce(nthreads, flags) and checks the resulting + * enforce_domain events. The flags column also selects how no_new_privs is + * set: with LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS child_enforce() skips the + * prctl() so the flag sets it (and, with TSYNC, propagates to the siblings); + * otherwise a prior prctl() sets it on the caller (and TSYNC propagates that). + */ + +/* clang-format off */ +FIXTURE(trace_enforce) { + /* clang-format on */ + int tracefs_ok; +}; + +FIXTURE_SETUP(trace_enforce) +{ + int ret; + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, unshare(CLONE_NEWNS)); + ASSERT_EQ(0, mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)); + + ret = tracefs_fixture_setup(); + if (ret) { + clear_cap(_metadata, CAP_SYS_ADMIN); + self->tracefs_ok = 0; + SKIP(return, "tracefs not available"); + } + self->tracefs_ok = 1; + + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, true)); + ASSERT_EQ(0, tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, true)); + ASSERT_EQ(0, tracefs_clear()); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +FIXTURE_TEARDOWN(trace_enforce) +{ + if (!self->tracefs_ok) + return; + + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_enable_event(TRACEFS_CREATE_RULESET_ENABLE, false); + tracefs_enable_event(TRACEFS_CREATE_DOMAIN_ENABLE, false); + tracefs_enable_event(TRACEFS_ENFORCE_DOMAIN_ENABLE, false); + tracefs_fixture_teardown(); + clear_cap(_metadata, CAP_SYS_ADMIN); +} + +/* clang-format off */ +FIXTURE_VARIANT(trace_enforce) { + /* clang-format on */ + /* Inputs to child_enforce(). */ + int nthreads; + __u32 flags; + /* Expected enforce_domain event counts. */ + int total; + int complete; + int process_wide; + int no_new_privs; +}; + +/* clang-format off */ + +/* Single thread, no flags: prctl-backed no_new_privs. */ +FIXTURE_VARIANT_ADD(trace_enforce, single) { + .nthreads = 0, .flags = 0, + .total = 1, .complete = 1, .process_wide = 1, .no_new_privs = 1, +}; + +/* Single thread: the NO_NEW_PRIVS flag sets no_new_privs (no prctl). */ +FIXTURE_VARIANT_ADD(trace_enforce, no_new_privs) { + .nthreads = 0, .flags = LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS, + .total = 1, .complete = 1, .process_wide = 1, .no_new_privs = 1, +}; + +/* TSYNC on a lone thread still concludes, process-wide. */ +FIXTURE_VARIANT_ADD(trace_enforce, tsync_single) { + .nthreads = 0, .flags = LANDLOCK_RESTRICT_SELF_TSYNC, + .total = 1, .complete = 1, .process_wide = 1, .no_new_privs = 1, +}; + +/* TSYNC sweeps N siblings; the caller's prctl-backed nnp propagates to all. */ +FIXTURE_VARIANT_ADD(trace_enforce, tsync_multithread) { + .nthreads = 3, .flags = LANDLOCK_RESTRICT_SELF_TSYNC, + .total = 4, .complete = 1, .process_wide = 4, .no_new_privs = 4, +}; + +/* TSYNC + NO_NEW_PRIVS flag sets nnp on the caller and every swept sibling. */ +FIXTURE_VARIANT_ADD(trace_enforce, tsync_no_new_privs) { + .nthreads = 3, + .flags = LANDLOCK_RESTRICT_SELF_TSYNC | LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS, + .total = 4, .complete = 1, .process_wide = 4, .no_new_privs = 4, +}; + +/* Non-TSYNC on a multi-threaded process enforces only the caller. */ +FIXTURE_VARIANT_ADD(trace_enforce, multithread_non_tsync) { + .nthreads = 3, .flags = 0, + .total = 1, .complete = 1, .process_wide = 0, .no_new_privs = 1, +}; + +/* clang-format on */ + +/* + * One create_domain and variant->total enforce_domain events sharing that + * domain ID; complete=1 marks the single concluding event, and the process_wide + * / no_new_privs counts match the variant. Counts are order-independent, + * evaluated after the syscall returns. + */ +TEST_F(trace_enforce, enforce) +{ + pid_t pid; + int status; + char *buf; + char domain[64]; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + if (pid == 0) + _exit(child_enforce(variant->nthreads, variant->flags)); + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))); + EXPECT_EQ(variant->total, count_enforce_matches(buf, NULL, -1, -1, -1)) + { + TH_LOG("Expected %d enforce_domain events\n%s", variant->total, + buf); + } + EXPECT_EQ(variant->complete, + count_enforce_matches(buf, NULL, 1, -1, -1)); + EXPECT_EQ(variant->total - variant->complete, + count_enforce_matches(buf, NULL, 0, -1, -1)); + EXPECT_EQ(variant->process_wide, + count_enforce_matches(buf, NULL, -1, 1, -1)); + EXPECT_EQ(variant->total - variant->process_wide, + count_enforce_matches(buf, NULL, -1, 0, -1)); + EXPECT_EQ(variant->no_new_privs, + count_enforce_matches(buf, NULL, -1, -1, 1)); + + ASSERT_EQ(0, tracefs_extract_field(buf, REGEX_CREATE_DOMAIN(TRACE_TASK), + "domain", domain, sizeof(domain))); + EXPECT_EQ(variant->total, + count_enforce_matches(buf, domain, -1, -1, -1)); + + free(buf); +} + +/* + * A non-leader thread enforcing a domain while the group leader lingers as an + * un-reaped zombie reports process_wide=0: get_nr_threads() counts the zombie + * leader, so the group is not single-threaded. This is the reachable half of + * the caveat that process_wide==0 never proves the process is multi-threaded + * (get_nr_threads(), unlike the leader-relative thread_group_empty(), counts + * the zombie leader). + */ +TEST_F(trace, enforce_single_non_leader) +{ + pid_t pid; + int status; + char *buf; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + if (pid == 0) { + pthread_t worker; + + if (pthread_create(&worker, NULL, enforce_nonleader, NULL)) + _exit(1); + /* Leader leaves; the worker enforces as a non-leader. */ + pthread_exit(NULL); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(1, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))); + EXPECT_EQ(1, count_enforce_matches(buf, NULL, 1, 0, -1)) + { + TH_LOG("Expected complete=1 process_wide=0 for non-leader\n%s", + buf); + } + + free(buf); +} + +/* + * Verifies the flags-only path (ruleset_fd == -1) creates no domain and emits + * neither create_domain nor enforce_domain, with and without TSYNC. + */ +TEST_F(trace, enforce_flags_only) +{ + pid_t pid; + int status; + char *buf; + + ASSERT_EQ(0, tracefs_clear_buf()); + + pid = fork(); + ASSERT_LE(0, pid); + if (pid == 0) { + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + if (landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF)) + _exit(1); + if (landlock_restrict_self( + -1, LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF | + LANDLOCK_RESTRICT_SELF_TSYNC)) + _exit(1); + _exit(0); + } + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + buf = tracefs_read_buf(); + ASSERT_NE(NULL, buf); + + EXPECT_EQ(0, + tracefs_count_matches(buf, REGEX_CREATE_DOMAIN(TRACE_TASK))); + EXPECT_EQ(0, count_enforce_matches(buf, NULL, -1, -1, -1)) + { + TH_LOG("No enforce_domain expected on flags-only path\n%s", + buf); + } + + free(buf); +} + +static void enforce_nop_handler(int sig) +{ +} + +struct abort_signaler_data { + pthread_t target; + volatile bool stop; +}; + +/* + * Hammers the target thread with SIGUSR1 to interrupt the TSYNC prepare wait. + */ +static void *abort_signaler(void *arg) +{ + struct abort_signaler_data *data = arg; + + while (!data->stop) + pthread_kill(data->target, SIGUSR1); + return NULL; +} + +/* + * Child body for the abort test: with idle siblings and a signaler interrupting + * it, repeatedly enforces under TSYNC. An interrupted attempt aborts its + * just-created domain (create_domain + free_domain, zero enforce_domain) while + * -ERESTARTNOINTR transparently restarts the syscall, so a successful retry may + * add its own full lifecycle. + */ +static int child_abort(int nsiblings, int attempts) +{ + pthread_t threads[200]; + pthread_t signaler; + pthread_barrier_t barrier; + struct abort_signaler_data data = {}; + struct sigaction sa = {}; + int i; + + sa.sa_handler = enforce_nop_handler; + if (sigaction(SIGUSR1, &sa, NULL)) + return 1; + + if (pthread_barrier_init(&barrier, NULL, nsiblings + 1)) + return 1; + for (i = 0; i < nsiblings; i++) + if (pthread_create(&threads[i], NULL, enforce_idle, &barrier)) + return 1; + pthread_barrier_wait(&barrier); + + data.target = pthread_self(); + if (pthread_create(&signaler, NULL, abort_signaler, &data)) + return 1; + + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + for (i = 0; i < attempts; i++) { + int ruleset_fd = build_enforce_ruleset(); + + if (ruleset_fd < 0) + break; + /* + * Ignore the result: an abort returns an error, that is fine. + */ + landlock_restrict_self(ruleset_fd, + LANDLOCK_RESTRICT_SELF_TSYNC); + close(ruleset_fd); + } + + data.stop = true; + pthread_join(signaler, NULL); + return 0; +} + +/* + * Verifies the abort contract: a domain aborted by a thread-sync failure emits + * create_domain and free_domain but zero enforce_domain. The signal race is + * probabilistic and -ERESTARTNOINTR may add a successful retry's lifecycle, so + * events are grouped by domain ID and the test SKIPs if no abort occurred. + */ +TEST_F(trace, enforce_abort) +{ + pid_t pid; + int status, retry; + char *buf = NULL; + const char *cursor; + char domain[64]; + bool abort_found = false; + + ASSERT_EQ(0, tracefs_clear_buf()); + + /* free_domain fires from a kworker, so widen the filter first. */ + set_cap(_metadata, CAP_SYS_ADMIN); + tracefs_clear_pid_filter(); + clear_cap(_metadata, CAP_SYS_ADMIN); + + pid = fork(); + ASSERT_LE(0, pid); + if (pid == 0) + /* + * Match tsync_test's NUM_IDLE_THREADS: enough siblings that + * credential preparation runs in several serialized waves, + * giving the signaler a window to interrupt the thread-sync + * wait and abort the operation. A handful of threads finishes + * in a single wave, leaving no window (the abort never fires). + */ + _exit(child_abort(200, 8)); + + ASSERT_EQ(pid, waitpid(pid, &status, 0)); + ASSERT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + + /* Poll for the asynchronous free_domain events. */ + for (retry = 0; retry < 10; retry++) { + usleep(100000); + set_cap(_metadata, CAP_SYS_ADMIN); + free(buf); + buf = tracefs_read_trace(); + clear_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_NE(NULL, buf); + } + + set_cap(_metadata, CAP_SYS_ADMIN); + ASSERT_EQ(0, tracefs_set_pid_filter(getpid())); + clear_cap(_metadata, CAP_SYS_ADMIN); + + /* + * Walk every create_domain and look for one whose domain ID has zero + * enforce_domain events but a matching free_domain: that is an aborted + * domain (created, never enforced, freed). + */ + cursor = buf; + while (tracefs_extract_field(cursor, REGEX_CREATE_DOMAIN(TRACE_TASK), + "domain", domain, sizeof(domain)) == 0) { + const char *cd, *nl; + char free_pattern[256]; + + if (count_enforce_matches(buf, domain, -1, -1, -1) == 0) { + snprintf( + free_pattern, sizeof(free_pattern), + TRACE_PREFIX( + KWORKER_TASK) "landlock_free_domain: " + "domain=%s denials=[0-9]\\+$", + domain); + if (tracefs_count_matches(buf, free_pattern) >= 1) + abort_found = true; + } + + cd = strstr(cursor, "landlock_create_domain:"); + if (!cd) + break; + nl = strchr(cd, '\n'); + if (!nl) + break; + cursor = nl + 1; + } + + if (!abort_found) { + free(buf); + SKIP(return, "signal race did not produce a thread-sync abort"); + } + + free(buf); +} + +/* + * The following tests are intentionally elided because the underlying kernel + * mechanisms are already validated by audit tests: + * + * - Domain ID monotonicity: validated by audit_test.c:layers. The same + * landlock_get_id_range() function serves both audit and trace. + * + * - Domain deallocation order (LIFO): validated by audit_test.c:layers. Trace + * events fire from the same free_domain_work() code path. + * + * - Max-layer stacking (16 domains): validated by audit_test.c:layers. + * + * - IPv6 network tests: IPv6 hook dispatch uses the same + * current_check_access_socket() as IPv4, validated by net_test.c:audit tests. + * + * - Per-access-right full matrix (all 16 FS rights): hook dispatch is validated + * by fs_test.c:audit tests. Trace tests verify representative samples to + * ensure bitmask encoding is correct. + * + * - Combined log flag variants (e.g., LOG_SUBDOMAINS_OFF + LOG_NEW_EXEC_ON): + * individual flag tests above cover each flag's effect on trace fields. Flag + * combination logic is validated by audit_test.c:audit_flags tests. + * + * - fs.refer multi-record denials and fs.change_topology (mount): + * trace_denial() uses the same code path for all FS request types. The + * DENTRY union member is validated by the deny_access_fs_fields + * test. Audit tests in fs_test.c cover refer and mount denial specifics. + */ + +TEST_HARNESS_MAIN diff --git a/tools/testing/selftests/landlock/true.c b/tools/testing/selftests/landlock/true.c index 3f9ccbf52783..1e39b664512d 100644 --- a/tools/testing/selftests/landlock/true.c +++ b/tools/testing/selftests/landlock/true.c @@ -1,5 +1,15 @@ // SPDX-License-Identifier: GPL-2.0 +/* + * Minimal helper for Landlock selftests. Opens its own working directory + * before exiting, which may trigger access denials depending on the sandbox + * configuration. + */ + +#include +#include + int main(void) { + close(open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC)); return 0; } diff --git a/tools/testing/selftests/landlock/tsync_test.c b/tools/testing/selftests/landlock/tsync_test.c index 9cf1491bbaaf..2b53596c986e 100644 --- a/tools/testing/selftests/landlock/tsync_test.c +++ b/tools/testing/selftests/landlock/tsync_test.c @@ -62,32 +62,104 @@ static void *idle(void *data) pthread_cleanup_pop(1); } -TEST(multi_threaded_success) +FIXTURE(multi_threaded) +{ + int ruleset_fd; +}; + +FIXTURE_VARIANT(multi_threaded) +{ + const __u32 restrict_flags; + /* Sets no_new_privs with prctl(2) before the enforcement. */ + const bool prior_no_new_privs; + /* Enforces the maximum number of allowed layers beforehand. */ + const bool max_layers; + const int expected_errno; + /* Expected no_new_privs state of all threads after the call. */ + const bool expected_no_new_privs; +}; + +/* clang-format off */ +FIXTURE_VARIANT_ADD(multi_threaded, success) { + /* clang-format on */ + .restrict_flags = LANDLOCK_RESTRICT_SELF_TSYNC, + .prior_no_new_privs = true, + .expected_no_new_privs = true, +}; + +/* clang-format off */ +FIXTURE_VARIANT_ADD(multi_threaded, no_new_privs) { + /* clang-format on */ + .restrict_flags = LANDLOCK_RESTRICT_SELF_TSYNC | + LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS, + .expected_no_new_privs = true, +}; + +/* clang-format off */ +FIXTURE_VARIANT_ADD(multi_threaded, no_new_privs_max_layers) { + /* clang-format on */ + .restrict_flags = LANDLOCK_RESTRICT_SELF_TSYNC | + LANDLOCK_RESTRICT_SELF_NO_NEW_PRIVS, + .max_layers = true, + .expected_errno = E2BIG, + .expected_no_new_privs = false, +}; + +FIXTURE_SETUP(multi_threaded) +{ + self->ruleset_fd = create_ruleset(_metadata); + + if (variant->max_layers) { + /* Enforces the maximum number of allowed layers. */ + for (int i = 0; i < LANDLOCK_MAX_NUM_LAYERS; i++) + ASSERT_EQ(0, + landlock_restrict_self(self->ruleset_fd, 0)); + } + + disable_caps(_metadata); +} + +FIXTURE_TEARDOWN(multi_threaded) +{ + EXPECT_EQ(0, close(self->ruleset_fd)); +} + +TEST_F(multi_threaded, restrict) { pthread_t t1, t2; bool no_new_privs1, no_new_privs2; - const int ruleset_fd = create_ruleset(_metadata); - - disable_caps(_metadata); ASSERT_EQ(0, pthread_create(&t1, NULL, idle, &no_new_privs1)); ASSERT_EQ(0, pthread_create(&t2, NULL, idle, &no_new_privs2)); - ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)); + if (variant->prior_no_new_privs) { + ASSERT_EQ(0, prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)); + } else { + /* No prior prctl(2) PR_SET_NO_NEW_PRIVS call. */ + ASSERT_EQ(0, prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + } - EXPECT_EQ(0, landlock_restrict_self(ruleset_fd, - LANDLOCK_RESTRICT_SELF_TSYNC)); + if (variant->expected_errno) { + EXPECT_EQ(-1, landlock_restrict_self(self->ruleset_fd, + variant->restrict_flags)); + EXPECT_EQ(variant->expected_errno, errno); + } else { + EXPECT_EQ(0, landlock_restrict_self(self->ruleset_fd, + variant->restrict_flags)); + } + + /* Checks the no_new_privs state of the calling thread. */ + EXPECT_EQ(variant->expected_no_new_privs, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); ASSERT_EQ(0, pthread_cancel(t1)); ASSERT_EQ(0, pthread_cancel(t2)); ASSERT_EQ(0, pthread_join(t1, NULL)); ASSERT_EQ(0, pthread_join(t2, NULL)); - /* The no_new_privs flag was implicitly enabled on all threads. */ - EXPECT_TRUE(no_new_privs1); - EXPECT_TRUE(no_new_privs2); - - EXPECT_EQ(0, close(ruleset_fd)); + /* Checks the no_new_privs state of the sibling threads. */ + EXPECT_EQ(variant->expected_no_new_privs, no_new_privs1); + EXPECT_EQ(variant->expected_no_new_privs, no_new_privs2); } TEST(multi_threaded_success_despite_diverging_domains)