Перейти до вмісту

Блог ·

Як підписане оновлення агента себе відхиляє

Контрольна сума доводить, що завантаження прийшло цілим, а не що надіслали його ви. Чого насправді коштує підпис агента, який оновлює себе сам.

Автор: Provibr

Будь-яка програма, що оновлює себе сама, є віддаленим виконанням коду, яке ви запросили самі. Це не аргумент проти автоматичних оновлень: вони є єдиним реалістичним способом уберегти від гниття парк агентів на клієнтському залізі. Це аргумент за точність щодо того, що саме обіцяє механізм оновлення, бо більшість із них обіцяє менше, ніж мається на увазі в їхній документації.

Ми запускаємо агента на чужих серверах. Він розмовляє з нашою платформою, застосовує команди до їхнього гіпервізора і замінює власний виконуваний файл, коли ми випускаємо нову версію. Ось що нам довелося побудувати, перш ніж ми погодилися йому це дозволити.

Контрольна сума відповідає не на те питання

Майже кожен інструмент, що оновлює себе сам, публікує SHA-256 поруч із файлом для завантаження, і багато хто на цьому спиняється. Контрольна сума корисна, і вона не робить тієї роботи, яку їй приписують.

ЗасібЩо він насправді доводитьЧого він не бачить
TLS на завантаженніБайти прийшли з того хоста тим зʼєднаннямНічого про те, хто їх створив
SHA-256, опублікована поруч із файломЗавантаження не обрізане й не пошкодженеХто може змінити файл, може змінити й число
Підпис над байтамиЦі байти створив власник одного приватного ключаЧи належить той ключ і досі вам

Середній рядок є всією проблемою. Контрольна сума й артефакт зазвичай лежать в одному місці й зазвичай доступні на запис одному й тому самому процесу. Якщо зловмисник може замінити одне, він може замінити й друге, і перевірка пройде із запалом.

Отже, контрольна сума є дешевою попередньою перевіркою і явно не є межею довіри. Вона ловить обрізане завантаження за один прохід по байтах, які ми й так передавали потоком. Якорем є підпис.

Де має жити відкритий ключ

Ось перше рішення, від якого залежить, чи все це справжнє: ключ для перевірки не може бути файлом, що їде поруч із виконуваним файлом, бо тоді компрометація шляху доставки компрометує й ключ.

Наш агент отримує відкритий ключ Ed25519 платформи вкомпільованим у себе, зі змінної середовища на етапі збірки. У релізній збірці іншого джерела немає. У зневаджувальній збірці розробник може вказати локальний ключ змінною середовища, і цей шлях цілком вирізається з компіляції атрибутом cfg, бо ключ, який можна перевизначити під час роботи, є не якорем, а порадою.

Дві дрібниці навколо цього важать більше, ніж мають вигляд.

Скрипт збірки оголошує, що має запуститися наново, коли та змінна середовища змінюється. Без цього рядка релізна збірка, яка ділить кеш цілі з попередньою, радо перевикористає ключ із минулої компіляції, і ніхто цього не помітить, доки воно не дійде до клієнта.

А розбирач ключа суворий щодо кодування, а не щодо довжини. Відкритий ключ Ed25519 у формі SPKI є фіксованим 12-байтовим префіксом, за яким ідуть 32 байти; ключ X25519 теж має 32 байти, але з іншим ідентифікатором алгоритму. Приймати будь-що завдовжки 32 байти означало б узяти ключ від іншого алгоритму й впасти пізніше, десь там, де це не має жодного сенсу.

Відмовляти ще до завантаження

Коли платформа наказує агентові оновитися, агент проходить увесь список передумов, перш ніж завантажити хоч один байт:

  1. Чи є у вказівці придатна SHA-256?
  2. Чи є URL для завантаження?
  3. Чи є підпис і чи має він рівно 64 байти?
  4. Чи не працюємо ми вже на цій версії?
  5. Чи існує на цьому хості привілейований скрипт застосування?
  6. Чи існує локальний файл якоря довіри?
  7. Чи має цей виконуваний файл узагалі вкомпільований ключ платформи?

Уваги заслуговує третій крок. Старіша платформа, яка не заповнює поле підпису, має впасти саме тут, голосно, а не тихо відкотитися до слабшої перевірки, яку ми колись робили. Необовʼязкова перевірка не є перевіркою. Зробити відсутнє поле твердою помилкою це різниця між механізмом і звичкою.

Порядок останніх трьох теж свідомий. Повідомлення «на вашому хості бракує привілейованого кроку, запустіть інсталятор ще раз» дає більше змоги діяти, ніж «цей виконуваний файл не має ключа платформи», тож воно йде першим.

Підписуйте байти, а не їхній опис

Підпис покриває артефакт рівно таким, яким він лягає на диск. Чистий Ed25519, без попереднього гешування, без канонічного конверта, без метаданих усередині. Причина не в чистоті. Причина в тому, що дві цілком різні реалізації мають дійти того самого вироку.

На агенті це ed25519-dalek із verify_strict, який відхиляє підписи з торсійними компонентами. З одним ключем і одним файлом має бути рівно один дійсний підпис, а несувора варіація цього не гарантує.

На хості привілейований скрипт, що власне встановлює оновлення, перевіряє ті самі байти через openssl pkeyutl -verify -pubin -rawin. Той самий ключ, ті самі байти, та сама відповідь. Якби ми гешували наперед або підписували маніфест замість артефакта, перевірці на рівні оболонки довелося б перевигадувати наше обрамлення, і дві перевірки почали б розходитися.

Це та сама перевірка, яку ми руками робимо на сервері після кожного релізу, проти відкритого продакшн-ключа, перш ніж повірити, що реліз відбувся.

Маніфест поруч з артефактом несе версію, ідентифікатор збірки, контрольну суму, розмір, час релізу, вид артефакта й підпис. Це метадані для панелі, і жодним із них не довіряють: підпис у ньому стоїть над виконуваним файлом, а не над маніфестом.

Агент не може встановити власне оновлення

Агент працює від непривілейованого користувача. Він не може писати в каталог, де лежить його власний виконуваний файл, і не повинен мати такої змоги.

Тож він викладає файли на проміжний майданчик. Спершу він записує в проміжний каталог підпис і контрольну суму, і лише потім перейменовує на місце поруч із ними перевірений виконуваний файл, бо привілейований скрипт бере наявність саме цього файла за сигнал до старту, а два інші файли мають на той момент уже бути там. Далі агент чисто завершується, і менеджер служб перезапускає його, спершу виконавши привілейований крок перед стартом.

Той скрипт варто описати, бо його припущення протилежні до тих, що спали б вам на думку.

Він завжди завершується нулем. Невдалий крок перед стартом взагалі не дав би службі запуститися, а зламане оновлення, що зводить клієнта офлайн, набагато гірше за зламане оновлення, яке тихо не застосовується.

Проміжному каталогу не довіряють. Усе в ньому записав непривілейований користувач агента: виконуваний файл, контрольну суму, підпис. Хто захопить того користувача, може замінити всі три. Саме тому єдине, що вирішує, це підпис, перевірений ключем із привілейованого боку.

Він копіює, перш ніж перевіряти. Викладені байти копіюються рівно один раз у новий тимчасовий файл, що належить root, поруч із місцем призначення, і кожна подальша перевірка читає саме копію. Файл, який перевіряють, є тим самим inode, який перейменовують на місце, тож немає вікна, у якому між перевіркою і встановленням можна підсунути інший файл.

Решта свідомо нудна: відхиляти символьні посилання, вимагати рівно 64 шістнадцяткові символи контрольної суми, вимагати, щоб файл починався з магічного числа ELF, розширювати права до виконуваних лише після того, як підпис пройшов, запустити його один раз від користувача служби, а не від root, і аж потім перейменувати поверх робочого файла. Підпис каже, звідки прийшли байти. Він не каже, що вони заслуговують на root.

Реліз, який ми підписали не тим ключем

Механізм вище працює. Ми все одно випустили зламаний реліз, і корисною частиною є те, як саме він упав.

Наш скрипт збірки приймав прапорець «це продакшн-реліз». Тоді той прапорець керував лише публікацією, а не тим, яким ключем підписувати. Реліз, зібраний без того, щоб ще й назвати продакшн-ключ на сервері, підписувався ключем розробки, ніс у собі вкомпільований ліцензійний ключ розробки, а потім перевірявся локально проти того самого ключа розробки. Перевірка казала, що з артефактом усе гаразд, бо він був внутрішньо узгоджений. Просто він був не наш.

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

З цього вийшли дві сторожі, обидві в коді, а не в документі. Збірка для продакшну без названого продакшн-ключа тепер є твердою помилкою, що спиняє скрипт. А ключ підписування і ключ перевірки мають бути на одній машині: перевіряти підпис ключем, узятим деінде, не доводить геть нічого.

Загальний урок варто сказати прямо. Крок перевірки, якому дали обидві половини узгодженої пари з неправильного середовища, пройде, і пройде впевнено. Схеми підпису захищають від зловмисників. Від вас вони не захищають.

Ліцензія не є виконуваним файлом, і підписані обидва

Ми підписуємо одним ключем дві різні речі: виконувані файли агента й файли ліцензій. Це небезпека, бо підпис над одним ніколи не має прийматися як підпис над другим.

Розділення є структурним, а не угодою про назви. Файл ліцензії підписує текстовий конверт, складений з ідентифікатора формату, солі, одноразового числа й шифротексту, зʼєднаних переводами рядка. Виконуваний файл підписує сирі байти ELF. Скрипт установлення перевіряє магічне число ELF, перш ніж узагалі щось перевіряти, тож дійсний підпис ліцензії ніколи не вдасться подати як дійсний виконуваний файл.

У ліцензійному потоці є ще одна властивість, яку варто вкрасти: він перевіряє підпис перед виведенням ключа розшифрування. Argon2id з витратою памʼяті 64 MiB дорогий за задумом, і робити цю роботу для файла, який ніколи не був нашим, є безкоштовною відмовою в обслуговуванні. Дешеві перевірки автентичності мають стояти перед дорогою криптографією.

Чого це не купує

Підпис привʼязує артефакт до ключа. Він нічого не каже про те, чи ключ і досі ваш, і нічого про те, чи версія, яку ви підписали, хоч чогось варта. Якщо наш ключ підписування витече, кожен агент прийме те, що збере зловмисник, а ротація ключа означає перезбирання кожного виконуваного файла й заміну якоря довіри на кожному хості, бо ключ вкомпільований. Це ціна того, що ключ є якорем, а не порадою, і це правильний обмін, але це справжня ціна, і їй місце в плані, а не у виносці.

Саме так працює оновлення агента в Provibr, де агент працює на власному залізі клієнта, зʼєднується лише назовні і тримає облікові дані гіпервізора на тому хості.

Почніть з одного хоста й одного агента

Створіть обліковий запис, встановіть агента однією командою та підключіть свій перший хост Proxmox. Пробний період триває 14 днів і не потребує платіжних даних.