MSSQL 4.9 · 4부. T-SQL 프로그래밍

동적 SQL 과 SQL 주입

sp_executesql 로 문장을 만들고, 매개 변수화가 무엇을 막는지 봅니다. 실습 데이터베이스에서 실제로 뚫어 보입니다.

예상 학습 시간 22분 난이도 고급
개념 설명

문장을 만들어서 실행합니다

4.3 에서 선택적 조건을 @p IS NULL OR 열 = @p 로 적었습니다. 그런데 매개 변수로 넘길 수 없는 것들이 있습니다 — 정렬할 열 이름, 조회할 표 이름 같은 것입니다.

그런 자리에서는 문장 자체를 문자열로 만들어 실행합니다. 이것을 동적 SQL 이라고 하고, 방법이 둘입니다.

방법매개 변수계획 재사용
EXEC (@sql)넘길 수 없음안 됨
sp_executesql넘길 수 있음

이 차이가 이 단원의 전부입니다. 무엇을 매개 변수로 넘기고 무엇을 이어 붙일지가 보안과 성능을 함께 정합니다.

주입

이어 붙이면 문장이 바뀝니다

아래는 막는 방법을 배우기 위한 예제입니다. 실습 데이터베이스에서만 실행하십시오. 남의 시스템에 시도하는 것은 범죄입니다.

제목으로 글을 찾는 프로시저를 문자열을 이어 붙여 만들어 봅니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_SEARCH_BAD
    @keyword nvarchar(100)
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @sql nvarchar(max) =
        N'SELECT COUNT(*) AS 건수 FROM Board.POSTS WHERE title LIKE N''%' + @keyword + N'%''';
    PRINT @sql;                 -- 만들어진 문장을 봅니다
    EXEC (@sql);
END
GO
EXEC Board.P_SEARCH_BAD @keyword = N'인덱스';              -- (가)
EXEC Board.P_SEARCH_BAD @keyword = N''' OR 1=1 --';      -- (나)
결과 (가) — 정상
SELECT COUNT(*) AS 건수 FROM Board.POSTS WHERE title LIKE N'%인덱스%' 건수 ------ 17
결과 (나) — 따옴표를 넣으면
SELECT COUNT(*) AS 건수 FROM Board.POSTS WHERE title LIKE N'%' OR 1=1 --%' 건수 ------ 507
조건이 통째로 바뀌었습니다. 뒤의 %' 는 -- 뒤로 밀려 주석이 되었습니다.

넘긴 값이 값으로 다뤄지지 않고 문장의 일부가 되었습니다. 따옴표 하나로 LIKE 조건을 닫고, OR 1=1 을 붙이고, -- 로 나머지를 주석 처리했습니다.

읽는 데서 끝나지 않습니다. 다른 표의 자료를 실어 낼 수 있습니다.

SQL
-- 같은 방식으로 만든, 목록을 내는 프로시저입니다. 열을 둘 냅니다.
CREATE OR ALTER PROCEDURE Board.P_SEARCH_BAD2
    @keyword nvarchar(100)
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @sql nvarchar(max) =
        N'SELECT TOP 3 num, title FROM Board.POSTS WHERE title LIKE N''%' + @keyword + N'%''';
    PRINT @sql;
    EXEC (@sql);
END
GO

EXEC Board.P_SEARCH_BAD2
     @keyword = N''' UNION SELECT num, user_id FROM Member.USERS --';
만들어진 문장
SELECT TOP 3 num, title FROM Board.POSTS WHERE title LIKE N'%' UNION SELECT num, user_id FROM Member.USERS --%'
결과
numtitle
1hong
1조인 질문드립니다
2kim
2트랜잭션 질문드립니다
3lee
3.8 에서 Member 스키마로 나눠 둔 회원 아이디가 글 제목 자리에 실려 나왔습니다.

프로시저가 읽을 수 있는 것은 무엇이든 나갈 수 있습니다. 4.2 에서 프로시저에만 권한을 준 것이 여기서 무너집니다 — 프로시저는 자기 권한으로 Member.USERS 를 읽을 수 있고, 주입된 문장도 그 권한으로 돕니다.

SELECT 만 위험한 것이 아닙니다. 세미콜론으로 문장을 끊고 UPDATEDROP 을 이어 붙일 수도 있습니다. 따옴표를 두 개로 바꿔 막으려는 시도는 권하지 않습니다 — 빠뜨리는 자리가 반드시 생기고, 숫자 매개 변수에는 따옴표가 아예 필요 없어 그 방어가 통하지 않습니다.

방어

값은 매개 변수로 넘깁니다

sp_executesql 은 문장과 매개 변수를 나누어 받습니다. 넘긴 값은 문장이 될 수 없습니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_SEARCH_OK
    @keyword nvarchar(100)
AS
BEGIN
    SET NOCOUNT ON;

    -- 값이 들어갈 자리에 @kw 라고만 적어 둡니다.
    DECLARE @sql nvarchar(max) =
        N'SELECT COUNT(*) AS 건수 FROM Board.POSTS WHERE title LIKE N''%'' + @kw + N''%''';

    -- 문장 · 매개 변수 선언 · 값을 따로 넘깁니다.
    EXEC sys.sp_executesql @sql, N'@kw nvarchar(100)', @kw = @keyword;
END
GO
EXEC Board.P_SEARCH_OK @keyword = N'인덱스';
EXEC Board.P_SEARCH_OK @keyword = N''' OR 1=1 --';
EXEC Board.P_SEARCH_OK @keyword = N''' UNION SELECT num, user_id FROM Member.USERS --';
결과
넘긴 값건수
인덱스17
' OR 1=1 --0
' UNION SELECT … --0
주입 문자열이 그대로 낱말로 다뤄져 그런 제목을 찾다가 0 건이 나왔습니다.

문장은 이미 컴파일되어 있고 값만 나중에 채워집니다. 그래서 값 안에 무엇이 들어 있든 문장 구조가 바뀌지 않습니다. 응용 프로그램에서 ADO.NET 매개 변수를 사용하는 것도 같은 원리입니다.

이름

매개 변수로 넘길 수 없는 것

값은 매개 변수로 넘기면 됩니다. 그런데 열 이름과 표 이름은 넘길 수 없습니다.

SQL
DECLARE @col nvarchar(50) = N'hit_count';
DECLARE @sql nvarchar(max) =
    N'SELECT TOP 3 num, title, hit_count FROM Board.POSTS ORDER BY @c DESC';
EXEC sys.sp_executesql @sql, N'@c nvarchar(50)', @c = @col;
오류
메시지 1008 The SELECT item identified by the ORDER BY number 1 contains a variable as part of the expression identifying a column position. …
매개 변수는 값이 오는 자리에만 놓을 수 있습니다.

이름은 이어 붙일 수밖에 없습니다. 그러면 QUOTENAME 으로 감쌉니다. 받은 문자열을 대괄호로 묶어 통째로 하나의 이름이 되게 합니다.

SQL
DECLARE @col nvarchar(50) = N'hit_count';
DECLARE @sql nvarchar(max) =
    N'SELECT TOP 3 num, title, hit_count FROM Board.POSTS ORDER BY '
    + QUOTENAME(@col) + N' DESC';
PRINT @sql;
EXEC sys.sp_executesql @sql;
결과
SELECT TOP 3 num, title, hit_count FROM Board.POSTS ORDER BY [hit_count] DESC
numtitlehit_count
214윈도 함수 질문드립니다498
71외래 키 실무에서 겪은 일497
285실행 계획 초보자가 자주 하는 실수495

공격 문자열을 넣어도 대괄호 안에 통째로 들어가 그런 이름의 열을 찾다가 실패합니다.

SQL
DECLARE @col nvarchar(200) = N'hit_count DESC; DROP TABLE Board.T_X --';
SELECT QUOTENAME(@col) AS QUOTENAME결과;
결과
[hit_count DESC; DROP TABLE Board.T_X --] 메시지 207 Invalid column name 'hit_count DESC; DROP TABLE Board.T_X --'.
DROP 이 실행되지 않았습니다. 열 이름으로만 해석되어 207 로 끝났습니다.

더 확실한 방법은 허용 목록입니다. 받을 수 있는 이름을 미리 정해 두고 그 밖은 거절합니다.

SQL
DECLARE @col nvarchar(50) = N'hit_count';   -- 화면에서 받은 값입니다

IF @col NOT IN (N'num', N'title', N'hit_count', N'reg_date')
    THROW 50005, N'정렬할 수 없는 열입니다', 1;

DECLARE @sql nvarchar(max) =
    N'SELECT TOP 3 num, title, hit_count FROM Board.POSTS ORDER BY '
    + QUOTENAME(@col) + N' DESC';
EXEC sys.sp_executesql @sql;

둘을 함께 사용하십시오. 허용 목록이 무엇을 받을지 정하고, QUOTENAME 이 혹시 새는 것을 막습니다. 목록을 늘릴 때 실수하더라도 두 번째 방어가 남습니다.

그 밖

보안 말고도 갈리는 것 셋

계획 캐시

4.2 에서 값만 다른 문장이 캐시를 여러 칸 차지하는 것을 보았습니다. 동적 SQL 에서도 그대로입니다.

SQL
DBCC FREEPROCCACHE;                      -- 실습 데이터베이스에서만
GO
EXEC Board.P_SEARCH_BAD @keyword = N'인덱스';    -- 이어 붙인 쪽
EXEC Board.P_SEARCH_BAD @keyword = N'조인';
EXEC Board.P_SEARCH_BAD @keyword = N'트랜잭션';
EXEC Board.P_SEARCH_OK  @keyword = N'인덱스';    -- 매개 변수화한 쪽
EXEC Board.P_SEARCH_OK  @keyword = N'조인';
EXEC Board.P_SEARCH_OK  @keyword = N'트랜잭션';
GO
SELECT cp.objtype AS 종류, cp.usecounts AS 사용횟수
FROM sys.dm_exec_cached_plans cp
    CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) st
WHERE st.text LIKE '%Board.POSTS WHERE title LIKE%';
결과
종류사용횟수무엇
Adhoc1'%인덱스%' 가 박힌 문장
Adhoc1'%조인%' 가 박힌 문장
Adhoc1'%트랜잭션%' 이 박힌 문장
Prepared3(@kw nvarchar(100)) … 한 벌
매개 변수화는 보안만의 문제가 아닙니다.

소유권 체인이 끊깁니다

4.2 의 가장 큰 이점은 표 권한 없이 프로시저만 실행하게 할 수 있다는 것이었습니다. 동적 SQL 안에서는 그것이 통하지 않습니다.

SQL
-- 같은 일을 하는 프로시저 둘. 표 권한은 주지 않고 실행 권한만 줍니다.
CREATE OR ALTER PROCEDURE Board.P_STATIC AS
BEGIN SET NOCOUNT ON; SELECT COUNT(*) AS 글수 FROM Board.POSTS; END
GO
CREATE OR ALTER PROCEDURE Board.P_DYNAMIC AS
BEGIN SET NOCOUNT ON;
    EXEC sys.sp_executesql N'SELECT COUNT(*) AS 글수 FROM Board.POSTS';
END
GO

-- 표 권한이 없는 사용자를 하나 만들어 실행 권한만 줍니다.
CREATE USER dyn_user WITHOUT LOGIN;
GRANT EXECUTE ON Board.P_STATIC  TO dyn_user;
GRANT EXECUTE ON Board.P_DYNAMIC TO dyn_user;
GO

EXECUTE AS USER = 'dyn_user';
EXEC Board.P_STATIC;
EXEC Board.P_DYNAMIC;
REVERT;
결과 — 문장을 박아 둔 쪽
글수
507
오류 — 동적 SQL 을 쓴 쪽
메시지 229 The SELECT permission was denied on the object 'POSTS', database 'MssqlLab', schema 'Board'.
동적 SQL 은 별도의 배치로 실행되어 소유권 체인이 이어지지 않습니다.

해결하려면 그 사용자에게 표 권한을 주거나(4.2 의 이점을 잃습니다) 프로시저에 WITH EXECUTE AS OWNER 를 붙입니다. 뒤쪽이 낫지만, 그렇게 하면 그 프로시저 안의 모든 것이 소유자 권한으로 도므로 주입이 뚫렸을 때의 피해도 커집니다.

유니코드 리터럴을 잃습니다

SQL
DECLARE @sql1 nvarchar(max) = N'SELECT COUNT(*) FROM Board.POSTS WHERE title LIKE ''%인덱스%''';
DECLARE @sql2 nvarchar(max) = N'SELECT COUNT(*) FROM Board.POSTS WHERE title LIKE N''%인덱스%''';
EXEC (@sql1);
EXEC (@sql2);
결과
안쪽 리터럴건수
'%인덱스%' (N 없음)0
N'%인덱스%'17
바깥 문자열이 nvarchar 여도 안쪽 리터럴은 따로입니다.

동적 SQL 안의 문자열 리터럴에도 N 을 붙여야 합니다. 3.8 의 대조가 한글을 담지 못하는 자리라 조용히 0 건이 나옵니다. 매개 변수로 넘기면 이 문제 자체가 생기지 않습니다.

연습

직접 해보기

1. 취약한 프로시저를 고칩니다 난이도 하

아래 프로시저는 회원 아이디로 글을 찾습니다. 주입이 되는 자리를 찾고 고쳐 보세요. 고친 뒤에는 주입 문자열을 넣어도 아무것도 새지 않아야 합니다.

SQL
CREATE OR ALTER PROCEDURE Board.P_BY_USER_BAD
    @user_id varchar(50)
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @sql nvarchar(max) =
        N'SELECT COUNT(*) AS 글수 FROM Board.POSTS p
          JOIN Member.USERS u ON u.num = p.user_num
          WHERE u.user_id = ''' + @user_id + N'''';
    EXEC (@sql);
END
-- 값을 이어 붙인 것이 문제입니다. 애초에 동적 SQL 이 필요 없는 문장이지만, -- 필요하다고 가정하고 sp_executesql 로 고칩니다. CREATE OR ALTER PROCEDURE Board.P_BY_USER_OK @user_id varchar(50) AS BEGIN SET NOCOUNT ON; DECLARE @sql nvarchar(max) = N'SELECT COUNT(*) AS 글수 FROM Board.POSTS p JOIN Member.USERS u ON u.num = p.user_num WHERE u.user_id = @uid'; EXEC sys.sp_executesql @sql, N'@uid varchar(50)', @uid = @user_id; END GO EXEC Board.P_BY_USER_OK @user_id = 'hong'; -- 정상 EXEC Board.P_BY_USER_OK @user_id = ''' OR 1=1 --'; -- 0 건 -- 고치기 전에는 두 번째 호출이 전체 건수를 냅니다. -- 고친 뒤에는 그런 아이디를 찾다가 0 건이 됩니다. -- 사실 이 프로시저에는 동적 SQL 이 필요 없습니다. 문장이 고정이기 때문입니다. -- 동적 SQL 을 만들기 전에 "정말 문장이 달라져야 하는가" 를 먼저 물으십시오. -- 값만 달라진다면 매개 변수만으로 끝납니다(4.3).
2. 정렬 열을 받는 목록 프로시저 난이도 중

목록 화면에서 정렬 기준을 고를 수 있게 해야 합니다. 게시판 번호와 정렬 열, 그리고 선택적인 검색 낱말을 받는 프로시저를 만들어 보세요. 이 단원의 방어를 모두 적용하십시오.

값과 이름을 나누어 생각하십시오. 값은 매개 변수로, 이름은 허용 목록과 QUOTENAME 으로. 4.3 의 선택적 조건도 함께 필요합니다.
CREATE OR ALTER PROCEDURE Board.P_POST_LIST_SORT @board_num int, @order_by nvarchar(50) = N'reg_date', @keyword nvarchar(100) = NULL AS BEGIN SET NOCOUNT ON; -- (1) 이름은 허용 목록으로 거릅니다. IF @order_by NOT IN (N'num', N'title', N'hit_count', N'reg_date') THROW 50005, N'정렬할 수 없는 열입니다', 1; DECLARE @sql nvarchar(max) = N'SELECT TOP (5) num, title, hit_count, reg_date FROM Board.POSTS WHERE board_num = @b AND (@kw IS NULL OR title LIKE N''%'' + @kw + N''%'') ORDER BY ' + QUOTENAME(@order_by) + N' DESC -- (2) 그래도 QUOTENAME OPTION (RECOMPILE);'; -- (4) 선택적 조건이므로 -- (3) 값은 모두 매개 변수로 넘깁니다. EXEC sys.sp_executesql @sql, N'@b int, @kw nvarchar(100)', @b = @board_num, @kw = @keyword; END GO EXEC Board.P_POST_LIST_SORT @board_num = 1, @order_by = N'hit_count'; -- 70 제약 조건 실무에서 겪은 일 490 -- 140 인덱스 예제 모음 480 EXEC Board.P_POST_LIST_SORT @board_num = 1, @order_by = N'hit_count DESC; DROP TABLE x --'; -- 메시지 50005 정렬할 수 없는 열입니다 EXEC Board.P_POST_LIST_SORT @board_num = 1, @keyword = N''' OR 1=1 --'; -- 빈 결과 — 그런 제목이 없습니다 -- 넷을 모두 적용했습니다. -- 허용 목록 받을 이름을 미리 정합니다 -- QUOTENAME 혹시 새는 것을 막습니다 -- 매개 변수 값은 문장이 될 수 없습니다 -- RECOMPILE 선택적 조건이 인덱스를 사용하게 합니다(4.3) -- 안쪽 리터럴에 N 을 붙인 것도 잊지 마십시오. 없으면 한글 검색이 0 건이 됩니다.

이 단원에서 만든 것을 지웁니다.
DROP PROCEDURE Board.P_SEARCH_BAD, Board.P_SEARCH_BAD2, Board.P_SEARCH_OK,
Board.P_BY_USER_BAD, Board.P_BY_USER_OK, Board.P_POST_LIST_SORT,
Board.P_STATIC, Board.P_DYNAMIC;
DROP USER dyn_user;

요약
  • 동적 SQL 은 문장 자체가 달라져야 할 때만 사용합니다. 값만 달라진다면 매개 변수로 끝납니다(4.3).
  • 값을 이어 붙이면 넘긴 문자열이 문장의 일부가 됩니다. 따옴표 하나로 조건이 뒤집히고(507 건), UNION 으로 다른 표의 자료가 실려 나옵니다.
  • 값은 sp_executesql 의 매개 변수로 넘기십시오. 문장이 먼저 컴파일되므로 값 안에 무엇이 있든 구조가 바뀌지 않습니다.
  • 열 이름·표 이름은 매개 변수로 넘길 수 없습니다(1008). 허용 목록으로 거르고 QUOTENAME 으로 감싸십시오. 둘을 함께 씁니다.
  • 매개 변수화는 보안만의 문제가 아닙니다. 이어 붙이면 계획이 호출마다 새로 생기고(Adhoc 3 개), 매개 변수화하면 한 벌을 다시 사용합니다(Prepared 사용횟수 3).
  • 동적 SQL 안에서는 소유권 체인이 끊깁니다(229). 4.2 에서 얻은 이점을 잃습니다. WITH EXECUTE AS OWNER 로 이을 수 있지만 주입이 뚫렸을 때의 피해도 함께 커집니다.
  • 동적 SQL 안의 문자열 리터럴에도 N 을 붙이십시오. 없으면 한글 검색이 조용히 0 건이 됩니다.