fix(core,helper): only let the owner drive the elevated Core

Changelog: The elevated Core only answers its owner
This commit is contained in:
chen08209
2026-10-05 08:24:15 +08:00
parent 4b59eca853
commit d780cb863d
40 changed files with 1561 additions and 153 deletions
+76 -8
View File
@@ -79,7 +79,26 @@ for `settimeofday`.
`_makeRealProfileTask` in `lib/common/task.dart` writes `mixed-port`, `allow-lan`, and `external-controller` into every
generated profile. With `allow-lan` off, mihomo's `genAddr` binds each listener to `127.0.0.1`; the external controller is
`ExternalControllerStatus.close` by default and never binds anywhere but loopback.
`ExternalControllerStatus.close` by default and never binds anywhere but loopback. The task also clears
`external-controller-tls`, `-unix` and `-pipe` in every mode: the unix and pipe controllers answer without a secret, and
the unix one unlinks whatever file sits at its path before opening it to everyone, so a profile may not choose them.
Outside safe mode, profiles retain `listeners`, `tunnels`, `ss-config`, `vmess-config`, `tuic-server`, `iptables`,
`dns.listen` and `ntp.write-to-system`. Extra inbounds can bypass the app's LAN and authentication settings, and a TUN
listener can change routing independently of the app's TUN toggle. These network and clock effects are an accepted
boundary; they are not treated as arbitrary filesystem modification. The task disables `iptables` when its
`inbound-interface` contains a space, because mihomo interpolates it into commands and splits their arguments on spaces.
Safe mode clears these extra inbounds and iptables, closes DNS listening and disables NTP system time writes even after
local overrides. The editor omits disabled alternate controllers and keeps listener completion.
An open external controller always has a secret, because mihomo serves a controller with an empty one to every local
user and, through CORS, to any page the user visits. `PatchClashConfig.secret` is that secret and it belongs to FlClash,
as the address does: the task writes it over whatever the profile carries, and `UpdateParams.secret` hands the same value
to a running Core, which restarts the controller when it changes. `ensureControllerSecret` keeps the pair consistent
(an open controller gets a generated secret, a closed one drops it, so every opening rotates it) and runs where a
config enters the app: the settings toggle, startup, and `writeConfig` for a restored backup. `_effectivePatchConfig`
reports a controller without a secret as closed, and `_makeRealProfileTask` independently closes it without a secret
or in safe mode, so a writer that skips `ensureControllerSecret` opens nothing. The settings page shows the secret in
an editable row, which refuses an empty value.
The loopback listener takes local connections without authentication by default: mandatory credentials would lock out
every external local consumer a proxy client exists for. The binding only guarantees traffic originates on the device;
@@ -92,6 +111,32 @@ withholds the Android VPN system proxy declaration because `ProxyInfo` cannot ca
traffic). Desktop system proxy is deliberately not gated — dropping it would leak traffic direct, so the setting's
description tells users to supply credentials manually instead.
### Local Trust Boundary
The Windows Helper answers only a connection whose client process runs under the owner SID it was installed for;
the Linux Helper only the owner uid. A setuid Core is executable by `root:admin` (macOS) or the installing user's primary
group, so its boundary also includes every member of that group, not just the installing account. Profiles cannot choose
a controller address, a secret or alternate controllers. Another account outside those permissions, a subscription,
or a remote party reaching the privileged Core's control or filesystem APIs is a vulnerability.
A process running as the same account is not stopped. It can ask the Helper to start the Core
against a socket or pipe it owns, or run a setuid Core against a port it listens on, and so become the host that drives
a privileged Core: `initClash` chooses the home directory every path check anchors on, and `getConfig` reads any path.
The Core connects to whatever address it is given and cannot tell that host from FlClash. An account check cannot
distinguish it from a same-account process that can already read FlClash's memory and files. This remains a user-to-root
or user-to-SYSTEM privilege escalation, not a complete security fix. An open external
controller is inside the same boundary: its secret is stored under the account, and `/upgrade` stays mounted (it fetches
fixed MetaCubeX release URLs, not caller-chosen code). mihomo's `IsSafePath` compares paths lexically, so a symlink inside
the home directory escapes it; that fix belongs upstream, not in the submodule.
Closing the same-account case requires limiting what a hijacked Core can do. Keeping the main Core unprivileged and
moving host changes into narrow platform helpers requires architectural work, but need not introduce repeated prompts
or remove ordinary proxy functionality. Features that require additional privileges, such as identifying other users'
processes, need platform-specific handling. Descriptor-based filesystem confinement can preserve ordinary managed
paths; intentional external symlink targets need explicit allowed roots. These changes have not been implemented.
Account authentication, a private socket directory or a symlink check at configuration time does not remove the
privileged filesystem operations available to the host. Changes must not widen access to those operations.
### Interface Name Modes
The same task also writes `interface-name` per `PatchClashConfig.interfaceNameMode`: `clear` (default) forces it empty so a
@@ -667,6 +712,8 @@ rewritten; each owner reads the flag where it would otherwise act:
user's values and the installed app keeps `127.0.0.1:9090`. `SetupAction.requestAdmin` skips authorization.
- System clock: `_makeRealProfileTask` sets `write-to-system: false` on whatever `ntp` section the generated profile
ends up with, so neither the user's override nor the profile's own NTP config can move the host's time.
- Profile listeners and firewall rules: `_makeRealProfileTask` clears `listeners`, `tunnels`, `ss-config`,
`vmess-config`, `tuic-server` and `iptables`, and closes `dns.listen`, preventing profile-only changes during apply.
- System proxy: the `proxy` plugin handle in `lib/common/proxy.dart` is `null`, so `ProxyManager` and exit cleanup
never write the OS setting, not even the `stopProxy` that would clear what the installed app configured.
- System DNS: `systemDnsCoordinator` is `null` and `shouldPatchSystemDnsProvider` is `false`, so neither the apply nor
@@ -1020,8 +1067,10 @@ after calculating the SHA256 of the Core produced for the active Flutter configu
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the
current executable path, starts it, and waits for the running state.
- `FlClashHelperService.exe install --owner-pid <pid>` reads the account of that process (the app, which Dart names with
its own PID because the elevated installer may be another administrator), stops and removes any stale registration,
creates the auto-start service for the current executable path with `--owner-sid <SID>` as its launch argument,
starts it, and waits for the running state. A service started without an owner exits instead of serving anyone.
- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used
by the Windows package uninstaller.
@@ -1060,22 +1109,41 @@ is registered at package install, and `Linux.registerService` asks for elevation
- A Linux host without systemd (`/run/systemd/system` absent) has no Helper: `system.hasHelperService` is false there,
readiness is the `stat` check, and `pkexec` sets the setuid bit on the bundled Core as before.
A setuid Core is a root process for whoever gives it an address to dial (`argv[1]`), so its mode never lets others
execute it: macOS runs `chown root:admin` and `chmod 4750` (and refuses an account outside the `admin` group, which
could not run the result), and a Linux host without systemd hands it to the requesting user's primary group under
`pkexec`. `System.isPrivilegedStatOutput` reads a Core that others can execute as unauthorized, so one that an earlier
version marked `+sx` is re-authorized on the next TUN enable.
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
`/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists;
it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex session ownership:
it never hashes the Core. Protocol version 6 (8 on Windows, where the owner check and the browser-origin check arrived
and an older Helper must be reinstalled) uses 32-character lowercase-hex session ownership:
- `GET /ping?coreSha256=...` returns the current Helper executable path with `x-flclash-helper-protocol` when the
requested SHA matches.
- `POST /start` rejects unknown JSON fields, validates `{address, sessionId}`, then releases any previously managed Core
before verifying the Core — so every outcome, including a rejected one, leaves the Helper owning no Core — and returns
- `POST /start` rejects unknown JSON fields and validates `{address, sessionId}` and, on Linux, the socket owner, which
leaves a running Core alone. It then releases any previously managed Core before verifying the Core — so every
outcome of a request that passed validation, including a rejected one, leaves the Helper owning no Core — and returns
`{sessionId, pid}`.
- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409.
- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching.
`/start` and `/stop` take at most 4096 bytes and need a `Content-Length` (HTTP 413 and 411 otherwise); `HelperClient`
always sends one. All endpoints reject `Origin` headers and non-local `Host` values, preventing browser requests and DNS
rebinding through an owner-account browser, which would otherwise pass the Windows account check. The accepted
authorities are `127.0.0.1`, `localhost` and the Linux Unix-socket placeholder `FlClashHelperService`. The Helper grants
no CORS access.
Endpoints bind only to `127.0.0.1:47890` on Windows and to `/run/flclash/helper.sock` on Linux, and do not use
request-token authentication. Lifecycle safety comes from the fixed executable/hash, the strict address namespace
(`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
request-token authentication. Who may call them is decided per connection, before any request is read: the Linux
socket drops a peer whose `SO_PEERCRED` UID is not the owner's, and the Windows listener (`service/peer.rs`) traces the
connection through the TCP table to the process holding its client end and drops it unless that process runs as the
owner SID the service was registered with. The Windows Core runs as SYSTEM and dials whatever pipe `/start` names, so
without that check any local account would host a SYSTEM Core. What no per-connection check closes is a process that
runs as the owner itself, on either platform. Lifecycle safety also comes from the fixed executable/hash, the strict
address namespace (`\\.\pipe\FlClashCore_<32 hex>` on Windows, `/tmp/FlClashSocket_<digits>.sock` on Linux), the session-scoped stop
contract, Dart-side peer-PID verification on Windows and, on Unix, the Core socket that `plugins/rust_api` sets to
mode `0600` so only the owning user (and the root-effective Core) can connect. When the Helper service itself shuts
down, it unconditionally stops the Core process it owns; under systemd the unit's control group does the same.