banger/docs/privileges.md
Thales Maciel 182bccf8af
roothelper: pin bridge name + IP + CIDR to a banger-managed shape
priv.ensure_bridge / priv.create_tap accepted the daemon's network
config triple (BridgeName, BridgeIP, CIDR) and forwarded it straight
to `ip link` / `ip addr` / `ip link set master`. Argv-style exec
ruled out shell injection, but the kernel happily honours those
commands against any iface a compromised owner-uid daemon names —
including eth0/docker0/lo. Concretely:

  * priv.ensure_bridge could `ip link set <iface> up` against any
    host interface and `ip addr add` arbitrary IP/CIDR to it.
  * priv.create_tap could `ip link set <new-tap> master <iface>`,
    bridging the per-VM tap into the host's primary LAN so the
    guest sees host-local broadcast traffic.
  * priv.sync_resolver_routing / priv.clear_resolver_routing only
    enforced "name shaped like a Linux iface" — no banger constraint.

New validators (single chokepoint via validateNetworkConfig):
  * validateBangerBridgeName: name must equal "br-fc" or start with
    "br-fc-". Stops a compromised daemon from naming any host iface
    in these RPCs. Users with a custom bridge keep the prefix.
  * validateCIDRPrefix: numeric in [8, 32]. Wider prefixes would
    silently widen the bridge subnet beyond what the daemon intends.
  * validateNetworkConfig bundles bridge-name + validateIPv4 +
    validateCIDRPrefix so every helper RPC that takes the triple
    stays in lockstep.

Wired into methodEnsureBridge, methodCreateTap, and the resolver-
routing pair (replacing the older validateLinuxIfaceName-only check
with the stricter banger-bridge check).

docs/privileges.md updated: the helper-RPC table rows now spell out
the banger-managed bridge constraint, and the trust list includes
the new validators.

Tests: TestValidateBangerBridgeName (default + suffixed accepted,
host ifaces / wrong prefix / oversized rejected), TestValidate
CIDRPrefix (boundary + non-numeric + IPv6-style 64 rejected),
TestValidateNetworkConfig (happy path + each-field-bad cases).
Smoke at JOBS=4 still green — banger's defaults sail through the
new gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:19:28 -03:00

17 KiB

Privileges

This document describes exactly what banger does with the privileges it asks for, what runs where, and how to undo it. The aim is to give a reader enough information to grant — or refuse — the privileges with their eyes open.

Two services, two trust boundaries

banger system install lays down two systemd units:

Unit User Socket Purpose
bangerd.service owner user (chosen at install) /run/banger/bangerd.sock (0600, owner) Orchestration: VM/image lifecycle, store, RPC to the CLI.
bangerd-root.service root /run/banger-root/bangerd-root.sock (0600, owner; root-owned dir at 0711) Narrow root helper: bridge/tap, DM snapshots, NAT, Firecracker launch.

The owner daemon does all the business logic. It never runs as root. The root helper runs as root but only accepts a fixed list of operations and rejects every input that isn't a banger-managed path or name.

The CLI (banger ...) talks to the owner daemon. The owner daemon talks to the root helper for the handful of things only root can do. Users and CI scripts never call the root helper directly.

Why two daemons

Before this split the owner daemon shelled sudo for every device or network operation. That meant the user's sudo config gated daily work, and an attacker who compromised the owner daemon inherited arbitrary sudo reach. After the split, the owner daemon has no ambient root. The only way for it to make a privileged change is to ask the helper, and the helper only honours requests that fit a specific shape.

Authentication

The root helper:

  • Listens on a Unix socket at /run/banger-root/bangerd-root.sock, mode 0600, owned by the registered owner UID, in a root-owned runtime dir at 0711.
  • Reads SO_PEERCRED on every accepted connection and rejects any caller whose UID is not 0 or the owner UID recorded in /etc/banger/install.toml. The match is by UID, not username.
  • Decodes one JSON request per connection and dispatches it through a named-method switch. Unknown methods return unknown_method.

The owner daemon:

  • Listens on /run/banger/bangerd.sock, mode 0600, owned by the install-time owner user. Other host users cannot connect.
  • Reads SO_PEERCRED on every accepted connection and rejects any caller whose UID is not 0 or the install-time owner UID. The filesystem perms already gate access; the peer-cred read is belt-and-braces in case the socket FD is ever leaked to a non-owner process.
  • Resolves the helper socket path from the install metadata and retries with backoff if the helper hasn't started yet.

There is no network listener. Every banger control surface is a Unix socket on the local host.

What the root helper will do, exactly

The helper exposes a fixed list of RPC methods (see internal/roothelper/roothelper.go for the canonical set). Each is shaped so the owner daemon can name a banger-managed object but cannot pass an arbitrary host path or interface name. Every input that names a path, device, PID, or interface is checked against a validator before the helper touches the host.

Method Effect Validation gate
priv.ensure_bridge Create the configured Linux bridge if missing; assign the bridge IP. Bridge name must equal br-fc or start with br-fc- (so a compromised daemon can't drive ip link against eth0 / docker0 / lo). Bridge IP must parse as IPv4. CIDR prefix must be a number in [8, 32].
priv.create_tap ip link add tap NAME tuntap and add to bridge, owned by the owner user. Tap name must match tap-fc-* or tap-pool-*. Bridge config (name + IP + CIDR) passes the same banger-managed check as priv.ensure_bridge, otherwise the new tap could be master-attached to an arbitrary host iface.
priv.delete_tap ip link del NAME. Same prefix check on the tap name.
priv.sync_resolver_routing resolvectl dns/domain/default-route on the configured bridge. Bridge name must equal br-fc or start with br-fc- (same banger-managed check). Resolver address must parse via net.ParseIP.
priv.clear_resolver_routing resolvectl revert on the bridge. Same banger-managed bridge-name check.
priv.ensure_nat iptables -t nat MASQUERADE for (guest_ip, tap) plus matching FORWARD rules; enable=false removes them. Tap must be banger-prefixed. Guest IP must parse as IPv4.
priv.create_dm_snapshot Create a dmsetup device-mapper snapshot from rootfs.ext4 with COW backing file. Both paths must be inside /var/lib/banger; DM name must start with fc-rootfs-.
priv.cleanup_dm_snapshot dmsetup remove and losetup -d for a snapshot the helper itself just created. Every non-empty dmsnap.Handles field is checked: DM name fc-rootfs-*, DM device /dev/mapper/fc-rootfs-*, loops /dev/loopN.
priv.remove_dm_snapshot dmsetup remove by target. Target must be either a fc-rootfs-* name or a /dev/mapper/fc-rootfs-* path.
priv.fsck_snapshot e2fsck -fy against the DM device. DM device path must match /dev/mapper/fc-rootfs-*. Exit 1 (filesystem cleaned) is tolerated.
priv.read_ext4_file Read a file from inside an ext4 image via debugfs cat. Image path must be inside /var/lib/banger or a managed DM device. Guest path is rejected if it contains debugfs-hostile chars ("/\/newline).
priv.write_ext4_files Batch write files into an ext4 image, root:root, mode-controlled. Same image-path validator.
priv.resolve_firecracker_binary Stat and return the firecracker binary path. Path is opened with O_PATH | O_NOFOLLOW (refusing symlinks) and Fstat'd through the resulting fd: must be a regular file, executable, root-owned, not group/world-writable.
priv.launch_firecracker Start the firecracker process for a VM (jailer-wrapped). Socket and vsock paths must be inside /run/banger. Log/metrics/kernel/initrd paths must be inside /var/lib/banger. Tap name must be banger-prefixed. Drives must be inside the state dir or be a /dev/mapper/fc-rootfs-* device. Jailer chroot base must be inside the system state/runtime dirs; jailer UID/GID must equal the registered owner. Binary must pass the same root-owned-executable check.
priv.ensure_socket_access chown and chmod 0600 on a firecracker API or vsock socket so the owner user can talk to it. Path must be inside /run/banger and not a symlink. The helper opens it with O_PATH | O_NOFOLLOW, refuses anything that isn't a unix socket, and chmod/chown via the resulting fd (no symlink-follow). The local-priv fallback uses chown -h.
priv.cleanup_jailer_chroot Detach every mount under the per-VM jailer chroot via direct umount2(MNT_DETACH | UMOUNT_NOFOLLOW) syscalls (deepest-first), then rm -rf the tree. Path must be inside the system state/runtime dirs and not a symlink — including no symlinks at intermediate components (resolved with EvalSymlinks and re-checked). UMOUNT_NOFOLLOW makes the unmounts symlink-safe even if a path is swapped after validation. A findmnt guard refuses to rm -rf if any mount remains underneath.
priv.find_firecracker_pid Resolve a firecracker PID by API socket path. Filters to processes whose cmdline mentions the requested API socket.
priv.kill_process / priv.signal_process Send SIGKILL or a named signal to a PID. PID must refer to a running process whose /proc/<pid>/cmdline mentions firecracker.
priv.process_running Check whether a PID is alive (no host mutation). Read-only; same cmdline filter.

Anything outside this list returns unknown_method and is logged. The helper does not run a shell, does not exec helper scripts, and does not accept commands as strings.

Filesystem mutations

Path used Owner What is created or changed
/etc/banger/install.toml root, 0644 Written once by banger system install. Holds owner UID/GID/home, install timestamp, version. Read by both daemons at startup.
/etc/systemd/system/bangerd.service root, 0644 Owner-daemon unit. Contents are deterministic; see below.
/etc/systemd/system/bangerd-root.service root, 0644 Root-helper unit.
/usr/local/bin/banger root, 0755 Copy of the build output.
/usr/local/bin/bangerd root, 0755 Same binary, second name.
/usr/local/lib/banger/banger-vsock-agent root, 0755 Companion agent injected into guests at image-pull time.
/var/lib/banger/... owner (via systemd StateDirectory=banger), 0700 Image artifacts, VM dirs, work disks, kernels, OCI cache, SSH key + known_hosts.
/var/cache/banger/... owner, 0700 Bundle and OCI download cache.
/run/banger/... owner, 0700 Owner daemon socket and per-VM firecracker API + vsock sockets.
/run/banger-root/... root, 0711 Root-helper socket dir; the socket itself is 0600.
~/.config/banger/config.toml owner Optional user config. Read by the owner daemon at startup.

Outside these directories, banger does not write to the host filesystem during normal operation. The two exceptions are file-sync (the user explicitly opts in to copying paths from their home into a guest, which the owner daemon validates is inside the owner home before reading) and the install/uninstall actions above.

Why the owner home is locked down

The [[file_sync]] config lets users mirror host files into guests. banger refuses to follow paths that escape the owner home, including through symlinks:

  • ResolveFileSyncHostPath (internal/config/config.go) expands a leading ~/ and rejects any candidate that resolves outside the configured OwnerHomeDir.
  • ResolveExistingFileSyncHostPath re-checks after EvalSymlinks so a symlink inside ~/.aws that points at /etc/shadow cannot leak out.

This means an installed banger never reads outside the owner home in the file-sync path, even if the owner edits config to try.

Network mutations

For each running VM banger creates:

  • One bridge (default banger0, configurable). Created on first VM start, never deleted automatically.
  • One tap interface named tap-fc-<vm_id>. Created on VM start, deleted on VM stop or crash recovery.
  • One iptables MASQUERADE rule per VM, only when --nat was passed. Removed by the symmetric EnsureNAT(enable=false) call at stop.
  • Optionally, resolvectl routing entries that send *.vm lookups to banger's in-process DNS server on the bridge. Reverted at stop.

Banger does not touch UFW, firewalld, or other rule managers. It only edits the iptables tables it created the rules in.

Cleanup and uninstall

Per-VM cleanup happens at:

  • banger vm stop <name> — stops firecracker, removes the per-VM tap, drops the NAT rule, removes the DM snapshot, removes per-VM sockets, leaves the work disk.
  • banger vm delete <name> — same as stop, plus deletes the per-VM state directory under /var/lib/banger/vms/<id> (work disk, metadata).
  • banger vm prune — bulk version.
  • Crash recovery: on daemon start, reconcile runs the same teardown for any VM whose firecracker process is no longer alive.

System-level uninstall:

sudo banger system uninstall          # remove services, units, binaries
sudo banger system uninstall --purge  # also remove /var/lib/banger,
                                      # /var/cache/banger, /run/banger

Without --purge, the state dirs survive so a reinstall can pick up where the previous one left off. With --purge, banger leaves no files behind under /var/lib, /var/cache, or /run.

What uninstall does, in order:

  1. systemctl disable --now bangerd.service bangerd-root.service.
  2. Remove /etc/systemd/system/bangerd.service and bangerd-root.service.
  3. Remove /etc/banger/install.toml and /etc/banger/.
  4. systemctl daemon-reload.
  5. Remove /usr/local/bin/banger, /usr/local/bin/bangerd, /usr/local/lib/banger/.
  6. With --purge only: remove the system state, cache, and runtime dirs.

What uninstall does NOT do automatically:

  • It does not delete the bridge or any iptables rules. Stop your VMs first (banger vm stop --all) so the per-VM teardown drops them. The bridge itself is intentionally persistent — a future reinstall reuses it. To remove it manually: sudo ip link del banger0.
  • It does not undo resolvectl routing on a bridge that no longer exists; the entries are harmless if the bridge is gone.
  • It does not remove the owner user, the owner's home, or anything the user wrote into a guest from inside the guest.

Running outside the system install

Everything above describes the supported deployment: banger system install lays down both systemd units and the helper takes over every privileged operation.

It is also possible to run bangerd directly without installing the helper — the binary still works as a per-user daemon and shells sudo -n for each privileged operation it would otherwise hand off (iptables, ip, mount, mknod, dmsetup, e2fsck, kill, chown -h, chmod, losetup, chown, chmod, firecracker). This mode is intended for ad-hoc developer machines while iterating on banger itself.

It carries a different trust model:

  • It needs NOPASSWD sudoers entries for the developer (otherwise every VM action prompts for a password).
  • Once those entries exist, any process running as the developer can invoke those commands with arbitrary arguments — banger's input validators only constrain what banger itself sends. They are no defence against a different program on the same account.
  • The helper's SO_PEERCRED boundary, the systemd hardening (NoNewPrivileges, ProtectSystem=strict, the narrow CapabilityBoundingSet), and the helper's own input validators are all bypassed.

If you care about isolating banger's blast radius from anything else running as your user, use the system install. If you only need banger to work on your own dev box, the non-system mode is fine — just don't run it on a shared or production host.

Hardening of the systemd units

The two units ship with restrictive defaults; they are written by banger at install time and the contents are deterministic.

Owner daemon (bangerd.service):

  • User= is the install-time owner; never root.
  • NoNewPrivileges=yes.
  • ProtectSystem=strict — system directories are read-only.
  • ProtectHome=read-only — owner home is read-only to the daemon unit. The daemon writes only to StateDirectory, CacheDirectory, RuntimeDirectory, plus owner config that the user edits.
  • ProtectControlGroups, ProtectKernelLogs, ProtectKernelModules, ProtectClock, ProtectHostname, RestrictSUIDSGID, LockPersonality.
  • RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_VSOCK.
  • No AmbientCapabilities.

Root helper (bangerd-root.service):

  • Same hardening as above, plus ProtectHome=yes (no host-home visibility at all from the helper).
  • CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_RAW CAP_SYS_ADMIN. Only the capabilities required for tap/bridge, iptables, dmsetup, loop devices, and Firecracker. No CAP_SYS_BOOT, no CAP_SYS_PTRACE, no CAP_SYS_MODULE, no CAP_NET_BIND_SERVICE.
  • ReadWritePaths=/var/lib/banger.

What this leaves you trusting

If you install banger as root, you are trusting:

  1. The two binaries banger drops under /usr/local/bin and the companion agent under /usr/local/lib/banger. These should match the build artifacts you reviewed.
  2. The path/identifier validators in internal/roothelper/roothelper.go to be tight: validateManagedPath, validateTapName, validateDMName, validateDMDevicePath, validateLoopDevicePath, validateDMRemoveTarget, validateDMSnapshotHandles, validateRootExecutable, validateNotSymlink, validateExt4ImagePath, validateLinuxIfaceName, validateBangerBridgeName, validateNetworkConfig, validateCIDRPrefix, validateIPv4, validateResolverAddr, validateSignalName, and validateFirecrackerPID. If any of these are bypassed, the helper would carry out a privileged op against an unmanaged target. They are unit-tested in internal/roothelper/roothelper_test.go.
  3. The Firecracker binary banger executes. The helper refuses to launch anything that isn't a regular, executable, root-owned, not world-writable file — but the binary's own behaviour is your responsibility.
  4. Your own owner-user account. The owner can ask the helper to create taps, run firecracker, and edit ext4 images under /var/lib/banger. Anyone with the owner's UID can do those things; treat that account as semi-privileged.

What you do not have to trust:

  • The CLI process. It only talks Unix-socket RPC.
  • Other host users. The helper socket is 0600 root and the owner socket is 0700 owner.
  • The contents of the user's home, except the file paths that [[file_sync]] explicitly names — and even those are clamped to the owner home.
  • The guest. Guests cannot reach the helper or the owner daemon; the only host endpoint a guest sees is the in-process DNS server on the bridge IP and the bridge itself for outbound NAT.