Skip to content

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:

  1. Server starts - Tuoni server calls TcpListenerPlugin#init.
  2. 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 .
  3. 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.
  4. Listener starts the TCP server - The listener starts the TCP server and waits for incoming connections.
  5. 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).
  6. If metadata is present, the listener registers a new agent with the server (ListenerContext#registerAgent).
  7. It will then start to listen for commands that the user will queue for the agent (Agent#getCommandQueue#subscribe).
  8. 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).
  9. Listener marks the command as sent or failed (SerializedCommand#setSendStatus) and the agent will execute the command.
  10. 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.