The ping rabbit hole
/ 11 min read
Table of Contents
It started with a typo. I pasted a URL into ping instead of a hostname:
$ ping http://git.example.orgPING lorien.example.org (185.183.181.181) 56(84) bytes of data.64 bytes from lorien.example.org (185.183.181.181): icmp_seq=1 ttl=51 ...Hold on. That shouldn’t work at all. http://git.example.org is a URL, not a hostname. ping speaks ICMP, a layer-3 protocol that has no idea what HTTP, URLs, or ports are. Feed that string to ping on most Linux boxes and you get what you’d expect:
ping: http://git.example.org: Name or service not knownYet here it was, pinging a thing that starts with a protocol scheme. No error. Real ICMP replies.
And it got worse. git.example.org points to a machine called curzio (193.43.108.75). I could check:
$ getent hosts git.example.org193.43.108.75 curzio.example.org
$ dig +short git.example.orgcurzio.example.org.193.43.108.75Everything agreed: curzio. So I had two impossibilities stacked on top of each other. A URL that resolves at all, and a URL that resolves to the wrong host: not an error, not curzio, but a third answer that shouldn’t exist.
This is the story of chasing both down to two RFCs that disagree about what a “name” is, and three separate design decisions that had to line up just right to produce it.
Wrong theory #1: “ping strips the URL scheme”
The first, obvious guess: ping doesn’t understand URLs, so surely it strips http:// and resolves the bare hostname. Plausible and tidy, and wrong the moment you look at the output. If ping resolved the bare git.example.org it would get curzio, like every other tool. It got lorien. Whatever ping was resolving, it wasn’t the clean hostname.
Wrong theory #2: stale cache
There had been a stale-cache episode earlier that day (systemd-resolved serving an old record while dig, which bypasses the local resolver, saw the fresh one). Cache is always a good suspect. But:
$ resolvectl query --cache=no 'http://git.example.org'http://git.example.org: 185.183.181.181 -- link: wlan0 (lorien.example.org)-- Data from: network--cache=no, and the answer says “Data from: network”. A real DNS server, somewhere, was answering lorien for this query. Not cache. Something on the wire was responding to… what name, exactly?
Wrong theory #3: “:// becomes a dot”
Next guess: maybe the resolver normalizes http://git into http.git, and a record http.git.example.org exists in the zone. Easy to check:
$ dig +short http.git.example.org(nothing)
$ dig +short https.git.example.org(nothing)Dead end. No such records. But while poking at the zone, one probe changed everything:
$ dig +short pippo123xyz.example.orglorien.example.org.185.183.181.181A random, made-up name under example.org resolves to lorien.
The first real answer: a wildcard
The zone has a wildcard record:
*.example.org → lorien.example.orgAny name under example.org without an explicit record falls through to lorien. git.example.org has an explicit CNAME to curzio, so the exact name gives the right answer, and only the exact name.
Here’s the thing about DNS labels: they split on dots, and nothing else. The string http://git.example.org parses as:
http://git . example . org└────────┘ ONE label:// is not a separator. http://git is a single label. A weird-looking one, but as far as the DNS wire format cares, a label is just a length-prefixed run of bytes. That label has no explicit record, so the wildcard catches it. Lorien.
Two nice confirmations. First, any fake scheme works. It was never about http:
$ resolvectl query 'qualsiasi://cosa.example.org'→ 185.183.181.181 (lorien)Second, per RFC 4592, an existing node “shadows” the wildcard for its whole subtree, which is why nothing under git. matched:
$ dig +short foo.bar.git.example.org(nothing: the explicit 'git' node blocks the wildcard below it)Proving ping’s innocence with strace
But should that string even reach the DNS in the first place? Time to stop theorizing about what ping does and just watch it:
$ strace ping -c 3 http://git.example.orgexecve("/usr/bin/ping", ["ping", "-c", "3", "http://git.example.org"], ...) = 0...socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) = 3...connect(5, {sa_family=AF_UNIX, sun_path="/run/systemd/resolve/io.systemd.Resolve"}, 42) = 0sendto(5, "{\"method\":\"io.systemd.Resolve.Re"..., 120, ...) = 120recvfrom(5, "{\"parameters\":{\"addresses\":[{\"if"..., ...) = 132The trace leaves no room for doubt:
- The argument arrives verbatim in
argv. There is no URL parsing anywhere in ping. - ping calls
getaddrinfo(), which walks NSS, which loadslibnss_resolve, which hands the raw string to systemd-resolved over a Varlink socket. ping never talks DNS itself. - resolved answers with 185.183.181.181, and ping starts firing ICMP at it.
So ping is a bystander. It handed an opaque string to the system resolver and the resolver said “sure, that’s 185.183.181.181”. So that’s the whole story?
Not quite. A friend ran the same command on their machine and got:
sonne@clockwork:~$ ltrace ping https://git.example.org 2>&1 | grep getaddrgetaddrinfo("https://git.example.org", nil, ...) = -2-2 is EAI_NONAME, “Name or service not known”. Same ping, same string, same public DNS zone with the same wildcard. Their machine refuses to resolve it at all. Mine resolves it to lorien.
This is where the rabbit hole gets good.
Two resolvers, two definitions of “valid name”
The difference between the machines is the NSS backend serving getaddrinfo. Mine (Arch, stock config):
$ grep hosts: /etc/nsswitch.confhosts: mymachines resolve [!UNAVAIL=return] files myhostname dnsThe first backend is resolve, systemd-resolved. My friend’s machine uses the classic glibc dns backend. The nice part is that I could reproduce both behaviors on my own machine, same network, same everything, just by forcing the backend:
$ getent -s resolve hosts 'https://git.example.org'185.183.181.181 lorien.example.org # exit 0
$ getent -s dns hosts 'https://git.example.org' # exit 2, just like clockwork
$ getent -s dns hosts 'git.example.org'193.43.108.75 curzio.example.org # exit 0, clean names are fineSame question, two backends, opposite answers. Why?
Mapping the boundary by hand
The wildcard makes a nice oracle. Any name that actually gets queried returns lorien; any name rejected before reaching the wire returns nothing. So I probed character by character:
| Label tested | systemd-resolved | glibc classic |
|---|---|---|
zzz |
OK | OK |
zz-z (hyphen) |
OK | OK |
zz_z (underscore) |
OK | OK |
zz9z (digit) |
OK | OK |
zz:z (colon) |
OK | REJECTED |
zz/z (slash) |
OK | REJECTED |
https://zz |
OK | REJECTED |
zz+z (plus) |
OK | REJECTED |
zz~z (tilde) |
OK | REJECTED |
Notice glibc also rejects + and ~, so this isn’t “glibc hates URLs”. It’s an allowlist: letters, digits, hyphen, dot, underscore. Everything else dies.
And where does it die? strace answers that too:
# Clean name through glibc's dns backend:connect(3, {sin_port=htons(53), sin_addr="1.1.1.1"}) = 0 ← real DNS query
# Dirty name through glibc's dns backend:(no socket to port 53 at all) ← rejected in-processglibc refuses the name locally, before it opens a single socket. The -2 my friend saw was manufactured inside the C library. systemd-resolved, on the other hand, happily puts the weird label on the wire, and on this particular zone the wildcard rewards it with an answer.
The two RFCs behind the split
This isn’t a bug in either component. It’s two components implementing two different specs.
- RFC 952 / RFC 1123 §2.1, “hostnames”: the preferred name syntax. Letters, Digits, Hyphen (LDH). This is what glibc’s
res_hnok()enforces; the function name literally means “host name OK”. The glibc resolver descends from 1980s BIND code and is built around the idea of a host name. - RFC 2181 §11, “DNS labels”: the DNS protocol puts no character restrictions on a label at all. A label is an arbitrary sequence of up to 63 octets. This is what systemd-resolved’s
dns_name_is_valid()checks, and it only checks lengths.
So https://git is at once an illegal hostname and a perfectly legal DNS label. Both resolvers are right, each inside its own charter.
Why each one chose as it did
glibc is strict because of lineage and security. The LDH filter historically protected the response path: a hostile DNS server could return a “hostname” stuffed with shell metacharacters or control bytes that later landed in logs, .rhosts files, and scripts. Filtering to LDH was hardening. And glibc, being the C library of half the world, changes behavior with great reluctance. The underscore was only grudgingly allowed once real-world records like _dmarc and _sip._tcp made rejecting it untenable.
systemd-resolved is permissive because it has to be. It isn’t a hostname-to-address shim, it’s a full system resolver that also speaks mDNS and LLMNR. DNS-SD service names legitimately contain spaces and arbitrary UTF-8 (Kitchen Speaker._raop._tcp.local). A resolver that has to handle those can’t enforce LDH. So its permissiveness isn’t laziness. It’s a requirement, plus a design choice: carry whatever the protocol allows, and leave the meaning of it to the caller.
They sit on opposite sides of Postel’s law. glibc: conservative in what it sends. resolved: liberal in what it accepts.
The impedance mismatch
Neither component is wrong. The surprise comes from the glue. NSS wired a narrow API to a wide backend:
getaddrinfo() ... asks a "hostname" question (RFC 1123 semantics) │ └── nsswitch → libnss_resolve → systemd-resolved (RFC 2181 semantics)The narrow question inherits the liberality of the wide backend. On a glibc-only machine the two semantics coincide and the surprise never shows up.
Is that a misconfiguration? No. My nsswitch.conf is the untouched Arch default, exactly what man nss-resolve recommends. Is it a contract violation? Also no, and this part matters: POSIX never promised that getaddrinfo rejects non-LDH names. That strictness was an implementation detail of the BIND-derived resolver, not a guarantee of the API. And the security-relevant half of the old filter, sanitizing hostile responses, is something resolved still does (careful record parsing, escaping, DNSSEC). What was lost is only input-side strictness, and its practical impact rounds to zero… unless someone’s zone has a wildcard.
Why ping delegates everything
The last design decision in the chain: why doesn’t ping validate its input? Because it deliberately has no opinion about what a valid name is, and that turns out to be the right call.
ping’s job is ICMP at layer 3. It needs an address, and the name is a courtesy. By calling getaddrinfo it inherits, for free and in step with every other tool on the system, whatever the admin configured: /etc/hosts, search domains, mDNS, split-DNS, dual-stack. If ping enforced LDH itself it would break perfectly good lookups the resolver handles fine, like ping "Marco's iPhone.local", IDN names, or underscores. A tool can’t know which name schemes the system supports; only the resolver does. So ping treats the string as opaque and takes the verdict: -2 means “unknown host”, an address means start pinging.
The price of that honest abstention is that ping becomes a mirror of the whole resolution stack beneath it, which is exactly why ping http://... turned out to be such a good window into how the system really works.
The three-body problem
The full explanation needs three separate design decisions, all pointing the same way:
| Layer | Decision | Contribution |
|---|---|---|
| ping | abstain; delegate everything to getaddrinfo |
passes https://git... through untouched |
| systemd-resolved | liberal; any label is valid (RFC 2181) | puts the weird name on the wire |
| the DNS zone | wildcard *.example.org |
manufactures a positive answer |
Take away any one and the magic disappears. Strict glibc backend? Rejected before the network (-2). No wildcard? NXDOMAIN. I checked: https://zz.google.com and https://zz.git.example.org (wildcard shadowed by the existing git node) both fail even through resolved. Input validation in ping? You’d never see the resolver’s behavior at all.
Each decision makes sense on its own. The weirdness only appears where all three meet.
Takeaways
- DNS labels split on dots, period.
http://gitis one label, and the wire format doesn’t care that it looks like garbage. - A wildcard record turns “invalid input” into “valid answer”. If you’re debugging DNS oddities,
dig +short randomjunk.example.comtells you in one command whether a wildcard is in play. getaddrinfois not one behavior. It’s a dispatcher, and how strict it is depends on which NSS backend answers. Code that feeds user input togetaddrinfobehaves differently across distros. A test that passes on Arch or Fedora (resolved) can fail on a minimal Debian container (glibc dns), and the other way around.- If you need strict hostname validation, do it yourself, in your own code, before calling
getaddrinfo. That’s the layer that knows your policy. Don’t cripple the system resolver to get strictness; you’d trade caching, split-DNS, mDNS and DNSSEC for a check of near-zero practical value. - When two systems validate the same input under different rules, you can’t guess the symptoms. My first three theories were all reasonable and all wrong. What settled it every time was instrumentation:
digagainst a hypothesis,getent -s <backend>to isolate resolvers,straceto see what actually crossed which boundary. Measure, don’t infer.
What looked like a haunted ping was, in the end, the exact spot where RFC 1123 and RFC 2181 disagree, surfacing through an unvalidated string, a permissive resolver, and one wildcard record that sat there for years waiting for someone to paste a URL into the wrong command.