Ін’єкція SQL та як її запобігти?

Стійкість зверху

Я щойно оголосив про новий курс Learn Spring , орієнтований на основи Spring 5 та Spring Boot 2:

>> ПЕРЕВІРИТИ КУРС

1. Вступ

Незважаючи на те, що одна з найвідоміших вразливостей, SQL Injection продовжує посідати перше місце у сумнозвісному списку 10 найкращих OWASP - тепер це частина більш загального класу Injection .

У цьому підручнику ми дослідимо поширені помилки кодування в Java, які призводять до уразливого додатка, та способи їх уникнення за допомогою API, доступних у стандартній бібліотеці виконання JVM. Ми також розглянемо, який захист ми можемо отримати від ORM, таких як JPA, Hibernate та інші, і про які сліпі плями нам все одно доведеться турбуватися.

2. Як додатки стають вразливими до введення SQL?

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

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

public List unsafeFindAccountsByCustomerId(String customerId) throws SQLException { // UNSAFE !!! DON'T DO THIS !!! String sql = "select " + "customer_id,acc_number,branch_id,balance " + "from Accounts where customer_id = '" + customerId + "'"; Connection c = dataSource.getConnection(); ResultSet rs = c.createStatement().executeQuery(sql); // ... }

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

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

curl -X GET \ '//localhost:8080/accounts?customerId=abc%27%20or%20%271%27=%271' \

Якщо припустити, що значення параметра customerId не перевіряється, поки воно не досягне нашої функції, ось що ми отримаємо:

abc' or '1' = '1

Коли ми приєднуємо це значення до фіксованої частини, ми отримуємо остаточний оператор SQL, який буде виконано:

select customer_id, acc_number,branch_id, balance from Accounts where customerId = 'abc' or '1' = '1'

Можливо, не те, що ми хотіли ...

Розумний розробник (чи не всі ми?) Тепер міркував би: «Це безглуздо! Я ніколи не використовував би конкатенацію рядків для побудови такого запиту ”.

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

  • Складні запити з динамічними критеріями пошуку: додавання пропозицій UNION залежно від критеріїв, наданих користувачем
  • Динамічне групування або впорядкування: API REST, що використовуються як серверне середовище для таблиці даних графічного інтерфейсу

2.1. Я використовую JPA. Я в безпеці, так?

Це поширена помилкова думка . JPA та інші ORM звільняють нас від створення операторів SQL, кодованих вручну, але вони не заважають нам писати вразливий код .

Давайте подивимося, як виглядає версія JPA попереднього прикладу:

public List unsafeJpaFindAccountsByCustomerId(String customerId) { String jql = "from Account where customerId = '" + customerId + "'"; TypedQuery q = em.createQuery(jql, Account.class); return q.getResultList() .stream() .map(this::toAccountDTO) .collect(Collectors.toList()); } 

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

3. Методи профілактики

Тепер, коли ми знаємо, що таке ін’єкція SQL, давайте подивимось, як ми можемо захистити наш код від такого роду атак. Тут ми зосередимося на декількох дуже ефективних техніках, доступних у Java та інших мовах JVM, але подібні концепції доступні і в інших середовищах, таких як PHP, .Net, Ruby тощо.

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

3.1. Параметризовані запити

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

Давайте перепишемо нашу прикладну функцію, щоб використовувати цю техніку:

public List safeFindAccountsByCustomerId(String customerId) throws Exception { String sql = "select " + "customer_id, acc_number, branch_id, balance from Accounts" + "where customer_id = ?"; Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1, customerId); ResultSet rs = p.executeQuery(sql)); // omitted - process rows and return an account list }

Тут ми використовували prepareStatement () доступний метод в Connection , наприклад , щоб отримати PreparedStatement . Цей інтерфейс розширює звичайний інтерфейс Statement кількома методами, які дозволяють нам безпечно вставляти надані користувачем значення у запит перед його виконанням.

Для JPA ми маємо подібну функцію:

String jql = "from Account where customerId = :customerId"; TypedQuery q = em.createQuery(jql, Account.class) .setParameter("customerId", customerId); // Execute query and return mapped results (omitted)

При запуску цього коду під Spring Boot ми можемо встановити для властивості logging.level.sql значення DEBUG і подивитися, який запит насправді побудований для виконання цієї операції:

// Note: Output formatted to fit screen [DEBUG][SQL] select account0_.id as id1_0_, account0_.acc_number as acc_numb2_0_, account0_.balance as balance3_0_, account0_.branch_id as branch_i4_0_, account0_.customer_id as customer5_0_ from accounts account0_ where account0_.customer_id=?

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

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

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

// This WILL NOT WORK !!! PreparedStatement p = c.prepareStatement("select count(*) from ?"); p.setString(1, tableName);

Тут JPA також не допоможе:

// This WILL NOT WORK EITHER !!! String jql = "select count(*) from :tableName"; TypedQuery q = em.createQuery(jql,Long.class) .setParameter("tableName", tableName); return q.getSingleResult(); 

В обох випадках ми отримаємо помилку виконання.

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

3.2. API критеріїв JPA

Since explicit JQL query building is the main source of SQL Injections, we should favor the use of the JPA's Query API, when possible.

For a quick primer on this API, please refer to the article on Hibernate Criteria queries. Also worth reading is our article about JPA Metamodel, which shows how to generate metamodel classes that will help us to get rid of string constants used for column names – and the runtime bugs that arise when they change.

Let's rewrite our JPA query method to use the Criteria API:

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId)); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)

Here, we've used more code lines to get the same result, but the upside is that now we don't have to worry about JQL syntax.

Another important point: despite its verbosity, the Criteria API makes creating complex query services more straightforward and safer. For a complete example that shows how to do it in practice, please take a look at the approach used by JHipster-generated applications.

3.3. User Data Sanitization

Data Sanitization is a technique of applying a filter to user supplied-data so it can be safely used by other parts of our application. A filter's implementation may vary a lot, but we can generally classify them in two types: whitelists and blacklists.

Blacklists, which consist of filters that try to identify an invalid pattern, are usually of little value in the context of SQL Injection prevention – but not for the detection! More on this later.

Whitelists, on the other hand, work particularly well when we can define exactly what is a valid input.

Let's enhance our safeFindAccountsByCustomerId method so now the caller can also specify the column used to sort the result set. Since we know the set of possible columns, we can implement a whitelist using a simple set and use it to sanitize the received parameter:

private static final Set VALID_COLUMNS_FOR_ORDER_BY = Collections.unmodifiableSet(Stream .of("acc_number","branch_id","balance") .collect(Collectors.toCollection(HashSet::new))); public List safeFindAccountsByCustomerId( String customerId, String orderBy) throws Exception { String sql = "select " + "customer_id,acc_number,branch_id,balance from Accounts" + "where customer_id = ? "; if (VALID_COLUMNS_FOR_ORDER_BY.contains(orderBy)) { sql = sql + " order by " + orderBy; } else { throw new IllegalArgumentException("Nice try!"); } Connection c = dataSource.getConnection(); PreparedStatement p = c.prepareStatement(sql); p.setString(1,customerId); // ... result set processing omitted }

Here, we're combining the prepared statement approach and a whitelist used to sanitize the orderBy argument. The final result is a safe string with the final SQL statement. In this simple example, we're using a static set, but we could also have used database metadata functions to create it.

We can use the same approach for JPA, also taking advantage of the Criteria API and Metadata to avoid using String constants in our code:

// Map of valid JPA columns for sorting final Map
    
      VALID_JPA_COLUMNS_FOR_ORDER_BY = Stream.of( new AbstractMap.SimpleEntry(Account_.ACC_NUMBER, Account_.accNumber), new AbstractMap.SimpleEntry(Account_.BRANCH_ID, Account_.branchId), new AbstractMap.SimpleEntry(Account_.BALANCE, Account_.balance)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); SingularAttribute orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy); if (orderByAttribute == null) { throw new IllegalArgumentException("Nice try!"); } CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery cq = cb.createQuery(Account.class); Root root = cq.from(Account.class); cq.select(root) .where(cb.equal(root.get(Account_.customerId), customerId)) .orderBy(cb.asc(root.get(orderByAttribute))); TypedQuery q = em.createQuery(cq); // Execute query and return mapped results (omitted)
    

This code has the same basic structure as in the plain JDBC. First, we use a whitelist to sanitize the column name, then we proceed to create a CriteriaQuery to fetch the records from the database.

3.4. Are We Safe Now?

Let's assume that we've used parameterized queries and/or whitelists everywhere. Can we now go to our manager and guarantee we're safe?

Well… not so fast. Without even considering Turing's halting problem, there are other aspects we must consider:

  1. Stored Procedures: These are also prone to SQL Injection issues; whenever possible please apply sanitation even to values that will be sent to the database via prepared statements
  2. Triggers: Same issue as with procedure calls, but even more insidious because sometimes we have no idea they're there…
  3. Insecure Direct Object References: Even if our application is SQL-Injection free, there's still a risk that associated with this vulnerability category – the main point here is related to different ways an attacker can trick the application, so it returns records he or she was not supposed to have access to – there's a good cheat sheet on this topic available at OWASP's GitHub repository

In short, our best option here is caution. Many organizations nowadays use a “red team” exactly for this. Let them do their job, which is exactly to find any remaining vulnerabilities.

4. Damage Control Techniques

As a good security practice, we should always implement multiple defense layers – a concept known as defense in depth. The main idea is that even if we're unable to find all possible vulnerabilities in our code – a common scenario when dealing with legacy systems – we should at least try to limit the damage an attack would inflict.

Of course, this would be a topic for a whole article or even a book but let's name a few measures:

  1. Apply the principle of least privilege: Restrict as much as possible the privileges of the account used to access the database
  2. Use database-specific methods available in order to add an additional protection layer; for example, the H2 Database has a session-level option that disables all literal values on SQL Queries
  3. Use short-lived credentials: Make the application rotate database credentials often; a good way to implement this is by using Spring Cloud Vault
  4. Log everything: If the application stores customer data, this is a must; there are many solutions available that integrate directly to the database or work as a proxy, so in case of an attack we can at least assess the damage
  5. Використовуйте WAF або подібні рішення для виявлення вторгнень: це типові приклади чорного списку - зазвичай вони постачаються з великою базою даних відомих підписів атак і викликають програмовану дію при виявленні. Деякі з них також включають в себе агенти JVM, які можуть виявляти вторгнення, застосовуючи певні інструменти - головна перевага цього підходу полягає в тому, що можливу вразливість стає набагато легше виправити, оскільки ми матимемо повну трасу стека.

5. Висновок

У цій статті ми розглянули вразливості SQL Injection у додатках Java - дуже серйозну загрозу для будь-якої організації, яка залежить від даних для їх бізнесу - та способи запобігання їх використанням простих методів.

Як завжди, повний код цієї статті доступний на Github.

Дно стійкості

Я щойно оголосив про новий курс Learn Spring , орієнтований на основи Spring 5 та Spring Boot 2:

>> ПЕРЕВІРИТИ КУРС