Небольшое вступление

Думаю, у каждого в жизни была ситуация, когда возникает желание разобраться в какой-то теме (будь то новый язык программирования, сети, нюансы работы компонентов ПК и т. д.), но на желании всё и останавливается. На мой взгляд это происходит из-за отсутствия конкретной цели. "Хочу выучить C++" это слишком расплывчатая задача. "Хочу написать программу на C++" уже точнее. У меня такой первичной целью стало желание узнать побольше про регулярные выражения, позднее возникла идея, которая вместила в себе ещё и другие вещи, а сами регулярки отошли на третий план, но зато появилась конкретная задача – хочу сделать свою песочницу для регулярных выражений.

Оглавление

  1. Примерные требования

  2. Chroot

  3. Docker

  4. SSH Fingerprint

  5. Команды и дополнительные файлы

  6. Параметры оболочки

  7. Пользовательский ввод

  8. Fork бомбы

  9. ls

  10. Итог

1. Примерные требования

  • Внешний доступ

  • Отдельное окружение

  • Минимум лишних возможностей

  • Заготовленные образцы текстов

  • Доступ к утилитам, использующим regex

  • Меры защиты от шаловливых пользователей

Внешний доступ – это просто рабочий SSH сервер, заготовки текстов даже описывать нет смысла, а с остальным уже интереснее. При мысли про отдельное окружение в первую очередь приходят в голову слова docker и chroot, я использовал и то, и другое. С помощью docker можно сделать отдельную систему внутри хостовой системы, что изолирует пользователей от хоста, а chroot скроет всё, к чему им не надо иметь доступа (системные файлы, логи, директория tmp и т. п.). Помимо этого у пользователя есть своя оболочка, по умолчанию это bash, но её можно заменить на rbash (Restricted Bash), которая запрещает определённые действия:

  • Смена директории

  • Использование слэша в пути к исполняемым файлам

  • Изменение переменных оболочки

  • Перенаправление вывода

  • Использование команды exec

В качестве утилит я добавлю awk, sed, grep, tr, ripgrep, head, tail так как они либо поддерживают использование регулярных выражений, либо помогают в обработке текста, ну и всякие cat, clear, ls и т.п. ещё стоит добавить. Меры защиты частично покрываются docker, chroot и rbash, но этого недостаточно. Ещё как минимум пользователь не должен иметь возможности произвольно создавать файлы, плюс надо учитывать всякие вредные конструкции по типу :(){ :|:& };:, но о об этом позже.

2. Chroot

Команда chroot подменяет корень файловой системы для всех процессов внутри окружения созданного после chroot. Из этого вытекает важный нюанс, сам по себе chroot работать не будет, даже оболочка не запустится.

Дело в том, что оболочка – это исполняемый файл со своими зависимостями. Так что и то, и другое должно быть внутри директории до выполнения команды chroot, чтобы к ним был доступ после изменения корня файловой системы. В этом нам помогут две утилиты:

  • which – показывает абсолютный путь до указанного исполняемого файла (команды)

  • ldd – показывает все используемые исполняемым файлом (командой) зависимости

Небольшое пояснение работы утилиты ldd, на вход она принимает только абсолютный путь до нужной команды, поэтому ldd bash не сработает, зато сработает ldd $(which bash). Из вывода же нам нужны все пути, где указана директория /lib или /lib64, самая первая зависимость в выводе ldd – это библиотека из ядра системы (linux-vdso.so.1), она доступна всегда и не является отдельным файлом, который можно скопировать.

И можно, конечно, руками добавить в chroot все команды и зависимости, но лучше сделать скрипт, который выполнит это сам.

make_chroot.sh
#!/bin/bash

mkdir -p "$1"    # Создаём директорию для chroot, если её нет
CURRENT_DIR="$1" # Сохраняем имя основной chroot директории в переменную
shift            # Убираем первый аргумент из переданных,
                 # теперь в аргументах останутся только нужные команды

# Перебор указанных в аргументах команд
for arg in "$@"; do
        util=$(which $arg) # Получаем абсолютный путь до команды
        if [ "$util" ]; then # Если команда существует, то ...
                libs=$(ldd $util) # Сохраняем вывод команды ldd
                # Копируем все библиотеки из вывода ldd с сохранением
                # оригинального пути, кроме первой
                echo "$libs" | grep "lib" | awk '{print $(NF-1)}' \
                | while read -r lib; do
                        if [ -f "$lib" ]; then
                                mkdir -p "$(dirname "$CURRENT_DIR$lib")"
                                cp "$lib" "$CURRENT_DIR$lib"
                        fi
                done
                # Копируем исполняемый файл команды в поддиректорию /usr/bin
                mkdir -p "$(dirname "$CURRENT_DIR/usr/bin/$arg")"
                cp "$util" "$(dirname "$CURRENT_DIR/usr/bin/$arg")"
        else # Если команды не существует, то ...
                echo "no such command: $arg"
        fi
done

Опробуем скрипт, сначала указываем нужную директорию (если её нет, то скрипт сам создаст), а затем нужные команды. После чего выполняем chroot в данную директорию и запускаем bash.

Отлично, теперь есть первичная заготовка для chroot окружения, и она работает, пора засунуть её в docker. Создадим новую директорию и скопируем созданный ранее скрипт в неё. Все последующие действия выполняются в рамках этой директории.

3. Docker

Я буду использовать образ Debian:bookworm-slim в качестве исходного, так как с дебианом я знаком, а возиться с условным Alpine мне не хочется (поэтому все последующие команды актуальны конкретно для указанного образа дистрибутива). Вот dockerfile, который мы будем использовать и впоследствии модифицировать:

# Указываем образ debian:bookworm-slim
FROM debian:bookworm-slim 
# Переменные для имени и пароля пользователя
ARG USER_PASSWORD 
ARG USER_NAME="regex" 

# Обновление пакетов и установка OpenSSH-Server
RUN apt update -y && apt upgrade -y && apt install -y ssh 

# Создание нового пользователя, у него будет своя домашняя директория и оболочка rbash
RUN useradd -m -s /usr/bin/rbash ${USER_NAME}
# Меняем пароль нового пользователя
RUN echo "${USER_NAME}:${USER_PASSWORD}" | chpasswd 

# Переходим в директорию /home
WORKDIR /home

# Копируем скрипт для создания chroot окружения из хостовой системы
COPY ./make_chroot.sh .
# Запускаем скрипт, указываем команды, которые хотим видеть в chroot окружении
RUN ./make_chroot.sh ${USER_NAME} ls rbash

# Запускаем ssh-server в фоновом режиме
CMD ["/usr/sbin/sshd", "-D"]
# Открываем 22-ой порт для внешнего доступа со стороны хоста
EXPOSE 22/tcp

Отмечу, что в данном dockerfile есть как относительные пути, так и абсолютные. В блоке кода выше есть команда WORKDIR /home, все последующие строки, которые мы добавим в dockerfile будут учитывать эту команду.

Пароль будем передавать при сборке образа в команде sudo docker build --build-arg USER_PASSWORD=[PASSWORD] -t my-debian ., где [PASSWORD] это нужный для пользователя пароль. В таком случае docker выдаст предупреждение, что передавать пароль таким образом не надо, в целом он будет прав. 

В нашем случае пароль в явном виде сохранится в истории команд, что не очень хорошо, но ни к какой важной информации он не предоставляет доступа, поэтому не вижу смысла что-то менять. Запускаем контейнер командой sudo docker run -d --hostname regex --name regex-debian -p 2222:22 my-debian. Теперь у нас должен быть доступ к контейнеру по SSH через 2222 порт хоста. Попробуем подключиться и… что-то не так.

Проверим статус контейнера командой sudo docker ps -l и его логи командой sudo docker logs regex-debian. Видим, что контейнер завершился с кодом 255, любой код кроме нуля обозначает ошибку. В логах только одно сообщение – "Missing privilege separation directory: /run/sshd". Тут хочется немного погрузиться в особенности работы docker и дистрибутивов Linux.

Во всех дистрибутивах Linux есть система инициализации, в большинстве случаев это Systemd. Она работает как главный родитель для всех подпроцессов в системе, и её PID равен 1, то есть выше процессов просто нет. В случае с контейнерами docker занимает место системы инициализации, и PID равный 1, поэтому условная команда systemctl выдаст ошибку, так как не находит рабочий Systemd в системе.

Директория /run/sshd, которая фигурирует в ошибке как раз должна создаваться Systemd при каждом запуске системы заново, но не создаётся. Поэтому просто добавим её руками в dockerfile:

RUN mkdir -p /run/sshd

Пересобираем контейнер командой sudo docker build --build-arg USER_PASSWORD=[PASSWORD] -t my-debian .. И снова запускаем его командой sudo docker run -d --hostname regex --name regex-debian -p 2222:22 my-debian.

Теперь всё работает, но мы не внутри chroot окружения, это видно по команде pwd, корень системы не скрыт. Ранее я показывал использование chroot отдельной командой, а ещё для неё нужны root-права. Но, к счастью, умные люди уже всё предусмотрели, в конфигурационном файле /etc/ssh/sshd_config есть параметр ChrootDirectory, он позволяет делать автоматический переход в chroot окружение при подключении по SSH. Но у него есть важный нюанс – владельцем указанной в параметре директории должен быть root-пользователь, иначе происходит разрыв соединения.

Помимо ChrootDirectory и изменения владельца добавим ещё несколько строк в наш dockerfile:

# Меняем владельца и группу домашней директории пользователя на root
RUN chown -R root:root ${USER_NAME}

# Запрет на логин пользователя root по ssh
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config
# Запрет на туннелирование трафика через ssh
RUN echo "AllowTcpForwarding no" >> /etc/ssh/sshd_config
# Автоматический chroot 
RUN echo "ChrootDirectory /home/${USER_NAME}" >> /etc/ssh/sshd_config
# Принудительный вызов оболочки rbash
RUN echo "ForceCommand rbash --login" >> /etc/ssh/sshd_config

Касательно последней строчки, при подключении по SSH можно указать флаг -t и с его помощью выполнить любую доступную команду вместо установленной по умолчанию (обычно по умолчанию стоит запуск оболочки пользователя), но параметр ForceCommand препятствует этому. В данном случае, что не укажи в -t всё равно запустится rbash.

И вот наконец-то у нас рабочее chroot окружение! Хотя уж больно оно блеклое, надо добавить красок и дополнительных ограничений пользователя. Но прежде нужно поправить одну вещь.

4. SSH Fingerprint

При каждой пересборке контейнера генерируются новые ключи SSH, и из-за этого при подключении возникает ошибка несоответствия отпечатка SSH, который генерируется с помощью хэширования открытого ключа SSH сессии.

Это можно исправить. Нужно один раз сгенерировать ключ шифрования для SSH и скопировать его в контейнер. Для этого есть команда ssh-keygen, она создаст открытый и закрытый SSH ключи, указываем флаг -t rsa для выбора конкретного алгоритма шифрования. В хостовой системе создаём директорию для ключей "fingerprint-ssh", переходим в неё и генерируем ключи. В качестве названия просто указываем "ssh_host_rsa_key", аналогичное название у ключей, которые генерируются автоматически.

Теперь нужно добавить эти ключи в контейнер, они должны находиться в директории /etc/ssh. Допишем в dockerfile следующие строки:

# Удаляем автоматически созданные ключи
RUN rm /etc/ssh/*key*
# Копируем наши ключи
COPY ./fingerprint-ssh/* /etc/ssh

Всё, теперь ошибки несоответствия отпечатка SSH больше не будет.

5. Команды и дополнительные файлы

Во-первых нужно дополнить список команд, которые будут в chroot окружении:

# Было
RUN ./make_chroot.sh ${USER_NAME} ls rbash
# Стало
RUN apt install -y ripgrep
RUN ./make_chroot.sh ${USER_NAME} ls rbash cat grep awk rg tr wc sed clear head tail

Теперь сделаем дополнительный скрипт, который будет выключать встроенные (builtin) команды оболочки. Узнать список таких команд можно с помощью enable, если не указывать аргументы, то в выводе будут все доступные встроенные команды. А флаг -n позв��ляет запретить конкретную команду.

buildin.sh
#!/bin/bash

# Построчная обработка вывода команды enable
while IFS= read -r line; do
        command=$(echo $line | awk '{printf $2}') # Очищаем строку
        # Фильтруем команды, которые нужно оставить доступными
        if [ "$command" = "enable" ] \
        || [ "$command" = "logout" ] \
        || [ "$command" = "exit" ] \
        || [ "$command" = "echo" ]; then continue; fi
        # Записываем строку с запретом в указанную в первом аргументе директорию
        echo "enable -n $command" >> $1
done <<< $(enable)

# Запрещаем саму enable, чтобы нельзя было откатить запреты
echo "enable -n enable" >> $1

Скрипт скопируем в директорию /home (в ней мы уже находимся благодаря WORKDIR).

COPY ./buildin.sh .

Теперь сделаем несколько новых скриптов, которые будут вызываться как команды. Отдельно отмечу, что в отличие от прошлых скриптов в первой строке нужно указать полный путь до rbash оболочки пользователя, так как обычную bash оболочку мы не добавляем.

Справка по доступным командам (helpme).
#!/usr/bin/rbash

COLOR='\033[1;32m' # ANSI код для зелёного цвета
NoColor='\033[0m' # ANSI код очистки цвета (обычный текст)

# Функция для определения длины строки + 2 пробела по краям
function sizing() {
        some_size=0
        while IFS= read -r line; do
                (( ${#line} > some_size )); some_size=${#line}
        done <<< "$1"
        echo $((some_size+2))
}

# Переменные для сохранения переданных аргументов
# в формате подходящем для использования в команде ls
flags=""
extensions="--hide=*"
files="--hide="

# Перебор переданных аргументов (форматы файлов, которые нужно скрыть)
for arg in "$@"; do
        if [[ "$arg" == *"."* ]]; then
                flags+="$extensions$arg "
        else
                flags+="$files$arg "
        fi
done

echo " i Доступные команды:"
# Сохраняем вывод команды ls usr/bin $flags в переменную
command=$(ls usr/bin $flags | tr '\n' ' ')
if [ "$line" != "" ]; then
        command=$(echo "${command:0:-1}")
fi
# Вычисляем длину вывода команды ls
size=$(sizing "$command")

# Верхняя часть рамки
printf "╭"; for ((i=0; i<size; i++)); do printf "─"; done; printf "╮"

# Выводим доступные команды в одной строке
while IFS= read -r line; do
        printf "\n│ "
        printf "$COLOR$line$NoColor"
        new_size=$((size-${#line}+2))
        printf "%${new_size}s" "│"
done <<< $command

# Нижняя часть рамки
printf "\n╰"; for ((i=0; i<size; i++)); do printf "─"; done; printf "╯\n"
Вывод справки по регулярным выражениям (regex_info).
#!/usr/bin/rbash

#-- Colors -----------------------------------------------------------------------
YELLOW='\033[1;33m'
BLUE='\033[94m'
GREEN='\033[0;32m'
RED='\033[1;31m'
NONE='\033[0m'

#-- Body -------------------------------------------------------------------------

# Корректная обработка выхода и восстановление настроек терминала
function good_exit () {
        printf "\033[?1049l"
        printf "\033[?25h"
        exit
}

# Сохраняем текущее содержимое терминала
printf "\033[?1049h\033[H"
# Очищаем экран
clear
# Скрываем курсор в терминале
printf "\033[?25l"

# Запрещаем прерывание скрипта через CTRL+C
trap '' SIGINT

# Считываем текстовую справку
data="$PATH/info.txt"
#-- Main -------------------------------------------------------------------------

i=1
while IFS= read -r line; do           # Построчный вывод текстовой справки
        printf "\033[%d;0H" $i        # Перемещаемся на строку i
        line=${line//\{yel\}/$YELLOW} #
        line=${line//\{blu\}/$BLUE}   #
        line=${line//\{gre\}/$GREEN}  # Замена меток цвета на его ANSI код
        line=${line//\{red\}/$RED}    #
        line=${line//\{nc\}/$NONE}    #
        printf "%b" "$line"           # Вывод строки текстовой справки
        i=$((i+1))                    # Смещение номера строки
done < "$data"

#-- Keyboard stop ----------------------------------------------------------------
while true; do # Обработка закрытия справки при нажатии q/Q
        read -s -n 1 var
        if [[ $var == "q" ]] || [[ $var == "Q" ]]; then
                good_exit
        fi
done

Сама справка (info.txt):

Создадим директорию /commands в хостовой директории и добавим туда файлы helpme, regex_info и info.txt. Также сделаем директорию /data, в ней будут храниться текстовые файлы для практики в регулярных выражениях. Их создание я не буду описывать.

И сделаем приветственное сообщение hello.txt, оставим его в главной директории песочницы.

6. Параметры оболочки

Для начало важное уточнение, при подключении по SSH сначала запускается оболочка по умолчанию, указанная при создании пользователя, а уже потом команда из параметра ForceCommand. Кто-то ранее мог заметить, что оболочка rbash в параметре ForceCommand вызывается с флагом --login, суть в том, что в Linux есть такие виды оболочек как Login shell и Non login shell, они считывают параметры оболочки из разных конфигурационных файлов.

Login shell

Non login shell

/etc/profile (глобальные настройки)

/etc/bash.bashrc (глобальные настройки)

~/.profile (персональные настройки)

~/.bashrc (персональные настройки)

Оболочка по умолчанию запускается как Non login shell, и если в ForceCommand указан просто rbash, то выходит так, что конфиги для Non login shell считываются дважды, и команды из них выполняются дважды. Поэтому в ForceCommand ещё есть флаг --login, чтобы считать конфиги единожды. Это пригодится чуть позже, когда будем добавлять приветственное сообщение. Вот подтверждение вышесказанного:

Обычный rbash
Обычный rbash
rbash с флагом логина
rbash с флагом логина

Параметры нашей rbash оболочки будем указывать в файле /etc/profile, поэтому нужно создать директорию /etc в нашем chroot окружении, также надо удалить лишние файлы из него (.bashrc, .profile и .bash_logout), они нам не понадобятся. Ну и ещё несколько мелочей.

# Переходим в домашнюю директорию пользователя
WORKDIR /home/${USER_NAME}
# Удаляем ненужные файлы (.bashrc .profile .bash_logout)
RUN rm ./.*
# Создаём 
RUN mkdir etc

# Переменная PS1 отвечает за текстовый блок слева от поля ввода в консоли
RUN echo "PS1='\033[1;32m${USER_NAME}@\h\033[0m:\033[1;34m\w\033[0m\$ '" >> etc/profile
# Ограничиваем перенную PATH одной директорией, где будут все файлы команд
RUN echo "readonly PATH=/usr/bin" >> etc/profile
# При трёх минутах бездействия пользователя отключит
RUN echo "readonly TMOUT=180" >> etc/profile

# Копируем приветственное сообщение
COPY hello.txt etc/hello.txt
# Вывод приветственного сообщения при входе
RUN echo "cat /etc/hello.txt" >> etc/profile
# Вывод справки по доступным командам при входе
RUN echo "helpme .old rm .txt rbash" >> etc/profile
# Добавим alias для helpme
RUN echo "alias helpme='helpme .old rm .txt rbash'" >> etc/profile

# Запрет встроенных команд
RUN /home/buildin.sh etc/profile
# Копируем текстовые файлы для регулярных выражений
COPY ./data/* .
# Копируем наши команды
COPY ./commands/* usr/bin

Ну и пора проверить, что же у нас получается. Пересобираем: sudo docker build --build-arg USER_PASSWORD=[PASSWORD] -t my-debian .. Запускаем: sudo docker run -d --hostname regex --name regex-debian -p 2222:22 my-debian.

Отлично, оформление есть, команды есть, ограничения есть. По сути основа готова. Добавим ещё пару вещей.

7. Пользовательский ввод

Костыли, костыли, любимые мои. План следующий:

  • Пользователю доступна команда-прослойка с именем nano, это просто bash скрипт, который изнутри себя будет вызвать оригинальное nano.

  • Сама оригинальная nano будет называться nano.old и её прямой вызов из оболочки мы запретим.

  • А в команде-прослойке пропишем вызов nano.old с именем файла data_$$.txt, где $$ номер процесса (PID) конкретной оболочки, таким образом каждый отдельный пользователь будет иметь свой текстовик.

  • При выходе пользователя, его файл data_$$.txt будет удаляться.

  • Аналогично с командой rm.

Сначала разберёмся с созданием и удалением файла, нам понадобятся, собственно nano и утилита rm. Их нужно убрать из доступных для прямого вызова пользователем. Ручками делать не охота, так что напишем ещё один скрипт (это последний, чесcлово).

restricted.sh
#!/bin/bash

CURRENT_FILE="$1" # Сохраняем имя директории в переменную с наглядным названием
shift             # Убираем самый первый аргумент из переданных,
                  # таким образом в аргументах останутся только нужные команды

# Перебор указанных в аргументах команд
for arg in "$@"; do
        echo "function ${arg}() { echo \"\${0}: ${arg}: command not found\"; }; \
        export -f ${arg}" >> $CURRENT_FILE
        # для каждой команды добавляем строку формата:
        # rbash: ARG: command not found
done
# Во многих руководствах советуют запрещать команды через alias, но это метод
# нерабочий. Пользовательский alias можно обойти с помощью кавычек или обратного
# слэша, а функцию нет.

Нужно добавить этот скрипт в dockerfile ещё включить nano и rm в chroot окружение, и добавить им расширение .old:

# Было
RUN apt install -y ripgrep
RUN ./make_chroot.sh ${USER_NAME} ls rbash cat grep awk rg tr wc sed clear head tail
# Стало
RUN apt install -y ripgrep nano
RUN ./make_chroot.sh ${USER_NAME} ls rbash cat grep awk rg tr wc sed clear head tail nano rm

# Было
COPY ./buildin.sh .
# Стало
COPY ./buildin.sh .
COPY ./restricted.sh .
RUN mv ${USER_NAME}/usr/bin/nano ${USER_NAME}/usr/bin/nano.old
RUN mv ${USER_NAME}/usr/bin/rm ${USER_NAME}/usr/bin/rm.old

# Удалить
RUN /home/buildin.sh etc/profile

Помимо этого нужно добавить директорию для пользовательских файлов, системные файлы терминала, команды в /etc/profile для создания и удаления пользовательских файлов, а также запретить nano.old, rm.old и rbash для прямого вызова с помощью ранее написанного скрипта:

# Директория для пользовательских файлов
RUN mkdir usr/data
# Разрешаем создание файлов
RUN chmod 777 usr/data

# Устанавливаем переменную терминала
RUN echo "readonly TERM=xterm-16color" >> etc/profile
# Директория для системных файлов терминала
RUN mkdir -p usr/share/terminfo
# Копируем файлы с хоста
RUN cp -R --preserve=mode /usr/share/terminfo/* usr/share/terminfo

# Эта строчка,
RUN echo "readonly USER_ID=\$$" >> etc/profile
# и эта понадобятся чуть позже
RUN echo "export USER_ID" >> etc/profile
# Удаление файла
RUN echo "trap 'rm \"/usr/data/data_\${$}.txt\"' EXIT" >> etc/profile

# Запрещаем команды
RUN /home/restricted.sh etc/profile nano.old rbash rm.old

# Ранее мы удалили эту строчку, так как она препятствует
# выполнению предыдущих пяти команд, теперь возвращаем
RUN /home/buildin.sh etc/profile

Пара слов про запрет использования rbash самим пользователем, у rbash (как и обычного bash) есть флаг -c, который позволяет выполнять произвольный код в качестве отдельного процесса, и на этот процесс не распространяются ограничения нашей оболочки. Это существенная дыра в безопасности, поэтому её нужно перекрывать.

Теперь пришло время прослойки для nano, она довольно простая и обеспечивает в том числе ограничение по размеру файла.

#!/usr/bin/rbash

# Используем ранее экспортированную переменную USER_ID, так как использование $$ в скрипте
# даст PID этого скрипта, а не оболочки, из которой он вызывается
file="/usr/data/data_${USER_ID}.txt"
# Максимальный размер в байтах (5 Кб)
max=5120

echo "Ваш файл - $file"
# Запуск редактора в ограниченном режиме
nano.old --restricted $file
# Если файл существует...
if [ -e  $file ]; then
        # Вычисляем размер
        bytes_count=$(cat $file | wc -c)
        # Если размер больше максимально доступног��...
        if (("$bytes_count" > $max)); then
                # Выводим то, что влезает в ограничение, чтобы можно было CTRL+C, CTRL+V
                head -c $max $file
                printf "\nFile too big. Allowed content listed above\n"
                # Удаляем файл
                rm
        fi

fi

По поводу --restricted, сочетания клавиш CTRL+R + CTRL+X позволяют запустить по сути мини консоль внутри nano, а это отдельный от нашей оболочки процесс и, как было ранее сказано, наши ограничения в отдельном процессе не работают. Но указанный флаг --restricted отключает эту возможность

Ещё аналогичную прослойку нужно сделать для rm.

#!/usr/bin/rbash

# Используем ранее экспортированную переменную USER_ID, так как использование $$ в скрипте
# даст PID этого скрипта, а не оболочки, из которой он вызывается
file="/usr/data/data_${USER_ID}.txt"
if [ -f $file ]; then
        rm.old $file
        echo "$file удалён"
fi

Обе прослойки, как и ранее созданные команды, нужно добавить в директорию commands.

8. Fork бомбы

Fork бомба – это обычно какая-то функция, которая рекурсивно вызывает сама себя, тем самым она забивает доступные ресурсы системы и ломает её. Самый простой её пример ранее уже фигурировал в тексте:

:(){ :|:& };:

Если в текущей среде запустить fork бомбу, то получится следующее:

Команда полностью занимает доступные ресурсы и не даёт даже начать любой другой процесс. Такая команда использует различные встроенный операторы из оболочки, которые нельзя нормально запретить. Решение, до которого я дошёл, не самое элегантное, но в целом рабочее. В docker есть возможность ограничить максимальное потребление оперативной памяти и процессора контейнером. Делается это с помощью флагов --memory и --cpus соответственно. Без ограничений fork бомба будет работать пока не забьётся хостовая память и процессор, а это займёт время, в течение которого вся среда будет не рабочей. Ограничения ускорят это, а когда будут достигнуты лимиты, контейнер будет "убит" OOM-killer'ом. Но у docker и тут есть козырь, контейнер может перезапускаться автоматически с флагом --restart, и как только он умрёт, то быстро встанет обратно (1-3 секунды). Проверим, как всё работает: sudo docker run --restart=unless-stopped --cpus="1" --memory="512m" -d --hostname regex --name regex-debian -p 2222:22 my-debian.

Отлично, контейнер оперативно перезапустился и готов к использованию.

На просторах интернета я читал про возможность нивелировать fork бомбы с помощью ресурсных ограничений оболочки (ulimit, pam_limits), но они не подходят в данном случае, поскольку в системе существует лишь один доступный пользователь. И, например, если ограничить максимум пользовательских процессов до двадцати (ulimit -u 20), то это просто приведёт к тому, что контейнер превратится в зомби. Команды он перестанет воспринимать (но будет выводить "fork: retry"), и умирать он не захочет, так как эти 20 процессов не могут забить оперативную память, чтобы пришёл OOM-killer. И это будет распространяться на все открытые ssh соединения.

9. ls

Под конец добавим прослойку для команды ls, чтобы пользователь не мог увидеть ничего лишнего.

#!/usr/bin/rbash

# Скрываем системные папки, добавляем сортировку и ограничиваем вывод корневой директорией
ls.old --hide=etc --hide=usr --hide=lib --hide=lib64 -X /

И поменяем немного dockerfile:

# Было
RUN /home/restricted.sh etc/profile nano.old rbash rm.old
# Стало
RUN /home/restricted.sh etc/profile nano.old rbash rm.old ls.old

# Было
COPY ./buildin.sh .
COPY ./restricted.sh .
RUN mv ${USER_NAME}/usr/bin/nano ${USER_NAME}/usr/bin/nano.old
RUN mv ${USER_NAME}/usr/bin/rm ${USER_NAME}/usr/bin/rm.old

# Стало
COPY ./buildin.sh .
COPY ./restricted.sh .
RUN mv ${USER_NAME}/usr/bin/nano ${USER_NAME}/usr/bin/nano.old
RUN mv ${USER_NAME}/usr/bin/rm ${USER_NAME}/usr/bin/rm.old
RUN mv ${USER_NAME}/usr/bin/ls ${USER_NAME}/usr/bin/ls.old

Ещё нужно заменить ls в helpme на ls.old

10. Итог

Если собрать целиком наш dockerfile, то получится следующее.

Я убрал все комментарии и поменял порядок строк, но команды остались прежние.

FROM debian:bookworm-slim 
ARG USER_PASSWORD 
ARG USER_NAME="regex" 

RUN apt update -y && apt upgrade -y && apt install -y ssh 
RUN useradd -m -s /usr/bin/rbash ${USER_NAME}
RUN echo "${USER_NAME}:${USER_PASSWORD}" | chpasswd 

WORKDIR /home
COPY ./make_chroot.sh .
RUN apt install -y ripgrep nano
RUN ./make_chroot.sh ${USER_NAME} ls rbash cat grep awk rg tr wc sed clear head tail nano rm

RUN mkdir -p /run/sshd
RUN chown -R root:root ${USER_NAME}
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config
RUN echo "AllowTcpForwarding no" >> /etc/ssh/sshd_config
RUN echo "ChrootDirectory /home/${USER_NAME}" >> /etc/ssh/sshd_config
RUN echo "ForceCommand rbash --login" >> /etc/ssh/sshd_config
RUN rm /etc/ssh/*key*
COPY ./fingerprint-ssh/* /etc/ssh

COPY ./buildin.sh .
COPY ./restricted.sh .
RUN mv ${USER_NAME}/usr/bin/nano ${USER_NAME}/usr/bin/nano.old
RUN mv ${USER_NAME}/usr/bin/rm ${USER_NAME}/usr/bin/rm.old
RUN mv ${USER_NAME}/usr/bin/ls ${USER_NAME}/usr/bin/ls.old

WORKDIR /home/${USER_NAME}
RUN rm ./.*
RUN mkdir etc
COPY hello.txt etc/hello.txt
COPY ./data/* .
COPY ./commands/* usr/bin/
RUN echo "PS1='\033[1;32m${USER_NAME}@\h\033[0m:\033[1;34m\w\033[0m\$ '" >> etc/profile
RUN echo "cat /etc/hello.txt" >> etc/profile
RUN echo "helpme .old rm .txt rbash" >> etc/profile
RUN echo "alias helpme='helpme .old rm .txt rbash'" >> etc/profile
RUN echo "readonly PATH=/usr/bin" >> etc/profile
RUN echo "readonly TMOUT=180" >> etc/profile

RUN mkdir usr/data
RUN chmod 777 usr/data
RUN echo "readonly TERM=xterm-16color" >> etc/profile
RUN mkdir -p usr/share/terminfo
RUN cp -R --preserve=mode /usr/share/terminfo/* usr/share/terminfo

RUN echo "readonly USER_ID=\$$" >> etc/profile
RUN echo "export USER_ID" >> etc/profile
RUN echo "trap 'rm \"/usr/data/data_\${$}.txt\"' EXIT" >> etc/profile

RUN /home/restricted.sh etc/profile nano.old rbash rm.old ls.old
RUN /home/buildin.sh etc/profile

CMD ["/usr/sbin/sshd", "-D"]
EXPOSE 22/tcp

Структура получается следующая:

Все файлы имеются здесь

А если интересно потрогать руками, то вам сюда - ssh regex@176.106.242.151 -p 2222

Пароль - Yz0oYsX5qj3tZTZC9ng3