In Mayhem we have been provided with a PCAP file. Our task is to analyze it, find the secrets and decode the traffic.
PCAP Analysis
At first glance, we have HTTP traffic. This originates from a Python web server. The files Install.ps1 and notepad.exe are being transmitted via port 1337.
After the notepad.exe is transferred we have further HTTP traffic. This time only on port 80. Post requests are sent at regular intervals, the data content of these appears to be encrypted.
This seems to be caused from notepad.exe. We extract the HTTP objects in order to analyze them further.
Research
Decompiling the executable using ghidra did not bring any new findings. But we can also submit them to Virustotal for analysis.
As expected, this is flagged as malicious. It is also associated with Havoc, a C2 framework. This could be a beacon. A Havoc C2 beacon is a lightweight agent that communicates with a Havoc C2 server, allowing remote control of a compromised system. It typically operates covertly, sending periodic check-ins and receiving instructions.
Havoc:
How traffic from a Havoc beacon can be analyzed is described by Immersive Labs in a blog post.
From the blog post we can draw the necessary information on how to identify and decode the traffic as such.
The traffic is encrypted as assumed. Havoc uses AES in CTR mode for this purpose.
The key material (AES key + IV) is also transmitted. Since we have HTTP traffic in front of us, we probably have access to it.
In addition, a magic byte value is transmitted to identify the demon traffic
Upon checking the Havoc C2 GitHub repository, we identified the definition of the 0xDEADBEEF magic value, found in the Defines.h file.
We search for the hex value 0xDEADBEEF and actually find it.
In addition to these, we also find the agent id, the AES key and the IV as described in the blog post.
However, we must remove the header information beforehand, or only take the data into account. (Marked in white in the image above). And we see that we can decipher the traffic:
The following packets after the initial one have a header without the AES key and IV. This header is only 40 bytes long (to be identified by the COMMAND_NOJOB). By removing this header first, the subsequent data can be decrypted with the same key and IV.
Fortunately, Immersive Labs also provides a script that allows you to analyze the Havoc PCAP traffic. This script is able to extract the AES key and IV, identify the commands and store the deciphered requests and responses.
We run the script without any special flags and see that it was able to extract the AES key and the IV and some commands. We do not see what exactly happens.
We can identify the following commands.
COMMAND_NOJOB
COMMAND_MEM_FILE
COMMAND_PROC
We look at the source and see that the response and request bodies are stored decrypted as .bin files at the storage path if the --save flag is used. Unfortunately it is a bit of a mess and not chronologically ordered. Nevertheless, sufficient to answer all questions.
Modifying havoc-pcap-parser.py And Decryption Of The Traffic
Tested with the following packages and tshark version:
pyshark 0.6
lxml 5.3.2
tshark 4.2.2
There may be an issue where different versions of pyshark / tshark parse the pcap differently, resulting in a different hex encoding in packet.http.file_data / empty file_data field which will not allow you to decrypt the data due to an exception thrown by tsharkbody_to_bytes.
We customize the script to decrypt even if the --save flag is not set and if something can be decrypted it is also printed to the console:
# If we have keys lets decode the payload
aes_keys = sessions.get(request_header['agent_id'], None)
if not aes_keys:
print(f"[!] No AES Keys for Agent with ID {request_header['agent_id']}")
return
# If save_path is set, make sure the directory exists
if save_path and not os.path.exists(save_path):
print(f"[!] Save path {save_path} does not exist, creating")
os.makedirs(save_path)
# Decrypt the Request Body
if request_payload:
print(" [+] Decrypting Request Body")
decrypted_request = aes_decrypt_ctr(aes_keys['aes_key'], aes_keys['aes_iv'], request_payload)
# Always print
decoded = decrypted_request.decode('utf-8', errors='ignore').strip()
print(f"\n\033[92m[Decrypted Request]\033[0m\n{decoded}\n\n")
# Save only if save_path is set
if save_path:
save_file = f'{save_path}/{unique_id}-request-{request_header["agent_id"]}.bin'
with open(save_file, 'wb') as output_file:
output_file.write(decrypted_request)
# Decrypt the Response Body
if response_payload:
print(" [+] Decrypting Response Body")
decrypted_response = aes_decrypt_ctr(aes_keys['aes_key'], aes_keys['aes_iv'], response_payload)
# Always print
decoded = decrypted_response.decode('utf-8', errors='ignore').strip()
print(f"\n\033[94m[Decrypted Response - {command}]\033[0m\n{decoded}\n\n")
# Save only if save_path is set
if save_path:
save_file = f'{save_path}/{unique_id}-response-{request_header["agent_id"]}.bin'
with open(save_file, 'wb') as output_file:
output_file.write(decrypted_response)
Here is the customized version of the script:
havoc-pcap-modified.py
# Copyright (C) 2024 Kev Breen, Immersive Labs
# https://github.com/Immersive-Labs-Sec/HavocC2-Forensics
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
import argparse
import struct
import binascii
from binascii import unhexlify
from uuid import uuid4
try:
import pyshark
except ImportError:
print("[-] Pyshark not installed, please install with 'pip install pyshark'")
exit(0)
try:
from Crypto.Cipher import AES
from Crypto.Util import Counter
except ImportError:
print("[-] PyCryptodome not installed, please install with 'pip install pycryptodome'")
exit(0)
demon_constants = {
1: "GET_JOB",
10: 'COMMAND_NOJOB',
11: 'SLEEP',
12: 'COMMAND_PROC_LIST',
15: 'COMMAND_FS',
20: 'COMMAND_INLINEEXECUTE',
21: 'COMMAND_JOB',
22: 'COMMAND_INJECT_DLL',
24: 'COMMAND_INJECT_SHELLCODE',
26: 'COMMAND_SPAWNDLL',
27: 'COMMAND_PROC_PPIDSPOOF',
40: 'COMMAND_TOKEN',
99: 'DEMON_INIT',
100: 'COMMAND_CHECKIN',
2100: 'COMMAND_NET',
2500: 'COMMAND_CONFIG',
2510: 'COMMAND_SCREENSHOT',
2520: 'COMMAND_PIVOT',
2530: 'COMMAND_TRANSFER',
2540: 'COMMAND_SOCKET',
2550: 'COMMAND_KERBEROS',
2560: 'COMMAND_MEM_FILE', # Beacon Object File
4112: 'COMMAND_PROC', # Shell Command
4113: 'COMMMAND_PS_IMPORT',
8193: 'COMMAND_ASSEMBLY_INLINE_EXECUTE',
8195: 'COMMAND_ASSEMBLY_LIST_VERSIONS',
}
# Used to store the AES Keys for each session
sessions = {}
def tsharkbody_to_bytes(hex_string):
"""
Converts a TShark hex formated string to a byte string.
:param hex_string: The hex string from TShark.
:return: The byte string.
"""
# its concatonated strings
hex_string = hex_string.replace(':', '')
#unhex it
hex_bytes = unhexlify(hex_string)
return hex_bytes
def aes_decrypt_ctr(aes_key, aes_iv, encrypted_payload):
"""
Decrypts an AES-encrypted payload in CTR mode.
:param aes_key: The AES key as a byte string.
:param aes_iv: The AES IV (Initialization Vector) for the counter, as a byte string.
:param encrypted_payload: The encrypted payload as a byte string.
:return: The decrypted plaintext as a byte string.
"""
# Initialize the counter for CTR mode
ctr = Counter.new(128, initial_value=int.from_bytes(aes_iv, byteorder='big'))
# Create the cipher in CTR mode
cipher = AES.new(aes_key, AES.MODE_CTR, counter=ctr)
# Decrypt the payload
decrypted_payload = cipher.decrypt(encrypted_payload)
return decrypted_payload
def parse_header(header_bytes):
"""
Parses a 20-byte header into an object.
:param header_bytes: A 20-byte header.
:return: A dictionary representing the parsed header.
"""
if len(header_bytes) != 20:
raise ValueError("Header must be exactly 20 bytes long")
# Unpack the header
payload_size, magic_bytes, agent_id, command_id, mem_id = struct.unpack('>I4s4sI4s', header_bytes)
# Convert bytes to appropriate representations
magic_bytes_str = binascii.hexlify(magic_bytes).decode('ascii')
agent_id_str = binascii.hexlify(agent_id).decode('ascii')
mem_id_str = binascii.hexlify(mem_id).decode('ascii')
command_name = demon_constants.get(command_id, f'Unknown Command ID: {command_id}')
return {
'payload_size': payload_size,
'magic_bytes': magic_bytes_str,
'agent_id': agent_id_str,
'command_id': command_name,
'mem_id': mem_id_str
}
def parse_request(http_pair, magic_bytes, save_path):
request = http_pair['request']
response = http_pair['response']#
unique_id = uuid4()
print("[+] Parsing Request")
try:
request_body = tsharkbody_to_bytes(request.get('file_data', ''))
header_bytes = request_body[:20]
request_payload = request_body[20:]
request_header = parse_header(header_bytes)
except Exception as e:
print(f"[!] Error parsing request body: {e}")
return
# If there is no magic this is not Havoc
if request_header.get("magic_bytes", '') != magic_bytes:
return
if request_header['command_id'] == 'DEMON_INIT':
print("[+] Found Havoc C2")
print(f" [-] Agent ID: {request_header['agent_id']}")
print(f" [-] Magic Bytes: {request_header['magic_bytes']}")
print(f" [-] C2 Address: {request.get('uri')}")
aes_key = request_body[20:52]
aes_iv = request_body[52:68]
print(f" [+] Found AES Key")
print(f" [-] Key: {binascii.hexlify(aes_key).decode('ascii')}")
print(f" [-] IV: {binascii.hexlify(aes_iv).decode('ascii')}")
if request_header['agent_id'] not in sessions:
sessions[request_header['agent_id']] = {
"aes_key": aes_key,
"aes_iv": aes_iv
}
# We dont want to process the rest of the request
response_payload = None
elif request_header['command_id'] == 'GET_JOB':
print(" [+] Job Request from Server to Agent")
# if the pcap did not contain an init or we have manually passed keys add the found keys message
# Grab the response header to get the incoming request.
try:
response_body = tsharkbody_to_bytes(response.get('file_data', ''))
except Exception as e:
print(f"[!] Error parsing request body: {e}")
return
header_bytes = response_body[:12]
response_payload = response_body[12:]
command_id = struct.unpack('<H', header_bytes[:2])[0]
command = demon_constants.get(command_id, f'Unknown Command ID: {command_id}')
print(f" [-] C2 Address: {request.get('uri')}")
print(f" [-] Comamnd: {command}")
else:
print(f" [+] Unknown Command: {request_header['command_id']}")
response_payload = None
# If we have keys lets decode the payload
aes_keys = sessions.get(request_header['agent_id'], None)
if not aes_keys:
print(f"[!] No AES Keys for Agent with ID {request_header['agent_id']}")
return
# If save_path is set, make sure the directory exists
if save_path and not os.path.exists(save_path):
print(f"[!] Save path {save_path} does not exist, creating")
os.makedirs(save_path)
# Decrypt the Request Body
if request_payload:
print(" [+] Decrypting Request Body")
decrypted_request = aes_decrypt_ctr(aes_keys['aes_key'], aes_keys['aes_iv'], request_payload)
# Always print
decoded = decrypted_request.decode('utf-8', errors='ignore').strip()
print(f"\n\033[92m[Decrypted Request]\033[0m\n{decoded}\n\n")
# Save only if save_path is set
if save_path:
save_file = f'{save_path}/{unique_id}-request-{request_header["agent_id"]}.bin'
with open(save_file, 'wb') as output_file:
output_file.write(decrypted_request)
# Decrypt the Response Body
if response_payload:
print(" [+] Decrypting Response Body")
decrypted_response = aes_decrypt_ctr(aes_keys['aes_key'], aes_keys['aes_iv'], response_payload)
# Always print
decoded = decrypted_response.decode('utf-8', errors='ignore').strip()
print(f"\n\033[94m[Decrypted Response - {command}]\033[0m\n{decoded}\n\n")
# Save only if save_path is set
if save_path:
save_file = f'{save_path}/{unique_id}-response-{request_header["agent_id"]}.bin'
with open(save_file, 'wb') as output_file:
output_file.write(decrypted_response)
def read_pcap_and_get_http_pairs(pcap_file, magic_bytes, save_path):
capture = pyshark.FileCapture(pcap_file, display_filter='http')
http_pairs = {}
current_stream = None
request_data = None
print("[+] Parsing Packets")
for packet in capture:
try:
# Check if we are still in the same TCP stream
if current_stream != packet.tcp.stream:
# Reset for a new stream
current_stream = packet.tcp.stream
request_data = None
if 'HTTP' in packet:
if hasattr(packet.http, 'request_method'):
# This is a request
request_data = {
'method': packet.http.request_method,
'uri': packet.http.request_full_uri,
'headers': packet.http.get_field_value('request_line'),
'file_data': packet.http.file_data if hasattr(packet.http, 'file_data') else None
}
elif hasattr(packet.http, 'response_code') and request_data:
# This is a response paired with the previous request
response_data = {
'code': packet.http.response_code,
'phrase': packet.http.response_phrase,
'headers': packet.http.get_field_value('response_line'),
'file_data': packet.http.file_data if hasattr(packet.http, 'file_data') else None
}
# Pair them together in a dictionary
http_pairs[f"{current_stream}_{packet.http.request_in}"] = {
'request': request_data,
'response': response_data
}
parse_request(http_pairs[f"{current_stream}_{packet.http.request_in}"], magic_bytes, save_path)
#print(http_pairs[f"{current_stream}_{packet.http.request_in}"])
request_data = None # Reset request data after pairing
except AttributeError as e:
# Ignore packets that don't have the necessary HTTP fields
pass
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Extract Havoc Traffic from a PCAP')
parser.add_argument(
'--pcap',
help='Path to pcap file',
required=True)
parser.add_argument(
"--aes-key",
help="AES key",
required=False)
parser.add_argument(
"--aes-iv",
help="AES initialization vector",
required=False)
parser.add_argument(
"--agent-id",
help="Agent ID",
required=False)
parser.add_argument(
'--save',
help='Save decrypted payloads to file',
default=False,
required=False)
parser.add_argument(
'--magic',
help='Set the magic bytes marker for the Havoc C2 traffic',
default='deadbeef',
required=False)
# Parse the arguments
args = parser.parse_args()
# Custom check for the optional values
if any([args.aes_key, args.aes_iv, args.agent_id]) and not all([args.aes_key, args.aes_iv, args.agent_id]):
parser.error("[!] If you provide one of 'aes-key', 'aes-iv', or 'agent-id', you must provide all three.")
if args.agent_id and args.aes_key and args.aes_iv:
sessions[args.agent_id] = {
"aes_key": unhexlify(args.aes_key),
"aes_iv": unhexlify(args.aes_iv)
}
print(f"[+] Added session keys for Agent ID {args.agent_id}")
#find_havoc_packets(packets, args.save)
# Usage example
http_pairs = read_pcap_and_get_http_pairs(args.pcap, args.magic, args.save)
After running the script we can answer the question with context to the commands issued:
What is the SID of the user that the attacker is executing everything under?
What is the Link-local IPv6 Address of the server? Enter the answer exactly as you see it
The attacker printed a flag for us to see. What is that flag?
The attacker added a new account as a persistence mechanism. What is the username and password of that account? Format is username:password
The attacker found an important file on the server. What is the full path of that file?
What is the flag found inside the file from question 5?