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.
- {sshcatch-0.3.0 → sshcatch-0.4.0}/.gitignore +2 -0
- {sshcatch-0.3.0 → sshcatch-0.4.0}/CHANGELOG.md +45 -5
- {sshcatch-0.3.0 → sshcatch-0.4.0}/PKG-INFO +97 -20
- {sshcatch-0.3.0 → sshcatch-0.4.0}/README.md +94 -18
- {sshcatch-0.3.0 → sshcatch-0.4.0}/pyproject.toml +15 -2
- {sshcatch-0.3.0 → sshcatch-0.4.0}/sshcatch.py +127 -39
- {sshcatch-0.3.0 → sshcatch-0.4.0}/LICENSE +0 -0
|
@@ -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
|
|
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**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sshcatch
|
|
3
|
-
Version: 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
|
+
[](https://pypi.org/project/sshcatch/)
|
|
31
|
+
[](https://pypi.org/project/sshcatch/)
|
|
32
|
+
[](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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
235
|
-
|
|
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
|
+
[](https://pypi.org/project/sshcatch/)
|
|
4
|
+
[](https://pypi.org/project/sshcatch/)
|
|
5
|
+
[](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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
209
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
121
|
-
self.
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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(
|
|
564
|
+
f"{addr_str(args.reverse, listener.get_port())}", addr=self._addr, user=user)
|
|
526
565
|
return True
|
|
527
|
-
|
|
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,
|
|
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,
|
|
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"{
|
|
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:
|
|
642
|
-
|
|
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",
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
938
|
-
except
|
|
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
|