Category Archives: Scripting

Maintaining a consistent Windows PE build

UPDATE – new much improved script here.

Having a WDS server with a bootable Windows PE instance is extremely useful. Got a server into a reboot loop by deleting a driver you were sure was inactive? Run out of space on C: and need to image and repartition? OS won’t boot but you need to salvage files? You get the picture. They key though, is making sure you’ve integrated all the network and mass storage controller drivers your hardware will need.

One of the most useful tools that’s not included in the image by default is ImageX – the tool for reading and writing WIM images. Pair this up with the GUI wrapper GImageX and you’ve got yourself a free replacement for Ghost.

Once you’ve downloaded WAIK, making a Windows PE build is a pain – there’s so much messy syntax to get right. Since you’re likely to keep needing to rebuild it to add more drivers, it’s a good idea to write a script to rebuild it from scratch each time. Here’s my build_pe_amd64.cmd:

set SOURCE=C:\WINPE_DRIVERS
set PATH=%PATH%;C:\Program Files\Windows AIK\Tools\PETools\;C:\Program Files\Windows AIK\Tools\%PROCESSOR_ARCHITECTURE%
set PATH=%PATH%;C:\Program Files\Windows AIK\Tools\Servicing
del c:\temp\pe_amd64\ISO\sources\boot.wim
del c:\temp\pe_amd64\winpe_amd64.iso
rd /s /q c:\temp\pe_amd64
call copype.cmd amd64 c:\temp\pe_amd64
dism /Mount-Wim /WimFile:c:\temp\pe_amd64\winpe.wim /Index:1 /MountDir:c:\temp\pe_amd64\mount
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\winpe-scripting.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\en-us\winpe-scripting_en-us.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\winpe-wmi.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\en-us\winpe-wmi_en-us.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\winpe-mdac.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\en-us\winpe-mdac_en-us.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\WinPE-HTA.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Package /PackagePath:"%ProgramFiles%\Windows AIK\Tools\PETools\amd64\WinPE_FPs\en-us\WinPE-HTA_en-us.cab"
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\common\vmxnet3\vmxnet3ndis5.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\pvscsi\pvscsi.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\perc4esi\oemsetup.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\lsi_sas\lsi_sas.inf /forceunsigned
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\nc373i_ris\b06nd.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\sa_5x6x\hpcisss.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\sa_p400\hpcissx2.inf
dism /image:c:\temp\pe_amd64\mount /Add-Driver /driver:%SOURCE%\x64\yk62\yk62x64.inf
copy "%ProgramFiles%\Windows AIK\Tools\PETools\amd64\bootsect.exe" c:\temp\pe_amd64\mount\Windows
copy /y %SOURCE%\common\*.cmd c:\temp\pe_amd64\mount\Windows\System32
copy "%SOURCE%\x64\Tools\*.*" c:\temp\pe_amd64\mount\Windows\System32
copy "%ProgramFiles%\Windows AIK\Tools\amd64\*.*" c:\temp\pe_amd64\mount\Windows\System32
md c:\temp\pe_amd64\mount\scripts
::script for launching my unattended OS installer
copy \\myserver\unattended\scripts\init.vbs c:\temp\pe_amd64\mount\scripts
dism /Unmount-Wim /MountDir:c:\temp\pe_amd64\mount /commit
imagex /export c:\temp\pe_amd64\winpe.wim 1 c:\temp\pe_amd64\ISO\sources\boot.wim
::uncomment the following line to build a bootable ISO image
::oscdimg -n -bc:\temp\pe_amd64\etfsboot.com c:\temp\pe_amd64\ISO c:\temp\pe_amd64\winpe_amd64.iso
ren c:\temp\pe_amd64\ISO\sources\boot.wim boot_amd64.wim
WDSUTIL /Verbose /Progress /Replace-Image /Image:"Microsoft Windows PE 3.0 (x64)" /ImageType:Boot /Architecture:x64 /ReplacementImage /Name:"Microsoft Windows PE 3.0 (x64)" /ImageFile:"c:\temp\pe_amd64\ISO\sources\boot_amd64.wim"
ren c:\temp\pe_amd64\ISO\sources\boot_amd64.wim boot.wim
cd\scripts

Windows 7 login scripts and missing network drives

Most Windows sysadmins use a Group Policy object to launch their login script. You may have noticed that Windows 7 and Vista fail to connect network drives defined in the login script if the user is a local admin and UAC is enabled. The script completes successfully and no error condition is encountered, but no drive mappings. Run it again manually and everything’s fine.

This is in fact by design, and it’s caused by the way UAC works. When you’re a member of the Administrators group and you log in, your account is busted down to a non-privileged user by UAC. This running context is completely separate from the context you get when you right-click Command Prompt and launch as an administrator. As you’ll probably have noticed, network drives connected in one context are not visible in the other. The problem is that GPO-based login scripts are being executed in the Administrator context – not the ordinary user context.

So, how do we get it working? Microsoft offer an unsupported kludge in KB937624 – getting around the issue by weakening Windows security and forcing network connections to be common to both user contexts. They carefully designed this not to be the case, so modifying the behaviour does seem like a bad idea.

However, Microsoft’s preferred solution (example launchapp.wsf script in the appendix of that page) is to use the GPO-triggered script to set a Scheduled Task to run immediately in the other (non-admin) context, and run your login script from there – much better.
The reasons I’m writing all this up are that:

  • Microsoft’s example script has some illegal character/line-wrap in there – copying and pasting it won’t work!
  • This method doesn’t work with XP so some forking logic is needed if you have mixed clients.
  • They make no allowances for multiple admin/non-admin users sharing the same PC.
  • This appears to be Microsoft’s sole example document of how to program using the Task Scheduler 2.0 API, and it neglects to define several absolutely essential object properties if you want to do something more useful than simply open Notepad.

My particular problem was that I needed to launch a script with a space character in the path, e.g.:

cscript.exe //nologo "\\domain.com\netlogon\departmentX users\logon.vbs"

For me changing this path name was not an option as there were many other dependencies. I spent a long time wondering why the API was eating my quotes as I fed it the above string and I tried various ways to escape them. Eventually I launched the Scheduled Tasks MMC tool (click on the root of Task Scheduler Library to see the job). Looking in the Action properties I realised that there are separate fields for the starting directory and for the arguments. Manually editing the job to use these got it working:
Task Properties Dialog
Frustratingly, there don’t seem to be any examples on the Web showing you how to populate these fields programmatically. Guessing the Arguments property was straighforward but StartIn is not a valid propery name. I read on Wikipedia that Task Scheduler 2.0 uses XML to store its jobs so I exported the job and viewed it. Luckily they used consistent property names in the XML (Arguments and WorkingDirectory) and I was able to set them in VBScript (see highlighted lines below).

There was an additional complication though. Once a user has run the Scheduled Task, it’s left behind on the system. In my initial testing this wasn’t a problem because I was testing admin users, but I soon discovered that a non-privileged user cannot delete and recreate the task if one created by another user already exists. So we need only schedule the task if the current user is running in an elevated security context. By far the simplest method is to parse the output of the whoami /groups command, as explained in this post:
http://blogs.technet.com/b/jhoward/archive/2008/11/19/how-to-detect-uac-elevation-from-vbscript.aspx

UPDATE – added some logic to prevent the login script from launching for RemoteApp sessions to Terminal Servers.

'launchapp.vbs, modified from Microsoft's launchapp.wsf
'launches a process as interactive user, NOT as the elevated privilege user context

Option Explicit

Const TriggerTypeRegistration = 7
Const ActionTypeExecutable = 0
Const FlagTaskCreate = 2
Const LogonTypeInteractive = 3

Dim strWorkingDirectory, strHostname, strOSVer, colProcessList, strUser, strDomain
Dim objNetwork, objComputer, objShell, objExec, objWMI, objItem, strScriptName, strStdOut

strWorkingDirectory = "\\domain.com\netlogon\DepartmentX Users"

'launch this login script
strScriptName = "logon.vbs"

Set objNetwork = CreateObject("WScript.Network")
Set objShell = CreateObject("WScript.Shell")
Set objWMI = GetObject("winmgmts:{impersonationLevel=impersonate}!\\.\root\cimv2") 
strHostname = objNetwork.ComputerName
Set objComputer = GetObject("WinNT://" & strHostname & ",computer")
strOSVer = objComputer.OperatingSystemVersion

If strOSVer >= "6.0" Then
  If IsElevated() Then
    'Machine has UAC and user is elevated so use LAUNCHAPP.WSF Task Scheduler method based
    'on appendix from http://technet.microsoft.com/en-us/library/cc766208(WS.10).aspx

    'Are we launched in a RemoteApp session on a Terminal Server? If so, quit.
    Set colProcessList = objWMI.ExecQuery("Select * from Win32_Process Where Name = 'rdpshell.exe'")
    For Each objItem In colProcessList
      objItem.GetOwner strUser, strDomain
      'If we're an admin we can see all users' processes so we need to check only our own
      If strUser = objNetwork.UserName Then
        WScript.Quit
      End If  
    Next

    LaunchApp
  Else
    'User is not elevated, so launch the script normally
    objShell.Run "cscript.exe //nologo " & Chr(34) & strWorkingDirectory & "\" & strScriptName & Chr(34), 1
  End If
Else
  'This is a Windows XP/2003 machine, so launch the script normally
  objShell.Run "cscript.exe //nologo " & Chr(34) & strWorkingDirectory & "\" & strScriptName & Chr(34), 1
End If

Set objNetwork = nothing
Set objComputer = nothing
Set objShell = nothing

Function IsElevated()
  IsElevated = False
  strStdOut = ""
  Set objExec = objShell.Exec ("whoami /groups")
  Do While (objExec.Status = 0)
    WScript.Sleep 100
    If Not objExec.StdOut.AtEndOfStream Then
      strStdOut = strStdOut & objExec.StdOut.ReadAll
    End If
  Loop
  If InStr(strStdOut,"S-1-16-12288") Then
    IsElevated = True
  End If
  Set objExec = nothing
End Function

Sub LaunchApp
  Dim objTaskService
  Dim strTaskName, rootFolder, taskDefinition, triggers, trigger, Action

  'Create the TaskService object
  Set objTaskService = CreateObject("Schedule.Service")
  Call objTaskService.Connect()
  strTaskName = "Launch App As Interactive User"

  'Get a folder to create a task definition in
  Set rootFolder = objTaskService.GetFolder("\")

  'Delete the task if already present
  On Error Resume Next
  Call rootFolder.DeleteTask(strTaskName, 0)
  Err.Clear

  'Create the new task
  Set taskDefinition = objTaskService.NewTask(0)

  'Create a registration trigger
  Set triggers = taskDefinition.Triggers
  Set trigger = triggers.Create(TriggerTypeRegistration)

  'Create the action for the task to execute
  Set Action = taskDefinition.Actions.Create(ActionTypeExecutable)
  Action.Path = "cscript.exe"
  Action.Arguments = "//nologo " & strScriptName
  Action.WorkingDirectory = strWorkingDirectory

  'Register (create) the task
  call rootFolder.RegisterTaskDefinition(strTaskName, taskDefinition, FlagTaskCreate,,, LogonTypeInteractive)

  Set objTaskService = nothing
End Sub

Wake on LAN for VPN users

In the absence of a Terminal Server, having users remotely use the software on their desktop PCs is often easier than having to manage software packages on laptops which may be part of a generic pool. In an energy conscious business there is the problem of what to do if one of your remote users wants to get at their desktop PC while it’s asleep or powered down. Wake on LAN works by sending a magic packet – featuring the target PC’s MAC address – to the network broadcast address. A MAC address is pretty unwieldy, so the ideal solution is an intranet page allowing users to wake a PC by hostname once they’re connected to the VPN.

To do this you will need some kind of host database to link hostnames to MAC addresses (remember – the machines could be switched off, so the DHCP database is no good). Your intranet server must also be on the same subnet as the machines you intend to wake. I have only used this in a single subnet so I haven’t investigated scalability, but it just looks like a case of enabling directed broadcasts on your routers. My login script updates host database entries and collects other WMI info such as make & model, tag number, spec, etc.

I implemented this Wake on LAN four years ago so there may be neater ways of doing it by now. At the time I couldn’t do the whole thing in ASP because there was no free socket library for VBScript, so I used Perl to create the magic packet. I used a generic wakeonlan.pl script by José Pedro Oliveira and tweaked it to post back to the ASP page.

Here are the required scripts – the first is ASP part you would need for the Intranet page:

<% Language=VBScript %>
<p>Wake your PC to allow you to connect to it remotely.</p>
<form method="get" action="./default.asp">Name of PC to power on: <input name="hostname" maxlength=14><input type="submit" value="wake"><br>
<%
Dim strHostname
strHostname = Request.QueryString("hostname")

'check query string from form above
If Not strHostname = "" Then
  'remove potential SQL injection attack characters
  strHostname = killChars(strHostname)
  Dim strConnection, objConnection, objRecordSet, objCommand, objResult

  'create connection object
  strConnection = "Provider=SQLOLEDB; Data Source=sqlsvr.domain.com; Initial Catalog=HostDB;User Id=HostDB_RO;Password=yourpassword"
  Set objConnection = CreateObject("ADODB.Connection")
  objConnection.Open strConnection

  'create command object
  set objCommand = CreateObject("ADODB.Command")
  objCommand.ActiveConnection = objConnection

  'check to see if a MAC exists for this hostname - you'll need to customize this depending on your database
  objCommand.CommandText = "SELECT * FROM Inventory WHERE Hostname='" & strHostname & "'"
  set objRecordSet = objCommand.Execute
  If Not objRecordSet.EOF Then
    'if it does exist then wake it
    Response.Redirect ("./wakeonlan.plx?MAC=" & objRecordSet.Fields.Item("MAC").value)
  Else
    Response.Write ("<em>Unknown computer: '" & strHostname & "'.</em>")
  End If
  objRecordSet.Close
  Set objCommand = nothing
  objConnection.Close
  Set objConnection = nothing
End If

'check query string for result from wakeonlan.plx
Dim strResult, strMAC
strResult = Request.QueryString("result")
If strResult = "True" Then
  Response.Write("<em>Wake-up packet sent. Wait around one minute before connecting.</em>")
End If
If strResult = "False" Then
  Response.Write("<em>Invalid MAC.</em>")
End If

'sanitize against SQL injection attacks
Function killChars(strWords)
  Dim arrBadChars, strNewChars
  arrBadChars = array("select", "drop", ";", "--", "insert", "delete", "xp_", "'", "=", " ")
  strNewChars = strWords
  For i = 0 To uBound(arrBadChars)
    strNewChars = replace(strNewChars, arrBadChars(i), "")
  Next
  killChars = strNewChars
End Function
%>
</form>

And this is wakeonlan.plx:

#!/usr/bin/perl
#
# wakeonlan.plx
# based on José Pedro Oliveira's wakeonlan.pl v1.4.2.3 <jpo@di.uminho.pt>

use strict;
use Env "QUERY_STRING";
use Socket;

# your LAN broadcast address
my $DEFAULT_IP = '172.16.1.255';
my $DEFAULT_PORT = getservbyname('discard', 'udp');
my %FORM;
my $result;

&parse_query_string;
&wake($FORM{MAC});

print 'Status: 302 Moved', "\r\n", 'Location: ./default.asp?result=', $result, "\r\n\r\n";

sub parse_query_string {
  my ($buffer, @pairs, $pair, $name, $value);
  if (length ($ENV{'QUERY_STRING'}) > 0){
    $buffer = $ENV{'QUERY_STRING'};
    @pairs = split(/&/, $buffer);
    foreach $pair (@pairs){
      ($name, $value) = split(/=/, $pair);
      $value =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg;
      $FORM{$name} = $value;
    }
  }
}

sub wake {
  my $hwaddr  = shift;
  my $ipaddr  = $DEFAULT_IP;
  my $port    = $DEFAULT_PORT;
  my ($raddr, $them, $proto);
  my ($hwaddr_re, $pkt);

  # Validate hardware address (ethernet address)
  $hwaddr_re = join(':', ('[0-9A-Fa-f]{1,2}') x 6);
  if ($hwaddr !~ m/^$hwaddr_re$/) {
    $result = "False";
    return undef;
  }

  # Generate magic packet
  foreach (split /:/, $hwaddr) {
    $pkt .= chr(hex($_));
  }
  $pkt = chr(0xFF) x 6 . $pkt x 16;

  # Allocate socket and send packet
  $raddr = gethostbyname($ipaddr);
  $them = pack_sockaddr_in($port, $raddr);
  $proto = getprotobyname('udp');

  socket(S, AF_INET, SOCK_DGRAM, $proto) or die "socket : $!";
  setsockopt(S, SOL_SOCKET, SO_BROADCAST, 1) or die "setsockopt : $!";

  $result = "True";

  send(S, $pkt, 0, $them) or die "send : $!";
  close S;
}