sshcatch 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sshcatch-0.2.1 → sshcatch-0.3.0}/CHANGELOG.md +58 -0
- {sshcatch-0.2.1 → sshcatch-0.3.0}/PKG-INFO +58 -17
- {sshcatch-0.2.1 → sshcatch-0.3.0}/README.md +56 -15
- {sshcatch-0.2.1 → sshcatch-0.3.0}/pyproject.toml +5 -1
- {sshcatch-0.2.1 → sshcatch-0.3.0}/sshcatch.py +170 -40
- {sshcatch-0.2.1 → sshcatch-0.3.0}/.gitignore +0 -0
- {sshcatch-0.2.1 → sshcatch-0.3.0}/LICENSE +0 -0
|
@@ -4,6 +4,64 @@ All notable changes to **sshcatch** are documented here.
|
|
|
4
4
|
This project follows [Keep a Changelog](https://keepachangelog.com/) and
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-08-02
|
|
8
|
+
|
|
9
|
+
Pose as another SSH server.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`--mimic debian` / `--mimic dropbear`** poses as a real OpenSSH 8.4p1 (Debian 11) or
|
|
14
|
+
Dropbear 2024.86 server: version banner, KEXINIT/HASSH, offered host keys and auth
|
|
15
|
+
methods match a capture of the real server. After authentication `sshcatch` differs from a
|
|
16
|
+
real server anyway, so `--mimic` only works pre-auth. Every modification to asyncssh,
|
|
17
|
+
the reasoning and the captures are in
|
|
18
|
+
[`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md).
|
|
19
|
+
- `select_host_keys()` offers only the host-key types the mimicked server has, in its order.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **asyncssh is pinned to 2.24.0 exactly** - `--mimic` patches library internals, so a
|
|
24
|
+
different version could break the disguise silently. If a patched internal is gone,
|
|
25
|
+
sshcatch refuses to start.
|
|
26
|
+
- The sdist ships only what is needed to install the package (no `mimic-refs/`).
|
|
27
|
+
- The startup summary shows the active mimic preset.
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
|
|
31
|
+
- **`--version-banner` presets** (`ubuntu`, `debian`, `dropbear`, `windows`, `macos`)
|
|
32
|
+
are dropped and the flag now takes a literal string only, `--mimic` sets a fitting
|
|
33
|
+
banner itself.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## [0.2.2] - 2026-07-31
|
|
37
|
+
|
|
38
|
+
Multiple host keys now used as default.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **Three host keys instead of one.** ed25519, RSA-3072 and ecdsa-nistp256 are
|
|
43
|
+
generated on first run if missing and stored together in one `sshcatch_host_key`
|
|
44
|
+
file, so clients can pick their algorithm. Existing key files are read with
|
|
45
|
+
`read_private_key_list()` and may hold any number of keys.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- The startup summary prints the fingerprint of each key.
|
|
50
|
+
- **Startup failures go through a single handler.** `PermissionError` is no longer
|
|
51
|
+
special-cased: it always blamed the port, even when the host key file was the
|
|
52
|
+
real problem. The underlying error is shown instead - it already names the
|
|
53
|
+
address and port.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- An empty, unreadable or passphrase-protected host key file now ends in a clean
|
|
58
|
+
error message instead of a traceback.
|
|
59
|
+
- `authorized_keys` lines that do not start with a key say why they were refused.
|
|
60
|
+
Options in front of the key (`from="..."`, `restrict`, ...) remain unsupported -
|
|
61
|
+
sshcatch cannot enforce them, so such keys are rejected rather than silently
|
|
62
|
+
accepted without their restrictions.
|
|
63
|
+
|
|
64
|
+
|
|
7
65
|
## [0.2.1] - 2026-07-29
|
|
8
66
|
|
|
9
67
|
Forwarding hardening and help/logging polish.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sshcatch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
|
|
5
5
|
Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
|
|
6
6
|
Project-URL: Repository, https://github.com/LorenzMap/sshcatch
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Security
|
|
22
22
|
Classifier: Topic :: System :: Networking
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: asyncssh
|
|
24
|
+
Requires-Dist: asyncssh==2.24.0
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
27
|
# sshcatch
|
|
@@ -39,6 +39,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
|
39
39
|
This is a pentesting tool. Only point it at systems and networks you are authorized
|
|
40
40
|
to test.
|
|
41
41
|
|
|
42
|
+
## Why this tool exists
|
|
43
|
+
|
|
44
|
+
- During engagements and CTFs I love to use 'simple' tools on my host that just work
|
|
45
|
+
- http-server -> `python3 -m http.server`
|
|
46
|
+
- smb-server -> `impacket-smbserver`
|
|
47
|
+
- ssh-server -> ??? (now `sshcatch`)
|
|
48
|
+
|
|
49
|
+
- `sshd` can be used, but:
|
|
50
|
+
- configuring it through `sshd_configs` is a pain
|
|
51
|
+
- multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
|
|
52
|
+
- logins are controlled by the OS so a user must be created (and secured)
|
|
53
|
+
- ForceCommands need to be set up to restrict the shell
|
|
54
|
+
|
|
55
|
+
- My solution: `sshcatch`
|
|
56
|
+
- Simply configure through clear flags and arguments on the commandline
|
|
57
|
+
- restrictive defaults, every feature must be enabled consciously
|
|
58
|
+
- Never allow shells (or commands)
|
|
59
|
+
- Forward/Reverse tunnels can be individually activated
|
|
60
|
+
- SCP/SFTP file uploads and downloads can be individually activated
|
|
61
|
+
- restrictive upload handling to prevent overwriting
|
|
62
|
+
- symlinks denied
|
|
63
|
+
|
|
42
64
|
## Install
|
|
43
65
|
|
|
44
66
|
With `pipx` (recommended, installs into an isolated environment and puts
|
|
@@ -62,8 +84,9 @@ cd sshcatch
|
|
|
62
84
|
pipx install . # or: pip install .
|
|
63
85
|
```
|
|
64
86
|
|
|
65
|
-
Needs Python 3.10+.
|
|
66
|
-
first run (or point `--host-key`
|
|
87
|
+
Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
|
|
88
|
+
into a single file in the working directory on first run (or point `--host-key`
|
|
89
|
+
at your own file).
|
|
67
90
|
|
|
68
91
|
## How it works
|
|
69
92
|
|
|
@@ -99,6 +122,21 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
|
|
|
99
122
|
|
|
100
123
|
Renames, deletes and directory removal are denied.
|
|
101
124
|
|
|
125
|
+
## Word of Warning
|
|
126
|
+
|
|
127
|
+
Only using `--version-banner` obviously isn't enough deception against a sufficiently
|
|
128
|
+
sophisticated observer because some of the data transferred in cleartext on the wire
|
|
129
|
+
during connection establishment is a clear tell. Use `--mimic` if that's something you
|
|
130
|
+
want to try and dodge in an engagement. Check out
|
|
131
|
+
[`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
|
|
132
|
+
for details about `--mimic`.
|
|
133
|
+
|
|
134
|
+
Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
|
|
135
|
+
and everything else a real honeypot needs are deliberately out of scope. There are other
|
|
136
|
+
projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
|
|
137
|
+
[cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
|
|
138
|
+
|
|
139
|
+
|
|
102
140
|
## Examples
|
|
103
141
|
|
|
104
142
|
Let one user pull/put files from the current directory via SCP/SFTP:
|
|
@@ -130,7 +168,7 @@ and saving them into a file:
|
|
|
130
168
|
|
|
131
169
|
```
|
|
132
170
|
# Server
|
|
133
|
-
sshcatch -1 -u arthur:42 --version-banner
|
|
171
|
+
sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
|
|
134
172
|
--pre-auth-banner "What is the answer to life the universe and everything" \
|
|
135
173
|
--post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
|
|
136
174
|
-o sshcatch.log -t
|
|
@@ -138,11 +176,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
|
|
|
138
176
|
|
|
139
177
|
|
|
140
178
|
My favorite one: Reverse tunnel and SCP uploads for the keys in
|
|
141
|
-
`./
|
|
179
|
+
`./authorized_keys` on port 2222:
|
|
142
180
|
|
|
143
181
|
```
|
|
144
182
|
# Server
|
|
145
|
-
sshcatch --reverse --authorized-keys ./
|
|
183
|
+
sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
|
|
146
184
|
|
|
147
185
|
# Client
|
|
148
186
|
ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
|
|
@@ -156,15 +194,15 @@ scp -P 2222 loot.tar user@host:. # upload
|
|
|
156
194
|
The full reference below is `sshcatch --help`:
|
|
157
195
|
|
|
158
196
|
```
|
|
159
|
-
usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--
|
|
160
|
-
[--version] [-u USER:PASS] [--open-auth]
|
|
197
|
+
usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
|
|
198
|
+
[--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
|
|
161
199
|
[--authorized-keys FILE] [--forward] [--reverse]
|
|
162
200
|
[--scp-upload] [--scp-download] [--scp-dir DIR]
|
|
163
201
|
[--version-banner STRING] [--pre-auth-banner STRING]
|
|
164
202
|
[--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
|
|
165
203
|
|
|
166
204
|
sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
|
|
167
|
-
and simple SCP transfers (NEVER opens a shell!)
|
|
205
|
+
and simple SCP/SFTP transfers (NEVER opens a shell!)
|
|
168
206
|
By default all features are disabled. Use flags to enable features.
|
|
169
207
|
|
|
170
208
|
options:
|
|
@@ -174,7 +212,14 @@ options:
|
|
|
174
212
|
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
175
213
|
-1, --single close the listener after first successful auth (and
|
|
176
214
|
exit when that connection ends)
|
|
177
|
-
--
|
|
215
|
+
--mimic PRESET pose as another SSH server - presets (case-
|
|
216
|
+
insensitive): debian, dropbear, none - match the
|
|
217
|
+
preset's pre-auth (banner, KEXINIT, server-sig-algs,
|
|
218
|
+
...) exactly - banner can be overridden by --version-
|
|
219
|
+
banner - check Github repository for details
|
|
220
|
+
--host-key FILE server host key file, may hold several keys - auto-
|
|
221
|
+
generated if missing - uses ./sshcatch_host_key by
|
|
222
|
+
default
|
|
178
223
|
--version show program's version number and exit
|
|
179
224
|
|
|
180
225
|
authentication:
|
|
@@ -200,10 +245,7 @@ SCP / SFTP file transfer:
|
|
|
200
245
|
|
|
201
246
|
banners:
|
|
202
247
|
--version-banner STRING
|
|
203
|
-
|
|
204
|
-
glance deception, it can still be identified as
|
|
205
|
-
asyncssh - presets (case-insensitive): ubuntu, debian,
|
|
206
|
-
dropbear, windows, macos
|
|
248
|
+
manually set 'SSH-2.0-STRING' version banner
|
|
207
249
|
--pre-auth-banner STRING
|
|
208
250
|
banner shown to every client before login
|
|
209
251
|
--post-auth-banner STRING
|
|
@@ -225,8 +267,7 @@ examples: (also check README on Github)
|
|
|
225
267
|
sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
|
|
226
268
|
# My favorite one
|
|
227
269
|
# Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
|
|
228
|
-
|
|
229
|
-
sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
270
|
+
sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
|
|
230
271
|
```
|
|
231
272
|
|
|
232
273
|
## License
|
|
@@ -13,6 +13,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
|
13
13
|
This is a pentesting tool. Only point it at systems and networks you are authorized
|
|
14
14
|
to test.
|
|
15
15
|
|
|
16
|
+
## Why this tool exists
|
|
17
|
+
|
|
18
|
+
- During engagements and CTFs I love to use 'simple' tools on my host that just work
|
|
19
|
+
- http-server -> `python3 -m http.server`
|
|
20
|
+
- smb-server -> `impacket-smbserver`
|
|
21
|
+
- ssh-server -> ??? (now `sshcatch`)
|
|
22
|
+
|
|
23
|
+
- `sshd` can be used, but:
|
|
24
|
+
- configuring it through `sshd_configs` is a pain
|
|
25
|
+
- multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
|
|
26
|
+
- logins are controlled by the OS so a user must be created (and secured)
|
|
27
|
+
- ForceCommands need to be set up to restrict the shell
|
|
28
|
+
|
|
29
|
+
- My solution: `sshcatch`
|
|
30
|
+
- Simply configure through clear flags and arguments on the commandline
|
|
31
|
+
- restrictive defaults, every feature must be enabled consciously
|
|
32
|
+
- Never allow shells (or commands)
|
|
33
|
+
- Forward/Reverse tunnels can be individually activated
|
|
34
|
+
- SCP/SFTP file uploads and downloads can be individually activated
|
|
35
|
+
- restrictive upload handling to prevent overwriting
|
|
36
|
+
- symlinks denied
|
|
37
|
+
|
|
16
38
|
## Install
|
|
17
39
|
|
|
18
40
|
With `pipx` (recommended, installs into an isolated environment and puts
|
|
@@ -36,8 +58,9 @@ cd sshcatch
|
|
|
36
58
|
pipx install . # or: pip install .
|
|
37
59
|
```
|
|
38
60
|
|
|
39
|
-
Needs Python 3.10+.
|
|
40
|
-
first run (or point `--host-key`
|
|
61
|
+
Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
|
|
62
|
+
into a single file in the working directory on first run (or point `--host-key`
|
|
63
|
+
at your own file).
|
|
41
64
|
|
|
42
65
|
## How it works
|
|
43
66
|
|
|
@@ -73,6 +96,21 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
|
|
|
73
96
|
|
|
74
97
|
Renames, deletes and directory removal are denied.
|
|
75
98
|
|
|
99
|
+
## Word of Warning
|
|
100
|
+
|
|
101
|
+
Only using `--version-banner` obviously isn't enough deception against a sufficiently
|
|
102
|
+
sophisticated observer because some of the data transferred in cleartext on the wire
|
|
103
|
+
during connection establishment is a clear tell. Use `--mimic` if that's something you
|
|
104
|
+
want to try and dodge in an engagement. Check out
|
|
105
|
+
[`mimic-refs/mimic-notes.md`](https://github.com/LorenzMap/sshcatch/blob/main/mimic-refs/mimic-notes.md)
|
|
106
|
+
for details about `--mimic`.
|
|
107
|
+
|
|
108
|
+
Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
|
|
109
|
+
and everything else a real honeypot needs are deliberately out of scope. There are other
|
|
110
|
+
projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
|
|
111
|
+
[cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
|
|
112
|
+
|
|
113
|
+
|
|
76
114
|
## Examples
|
|
77
115
|
|
|
78
116
|
Let one user pull/put files from the current directory via SCP/SFTP:
|
|
@@ -104,7 +142,7 @@ and saving them into a file:
|
|
|
104
142
|
|
|
105
143
|
```
|
|
106
144
|
# Server
|
|
107
|
-
sshcatch -1 -u arthur:42 --version-banner
|
|
145
|
+
sshcatch -1 -u arthur:42 --version-banner 'heart_of_gold' \
|
|
108
146
|
--pre-auth-banner "What is the answer to life the universe and everything" \
|
|
109
147
|
--post-auth-banner "flag{So_Long_and_Thanks_for_All_the_Fish}" \
|
|
110
148
|
-o sshcatch.log -t
|
|
@@ -112,11 +150,11 @@ sshcatch -1 -u arthur:42 --version-banner debian \
|
|
|
112
150
|
|
|
113
151
|
|
|
114
152
|
My favorite one: Reverse tunnel and SCP uploads for the keys in
|
|
115
|
-
`./
|
|
153
|
+
`./authorized_keys` on port 2222:
|
|
116
154
|
|
|
117
155
|
```
|
|
118
156
|
# Server
|
|
119
|
-
sshcatch --reverse --authorized-keys ./
|
|
157
|
+
sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
|
|
120
158
|
|
|
121
159
|
# Client
|
|
122
160
|
ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
|
|
@@ -130,15 +168,15 @@ scp -P 2222 loot.tar user@host:. # upload
|
|
|
130
168
|
The full reference below is `sshcatch --help`:
|
|
131
169
|
|
|
132
170
|
```
|
|
133
|
-
usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--
|
|
134
|
-
[--version] [-u USER:PASS] [--open-auth]
|
|
171
|
+
usage: sshcatch [-h] [--help] [-p PORT] [-b BIND] [-1] [--mimic PRESET]
|
|
172
|
+
[--host-key FILE] [--version] [-u USER:PASS] [--open-auth]
|
|
135
173
|
[--authorized-keys FILE] [--forward] [--reverse]
|
|
136
174
|
[--scp-upload] [--scp-download] [--scp-dir DIR]
|
|
137
175
|
[--version-banner STRING] [--pre-auth-banner STRING]
|
|
138
176
|
[--post-auth-banner STRING] [-q | -v] [-o FILE] [-t] [--plain]
|
|
139
177
|
|
|
140
178
|
sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
|
|
141
|
-
and simple SCP transfers (NEVER opens a shell!)
|
|
179
|
+
and simple SCP/SFTP transfers (NEVER opens a shell!)
|
|
142
180
|
By default all features are disabled. Use flags to enable features.
|
|
143
181
|
|
|
144
182
|
options:
|
|
@@ -148,7 +186,14 @@ options:
|
|
|
148
186
|
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
149
187
|
-1, --single close the listener after first successful auth (and
|
|
150
188
|
exit when that connection ends)
|
|
151
|
-
--
|
|
189
|
+
--mimic PRESET pose as another SSH server - presets (case-
|
|
190
|
+
insensitive): debian, dropbear, none - match the
|
|
191
|
+
preset's pre-auth (banner, KEXINIT, server-sig-algs,
|
|
192
|
+
...) exactly - banner can be overridden by --version-
|
|
193
|
+
banner - check Github repository for details
|
|
194
|
+
--host-key FILE server host key file, may hold several keys - auto-
|
|
195
|
+
generated if missing - uses ./sshcatch_host_key by
|
|
196
|
+
default
|
|
152
197
|
--version show program's version number and exit
|
|
153
198
|
|
|
154
199
|
authentication:
|
|
@@ -174,10 +219,7 @@ SCP / SFTP file transfer:
|
|
|
174
219
|
|
|
175
220
|
banners:
|
|
176
221
|
--version-banner STRING
|
|
177
|
-
|
|
178
|
-
glance deception, it can still be identified as
|
|
179
|
-
asyncssh - presets (case-insensitive): ubuntu, debian,
|
|
180
|
-
dropbear, windows, macos
|
|
222
|
+
manually set 'SSH-2.0-STRING' version banner
|
|
181
223
|
--pre-auth-banner STRING
|
|
182
224
|
banner shown to every client before login
|
|
183
225
|
--post-auth-banner STRING
|
|
@@ -199,8 +241,7 @@ examples: (also check README on Github)
|
|
|
199
241
|
sshcatch --open-auth --forward Allow ANYONE! to tunnel through this SSH server
|
|
200
242
|
# My favorite one
|
|
201
243
|
# Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
|
|
202
|
-
|
|
203
|
-
sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
244
|
+
sshcatch --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
|
|
204
245
|
```
|
|
205
246
|
|
|
206
247
|
## License
|
|
@@ -25,7 +25,8 @@ classifiers = [
|
|
|
25
25
|
"Topic :: Security",
|
|
26
26
|
"Topic :: System :: Networking",
|
|
27
27
|
]
|
|
28
|
-
|
|
28
|
+
# Pinned exactly due to mimic monkey-patches on asyncssh internals (see mimic-refs/mimic-notes.md)
|
|
29
|
+
dependencies = ["asyncssh==2.24.0"]
|
|
29
30
|
|
|
30
31
|
[project.urls]
|
|
31
32
|
Homepage = "https://github.com/LorenzMap/sshcatch"
|
|
@@ -40,3 +41,6 @@ path = "sshcatch.py"
|
|
|
40
41
|
|
|
41
42
|
[tool.hatch.build.targets.wheel]
|
|
42
43
|
only-include = ["sshcatch.py"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
only-include = ["sshcatch.py", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
@@ -1,14 +1,16 @@
|
|
|
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
|
|
13
|
+
import re
|
|
12
14
|
import sys
|
|
13
15
|
import time
|
|
14
16
|
from pathlib import Path
|
|
@@ -16,7 +18,7 @@ from itertools import count
|
|
|
16
18
|
|
|
17
19
|
import asyncssh
|
|
18
20
|
|
|
19
|
-
__version__ = "0.
|
|
21
|
+
__version__ = "0.3.0"
|
|
20
22
|
|
|
21
23
|
# ── Logging ───────────────────────────────────────────────────────────
|
|
22
24
|
|
|
@@ -251,8 +253,7 @@ class SFTPCatchServer(asyncssh.SFTPServer):
|
|
|
251
253
|
self._ensure_parent(new)
|
|
252
254
|
target = old.decode(errors="replace")
|
|
253
255
|
Path(os.fsdecode(self.map_path(new))).write_text(f"symlink -> {target}\n")
|
|
254
|
-
self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)",
|
|
255
|
-
logging.WARNING)
|
|
256
|
+
self._log_scp(f"SYMLINK {self._local_path(new)} -> {target} (placeholder)", logging.WARNING)
|
|
256
257
|
|
|
257
258
|
def setstat(self, path, attrs):
|
|
258
259
|
# Allow (upload only): perms/timestamps on uploaded files
|
|
@@ -366,10 +367,11 @@ def make_server_factory(args, single_future=None):
|
|
|
366
367
|
if args.authorized_keys:
|
|
367
368
|
for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
|
|
368
369
|
line = line.strip()
|
|
369
|
-
if not line or line.startswith('#'):
|
|
370
|
-
|
|
370
|
+
if not line or line.startswith('#'): continue
|
|
371
|
+
key_only = re.search(r"^\S+ AAAA\S+", line)
|
|
371
372
|
try:
|
|
372
|
-
|
|
373
|
+
if not key_only: raise ValueError("Must start with 'keytype base64' (no options)")
|
|
374
|
+
k = asyncssh.import_public_key(key_only.group())
|
|
373
375
|
fp = k.get_fingerprint()
|
|
374
376
|
auth_keys_fps.add(fp)
|
|
375
377
|
log_info(f"Loaded key line={i} fingerprint={fp}")
|
|
@@ -468,6 +470,11 @@ def make_server_factory(args, single_future=None):
|
|
|
468
470
|
# always accept passwords so we can log them
|
|
469
471
|
return True
|
|
470
472
|
|
|
473
|
+
def kbdint_auth_supported(self):
|
|
474
|
+
# force publickey,password auth for mimic (drop keyboard-interactive)
|
|
475
|
+
if not args.mimic == "none": return False
|
|
476
|
+
return super().kbdint_auth_supported()
|
|
477
|
+
|
|
471
478
|
def validate_password(self, username, password):
|
|
472
479
|
accepted = accept_password(username, password)
|
|
473
480
|
if accepted:
|
|
@@ -543,24 +550,32 @@ def make_server_factory(args, single_future=None):
|
|
|
543
550
|
|
|
544
551
|
# ── Server start ──────────────────────────────────────────────────────
|
|
545
552
|
|
|
553
|
+
GENERATE_KEYS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
|
|
554
|
+
GENERATE_OPTS = {"ssh-rsa": {"key_size": 3072}}
|
|
555
|
+
|
|
546
556
|
async def start_server(args):
|
|
547
|
-
# Handle Host
|
|
557
|
+
# Handle Host keys
|
|
548
558
|
key_path = args.host_key if args.host_key else Path.cwd()
|
|
549
|
-
if key_path.is_dir():
|
|
559
|
+
if key_path.is_dir():
|
|
550
560
|
key_path = key_path / "sshcatch_host_key"
|
|
551
561
|
if not key_path.parent.is_dir():
|
|
552
562
|
raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
|
|
553
563
|
|
|
554
564
|
if key_path.is_file():
|
|
555
|
-
|
|
556
|
-
|
|
565
|
+
try: host_keys = asyncssh.read_private_key_list(str(key_path))
|
|
566
|
+
except ValueError as e: raise ValueError(f"Could not read host key file {key_path}: {e}") from None
|
|
567
|
+
if not host_keys: raise ValueError(f"No usable host key in {key_path}")
|
|
568
|
+
log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
|
|
557
569
|
else:
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
log_info(f"Generated host
|
|
570
|
+
host_keys = [asyncssh.generate_private_key(a, **GENERATE_OPTS.get(a, {})) for a in GENERATE_KEYS]
|
|
571
|
+
key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
|
|
572
|
+
log_info(f"Generated host keys file: {key_path}")
|
|
561
573
|
if os.name == "posix": key_path.chmod(0o600)
|
|
562
574
|
else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
|
|
563
|
-
|
|
575
|
+
|
|
576
|
+
# Choose which keys to expose and what host-key algorithms to advertise
|
|
577
|
+
exposed_keys, host_key_algs = select_host_keys(host_keys, args.mimic)
|
|
578
|
+
fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in exposed_keys]
|
|
564
579
|
|
|
565
580
|
# for single-connection mode - resolving releases the bind on the listen port
|
|
566
581
|
single_future = asyncio.get_running_loop().create_future() if args.single else None
|
|
@@ -569,12 +584,15 @@ async def start_server(args):
|
|
|
569
584
|
server_factory, n_users, n_keys = make_server_factory(args, single_future)
|
|
570
585
|
opts = {
|
|
571
586
|
"server_factory": server_factory,
|
|
572
|
-
"server_host_keys":
|
|
587
|
+
"server_host_keys": exposed_keys,
|
|
573
588
|
# SFTPv3 only so all transfers use open() and not open56()
|
|
574
589
|
"sftp_version": 3,
|
|
575
590
|
}
|
|
576
|
-
if args.version_banner:
|
|
591
|
+
if args.version_banner:
|
|
577
592
|
opts["server_version"] = args.version_banner
|
|
593
|
+
if args.mimic != "none":
|
|
594
|
+
opts.update(MIMIC_PRESETS[args.mimic][1])
|
|
595
|
+
apply_mimic_patches(args.mimic, host_key_algs)
|
|
578
596
|
|
|
579
597
|
has_scp = args.scp_upload or args.scp_download
|
|
580
598
|
if has_scp:
|
|
@@ -634,10 +652,12 @@ async def start_server(args):
|
|
|
634
652
|
if args.single: summary.append("Mode .......... single-connection")
|
|
635
653
|
if has_scp: summary.append(f"SCP dir ....... {args.scp_dir.resolve()}")
|
|
636
654
|
summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
|
|
655
|
+
summary.append(f"Mimic ......... {args.mimic}")
|
|
637
656
|
if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
|
|
638
657
|
if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
|
|
639
|
-
summary.append(f"Host key ...... {fingerprint}")
|
|
640
658
|
summary.append(f"Key file ...... {key_path}")
|
|
659
|
+
for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
|
|
660
|
+
summary.append(f"\n")
|
|
641
661
|
log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
|
|
642
662
|
|
|
643
663
|
acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
|
|
@@ -656,18 +676,124 @@ async def start_server(args):
|
|
|
656
676
|
|
|
657
677
|
# ── Main ──────────────────────────────────────────────────────────────
|
|
658
678
|
|
|
659
|
-
#
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
"
|
|
679
|
+
# ── Mimic presets ─────────────────────────────────────────────────────
|
|
680
|
+
# - algorithm lists below shape the cleartext KEXINIT
|
|
681
|
+
# - apply_mimic_patches() handles the monkey-patches of asyncssh
|
|
682
|
+
# - for full reasoning see 'mimic-refs/mimic-notes.md'
|
|
683
|
+
|
|
684
|
+
DEBIAN_ALGS = {
|
|
685
|
+
"kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
|
|
686
|
+
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
|
|
687
|
+
"diffie-hellman-group-exchange-sha256", "diffie-hellman-group16-sha512",
|
|
688
|
+
"diffie-hellman-group18-sha512", "diffie-hellman-group14-sha256"],
|
|
689
|
+
"encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes192-ctr",
|
|
690
|
+
"aes256-ctr", "aes128-gcm@openssh.com", "aes256-gcm@openssh.com"],
|
|
691
|
+
"mac_algs": ["umac-64-etm@openssh.com", "umac-128-etm@openssh.com",
|
|
692
|
+
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
|
|
693
|
+
"hmac-sha1-etm@openssh.com", "umac-64@openssh.com", "umac-128@openssh.com",
|
|
694
|
+
"hmac-sha2-256", "hmac-sha2-512", "hmac-sha1"],
|
|
695
|
+
"compression_algs": ["none", "zlib@openssh.com"],
|
|
696
|
+
"signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com", "ssh-rsa",
|
|
697
|
+
"rsa-sha2-256", "rsa-sha2-512", "ssh-dss",
|
|
698
|
+
"ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
|
699
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
700
|
+
"webauthn-sk-ecdsa-sha2-nistp256@openssh.com"],
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
DROPBEAR_ALGS = {
|
|
704
|
+
"kex_algs": ["curve25519-sha256", "curve25519-sha256@libssh.org",
|
|
705
|
+
"ecdh-sha2-nistp521", "ecdh-sha2-nistp384", "ecdh-sha2-nistp256",
|
|
706
|
+
"diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1"],
|
|
707
|
+
"encryption_algs": ["chacha20-poly1305@openssh.com", "aes128-ctr", "aes256-ctr"],
|
|
708
|
+
"mac_algs": ["hmac-sha1", "hmac-sha2-256"],
|
|
709
|
+
"compression_algs": ["zlib@openssh.com", "none"],
|
|
710
|
+
"signature_algs": ["ssh-ed25519", "sk-ssh-ed25519@openssh.com",
|
|
711
|
+
"ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
|
712
|
+
"sk-ecdsa-sha2-nistp256@openssh.com", "rsa-sha2-256", "ssh-rsa"],
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
MIMIC_PRESETS = {
|
|
716
|
+
"debian": ("OpenSSH_8.4p1 Debian-5+deb11u7", DEBIAN_ALGS), # Debian 11 (bullseye)
|
|
717
|
+
"dropbear": ("dropbear_2024.86", DROPBEAR_ALGS), # Dropbear 2024.86
|
|
718
|
+
"none": None # asyncssh defaults
|
|
666
719
|
}
|
|
667
720
|
|
|
721
|
+
ADVERTISED_HOSTKEY_ALGS = {
|
|
722
|
+
"ssh-ed25519": [b"ssh-ed25519"],
|
|
723
|
+
"ecdsa-sha2-nistp256": [b"ecdsa-sha2-nistp256"],
|
|
724
|
+
"ssh-rsa": [b"rsa-sha2-512", b"rsa-sha2-256", b"ssh-rsa"],
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
MIMIC_HOSTKEYS = {
|
|
728
|
+
"debian": ["ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519"], # debian: rsa+ecdsa+ed25519
|
|
729
|
+
"dropbear": ["ssh-ed25519"], # dropbear: ed25519 only
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def select_host_keys(host_keys, mimic):
|
|
734
|
+
# Select fitting host keys for the mimicked server and order them in the default way
|
|
735
|
+
keys_by_algo = {k.get_algorithm(): k for k in host_keys}
|
|
736
|
+
wanted = MIMIC_HOSTKEYS.get(mimic, [])
|
|
737
|
+
missing = [a for a in wanted if a not in keys_by_algo]
|
|
738
|
+
if missing:
|
|
739
|
+
log_info(f"Host-keys missing: {missing} - a real {mimic} server provides "
|
|
740
|
+
f"those (delete the host-key file to auto-generate all types)",
|
|
741
|
+
level=logging.WARNING)
|
|
742
|
+
order = [a for a in wanted if a in keys_by_algo]
|
|
743
|
+
# return (keys_to_expose, advertised_algs)
|
|
744
|
+
if not order: return host_keys, None
|
|
745
|
+
return ([keys_by_algo[a] for a in order],
|
|
746
|
+
[alg for a in order for alg in ADVERTISED_HOSTKEY_ALGS[a]])
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def apply_mimic_patches(mimic, host_key_algs=None):
|
|
750
|
+
# Monkey-patch asyncssh internals so the cleartext/pre-auth transport matches the mimicked server
|
|
751
|
+
from asyncssh.connection import SSHConnection, SSHServerConnection
|
|
752
|
+
from asyncssh.constants import MSG_IGNORE
|
|
753
|
+
|
|
754
|
+
def wrap(cls, name, fn):
|
|
755
|
+
orig = getattr(cls, name, None)
|
|
756
|
+
if orig is None:
|
|
757
|
+
raise RuntimeError(
|
|
758
|
+
f"Cannot apply --mimic {mimic}: asyncssh internal '{name}' changed. "
|
|
759
|
+
f"Refusing to run a fingerprintable disguise (use '--mimic none' to bypass)")
|
|
760
|
+
setattr(cls, name, functools.partialmethod(fn, orig))
|
|
761
|
+
|
|
762
|
+
def kex(self, orig):
|
|
763
|
+
# 1. Modify KEXINIT to match mimicked server
|
|
764
|
+
algs = [a for a in orig(self) if a != b"ext-info-s"]
|
|
765
|
+
if self.is_server() and mimic == "dropbear":
|
|
766
|
+
algs = [b"kexguess2@matt.ucc.asn.au"] + algs
|
|
767
|
+
return algs
|
|
768
|
+
wrap(SSHConnection, "_get_extra_kex_algs", kex)
|
|
769
|
+
|
|
770
|
+
def ext_info(self, orig):
|
|
771
|
+
# 2. Strip asyncssh 'global-requests-ok' from EXT_INFO
|
|
772
|
+
self._extensions_to_send.pop(b"global-requests-ok", None)
|
|
773
|
+
return orig(self)
|
|
774
|
+
wrap(SSHConnection, "_send_ext_info", ext_info)
|
|
775
|
+
|
|
776
|
+
def send_packet(self, orig, pkttype, *a, **kw):
|
|
777
|
+
# 3. Suppress asyncssh's SSH_MSG_IGNORE traffic-analysis chaff (added with
|
|
778
|
+
# OpenSSH 9.5). Safe to drop all because asyncssh only sends MSG_IGNORE
|
|
779
|
+
# as this chaff using self.send_packet() before each real packet
|
|
780
|
+
if pkttype != MSG_IGNORE:
|
|
781
|
+
return orig(self, pkttype, *a, **kw)
|
|
782
|
+
wrap(SSHConnection, "send_packet", send_packet)
|
|
783
|
+
|
|
784
|
+
if host_key_algs is not None:
|
|
785
|
+
# 4. Pin the advertised server host-key algorithms
|
|
786
|
+
algs = list(host_key_algs)
|
|
787
|
+
def init(self, orig, *a, **kw):
|
|
788
|
+
orig(self, *a, **kw)
|
|
789
|
+
self._server_host_key_algs = [x for x in algs if x in self._server_host_keys]
|
|
790
|
+
wrap(SSHServerConnection, "__init__", init)
|
|
791
|
+
|
|
792
|
+
# ── Startup and arguments ─────────────────────────────────────────────
|
|
793
|
+
|
|
668
794
|
_description="""\
|
|
669
795
|
sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
|
|
670
|
-
and simple SCP transfers (NEVER opens a shell!)
|
|
796
|
+
and simple SCP/SFTP transfers (NEVER opens a shell!)
|
|
671
797
|
By default all features are disabled. Use flags to enable features.
|
|
672
798
|
"""
|
|
673
799
|
|
|
@@ -682,12 +808,11 @@ examples: (also check README on Github)
|
|
|
682
808
|
%(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
|
|
683
809
|
# My favorite one
|
|
684
810
|
# Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
|
|
685
|
-
|
|
686
|
-
%(prog)s --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
811
|
+
%(prog)s --reverse --authorized-keys ./authorized_keys --scp-upload -p 2222
|
|
687
812
|
"""
|
|
688
813
|
|
|
689
814
|
def build_parser(full=False):
|
|
690
|
-
# Help got
|
|
815
|
+
# Help got too long so splitting it into '-h' and '--help'
|
|
691
816
|
def help_text(short_help=None, long_help=""):
|
|
692
817
|
if full: return (f"{short_help} " if short_help else "") + long_help
|
|
693
818
|
else: return short_help if short_help else argparse.SUPPRESS
|
|
@@ -709,8 +834,16 @@ def build_parser(full=False):
|
|
|
709
834
|
parser.add_argument("-1", "--single", action="store_true",
|
|
710
835
|
help=help_text(short_help="close the listener after first successful auth",
|
|
711
836
|
long_help="(and exit when that connection ends)"))
|
|
837
|
+
parser.add_argument("--mimic", metavar="PRESET", type=str.lower,
|
|
838
|
+
choices=list(MIMIC_PRESETS), default="none",
|
|
839
|
+
help=help_text(long_help="pose as another SSH server "
|
|
840
|
+
f"- presets (case-insensitive): {', '.join(list(MIMIC_PRESETS))} "
|
|
841
|
+
"- match the preset's pre-auth (banner, KEXINIT, server-sig-algs, ...) "
|
|
842
|
+
"exactly - banner can be overridden by --version-banner "
|
|
843
|
+
"- check Github repository for details"))
|
|
712
844
|
parser.add_argument("--host-key", metavar="FILE", type=Path,
|
|
713
|
-
help=help_text(long_help="server host key file
|
|
845
|
+
help=help_text(long_help="server host key file, may hold several keys "
|
|
846
|
+
"- auto-generated if missing - uses ./sshcatch_host_key by default"))
|
|
714
847
|
parser.add_argument("--version", action="version",
|
|
715
848
|
version=f"%(prog)s {__version__}",
|
|
716
849
|
help=help_text(long_help="show program's version number and exit"))
|
|
@@ -742,9 +875,7 @@ def build_parser(full=False):
|
|
|
742
875
|
|
|
743
876
|
banners = parser.add_argument_group("banners")
|
|
744
877
|
banners.add_argument("--version-banner", metavar="STRING",
|
|
745
|
-
help=help_text(long_help="
|
|
746
|
-
"only first-glance deception, it can still be identified as asyncssh - "
|
|
747
|
-
f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}"))
|
|
878
|
+
help=help_text(long_help="manually set 'SSH-2.0-STRING' version banner"))
|
|
748
879
|
banners.add_argument("--pre-auth-banner", metavar="STRING",
|
|
749
880
|
help=help_text(long_help="banner shown to every client before login"))
|
|
750
881
|
banners.add_argument("--post-auth-banner", metavar="STRING",
|
|
@@ -781,10 +912,9 @@ def main():
|
|
|
781
912
|
configure_logging(output=args.output, timestamps=args.timestamps,
|
|
782
913
|
plain=args.plain, console_level=console_level)
|
|
783
914
|
|
|
784
|
-
# Handle version-banner
|
|
785
|
-
if args.version_banner:
|
|
786
|
-
args.version_banner =
|
|
787
|
-
args.version_banner.lower(), args.version_banner)
|
|
915
|
+
# Handle --mimic version-banner
|
|
916
|
+
if args.mimic != "none" and not args.version_banner:
|
|
917
|
+
args.version_banner = MIMIC_PRESETS[args.mimic][0]
|
|
788
918
|
|
|
789
919
|
# Validate user format
|
|
790
920
|
if args.user:
|
|
@@ -803,10 +933,10 @@ def main():
|
|
|
803
933
|
parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
|
|
804
934
|
|
|
805
935
|
try: asyncio.run(start_server(args))
|
|
806
|
-
except
|
|
807
|
-
parser.error(f"Permission denied - port {args.port} requires root")
|
|
808
|
-
except OSError as e:
|
|
936
|
+
except (OSError, ValueError) as e:
|
|
809
937
|
parser.error(f"Could not start server: {e}")
|
|
938
|
+
except RuntimeError as e:
|
|
939
|
+
parser.error(str(e))
|
|
810
940
|
except KeyboardInterrupt:
|
|
811
941
|
print()
|
|
812
942
|
|
|
File without changes
|
|
File without changes
|