질문/조언팁/리소스 공유

※ RXE 모듈이나 애드온 등에서 쿼리를 할 때는 XML 쿼리를 사용하시기를 권장합니다. 아래의 팁은 XML 쿼리를 사용할 수 없는 특수한 환경(예: 스킨)이라든지, 날코딩으로 자료를 개발하실 때 그나마 안전하게 작업하기 위한 방법입니다.

 

Prepared statement(준비된 쿼리 / 여기서 statement는 query와 동의어로 봐도 무방함) 방식의 DB 쿼리는 원래 동일한 쿼리를 여러 번 반복 실행할 때 성능을 향상시키기 위해 개발되었습니다. 쿼리에서 데이터가 들어가는 부분을 다 빼놓고 문법만 분석하여 DB서버가 쿼리 계획을 세우고 최적화할 기회를 주고 나서, 그렇게 최적화된 쿼리에 데이터를 집어넣어서 빠르게 반복 실행하려는 목적이었지요.

 

그런데 여기에 성능보다 더 중요한 보안상의 장점이 있다는 사실이 밝혀지면서 이제는 성능보다 보안 때문에 prepared statement를 사용하는 개발자들이 더 많은 것 같습니다. XE에서도 mysqli_innodb 타입을 선택하면 prepared statement를 사용하는 것이 기본이고요. 이게 보안에 도움이 되는 이유는 데이터를 빼고 쿼리 문법만 먼저 분석하는 단계를 거치기 때문입니다. 사용자가 입력한 데이터에 위험한 내용이 들어 있더라도, 쿼리 문법을 분석하는 단계에는 그 데이터가 들어가지 않으므로 절대적으로 안전하다는 것입니다.

 

예를 들어 아래와 같이 쿼리를 하면

 

mysql_query("INSERT INTO members (id, email) VALUES ('" . $_POST['id'] . "', '" . $_POST['email'] . "')");

 

쿼리 문법과 데이터를 구분하지 않고 그냥 한 번에 전달하게 됩니다. 따라서 $_POST['email'] 변수에 '; DROP TABLE members; -- 이런 내용이 들어가면 DB서버는 그게 쿼리 문법의 일부인 줄 알고 members 테이블을 통째로 날려버립니다.

 

이런 공격을 SQL 인젝션이라고 합니다. SQL 인젝션 공격을 막기 위해 과거에는 다양한 함수를 사용하여 특수문자를 무력화시키려는 시도를 해왔습니다. addslashes() 함수는 이름 그대로 따옴표 앞에 백슬래시를 붙여줍니다. 그런데 ASCII가 아닌 특수문자는 제대로 처리하지 못하는 단점이 있어서 mysql_escape_string() 와 같은 대체 함수가 나왔고, 그것도 허점이 있다고 해서mysql_real_escape_string() 함수가 나오는 등... 진화하는 해킹 수법에 맞서기 위해 함수도 계속 바뀌는 문제가 있었습니다.

 

그래서 개발자들은 생각을 하게 된 거죠. 멍청한 DB서버가 쿼리 문법과 사용자 데이터를 구분하지 못하는 것이 문제의 근원인데, 우리가 언제까지 일일이 특수문자 이스케이프 처리해 가면서 DB서버 뒷바라지를 해야겠냐고요. 그냥 여기부터 여기까지는 쿼리 문법이니까 알아서 해석해! 그리고 여기부터는 사용자 데이터니까 해석할 생각 말고 그냥 넣어둬! 라고 DB서버한테 일러주면 되는 거잖아요.

 

mysqli를 사용해서도 prepared statement를 실행할 수 있습니다. 그러나 mysqli라는 녀석이 사용자 편의성 따위는 안중에도 없는 극악의 설계를 자랑하는 녀석이기 때문에;;; 사용하기가 너무 어려워서 실제로 정확하게 쓸 줄 아는 사람이 많지 않습니다. 그 밖에도 여러 가지 불편함이 있어서 그것을 해결하기 위해 PDO가 등장했습니다. PDO를 사용하면 빠르고 안전한 쿼리를 아주 쉽게 실행할 수 있고, 심지어 쿼리문도 훨씬 깔끔해집니다.

 

// 권장하는 기본 옵션입니다. 필요에 따라 바꿔도 됩니다.

$options = array(

    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,

    PDO::ATTR_EMULATE_PREPARES => false,
);

 

// DB 연결

$pdo = new PDO('mysql:host=localhost;dbname=DB명;charset=utf8', '아이디', '비번', $options);

 

// 쿼리 문법부터 먼저 전달

$stmt = $pdo->prepare('INSERT INTO members (id, email) VALUES (?, ?)');

 

// 데이터 전달

$stmt->execute(array($_POST['id'], $_POST['email']));

 

// 결과 받아오기

$result = $stmt->fetchAll();

$result = $stmt->fetchAll(PDO::FETCH_ASSOC);  // 연관배열로 받아오기

$result = $stmt->fetchAll(PDO::FETCH_OBJ);  // 오브젝트로 받아오기

 

보시다시피 쿼리 문법을 전달할 때는 지저분한 데이터가 들어가지 않기 때문에 내가 지금 어떤 쿼리를 하고 있는지 파악하기가 무척 쉽습니다. 데이터는 나중에 배열에 넣어서 따로 전달하고요. 어디까지가 쿼리 문법이고 어디부터 사용자 데이터인지 너무나 명백하기 때문에, 문자열 넣을 때 따옴표 넣을 필요도 없고 입력하는 내용을 이스케이프 할 필요도 없습니다. 그냥 넣어도 안전하게 들어갑니다. (물론 HTML 같은 것은 나중에 출력할 때에 대비하여 필터링을 해야 합니다.)

 

전달해야 할 변수가 많을 때는 물음표 대신 이름을 달아주면 구분하기가 좀더 쉽습니다.

 

$stmt = $pdo->prepare('INSERT INTO members (id, email) VALUES (:id, :email)');

$stmt->execute(array(

    ':id' => $_POST['id'],

    ':email' => $_POST['email'],

));

 

다시 말씀드리지만, 문자열 넣을 때 따옴표 넣을 필요도 없고 사용자가 입력한 내용에 addslashes(), mysql_escape_string() 등 불완전한 함수를 적용할 필요도 없습니다. 그냥 넣어도 SQL 인젝션 공격에 완벽하게 방어가 됩니다.

 

주의할 점은 딱 하나입니다. 쿼리 문법을 전달하는 단계에는 사용자가 입력한 내용이 절대 들어가서는 안됩니다. 예를 들어 아래와 같이 쿼리하면 prepared statement를 사용했음에도 불구하고 해킹당할 수 있습니다.

 

// 잘못된 예제입니다

$stmt = $pdo->prepare('SELECT * FROM documents WHERE ' . $_GET['search_target'] . ' LIKE :q');

$stmt->execute(array(

    ':q' => '%'.$_GET['search_keyword'].'%'

));

 

사용자가 입력한 내용에 따라 쿼리 문법의 일부분이 달라질 수밖에 없는 경우라면, 개발자가 정해놓은 것 외에는 절대 입력할 수 없도록 안전장치를 추가해야 합니다.

 

// 올바른 예제입니다

$column_name = $_GET['search_target'];

if (!in_array($column_name, array('title, content, tags')) {

    die('해킹시도 즐');

}

$stmt = $pdo->prepare('SELECT * FROM documents WHERE ' . $column_name . ' LIKE :q');

$stmt->execute(array(

    ':q' => '%'.$_GET['search_keyword'].'%'

));

 

이렇게 해놓으면 SQL 인젝션 취약점이 발생할 수가 없습니다.

 

PDO 매뉴얼: https://www.php.net/manual/en/book.pdo.php

문법, 함수 사용법 등에 대한 질문은 사절합니다. 매뉴얼을 정독하세요.

글쓴이 기진곰

profile
GitHub 아이디는 @kijin입니다. 사람을 위한 인터넷 생태계의 발전과 웹 보안에 많은 관심을 가지고 있습니다.
우리가 만들어 가는 XE의 새 이름, 라이믹스(Rhymix) 프로젝트에 참여하고 있습니다.
오픈소스 도로명주소 검색서버 및 API Postcodify를 개발, 운영하고 있습니다.
국내외 서버 및 클라우드서버 세팅, 이전, 튜닝해 드립니다.