SQL 데이터베이스가 뛰어난 성능과 즉각적인 파괴로부터 안전하다고 생각하시나요? 안타깝게도 SQL 인젝션은 그 생각에 동의하지 않습니다!
네, 보통 “보안 강화”나 “악성 접근 방지”와 같은 흔한 표현으로 시작하지 않으려고 합니다. 하지만 SQL 인젝션은 개발자라면 누구나 알고 있으며 방지법을 숙지하고 있어야 하는 고전적인 공격 기법입니다. 물론 때로는 실수할 수 있고, 그 결과는 심각한 재앙으로 이어질 수 있습니다.
만약 SQL 인젝션이 무엇인지 이미 알고 있다면, 이 글의 뒷부분으로 바로 넘어가셔도 좋습니다. 하지만 웹 개발에 처음 입문하거나 더 높은 수준을 목표로 하는 분들을 위해 간략하게 소개할 필요가 있겠습니다.
SQL 인젝션이란 무엇일까요?
SQL 인젝션을 이해하는 핵심은 이름 자체에 있습니다. 바로 ‘SQL’과 ‘인젝션’의 조합이죠. 여기서 ‘인젝션’은 의학적인 의미가 아니라 ‘주입하다’라는 동사의 의미로 쓰였습니다. 이 두 단어를 합쳐보면, 웹 애플리케이션에 SQL 코드를 넣는다는 개념을 알 수 있습니다.
웹 애플리케이션에 SQL을 넣는다… 흠… 우리가 일반적으로 하는 일 아닌가요? 맞습니다. 하지만 공격자가 데이터베이스를 조작하는 것을 원하지 않는다는 것이 핵심입니다. 예시를 통해 좀 더 자세히 알아볼까요.
지역 전자상점 웹사이트를 PHP로 구축하고 있으며, 문의 양식을 추가하려고 한다고 가정해 봅시다. 다음과 같은 형태일 수 있습니다.
<form action="record_message.php" method="POST"> <label>이름</label> <input type="text" name="name"> <label>메시지</label> <textarea name="message" rows="5"></textarea> <input type="submit" value="보내기"> </form>
이후 상점 주인이 고객 메시지를 확인할 수 있도록, `send_message.php` 파일이 모든 정보를 데이터베이스에 저장한다고 가정해 보겠습니다. 해당 코드는 다음과 같을 수 있습니다.
<?php $name = $_POST['name']; $message = $_POST['message']; // 해당 사용자가 이미 메시지를 가지고 있는지 확인 mysqli_query($conn, "SELECT * from messages where name = $name"); // 나머지 코드
여기서 가장 먼저 해당 사용자가 아직 읽지 않은 메시지가 있는지 확인하려고 합니다. `name = $name`인 메시지를 찾는 `SELECT *` 쿼리는 매우 단순해 보입니다.
하지만 이것은 매우 위험한 생각입니다!
아무 생각 없이 우리는 데이터베이스를 파괴할 수 있는 문을 열어버린 것입니다. 이를 위해서는 공격자가 다음 조건을 만족해야 합니다.
- 애플리케이션이 SQL 데이터베이스를 사용하고 있을 것 (거의 모든 애플리케이션이 그렇습니다).
- 현재 데이터베이스 연결이 데이터베이스에 ‘편집’ 및 ‘삭제’ 권한을 가지고 있을 것.
- 중요한 테이블 이름을 추측할 수 있을 것.
세 번째 요점은 이제 공격자가 우리가 전자상점을 운영한다는 사실을 알고 있고, 주문 데이터를 ‘orders’ 테이블에 저장할 가능성이 매우 높다는 것을 의미합니다. 이러한 정보를 모두 가지고 있다면, 공격자는 이름 입력란에 다음과 같은 값을 넣기만 하면 됩니다.
Joe; TRUNCATE orders; —
이 쿼리가 PHP 스크립트에 의해 실행될 때 어떤 일이 일어나는지 살펴봅시다.
`SELECT * FROM messages WHERE name = Joe; TRUNCATE orders; –`
쿼리의 첫 번째 부분은 문법 오류가 있지만 (‘Joe’ 주변에 따옴표가 없음), 세미콜론은 MySQL 엔진이 새로운 구문인 `TRUNCATE orders`를 해석하도록 강제합니다. 순식간에 모든 주문 내역이 삭제됩니다!
이제 SQL 인젝션의 작동 방식을 알았으니, 어떻게 막을 수 있을지 알아볼 차례입니다. 성공적인 SQL 인젝션에 필요한 두 가지 조건은 다음과 같습니다.
PHP에서 SQL 인젝션 방지하기
데이터베이스 연결, 쿼리 및 사용자 입력이 필수적인 상황에서 SQL 인젝션을 어떻게 막을 수 있을까요? 다행히 방법은 매우 간단하며, 두 가지 주요 접근법이 있습니다. 첫째, 사용자 입력을 삭제하고, 둘째, 준비된 구문을 사용하는 것입니다.
사용자 입력 삭제
이전 버전의 PHP(5.5 이하, 특히 공유 호스팅 환경에서 흔히 사용)를 사용하는 경우, `mysql_real_escape_string()` 함수를 사용하여 모든 사용자 입력을 처리하는 것이 좋습니다. 기본적으로 이 함수는 문자열 내의 특수 문자를 제거하여 데이터베이스에서 사용할 때 특별한 의미를 갖지 않도록 만듭니다.
예를 들어 ‘I’m a string’과 같은 문자열이 있다면, 공격자는 작은따옴표(‘)를 이용하여 데이터베이스 쿼리를 조작하고 SQL 인젝션을 유발할 수 있습니다. 하지만 `mysql_real_escape_string()`을 사용하면 작은따옴표 앞에 백슬래시(\)를 추가하여 ‘I\’m a string’으로 이스케이프 처리합니다. 결과적으로 이 문자열은 더 이상 쿼리 조작에 사용될 수 없게 되고, 단순히 무해한 문자열로 데이터베이스에 전달됩니다.
하지만 이 방법에는 큰 단점이 있습니다. PHP에서 아주 구식의 데이터베이스 접근 방식과 함께 사용되는 아주 오래된 기술이라는 것입니다. PHP 7부터 이 함수는 더 이상 존재하지 않으므로, 다음 해결책으로 넘어가야 합니다.
준비된 구문 사용
준비된 구문은 데이터베이스 쿼리를 더욱 안전하고 신뢰성 있게 만드는 방법입니다. 핵심 아이디어는 원시 쿼리를 데이터베이스로 직접 보내는 대신, 먼저 데이터베이스에 보낼 쿼리의 구조를 미리 알리는 것입니다. 이것이 바로 ‘준비’한다는 의미입니다. 구문이 준비되면, 데이터베이스는 이전에 보낸 쿼리 구조에 입력값을 ‘채워넣을’ 수 있도록 매개변수화된 입력으로 데이터를 전달합니다. 이렇게 하면 입력값이 특별한 권한을 갖지 못하고, 전체 프로세스에서 단순한 변수(또는 페이로드)로 처리됩니다. 준비된 구문의 예시는 다음과 같습니다.
<?php $servername = "localhost"; $username = "username"; $password = "password"; $dbname = "myDB"; // 연결 생성 $conn = new mysqli($servername, $username, $password, $dbname); // 연결 확인 if ($conn->connect_error) { die("연결 실패: " . $conn->connect_error); } // 준비 및 바인딩 $stmt = $conn->prepare("INSERT INTO MyGuests (firstname, lastname, email) VALUES (?, ?, ?)"); $stmt->bind_param("sss", $firstname, $lastname, $email); // 매개변수 설정 및 실행 $firstname = "John"; $lastname = "Doe"; $email = "[email protected]"; $stmt->execute(); $firstname = "Mary"; $lastname = "Moe"; $email = "[email protected]"; $stmt->execute(); $firstname = "Julie"; $lastname = "Dooley"; $email = "[email protected]"; $stmt->execute(); echo "새 레코드 생성 완료"; $stmt->close(); $conn->close(); ?>
준비된 구문을 처음 접하는 분들에게는 과정이 다소 복잡하게 느껴질 수 있지만, 그 개념은 충분히 익힐 가치가 있습니다. 자세한 내용은 여기에서 확인할 수 있습니다.
이미 PHP의 PDO 확장에 익숙하고 이를 사용하여 준비된 구문을 만드는 분들을 위해 몇 가지 주의사항을 알려드리겠습니다.
경고: PDO를 설정할 때 주의하십시오!
데이터베이스 접근에 PDO를 사용하면, 잘못된 보안 인식에 빠지기 쉽습니다. “아, PDO를 사용하고 있으니, 이제 보안에 대해서는 더 이상 걱정할 필요가 없겠지?” 라는 생각을 하기 쉽습니다. PDO(또는 MySQLi 준비된 구문)는 대부분의 SQL 인젝션 공격을 효과적으로 막아주지만, 설정 시 주의를 기울여야 합니다. 일반적으로 튜토리얼이나 이전 프로젝트에서 코드를 복사해서 붙여넣는 경우가 많지만, 이 설정으로 인해 모든 노력이 수포로 돌아갈 수 있습니다.
$dbConnection->setAttribute(PDO::ATTR_EMULATE_PREPARES, true);
위 설정은 PDO에게 데이터베이스의 준비된 구문 기능을 실제로 사용하는 대신, 준비된 구문을 에뮬레이션하도록 지시합니다. 결과적으로 PHP는 준비된 구문을 만들고 매개변수를 설정하는 것처럼 보이지만, 실제로는 간단한 쿼리 문자열을 데이터베이스로 보냅니다. 즉, 이전과 마찬가지로 SQL 인젝션에 취약해지는 것입니다.
해결책은 간단합니다. 이 에뮬레이션 설정을 `false`로 설정해야 합니다.
$dbConnection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
이제 PHP 스크립트는 데이터베이스 수준에서 준비된 구문을 사용하도록 강제되어 모든 형태의 SQL 인젝션을 방지합니다.
WAF를 이용한 방어
웹 애플리케이션 방화벽(WAF)을 사용하면 SQL 인젝션으로부터 웹 애플리케이션을 보호할 수 있다는 사실을 알고 계신가요?
WAF는 SQL 인젝션뿐만 아니라 사이트 간 스크립팅, 인증 손상, 사이트 간 위조, 데이터 노출과 같은 다양한 레이어 7 취약점을 방지하는 데 유용합니다. Mod Security와 같은 자체 호스팅 솔루션이나 클라우드 기반 WAF를 사용할 수 있습니다.
SQL 인젝션과 최신 PHP 프레임워크
SQL 인젝션은 너무나 흔하고, 쉽고, 실망스럽고, 위험해서 모든 최신 PHP 웹 프레임워크에는 기본적으로 이 공격에 대한 방어 기능이 내장되어 있습니다. 예를 들어 WordPress에는 `$wpdb->prepare()` 함수가 있고, MVC 프레임워크를 사용하는 경우, 프레임워크가 모든 복잡한 작업을 대신 처리하므로 SQL 인젝션에 대해 걱정할 필요조차 없습니다. WordPress에서는 구문을 명시적으로 준비해야 하는 점이 조금 번거롭지만, WordPress는 원래 그런 특징을 가지고 있습니다.
핵심은 현대 웹 개발자들은 SQL 인젝션에 대해 생각할 필요가 없고, 결과적으로 그 가능성조차 인식하지 못한다는 점입니다. 따라서 애플리케이션에 단 하나의 백도어를 열어두더라도(예를 들어 `$_GET` 쿼리 매개변수를 사용하거나 과거의 더티 쿼리 습관을 버리지 못한다면), 그 결과는 매우 심각할 수 있습니다. 따라서 항상 기초를 더 깊이 파고드는 것이 좋습니다.
결론
SQL 인젝션은 웹 애플리케이션에 대한 매우 끔찍한 공격이지만, 쉽게 피할 수 있습니다. 이 글에서 보았듯이, 사용자 입력을 처리할 때 주의를 기울이고 (SQL 인젝션은 사용자 입력 처리 시 발생할 수 있는 유일한 위협이 아닙니다), 데이터베이스를 쿼리하는 방식을 개선하는 것이 전부입니다. 웹 프레임워크가 보안 작업을 자동으로 수행해 줄 수도 있지만, 이러한 유형의 공격을 항상 인지하고, 이에 속지 않도록 주의하는 것이 중요합니다.