Introduction
Microsoft says this isn't possible... I beg to differ!
SCCM 2007 has a very useful feature called Operating System Deployment(OSD). OSD task sequences(TS) is a conditions engine that is quite powerful that can do anything from install applications to running tools that return information about a remote system that is running the TS. Knowing this, OSD and TS is created with the intention you are going to be using it like MDT and automating an imaging process, but TS has the potential to do so much more. That is where this tool comes into play. Imagine you need to install a bank of applications in a certain order, which can be done already, but you need the user to interact with this install process (answer a question or select options). Currently, SCCM 2007 Task Sequences have no ability to interact with the user. Searching the web, you will find a few dark corners that talk about how it can be done (See this link). I took the ideas of blogs like this and created a C# version using http://pinvoke.net/ as a reference for my Windows API calls I needed.
Using the SCCM 2007 Console for Interactive Task Sequences
I setup this project with only the basics. Expand on it as you wish. Let's get into using TS in SCCM 2007 Console, then review the code!
First you would open your SCCM 2007 console and navigate to the OSD section. Under OSD, you will see a TS section. You can organize these into function folders. I create one called Test and stick my experiments in there.
Once you have a folder, you can click new task sequence and a wizard will appear.
Select 'Create a new custom task sequence'. This will give you a blank TS.
In your new TS, Click Add > general > Run Command Line.
This is where the magic will happen. This app we create is nothing more than an application launcher that will make the given app appear to the user at any given step in a TS after any given conditions are met, changing that default behavior of the TS that everything is hidden from the user!
You will want to have the launcher.exe and launch2interactiveuser.exe in a SCCM Package along with any other executable you are wanting the user to use as part of the step. The 'Package' check box needs to be used and selected.
Using the Code
Now let's get into the code. This process is basically duplicating a token of the currently logged in user and executing the target executable with that duplicate token.
I ran into an issue with launching a external application directly from my code... I needed the exit code to return to launch2interactiveuser.exe so I could return it to the TS engine to make more decisions in the TS Steps. When launching in the context of the logged in user, the launched app ran in a different process. So solve this, you will see a small executable at the bottom called launcher.exe that I use to pass the exit code back to launch2interactiveuser.exe. I'm sure there are more elegant ways to do this, but I was going for a quick and simple way that works for almost every situation in an enterprise.
namespace launch2InteractiveUser
{
class Program
{
Provide some references to DLLs we need further down to launch an app into a logged in users session and duplicate their token.
[DllImport("advapi32", SetLastError = true),
SuppressUnmanagedCodeSecurityAttribute]
static extern int OpenProcessToken(
System.IntPtr ProcessHandle, int DesiredAccess, ref IntPtr TokenHandle );
[DllImport("kernel32", SetLastError = true),
SuppressUnmanagedCodeSecurityAttribute]
static extern bool CloseHandle(IntPtr handle);
[DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public extern static bool DuplicateToken(IntPtr ExistingTokenHandle,
int SECURITY_IMPERSONATION_LEVEL, ref IntPtr DuplicateTokenHandle);
public const int TOKEN_DUPLICATE = 2;
public const int TOKEN_QUERY = 0X00000008;
public const int TOKEN_IMPERSONATE = 0X00000004;
public static string User;
public static string Domain;
public static string OwnerSID;
static void Main(string[] args)
{
string app = "";
string arg = "";
So the following command line arguments setup the target app we are launching to the user.
int timeout = 1800000;
foreach (string s in args)
{
if (s.Contains("/app="))
app = s.Substring(5);
if (s.Contains("/args="))
arg = s.Substring(6);
if (s.Contains("/timeout="))
timeout = Int32.Parse(s.Substring(9));
}
string USERID = "";
Here, we make a call to WMI to get the logged in user:
using (ManagementClass computer = new ManagementClass("Win32_ComputerSystem"))
{
ManagementObjectCollection localComputer = computer.GetInstances();
foreach (ManagementObject mo in localComputer)
{
try
{
USERID = mo["UserName"].ToString();
Console.WriteLine("User name in Win32_ComputerSystem(" + USERID + ")");
}
catch (Exception er)
{
Environment.Exit(999);
}
}
}
If the user is available, we find ANY process that is running by the user. User
, Domain
, and OwnerID
is set when GetProcessInfoByPID
is called (method shown below).
if (USERID != "")
{
int PID = 0;
int SID = 0;
Process[] localByName = Process.GetProcesses();
int x = 0;
foreach (Process p in localByName)
{
string results = GetProcessInfoByPID(p.Id);
Console.WriteLine(results + " " + User + " " + Domain);
string s = Domain + "\\" + User;
if (s.ToLower() == USERID.ToLower() && p.Responding)
{
PID = p.Id;
SID = p.SessionId;
x = 1;
break;
}
}
Now that we have ANY process running in the users context, we need to impersonate that users token assigned to that process and duplicate it. Windows API has a handy method of doing that, so let's call OpenProcessToken()
.
if (x == 1)
{
IntPtr hToken = IntPtr.Zero;
Process proc = Process.GetProcessById(PID);
if (OpenProcessToken(proc.Handle,
TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE,
ref hToken) != 0)
{
try
{
string path2 = System.IO.Path.GetDirectoryName
(System.Reflection.Assembly.GetExecutingAssembly().Location);
Since we need to get that exit code back, we are going to call our small launcher app defined here.
FileInfo launcher = new FileInfo(path2+@"\launcher.exe");
if (!launcher.Exists)
{
CloseHandle(hToken);
Console.WriteLine("Missing Launcher in execution directory");
Environment.Exit(997);
}
DateTime start = DateTime.Now;
DateTime end = DateTime.Now.AddMinutes(30);
try
{
end = DateTime.Now.AddSeconds(Double.Parse(args[1]));
}
catch { }
This is the magic!!!! CreateProcessAsUser()
is the Windows API call that will spawn the application to the user as if they double clicked the executable themselves. We are basically calling launcher.exe with arguments that will launch our target app. We monitor the process and wait for it to finish. We set a Timeout of 30 minutes earlier in the project but also have an argument we can pass for timeout.
CreateProcessAsUser(hToken, app,arg);
Process myProcess = Process.GetProcessById(proInfo.dwProcessID);
myProcess.WaitForExit(timeout);
(System.Reflection.Assembly.GetExecutingAssembly().Location);
string path = Environment.GetEnvironmentVariable("Temp");
Now that the process has finished running, we go and fetch the exit code.
FileInfo exitCodeFile = new FileInfo(path+"\\exit.results");
if (exitCodeFile.Exists)
{
string code = "";
using (StreamReader sr = new StreamReader(exitCodeFile.FullName))
{
code = sr.ReadToEnd();
}
exitCodeFile.OpenText().Dispose();
Console.WriteLine("Exit Code: " + code);
exitCodeFile.Delete();
CloseHandle(hToken);
Environment.Exit(Int32.Parse(code));
}
else if (DateTime.Now > end)
{
Console.WriteLine("Process has timed out.");
CloseHandle(hToken);
Environment.Exit(996);
}
else
{
Console.WriteLine("Exit.results file missing or something bad happened");
CloseHandle(hToken);
Environment.Exit(995);
}
Very important!!! You always have to CloseHandle()
on your duplicate token.
}
finally
{
CloseHandle(hToken);
}
}
else
{
string s = String.Format("OpenProcess Failed {0},
privilege not held", Marshal.GetLastWin32Error());
throw new Exception(s);
}
}
else
{
Environment.Exit(998);
}
}
else
{
Environment.Exit(999);
}
}
This is the method mentioned above that helps us find ANY process spawned by the user.
static string GetProcessInfoByPID(int PID)
{
User = String.Empty;
Domain = String.Empty;
OwnerSID = String.Empty;
string processname = String.Empty;
try
{
ObjectQuery sq = new ObjectQuery
("Select * from Win32_Process Where ProcessID = '" + PID + "'");
ManagementObjectSearcher searcher = new ManagementObjectSearcher(sq);
if (searcher.Get().Count == 0)
return OwnerSID;
foreach (ManagementObject oReturn in searcher.Get())
{
string[] o = new String[2];
oReturn.InvokeMethod("GetOwner", (object[])o);
processname = (string)oReturn["Name"];
User = o[0];
if (User == null)
User = String.Empty;
Domain = o[1];
if (Domain == null)
Domain = String.Empty;
string[] sid = new String[1];
oReturn.InvokeMethod("GetOwnerSid", (object[])sid);
OwnerSID = sid[0];
return OwnerSID;
}
}
catch
{
return OwnerSID;
}
return OwnerSID;
}
The following are wrappers for the Windows API calls. Most of the code here are borrowed stubs from http://pinvoke.net/.
static ProcessUtility.PROCESS_INFORMATION proInfo =
new ProcessUtility.PROCESS_INFORMATION();
static void CreateProcessAsUser(IntPtr token, string app, string args)
{
IntPtr hToken = token;
IntPtr hDupedToken = IntPtr.Zero;
ProcessUtility.PROCESS_INFORMATION pi =
new ProcessUtility.PROCESS_INFORMATION();
try
{
ProcessUtility.SECURITY_ATTRIBUTES sa =
new ProcessUtility.SECURITY_ATTRIBUTES();
sa.Length = Marshal.SizeOf(sa);
bool result = ProcessUtility.DuplicateTokenEx(
hToken,
ProcessUtility.GENERIC_ALL_ACCESS,
ref sa,
(int)ProcessUtility.SECURITY_IMPERSONATION_LEVEL.
SecurityIdentification,
(int)ProcessUtility.TOKEN_TYPE.TokenPrimary,
ref hDupedToken
);
if (!result)
{
throw new ApplicationException("DuplicateTokenEx failed");
}
ProcessUtility.STARTUPINFO si = new ProcessUtility.STARTUPINFO();
si.cb = Marshal.SizeOf(si);
si.lpDesktop = "winsta0\\default";
string execPath = System.IO.Path.GetDirectoryName
(System.Reflection.Assembly.GetExecutingAssembly().Location);
ProcessUtility.PROFILEINFO profileInfo = new ProcessUtility.PROFILEINFO();
try
{
result = ProcessUtility.LoadUserProfile(hDupedToken, ref profileInfo);
}
catch { }
if (!result)
{
int error = Marshal.GetLastWin32Error();
string message = String.Format("LoadUserProfile Error: {0}", error);
throw new ApplicationException(message);
}
if (args == "")
{
result = ProcessUtility.CreateProcessAsUser(
hDupedToken, null, execPath + "\\launcher.exe \"" +
app + "\"", ref sa, ref sa, true, 0, IntPtr.Zero, execPath, ref si, ref pi );
}
else
{
result = ProcessUtility.CreateProcessAsUser(
hDupedToken, null, execPath + "\\launcher.exe \"" + app +"\" \"" +
args + "\"", ref sa, ref sa, true, 0, IntPtr.Zero, execPath, ref si, ref pi );
}
try
{
result = ProcessUtility.UnloadUserProfile
(hDupedToken, profileInfo.hProfile);
}
catch { }
proInfo = pi;
if (!result)
{
int error = Marshal.GetLastWin32Error();
string message = String.Format
("CreateProcessAsUser Error: {0}", error);
throw new ApplicationException(message);
}
}
finally
{
if (pi.hProcess != IntPtr.Zero)
ProcessUtility.CloseHandle(pi.hProcess);
if (pi.hThread != IntPtr.Zero)
ProcessUtility.CloseHandle(pi.hThread);
if (hDupedToken != IntPtr.Zero)
ProcessUtility.CloseHandle(hDupedToken);
}
}
}
public class ProcessUtility
{
[StructLayout(LayoutKind.Sequential)]
public struct STARTUPINFO
{
public Int32 cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public Int32 dwX;
public Int32 dwY;
public Int32 dwXSize;
public Int32 dwXCountChars;
public Int32 dwYCountChars;
public Int32 dwFillAttribute;
public Int32 dwFlags;
public Int16 wShowWindow;
public Int16 cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public Int32 dwProcessID;
public Int32 dwThreadID;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROFILEINFO
{
public int dwSize;
public int dwFlags;
[MarshalAs(UnmanagedType.LPTStr)]
public String lpUserName;
[MarshalAs(UnmanagedType.LPTStr)]
public String lpProfilePath;
[MarshalAs(UnmanagedType.LPTStr)]
public String lpDefaultPath;
[MarshalAs(UnmanagedType.LPTStr)]
public String lpServerName;
[MarshalAs(UnmanagedType.LPTStr)]
public String lpPolicyPath;
public IntPtr hProfile;
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public Int32 Length;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}
public enum SECURITY_IMPERSONATION_LEVEL
{
SecurityAnonymous,
SecurityIdentification,
SecurityImpersonation,
SecurityDelegation
}
public enum TOKEN_TYPE
{
TokenPrimary = 1,
TokenImpersonation
}
public const int GENERIC_ALL_ACCESS = 0x10000000;
[
DllImport("kernel32.dll",
EntryPoint = "CloseHandle", SetLastError = true,
CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)
]
public static extern bool CloseHandle(IntPtr handle);
[
DllImport("advapi32.dll",
EntryPoint = "CreateProcessAsUser", SetLastError = true,
CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)
]
public static extern bool
CreateProcessAsUser(IntPtr hToken, string lpApplicationName,
string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandle, Int32 dwCreationFlags,
IntPtr lpEnvrionment,
string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
ref PROCESS_INFORMATION lpProcessInformation);
[
DllImport("advapi32.dll",
EntryPoint = "DuplicateTokenEx")
]
public static extern bool
DuplicateTokenEx(IntPtr hExistingToken, Int32 dwDesiredAccess,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
Int32 ImpersonationLevel, Int32 dwTokenType,
ref IntPtr phNewToken);
[
DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Auto)
]
public static extern bool
LoadUserProfile(IntPtr hToken,
ref PROFILEINFO lpProfileInfo);
[
DllImport("userenv.dll", SetLastError = true, CharSet = CharSet.Auto)
]
public static extern bool
UnloadUserProfile(IntPtr hToken,
IntPtr hProfile);
}
The Launcher.exe app. As I said earlier, I had to devise a way to pass the exit code around. For my purposes, the best place both the user and the SYSTEM account would share is the Environment.GetEnvironmentVariable("Temp"); directory.
namespace launcher
{
class Program
{
static void Main(string[] args)
{
char[] split = { ' ' };
Process process = new Process();
process.StartInfo.FileName = args[0];
if (args.Length > 1)
{
process.StartInfo.Arguments = args[1];
}
process.StartInfo.WindowStyle = ProcessWindowStyle.Normal;
process.Start();
process.WaitForExit();
int exitCode = process.ExitCode;
string path = Environment.GetEnvironmentVariable("Temp");
using (StreamWriter outfile =
new StreamWriter(path + @"\exit.results"))
{
outfile.Write(exitCode.ToString());
outfile.Close();
outfile.Dispose();
}
}
}
}
Points of Interest
This solved the issue of "Can you deploy all these custom processes based off of the end users decisions with SCCM?" The normal answer would be no. The normal process might entertain such options of having the user click something to start it, or email a link. You are relying on someone else to complete your task. You can now force the tasks to be presented to the user. If the user refuses, you have that recorded in the TS as well.
History
- 8/30/2011 - Initial post
- 8/31/2011 - Code walkthrough