Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
58ca20d
yara_updated
doomedraven Feb 17, 2026
bf73865
yara_updated
doomedraven Feb 17, 2026
ccf4bb8
Windows analyzer: harden 32/64-bit ctypes and struct compatibility
enzok May 5, 2026
bfa40db
Update classes and privileges
enzok May 7, 2026
243575d
Launch processes with Explorer.exe as parent
enzok May 7, 2026
25b503a
Enable disguise background launch by default unless explicitly disabled
enzok May 7, 2026
84992e9
Use background_processes option with bounds for disguise launches
enzok May 7, 2026
224fcf7
Document background_processes submission option in UI help
enzok May 7, 2026
117b081
Use KERNEL32.GetLastError for attribute list size probe logging
enzok May 7, 2026
340e1ee
process.py ruff fixes
kevoreilly May 7, 2026
db1e677
human.py ruff fixes
kevoreilly May 7, 2026
82af98a
Bump agent version from 0.20 to 0.21
kevoreilly May 8, 2026
ba6e11d
Remove legacy 32-bit architecture check
kevoreilly May 8, 2026
fd0102f
Merge pull request #3006 from enzok/feats-01
kevoreilly May 11, 2026
1b918e8
Merge branch 'master' into yara_update
kevoreilly May 11, 2026
a624c84
fix poetry.lock
kevoreilly May 11, 2026
08b05b0
Attempt 2 fix poetry.lock
kevoreilly May 11, 2026
9b46ac7
Fix poetry.lock third try
kevoreilly May 11, 2026
48666ec
Merge pull request #2918 from kevoreilly/yara_update
kevoreilly May 11, 2026
61f2754
Fix this damn poetry lock please
kevoreilly May 11, 2026
59d5a35
ci: Update requirements.txt
actions-user May 11, 2026
0ac4785
Attempt to fix pytest test failure
kevoreilly May 11, 2026
d7fc7fc
Fix poetry warning: "poetry.dev-dependencies" section is deprecated i…
kevoreilly May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 40 additions & 37 deletions agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import argparse
import base64
import cgi
import enum
import http.server
import ipaddress
Expand All @@ -23,9 +22,12 @@
import tempfile
import time
import traceback
from io import StringIO
from email.parser import BytesParser
from email.policy import default as email_policy
from io import BytesIO, StringIO
from threading import Lock
from typing import Iterable
from urllib.parse import parse_qs
from zipfile import ZipFile

try:
Expand All @@ -40,10 +42,10 @@
# The analysis process interacts with low-level Windows libraries that need a
# x86 Python to be running.
# (see https://github.com/kevoreilly/CAPEv2/issues/1680)
if sys.maxsize > 2**32 and sys.platform == "win32":
sys.exit("You should install python3 x86! not x64")
#if sys.maxsize > 2**32 and sys.platform == "win32":
# sys.exit("You should install python3 x86! not x64")

AGENT_VERSION = "0.20"
AGENT_VERSION = "0.21"
AGENT_FEATURES = [
"execpy",
"execute",
Expand Down Expand Up @@ -110,6 +112,37 @@ def _missing_(cls, value):
class MiniHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
server_version = "CAPE Agent"

def _parse_form_and_files(self):
content_length = int(self.headers.get("Content-Length", "0") or 0)
content_type = self.headers.get("Content-Type", "")
media_type = content_type.split(";", 1)[0].strip().lower()
body = self.rfile.read(content_length) if content_length > 0 else b""

form = {}
files = {}

if media_type == "multipart/form-data":
message = BytesParser(policy=email_policy).parsebytes(
b"Content-Type: " + content_type.encode("utf-8") + b"\r\n\r\n" + body
)
for part in message.iter_parts():
name = part.get_param("name", header="content-disposition")
if not name:
continue
filename = part.get_filename()
payload = part.get_payload(decode=True) or b""
if filename:
files[name] = BytesIO(payload)
else:
charset = part.get_content_charset("utf-8")
form[name] = payload.decode(charset, errors="replace")
elif media_type == "application/x-www-form-urlencoded":
# Match cgi.FieldStorage default behavior: ignore blank form values.
parsed = parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=False)
form = {k: v[-1] if v else "" for k, v in parsed.items()}

return form, files

def do_GET(self):
request.client_ip, request.client_port = self.client_address
request.form = {}
Expand All @@ -119,47 +152,17 @@ def do_GET(self):
self.httpd.handle(self)

def do_POST(self):
environ = {
"REQUEST_METHOD": "POST",
"CONTENT_TYPE": self.headers.get("Content-Type"),
}

form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ=environ)

request.client_ip, request.client_port = self.client_address
request.form = {}
request.files = {}
request.form, request.files = self._parse_form_and_files()
request.method = "POST"

if form.list:
for key in form.keys():
value = form[key]
if value.filename:
request.files[key] = value.file
else:
request.form[key] = value.value
self.httpd.handle(self)

def do_DELETE(self):
environ = {
"REQUEST_METHOD": "DELETE",
"CONTENT_TYPE": self.headers.get("Content-Type"),
}

form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ=environ)

request.client_ip, request.client_port = self.client_address
request.form = {}
request.files = {}
request.form, request.files = self._parse_form_and_files()
request.method = "DELETE"

if form.list:
for key in form.keys():
value = form[key]
if value.filename:
request.files[key] = value.file
else:
request.form[key] = value.value
self.httpd.handle(self)


Expand Down
11 changes: 0 additions & 11 deletions agent/test_python_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,6 @@
import pytest


def test_32_bit(monkeypatch):
with monkeypatch.context() as m:
# Unload "agent" module if previously imported.
sys.modules.pop("agent", None)
m.setattr(sys, "maxsize", 2**64)
m.setattr(sys, "platform", "win32")
with pytest.raises(SystemExit):
# Should raise an exception.
import agent # noqa: F401


def test_python_version(monkeypatch):
with monkeypatch.context() as m:
# Unload "agent" module if previously imported.
Expand Down
115 changes: 65 additions & 50 deletions analyzer/windows/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import timeit
import traceback
from contextlib import suppress
from ctypes import byref, c_buffer, c_int, create_string_buffer, sizeof, wintypes
from ctypes import byref, c_buffer, c_int, c_void_p, create_string_buffer, sizeof, wintypes
from pathlib import Path
from shutil import copy
from threading import Lock, Thread
Expand Down Expand Up @@ -50,6 +50,35 @@
SHELL32,
USER32,
)

KERNEL32.OpenProcess.restype = c_void_p
KERNEL32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
KERNEL32.CreateMutexA.restype = c_void_p
KERNEL32.OpenEventA.restype = c_void_p
ADVAPI32.OpenSCManagerA.restype = c_void_p
ADVAPI32.OpenSCManagerA.argtypes = [wintypes.LPCSTR, wintypes.LPCSTR, wintypes.DWORD]
ADVAPI32.OpenServiceW.restype = c_void_p
ADVAPI32.OpenServiceW.argtypes = [c_void_p, wintypes.LPCWSTR, wintypes.DWORD]
ADVAPI32.QueryServiceStatusEx.argtypes = [c_void_p, c_int, c_void_p, wintypes.DWORD, c_void_p]
ADVAPI32.QueryServiceStatusEx.restype = wintypes.BOOL
ADVAPI32.CloseServiceHandle.argtypes = [c_void_p]
ADVAPI32.CloseServiceHandle.restype = wintypes.BOOL
USER32.GetShellWindow.restype = c_void_p
USER32.GetWindowThreadProcessId.argtypes = [c_void_p, c_void_p]
USER32.GetWindowThreadProcessId.restype = wintypes.DWORD
KERNEL32.OpenThread.restype = c_void_p
KERNEL32.CreateToolhelp32Snapshot.restype = c_void_p
KERNEL32.CreateFileW.restype = c_void_p
KERNEL32.CreateEventW.restype = c_void_p
KERNEL32.OpenEventW.restype = c_void_p
KERNEL32.GetCurrentProcess.restype = c_void_p
KERNEL32.CloseHandle.argtypes = [c_void_p]
KERNEL32.CloseHandle.restype = wintypes.BOOL
PSAPI.EnumProcesses.argtypes = [c_void_p, wintypes.DWORD, c_void_p]
PSAPI.EnumProcesses.restype = wintypes.BOOL
PSAPI.GetProcessImageFileNameA.argtypes = [c_void_p, c_void_p, wintypes.DWORD]
PSAPI.GetProcessImageFileNameA.restype = wintypes.DWORD

from lib.common.exceptions import CuckooError, CuckooPackageError
from lib.common.hashing import hash_file
from lib.common.results import upload_to_host
Expand Down Expand Up @@ -119,7 +148,7 @@ def pids_from_image_names(suffixlist):
log.debug("psapi.EnumProcesses failed")
return retpids

suffixlist = tuple([x.lower() for x in suffixlist])
suffixlist = tuple(x.lower() for x in suffixlist)
num_processes = int(num_bytes.value / sizeof(wintypes.DWORD))
pids = lpid_process_ptr[:num_processes]

Expand Down Expand Up @@ -474,51 +503,36 @@ def run(self):
self.target = self.package.move_curdir(self.target)
log.debug("New location of moved file: %s", self.target)

# Set the DLL to that specified by package
if self.package.options.get("dll") is not None:
MONITOR_DLL = self.package.options["dll"]
log.info("Analyzer: DLL set to %s from package %s", MONITOR_DLL, self.package_name)
else:
log.info("Analyzer: Package %s does not specify a DLL option", self.package_name)

# Set the DLL_64 to that specified by package
if self.package.options.get("dll_64") is not None:
MONITOR_DLL_64 = self.package.options["dll_64"]
log.info("Analyzer: DLL_64 set to %s from package %s", MONITOR_DLL_64, self.package_name)
else:
log.info("Analyzer: Package %s does not specify a DLL_64 option", self.package_name)

# Set the loader to that specified by package
if self.package.options.get("loader") is not None:
LOADER32 = self.package.options["loader"]
log.info("Analyzer: Loader set to %s from package %s", LOADER32, self.package_name)
else:
log.info("Analyzer: Package %s does not specify a loader option", self.package_name)

# Set the loader_64 to that specified by package
if self.package.options.get("loader_64") is not None:
LOADER64 = self.package.options["loader_64"]
log.info("Analyzer: Loader_64 set to %s from package %s", LOADER64, self.package_name)
else:
log.info("Analyzer: Package %s does not specify a loader_64 option", self.package_name)
# Set the DLL/loader to that specified by package
for key in ("dll", "dll_64", "loader", "loader_64"):
if (value := self.package.options.get(key)) is not None:
log.info("Analyzer: %s set to %s from package %s", key, value, self.package_name)
if key == "dll":
MONITOR_DLL = value
elif key == "dll_64":
MONITOR_DLL_64 = value
elif key == "loader":
LOADER32 = value
elif key == "loader_64":
LOADER64 = value
else:
log.info("Analyzer: Package %s does not specify a %s option", self.package_name, key)

# randomize monitor DLL and loader executable names
if MONITOR_DLL is not None:
copy(os.path.join("dll", MONITOR_DLL), CAPEMON32_NAME)
else:
copy("dll\\capemon.dll", CAPEMON32_NAME)
if MONITOR_DLL_64 is not None:
copy(os.path.join("dll", MONITOR_DLL_64), CAPEMON64_NAME)
else:
copy("dll\\capemon_x64.dll", CAPEMON64_NAME)
if LOADER32 is not None:
copy(os.path.join("bin", LOADER32), LOADER32_NAME)
else:
copy("bin\\loader.exe", LOADER32_NAME)
if LOADER64 is not None:
copy(os.path.join("bin", LOADER64), LOADER64_NAME)
else:
copy("bin\\loader_x64.exe", LOADER64_NAME)
for source_name, dest_name, default_name, source_dir in [
(MONITOR_DLL, CAPEMON32_NAME, "capemon.dll", "dll"),
(MONITOR_DLL_64, CAPEMON64_NAME, "capemon_x64.dll", "dll"),
(LOADER32, LOADER32_NAME, "loader.exe", "bin"),
(LOADER64, LOADER64_NAME, "loader_x64.exe", "bin"),
]:
if source_name is not None:
if os.path.basename(source_name) != source_name:
log.warning("Path traversal attempt detected in source_name: '%s'", source_name)
return
source_path = os.path.join(source_dir, source_name)
else:
source_path = os.path.join(source_dir, default_name)
copy(source_path, dest_name)

si = subprocess.STARTUPINFO()
# STARTF_USESHOWWINDOW
Expand Down Expand Up @@ -899,6 +913,8 @@ class Files:
"vmtoolsd.exe",
"vmsrvc.exe",
"python.exe",
"pythonw.exe",
"python3.exe",
"perl.exe",
]

Expand Down Expand Up @@ -978,7 +994,7 @@ def dump_file(self, filepath, metadata="", pids="", ppids="", category="files"):
log.exception(e)

def delete_file(self, filepath, pid=None):
"""A file is about to removed and thus should be dumped right away."""
"""A file is about to be removed and thus should be dumped right away."""
self.add_pid(filepath, pid)
self.dump_file(filepath)

Expand Down Expand Up @@ -1026,7 +1042,7 @@ def add_pids(self, pids):
self.add_pid(pids)

def has_pid(self, pid, notrack=True):
"""Return whether or not this process identifier being tracked."""
"""Return whether this process identifier being tracked."""
pid = int(pid)
if pid in self.pids:
return True
Expand Down Expand Up @@ -1385,8 +1401,7 @@ def _inject_process(self, process_id, thread_id, mode):
# Add the new process ID to the list of monitored processes.
self.analyzer.process_list.add_pid(process_id)

# We're done operating on the processes list,
# release the lock
# We're done operating on the processes list, release the lock
self.analyzer.process_lock.release()

proc.inject(interest=filepath, nosleepskip=True)
Expand All @@ -1397,7 +1412,6 @@ def _handle_process(self, data):
"""Request for injection into a process."""
# Parse the process identifier.
# PROCESS:1:1824,2856
process_id = thread_id = None
# We parse the process ID.
pid_s, tid_s = data.split(b",", 1)
process_id = int(pid_s)
Expand Down Expand Up @@ -1541,6 +1555,7 @@ def _handle_file_move(self, data):
if b"::" not in data:
log.warning("Received FILE_MOVE command from monitor with an incorrect argument")
return

pid, paths = data.split(b",", 1)
old_filepath, new_filepath = paths.split(b"::", 1)
self.analyzer.files.move_file(old_filepath.decode(), new_filepath.decode(), pid.decode())
Expand Down
Loading
Loading