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:
parent
6f11fb7598
commit
079ad04f0e
5 changed files with 395 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
44
defects/pidgin-0001/TICKET.md
Normal file
44
defects/pidgin-0001/TICKET.md
Normal 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 |
|
||||
136
defects/pidgin-0001/test/test_pidgin_0001.py
Normal file
136
defects/pidgin-0001/test/test_pidgin_0001.py
Normal 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")
|
||||
49
defects/pidgin-0002/TICKET.md
Normal file
49
defects/pidgin-0002/TICKET.md
Normal 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
|
||||
165
defects/pidgin-0002/test/test_pidgin_0002.py
Normal file
165
defects/pidgin-0002/test/test_pidgin_0002.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue