mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 08:02:09 +00:00
* fix(ssh): launch the Windows relay outside sshd's job without WMI Win32-OpenSSH kills a session's job on close but allows breakaway. relay.js gains a one-shot launcher mode that starts the detached relay with CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon, so a standard user no longer needs a WMI Remote Enable grant. WMI stays as the fallback for a relay without the addon, and a refusal there is named. The Windows SSH-host lanes drop their WMI grant and assert the breakaway route and adoption. * fix(ssh): find runtime holds without WMI on a standard-user Windows host The store GC read held runtimes through Get-CimInstance Win32_Process, which WMI refuses to a standard user's SSH logon, so the pass kept every runtime. On a refusal it now reads this account's own process image paths through Get-Process. * build(relay): ship the Windows relay launcher addon in every desktop package macOS and Linux packages carried Windows relays without windows-process-tree.node, so a legacy-runtime relay they uploaded to a Windows SSH host could not launch outside sshd's job and fell back to WMI, which a standard user is refused. A reusable Windows job now compiles the x64 and arm64 addons once and uploads them; release-cut, release-mac-build, and the hourly/daily/adhoc mac builds download them before build:release and require both arches. Staging now rejects a binary with the wrong PE machine, the ReadProcessMemory import, or no spawnOutsideJob export, so a stale pre-launcher build cannot ship. * ci(ssh): run the Windows SSH-host lanes when the relay process-tree build scripts change The staging and gyp-rebuild scripts decide which windows-process-tree addon the relay ships, so a change to either must re-prove the Windows host cells. * test(ci): find the mac orcad-template download by artifact name The release mac job now also downloads the relay Windows process-tree addons, so the first download-artifact step is no longer the template's. --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
553 lines
33 KiB
Markdown
553 lines
33 KiB
Markdown
# Windows EDR signal surface
|
|
|
|
Orca's Windows process tree is shaped like the thing behavioural EDR is built to
|
|
find. An enterprise Windows 11 / Intune tenant opened **six Microsoft Defender
|
|
for Endpoint incidents against Orca 1.4.192 in eight days**. All six fired as
|
|
active incidents and stayed open; three closed only because a human classified
|
|
them by hand in the portal. Defender never downgraded or closed one on its own.
|
|
|
|
None were signature hits. Every one was behavioural process-tree scoring, and
|
|
two escalated to multi-stage incidents carrying ATT&CK tactic mappings
|
|
(Execution, Collection).
|
|
|
|
The framing this document keeps throughout, because both halves matter:
|
|
|
|
> **Defender is not malfunctioning. It is describing the code accurately.** Orca
|
|
> really does copy its own signed image under a different name, really did read
|
|
> every process's memory on a timer, really does run base64-encoded PowerShell
|
|
> with the execution policy bypassed, and really does take screenshots and
|
|
> synthesise input from a runtime-compiled assembly. Each of those is a
|
|
> deliberate engineering choice with issue history behind it. The problem is not
|
|
> that the capabilities are illegitimate — it is that their **behavioural
|
|
> signature overlaps with attack techniques**, and an EDR scoring behaviour
|
|
> cannot see the difference.
|
|
|
|
Do not read this as a bug report against Defender, and do not read it as a claim
|
|
that Orca is malware. It is a map of which of our behaviours are legible to an
|
|
EDR as attack-technique-shaped, why each one exists, and what engineers and
|
|
administrators can do about it.
|
|
|
|
## What the tenant actually saw
|
|
|
|
Four independent evidence clusters, from six incidents:
|
|
|
|
| Cluster | Incidents | Evidence |
|
|
| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
|
|
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
|
|
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
|
|
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
|
|
|
|
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
|
|
**Execution + Collection**, and a description reading _"Screenshots were taken
|
|
unexpectedly on this device… Screen capture code was found in a script launched
|
|
by powershell.exe."_ Incident F added _"suspicious MSIL code"_, from the
|
|
`Add-Type -TypeDefinition` that recompiles inline C# P/Invoke on every
|
|
operation.
|
|
|
|
In the update cluster the uninstaller is genuinely `NotSigned`, while `Orca.exe`
|
|
and `orca-terminal-daemon.exe` report `Valid CN=SignPath Foundation`.
|
|
|
|
## The behaviours, and why each one exists
|
|
|
|
### The daemon runs from a copy of our own image
|
|
|
|
`src/main/daemon/daemon-host-relocation.ts` copies the Electron runtime into
|
|
`%LOCALAPPDATA%\Orca\daemon-host\<version>\` and forks the terminal daemon from
|
|
there.
|
|
|
|
It exists because the NSIS installer deletes the old install directory and force-
|
|
kills every process imaged under it. Without relocation, an auto-update kills the
|
|
terminal daemon and every live terminal with it. The copy is a run-as-node
|
|
`Orca.exe` rather than `node.exe` so there is no console flash and asar still
|
|
resolves; `config/nsis/orca-installer-hooks.nsh` reaps it on a real uninstall
|
|
(guarded by `${isUpdated}` so an update's `uninstallOldVersion` never fires it).
|
|
|
|
**At the time of these incidents the copy was also renamed** to
|
|
`orca-terminal-daemon.exe`, the image name every incident here reports, and
|
|
`DAEMON_HOST_EXE_NAME`'s comment stated the reason without varnish: _"so the NSIS
|
|
updater's `taskkill /IM Orca.exe` can't match it."_ The rename has since been
|
|
removed; the copy now keeps the app exe's own file name, because the updater's
|
|
kill sweep is path-scoped on every host that has PowerShell and the rename only
|
|
ever bought the no-PowerShell fallback. See
|
|
[`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md).
|
|
|
|
**How an EDR reads it: MITRE T1036, masquerading** — and, for what remains,
|
|
**T1036.005**. A signed executable copied out of the install directory into
|
|
`%LOCALAPPDATA%` under a different name, which then spawns shells, matches the
|
|
textbook description closely enough that no behavioural engine can be expected to
|
|
score it low. Dropping the rename removes that literal indicator but not the
|
|
underlying shape: execution from a non-standard user-writable location is scored
|
|
on its own. Note also that the strongest form of the T1036 signal was never
|
|
present here — the shipped binary's `OriginalFilename` is empty, so there was no
|
|
embedded name for the old disk name to contradict.
|
|
|
|
### Every process gets a handle, on a timer
|
|
|
|
`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one
|
|
of **two** flag sets: identity (`None | CreationTime`) for callers that read only
|
|
pid, ppid and name, and detailed (`+ CommandLine`) for callers that match on a
|
|
command line. pid, ppid and name come out of the snapshot itself and open
|
|
nothing, so an identity scan opens nothing at all. `CommandLine` is what opens a
|
|
handle: the addon calls `GetProcessCommandLine` per process, which opens
|
|
`PROCESS_QUERY_LIMITED_INFORMATION` — the same right Task Manager takes — and
|
|
asks the kernel for the string. Upstream it opened
|
|
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walked the PEB with three
|
|
`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored
|
|
`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches).
|
|
|
|
`Memory` is retired as of this change, and that is a real reduction: it made
|
|
`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION |
|
|
PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose
|
|
result no caller read (`src/process.cc:47-63`). Dropping it halves the handles
|
|
opened per snapshot. On its own it removed no memory read — both handles carried
|
|
`PROCESS_VM_READ` at the time — so it composes with the patch below rather than
|
|
substituting for it.
|
|
|
|
It exists because seven independent readers used to fork `powershell.exe` for a
|
|
`Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell
|
|
Transcription policy recorded **~289 GB across 1.4 million files** because a scan
|
|
ran every ~2 seconds (#15209); a Group Policy or AV block turned a query into
|
|
"unavailable", which callers read as "no evidence", which is how a PTY tree
|
|
survived its own teardown (#9045, #10475); and the scan cost ~700 ms per pane, so
|
|
panes multiplied it (#15036). The native snapshot answers the same question in
|
|
15.9 ms against 706 ms for CIM — p50, measured on Windows 11 at 1050 processes.
|
|
See
|
|
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
|
|
|
|
Asking for fewer fields is cheaper, and each caller now asks for the smallest set
|
|
that answers it. There are exactly **two** TTL-cached snapshots, one per flag
|
|
set, never one per caller: the fan-out the cache exists to remove is one scan per
|
|
_caller_, and each reader still serves every caller wanting its flag set, so a
|
|
32-wide teardown still collapses into one scan of each. Teardown identity and the
|
|
owner probe select the identity set and therefore open no handles; the per-pane
|
|
foreground tracker genuinely needs a command line and still pays for one. A third
|
|
cache would need a third flag set, not a third caller. Measured at 492 processes,
|
|
p50: identity 6.3 ms, detailed 12.3 ms — see
|
|
[`windows-process-enumeration.md`](./windows-process-enumeration.md).
|
|
|
|
**How an EDR read it:** a cross-process handle plus a remote memory read against
|
|
every process on the box, repeating on a cadence, is the read half of the
|
|
telemetry that credential dumping and process injection produce. MDE surfaced it
|
|
as "suspicious memory activity".
|
|
|
|
**The memory read is gone.** A fourth hunk in
|
|
`config/patches/@vscode__windows-process-tree@0.8.0.patch` has
|
|
`GetProcessCommandLine` call `NtQueryInformationProcess` with
|
|
`ProcessCommandLineInformation` (class 60, Windows 8.1+; Electron's floor is
|
|
Windows 10), which returns a `UNICODE_STRING` the kernel builds and needs only
|
|
`PROCESS_QUERY_LIMITED_INFORMATION`. Measured on ~540 processes, per detailed
|
|
scan: `ReadProcessMemory` 1128 → **0**, desired access `0x0410` → `0x1000`, with
|
|
byte-identical command lines on every process both readers recovered. There is no
|
|
PEB fallback to reinstate it — a hooked `ntdll` answering
|
|
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
|
|
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
|
|
|
|
Because the property is the _absence_ of an import, it is checkable on the
|
|
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
|
|
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
|
|
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
|
|
bearing because the published tarball ships a _loadable_ prebuilt built from
|
|
unpatched source, so "it required cleanly" is not evidence.
|
|
|
|
What to declare to administrators is now one
|
|
`PROCESS_QUERY_LIMITED_INFORMATION` handle per process on a detailed snapshot and
|
|
no remote memory access at all; an identity snapshot opens nothing. What this
|
|
does not narrow is _which_ processes are asked — a detailed scan still queries
|
|
every pid, including `lsass.exe`. Restricting the command-line pass to Orca's own
|
|
subtree needs job-object membership as its source of truth (a ppid-derived
|
|
allowlist would miss the detached, reparented descendants of #9045 and #10475),
|
|
and remains unclaimed work.
|
|
|
|
### Encoded, policy-bypassing PowerShell
|
|
|
|
Three sites are named in the incident analysis:
|
|
|
|
- `src/relay/windows-port-scan.ts` ran
|
|
`-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand` over a
|
|
`Get-NetTCPConnection -State Listen` script to find dev-server ports.
|
|
Enumerating listening ports is **MITRE T1049**, network service discovery, and
|
|
doing it through an encoded policy-bypassed shell is the aggravating factor
|
|
rather than the finding itself. The ordinary scan now starts no PowerShell at
|
|
all — `netstat.exe -ano`, with the owning process name projected off the shared
|
|
native table — and that payload survives only as the last-resort fallback, as
|
|
`-Command` with no policy override.
|
|
- `src/main/daemon/shell-ready.ts` uses `-EncodedCommand` for the OSC 133
|
|
bootstrap.
|
|
- `src/main/agent-hooks/windows-powershell-hook-launcher.ts` wraps managed hooks.
|
|
|
|
**No site spells the pair any more.** `src/main/ssh/ssh-remote-powershell.ts`,
|
|
`src/shared/setup-agent-sequencing.ts`,
|
|
`src/shared/windows-cmd-runner-delayed-launch.ts` and
|
|
`src/shared/windows-interactive-login-spawn.ts` each dropped
|
|
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script _files_,
|
|
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
|
|
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
|
|
pattern to copy rather than restoring the switch — the switch loses to a GPO
|
|
scope anyway, so it never covered the locked-down case.
|
|
|
|
What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
|
|
(`src/main/daemon/shell-ready.ts`, `src/main/providers/local-pty-shell-ready.ts`,
|
|
`src/main/providers/windows-shell-args.ts`), the hook wrappers
|
|
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
|
|
`src/main/agent-hooks/runtime-home-hook-command.ts`,
|
|
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
|
|
— that last one only as a _fallback_ since #18875, see below),
|
|
`src/main/runtime/windows-default-route-interfaces.ts`,
|
|
`src/main/runtime/orchestration/setup-completion-signal.ts`,
|
|
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
|
|
`src/main/runtime/windows-mobile-firewall.ts` encodes a script and launches it
|
|
_elevated_ through `Start-Process -Verb RunAs`, which is a stronger shape than
|
|
any of those; only that hop is encoded, because `-ArgumentList` re-splits an
|
|
unquoted parameter string on whitespace.
|
|
|
|
One site still spells `-ExecutionPolicy Bypass` with **no** encoding, the weaker
|
|
signal: `src/main/cli/wsl-cli-scripts.ts` (`-File`, and it is a real script
|
|
file, so the switch is not a no-op there). Every Orca-managed WSL terminal now
|
|
reaches it by default on each CLI call (`docs/reference/wsl-managed-cli.md`), not
|
|
only after the user registers the WSL CLI. `src/main/system-fonts.ts` dropped it
|
|
for plain `-Command`; `src/shared/secure-path-windows-acl.ts` no longer runs
|
|
PowerShell at all, having moved to `icacls.exe`; and computer use now asks for
|
|
`-ExecutionPolicy RemoteSigned` in
|
|
`src/main/computer/windows-powershell-execution-policy.ts`, falling back to
|
|
`Bypass` only after a policy-blocked start.
|
|
|
|
Regenerate with `rg -- '-EncodedCommand|-ExecutionPolicy' src/` rather than
|
|
trusting the lists above, and note that a raw grep under-reports: the hook sites
|
|
reach `-EncodedCommand` through `wrapWindowsPowerShellEncodedCommand` and never
|
|
spell the flag themselves.
|
|
|
|
Encoding is not gratuitous: it shields paths and switches from `cmd.exe` and MSYS
|
|
rewriting (#6078, #14815), which is a real class of corruption. But
|
|
`-EncodedCommand` is a first-class Defender alert title ("Suspicious PowerShell
|
|
command line"), and base64 raises the score rather than lowering it, because it
|
|
denies the analyser the payload it would otherwise clear.
|
|
|
|
The hook launcher is prior art worth knowing about. #16003 measured, on a
|
|
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
|
|
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
|
|
payload — `exit 0` was denied too. The fix was to stop _spelling_ the flags:
|
|
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
|
|
#16576, the execution policy bypass moved in-payload as a process-scope
|
|
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
|
|
measured denial keyed on `-WindowStyle Hidden` + `-EncodedCommand`, not on the
|
|
bypass. It is also honest that the underlying behaviour did not change.
|
|
|
|
Copy the pattern, but copy its caveat too. `windows-powershell-hook-launcher.ts`
|
|
records that dropping `-WindowStyle Hidden` was a real tradeoff whose suppression
|
|
"was never measured" and "remains unverified on a real box". Reducing spelled
|
|
flags is the right instinct; treat any specific claim about what a removed flag
|
|
was doing as unproven until someone measures it.
|
|
|
|
### `cmd.exe /c` carrying caret-escaped free text
|
|
|
|
`buildWindowsCmdShimCommandLine` in
|
|
`src/shared/child-process/windows-command-line.ts` builds `/d /v:off /s /c "…"`
|
|
for the `.cmd` and `.bat` targets Windows can only start through `cmd.exe`
|
|
(`codex.cmd` being the one that matters). Because cmd expands `%VAR%` even inside
|
|
a quoted token, each `%` is broken with `"^%"`.
|
|
|
|
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
|
|
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
|
|
"e^%F^%g", "h"]` — the `&` truncated the argument _and_ ran the remainder as a
|
|
command.
|
|
|
|
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
|
|
`cmd.exe` command lines, and the free text being escaped here is an agent prompt,
|
|
so the line is long, high-entropy, and attacker-shaped. It is the exact input an
|
|
obfuscated-command-line detector is tuned on.
|
|
|
|
### The spawn tree itself
|
|
|
|
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
|
|
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
|
|
terminal multiplexer for coding agents _is_. `reg.exe` appears from
|
|
`src/main/win32-utils.ts`,
|
|
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
|
|
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
|
|
|
|
Nothing here is avoidable in principle. What is controllable is depth and
|
|
breadth: every interpreter hop between Orca and the thing the user asked for adds
|
|
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
|
|
_shorten the interpreter chain_ rather than to hide a window.
|
|
|
|
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
|
|
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
|
|
decoded payload was a `Test-Path` and a call to `~/.orca/agent-hooks/claude-hook.cmd`.
|
|
It now registers the bare script path itself, with no shell operators, so `bash ->
|
|
powershell -> cmd -> curl` became `bash -> cmd -> curl` and one
|
|
`powershell.exe -EncodedCommand` per hook event — a first-class Defender alert
|
|
title — leaves the tree. The reporting box fired ~6 900 of them in five days,
|
|
70% from Claude sessions that were not running under Orca at all and whose hook
|
|
exits at its first `ORCA_PANE_KEY` guard.
|
|
|
|
What is measured is latency and the hop count, nothing else: median 471 ms ->
|
|
213 ms per event idle, and 656 ms -> 296 ms (p95 696 ms -> 337 ms) under 10-way
|
|
concurrency, invoked as Claude Code invokes it. **No EDR verdict on either tree
|
|
was measured**, so claim the removed `-EncodedCommand` spelling and the shorter
|
|
chain, not a score. `cmd.exe` remains in the tree, spelled by MSYS's own `.cmd`
|
|
spawn rather than by us — the doc's one "unavoidable for `.cmd`/`.bat`" case,
|
|
carrying only an absolute path, with no caret escaping, no
|
|
encoding and no free text. The encoded launcher is still the shape for profile
|
|
paths the shells cannot carry bare (a space, `%`, `^`, `&`, non-ASCII, a UNC
|
|
profile).
|
|
|
|
The direct shape first shipped as `<path> || echo {}`, gated on Git Bash being
|
|
resolvable. That gate guessed at a choice Claude Code makes on its own: it runs
|
|
hooks under Windows PowerShell 5.1 when it picks PowerShell, and 5.1 rejects `||`
|
|
(parse error, exit 1), so every hook failed (STA-8913). The registered command is
|
|
now the bare path, which parses in Git Bash, cmd.exe, pwsh and PowerShell 5.1.
|
|
The neutral `{}` for a missing payload lives in the `claude-hook.cmd` entry, which
|
|
hands off to `claude-hook-impl.cmd` in the same `cmd.exe`; a deleted entry is an
|
|
ordinary non-blocking hook error (never exit 2) until the next install rewrites it.
|
|
Compat consumers such as cursor-agent and Devin import `~/.claude/settings.json`
|
|
and run `command` through their own launcher (the payload carries a
|
|
`DEVIN_PROJECT_DIR` skip for that); one that cannot start a `.cmd` at all still
|
|
gets a launch failure. Before widening the direct shape to another agent, measure
|
|
that consumer's host.
|
|
|
|
### Computer use: screen capture, synthetic input, runtime-compiled MSIL
|
|
|
|
`native/computer-use-windows/runtime.ps1` is a large PowerShell script.
|
|
`src/main/computer/desktop-script-provider-bridge.ts` launches it as
|
|
`powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy RemoteSigned
|
|
-File runtime.ps1 <operation.json>`, retrying once at `Bypass` only if the start
|
|
comes back policy-blocked — **once per operation**, with
|
|
`desktop-script-provider-client.ts` writing a fresh `operation.json` into a new
|
|
temp directory each time. On every launch the script runs `Add-Type
|
|
-TypeDefinition` over inline C# that P/Invokes `SendInput` and the window APIs,
|
|
then captures the screen through `Graphics.CopyFromScreen`.
|
|
|
|
That is four separate high-signal behaviours stacked in one process:
|
|
|
|
| Behaviour | How it is scored |
|
|
| --------------------------------------------- | ------------------------------------------------------------- |
|
|
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
|
|
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
|
|
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
|
|
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
|
|
|
|
The bottom two rows are the two the incident text named directly, and they are
|
|
also the two a persistent runtime host would remove: a long-lived helper compiles
|
|
its P/Invoke stubs once and answers operations over a channel, so neither the
|
|
MSIL recompilation nor the interpreter burst repeats. A change doing that is in
|
|
flight and unmerged at the time of writing; check the code rather than this
|
|
paragraph for what the shipped build does. Screen capture and `SendInput` are
|
|
inherent to the feature and no refactor removes them.
|
|
|
|
### SSH hosts: upload-stage file identity and runtime-store GC
|
|
|
|
These run on the _remote_ Windows host over SSH, not on the desktop, but the
|
|
host's EDR scores them the same way.
|
|
|
|
The relay upload stage fences each slot with the directory's file ID (volume
|
|
serial plus file index). That used to come from `Add-Type -TypeDefinition` over
|
|
a P/Invoke of `GetFileInformationByHandle`, compiled in every stage command. When
|
|
the relay runs on Orca's pinned Node (design D5), node.exe is already hashed
|
|
against the pin and has run once, so the stage commands now ask it instead:
|
|
`src/main/ssh/ssh-relay-upload-stage-windows-commands.ts` runs
|
|
`node.exe -e <fixed script> -- <path>`, a fixed `fs.lstatSync(..., { bigint: true })`
|
|
with the path as an argument. libuv fills `dev` and `ino` from the same volume
|
|
serial and file index, so both readers write the same `vol:high:low` lowercase
|
|
hex, and identity files are compared after normalising hex spelling. An old
|
|
client can recover a stage a new one reserved, and the reverse.
|
|
|
|
Two alternatives were rejected:
|
|
|
|
- **PowerShell alone.** Neither .NET Framework (Windows PowerShell 5.1) nor .NET
|
|
exposes a file index without P/Invoke, which is what `Add-Type` compiles.
|
|
`fsutil file queryfileid` would spawn another binary per lookup and prints a
|
|
different format, which would break mixed-version recovery.
|
|
- **Host Node.** Relays still on the host's own Node (rung C and the legacy
|
|
path) keep the `Add-Type` helper, because Orca has not verified that binary.
|
|
That is the one remaining `Add-Type` site on SSH hosts; it goes when those
|
|
rungs do.
|
|
|
|
A lookup costs one short-lived node.exe per existing stage directory the command
|
|
inspects, usually one or two. It is not a loop over the whole pool.
|
|
|
|
Runtime-store GC (`src/main/ssh/remote-node-runtime-store-windows.ts`) reads the
|
|
store in one PowerShell invocation. It learns which runtimes are in use from a
|
|
single `Get-CimInstance Win32_Process` query, filtered on an image path under
|
|
`runtimes\`. It never matches on the image name, so another program's node.exe
|
|
holds nothing. WMI refuses a standard user's SSH logon, so a refusal falls back to
|
|
`Get-Process`, which reads the image path of the account's own processes — the
|
|
only ones running from its store. If both fail, no process check has run and the
|
|
pass keeps everything. Windows itself also refuses to delete a running image, which is a
|
|
second safeguard.
|
|
|
|
### SSH hosts: starting the relay outside the session
|
|
|
|
Win32-OpenSSH puts each session's shell in a job with
|
|
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK`
|
|
(`contrib/win32/win32compat/w32-doexec.c`, unchanged since 2018), so the relay
|
|
must leave that job to outlive the connection. It used to leave through WMI
|
|
`Win32_Process.Create`, which is both an EDR-scored remote-execution shape
|
|
(T1047) and refused to a standard user's network logon unless an administrator
|
|
grants Remote Enable on `root\cimv2`.
|
|
|
|
Now `relay.js --windows-breakaway-launch` runs once per launch on the same
|
|
node.exe and calls `spawnOutsideJob` in the staged process-tree addon
|
|
(`src/process_launch.cc` in the patch): one `CreateProcessW` with
|
|
`CREATE_BREAKAWAY_FROM_JOB`, and a handle list that passes only the relay's
|
|
three stdio handles, so no SSH channel pipe is inherited. libuv never passes that
|
|
flag, so Node alone cannot do this. WMI remains only as the fallback for a relay
|
|
built without the addon or a job that refuses breakaway, and a refusal there is
|
|
reported as `ORCA_RELAY_LAUNCH_REFUSED`. The Windows SSH-host lanes run with no
|
|
WMI grant and assert the breakaway route.
|
|
|
|
## Signing is not the gate
|
|
|
|
The most useful calibration in the whole incident set came from the reporter's
|
|
own machine: **Antigravity IDE's main executable is `NotSigned` and was not
|
|
flagged, while Orca's is signed and was flagged six times.** Their conclusion:
|
|
_"signing is not the gate here — behaviour is."_
|
|
|
|
The mechanism is that Defender reputation is signer **plus prevalence**, and
|
|
prevalence is keyed on **file hash**. A widely installed unsigned binary clears
|
|
on install count alone. Orca's signature is a free OV certificate from SignPath
|
|
Foundation (`config/electron-builder.config.cjs` sets
|
|
`win.signtoolOptions.publisherName`; `config/scripts/verify-windows-inner-signature.mjs`
|
|
pins `CN=SignPath Foundation, O=SignPath Foundation, L=Lewes, S=Delaware, C=US`),
|
|
shared across many OSS projects, with no independent SmartScreen or MAPS
|
|
reputation of its own. Every release ships new hashes, so whatever prevalence a
|
|
build accumulates resets on the next update. Dev channels ship unsigned by
|
|
design, because SignPath's approval waits cannot fit a dev cadence
|
|
(`config/scripts/verify-dev-channel-packaging.mjs`).
|
|
|
|
Signing the uninstaller is worth doing — an unsigned `old-uninstaller.exe`
|
|
running under a signed installer is a gratuitous contribution to the update
|
|
cluster — but do not expect it to change the behavioural verdict. The three
|
|
non-update clusters contain no unsigned binary at all.
|
|
|
|
## What we do not know
|
|
|
|
Two limits the incident analysis recorded, kept here rather than smoothed over:
|
|
|
|
- **No data on Hermes.** Nothing in this document describes how Hermes behaves
|
|
under the same tenant policy — though `src/shared/hermes-startup-query.ts` does
|
|
spell `-EncodedCommand`, so the gap is telemetry, not surface.
|
|
- **Antigravity not being flagged is absence of evidence, not proof.** It is one
|
|
reporter's recollection from one machine, not a measurement. It is strong
|
|
enough to falsify "the problem is that we are not signed well enough"; it is
|
|
not strong enough to support a positive claim about how Defender scores that
|
|
product.
|
|
|
|
Add to those: this is one tenant with one policy configuration. Whether the same
|
|
build scores the same way elsewhere is unmeasured.
|
|
|
|
## Guidance for engineers
|
|
|
|
Fixes for several of the shapes above are in flight in separate changes; nothing
|
|
in this section should be read as a statement that a given site has already
|
|
changed. Check the code before relying on it.
|
|
|
|
The checklist. On Windows, do not reach for:
|
|
|
|
| Don't | Instead |
|
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
|
|
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
|
|
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
|
|
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
|
|
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
|
|
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
|
|
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
|
|
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
|
|
|
|
Two framing rules that outlast the table:
|
|
|
|
- **Shorten the interpreter chain.** Each hop between Orca and the user's actual
|
|
target is a scored edge and a place for AV to deny a `CreateProcess`. This is
|
|
the shipped doctrine of #15520 and #15595.
|
|
- **Do not spell a flag you can avoid spelling.** #16003 measured a denial that
|
|
was independent of the payload and keyed purely on the switch combination on
|
|
the command line. What is on the line is itself the detection surface.
|
|
|
|
## Guidance for administrators deploying Orca
|
|
|
|
### Path exclusions alone will not silence these
|
|
|
|
This is the single most important operational point, and it is the one most
|
|
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
|
|
Defender Antivirus path exclusions suppress _scan_ detections; they do not
|
|
suppress EDR behavioural alerts the same way. Adding
|
|
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
|
|
incidents to stop will not work.
|
|
|
|
### What actually stops incidents being created
|
|
|
|
An **MDE alert suppression rule** scoped to the process tree. Build it in
|
|
Microsoft 365 Defender (Settings → Endpoints → Alert suppression), conditioned
|
|
on:
|
|
|
|
- **Alert titles** — `A suspicious file was observed` and
|
|
`Suspicious PowerShell command line`, plus any further titles your tenant
|
|
actually produced. Take the titles from your own incidents rather than from
|
|
this list.
|
|
- **File paths** — `Orca.exe` and `orca-terminal-daemon.exe` under
|
|
`%LOCALAPPDATA%\Programs\orca\` and `%LOCALAPPDATA%\Orca\daemon-host\`.
|
|
|
|
Scope it as narrowly as your tenant will tolerate, and review it when Orca
|
|
updates: the `daemon-host` path carries a `<version>` segment, so a rule pinned
|
|
to one version will silently stop matching. Two traps in that path in particular.
|
|
Materialization stages into a `<version>.staging-<hex>` sibling before renaming
|
|
it into place, so an exact-version rule misses the tree **mid-update** — which is
|
|
precisely when the update-cluster incidents fire. And the root falls back to the
|
|
Electron `userData` path when `LOCALAPPDATA` is unset, so
|
|
`%LOCALAPPDATA%\Orca\daemon-host\` is the normal location rather than a
|
|
guaranteed one. Prefer a prefix match on `…\Orca\daemon-host\` over a rule
|
|
pinned to one full path.
|
|
|
|
Add AV path exclusions for those two directories as well — they cut scan cost on
|
|
a tree that is rewritten on every update — but understand the division of
|
|
labour. The exclusions reduce scanning; **the suppression rule is what stops
|
|
incidents being created.**
|
|
|
|
### Check your ASR rules
|
|
|
|
Check whether the tenant has the Attack Surface Reduction rule **"Block
|
|
executable files from running unless they meet a prevalence, age, or trusted list
|
|
criterion"** enabled. If it is, that alone explains a freshly signed Orca build
|
|
being hit immediately after every update: each release ships new hashes, so every
|
|
build starts at zero prevalence and zero age no matter how it is signed. Either
|
|
allowlist the Orca install paths for that rule or expect a hit on each update.
|
|
|
|
### Expect the alerts to recur after each update
|
|
|
|
Prevalence is keyed on file hash. An update replaces the hashes, the reputation
|
|
starts over, and a suppression rule is the only thing carrying across.
|
|
|
|
## Computer use: decide before you deploy
|
|
|
|
Read this section before enabling computer use on a monitored endpoint, not
|
|
after.
|
|
|
|
> **On a monitored endpoint, an alert reading "Screenshots were taken
|
|
> unexpectedly on this device" is not the kind of finding a SOC dismisses on
|
|
> sight.**
|
|
|
|
Incident E is the shape to expect: 5 alerts, 37 evidence items, a multi-stage
|
|
incident mapped to ATT&CK **Execution + Collection**, and a description naming
|
|
screen capture found in a script launched by `powershell.exe`. Incident F adds
|
|
runtime-compiled MSIL to the same tree.
|
|
|
|
Every part of that is an accurate description of what the feature does. Orca's
|
|
computer use takes screenshots, synthesises keyboard and mouse input into other
|
|
applications, and compiles the P/Invoke stubs it needs at runtime. An
|
|
organisation that monitors for Collection-tactic activity — and any organisation
|
|
running MDE with default incident creation does — will see it, and will see it
|
|
as Collection.
|
|
|
|
So decide deliberately, in advance:
|
|
|
|
- **Allowlist it**, with a suppression rule covering the computer-use tree
|
|
(`powershell.exe` with `-File …\runtime.ps1`) as well as the base Orca paths,
|
|
and tell your SOC what it is before the first incident rather than during it.
|
|
- **Or leave it disabled** on monitored endpoints.
|
|
|
|
What does not work is deploying it un-triaged and handling the incidents
|
|
reactively. By the time a Collection-tactic incident is open, an analyst is
|
|
already reading a description of screenshots being taken without the user's
|
|
knowledge, and the burden of proof has moved to you.
|