sshcatch 0.2.2__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,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.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
+
7
36
  ## [0.2.2] - 2026-07-31
8
37
 
9
38
  Multiple host keys now used as default.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshcatch
3
- Version: 0.2.2
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
@@ -50,7 +50,7 @@ to test.
50
50
  - configuring it through `sshd_configs` is a pain
51
51
  - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
52
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
53
+ - ForceCommands need to be set up to restrict the shell
54
54
 
55
55
  - My solution: `sshcatch`
56
56
  - Simply configure through clear flags and arguments on the commandline
@@ -124,9 +124,12 @@ Renames, deletes and directory removal are denied.
124
124
 
125
125
  ## Word of Warning
126
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.
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`.
130
133
 
131
134
  Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
132
135
  and everything else a real honeypot needs are deliberately out of scope. There are other
@@ -165,7 +168,7 @@ and saving them into a file:
165
168
 
166
169
  ```
167
170
  # Server
168
- sshcatch -1 -u arthur:42 --version-banner debian \
171
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
169
172
  --pre-auth-banner "What is the answer to life the universe and everything" \
170
173
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
171
174
  -o sshcatch.log -t
@@ -173,11 +176,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
173
176
 
174
177
 
175
178
  My favorite one: Reverse tunnel and SCP uploads for the keys in
176
- `./authorized-keys` while posing as an Ubuntu SSH server on port 2222:
179
+ `./authorized_keys` on port 2222:
177
180
 
178
181
  ```
179
182
  # Server
180
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
183
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
181
184
 
182
185
  # Client
183
186
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
@@ -191,15 +194,15 @@ scp -P 2222 loot.tar user@host:. # upload
191
194
  The full reference below is `sshcatch --help`:
192
195
 
193
196
  ```
194
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
195
- [--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]
196
199
  [--authorized-keys FILE] [--forward] [--reverse]
197
200
  [--scp-upload] [--scp-download] [--scp-dir DIR]
198
201
  [--version-banner STRING] [--pre-auth-banner STRING]
199
202
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
200
203
 
201
204
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
202
- and simple SCP transfers (NEVER opens a shell!).
205
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
203
206
  By default all features are disabled. Use flags to enable features.
204
207
 
205
208
  options:
@@ -209,6 +212,11 @@ options:
209
212
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
210
213
  -1, --single close the listener after first successful auth (and
211
214
  exit when that connection ends)
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
212
220
  --host-key FILE server host key file, may hold several keys - auto-
213
221
  generated if missing - uses ./sshcatch_host_key by
214
222
  default
@@ -237,10 +245,7 @@ SCP / SFTP file transfer:
237
245
 
238
246
  banners:
239
247
  --version-banner STRING
240
- sent as 'SSH-2.0-STRING' version banner - only first-
241
- glance deception, it can still be identified as
242
- asyncssh - presets (case-insensitive): ubuntu, debian,
243
- dropbear, windows, macos
248
+ manually set 'SSH-2.0-STRING' version banner
244
249
  --pre-auth-banner STRING
245
250
  banner shown to every client before login
246
251
  --post-auth-banner STRING
@@ -262,8 +267,7 @@ examples: (also check README on Github)
262
267
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
263
268
  # My favorite one
264
269
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
265
- # while posing shallowly as an Ubuntu SSH server on port 2222
266
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
270
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
267
271
  ```
268
272
 
269
273
  ## License
@@ -24,7 +24,7 @@ to test.
24
24
  - configuring it through `sshd_configs` is a pain
25
25
  - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
26
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
27
+ - ForceCommands need to be set up to restrict the shell
28
28
 
29
29
  - My solution: `sshcatch`
30
30
  - Simply configure through clear flags and arguments on the commandline
@@ -98,9 +98,12 @@ Renames, deletes and directory removal are denied.
98
98
 
99
99
  ## Word of Warning
100
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.
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`.
104
107
 
105
108
  Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
106
109
  and everything else a real honeypot needs are deliberately out of scope. There are other
@@ -139,7 +142,7 @@ and saving them into a file:
139
142
 
140
143
  ```
141
144
  # Server
142
- sshcatch -1 -u arthur:42 --version-banner debian \
145
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
143
146
  --pre-auth-banner "What is the answer to life the universe and everything" \
144
147
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
145
148
  -o sshcatch.log -t
@@ -147,11 +150,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
147
150
 
148
151
 
149
152
  My favorite one: Reverse tunnel and SCP uploads for the keys in
150
- `./authorized-keys` while posing as an Ubuntu SSH server on port 2222:
153
+ `./authorized_keys` on port 2222:
151
154
 
152
155
  ```
153
156
  # Server
154
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
157
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
155
158
 
156
159
  # Client
157
160
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
@@ -165,15 +168,15 @@ scp -P 2222 loot.tar user@host:. # upload
165
168
  The full reference below is `sshcatch --help`:
166
169
 
167
170
  ```
168
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
169
- [--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]
170
173
  [--authorized-keys FILE] [--forward] [--reverse]
171
174
  [--scp-upload] [--scp-download] [--scp-dir DIR]
172
175
  [--version-banner STRING] [--pre-auth-banner STRING]
173
176
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
174
177
 
175
178
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
176
- and simple SCP transfers (NEVER opens a shell!).
179
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
177
180
  By default all features are disabled. Use flags to enable features.
178
181
 
179
182
  options:
@@ -183,6 +186,11 @@ options:
183
186
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
184
187
  -1, --single close the listener after first successful auth (and
185
188
  exit when that connection ends)
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
186
194
  --host-key FILE server host key file, may hold several keys - auto-
187
195
  generated if missing - uses ./sshcatch_host_key by
188
196
  default
@@ -211,10 +219,7 @@ SCP / SFTP file transfer:
211
219
 
212
220
  banners:
213
221
  --version-banner STRING
214
- sent as 'SSH-2.0-STRING' version banner - only first-
215
- glance deception, it can still be identified as
216
- asyncssh - presets (case-insensitive): ubuntu, debian,
217
- dropbear, windows, macos
222
+ manually set 'SSH-2.0-STRING' version banner
218
223
  --pre-auth-banner STRING
219
224
  banner shown to every client before login
220
225
  --post-auth-banner STRING
@@ -236,8 +241,7 @@ examples: (also check README on Github)
236
241
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
237
242
  # My favorite one
238
243
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
239
- # while posing shallowly as an Ubuntu SSH server on port 2222
240
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
244
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
241
245
  ```
242
246
 
243
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,11 +1,12 @@
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
@@ -17,7 +18,7 @@ from itertools import count
17
18
 
18
19
  import asyncssh
19
20
 
20
- __version__ = "0.2.2"
21
+ __version__ = "0.3.0"
21
22
 
22
23
  # ── Logging ───────────────────────────────────────────────────────────
23
24
 
@@ -252,8 +253,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
252
253
  self._ensure_parent(new)
253
254
  target = old.decode(errors="replace")
254
255
  Path(os.fsdecode(self.map_path(new))).write_text(f"symlink -> {target}\n")
255
- self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)",
256
- logging.WARNING)
256
+ self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)", logging.WARNING)
257
257
 
258
258
  def setstat(self, path, attrs):
259
259
  # Allow (upload only): perms/timestamps on uploaded files
@@ -470,6 +470,11 @@ def make_server_factory(args, single_future=None):
470
470
  # always accept passwords so we can log them
471
471
  return True
472
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
+
473
478
  def validate_password(self, username, password):
474
479
  accepted = accept_password(username, password)
475
480
  if accepted:
@@ -545,9 +550,8 @@ def make_server_factory(args, single_future=None):
545
550
 
546
551
  # ── Server start ──────────────────────────────────────────────────────
547
552
 
548
- HOST_KEY_ALGS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
549
- HOST_KEY_OPTS = {"ssh-rsa": {"key_size": 3072}}
550
-
553
+ GENERATE_KEYS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
554
+ GENERATE_OPTS = {"ssh-rsa": {"key_size": 3072}}
551
555
 
552
556
  async def start_server(args):
553
557
  # Handle Host keys
@@ -563,13 +567,15 @@ async def start_server(args):
563
567
  if not host_keys: raise ValueError(f"No usable host key in {key_path}")
564
568
  log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
565
569
  else:
566
- host_keys = [asyncssh.generate_private_key(a, **HOST_KEY_OPTS.get(a, {}))
567
- for a in HOST_KEY_ALGS]
570
+ host_keys = [asyncssh.generate_private_key(a, **GENERATE_OPTS.get(a, {})) for a in GENERATE_KEYS]
568
571
  key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
569
572
  log_info(f"Generated host keys file: {key_path}")
570
573
  if os.name == "posix": key_path.chmod(0o600)
571
574
  else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
572
- fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in host_keys]
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]
573
579
 
574
580
  # for single-connection mode - resolving releases the bind on the listen port
575
581
  single_future = asyncio.get_running_loop().create_future() if args.single else None
@@ -578,12 +584,15 @@ async def start_server(args):
578
584
  server_factory, n_users, n_keys = make_server_factory(args, single_future)
579
585
  opts = {
580
586
  "server_factory": server_factory,
581
- "server_host_keys": host_keys,
587
+ "server_host_keys": exposed_keys,
582
588
  # SFTPv3 only so all transfers use open() and not open56()
583
589
  "sftp_version": 3,
584
590
  }
585
- if args.version_banner:
591
+ if args.version_banner:
586
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)
587
596
 
588
597
  has_scp = args.scp_upload or args.scp_download
589
598
  if has_scp:
@@ -643,10 +652,12 @@ async def start_server(args):
643
652
  if args.single: summary.append("Mode .......... single-connection")
644
653
  if has_scp: summary.append(f"SCP dir ....... {args.scp_dir.resolve()}")
645
654
  summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
655
+ summary.append(f"Mimic ......... {args.mimic}")
646
656
  if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
647
657
  if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
648
658
  summary.append(f"Key file ...... {key_path}")
649
659
  for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
660
+ summary.append(f"\n")
650
661
  log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
651
662
 
652
663
  acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
@@ -665,18 +676,124 @@ async def start_server(args):
665
676
 
666
677
  # ── Main ──────────────────────────────────────────────────────────────
667
678
 
668
- # Quick --version-banner presets: keyword -> realistic 'SSH-2.0-<value>' banner.
669
- VERSION_PRESETS = {
670
- "ubuntu": "OpenSSH_9.6p1 Ubuntu-3ubuntu13.5",
671
- "debian": "OpenSSH_9.2p1 Debian-2+deb12u3",
672
- "dropbear": "dropbear_2022.83",
673
- "windows": "OpenSSH_for_Windows_9.5",
674
- "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
675
719
  }
676
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
+
677
794
  _description="""\
678
795
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
679
- and simple SCP transfers (NEVER opens a shell!).
796
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
680
797
  By default all features are disabled. Use flags to enable features.
681
798
  """
682
799
 
@@ -691,8 +808,7 @@ examples: (also check README on Github)
691
808
  %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
692
809
  # My favorite one
693
810
  # Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
694
- # while posing shallowly as an Ubuntu SSH server on port 2222
695
- %(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
696
812
  """
697
813
 
698
814
  def build_parser(full=False):
@@ -718,6 +834,13 @@ def build_parser(full=False):
718
834
  parser.add_argument("-1", "--single", action="store_true",
719
835
  help=help_text(short_help="close the listener after first successful auth",
720
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"))
721
844
  parser.add_argument("--host-key", metavar="FILE", type=Path,
722
845
  help=help_text(long_help="server host key file, may hold several keys "
723
846
  "- auto-generated if missing - uses ./sshcatch_host_key by default"))
@@ -752,9 +875,7 @@ def build_parser(full=False):
752
875
 
753
876
  banners = parser.add_argument_group("banners")
754
877
  banners.add_argument("--version-banner", metavar="STRING",
755
- help=help_text(long_help="sent as 'SSH-2.0-STRING' version banner - "
756
- "only first-glance deception, it can still be identified as asyncssh - "
757
- f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}"))
878
+ help=help_text(long_help="manually set 'SSH-2.0-STRING' version banner"))
758
879
  banners.add_argument("--pre-auth-banner", metavar="STRING",
759
880
  help=help_text(long_help="banner shown to every client before login"))
760
881
  banners.add_argument("--post-auth-banner", metavar="STRING",
@@ -791,10 +912,9 @@ def main():
791
912
  configure_logging(output=args.output, timestamps=args.timestamps,
792
913
  plain=args.plain, console_level=console_level)
793
914
 
794
- # Handle version-banner presets
795
- if args.version_banner:
796
- args.version_banner = VERSION_PRESETS.get(
797
- 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]
798
918
 
799
919
  # Validate user format
800
920
  if args.user:
@@ -815,6 +935,8 @@ def main():
815
935
  try: asyncio.run(start_server(args))
816
936
  except (OSError, ValueError) as e:
817
937
  parser.error(f"Could not start server: {e}")
938
+ except RuntimeError as e:
939
+ parser.error(str(e))
818
940
  except KeyboardInterrupt:
819
941
  print()
820
942
 
File without changes
File without changes