sshcatch 0.3.0__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,44 @@ 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
+
7
45
  ## [0.3.0] - 2026-08-02
8
46
 
9
47
  Pose as another SSH server.
@@ -129,7 +167,7 @@ Large rewrite of the SFTP layer and the logging system.
129
167
 
130
168
  - **Symlink handling redesigned.**
131
169
  - On **download**, symlinks are denied (`_require_not_symlink`) and hidden from
132
- 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.
133
171
  - On **upload**, a symlink is no longer rejected outright; instead a small
134
172
  placeholder file recording the target is written, so recursive uploads that
135
173
  contain a link still complete.
@@ -161,9 +199,9 @@ Large rewrite of the SFTP layer and the logging system.
161
199
 
162
200
  ### Removed
163
201
 
164
- - **`-K` / `--full-keys` flag** superseded by `-vv` verbosity.
202
+ - **`-K` / `--full-keys` flag** - superseded by `-vv` verbosity.
165
203
  - **Startup symlink scan** that refused to start if the SCP directory contained
166
- any symlink replaced by the per-operation symlink handling above.
204
+ any symlink - replaced by the per-operation symlink handling above.
167
205
 
168
206
  ### Security
169
207
 
@@ -171,7 +209,8 @@ Large rewrite of the SFTP layer and the logging system.
171
209
  traversal out of the chroot is blocked on every path-taking operation rather
172
210
  than only checked once at startup.
173
211
 
174
- ## [0.1.1]
212
+
213
+ ## [0.1.1] - 2026-07-12
175
214
 
176
215
  ### Fixed
177
216
 
@@ -180,6 +219,7 @@ Large rewrite of the SFTP layer and the logging system.
180
219
  its basename, so `./`-prefixed and other non-canonical paths are collapsed
181
220
  first and can no longer slip past the "root directory only" restriction.
182
221
 
183
- ## [0.1.0]
222
+
223
+ ## [0.1.0] - 2026-07-12
184
224
 
185
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.3.0
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
@@ -22,21 +22,26 @@ Classifier: Topic :: Security
22
22
  Classifier: Topic :: System :: Networking
23
23
  Requires-Python: >=3.10
24
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
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,19 +114,48 @@ 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
161
  Only using `--version-banner` obviously isn't enough deception against a sufficiently
@@ -131,12 +165,11 @@ want to try and dodge in an engagement. Check out
131
165
  [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
132
166
  for details about `--mimic`.
133
167
 
134
- 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
135
169
  and everything else a real honeypot needs are deliberately out of scope. There are other
136
170
  projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
137
171
  [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
138
172
 
139
-
140
173
  ## Examples
141
174
 
142
175
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -151,6 +184,17 @@ scp -r loot/ user@host:pete/pc/
151
184
  sftp user@host
152
185
  ```
153
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
+
154
198
  Let anyone tunnel through the server (local and dynamic forwards): **Be careful with this one!**
155
199
 
156
200
  ```
@@ -162,6 +206,24 @@ ssh -NL 8080:internal:80 user@host # local forward
162
206
  ssh -ND 1080 user@host # dynamic (SOCKS)
163
207
  ```
164
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
+
165
227
  Using single-mode to return something to the first successful authentication
166
228
  by closing the server afterwards, while printing timestamped logs to the console
167
229
  and saving them into a file:
@@ -174,7 +236,6 @@ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
174
236
  -o sshcatch.log -t
175
237
  ```
176
238
 
177
-
178
239
  My favorite one: Reverse tunnel and SCP uploads for the keys in
179
240
  `./authorized_keys` on port 2222:
180
241
 
@@ -187,7 +248,6 @@ ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
187
248
  scp -P 2222 loot.tar user@host:. # upload
188
249
  ```
189
250
 
190
-
191
251
  ## Options
192
252
 
193
253
  `sshcatch -h` prints a short summary with just the flags you need to get going.
@@ -196,7 +256,8 @@ The full reference below is `sshcatch --help`:
196
256
  ```
197
257
  usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
198
258
  [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
199
- [--authorized-keys FILE] [--forward] [--reverse]
259
+ [--authorized-keys FILE] [--forward [SOCKS5]]
260
+ [--forward-socks-auth USER:PASSWORD] [--reverse [BIND]]
200
261
  [--scp-upload] [--scp-download] [--scp-dir DIR]
201
262
  [--version-banner STRING] [--pre-auth-banner STRING]
202
263
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
@@ -224,15 +285,22 @@ options:
224
285
 
225
286
  authentication:
226
287
  -u USER:PASS, --user USER:PASS
227
- allowed user:password (repeatable)
288
+ allowed user:password (repeatable) - an empty password
289
+ (user:) allows login by username only
228
290
  --open-auth accept any credentials (open mode)
229
291
  --authorized-keys FILE
230
292
  authorized_keys file for key auth (username
231
293
  independent)
232
294
 
233
295
  tunneling:
234
- --forward enable forward tunnels (client: ssh -NL / -ND)
235
- --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)
236
304
 
237
305
  SCP / SFTP file transfer:
238
306
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
@@ -270,6 +338,15 @@ examples: (also check README on Github)
270
338
  sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
271
339
  ```
272
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
+
273
350
  ## License
274
351
 
275
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
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,19 +87,48 @@ 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
134
  Only using `--version-banner` obviously isn't enough deception against a sufficiently
@@ -105,12 +138,11 @@ want to try and dodge in an engagement. Check out
105
138
  [`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
106
139
  for details about `--mimic`.
107
140
 
108
- 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
109
142
  and everything else a real honeypot needs are deliberately out of scope. There are other
110
143
  projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
111
144
  [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
112
145
 
113
-
114
146
  ## Examples
115
147
 
116
148
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -125,6 +157,17 @@ scp -r loot/ user@host:pete/pc/
125
157
  sftp user@host
126
158
  ```
127
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
+
128
171
  Let anyone tunnel through the server (local and dynamic forwards): **Be careful with this one!**
129
172
 
130
173
  ```
@@ -136,6 +179,24 @@ ssh -NL 8080:internal:80 user@host # local forward
136
179
  ssh -ND 1080 user@host # dynamic (SOCKS)
137
180
  ```
138
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
+
139
200
  Using single-mode to return something to the first successful authentication
140
201
  by closing the server afterwards, while printing timestamped logs to the console
141
202
  and saving them into a file:
@@ -148,7 +209,6 @@ sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
148
209
  -o sshcatch.log -t
149
210
  ```
150
211
 
151
-
152
212
  My favorite one: Reverse tunnel and SCP uploads for the keys in
153
213
  `./authorized_keys` on port 2222:
154
214
 
@@ -161,7 +221,6 @@ ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
161
221
  scp -P 2222 loot.tar user@host:. # upload
162
222
  ```
163
223
 
164
-
165
224
  ## Options
166
225
 
167
226
  `sshcatch -h` prints a short summary with just the flags you need to get going.
@@ -170,7 +229,8 @@ The full reference below is `sshcatch --help`:
170
229
  ```
171
230
  usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
172
231
  [--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
173
- [--authorized-keys FILE] [--forward] [--reverse]
232
+ [--authorized-keys FILE] [--forward [SOCKS5]]
233
+ [--forward-socks-auth USER:PASSWORD] [--reverse [BIND]]
174
234
  [--scp-upload] [--scp-download] [--scp-dir DIR]
175
235
  [--version-banner STRING] [--pre-auth-banner STRING]
176
236
  [--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
@@ -198,15 +258,22 @@ options:
198
258
 
199
259
  authentication:
200
260
  -u USER:PASS, --user USER:PASS
201
- allowed user:password (repeatable)
261
+ allowed user:password (repeatable) - an empty password
262
+ (user:) allows login by username only
202
263
  --open-auth accept any credentials (open mode)
203
264
  --authorized-keys FILE
204
265
  authorized_keys file for key auth (username
205
266
  independent)
206
267
 
207
268
  tunneling:
208
- --forward enable forward tunnels (client: ssh -NL / -ND)
209
- --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)
210
277
 
211
278
  SCP / SFTP file transfer:
212
279
  --scp-upload enable file upload (SCP/SFTP write) - files get suffix
@@ -244,6 +311,15 @@ examples: (also check README on Github)
244
311
  sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
245
312
  ```
246
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
+
247
323
  ## License
248
324
 
249
325
  MIT
@@ -25,8 +25,21 @@ classifiers = [
25
25
  "Topic :: Security",
26
26
  "Topic :: System :: Networking",
27
27
  ]
28
- # Pinned exactly due to mimic monkey-patches on asyncssh internals (see mimic-refs/mimic-notes.md)
29
- dependencies = ["asyncssh==2.24.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
30
43
 
31
44
  [project.urls]
32
45
  Homepage = "https://github.com/LorenzMap/sshcatch"
@@ -17,8 +17,10 @@ from pathlib import Path
17
17
  from itertools import count
18
18
 
19
19
  import asyncssh
20
+ from python_socks import ProxyError, ProxyType
21
+ from python_socks.async_.asyncio import Proxy
20
22
 
21
- __version__ = "0.3.0"
23
+ __version__ = "0.4.0"
22
24
 
23
25
  # ── Logging ───────────────────────────────────────────────────────────
24
26
 
@@ -115,13 +117,16 @@ class SFTPCatchServer(asyncssh.SFTPServer):
115
117
  self._log_scp(f"DENIED {detail} ({reason})", level)
116
118
  raise asyncssh.SFTPPermissionDenied("Permission denied")
117
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
+
118
126
  def _execute_wrapped_log(self, path, fn, *args):
119
127
  try: return fn(*args)
120
- except FileNotFoundError:
121
- self._log_scp(f"NOTFOUND {self._local_path(path)}", logging.WARNING)
122
- raise
123
- except OSError as e:
124
- 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)
125
130
  raise
126
131
 
127
132
  # ── path checks ───────────────────────────────────────────────
@@ -162,7 +167,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
162
167
  def _ensure_parent(self, path):
163
168
  parent = Path(os.fsdecode(self.map_path(path))).parent
164
169
  if not parent.is_dir():
165
- parent.mkdir(parents=True, exist_ok=True)
170
+ self._execute_wrapped_log(path, lambda: parent.mkdir(parents=True, exist_ok=True))
166
171
  self._log_scp(f"MKPARENT {self._local_path(path)}", logging.INFO)
167
172
 
168
173
  def _local_path(self, path):
@@ -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):
@@ -502,29 +529,52 @@ def make_server_factory(args, single_future=None):
502
529
 
503
530
  # ── ALLOWED (gated) ──────────────────────────────────────────
504
531
 
505
- 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):
506
533
  # Allow (--forward): direct TCP forward (client: ssh -NL / -ND)
507
534
  route = f"{addr_str(orig_host, orig_port)} -> {addr_str(dest_host, dest_port)}"
508
535
  if not args.forward:
509
536
  return self._deny_tunnel(f"forward {route}", "Forwarding is disabled")
510
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
511
551
  log_tunnel(f"Forward {route}", addr=self._addr, user=user)
512
- return True
552
+ return await self._conn.forward_connection(dest_host, dest_port)
513
553
 
514
- def server_requested(self, listen_host, listen_port):
554
+ async def server_requested(self, listen_host, listen_port):
515
555
  # Allow (--reverse): remote TCP listen (client: ssh -NR)
516
556
  if not args.reverse:
517
557
  return self._deny_tunnel(f"reverse {addr_str(listen_host, listen_port)}", "Reverse is disabled")
518
558
  user = self._conn.get_extra_info("username")
519
- log_tunnel(f"Reverse listen on {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
520
559
 
521
560
  def accept(orig_host, orig_port):
522
561
  # Log the connection - real target is requested/resolved on the
523
562
  # client so we can't show it (decided against packet inspection)
524
563
  log_tunnel(f"Reverse {addr_str(orig_host, orig_port)} on "
525
- 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)
526
565
  return True
527
- 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
528
578
 
529
579
  # ── DENIED forwarding/tunneling ──────────────────────────────
530
580
  # asyncssh rejects these by default, override to deny anyway and log them
@@ -545,7 +595,7 @@ def make_server_factory(args, single_future=None):
545
595
  # Deny: layer-2 TAP tunnel
546
596
  return self._deny_tunnel(f"tap unit={unit}")
547
597
 
548
- return SSHCatchServer, len(users), len(auth_keys_fps)
598
+ return SSHCatchServer, users, len(auth_keys_fps)
549
599
 
550
600
 
551
601
  # ── Server start ──────────────────────────────────────────────────────
@@ -581,7 +631,7 @@ async def start_server(args):
581
631
  single_future = asyncio.get_running_loop().create_future() if args.single else None
582
632
 
583
633
  # Build the connection options for the ssh server
584
- server_factory, n_users, n_keys = make_server_factory(args, single_future)
634
+ server_factory, users, n_keys = make_server_factory(args, single_future)
585
635
  opts = {
586
636
  "server_factory": server_factory,
587
637
  "server_host_keys": exposed_keys,
@@ -633,13 +683,21 @@ async def start_server(args):
633
683
  if args.open_auth: auth_mode = "open (accept any)"
634
684
  elif args.user or args.authorized_keys:
635
685
  parts = []
636
- 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)}")
637
687
  if args.authorized_keys: parts.append(f"{n_keys} key{'s'*(n_keys!=1)}")
638
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)}"
639
693
  else: auth_mode = "reject all (no auth configured)"
640
694
  features = []
641
- if args.forward: features.append("forward-tunnel")
642
- 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})")
643
701
  if args.scp_upload: features.append("scp-upload")
644
702
  if args.scp_download: features.append("scp-download")
645
703
  if not features: features.append("log-only (connect & close)")
@@ -791,6 +849,19 @@ def apply_mimic_patches(mimic, host_key_algs=None):
791
849
 
792
850
  # ── Startup and arguments ─────────────────────────────────────────────
793
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
+
794
865
  _description="""\
795
866
  sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
796
867
  and simple SCP/SFTP transfers (NEVER opens a shell!)
@@ -850,17 +921,23 @@ def build_parser(full=False):
850
921
 
851
922
  auth = parser.add_argument_group("authentication")
852
923
  auth.add_argument("-u", "--user", action="append", metavar="USER:PASS",
853
- 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"))
854
926
  auth.add_argument("--open-auth", action="store_true",
855
927
  help="accept any credentials (open mode)")
856
928
  auth.add_argument("--authorized-keys", metavar="FILE", type=Path,
857
929
  help="authorized_keys file for key auth (username independent)")
858
930
 
859
931
  tunnel = parser.add_argument_group("tunneling")
860
- tunnel.add_argument("--forward", action="store_true",
861
- help="enable forward tunnels (client: ssh -NL / -ND)")
862
- tunnel.add_argument("--reverse", action="store_true",
863
- 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)"))
864
941
 
865
942
  scp = parser.add_argument_group("SCP / SFTP file transfer")
866
943
  scp.add_argument("--scp-upload", action="store_true",
@@ -916,6 +993,20 @@ def main():
916
993
  if args.mimic != "none" and not args.version_banner:
917
994
  args.version_banner = MIMIC_PRESETS[args.mimic][0]
918
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)
1009
+
919
1010
  # Validate user format
920
1011
  if args.user:
921
1012
  for entry in args.user:
@@ -933,12 +1024,9 @@ def main():
933
1024
  parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
934
1025
 
935
1026
  try: asyncio.run(start_server(args))
936
- except (OSError, ValueError) as e:
937
- parser.error(f"Could not start server: {e}")
938
- except RuntimeError as e:
939
- parser.error(str(e))
940
- except KeyboardInterrupt:
941
- 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()
942
1030
 
943
1031
 
944
1032
  if __name__ == "__main__":
File without changes