clamav: 5-MOAD scan; clamav-0001 CWE-312 proxy password logged verbatim on curl failure

MOAD-0001 (CWE-407): CLEAN. AC trie, BM hash, and hash tables used throughout
our hot scan paths. No linear membership scan in our critical code paths.

MOAD-0002 (Intertangle): CLEAN. cl_engine is read-only during scan;
cli_ctx is stack-local per scan_common invocation.

MOAD-0003 (Leaked Context): CLEAN. No pthread thread-locals for scan
identity; cli_ctx is per-scan-invocation stack variable.

MOAD-0004 (Logged Secret): clamav-0001 — create_curl_handle() in
libfreshclam/libfreshclam_internal.c:735 logs g_proxyPassword verbatim
in our LOGG_ERROR path when curl_easy_setopt(CURLOPT_PROXYPASSWORD) fails.
FreshClam logs are world-readable by default on Linux. Fix: remove our
credential from our error message, retain our option name for debugging.

MOAD-0005 (Thundering Herd): CLEAN. cache.c uses splay-tree-per-bucket
with pthread_mutex_t protecting each bucket on every access.
This commit is contained in:
russell@unturf.com 2026-04-03 13:08:08 -04:00
parent 5e1c00c890
commit 01b9562d57
4 changed files with 267 additions and 0 deletions

View file

@ -8,6 +8,7 @@ Rule: clone, scan, delete clone after. Keep disk under 90%.
- [ ] Squid (C, HTTP proxy, huge install base)
- [ ] PgBouncer (C, PostgreSQL connection pooler)
- [x] Suricata (C, IDS/IPS) — suricata-0001 (CWE-407 threshold SID lookup O(T×S), CWE-312 auth header logging); suricata-0002 (CWE-407 EveHttpLogJSONHeaders O(H×F=53) per tx, 53x); MOADs 0003/0005 CLEAN
- [x] ClamAV (C, antivirus engine) — clamav-0001 MOAD-0004 CWE-312 proxy password logged verbatim on curl failure; MOAD-0001/0002/0003/0005 CLEAN (AC trie, BM hash, mutex-protected cache)
- [ ] Snort (C, IDS/IPS)
- [ ] WireGuard (deeper, Go userspace tools)
- [ ] Thunderbird (C++, email client, undo/history)

View file

@ -0,0 +1,71 @@
# clamav-0001 — CWE-312 Proxy password logged verbatim on curl setup failure
**Target:** ClamAV (Cisco-Talos/clamav, current HEAD)
**File:** `libfreshclam/libfreshclam_internal.c`
**Function:** `create_curl_handle`
**Severity:** MEDIUM
**MOAD:** 0004 (CWE-312 — Cleartext Storage/Logging of Sensitive Information)
**Impact:** Proxy authentication password exposed in log output on curl failure
## Defect
When freshclam initializes a libcurl handle with proxy credentials, it logs
the raw password string in the error path if `curl_easy_setopt` fails:
```c
// libfreshclam/libfreshclam_internal.c:734-735
if (CURLE_OK != curl_easy_setopt(curl, CURLOPT_PROXYPASSWORD, g_proxyPassword)) {
logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD (%s)!\n", g_proxyPassword);
}
```
`g_proxyPassword` is a global holding our proxy authentication password, read
from the FreshClam configuration file. If `curl_easy_setopt` fails (for
example, if the curl handle is corrupted or the option is unsupported in our
libcurl build), our password is emitted to `freshclam.log` and to stderr.
FreshClam logs are world-readable by default on many Linux distributions
(`/var/log/clamav/freshclam.log`, mode 0644). Any local user can read them.
## Fix
Remove `%s` and `g_proxyPassword` from our error log message. Our error log
should confirm that the option-set operation failed, not echo back our
credential:
```c
// Fixed: password redacted from error message
if (CURLE_OK != curl_easy_setopt(curl, CURLOPT_PROXYPASSWORD, g_proxyPassword)) {
logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD!\n");
}
```
Same fix applies to CURLOPT_PROXYUSERNAME on line 732, which logs our proxy
username (not a password, but still potentially sensitive identity information).
## MOAD 0001-0005 Scan Results
- **MOAD-0001 (CWE-407):** CLEAN. Our hot scan paths use the Aho-Corasick
trie (O(N) per byte stream), Boyer-Moore hash lookup (O(1) per block), and
hash tables throughout. No linear membership scan confirmed in any hot path.
Minor bounded O(N*256) in icon group dedup during DB load (G capped at 256).
Minor O(N^2) in CRB cert dedup during DB load but CRB files are tiny (<200
entries) and loaded once at startup.
- **MOAD-0002 (Intertangle):** CLEAN. `cl_engine` is treated as immutable
during scan. Per-scan state is isolated in `cli_ctx` (stack-local in
`scan_common`). The engine is compiled once by `cl_engine_compile`, then
read-only for all scan threads.
- **MOAD-0003 (Leaked Context):** CLEAN. No `pthread_getspecific`,
`pthread_setspecific`, or `__thread` thread-locals found in scan paths.
`cli_ctx` is allocated per-scan-invocation as a stack variable, not as a
thread-scoped carrier.
- **MOAD-0004 (Logged Secret):** **DEFECT FOUND** — see above.
`g_proxyPassword` logged verbatim on curl setup failure.
- **MOAD-0005 (Thundering Herd):** CLEAN. `libclamav/cache.c` uses a
splay-tree-per-bucket design with `pthread_mutex_t` protecting each bucket.
Cache lookup and insert are always mutex-guarded (`pthread_mutex_lock` at
line 549, unlocked at 559).

View file

@ -0,0 +1,14 @@
--- a/libfreshclam/libfreshclam_internal.c
+++ b/libfreshclam/libfreshclam_internal.c
@@ -729,9 +729,9 @@ static CURL *create_curl_handle(const char *server, bool bCheckCert)
if (g_proxyUsername) {
if (CURLE_OK != curl_easy_setopt(curl, CURLOPT_PROXYUSERNAME, g_proxyUsername)) {
- logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYUSERNAME (%s)!\n", g_proxyUsername);
+ logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYUSERNAME!\n");
}
if (CURLE_OK != curl_easy_setopt(curl, CURLOPT_PROXYPASSWORD, g_proxyPassword)) {
- logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD (%s)!\n", g_proxyPassword);
+ logg(LOGG_ERROR, "create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD!\n");
}
}
}

View file

@ -0,0 +1,181 @@
"""
test_clamav_0001.py CWE-312 proxy password logged verbatim
ClamAV freshclam's create_curl_handle() logs g_proxyPassword verbatim in our
error path when curl_easy_setopt(CURLOPT_PROXYPASSWORD) fails.
This test simulates our defect and our fix by checking whether a password
value appears in a log message produced when a credential setup fails. We
measure string-match work done in each case and assert that our fixed version
does not expose our credential.
"""
import sys
import time
import re
export_PYTHONUNBUFFERED = 1 # reminder: run with PYTHONUNBUFFERED=1
# ---------------------------------------------------------------------------
# Simulate defect: log helper that DOES include our credential value
# ---------------------------------------------------------------------------
def log_proxy_password_defect(password: str) -> str:
"""Simulate our defective log line that includes our proxy password."""
return f"create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD ({password})!\n"
def log_proxy_password_fixed(password: str) -> str:
"""Simulate our fixed log line that omits our proxy password."""
_ = password # credential is consumed by curl, never reaches our log
return "create_curl_handle: Failed to set CURLOPT_PROXYPASSWORD!\n"
# ---------------------------------------------------------------------------
# Check: does our credential appear in the logged string?
# ---------------------------------------------------------------------------
def credential_visible_in_log(log_line: str, credential: str) -> bool:
"""Return True if our credential value is present in our log output."""
return credential in log_line
# ---------------------------------------------------------------------------
# Performance simulation: at N=100 and N=1000 log entries, measure work
# for a log scrubber that must find and redact secrets post-hoc
# ---------------------------------------------------------------------------
def scrub_log_entries_linear(entries: list, credential: str) -> int:
"""
Simulate our naive post-hoc log scrubber: scan every log entry for our
credential string. This is O(N * L) where N = entries, L = line length.
Returns count of lines that needed redaction.
"""
found = 0
for entry in entries:
if credential in entry:
found += 1
return found
def scrub_log_entries_set(entries: list, credential: str) -> int:
"""
Optimized scrubber using a set of known secrets for O(1) membership.
Still O(N) over entries but avoids per-char scanning for each secret.
Returns count of lines that needed redaction.
"""
secret_set = {credential}
found = 0
for entry in entries:
# In practice this would use a compiled regex or Aho-Corasick.
# Here we simulate O(1) set lookup after extracting token.
for secret in secret_set:
if secret in entry:
found += 1
break
return found
def benchmark(n: int, credential: str) -> tuple:
"""
Build N log entries (half defective, half fixed), run both scrubbers.
Returns (defective_time_ms, fixed_time_ms, ratio).
"""
defective_entries = [log_proxy_password_defect(credential) for _ in range(n // 2)]
fixed_entries = [log_proxy_password_fixed(credential) for _ in range(n - n // 2)]
all_entries = defective_entries + fixed_entries
# Defect scenario: scrubber must scan every line
t0 = time.perf_counter()
for _ in range(100):
scrub_log_entries_linear(all_entries, credential)
defective_time = (time.perf_counter() - t0) * 1000 / 100
# Fixed scenario: no credentials in log, scrubber finds nothing
# (representative of O(N) scan with no matches = fast path)
clean_entries = [log_proxy_password_fixed(credential) for _ in range(n)]
t1 = time.perf_counter()
for _ in range(100):
scrub_log_entries_linear(clean_entries, credential)
fixed_time = (time.perf_counter() - t1) * 1000 / 100
# Ratio: how much more work the defective case creates per scan
ratio = defective_time / fixed_time if fixed_time > 0 else float("inf")
return defective_time, fixed_time, ratio
# ---------------------------------------------------------------------------
# Main test
# ---------------------------------------------------------------------------
def main():
password = "s3cr3tProxyP@ssw0rd!"
all_passed = True
print("=== clamav-0001: CWE-312 Proxy password in log ===\n")
# --- Correctness checks ---
defect_line = log_proxy_password_defect(password)
fixed_line = log_proxy_password_fixed(password)
# 1. Defect: password IS in our log output
result = credential_visible_in_log(defect_line, password)
status = "PASS" if result else "FAIL"
print(f"[{status}] Defect: password visible in log = {result}")
if not result:
all_passed = False
# 2. Fix: password NOT in our log output
result = not credential_visible_in_log(fixed_line, password)
status = "PASS" if result else "FAIL"
print(f"[{status}] Fix: password NOT visible in log = {result}")
if not result:
all_passed = False
# 3. Fix log still mentions our option name (useful for debugging)
result = "CURLOPT_PROXYPASSWORD" in fixed_line
status = "PASS" if result else "FAIL"
print(f"[{status}] Fix: log still names the option = {result}")
if not result:
all_passed = False
# 4. Defect log contains our password as a %s format expansion
pattern = re.compile(r"\(([^)]+)\)")
match = pattern.search(defect_line)
extracted = match.group(1) if match else ""
result = extracted == password
status = "PASS" if result else "FAIL"
print(f"[{status}] Defect: extracted credential from log = '{extracted}' (expected '{password}')")
if not result:
all_passed = False
print()
# --- Performance benchmarks ---
for n in [100, 1000]:
d_ms, f_ms, ratio = benchmark(n, password)
print(f"N={n}: defective scrub={d_ms:.3f}ms, fixed (no matches)={f_ms:.3f}ms, ratio={ratio:.2f}x")
# The scrubber must do more work on defective logs because it finds matches.
# We assert the defective scenario is at least 1.0x (may be similar since both O(N)).
if ratio >= 1.0:
print(f"[PASS] N={n}: defective log requires >= 1.0x scrubbing work (ratio={ratio:.2f}x)")
else:
print(f"[FAIL] N={n}: expected defective >= fixed scrub work, got {ratio:.2f}x")
all_passed = False
print()
# --- Summary ---
if all_passed:
print("ALL PASS")
return 0
else:
print("SOME TESTS FAILED")
return 1
if __name__ == "__main__":
sys.exit(main())