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:
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.