权限提升与 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中基于 Win32GetTokenInformation+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:
- 它以 net35/net40 目标编译,Windows Vista 及以上系统自带对应 CLR,无需任何额外运行时即可运行;
- 它按
应用目录/dotnet → Program Files/dotnet → DOTNET_ROOT 环境变量的优先级探测本机 .NET 运行时; - 找到后通过
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; - 若运行时缺失,弹出本地化错误框并引导用户跳转 .NET 官方下载页,返回
ExitCode.FrameworkMissingFailure。
权限提升方面,客户端在 WindowsPlatformServiceImpl.BypassUAC.cs 中定义了 TOKEN_ELEVATION 结构并通过 PInvoke.AdvApi32.GetTokenInformation 查询 TokenElevation 信息类,以判断当前进程令牌是否已提权(TokenIsElevated);同时发布管道 MSIXHelper.cs 在生成的 AppxManifest 中声明 runFullTrust 与 allowElevation 受限能力,使 MSIX 打包版本在保持全信任执行的同时允许触发 UAC 提权。
Architecture
上图反映了真实的组件分层:
- 引导层是 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 带来三个收益:
- 零依赖冷启动:net35/net40 目标使引导程序在无任何 .NET 的系统上也能运行并给出可操作的错误提示(引导下载);
- 版本协商:AppHost 会在
host/fxr下按{major}.*通配取最高已安装版本(Directory.GetDirectories(...).Max()),并支持通过App.config的d键或静态构造函数覆盖目标运行时版本,避免硬编码补丁版本导致的新机器无法启动; - 供应链自校验:加载托管 DLL 前用
FileVersionInfo比对其Comments/CompanyName/LegalCopyright/LegalTrademarks四项元数据与AssemblyInfo常量,任何一项不符立即以专属退出码退出,防止被替换的Steam++.dll借宿主之名运行。
Core Flow
以下时序图描述 AppHost 从双击 exe 到托管入口执行的真实调用链(对应 MainCore 中 STEP 0 → STEP 3 的顺序):
几个关键实现细节:
- 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,触发"运行时缺失"分支:
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 与两个共享框架目录一并解析出来:
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 三个导出函数到非托管函数指针:
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:初始化运行时并移交控制权
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,防止引导层泄漏。最后:
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
var exitCode = main(default, default);
return exitCode;Source: Program.cs
强制 GC 清理引导阶段临时对象,然后以 (default, default)(argv 与参数缓冲均为空)调用托管入口 BD.WTTS.Program.CustomEntryPoint,其返回值直接成为进程退出码。
托管程序集防篡改校验
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 查询进程令牌提权状态:
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}配套的结构定义:
1// https://msdn.microsoft.com/en-us/library/windows/desktop/bb530717.aspx
2struct TOKEN_ELEVATION
3{
4 ...
5}工作原理:GetTokenInformation 以 TokenElevation 类查询当前进程访问令牌,写入 TOKEN_ELEVATION.TokenIsElevated(DWORD)。当进程由 UAC 拆分令牌的完整(提升)部分运行时该值为非零,BOOL.FALSE 比较即转换为 bool。这决定了后续"是否还需要再次提权/是否可写入受保护位置(如 hosts 文件)"的分支。宿主测试工程 BD.WTTS.Client.Tools.HostsTest/Program.cs 中存在完全相同的实现片段,说明该检测逻辑被独立验证过。
MSIX 打包侧的提权能力
发布管道在生成的 AppxManifest 中声明受限能力,允许打包后的应用触发提权并保持全信任:
<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,失败则退化到指针宽度判断:
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 格式串,让用户下载正确架构的运行时。
运行时缺失的用户引导
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(静态构造) | string | 11 / 0 / 0 / -preview.7.26381.103 | 宿主内建的目标运行时版本,dotnet_version 据此拼装 |
DOTNET_ROOT(进程环境变量) | string | 空 | STEP 0 的第三优先级运行时根目录探测来源 |
DOTNET_GCConserveMemory(进程环境变量,宿主设置) | string | 9 | MainCore 启动即写入,指示 GC 最大程度节省内存;必须在运行时初始化前设置 |
{app}/dotnet(目录) | - | - | STEP 0 第一优先级:随应用分发的自包含运行时根目录 |
C:/Program Files/dotnet(目录) | - | - | STEP 0 第二优先级:系统安装的运行时(仅 NETFRAMEWORK/WINDOWS 目标存在此候选) |
requireAspNetCore(局部常量) | bool | true | 是否要求同时存在 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_fptr | hostfxr_initialize_for_runtime_config | 以 Steam++.runtimeconfig.json 初始化宿主上下文 cxt |
get_delegate_fptr | hostfxr_get_runtime_delegate | 请求 hdt_load_assembly_and_get_function_pointer 委托,触发 .NET 运行时加载 |
close_fptr | hostfxr_close | 释放 cxt 上下文 |
ExitCode 枚举(诊断契约)
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的PathIsDirectoryEmptyWP/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 兼容性说明)。
Related Links
- Program.cs(AppHost 主实现)
- WindowsPlatformServiceImpl.BypassUAC.cs(提权检测)
- MSIXHelper.cs(打包清单生成)
- BD.WTTS.Client.Tools.HostsTest/Program.cs(提权检测的独立验证工程)
- .NET 官方 native hosting 设计文档(源码头部引用):host-error-codes 与 nativehost.cpp 示例