This blog describes how a logic flaw in ntfs-3g SUID binary help function was used to expand the attack surface, work through the heap and escalate privileges in the end. Patches and advisory [NTFS3G-SA-2022-0002] were released by Tuxera Inc. while [UNPAR-2022-0] contains additional information on only those vulnerabilites from above advisory reported by Unparalleled IT Services and discussed here.
While performing forensic analysis on an NTFS file system image, I noticed that the ntfs-3g tool for mounting the image in userspace had the SUID bit set. This finding was quite unexpected as many other user space file system implementations make use of the fusermount SUID binary instead. Apart from that the ntfs-3g process changed the EUID from 0 to the one of the caller while processing the NTFS image, but kept UID 0 as the saved UID, thus it was still able to regain root privileges later on:
$ cat /proc/410/stat ... Uid: 1001 1001 0 1001 Gid: 100 100 100 100
That meant that there were at least two very good reasons to look more closely. First the mount process of user space file systems itself is a really critical step. Any flaws or races regarding the location or permission checks of the mount point of the file system may allow mounting the image to unintended locations, e.g. overmounting /lib. Usually this causes DoS conditions or privilege escalation. The fusermount SUID binary was therefore especially hardened against such attacks after having suffered from severe vulnerabilities more than once. As ntfs-3g did not use that tool, flaws in the privileged mount code of ntfs-3g due to code duplication or rewrite were likely.
Secondly ntfs-3g retained root privileges by keeping the saved user ID 0 while processing the NTFS image. Therefore it had a huge attack surface compared to fusermount as ntfs-3g has also to get both the full complexity of NTFS file system code and the FUSE kernel communication code right. fusermount instead has to excel only at the mount operation code security.
So let the hunt begin.
As probably lowest hanging fruit, the mount code of ntfs-3g was checked using strace. With this method it is easy to detect any insecure file system operations, usually races (e.g. stat+open instead of fstatat+openat+fstat) or or missing permission checks at all (no stat/access at all or not handing special directories permissions like in /tmp or /var/mail). But tracing of program ntfs-3g execution with various command line settings did not show any relevant flaws.
While experimenting with strace and command line options, the mount options help text triggered my curiosity:
$ /bin/ntfs-3g -o --help,no_detach image dir-dont-care ... short read on fuse device
The strace output quickly confirmed, that ntfs-3g attempted to read FUSE protocol data from stdin instead from a file descriptor connected to /dev/fuse. When executing ntfs-3g the caller has full control of the stdin file descriptor and subsequently controls which data is provided to the privileged process via FUSE protocol communication. Thus also implies, that any security flaw in handling of the usually trusted FUSE requests by the kernel will now be a good target for privilege escalation attacks. This flaw was assigned [CVE-2022-30783].
To find a suitable vulnerability, the list of FUSE requests was screened (see e.g. [fuse(4) man page]). Usually code review for integer overflows in buffer access using both offset and size are quite promising, especially when the programmer did not consider such overflows at all or assumed, that both values are safe, cannot be user controlled and are therefore trusted. According to man pages FUSE_OPEN and FUSE_OPENDIR requests meet those requirements. Assuming that FUSE_OPENDIR is the more complex and hence more attackable request, the handler function was reviewed first. And really, the handler function fuse_lib_readdir (see [libfuse-lite/fuse.c] line 2205) obviously got the offset handling wrong, negative offsets will access data before the start of the heap based content buffer. This flaw was assigned [CVE-2022-30787].
static void fuse_lib_readdir(fuse_req_t req, fuse_ino_t ino, size_t size, off_t off, struct fuse_file_info *llfi) ... if (!dh->filled) { int err = readdir_fill(f, req, ino, size, off, dh, &fi); if (err) { reply_err(req, err); goto out; } } if (dh->filled) { if (off < dh->len) { if (off + size > dh->len) size = dh->len - off; } else size = 0; } else { size = dh->len; off = 0; } ... fuse_reply_buf(req, dh->contents + off, size); ...
So the next step was to create a tool to generate FUSE requests and send them to ntfs-3g to extract the heap data. This will be needed for escalation later on anyway as due to ASLR the memory layout has to be deducted. To get to the point to extract memory the FUSE_INIT, FUSE_LOOKUP, FUSE_OPENDIR and FUSE_READDIR were implemented. FUSE_READDIR then returned up to 64kb of heap memory due to response size limits. This was by far sufficient as the heap was growing in a predictable size, thus the 64kb covered all data from the beginning of the heap up to the start of the dh->contents buffer. With that data the addresses of all relevant heap structures but also many function pointers to the ntfs-3g binary were known.
As ntfs-3g memory returned by fuse_lib_readdir is not copied but sent using writev, no SEGV is triggered for unmapped addresses. ntfs-3g will see an EFAULT error, which is even reported but otherwise ignored:
fuse: writing device: Bad address
Yet just reading memory does not allow escalation. But the directory handle returned by FUSE_OPENDIR was quite interesting as it was identical the memory address of the struct fuse_dh. This handle was then used in FUSE_READDIR to return the data. So by placing a valid struct fuse_dh on the heap and referencing it in FUSE_READDIR using a bogus directory handle (one not acquired via FUSE_OPENDIR), this will allow reading of arbitrary memory locations as dh->contents can be controlled. Moreover when dh->filled (see code above) is 0, then the function readdir_fill will be called and in the end write data into the contents buffer. This flaw was assigned [CVE-2022-30787].
Following the code flow from the readdir_fill function showed, that via fuse_fs_readdir, fs->op.readdir, fill_dir (all from [libfuse-lite/fuse.c]), fuse_add_direntry and fuse_add_dirent (see [libfuse-lite/fuse_lowlevel.c]) data is copied to the buffer:
char *fuse_add_dirent(char *buf, const char *name, const struct stat *stbuf, off_t off) { unsigned namelen = strlen(name); unsigned entlen = FUSE_NAME_OFFSET + namelen; unsigned entsize = fuse_dirent_size(namelen); unsigned padlen = entsize - entlen; struct fuse_dirent *dirent = (struct fuse_dirent *) buf; dirent->ino = stbuf->st_ino; dirent->off = off; dirent->namelen = namelen; dirent->type = (stbuf->st_mode & 0170000) >> 12; strncpy(dirent->name, name, namelen); if (padlen) memset(buf + entlen, 0, padlen); return buf + entsize; }
Obviously fuse_add_dirent overwrites the attacker supplied buffer with directory listing data extracted from the attacker provided NTFS image. It was tempting to continue with exploitation using the filename const char *name to provide the source data to be copied but that would make it impossible to copy binary data containing NULL bytes. Therefore another approach was choosen.
The struct fuse_dirent from the code above contains the inode number ino at offset 0. Thus creating crafted directories and files, so that there is for each byte value (0-255) at least a single file with an NTFS inode number starting with that byte, will allow to write exactly that byte plus some short tail to an arbitrary location. Thus by listing the appropriate directory entries one by one this will copy attacker controlled bytes to the target addresses, hence providing a full write-what-where primitive (see writeMemory function in the PoC).
Last but not least a what/where had to be selected. The heap contains the struct fuse_fs with all the FUSE operation function pointers in struct fuse_operations op. Therefore overwriting such address gives control over the function pointer. The mknod function pointer was deemed an especially good target, as the function was called with a pointer to the attacker supplied node name string. Calling system or execve directly to gain a shell would not work for escalation, as ntfs-3g had the valuable UID 0 only in the saved UID, current UID and EUID are the one of the calling user. Therefore setresuid has to be called before executing the shell. Therefore the easiest way is to use the linker to load the code via dlopen. Luckily the ntfs-3g contained such a call in the select_reparse_plugin function.
Putting it all together, the complete help-to-heap [PoC] exploit creates an NTFS image with crafted inode numbers, compiles a shared library with shell code at /tmp/s.so, reads the heap to extract all relevant memory addresses, massages the heap to allow reliable heap spraying, overwrites the mknod FUSE operation function pointer and invokes mknod tmp/s.so to run the library.
$ ./help-to-heap ... fuse: writing device: Bad address * returning 0xe9b0 bytes Maybe struct match dir 0x564978eb0b10 with content 0x564978eb1910 test 0x564978eb0b10 Assuming heap start at 0x564978ea3000 with 0xe9b0 bytes data extracted Got fuse_fs address 0x564978eb0030. Got mknod op: 0x564976faecb0 Type shell commands: id uid=0(root) gid=100(users) groups=100(users) ...
There might be some take aways from exploiting these vulnerabilities.
In SUID (privileged) context everything is security critical: Even such basic function as --help just writing to stdout/stderr has to implemented carefully to ensure that there are no unexpected side effects on the whole program.
Be observing: There are no special tools needed nor specific exploitation strategies. Observing unexpected program behaviour, e.g. weird error messages for --help or directory handle number patterns looking like user space addresses, leads you the way from one step to the next.
References:
Comments are welcome, but there is no forum system im place yet. If there is something important to be added to this page, please send it as e-mail. Legal: Appropriate comments will be published, there is no right for you to get them published, use a "Nick:" entry in your comment otherwise for attribution "Anonymous" is used, comment mails are deleted after processing (GDPR), IPR rights for your comment stay with you except that the content may be used to correct or improve the page while referencing to your comment as source of the change, comment data is not submitted to third parties. Phuuu, inhale!