|
Handle Logons in Windows NT and Windows 2000 with Your Own Logon Session Broker
|
|
Keith Brown
|
| What if you need to be able to launch arbitrary processes using arbitrary credentials into arbitrary window stations? If you do, you'll get some help implementing a "logon session broker," a term that I cooked up to represent one of the primary functions performed by the COM and system SCMs. |
|
This article assumes you're familiar with C++, Win32, and Security |
Code for this article: cmdasuser.exe (29KB)
Keith Brown works at DevelopMentor, developing the COM and Windows NT security curriculum. He is coauthor of Effective COM (Addison-Wesley, 1999), and is writing a developer's guide to distributed security. Reach Keith at http://www.develop.com/kbrown.
|
Many system-level developers eventually find themselves wishing they
had some of the functionality of the COM Service Control Manager (SCM) or the Microsoft® Windows® Service architecture available to them because they both provide the necessary infrastructure to launch processes in arbitrary logon sessions using arbitrary credentials.
For example, let's say you have a Virtual Teller window application. While the teller is logged on, the bank manager needs to perform some work from the teller's machine. In COM, to run a server as a principal named DOMA\Bob, you only need to inject the authority (DOMA), the principal (Bob), and a password into the correct places in the registry. In this case, the authority and principal are written to the RunAs-named value under the server's AppID key, and
the password is tucked away into the local security policy via the Local Security Authority (LSA) API (see my November 1998 Security Briefs column in MSJ for more details).
With this information in place, the operating system can happily launch the COM server in an environment tailored to its needs. If a COM server or system service needs to interact with the person sitting behind the console on a regular basis, it can indicate this desire declaratively (in COM this is done by setting the RunAs-named value to Interactive User). Once again, the operating system happily starts the server process in the desired environment.
What if you want to write your own SCM-like application, one that does something slightly different? What if you need to be able to launch arbitrary processes using arbitrary credentials into arbitrary window stations? If you do, you'll get some help implementing a "logon session broker," a term that I cooked up to represent one of the primary functions performed by the COM and system SCMs.
In my May 1999 column, I described a handy little tool I built called cmdasuser that allows you to launch an interactive command shell running as a distinguished principal other than yourself. Some of you might be aware of the switch user (SU) tool in the Windows NT® Resource Kit, which performs basically the same function. Unfortunately, it does not ship with source code or a description of the subtleties of its implementation. cmdasuser pulls together several techniques that you need to master to write a logon session broker. I hope you'll find it useful.
My motivation for writing this article was a couple of discoveries I made that helped me fix a deficiency and a nagging bug in cmdasuser. The major deficiency was obvious: the tool did not bother to load a user profile, which meant that applications launched by cmdasuser had virtually no access to HKEY_CURRENT_USER or customized environment strings.
The nagging bug manifested itself in a very odd way; after running cmdasuser one or more times on a machine, when the screen saver tried to activate, it failed with the message shown in Figure 1. This is actually a subtle security hole, not just an annoyance. The problem is, if you walk away from your desk expecting your password-protected screen saver to activate when you're gone for more than 10 minutes, you'll come back an hour later to find this dialog instead of your password-protected screen saver.
|
 |
|
Figure 1 Failing Screen Saver
|
You should note that this article applies to Windows NT-based operating systems, including Windows 2000. If I mention Windows for brevity, I'm referring to these versions of the operating system. I won't discuss Windows 9x since most of the features I'll be covering aren't implemented there.
Brokering Logon Sessions
To anyone who has meandered through the Win32® security API, it might seem trivial to implement cmdasuser. Just call LogonUser to establish a logon session for the new user, and then use the token returned from LogonUser to create a new process via the handy CreateProcessAsUser function. This function takes the exact same parameters as CreateProcess, with the addition of a single extra parameter (a handle to a token) that allows you to control the security context for the new process.
Figure 2 shows some code that spawns a copy of my favorite integrated development environment, running as .\Alice (in other words, a local account named Alice) so that I can test how well it functions in a nonadministrative environment. I've omitted error handling for brevity.
At first glance, the code appears to do exactly what I want: launch MSDEV.EXE to run as Alice. In fact, there's really nothing terribly wrong with the code, and it will likely succeedassuming MSDEV.EXE is in the path and .\Alice can be authenticated on the machine where this code is executed. But there are a few hidden gotchas.
First, not just anyone is allowed to call LogonUser or CreateProcessAsUser. Generally, only code running in the System logon session is allowed to make these calls. The System logon session is a special, trusted security context that is created at boot time on every machine running Windows NT or Windows 2000, and code that runs inside of it must be trusted to help enforce (rather than subvert) the security policy of the machine. Without getting into too much theory, suffice it to say that code running in this logon session has ultimate power over the machine, and only administrators control which applications are allowed the privilege of running in this context by installing those applications as services.
To make this code work, you'll need to run it within the context of the System logon session. No big deal; you just need to write a service for Windows NT. If you're a system-level developer, you've done this once or twice in your lifetime. You can then install the service and ask it to perform the logon on your behalf. To make the cmdasuser tool easy for a developer to use, it automatically installs itself as a service when launched from the command line (by calling CreateService), and calls StartService to launch another copy of itself running in the System logon session. It can execute the code in Figure 2 from within this trusted security context, and then you've solved the first problem.
Note that this technique of temporarily installing a service is simply a convenience for the developer using the tool; most folks don't deploy software that behaves this way. The benefit of using this mechanism is that as long as the developer is an administrator on the machine where she's working, she can temporarily inject trusted code into the System logon session without having to remember to explicitly install a service first.
Only administrators are allowed to install services, so like all considerate programs that require administrative privileges, cmdasuser first verifies that the person who launched it is really an administrator. Figure 3 demonstrates how you can do this in your own applications. Note that instead of looking up the SID for Administrators, which would make your code fragile in the face of localization (since it's not spelled the same way in other languages), you instead form it programmatically based on well-known components specified in the WINNT.H header file.
Window Station Allocation
The second problem you need to solve relates to window station and desktop management. I talked a little about window stations and desktops in my May 1999 Security Briefs column. In fact, it would be a good idea to review that column if you're not familiar with these terms because I'll be discussing them in more detail here.
When writing a logon session broker, you need to know the basic ins and outs of managing window stations. Understanding the default window station allocation strategy is a great place to start. The basic model is quite simple. Given a set of processes running on a machine, the default window station boundaries will generally be the same as the logon session boundaries. Visualizing this is not difficult at all, assuming you have a good grasp of what a logon session is in the first place.
Logon sessions don't get a lot of coverage in the SDK documentation, primarily because they are a somewhat abstract concept and there aren't many APIs that deal with them. Most security APIs deal with a more concrete object called the token. Each token is associated with a logon session (you can discover the ID of the logon session for any token by calling GetTokenInformation, asking for TokenStatistics, and scraping out the 64-bit identifier stored in TOKEN_STATISTICS.AuthenticationId). A great example of this is the System logon session, whose logon session ID is hardcoded to be 999 on both Windows NT and Windows 2000. (The definition appears in WINNT.H as a manifest constant, SYSTEM_LUID, which is defined as 0x3E7.) No matter which logon session a process runs within, it will always be linked back to its associated logon session via a token, and the default window station for that process will be based on the process's logon session.
For instance, here's a partial list of window stations I found on my laptop:
|
 |
|
Figure 4 Thanks PVIEW
|
Can you guess which of these window stations is associated with the System logon session? Pretty easy, eh? Let's build on that example. Whenever a call to LogonUser succeeds, this creates an entirely new logon session, and you get back a token that you can use for various things, including starting new processes via CreateProcessAsUser. In fact, I just cut out the code snippet from Figure 2 and used it to launch MSDEV.EXE running as Alice. I then used PVIEW.EXEan incredibly useful tool that ships with the Windows NT Resource Kitto display the new logon session ID for the process token 0x2BE10 (see Figure 4).
After enumerating the window stations on my machine, lo and behold:
|
WinSta0
Service-0x0-3e7$
Service-0x0-2be10$
|
If you want to try this yourself just run WINOBJ.EXE, a tool that ships with the Platform SDK (it's tucked away in \mssdk\bin\winnt), and open up the Windows folder to find a list of window station objects on your machine. The point is that when left to its own devices, the system creates new window stations whenever they are needed when you call CreateProcessAsUser. You can adjust this default behavior by simply modifying a parameter in the STARTUPINFO structure. I'll get back to the details shortly, but here are a couple of other window stations that are running on my laptop that I didn't mention earlier:
|
These window stations were created explicitly by someone, and with a little help from Jeffrey Richter's fine TINJLIB sample from his book, Advanced Windows (Microsoft Press, 1997), I was able to discover exactly which processes were using them. I used a slightly modified version of Jeff's sample to inject a DLL into each process running on my system. The code in this DLL dumped some information about the process, including the window station that hosted it. I discovered that Microsoft Internet Information Server (INETINFO.EXE) is responsible for the first rogue window station in the previous list, and the Task Scheduler (MSTASK.EXE) is responsible for the second. My guess is that these servers create their unique window stations at startup and migrate into them by calling SetProcessWindowStation to gain a little more isolation from the rest of the system.
Using PVIEW.EXE, I looked up the logon session ID for the interactive logon session and its value was 0x3CF8. (To do this, all I had to do was look at the token for the shell, EXPLORER.EXE, or any processes that I've launched from the shell.) You'll notice that no window station I've listed appears to correspond to that value, and that's by design, as processes running in the interactive logon session normally run in WinSta0.
WinSta0 is a special window station that houses all processes that will need to interact with the person sitting behind the console by displaying windows, obtaining mouse and keyboard input, and so on. This is the only window station capable of these feats, so any system service that needs this type of interaction must be hosted in WinSta0, including the Messenger service and the interactive logon service (Winlogon). Winlogon is a logon session broker that most programmers are familiar with. It acts as a gateway for bootstrapping new
interactive users into the system, and is the process that you see when you first log on to Windows.
If you use Winlogon to log on to Windows interactively (by pressing Ctrl-Alt-Delete and entering a password), Winlogon establishes a new logon session for you. This is conceptually similar to the way I called LogonUser earlier to start a new logon session for Alice. Winlogon then starts the shell process (EXPLORER.EXE, by default) in the interactive user's logon session and directs it to run in its natural habitat, WinSta0, where all interactive processes run. Note that the name WinSta0 is not tied to any particular interactive logon session because there will likely be many interactive users (and therefore logon sessions) that come and go without restarting the operating system. Regardless, WinSta0 remains present so it can host interactive services like Winlogon, which continue to run whether or not there is an interactive user present.
When you call CreateProcessAsUser, you need to choose whether to take matters into your own hands or to simply abide by the natural order and direct the new process into a window station allocated specifically for the process's logon session. Like most things in Windows, going with the flow is generally easier and requires less typing, so that's why I carefully constructed my STARTUPINFO structure in Figure 2 that calls CreateProcessAsUser, specifying an empty string for the lpDesktop parameter:
|
STARTUPINFO si = { sizeof si, 0, "" };
|
|
This is simply C/C++ shorthand for the following (perhaps clearer) code snippet:
|
STARTUPINFO si;
ZeroMemory( si, sizeof si );
si.cb = sizeof si;
si.lpDesktop = "";
|
However you decide to type it, notice that this code explicitly sets lpDesktop to point to a string that consists of a single NULL terminator. Most of you who have called CreateProcess have safely ignored this parameter, but when you call CreateProcessAsUser it has much greater importance: you are choosing a window station allocation policy. If you specify an empty string as I have here, you are choosing to allocate window stations based on the natural logon session boundaries that I've described. If you pass a string specifying a window station and desktop, you are taking matters into your own hands.
Most people get bitten when they ignore lpDesktop completely and set it to NULL. This indicates that you want the new process colocated in the parent process's window station, which is generally desirable when you call CreateProcess since both processes share the same logon session. However, when you call CreateProcessAsUser, unless you really know what you're doing, you should specify an empty string to allow the system to work its magic for you. This creates a window station (if necessary) to host the process based on the logon session within which the process will run.
Real World Limitations
It's interesting to look at the window station allocation policy that the system SCM uses. If you take two services, configure them to run as two distinct principals (say, Alice and Bob), and start those services, then by using PVIEW you'll see that each of the processes runs in a distinct logon session. And by using WINOBJ, you'll see a corresponding window station for each of these processes. Nothing surprising there.
What if you configure both services to run as Alice? After restarting the services, you'd see that both processes still run in distinct logon sessions and therefore have distinct window stations. Why is this? Well, the system SCM is simply creating a new logon session whenever it needs to launch a process to host a service configured to run as a distinguished principal (as opposed to being configured to run as LocalSystem, in which case the SCM injects the new process into the System logon session). LogonUser creates a new logon session each time you call it, even if you call it twice for the same principal. This explains why multiple window stations will often be allocated for processes running as the same principal. The subtle distinction is that those processes are running in separate logon sessions.
Most services that ship with the operating system don't run as a distinguished principal. Rather, they are configured to run as LocalSystem, which means they all share the same logon session. (Recall that there is only one System logon session, specifically number 0x3E7.) This means that these system services will naturally be placed in the Service-0x0-3e7$ window station. However, services configured to run as LocalSystem may be specifically configured to run in WinSta0 via the CreateService API or via the built-in service administration tools. In that case, when the system SCM is required to launch the process hosting the service, it will direct the new process into WinSta0. Interestingly enough, only services designated to run as LocalSystem are allowed this privilege. This is due to the tight security constraints applied to WinSta0, which I'll discuss shortly.
Brokering logon sessions (and therefore window stations) can be a tough job in the real world, where there is an effective upper limit on how many desktops can be created. If you call CreateProcessAsUser to start a process running in a newly created logon session and allow the system to create a new window station for the logon session, it will also create a default desktop to host the threads in the process. The desktop limitation leads to an upper limit on the number of processes that a logon broker can launch unless some sort of conservation scheme is implemented (you'll see an example shortly). The effect is that in Windows NT 4.0, if you try to launch several services configured to run as distinguished principals (even the same principal), you'll reach the limit after about 11 processes are running in their own distinct window stations. The result is not pretty. Refer to the last section of Knowledge Base article Q169321 (http://support.microsoft.com/?kbid=169321) for the details.
Windows 2000 makes life a bit easier by raising the effective upper limit on desktops significantly. For example, I was able to create 75 window station/desktop pairs on Windows 2000 beta 3. You can get the same functionality by installing the Option Pack for Windows NT 4.0, probably because Microsoft Transaction Server (MTS) encourages server packages to run as distinguished principals, so each of them would theoretically chew up a new desktop when launched. I say theoretically because in Windows NT 4.0 Service Pack 4.0, an interesting mechanism was implemented inside the COM SCM that helps alleviate this problem.
Here's how it works. In SP4, the COM SCM took the bull by the horns and provided a more efficient logon session allocation scheme, which helps reduce the overall number of distinct window stations required by COM servers. The idea is simple: if 10 different COM servers are all configured to run as Alice on a particular machine, why can't they share the same logon session? In fact, think about what it would take to make this work. To put 10 processes into a single logon session, you'd have to call LogonUser just once and CreateProcessAsUser 10 times, using the same token each time. This would naturally place each process in the same logon session and window station, thus reducing
the overhead for each process. So starting with SP4, the COM SCM caches tokens for COM servers on a per-principal basis.
There is one caveat when using this technique. You need to remember that authorization information for the principal (groups and privileges) is cached in the token. If this information changes, the cached token for that principal should be considered stale and refreshed by establishing a new logon session. The COM SCM deals with this in a reasonable way; it appears to call LogonUser each time a new COM server (set to RunAs Alice, say) needs to be launched, and compares the set of groups in the new token with the set of groups in the cached token for Alice. If anything has changed, the COM SCM simply replaces the old token in the cache with the new one and starts the new server processand any subsequent server processes configured to run as Aliceusing the fresh token. As an added precaution, the COM SCM also uses an algorithm that causes cached tokens to become stale after a fixed period of time. This scheme is easy to implement yourself if your own logon session broker spawns several processes yet wants to run on earlier versions of Windows NT, where desktops are scarce resources.
Note that this applies to the COM SCM. The system SCMthe one that launches services, as opposed to COM serversdoes not use this approach, at least according to my tests on Windows NT SP4 and Windows 2000 beta 3.
Directing Processes into WinSta0
Well, I've talked quite a bit about window station allocation schemes, but all this time I've left MSDEV.EXE running as Alice in a window station called Service-0x0-2be10$. I keep looking for Visual Studio® on my desktop, but I can't find it anywhere. (Oh yeah, only WinSta0 is actually visible.) This is not the behavior I wanted. I was hoping to run MSDEV.EXE in a logon session for Alice, but I also need to be able to interact with it to see how well it functions when running under Alice's credentials rather than mine. (I'm an administrator after all, and that's not a very challenging environment for an application.)
Note that when I called LogonUser, I specified LOGON32_LOGON_INTERACTIVE. While you might get the impression that this has something to do with window station assignment, it does not. The dwLogonType parameter to LogonUser specifies the desired type of logon session, and thus the logon rights that Alice must be granted to have the right to establish that type of logon session. I wrote in more detail about the various types of logon sessions (and the rights required for each type) back in my February 1999 Security Briefs column, in case you want more information on the dwLogonType parameter to LogonUser. Suffice it to say that this has no effect whatsoever on the window station allocation policy when you call LogonUser / CreateProcessAsUser.
My goal is to share WinSta0 with both the interactive logon session (the session I'm using right now to type this article) and the new logon session I just created for Alice. While this is within the realm of possibility, it goes against the grain of the natural window station allocation policy, and therefore takes a bit more work to achieve.
I'll need to deal with the DACLs on the interactive window station and desktop. Since the normal state of affairs consists of window stations partitioned based on logon session boundaries, the DACL for each window station should theoretically be very simple: just grant access to the principal whose logon session naturally belongs in the window station. In fact, here's a partial dump of the DACL for the window station that MSDEV.EXE is running in currently (I obtained this information via the EnumWindowStations and GetUserObjectSecurity APIs):
|
grant 0x000f006e to Alice
grant 0x00000100 to Administrators
|
Without getting into the details of what each bit in the access mask represents, this basically allows any process running as Alice to live in this window station, regardless of its logon session. So you can call LogonUser(Alice)/CreateProcessAsUser 10 times and force all 10 processes (each in separate logon sessions) to run inside the Service-0x0-2be10$ window station by setting the lpDesktop parameter appropriately (to be specific, you'd set it to Service-0x0-2be10$\Default).
While this works fine in noninteractive window stations, WinSta0 is not quite as forgiving. Take a look at a partial dump of the DACL on WinSta0:
|
grant 0x00000024 to kbrown
grant 0x000f037f to S-1-5-5-0-0x3CF8
grant 0x00020166 to Administrators
grant 0x000f037f to SYSTEM
|
The first element in the DACL grants a couple of permissions directly to my user account. But the interesting entry is the second one, which grants full permissions to my logon session (0x3CF8)that is, the session established when I logged on to my laptop in the morning via Winlogon.
You see, tokens generated by a call to LogonUser always contain a special SID that you can place in a DACL to grant and deny access to that logon session only. This is much more fine-grained than granting all access directly to my user account; by granting access only to an individual logon session, the system is effectively granting access to a particular instance of me. Since the System account only ever has a single logon session, there is no need for a logon session SIDand you won't find one if you look in a token for the System logon session. This is why the previous DACL simply grants permissions directly to the System account, as opposed to a logon session SID for session 999.
So to run MSDEV.EXE simultaneously in Alice's logon session and in WinSta0, you must first adjust the DACL on WinSta0 (and its default desktop) to explicitly grant access to Alice's logon session SID. This is not difficult conceptually, and Figure 5 shows a helper function that can be used to accomplish this task. Note that you can quickly locate the logon session SID in the token because it is marked with a special flag, SE_GROUP_LOGON_ID. The helper function uses the SetEntriesInAcl API (introduced in Windows NT 4.0) to avoid having to deal with the older ACL manipulation APIs such as AddAce and friends, whose main goal seems to be aggravating the calluses on the tips of your fingers. (Code that uses these APIs has a tendency to be extremely verbose.)
The good thing about the code in Figure 5 is that it is easy to read and clearly shows the technique for searching for the logon SID and granting access to that SID in the interactive window station and default desktop DACLs. The bad thing about this code is that it has a very unintuitive and nasty side effect on Windows NT 4.0. While this code is correct, it caused the weird problem with my screen saver that I mentioned at the beginning of this article.
SetEntriesInAcl
Here's the story: SetEntriesInAcl has some problems dealing with DACLs that contain inheritable ACEs. To take a concrete example, I'll show you the full-blown DACL on WinSta0 (previously, I omitted the inheritable ACEs to simplify things):
|
grant 0x00000024 to SHAWN\kbrown
grant 0xf0000000 to S-1-5-5-0-0x3CF8 (inherit)
grant 0x000f037f to S-1-5-5-0-0x3CF8
grant 0x200000c7 to Administrators (inherit)
grant 0x00020166 to Administrators
grant 0xf0000000 to SYSTEM (inherit)
grant 0x000f037f to SYSTEM
|
The three entries I've annotated with "(inherit)" actually don't apply to WinSta0 at all. They are only there to be propagated to any new, securable child objects that happen to be created in WinSta0namely, desktops.
To truly protect your desktop from unwanted snoopers when a screen saver activates, Winlogon creates a new desktop called Screen-saver, switches to that desktop, and executes the screen saver program on that desktop, thus hiding the default desktop where your work-in-progress lives. (In Windows 2000, only password-protected screen savers exhibit this behavior.) Those inheritable entries in the WinSta0 DACL help form the DACL for the newly created Screen-saver desktop, and the important entry is the one that grants GENERIC_ALL (0xf0000000) permissions to the SID for the interactive logon session. This allows the screen saverwhich runs in the same logon session as the interactive userto operate on the newly created Screen-saver desktop. When the new desktop is created, the OS automatically converts that GENERIC_ALL inherit-only ACE from the parent window station's DACL into an ACE in the new desktop's DACL, which grants full control to the logon session SID.
The problem is that if you use SetEntriesInAcl to adjust the window station DACL (as shown in Figure 5), you'll find that the new ACL it produces loses all of the existing inherit-only entries. Thus, when the Screen-saver desktop gets created, the interactive logon session doesn't have access rights to use it. Sadly, this causes yet another variation of that infamous error dialog (see Figure 1) when the screen saver process attempts to initialize on the new desktop. What's a developer to do?
This particular problem with SetEntriesInAcl is fixed in Windows 2000 beta 3, but if you are shipping code that supports Windows NT 4.0, I've provided an alternate helper function that adjusts the DACL using the low-level ACL APIs. (Refer to the sample code at the link at the top of this page.) It's not nearly as pretty, but it will make your customers' screen savers oh so much happier. (And hey, while you're cutting and pasting this code, I'll be busy filing down the calluses on my fingertips.)
Window Station Wrap-up
So now you have the code necessary to create a new logon session for Alice and adjust the DACLs on the interactive window station and desktop. Now you need to figure out how to direct the new process into the interactive window station and desktop via CreateProcessAsUser. One approach would be to create the process in its natural habitat (a window station named Service-0x0-2be10$ in this case), and then have the process call the SetProcessWindowStation and SetThreadDesktop APIs to migrate over to WinSta0\
Default on the fly.
There are two problems with this approach. The first one is obvious: it's improbable that any given application will provide this code for you. (You can bet that NOTEPAD.EXE doesn't call these functions.)
The second problem is not so obvious. Based on my tests in both Windows NT 4.0 and Windows 2000 beta 3, if a process switches to another window station, the original window station and default desktop created for that process won't be destroyed until the process terminates. In fact, back in the days before SP4, when COM servers were consuming precious desktop resources, I tried writing a couple of COM servers (both configured to run as the same principal) to share window stations using this technique. The first COM server that appeared would create a named window station and switch to it, and the second server would then try to join the first in the precreated window station. Both servers were successful in connecting to
the shared window station, but I ended up with three window station/desktop pairs instead of just one because
I couldn't rid myself of the original ones allocated for the two processes.
You'll want to make sure that the system doesn't bother creating a window station for Alice's logon session in the first place. To do this, you simply need to adjust the lpDesktop parameter in your call to CreateProcessAsUser from this
|
STARTUPINFO si = { sizeof si, 0, "" };
|
STARTUPINFO si = { sizeof si, 0,
"WinSta0\\Default" };
|
|
Preparing the Environment
Using all you've learned so far, you can now spawn a process in a newly established logon session for Alice so that you can see how it behaves in a nonadministrative logon session. You also know how to safely direct that process into WinSta0, so you can actually interact with it and see its windows on the screen. However, many applications rely on environment variable settings, and in your call to CreateProcessAsUser you simply passed NULL for lpEnvironment, the environment block parameter. This means the new process will get an exact copy of the environment block for the parent process, which is clearly not what you want.
For a simple example, bring up a command shell and type
|
|
to list all environment variables that start with the letter U. On my laptop, running Windows NT Server SP4, I get the following output:
|
USERDOMAIN=SHAWN
USERNAME=kbrown
USERPROFILE=D:\WINNT\Profiles\kbrown
|
This information is clearly tied to the principal SHAWN\
kbrown, and you'd like to customize this for Alice. Also, if you right-click on My Computer and choose Properties, the Environment tab (it's called Advanced in Windows 2000 beta 3) shows a list of environment variables broken into two distinct parts, one for the principal (in my case, kbrown) and one for the system as a whole. The systemwide variables are shared by all principals, but clearly you need to load Alice's environment variables, not those for kbrown.
Where are these environment variables stored? The system variables are easythey are tucked away in the following registry key:
|
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\
Session Manager\Environment
|
|
The user-specific environment variables are also stored in the registry, but these are stored on a per-user basis:
|
HKEY_USERS\<user sid>\Environment
|
Each subkey under HKEY_USERS is known as a registry hive, a file that can be loaded on the fly. Normally, when a user logs in via Winlogon, the system automatically loads the user's hive into HKEY_USERS and processes running in that user's logon session can conveniently access this hive via the HKEY_CURRENT_USER registry key, which is simply a convenient alias. Not only are per-user environment variables stored here, but most nontrivial applications (such as MSDEV.EXE) also tuck away gobs of per-user settings such as font preferences.
The problem with the solution so far is that CreateProcessAsUser doesn't automatically load Alice's hive and create an appropriate environment block for the process running in Alice's logon session. In fact, using the registry editor I can show you exactly what the registry looks like under HKEY_USERS on my laptop with myself logged in via Winlogon and MSDEV.EXE running in the logon session I created for Alice:
|
HKEY_USERS
.Default
S-1-5-21-700332275-1906905974-1264475144-1541
|
|
The second hive listed is named after the SID for my own principal, kbrown. The first hive (.Default) is always present, and it is normally used as the starting point for creating hives for new users at their first logon. In this case, the .Default hive is pulling double duty because it's also the hive that MSDEV.EXE sees through HKEY_CURRENT_
USERS. This is absolutely not what I intended and, in fact, may cause the process to fail in unexpected ways because Alice does not have full access rights to the .Default hive. Using REGEDT32.EXE, if you take a peek at the DACL for this hive, you'll see that it looks something like this:
|
grant ALL access to Administrators
grant ALL access to SYSTEM
grant READ access to Everyone
|
Since Alice is not a member of the administrator's group and MSDEV.EXE is running in Alice's logon session, if MSDEV.EXE tries to write to a registry key under HKEY_CURRENT_USERsomething that occurs on a regular basis for the file and workspace MRU list, preferences, and the likeit will fail miserably.
The solution is to load the user's hive yourself, but you will quickly find that this is not a trivial job. Where is the user's hive located? Looking on my machine, I see several profile directories under %SYSTEMROOT%:
|
Administrator
Alice
All Users
Default User
kbrown
|
|
Apparently I logged in as Alice via Winlogon at some time in the past because that's normally when user profiles are created. Looking in the Alice subdirectory, I clearly see Alice's hive, NTUSER.DAT. However, see what happens to this list if I delete the Alice account (via User Manager), add a new account named Alice, log out, and log back in as Alice (temporarily) to initialize the new user profile:
|
Administrator
Alice
alice.000
All Users
Default User
kbrown
|
Ouch! Which of these directories should I choose? Well, it turns out that there is a registry key that tells me:
|
HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\
ProfileList
S-1-5-21-700332275-1906905974-1264475144-1003
S-1-5-21-700332275-1906905974-1264475144-1541
S-1-5-21-700332275-1906905974-1264475144-500
|
Under this registry key, the system maintains the current list of user profiles based on SIDs. A named value under each subkey, ProfileImagePath, points to the directory where the profile lives. So all you have to do is know how to convert a binary SID to its corresponding textual form, look up this registry key, append \NTUSER.DAT, and call LoadRegistryKey to load the hive. The calluses on my fingers are already starting to thicken just thinking about writing this code.
To be fair, someone in Microsoft Product Support has already done this work and provided some code that you can scavenge from Knowledge Base article Q168877 (http://support.microsoft.com/?kbid=168877). This article doesn't address what you should do if the user whose profile you'd like to load has never logged on to the machine before (via Winlogon) and their profile doesn't even exist yet.
One approach to dealing with this issue is to try guessing what the system does when it creates a new profile, and then mimic that behavior. The system appears to perform a deep copy of all the files under the Default User profile, and then changes the DACLs on these new directories and files to grant access to the new user. It also must load the newly created registry hive and change the DACLs on the registry keys themselves (since they won't grant full access to the new user otherwise). Finally, the system needs to update the ProfileList to add a new entry mapping the user SID to the profile directory. You could probably figure out how to do all of this yourself based on empirical evidence, but I haven't found any documentation that will help you feel confident that you haven't missed any subtle steps.
Windows 2000 to the Rescue
It turns out that the Windows 2000 Platform SDK documents a whole new set of functions for dealing with these tough issues, and makes loading and creating new profiles as easy as making a single function call. The DLL whose interface I am referring to is called USERENV.DLL, and it contains (among others) four functions that you can use to make cmdasuser shine: LoadUserProfile, UnloadUserProfile, CreateEnvironmentBlock, and DestroyEnvironmentBlock.
LoadUserProfile loads a user's HKEY_USERS hive automatically, and the only thing you need to make this work is the token for that user. This is exactly what a logon session broker needs. In fact, if the user hasn't yet logged in to the machine via Winlogon (and thus doesn't have a profile yet), the function creates a new profile, performing all the grungy file copying, DACL manipulating, and so forth on your behalf. Creating a new profile takes a few seconds to complete, and you'll hear the hard drive clicking away while this function executes. But believe me, for those of you who have been waiting for this API, it's a joyful noise the first time you hear it. The corresponding function, UnloadUserProfile, unloads the hive from the registry.
CreateEnvironmentBlock is another handy function that creates a set of environment variables in a form that you can pass directly to CreateProcess(AsUser), provided you have the token for the user. This does all the work of looking up the environment variables from various places in the registry, expanding user-specific strings, and so on to give you the correct environment block for a process running in an alternate logon session.
Given these four functions, it's easy to manipulate the environment correctly for processes you create via CreateProcessAsUser. But what if you need to ship code today?
A Solution Using Windows NT 4.0?
It turns out that USERENV.DLL ships as a base component of Windows NT 4.0, but its interface has been undocumented and its import library, USERENV.LIB, has not been made available. But this didn't stop me from trying to use it, given the documented interface in Windows 2000. So I wrote a little helper class (see Figure 6) that loads USERENV.DLL dynamically and calls GetProcAddress, looking for the four entry points that I enumerated earlier. Each of these entry points (if found) is stored in a member variable of the class that is strongly typed as a function pointer with the appropriate corresponding signature. This makes it easy to use the interface from USERENV.DLL, and it's generally a cool technique for explicitly loading classic DLLs.
The final code for creating the logon session, loading the user profile, creating an appropriate environment block, and creating the new process is shown in Figure 7. I have tested the resulting application on Windows 2000 beta 3 as well as Windows NT 4.0 with Service Packs 4, 3, and 1 (I didn't happen to have a copy of SP2 handy). It appears to perform flawlessly.
I always wrestle with using undocumented features, but in this particular case there really isn't an alternative other than simply not loading the user profile at all. Given the fact that the API is documented in the Windows 2000 Platform SDK, you don't have to worry about the API going away any time soon. I also find it interesting that when the SUSS service is running on Windows NT 4.0 (this is the service component of the SU tool from the Resource Kit that I mentioned earlier), if you examine the DLLs loaded into its process via PVIEW, you'll find USERENV.DLL roosting there. Actually, I've discovered that this capability is not only supported, but partially documented in Knowledge Base article Q196070.
Cleanup
After you've loaded a user profile, you need to eventually unload it, which means you need some way of determining when it's no longer necessary. This particular function is very dependent on the problem domain, and for cmdasuser I chose to take a very simple approach: as soon as the command shell terminates, I assume that it's safe to unload the profile. I chose this policy because it's easy to implement and pretty easy to explain to someone using the program.
From what you've seen so far, cmdasuser is designed to run as two distinct processes. The first is the initial process spawned by an administrator who invokes cmdasuser, passing an authority, principal, and password on the command line. At this point, cmdasuser installs itself (temporarily) as a service and calls StartService to start a second copy of itself running in the System logon session, where LogonUser and CreateProcessAsUser can be called with wild abandon. By this time, the first instance of the process has terminated and the second instance calls CreateProcessAsUser to spawn a command shell. It then calls DeleteService to remove itself from the system SCM's database and shuts down. This doesn't leave any room to clean up after the command shell has terminated. I could have written the service so it would block until the command shell shut down, and only then clean up, but this would limit cmdasuser to running a single command shell at a time.
So the approach that I took was to spawn a third copy of cmdasuser in the System logon session, instead of directly calling CreateProcessAsUser. This way, the second instance of cmdasuser (the one launched by the system SCM) is simply used to inject an independent process (one that is not under the control of the system SCM at all) that has an independent lifetime. This third process is the one that calls CreateProcessAsUser and blocks until the command shell exits, at which time it unloads the user profile. I distinguish the roles of each instance of cmdasuser simply based on command-line parameters and the security context within which they run.
An alternate approach would have been to run a single copy of the cmdasuser service and have it spawn threads for each command shell it launched, but that would require some form of interprocess communication, and I wanted to keep this example really simple. As far as I can tell, this multithread model is the one used by SU and SUSS in the Resource Kit, and the two processes use RPC to communicate with each other.
Summary
Managing logon sessions involves some tricky aspects of Windows NT security, but it's easy once you understand the issues that need to be addressed such as windows stations, user profile management, and environment block manipulation.
Even if you don't plan to write a logon session broker any time soon, I hope this article has helped you gain a better understanding of logon sessions, window stations, and the way COM servers and system services work.
I'd like to thank Mike Woodring who originally pointed out USERENV.DLL in an early Windows 2000 beta, giving me the impetus to put the finishing touches on the cmdasuser tool. I'd also like to thank Saji Abraham for pointing out the new behavior of the COM SCM, and Jeff Richter for his awesome TINJLIB sample.
You can download the source code for cmdasuser from the MSJ Web site. For the very latest updates and news about the cmdasuser tool, go to my security samples page at http://www.develop.com/kbrown
.
|
|
|