La séparation de privilèges en C

Vincent Bernat

Note

Cet article a été publié dans GNU/Linux Magazine n° 119 en 2009. Il est reproduit ici avec de très légères retouches cosmétiques.

La séparation de privilèges1 est une technique popularisée par OpenSSH et utilisée notamment dans le projet OpenBSD permettant de séparer un programme en deux parties communiquant entre elles. Une partie s’occupe des opérations nécessitant des privilèges particuliers (ouvrir une socket réseau, ouvrir un fichier). L’autre partie va tourner sans aucun privilège particulier dans un chroot et devra effectuer la plupart des opérations nécessaires à la bonne marche du programme.Cette technique doit permettre de minimiser le nombre de lignes de code requérant des privilèges et donc le nombre de lignes à auditer attentivement. Ajouter la séparation de privilèges à un programme n’est pas forcément très compliqué. Nous allons voir comment créer un petit sniffer l’exploitant.

Un tcpdump « light »#

Nous allons écrire à titre d’exemple un clone léger de tcpdump. Il prend en paramètre l’interface sur laquelle écouter. Il va afficher sur la sortie standard les paquets qu’il reçoit (IP source, IP destination, protocole et éventuellement les ports source et destination).

Nous allons de plus ajouter une fonctionnalité dont le seul but sera de mieux illustrer notre article : régulièrement, notre sniffer va ajouter en fin d’un fichier de log quelques statistiques.

Le code#

Voici une première monture de notre sniffer. Cette version doit être lancée en tant qu’utilisateur root afin de fonctionner.

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <net/if.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <netinet/udp.h>
#include <netinet/tcp.h>
#include <linux/if_ether.h>
#include <linux/if_packet.h>
#include <linux/filter.h>
#include <time.h>

#define MFS 1514

#define LOG "sniffer.log"

int
main(int argc, char **argv)
{
    int s;   /* Raw socket */
    char ifname[IFNAMSIZ]; /* Interface name */
    struct sockaddr_ll sa; /* Bind options */
    char packet[MFS + 1];  /* Received packet */
    int n;   /* Received bytes */
    int nb = 0;   /* Received packets since start */
    struct iphdr ip;
    struct udphdr udp;
    struct tcphdr tcp;
    FILE *log;

    /* BPF filter:
       tcpdump -ni eth0 -s0 -dd ether dst ff:ff:ff:ff:ff:ff */
    struct sock_filter filter[] = {
        { 0x20, 0, 0, 0x00000002 },
        { 0x15, 0, 3, 0xffffffff },
        { 0x28, 0, 0, 0x00000000 },
        { 0x15, 0, 1, 0x0000ffff },
        { 0x6, 0, 0, 0x00000000 },
        { 0x6, 0, 0, 0x0000ffff },
    };

    struct sock_fprog prog = {
        .filter = filter,
        .len = 6
    };

    if (argc != 2) {
        fprintf(stderr, "Usage: %s interfaceface\n", argv[0]);
        exit(1);
    }

    /* Open the raw socket, ❶ */
    if ((s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
        fprintf(stderr, "unable to open raw socket (%m)\n");
        exit(1);
    }

    /* Bind it to the interface we want to listen to */
    memset(&sa, 0, sizeof(sa));
    sa.sll_family = AF_PACKET;
    sa.sll_protocol = 0;
    strncpy(ifname, argv[1], IFNAMSIZ);
    ifname[IFNAMSIZ-1] = '\0';

    if ((sa.sll_ifindex = if_nametoindex(ifname)) == 0) {
        fprintf(stderr, "unknown interface %s\n", ifname);
        exit(1);
    }

    if (bind(s, (struct sockaddr*)&sa, sizeof(sa)) < 0) {
        fprintf(stderr, "unable to listen to %s (%m)\n", ifname);
        exit(1);
    }

    /* Setup a filter, ❷ */
    if (setsockopt(s, SOL_SOCKET, SO_ATTACH_FILTER,
                   &prog, sizeof(prog)) < 0) {
        fprintf(stderr, "unable to set filter (%m)\n");
        exit(1);
    }

    while (1) {
        /* Receive packet */
        if ((n = recvfrom(s, packet, MFS, 0, NULL, NULL)) < 0) {
            fprintf(stderr, "error while receiving (%m)\n");
            exit(1);
        }

        if (n < 2*ETH_ALEN + 2 + sizeof(struct iphdr)) continue;

        memcpy(&ip, packet + 2*ETH_ALEN + 2, sizeof(struct iphdr));
        if (ip.version != 4)
            continue;

        /* Display some data, ❸ */
        printf("%s > ", inet_ntoa(*(struct in_addr *)&ip.saddr));
        printf("%s", inet_ntoa(*(struct in_addr *)&ip.daddr));

        switch (ip.protocol) {
        case IPPROTO_UDP:
            memcpy(&udp, packet + 2*ETH_ALEN + 2 + ip.ihl*4,
                   sizeof(struct udphdr));
            printf(" : UDP [port %d > port %d]",
                   ntohs(udp.source), ntohs(udp.dest));
            break;

        case IPPROTO_TCP:
            memcpy(&tcp, packet + 2*ETH_ALEN + 2 + ip.ihl*4,
                   sizeof(struct tcphdr));
            printf(" : TCP [port %d > port %d]",
                   ntohs(tcp.source), ntohs(tcp.dest));
            break;

        default:
            printf(" : protocol %d", ip.protocol);
            break;
        }

        printf("\n");

        /* Log to some file, ❹ */
        if (nb++ > 20) {
            if ((log = fopen(LOG, "a+")) == NULL) {
                fprintf(stderr, "unable to open %s (%m)\n", LOG);
                exit(1);
            }

            fprintf(log, "%s: %ld: %d packets received\n",
                    argv[0], time(NULL), nb);
            fclose(log);
            nb = 0;
        }
    }
}

Voyons pas à pas comment notre sniffer fonctionne :

En ❶, une socket de niveau 2 (AF_PACKET) en mode raw (SOCK_RAW) est d’abord ouverte. Ce type de socket permet de recevoir tous les paquets arrivant sur une interface réseau, sans altération.

Ensuite, nous associons notre socket à l’interface physique sur laquelle nous souhaitons récupérer les paquets.

En ❷, nous attachons ensuite un filtre de façon à ne remonter que certains paquets. Ce filtre n’a qu’une vertu pédagogique et se contente d’éviter de remonter les paquets broadcast. Il s’agit d’un filtre écrit en BPF. Le commentaire indique la commande tcpdump utilisée pour obtenir ce filtre.

En ❸, Pour chaque paquet reçu, on extrait quelques informations pertinentes pour les afficher à l’utilisateur.

Enfin, tous les 20 paquets, en ❹, on ouvre un fichier de logs pour indiquer que, à tel moment, nous avons reçu une vingtaine de paquets. Il s’agit également d’une fonctionnalité purement pédagogique qui va nous compliquer la tâche par la suite.

Pour compiler notre programme, nous utilisons la commande suivante :

cc -Wall -Werror -O0 -g -o sniffer1 sniffer1.c

En l’exécutant et en donnant le nom d’une interface comme premier argument, on obtient les informations attendues. On peut aussi vérifier que le fichier indiqué en tête du code est bien créé avec les informations de date et de nombre de paquets reçus.

10.0.2.2 -> 87.248.120.129 : protocol 1
87.248.120.129 -> 10.0.2.2 : protocol 1
10.0.2.2 -> 10.0.2.1 : UDP [port 49118 -> port 53]
10.0.2.1 -> 10.0.2.2 : UDP [port 53 -> port 49118]
10.0.2.2 -> 87.248.120.129 : protocol 1
87.248.120.129 -> 10.0.2.2 : protocol 1
10.0.2.2 -> 10.0.2.1 : UDP [port 58886 -> port 53]
10.0.2.1 -> 10.0.2.2 : UDP [port 53 -> port 58886]

Dans notre exemple, nous avons capturé le résultat de la commande ping.

Pourquoi ajouter la séparation de privilèges#

Imaginons que nous ajoutions encore un peu de code dans notre sniffer afin d’afficher plus d’informations, à la manière de tcpdump. Tout ce code tournerait avec les privilèges de root. Le passé a montré qu’il n’était pas rare de trouver, dans ce type d’applications, des vulnérabilités menant au mieux à un crash de l’application2 et au pire à l’exécution de code à distance.

Il serait alors intéressant de minimiser l’impact d’une telle vulnérabilité en n’exécutant que le strict minimum avec des privilèges élevés.

En effet, dans le code ci-dessus, très peu de choses nécessitent des privilèges :

Tous les autres appels peuvent fonctionner sans accéder au système de fichiers (donc par exemple dans un chroot vide) et sans utiliser de privilèges particuliers (comme ceux de root), y compris par exemple la mise en place d’un filtre.

Si nous étions capable d’exécuter le reste du code dans un chroot avec un utilisateur sans aucun privilège, un attaquant qui prendrait le contrôle de notre sniffer à l’aide d’un paquet spécialement prévu à cet effet ne pourrait pas faire grand-chose : il ne pourrait pas exécuter de shell, il ne pourrait pas lire de fichiers et il ne pourrait exécuter aucune fonction privilégiée. Même l’exploitation d’une faille noyau deviendrait ardue dans ce cas.

La séparation de privilèges du pauvre#

Il existe une manière très simple d’obtenir une séparation des privilèges : abandonner les droits root très tôt, par exemple juste après l’appel à bind().

On utilise le code suivant pour abandonner tout privilège. Ce code peut être placé après l’appel à bind() :

if ((user = getpwnam("nobody")) == NULL) {
    fprintf(stderr, "no user `nobody'\n");
    exit(1);
}
uid = user->pw_uid;

if ((group = getgrnam("nogroup")) == NULL) {
    fprintf(stderr, "no group `nogroup'\n");
    exit(1);
}
gid = group->gr_gid;

if (chroot("/var/empty") == -1) {
    fprintf(stderr, "unable to chroot (%m)\n");
    exit(1);
}

if (chdir("/") != 0) {
    fprintf(stderr, "unable to chdir (%m)\n");
    exit(1);
}

gidset[0] = gid;
if ((setresgid(gid, gid, gid) == -1) ||
    (setgroups(1, gidset) == -1) ||
    (setresuid(uid, uid, uid) == -1)) {
    fprintf(stderr, "unable to drop privileges (%m)\n");
    exit(1);
}

Voyons ce que fait exactement ce code et pourquoi chaque ligne est utile. Tout d’abord, il est important de tester le résultat de chaque appel. En effet, si on ne parvient pas à changer d’utilisateur, on reste root et il serait alors plus simple de sortir du chroot. De la même façon, si on ne parvient pas à mettre en place le chroot, on accède à des informations importantes. Il est donc vital de bien tester le résultat de chaque fonction !

On obtient d’abord l’UID de l’utilisateur nobody. Nous allons en effet faire tourner le reste du processus sous cet utilisateur. Il s’agit d’une solution de facilité pour ne pas avoir à créer un utilisateur spécifique, mais cela amoindrit la protection que nous voulons obtenir. En effet, cet utilisateur a quelques privilèges, notamment celui d’avoir éventuellement d’autres processus qui tournent sous son nom ou des fichiers qui lui appartiennent. Un attaquant pourrait alors, même dans le chroot, s’attacher à un autre processus du même utilisateur, processus qui pourrait ne pas être enfermé dans un chroot, et obtenir ainsi des droits supplémentaires. Il est donc important de créer un utilisateur propre à son application, par exemple _sniffer dans notre cas.

De la même façon, on récupère le GID du groupe nogroup. Encore une fois, dans la vraie vie, il faut créer un groupe propre à son application, par exemple _sniffer.

On s’enferme dans le répertoire /var/empty, qui comme son nom l’indique doit être vide. De plus, il doit appartenir à root et avec des droits adéquats de façon à ce qu’il ne soit pas possible d’y créer des fichiers. Se placer dans un chroot nécessite les droits root, ce qui explique pourquoi on effectue cette opération avant d’abandonner les droits roots.

On se place à la racine de notre chroot. En effet, sans cet appel à chdir(), notre application serait toujours implantée dans son répertoire de démarrage.

On perd ensuite les droits liés au groupe puis à l’utilisateur. En un seul appel, les fonctions setresgid() et setresuid() permettent de changer l’utilisateur ou le groupe réel, effectif et sauvegardé. En effet, quand un processus est root, il peut perdre temporairement ses privilèges, par exemple avec setuid(), mais aussi les regagner par la suite avec seteuid(). Il est donc important d’abandonner toute possibilité de retour en arrière. Dans le cas contraire, un attaquant saura en faire bon usage.

Pour exécuter cet exemple, n’oubliez pas de créer le répertoire /var/empty. Après compilation, le nouveau programme fonctionne comme l’ancien, mais ne parvient plus à écrire les statistiques dans le fichier prévu à cet effet. En effet, non seulement le fichier n’est pas accessible depuis le chroot, mais il appartient à root.

Une solution pour contourner cette difficulté serait d’ouvrir ce fichier alors qu’on est root. Il serait alors possible d’écrire dedans sans aucun droit particulier. Mais ce serait trop facile : on veut absolument ouvrir ce fichier à chaque fois que l’on veut écrire dedans car, par exemple, un logrotate peut passer dessus.

Nous sommes alors dans une situation classique où la simple perte de privilèges ne résout pas notre problème.

La séparation de privilèges#

Afin de pouvoir ponctuellement écrire dans un fichier, nous devons couper notre application en deux processus distincts. Un des processus tournera sous root et l’autre dans un chroot sans aucun privilège. Les deux processus vont communiquer via une paire de sockets.

Une première tentative#

Voici, en pseudo-code, comment il serait possible d’écrire notre sniffer en utilisant deux processus séparés.

int pair[2];

/* Get a pair of socket */
socketpair(AF_LOCAL, SOCK_DGRAM, PF_UNSPEC, pair);

/* Create monitored process */
switch (fork()) {
case 0:
    /* In the child, chroot and drop privileges */
    chroot("/var/empty");
    chdir("/");
    setresgid("nogroup");
    setgroups("nogroup");
    setresuid("nobody");
    close(pair[1]);
    break;

default:
    /* In the parent */
    close(pair[0]);
    while (1) {
        recv_child(cmd);
        switch (cmd.id) {
        case OPENSOCKET:
            s = socket();
            bind(s, cmd.arg);
            setsockopt(s, filter);
            break;

        case GETPACKET:
            n = recvfrom(s, packet);
            write_child(n, packet);
            break;

        case OPENLOGFILE:
            log = fopen("/var/log/sniffer.log");
            break;

        case WRITETOLOGFILE:
            fprintf(log, cmd.arg);
            break;

        case CLOSELOGFILE:
            fclose(log);
            break;

        }

        exit(0);
    }
}

/* At this point, we don't have any privilege */
cmd = {
    .id = OPENSOCKET,
    .arg = ifname
};
write_parent(cmd);

while (1) {
    cmd = {
        .id = GETPACKET,
        .arg = NULL
    };
    write_parent(cmd);

    n = recv_parent(packet, MFS);
    process_packet(packet, n);
    if (nb++ > 20) {
        cmd = {
            .id = OPENLOGFILE,
            .arg = NULL
        };
        write_parent(cmd);

        cmd = {
            .id = WRITETOLOGFILE,
            .arg = "..."
        };
        write_parent(cmd);

        cmd = {
            .id = CLOSELOGFILE,
            .arg = NULL
        };
        write_parent(cmd);
    }
}

En premier lieu, nous créons une paire de sockets que l’on va utiliser comme des pipes bidirectionnels : ce qu’on écrit sur une des sockets est reçu de l’autre côté et vice-versa. Nous utilisons des sockets Unix (AF_UNIX), anonymes (socketpair() ne sait pas faire autre chose) et orientées datagramme (SOCK_DGRAM). Cette dernière caractéristique est très intéressante, car contrairement aux sockets orientées flux, il y a conservation de la limite des messages (si on envoie 10 octets, on lit 10 octets de l’autre côté et non 7 puis 3) et, dans le cadre des sockets Unix, elles sont fiables et ne réordonnent pas les messages (ce qui n’est pas le cas pour des sockets sur IP, car cela correspond au protocole UDP). Les deux processus peuvent donc communiquer par ce moyen à l’aide d’un protocole prédéterminé de manière relativement simple.

Ensuite, dans le processus père, nous conservons les privilèges de root et nous attendons de recevoir les ordres du fils que nous exécutons. Ces ordres consistent en :

  • ouvrir la socket, l’associer à l’interface adéquate et placer le filtre ;
  • ouvrir le fichier de logs ;
  • écrire dans le fichier de logs ;
  • fermer le fichier de logs.

Un point important à noter est qu’il n’est pas possible depuis le fils de choisir le fichier de logs à ouvrir. Il est crucial de ne pas transformer le père en un simple proxy. Si le fils pouvait choisir le nom du fichiers de log, un attaquant pourrait par exemple demander à ouvrir /etc/passwd et se créer un compte ! Il est critique de considérer le fils comme hostile, car potentiellement contrôlé par l’attaquant, comme dans une application client/serveur !

Le père dispose cependant d’une arme redoutable que ne possède pas une application client/serveur classique : il peut tuer son fils (ainsi que lui-même au cas où le fils a changé de PID grâce à un appel à fork()) s’il considère qu’il ne se comporte pas correctement, mettant ainsi un terme à toute attaque. Certes, dans notre cas, le sniffer cessera alors de fonctionner, mais comme, de toute façon, il était contrôlé par un attaquant, il ne pouvait plus accomplir sa tâche.

Dans le fils, nous avons remplacé les appels nécessitant des privilèges particuliers à un envoi de message au processus parent afin d’exécuter les actions nécessaires. Toutefois, nous avons aussi déplacé certains appels non privilégiés comme la mise en place d’un filtre et la lecture d’un paquet : seul le parent a accès à la socket et le fils doit donc indiquer au père ce qu’il veut faire.

Dans notre exemple précédent, la mise en place d’un filtre et la lecture d’un paquet ne nécessitaient aucun privilège. Nous avons donc régressé sur ce point : du code supplémentaire est exécuté en root. Toutefois, nous pouvons de nouveau écrire notre fichier de logs, mais la quasi-totalité du code nécessaire est en fait exécuté par root.

L’un des intérêts de la séparation de privilèges est de réduire drastiquement le code tournant sous root, réduisant ainsi la quantité de code à auditer attentivement. Dans notre exemple, il y a manifestement trop de code qui tourne sous root (pour un exemple aussi simple). Il existe heureusement une solution.

Passer des descripteurs de fichiers dans les sockets#

Les sockets Unix implémentent une fonctionnalité essentielle à la séparation de privilèges. Elles permettent en effet de faire passer des descripteurs de fichiers, c’est-à-dire de dupliquer un descripteur de fichier dans l’espace du processus receveur (une sorte de dup() interprocessus) ! Dans notre exemple précédent, le fils devait demander au père de réceptionner pour lui chaque paquet. Désormais, on peut adopter l’approche suivante :

  • Le fils demande l’ouverture d’une socket.
  • Le père ouvre la socket, l’associe à l’interface et transmet la socket au fils.
  • Le fils met en place le filtre.
  • Le fils lit les paquets directement depuis la socket !

Le père a beaucoup moins de choses à faire : il n’a plus besoin de configurer le filtre, il n’a plus besoin de lire et de transmettre les paquets réseau. De manière identique, il lui suffira d’ouvrir le fichier de logs, il n’aura plus besoin d’écrire dedans. C’est autant de code en moins dans le père et d’interactions en moins à gérer. Cela va nous simplifier notre code et réduire le risque d’avoir un bug dans la partie qui tourne sous root.

En pratique, comment envoyer et recevoir un descripteur de fichier dans une socket Unix ? La solution est expliquée dans la page de manuel unix(7) : via les appels sendmsg() et recvmsg(), il est possible de transmettre un descripteur de fichier ou l’identité de l’utilisateur (de manière fiable, c’est le système d’exploitation qui est garant de cette transmission). La page de manuel cmsg(3) contient un exemple d’envoi d’un ensemble de descripteurs de fichiers.

Pour trouver un exemple complet, je vous conseille d’aller voir les spécialistes de la séparation de privilèges. À titre d’exemple, nous pouvons examiner le code source de syslogd et y découvrir un fichier regroupant les deux fonctions dont nous avons besoin.

En voici une version allégée qui ne gère pas les erreurs. Pour des projets réels, il faut bien sûr utiliser la version complète !

void
send_fd(int sock, int fd)
{
    struct msghdr msg;
    union {
        struct cmsghdr hdr;
        char buf[CMSG_SPACE(sizeof(int))];
    } cmsgbuf;
    struct cmsghdr *cmsg;
    struct iovec vec;
    int result;

    memset(&msg, 0, sizeof(msg));
    msg.msg_control = (caddr_t)&cmsgbuf.buf;
    msg.msg_controllen = sizeof(cmsgbuf.buf);
    cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_len = CMSG_LEN(sizeof(int));
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    *(int *)CMSG_DATA(cmsg) = fd;
    vec.iov_base = &result;
    vec.iov_len = sizeof(int);
    msg.msg_iov = &vec;
    msg.msg_iovlen = 1;
    sendmsg(sock, &msg, 0);
}

int
receive_fd(int sock)
{
    struct msghdr msg;
    union {
        struct cmsghdr hdr;
        char buf[CMSG_SPACE(sizeof(int))];
    } cmsgbuf;
    struct cmsghdr *cmsg;
    struct iovec vec;
    int result;
    int fd;

    memset(&msg, 0, sizeof(msg));
    vec.iov_base = &result;
    vec.iov_len = sizeof(int);
    msg.msg_iov = &vec;
    msg.msg_iovlen = 1;
    msg.msg_control = &cmsgbuf.buf;
    msg.msg_controllen = sizeof(cmsgbuf.buf);
    recvmsg(sock, &msg, 0);
    cmsg = CMSG_FIRSTHDR(&msg);
    assert(cmsg->cmsg_type == SCM_RIGHTS);
    fd = (*(int *)CMSG_DATA(cmsg));
    return fd;
}

Seconde tentative#

Forts de ces nouveaux acquis, nous allons pouvoir réécrire notre sniffer en utilisant la possibilité de passer les descripteurs de fichiers à travers la socket.

Nous devons tout d’abord convenir d’un protocole très simple pour communiquer entre le père et le fils. Le père n’a que deux choses à savoir faire : ouvrir une socket et ouvrir le fichier de logs. Le protocole est alors très simple : le fils envoie la commande à exécuter sur un entier, suivi du nom de l’interface dans le cas de l’ouverture de la socket et il reçoit en retour le descripteur de fichier (la socket ou celui du fichier de logs). Voici le code qui va remplacer les appels à socket() et bind() ainsi que celui à fopen() :

int
priv_socket(char *name)
{
    int cmd;
    char ifname[IFNAMSIZ];

    strncpy(ifname, name, IFNAMSIZ);
    cmd = PRIV_SOCKET;
    must_write(remote, &cmd, sizeof(int));
    must_write(remote, ifname, IFNAMSIZ);
    return receive_fd(remote);
}

FILE *
priv_fopen()
{
    int cmd, fd;

    cmd = PRIV_LOGFILE;
    must_write(remote, &cmd, sizeof(int));
    fd = receive_fd(remote);
    if (fd == -1)
        return NULL;
    return fdopen(fd, "a");
}

Notons un point important concernant la sécurité. L’appel fdopen() ne peut pas réouvrir le fichier avec un mode différent, par exemple "w". En effet, fdopen() se contente d’intégrer les informations du descripteur de fichier dans une structure que les autres appels manipuleront par la suite. Il va d’ailleurs vérifier la cohérence entre le mode demandé et celui associé au descripteur de fichier avec fcntl(). Si l’on tente d’élargir les permissions, cet appel échouera. Cette vérification est sans incidence sur la sécurité du procédé.

Côté père, on reçoit d’abord un entier (il y a respect de la frontière des messages, c’est facile), puis, selon sa valeur, on effectue les actions demandées et on renvoie le descripteur de fichier :

void
priv_loop()
{
    int cmd;
    char ifname[IFNAMSIZ];
    int s;
    struct sockaddr_ll sa;
    int alreadyopen = 0;

    while (!may_read(remote, &cmd, sizeof(int))) {
        switch (cmd) {
        case PRIV_SOCKET:
            if (alreadyopen)
                exit(1);
            must_read(remote, ifname, IFNAMSIZ);
            ifname[IFNAMSIZ-1] = '\0';
            if ((s = socket(AF_PACKET, SOCK_RAW,
                            htons(ETH_P_ALL))) < 0) {
                fprintf(stderr, "unable to open raw socket (%m)\n");
                exit(1);
            }

            memset(&sa, 0, sizeof(sa));
            sa.sll_family = AF_PACKET;
            sa.sll_protocol = 0;
            ifname[IFNAMSIZ-1] = '\0';
            if ((sa.sll_ifindex = if_nametoindex(ifname)) == 0) {
                fprintf(stderr, "unknown interface %s\n", ifname);
                exit(1);
            }

            if (bind(s, (struct sockaddr*)&sa, sizeof(sa)) < 0) {
                fprintf(stderr, "unable to listen to %s (%m)\n", ifname);
                exit(1);
            }

            alreadyopen = 1;
            send_fd(remote, s);
            close(s);
            break;

        case PRIV_LOGFILE:
            if ((s = open(LOG, O_WRONLY | O_APPEND | O_CREAT, 0600)) < 0) {
                fprintf(stderr, "unable to open %s (%m)\n", LOG);
                exit(1);
            }
            send_fd(remote, s);
            close(s);
            break;

        default:
            fprintf(stderr, "unknown command\n");
            exit(1);
        }
    }
}

Les fonctions must_write(), must_read() et may_read() sont simplement des wrappers autour de write() et read() que nous ne reproduisons pas par manque de place. Vous pouvez les récupérer dans le code source de syslogd.

Enfin, on remplace l’appel à socket() par un appel à priv_socket() et on retire l’appel à bind(). On remplace l’appel à fopen() par un appel à priv_fopen().

Il reste quelques menus détails à régler. Si le père ou le fils meurent (volontairement ou non), le processus restant doit également mourir. Il suffit pour cela de mettre en place quelques fonctions réagissant aux signaux et d’utiliser atexit() dans le père pour tuer le fils en cas de mort du père. Cela permet aussi à l’utilisateur d’arrêter le sniffer en tuant soit le père, soit le fils.

On obtient alors une application fonctionnellement identique à notre version sans séparation de privilèges, mais tournant dans deux processus communiquant entre eux :

root     12174 S+   19:25   0:00 ./sniffer4 wifi
nobody   12175 S+   19:25   0:00 ./sniffer4 wifi

Si un attaquant prend le contrôle du fils, il pourra uniquement écrire à la fin du fichier de logs et écouter sur l’interface réseau. Il pourra malheureusement aussi envoyer des paquets arbitraires sur cette interface.

Notons qu’il est peu probable qu’il puisse écouter sur une autre interface réseau, car le fils ne peut demander à écouter que sur une seule interface réseau. C’est l’utilité de notre variable alreadyopen. C’est une composante importante de la séparation de privilèges : gérer un état au niveau du père de façon à ne pas permettre au fils d’exécuter plusieurs fois certaines commandes. Cela rend la tâche de l’attaquant plus ardue.

Le code à auditer sérieusement est désormais confiné dans la fonction priv_loop(), ainsi que ses dépendances. Les bugs se trouvant en dehors de ce périmètre ne permettent, a priori, pas un accès root immédiat. Les éventuelles erreurs de parsing sont toujours là, mais ne procurent plus à l’attaquant autant de pouvoirs. Malgré la possibilité pour l’attaquant d’envoyer des paquets arbitraires sur l’interface sur laquelle on écoute, cette dernière version du sniffer semble constituer un bon équilibre entre les possibilités offertes à un attaquant ayant compromis le processus non privilégié et la quantité et la complexité du code tournant avec des privilèges élevés.

Les alternatives#

Ajouter la séparation de privilèges n’est pas foncièrement très compliqué. Cela nécessite d’écrire correctement un certain nombre de lignes de code et de convenir d’un protocole adéquat entre le père et le fils, notamment en ce qui concerne les éventuelles remontées d’erreur. Dans notre exemple simpliste, nous arrêtons simplement le sniffer si le fichier dans lequel on veut loguer se trouve dans un répertoire qui n’existe pas. Il est parfois souhaitable d’être plus propre et de faire remonter du père vers le fils ce genre d’erreurs.

Pour survenir à ces difficultés, il existe deux pistes à explorer.

privman#

privman est une bibliothèque facilitant l’ajout de la séparation de privilèges dans une application. Il suffit en effet d’appeler une fonction en début de programme pour créer le moniteur (le père), puis de remplacer les fonctions nécessitant des privilèges particuliers par les wrappers fournis. Ceux-ci respectent la même interface que les fonctions qu’ils remplacent.

L’exemple fourni sur site web est une bonne illustration de la facilité fournie par une telle bibliothèque.

#include <privman.h>
int main()
{
    priv_init();
    int fd = priv_open("/etc/shadow", O_RDONLY);
    return 0;
}

Il faut ensuite renseigner un fichier de configuration afin d’indiquer les opérations autorisées par le moniteur. Comme indiqué auparavant, il est indispensable que celui-ci ne se transforme en un simple proxy. Le fichier de configuration indique par exemple les fichiers que l’on peut ouvrir en lecture seule.

Cette bibliothèque ne semble malheureusement plus maintenue. J’ignore également si elle a été auditée.

imsg#

Le projet OpenBSD utilise très fortement la séparation de privilèges. Aussi, les développeurs ont mis au point un ensemble de méthodes afin de faciliter la communication entre processus, y compris le passage de descripteurs de fichiers. Les nouveaux développements n’utilisent plus les fonctions empruntées dans notre exemple.

Prenons par exemple le démon relayd qui est un répartiteur de charge. Le fichier à regarder est imsg.h. On y trouve un ensemble de fonctions que l’on peut utiliser à la place des fonctions issues de syslogd.

Ces méthodes implémentent un protocole au-dessus des sockets Unix et permettent de gérer plus facilement la communication entre le père et le fils : on envoie des ensembles d’octets, dont certains peuvent être associés à un descripteur de fichier et on reçoit des ensembles d’octets, éventuellement associés à des descripteurs de fichiers. Il est donc possible, en un seul échange de messages, d’obtenir toutes les informations nécessaires tout en gérant les cas d’erreur qui peuvent survenir.

Mise à jour (02.2023)

En anglais, l’article « Privilege drop, privilege separation, and restricted-service operating mode in OpenBSD » constitue une bonne lecture sur la séparation des privilèges dans OpenBSD.

Conclusion#

Alors que la séparation de privilèges est la norme pour le projet OpenBSD, elle est encore très souvent l’exception dans beaucoup d’autres univers. Prenez votre distribution GNU/Linux préférée, regardez la liste des processus tournant sous root et trouvez ceux qui utilisent la séparation de privilèges. Pour ma part, je ne repère que OpenSSH (et éventuellement Postfix, même si ce n’est pas du tout la même méthode).

La séparation de privilèges est pourtant un atout considérable dans la sécurisation d’une application. Espérons qu’elle sera de plus en plus utilisée.


  1. Niels Provos, Markus Friedl, Peter Honeyman, « Preventing Privilege Escalation ». ↩︎

  2. Il y a d’ailleurs une telle vulnérabilité dans notre sniffer, sauras-tu la trouver ? ↩︎