Implementing a custom Listener Plugin
As part of this tutorial, we will create a simple TCP listener where the listener in server is waiting for incoming connections from agents. Agents will have a shellcode which will establish a TCP connection to the listener and starts sending and receiving data to and from the server.
Most of the details about actually establishing TCP connections in this example are omitted. This example focuses only on specifics of Tuoni SDK.
We'll first implement a ListenerPlugin which will provide the configuration schema, example configurations and logic to create the listener object.
| public class TcpListenerPlugin implements ListenerPlugin {
@Override
public ConfigurationSchema getConfigurationSchema() throws SerializationException {
return myJsonLibrary.createSchema("""
{
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "IP address or hostname for the shellcode to connect to"
},
"port": {
"type": "number",
"description": "Port for the shellcode to connect to"
}
"listenPort": {
"type": "number",
"description": "Port for the listener to listen on"
}
},
"required": ["address", "port", "listenPort"]
}
""");
}
@Override
public Listener create(long listenerId, Configuration configuration,
ListenerContext listenerContext) throws InitializationException, ValidationException {
var tcpConf = myJsonLibrary.parse(configuration);
return new TcpListener(listenerId, listenerContext, new TcpServer(tcpConf.address()), tcpConf.port(),
tcpConf.listenPort());
}
@Override
public void init(ListenerPluginContext pluginContext) throws InitializationException {
}
}
|
Since we're implementing a shellcode listener, then TcpListener
will need to implement the ShellcodeListener
interface. The ShellcodeListener
interface provides methods for generating shellcode and listening for incoming connections.
| public class TcpListener implements ShellcodeListener{
private final long listenerId;
private final ListenerContext listenerContext;
private final TcpServer tcpServer;
private final String address;
private final int port;
private ListenerStatus listenerStatus = ListenerStatus.CREATED;
public TcpListener(
long listenerId,
ListenerContext listenerContext,
TcpServer tcpServer,
String address,
int port) {
this.listenerId = listenerId;
this.listenerContext = listenerContext;
this.tcpServer = tcpServer;
this.address = address;
this.port = port;
tcpServer.onNewConnection((connection, initialBytes) -> {
Optional<AgentMetadata> agentMetadata = listenerContext.readMetadata(initialBytes);
if (agentMetadata.isEmpty()) {
// Invalid first message, close the connection
connection.close();
return;
}
registerNewAgent(listenerContext, connection, agentMetadata);
});
tcpServer.onNewData((connection, metadataBytes, dataBytes) -> {
Agent agent = connection.getAgent();
Optional<AgentMetadata> metadata = listenerContext.readMetadata(metadataBytes);
if (metadata.isEmpty()) {
return;
}
agent.submitSerializedRequest(metadata.get(), dataBytes);
});
}
@Override
public ShellCodeWithConf generateShellCode(String pipeName, PayloadType payloadType)
throws SerializationException {
// Read and manipulate the shellcode
var shellCodeBytes = MyShellCodeUtil.readShellcode("tcp")
.replaceDefaultPipeNameWith(pipeName)
.asByteBuffer();
return new ShellCodeWithConf(
shellCodeBytes,
MyShellCodeUtil.serializeForShellcode(address, port),
PluginIpcType.NAMED_PIPE);
}
@Override
public ByteBuffer serializeUpdatedConfiguration(Configuration configuration)
throws SerializationException {
// Updating shellcode configuration after startup is unsupported
return ByteBuffer.allocate(0);
}
@Override
public String getInfo() {
return "listener-tcp[id=" + listenerId + "]: " + getStatus();
}
@Override
public ListenerStatus getStatus() {
return status;
}
@Override
public void start() throws ExecutionException {
if (this.status == ListenerStatus.DELETED) {
throw new ExecutionException("listener=%d is deleted".formatted(listenerId));
}
this.status = ListenerStatus.STARTED;
tcpServer.start();
}
@Override
public void stop() throws ExecutionException {
if (this.status == ListenerStatus.DELETED) {
throw new ExecutionException("listener=%d is deleted".formatted(listenerId));
}
tcpServer.stop();
this.status = ListenerStatus.STOPPED;
}
@Override
public void delete() throws ExecutionException {
tcpServer.stop();
this.status = ListenerStatus.DELETED;
}
@Override
public Listener reconfigure(Configuration newConfiguration)
throws ExecutionException, SerializationException, ValidationException {
var tcpConf = myJsonLibrary.parse(newConfiguration);
return new TcpListener(listenerId, listenerContext, tcpServer.reconfigure(tcpConf.listenPort()),
address, port);
}
private void registerNewAgent(ListenerContext listenerContext, TcpConnection connection,
AgentMetadata agentMetadata)
throws AgentRegistrationException {
Agent agent = listenerContext.registerAgent(agentMetadata.guid(), agentMetadata);
connection.setAgent(agent);
agent.getCommandQueue().subscribe(new Subscriber<>() {
@Override
public void onSubscribe(Subscription subscription) {
subscription.request(Long.MAX_VALUE);
connection.onClose(() -> subscription.cancel());
}
@Override
public void onNext(SerializedCommand command) {
try {
connection.send(command.getSerializedBytes());
command.setSendStatus(SendStatus.successful(Instant.now()));
} catch (Exception e) {
command.setSendStatus(SendStatus.failure(e, Instant.now(), true));
}
}
@Override
public void onError(Throwable throwable) {
// depending on plugin logic, either log error, retry subscription or cleanup resources
}
@Override
public void onComplete() {
// cleanup resources
}
});
}
}
|
How does this plugin work?
We'll assume that all the omitted parts are implemented correctly and the plugin JAR is bundled correctly with manifest, service files, built and deployed to the Tuoni server plugins directory.
In that case when starting Tuoni server, for shellcode agents, the server will load the plugin and the user can create a new listener over the REST API with the name given in the plugin manifest`.
The listener will go through the following stages:
- Server starts - Tuoni server calls
TcpListenerPlugin#init
.
- User asks for available listener plugins over the REST API - Tuoni server returns information about the
TcpListenerPlugin
which includes info from manifest and also calling TcpListenerPlugin#getConfigurationSchema
and TcpListenerPlugin#getExampleConfigurations
.
- Listener created over REST API - User provides configuration for the listener. Tuoni server calls
TcpListener#create
with the configuration. If the configuration is valid, the listener is created and server will call TcpListener#start
to start the listener.
- Listener starts the TCP server - The listener starts the TCP server and waits for incoming connections.
- When a connection is established, the listener reads data according to some data structure and attempts to parse the metadata from the incoming data (
ListenerContext#readMetadata
).
- If metadata is present, the listener registers a new agent with the server (
ListenerContext#registerAgent
).
- It will then start to listen for commands that the user will queue for the agent (
Agent#getCommandQueue#subscribe
).
- If a command is created and queued, it will be triggered by the subscriber and the listener will send the serialized command TLV bytes to the agent (
SerializedCommand#getSerializedBytes
).
- Listener marks the command as sent or failed (
SerializedCommand#setSendStatus
) and the agent will execute the command.
- The agent will send the result back to the server and the listener will receive the result bytes and pass it to server using
Agent#submitSerializedRequest
.