BTP

Dynamic SQL — 90%가 Injection 취약 #shorts #SAP #HANA

▶ YouTube에서 보기

SAP HANA SQLScript의 EXEC 문은 강력한 기능이지만, 잘못 사용하면 SQL Injection 공격에 그대로 노출됩니다. 이 글은 실무에서 발생하는 취약 패턴을 재현하고, 안전하게 Dynamic SQL을 설계하는 방법을 단계별로 설명합니다.

Dynamic SQL의 동작 원리

일반적인 SQLScript는 컴파일 시점에 SQL 구조가 확정됩니다. 반면 Dynamic SQL은 런타임에 문자열로 SQL을 조립해서 실행합니다. HANA에서는 EXEC 또는 EXECUTE IMMEDIATE 구문으로 이를 구현합니다.

Dynamic SQL이 필요한 상황은 분명히 존재합니다. 컬럼명을 파라미터로 받아야 하거나, 조건절의 개수가 런타임에 결정되거나, 테이블명 자체가 동적으로 변경되는 경우입니다. 문제는 이 유연함을 가능하게 하는 바로 그 메커니즘이 SQL Injection의 문을 열어준다는 점입니다.

-- 기본 EXEC 구문
DECLARE v_stmt  NVARCHAR(1000);
DECLARE v_result NVARCHAR(200);

v_stmt := 'SELECT PROD_NAME FROM PRODUCTS WHERE PROD_ID = ' || :p_id;
EXEC v_stmt INTO v_result;

겉보기에 문제없어 보이지만, p_id에 어떤 값이 들어오느냐에 따라 전혀 다른 SQL이 실행됩니다.

취약한 패턴 재현 — SQL Injection 시나리오

입력된 직원 ID로 급여를 조회하는 프로시저입니다. 실무에서 자주 볼 수 있는 패턴입니다:

CREATE OR REPLACE PROCEDURE GET_EMPLOYEE_SALARY(
    IN p_emp_id NVARCHAR(50),
    OUT p_result NVARCHAR(500)
)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE v_sql NVARCHAR(2000);

    -- 취약한 구현: 입력값을 직접 문자열 연결
    v_sql := 'SELECT SALARY FROM HR_EMPLOYEES WHERE EMP_ID = '''
             || :p_emp_id || '''';
    EXEC v_sql INTO p_result;
END;

정상 호출:

CALL GET_EMPLOYEE_SALARY('EMP-1042', ?);
-- 실행: SELECT SALARY FROM HR_EMPLOYEES WHERE EMP_ID = 'EMP-1042'

악의적 입력:

-- 전체 데이터 탈취
CALL GET_EMPLOYEE_SALARY(''' OR ''1''=''1', ?);
-- 실행: SELECT SALARY FROM HR_EMPLOYEES WHERE EMP_ID = '' OR '1'='1'
-- → 모든 직원 급여 반환!

-- DDL 주입 — 테이블 파괴
CALL GET_EMPLOYEE_SALARY('''; DROP TABLE HR_EMPLOYEES; --', ?);

HANA SQLScript의 EXEC는 세미콜론으로 복수 구문을 실행할 수 있어, DDL 주입이 그대로 실행됩니다.

Bind Parameter로 안전하게 재작성

SQL Injection의 근본적인 해결책은 Bind Parameter입니다. 값을 SQL 문자열에 직접 삽입하는 대신, 자리표시자(?)를 두고 USING 절로 값을 바인딩합니다:

CREATE OR REPLACE PROCEDURE GET_EMPLOYEE_SALARY_SAFE(
    IN p_emp_id NVARCHAR(50),
    OUT p_result NVARCHAR(500)
)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE v_sql NVARCHAR(2000);

    -- 안전: ? 자리표시자 + USING 절
    v_sql := 'SELECT SALARY FROM HR_EMPLOYEES WHERE EMP_ID = ?';
    EXEC v_sql INTO p_result USING p_emp_id;
END;

USING 절에 전달된 값은 SQL 문법으로 해석되지 않고 순수 데이터로 처리됩니다. 아무리 악의적인 문자열이 들어와도 SQL 구조는 변경되지 않습니다.

복수 파라미터를 바인딩할 때는 쉼표로 구분해서 자리표시자 순서대로 나열합니다:

CREATE OR REPLACE PROCEDURE SEARCH_ORDERS_BY_RANGE(
    IN p_from_date DATE,
    IN p_to_date   DATE,
    IN p_status    NVARCHAR(20),
    OUT p_count    INTEGER
)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE v_sql NVARCHAR(2000);

    v_sql := 'SELECT COUNT(*) FROM SALES_ORDERS '
          || 'WHERE ORDER_DATE BETWEEN ? AND ? '
          || 'AND STATUS = ?';

    EXEC v_sql INTO p_count USING p_from_date, p_to_date, p_status;
END;

자리표시자 순서와 USING 절의 인자 순서가 1:1로 매핑됩니다. IN 파라미터 타입은 실제 컬럼 타입과 반드시 일치시켜야 런타임 오류를 예방할 수 있습니다.

동적 식별자 처리 — 화이트리스트와 Sanitization

Bind Parameter는 값(value)에만 적용됩니다. 컬럼명이나 테이블명 같은 식별자(identifier)는 바인딩이 불가합니다. 식별자를 동적으로 처리해야 할 때는 두 가지 방법을 사용합니다.

첫 번째는 화이트리스트(allowlist) 패턴입니다. 허용된 식별자 목록에서만 통과시킵니다:

CREATE OR REPLACE PROCEDURE GET_INVENTORY_FIELD(
    IN p_column_name NVARCHAR(50),
    IN p_item_id     NVARCHAR(30),
    OUT p_result     NVARCHAR(500)
)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE v_sql      NVARCHAR(2000);
    DECLARE v_safe_col NVARCHAR(50);

    IF :p_column_name = 'QUANTITY' THEN
        v_safe_col := 'QUANTITY';
    ELSEIF :p_column_name = 'UNIT_PRICE' THEN
        v_safe_col := 'UNIT_PRICE';
    ELSEIF :p_column_name = 'WAREHOUSE_LOC' THEN
        v_safe_col := 'WAREHOUSE_LOC';
    ELSE
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = 'Invalid column: ' || :p_column_name;
    END IF;

    v_sql := 'SELECT ' || :v_safe_col
          || ' FROM INVENTORY WHERE ITEM_ID = ?';
    EXEC v_sql INTO p_result USING p_item_id;
END;

두 번째는 정규식 기반 Sanitization 함수입니다. 허용 컬럼이 많을 때 적합합니다:

CREATE OR REPLACE FUNCTION SANITIZE_IDENTIFIER(
    IN p_input NVARCHAR(100)
) RETURNS NVARCHAR(100)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE v_clean NVARCHAR(100);

    -- 영문자, 숫자, 언더스코어만 허용
    v_clean := REGEXP_REPLACE(:p_input, '[^A-Za-z0-9_]', '');

    IF LENGTH(:v_clean) = 0 OR SUBSTRING(:v_clean, 1, 1) BETWEEN '0' AND '9' THEN
        SIGNAL SQLSTATE '45000'
            SET MESSAGE_TEXT = 'Identifier sanitization failed: ' || :p_input;
    END IF;

    RETURN :v_clean;
END;

식별자는 반드시 큰따옴표(")로 감싸는 것이 추가 안전장치가 됩니다. 큰따옴표 안에서는 SQL 예약어도 식별자로 처리되어 예약어 충돌 공격을 차단합니다.

APPLY_FILTER — 제한된 환경에서의 대안

HANA에는 테이블 변수에 동적 필터를 적용하는 APPLY_FILTER 함수가 있습니다. DML 및 DDL 구문을 포함할 수 없어 공격 표면이 좁습니다:

CREATE OR REPLACE PROCEDURE FILTER_ACTIVE_PRODUCTS(
    IN p_filter_expr NVARCHAR(500),
    OUT p_results    TABLE(PROD_ID NVARCHAR(20), PROD_NAME NVARCHAR(100), PRICE DECIMAL(15,2))
)
LANGUAGE SQLSCRIPT AS
BEGIN
    DECLARE tv_base TABLE(PROD_ID NVARCHAR(20), PROD_NAME NVARCHAR(100), PRICE DECIMAL(15,2));

    tv_base = SELECT PROD_ID, PROD_NAME, PRICE
              FROM PRODUCT_CATALOG
              WHERE IS_ACTIVE = 1;

    -- p_filter_expr 예: 'PRICE > 10000 AND PRICE < 50000'
    p_results = APPLY_FILTER(:tv_base, :p_filter_expr);
END;

테이블 변수에만 적용 가능하며, INSERT/UPDATE/DELETE/DDL은 실행 불가합니다. 일반 EXEC보다 안전하지만 완전한 방어는 아니므로 입력값 검증은 여전히 필요합니다.

성능 최적화 — 실행 계획 재사용

Dynamic SQL은 보안뿐 아니라 성능 문제도 야기합니다. 매번 다른 SQL 문자열이 생성되면 HANA가 실행 계획(Execution Plan)을 캐시할 수 없어 파싱 비용이 반복 발생합니다. Bind Parameter를 사용하면 SQL 구조가 동일하게 유지되어 실행 계획이 재사용됩니다:

-- 나쁜 패턴: 루프마다 다른 SQL → 계획 캐시 미스
FOR i IN 1 .. 1000 DO
    v_sql := 'SELECT PRICE FROM CATALOG WHERE SKU = ''' || :v_sku_arr[i] || '''';
    EXEC v_sql INTO v_price;
END FOR;

-- 좋은 패턴: 구조 고정 + 바인딩 → 계획 재사용
v_sql := 'SELECT PRICE FROM CATALOG WHERE SKU = ?';
FOR i IN 1 .. 1000 DO
    EXEC v_sql INTO v_price USING :v_sku_arr[i];
END FOR;

M_SQL_PLAN_CACHE 뷰에서 PLAN_SHARING_TYPE = 'SHARED'를 확인하면 실행 계획이 실제로 재사용되고 있는지 검증할 수 있습니다.

권한 최소화와 SECURITY 모드 설계

Dynamic SQL을 포함하는 프로시저는 권한 최소화(Principle of Least Privilege)를 반드시 적용해야 합니다. 실행 계정에 DDL 권한이 있다면 SQL Injection 성공 시 테이블 삭제나 스키마 변조까지 가능해집니다:

-- 실행 역할에 SELECT만 부여, DDL 권한 제외
GRANT SELECT ON HR_EMPLOYEES TO PROC_EXEC_ROLE;
GRANT SELECT ON SALES_ORDERS  TO PROC_EXEC_ROLE;

-- 기본 DEFINER SECURITY: 소유자 권한으로 실행
-- 소유자 계정 권한이 공격 경계가 됨

-- 호출자 권한이 필요한 경우 명시적으로 INVOKER 선언
CREATE OR REPLACE PROCEDURE AUDIT_LOG_QUERY(...)
LANGUAGE SQLSCRIPT
SQL SECURITY INVOKER
AS BEGIN
    ...
END;

기본 DEFINER SECURITY 모드에서는 호출자의 권한이 아닌 프로시저 소유자의 권한이 적용됩니다. 소유자 계정 권한이 곧 공격 경계이므로, 프로덕션에서는 소유자 계정에 SELECT 이상의 권한을 부여하지 않도록 합니다.

Production 배포 전 필수 점검

Dynamic SQL 프로시저를 배포하기 전 다음 항목을 반드시 확인합니다.

첫째, 모든 값 파라미터가 EXEC ... USING 바인딩으로 전달되고 있는가. 문자열 연결(||)로 값이 삽입되는 경로가 단 하나라도 있다면 취약합니다.

둘째, 동적 식별자를 처리한다면 화이트리스트 분기 또는 REGEXP_REPLACE 기반 정제 함수를 반드시 거쳐야 합니다. 외부 입력이 식별자로 직접 들어가는 경로는 없어야 합니다.

셋째, 잘못된 입력에 대해 SIGNAL SQLSTATE '45000'으로 즉시 예외를 발생시키는 가드 로직이 있어야 합니다. 조용히 빈 결과를 반환하거나 오류를 무시하는 패턴은 공격자에게 유리합니다.

넷째, 프로시저 소유자 및 실행자 계정에 DDL 권한이 없어야 합니다. M_SQL_PLAN_CACHE에서 실행 계획 재사용률도 함께 검증합니다.

이 네 가지를 충족하면 HANA Dynamic SQL의 SQL Injection 위험은 실질적으로 제거됩니다. EXEC는 강력하지만, 방어 패턴 없이 배포하는 순간 데이터베이스 전체가 공격 표면이 됩니다.

댓글 0

아직 댓글이 없습니다.