Hidden Space Explorer
A tool for the classic "df -h says 100%, du -sh finds nothing" incident.
Architecture: the privileged part — scanning /proc, ftruncate,
signals, ptrace — is a standalone C daemon (hsedd) that runs in the
background or on demand and accepts commands over a Unix socket. hsed is
the interactive TUI (Python/Textual), a thin client that connects to it.
Python never touches /proc, ftruncate, kill, or ptrace directly —
every privileged operation happens in hsedd.
┌──────────────┐ SCAN / TRUNCATE / HUP / KILL / STREAM ┌───────────────────────┐
│ hsed │ ────────────────────────────────────────► │ hsedd (C daemon) │
│ (Python TUI) │ JSON-lines responses │ /proc, ftruncate, │
└──────────────┘ ◄──────────────────────────────────────── │ kill, ptrace(SEIZE) │
Unix domain socket └───────────────────────┘
sudo dpkg -i hsed_1.0.0_amd64.debThat's it — hsed and hsedd both land on PATH. The Python TUI runs
against a vendored copy of its dependencies bundled in the package, so
nothing else needs to be installed (no pip, no internet access needed).
Start the backend:
sudo systemctl enable --now hsed # if systemd is available, or:
sudo hsedd # background daemon
sudo hsedd --foreground # stays attached (Ctrl-C to stop)Then just run:
sudo hsedcd backend && make # builds hsedd
cd ../packaging && ./build-deb.sh # assembles + builds the .deb
chmod +x ./build-deb.sh # if permission denied
sudo ./build-deb.sh
sudo dpkg -i hsed_1.0.0_amd64.deb
sudo systemctl enable --now hsed
See packaging/build-deb.sh for exactly what goes into the package (it's
the same steps as above: compile hsedd, vendor the Python deps, lay out
the usr//lib/ tree, dpkg-deb --build).
On Linux, deleting a file (rm, unlink()) only removes its directory
entry. If a process still holds an open file descriptor to that inode, the
kernel keeps the inode — and every block it occupies — alive until the last
fd is closed. du walks the directory tree, so it never sees these blocks.
df reports real filesystem block usage, so it does. The gap between the
two is exactly this: unlinked-but-open files.
Common causes:
- a service logs to a file that got deleted by a naive cleanup cron/log
rotation that didn't
HUP/USR1the process afterward — it keeps writing into the void, forever growing - a crashed/restarted process leaked an fd to a large temp file
- a compromised process or container deliberately
open()s a file, thenunlink()s it immediately, to hide payload/exfil data fromfind,du, antivirus directory scans, and casualls
The only reliable way to find these is /proc/<pid>/fd/*, where the kernel
suffixes the symlink target with (deleted). hsedd automates that scan
and exposes four more things over its socket protocol:
SCAN— walk/proc, list every unlinked-but-open fdTRUNCATE <pid> <fd>— reopen the same inode through/proc/<pid>/fd/<fd>andftruncateit to zero, freeing the disk blocks immediately, without signaling, pausing, or restarting the processHUP <pid>— the polite request: ask the process to reopen its log files itself, if it supports that conventionKILL <pid>— SIGKILL, for when the process is hung/unresponsive and won't release the fd any other way. Immediate, uncatchable, no graceful shutdown; the kernel releases every fd as part of normal process teardown. The TUI requires typing the wordKILLto confirm — a single stray keypress can't trigger it.STREAM <pid> <fd>— attach viaptrace(PTRACE_SEIZE)and stream, live, exactly what bytes the process is writing into that fd right now
sudo hsed
sudo hsed --min-size 10485760 # only show entries >= 10MiB
sudo hsed --pid 4821 # inspect one process only
sudo hsed --socket /run/hsed.sock # point at a non-default daemonhsed connects to hsedd's socket — $HSED_SOCKET, else /run/hsed.sock
if you're root, else /tmp/hsed-<uid>.sock. If it can't connect, it tells
you exactly how to start the daemon and exits, rather than pretending to
work with no data.
| Key | Action |
|---|---|
r |
Rescan immediately |
/ |
Filter table by process name or path substring |
Enter |
Open details + actions for the selected row |
v |
Hidden filesystem tree view (paths reconstructed from deleted entries) |
q |
Quit |
Inside the detail panel:
| Key | Action |
|---|---|
s |
Stream live writes to this fd (ptrace, via hsedd) |
t |
Truncate the hidden file, reclaim its space now |
h |
Send SIGHUP, asking the process to reopen its files |
k |
SIGKILL the process (type KILL to confirm) |
Esc |
Close |
Truncate is a low-level operation. It is safe for the common case — an append-only log file a process just keeps writing to sequentially. It is not safe, in general, for:
- files a process has
mmap()'d (truncating out from under a live mapping can raiseSIGBUSon the process's next access to that region) - files accessed at arbitrary offsets rather than append-only (database data files, WAL/redo logs, B-tree indexes, anything with a defined binary layout)
For a production database or anything similar, prefer h (SIGHUP / graceful
reopen) first. Reach for t (truncate) only on confirmed append-only
log-style writers, and k (SIGKILL) only when the process is genuinely
hung and a supervisor (systemd, container runtime, etc.) will restart it —
not as a routine way to free space. When in doubt, test the exact same
binary/version against a staging replica first.
Streaming uses ptrace. hsedd attaches with PTRACE_SEIZE (which,
unlike PTRACE_ATTACH, doesn't stop the target as a side effect) and only
interrupts it briefly on each traced write/pwrite64/writev syscall —
for a typical daemon this is not noticeable, but for latency-sensitive
workloads it can matter. It requires root or CAP_SYS_PTRACE, and depends
on /proc/sys/kernel/yama/ptrace_scope allowing the attach (root is
unaffected by this setting).
Streaming scope in this version: x86_64 only, and it traces exactly the
PID/TID you give it — it does not follow clone()'d threads the way
strace -f does. If the fd could be written by a different thread of a
multi-threaded target, find that thread's TID under /proc/<pid>/task/ and
pass it as the pid instead. writev() calls are reported (syscall +
length) but the payload preview isn't captured in this version — plain
write()/pwrite64() are.
This tool only detects and lets you act on already-open fds. It does
not detect rootkits, hook the kernel, or hide/unhide anything itself — it's
a read side, standard /proc inspection (the same data lsof -a +L1 or
find /proc/*/fd -lname '* (deleted)' gives you), and a write side that
uses only ftruncate, kill, and standard ptrace(2). No new
kernel-level capability is introduced beyond what root/CAP_SYS_PTRACE
already grant — the same privileges strace -p and gdb -p already need.
If you want to drive hsedd from something other than this TUI, the wire
format is a small newline-delimited text-command / JSON-lines-response
protocol over the Unix socket — see backend/src/protocol.h for the exact
schema (SCAN, TRUNCATE, HUP, KILL, STREAM, PING, QUIT).
sudo systemctl disable --now hsed # if you enabled the service
sudo dpkg -r hsed # remove
sudo dpkg -P hsed # remove + purge runtime socket/pidfilebackend/ # the C daemon (binary: hsedd)
├── src/
│ ├── proc_scan.c/.h # /proc/*/fd walk -> hsed_entry_t list
│ ├── reclaim.c/.h # ftruncate via /proc/pid/fd/N, kill(sig)
│ ├── tracer.c/.h # ptrace(SEIZE/INTERRUPT) live write streamer (x86_64)
│ ├── protocol.c/.h # JSON-lines formatting, line I/O helpers
│ ├── server.c/.h # Unix socket accept loop, one thread per connection
│ ├── base64.c/.h # binary-safe transport for captured write() bytes
│ ├── util.c/.h # logging, small string helpers
│ └── hsed.c # main(): args, daemonization, signal handling
├── systemd/hsed.service
└── Makefile
hidden_space_explorer/ # the Python TUI (thin client, command: hsed)
├── client.py # HsedClient / StreamSession — talks to hsedd's socket
├── app.py # Textual UI: table, tree view, detail modal, stream screen
└── __main__.py # CLI entry point
packaging/
├── build-deb.sh # builds hsedd, vendors Python deps, assembles the .deb
└── hsed_1.0.0_amd64/DEBIAN/ # control, postinst, postrm
Hidden Space Explorer (Russian README)
Инструмент для классического инцидента: «df -h показывает 100%, du -sh не находит ничего».
Архитектура: привилегированная часть — сканирование /proc, ftruncate,
сигналы, ptrace — представляет собой автономный демон на C (hsedd), который
работает в фоне или по требованию и принимает команды через Unix-сокет. hsed —
это интерактивный TUI (Python/Textual), тонкий клиент, который подключается к
нему. Python никогда не обращается к /proc, ftruncate, kill или ptrace
напрямую — все привилегированные операции выполняются в hsedd.
┌──────────────┐ SCAN / TRUNCATE / HUP / KILL / STREAM ┌───────────────────────┐
│ hsed │ ────────────────────────────────────────► │ hsedd (демон на C) │
│ (Python TUI) │ ответы в JSON-lines │ /proc, ftruncate, │
└──────────────┘ ◄──────────────────────────────────────── │ kill, ptrace(SEIZE) │
Unix domain socket └───────────────────────┘
sudo dpkg -i hsed_1.0.0_amd64.debВот и всё — hsed и hsedd оказываются в PATH. Python TUI работает с
вендорной копией своих зависимостей, входящей в состав пакета, поэтому
ничего дополнительно устанавливать не нужно (ни pip, ни доступ в интернет).
Запуск бэкенда:
sudo systemctl enable --now hsed # если доступен systemd, или:
sudo hsedd # фоновый демон
sudo hsedd --foreground # остаётся прикреплённым к терминалу (Ctrl-C для остановки)Затем просто запустите:
sudo hsedcd backend && make # собираем hsed
cd ../packaging && ./build-deb.sh # компоновка + сборка
chmod +x ./build-deb.sh # если ошибка доступ запрещен
sudo ./build-deb.sh
sudo dpkg -i hsed_1.0.0_amd64.deb
sudo systemctl enable --now hsed Подробности того, что попадает в пакет, смотрите в packaging/build-deb.sh
(те же шаги, что и выше: скомпилировать hsedd, вендорить зависимости Python,
разложить дерево usr//lib/, dpkg-deb --build).
В Linux удаление файла (rm, unlink()) убирает только его запись в каталоге.
Если процесс всё ещё держит открытый файловый дескриптор на этот inode, ядро
сохраняет inode — и каждый занятый им блок — живым до тех пор, пока последний
дескриптор не будет закрыт. du обходит дерево каталогов, поэтому никогда
не видит эти блоки. df сообщает реальное использование блоков файловой
системы, поэтому видит. Разница между ними — это именно оно: удалённые,
но открытые файлы.
Распространённые причины:
- сервис пишет логи в файл, который был удалён наивным кроном очистки или
ротацией логов, не отправившей процессу
HUP/USR1после этого — он продолжает писать в пустоту, бесконечно увеличиваясь в размере - упавший/перезапущенный процесс утёк fd на большой временный файл
- скомпрометированный процесс или контейнер намеренно делает
open()файла, затем сразуunlink(), чтобы скрыть полезную нагрузку/данные эксфильтрации отfind,du, антивирусного сканирования каталогов и обычногоls
Единственный надёжный способ найти их — /proc/<pid>/fd/*, где ядро добавляет
к цели симлинка суффикс (deleted). hsedd автоматизирует это сканирование
и предоставляет ещё четыре возможности через свой сокетный протокол:
SCAN— обойти/proc, перечислить каждый удалённый-но-открытый fdTRUNCATE <pid> <fd>— переоткрыть тот же inode через/proc/<pid>/fd/<fd>и обрезать его до нуля с помощьюftruncate, немедленно освобождая дисковые блоки, без сигналов, паузы или перезапуска процессаHUP <pid>— вежливый запрос: попросить процесс самостоятельно переоткрыть свои файлы логов, если он поддерживает это соглашениеKILL <pid>— SIGKILL, для случаев, когда процесс завис/не отвечает и не освобождает fd иным способом. Мгновенный, неперехватываемый, без корректного завершения; ядро освобождает каждый fd в рамках обычной процедуры завершения процесса. TUI требует ввода словаKILLдля подтверждения — случайное нажатие клавиши не может активировать его.STREAM <pid> <fd>— подключиться черезptrace(PTRACE_SEIZE)и транслировать в реальном времени, какие именно байты процесс записывает в этот fd прямо сейчас
sudo hsed
sudo hsed --min-size 10485760 # показать только записи размером >= 10 МиБ
sudo hsed --pid 4821 # проверить только один процесс
sudo hsed --socket /run/hsed.sock # указать нестандартный сокет демонаhsed подключается к сокету hsedd — $HSED_SOCKET, иначе /run/hsed.sock,
если вы root, иначе /tmp/hsed-<uid>.sock. Если подключиться не удаётся,
он сообщает, как именно запустить демон, и завершает работу, а не делает вид,
что работает без данных.
| Клавиша | Действие |
|---|---|
r |
Немедленно пересканировать |
/ |
Фильтровать таблицу по подстроке имени процесса или пути |
Enter |
Открыть детали + действия для выбранной строки |
v |
Скрытое дерево файловой системы (пути, восстановленные из удалённых записей) |
q |
Выйти |
Внутри панели деталей:
| Клавиша | Действие |
|---|---|
s |
Транслировать живую запись в этот fd (ptrace, через hsedd) |
t |
Обрезать скрытый файл, немедленно вернуть его место |
h |
Отправить SIGHUP, попросив процесс переоткрыть свои файлы |
k |
SIGKILL процессу (введите KILL для подтверждения) |
Esc |
Закрыть |
Truncate — это низкоуровневая операция. Она безопасна для частого случая — файла лога, дописываемого только в конец, в который процесс просто продолжает писать последовательно. Она не безопасна, в общем, для:
- файлов, которые процесс отобразил в память через
mmap()(обрезание под живым отображением может вызватьSIGBUSпри следующем обращении процесса к этой области) - файлов с произвольным доступом, а не только с дописыванием в конец (файлы данных баз данных, WAL/redo-логи, индексы B-tree, что-либо с определённой бинарной структурой)
Для промышленной базы данных или чего-то подобного предпочтите h (SIGHUP /
корректное переоткрытие) в первую очередь. Используйте t (truncate) только
на подтверждённо дописываемых в конец лог-файлах, и k (SIGKILL) только когда
процесс действительно завис и супервизор (systemd, среда выполнения контейнеров
и т.п.) перезапустит его — не как рутинный способ освободить место. Если
сомневаетесь, сначала протестируйте на идентичном бинарнике/версии на
промежуточной реплике.
Стриминг использует ptrace. hsedd подключается с PTRACE_SEIZE (который,
в отличие от PTRACE_ATTACH, не останавливает цель как побочный эффект) и лишь
кратковременно прерывает её на каждом трассируемом системном вызове
write/pwrite64/writev — для типичного демона это незаметно, но для
чувствительных к задержкам рабочих нагрузок может иметь значение. Требуется
root или CAP_SYS_PTRACE, а также /proc/sys/kernel/yama/ptrace_scope,
разрешающая подключение (на root эта настройка не влияет).
Область стриминга в этой версии: только x86_64, и трассируется ровно тот
PID/TID, который вы указали — он не следует за потоками, созданными clone(),
в отличие от strace -f. Если fd может быть записан другим потоком
многопоточной цели, найдите TID этого потока в /proc/<pid>/task/ и передайте
его как pid. Вызовы writev() сообщаются (системный вызов + длина), но
предпросмотр полезной нагрузки не захватывается в этой версии — обычные
write()/pwrite64() захватываются.
Этот инструмент только обнаруживает и позволяет действовать на уже открытые
fd. Он не обнаруживает руткиты, не перехватывает ядро и не скрывает/показывает
что-либо сам — это сторона чтения, стандартная инспекция /proc (те же данные,
что дают lsof -a +L1 или find /proc/*/fd -lname '* (deleted)'), и сторона
записи, использующая только ftruncate, kill и стандартный ptrace(2).
Никаких новых возможностей уровня ядра не вводится сверх того, что уже дают
root/CAP_SYS_PTRACE — те же привилегии, которые уже нужны для strace -p и
gdb -p.
Если вы хотите управлять hsedd не из этого TUI, формат передачи данных —
это небольшой протокол с текстовыми командами, разделяемыми переводом строки, и
ответами в JSON-lines через Unix-сокет — точную схему смотрите в
backend/src/protocol.h (SCAN, TRUNCATE, HUP, KILL, STREAM, PING,
QUIT).
sudo systemctl disable --now hsed # если вы включили службу
sudo dpkg -r hsed # удалить
sudo dpkg -P hsed # удалить + очистить сокет/pid-файл времени выполненияbackend/ # демон на C (бинарник: hsedd)
├── src/
│ ├── proc_scan.c/.h # обход /proc/*/fd -> список hsed_entry_t
│ ├── reclaim.c/.h # ftruncate через /proc/pid/fd/N, kill(sig)
│ ├── tracer.c/.h # ptrace(SEIZE/INTERRUPT) стример живой записи (x86_64)
│ ├── protocol.c/.h # форматирование JSON-lines, помощники строкового ввода-вывода
│ ├── server.c/.h # цикл приёма Unix-сокета, по одному потоку на соединение
│ ├── base64.c/.h # бинарно-безопасная передача захваченных байтов write()
│ ├── util.c/.h # логирование, небольшие строковые помощники
│ └── hsed.c # main(): аргументы, демонизация, обработка сигналов
├── systemd/hsed.service
└── Makefile
hidden_space_explorer/ # Python TUI (тонкий клиент, команда: hsed)
├── client.py # HsedClient / StreamSession — общается с сокетом hsedd
├── app.py # Textual UI: таблица, вид дерева, модальное окно деталей, экран стрима
└── __main__.py # точка входа CLI
packaging/
├── build-deb.sh # сборка hsedd, вендоринг зависимостей Python, компоновка .deb
└── hsed_1.0.0_amd64/DEBIAN/ # control, postinst, postrm



