
Все началось с того, что друг показал удивительный артефакт: на флешке в одном каталоге было два файла с идентичным названием. Разгадка, конечно, простая: во всем виноват фотоаппарат, у которого, возможно, меньше проверок в момент записи кадра.
Этот прецедент побудил поискать ответы на несколько вопросов. Можно ли обмануть операционную систему компьютера и нарушить запреты файловых систем? И если это получится, то как отреагирует ОС?
Под катом вас ждет небольшой экскурс в особенности работы файловых систем и набор экспериментов.
Большинство ограничений на имена файлов и каталогов очевидны.
- Длина имени файла ограничена. Структура, которая хранит информацию о файле не бесконечна.
- Длина абсолютного пути до файла ограничена. Чуть менее очевидно, но причина идентична: размер буфера, в котором хранится абсолютный путь до файла, тоже не бесконечен.
- Разделитель в пути, символ «/», использовать нельзя.
- Нуль-символ использовать нельзя — это признак конца строки.
- Внутри одного каталога не может быть двух файлов с одним именем.
Переполнять имена файлов — не так интересно, потому что обычно размер полей прописан отдельным целочисленным полем. Если его переполнить, структура потеряет целостность. А вот добавить в имя разделитель или сделать два файла с одним именем… Уже догадываетесь?

Все есть файл
В семействе операционных систем *NIX есть красивая идея, которая описывается словами «Everything is a file», то есть «все есть файл». Благодаря этой абстракции практически все операции ввода-вывода используют один интерфейс, состоящий из следующих системных вызовов. Это не полный список.
- open(2) — открытие ресурса на чтение или запись.
- read(2) — чтение байтов из ресурса.
- write(2) — запись байтов в ресурс.
- close(2) — закрытие ресурса.
Пионеры компьютерных наук — это умные люди, которые решали невиданные ранее задачи. Это великое наследие, к которому иногда возникают вопросы. Например, почему системный вызов для создания файла называется creat(2), а не create(2)? Маленькие познавательные тексты и рассуждения по темам будущих статей — в моем Telegram-канале.
Эти вызовы позволяют получать доступ практически ко всем ресурсам в операционной системе.
- К отдельным файлам и каталогам, которые лежат на накопителе.
- Непосредственно к байтовому представлению на накопителе (файлы /dev/sd* и /dev/nvme*).
- К внешнему устройству: принтеру, мышке, клавиатуре (файлы /dev/input/, /dev/mouse*).
- Даже сокеты Беркли, которые используются для доступа в Интернет, поддерживают перечисленные операции. Только открытие происходит с помощью системного вызова socket(2), а не через open(2).
Но не будет погружаться на всю глубину этой абстракции, взглянем в самое начало. Каталог — это тоже файл. Да, его можно прочитать утилитой cat. Но не в современных системах.
root@ubuntu-2204:~# cat /tmp cat: /tmp: Is a directory root@ubuntu-2204:~#
В Linux этот фокус не работает, а вот во FreeBSD до 12 версии можно вывести каталог.
root@freebsd-11:~ # cat /tmp X...X .X11-unix<X .XIM-unix<X .ICE-unix<X PERL5_DEFAULT\local.UgDju9Hno\dmail.RsZJCJr4Yt6\Hmail.RsygwgsVOYB4\(write-422194.oZxxhash-737aeb.oZbench-fb2faf.lz4io-a10809.olz4cli-b2a9d6.o root@freebsd:~ #
Вывод — это не просто набор непечатных символов и имен файлов, а текстовое представление содержимого каталога. Каталог — это массив структур dirent, который выглядит следующим образом:
struct dirent { __uint32_t d_fileno; /* file number of entry */ __uint16_t d_reclen; /* length of this record */ __uint8_t d_type; /* file type, see below */ __uint8_t d_namlen; /* length of string in d_name */ #ifdef _POSIX_SOURCE char d_name[255 + 1]; /* name must be no longer than this */ #else #define MAXNAMLEN 255 char d_name[MAXNAMLEN + 1]; /* name must be no longer than this */ #endif };
Для чтения каталогов есть отдельные функции, которые разбирают содержимое каталога на структуры dirent: opendir(3), readdir(3) и closedir(3). Но в академических целях можно обойтись без них:
#include <stdio.h> // printf(3) #include <sys/types.h> // open(2) #include <sys/stat.h> // open(2) #include <fcntl.h> // open(2) #include <errno.h> // errno #include <string.h> // strerror(3) #include <unistd.h> // close(2) #include <dirent.h> #define BUF_SIZE 4096 int main(int argc, char* argv[]) { // Открываем на чтение. Без флага O_DIRECTORY будет ошибка. int fd = open(argv[1], O_DIRECTORY | O_RDONLY); if(fd < 0) { printf("Cannot open %s: %s\n", argv[1], strerror(errno)); return 1; } unsigned char buf[BUF_SIZE]; while(1) { struct dirent dir; /* Считываем по одному полю */ ssize_t count = read(fd, &dir.d_fileno, sizeof(dir.d_fileno)); /* Обработка ошибок один раз для читаемости */ if(count < 0) { printf("Read error: %s\n", strerror(errno)); return 2; } if(count == 0) break; count += read(fd, &dir.d_reclen, sizeof(dir.d_reclen)); count += read(fd, &dir.d_type, sizeof(dir.d_type)); count += read(fd, &dir.d_namlen, sizeof(dir.d_namlen)); count += read(fd, &dir.d_name, dir.d_reclen - count); dir.d_name[dir.d_namlen] = '\0'; printf("Entry:\n"); printf("\td_fileno: %u\n", dir.d_fileno); printf("\td_reclen: %u (read: %zd)\n", dir.d_reclen, count); printf("\td_type: %u\n", dir.d_type); printf("\td_namelen: %u\n", dir.d_namlen); printf("\td_name: %s\n", dir.d_name); } close(fd); return 0; }
Да, это тоже работает:
root@freebsd:~ # ./a.out /tmp Entry: d_fileno: 11556864 d_reclen: 12 (read: 12) d_type: 4 d_namelen: 1 d_name: . Entry: d_fileno: 2 d_reclen: 12 (read: 12) d_type: 4 d_namelen: 2 d_name: .. Entry: d_fileno: 11556869 d_reclen: 20 (read: 20) d_type: 4 d_namelen: 9 d_name: .X11-unix Entry: d_fileno: 11556870 d_reclen: 20 (read: 20) d_type: 4 d_namelen: 9 d_name: .XIM-unix Entry: d_fileno: 11556871 d_reclen: 20 (read: 20) d_type: 4 d_namelen: 9 d_name: .ICE-unix Entry: d_fileno: 11556872 d_reclen: 20 (read: 20) d_type: 4 d_namelen: 10 d_name: .font-unix Entry: d_fileno: 11556915 d_reclen: 408 (read: 408) d_type: 8 d_namelen: 13 d_name: PERL5_DEFAULT
Кстати, в функциях для работы с директориями нет ничего про запись. Но раз у нас есть общий интерфейс, то попробуем открыть директорию на запись и что-нибудь сломать.
#include <stdio.h> // printf(3) #include <sys/types.h> // open(2) #include <sys/stat.h> // open(2) #include <fcntl.h> // open(2) #include <errno.h> // errno #include <string.h> // strerror(3) #include <unistd.h> // close(2) int main(int argc, char* argv[]) { /* Сперва откроем на запись, вдруг прокатит */ int fd = open(argv[1], O_DIRECTORY | O_WRONLY); if(fd < 0) { printf("Cannot open for write %s: %s\n", argv[1], strerror(errno)); return 1; } return 0; }
Компилируем, запускаем.
root@freebsd:~ # cc write.c root@freebsd:~ # ./a.out /tmp Cannot open for write /tmp: Is a directory root@freebsd:~ #
Не получилось. Я проверял этот код на FreeBSD 11, которая вышла в 2016 году. Может, в каноническом UNIX нет таких ограничений? Перепишем код в синтаксис K&R. Константы O_WRONLY тоже нет, поэтому заглядываем в документацию и выбираем число 1.
int main(argc, argv) int argc; char* argv[]; { int fd; fd = open(argv[1], 1); if(fd < 0) perror("open"); return 0; }
И запустим. Для проверки таких гипотез можно использовать эмулятор PDP-11/70 на JavaScript, который доступен в браузере. Он также содержит образы Unix v5 и BSD2.11. Запустим код на Unix v5.
# cc a.c # ./a.out /tmp open: Is a directory
Это было ожидаемо. Более того, попытки поискать что-то похожее на структуру dirent на накопителе не увенчаются успехом. Структура dirent — это абстракция, независимая от используемой файловой системы. Ядро «подкладывает» подобные структуры при запросе, поэтому записать в них ничего нельзя.
Хорошо, мы не можем из пространства пользователя изменить содержимое каталога. Тогда замыслим шалость!
Не доверяй пользовательским данным
Если мы не можем через операционную систему ввести некорректные данные, значит, мы сделаем это без ее ведома. Откроем диск в HEX-редакторе на Windows, например. Про Windows я, конечно, погорячился, но основная мысль сохраняется:
- создадим «диск» в виде файла на 50 МБ,
- разметим файловую систему внутри файла,
- примонтируем содержимое файла в каталог mnt,
- создадим файл с названием 12345678,
- отмонтируем «диск»,
- через HEX-редактор изменим имя файла,
- примонтируем «диск» и проверим успешность затеи.
Благодаря идее «все есть файл» для операционной системы нет разницы между файлом-диском и обычным файлом. Это позволяет создать файловую систему не на разделе диска, а в обычном файле. А потом примонтировать этот файл как будто это диск.
Выполняем подготовку:
# Создаем файл dd if=/dev/zero of=img bs=1M count=50 # Создаем файловую систему mkfs.ext4 img # Монтируем диск mkdir mnt mount img mnt # Пустой файл touch mnt/12345678 # Отмонтируем umount mnt # Переименовываем 12345678 в 87654321 sed -i 's/12345678/87654321/g' img
Для переименования я использовал неинтерактивный текстовый редактор sed, он отлично справляется со своей задачей. Монтируем:
void@ubuntu-2204:~# mount img mnt void@ubuntu-2204:~# ls -l mnt ls: reading directory 'mnt': Bad message total 0
Ого,
[36446.876771] EXT4-fs error (device loop1): htree_dirblock_to_tree:1080: inode #2: comm ls: Directory block failed checksum
Логично. Файловые системы работают с аппаратным обеспечением, которое в силу своей физической природы может дать сбой. Контрольные суммы позволяют обнаруживать и сообщать об ошибках. Воспользуемся утилитой e2fsck для проверки файловой системы.
void@ubuntu-2204:~# e2fsck img e2fsck 1.46.5 (30-Dec-2021) img contains a file system with errors, check forced. Pass 1: Checking inodes, blocks, and sizes Pass 2: Checking directory structure Directory inode 2, block #0: directory passes checks but fails checksum. Fix<y>? yes Pass 3: Checking directory connectivity Pass 3A: Optimizing directories Pass 4: Checking reference counts Pass 5: Checking group summary information img: ***** FILE SYSTEM WAS MODIFIED ***** img: 13/12800 files (7.7% non-contiguous), 1840/12800 blocks void@ubuntu-2204:~#
Монтируем и проверяем заново:
void@ubuntu-2204:~# ls -l mnt/ total 16 -rw-r--r-- 1 void root 0 Mar 5 18:44 87654321 -rw-r--r-- 1 void root 0 Mar 5 18:31 blablabla drwx------ 2 void root 16384 Mar 5 18:26 lost+found
Сработало. Тогда попробуем первое испытание: создадим два файла и переименованием назначим им одно имя.
void@ubuntu-2204:~/mnt# ls -li total 24 12 -rw-r--r-- 1 void root 2 Mar 5 19:26 1.txt 13 -rw-r--r-- 1 void root 20 Mar 5 19:26 2.txt 11 drwx------ 2 void root 16384 Mar 5 18:26 lost+found void@ubuntu-2204:~/mnt# cat 1.txt 1 void@ubuntu-2204:~/mnt# cat 2.txt 2222222222222222222 void@ubuntu-2204:~/mnt#
Переименовываем и запускаем пересчет контрольной суммы:
void@ubuntu-2204:~# e2fsck img e2fsck 1.46.5 (30-Dec-2021) img contains a file system with errors, check forced. Pass 1: Checking inodes, blocks, and sizes Pass 2: Checking directory structure Duplicate entry 'a.txt' found. Marking ??? (2) to be rebuilt. Directory inode 2, block #0: directory passes checks but fails checksum. Fix<y>? yes Pass 3: Checking directory connectivity Pass 3A: Optimizing directories Entry 'a.txt' in / (2) has a non-unique filename. Rename to a.txt~0<y>? no Pass 4: Checking reference counts Pass 5: Checking group summary information img: ***** FILE SYSTEM WAS MODIFIED ***** img: ********** WARNING: Filesystem still has errors ********** img: 13/12800 files (7.7% non-contiguous), 1842/12800 blocks void@ubuntu-2204:~# mount img mnt/
Утилита e2fsck исправляет контрольную сумму и находит неуникальное имя. Мы, в свою очередь, отказываемся от переименования и монтируем «диск». Что мы видим?
void@ubuntu-2204:~# ls -li mnt total 24 12 -rw-r--r-- 1 void root 2 Mar 5 19:26 a.txt 12 -rw-r--r-- 1 void root 2 Mar 5 19:26 a.txt 11 drwx------ 2 void root 16384 Mar 5 18:26 lost+found void@ubuntu-2204:~# cat mnt/a.txt 1
Драйвер системы выдает небольшую аномалию: файл с меньшим числовым идентификатором, номером inode, отображается два раза. При этом все обращения из интерпретатора командной строки взаимодействуют именно с «первым» файлом.
void@ubuntu-2204:~# rm mnt/a.txt void@ubuntu-2204:~# ls -l mnt/ ls: cannot access 'mnt/a.txt': No such file or directory total 16 ? -????????? ? ? ? ? ? a.txt drwx------ 2 void root 16384 Mar 5 18:26 lost+found void@ubuntu-2204:~# cat mnt/a.txt cat: mnt/a.txt: No such file or directory void@ubuntu-2204:~# umount mnt void@ubuntu-2204:~# mount img mnt/ void@ubuntu-2204:~# ls -li mnt/ total 20 13 -rw-r--r-- 1 void root 20 Mar 5 19:26 a.txt 11 drwx------ 2 void root 16384 Mar 5 18:26 lost+found void@ubuntu-2204:~# cat mnt/a.txt 2222222222222222222
Если удалить файл, то его «тезка» перейдет в состояние кота Шредингера. Файл есть, автодополнение по имени работает, но файла нет. После перемонтирования файловой системы второй файл без проблем находится.
Теперь сделаем страшное и добавим символ-разделитель:
void@ubuntu-2204:~# cat >mnt/split-me.txt Hello, Habr! void@ubuntu-2204:~# umount mnt void@ubuntu-2204:~# sed -i 's/split-me.txt/split\/me.txt/g' img void@ubuntu-2204:~# e2fsck img e2fsck 1.46.5 (30-Dec-2021) img contains a file system with errors, check forced. Pass 1: Checking inodes, blocks, and sizes Pass 2: Checking directory structure Entry 'split/me.txt' in ??? (2) has illegal characters in its name. Fix<y>? no Directory inode 2, block #0: directory passes checks but fails checksum. Fix<y>? yes Pass 3: Checking directory connectivity Pass 3A: Optimizing directories Pass 4: Checking reference counts Pass 5: Checking group summary information img: ***** FILE SYSTEM WAS MODIFIED ***** img: ********** WARNING: Filesystem still has errors ********** img: 12/12800 files (8.3% non-contiguous), 1841/12800 blocks
Проверка находит такую ошибку, но не считает ее критичной. Хорошо, монтируем:
void@ubuntu-2204:~# ls -l mnt/ ls: reading directory 'mnt/': Input/output error total 16 drwx------ 2 void root 16384 Mar 5 18:26 lost+found void@ubuntu-2204:~# ls -l mnt/split/me.txt ls: cannot access 'mnt/split/me.txt': No such file or directory void@ubuntu-2204:~# mkdir mnt/split void@ubuntu-2204:~# cat mnt/split/me.txt cat: mnt/split/me.txt: No such file or directory void@ubuntu-2204:~# echo Wow > mnt/split/me.txt void@ubuntu-2204:~# cat mnt/split/me.txt Wow
Результат не заставляет себя долго ждать.
- Частично сломалось отображение содержимого каталога. Объекты с номером inode больше, чем у «сломанного» файла, не попадут в вывод.
- Сам «сломанный» файл недоступен.
С одной стороны, это пример хорошей устойчивости к ошибкам. С другой — этой проблеме не день и не два, а 45 лет, если брать за точку отсчета выход UNIX v7.
Статья посвящена семейству операционных систем *NIX. Но в процессе написания стало интересно как такие ситуации обрабатывают «оконные» файловые системы.
Возможно, эти тексты тоже вас заинтересуют:
→ Figma закрыла Dev Mode: пути обхода и их краткий обзор
→ Мини-ПК для «тяжелых» и не очень задач: 5 моделей начала весны 2024 года
→ 10нм техпроцесс и 6 ГГц: Intel ставит новые рекорды производительности чипов. Что нового?
exFAT
Брать NTFS кажется избыточным. Но статья началась из-за бага на флешке фотоаппарата. Там используется файловая система от Microsoft — exFAT. Это преемник FAT, предназначенный для съемных носителей. Отличный кандидат. Хотя для Ubuntu 22.04 необходимо поставить именно exfat-fuse.
Теперь можно создать образ как раньше:
void@ubuntu-2204:~# dd if=/dev/zero of=img bs=1M count=50 50+0 records in 50+0 records out 52428800 bytes (52 MB, 50 MiB) copied, 0.024757 s, 2.1 GB/s void@ubuntu-2204:~# mkfs.exfat img exfatprogs version : 1.1.3 Creating exFAT filesystem(img, cluster size=4096) Writing volume boot record: done Writing backup volume boot record: done Fat table creation: done Allocation bitmap creation: done Upcase table creation: done Writing root directory entry: done Synchronizing... exFAT format complete!<br>void@ubuntu-2204:~# mount img mnt void@ubuntu-2204:~# cat > mnt/1.txt 111111111111111111111111 void@ubuntu-2204:~# cat > mnt/2.txt 222222222222222222222
Заменить имя файла через sed уже не получится: для имен exFAT использует кодировку UTF-16. Хорошо, воспользуемся интерпретатором Python и напишем такой скрипт:
import sys file, pattern, replace = sys.argv[1:4] pattern = pattern.encode("utf-16le") replace = replace.encode("utf-16le") with open(file, "rb") as f: raw_data = f.read() raw_data = raw_data.replace(pattern, replace) with open(file, "wb") as f: f.write(raw_data)
Неэффективно по памяти, но для образа в 50 МБ это не проблема. Производим замену:
void@ubuntu-2204:~# python3 replace.py img 2.txt 1.txt void@ubuntu-2204:~# mount img mnt void@ubuntu-2204:~# ls -li mnt/ total 8 10 -rwxr-xr-x 1 void root 25 Mar 5 20:48 1.txt 10 -rwxr-xr-x 1 void root 25 Mar 5 20:48 1.txt
Реакция Ubuntu не поменялась: доступен только первый файл. Аналогично с UNIX-разделителем. А вот Windows-разделитель вносит смуту, но отображает файл:
void@ubuntu-2204:~# ls -l mnt/ ls: cannot access 'mnt/2\txt': No such file or directory total 4 -rwxr-xr-x 1 void root 25 Mar 5 20:48 1.txt -????????? ? ? ? ? ? '2\txt' void@ubuntu-2204:~# ls -l mnt/2\\txt ls: cannot access 'mnt/2\txt': No such file or directory
Проверим на Windows и записываем на флешку.
dd if=img of=/dev/<здесь мой диск> bs=4M

Уведомление в Windows 10.

Проигнорировать исправление ошибок нельзя.
Windows тут же определяет накопитель и предлагает исправить ошибки. Исправление ошибок — это предложение, от которого нельзя отказаться. Потому что иначе не получится просмотреть содержимое накопителя в графическом интерфейсе.

Консоль топ.
Командная строка покажет первые файлы, но потом запнется на «сломанном» файле. Прочитать, однако, файлы рядом возможно.
Абстракции для файловых систем в *NIX — это не только универсальный интерфейс, но и отличная устойчивость к ошибкам. Что думаете по поводу рассмотренных артефактов вы? Поделитесь своим мнением в комментариях!
