beebox > create user 탭 > 회원가입 > 회원가입한 계정으로 로그인 > Cross-Site Request Forgery - ChangePassword
스크립트가 포함된 게시글을 조회한 사용자의 비밀번호를 강제로 변경시키기
패스워드 변경 페이지 확인
패스워드 변경 시도 시 재인증 절차 없이 패스워드가 변경되는 것 확인할 수 있다.
패스워드 변경 요청 주소 확인해보면 정상절차를 검증하는 토큰 값이 포함되어 있지 않다.
패스워드를 변경하는 중요 기능을 구현할 때 요청 주체 및 요청 절차를 확인하지 않고 패스워드를 변경하고 있다. ⇒ CSRF 취약점 존재
로그아웃 후 재로그인 시도 시 기존 패스워드로는 로그인이 불가하고 변경한 패스워드로는 로그인이 가능하다.
자동으로 패스워드 변경을 요청을 발생시키는 코드를 작성하여 게시판에 올려보자.
<iframe src="http://beebox/bWAPP/csrf_1.php?password_new=1234&password_conf=1234&action=chage" witdh="0" height="0"></iframe>
이렇게 업로드 된 게시물을 다른 사용자가 조회하게 되면 요청하지 않았어도 자동으로 패스워드가 변경된다.
개발자 도구 > Network 탭에서 요청이 전달됐는지 확인할 수 있다.
beebox VM에서 소스 코드를 확인해보자.
$ sudo gedit /var/www/bWAPP/csrf_1.php
패스워드 변경에 필요한 사용자 입력을 받아 서버로 전달하는 부분
<form action="<?php echo($_SERVER["SCRIPT_NAME"]); ?>" method="GET">
<?php
# 보안 등급이 높으면 현재 패스워드를 함께 입력하도록 되어 있다.
if($_COOKIE["security_level"] == "1" or $_COOKIE["security_level"] == "2")
{
?>
<p><label for="password_curr">Current password:</label><br />
<input type="password" id="password_curr" name="password_curr"></p>
<?php
}
?>
# 보안 등급이 낮은 경우 패스워드 변경에 꼭 필요한 값만 입력받는다. (변경할 패스워드)
<p><label for="password_new">New password:</label><br />
<input type="password" id="password_new" name="password_new"></p>
<p><label for="password_conf">Re-type new password:</label><br />
<input type="password" id="password_conf" name="password_conf"></p>
<button type="submit" name="action" value="change">Change</button>
</form>
서버 내부에서 처리
# 패스워드 변경에 필요한 값을 포함하고 있는 요청 파라미터가 존재하는 확인
if(isset($_REQUEST["action"]) && isset($_REQUEST["password_new"]) && isset($_REQUEST["password_conf"]))
{
$password_new = $_REQUEST["password_new"];
$password_conf = $_REQUEST["password_conf"];
# 새 패스워드 값이 없는 경우
if($password_new == "")
{
$message = "<font color=\"red\">Please enter a new password...</font>";
}
else
{
# 새 패스워드와 새 패스워드 확인의 값이 다른 경우
if($password_new != $password_conf)
{
$message = "<font color=\"red\">The passwords don't match!</font>";
}
else
{
# 패스워드 변경 대상 정보를 추출 = 변경 대상의 ID를 추출 = 로그인한 사용자의 ID를 추출
$login = $_SESSION["login"];
$password_new = mysqli_real_escape_string($link, $password_new);
$password_new = hash("sha1", $password_new, false);
# 보안 등급이 낮은 경우 요청 주체를 확인하지(재인증하지 않고) 요청을 처리
if($_COOKIE["security_level"] != "1" && $_COOKIE["security_level"] != "2")
{
$sql = "UPDATE users SET password = '" . $password_new . "' WHERE login = '" . $login . "'";
$recordset = $link->query($sql);
if(!$recordset)
{
die("Connect Error: " . $link->error);
}
$message = "<font color=\"green\">The password has been changed!</font>";
}
else
{
# 보안 등급이 높은 경우, 현재 패스워드가 요청 파라미터로 전달되었는 확인
if(isset($_REQUEST["password_curr"]))
{
$password_curr = $_REQUEST["password_curr"];
$password_curr = mysqli_real_escape_string($link, $password_curr);
$password_curr = hash("sha1", $password_curr, false);
# 현재 로그인한 사용자의 패스워드가 요청 파라미터로 전달된 것과 동일한지 확인
$sql = "SELECT password FROM users WHERE login = '" . $login . "' AND password = '" .$password_curr . "'";
$recordset = $link->query($sql);
if(!$recordset)
{
die("Connect Error: " . $link->error);
}
$row = $recordset->fetch_object();
# 패스워드가 일치하는 경우(=로그인한 사용자의 요청이 맞음) 패스워드를 변경
if($row)
{
$sql = "UPDATE users SET password = '" . $password_new . "' WHERE login = '" . $login . "'";
$recordset = $link->query($sql);
if(!$recordset)
{
die("Connect Error: " . $link->error);
}
$message = "<font color=\"green\">The password has been changed!</font>";
}
else
{
$message = "<font color=\"red\">The current password is not valid!</font>";
}
}
}
}
}
}
?>
openeg > 로그인 > 게시판에 스크립트 작성 가능한지 확인
스크립트가 포함되어 있는 게시글 조회 시 스크립트가 실행되는 것을 확인할 수 있다.
아래 코드를 포함한 게시물을 작성한다.
<form action="write.do" method="post" enctype="multipart/form-data">
<input type="text" name="subject" value="저렴한 대출상품 안내" />
<input type="hidden" name="writer" value="김실장" />
<input type="hidden" name="writerId" value="test" />
<textarea name="content">시중에서 가장 저렴한 대출금리의 상품을 연결해 드립니다. 연락주세요.</textarea>
<input type="submit" id="btnSubmit" />
</form>
<script>
document.getElementById("btnSubmit").click();
</script>
작성된 스크립트에서 Submit Query
버튼을 클릭하면 자동으로 게시물이 작성되는 것을 확인할 수 있다.
// BoardController.java
// 게시판 글쓰기 페이지 요청
@RequestMapping("/write.do")
public String boardWrite(@ModelAttribute("BoardModel") BoardModel boardModel, HttpSession session) {
// (1) 임의의 난수를 발생 시켜서 세션에 저장
String token = UUID.randomUUID().toString(); // 추가
session.setAttribute("session_token", token); // 추가
return "/board/write";
}
<!-- write.jsp -->
<form action="write.do" method="post" onsubmit="return writeFormCheck()" enctype="multipart/form-data">
<!-- (2) 세션에 저장된 토큰값을 HIDDEN 필드의 값으로 설정 -->
<input type="hidden" name="request_token" value="${session_token}" /> <!-- 추가 -->
<table class="boardWrite">
<tr>
<th><label for="subject">제목</label></th>
<td>
<input type="text" id="subject" name="subject" class="boardSubject" size="70" />
<input type="hidden" id="writer" name="writer" value="${userName}" />
<input type="hidden" id="writerId" name="writerId" value="${userId}" />
</td>
</tr>
<tr>
<th><label for="content">내용</label></th>
<td>
<textarea id="boardContent" name="content" class="boardContent"></textarea>
</td>
</tr>
<tr>
<th><label for="file">파일</label></th>
<td>
<input type="file" id="file" name="file" />
<span class="date"> * 임의로 파일명이 변경될 수 있습니다.</span>
</td>
</tr>
</table>
<br />
<center>
<input type="reset" value="재작성" class="writeBt" />
<input type="submit" value="확인" class="writeBt" />
</center>
</form>
// BoardController.java
// 게시판 글 저장
@RequestMapping(value = "/write.do", method = RequestMethod.POST)
public String boardWriteProc(@ModelAttribute("BoardModel") BoardModel boardModel, MultipartHttpServletRequest request, HttpSession session) {
// (3) 세션에 저장된 토큰 값과 요청 파라미터를 통해 전달된 토큰 값을 비교
// 일치하면 기존처럼 글 저장을 처리하고, 일치하지 않으면 오류 메시지를 반환
String stoken = (String)session.getAttribute("session_token");
String ptoken = request.getParameter("request_token");
if (ptoken == null || !ptoken.equals(stoken)) {
session.setAttribute("error_message", "잘못된 접근입니다.");
return "redirect:list.do";
}
//list.jsp
<script type="text/javascript">
function selectedOptionCheck(){
$("#type > option[value=<%=request.getParameter("type")%>]").attr("selected", "true");
}
function moveAction(where){
switch (where) {
case 1:
location.href = "write.do";
break;
case 2:
location.href = "list.do";
break;
}
}
// (4) 세션에 error_message가 있는 경우 출력
<c:if test="!empty error_message">
alert("${error_message}");
// (5) 메세지 출력 후 세션에 저장된 error_message의 값을 삭제
<c:set var="error_message" value="" />
</c:if>
</script>
Cross-Site Request Forgery (Change Secret)은 BeeBox에서는 해당 실습을 CSRF로 분류하고 있으나 보안기능 결정에 사용되는 부적절한 입력값 취약점으로 보는 것이 맞다.
외부에서 입력도진 값에 의존해서 주요 기능을 처리할 때 발생
외부에서 입력된 값은 제한적으로 사용해야 하며, 반드시 검증 후 사용
계좌이체 서비스에서 다른 사람 계좌로 들어갈 금액을 내 계좌로 가져오기
GET /bWAPP/csrf_2.php
account=123-45678-90&amount=0&action=transfer
중요 기능임에도 정상적인 절차에 따른 요청인지 확인하지 않고, 요청 주체를 확인하는 로직이 없다.
계좌번호를 변경하면 내가 보내고자 했던 계좌번호가 아닌 엉뚱한 계좌로 송금되는 것을 확인할 수 있다.