sshcatch 0.2.2__tar.gz → 0.4.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.
@@ -209,6 +209,8 @@ tempCodeRunnerFile.py
209
209
  # PyPI configuration file
210
210
  .pypirc
211
211
 
212
+ CLAUDE.md
213
+
212
214
  # Marimo
213
215
  marimo/_static/
214
216
  marimo/_lsp/
@@ -4,6 +4,73 @@ 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.4.0] - 2026-09-14
8
+
9
+ Route forwards through an upstream proxy, choose the reverse bind, passwordless users.
10
+
11
+ ### Added
12
+
13
+ - **`--forward [SOCKS5]`** takes an optional SOCKS5 proxy (`--forward 127.0.0.1:1080`) and
14
+ routes every forwarded connection through it, transparent to the connecting client. The
15
+ new `socks5_forward()` helper connects with `rdns=True`, so hostnames (and their DNS) are
16
+ resolved by the proxy and never by the sshcatch host. Adds a dependency on `python-socks`.
17
+ - **`--forward-socks-auth USER:PASSWORD`** for upstream proxies that require credentials.
18
+ Refused unless `--forward` actually names a proxy.
19
+ - **`--reverse [BIND]`** takes an optional bind address (default `127.0.0.1`). sshcatch now
20
+ creates the reverse listener itself via `forward_local_port()` instead of handing the
21
+ request back to asyncssh, so the **client no longer decides which interface is exposed** -
22
+ `--reverse 0.0.0.0` is an explicit server-side choice.
23
+ - **Passwordless users.** `-u name:` (empty password) now accepts the SSH `none` method for
24
+ that user, which allows non-interactive logins.
25
+ - `parse_hostport()` validates `HOST:PORT` and `[IPv6]:PORT` arguments.
26
+ - **A pytest suite in `tests/`** (106 tests) covering auth, tunnels, SCP/SFTP, banners,
27
+ host keys, logging, mimicry and OpenSSH-client interop.
28
+
29
+ ### Changed
30
+
31
+ - `connection_requested()` and `server_requested()` are coroutines now. Both may have to
32
+ await an upstream connection or a listener before they can answer.
33
+ - **SCP/SFTP filesystem errors are reported through a single `_log_fs_error()`.** The generic
34
+ `ERROR` category is now `FSERROR`, and failures while listing a directory or auto-creating
35
+ a parent directory are logged as well instead of only surfacing on the client.
36
+ - `mimic-refs/capture.py` normalizes the local ssh client's own version lines, so a trace
37
+ stays comparable across client updates. The committed captures were re-normalized.
38
+
39
+ ### Fixed
40
+
41
+ - A reverse listener that cannot bind (address in use, privileged port) is logged with the
42
+ address and the reason instead of failing silently.
43
+
44
+
45
+ ## [0.3.0] - 2026-08-02
46
+
47
+ Pose as another SSH server.
48
+
49
+ ### Added
50
+
51
+ - **`--mimic debian` / `--mimic dropbear`** poses as a real OpenSSH 8.4p1 (Debian 11) or
52
+ Dropbear 2024.86 server: version banner, KEXINIT/HASSH, offered host keys and auth
53
+ methods match a capture of the real server. After authentication `sshcatch` differs from a
54
+ real server anyway, so `--mimic` only works pre-auth. Every modification to asyncssh,
55
+ the reasoning and the captures are in
56
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md).
57
+ - `select_host_keys()` offers only the host-key types the mimicked server has, in its order.
58
+
59
+ ### Changed
60
+
61
+ - **asyncssh is pinned to 2.24.0 exactly** - `--mimic` patches library internals, so a
62
+ different version could break the disguise silently. If a patched internal is gone,
63
+ sshcatch refuses to start.
64
+ - The sdist ships only what is needed to install the package (no `mimic-refs/`).
65
+ - The startup summary shows the active mimic preset.
66
+
67
+ ### Removed
68
+
69
+ - **`--version-banner` presets** (`ubuntu`, `debian`, `dropbear`, `windows`, `macos`)
70
+ are dropped and the flag now takes a literal string only, `--mimic` sets a fitting
71
+ banner itself.
72
+
73
+
7
74
  ## [0.2.2] - 2026-07-31
8
75
 
9
76
  Multiple host keys now used as default.
@@ -100,7 +167,7 @@ Large rewrite of the SFTP layer and the logging system.
100
167
 
101
168
  - **Symlink handling redesigned.**
102
169
  - On **download**, symlinks are denied (`_require_not_symlink`) and hidden from
103
- listings a transfer can never follow a link out of the chroot.
170
+ listings - a transfer can never follow a link out of the chroot.
104
171
  - On **upload**, a symlink is no longer rejected outright; instead a small
105
172
  placeholder file recording the target is written, so recursive uploads that
106
173
  contain a link still complete.
@@ -132,9 +199,9 @@ Large rewrite of the SFTP layer and the logging system.
132
199
 
133
200
  ### Removed
134
201
 
135
- - **`-K` / `--full-keys` flag** superseded by `-vv` verbosity.
202
+ - **`-K` / `--full-keys` flag** - superseded by `-vv` verbosity.
136
203
  - **Startup symlink scan** that refused to start if the SCP directory contained
137
- any symlink replaced by the per-operation symlink handling above.
204
+ any symlink - replaced by the per-operation symlink handling above.
138
205
 
139
206
  ### Security
140
207
 
@@ -142,7 +209,8 @@ Large rewrite of the SFTP layer and the logging system.
142
209
  traversal out of the chroot is blocked on every path-taking operation rather
143
210
  than only checked once at startup.
144
211
 
145
- ## [0.1.1]
212
+
213
+ ## [0.1.1] - 2026-07-12
146
214
 
147
215
  ### Fixed
148
216
 
@@ -151,6 +219,7 @@ Large rewrite of the SFTP layer and the logging system.
151
219
  its basename, so `./`-prefixed and other non-canonical paths are collapsed
152
220
  first and can no longer slip past the "root directory only" restriction.
153
221
 
154
- ## [0.1.0]
222
+
223
+ ## [0.1.0] - 2026-07-12
155
224
 
156
225
  - Initial version published on GitHub.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: sshcatch
3
- Version: 0.2.2
3
+ Version: 0.4.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,22 +21,27 @@ 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
+ Requires-Dist: python-socks>=3.0
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # sshcatch
28
29
 
30
+ [![PyPI](https://img.shields.io/pypi/v/sshcatch)](https://pypi.org/project/sshcatch/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/sshcatch)](https://pypi.org/project/sshcatch/)
32
+ [![License](https://img.shields.io/pypi/l/sshcatch)](https://github.com/LorenzMap/sshcatch/blob/main/LICENSE)
33
+
29
34
  A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP /
30
35
  SFTP transfers - it **never opens a shell**.
31
36
 
32
37
  By default all features are disabled: connections are logged and closed. Turn on
33
38
  only what you need with the flags described below. Handy on an engagement when
34
- you want a controlled SSH endpoint (a tunnel relay or a file drop) without
39
+ you want a controlled SSH endpoint (a tunnel relay or a file drop) without
35
40
  setting up a full `sshd`.
36
41
 
37
42
  Built on [asyncssh](https://github.com/ronf/asyncssh).
38
43
 
39
- This is a pentesting tool. Only point it at systems and networks you are authorized
44
+ This is a pentesting tool. Only point it at systems and networks you are authorized
40
45
  to test.
41
46
 
42
47
  ## Why this tool exists
@@ -48,15 +53,15 @@ to test.
48
53
 
49
54
  - `sshd` can be used, but:
50
55
  - configuring it through `sshd_configs` is a pain
51
- - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
56
+ - multiple use cases require different configs (tunnel direction? sftp direction? different ports?)
52
57
  - 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
58
+ - ForceCommands need to be set up to restrict the shell
54
59
 
55
60
  - My solution: `sshcatch`
56
- - Simply configure through clear flags and arguments on the commandline
61
+ - Simply configure through clear flags and arguments on the command line
57
62
  - restrictive defaults, every feature must be enabled consciously
58
63
  - Never allow shells (or commands)
59
- - Forward/Reverse tunnels can be individually activated
64
+ - Forward/Reverse tunnels can be individually activated
60
65
  - SCP/SFTP file uploads and downloads can be individually activated
61
66
  - restrictive upload handling to prevent overwriting
62
67
  - symlinks denied
@@ -90,11 +95,11 @@ at your own file).
90
95
 
91
96
  ## How it works
92
97
 
93
- Without any flags sshcatch is in **log-only** mode: It accepts the
98
+ Without any flags sshcatch is in **log-only** mode: it accepts the
94
99
  connection, records the client version, username, offered passwords and public
95
100
  keys, then closes. Nothing else is enabled until you ask for it.
96
101
 
97
- Adjust the amount of logging using `-vv` and `-q`. Or put everything into a
102
+ Adjust the amount of logging using `-vv` and `-q`. Or put everything into a
98
103
  logfile using `-o`.
99
104
 
100
105
  #### Tunnels
@@ -109,31 +114,62 @@ Only plain **TCP** forwards are ever available. UNIX-domain-socket forwards
109
114
  (`ssh -L /sock:...` / `-R /sock:...`) and TUN/TAP tunnels (`ssh -w`) are always
110
115
  denied, even with `--forward` / `--reverse` set.
111
116
 
117
+ `--forward` enables forward tunnels. A client connects with `ssh -L` / `ssh -D`
118
+ and may then reach any destination through the network stack of the host
119
+ sshcatch runs on. To chain all of those forwarded connections into an upstream
120
+ SOCKS5 proxy, use `--forward 127.0.0.1:1080`. This is transparent to the
121
+ connecting client. If the proxy needs credentials, pass them with
122
+ `--forward-socks-auth`. If you want to further restrict what a client can do
123
+ via the network, check out my Project [`socksscope`](https://github.com/LorenzMap/socksscope)!
124
+
125
+ `--reverse` enables reverse tunnels. A client connects with `ssh -R`, which
126
+ opens a listening port on the host running sshcatch. Connections to that port
127
+ are routed through the network stack of the client. Normal SSH servers let the
128
+ client choose the interface that port is bound to. sshcatch overrides this and
129
+ always binds to localhost. Pass a BIND address (`--reverse 0.0.0.0`) to listen
130
+ somewhere else.
131
+
112
132
  #### SCP / SFTP
113
133
 
114
134
  **Symlinks** are handled very restrictively: On upload they create a placeholder file
115
- that contains the original target. On download they are outright denied.
135
+ that contains the original target. On download they are outright denied.
116
136
 
117
- The host key, `authorized_keys`, logfile and the sshcatch script itself are **protected**
137
+ The host key, `authorized_keys`, logfile and the sshcatch script itself are **protected**
118
138
  and hidden when they live inside the SCP directory.
119
139
 
120
- Uploads **never overwrite** an existing file. The new file gets a numeric suffix
140
+ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
121
141
  (`loot.tar` -> `loot_1.tar`). Non-existent parent folders are created.
122
142
 
123
143
  Renames, deletes and directory removal are denied.
124
144
 
145
+ #### Authentication
146
+
147
+ `-u name:pass` is a normal password login (repeatable). Leave the password empty
148
+ (`-u name:`) to make that user passwordless, which allows easy non-interactive
149
+ logins. Because passwordless users are inherently insecure, it is best to
150
+ combine them with `-1`, which closes the listener after the first successful
151
+ login (the server stays up until that one connection closes).
152
+
153
+ You can also enable key-based authentication by pointing `--authorized-keys FILE`
154
+ at an "authorized_keys" file.
155
+
156
+ Or use `--open-auth` to disable authentication entirely. That is obviously
157
+ insecure, but it is there if you have a valid use case for it.
158
+
125
159
  ## Word of Warning
126
160
 
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.
161
+ Only using `--version-banner` obviously isn't enough deception against a sufficiently
162
+ sophisticated observer because some of the data transferred in cleartext on the wire
163
+ during connection establishment is a clear tell. Use `--mimic` if that's something you
164
+ want to try and dodge in an engagement. Check out
165
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
166
+ for details about `--mimic`.
130
167
 
131
- Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
168
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
132
169
  and everything else a real honeypot needs are deliberately out of scope. There are other
133
170
  projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
134
171
  [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
135
172
 
136
-
137
173
  ## Examples
138
174
 
139
175
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -148,6 +184,17 @@ scp -r loot/ user@host:pete/pc/
148
184
  sftp user@host
149
185
  ```
150
186
 
187
+ Passwordless single-use forward. The username is the only "auth". The server
188
+ keeps serving the first connection but accepts no new ones:
189
+
190
+ ```
191
+ # Server
192
+ sshcatch -1 -u agent: --forward
193
+
194
+ # Client
195
+ ssh -NL 8080:internal:80 agent@host # no password, no key
196
+ ```
197
+
151
198
  Let anyone tunnel through the server (local and dynamic forwards): **Be careful with this one!**
152
199
 
153
200
  ```
@@ -159,47 +206,64 @@ ssh -NL 8080:internal:80 user@host # local forward
159
206
  ssh -ND 1080 user@host # dynamic (SOCKS)
160
207
  ```
161
208
 
209
+ Expose a reverse listener on all interfaces (default is only localhost):
210
+
211
+ ```
212
+ # Server
213
+ sshcatch --open-auth --reverse 0.0.0.0
214
+ ```
215
+
216
+ Forward directly to an upstream SOCKS pivot with plain SSH. A teammate uses a normal dynamic
217
+ forward and their traffic (and DNS) egresses through the proxy:
218
+
219
+ ```
220
+ # Server (chains every forward into the SOCKS5 pivot on :1080)
221
+ sshcatch -u arthur:42 --forward 127.0.0.1:1080
222
+
223
+ # Client
224
+ ssh -ND 1080 user@host
225
+ ```
226
+
162
227
  Using single-mode to return something to the first successful authentication
163
228
  by closing the server afterwards, while printing timestamped logs to the console
164
229
  and saving them into a file:
165
230
 
166
231
  ```
167
232
  # Server
168
- sshcatch -1 -u arthur:42 --version-banner debian \
233
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
169
234
  --pre-auth-banner "What is the answer to life the universe and everything" \
170
235
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
171
236
  -o sshcatch.log -t
172
237
  ```
173
238
 
174
-
175
239
  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:
240
+ `./authorized_keys` on port 2222:
177
241
 
178
242
  ```
179
243
  # Server
180
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
244
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
181
245
 
182
246
  # Client
183
247
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
184
248
  scp -P 2222 loot.tar user@host:. # upload
185
249
  ```
186
250
 
187
-
188
251
  ## Options
189
252
 
190
253
  `sshcatch -h` prints a short summary with just the flags you need to get going.
191
254
  The full reference below is `sshcatch --help`:
192
255
 
193
256
  ```
194
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
195
- [--version] [-u USER:PASS] [--open-auth]
196
- [--authorized-keys FILE] [--forward] [--reverse]
257
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
258
+ [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
259
+ [--authorized-keys FILE] [--forward [SOCKS5]]
260
+ [--forward-socks-auth USER:PASSWORD] [--reverse [BIND]]
197
261
  [--scp-upload] [--scp-download] [--scp-dir DIR]
198
262
  [--version-banner STRING] [--pre-auth-banner STRING]
199
263
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
200
264
 
201
265
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
202
- and simple SCP transfers (NEVER opens a shell!).
266
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
203
267
  By default all features are disabled. Use flags to enable features.
204
268
 
205
269
  options:
@@ -209,6 +273,11 @@ options:
209
273
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
210
274
  -1, --single close the listener after first successful auth (and
211
275
  exit when that connection ends)
276
+ --mimic PRESET pose as another SSH server - presets (case-
277
+ insensitive): debian, dropbear, none - match the
278
+ preset's pre-auth (banner, KEXINIT, server-sig-algs,
279
+ ...) exactly - banner can be overridden by --version-
280
+ banner - check Github repository for details
212
281
  --host-key FILE server host key file, may hold several keys - auto-
213
282
  generated if missing - uses ./sshcatch_host_key by
214
283
  default
@@ -216,15 +285,22 @@ options:
216
285
 
217
286
  authentication:
218
287
  -u USER:PASS, --user USER:PASS
219
- allowed user:password (repeatable)
288
+ allowed user:password (repeatable) - an empty password
289
+ (user:) allows login by username only
220
290
  --open-auth accept any credentials (open mode)
221
291
  --authorized-keys FILE
222
292
  authorized_keys file for key auth (username
223
293
  independent)
224
294
 
225
295
  tunneling:
226
- --forward enable forward tunnels (client: ssh -NL / -ND)
227
- --reverse enable reverse tunnels (client: ssh -NR)
296
+ --forward [SOCKS5] enable forward tunnels (client: ssh -NL / -ND) -
297
+ optional SOCKS5 proxy HOST:PORT to route every
298
+ forwarded connection through (e.g. 127.0.0.1:1080; the
299
+ proxy resolves DNS)
300
+ --forward-socks-auth USER:PASSWORD
301
+ username:password for the --forward SOCKS5 proxy
302
+ --reverse [BIND] enable reverse tunnels (client: ssh -NR) - optional
303
+ BIND address to listen on (default: 127.0.0.1)
228
304
 
229
305
  SCP / SFTP file transfer:
230
306
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
@@ -237,10 +313,7 @@ SCP / SFTP file transfer:
237
313
 
238
314
  banners:
239
315
  --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
316
+ manually set 'SSH-2.0-STRING' version banner
244
317
  --pre-auth-banner STRING
245
318
  banner shown to every client before login
246
319
  --post-auth-banner STRING
@@ -262,10 +335,18 @@ examples: (also check README on Github)
262
335
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
263
336
  # My favorite one
264
337
  # 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
338
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
267
339
  ```
268
340
 
341
+ ## Testing
342
+
343
+ - the test suite lives in `tests/` (pytest)
344
+ - run it from a virtualenv with the runtime and dev dependencies installed
345
+ (`asyncssh`, `python-socks`, `pytest`, `coverage`)
346
+ - the interop tests also need the OpenSSH client tools and `sshpass` on `PATH`
347
+ - run via `python -m pytest` or `tests/test.sh`
348
+ - to get the coverage of the tests, run `tests/test.sh cov`
349
+
269
350
  ## License
270
351
 
271
352
  MIT
@@ -1,16 +1,20 @@
1
1
  # sshcatch
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/sshcatch)](https://pypi.org/project/sshcatch/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/sshcatch)](https://pypi.org/project/sshcatch/)
5
+ [![License](https://img.shields.io/pypi/l/sshcatch)](https://github.com/LorenzMap/sshcatch/blob/main/LICENSE)
6
+
3
7
  A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP /
4
8
  SFTP transfers - it **never opens a shell**.
5
9
 
6
10
  By default all features are disabled: connections are logged and closed. Turn on
7
11
  only what you need with the flags described below. Handy on an engagement when
8
- you want a controlled SSH endpoint (a tunnel relay or a file drop) without
12
+ you want a controlled SSH endpoint (a tunnel relay or a file drop) without
9
13
  setting up a full `sshd`.
10
14
 
11
15
  Built on [asyncssh](https://github.com/ronf/asyncssh).
12
16
 
13
- This is a pentesting tool. Only point it at systems and networks you are authorized
17
+ This is a pentesting tool. Only point it at systems and networks you are authorized
14
18
  to test.
15
19
 
16
20
  ## Why this tool exists
@@ -22,15 +26,15 @@ to test.
22
26
 
23
27
  - `sshd` can be used, but:
24
28
  - configuring it through `sshd_configs` is a pain
25
- - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
29
+ - multiple use cases require different configs (tunnel direction? sftp direction? different ports?)
26
30
  - 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
31
+ - ForceCommands need to be set up to restrict the shell
28
32
 
29
33
  - My solution: `sshcatch`
30
- - Simply configure through clear flags and arguments on the commandline
34
+ - Simply configure through clear flags and arguments on the command line
31
35
  - restrictive defaults, every feature must be enabled consciously
32
36
  - Never allow shells (or commands)
33
- - Forward/Reverse tunnels can be individually activated
37
+ - Forward/Reverse tunnels can be individually activated
34
38
  - SCP/SFTP file uploads and downloads can be individually activated
35
39
  - restrictive upload handling to prevent overwriting
36
40
  - symlinks denied
@@ -64,11 +68,11 @@ at your own file).
64
68
 
65
69
  ## How it works
66
70
 
67
- Without any flags sshcatch is in **log-only** mode: It accepts the
71
+ Without any flags sshcatch is in **log-only** mode: it accepts the
68
72
  connection, records the client version, username, offered passwords and public
69
73
  keys, then closes. Nothing else is enabled until you ask for it.
70
74
 
71
- Adjust the amount of logging using `-vv` and `-q`. Or put everything into a
75
+ Adjust the amount of logging using `-vv` and `-q`. Or put everything into a
72
76
  logfile using `-o`.
73
77
 
74
78
  #### Tunnels
@@ -83,31 +87,62 @@ Only plain **TCP** forwards are ever available. UNIX-domain-socket forwards
83
87
  (`ssh -L /sock:...` / `-R /sock:...`) and TUN/TAP tunnels (`ssh -w`) are always
84
88
  denied, even with `--forward` / `--reverse` set.
85
89
 
90
+ `--forward` enables forward tunnels. A client connects with `ssh -L` / `ssh -D`
91
+ and may then reach any destination through the network stack of the host
92
+ sshcatch runs on. To chain all of those forwarded connections into an upstream
93
+ SOCKS5 proxy, use `--forward 127.0.0.1:1080`. This is transparent to the
94
+ connecting client. If the proxy needs credentials, pass them with
95
+ `--forward-socks-auth`. If you want to further restrict what a client can do
96
+ via the network, check out my Project [`socksscope`](https://github.com/LorenzMap/socksscope)!
97
+
98
+ `--reverse` enables reverse tunnels. A client connects with `ssh -R`, which
99
+ opens a listening port on the host running sshcatch. Connections to that port
100
+ are routed through the network stack of the client. Normal SSH servers let the
101
+ client choose the interface that port is bound to. sshcatch overrides this and
102
+ always binds to localhost. Pass a BIND address (`--reverse 0.0.0.0`) to listen
103
+ somewhere else.
104
+
86
105
  #### SCP / SFTP
87
106
 
88
107
  **Symlinks** are handled very restrictively: On upload they create a placeholder file
89
- that contains the original target. On download they are outright denied.
108
+ that contains the original target. On download they are outright denied.
90
109
 
91
- The host key, `authorized_keys`, logfile and the sshcatch script itself are **protected**
110
+ The host key, `authorized_keys`, logfile and the sshcatch script itself are **protected**
92
111
  and hidden when they live inside the SCP directory.
93
112
 
94
- Uploads **never overwrite** an existing file. The new file gets a numeric suffix
113
+ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
95
114
  (`loot.tar` -> `loot_1.tar`). Non-existent parent folders are created.
96
115
 
97
116
  Renames, deletes and directory removal are denied.
98
117
 
118
+ #### Authentication
119
+
120
+ `-u name:pass` is a normal password login (repeatable). Leave the password empty
121
+ (`-u name:`) to make that user passwordless, which allows easy non-interactive
122
+ logins. Because passwordless users are inherently insecure, it is best to
123
+ combine them with `-1`, which closes the listener after the first successful
124
+ login (the server stays up until that one connection closes).
125
+
126
+ You can also enable key-based authentication by pointing `--authorized-keys FILE`
127
+ at an "authorized_keys" file.
128
+
129
+ Or use `--open-auth` to disable authentication entirely. That is obviously
130
+ insecure, but it is there if you have a valid use case for it.
131
+
99
132
  ## Word of Warning
100
133
 
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.
134
+ Only using `--version-banner` obviously isn't enough deception against a sufficiently
135
+ sophisticated observer because some of the data transferred in cleartext on the wire
136
+ during connection establishment is a clear tell. Use `--mimic` if that's something you
137
+ want to try and dodge in an engagement. Check out
138
+ [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
139
+ for details about `--mimic`.
104
140
 
105
- Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
141
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
106
142
  and everything else a real honeypot needs are deliberately out of scope. There are other
107
143
  projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
108
144
  [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
109
145
 
110
-
111
146
  ## Examples
112
147
 
113
148
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -122,6 +157,17 @@ scp -r loot/ user@host:pete/pc/
122
157
  sftp user@host
123
158
  ```
124
159
 
160
+ Passwordless single-use forward. The username is the only "auth". The server
161
+ keeps serving the first connection but accepts no new ones:
162
+
163
+ ```
164
+ # Server
165
+ sshcatch -1 -u agent: --forward
166
+
167
+ # Client
168
+ ssh -NL 8080:internal:80 agent@host # no password, no key
169
+ ```
170
+
125
171
  Let anyone tunnel through the server (local and dynamic forwards): **Be careful with this one!**
126
172
 
127
173
  ```
@@ -133,47 +179,64 @@ ssh -NL 8080:internal:80 user@host # local forward
133
179
  ssh -ND 1080 user@host # dynamic (SOCKS)
134
180
  ```
135
181
 
182
+ Expose a reverse listener on all interfaces (default is only localhost):
183
+
184
+ ```
185
+ # Server
186
+ sshcatch --open-auth --reverse 0.0.0.0
187
+ ```
188
+
189
+ Forward directly to an upstream SOCKS pivot with plain SSH. A teammate uses a normal dynamic
190
+ forward and their traffic (and DNS) egresses through the proxy:
191
+
192
+ ```
193
+ # Server (chains every forward into the SOCKS5 pivot on :1080)
194
+ sshcatch -u arthur:42 --forward 127.0.0.1:1080
195
+
196
+ # Client
197
+ ssh -ND 1080 user@host
198
+ ```
199
+
136
200
  Using single-mode to return something to the first successful authentication
137
201
  by closing the server afterwards, while printing timestamped logs to the console
138
202
  and saving them into a file:
139
203
 
140
204
  ```
141
205
  # Server
142
- sshcatch -1 -u arthur:42 --version-banner debian \
206
+ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
143
207
  --pre-auth-banner "What is the answer to life the universe and everything" \
144
208
  --post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
145
209
  -o sshcatch.log -t
146
210
  ```
147
211
 
148
-
149
212
  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:
213
+ `./authorized_keys` on port 2222:
151
214
 
152
215
  ```
153
216
  # Server
154
- sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
217
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
155
218
 
156
219
  # Client
157
220
  ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
158
221
  scp -P 2222 loot.tar user@host:. # upload
159
222
  ```
160
223
 
161
-
162
224
  ## Options
163
225
 
164
226
  `sshcatch -h` prints a short summary with just the flags you need to get going.
165
227
  The full reference below is `sshcatch --help`:
166
228
 
167
229
  ```
168
- usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--host-key FILE]
169
- [--version] [-u USER:PASS] [--open-auth]
170
- [--authorized-keys FILE] [--forward] [--reverse]
230
+ usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
231
+ [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
232
+ [--authorized-keys FILE] [--forward [SOCKS5]]
233
+ [--forward-socks-auth USER:PASSWORD] [--reverse [BIND]]
171
234
  [--scp-upload] [--scp-download] [--scp-dir DIR]
172
235
  [--version-banner STRING] [--pre-auth-banner STRING]
173
236
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
174
237
 
175
238
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
176
- and simple SCP transfers (NEVER opens a shell!).
239
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
177
240
  By default all features are disabled. Use flags to enable features.
178
241
 
179
242
  options:
@@ -183,6 +246,11 @@ options:
183
246
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
184
247
  -1, --single close the listener after first successful auth (and
185
248
  exit when that connection ends)
249
+ --mimic PRESET pose as another SSH server - presets (case-
250
+ insensitive): debian, dropbear, none - match the
251
+ preset's pre-auth (banner, KEXINIT, server-sig-algs,
252
+ ...) exactly - banner can be overridden by --version-
253
+ banner - check Github repository for details
186
254
  --host-key FILE server host key file, may hold several keys - auto-
187
255
  generated if missing - uses ./sshcatch_host_key by
188
256
  default
@@ -190,15 +258,22 @@ options:
190
258
 
191
259
  authentication:
192
260
  -u USER:PASS, --user USER:PASS
193
- allowed user:password (repeatable)
261
+ allowed user:password (repeatable) - an empty password
262
+ (user:) allows login by username only
194
263
  --open-auth accept any credentials (open mode)
195
264
  --authorized-keys FILE
196
265
  authorized_keys file for key auth (username
197
266
  independent)
198
267
 
199
268
  tunneling:
200
- --forward enable forward tunnels (client: ssh -NL / -ND)
201
- --reverse enable reverse tunnels (client: ssh -NR)
269
+ --forward [SOCKS5] enable forward tunnels (client: ssh -NL / -ND) -
270
+ optional SOCKS5 proxy HOST:PORT to route every
271
+ forwarded connection through (e.g. 127.0.0.1:1080; the
272
+ proxy resolves DNS)
273
+ --forward-socks-auth USER:PASSWORD
274
+ username:password for the --forward SOCKS5 proxy
275
+ --reverse [BIND] enable reverse tunnels (client: ssh -NR) - optional
276
+ BIND address to listen on (default: 127.0.0.1)
202
277
 
203
278
  SCP / SFTP file transfer:
204
279
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
@@ -211,10 +286,7 @@ SCP / SFTP file transfer:
211
286
 
212
287
  banners:
213
288
  --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
289
+ manually set 'SSH-2.0-STRING' version banner
218
290
  --pre-auth-banner STRING
219
291
  banner shown to every client before login
220
292
  --post-auth-banner STRING
@@ -236,10 +308,18 @@ examples: (also check README on Github)
236
308
  sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
237
309
  # My favorite one
238
310
  # 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
311
+ sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
241
312
  ```
242
313
 
314
+ ## Testing
315
+
316
+ - the test suite lives in `tests/` (pytest)
317
+ - run it from a virtualenv with the runtime and dev dependencies installed
318
+ (`asyncssh`, `python-socks`, `pytest`, `coverage`)
319
+ - the interop tests also need the OpenSSH client tools and `sshpass` on `PATH`
320
+ - run via `python -m pytest` or `tests/test.sh`
321
+ - to get the coverage of the tests, run `tests/test.sh cov`
322
+
243
323
  ## License
244
324
 
245
325
  MIT
@@ -25,7 +25,21 @@ classifiers = [
25
25
  "Topic :: Security",
26
26
  "Topic :: System :: Networking",
27
27
  ]
28
- dependencies = ["asyncssh>=2.21.0,<2.25.0"]
28
+ # asyncssh pinned exactly due to mimic monkey-patches on its internals (see mimic-refs/mimic-notes.md)
29
+ dependencies = ["asyncssh==2.24.0", "python-socks>=3.0"]
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8", "coverage>=7"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ addopts = "-ra"
37
+
38
+ [tool.coverage.run]
39
+ source = ["sshcatch"]
40
+ parallel = true
41
+ sigterm = true
42
+ branch = true
29
43
 
30
44
  [project.urls]
31
45
  Homepage = "https://github.com/LorenzMap/sshcatch"
@@ -40,3 +54,6 @@ path = "sshcatch.py"
40
54
 
41
55
  [tool.hatch.build.targets.wheel]
42
56
  only-include = ["sshcatch.py"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ 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
@@ -16,8 +17,10 @@ from pathlib import Path
16
17
  from itertools import count
17
18
 
18
19
  import asyncssh
20
+ from python_socks import ProxyError, ProxyType
21
+ from python_socks.async_.asyncio import Proxy
19
22
 
20
- __version__ = "0.2.2"
23
+ __version__ = "0.4.0"
21
24
 
22
25
  # ── Logging ───────────────────────────────────────────────────────────
23
26
 
@@ -114,13 +117,16 @@ class SFTPCatchServer(asyncssh.SFTPServer):
114
117
  self._log_scp(f"DENIED {detail} ({reason})", level)
115
118
  raise asyncssh.SFTPPermissionDenied("Permission denied")
116
119
 
120
+ def _log_fs_error(self, path, error):
121
+ if isinstance(error, FileNotFoundError):
122
+ self._log_scp(f"NOTFOUND {self._local_path(path)}", logging.WARNING)
123
+ else:
124
+ self._log_scp(f"FSERROR {self._local_path(path)} ({error.strerror or error})", logging.WARNING)
125
+
117
126
  def _execute_wrapped_log(self, path, fn, *args):
118
127
  try: return fn(*args)
119
- except FileNotFoundError:
120
- self._log_scp(f"NOTFOUND {self._local_path(path)}", logging.WARNING)
121
- raise
122
- except OSError as e:
123
- self._log_scp(f"ERROR {self._local_path(path)} ({e.strerror or e})", logging.WARNING)
128
+ except OSError as error:
129
+ self._log_fs_error(path, error)
124
130
  raise
125
131
 
126
132
  # ── path checks ───────────────────────────────────────────────
@@ -161,7 +167,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
161
167
  def _ensure_parent(self, path):
162
168
  parent = Path(os.fsdecode(self.map_path(path))).parent
163
169
  if not parent.is_dir():
164
- parent.mkdir(parents=True, exist_ok=True)
170
+ self._execute_wrapped_log(path, lambda: parent.mkdir(parents=True, exist_ok=True))
165
171
  self._log_scp(f"MKPARENT {self._local_path(path)}", logging.INFO)
166
172
 
167
173
  def _local_path(self, path):
@@ -252,8 +258,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
252
258
  self._ensure_parent(new)
253
259
  target = old.decode(errors="replace")
254
260
  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)
261
+ self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)", logging.WARNING)
257
262
 
258
263
  def setstat(self, path, attrs):
259
264
  # Allow (upload only): perms/timestamps on uploaded files
@@ -289,16 +294,20 @@ class SFTPCatchServer(asyncssh.SFTPServer):
289
294
  self._require_not_symlink(path)
290
295
  self._log_scp(f"LISTDIR {self._local_path_log(path)}", logging.DEBUG)
291
296
  # hide protected files and symlinks
292
- async for name in super().scandir(path):
293
- if name.filename not in (b".", b".."):
294
- mapped = self.map_path(posixpath.join(path, name.filename))
295
- relative = self.reverse_map_path(mapped).decode(errors="replace").lstrip("/")
296
- if relative in self._protected_files:
297
- continue
298
- if os.path.islink(os.fsdecode(mapped)):
299
- self._log_scp(f"SKIP symlink {relative}", logging.DEBUG)
300
- continue
301
- yield name
297
+ try:
298
+ async for name in super().scandir(path):
299
+ if name.filename not in (b".", b".."):
300
+ mapped = self.map_path(posixpath.join(path, name.filename))
301
+ relative = self.reverse_map_path(mapped).decode(errors="replace").lstrip("/")
302
+ if relative in self._protected_files:
303
+ continue
304
+ if os.path.islink(os.fsdecode(mapped)):
305
+ self._log_scp(f"SKIP symlink {relative}", logging.DEBUG)
306
+ continue
307
+ yield name
308
+ except OSError as error:
309
+ self._log_fs_error(path, error)
310
+ raise
302
311
 
303
312
  # read - file download after open
304
313
 
@@ -353,6 +362,21 @@ class SFTPCatchServer(asyncssh.SFTPServer):
353
362
  self._deny_sftp("UNLOCK")
354
363
 
355
364
 
365
+ # ── SOCKS5 upstream ───────────────────────────────────────────────────
366
+
367
+ async def socks5_forward(loop, proxy, dest_host, dest_port, auth):
368
+ # Connect to dest through the SOCKS5 proxy; returns the channel-side
369
+ # SSHForwarder to hand back from connection_requested
370
+ proxy_host, proxy_port = proxy
371
+ user, password = auth if auth else (None, None)
372
+ # rdns=True so the destination name is resolved by the proxy, not by us
373
+ socks = Proxy(proxy_type=ProxyType.SOCKS5, host=proxy_host, port=proxy_port,
374
+ username=user, password=password, rdns=True, loop=loop)
375
+ sock = await socks.connect(dest_host=dest_host, dest_port=dest_port)
376
+ _, peer = await loop.create_connection(asyncssh.forward.SSHForwarder, sock=sock)
377
+ return asyncssh.forward.SSHForwarder(peer)
378
+
379
+
356
380
  # ── SSH server factory ────────────────────────────────────────────────
357
381
 
358
382
  def make_server_factory(args, single_future=None):
@@ -435,6 +459,9 @@ def make_server_factory(args, single_future=None):
435
459
  self._log_client_version()
436
460
  if args.pre_auth_banner:
437
461
  self._send_banner(args.pre_auth_banner)
462
+ if users.get(username) == "":
463
+ log_auth("Login accepted (passwordless user)", success=True, addr=self._addr, user=username)
464
+ return False
438
465
  return True
439
466
 
440
467
  def _post_auth(self):
@@ -470,6 +497,11 @@ def make_server_factory(args, single_future=None):
470
497
  # always accept passwords so we can log them
471
498
  return True
472
499
 
500
+ def kbdint_auth_supported(self):
501
+ # force publickey,password auth for mimic (drop keyboard-interactive)
502
+ if not args.mimic == "none": return False
503
+ return super().kbdint_auth_supported()
504
+
473
505
  def validate_password(self, username, password):
474
506
  accepted = accept_password(username, password)
475
507
  if accepted:
@@ -497,29 +529,52 @@ def make_server_factory(args, single_future=None):
497
529
 
498
530
  # ── ALLOWED (gated) ──────────────────────────────────────────
499
531
 
500
- def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
532
+ async def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
501
533
  # Allow (--forward): direct TCP forward (client: ssh -NL / -ND)
502
534
  route = f"{addr_str(orig_host, orig_port)} -> {addr_str(dest_host, dest_port)}"
503
535
  if not args.forward:
504
536
  return self._deny_tunnel(f"forward {route}", "Forwarding is disabled")
505
537
  user = self._conn.get_extra_info("username")
538
+ # --forward SOCKS: egress through the SOCKS5 proxy instead of a direct socket
539
+ if args.forward is not True:
540
+ proxy = addr_str(*args.forward)
541
+ try: fwd = await socks5_forward(asyncio.get_running_loop(), args.forward,
542
+ dest_host, dest_port, args.forward_socks_auth)
543
+ except (OSError, ProxyError) as e:
544
+ log_tunnel(f"Forward {route} via socks {proxy} failed ({e})",
545
+ addr=self._addr, user=user, level=logging.WARNING)
546
+ # This forward failes, SSH connection stays open
547
+ raise asyncssh.ChannelOpenError(
548
+ asyncssh.OPEN_CONNECT_FAILED, f"SOCKS proxy: {e}") from None
549
+ log_tunnel(f"Forward {route} via socks {proxy}", addr=self._addr, user=user)
550
+ return fwd
506
551
  log_tunnel(f"Forward {route}", addr=self._addr, user=user)
507
- return True
552
+ return await self._conn.forward_connection(dest_host, dest_port)
508
553
 
509
- def server_requested(self, listen_host, listen_port):
554
+ async def server_requested(self, listen_host, listen_port):
510
555
  # Allow (--reverse): remote TCP listen (client: ssh -NR)
511
556
  if not args.reverse:
512
557
  return self._deny_tunnel(f"reverse {addr_str(listen_host, listen_port)}", "Reverse is disabled")
513
558
  user = self._conn.get_extra_info("username")
514
- log_tunnel(f"Reverse listen on {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
515
559
 
516
560
  def accept(orig_host, orig_port):
517
561
  # Log the connection - real target is requested/resolved on the
518
562
  # client so we can't show it (decided against packet inspection)
519
563
  log_tunnel(f"Reverse {addr_str(orig_host, orig_port)} on "
520
- f"{addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
564
+ f"{addr_str(args.reverse, listener.get_port())}", addr=self._addr, user=user)
521
565
  return True
522
- return accept
566
+
567
+ # Create a listener so we control the bind address
568
+ try:
569
+ listener = await self._conn.forward_local_port(
570
+ args.reverse, listen_port, args.reverse, listen_port, accept)
571
+ except OSError as e:
572
+ log_tunnel(f"Reverse bind {addr_str(args.reverse, listen_port)} failed "
573
+ f"({e.strerror or e})", addr=self._addr, user=user, level=logging.WARNING)
574
+ raise
575
+ log_tunnel(f"Reverse listen on {addr_str(args.reverse, listener.get_port())}",
576
+ addr=self._addr, user=user)
577
+ return listener
523
578
 
524
579
  # ── DENIED forwarding/tunneling ──────────────────────────────
525
580
  # asyncssh rejects these by default, override to deny anyway and log them
@@ -540,14 +595,13 @@ def make_server_factory(args, single_future=None):
540
595
  # Deny: layer-2 TAP tunnel
541
596
  return self._deny_tunnel(f"tap unit={unit}")
542
597
 
543
- return SSHCatchServer, len(users), len(auth_keys_fps)
598
+ return SSHCatchServer, users, len(auth_keys_fps)
544
599
 
545
600
 
546
601
  # ── Server start ──────────────────────────────────────────────────────
547
602
 
548
- HOST_KEY_ALGS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
549
- HOST_KEY_OPTS = {"ssh-rsa": {"key_size": 3072}}
550
-
603
+ GENERATE_KEYS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
604
+ GENERATE_OPTS = {"ssh-rsa": {"key_size": 3072}}
551
605
 
552
606
  async def start_server(args):
553
607
  # Handle Host keys
@@ -563,27 +617,32 @@ async def start_server(args):
563
617
  if not host_keys: raise ValueError(f"No usable host key in {key_path}")
564
618
  log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
565
619
  else:
566
- host_keys = [asyncssh.generate_private_key(a, **HOST_KEY_OPTS.get(a, {}))
567
- for a in HOST_KEY_ALGS]
620
+ host_keys = [asyncssh.generate_private_key(a, **GENERATE_OPTS.get(a, {})) for a in GENERATE_KEYS]
568
621
  key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
569
622
  log_info(f"Generated host keys file: {key_path}")
570
623
  if os.name == "posix": key_path.chmod(0o600)
571
624
  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]
625
+
626
+ # Choose which keys to expose and what host-key algorithms to advertise
627
+ exposed_keys, host_key_algs = select_host_keys(host_keys, args.mimic)
628
+ fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in exposed_keys]
573
629
 
574
630
  # for single-connection mode - resolving releases the bind on the listen port
575
631
  single_future = asyncio.get_running_loop().create_future() if args.single else None
576
632
 
577
633
  # Build the connection options for the ssh server
578
- server_factory, n_users, n_keys = make_server_factory(args, single_future)
634
+ server_factory, users, n_keys = make_server_factory(args, single_future)
579
635
  opts = {
580
636
  "server_factory": server_factory,
581
- "server_host_keys": host_keys,
637
+ "server_host_keys": exposed_keys,
582
638
  # SFTPv3 only so all transfers use open() and not open56()
583
639
  "sftp_version": 3,
584
640
  }
585
- if args.version_banner:
641
+ if args.version_banner:
586
642
  opts["server_version"] = args.version_banner
643
+ if args.mimic != "none":
644
+ opts.update(MIMIC_PRESETS[args.mimic][1])
645
+ apply_mimic_patches(args.mimic, host_key_algs)
587
646
 
588
647
  has_scp = args.scp_upload or args.scp_download
589
648
  if has_scp:
@@ -624,13 +683,21 @@ async def start_server(args):
624
683
  if args.open_auth: auth_mode = "open (accept any)"
625
684
  elif args.user or args.authorized_keys:
626
685
  parts = []
627
- if args.user: parts.append(f"{n_users} user{'s'*(n_users!=1)}")
686
+ if args.user: parts.append(f"{len(users)} user{'s'*(len(users)!=1)}")
628
687
  if args.authorized_keys: parts.append(f"{n_keys} key{'s'*(n_keys!=1)}")
629
688
  auth_mode = f"restricted ({', '.join(parts)})"
689
+ pw_users = sorted(u for u, p in users.items() if p != "")
690
+ pwless_users = sorted(u for u, p in users.items() if p == "")
691
+ if pw_users: auth_mode += f"\n Users ................ {', '.join(pw_users)}"
692
+ if pwless_users: auth_mode += f"\n Users (no password) .. {', '.join(pwless_users)}"
630
693
  else: auth_mode = "reject all (no auth configured)"
631
694
  features = []
632
- if args.forward: features.append("forward-tunnel")
633
- if args.reverse: features.append("reverse-tunnel")
695
+ if args.forward:
696
+ if args.forward is True: features.append("forward-tunnel")
697
+ else:
698
+ via = f"forward-tunnel (via socks {addr_str(*args.forward)}"
699
+ features.append(via + (", auth)" if args.forward_socks_auth else ")"))
700
+ if args.reverse: features.append(f"reverse-tunnel ({args.reverse})")
634
701
  if args.scp_upload: features.append("scp-upload")
635
702
  if args.scp_download: features.append("scp-download")
636
703
  if not features: features.append("log-only (connect & close)")
@@ -643,10 +710,12 @@ async def start_server(args):
643
710
  if args.single: summary.append("Mode .......... single-connection")
644
711
  if has_scp: summary.append(f"SCP dir ....... {args.scp_dir.resolve()}")
645
712
  summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
713
+ summary.append(f"Mimic ......... {args.mimic}")
646
714
  if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
647
715
  if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
648
716
  summary.append(f"Key file ...... {key_path}")
649
717
  for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
718
+ summary.append(f"\n")
650
719
  log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
651
720
 
652
721
  acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
@@ -665,18 +734,137 @@ async def start_server(args):
665
734
 
666
735
  # ── Main ──────────────────────────────────────────────────────────────
667
736
 
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",
737
+ # ── Mimic presets ─────────────────────────────────────────────────────
738
+ # - algorithm lists below shape the cleartext KEXINIT
739
+ # - apply_mimic_patches() handles the monkey-patches of asyncssh
740
+ # - for full reasoning see 'mimic-refs/mimic-notes.md'
741
+
742
+ DEBIAN_ALGS = {
743
+ "kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
744
+ "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
745
+ "diffie-hellman-group-exchange-sha256", "diffie-hellman-group16-sha512",
746
+ "diffie-hellman-group18-sha512", "diffie-hellman-group14-sha256"],
747
+ "encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr",
748
+ "aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"],
749
+ "mac_algs": ["umac-64-etm@openssh.com", "umac-128-etm@openssh.com",
750
+ "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
751
+ "hmac-sha1-etm@openssh.com", "umac-64@openssh.com", "umac-128@openssh.com",
752
+ "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1"],
753
+ "compression_algs": ["none", "zlib@openssh.com"],
754
+ "signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com", "ssh-rsa",
755
+ "rsa-sha2-256", "rsa-sha2-512", "ssh-dss",
756
+ "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
757
+ "sk-ecdsa-sha2-nistp256@openssh.com",
758
+ "webauthn-sk-ecdsa-sha2-nistp256@openssh.com"],
675
759
  }
676
760
 
761
+ DROPBEAR_ALGS = {
762
+ "kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
763
+ "ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256",
764
+ "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"],
765
+ "encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-ctr"],
766
+ "mac_algs": ["hmac-sha1", "hmac-sha2-256"],
767
+ "compression_algs": ["zlib@openssh.com", "none"],
768
+ "signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com",
769
+ "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
770
+ "sk-ecdsa-sha2-nistp256@openssh.com", "rsa-sha2-256", "ssh-rsa"],
771
+ }
772
+
773
+ MIMIC_PRESETS = {
774
+ "debian": ("OpenSSH_8.4p1 Debian-5+deb11u7", DEBIAN_ALGS), # Debian 11 (bullseye)
775
+ "dropbear": ("dropbear_2024.86", DROPBEAR_ALGS), # Dropbear 2024.86
776
+ "none": None # asyncssh defaults
777
+ }
778
+
779
+ ADVERTISED_HOSTKEY_ALGS = {
780
+ "ssh-ed25519": [b"ssh-ed25519"],
781
+ "ecdsa-sha2-nistp256": [b"ecdsa-sha2-nistp256"],
782
+ "ssh-rsa": [b"rsa-sha2-512", b"rsa-sha2-256", b"ssh-rsa"],
783
+ }
784
+
785
+ MIMIC_HOSTKEYS = {
786
+ "debian": ["ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"], # debian: rsa+ecdsa+ed25519
787
+ "dropbear": ["ssh-ed25519"], # dropbear: ed25519 only
788
+ }
789
+
790
+
791
+ def select_host_keys(host_keys, mimic):
792
+ # Select fitting host keys for the mimicked server and order them in the default way
793
+ keys_by_algo = {k.get_algorithm(): k for k in host_keys}
794
+ wanted = MIMIC_HOSTKEYS.get(mimic, [])
795
+ missing = [a for a in wanted if a not in keys_by_algo]
796
+ if missing:
797
+ log_info(f"Host-keys missing: {missing} - a real {mimic} server provides "
798
+ f"those (delete the host-key file to auto-generate all types)",
799
+ level=logging.WARNING)
800
+ order = [a for a in wanted if a in keys_by_algo]
801
+ # return (keys_to_expose, advertised_algs)
802
+ if not order: return host_keys, None
803
+ return ([keys_by_algo[a] for a in order],
804
+ [alg for a in order for alg in ADVERTISED_HOSTKEY_ALGS[a]])
805
+
806
+
807
+ def apply_mimic_patches(mimic, host_key_algs=None):
808
+ # Monkey-patch asyncssh internals so the cleartext/pre-auth transport matches the mimicked server
809
+ from asyncssh.connection import SSHConnection, SSHServerConnection
810
+ from asyncssh.constants import MSG_IGNORE
811
+
812
+ def wrap(cls, name, fn):
813
+ orig = getattr(cls, name, None)
814
+ if orig is None:
815
+ raise RuntimeError(
816
+ f"Cannot apply --mimic {mimic}: asyncssh internal '{name}' changed. "
817
+ f"Refusing to run a fingerprintable disguise (use '--mimic none' to bypass)")
818
+ setattr(cls, name, functools.partialmethod(fn, orig))
819
+
820
+ def kex(self, orig):
821
+ # 1. Modify KEXINIT to match mimicked server
822
+ algs = [a for a in orig(self) if a != b"ext-info-s"]
823
+ if self.is_server() and mimic == "dropbear":
824
+ algs = [b"kexguess2@matt.ucc.asn.au"] + algs
825
+ return algs
826
+ wrap(SSHConnection, "_get_extra_kex_algs", kex)
827
+
828
+ def ext_info(self, orig):
829
+ # 2. Strip asyncssh 'global-requests-ok' from EXT_INFO
830
+ self._extensions_to_send.pop(b"global-requests-ok", None)
831
+ return orig(self)
832
+ wrap(SSHConnection, "_send_ext_info", ext_info)
833
+
834
+ def send_packet(self, orig, pkttype, *a, **kw):
835
+ # 3. Suppress asyncssh's SSH_MSG_IGNORE traffic-analysis chaff (added with
836
+ # OpenSSH 9.5). Safe to drop all because asyncssh only sends MSG_IGNORE
837
+ # as this chaff using self.send_packet() before each real packet
838
+ if pkttype != MSG_IGNORE:
839
+ return orig(self, pkttype, *a, **kw)
840
+ wrap(SSHConnection, "send_packet", send_packet)
841
+
842
+ if host_key_algs is not None:
843
+ # 4. Pin the advertised server host-key algorithms
844
+ algs = list(host_key_algs)
845
+ def init(self, orig, *a, **kw):
846
+ orig(self, *a, **kw)
847
+ self._server_host_key_algs = [x for x in algs if x in self._server_host_keys]
848
+ wrap(SSHServerConnection, "__init__", init)
849
+
850
+ # ── Startup and arguments ─────────────────────────────────────────────
851
+
852
+ def parse_hostport(spec):
853
+ # return (host, port); raises ValueError on bad input
854
+ if spec.startswith("["):
855
+ host, sep, port = spec[1:].partition("]:")
856
+ if not sep: raise ValueError(f"'{spec}' - expected [IPv6]:PORT")
857
+ else:
858
+ host, sep, port = spec.rpartition(":")
859
+ if not sep: raise ValueError(f"'{spec}' - expected HOST:PORT")
860
+ if not host: raise ValueError(f"'{spec}' - missing host")
861
+ if not port.isdigit() or not (0 < int(port) < 65536):
862
+ raise ValueError(f"'{spec}' - invalid port")
863
+ return host, int(port)
864
+
677
865
  _description="""\
678
866
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
679
- and simple SCP transfers (NEVER opens a shell!).
867
+ and simple SCP/SFTP transfers (NEVER opens a shell!)
680
868
  By default all features are disabled. Use flags to enable features.
681
869
  """
682
870
 
@@ -691,8 +879,7 @@ examples: (also check README on Github)
691
879
  %(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
692
880
  # My favorite one
693
881
  # 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
882
+ %(prog)s --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
696
883
  """
697
884
 
698
885
  def build_parser(full=False):
@@ -718,6 +905,13 @@ def build_parser(full=False):
718
905
  parser.add_argument("-1", "--single", action="store_true",
719
906
  help=help_text(short_help="close the listener after first successful auth",
720
907
  long_help="(and exit when that connection ends)"))
908
+ parser.add_argument("--mimic", metavar="PRESET", type=str.lower,
909
+ choices=list(MIMIC_PRESETS), default="none",
910
+ help=help_text(long_help="pose as another SSH server "
911
+ f"- presets (case-insensitive): {', '.join(list(MIMIC_PRESETS))} "
912
+ "- match the preset's pre-auth (banner, KEXINIT, server-sig-algs, ...) "
913
+ "exactly - banner can be overridden by --version-banner "
914
+ "- check Github repository for details"))
721
915
  parser.add_argument("--host-key", metavar="FILE", type=Path,
722
916
  help=help_text(long_help="server host key file, may hold several keys "
723
917
  "- auto-generated if missing - uses ./sshcatch_host_key by default"))
@@ -727,17 +921,23 @@ def build_parser(full=False):
727
921
 
728
922
  auth = parser.add_argument_group("authentication")
729
923
  auth.add_argument("-u", "--user", action="append", metavar="USER:PASS",
730
- help="allowed user:password (repeatable)")
924
+ help=help_text(short_help="allowed user:password (repeatable)",
925
+ long_help="- an empty password (user:) allows login by username only"))
731
926
  auth.add_argument("--open-auth", action="store_true",
732
927
  help="accept any credentials (open mode)")
733
928
  auth.add_argument("--authorized-keys", metavar="FILE", type=Path,
734
929
  help="authorized_keys file for key auth (username independent)")
735
930
 
736
931
  tunnel = parser.add_argument_group("tunneling")
737
- tunnel.add_argument("--forward", action="store_true",
738
- help="enable forward tunnels (client: ssh -NL / -ND)")
739
- tunnel.add_argument("--reverse", action="store_true",
740
- help="enable reverse tunnels (client: ssh -NR)")
932
+ tunnel.add_argument("--forward", nargs="?", const=True, default=None, metavar="SOCKS5",
933
+ help=help_text(short_help="enable forward tunnels (client: ssh -NL / -ND)",
934
+ long_help="- optional SOCKS5 proxy HOST:PORT to route every forwarded connection "
935
+ "through (e.g. 127.0.0.1:1080; the proxy resolves DNS)"))
936
+ tunnel.add_argument("--forward-socks-auth", metavar="USER:PASSWORD",
937
+ help=help_text(long_help="username:password for the --forward SOCKS5 proxy"))
938
+ tunnel.add_argument("--reverse", nargs="?", const="127.0.0.1", default=None, metavar="BIND",
939
+ help=help_text(short_help="enable reverse tunnels (client: ssh -NR)",
940
+ long_help="- optional BIND address to listen on (default: 127.0.0.1)"))
741
941
 
742
942
  scp = parser.add_argument_group("SCP / SFTP file transfer")
743
943
  scp.add_argument("--scp-upload", action="store_true",
@@ -752,9 +952,7 @@ def build_parser(full=False):
752
952
 
753
953
  banners = parser.add_argument_group("banners")
754
954
  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)}"))
955
+ help=help_text(long_help="manually set 'SSH-2.0-STRING' version banner"))
758
956
  banners.add_argument("--pre-auth-banner", metavar="STRING",
759
957
  help=help_text(long_help="banner shown to every client before login"))
760
958
  banners.add_argument("--post-auth-banner", metavar="STRING",
@@ -791,10 +989,23 @@ def main():
791
989
  configure_logging(output=args.output, timestamps=args.timestamps,
792
990
  plain=args.plain, console_level=console_level)
793
991
 
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)
992
+ # Handle --mimic version-banner
993
+ if args.mimic != "none" and not args.version_banner:
994
+ args.version_banner = MIMIC_PRESETS[args.mimic][0]
995
+
996
+ # Validate optional --forward SOCKS
997
+ if isinstance(args.forward, str):
998
+ try: args.forward = parse_hostport(args.forward)
999
+ except ValueError as e: parser.error(f"Invalid --forward SOCKS proxy: {e}")
1000
+
1001
+ # Handle --forward-socks-auth
1002
+ if args.forward_socks_auth is not None:
1003
+ if not isinstance(args.forward, tuple):
1004
+ parser.error("--forward-socks-auth requires --forward with a SOCKS proxy (HOST:PORT)")
1005
+ user, sep, password = args.forward_socks_auth.partition(":")
1006
+ if not sep or not user or not password:
1007
+ parser.error("Invalid --forward-socks-auth, expected USER:PASSWORD")
1008
+ args.forward_socks_auth = (user, password)
798
1009
 
799
1010
  # Validate user format
800
1011
  if args.user:
@@ -813,10 +1024,9 @@ def main():
813
1024
  parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
814
1025
 
815
1026
  try: asyncio.run(start_server(args))
816
- except (OSError, ValueError) as e:
817
- parser.error(f"Could not start server: {e}")
818
- except KeyboardInterrupt:
819
- print()
1027
+ except (OSError, ValueError) as e: parser.error(f"Could not start server: {e}")
1028
+ except RuntimeError as e: parser.error(str(e))
1029
+ except KeyboardInterrupt: print()
820
1030
 
821
1031
 
822
1032
  if __name__ == "__main__":
File without changes