웹/Back-End

PHP의 SQL Injection 방지 대책

하루히즘 2021. 1. 16. 02:56

공부를 하다 보면 LoS든 Webhacking.kr이든 대부분의 워게임 문제들은 PHP와 MySQL로 작성되어 있다. 이는 PHP가 (개발자가 신경 쓰지 않는다면) 특히 보안에 취약한 웹 서버 언어라는 것을 증명하면서도 MySQL과 같이 많이 쓰이고 접하기 쉽기 때문에 아직까지 사용되는 게 아닐까 생각한다. 그런 PHP에서도 이런 취약한 언어라는 오명을 벗기 위해 다양한 방지책들을 제공하고 있는데 이를 이번 포스트에서 실습해보겠다.

 

실습 환경의 PHP는 8, MySQL은 MariaDB 10.4.17 버전이다. PHP 5나 PHP 7 때와는 달리 PHP 8 버전이 되면서 mysqli_real_escape_string() 같은 함수는 사라졌고 이제 mysqli 클래스를 사용하도록 강제되는 것 같다. 최신 PHP에 맞게 실습을 수행해보았다.

실습 페이지는 이름과 비밀번호를 입력받아 삽입하고 제거하는 기능을 갖고 있다. 옆의 체크박스는 SQL Injection 필터링 적용 여부를 지정한다.

mysqli_real_escape_string() / mysqli->escape_string()

제일 기본적이면서도 확실한 방법이다. PHP 8 이전에는 별도의 함수로 존재했으나 PHP 8부터는 mysqli 객체에서 호출되는 메서드로 바뀌었다. 이 함수는 SQL Injection에 사용될 수 있는 문자(따옴표, 큰따옴표, 널 문자, 개행 문자, 역슬래시 등)를 이스케이프 시켜 일반 문자로 인코딩 시킨다. 이 함수를 실행하려면 mysqli 객체의 연결이 활성화되어 있어야 하며 그렇지 않다면 빈 문자열(NULL)을 반환한다. 사용법은 단순히 필터링할 쿼리를 함수의 매개변수로 전달하면 된다.

$connection = new mysqli(...);
...
if($sqli_filter){
	$query = $connection->escape_string($query);
}    

이를 적용했을 때와 적용하지 않았을 때 차이를 보면 이스케이프 대상 문자가 어떻게 처리되는지 볼 수 있다. 먼저 위의 실습 페이지에서 사용자 정보를 등록하는 폼에 필터링을 적용하지 않았을 때와 적용했을 때 각각 1, 2와 3, 4를 입력하면 다음과 같다.

# INSERT INTO user_table VALUES('1', '2');
# INSERT INTO user_table VALUES('3', '4');

MariaDB [vulnerable_db]> select * from user_table;
+------+------+
| name | pw   |
+------+------+
| 1    | 2    |
| 3    | 4    |
+------+------+
2 rows in set (0.000 sec)

둘 다 정상적으로 등록되어 1, 2와 3, 4가 저장된 것을 볼 수 있다. 그렇다면 1, 2'나 3', 4 처럼 따옴표가 들어간 값을 입력하면 어떨까?

# INSERT INTO user_table VALUES('1', '2'');
# INSERT INTO user_table VALUES('3\'', '4');

MariaDB [vulnerable_db]> select * from user_table;
+------+------+
| name | pw   |
+------+------+
| 3'   | 4    |
+------+------+
1 row in set (0.000 sec)

필터링되지 않은 1, 2' 쿼리는 따옴표가 맞지 않아 실패했지만 필터링된 3', 4처럼 입력한 쿼리는 따옴표가 이스케이프 되어 일반 문자로 취급되었기 때문에 정상적으로 저장된 것을 볼 수 있다. 이것이 어떻게 SQL Injection을 방어할 수 있는 것일까? 현재 이 실습의 소스 코드는 다음과 같다.

...
$query = "INSERT INTO user_table VALUES('$name', '$password');";
...
$query = "DELETE FROM user_table WHERE name='$name' and pw='$password';";
...

데이터를 삭제할 때 name과 pw를 받아서 일치하는지 확인하고 있는데 만약 pw 파라미터에서 따옴표를 이용해 WHERE 조건절을 항상 참으로 만든다면 모든 데이터를 삭제할 수 있다.

MariaDB [vulnerable_db]> select * from user_table;
+-------+------+
| name  | pw   |
+-------+------+
| aaaa  | 111  |
| bbbbb | 22   |
| cccc  | 33   |
+-------+------+
3 rows in set (0.000 sec)

# DELETE FROM user_table WHERE name='sqli' and pw='' or true#';

MariaDB [vulnerable_db]> select * from user_table;
Empty set (0.000 sec)

위처럼 비밀번호를 "' or true#" 처럼 입력했더니 쿼리의 WHERE 절이 name이 'sqli'고 pw가 ''인 경우 또는 true 즉 항상 참인 경우 user_table에서 데이터를 삭제하라는 쿼리가 작성되었다. 그래서 true와 OR 연산하기 때문에 WHERE 절은 항상 참이 되어 user_table의 모든 데이터가 삭제된 것을 볼 수 있다. 그렇다면 필터링이 적용됐다면 어떨까?

MariaDB [vulnerable_db]> select * from user_table;
+-------+--------+
| name  | pw     |
+-------+--------+
| aaaaa | 1111   |
| bbb   | 222    |
| cccc  | 333333 |
+-------+--------+
3 rows in set (0.000 sec)

# DELETE FROM user_table WHERE name='sqli' and pw='\' or true#';

MariaDB [vulnerable_db]> select * from user_table;
+-------+--------+
| name  | pw     |
+-------+--------+
| aaaaa | 1111   |
| bbb   | 222    |
| cccc  | 333333 |
+-------+--------+
3 rows in set (0.000 sec)

따옴표가 필터링되면서 name이 'sqli'고 pw가 '\' or true#'인 데이터를 삭제하는 쿼리로 변경되었다. 이에 해당하는 쿼리는 없기 때문에 아무런 데이터도 삭제되지 않은 것을 볼 수 있다. 이처럼 사용자 입력으로 받은 파라미터를 꼼꼼히 이스케이프 하는 것만으로도 SQL Injection에 대한 거의 모든 시도는 막아낼 수 있다.

PDO(PHP Data Object)

PHP가 주로 MySQL, MariaDB와 같이 쓰이긴 하지만 다른 데이터베이스와도 상호작용할 수 있는데 이때 사용할 수 있는 기능이 PDO, PHP Data Object다. PDO는 수많은 종류의 데이터베이스에 쿼리를 전송하고 결과를 받아볼 수 있는 일관성있는 인터페이스를 제공하는데 각각의 데이터베이스 드라이버들이 이 인터페이스를 구현하고 있기 때문에 'data-access' 계층의 접근을 제공한다. 간단히 말해서 동일한 API로 다른 기종의 데이터베이스에 접근할 수 있다는 것이다.

 

PDO를 사용하기 위해서는 PDO 객체에 쿼리문을 미리 등록해두어야 한다. 이때 쿼리에는 파라미터가 삽입될 공간(placeholder)만 미리 지정해둔다. 예를 들어 '?'나 ':id' 같은 문자를 쿼리문에 삽입하는 것인데 "select * from board where id=?"나 "select * from board where id=:id"처럼 작성된 쿼리를 PDO 객체에 등록한 후 필요할 때마다 파라미터만 바꿔서 적용해주면서 전송할 수 있기 때문에 쿼리를 더 간편하고 효과적으로 작성할 수 있다.

 

mysqli_real_escape_string() 같은 함수는 mysqli 클래스의 escape_string() 메서드로 지원되는 것처럼 mysqli 객체는 절차적, 객체적 사용을 모두 지원하지만 이 PDO 객체는 오직 객체적 사용만 지원한다. 그렇기 때문에 사용한다고 하면 mysqli도 객체적으로 사용하는 편이 일관성있을 것이다. PDO에서는 다음과 같이 11가지의 데이터베이스에 대한 인터페이스를 제공한다.

이 중 실습 환경인 MySQL의 PDO를 사용해볼 수 있다. 적당한 문서가 없었기 때문에 MySQL데이터베이스에 PDO(PHP Data Object) 사용법 (tistory.com)를 참고해서 진행했다.

try {
	$pdo = new PDO('mysql:host=localhost;port=3306;dbname=vulnerable_db;charset=utf8', 'root', '');
	$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
	$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
	...
} catch(PDOException $e){
	...
}
...
if($_REQUEST['mode'] == 'Register'){
	$query = "INSERT INTO user_table VALUES(?, ?);";
	$preparedStatement = $pdo->prepare($query);
	$result = $preparedStatement->execute(array($name, $password));
} else if($_REQUEST['mode'] == 'Unregister'){
	$query = "DELETE FROM user_table WHERE name=:name and pw=:password;";
	$preparedStatement = $pdo->prepare($query);
	$preparedStatement->bindParam(':name', $name, PDO::PARAM_STR);
	$preparedStatement->bindParam(':password', $password, PDO::PARAM_STR);
	$result = $preparedStatement->execute();
}
...

먼저 mysql에 localhost:3306에 있는 vulnerable_db에 root 계정과 비밀번호 ''으로 연결했다. 그리고 몇 가지 속성을 설정해주고 있는데 PDO::ATTR_EMULATE_PREPARES 속성은 MySQL의 PDO가 기본적으로 에뮬레이트 된 prepare를 사용하기 때문에 이를 해제해주는 설정이다. 데이터베이스 서버에서 이를 지원하지 않는다면 에뮬레이트 된 것을 사용하는 게 맞지만 지금은 지원하기 때문에 이를 해제해서 서버 측의 prepare를 사용하도록 하는 것이다. 자세한 설명은 mysql - emulated prepared statements vs real prepared statements - Stack Overflow가 도움이 될 것이다.

PDO::ATTR_ERRMODE는 PDO 객체 생성이 실패했을 때 어떻게 동작하는지를 지정하는 것으로 PDO::ERRMODE_EXCEPTION으로 설정하여 실패 시 예외를 발생시키고 있다. 그래서 PDO 객체를 생성하는 부분이 try-catch로 감싸져있는 것이다.

 

이후 이렇게 생성된 PDO 객체에 대하여 prepare() 메서드를 호출하여 쿼리를 전달하는데 위에서 언급했듯이 파라미터가 삽입될 부분을 '?'나 ':id' 같은 placeholder로 표시해두어야 한다. 그리고 execute() 메서드 호출 시 위의 예제에서 Register 모드처럼 array() 생성자를 이용하여 한꺼번에 배열로 전달하거나 Unregister 모드처럼 bindParam(), bindValue() 메서드(차이점)로 각각의 placeholder에 파라미터를 삽입한 후 execute() 메서드만 호출하여 실행할 수 있다. 이때 쿼리를 보면 이전과는 달리 파라미터들을 따옴표로 감싸줄 필요가 없는데 PDO 내부적으로 SQL Injection에 대한 필터링같은 문제들을 해결하기 때문이다.

# aaaa / 11111
# bbbb / 'cde
# cccc / edf'#

MariaDB [vulnerable_db]> select * from user_table;
+------+-------+
| name | pw    |
+------+-------+
| aaaa | 11111 |
| bbbb | 'cde  |
| cccc | edf'# |
+------+-------+
3 rows in set (0.000 sec)

마찬가지로 계정 삭제 역시 아까같은 SQL Injection은 일어나지 않았다.

# sqli / 1' or true#

MariaDB [vulnerable_db]> select * from user_table;
+------+-------+
| name | pw    |
+------+-------+
| aaaa | 11111 |
| bbbb | 'cde  |
| cccc | edf'# |
+------+-------+
3 rows in set (0.000 sec)

원래대로라면 "DELETE FROM user_table WHERE name=:name and pw=:password;"이 "DELETE FROM user_table WHERE name='sqli' and pw='1' or true#;"처럼 실행되어 user_table의 모든 정보가 삭제되었을 것이다. 하지만 PDO를 적용한 결과 위처럼 SQL Injection에 대하여 효과적으로 방어할 수 있었다.

' > Back-End' 카테고리의 다른 글

Python과 Flask를 활용한 웹 서버 구축  (0) 2020.12.28