From Fork to Framework: What Modifying Apollo Taught Us About Agent Invasion

From Fork to Framework: What Modifying Apollo Taught Us About Agent Invasion

Share

TL;DR
We forked Apollo, built a custom obfuscator to strip every detectable string and metadata pattern, 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 before any defender did.

If you’re running Mythic agents, or weighing whether to fork an existing agent or build 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” actually turned into, why it eventually wasn’t enough, and how the limits of that approach directly shaped the architectural decisions we made when we finally did build from scratch.

The Problem: Hardened clients started consistently flagging our Red Team ops during assessments. Their endpoint detection was catching our tooling and has gotten good enough that the surface-level customization we previously relied on no longer bought us much runway.

A coworker who had moved to SpecterOps pointed us to Mythic, an open-source C2 framework. The platform's modular architecture, the ability to extend agent functionality on the fly, and the ecosystem of existing agents made it look like exactly what we needed. And we had just the job for it: an upcoming engagement that required application whitelisting bypass, bypassing a well-known EDR that we did not have a payload working against, and a very limited amount of time to get something working.

Building a custom agent from scratch wasn’t realistic on that timeline, so we needed an existing foundation we could extend fastSo, we did what most teams would do: we 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 Mythic agent development. We chose .NET specifically because we wanted to build AppDomain injection and MSBuild XML payload capabilities for application whitelisting bypass, and Apollo gave us a solid foundation to add them on top of.

Forking software means creating an independent copy of an existing project so it can be modified without affecting the original. This is a concept that red teams rely on to quickly generate private tooling that is not publicly signature without starting from scratch.

Why Changing Apollo’s Strings Isn’t Enough: Elastic’s Mythic YARA Rule and Behavioral Detection

The first commit on the fork was a targeted rename: Elastic was flagging specific struct and field names in MythicStructs.cs, so we changed them by hand. We knew exactly which strings to target because Elastic publishes their detection signatures. Their Multi_Trojan_Mythic YARA rule in the protections-artifacts repository lists eleven JSON field names from the Mythic C2 protocol and fires at severity 100 if seven of them appear in a file or memory scan.

rule Multi_Trojan_Mythic_4beb7e17 { 
    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 – The public Elastic YARA rule targeting Mythic agents (from elastic/protections-artifacts)

Pre-mitigation Apollo source contained ten of the eleven plaintext indicators in Elastic's Mythic YARA rule. We changed several values and two action-string literals in MythicStructs.cs, reducing the compiled-relevant count to five. Later we removed the active Costura weaving configuration, introduced ILRepack to merge the build-output assemblies, and integrated the first custom obfuscator binary. Costura had embedded dependencies as compressed resources, preventing a conventional assembly-level obfuscation pass from seeing their internal types. ILRepack instead produced a single assembly that could be passed through the obfuscator.

But even the early manual changes taught us something important. The detectable strings weren't just the cosmetic identifiers. They were baked into the Mythic communication structs themselves (which required additional Mythic server changes). The protocol shapes, the field names in the JSON that the agent sends back to the team server, and the structural patterns of how Mythic agents handle check-ins and task responses. All of it carried enough signal that behavioral detection picked up where string detection left off. We'd cleaned the outside of the binary and left the inside alone.

The other problem was feature velocity. Apollo's task loading model works by compiling C# task modules server-side using dotnet build, then pushing the pre-compiled DLL bytes to the agent, which loads them via Assembly.Load(). Every new capability we added went through that pipeline, and each one had to be manually vetted for strings, type names, and signatures that would re-introduce IOCs we'd already scrubbed. There was no systematic approach. It was all ad hoc, which meant it was inconsistent, which meant we were shipping capabilities we weren't fully confident in.

We needed a build-time obfuscation pass that could handle the entire binary programmatically, consistently, and repeatably. The obfuscator repo was created the same day as the first fork commit.

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 of the well-known options, and the answer was no. Not because they don't work, but because they are already well-known to EDR vendors. The obfuscation output patterns that off-the-shelf tools produce are themselves signatures. Widely used obfuscators introduce recognizable artifacts of their own, including predictable symbol-renaming schemes, characteristic control-flow transformations, and identifiable runtime helpers. Endpoint detection products and ML classifiers are trained on the characteristic output of the popular obfuscators. Using a known obfuscator trades one signature set for another. You stop looking like Apollo and start looking like a binary processed by a well-known obfuscator, which isn't a meaningful improvement.

Note: we built 99% of this with the help of Copilot, Codex, and Claude Code to do the grunt work.

Our implementation uses dnlib for managed assembly and IL transformations and AsmResolver for post-write PE resource and metadata patching. During hardening, the design shifted toward producing plausible enterprise-style names and metadata rather than conspicuously obfuscated output. That became a central principle of the project. The pipeline runs over a dozen passes on the in-memory IL representation, each gated by a stage configuration flag. Every design decision was oriented around one question: would this output pattern itself become a detection signal? Based on public knowledge and help from LLMs, this is the list we came up with (they were not all used in the end, a few of them were too complex and consistently broke everything):

// Pre-scan: CollectILProtectedMethods 
// Pre-step: BuildInterfaceMap (when symbol renaming is enabled) 

// Pass 0:  RewriteExternalRefs                 optional 
// Pass 1:  RenameSymbols 
// Pass 2:  EncryptStrings 
// Pass 3:  RewriteAssemblyMetadata 
// Pass 4:  ObfuscateNamespaces 
// Pass 5:  RenameResourcesAndPatchReferences 
// Pass 5b: StripCompilerAttributes 
// Pass 5c: ScrubAssemblyRefs 
// Pass 6:  ObfuscateCustomAttributeStrings 
// Pass 7:  InjectDeadCode 
// Pass 8:  FlattenControlFlow 
// Pass 9:  EncryptMethods                      experimental 
// Pass 10: InjectAntiDebug 

// dnlib module.Write(...) 
  
// Pass 11: PatchFileInfo                       post-write, AsmResolver

Figure 2 – Obfuscation pipeline steps

We reviewed the major obfuscation techniques for the obvious detection artifacts they could introduce, and some of the conclusions were counterintuitive. Symbol renaming is one of the foundational passes, but its naming strategy matters. Predictable generated names, such as short sequential identifiers or prefixed hexadecimal values, make the transformation conspicuous. We, therefore, built a RealisticNameGenerator with separate pools containing more than 450 enterprise-style type, method, field, property, parameter, and event names. Namespace remapping follows explicit semantic rules: for example, Apollo.Management.C2 becomes {Company}.Net.Messaging rather than a random string. The design goal is for the resulting metadata to resemble ordinary internal .NET tooling instead of advertising that the binary has been obfuscated.

private static readonly string[] TypeNames = new[] 
{ 
    "ServiceProvider", "DataContext", "ConfigurationManager", 
    "RequestHandler", "EventDispatcher", "ResourcePool", 
    "TokenValidator", "PolicyEvaluator", 
    // ... ~80 more entries ... 
    "ComponentFactory", "WidgetRenderer" 
};

Figure 3 – The type name pool from RealisticNameGenerator.cs. Output assemblies look like enterprise SDKs, not obfuscated malware.

This produced binaries that looked more like actual applications rather than obfuscated malware:

Figure 4 – Obfuscated Apollo agent decompiled in dnSpy
Figure 4 – Obfuscated Apollo agent decompiled in dnSpy

String encryption rewrites eligible original ldstr instructions as integer tokens followed by calls to an injected decryptor. A new main build normally receives a cryptographically generated 16-byte rolling-XOR key; that key is saved in the obfuscation map, so related assembly runs can use the same mapping. Independent builds therefore normally produce different ciphertext; although, oversized or explicitly filtered strings are left unchanged. Key randomization removes stable ciphertext patterns, but it does not make the decryptor structure inherently unrecognizable.

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 rename non-framework assembly references. Finally, the PE version resources are rewritten using configured company, product, filename, and description values or company-themed fallbacks.

The Apollo Obfuscation Bottleneck: Dynamic Task Loading, ILRepack, and Symbol Name Dependencies

Dynamic loading of new commands proved to be the most challenging obstacle of the entire effort. After the pipeline produced clean polymorphic binaries capable of defeating the target EDR on every attempt, the dynamic task loading mechanism became the next hurdle. We needed to keep the primary binaries slim to avoid introducing unnecessary IOCs that static scans could detect, while also preserving full obfuscation. These competing requirements produced fragile code that broke repeatedly.

Before the ILRepack migration, Apollo used Costura.Fody to embed Copy Local companion assemblies, including ApolloInterop, Tasks, Injection, and the transport-profile DLLs as compressed resources in a single-file managed payload. That worked well for delivery, but it prevented straightforward whole-program obfuscation. The obfuscator operated on the IL of the outer assembly it was given; it did not extract and rewrite the compressed assemblies stored in Costura's resources. Any transformations applied only to the outer module would therefore leave the embedded assemblies' original metadata intact, including Apollo-specific names such as ApolloInterop.Interfaces.IAgent.

We tried obfuscating each DLL separately before Costura packed them, but that meant running multiple independent obfuscation passes with no shared rename map. Type names diverge between assemblies, and the runtime type resolution breaks because the main agent is looking for a type named Component47293 in ApolloInterop while ApolloInterop still calls it IAgent or has renamed it to something different entirely.

The solution was to rip out Costura entirely and replace it with ILRepack. ILRepack merges multiple assemblies into a single binary at the IL level, not as embedded resources. One merged assembly going into the obfuscator means one consistent rename pass across all types, no recognizable DLL names in the output, and a single artifact to track through the pipeline.

Apollo.exe + build-output DLLs 
              | 
              v 
          ILRepack 
              | 
              v 
        merged.exe -------> merged.exe.pre-obfusc 
              | 
              v 
    DotNetObfuscator 
              | 
              v 
            b.exe 
              | renamed over 
              v 
        merged.exe  <- final payload bytes

Figure 5 – Apollo build obfuscation pipeline

The consistency problem arrived almost immediately. Apollo loads task modules at runtime as pre-compiled DLL bytes. Those task DLLs reference types in the base agent. After obfuscation renames those types in the merged assembly, a newly compiled task that references them by their original names can't find them. The obfuscator gained import/export map support within its first three weeks. A basic source rewriter followed almost immediately, but making it safe across task code, bundled library source, generic types, nested types, framework-name collisions, and member references required roughly seven months of iterative hardening.

After the merged agent is obfuscated, the tool exports the rename map, and Apollo persists that JSON in Mythic as a file associated with the payload build, as shown below:

Figure 6 – Persisting the obfuscation map to Mythic's database so dynamically compiled tasks can reference the correct renamed types.
Figure 6 – Persisting the obfuscation map to Mythic's database so dynamically compiled tasks can reference the correct renamed types.

When a dynamic task is compiled later, the builder retrieves the map and supporting type-context information, then selectively rewrites namespace and type references in temporary copies of the task source before compiling them against the obfuscated merged assembly. See below for an obfuscation map sample:

{ 
  "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": "Kqh7b2LLuBPuyypgSQPx2A==", 
  "seed": 589226583, 
  "totalMappings": 16491

Figure 7 – Sample obfuscation map stored in the Mythic database

The process deliberately avoids unsafe textual replacements rather than replacing every occurrence of every original name. Conceptually, the whole flow looks something like this:

Figure 8 – Overview of the Apollo obfuscation pipeline
Figure 8 – Overview of the Apollo obfuscation pipeline

The dynamic loading chain stays intact, and tasks compiled post-obfuscation correctly reference the renamed types in the base agent. What it isn't is elegant. The builder needs source-rewriting logic that understands enough about Apollo's type system to find every cross-reference and patch it correctly. A change to the obfuscation pipeline (a new pass, a changed naming scheme, an update to how namespaces are mapped) can silently break the map format, which breaks the rewriting logic, which breaks dynamic task loading. You discover this during an op when a task fails to initialize, and you have to start debugging a Mythic builder pipeline in the middle of a time-boxed engagement.

The namespace discovery mechanism compounded the problem. Original Apollo discovered built-in task implementations by loading the Tasks assembly and registering public classes whose full names began with Tasks.. The segment after the namespace prefix became the command name: for example, Tasks.ps was registered as ps. Namespace remapping broke that immediately.

// ORIGINAL (commented out after obfuscation broke it): 
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; 
    } 
} 
  

// REPLACEMENT (signature-based, namespace-agnostic): 
foreach (Type t in exportedTypes) 
{ 
    if (t != null && 
        t.IsSubclassOf(typeof(Tasking)) && 
        !t.IsAbstract) 
    { 
        _loadedTaskTypes[t.Name] = t; 
    } 
}

Figure 9 – Apollo's legacy namespace-based task discovery and the hierarchy-based replacement

Command dispatch still depends on the class name because the replacement stores each type under t.Name. The build therefore preserves task class names such as ps, ls, and sleep while allowing their namespaces and referenced agent types to be renamed. Dynamically compiled task source is patched before compilation so references to renamed types such as Tasking match the obfuscated merged assembly.

The source-rewriting layer became one of the most fragile parts of the system. Before server-side dotnet build, Apollo reads the payload-specific obfuscation map and uses regex and string replacement to rewrite selected namespace and type references in temporary copies of the task source. Although the rewriter contains numerous guards for strings, local declarations, member access, generics, and namespace collisions, it remains sensitive to formatting, ambiguous identifiers, and whether the map exactly matches the payload. Edge cases broke it repeatedly.

The deeper problem was architectural. The agent was designed as a training tool. Its namespace-based discovery, reflection by name, cross-assembly APIs, command dispatch through class names, and dynamically compiled task modules all assume stable, readable symbol names throughout the build and runtime pipeline. Those assumptions are reasonable for a training tool. They are load-bearing walls when you try to retrofit heavy obfuscation onto the architecture.

Every time we fixed one detection class, something else in the agent broke. The commit history tells the story:

Commit

Date

Message

Gap

f10f60a

Jul 17, 2025

Modified structs to remove IOCs (elastic)

fec53ba

Jul 21

Modified structs to remove IOCs (elastic) part 2

+4 days

7bbda81

Jul 23, 08:50

Obfuscated COFFLoader DLL (elastic)

+2 days

00f47df

Jul 23, 08:52

Obfuscated COFFLoader DLL (elastic) again...

+2 min

3427942

Feb 26, 2026 20:20

fixed wacatac sig

+7 months

305c9af

Feb 26, 21:11

fixes link, ps, powershell, screenshot_inject, ls, keylog_inject, execute_pe

+51 min

Figure 10 – The whack-a-mole commit timeline. Every obfuscation fix cascaded into broken task modules.

That wasn't a sign that we were making mistakes. It was a sign that we were fighting the architecture, and the architecture was always going to win. That recognition was the turning point.

Designing Mythic Agents for Obfuscation: What We’d Do Differently

If we were starting over today, these are the decisions we would do differently:

  • Design for obfuscation from day one. Define explicit symbol contracts before writing the first task handler. Every cross-module reference, dynamic loading path, and runtime discovery mechanism must function correctly when names are randomized. If a discovery mechanism depends on readable symbol names, it will fail under obfuscation. Build the fallback into the architecture before you need it.
  • 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.
  • Per-build randomization is non-negotiable. Any fixed obfuscation output is a signature with extra steps. The encryption keys, the name pools, the dead code variants: all of it has to be re-seeded per build. Consistency is your enemy.
  • Budget for the pipeline, not just the payload. Every shortcut we took on the Apollo fork became a wall we had to climb over later. The assembly merge logic, the namespace discovery workaround, the obfuscation map persistence: all of them were tactical solutions to architectural problems. The ILRepack migration alone cost more developer-hours than writing the initial obfuscator. If you're going to heavily modify an agent's internals, scope that work as a project, not a side task.
  • Know when to stop fighting the fork. There is a point at which the delta between "the agent you forked" and "the agent you need" is large enough that you are no longer working with the original codebase in any meaningful sense. We crossed that line much earlier than we acknowledged it. The right call at that point is to take what you've learned and build from scratch (or move on to something else entirely).

The Apollo work directly informed the design of the custom agents we built afterward, including the architecture decisions, the developer workflow, and the testing infrastructure. Those agents are the subject of my next blog, along with an open-source release of the Mythic development MCP server suite we built to support the work. The lessons from the fork phase aren't just interesting retrospectively. These greenfield agents directly shaped the reliability of our offensive tooling and consistently bypassed every modern EDR solution the team encountered during engagements.

Previously, client requests for a payload ahead of an assumed breach assessment introduced significant uncertainty about whether the tooling would evade detection. The new agents eliminated that concern entirely. Operators can now execute the end-to-end MCP test suite against the internal EDR lab and confirm which commands, behaviors, and loader types would succeed, all within ten minutes. The first design document for the greenfield agents opened with a list of constraints pulled directly from these failures.


Rob Antonucci Profile Bio

By Rob Antonucci

Sr. Security Consultant

Rob Antonucci (OSCP) is a Senior Security Consultant at Bishop Fox, where he specializes in red teaming, network penetration testing, and purple team engagements. With over a decade in offensive security, he focuses on realistic adversary simulation against Fortune 500 enterprises — emulating the tradecraft of real-world threat actors to measure and strengthen how organizations detect and respond to attacks.

Subscribe to our blog

Be first to learn about latest tools, advisories, and findings.