#+title: Shared System
#+date: 2026-03-24 Tue
#+author: W. Kosior
#+email: wkosior@agh.edu.pl
#+HTML_HEAD:
In these exercises we are going to configure some security aspects of a
multiuser system (a shared server).
To be graded, send
1. the required screenshots,
2. your =playbook.yaml=, and
3. other files used by the playbook
to @@html:wkosior@agh.edu.pl@@ along with
your names. You can work in teams of up to three people.
* Our Setup
We shall be using [[https://pluton.kt.agh.edu.pl/~wokosior/bso-vms/debian-13-nocloud-amd64.qcow2][the Debian VM image]] from [[../01-secure-booting.org][the first topic]]. Its credentials are
=root=/=security=. It can be run with the following QEMU command.
#+begin_src shell-script
qemu-system-x86_64 \
-m 1G \
-enable-kvm \
-hda debian-13-nocloud-amd64.qcow2 \
-net nic -net user,hostfwd=tcp::30022-:22 \
-display none # Use `-vga virtio' / `-nographic' for a GUI / serial port.
#+end_src
If possible, choose one one your team's computers for running Ansible and one
for logging in to the VM interactively. Which PC actually runs the VM matters
little.
* User creation
Copy SSH public key to your the VM and create an inventory file for Ansible.
Then, start with the following in your playbook.
#+begin_src yaml
- name: Ansible Playbook for Shared Server
hosts: tilde # Make the host names here and in the inventory match.
tasks:
- name: Add groups
ansible.builtin.group:
name: users
- name: Set default user shell
ansible.builtin.replace:
path: /etc/default/useradd
regexp: ^SHELL=.*
replace: SHELL=/bin/bash
- name: Add users
ansible.builtin.user:
name: "{{ item.name }}"
group: users
password: "{{ item.password }}"
update_password: on_create
loop:
- name: theodore
password: ? # Fill in.
- name: eugenia
password: ? # Fill in.
- name: pancratius
password: ? # Fill in.
#+end_src
The =loop= construct creates multiple user accounts. Generate password hashes
for them using the =openssl passwd -5 -stdin= command.
*Prepare screenshot 1: output of a successful playbook execution that created
user accounts.*
* Quota configuration
Use the following task to install the =quota= package necessary for disk limits
configuration.
#+begin_src yaml
- name: Install quota tools
ansible.builtin.apt:
name: quota
cache_valid_time: 86400
#+end_src
Edit =/etc/fstab= to add =usrquota= and =grpquota= to the options of the root
filesystem. They shall be used automatically upon boot, after initrafms is left
(the root filesystem gets *remounted* with the options from the =fstab=).
Instead of rebooting, remount the root filesystem manually.
#+begin_src shell-script
mount -o remount /
#+end_src
Verify that the quota-related mount options are active.
#+begin_src shell-script
grep -E '/\s.*usrquota,grpquota' /proc/mounts
#+end_src
Use the following to create quota files inside the root filesystem. You can
ignore the warning that is likely to show up.
#+begin_src shell-script
quotacheck --no-remount --user --group --create-files /
ls -l /aquota* # See the created files.
#+end_src
Turn on the quota. Note the statuses of =quotaon=.
#+begin_src shell-script
quotaon -p /
echo $?
quotaon /
quotaon -p /
echo $?
#+end_src
Now, use Ansible to automate what you've just done.
1. For idempotent updates of =/etc/fstab= you can use the [[https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/script_module.html#ansible-collections-ansible-builtin-script-module][ansible.builtin.script]]
module with the command below placed inside the script.
2. You can use a shell conditional to perform =mount -o remount /= only if
=grep= fails to find the quota options in =/proc/mounts=.
3. You can use a shell conditional to only run =quotacheck= when an =aquota.=
file is missing.
4. You can use a shell conditional to only perform =quotaon /= when =quotaon -p
/= reports that the feature is currently off.
#+begin_src shell-script
# The perl command to place inside the script.
perl -pe '$_ =~ s/^((\S+\s+){3}\S+)/\1,usrquota,grpquota/
unless /^(#.*)$|^\S+\s+\/\S|usrquota,grpquota/' \
-i /etc/fstab
#+end_src
Now, set some limits as root.
#+begin_src shell-script
repquota /
export EDITOR=vi # optional
edquota -u theodore / # set soft / hard to 10000 / 20000 blocks
#+end_src
As theodore, try to create a big file.
#+begin_src shell-script
dd if=/dev/zero bs=1024 count=10000 of=my-file
quota -u theodore
#+end_src
As root, view the block usage by users.
#+begin_src shell-script
repquota /
#+end_src
As theodore, try to create another big file.
#+begin_src shell-script
dd if=/dev/zero bs=1024 count=10000 of=my-other-file
#+end_src
This time theodore has exceeded not just the soft limit but also the hard one.
*Prepare screenshot 2: output of the error message when theodore exceeded hard
block limit.*
* Inode Limits
As theodore, create many small (empty) files.
#+begin_src shell-script
rm my-other-file
mkdir -p empty-files
while touch empty-files/$RANDOM$RANDOM$RANDOM; do true; done
#+end_src
At some point further creation should fail, even though the filesystem seems to
have plenty of space left.
#+begin_src shell-script
df /
df -i /
#+end_src
Remove the problematic small files. Then, set theodore's inode limits to 1000 /
2000 with edquota. Then, as theodore, run the loop again.
#+begin_src shell-script
mkdir -p empty-files
while touch empty-files/$RANDOM$RANDOM$RANDOM; do true; done
#+end_src
Use =repquota= again to see current usage by theodore. Then, change the grace
periods of soft limits to lower (=setquota='s =-t= option).
#+begin_src shell-script
setquota -t $((60*60*24*2)) $((60*60*24*3)) /
#+end_src
Also change the remaining grace time of theodore (=setquota='s =-T= option).
Then, view the numbers using =repquota=.
Finally, use =setquota= instead of =edquota= to noninteractively set the quota
of the remaining users. Note the behavior of the =-p= option of =setquota=.
#+begin_src shell-script
setquota -u eugenia 15000 20000 1000 2000 /
setquota -u -p eugenia pancratius / # Copy quotas of eugenia.
repquota /
#+end_src
Now, put =setquota= commands in the playbook so that it can be used to replicate
your configuration.
*Prepare screenshot 3: output of the =ansible-playbook= command when running the
playbook that sets all users' quotas.*
* Automatically assigning quotas to users
Use Ansible to assign =/= quota to user nobody (who exists by default in the
system).
Now, cause each new users to use the quota of =nobody=. To achieve this, use
Ansible to idempotently (use sed, perl or just install the entire file) change
the relevant quota option in =/etc/adduser.conf=. You'll notice the option
immediately once you look at the comments in system's default =adduser.conf=.
You can use =-v= to report quota of all users, including those who have no files
on the filesystem (as in the case of user =nobody=).
#+begin_src shell-script
repquota /
repquota -v /
#+end_src
Once you've run the playbook, add a new user *interactively* and see that he's
had his quota copied from nobody.
#+begin_src shell-script
adduser --ingroup users alexander
repquota /
#+end_src
*Prepare screenshot 4: output of the two commands above.*
* Sudoers
We shall allow a single user to change disk quota grace time of all the others.
Create an executable shell script under =/usr/local/bin/=. It should accept 3
arguments: username, block grace time and inode grace time. It should call
=setquota=.
#+begin_src shell-script
setquota -u "$1" -T "$2" "$3" /
#+end_src
Now, use =visudo -f /etc/sudoers.d/eugenia-setquota= and initially, populate the
file with garbage (hit some random keys on the keyboard). When you try to save
the file, =visudo= should detect a syntax error and prevent you from deploying
configuration that could make =sudo= inoperable and render the system
inaccessible. Type =h= to view =visudo='s help.
*Prepare screenshot 5: =visudo='s help displayed after trying to save an
incorrect =sudoers= configuration.*
Edit the file again and populate it with a line allowing eugenia to execute the
script as root.
#+begin_example
eugenia ALL = /usr/local/bin/YOUR-SCRIPT-NAME .*
#+end_example
Log in as eugenia and try running the script with =sudo= to change the grace
time of some other user with soft limit exceeded. Then, configure Ansible to
install the script under =/etc/sudoers.d/= with permissions =440=.
* Doas
We shall configure =doas= to serve the same purpose that =sudo= has served.
Instal =doas= from the repository. Use the manual (=man doas=) to find the path
of the config file used by =doas=. Then, use Ansible to create it with the
following contents (with script path adapted) and with permissions 440.
#+begin_example
permit eugenia as root cmd /usr/local/bin/YOUR-SCRIPT-NAME
#+end_example
Log in as eugenia and try running the script with =doas= to change the grace
time of some other user with soft limit exceeded. Additionally, run =doas= with
some other command to see that this is not allowed.
*Prepare screenshot 6: The aforementioned two =doas= invokations with the output
(when present) visible.*
* Resource limits
We shall now configure limits for other types of resources. You can consult the
relevant manuals.
#+begin_src shell-script
man getrlimit
man limits.conf
#+end_src
Although these limits are per-process or per-session rather than per-user, we
can configure them to effectively limit how much resources each user can
consume.
Create a new file with the following contents (substitute the question marks to
limit maximum size of total available memory, use the aforementioned manuals to
find the relevant limit name).
#+begin_src example
@users - nofile 500
@users - ? 30000
pancratius hard ? 60000
#+end_src
Use Ansible to install this file under =/etc/security/limits.d/=, with filename
ending with =.conf=.
As pancratius, log in and run the following commands to view process' current
limits.
#+begin_src shell-script
ulimit -Sa
ulimit -Ha
#+end_src
Allocate 2 MiB of memory in bash.
#+begin_src shell-script
MY_SHELL_VAR="$(yes | dd bs=1024 count=2048)"
free -h # See how much memory is available in the system.
#+end_src
Now, try to allocate more. Create a few more variables with 2 MiB of text each,
calling =free -h= every time.
*Prepare screenshot 7: output of a =free -h= commands showing a lot of available
momery, followed by a failing bash command that exceeded process' own limit.*
* Login sessions limit
Use Ansible to install another file under =limits.d=. This time set the
=maxlogins= limit to 3 for all members of group =users=. This is an exceptional
type of limit — it applies to user accounts, not to individual sessions.
In separate terminal tabs, try SSH'ing multiple times as pancratius.
*Prepare screenshot 8: an SSH connection failing due to account's logins limit.*
* Capabilities
Use Ansible to install =dumpcap= executable.
#+begin_src yaml
- name: Install `dumpcap' program.
ansible.builtin.apt:
name: wireshark-common
install_recommends: false
#+end_src
Verify that, as root, you can use dumpcap to capture packets from the command
line.
#+begin_src shell-script
dumpcap -i ens3 -w my-network-packets.dump # Interrupt to stop.
#+end_src
Verify that you cannot do this as one of the other users.
Dumpcap should print a message explaining what to do to enable non-root users to
capture packets. The official method uses Debian's =dpkg-reconfigure= to add
capabilites to dumpcap's binary and change its owner group to =wireshark=. When
executed, dumpcap then checks if the executing process is a member of the
=wireshark= group. If so, it starts dumping packets.
Marrying curses-based =dpkg-reconfigure= with Ansible is notoriously hard.
Instead, we can add the necessary capabilities to the binary ourselves.
Use the last command suggested by failed dumpcap invokation. Then, perform a
successful dump as =pancratius=.
#+begin_src shell-script
dumpcap -i ens3 -w pancratius-packets.dump
#+end_src
You have added =CAP_NET_RAW= and =CAP_NET_ADMIN= to the permitted set of the
executable. You have also set the "Effective" bit.
Dumping now works, but we're not leveraging the fact that =dumpcap= binary is
smart. The program knows how to enable the necessary capabilities in case they
are initially only present in the "Permitted" set.
Run =getcap /usr/bin/dumpcap= to view the capabilities associated with the file.
Now, modify the earlier command to put =CAP_NET_RAW= and =CAP_NET_ADMIN= in the
"Permitted" set of =dumpcap= but *not to* set the "Effective" bit.
As root, run the following.
#+begin_src shell-script
getcap
sudo -u pancratius dumpcap -i ens3 -w /home/pancratius/pancratius-packets.dump
#+end_src
*Prepare screenshot 9: Output of the two commands above. The binary lacks the
"Effective" capability bit, but unprivileged dumping still works.*
/Note that =dumpcap= installed from other sources might not enable the
capabilitied by itself. This has been witnessed with Ubuntu 24's version./
* Capabilities and Tracing
Make sure =strace= is installed in the VM. Run the following command to see how
=strace= uses =ptrace()= to inspect other process' syscalls and print the
information.
#+begin_src shell-script
strace ls # Run `ls' under `strace'.
#+end_src
Now, try executing the privileged =dumpcap= binary under =strace=. Once again,
use pancratius' unprivileged account. Notice that binary's capabilities are not
acquired by the process when it is being traced.
*Prepare screenshot 10: Final output lines of the =strace= command used to trace
=dumpcap=. The output should indicate missing capabilities.*
* Cgroups
As pancratius, start a CPU-consuming process.
#+begin_src shell-script
yes > /dev/null
#+end_src
As root, view the process list on the server and see that the =yes= command
consumes 100% of the CPU.
#+begin_src shell-script
top # Press `f`, select `CPU' as the sorting value and press `q'.
#+end_src
Note its PID.
Now, look into the main cgroup. Look for the =yes= process' PID there.
#+begin_src shell-script
tail /sys/fs/cgroup/cgroup.procs
wc -l /sys/fs/cgroup/cgroup.procs
grep $YES_PID /sys/fs/cgroup/cgroup.procs # Not present here.
#+end_src
The process must be in one of the child / grandchild cgroups. Find the PID of
=yes= in one of the =cgroup.procs= files under =/sys/fs/cgroup/user.slice/=.
Now, create a new cgroup.
#+begin_src shell-script
mkdir /sys/fs/cgroup/naughty
cat /sys/fs/cgroup/naughty/cgroup.procs
#+end_src
Write the pid of =yes= to =cgrop.procs= to tell the kernel to move it into this
cgroup.
Verify that the process is now indeed considered a member of the new cgroup.
#+begin_src shell-script
cat /proc/142778/cgroup
#+end_src
Check that the =cpu= controller used to manage processor usage is supported by
the kernel and available to the cgroup.
#+begin_src shell-script
cat /sys/fs/cgroup/naughty/cgroup.controllers
#+end_src
See the measured *total* CPU usage by =yes=.
#+begin_src shell-script
cat /sys/fs/cgroup/naughty/cpu.stat
#+end_src
Check the current cgroup limit on CPU usage. The first value designates the
limit while the second one — the time period considered when applying the limit.
#+begin_src shell-script
cat /sys/fs/cgroup/naughty/cpu.max
#+end_src
Configure a 50% limit by writing to the file. Replace value =max= with half of
the period value.
Use =top= again to see if CPU usage by =yes= dropped to 50%.
*Prepare screenshot 11: output of =top= with the CPU usage by =yes= reported as
about 50% or less.*
* System-wide Limits Using Cgroups
The init system (the initial process that starts (typically) all services in the
system) used by Debian is systemd. It also does *MANY* (😉) other things and
can be configured to automatically apply limits to cgroups in which users'
processes are put.
#+begin_src shell-script
# Documentation on setting limits with systemd + cgroups.
man systemd.resource-control
#+end_src
Systemd associates groups of processes with units that it calls "slices".
Slices correspond to, among others, process groups of logged in users. We can
configure limits by putting configuration files in the respective slices'
directories under =/etc/systemd=.
Use Ansible to create a file
=/etc/systemd/system/user-.slice.d/50-Stop-naughty-processes.conf= with the
following contents.
#+begin_src conf
[Slice]
CPUQuota=30%
CPUQuotaPeriodSec=100ms
#+end_src
Notice that there is no user id after the minus sign in directory name
=user-.slice.d=. The file shall be used by all user slices.
Make Ansible invoke =systemctl daemon-reload= each time it changes the file.
Once the file is there and =systemctl daemon-reload= has been invoked, log out
and log back in as pancratius. View his cgroup's =cpu.max= file to see that
the limits are now applied automatically.
Using Ansible, add a similarly named file under =user-0.slice.d= but with 2x
higher limits. Make =daemon-reload= happen afterwards.
Use the following command to verify that the configuration works as intended and
results in higher limits just for the root processes.
#+begin_src shell-script
grep . $(find /sys/fs/cgroup/user.slice -name "cpu.max")
#+end_src
*Prepare screenshot 12: output of the commands above.*
* The Fork Bomb and PID Limits
As pancratius, run another =yes > /dev/null= if you have already killed the
previous =yes= process. Then, write 1 to the =cgroup.freeze= file of the =yes=
process' cgroup. Verify in =top= that the =yes= process no longer consumes
*any* CPU.
As root, add pancratius' =bash= process to the =naughty= cgroup. As pancratius,
stop the =yes= process and, in the command line, execute the fork bomb and watch
the system freeze as dozens of new processes get created by bash.
#+begin_src shell-script
:()(:|:);:
#+end_src
Terminate the VM and restart the system. You'll now use a cgroup to terminate a
fork bomb process tree.
Log in as pancratius and, as root, check the maximum allowed number of
processes. Decrease it to 20 by writing to a =pids.max= file of the cgroup.
#+begin_src shell-script
cat /sys/fs/cgroup/user.slice/user-$PANCRATIUS_UID.slice/pids.max
#+end_src
Now, once again execute a fork bomb as pancratius. This time do it in a loop.
#+begin_src shell-script
:()(:|:);while true; do :;done
#+end_src
It should not break the system this time. See all the bash process PIDs.
#+begin_src shell-script
cat /sys/fs/cgroup/user.slice/user-$PANCRATIUS_UID.slice/session-*.scope/cgroup.procs
#+end_src
Now, kill them all at once.
#+begin_src shell-script
echo 1 > /sys/fs/cgroup/user.slice/user-$PANCRATIUS_UID.slice/cgroup.kill
#+end_src
Use the earlier =cat= command and see that the empty cgroup got automatically
removed by systemd.
*Prepare screenshot 13: Output of the two =cat= commands and the =echo= between
them.*
* Extra (for Bonus Points!)
Configure Ansible Vault to store user password hashes outside of your playbook
file (which you could then share).
Follow the advice in the warning and change the system to use ext4 internal
quota instead of =aquota.user= and =aquota.group= files.