From d9ca54a1542c0ccc2bac914ddcada7e3ad19113a Mon Sep 17 00:00:00 2001 From: deepseek Date: Wed, 16 Sep 2026 22:29:47 +0800 Subject: [PATCH 1/3] feat(auth): recover the attach-instance launch token from process memory An attached dsh web has no console tail of ours to read, so reusing a running instance used to end at the restart-and-take-over prompt as soon as its 401 auth wall appeared. The shell now reads the launch token back out of the listener process and navigates with it. - new src-tauri/src/memtok.rs: raw kernel32 FFI, no new dependency; harvests maximal 43-char base64url runs plus the 32-byte tail-character constraint; three passes (skip MEM_IMAGE, then all memory, then all memory without the tail filter); 4MB chunks read by min(CPU,8) workers off an atomic range queue; probes every 16 candidates with early stop. A candidate is accepted only after a real HTTP exchange (303 + dsh-auth-* Set-Cookie) - shape is never proof - dsh.rs: check_auth_wall_now runs the recovery before the takeover prompt; at most two scans per run and the same token is never navigated twice, so an intercepting front-auth plugin cannot turn into a scan loop - the token never reaches the log: not landing on disk is the whole point of this auth - CI: build-shell.yml also runs on pull requests to prove the change compiles; the version bump and the release stay restricted to main --- .github/workflows/build-shell.yml | 16 + CHANGELOG.md | 20 ++ README.md | 2 +- src-tauri/src/dsh.rs | 95 +++++- src-tauri/src/lib.rs | 1 + src-tauri/src/memtok.rs | 521 ++++++++++++++++++++++++++++++ 6 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/memtok.rs diff --git a/.github/workflows/build-shell.yml b/.github/workflows/build-shell.yml index 7a4961c..3bafd3f 100644 --- a/.github/workflows/build-shell.yml +++ b/.github/workflows/build-shell.yml @@ -4,6 +4,8 @@ name: build dsh-local-shell (shell) # 触发: 壳/前端/构建配置变更(push main)。版本号自动递增: 以最新 Release # tag 为基准 patch +1, 构建时写入 tauri.conf.json(不回写仓库), # 旧 Release 永不被覆盖。 +# PR 也会跑本工作流, 但只构建并上传产物以校验可编译 —— 版本递增与发布 +# 都限定在 main 上进行(见各步骤的 if 条件)。 on: workflow_dispatch: @@ -17,6 +19,18 @@ on: - 'pnpm-workspace.yaml' - '.npmrc' - '.github/workflows/build-shell.yml' + # Pull requests build and upload the exe to prove the change compiles, but + # never touch the version or publish a release (see the `if:` guards below). + pull_request: + branches: [main, master] + paths: + - 'src/**' + - 'src-tauri/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.npmrc' + - '.github/workflows/build-shell.yml' permissions: contents: write @@ -42,6 +56,7 @@ jobs: run: pnpm install --no-frozen-lockfile --config.dangerously-allow-all-builds=true - name: bump version (latest release tag + 0.0.1, in-memory only) + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' shell: pwsh env: GH_TOKEN: ${{ github.token }} @@ -80,6 +95,7 @@ jobs: if-no-files-found: error - name: publish shell exe to a NEW patch release (older tags are never touched) + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' shell: bash env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d37fbd7..6f2b59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## v2.0.6 — 2026-09-16 + +**复用(attach)实例不再卡在「是否重启接管」:从运行中的进程内存里取回 launch token**: + +- **新增 `src-tauri/src/memtok.rs`**(纯 `kernel32` 裸 FFI,不加依赖):DSH 的 launch token 只存在于运行进程的内存里(打印过的那一行早被 GC 回收),attach 实例又没有本应用自己的子进程控制台尾可读——此前唯一出路是弹窗让用户重启接管。现在直接读监听 3080 那个进程的内存:枚举可读内存区 → 收集 43 字符 base64url 的**极大串**(附加尾字符约束 `AEIMQUYcgkosw048`——43 字符编码 32 字节时末位只承载 4 bit,这是长度带来的数学必然,不是对内容的猜测)→ 逐个候选走 HTTP 交换,**只有回 303 且签发 `dsh-auth-*` cookie 的才算数**(形状永不作为证据) +- **三遍递进**:先跳过 MEM_IMAGE(映射的 DLL 代码不可能存 JS 字符串,少读约 160MB)→ 再全量内存 → 最后去掉尾字符约束兜底(防上游改编码) +- **速度**:4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即探测、命中即刻停扫(early stop);探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头 +- **回退**:无监听进程 / `OpenProcess` 失败(跨用户或已退出)/ 无候选 / 候选全不通过 → 落到原来的「是(壳接管重启并自动认证)/ 否(退出保留外部实例)」弹窗,文案改为说明已尝试内存提取;每次运行最多扫 2 次,同一令牌不重复导航(前端登录插件在 HTTP 层拦 token URL 时不会变成扫描环) +- **不落盘**:全程只读(`ReadProcessMemory`),令牌明文绝不写日志——不落盘正是这套认证的设计前提;日志只记扫描统计(区域数/跳过映像/读取量/候选数/探测数/耗时/是否早停) +- **CI**:`build-shell.yml` 增加 `pull_request` 触发,PR 只构建并上传 exe 以校验可编译,不递增版本、不发布 Release + +Attach-mode token recovery from the running process's memory (no more "restart and take over?" on reuse): + +- New `src-tauri/src/memtok.rs` (raw `kernel32` FFI, no new dependency): the launch token lives only in the running process, and an attached instance has no console tail of ours to read — so reusing a running `dsh web` used to end at the takeover prompt. The shell now reads the listener's memory: enumerate readable regions → harvest every maximal 43-character base64url run (plus the tail-character constraint `AEIMQUYcgkosw048`, a mathematical consequence of encoding exactly 32 bytes) → exchange each candidate over HTTP and accept only one that answers `303` while minting the `dsh-auth-*` cookie. Shape is never accepted as proof +- Three passes: skip MEM_IMAGE first (mapped executables cannot hold a JS string, ~160MB less to read), then all memory, then all memory without the tail filter +- Fast: 4MB `ReadProcessMemory` chunks, `min(CPU,8)` workers pulling ranges off an atomic queue, and a probe round every 16 candidates so a hit stops the scan immediately; probes are a hand-written minimal HTTP/1.1 exchange with an explicit `Host` header (without it the server always answers 401 — the cookie name and its signed audience both derive from the request authority) +- Fallback: no listener, `OpenProcess` denied, no candidate, or nothing verifying → the original 「是」take over / 「否」quit prompt, reworded to say memory recovery was tried. At most two scans per run and the same token is never navigated twice, so a front-auth plugin intercepting the token URL cannot turn into a scan loop +- Read-only and disk-free: `ReadProcessMemory` only; the token never reaches the log, because not landing on disk is the whole point of this auth +- CI: `build-shell.yml` also runs on pull requests to prove the change compiles; a PR bumps no version and publishes no release + ## v2.0.5 — 2026-09-15 **启动页重构 + 深浅色跟随系统**(前端 UI 层重写;业务逻辑与 Rust 侧零改动): diff --git a/README.md b/README.md index a3b88c0..351657f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Ships as a **single portable bare exe** (~4.5 MB, no installer). - **Tray icon pinned to the taskbar**: the app writes the Windows notification-area setting (`IsPromoted`) at startup, so the icon no longer falls into the overflow on every launch - **Task-done notification**: when a session transitions from running to idle, a Windows toast fires with two buttons — **"Open Window"** (restore & focus) and **"Got it"** (dismiss); left untouched it auto-collapses after a few seconds - **Link context menu**: right-clicking a link in the chat shows a clean two-item menu — "Open in browser" / "Copy link" (replacing the misleading default WebView2 menu); left-click still opens external links in the system default browser -- **Attach mode**: if DSH is already listening on 3080 at startup, the app attaches instead of spawning a second one — and never kills an instance it didn't start +- **Attach mode**: if DSH is already listening on 3080 at startup, the app attaches instead of spawning a second one — and never kills an instance it didn't start. When that instance demands the browser token and its printed line is long gone, the shell recovers the token **from the running process's own memory** and navigates with it (accepted only after a real HTTP `303` + `dsh-auth-*` exchange — never on shape alone), so reusing a running DSH no longer ends at the "restart and take over?" prompt; only a failed recovery falls back to that prompt - **Clean exit**: only tray right-click → "Quit (close DSH)" exits, tearing down the process tree it spawned via `taskkill /T` with zero orphans - **Single instance**: launching the exe again just focuses the existing window - **Portable & small**: one file, no installer, no DLL dependencies; data/logs go to `%LOCALAPPDATA%\dsh-desktop\` diff --git a/src-tauri/src/dsh.rs b/src-tauri/src/dsh.rs index 5c99d68..9525193 100644 --- a/src-tauri/src/dsh.rs +++ b/src-tauri/src/dsh.rs @@ -568,15 +568,28 @@ pub(crate) fn check_auth_wall_now(app: &AppHandle) { } log_write( LogLevel::Warn, - "[auth-wall] 10秒内token仍未出现, 回退到接管弹窗(重启后重试捕获)", + "[auth-wall] 10秒内token仍未在控制台尾出现, 改从运行中实例的内存恢复", ); + } + // 内存恢复: attach 实例(复用场景)没有可读的子进程控制台尾, 这是它 + // 唯一的令牌来源; 自启实例则是控制台尾也没抓到时的兜底。DSH 的 + // launch token 只存在于运行进程的内存里 —— 打印过的那一行早已被 + // GC 回收, 磁盘上任何东西都重建不出来。 + if recover_token_from_memory(&app) { + // 重新放行后续 401: 令牌导航若仍未生效(前端登录插件在 HTTP 层 + // 拦截 token URL 是已知情况), 第二次扫描会拿到同一个令牌并直接 + // 放弃, 由 MAX_SCANS 与 LAST_TOKEN 双重封顶, 不会变成扫描环。 + AUTH_WALL_HANDLED.store(false, std::sync::atomic::Ordering::Relaxed); + return; + } + if owned { AUTH_WALL_HANDLED.store(false, std::sync::atomic::Ordering::Relaxed); } let take_over = crate::prompt_yes_no( &app, "DSH 需要浏览器认证", concat!( - "检测到 DSH 需要浏览器令牌(新版本认证),但当前实例不是本应用启动的,无法获取令牌。\n\n", + "检测到 DSH 需要浏览器令牌(新版本认证),已尝试从运行中的实例内存提取令牌但未取得。\n\n", "「是」重启 dsh web 宿主(由本应用接管,自动完成认证)\n", "「否」退出本应用(保留当前 DSH 实例)", ), @@ -600,6 +613,84 @@ pub(crate) fn check_auth_wall_now(app: &AppHandle) { }); } +/// Recover a launch token from the memory of the process listening on the DSH +/// port and, when one verifies over HTTP, navigate the main window to the +/// tokenized URL. Returns true only when the window was navigated with a token +/// that really exchanged for a browser session — shape alone never navigates. +/// +/// This is the attach path's only token source: an instance we did not spawn +/// has no console tail of ours to read, which is why reusing a running `dsh web` +/// used to end at the takeover prompt. Bounded to `MAX_SCANS` scans per run, +/// and the same token is never navigated twice: a token that answers 303 over +/// HTTP but still leaves the webview unauthenticated means something sits in +/// front of the token URL (the dsh-remote login plugin is the known case), and +/// the caller must fall back to the prompt instead of scanning in a loop. +fn recover_token_from_memory(app: &AppHandle) -> bool { + /// Scans allowed per app run: one to use the token, one to learn that using + /// the same token again changes nothing. + const MAX_SCANS: u32 = 2; + static SCANS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + static LAST_TOKEN: Mutex> = Mutex::new(None); + + if SCANS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= MAX_SCANS { + log_write( + LogLevel::Warn, + "[auth-wall] 内存恢复已用尽本次运行的重试次数, 回退接管弹窗", + ); + return false; + } + let Some(pid) = port_listener_pid() else { + log_write( + LogLevel::Warn, + &format!("[auth-wall] 内存恢复跳过: 没有进程监听 127.0.0.1:{DSH_PORT}"), + ); + return false; + }; + let started = Instant::now(); + let token = match crate::memtok::recover(pid, DSH_PORT) { + crate::memtok::Outcome::Found { token, stats } => { + log_write( + LogLevel::Info, + &format!( + "[auth-wall] 内存恢复命中令牌(pid {pid}, {:.1}s): {stats}", + started.elapsed().as_secs_f64() + ), + ); + token + } + crate::memtok::Outcome::NotFound { detail } => { + log_write( + LogLevel::Warn, + &format!( + "[auth-wall] 内存恢复未取得令牌(pid {pid}, {:.1}s): {detail}", + started.elapsed().as_secs_f64() + ), + ); + return false; + } + }; + { + let Ok(mut last) = LAST_TOKEN.lock() else { + return false; + }; + if last.as_deref() == Some(token.as_str()) { + log_write( + LogLevel::Warn, + "[auth-wall] 内存恢复拿到与上次相同的令牌而导航未生效, 回退接管弹窗", + ); + return false; + } + *last = Some(token.clone()); + } + // 令牌明文绝不写进日志: 不落盘正是这套认证的设计前提。 + log_write( + LogLevel::Info, + &format!("[auth-wall] 用内存恢复的令牌重新导航(pid {pid})"), + ); + navigate_main(app, &format!("{DSH_BASE}/?token={token}")); + true +} + /// Outcome of one candidate attempt. enum Attempt { Ready, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3072b59..65621f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ //! task-completion event monitor. mod dsh; +mod memtok; mod menu; mod monitor; mod update; diff --git a/src-tauri/src/memtok.rs b/src-tauri/src/memtok.rs new file mode 100644 index 0000000..a47d503 --- /dev/null +++ b/src-tauri/src/memtok.rs @@ -0,0 +1,521 @@ +//! Launch-token recovery from the memory of a running `dsh web` process. +//! +//! `dsh web` mints one random launch token per process and prints it exactly +//! once, as `http://127.0.0.1:/?token=<43 base64url characters>`. The +//! token is held only in that process (`PROCESS_LAUNCH_TOKENS` / +//! `BrowserAuth.launchToken` in `packages/client/connection/src/browser-auth.ts`), +//! so once the printed line is gone — a detached desktop shell, a scrolled-away +//! terminal, a recycled log — an attached instance cannot be authenticated from +//! disk. This module reads the token back out of the owning process instead: +//! +//! 1. enumerate the committed, readable regions of the process listening on +//! the port; +//! 2. harvest every maximal base64url run of exactly the token length, with +//! the 32-byte tail signature that `encodeBase64Url(randomBytes(32))` makes +//! a mathematical certainty; +//! 3. exchange each candidate over HTTP and accept only the one that answers +//! 303 while minting the `dsh-auth-*` browser session cookie. +//! +//! Shape is never accepted as proof, and nothing is written to disk: this +//! reader only ever calls `ReadProcessMemory`. +//! +//! Windows only. The reader is raw `kernel32` FFI rather than a new dependency +//! — the same reason `lib.rs` declares `user32` by hand instead of pulling in a +//! bindings crate. + +/// Result of one recovery attempt. +pub(crate) enum Outcome { + /// A candidate exchanged for a browser session. `stats` carries the + /// per-pass scan statistics for the log (never the token itself). + Found { token: String, stats: String }, + /// Nothing verified. `detail` carries the per-pass scan statistics and the + /// reason, so the fallback prompt can say what was already tried. + NotFound { detail: String }, +} + +/// Recover the verified launch token of the process listening on `port`. +/// +/// Blocking and CPU-bound (a second or two of memory reads); call it from a +/// background thread. Returns `NotFound` — never a shape-only guess — when the +/// process cannot be opened, holds no candidate, or no candidate verifies. +#[cfg(windows)] +pub(crate) fn recover(pid: u32, port: u16) -> Outcome { + imp::recover(pid, port) +} + +#[cfg(not(windows))] +pub(crate) fn recover(_pid: u32, _port: u16) -> Outcome { + Outcome::NotFound { + detail: "内存恢复仅 Windows 可用".to_string(), + } +} + +#[cfg(windows)] +mod imp { + use super::Outcome; + use std::collections::HashSet; + use std::ffi::c_void; + use std::fmt; + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpStream}; + use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + const PROCESS_QUERY_INFORMATION: u32 = 0x0400; + const PROCESS_VM_READ: u32 = 0x0010; + const MEM_COMMIT: u32 = 0x1000; + const MEM_IMAGE: u32 = 0x0100_0000; + const PAGE_GUARD: u32 = 0x100; + const PAGE_NOACCESS: u32 = 0x01; + + /// `encodeBase64Url(randomBytes(32))` — 32 bytes become 43 base64url + /// characters. + const TOKEN_LEN: usize = 43; + /// Bytes per `ReadProcessMemory` call, plus a `TOKEN_LEN` overlap so a token + /// straddling a chunk boundary is still seen whole. + const CHUNK_BYTES: usize = 4 * 1024 * 1024; + /// Candidates one worker gathers before an HTTP probe round. Batches keep + /// the scan from reading all of memory before the first exchange, so a + /// token found early stops the scan early. + const BATCH_SIZE: usize = 16; + const MAX_WORKERS: usize = 8; + /// Per-candidate probe timeout. A live `dsh web` answers a loopback GET in + /// single-digit milliseconds, so anything slower is a dead or wedged server. + const PROBE_TIMEOUT: Duration = Duration::from_millis(1500); + + /// Characters a 43-character base64url encoding of exactly 32 bytes can end + /// with. The final character carries only the last 4 input bits, so its + /// 6-bit value is a multiple of four. This is a consequence of the token's + /// length, not an assumption about its content. + const TAIL_ALPHABET: &[u8] = b"AEIMQUYcgkosw048"; + + #[link(name = "kernel32")] + extern "system" { + fn OpenProcess(access: u32, inherit: i32, process_id: u32) -> *mut c_void; + fn CloseHandle(handle: *mut c_void) -> i32; + fn VirtualQueryEx( + process: *mut c_void, + address: *const c_void, + info: *mut MemoryBasicInformation, + length: usize, + ) -> usize; + fn ReadProcessMemory( + process: *mut c_void, + address: *const c_void, + buffer: *mut c_void, + size: usize, + read: *mut usize, + ) -> i32; + fn GetLastError() -> u32; + } + + /// `MEMORY_BASIC_INFORMATION`. On x64 Windows inserts `PartitionId` (a WORD) + /// between `AllocationProtect` and `RegionSize`; declaring `region_size` as + /// `usize` makes the compiler insert exactly that padding, so every field + /// lands at its real offset on both x64 and x86. + /// + /// The two leading-pad fields are written by `VirtualQueryEx` and never read + /// here, but `#[repr(C)]` needs them present to keep the offsets right. + #[repr(C)] + struct MemoryBasicInformation { + base_address: *mut c_void, + _allocation_base: *mut c_void, + _allocation_protect: u32, + region_size: usize, + state: u32, + protect: u32, + ty: u32, + } + + impl MemoryBasicInformation { + fn empty() -> Self { + Self { + base_address: std::ptr::null_mut(), + _allocation_base: std::ptr::null_mut(), + _allocation_protect: 0, + region_size: 0, + state: 0, + protect: 0, + ty: 0, + } + } + } + + /// Owns the process handle so every early return still closes it. + /// + /// `Send`/`Sync` are asserted by hand because the handle is a bare pointer: + /// a Win32 process handle is just a kernel object reference, and reading it + /// from several threads at once with `ReadProcessMemory` is a read-only + /// operation the kernel serializes. + struct ProcessHandle(*mut c_void); + + unsafe impl Send for ProcessHandle {} + unsafe impl Sync for ProcessHandle {} + + impl Drop for ProcessHandle { + fn drop(&mut self) { + unsafe { CloseHandle(self.0) }; + } + } + + /// Per-pass scan statistics, for the shell log. Contains no token. + struct PassStats { + regions: usize, + images_skipped: usize, + image_bytes: u64, + scanned: u64, + candidates: usize, + probed: usize, + solved: bool, + elapsed: Duration, + } + + impl fmt::Display for PassStats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "regions={} imagesSkipped={}({:.1}MB) scanned={:.1}MB candidates={} probed={} earlyStop={} {:.1}s", + self.regions, + self.images_skipped, + self.image_bytes as f64 / 1_048_576.0, + self.scanned as f64 / 1_048_576.0, + self.candidates, + self.probed, + if self.solved { "yes" } else { "no" }, + self.elapsed.as_secs_f64(), + ) + } + } + + const fn base64url_table() -> [bool; 256] { + let mut table = [false; 256]; + let mut c = b'A'; + while c <= b'Z' { + table[c as usize] = true; + c += 1; + } + let mut c = b'a'; + while c <= b'z' { + table[c as usize] = true; + c += 1; + } + let mut c = b'0'; + while c <= b'9' { + table[c as usize] = true; + c += 1; + } + table[b'-' as usize] = true; + table[b'_' as usize] = true; + table + } + + const fn tail_table() -> [bool; 256] { + let mut table = [false; 256]; + let mut i = 0; + while i < TAIL_ALPHABET.len() { + table[TAIL_ALPHABET[i] as usize] = true; + i += 1; + } + table + } + + static BASE64URL: [bool; 256] = base64url_table(); + static TAIL: [bool; 256] = tail_table(); + + fn is_readable(info: &MemoryBasicInformation) -> bool { + let protect = info.protect & 0xFF; + info.state == MEM_COMMIT + && (info.protect & PAGE_GUARD) == 0 + && protect != 0 + && protect != PAGE_NOACCESS + } + + /// Append every maximal base64url run of exactly `TOKEN_LEN` bytes. + /// + /// Maximality is what keeps ordinary hashes and ids out: a run longer than + /// the token length is some other encoding, and a run shorter than it is + /// truncated. `tail_filter` additionally requires the final character to be + /// reachable by a 32-byte payload; the last pass drops it to stay correct if + /// the upstream token encoding ever changes. + fn collect(buffer: &[u8], tail_filter: bool, out: &mut Vec) { + let length = buffer.len(); + for i in 0..length { + if !BASE64URL[buffer[i] as usize] { + continue; + } + // Only a run's first byte can start the token. + if i > 0 && BASE64URL[buffer[i - 1] as usize] { + continue; + } + let end = i + TOKEN_LEN; + if end > length { + break; + } + let mut shaped = true; + for k in 1..TOKEN_LEN { + if !BASE64URL[buffer[i + k] as usize] { + shaped = false; + break; + } + } + if !shaped { + continue; + } + if end < length && BASE64URL[buffer[end] as usize] { + continue; + } + if tail_filter && !TAIL[buffer[end - 1] as usize] { + continue; + } + // Every byte passed the base64url table, so the slice is ASCII. + match std::str::from_utf8(&buffer[i..end]) { + Ok(token) => out.push(token.to_string()), + Err(_) => continue, + } + } + } + + /// Exchange one candidate for a browser session over the loopback HTTP + /// server. A valid launch token answers `303` with a `Set-Cookie` for the + /// `dsh-auth-*` session; every other candidate answers `401`. + /// + /// The request is hand-written rather than built with the crate's HTTP + /// client for two reasons: an explicit `Host` header is mandatory (the + /// cookie name and its signed audience both derive from the request + /// authority, so a request without `Host` is always 401), and the probe + /// must see the bare `303` instead of following it. + fn probe(token: &str, port: u16) -> bool { + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let Ok(mut stream) = TcpStream::connect_timeout(&address, PROBE_TIMEOUT) else { + return false; + }; + let _ = stream.set_read_timeout(Some(PROBE_TIMEOUT)); + let _ = stream.set_write_timeout(Some(PROBE_TIMEOUT)); + let request = format!( + "GET /?token={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: */*\r\nConnection: close\r\n\r\n" + ); + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + // Headers only: the body is irrelevant, and `Connection: close` ends the + // read at EOF if the server keeps the socket open. + let mut head: Vec = Vec::with_capacity(4096); + let mut chunk = [0u8; 1024]; + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + head.extend_from_slice(&chunk[..read]); + if head.windows(4).any(|window| window == b"\r\n\r\n") || head.len() > 16 * 1024 { + break; + } + } + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&head); + let mut lines = text.split("\r\n"); + let status = lines.next().unwrap_or_default(); + if !status.starts_with("HTTP/1.1 303") && !status.starts_with("HTTP/1.0 303") { + return false; + } + text.split("\r\n").any(|line| { + let lower = line.to_ascii_lowercase(); + lower.starts_with("set-cookie:") && lower.contains("dsh-auth-") + }) + } + + /// Probe the candidates of one batch, skipping any already probed by another + /// worker. Returns the first candidate that verifies. + fn probe_batch( + batch: &[String], + port: u16, + seen: &Mutex>, + probed: &AtomicUsize, + ) -> Option { + let fresh: Vec = { + let mut guard = match seen.lock() { + Ok(guard) => guard, + Err(_) => return None, + }; + batch + .iter() + .filter(|candidate| guard.insert((*candidate).clone())) + .cloned() + .collect() + }; + for candidate in fresh { + probed.fetch_add(1, Ordering::Relaxed); + if probe(&candidate, port) { + return Some(candidate); + } + } + None + } + + /// One full pass: enumerate, then read the regions on several threads, + /// probing candidates as they arrive and stopping the moment one verifies. + fn scan( + process: &ProcessHandle, + port: u16, + skip_images: bool, + tail_filter: bool, + ) -> (Option, PassStats) { + let started = Instant::now(); + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut images_skipped = 0usize; + let mut image_bytes = 0u64; + + // Enumerate first, then read: enumeration is cheap and the resulting + // list is what the workers divide between them. + let mut address = 0usize; + let mut info = MemoryBasicInformation::empty(); + loop { + let written = unsafe { + VirtualQueryEx( + process.0, + address as *const c_void, + &mut info, + std::mem::size_of::(), + ) + }; + if written == 0 { + break; + } + let base = info.base_address as usize; + let size = info.region_size; + let next = base.saturating_add(size); + if is_readable(&info) && size > 0 { + if skip_images && (info.ty & MEM_IMAGE) != 0 { + // A mapped image is executable code; the token is a JS string + // on the V8 heap in private pages. + images_skipped += 1; + image_bytes += size as u64; + } else { + ranges.push((base, size)); + } + } + if next <= address { + break; + } + address = next; + } + + let next_range = AtomicUsize::new(0); + let scanned = AtomicU64::new(0); + let probed = AtomicUsize::new(0); + let candidates = AtomicUsize::new(0); + let solved = AtomicBool::new(false); + let winner: Mutex> = Mutex::new(None); + let seen: Mutex> = Mutex::new(HashSet::new()); + + let workers = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1) + .min(MAX_WORKERS); + + let record = |hit: String| { + solved.store(true, Ordering::Relaxed); + if let Ok(mut slot) = winner.lock() { + if slot.is_none() { + *slot = Some(hit); + } + } + }; + + std::thread::scope(|scope| { + for _ in 0..workers { + scope.spawn(|| { + let mut buffer = vec![0u8; CHUNK_BYTES + TOKEN_LEN]; + let mut batch: Vec = Vec::with_capacity(BATCH_SIZE); + while !solved.load(Ordering::Relaxed) { + let index = next_range.fetch_add(1, Ordering::Relaxed); + if index >= ranges.len() { + break; + } + let (base, size) = ranges[index]; + let mut offset = 0usize; + while offset < size && !solved.load(Ordering::Relaxed) { + let want = buffer.len().min(size - offset); + let mut read = 0usize; + let ok = unsafe { + ReadProcessMemory( + process.0, + (base + offset) as *const c_void, + buffer.as_mut_ptr() as *mut c_void, + want, + &mut read, + ) + }; + if ok != 0 && read > 0 { + scanned.fetch_add(read as u64, Ordering::Relaxed); + collect(&buffer[..read], tail_filter, &mut batch); + } + if batch.len() >= BATCH_SIZE { + candidates.fetch_add(batch.len(), Ordering::Relaxed); + if let Some(hit) = probe_batch(&batch, port, &seen, &probed) { + record(hit); + break; + } + batch.clear(); + } + offset += CHUNK_BYTES; + } + } + // Candidates this worker read but never got to probe. + if !batch.is_empty() && !solved.load(Ordering::Relaxed) { + candidates.fetch_add(batch.len(), Ordering::Relaxed); + if let Some(hit) = probe_batch(&batch, port, &seen, &probed) { + record(hit); + } + } + }); + } + }); + + let hit = winner.lock().ok().and_then(|mut slot| slot.take()); + let stats = PassStats { + regions: ranges.len(), + images_skipped, + image_bytes, + scanned: scanned.load(Ordering::Relaxed), + candidates: candidates.load(Ordering::Relaxed), + probed: probed.load(Ordering::Relaxed), + solved: hit.is_some(), + elapsed: started.elapsed(), + }; + (hit, stats) + } + + /// Open the process and run the passes: mapped images first (they cannot + /// hold a JS string, so this pass reads roughly a fifth less memory), then + /// all of memory, then all of memory without the tail filter. + pub(super) fn recover(pid: u32, port: u16) -> Outcome { + let raw = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if raw.is_null() { + let error = unsafe { GetLastError() }; + return Outcome::NotFound { + detail: format!("OpenProcess 失败(Win32 错误 {error}), 目标进程可能属于其他用户或已退出"), + }; + } + let handle = ProcessHandle(raw); + + let mut report: Vec = Vec::new(); + for (index, skip_images, tail_filter) in + [(0usize, true, true), (1, false, true), (2, false, false)] + { + let (hit, stats) = scan(&handle, port, skip_images, tail_filter); + report.push(format!("pass{} {stats}", index + 1)); + if let Some(token) = hit { + return Outcome::Found { + token, + stats: report.join("; "), + }; + } + } + Outcome::NotFound { + detail: report.join("; "), + } + } +} From d46ae53613d5003cfcf9f178a5cd733d9ac505ea Mon Sep 17 00:00:00 2001 From: deepseek Date: Wed, 16 Sep 2026 22:37:34 +0800 Subject: [PATCH 2/3] perf(auth): anchor-first scanning and parallel token probes Measured on this machine's live 3080 instance with the reference PowerShell scanner: an unanchored whole-memory pass yields 656 candidates and the real token sat on the 649th probe, while the anchored pass (the printed URL's token=) sees single-digit candidates. Both numbers are now part of the design. - pass 1 scans only what follows the token= anchor; the unanchored passes stay as the reliable fallback, because the two scans are not subsets of each other (a token followed by base64url bytes is not a maximal run) - probe batches run on 8 lanes with an early stop, so a whole-memory pass no longer spends most of its time on serial HTTP round-trips - CHANGELOG records the measured baseline --- CHANGELOG.md | 8 +-- src-tauri/src/memtok.rs | 156 +++++++++++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b59c..b815d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ **复用(attach)实例不再卡在「是否重启接管」:从运行中的进程内存里取回 launch token**: - **新增 `src-tauri/src/memtok.rs`**(纯 `kernel32` 裸 FFI,不加依赖):DSH 的 launch token 只存在于运行进程的内存里(打印过的那一行早被 GC 回收),attach 实例又没有本应用自己的子进程控制台尾可读——此前唯一出路是弹窗让用户重启接管。现在直接读监听 3080 那个进程的内存:枚举可读内存区 → 收集 43 字符 base64url 的**极大串**(附加尾字符约束 `AEIMQUYcgkosw048`——43 字符编码 32 字节时末位只承载 4 bit,这是长度带来的数学必然,不是对内容的猜测)→ 逐个候选走 HTTP 交换,**只有回 303 且签发 `dsh-auth-*` cookie 的才算数**(形状永不作为证据) -- **三遍递进**:先跳过 MEM_IMAGE(映射的 DLL 代码不可能存 JS 字符串,少读约 160MB)→ 再全量内存 → 最后去掉尾字符约束兜底(防上游改编码) -- **速度**:4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即探测、命中即刻停扫(early stop);探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头 +- **四遍递进, 锚点优先**:先只认打印行 `token=` 之后的 43 字符(实测同一实例:锚点法候选由数百降到个位数)→ 再扫全内存的极大 base64url 串(先跳过 MEM_IMAGE,映射的 DLL 代码不可能存 JS 字符串,少读约 160MB)→ 全量内存 → 最后去掉尾字符约束兜底(防上游改编码) +- **速度**(实测本机 3080 实例基线:扫描 592.5MB / 656 个候选 / 2.5s):4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即 **8 路并行探测**、命中即刻停扫(early stop)。串行探测在数百个候选下会变成主要开销——实测真令牌出现在第 649 次探测上, 这个量级必须并行;探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头 - **回退**:无监听进程 / `OpenProcess` 失败(跨用户或已退出)/ 无候选 / 候选全不通过 → 落到原来的「是(壳接管重启并自动认证)/ 否(退出保留外部实例)」弹窗,文案改为说明已尝试内存提取;每次运行最多扫 2 次,同一令牌不重复导航(前端登录插件在 HTTP 层拦 token URL 时不会变成扫描环) - **不落盘**:全程只读(`ReadProcessMemory`),令牌明文绝不写日志——不落盘正是这套认证的设计前提;日志只记扫描统计(区域数/跳过映像/读取量/候选数/探测数/耗时/是否早停) - **CI**:`build-shell.yml` 增加 `pull_request` 触发,PR 只构建并上传 exe 以校验可编译,不递增版本、不发布 Release @@ -14,8 +14,8 @@ Attach-mode token recovery from the running process's memory (no more "restart and take over?" on reuse): - New `src-tauri/src/memtok.rs` (raw `kernel32` FFI, no new dependency): the launch token lives only in the running process, and an attached instance has no console tail of ours to read — so reusing a running `dsh web` used to end at the takeover prompt. The shell now reads the listener's memory: enumerate readable regions → harvest every maximal 43-character base64url run (plus the tail-character constraint `AEIMQUYcgkosw048`, a mathematical consequence of encoding exactly 32 bytes) → exchange each candidate over HTTP and accept only one that answers `303` while minting the `dsh-auth-*` cookie. Shape is never accepted as proof -- Three passes: skip MEM_IMAGE first (mapped executables cannot hold a JS string, ~160MB less to read), then all memory, then all memory without the tail filter -- Fast: 4MB `ReadProcessMemory` chunks, `min(CPU,8)` workers pulling ranges off an atomic queue, and a probe round every 16 candidates so a hit stops the scan immediately; probes are a hand-written minimal HTTP/1.1 exchange with an explicit `Host` header (without it the server always answers 401 — the cookie name and its signed audience both derive from the request authority) +- Four passes, most specific first: the 43 characters behind the printed URL's `token=` anchor (measured on a live instance: single-digit candidates instead of hundreds), then every maximal base64url run over private memory (mapped executables cannot hold a JS string, ~160MB less to read), then all memory, then all memory without the tail filter +- Fast (measured baseline on this machine's 3080 instance: 592.5MB scanned, 656 candidates, 2.5s): 4MB `ReadProcessMemory` chunks, `min(CPU,8)` workers pulling ranges off an atomic queue, and a probe round every 16 candidates across 8 lanes so a hit stops the scan immediately — the real token showed up on the 649th probe, and that volume has to be probed in parallel. Probes are a hand-written minimal HTTP/1.1 exchange with an explicit `Host` header (without it the server always answers 401 — the cookie name and its signed audience both derive from the request authority) - Fallback: no listener, `OpenProcess` denied, no candidate, or nothing verifying → the original 「是」take over / 「否」quit prompt, reworded to say memory recovery was tried. At most two scans per run and the same token is never navigated twice, so a front-auth plugin intercepting the token URL cannot turn into a scan loop - Read-only and disk-free: `ReadProcessMemory` only; the token never reaches the log, because not landing on disk is the whole point of this auth - CI: `build-shell.yml` also runs on pull requests to prove the change compiles; a PR bumps no version and publishes no release diff --git a/src-tauri/src/memtok.rs b/src-tauri/src/memtok.rs index a47d503..0a437ff 100644 --- a/src-tauri/src/memtok.rs +++ b/src-tauri/src/memtok.rs @@ -10,9 +10,10 @@ //! //! 1. enumerate the committed, readable regions of the process listening on //! the port; -//! 2. harvest every maximal base64url run of exactly the token length, with -//! the 32-byte tail signature that `encodeBase64Url(randomBytes(32))` makes -//! a mathematical certainty; +//! 2. harvest candidates — first the 43 characters behind the printed URL's +//! `token=` anchor, then every maximal base64url run of exactly the token +//! length, filtered by the 32-byte tail signature that +//! `encodeBase64Url(randomBytes(32))` makes a mathematical certainty; //! 3. exchange each candidate over HTTP and accept only the one that answers //! 303 while minting the `dsh-auth-*` browser session cookie. //! @@ -80,10 +81,21 @@ mod imp { /// token found early stops the scan early. const BATCH_SIZE: usize = 16; const MAX_WORKERS: usize = 8; + /// Candidates probed at once inside one batch. A whole-memory pass yields + /// hundreds of candidates and the real token is often near the end of them, + /// so probing one at a time would make the HTTP round-trips the dominant + /// cost of the whole recovery. + const PROBE_LANES: usize = 8; /// Per-candidate probe timeout. A live `dsh web` answers a loopback GET in /// single-digit milliseconds, so anything slower is a dead or wedged server. const PROBE_TIMEOUT: Duration = Duration::from_millis(1500); + /// The `token=` of the printed launch URL. Passing it as the anchor narrows + /// a pass to the one place the token is known to appear verbatim; measured + /// on a live instance the anchored pass sees single-digit candidates where + /// the unanchored pass sees hundreds. + const TOKEN_ANCHOR: &[u8] = b"token="; + /// Characters a 43-character base64url encoding of exactly 32 bytes can end /// with. The final character carries only the last 4 input bits, so its /// 6-bit value is a multiple of four. This is a consequence of the token's @@ -238,7 +250,7 @@ mod imp { /// truncated. `tail_filter` additionally requires the final character to be /// reachable by a 32-byte payload; the last pass drops it to stay correct if /// the upstream token encoding ever changes. - fn collect(buffer: &[u8], tail_filter: bool, out: &mut Vec) { + fn collect_runs(buffer: &[u8], tail_filter: bool, out: &mut Vec) { let length = buffer.len(); for i in 0..length { if !BASE64URL[buffer[i] as usize] { @@ -252,27 +264,60 @@ mod imp { if end > length { break; } - let mut shaped = true; - for k in 1..TOKEN_LEN { - if !BASE64URL[buffer[i + k] as usize] { - shaped = false; - break; - } - } - if !shaped { + if !shaped(buffer, i, end) { continue; } if end < length && BASE64URL[buffer[end] as usize] { continue; } - if tail_filter && !TAIL[buffer[end - 1] as usize] { + accepted(buffer, i, end, tail_filter, out); + } + } + + /// Append every token that starts immediately after `marker`. + /// + /// The marker itself is not base64url, so a match is by construction the + /// start of its run — no predecessor check is needed. + fn collect_anchored(buffer: &[u8], marker: &[u8], tail_filter: bool, out: &mut Vec) { + let span = marker.len() + TOKEN_LEN; + if buffer.len() < span { + return; + } + for i in 0..=buffer.len() - span { + if !buffer[i..].starts_with(marker) { + continue; + } + let start = i + marker.len(); + let end = start + TOKEN_LEN; + if !shaped(buffer, start, end) { continue; } - // Every byte passed the base64url table, so the slice is ASCII. - match std::str::from_utf8(&buffer[i..end]) { - Ok(token) => out.push(token.to_string()), - Err(_) => continue, + if end < buffer.len() && BASE64URL[buffer[end] as usize] { + continue; } + accepted(buffer, start, end, tail_filter, out); + } + } + + /// True when every byte of `buffer[start..end]` is base64url. + fn shaped(buffer: &[u8], start: usize, end: usize) -> bool { + (start..end).all(|at| BASE64URL[buffer[at] as usize]) + } + + /// Apply the tail filter and push the token when it passes. + fn accepted( + buffer: &[u8], + start: usize, + end: usize, + tail_filter: bool, + out: &mut Vec, + ) { + if tail_filter && !TAIL[buffer[end - 1] as usize] { + return; + } + // Every byte passed the base64url table, so the slice is ASCII. + if let Ok(token) = std::str::from_utf8(&buffer[start..end]) { + out.push(token.to_string()); } } @@ -326,8 +371,9 @@ mod imp { }) } - /// Probe the candidates of one batch, skipping any already probed by another - /// worker. Returns the first candidate that verifies. + /// Probe the candidates of one batch on several lanes at once, skipping any + /// already probed by another worker. Returns the first candidate that + /// verifies; that hit stops the remaining lanes. fn probe_batch( batch: &[String], port: u16, @@ -345,13 +391,40 @@ mod imp { .cloned() .collect() }; - for candidate in fresh { - probed.fetch_add(1, Ordering::Relaxed); - if probe(&candidate, port) { - return Some(candidate); - } + if fresh.is_empty() { + return None; } - None + + let winner: Mutex> = Mutex::new(None); + let stop = AtomicBool::new(false); + let next = AtomicUsize::new(0); + let lanes = fresh.len().min(PROBE_LANES); + + std::thread::scope(|scope| { + for _ in 0..lanes { + scope.spawn(|| loop { + if stop.load(Ordering::Relaxed) { + break; + } + let index = next.fetch_add(1, Ordering::Relaxed); + if index >= fresh.len() { + break; + } + probed.fetch_add(1, Ordering::Relaxed); + if probe(&fresh[index], port) { + stop.store(true, Ordering::Relaxed); + if let Ok(mut slot) = winner.lock() { + if slot.is_none() { + *slot = Some(fresh[index].clone()); + } + } + break; + } + }); + } + }); + + winner.lock().ok().and_then(|mut slot| slot.take()) } /// One full pass: enumerate, then read the regions on several threads, @@ -359,6 +432,7 @@ mod imp { fn scan( process: &ProcessHandle, port: u16, + anchor: Option<&[u8]>, skip_images: bool, tail_filter: bool, ) -> (Option, PassStats) { @@ -450,7 +524,15 @@ mod imp { }; if ok != 0 && read > 0 { scanned.fetch_add(read as u64, Ordering::Relaxed); - collect(&buffer[..read], tail_filter, &mut batch); + match anchor { + Some(marker) => collect_anchored( + &buffer[..read], + marker, + tail_filter, + &mut batch, + ), + None => collect_runs(&buffer[..read], tail_filter, &mut batch), + } } if batch.len() >= BATCH_SIZE { candidates.fetch_add(batch.len(), Ordering::Relaxed); @@ -488,9 +570,11 @@ mod imp { (hit, stats) } - /// Open the process and run the passes: mapped images first (they cannot - /// hold a JS string, so this pass reads roughly a fifth less memory), then - /// all of memory, then all of memory without the tail filter. + /// Open the process and run the passes, cheapest and most specific first: + /// the printed launch URL's `token=` anchor, then every maximal base64url + /// run over private memory (mapped images cannot hold a JS string, so that + /// pass reads roughly a fifth less), then the same over all of memory, and + /// finally without the tail filter. pub(super) fn recover(pid: u32, port: u16) -> Outcome { let raw = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; if raw.is_null() { @@ -502,11 +586,15 @@ mod imp { let handle = ProcessHandle(raw); let mut report: Vec = Vec::new(); - for (index, skip_images, tail_filter) in - [(0usize, true, true), (1, false, true), (2, false, false)] - { - let (hit, stats) = scan(&handle, port, skip_images, tail_filter); - report.push(format!("pass{} {stats}", index + 1)); + for (index, anchor, skip_images, tail_filter) in [ + (0usize, Some(TOKEN_ANCHOR), true, true), + (1, None, true, true), + (2, None, false, true), + (3, None, false, false), + ] { + let (hit, stats) = scan(&handle, port, anchor, skip_images, tail_filter); + let kind = if anchor.is_some() { "anchor" } else { "runs" }; + report.push(format!("pass{}[{kind}] {stats}", index + 1)); if let Some(token) = hit { return Outcome::Found { token, From 1a75504625f2e9f1f9ff46e0778ead2938a1f324 Mon Sep 17 00:00:00 2001 From: deepseek Date: Wed, 16 Sep 2026 22:44:56 +0800 Subject: [PATCH 3/3] docs(changelog): drop the stray space in the v2.0.6 note --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b815d9e..574f6d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **新增 `src-tauri/src/memtok.rs`**(纯 `kernel32` 裸 FFI,不加依赖):DSH 的 launch token 只存在于运行进程的内存里(打印过的那一行早被 GC 回收),attach 实例又没有本应用自己的子进程控制台尾可读——此前唯一出路是弹窗让用户重启接管。现在直接读监听 3080 那个进程的内存:枚举可读内存区 → 收集 43 字符 base64url 的**极大串**(附加尾字符约束 `AEIMQUYcgkosw048`——43 字符编码 32 字节时末位只承载 4 bit,这是长度带来的数学必然,不是对内容的猜测)→ 逐个候选走 HTTP 交换,**只有回 303 且签发 `dsh-auth-*` cookie 的才算数**(形状永不作为证据) - **四遍递进, 锚点优先**:先只认打印行 `token=` 之后的 43 字符(实测同一实例:锚点法候选由数百降到个位数)→ 再扫全内存的极大 base64url 串(先跳过 MEM_IMAGE,映射的 DLL 代码不可能存 JS 字符串,少读约 160MB)→ 全量内存 → 最后去掉尾字符约束兜底(防上游改编码) -- **速度**(实测本机 3080 实例基线:扫描 592.5MB / 656 个候选 / 2.5s):4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即 **8 路并行探测**、命中即刻停扫(early stop)。串行探测在数百个候选下会变成主要开销——实测真令牌出现在第 649 次探测上, 这个量级必须并行;探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头 +- **速度**(实测本机 3080 实例基线:扫描 592.5MB / 656 个候选 / 2.5s):4MB 分块 `ReadProcessMemory` + 多线程 `min(CPU,8)` 从原子队列领区间 + 攒够 16 个候选即 **8 路并行探测**、命中即刻停扫(early stop)。串行探测在数百个候选下会变成主要开销——实测真令牌出现在第 649 次探测上,这个量级必须并行;探测是手写的最小 HTTP/1.1 over TCP,显式带 `Host` 头(缺它必然 401——cookie 名与签名 audience 都由请求 authority 派生),只读响应头 - **回退**:无监听进程 / `OpenProcess` 失败(跨用户或已退出)/ 无候选 / 候选全不通过 → 落到原来的「是(壳接管重启并自动认证)/ 否(退出保留外部实例)」弹窗,文案改为说明已尝试内存提取;每次运行最多扫 2 次,同一令牌不重复导航(前端登录插件在 HTTP 层拦 token URL 时不会变成扫描环) - **不落盘**:全程只读(`ReadProcessMemory`),令牌明文绝不写日志——不落盘正是这套认证的设计前提;日志只记扫描统计(区域数/跳过映像/读取量/候选数/探测数/耗时/是否早停) - **CI**:`build-shell.yml` 增加 `pull_request` 触发,PR 只构建并上传 exe 以校验可编译,不递增版本、不发布 Release