Sudeep vision

Blogs about cyber security - APTs and CTFs

View on GitHub

Neurogrid CTF 2025 - Resurrection forensics challenge write up

Overview

Challenge name Resurrection
Solution author Sudeep Singh
Category Forensics

In this challenge, we are provided a memory dump from a Linux machine along with OS symbols and a network PCAP.

Below is the directory structure of the provided files.

$ tree
.
├── Ubuntu_6.8.0-31-generic_6.8.0-31.31_amd64.json.xz
├── memory.dmp
└── network.pcapng

Initial analysis

Memory dump analysis

First, I checked the list of running processes on the Linux machine using the below command

vol -f memory.dmp linux.pslist.PsList

I identified a few potential candidates which could be malicious processes. Next I checked the procMaps for each of the potential malicious processes to identify the actual malicious process.

vol -f memory.dmp linux.proc.Maps --pid <pid>

One specific process with pid 5197 stood out as malicious because its process memory map showed that a binary was mapped to the process memory address space using memfd_create syscall. The name of this process according to pslist module is 3 which is also suspicious.

The memfd_create syscall allows a file to be mapped to the memory without any presence on the disk. This technique is abused by threat actors for fileless execution of malware on Linux machines.

└─$ vol -f memory.dmp linux.proc.Maps --pid 5197                          
Volatility 3 Framework 2.27.0
Progress:  100.00               Stacking attempts finished           
PID     Process Start   End     Flags   PgOff   Major   Minor   Inode   File Path       File output

5197    3       0x400000        0x6ac000        r-x     0x0     0       1       1178    /memfd:libudev-cache (deleted)  Disabled
5197    3       0x6ac000        0x96b000        r--     0x2ac000        0       1       1178    /memfd:libudev-cache (deleted)  Disabled
5197    3       0x96b000        0x983000        rw-     0x56b000        0       1       1178    /memfd:libudev-cache (deleted)  Disabled
5197    3       0x983000        0x9b3000        rw-     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0xc000000000    0xc000400000    rw-     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0xc000400000    0xc004000000    ---     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0x73a009d80000  0x73a009e00000  rw-     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0x73a009e00000  0x73a00be00000  rw-     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0x73a00be00000  0x73a01bf80000  ---     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0x73a01bf80000  0x73a01bf81000  rw-     0x0     0       0       0       Anonymous Mapping       Disabled
5197    3       0x73a01bf81000  0x73a03bf80000  ---     0x0     0       0       0       Anonymous Mapping       Disabled

We can see that a binary with the name, libudev-cache was mapped to the memory address space of the process with pid 5197 using memfd_create. In order to dump the binary, we will dump the first few segments. The executable segment above corresponds to the CODE section of the ELF binary.

Using the below 3 commands, we can dump all the segments.

vol -f memory.dmp linux.proc.Maps --pid 5197 --address 0x6ac000 --dump
vol -f memory.dmp linux.proc.Maps --pid 5197 --address 0x96b000 --dump
vol -f memory.dmp linux.proc.Maps --pid 5197 --address 0x983000 --dump

Now we can combine all the dumped segments into a ELF binary

 cat pid.5197.vma.0x400000-0x6ac000.dmp \
 pid.5197.vma.0x6ac000-0x96b000.dmp \
 pid.5197.vma.0x96b000-0x983000.dmp \
 pid.5197.vma.0x983000-0x9b3000.dmp \
 > full_binary.dmp

We can confirm this is a 64-bit ELF binary which was written in Golang

└─$ file full_binary.dmp                                                          
full_binary.dmp: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=7CTWG2Cxpe3VqhVMajtX/lB4Hj4LiMmEssbOxebIw/a8BwaNTj4tlGvFbIs0KU/cgRyK_4RDrltBD4RugZY, stripped

Since this is a Golang binary, I first ran GoReSym against it to identify the dependencies.

The output of GoReSym shows the below dependencies.

    "BuildInfo": {
        "GoVersion": "go1.23.0",
        "Path": "gopher",
        "Main": {
            "Path": "gopher",
            "Version": "(devel)",
            "Sum": "",
            "Replace": null
        },
        "Deps": [
            {
                "Path": "github.com/gen2brain/shm",
		...
            },
            {
                "Path": "github.com/godbus/dbus/v5",
		...
            },
            {
                "Path": "github.com/jezek/xgb",
		...
            },
            {
                "Path": "github.com/kbinani/screenshot",
		...
            },
            {
                "Path": "github.com/shirou/gopsutil/v4",
		...
            },
            {
                "Path": "github.com/tklauser/go-sysconf",
		...
            },
            {
                "Path": "github.com/tklauser/numcpus",
		...
            },
            {
                "Path": "github.com/vmihailenco/msgpack/v5",
		...
            },
            {
                "Path": "github.com/vmihailenco/tagparser/v2",
		...
            },
            {
                "Path": "golang.org/x/sys",
		...
            }
        ],

After some more analysis, I confirmed that this Golang binary is an Adaptix C2 gopher agent. Adaptix C2 is an open-source offensive security framework.

Next I extracted the config of this agent. I used the GitHub project here to extract the config from the gopher agent. The way this tool works is by bruteforcing the binary to look for the AES GCM key and the IV. Once it finds certain keywords specific to the gopher agent in the decrypted content, that confirms the correct decryption key and IV.

I modified the go config extractor to also dump the correct AES key and IV. We will use this AES key and IV to decrypt the first network packet sent by the agent to the C2 server later.

Below is the decrypted config of the gopher agent.

go run main.go ..\..\full_memory.dmp
{
  "profile": {
    "type": 2421052563,
    "addresses": [
      "192.168.91.133:8484"
    ],
    "banner_size": 1,
    "conn_timeout": 0,
    "conn_count": 1000000000,
    "use_ssl": false,
    "ssl_cert": null,
    "ssl_key": null,
    "ca_cert": null
  },
  "aes_key": "c7288225d356417174cabc03bc2700e4",
  "iv": "12ab1c5770f5d92e2f55457f",
  "key_offset": 5691776,
  "config_size": 122
}

From the extracted config, we can see the C2 IP address and port number. We also have the AES GCM key and the IV.

PCAP analysis

Since we have the C2 server IP and the port number. Let’s check the number of packets sent by the client to the C2 server. We will filter for packets containing tcp payload since those are the packets containing data.

└─$ tshark -Y "ip.addr == 192.168.91.133 && tcp.dstport == 8484 && tcp.payload" -r network.pcapng | more 
 5287  49.255932 192.168.91.191 → 192.168.91.133 TCP 292 54770 → 8484 [PSH, ACK] Seq=1 Ack=2 Win=32128 Len=226 TSval=1717558140 TSecr=242572312
12629 638.954422 192.168.91.191 → 192.168.91.133 TCP 263 54770 → 8484 [PSH, ACK] Seq=227 Ack=103 Win=32128 Len=197 TSval=1718147848 TSecr=243161856
12765 650.119590 192.168.91.191 → 192.168.91.133 TCP 1514 54770 → 8484 [ACK] Seq=424 Ack=208 Win=32128 Len=1448 TSval=1718159013 TSecr=243173066
12766 650.119603 192.168.91.191 → 192.168.91.133 TCP 1514 54770 → 8484 [ACK] Seq=1872 Ack=208 Win=32128 Len=1448 TSval=1718159013 TSecr=243173066

Decryption of the packets

First packet

The first packet after infection is sent by the gopher agent to the C2 server. We will use the AES GCM key and the IV identified above to decrypt it.

Below is the structure of the SessionInfo packet sent by the client to the server.

type SessionInfo struct {
	Process    string `msgpack:"process"`
	PID        int    `msgpack:"pid"`
	User       string `msgpack:"user"`
	Host       string `msgpack:"host"`
	Ipaddr     string `msgpack:"ipaddr"`
	Elevated   bool   `msgpack:"elevated"`
	Acp        string `msgpack:"acp"`
	Oem        string `msgpack:"oem"`
	Os         string `msgpack:"os"`
	OSVersion  string `msgpack:"os_version"`
	EncryptKey []byte `msgpack:"encrypt_key"`
}

From the SessionInfo object, we can see the encrypt_key field. This key will be the session key that is used by the client and the server to encrypt and decrypt all the packets exchanged after the first packet.

The structure of the packets is as shown below.

Offset Len Name Description
0 4 Length Length of the ciphertext
4 16 IV AES GCM IV. This will be different for each packet
16 Length Ciphertext Encrypted data
Length - 16 16 AES GCM tag This is the authentication tag used in AES GCM decryption

Below is the decrypted SessionInfo object extracted from the first network packet sent by the gopher agent to the C2 server.

{
    "process": "memfd:libudev-cache",
    "pid": 5197,
    "user": "dev",
    "host": "dev-work01",
    "ipaddr": "192.168.91.191",
    "elevated": false,
    "os": "linux",
    "os_version": "Ubuntu 24.04",
    "encrypt_key": "a0d03c1a2ed1f66b82fb5e8241e1f46c"
}

Now we have the AES key used to encrypt and decrypt all the remaining packets. I’ve included the full code to extract the decrypted streams from the PCAP in the Appendix.

Below are a few decrypted messages extracted from the client to server network packets.

To summarize, based on the decrypted packets, we can see that the attacker enumerated the list of files in the directory, /home/dev/projects/technova-dev-service and then compressed the contents of the directory to a ZIP archive in the path, /tmp/tmp.zip which was exfiltrated to the attacker’s server by the gopher agent.

===== CLIENT -> SERVER MESSAGES =====

--- Decrypted Message #1 ---

{
    "type": 1,
    "object": [
        {
            "code": 3,
            "id": 1446581947,
            "data": {
                "output": "uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)\n"
            }
        }
    ]
}
....

--- Decrypted Message #3 ---

{
    "type": 1,
    "object": [
        {
            "code": 3,
            "id": 3026683914,
            "data": {
                "output": "dev adm cdrom sudo dip plugdev users lpadmin\n"
            }
        }
    ]
}

...

--- Decrypted Message #13 ---

{
    "type": 1,
    "object": [
        {
            "code": 12,
            "id": 4154001035,
            "data": {
                "result": true,
                "status": "",
                "path": "/home/dev/projects/technova-dev-service",
                "files": [
                    {
                        "mode": "-rwxrwxrwx",
                        "nlink": 1,
                        "user": "dev",
                        "group": "dev",
                        "size": 95,
                        "date": "Jun  1 02:10",
                        "filename": "README.md",
                        "is_dir": false
                    },
                    {
                        "mode": "drwxrwxrwx",
                        "nlink": 2,
                        "user": "dev",
                        "group": "dev",
                        "size": 4096,
                        "date": "Jun  2 05:17",
                        "filename": "config",
                        "is_dir": true
                    },
                    {
                        "mode": "-rwxrwxrwx",
                        "nlink": 1,
                        "user": "dev",
                        "group": "dev",
                        "size": 182,
                        "date": "Jun  1 02:11",
                        "filename": "main.py",
                        "is_dir": false
                    },
...

--- Decrypted Message #22 ---

{
    "type": 2,
    "object": [
        {
            "command_id": 5,
            "job_id": "c23e8d5d",
            "data": {
                "id": 2126187672,
                "path": "/tmp/tmp.zip",
                "size": 1656,
                "content": "PK\u0003\u0004\u0014\u0000\u0000\u0000\u0000\u0000",
                "start": true,
                "finish": false,
                "canceled": false
            }
        }
    ]
}

...
}

I wrote another script to parse out the ZIP archive from the above output. After extracting the ZIP archive and decompressing it, below are the contents

$ tree .
.
└── technova-dev-service
    ├── README.md
    ├── config
    │   └── dev.env
    └── main.py

Flag was inside the dev.env file as shown below.

$ cat dev.env 
DEBUG=true
SECRET_KEY=HTB{d3l3t3d_d03snt_m34n_n0t_3xsi5tEd}

Appendix

Script to decrypt the packets sent from client to server

import pyshark
import msgpack
from Crypto.Cipher import AES
import binascii
import json
import io
import base64

PCAP = "network.pcapng"

SESSION_KEY = bytes.fromhex("a0d03c1a2ed1f66b82fb5e8241e1f46c")

SERVER_IP = "192.168.91.133"
SERVER_PORT = "8484"
CLIENT_IP = "192.168.91.191"

def pretty(obj):
    def convert(o):
        if isinstance(o, (bytes, bytearray)):
            try:
                return o.decode("utf-8")
            except UnicodeDecodeError:
                return {"__base64__": base64.b64encode(o).decode()}

        if isinstance(o, msgpack.ExtType):
            return {
                "__exttype__": o.code,
                "__data__": base64.b64encode(o.data).decode()
            }

        if isinstance(o, (list, tuple)):
            return [convert(x) for x in o]

        if isinstance(o, dict):
            new_dict = {}
            for k, v in o.items():
                if isinstance(k, (bytes, bytearray)):
                    try:
                        new_k = k.decode("utf-8")
                    except UnicodeDecodeError:
                        new_k = "__base64key__:" + base64.b64encode(k).decode()
                else:
                    new_k = str(k)

                new_dict[new_k] = convert(v)
            return new_dict

        return o

    safe = convert(obj)
    return json.dumps(safe, indent=4, ensure_ascii=False)

def deep_unpack(obj):
    if isinstance(obj, (bytes, bytearray)):
        try:
            unpacked = msgpack.unpackb(obj, raw=False)
            return deep_unpack(unpacked)
        except Exception:
            return obj

    if isinstance(obj, list):
        return [deep_unpack(x) for x in obj]

    if isinstance(obj, dict):
        return {k: deep_unpack(v) for k, v in obj.items()}

    return obj


def unpack_msgpack_stream(raw):
    buf = io.BytesIO(raw)
    up = msgpack.Unpacker(buf, raw=False)
    out = []
    try:
        for obj in up:
            out.append(obj)
    except Exception:
        pass
    return out

def extract_tcp_streams():
    client_to_server = bytearray()

    cap = pyshark.FileCapture(
        PCAP,
        display_filter=f"tcp.port == {SERVER_PORT}"
    )

    for pkt in cap:
        if not hasattr(pkt, "tcp"):
            continue
        if not hasattr(pkt.tcp, "payload"):
            continue

        hexdata = pkt.tcp.payload.replace(":", "")
        if len(hexdata) == 0:
            continue

        raw = bytes.fromhex(hexdata)

        src = pkt.ip.src
        dst = pkt.ip.dst

        if src == CLIENT_IP and pkt.tcp.dstport == SERVER_PORT:
            client_to_server.extend(raw)

    return bytes(client_to_server)

def decrypt_gcm(blob, key):
    if len(blob) < 4:
        return None

    msg_len = int.from_bytes(blob[:4], "big")
    enc = blob[4:4 + msg_len]

    if len(enc) < 12 + 16:  # nonce + tag
        return None

    nonce = enc[:12]
    ciphertext = enc[12:-16]
    tag = enc[-16:]

    cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
    try:
        pt = cipher.decrypt_and_verify(ciphertext, tag)
        return pt
    except Exception:
        return None

def split_length_prefixed_stream(stream):
    """
    Takes a TCP byte-stream and yields each length-prefixed Gopher message.
    """
    out = []
    i = 0
    while i + 4 <= len(stream):
        msg_len = int.from_bytes(stream[i:i + 4], "big")
        if msg_len == 0:
            i += 4
            continue

        start = i
        end = i + 4 + msg_len
        if end > len(stream):
            break

        out.append(stream[start:end])
        i = end

    return out

def decode_and_print(messages, direction):
    print(f"\n\n===== {direction} MESSAGES =====")

    for idx, blob in enumerate(messages):
        pt = decrypt_gcm(blob, SESSION_KEY)
        if not pt:
            continue

        print(f"\n--- Decrypted Message #{idx} ---\n")

        objs = unpack_msgpack_stream(pt)
        for o in objs:
            clean = deep_unpack(o)
            print(pretty(clean))


def main():
    print("[*] Reassembling TCP streams...")
    c2s_stream = extract_tcp_streams()

    print(f"Client->Server stream length: {len(c2s_stream)}")

    print("[*] Splitting client->server...")
    c2s_msgs = split_length_prefixed_stream(c2s_stream)

    decode_and_print(c2s_msgs, "CLIENT -> SERVER")

if __name__ == "__main__":
    main()

Script to extract the ZIP archive from the output generated by above script

import json
import base64
import re
import sys

def extract_zip_from_logs(input_file, output_file):
    print(f"Reading from {input_file}...")
    
    try:
        with open(input_file, 'r', encoding='utf-8') as f:
            full_text = f.read()
    except FileNotFoundError:
        print(f"Error: {input_file} not found.")
        return

    zip_binary = bytearray()
    
    fragments = re.split(r'--- Decrypted Message #\d+ ---', full_text)
    
    chunks_found = 0

    for fragment in fragments:
        fragment = fragment.strip()
        if not fragment:
            continue

        start_idx = fragment.find('{')
        end_idx = fragment.rfind('}')

        if start_idx == -1 or end_idx == -1:
            continue

        json_str = fragment[start_idx:end_idx+1]

        try:
            data_obj = json.loads(json_str)
        except json.JSONDecodeError:
            continue

        if "object" in data_obj and isinstance(data_obj["object"], list):
            for item in data_obj["object"]:
                if not isinstance(item, dict):
                    continue
                    
                job_data = item.get("data")

                if not job_data:
                    continue
                
                if job_data.get("path") == "/tmp/tmp.zip":
                    chunks_found += 1
                    content = job_data.get("content")
                    
                    if content is None:
                        continue

                    if isinstance(content, dict) and "__base64__" in content:
                        b64_str = content["__base64__"]
                        try:
                            decoded_bytes = base64.b64decode(b64_str)
                            zip_binary.extend(decoded_bytes)
                        except Exception as e:
                            print(f"Error decoding base64: {e}")
                    elif isinstance(content, str):
                        try:
                            raw_bytes = content.encode('latin1')
                            zip_binary.extend(raw_bytes)
                        except UnicodeEncodeError:
                            print("Warning: Could not encode string content with latin1")

    if chunks_found > 0:
        print(f"Found {chunks_found} chunks matching '/tmp/tmp.zip'.")
        print(f"Writing {len(zip_binary)} bytes to {output_file}...")
        
        with open(output_file, 'wb') as f:
            f.write(zip_binary)
            
        print("Done.")
    else:
        print("No matching data found for /tmp/tmp.zip")

if __name__ == "__main__":
    extract_zip_from_logs("decrypted_client_packets.txt", "tmp.zip")