Developing Plugins
SSH-MITM is built around a modular plugin system. Every major component — session handling, authentication, SSH/SCP/SFTP forwarding, NETCONF, and port forwarding — is a replaceable plugin. This guide explains the architecture and shows how to write your own plugins.
Class Diagram
The diagram below shows the complete plugin class hierarchy. Every plugin class
ultimately inherits from SSHMITMBaseModule, which provides argument
parsing, entry-point discovery, and configuration-file support.
classDiagram
direction TB
class BaseModule {
+parser_arguments()$
+argument_group()$
+args
}
class SSHMITMBaseModule {
+entry_point_prefix = "sshmitm"
}
class BaseForwarder {
+session
}
class ExecForwarder {
+handle_client_data(data)
+handle_server_data(data)
+handle_error(data)
+close_session(channel)
}
class SCPBaseForwarder {
+rewrite_scp_command(command)
+register_exec_handler()$
}
class SCPForwarder
class NetconfBaseForwarder
class NetconfForwarder
class SSHBaseForwarder {
+handle_client_data(data)
+handle_server_data(data)
+handle_server_error(data)
}
class SSHForwarder
class SSHMirrorForwarder
class SFTPHandlerBasePlugin {
+handle_data(data, offset, length)
+close()
}
class SFTPHandlerPlugin
class BaseSFTPServerInterface
class SFTPProxyServerInterface
class LocalPortForwardingBaseForwarder {
+setup(session)$
}
class LocalPortForwardingForwarder
class SOCKSTunnelForwarder
class RemotePortForwardingBaseForwarder
class RemotePortForwardingForwarder
class InjectableRemotePortForwardingForwarder
class Authenticator {
+get_auth_methods()
+authenticate()
}
class AuthenticatorPassThrough
class AuthenticatorRemote
class BaseServerInterface
class ServerInterface
class BaseSession
class Session
class MoshForwarder
BaseModule <|-- SSHMITMBaseModule
SSHMITMBaseModule <|-- BaseForwarder
BaseForwarder <|-- ExecForwarder
BaseForwarder <|-- SSHBaseForwarder
ExecForwarder <|-- SCPBaseForwarder
SCPBaseForwarder <|-- SCPForwarder
ExecForwarder <|-- NetconfBaseForwarder
NetconfBaseForwarder <|-- NetconfForwarder
ExecForwarder <|-- MoshForwarder
SSHBaseForwarder <|-- SSHForwarder
SSHForwarder <|-- SSHMirrorForwarder
SSHMITMBaseModule <|-- SFTPHandlerBasePlugin
SFTPHandlerBasePlugin <|-- SFTPHandlerPlugin
SSHMITMBaseModule <|-- BaseSFTPServerInterface
BaseSFTPServerInterface <|-- SFTPProxyServerInterface
SSHMITMBaseModule <|-- LocalPortForwardingBaseForwarder
LocalPortForwardingBaseForwarder <|-- LocalPortForwardingForwarder
LocalPortForwardingForwarder <|-- SOCKSTunnelForwarder
SSHMITMBaseModule <|-- RemotePortForwardingBaseForwarder
RemotePortForwardingBaseForwarder <|-- RemotePortForwardingForwarder
RemotePortForwardingForwarder <|-- InjectableRemotePortForwardingForwarder
SSHMITMBaseModule <|-- Authenticator
Authenticator <|-- AuthenticatorPassThrough
Authenticator <|-- AuthenticatorRemote
SSHMITMBaseModule <|-- BaseServerInterface
BaseServerInterface <|-- ServerInterface
SSHMITMBaseModule <|-- BaseSession
BaseSession <|-- Session
Architecture Overview
All plugins inherit from SSHMITMBaseModule
(sshmitm.core.modules.SSHMITMBaseModule), which itself extends BaseModule
from the module parser. The base class provides:
Argument parsing —
parser_arguments()registers CLI flags;self.argsexposes the parsed values at runtime.Entry-point discovery — SSH-MITM finds plugins by scanning
[project.entry-points."sshmitm.<BaseClassName>"]groups registered inpyproject.toml.Configuration file support — every CLI argument can alternatively be set in an INI file; the section name is derived from the fully-qualified class name.
Instantiation — the server passes a
Session(or equivalent context object) to each plugin’s__init__.
A minimal plugin looks like this:
from sshmitm.forwarders.ssh import SSHForwarder
class MySSHPlugin(SSHForwarder):
@classmethod
def parser_arguments(cls) -> None:
group = cls.argument_group()
group.add_argument("--my-option", dest="my_option", help="...")
def __init__(self, session):
super().__init__(session)
# self.args.my_option is available here
Registering a Plugin
Plugins are discovered via Python entry points. Add an entry to your package’s
pyproject.toml:
[project.entry-points."sshmitm.SSHBaseForwarder"]
my-plugin = "mypkg.myplugin:MySSHPlugin"
The key (my-plugin) is the name used on the command line:
ssh-mitm server --ssh-interface my-plugin
After adding the entry point, reinstall your package so the entry point is registered:
$ pip install /path/to/your/plugin/
The entry-point group must match the base class name of the plugin type you are extending:
Entry-point group |
Plugin type |
|---|---|
|
SSH terminal session forwarder ( |
|
SCP file-transfer forwarder ( |
|
NETCONF subsystem forwarder ( |
|
SFTP file-transfer handler ( |
|
SFTP server interface ( |
|
Local port-forwarding handler — |
|
Remote port-forwarding handler — |
|
Authentication handler ( |
|
SSH server interface ( |
|
Session handler ( |
|
Exec command handler (e.g. Mosh); registered via |
Plugin Types
SSH Forwarder Plugins
Base class: sshmitm.forwarders.ssh.SSHForwarder
CLI argument: --ssh-interface
Entry-point group: sshmitm.SSHBaseForwarder
SSH forwarder plugins intercept the interactive terminal session between the SSH client and the remote server. Override the stream hooks to read or modify data in-flight.
classDiagram
direction LR
class SSHBaseForwarder["SSHBaseForwarder\n«base»"]
class SSHForwarder["SSHForwarder\n«default»"]
class SSHMirrorForwarder["SSHMirrorForwarder\n«built-in plugin»"]
SSHBaseForwarder <|-- SSHForwarder
SSHForwarder <|-- SSHMirrorForwarder
Method |
Purpose |
|---|---|
|
Data typed by the user (client → server). Return the bytes to forward. |
|
Output from the remote server (server → client). Return the bytes to forward. |
|
Error output from the remote server. Return the bytes to forward. |
|
Called when the session closes. Clean up resources here. |
Example — log all terminal output to a file:
from sshmitm.forwarders.ssh import SSHForwarder
class LoggingSSHForwarder(SSHForwarder):
"""Logs all terminal output to a file"""
@classmethod
def parser_arguments(cls) -> None:
group = cls.argument_group()
group.add_argument(
"--log-file",
dest="log_file",
required=True,
help="path to the log file",
)
def __init__(self, session) -> None:
super().__init__(session)
self._log = open(self.args.log_file, "ab") # noqa: SIM115
def handle_server_data(self, data: bytes) -> bytes:
self._log.write(data)
return data
def close_session(self, channel) -> None:
super().close_session(channel)
self._log.close()
Registration:
[project.entry-points."sshmitm.SSHBaseForwarder"]
logging = "mypkg.ssh_log:LoggingSSHForwarder"
Usage:
ssh-mitm server --ssh-interface logging --log-file /tmp/session.log
SCP Forwarder Plugins
Base class: sshmitm.forwarders.scp.SCPForwarder
CLI argument: --scp-interface
Entry-point group: sshmitm.SCPBaseForwarder
SCP forwarder plugins intercept Secure Copy (SCP) file transfers. The SCP protocol wraps file metadata and content in a simple byte stream; the base class parses it and exposes higher-level hooks.
classDiagram
direction LR
class ExecForwarder["ExecForwarder"]
class SCPBaseForwarder["SCPBaseForwarder\n«base»"]
class SCPForwarder["SCPForwarder\n«default»"]
class SCPDebugForwarder["SCPDebugForwarder\n«built-in»"]
class SCPInjectFile["SCPInjectFile\n«built-in»"]
class SCPReplaceFile["SCPReplaceFile\n«built-in»"]
class SCPStorageForwarder["SCPStorageForwarder\n«built-in»"]
class SCPRewriteCommand["SCPRewriteCommand\n«built-in»"]
class CVE202229154["CVE202229154\n«built-in»"]
ExecForwarder <|-- SCPBaseForwarder
SCPBaseForwarder <|-- SCPForwarder
SCPForwarder <|-- SCPDebugForwarder
SCPForwarder <|-- SCPInjectFile
SCPForwarder <|-- SCPReplaceFile
SCPForwarder <|-- SCPStorageForwarder
SCPForwarder <|-- SCPRewriteCommand
SCPForwarder <|-- CVE202229154
Method |
Purpose |
|---|---|
|
Raw bytes from the client (client → server). Return the bytes to forward. |
|
Raw bytes from the server (server → client). Return the bytes to forward. |
|
Modify the SCP shell command before it is executed on the server. |
|
Called when the remote side sends an error response. |
Useful attributes set by the base class after the first control command:
self.file_command—"C"(file) or"D"(directory)self.file_mode— Unix permission string (e.g."0644")self.file_size— file size in bytesself.file_name— destination file name
Example — print a hexdump of all SCP traffic:
from sshmitm.forwarders.scp import SCPForwarder
from sshmitm.utils import format_hex
class SCPDebugForwarder(SCPForwarder):
"""Prints SCP traffic as a hexdump"""
def handle_client_data(self, data: bytes) -> bytes:
print("[SCP] client → server:")
print(format_hex(data))
return super().handle_client_data(data)
def handle_server_data(self, data: bytes) -> bytes:
print("[SCP] server → client:")
print(format_hex(data))
return super().handle_server_data(data)
Registration:
[project.entry-points."sshmitm.SCPBaseForwarder"]
debug = "mypkg.scp_debug:SCPDebugForwarder"
Usage:
ssh-mitm server --scp-interface debug
Netconf Forwarder Plugins
Base class: sshmitm.forwarders.netconf.NetconfForwarder
CLI argument: --netconf-interface
Entry-point group: sshmitm.NetconfBaseForwarder
NETCONF (RFC 6241) is an XML-based network management protocol that runs as an
SSH subsystem (ssh -s netconf). It is widely used to configure routers,
switches, and other network devices from vendors such as Cisco, Juniper, and
Nokia.
NETCONF forwarder plugins intercept the XML RPCs exchanged between the management client and the target network device. Override the stream hooks to inspect or modify the NETCONF messages in-flight.
classDiagram
direction LR
class ExecForwarder["ExecForwarder"]
class NetconfBaseForwarder["NetconfBaseForwarder\n«base»"]
class NetconfForwarder["NetconfForwarder\n«default»"]
ExecForwarder <|-- NetconfBaseForwarder
NetconfBaseForwarder <|-- NetconfForwarder
Method |
Purpose |
|---|---|
|
NETCONF RPC from the client (client → device). Return the bytes to forward. |
|
NETCONF response from the device (device → client). Return the bytes to forward. |
|
Called when the remote side sends an error response. |
|
Called when the NETCONF session closes. |
Example — log all NETCONF RPC messages:
import logging
from sshmitm.forwarders.netconf import NetconfForwarder
class LoggingNetconfForwarder(NetconfForwarder):
"""Logs all NETCONF RPC traffic"""
def handle_client_data(self, data: bytes) -> bytes:
logging.info("[NETCONF] client→device: %s", data.decode(errors="replace"))
return super().handle_client_data(data)
def handle_server_data(self, data: bytes) -> bytes:
logging.info("[NETCONF] device→client: %s", data.decode(errors="replace"))
return super().handle_server_data(data)
Registration:
[project.entry-points."sshmitm.NetconfBaseForwarder"]
logging = "mypkg.netconf_log:LoggingNetconfForwarder"
Usage:
ssh-mitm server --netconf-interface logging
SFTP Handler Plugins
Base class: sshmitm.forwarders.sftp.SFTPHandlerPlugin
CLI argument: --sftp-handler
Entry-point group: sshmitm.SFTPHandlerBasePlugin
SFTP handler plugins are instantiated per file transfer. The plugin receives every chunk of file data as it passes through and can inspect, modify, or store it.
classDiagram
direction LR
class SFTPHandlerBasePlugin["SFTPHandlerBasePlugin\n«base»"]
class SFTPHandlerPlugin["SFTPHandlerPlugin\n«default»"]
class SFTPHandlerStoragePlugin["SFTPHandlerStoragePlugin\n«built-in»"]
class SFTPProxyReplaceHandler["SFTPProxyReplaceHandler\n«built-in»"]
class SFTPHandlerCheckFilePlugin["SFTPHandlerCheckFilePlugin\n«built-in»"]
SFTPHandlerBasePlugin <|-- SFTPHandlerPlugin
SFTPHandlerPlugin <|-- SFTPHandlerStoragePlugin
SFTPHandlerPlugin <|-- SFTPProxyReplaceHandler
SFTPHandlerPlugin <|-- SFTPHandlerCheckFilePlugin
Method |
Purpose |
|---|---|
|
Called for every block of file data. Return the bytes to forward. |
|
Called when the file transfer completes. Release file handles here. |
|
Return a custom |
The plugin’s __init__ receives:
sftp— theSFTPBaseHandlefor the current file;sftp.sessiongives access to the activeSession.filename— the remote file path being transferred.
Example — store every transferred file:
import os
from sshmitm.forwarders.sftp import SFTPBaseHandle, SFTPHandlerPlugin
class SFTPStoragePlugin(SFTPHandlerPlugin):
"""Saves every SFTP file transfer to disk"""
@classmethod
def parser_arguments(cls) -> None:
group = cls.argument_group()
group.add_argument(
"--sftp-store-dir",
dest="sftp_store_dir",
default="/tmp/sftp-captures",
help="directory to store captured files",
)
def __init__(self, sftp: SFTPBaseHandle, filename: str) -> None:
super().__init__(sftp, filename)
os.makedirs(self.args.sftp_store_dir, exist_ok=True)
safe_name = os.path.basename(filename) or "unnamed"
dest = os.path.join(self.args.sftp_store_dir, safe_name)
self._out = open(dest, "wb") # noqa: SIM115
def handle_data(self, data: bytes, *, offset=None, length=None) -> bytes:
self._out.write(data)
return data
def close(self) -> None:
self._out.close()
Registration:
[project.entry-points."sshmitm.SFTPHandlerBasePlugin"]
store = "mypkg.sftp_store:SFTPStoragePlugin"
Usage:
ssh-mitm server --sftp-handler store --sftp-store-dir /tmp/captures
Providing a custom SFTP server interface
If your plugin also needs to intercept SFTP protocol-level operations (e.g.,
to lie about a file’s size before the client requests it), define a nested
class that extends SFTPProxyServerInterface and return it from
get_interface():
from sshmitm.forwarders.sftp import SFTPHandlerPlugin
from sshmitm.interfaces.sftp import SFTPProxyServerInterface
class MyHandler(SFTPHandlerPlugin):
class CustomSFTPInterface(SFTPProxyServerInterface):
def stat(self, path):
attrs = super().stat(path)
# modify attrs here
return attrs
@classmethod
def get_interface(cls):
return cls.CustomSFTPInterface
def handle_data(self, data, *, offset=None, length=None) -> bytes:
return data
def close(self) -> None:
pass
Local Port Forwarding Plugins
Base class: sshmitm.forwarders.tunnel.LocalPortForwardingForwarder
CLI argument: --local-port-forwarder
Entry-point group: sshmitm.LocalPortForwardingBaseForwarder
Local port forwarding plugins handle connections that the SSH client opens
towards the server (ssh -L). LocalPortForwardingForwarder uses multiple
inheritance — it combines TunnelForwarder (a bidirectional threading
forwarder) with the plugin base class.
classDiagram
direction LR
class TunnelForwarder["TunnelForwarder\n(threading.Thread)"]
class LocalPortForwardingBaseForwarder["LocalPortForwardingBaseForwarder\n«base»"]
class LocalPortForwardingForwarder["LocalPortForwardingForwarder\n«default»"]
class SOCKSTunnelForwarder["SOCKSTunnelForwarder\n«built-in plugin»"]
TunnelForwarder <|-- LocalPortForwardingForwarder
LocalPortForwardingBaseForwarder <|-- LocalPortForwardingForwarder
LocalPortForwardingForwarder <|-- SOCKSTunnelForwarder
The most important hook is the class-level setup() method, which is called
once when a session is established, before any connections arrive. Use it to
start background threads or TCP listeners.
from typing import ClassVar
from sshmitm.forwarders.tunnel import LocalPortForwardingForwarder
from sshmitm.plugins.session.tcpserver import TCPServerThread
class MyTunnelPlugin(LocalPortForwardingForwarder):
"""Intercepts local port forwarding connections"""
servers: ClassVar[list] = []
@classmethod
def parser_arguments(cls) -> None:
group = cls.argument_group()
group.add_argument(
"--listen-address",
dest="listen_address",
default="127.0.0.1",
help="address to listen on",
)
@classmethod
def setup(cls, session) -> None:
args, _ = cls.parser().parse_known_args(None, None)
thread = TCPServerThread(
lambda addr, client, remote: cls._handle(session, addr, client, remote),
network=args.listen_address,
)
thread.start()
cls.servers.append(thread)
@classmethod
def _handle(cls, session, addr, client, remote) -> None:
# custom forwarding logic
pass
Registration:
[project.entry-points."sshmitm.LocalPortForwardingBaseForwarder"]
my-tunnel = "mypkg.tunnel:MyTunnelPlugin"
Usage:
ssh-mitm server --local-port-forwarder my-tunnel
Remote Port Forwarding Plugins
Base class: sshmitm.forwarders.tunnel.RemotePortForwardingForwarder
CLI argument: --remote-port-forwarder
Entry-point group: sshmitm.RemotePortForwardingBaseForwarder
Remote port forwarding plugins handle connections that the SSH server opens
back towards the client (ssh -R). The structure mirrors the local
forwarding plugin above.
classDiagram
direction LR
class RemotePortForwardingBaseForwarder["RemotePortForwardingBaseForwarder\n«base»"]
class RemotePortForwardingForwarder["RemotePortForwardingForwarder\n«default»"]
class InjectableRemotePortForwardingForwarder["InjectableRemotePortForwardingForwarder\n«built-in plugin»"]
RemotePortForwardingBaseForwarder <|-- RemotePortForwardingForwarder
RemotePortForwardingForwarder <|-- InjectableRemotePortForwardingForwarder
Registration:
[project.entry-points."sshmitm.RemotePortForwardingBaseForwarder"]
my-remote = "mypkg.remote_tunnel:MyRemoteTunnelPlugin"
Usage:
ssh-mitm server --remote-port-forwarder my-remote
Authenticator Plugins
Base class: sshmitm.authentication.Authenticator
CLI argument: --authenticator
Entry-point group: sshmitm.Authenticator
Authenticator plugins control how SSH-MITM validates client credentials and how it connects to the upstream server. The default implementation performs a transparent pass-through: it replays the client’s credentials against the real server and accepts if the server accepts.
classDiagram
direction LR
class Authenticator["Authenticator\n«base»"]
class AuthenticatorPassThrough["AuthenticatorPassThrough\n«default»"]
class AuthenticatorRemote["AuthenticatorRemote\n«built-in»"]
Authenticator <|-- AuthenticatorPassThrough
Authenticator <|-- AuthenticatorRemote
Key attributes available in __init__ (after calling super().__init__):
self.session— the currentSessioninstance.self.REQUEST_AGENT_BREAKIN— set toTrueto request SSH agent forwarding even when the client has not enabled it (for credential harvesting scenarios).
Key methods to override:
Method |
Purpose |
|---|---|
|
Return the list of auth methods advertised to the client
(e.g. |
|
Transform or replace the credentials before forwarding to the server. |
Example — accept any password and log it:
import logging
from sshmitm.authentication import Authenticator
import paramiko
class LoggingAuthenticator(Authenticator):
"""Accepts all logins and logs credentials"""
def get_auth_methods(self, host, port, username):
return ["password"]
def authenticate(self, username, credentials, *, wait=False):
logging.info("Login attempt: user=%s password=%s", username, credentials)
return paramiko.common.AUTH_SUCCESSFUL
Registration:
[project.entry-points."sshmitm.Authenticator"]
logging-auth = "mypkg.auth:LoggingAuthenticator"
Usage:
ssh-mitm server --authenticator logging-auth
Server Interface Plugins
Base class: sshmitm.interfaces.server.ServerInterface
CLI argument: --auth-interface
Entry-point group: sshmitm.BaseServerInterface
The server interface is the Paramiko ServerInterface that SSH-MITM
presents to connecting clients. Override its methods to change which channel
types and authentication methods are accepted.
classDiagram
direction LR
class BaseServerInterface["BaseServerInterface\n«base»"]
class ServerInterface["ServerInterface\n«default»"]
BaseServerInterface <|-- ServerInterface
Common methods to override:
Method |
Purpose |
|---|---|
|
Accept or deny a channel type ( |
|
Allow or deny an interactive shell. |
|
Allow or deny command execution. |
|
Allow or deny a pseudo-terminal. |
Registration:
[project.entry-points."sshmitm.BaseServerInterface"]
my-interface = "mypkg.server_iface:MyServerInterface"
Usage:
ssh-mitm server --auth-interface my-interface
Session Plugins
Base class: sshmitm.session.Session
CLI argument: --session-class
Entry-point group: sshmitm.BaseSession
Session plugins wrap the entire lifecycle of a single SSH connection. Subclass
Session to add state or hooks that span all the other plugin types for a
given connection.
classDiagram
direction LR
class BaseSession["BaseSession\n«base»"]
class Session["Session\n«default»"]
BaseSession <|-- Session
Because the Session class is large, it is usually better to hook into the
more targeted plugin types above. Only use a custom session class when you
need to track state across multiple sub-protocols (SSH + SFTP + SCP)
simultaneously.
Registration:
[project.entry-points."sshmitm.BaseSession"]
my-session = "mypkg.session:MySession"
Usage:
ssh-mitm server --session-class my-session
Exec Handler Plugins
Entry-point group: sshmitm.ExecHandler
Exec handlers extend ExecForwarder directly and are dispatched for
specific SSH exec commands. SSH-MITM matches the command byte prefix against
registered handlers via SCPBaseForwarder.register_exec_handler().
The built-in example is MoshForwarder, which intercepts Mosh server launch
commands:
from sshmitm.forwarders.exec import ExecForwarder
from sshmitm.forwarders.scp import SCPBaseForwarder
class MyExecHandler(ExecForwarder):
def handle_client_data(self, data: bytes) -> bytes:
# inspect or modify data sent to the exec'd process
return data
def handle_server_data(self, data: bytes) -> bytes:
# inspect or modify output from the exec'd process
return data
SCPBaseForwarder.register_exec_handler(b"my-command", MyExecHandler)
Registration:
[project.entry-points."sshmitm.ExecHandler"]
my-handler = "mypkg.exec_handler:MyExecHandler"
Adding CLI Arguments
Every plugin can expose its own command-line flags by implementing the
parser_arguments() classmethod:
@classmethod
def parser_arguments(cls) -> None:
group = cls.argument_group() # argument group named after the class
group.add_argument(
"--my-flag",
dest="my_flag",
action="store_true",
default=False,
help="enables my feature",
)
group.add_argument(
"--my-file",
dest="my_file",
help="path to a file",
)
At runtime, access the values via self.args:
def __init__(self, session) -> None:
super().__init__(session)
if self.args.my_flag:
path = os.path.expanduser(self.args.my_file)
...
cls.argument_group() creates a named section in the --help output so
that each plugin’s options are grouped separately.
Configuration File Support
All CLI arguments can also be supplied through a configuration file. The section name is derived from the fully-qualified class name:
[mypkg.myplugin:MySSHPlugin]
my-flag = true
my-file = ~/captures/session.log
Pass the configuration file to the server with --config:
ssh-mitm server --config myconfig.ini --ssh-interface my-plugin
Packaging a Plugin as a Standalone Package
If you want to distribute your plugin independently from SSH-MITM, create a
normal Python package with a pyproject.toml that declares the entry point:
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "sshmitm-my-plugin"
version = "0.1.0"
dependencies = ["ssh-mitm"]
[project.entry-points."sshmitm.SSHBaseForwarder"]
my-plugin = "mypkg.myplugin:MySSHPlugin"
After pip install sshmitm-my-plugin, the plugin is available to SSH-MITM
automatically — no configuration change needed beyond selecting it on the CLI.