TL;DR
We forked Apollo, built a custom obfuscator to strip the detectable string and metadata patterns we could identify, and spent months fixing the fallout from a design we never fully controlled. The agent’s architecture assumed stable, readable symbol names at every layer, and that assumption defeated us long before any defender did.
If you’re running Mythic agents or weighing whether to fork an agent vs. building one from scratch, this post will tell you where the architecture will break down before any defender gets the chance. This is the story of what happened when we forked Mythic’s Apollo agent, what “cleaning it up” consisted of, why it wasn’t enough, and how the limits of that approach directly shaped the architectural decisions we made when we finally did start building things from the ground up.
The Problem: Hardened customer environments started consistently flagging our red team operations. Their endpoint detection products were catching our tooling and had improved to the point that the surface-level customization we had previously relied upon no longer bought us much runway.
A coworker who had recently moved to SpecterOps pointed us to Mythic, an open-source command-and-control (C2) framework. The platform’s modular architecture, ability to quickly extend agent functionality, and the ecosystem of existing agents made it appear to be exactly what was needed. Additionally, an upcoming engagement played to the framework’s strengths; requiring application allowlisting bypass, evasion of a well-known endpoint detection and response (EDR) product against which we did not have a working payload, and a limited amount of time with which to work.
Building a custom agent from scratch was not realistic under such constraints; we needed an existing foundation that we could extend quickly. So, we did what most teams in our position would do, and forked Apollo. It was the most mature .NET agent in the Mythic ecosystem, designed for use in SpecterOps training offerings and widely treated as the de facto starting point for agent development. We chose .NET specifically, because we wanted to build AppDomain injection and MSBuild XML payload capabilities for the application allowlisting bypass, and Apollo provided a strong base from which to start.
Forking software means creating an independent copy of an existing project so that it can be modified without affecting the original. Red teams frequently rely on forking to produce private tooling for which no public detection signatures currently exist, without having to start from scratch.
Why Changing Apollo’s Strings Isn’t Enough: Elastic’s YARA Rules and Heuristic Detection
The first change made to the fork was the targeted renaming of strings. Elastic was flagging specific struct and field names in MythicStructs.cs, so we manually changed them to prevent this from happening. We knew exactly which strings to target because Elastic publishes their detection signatures. The Multi_Trojan_Mythic_4beb7e17 YARA rule in the protections-artifacts repository lists eleven Mythic protocol strings and fires at severity 100 if seven of them appear in a file or memory scan:
rule Multi_Trojan_Mythic_4beb7e17 {
meta:
description = "Detects Mythic C2 framework agent communication strings"
author = "Elastic Security"
severity = 100
strings:
$a1 = "task_id"
$a2 = "post_response"
$a3 = "c2_profile"
$a4 = "get_tasking"
$a5 = "tasking_size"
$a6 = "get_delegate_tasks"
$a7 = "total_chunks"
$a8 = "is_screenshot"
$a9 = "file_browser"
$a10 = "is_file"
$a11 = "access_time"
condition:
7 of them
}
Figure 1 – Elastic YARA rule targeting Mythic agents
The unmodified Apollo source contained ten of the eleven indicators defined in Elastic’s Mythic YARA rule. In the first commit to the fork, we renamed six targeted field names (c2_profile, is_file, file_browser, get_tasking, post_response, and is_screenshot), reducing the number of indicators in the compiled binary to below the rule’s threshold.
But even these early manual changes taught us something important. The detectable strings weren’t just cosmetic identifiers, they were baked into the Mythic communication structs themselves. This included the protocol shapes, JSON field names the agent sent back to the server, and the structural patterns of how Mythic agents handled check-ins and task responses. Together, these artifacts provided enough static signal for heuristic and machine learning (ML)-based detections to pick up where simple string matching left off. We saw this when Microsoft Defender’s Wacatac heuristic flagged the compiled binary, with linked Injection and DInvokeResolver assemblies and a hardcoded User-Agent string. Similarly, SentinelOne’s static engine flagged internal identifiers such as IMythicMessage and GetMythicUUID. We had cleaned up the binary’s surface-level indicators while leaving much of its underlying structure intact.
The other problem was feature velocity. Apollo’s task loading model compiles C# task modules server-side using the dotnet build command, then sends the pre-compiled DLL bytes to the agent, which loads them at runtime with Assembly.Load(). Every new capability we added passed through this pipeline, and each one had to be manually reviewed for strings, type names, and other signatures that would re-introduce indicators of compromise (IOCs) we had already removed. We had no systematic way to perform such reviews. The process was entirely ad hoc, making it inconsistent and leaving us unable to confidently assess whether new capabilities would re-introduce known detection signals.
What we needed, was a build-time obfuscation pass that could process the entire binary programmatically, consistently, and repeatably. That need was apparent from the outset, and so the custom obfuscator repository was created the same day as the first commit to our Apollo fork.
Why Off-the-Shelf .NET Obfuscators Fail Against EDR: Building a Detection-Aware Obfuscation Pipeline
The first question we asked was whether we should use an existing .NET obfuscator. We evaluated several well-known options and ultimately decided against them all. Popular obfuscators can introduce recognizable artifacts of their own; predictable symbol-renaming schemes, characteristic control-flow transformations, and identifiable runtime helpers can all provide additional signals for static analysis and ML-based classifiers. Using a known obfuscator therefore risked trading one class of detectable artifacts for another.
Our implementation uses dnlib for managed assembly and intermediate language (IL) transformations, and AsmResolver for post-write portable executable (PE) resource and metadata patching. As the obfuscator matured, its design shifted toward producing plausible, enterprise-style names and metadata rather than output that appeared conspicuously obfuscated. This became a central principle of the project, and every design decision was evaluated against the same question: could the resulting pattern itself become a detection signal?
The obfuscation pipeline ultimately grew to 16 processing steps operating on the in-memory IL representation, with each stage controlled by a configuration flag. Drawing on public research and analysis assisted by large language models (LLMs), we developed the set of transformations shown below. Not every pass was enabled in the final pipeline as several proved too complex to stabilize:
Pass | Production Configuration Usage |
Pre-scan: CollectILProtectedMethods | Active |
Pre-step: BuildInterfaceMap | Active, when symbol renaming is enabled |
Pass 0: RewriteExternalRefs | Optional, disabled by default |
Pass 1: RenameSymbols | Active |
Pass 2: EncryptStrings | Active |
Pass 3: RewriteAssemblyMetadata | Active |
Pass 4: ObfuscateNamespaces | Active |
Pass 5: RenameResourcesAndPatchReferences | Active |
Pass 5b: StripCompilerAttributes | Active |
Pass 5c: ScrubAssemblyRefs | Active |
Pass 6: ObfuscateCustomAttributeStrings | Active |
Pass 7: InjectDeadCode | Active |
Pass 8: FlattenControlFlow | Active |
Pass 9: EncryptMethods | Experimental, disabled by default |
Pass 10: InjectAntiDebug | Active |
Pass 11: PatchFileInfo | Active, post-write, uses AsmResolver |
Figure 2 – Obfuscation pipeline with 16 processing steps and their use in production
We reviewed the major obfuscation techniques for the detection artifacts they might introduce, and some of the conclusions were counterintuitive. Symbol renaming, for example, is a foundational obfuscation technique, but the naming strategy matters. Short sequential identifiers, prefixed hexadecimal values, and other generated names can make the transformation conspicuous.
To avoid these patterns, we built a RealisticNameGenerator class with separate pools of more than 450 enterprise-style names for types, methods, fields, properties, parameters, and events. Namespace remapping followed explicit semantic rules as well: for example, Apollo.Management.C2 became {Company}.Net.Messaging rather than an arbitrary string. The goal was for the resulting metadata to resemble conventional internal .NET tooling rather than advertise that the binary had been obfuscated:
private static readonly string[] TypeNames = new[]
{
"ServiceProvider", "DataContext", "ConfigurationManager",
"RequestHandler", "EventDispatcher", "ResourcePool",
"TokenValidator", "PolicyEvaluator",
…omitted for brevity…
"ComponentFactory", "WidgetRenderer"
};
Figure 3 – The TypeNames pool in RealisticNameGenerator.cs
As a result, the binaries more closely resembled conventional .NET applications than obfuscated software:

String obfuscation replaces eligible ldstr IL instructions as integer tokens and calls to an injected runtime decoder. Each new main build receives a randomly generated 16-byte rolling-XOR key, which is recorded in the obfuscation map so that related assembly runs can reuse the same mapping. Independent builds therefore produce different encoded string data, while oversized or explicitly excluded strings remain unchanged. Randomizing the key eliminates stable encoded-string patterns across builds, but does not inherently disguise the structure of the runtime decoder.
Metadata cleanup selectively removes compiler and debugger attributes that can expose state-machine, generated-code, or debugging information. Separate resource and assembly-reference passes remove known ILRepack, ILMerge, and Costura artifacts and non-framework assembly references. Finally, the pipeline rewrites PE version resources using configured company, product, filename, and description values, falling back to company-themed values when explicit configuration is not provided.
The Apollo Obfuscation Bottleneck: Dynamic Task Loading, ILRepack, and Symbol Name Dependencies
Dynamic loading of new tasks proved to be the most challenging obstacle of the entire effort. By this point, the pipeline was producing per-build randomized binaries that consistently evaded Elastic’s YARA rules and Microsoft Defender’s Wacatac heuristic across tests run between July, 2025, through February, 2026. Dynamic task loading, however, introduced a different problem. We needed to keep the primary binaries small and minimize static detection artifacts while still applying the same obfuscation protections to dynamically loaded capabilities. Reconciling those requirements required increasingly complex integration logic, and the resulting implementation proved fragile and repeatedly broke.
Before our adjustments, Apollo used Costura.Fody to embed companion assemblies marked Copy Local, including the ApolloInterop, Tasks, and Injection assemblies and the transport-profile DLLs. This worked well for packaging and delivery, but Costura stored those dependencies as compressed resources within the outer assembly. Because the custom obfuscator operated only on the IL of the assembly it was given, it could not transform the types and metadata contained within those embedded assemblies. Obfuscating only the outer module would therefore leave Apollo-specific metadata intact, including names such as the ApolloInterop.Interfaces.IAgent interface.
We initially tried obfuscating each DLL independently before Costura packaged them, but the separate passes did not share a common rename map. References to the same type could therefore diverge across assembly boundaries and break runtime type resolution. For example, the main agent might expect a type name Component47293 in the ApolloInterop assembly while ApolloInterop still called it the IAgent interface or independently assigned it something else entirely. Without coordinated cross-assembly renaming, the independently obfuscated modules could no longer reliably resolve one another’s types at runtime.
The solution was to remove Costura from the active build configuration and replace it with ILRepack. Rather than embedding dependencies as resources, ILRepack merged the build-output assemblies into a single managed assembly before obfuscation. Passing that merged assembly through the custom obfuscator provided a unified view of the program’s IL and metadata, allowing a single, consistent rename pass across its types. It also eliminated the companion DLLs as separately named embedded resources and gave us a single artifact to carry through the remainder of the pipeline:

The consistency problem emerged almost immediately. Apollo loads task modules at runtime as pre-compiled DLL bytes, and those modules reference types defined in the base agent. Once obfuscation renamed those types in the merged assembly, newly compiled tasks that still referenced the original names could no longer resolve them. To address this, the custom obfuscator gained map import and export support within its first three weeks, followed shortly afterward by a basic source rewriter. Making that rewriting reliable, however, required roughly seven months of iterative hardening to account for task code, bundled library source, generic and nested types, framework-name collisions, and member references.
After the merged agent is obfuscated, the tool exports its build-specific obfuscation map, which Apollo persists in Mythic as a JSON file associated with the payload build, shown below. This map is operationally sensitive because it contains the symbol-renaming dictionary, the rolling-XOR key used for string obfuscation, and the build’s randomization seed. Possession of the map would allow an analyst to reverse the symbol renaming for that specific build and recover strings protected by the corresponding XOR transformation:

When a dynamic task is later compiled, the builder retrieves the build-specific obfuscation map and supporting type-context information. It then selectively rewrites namespace and type references in temporary copies of the task source before compiling the task against the obfuscated merged assembly. This preserves compatibility between dynamically compiled tasks and the renamed types they reference in the base agent. The following excerpt shows a sample obfuscation map:
{
"types": {
"C2ProfileManager": "CodeEmitter",
"Apollo": "MetricsCollector",
…omitted for brevity…
},
"fields": {
"Apollo.Config.EgressProfiles": "_pool",
…omitted for brevity…
},
"properties": {
"Apollo.Management.Socks.SocksClient.ID": "Headers",
…omitted for brevity…
},
"strings": {
"105": "MythicTask",
…omitted for brevity…
},
"companyName": "Contoso",
"xorKey": "[REDACTED]",
"seed": [REDACTED],
"totalMappings": 16491
}
Figure 7 – Sample obfuscation map
Rather than blindly replacing every occurrence of an original name, the rewriter applies targeted substitutions, reducing the risk of modifying unrelated identifiers or otherwise corrupting the task source. Conceptually, the end-to-end process looks as follows:

The dynamic loading chain remained intact, and tasks compiled after obfuscation correctly referenced the renamed types in the base agent. This approach worked, but it introduced a fragile dependency between the obfuscator and the builder. The builder’s source-rewriting logic needed enough awareness of Apollo’s type system to identify and correctly update every relevant cross-reference. Any change to the obfuscation pipeline (such as a new pass, a modified naming scheme, or changing namespace-remapping behavior) could invalidate assumptions in the map format or rewriting logic and, in turn, break dynamic task loading.
That fragility eventually surfaced during a live operation, when a task failed to initialize and forced us to debug the Mythic builder pipeline in the middle of a time-boxed assessment. The failure reinforced a recurring lesson from the project: an obfuscation technique was only useful if the surrounding build and runtime infrastructure could reliably support it.
The namespace-based discovery mechanism compounded the problem. Apollo originally discovered built-in task implementations by loading the Tasks assembly and registering public classes whose fully qualified names began with the Tasks namespace prefix. The portion of the name following that prefix became the command name; for example, the Tasks.ps class was registered as the ps command. This meant task discovery depended directly on namespace and type names, which was exactly what the obfuscator was designed to change:
foreach(Type t in _tasksAsm.GetTypes())
{
if (t.FullName.StartsWith("Tasks.") &&
t.IsPublic && t.IsClass && t.IsVisible)
{
string commandName = t.FullName.Split('.')[1];
_loadedTaskTypes[commandName] = t;
}
}
Figure 9 – Apollo’s legacy namespace-based task discovery
Namespace remapping immediately broke this discovery mechanism, requiring the registration logic to be adapted to the obfuscated names:
foreach (Type t in exportedTypes)
{
if (t != null &&
t.IsSubclassOf(typeof(Tasking)) &&
!t.IsAbstract)
{
_loadedTaskTypes[t.Name] = t;
}
}
Figure 10 – Replacement hierarchy-based task discovery
Command dispatch still depended on the task class name because the revised registration logic stored each type using its Name property. The build therefore preserved task class names such as ps, ls, and sleep while allowing their namespaces and references to agent types to be renamed. For dynamically compiled tasks, the builder rewrote references to renamed dependencies before compilation so that they matched the corresponding types in the obfuscated merged assembly.
The source-rewriting layer became one of the most fragile parts of the system. Before invoking the server-side dotnet build, Apollo retrieved the payload-specific obfuscation map and used regular expressions and targeted string replacement to modify selected namespace and type references in temporary copies of the task source. Rewriting source code this way was inherently brittle. Although the rewriter contained numerous safeguards for string literals, local declarations, member access, generic types, and namespace collisions, it remained sensitive to formatting, ambiguous identifiers, and mismatches between the task source and the payload-specific map. Edge cases repeatedly exposed those limitations.
The deeper problem was architectural. Apollo was originally designed as a training tool, and many of its core mechanisms such as namespace-based discovery, reflection by name, cross-assembly application programming interfaces (APIs), class-name-based command dispatch, and dynamically compiled task modules, assumed that symbol names would remain stable and readable for their original purpose, but they became load-bearing walls when we attempted to retrofit heavy obfuscation onto the architecture. Changing names and metadata to eliminate one class of detection artifacts routinely violated assumptions elsewhere, producing a repeated cycle where fixing one problem exposed another, as shown by the commit history below:
Commit | Date | Message | Gap |
e2a83df | Aug 7, 2025 | Fixed load feature with obfuscation | +15 days |
594b8b5 | Aug 7, 2025 | fixed builder.py (some tasks were broken with the obfuscated source) | +0 |
a5a374c | Nov 4, 2025 | fixed unobfuscated inline_assembly | +3 months |
965f972 | Feb 18, 2026 | feat: obfuscation hardening - type map support, exclusion config, improved task loading | +3.5 months |
0825dfb | Feb 24, 2026 | Fix DLL export build dependency mismatch | +6 days |
a650968 | Feb 24, 2026 | Fix DLL exports by excluding export method names from obfuscation | +0 |
320e0c0 | Feb 25, 2026 | fixed kerberos compilation issues with new obfuscation | +1 day |
355a6bf | Feb 27, 2026 | bug: fixed inline_assembly | +1 day |
b4ef80d | Feb 27, 2026 | bug: fixed kerberos obfuscation bug | +0 |
4610dd3 | Mar 31, 2026 | Remove IOC strings to evade S1 static detection | +1 month |
Figure 11 – Verbatim commit messages from the Apollo fork, showing repeated cycle of breakages and fixes
These failures were not simply implementation mistakes; they were symptoms of a much deeper mismatch between the obfuscation model and the architecture we were trying to preserve. Each workaround added further complexity without removing the underlying assumptions that made the system fragile. Finally recognizing that distinction became a key turning point in the project.
Designing Mythic Agents for Obfuscation: What We’d Do Differently
The first design document for the greenfield agents opened with a set of constraints drawn directly from the failures outlined here. If we were starting again today, we would make several architectural decisions differently:
- Design for obfuscation from day one — Define explicit symbol contracts before writing the first task handler. Cross-module references, dynamic loading paths, and runtime discovery mechanisms should continue to function when implementation names change. Any mechanism that depends on stable, readable symbol names creates friction with aggressive obfuscation. Design that separation into the architecture from the beginning.
- Treat the obfuscator as a detection surface, not just a solution — Output that looks like“an obfuscated binary”is already a detection class. Realistic output, where binaries look like legitimate enterprise tooling, matters as much as thoroughness. An ML classifier isn’t only looking for known malware signatures; it’s also looking for the patterns that known obfuscators produce.
Randomize across builds — Deterministic transformations can create stable artifacts across otherwise distinct builds. Where practical, build-specific values such as string-obfuscation keys, generated names, and transformation variants should change between builds rather than becoming persistent identifiers of the tooling.
- Budget for the pipeline, not just the payload — Many of the shortcuts we took became engineering problems we had to solve later. Assembly merging, namespace-discovery workarounds, obfuscation-map persistence, and dynamic task rewriting were all consequences of adapting an architecture that had not been designed around these requirements. The
ILRepackmigration alone consumed more time than the initial obfuscator. If an agent’s internals require substantial modification, the surrounding build and runtime infrastructure should be scoped as part of the project rather than treated as a side task. - Know when to stop fighting the fork — At some point, the delta between the agent you forked and the agent you need can become large enough that preserving the original architecture creates more complexity than it saves. We crossed that threshold earlier than we recognized. Once the accumulated compatibility workarounds begin driving the design, it may be more effective to carry the lessons forward into a new architecture, or choose a foundation better aligned with your requirements.
The foundational work here directly informed the custom agents we subsequently built, shaping their architecture, developer workflow, and testing infrastructure. Those agents will be the subject of our next post, along with an open-source release of the Mythic development Model Context Protocol (MCP) server suite we built to support their development and validation. From March, 2026, through September, 2026, the new agents successfully avoided detection by Elastic, Microsoft Defender, Microsoft Defender for Endpoint, CrowdStrike, and SentinelOne in all environments the team encountered during its engagements.
Previously, client requests for a payload ahead of an assumed breach assessment introduced significant uncertainty about whether the tooling would survive the target environment’s detection stack. The new development and testing model substantially reduced that uncertainty. Operators can now run the end-to-end MCP test suite against our internal EDR lab and quickly validate which commands, behaviors, and loader types pass the relevant detection controls before deploying them during an operation.
Subscribe to our blog
Be first to learn about latest tools, advisories, and findings.
Thank You! You have been subscribed.
Recommended Posts
You might be interested in these related posts.
Submit and Execute: Weaponizing Reflected XSS in Siemens Teamcenter's Login Redirect (CVE-2026-58113)
One Port to Root: Weaponizing Check Point Management CVE-2026-93616
Master Key Included: Detecting SolarWinds ARM CVE-2026-28326