From APT28 to RePythonNET: automating .NET malware analysis
嗯,用户让我用中文总结一篇文章,控制在100字以内,而且不需要特定的开头。首先,我需要通读整篇文章,抓住主要信息。 文章主要讲的是TDR团队如何逆向分析.NET恶意软件,提到了工具和方法。他们遇到了工具匮乏的问题,特别是对于复杂的样本。然后,他们开发了RePythonNET-MCP项目,利用pythonnet和dnlib来自动化分析过程,并结合AI助手来提高效率。 总结的时候,我需要涵盖这些要点:逆向工程、工具和方法、RePythonNET-MCP项目、自动化和AI的结合。同时要控制在100字以内,语言要简洁明了。 可能会这样组织句子:介绍TDR团队的方法和工具,提到遇到的问题,然后介绍他们开发的解决方案以及其优势。 最后检查一下字数是否符合要求,并确保内容准确传达文章的核心信息。 </think> 文章介绍了TDR团队如何通过工具和方法逆向分析.NET恶意软件,并探讨了相关工具匮乏的问题。作者分享了对APT28使用的Covenant框架的分析经验,并展示了如何利用pythonnet和dnlib自动化字符串解密和反编译过程。文章还介绍了RePythonNET-MCP开源项目及其功能模块,旨在提高.NET恶意软件分析的效率。 2026-4-16 12:55:13 Author: blog.sekoia.io(查看原文) 阅读量:9 收藏

This blogpost covers the tooling and methodology we use at TDR to reverse engineer .NET malware. In our daily work, we encounter a wide range of malware, sophisticated or not, and a significant portion of it is written in .NET. Yet, the .NET reverse engineering ecosystem remains surprisingly thin, with few dedicated tools and few in-depth write-ups, especially compared to the volume of .NET malware actually seen in the wild. This is perhaps because .NET malware tends to fall into one of two categories:

  • Simpler samples, where no advanced skills are strictly required, though fully understanding the code can still be time-consuming.
  • More complex ones, involving control flow flattening and/or requiring a solid grasp of the intermediate language, where the analysis often stalls due to a lack of proper tooling.

At the end of 2025, we published our Advent of Configuration Extraction series, outlining the methodology we employ at Sekoia’s Threat Detection & Research (TDR) team to automate malware configuration extraction. The second article detailed how we leverage pythonnet to extract the configuration of QuasarRAT, an open-source .NET-based RAT publicly available on GitHub. This article goes further, taking a deeper look at .NET malware analysis and how this work can be automated at scale.

Reversing Covenant, the old way

In 2025, we published an analysis of a new APT28 infection chain, culminating in the deployment of Covenant, an open-source .NET command and control framework. At the time of analysis, the nature of the implant was not immediately obvious: the samples were obfuscated, with randomized symbol names and encrypted strings. Only through reversing did we identify the payload as a Covenant Grunt, the client-side implant responsible for establishing a C2 channel, executing commands and retrieving additional modules.

What makes this sample particularly interesting is the way APT28 extended the framework. Covenant allows operators to implement custom outbound C2 protocols by defining a C2Bridge and a BridgeListener, without modifying the core framework. Here, the attacker implemented a custom C2Bridge leveraging the Koofr or Filen API, relying entirely on file uploads and downloads to that service for its communications.

The sample is further hardened through obfuscation: function names are randomized and strings are encrypted, as illustrated in the main function below.

Figure 1: Extract of encrypted strings in Covenant.

Manually reversing this sample is not particularly difficult, but it is tedious:

  • All strings must be identified and decrypted.
  • Functions must be renamed incrementally as their purpose becomes clear.

The main friction comes from tooling. dnSpy does not lend itself well to code modification: while technically possible, it is not practical for this kind of iterative work. The alternative, copying the decompiled code into Visual Studio, introduces its own overhead and is far from a clean workflow.

APT28’s current operational Tactics

APT28 is a Russia-nexus intrusion set attributed by Western intelligence services to Russia’s General Staff Main Intelligence Directorate (GRU) 85th Main Special Service Center known as Military Unit 26165. This intrusion set, which started its operations in 2004 against occidental Military networks, is especially known for its hybrid operations on the sidelines of armed conflict and diplomatic crises related to Russia.

Between 2021 and 2024, APT28 utilized an arsenal of modular and often disposable implants. Malwares like MASEPIE, STEELHOOK, and OCEANMAP are designed for specific, short-term tasks such as establishing basic persistence, executing reconnaissance commands, or stealing browser data like credentials and session cookies.

In contrast, a more sophisticated infection chain, identified in late 2024 and throughout 2025 (notably in Operation Phantom Net Voxel), demonstrates a strategic move toward stealthier delivery and persistence. This chain frequently begins with lure documents delivered via Signal Desktop to bypass Windows Mark-of-the-Web (MOTW) protections. The infection leverages VBA macros to perform a user-level COM hijack, ensuring persistence by loading a malicious DLL. This stage typically uses steganography to extract a shellcode from a valid PNG file, which then executes the GruntHTTPStager of the Covenant framework.

In this refined model, APT28 uses Covenant primarily for initial reconnaissance and as a delivery vehicle for specialized modules. A critical component for maintaining access is BeardShell, a stealthy C++ malware acting as a fallback mechanism to re-infect the environment if the primary access is lost. Alongside it, the group deploys SlimAgent, a dedicated spyware module focused on keylogging and information theft.

Reversing Covenant, a better way

Using the same techniques as in our advent of Configuration Extraction, we can leverage pythonnet and dnlib to automate parts of the analysis. In the case of APT28’s Covenant sample, the immediate goal is to recover the C2 servers, which are stored as encrypted strings. The process breaks down into three steps: locating the decryption routine, extracting the key, and finally patching the binary with the decrypted strings.

Finding the decryption routine

As explained here, we can fingerprint the decryption function using intermediate language (IL). The idea is straightforward: when analysing a new sample, we can iterate over all methods in the module and check whether their instruction stream contains a known sequence of opcodes characteristic of the decryption routine.

signature = ["nop", "ldloc.0", "ldarg.0", "ldloc.1", "ldelem.u1", "ldsfld", "ldloc.1", "ldsfld", "callvirt", "rem", "callvirt", "xor", "conv.u1", "callvirt", "nop", "nop", "ldloc.1", "ldc.i4.1", "add", "stloc.1", "ldloc.1", "ldarg.0", "ldlen", "conv.i4", "clt", "stloc.2", "ldloc.2", "brtrue.s"]

def has_config_pattern(method):
  if method.HasBody:
    ins = [x.OpCode.Name for x in method.Body.Instructions]
    if len(signature) > len(ins):
      return False
    for i in range(len(ins) - len(signature) + 1):
      if ins[i:i+len(signature)] == signature:
        return True
  return False

for t in module.GetTypes():
  for m in t.Methods:
    if not m.HasBody:
      continue
    if has_config_pattern(m):
      print(m.Name)

Once the decryption routine is identified, we need to recover the key it uses. The key is not a hardcoded constant: it is a class member initialized in a static constructor (.cctor), which means we cannot simply read it by name from the binary. We first extract the key’s name by inspecting the ldsfld instructions inside the decryption routine, then walk back up to the declaring class to find the constructor that initializes it.

if has_config_pattern(m):
  DecryptionFunction = m.Name
  for instr in m.Body.Instructions:
    if instr.OpCode.Name == "ldsfld":
      KeyName = instr.Operand.Name
      KeyClassName = instr.Operand.get_DeclaringType()

# Locate the class and its static constructor
t = next(t for t in module.GetTypes() if t.FullName == KeyClassName.FullName)
cctor = next((m for m in t.Methods if m.Name == ".cctor"), None)

Patching the binary

With the decryption routine and the key in hand, the last step is to patch the binary in place. The pattern to target is a ldstr instruction carrying an encrypted string immediately followed by a call to the decryption function. We replace the ldstr operand with the decrypted value and turn the call into a nop.

previous_instr = None
for t in module.GetTypes():
  for m in t.Methods:
    if not m.HasBody:
      continue
    for instr in m.Body.Instructions:
      if instr.OpCode.Name == "ldstr":
        previous_instr = instr
      elif instr.OpCode.Name == "call" and instr.Operand.Name == DecryptionFunction:
        decrypted_str = decrypt_str(previous_instr.Operand, key)
        print("{} decrypted to {}".format(previous_instr.Operand, decrypted_str))
        previous_instr.Operand = decrypted_str
        instr.OpCode = OpCodes.Nop
      else:
        previous_instr = None

The patched module can then be written back to disk:

from dnlib.DotNet.Writer import ModuleWriterOptions
opts = ModuleWriterOptions(module)
opts.Logger = None
module.Write("C:\\output.dll", opts)

The figure 2 is the result of the string decryption that can be compared to figure 1.

Figure 2: Extract of Covenant after automatic decryption (same function as figure 1).

The code used to automatically decrypt the strings is provided here.

Reversing .NET, the good way

We now have a script that automates string decryption for APT28’s Covenant sample. The remaining obfuscation challenge is randomized function names. In 2026, the natural answer to that problem is to feed the decompiled code to an AI assistant and let it handle the renaming. .NET works in our favor here: since the runtime relies on an intermediate language, decompilation produces relatively clean and verbose C# code, which is well-suited for that kind of analysis.

The question is whether we can programmatically generate that decompiled C# output. It turns out we can, by combining dnlib with ILSpy’s decompilation engine through pythonnet.

import clr
import os

# Resolve DLL paths relative to this script
base_dir = os.path.dirname(os.path.abspath(__file__))

clr.AddReference("System")
clr.AddReference(os.path.join(base_dir, "dll", "dnlib.dll"))
clr.AddReference(os.path.join(base_dir, "dll", "dnSpy.Contracts.Logic.dll"))
clr.AddReference(os.path.join(base_dir, "dll", "ICSharpCode.Decompiler.dll"))

# Keep reference to resolve internal types via reflection
ilspy_assembly = clr.AddReference(
    os.path.join(base_dir, "dll", "dnSpy.Decompiler.ILSpy.Core.dll")
)

import dnlib
import System
from dnSpy.Contracts.Decompiler import StringBuilderDecompilerOutput, DecompilationContext
import ICSharpCode.Decompiler
import dnSpy.Decompiler.ILSpy.Core

# CSharpDecompiler and its settings are internal types, so we use reflection
# to retrieve and instantiate them instead of importing them directly.
decompilation_context = System.Activator.CreateInstance(DecompilationContext)
csharp_type   = ilspy_assembly.GetType("dnSpy.Decompiler.ILSpy.Core.CSharp.CSharpDecompiler")
settings_type = ilspy_assembly.GetType("dnSpy.Decompiler.ILSpy.Core.Settings.CSharpVBDecompilerSettings")
settings      = System.Activator.CreateInstance(settings_type, None)
decompiler    = System.Activator.CreateInstance(csharp_type, settings, None)

# Decompile `t` (a dnlib TypeDef) and retrieve the C# source
out = System.Activator.CreateInstance(StringBuilderDecompilerOutput)
decompiler.Decompile(t, out, decompilation_context)
csharp_source = out.ToString()

One detail worth noting: CSharpDecompiler and CSharpVBDecompilerSettings are internal types within the ILSpy assembly, meaning they are not directly importable. We therefore instantiate them via reflection using System.Activator.CreateInstance, which lets us work with non-public types without modifying the underlying DLLs.

Rather than writing one-off scripts for each kind of .NET sample, we can wrap this capability into an MCP server and expose it as a set of tools that an AI assistant can call autonomously. The AI is then able to load a binary, decompile classes or methods on demand, and reason over the resulting C# source to rename functions, identify patterns, or extract configuration data. The following screenshot illustrates this on the APT28 Covenant sample with Claude Desktop. Here, we only provide this input to Claude Desktop:

“Using pythonnet, analyze the file 69609e89b04d8d27dc47bda2971376cfd760abb40ffe325f00d0cf3303be8906. No guessing. Send me the C2 if you find it.”

Figure 3: Result of the analysis by Claude desktop using our MCP service.

RePythonNet-MCP

To support the workflow described in this article, we are releasing RePythonNET as an open source project https://github.com/SEKOIA-IO/RePythonNET-MCP.

The typical analysis workflow is straightforward: upload your sample to the Docker container via the /upload endpoint, then ask your MCP client to load the binary and analyse it.

The server exposes a set of tools organized around the main phases of a reverse engineering session:

  • Session: load_binary, load_from_url, list_active_sessions, unload_binary, save_binary
  • Metadata: get_module_info, get_entry_point, get_resources, extract_resource
  • Navigation: list_all_classes, list_all_methods, list_all_methods_inside_class, get_fields, get_custom_attributes, get_imports
  • Analysis: find_pinvoke, find_crypto_patterns, find_reflection_calls, find_large_byte_arrays, find_dead_code, get_strings, search_string, search_method_by_name
  • IL: get_opcodes, get_method_calls, get_callers, get_call_graph
  • Decompile: decompile_method, decompile_class, decompile_all_classes
  • Patch: rename_class, rename_method, rename_field, patch_ldstr, nop_instructions, patch_return, patch_branch, set_field_constant

Two HTTP endpoints are also available: /upload to push samples to the container, and /download to retrieve patched binaries. Full documentation is available in the GitHub repository.

Conclusion

.NET malware analysis involves a mix of well-understood techniques and tooling gaps that slow down the work in practice. This article walked through one concrete example, from manual string decryption to a more automated approach built on pythonnet and dnlib. Adding AI-assisted analysis through an MCP server is a natural extension of that, not a silver bullet, but a practical way to offload some of the more repetitive parts of the work.

Even if RePythonNET is a tool built for our own needs we hope it will be of value to the community.

TDR is the Sekoia Threat Detection & Research team. Created in 2020, TDR provides exclusive Threat Intelligence, including fresh and contextualised IOCs and threat reports for the Sekoia SOC Platform TDR is also responsible for producing detection materials through a built-in Sigma, Sigma Correlation and Anomaly rules catalogue. TDR is a team of multidisciplinary and passionate cybersecurity experts, including security researchers, detection engineers, reverse engineers, and technical and strategic threat intelligence analysts. Threat Intelligence analysts and researchers are looking at state-sponsored & cybercrime threats from a strategic to a technical perspective to track, hunt and detect adversaries. Detection engineers focus on creating and maintaining high-quality detection rules to detect the TTPs most widely exploited by adversaries. TDR experts regularly share their analysis and discoveries with the community through our research blog, GitHub repository or X / Twitter account. You may also come across some of our analysts and experts at international conferences (such as BotConf, Virus Bulletin, CoRIIN and many others), where they present the results of their research work and investigations.

Share this post:


文章来源: https://blog.sekoia.io/apt28-to-repythonnet-automating-net-malware-analysis/
如有侵权请联系:admin#unsafe.sh