pidgin: 5-MOAD scan; pidgin-0001 CWE-407 499.5x, pidgin-0002 CWE-312; add Python tests + TICKET.md, mark SCAN-TODO done

This commit is contained in:
russell@unturf.com 2026-04-03 14:02:01 -04:00
parent 6f11fb7598
commit 079ad04f0e
5 changed files with 395 additions and 1 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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")

View file

@ -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="<hash>"`
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

View file

@ -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: <redacted>\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")