Stop the one-line install handing a standard user a way in
The bootstrap command staged its script into C:\Windows\Temp and then ran it elevated. That directory grants Users create-file with CREATOR OWNER full control, so somebody without admin rights can pre-plant the exact path the elevated run is about to execute. It now stages under Program Files, creates the directory with an explicit DACL when it is missing, and refuses to run a staged file that is not owned by an admin principal. A failed hardening is loud rather than silent: the run stops instead of carrying on with a file it could not protect. The token moved out of the query string into a header. In a query string it lands verbatim in the reverse proxy and IIS logs, which is a credential written to a file nobody thinks of as a secret store. The query form is still accepted, because bays provisioned before this change are holding install commands that use it, and it logs that it was used - without logging the token - so a site can see who still needs a re-issued command before turning it off with plugin.endpoints.bootstrapquerytoken. Two smaller things this also closes: A request with no token at all answered 400 naming the header, which told an anonymous caller the state of that switch and contradicted the module's own promise that anything unusable answers 404. No token is now 404. The 400 is kept for a token genuinely presented in the query form while the query form is off, which is a caller who needs to know their command is stale. The rollout documentation was republishing the vulnerable one-liner verbatim, so a tech following the runbook reinstated exactly what this fixes. Both documents now carry the current command. Also corrects the operator-facing copy for the token, which named a query argument and a header that do not exist: it is ?t= and X-Endpoint-Bootstrap-Token, per bootstrap.py's own constants. Anybody following the old text got a 404 with no reason given. Both switches added in this range are now declared with the other plugin settings and reachable from their settings page. Read through Setting.get but never declared, they were rows that did not exist until someone wrote one by hand, which on an air-gapped box means the feature was unreachable.
This commit is contained in:
@@ -791,27 +791,62 @@ One-line install and hands a technician one command, which is run in an
|
||||
ELEVATED PowerShell on a PC with no agent:
|
||||
|
||||
```
|
||||
powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1?t=<token>' -OutFile 'C:\Windows\Temp\shopdb-bootstrap.ps1'; & 'C:\Windows\Temp\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Windows\Temp\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
powershell -ExecutionPolicy Bypass -c "try { New-Item -ItemType Directory -Force -Path 'C:\Program Files\ShopDB\bootstrap' | Out-Null; icacls 'C:\Program Files\ShopDB\bootstrap' /inheritance:r /grant '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' | Out-Null; if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) { Write-Host 'Refusing to run: could not restrict C:\Program Files\ShopDB\bootstrap to administrators (icacls failed). Fix the directory or delete it, then run the command again.' -ForegroundColor Red; exit 6 }; Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue; Invoke-WebRequest -UseBasicParsing '<baseurl>/api/endpoints/siteagent/bootstrap.ps1' -Headers @{'X-Endpoint-Bootstrap-Token'='<token>'} -OutFile 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1'; if ((Get-Acl 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1').GetOwner([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-32-544','S-1-5-18',[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) { Write-Host 'Refusing to run: the staged bootstrap script is not owned by an administrator.' -ForegroundColor Red; exit 5 }; & 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' } finally { Remove-Item 'C:\Program Files\ShopDB\bootstrap\shopdb-bootstrap.ps1' -Force -ErrorAction SilentlyContinue }"
|
||||
```
|
||||
|
||||
Download then run, never `irm ... | iex`: Microsoft Defender scores the
|
||||
piped-to-iex shape as `Trojan:Win32/Commando.A!ml` and kills the process before
|
||||
a line of it runs (reproduced 5 of 5 on a default install, no ASR rules), and
|
||||
every bay runs Defender. The shape of the line is load-bearing too:
|
||||
every bay runs Defender. The rest of the shape is load-bearing too:
|
||||
|
||||
- a literal temp path and no `$` anywhere, because pasting into an
|
||||
already-running PowerShell would otherwise expand `$p` and `$env:TEMP` in the
|
||||
PARENT and hand the child a broken line - as written it means the same in
|
||||
cmd.exe and in PowerShell;
|
||||
- the token rides in the `X-Endpoint-Bootstrap-Token` HEADER. A query string
|
||||
lands verbatim in the IIS and waitress access logs, and a live install token
|
||||
has no business sitting in a log file;
|
||||
- staging is `C:\Program Files\ShopDB\bootstrap`, **not**
|
||||
`C:\Windows\Temp`. That directory grants Users create-file and gives CREATOR
|
||||
OWNER full control of what they create, so any standard user on the bay can
|
||||
pre-plant `C:\Windows\Temp\shopdb-bootstrap.ps1` and wait for a tech to
|
||||
paste the command, which then runs the planted content elevated;
|
||||
- the line re-asserts an admin-only DACL on the directory it creates
|
||||
(`icacls /inheritance:r` granting only the well-known SIDs `S-1-5-32-544` and
|
||||
`S-1-5-18`, never group names, which a rename or a localized install would
|
||||
break), and CHECKS that icacls succeeded: it is a native command, so it
|
||||
reports failure only in an exit code and `$ErrorActionPreference` never
|
||||
applies to it. Exit 6 with a red message, rather than downloading into a
|
||||
directory that still inherits whatever the parent grants;
|
||||
- it refuses (exit 5) to run a staged file owned by anyone but an
|
||||
administrator, `SYSTEM`, or the elevated caller, which is what a pre-planted
|
||||
file looks like;
|
||||
- a literal staging path and no `$` anywhere - including the exit-code read,
|
||||
which is `Get-Variable LASTEXITCODE -ValueOnly`. Pasting into an
|
||||
already-running PowerShell would otherwise expand every `$` in the PARENT and
|
||||
hand the child a broken line. As written it means the same in cmd.exe and in
|
||||
PowerShell;
|
||||
- single quotes for every inner literal, so the outer `-c "..."` is never
|
||||
closed early;
|
||||
- `try`/`finally`, so the dropped file is removed even when the script exits
|
||||
non-zero or the download itself fails. The wrapper's own exit code is the
|
||||
removal's, so read the script's printed result rather than `%ERRORLEVEL%`.
|
||||
|
||||
The same URL is offered in the UI as a **Download .ps1** link, for a tech who
|
||||
would rather save the file and run it. Fetching it does not spend a use either
|
||||
way.
|
||||
The same script is offered in the UI as a **Download .ps1** link, for a tech
|
||||
who would rather save the file and run it. That link is the one caller that
|
||||
cannot send a header, so it uses `?t=<token>&via=download`. Fetching the script
|
||||
does not spend a use either way.
|
||||
|
||||
The `?t=` form without `via=download` is accepted ONLY so a bay provisioned
|
||||
before this change - one still running a command pasted when the query form was
|
||||
the published shape - keeps working. It is deprecated: every such fetch logs a
|
||||
warning naming the caller's IP, and that warning is how the rollout finds bays
|
||||
whose command needs re-issuing. Do not publish it, do not paste it into a new
|
||||
bay, and do not build it by hand. A site that has re-issued all of its live
|
||||
commands turns the query form off with
|
||||
`plugin.endpoints.bootstrapquerytoken` = false, which also disables the
|
||||
Download .ps1 link.
|
||||
|
||||
The key is declared in the endpoints plugin's `get_settings_defaults()`, so it
|
||||
seeds as a boolean row, and it is flipped from Settings > Endpoints > Build
|
||||
site agent > One-line install ("Accept the token in the URL"). It is read
|
||||
through the core settings API, not a route of this plugin's own.
|
||||
|
||||
The token is only an authorization to FETCH the installer. The package it
|
||||
serves carries no shared key, whatever `plugin.endpoints.sharedkeyallowed`
|
||||
@@ -822,26 +857,41 @@ Admin routes (admin role, audited):
|
||||
|
||||
- `POST /api/endpoints/siteagent/bootstrap/tokens` `{"hours": 24, "maxuses": 25}`
|
||||
(`hours` 1-168, `maxuses` 1-500). Returns
|
||||
`{"secret", "command", "scripturl", "token"}`; `scripturl` is the same token
|
||||
URL the command downloads, for the UI's Download .ps1 link.
|
||||
The secret is in the response exactly once; only its sha256 and a 12-character
|
||||
`tokenprefix` are stored.
|
||||
`{"secret", "command", "scripturl", "token"}`. `command` carries the token in
|
||||
the header; `scripturl` is the same route for the UI's Download .ps1 link, in
|
||||
the `?t=<secret>&via=download` form a browser link can actually use. The
|
||||
`via=download` marker is what keeps an admin's own click out of the
|
||||
un-migrated-bay warning count - it is a hint, not authorization, and nothing
|
||||
is granted by it; what matters is that a command generated before this change
|
||||
cannot carry it. The secret is in the response exactly once; only its sha256
|
||||
and a 12-character `tokenprefix` are stored.
|
||||
- `GET /api/endpoints/siteagent/bootstrap/tokens` - prefix, created, expires,
|
||||
`usecount` / `maxuses`, `revokedat`, `lastusedat`, `lastusedip` and a `state`
|
||||
of `active`, `expired`, `exhausted` or `revoked`. Never a secret.
|
||||
- `POST /api/endpoints/siteagent/bootstrap/tokens/<tokenid>/revoke`
|
||||
|
||||
Token-authenticated routes. No login; the token is the whole authorization and
|
||||
travels in the query string as `t`, because a PC with no agent has neither a
|
||||
credential nor a user session to put in a header:
|
||||
Token-authenticated routes. No login; the token is the whole authorization,
|
||||
because a PC with no agent has neither a credential nor a user session. It
|
||||
travels in the `X-Endpoint-Bootstrap-Token` header:
|
||||
|
||||
| Route | Spends a use | Returns |
|
||||
|---|---|---|
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1?t=...` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package?t=...` | yes | the keyless agent package ZIP |
|
||||
| `GET /api/endpoints/siteagent/bootstrap.ps1` | no | `text/plain` PowerShell, generated per token |
|
||||
| `GET /api/endpoints/siteagent/bootstrap/package` | yes | the keyless agent package ZIP |
|
||||
|
||||
Both also accept `?t=<secret>` while
|
||||
`plugin.endpoints.bootstrapquerytoken` is true (the default), for bays
|
||||
provisioned before the header form and for the UI Download .ps1 link. A query
|
||||
token without `via=download` logs a deprecation warning against the caller's
|
||||
IP; the Download link's own fetches log at info instead and are audited as
|
||||
`(query-string token, admin download link)`, so the warning count stays a list
|
||||
of bays to re-issue. With the setting false, a query token answers **400**
|
||||
naming the header.
|
||||
|
||||
Both answer **404** for a token that is unknown, malformed, expired, revoked or
|
||||
exhausted, so the URL confirms nothing about which tokens exist. Both are
|
||||
exhausted, and for a request that presents no token at all - so the URL
|
||||
confirms nothing about which tokens exist, and a bare probe cannot learn
|
||||
whether this site still accepts the query form. Both are
|
||||
rate-limited per IP (fixed window, `ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX` in
|
||||
`ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS`, 429 `RATE_LIMITED` over
|
||||
budget) and both write an audit row against `EndpointBootstrapToken` carrying
|
||||
@@ -851,7 +901,9 @@ retried paste must not burn the download budget.
|
||||
The generated script:
|
||||
|
||||
- refuses to run unelevated (exit 1) and says to reopen PowerShell as
|
||||
administrator;
|
||||
administrator (the wrapper line has already refused with exit 6 if the
|
||||
staging directory could not be locked down, or exit 5 if the staged file was
|
||||
owned by someone else);
|
||||
- refuses when the agent service already exists (exit 2) and says a managed bay
|
||||
should get the agent from GE-Enforce, unless `SHOPDB_BOOTSTRAP_FORCE` is set
|
||||
to anything but `0` in the same session;
|
||||
|
||||
Reference in New Issue
Block a user