fix(security): stop hive-discord.service executing code planted under /tmp (#5435) - #5481
Conversation
… /tmp
hive-discord.service runs `node bot.js` with
WorkingDirectory=/tmp/hive/discord, so the executed file resolves out of a
world-writable parent that is cleared on every reboot. /tmp's sticky bit
prevents deleting or renaming entries owned by someone else, but not creating
new ones, so a local unprivileged user who wins the post-reboot race —
creating /tmp/hive/discord/bot.js before hive-deploy repopulates the checkout
— gets code execution as the service user along with the Discord bot token
from /etc/hive/discord.env. Requires=hive.service orders startup and validates
nothing about who owns the code.
Add bin/hive-checkout-guard.sh as an ExecStartPre. It refuses to start unless
every directory from / down to the checkout is owned by the service user or
root and is not writable by anyone else (a sticky ancestor such as /tmp is
accepted; the leaf is not, since the leaf is where a new bot.js would appear),
and unless bot.js itself exists, is a regular file rather than a symlink, and
is not group- or world-writable.
The guard is a script rather than an inline directive on purpose. The obvious
one-liner
ExecStartPre=/usr/bin/find <dir> -maxdepth 0 -user dev ! -perm /go=w -print -quit
is a no-op: find exits 0 after a successful traversal whether or not anything
matched, so the unsafe case starts the unit anyway.
The checkout stays at /tmp/hive. HIVE_REPO_DIR defaults there, but the literal
path is also hardcoded in bin/hive.sh, bin/kick-agents.sh and the ExecStart of
hive-snapshot.service; relocating it is a deploy-layout change across all of
those rather than a fix to this unit, and existing hosts keep their checkout
where it is. hive-deploy.sh gains an explicit install block for the guard
because both of its sync loops skip files that are not already installed, so a
new helper is otherwise never bootstrapped and an upgraded host would pull a
unit referencing a script it does not have.
PrivateTmp is deliberately not set and ProtectSystem is deliberately not
strict: either would hide or freeze the very checkout the unit runs from and
stop the bot starting. The contract test pins both as absent so a later
hardening pass cannot introduce them. NoNewPrivileges, ProtectSystem=full,
ProtectHome=read-only, PrivateDevices and RestrictSUIDSGID are added.
src/deploy/test_hive_discord_unit_contract.sh executes the guard against real
directory trees and asserts on its exit status, once per attack shape,
including the pre-fix arrangement. Wired into v2-ci.yml alongside the other
deploy contract tests. Verified failing against the unfixed unit (3 failures)
and passing against the fixed one (21 assertions).
Fixes #5435
Signed-off-by: Andrew Anderson <andy@clubanderson.com>
Signed-off-by: Andrew Anderson <andy@clubanderson.com>
|
While scoping this I checked the other seven units in I left it out of this PR deliberately to keep the change disjoint and reviewable, and filed it as #5483 instead. It is lower severity — |
|
[APPROVALNOTIFIER] This PR is APPROVED Approval requirements bypassed by manually added approval. This pull-request has been approved by: The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
…ut (kubestellar#5483) hive-snapshot.service executed /tmp/hive/dashboard/publish-snapshot.sh as User=dev with no ownership guard. /tmp is world-writable and cleared on reboot; the sticky bit does not prevent CREATING /tmp/hive/dashboard/ publish-snapshot.sh in the window before hive-deploy repopulates the checkout, so a local unprivileged user who won that race got code execution as dev on the next timer fire — and the real script reads the GitHub App token from /var/run/hive-metrics/gh-app-token.cache. Same class and same fix as kubestellar#5435 / kubestellar#5481: - ExecStartPre=/usr/local/bin/hive-checkout-guard.sh /tmp/hive/dashboard publish-snapshot.sh — the existing guard generalizes unmodified; its exit status is the assertion, so a violation stops ExecStart. - Hardening pass mirroring hive-discord.service: NoNewPrivileges, ProtectSystem=full, ProtectHome=read-only, PrivateDevices, RestrictSUIDSGID. PrivateTmp and ProtectSystem=strict deliberately absent (both would break execution out of the /tmp checkout) and pinned absent by the contract test. - src/deploy/test_hive_snapshot_unit_contract.sh executes the guard with this unit's argument shape (the entrypoint is the 755 script itself, so the writable-file check is the live one) and pins the unit's directives. The v2-ci.yml step wiring this test in (next to the kubestellar#5435 step) is NOT in this PR: the App push token lacks workflows permission. A maintainer should add it; exact step text is in the PR body. Refs kubestellar#5483 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: sec-check <sec-check@hive.kubestellar.io>
…r /tmp (#5483) hive-snapshot.service ran ExecStart=/tmp/hive/dashboard/publish-snapshot.sh, resolving the executed script out of a world-writable parent that is cleared on every reboot. /tmp's sticky bit prevents replacing entries owned by others but not creating new ones, so a local user who created the path before hive-deploy repopulated the checkout got code execution as the service user. Lower severity than #5435 (Type=oneshot on a timer, not Restart=always; no EnvironmentFile) but not zero: publish-snapshot.sh reads a GitHub App token from /var/run/hive-metrics/gh-app-token.cache and pushes to kubestellar/docs. bin/hive-checkout-guard.sh from #5481 generalizes to this with no changes: it resolves the service user from the running EUID and takes the directory and entrypoints as arguments, so it is wired in as-is via ExecStartPre. ProtectHome is deliberately NOT read-only here, unlike hive-discord.service: publish-snapshot.sh drives git and gh, which write under $HOME. PrivateTmp and ProtectSystem=strict remain absent for the same reason as #5481, plus the DOCS_REPO_DIR clone also lives under /tmp. The new contract test executes the guard against real directory trees and asserts on exit status rather than grepping for ExecStartPre, and is registered individually in v2-ci.yml because an unregistered deploy test never runs. Fixes #5483 Signed-off-by: Andy Anderson <andy@clubanderson.com>
Security Fix
Files claimed:
systemd/hive-discord.service,bin/hive-checkout-guard.sh(new),bin/hive-deploy.sh,src/deploy/test_hive_discord_unit_contract.sh(new),.github/workflows/v2-ci.yml,CHANGELOG.md.hive-discord.servicerunsnode bot.jswithWorkingDirectory=/tmp/hive/discord, so the executed file resolves out of a world-writable parent that is cleared on every reboot./tmp's sticky bit stops a user deleting or renaming entries owned by someone else — it does not stop them creating/tmp/hive/discord/bot.jsin the window after a reboot wipes/tmpand beforehive-deployrepopulates the checkout. The winner of that race gets code execution asdevwith the Discord bot token from/etc/hive/discord.env.Requires=hive.serviceorders startup and validates nothing about who owns the code.The fix
ExecStartPre=/usr/local/bin/hive-checkout-guard.sh /tmp/hive/discord bot.js— refuses to start unless:/down to the checkout is owned by the service user or root and not writable by anyone else. A sticky ancestor such as/tmpis accepted; the leaf is not, because the leaf is where a newbot.jswould appear and sticky only protects entries that already exist.bot.jsexists, is a regular file (not a symlink), and is not group- or world-writable.Plus
NoNewPrivileges,ProtectSystem=full,ProtectHome=read-only,PrivateDevices,RestrictSUIDSGID.Why a script and not an inline directive
The obvious one-liner is a no-op guard:
findexits 0 after a successful traversal whether or not anything matched, so the unsafe case exits 0 too and systemd starts the unit anyway. It would grep green. That is the same shape of dead assertion #5398 found in another contract test, so the guard's exit status is the assertion and the test executes it rather than grepping for it.Why the path did not move
The issue's first recommendation was relocating the checkout. I did not, and the checkout stays at
/tmp/hive.HIVE_REPO_DIRdefaults there, but the literal/tmp/hiveis also hardcoded inbin/hive.sh:27, throughoutbin/kick-agents.sh(including the loop that reinstalls unit files from/tmp/hive/systemd/*), and in theExecStartofhive-snapshot.service. Changing the default alone would leave those pointing at a path nothing pulls into. That is a deploy-layout decision across all of them — which is why the issue was filed as issue-only — not a fix to this unit. The guard closes the code-execution path without moving anything.Upgrade behaviour: existing hosts keep their checkout exactly where it is, so nothing migrates and nothing breaks. The one real upgrade hazard is that
hive-deploy.sh's two sync loops both skip files that are not already installed ([ -f "$dst" ] || continue), so a new helper is never bootstrapped — the same problem that needed an explicit block forhive-baseline-check.sh. Without handling it, an upgraded host would pull a unit whoseExecStartPrepoints at a script it does not have and the bot would fail to start.hive-deploy.shtherefore installs the guard explicitly, beforekick-agents.shreinstalls the units.Two directives deliberately NOT set
PrivateTmp=truewould give the unit a private/tmpnamespace, hiding the very checkoutWorkingDirectorypoints at — the service would fail to start.ProtectSystem=strictwould make/tmpread-only for the same tree. Both are the intuitive "more hardening" choice here and both break the bot, so the contract test pins them as absent to stop a later hardening pass introducing them.Testing
src/deploy/test_hive_discord_unit_contract.shbuilds real directory trees shaped like the deployment (sticky world-writable ancestor standing in for/tmp) and runs the guard against each attack shape, asserting exit status: healthy checkout (must allow), post-reboot window withbot.jsabsent, world-writable leaf, sticky world-writable leaf, world- and group-writable ancestors, world- and group-writablebot.js, symlinkedbot.js, symlinked checkout directory, missing directory.ExecStartPre, noNoNewPrivileges, noProtectSystem).Wired into
v2-ci.ymlnext to the other deploy contract tests —src/deploy/test_*.share registered individually there, so an unregistered test would never run.One portability bug was caught while writing this and is worth noting for anyone reusing the guard: BSD/macOS
stat -f '%Lp'prints only the low three permission bits and drops the sticky bit, while GNUstat -c '%a'includes it. Inferring sticky from the digit count therefore reports every sticky directory as non-sticky and would reject/tmpitself. The guard reads sticky viatest -kinstead.Overlap with #5436
#5436 (for #5434) touches the sibling unit
systemd/ttyd-hive.service— no file overlap. It set no hardening-directive precedent to follow: it changed only a bind address (-i 127.0.0.1) and added noProtect*/NoNewPrivilegeslines, and no unit insystemd/currently carries any hardening directives. I followed its documentation posture instead — an inline comment explaining why the safe default is the default and what to do to change it — and confined the new directives to this unit rather than sweeping all eight, keeping the change reviewable and disjoint. A consistent hardening pass across the remaining units is worth a follow-up but is deliberately not in this PR.Not verified
ExecStartPrepath was not observed refusing a real start. The guard's logic is verified directly, but its integration with systemd is verified only by reading the unit.statfallbacks were exercised on BSDstatlocally; the GNU branch is exercised by CI on Linux, which is the platform that actually matters for these units.ProtectHome=read-onlyis safe as far as the bot's source shows (no writes to$HOMEanywhere indiscord/), but a runtime that writes an npm/cache path under home would notice it.Fixes #5435