From 079ad04f0e4fa76deda73de2f8162648a29006b3 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 3 Apr 2026 14:02:01 -0400 Subject: [PATCH] pidgin: 5-MOAD scan; pidgin-0001 CWE-407 499.5x, pidgin-0002 CWE-312; add Python tests + TICKET.md, mark SCAN-TODO done --- SCAN-TODO.md | 2 +- defects/pidgin-0001/TICKET.md | 44 +++++ defects/pidgin-0001/test/test_pidgin_0001.py | 136 +++++++++++++++ defects/pidgin-0002/TICKET.md | 49 ++++++ defects/pidgin-0002/test/test_pidgin_0002.py | 165 +++++++++++++++++++ 5 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 defects/pidgin-0001/TICKET.md create mode 100644 defects/pidgin-0001/test/test_pidgin_0001.py create mode 100644 defects/pidgin-0002/TICKET.md create mode 100644 defects/pidgin-0002/test/test_pidgin_0002.py diff --git a/SCAN-TODO.md b/SCAN-TODO.md index c663f0ace..a86eed7ea 100644 --- a/SCAN-TODO.md +++ b/SCAN-TODO.md @@ -33,7 +33,7 @@ Rule: clone, scan, delete clone after. Keep disk under 90%. ## Priority 4 — Desktop apps -- [ ] Pidgin/Finch (C, chat client) +- [x] Pidgin/Finch (C, chat client) — pidgin-0001 CWE-407 499.5x, pidgin-0002 CWE-312 - [ ] HexChat (C, IRC client) - [ ] Evolution (C, email/calendar) - [x] Thunderbird (C++, email) — see Priority 1 entry above; 8 defects total diff --git a/defects/pidgin-0001/TICKET.md b/defects/pidgin-0001/TICKET.md new file mode 100644 index 000000000..509a3ac7f --- /dev/null +++ b/defects/pidgin-0001/TICKET.md @@ -0,0 +1,44 @@ +# pidgin-0001 — CWE-407 add_all_buddies_to_permit_list O(B^2) GSList scan + +**MOAD:** 0001 (CWE-407 Sedimentary Defect) +**Severity:** MEDIUM +**Ratio:** 499.5x at B=1000 +**UNDF:** UNDF-2026-000001141 + +## Location + +`libpurple/privacy.c` — `add_all_buddies_to_permit_list()` + +## Pattern + +`add_all_buddies_to_permit_list()` synchronizes our account permit (allow) list +with our buddy list. It calls `purple_find_buddies(account, NULL)` to get all B +buddies, then iterates them. For each buddy it calls: + +```c +g_slist_find_custom(account->permit, name, (GCompareFunc)g_utf8_collate) +``` + +`account->permit` is a GSList. As buddies are added our permit list grows. +Each `g_slist_find_custom` scans our entire growing list: O(B^2/2) total. + +With 1000 contacts, switching privacy modes triggers 499,500 string comparisons +instead of 1,000. + +Called from `purple_privacy_allow()` and `purple_privacy_deny()` when switching +from `ALLOW_BUDDYLIST` mode. + +## Fix + +Before our buddy iteration loop, snapshot `account->permit` into a `GHashTable` +for O(1) membership tests. Replace `g_slist_find_custom()` with +`g_hash_table_lookup()`. Destroy our snapshot after our loop. + +## Speedup + +| B (buddies) | Defective ops | Fixed ops | Ratio | +|-------------|---------------|-----------|-------| +| 100 | 4,950 | 100 | 49.5x | +| 500 | 124,750 | 500 | 249.5x | +| 1,000 | 499,500 | 1,000 | 499.5x | +| 2,000 | 1,999,000 | 2,000 | 999.5x | diff --git a/defects/pidgin-0001/test/test_pidgin_0001.py b/defects/pidgin-0001/test/test_pidgin_0001.py new file mode 100644 index 000000000..e4f3fcf75 --- /dev/null +++ b/defects/pidgin-0001/test/test_pidgin_0001.py @@ -0,0 +1,136 @@ +""" +pidgin-0001: add_all_buddies_to_permit_list O(B^2) GSList scan + +libpurple/privacy.c: add_all_buddies_to_permit_list() iterates all B buddies +and for each calls g_slist_find_custom(account->permit, ...) which scans our +growing permit GSList linearly. As buddies are added the list grows to B, +making total comparisons O(B^2/2). + +Fix: snapshot account->permit into a GHashTable before our loop so each +membership test is O(1), reducing total work to O(B). + +CWE-407: Algorithmic Complexity +UNDF: UNDF-2026-000001141 +""" + +import time +import sys + +PYTHONUNBUFFERED = 1 # ensure unbuffered output + + +def add_buddies_defective(buddies: list[str]) -> int: + """ + Models the defective C code: + for each buddy in find_buddies(account): + if not g_slist_find_custom(account->permit, name, g_utf8_collate): + purple_privacy_permit_add(account, name, local) + + g_slist_find_custom scans account->permit linearly. + As our permit list grows, each scan takes longer: O(B^2) total. + Returns operation count. + """ + permit = [] # GSList analog + ops = 0 + for buddy in buddies: + # linear scan of permit list + found = False + for p in permit: + ops += 1 + if p == buddy: + found = True + break + if not found: + permit.append(buddy) + return ops + + +def add_buddies_fixed(buddies: list[str]) -> int: + """ + Models our fix: + permit_set = g_hash_table_new(g_str_hash, g_str_equal) + for p in account->permit: + g_hash_table_add(permit_set, p) + for each buddy in find_buddies(account): + if not g_hash_table_lookup(permit_set, name): + purple_privacy_permit_add(account, name, local) + g_hash_table_destroy(permit_set) + + Each lookup is O(1): total work is O(B). + Returns operation count. + """ + permit_set = set() + ops = 0 + for buddy in buddies: + ops += 1 # O(1) hash lookup + if buddy not in permit_set: + permit_set.add(buddy) + return ops + + +def make_buddies(n: int) -> list[str]: + return [f"buddy{i}@example.com" for i in range(n)] + + +def bench(n: int) -> tuple[float, float, float]: + buddies = make_buddies(n) + + t0 = time.perf_counter() + ops_def = add_buddies_defective(list(buddies)) + t1 = time.perf_counter() + ops_fix = add_buddies_fixed(list(buddies)) + t2 = time.perf_counter() + + ratio = ops_def / ops_fix + return ops_def, ops_fix, ratio + + +def test_correctness(): + """Both paths produce same deduplication result.""" + buddies = ["alice@x.com", "bob@x.com", "alice@x.com", "carol@x.com"] + + # defective: simulate permit list construction + permit_def = [] + for b in buddies: + if b not in permit_def: + permit_def.append(b) + + # fixed: hash set + permit_fix = list(dict.fromkeys(buddies)) + + assert permit_def == permit_fix, f"Mismatch: {permit_def} vs {permit_fix}" + print("correctness: PASS") + + +def test_speedup_100(): + ops_def, ops_fix, ratio = bench(100) + print(f"B=100: defective={ops_def} ops, fixed={ops_fix} ops, ratio={ratio:.1f}x") + assert ratio > 20, f"Expected >20x speedup at B=100, got {ratio:.1f}x" + print("speedup B=100: PASS") + + +def test_speedup_1000(): + ops_def, ops_fix, ratio = bench(1000) + print(f"B=1000: defective={ops_def} ops, fixed={ops_fix} ops, ratio={ratio:.1f}x") + assert ratio > 200, f"Expected >200x speedup at B=1000, got {ratio:.1f}x" + print("speedup B=1000: PASS") + + +if __name__ == "__main__": + import os + os.environ["PYTHONUNBUFFERED"] = "1" + + print("pidgin-0001: add_all_buddies_to_permit_list O(B^2) -> O(B)") + print("=" * 60) + + sizes = [50, 100, 200, 500, 1000, 2000] + for n in sizes: + ops_def, ops_fix, ratio = bench(n) + print(f"B={n:4d}: defective={ops_def:8d} ops | fixed={ops_fix:6d} ops | ratio={ratio:.1f}x") + + print() + test_correctness() + test_speedup_100() + test_speedup_1000() + print() + print("ALL TESTS PASS") diff --git a/defects/pidgin-0002/TICKET.md b/defects/pidgin-0002/TICKET.md new file mode 100644 index 000000000..228b3cee0 --- /dev/null +++ b/defects/pidgin-0002/TICKET.md @@ -0,0 +1,49 @@ +# pidgin-0002 — CWE-312 SIP SIMPLE Authorization header logged verbatim + +**MOAD:** 0004 (CWE-312: Cleartext Storage of Sensitive Information) +**Severity:** MEDIUM-HIGH +**UNDF:** UNDF-2026-000001142 + +## Location + +`libpurple/protocols/simple/simple.c` lines 661-669 + +## Pattern + +Our SIMPLE (SIP) protocol plugin constructs Authorization and Proxy-Authorization +headers via `auth_header()` and immediately logs our full header value: + +```c +buf = auth_header(sip, &sip->registrar, method, url); +auth = g_strdup_printf("Authorization: %s\r\n", buf); +g_free(buf); +purple_debug(PURPLE_DEBUG_MISC, "simple", "header %s", auth); +``` + +Our `auth` variable contains either: +- **Digest response**: `Digest username="...", realm="...", nonce="...", response=""` + Our response hash is crackable offline or usable in replay attacks. +- **NTLM Type3 blob**: produced by `purple_ntlm_gen_type3(authuser, sip->password, ...)` + Our NTLM hash is crackable offline with hashcat mode 5600 (NetNTLMv2). + +Pidgin debug output goes to our Debug Window, console (when started with debug +flags), crash dumps, and any log file our user has configured. + +## Fix + +Remove our `purple_debug()` calls at lines 664 and 669, or replace with a +redacted version that logs only our auth method and type: + +```c +purple_debug(PURPLE_DEBUG_MISC, "simple", + "sending auth header type=%d for method=%s\n", + sip->registrar.type, method); +``` + +## Impact + +Any Pidgin user running with debug mode enabled (on by default in debug builds +and when our Debug Window is open) exposes their SIP credentials to: +1. Shoulder-surfing via our on-screen Debug Window +2. Offline NTLM crack if our debug log is read by an attacker +3. Inclusion in crash reports / bug reports submitted to third parties diff --git a/defects/pidgin-0002/test/test_pidgin_0002.py b/defects/pidgin-0002/test/test_pidgin_0002.py new file mode 100644 index 000000000..770facd85 --- /dev/null +++ b/defects/pidgin-0002/test/test_pidgin_0002.py @@ -0,0 +1,165 @@ +""" +pidgin-0002: SIP SIMPLE Authorization header logged verbatim (CWE-312) + +libpurple/protocols/simple/simple.c lines 661-669: after constructing our +Authorization or Proxy-Authorization header via auth_header(), our SIMPLE +plugin calls purple_debug(PURPLE_DEBUG_MISC, "simple", "header %s", auth) +which logs our full credential string to debug output. + +Our auth string contains either: +- Digest response (offline replay/preimage attack risk) +- NTLM Type3 blob (offline crack risk via hashcat mode 5600) + +Fix: remove or redact our purple_debug() calls, logging only our auth type +and method rather than our full credential value. + +CWE-312: Cleartext Storage of Sensitive Information +UNDF: UNDF-2026-000001142 +""" + +import re +import os + + +PYTHONUNBUFFERED = 1 # ensure unbuffered output + + +# --- Defective model --- + +def build_digest_auth(username: str, realm: str, nonce: str, response: str) -> str: + """Build a Digest Authorization header value.""" + return ( + f'Digest username="{username}", realm="{realm}", ' + f'nonce="{nonce}", response="{response}"' + ) + + +def build_ntlm_auth(ntlm_blob: str) -> str: + """Build an NTLM Authorization header value.""" + return f'NTLM gssapi-data="{ntlm_blob}"' + + +def defective_send_register(auth_value: str, log_sink: list[str]) -> str: + """ + Models defective code: + auth = g_strdup_printf("Authorization: %s\r\n", buf) + purple_debug(PURPLE_DEBUG_MISC, "simple", "header %s", auth) + """ + header = f"Authorization: {auth_value}\r\n" + # defective: log the full header including credential + log_sink.append(f"[DEBUG simple] header {header}") + return header + + +def fixed_send_register(method: str, auth_type: str, log_sink: list[str]) -> str: + """ + Models fixed code: + purple_debug(PURPLE_DEBUG_MISC, "simple", + "sending auth header type=%d for method=%s\n", type, method) + Logs only auth type and method, never credential value. + """ + log_sink.append(f"[DEBUG simple] sending auth header type={auth_type} for method={method}") + return f"Authorization: \r\n" + + +# --- Tests --- + +def test_defective_leaks_credential(): + """Defective code emits credential to debug log.""" + log = [] + response_hash = "a3f2c1b9deadbeef" + auth = build_digest_auth("alice", "corp.example.com", "nonce123", response_hash) + defective_send_register(auth, log) + + assert len(log) == 1 + # credential appears in log + assert response_hash in log[0], "Expected response hash in defective log" + assert "alice" in log[0], "Expected username in defective log" + print(f"defective log entry: {log[0][:80]}...") + print("defective leaks credential: CONFIRMED") + + +def test_defective_leaks_ntlm(): + """Defective code leaks NTLM hash blob to debug log.""" + log = [] + ntlm_blob = "TlRMTVNTUAADAAAA" + "A" * 60 # simulated NTLM Type3 + auth = build_ntlm_auth(ntlm_blob) + defective_send_register(auth, log) + + assert len(log) == 1 + assert ntlm_blob in log[0], "Expected NTLM blob in defective log" + print(f"defective NTLM log contains blob: CONFIRMED") + + +def test_fixed_does_not_leak_credential(): + """Fixed code logs only auth type and method, no credential value.""" + log = [] + fixed_send_register("REGISTER", "Digest", log) + + assert len(log) == 1 + entry = log[0] + # no credential value in log + assert "response=" not in entry, "Fixed log must not contain response hash" + assert "gssapi-data=" not in entry, "Fixed log must not contain NTLM blob" + assert "nonce=" not in entry, "Fixed log must not contain nonce" + # does log method info + assert "REGISTER" in entry, "Fixed log should contain method name" + assert "Digest" in entry, "Fixed log should contain auth type" + print(f"fixed log entry: {entry}") + print("fixed does not leak credential: PASS") + + +def test_fixed_does_not_leak_ntlm(): + """Fixed code for NTLM path also redacts credential.""" + log = [] + fixed_send_register("REGISTER", "NTLM", log) + + assert "gssapi-data=" not in log[0] + assert "NTLM" in log[0] + print("fixed does not leak NTLM blob: PASS") + + +def test_pattern_detection(): + """ + Verify our fix pattern: scan for purple_debug calls logging 'auth' or + 'Authorization' header values. Our fix removes these calls. + """ + # Defective pattern: purple_debug with auth variable containing header + defective_pattern = re.compile( + r'purple_debug\s*\(.*"header\s+%s"\s*,\s*auth\s*\)', + re.DOTALL + ) + + defective_code = ''' + auth = g_strdup_printf("Authorization: %s\\r\\n", buf); + g_free(buf); + purple_debug(PURPLE_DEBUG_MISC, "simple", "header %s", auth); + ''' + + fixed_code = ''' + auth = g_strdup_printf("Authorization: %s\\r\\n", buf); + g_free(buf); + purple_debug(PURPLE_DEBUG_MISC, "simple", + "sending auth header type=%d for method=%s\\n", + sip->registrar.type, method); + ''' + + assert defective_pattern.search(defective_code), "Should detect defective pattern" + assert not defective_pattern.search(fixed_code), "Should not match fixed code" + print("pattern detection: PASS") + + +if __name__ == "__main__": + os.environ["PYTHONUNBUFFERED"] = "1" + + print("pidgin-0002: SIP SIMPLE Authorization header logged verbatim (CWE-312)") + print("=" * 70) + + test_defective_leaks_credential() + test_defective_leaks_ntlm() + test_fixed_does_not_leak_credential() + test_fixed_does_not_leak_ntlm() + test_pattern_detection() + + print() + print("ALL TESTS PASS")