7744 blackhole маршрута вместо firewall: fast path при 7 Gbps и дупките от по-специфичните BGP маршрути
Граничният рутер на нашата мрежа (AS47453) държи три пълни BGP таблици — над 3,3 милиона IPv4 маршрута, от които около 1,1 милиона активни — и в пиковите часове през него минават 6–7 Gbps реален трафик при над половин милион пакета в секунда. Машината е CCR2116-12G-4S+ с 16 ядра и 16 GB RAM на RouterOS 7.24.
При този профил рутерът има една-единствена задача: да препраща пакети. И точно затова на него няма connection tracking, няма filter, няма NAT, няма mangle. Тази статия е за това как при такава конфигурация се блокират около 4700 зловредни мрежи с различна мрежова маска, без да се пипа firewall, как три хиляди парчета от тях тихо се оказаха неблокирани заради BGP и колко точно струва всяка операция, измерено в секунди на живата машина.
Обновено на 16.09.2026. Статията описва три версии на един и същ скрипт. Първата, от 9 септември, имаше пропуск: blackhole маршрутът за голяма мрежа не блокира нищо там, където някой анонсира в BGP по-специфичен префикс. Втората го поправи, но всяка сутрин триеше и добавяше наново около 7700 маршрута, за да отрази двайсетина реални промени. Третата пише само разликата. Кодът на първите две версии е съкратен до същественото.
Защо conntrack е изключен и какво губиш с него
Connection tracking е това, което позволява на RouterOS да знае, че даден пакет принадлежи на вече установена сесия. От него зависят NAT, connection-state правилата, mangle таблицата и цялата логика „запомни този източник в address-list“.
На рутер, който прави чист рутинг и транзит, conntrack е чиста загуба — той иска памет и такт за всяка сесия, а никой не го пита. Изключен е и стои изключен. Резултатът се вижда: CPU средно 15% при седем гигабита и 14,7 от 16 GB свободна памет.
Прави се с един ред:
/ip firewall connection tracking set enabled=no
# проверка
/ip firewall connection tracking print
enabled: no
active-ipv4: no
active-ipv6: no
Оттук нататък обаче отпада цял набор инструменти. Mangle таблицата не работи изобщо, матчърите по connection-state са безсмислени, а записването на нарушител в address-list с action=add-src-to-address-list е невъзможно — това действие е валидно само в mangle, а mangle без conntrack го няма.
Практическото следствие е по-широко, отколкото изглежда: на такъв рутер firewall-ът изобщо не е инструмент. Не можеш да запишеш източник на флуд в address-list, но не можеш и да го ограничиш по скорост — всяко правило в raw и всяка simple queue гасят fast path (виж следващата секция). Каквото и да сложиш в тези таблици, плащаш го с производителността на целия рутер, а не само на проблемния трафик.
Fast path — и защо едно-единствено правило го изключва
Fast path е бързият път през ядрото на RouterOS: пакетът се предава от входящия към изходящия интерфейс, без да минава през пълната обработка. Проверява се така:
/ip settings print
allow-fast-path: yes
ipv4-fast-path-active: yes
ipv4-fast-path-packets: 88518666621
ipv4-fast-path-bytes: 90952274398842
Условията да е активен са прости и безкомпромисни: allow-fast-path=yes, изключен conntrack, и нито едно активно правило във filter/raw/mangle. Simple queues също го гасят.
Измерихме точната цена на живата машина — включихме едно-единствено raw правило (drop по address-list) и веднага го върнахме на disabled:
| Показател | Без raw правила | С ЕДНО активно raw правило |
|---|---|---|
| fast path пакети/сек | 511 826 | 0 |
ipv4-fast-path-active |
true | false |
| CPU | 9–10% | 23% |
Това не е „малко по-скъпо“ — това е двоичен превключвател. Едно правило изключва fast path напълно, за целия трафик, а не само за пакетите, които то мачва. Оттам нататък firewall-ът е негоден инструмент на този рутер, независимо колко елегантно е написан.
Задачата: над 4600 мрежи за блокиране
Исканото беше просто по формулировка: рутерът да не си говори с известните зловредни и невалидни мрежи.
Готовият списък за това е firehol_level1. FireHOL е отворен проект, който събира десетки публични репутационни списъци, обединява ги, премахва припокриванията и ги публикува в готов за машинна употреба вид, като ги обновява по няколко пъти на ден. Списъците са степенувани по агресивност и level 1 е най-консервативното ниво — в него влиза само това, с което една нормална мрежа няма никаква работа: bogon-и и невалидна адресация, блокове от Spamhaus DROP и EDROP (мрежи, отписани от собствениците си или изцяло в ръцете на злоупотребяващи) и адреси на известни командни сървъри на ботнети.
Идеята на това ниво е да може да се блокира без страх от фалшиви положителни — затова е и единственото, което си струва да седи директно на граничен рутер. Към септември 2026 в него има около 4670 префикса.
Класическият начин е address-list плюс drop правило в raw. От предната секция вече е ясно защо това е невъзможно тук — ще струва fast path-а. В теста CPU-то скочи от 10 на 23%, а в реални условия и без fast path горната граница е още по-висока — стига до 30%, с пикове, за които и досега нямаме обяснение. И всичко това за трафик, който е малък по обем, но не и маловажен.
Остава другият лост: blackhole маршрути. Маршрут с type=blackhole дропва пакета в самата forwarding таблица. Първо трябваше да се докаже, че той не деактивира fast path — и той не го деактивира. Граничният ни рутер винаги е имал набор системни blackhole — bogon мрежите и тези, които сами рекламираме — заедно с активен fast path, а тестът с още 500 не промени нищо: ipv4-fast-path-active=true през цялото време, CPU се качи временно до 19% само докато траеше добавянето.
Допълнителен ефект: на рутера е включен
rp-filter=loose. Loose uRPF отхвърля пакети от източник, за който няма валиден маршрут — а blackhole маршрут не е валиден път. Тоест блокирането се очаква да работи и в двете посоки, не само на изхода.
Тестовете: колко струва всяка операция при 3,3 милиона маршрута
Преди да се пише скрипт, трябваше да се знае с какви времена се работи. Всички измервания са с вътрешен [:timestamp] на самата машина, върху 500 тестови /32 в документационните диапазони, при жив трафик:
| Операция | Време | На единица |
|---|---|---|
/ip route add × 500 |
1,88 s | 3,76 ms |
| същото, но с коментар на всеки | 2,47 s | 4,94 ms |
/ip route remove на събран списък |
1,97 s | 3,93 ms |
find where blackhole |
4,0 s | — |
find where dst-address="X/32" |
1,09 s | — |
find where distance=10 (само) |
7 min 40 s | — |
find where comment="..." |
7 min 17 s | — |
get comment в цикъл × 522 |
1 min 51 s | 213 ms на четене |
Тук са двата урока, които определиха целия дизайн:
1. Флагът blackhole е евтин филтър, всичко останало не е. Разликата между find where blackhole (4 секунди) и find where comment= (437 секунди) е 113 пъти. При това търсенето по distance само по себе си е също толкова бавно, но blackhole and distance=10 е бързо — RouterOS първо стеснява по флага и после проверява останалото само върху намереното. Правилото е: условието задължително трябва да съдържа blackhole.
2. Никога не маркирай масови маршрути с коментар. Изглежда като най-естественото нещо на света — слагаш comment="firehol" и после ги намираш по него. На тази таблица това е седем минути и половина на всяко търсене, тоест скриптът никога не би завършил в разумно време. Същото важи и за четенето на свойства в цикъл: get по идентификатор също е линеен скан — 213 милисекунди на запис, две минути за петстотин.
Скритата цена: audit log-ът
Най-неприятното откритие не беше свързано с времето. RouterOS записва в системния лог всяко изпълнение на конфигурационна команда. 500 добавени маршрута дадоха 507 реда в лога за 2,5 секунди. При memory-lines=1000 един цикъл add + remove изяде 976 от 1000 реда — цялата история на BGP сесиите и логините просто изчезна.
С 4657 префикса логът се пренаписва около десет пъти при всяко пускане. Тоест ако не се внимава, защитата, която току-що си вдигнал, те ослепява за всичко останало на рутера. Същият урок излезе и по-рано при друг наш scheduler, който викаше enable/disable безусловно на стотици DHCP lease-а всеки час — поправката беше да чете текущото състояние и да пише само при разлика.
Изводът е общ и си струва да се запомни: всяка
set,addилиremoveв автоматичен скрипт е един ред в лога, независимо дали променя нещо. Или увеличавашmemory-lines, или изнасяш лога на syslog сървър, или сравняваш преди да пишеш.
Първите две версии на скрипта не последваха този съвет и платиха за това при всяко пускане. Третата го следва — подробностите са в края на статията.
Рутерът се справя сам — без Linux посредник
Първоначалното намерение беше списъкът да се тегли и обработва на сървър, а на рутера да се пращат готови команди. Очаквах стринговите операции в RouterOS да са неизползваемо бавни при близо пет хиляди реда. Сгреших — измерено на самата машина:
| Стъпка | Време |
|---|---|
| изтегляне на netset-а (72 KiB) | 1,03 s |
| прочитане на файла | 0,015 s |
| парсване на 4690 реда → 4657 префикса | 0,455 s |
Има обаче един капан, който струва време, ако не се знае:
# НЕ работи за голям файл — връща празно БЕЗ грешка
:local buf [/file get fh.txt contents]
# Работи: чете на парчета
:local buf ""
:local off 0
:local part
:do {
:set part [/file read file=fh.txt chunk-size=32768 offset=$off as-value]
:set buf ($buf . ($part->"data"))
:set off ($off + 32768)
} while=([:len ($part->"data")] = 32768)
/file get contents за файл от 74 KB върна дължина 0 — мълчаливо, без съобщение за грешка. За малък файл работи безупречно, което прави засечката още по-коварна. Целият файл се събира на парчета за 15 милисекунди.
Първата версия на скрипта
Логиката е дневен цикъл: изтегли, изчисти своите стари, сложи новите. Съкратен вариант на същественото:
# 1. изтегляне; при неуспех НЕ се трие нищо
:do { /tool fetch url=$url dst-path=bh.txt check-certificate=no } on-error={ :error "stop" }
# 2. парсване ред по ред; изключенията се проверяват в hash, не в цикъл
:if ([:typeof ($keep->$pfx)] != "nothing") do={ :set skipped ($skipped + 1) }
# 3. триене САМО на своите — задължително през флага blackhole
/ip route remove [/ip route find where blackhole and distance=10]
# 4. добавяне на целия списък наново
/ip route add dst-address=$pfx type=blackhole distance=10
Няколко решения в този код не са очевидни:
Маркерът е distance=10, а не коментар. Комбинацията blackhole and distance=10 отделя нашите маршрути от системните за под пет секунди. Стойността обаче трябва да е в диапазона 3–19: BGP на този рутер е с distance 20, а системните blackhole са 1 и 2. Ако сложиш 200, blackhole маршрутът губи от BGP при точно съвпадение на префикса и стои неактивен — тиха, неработеща защита. По-специфичният префикс печели винаги, така че проблемът е само при пълно съвпадение — но именно Spamhaus DROP частта съдържа блокове, които някой анонсира точно така. Същото правило обаче работи и срещу нас, и точно това пропуснахме в първата версия (виж „Дупката“ по-долу).
Списъкът с изключения е на две нива. Едните са точни префикси — системните bogon-и и собствените ни мрежи, които вече ги има и не бива да се дублират. Другите са цели блокове по начало на адреса: мрежите на съседите, клиентите и партньорите ни. Firehol не е чист bogon списък — в него влизат и напълно живи български мрежи, включително на наш пиър. В първата версия реално се изрязваха 15 реда; останалите записи са застраховка за бъдещи версии на списъка. Изключването по начало на адреса обаче се оказа недостатъчно, за това също по-долу.
Отделна routing-table не става за маркер. Пробвано — маршрут извън main просто не участва във forwarding-а и не блокира нищо.
Има и няколко чисто синтактични капана в RouterOS, платени в движение: масив не приема стрингов литерал за ключ ([:toarray "a,b,c"] плюс цикъл, който пълни hash-а); командите на един ред искат ;, не интервал; и в RouterOS 7 няма /system scheduler run — за ръчно пускане се изпълнява самият код.
Капанът с правата на scheduler-а
Скриптът беше тестван парче по парче през SSH като admin и работеше. Първото пускане през самия scheduler обаче даде:
bh-sync: fetch failed
Причината е, че policy=read,write,test не стига за /tool fetch. Fetch записва файл, а файловите операции се контролират от ftp policy. Правилното е policy=ftp,read,write,test.
Това щеше да излезе чак на първото автоматично пускане в 5:30 сутринта, при това като тиха повреда — старите маршрути щяха да стоят, никой нямаше да забележи месеци наред. Хвана се само защото scheduler-ът беше пуснат нарочно с преместен старт. Изпълнението през SSH не доказва, че scheduler-ът ще проработи — правата са различни. Тествай през самия scheduler.
Същият този провал потвърди и че fail-safe-ът работи както трябва: при неуспешно изтегляне блокът on-error спира скрипта преди триенето и маршрутите остават непокътнати. Провалът е fail-closed за данните и fail-open за защитата — точно в тази посока, в която го искаш.
Първата версия в продукция
Scheduler-ът тръгва всяка сутрин в 5:30. Пълният цикъл на първата версия (изтегляне, парсване, триене на старите, добавяне на новите) отнемаше между 27 и 42 секунди според натоварването и скоростта на изтеглянето:
21:40:48 старт 21:41:30 bh-sync: staro=4642 novo=4642 propusnati=15 # 42 секунди общо
Триенето на 4642 маршрута в реален мащаб се раздели така: find where blackhole and distance=10 = 4,67 s, самото remove на събрания списък = 12,42 s (2,68 ms на маршрут, по-ефективно, отколкото на пакети от по 500). Времето на find не зависи от броя намерени: то е един пълен скан на цялата таблица, независимо дали ще върне 20 или 5000 записа.
Fast path остана активен, CPU беше 15–18%, BGP сесиите не мигнаха. Всичко изглеждаше наред. Шест дни по-късно се оказа, че немалка част от тези 4642 маршрута не блокират нищо.
Дупката: по-специфичният BGP маршрут печели
На 15 септември сравнявахме логовете на хостинг сървърите с блоклистите и попаднахме на нещо, което не трябваше да го има. Двете най-активни мрежи, които обхождаха wp-login.php на хостваните WordPress сайтове (стотици адреси за 48 часа), бяха във firehol_level1 и имаха активен blackhole маршрут на рутера. Въпреки това от тях идваха пълни HTTPS сесии.
Рутерът сам показа защо. Адресите в примерите са условни, от резервирания диапазон 198.18.0.0/15:
# blackhole маршрутът за мрежата е в таблицата и е активен /ip route print where blackhole and dst-address=198.18.32.0/19 # ...но пакет към адрес от нея не се дропва :put ([/ip route check 198.18.45.10 once as-value]->"status") ok
Причината е най-основното правило в рутирането — longest prefix match. Когато за даден адрес има няколко маршрута, печели този с най-дългата маска, и то преди да се погледнат distance и типът на маршрута. Нашият blackhole беше за /19. Ъпстриймът обаче анонсираше същото адресно пространство и като 32 отделни /24. За всеки адрес от /19 има по-специфичен /24 през BGP, така че blackhole маршрутът стои активен в таблицата, но не хваща нито един пакет.
| Маршрут | Маска | Distance | Пакет към 198.18.45.10 |
|---|---|---|---|
| 198.18.32.0/19, blackhole | 19 | 10 | губи: по-къса маска |
| 198.18.45.0/24, BGP | 24 | 20 | печели, пакетът отива към ъпстрийма |
В първата версия на тази статия сами бяхме написали, че по-специфичният префикс печели винаги. Тогава имахме предвид, че нашият blackhole ще бие по-общ BGP маршрут. Правилото обаче работи и в обратна посока, а точно нея пропуснахме.
Колко голяма беше дупката
- Случайна извадка от 300 префикса: 9 засенчени, около 3%.
- Проверка на целия списък: 2422 префикса с маска по-къса от /24 правят 69 888 парчета /24. От тях 3074 бяха засенчени, в 266 мрежи. При 166 от тези мрежи засенчването беше пълно, тоест blackhole маршрутът им не блокираше нищо.
- Засенчените мрежи са в 229 автономни системи, предимно хостинг и VPS доставчици.
- От тях идваха 20% от адресите, атакували wp-login на единия хостинг сървър, и 6% на другия.
Сред 34-те мрежи от level1, които реално бяха атакували сървърите ни през последната седмица, засенчени бяха 21. Това число преувеличава проблема, защото блокираните мрежи изобщо не стигат до логовете. Показва обаче, че 3% от списъка не са 3% от проблема: мрежите, раздробени на /24 в BGP, са типични за големите хостинг платформи, а оттам идва голяма част от атаките.
Как се открива засенчването: питай FIB-а
Първият опит беше за всеки blackhole да се прочетат по-специфичните маршрути от таблицата. При 3,3 милиона маршрута това е безнадеждно: /ip route get по идентификатор струва 190 ms на маршрут и първият dry-run продължи 22 минути.
Правилният въпрос не е „какви маршрути има“, а „къде ще отиде пакет към този адрес“. Точно това прави /ip route check. Той пита forwarding таблицата така, както я вижда пакетът, и струва 0,29 ms. Отговор failed значи, че пакетът ще бъде дропнат от blackhole-а. Отговор ok значи, че нещо по-специфично го праща нататък.
| Операция | Време |
|---|---|
/ip route get на един маршрут |
190 ms |
/ip route check на един адрес |
0,29 ms |
| аритметика с IP адрес в скрипт | 0,014 ms |
| проверка на всички 69 788 парчета /24 | около 20 s |
Проверката разбива всяка мрежа по-къса от /24 на парчета и пита за първия адрес на всяко:
# $nip = началният адрес на мрежата, $len = маската ѝ
:if (($len >= 8) and ($len < 24)) do={
:for i from=0 to=((1 << (24 - $len)) - 1) do={
:local b ($nip + ($i * 256))
:local r [/ip route check ($b + 1) once as-value]
:if (($r->"status") != "failed") do={
# това /24 парче НЕ е блокирано
}
}
}
Условието $len >= 8 е предпазител: мрежа /7 би означавала 131 072 проверки само за нея.
Поправката: /24 blackhole с distance=11
Щом по-специфичният маршрут печели, отговорът е blackhole със същата маска. За всяко засенчено парче скриптът добавя /24 blackhole. При равна маска вече решава distance и нашите 11 бият BGP-то с 20.
Защо 11, а не пак 10. Така раздробените маршрути се отличават от основните със същия евтин филтър, blackhole and distance=11, и могат да се махнат отделно, без да се пипа останалото. Двете стойности са в безопасния диапазон 3–19.
Защо /24. /24 е общоприетата граница за филтриране в глобалната BGP таблица. Мрежите по-дълги от нея повечето оператори изобщо не ги приемат, така че на практика няма анонс, който да засенчи /24 blackhole.
Другата страна: засенчването пазеше и легитимни мрежи
Преди да запушим дупките, проверихме какво точно ще запушим. Сред засенчените парчета се оказаха и мрежи, които в никакъв случай не искаме да режем:
- Cloudflare: 4 парчета /24 от два техни /23, които са в level1. Клиентите не губеха достъп до сайтове зад Cloudflare само защото Cloudflare анонсира тези мрежи като /24. Това не беше защита, а късмет, и поправката щеше да го развали.
- Amazon: 8 парчета /24 в блок, който днес се анонсира от Amazon, и два блока, собственост на Amazon, които първата версия режеше още от първия ден, защото там маските съвпадаха.
- Microsoft: едно /24, анонсирано от Microsoft.
Всички те идват в level1 от Spamhaus DROP. Причините за вписването им не сме проверявали. Решихме Cloudflare, Amazon и Microsoft да се изключат, защото прекъснат достъп на клиент до облачна услуга струва много повече от ползата. Един-единствен адрес на виртуална машина в Amazon, влязъл в списъка от друг източник, нарочно остана блокиран.
Изключенията трябва да са истински CIDR
Първата версия изключваше мрежи по начало на текста: "198.51." хваща всичко, което започва така. За собствените мрежи и тези на съседите това стига, но за Cloudflare (15 диапазона с маски от /13 до /22) не става. Затова v2 има трети списък, keepcidr. Всеки запис в него се превръща в истински ip-prefix и се проверява с оператора in:
:local kc [:toarray ""]
:local kl [:toarray ""]
:foreach c in=$keepcidr do={
# текст -> тип ip-prefix
:set kc ($kc, [[:parse (":return " . $c)]])
# и маската отделно
:set kl ($kl, [:tonum [:pick $c ([:find $c "/"] + 1) [:len $c]]])
}
Най-простият начин, който намерихме, да превърнем текст в ip-prefix, е да го компилираме като израз с :parse и да го изпълним.
Тук dry-run-ът хвана и бъг в самия скрипт. Първият вариант пропускаше мрежа от списъка, ако началният ѝ адрес попада в изключение. Изключението за Microsoft обаче е едно /24 вътре в /23 от списъка и така отпадаше цялото /23, заедно с половината, която не е тяхна. Правилното условие е мрежата да се пропуска само ако е изцяло вътре в изключението, тоест маската ѝ да е поне толкова дълга:
:if (($nip in ($kc->$j)) and ($plen >= ($kl->$j))) do={
:set ok false
}
Ако мрежата е по-голяма от изключението, тя влиза в списъка, а при раздробяването парчетата ѝ, които попадат в изключение, се прескачат.
Скриптът, версия 2
Съкратено, само разликите спрямо първата версия. Изтеглянето, четенето на парчета и парсването са същите:
:local dryrun false
:local maxsplit 10000
# изключения на три нива
:local keeplist [:toarray "0.0.0.0/8,10.0.0.0/8,127.0.0.0/8,..."] # точни префикси
:local keepnet [:toarray "198.51.,203.0.113."] # начало на адрес
:local keepcidr [:toarray "192.0.2.0/24,198.51.100.0/24"] # истински CIDR
# триене на всичко старо и добавяне на целия нов списък
/ip route remove [/ip route find where blackhole and (distance=10 or distance=11)]
:foreach p,v in=$new do={ /ip route add dst-address=$p blackhole distance=10 }
# втори проход: кои /24 парчета не са блокирани
:foreach p,v in=$new do={
# ... разбиване на /24, route check (виж по-горе) ...
:if (($r->"status") != "failed") do={
/ip route add dst-address=([:tostr $b] . "/24") blackhole distance=11
}
}
Няколко неща в този код не са очевидни:
Вторият проход е след добавянето, не преди него. Той проверява дали нашите нови blackhole маршрути реално действат, затова те трябва вече да са в таблицата.
dryrun не е за украса. С true скриптът тегли, парсва и прави всички проверки, но не пипа маршрути, само отпечатва резултата. Точно така се хвана бъгът с изключението на Microsoft.
maxsplit е таван. Ако някоя бъдеща версия на списъка съдържа нещо неочаквано, скриптът спира на 10 000 раздробени маршрута и пише предупреждение в лога, вместо да налее стотици хиляди.
Кирилицата в :log и :put се изрязва, затова съобщенията са на латиница.
Внедряване без изненади
Скриптът живее изцяло в on-event на scheduler-а, без отделен /system script. Смяната на 150 реда код в работещ scheduler на граничния рутер има свои капани. Три от тях ни бяха изяли време още при първата версия:
| Начин | Какво става |
|---|---|
on-event={...} през SSH |
CLI-то чете ред по ред, не приема многоредов код и се опитва да изпълни парчета от скрипта като отделни команди. |
on-event="..." |
Приема \n, но RouterOS замества всяка $променлива в двойни кавички с празно. Кодът се записва синтактично валиден и функционално мъртъв, без никаква грешка. |
| изпълнение през SSH | Минава с правата на admin, не с тези на scheduler-а. Това е капанът с ftp policy от по-горе. |
Работещото е вариантът с кавички, с екраниране в точно този ред: първо обратните наклонени черти, после кавичките, после $, накрая новите редове:
perl -pe 's/\\/\\\\/g; s/"/\\"/g; s/\$/\\\$/g; s/\n/\\n/g' bh-sync-v2.rsc
Самата смяна мина така:
/export compactи копие на локалната машина.- Новият код отива във временен, изключен scheduler. Чете се обратно и се сравнява с
diffсрещу изходния файл. Така при първата версия хванахме изядените променливи, преди да стигнат до истинския scheduler. - Dry-run на записания текст, не на локалния файл. В RouterOS 7 няма
/system scheduler run, но кодът може да се вземе от scheduler-а и да се изпълни байт по байт::local code [:parse [/system scheduler get [find name=bh-sync-v2-test] on-event]] $code
Пълният dry-run отне 34 секунди.
- Същият код отива в истинския scheduler, пак
diff, временният се трие. - Първо пускане по същия начин. След него идва пълният тест през самия scheduler: временен еднократен scheduler (
interval=0) със същата policy и копие на кода. Така се проверяват и правата заfetch, и поведението при автоматично пускане, без да се чака 5:30.
Резултатът от втората версия
Пълният цикъл през scheduler-а:
23:01:44 старт 23:03:42 bh-sync: staro=7744 novo=4667 propusnati=18 proverki=69788 razdrobeni=3077 # 118 секунди общо
Проверката с /ip route check преди и след:
| Адрес от | v1 | v2 |
|---|---|---|
| двете мрежи, атакували wp-login | ok (минава) | failed (дропнат) |
| Cloudflare, засенчените парчета | ok | ok |
| Microsoft | ok | ok |
| Amazon, засенчените парчета | ok | ok |
| Amazon, блоковете с еднаква маска | failed (грешно блокиран) | ok |
През scheduler-а цикълът е почти двойно по-бавен, отколкото през SSH. Scheduler-ът записва в лога всяко добавяне и махане на маршрут („route … removed by scheduler:blackhole-sync“). Това са около 15 000 реда на цикъл при памет за 1000 реда. Предполагаме, че това е и причината за разликата.
Малък капан при наблюдение:
/log print where message~"bh-sync"хваща и тези 15 000 реда, защото в тях е името на scheduler-а. Търси се"bh-sync: ", с двоеточие и интервал.
За ефекта върху самите атаки е рано. През първите 15 минути след внедряването в логовете на уеб сървърите нямаше нито една заявка от двете мрежи, при три за половин час преди това. Трафикът от тях обаче идва на изблици, така че това е обнадеждаващо, но още не е доказателство. Истинската проверка е едно цяло денонощие логове.
Третата версия: пиши само разликата
Втората версия работеше, но всяка сутрин плащаше висока цена за почти нищо. Логовете от два поредни дни:
| 15.09 | 16.09 | Разлика | |
|---|---|---|---|
| основен списък (distance=10) | 4667 | 4666 | 1 |
| раздробени /24 (distance=11) | 3077 | 3059 | 18 |
За да отрази 19 реални промени, скриптът триеше и добавяше наново около 7700 маршрута — близо 15 000 конфигурационни операции. Оттам идваха и трите проблема от предната секция: логът се пренаписваше, цикълът траеше 118 секунди, а защитата стоеше свалена около минута и половина.
Решението беше записано още в секцията за audit log-а: сравни, преди да пишеш. Спъваше го само едно — четенето на съществуващите маршрути изглеждаше невъзможно.
Четенето, което изглеждаше невъзможно
При търсенето на засенчени парчета /ip route get по идентификатор струваше 190 ms на маршрут. За 4666 маршрута това са около 15 минути. Изводът тогава беше, че таблицата не може да се чете от скрипт.
Изводът беше грешен — вярно беше само за get. Същите данни се четат с един скан:
:local have10 [:toarray ""]
:foreach r in=[/ip route print as-value proplist=dst-address where blackhole distance=10] do={
:set ($have10->[:tostr ($r->"dst-address")]) ($r->".id")
}
4666 маршрута за 5,3 секунди. Резултатът е асоциативен масив с ключ префикса и стойност идентификатора — точно каквото трябва и за сравнението, и за remove. Полето .id идва само, дори когато не е в proplist. Ако се сложи там изрично, RouterOS връща грешка.
Това е третият път в тази статия, когато разликата между работещ и невъзможен скрипт е избор на команда.
getправи 4666 заявки,print as-value— една.
Сирачетата без коментар
С основния списък сравнението е просто: префикс, който го има в новия списък, но не и на рутера, се добавя, а обратното се маха. С раздробените /24 е по-сложно. Когато голяма мрежа отпадне от списъка, нейните /24 парчета трябва да паднат с нея, а на рутера няма запис кое парче на кой родител принадлежи.
Очевидното решение е родителят да се пише в comment. То обаче изисква еднократно преизграждане на всичките 3000 парчета. Има по-евтин начин: една /24 може да има само 16 възможни родителя — от /23 до /8. Мрежата на всеки се пресмята с маска и се търси в новия списък:
:local masks [:toarray "255.255.254.0,255.255.252.0,...,255.0.0.0"] # /23 .. /8
:foreach p,id in=$have11 do={
:local ip [:toip [:pick $p 0 [:find $p "/"]]]
:local ziv false
:local j 0
:foreach m in=$masks do={
:if (($new->([:tostr ($ip & [:toip $m])] . "/" . (23 - $j))) = 1) do={
:set ziv true
}
:set j ($j + 1)
}
:if (!$ziv) do={ /ip route remove $id } # родителят е отпаднал
}
3060 парчета по 16 търсения в хеш отнемат няколко секунди. Операторът & работи директно върху IP адреси. Логиката е проверена в двете посоки: четирите /24 парчета на една /22 от списъка намират родителя си, а измислена /24 извън списъка излиза сираче.
Първо добавяне, после триене
Редът на операциите вече е обратен: първо се добавят новите маршрути, после се махат отпадналите. Няма момент, в който мрежа от списъка да не е блокирана.
Предпазителят
Първите две версии пазеха само от неуспешно изтегляне. Ако fetch върнеше отрязан файл, скриптът щеше да изтрие цялата защита и да добави каквото е успял да прочете. Третата версия отказва да пипа каквото и да е при подозрително къс списък:
:if ([:len $new] < 3000) do={
:log warning ("bh-sync: lista samo " . [:len $new] . " prefiksa - NISHTO ne e promeneno")
:error "stop"
}
Нормалният списък е около 4700 префикса, така че праг от 3000 оставя място за естествените колебания.
Скриптът, версия 3
Изтеглянето, парсването, изключенията и вторият проход са дословно от втората версия. Сменена е частта около тях:
:local dryrun false
:local minlist 3000
# ... fetch, парсване, изключения (както във версия 2) -> $new ...
# предпазител: къс списък = счупено изтегляне
:if ([:len $new] < $minlist) do={ :log warning "..." ; :error "stop" }
# 1. какво вече има на рутера: $have10 и $have11 с print as-value (виж по-горе)
# 2. ПЪРВО добавяме новите
:foreach p,v in=$new do={
:if ([:typeof ($have10->$p)] = "nothing") do={
:if (!$dryrun) do={ /ip route add dst-address=$p blackhole distance=10 }
}
}
# 3. после махаме отпадналите
:foreach p,id in=$have10 do={
:if (($new->$p) != 1) do={
:if (!$dryrun) do={ /ip route remove $id }
}
}
# 4. втори проход, както във версия 2
# 5. сирачета: /24 без жив родител в новия списък (виж по-горе)
:log info ("bh-sync v3: d10 +" . $a10 . " -" . $r10 . " | d11 +" . $a11 . " -" . $r11 . " ...")
В точка 4 има нещо неочевидно: вторият проход изобщо не сравнява с have11. Не е нужно. Парче, което вече е блокирано с distance=11, връща failed при route check и просто не се добавя втори път. FIB-ът сам казва какво липсва.
Внедряване на третата версия
Мина по същия път като втората: export, ръчно пускане, запис в scheduler-а, diff, пълен тест през временен scheduler. И пак хвана двата описани по-горе капана. Първият запис в on-event беше без екраниране на $ — командата мина без грешка, а в scheduler-а остана код без нито една променлива. Хвана го само diff-ът. После тестовият scheduler беше създаден без ftp policy, изтеглянето гръмна и скриптът спря, без да пипне нито един маршрут.
Имаше и нещо ново: изпълнението през SSH не записва в лога нито една промяна по маршрутите, записва само изпълнението през scheduler. Колко лог произвежда скриптът, се мери само през scheduler-а.
Резултатът
Ръчното пускане свърши реалната работа: шест мрежи влязоха в списъка, шест излязоха и едно ново парче се оказа засенчено от BGP. Тестът през scheduler-а десет минути по-късно нямаше какво да промени:
09:57:01 bh-sync v3: d10 +6 -6 (4666) | d11 +1 -0 | proverki=69746 # на ръка, 44,0 s 10:08:28 bh-sync v3: d10 +0 -0 (4666) | d11 +0 -0 | proverki=69746 # scheduler, 43,7 s
| Показател | Версия 2 | Версия 3 |
|---|---|---|
| операции по таблицата на цикъл | ~15 000 | колкото са промените (13 при първото пускане) |
| редове в лога от маршрути | ~15 000 | 0 при теста през scheduler |
| цикъл през scheduler | 118 s | 44 s |
| време без защита | ~1,5 мин | няма |
| при отрязан списък | трие защитата | не пипа нищо |
ipv4-fast-path-active |
true | true |
Цикълът през scheduler-а вече е колкото през SSH. Това потвърждава предположението от втората версия: двойното забавяне идваше от писането в лога, не от самия scheduler.
Нещо, което още не е видяно: тестът през scheduler-а нямаше какво да промени, тоест добавянето и махането през scheduler-а ще се видят чак при първото истинско пускане на следващата сутрин. Поотделно е проверено всичко — добавянето и махането на ръка, сирачетата в двете посоки и празният цикъл през scheduler-а.
Връщането назад е два реда. Системните blackhole не се засягат:
/ip route remove [find where blackhole and (distance=10 or distance=11)] /system scheduler disable blackhole-sync
Какво остава
Ефектът върху самите атаки. Цялото денонощие логове от уеб сървърите, за което става дума в резултата от втората версия, още не е прегледано.
Първото истинско пускане през scheduler-а с реални промени — следващата сутрин в 05:30.
Какво си струва да се вземе от този случай
- Fast path е двоичен. Едно активно правило в raw го изключва за целия трафик. Ако рутерът е чисто транзитен, firewall-ът просто не е инструмент на тази машина.
- Blackhole маршрутите са единственият лост, който не го гаси. Проверено с 500, после с 4642 и накрая със 7744 записа при жив трафик.
- Активен blackhole в таблицата не значи блокиран трафик. Longest prefix match се прилага преди distance и един по-специфичен BGP анонс тихо го обезсмисля.
- Питай FIB-а, не конфигурацията.
/ip route checkказва за 0,29 ms какво реално ще стане с пакета. - Когато запушваш дупка, провери какво е минавало през нея. Тук през нея минаваха и Cloudflare, и Amazon.
- Изключенията трябва да са истински мрежи с маска, не начало на текст.
- Измервай, преди да проектираш. Разликата между работещ и невъзможен скрипт тук три пъти беше избор на команда: 4 секунди срещу 7 минути при търсенето, 0,29 ms срещу 190 ms при проверката и 5 секунди срещу 15 минути при четенето на таблицата.
- Audit log-ът е реален ресурс. Масовите операции го изяждат и те ослепяват точно когато трябва да виждаш какво става.
- Тествай автоматиката през самата автоматика. Правата на scheduler-а не са правата на SSH сесията, а и логовете му не са.
- Сравни, преди да пишеш. Diff-ът свали операциите от 15 000 на броя на реалните промени и махна прозореца без защита.
- Проверявай записания код с diff. RouterOS изяжда
$от низа без никаква грешка — дори когато капанът ти е известен.