Debugging Windows: New Users, Powershell, and TLS

A little context

I wanted to work on this PR in Infection Monkey. Basically, a very simple feature - make the Monkey create a new user, and try to create an HTTPS request as that new user.

ToC

More context

👩‍🔧 If you’re here for the technical stuff only you can skip this section.

The Infection Monkey is getting a big upgrade right now (Jan 2020) relating to the Zero Trust territory. One of Zero Trust’s pillars is People (i.e. User Identity) - meaning how User Identity is secured in your network. The test we wanted to implement in the Monkey to check out that pillar was supposed to imitate the following Attack scenario:

  1. Attacker gets into the machine.
  2. Attacker creates a new local user.
  3. Attacker does actions as that new user, including communicating with the internet.

This test checks how much a network adheres to the People pillar of Zero Trust, since if you’re enforcing that part of you network security correctly - a totally new, unknown user SHOULDN’T be able to access the internet at all.

How I wanted to do it

I already had a POC of the following flow working and in the Repo:

  1. Create a new user with the net user command.
  2. Log on as the new user (see on Github):
import win32security
import win32con
try:
    # Logon as new user: https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-logonusera
    self.logon_handle = win32security.LogonUser(
        self.username,
        ".",  # Use current domain.
        self.password,
        win32con.LOGON32_LOGON_INTERACTIVE,  # Logon type - interactive (normal user). Need this to open ping
        # using a shell.
        win32con.LOGON32_PROVIDER_DEFAULT)  # Which logon provider to use - whatever Windows offers.
except Exception as err:
    raise NewUserError("Can't logon as {}. Error: {}".format(self.username, str(err)))
  1. Run PING.exe as that user using CreateProcessAsUser, with self.logon_handle (see on GitHub):
def run_as(self, command):
    # Importing these only on windows, as they won't exist on linux.
    import win32con
    import win32process
    import win32api
    import win32event
    exit_code = -1
    process_handle = None
    thread_handle = None
    try:
        # Open process as that user:
        # https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessasusera
        process_handle, thread_handle, _, _ = win32process.CreateProcessAsUser(
            self.get_logon_handle(),  # A handle to the primary token that represents a user.
            None,  # The name of the module to be executed.
            command,  # The command line to be executed.
            None,  # Process attributes
            None,  # Thread attributes
            True,  # Should inherit handles
            win32con.NORMAL_PRIORITY_CLASS,  # The priority class and the creation of the process.
            None,  # An environment block for the new process. If this parameter is NULL, the new process
            # uses the environment of the calling process.
            None,  # CWD. If this parameter is NULL, the new process will have the same current drive and
            # directory as the calling process.
            win32process.STARTUPINFO()  # STARTUPINFO structure.
            # https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/ns-processthreadsapi-startupinfoa
        )
        logger.debug(
            "Waiting for process to finish. Timeout: {}ms".format(WAIT_TIMEOUT_IN_MILLISECONDS))
        # Ignoring return code, as we'll use `GetExitCode` to determine the state of the process later.
        _ = win32event.WaitForSingleObject(  # Waits until the specified object is signaled, or time-out.
            process_handle,  # Ping process handle
            WAIT_TIMEOUT_IN_MILLISECONDS  # Timeout in milliseconds
        )
        exit_code = win32process.GetExitCodeProcess(process_handle)
    finally:
        try:
            if process_handle is not None:
                win32api.CloseHandle(process_handle)
            if thread_handle is not None:
                win32api.CloseHandle(thread_handle)
        except Exception as err:
            logger.error("Close handle error: " + str(err))
    return exit_code

One Googling session later to look for the proper way to send HTTP requests in Windows; and I find that to do that, since we don’t have curl, I’ll need to use powershell. All I wanted to do was change PING.exe to powershell.exe -command "Invoke-WebRequest https://infectionmonkey.com/". Sounds simple, doesn’t it?

WhatsTheWorstThatCouldHappen

The problems

The first problem: The application failed to initialize properly (0xc0000124)

Like any good developer, I went looking for this error code. I also improved my logging of the error code. The problem seemed to be a DLL init problem.

STATUS_DLL_INIT_FAILED

What does that mean? I used some procmon to try to figure out what the problem was, but that proved not super useful. My hunch was that since powershell is a modern app, it has more dependencies and requirements that ping simply doesn’t, so after a lot of time wasted on trying to pinpoint the issue, I decided to go with a “try until it works” approach.

Based on this StackOverflow thread, I realized I should use CreateProcessWithLogonW instead of CreateProcessAsUser. CreateProcessWithLogonW managed to create an environment where Powershell.exe could run, whereas LogonUser + CreateProcessAsUser didn’t.

This is because CreateProcessWithLogonW uses an RPC call to ncalrpc:[SECLOGON] in svchost (SeclCreateProcessWithLogonW from seclogon.dll called). Internally it does CreateProcessAsUser eventually, but it logs on in a different way and ALSO creates a new session + window. This was enough for powershell to run; the actual issue of the environment that powershell needed to run was solved.

This was VERY hard and cryptic to figure out, and took the better part of a day. I was very happy to be done with it; then I got another Error code. 0x1. Hey, at least it’s a normal number this time 😑

The second problem: The response content cannot be parsed because the Internet Explorer engine is not available, or Internet Explorer's first-launch configuration is not complete.

The second bug was with the actual command itself. After creating a new user, Invoke-WebRequest won’t work. How did I figure this out? I created a debug breakpoint and manually opened a powershell as the new user, just after it was created.

We tried running echo hello - it worked. So we tried running Invoke-WebRequest and it failed:

powershell error code

This is because Invoke-WebRequest internally uses Internet Explorer’s engine to parse the response. Since the user is totally new, “Internet Explorer’s first-launch configuration is not complete” and therefore the Internet Explorer engine is not available.

ugh

The fix for that was to add the -UseBasicParsing flag. At first, I thought it was deprecated, but I looked at the wrong documentation since I was testing with an old powershell version. In the newer powershell versions this shouldn’t be an issue at all since PS6 doesn’t rely on Windows.

The third problem: Unsupported TLS version

After I was done testing the feature on my machine, I moved to testing the feature on a specific Windows server; where, to no one’s surprise, the feature didn’t work correctly. This happened since I was testing with https://infectionmonkey.com which ONLY supported TLS 1.2, but the powershell commandlet I was using used TLS 1.0 by default.

To overcome this I manually decided which security protocol powershell was going to use, by adding the [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 command to the powershell script. That set the TLS version of the request and fixed the issue. See the pull request.

This problem also brought to my attention the fact that https://infectionmonkey.com doesn’t support old TLS versions - but we decided that’s OK 🔐😀

Conclusions

  • Windows will never cease to surprise me, but not all surprises are good.
  • If you’re using Windows, try to use the highest-level API you can use. Usually that gives the best results.
  • Talking to people is still the #1 method of debugging. 100% of the fixes in this process were inspired by my coworkers.
  • I can fix anything given enough time. So can you 🗻