sendmail: 5-MOAD scan; sendmail-0001 CWE-312 SASL client password logged verbatim at debug level

getauth() in usersmtp.c logs all SASL auth fields including password
verbatim when tTd(95,5) debug flag is active. Fix: redact password
field with <REDACTED> at log serialization layer. 6/6 tests PASS.
MOADs 0001/0002/0003/0005 CLEAN.
This commit is contained in:
russell@unturf.com 2026-04-03 13:17:05 -04:00
parent 49edd9e440
commit 0f2062b0cc
4 changed files with 268 additions and 0 deletions

View file

@ -0,0 +1,79 @@
# sendmail-0001 — MOAD-0004 (CWE-312) SASL client password logged verbatim at debug level
**Target:** Sendmail 8.18.1
**File:** `sendmail/usersmtp.c`
**Severity:** MEDIUM
**MOAD:** 0004 (Logged Secret / CWE-312)
**Benchmark:** Any debug-enabled deployment exposes plaintext password to syslog
## Defect
In `getauth()`, when trace flag `tTd(95, 5)` is active (a debug mode that operators
enable by adding `O LogLevel=95` or `-d95.5` to diagnose SASL auth failures),
our code logs each SASL auth field name and value verbatim to syslog:
```c
/* sendmail/usersmtp.c, getauth(), ~line 1000 */
if (tTd(95, 5))
sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s",
sasl_info_name[r], (*sai)[r]);
```
`sasl_info_name[]` contains `{ "user id", "authentication id", "password", "realm", "mechlist" }`.
When `r == SASL_PASSWORD` (index 2), this emits:
```
sendmail[PID]: getauth password=s3cr3t_relay_pass
```
to syslog at LOG_DEBUG. Our syslog typically goes to `/var/log/mail.log` (world-readable
on many systems), to central syslog aggregators, and to SIEM platforms. Any
recipient of our log stream receives our relay authentication credential in plaintext.
Our defect is that our credential denylist does not exist at our log serialization
layer — our raw value is passed directly to `sm_syslog()` without redaction.
## Fix
Redact our password field at our logging callsite. Our fix pattern is a simple
ternary that never suppresses our log line (preserving debug signal) but replaces
our secret value with `"<REDACTED>"`:
```c
if (tTd(95, 5))
sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s",
sasl_info_name[r],
(r == SASL_PASSWORD) ? "<REDACTED>" : (*sai)[r]);
```
Our fix preserves: which field was loaded (name printed), that our load succeeded
(line still appears), and debug traceability (all non-secret fields unchanged).
## MOAD 0001-0005 Scan Results
**MOAD-0001 (CWE-407):** `sasl.c:intersect()` and `usersmtp.c:str_union()` scan
mechanism lists with `iteminlist()` inside a while loop — O(M*N). In practice
N ≤ 10 SASL mechanisms so no scalable defect. **CLEAN** for hotpath purposes.
`recipient()` in `recipient.c` uses a sorted linked list with a sort function
for dedup — O(N) per insert with early exit, not O(N^2). **CLEAN.**
`dochompheader()` in `headers.c` scans existing headers to delete defaults —
O(H) per header with H bounded by distinct header types (< 100). **CLEAN.**
**MOAD-0002 (Intertangle):** sendmail uses `CurEnv` and `BlankEnvelope` as
process-global state, but our process-per-connection fork model means each
SMTP session has its own process address space. No shared mutable state
between concurrent connections. Architectural concern but not a defect
under our current threat model. **CLEAN.**
**MOAD-0003 (Leaked Context):** sendmail is single-threaded per process
(fork model). No `pthread_key_t`, no `__thread`, no thread-local storage.
**CLEAN.**
**MOAD-0004 (CWE-312):** `getauth()` at line 1001 logs SASL password verbatim.
**DEFECT — this ticket.**
**MOAD-0005 (Thundering Herd):** MCI connection cache (`mci.c:mci_cache()`,
`mci_scan()`) is accessed only within a single forked process. No concurrent
goroutines or threads contend on our cache. **CLEAN.**

View file

@ -0,0 +1,17 @@
# UNDF: UNDF-2026-PENDING
--- a/sendmail/usersmtp.c
+++ b/sendmail/usersmtp.c
@@ -998,8 +998,10 @@ getauth(mci, e, sai)
goto fail;
got |= 1 << r;
}
else
goto fail;
if (tTd(95, 5))
- sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s",
- sasl_info_name[r], (*sai)[r]);
+ sm_syslog(LOG_DEBUG, NOQID, "getauth %s=%s",
+ sasl_info_name[r],
+ (r == SASL_PASSWORD) ? "<REDACTED>" : (*sai)[r]);
++i;
}

View file

@ -0,0 +1,171 @@
"""
sendmail-0001 MOAD-0004 (CWE-312)
SASL client password logged verbatim at debug level in getauth()
Simulates our defect (logging a password field verbatim) and our fix
(redacting password field before logging). Asserts our fix never exposes
our plaintext password in our log output.
"""
import sys
import time
export_PYTHONUNBUFFERED = True # reminder: run with python3 -u or PYTHONUNBUFFERED=1
# --- Constants mirroring sendmail's sasl_info_name[] ---
SASL_USER = 0
SASL_AUTHID = 1
SASL_PASSWORD = 2
SASL_DEFREALM = 3
SASL_MECHLIST = 4
sasl_info_name = ["user id", "authentication id", "password", "realm", "mechlist"]
# --- Simulate the SASL auth info array ---
sai = [
"relay_user", # SASL_USER
"relay_authid", # SASL_AUTHID
"s3cr3t_relay_pass", # SASL_PASSWORD
"example.com", # SASL_DEFREALM
"PLAIN LOGIN", # SASL_MECHLIST
]
def getauth_defective(sai):
"""
Simulates our defective getauth() logging path from sendmail/usersmtp.c ~line 1001.
Logs all fields verbatim password is exposed.
"""
log_lines = []
for r in range(len(sai)):
# tTd(95, 5) debug path — logs name=value verbatim
line = "getauth {}={}".format(sasl_info_name[r], sai[r])
log_lines.append(line)
return log_lines
def getauth_fixed(sai):
"""
Simulates our fixed getauth() logging path.
Redacts our password field before logging.
"""
log_lines = []
for r in range(len(sai)):
value = "<REDACTED>" if r == SASL_PASSWORD else sai[r]
line = "getauth {}={}".format(sasl_info_name[r], value)
log_lines.append(line)
return log_lines
def password_exposed_in_log(log_lines, password):
"""Return True if our plaintext password appears anywhere in our log output."""
return any(password in line for line in log_lines)
def password_field_logged(log_lines):
"""Return True if our password field log line appears (field name present)."""
return any("password" in line for line in log_lines)
def run_tests():
password = sai[SASL_PASSWORD]
passed = 0
failed = 0
print("=" * 60)
print("sendmail-0001 — MOAD-0004 CWE-312 unit tests")
print("=" * 60)
# --- Test 1: Defective version exposes password ---
defect_log = getauth_defective(sai)
exposed = password_exposed_in_log(defect_log, password)
if exposed:
print("PASS test1: defective getauth() exposes plaintext password in log")
passed += 1
else:
print("FAIL test1: defective getauth() did not expose password (unexpected)")
failed += 1
# --- Test 2: Fixed version does NOT expose password ---
fixed_log = getauth_fixed(sai)
exposed_after_fix = password_exposed_in_log(fixed_log, password)
if not exposed_after_fix:
print("PASS test2: fixed getauth() does NOT expose plaintext password in log")
passed += 1
else:
print("FAIL test2: fixed getauth() still exposes plaintext password")
failed += 1
# --- Test 3: Fixed version still logs our password field name (traceability) ---
field_present = password_field_logged(fixed_log)
if field_present:
print("PASS test3: fixed getauth() still logs password field name for traceability")
passed += 1
else:
print("FAIL test3: fixed getauth() removed password field log line entirely")
failed += 1
# --- Test 4: Fixed version logs REDACTED marker ---
redacted_present = any("<REDACTED>" in line for line in fixed_log)
if redacted_present:
print("PASS test4: fixed getauth() logs <REDACTED> marker for password field")
passed += 1
else:
print("FAIL test4: fixed getauth() missing <REDACTED> marker")
failed += 1
# --- Test 5: Fixed version logs non-secret fields unchanged ---
username_logged = any("relay_user" in line for line in fixed_log)
if username_logged:
print("PASS test5: fixed getauth() logs non-secret fields (username) unchanged")
passed += 1
else:
print("FAIL test5: fixed getauth() suppressed non-secret field logging")
failed += 1
# --- Test 6: Benchmark — simulate N=100 and N=1000 SASL auth attempts ---
# Our fix adds one integer comparison per field per auth attempt — negligible overhead.
# We assert our fix's overhead is < 10x relative to a plain strcmp (it's O(1)).
import timeit
N = 100000 # auth attempts to benchmark
def bench_defective():
for _ in range(N):
getauth_defective(sai)
def bench_fixed():
for _ in range(N):
getauth_fixed(sai)
t_defective = timeit.timeit(bench_defective, number=1)
t_fixed = timeit.timeit(bench_fixed, number=1)
ratio = t_fixed / t_defective if t_defective > 0 else 1.0
print()
print("Benchmark ({} iterations):".format(N))
print(" Defective: {:.4f}s".format(t_defective))
print(" Fixed: {:.4f}s".format(t_fixed))
print(" Overhead: {:.2f}x (expected < 2x for ternary check)".format(ratio))
if ratio < 2.0:
print("PASS test6: fix overhead < 2x (single ternary, O(1))")
passed += 1
else:
print("FAIL test6: fix overhead unexpectedly large ({:.2f}x)".format(ratio))
failed += 1
print()
print("=" * 60)
total = passed + failed
print("Results: {}/{} PASS".format(passed, total))
if failed == 0:
print("ALL PASS")
return 0
else:
print("FAILURES: {}".format(failed))
return 1
if __name__ == "__main__":
sys.exit(run_tests())