From ff9a829e29038eeec3e23b062ec90c838fc9efa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sat, 10 Oct 2026 11:17:31 +0800 Subject: [PATCH] fix(shell): preserve Windows launcher paths and elevation arguments --- .github/tests/windows-launcher/validation.ps1 | 96 +++++++++++++++++++ .../windows-launcher/windows-launcher-stub.cs | 24 +++++ .github/workflows/windows-launcher.yml | 32 +++++++ .../napcat-shell-loader/launcher-elevate.ps1 | 20 ++++ .../napcat-shell-loader/launcher-user.bat | 10 +- .../launcher-win10-user.bat | 9 +- .../napcat-shell-loader/launcher-win10.bat | 21 ++-- packages/napcat-shell-loader/launcher.bat | 21 ++-- 8 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 .github/tests/windows-launcher/validation.ps1 create mode 100644 .github/tests/windows-launcher/windows-launcher-stub.cs create mode 100644 .github/workflows/windows-launcher.yml create mode 100644 packages/napcat-shell-loader/launcher-elevate.ps1 diff --git a/.github/tests/windows-launcher/validation.ps1 b/.github/tests/windows-launcher/validation.ps1 new file mode 100644 index 00000000..4e59e33e --- /dev/null +++ b/.github/tests/windows-launcher/validation.ps1 @@ -0,0 +1,96 @@ +$ErrorActionPreference = 'Stop' +$auditRoot = Join-Path ([IO.Path]::GetTempPath()) 'napcat-windows-launcher-tests' +$sourceRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../packages/napcat-shell-loader')) +New-Item -ItemType Directory -Path $auditRoot -Force | Out-Null +$compiledStub = Join-Path $auditRoot ('stub-' + [guid]::NewGuid().ToString('N') + '.exe') +& (Join-Path $env:WINDIR 'Microsoft.NET/Framework64/v4.0.30319/csc.exe') /nologo ("/out:" + $compiledStub) (Join-Path $PSScriptRoot 'windows-launcher-stub.cs') +if ($LASTEXITCODE -ne 0) { throw 'Failed to compile launcher fixture' } +$stubDirectory = Join-Path $auditRoot 'tools' +New-Item -ItemType Directory -Path $stubDirectory -Force | Out-Null +foreach ($stubName in @('reg.exe', 'net.exe', 'powershell.exe')) { + Copy-Item -LiteralPath $compiledStub -Destination (Join-Path $stubDirectory $stubName) -Force +} +$results = [Collections.Generic.List[object]]::new() +foreach ($launcher in @('launcher.bat', 'launcher-win10.bat', 'launcher-user.bat', 'launcher-win10-user.bat')) { + foreach ($pathKind in @('plain', 'space', 'special', 'unquoted', 'missing-registry', 'boot-error', 'elevation')) { + if ($pathKind -eq 'elevation' -and $launcher -like '*-user.bat') { continue } + $directoryName = switch ($pathKind) { + plain { 'plain' } + space { 'space path' } + special { "路径 & () ! # ' test" } + default { "路径 & () ! # ' $pathKind" } + } + $fixture = Join-Path $auditRoot ($launcher.Replace('.bat', '') + '\' + $directoryName) + New-Item -ItemType Directory -Path $fixture -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $sourceRoot $launcher) -Destination $fixture -Force + Copy-Item -LiteralPath $compiledStub -Destination (Join-Path $fixture 'NapCatWinBootMain.exe') -Force + New-Item -ItemType File -Path (Join-Path $fixture 'QQ.exe') -Force | Out-Null + $output = Join-Path $fixture 'arguments.txt' + if (Test-Path -LiteralPath $output) { Remove-Item -LiteralPath $output } + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $env:ComSpec + $startInfo.Arguments = '/d /s /c ""' + (Join-Path $fixture $launcher) + '" -q "123456" "argument with spaces" "literal!value""' + $startInfo.WorkingDirectory = $auditRoot + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardInput = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.EnvironmentVariables['PATH'] = $stubDirectory + ';' + $env:PATH + $startInfo.EnvironmentVariables['NAPCAT_AUDIT_UNINSTALL'] = if ($pathKind -eq 'missing-registry') { '' } elseif ($pathKind -eq 'unquoted') { Join-Path $fixture 'Uninstall.exe' } else { '"' + (Join-Path $fixture 'Uninstall.exe') + '"' } + $startInfo.EnvironmentVariables['NAPCAT_AUDIT_NET_STATUS'] = if ($pathKind -eq 'elevation') { '1' } else { '0' } + $startInfo.EnvironmentVariables['NAPCAT_AUDIT_BOOT_STATUS'] = if ($pathKind -eq 'boot-error') { '31' } else { '0' } + $startInfo.EnvironmentVariables['NAPCAT_AUDIT_OUTPUT'] = $output + $process = [Diagnostics.Process]::Start($startInfo) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.StandardInput.WriteLine('') + $process.StandardInput.Close() + if (-not $process.WaitForExit(10000)) { + & taskkill.exe /PID $process.Id /T /F | Out-Null + throw "$launcher $pathKind timed out" + } + $expected = @((Join-Path $fixture 'QQ.exe'), (Join-Path $fixture 'NapCatWinBootHook.dll'), '-q', '123456', 'argument with spaces', 'literal!value') + if ($pathKind -eq 'missing-registry') { $expected = @() } + if ($pathKind -eq 'elevation') { + $terminal = if ($launcher -eq 'launcher.bat') { 'wt.exe' } else { 'powershell.exe' } + $expected = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $fixture 'launcher-elevate.ps1'), $terminal, (Join-Path $fixture $launcher), '-q', '123456', 'argument with spaces', 'literal!value') + } + $actual = @() + if (Test-Path -LiteralPath $output) { + $actual = @(Get-Content -LiteralPath $output -Encoding UTF8 | Select-Object -Skip 1 | ForEach-Object { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_)) }) + } + $passed = $actual.Count -eq $expected.Count + for ($argumentIndex = 0; $argumentIndex -lt $expected.Count -and $passed; $argumentIndex++) { + $passed = $actual[$argumentIndex] -ceq $expected[$argumentIndex] + } + $expectedExit = if ($pathKind -eq 'missing-registry') { 1 } elseif ($pathKind -eq 'boot-error') { 31 } else { 0 } + $passed = $passed -and $process.ExitCode -eq $expectedExit + $result = [PSCustomObject]@{ launcher=$launcher; path=$pathKind; passed=$passed; exit=$process.ExitCode; actual=$actual; stdout=$stdout.Result; stderr=$stderr.Result } + $results.Add($result) + $result | Select-Object launcher,path,passed,exit | ConvertTo-Json -Compress + } +} +function Start-Process { + param([string]$FilePath, [string]$ArgumentList, [string]$Verb) + $global:napcatAuditElevationRequest = @{ file=$FilePath; arguments=$ArgumentList; verb=$Verb } +} +foreach ($terminal in @('wt.exe', 'powershell.exe')) { + $testScript = "C:\路径 & () ! # '\launcher.bat" + $testArguments = @('-q', '123456', 'argument with spaces', 'literal!value', 'literal$()', 'quote"value') + & (Join-Path $sourceRoot 'launcher-elevate.ps1') $terminal $testScript @testArguments + $encodedCommand = ($global:napcatAuditElevationRequest.arguments -split ' ')[-1] + $command = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand)) + $parseTokens = $null + $parseErrors = $null + [Management.Automation.Language.Parser]::ParseInput($command, [ref]$parseTokens, [ref]$parseErrors) | Out-Null + $match = [regex]::Match($command, "FromBase64String\('([A-Za-z0-9+/=]+)'\)") + $payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($match.Groups[1].Value)) | ConvertFrom-Json + $passed = $parseErrors.Count -eq 0 -and $payload.script -ceq $testScript -and $global:napcatAuditElevationRequest.verb -eq 'RunAs' + $passed = $passed -and ($payload.arguments | ConvertTo-Json -Compress) -ceq ($testArguments | ConvertTo-Json -Compress) + $result = [PSCustomObject]@{ launcher='launcher-elevate.ps1'; path=$terminal; passed=$passed; exit=0 } + $results.Add($result) + $result | ConvertTo-Json -Compress +} +$results | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $auditRoot 'results.json') -Encoding UTF8 +if ($results.Where({ -not $_.passed }).Count) { exit 1 } diff --git a/.github/tests/windows-launcher/windows-launcher-stub.cs b/.github/tests/windows-launcher/windows-launcher-stub.cs new file mode 100644 index 00000000..cd68e5a1 --- /dev/null +++ b/.github/tests/windows-launcher/windows-launcher-stub.cs @@ -0,0 +1,24 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +public class NapCatLauncherAudit { + public static int Main(string[] arguments) { + string name = Path.GetFileNameWithoutExtension(Process.GetCurrentProcess().MainModule.FileName); + if (name == "reg") { + string uninstall = Environment.GetEnvironmentVariable("NAPCAT_AUDIT_UNINSTALL"); + if (String.IsNullOrEmpty(uninstall)) return 1; + Console.OutputEncoding = new UTF8Encoding(false); + Console.WriteLine(" UninstallString REG_SZ " + uninstall); + return 0; + } + if (name == "net") return Int32.Parse(Environment.GetEnvironmentVariable("NAPCAT_AUDIT_NET_STATUS")); + string output = Environment.GetEnvironmentVariable("NAPCAT_AUDIT_OUTPUT"); + using (StreamWriter writer = new StreamWriter(output, false, new UTF8Encoding(false))) { + writer.WriteLine(name); + foreach (string argument in arguments) writer.WriteLine(Convert.ToBase64String(Encoding.UTF8.GetBytes(argument))); + } + return Int32.Parse(Environment.GetEnvironmentVariable("NAPCAT_AUDIT_BOOT_STATUS") ?? "0"); + } +} diff --git a/.github/workflows/windows-launcher.yml b/.github/workflows/windows-launcher.yml new file mode 100644 index 00000000..cd5b46e7 --- /dev/null +++ b/.github/workflows/windows-launcher.yml @@ -0,0 +1,32 @@ +name: Windows launcher + +on: + push: + paths: + - packages/napcat-shell-loader/launcher* + - .github/tests/windows-launcher/** + - .github/workflows/windows-launcher.yml + pull_request: + paths: + - packages/napcat-shell-loader/launcher* + - .github/tests/windows-launcher/** + - .github/workflows/windows-launcher.yml + workflow_dispatch: + +permissions: + contents: read + +jobs: + launchers: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Check paths, registry, arguments and elevation + shell: pwsh + run: ./.github/tests/windows-launcher/validation.ps1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-launcher-results + path: ${{ runner.temp }}/napcat-windows-launcher-tests/results.json diff --git a/packages/napcat-shell-loader/launcher-elevate.ps1 b/packages/napcat-shell-loader/launcher-elevate.ps1 new file mode 100644 index 00000000..cc8470a2 --- /dev/null +++ b/packages/napcat-shell-loader/launcher-elevate.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' +$terminal = $args[0] +$payload = @{ + script = $args[1] + arguments = @($args | Select-Object -Skip 2) +} | ConvertTo-Json -Compress +$encodedPayload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) +$command = @" +`$payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$encodedPayload')) | ConvertFrom-Json +`$launchArguments = @(`$payload.arguments) +& `$payload.script @launchArguments +exit `$LASTEXITCODE +"@ +$encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) +$commandArguments = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand $encodedCommand" +if ($terminal -eq 'wt.exe') { + Start-Process -FilePath $terminal -ArgumentList "powershell.exe $commandArguments" -Verb RunAs +} else { + Start-Process -FilePath $terminal -ArgumentList $commandArguments -Verb RunAs +} diff --git a/packages/napcat-shell-loader/launcher-user.bat b/packages/napcat-shell-loader/launcher-user.bat index fbf28945..e4fb1095 100644 --- a/packages/napcat-shell-loader/launcher-user.bat +++ b/packages/napcat-shell-loader/launcher-user.bat @@ -7,11 +7,14 @@ set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js" set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll" set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe" set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs" -:loop_read +set "RetString=" for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( set "RetString=%%~b" goto :napcat_boot ) +echo QQ installation was not found in the registry. +pause +exit /b 1 :napcat_boot for %%a in ("%RetString%") do ( @@ -23,9 +26,10 @@ set "QQPath=%pathWithoutUninstall%QQ.exe" if not exist "%QQpath%" ( echo provided QQ path is invalid pause - exit /b + exit /b 1 ) "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* - +set "napcat_exit_code=%ERRORLEVEL%" pause +exit /b %napcat_exit_code% diff --git a/packages/napcat-shell-loader/launcher-win10-user.bat b/packages/napcat-shell-loader/launcher-win10-user.bat index abfad977..f9980625 100644 --- a/packages/napcat-shell-loader/launcher-win10-user.bat +++ b/packages/napcat-shell-loader/launcher-win10-user.bat @@ -7,11 +7,14 @@ set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js" set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll" set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe" set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs" -:loop_read +set "RetString=" for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( set "RetString=%%~b" goto :napcat_boot ) +echo QQ installation was not found in the registry. +pause +exit /b 1 :napcat_boot for %%a in ("%RetString%") do ( @@ -23,11 +26,13 @@ set "QQPath=%pathWithoutUninstall%QQ.exe" if not exist "%QQpath%" ( echo provided QQ path is invalid pause - exit /b + exit /b 1 ) "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* +set "napcat_exit_code=%ERRORLEVEL%" REM Optional: -q for quick login, omit for QR code login REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456 pause +exit /b %napcat_exit_code% diff --git a/packages/napcat-shell-loader/launcher-win10.bat b/packages/napcat-shell-loader/launcher-win10.bat index a56fcdb7..900712e2 100644 --- a/packages/napcat-shell-loader/launcher-win10.bat +++ b/packages/napcat-shell-loader/launcher-win10.bat @@ -3,24 +3,26 @@ setlocal DisableDelayedExpansion cd /d "%~dp0" || exit /b 1 chcp 65001 >nul net session >nul 2>&1 -if %ERRORLEVEL% == 0 ( - echo Administrator mode detected. -) else ( - echo Please run this script in administrator mode. - powershell -Command "Start-Process 'cmd.exe' -ArgumentList '/c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs" - exit -) +if not errorlevel 1 goto :napcat_admin +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0launcher-elevate.ps1" powershell.exe "%~f0" %* +exit /b %ERRORLEVEL% + +:napcat_admin +echo Administrator mode detected. set "NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json" set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js" set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll" set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe" set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs" -:loop_read +set "RetString=" for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( set "RetString=%%~b" goto :napcat_boot ) +echo QQ installation was not found in the registry. +pause +exit /b 1 :napcat_boot for %%a in ("%RetString%") do ( @@ -32,9 +34,10 @@ set "QQPath=%pathWithoutUninstall%QQ.exe" if not exist "%QQPath%" ( echo provided QQ path is invalid pause - exit /b + exit /b 1 ) "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* +exit /b %ERRORLEVEL% REM Optional: -q for quick login, omit for QR code login REM Example: "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" -q 123456 diff --git a/packages/napcat-shell-loader/launcher.bat b/packages/napcat-shell-loader/launcher.bat index 17e30db8..4c90feb5 100644 --- a/packages/napcat-shell-loader/launcher.bat +++ b/packages/napcat-shell-loader/launcher.bat @@ -3,24 +3,26 @@ setlocal DisableDelayedExpansion cd /d "%~dp0" || exit /b 1 chcp 65001 >nul net session >nul 2>&1 -if %ERRORLEVEL% == 0 ( - echo Administrator mode detected. -) else ( - echo Please run this script in administrator mode. - powershell -Command "Start-Process 'wt.exe' -ArgumentList 'cmd /c cd /d \"%cd%\" && \"%~f0\" %*' -Verb runAs" - exit -) +if not errorlevel 1 goto :napcat_admin +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0launcher-elevate.ps1" wt.exe "%~f0" %* +exit /b %ERRORLEVEL% + +:napcat_admin +echo Administrator mode detected. set "NAPCAT_PATCH_PACKAGE=%cd%\qqnt.json" set "NAPCAT_LOAD_PATH=%cd%\loadNapCat.js" set "NAPCAT_INJECT_PATH=%cd%\NapCatWinBootHook.dll" set "NAPCAT_LAUNCHER_PATH=%cd%\NapCatWinBootMain.exe" set "NAPCAT_MAIN_PATH=%cd%\napcat.mjs" -:loop_read +set "RetString=" for /f "tokens=2*" %%a in ('reg query "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\QQ" /v "UninstallString"') do ( set "RetString=%%~b" goto :napcat_boot ) +echo QQ installation was not found in the registry. +pause +exit /b 1 :napcat_boot for %%a in ("%RetString%") do ( @@ -32,7 +34,8 @@ set "QQPath=%pathWithoutUninstall%QQ.exe" if not exist "%QQPath%" ( echo provided QQ path is invalid pause - exit /b + exit /b 1 ) "%NAPCAT_LAUNCHER_PATH%" "%QQPath%" "%NAPCAT_INJECT_PATH%" %* +exit /b %ERRORLEVEL%