koi-handler 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.
- koi_handler-0.1.0/PKG-INFO +5 -0
- koi_handler-0.1.0/pyproject.toml +15 -0
- koi_handler-0.1.0/setup.cfg +4 -0
- koi_handler-0.1.0/src/koi/main.py +498 -0
- koi_handler-0.1.0/src/koi/shell_handler/__init__.py +26 -0
- koi_handler-0.1.0/src/koi/shell_handler/core.py +458 -0
- koi_handler-0.1.0/src/koi/shell_handler/exceptions.py +35 -0
- koi_handler-0.1.0/src/koi/shell_handler/result.py +53 -0
- koi_handler-0.1.0/src/koi/shell_handler/session.py +218 -0
- koi_handler-0.1.0/src/koi/ui.py +217 -0
- koi_handler-0.1.0/src/koi_handler.egg-info/PKG-INFO +5 -0
- koi_handler-0.1.0/src/koi_handler.egg-info/SOURCES.txt +13 -0
- koi_handler-0.1.0/src/koi_handler.egg-info/dependency_links.txt +1 -0
- koi_handler-0.1.0/src/koi_handler.egg-info/entry_points.txt +2 -0
- koi_handler-0.1.0/src/koi_handler.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "koi-handler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Reverse shell listener"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
koi = "koi.main:main"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools.packages.find]
|
|
15
|
+
where = ["src"]
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import select
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import termios
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import tty
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Dict, Optional
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
|
+
|
|
22
|
+
from koi.ui import (
|
|
23
|
+
gradient_text, colored_text, display_art, print_report_box, print_status_line, breaker, notify,
|
|
24
|
+
PUMPKIN, WHITE, SILVER, CORAL, UMBER,
|
|
25
|
+
_b, _d, _r, _g, _c, _p, _y, _o, _gr
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
LOCALUSER = os.getenv("USER") or os.getenv("USERNAME") or "user"
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Session:
|
|
32
|
+
id: int
|
|
33
|
+
conn: socket.socket
|
|
34
|
+
addr: tuple
|
|
35
|
+
connected_at: datetime = field(default_factory=datetime.now)
|
|
36
|
+
alive: bool = True
|
|
37
|
+
upgraded: bool = False
|
|
38
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
39
|
+
|
|
40
|
+
def _uptime(self) -> str:
|
|
41
|
+
secs = int((datetime.now() - self.connected_at).total_seconds())
|
|
42
|
+
m, s = divmod(secs, 60)
|
|
43
|
+
h, m = divmod(m, 60)
|
|
44
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
45
|
+
|
|
46
|
+
def status_dot(self) -> str:
|
|
47
|
+
if not self.alive:
|
|
48
|
+
return _gr("○")
|
|
49
|
+
return _p("◆") if self.upgraded else _r("●")
|
|
50
|
+
|
|
51
|
+
def send(self, data: bytes) -> bool:
|
|
52
|
+
try:
|
|
53
|
+
with self._lock:
|
|
54
|
+
self.conn.sendall(data)
|
|
55
|
+
return True
|
|
56
|
+
except OSError:
|
|
57
|
+
self.alive = False
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
self.alive = False
|
|
62
|
+
for fn in (lambda: self.conn.shutdown(socket.SHUT_RDWR), self.conn.close):
|
|
63
|
+
try:
|
|
64
|
+
fn()
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
class RawTerminal:
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self._old = None
|
|
71
|
+
self._fd = sys.stdin.fileno()
|
|
72
|
+
|
|
73
|
+
def __enter__(self):
|
|
74
|
+
self._old = termios.tcgetattr(self._fd)
|
|
75
|
+
tty.setraw(self._fd)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(self, *_):
|
|
79
|
+
if self._old:
|
|
80
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old)
|
|
81
|
+
|
|
82
|
+
def print_help():
|
|
83
|
+
data = {
|
|
84
|
+
f"{_g('ls')}": "List all active sessions",
|
|
85
|
+
f"{_g('go')} {_p('<id>')}": "Enter a session interactively",
|
|
86
|
+
f"{_g('upgrade')} {_p('<id>')}": "Upgrade session to a full PTY",
|
|
87
|
+
f"{_g('kill')} {_p('<id>')}": "Terminate and remove a session",
|
|
88
|
+
f"{_g('help')}": "Show this message",
|
|
89
|
+
f"{_g('exit')}": "Shut down the listener",
|
|
90
|
+
}
|
|
91
|
+
print_report_box("Commands", data)
|
|
92
|
+
data = {
|
|
93
|
+
f"{_y('Ctrl+Z')}": "Background → return to listener shell",
|
|
94
|
+
f"{_y('Ctrl+C')}": "Send SIGINT to remote (keeps session alive)",
|
|
95
|
+
}
|
|
96
|
+
print_report_box("Session Signals", data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Listener:
|
|
100
|
+
CTRL_Z = b"\x1a"
|
|
101
|
+
CTRL_C = b"\x03"
|
|
102
|
+
|
|
103
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 4444):
|
|
104
|
+
self.host = host
|
|
105
|
+
self.port = port
|
|
106
|
+
self._sessions: Dict[int, Session] = {}
|
|
107
|
+
self._next_id = 1
|
|
108
|
+
self._id_lock = threading.Lock()
|
|
109
|
+
self._running = False
|
|
110
|
+
self._server_sock: Optional[socket.socket] = None
|
|
111
|
+
self._notify_r, self._notify_w = os.pipe()
|
|
112
|
+
|
|
113
|
+
def _add(self, conn, addr) -> Session:
|
|
114
|
+
with self._id_lock:
|
|
115
|
+
sid = self._next_id
|
|
116
|
+
self._next_id += 1
|
|
117
|
+
sess = Session(id=sid, conn=conn, addr=addr)
|
|
118
|
+
self._sessions[sid] = sess
|
|
119
|
+
return sess
|
|
120
|
+
|
|
121
|
+
def _get(self, sid: int) -> Optional[Session]:
|
|
122
|
+
return self._sessions.get(sid)
|
|
123
|
+
|
|
124
|
+
def _remove(self, sid: int) -> None:
|
|
125
|
+
sess = self._sessions.pop(sid, None)
|
|
126
|
+
if sess:
|
|
127
|
+
sess.close()
|
|
128
|
+
|
|
129
|
+
def _prune(self) -> None:
|
|
130
|
+
for sid in [k for k, s in self._sessions.items() if not s.alive]:
|
|
131
|
+
self._sessions.pop(sid)
|
|
132
|
+
|
|
133
|
+
def _accept_loop(self):
|
|
134
|
+
while self._running:
|
|
135
|
+
try:
|
|
136
|
+
self._server_sock.settimeout(1.0)
|
|
137
|
+
conn, addr = self._server_sock.accept()
|
|
138
|
+
except socket.timeout:
|
|
139
|
+
continue
|
|
140
|
+
except OSError:
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
sess = self._add(conn, addr)
|
|
144
|
+
os.write(self._notify_w, b"1\n")
|
|
145
|
+
sys.stdout.write(f"\r\033[K")
|
|
146
|
+
notify('new', f"{_b(_c(f'#{sess.id}'))} {_c(addr[0])}{_gr(f':{addr[1]}')}")
|
|
147
|
+
sys.stdout.write(self._prompt())
|
|
148
|
+
sys.stdout.flush()
|
|
149
|
+
|
|
150
|
+
def start(self):
|
|
151
|
+
self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
152
|
+
self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
153
|
+
self._server_sock.bind((self.host, self.port))
|
|
154
|
+
self._server_sock.listen(16)
|
|
155
|
+
self._running = True
|
|
156
|
+
|
|
157
|
+
threading.Thread(target=self._accept_loop, daemon=True, name="accept").start()
|
|
158
|
+
|
|
159
|
+
display_art()
|
|
160
|
+
print()
|
|
161
|
+
notify('info', f"Listening on {_b(self.host)}:{_b(self.port)}")
|
|
162
|
+
print()
|
|
163
|
+
|
|
164
|
+
self._main_loop()
|
|
165
|
+
|
|
166
|
+
def stop(self):
|
|
167
|
+
self._running = False
|
|
168
|
+
if self._server_sock:
|
|
169
|
+
try:
|
|
170
|
+
self._server_sock.close()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
for s in list(self._sessions.values()):
|
|
174
|
+
if s.upgraded:
|
|
175
|
+
s.send(b"exit\n")
|
|
176
|
+
time.sleep(0.5)
|
|
177
|
+
s.close()
|
|
178
|
+
print("\n" + gradient_text(" Shutting down. Goodbye.\n", PUMPKIN, UMBER))
|
|
179
|
+
|
|
180
|
+
def _prompt(self) -> str:
|
|
181
|
+
alive = sum(1 for s in self._sessions.values() if s.alive)
|
|
182
|
+
noun = "session" if alive == 1 else "sessions"
|
|
183
|
+
count = colored_text(str(alive), PUMPKIN if alive else SILVER)
|
|
184
|
+
return (
|
|
185
|
+
f"{LOCALUSER}"
|
|
186
|
+
+ colored_text("@", PUMPKIN)
|
|
187
|
+
+ colored_text("koi", WHITE)
|
|
188
|
+
+ _gr("(")
|
|
189
|
+
+ count
|
|
190
|
+
+ _gr(f" {noun})")
|
|
191
|
+
+ gradient_text(" ❯ ", PUMPKIN, CORAL)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _main_loop(self):
|
|
195
|
+
while self._running:
|
|
196
|
+
try:
|
|
197
|
+
r, _, _ = select.select([self._notify_r], [], [], 0)
|
|
198
|
+
if r:
|
|
199
|
+
os.read(self._notify_r, 4096)
|
|
200
|
+
raw = input(self._prompt()).strip()
|
|
201
|
+
except EOFError:
|
|
202
|
+
break
|
|
203
|
+
except KeyboardInterrupt:
|
|
204
|
+
print()
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
if not raw:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
parts = raw.split()
|
|
211
|
+
cmd = parts[0].lower()
|
|
212
|
+
|
|
213
|
+
if cmd in ("exit", "quit"):
|
|
214
|
+
self.stop()
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
elif cmd in ("help", "h", "?"):
|
|
218
|
+
print_help()
|
|
219
|
+
|
|
220
|
+
elif cmd in ("ls", "l", "list"):
|
|
221
|
+
self._cmd_ls()
|
|
222
|
+
|
|
223
|
+
elif cmd in ("go", "g", "interact"):
|
|
224
|
+
if len(parts) < 2:
|
|
225
|
+
notify('error', f"Usage: go {_p('<id>')}")
|
|
226
|
+
else:
|
|
227
|
+
try:
|
|
228
|
+
self._cmd_go(int(parts[1]))
|
|
229
|
+
except ValueError:
|
|
230
|
+
notify('error', "Session id must be an integer.")
|
|
231
|
+
|
|
232
|
+
elif cmd in ("upgrade", "u"):
|
|
233
|
+
if len(parts) < 2:
|
|
234
|
+
notify('error', f"Usage: upgrade {_p('<id>')}")
|
|
235
|
+
else:
|
|
236
|
+
try:
|
|
237
|
+
self._cmd_upgrade(int(parts[1]))
|
|
238
|
+
except ValueError:
|
|
239
|
+
notify('error', "Session id must be an integer.")
|
|
240
|
+
|
|
241
|
+
elif cmd == "kill":
|
|
242
|
+
if len(parts) < 2:
|
|
243
|
+
notify('error', f"Usage: kill {_p('<id>')}")
|
|
244
|
+
else:
|
|
245
|
+
try:
|
|
246
|
+
self._cmd_kill(int(parts[1]))
|
|
247
|
+
except ValueError:
|
|
248
|
+
notify('error', "Session id must be an integer.")
|
|
249
|
+
|
|
250
|
+
else:
|
|
251
|
+
notify('error', f"Unknown command: {_p(cmd)} — type {_b('help')}")
|
|
252
|
+
|
|
253
|
+
def _cmd_ls(self):
|
|
254
|
+
self._prune()
|
|
255
|
+
if not self._sessions:
|
|
256
|
+
print()
|
|
257
|
+
notify('status', _gr('No active sessions.'))
|
|
258
|
+
print()
|
|
259
|
+
return
|
|
260
|
+
data = {}
|
|
261
|
+
for s in sorted(self._sessions.values(), key=lambda x: x.id):
|
|
262
|
+
key = f"#{s.id} {s.status_dot()} {_c(s.addr[0])}{_gr(f':{s.addr[1]}')}"
|
|
263
|
+
data[key] = s._uptime()
|
|
264
|
+
print_report_box("Sessions", data)
|
|
265
|
+
|
|
266
|
+
def _cmd_go(self, sid: int):
|
|
267
|
+
self._prune()
|
|
268
|
+
sess = self._get(sid)
|
|
269
|
+
if sess is None:
|
|
270
|
+
notify('error', f"Session {_p(f'#{sid}')} not found.")
|
|
271
|
+
return
|
|
272
|
+
if not sess.alive:
|
|
273
|
+
notify('error', f"Session {_p(f'#{sid}')} is no longer alive.")
|
|
274
|
+
self._remove(sid)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
ip, port = sess.addr
|
|
278
|
+
print()
|
|
279
|
+
notify('info', f"Entering session {_b(_r(f'#{sid}'))} {_c(ip)}{_gr(f':{port}')}")
|
|
280
|
+
notify('status', _gr('Ctrl+Z to background · Ctrl+C sends SIGINT to remote'))
|
|
281
|
+
print()
|
|
282
|
+
|
|
283
|
+
if sess.upgraded:
|
|
284
|
+
self._sync_winsize(sess)
|
|
285
|
+
self._drain(sess, 0.3)
|
|
286
|
+
sess.send(b"\n")
|
|
287
|
+
time.sleep(0.15)
|
|
288
|
+
signal.signal(signal.SIGWINCH, lambda *_: self._winch(sess))
|
|
289
|
+
|
|
290
|
+
breaker()
|
|
291
|
+
|
|
292
|
+
reason = self._interact(sess)
|
|
293
|
+
|
|
294
|
+
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
|
|
295
|
+
print()
|
|
296
|
+
breaker()
|
|
297
|
+
|
|
298
|
+
if reason == "backgrounded":
|
|
299
|
+
print()
|
|
300
|
+
notify('warning', f"Session {_b(_c(f'#{sid}'))} backgrounded. Back at listener shell.")
|
|
301
|
+
print()
|
|
302
|
+
elif reason == "disconnected":
|
|
303
|
+
print()
|
|
304
|
+
notify('error', f"Session {_b(_c(f'#{sid}'))} disconnected.")
|
|
305
|
+
print()
|
|
306
|
+
self._remove(sid)
|
|
307
|
+
|
|
308
|
+
def _cmd_kill(self, sid: int):
|
|
309
|
+
sess = self._get(sid)
|
|
310
|
+
if sess is None:
|
|
311
|
+
notify('error', f"Session {_p(f'#{sid}')} not found.")
|
|
312
|
+
return
|
|
313
|
+
if sess.upgraded:
|
|
314
|
+
sess.send(b"exit\n")
|
|
315
|
+
time.sleep(0.5)
|
|
316
|
+
self._remove(sid)
|
|
317
|
+
notify('success', f"Session {_p(f'#{sid}')} terminated.")
|
|
318
|
+
|
|
319
|
+
def _drain(self, sess: Session, duration: float = 0.5) -> None:
|
|
320
|
+
"""Read and discard incoming data for `duration` seconds."""
|
|
321
|
+
deadline = time.monotonic() + duration
|
|
322
|
+
while True:
|
|
323
|
+
remaining = deadline - time.monotonic()
|
|
324
|
+
if remaining <= 0:
|
|
325
|
+
break
|
|
326
|
+
try:
|
|
327
|
+
r, _, _ = select.select([sess.conn], [], [], min(remaining, 0.05))
|
|
328
|
+
if r:
|
|
329
|
+
sess.conn.recv(4096)
|
|
330
|
+
except OSError:
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
def _sync_winsize(self, sess: Session) -> None:
|
|
334
|
+
"""Send stty rows/cols — caller must drain the echo afterwards."""
|
|
335
|
+
try:
|
|
336
|
+
cols, rows = shutil.get_terminal_size()
|
|
337
|
+
except Exception:
|
|
338
|
+
return
|
|
339
|
+
sess.send(f"stty rows {rows} cols {cols} 2>/dev/null\n".encode())
|
|
340
|
+
|
|
341
|
+
def _winch(self, sess: Session) -> None:
|
|
342
|
+
"""SIGWINCH handler: sync size and silently drain echo."""
|
|
343
|
+
self._sync_winsize(sess)
|
|
344
|
+
self._drain(sess, 0.15)
|
|
345
|
+
|
|
346
|
+
def _cmd_upgrade(self, sid: int) -> None:
|
|
347
|
+
self._prune()
|
|
348
|
+
sess = self._get(sid)
|
|
349
|
+
if sess is None:
|
|
350
|
+
notify('error', f"Session {_p(f'#{sid}')} not found.")
|
|
351
|
+
return
|
|
352
|
+
if not sess.alive:
|
|
353
|
+
notify('error', f"Session {_p(f'#{sid}')} is no longer alive.")
|
|
354
|
+
self._remove(sid)
|
|
355
|
+
return
|
|
356
|
+
if sess.upgraded:
|
|
357
|
+
notify('warning', f"Session {_p(f'#{sid}')} is already upgraded.")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
frames = ["⣾","⣽","⣻","⢿","⡿","⣟","⣯","⣷"]
|
|
361
|
+
stop_spin = threading.Event()
|
|
362
|
+
|
|
363
|
+
def _spin():
|
|
364
|
+
i = 0
|
|
365
|
+
while not stop_spin.is_set():
|
|
366
|
+
frame = colored_text(frames[i % len(frames)], PUMPKIN)
|
|
367
|
+
sys.stdout.write(f"\r {frame} {colored_text('Upgrading shell…', SILVER)}")
|
|
368
|
+
sys.stdout.flush()
|
|
369
|
+
time.sleep(0.08)
|
|
370
|
+
i += 1
|
|
371
|
+
sys.stdout.write("\r\033[K")
|
|
372
|
+
sys.stdout.flush()
|
|
373
|
+
|
|
374
|
+
spin_thread = threading.Thread(target=_spin, daemon=True)
|
|
375
|
+
spin_thread.start()
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
spawn = (
|
|
379
|
+
"python3 -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
|
|
380
|
+
"python -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
|
|
381
|
+
"script -qc /bin/bash /dev/null\n"
|
|
382
|
+
)
|
|
383
|
+
sess.send(spawn.encode())
|
|
384
|
+
self._drain(sess, 0.8)
|
|
385
|
+
|
|
386
|
+
if not sess.alive:
|
|
387
|
+
stop_spin.set()
|
|
388
|
+
spin_thread.join()
|
|
389
|
+
notify('error', f"Session {_p(f'#{sid}')} died during upgrade.")
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
sess.send(b"export TERM=xterm-256color HISTFILE=/dev/null\n")
|
|
393
|
+
self._drain(sess, 0.3)
|
|
394
|
+
|
|
395
|
+
self._sync_winsize(sess)
|
|
396
|
+
self._drain(sess, 0.3)
|
|
397
|
+
|
|
398
|
+
sess.upgraded = True
|
|
399
|
+
|
|
400
|
+
finally:
|
|
401
|
+
stop_spin.set()
|
|
402
|
+
spin_thread.join()
|
|
403
|
+
|
|
404
|
+
notify('success', f"Shell {_p(f'#{sid}')} upgraded successfully.")
|
|
405
|
+
|
|
406
|
+
def _interact(self, sess: Session) -> str:
|
|
407
|
+
"""
|
|
408
|
+
Full-duplex pass-through between local stdin/stdout and the remote socket.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
"backgrounded" – Ctrl+Z pressed
|
|
412
|
+
"disconnected" – remote closed the connection
|
|
413
|
+
"""
|
|
414
|
+
stop_event = threading.Event()
|
|
415
|
+
result = ["backgrounded"]
|
|
416
|
+
|
|
417
|
+
def _recv():
|
|
418
|
+
while not stop_event.is_set() and sess.alive:
|
|
419
|
+
try:
|
|
420
|
+
r, _, _ = select.select([sess.conn], [], [], 0.1)
|
|
421
|
+
if not r:
|
|
422
|
+
continue
|
|
423
|
+
data = sess.conn.recv(4096)
|
|
424
|
+
if not data:
|
|
425
|
+
sess.alive = False
|
|
426
|
+
result[0] = "disconnected"
|
|
427
|
+
stop_event.set()
|
|
428
|
+
return
|
|
429
|
+
sys.stdout.buffer.write(data)
|
|
430
|
+
sys.stdout.buffer.flush()
|
|
431
|
+
except OSError:
|
|
432
|
+
sess.alive = False
|
|
433
|
+
result[0] = "disconnected"
|
|
434
|
+
stop_event.set()
|
|
435
|
+
|
|
436
|
+
recv_thread = threading.Thread(target=_recv, daemon=True)
|
|
437
|
+
|
|
438
|
+
with RawTerminal():
|
|
439
|
+
recv_thread.start()
|
|
440
|
+
try:
|
|
441
|
+
while not stop_event.is_set():
|
|
442
|
+
r, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
443
|
+
if not r:
|
|
444
|
+
continue
|
|
445
|
+
key = os.read(sys.stdin.fileno(), 1024)
|
|
446
|
+
|
|
447
|
+
if self.CTRL_Z in key:
|
|
448
|
+
before = key[: key.index(self.CTRL_Z)]
|
|
449
|
+
if before:
|
|
450
|
+
sess.send(before)
|
|
451
|
+
result[0] = "backgrounded"
|
|
452
|
+
stop_event.set()
|
|
453
|
+
break
|
|
454
|
+
|
|
455
|
+
if not sess.send(key):
|
|
456
|
+
result[0] = "disconnected"
|
|
457
|
+
stop_event.set()
|
|
458
|
+
break
|
|
459
|
+
except OSError:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
stop_event.set()
|
|
463
|
+
recv_thread.join(timeout=1.0)
|
|
464
|
+
return result[0]
|
|
465
|
+
|
|
466
|
+
def main():
|
|
467
|
+
parser = argparse.ArgumentParser(
|
|
468
|
+
description="ShellHandler – multi-session reverse shell listener",
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--host", default="0.0.0.0",
|
|
472
|
+
help="Bind address (default: 0.0.0.0)"
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"--port", "-p", type=int, default=4444,
|
|
476
|
+
help="Listen port (default: 4444)"
|
|
477
|
+
)
|
|
478
|
+
args = parser.parse_args()
|
|
479
|
+
|
|
480
|
+
listener = Listener(host=args.host, port=args.port)
|
|
481
|
+
|
|
482
|
+
signal.signal(
|
|
483
|
+
signal.SIGINT,
|
|
484
|
+
lambda *_: (print(), notify('warning', f"Use {_b('exit')} to quit cleanly."))
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
listener.start()
|
|
489
|
+
except PermissionError:
|
|
490
|
+
notify('error', f"Permission denied on port {args.port}.")
|
|
491
|
+
sys.exit(1)
|
|
492
|
+
except OSError as e:
|
|
493
|
+
notify('error', f"Cannot start listener: {e}")
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
if __name__ == "__main__":
|
|
498
|
+
main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .core import ShellHandler
|
|
2
|
+
from .result import CommandResult, StreamLine
|
|
3
|
+
from .session import PersistentSession
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ShellHandlerError,
|
|
6
|
+
CommandTimeout,
|
|
7
|
+
CommandFailed,
|
|
8
|
+
MaxRetriesExceeded,
|
|
9
|
+
SessionClosed,
|
|
10
|
+
SessionTimeout,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ShellHandler",
|
|
15
|
+
"CommandResult",
|
|
16
|
+
"StreamLine",
|
|
17
|
+
"PersistentSession",
|
|
18
|
+
"ShellHandlerError",
|
|
19
|
+
"CommandTimeout",
|
|
20
|
+
"CommandFailed",
|
|
21
|
+
"MaxRetriesExceeded",
|
|
22
|
+
"SessionClosed",
|
|
23
|
+
"SessionTimeout",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "1.0.0"
|