- remove Java plugins (velocity/paper), dashboard, all MC-specific code
(handshake, death_code, varint, hostname-HMAC); available in history pre-v0.2
- merge crates/* into one package with src/bin/{rampart,rampart-manager,rampart-cli}
- ProtocolHandler trait + registry (no implementations yet), universal PoW kept
- XDP: universal L3/L4 filter (xdp/core/) + pluggable hook API (xdp/hooks/),
fix IPv6 saddr bug; clang build verified
- docs: bilingual knowledge base (docs/kb/: attacks x4, defense-levels,
practice x3), rewrite README/architecture for universal concept
- TODO.md v4.0: <=300-line module limit, competitor benchmark section (ref/)
- deploy/CI/docs cleanup: no MC references, new binary names
cargo build/clippy(-D warnings)/test green (55 tests)
15 KiB
Slowloris and Slow Attacks: Exhaustion Without Bandwidth
Knowledge Base · Rampart attack fundamentals · Related: defense-levels.md, http-flood.md
English
1. The opposite of a flood
A volumetric attack shouts; a slow attack whispers. Slowloris (named after the original 2009 tool) does not try to overwhelm bandwidth or packet rate — it tries to occupy every available connection slot with connections that are technically alive but never finish.
The mechanism exploits how HTTP servers parse requests:
- Attacker opens a TCP connection and sends
GET / HTTP/1.1. - Then it sends headers one at a time, dribbling out partial headers like
X-a: b\r\nevery few seconds. - The server cannot reject the request yet: the header block is incomplete, and by protocol it must keep waiting for
\r\n\r\nthat terminates the header section. - Just before the server's read timeout fires, the attacker sends another partial header — resetting the timer.
- Repeat forever.
Each connection consumes a worker/thread/event-loop slot and a socket buffer while transferring essentially zero bytes. When all workers are busy waiting for "requests in progress", legitimate clients queue behind them and time out.
sequenceDiagram
participant A as Attacker
participant W as Server worker pool
participant L as Legitimate client
A->>W: TCP connect + "GET / HTTP/1.1"
loop every few seconds, forever
A->>W: "X-a: b" (partial header)
Note over W: read timer resets,<br/>worker stays occupied<br/>FILTERING POINT: timeout must be<br/>shorter than the dribble interval
end
Note over W: pool exhausted:<br/>all workers wait on dead requests
L->>W: (legitimate request)
W--)L: queued → connection timeout
Hundreds of such connections from a single IP can take down a default-configured web server. The attack traffic is measured in bytes per minute — it slips under any volumetric threshold.
2. How slow attacks differ from volumetric
| Property | Volumetric (SYN/UDP flood) | Slow (Slowloris family) |
|---|---|---|
| Resource attacked | Bandwidth, backlog, pps budget | Worker pool, sockets, memory per connection |
| Traffic volume | Huge | Tiny |
| Visible at L3/L4? | Yes — flag anomalies, rate spikes | No — packets look perfectly normal TCP |
| Source count needed | Often many IPs | Often one IP is enough |
| Right filter layer | Kernel (XDP) | Userspace, after parsing begins |
This last row matters most: no amount of kernel-level filtering can distinguish a Slowloris connection from a slow human client, because at the byte level they look identical. The decision requires understanding application state ("this request has been incomplete for 10 seconds"), which only exists above the kernel.
Variants of the same idea:
- Slow Read — complete request, but read the response 1 byte at a time, keeping the socket open.
- Slow POST / R-U-Dead-Yet — declare
Content-Length: 1000000, then send body bytes at a trickle. - Slow connection holding — open and just never send anything (idle hold).
3. Defense
3.1 Header/body read timeouts — the primary weapon
The server must enforce absolute deadlines independent of incoming bytes:
- Header deadline: total time to receive the full header block (e.g. 10 s). Not an idle timeout that the dribble resets — a hard cap from connection start to end-of-headers.
- Body deadline: total time to receive declared
Content-Lengthbytes, and a sanity cap on the length itself. - Idle eviction: a connection with no bytes for N seconds is closed regardless of state.
In Rampart's userspace engine this is exactly tokio::time::timeout around the handshake/read phase — if a full request isn't parsed within the window, the connection is dropped and the source gets a reputation penalty.
3.2 Per-IP connection limits
A single IP rarely needs more than a handful of concurrent connections. Enforce:
- max concurrent connections per IP,
- max new connections per second per IP (token bucket),
- global cap on half-parsed requests as a fraction of the pool.
With per-IP caps, one machine running Slowloris can occupy its own quota and nothing else; a distributed variant is then throttled by rate limits plus ASN reputation weighting (datacenter sources get smaller quotas than residential).
3.3 Reverse-proxy buffering — shrink the blast radius
Putting a buffering reverse proxy (or Rampart edge itself) in front of the application changes the game:
- The proxy reads the complete request before forwarding anything upstream.
- The application's workers only ever see fully-formed requests; slow clients stall proxy buffers, not app threads.
- Proxy buffers are cheap, sized for thousands of stalled connections, and paired with aggressive timeouts from §3.1.
Why the userspace filter is the main defense here
flowchart TD
C[Connection established] --> X{XDP layer}
X -- "sees only valid TCP.<br/>Cannot tell slow attack<br/>from slow human" --> P[Bytes reach userspace]
P --> T{Userspace engine:<br/>header read timeout,<br/>conn-per-IP limit,<br/>request state tracking}
T -- "incomplete past deadline" --> D[Drop + reputation penalty]
T -- complete in time --> APP[Application logic]
style D fill:#f5d0d0
Kernel layers (XDP, nftables) operate on packets and flags; a slow attack produces perfectly valid packets. The discriminating information — "how long has this request been incomplete, how many such requests does this IP hold" — lives in application-level session state. Hence: cheap kernel layers still handle the volumetric shield, but the userspace engine is where slow attacks actually get decided, which is why Rampart places its timeout/reputation logic there rather than trying to push everything into eBPF.
Русский
1. Полная противоположность фладу
Объёмная атака кричит; медленная атака шепчет. Slowloris (назван по одноимённому инструменту 2009 года) не пытается задавить полосу или pps — он пытается занять все слоты соединений соединениями, которые формально живы, но никогда не завершаются.
Механизм эксплуатирует то, как HTTP-серверы парсят запросы:
- Атакующий открывает TCP-соединение и шлёт
GET / HTTP/1.1. - Затем отправляет заголовки по одному, выпуская частичные заголовки вроде
X-a: b\r\nраз в несколько секунд. - Сервер пока не может отклонить запрос: блок заголовков неполон, и по протоколу он обязан ждать завершающие
\r\n\r\n. - За мгновение до срабатывания read-таймаута атакующий шлёт ещё один частичный заголовок — таймер сбрасывается.
- И так бесконечно.
Каждое соединение занимает слот воркера/потока/события цикла и сокетный буфер, передавая фактически ноль байт. Когда все воркеры ждут «запросы в процессе», легитимные клиенты встают в очередь и отваливаются по таймауту.
sequenceDiagram
participant A as Атакующий
participant W as Пул воркеров сервера
participant L as Легитимный клиент
A->>W: TCP connect + "GET / HTTP/1.1"
loop каждые несколько секунд, вечно
A->>W: "X-a: b" (частичный заголовок)
Note over W: read-таймер сброшен,<br/>воркер занят<br/>ТОЧКА ФИЛЬТРАЦИИ: таймаут должен быть короче<br/>интервала «капельницы»
end
Note over W: пул исчерпан:<br/>все воркеры ждут мёртвые запросы
L->>W: (легитимный запрос)
W--)L: в очереди → connection timeout
Несколько сотен таких соединений с одного IP кладут веб-сервер с дефолтной конфигурацией. Трафик атаки измеряется в байтах в минуту — под любой объёмный порог не попадает.
2. Чем медленные атаки отличаются от объёмных
| Свойство | Объёмные (SYN/UDP flood) | Медленные (семейство Slowloris) |
|---|---|---|
| Атакуемый ресурс | Полоса, backlog, бюджет pps | Пул воркеров, сокеты, память на соединение |
| Объём трафика | Огромный | Крошечный |
| Видно на L3/L4? | Да — аномалии флагов, всплески rate | Нет — пакеты выглядят абсолютно нормальным TCP |
| Сколько нужно источников | Часто много IP | Часто хватает одного |
| Правильный слой фильтрации | Ядро (XDP) | Userspace, после начала парсинга |
Последняя строка важнее всего: никакая фильтрация уровня ядра не отличит Slowloris-соединение от медленного человеческого клиента — на уровне байтов они идентичны. Решение требует понимания прикладного состояния («этот запрос неполон уже 10 секунд»), которого у ядра просто нет.
Варианты той же идеи:
- Slow Read — запрос полный, но ответ читается по 1 байту, сокет держится открытым.
- Slow POST / R-U-Dead-Yet — объявляется
Content-Length: 1000000, затем тело капает по байтам. - Удержание соединения — открыть и вообще ничего не слать (idle-hold).
3. Защита
3.1 Таймауты на чтение заголовков/тела — главное оружие
Сервер обязан держать абсолютные дедлайны независимо от входящих байтов:
- Дедлайн заголовков: общее время на получение полного блока заголовков (например, 10 с). Не idle-таймаут, который «капельница» сбрасывает, а жёсткий лимит от начала соединения до конца заголовков.
- Дедлайн тела: общее время на получение заявленных
Content-Lengthбайт плюс санитарный потолок на саму длину. - Evict по простою: соединение без единого байта N секунд закрывается независимо от состояния.
В userspace-движке Rampart это ровно tokio::time::timeout вокруг фазы хендшейка/чтения — если полный запрос не распарсен в окно, соединение дропается, источник получает штраф репутации.
3.2 Лимиты соединений per IP
Одному IP редко нужно больше горстки одновременных соединений. Вводим:
- максимум одновременных соединений на IP,
- максимум новых соединений в секунду на IP (token bucket),
- глобальный потолок на долю недопарсенных запросов в пуле.
При per-IP квотах одна машина со Slowloris занимает только свою квоту и больше ничего; распределённый вариант затем душится rate limit'ами и весами ASN-репутации (датацентровые источники получают меньшие квоты, чем residential).
3.3 Буферизация reverse-proxy — сузить радиус поражения
Буферизирующий reverse-proxy (или сам edge Rampart'а) перед приложением меняет правила игры:
- Прокси читает полный запрос до пересылки чего-либо наверх.
- Воркеры приложения видят только полностью сформированные запросы; медленные клиенты упираются в буферы прокси, а не в потоки приложения.
- Буферы прокси дёшевы, рассчитаны на тысячи зависших соединений и работают в паре с агрессивными таймаутами из §3.1.
Почему userspace-фильтр здесь главный
flowchart TD
C[Соединение установлено] --> X{Слой XDP}
X -- "видит только валидный TCP.<br/>Не отличит медленную атаку<br/>от медленного человека" --> P[Байты доходят до userspace]
P --> T{Userspace-движок:<br/>header read timeout,<br/>лимит conn-per-IP,<br/>отслеживание состояния запроса}
T -- "неполон за дедлайном" --> D[Дроп + штраф репутации]
T -- "успел целиком" --> APP[Прикладная логика]
style D fill:#f5d0d0
Ядерные слои (XDP, nftables) оперируют пакетами и флагами; медленная атака производит идеально валидные пакеты. Различающая информация — «как долго этот запрос остаётся незавершённым и сколько их у этого IP» — живёт в прикладном состоянии сессии. Поэтому: дешёвые ядерные слои продолжают держать объёмный щит, но медленные атаки реально решаются в userspace-движке — именно поэтому Rampart размещает там логику таймаутов и репутации, а не пытается затолкать всё в eBPF.