Mon, 15 Aug 2011

Running ClamAV (Antivirus Software) in Windows Azure

[UPDATE 10/29/2011] I’ve taken down this sample app, so the URL no longer works. The code is still available.

Somewhat regularly, someone asks what antivirus options they have in Windows Azure, usually for the purpose of scanning files uploaded by end-users. The answer I always give is that though Windows Azure doesn’t include any built-in functionality for performing virus scanning, there’s nothing stopping application developers from building this into their applications.

To show how this might be done, I put together an application today that accepts a file upload, stores it in blob storage, and asynchronously (in a worker role) scans it for viruses. It uses ClamAV, an open-source antivirus engine, to detect potential viruses. You can try it yourself at http://antivirus.cloudapp.net.

BIG DISCLAIMER: I’m not an expert on viruses, virus scanners, or security in general. The purpose of this blog post is not to demonstrate security best practices or to make recommendations. This post and accompanying code is meant to be purely educational.

Okay, with that out of the way, let’s move on to the fun stuff.

The Web Front-End

My virus scanning app has a simple front-end that accepts a file upload, and then displays the results of the virus scan once it’s complete. (The actual scan is handled by the back-end.) The front-end is an ASP.NET MVC 3 web role (using the new template in the 1.4 tools release).

The main page is a form (prettied up a bit by Formly) that either accepts a file or uses the “EICAR test” instead. That second option is really cool. The EICAR test is a tiny, well-known sequence of 68 bytes that is completely harmless but should be flagged by most antivirus engines as harmful. This makes it perfect for testing an antivirus solution, and so I included it as an option in my app so users can verify the scanning is working without having to find and upload a piece of true malware.

Here’s the controller action that handles the form submission:

[HttpPost]
public ActionResult Submit(string method)
{
    var guid = Guid.NewGuid().ToString();
    var blob = incomingContainer.GetBlobReference(guid);
    if (method == "file")
    {
        blob.Properties.ContentType = Request.Files[0].ContentType;
        blob.UploadFromStream(Request.Files[0].InputStream);
    }
    else
    {
        string eicar = @"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
        blob.UploadText(eicar);
    }
    incomingQueue.AddMessage(new CloudQueueMessage(guid));
    return RedirectToAction("show", new { id = guid });
}

It uploads either the file submitted or the EICAR test file (yup, that’s it right there in the code) to a blob in the “incoming” directory and then puts a message on a queue so the worker role knows to scan this blob later. Finally, it redirects the browser to a “show” page that simply polls for the blob’s status and shows the results of the scan when they’re ready.

That page uses Superagent to make the AJAX polling easier (cleaner syntax than native jQuery, quite a nice project), and it polls the following action in the controller:

[HttpPost]
public ActionResult Check(string id)
{
    try
    {
        var blob = publicContainer.GetBlobReference(id);
        blob.FetchAttributes();
        return Json(new { done = true, quarantined = blob.Metadata["quarantined"] == "true", url = blob.Uri.AbsoluteUri });
    }
    catch (Exception e)
    {
        return Json(new { done = false });
    }
}

This code looks for the blob to show up in the “public” container. What will show up there is either the original blob (safe to be shared and downloaded by other users), or a replacement blob with the text “File quarantined: possible infection,” and a piece of metadata specifying that its been quarantined. The above method simply returns the completion and quarantine status to the client, where some JavaScript displays the right thing (using the jQuery tmpl plugin).

The Back-End

The worker role is where the antivirus stuff happens. Inside the worker role, I’ve included ClamAV (including the required Visual Studio 2005 C++ redistributable binaries) as “Content.” ClamAV fetches the most up-to-date virus signatures at runtime using a command called “freshclam.” Code in OnStart() creates a directory within a local storage resource, tells “freshclam” to download the signature database to that location, and launches “clamd” (ClamAV as a daemon) configured to use that local storage resource:

var clamPath = RoleEnvironment.GetLocalResource("clamav").RootPath;
Directory.CreateDirectory(clamPath);
Directory.CreateDirectory(Path.Combine(clamPath, "db"));
File.WriteAllText(Path.Combine(clamPath, "clamd.conf"),
    string.Format("TCPSocket 3310\nMaxThreads 2\nLogFile {0}\nDatabaseDirectory {1}",
        Path.Combine(clamPath, "clamd.log"), Path.Combine(clamPath, "db")));

FreshClam(false);
Process.Start(Environment.ExpandEnvironmentVariables(@"%RoleRoot%\approot\clamav\clamd.exe"),
    "-c " + Path.Combine(clamPath, "clamd.conf"));

The implementation of FreshClam(…) is as follows:

private void FreshClam(bool notify)
{
    var clamPath = RoleEnvironment.GetLocalResource("clamav").RootPath;
    var args = string.Format("--datadir={0}", Path.Combine(clamPath, "db"));
    if (notify)
    {
        args += string.Format(" --daemon-notify={0}", Path.Combine(clamPath, "clamd.conf"));
    }
    var proc = Process.Start(Environment.ExpandEnvironmentVariables(@"%RoleRoot%\approot\clamav\freshclam.exe"), args);
    proc.WaitForExit();
    if (proc.ExitCode != 0)
    {
        throw new Exception("freshclam failed");
    }
}

From then on, the code enters a fairly standard loop, checking for new blobs to scan, scanning them, and then moving them to either the “public” or “quarantine” container depending on the results of the scan. Every fifteen minutes, FreshClam is executed again to pull down any new virus signatures:

while (true)
{
    if (DateTime.UtcNow - lastRefresh > TimeSpan.FromMinutes(15))
    {
        FreshClam(true);
        lastRefresh = DateTime.UtcNow;
    }
    var msg = q.GetMessage();
    if (msg != null)
    {
        var name = msg.AsString;
        var blob = incomingContainer.GetBlobReference(name);
        var result = new ClamClient("127.0.0.1", 3310).SendAndScanFile(blob.DownloadByteArray()).Result;
        if (result == ClamScanResults.Clean)
        {
            publicContainer.GetBlobReference(name).CopyFromBlob(blob);
            blob.Delete();
        }
        else
        {
            var publicBlob = publicContainer.GetBlobReference(name);
            publicBlob.Metadata["quarantined"] = "true";
            publicBlob.UploadText("File quarantined: possible infection.");
            quarantineContainer.GetBlobReference(name).CopyFromBlob(blob);
            blob.Delete();
        }
        q.DeleteMessage(msg);
    }
    else
    {
        Thread.Sleep(TimeSpan.FromSeconds(1));
    }
}

ClamClient comes from the nClam library, which, thanks to NuGet, is as easy to acquire as install-package nclam.

I’m only using a single instance of the worker role, because I don’t need high availability. This also lets me not worry about using a unique port number for each instance of “clamd” when running locally. If you try to run this locally with more worker role instances, you’ll probably want to declare an internal endpoint and use the port provided by the runtime instead of hardcoding port 3310.

Try it Out, and Download the Code

You can try the solution at http://antivirus.cloudapp.net.

You’ve seen most of the interesting code right here in this post, but if you want to see all the details, you can download the full Visual Studio solution here: http://cdn.blog.smarx.com/files/AntiVirus_source.zip

Note that I didn’t include the ClamAV or VS redistributable binaries, primarily to keep the zip file size down. If you want to actually run this application, you’ll need to get those binaries and put them in the “clamav” folder within the worker role.