#1811·clamav

fuzz: check the result of fopen() in clamav_scanfile_fuzzer

Author: Namron2000Created Aug 23, 2026Updated Aug 23, 2026

Problem

fuzz/clamav_scanfile_fuzzer.cpp writes the input to a temporary file before handing it to cl_scanfile(), but does not check whether the file was opened:

c
fuzzfile = fopen(tmp_file_name, "w");
fwrite(data, size, 1, fuzzfile);        // line 117
fclose(fuzzfile);

When fopen() fails, fuzzfile is NULL and fwrite() dereferences it, so the fuzzer dies with a SEGV on address 0 that looks like a finding but is not:

==80==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
SCARINESS: 10 (null-deref)
    #0 _IO_fwrite
    #1 fwrite
    #2 LLVMFuzzerTestOneInput  fuzz/clamav_scanfile_fuzzer.cpp:117:5

The scanned input has nothing to do with it — the crash occurs before cl_scanfile() is reached, so every input in the corpus "crashes" identically.

fopen() fails easily because tmp_file_name is relative, so the harness writes into whatever the current working directory happens to be. Running the target from a read-only directory is enough to reproduce it; that is how we hit it, replaying corpora with the working directory set to /out. Each of the six formats this harness is built for (PE, ELF, HTML, HWP3, OLE2, SWF) reports it separately, so a single missing check turns into six phantom findings.

Fix

The essential part is the fopen check; the rest removes the reason it failed in the first place. The complete patch:

diff
--- a/fuzz/clamav_scanfile_fuzzer.cpp
+++ b/fuzz/clamav_scanfile_fuzzer.cpp
@@ -111,10 +111,22 @@
 
     __pid_t pid = getpid();
 
-    snprintf(tmp_file_name, sizeof(tmp_file_name), "tmp.scanfile.%d", pid);
+    const char* tmpdir = getenv("TMPDIR");
+    if (NULL == tmpdir) {
+        tmpdir = "/tmp";
+    }
 
+    snprintf(tmp_file_name, sizeof(tmp_file_name), "%s/tmp.scanfile.%d", tmpdir, pid);
+
     fuzzfile = fopen(tmp_file_name, "w");
-    fwrite(data, size, 1, fuzzfile);
+    if (NULL == fuzzfile) {
+        return 0;
+    }
+    if (size != 0 && 1 != fwrite(data, size, 1, fuzzfile)) {
+        fclose(fuzzfile);
+        unlink(tmp_file_name);
+        return 0;
+    }
     fclose(fuzzfile);
 
     const char* virus_name = nullptr;

Reproducing

No reproducer file is attached, because the input plays no role: the crash is at line 117, before cl_scanfile() is reached, so any bytes trigger it whenever fopen fails. The condition is environmental, not input-based — tmp_file_name is relative, so the harness writes into whatever the working directory happens to be.

The program below isolates the write step -- the before function is the current code verbatim, with only the cl_scanfile() call that follows it omitted, since the crash happens first. It needs no ClamAV build:

Save the source below as harness_check.c, then:

bash
cc -O0 -g harness_check.c -o harness_check
mkdir ro && chmod 555 ro && cd ro

../harness_check before     # exit 139 (SIGSEGV): fopen failed, fwrite(NULL)
../harness_check after      # exit 0

cd .. && ./harness_check before   # exit 0 -- writable cwd
harness_check.c
c
/*
 * Standalone demonstration of the clamav_scanfile_fuzzer.cpp issue.
 *
 * Reproduces the NULL dereference without building ClamAV: it isolates the
 * write step of LLVMFuzzerTestOneInput() as it is today ("before") and as it
 * would be with this PR applied ("after").
 *
 *   cc -O0 -g harness_check.c -o harness_check
 *   mkdir ro && chmod 555 ro && cd ro
 *   ../harness_check before     # exit 139 (SIGSEGV) -- fopen failed, fwrite(NULL)
 *   ../harness_check after      # exit 0
 *   cd .. && ./harness_check before   # exit 0 -- writable cwd, which is why
 *                                     # oss-fuzz's own runner never sees this
 *
 * The 'before' path is the current upstream code verbatim; only the ClamAV
 * scan call that follows it is omitted, since the crash happens before it.
 */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

/* fuzz/clamav_scanfile_fuzzer.cpp:111-119 as it stands today */
static void before(const unsigned char *data, size_t size, int pid)
{
    FILE *fuzzfile = NULL;
    char tmp_file_name[200] = {0};

    snprintf(tmp_file_name, sizeof(tmp_file_name), "tmp.scanfile.%d", pid);

    fuzzfile = fopen(tmp_file_name, "w");
    fwrite(data, size, 1, fuzzfile);        /* line 117: fuzzfile may be NULL */
    fclose(fuzzfile);
}

/* the same region with this PR applied */
static int after(const unsigned char *data, size_t size, int pid)
{
    FILE *fuzzfile = NULL;
    char tmp_file_name[200] = {0};

    const char *tmpdir = getenv("TMPDIR");
    if (NULL == tmpdir) {
        tmpdir = "/tmp";
    }

    snprintf(tmp_file_name, sizeof(tmp_file_name), "%s/tmp.scanfile.%d", tmpdir, pid);

    fuzzfile = fopen(tmp_file_name, "w");
    if (NULL == fuzzfile) {
        return 0;
    }
    if (size != 0 && 1 != fwrite(data, size, 1, fuzzfile)) {
        fclose(fuzzfile);
        unlink(tmp_file_name);
        return 0;
    }
    fclose(fuzzfile);
    unlink(tmp_file_name);
    return 1;
}

int main(int argc, char **argv)
{
    unsigned char buf[64];
    memset(buf, 0x41, sizeof buf);

    if (argc < 2) {
        fprintf(stderr, "usage: %s before|after\n", argv[0]);
        return 2;
    }
    if (0 == strcmp(argv[1], "before")) {
        before(buf, sizeof buf, getpid());
        puts("before: survived (the working directory was writable)");
    } else {
        printf("after: %s\n", after(buf, sizeof buf, getpid())
                                  ? "wrote the file, scan would proceed"
                                  : "fopen failed, input skipped cleanly");
    }
    return 0;
}
working directory current code with this patch
not writable exit 139 (SIGSEGV) exit 0
writable exit 0 exit 0

The last row is why oss-fuzz's own runner never reports this: it runs the target from a writable directory. We hit it replaying corpora with the working directory set to /out; the casr report for that run records PWD=/out and the stack quoted under Problem above.

Because the harness source is compiled once per format, with the CLAMAV_FUZZ_* defines only affecting scanopts.parse — which is consumed after the crash point — LLVMFuzzerTestOneInput is identical in all six binaries. All six formats (PE, ELF, HTML, HWP3, OLE2, SWF) report it separately, and one fix covers all of them.

Notes

Three separate things, and you may want only the first:

  1. fopen NULL check — fixes the crash. Two lines, no downside.
  2. fwrite return check — a partial write would leave cl_scanfile() scanning a truncated file, silently fuzzing something other than the input. The size != 0 guard is needed because fwrite(data, size, 1, f) legitimately returns 0 for a zero-length input, which libFuzzer does produce.
  3. TMPDIR instead of the working directory — addresses why fopen failed at all. The harness currently only works when run from a writable directory.

One design question for you: returning 0 on failure makes the harness skip inputs silently, so a misconfigured environment looks like a clean run with no coverage rather than an error. If you would rather it be visible, a perror("fopen") before the return 0 gives diagnosability without reintroducing a crash.


The patch applies cleanly to main, rel/1.5, rel/1.4 and rel/1.0 — the file has not changed across them — so backporting is a straight cherry-pick if you want it on the release branches.

Best regards, The Fandango Cispa Team