Add self-protection guard programs to the RestrictFileSystemAccess= skeleton that
prevent non-PID1 processes from obtaining FDs to our maps, programs, or
links via the bpf() syscall.
This blocks the primary attack vector against the RestrictFileSystemAccess= policy:
using BPF_MAP_GET_FD_BY_ID to get an FD to the verity_devices map,
then BPF_MAP_UPDATE_ELEM to inject fake trusted devices. Protection of
program and link IDs is defense-in-depth (the kernel already blocks
BPF_LINK_UPDATE and BPF_LINK_DETACH for LSM tracing links).
Additionally, a ptrace guard (lsm/ptrace_access_check) blocks
PTRACE_MODE_ATTACH to PID1 from other processes, preventing
extraction of sensitive state from PID1's address space via
ptrace, /proc/1/mem, process_vm_readv(), or pidfd_getfd().
Guard logic:
1. Allow all BPF ops from PID1 (tgid == 1, unspoofable)
2. Deny BPF_MAP_GET_FD_BY_ID for our protected map IDs
3. Deny BPF_PROG_GET_FD_BY_ID for our program IDs
4. Deny BPF_LINK_GET_FD_BY_ID for our link IDs
5. Allow everything else (zero collateral damage)
The guard starts inactive (all protected IDs default to 0 in .bss).
After skeleton attach, PID1 queries kernel-assigned IDs via
bpf_obj_get_info_by_fd() and writes them into the guard globals via
the mmap'd .bss, then extracts owned FDs and destroys the skeleton.
Destroying the skeleton unmaps the .bss page from PID1's address
space, so no BPF state — guard globals, protected map/prog/link IDs,
initramfs_s_dev — remains readable via /proc/1/mem. The kernel map
data persists (held by the dup'd FDs) but is only accessible via
bpf_map_* syscalls, which the guard itself blocks for non-PID1.
Signed-off-by: Christian Brauner <brauner@kernel.org>
The BPF link and .bss map FDs must survive PID1 re-execution
(daemon-reexec, switch_root, soft-reboot). Without serialization,
manager_free() closes them before execv, programs detach, and the
verity_devices map is freed. After exec a fresh skeleton would have
an empty map — but existing dm-verity devices have already called
bdev_setintegrity and won't call it again. The result would be a
deny-default policy with an empty map, i.e., all execution denied
and the system bricked.
Add serialize/deserialize support using systemd's existing
serialize_fd / fdset_cloexec / deserialize_fd infrastructure:
Before exec (in manager_serialize via bpf_restrict_fsaccess_serialize):
- Dup each link FD and the .bss map FD into the FDSet
- fdset_cloexec(fds, false) + execv() preserves them across exec
After exec (in manager_deserialize + bpf_restrict_fsaccess_setup):
- Deserialize the link FDs and .bss map FD into the Manager struct
- bpf_restrict_fsaccess_setup() detects the deserialized FDs and skips
skeleton re-creation entirely — the programs are already attached
- If no longer in initrd, clear initramfs_s_dev in the kernel map
No bpffs pinning is needed. This avoids a bpffs mount dependency and
eliminates the external attack surface that pinned objects would create
(discoverable/manipulable via unlink or BPF_OBJ_GET). The FDs remain
private to PID1.
Signed-off-by: Christian Brauner <brauner@kernel.org>
Add a new RestrictFileSystemAccess= boolean setting in the [Manager] section of
system.conf that enforces execution only from signed dm-verity block
devices and the initramfs during early boot.
When RestrictFileSystemAccess=yes is set, PID1 loads a BPF LSM program early in boot
that:
Integrity tracking (self-populating, no userspace involvement):
- bdev_setintegrity: records dm-verity signature status in a BPF hash
map when the kernel signals device integrity via
security_bdev_setintegrity()
- bdev_free_security: removes devices from the map on teardown
Execution enforcement (deny-default policy):
- bprm_check_security: blocks execve() from untrusted sources
- mmap_file: blocks PROT_EXEC mmap (shared libs, anonymous exec memory)
- file_mprotect: blocks W->X transitions (JIT, libffi, etc.)
Trust anchors:
- Signed dm-verity volumes (sig_valid flag in the BPF map)
- Initramfs (s_dev captured at load time, cleared after switch_root)
- Everything else is denied (tmpfs, procfs, sysfs, anonymous PROT_EXEC)
PID1 requires dm-verity require_signatures=1 to be enabled and refuses
to load the BPF program otherwise, ensuring the kernel enforces that all
dm-verity devices carry valid signatures.
After attach, PID1 extracts owned FDs from the skeleton (link FDs +
.bss map FD) and lets the skeleton be destroyed. The dup'd link FDs
keep programs attached via the kernel reference chain (link FD ->
bpf_link -> bpf_prog -> bpf_map). Destroying the skeleton unmaps the
.bss page from PID1's address space so no BPF state is readable via
/proc/1/mem. The .bss map FD is retained for targeted writes (clearing
initramfs_s_dev after switch_root via mmap).
Signed-off-by: Christian Brauner <brauner@kernel.org>
The fix for the corrupted state when units become aliased on reload
leaks the now-aliased unit's resources, which become untracked and
essentially lost.
While fixing the state corruption is of course necessary, leaking
processes/etc. is not ideal for a system and service manager, so
instead attempt to keep track of them by creating stub units
on-the-fly as replacements.
This way resources are not leaked, there are clear indications of
where they moved, and all state can be tracked as expected.
Ignore timers/sockets/paths as those are internal resources/triggers.
Follow-up for a77c7a8224
Previously, we passed around information about the MetricFamily'ies
and the varlink connection in a helper structure. Having a hybrid of
const static and runtime stuff is iffy. Let's simplify things by passing
two separate parameters.
Also, in report-cgroup.c we built a cache of parsed values. This
requires additional storage requirements and introduces complexity when
dealing with population of the cache at the appropriate time.
This cache is not useful: for each cgroup, we generate a list of
metrics, and we have all the information at hand. The only reason
why we'd create the cache and not generate all the relevant replies
at once was that the helper functions called the .generate function
for each MetricFamily separately.
The MetricFamily interface is changed, so that metrics can be
defined without a .generate function. This is understood to mean
that the preceding metric family's .generate function will also
genarate this family. This allows us to define related metrics
nicely in a table:
{ METRIC_IO_SYSTEMD_CGROUP_PREFIX "CpuUsage", generate_func },
{ METRIC_IO_SYSTEMD_CGROUP_PREFIX "IOReadBytes", NULL },
{ METRIC_IO_SYSTEMD_CGROUP_PREFIX "IOReadOperations", NULL },
{ METRIC_IO_SYSTEMD_CGROUP_PREFIX "SomethingElse", generate_func2 },
...
When implementing .Describe, we list all the families. When implementing
.List, we only call those with .generate, and we get the same results
as before.
This allows the .generate functions to be simplified: instead of
keeping state, they just spit out all the metrics for a given
object in a tight loop.
If a unit has active jobs, when it gets serialized there are job
subsections, each with their own empty line marker. The skipping
function ignores this and skips until the marker, but then leaves
the job in place, breaking deserialization.
Consume jobs subsections too.
This shows up now that there's TEST-07-PID1.alias-corruption,
which occasionally fails when the aliased unit happens to
still have a job when the reexec happens.
```
[ 967.551630] TEST-07-PID1.sh[179]: + echo 'Testing with: systemctl daemon-reexec'
[ 967.551630] TEST-07-PID1.sh[179]: Testing with: systemctl daemon-reexec
[ 968.405274] TEST-07-PID1.sh[179]: + echo '--- Attempt 1/3 ---'
[ 968.405274] TEST-07-PID1.sh[179]: --- Attempt 1/3 ---
[ 968.698641] TEST-07-PID1.sh[179]: + echo 'Running daemon-reexec...'
[ 968.698641] TEST-07-PID1.sh[179]: Running daemon-reexec...
[ 969.130261] TEST-07-PID1.sh[179]: + echo 'legit.service PID remains 1282. Attempt 1 passed.'
[ 969.130261] TEST-07-PID1.sh[179]: legit.service PID remains 1282. Attempt 1 passed.
[ 970.870456] TEST-07-PID1.sh[179]: + echo '--- Attempt 2/3 ---'
[ 970.870456] TEST-07-PID1.sh[179]: --- Attempt 2/3 ---
[ 971.267205] TEST-07-PID1.sh[179]: + echo 'Running daemon-reexec...'
[ 971.267205] TEST-07-PID1.sh[179]: Running daemon-reexec...
[ 971.715743] TEST-07-PID1.sh[179]: + echo 'legit.service PID changed from 1282 to 1643!'
[ 971.715743] TEST-07-PID1.sh[179]: legit.service PID changed from 1282 to 1643!
```
https://github.com/systemd/systemd/actions/runs/25376867873/job/74414201255
exec_command_list_build_json() serializes a linked list of ExecCommand into a JSON array. It was static in varlink-unit.c (used by service_context_build_json) but is also needed by the upcoming socket context builder. Move it next to exec_command_build_json() in varlink-common where it can be shared.
Add varlink context and runtime builders for .timer units:
TimerContext: MonotonicTimers (with MonotonicTimerBase enum), CalendarTimers, Unit, OnClockChange, OnTimezoneChange, AccuracyUSec, RandomizedDelayUSec, RandomizedOffsetUSec, FixedRandomDelay, Persistent, WakeSystem, RemainAfterElapse, DeferReactivation
TimerRuntime: Result (TimerResult enum), NextElapseUSecRealtime, NextElapseUSecMonotonic, LastTriggerUSec
MonotonicTimerSpec and CalendarTimerSpec are separate types since they have different value types (int vs string). MonotonicTimerBase and TimerResult are proper varlink enum types.
Compared to the old io-systemd-Unit-List branch, this adds RandomizedOffsetUSec and DeferReactivation (both present in D-Bus but previously missing), and adds full runtime fields.
Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Add varlink context and runtime builders for .swap units:
SwapContext: What, Priority, Options, TimeoutUSec, ExecActivate, ExecDeactivate
SwapRuntime: ControlPID, Result, CleanResult, UID, GID (SwapResult enum)
SwapResult is exposed as a proper varlink enum type. Runtime follows the same pattern as MountRuntime (ControlPID, Result, CleanResult, ref UID/GID).
The integration test is conditional since swap units may not be present on all systems.
Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Add varlink context and runtime builders for .scope units:
ScopeContext: OOMPolicy (enum), RuntimeMaxUSec, RuntimeRandomizedExtraUSec, TimeoutStopUSec
ScopeRuntime: Result (ScopeResult enum)
Both OOMPolicy and ScopeResult are exposed as proper varlink enum types.
Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Add varlink context and runtime builders for .path units:
PathContext: Paths (array of PathType enum + path specs), Unit, MakeDirectory, DirectoryMode, TriggerLimit
PathRuntime: Result (PathResult enum)
Both PathType and PathResult are exposed as proper varlink enum types.
Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Let's limit the number of environment variables when creating a
transient unit via StartTransientUnit as well, since validating the
environment variable names/assignments is expensive.
Follow-up for 49c1e1bcf2.
If a unit has active jobs, when it gets serialized there are job
subsections, each with their own empty line marker. The skipping
function ignores this and skips until the marker, but then leaves
the job in place, breaking deserialization.
Consume jobs subsections too.
This shows up now that there's TEST-07-PID1.alias-corruption,
which occasionally fails when the aliased unit happens to
still have a job when the reexec happens.
[ 967.551630] TEST-07-PID1.sh[179]: + echo 'Testing with: systemctl daemon-reexec'
[ 967.551630] TEST-07-PID1.sh[179]: Testing with: systemctl daemon-reexec
[ 968.405274] TEST-07-PID1.sh[179]: + echo '--- Attempt 1/3 ---'
[ 968.405274] TEST-07-PID1.sh[179]: --- Attempt 1/3 ---
[ 968.698641] TEST-07-PID1.sh[179]: + echo 'Running daemon-reexec...'
[ 968.698641] TEST-07-PID1.sh[179]: Running daemon-reexec...
[ 969.130261] TEST-07-PID1.sh[179]: + echo 'legit.service PID remains 1282. Attempt 1 passed.'
[ 969.130261] TEST-07-PID1.sh[179]: legit.service PID remains 1282. Attempt 1 passed.
[ 970.870456] TEST-07-PID1.sh[179]: + echo '--- Attempt 2/3 ---'
[ 970.870456] TEST-07-PID1.sh[179]: --- Attempt 2/3 ---
[ 971.267205] TEST-07-PID1.sh[179]: + echo 'Running daemon-reexec...'
[ 971.267205] TEST-07-PID1.sh[179]: Running daemon-reexec...
[ 971.715743] TEST-07-PID1.sh[179]: + echo 'legit.service PID changed from 1282 to 1643!'
[ 971.715743] TEST-07-PID1.sh[179]: legit.service PID changed from 1282 to 1643!
Follow-up for a77c7a8224
Follow-up for 0742986650
Add ReloadCount to the io.systemd.Metrics family table so it can be
queried alongside other manager-level metrics via systemd-report.
Also extend the existing integration test to cross-check the value
returned by systemd-report against the D-Bus and Varlink transports
on every assertion.
Co-developed-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Simon Lucido <simonlucido@meta.com>
On initrds where nfnetlink module is missing, trying to open
a NETLINK_NETFILTER netlink socket takes a lot of time then fails.
This makes boot noticibly slower. Even though probably no
unit in an initrd need netfilter.
So here we delay opening the socket until we know we need it.
Let's cap the number of states/patterns per query to something
reasonable, i.e. max 256 states and 4K patterns per query.
---
Also, use the env assignments limit in a couple more places.
Introduce a counter that tracks how many configuration reloads have
been successfully completed by the manager. The increment lives in
manager_reload() right after the "point of no return", so failed
reload attempts that bail out earlier (e.g. during serialization)
do not bump the counter.
It is accessible as a new ReloadCount property to
org.freedesktop.systemd1.Manager (D-Bus) and ReloadCount to
io.systemd.Manager.Describe (Varlink).
Also add an integration test for ReloadCount
that verifies that the new ReloadCount property increments by one per
daemon-reload, accumulates correctly across multiple reloads, and that
D-Bus and Varlink return identical values. Also tests that the counter
reset after a reexec.
Co-developed-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Simon Lucido <simonlucido@meta.com>
Turns out we can utilize this limit at a couple more places, so let's
move the previously defined limit constant to env-util.h and use it to
guard a couple more D-Bus methods. Also, bump it a bit, given it's meant
to be a safety cap that can't be hit in valid scenarios.
Follow-up for 7671b43cb8.
I am really not a fan of full code lines passed to macros as parameters.
Let's get rid of the 3rd parameter of FOREACH_OPTION() hence:
1. Let's return errors just as a regular value (though a negative one),
that can be handled via a OPTION_ERROR case statement for the switch.
This normalizes handling of the error, just like any other event
returned by the option parser.
2. In order to avoid exploding the amount of boilerplate in each use
(that just propagates the error on OPTION_ERROR), let's then
introduce an explicit FOREACH_OPTION_OR_RETURN(), that returns from
the calling function on its own (and makes that clear in the name).
Together this cleans up, normalizes the logic and shortens the code.
This commit adds a simple version of io.systemd.Unit.StartTransient
for varlink. It is similar to the dbus version, but there is a key
difference:
1. Instead of building the unit from key/value properties it
takes a structured json object "UnitContext" with a "Service" field
inside.
It is also only implementing a minimal set of what can be done with a
service.
2. No aux units (for now)
3. When called with --more the varlink socket can notify about
state changes depending on the notify{Job,Unit}Changes parameter
This aligns to the json objects/format from
https://github.com/systemd/systemd/pull/39391
and to show how the format can be shared it adds a new
(minimal) `ServiceContext` that is now part of
`io.systemd.Unit.List()`.
Until very recently all types defined by execute.h started with "Exec"
in the name. I think that was useful, since it made clear that the types
are associated with the ExecContext infrastructure. Let's hence restore
this.
(If we every move these types out of execute.h we should drop the "Exec"
prefix again. But today is not that day.)
No real code changes, just dumb renaming.
When a job reaches the job_uninstall() stage we used to set the
state to JOB_WAITING. However now that we have a JOB_FINISHED state [1]
we should use that instead. This is more accurate so when
varlink_job_send_removed_signal() is called the job is in the expected
state and that is what the user will see.
Note that this does not change the D-Bus API because there
bus_job_send_removed_signal() doesn't send the state, it only sends
the result.
[1] https://github.com/systemd/systemd/pull/41811
This commit adds a simple version of io.systemd.Unit.StartTransient
for varlink. It is similar to the dbus version, but there is a key
difference:
1. Instead of building the unit from key/value properties it
takes a json object in the "service" parameter. It is also
only implementing a minimal set of what can be done with a
service (for now)
2. No aux units (for now)
3. When called with --more the varlink socket can notify about
unit job and state changes controlled via a bool on the
varlink call inputs: notify{Job,Unit}Changes
We use the new io.systemd.Job interface when outputing the
io.systemd.Unit.StartTransient result as it makes the output
nice and mirrors the input.
Note that the property names follow the D-Bus naming to make a
future "systemctl show" transition from D-Bus -> varlink easier.
Because UnitContext is now also used for the inputs we need
to make a bunch of fields `SD_VARLINK_NULLABLE` so that the
input is even accepted. This does not affect the output, it
is still fully populated, just the schema. The ID of UnitContext
is still required.
Thanks to ikruglov and Lennart for their excellent feedback on
this.
Extract the transient unit setup logic from transient_unit_from_message()
in dbus-manager.c into a shared helper.
This prepares for reuse by the varlink StartTransient implementation.
It was requested to make the 'c', 'opt', and 'arg' params the same, i.e.
defined through the FOREACH_OPTION macro. But we can't do that easily,
because 'c' was defined in the for loop definition, and we can only
define variables of the same type in that way. Also, in some cases we
need only 'c', in other cases with need 'c' and 'arg, in some cases 'c'
and 'opt', and in other cases all three. We'd need to either
conditionalize or mark those variables with _unused_ to deal with
compiler warnings. But a different approach works quite nicely: add
state.opt and state.arg to show the current option and it's argument.
(The short names are picked on purpose to reduce verbosity since those
are used a lot.)
So far when a job completed we'd never transition into any new state,
we'd just do some final processing work (such as notifying clients) and
destroy it.
Let's change that, and briefly enter a final state: "finished". This is
useful so that code that notifies clients can generically send the
quadruplet of id, type, state, result for any change notification and
naturally can communicate job completion that way: by setting the state
field to "finished".
Prompted by the discussions in: #41583
This changes the .result field to invalid initially, which arguably
makes more sense than "done", which was previously the default.
This is a correctnes fix, and afaics has no effect on the API, since we
do not expose this 1:1 as D-Bus property: it's only seen on D-Bus as
part of the job completion signal, at which part it is correctly
initialized.
Noticed while reviewing: https://github.com/systemd/systemd/pull/41583
Every dlopen_xxx() helper now takes an int log_level argument. It is
passed through to dlopen_many_sym_or_warn() (which in turn propagates it
to dlopen_verbose() for the library-not-installed case), and is used by
the fallback stub when support for the library is not compiled in to
emit a "<lib> support is not compiled in." message at the caller's level.
Callers pass LOG_DEBUG when gracefully degrading, or a higher level when
the failure should surface, and no longer need to log redundantly at the
call site.
As part of this, dlopen_bpf_full() (which already took a log_level) is
merged into dlopen_bpf() rather than keeping both.
The static inline fallbacks used to live in the headers, which required
pulling log.h in from every header that declared a dlopen_xxx(). Move
them into the .c files instead: the declaration is always outside the
#if HAVE_XXX block, the impl sits outside the outer #if HAVE_XXX wrap
with its own internal #if HAVE_XXX/#else/#endif, and apparmor-util.c,
idn-util.c, libmount-util.c and pam-util.c are now always compiled so
they can host their stubs.
The six .bpf.c files and their shared .bpf.h headers now live directly
under src/bpf/, rather than scattered across src/core/bpf/<prog>/,
src/network/bpf/<prog>/ and src/nsresourced/bpf/<prog>/.
All BPF compilation logic — the BPF_FRAMEWORK determination (clang/gcc/
bpftool/llvm-strip lookups), flag and command construction, vmlinux.h
handling, the bpf_programs list and the loop that builds the unstripped
object, the stripped object and the skeleton header for each program —
moves into src/bpf/meson.build. The top-level meson.build only keeps
option handling and the libbpf dependency. subdir('src/bpf') is pulled
up before config.h generation so that BPF_FRAMEWORK, HAVE_VMLINUX_H and
ENABLE_SYSCTL_BPF land in conf in time.
Skeleton wrapper headers (<prog>-skel.h) are now emitted by a
configure_file() template (src/bpf/bpf-skel-wrapper.h.in) at meson
setup, replacing the previously checked-in shims of the same name.
Consumer #include paths are flattened:
"bpf/<prog>/<prog>-skel.h" becomes "<prog>-skel.h",
"bpf/<prog>/<prog>-api.bpf.h" becomes "<prog>-api.bpf.h". src/bpf is
added to includes so the shared BPF headers resolve.
sysctl-write-event.h, now shared between userspace (networkd-sysctl.c)
and BPF (sysctl-monitor.bpf.c) from a single location, gains guarded
includes so pid_t and uint64_t resolve on both sides: vmlinux.h in the
BPF case (selected via __bpf__), stdint.h + sys/types.h otherwise.
Split the single directory fd that chaseat() used to take into two separate
fds: a root_fd that sets the chroot boundary (symlinks may not escape it,
absolute symlinks resolve relative to it), and a dir_fd that path resolution
starts from. This makes the chroot semantics of chaseat() explicit at every
call site instead of encoding them in the CHASE_AT_RESOLVE_IN_ROOT flag,
which is removed. It also decouples the starting directory from the root
boundary, so callers can descend from any inode inside the tree without
having to reopen the root separately.
XAT_FDROOT passed as root_fd means "no containment" (host root); as dir_fd
it means "start at root_fd". For a smoother transition, AT_FDCWD is also
accepted as root_fd and treated as XAT_FDROOT. When root_fd points to a
directory that is actually the host root, it is normalized to XAT_FDROOT
up front so the existing shortcut path can kick in.
Absolute paths returned by chaseat() are now relative to root_fd, and
relative paths are relative to dir_fd. The result is absolute only when
there is no chroot boundary (root_fd is XAT_FDROOT), or when an absolute
symlink made resolution jump out of the dir_fd subtree; otherwise callers
get a relative path they can feed straight back into an openat()-style
call against dir_fd. Specifically, when dir_fd == root_fd and we're not
operating on the host's root directory, we return a relative path even if
we received an absolute path or resolved an absolute symlink to allow
passing the path directly to openat() style functions. We do this to not
have to go modify every caller of chaseat() to make sure they deal properly
with any absolute paths they might receive. Only when root_fd != dir_fd do
we have to return an absolute path to indicate that the path is relative to
root_fd and not dir_fd.
The shortcut that skips the per-component walk is reworked around a new
chase_xopenat() helper that funnels CHASE_NOFOLLOW, CHASE_MUST_BE_* and
CHASE_TRIGGER_AUTOFS through xopenat_full()'s O_NOFOLLOW, O_DIRECTORY,
XO_REGULAR, XO_SOCKET and XO_TRIGGER_AUTOMOUNT flags. As a result these
flags no longer force us off the shortcut and can be dropped from
CHASE_NO_SHORTCUT_MASK, and the old openat_opath_with_automount() helper
goes away. A CHASE_MUST_BE_ANY alias is introduced for shortcut callers
(stat/access paths) that don't go through xopenat_full() and still need
to bail on those flags locally.
All *_and_* helpers built on top of chaseat() (chase_and_openat,
chase_and_opendirat, chase_and_statat, chase_and_accessat,
chase_and_fopenat_unlocked, chase_and_unlinkat, chase_and_open_parent_at)
gain the same root_fd parameter, and every call site in the tree is
ported to the new signature.
chase_and_open() is also fixed to correctly handle CHASE_EXTRACT_FILENAME
without CHASE_PARENT.
fd_is_fs_type() returns < 0 for errors, 0 for false, and > 0 for true, so
in the r > branch we'd most likely report EPERM together with the error
message which is misleading.