This is the first challenge out of three in the fullchain challenge series. We
are given a QEMU VM, into which we can log in using the ctf
user, but instead
of a shell we get a memo app. The goal is to get a shell and read the first
flag.
The author has provided the source code for the memo app and for the subset of the driver that the app talks so.
The app opens the device and mmap()
s the first page. It splits the page into
16 256-byte parts. The first part contains the pointers to the other 15 parts.
The user can read and write into the other parts.
The user can also read and write into the device by specifying an offset and a size. The first page is excluded from this with the following check:
if((offset = getint() + 0x1000) < 0x1000 || lseek(fd, offset, SEEK_SET) < 0){
puts("Out of range");
If we are somehow able to circumvent this and access the first page anyway, we get the arbitrary read/write capability.
The maximum amount of data that the device can hold is MEMOPAGE_SIZE_MAX
,
which is 1G. If we start the access from the last byte, the offset will wrap
around and start referencing the first page.
The module stores the data with page granularity. The functions get_memo_ro()
and get_memo_rw()
return the pointer to the page associated with a given
offset. chrdev_read()
and chrdev_write()
loop over pages and perform the
respective operation. Unlike chrdev_seek()
, they don't have any bounds
checks, so it's only natural to wonder what would happen if the loop starts in
bounds, but goes out of bounds.
Unfortunately one cannot see this in the provided char.c
file. One can,
however, either reverse the module, or get the memo.c
file from the next
kmemo challenge.
Ultimately the problem is that __pgoff_to_memopage()
looks only at the lower
30 bits of the offset and ignores the others, so MEMOPAGE_SIZE_MAX
is treated
the same way as 0.
There are many ways to leverage the arbitrary read-write capability for getting code execution. The leaked part addresses point to the mmap area, which also contains libc. We don't know where the binary, the heap and the stack are.
I chose faking an atexit()
descriptor. libc's __exit_funcs
variable points
to a struct exit_function_list
:
struct exit_function_list
{
struct exit_function_list *next;
size_t idx;
struct exit_function fns[32];
};
For our purposes next
needs to be NULL
and idx
needs to be 1 (the number
of elements in the following array). struct exit_function
is defined as
follows:
struct exit_function
{
/* `flavour' should be of type of the `enum' above but since we need
this element in an atomic operation we have to use `long int'. */
long int flavor;
union
{
void (*at) (void);
struct
{
void (*fn) (int status, void *arg);
void *arg;
} on;
struct
{
void (*fn) (void *arg, int status);
void *arg;
void *dso_handle;
} cxa;
} func;
};
There are 3 different function pointer types that one can call, distinguished
by the flavor
value: without arguments (ef_at == 3
), with int, void *
arguments (ef_on == 2
) and with void *, int
arguments (ef_cxa == 4
).
The easiest would be to use any of them with one_gadget. Unfortunately we have
no means to satisfy the one_gadget constraints in this challenge. An
alternative is to use ef_cxa
with system()
and "/bin/sh"
.
Using arbitrary write, one can craft an exit_function_list
instance in libc's
.bss
and overwrite __exit_funcs
with a pointer to it.
We don't interface with the memo application's stdin/stdout directly. Between
us sits the TTY layer, which does ugly things to the data we send. As long as
we deal with printable characters, everything is fine and dandy. But we need to
send binary data in order to fake the part pointers and the atexit()
descriptor. And some of that data may be treated as terminal control
characters.
man 3 termios
is our
best friend here. From that we learn that 0x7f
aka 0177
is a control
character called VERASE
, which is essentially a backspace. This one is very
important, because it occurs in all the mmap()
ped addresses. This handicaps
us severely: instead of directly crafting what we need, we need to do partial
overwrites on data that already contains 0x7f
. For example, the first part
pointer already has it. Therefore, it's sufficient to overwrite only its lowest
5 bytes.
Here comes the next problem: it appears that any data we send must end with a
newline, or the TTY layer won't forward it to the app. Of course, we could try
sending a lot and exhaust some internal buffer, which would force a flush, but
we need to send exactly 5 bytes. Fortunately for us, the manual points to the
VEOF
control character, aka 0x4
, aka Ctrl-D
, which forces a flush. The
downside is, of course, that we cannot use 0x4
in our payloads as well.
How many more characters are there that we can't use? I counted these:
BAD_CHARS = b"\x03\x04\x0a\x11\x13\x7f"
This means we have to restart the app until ASLR gives us an address without
any of them. An easy fix, by the way, would be to use the VLNEXT
aka 0x16
aka 026
control character, which escapes the next character after it.
Unfortunately it's recognized only when IEXTEN
is set, and it's not. This can
be confirmed by getting the shell in the local setup and running:
$ stty -a
[...]
isig icanon -iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke -flusho -extproc
Back to 0x4
, it's ef_cxa
, which we need for our exploitation. So we need to
find an existing writable memory region, which contains:
04 .. .. .. .. .. .. ..
.. .. .. .. .. .. .. ..
.. .. .. .. .. 7f .. ..
and fill the blanks with the struct exit_function_list
contents. I ended up
eyeballing the .data
and .bss
sections and found such a location relatively
quickly.
Finally, the initial __exit_funcs
value is &initial
, which is inside libc,
so a partial overwrite works for it as well.
The challenge runs in a VM with quite a primitive userspace. The debugger is not available and building one is a hassle. How to debug crashes and eyeball section contents?
I used the initramfs-wrap script, which adds a Debian root that contains GDB (and anything else one can wish for) and moves the original contents into a chroot. It allowed me to run the challenge in one tmux tab and GDB in the other. It was also possible to run GDB on the host and connect it to the gdbserver in the guest, since the guest kernel was compiled with network support.
SECCON{k3rn3l_bug5_5p1ll_0v3r_1n70_u53r_pr0gr4m5}
The code uses nested C functions (a GNU extension), which makes the stack
executable. One can find the location of the stack by looking at the environ
variable, which is located inside libc. This opens up an alternative
exploitation direction: write the shellcode to the stack.