phobos-networksystem.sh
The telephone on the bench reaches three numbers.
It is not the handset that enforces that. Somebody outside the room places every call for you, checks the number first, and hands back the line once it is connected.
The network layer owns the whole network restriction: the connect guard that supervises outbound connections, the egress broker that enforces a host name, the inbound filter that fronts a listener, and the Landlock port rules that express the same ports in the kernel.
Running it on its own
${PHOBOS_HOME}/phobos-networksystem.sh --config exercise.cfg -- curl https://example.org
| Option | What it is for |
|---|---|
--config <file> | A configuration to build the specification from. Repeatable. |
--spec-parent <dir> | Where the specification directory is made. The default is /var/tmp. |
--tail-flags-file <file> | The tail flags file to apply. |
--connect-guard-bin <path> | The connect guard program to use. |
--haproxy-bin <path> | The HAProxy program the broker and the inbound filter run as. |
--resolver <ip[:port]> | The Domain Name System (DNS) resolver the broker resolves an exact host name through, and the one a udp host name is resolved through once at the start. |
--landlock-bin <path> | The phobos-landlock-filesystem-and-networksystem program that applies the port rules. |
--report-filesystem | Have the connect guard report the filesystem layer's denials too. phobos.sh passes it when the filesystem layer is in the chain. |
--group-lock-above | The timeout layer's group lock is above this layer, so the connect guard counts the calls the lock refuses outright for the timeout layer. phobos.sh passes it when the timeout layer applies the lock. |
--debug | Report what the layer builds and runs. |
The connect guard
The guard is the first thing the layer puts in front of the rest of the chain. It forks: the
child installs a seccomp filter and becomes the rest of the chain, and the parent stays beside
it as a supervisor that is never restricted. Every connect() the command makes is trapped to
the supervisor and decided against the allow-list.
What happens next depends on the socket. A stream connection the supervisor makes itself,
handing the connected socket back, so the command never runs a stream connect() of its own.
Letting the kernel continue the original call would re-run it against whatever is in the
command's memory at that later moment, so a command could show one address to the check and
connect to another once it passed.
A datagram connect() and every datagram send that names a destination the supervisor makes
itself too. A sendto() with no address is the send of a connected socket, whose peer the
supervisor vetted when it made the connect, and the filter lets it through. It creates
each datagram socket of the command, keeps its own descriptor for the same open file
description, copies the address, the data and the lengths out of the command once, checks the
copies and sends from them. The call is never let through to the kernel, because the kernel would
read the address from the command's memory a second time, and a second thread could rewrite it in
between. Measured in an ordinary container, about one send in six reached an address the
allow-list never named before the guard did this, and none does now. The cost is spelled out in
what Phobos does not protect against.
The guard judges the source port itself, because it runs outside Landlock. A connect or send on
a datagram socket that was never bound is refused unless the policy grants an ephemeral UDP
bind, which the layer tells it when a udp [connect] rule or a [bind] row of port 0 for
udp exists.
The guard refuses a raw, packet or ICMP socket before the socket exists, and refuses
io_uring, which would otherwise reach connect unseen.
The guard never lets the kernel run a command's own listen(). The kernel gives a socket that
was never bound a port of its choosing when it listens, and Landlock has no check for that, so
the guard creates and holds every stream socket of the command, runs listen() itself, and
refuses a socket that is unbound unless a [bind] row names port 0. The guard only ever acts on
a socket it made, so swapping another socket under the descriptor cannot create a listener. A
repeated listen() on a socket that already listens is answered with success and changes
nothing, including its backlog.
The guard enforces a rule by the address and the port it can see:
| The rule names | The guard holds it to |
|---|---|
| an address literal | that exact address |
the name localhost | the whole loopback range, every 127.x.x.x address and ::1 |
| a range in Classless Inter-Domain Routing (CIDR) notation | that network |
| a host name in a stream rule it cannot tie to an address | its port alone, with the host left to the broker |
a host name in a udp rule | the addresses the name had when the run began |
A connect of a family the guard does not carry, a UNIX-domain socket among them, is refused
rather than made outside the Landlock view the command is held to.
Where the connect guard program is missing or not executable, the layer ends the run with
PHB-ERUNTIME. A run cannot lose connect supervision unnoticed.
The egress broker
The guard reads a destination address, and it cannot read a Transport Layer Security (TLS) ClientHello. A rule that names a host therefore constrains nothing about the onward address on its own. The layer starts the egress broker automatically whenever the allow-list names a host, rather than leaving that to a flag a run could forget.
The broker is an HAProxy on loopback. The guard hands it every allowed connection with a PROXY protocol header naming the destination the command meant, and the broker decides the host:
- For an exact name it does not trust the header at all. It resolves the name itself, through the resolver you gave, and connects only to that address.
- For an address, a range or
*, the header's destination is used unchanged.
The broker decides a connection to a port no name rule names at once. On a port a name rule names, it reads the TLS host name first. A server there that speaks first, as SMTP, MySQL or SSH do, therefore reaches its client only after the broker's five-second inspection delay.
A name with a star in it, such as *.example.org, never reaches the broker. The layer refuses
it with PHB-EPOLICY before it starts anything, because a wildcard cannot resolve to an
address.
An exact name needs a resolver, and the layer refuses the run with PHB-ERUNTIME where none
was given rather than running without the host-name enforcement the rule asked for. Each exact
name is mapped to a loopback placeholder in /etc/hosts beforehand, so the command's own name
resolution succeeds without a DNS query the guard would refuse.
Starting the broker changes the run's posture, and the layer says so on standard error: the
broker's onward connection needs a real network, so a rule that names a host cannot work in a
container started with --network none.
A host name in a udp rule
A datagram carries no TLS host name for the broker to read, so a udp rule that names a host is
held to the addresses the name had at the start of the run. Before the command starts, the layer
runs the guard in its resolve mode, which asks the resolver you gave for the A and AAAA records of
each name and nothing else. It never reads /etc/resolv.conf. The guard is then given one rule
for each address, at most sixteen for a name (the IPv4 records first, the rest dropped without
refusing the run), and the command is shown the same addresses in
/etc/hosts, so the two cannot disagree about where the name leads. The /etc/hosts lines hold
the real addresses, not the loopback placeholder a stream name gets.
The answer is a snapshot. An address the name gains later is not reachable, and one it loses stays reachable until the run ends. The lookup reads an answer that whoever controls the name's domain influences, so it takes an address only from a record that belongs to the name asked or to a chain of aliases that leads from it, and refuses a truncated or malformed answer.
The layer refuses the run with PHB-ERUNTIME, before the command starts, where no resolver was
given, a name does not resolve, /etc/hosts already maps the name to a different set of
addresses, more than 64 names need resolving, or the addresses add up to more rules than the
guard keeps, 256. A name written in two
cases is resolved once. The lookup needs a networked container, as a stream name does.
What the layer reports
The connect guard is the run's one supervisor when this layer is on, so it prints the
Phobos Security Error lines for the whole run, at most 100 per run, and a closing
Phobos Security Summary line.
- Its own refusals. A connect or datagram to a destination no
[connect]rule names givesconnect to the Endpoint 93.184.216.34:443 over TCPorsend to the Endpoint ... over UDP. A socket the guard refuses to open givesopen the Socket Type raw (AF_INET, SOCK_RAW), and a listen on a socket bound to no port giveslisten on the Port chosen by the kernel over TCP. Three more come from keeping sockets local. A listen on a socket the guard cannot pin to the loopback device giveslisten outside the Loopback Interface. A datagram socket its receive filter cannot be attached to givesopen the Socket Type unfiltered (AF_INET, SOCK_DGRAM). A refused socket option givesset the Socket Option SO_BINDTODEVICE, and the same forSO_BINDTOIFINDEX,IP_RECVERRandIPV6_RECVERR. - A UNIX socket. The guard refuses every connect to a UNIX socket. It prints
connect to the Socket File '<path>'only for the command and what the command started. Each layer's shell asks the name service cache for the user at start-up, and the guard refuses that as well, but those are Phobos's own helpers and they get no line. With the filesystem layer off the guard prints no UNIX connect. - A port Landlock refuses to bind. The guard sees the
bindcall, judges the port against the network ruleset, and words a refusal asbind the Port 8080 over TCP. - The egress broker. The broker logs every connection the guard hands it to an anonymous pipe
that no path names. Only HAProxy and the guard hold it, and the guard words each TLS host name the
broker refuses, or each connection with no name to an address no rule allows, as
connect to the Host 'example.org' on Port 443. The host name is hex in the log, so a name the program chose cannot inject anything into the line. The broker can log a refusal after the command has ended. Each connection therefore carries a random token the broker logs back, and the guard reads on after the command for up to two seconds, until every token has come back. A run that a signal is ending does not wait. With the filesystem layer off, a command that runs as the same user can still write to the pipe through/procand forge such a line. A forged line changes the output and never the broker's decisions. - The calls its filter refuses outright.
io_uringand a call through a foreign application binary interface (ABI) fail withEACCES, as before, and each prints a line. The guard answerssetsidandsetpgidfrom its ledger of virtual groups and prints nothing. Where the kernel cannot continue a supervised call they fail withEACCESand print a line as before.
Reporting decides nothing. If the guard dies, every call it watches fails with ENOSYS, and no
call gains a right. If the kernel cannot have the reporting traps, the guard keeps the filter it
had before and says once what goes unreported.
The inbound filter
An [accept] rule starts a second HAProxy that binds the public port, rejects a connection
whose source address is not in the rule's list, and forwards an admitted one to the backend
port on loopback. A public port with no source admits nobody, which is the fail-closed
direction.
This assumes a networked container, so the layer says so loudly before it starts. Where the
filter cannot start, the run is refused rather than left with the listener exposed. With the
network restriction disabled, phobos.sh reports that the [accept] rule starts no filter and
that the listener's port is not locked either.
The Landlock port rules
[connect] and [bind] produce --connect-tcp, --bind-tcp, --connect-udp and
--bind-udp. The kernel enforces the two udp flags only from Landlock version 10. Below it the
enforcer leaves them out and warns, and the connect guard alone holds where a datagram goes. The layer applies them on a Landlock ruleset of its
own, created with --no-filesystem, which composes with the filesystem layer's ruleset by
intersection. It is applied inside the guard's child lineage, after the supervisor has forked,
so the supervisor that connects on the command's behalf stays unrestricted.
The ruleset is applied on every run, including a run whose policy names no port, because it
carries --close-bind: both bind directions are handled with nothing granted, so only a [bind]
row opens a port. A row for port 0 becomes --ephemeral-bind-tcp or --ephemeral-bind-udp,
which grants a port the kernel chooses and never one the command names. On a kernel below
Landlock version 4 (TCP) or 10 (UDP) the direction cannot be closed: the layer's enforcer says
so on every run and leaves it open, and --minimum-landlock-version in the tail flags makes that
a refusal. A missing Landlock program ends the run with PHB-ERUNTIME.
Two details are worth knowing before a policy surprises you:
- A rule that names no port cannot be expressed. Only a host that is exactly one loopback
address can omit its port:
localhost, one address in127.0.0.0/8, or::1, each optionally with/32or/128. A section that holds one leaves that transport's port layer off, and the layer says so; the guard still filters the run. Phobos refuses a range such as127.0.0.1/1, a name such as127.evil.exampleand an external host with no port withPHB-EPOLICY. - A concrete port beside such a rule gets no Landlock rule either. Landlock cannot keep loopback open on every port and close the rest, so the guard alone enforces that port, by host and port, and the layer logs the ports this applies to. For udp the rule needs no Landlock version 10 either.
A udp [connect] rule brings --ephemeral-bind-udp with it, for the source port the kernel
auto-binds to an outgoing datagram. Without it, an allowed send would be denied its own source
port, now that UDP bind is always handled.
Further reading
- Network subsystem: the same layer from the inside
- Seccomp: how the guard traps a call
- HAProxy: the two roles it plays here
[connect]and[bind]: the rule syntax in full