저번주 SQL Injection에 대해 남들에게 설명할 기회가 생겼다. 이미 많은 곳에서 SQL injection을 설명하고 있다. 그럼에도 불구하고, 생각보다 잘 알려지지 않은 부분이 있는것 같다. 여기서는 생각 보다 아는 사람이 드문 내용을 끄적여 본다.
SQL은 기본적으로 텍스트 형식이다.
SQL은 DBMS에 명령을 내리는 텍스트 언어이다. 그러다 보니 복잡한 문법 구조를 가졌다. SQL은 정방향으로 읽어서 처리할 수 있는 언어가 아니다. 복잡한 구조를 가진 텍스트 언어들은 필연적으로 AST(Abstract-Syntax Tree)로 변환후 처리를 해야한다. SQL은 어쩔때는 정방향으로, 어쩔때는 Quote 처리를, 어쩔때는 괄호 처리를 위해 Stack을 사용하며 텍스트 처리를 해야한다.
1 + 2 + 3 – 4 와 같은 텍스트는 그저 앞으로 읽어가면서 처리하면 된다.
그러나, 1 + 2 * 3 + 4 / 5 와 같은 수준만 돼도 그저 앞으로 읽어가면서 처리할 수 없다. 하물며, 수식에 괄호가 들어온다면 단순히 텍스트 상태로 처리하기는 어려워진다.
(연산 수식에 순서가 생기는 순간 단순히 전진하며 계산하기는 어려워진다)
텍스트를 AST로 변환하는 과정은 공격에 상당한 취약한 지점이 된다. 특히, SQL은 사용자의 입력을 변수로 하여금 데이터를 삽입, 검색, 수정, 삭제한다. 이때 사용자의 입력이 DBMS로 직접 흐르게 된다. 여기서 사용자의 입력이 조금이라도 AST에 영향을 준다면 문제가 된다. 아예 다른 의미를 가진 쿼리가 되어, 의도하지 않은 동작을 DBMS가 하게 된다. 이것이 SQL Injection이다.
SQL은 단순히 SELECT * FROM <TABLE> WHERE name = 'ESukmean'
형태다! 라고 외울 수 있다. 하지만 엄연히 SQL도 정규 문법이 있고, BNF로 풀어쓸 수 있다. 이런 형태는 https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/SELECT.html#GUID-CFA006CA-6FF1-4972-821E-6996142A51C6 등에서 찾아 볼 수 있다.
다음 SQL은 ‘가위’라는 물품을 검색하는 SQL 이다: SELECT * FROM products WHERE type = '가위'
. 이 쿼리를 풀어보자면 다음과 같이 분석된다.
여기서 사용자가 입력하는 텍스트를 변수로 하여금 SQL이 바뀐다고 해보자. 웹 사이트에서 품목을 검색받는 경우를 생각해 볼 수 있다. 사용자가 ‘가위’를 검색할 수도, ‘풀’을 검색할 수도 있다. 또는 ‘초코파이’를 검색할 수도 있다. 이 입력에 따라서 SELECT * FROM products WHERE type = '풀'
이 될수도, SELECT * FROM products WHERE type = '가위'
가 될 수도 있다. 사용자의 입력이 변수가 되어 쿼리에 반영된다. 그렇지만 아직은 쿼리의 구조 자체는 바뀌지 않았다. 쿼리의 목적은 아직 동일하다.
이제 조금 멀리 나가보자. 만약 사용자가 '풀' or hide = 'N'
이라는 검색어를 넣었다고 생각해 보자. 프로그래머는 '풀' or hide = 'N'
이라는 품목을 보여주고 싶을 것이다. 하지만 실제 SQL 쿼리는 SELECT * FROM products WHERE type = '풀' or hide = 'N'
이 된다. 이미 짐작할 수 있듯, SQL의 구조가 바뀌었다.
필자는 이것을 구조가 파괴됐다라고 말한다. SQL의 구조가 원래와 다르게 변형됐기 때문에, 이전에 [물품만을 검색]하는 SQL에서 [물품을 검색하고 + 감춰진 항목을 검색하는] SQL이 됐다. 이것이 SQL Injection의 본질이다. 이것만 이해하면 다른것들은 전부 바리에이션으로 이해할 수 있다.
예를들어, products 테이블 옆에 members 테이블이 있다고 하자. 컬럼을 예측할 수 있는 상황이라면 SQL Injection을 통해서 members table의 데이터도 읽을 수 있다. UNION이라는 문법이 있기 때문이다! 품목에 '풀' UNION SELECT * FROM ~~~
을 집어 넣으면 된다. 그러면 다음과 같은 쿼리가 만들어질 것이다: SELECT name, price, hide FROM products WHERE type = '풀' UNION SELECT * FROM name, birthdate as price, password as hide FROM members
방어 방법에 대해서는 여러 블로그나 책에서 다루고 있다. 어쨋든 본질은 ‘SQL 구조가 파괴되지 않도록 해야 한다는 것’이다.
텍스트의 구조 유지
SQL Injection을 방어하기에 가장 좋은 방법은 구조와 데이터를 분리하는 것이다. SQL Injection은 데이터가 구조로 읽히면서 발생한다. 위의 예시에서UNION SELECT * FROM ~~~
을 검색하고 싶었으나, 이것이 구조로 읽히면서 원치 않는 데이터가 읽혔다.
구조 부분과 데이터 부분을 분리하는 가장 좋은 방법은 Prepared Statement이다. Prepared Statement는 구조 부분과 데이터 부분을 분리해서 전송하는 방식을 쓴다. 사용자의 입력은 데이터 부분에 들어간다. 사용자의 입력이 ‘구조’ 영역에 들어가지 않기 때문에 쿼리의 구조적 변화가 발생할 수 없다.
위의 쿼리를 예시로 든다면SELECT name, price FROM products WHERE type = ?
와 같이 텍스트로 된 구조가 하나 보내지고, ?
에 해당되는 데이터가 별도로 '풀' UNION SELECT * FROM name, birthdate as price, password as hide FROM members
와 같이 주어진다. 구조체 부분만이 따로 Parser를 통해서 AST가 된다. AST 생성이 완료된 뒤에 데이터가 별도로 합쳐진다. 그렇기 때문에 사용자가 무엇을 입력하든 관계없이 구조가 보장 된다.
Prepared Statement를 사용하면 성능상 이점이 있을수도 있다. 텍스트로 된 쿼리는 매번 AST를 만들기 위해서 파싱이 필요하다. 하지만 Prepared Statement는 구조가 같은 쿼리에 대해 늘 똑같은 입력이 주어진다. 다시 말해서, 쿼리가 완전 동일한지 바로 비교할 수 있다. 똑같은 구조의 쿼리라면 이미 만들어진 AST를 사용할 수 있다. 즉, 쿼리 구조를 캐싱할 수 있다.
구조 변화를 방어하기 위해 Quote를 사용할 수도 있다. Quote(Escape)를 사용하면 렉서가 하나의 이어진 문자열로 인식한다. 결국 목적은 같다. 그러나 prepared statement에 비해 특별히 장점이 있진 않기에 이 글에서는 넘어간다.
결론
본질은 “텍스트”를 처리하기 위해서는 어쨋든 처리 과정이 필요하다는 부분이다. 텍스트 자체에 변화가 오면 당연히 정상적인 처리를 할 수 없다.
SQL은 복잡한 언어로서, 컴파일러에 의한 AST 변환이 필요하다. 이때, 의도하지 않은 형태의 AST로 변환되면 문제가 발생한다. 사용자의 입력이 정재되지 않고 곧바로 SQL에 흘러가면 이러한 문제가 발생할 수 있다. 그러므로, 사용자의 입력이 SQL 구조에 영향이 가지 않도록 해야한다.
그 중 가장 나은 방법은 Prepared Statement이다. Prepared Statement를 사용하면 데이터와 구조를 나눠서 전송한다. 덕분에 구조 텍스트의 AST 변환과 사용자의 입력이 분리된다.
답글 남기기