Repository Wiki
BeyondDimension/SteamTools

权限提升与 AppHost 宿主

本文档介绍 SteamTools(Watt Toolkit)在 Windows 平台上的原生启动器宿主 BD.WTTS.Client.AppHost —— 一个用旧版 .NET Framework(net35/net40)编译的引导程序,负责探测并加载 .NET 运行时、以非托管方式启动托管入口 BD.WTTS.Program.CustomEntryPoint;以及配套的权限提升(UAC Elevation)检测机制,包括 TOKEN_ELEVATION 令牌查询与 MSIX 打包清单中的 allowElevation 能力声明。

Purpose and Scope

本页覆盖以下内容:

  • AppHost 宿主:src/BD.WTTS.Client.AppHost/Program.cs 的完整启动控制流 —— .NET 运行时探测(STEP 0)、hostfxr 加载与导出函数获取(STEP 1)、运行时初始化与委托获取(STEP 2)、托管程序集加载与入口调用(STEP 3)。
  • 宿主完整性校验:通过 FileVersionInfo 对托管 DLL 的元数据(Comments/CompanyName/LegalCopyright/LegalTrademarks)进行防篡改校验。
  • 权限提升检测:WindowsPlatformServiceImpl.BypassUAC.cs 中基于 Win32 GetTokenInformation + TokenElevation 的进程令牌提权状态判断。
  • MSIX 打包侧的提权能力:MSIXHelper.cs 生成的清单中 runFullTrust 与 allowElevation 受限能力声明。
  • 宿主退出码体系:ExitCode 枚举(5500/5701 起)作为宿主与安装器/外壳之间的诊断契约。

以下内容不在本页范围,由兄弟页面承接:

  • 具体的 UAC 绕过策略细节(计划任务/COM 提权等)属于客户端平台服务实现的一部分,本页仅记录已验证的令牌检测入口;完整的 WindowsPlatformServiceImpl 服务族请参见对应平台服务页面。
  • 加速器、ASF 等业务模块的运行时加载不属于宿主职责(AppHost 中相关探测代码已被注释为 const bool requireAspNetCore = true)。

Overview

SteamTools 的主程序 Steam++(Avalonia 客户端)运行在现代 .NET(源码中当前为 11.0.0-preview.7.26381.103)之上,但 Windows 用户的机器上可能没有安装任何 .NET 运行时。为了让安装包在"裸机"上也能直接启动,项目引入了一个极小体积的引导宿主 BD.WTTS.Client.AppHost:

  1. 它以 net35/net40 目标编译,Windows Vista 及以上系统自带对应 CLR,无需任何额外运行时即可运行;
  2. 它按 应用目录/dotnet → Program Files/dotnet → DOTNET_ROOT 环境变量 的优先级探测本机 .NET 运行时;
  3. 找到后通过 NativeLibrary.Load 加载 hostfxr.dll,用非托管函数指针(delegate* unmanaged[Cdecl])完成 hostfxr_initialize_for_runtime_config → hostfxr_get_runtime_delegate → load_assembly_and_get_function_pointer 三段式调用,最终把控制权交给托管入口 BD.WTTS.Program.CustomEntryPoint;
  4. 若运行时缺失,弹出本地化错误框并引导用户跳转 .NET 官方下载页,返回 ExitCode.FrameworkMissingFailure。

权限提升方面,客户端在 WindowsPlatformServiceImpl.BypassUAC.cs 中定义了 TOKEN_ELEVATION 结构并通过 PInvoke.AdvApi32.GetTokenInformation 查询 TokenElevation 信息类,以判断当前进程令牌是否已提权(TokenIsElevated);同时发布管道 MSIXHelper.cs 在生成的 AppxManifest 中声明 runFullTrust 与 allowElevation 受限能力,使 MSIX 打包版本在保持全信任执行的同时允许触发 UAC 提权。

Architecture

Loading diagram...

上图反映了真实的组件分层:

  • 引导层是 AppHost 项目本身(Program.cs + MessageBox.cs/NativeLibrary.cs/Net35.cs/Strings.cs 四个辅助文件),它必须兼容 net35/net40 到 net7+ 多个目标,因此大量使用 #if 条件编译;
  • 运行时探测是 MainCore 中的 for (byte i = 0; true; i++) 循环,用 DotNetRootType 枚举作为循环变量依次枚举三种候选根目录;
  • 宿主 API 层即 .NET 官方定义的 hostfxr 三件套导出函数,通过函数指针直接互操作;
  • 托管层是被启动的真正客户端 Steam++,其中 BypassUAC 分部文件提供提权检测;
  • 发布管道在 MSIX 清单层面声明提权相关能力。

设计意图

为什么不用 dotnet Steam++.dll 直接启动?因为那要求用户先装好运行时,且启动入口受 apphost.exe(微软生成的通用宿主)约束。自研 AppHost 带来三个收益:

  1. 零依赖冷启动:net35/net40 目标使引导程序在无任何 .NET 的系统上也能运行并给出可操作的错误提示(引导下载);
  2. 版本协商:AppHost 会在 host/fxr 下按 {major}.* 通配取最高已安装版本(Directory.GetDirectories(...).Max()),并支持通过 App.config 的 d 键或静态构造函数覆盖目标运行时版本,避免硬编码补丁版本导致的新机器无法启动;
  3. 供应链自校验:加载托管 DLL 前用 FileVersionInfo 比对其 Comments/CompanyName/LegalCopyright/LegalTrademarks 四项元数据与 AssemblyInfo 常量,任何一项不符立即以专属退出码退出,防止被替换的 Steam++.dll 借宿主之名运行。

Core Flow

以下时序图描述 AppHost 从双击 exe 到托管入口执行的真实调用链(对应 MainCore 中 STEP 0 → STEP 3 的顺序):

Loading diagram...

几个关键实现细节:

  • GC 策略前置:MainCore 在做任何事之前先 Environment.SetEnvironmentVariable("DOTNET_GCConserveMemory", "9", Process)。因为宿主进程就是最终客户端进程,这个环境变量必须在运行时初始化前设置才会生效,值 9 表示最大程度节流内存(更小堆、更频繁 GC)。
  • 无参数才做兼容检查:if (args.Length == 0 && !CompatibilityCheck(baseDirectory)) return 0; —— 只有用户直接双击时才弹出兼容性引导,命令行/脚本调用跳过。
  • DEBUG 回退路径:File.Exists 检查失败时,Release 构建直接报 EntryPointFileNotFound;Debug 构建则回退到 src/BD.WTTS.Client.Avalonia.App/bin/Debug/net{ver}-windows10.0.19041/ 的本地编译输出,方便开发调试。

实现详解

STEP 0:运行时探测与版本协商

MainCore 用一个无限 for 循环驱动 DotNetRootType 枚举,i 超出枚举范围时 dotnet_root 为 null,触发"运行时缺失"分支:

csharp
1var dotnet_root = i switch 2{ 3 (byte)DotNetRootType.BaseDir => Path.Combine(baseDirectory, "dotnet"), // 优先使用根目录上的运行时 4#if NETFRAMEWORK || WINDOWS 5 (byte)DotNetRootType.ProgramFiles => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet"), // 查找已安装的运行时 6#endif 7 (byte)DotNetRootType.EnvironmentVariable => Environment.GetEnvironmentVariable("DOTNET_ROOT") ?? string.Empty, // 检查环境变量中设定的路径 8 _ => null, 9};

Source: Program.cs

候选目录非空后,按主版本通配符取最高补丁版本,并把 hostfxr 与两个共享框架目录一并解析出来:

csharp
1string usable_dotnet_version = dotnet_version; 2var dotnet_version_max = Directory.GetDirectories(dir_hostfxr_path, $"{dotnet_version_major}.*").Select(x => 3 { 4 try 5 { 6 return new Version(Path.GetFileName(x)); 7 } 8 catch 9 { 10 return null; 11 } 12 }).Where(x => x != null).Max(); 13if (dotnet_version_max != null) 14{ 15 usable_dotnet_version = dotnet_version_max.ToString(); 16}

Source: Program.cs

Version 比较语义保证 11.0.1 会覆盖 11.0.0;try/catch 过滤掉无法解析的目录名(如残留的 11.0.0-preview 目录在 new Version(...) 抛异常时被剔除)。校验条件是 hostfxr 文件存在、shared/Microsoft.NETCore.App/{ver} 目录存在且非空、且 requireAspNetCore 为真时 shared/Microsoft.AspNetCore.App/{ver} 同样非空——三者齐备才 break 跳出循环。

目标版本本身可被两层覆盖:静态构造函数中的默认值(11.0.0-preview.7.26381.103),以及 NETFRAMEWORK 下 App.config 的 d 键。后者支持 1、1.0、1.0.0-pre 三种段数,并显式解析 - 后的 PRE/RC 后缀字符串,避免预发布版本号导致 int.TryParse 失败。

STEP 1:加载 hostfxr 与获取导出

AppHost 没有使用官方 nethost 库,而是自己定位 hostfxr.dll 路径后用 NativeLibrary.Load 加载,再 GetExport 三个导出函数到非托管函数指针:

csharp
1var lib = NativeLibrary.Load(hostfxr_path); 2init_fptr = (delegate* unmanaged[Cdecl]<nint, nint, out nint, int>) 3 NativeLibrary.GetExport(lib, "hostfxr_initialize_for_runtime_config"); 4get_delegate_fptr = (delegate* unmanaged[Cdecl]<nint, hostfxr_delegate_type, out delegate* unmanaged[Cdecl]<nint, nint, nint, nint, nint, out delegate* unmanaged[Cdecl]<nint, int, int>, int>, int>) 5 NativeLibrary.GetExport(lib, "hostfxr_get_runtime_delegate"); 6close_fptr = (delegate* unmanaged[Cdecl]<nint, int>) 7 NativeLibrary.GetExport(lib, "hostfxr_close");

Source: Program.cs

注意 get_delegate_fptr 的签名:它通过 out 参数返回的正是 load_assembly_and_get_function_pointer 本身的函数指针类型(<nint, nint, nint, nint, nint, out delegate* unmanaged[Cdecl]<nint, int, int>, int>),即官方 native hosting 文档中的 load_assembly_and_get_function_pointer_fn。任一指针为 default 即返回 ExitCode.Failure_load_hostfxr。

STEP 2-3:初始化运行时并移交控制权

csharp
1rc = init_fptr(config_path_, default, out cxt); 2... 3rc = get_delegate_fptr( 4 cxt, 5 hostfxr_delegate_type.hdt_load_assembly_and_get_function_pointer, 6 out load_assembly_and_get_function_pointer); 7... 8close_fptr(cxt);

Source: Program.cs

hostfxr_initialize_for_runtime_config 的第二个参数是 hostfxr_initialize_parameters 结构体指针,此处传 default(仅使用 Steam++.runtimeconfig.json 推断框架);取得委托后立即 hostfxr_close(cxt) 释放上下文——这是官方推荐做法,运行时一旦拿到 hdt_load_assembly_and_get_function_pointer 委托就已加载完毕。所有 Marshal.StringToHGlobalUni 分配的非托管字符串都在 finally 中 FreeHGlobal,防止引导层泄漏。最后:

csharp
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced); var exitCode = main(default, default); return exitCode;

Source: Program.cs

强制 GC 清理引导阶段临时对象,然后以 (default, default)(argv 与参数缓冲均为空)调用托管入口 BD.WTTS.Program.CustomEntryPoint,其返回值直接成为进程退出码。

托管程序集防篡改校验

csharp
1var fvi = FileVersionInfo.GetVersionInfo(dotnetlib_path); 2if (fvi.Comments != Description) 3 return (int)ExitCode.Failure_fvi_Description; 4if (fvi.CompanyName != Company) 5 return (int)ExitCode.Failure_fvi_Company; 6if (fvi.LegalCopyright != Copyright) 7 return (int)ExitCode.Failure_fvi_Copyright; 8if (fvi.LegalTrademarks != Trademark) 9 return (int)ExitCode.Failure_fvi_Trademark;

Source: Program.cs

常量 Description/Company/Copyright/Trademark 来自 BD.WTTS.AssemblyInfo(using static BD.WTTS.AssemblyInfo;)。这是轻量级完整性门禁:宿主与被加载 DLL 由同一仓库同一版本编译,元数据不匹配意味着 DLL 被篡改或版本错配。四项各自对应独立退出码,便于外部诊断。

权限提升实现

TOKEN_ELEVATION 令牌检测

客户端平台的 WindowsPlatformServiceImpl.BypassUAC.cs 复刻了 MSDN bb530717 文档中的结构定义,通过 AdvApi32 查询进程令牌提权状态:

csharp
1TOKEN_ELEVATION elevation = default; 2if (PInvoke.AdvApi32.GetTokenInformation( 3 token, 4 PInvoke.AdvApi32.TOKEN_INFORMATION_CLASS.TokenElevation, 5 &elevation, 6 sizeof(TOKEN_ELEVATION), 7 out _)) 8{ 9 return elevation.TokenIsElevated != BOOL.FALSE; 10}

Source: WindowsPlatformServiceImpl.BypassUAC.cs

配套的结构定义:

csharp
1// https://msdn.microsoft.com/en-us/library/windows/desktop/bb530717.aspx 2struct TOKEN_ELEVATION 3{ 4 ... 5}

Source: WindowsPlatformServiceImpl.BypassUAC.cs

工作原理:GetTokenInformation 以 TokenElevation 类查询当前进程访问令牌,写入 TOKEN_ELEVATION.TokenIsElevated(DWORD)。当进程由 UAC 拆分令牌的完整(提升)部分运行时该值为非零,BOOL.FALSE 比较即转换为 bool。这决定了后续"是否还需要再次提权/是否可写入受保护位置(如 hosts 文件)"的分支。宿主测试工程 BD.WTTS.Client.Tools.HostsTest/Program.cs 中存在完全相同的实现片段,说明该检测逻辑被独立验证过。

MSIX 打包侧的提权能力

发布管道在生成的 AppxManifest 中声明受限能力,允许打包后的应用触发提权并保持全信任:

csharp
<rescap:Capability Name="runFullTrust"/> <rescap:Capability Name="allowElevation"/>

Source: MSIXHelper.cs

runFullTrust 使 MSIX 应用以完整桌面权限运行(而非 AppContainer 沙箱),allowElevation 则显式授权它显示 UAC 提权提示——默认情况下 MSIX 打包应用不允许触发提权。这两个声明与 AppHost 引导层配合:MSIX 外壳先以全信任方式拉起 AppHost,AppHost 再在需要时(如写 hosts、改代理)借助允许提权的能力获得管理员令牌。

Usage Examples

平台/架构兼容辅助方法

AppHost 需要从 net35 一路编译到 net7+,大量辅助方法通过条件编译桥接新旧 API。架构检测就是一个典型例子——旧框架下通过反射读取 RuntimeInformation.ProcessArchitecture,失败则退化到指针宽度判断:

csharp
1static Architecture GetProcessArchitecture() 2{ 3 Architecture processArchitecture; 4#if NET471_OR_GREATER || NETCOREAPP 5 processArchitecture = RuntimeInformation.ProcessArchitecture; 6#else 7 try 8 { 9 processArchitecture = (Architecture)Type.GetType("System.Runtime.InteropServices.RuntimeInformation").GetProperty("ProcessArchitecture", BindingFlags.Public | BindingFlags.Static).GetValue(null, null); 10 } 11 catch 12 { 13 processArchitecture = 14#if NET35 15 IntPtr.Size == 8 16#else 17 Environment.Is64BitProcess 18#endif 19 ? Architecture.X64 : Architecture.X86; 20 } 21#endif 22 return processArchitecture; 23}

Source: Program.cs

该结果用于错误提示文案:运行时缺失时把 x86/x64/Arm32/Arm64 架构名代入 _NetRuntime/_AspNetCoreRuntime 格式串,让用户下载正确架构的运行时。

运行时缺失的用户引导

csharp
1// 此应用程序必须安装 {0} 才能运行,你想现在就下载并安装运行时吗? 2var archStr = ToString(GetProcessArchitecture()); 3var _NetRuntime = string.Format(NetRuntimeFormat1, archStr); 4string _Runtime; 5if (requireAspNetCore) 6{ 7 var _AspNetCoreRuntime = string.Format(AspNetCoreRuntimeFormat1, archStr); 8 _Runtime = $"{_AspNetCoreRuntime} {And} {_NetRuntime}"; 9} 10else 11{ 12 _Runtime = _NetRuntime; 13} 14var text = string.Format(FrameworkMissingFailureFormat1, _Runtime); 15var result = ShowErrMessageBox(text, WPFMessageBoxButton.YesNo); 16if (result == WPFMessageBoxResult.Yes) 17{ 18 DownloadDotNetRuntime(); 19} 20return (int)ExitCode.FrameworkMissingFailure;

Source: Program.cs

DownloadDotNetRuntime 拼接 https://dotnet.microsoft.com/{lang}/download/dotnet/{major}.{minor} 并以 UseShellExecute = true 打开默认浏览器,GetLang() 返回用户语言以本地化下载页。所有文案常量(NetRuntimeFormat1、FrameworkMissingFailureFormat1 等)集中在 Strings.cs 与 BD.WTTS.Client.Resources.Strings 资源中。

Configuration Options

配置项类型默认值说明
d(App.config AppSettings,仅 NETFRAMEWORK)string无覆盖目标 .NET 运行时版本,支持 1 / 1.0 / 1.0.0-pre 三种段数,长度上限 sbyte.MaxValue;解析结果为空或版本号非法时退出码 Failure_read_dotnet_version
dotnet_version_major/minor/build/preOrRcString(静态构造)string11 / 0 / 0 / -preview.7.26381.103宿主内建的目标运行时版本,dotnet_version 据此拼装
DOTNET_ROOT(进程环境变量)string空STEP 0 的第三优先级运行时根目录探测来源
DOTNET_GCConserveMemory(进程环境变量,宿主设置)string9MainCore 启动即写入,指示 GC 最大程度节省内存;必须在运行时初始化前设置
{app}/dotnet(目录)--STEP 0 第一优先级:随应用分发的自包含运行时根目录
C:/Program Files/dotnet(目录)--STEP 0 第二优先级:系统安装的运行时(仅 NETFRAMEWORK/WINDOWS 目标存在此候选)
requireAspNetCore(局部常量)booltrue是否要求同时存在 Microsoft.AspNetCore.App 共享框架;原动态探测逻辑(检查 modules/Accelerator、modules/ArchiSteamFarmPlus 目录)已被注释禁用

API Reference

static int Main(string[] args) / static int MainCore(string[] args)

说明:AppHost 进程入口。Release 下 Main 即 MainCore 主体;Debug 下 Main 包裹 MainCore 捕获全部异常打印并 Console.ReadLine() 便于断点调试,异常时返回 InternalServerError(5500)。带 [STAThread],且 NET40+ 标注 HandleProcessCorruptedStateExceptions。

参数:

  • args (string[]):命令行参数;args.Length == 0 时才执行 CompatibilityCheck 兼容性引导。

返回: 进程退出码。正常路径为托管入口 CustomEntryPoint 的返回值;失败路径为 ExitCode 枚举值。

delegate* unmanaged[CDecl] 宿主函数指针三件套

指针导出名作用
init_fptrhostfxr_initialize_for_runtime_config以 Steam++.runtimeconfig.json 初始化宿主上下文 cxt
get_delegate_fptrhostfxr_get_runtime_delegate请求 hdt_load_assembly_and_get_function_pointer 委托,触发 .NET 运行时加载
close_fptrhostfxr_close释放 cxt 上下文

ExitCode 枚举(诊断契约)

csharp
1enum ExitCode 2{ 3 InternalServerError = 5500, 4 5 Failure_load_hostfxr = 5701, 6 Failure_get_dotnet_load_assembly, 7 Failure_load_assembly_and_get_function_pointer, 8 FrameworkMissingFailure, 9 EntryPointFileNotFound, 10 11 Failure_read_dotnet_version, 12 13 Failure_fvi_Description, 14 Failure_fvi_Company, 15 Failure_fvi_Copyright, 16 Failure_fvi_Trademark, 17}

Source: Program.cs

从 5701 起连续递增(57025709 依次为后续成员),5500 为内部异常兜底。外部安装器/启动器可据此区分"缺运行时"(5704,可引导安装)与"程序集校验失败"(57075710,需重新下载)。

Failure Modes, Edge Cases & Concurrency

  • 运行时根目录为空目录/不存在:PathIsDirectoryEmpty 判定后 continue 进入下一候选。NET35/NET40 用 shlwapi.dll 的 PathIsDirectoryEmptyW P/Invoke,NET7+WINDOWS 用 LibraryImport 源生成封送,其余平台用 Directory.EnumerateFileSystemEntries().Any() 的托管实现——注释明确指出这是为了修复"Files 枚举不到文件夹数量而被误判为空"的问题;DirectoryNotFoundException 视为空。
  • hostfxr 目录含非法版本名:new Version(...) 抛异常时返回 null 并被 Where(x => x != null) 过滤,不中断探测。
  • App.config 版本解析失败:外层 try/catch 吞掉异常,dotnet_version == default 时返回 Failure_read_dotnet_version;段数为 0(解析失败)时回退为 0.0.0 并继续。
  • 打开下载页失败:OpenCoreByProcess 捕获 Win32Exception,以 OpenCoreByProcess_Win32Exception_ 格式(十六进制 NativeErrorCode)弹框提示,不抛出。
  • 调试构建的路径回退:assemblies/ 下缺少 runtimeconfig.json 或 DLL 时,Debug 构建改用 src/BD.WTTS.Client.Avalonia.App/bin/Debug/net{ver}-windows10.0.19041/ 下的本地产物;Release 构建直接 EntryPointFileNotFound。
  • 并发/线程模型:AppHost 是纯引导层,[STAThread] + 无多线程逻辑;hostfxr_close 在取到委托后立即调用,避免运行时初始化期间持有悬空上下文。所有非托管字符串句柄在 finally 中释放,杜绝进程级泄漏。
  • 提权检测的边界:GetTokenInformation 调用失败(返回假)时方法返回 false(视作未提权),调用方据此走需要重新请求管理员的路径,而非假定已提权。

Performance & Operational Notes

  • 启动开销:引导层仅做目录枚举、File.Exists 与版本号解析,无网络/注册表操作;init_fptr 之前唯一的重操作是 FileVersionInfo.GetVersionInfo(读一次文件元数据)。
  • 内存:DOTNET_GCConserveMemory=9 让客户端以更小堆换取更频繁 GC——这是客户端长驻、追求常驻内存的明确取舍,注释中同时指出"暂停时间可能更长"。GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced) 在移交控制权前清理引导临时对象。
  • 包体积权衡:源码 TODO 注释记录了"合并 32 位与 64 位本机库"的构想及其否决理由(体积增加 1.x~2 倍),以及 macOS 上库合并但存储昂贵的对比——体现了对分发包体积的持续约束。
  • 运维诊断:退出码是宿主对外唯一诊断通道,安装/更新失败排障应优先读取进程退出码并对照 ExitCode 表。

Extension Points

  • 切换目标 .NET 版本:发布新版时更新静态构造函数中的 dotnet_version_major/minor/build/preOrRcString 四个字段(或对 NETFRAMEWORK 目标改用 App.config 的 d 键做运行时切换),STEP 0 的 {major}.* 通配会自动协商到已装的最高补丁版本。
  • 按需启用 AspNetCore:RequireAspNetCore(baseDirectory) 的实现(按 modules/Accelerator、modules/ArchiSteamFarmPlus 目录是否非空判断)已完整保留在注释中,若要恢复"仅装了 ASF/加速器模块才要求 AspNetCore 运行时"的体积优化,取消注释并把 const bool requireAspNetCore = true 改回变量即可。
  • 新增退出码:在 ExitCode 枚举尾部追加成员即可获得下一个连续编号,外部工具只需识别新增值。
  • 新增架构支持:ToString(Architecture) 的 switch 补充枚举分支;Architecture 枚举本身在 NET471 以下由 AppHost 自带副本定义(X86=0, X64=1, Arm=2, Arm64=3, Armv6=7,注释保留 Wasm/S390x/LoongArch64/Ppc64le 的占位与 SkiaSharp 兼容性说明)。

Sources

(1 files)