Monday, September 06, 2010

Powershell script to generate an executable from a powershell script.

I was helping someone with a bit of powershelling. The guy needed a small script which asked a user a few questions and then the account created a local account on the machine based on the answers. He had started to use some Windows Forms powershell code and helped him with a few small details. The problem is that it's a bit ugly to run a Windows Forms script with a big shell sitting in the background doing nothing.

There are a few different solutions out there to hide the Powershell console window but was thinking in the lines about. Hey why not just compile an executable with the Add-Type cmdlet, embed the script inside the executable and run the script from inside the executable. At deployment you only have to run the executable.

A little script compiles a Powershell script into an executable. The script is not brilliant and is only intended for two different scenarios.

  1. Embed scripts where you do NOT want a console window. This executable allows NO console interaction.
  2. Embed scripts where you keep the Runspace alive (the -KeepAlive switch) for event listeners. This is useful if you are register for WMI events or other events in your script.
Here it is, enjoy!

function New-PSExecutable {  
param(
[string]$Scriptname=$(throw "Mandatory param -scriptname is missing."),
[string]$Filename,
[switch]$KeepAlive
) 
$code=@" 
using System; using System.Diagnostics;
using System.Windows.Forms;
using System.Management.Automation.Runspaces;
using System.Management.Automation;
namespace CosmosKey.Powershell.Utils
{
class RunEmbedded
{
  private static string codeB64 = "@@@";
private static string funcName = "CosmosKeyRunEmbedded";
private static Pipeline pipe;
  private static Runspace rs = RunspaceFactory.CreateRunspace();
static void Main(string[] args)
{
  try
  {
    rs.Open();
    pipe = rs.CreatePipeline();
    String script = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(codeB64));
    System.Text.StringBuilder sb = new System.Text.StringBuilder();
    sb.AppendLine("function " + funcName + " {");
    sb.AppendLine(script);
    sb.AppendLine("}");
    sb.AppendLine("[reflection.assembly]::loadwithpartialname('System.Xml')");
    sb.AppendLine("[reflection.assembly]::loadwithpartialname('System.Management')");
    sb.AppendLine("[reflection.assembly]::loadwithpartialname('System.DirectoryServices')");
    sb.AppendLine("[reflection.assembly]::loadwithpartialname('System.Data')");
    sb.Append(funcName);
    foreach(string arg in args){
      sb.Append(" ");
      if(arg.StartsWith("-")){
        sb.Append(arg);
      } else if (arg.Contains(" ")){
        sb.Append("\"" + arg + "\"");
      } else {
        sb.Append(arg);
      }
    }
    sb.AppendLine("");
    pipe.Commands.AddScript(sb.ToString());
    ZZZ
    CloseRunspace();
  }
  catch (Exception) { ;}
}
private static void CloseRunspace(){
    pipe.StopAsync();
    rs.CloseAsync();
  }
}
}
"@
if(test-path $scriptname){
$script:scriptFilename = get-item $scriptname
} else {
throw "Can't find script file"
}
if([string]::IsNullOrEmpty($filename)){
$filename = $scriptFilename.Name.ToLower() -replace ".ps1",".exe"
$filename = "$pwd\$filename"
}
Write-Host "Source script file: $($scriptFilename.FullName)"
Write-Host "Output executable : $filename"
$scriptBytes = [io.file]::ReadAllBytes($scriptFilename.FullName)
$b64 = [convert]::tobase64string($scriptBytes)
$newCode = $code -replace "@@@",$b64
if($KeepAlive){
$newCode = $newCode -replace "ZZZ","pipe.InvokeAsync();Application.Run();"
} else {
$newCode = $newCode -replace "ZZZ","pipe.Invoke();"
}
$formsAssembly = [reflection.assembly]::LoadWithPartialName("System.Windows.Forms")
Add-Type $newCode -OutputAssembly $filename -OutputType WindowsApplication -ReferencedAssemblies $formsAssembly.Location 
}

To build the executable just load the function and run

New-PSExecutable [outputfile] [-KeepAlive]

The scriptname vaariable is mandatory obviously but the outputfile will be the scriptname without the .ps1 extension and with an .exe extension if it's left out.
The -KeepAlive flag will make sure that the application stays alive if the script is using events. The executable can be shutdown from within the script with [Windows.Forms.Application]::Exit() when using the -KeepAlive switch, if you need to exit the application from within.

Here are two sample scripts one test script for being executed without the -KeepAlive and one which uses events which is better compiled with the -KeepAlive switch.

Without -KeepAlive:

[void][system.reflection.assembly]::LoadWithPartialName("System.Windows.Forms")
[windows.forms.messagebox]::Show("Hey there's no script here... or is there ;)")




With -KeepAlive:

[void][system.reflection.assembly]::LoadWithPartialName("System.Windows.Forms")
[void][system.reflection.assembly]::LoadWithPartialName("System.Timers")
$timer = New-Object Timers.Timer
$timer.Interval = 5000
$global:maxTimes = 5
$job = Register-ObjectEvent -inputObject $timer -eventName Elapsed -sourceIdentifier Timer.Random -Action {
    $global:maxTimes--
    [windows.forms.messagebox]::Show($maxTimes)
    if($global:maxTimes -le 0) {
        [windows.forms.application]::Exit()
    }
}
[windows.forms.messagebox]::Show("Started. Runs 5 times")
$timer.start()



The scripts aren't perfect but they did what I needed. 
Please Enjoy!

Regards Johan Akerstrom

10 comments:

  1. Really cool! I'm in the process of writing a powershell script to take an inventory of the local machine's hardware and installed software and output it to a text file or csv. I would like to compile this to an .exe so a technically challenged person can run it. Will this require powershell to be installed on the local machine? Or will it work so long as .NET is installed?

    ReplyDelete
  2. If you need to run in apartment mode, e.g. if you are using COM objects or - as in my case - want AutoCompleteMode to work in ComboBox... just add this line before rs.Open():
    rs.ApartmentState = System.Threading.ApartmentState.STA;

    ReplyDelete
  3. Hi, unfortunately it does not (yet) work for me. In addition to the exe a pdb file is created. What is missing? Thanks in advance, Hans-Peter

    ReplyDelete
  4. Good job!

    It works on the machine that I have powershell installed, but like Hans-Peter not on another machine.
    Same question, what did we forget ?
    By the way, I didn't understand what you talk about rs.Open():...

    Thank you
    Lionel

    ReplyDelete
  5. Important note: If your PS1 script, when converted to EXE using the above function, does not work, then first of all check that the file with the original PS1 script is saved as ANSI. Please note that many editors for PS1 save the text as "UTF-8" or as "Unicode" -- these formats will NOT work with the above function.

    The easiest way ensure that your PS1 script is saved as "ANSI" is to open it in the standard Windows Notepad, then to go to "File-->Save As" menu and to select on the GUI below the file name "Encoding: ANSI".

    If you want to make sure that your PS1 file is read properly by the above function, then execute it step-by-step and check that the values of the variable $scriptBytes are those of the bytes in the file. For ANSI files they will be the same as the ASCII of the characters inside the file, but for "UTF-8" there will be three extra bytes in front (thus $scriptBytes[0] to $scriptBytes[2] will not be what you expect), and for "Unicode" there will be two extra bytes in front and then every even element in $scriptBytes will be zero.

    Another tip: if you want to see what other possible errors prevent your EXE from working properly, then replace the following line in the function:

    catch (Exception) { ;}

    with these lines:

    catch (Exception e)
    {
    string text = ("Exception caught: " + e.ToString ());
    System.IO.File.WriteAllText(@"C:\Temp\Errors.txt", text);
    }

    Then look into file "C:\Temp\Errors.txt" for the errors encountered by the EXE file.

    Hope this will help those who find that the above routine does not produce working EXE files.

    It took me several hours to get it working with my PS1 script.

    Regards,
    Paul W.

    ReplyDelete
  6. My script calls .net form objects. The exe runs in the background but I cannot see my form. Any thoughts?

    ReplyDelete
  7. Thanks for this great script!

    However, if I have characters with accents in my strings, they don't appear in msgBox, contextualMenus... They are replaced with a squared character. My ps1 script is encoded in ANSI. Any ideas to resolve this?

    ReplyDelete
  8. PShellExec handles .net form objects & hides the Powershell console.

    ReplyDelete
  9. HI,

    nice script. it works very well.

    But i have a question. Is it possible to decompile the exe script back to a powershell script ?

    Because i wrote a big script with Gui and all and would sell it in a community from me. Hope this is allowed too lol.

    Hope on an answer here.

    Cheers
    Manuel

    ReplyDelete
  10. Guys Don't forget to add all modules, all dll's and all snapins in your script. I think I've missed loading some assemblies in the script. So check that all assemblies are loaded first.

    - Manuel, No you can't, the script is embedding the supplied script as text inside the .EXE so no you won't be able to disassemble it.

    ReplyDelete