2023-03-05 18:56:22 +01:00
|
|
|
|
#+TITLE: Dynamic host configuration, please
|
2023-03-07 10:52:24 +01:00
|
|
|
|
#+DATE: 2023-03-07
|
2023-03-05 18:56:22 +01:00
|
|
|
|
* Prologue
|
|
|
|
|
The minimal viable product for an OpenBSD laptop has the following
|
|
|
|
|
features:
|
|
|
|
|
1. It has a real time clock (RTC).
|
|
|
|
|
2. It runs Emacs.
|
|
|
|
|
3. It can suspend *and* resume.
|
|
|
|
|
4. It has working Wi-Fi.
|
|
|
|
|
With those things available we can start to improve the user
|
|
|
|
|
experience.
|
|
|
|
|
|
|
|
|
|
A smart phone is basically always online in urban areas and even in
|
|
|
|
|
rural areas[fn:: My phone automatically connected to the Wi-Fi at Elk
|
|
|
|
|
Lakes Cabin. Never mind that we had to drag the satellite dish over
|
|
|
|
|
the pass.]. Nearly seven years ago at a hackathon in Cambridge, UK, we
|
|
|
|
|
set out to have a similar experience for our laptops. We will look at
|
|
|
|
|
how OpenBSD configures Wi-Fi networks, deals with network
|
|
|
|
|
auto-configuration for IPv4 and IPv6, and DNS resolution. We will show
|
|
|
|
|
how it does this in a reasonably secure way with minimal manual
|
|
|
|
|
configuration.
|
|
|
|
|
* Join the Wi-Fi.
|
|
|
|
|
The reader might recognize this conversation when arriving at a new
|
|
|
|
|
location and taking out their phone:
|
|
|
|
|
#+begin_quote
|
|
|
|
|
Me: Hey, what's the Wi-Fi password?
|
|
|
|
|
|
|
|
|
|
Them: We are in the middle of nowhere, there is no Wi-Fi.
|
|
|
|
|
|
|
|
|
|
Me: All lower-case, one word?
|
|
|
|
|
#+end_quote
|
|
|
|
|
On the phone, we need to select the Wi-Fi and enter the password only
|
|
|
|
|
once. The phone then remembers it indefinitely and auto-connects to
|
|
|
|
|
it whenever the Wi-Fi is in range.
|
|
|
|
|
|
|
|
|
|
On OpenBSD, network interfaces are configured by [[https://man.openbsd.org/ifconfig.8][ifconfig(8)]], or
|
|
|
|
|
persistently in [[https://man.openbsd.org/hostname.if.5][/etc/hostname.IF]][fn::IF denotes a specific network
|
|
|
|
|
interface. For example for iwm0 the file is =/etc/hostname.iwm0=],
|
|
|
|
|
which is read by [[https://man.openbsd.org/netstart.8][netstart(8)]] during boot. netstart(8) calls ifconfig(8)
|
|
|
|
|
internally to handle the network configuration.
|
|
|
|
|
|
2023-03-05 19:03:33 +01:00
|
|
|
|
For a long time, we could only configure one Wi-Fi network:
|
2023-03-05 18:56:22 +01:00
|
|
|
|
#+begin_src shell
|
|
|
|
|
$ cat /etc/hostname.iwm0
|
|
|
|
|
nwid home wpakey "trivial password"
|
|
|
|
|
inet autoconf
|
|
|
|
|
inet6 autoconf
|
|
|
|
|
up
|
|
|
|
|
#+end_src
|
|
|
|
|
|
|
|
|
|
This configures a Wi-Fi network named "home" and a password "trivial
|
|
|
|
|
password". IPv4 and IPv6 auto-configuration are enabled. Whenever the
|
|
|
|
|
network is in range the kernel automatically connects to it.
|
|
|
|
|
|
|
|
|
|
That is not a good user experience (UX). We typically take our laptops
|
|
|
|
|
with us and connect to different Wi-Fi networks, like our phones. We
|
|
|
|
|
have a Wi-Fi at home, at work, there are open Wi-Fis at hotels, and so
|
|
|
|
|
on.
|
|
|
|
|
|
|
|
|
|
People came up with all kinds of weird shell scripts that would run in
|
|
|
|
|
the background or triggered by [[https://man.openbsd.org/cron.8][cron(8)]] to notice when the laptop moved
|
|
|
|
|
to a different Wi-Fi. The script would then call ifconfig(8) to
|
2023-03-06 07:08:07 +01:00
|
|
|
|
reconfigure Wi-Fi from a list of networks it knew about. This was all
|
2023-03-05 18:56:22 +01:00
|
|
|
|
incredibly fragile and not the OpenBSD way.
|
|
|
|
|
|
|
|
|
|
Peter Hessler (phessler@), with the help of Stefan Sperling (stsp@)
|
|
|
|
|
went ahead and tackled this problem: What if we could pass multiple
|
|
|
|
|
=(name, password)= tuples to the kernel and the kernel would chose the
|
|
|
|
|
right one?
|
|
|
|
|
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
$ cat /etc/hostname.iwm0
|
|
|
|
|
join home wpakey "trivial password"
|
|
|
|
|
join work wpakey zUDciIezevfySqam
|
|
|
|
|
join "Airport Wi-Fi"
|
|
|
|
|
join ""
|
|
|
|
|
inet autoconf
|
|
|
|
|
inet6 autoconf
|
|
|
|
|
up
|
|
|
|
|
#+end_src
|
|
|
|
|
=join= implements exactly this. The argument to =join= is the name of
|
|
|
|
|
the network and the following =wpakey= is the password for that
|
|
|
|
|
network. If we leave out the =wpakey=, the Wi-Fi is open and does not
|
|
|
|
|
require a password. Using =join= with the empty string (~join ""~)
|
|
|
|
|
means the kernel will try to connect to any open Wi-Fi if no Wi-Fi
|
|
|
|
|
from the join list is found first.
|
|
|
|
|
|
|
|
|
|
We still need to configure the name and password by editing a file
|
|
|
|
|
in =/etc/= and run netstart(8) when we encounter a new Wi-Fi. This is
|
|
|
|
|
probably not the best UI[fn::As far as I am concerned ed(1) is the
|
|
|
|
|
pinnacle of UI design, but YMMV.] but the UX is pretty good and on par
|
|
|
|
|
with a smart phone. Once the Wi-Fi is configured by adding a =join=
|
|
|
|
|
line, the kernel will automatically re-connect to a known Wi-Fi
|
|
|
|
|
whenever it comes into range.
|
|
|
|
|
* Stop slacking.
|
|
|
|
|
Now that we are connected to the Wi-Fi, we need to configure IP
|
|
|
|
|
addresses.
|
|
|
|
|
|
|
|
|
|
We started our efforts to improve the network configuration user
|
|
|
|
|
experience with IPv6 for two reasons. Even in this day and age
|
|
|
|
|
IPv6 is a technology for early adopters[fn::Which is quite sad.], they
|
|
|
|
|
are used to pain. When we break IPv4, people tend to complain. With
|
|
|
|
|
IPv6 they are eager to help debug the problem.
|
|
|
|
|
|
|
|
|
|
The other reason was, OpenBSD got IPv6 support from the KAME project
|
|
|
|
|
in the late 1990s and early 2000s and then there was not a lot of work
|
|
|
|
|
done afterwards. The network configuration was handled mostly in the
|
|
|
|
|
kernel, so there was no isolation from malicious input. For the most
|
|
|
|
|
part it assumed a stationary work station that tried to acquire an
|
|
|
|
|
IPv6 prefix for stateless address auto-configuration during boot by
|
|
|
|
|
sending three router solicitations and then listened for router
|
|
|
|
|
advertisements to create auto-configuration addresses and renewed
|
|
|
|
|
their lifetimes when a new advertisement flew by. There was some
|
|
|
|
|
rudimentary code in rtsold(8) to handle movement between networks, but
|
|
|
|
|
nobody was using it because it was optional. rtsold(8) was used in
|
|
|
|
|
one-shot mode where it would sent at most three router solicitations
|
|
|
|
|
when an interface connected to the network and then it would exit.
|
|
|
|
|
|
|
|
|
|
We started to write [[https://man.openbsd.org/slaacd.8][slaacd(8)]][fn:name_things:I should not be allowed
|
|
|
|
|
to name things.] and once that was working we could delete rtsold(8)
|
|
|
|
|
and remove a lot of code from the kernel.
|
|
|
|
|
|
|
|
|
|
slaacd(8) is a privilege separated network daemon that build previous
|
|
|
|
|
experience with privilege separation in OpenBSD. It uses three
|
|
|
|
|
processes, the /parent/ process to configure the system, the
|
|
|
|
|
/frontend/ process to talk to the outside world and the /engine/
|
|
|
|
|
process to handle untrusted data and run a state machine for the
|
|
|
|
|
stateless address auto-configuration protocol.
|
|
|
|
|
|
|
|
|
|
pledge(2) restricts what a process is allowed to do and this is
|
|
|
|
|
enforced by the kernel. Enforcement means that the kernel will
|
|
|
|
|
terminate processes that violate what they pledged they would do. The
|
|
|
|
|
pledges themselves are in broad strokes, we do not concern ourselves
|
|
|
|
|
with single system calls but with groups of system calls. For example,
|
|
|
|
|
the process is allowed to interact with open file descriptors
|
|
|
|
|
(="stdio"=), it is allowed to open connections to hosts on the
|
|
|
|
|
Internet (="inet"=), or it is allowed to open files for reading
|
|
|
|
|
(="rpath"=).
|
|
|
|
|
|
|
|
|
|
The /parent/ process pledges that it will only open new network
|
|
|
|
|
sockets, send those to other processes and reconfigure the routing
|
|
|
|
|
table (="stdio inet sendfd wroute"=). The /frontend/ process pledges
|
|
|
|
|
to only receive file descriptors, open unix domain sockets and check
|
|
|
|
|
the state of the routing table (="stdio unix recvfd route"=). Checking
|
|
|
|
|
the routing table includes seeing which flags are configured per
|
|
|
|
|
interface. The /engine/ process pledges to only read and write to
|
|
|
|
|
already open file-descriptors (="stdio"=). The /engine/ process is
|
|
|
|
|
very restricted what it is allowed to do. This is important because it
|
|
|
|
|
handles untrusted data coming from the network. While the /frontend/
|
|
|
|
|
process talks to the network, it never looks at the data. An attacker
|
|
|
|
|
will not be able to confuse the /frontend/ process with data they
|
|
|
|
|
sent. They can and did [[https://ftp.openbsd.org/pub/OpenBSD/patches/7.0/common/014_slaacd.patch.sig][confuse]] the /engine/ process.
|
|
|
|
|
|
|
|
|
|
For more details see [[file:privsep.org]["Privilege drop, privilege separation, and
|
|
|
|
|
restricted-service operating mode in OpenBSD"]].
|
|
|
|
|
|
|
|
|
|
slaacd(8) is enabled per default on all OpenBSD installations.
|
|
|
|
|
|
|
|
|
|
IPv6 stateless address auto-configuration is enabled on an interface
|
2023-03-08 18:53:30 +01:00
|
|
|
|
by setting the =AUTCONF6= flag using [[https://man.openbsd.org/ifconfig.8][ifconfig(8)]]: =ifconfig iwm0 inet6
|
2023-03-05 18:56:22 +01:00
|
|
|
|
autoconf=. The kernel announces this changed interface flag to the
|
|
|
|
|
whole system using a broadcasted route message. slaacd(8) reads those
|
|
|
|
|
messages using a [[https://man.openbsd.org/route.4][route(4)]] socket.
|
|
|
|
|
|
|
|
|
|
slaacd(8) handles all aspects of stateless address
|
|
|
|
|
auto-configuration. It sends router solicitations when needed, either
|
|
|
|
|
multi-cast or uni-cast, depending on which is appropriate. It waits
|
|
|
|
|
for router advertisements, parses them, and configures default routes,
|
|
|
|
|
global and temporary IPv6 addresses, and passes name server
|
|
|
|
|
information via a route message to the rest of the system. It takes
|
|
|
|
|
care of the lifetimes of addresses, default routes, and name server
|
|
|
|
|
information expiring and removing those from the system when no router
|
|
|
|
|
advertisements are received to extend the lifetime.
|
|
|
|
|
|
|
|
|
|
slaacd(8) also monitors when network interfaces regain their
|
|
|
|
|
connection to a network. For example because the laptop woke up from
|
|
|
|
|
suspend or it got moved out of range of a Wi-Fi network and moved back
|
|
|
|
|
into range. It then needs to find out if it connected to the same
|
|
|
|
|
network as before or if it is now in a new network. If it is a new
|
|
|
|
|
network we need to replace the old addresses, default route, and name
|
|
|
|
|
servers. If there is no IPv6 available it needs to remove the old
|
|
|
|
|
information.
|
|
|
|
|
|
|
|
|
|
The stateless address auto-configuration specification allows multiple
|
|
|
|
|
default routers being present on the same layer two network,
|
|
|
|
|
announcing the same or different network information. slaacd(8) tries
|
|
|
|
|
to handle this, but this has not been extensively tested in all
|
|
|
|
|
possible cases. There are still open questions being discussed at the
|
|
|
|
|
IETF on how to run networks with different network prefixes in the
|
|
|
|
|
same layer two network. Hic sunt dracones...
|
|
|
|
|
|
|
|
|
|
slaacd(8) does handle multiple interfaces just fine and we will show
|
|
|
|
|
later how we pick the right source address when multiple are available
|
|
|
|
|
to chose from.
|
|
|
|
|
|
|
|
|
|
* Dynamic host configuration, please.
|
|
|
|
|
With IPv6 address configuration mostly solved, it was time to look at
|
|
|
|
|
IPv4 again. We used a fork of ISC's dhclient(8). Henning Brauer
|
|
|
|
|
(henning@) added privilege separation to it and in recent years
|
|
|
|
|
Kenneth Westerback (krw@) heroically maintained it. It was showing its
|
|
|
|
|
age though. The privilege separation was never quite right. This
|
|
|
|
|
became more visible with the integration of pledge(2) and it would be
|
|
|
|
|
difficult to integrate some of the features we developed in slaacd(8).
|
|
|
|
|
|
|
|
|
|
It was time to write a new daemon. Otto Moerbeek (otto@) solved the
|
|
|
|
|
most pressing problem by suggesting a name for it: dhcpleased(8). We
|
|
|
|
|
try to be polite towards the computer. It is pronounced "dynamic host
|
|
|
|
|
configuration, please". The "d" is silent.
|
|
|
|
|
|
|
|
|
|
On a very high level IPv4 DHCP and IPv6 stateless address
|
|
|
|
|
auto-configuration are very similar. We request some information from
|
|
|
|
|
the router[fn::In IPv6 we might not need to request the information,
|
|
|
|
|
it might just show up unannounced.], we use it to configure the system
|
|
|
|
|
and we make sure that information does not expire. When we move
|
|
|
|
|
networks we need to probe if our information is still up to date and
|
|
|
|
|
if not, reconfigure the system.
|
|
|
|
|
|
|
|
|
|
The obvious solution is to copy =sbin/slaacd= to =sbin/dhcpleased= and
|
|
|
|
|
replace the IPv6 specific bits with IPv4 specific bits. And that is
|
|
|
|
|
exactly what we did.
|
|
|
|
|
|
|
|
|
|
On paper DHCP looks more complicated than IPv6 stateless address
|
|
|
|
|
auto-configuration because it negotiates with the server and there is
|
|
|
|
|
a complicated state machine to implement.
|
|
|
|
|
|
|
|
|
|
In practice it is the other way around. The "stateless" part in IPv6
|
|
|
|
|
does not apply to the client. The client must keep state and implement
|
|
|
|
|
a state machine to keep track of which routers are available and when
|
|
|
|
|
various information expires. In IPv4 we talk to one server and all
|
|
|
|
|
information expires at the same time.
|
|
|
|
|
|
|
|
|
|
We will talk about a few differences between slaacd(8) and
|
|
|
|
|
dhcpleased(8) in a moment, but from the user perspective both behave
|
|
|
|
|
the same. They make sure that the address configuration and default
|
|
|
|
|
gateway are always up to date and they pay attention when the machine
|
|
|
|
|
moves between networks, either while awake or while sleeping.
|
|
|
|
|
|
|
|
|
|
Because dhcpleased(8) has to use [[https://man.openbsd.org/bpf.4][bpf(4)]] instead of regular sockets for
|
|
|
|
|
some of the network packets it needs to sent, the /parent/ process
|
|
|
|
|
cannot use pledge(2). There is nothing it could pledge that would
|
|
|
|
|
allow the usage of bpf(4) at the moment. To protect the system and
|
|
|
|
|
prevent exfiltration of sensitive data we use [[https://man.openbsd.org/unveil.2][unveil(2)]] to restrict
|
|
|
|
|
the /parent/ process' view of the file system. dhcpleased(8) can only
|
|
|
|
|
read its configuration file, read and write =/dev/bpf=, and read,
|
|
|
|
|
write and create files underneath =/var/db/dhcpleased/= to store
|
|
|
|
|
information about received leases.
|
|
|
|
|
|
|
|
|
|
While we could get away with not implementing a config file for
|
|
|
|
|
slaacd(8), we were not this lucky with dhcpleased(8). Some systems out
|
|
|
|
|
there will only give us a DHCP lease if we sent the correct /client
|
|
|
|
|
id/ for example.
|
|
|
|
|
|
|
|
|
|
There are a lot of DHCP options specified in RFC 2132. We only
|
|
|
|
|
implement the bare minimum, only the options we need and can
|
|
|
|
|
handle. We do not need a swap server or a cookie server to get the
|
|
|
|
|
quote of the day.
|
|
|
|
|
|
|
|
|
|
Like slaacd(8), dhcpleased(8) is enabled on all OpenBSD
|
|
|
|
|
installations.
|
|
|
|
|
* Route priorities.
|
|
|
|
|
dhcpleased(8) and slaacd(8) can handle multiple interfaces at the same
|
|
|
|
|
time. The routing table might look like this:
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
$ netstat -nrf inet
|
|
|
|
|
Routing tables
|
|
|
|
|
|
|
|
|
|
Internet:
|
|
|
|
|
Destination Gateway Flags Refs Use Mtu Prio Iface
|
|
|
|
|
default 192.168.1.1 UGS 4 110 - 8 em0
|
|
|
|
|
default 192.168.178.1 UGS 0 0 - 12 iwm0
|
|
|
|
|
[...]
|
|
|
|
|
#+end_src
|
|
|
|
|
We end up with two default routes, one gateway is reachable via the
|
|
|
|
|
/em0/ interface with priority value 8 and the other gateway is
|
|
|
|
|
reachable via the /iwm0/ interface with priority value 12. A route has
|
|
|
|
|
higher priority when its priority value is lower. /em0/ is an Ethernet
|
|
|
|
|
interface and it gets higher priority over the Wi-Fi interface
|
|
|
|
|
/iwm0/. All things being equal, the kernel will pick the address from
|
|
|
|
|
/em0/ as source address when making a new connection to the internet
|
|
|
|
|
and route traffic over the Ethernet interface, which is presumably
|
|
|
|
|
faster.
|
|
|
|
|
|
|
|
|
|
If we pick up the laptop and unplug the Ethernet interface, all things
|
|
|
|
|
are no longer equal, the route over /em0/ is no longer usable and
|
|
|
|
|
existing connections using it will stall and time out. New connections
|
|
|
|
|
will instead use /iwm0/.
|
|
|
|
|
|
|
|
|
|
If we plug /em0/ back in again, session might come alive again and new
|
|
|
|
|
connections will use /em0/. Connections that are running over /iwm0/
|
|
|
|
|
will continue working, because the interface is still connected to
|
|
|
|
|
the Wi-Fi.
|
|
|
|
|
|
|
|
|
|
Applications like web browsers, email clients or even video
|
|
|
|
|
conferencing systems will automatically establish a new connection
|
|
|
|
|
when they notice the old one is dead.
|
|
|
|
|
|
|
|
|
|
Unfortunately [[https://man.openbsd.org/ssh.1][ssh(1)]] is not one of them. If switching between wired
|
|
|
|
|
and wireless happens seldomly [[https://man.openbsd.org/tmux.1][tmux(1)]] on the remote system might help
|
|
|
|
|
with ssh(1) disconnects. Or maybe a [[https://man.openbsd.org/wg.4][wg(4)]] tunnel can be used so that
|
|
|
|
|
the source address does not change when switching between wired and
|
|
|
|
|
wireless.
|
|
|
|
|
* Cellular networks.
|
|
|
|
|
In addition to Ethernet and Wi-Fi networks, OpenBSD supports "Mobile
|
|
|
|
|
Broadband Interface Model" devices using the [[https://man.openbsd.org/umb.4][umb(4)]] driver. These can
|
|
|
|
|
be used to connect to UMTS or LTE networks. They require a sim card
|
|
|
|
|
and after being configured using a PIN they will connect to cellular
|
|
|
|
|
networks and automatically configure an IP address and default
|
|
|
|
|
route. The default route has an even lower route priority than Wi-Fi
|
|
|
|
|
so it will only be used when Ethernet and Wi-Fi are not connected.
|
|
|
|
|
* It is always DNS.[fn::In my line of work that is certainly true, but that is just sample bias.]
|
|
|
|
|
We need to talk about DNS next. Humans are not particularly good at
|
|
|
|
|
remembering =2606:2800:220:1:248:1893:25c8:1946=, we are much better
|
|
|
|
|
with names like /example.com/. When we run ~ping6 example.com~ we
|
|
|
|
|
sooner or later end up in [[https://man.openbsd.org/asr_run.3][libc's stub resolver]]. It will open
|
|
|
|
|
=/etc/resolv.conf=, and look for /nameserver/ lines to use for DNS
|
|
|
|
|
resolution.
|
|
|
|
|
|
|
|
|
|
We can learn name servers from dhcpleased(8), slaacd(8), umb(4),
|
|
|
|
|
and [[https://man.openbsd.org/iked.8][iked(8)]]. Historically dhclient(8) owned =/etc/resolv.conf=, which
|
|
|
|
|
means that no other process could add name servers to it. dhclient(8)
|
|
|
|
|
would just overwrite whatever was in there whenever it renewed its
|
|
|
|
|
lease. This made it impossible to sometimes move to an IPv6-only
|
|
|
|
|
network. slaacd(8) could not configure name servers and the left-over
|
|
|
|
|
IPv4 name servers were not reachable.
|
|
|
|
|
|
|
|
|
|
We can either teach all name server sources to somehow cooperate and
|
|
|
|
|
to not scribble over each other and share responsibility of
|
|
|
|
|
=/etc/resolv.conf= or we can run an arbitrator that collects name
|
|
|
|
|
servers from diverse sources and handles the contents of
|
|
|
|
|
=/etc/resolv.conf=.
|
|
|
|
|
|
|
|
|
|
[[https://man.openbsd.org/resolvd.8][resolvd(8)]] is such an arbitrator. It is another always enabled
|
|
|
|
|
daemon. It collects name servers from all the mentioned sources and
|
|
|
|
|
adds them to =/etc/resolv.conf=.
|
|
|
|
|
|
|
|
|
|
It also monitors if =/etc/resolv.conf= gets edited in which case it
|
|
|
|
|
re-reads the file and makes sure that the learned name servers are at
|
|
|
|
|
the beginning of the file. This is useful when the administrator of
|
|
|
|
|
the machine decides to add options to =/etc/resolv.conf=. For example,
|
|
|
|
|
we can edit the file and add =family inet6 inet= to prefer IPv6 over
|
|
|
|
|
IPv4 and resolvd(8) will cope. There is no need for an extra
|
|
|
|
|
configuration file, =/etc/resolv.conf= is the configuration file.
|
|
|
|
|
|
|
|
|
|
Name servers are announced using route messages and resolvd(8) listens
|
|
|
|
|
for them using a route(4) socket. They can also be observed using the
|
|
|
|
|
[[https://man.openbsd.org/route.8][route(8)]] tool: ~$ route monitor~.
|
|
|
|
|
|
|
|
|
|
resolvd(8) can also request that name servers are re-announced by their
|
|
|
|
|
sources. This is useful when resolvd(8) gets restarted.
|
|
|
|
|
* Let us unwind[fn:: See [fn:name_things].] a bit.
|
|
|
|
|
Good old plain DNS is not a secure protocol. It exchanges
|
|
|
|
|
un-authenticated UDP packets without any integrity protection. This
|
|
|
|
|
makes it easy for an attacker to spoof answer packets.
|
|
|
|
|
|
|
|
|
|
DNS answer packets are untrusted data, they come from the
|
|
|
|
|
network. However, the process that sends DNS queries and parses the
|
|
|
|
|
answer using the libc functions is almost always the single main
|
|
|
|
|
process of the tool. When we run ~ping example.com~, DNS packets are
|
|
|
|
|
parsed using our user. An attacker who can spoof a DNS answer might be
|
|
|
|
|
able to trigger a bug in libc and gain code execution that way.
|
|
|
|
|
|
|
|
|
|
On OpenBSD ping(8) pledges ="stdio DNS"=, so the attacker will not get
|
|
|
|
|
very far, but there are many more programs in ports that are not
|
|
|
|
|
pledged that might want to resolve names.
|
|
|
|
|
|
|
|
|
|
It would be worthwhile to have some sort of proxy running on localhost
|
|
|
|
|
so that DNS packets from the outside need to traverse a well locked
|
|
|
|
|
down process running in a different address-space and as a different
|
|
|
|
|
user than the program that needs to resolve a name.
|
|
|
|
|
|
|
|
|
|
An early experiment was rebound(8), written by Ted Unangst (tedu@). It
|
|
|
|
|
was simplistic and did not understand DNS at all, it would just
|
|
|
|
|
forward packets, but it would sit between the Internet and the
|
|
|
|
|
program.
|
|
|
|
|
|
|
|
|
|
An alternative is to run a full recursive resolver like [[https://man.openbsd.org/unbound.8][unbound(8)]] on
|
|
|
|
|
the laptop, but this leads to problems, too. unbound(8) expects a well
|
|
|
|
|
working network where nobody interferes with DNS, this is true in data
|
|
|
|
|
centres and can be achieved in well maintained home networks, but it
|
|
|
|
|
is not something we find when moving laptops to arbitrary networks
|
|
|
|
|
like free Wi-Fi in a hotel or airport.
|
|
|
|
|
|
|
|
|
|
We can either give up and move to a different hotel[fn::Which is not
|
|
|
|
|
realistic.], or we need to adjust our expectations, figure out what we
|
|
|
|
|
have and work with that.
|
|
|
|
|
|
|
|
|
|
It turns out that often the quality of the network changes over
|
|
|
|
|
time. When we first connect to a hotel Wi-Fi we may find ourselves in
|
|
|
|
|
what is referred to as a /captive portal/. Everything is blocked, DNS
|
|
|
|
|
gets intercepted, and we are redirected to a web site where we need to
|
|
|
|
|
agree to the terms and conditions. Maybe provide our name and room
|
|
|
|
|
number. Once we are past that, network quality improves considerably
|
|
|
|
|
and we are mostly free to talk to the outside world.
|
|
|
|
|
|
|
|
|
|
This is where [[https://man.openbsd.org/unwind.8][unwind(8)]] comes in. It is another privilege separated
|
|
|
|
|
network daemon that provides a recursive name server for the local
|
|
|
|
|
machine. resolvd(8) detects when it is running and automatically
|
|
|
|
|
rewrites =/etc/resolv.conf= to have only =nameserver 127.0.0.1= listed
|
|
|
|
|
as name server.
|
|
|
|
|
|
|
|
|
|
With that we have the first problem solved, or at least improved on
|
|
|
|
|
the situation. Programs that need DNS resolution are insulated from
|
|
|
|
|
the Internet. An attacker needs to get past unwind(8) first before
|
|
|
|
|
they can try to attack the libc stub resolver.
|
|
|
|
|
|
|
|
|
|
unwind(8) understands and speaks DNS and it actively observes the
|
|
|
|
|
network quality.
|
|
|
|
|
|
|
|
|
|
We did not write our own recursive name server. That would be
|
|
|
|
|
difficult, it would be unlikely we would get it right on first
|
|
|
|
|
try[fn:: Or second or third try for that matter.], and DNS is
|
|
|
|
|
constantly evolving, so it is a lot of effort to keep up. Instead we
|
|
|
|
|
are standing on the shoulders of giants and use libunbound, which is
|
|
|
|
|
part of [[https://man.openbsd.org/unbound.8][unbound(8)]]. It is developed under a BSD license by [[https://www.nlnetlabs.nl/][NLnet Labs]].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The resolver process pledges ="stdio inet dns rpath"= and
|
|
|
|
|
restricts access to the file system using unveil(2) to
|
|
|
|
|
=/etc/ssl/cert.pem=. This is the process that is exposed to the
|
|
|
|
|
Internet and handles untrusted data. It would be preferable to have
|
|
|
|
|
one process exposed to the Internet and another to parse untrusted
|
|
|
|
|
data but that is not possible to do with libunbound.
|
|
|
|
|
|
|
|
|
|
Since we are using a real recursive name server, that gives us a lot
|
|
|
|
|
of options on how we can resolve names:
|
|
|
|
|
+ We can do our own recursion, walk down from the root zone using
|
|
|
|
|
qname minimization to improve privacy.
|
|
|
|
|
+ We can use the name server we learned from dhcpleased(8) and
|
|
|
|
|
slaacd(8) as forwarders, so we do not need to do our own recursion,
|
|
|
|
|
which might be faster.
|
|
|
|
|
+ We can try to opportunistically speak DNS over TLS (DoT) to the
|
|
|
|
|
learned name servers to prevent eavesdroppers from listening in.
|
|
|
|
|
+ We can configure forwarders manually to not depend on the network
|
|
|
|
|
provided name servers. Those might be more trustworthy. They can
|
|
|
|
|
also be DoT forwarders to prevent eavesdropping.
|
|
|
|
|
+ As a last resort, unwind(8) can behave exactly like the libc stub
|
|
|
|
|
resolver[fn::I call this the Dutch train problem. The free Wi-Fi on
|
|
|
|
|
Dutch trains do not like DNS queries with an /EDNS0/ option, they
|
|
|
|
|
intercept them, do not understand them, and answer /NXDOMAIN/. There
|
|
|
|
|
are other free Wi-Fi networks that are similarly broken.].
|
|
|
|
|
We call these resolving strategies and unwind(8) actively probes if
|
|
|
|
|
they are usable by sending test queries when it notices that the
|
|
|
|
|
network changed, for example because we moved to a different Wi-Fi
|
|
|
|
|
network or woke up from suspend. It then orders them by quality and
|
|
|
|
|
picks the best one.
|
|
|
|
|
|
|
|
|
|
There is an implicit skew in the strategies for finding the best one:
|
|
|
|
|
A manually configured DoT name server is always considered better than
|
|
|
|
|
a name server provided by the local network. As long as its available
|
|
|
|
|
and not atrociously slow.
|
|
|
|
|
|
|
|
|
|
unwind(8) is not too concerned about preserving privacy, it is
|
|
|
|
|
pragmatic and tries to resolve names the best way it can, if that
|
|
|
|
|
means using the local name servers provided by the network because
|
|
|
|
|
they are the only ones available it will use them.
|
|
|
|
|
|
|
|
|
|
Since unwind(8) uses libunbound it also supports DNSSEC. DNSSEC
|
|
|
|
|
provides data integrity and cryptographic authenticity, it does not
|
|
|
|
|
provide confidentiality.
|
|
|
|
|
|
|
|
|
|
unwind(8) is pragmatic about DNSSEC. When it tests the quality of a
|
|
|
|
|
resolving strategy it also tries to find out if DNSSEC is
|
|
|
|
|
available. There are many reasons why DNSSEC is not available: The
|
|
|
|
|
network is misconfigured, DNSSEC is flat out blocked or the laptop
|
|
|
|
|
does not (yet) have the correct time. If DNSSEC does not work
|
|
|
|
|
unwind(8) does not insist on using it.
|
|
|
|
|
|
|
|
|
|
Of course this makes it susceptible to a downgrade attack. To mitigate
|
|
|
|
|
this, unwind(8) will insist on DNSSEC working after it discovered once
|
|
|
|
|
that DNSSEC is working in the local network. This means that an
|
|
|
|
|
attacker needs to be able to block DNSSEC from the moment we connect
|
|
|
|
|
to a network. They cannot show up later and try to downgrade
|
|
|
|
|
us. unwind(8) will only become lenient again when we connect to a new
|
|
|
|
|
network.
|
|
|
|
|
|
|
|
|
|
This is not a strong mitigation of course, but DNSSEC is not a silver
|
|
|
|
|
bullet that fixes everything at the resolver. Applications also need
|
|
|
|
|
to do their part and decide how much they are willing to trust
|
|
|
|
|
DNS. For example ssh(1)'s /VerifyHostKeyDNS/ feature will only trust
|
|
|
|
|
host key fingerprints it obtained from DNS if they were validated
|
|
|
|
|
using DNSSEC and the validator runs on the local
|
|
|
|
|
machine[fn::Technically not entirely true, ssh(1) trusts what libc
|
|
|
|
|
indicates and libc automatically trusts localhost. See /trust-ad/ in
|
|
|
|
|
[[https://man.openbsd.org/resolv.conf.5][resolv.conf(5)]].]. Otherwise it will ask the user what to do.
|
|
|
|
|
|
|
|
|
|
A worst case scenario when joining a somewhat broken Wi-Fi network
|
|
|
|
|
with captive portal and a manually configured DoT name server might
|
|
|
|
|
look like this:
|
|
|
|
|
1. We connect to the network, we cannot reach the DoT name server and
|
|
|
|
|
cannot do our own recursion.
|
|
|
|
|
2. unwind(8) will chose the name server provided by the
|
|
|
|
|
network. It also notes that we just connected to a new network so
|
|
|
|
|
it is lenient with respect to DNSSEC validation. In effect it will
|
|
|
|
|
ignore validation errors.
|
|
|
|
|
3. We try to access a web site and the captive portal
|
|
|
|
|
detection in the browser triggers. We click the buttons and fill in
|
|
|
|
|
the forms until we are allowed on the internet.
|
|
|
|
|
4. unwind(8) notices that it can do its own recursion.
|
|
|
|
|
5. At the same time, unwind(8) notices that the DoT name server is
|
|
|
|
|
also reachable now and starts using it.
|
|
|
|
|
|
|
|
|
|
unwind(8) does not natively support DNS over HTTPS (DoH) and we
|
|
|
|
|
sometimes find ourselves in networks that block everything except for
|
|
|
|
|
TCP port 443. One way around this is to use dnscrypt-proxy from ports
|
|
|
|
|
which does support DoH. We can point unwind(8) at it by manually
|
|
|
|
|
configuring a plain DNS forwarder in addition to a DoT forwarder:
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
$ cat /etc/unwind.conf
|
|
|
|
|
forwarder "9.9.9.9" port 853 authentication name "dns.quad9.net" DoT
|
|
|
|
|
forwarder "2620:fe::9" port 853 authentication name "dns.quad9.net" DoT
|
|
|
|
|
forwarder "127.0.0.1" port 5353 # dnscrypt-proxy for DoH
|
|
|
|
|
#+end_src
|
|
|
|
|
* Time for gelato.[fn:: Again, see [fn:name_things].]
|
|
|
|
|
People from the future might encounter networks without any IPv4. If
|
|
|
|
|
they are not too far in the future they might still need to talk to
|
|
|
|
|
IPv4 hosts on the Internet.
|
|
|
|
|
|
|
|
|
|
There are various transition technologies that get us from an IPv4
|
|
|
|
|
only Internet to an IPv6 only Internet. We will only look at /NAT64/,
|
|
|
|
|
/DNS64/, and /464XLAT/.
|
|
|
|
|
|
|
|
|
|
/NAT64/ allows us to reach IPv4 hosts from an IPv6 only network by
|
|
|
|
|
pretending that the hosts are IPv6 enabled. IPv6 addresses are so big
|
|
|
|
|
that we can easily encode all of IPv4 in an IPv6 /64 prefix, which is
|
|
|
|
|
the usual size of on IPv6 prefix we see per layer two network. In fact
|
|
|
|
|
we don't need the whole /64, a /96 is enough to encode the whole IPv4
|
|
|
|
|
Internet.
|
|
|
|
|
|
|
|
|
|
Let us pretend we know the /96 prefix used for /NAT64/ and the IPv4
|
|
|
|
|
address we want to reach. Forming an IPv6 address for the host is then
|
|
|
|
|
simply a bitwise-or operation of the IPv4 address with the /96 prefix,
|
|
|
|
|
the IPv4 address fills in the lower bits of the IPv6 prefix. This is
|
|
|
|
|
called address synthesis.
|
|
|
|
|
|
|
|
|
|
We can then use this address to connect to the IPv4-only
|
|
|
|
|
host. Somewhere on the network path is the /NAT64/ gateway that is
|
|
|
|
|
dual stacked. It knows that our packets are using /NAT64/ because it
|
|
|
|
|
is configured with the /96 prefix. It intercepts the packets and forms
|
|
|
|
|
IPv4 packets and sends them on their way. The gateway needs to be
|
|
|
|
|
stateful to be able to /NAT/ the return traffic back to us.
|
|
|
|
|
|
|
|
|
|
To find out the IPv4 address we want to connect to we of course use
|
|
|
|
|
DNS. The local name servers that slaacd(8) learned about would know
|
|
|
|
|
about the /NAT64/ prefix used in the network and do the address
|
|
|
|
|
synthesis for us. This is called /DNS64/. The problem with this is that
|
|
|
|
|
the name servers spoof DNS answers, something that DNSSEC tries very
|
|
|
|
|
hard to prevent. unwind(8) will detect this and generate an error, or
|
|
|
|
|
unwind(8) might not even talk to the designated name servers at all.
|
|
|
|
|
|
|
|
|
|
To get around this unwind(8) can itself detect the presence of /DNS64/
|
|
|
|
|
on a network by asking the local name servers for the /AAAA/ record,
|
|
|
|
|
i.e. the IPv6 address, for something that is guaranteed to never have
|
|
|
|
|
one: /ipv4only.arpa/. If it gets an answer, it can reverse the address
|
|
|
|
|
synthesis and learn the /NAT64/ prefix. With that information it can
|
|
|
|
|
do /DNS64/ itself and there is no longer a problem with DNSSEC.
|
|
|
|
|
|
|
|
|
|
The downsides of this mechanism are that it is quite complicated, it
|
|
|
|
|
messes around with DNS, and it does not work with IPv4 address
|
|
|
|
|
literals. It also does not work with programs that are fundamentally
|
|
|
|
|
IPv4 only: =ping example.com= will never work in an IPv6 only network
|
|
|
|
|
with only /NAT64 / DNS64/.
|
|
|
|
|
|
|
|
|
|
Instead of pretending the IPv4 host we want to reach has IPv6, we can
|
|
|
|
|
pretend to have working IPv4 if a /NAT64/ gateway is present. We ask
|
|
|
|
|
the kernel via the [[https://man.openbsd.org/pf.4][pf(4)]] firewall to do the IPv4 to IPv6 translation
|
|
|
|
|
for us. The /NAT64/ gateway will then do the reverse translation and
|
|
|
|
|
send an IPv4 packet on its way. This is called /464XLAT/.
|
|
|
|
|
|
|
|
|
|
We first need an IPv4 address, RFC 7335 reserved =192.0.0.0/29= for
|
|
|
|
|
this purpose:
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
ifconfig pair1 inet 192.0.0.4/29
|
|
|
|
|
#+end_src
|
|
|
|
|
We then need a default gateway:
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
ifconfig pair2 rdomain 1
|
|
|
|
|
ifconfig pair2 inet 192.0.0.1/29
|
|
|
|
|
#+end_src
|
|
|
|
|
Because pf(4) will only do address family translation on inbound rules
|
|
|
|
|
we need a different /rdomain/ and use [[https://man.openbsd.org/pair.4][pair(4)]] interfaces. We need to
|
|
|
|
|
connect them:
|
|
|
|
|
#+begin_src shell
|
|
|
|
|
ifconfig pair1 patch pair2
|
|
|
|
|
#+end_src
|
|
|
|
|
And then we can configure our default route:
|
|
|
|
|
#+begin_src
|
|
|
|
|
route add -host -inet default 192.0.0.1 -priority 48
|
|
|
|
|
#+end_src
|
|
|
|
|
We set it to a very low priority[fn:: Remember, a high priority
|
|
|
|
|
*value* means low priority.] so that it does not interfere with routes
|
|
|
|
|
dhcpleased(8) configures when we move to an IPv4 enabled network.
|
|
|
|
|
|
|
|
|
|
We then need to configure address family translation in pf(4) when we
|
2023-11-25 13:04:52 +01:00
|
|
|
|
detect /NAT64/ being present. This is were [[https://codeberg.org/fobser/gelatod][gelatod(8)]] comes in. It is
|
2023-03-05 18:56:22 +01:00
|
|
|
|
a Customer-side transLATor (/CLAT/) configuration daemon[fn::If you
|
|
|
|
|
squint just right, gelato kinda sounds like clat[fn::Again, I really
|
|
|
|
|
really should be prohibited from naming things.].]. /CLAT/ is what
|
|
|
|
|
/464XLAT/ calls the address translation happening on the laptop.
|
|
|
|
|
|
|
|
|
|
gelatod(8) is yet another privilege separated daemon[fn::At this point
|
|
|
|
|
you should believe me that that is a good thing and I will not go into
|
|
|
|
|
pledge details.] that checks for the presence of a /NAT64/
|
|
|
|
|
gateway whenever we change networks. It does so either via the
|
|
|
|
|
/ipv4only.arpa/ trick or explicitly via router advertisements. RFC
|
|
|
|
|
8781 specifies how a network can signal the presence of a /NAT64/
|
|
|
|
|
gateway.
|
|
|
|
|
|
|
|
|
|
gelatod(8) needs a pf(4) anchor into which it adds rules that are
|
|
|
|
|
similar to this example:
|
|
|
|
|
#+begin_src
|
|
|
|
|
pass in log quick on pair2 inet af-to inet6 \
|
|
|
|
|
from 2001:db8::da68:f613:4573:4ed0 to 64:ff9b::/96 \
|
|
|
|
|
rtable 0
|
|
|
|
|
#+end_src
|
|
|
|
|
The rule is doing address family translation to IPv6 on incoming
|
|
|
|
|
packets on =pair2=. In this example it uses
|
|
|
|
|
=2001:db8::da68:f613:4573:4ed0= as the IPv6 source address, gelatod(8)
|
|
|
|
|
learned this from the system when slaacd(8) configured
|
|
|
|
|
it. =64:ff9b::/96= is the learned /NAT64/ prefix and we are moving
|
|
|
|
|
traffic back to =rtable 0=. Remember =pair2= is in rdomain 1[fn::Do
|
|
|
|
|
not ask me about the difference between an rdomain and an rtable, I do
|
|
|
|
|
not know either.].
|
|
|
|
|
|
|
|
|
|
While this is all cute and works rather well, it is also completely
|
|
|
|
|
horribly complicated to set up. And that is why gelatod(8) is not in
|
|
|
|
|
OpenBSD base but lives in ports. We believe in good defaults in
|
|
|
|
|
OpenBSD and try to keep the buttons a user has to push to get
|
|
|
|
|
something working to an absolute minimum.
|
|
|
|
|
* Future work.
|
|
|
|
|
Which brings us to future work.
|
|
|
|
|
|
|
|
|
|
We want the functionality of gelatod(8) in OpenBSD base. gelatod(8)
|
|
|
|
|
was mostly a proof of concept. We imagine that a new network device
|
|
|
|
|
like clat(4) take over the role of client side address family
|
|
|
|
|
translation. It could be always present and gelatod(8) just enables
|
|
|
|
|
and disables it. At that point we can move the functionality into
|
|
|
|
|
slaacd(8) and delete gelatod(8). /CLAT/ is defined as a stateless
|
|
|
|
|
mechanism so it does not need the full pf(4) machinery for address
|
|
|
|
|
family translation.
|
|
|
|
|
|
|
|
|
|
It would be nice to have DNS over HTTPS (DoH) and DNS over Quic (DoQ)
|
|
|
|
|
natively in unwind(8). We are mostly waiting on upstream to implement
|
|
|
|
|
support in unbound(8).
|
|
|
|
|
|
|
|
|
|
And then there is some ongoing maintenance, little things that could
|
|
|
|
|
be improved:
|
|
|
|
|
+ The captive portal detection in unwind(8) is not perfect and it will
|
|
|
|
|
probably never be.
|
|
|
|
|
+ dhcpleased(8) and slaacd(8) should remember IP addresses from
|
|
|
|
|
networks they have been connected to before to be able to quickly
|
|
|
|
|
re-establish connectivity by probing if we are connecting to a
|
|
|
|
|
previous network while the lifetime of our addresses did not expire
|
|
|
|
|
yet. RFC 4436 "Detecting Network Attachment in IPv4 (DNAv4)" and RFC
|
|
|
|
|
6059 "Simple Procedures for Detecting Network Attachment in IPv6"
|
|
|
|
|
have the details.
|
|
|
|
|
+ It would be nice if the dhcpleased(8) parent process could be
|
|
|
|
|
pledged. This is not currently possible because of bpf(4). Things to
|
|
|
|
|
investigate here are changes to the network stack that would allow
|
|
|
|
|
us to use raw sockets instead of bpf(4) sockets or the ability to
|
|
|
|
|
[[https://man.openbsd.org/dup.2][dup(2)]] an existing bpf(4) socket and re-program the interface it is
|
|
|
|
|
using.
|
|
|
|
|
* Epilogue
|
|
|
|
|
Writing all this software over the last six to seven years was a lot
|
|
|
|
|
of fun. And combined with all the other features OpenBSD has to offer
|
|
|
|
|
like the /join/ feature, working suspend and resume and accelerated
|
|
|
|
|
video on /amd/ and /intel/ graphic cards makes it a pleasure to use
|
|
|
|
|
OpenBSD on a laptop as a daily driver. Things just work. Mostly. And
|
|
|
|
|
if they do not you have something to fix!
|