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 */ }
}
}
}
}
Join the conversation! Your thoughts help the community grow.