Пошук потенційних вразливостей у коді, частина 1: теорія

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

pic

Як можна запобігти уразливостям?

Як шукати уразливості

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

pic

Які заходи можна вжити для запобігання таким уразливостям? Три варіанти одразу приходять на думку:

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

Але як уникнути уразливостей на етапі розробки без залучення людини? Тут на допомогу приходить статичне тестування безпеки програм (SAST). Воно працює безпосередньо з вихідним кодом і не залежить від людини.

Трішки спойлеру: ми зосередимося на теоретичних аспектах цієї проблеми.
У наступній статті ми поглянемо на деталі конкретного рішення для SAST, PVS-Studio.

Чому важко виявити забруднені дані?

Як працювати з кодом

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

public static void main(String[] args) throws SQLException{  
 var query = "SELECT * FROM foo WHERE bar = '" + args[0] + "'";
var conn = getConn();  
 var st = conn.createStatement(); var rs = st.executeQuery(query);  
 while (rs.next()) {  
 System.out.println(rs.getString("baz"));  
 }  
}

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

Більш конкретно, це парсери, які обробляють вихідний код, перетворюючи потік токенів на абстрактне синтаксичне дерево. У нашій недавній статті моя команда та я вже детально описали, як розробити аналізатор з нуля — ми описали цей процес. Хочу також зауважити, що вам не обов'язково писати парсер з нуля. Ви можете або використовувати генератори парсерів, такі як ANTLR, або готові бібліотеки, включаючи API компіляторів.

Повернемося до нашого коду.
Після парсингу абстрактне синтаксичне дерево (AST) виглядатиме приблизно так (уявлення художника):

pic

Це дещо спрощено, але досить наочно. Якщо у вас є бажання для розваги, ви можете порівняти вихідний код з отриманим AST. Тепер у нас є подання коду, з яким легко працювати програмно, але що далі?

Як розпізнати заражені дані?

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

  • Sinks (поглиначі) — це небезпечні частини програми, куди можуть потрапити заражені дані. Якщо це відбудеться, буде виконана потенційно небезпечна операція.
  • Sources (джерела) — вказують на те, де можуть з'являтися дані.
    До таких джерел відносяться: методи контролера, форми настільних застосунків або введення через консоль.
  • Sanitization (санітизація) — це методи або перевірки, що валідють дані.

Це дійсно просто: ми позначаємо методи бібліотек, як-от java.sql.statement.executeQuery, і призначаємо їх як sinks (поглиначі). Так само ми позначаємо джерела даних.

Навіщо ускладнювати, якщо ми можемо просто використовувати sinks?

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

String parameter;  
switch (args[0]) {  
 case "1":  
 parameter = "qux";  
 break;  
 case "2":  
 parameter = "quux";  
 break;  
 default:  
 throw new IllegalArgumentException("Unexpected argument");  
}
var query = "SELECT * FROM foo WHERE bar = '" + parameter + "'";  
// ....

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

Таким чином, щоб не скаржитись на досить безпечний код, нам потрібно звернути увагу на джерела даних і, відповідно, їх перевірку (санітизацію).

Конкретний механізм реалізації анотацій не має великого значення. Головне тут — це те, що ми повинні бути здатні знаходити sinks (поглиначі) і sources (джерела).
Для ясності ми позначимо джерело помаранчевим кольором, а поглинач — червоним для наведеного вище прикладу:

pic

Чи можемо ми визначити, чи є зв'язок між джерелом і поглиначем, знаючи, що є джерело і що є поглинач? Це легко в нашому випадку, нам просто потрібно переконатися, що args сприяє створенню змінної query.
Але що, якщо ми маємо більш складний приклад? Я швидко вигадав не надто складний код, щоб продемонструвати:

int mode = 0;  
String defaultQ = "false";  
String field = "";  
Statement sql;
public static void main(String[] args) throws SQLException {  
 var st1 = args[0];  
 var statement = args[1];  
 var flag = Boolean.parseBoolean(args[2]);  
 String query;  
 if (flag) {  
 st1 = statement;  
 statement = "SELECT * FROM TBL";  
 query = st1 + statement;  
 } else if (st1.equals("foo")) {  
 if (this.mode == 1) {  
 query = this.defaultQ;  
 } else {  
 query = statement + "LIMIT 1";  
 }  
 } else {  
 query = ";";  
 } query = field + query;  
 sql.executeQuery(query);  
}

А ось його AST з позначеними джерелом і поглиначем (це клікабельне):

pic

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

pic

Так, відстежувати дані від джерела до поглинача все ще здається простим, але як нам відстежувати рух даних, якщо AST не має напрямку? Добре, якщо ви уважно слухали, ви могли помітити подібності: на зображенні операції йдуть згори вниз, а тіла вкладених операцій йдуть праворуч.
Але AST не розрізняє звичайні присвоєння, умовні оператори чи цикли — для нього це все просто синтаксичні конструкції. Тому "підйом" по дереву, безумовно, можливий, але важкий. Нам потрібно стежити за:

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

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

Цілісність потоку виконання

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

Якщо абстрактне синтаксичне дерево (AST) має на меті показати — о, не знепритомнійте зараз — синтаксис мови, то граф потоку виконання (CFG) допомагає відобразити порядок операторів у коді.
Граф потоку виконання (CFG) базується на абстрактному синтаксичному дереві (AST) (пам'ятаєте, я згадував, що можна простежити напрямок виконання коду за допомогою AST?), і як тільки ви побудуєте його один раз, вам буде набагато легше отримувати інформацію з нього.

Дозвольте показати вам граф потоку виконання для того неприємного AST, про який йшлося вище:

pic

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

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

Для вирішення цієї проблеми є два підходи. Хоча достатньо лише одного, ми розглянемо обидва для ясності: форма SSA (статичне одноразове призначення) і граф DU.
Розпочнемо з першого підходу.

Проміжне подання

Тема проміжного подання настільки широка, що ми могли б розширити обговорення до байт-коду (до речі, в .NET проміжну мову називають Common Intermediate Language (CIL)). Тому я окреслю основні проблеми, які ми намагаємося вирішити:

  • відслідковувати переписування значень змінних складно;
  • моніторинг використання змінних у різних гілках може бути складним.

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

Повернемося до справи — точніше, до проблем. Ми можемо вирішити їх за допомогою простої форми проміжного подання, сумісної з мовою, а саме згадуваної статичної одноразової привласнення (SSA).
Правило просте: кожній змінній можна присвоїти значення лише один раз. Тож цей код:

var a = 5;  
a = a + c;  
var b = a;

Перетворюється на такий:

var a1 = 5;  
var a2 = a1 + c;  
var b = a2;

Задача ускладнюється, коли йдеться про умови та цикли. Ми використовуємо підхід з функцією φ (фі) і застосовуємо ці функції для евристичного визначення результату розгалуження.
Код:

int x = 5;  
if (cond) {  
 x = x + 3;  
} else {  
 x = a;  
}  
System.out.println(x);

Перетворюється на такий:

int x1 = 0, x2 = 0;  
int x0 = 5;  
if (cond) {  
 x1 = x0 + 3;  
} else {  
 x2 = a;  
}  
int x3 = phi(x1, x2);  
System.out.println(x3);

Так, нам довелося одразу ініціалізувати змінні, щоб забезпечити сумісність синтаксису.

Повернемося до нашого прикладу і отримаємо наступну форму SSA:

int mode = 0;  
String defaultQ = "false";  
String field = "";  
Statement sql;
public static void main(String[] args) throws SQLException {  
 var st1_0 = args[0];  
 var statement_0 = args[1];  
 var flag = Boolean.parseBoolean(args[2]);  
 String query_1 = null;  
 String query_2 = null;  
 if (flag) {  
 var st1_1 = statement_0;  
 var statement_1 = "SELECT * FROM TBL";  
 query_1 = st1_1 + statement_1;  
 } else {  
 String query_2_0 = null;  
 String query_2_3 = null;  
 if (st1_0.equals("foo")) {  
 String query_2_1 = null;  
 String query_2_2 = null;  
 if (this.mode == 1) {  
 query_2_1 = this.defaultQ;  
 } else {  
 query_2_2 = statement_0 + "LIMIT 1";  
 }  
 query_2_0 = phi(query_2_1, query_2_2);  
 } else {  
 query_2_3 = ";";  
 }  
 query_2 = phi(query_2_0, query_2_3);  
 }  
 var query_3 = phi(query_1, query_2);  
 var query_4 = field + query_3;  
 sql.executeQuery(query_4);  
}

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

Використання ланцюгів

Ми змінили код, але нам не обов’язково аналізувати його безпосередньо. На основі форми SSA, ми можемо побудувати останнє, що нам сьогодні потрібно — ланцюги визначення-використання, або ланцюги визначення та використання (def-use chains). Є також їх протилежний аналог — ланцюги використання-визначення (UD chains), але зараз ми зосередимося на перших.

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

Отже, якщо ви досі не розумієте, що це за ланцюги — або їх назва не дає вам ясності — дозвольте пояснити: ланцюги DU зв'язують ініціалізацію значення змінної та її подальше використання. Таким чином, ми створили нову змінну для кожного нового перезапису значення query. Якщо ми побудуємо ланцюги для всіх цих змінних і якось з’єднаємо їх, ми отримаємо ось таке:

pic

Трохи лякає, правда? Дозвольте пояснити:) Оскільки кожен перезапис використовується лише один раз, кожен ланцюг має два елементи: визначення зліва та використання справа. Їх зв'язок також показаний зліва.
З точки зору програми, читати це легко, оскільки ми можемо перейти в будь-який кінець ланцюга і продовжити через з’єднані ланцюги.

Перевагу обробки через def-use (визначення-використання) легше зрозуміти, коли це показано на графі потоку керування (CFG):

pic

В поясненні ми будемо рухатися знизу вгору:

  • Потенційно небезпечний SQL запит позначено червоним.
  • Елементи ланцюгів, побудованих з query, що можуть містити забруднені дані, позначені оранжевим.
  • Зеленим позначено елементи ланцюгів, за якими немає сенсу шукати забруднені дані. Це очевидно для літералів, але менш зрозуміло для полів об'єктів.
    Справа в тому, що статичний аналіз має труднощі з міжпроцедурним обчисленням потенційного значення поля, тому ми не будемо намагатися це зробити.
  • Синім і фіолетовим позначено вузли з інших ланцюгів змінних (statement і st1), які можуть містити небезпечні дані.
  • Оскільки фіолетовий знаходиться вгорі, дані надходять від параметрів main, що вказує на те, що ми знайшли два шляхи, де можуть текти забруднені дані.
  • Білим позначено те, що не належить до ланцюгів, які нас цікавлять.

Як ви, можливо, помітили трохи раніше, це було б складніше продемонструвати з незв’язаними ланцюгами 🙂 Насправді ми повернемося до ідеї обходу пізніше, але наразі в нас є все необхідне для обходу методу. А що якщо нам потрібно обходити кілька методів?

Граф викликів

Мушу визнати, що ми пропустили одну річ. Якщо ми змінимо самий перший приклад навіть таким тривіальним чином:

public static void main(String[] args) throws SQLException {  
 var foo = findFoo(args[0]);  
 // ....
}  
private static Foo findFoo(String bar) throws SQLException {  
 var query = "SELECT * FROM foo WHERE bar = '" + bar + "'";  
var conn = getConn();  
 var st = conn.prepareStatement(query);  
 var rs = st.executeQuery(query);  

 // ....  

 return foo;  
}

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

pic

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

pic

Його просто побудувати: потрібно просто заздалегідь визначити всі виклики методів у проекті та з’єднати їх ребрами. Графи викликів великих проектів іноді можуть дати цікаві патерни.
Наприклад, ось граф викликів для аналізатора Lua з згаданого раніше статті. Ви можете дослідити його, натиснувши нижче.

pic

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

Як знайти заражені дані

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

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

public static void main(String[] args) throws SQLException {  
 var foo = findFoo(args[0]);  
 // ....  
}

При введенні foo, ми не бачимо нічого неправильного, але ми знаходимо виклик findFoo:

pic

З графа викликів ми знаходимо цей метод.

pic

Тут не було складно, але інший шлях був би складнішим. В будь-якому разі, ось як ми потрапляємо до findFoo:

private static Foo findFoo(String bar) throws SQLException {  
 var query = "SELECT * FROM foo WHERE bar = '" + bar + "'";
var conn = getConn();  
 var st = conn.prepareStatement(query);  
 var rs = st.executeQuery(query);  

 // ....
return foo;  
}

Давайте побудуємо граф керування потоком (CFG) для цього:

pic

Нам не потрібно створювати SSA, оскільки все вже було призначено один раз. О, як нам пощастило 🙂 Єдине, що залишилось зробити, це завершити ланцюг для query і bar. Для зручності я позначив їхнє з'єднання.

pic

Зверху ми маємо ланцюг для bar (з підписом, прихованим за BEGIN2_), а внизу — для query. Коли ми доходимо до виконання SQL-запиту (executeQuery), не зустрічаючи жодної перевірки або використання параметрів запиту, ми розуміємо, що ось він — потенційний шлях для заражених даних.

Немає перевірки на SQL ін'єкцію, але технічно ми могли б сказати, що це щось схоже на перевірку на наявність ‘;’.

Що ж, оскільки виключення не обробляються ніде, воно безпосередньо викидається в консоль — ось у нас і повний набір порушень безпеки (ніколи не робіть так).
Так, приклад з main трохи надуманий, але абсолютно та сама логіка застосовується до методів контролерів та будь-якого іншого джерела.

Чому я сказав на початку розділу, що все зводиться до просто обходу графу, а останній розділ називався “Чому важко виявити заражені дані”? Ну, тому що це був шлях, який нам довелося пройти, щоб потрапити сюди 🙂

Післямова

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

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

Щоб не пропустити інші статті про якість коду, як ця, ви можете підписатися на:

Перекладено з: Looking for potential vulnerabilities in code, part 1: theory

Leave a Reply

Your email address will not be published. Required fields are marked *