Operating System Command Injection

Operating system command injection in C/C++

Description

system(), popen(), and _popen() pass a string to a command interpreter, as does C++'s std::system() from <cstdlib>. If the string includes untrusted input, shell metacharacters, command separators, redirections, or additional arguments may be interpreted as code. wordexp() also performs shell word expansion and can execute command substitutions unless WRDE_NOCMD is specified.

Escaping shell characters alone cannot safely handle every shell and target program's syntax. Even when shell commands are blocked, argument injection may remain if the target program treats untrusted values as options. A helper name such as shell_escape() or sanitize_command_arg() does not establish safety.

Potential impact

  • Arbitrary operating system commands may run with the application's privileges.
  • Consequences can include file deletion or modification, sensitive data exposure, internal system access, and service disruption.
  • A highly privileged process may expose the entire system to compromise.

Remediation

Prefer library APIs that provide the required function without an external command. If a separate process is necessary:

  1. Fix the executable path in code and use execve(), an appropriate posix_spawn() function, or the platform's process API.
  2. Keep the executable and arguments separate instead of assembling a command string.
  3. Validate each argument against an allow-list for the target program's data format. Check option injection risks, including leading - and the handling of --.
  4. Supply only necessary environment variables with trusted values and handle errors.
  5. If wordexp() is unavoidable, include WRDE_NOCMD and check its return value. It rejects requested command substitution with WRDE_CMDSUB; it does not make other forms of word expansion generally safe.

Examples

C

Before

c
#include <stdlib.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    return system(argv[1]);
}

After

c
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int valid_label(const char *value) {
    size_t length = strlen(value);
    if (length == 0 || length > 64) {
        return 0;
    }

    for (size_t i = 0; i < length; ++i) {
        unsigned char ch = (unsigned char)value[i];
        if (!isalnum(ch) && ch != '.' && ch != '_' && ch != '-') {
            return 0;
        }
    }
    return 1;
}

int main(int argc, char **argv) {
    if (argc != 2 || !valid_label(argv[1])) {
        return 2;
    }

    char *const args[] = {"printf", "Archive: %s\n", argv[1], NULL};
    execv("/usr/bin/printf", args);
    perror("execv");
    return 1;
}

Explanation:

  • Before: The entire argv[1] value becomes a shell command. Adding quotes does not make all shell syntax safe.
  • After: The executable and format string are fixed, and the user value is a separate argument. Allow-list validation also restricts the value to its intended business format.

If wordexp() is needed, explicitly prohibit command substitution and handle every error.

c
#include <wordexp.h>

int expand_without_commands(const char *input, wordexp_t *words) {
    int result = wordexp(input, words, WRDE_NOCMD | WRDE_UNDEF);
    if (result != 0) {
        if (result == WRDE_NOSPACE) {
            wordfree(words);
        }
        return -1;
    }
    return 0;
}

On success, the caller must release the result with wordfree() after use. Partially allocated memory must also be released on WRDE_NOSPACE.

C++

Before

cpp
#include <cstdlib>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    return std::system(argv[1]);
}

After

cpp
#include <cctype>
#include <cstdio>
#include <cstring>
#include <unistd.h>

static bool valid_label(const char *value) {
    std::size_t length = std::strlen(value);
    if (length == 0 || length > 64) {
        return false;
    }

    for (std::size_t i = 0; i < length; ++i) {
        unsigned char ch = static_cast<unsigned char>(value[i]);
        if (!std::isalnum(ch) && ch != '.' && ch != '_' && ch != '-') {
            return false;
        }
    }
    return true;
}

int main(int argc, char **argv) {
    if (argc != 2 || !valid_label(argv[1])) {
        return 2;
    }

    char program[] = "/usr/bin/printf";
    char format[] = "Archive: %s\n";
    char *const args[] = {program, format, argv[1], nullptr};
    execv(program, args);
    std::perror("execv");
    return 1;
}

Call a fixed executable directly instead of using std::system(), and pass the user value as a separate argument. This example is valid in a C++23 and POSIX.1-2024 environment; the allow-list also restricts the value to its business format.

References