The ELFs that produce the different gifts are well on track with their duties, but all that wood turning, gluing, cropping, and sewing makes for a very hungry crowd of exhausted ELFs. For these ELFs, the Christmas village has a large but very good ELF canteen, where all kinds of treats are available. One of the most wanted goodies are the dumplings with sweet sauce. But, for today, the kitchen was a little bit slow and the hungry crowd is demanding their dumplings and are eager to get their fingers on that delicious sauce. While there are very solid walls around the kitchen, there are a few loopholes that the ELF crowd wants to exploit. However, only a finger can fit into those loopholes and you have to find the sauce pot blindly. Can you help them?
process_vm_{readv,writev}
With ptrace(2), we already know a very powerful tool to manipulate and control the run time state of another process. We can stop processes, poke around their registers, and access the remote process' memory. However, the access path is more like the eye of an needle, as we have to PTRACE_PEEKUSER
and PTRACE_POKEUSER
individual words (e.g. 64 bits) from/into the other process' address space. So, reading the entire memory would take an enormous amount of system calls.
For debugging purposes, this is no real limitation, but for some applications, ptrace was no option and a better interface was needed. One such application is CRIU (Checkpoint-Restore-In-Userspace), which allows the user to snapshot a running application into a collection of files and restore the process later on as if the process was only paused. It is even possible to transfer the snapshot to another machine and restart it there. CRIU is a very useful tool for containerized environments, where your "microservice" boils down to a Linux process that runs in its own VM/FS/network/user namespace. To get it's hands on the run-time state of the process that should be checkpointed, CRIU uses the process_vm_readv(2) system call.
ssize_t process_vm_readv(pid_t pid,
const struct iovec *local_iov, unsigned long liovcnt,
const struct iovec *remote_iov, unsigned long riovcnt,
unsigned long flags);
The system call, which has also a writing pendant with process_vm_writev(2), has a straight forward interface. It takes the pid
of the remote process and two scattered buffers that describe areas in the remote address space (remote_iov, riovcnt
) and in the local address space (local_iov
, liovcnt
). When invoked, the system call then copies the contents of the remote area into the local area. For example:
char buf[0x400];
struct iovec local[1] = {
{.iov_base = buf, .iov_len = sizeof(buf)}
};
struct iovec remote[2] = {
{.iov_base = 0x1000, .iov_len = 0x100},
{.iov_base = 0x4000, .iov_len = 0x300},
}
process_vm_readv(1234, local, 1, remote, 2, 0);
With this piece of code, you read 0x400
bytes of memory into a buffer in your process. The source is the address space of process with the pid 1234
and we copy the bytes: [0x1000, 0x10ff]
and [0x4000, 0x42ff]
. As you already see from the example, it is rather easy to come up with the pointers for local_iov
as they are in your own process. The pointers within the remote process are rather hard to guess though. For this, one can use ptrace()
or information from the proc file system. For example, in /proc/1234/maps
, we see all memory mappings of our remote process:
$ cat /proc/1234/maps
555b377ea000-555b37857000 r--p 00000000 103:04 16254201 /usr/bin/python3.10
555b37857000-555b37afa000 r-xp 0006d000 103:04 16254201 /usr/bin/python3.10
555b37afa000-555b37d3a000 r--p 00310000 103:04 16254201 /usr/bin/python3.10
555b37d3a000-555b37d40000 r--p 00550000 103:04 16254201 /usr/bin/python3.10
555b37d40000-555b37d80000 rw-p 00556000 103:04 16254201 /usr/bin/python3.10
555b37d80000-555b37dc6000 rw-p 00000000 00:00 0
[...]
7fffd57c3000-7fffd57e5000 rw-p 00000000 00:00 0 [stack]
7fffd57f6000-7fffd57fa000 r--p 00000000 00:00 0 [vvar]
7fffd57fa000-7fffd57fc000 r-xp 00000000 00:00 0 [vdso]
What we see here, are memory areas (first range) that are mapped (see A Map to Persistance!) in the python process. We see the memory protection bits (e.g. rw-p
), the size of the mapping (e.g. 00310000
), the device id of the hard disk (e.g 103:04
) where the file resides, the inode number of the file (e.g., 16254201
), and the name of the file (e.g., /usr/bin/python3.10
). So the first few lines of this listing, show those memory areas where the Python program was mapped into this Python process.
Today, we want to toy around with process_vm_readv
and process_vm_writev
to peek and poke around in the memory of a running python3 process. We chose the CPython interpreter for this exercise as it is rather easy to get your hands onto the pointer of some object. In CPython3, which is probably the variant of Python that is installed on you computer, the id()
function returns a rather useful value ($ pydoc3 id
):
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.)
So, when we use id()
on an object, we get the address of that object in memory. So it is today's task, to use that information to get the memory of some object from a Python process and decode them. Furthermore, with process_vm_writev
you can also manipulate those objects. For example, change the value of a floating point object. For your convenience, we already bring you a small test.py
script:
$ python3 test.py
pointers ['0x7f75917fdad0', '0x7f75917fe6f0', '0x7f75917fe6f0']
objects ['0xdeadbeef', 23.0, 23.0]
>> PyObject @ 0x7f75917fdad0: refcount=3, type=int
>> PyLongObject: ob_digit[0] = 0x1eadbeef
>> PyObject @ 0x7f75917fe6f0: refcount=4, type=float
>> PyFloatObject: ob_fval=23.000000
object ['0xdeadabba', 529.0, 529.0]
In this output, the >>
-prefixed lines are from the remote process, while the python process printed those other three lines. In the output we also see that we quadrupled the value of D
(23 -> 529). You might ask yourself, why the value of D2
also changed here. But answering that question is up to you. (Hint: Look at the pointers).
You will need the python3 development files to get your hands onto the Python header files. On Debian, they are in the python3-dev
package. With pkg-config --cflags python3
, you can test whether you have the headers correctly installed. On my machine, this results in:
$ pkg-config --cflags python3
-I/usr/include/python3.10 -I/usr/include/x86_64-linux-gnu/python3.10
As you have to interpret untyped memory, you will have to use the python3-dev headers to get the C types that interpret that memory. Useful C types for you today are PyObject
(any python object), PyTypeObject
(objects that represent types), PyFloatObject
(a floating point number), and PyLongObject
(an integer number). In a nutshell, you will just cast the untyped memory from peek()
to those types and use them:
PyObject *obj = peek(pid, ptr, sizeof(PyObject));
printf(" PyObject @ %p: refcount=%ld\n", ptr, Py_REFCNT(obj));
Have a look at the header files in /usr/include/python3.10/
. A good starting point are the declarations of PyObject
and PyVarObject
in object.h
(/usr/include/python3.10/object.h
).
Last modified: 2023-12-01 15:52:27.583349, Last author: , Permalink: /p/advent-20-vmreadv
Technische Universität Braunschweig
Universitätsplatz 2
38106 Braunschweig
Postfach: 38092 Braunschweig
Telefon: +49 (0) 531 391-0