So I am trying to run a PowerShell script as a windows service and I've tried different solutions, but I have a problem, the PowerShell script file should not be available to an admin, and I cannot make use of 3rd party libraries or similar. It is riskier exposing the running script itself, rather than exposing .exe file, to admins. I've tried this solution: https://learn.microsoft.com/en-us/archive/msdn-magazine/2016/may/windows-powershell-writing-windows-services-in-powershell
I am trying to use this (https://www.janhendrikpeters.de/2021/01/15/developing-a-windows-service-in-powershell/) solution now, see code.
Passing my PowerShell script to the script and the service code by the $OnStart variable works, but the PowerShell script needs to be available on the server. Essentially I want my PS code to be contained within the compiled exe file. So I tried making a "$OnStart" code block in before the .NET part, converting it to a string, and let the .NET code start this code. However, this does not work because I use " quotations in my PowerShell code, and yes I cannot change " to ' because certain functions in my script need double quotations. A solution I haven't tried is at the start of the script make a file, and write my PowerShell script to the file, use the Addscript function with the path to this file, run the script, and after the script has started to delete the file.
Is this a viable option?
Or is there a way to pass a script block, of PowerShell code that contains ", into the "AddScript" function?
param
(
[string]
$ServiceName = 'InfraSvc',
[string]
$OutPath = $pwd.Path,
[string]
$OnStart,
[string]
$OnStop,
[switch]
$Register
)
$binPath = (Join-Path -Path $OutPath -ChildPath "$ServiceName.exe")
if (Test-Path -Path $binPath)
{
Remove-Item -Path $binPath
}
Add-Type -TypeDefinition @"
using System;
using System.ServiceProcess;
using System.Management.Automation;
public static class HostProgram
{
#region Nested classes to support running as service
public const string ServiceName = "$ServiceName";
public class Service: ServiceBase
{
public Service()
{
ServiceName = HostProgram.ServiceName;
}
protected override void OnStart(string[] args)
{
HostProgram.Start(args);
}
protected override void OnStop()
{
HostProgram.Stop();
}
}
#endregion
static void Main(string[] args)
{
if (!Environment.UserInteractive)
// running as service
using (var service = new Service())
ServiceBase.Run(service);
else
{
// running as console app
Start(args);
Console.WriteLine("Press any key to stop...");
Console.ReadKey(true);
Stop();
}
}
private static void Start(string[] args)
{
// service startup code here
string onStart = @"$OnStart";
if (string.IsNullOrWhiteSpace(onStart)) return;
using (var psh = PowerShell.Create())
{
psh.AddScript((System.IO.File.ReadAllText(onStart)));
psh.Invoke();
}
}
private static void Stop()
{
// service startup code here
string onStop = @"$OnStop";
if (string.IsNullOrWhiteSpace(onStop)) return;
using (var psh = PowerShell.Create())
{
psh.AddScript((System.IO.File.ReadAllText(onStop)));
psh.Invoke();
}
}
}
"@ -OutputAssembly $binPath -ReferencedAssemblies System.ServiceProcess, System.Management.Automation
if ($Register.IsPresent)
{
New-Service -Name $ServiceName -BinaryPathName $binPath -StartupType Automatic
}
My main problem was to write a windows service in powershell, make it into an .exe file to "hide" code and configuration after install and run it from Services.msc.
I managed to solve these problems, however I will not say that it a beautiful solution. Every problem is accounted for and solved in the same script.
Powershell code for making the script pack itself into a SED-File:
$Text =
'[Version]
Class=IEXPRESS
SEDVersion=3
[Options]
PackagePurpose=InstallApp
ShowInstallProgramWindow=1
HideExtractAnimation=0
UseLongFileName=1
InsideCompressed=0
CAB_FixedSize=0
CAB_ResvCodeSigning=0
RebootMode=N
InstallPrompt=%InstallPrompt%
DisplayLicense=%DisplayLicense%
FinishMessage=%FinishMessage%
TargetName=%TargetName%
FriendlyName=%FriendlyName%
AppLaunched=%AppLaunched%
PostInstallCmd=%PostInstallCmd%
AdminQuietInstCmd=%AdminQuietInstCmd%
UserQuietInstCmd=%UserQuietInstCmd%
SourceFiles=SourceFiles
[Strings]
InstallPrompt=
DisplayLicense=
FinishMessage=
TargetName='+$PackedExePath+'
FriendlyName=Filetransfer2
AppLaunched=powershell.exe -ExecutionPolicy Bypass -File '+$CurrentScriptName+'
PostInstallCmd=<None>
AdminQuietInstCmd=
UserQuietInstCmd=
FILE0="'+ $CurrentScriptName + '"
[SourceFiles]
SourceFiles0='+ $PSScriptRoot83 +'
[SourceFiles0]
%FILE0%=
'
Set-Content -Path $SedFilePath -Value $Text
#Checking if the file is ready to be run by iExpress
$isSEDOpen = Test-FileLock -Path $SedFilePath
while ($isSEDOpen) {
#Write-host "Waiting for SED file to be ready for use"
Start-Sleep(3)
$isSEDOpen = Test-FileLock -Path $SedFilePath
}
if(-not $isSEDOpen) {
#Write-host "$SedFilePath is ready for use"
}
C:\Windows\System32\iexpress.exe /N /Q $SedFilePath
Because I install the service in the "Program files" folder I had to translate the path into a 8.3 filename to make iExpress run it. Therefore, all the variables used in $Text variable for making a .SED-file that iExpress need for packing the powershellscript is translated. This is done by replacing "Program files" with "PROGRA~1". Code example:
$PSScriptRoot83 = $PSScriptRoot.replace('Program Files','PROGRA~1')
The path to the packed powershell code is then used in the Windows service code, with the "$PackedExePath". Windows service code for starting and stopping the packed powershell file:
Add-Type -TypeDefinition @"
using System;
using System.ServiceProcess;
using System.Management.Automation;
using System.Diagnostics;
public static class HostProgram
{
public const string ServiceName = "$ServiceName";
public class Service : ServiceBase
{
public Service()
{
ServiceName = HostProgram.ServiceName;
}
protected override void OnStart(string[] args)
{
HostProgram.Start(args);
}
protected override void OnStop()
{
HostProgram.Stop();
}
}
static void Main(string[] args)
{
if(!Environment.UserInteractive)
using (var service = new Service())
ServiceBase.Run(service);
else
{
Start(args);
Console.WriteLine("Press any key to stop..");
Console.ReadKey(true);
Stop();
}
}
private static void Start(string[] args)
{
Console.WriteLine("Started");
string exePath = @"$PackedExePath";
Process.Start(exePath);
}
private static void Stop()
{
foreach (var process in Process.GetProcessesByName("$PackedexeName"))
{
process.Kill();
}
Console.WriteLine("stopped");
}
}
"@ -OutputAssembly $binPath -ReferencedAssemblies System.ServiceProcess, System.Management.Automation
if (Test-Path $binPath) {
New-Service -Name $ServiceName -BinaryPathName $binPath -Description $ServiceDescription -StartupType Automatic
}
It is possible to do this in the same script, however you need to check if the service is already installed in order to avoid making a new package of the script and a new windows service every time you start the service. Therefore all of the code shown above is wrapped in this if-statement:
try {
$GottenName = Get-Service -Name $ServiceName -ErrorAction Stop
} catch {}
if (-not ($GottenName.Name -match $ServiceName)){
#Previous code snippes here...
}
It is most likely better to just make a windows service in .Net, but if you have no other options than powershell and your powershell code needs to be packed, this is the best free way I could come up with. I know there are some tools to pack your powershell scripts, but I could not afford those.
It is worth mentioning that iExpress contains some vulnerabilites, however they will not be of any risk in this scenario. You can read more about the vulnerabilities in iExpress here.