We’re going to learn SELinux by trying to confine a daemon (gist with the code - please be careful!) that we need on our server for its incredibly powerful functionality. Our end goal is to ensure that, even if danger-daemon gets compromised, the attacker will not get full root access to our system. The working environment for this post is a Fedora 33 Server with the default SELinux configuration.

This walkthrough assumes you’ve understood basic concepts of SELinux but want to actually see something done. If that isn’t the case, we recommend going to the following resources:

danger-daemon

Let’s look at a few ways to use the daemon. It starts via systemd:

# systemctl start danger-daemon.service

It’ll log to the journal:

# journalctl -u danger-daemon.service -e

....
danger-daemon[26367]: Couldn't access /etc/danger-daemon/conf so using defaults
danger-daemon[26367]: Configuration:
danger-daemon[26367]: Password = P@ssw0rd
danger-daemon[26367]: Port = 1065
danger-daemon[26367]: IP = 127.0.0.1

danger-daemon may need to access its configuration file at /etc/danger-daemon/conf and all files in /var/log. Here is some sample usage:

$ nc 127.0.0.1 1065
Available commands:

login - authenticate
access? - check authentication
readlog - read a log file
exit - exit
?/help - show this help

> readlog
Authenticate first!
> login
Password: P@ssw0rd
Access granted!
> readlog
...
<snip outout of tree /var/log>
...
8 directories, 33 files
>> Log file: /etc/passwd
Hey, what's the big idea? That isn't in /var/log/!

Basically, the user connects to the TCP server, authenticates with a password, and then is given a tree of all files/directories in /var/log and asked which one they’d like to read. It attempts to validate the file name, but it has a directory traversal bug:

Log file: /var/log/../../etc/passwd
<snip>
root:x:0:0:root:/root:/bin/bash
<snip>

And it has a secret backdoor:

$ nc 127.0.0.1 1065
Available commands:

login - authenticate
access? - check authentication
readlog - read a log file
exit - exit
?/help - show this help

> please give me a shell
Sure!
[root@localhost]# id
id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

Danger daemon is an intentionally buggy log reading program, but these types of issues may exist in real products (danger-daemon’s secret backdoor can be viewed instead as an exploit chain leading to a shell). You’d like to be able to use your log reading software without worry, so is there any way to use SELinux to hopefully protect our server if someone discovers these bugs?

Generated Policy

First, lets generate some files:

$ sepolicy generate --init $(which danger-daemon)
Created the following files:
/home/example/danger-daemon/danger-daemon.te # Type Enforcement file
/home/example/danger-daemon/danger-daemon.if # Interface file
/home/example/danger-daemon/danger-daemon.fc # File Contexts file
/home/example/danger-daemon/danger-daemon_selinux.spec # Spec file
/home/example/danger-daemon/danger-daemon.sh # Setup Script

The most important file to understand in here is danger-daemon.te. This is the “type enforcement” file. There isn’t a lot of easy to digest information out there, but selinuxproject.org has some info and so does the SELinuxProject Github. Luckily we don’t have to be rocket surgeons to write the policy we want, so let’s just start looking at things.

You can just run the danger-daemon.sh script as root before even looking at the other files and see if anything has happened. After running it you’ll notice that there is a fancy new context for the danger-daemon binary:

$ ls -Z $(which danger-daemon)
system_u:object_r:danger_daemon_exec_t:s0 /usr/bin/danger-daemon

It seems like everything works fine when we restart the daemon, but we can see things happening using ausearch.

# ausearch -m AVC -ts recent

The ausearch tool is used to query the audit daemon logs. In this case we’re looking for recent AVC (Access Vector Cache) type messages: these are messages that are related to SELinux access. Here is the log from startup:

type=AVC msg=audit(..): avc:  denied  { create } for ... comm="danger-daemon" scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:system_r:danger_daemon_t:s0 tclass=tcp_socket permissive=1
type=AVC msg=audit(..): avc:  denied  { setopt } for  ...  comm="danger-daemon" scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:system_r:danger_daemon_t:s0 tclass=tcp_socket permissive=1
type=AVC msg=audit(..): avc:  denied  { bind } for  ... comm="danger-daemon" scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:system_r:danger_daemon_t:s0 tclass=tcp_socket permissive=1
type=AVC msg=audit(..): avc:  denied  { name_bind } for ... comm="danger-daemon" src=1065 scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=1
type=AVC msg=audit(..): avc:  denied  { node_bind } for  ... comm="danger-daemon" saddr=127.0.0.1 src=1065 scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:object_r:node_t:s0 tclass=tcp_socket permissive=1
type=AVC msg=audit(..): avc:  denied  { listen } for ... comm="danger-daemon" laddr=127.0.0.1 lport=1065 scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:system_r:danger_daemon_t:s0 tclass=tcp_socket permissive=1

These 6 errors are fairly clear: we can see that the daemon should not be allowed to perform the socket operations – create, setsockopt, bind, name_bind, node_bind, and listen – required to set up a server, but permissive=1 says SELinux is acting permissively (it is only logging errors but allowing things to happen). Let’s move to those generated files now to see what is going on. Here is the danger_daemon.te file that was generated:

policy_module(danger_daemon, 1.0.0)

########################################
#
# Declarations
#

type danger_daemon_t;
type danger_daemon_exec_t;
init_daemon_domain(danger_daemon_t, danger_daemon_exec_t)

permissive danger_daemon_t;

########################################
#
# danger_daemon local policy
#
allow danger_daemon_t self:process { fork };
allow danger_daemon_t self:fifo_file rw_fifo_file_perms;
allow danger_daemon_t self:unix_stream_socket create_stream_socket_perms;

domain_use_interactive_fds(danger_daemon_t)

files_read_etc_files(danger_daemon_t)

miscfiles_read_localization(danger_daemon_t)

We see the permissive danger_daemon_t line. What happens if that is removed and rebuilt? Now the server won’t start and the errors look like this:

type=AVC msg=audit(1614832054.757:2868): avc:  denied  { create } for ... comm="danger-daemon" scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:system_r:danger_daemon_t:s0 tclass=tcp_socket permissive=0

We see in the standard output that the daemon is failing:

# journalctl -u danger-daemon.service
... danger-daemon[16832]: socket: Permission denied

Permissive mode is now off and danger-daemon can’t run because it can’t create a socket. So now we’ve completely gutted the permissions of danger-daemon and need to rebuild them. Using permissive danger_daemon_t is actually quite helpful during development, because it just displays errors instead of causing real errors. Let’s remove that line because we want to see the actual failures.

Let’s also remove most of this file because we don’t really need it. We’re left to start building from this stripped policy:

policy_module(danger_daemon, 1.0.0)

type danger_daemon_t;
type danger_daemon_exec_t;

# We're a daemon and want init to be able to start us.
init_daemon_domain(danger_daemon_t, danger_daemon_exec_t)

# This is a forking tcp server, so we'll need to be able to fork.
allow danger_daemon_t self:process { fork };

Granting permissions

So we need to say what danger-daemon is allowed to do, and the ausearch tool output helps do that. It looks like the first thing we need to do is allow it to perform certain actions on a socket. We can accomplish this with allow statements. These are of the form:

allow SOURCE TARGET:OBJECT { ACTIONS };

The object in this case is tcp_socket, and we want to be able to perform the actions seen in the ausearch output. The source should just be the domain created by the generated files, danger_daemon_t, and target will vary depending on what the output said:

allow danger_daemon_t self:tcp_socket {
    accept bind create setopt listen read write getattr ioctl shutdown
};
allow danger_daemon_t unreserved_port_t:tcp_socket { name_bind };
allow danger_daemon_t node_t:tcp_socket { node_bind };

The first line is fairly straightforward, many of the actions we want to do map exactly to the C functions we want to call and there is no other domain involved, so we use self. The next line allows the process to bind to unreserved ports, but the target domain is different this time, unreserved_port_t. To see port domains check this command:

# semanage port -l
...
unreserved_port_t              sctp     1024-65535
unreserved_port_t              tcp      61001-65535, 1024-32767
unreserved_port_t              udp      61001-65535, 1024-32767
...

So with that allow statement the process is maybe allowed to bind to a port in that range. Since danger-daemon is configurable, that is important to remember: it can’t bind to any port out of those ranges. We said maybe, because if you look at the output of that command, there are actually a lot of ports inside of that range that are assigned to some other domain. For example, if we try to bind to port 5000 with this current Fedora setup:

type=AVC msg=audit(..): avc:  denied  { name_bind } for ... comm="danger-daemon" src=5000 scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:object_r:commplex_main_port_t:s0 tclass=tcp_socket permissive=0

This is because port 5000 already belongs to commplex_main_port_t:

# semanage port -l | grep 5000
commplex_main_port_t           tcp      5000

This is a gotcha, but if you see bind errors and have bind permissions, it might be that you need to use semanage to assign the port to your type. Note that this could also be used to restrict danger-daemon to only bind on a single port by adding a danger_daemon_port_t type, assigning it to a port with semanage, and then adding a rule to allow binding. This also stops other processes on the server from binding danger-daemon’s port and potentially imitating it. This is fairly straightforward to do:

# semanage port -a -t danger_daemon_port_t -p tcp 1065

We’re not going to go that route here though.

Anyway, we can use the service again, kinda:

$ nc 127.0.0.1 1065
?
login - authenticate
access? - check authentication
readlog - read a log file
exit - exit

Seems to work, but we run into an issue when trying to run readlog:

$ nc 127.0.0.1 1065
login
Password: pass
Access granted!
readlog

Seems like something happened. The logs show an error and ausearch shows:

type=AVC msg=audit(..): avc:  denied  { execute } for ... scontext=system_u:system_r:danger_daemon_t:s0 tcontext=system_u:object_r:bin_t:s0 tclass=file permissive=0

Looks like the program needs to execv in order to run readlog. Let’s give it the permissions then:

allow danger_daemon_t bin_t:file { map read execute execute_no_trans };

This brings us a little further:

$ nc 127.0.0.1 1065
login
Password: pass
Access granted!
readlog
/var/log [error opening dir]

0 directories, 0 files

Is there something special about the permissions of /var/log?

ls -Z /var | grep log
    system_u:object_r:var_log_t:s0 log

There is! It has the var_log_t domain and thus everything in that directory also has it by default unless otherwise stated (children inherit parent domains). We need to allow the daemon to read directories and files in the var_log_t domain. Simple enough:

allow danger_daemon_t var_log_t:{ file dir } { open read getattr };
allow danger_daemon_t var_log_t:dir { search };

This let’s us list the base /var/log/ directory, but there are still a lot of different domains in /var/log/ that aren’t var_log_t, for example firewalld has its own type:

system_u:object_r:firewalld_var_log_t:s0 firewalld

We’ll have to do the same allow as above for each and every one of them that we want the daemon to be able to read. To make this easier, use define to make macro:

define(`read_file_dir', `
    require { type $1; }
    allow danger_daemon_t $1:{ file dir } { open read getattr };
    allow danger_daemon_t $1:dir { search }
')

....
read_file_dir(var_log_t);
read_file_dir(firewalld_var_log_t);
...

This has restored all of the important functionality to the daemon, and there is a bonus:

$ nc 127.0.0.1 1065
please give me a shell

We can’t use the backdoor to get a shell anymore and the directory traversal bug is less of an issue:

Log file: /var/log/../../etc/shadow
/usr/bin/cat: /var/log/../../etc/shadow: Permission denied

We encourage you to really take a minute to appreciate what just happened and just how impressive these results really are. All we had to specify is what we wanted danger-daemon to do and in doing so we stopped it from doing things we didn’t want it to do. We were also able to specify the things we wanted danger-daemon to do without ever referencing the source code. If some would be hacker attempts to compromise danger-daemon, they may just be stopped in their tracks just because we wrote a single 50 line file.

There is one more thing to take care of. With the current policy in place, if danger-daemon is started via an interactive session by an unconfined user, this policy is not enforced. This post is running long, so we’ll just share the additions needed to stop this from happening. There needs to be a type transition added from the unconfined_t to danger_daemon_t:

role unconfined_r types danger_daemon_t;
type_transition unconfined_t danger_daemon_exec_t:process danger_daemon_t;

Final setup

The final type enforcement file is fairly easy to understand:

policy_module(danger_daemon, 1.0.0)

require {
    type unreserved_port_t, node_t, bin_t, unconfined_t;
    class process transition;
    role unconfined_r;
}

type danger_daemon_t;
type danger_daemon_exec_t;

# Respect this policy even when started from an unconfined context
role unconfined_r types danger_daemon_t;
type_transition unconfined_t danger_daemon_exec_t:process danger_daemon_t;

# Allows init to start the process as a daemon
init_daemon_domain(danger_daemon_t, danger_daemon_exec_t)

# Define our own type for this daemon's etc files
type danger_daemon_etc_t;
files_type(danger_daemon_etc_t);

# The process uses `fork` for handling connections.
allow danger_daemon_t self:process { fork };

# Allow the daemon to be a TCP server and act on sockets
allow danger_daemon_t self:tcp_socket {
        accept bind create listen read
        write getattr ioctl shutdown
        setopt
};
allow danger_daemon_t unreserved_port_t:tcp_socket { name_bind };
allow danger_daemon_t node_t:tcp_socket { node_bind };

# readlog requires the ability to execv
allow danger_daemon_t bin_t:file { read map execute execute_no_trans };

define(`read_file_dir', `
        require { type $1; }
        allow danger_daemon_t $1:{ file dir } { open read getattr };
        allow danger_daemon_t $1:dir { search }
')

# readlog requires the ability to read files in the log dir.
read_file_dir(var_log_t);
read_file_dir(samba_log_t);
read_file_dir(faillog_t);
read_file_dir(var_log_t);
read_file_dir(sssd_var_log_t);
read_file_dir(lastlog_t);
read_file_dir(rpm_log_t);
read_file_dir(firewalld_var_log_t);
read_file_dir(chronyd_var_log_t);
read_file_dir(plymouthd_var_log_t);
read_file_dir(auditd_log_t);

# Read its own config files
read_file_dir(danger_daemon_etc_t);

The danger-daemon.fc file was also updated:

/usr/bin/danger-daemon          --      gen_context(system_u:object_r:danger_daemon_exec_t,s0)
/etc/danger-daemon(/.*)?                gen_context(system_u:object_r:danger_daemon_etc_t,s0)
/etc/danger-daemon/danger-daemon.service        --      gen_context(system_u:object_r:systemd_unit_file_t,s0)

This protects danger-daemon’s files from other processes.

Takeaways

SELinux is more approachable than some might think, and it is incredibly powerful. The danger-daemon process was running as root and allowed for complete unauthenticated compromise of our server, but now it has been sandboxed to have limited effect. This small type enforcement file may very well be capable of keeping your company off the front page.

Obviously the bugs in danger-daemon need to be fixed to have a secure setup here, but at least the defense in depth aspect of SELinux saves us something.

Extra Credit: Why the path traversal still exists

If you followed along at home you might have noticed that the path traversal still lets you read arbitrary files in the etc_t domain. To try to figure out why this is, there is another useful SELinux tool called sesearch. Let’s see if there are any allow rules of interest:

# sesearch -s danger_daemon_t -t etc_t -A
allow domain base_file_type:dir { getattr open search };
allow domain base_ro_file_type:dir { ioctl lock read };
allow domain base_ro_file_type:file { getattr ioctl lock open read };
allow domain base_ro_file_type:lnk_file { getattr read };
allow domain file_type:blk_file map; [ domain_can_mmap_files ]:True
allow domain file_type:chr_file map; [ domain_can_mmap_files ]:True
allow domain file_type:file map; [ domain_can_mmap_files ]:True
allow domain file_type:lnk_file map; [ domain_can_mmap_files ]:True

Turns out, that init_daemon_domain gives the type attribute domain to danger_daemon_t, and with this comes to privilege to read base_ro_file_type objects. Another useful tool, seinfo, let’s us see why this gives us read access to etc_t files:

# seinfo -x -t etc_t

Types: 1
   type etc_t alias { snmpd_etc_t automount_etc_t }, base_file_type, base_ro_file_type, configfile, file_type, non_auth_file_type, non_security_file_type;

This is due to the base SELinux setup on this Fedora server. In some cases it may be worth looking in to how to fix this, but this does show that it is probably worth giving your configuration files a custom domain like we did above with danger_daemon_etc_t.