SELinux is a very fine-grained access control system for Linux, which can be used to significantly increase the security of the OS. I had some difficulty building a mental model of SELinux, and I’m simply documenting this for myself and hopefully others here.

What to expect: SELinux’s basic concepts, some examples, and also links to in-depth documentation to go further. After reading the text, you should be able to understand how the access control works, and how to figure out why a particular access is authorized or denied.

The basics

Note that SELinux is not enabled on every Linux distribution. Fedora has it enabled by default, but not Debian.

Posix ACLs

To understand what SELinux is about, I find it useful to start from the much simpler Posix ACLs.

In the case of a regular file, the system defines:

  • an owning user
  • an owning group
  • three permission types: read, write, execute
  • whether each permission is granted for: the owning user, the owning group, and everybody else.

For example if we have these settings:

-rwxr----.  bob guest  example_binary

Then it is straightforward to determine which access to this file a process owned by a certain user has:

  • if the process is owned by ‘bob’, then it can read/write/execute example_binary
  • if the process is owned by ‘alice’ who is in the group ‘guest’, then it can only read example_binary but not modify or execute it
  • if the process is owned by ‘carlos’ who is not in the group ‘guest’, then no access is allowed.

The Posix ACL system has the following characteristics:

  • each file has some security attributes directly attached to it (user, group, permissions)
  • permissions are specified for each file independently
  • permissions can be modified by the user who owns the file (or root)
  • whether access is granted or not can be computed from (1) the security attributes attached to the file, and (2) the user owning the process requesting the access
  • the algorithm used to compute the access is fixed.

For more detail: Description of Posix ACLs on Usenix.

SELinux

The SELinux system is completely distinct from the Posix one, and it complements it: a file access request must go through the Posix ACLs then the SELinux system.

We’ll use the example of file access to introduce the core concepts.

In SELinux, every entity has something called a security context attached to it, which is a string with colon-separated parts. For example system_u:object_r:shell_exec_t:s0.

A very important distinction compared to the Posix ACLs is that security contexts represent membership in “sets”: they do not directly define permissions, and the exact same context can (and is) used for multiple entities.

In the case of files, the context is stored on the filesystem, and can be printed using ls -Z. For example:

$ ls -Z /bin/bash ~/example_binary
system_u:object_r:shell_exec_t:s0    /bin/bash
unconfined_u:object_r:unlabeled_t:s0 ~/example_binary

The security context attached to processes can be printed using the same -Z flag, with ps. For example:

$ ps -Z
LABEL                                                 PID   TTY   TIME     CMD
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 16096 pts/2 00:00:00 bash
system_u:system_r:syslogd_t:s0                        1409  Ssl   0:03     /usr/sbin/rsyslogd -n

A process’s security context is related but not identical to the security context of the binary file that was executed. The way it is computed is beyond the scope of this short explanation (to learn about it later, the keyword is “domain transition”).

In order to determine whether the bash process has access to the example_binary file, you only need three inputs:

  • the security context associated with the process
  • the security context associated with the file
  • the file type.

The actual access decision is then computed through a set of rules contained in a policy defined by the administrator. The policy is possibly unique to the local computer. This means in the general case that it is not possible to mentally compute the access decision: you have to look up the actual rules. This gives SELinux extreme flexibility in terms of access control, which comes at the cost of significant complexity.

Importantly, security contexts are assigned automatically and can only be modified by users within strict limits that are also part of the policy. In some cases, users can’t change contexts at all - but the administrator can also decide to give a lot of leeway to certain groups of users.

Such a system makes it possible to perfectly compartimentalize what certain groups of users can access: if Alice works on project1 and Bob on project2, it is possible to make sure that Alice and Bob can not accidentally grant the other access to a file they should not have access to. While Posix ACLs makes it possible to set up similar restrictions, they rely on Alice and Bob doing the right thing and not making any mistakes, which may not be acceptable in certain scenarios.

In this section we’ve taken the example of a regular file and a process, but SELinux supports many more entities than files, for example INET sockets, filesystems, semaphores, shared memory, etc. These are called classes in SELinux linguo. Different access rules can be defined for each class. Use seinfo -c to print the full list of classes on your system.

This also means that the set of supported permissions depends on the class. They are generally very fine grained, and in the case of file the difference with the Posix ACLs is particular large. seinfo --common file -x prints the full list of supported permissions for a regular file. Here’s the output on my system:

$ seinfo --common file -x

Commons: 1
   common file
{
	append
	audit_access
	create
	execmod
	execute
	getattr
	ioctl
	link
	lock
	map
	mounton
	open
	quotaon
	read
	relabelfrom
	relabelto
	rename
	setattr
	swapon
	unlink
	watch
	watch_mount
	watch_mountns
	watch_reads
	watch_sb
	watch_with_perm
	write
}

In summary, SELinux has the following characteristics:

  • each entity (class) has a security context attached to it
  • access decisions are computed from three inputs: (1) the security context attached to the process requesting access, (2) the security context attached to the entity that is being accessed, and (3) its class (e.g. ‘file’)
  • access decisions are computed using a set of rules contained in a policy, not a fixed algorithm. These rules are defined by an administrator, and can be completely different from one system to another.

Security Context

Now let’s dive into what a security context contains.

Note that since SELinux policies are extremely flexible, I will from now on use examples from the default policy shipped with Fedora 42 to avoid making the text too abstract,

Unless the policy says otherwise, contexts are by default inherited from the “parent” entity.

For example, a process has the same context as the parent, a file created inside a directory has the same context than the parent directory, etc.

Overview

A security context is composed of 3 to 5 parts, separated by colons, and always has the same structure regardless of the class:

user:role:type:level:category

user, role, and type are always present. Depending on the entity and the local configuration, level and category can be present or not.

Example 1: let’s recall the security context for the /bin/bash file that was mentioned above:

system_u:object_r:shell_exec_t:s0
  • the SELinux user is system_u
  • the role is object_r
  • the type is shell_exec_t
  • the level is s0
  • the category is absent.

level is only used in very high security situations, and for a stock consumer distribution like Fedora it will have the same value everywhere (s0). For the sake of brevity, I will not mention it further because it won’t play any role. However, category is used for containers (docker, podman).

In broad strokes:

  • The type is used to make access decision (with the category). Policy rules take as input the type associated with the process requesting access, the type associated with the entity, and its class.
  • The role determines which types can follow in the context
  • The user determines which roles can follow in the context

A very coarse mental model is: each unix user (those defined in /etc/passwd) has a type of job associated with it. This is described by the SELinux user (e.g. sysadm_u: system administrator). Each type of job can have distinct tasks they can perform: each task is described by a role. Each task requires some accesses, this is described by the type.

Processes launched by the system have either system_u or unconfined_u as users in their context.

Example 2: Security context for the first process after login

Unix users are mapped to a SELinux user, which is used to set the user part of the security context of the first process on login. This mapping can be printed with semanage login -l (as root):

# semanage login -l

Login Name           SELinux User         MLS/MCS Range        Service

__default__          unconfined_u         s0-s0:c0.c1023       *
root                 unconfined_u         s0-s0:c0.c1023       *

In that case, all users are mapped to the unconfined_u SELinux user. It is associated with two roles:

$ seinfo -u unconfined_u -x

Users: 1
   user unconfined_u roles { system_r unconfined_r } level s0 range s0 - s0:c0.c1023;

In that case, the policy is set up such as role unconfined_r is selected by default at login. This role has access to many types (use seinfo -r unconfined_r -x to list them all), and type unconfined_t is selected by default at login.

The final context can be queried with id -Z, which shows the context of the running process (here id, which inherited its context from the shell, which ultimately inherited it from the login process):

$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Extended Example: Context Transitions

We’ve seen above that on Fedora 42, users have by default access to two roles: system_r and unconfined_r, which means they are able to launch processes whose security context have either of these roles. Let’s see how to launch a process with system_r.

What types does system_r give access to? (the list has been been shortened a lot)

$ seinfo -r system_r -x

Roles: 1
   role system_r types { NetworkManager_dispatcher_chronyc_t 
   NetworkManager_dispatcher_cloud_t NetworkManager_dispatcher_console_t 
   NetworkManager_dispatcher_custom_t NetworkManager_dispatcher_ddclient_t
   NetworkManager_dispatcher_dhclient_t NetworkManager_dispatcher_dnssec_t 
   NetworkManager_dispatcher_iscsid_t NetworkManager_dispatcher_nvme_t 
   NetworkManager_dispatcher_sendmail_t NetworkManager_dispatcher_t
   NetworkManager_dispatcher_tlp_t NetworkManager_dispatcher_winbind_t
   NetworkManager_priv_helper_t
   [...]
   vnstatd_t vpnc_t w3c_validator_script_t watchdog_t watchdog_unconfined_t
   wdmd_t webalizer_script_t webalizer_t winbind_helper_t winbind_t
   wine_home_t wine_t wireguard_t wireshark_home_t wpa_cli_t xauth_home_t
   xdm_home_t xdm_t xdm_unconfined_t xenconsoled_t xend_t xenstored_t 
   xserver_t ypbind_t yppasswdd_t ypserv_t ypxfr_t zabbix_agent_t
   zabbix_script_t zabbix_t zarafa_deliver_t zarafa_gateway_t zarafa_ical_t
   zarafa_indexer_t zarafa_monitor_t zarafa_server_t zarafa_spooler_t
   zebra_t zoneminder_script_t zoneminder_t zos_remote_t };

In theory we can use any type listed here. We can test that with the runcon command which makes it possible to change the security context. In that case, replacing the role and type parts compared to the parent’s context:

$ runcon -r system_r -t inetd_child_t /bin/id -Z
unconfined_u:system_r:inetd_child_t:s0

$ runcon -r system_r -t watchdog_t /bin/id -Z
runcon: ‘/bin/id’: Permission denied

It turns out that it is only possible to launch /bin/id from certain types. Even though we have access to any of the types listed above, very few of them make it possible to run /bin/id because of the permissions that are associated with them. The /bin/id file has security context system_u:object_r:bin_t:s0, which is a rather generic type - thus executable by very few types (more on this in the next section).

Let’s up the difficulty by running an executable that does something non trivial (/bin/ls). I picked the context unconfined_u:unconfined_r:container_t for didactic purposes:

# cd ~
$ touch hello
$ ls -Z hello
unconfined_u:object_r:user_home_t:s0 hello

$ ls -Z /bin/ls
system_u:object_r:bin_t:s0 /bin/ls

$ /bin/ls hello
hello

$ runcon -t container_t /bin/ls hello
ls: cannot access 'hello': Permission denied

The above shows:

  • running ls with the parent’s context (unconfined_u:unconfined_r:unconfined_t) works.
  • running ls with unconfined_u:unconfined_r:container_t:s0 fails on a permission denied, on the same file.

In the second invocation, the process got launched successfully (with the changed security context), then when it tried to read hello, permission was denied by SELinux.(you can use strace --secontext to check that). The reason is that container_t is not allowed to access files with type user_home_t: by default, containers can not read any data from a user’s home directory.

If we want to grant a process running with type container_t access to hello, we need to change the security context of the file on disk (“relabel” it) using chcon:

$ chcon -t container_file_t hello
$ ls -Z hello
unconfined_u:object_r:container_file_t:s0 hello

$ runcon -t container_t -l s0 /bin/ls hello
hello

$ /bin/ls hello
hello

Now it works! The domain container_t has access to regular files with type container_file_t. And it turns out that domain unconfined_t also has access to container_file_t: the vanilla ls hello still works.

Access Rules

It’s now time to explain how you can figure out which types (aka domains) have access to what, which will make the examples above a bit less magical.

SELinux is based on an allowlist model: it denies all access requests by default. Any access has to be explictly granted by what is called an Access Vector Rule (AVC) - a fancy way of saying “allow rule”.

An AVC rule has the following structure:

allow <source_type> <target_type> : <class> <perm_set>;

For example, here’s a rule that grants domain container_t access to bin_t files:

allow container_t bin_t:file { entrypoint execute execute_no_trans getattr ioctl lock map open read };

This rule means that domain container_t (i.e. processes whose security context has type container_t) is allowed some access to objects of type bin_t with class file, through one of the permissions specified. Note the absence of write in the permission list: container_t can execute binaries with type bin_t, but they cannot modify existing ones.

There are many allow rules (67976 on my system as of the time of writing) and making sense of them requires assistance. This is what sesearch is for: with it you can retrieve rules that are relevant to your case. For example, let’s answer the question: which domains can execute a file with type bin_t? - the problem we were having above.

The following command searches for such rules (-A restricts to “allow rules” - yes there are other types, -t bin_t restricts to targets that include bin_t, -c file restricts to the ‘file’ class, and -p execute restricts to permission sets that include ‘execute’):

$ sesearch -A -t bin_t -c file -p execute
allow NetworkManager_dispatcher_t base_ro_file_type:file { execute execute_no_trans map };
allow NetworkManager_ssh_t base_ro_file_type:file { execute execute_no_trans map };
allow NetworkManager_t base_ro_file_type:file { execute execute_no_trans map };
allow abrt_dump_oops_t base_ro_file_type:file { execute execute_no_trans };
allow abrt_retrace_coredump_t base_ro_file_type:file { execute execute_no_trans map };
allow abrt_retrace_worker_t base_ro_file_type:file { execute execute_no_trans map };
allow abrt_t base_ro_file_type:file { execute execute_no_trans map };
allow abrt_t exec_type:file { execute execute_no_trans ioctl lock map open read };
allow abrt_upload_watch_t base_ro_file_type:file { execute execute_no_trans map };
allow accountsd_t base_ro_file_type:file { execute execute_no_trans map };
[... skip 365 rules ...]
allow wireshark_t base_ro_file_type:file { execute execute_no_trans map };
allow xend_t base_ro_file_type:file { execute execute_no_trans map };
allow xenstored_t base_ro_file_type:file { execute execute_no_trans map };
allow xguest_dbusd_t bin_t:file { execute map };
allow xguest_gkeyringd_t bin_t:file { execute map };
allow xguest_usertype base_ro_file_type:file { execute execute_no_trans map };
allow yppasswdd_t base_ro_file_type:file { execute execute_no_trans map };
allow ypserv_t base_ro_file_type:file { execute execute_no_trans map };
allow zabbix_domain base_ro_file_type:file { execute execute_no_trans map };
allow zoneminder_t base_ro_file_type:file { execute execute_no_trans map };

You’ll notice that even though we searched for bin_t, other target types appear in the results (for ex. base_ro_file_type) We’ll clarify why below. For now, it’s fine to mentally replace those types with bin_t.

Let’s take the first domain mentioned in the output above: NetworkManager_dispatcher_t, and try running /bin/id in it:

$ runcon -t NetworkManager_dispatcher_t /bin/id -Z
runcon: invalid context: ‘unconfined_u:unconfined_r:NetworkManager_dispatcher_t:s0’: Invalid argument

The error message tells us implicitly that unconfined_r cannot be combined with NetworkManager_dispatcher_t in a context. There are no commands that directly list the roles that can be combined with a particular type, but we can make one using seinfo. The choice is pretty limited:

$ seinfo -r -x | grep ' NetworkManager_dispatcher_t ' | sed -e 's/[ ]*\(.*\)/\1/g' | cut -d ' ' -f 2
system_r

And since unconfined_u can be combined with system_r (see Overview above) we can indeed use this role:

$ runcon -r system_r -t NetworkManager_dispatcher_t /bin/id -Z
runcon: ‘/bin/id’: Permission denied

Uh-oh. It fails again, this time for a different reason. The context we requested (unconfined_u:system_r:NetworkManager_dispatcher_t:s0) is valid but some access is still missing. The error message hints at not being able to execute /bin/id, but let’s check.

Denials are logged (with some exceptions, container_t being one of them), and the log can be queried using ausearch (“audit search”) as root. The following shows the last error that happened today (-m avc restricts to errors of type avc, which is what we’re looking for, -i prints a single line per error and -ts today restricts to errors that happened today). Output reformatted for readability:

# ausearch -m avc -i -ts today | tail -n 1
type=AVC msg=audit([REDACTED]) : avc:  denied  { entrypoint } for
  pid=21612
  comm=runcon
  path=/usr/bin/id
  dev="[REDACTED]"
  ino=3780381
  scontext=unconfined_u:system_r:NetworkManager_dispatcher_t:s0
  tcontext=system_u:object_r:bin_t:s0
  tclass=file
  permissive=0

Now we know that it’s the ‘entrypoint’ permission that was denied. The error message also shows the process name as well as all the information relevant for an AVC rule: source and target contexts, class.

What is this ‘entrypoint’ permission? According to Fedora’s documentation, it is for a file to “be executed as the entry point of the new domain in a transition.” Transition here means changing a process’s domain, here from unconfined_t (the shell’s domain) to NetworkManager_dispatcher_t (what we asked for). We can check that this permission is indeed not granted by running:

$ sesearch -A -s NetworkManager_dispatcher_t -t bin_t -c file -p entrypoint
[no output]

So that’s it: we cannot run /bin/id in domain NetworkManager_dispatcher_t - unless we add another rule allowing it.

If we want to run /bin/id in a domain that is not unconfined_t, we need a type that has both ‘execute’ and ‘entrypoint’ permissions on bin_t. One such domain is inetd_child_t - which I found by trial and error. Output reformatted for readability:

$ sesearch -A -s inetd_child_t -t bin_t -c file -p execute,entrypoint
allow files_unconfined_type file_type:file { 
   append audit_access create execute execute_no_trans getattr ioctl link lock 
   map mounton open quotaon read relabelfrom relabelto rename setattr swapon 
   unlink watch watch_mount watch_mountns watch_reads watch_sb watch_with_perm 
   write };
allow inetd_child_t bin_t:file entrypoint;

The first rule gives ‘execute’ permission, the second one ‘entrypoint’. And indeed things work:

$ runcon -r system_r -t inetd_child_t /bin/id -Z
unconfined_u:system_r:inetd_child_t:s0

Attributes and Aliases

Let’s come back to the question of knowing why when we requested bin_t in sesearch above, results were showing other identifiers. For example, the following returns exactly one rule on my system:

$ sesearch -A -s container_t -t bin_t -c file -p execute,entrypoint
allow svirt_sandbox_domain exec_type:file { entrypoint execute execute_no_trans getattr ioctl lock map open read };

svirt_sandbox_domain and exec_type are called attributes in SELinux linguo: they are sets of types. When they are used in a rule, all types in the set will match. You can list an attribute’s definition with seinfo:

$ seinfo -a svirt_sandbox_domain -x

Type Attributes: 1
   attribute svirt_sandbox_domain;
	container_t
	openshift_initrc_t
	svirt_kvm_net_t
	svirt_qemu_net_t

The rule listed above thus applies equally to container_t, openshift_initrc_t, svirt_kvm_net_t, and svirt_qemu_net_t. I’m not showing the output for exec_type because it contains more than 1000 types: all the file types that have been defined that correspond to something that can be executed.

Attributes make the whole system more modular. It makes sense to allow containers to run any binary, since it’s a generic sandbox. The naive way of doing this would be to write one rule per type. The problem is that people will create new types for programs that they are the only ones to run: they will have to remember to add the rule to give access to container_t. But containers may not be the only sandboxed system, and then you need a rule for each of them. Attributes provide a form of “API”: if you add a new type for a binary, just add it to exec_type attribute. If you add a new sandbox environment, add its type to svirt_sandbox_domain.

If it wasn’t complicated enough, types can also have aliases, which are synonym types. You can list all aliases and attributes for a given type with seinfo. Example, reformatted for readability:

$ seinfo -t container_t -x

Types: 1
   type container_t alias svirt_lxc_net_t, can_dump_kernel,
     can_receive_kernel_messages, container_domain, container_net_domain, 
     corenet_unconfined_type, corenet_unlabeled_type, domain, 
     kernel_system_state_reader, mcs_constrained_type, pcmcia_typeattr_1, 
     process_user_target, sandbox_net_domain, svirt_sandbox_domain, 
     syslog_client_type;

The list that follows the word “alias” contains all the aliases and all the attributes including container_t. How do you tell the difference? Types end (by convention) with _t, attributes do not (though they often end in _domain). You can test each individually with seinfo -a <name>: if the output is empty, <name> is a type, otherwise it’s an attribute.

In summary

  • Attributes are sets of types
  • Aliases are type synonyms

Going further

I hope the above gave you a first understanding of SELinux and how to interact with it. We just scratched the surface here. In particular we only focused on understanding how access is enforced, and left out how to write policies entirely.

In order to learn more, I can not recommend enough the SELinux Notebook, which is as close as a comprehensive reference you’ll find on the internet. RedHat’s documentation is also a very entry point.

To really get into the details: