fuzz: check the result of fopen() in clamav_scanfile_fuzzer
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:
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:5The 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:
--- 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:
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 cwdharness_check.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:
fopenNULL check — fixes the crash. Two lines, no downside.fwritereturn check — a partial write would leavecl_scanfile()scanning a truncated file, silently fuzzing something other than the input. Thesize != 0guard is needed becausefwrite(data, size, 1, f)legitimately returns 0 for a zero-length input, which libFuzzer does produce.TMPDIRinstead of the working directory — addresses whyfopenfailed 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
Source: Cisco-Talos/clamav