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.
Analysis
Understanding how the payload is decrypted
The application begins by printing out some text to the 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.
The lpAddress_data shown below represents a total of 4,282,888 bytes that will be manipulated and decrypted.
The 32-byte key that will be modified and used in the decryption process is seen below.
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.
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.
Common values to know
0x800C=CALG_SHA_2560x660E=CALG_AES_1280x18inCryptAcquireContextW=PROV_RSA_AES0xF0000000=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.
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.
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.
The decryption of the payload is finally performed.
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.
The function to execute the payload first performs the verification before allocating new memory to copy it into and executing it directly from memory.
A sanity check is performed to determine that the decrypted bytes begin with MZ (0x5A4D) to gain confidence that the decryption was successful.
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
- Start address:
- That the payload bytes are all XOR’ed with
0x13before decryption - That the RSA decryption key bytes are all modified with
0x6with theADDoperation before decryption - That the RSA decryption key bytes are
SHA-256hashed 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.
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.
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.
The sandbox shows the C2 IP it reached out to as not being malicious.
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.
The known communicating files are in line with the samples I am investigating.
The next post will investigate this embedded payload in more detail.
Indicators of Compromise
| Name | Value |
|---|---|
| Fooder | E68435F3899D0F01810AFFF7A420429F |
| embedded payload | 263ABB5833366F37B4D623B462F8A1A9 |
| C2 IP | 212.232.22[.]136 |



















