본문 바로가기

프로젝트/DVWA 실습

DVWA 실습 #8 - SQL Injection

DVWA의 일곱 번째 실습 대상인 SQL Injection이다. 현재 데이터베이스에는 5명의 사용자 정보(이름, 비밀번호 등)가 저장되어 있으며 SQL Injection으로 이들의 비밀번호를 탈취하는 것이 목적이다. 현재는 해당 사용자의 ID를 입력하면 이름(First Name, Surname)만 출력하고 있으며 난이도에 따라 ID 입력 방식이 조금씩 달라지고 있다.

SQL Injection?

SQL Injection은 사용자 입력이 서버측에서 실행되는 SQL 쿼리에 삽입되어 의도했던 것과 다른 동작을 수행하게 되는 취약점이다. 이전에 실습했던 커맨드 인젝션처럼 Injection 취약점으로 데이터베이스 종류에 따라 형태는 달라지지만 데이터베이스와 연결된 대부분의 사이트에서 공격 대상이 된다. 공격자는 이를 통해 다른 사용자의 정보나 데이터베이스 구조 등 민감한 정보를 알아내거나 수정할 수 있다.

 

이 취약점이 발생하는 원인은 결국 사용자 입력에 대한 검증이 미비했기 때문이다. 보통 올바르지 않은 값을 입력하면 원하는 값이 나오지 않기 때문에 막연히 '사용자가 정상적인 값을 입력하겠지'라고 생각할 수 있는데 공격자는 일부러 올바르지 않은 값을 입력하여 서버를 공격하기 때문에 사용자 입력값은 항상 검증될 필요가 있다.

다른 원인은 쿼리가 동적으로 생성되기 때문이다. 동적 쿼리는 "select * from table where name=$_GET['name']" 처럼 사용자 입력에 따라 동적으로 생성되는 쿼리를 말한다. 이렇게 사용자 입력을 직접 쿼리문에 삽입하는 대신 지정된 파라미터를 가진 쿼리문을 미리 생성해두고 사용자 입력값을 삽입하는 자바의 PreparedStatement나 PHP의 PDO(PHP Data Object)를 사용하는 것이 안전하다.

데이터베이스에 접근하는 계정의 권한을 세부적으로 설정해서 불필요한 작업이 공격자에 의해 발생하지 않도록 하는 것도 좋다.

 

SQL Injection은 OWASP Top10 리스트에도 꾸준히 등장하는 항목으로 공격자의 역량이나 적용된 방지책에 따라 피해 규모가 달라지지만 사용자 입력을 받는 모든 웹 애플리케이션에서 발생할 수 있기 때문에 위험도가 높은 취약점이다. 공격 종류는 일반적인 sqli부터 blind, union, error based 등 수많은 방법이 있으며 이에 따른 수많은 매뉴얼과 Cheat Sheet도 존재한다. 이를 여기서 하나하나 나열하는 것은 의미가 없고 너무 많기 때문에 하지 않겠다. 대신 SQL Injection | OWASP'SQL Injection Walkthrough' - SecuriTeam, SQL Injection Cheat Sheet | Netsparker를 읽어보면 좋다.

Security Level: low

이 단계에서는 다음과 같은 요청이 전송된다.

GET /dvwa/vulnerabilities/sqli/?id=1&Submit=Submit HTTP/1.1
Host: 192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/sqli/?id=7&Submit=Submit
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; PHPSESSID=27f9878f2479d4c7b9d673fdabe1f98b; security_level=1
Connection: close

GET 메서드기 때문에 조회하는 사용자 id가 URL 파라미터로 전달되는 것을 볼 수 있다.이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?php

if( isset( $_REQUEST[ 'Submit' ] ) ) {
    // Get input
    $id = $_REQUEST[ 'id' ];

    // Check database
    $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
    $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

    // Get results
    $num = mysql_numrows( $result );
    $i   = 0;
    while( $i < $num ) {
        // Get values
        $first = mysql_result( $result, $i, "first_name" );
        $last  = mysql_result( $result, $i, "last_name" );

        // Feedback for end user
        echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";

        // Increase loop count
        $i++;
    }

    mysql_close();
}

?>

$_REQUEST 변수는 GET과 POST로 받은 파라미터들을 같이 저장하고 있다. 그 중 id 파라미터가 전달되었다면 이를 동적 쿼리에 직접 삽입한 후 mysql_query() 함수를 호출하여 데이터베이스에 SQL 쿼리를 전달하고 있다. 그런데 이 코드에서는 사용자 입력에 대한 검증이 아무것도 없기 때문에 SQL Injection에 취약하며 결과로 받은 데이터들이 여러개 있을 경우 모두 출력하는 로직이 구현되어 있다. 회원 별로 고유한 id를 조회하는데 여러개의 데이터를 출력하는 경우를 고려할 필요가 있을까? 여러모로 문제점이 많은 취약한 코드다.

Security Level: medium

이번 단계에는 사용자 입력이 아닌 리스트 박스를 활용하여 사용자 ID를 선택하도록 하고 있다. 이 단계에서는 다음과 같은 요청이 전송된다.

POST /dvwa/vulnerabilities/sqli/ HTTP/1.1
Host: 192.168.56.103
Content-Length: 18
Cache-Control: max-age=0
Origin: http://192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/sqli/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=medium; PHPSESSID=27f9878f2479d4c7b9d673fdabe1f98b; security_level=1
Connection: close

id=1&Submit=Submit

GET 메서드를 활용했던 Low 단계와 달리 POST 메서드를 활용하는 것을 볼 수 있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?php

if( isset( $_POST[ 'Submit' ] ) ) {
    // Get input
    $id = $_POST[ 'id' ];
    $id = mysql_real_escape_string( $id );

    // Check database
    $query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
    $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

    // Get results
    $num = mysql_numrows( $result );
    $i   = 0;
    while( $i < $num ) {
        // Display values
        $first = mysql_result( $result, $i, "first_name" );
        $last  = mysql_result( $result, $i, "last_name" );

        // Feedback for end user
        echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";

        // Increase loop count
        $i++;
    }

    //mysql_close();
}

?>

이번 단계부터는 mysql_real_escape_string() 함수를 id 파라미터에 적용하고 있다. LoS를 풀 때도 이 함수가 적용됐으면 따옴표나 역슬래시같은 특수문자들은 사용할 엄두도 못 냈는데 그러면 SQL Injection에 안전한 게 아닐까? 하지만 여전히 동적 쿼리기 때문에 쿼리가 WHERE 절에서 끝나지 않고 추가적으로 삽입될 수 있다는 문제가 있다. 그리고 꼭 따옴표같은 게 들어가야 SQL Injection이 가능한 것도 아니기 때문에 여전히 취약한 코드다.

Security Level: high

이 단계에서는 아이디 검색 폼을 별도의 창으로 띄우고 있다. Submit 버튼을 클릭하면 다음과 같은 요청이 전송된다.

POST /dvwa/vulnerabilities/sqli/session-input.php HTTP/1.1
Host: 192.168.56.103
Content-Length: 18
Cache-Control: max-age=0
Origin: http://192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/sqli/session-input.php
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=high; PHPSESSID=27f9878f2479d4c7b9d673fdabe1f98b; security_level=1
Connection: close

id=1&Submit=Submit

이 요청 이후 실습 페이지로 한번 더 요청이 전송되는데 딱히 포함된 파라미터는 없지만 위 페이지에서 제출한 아이디 값의 계정 정보를 실습 페이지에 업데이트하고 있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?php

if( isset( $_SESSION [ 'id' ] ) ) {
    // Get input
    $id = $_SESSION[ 'id' ];

    // Check database
    $query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
    $result = mysql_query( $query ) or die( '<pre>Something went wrong.</pre>' );

    // Get results
    $num = mysql_numrows( $result );
    $i   = 0;
    while( $i < $num ) {
        // Get values
        $first = mysql_result( $result, $i, "first_name" );
        $last  = mysql_result( $result, $i, "last_name" );

        // Feedback for end user
        echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";

        // Increase loop count
        $i++;
    }

    mysql_close();
}

?>

이번 단계에서는 $_SESSION 변수를 이용하여 세션 값을 참조하고 있다. 아마 새로 열린 페이지에서 id를 입력하고 Submit하면 현재 로그인한 사용자의 세션에 해당 id 값을 저장하는 듯 한데 이번에는 SQL Injection 필터링이 적용되지 않은 대신 한번에 한 계정 정보만 출력할 수 있도록 LIMIT으로 출력 제한을 걸고 있다. 하지만 근본적으로 불필요한 로직(여러 결과를 출력하는 while 문)을 제거하지 않았기 때문에 그리고 동적 쿼리를 생성하고 있기 때문에 아직까지 취약하다고 할 수 있다.

Security Level: impossible

이 단계에서는 Low 단계와 같은 입력폼으로 돌아왔으며 다음과 같은 요청이 전송된다.

GET /dvwa/vulnerabilities/sqli/?id=&Submit=Submit&user_token=e55b43d87d0cc35f11484df187934236 HTTP/1.1
Host: 192.168.56.103
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.56.103/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit&user_token=5e60b04474d63e1c8ca2e0ef5219d274
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=impossible; PHPSESSID=27f9878f2479d4c7b9d673fdabe1f98b; security_level=1
Connection: close

다시 GET 메서드로 조회하고 있으며 CSRF 토큰이 사용된 것을 볼 수 있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?php

if( isset( $_GET[ 'Submit' ] ) ) {
    // Check Anti-CSRF token
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

    // Get input
    $id = $_GET[ 'id' ];

    // Was a number entered?
    if(is_numeric( $id )) {
        // Check the database
        $data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
        $data->bindParam( ':id', $id, PDO::PARAM_INT );
        $data->execute();
        $row = $data->fetch();

        // Make sure only 1 result is returned
        if( $data->rowCount() == 1 ) {
            // Get values
            $first = $row[ 'first_name' ];
            $last  = $row[ 'last_name' ];

            // Feedback for end user
            echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
        }
    }
}

// Generate Anti-CSRF token
generateSessionToken();

?>

먼저 파라미터로 받은 CSRF 토큰을 세션에 저장된 토큰과 검증한 후 전달받은 id 파라미터가 오직 숫자로만 이루어져 있는지 is_numeric() 함수를 이용해 검사하고 있다. 그리고 자체적으로 SQL Injection 필터링이 적용되는 PDO를 활용하여 미리 생성(prepare)된 쿼리에 안전하게 파라미터 값을 삽입하고 있다. 사용자 입력값에 대해 필터링이 이루어지고 동적 쿼리가 아닌 정적 쿼리를 사용하기 때문에 이 코드는 안전하다고 할 수 있다.