This is a write-up for the task 0av
from zer0pts CTF 2022.
The problem provides a connection to a VM instance. There, you can find the flag file flag.txt
under directory /playground
. The flag file has all the permissions that you need, so you can just cat flag.txt
to get the flag.
Of course not. In the background, there is an antivirus process called 0av
running in root permissions, inspecting any file open attempts.
int main(void) {
/* blahblah */
fd = fanotify_init(FAN_CLOEXEC | FAN_CLASS_CONTENT | FAN_NONBLOCK, O_RDONLY);
if (fd == -1) {
perror("fanotify_init");
exit(EXIT_FAILURE);
}
if (fanotify_mark(fd,
FAN_MARK_ADD | FAN_MARK_MOUNT,
FAN_OPEN_PERM, AT_FDCWD, "/") == -1) {
perror("fanotify_mark");
exit(EXIT_FAILURE);
}
/* blahblah */
}
As you can see, every files under /
is inspected, with the new fanotify
. It is running with FAN_OPEN_PERM
mode, which suspends any file open accesses until this fanotify
grants the access to it. The filter itself is very simple yet effective.
static int scanfile(int fd) {
char path[PATH_MAX];
ssize_t path_len;
char procfd_path[PATH_MAX];
char buf[0x10];
if (read(fd, buf, 7) != 7)
return 0;
if (memcmp(buf, "zer0pts", 7))
return 0;
/* Malware detected! */
snprintf(procfd_path, sizeof(procfd_path), "/proc/self/fd/%d", fd);
if ((path_len = readlink(procfd_path, path, sizeof(path) - 1)) == -1) {
perror("readlink");
exit(EXIT_FAILURE);
}
path[path_len] = '\0';
unlink(path);
return 1;
}
As you can see, it just reads 7 bytes ahead from the opened file, and compares it with "zer0pts"
. Of course, all flags from zer0pts CTF start with that 7 bytes. If it matches, it locates the original file, unlinks it, and rejects the open.
According to man 7 fanotify
, we can see that somehow closing the fanotify
file descriptor will grant a flag. Because there are no explicit close()
in the antivirus source code, we need to kill the process in order to close them.
Closing the fanotify file descriptor When all file descriptors referring to the fanotify notification group are closed, the fanotify group is released and its resources are freed for reuse by the kernel. Upon close(2), outstanding permission events will be set to allowed.
Then, we need to look at the source again.
if ((path_len = readlink(procfd_path, path, sizeof(path) - 1)) == -1) {
perror("readlink");
exit(EXIT_FAILURE);
}
When the readlink()
fails, the antivirus exits and never restarts again. Thus, if we can somehow make readlink()
return -1 once, we can just cat
the flag. According to man 2 readlink
, these are the possible conditions which makes readlink()
return -1.
EACCES Search permission is denied for a component of the path prefix. (See also path_resolution(7).)
EFAULT buf extends outside the process's allocated address space.
EINVAL bufsiz is not positive.
EINVAL The named file (i.e., the final filename component of pathname) is not a symbolic link.
EIO An I/O error occurred while reading from the filesystem.
ELOOP Too many symbolic links were encountered in translating the pathname.
ENAMETOOLONG A pathname, or a component of a pathname, was too long.
ENOENT The named file does not exist.
ENOMEM Insufficient kernel memory was available.
ENOTDIR A component of the path prefix is not a directory.
Among these options, ENAMETOOLONG
looked like the most viable one. If we can make a directory path long enough(about PATH_MAX == 2^12), it will make readlink()
return -1 and die. Therefore, I just made a bunch of directories with silly long names, and moved the flag in it. Note that moving the file from one place to another is both a different system call and a fanotify
event. After then, just reading the flag make readlink()
fail and return -1, killing the antivirus process, and granting access permissions to all pending accesses.
Please refer to the attached file for the actual solution code.