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.
- {sshcatch-0.2.2 → sshcatch-0.4.0}/.gitignore +2 -0
- {sshcatch-0.2.2 → sshcatch-0.4.0}/CHANGELOG.md +74 -5
- {sshcatch-0.2.2 → sshcatch-0.4.0}/PKG-INFO +118 -37
- {sshcatch-0.2.2 → sshcatch-0.4.0}/README.md +114 -34
- {sshcatch-0.2.2 → sshcatch-0.4.0}/pyproject.toml +18 -1
- {sshcatch-0.2.2 → sshcatch-0.4.0}/sshcatch.py +275 -65
- {sshcatch-0.2.2 → sshcatch-0.4.0}/LICENSE +0 -0
|
@@ -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
|
|
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**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
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,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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
`./
|
|
240
|
+
`./authorized_keys` on port 2222:
|
|
177
241
|
|
|
178
242
|
```
|
|
179
243
|
# Server
|
|
180
|
-
sshcatch --reverse --authorized-keys ./
|
|
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] [--
|
|
195
|
-
[--version] [-u USER:PASS] [--open-auth]
|
|
196
|
-
[--authorized-keys FILE] [--forward
|
|
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
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
[](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,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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
-
`./
|
|
213
|
+
`./authorized_keys` on port 2222:
|
|
151
214
|
|
|
152
215
|
```
|
|
153
216
|
# Server
|
|
154
|
-
sshcatch --reverse --authorized-keys ./
|
|
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] [--
|
|
169
|
-
[--version] [-u USER:PASS] [--open-auth]
|
|
170
|
-
[--authorized-keys FILE] [--forward
|
|
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
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
120
|
-
self.
|
|
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
|
-
|
|
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):
|
|
@@ -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
|
|
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(
|
|
564
|
+
f"{addr_str(args.reverse, listener.get_port())}", addr=self._addr, user=user)
|
|
521
565
|
return True
|
|
522
|
-
|
|
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,
|
|
598
|
+
return SSHCatchServer, users, len(auth_keys_fps)
|
|
544
599
|
|
|
545
600
|
|
|
546
601
|
# ── Server start ──────────────────────────────────────────────────────
|
|
547
602
|
|
|
548
|
-
|
|
549
|
-
|
|
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, **
|
|
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
|
-
|
|
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,
|
|
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":
|
|
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"{
|
|
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:
|
|
633
|
-
|
|
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
|
-
#
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
"
|
|
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
|
-
|
|
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",
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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="
|
|
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
|
|
795
|
-
if args.version_banner:
|
|
796
|
-
args.version_banner =
|
|
797
|
-
|
|
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
|
-
|
|
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
|