Search code examples
c#asp.netpowershellactive-directoryimpersonation

ASP.NET to host Active Directory RSAT powershell scripts--DC hangs up


I'm working on making a ASP.NET (C#) app that is basically a gateway for running Powershell scripts for routine admin tasks. Some of these scripts use the ActiveDirectory RSAT module and I'm finding that some of these cmdlets will not run correctly when invoked via the gateway and the trace seems to imply that the connection to the domain controller is successful, but is then being shut down by the DC.

The following code is an ASP.NET web form that has one text input to specify a username. Basically, it does the following:

  • Assumes the identity of the web user (confirmed to be inherited by powershell)
  • Creates a powershell runspace and a pipeline within that runspace
  • Invokes the Get-ADUser cmdlet and passes the username as the Identity parameter
  • Confirms success by reading the user's name into an output element on the form.

    protected void LookupButton_Click( object sender, EventArgs e ) {
      WindowsImpersonationContext impersonationContext = ((WindowsIdentity)User.Identity).Impersonate();
      Runspace runspace;
      Pipeline pipe;
      try {
        runspace = new_runspace();
        runspace.Open();
        pipe = runspace.CreatePipeline();
        Command cmd = new Command("Get-ADUser");
        cmd.Parameters.Add(new CommandParameter("Identity", text_username.Text));
        pipe.Commands.Add(cmd);
    
        PSObject ps_out = pipe.Invoke().First();
        output.Text = ps_out.Properties["Name"].Value.ToString();
      }
      catch( Exception ex ) {
        error.Text = ex.ToString();
      }
      finally {
        impersonationContext.Undo();
      }
    }
    
    private Runspace new_runspace( ) {
      InitialSessionState init_state = InitialSessionState.CreateDefault();
      init_state.ThreadOptions = PSThreadOptions.UseCurrentThread;
      init_state.ImportPSModule(new[] { "ActiveDirectory" });
      return RunspaceFactory.CreateRunspace(init_state);
    }
    

The interesting part is the specific wording in the error message exposed in the catch block (emphasis mine):

System.Management.Automation.CmdletInvocationException: Unable to contact the server. This may be because this server does not exist, it is currently down, or it does not have the Active Directory Web Services running. ---> Microsoft.ActiveDirectory.Management.ADServerDownException: Unable to contact the server. This may be because this server does not exist, it is currently down, or it does not have the Active Directory Web Services running. ---> System.ServiceModel.CommunicationException: The socket connection was aborted. This could be caused by an error processing your message or a receive timeout being exceeded by the remote host, or an underlying network resource issue. Local socket timeout was '00:01:59.6870000'. ---> System.IO.IOException: The read operation failed, see inner exception. ---> System.ServiceModel.CommunicationException: The socket connection was aborted. This could be caused by an error processing your message or a receive timeout being exceeded by the remote host, or an underlying network resource issue. Local socket timeout was '00:01:59.6870000'. ---> System.Net.Sockets.SocketException: An existing connection was forcibly closed by the remote host

The upper-level exceptions suggest there was a timeout, but there was no timeout indicated by the lower exceptions (and it took only a few seconds to return from the command). In cases where the server is not reachable, the lowest-level exception message says as much, but this specific wording is making me think there is some kind of authentication (or other security) problem at work here.

UPDATE Feb 19, 2013: When using the impersonation method described here, the script runs as expected. This is making me think that the issue may be that the WindowsIdentity object provided by Windows authentication is perhaps unsuitable for a script that essentially makes RPC calls against AD. Unfortunately, it's not really desirable for me to abandon windows auth because I would have to handle the users' passwords in my application code (and that's not a responsibility that I want).

I haven't been able to find any documentation about what exactly windows auth is doing or what kind of impersonation is allowed to result from its use. Is it possible to do this while using windows auth or am I going to have to require the user to give me their password?


Solution

  • Cause

    The root cause of this problem is that when you use Windows Authentication in IIS, the security token is valid only for authenticating the web client machine to the web server machine. The same token is not valid for authenticating the web server machine to any other machine and that is what my application was trying to do:

    1. The client obtains a security token and sends it to the web server.
    2. IIS asks a DC to verify the token and the token is verified. The web client is authenticated to the web server at this point.
    3. IIS checks the authenticated identity against the application's authorization rules.
    4. The web application impersonates the identity using the token received by IIS and runs a script that will then inherit the same security token.
    5. The script tries to use the same token to authenticate to a remote RPC service.
    6. The domain controller recognizes the authentication attempt as a replay attack (that token was for a different service) and shuts down the connection.

    It's not exactly correct to call this a "side-effect" of Kerberos, but it wasn't obvious to me at first although it seems obvious in hindsight. I hope someone can benefit from this information.

    Solution

    The solution to this is for the application to generate it's own security token that can then be used to authenticate as the web user to services on other machines by making an API call to LogonUser(). Your application code will need access to the user's clear password and this can be made available by enabling only HTTP Basic authentication in IIS. The web server will still enforce the same authentication and authorization rules, but both the user name and password will be made available to your application code. Keep in mind that these credentials are transmitted to the web server in the clear, so you will want to require SSL before using this in production.

    I created a small helper class based on the procedure described here that facilitates this process. Here is a short demonstration:

    Logon helper:

    public class IdentityHelper {
      [DllImport("advapi32.dll")]
      private static extern int LogonUserA( String lpszUserName, String lpszDomain, String lpszPassword, int dwLogonType, int dwLogonProvider, ref IntPtr phToken );
    
      [DllImport("advapi32.dll",
        CharSet = CharSet.Auto,
        SetLastError = true)]
      private static extern int DuplicateToken( IntPtr hToken, int impersonationLevel, ref IntPtr hNewToken );
    
      [DllImport("kernel32.dll",
        CharSet = CharSet.Auto)]
      private static extern bool CloseHandle( IntPtr handle );
    
      public const int LOGON32_LOGON_INTERACTIVE = 2;
      public const int LOGON32_PROVIDER_DEFAULT = 0;
      public const int IMPERSONATION_LEVEL_IMPERSONATE = 2;
    
      public static WindowsIdentity Logon( string username, string password, string domain = "" ) {
        IntPtr token = IntPtr.Zero;
        WindowsIdentity wid = null;
    
        if( domain == "" ) {
          split_username(username, ref username, ref domain);
        }
    
        if( LogonUserA(username, domain, password, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, ref token) != 0 ) {
          wid = WIDFromToken(token);
        }
        if( token != IntPtr.Zero ) CloseHandle(token);
        return wid;
      }
    
      public static WindowsIdentity WIDFromToken( IntPtr src ) {
        WindowsIdentity wid = null;
        IntPtr token = IntPtr.Zero;
        if( DuplicateToken(src, IMPERSONATION_LEVEL_IMPERSONATE, ref token) != 0 ) {
          wid = new WindowsIdentity(token);
        }
        if( token != IntPtr.Zero ) CloseHandle(token);
        return wid;
      }
    
      private static void split_username( string username, ref string username_out, ref string domain_out ) {
        string[] composite_username = username.Split(new char[] { '\\' });
        if( composite_username.Length == 2 ) {
          domain_out = composite_username[0];
          username_out = composite_username[1];
        }
      }
    }
    

    Powershell helper class:

    public class PSHelper {
      public static Runspace new_runspace() {
        InitialSessionState init_state = InitialSessionState.CreateDefault();
        init_state.ThreadOptions = PSThreadOptions.UseCurrentThread;
        init_state.ImportPSModule(new[] { "ActiveDirectory" });
        return RunspaceFactory.CreateRunspace(init_state);
      }
    }
    

    ASP.NET form handler:

    protected void LookupButton_Click( object sender, EventArgs e ) {
      string outp = "";
      WindowsIdentity wid = IdentityHelper.Logon(Request["AUTH_USER"], Request["AUTH_PASSWORD"]);
      using( wid.Impersonate() ) {
        Runspace runspace;
        Pipeline pipe;
        runspace = PSHelper.new_runspace();
        runspace.Open();
        pipe = runspace.CreatePipeline();
        Command cmd = new Command("Get-ADUser");
        cmd.Parameters.Add(new CommandParameter("Identity", text_username.Text));
        pipe.Commands.Add(cmd);
        outp = pipe.Invoke().First().Properties["Name"].Value.ToString();
      }
      output.Text = outp;
    }