Skip to content

CWE-114: Process Control - Java

Overview

In Java, CWE-114 vulnerabilities occur when untrusted input decides which native library gets loaded or which external process runs. Weak library loading lets an attacker get their own native code loaded, by hijacking the search path, manipulating the path string, or substituting the library file.

Primary Defence: Use System.load() with absolute paths instead of System.loadLibrary(), configure ProcessBuilder with explicit argument arrays and a replaced (not merely emptied) environment, and implement allowlists for library names and process commands. Runtime.exec(String) does not run a shell - the weakness it carries is argument injection, which argument arrays and -- are what fix.

Common Vulnerable Patterns

Using System.loadLibrary() with untrusted library name

// VULNERABLE - Searches java.library.path, can be hijacked
public class UnsafeLibraryLoader {
    public void loadLibrary(String libraryName) {
        // User controls library name - attacker can place malicious library
        // in java.library.path or current directory
        System.loadLibrary(libraryName);
    }
}

// Attack: If attacker controls libraryName or places malicious library
// in search path, their code executes with application privileges

Why this is vulnerable: System.loadLibrary() searches java.library.path and other system paths, allowing attackers who can place malicious libraries in these directories or manipulate the library path to execute arbitrary native code.

Loading library from user-controlled path

// VULNERABLE - Path can be manipulated
public class UnsafeNativeLoader {
    public void loadNativeLib(String userPath) {
        // User controls full path - can point to malicious library
        System.load(userPath);
    }
}

// Attack: User provides "/tmp/evil.so" containing malicious code

Why this is vulnerable: Accepting a user-controlled path for System.load() loads whatever native library the path names, including one in a directory the attacker controls, so their native code executes with the application's privileges.

Building library path from user input

// VULNERABLE - Path traversal + library injection
public class UnsafePluginLoader {
    private static final String PLUGIN_DIR = "/opt/app/plugins/";

    public void loadPlugin(String pluginName) {
        // User controls pluginName - can use path traversal
        String libraryPath = PLUGIN_DIR + pluginName + ".so";
        System.load(libraryPath);
    }
}

// Attack: pluginName = "../../../tmp/malicious"
// Loads: /opt/app/plugins/../../../tmp/malicious.so = /tmp/malicious.so

Why this is vulnerable: Concatenating user input into a file path without validation lets ../ sequences escape the plugin directory, so the library that loads can come from anywhere on the filesystem.

Using Runtime.exec() with unsanitized input

import java.io.IOException;

// VULNERABLE - Argument injection; becomes command injection if the command is a shell
public class UnsafeProcessLauncher {
    public void convertImage(String inputFile) {
        try {
            // User controls inputFile - splits into extra argv entries
            String command = "convert " + inputFile + " output.png";
            Runtime.getRuntime().exec(command);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

// Attack: inputFile = "-write /var/www/html/shell.php"
// Runs: convert -write /var/www/html/shell.php output.png
//
// If the command string starts a shell, the same input is full command injection:
//   String command = "/bin/sh -c \"convert " + inputFile + " output.png\"";

Why this is vulnerable: Runtime.exec(String) does not invoke a shell. It splits the string on whitespace with a StringTokenizer - no quoting, no metacharacter handling - and execs the first token directly. So inputFile = "input.jpg; rm -rf /" does not delete anything: convert simply receives the extra argv entries input.jpg;, rm, -rf and /. What the attacker actually controls is the argument vector of whatever program is named, which is enough on its own - convert reads -write as an output directive, tar reads --to-command as a command to run, and ssh reads -o ProxyCommand=. Shell metacharacters only become live when the command string names a shell (/bin/sh -c ..., cmd.exe /c ...), and then every one of them applies. Runtime.exec(String) is also deprecated as of Java 18 precisely because the splitting is a trap.

Secure Patterns

Use System.load() with absolute path and validation

import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;

public class SecureLibraryLoader {
    // Hardcoded allowlist of allowed libraries
    private static final Path LIBRARY_DIR = Paths.get("/opt/app/lib").toAbsolutePath();
    private static final Set<String> ALLOWED_LIBRARIES = Set.of(
        "libcrypto.so",
        "libssl.so",
        "libcustom.so"
    );

    public void loadLibrary(String libraryName) {
        // Validate library is in allowlist
        if (!ALLOWED_LIBRARIES.contains(libraryName)) {
            throw new SecurityException("Library not in allowlist: " + libraryName);
        }

        // Construct absolute path
        Path libraryPath = LIBRARY_DIR.resolve(libraryName).normalize();

        // Verify path hasn't escaped library directory (path traversal protection)
        if (!libraryPath.startsWith(LIBRARY_DIR)) {
            throw new SecurityException("Path traversal attempt detected");
        }

        // Verify file exists and is a regular file
        File libraryFile = libraryPath.toFile();
        if (!libraryFile.exists() || !libraryFile.isFile()) {
            throw new SecurityException("Library file not found or is not a file");
        }

        // Load with absolute path - bypasses library search path
        System.load(libraryPath.toString());
    }
}

Why this works:

  • Absolute paths bypass java.library.path search behavior.
  • Allowlists restrict loading to approved library names.
  • normalize() resolves . and .. to block traversal.
  • startsWith() prevents escaping the library directory.
  • File checks ensure only regular files are loaded.

Secure ProcessBuilder with argument array

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class SecureProcessLauncher {
    private static final Path CONVERT_BINARY = Paths.get("/usr/bin/convert");
    private static final Path UPLOAD_DIR = Paths.get("/opt/app/uploads").toAbsolutePath();
    private static final Path OUTPUT_DIR = Paths.get("/opt/app/outputs").toAbsolutePath();
    private static final Path WORK_DIR = Paths.get("/var/lib/app/work");

    // Daemon threads: a pool of non-daemon threads would keep the JVM alive.
    private static final ExecutorService DRAIN_POOL =
        Executors.newCachedThreadPool(r -> {
            Thread thread = new Thread(r, "convert-output-drain");
            thread.setDaemon(true);
            return thread;
        });

    public void convertImage(String inputFile, String outputFile) throws IOException {
        Path inputPath = resolveExistingInput(inputFile);
        Path outputPath = resolveOutputTarget(outputFile);

        // Argument array - no string is ever parsed into arguments
        ProcessBuilder pb = new ProcessBuilder(
            CONVERT_BINARY.toString(),  // Absolute path to binary
            "--",                       // Stop option parsing: a leading "-" is now a filename
            inputPath.toString(),
            outputPath.toString()
        );

        // Replace the inherited environment rather than emptying it. An empty environment
        // has no PATH, HOME or TMPDIR, and most binaries (ImageMagick included) fail or
        // misbehave without them.
        Map<String, String> env = pb.environment();
        env.clear();
        env.put("PATH", "/usr/bin:/bin");
        env.put("HOME", WORK_DIR.toString());
        env.put("LANG", "C");

        pb.directory(WORK_DIR.toFile());
        pb.redirectErrorStream(true);

        Process process = pb.start();

        // Drain the pipe on another thread. Reading inline would block until EOF,
        // which a hung child never reaches - so the timeout below would never be
        // evaluated and the "bounded wait" would be unbounded in the one case it
        // exists for. Draining is what stops a full-buffer deadlock; the timeout
        // is what stops a silent hang. They are different failures, and handling
        // both requires the read not to be on this thread.
        Future<String> output = DRAIN_POOL.submit(() ->
            new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));

        try {
            if (!process.waitFor(60, TimeUnit.SECONDS)) {
                process.destroyForcibly();
                output.cancel(true);
                throw new IOException("Conversion timed out");
            }

            // The process has exited, so its end of the pipe is closed and this
            // returns. Still bounded, in case a grandchild inherited the write
            // end and is holding it open after the child itself has gone.
            String text;
            try {
                text = output.get(5, TimeUnit.SECONDS);
            } catch (TimeoutException e) {
                output.cancel(true);
                text = "(output not collected)";
            } catch (ExecutionException e) {
                throw new IOException("Failed reading process output", e.getCause());
            }

            if (process.exitValue() != 0) {
                throw new IOException("Process failed with exit code "
                        + process.exitValue() + ": " + text);
            }
        } catch (InterruptedException e) {
            process.destroyForcibly();
            output.cancel(true);
            Thread.currentThread().interrupt();
            throw new IOException("Process interrupted", e);
        }
    }

    // For a file that must already exist.
    private Path resolveExistingInput(String fileName) throws IOException {
        Path path = UPLOAD_DIR.resolve(fileName).normalize();
        requireInside(UPLOAD_DIR, path);

        if (!Files.isRegularFile(path)) {
            throw new SecurityException("Not a regular file: " + fileName);
        }
        return path;
    }

    // For a file the process is about to create. The file itself cannot be checked -
    // it does not exist yet - so the check is on the directory that will hold it.
    private Path resolveOutputTarget(String fileName) throws IOException {
        Path path = OUTPUT_DIR.resolve(fileName).normalize();
        requireInside(OUTPUT_DIR, path);

        if (!Files.isDirectory(path.getParent())) {
            throw new SecurityException("Output directory does not exist");
        }
        return path;
    }

    private void requireInside(Path base, Path candidate) {
        // Path.startsWith compares path elements, not characters, so
        // /opt/app/uploads-public is correctly rejected against /opt/app/uploads.
        if (!candidate.startsWith(base)) {
            throw new SecurityException("Path outside allowed directory");
        }
    }
}

Why this works:

  • Argument arrays avoid shell interpretation and metacharacter injection, and -- stops the program treating an attacker-chosen filename as an option (convert -write ...).
  • Absolute binary paths prevent PATH hijacking.
  • A replaced environment removes LD_PRELOAD and LD_LIBRARY_PATH without leaving the child with no PATH at all.
  • Input and output are resolved against separate fixed base directories, so the caller supplies a filename rather than a path, and neither one can be an absolute path.
  • The two are validated differently on purpose. Files.isRegularFile is the right check for an input that must already exist, and the wrong one for an output the process has not written yet - applying it to both is a silent way to make the method reject every legitimate call.
  • Path.startsWith compares name elements rather than characters, so it does not have the sibling-directory hole that String.startsWith on a path has.
  • The output pipe is drained on another thread, which is what lets the two ways a child hangs be handled separately. Reading inline with readAllBytes() closes the full-buffer deadlock and reopens the other one: it blocks until EOF, which a child that emits little output and then hangs never reaches, so the waitFor timeout underneath it is never evaluated. Wherever a timeout sits after a blocking read of the same process's stream, check that the read can finish without it.

Secure JNI library loading with hash verification

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Map;

public class SecureJNILoader {
    private static final Path LIBRARY_DIR = Paths.get("/opt/app/lib/native").toAbsolutePath();

    // SHA-256 hashes of trusted libraries
    private static final Map<String, String> LIBRARY_HASHES = Map.of(
        "libcrypto.so", "abc123...def456",
        "libssl.so", "789xyz...012abc"
    );

    public void loadTrustedLibrary(String libraryName) {
        if (!LIBRARY_HASHES.containsKey(libraryName)) {
            throw new SecurityException("Library not in trusted list: " + libraryName);
        }

        Path libraryPath = LIBRARY_DIR.resolve(libraryName).normalize();

        // Verify path integrity
        if (!libraryPath.startsWith(LIBRARY_DIR)) {
            throw new SecurityException("Path traversal detected");
        }

        // Verify library hash before loading
        String expectedHash = LIBRARY_HASHES.get(libraryName);
        String actualHash = calculateSHA256(libraryPath);

        if (!expectedHash.equals(actualHash)) {
            throw new SecurityException("Library hash mismatch - possible tampering");
        }

        // Load verified library
        System.load(libraryPath.toString());
    }

    private String calculateSHA256(Path filePath) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] fileBytes = Files.readAllBytes(filePath);
            byte[] hashBytes = digest.digest(fileBytes);

            // Convert to hex string
            StringBuilder sb = new StringBuilder();
            for (byte b : hashBytes) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();

        } catch (NoSuchAlgorithmException | IOException e) {
            throw new SecurityException("Failed to verify library hash", e);
        }
    }
}

Why this works:

  • SHA-256 verification detects tampering or substitution.
  • Allowlists restrict which libraries can be loaded.
  • Path normalization blocks traversal and hijacking.
  • Library must match name, path, and hash to load.

Plugin system with secure class loading

import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class SecurePluginLoader {
    private static final Path PLUGIN_DIR = Paths.get("/opt/app/plugins").toAbsolutePath();

    public Object loadPlugin(String pluginName, Class<?> pluginInterface) {
        // Validate plugin name (alphanumeric only)
        if (!pluginName.matches("^[a-zA-Z0-9_-]+$")) {
            throw new SecurityException("Invalid plugin name");
        }

        Path pluginPath = PLUGIN_DIR.resolve(pluginName + ".jar").normalize();

        // Verify path integrity
        if (!pluginPath.startsWith(PLUGIN_DIR)) {
            throw new SecurityException("Path traversal attempt");
        }

        if (!Files.isRegularFile(pluginPath)) {
            throw new SecurityException("Plugin file not found");
        }

        // A separate loader keeps the plugin's classes out of the application
        // classloader so the plugin can later be discarded. Close it when done -
        // an unclosed URLClassLoader holds the jar open and leaks on redeploy.
        try (URLClassLoader classLoader = new URLClassLoader(
                new URL[]{pluginPath.toUri().toURL()},
                this.getClass().getClassLoader())) {

            String className = "com.app.plugins." + pluginName + ".Plugin";

            // loadClass does not run the class's static initialiser; the interface
            // check below therefore happens before any plugin code executes.
            Class<?> pluginClass = classLoader.loadClass(className);

            if (!pluginInterface.isAssignableFrom(pluginClass)) {
                throw new SecurityException("Plugin doesn't implement required interface");
            }

            return pluginClass.getDeclaredConstructor().newInstance();

        } catch (Exception e) {
            throw new SecurityException("Failed to load plugin", e);
        }
    }
}

Why this works:

  • Name validation enforces an allowlist-safe format, so the jar name cannot be a path.
  • Path normalization and the startsWith check keep the jar inside the plugin directory.
  • Loading through a child URLClassLoader rather than the application classloader keeps the plugin's classes separately namespaced and discardable.
  • loadClass resolves the class without initialising it, so the interface check runs before any plugin code does.

Closing the loader assumes the plugin's whole class graph loads eagerly. The try-with-resources block closes classLoader as soon as the constructor call above returns, which is safe for the interface check and construction because both already ran against classes the loader has already resolved. It is not safe for a class the plugin references only from a method body that hasn't executed yet - a helper class, a lambda's synthetic class, anything not touched during construction - because the jar is already closed by the time that code path runs, and the reference fails with NoClassDefFoundError. If a plugin's classes are not guaranteed to load eagerly, keep the loader open until the plugin instance itself is discarded (track both together and close them as a pair) instead of closing it here.

What this does not do: a separate classloader is a namespace boundary, not a security boundary. Once instantiated, the plugin runs with the full privileges of the JVM - it can read any file the process can, open sockets, and call System.exit. SecurityManager was the mechanism that used to constrain it; it is deprecated for removal by JEP 411 and permanently disabled since Java 24, so -Djava.security.manager no longer provides anything to fall back on. Signature or hash verification, as in the previous example, is what establishes that the jar is the one you shipped. If a plugin genuinely needs to be contained rather than authenticated, run it in its own JVM under a restricted OS account or in a container, and talk to it over IPC.

Considerations

The strongest fix is not accepting the name at all. Every pattern above assumes the application must map some request value onto a library, plugin or binary. Where the set is fixed and known at build time, drop the parameter and call the loader with a constant - an allowlist that is consulted once, at compile time, cannot be bypassed. Reach for the allowlist-and-validate shape only when the choice genuinely varies at runtime.

Hash pinning versus filesystem permissions. SecureJNILoader pins a SHA-256 per library. That is worth the maintenance cost when the library directory is writable by anything other than root or the deployment process - a shared host, a container with a writable bind mount, an installer that runs as the service account. Where the directory is owned by root and the service runs unprivileged, the permissions already give you what the hash gives you, and the pin turns every library upgrade into a code change. Pick one; carrying both and updating neither is the usual outcome.

A hash check is inherently time-of-check-to-time-of-use: the file is read once to hash it and again by System.load(). That gap only matters if an attacker can write to the directory between the two - which is the same condition that made the hash worth adding. Where it matters, open the file once and hold the descriptor, or set the permissions so the race cannot be run.

java.library.path is set by whoever starts the JVM. System.loadLibrary() is only a weakness when someone other than the operator controls that property or the environment the JVM inherits. On a container image with a fixed entrypoint, it is usually fine, and swapping it for System.load() with a hardcoded absolute path is a small clarity win rather than a fix. On a shared host, or anywhere the startup command is assembled from configuration a user can edit, it is the whole vulnerability. Decide which of the two you are looking at before recording a finding.

Argument injection survives the switch to ProcessBuilder. Moving off Runtime.exec(String) removes the tokenisation surprise; it does not stop an attacker-supplied value being read as an option by the program you invoke. Whether that matters depends entirely on the program: echo has no dangerous options, convert, tar, ssh, curl and find all do. Where the value is a filename, -- before the positional arguments settles it. Where the program has no --, resolve the value against a fixed base directory so it can never begin with -.

Testing

A scanner rule for System.loadLibrary or Runtime.exec goes quiet the moment the call changes shape, whether or not the replacement works. Two failures here are invisible to any re-scan: a path validator that rejects every legitimate output file, and a test written against a Runtime.exec shell payload that never fires in the first place. Assert both directions.

  • convertImage("photo.jpg", "photo.png") completes and photo.png exists in OUTPUT_DIR. If it throws SecurityException: Not a regular file, the output is being validated as though it already exists - the most common way this fix ships broken.
  • convertImage("../../etc/passwd", "out.png") throws SecurityException, and so does convertImage("photo.jpg", "../../tmp/out.png"). Both directions, not just the input.
  • resolveExistingInput rejects a file in a sibling directory named /opt/app/uploads-public/x.jpg. Path.startsWith handles this correctly; the assertion is there to catch a later refactor to String.startsWith, which does not.
  • loadLibrary("libcrypto.so") succeeds and loadLibrary("../../../tmp/evil.so") throws. A test that asserts only the second passes identically against a method that throws unconditionally.
  • Run Runtime.getRuntime().exec("cmd /c echo a; echo B") once and read the output. It prints a; echo B on one line, not two - confirming the tokenisation is whitespace-only and that a ; payload here is argument injection rather than command injection. Worth doing before writing a test that asserts a shell payload is blocked, because it was never live.
  • With a pinned hash, corrupt one byte of the library file and assert loadTrustedLibrary throws before System.load is reached. A hash check placed after the load still satisfies a test that only looks for the exception.

Additional Resources