Side Quest

Rootless Podman and SELinux: Why container_t Cannot Touch httpd_sys_content_t

Difficulty: Advanced | Reward: +200 XP | Prerequisites: Level 5 - Container Security

Mission Briefing

Here is a side quest for the weekend. It takes ten minutes, one rootless Podman container, and a single host directory. If you already know exactly why the denial below happens, skip to the scoring box and check whether you can name all three fixes. If you got the denial once, shrugged, and slapped a :z on the volume until it went away, this is the level where you learn what that flag actually did.

The setup is the most common container-plus-SELinux moment there is. You have some content on the host. You want a container to read it. You bind-mount the directory, start the container, and the process inside cannot read a single file. Nothing is broken. SELinux is doing precisely what it was told. The trick is learning to read what it was told.

Set the Scene

We are rootless, so everything here runs as your normal user with Podman. No sudo, no root daemon. That matters less to SELinux than people expect, and we will get to why. First, make a directory and put a file in it.

[you@gamehost ~]$ mkdir ~/webroot && echo "hello from the host" > ~/webroot/index.html
[you@gamehost ~]$ ls -Z ~/webroot
unconfined_u:object_r:user_home_t:s0 index.html

Note the type: user_home_t. That is the label everything in your home directory carries by default. Hold onto it. Now mount that directory into a container and try to read the file from inside.

[you@gamehost ~]$ podman run --rm -v ~/webroot:/usr/share/nginx/html:ro \
  registry.access.redhat.com/ubi9/nginx-124 cat /usr/share/nginx/html/index.html
cat: /usr/share/nginx/html/index.html: Permission denied

Permission denied, and the file is world-readable. This is the denial. If you disable enforcing to make it work, you have failed the side quest and you owe us a replay of Level 1. We are going to read the log instead.

Read the Denial Before You Fix It

Every good container-SELinux fix starts in the audit log, exactly like it did in Level 7. Pull the most recent AVC.

[you@gamehost ~]$ ausearch -m AVC -ts recent
type=AVC msg=audit(1756290000.114:412): avc: denied { read } for
pid=5120 comm="cat" name="index.html" dev="dm-0" ino=41022
scontext=system_u:system_r:container_t:s0:c142,c711
tcontext=unconfined_u:object_r:user_home_t:s0
tclass=file permissive=0

There is the whole story in two lines. The process is running as container_t. Podman assigned it a unique pair of MCS categories, c142,c711. The file it wants is still labeled user_home_t, with no categories at all. Two separate things are wrong here, and understanding both is the point of the quest.

Problem One: Type Enforcement

SELinux type enforcement is an allowlist. A process type may touch a file type only if the loaded policy contains an explicit rule saying so. There is no rule that lets container_t read user_home_t, and there should not be, because a confined container reaching into your home directory is exactly the thing svirt exists to stop. The container is not allowed to read arbitrary host labels. It is allowed to read one file type in particular.

[you@gamehost ~]$ sesearch -A -s container_t -c file -p read | grep container_file_t
allow container_t container_file_t:file { read getattr open ... };

That is the type the container is waiting for: container_file_t. Content that carries it can be read by container_t. Content labeled user_home_t, httpd_sys_content_t, etc_t, or anything else is off the menu by design. If you have ever wondered why a host directory that Apache reads perfectly (httpd_sys_content_t) is invisible to a container, this is why. The web server type and the container type are different allowlists. Renaming or moving the file does nothing, because type enforcement does not care what a file is called. It cares what it is labeled. That is the same lesson from the type-enforcement guide, seen from the container side.

Problem Two: The Categories

Even if you relabel the file to container_file_t, you are only halfway. Look again at the process context: s0:c142,c711. Podman hands every container a unique category pair drawn from the Multi-Category Security range. This is svirt, and it is what keeps two containers on the same host from reading each other's files even when both are container_file_t. A container can read a container_file_t file only if the file's categories are a subset of the process's categories. A file with no categories, or with a different pair, stays denied.

So the correct fix has to solve both problems at once: change the type to container_file_t, and give the file the calling container's exact category set. Doing that by hand is miserable. This is what the volume flags are for.

The Three Fixes, Ranked

You earn the achievement by naming all three before you read them. Points awarded on the honor system, the way we always do it.

Fix one, the :z and :Z volume flags. Append one of these flags to the mount and Podman relabels the host content for you.

[you@gamehost ~]$ podman run --rm -v ~/webroot:/usr/share/nginx/html:ro,Z \
  registry.access.redhat.com/ubi9/nginx-124 cat /usr/share/nginx/html/index.html
hello from the host

The difference between the two flags is the entire reason we made you read the categories. Lowercase :z relabels the content to container_file_t with a shared label and no private category, so several containers can read the same volume. Uppercase :Z relabels it to container_file_t with the calling container's private category pair, so only that container can read it. Powerful, and a footgun if you point it at the wrong directory. Running :Z against a path that other services also use will relabel their files out from under them, and pointing either flag at something like /home or /var will recursively rewrite labels you very much wanted to keep. Mount narrow directories, never system ones.

Fix two, relabel it yourself with chcon. When you want to see the mechanism rather than let Podman hide it, set the type directly.

[you@gamehost ~]$ chcon -t container_file_t ~/webroot/index.html
[you@gamehost ~]$ ls -Z ~/webroot/index.html
unconfined_u:object_r:container_file_t:s0 index.html

This gets you past the type problem. It does not set categories, so it works with :z-style shared access but a :Z private-category container will still be denied. And chcon is temporary: the next restorecon, relabel, or policy-driven relabel resets it to the default. Which is the whole reason fix three exists.

Fix three, make it permanent with semanage fcontext. If a directory is going to serve containers for the long haul, teach the policy that container_file_t is its correct default, then apply it.

[you@gamehost ~]$ sudo semanage fcontext -a -t container_file_t "/srv/webroot(/.*)?"
[you@gamehost ~]$ sudo restorecon -Rv /srv/webroot
Relabeled /srv/webroot/index.html from ...:user_home_t:s0 to ...:container_file_t:s0

Now the label survives a relabel, because it is the documented default rather than a manual override. This is the same semanage fcontext then restorecon pattern you used for the labelling denials in Level 7. The tool does not change based on whether the consumer is a web server or a container. The label is the whole conversation.

Why Rootless Did Not Save You

People assume rootless Podman means SELinux stops mattering, because the container is already running as an unprivileged user inside a user namespace. It does not. The user namespace confines UIDs and GIDs. SELinux confines types. They are two independent layers, and the process still runs as container_t with its own MCS categories regardless of which user launched it. A rootless container that escapes its user-namespace mapping, through a kernel bug or a misconfigured mount, still hits the type-enforcement wall on the way to anything it was not labeled to touch. That is the entire argument for leaving SELinux on under rootless containers, not off. Two locks on the door are the point, not redundancy to be trimmed.

ACHIEVEMENT UNLOCKED

Container Confinement, Tier 1

You can read a container AVC denial, name both the type problem and the category problem from the scontext and tcontext alone, and pick the right one of three fixes for whether the access is temporary, shared, or permanent. +200 XP, and a permanent immunity to reflexively reaching for a broad :Z on a directory you did not mean to relabel.

Next: the full multi-service version of this thinking is Level 6, the Boss Level, where containers are only one of the things denied. If the category mechanics are new to you, the SELinux vs AppArmor guide explains why a path-based model cannot express MCS at all.