Running software under SELinux-confined users to protect against exploits

I’ve already tried starting a conversation about it on the Mattermost chat, perhaps this forum will be a better place. Is anyone else looking into SELinux-confined users to protect against the recent exploits and similar future exploits? I’ve tested on one server that Copy Fail, Dirty Frag, Fragnesia and ssh-keysign-pwn fail for users created with useradd -Z user_u username while they all succeed for normal users. I made the tests before updating the kernel or applying mitigations. I didn’t have to make any changes to SELinux configuration.

My plan for migrating the software I’m running from unconfined to confined users is to start with the most restrictive user type (useradd -Z guest_u), configure it with setsebool or audit2allow until the software runs normally, and if it becomes too much work, start again with a less restrictive user type (user_u).

On the web I’ve only found one relevant article, tt’s about Copy Fail and SELinux, but it’s written from Debian perspective. I can’t link to it here because I don’t have the permission to post links. If anyone knows other resources or has any advice please let me know.

I’ll add some details from my tests in case someone finds them helpful.

Copy Fail exploit running as a confined user:

[testseuser1@instance-20260427-1814 copyfail]$ ./copy_fail_exp.py
Traceback (most recent call last):
  File "/home/testseuser1/2026.05.13.exploitcollection/copyfail/copyfail/./copy_fail_exp.py", line 24, in <module>
    while i<len(e):c(f,i,e[i:i+4]);i+=4
                   ^^^^^^^^^^^^^^^
  File "/home/testseuser1/2026.05.13.exploitcollection/copyfail/copyfail/./copy_fail_exp.py", line 11, in c
    u,_=a.accept()
        ^^^^^^^^^^
  File "/usr/lib64/python3.12/socket.py", line 295, in accept
    fd, addr = self._accept()
               ^^^^^^^^^^^^^^
PermissionError: [Errno 13] Permission denied

(I added some line breaks to the Python script to get better stack traces.)

SELinux denials:

time->Thu May 14 12:16:17 2026
type=PROCTITLE msg=audit(1778753777.096:10801): proctitle=707974686F6E33002E2F636F70795F6661696C5F6578702E7079
type=SYSCALL msg=audit(1778753777.096:10801): arch=c000003e syscall=288 success=no exit=-13 a0=4 a1=0 a2=0 a3=80000 items=0 ppid=8064 pid=8100 auid=1003 uid=1003 gid=1003 euid=1003 suid=1003 fsuid=1003 egid=1003 sgid=1003 fsgid=1003 tty=pts0 ses=19 comm="python3" exe="/usr/bin/python3.12" subj=user_u:user_r:user_t:s0 key=(null)
type=AVC msg=audit(1778753777.096:10801): avc:  denied  { accept } for  pid=8100 comm="python3" scontext=user_u:user_r:user_t:s0 tcontext=user_u:user_r:user_t:s0 tclass=alg_socket permissive=0

Running as an unconfined user:

[user1@instance-20260427-1814 copyfail]$ ./copy_fail_exp.py
[root@instance-20260427-1814 copyfail]# 

Dirty Frag exploit running as a confined user:

[testseuser1@instance-20260427-1814 dirtyfrag]$ DIRTYFRAG_VERBOSE=1 ./exp
[su] SIOCSIFFLAGS: Operation not permitted
[su] corruption stage failed (status=0x100)

=== rxrpc/rxkad LPE EXPLOIT (uid=1000 → root) ===
[*] uid=1003 euid=1003 gid=1003
[!] socket(AF_RXRPC): Permission denied — module not loadable?

=== rxrpc/rxkad LPE EXPLOIT (uid=1000 → root) ===
[*] uid=1003 euid=1003 gid=1003
[!] socket(AF_RXRPC): Permission denied — module not loadable?

=== rxrpc/rxkad LPE EXPLOIT (uid=1000 → root) ===
[*] uid=1003 euid=1003 gid=1003
[!] socket(AF_RXRPC): Permission denied — module not loadable?

=== rxrpc/rxkad LPE EXPLOIT (uid=1000 → root) ===
[*] uid=1003 euid=1003 gid=1003
[!] socket(AF_RXRPC): Permission denied — module not loadable?
dirtyfrag: failed (rc=1)

SELinux denials:

time->Fri May 15 12:22:00 2026
type=PROCTITLE msg=audit(1778840520.956:36117): proctitle="./exp"
type=SYSCALL msg=audit(1778840520.956:36117): arch=c000003e syscall=16 success=no exit=-1 a0=3 a1=8914 a2=7ffe25b87110 a3=0 items=0 ppid=19550 pid=19551 auid=1003 uid=1003 gid=1003 euid=1003 suid=1003 fsuid=1003 egid=1003 sgid=1003 fsgid=1003 tty=pts0 ses=29 comm="exp" exe="/home/testseuser1/2026.05.13.exploitcollection/dirtyfrag/dirtyfrag/exp" subj=user_u:user_r:user_t:s0 key=(null)
type=AVC msg=audit(1778840520.956:36117): avc:  denied  { net_admin } for  pid=19551 comm="exp" capability=12  scontext=user_u:user_r:user_t:s0 tcontext=user_u:user_r:user_t:s0 tclass=cap_userns permissive=0
----
time->Fri May 15 12:22:00 2026
type=PROCTITLE msg=audit(1778840520.956:36118): proctitle="./exp"
type=SYSCALL msg=audit(1778840520.956:36118): arch=c000003e syscall=41 success=no exit=-13 a0=21 a1=2 a2=2 a3=0 items=0 ppid=19281 pid=19550 auid=1003 uid=1003 gid=1003 euid=1003 suid=1003 fsuid=1003 egid=1003 sgid=1003 fsgid=1003 tty=pts0 ses=29 comm="exp" exe="/home/testseuser1/2026.05.13.exploitcollection/dirtyfrag/dirtyfrag/exp" subj=user_u:user_r:user_t:s0 key=(null)
type=AVC msg=audit(1778840520.956:36118): avc:  denied  { create } for  pid=19550 comm="exp" scontext=user_u:user_r:user_t:s0 tcontext=user_u:user_r:user_t:s0 tclass=rxrpc_socket permissive=0

Running as an unconfined user:

[user1@instance-20260427-1814 dirtyfrag]$ ./exp
[root@instance-20260427-1814 dirtyfrag]# 

Fragnesia exploit running as a confined user:

[testseuser1@instance-20260427-1814 fragnesia]$ ./exp
[*] uid=1003 euid=1003 gid=1003 egid=1003
[*] mode=xfrm_espintcp_pagecache_replace collateral=after

[*] target=/usr/bin/su size=57344
outer_write_open_denied=1 errno=13 (Permission denied)
userns_setup: outer_uid=1003 outer_gid=1003 ns_uid=0 ns_gid=0
netns_setup=1
namespace_gate_failed: SIOCSIFFLAGS lo up errno=1 (Operation not permitted)

SELinux denials:

time->Fri May 15 13:42:19 2026
type=PROCTITLE msg=audit(1778845339.061:37455): proctitle="./exp"
type=SYSCALL msg=audit(1778845339.061:37455): arch=c000003e syscall=16 success=no exit=-1 a0=3 a1=8914 a2=7fffe1c29710 a3=0 items=0 ppid=20929 pid=20930 auid=1003 uid=1003 gid=1003 euid=1003 suid=1003 fsuid=1003 egid=1003 sgid=1003 fsgid=1003 tty=pts4 ses=42 comm="exp" exe="/home/testseuser1/2026.05.13.exploitcollection/fragnesia/fragnesia/exp" subj=user_u:user_r:user_t:s0 key=(null)
type=AVC msg=audit(1778845339.061:37455): avc:  denied  { net_admin } for  pid=20930 comm="exp" capability=12  scontext=user_u:user_r:user_t:s0 tcontext=user_u:user_r:user_t:s0 tclass=cap_userns permissive=0

Running as an unconfined user:

[user1@instance-20260427-1814 fragnesia]$ ./exp
[*] uid=1001 euid=1001 gid=1001 egid=1001
[*] mode=xfrm_espintcp_pagecache_replace collateral=after

[*] target=/usr/bin/su size=57344
outer_write_open_denied=1 errno=13 (Permission denied)
userns_setup: outer_uid=1001 outer_gid=1001 ns_uid=0 ns_gid=0
netns_setup=1
loopback_up=1
xfrm_espintcp_state_add=1
namespace_setup_complete=1
userns_root_mapped_to_outer_user_write_open_denied=1 errno=13 (Permission denied)

[*] timing: rx_pre_ulp=30000us tx_pre_splice=1000us rx_post_ulp=30000us
[*] range: offset=0x0 len=192 last=0xbf enc_len=4080 splice_len=4096
[*] union: transformed=0x0-0x10ae collateral_after=0xc0-0x10ae
[*] payload=7f454c4602010100000000000000000002003e0001000000780040000000000040000000000000000000000000000000000000004000380001000000000000000100000005000000000000000000000000004000000000000000400000000000b800000000000000b800000000000000001000000000000031ff31f631c0b06a0f05b0690f05b0740f056a00488d0512000000504889e2488d3d1200000031f66a3b580f055445524d3d787465726d002f62696e2f7368000000000000000000

stream0_table_entries=256


[*] smashing 192 bytes into read-only page cache  changed=0  skipped=0  remaining=192
  0000 [7f]45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00 
  0010  02 00 3e 00 01 00 00 00  78 00 40 00 00 00 00 00 
  0020  40 00 00 00 00 00 00 00  00 00 00 00 00 00[*] smashing 192 bytes into read-only page cache  changed=176  skipped=16  remaining=000 00 00 00 00  0000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  01 00 00 00 05 00 00 00  00 00 00 00 00 00  0010  02 00 3e 00 01 00 00 00  78 00 40 00 00 00 00 00  00 00 40 00 00 00 00 00  00 00 40 00 00 00  0020  40 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  b8 00 00 00 00 00 00 00  b8 00 00 00 00 00  0030  00 00 00 00 40 00 38 00  01 00 00 00 00 00 00 00  00 10 00 00 00 00 00 00  31 ff 31 f6 31 c0  0040  01 00 00 00 05 00 00 00  00 00 00 00 00 00 00 00  0f 05 b0 69 0f 05 b0 74  0f 05 6a 00 48 8d  0050  00 00 40 00 00 00 00 00  00 00 40 00 00 00 00 00  00 00 00 50 48 89 e2 48  8d 3d 12 00 00 00  0060  b8 00 00 00 00 00 00 00  b8 00 00 00 00 00
  00a0  6a 3b 58 0f 05 54 45 52  4d 3d 78 74 65 72[*] verifying 192 bytes...
[*] bytes_flip_summary len=192 changed=176 skipped=16 00 
[+] BUG: changed requested copied byte range to desired values (100%)
──────────────────────────────────────────────────
──────────

bash: /root/.bashrc: Permission denied
bash-5.2# 
exit
[user1@instance-20260427-1814 fragnesia]$ su
[root@instance-20260427-1814 fragnesia]# 

ssh-keysign-pwn chage_pwn running as a confined user:

[testseuser1@instance-20260427-1814 ssh-keysign-pwn]$ ./chage_pwn
^C

(I manually stopped it after about 30 seconds.)

SELinux denials:

time->Fri May 15 15:14:25 2026
type=PROCTITLE msg=audit(1778850865.475:83176): proctitle="./chage_pwn"
type=SYSCALL msg=audit(1778850865.475:83176): arch=c000003e syscall=438 success=no exit=-1 a0=3 a1=1b a2=0 a3=0 items=0 ppid=21517 pid=21587 auid=1003 uid=1003 gid=1003 euid=1003 suid=1003 fsuid=1003 egid=1003 sgid=1003 fsgid=1003 tty=pts3 ses=50 comm="chage_pwn" exe="/home/testseuser1/2026.05.13.exploitcollection/ssh-keysign-pwn/ssh-keysign-pwn/chage_pwn" subj=user_u:user_r:user_t:s0 key=(null)
type=AVC msg=audit(1778850865.475:83176): avc:  denied  { ptrace } for  pid=21587 comm="chage_pwn" scontext=user_u:user_r:user_t:s0 tcontext=user_u:user_r:passwd_t:s0 tclass=process permissive=0

Running as an unconfined user:

[user1@instance-20260427-1814 ssh-keysign-pwn]$ ./chage_pwn 
fd 6 -> /etc/shadow (round=1 try=163)
[here comes the content of /etc/shadow]

It would be good to know which exact capability is being denied, thereby stopping the exploit software from running?

You can find the denial entries in my first post. Here I’ll also show them in an easier-to-read format, produced by audit2allow -M

Copy Fail:


module module1 1.0;

require {
        type user_t;
        class alg_socket accept;
}

#============= user_t ==============
allow user_t self:alg_socket accept;

Dirty Frag and Fragnesia:


module module2 1.0;

require {
        type user_t;
        class rxrpc_socket create;
        class cap_userns net_admin;
}

#============= user_t ==============
allow user_t self:cap_userns net_admin;
allow user_t self:rxrpc_socket create;

ssh-keysign-pwn:


module module3 1.0;

require {
        type passwd_t;
        type user_t;
        class process ptrace;
}

#============= user_t ==============
allow user_t passwd_t:process ptrace;

These are the missing privileges, they are related to alg sockets, rxrpc sockets, user namespaces and ptrace. Users created with useradd -Z user_u username don’t have these privileges by default and I think the vast majority of applications don’t need them.

New users can post links but mostly to allowed domains like rocky domains, once you pass the threshold after posting a little bit more, then you’ll be able to post more links. Unfortunate side-effect of spam users registering and posting spam links so we had to restrict it for new users with very little posts to their name. Once the trust level goes up you will be fine. Alternatively, we can add the domain to the allowed list to help make it easier - send us a message if needed.

I’m interested to know if when using staff_u or sysadm_u would the exploit occur. Mainly due to a lot of these using suid like sudo or su then those higher levels that do have access to sudo etc would no longer stop the exploiot from running. Saying that, since those users know they have higher privileges they are unlikely to use or try to use the exploit anyway. Worse, if their account has been compromised.

I think user_u is about the lowest you could go and probably the best place to start from the reading I did on the topic from some pages I found with google. It will take a lot of work though until things become more usable. I know I would have a lot of problems trying to use VPN’s, etc. The idea is interesting though if you don’t mind all the extra work to make it usable.

Thanks for your reply. It’s important to distinguish between user accounts meant for people and user accounts meant for individual applications. For user accounts meant for people, user_u could be too restrictive and staff_u or sysadm_u might be preferred. But when user accounts are meant for individual applications running on a server, staff_u and sysadm_u are probably too permissive for no good reason, even if they are still better than running unconfined.

In my tests today, sysadm_u only prevented Dirty Frag and Fragnesia, it didn’t prevent Copy Fail or ssh-keysign-pwn. staff_u prevented all 4 exploits just like user_u. However with staff_u it’s possible to start a shell with the same privileges as sysadm_u by running newrole -r sysadm_r and entering own account password. user_u is also better protected against potential security bugs in sudo. So I think in terms of security staff_u is about halfway between user_u and sysadm_u.

I only did some quick tests yesterday, but sudo cannot be used by user_u anyway. You need at least staff_u. That said, I did have problems whereby I couldn’t read log files or use tools like audit2allow even with sysadm_u. I expect as the root user I could then get selinux to allow them so for everyday administrative stuff even with the higher levels a lot of extra selinux work is required before it even becomes usable.

That’s what I mean. Because user_u is blocked from using sudo, any security bugs in sudo shouldn’t affect user_u, but they might affect staff_u.

Would it be possible to configure sudo or newrole so that your user is granted unconfined_r role after entering the password? If so, this might be the most realistic option for a confined account that can be used for administrative tasks.

Actually just found out, giving the user sysadm_u would be the wrong thing anyway. The user would be given staff_u, but then in /etc/sudoers.d/ configure a file with an equivalent below:

<example_user> ALL=(ALL) TYPE=sysadm_t ROLE=sysadm_r ALL

taken from here: https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html/using_selinux/managing-confined-and-unconfined-users_using-selinux#confining-an-administrator-using-sudo-and-the-sysadm_r-role_managing-confined-and-unconfined-users

That I would expect probably solve the issues I was having. So we have to look at it at both user and role perspectives.

newrole is easier to set up because it doesn’t require writing any configuration directives, just the policycoreutils-newrole package needs to be installed.

$ newrole -r unconfined_r
Password: 
$ su
Password: 
# id
uid=0(root) gid=0(root) groups=0(root) context=staff_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

This gives an unconfined root shell in which any administrative tasks should be possible. It works because by default unconfined_r is one of the roles that staff_u can switch to:

# semanage user --list | grep staff_u
staff_u         user       s0         s0-s0:c0.c1023                 system_r staff_r sysadm_r unconfined_r

sudo requires configuration but I guess it’s more flexible and can be more convenient if you want to use it often.

All of my applications are now running confined as user_u with no issues. I’ve only made these configuration changes:

setsebool -P selinuxuser_tcp_server on
setsebool -P selinuxuser_udp_server on

These settings apply to user_u and staff_u and they allow TCP and UDP servers on all ports (starting from 1024).

I had no luck with guest_u and xguest_u, there were multiple issues which I wasn’t able to solve.

Creating new confined users is easy with useradd -Z user_u username. Confining existing users is more complicated, this is what has worked for me:

  1. Log in as the user. Stop the running applications and temporarily disable those crontab entries which could trigger during the process. Log out.

  2. Log in as root.

  3. Make sure no processes are running as the user:
    # ps ufww -u username -U username

  4. Clear any existing SELinux mapping for the user:
    # usermod -Z "" username

    If you don’t clear an existing mapping, the next command can fail with strange errors such as: [libsemanage]: MLS range s0-s0:c0.c1023 for Unix user (...) exceeds allowed range s0 for SELinux user user_u

    Set up a new mapping:
    # usermod -Z user_u username

    user_u can be replaced with staff_u, guest_u or xguest_u if this is what you want.

    To list mappings:
    # semanage login --list

    If semanage is missing, the package to install is policycoreutils-python-utils.

  5. Update SELinux attributes of files:
    # restorecon -rF /home/username /var/spool/cron

    If you have other locations where the user can write, you can add them too.

  6. Log in as the user. Start the applications again. If you have disabled any crontab entries, reenable them.

  7. As root, check for SELinux denials:
    # ausearch -m avc -ts today

    If there are denials, try to get more explanation:
    # cat /var/log/audit/audit.log | audit2allow --why

Useful resources:

Great info :slight_smile:

I didn’t use the usermod command to change for existing users, the command I used for changing between policies was:

semanage login -a -s user_u testuser

I did see the option on usermod previously when I started reading about this topic after your initial post, but then I found this website: Confine untrusted users (including your children) with SELinux | Major Hayden which gave the command I used. Whether there is any difference or not, I’ve no idea. I used restorecon on the home directory for that user as well just in case.

I haven’t noticed any important differences between usermod -Z and the semanage login commands. In both cases restorecon is needed afterwards (you can check with ls -lZ /home/username that it’s not done automatically) and in both cases you need to clear an existing mapping first. For example, if you switch a user to staff_u with semanage login -a -s staff_u username and then try to switch the same user to user_u with semanage login -a -s user_u username (or semanage login -m -s user_u username), it will fail with the same error that I have written about, unless you first run semanage login -d username. I’ve chosen usermod because it’s easier to remember: useradd -Z user_u username creates a new user, usermod -Z user_u username modifies an existing user. semanage is always useful for listing existing mappings with semanage login -l.