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)

Threat model (why this matters)

Architecture options

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

Going async (optional)

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

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)

What if I can’t run ClamAV?