Changing argv changes the output of ps

mini kernel
|

If you change argv inside your program, /proc/<pid>/cmdline and ps will reflect the change.

This is not a bug, and is used to allow processes to change their title to something more informative. For example a program could change its name from prog_name to prog_name - Loading x file and someone running ps would see this. In this “mini” article we will go through my journey of investigating how this happens.

Let’s use this simple program as example:

int main(int argc, char *argv[]) {
    if(argc != 3) {
        printf("I want 3 arguments");
        return -1;
    }

    memcpy(argv[0], "hidden_prog", strlen(argv[0]));
    memcpy(argv[1], "xxxxxxxxxxxxxxxx", strlen(argv[1]));
    memcpy(argv[2], "yyyyyyyyyyyyyyyy", strlen(argv[2]));

    sleep(1000);
    return 0;
}

Running ./my_program arg1 arg2 in one terminal and running ps a -o args | grep hidden in a second terminal we get:

hidden_prog  xxxx yyyy

Similarly running cat /proc/<pid>/cmdline we get hidden_prog\x00xxxx\x00yyyy.

Getting the real command name

ps has the c option which as the manual says:

Show the true command name. This is derived from the name of the executable file, rather than from the argv value. Command arguments and any modifications to them are thus not shown.

We get the real binary name, but we cannot get the original argv.

ps auxc | grep my_program

jofra     8997  0.0  0.0   4380   724 pts/4    S+   14:26   0:00 my_program

cat /proc/<pid>/comm also returns the original program name: my_program.

Does ps use proc?

Yes, behind the scenes, ps just queries the proc filesystem to get the information it needs about the running processes as exemplified below.

Fake name: strace -- ps p <pid> -o args 2>&1 | grep -A 2 <pid>

openat(AT_FDCWD, "/proc/18025/cmdline", O_RDONLY) = 6
read(6, "hidden_prog\0\0xxxx\0yyyy\0", 131072) = 23

Real name: strace -- ps p <pid> c 2>&1 | grep -A 2 <pid>

openat(AT_FDCWD, "/proc/18025/stat", O_RDONLY) = 6
read(6, "18025 (my_program) S 14032 18025"..., 2048) = 320
openat(AT_FDCWD, "/proc/18025/status", O_RDONLY) = 6
read(6, "Name:\tmy_program\nUmask:\t0002\nSta"..., 2048) = 1366

The real command name, appears in multiple proc files.

Investigating proc

Let’s go a bit deeper and see what proc is doing behind the scenes. The proc filesystem is a kernel mechanism to access information about processes and more, and so we need to read some kernel source code to investigate what is happening.

/proc/pid/cmdline

We are interested in how /proc/<pid>/cmdline is handled to see how we get the “fake” program name. This is done in the get_mm_cmdline function which can be found in fs/proc/base.c.

static ssize_t get_mm_cmdline(struct mm_struct *mm, char __user *buf,
                  size_t count, loff_t *ppos)
{
    // (...)
    arg_start = mm->arg_start;
    arg_end = mm->arg_end;
    env_start = mm->env_start;
    env_end = mm->env_end;
    // (...)

    // (1)
    /*
     * Magical special case: if the argv[] end byte is not
     * zero, the user has overwritten it with setproctitle(3).
     */
    if (access_remote_vm(mm, arg_end-1, &c, 1, FOLL_ANON) == 1 && c)
        return get_mm_proctitle(mm, buf, count, pos, arg_start);

    // (...)

    page = (char *)__get_free_page(GFP_KERNEL);
    if (!page)
        return -ENOMEM;

    // (2)
    len = 0;
    while (count) {
        int got;
        size_t size = min_t(size_t, PAGE_SIZE, count);

        got = access_remote_vm(mm, pos, page, size, FOLL_ANON);
        if (got <= 0)
            break;
        got -= copy_to_user(buf, page, got);
        if (unlikely(!got)) {
            if (!len)
                len = -EFAULT;
            break;
        }
        pos += got;
        buf += got;
        len += got;
        count -= got;
    }

    free_page((unsigned long)page);
    return len;
}

As we can see in (1) the kernel is aware that the user may have overwritten the program name, and it handles it in a different way if the last byte of argv is not \x00 as expected. If this is the case, it calls get_mm_proctitle which just starts reading from arg_start and stops when it finds a null byte (or reaches the maximum size of 1 page).

In our example however, we do not change the last byte of argv (we keep the size of each arg the same) and so we still go through the normal case (2) where the kernel simply returns the contents from arg_start to arg_end.

/proc/pid/comm

If we now look at /proc/<pid>/comm, we see it’s handled by comm_show, where proc_task_name eventually calls __get_task_comm.

static int comm_show(struct seq_file *m, void *v)
{
    // (...)
    p = get_proc_task(inode);

    // (...)

    proc_task_name(m, p, false); // <-- eventually calls '__get_task_comm'
    // (...)
}
char *__get_task_comm(char *buf, size_t buf_size, struct task_struct *tsk)
{
    task_lock(tsk);
    strncpy(buf, tsk->comm, buf_size);
    task_unlock(tsk);
    return buf;
}

__get_task_comm simply gets ->comm from the task_struct of the process being queried, meaning that changes to argv do not affect it.

writing to /proc/pid/comm

Looking through the code I also noticed that comm_write is used as the write operation of the structure that describes /proc/<pid>/comm. This means that we can also change tsk->comm by writing to /proc/<pid>/comm.

static const struct file_operations proc_pid_set_comm_operations = {
    .open       = comm_open,
    .read       = seq_read,
    .write      = comm_write, // <--
    .llseek     = seq_lseek,
    .release    = single_release,
};
static ssize_t comm_write(struct file *file, const char __user *buf,
                size_t count, loff_t *offset)
{
    // (...)
    if (copy_from_user(buffer, buf, count > maxlen ? maxlen : count))
        return -EFAULT;

    // (...)

    set_task_comm(p, buffer);
    // (...)
}

Let’s try it and see what happens:

void get_comm_name(char *buf, size_t buf_sz) {
    FILE *f = fopen("/proc/self/comm", "r");
    memset(buf, 0, buf_sz);
    fread(buf, buf_sz, 1, f);
    fclose(f);
}

void set_comm_name(char *buf, size_t buf_sz) {
    FILE *f = fopen("/proc/self/comm", "w");
    fwrite(buf, buf_sz, 1, f);
    fclose(f);
}

int main(int argc, char *argv[]) {
    char buf[64];

    get_comm_name(buf, sizeof(buf));
    printf("old_comm = %s", buf);

    set_comm_name("hidden_prog", 11);

    get_comm_name(buf, sizeof(buf));
    printf("new_comm = %s", buf);

    return 0;
}

Running ps auxc or cat /proc/<pid>/comm now will also get us the “fake” name. To the best of my knowledge there is no way to recover the original name. Kinda cool?!

Conclusion

In conclusion if someone wants to hide a program from ps, top, htop, etc.. one can. In my opinion it’s slightly weird that there is no way to get the original name of the executable that was spawned (afaik), but I guess a program could also just copy itself to a file with a different name, run the new file, and exit, which would result in a similar situation.