
웹취약점 점검에서 개발한 웹에 대하여 점검을 진행하였고, 여러 취약점들을 발견하였다.
발견한 취약점들을 대상으로 시큐어 코딩을 진행하고자 한다.
이번 대상은 주요정보통신기반시설 분석·평가 가이드 5번 항목 - SQL Injection 이다.
SQL Injection 취약점이 발견된 항목은 다음과 같다.
| URL | 파라미터 | 메뉴 |
|---|---|---|
| http://jmseok.com/web/auth/id_check.php | input_id | 회원가입 > 아이디 중복체크 |
| http://jmseok.com/web/auth/login_proc.php | input_id | 로그인 |
| http://jmseok.com/web/board/free_search.php | input_search | 자유게시판 > 게시글 검색 |
| http://jmseok.com/web/board/free_view.php | num | 자유게시판 > 게시글 확인 |
| http://jmseok.com/web/board/free_edit.php | num | 자유게시판 > 게시글 수정 |
| http://jmseok.com/web/board/mem_search.php | input_search | 회원게시판 > 게시글 검색 |
| http://jmseok.com/web/board/mem_view.php | num | 회원게시판 > 게시글 확인 |
| http://jmseok.com/web/board/mem_edit.php | num | 회원게시판 > 게시글 수정 |
| http://jmseok.com/web/board/download.php | num | 회원게시판 > 첨부파일 다운로드 |
Prepared Statement
: SQL 쿼리의 구조와 데이터를 분리하는 방식이다.
데이터베이스에 먼저 퀴리문을 보내면, DB가 해당 쿼리문을 파싱·컴파일한다.
이후 파라미터를 보내게 되면, 이는 쿼리문으로 취급되는 것이 아닌 순수한 데이터로만 취급되므로 SQL Injection을 방어할 수 있게 된다.
(기존)
$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";
2) $ stmt = mysqli_prepare($con, $sql);
3) mysqli_stmt_bind_param($stmt, "s", $input_id);
4) mysqli_stmt_execute($stmt);
5) mysqli_stmt_store_result($stmt);
6) $ count = mysqli_stmt_num_rows($stmt);
(기존)
$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);
3) mysqli_stmt_fetch($stmt);
(기존)
$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 (바이너리 데이터)
(기존)
$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번 글이 정상 조회된다.
(기존)
$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 동작은 위와 동일하다.
(기존)
$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 동작은 위와 동일하다.
(기존)
$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 동작은 위와 동일하다.
(기존)
$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 동작은 위와 동일하다.
(기존)
$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 동작은 위와 동일하다.
| SQL Injection |
|---|
![]() |
| ▲ input_search 파라미터에, 참 조건 스크립트 [test%'+and+'1%'='1] 를 입력하여도, 실행 결과가 나타나지 않음 |