Skip to content

fix(fleet): the v2 agent's shell injection and fail-open auth, which is what the Pi runs - #62

Open
zemo-g wants to merge 2 commits into
masterfrom
security/fleet-v2-logs-jobs-injection
Open

zemo-g wants to merge 2 commits into
masterfrom
security/fleet-v2-logs-jobs-injection

Conversation

@zemo-g

@zemo-g zemo-g commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Two fixes to fleet_agent.rail, the v2 agent. v3 got both of these; v2
never did, and v2 is what runs on the Pi, because v3 cannot be built there
at all (cross-compile duplicate symbols plus 416 MB of RAM). The node that
could not take the fixes was the one left holding the bugs.

1. Shell injection in /logs/<svc> and /jobs/<id>

710cd72 fixed this in fleet_agent_v3.rail and did not touch this file,
which has the identical flaw: svc is interpolated straight into a shell
command, and id three times over.

Same allowlist as v3 (letters, digits, ., _, -), which excludes every
shell metacharacter, whitespace and /. A denylist would miss *, ?,
~, quotes and =.

2. Auth failed OPEN, and the Pi's token had drifted

Three faults had to line up, and the third was invisible until the first two
were fixed.

  • Rail's Linux runtime dropped envp, so shell() had no HOME and ~
    would not expand under dash, and load_token returned empty. That is
    fixed at the root in fix(linux): _start never passed envp, and getenv could not even link #60; this branch also makes load_token fall back to
    the passwd home directory so it works regardless.
  • check_auth read if length token == 0 then true: fail OPEN. v3 was
    hardened to deny in 519a18b. Now fails closed.
  • The Pi's fleet token had drifted. mini, studio and air share one
    token; the Pi alone held a different one after a rotation missed it.
    Fail-open meant the Pi accepted the wrong token anyway, so the drift was
    invisible for exactly as long as the bug existed. Synced separately.

The two code changes are only safe together: fail-closed alone denies every
request, which is what happened when it was tried first and took the Pi's
control plane down until it was rolled back.

Verification

Asked all four nodes, before and after:

valid /logs /logs/a;true wrong token no token
before 200 200 200 (Pi) 200 (Pi)
after 200 400 403 403

Deployed to mini, studio, air and pi, previous binaries kept alongside.

Note on method

"Is it patched" was first answered wrongly by grepping the compiled binary
for the allowlist literal. Rail does not store string literals greppably; a
control search could not find forbidden or X-Fleet-Token either. Only a
behavioural probe settles it.

zemo-g and others added 2 commits August 28, 2026 22:36
…i runs

710cd72 fixed this shell injection in fleet_agent_v3.rail. It did not touch
fleet_agent.rail, the v2 agent, which has the identical flaw: /logs/<svc>
interpolates svc straight into a shell command, and /jobs/<id> interpolates
id three times.

v2 is what runs on the Pi, because v3 cannot be built there at all
(cross-compile duplicate symbols plus 416 MB of RAM). So the node that could
not take the fix was the one left holding the bug, and it stayed that way
for two months while the fix sat unpushed on a branch.

Same allowlist as v3: letters, digits, '.', '_', '-', which excludes every
shell metacharacter, whitespace and '/'. A denylist would miss '*', '?',
'~', quotes and '='.

Verified by asking each node before and after. Every one of mini, studio,
air and pi answered 200 to /logs/a;true beforehand; all four now answer 400,
and a legitimate service name still answers 200. The v3 binary was rebuilt
and deployed to the three Macs and this v2 build to the Pi, each with the
previous binary kept alongside.

Worth recording how the "is it vulnerable" question was first answered
WRONGLY: by grepping the compiled binary for the allowlist literal. Rail
does not store string literals in a greppable form, and a control search
proved it could not find "forbidden" or "X-Fleet-Token" either. The
detection method was broken, not the binary. Only a behavioural probe
settled it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
The Pi's fleet control plane accepted a wrong token and no token alike. Two
independent faults had to line up, and a third hid the result.

1. Rail's Linux runtime does not propagate envp. Measured with a probe:
   shell() on the Pi sees ONE environment variable and no HOME, while the
   same probe on macOS sees 43. The macOS prologue takes envp from x2, the
   main(argc,argv,envp) convention, which a static Linux binary with its own
   _start never receives. So `~` does not expand and load_token returned "".
2. v2's check_auth read `if length token == 0 then true`: fail OPEN. v3 was
   hardened to deny in 519a18b; v2 never was, and v2 is what the Pi runs.
3. Empty token plus fail-open meant every request was allowed, which is
   also why nobody noticed that the Pi's token had drifted away from the
   fleet's during a rotation. mini, studio and air all share ad640cd6…; the
   Pi alone held 02538e41… . Fail-open made the drift invisible.

load_token now falls back to the passwd home directory when `~` yields
nothing. `~` stays first because it works on macOS and never reaches the
fallback there, and getent does not exist on macOS. No ${...} braces: Rail
treats { } inside a string as interpolation, which mangled a first attempt.
check_auth now fails closed. Both changes are required together; either
alone leaves the plane open or shut.

Verified on all four nodes: correct token 200, wrong token 403, no token
403, the old drifted token 403, and the injection guard still 400.

Three false conclusions on the way, all from testing the wrong thing:
grepping a Rail binary for a string literal (they are not stored greppably,
proven by a control search); verifying the shell form under `env -i /bin/sh`
rather than under Rail's own shell(); and finally reading 403 as "the fix is
broken" when the Pi was correctly rejecting the MINI's token, which is what
the request bytes showed once I looked at them instead of inferring. The fix
had been right for two deploys before I could see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzjMXh1wxgasCH81MJfTCk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant