-
Notifications
You must be signed in to change notification settings - Fork 0
/
tarpyt
executable file
·291 lines (235 loc) · 10.1 KB
/
tarpyt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env python3
#
# This file is part of TarPyt.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
"""
TarPyt: A multi-protocol tarpit.
TarPyt works by manipulating flexibility in certain protocols to stall and delay a client trying
to connect. The main purpose of this is to frustrate hostile actors (e.g. ssh bruteforce) and
tie up their resources. In the ideal case, they blacklist your server for future attacks, but more
likely what will happen is that you'll waste their time and money. It's about the small wins.
"""
from argparse import ArgumentParser, Namespace
import asyncio
import ctypes
import enum
import ipaddress as ip
import logging
from logging.handlers import SysLogHandler
import random
from socket import socket, AF_UNSPEC
import string
import sys
import typing as T
# Logging setup
log = logging.getLogger('TarPyt')
# see `man 3 sd_listen_fds`, this is a #define in libsystemd
SD_LISTEN_FDS_START = 3
sd_lib = ctypes.CDLL('libsystemd.so')
class Protocol(enum.StrEnum):
"""Currently supported Protocols"""
HTTP = enum.auto()
SSH = enum.auto()
SMTP = enum.auto()
class Handler():
"""
Generic base class to handle connections. Subclasses must implement get_msg and optionally
get_msg_prefix, if appropriate for the protocol
"""
__slots__ = ("counter",)
def __init__(self: T.Self) -> None:
self.counter: int = 0
async def timeout(self: T.Self) -> None:
"""Sleep for a random amount of time between 3 and 10 s"""
await asyncio.sleep(random.randint(3, 10))
def get_msg_prefix(self: T.Self) -> bytes:
"""Optional prefix if required by the protocol, to be sent before the main loop."""
return b''
def get_msg(self: T.Self) -> bytes:
"""Main message that will be send during the while loop"""
return b''
async def write(self: T.Self,
reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
"""
Write nonsense to a socket to conform with a particular message format. Each loop iteration
will send a random formatted string, then wait some time before doing it again
Parameters
----------
reader : asyncio.StreamReader
StreamReader from asyncio.start_server(), unused.
writer : asyncio.StreamWriter
StreamWriter from asyncio.start_server()
"""
_ = reader # unused argument
host = writer.get_extra_info("peername", default=None)
if host is None:
raise ValueError("what the fuck?")
hostname = ip.ip_address(host[0])
# check if we got an embedded ipv4
if (hostname.version) == 6 and (addr := hostname.ipv4_mapped):
hostname = addr
try:
self.counter += 1
log.info("New Connection from %s (%d total)", hostname, self.counter)
# some protocols (HTTP) require an initial string before the tarpit can begin
if len(prefix := self.get_msg_prefix()) > 0:
writer.write(prefix)
while True:
await self.timeout()
writer.write(self.get_msg())
await writer.drain()
except (ConnectionResetError, TimeoutError):
self.counter -= 1
log.info("Connection closed with remote host %s (%d total)", hostname, self.counter)
class SSHHandler(Handler):
"""Handler class for ssh connections"""
__slots__ = ()
def get_msg(self: T.Self) -> bytes:
"""
RFC 4253:
The server MAY send other lines of data before sending the version
string. Each line SHOULD be terminated by a Carriage Return and Line
Feed. Such lines MUST NOT begin with "SSH-", and SHOULD be encoded
in ISO-10646 UTF-8 [RFC3629] (language is not specified).
So, we are allowed to send random bytes that may or may not decode to UTF-8, with
a carriage return and a new line at the end. It can't have "SSH-" at the beginning
but we're sending only one byte
"""
return random.randbytes(1) + b'\r\n'
class HTTPHandler(Handler):
"""Handler class for HTTPS connections"""
__slots__ = ()
def get_msg_prefix(self: T.Self) -> bytes:
"""
Returns required HTTP version bytes string
"""
return 'HTTP/1.1 200 OK\r\n'.encode('latin-1')
def get_msg(self: T.Self) -> bytes:
"""
RFC 2616:
Request (section 5) and Response (section 6) messages use the generic
message format of RFC 822 [9] for transferring entities (the payload
of the message). Both types of message consist of a start-line, zero
or more header fields (also known as "headers"), an empty line (i.e.,
a line with nothing preceding the CRLF) indicating the end of the
header fields, and possibly a message-body.
There is no limit defined for the amount of headers that can be send, so we can just do
whatever we want here. HTTP is also latin-1 encoded, no source for that.
"""
(header, value) = random.choices(string.ascii_letters, k=2)
return f'X-{header}: {value}\r\n'.encode('latin-1')
class SMTPHandler(Handler):
"""Handler class for SMTP connections"""
__slots__ = ()
def get_msg(self: T.Self) -> bytes:
"""
RFC 5321:
The format for multiline replies requires that every line, except the
last, begin with the reply code, followed immediately by a hyphen,
"-" (also known as minus), followed by text. The last line will
begin with the reply code, followed immediately by <SP>, optionally
some text, and <CRLF>. As noted above, servers SHOULD send the <SP>
if subsequent text is not sent, but clients MUST be prepared for it
to be omitted.
220 is the server HELO code, so a client has to wait for it to be over to know what to
do, therefore we can just continually send a "220-" prefixed message
"""
return b'220-' + random.randbytes(1) + b'\r\n'
def setup_logging(log_level: str, log_to_stdout: bool) -> None:
"""Initialize logging"""
log.setLevel(log_level)
if log_to_stdout:
log.addHandler(logging.StreamHandler(sys.stdout))
else:
log.addHandler(SysLogHandler(address='/run/systemd/journal/dev-log'))
log.debug("Setting log level to %s", log_level)
def get_sd_socket() -> socket:
"""
Retrieve the socket from systemd and convert it to a Python socket for use in asyncio. Checks
are in place to make sure the socket is valid.
Raises
------
ConnectionError
When the socket cannot be acquired.
Returns
-------
sock
Listening socket for the service.
"""
listen_fds: int = sd_lib.sd_listen_fds(0)
if listen_fds <= 0:
raise ConnectionError("Bad fd from systemd")
ret: int = sd_lib.sd_is_socket_inet(SD_LISTEN_FDS_START, AF_UNSPEC, 0, ctypes.c_uint16(0))
if ret != 0:
raise ConnectionError("Not an inet socket")
# We always use the first socket
return socket(fileno=SD_LISTEN_FDS_START)
def sd_notify(status: dict[str, int | str]) -> None:
"""
Use sd-notify to inform systemd we're ready
"""
state_str = '\n'.join([f'{k:s}={str(v):s}' for k,v in status.items()]).encode('utf-8')
state = ctypes.create_string_buffer(state_str, len(state_str) + 1)
sd_lib.sd_notify(0, state)
def parse_arguments() -> Namespace:
"""
Parse arguments
"""
parser = ArgumentParser(prog=__file__, description=__doc__)
parser.add_argument("--protocol", help="The protocol to use as the tarpit (lowercase)",
type=Protocol, choices=("http", "ssh", "smtp"), dest="protocol",
required=True)
parser.add_argument("--log-level",
help="log level to use (standard python logging module levels)",
type=str, choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"),
default="WARNING")
parser.add_argument("--log-to-stdout",
help=("logs information to stdout instead of the journal. This should be "
"used when testing tarpyt."),
action='store_true', default=False)
return parser.parse_args()
async def main() -> None:
"""
Main run function for TarPyt. Should only be called by systemd, or if testing by
systemd-socket-activate.
"""
args = parse_arguments()
setup_logging(args.log_level, args.log_to_stdout)
try:
sock = get_sd_socket()
log.debug("Successfully got socket from systemd")
except ConnectionError as e:
log.critical("Error getting socket: %s", str(e))
sys.exit(1)
log.debug("Setting protocol to %s", args.protocol)
handler: Handler
match args.protocol:
case Protocol.HTTP:
handler = HTTPHandler()
case Protocol.SMTP:
handler = SMTPHandler()
case Protocol.SSH:
handler = SSHHandler()
case _:
log.critical("No such protocol '%s'", args.protocol)
sys.exit(1)
server = await asyncio.start_server(handler.write, sock=sock)
async with server:
sd_notify({"READY": 1, "STATUS": f"Listening for {args.protocol:s} connections"})
log.info("Server is now accepting connections")
try:
await server.serve_forever()
except (asyncio.exceptions.CancelledError, RuntimeError):
pass
log.info("Server is shutting down")
sd_notify({"STOPPING": 1})
if __name__ == '__main__':
asyncio.run(main())