Jailing programs started from RC scripts

Posted on August 26, 2022
Tags: freebsd, jails

While porting Cardano Node I've got an idea to make the daemon run under a thin jail for added security. By “thin” I mean a minimal chroot environment (in contrary to full-system jails created by Bastille or iocage) that gets set up right before the daemon starts and destroyed after its shutdown. This post summarizes the knowledge I mined during the process of writing a RC service file for Cardano Node.

jail(8)

The jail command is used to create, modify and destroy jails. Creating a jail usually means running some program specified by command or exec.start parameters. When the jailed process exits (and all children processes spawned by it) the jail itself gets destroyed. The path passed in either of these parameters is resolved against jail's root, not the host filesystem.

Another important parameter is called path. It sets the directory that will be used as a root for the jail.

Now we're ready to run a simple program under a jail:

mkdir /tmp/myjail
cp /rescue/echo /tmp/myjail/
jail -c path=/tmp/myjail command=/echo Hello from jail

Hello from jail

Library dependencies

Why did I use echo program from the /rescue/ directory for the running example? Well, let’s try the usual /bin/echo:

cp /bin/echo /tmp/myjail/
jail -c path=/tmp/myjail command=/echo Hello from jail

ELF interpreter /libexec/ld-elf.so.1 not found, error 2
jail: /echo Hello from jail: exited on signal 6

Most programs in the wild are dynamically linked which means they need all libraries they are linked to and the runtime linker to start. So, to make the dynamic echo work we’d need to put all this stuff into the chroot too:

mkdir /tmp/myjail/libexec
cp /libexec/ld-elf.so.1 /tmp/myjail/libexec/
mkdir /tmp/myjail/lib
cp /lib/libc.so.7 /tmp/myjail/lib/
jail -c path=/tmp/myjail command=/echo Hello from jail

Hello from jail

The ldd utility can be used to find out which libraries are required by a given executable:

ldd /usr/local/bin/cardano-node

/usr/local/bin/cardano-node:
        libssl.so.111 => /usr/lib/libssl.so.111 (0x200000)
        libcrypto.so.111 => /lib/libcrypto.so.111 (0x200000)
        libz.so.6 => /lib/libz.so.6 (0x200000)
        libutil.so.9 => /lib/libutil.so.9 (0x200000)
        libgmp.so.10 => /usr/local/lib/libgmp.so.10 (0x200000)
        libm.so.5 => /lib/libm.so.5 (0x200000)
        librt.so.1 => /usr/lib/librt.so.1 (0x200000)
        libdl.so.1 => /usr/lib/libdl.so.1 (0x200000)
        libffi.so.8 => /usr/local/lib/libffi.so.8 (0x200000)
        libthr.so.3 => /lib/libthr.so.3 (0x200000)
        libc.so.7 => /lib/libc.so.7 (0x200000)

Instead of copying all of them by hand the following shell incantation can be used:

ldd ${command} | cut -s -d " " -f 3 | grep -E '^(/lib|/usr)' | sort -u | xargs -I % cp % ${jail_root}/lib/

devfs

A lot of programs make access to device pseudo-files under the /dev/ directory. Missing /dev/random may cause obscure runtime errors, which are hard to diagnose. To provide an access to the /dev/ stuff for the jailed program the devfs filesystem has to be mounted inside the jail:

mkdir /tmp/myjail/dev
mount -o ruleset=4 -t devfs devfs /tmp/myjail/dev

The ruleset=4 parameter corresponds to the [devfsrules_jail=4] section of the /etc/defaults/devfs.rules file. This ruleset defines a minimal set of device pseudo-files that should be available for a common jail.

Don't forget to unmount this path after the jail is destroyed:

umount /tmp/myjail/dev

Accessing parts of the host filesystem

Jailed program can't access any paths on the host filesystem on their own. To allow them to do so the loopback filesystem is used:

mkdir /tmp/not_in_jail
mkdir /tmp/myjail/in_jail
mount_nullfs /tmp/not_in_jail /tmp/myjail/in_jail

Now the jail can access the contents of /tmp/not_in_jail directory as /in_jail. Roughly speaking, mount_nullfs acts as ln -s but with ability to span chroot boundaries. Files can be mounted with mount_nullfs just like directories.

The cardano_node RC script uses this trick to hide the fact that the daemon runs under the jail. To do that the config and log directories provided by the user are null-mounted into the jail to make them reachable by the daemon.

Running under a different user

Setting exec.jail_user jail parameter will cause the starting command to run under specified user. If exec.system_jail_user is also present, the user information is taken from the host /etc/passwd. Thanks to this parameter, no in-jail user setup should be performed each time the jail starts.

Jail networking

To allow the jail to listen for and accept incoming connections it is sufficient to pass ip4=inherit, ip6=inherit and host=inherit jail parameters during its creation.

Wrapping up

There are a lot more parameters worth mentioning, but man 8 jail will do it better. For a complete example of the jailed service script see ${PORTSDIR}/net-p2p/cardano-node/files/cardano_node.in.