sshcatch 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sshcatch-0.2.1 → sshcatch-0.2.2}/CHANGELOG.md +29 -0
- {sshcatch-0.2.1 → sshcatch-0.2.2}/PKG-INFO +41 -4
- {sshcatch-0.2.1 → sshcatch-0.2.2}/README.md +40 -3
- {sshcatch-0.2.1 → sshcatch-0.2.2}/sshcatch.py +27 -19
- {sshcatch-0.2.1 → sshcatch-0.2.2}/.gitignore +0 -0
- {sshcatch-0.2.1 → sshcatch-0.2.2}/LICENSE +0 -0
- {sshcatch-0.2.1 → sshcatch-0.2.2}/pyproject.toml +0 -0
|
@@ -4,6 +4,35 @@ All notable changes to **sshcatch** are documented here.
|
|
|
4
4
|
This project follows [Keep a Changelog](https://keepachangelog.com/) and
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.2.2] - 2026-07-31
|
|
8
|
+
|
|
9
|
+
Multiple host keys now used as default.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Three host keys instead of one.** ed25519, RSA-3072 and ecdsa-nistp256 are
|
|
14
|
+
generated on first run if missing and stored together in one `sshcatch_host_key`
|
|
15
|
+
file, so clients can pick their algorithm. Existing key files are read with
|
|
16
|
+
`read_private_key_list()` and may hold any number of keys.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The startup summary prints the fingerprint of each key.
|
|
21
|
+
- **Startup failures go through a single handler.** `PermissionError` is no longer
|
|
22
|
+
special-cased: it always blamed the port, even when the host key file was the
|
|
23
|
+
real problem. The underlying error is shown instead - it already names the
|
|
24
|
+
address and port.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- An empty, unreadable or passphrase-protected host key file now ends in a clean
|
|
29
|
+
error message instead of a traceback.
|
|
30
|
+
- `authorized_keys` lines that do not start with a key say why they were refused.
|
|
31
|
+
Options in front of the key (`from="..."`, `restrict`, ...) remain unsupported -
|
|
32
|
+
sshcatch cannot enforce them, so such keys are rejected rather than silently
|
|
33
|
+
accepted without their restrictions.
|
|
34
|
+
|
|
35
|
+
|
|
7
36
|
## [0.2.1] - 2026-07-29
|
|
8
37
|
|
|
9
38
|
Forwarding hardening and help/logging polish.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshcatch
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
|
|
5
5
|
Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
|
|
6
6
|
Project-URL: Repository, https://github.com/LorenzMap/sshcatch
|
|
@@ -39,6 +39,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
|
39
39
|
This is a pentesting tool. Only point it at systems and networks you are authorized
|
|
40
40
|
to test.
|
|
41
41
|
|
|
42
|
+
## Why this tool exists
|
|
43
|
+
|
|
44
|
+
- During engagements and CTFs I love to use 'simple' tools on my host that just work
|
|
45
|
+
- http-server -> `python3 -m http.server`
|
|
46
|
+
- smb-server -> `impacket-smbserver`
|
|
47
|
+
- ssh-server -> ??? (now `sshcatch`)
|
|
48
|
+
|
|
49
|
+
- `sshd` can be used, but:
|
|
50
|
+
- configuring it through `sshd_configs` is a pain
|
|
51
|
+
- multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
|
|
52
|
+
- logins are controlled by the OS so a user must be created (and secured)
|
|
53
|
+
- I want to restrict the shell so my host is safe
|
|
54
|
+
|
|
55
|
+
- My solution: `sshcatch`
|
|
56
|
+
- Simply configure through clear flags and arguments on the commandline
|
|
57
|
+
- restrictive defaults, every feature must be enabled consciously
|
|
58
|
+
- Never allow shells (or commands)
|
|
59
|
+
- Forward/Reverse tunnels can be individually activated
|
|
60
|
+
- SCP/SFTP file uploads and downloads can be individually activated
|
|
61
|
+
- restrictive upload handling to prevent overwriting
|
|
62
|
+
- symlinks denied
|
|
63
|
+
|
|
42
64
|
## Install
|
|
43
65
|
|
|
44
66
|
With `pipx` (recommended, installs into an isolated environment and puts
|
|
@@ -62,8 +84,9 @@ cd sshcatch
|
|
|
62
84
|
pipx install . # or: pip install .
|
|
63
85
|
```
|
|
64
86
|
|
|
65
|
-
Needs Python 3.10+.
|
|
66
|
-
first run (or point `--host-key`
|
|
87
|
+
Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
|
|
88
|
+
into a single file in the working directory on first run (or point `--host-key`
|
|
89
|
+
at your own file).
|
|
67
90
|
|
|
68
91
|
## How it works
|
|
69
92
|
|
|
@@ -99,6 +122,18 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
|
|
|
99
122
|
|
|
100
123
|
Renames, deletes and directory removal are denied.
|
|
101
124
|
|
|
125
|
+
## Word of Warning
|
|
126
|
+
|
|
127
|
+
Only using `--version-banner` obviously isn't enough deception because the KEXINIT
|
|
128
|
+
that is transferred cleartext on the wire is a clear tell. This differing HASSH
|
|
129
|
+
can be easily detected by a sufficiently sophisticated observer.
|
|
130
|
+
|
|
131
|
+
Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
|
|
132
|
+
and everything else a real honeypot needs are deliberately out of scope. There are other
|
|
133
|
+
projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
|
|
134
|
+
[cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
|
|
135
|
+
|
|
136
|
+
|
|
102
137
|
## Examples
|
|
103
138
|
|
|
104
139
|
Let one user pull/put files from the current directory via SCP/SFTP:
|
|
@@ -174,7 +209,9 @@ options:
|
|
|
174
209
|
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
175
210
|
-1, --single close the listener after first successful auth (and
|
|
176
211
|
exit when that connection ends)
|
|
177
|
-
--host-key FILE server host key file
|
|
212
|
+
--host-key FILE server host key file, may hold several keys - auto-
|
|
213
|
+
generated if missing - uses ./sshcatch_host_key by
|
|
214
|
+
default
|
|
178
215
|
--version show program's version number and exit
|
|
179
216
|
|
|
180
217
|
authentication:
|
|
@@ -13,6 +13,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
|
13
13
|
This is a pentesting tool. Only point it at systems and networks you are authorized
|
|
14
14
|
to test.
|
|
15
15
|
|
|
16
|
+
## Why this tool exists
|
|
17
|
+
|
|
18
|
+
- During engagements and CTFs I love to use 'simple' tools on my host that just work
|
|
19
|
+
- http-server -> `python3 -m http.server`
|
|
20
|
+
- smb-server -> `impacket-smbserver`
|
|
21
|
+
- ssh-server -> ??? (now `sshcatch`)
|
|
22
|
+
|
|
23
|
+
- `sshd` can be used, but:
|
|
24
|
+
- configuring it through `sshd_configs` is a pain
|
|
25
|
+
- multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
|
|
26
|
+
- logins are controlled by the OS so a user must be created (and secured)
|
|
27
|
+
- I want to restrict the shell so my host is safe
|
|
28
|
+
|
|
29
|
+
- My solution: `sshcatch`
|
|
30
|
+
- Simply configure through clear flags and arguments on the commandline
|
|
31
|
+
- restrictive defaults, every feature must be enabled consciously
|
|
32
|
+
- Never allow shells (or commands)
|
|
33
|
+
- Forward/Reverse tunnels can be individually activated
|
|
34
|
+
- SCP/SFTP file uploads and downloads can be individually activated
|
|
35
|
+
- restrictive upload handling to prevent overwriting
|
|
36
|
+
- symlinks denied
|
|
37
|
+
|
|
16
38
|
## Install
|
|
17
39
|
|
|
18
40
|
With `pipx` (recommended, installs into an isolated environment and puts
|
|
@@ -36,8 +58,9 @@ cd sshcatch
|
|
|
36
58
|
pipx install . # or: pip install .
|
|
37
59
|
```
|
|
38
60
|
|
|
39
|
-
Needs Python 3.10+.
|
|
40
|
-
first run (or point `--host-key`
|
|
61
|
+
Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
|
|
62
|
+
into a single file in the working directory on first run (or point `--host-key`
|
|
63
|
+
at your own file).
|
|
41
64
|
|
|
42
65
|
## How it works
|
|
43
66
|
|
|
@@ -73,6 +96,18 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
|
|
|
73
96
|
|
|
74
97
|
Renames, deletes and directory removal are denied.
|
|
75
98
|
|
|
99
|
+
## Word of Warning
|
|
100
|
+
|
|
101
|
+
Only using `--version-banner` obviously isn't enough deception because the KEXINIT
|
|
102
|
+
that is transferred cleartext on the wire is a clear tell. This differing HASSH
|
|
103
|
+
can be easily detected by a sufficiently sophisticated observer.
|
|
104
|
+
|
|
105
|
+
Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
|
|
106
|
+
and everything else a real honeypot needs are deliberately out of scope. There are other
|
|
107
|
+
projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
|
|
108
|
+
[cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
|
|
109
|
+
|
|
110
|
+
|
|
76
111
|
## Examples
|
|
77
112
|
|
|
78
113
|
Let one user pull/put files from the current directory via SCP/SFTP:
|
|
@@ -148,7 +183,9 @@ options:
|
|
|
148
183
|
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
149
184
|
-1, --single close the listener after first successful auth (and
|
|
150
185
|
exit when that connection ends)
|
|
151
|
-
--host-key FILE server host key file
|
|
186
|
+
--host-key FILE server host key file, may hold several keys - auto-
|
|
187
|
+
generated if missing - uses ./sshcatch_host_key by
|
|
188
|
+
default
|
|
152
189
|
--version show program's version number and exit
|
|
153
190
|
|
|
154
191
|
authentication:
|
|
@@ -9,6 +9,7 @@ import asyncio
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
11
|
import posixpath
|
|
12
|
+
import re
|
|
12
13
|
import sys
|
|
13
14
|
import time
|
|
14
15
|
from pathlib import Path
|
|
@@ -16,7 +17,7 @@ from itertools import count
|
|
|
16
17
|
|
|
17
18
|
import asyncssh
|
|
18
19
|
|
|
19
|
-
__version__ = "0.2.
|
|
20
|
+
__version__ = "0.2.2"
|
|
20
21
|
|
|
21
22
|
# ── Logging ───────────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -366,10 +367,11 @@ def make_server_factory(args, single_future=None):
|
|
|
366
367
|
if args.authorized_keys:
|
|
367
368
|
for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
|
|
368
369
|
line = line.strip()
|
|
369
|
-
if not line or line.startswith('#'):
|
|
370
|
-
|
|
370
|
+
if not line or line.startswith('#'): continue
|
|
371
|
+
key_only = re.search(r"^\S+ AAAA\S+", line)
|
|
371
372
|
try:
|
|
372
|
-
|
|
373
|
+
if not key_only: raise ValueError("Must start with 'keytype base64' (no options)")
|
|
374
|
+
k = asyncssh.import_public_key(key_only.group())
|
|
373
375
|
fp = k.get_fingerprint()
|
|
374
376
|
auth_keys_fps.add(fp)
|
|
375
377
|
log_info(f"Loaded key line={i} fingerprint={fp}")
|
|
@@ -543,24 +545,31 @@ def make_server_factory(args, single_future=None):
|
|
|
543
545
|
|
|
544
546
|
# ── Server start ──────────────────────────────────────────────────────
|
|
545
547
|
|
|
548
|
+
HOST_KEY_ALGS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
|
|
549
|
+
HOST_KEY_OPTS = {"ssh-rsa": {"key_size": 3072}}
|
|
550
|
+
|
|
551
|
+
|
|
546
552
|
async def start_server(args):
|
|
547
|
-
# Handle Host
|
|
553
|
+
# Handle Host keys
|
|
548
554
|
key_path = args.host_key if args.host_key else Path.cwd()
|
|
549
|
-
if key_path.is_dir():
|
|
555
|
+
if key_path.is_dir():
|
|
550
556
|
key_path = key_path / "sshcatch_host_key"
|
|
551
557
|
if not key_path.parent.is_dir():
|
|
552
558
|
raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
|
|
553
559
|
|
|
554
560
|
if key_path.is_file():
|
|
555
|
-
|
|
556
|
-
|
|
561
|
+
try: host_keys = asyncssh.read_private_key_list(str(key_path))
|
|
562
|
+
except ValueError as e: raise ValueError(f"Could not read host key file {key_path}: {e}") from None
|
|
563
|
+
if not host_keys: raise ValueError(f"No usable host key in {key_path}")
|
|
564
|
+
log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
|
|
557
565
|
else:
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
566
|
+
host_keys = [asyncssh.generate_private_key(a, **HOST_KEY_OPTS.get(a, {}))
|
|
567
|
+
for a in HOST_KEY_ALGS]
|
|
568
|
+
key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
|
|
569
|
+
log_info(f"Generated host keys file: {key_path}")
|
|
561
570
|
if os.name == "posix": key_path.chmod(0o600)
|
|
562
571
|
else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
|
|
563
|
-
|
|
572
|
+
fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in host_keys]
|
|
564
573
|
|
|
565
574
|
# for single-connection mode - resolving releases the bind on the listen port
|
|
566
575
|
single_future = asyncio.get_running_loop().create_future() if args.single else None
|
|
@@ -569,7 +578,7 @@ async def start_server(args):
|
|
|
569
578
|
server_factory, n_users, n_keys = make_server_factory(args, single_future)
|
|
570
579
|
opts = {
|
|
571
580
|
"server_factory": server_factory,
|
|
572
|
-
"server_host_keys":
|
|
581
|
+
"server_host_keys": host_keys,
|
|
573
582
|
# SFTPv3 only so all transfers use open() and not open56()
|
|
574
583
|
"sftp_version": 3,
|
|
575
584
|
}
|
|
@@ -636,8 +645,8 @@ async def start_server(args):
|
|
|
636
645
|
summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
|
|
637
646
|
if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
|
|
638
647
|
if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
|
|
639
|
-
summary.append(f"Host key ...... {fingerprint}")
|
|
640
648
|
summary.append(f"Key file ...... {key_path}")
|
|
649
|
+
for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
|
|
641
650
|
log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
|
|
642
651
|
|
|
643
652
|
acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
|
|
@@ -687,7 +696,7 @@ examples: (also check README on Github)
|
|
|
687
696
|
"""
|
|
688
697
|
|
|
689
698
|
def build_parser(full=False):
|
|
690
|
-
# Help got
|
|
699
|
+
# Help got too long so splitting it into '-h' and '--help'
|
|
691
700
|
def help_text(short_help=None, long_help=""):
|
|
692
701
|
if full: return (f"{short_help} " if short_help else "") + long_help
|
|
693
702
|
else: return short_help if short_help else argparse.SUPPRESS
|
|
@@ -710,7 +719,8 @@ def build_parser(full=False):
|
|
|
710
719
|
help=help_text(short_help="close the listener after first successful auth",
|
|
711
720
|
long_help="(and exit when that connection ends)"))
|
|
712
721
|
parser.add_argument("--host-key", metavar="FILE", type=Path,
|
|
713
|
-
help=help_text(long_help="server host key file
|
|
722
|
+
help=help_text(long_help="server host key file, may hold several keys "
|
|
723
|
+
"- auto-generated if missing - uses ./sshcatch_host_key by default"))
|
|
714
724
|
parser.add_argument("--version", action="version",
|
|
715
725
|
version=f"%(prog)s {__version__}",
|
|
716
726
|
help=help_text(long_help="show program's version number and exit"))
|
|
@@ -803,9 +813,7 @@ def main():
|
|
|
803
813
|
parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
|
|
804
814
|
|
|
805
815
|
try: asyncio.run(start_server(args))
|
|
806
|
-
except
|
|
807
|
-
parser.error(f"Permission denied - port {args.port} requires root")
|
|
808
|
-
except OSError as e:
|
|
816
|
+
except (OSError, ValueError) as e:
|
|
809
817
|
parser.error(f"Could not start server: {e}")
|
|
810
818
|
except KeyboardInterrupt:
|
|
811
819
|
print()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|