Privilege separation in C

Vincent Bernat

Note

This ar­ti­cle was pub­lished in GNU/Linux Mag­a­zine n° 119 in 2009 in French. It is trans­lated here to Eng­lish with an LLM.

Priv­i­lege sep­a­ra­tion1 is a tech­nique pop­u­lar­ized by OpenSSH and no­tably used in the OpenBSD project, al­low­ing the sep­a­ra­tion of a pro­gram into two com­mu­ni­cat­ing parts. One part han­dles op­er­a­tions re­quir­ing spe­cific priv­i­leges like open­ing a net­work socket or open­ing a file. The other part runs with­out any spe­cial priv­i­leges in a ch­root and must per­form most of the op­er­a­tions nec­es­sary for the proper func­tion­ing of the pro­gram. This tech­nique aims to min­i­mize the num­ber of lines of code re­quir­ing priv­i­leges and thus the num­ber of lines to be care­fully au­dited. Adding priv­i­lege sep­a­ra­tion to a pro­gram is not nec­es­sar­ily very com­pli­cated. We will see how to cre­ate a small snif­fer us­ing this technique.

A “light” tcpdump#

As an ex­am­ple, we will write a light­weight clone of tcp­dump. It takes as a pa­ram­e­ter the in­ter­face on which to lis­ten. It will dis­play on the stan­dard out­put the pack­ets it re­ceives: source IP, des­ti­na­tion IP, pro­to­col, and pos­si­bly source and des­ti­na­tion ports.

We will also add a fea­ture whose sole pur­pose will be to bet­ter il­lus­trate our ar­ti­cle: reg­u­larly, our snif­fer will ap­pend some sta­tis­tics to the end of a log file.

The code#

Here is a first ver­sion of our snif­fer. This ver­sion must be launched as the root user in or­der to function.

#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;
        }
    }
}

Let’s see step by step how our snif­fer works:

In ❶, a level 2 socket (AF_​PACKET) in raw mode (SOCK_​RAW) is first opened. This type of socket al­lows re­ceiv­ing all pack­ets ar­riv­ing on a net­work in­ter­face, with­out al­ter­ation. Then, we as­so­ciate our socket with the phys­i­cal in­ter­face on which we want to re­trieve the packets.

In ❷, we then at­tach a fil­ter to only bring up cer­tain pack­ets. This fil­ter has only an ed­u­ca­tional pur­pose and avoids bring­ing up broad­cast pack­ets. It is a fil­ter writ­ten in BPF. The com­ment in­di­cates the tcpdump com­mand used to ob­tain this filter.

In ❸, for each re­ceived packet, we ex­tract some rel­e­vant in­for­ma­tion to dis­play to the user.

Fi­nally, ev­ery 20 pack­ets, in ❹, we open a log file to in­di­cate that, at such a mo­ment, we re­ceived about twenty pack­ets. This is also a purely ed­u­ca­tional fea­ture that will com­pli­cate our task later on.

To com­pile our pro­gram, we use the fol­low­ing command:

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

By ex­e­cut­ing it and pro­vid­ing the name of an in­ter­face as the first ar­gu­ment, we ob­tain the ex­pected in­for­ma­tion. We can also ver­ify that the file spec­i­fied at the top of the code is in­deed cre­ated with the date in­for­ma­tion and the num­ber of pack­ets received.

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]

In our ex­am­ple, we cap­tured the re­sult of the ping command.

Why add privilege separation?#

Let’s imag­ine we add a bit more code to our snif­fer to dis­play more in­for­ma­tion, sim­i­lar to tcpdump. All this code would run with root priv­i­leges. The past has shown that it is not un­com­mon to find, in this type of ap­pli­ca­tion, vul­ner­a­bil­i­ties lead­ing at best to a crash of the ap­pli­ca­tion2 and at worst to re­mote code ex­ecution.

It would be in­ter­est­ing to min­i­mize the im­pact of such a vul­ner­a­bil­ity by ex­e­cut­ing only the strict min­i­mum with el­e­vated privileges.

In­deed, in the code above, very few things re­quire privileges:

All other calls can func­tion with­out ac­cess­ing the file sys­tem (so for ex­am­ple in an empty ch­root) and with­out us­ing any par­tic­u­lar priv­i­leges (such as those of root), in­clud­ing for ex­am­ple the im­ple­men­ta­tion of a filter.

If we were able to ex­e­cute the rest of the code in a ch­root with a user with­out any priv­i­leges, an at­tacker who would take con­trol of our snif­fer us­ing a spe­cially crafted packet could not do much: they could not ex­e­cute a shell, they could not read files, and they could not ex­e­cute any priv­i­leged func­tion. Even ex­ploit­ing a ker­nel flaw would be­come dif­fi­cult in this case.

Poor man’s privilege separation#

There is a very sim­ple way to achieve priv­i­lege sep­a­ra­tion: drop root rights very early, for ex­am­ple just af­ter the call to bind().

We use the fol­low­ing code to drop all priv­i­leges. This code can be placed af­ter the bind() call:

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);
}

Let’s see ex­actly what this code does and why each line is use­ful. First, it is im­por­tant to test the re­sult of each call. In­deed, if we fail to change users, we re­main root and it would then be eas­ier to exit the ch­root. Sim­i­larly, if we fail to set up the ch­root, we ac­cess im­por­tant in­for­ma­tion. It is there­fore vi­tal to prop­erly test the re­sult of each function!

We first ob­tain the UID of the nobody user. We will in­deed run the rest of the process un­der this user. This is an easy so­lu­tion to avoid hav­ing to cre­ate a spe­cific user, but it di­min­ishes the pro­tec­tion we want to achieve. In­deed, this user has some priv­i­leges, no­tably that of po­ten­tially hav­ing other pro­cesses run­ning un­der its name or files that be­long to it. An at­tacker could then, even in the ch­root, at­tach to an­other process of the same user, a process that might not be con­fined in a ch­root, and thus gain ad­di­tional rights. It is there­fore im­por­tant to cre­ate a user spe­cific to your ap­pli­ca­tion, for ex­am­ple _sniffer in our case.

Sim­i­larly, we re­trieve the GID of the nogroup group. Again, in real life, you should cre­ate a group spe­cific to your ap­pli­ca­tion, for ex­am­ple _sniffer.

We con­fine our­selves in the /var/​empty di­rec­tory, which as its name sug­gests must be empty. More­over, it must be­long to root and have ad­e­quate per­mis­sions so that it is not pos­si­ble to cre­ate files there. Plac­ing one­self in a ch­root re­quires root rights, which ex­plains why we per­form this op­er­a­tion be­fore drop­ping root rights.

We move to the root of our ch­root. In­deed, with­out this call to chdir(), our ap­pli­ca­tion would still be lo­cated in its startup di­rectory.

We then lose the rights re­lated to the group and then to the user. In a sin­gle call, the setresgid() and setresuid() func­tions al­low chang­ing the real, ef­fec­tive, and saved user or group. In­deed, when a process is root, it can tem­po­rar­ily lose its priv­i­leges, for ex­am­ple with setuid(), but also re­gain them later with seteuid(). It is there­fore im­por­tant to aban­don any pos­si­bil­ity of go­ing back. Oth­er­wise, an at­tacker will know how to make good use of it.

To run this ex­am­ple, do not for­get to cre­ate the /var/​empty di­rec­tory. Af­ter com­pi­la­tion, the new pro­gram works like the old one, but can no longer write the sta­tis­tics to the in­tended file. In­deed, not only is the file not ac­ces­si­ble from the ch­root, but it be­longs to root.

A so­lu­tion to cir­cum­vent this dif­fi­culty would be to open this file while we are root. It would then be pos­si­ble to write to it with­out any par­tic­u­lar rights. But that would be too easy: we ab­so­lutely want to open this file each time we want to write to it be­cause, for ex­am­ple, a logrotate might have pro­cessed it.

We are then in a clas­sic sit­u­a­tion where the sim­ple loss of priv­i­leges does not solve our problem.

Privilege separation#

In or­der to oc­ca­sion­ally write to a file, we need to split our ap­pli­ca­tion into two dis­tinct pro­cesses. One process will run as root and the other in a ch­root with­out any priv­i­leges. The two pro­cesses will com­mu­ni­cate via a pair of sockets.

A first attempt#

Here, in pseudo-code, is how it would be pos­si­ble to write our snif­fer us­ing two sep­a­rate processes.

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);
    }
}

First, we cre­ate a pair of sock­ets that we will use as bidi­rec­tional pipes: what we write on one of the sock­ets is re­ceived on the other side and vice versa. We use Unix sock­ets (AF_​UNIX), anony­mous (socketpair() can­not do any­thing else) and data­gram-ori­ented (SOCK_​DGRAM). This last char­ac­ter­is­tic is very in­ter­est­ing be­cause, un­like stream-ori­ented sock­ets, it pre­serves mes­sage bound­aries (if we send 10 bytes, we read 10 bytes on the other side and not 7 then 3) and, in the con­text of Unix sock­ets, they are re­li­able and do not re­order mes­sages (which is not the case for IP sock­ets, as this cor­re­sponds to the UDP pro­to­col). The two pro­cesses can there­fore com­mu­ni­cate through this means us­ing a pre­de­ter­mined pro­to­col in a rel­a­tively sim­ple manner.

Then, in the par­ent process, we re­tain root priv­i­leges and wait to re­ceive or­ders from the child that we ex­e­cute. These or­ders consist of:

  • open­ing the socket, as­so­ci­at­ing it with the ap­pro­pri­ate in­ter­face and set­ting the filter,
  • open­ing the log file,
  • writ­ing to the log file, and
  • clos­ing the log file.

An im­por­tant point to note is that it is not pos­si­ble from the child to choose the log file to open. It is cru­cial not to turn the par­ent into a sim­ple proxy. If the child could choose the name of the log file, an at­tacker could, for ex­am­ple, ask to open /etc/​passwd and cre­ate an ac­count! It is crit­i­cal to con­sider the child as hos­tile, as po­ten­tially con­trolled by the at­tacker, as in a client/server ap­plication!

Also, the par­ent has a for­mi­da­ble weapon that a clas­sic client/server ap­pli­ca­tion does not have: it can kill its child if it con­sid­ers that it is not be­hav­ing cor­rectly, thus putting an end to any at­tack. Cer­tainly, in our case, the snif­fer will then cease to func­tion, but since it was con­trolled by an at­tacker any­way, it could no longer ac­com­plish its task.

In the child, we have re­placed the calls re­quir­ing par­tic­u­lar priv­i­leges with a mes­sage send to the par­ent process to ex­e­cute the nec­es­sary ac­tions. More­over, we have also moved some non-priv­i­leged calls such as set­ting up a fil­ter and read­ing a packet: only the par­ent has ac­cess to the socket and the child must there­fore tell the par­ent what it wants to do.

In our pre­vi­ous ex­am­ple, set­ting up a fil­ter and read­ing a packet did not re­quire any priv­i­leges. We have there­fore re­gressed on this point: ad­di­tional code is ex­e­cuted as root. How­ever, we can again write our log file, but al­most all of the nec­es­sary code is ac­tu­ally ex­e­cuted by root.

One of the in­ter­ests of priv­i­lege sep­a­ra­tion is to dras­ti­cally re­duce the code run­ning as root, thus re­duc­ing the amount of code to care­fully au­dit. In our ex­am­ple, there is too much code run­ning as root (for such a sim­ple ex­am­ple). For­tu­nately, there is a solution.

Passing file descriptors in sockets#

Unix sock­ets im­ple­ment a fea­ture es­sen­tial to priv­i­lege sep­a­ra­tion. They in­deed al­low pass­ing file de­scrip­tors, that is, du­pli­cat­ing a file de­scrip­tor in the re­ceiv­ing process’s space (a kind of in­ter-process dup()). In our pre­vi­ous ex­am­ple, the child had to ask the par­ent to re­ceive each packet for it. Now, we can adopt the fol­low­ing approach:

  • The child re­quests the open­ing of a socket.
  • The par­ent opens the socket, as­so­ciates it with the in­ter­face and trans­mits the socket to the child.
  • The child sets up the filter.
  • The child reads the pack­ets di­rectly from the socket!

The par­ent has much less to do: it no longer needs to con­fig­ure the fil­ter, it no longer needs to read and trans­mit net­work pack­ets. Sim­i­larly, it will only need to open the log file, it will no longer need to write to it. That is less code in the par­ent and fewer in­ter­ac­tions to man­age. This will sim­plify our code and re­duce the risk of hav­ing a bug in the part that runs as root.

In prac­tice, how do we send and re­ceive a file de­scrip­tor in a Unix socket? The so­lu­tion is ex­plained in the unix(7) man­ual page: via the sendmsg() and recvmsg() calls, it is pos­si­ble to trans­mit a file de­scrip­tor or the user’s iden­tity (re­li­ably, it is the op­er­at­ing sys­tem that guar­an­tees this trans­mis­sion). The cmsg(3) man­ual page con­tains an ex­am­ple of send­ing a set of file de­scriptors.

To find a com­plete ex­am­ple, I ad­vise you to look at the spe­cial­ists in priv­i­lege sep­a­ra­tion. As an ex­am­ple, we can ex­am­ine the source code of syslogd and dis­cover a file group­ing the two func­tions we need.

Here is a light­weight ver­sion that does not han­dle er­rors. For real projects, you should use the com­plete version!

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;
}

Second attempt#

With this new knowl­edge, we can now re­write our snif­fer us­ing the abil­ity to pass file de­scrip­tors through the socket.

We must first agree on a very sim­ple pro­to­col to com­mu­ni­cate be­tween the par­ent and the child. The par­ent only needs to know how to do two things: open a socket and open the log file. The pro­to­col is then very sim­ple: the child sends the com­mand to ex­e­cute on an in­te­ger, fol­lowed by the name of the in­ter­face in the case of open­ing the socket, and it re­ceives in re­turn the file de­scrip­tor (the socket or that of the log file). Here is the code that will re­place the calls to socket() and bind() as well as the one to 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");
}

Let’s note an im­por­tant point con­cern­ing se­cu­rity. The fdopen() call can­not re­open the file with a dif­fer­ent mode, for ex­am­ple "w". In­deed, fdopen() in­te­grates the in­for­ma­tion from the file de­scrip­tor into a struc­ture that other calls will sub­se­quently ma­nip­u­late. It will also check the con­sis­tency be­tween the re­quested mode and the one as­so­ci­ated with the file de­scrip­tor us­ing fcntl(). If we at­tempt to ex­pand per­mis­sions, this call will fail. This ver­i­fi­ca­tion has no im­pact on the se­cu­rity of the process.

On the par­ent side, we first re­ceive an in­te­ger (there is re­spect for mes­sage bound­aries, it is easy), then, de­pend­ing on its value, we per­form the re­quested ac­tions and send back the file de­scriptor:

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);
        }
    }
}

The func­tions must_​write(), must_​read(), and may_​read() are wrap­pers around write() and read() that we do not re­pro­duce here due to lack of space. You can re­trieve them from the syslogd source code.

Fi­nally, we re­place the call to socket() with a call to priv_​socket() and re­move the call to bind(). We re­place the call to fopen() with a call to priv_​fopen().

There are a few mi­nor de­tails to sort out. If the par­ent or child dies (vol­un­tar­ily or not), the re­main­ing process must also die. It’s suf­fi­cient to set up a few func­tions re­act­ing to sig­nals and to use atexit() in the par­ent to kill the child in case of the par­ent’s death. This also al­lows the user to stop the snif­fer by killing ei­ther the par­ent or the child.

We then ob­tain an ap­pli­ca­tion func­tion­ally iden­ti­cal to our ver­sion with­out priv­i­lege sep­a­ra­tion, but run­ning in two pro­cesses com­mu­ni­cat­ing with each other:

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

If an at­tacker takes con­trol of the child, they will only be able to write to the end of the log file and lis­ten on the net­work in­ter­face. They will un­for­tu­nately also be able to send ar­bi­trary pack­ets on this in­terface.

Note that it is un­likely that they can lis­ten on an­other net­work in­ter­face, as the child can only re­quest to lis­ten on a sin­gle net­work in­ter­face. This is the pur­pose of our alreadyopen vari­able. This is an im­por­tant com­po­nent of priv­i­lege sep­a­ra­tion: man­ag­ing a state at the par­ent level so as not to al­low the child to ex­e­cute cer­tain com­mands mul­ti­ple times. This makes the at­tacker’s task more difficult.

The code to be se­ri­ously au­dited is now con­fined to the priv_​loop() func­tion, as well as its de­pen­den­cies. Bugs found out­side this perime­ter do not, a pri­ori, al­low im­me­di­ate root ac­cess. Any pars­ing er­rors are still there, but no longer pro­vide the at­tacker with as much power. De­spite the pos­si­bil­ity for the at­tacker to send ar­bi­trary pack­ets on the in­ter­face we are lis­ten­ing to, this lat­est ver­sion of the snif­fer seems to con­sti­tute a good bal­ance be­tween the pos­si­bil­i­ties of­fered to an at­tacker who has com­pro­mised the un­priv­i­leged process and the quan­tity and com­plex­ity of the code run­ning with el­e­vated privileges.

The alternatives#

Adding priv­i­lege sep­a­ra­tion is not fun­da­men­tally very com­pli­cated. It re­quires cor­rectly writ­ing a cer­tain num­ber of lines of code and agree­ing on an ad­e­quate pro­to­col be­tween the par­ent and the child, par­tic­u­larly with re­gard to pos­si­ble er­ror re­port­ing. In our sim­plis­tic ex­am­ple, we stop the snif­fer if the file we want to log to is in a di­rec­tory that does not ex­ist. It is some­times de­sir­able to be cleaner and to re­port this kind of er­ror from the par­ent to the child.

To over­come these dif­fi­cul­ties, there are two av­enues to explore.

privman#

priv­man is a li­brary fa­cil­i­tat­ing the ad­di­tion of priv­i­lege sep­a­ra­tion in an ap­pli­ca­tion. It is in­deed suf­fi­cient to call a func­tion at the be­gin­ning of the pro­gram to cre­ate the mon­i­tor (the par­ent), then to re­place the func­tions re­quir­ing par­tic­u­lar priv­i­leges with the pro­vided wrap­pers. These re­spect the same in­ter­face as the func­tions they replace.

The ex­am­ple pro­vided on the web­site is a good il­lus­tra­tion of the ease pro­vided by such a library.

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

You then need to fill out a con­fig­u­ra­tion file to in­di­cate the op­er­a­tions au­tho­rized by the mon­i­tor. As men­tioned ear­lier, it is es­sen­tial that it does not turn into a sim­ple proxy. The con­fig­u­ra­tion file in­di­cates, for ex­am­ple, the files that can be opened in read-only mode.

Un­for­tu­nately, this li­brary no longer seems to be main­tained. I also do not know if it has been audited.

imsg#

The OpenBSD project uses priv­i­lege sep­a­ra­tion very heav­ily. As such, the de­vel­op­ers have de­vel­oped a set of meth­ods to fa­cil­i­tate com­mu­ni­ca­tion be­tween pro­cesses, in­clud­ing the pass­ing of file de­scrip­tors. New de­vel­op­ments no longer use the func­tions bor­rowed in our example.

Let’s take for ex­am­ple the relayd dae­mon which is a load bal­ancer. The file to look at is imsg.h. There we find a set of func­tions that can be used in­stead of the func­tions from syslogd.

These meth­ods im­ple­ment a pro­to­col on top of Unix sock­ets and al­low for eas­ier man­age­ment of com­mu­ni­ca­tion be­tween the par­ent and the child: we send sets of bytes, some of which can be as­so­ci­ated with a file de­scrip­tor, and we re­ceive sets of bytes, pos­si­bly as­so­ci­ated with file de­scrip­tors. It is there­fore pos­si­ble, in a sin­gle mes­sage ex­change, to ob­tain all the nec­es­sary in­for­ma­tion while man­ag­ing er­ror cases that may occur.

Up­date (2023-02)

The ar­ti­cle “Priv­i­lege drop, priv­i­lege sep­a­ra­tion, and re­stricted-ser­vice op­er­at­ing mode in OpenBSD” is a good read on priv­i­lege sep­a­ra­tion in OpenBSD.

Conclusion#

While priv­i­lege sep­a­ra­tion is the norm for the OpenBSD project, it is still very of­ten the ex­cep­tion in many other uni­verses. Take your fa­vorite GNU/Linux dis­tri­bu­tion, look at the list of pro­cesses run­ning as root and find those that use priv­i­lege sep­a­ra­tion. For my part, I only spot OpenSSH (and pos­si­bly Post­fix, even if it is not at all the same method).

Priv­i­lege sep­a­ra­tion is, how­ever, a con­sid­er­able as­set in se­cur­ing an ap­pli­ca­tion. Let’s hope it will be more and more used.