How to read stdout from an external process in Java 17

dimitrilc 3 Tallied Votes 1K Views Share

Introduction

Although not included in the headlines, the release of JDK 17 also added 3 sets of new methods to the class java.lang.Process:

  1. inputReader() to read from stdout.
  2. inputWriter() to write to stdin.
  3. errorReader() to read from stderr.

In this tutorial, we are going to learn how to use Process.inputReader() to read stdout from external processes.

Goals

At the end of this tutorial, you would have learned:

  1. How to read stdout from an external process.
  2. What command injection is.

Prerequisite Knowledge

  1. Basic Java.
  2. Java IO/NIO.

Tools Required

  1. A Java IDE with at least JDK 17 support.

Project Setup

To follow along with this tutorial, perform the steps below:

  1. Create a new Java project.

  2. Create a package com.example.

  3. Create a Java class called Entry.java. This is where our main() method lives.

  4. Create a static reference to the current Runtime object like below.

     private static final Runtime runtime = Runtime.getRuntime();

Read stdout from scripts

If the security policy allows it, it is possible to execute a script that is not part of the current Java runtime environment.

Executing scripts written in other programming languages or system shells can also be used for illegitimate purposes, such as in a code injection/shell injection attack. After an external script is run, it can potentially evade your Java application’s permissions. If you are running a service that is executing arbitrary code from end users(e.g Slack/Discord bots that execute code), then you need to make sure that proper safeguards are in place.

Without further ado, let us create some code that can both create and execute an external python script.

Inside the Entry class, we will create two new methods, createPyScript() and execPyScript(). Copy and paste the code below:

    private static Path createPyScript(Path path, String content){ //1
       //Creates the script if does not exists, else do nothing.
       try {
           Files.writeString(path, content, StandardOpenOption.CREATE); //2
       } catch (IOException e) {
           e.printStackTrace();
       }

       return path; //3
    }

    private static void execPyScript(Path script){ //4
       try {
           Process process = runtime.exec("python " + script); //5

           try (BufferedReader reader = process.inputReader()){ //6
               reader.lines() //7
                       .forEach(System.out::println); //8
           }

       } catch (IOException e) {
           e.printStackTrace();
       }
    }

The first method,createPyScript(), creates the actual script file in the file system. We only need to pass to it the content of the script file and the Path object representing the file location and name. To keep our example simple and not having to perform existence checks/removal, the StandardOpenOption.CREATE enum passed to the method makes sure that a new file is created only if it does not exist (but the file is still subject to the default writing behavior of Files.writeString()).

The second method, execPyScript(), receives the Path object representing the file location, and then executes that script:

  1. There are two ways to execute shell commands from Java, using a ProcessBuilder or a Runtime object. Line 5 uses the application Runtime object, whose exec() method executes the system command calling the python executable.
  2. Line 5 also gives us a reference to the Process object. Runtime.exec() spawns a new subprocess, which we can control via this Process object.
  3. On line 6, we declared a BufferedReader object in a try-with-resources block to retrieve sdout from the Python script.
  4. On lines 7 and 8, we retrieved a Stream<String> of all the lines printed to process stdout and printed them out in our IDE.

In main(), call the code like below:

    String pyContent = """
               for i in range(1, 6):
                   print(f'Found secret {i}')
               """; //15
    Path path = Path.of(".", "test.py"); //16

    Path script = createPyScript(path, pyContent); //17
    execPyScript(script); //18

On line 15, we create the script file content using the convenient new multiline String syntax. Python code is obviously white-space aware, so it is important to understand how to use the multiline syntax correctly in this case.

The Python code is just a very simple for loop that uses the f-string Literal String Interpolation (PEP 498) syntax to print some text 5 times.

Line 17 is where we call the method to create the script. Line 18 is where we call the method to execute the python script created on line 17.

The code prints:

    Found secret 1
    Found secret 2
    Found secret 3
    Found secret 4
    Found secret 5

Read stdout from system commands

Using a very similar approach, it is also possible to read stdout from system commands. Command injections are even easier (for attackers) to perform compared to the previous approach as this does even require write permissions(to create our own script).

Inside the Entry class, create an execSysCommands() method that can execute any number of shell commands.

    private static void execSysCommands(String... commands){ //9
       try {
           for (String command : commands){ //10
               Process process = runtime.exec(command); //11

               try (BufferedReader reader = process.inputReader()){ //12
                   reader.lines() //13
                           .forEach(System.out::println); //14
               }

           }
       } catch (IOException e) {
           e.printStackTrace();
       }
    }

In this scenario, we can pass the command directly to runtime.exec() without having to call an executable such as “python”.

Python is not pre-installed on Windows like most Linux distributions, so attackers are unlikely to call it if they found out that your server is Windows. Instead, they are going to use some commands that came pre-installed in Windows systems.

In main(), create some commands and call the execSysCommands() method like below:

    String ipconfig = "ipconfig /all";
    String systeminfo = "systeminfo";
    execSysCommands(ipconfig, systeminfo);

And with just two commands, the attacker was able to retrieve some of your networking information.

Solution Code

    package com.example;

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.StandardOpenOption;

    public class Entry {

       private static final Runtime runtime = Runtime.getRuntime();

       public static void main(String[] args) {
           String pyContent = """
                       for i in range(1, 6):
                           print(f'Found secret {i}')
                       """; //15
           Path path = Path.of(".", "test.py"); //16

           Path script = createPyScript(path, pyContent); //17
           execPyScript(script); //18

           String ipconfig = "ipconfig /all";
           String systeminfo = "systeminfo";
           execSysCommands(ipconfig, systeminfo);
       }

       private static Path createPyScript(Path path, String content){ //1
           //Creates the script if does not exists, else do nothing.
           try {
               Files.writeString(path, content, StandardOpenOption.CREATE); //2
           } catch (IOException e) {
               e.printStackTrace();
           }

           return path; //3
       }

       private static void execPyScript(Path script){ //4
           try {
               Process process = runtime.exec("python " + script); //5

               try (BufferedReader reader = process.inputReader()){ //6
                   reader.lines() //7
                           .forEach(System.out::println); //8
               }

           } catch (IOException e) {
               e.printStackTrace();
           }
       }

       private static void execSysCommands(String... commands){ //9
           try {
               for (String command : commands){ //10
                   Process process = runtime.exec(command); //11

                   try (BufferedReader reader = process.inputReader()){ //12
                       reader.lines() //13
                               .forEach(System.out::println); //14
                   }

               }
           } catch (IOException e) {
               e.printStackTrace();
           }
       }

    }

Summary

We have learned how to read stdout from an external process using the brand new inputReader() methods in JDK 17. If your app allows remote code execution(like a cloud IDE), then it is important to look into ways to secure your environment properly.

The full project code can be found here https://github.com/dmitrilc/DaniWebProcessStdout