SQL 인젝션
SQL 인젝션은 사용자가 넣은 값이 데이터가 아니라 질의문의 일부로 실행되는 실패입니다. 프로그램이 입력을 문자열로 이어 붙여 질의문을 만들 때 생깁니다. 공격자는 그 자리에 따옴표 하나를 밀어 넣어 원래 질의의 뜻을 바꿉니다. 결과가 어긋난 값 하나로 끝나지 않고 표 전체가 나가기도 합니다.
상세
SQL(Structured Query Language, 구조화 질의 언어)은 데이터베이스에 보내는 명령을 글자로 적는 언어입니다. 프로그램은 이 명령을 대개 문자열로 조립해서 보냅니다. 문제는 조립 재료에 사용자 입력이 섞일 때 시작됩니다.
CWE(Common Weakness Enumeration, 공통 약점 목록)의 89번 항목은 이 실패를 이렇게 적습니다. 제품은 상위 구성요소에서 온 외부 영향 입력으로 SQL 명령의 전부나 일부를 구성합니다. 그런데 의도한 SQL 명령을 바꿀 수 있는 특수 요소를 중화하지 않거나 잘못 중화합니다. 사용자가 통제할 수 있는 입력에서 SQL 구문을 충분히 제거하거나 인용부호로 감싸지 않으면, 만들어진 질의는 그 입력을 평범한 사용자 데이터가 아니라 SQL 로 해석하게 됩니다.
한 줄로 줄이면 이렇습니다. 데이터와 코드가 같은 문자열에 섞입니다. 문자열이 완성되어 서버에 도착하는 순간, 어디까지가 개발자가 쓴 구문이고 어디부터가 사용자가 넣은 값인지 구별할 근거가 사라집니다. 서버는 도착한 글자를 그냥 파싱합니다.
OWASP(Open Worldwide Application Security Project)는 같은 것을 공격자 쪽에서 적습니다.
애플리케이션이 문자열 연결과 사용자 제공 입력으로 동적 데이터베이스 질의를 만들면 공격자가 SQL 인젝션을 쓸 수 있다는 것입니다.
CWE-89 는 SQL injection 을 공격 관점의 통용어로, SQLi 를 그 줄임말로 함께 적어 둡니다.
발생 조건
두 가지가 동시에 성립할 때 터집니다.
- 질의문을 문자열 연결로 만든다. 값 자리에 사용자 입력을 그대로 붙인다
- 붙는 값이 그 값 자리의 구문 경계를 넘는다
앞의 것은 OWASP 가 공격 성립 조건으로 세운 둘 그대로입니다. 문자열 연결, 그리고 사용자 제공 입력입니다. 뒤의 것은 그 값이 값으로만 남지 않는다는 뜻입니다. 경계를 넘는 글자가 무엇인지는 값 자리의 모양에 달렸습니다. 작은따옴표로 감싼 자리라면 작은따옴표 하나가 그 자리를 닫습니다. 수치 컬럼처럼 따옴표 없이 값이 들어가는 자리라면 이어 붙인 글자가 그대로 구문이 됩니다.
flowchart TD
A[사용자 입력] --> B{문자열 연결로 질의문을 만드나}
B -->|아니오. 플레이스홀더로 보낸다| S[값으로만 다뤄진다]
B -->|예| C{입력이 값 자리의 구문 경계를 넘나}
C -->|아니오| S
C -->|예| D[값 자리가 닫히고 뒤가 구문이 된다]
D --> E[입력의 일부가 SQL 로 실행된다]
둘 중 하나가 빠지면 입력은 값 자리에 머뭅니다. 그 선이 이 문서의 검증입니다.
구문 경계를 넘지 않는 입력
CWE-89 의 예제가 이 선을 그대로 적습니다.
기본 질의 문자열과 사용자 입력 문자열을 이어 붙여 질의를 만들었으므로, 그 질의는 itemName 에 작은따옴표 문자가 들어 있지 않을 때만 의도대로 동작한다는 문장입니다.
그 예제의 값 자리는 작은따옴표로 감싸여 있습니다.
따옴표가 없는 입력은 값 자리에 얌전히 앉습니다.
따옴표 하나가 들어오는 순간 값 자리가 닫히고 그 뒤가 구문이 됩니다.
문자열 밖으로 전달되는 값
값을 문자열에 박지 않고 따로 보내면 됩니다. PHP 매뉴얼은 프리페어드 스테이트먼트의 매개변수는 인용부호로 감쌀 필요가 없고 드라이버가 알아서 처리한다고 적습니다. 그리고 애플리케이션이 프리페어드 스테이트먼트만 쓴다면 개발자는 SQL 인젝션이 일어나지 않으리라 확신할 수 있다고 덧붙입니다. 다만 같은 문장이 단서를 답니다. 질의의 다른 부분을 이스케이프하지 않은 입력으로 조립하고 있다면 SQL 인젝션은 여전히 가능하다는 것입니다.
PostgreSQL 문서도 같은 자리를 짚습니다.
PL/pgSQL 의 EXECUTE 에서 명령 문자열은 $1 · $2 같은 매개변수 값을 참조할 수 있고, 이 기호들은 USING 절에 넘긴 값을 가리킵니다.
문서는 이 방식이 데이터 값을 텍스트로 명령 문자열에 끼워 넣는 것보다 나은 경우가 많다고 적습니다.
값을 텍스트로 바꿨다가 되돌리는 실행 시점 비용이 없고, 인용이나 이스케이프가 필요 없어 SQL 인젝션 공격에 훨씬 덜 취약하다는 이유입니다.
EXECUTE 'SELECT count(*) FROM mytable WHERE inserted_by = $1 AND inserted <= $2'
INTO c
USING checked_user, checked_date;
checked_user 가 어떤 글자를 담고 있든 이 문자열의 구문은 변하지 않습니다.
값은 명령 문자열 밖에서 따로 전달됩니다.
예시
CWE-89 의 C# 예제
CWE-89 는 인증된 사용자의 이름과 물품명으로 항목을 찾는 코드를 취약 코드로 싣습니다.
string userName = ctx.getAuthenticatedUserName();
string query = "SELECT * FROM items WHERE owner = '"
+ userName + "' AND itemname = '" + ItemName.Text + "'";
sda = new SqlDataAdapter(query, conn);
ItemName.Text 가 평범한 물품명이면 의도대로 돕니다.
사용자 이름이 wiley 인 공격자가 물품명 자리에 이 값을 넣으면 달라집니다.
name' OR 'a'='a
완성된 질의는 이렇게 됩니다.
SELECT * FROM items WHERE owner = 'wiley' AND itemname = 'name' OR 'a'='a';
붙은 OR 'a'='a 때문에 WHERE 절이 언제나 참이 됩니다.
질의는 논리적으로 SELECT * FROM items; 와 같아집니다.
인증된 사용자가 소유한 항목만 돌려준다는 제약이 사라지고, items 표에 저장된 항목 전체가 나갑니다.
Python sqlite3 문서의 재현
Python 공식 문서는 같은 일을 대화형 셸에서 그대로 재현해 보입니다.
문서가 붙인 주석이 Never do this -- insecure! 입니다.
>>> # Never do this -- insecure!
>>> symbol = input()
' OR TRUE; --
>>> sql = "SELECT * FROM stocks WHERE symbol = '%s'" % symbol
>>> print(sql)
SELECT * FROM stocks WHERE symbol = '' OR TRUE; --'
>>> cur.execute(sql)
문서는 공격자가 작은따옴표를 그냥 닫고 OR TRUE 를 밀어 넣어 모든 행을 고를 수 있다고 적습니다.
입력값 ' OR TRUE; -- 의 앞머리 작은따옴표가 symbol 의 값 자리를 닫습니다.
완성된 문자열에서 그 자리는 빈 문자열입니다.
그 뒤로 OR TRUE 가 조건 자리에 들어와 있습니다.
문서는 대신 DB-API(Database API, 데이터베이스 접속 규격)의 매개변수 치환을 쓰라고 적습니다.
params = (1972,)
cur.execute("SELECT * FROM lang WHERE first_appeared = ?", params)
? 자리에 값을 튜플로 따로 넘깁니다. 질의 문자열은 변수와 무관하게 고정됩니다.
Django 의 raw() 경고
Django 공식 문서는 원시 질의에 문자열 포매팅을 쓰거나 플레이스홀더에 인용부호를 두르지 말라고 경고합니다. 문서가 나란히 실은 두 가지 잘못은 이렇습니다.
>>> query = "SELECT * FROM myapp_person WHERE last_name = %s" % lname
>>> Person.objects.raw(query)
>>> query = "SELECT * FROM myapp_person WHERE last_name = '%s'"
문서는 params 인자를 쓰고 플레이스홀더를 인용부호로 감싸지 않는 것이 SQL 인젝션을 막아 준다고 적습니다.
문자열 보간을 쓰거나 플레이스홀더에 따옴표를 두르면 SQL 인젝션 위험에 놓인다는 것입니다.
>>> lname = "Doe"
>>> Person.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [lname])
경계
플레이스홀더를 썼으면 이 문제는 끝난 것인가. 아닙니다. 매개변수화가 덮는 자리는 값뿐입니다. 표 이름과 컬럼 이름은 덮이지 않습니다.
psycopg 3 문서가 이 선을 못 박습니다.
이 방식으로 바인딩해야 하는 것은 질의의 값뿐이고, 표 이름이나 필드 이름을 질의에 합치는 데 써서는 안 된다는 것입니다.
실행 시점에 표 이름을 고르는 것처럼 질의를 동적으로 만들어야 하면 psycopg.sql 모듈의 기능을 쓰라고 안내합니다.
cur.execute("INSERT INTO %s VALUES (%s)", ('numbers', 10)) # WRONG
cur.execute( # correct
SQL("INSERT INTO {} VALUES (%s)").format(Identifier('numbers')),
(10,))
PostgreSQL 문서도 같은 판정을 내립니다.
매개변수 기호는 데이터 값에만 쓸 수 있고, 표나 컬럼 이름을 실행 시점에 정하려면 명령 문자열에 텍스트로 끼워 넣어야 한다는 것입니다.
그래서 그 자리에는 다른 도구를 씁니다.
컬럼이나 표 식별자를 담은 표현식은 동적 질의에 넣기 전에 quote_ident 를 통과시키라고 적습니다.
만들어진 명령 안에서 리터럴 문자열이 되어야 할 값은 quote_literal 을 통과시키라고 적습니다.
format() 의 %I 지정자로 표·컬럼 이름을 자동 인용해 넣는 방법도 함께 제시합니다.
OWASP 는 이 자리를 아예 별도 방어 항목으로 세워 둡니다. 표 이름 · 컬럼 이름 · 정렬 방향처럼 바인드 변수를 쓸 수 없는 부분이 있으면 입력 검증이나 질의 재설계가 가장 알맞은 방어라는 것입니다. 표나 컬럼 이름이 필요할 때 그 값은 이상적으로는 사용자 매개변수가 아니라 코드에서 와야 한다고 덧붙입니다.
관련 항목
값을 구문과 분리하는 수단
프리페어드 스테이트먼트 · 매개변수화 쿼리 · 바인드 변수 · 플레이스홀더 · quote_ident · quote_literal · sql.Identifier · format()
매개변수화가 못 닿는 자리를 막는 방어
저장 프로시저 · 이스케이프 · 허용 목록 입력 검증 · 최소 권한 원칙 · 웹 애플리케이션 방화벽
이 실패를 나누는 갈래와 확인 기법
인밴드 · 아웃오브밴드 · 블라인드 SQL 인젝션 · 유니온 기반 SQL 인젝션 · 불리언 기반 SQL 인젝션 · 에러 기반 SQL 인젝션 · 시간 지연 기반 SQL 인젝션
이 문제가 일어나는 언어와 시스템
SQL · 동적 SQL · 데이터베이스 · PL/pgSQL
이 문제의 방어를 구현·채택한 언어와 도구
PostgreSQL · PHP · Python · Django · psycopg 3 · ORM(Object-Relational Mapping, 객체 관계 매핑)
이 문제를 정의·분류하는 표준·문서
CWE · OWASP · OWASP Top 10 · OWASP WSTG(OWASP Web Security Testing Guide, 웹 보안 테스트 가이드)
다른 이름: SQL injection · SQLi