[1. SQL Injection]

Minseok Jo·2025년 9월 29일

웹 시큐어코딩

목록 보기
1/11
post-thumbnail

웹취약점 점검에서 개발한 웹에 대하여 점검을 진행하였고, 여러 취약점들을 발견하였다.
발견한 취약점들을 대상으로 시큐어 코딩을 진행하고자 한다.

이번 대상은 주요정보통신기반시설 분석·평가 가이드 5번 항목 - SQL Injection 이다.

SQL Injection 취약점이 발견된 항목은 다음과 같다.

URL파라미터메뉴
http://jmseok.com/web/auth/id_check.phpinput_id회원가입 > 아이디 중복체크
http://jmseok.com/web/auth/login_proc.phpinput_id로그인
http://jmseok.com/web/board/free_search.phpinput_search자유게시판 > 게시글 검색
http://jmseok.com/web/board/free_view.phpnum자유게시판 > 게시글 확인
http://jmseok.com/web/board/free_edit.phpnum자유게시판 > 게시글 수정
http://jmseok.com/web/board/mem_search.phpinput_search회원게시판 > 게시글 검색
http://jmseok.com/web/board/mem_view.phpnum회원게시판 > 게시글 확인
http://jmseok.com/web/board/mem_edit.phpnum회원게시판 > 게시글 수정
http://jmseok.com/web/board/download.phpnum회원게시판 > 첨부파일 다운로드

0. SQLi 대응방안

Prepared Statement
: SQL 쿼리의 구조와 데이터를 분리하는 방식이다.
데이터베이스에 먼저 퀴리문을 보내면, DB가 해당 쿼리문을 파싱·컴파일한다.
이후 파라미터를 보내게 되면, 이는 쿼리문으로 취급되는 것이 아닌 순수한 데이터로만 취급되므로 SQL Injection을 방어할 수 있게 된다.


1. 시큐어 코딩

[1. 회원가입 페이지 - 아이디 중복체크]

(기존)

$sql = "select * from users where user_id='$input_id'";
$result = mysqli_query($con, $sql);

if (mysqli_num_rows($result) > 0)
    echo "이미 사용중인 아이디입니다.";
else
    echo "사용 가능한 아이디입니다.";

(수정 후)

// 시큐어 코딩
$sql = "select 1 from users where user_id= ? limit 0,1";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "s", $input_id);
mysqli_stmt_execute($stmt);
mysqli_stmt_store_result($stmt);

$count = mysqli_stmt_num_rows($stmt);

if ($count > 0)
    echo "이미 사용중인 아이디입니다.";
else
    echo "사용 가능한 아이디입니다.";

1) $sql = "select 1 from users where user_id= ? limit 0,1";

  • SQL 템플릿으로 변경, ?가 placeholder로서, 나중에 실제 값으로 바인딩
  • select 1 을 통해 실제 컬럼값이 아닌, 상수값 1만 반환하도록 함으로써 존재 여부만 확인하도록 최적화

2) $ stmt = mysqli_prepare($con, $sql);

  • DB에 퀴리 템플릿을 준비(prepare) 하도록 요청
  • 성공시 statement 객체 반환, 실패시 false 반환

3) mysqli_stmt_bind_param($stmt, "s", $input_id);

  • placeholder(?)에 실제 값(파라미터)를 반환
  • "s"는 바인딩되는 파라미터의 타입을 의미한다. (string)
  • 해당 과정을 통해 $input_id를 문자열로 바인딩한다. 바인딩된 값은 데이터로서 취급되며, 퀴리구조로 취급되지 않는다.

4) mysqli_stmt_execute($stmt);

  • 준비된 statement를 실행한다. 이 시점에 실제 바인딩 값이 서버로 전송되어 쿼리가 실행된다.

5) mysqli_stmt_store_result($stmt);

  • 실행결과를 클라이언트(php) 쪽에 버퍼링(저장)한다.

6) $ count = mysqli_stmt_num_rows($stmt);

  • 저장된 결과(버퍼)에 대하여 행의 개수를 반환

[2. 로그인 처리페이지]

(기존)

$input_id = $_POST["input_id"];
$input_pw = hash('sha256', $_POST["input_pw"]);

$sql = "select * from users where user_id = '$input_id'";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);
$db_pass = $row["user_pw"];

mysqli_close($con);

if ($input_pw == $db_pass) {
    session_start();
    $_SESSION["user_id"] = $row["user_id"];
    $_SESSION["user_name"] = $row["user_name"];

    echo "<script>
        alert('로그인 완료');
        location.href='../main/index.php';
    </script>";
} else {
    echo "<script>
        alert('정보가 일치하지 않습니다.');
        history.go(-1);
    </script>";
}

(수정 후)

// 시큐어 코딩
$input_id = $_POST["input_id"];
$input_pw = hash('sha256', $_POST["input_pw"]);

$sql = "select user_pw, user_id, user_name from users where user_id = ? limit 0,1";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "s", $input_id);
mysqli_stmt_execute($stmt);
mysqli_stmt_store_result($stmt);

if (mysqli_stmt_num_rows($stmt) == 0) {
    mysqli_stmt_close($stmt);
    mysqli_close($con);

    echo "<script>
        alert('정보가 일치하지 않습니다.');
        history.go(-1);
    </script>";
    exit;
}

mysqli_stmt_bind_result($stmt, $db_pass, $db_user_id, $db_user_name);
mysqli_stmt_fetch($stmt);

mysqli_stmt_close($stmt);
mysqli_close($con);

if ($input_pw == $db_pass) {
    session_start();
    $_SESSION["user_id"] = $db_user_id;
    $_SESSION["user_name"] = $db_user_name;

    echo "<script>
        alert('로그인 완료');
        location.href='../main/index.php';
    </script>";
} else {
    echo "<script>
        alert('정보가 일치하지 않습니다.');
        history.go(-1);
    </script>";
}

1)
$ sql = "select user_pw, user_id, user_name from users where user_id = ? limit 0,1";
$ stmt = mysqli_prepare($ con, $ sql);
mysqli_stmt_bind_param($ stmt, "s", $ input_id);
mysqli_stmt_execute($ stmt);
mysqli_stmt_store_result($ stmt);

Prepared Statement 동작 동일

2) mysqli_stmt_bind_result($ stmt, $ db_pass, $ db_user_id, $ db_user_name);

  • select 문에서 지정한 컬럼 순서대로 각각 $db_pass, $db_user_id, $db_user_name에 매핑

3) mysqli_stmt_fetch($stmt);

  • DB에서 조회한 값을 위에서 지정한 변수에 할당

[3. 자유게시판 - 게시글 검색]

(기존)

$sql = "select count(*) as total from freeboard where ($input_select like '%{$input_search}%')";
$result = mysqli_query($con, $sql);
$_result = mysqli_fetch_assoc($result);
$count = $_result["total"];

+

$sql = "select * from freeboard where ($input_select like '%{$input_search}%')
            order by $order desc limit $limit offset $offset";
$result = mysqli_query($con, $sql);

(수정 후)

// 시큐어 코딩
$sql = "select count(*) as total from freeboard where ($input_select like concat('%', '?', '%')";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "s", $input_search);
mysqli_stmt_execute($stmt);
mysqli_stmt_bind_result($stmt, $count);
mysqli_stmt_fetch($stmt);
mysqli_stmt_close($stmt);

+

$sql = "select * from freeboard wehre $input_select like concat('%',?,'%') order by $order desc limit ? offset ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "sii", $input_search, $limit, $offset);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);

1) mysqli_stmt_bind_param($stmt, "sii", $input_search, $limit, $offset);
위의 경우, 바인딩 되는 Placeholder(?)의 개수가 3개이므로, 타입 지정 문자열도 3개로 지정해주었다.

타입 지정 문자열
"s" : string (문자열)
"i" : integer (정수)
"d" : double (실수)
"b" : blob (바이너리 데이터)


[4. 자유게시판 - 게시글확인]

(기존)

$sql = "select * from freeboard where num = {$_GET['num']}";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

+

$sql = "update freeboard set views={$views} where (num={$_GET['num']})";
mysqli_query($con, $sql);

+

$sql = "select * from freeboard where num = {$_GET['num']}";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

(수정 후)

// 시큐어 코딩
$sql = "select * from freeboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $_GET["num"]);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);    
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

+

$sql = "update freeboard set views = ? where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "ii", $views, $_GET["num"]);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);

+

$sql = "select * from freeboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $_GET["num"]);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

Prepared Statement 동작은 위와 동일하다.

※ 특이점 발견 내용

num = 56 and 1=1 실행 결과

Prepared Statement를 적용한뒤, num 파라미터에 대하여 56 and 1=1을 넣어 SQLi를 시도하였을때, 56번 게시글이 정상적으로 출력되었다.

전달받는 num파라미터는 정수로 인식되도록 하였는데, 56 and 1=1은 정수가 아닌 상황이다.
그럼에도 불구하고 56번 게시글이 정상적으로 출력되는 이유는 무엇일까?

"59 and 1=1" 문자열이 바인딩되면 MySQL은 그 문자열을 숫자로 변환하려고 시도한다.
숫자로 변환할 때 문자열의 앞부터 변환가능한 숫자 부분만 읽는다.
따라서 처음 만나는 59는 숫자 59로 변환되며, 뒤의 and 1=1은 데이터로서 무시된다. 따라서 num=59(숫자) 가 되어 59번 글이 정상 조회된다.


[5. 자유게시판 - 게시글수정]

(기존)

$sql = "select * from freeboard where num=$num and password=$input_pass";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

+

$sql = "delete from freeboard where num = $num";

(수정 후)

// 시큐어 코딩
$sql = "select * from freeboard where num= ? and password= ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "ii", $num, $input_pass);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

+

$sql = "delete from freeboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $num);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);

Prepared Statement 동작은 위와 동일하다.


[6. 회원게시판 - 게시글검색]

(기존)

$sql = "select count(*) as total from memboard where ($input_select like '%{$input_search}%')";
$result = mysqli_query($con, $sql);
$_result = mysqli_fetch_assoc($result);
$count = $_result["total"];

+

$sql = "select * from memboard where ($input_select like '%{$input_search}%')
            order by $order desc limit $limit offset $offset";
$result = mysqli_query($con, $sql);

(수정 후)

// 시큐어 코딩
$sql = "select count(*) as total from memboard where $input_select like concat('%',?,'%')";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "s", $input_search);
mysqli_stmt_execute($stmt);
mysqli_stmt_bind_result($stmt, $count);
mysqli_stmt_fetch($stmt);
mysqli_stmt_close($stmt);

+

$sql = "select * from memboard where $input_select like concat('%',?,'%') order by $order desc limit ? offset ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "sii", $input_search, $limit, $offset);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt)

Prepared Statement 동작은 위와 동일하다.


[7. 회원게시판 - 게시글확인]

(기존)

$sql = "select title, content, user_name, write_time, views, likes from memboard where num = {$_GET['num']}";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

+

$sql = "update memboard set views={$views} where (num={$_GET['num']})";
mysqli_query($con, $sql);

+

$sql = "select * from memboard where num = {$_GET['num']}";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

(수정 후)

// 시큐어 코딩
$sql = "select * from memboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $_GET["num"]);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

+

$sql = "update memboard set views= ? where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "ii", $views, $_GET["num"]);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);

+

$sql = "select * from memboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $_GET["num"]);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

Prepared Statement 동작은 위와 동일하다.


[8. 회원게시판 - 게시글수정]

(기존)

$sql = "select * from memboard where num=$num";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

+

$sql = "delete from memboard where num = $num";
mysqli_query($con, $sql);

+

$sql = "select * from likes where board_num=$num and user_id='{$_SESSION['user_id']}'";
$result = mysqli_query($con, $sql);

+

$sql = "insert into likes (board_num, user_id) values ($num, '{$_SESSION['user_id']}')";
mysqli_query($con, $sql);
+

$sql = "update memboard set likes=$likes, views=$views where num=$num";
mysqli_query($con, $sql);

(수정 후)

// 시큐어 코딩
$sql = "select * from memboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $num);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);
mysqli_stmt_close($stmt);

+

$sql = "delete from memboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $num);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);

+

$sql = "select * from likes where board_num=? and user_id= ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "is", $num, $user_id);
mysqli_stmt_execute($stmt);
mysqli_stmt_store_result($stmt);
$count = mysqli_stmt_num_rows($stmt);
mysqli_stmt_close($stmt);
        
+

$sql = "insert into likes (board_num, user_id) values (?, ?)";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "is", $num, $user_id);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);
            
+

$sql = "update memboard set likes=$likes, views=$views where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $num);
mysqli_stmt_execute($stmt);
mysqli_stmt_close($stmt);

Prepared Statement 동작은 위와 동일하다.


[9. 회원게시판 - 파일다운로드]

(기존)

$sql = "select file_name, file_copy from memboard where num={$_GET['num']}";
$result = mysqli_query($con, $sql);
$row = mysqli_fetch_assoc($result);

(수정 후)

// 시큐어 코딩
$sql = "select file_name, file_copy from memboard where num = ?";
$stmt = mysqli_prepare($con, $sql);
mysqli_stmt_bind_param($stmt, "i", $num);
mysqli_stmt_execute($stmt);
$result = mysqli_stmt_get_result($stmt);
$row = mysqli_fetch_assoc($result);

Prepared Statement 동작은 위와 동일하다.


2. 보안 조치 결과

SQL Injection
input_search 파라미터에, 참 조건 스크립트 [test%'+and+'1%'='1] 를 입력하여도, 실행 결과가 나타나지 않음


0개의 댓글