diff options
| author | Hauke Mehrtens | 2026-06-27 20:00:36 +0000 |
|---|---|---|
| committer | Hauke Mehrtens | 2026-06-27 22:46:01 +0000 |
| commit | 5d7be43f8b9dec0eb47e245cfab81108bb131273 (patch) | |
| tree | 5d9c0b8092cbc2862bb4272a044f341e070d8564 | |
| parent | 2a42d34c3615e2449ca273f673eee7c429d819a9 (diff) | |
| download | odhcpd-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.c | 47 |
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)), |