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