정의
SQL Injection은 응용 프로그램 보안 상의 허점을 의도적으로 이용해 악의적인 SQL문을 실행되게 함으로써 데이터베이스를 비정상적으로 조작하는 코드 인젝션 공격 방법입니다. SQL Injection을 하기 위해서는 아래의 조건을 만족해야 합니다.
- 웹 애플리케이션이 DB와 연동되어 있다.
- 사용자가 입력한 외부 입력값이 SQL 구문의 일부로 사용된다.
사실 현대 웹 애플리케이션의 대부분은 위 조건들을 충족하고 있기 때문에 SQL Injection의 대상이 될 수 있습니다.
원인
데이터베이스와 연동된 웹 애플리케이션에서 입력된 데이터에 대한 유효성 검증을 하지 않아 발생하는 취약점으로, 취약한 웹 애플리케이션은 사용자로부터 입력된 값을 필터링 과정 없이 넘겨받아 동적 쿼리(Dynamic Query)를 생성합니다. 이로 인해 개발자가 의도하지 않은 쿼리가 생성되어 정보 유출에 악용될 수 있는 것입니다.
공격 방법 예시
|WHERE구문을 우회하는 쿼리 조건 무력화 - SQL 구문의 주석을 의도적으로 삽입
동적으로 생성되는 쿼리에 SQL 구문의 주석을 의도적으로 삽입하여 WHERE 조건을 무력화시킬 수 있는 방법입니다.
예시를 하나 들어보겠습니다. 어떠한 웹 애플리케이션 서비스에서 로그인하는 과정이 다음과 같았습니다.
1. 웹페이지에서 사용자로부터 ID, Password를 입력 받음
2. 입력받은 ID와 Password를 활용하여 아래와 같은 쿼리를 동적으로 생성
SELECT * FROM USER WHERE ID = '사용자가 입력한 ID' AND Password = '사용자가 입력한 비밀번호'
4. SELECT의 결과가 있다면 로그인
5. SELECT의 결과가 없다면 로그인 실패 알림창 띄우기
악의적인 사용자는 서비스에서 admin이라는 ID를 사용하고 있는 것을 알고 있다고 가정합시다. 그리고 로그인하는 과정에서 아래의 입력값을 입력합니다.
ID: admin'--
Password: 아무거나
위 입력값을 활용하여 웹 애플리케이션은 아래의 쿼리를 동적으로 생성합니다.
SELECT * FROM USER WHERE ID = 'admin'--' AND Password = '아무거나'
위의 쿼리에서는 입력한 ID에서 WHERE절의 ID 조건을 강제로 닫아버리고 그 이하의 내용을 주석으로 처리해버립니다. 결국 실제 쿼리는 admin이라는 아이디를 가진 유저가 있다면 비밀번호 검증을 하지 않고 바로 로그인을 시켜버리는 취약한 형태로 생성됩니다.
|WHERE 구문을 우회하는 쿼리 조건 무력화 - 항상 참이 되도록 Boolean식을 작성
동적으로 생성되는 쿼리의 WHERE절이 항상 참이 되도록 만들어 WHERE 조건을 무력화시키는 방법입니다. 이번에도 위에서 언급했던 서비스를 예로 들어보겠습니다. 이번에는 로그인 이후, 사용자의 정보를 보여주는 기능을 살펴보겠습니다.
1. 'My Info' 버튼을 클릭하면 로그인 시 사용했던 ID와 Password를 활용하여 아래와 같은 형태의 쿼리를 동적으로 생성
SELECT
NAME as name
PHONE_NUMBER as contact
FROM USER
WHERE ID = '사용자의 ID' AND Password ='사용자의 Password'
2. 사용자는 본인의 이름과 연락처를 확인
악의적인 사용자는 로그인 하는 과정에서 아래의 입력값을 입력합니다.
ID: 1
Password: 1' or '1' = '1
위 입력값을 활용하여 웹 애플리케이션은 아래의 쿼리를 동적으로 생성합니다.
SELECT * FROM USER WHERE ID = '1' AND Password = '1' or '1' = '1'
놀랍게도 로그인이 성공적으로 이루어집니다. SQL 구문에서 AND 연산은 OR 연산보다 우선한다는 특성 때문인데요. 위 쿼리를 우선순위를 확인하기 편하게 괄호를 씌우면 아래와 같습니다.
SELECT * FROM USER WHERE (ID = '1' AND Password = '1') or ('1' = '1')
ID = '1' AND Password = '1'은 실패할 확률이 매우 높겠지만, OR 연산자의 두 번째 피연산자는 항상 참인 Boolean 식입니다. 결국 WHERE 구문은 참이 되고, USER에 있는 모든 유저가 SELECT문의 결과로 반환됩니다. 이 서비스는 SELECT문의 결과가 있기만 하면 로그인이 되기 때문에 로그인에 성공합니다.
하지만 더 큰 문제가 있습니다. 악의적인 사용자가 'My Info'를 클릭하면, 아래와 같은 쿼리가 동적으로 생성됩니다.
SELECT
NAME as name
PHONE_NUMBER as contact
FROM USER
WHERE ID = '1' AND Password = '1' or '1' = '1'
아까 살펴봤듯 위와 같은 형태의 WHERE절은 항상 참이므로 WHERE절이 무력합니다. 로그인 과정에서야 SELECT문의 결과를 직접 확인할 수 없었지만, 이번에는 모든 유저의 이름과 연락처를 탈취할 수 있게 됩니다. 웹 애플리케이션 서비스에 인증 절차 없이 로그인한 것도 위험하지만, 이처럼 서비스의 기능에 따라 다른 사용자의 개인 정보를 탈취할 수 있는 더 큰 문제점도 있을 수 있습니다.
|직접 데이터베이스를 조작
직접 데이터베이스를 조작하는 쿼리문을 삽입하여 의도하지 않은 명령어까지 수행시키는 방법입니다. 이번에도 위에서 언급했던 서비스를 예로 들어보겠습니다. 이번에는 게시판 기능을 예시로 들어보겠습니다. 이 서비스에서는 아래와 같은 방식으로 게시판 기능이 운영됩니다.
1. 사용자가 게시글 제목과 내용을 작성
2. '작성 완료' 버튼을 누르면 아래와 같은 쿼리가 동적으로 생성 후 실행
INSERT INTO
BOARD(id, title, contents)
VALUES (임의의 게시글 id, '작성자가 입력한 제목', '작성자가 입력한 글 내용')
악의적인 사용자는 게시글을 작성할 때 아래의 입력값을 입력합니다.
제목: ㅋㅋ
내용: '); DELETE FROM BOARD--
위 입력값을 활용하여 웹 애플리케이션은 아래의 쿼리를 동적으로 생성합니다.
INSERT INTO
BOARD(id, title, contents)
VALUES (101, 'ㅋㅋ', ''); DELETE FROM BOARD--')
위 쿼리가 실행되면 우선 101번째 게시글을 DB에 추가하게 되고, BOARD 테이블에 있는 모든 데이터를 제거하게 됩니다.
대응 방법
여러 측면에서 다양한 대응 방법이 있지만 기술적 대응 방안을 위주로, 그중에서도 시큐어 코딩에 주목하여 대응 방법을 적어보겠습니다.
1. 입력값 유효성 검사
SQL Injection의 가장 기본적인 대응 전략으로, 모든 외부 입력값을 신뢰하지 않으며 항상 유효성을 검사하는 방법입니다. 입력값 유효성 검사는 두 가지 방식이 존재합니다.
| 블랙 리스트 방식
- SQL 쿼리의 구조를 변경시키는 문자나 키워드, 특수문자를 제한하는 방식입니다.
- SQL의 예약어인 UNION, GROUP BY와 COUNT() 등의 함수명, 세미콜론(;)이나 주석(--) 등의 특수문자들을 블랙리스트로 미리 정의하고, 블랙리스트로 지정되어 있는 문자나 키워드가 외부 입력값 안에 존재하면 공백 등으로 치환하는 방식으로 방어합니다.
- 하지만 정교하게 입력값을 검증하지 않는다면 블랙 리스트 방식의 검증을 우회할 수도 있으니 주의해야 합니다. 예를 들어 외부 입력값에SESELECTLECT라는 표현이 있을 때 중간의 SELECT가 공백으로 치환되면 그 좌우에 있는 문자들이 합쳐져서 다시SELECT라는 키워드로 완성됩니다.
| 화이트 리스트 방식
- 허용된 문자를 제외하고는 모두 허용하지 않는 방식으로, 금지된 문자 외에는 모두 허용하는 블랙 리스트 방식보다 보안성 측면에서는 훨씬 더 강력한 방법입니다.
- 웹 애플리케이션의 기능에 따라 알맞게 화이트 리스트를 작성해야 하는 번거로움이 존재합니다.
- 그러한 번거로움을 극복하기 위해, 그리고 유지보수의 편리함을 증진시키기 위해 개별 문자를 일일이 하나씩 모두 정의하는 것보다 정규식 등을 이용해서 화이트 리스트를 범주화/패턴화시키는 편이 좋습니다.
2. Prepared Statement 사용
일반적으로 SQL 쿼리의 실행 과정은 '구문분석(parsing) -> SQL 최적화(optimization) -> 실행 계획을 실행 가능한 코드로 포맷팅 (Row Source Generation) -> 실행(execute) -> 인출(fatch)' 총 다섯 가지 단계로 구성되어 있다고 축약할 수 있습니다. 매번 동적으로 쿼리를 생성하는 일반적인 Statement를 사용하게 되면 구문분석부터 인출까지의 모든 과정을 수행합니다.
Prepared Statement란 미리 쿼리에 대한 컴파일을 수행하고, 입력값을 나중에 넣는 방식입니다. 즉, 일반적인 Statement와는 달리 불필요하게 모든 과정을 수행하지 않고, 실행(execute) 전까지의 과정을 최초로 1번만 수행한 후 나중에는 실행 전에 미리 컴파일되어 있는 Prepared Statement를 가져다 쓰는 것이죠.
Prepared Statement는 자주 수행되는 복잡한 쿼리들의 성능을 개선하기 위해 많이들 사용하지만, SQL Injection에 대한 대응 방법으로서 역할을 하기도 합니다.Prepared Statement를 활용하면 쿼리의 문법 처리 과정이 미리 컴파일이 되어 있기 때문에, 외부 입력값으로 SQL 관련 구문이나 특수문자가 들어와도 그것은 SQL 문법으로서 역할을 할 수 없기 때문이죠.
더욱 구체적으로, 자바의 JDBC 코드를 예로 들어 Prepared Statement를 설명해보겠습니다. 본 글에서 계속 예시로 들었던 서비스에 로그인하는 쿼리를 Prepared Statement로 작성하면 다음과 같습니다.
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM USER WHERE ID = ? AND Password = ?")
위 코드에서 실제 쿼리 변수가 들어갈 자리에는 ?로 써져 있어서 특정값은 지정하지 않은 채로 남겨지게 되는데요. 변수 stmt가 생성되는 순간 연결되어 있는 DMBS에서는 위 쿼리에 대해 컴파일을 수행해놓습니다. 컴파일된 결과는 템플릿 같이 사용되며, 나중에 저 쿼리가 실제로 수행되어야 하는 순간 넘겨받은 입력값을? 에 바인드(연결)시켜주고 쿼리가 실행됩니다.
3. 오류 메시지 출력 제한
말 그대로 오류 메시지를 출력하는 것에 제한을 두는 것입니다. 구체적으로는 다음과 같은 방법이 있습니다.
- DB 관련 오류를 사용자에게 노출되지 않게 커스텀 오류 페이지를 만들어야 합니다.
- 너무 자세한 안내 메세지를 지양합니다. 가령 로그인 실패 시 "패스워드가 일치하지 않습니다"라는 메세지를 띄울 경우, 악의적인 사용자는 ID는 실제로 존재한다는 것을 알 수 있습니다. 이러한 구체적인 안내 메세지보다는 "로그인에 실패하였습니다"와 같이 추상적인 메세지를 띄우는 것이 안전성 측면에서 더욱 효율적입니다. 다만 추상적인 메세지는 사용자의 편의성 및 사용성을 떨어뜨릴 수 있습니다.
4. DB 보안
- 관리자가 사용하는 DB 계정과 웹 애플리케이션이 DB에 접근할 때 사용하는 계정을 분리시켜야 합니다.
- 분리시킬 뿐만 아니라, 분리된 웹 애플리케이션이 사용하는 DB 계정에 최소 권한만을 부여해줘야 합니다.
참고 자료
1. 박종명의 아름다운 개발 - [웹보안] SQL INJECTION
2. NoirStar님의 블로그 - SQL Injection 이란? (SQL 삽입 공격)
3. 예시 참고 - 유튜브 <SQL전문가 정미나> - 면접관이 SQL Injection에 대해 설명해보라고 한다면?
4. 예시 참고 - 유튜브 <코드없는 프로그래밍> - sql injection, SQL 인젝션 원인,이해하기
7. 인포섹님의 블로그 - [SQL 인젝션] Prepared Statement를 쓰면 SQL 인젝션 공격이 되지 않는 이유는?
8. Stack Overflow - How does a PreparedStatement avoid or prevent SQL injection?