sshcatch 0.2.1__tar.gz → 0.3.0__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.
@@ -4,6 +4,64 @@ 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.3.0] - 2026-08-02
8
+
9
+ Pose as another SSH server.
10
+
11
+ ### Added
12
+
13
+ - **`--mimic debian` / `--mimic dropbear`** poses as a real OpenSSH 8.4p1 (Debian 11) or
14
+ Dropbear 2024.86 server: version banner, KEXINIT/HASSH, offered host keys and auth
15
+ methods match a capture of the real server. After authentication `sshcatch` differs from a
16
+ real server anyway, so `--mimic` only works pre-auth. Every modification to asyncssh,
17
+ the reasoning and the captures are in
18
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md).
19
+ - `select_host_keys()` offers only the host-key types the mimicked server has, in its order.
20
+
21
+ ### Changed
22
+
23
+ - **asyncssh is pinned to 2.24.0 exactly** - `--mimic` patches library internals, so a
24
+ different version could break the disguise silently. If a patched internal is gone,
25
+ sshcatch refuses to start.
26
+ - The sdist ships only what is needed to install the package (no `mimic-refs/`).
27
+ - The startup summary shows the active mimic preset.
28
+
29
+ ### Removed
30
+
31
+ - **`--version-banner` presets** (`ubuntu`, `debian`, `dropbear`, `windows`, `macos`)
32
+ are dropped and the flag now takes a literal string only, `--mimic` sets a fitting
33
+ banner itself.
34
+
35
+
36
+ ## [0.2.2] - 2026-07-31
37
+
38
+ Multiple host keys now used as default.
39
+
40
+ ### Added
41
+
42
+ - **Three host keys instead of one.** ed25519, RSA-3072 and ecdsa-nistp256 are
43
+ generated on first run if missing and stored together in one `sshcatch_host_key`
44
+ file, so clients can pick their algorithm. Existing key files are read with
45
+ `read_private_key_list()` and may hold any number of keys.
46
+
47
+ ### Changed
48
+
49
+ - The startup summary prints the fingerprint of each key.
50
+ - **Startup failures go through a single handler.** `PermissionError` is no longer
51
+ special-cased: it always blamed the port, even when the host key file was the
52
+ real problem. The underlying error is shown instead - it already names the
53
+ address and port.
54
+
55
+ ### Fixed
56
+
57
+ - An empty, unreadable or passphrase-protected host key file now ends in a clean
58
+ error message instead of a traceback.
59
+ - `authorized_keys` lines that do not start with a key say why they were refused.
60
+ Options in front of the key (`from="..."`, `restrict`, ...) remain unsupported -
61
+ sshcatch cannot enforce them, so such keys are rejected rather than silently
62
+ accepted without their restrictions.
63
+
64
+
7
65
  ## [0.2.1] - 2026-07-29
8
66
 
9
67
  Forwarding hardening and help/logging polish.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshcatch
3
- Version: 0.2.1
3
+ Version: 0.3.0
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
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Security
22
22
  Classifier: Topic :: System :: Networking
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: asyncssh<2.25.0,>=2.21.0
24
+ Requires-Dist: asyncssh==2.24.0
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # 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
+ - ForceCommands need to be set up to restrict the shell
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+. A host key is auto-generated in the working directory on
66
- first run (or point `--host-key` at your own).
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,21 @@ 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 against a sufficiently
128
+ sophisticated observer because some of the data transferred in cleartext on the wire
129
+ during connection establishment is a clear tell. Use `--mimic` if that's something you
130
+ want to try and dodge in an engagement. Check out
131
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
132
+ for details about `--mimic`.
133
+
134
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
135
+ and everything else a real honeypot needs are deliberately out of scope. There are other
136
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
137
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
138
+
139
+
102
140
  ## Examples
103
141
 
104
142
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -130,7 +168,7 @@ and saving them into a file:
130
168
 
131
169
  ```
132
170
  # Server
133
- sshcatch -1 -u arthur:42 --version-banner debian \
171
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
134
172
  --pre-auth-banner "What is the answer to life the universe and everything" \
135
173
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
136
174
  -o sshcatch.log -t
@@ -138,11 +176,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
138
176
 
139
177
 
140
178
  My favorite one: Reverse tunnel and SCP uploads for the keys in
141
- `./authorized-keys` while posing as an Ubuntu SSH server on port 2222:
179
+ `./authorized_keys` on port 2222:
142
180
 
143
181
  ```
144
182
  # Server
145
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
183
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
146
184
 
147
185
  # Client
148
186
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
@@ -156,15 +194,15 @@ scp -P 2222 loot.tar user@host:. # upload
156
194
  The full reference below is `sshcatch --help`:
157
195
 
158
196
  ```
159
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
160
- [--version] [-u USER:PASS] [--open-auth]
197
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
198
+ [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
161
199
  [--authorized-keys FILE] [--forward] [--reverse]
162
200
  [--scp-upload] [--scp-download] [--scp-dir DIR]
163
201
  [--version-banner STRING] [--pre-auth-banner STRING]
164
202
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
165
203
 
166
204
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
167
- and simple SCP transfers (NEVER opens a shell!).
205
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
168
206
  By default all features are disabled. Use flags to enable features.
169
207
 
170
208
  options:
@@ -174,7 +212,14 @@ options:
174
212
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
175
213
  -1, --single close the listener after first successful auth (and
176
214
  exit when that connection ends)
177
- --host-key FILE server host key file (default: auto-generate)
215
+ --mimic PRESET pose as another SSH server - presets (case-
216
+ insensitive): debian, dropbear, none - match the
217
+ preset's pre-auth (banner, KEXINIT, server-sig-algs,
218
+ ...) exactly - banner can be overridden by --version-
219
+ banner - check Github repository for details
220
+ --host-key FILE server host key file, may hold several keys - auto-
221
+ generated if missing - uses ./sshcatch_host_key by
222
+ default
178
223
  --version show program's version number and exit
179
224
 
180
225
  authentication:
@@ -200,10 +245,7 @@ SCP / SFTP file transfer:
200
245
 
201
246
  banners:
202
247
  --version-banner STRING
203
- sent as 'SSH-2.0-STRING' version banner - only first-
204
- glance deception, it can still be identified as
205
- asyncssh - presets (case-insensitive): ubuntu, debian,
206
- dropbear, windows, macos
248
+ manually set 'SSH-2.0-STRING' version banner
207
249
  --pre-auth-banner STRING
208
250
  banner shown to every client before login
209
251
  --post-auth-banner STRING
@@ -225,8 +267,7 @@ examples: (also check README on Github)
225
267
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
226
268
  # My favorite one
227
269
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
228
- # while posing shallowly as an Ubuntu SSH server on port 2222
229
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
270
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
230
271
  ```
231
272
 
232
273
  ## License
@@ -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
+ - ForceCommands need to be set up to restrict the shell
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+. A host key is auto-generated in the working directory on
40
- first run (or point `--host-key` at your own).
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,21 @@ 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 against a sufficiently
102
+ sophisticated observer because some of the data transferred in cleartext on the wire
103
+ during connection establishment is a clear tell. Use `--mimic` if that's something you
104
+ want to try and dodge in an engagement. Check out
105
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
106
+ for details about `--mimic`.
107
+
108
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
109
+ and everything else a real honeypot needs are deliberately out of scope. There are other
110
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
111
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
112
+
113
+
76
114
  ## Examples
77
115
 
78
116
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -104,7 +142,7 @@ and saving them into a file:
104
142
 
105
143
  ```
106
144
  # Server
107
- sshcatch -1 -u arthur:42 --version-banner debian \
145
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
108
146
  --pre-auth-banner "What is the answer to life the universe and everything" \
109
147
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
110
148
  -o sshcatch.log -t
@@ -112,11 +150,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
112
150
 
113
151
 
114
152
  My favorite one: Reverse tunnel and SCP uploads for the keys in
115
- `./authorized-keys` while posing as an Ubuntu SSH server on port 2222:
153
+ `./authorized_keys` on port 2222:
116
154
 
117
155
  ```
118
156
  # Server
119
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
157
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
120
158
 
121
159
  # Client
122
160
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
@@ -130,15 +168,15 @@ scp -P 2222 loot.tar user@host:. # upload
130
168
  The full reference below is `sshcatch --help`:
131
169
 
132
170
  ```
133
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
134
- [--version] [-u USER:PASS] [--open-auth]
171
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
172
+ [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
135
173
  [--authorized-keys FILE] [--forward] [--reverse]
136
174
  [--scp-upload] [--scp-download] [--scp-dir DIR]
137
175
  [--version-banner STRING] [--pre-auth-banner STRING]
138
176
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
139
177
 
140
178
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
141
- and simple SCP transfers (NEVER opens a shell!).
179
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
142
180
  By default all features are disabled. Use flags to enable features.
143
181
 
144
182
  options:
@@ -148,7 +186,14 @@ options:
148
186
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
149
187
  -1, --single close the listener after first successful auth (and
150
188
  exit when that connection ends)
151
- --host-key FILE server host key file (default: auto-generate)
189
+ --mimic PRESET pose as another SSH server - presets (case-
190
+ insensitive): debian, dropbear, none - match the
191
+ preset's pre-auth (banner, KEXINIT, server-sig-algs,
192
+ ...) exactly - banner can be overridden by --version-
193
+ banner - check Github repository for details
194
+ --host-key FILE server host key file, may hold several keys - auto-
195
+ generated if missing - uses ./sshcatch_host_key by
196
+ default
152
197
  --version show program's version number and exit
153
198
 
154
199
  authentication:
@@ -174,10 +219,7 @@ SCP / SFTP file transfer:
174
219
 
175
220
  banners:
176
221
  --version-banner STRING
177
- sent as 'SSH-2.0-STRING' version banner - only first-
178
- glance deception, it can still be identified as
179
- asyncssh - presets (case-insensitive): ubuntu, debian,
180
- dropbear, windows, macos
222
+ manually set 'SSH-2.0-STRING' version banner
181
223
  --pre-auth-banner STRING
182
224
  banner shown to every client before login
183
225
  --post-auth-banner STRING
@@ -199,8 +241,7 @@ examples: (also check README on Github)
199
241
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
200
242
  # My favorite one
201
243
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
202
- # while posing shallowly as an Ubuntu SSH server on port 2222
203
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
244
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
204
245
  ```
205
246
 
206
247
  ## License
@@ -25,7 +25,8 @@ classifiers = [
25
25
  "Topic :: Security",
26
26
  "Topic :: System :: Networking",
27
27
  ]
28
- dependencies = ["asyncssh>=2.21.0,<2.25.0"]
28
+ # Pinned exactly due to mimic monkey-patches on asyncssh internals (see mimic-refs/mimic-notes.md)
29
+ dependencies = ["asyncssh==2.24.0"]
29
30
 
30
31
  [project.urls]
31
32
  Homepage = "https://github.com/LorenzMap/sshcatch"
@@ -40,3 +41,6 @@ path = "sshcatch.py"
40
41
 
41
42
  [tool.hatch.build.targets.wheel]
42
43
  only-include = ["sshcatch.py"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ only-include = ["sshcatch.py", "README.md", "CHANGELOG.md", "LICENSE"]
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
4
- and simple SCP transfers (NEVER opens a shell!).
4
+ and simple SCP/SFTP transfers (NEVER opens a shell!).
5
5
  """
6
6
 
7
7
  import argparse
8
8
  import asyncio
9
+ import functools
9
10
  import logging
10
11
  import os
11
12
  import posixpath
13
+ import re
12
14
  import sys
13
15
  import time
14
16
  from pathlib import Path
@@ -16,7 +18,7 @@ from itertools import count
16
18
 
17
19
  import asyncssh
18
20
 
19
- __version__ = "0.2.1"
21
+ __version__ = "0.3.0"
20
22
 
21
23
  # ── Logging ───────────────────────────────────────────────────────────
22
24
 
@@ -251,8 +253,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
251
253
  self._ensure_parent(new)
252
254
  target = old.decode(errors="replace")
253
255
  Path(os.fsdecode(self.map_path(new))).write_text(f"symlink -> {target}\n")
254
- self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)",
255
- logging.WARNING)
256
+ self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)", logging.WARNING)
256
257
 
257
258
  def setstat(self, path, attrs):
258
259
  # Allow (upload only): perms/timestamps on uploaded files
@@ -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
- continue
370
+ if not line or line.startswith('#'): continue
371
+ key_only = re.search(r"^\S+ AAAA\S+", line)
371
372
  try:
372
- k = asyncssh.import_public_key(line)
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}")
@@ -468,6 +470,11 @@ def make_server_factory(args, single_future=None):
468
470
  # always accept passwords so we can log them
469
471
  return True
470
472
 
473
+ def kbdint_auth_supported(self):
474
+ # force publickey,password auth for mimic (drop keyboard-interactive)
475
+ if not args.mimic == "none": return False
476
+ return super().kbdint_auth_supported()
477
+
471
478
  def validate_password(self, username, password):
472
479
  accepted = accept_password(username, password)
473
480
  if accepted:
@@ -543,24 +550,32 @@ def make_server_factory(args, single_future=None):
543
550
 
544
551
  # ── Server start ──────────────────────────────────────────────────────
545
552
 
553
+ GENERATE_KEYS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
554
+ GENERATE_OPTS = {"ssh-rsa": {"key_size": 3072}}
555
+
546
556
  async def start_server(args):
547
- # Handle Host key
557
+ # Handle Host keys
548
558
  key_path = args.host_key if args.host_key else Path.cwd()
549
- if key_path.is_dir():
559
+ if key_path.is_dir():
550
560
  key_path = key_path / "sshcatch_host_key"
551
561
  if not key_path.parent.is_dir():
552
562
  raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
553
563
 
554
564
  if key_path.is_file():
555
- host_key = asyncssh.read_private_key(str(key_path))
556
- log_info(f"Read host key: {key_path}")
565
+ try: host_keys = asyncssh.read_private_key_list(str(key_path))
566
+ except ValueError as e: raise ValueError(f"Could not read host key file {key_path}: {e}") from None
567
+ if not host_keys: raise ValueError(f"No usable host key in {key_path}")
568
+ log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
557
569
  else:
558
- host_key = asyncssh.generate_private_key("ssh-ed25519")
559
- host_key.write_private_key(str(key_path))
560
- log_info(f"Generated host key: {key_path}")
570
+ host_keys = [asyncssh.generate_private_key(a, **GENERATE_OPTS.get(a, {})) for a in GENERATE_KEYS]
571
+ key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
572
+ log_info(f"Generated host keys file: {key_path}")
561
573
  if os.name == "posix": key_path.chmod(0o600)
562
574
  else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
563
- fingerprint = host_key.get_fingerprint()
575
+
576
+ # Choose which keys to expose and what host-key algorithms to advertise
577
+ exposed_keys, host_key_algs = select_host_keys(host_keys, args.mimic)
578
+ fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in exposed_keys]
564
579
 
565
580
  # for single-connection mode - resolving releases the bind on the listen port
566
581
  single_future = asyncio.get_running_loop().create_future() if args.single else None
@@ -569,12 +584,15 @@ async def start_server(args):
569
584
  server_factory, n_users, n_keys = make_server_factory(args, single_future)
570
585
  opts = {
571
586
  "server_factory": server_factory,
572
- "server_host_keys": [host_key],
587
+ "server_host_keys": exposed_keys,
573
588
  # SFTPv3 only so all transfers use open() and not open56()
574
589
  "sftp_version": 3,
575
590
  }
576
- if args.version_banner:
591
+ if args.version_banner:
577
592
  opts["server_version"] = args.version_banner
593
+ if args.mimic != "none":
594
+ opts.update(MIMIC_PRESETS[args.mimic][1])
595
+ apply_mimic_patches(args.mimic, host_key_algs)
578
596
 
579
597
  has_scp = args.scp_upload or args.scp_download
580
598
  if has_scp:
@@ -634,10 +652,12 @@ async def start_server(args):
634
652
  if args.single: summary.append("Mode .......... single-connection")
635
653
  if has_scp: summary.append(f"SCP dir ....... {args.scp_dir.resolve()}")
636
654
  summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
655
+ summary.append(f"Mimic ......... {args.mimic}")
637
656
  if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
638
657
  if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
639
- summary.append(f"Host key ...... {fingerprint}")
640
658
  summary.append(f"Key file ...... {key_path}")
659
+ for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
660
+ summary.append(f"\n")
641
661
  log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
642
662
 
643
663
  acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
@@ -656,18 +676,124 @@ async def start_server(args):
656
676
 
657
677
  # ── Main ──────────────────────────────────────────────────────────────
658
678
 
659
- # Quick --version-banner presets: keyword -> realistic 'SSH-2.0-<value>' banner.
660
- VERSION_PRESETS = {
661
- "ubuntu": "OpenSSH_9.6p1 Ubuntu-3ubuntu13.5",
662
- "debian": "OpenSSH_9.2p1 Debian-2+deb12u3",
663
- "dropbear": "dropbear_2022.83",
664
- "windows": "OpenSSH_for_Windows_9.5",
665
- "macos": "OpenSSH_9.8",
679
+ # ── Mimic presets ─────────────────────────────────────────────────────
680
+ # - algorithm lists below shape the cleartext KEXINIT
681
+ # - apply_mimic_patches() handles the monkey-patches of asyncssh
682
+ # - for full reasoning see 'mimic-refs/mimic-notes.md'
683
+
684
+ DEBIAN_ALGS = {
685
+ "kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
686
+ "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
687
+ "diffie-hellman-group-exchange-sha256", "diffie-hellman-group16-sha512",
688
+ "diffie-hellman-group18-sha512", "diffie-hellman-group14-sha256"],
689
+ "encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr",
690
+ "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"],
691
+ "mac_algs": ["umac-64-etm@openssh.com", "umac-128-etm@openssh.com",
692
+ "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
693
+ "hmac-sha1-etm@openssh.com", "umac-64@openssh.com", "umac-128@openssh.com",
694
+ "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1"],
695
+ "compression_algs": ["none", "zlib@openssh.com"],
696
+ "signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com", "ssh-rsa",
697
+ "rsa-sha2-256", "rsa-sha2-512", "ssh-dss",
698
+ "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
699
+ "sk-ecdsa-sha2-nistp256@openssh.com",
700
+ "webauthn-sk-ecdsa-sha2-nistp256@openssh.com"],
701
+ }
702
+
703
+ DROPBEAR_ALGS = {
704
+ "kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
705
+ "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256",
706
+ "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"],
707
+ "encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-ctr"],
708
+ "mac_algs": ["hmac-sha1", "hmac-sha2-256"],
709
+ "compression_algs": ["zlib@openssh.com", "none"],
710
+ "signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com",
711
+ "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
712
+ "sk-ecdsa-sha2-nistp256@openssh.com", "rsa-sha2-256", "ssh-rsa"],
713
+ }
714
+
715
+ MIMIC_PRESETS = {
716
+ "debian": ("OpenSSH_8.4p1 Debian-5+deb11u7", DEBIAN_ALGS), # Debian 11 (bullseye)
717
+ "dropbear": ("dropbear_2024.86", DROPBEAR_ALGS), # Dropbear 2024.86
718
+ "none": None # asyncssh defaults
666
719
  }
667
720
 
721
+ ADVERTISED_HOSTKEY_ALGS = {
722
+ "ssh-ed25519": [b"ssh-ed25519"],
723
+ "ecdsa-sha2-nistp256": [b"ecdsa-sha2-nistp256"],
724
+ "ssh-rsa": [b"rsa-sha2-512", b"rsa-sha2-256", b"ssh-rsa"],
725
+ }
726
+
727
+ MIMIC_HOSTKEYS = {
728
+ "debian": ["ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"], # debian: rsa+ecdsa+ed25519
729
+ "dropbear": ["ssh-ed25519"], # dropbear: ed25519 only
730
+ }
731
+
732
+
733
+ def select_host_keys(host_keys, mimic):
734
+ # Select fitting host keys for the mimicked server and order them in the default way
735
+ keys_by_algo = {k.get_algorithm(): k for k in host_keys}
736
+ wanted = MIMIC_HOSTKEYS.get(mimic, [])
737
+ missing = [a for a in wanted if a not in keys_by_algo]
738
+ if missing:
739
+ log_info(f"Host-keys missing: {missing} - a real {mimic} server provides "
740
+ f"those (delete the host-key file to auto-generate all types)",
741
+ level=logging.WARNING)
742
+ order = [a for a in wanted if a in keys_by_algo]
743
+ # return (keys_to_expose, advertised_algs)
744
+ if not order: return host_keys, None
745
+ return ([keys_by_algo[a] for a in order],
746
+ [alg for a in order for alg in ADVERTISED_HOSTKEY_ALGS[a]])
747
+
748
+
749
+ def apply_mimic_patches(mimic, host_key_algs=None):
750
+ # Monkey-patch asyncssh internals so the cleartext/pre-auth transport matches the mimicked server
751
+ from asyncssh.connection import SSHConnection, SSHServerConnection
752
+ from asyncssh.constants import MSG_IGNORE
753
+
754
+ def wrap(cls, name, fn):
755
+ orig = getattr(cls, name, None)
756
+ if orig is None:
757
+ raise RuntimeError(
758
+ f"Cannot apply --mimic {mimic}: asyncssh internal '{name}' changed. "
759
+ f"Refusing to run a fingerprintable disguise (use '--mimic none' to bypass)")
760
+ setattr(cls, name, functools.partialmethod(fn, orig))
761
+
762
+ def kex(self, orig):
763
+ # 1. Modify KEXINIT to match mimicked server
764
+ algs = [a for a in orig(self) if a != b"ext-info-s"]
765
+ if self.is_server() and mimic == "dropbear":
766
+ algs = [b"kexguess2@matt.ucc.asn.au"] + algs
767
+ return algs
768
+ wrap(SSHConnection, "_get_extra_kex_algs", kex)
769
+
770
+ def ext_info(self, orig):
771
+ # 2. Strip asyncssh 'global-requests-ok' from EXT_INFO
772
+ self._extensions_to_send.pop(b"global-requests-ok", None)
773
+ return orig(self)
774
+ wrap(SSHConnection, "_send_ext_info", ext_info)
775
+
776
+ def send_packet(self, orig, pkttype, *a, **kw):
777
+ # 3. Suppress asyncssh's SSH_MSG_IGNORE traffic-analysis chaff (added with
778
+ # OpenSSH 9.5). Safe to drop all because asyncssh only sends MSG_IGNORE
779
+ # as this chaff using self.send_packet() before each real packet
780
+ if pkttype != MSG_IGNORE:
781
+ return orig(self, pkttype, *a, **kw)
782
+ wrap(SSHConnection, "send_packet", send_packet)
783
+
784
+ if host_key_algs is not None:
785
+ # 4. Pin the advertised server host-key algorithms
786
+ algs = list(host_key_algs)
787
+ def init(self, orig, *a, **kw):
788
+ orig(self, *a, **kw)
789
+ self._server_host_key_algs = [x for x in algs if x in self._server_host_keys]
790
+ wrap(SSHServerConnection, "__init__", init)
791
+
792
+ # ── Startup and arguments ─────────────────────────────────────────────
793
+
668
794
  _description="""\
669
795
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
670
- and simple SCP transfers (NEVER opens a shell!).
796
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
671
797
  By default all features are disabled. Use flags to enable features.
672
798
  """
673
799
 
@@ -682,12 +808,11 @@ examples: (also check README on Github)
682
808
  %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
683
809
  # My favorite one
684
810
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
685
- # while posing shallowly as an Ubuntu SSH server on port 2222
686
- %(prog)s --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
811
+ %(prog)s --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
687
812
  """
688
813
 
689
814
  def build_parser(full=False):
690
- # Help got to long so splitting it into '-h' and '--help'
815
+ # Help got too long so splitting it into '-h' and '--help'
691
816
  def help_text(short_help=None, long_help=""):
692
817
  if full: return (f"{short_help} " if short_help else "") + long_help
693
818
  else: return short_help if short_help else argparse.SUPPRESS
@@ -709,8 +834,16 @@ def build_parser(full=False):
709
834
  parser.add_argument("-1", "--single", action="store_true",
710
835
  help=help_text(short_help="close the listener after first successful auth",
711
836
  long_help="(and exit when that connection ends)"))
837
+ parser.add_argument("--mimic", metavar="PRESET", type=str.lower,
838
+ choices=list(MIMIC_PRESETS), default="none",
839
+ help=help_text(long_help="pose as another SSH server "
840
+ f"- presets (case-insensitive): {', '.join(list(MIMIC_PRESETS))} "
841
+ "- match the preset's pre-auth (banner, KEXINIT, server-sig-algs, ...) "
842
+ "exactly - banner can be overridden by --version-banner "
843
+ "- check Github repository for details"))
712
844
  parser.add_argument("--host-key", metavar="FILE", type=Path,
713
- help=help_text(long_help="server host key file (default: auto-generate)"))
845
+ help=help_text(long_help="server host key file, may hold several keys "
846
+ "- auto-generated if missing - uses ./sshcatch_host_key by default"))
714
847
  parser.add_argument("--version", action="version",
715
848
  version=f"%(prog)s {__version__}",
716
849
  help=help_text(long_help="show program's version number and exit"))
@@ -742,9 +875,7 @@ def build_parser(full=False):
742
875
 
743
876
  banners = parser.add_argument_group("banners")
744
877
  banners.add_argument("--version-banner", metavar="STRING",
745
- help=help_text(long_help="sent as 'SSH-2.0-STRING' version banner - "
746
- "only first-glance deception, it can still be identified as asyncssh - "
747
- f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}"))
878
+ help=help_text(long_help="manually set 'SSH-2.0-STRING' version banner"))
748
879
  banners.add_argument("--pre-auth-banner", metavar="STRING",
749
880
  help=help_text(long_help="banner shown to every client before login"))
750
881
  banners.add_argument("--post-auth-banner", metavar="STRING",
@@ -781,10 +912,9 @@ def main():
781
912
  configure_logging(output=args.output, timestamps=args.timestamps,
782
913
  plain=args.plain, console_level=console_level)
783
914
 
784
- # Handle version-banner presets
785
- if args.version_banner:
786
- args.version_banner = VERSION_PRESETS.get(
787
- args.version_banner.lower(), args.version_banner)
915
+ # Handle --mimic version-banner
916
+ if args.mimic != "none" and not args.version_banner:
917
+ args.version_banner = MIMIC_PRESETS[args.mimic][0]
788
918
 
789
919
  # Validate user format
790
920
  if args.user:
@@ -803,10 +933,10 @@ def main():
803
933
  parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
804
934
 
805
935
  try: asyncio.run(start_server(args))
806
- except PermissionError:
807
- parser.error(f"Permission denied - port {args.port} requires root")
808
- except OSError as e:
936
+ except (OSError, ValueError) as e:
809
937
  parser.error(f"Could not start server: {e}")
938
+ except RuntimeError as e:
939
+ parser.error(str(e))
810
940
  except KeyboardInterrupt:
811
941
  print()
812
942
 
File without changes
File without changes