Home MuddyWater Fooder Malware
Post
Cancel

MuddyWater Fooder Malware

Summary

This part of a series of binary analysis reviews of malware used (or created) by the MuddyWater threat group. MuddyWater may also be known as ATK51, Boggy Serpens, COBALT ULSTER, Earth Vetala, G0069, MERCURY, Mango Sandstorm, Seedworm, Static Kitten, TA450, or TEMP.Zagros.

In this post we are looking at a malware binary that is used as a loader for a Go-socks reverse tunneling tool which is embedded within the binary that it decrypts and loads into new memory for execution. Binary hash being analyzed in this post: E68435F3899D0F01810AFFF7A420429F

A high-level overview is shown below and we’ll walk through how this is seen within IDA Pro that provided the details to create a Python script to decrypt the embedded payload into a standalone binary for additional analysis.


Figure 1: Overview of embedded payload decryption process

Analysis

Understanding how the payload is decrypted

The application begins by printing out some text to the console.


Figure 2: Snake game banners printed to console


The malware changes the memory of this static data to RWE to perform additional operations on it. The array of data in lpAddress_data is encrypted and will be decrypted later on.


Figure 3: Calling `VirtualProtect` to update memory to `RWE`


The lpAddress_data shown below represents a total of 4,282,888 bytes that will be manipulated and decrypted.


Figure 4: The start of the encrypted embedded payload (pre-modification)


The 32-byte key that will be modified and used in the decryption process is seen below.


Figure 5: The key used for RSA decryption (pre-modification)


Two array manipulations are at play here: 1) A 32-byte static key has 0x6 added to each byte. 2) All bytes in lpAddress_data are XOR’ed with 0x13.


Figure 6: XOR operations ran against key and payload bytes pre-decryption


When we jump into the mw_Decryption function, we can quickly see that it is using the AES Cryptographic Provider. There are a few things we can derive from this that are important if we want to attempt to write a script to perform the decryption on our own.


Figure 7: RSA cryptography functions setting up for decryption


Common values to know

  • 0x800C = CALG_SHA_256
  • 0x660E = CALG_AES_128
  • 0x18 in CryptAcquireContextW = PROV_RSA_AES
  • 0xF0000000 = CRYPT_VERIFYCONTEXT

If we were to write this as proper C-code it would look similar to:

CryptAcquireContextW(&phProv, 0, L”Microsoft Enhanced RSA and AES Cryptographic Provider”,PROV_RSA_AES, CRYPT_VERIFYCONTEXT);
CryptCreateHash(phProv, CALG_SHA_256, 0, 0, &phHash);
CryptHashData(phHash, key, key_size, 0);
CryptDeriveKey(phProv, CALG_AES_128, phHash, 0, &phKey);

If we wanted to make the pseudo-C look closer to the C-code we could add a new enum with the constants and apply it. There are also type libraries you could import that may already have these values. Below is an example of one I use with common constant values.


Figure 8: Custom enum to map crypto constants to values


Once this has been added we can apply this to each of the values by using the “m” key on each constant in the pseudo-C.


Figure 9: Using the new enum in pseudo-C code


This in turn produces cleaner output. You should continue to build your own collection of enumerations and structs to apply to all future samples you reverse engineer.


Figure 10: Cleaner output after using custom enum


The decryption of the payload is finally performed.


Figure 11: Decryption of the payload


The result of the decryption is pointed to by the reference variable out_val that is returned from the function. This is then passed into a function that will verify the decryption and ultimately execute it.


Figure 12: Calls to decrypt and execute embedded payload


The function to execute the payload first performs the verification before allocating new memory to copy it into and executing it directly from memory.


Figure 13: Call to verification function before setting up payload execution


A sanity check is performed to determine that the decrypted bytes begin with MZ (0x5A4D) to gain confidence that the decryption was successful.


Figure 14: Checking that the first two bytes are “MZ” (0x5A4D)


Writing a script to save the payload

We now have enough information to write a script to fully decrypt the payload into a new standalone binary file.

We now know

  • The start and end address of the embedded hex bytes for the payload:
    • Start address: 0x14030A630
    • End address: 0x140720038
  • That the payload bytes are all XOR’ed with 0x13 before decryption
  • That the RSA decryption key bytes are all modified with 0x6 with the ADD operation before decryption
  • That the RSA decryption key bytes are SHA-256 hashed and only the first 16 bytes are used

Below is a script that will dump out the hex bytes to a file that can be used in our decryption script.

1
2
3
4
5
6
7
8
9
import ida_bytes

start = 0x14030A630
end   = 0x140828450

data = ida_bytes.get_bytes(start, end - start)

with open("C:\Temp\ida_dump_hex.txt", "w") as f:
    f.write(data.hex())

The following script will take the dumped hex and produce the complete dropped binary file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#!/usr/bin/env python3

import argparse
import hashlib
import pathlib
import re
import sys

from Crypto.Cipher import AES


STATIC_KEY = bytes.fromhex(
    "63 63 63 72 1A 4B 0C 7B 7A 11 0C 2E 0A 61 0B 0E "
    "10 0D 2D 1B 33 43 3F 0D 7F 0A 81 1C 90 0A 5E 40"
)

XOR_KEY = 0x13
KEY_ADD = 0x06
AES_IV = b"\x00" * 16


def read_hex_file(path: pathlib.Path) -> bytes:
    text = path.read_text(encoding="utf-8", errors="ignore")
    cleaned = re.sub(r"[^0-9a-fA-F]", "", text)

    if not cleaned:
        raise ValueError("Input file does not contain any hex characters")

    if len(cleaned) % 2 != 0:
        raise ValueError("Hex string has odd length after cleaning")

    return bytes.fromhex(cleaned)


def derive_key(static_key: bytes):
    mutated_key = bytes((b + KEY_ADD) & 0xFF for b in static_key)
    sha256_digest = hashlib.sha256(mutated_key).digest()
    aes_key = sha256_digest[:16]
    return mutated_key, sha256_digest, aes_key


def xor_data(data: bytes) -> bytes:
    return bytes(b ^ XOR_KEY for b in data)


def pkcs7_unpad(data: bytes) -> bytes:
    if not data:
        return data

    pad_len = data[-1]
    if pad_len == 0 or pad_len > 16:
        return data

    if len(data) < pad_len:
        return data

    if data[-pad_len:] != bytes([pad_len]) * pad_len:
        return data

    return data[:-pad_len]


def decrypt_payload(enc_data: bytes, aes_key: bytes) -> bytes:
    if len(enc_data) % 16 != 0:
        raise ValueError(
            f"Encrypted data length must be a multiple of 16, got {len(enc_data)}"
        )

    cipher = AES.new(aes_key, AES.MODE_CBC, AES_IV)
    dec = cipher.decrypt(enc_data)
    return pkcs7_unpad(dec)


def looks_like_pe(data: bytes) -> bool:
    if len(data) < 0x40:
        return False

    if data[:2] != b"MZ":
        return False

    e_lfanew = int.from_bytes(data[0x3C:0x40], "little")
    if e_lfanew + 4 > len(data):
        return False

    return data[e_lfanew:e_lfanew + 4] == b"PE\x00\x00"


def main():
    parser = argparse.ArgumentParser(description="Decrypt XOR + AES malware payload")
    parser.add_argument("-i", "--input", required=True, help="Input text file containing hex")
    parser.add_argument("-o", "--output", required=True, help="Output decrypted PE path")
    args = parser.parse_args()

    input_path = pathlib.Path(args.input)
    output_path = pathlib.Path(args.output)

    if not input_path.is_file():
        print(f"[!] Input file not found: {input_path}", file=sys.stderr)
        sys.exit(1)

    print("[+] Reading hex input")
    enc_blob = read_hex_file(input_path)
    print(f"[+] Loaded {len(enc_blob)} bytes")

    mutated_key, sha256_digest, aes_key = derive_key(STATIC_KEY)
    print(f"[+] Mutated key: {mutated_key.hex()}")
    print(f"[+] SHA256:     {sha256_digest.hex()}")
    print(f"[+] AES key:    {aes_key.hex()}")

    print("[+] XORing payload with 0x13")
    xor_blob = xor_data(enc_blob)

    print("[+] Decrypting with AES-128-CBC")
    dec_blob = decrypt_payload(xor_blob, aes_key)

    if dec_blob[:2] == b"MZ":
        print("[+] MZ header detected")
    else:
        print("[!] No MZ header detected")

    if looks_like_pe(dec_blob):
        e_lfanew = int.from_bytes(dec_blob[0x3C:0x40], "little")
        print(f"[+] Valid PE detected, e_lfanew = 0x{e_lfanew:x}")
    else:
        print("[!] Output does not fully validate as a PE")

    output_path.write_bytes(dec_blob)
    print(f"[+] Wrote decrypted output to: {output_path}")


if __name__ == "__main__":
    main()

When executing in the console we can see it run through to completion.


Figure 15: Executing mw_decrypt_pe.py script on the commandline


The resulting payload has an MD5 hash of 263ABB5833366F37B4D623B462F8A1A9. At the time of analysis this was not a known hash on VirusTotal. Since this payload is decrypted and executed in memory it would generally not be a standalone payload that would be submitted.

Detect-It-Easy shows the following details regarding the stage 2 malware binary.


Figure 16: Detect-It-Easy on decrypted payload


The new binary was dropped into the sandbox and it came up as clean which does not seem plausible. There can be a number of reasons for this to include malicious behavior not being triggered, indicators observed may not be known yet by the sandbox to mark them malicious, or the binary is a benign tool being used maliciously.


Figure 17: Sandbox report on decrypted payload


The sandbox shows the C2 IP it reached out to as not being malicious.


Figure 18: C2 IP address communicated with during execution in sandbox


In Virustotal the IP address shows that it is generically malicious but no vendors (at the time of analysis) are identifying it as a specific malware family.


Figure 19: VirusTotal showing AV vendors tagging IP as “Malware” and “Phishing”


The known communicating files are in line with the samples I am investigating.


Figure 20: VirusTotal showing other files communicating to same IP


The next post will investigate this embedded payload in more detail.

Indicators of Compromise

NameValue
FooderE68435F3899D0F01810AFFF7A420429F
embedded payload263ABB5833366F37B4D623B462F8A1A9
C2 IP212.232.22[.]136
This post is licensed under CC BY 4.0 by the author.