Skip to content

Anti-debug protection is ineffective in Safe mode — dnSpy can run and debug protected assemblies #76

Description

@mcpolo99

Problem

The default anti-debug mode (Safe) does not prevent dnSpy from running or debugging protected assemblies. A user can open an obfuscated exe in dnSpy, hit "Start" or even F5 (Debug), and the app runs normally.

Root Cause Analysis

Three modes exist

Mode Checks Strength Default?
Safe `Debugger.IsAttached`, `Debugger.IsLogging()`, `COR_ENABLE_PROFILING` env var Weak Yes
Win32 All Safe checks + native `IsDebuggerPresent()`, parent process name "dnspy", `OutputDebugString`, `CloseHandle` trap Medium No
Antinet Kills the CLR debugger thread by manipulating `DebuggerRCThread` memory + profiler detection Strong No

Why Safe mode fails

  1. dnSpy "Start without debugging": `Debugger.IsAttached` is `false` — the app runs freely
  2. dnSpy "Debug" (F5): The check runs on a background thread with a 500ms initial delay + 1s polling interval. dnSpy attaches before the first check, and there is a race window. Even when detected, the app has already started
  3. No startup-time check: `Initialize()` spawns a thread and returns immediately — the anti-debug check is async, not blocking

Why Win32 mode is better but still incomplete

  • Checks `IsDebuggerPresent()` (native API, harder to bypass than managed)
  • Checks parent process name for "dnspy" — but trivially bypassed by renaming dnspy.exe
  • Same async background thread model with the same race window
  • P/Invoke to kernel32.dll — incompatible with .NET Core/5+/6/7/8/10 on non-Windows

Why Antinet mode is strongest but most limited

  • Kills the CLR debugger thread at the runtime level (de4dot's technique)
  • Hard-coded CLR 2.0 and 4.0 memory offsets — will not work on .NET Core/5+
  • Uses `HandleProcessCorruptedStateExceptions` which is ignored on .NET Core
  • Memory scanning approach is fragile across CLR versions

Weaknesses Summary

Weakness Affects Severity
Safe mode is the default, trivially bypassed All users High
All modes use async polling (500ms delay) All modes Medium
Parent process check is name-based (rename bypass) Win32 Medium
Win32 P/Invoke is Windows-only .NET Core cross-platform Medium
Antinet hard-codes CLR offsets .NET Core/5+ High
No anti-attach prevention (only detection) All modes Medium
`Environment.FailFast(null)` is the only response All modes Low

Proposed Improvements

Phase 1 — Quick wins (low risk)

  1. Change default mode from Safe to Win32 — catches dnSpy parent process and native debugger
  2. Move initial check to startup (blocking) — check before the background thread, inside `Initialize()` directly, so the app never starts if a debugger is attached
  3. Reduce polling interval — 1000ms is too slow, 100-200ms is more appropriate
  4. Remove the 500ms initial sleep in the Worker thread — check immediately

Phase 2 — Hardening (medium risk)

  1. Hash-based process detection instead of name-based — hash the parent process exe bytes, not just the name
  2. Check debug port via `NtQueryInformationProcess(ProcessDebugPort)` — returns non-zero if any debugger is attached, regardless of type
  3. Timing checks — measure execution time of known operations; debugger stepping causes measurable slowdowns
  4. Hardware breakpoint detection — read DR0-DR7 debug registers via `GetThreadContext`
  5. PEB.BeingDebugged — read directly from PEB structure, harder to hook than `IsDebuggerPresent`

Phase 3 — Modern .NET support (high value)

  1. Create a .NET Core-compatible anti-debug mode — can't use Antinet's CLR internals approach. Use:
    • `Debugger.IsAttached` (still works on .NET Core)
    • `EventPipe` / diagnostics port detection
    • `DOTNET_EnableDiagnostics` environment variable check
    • Native `IsDebuggerPresent` via `[DllImport]` (Windows) or `ptrace(PTRACE_TRACEME)` (Linux)
  2. Auto-select mode based on target framework — detect if the target assembly is .NET Framework or .NET Core and choose the appropriate anti-debug implementation

Phase 4 — Response diversity (low risk, high impact)

  1. Vary the response — instead of always calling `Environment.FailFast()`, randomly choose between: silent exit, corrupted output, delayed crash, infinite loop, throw misleading exception
  2. Obfuscate the anti-debug code itself — the Initialize() call in .cctor is a known pattern that deobfuscators strip automatically

Compatibility Considerations

  • Safe mode must remain available for users who need maximum compatibility (some legitimate tools trigger debugger-like behavior)
  • Win32 mode breaks .NET Core on Linux/macOS — needs conditional compilation or runtime OS detection
  • Antinet mode is .NET Framework 2.0-4.x only — document this clearly
  • Any mode change should be opt-in or behind a version bump — changing the default is a breaking behavioral change

Relevant Code

  • `Confuser.Runtime/AntiDebug.Safe.cs` — Safe mode runtime stub
  • `Confuser.Runtime/AntiDebug.Win32.cs` — Win32 mode runtime stub
  • `Confuser.Runtime/AntiDebug.Antinet.cs` — Antinet mode entry point
  • `Confuser.Runtime/antinet/AntiManagedDebugger.cs` — CLR debugger thread killer
  • `Confuser.Runtime/antinet/AntiManagedProfiler.cs` — Profiler detection
  • `Confuser.Protections/AntiDebugProtection.cs:63` — Mode selection (defaults to Safe)
  • `Confuser.Protections/AntiDebugProtection.cs:33` — Preset is Minimum (included in all presets >= Minimum)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions