1. 序章

Despite being one of the best-known vulnerabilities, SQL Injection continues to rank on the top spot of the infamous OWASP Top 10’s list – now part of the more general Injection class.

このチュートリアルでは、脆弱なアプリケーションにつながるの一般的なコーディングミスと、JVMの標準ランタイムライブラリで利用可能なAPIを使用してそれらを回避する方法について説明します。 また、JPA、HibernateなどのORMからどのような保護を取得できるか、またどの死角についても心配する必要があるかについても説明します。

2. アプリケーションはどのようにしてSQLインジェクションに対して脆弱になりますか?

インジェクション攻撃が機能するのは、多くのアプリケーションで、特定の計算を実行する唯一の方法は、別のシステムまたはコンポーネントによって実行されるコードを動的に生成することであるためです。 このコードを生成する過程で、適切なサニタイズを行わずに信頼できないデータを使用する場合、ハッカーが悪用する可能性があります。

このステートメントは少し抽象的なように聞こえるかもしれませんので、教科書の例でこれが実際にどのように行われるかを見てみましょう。

public List<AccountDTO>
  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の値を、検証なしでクエリに入れました。 この値が信頼できるソースからのみ得られると確信している場合、悪いことは何も起こりませんが、私たちはできますか?

この関数がアカウントリソースのRESTAPI実装で使用されていると想像してみてください。 このコードを悪用するのは簡単です。クエリの固定部分と連結すると、意図した動作を変更する値を送信するだけです。

curl -X GET \
  'http://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句を追加
  • 動的なグループ化または順序付け:GUIデータテーブルへのバックエンドとして使用されるREST API

2.1. JPAを使用しています。 私は安全ですよね?

これはよくある誤解です。 JPAやその他のORMを使用すると、手動でコーディングされたSQLステートメントを作成できなくなりますが、脆弱なコードの記述を妨げることはありません。

前の例のJPAバージョンがどのように見えるかを見てみましょう。

public List<AccountDTO> unsafeJpaFindAccountsByCustomerId(String customerId) {    
    String jql = "from Account where customerId = '" + customerId + "'";        
    TypedQuery<Account> 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<AccountDTO> 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
}

ここでは、 Connectionインスタンスで使用可能なprepareStatement()メソッドを使用して、PreparedStatementを取得しました。 このインターフェイスは、通常のステートメントインターフェイスをいくつかのメソッドで拡張し、ユーザーが指定した値を実行する前にクエリに安全に挿入できるようにします。

JPAの場合、同様の機能があります。

String jql = "from Account where customerId = :customerId";
TypedQuery<Account> 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();

どちらの場合も、ランタイムエラーが発生します。

この背後にある主な理由は、プリペアドステートメントの性質そのものです。データベースサーバーは、結果セットをプルするために必要なクエリプランをキャッシュするためにそれらを使用します。これは通常、可能な値に対して同じです。 これは、 order by 句で使用される列など、SQL言語で使用可能なテーブル名やその他の構造には当てはまりません。

3.2. JPA Criteria API

明示的なJQLクエリの構築がSQLインジェクションの主なソースであるため、可能であれば、JPAのクエリAPIの使用を優先する必要があります。

このAPIの簡単な入門書については、HibernateCriteriaクエリに関する記事を参照してください。 また、JPA Metamodel に関するの記事も読む価値があります。この記事では、列名に使用される文字列定数と、それらが変更されたときに発生する実行時のバグを取り除くのに役立つメタモデルクラスを生成する方法を示しています。

CriteriaAPIを使用するようにJPAクエリメソッドを書き直してみましょう。

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root).where(cb.equal(root.get(Account_.customerId), customerId));

TypedQuery<Account> q = em.createQuery(cq);
// Execute query and return mapped results (omitted)

ここでは、同じ結果を得るためにさらに多くのコード行を使用しましたが、利点は、JQL構文について心配する必要がないことです。

もう1つの重要なポイント:その冗長性にもかかわらず、 Criteria APIは、複雑なクエリサービスの作成をより簡単かつ安全にします。実際にそれを行う方法を示す完全な例については、[ X247X]JHipsterで生成されたアプリケーション。

3.3. ユーザーデータのサニタイズ

データサニタイズは、ユーザーが指定したデータにフィルターを適用する手法であり、アプリケーションの他の部分で安全に使用できます。 フィルタの実装は大きく異なる場合がありますが、通常、ホワイトリストとブラックリストの2つのタイプに分類できます。

ブラックリストは、無効なパターンを識別しようとするフィルターで構成されており、SQLインジェクション防止のコンテキストでは通常ほとんど価値がありませんが、検出には価値がありません。 これについては後で詳しく説明します。

一方、ホワイトリストは、有効な入力を正確に定義できる場合に特に効果的です。

safeFindAccountsByCustomerId メソッドを拡張して、呼び出し元が結果セットの並べ替えに使用する列も指定できるようにします。 可能な列のセットがわかっているので、単純なセットを使用してホワイトリストを実装し、それを使用して受信したパラメーターをサニタイズできます。

private static final Set<String> VALID_COLUMNS_FOR_ORDER_BY
  = Collections.unmodifiableSet(Stream
      .of("acc_number","branch_id","balance")
      .collect(Collectors.toCollection(HashSet::new)));

public List<AccountDTO> 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
}

ここでは、プリペアドステートメントアプローチと、orderBy引数をサニタイズするために使用されるホワイトリストを組み合わせています。 最終結果は、最終SQLステートメントを含む安全な文字列です。 この簡単な例では、静的セットを使用していますが、データベースメタデータ関数を使用して作成することもできます。

JPAにも同じアプローチを使用できます。また、Criteria APIとメタデータを利用して、コードでString定数を使用しないようにします。

// Map of valid JPA columns for sorting
final Map<String,SingularAttribute<Account,?>> 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<Account,?> orderByAttribute = VALID_JPA_COLUMNS_FOR_ORDER_BY.get(orderBy);
if (orderByAttribute == null) {
    throw new IllegalArgumentException("Nice try!");
}

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Account> cq = cb.createQuery(Account.class);
Root<Account> root = cq.from(Account.class);
cq.select(root)
  .where(cb.equal(root.get(Account_.customerId), customerId))
  .orderBy(cb.asc(root.get(orderByAttribute)));

TypedQuery<Account> q = em.createQuery(cq);
// Execute query and return mapped results (omitted)

このコードの基本構造は、プレーンJDBCと同じです。 まず、ホワイトリストを使用して列名をサニタイズし、次にCriteriaQueryを作成してデータベースからレコードをフェッチします。

3.4. 私たちは今安全ですか?

あらゆる場所でパラメータ化されたクエリやホワイトリストを使用したと仮定しましょう。 今、私たちはマネージャーのところに行き、私たちが安全であることを保証できますか?

ええと…それほど速くはありません。 Turingの停止問題を考慮しなくても、考慮しなければならない他の側面があります。

  1. ストアドプロシージャこれらもSQLインジェクションの問題が発生しやすい。 可能な限り、プリペアドステートメントを介してデータベースに送信される値にもサニテーションを適用してください
  2. トリガー:プロシージャ呼び出しと同じ問題ですが、そこにあることがわからないことがあるため、さらに陰湿です…
  3. 安全でない直接オブジェクト参照:アプリケーションにSQLインジェクションがない場合でも、この脆弱性カテゴリに関連するリスクがあります。ここでの主なポイントは、攻撃者がアプリケーションをだますことができるさまざまな方法に関連しているため、彼または彼女がアクセスするはずがなかったレコードを返します–OWASPのGitHubリポジトリで利用可能なこのトピックに関する優れたチートシートがあります

要するに、ここでの最善の選択肢は注意です。 今日、多くの組織はまさにこのために「レッドチーム」を使用しています。 彼らに彼らの仕事をさせてください、それは正確に残っている脆弱性を見つけることです。

4. ダメージコントロールテクニック

優れたセキュリティプラクティスとして、常に複数の防御レイヤーを実装する必要があります。これは多層防御として知られる概念です。 主なアイデアは、コードに考えられるすべての脆弱性を見つけることができない場合でも(レガシーシステムを扱う場合の一般的なシナリオ)、少なくとも攻撃による被害を制限するように努める必要があるということです。

もちろん、これは記事全体または本のトピックになりますが、いくつかの対策を挙げましょう。

  1. 最小特権の原則を適用します。データベースへのアクセスに使用されるアカウントの特権を可能な限り制限します
  2. 追加の保護レイヤーを追加するために利用可能なデータベース固有の方法を使用します。 たとえば、H2データベースには、SQLクエリのすべてのリテラル値を無効にするセッションレベルのオプションがあります
  3. 短期間のクレデンシャルを使用する:アプリケーションにデータベースクレデンシャルを頻繁にローテーションさせます。 これを実装する良い方法は、 Spring CloudVaultを使用することです。
  4. すべてをログに記録します。アプリケーションが顧客データを保存する場合、これは必須です。 データベースに直接統合したり、プロキシとして機能したりするソリューションは多数あります。そのため、攻撃が発生した場合でも、少なくとも被害を評価できます。
  5. WAF または同様の侵入検知ソリューションを使用します。これらは典型的なブラックリストの例です。通常、既知の攻撃シグネチャの大規模なデータベースが付属しており、検出時にプログラム可能なアクションをトリガーします。 一部には、インストルメンテーションを適用することで侵入を検出できるin-JVMエージェントも含まれています。このアプローチの主な利点は、完全なスタックトレースが利用できるため、最終的な脆弱性の修正がはるかに簡単になることです。

5. 結論

この記事では、JavaアプリケーションのSQLインジェクションの脆弱性(ビジネスをデータに依存している組織にとって非常に深刻な脅威)と、簡単な手法を使用してそれらを防ぐ方法について説明しました。

いつものように、この記事の完全なコードはGithubで入手できます。