sshcatch 0.1.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.1.0/.gitignore +224 -0
- sshcatch-0.1.0/LICENSE +21 -0
- sshcatch-0.1.0/PKG-INFO +173 -0
- sshcatch-0.1.0/README.md +147 -0
- sshcatch-0.1.0/pyproject.toml +42 -0
- sshcatch-0.1.0/sshcatch.py +625 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# sshcatch runtime files - never commit these
|
|
221
|
+
# generated host keys (default and custom --host-key names)
|
|
222
|
+
*_host_key
|
|
223
|
+
# log files from -o/--output
|
|
224
|
+
*.log
|
sshcatch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LorenzMap
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sshcatch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sshcatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
|
|
5
|
+
Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
|
|
6
|
+
Project-URL: Repository, https://github.com/LorenzMap/sshcatch
|
|
7
|
+
Project-URL: Issues, https://github.com/LorenzMap/sshcatch/issues
|
|
8
|
+
Author: LorenzMap
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: pentest,scp,security,ssh,tunneling
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: System :: Networking
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: asyncssh>=2.15
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# sshcatch
|
|
28
|
+
|
|
29
|
+
A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP
|
|
30
|
+
transfers - it NEVER opens a shell.
|
|
31
|
+
|
|
32
|
+
By default all features are disabled: connections are logged and closed.
|
|
33
|
+
Turn on what you need with the flags described below. Handy on an engagement
|
|
34
|
+
when you want a controlled SSH endpoint without using a full `sshd`.
|
|
35
|
+
|
|
36
|
+
Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
pip install sshcatch
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or from source:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
git clone https://github.com/LorenzMap/sshcatch
|
|
48
|
+
cd sshcatch
|
|
49
|
+
pip install .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Needs Python 3.10+. A host key is auto-generated in the working directory on
|
|
53
|
+
first run (or point `--host-key` at your own).
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Because no shell is created on the server **always** use the `-N` flag on
|
|
58
|
+
your tunnel connections or you get disconnected instantly!
|
|
59
|
+
|
|
60
|
+
The SCP directory does intentionally **NOT** support subdirectories!
|
|
61
|
+
|
|
62
|
+
Log-only - just capture creds and full public keys:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
# Server
|
|
66
|
+
sshcatch -K
|
|
67
|
+
|
|
68
|
+
# Client
|
|
69
|
+
ssh user@host
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Let one user pull files via SCP/SFTP:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
# Server
|
|
76
|
+
sshcatch -u user:pass --scp-download
|
|
77
|
+
|
|
78
|
+
# Client
|
|
79
|
+
scp user@host:secret.txt .
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Let anyone tunnel through the server (local and dynamic forwards):
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
# Server
|
|
86
|
+
sshcatch --open-auth --forward
|
|
87
|
+
|
|
88
|
+
# Client
|
|
89
|
+
ssh -NL 8080:internal:80 user@host # local forward
|
|
90
|
+
ssh -ND 1080 user@host # dynamic (SOCKS)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
My favorite one - reverse tunnel and SCP uploads for the keys in
|
|
94
|
+
`./authorized-keys`, while posing as a ubuntu SSH server on port 2222:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
# Server
|
|
98
|
+
sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
99
|
+
|
|
100
|
+
# Client
|
|
101
|
+
ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
|
|
102
|
+
scp -P 2222 loot.tar user@host:. # upload
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Options
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE]
|
|
109
|
+
[-u USER:PASS] [--open-auth] [--authorized-keys FILE] [-K]
|
|
110
|
+
[--forward] [--reverse] [--scp-upload] [--scp-download]
|
|
111
|
+
[--scp-dir DIR] [--version-banner STRING]
|
|
112
|
+
[--pre-auth-banner STRING] [--post-auth-banner STRING]
|
|
113
|
+
[-o FILE] [-t] [--plain]
|
|
114
|
+
|
|
115
|
+
options:
|
|
116
|
+
-h, --help show this help message and exit
|
|
117
|
+
--version show program's version number and exit
|
|
118
|
+
-p PORT, --port PORT listen port (default: 22)
|
|
119
|
+
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
120
|
+
--host-key FILE server host key file (default: auto-generate)
|
|
121
|
+
|
|
122
|
+
authentication:
|
|
123
|
+
-u USER:PASS, --user USER:PASS
|
|
124
|
+
allowed user:password (repeatable)
|
|
125
|
+
--open-auth accept any credentials (open mode)
|
|
126
|
+
--authorized-keys FILE
|
|
127
|
+
authorized_keys file for key auth (username
|
|
128
|
+
independent)
|
|
129
|
+
-K, --full-keys log the full offered public key, not just its
|
|
130
|
+
fingerprint
|
|
131
|
+
|
|
132
|
+
tunneling:
|
|
133
|
+
--forward enable forward tunnels (client: ssh -NL / -ND)
|
|
134
|
+
--reverse enable reverse tunnels (client: ssh -NR)
|
|
135
|
+
|
|
136
|
+
SCP / file transfer:
|
|
137
|
+
--scp-upload enable file upload (SCP/SFTP write) - subdirectories
|
|
138
|
+
are disabled - files get suffix instead of overwriting
|
|
139
|
+
--scp-download enable file download (SCP/SFTP read) - subdirectories
|
|
140
|
+
are disabled
|
|
141
|
+
--scp-dir DIR directory for SCP/SFTP (default: cwd) - subdirectories
|
|
142
|
+
are disabled - host-key (and optional authorized_keys
|
|
143
|
+
and logfile) are protected
|
|
144
|
+
|
|
145
|
+
banners:
|
|
146
|
+
--version-banner STRING
|
|
147
|
+
sent as 'SSH-2.0-STRING' version banner - presets
|
|
148
|
+
(case-insensitive): ubuntu, debian, dropbear, windows,
|
|
149
|
+
macos
|
|
150
|
+
--pre-auth-banner STRING
|
|
151
|
+
banner shown to every client before login
|
|
152
|
+
--post-auth-banner STRING
|
|
153
|
+
banner shown only to clients that authenticate
|
|
154
|
+
successfully
|
|
155
|
+
|
|
156
|
+
logging:
|
|
157
|
+
-o FILE, --output FILE
|
|
158
|
+
append the log to FILE (plain with timestamps)
|
|
159
|
+
-t, --timestamps prefix console lines with a timestamp
|
|
160
|
+
--plain disable ANSI colors on the console
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## A word of warning
|
|
164
|
+
|
|
165
|
+
- This is a pentesting tool. Only point it at systems and networks you are
|
|
166
|
+
authorized to test.
|
|
167
|
+
|
|
168
|
+
- `--open-auth --forward` means ANYONE can tunnel through
|
|
169
|
+
your host - know what you're exposing before you run it.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT
|
sshcatch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# sshcatch
|
|
2
|
+
|
|
3
|
+
A quick-deploy SSH server for tunneling (local/remote/dynamic) and simple SCP
|
|
4
|
+
transfers - it NEVER opens a shell.
|
|
5
|
+
|
|
6
|
+
By default all features are disabled: connections are logged and closed.
|
|
7
|
+
Turn on what you need with the flags described below. Handy on an engagement
|
|
8
|
+
when you want a controlled SSH endpoint without using a full `sshd`.
|
|
9
|
+
|
|
10
|
+
Built on [asyncssh](https://github.com/ronf/asyncssh).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
pip install sshcatch
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or from source:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
git clone https://github.com/LorenzMap/sshcatch
|
|
22
|
+
cd sshcatch
|
|
23
|
+
pip install .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Needs Python 3.10+. A host key is auto-generated in the working directory on
|
|
27
|
+
first run (or point `--host-key` at your own).
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Because no shell is created on the server **always** use the `-N` flag on
|
|
32
|
+
your tunnel connections or you get disconnected instantly!
|
|
33
|
+
|
|
34
|
+
The SCP directory does intentionally **NOT** support subdirectories!
|
|
35
|
+
|
|
36
|
+
Log-only - just capture creds and full public keys:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
# Server
|
|
40
|
+
sshcatch -K
|
|
41
|
+
|
|
42
|
+
# Client
|
|
43
|
+
ssh user@host
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Let one user pull files via SCP/SFTP:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
# Server
|
|
50
|
+
sshcatch -u user:pass --scp-download
|
|
51
|
+
|
|
52
|
+
# Client
|
|
53
|
+
scp user@host:secret.txt .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Let anyone tunnel through the server (local and dynamic forwards):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
# Server
|
|
60
|
+
sshcatch --open-auth --forward
|
|
61
|
+
|
|
62
|
+
# Client
|
|
63
|
+
ssh -NL 8080:internal:80 user@host # local forward
|
|
64
|
+
ssh -ND 1080 user@host # dynamic (SOCKS)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
My favorite one - reverse tunnel and SCP uploads for the keys in
|
|
68
|
+
`./authorized-keys`, while posing as a ubuntu SSH server on port 2222:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
# Server
|
|
72
|
+
sshcatch --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
73
|
+
|
|
74
|
+
# Client
|
|
75
|
+
ssh -NR 9000:localhost:22 user@host -p 2222 # reverse tunnel
|
|
76
|
+
scp -P 2222 loot.tar user@host:. # upload
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Options
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
usage: sshcatch [-h] [--version] [-p PORT] [-b BIND] [--host-key FILE]
|
|
83
|
+
[-u USER:PASS] [--open-auth] [--authorized-keys FILE] [-K]
|
|
84
|
+
[--forward] [--reverse] [--scp-upload] [--scp-download]
|
|
85
|
+
[--scp-dir DIR] [--version-banner STRING]
|
|
86
|
+
[--pre-auth-banner STRING] [--post-auth-banner STRING]
|
|
87
|
+
[-o FILE] [-t] [--plain]
|
|
88
|
+
|
|
89
|
+
options:
|
|
90
|
+
-h, --help show this help message and exit
|
|
91
|
+
--version show program's version number and exit
|
|
92
|
+
-p PORT, --port PORT listen port (default: 22)
|
|
93
|
+
-b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
|
|
94
|
+
--host-key FILE server host key file (default: auto-generate)
|
|
95
|
+
|
|
96
|
+
authentication:
|
|
97
|
+
-u USER:PASS, --user USER:PASS
|
|
98
|
+
allowed user:password (repeatable)
|
|
99
|
+
--open-auth accept any credentials (open mode)
|
|
100
|
+
--authorized-keys FILE
|
|
101
|
+
authorized_keys file for key auth (username
|
|
102
|
+
independent)
|
|
103
|
+
-K, --full-keys log the full offered public key, not just its
|
|
104
|
+
fingerprint
|
|
105
|
+
|
|
106
|
+
tunneling:
|
|
107
|
+
--forward enable forward tunnels (client: ssh -NL / -ND)
|
|
108
|
+
--reverse enable reverse tunnels (client: ssh -NR)
|
|
109
|
+
|
|
110
|
+
SCP / file transfer:
|
|
111
|
+
--scp-upload enable file upload (SCP/SFTP write) - subdirectories
|
|
112
|
+
are disabled - files get suffix instead of overwriting
|
|
113
|
+
--scp-download enable file download (SCP/SFTP read) - subdirectories
|
|
114
|
+
are disabled
|
|
115
|
+
--scp-dir DIR directory for SCP/SFTP (default: cwd) - subdirectories
|
|
116
|
+
are disabled - host-key (and optional authorized_keys
|
|
117
|
+
and logfile) are protected
|
|
118
|
+
|
|
119
|
+
banners:
|
|
120
|
+
--version-banner STRING
|
|
121
|
+
sent as 'SSH-2.0-STRING' version banner - presets
|
|
122
|
+
(case-insensitive): ubuntu, debian, dropbear, windows,
|
|
123
|
+
macos
|
|
124
|
+
--pre-auth-banner STRING
|
|
125
|
+
banner shown to every client before login
|
|
126
|
+
--post-auth-banner STRING
|
|
127
|
+
banner shown only to clients that authenticate
|
|
128
|
+
successfully
|
|
129
|
+
|
|
130
|
+
logging:
|
|
131
|
+
-o FILE, --output FILE
|
|
132
|
+
append the log to FILE (plain with timestamps)
|
|
133
|
+
-t, --timestamps prefix console lines with a timestamp
|
|
134
|
+
--plain disable ANSI colors on the console
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## A word of warning
|
|
138
|
+
|
|
139
|
+
- This is a pentesting tool. Only point it at systems and networks you are
|
|
140
|
+
authorized to test.
|
|
141
|
+
|
|
142
|
+
- `--open-auth --forward` means ANYONE can tunnel through
|
|
143
|
+
your host - know what you're exposing before you run it.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sshcatch"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "LorenzMap" }]
|
|
14
|
+
keywords = ["ssh", "pentest", "tunneling", "scp", "security"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Information Technology",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Security",
|
|
26
|
+
"Topic :: System :: Networking",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["asyncssh>=2.15"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/LorenzMap/sshcatch"
|
|
32
|
+
Repository = "https://github.com/LorenzMap/sshcatch"
|
|
33
|
+
Issues = "https://github.com/LorenzMap/sshcatch/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
sshcatch = "sshcatch:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
path = "sshcatch.py"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
only-include = ["sshcatch.py"]
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
|
|
4
|
+
and simple SCP transfers (NEVER opens a shell!).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import posixpath
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from itertools import count
|
|
15
|
+
|
|
16
|
+
import asyncssh
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
# ── Logging ─--────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
RED = "\033[91m"
|
|
23
|
+
GREEN = "\033[92m"
|
|
24
|
+
YELLOW = "\033[93m"
|
|
25
|
+
PURPLE = "\033[95m"
|
|
26
|
+
CYAN = "\033[96m"
|
|
27
|
+
BOLD = "\033[1m"
|
|
28
|
+
RST = "\033[0m"
|
|
29
|
+
|
|
30
|
+
def configure_logging(output=None, timestamps=False, plain=False):
|
|
31
|
+
logger = logging.getLogger("sshcatch")
|
|
32
|
+
logger.setLevel(logging.INFO)
|
|
33
|
+
logger.propagate = False
|
|
34
|
+
logger.handlers.clear()
|
|
35
|
+
|
|
36
|
+
# Setup console logging
|
|
37
|
+
field = "plain" if plain else "colored"
|
|
38
|
+
line = f"%({field})s %(message)s"
|
|
39
|
+
if timestamps:
|
|
40
|
+
line = "%(asctime)s " + line
|
|
41
|
+
out = logging.StreamHandler(sys.stdout)
|
|
42
|
+
out.setFormatter(logging.Formatter(line, datefmt="%Y-%m-%d %H:%M:%S"))
|
|
43
|
+
logger.addHandler(out)
|
|
44
|
+
|
|
45
|
+
# Setup File Logging
|
|
46
|
+
if output:
|
|
47
|
+
file_handler = logging.FileHandler(output, mode="a", encoding="utf-8")
|
|
48
|
+
file_handler.setFormatter(logging.Formatter("%(asctime)s %(plain)s %(message)s",
|
|
49
|
+
datefmt="%Y-%m-%d %H:%M:%S"))
|
|
50
|
+
logger.addHandler(file_handler)
|
|
51
|
+
|
|
52
|
+
def _log(tag, color, msg, addr=None, user=None):
|
|
53
|
+
logger = logging.getLogger("sshcatch")
|
|
54
|
+
loc = (f"{BOLD}[{addr}]{RST}" if addr else "") + (f"{BOLD}[{user}]{RST}" if user else "")
|
|
55
|
+
colored = f"{color}[{tag}]{RST}" + loc
|
|
56
|
+
plain = f"[{tag}]" + (f"[{addr}]" if addr else "") + (f"[{user}]" if user else "")
|
|
57
|
+
logger.info(msg, extra={"colored": colored, "plain": plain})
|
|
58
|
+
|
|
59
|
+
def log_conn(msg, addr=None, user=None):
|
|
60
|
+
_log("+", GREEN, msg, addr, user)
|
|
61
|
+
|
|
62
|
+
def log_auth(msg, success=False, addr=None, user=None):
|
|
63
|
+
_log("AUTH", GREEN if success else YELLOW, msg, addr, user)
|
|
64
|
+
|
|
65
|
+
def log_scp(msg, addr=None, user=None):
|
|
66
|
+
_log("SCP", PURPLE, msg, addr, user)
|
|
67
|
+
|
|
68
|
+
def log_tunnel(msg, addr=None, user=None):
|
|
69
|
+
_log("TUNNEL", CYAN, msg, addr, user)
|
|
70
|
+
|
|
71
|
+
def log_info(msg, addr=None, user=None):
|
|
72
|
+
_log("*", BOLD, msg, addr, user)
|
|
73
|
+
|
|
74
|
+
def addr_str(host, port):
|
|
75
|
+
if ":" in str(host): return f"[{host}]:{port}"
|
|
76
|
+
else: return f"{host}:{port}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── SFTP server ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
DENY = asyncssh.SFTPPermissionDenied
|
|
82
|
+
|
|
83
|
+
class SFTPCatchServer(asyncssh.SFTPServer):
|
|
84
|
+
def __init__(self, chan, chroot, allow_upload, allow_download, protected_files=()):
|
|
85
|
+
super().__init__(chan, chroot=chroot)
|
|
86
|
+
self._root = Path(chroot)
|
|
87
|
+
self._allow_upload = allow_upload
|
|
88
|
+
self._allow_download = allow_download
|
|
89
|
+
self._protected_files = set(protected_files)
|
|
90
|
+
conn = chan.get_connection()
|
|
91
|
+
self._user = conn.get_extra_info("username") or "?"
|
|
92
|
+
peer = conn.get_extra_info("peername")
|
|
93
|
+
self._addr = addr_str(peer[0], peer[1]) if peer else "?"
|
|
94
|
+
self._log_scp("Session opened")
|
|
95
|
+
|
|
96
|
+
def _log_scp(self, msg):
|
|
97
|
+
log_scp(msg, addr=self._addr, user=self._user)
|
|
98
|
+
|
|
99
|
+
# ── path checks ──────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def _require_flat(self, path):
|
|
102
|
+
# Simple check if subdirectories are used - full 'name' must be equal to basename
|
|
103
|
+
name = path.lstrip(b"/")
|
|
104
|
+
if name in (b"", b".", b"..") or name != posixpath.basename(name):
|
|
105
|
+
self._log_scp(f"DENIED ACCESS {path.decode(errors='replace')}")
|
|
106
|
+
raise DENY("Access restricted to root directory")
|
|
107
|
+
|
|
108
|
+
def _require_not_protected(self, path):
|
|
109
|
+
# Prevent access to protected files in the scp dir
|
|
110
|
+
# (authorized_keys, host key, log file)
|
|
111
|
+
if os.fsdecode(posixpath.basename(path)) in self._protected_files:
|
|
112
|
+
self._log_scp(f"DENIED PROTECTED {path.decode(errors='replace')}")
|
|
113
|
+
raise DENY("Not allowed")
|
|
114
|
+
|
|
115
|
+
def _unique_write_path(self, path):
|
|
116
|
+
# Check if the upload overwrites something
|
|
117
|
+
# Watch out: asyncssh SFTP uses posixpath independently of the actual OS
|
|
118
|
+
base = posixpath.basename(path)
|
|
119
|
+
if not (self._root / os.fsdecode(base)).exists():
|
|
120
|
+
return path
|
|
121
|
+
# Simply add a number until we are unique
|
|
122
|
+
stem, ext = posixpath.splitext(base)
|
|
123
|
+
for n in count(1):
|
|
124
|
+
name = b"%s_%d%s" % (stem, n, ext)
|
|
125
|
+
if not (self._root / os.fsdecode(name)).exists():
|
|
126
|
+
return b"/" + name
|
|
127
|
+
|
|
128
|
+
# ── session / file handling ──────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def open(self, path, pflags, attrs):
|
|
131
|
+
self._require_flat(path)
|
|
132
|
+
self._require_not_protected(path)
|
|
133
|
+
is_write = bool(pflags & (0x02 | 0x04 | 0x08)) # WRITE|APPEND|CREAT
|
|
134
|
+
|
|
135
|
+
if is_write and not self._allow_upload:
|
|
136
|
+
self._log_scp(f"DENIED WRITE {path.decode(errors='replace')}")
|
|
137
|
+
raise DENY("Upload is disabled")
|
|
138
|
+
if not is_write and not self._allow_download:
|
|
139
|
+
self._log_scp(f"DENIED READ {path.decode(errors='replace')}")
|
|
140
|
+
raise DENY("Download is disabled")
|
|
141
|
+
|
|
142
|
+
# Prevent overwriting existing files
|
|
143
|
+
if is_write:
|
|
144
|
+
unique = self._unique_write_path(path)
|
|
145
|
+
if unique != path:
|
|
146
|
+
self._log_scp(f"EXISTS {path.decode(errors='replace')} -> "
|
|
147
|
+
f"{unique.decode(errors='replace')}")
|
|
148
|
+
path = unique
|
|
149
|
+
|
|
150
|
+
label = "WRITE" if is_write else "READ"
|
|
151
|
+
self._log_scp(f"{label} {path.decode(errors='replace')}")
|
|
152
|
+
return super().open(path, pflags, attrs)
|
|
153
|
+
|
|
154
|
+
def exit(self):
|
|
155
|
+
self._log_scp("Session closed")
|
|
156
|
+
return super().exit()
|
|
157
|
+
|
|
158
|
+
# ── blocked operation overwritten for better logging ────────────
|
|
159
|
+
|
|
160
|
+
def remove(self, path):
|
|
161
|
+
self._log_scp(f"DENIED DELETE {path.decode(errors='replace')}")
|
|
162
|
+
raise DENY("Not allowed")
|
|
163
|
+
|
|
164
|
+
def rename(self, old, new):
|
|
165
|
+
self._log_scp(f"DENIED RENAME {old.decode(errors='replace')}")
|
|
166
|
+
raise DENY("Not allowed")
|
|
167
|
+
|
|
168
|
+
def mkdir(self, path, attrs):
|
|
169
|
+
self._log_scp(f"DENIED MKDIR {path.decode(errors='replace')}")
|
|
170
|
+
raise DENY("Not allowed")
|
|
171
|
+
|
|
172
|
+
def rmdir(self, path):
|
|
173
|
+
self._log_scp(f"DENIED RMDIR {path.decode(errors='replace')}")
|
|
174
|
+
raise DENY("Not allowed")
|
|
175
|
+
|
|
176
|
+
def link(self, old, new):
|
|
177
|
+
self._log_scp(f"DENIED LINK {old.decode(errors='replace')}")
|
|
178
|
+
raise DENY("Not allowed")
|
|
179
|
+
|
|
180
|
+
def symlink(self, old, new):
|
|
181
|
+
self._log_scp(f"DENIED SYMLINK {old.decode(errors='replace')}")
|
|
182
|
+
raise DENY("Not allowed")
|
|
183
|
+
|
|
184
|
+
def scandir(self, path):
|
|
185
|
+
self._log_scp(f"DENIED LISTDIR {path.decode(errors='replace')}")
|
|
186
|
+
raise DENY("Not allowed")
|
|
187
|
+
|
|
188
|
+
# Overwriting every possible method on our SFTP server to prevent
|
|
189
|
+
# unintended access
|
|
190
|
+
# Will probably break on bigger asyncssh updates
|
|
191
|
+
_SFTP_ALL_OPS = {
|
|
192
|
+
# file I/O
|
|
193
|
+
"open", "open56", "close", "read", "write",
|
|
194
|
+
# attributes
|
|
195
|
+
"stat", "lstat", "fstat", "setstat", "lsetstat", "fsetstat",
|
|
196
|
+
# directory
|
|
197
|
+
"scandir", "mkdir", "rmdir",
|
|
198
|
+
# path ops
|
|
199
|
+
"realpath", "readlink", "symlink", "link", "rename", "posix_rename", "remove",
|
|
200
|
+
# filesystem
|
|
201
|
+
"statvfs", "fstatvfs", "fsync",
|
|
202
|
+
# locking
|
|
203
|
+
"lock", "unlock",
|
|
204
|
+
# lifecycle
|
|
205
|
+
"exit",
|
|
206
|
+
# internal helpers (called by base class, not by SFTP packets)
|
|
207
|
+
"map_path", "reverse_map_path",
|
|
208
|
+
"format_user", "format_group", "format_longname",
|
|
209
|
+
"convert_attrs",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Only these are allowed because they are required for basic filetransfer
|
|
213
|
+
# Most of them follow symlinks, which we prevent by checking the scp directory
|
|
214
|
+
# for them before start and don't even allow the server to run if they exist
|
|
215
|
+
_SFTP_WHITELIST = {
|
|
216
|
+
"open", # overridden above (with subdirectory prevention)
|
|
217
|
+
"close", # required to close file handles after read/write
|
|
218
|
+
"read", # required for file download (SCP get)
|
|
219
|
+
"write", # required for file upload (SCP put)
|
|
220
|
+
"stat", # SCP protocol queries file size/perms before transfer
|
|
221
|
+
"lstat", # like stat, but doesn't follow symlinks
|
|
222
|
+
"fstat", # stat on open file handle
|
|
223
|
+
"setstat", # SCP sets permissions and timestamps after upload
|
|
224
|
+
"fsetstat", # same as setstat but on an open file handle
|
|
225
|
+
"realpath", # resolves "." and ".." for path canonicalization
|
|
226
|
+
"exit", # overridden above
|
|
227
|
+
# internal helpers - blocking these would break the server
|
|
228
|
+
"map_path", "reverse_map_path",
|
|
229
|
+
"format_user", "format_group", "format_longname",
|
|
230
|
+
"convert_attrs",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def _make_sftp_deny(method_name):
|
|
234
|
+
# Lets do some funky overwriting
|
|
235
|
+
def denied(self, *args, **kwargs):
|
|
236
|
+
self._log_scp(f"DENIED {method_name}")
|
|
237
|
+
raise DENY("Not allowed")
|
|
238
|
+
denied.__name__ = method_name
|
|
239
|
+
return denied
|
|
240
|
+
|
|
241
|
+
# Block everything not whitelisted
|
|
242
|
+
# and skip methods already overridden in our class
|
|
243
|
+
for _name in _SFTP_ALL_OPS - _SFTP_WHITELIST:
|
|
244
|
+
if _name not in SFTPCatchServer.__dict__:
|
|
245
|
+
setattr(SFTPCatchServer, _name, _make_sftp_deny(_name))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── SSH server factory ────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def make_server_factory(args):
|
|
251
|
+
# Set up authentication once at startup to keep the runtime simpler
|
|
252
|
+
users = {}
|
|
253
|
+
if args.user:
|
|
254
|
+
for entry in args.user:
|
|
255
|
+
u, p = entry.split(":", 1)
|
|
256
|
+
users[u] = p
|
|
257
|
+
|
|
258
|
+
auth_keys_fps = set()
|
|
259
|
+
if args.authorized_keys:
|
|
260
|
+
for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
|
|
261
|
+
line = line.strip()
|
|
262
|
+
if not line or line.startswith('#'):
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
k = asyncssh.import_public_key(line)
|
|
266
|
+
fp = k.get_fingerprint()
|
|
267
|
+
auth_keys_fps.add(fp)
|
|
268
|
+
log_info(f"Loaded key line={i} fingerprint={fp}")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
log_info(f"Invalid key line={i} {e}")
|
|
271
|
+
|
|
272
|
+
# --open-auth set: always return true
|
|
273
|
+
if args.open_auth: accept_password = lambda u, p: True
|
|
274
|
+
# --user set: check password
|
|
275
|
+
elif users: accept_password = lambda u, p: users.get(u) == p
|
|
276
|
+
# default: deny everything
|
|
277
|
+
else: accept_password = lambda u, p: False
|
|
278
|
+
|
|
279
|
+
# --open-auth set: always return true
|
|
280
|
+
if args.open_auth: accept_key = lambda fp: True
|
|
281
|
+
# --authorized-keys set: check the key
|
|
282
|
+
elif auth_keys_fps: accept_key = lambda fp: fp in auth_keys_fps
|
|
283
|
+
# default: deny everything
|
|
284
|
+
else: accept_key = lambda fp: False
|
|
285
|
+
|
|
286
|
+
log_only = not (args.scp_upload or args.scp_download or args.forward or args.reverse)
|
|
287
|
+
|
|
288
|
+
class SSHCatchServer(asyncssh.SSHServer):
|
|
289
|
+
def connection_made(self, conn):
|
|
290
|
+
self._conn = conn
|
|
291
|
+
self._version_logged = False
|
|
292
|
+
# save last key fingerprint to dedup key probe/sign
|
|
293
|
+
self._last_key_fp = None
|
|
294
|
+
# track if we already sent the post-auth banner
|
|
295
|
+
self._post_sent = False
|
|
296
|
+
peer = conn.get_extra_info("peername")
|
|
297
|
+
self._addr = addr_str(peer[0], peer[1]) if peer else "?"
|
|
298
|
+
log_conn("Connection opened", addr=self._addr)
|
|
299
|
+
|
|
300
|
+
def _log_client_version(self):
|
|
301
|
+
if self._version_logged: return
|
|
302
|
+
version = self._conn.get_extra_info("client_version")
|
|
303
|
+
if version:
|
|
304
|
+
self._version_logged = True
|
|
305
|
+
log_conn(f"Client version: {version}", addr=self._addr)
|
|
306
|
+
|
|
307
|
+
def connection_lost(self, exc):
|
|
308
|
+
# fallback for clients that grab the banner and drop without auth
|
|
309
|
+
self._log_client_version()
|
|
310
|
+
user = self._conn.get_extra_info("username")
|
|
311
|
+
if exc: log_conn(f"Connection lost: {exc}", addr=self._addr, user=user)
|
|
312
|
+
else: log_conn("Connection closed", addr=self._addr, user=user)
|
|
313
|
+
|
|
314
|
+
# -- banner ---------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def _send_banner(self, text):
|
|
317
|
+
# Send auth messages so we never have to open a session
|
|
318
|
+
self._conn.send_auth_banner(text if text.endswith("\n") else text + "\n")
|
|
319
|
+
|
|
320
|
+
def begin_auth(self, username):
|
|
321
|
+
self._log_client_version()
|
|
322
|
+
if args.pre_auth_banner:
|
|
323
|
+
self._send_banner(args.pre_auth_banner)
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
def _post_auth(self):
|
|
327
|
+
# guarded because key auth may call this twice (probe + sign)
|
|
328
|
+
if args.post_auth_banner and not self._post_sent:
|
|
329
|
+
self._post_sent = True
|
|
330
|
+
self._send_banner(args.post_auth_banner)
|
|
331
|
+
|
|
332
|
+
# -- authentication -------------------------------------------
|
|
333
|
+
|
|
334
|
+
def public_key_auth_supported(self):
|
|
335
|
+
# always accept offers so we can log them
|
|
336
|
+
return True
|
|
337
|
+
|
|
338
|
+
def validate_public_key(self, username, key):
|
|
339
|
+
fp = key.get_fingerprint()
|
|
340
|
+
accepted = accept_key(fp)
|
|
341
|
+
# we log only the first time we see a key because clients
|
|
342
|
+
# may send probe first and then sign
|
|
343
|
+
if fp != self._last_key_fp:
|
|
344
|
+
self._last_key_fp = fp
|
|
345
|
+
if accepted: log_auth(f"Key accepted: {fp}", success=True, addr=self._addr, user=username)
|
|
346
|
+
else: log_auth(f"Key rejected: {fp}", success=False, addr=self._addr, user=username)
|
|
347
|
+
if args.full_keys: log_auth(f" {key.export_public_key().decode().strip()}", addr=self._addr, user=username)
|
|
348
|
+
if accepted:
|
|
349
|
+
self._post_auth()
|
|
350
|
+
# Schedule to close the connection if we dont need it
|
|
351
|
+
if log_only: self._schedule_close()
|
|
352
|
+
return accepted
|
|
353
|
+
|
|
354
|
+
def password_auth_supported(self):
|
|
355
|
+
# always accept passwords so we can log them
|
|
356
|
+
return True
|
|
357
|
+
|
|
358
|
+
def validate_password(self, username, password):
|
|
359
|
+
accepted = accept_password(username, password)
|
|
360
|
+
if accepted:
|
|
361
|
+
log_auth(f"Password accepted: {password}", success=True, addr=self._addr, user=username)
|
|
362
|
+
self._post_auth()
|
|
363
|
+
# Schedule to close the connection if we dont need it
|
|
364
|
+
if log_only: self._schedule_close()
|
|
365
|
+
else:
|
|
366
|
+
log_auth(f"Password rejected: {password}", success=False, addr=self._addr, user=username)
|
|
367
|
+
return accepted
|
|
368
|
+
|
|
369
|
+
def _schedule_close(self):
|
|
370
|
+
asyncio.get_running_loop().call_later(0.5, self._conn.close)
|
|
371
|
+
|
|
372
|
+
# -- tunneling ------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def connection_requested(self, dest_host, dest_port, orig_host, orig_port):
|
|
375
|
+
user = self._conn.get_extra_info("username")
|
|
376
|
+
if not args.forward:
|
|
377
|
+
log_tunnel(f"DENIED forward {addr_str(orig_host, orig_port)} -> "
|
|
378
|
+
f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
|
|
379
|
+
return False
|
|
380
|
+
log_tunnel(f"Forward {addr_str(orig_host, orig_port)} -> "
|
|
381
|
+
f"{addr_str(dest_host, dest_port)}", addr=self._addr, user=user)
|
|
382
|
+
return True
|
|
383
|
+
|
|
384
|
+
def server_requested(self, listen_host, listen_port):
|
|
385
|
+
user = self._conn.get_extra_info("username")
|
|
386
|
+
if not args.reverse:
|
|
387
|
+
log_tunnel(f"DENIED reverse {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
|
|
388
|
+
return False
|
|
389
|
+
log_tunnel(f"Reverse listen on {addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
|
|
390
|
+
|
|
391
|
+
def accept(orig_host, orig_port):
|
|
392
|
+
# Log the connection - real target is requested/resolved on the
|
|
393
|
+
# client so we can't show it (decided against packet inspection)
|
|
394
|
+
log_tunnel(f"Reverse {addr_str(orig_host, orig_port)} on "
|
|
395
|
+
f"{addr_str(listen_host, listen_port)}", addr=self._addr, user=user)
|
|
396
|
+
return True
|
|
397
|
+
return accept
|
|
398
|
+
|
|
399
|
+
return SSHCatchServer
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── Server start ──────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
async def start_server(args):
|
|
405
|
+
# Handle Host key
|
|
406
|
+
key_path = args.host_key if args.host_key else Path.cwd()
|
|
407
|
+
if key_path.is_dir():
|
|
408
|
+
key_path = key_path / "sshcatch_host_key"
|
|
409
|
+
if not key_path.parent.is_dir():
|
|
410
|
+
raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
|
|
411
|
+
|
|
412
|
+
if key_path.is_file():
|
|
413
|
+
host_key = asyncssh.read_private_key(str(key_path))
|
|
414
|
+
log_info(f"Read host key: {key_path}")
|
|
415
|
+
else:
|
|
416
|
+
host_key = asyncssh.generate_private_key("ssh-rsa", key_size=2048)
|
|
417
|
+
host_key.write_private_key(str(key_path))
|
|
418
|
+
log_info(f"Generated host key: {key_path}")
|
|
419
|
+
if os.name == "posix": key_path.chmod(0o600)
|
|
420
|
+
else: log_info(f"Please make sure the permissions on the host key are securely set!")
|
|
421
|
+
fingerprint = host_key.get_fingerprint()
|
|
422
|
+
|
|
423
|
+
# Build the connection options for the ssh server
|
|
424
|
+
opts = {
|
|
425
|
+
"server_factory": make_server_factory(args),
|
|
426
|
+
"server_host_keys": [host_key],
|
|
427
|
+
# SFTPv3 only so all transfers use open() and not open56()
|
|
428
|
+
"sftp_version": 3,
|
|
429
|
+
}
|
|
430
|
+
if args.version_banner:
|
|
431
|
+
opts["server_version"] = args.version_banner
|
|
432
|
+
|
|
433
|
+
has_scp = args.scp_upload or args.scp_download
|
|
434
|
+
if has_scp:
|
|
435
|
+
scp_dir = args.scp_dir.resolve()
|
|
436
|
+
protected_files = {
|
|
437
|
+
p.resolve().name
|
|
438
|
+
for p in (key_path, args.authorized_keys, args.output)
|
|
439
|
+
if p is not None and p.resolve().parent == scp_dir
|
|
440
|
+
}
|
|
441
|
+
if protected_files:
|
|
442
|
+
log_info(f"Protected in scp dir: {', '.join(sorted(protected_files))}")
|
|
443
|
+
opts["sftp_factory"] = lambda chan: SFTPCatchServer(
|
|
444
|
+
chan, chroot=str(scp_dir),
|
|
445
|
+
allow_upload=args.scp_upload, allow_download=args.scp_download,
|
|
446
|
+
protected_files=protected_files,
|
|
447
|
+
)
|
|
448
|
+
opts["allow_scp"] = True
|
|
449
|
+
else:
|
|
450
|
+
# We dont even create the SFTP Server if we dont have to
|
|
451
|
+
def denied_sftp(chan):
|
|
452
|
+
conn = chan.get_connection()
|
|
453
|
+
user = conn.get_extra_info("username") or "?"
|
|
454
|
+
peer = conn.get_extra_info("peername")
|
|
455
|
+
addr = addr_str(peer[0], peer[1]) if peer else "?"
|
|
456
|
+
log_scp("DENIED SFTP", addr=addr, user=user)
|
|
457
|
+
raise asyncssh.SFTPPermissionDenied("SCP/SFTP is disabled")
|
|
458
|
+
opts["sftp_factory"] = denied_sftp
|
|
459
|
+
# We allow logins however so we can log connections and credentials
|
|
460
|
+
opts["allow_scp"] = True
|
|
461
|
+
|
|
462
|
+
# Create our options object so everything is validated against asyncssh before we print it
|
|
463
|
+
options = await asyncssh.SSHServerConnectionOptions.construct(**opts)
|
|
464
|
+
|
|
465
|
+
# Print Startup Information
|
|
466
|
+
if args.bind: bind = addr_str(args.bind, args.port)
|
|
467
|
+
else: bind = f"{addr_str('0.0.0.0', args.port)} {addr_str('::', args.port)}"
|
|
468
|
+
if args.open_auth: auth_mode = "open (accept any)"
|
|
469
|
+
elif args.user or args.authorized_keys: auth_mode = "restricted"
|
|
470
|
+
else: auth_mode = "reject all (no auth configured)"
|
|
471
|
+
features = []
|
|
472
|
+
if args.forward: features.append("forward-tunnel")
|
|
473
|
+
if args.reverse: features.append("reverse-tunnel")
|
|
474
|
+
if args.scp_upload: features.append("scp-upload")
|
|
475
|
+
if args.scp_download: features.append("scp-download")
|
|
476
|
+
if not features: features.append("log-only (connect & close)")
|
|
477
|
+
|
|
478
|
+
if args.plain: print(f"\n-- sshcatch --")
|
|
479
|
+
else: print(f"\n{BOLD}sshcatch{RST}")
|
|
480
|
+
print(f" Listen ........ {bind}")
|
|
481
|
+
print(f" Auth .......... {auth_mode}")
|
|
482
|
+
print(f" Features ...... {', '.join(features)}")
|
|
483
|
+
if has_scp: print(f" SCP dir ....... {args.scp_dir.resolve()}")
|
|
484
|
+
print(f" Version ....... SSH-2.0-{options.version.decode()}")
|
|
485
|
+
if args.pre_auth_banner: print(f" Pre-auth ...... set")
|
|
486
|
+
if args.post_auth_banner: print(f" Post-auth ..... set")
|
|
487
|
+
print(f" Host key ...... {fingerprint}")
|
|
488
|
+
print(f" Key file ...... {key_path}")
|
|
489
|
+
print()
|
|
490
|
+
|
|
491
|
+
await asyncssh.listen(host=args.bind, port=args.port, options=options)
|
|
492
|
+
await asyncio.Event().wait() # run forever
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# ── Main ──────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
# Quick --version-banner presets: keyword -> realistic 'SSH-2.0-<value>' banner.
|
|
498
|
+
VERSION_PRESETS = {
|
|
499
|
+
"ubuntu": "OpenSSH_9.6p1 Ubuntu-3ubuntu13.5",
|
|
500
|
+
"debian": "OpenSSH_9.2p1 Debian-2+deb12u3",
|
|
501
|
+
"dropbear": "dropbear_2022.83",
|
|
502
|
+
"windows": "OpenSSH_for_Windows_9.5",
|
|
503
|
+
"macos": "OpenSSH_9.8",
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
_description="""\
|
|
507
|
+
sshcatch - a quick-deploy SSH server for tunneling (local/remote/dynamic)
|
|
508
|
+
and simple SCP transfers (NEVER opens a shell!).
|
|
509
|
+
By default all features are disabled: connections are logged and
|
|
510
|
+
closed. Use flags to enable features.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
_epilog="""\
|
|
514
|
+
examples:
|
|
515
|
+
%(prog)s -K Log-only (capture creds and full public keys)
|
|
516
|
+
%(prog)s -u user:pass --scp-download Allow one user to download via SCP/SFTP
|
|
517
|
+
%(prog)s --open-auth --forward Allow ANYONE! to tunnel through this SSH server
|
|
518
|
+
# My favorite one
|
|
519
|
+
# Allows reverse tunnels and uploads via SCP for the keys in ./authorized_keys
|
|
520
|
+
# while posing as a ubuntu SSH server on port 2222
|
|
521
|
+
%(prog)s --reverse --authorized-keys ./authorized-keys --scp-upload --version-banner ubuntu -p 2222
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
def main():
|
|
525
|
+
parser = argparse.ArgumentParser(
|
|
526
|
+
description=_description,
|
|
527
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
528
|
+
epilog=_epilog)
|
|
529
|
+
parser.add_argument("--version", action="version",
|
|
530
|
+
version=f"%(prog)s {__version__}")
|
|
531
|
+
parser.add_argument("-p", "--port", type=int, default=22,
|
|
532
|
+
help="listen port (default: 22)")
|
|
533
|
+
parser.add_argument("-b", "--bind", default="",
|
|
534
|
+
help="bind address (default: all IPv4/v6 interfaces)")
|
|
535
|
+
parser.add_argument("--host-key", metavar="FILE", type=Path,
|
|
536
|
+
help="server host key file (default: auto-generate)")
|
|
537
|
+
|
|
538
|
+
auth = parser.add_argument_group("authentication")
|
|
539
|
+
auth.add_argument("-u", "--user", action="append", metavar="USER:PASS",
|
|
540
|
+
help="allowed user:password (repeatable)")
|
|
541
|
+
auth.add_argument("--open-auth", action="store_true",
|
|
542
|
+
help="accept any credentials (open mode)")
|
|
543
|
+
auth.add_argument("--authorized-keys", metavar="FILE", type=Path,
|
|
544
|
+
help="authorized_keys file for key auth (username independent)")
|
|
545
|
+
auth.add_argument("-K", "--full-keys", action="store_true",
|
|
546
|
+
help="log the full offered public key, not just its fingerprint")
|
|
547
|
+
|
|
548
|
+
tunnel = parser.add_argument_group("tunneling")
|
|
549
|
+
tunnel.add_argument("--forward", action="store_true",
|
|
550
|
+
help="enable forward tunnels (client: ssh -NL / -ND)")
|
|
551
|
+
tunnel.add_argument("--reverse", action="store_true",
|
|
552
|
+
help="enable reverse tunnels (client: ssh -NR)")
|
|
553
|
+
|
|
554
|
+
scp = parser.add_argument_group("SCP / file transfer")
|
|
555
|
+
scp.add_argument("--scp-upload", action="store_true",
|
|
556
|
+
help="enable file upload (SCP/SFTP write) - subdirectories are disabled - "
|
|
557
|
+
"files get suffix instead of overwriting")
|
|
558
|
+
scp.add_argument("--scp-download", action="store_true",
|
|
559
|
+
help="enable file download (SCP/SFTP read) - subdirectories are disabled")
|
|
560
|
+
scp.add_argument("--scp-dir", default=Path.cwd(), metavar="DIR", type=Path,
|
|
561
|
+
help="directory for SCP/SFTP (default: cwd) - subdirectories are disabled - "
|
|
562
|
+
"host-key (and optional authorized_keys and logfile) are protected")
|
|
563
|
+
|
|
564
|
+
banners = parser.add_argument_group("banners")
|
|
565
|
+
banners.add_argument("--version-banner", metavar="STRING",
|
|
566
|
+
help="sent as 'SSH-2.0-STRING' version banner - "
|
|
567
|
+
f"presets (case-insensitive): {', '.join(VERSION_PRESETS)}")
|
|
568
|
+
banners.add_argument("--pre-auth-banner", metavar="STRING",
|
|
569
|
+
help="banner shown to every client before login")
|
|
570
|
+
banners.add_argument("--post-auth-banner", metavar="STRING",
|
|
571
|
+
help="banner shown only to clients that authenticate successfully")
|
|
572
|
+
|
|
573
|
+
logs = parser.add_argument_group("logging")
|
|
574
|
+
logs.add_argument("-o", "--output", metavar="FILE", type=Path,
|
|
575
|
+
help="append the log to FILE (plain with timestamps)")
|
|
576
|
+
logs.add_argument("-t", "--timestamps", action="store_true",
|
|
577
|
+
help="prefix console lines with a timestamp")
|
|
578
|
+
logs.add_argument("--plain", action="store_true",
|
|
579
|
+
help="disable ANSI colors on the console")
|
|
580
|
+
|
|
581
|
+
args = parser.parse_args()
|
|
582
|
+
|
|
583
|
+
# Validate the log output path
|
|
584
|
+
if args.output and not args.output.parent.is_dir():
|
|
585
|
+
parser.error(f"Log directory does not exist: {args.output.parent}")
|
|
586
|
+
|
|
587
|
+
# Set up logging before anything logs
|
|
588
|
+
configure_logging(output=args.output, timestamps=args.timestamps, plain=args.plain)
|
|
589
|
+
|
|
590
|
+
# Handle version-banner presets
|
|
591
|
+
if args.version_banner:
|
|
592
|
+
args.version_banner = VERSION_PRESETS.get(
|
|
593
|
+
args.version_banner.lower(), args.version_banner)
|
|
594
|
+
|
|
595
|
+
# Validate user format
|
|
596
|
+
if args.user:
|
|
597
|
+
for entry in args.user:
|
|
598
|
+
if ":" not in entry:
|
|
599
|
+
parser.error(f"Invalid user format '{entry}', expected USER:PASS")
|
|
600
|
+
|
|
601
|
+
# Validate scp-dir
|
|
602
|
+
if args.scp_upload or args.scp_download:
|
|
603
|
+
scp_dir = args.scp_dir.resolve()
|
|
604
|
+
if not scp_dir.is_dir():
|
|
605
|
+
parser.error(f"SCP directory does not exist: {scp_dir}")
|
|
606
|
+
# Scan for symlinks that point outside the chroot
|
|
607
|
+
for entry in scp_dir.rglob("*"):
|
|
608
|
+
if entry.is_symlink():
|
|
609
|
+
parser.error(f"Symlink in SCP dir! We don't do that! ({entry})")
|
|
610
|
+
|
|
611
|
+
# Validate authorized-keys
|
|
612
|
+
if args.authorized_keys and not args.authorized_keys.is_file():
|
|
613
|
+
parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
|
|
614
|
+
|
|
615
|
+
try: asyncio.run(start_server(args))
|
|
616
|
+
except PermissionError:
|
|
617
|
+
parser.error(f"Permission denied - port {args.port} requires root")
|
|
618
|
+
except OSError as e:
|
|
619
|
+
parser.error(f"Could not start server: {e}")
|
|
620
|
+
except KeyboardInterrupt:
|
|
621
|
+
print()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
if __name__ == "__main__":
|
|
625
|
+
main()
|