Security  

Scanning Uploaded Files for Malware in .NET Applications

Modern apps accept resumes, images, and PDFs—sometimes even archives. That’s also an attack surface. This guide shows how to scan uploads for malware in ASP.NET Core, with production-ready patterns, code, and a drop-in example using ClamAV (free, open-source) plus an optional Windows Defender fallback on Windows hosts.

TL;DR (what you’ll build)

  • A clean upload pipeline:

    1. stream upload → 2) store in quarantine → 3) scan → 4) move to safe storage if clean (else reject/delete).

  • An abstraction with ClamAV and Windows Defender implementations.

  • Controller/action that safely handles file names, sizes, content types, and malware verdicts.

  • EICAR-based tests to verify it works.

Threat model (why this matters)

  • Webshells in images/PDFs, poisoned archives, and macro-laden Office files.

  • Zip bombs and oversized payloads for DoS.

  • Living-off-the-land: tricking servers into executing uploaded files or making thumbnailers parse untrusted formats.

Architecture options

  • Inline (synchronous) scanning: block the request until verdict → simplest, clear UX, OK for small/medium files.

  • Async scanning: accept to quarantine, enqueue scan, and release file only when clean → best for large files / high throughput.

  • ICAP/sidecar: offload scanning to a network service (ClamAV, commercial engines).

This article implements inline first, with an easy path to async later.

Prerequisites & setup

1) Run ClamAV as a sidecar (Docker)

# docker-compose.yml
services:
  clamav:
    image: clamav/clamav:latest
    ports:
      - "3310:3310"   # clamd TCP
    environment:
      - CLAMAV_NO_FRESHCLAM=false  # keep signatures fresh
    restart: unless-stopped

clamd listens on 3310 and supports streaming scans (no need to write the whole file to disk).

/FileScanDemo
  /Services
    IFileScanner.cs
    ClamAvFileScanner.cs
    WindowsDefenderFileScanner.cs   (optional; Windows only)
  /Controllers
    UploadController.cs
  /Options
    UploadOptions.cs
  Program.cs

The scanning abstraction

// Services/IFileScanner.cs
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace FileScanDemo.Services
{
    public record ScanResult(bool IsClean, string? Engine = null, string? Signature = null, string? Raw = null);

    public interface IFileScanner
    {
        Task<ScanResult> ScanAsync(Stream content, string fileName, CancellationToken ct = default);
    }
}

ClamAV streaming scanner (zINSTREAM protocol)

// Services/ClamAvFileScanner.cs
using System;
using System.Buffers.Binary;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace FileScanDemo.Services
{
    public sealed class ClamAvFileScanner : IFileScanner
    {
        private readonly string _host;
        private readonly int _port;
        private readonly int _chunkSize;

        public ClamAvFileScanner(string host = "127.0.0.1", int port = 3310, int chunkSize = 64 * 1024)
        {
            _host = host;
            _port = port;
            _chunkSize = chunkSize;
        }

        public async Task<ScanResult> ScanAsync(Stream content, string fileName, CancellationToken ct = default)
        {
            using var client = new TcpClient();
            client.ReceiveTimeout = 60000;
            client.SendTimeout = 60000;

            await client.ConnectAsync(_host, _port);
            await using var net = client.GetStream();

            // Send zINSTREAM\0
            var cmd = Encoding.ASCII.GetBytes("zINSTREAM\0");
            await net.WriteAsync(cmd, 0, cmd.Length, ct);

            var buffer = new byte[_chunkSize];
            int read;
            while ((read = await content.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
            {
                // send 4-byte length (network byte order) + chunk
                Span<byte> len = stackalloc byte[4];
                BinaryPrimitives.WriteUInt32BigEndian(len, (uint)read);
                await net.WriteAsync(len, ct);
                await net.WriteAsync(buffer.AsMemory(0, read), ct);
            }

            // send 0 length to terminate
            Span<byte> zero = stackalloc byte[4];
            zero.Clear();
            await net.WriteAsync(zero, ct);
            await net.FlushAsync(ct);

            using var ms = new MemoryStream();
            var respBuf = new byte[4096];
            int n;
            while ((n = await net.ReadAsync(respBuf.AsMemory(0, respBuf.Length), ct)) > 0)
                ms.Write(respBuf, 0, n);

            var resp = Encoding.UTF8.GetString(ms.ToArray()).Trim();

            // Typical responses:
            // "stream: OK"
            // "stream: Eicar-Test-Signature FOUND"
            // "stream: <reason> ERROR"
            if (resp.EndsWith("OK", StringComparison.OrdinalIgnoreCase))
                return new ScanResult(true, Engine: "ClamAV", Raw: resp);

            if (resp.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
            {
                var sig = resp.Replace("stream:", "", StringComparison.OrdinalIgnoreCase)
                              .Replace("FOUND", "", StringComparison.OrdinalIgnoreCase)
                              .Replace(fileName, "", StringComparison.OrdinalIgnoreCase)
                              .Trim();
                return new ScanResult(false, Engine: "ClamAV", Signature: sig, Raw: resp);
            }

            // treat unknown/error as unsafe
            return new ScanResult(false, Engine: "ClamAV", Signature: "ScanError", Raw: resp);
        }
    }
}

Optional: Windows Defender fallback (Windows hosts)

// Services/WindowsDefenderFileScanner.cs
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;

namespace FileScanDemo.Services
{
    public sealed class WindowsDefenderFileScanner : IFileScanner
    {
        private readonly string _mpCmdRunPath;

        // Typical path on Server/Windows 10+:
        // C:\Program Files\Windows Defender\MpCmdRun.exe
        public WindowsDefenderFileScanner(string mpCmdRunPath)
        {
            _mpCmdRunPath = mpCmdRunPath;
        }

        public async Task<ScanResult> ScanAsync(Stream content, string fileName, CancellationToken ct = default)
        {
            // Write to a secure temp file for scanning
            var temp = Path.Combine(Path.GetTempPath(), $"scan_{Guid.NewGuid():N}_{Path.GetFileName(fileName)}");
            Directory.CreateDirectory(Path.GetDirectoryName(temp)!);

            await using (var fs = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true))
                await content.CopyToAsync(fs, ct);

            try
            {
                var psi = new ProcessStartInfo
                {
                    FileName = _mpCmdRunPath,
                    Arguments = $"-Scan -ScanType 3 -File \"{temp}\"",
                    RedirectStandardOutput = true,
                    RedirectStandardError = true,
                    UseShellExecute = false,
                    CreateNoWindow = true
                };

                using var proc = Process.Start(psi)!;
                string stdout = await proc.StandardOutput.ReadToEndAsync();
                string stderr = await proc.StandardError.ReadToEndAsync();
                await proc.WaitForExitAsync(ct);

                // ExitCode 0 usually means no threats found
                bool clean = proc.ExitCode == 0;
                return new ScanResult(clean, Engine: "Windows Defender", Signature: clean ? null : "ThreatDetected", Raw: stdout + stderr);
            }
            finally
            {
                try { File.Delete(temp); } catch { /* ignore */ }
            }
        }
    }
}

Upload constraints & options

// Options/UploadOptions.cs
namespace FileScanDemo.Options
{
    public sealed class UploadOptions
    {
        public long MaxBytes { get; set; } = 20 * 1024 * 1024; // 20 MB
        public string[] AllowedExtensions { get; set; } = new[] { ".pdf", ".png", ".jpg", ".jpeg", ".docx" };
        public string QuarantinePath { get; set; } = "App_Data/Quarantine";
        public string SafePath { get; set; } = "App_Data/Safe";
    }
}

Register options and scanners:

// Program.cs
using FileScanDemo.Options;
using FileScanDemo.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<UploadOptions>(builder.Configuration.GetSection("Upload"));
builder.Services.AddSingleton<IFileScanner>(sp =>
{
    // Prefer ClamAV sidecar; change host:port if running elsewhere
    return new ClamAvFileScanner("127.0.0.1", 3310);
    // Or combine: new CompositeScanner(new ClamAvFileScanner(...), new WindowsDefenderFileScanner(@"C:\Program Files\Windows Defender\MpCmdRun.exe"));
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

The secure upload controller (quarantine → scan → move)

// Controllers/UploadController.cs
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using FileScanDemo.Options;
using FileScanDemo.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace FileScanDemo.Controllers
{
    [ApiController]
    [Route("api/uploads")]
    public class UploadController : ControllerBase
    {
        private readonly IFileScanner _scanner;
        private readonly UploadOptions _opts;
        private readonly ILogger<UploadController> _logger;

        public UploadController(IFileScanner scanner, IOptions<UploadOptions> opts, ILogger<UploadController> logger)
        {
            _scanner = scanner;
            _opts = opts.Value;
            _logger = logger;

            Directory.CreateDirectory(_opts.QuarantinePath);
            Directory.CreateDirectory(_opts.SafePath);
        }

        [HttpPost]
        [RequestSizeLimit(25 * 1024 * 1024)] // server-side guard (separate from MaxBytes)
        public async Task<IActionResult> Upload([FromForm] IFormFile file, CancellationToken ct)
        {
            if (file == null || file.Length == 0) return BadRequest("No file.");
            if (file.Length > _opts.MaxBytes) return BadRequest($"File too large. Max {_opts.MaxBytes} bytes.");

            var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
            if (!_opts.AllowedExtensions.Contains(ext))
                return BadRequest("Extension not allowed.");

            // Generate a safe server name and keep the original as metadata
            var safeName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}_{RandomNumberGenerator.GetInt32(int.MaxValue):X8}{ext}";
            var quarantinePath = Path.Combine(_opts.QuarantinePath, safeName);
            var safePath = Path.Combine(_opts.SafePath, safeName);

            // Save to quarantine
            await using (var qfs = new FileStream(quarantinePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 64 * 1024, useAsync: true))
            {
                await using var input = file.OpenReadStream();
                await input.CopyToAsync(qfs, ct);
                qfs.Position = 0; // rewind for scanning

                // Scan in-place
                var scan = await _scanner.ScanAsync(qfs, file.FileName, ct);

                if (!scan.IsClean)
                {
                    _logger.LogWarning("Malware detected by {Engine}. Sig={Sig}. Original={OriginalName}. Raw={Raw}",
                        scan.Engine, scan.Signature, file.FileName, scan.Raw);

                    System.IO.File.Delete(quarantinePath);
                    return BadRequest($"Upload rejected: malware detected ({scan.Engine}: {scan.Signature}).");
                }
            }

            // Move from quarantine to safe storage
            System.IO.File.Move(quarantinePath, safePath, overwrite: false);

            // Return a stable token/ID rather than file system details
            return Ok(new
            {
                id = Path.GetFileNameWithoutExtension(safeName),
                originalName = file.FileName,
                length = file.Length,
                sha256 = await ComputeSha256Async(safePath),
            });
        }

        private static async Task<string> ComputeSha256Async(string path)
        {
            await using var fs = File.OpenRead(path);
            using var sha = SHA256.Create();
            var hash = await sha.ComputeHashAsync(fs);
            return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
        }
    }
}

Key safety notes

  • Never use the user’s original file name on disk.

  • Keep quarantine and safe directories separate; restrict NTFS/POSIX ACLs.

  • Use a the server reverse-proxy limit, and MaxRequestBodySize.

  • Allowlist extensions; never trust MIME type alone.

  • Don’t process (e.g., generate thumbnails) until after a clean verdict.

Going async (optional)

  • Write the file to quarantine and publish a queue message (e.g., Azure Queue, RabbitMQ).

  • A worker service pulls jobs, streams files to ClamAV, and writes verdicts to DB.

  • Only expose/download files whose status = Clean.

  • Re-scan periodically if signatures update (hash cache to avoid duplicates).

Testing with EICAR

Use the industry-standard harmless EICAR string to assert detection:

X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

Save it as eicar.com and upload it; ClamAV should return a FOUND verdict (signature-like). Your API should reject it with a 400 and log the signature.

Operational best practices

  • Timeouts & circuit breakers: if the scanner is down, fail-safe (reject).

  • Zip bomb protection: cap archive depth and decompressed size; avoid server-side auto-unzipping.

  • Metrics: count scanned files, detection rates, scanner latency, and alerts on errors.

  • Privacy: If you use any cloud AV APIs, ensure you have user consent and a data handling policy.

  • Updates: keep AV signatures fresh; monitor the freshness age.

Minimal configuration (appsettings.json)

{
  "Upload": {
    "MaxBytes": 20971520,
    "AllowedExtensions": [ ".pdf", ".png", ".jpg", ".jpeg", ".docx" ],
    "QuarantinePath": "App_Data/Quarantine",
    "SafePath": "App_Data/Safe"
  }
}

Ensure these folders exist and are not web-servable.

Quick checklist (copy/paste into your PR)

  • Max request size is enforced at Kestrel, the reverse proxy, and the controller.

  • Extension allowlist and server-side renaming.

  • Quarantine → scan → safe move; strict ACLs.

  • Scanner timeouts; reject on error.

  • Logs include engine, signature, and hash; PII-safe.

  • EICAR test in CI or staging.

  • Signatures auto-update and are monitored.

What if I can’t run ClamAV?

  • Windows-only: use the Windows Defender implementation shown above.

  • Enterprise: consider ICAP-speaking gateways or commercial multi-engine services (same IFileScanner shape).

  • Containers/K8s: run ClamAV as a DaemonSet or sidecar; point the app to the service DNS.