【PL/SQL】動的SQLのセキュアな書き方完全ガイド|USINGバインド・DBMS_ASSERT・ホワイトリスト・監査・攻撃テスト

【PL/SQL】動的SQLのセキュアな書き方|EXECUTE IMMEDIATEとバインド変数の正しい活用 PL/SQL

PL/SQLの動的SQLは柔軟さと引き換えに、SQLインジェクションという重大なセキュリティリスクと隣り合わせです。「EXECUTE IMMEDIATE 'SELECT ... WHERE id=' || p_id」のような文字列連結方式は、攻撃者にとってはテーブル全件流出・全件削除・パスワード抽出への直通ルートになります。実際のOWASP Top 10でもインジェクションは長年トップ脅威として扱われており、古い実装が現役で本番にいるのが業界の現実です。

正解は「リテラルは必ずバインド変数」「識別子はDBMS_ASSERTとホワイトリスト」のたった2原則です。これさえ守れば、Oracleの動的SQLはむしろ静的SQLより安全な領域へ持ち込めます。さらに副次効果としてカーソル共有によるパース削減(同じSQLが何度実行されても1回だけパース)が効き、安全と性能が同時に手に入る構造になります。

この記事ではセキュリティに全振りした動的SQL実戦ガイドとして、攻撃の実例再現、EXECUTE IMMEDIATEでのUSING正しい書き方、識別子動的化の罠とDBMS_ASSERT実戦、RETURNING INTO・OUT・IN OUTの全パターン、ホワイトリスト設計、動的SQL監査の組み込み、アンチパターン7選、テスト方法、FAQ10問まで2026年版で整理します。

この記事でわかること

  • SQLインジェクションが具体的にどんな攻撃文字列で成立するかの再現
  • EXECUTE IMMEDIATEのUSING句で正しくバインドする実装パターン
  • RETURNING INTO・OUT・IN OUTを使った戻り値取得の3パターン
  • テーブル名・カラム名など識別子を動的化する場合のDBMS_ASSERT活用
  • ホワイトリスト設計でDBMS_ASSERTだけでは防げない攻撃を遮断する方法
  • 動的SQLとカーソル共有・パース削減の関係(性能と安全の両立)
  • 動的SQL実行ログを取って異常な呼び出しを検知する監査設計
  • 本番で踏むアンチパターン7選と修正パターン
  • 動的SQLのセキュリティテスト方法(攻撃ペイロード集)
スポンサーリンク

30秒でわかる動的SQLセキュア化の結論

忙しい読者向けの結論先出しです。

結論 理由・根拠
リテラルは100%バインド変数(USING句)に置き換える 文字列連結はSQLインジェクションの温床。例外なし
② 識別子(表・列名)の動的化はDBMS_ASSERT+ホワイトリスト DBMS_ASSERTだけでは「存在する有効名」を許してしまうので両輪必須
③ DDLはサブプログラム単位で許可制 動的DDLは特権昇格の入口。利用箇所をパッケージで集中管理
RETURNING INTO + USING OUTで戻り値を安全に取得 戻り値もバインドで受け取り、文字列連結で展開しない
NDS(EXECUTE IMMEDIATE)優先、複雑時のみDBMS_SQL NDSはバインド強制力が強く誤実装しにくい
動的SQL監査ログを仕込む 異常な実行パターン(例: 1秒に100回違う表名)を後から検知できる
セキュリティテストを必ず行う 古典ペイロード10個で動的SQL関数を叩くテストを最低限実装

SQLインジェクションの実例|何が起きるかを知る

セキュアな書き方を学ぶ前に、悪い書き方が具体的にどう攻撃されるかを見ておくと実装ルールに対する納得感が変わります。以下は実際に攻撃が成立する典型例です。

シナリオ1|認証バイパス(一番ベタな攻撃)

ユーザーIDとパスワードを画面入力で受け取り、認証する古典実装を例に取ります。文字列連結方式だと、攻撃者は「' OR '1'='1」をパスワード欄に入れるだけで認証を素通りできます。

攻撃が成立する脆弱コード(やってはいけない例)
-- ❌ 脆弱:文字列連結でWHEREを組み立てている
CREATE OR REPLACE FUNCTION authenticate_bad(
  p_user IN VARCHAR2,
  p_pass IN VARCHAR2
) RETURN BOOLEAN AS
  v_cnt NUMBER;
BEGIN
  EXECUTE IMMEDIATE
    'SELECT COUNT(*) FROM users
       WHERE username = ''' || p_user || '''
         AND password = ''' || p_pass || ''''
    INTO v_cnt;
  RETURN v_cnt > 0;
END;
/

-- 攻撃: パスワードに ' OR '1'='1 を入力されると...
-- 生成されるSQL:
--   SELECT COUNT(*) FROM users
--    WHERE username = 'alice' AND password = '' OR '1'='1
-- → 必ず1件以上ヒットして認証通過してしまう

シナリオ2|情報窃取(UNIONインジェクション)

商品検索のキーワード欄にx' UNION SELECT username,password FROM users--のような文字列を入れられると、結果セットにユーザーテーブルの中身が混入します。WebアプリがそのままJSONに変換して返してしまえば、画面でハッシュ化されていないパスワードが丸見えです。

シナリオ3|全件削除(DDL/DCLインジェクション)

並び順(ORDER BY column)を画面で選ばせる実装に対してid; DROP TABLE products--を渡されたら、動的SQLの種類によっては2文目が実行されてしまいます。EXECUTE IMMEDIATEは1文制約があるため助かるケースもありますが、DBMS_SQLや複文許容の構造では即死します。

「うちのコードはWHERE句だけ動的だから安全」は誤解です。WHERE句に攻撃文字列を入れるだけでUNIONによる任意テーブル参照サブクエリでの全件抽出条件分岐でのデータ推測(ブラインドSQLi)が成立します。「文字列連結している瞬間に脆弱」と認識を改めてください。

正しい実装|USING句でバインド変数を使い切る

動的SQLでセキュリティを担保する最も重要な機構がEXECUTE IMMEDIATE ... USING句です。プレースホルダ:1, :2または:p_user, :p_passをSQL文中に置き、実値はUSING句で別経路で渡します。SQL本文と値が別々の経路でDBに送られるため、攻撃者がいくら値に「’ OR ‘1’=’1」を入れても「文字列値そのもの」として扱われ、SQL文の一部にはなり得ません。

前述の認証関数をセキュアに書き直す
-- ✅ セキュア:USING句でバインド
CREATE OR REPLACE FUNCTION authenticate_safe(
  p_user IN VARCHAR2,
  p_pass IN VARCHAR2
) RETURN BOOLEAN AS
  v_cnt NUMBER;
BEGIN
  EXECUTE IMMEDIATE
    'SELECT COUNT(*) FROM users
       WHERE username = :u
         AND password = :p'
    INTO v_cnt
    USING p_user, p_pass;       -- ← 値はUSINGで渡す
  RETURN v_cnt > 0;
END;
/

-- 攻撃: パスワードに ' OR '1'='1 を入力しても...
-- バインド値そのものとして扱われるため
-- WHERE password = (文字列値そのまま) になり常にFALSE
-- → 認証は安全に拒否される

USING句の3つの方向(IN/OUT/IN OUT)

USING句に渡す変数には3つの方向を指定できます。デフォルトはINで、戻り値を取りたい場合に明示的にOUTを指定します。INSERT直後の自動採番値を取得するなど、戻り値経路もバインドで安全に書ける設計が組まれています。

IN/OUT/IN OUTの全パターン
-- ① IN(デフォルト):値をSQLに渡す
EXECUTE IMMEDIATE
  'UPDATE products SET price = :p WHERE id = :i'
  USING 1500, 100;

-- ② OUT:SQLから値を受け取る(INSERT ... RETURNING や PL/SQL関数呼び出し)
DECLARE
  v_id NUMBER;
BEGIN
  EXECUTE IMMEDIATE
    'INSERT INTO orders(order_id, customer_id, amount)
     VALUES(orders_seq.NEXTVAL, :c, :a)
     RETURNING order_id INTO :id'
    USING 100, 5000, OUT v_id;     -- OUT を明示
  DBMS_OUTPUT.PUT_LINE('採番: ' || v_id);
END;
/

-- ③ IN OUT:値を渡しつつ更新後の値を受け取る
DECLARE
  v_qty NUMBER := 10;
BEGIN
  EXECUTE IMMEDIATE
    'BEGIN
       :q := :q * 2;     -- 受け取って2倍にして返す
     END;'
    USING IN OUT v_qty;
  DBMS_OUTPUT.PUT_LINE('更新後: ' || v_qty);  -- 20
END;
/

-- ④ SELECT INTO 形式(単一行):INTO句 + USING句 で組み合わせ
DECLARE
  v_name VARCHAR2(100);
BEGIN
  EXECUTE IMMEDIATE
    'SELECT product_name FROM products WHERE id = :i'
    INTO v_name
    USING 100;
END;
/

-- ⑤ BULK COLLECT INTO 形式:複数行をまとめて取得
DECLARE
  TYPE t_names IS TABLE OF VARCHAR2(100);
  v_names t_names;
BEGIN
  EXECUTE IMMEDIATE
    'SELECT product_name FROM products WHERE category = :c'
    BULK COLLECT INTO v_names
    USING 'BOOK';
END;
/

注意:USING句でNULLを渡したい場合はNULLリテラルを直接書けません。一旦変数にNULLを代入してからUSING v_nullで渡します。またVARCHAR2の長さ制限はOracleバインドでは32767バイトのため、CLOBは別途USING dbms_lob.substr(...)などで分割するか、CLOB変数で直接渡すかを使い分けます。

識別子の動的化|表・列名は別ルートで安全化する

「テーブル名や列名を動的に変えたい」という要件は実務でしばしば発生します。しかしUSING句は値(リテラル)にしか使えず、識別子には使えません。ここで安易に文字列連結すると一気に攻撃面が広がります。識別子の動的化にはDBMS_ASSERTホワイトリストの二段ガードが必須です。

DBMS_ASSERTの主要関数

OracleはSQLインジェクション対策の標準パッケージとしてDBMS_ASSERTを提供しています。名前として有効な形式かを検査し、不正なら例外を投げてくれます。主な関数は次の4つです。

  • SIMPLE_SQL_NAME(s):単純SQL名(識別子)として有効か検査。ダブルクォート不要の英数字・アンダースコア・$#を許容
  • QUALIFIED_SQL_NAME(s)schema.object@dblink形式の検査
  • SCHEMA_NAME(s):実際に存在するスキーマ名か検証
  • SQL_OBJECT_NAME(s):実際に存在するSQLオブジェクト名か検証
  • ENQUOTE_NAME(s):ダブルクォートで安全に括る
  • ENQUOTE_LITERAL(s):シングルクォートで安全に括る
DBMS_ASSERT+ホワイトリストの二段ガード実装
CREATE OR REPLACE PACKAGE pkg_safe_dyn AS
  -- 許可テーブルの集合(ホワイトリスト)
  TYPE t_allow IS TABLE OF VARCHAR2(30);
  c_allowed_tabs CONSTANT t_allow := t_allow(
    'PRODUCTS', 'ORDERS', 'CUSTOMERS', 'INVENTORY'
  );

  FUNCTION resolve_table(p_name VARCHAR2) RETURN VARCHAR2;
  PROCEDURE truncate_safe(p_table VARCHAR2);
END pkg_safe_dyn;
/

CREATE OR REPLACE PACKAGE BODY pkg_safe_dyn AS

  FUNCTION resolve_table(p_name VARCHAR2) RETURN VARCHAR2 AS
    v_upper VARCHAR2(30);
  BEGIN
    -- ① DBMS_ASSERTで形式チェック(不正名は例外)
    v_upper := UPPER(DBMS_ASSERT.SIMPLE_SQL_NAME(p_name));

    -- ② ホワイトリスト判定(事前許可された名前か)
    FOR i IN 1 .. c_allowed_tabs.COUNT LOOP
      IF v_upper = c_allowed_tabs(i) THEN
        RETURN v_upper;     -- 安全な名前を返す
      END IF;
    END LOOP;

    RAISE_APPLICATION_ERROR(-20001,
      '許可されていないテーブル名: ' || p_name);
  END;

  PROCEDURE truncate_safe(p_table VARCHAR2) AS
    v_safe VARCHAR2(30);
  BEGIN
    v_safe := resolve_table(p_table);  -- 必ずホワイトリスト経由
    EXECUTE IMMEDIATE 'TRUNCATE TABLE ' || v_safe;
  END;
END pkg_safe_dyn;
/

-- 使い方
BEGIN
  pkg_safe_dyn.truncate_safe('PRODUCTS');     -- OK
  -- pkg_safe_dyn.truncate_safe('SYS.AUD$');  -- ORA-20001 で拒否
  -- pkg_safe_dyn.truncate_safe('A--; DROP'); -- DBMS_ASSERTで例外
END;
/

DBMS_ASSERT単独では不十分な点に注意してください。SIMPLE_SQL_NAMEは「形式として有効な識別子か」しか見ないので、存在するテーブル名であればSYS.USER$でもHR.SALARYでも通過してしまいます。アプリが本来触ってはいけない表へのアクセスを防ぐにはホワイトリストとの併用が必須です。

DDLを動的に実行する場合の注意点

DDL(CREATE/ALTER/DROP/TRUNCATE)は構造上バインド変数を使えないため、識別子を文字列連結する以外に実装手段がありません。すなわち動的DDLは攻撃面の塊であり、次の3原則を必ず守ってください。

  1. 呼び出し権限を絞る:動的DDLを実行するパッケージに付与する権限はDBA権限ではなく必要最小限へ。Oracle 12c以降はAUTHID CURRENT_USERINHERIT PRIVILEGES制御で「呼び出し元の権限で動かす」設計が安全
  2. 受け取る引数は完全制限:DBMS_ASSERT+ホワイトリストで「許可された名前のみ」「許可された操作のみ」に絞り、自由文字列の受け取りを禁止
  3. 監査ログを必ず残す:誰がいつ何のDDLを実行したかを記録し、想定外の呼び出しを後追いできるようにする
安全なDDL実行ラッパー(操作も列挙でガード)
CREATE OR REPLACE PACKAGE pkg_admin_dyn AUTHID CURRENT_USER AS
  -- 許可される操作のみ列挙
  TYPE t_op IS TABLE OF VARCHAR2(20);
  c_ops CONSTANT t_op := t_op('TRUNCATE', 'REBUILD');

  PROCEDURE do_ddl(p_op IN VARCHAR2, p_target IN VARCHAR2);
END;
/

CREATE OR REPLACE PACKAGE BODY pkg_admin_dyn AS
  PROCEDURE do_ddl(p_op IN VARCHAR2, p_target IN VARCHAR2) AS
    v_op  VARCHAR2(20);
    v_tgt VARCHAR2(30);
    v_sql VARCHAR2(500);
    v_ok  BOOLEAN := FALSE;
  BEGIN
    -- 操作のホワイトリスト
    v_op := UPPER(DBMS_ASSERT.SIMPLE_SQL_NAME(p_op));
    FOR i IN 1 .. c_ops.COUNT LOOP
      IF v_op = c_ops(i) THEN v_ok := TRUE; EXIT; END IF;
    END LOOP;
    IF NOT v_ok THEN
      RAISE_APPLICATION_ERROR(-20002, '許可されない操作: ' || p_op);
    END IF;

    -- 対象のホワイトリスト(pkg_safe_dyn を再利用)
    v_tgt := pkg_safe_dyn.resolve_table(p_target);

    -- 監査ログ
    INSERT INTO dyn_audit(op, target, who, at_ts)
    VALUES(v_op, v_tgt, USER, SYSTIMESTAMP);

    -- 実行
    CASE v_op
      WHEN 'TRUNCATE' THEN
        EXECUTE IMMEDIATE 'TRUNCATE TABLE ' || v_tgt;
      WHEN 'REBUILD' THEN
        EXECUTE IMMEDIATE 'ALTER INDEX IDX_' || v_tgt || ' REBUILD';
    END CASE;
  END;
END;
/

動的SQL監査|異常な実行を後から検知する

セキュア実装でも「権限を持つ正規ユーザによる悪意ある操作」までは完全には防げません。事後検知のために動的SQL実行ログを必ず残します。Oracle標準のUnified Auditing独自の動的SQLラッパーを組み合わせるのが実務的なベストプラクティスです。

動的SQLを必ずログ経由にするラッパー設計
-- 監査表
CREATE TABLE dyn_sql_log(
  log_id    NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  exec_at   TIMESTAMP   DEFAULT SYSTIMESTAMP,
  who       VARCHAR2(64) DEFAULT USER,
  app_id    VARCHAR2(64),       -- アプリ識別子(CONTEXTで埋める)
  category  VARCHAR2(20),       -- DML/DDL/QUERY
  sql_text  CLOB,
  bind_args CLOB,               -- JSONで構造化
  err_code  NUMBER,
  err_msg   VARCHAR2(4000)
);

-- ラッパー
CREATE OR REPLACE PROCEDURE exec_safe(
  p_sql      IN VARCHAR2,
  p_category IN VARCHAR2,
  p_args     IN CLOB DEFAULT NULL    -- JSONで渡す
) AS
  PRAGMA AUTONOMOUS_TRANSACTION;
  v_log_id NUMBER;
BEGIN
  -- 実行前に監査ログ
  INSERT INTO dyn_sql_log(category, sql_text, bind_args)
  VALUES(p_category, p_sql, p_args)
  RETURNING log_id INTO v_log_id;
  COMMIT;

  -- 実行は呼び出し元のコンテキストで(ここでは型省略の例示)
  EXECUTE IMMEDIATE p_sql;
EXCEPTION
  WHEN OTHERS THEN
    UPDATE dyn_sql_log
       SET err_code = SQLCODE,
           err_msg  = SUBSTR(SQLERRM,1,4000)
     WHERE log_id = v_log_id;
    COMMIT;
    RAISE;
END;
/

異常検知のサンプルクエリ

監査ログが溜まれば次のような検知クエリが書けます。

異常パターン検知の例
-- ① 1分間に同一ユーザが100回以上動的SQLを叩いている
SELECT who, COUNT(*) cnt
  FROM dyn_sql_log
 WHERE exec_at > SYSTIMESTAMP - INTERVAL '1' MINUTE
 GROUP BY who
HAVING COUNT(*) >= 100
 ORDER BY cnt DESC;

-- ② 危険キーワードを含むSQL(既に防いでいるはずだが二重チェック)
SELECT log_id, who, sql_text
  FROM dyn_sql_log
 WHERE REGEXP_LIKE(sql_text, '(UNION|--|;\s*DROP|;\s*DELETE|sys\.)', 'i')
   AND exec_at > SYSDATE - 1;

-- ③ 通常使われない表へのアクセス(ホワイトリスト外)
SELECT log_id, who, sql_text
  FROM dyn_sql_log
 WHERE NOT REGEXP_LIKE(sql_text,
       'PRODUCTS|ORDERS|CUSTOMERS|INVENTORY', 'i')
   AND category IN ('DML','DDL')
   AND exec_at > SYSDATE - 1;

Oracle 12c以降のUnified Auditingをパッケージ単位で有効化(CREATE AUDIT POLICY)すれば、カスタム監査ログを書かなくても標準のUNIFIED_AUDIT_TRAILビューで実行履歴を追跡できます。Auditingオプションが利用可能なライセンスならこちらを優先し、カスタムログは「アプリ固有のIDやユーザコンテキストを残したい場合」に補完で使います。

セキュリティと性能の両立|カーソル共有の効果

バインド変数を使うとセキュリティが上がるだけでなく、副次効果としてカーソル共有が効きます。同じSQL文(バインドプレースホルダが揃った状態)が何度実行されてもパースは1回だけで済み、ライブラリキャッシュ消費もリテラル展開版の数十分の1〜数百分の1になります。

パース回数の差の実測イメージ

同一処理を100万回実行した場合、リテラル連結版は100万通りのSQLとして100万回パースされライブラリキャッシュを爆発させます。バインド版は1回パースして以降はソフトパースまたはキャッシュヒットで応答するため、CPU使用率が桁違いに低くなります。

カーソル共有が効く条件

  • SQL本文の1文字も違わない(空白の数も含む)
  • バインド変数が同じ位置に同じ型・近い長さで登場
  • CURSOR_SHARING=EXACTがデフォルト。FORCEはリテラルを自動でバインド化する裏技だが副作用大
  • 大文字小文字の違いも別SQLとして扱われるのでSQL本文は決め打ちで定数化するのがベスト

本番で踏むアンチパターン7選

① WHERE値を文字列連結する

最も古典的かつ最も致命的な脆弱性。たとえ「数値しか来ない」と分かっていても必ずバインド変数に統一してください。将来、別の開発者が同じ関数を別用途で使った瞬間に脆弱性が顕在化します。

② DBMS_ASSERTで満足してホワイトリストを省略

DBMS_ASSERTは形式チェックしかしないので、SYS.USER$のような有効名を阻止できません。識別子の動的化には必ずホワイトリストを併用してください。

③ ORDER BY句に画面入力をそのまま入れる

並び順カラム名を可変にする実装でORDER BY <ユーザ入力>と書くと、入力値にCASE WHEN (SELECT...) THEN 1 ELSE 1/0 ENDを仕込まれてブラインドSQLiが成立します(条件が真なら除算エラーで応答が変わる、という反応差からパスワードを1文字ずつ抽出される攻撃)。ORDER BY列は必ずホワイトリスト化、または列番号:nでバインドしてください。

④ DDLにDEFINER権限の固定パッケージを使う

DDLパッケージをAUTHID DEFINER(デフォルト)で書くと、パッケージ所有者の強い権限で動いてしまい権限昇格の入口になります。DDL実行系はAUTHID CURRENT_USER+必要最小権限で運用するのが鉄則です。

⑤ 例外を握りつぶしてエラー情報を返さない

EXCEPTION WHEN OTHERS THEN NULL;」で例外を消すと、インジェクション試行を検知できないだけでなく攻撃者にとっては反応の差で情報を抽出するブラインドSQLiの土壌になります。例外は必ずログ→再raiseしてください。

⑥ 攻撃テストをしないままリリース

古典ペイロード10個(' OR '1'='1'; DROP TABLE x--' UNION SELECT ... --等)を関数に投入して例外or拒否されることを確認するだけのテストでも実装の8割の脆弱性は炙り出せます。リリース前のチェックリストに必ず入れてください。

⑦ 動的SQL監査を取らない

「セキュアに書いたから安心」と監査を省くと、事後の異常検知や法令対応で詰みます。Unified Auditingまたは独自ログで誰が何を動的に実行したかを必ず保管してください。

動的SQLのセキュリティテスト|古典ペイロード10種

セキュア実装の確認のために、関数を直接攻撃ペイロードで叩く単体テストを仕込みます。次の10種をすべて投入し、いずれも例外or業務的拒否で結果が返ることを検証してください。

攻撃ペイロード10種テスト(pl/sqlで実装)
DECLARE
  TYPE t_payloads IS TABLE OF VARCHAR2(200);
  v_payloads t_payloads := t_payloads(
    Q'[' OR '1'='1]',                                  -- 1: 認証バイパス
    Q'[admin'--]',                                     -- 2: コメント切り
    Q'['; DROP TABLE products--]',                     -- 3: DDL注入
    Q'[' UNION SELECT username,password FROM users--]', -- 4: UNION窃取
    Q'[1; DELETE FROM orders WHERE 1=1--]',            -- 5: DML注入
    Q'[' OR (SELECT COUNT(*) FROM users)>0--]',        -- 6: ブラインド
    Q'[%' OR password LIKE '%a%' OR ']',                -- 7: LIKEワイルド
    Q'[admin' AND SYS_CONTEXT('USERENV','SID')>0--]',  -- 8: 関数経由
    Q'[' || (SELECT MAX(id) FROM users) || ']',        -- 9: 連結試行
    Q'[NULL OR 1=1--]'                                 -- 10: NULL含み
  );
  v_ok  PLS_INTEGER := 0;
  v_ng  PLS_INTEGER := 0;
BEGIN
  FOR i IN 1 .. v_payloads.COUNT LOOP
    BEGIN
      -- 認証関数(authenticate_safe)が攻撃で通ってしまわないことを確認
      IF authenticate_safe('alice', v_payloads(i)) THEN
        v_ng := v_ng + 1;
        DBMS_OUTPUT.PUT_LINE('NG: payload #' || i || ' bypassed!');
      ELSE
        v_ok := v_ok + 1;
      END IF;
    EXCEPTION
      WHEN OTHERS THEN
        v_ok := v_ok + 1;  -- 例外も「拒否」として正常
    END;
  END LOOP;

  DBMS_OUTPUT.PUT_LINE('OK=' || v_ok || ' / NG=' || v_ng);
  IF v_ng > 0 THEN
    RAISE_APPLICATION_ERROR(-20003, 'セキュリティテスト失敗');
  END IF;
END;
/

よくある質問

Qバインド変数を使えば必ず安全ですか?
Aリテラル(値)の動的化ならUSING句で100%安全です。ただし識別子(表名・列名・ORDER BY列)はUSING句で受けられないので、別途DBMS_ASSERT+ホワイトリストでガードする必要があります。「リテラルはバインド、識別子はホワイトリスト」の二段構造を必ず守ってください。
QCURSOR_SHARING=FORCEを使えばリテラル連結でも安全になりますか?
Aカーソル共有率は上がりますがセキュリティ的には何も解決しません。FORCEはOracleがリテラルを自動でバインドに置き換えるのみで、攻撃文字列はそのままSQLとして解釈されます。むしろEXACT(デフォルト)のままバインド変数を正しく使うのが安全と性能の両立解です。
QEXECUTE IMMEDIATEとDBMS_SQLはどちらを使うべき?
AまずEXECUTE IMMEDIATE(NDS)を優先してください。バインド構造が強制力を持ち、誤実装が起きにくい設計です。列数や型が実行時にしか分からない汎用クエリなど、NDSでは表現しきれないケースだけDBMS_SQLを使います。DBMS_SQLはAPI数が多く誤用しやすいので、使う場所をパッケージで集中管理し関数粒度で安全化レビューしてください。
QPRAGMA AUTONOMOUS_TRANSACTIONで動的SQLは安全ですか?
A安全性の話ではなく「トランザクション分離」の機能です。監査ログ書き込みなど本トランザクションがロールバックされても残したい処理に使います。インジェクション対策とは独立した概念なので、別軸でDBMS_ASSERT/USING句を必ず適用してください。
Q動的SQLでLIKE句のワイルドカードを安全に扱うには?
Aバインド変数に渡す前に%_ESCAPE文字でエスケープしてください。v_kw := REPLACE(REPLACE(p_kw,'\\','\\\\'),'%','\\%')のように前処理し、SQL側はLIKE :kw ESCAPE '\\'と書きます。これでユーザーが「%」を含む検索語を入れても部分一致が暴発せず、かつバインドの安全性は維持されます。
QIN句に動的個数の値を入れたい
Aバインド変数の数を可変にできない(バインド数は固定)ため、次の3パターンから選びます。①最大数を決めて:1, :2, ..., :10を全部宣言し未使用はNULLバインド、②値をコンマ連結文字列にしてWHERE x IN (SELECT REGEXP_SUBSTR(...) FROM dual CONNECT BY ...)のように展開、③コレクション型を作ってWHERE x MEMBER OF :collでバインド。③が最も柔軟で安全です。
Qシノニム経由で動的SQL対象を切り替えたい
Aシノニム名をホワイトリストで管理し、SQL本文では固定のシノニム名で書いて、実体表はCREATE OR REPLACE SYNONYMで切り替えるのが安全です。これでSQL本文に動的部分がなくなりインジェクション面が消滅し、カーソル共有も最大化されます。本番環境ではシノニム切替権限自体を厳しく制限してください。
QEXECUTE IMMEDIATEで複数文を一度に実行したい
A複数文の動的実行は推奨しません。EXECUTE IMMEDIATEは「BEGIN〜END」の無名ブロックを渡すか、個別に複数回呼ぶ設計に分けてください。セミコロン区切りの複文を許す実装はインジェクションで第二文を注入される温床なので、構造的に複文を許さない選択が安全です。
Qバインド変数を使うとExplain Planが分かりにくくなる
Aバインドピーキング(最初のバインド値で計画を立てる)の影響で実行計画が値依存に変動することはあります。Oracle 11g以降はAdaptive Cursor Sharingで値の偏りに応じて自動的に複数の計画を保持しますが、原因調査時はDBMS_XPLAN.DISPLAY_CURSOR(format=>'+PEEKED_BINDS')で実際にピークされた値を確認できます。
QCLOBやBLOBを動的SQLでバインドできますか?
ACLOBはUSING l_clobでバインド可能です。長大なテキスト(JSON・CSV等)を扱う場合に使います。BLOBも同様でUSING l_blob。注意点はカーソル共有を考慮するなら同じ場所に同じ型のLOBを渡し続ける設計にすること。型が変動するとハードパースが頻発し性能が劣化します。

関連記事で知識を深める

動的SQLとセキュリティ・性能設計に関連する記事を集めました。

まとめ|セキュアな動的SQLは2原則で守れる

動的SQLのセキュリティは複雑そうに見えますが、本質は「リテラルはバインド」「識別子はDBMS_ASSERT+ホワイトリスト」の二段構造に集約されます。これを全動的SQL関数で例外なく適用し、加えてDDL実行ラッパー化・監査ログ・セキュリティテストの3点を運用に組み込めば、PL/SQLにおけるインジェクションは構造的に根絶できます。本記事の要点を7項目で再確認します。

  1. EXECUTE IMMEDIATEのUSING句で値は100%バインド変数に
  2. RETURNING INTO + USING OUTで戻り値もバインドで安全取得
  3. 識別子の動的化はDBMS_ASSERT+ホワイトリストの二段ガード
  4. DDLはAUTHID CURRENT_USER+操作・対象の二重ホワイトリスト
  5. 動的SQL実行をラッパーに集約しUnified Auditingまたは独自監査ログを残す
  6. カーソル共有でパース削減のおまけが付く(性能と安全の両立)
  7. 古典攻撃ペイロード10種を入れた単体テストをリリース条件に組み込む

レガシーコードの動的SQLを順次セキュア化していくときは、まずWHERE値の文字列連結を片端からUSING句に置換、次に識別子の動的化がある関数だけホワイトリスト経由に統一、最後にDDL実行系をラッパー集約、の3ステップで進めるとリスクの大きい順から潰せます。本記事を実装テンプレとして自プロジェクトの動的SQLを総点検してみてください。