summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorHauke Mehrtens2026-06-27 20:00:36 +0000
committerHauke Mehrtens2026-06-27 22:46:01 +0000
commit5d7be43f8b9dec0eb47e245cfab81108bb131273 (patch)
tree5d9c0b8092cbc2862bb4272a044f341e070d8564
parent2a42d34c3615e2449ca273f673eee7c429d819a9 (diff)
downloadodhcpd-5d7be43f8b9dec0eb47e245cfab81108bb131273.tar.gz
statefiles: escape client hostnames in the lease state fileopenwrt-25.12
A DHCP client fully controls its requested hostname (DHCPv4 option 12, DHCPv6 FQDN option 39). The hostname is decoded with dn_expand(), and since DNS labels may carry any octet (RFC 2181 section 11) while a valid hostname is restricted to the LDH set (RFC 1035 section 2.3.1), the decoded label can contain arbitrary bytes - spaces, control characters and newlines included. statefiles_write_state6()/statefiles_write_state4() wrote lease->hostname into the space-delimited, line-based state file via fprintf("%s") without escaping, regardless of lease->hostname_valid; the "broken\x20" marker was only cosmetic. A newline in the hostname therefore injected an additional '#'-prefixed line into the state file, forging an arbitrary extra lease record, and a space forged additional fields on the same line. The forged record is served verbatim by downstream consumers such as LuCI's rpcd-mod-luci getDHCPLeases, turning an unauthenticated, pre-association DHCPv6/DHCPv4 client into a lease-record spoofer and - combined with a missing HTML-escape in LuCI - a stored XSS in the admin status page. The dnsmasq hosts-file writers already bail on !hostname_valid, so only the state file was affected. Escape every byte that is not a bare LDH character as \xNN before writing the hostname field. Valid hostnames are pure LDH and are emitted unchanged, so only hostnames already flagged as broken are altered. Reported-by: puru1761 (GHSA-hhmc-92hw-535f) Assisted-by: Claude:claude-opus-4-8 Link: https://github.com/openwrt/odhcpd/pull/404 Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de> (cherry picked from commit 68f382690bfaec56d5b1f31c3c31c48bcb642e3a)
-rw-r--r--src/statefiles.c47
1 files changed, 45 insertions, 2 deletions
diff --git a/src/statefiles.c b/src/statefiles.c
index 1bd5fda..97dd9c9 100644
--- a/src/statefiles.c
+++ b/src/statefiles.c
@@ -487,9 +487,49 @@ static void statefiles_write_state6_addr(struct dhcpv6_lease *lease, struct in6_
fprintf(ctxt->fp, " %s/%" PRIu8, ipbuf, prefix_len);
}
+/*
+ * Escape a client-supplied hostname so it is safe to write as a single field
+ * into the space-delimited, line-based state file: every byte that is not a
+ * bare LDH character (the RFC 1035 section 2.3.1 "preferred name syntax" set
+ * accepted by odhcpd_hostname_valid()) is encoded as \xNN. A DHCP client
+ * controls its requested hostname (DHCPv4 option 12, DHCPv6 FQDN option) and,
+ * since DNS labels may carry any octet (RFC 2181 section 11), dn_expand()/the
+ * option parser copy the bytes verbatim - so without escaping a hostname could
+ * embed a newline (forging an extra '#'-prefixed lease record) or a space
+ * (forging additional fields). Valid hostnames are pure LDH and are copied
+ * unchanged. The result is written into dst (always NUL-terminated, truncated
+ * if it would not fit) and returned.
+ */
+static const char *statefiles_escape_hostname(char *dst, size_t dstlen, const char *src)
+{
+ size_t pos = 0;
+
+ if (dstlen == 0)
+ return dst;
+
+ for (const unsigned char *c = (const unsigned char *)src; *c; c++) {
+ if ((*c >= '0' && *c <= '9') ||
+ (*c >= 'A' && *c <= 'Z') ||
+ (*c >= 'a' && *c <= 'z') ||
+ *c == '-' || *c == '_' || *c == '.') {
+ if (pos + 1 >= dstlen)
+ break;
+ dst[pos++] = *c;
+ } else {
+ if (pos + 4 >= dstlen)
+ break;
+ pos += sprintf(&dst[pos], "\\x%02x", *c);
+ }
+ }
+
+ dst[pos] = '\0';
+ return dst;
+}
+
static void statefiles_write_state6(struct write_ctxt *ctxt, struct dhcpv6_lease *lease)
{
char duidbuf[DUID_HEXSTRLEN];
+ char hostbuf[DNS_MAX_NAME_LEN * 4];
if (ctxt->fp) {
odhcpd_hexlify(duidbuf, lease->duid, lease->duid_len);
@@ -499,7 +539,8 @@ static void statefiles_write_state6(struct write_ctxt *ctxt, struct dhcpv6_lease
"# %s %s %x %s%s %" PRId64 " %" PRIx64 " %" PRIu8,
ctxt->iface->ifname, duidbuf, ntohl(lease->iaid),
lease->hostname && !lease->hostname_valid ? "broken\\x20": "",
- lease->hostname ? lease->hostname : "-",
+ statefiles_escape_hostname(hostbuf, sizeof(hostbuf),
+ lease->hostname ? lease->hostname : "-"),
(lease->valid_until > ctxt->now ?
(int64_t)(lease->valid_until - ctxt->now + ctxt->wall_time) :
(INFINITE_VALID(lease->valid_until) ? -1 : 0)),
@@ -518,6 +559,7 @@ static void statefiles_write_state6(struct write_ctxt *ctxt, struct dhcpv6_lease
static void statefiles_write_state4(struct write_ctxt *ctxt, struct dhcpv4_lease *lease)
{
char ipbuf[INET6_ADDRSTRLEN];
+ char hostbuf[DNS_MAX_NAME_LEN * 4];
if (lease->hostname && lease->hostname_valid) {
md5_hash(&lease->ipv4, sizeof(lease->ipv4), &ctxt->md5);
@@ -535,7 +577,8 @@ static void statefiles_write_state4(struct write_ctxt *ctxt, struct dhcpv4_lease
ctxt->iface->ifname,
ether_ntoa((struct ether_addr *)lease->hwaddr),
lease->hostname && !lease->hostname_valid ? "broken\\x20" : "",
- lease->hostname ? lease->hostname : "-",
+ statefiles_escape_hostname(hostbuf, sizeof(hostbuf),
+ lease->hostname ? lease->hostname : "-"),
(lease->valid_until > ctxt->now ?
(int64_t)(lease->valid_until - ctxt->now + ctxt->wall_time) :
(INFINITE_VALID(lease->valid_until) ? -1 : 0)),