The answer to this little conundrum, as helpfully suggested by @muru, is to have a monitor program running as root. This is designed to parse a token file (.a2kickme) which is created by a script invoked by the chroot user in a mounted folder, (visible to both the host and chroot filesystems), and which contains the chroot username and a 'keyword' which relates to the command that the chroot user is trying to run.
To prevent anyone from just creating such a file by hand and inserting any command, this is prevented by two layers of protection.
The token file is .hidden and the contents are encrypted using a hash algorithm. The random password for this is contained in a hidden .secret file with permissons 640, in another shared location.
The monitor program decrypts the single hash in the token file to extract the username of the user who wrote it and compare the keyword instruction to an array. If the keyword does not appear in the array, then the instruction is ignored.
The particular application for my purposes, is to allow a chroot user to modify the vhost htdocs for their own site, along with the apache site.conf configuration and SSL certificates. This was easy enough to achieve with the use of mount points using -B (bind) in /etc/fstab and I will not need to discuss here.
Setting up the chroot and creating the scripts to achieve the above, however, is described below in the hope that others may adapt some of the principles employed for their own use.
Create Chroot Directory:
$ sudo mkdir -p /home/chrt 755 root:root # This *MUST* be owned by root and writeable by nobody else.
$ sudo touch /home/chrt/YOU_ARE_CHROOT # This dummy file will appear in any listing (ls) of the root directory.
$ sudo chmod 644 /home/chrt/YOU_ARE_CHROOT # Protects the file from being removed by chroot user.
$ sudo chattr +i /home/chrt/YOU_ARE_CHROOT # Make the file immutable so that not even root can delete or modify. To unset use '$ sudo chattr -i ...'
Create New Chroot Owner and Group:
$ sudo useradd -r chrt # System User, No Login
$ sudo usermod -d /nonexistent chrt # No Home Directory
$ sudo usermod -s /bin/false chrt # No Shell
Add the following Lines to /etc/ssh/sshd_config:
(Recommended to use external editor for initial setup)
#define group to apply chroot jail to
Match group chrt
#specify chroot jail
ChrootDirectory /home/chrt
AllowTcpForwarding no
AllowAgentForwarding no
PermitTunnel no
X11Forwarding no
Create a new file to display the chroot name in SSH prompt:
(Chroot user prompt will appear as: (chrt){username}@myserver:~$
)
$ sudo touch /home/chrt/etc/debian_chroot
...and add the following entry: chrt
Install basic Chroot environment:
Note: this environment will not contain any tools (such as sudo, systemd, chmod, chown, nano or vim for example) other than the basic Bash commands. Tools can be added by the root user using the chroot /home/chrt /bin/bash
command to enter the chroot and then running apt-get as needed.
To create the basic chroot environment:
$ sudo apt-get update
$ sudo apt-get install debootstrap
$ sudo debootstrap --variant=buildd jammy /home/chrt < installs Ubuntu Jammy Jellyfish - change to any supported distro shortname
Create New SSH User and include in chrt group: (Users in chrt group will always be chroot()ed by sshd)
$ sudo useradd -s /bin/bash -d /home/chrt/home/{username}/ -m -G chrt {username}
Copy passwd and group files from host to chroot filesystem (Do this every time there is a change to the users and groups.)
$ sudo cp /etc/{passwd, group} /home/chrt/etc/
Create symlink from host home directory so ssh keys work:
$ sudo ln -s /home/chrt/home/{username} /home/
Note: This is because of sshd_config: 'AuthorizedKeysFile
%h/.ssh/authorized_keys' - where sshd expects %h to be
/home/{username}
PASSWORDS ARE DISABLED FOR ALL SSH USERS, so a key
pair is required for authentication
Create ssh public/private key pair using PuTTYgen
Copy public key to /home/chrt/home/{username}/.ssh/authorized_keys Copy
private key (keyfile.ppk) to your SSH Client
$ sudo chmod 644 /home/chrt/home/{username}/.ssh/authorized_keys
$ sudo chown {username}:{username} /home/chrt/home/{username}/.ssh/authorized_keys
Create .secret file in /path/to/shared/chrt
(It can be any mounted shared location, but ensure this location is used in all the scripts that need it)
$ sudo touch .secret # < Add a random password after creation
$ chmod 640 root:chrt /path/to/shared/chrt/.secret
# Make the file immutable so that not even root can delete or modify.
# To unset use '$ sudo chattr -i ...'
$ sudo chattr +i /path/to/shared/chrt/.secret
Create Scripts:
Install into: /home/chrt/usr/bin/
a2cleanup 750 root:chrt < usage: a2cleanup [-f] (removes
/var/www/chrt/.a2kickme with user prompt. Forces removal with -f flag)
a2reload 750 root:chrt < reloads apache (equivalent to systemctl
reload apache2.service)
a2restart 750 root:chrt < restarts apache (equivalent to systemctl restart apache2.service)
a2start 750 root:chrt < starts apache (equivalent to systemctl start apache2.service)
a2status 750 root:chrt < displays apache status (equivalent to systemctl status apache2.service)
a2enable 750 root:chrt < enables site.conf (equivalent to $ sudo a2ensite site.conf. Note a2ensite will NOT take any arguments in chroot)
a2disable 750 root:chrt < disables site.conf (equivalent to $ sudo a2dissite site.conf. Note a2dissite will NOT take any arguments in chroot)
All of the above 'command' files are based on the same code, with the only difference being the $CMD variable in the definitions section. I reproduce one of them below:
!/bin/bash
# Written by Andy Woolford
# CHROOT/usr/bin/a2status
FILE=/var/www/chrt/.a2kickme
SECRET_FILE=/path/to/shared/chrt/.secret
MSGB=$'already exists. Another process may be using this file.\n\nTry again later, or run ./a2cleanup'
MSG="The file: $FILE $MSGB"
CMD="status"
U=${SUDO_USER:-${USER}}
if [ -f "$FILE" ]; then
echo "$MSG"
else
touch $FILE
SECRET=($(<$SECRET_FILE))
echo "$U $CMD" | openssl enc -aes-256-cbc -md sha512 -a -pbkdf2 -iter 100000 \
-salt -pass pass:"$SECRET" > $FILE
fi
exit 0
The a2cleanup file, performs the function of removing the token file .a2kickme. It may be run interactively, requiring a user prompt to remove, or with the -f flag to force silent removal. It is called by a2monitor.sh after executing the requested command, but can be run manually if the token file is stuck in the folder for some reason. The above command file will prompt the user to do this should that happen.
#!/bin/bash
# Written by Andy Woolford
# CHROOT/usr/bin/a2cleanup
U=${SUDO_USER:-${USER}}
FILE=/var/www/chrt/.a2kickme
SECRET_FILE=/path/to/shared/chrt/.secret
SUCCESS="$FILE was removed."
NOOP="Nothing to do. Exiting cleanly."
CANCEL="Operaton cancelled by user."
SMH="Please answer y or n."
PROMPTB=$'already exists. Another process may be using this file.\n\nAre you sure you want to remove it? y/n: '
PROMPT="The file: $FILE $PROMPTB"
DENIED="Access Denied! To force cleanup, use a2cleanup -f"
EXISTS=false
[ -f "$FILE" ] && EXISTS=true
# Exit if nothing to do
if ! $EXISTS; then
echo $NOOP
exit 0
fi
# Force deletion of the target file when invoked with -f flag, regardless
while getopts "f" flag; do
case "${flag}" in
f) if $EXISTS; then
rm $FILE
echo $SUCCESS
else
echo $NOOP
fi
exit 0;;
esac
exit 0
done
F_READ=($(<$FILE))
SECRET=($(<$SECRET_FILE))
# Ensure that the user deleting the file is the same as the user who created it
if [ "$F_READ" != "" ] && [ "$SECRET" != "" ]; then
F_USER=$(echo "$F_READ" | openssl enc -aes-256-cbc -md sha512 -a -d -pbkdf2 -iter 100000 \
-salt -pass pass:"$SECRET" | awk '{print $1}')
# If the user is not the same, then refuse to delete it unless run with -f flag
if [ "$F_USER" != "$U" ] && [ "$U" != "root" ]; then
echo $DENIED
exit 1
else
while true; do
read -p "$PROMPT" YN
case $YN in
[Yy]) rm $FILE
echo $SUCCESS
break;;
[Nn]) echo $CANCEL; break;;
*) echo $SMH;;
esac
done
fi
else
# If either the target file or the secret file is blank then just delete it.
rm $FILE
echo $SUCCESS
fi
exit 0
The a2monitor.sh file is located in the host filesystem root directory. It is NOT accessible by the chroot user. In my application, a2monitor.sh watches /var/www/chrt and parses .a2kickme using the .secret password to recover the encrypted username and $CMD. It is only executable by root, but nevertheless it double checks to ensure the $USER system variable which invoked it is "root", (in case of tampering), and will ignore all commands if not. It also checks to make sure that the $CMD is valid before executing as root. The script will start at boot time and loop every x seconds. I have this set to 1 second in the TLOOP variable, but YMMV. Can be run manually if stopped with $ sudo systemctl start a2monitor.service
. a2cleanup -f is invoked by a2monitor when it completes, to silently remove the temporary file.
To adjust the loop delay:
TLOOP=1 # Set to 0 to exit after one pass only for testing
The a2monitor.sh script will also pipe the output of any command back to the console of the user who invoked it.
#!/bin/bash
# Written by Andy Woolford
# /root/a2monitor.sh
U=$USER
MESG=$(mesg | awk '{print $2}')
FILE=/var/www/chrt/.a2kickme
LOGFILE=/var/log/apache2/chrt/a2functions.log
SECRET_FILE=/path/to/shared/chrt/.secret
CLEANUP="/home/chrt/usr/bin/a2cleanup -f"
COMMANDS="status start restart reload enable disable"
CMD_ES="$(which a2ensite) site.conf"
CMD_DS="$(which a2dissite) site.conf"
CMD_SD="$(which systemctl)"
SD_SVC="apache2.service"
CURRENTDATE=`date`
TLOOP=1 # Set to 0 to exit after one pass only for testing
function isNotIn {
LIST=$1
VALUE=$2
! [[ $LIST =~ (^| )$VALUE($| ) ]]
}
# Loop every TLOOP seconds
while true; do
EXISTS=false
[ -f "$FILE" ] && EXISTS=true
if $EXISTS; then
F_READ=($(<$FILE))
SECRET=($(<$SECRET_FILE))
# Ensure that valid data exists and decrypt it
if [ "$F_READ" != "" ] && [ "$SECRET" != "" ]; then
F_USER=$(echo "$F_READ" | openssl enc -aes-256-cbc -md sha512 -a -d -pbkdf2 -iter 100000 \
-salt -pass pass:"$SECRET" | awk '{print $1}')
F_CMD=$(echo "$F_READ" | openssl enc -aes-256-cbc -md sha512 -a -d -pbkdf2 -iter 100000 \
-salt -pass pass:"$SECRET" | awk '{print $2}')
# Test if user and command are valid.
# Log failures and cleanup.
# Continue monitoring.
if isNotIn "$COMMANDS" $F_CMD || [ $U != "root" ]; then
echo "$CURRENTDATE $U $F_CMD Not Authorised!" >> $LOGFILE
$CLEANUP > /dev/null
continue
fi
case $F_CMD in
enable) CMD="$CMD_ES"
;;
disable) CMD="$CMD_DS"
;;
*) CMD="$CMD_SD $F_CMD $SD_SVC" ;;
esac
# Append the user and action to Log
echo "$CURRENTDATE $U $F_CMD Success" >> $LOGFILE
# Execute CMD and store result
RESULT=$($CMD)
$CLEANUP > /dev/null
if [ "$RESULT" != "" ]; then
# Ensure messages are enabled
mesg y
# Pipe the result to the user initiating the command
echo "$RESULT" | write "$F_USER"
# Reset messages to whatever it was before
mesg $MESG
fi
else
# If no valid data exists:
$CLEANUP > /dev/null
fi
fi
# For testing
if [ $TLOOP == 0 ]; then
break
else
sleep $TLOOP
fi
done
exit 0
Create a .service file in /etc/systemd/system/a2monitor.service:
$ sudo touch /etc/systemd/system/a2monitor.service
$ sudo chmod 644 /etc/systemd/system/a2monitor.service
Edit the file and add the following:
[Unit]
Description=Apache functions for chroot
[Service]
ExecStart=/bin/bash -c "/root/a2monitor.sh"
[Install]
WantedBy=multi-user.target
Then enable it:
$ sudo systemctl daemon-reload
$ sudo systemctl enable a2monitor.service
The service may now be started manually with:
$ sudo systemctl start a2monitor.service (the .service is optional)
Given that the service is enabled, it should start on reboot of the host machine.
Now that these scripts are installed, SSH into the console of the chroot user and verify that the user is actually chrooted. (chrt){username}@myserver:~$ cd /
should take you to the root of the chroot, not the root of the host. You can confirm this with a simple listing (chrt){username}@myserver:~$ ls
and the immutable file "YOU_ARE_CHROOT" should be in this list. The user prompt should also start with (chrt).
Assuming you have created the "a2status" script above and placed it in the CHROOT/usr/bin/ directory, then typing (chrt){username}@myserver:~$ a2status
should be the equivalent of root@myserver:~# systemctl status apache2.service
and the output should appear on the chroot user console after a short delay of approximately the TLOOP time.