티스토리 뷰


CSRF 공격(Cross Site Request Forgery)은 웹 어플리케이션 취약점 중 하나로 인터넷 사용자(희생자)가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 만드는 공격입니다.

CSRF를 통해 해커는 희생자의 권한을 도용하여 중요 기능을 실행하는 것이 가능합니다. 예를들어, 페이스북에 희생자의 계정으로 광고성 글을 올리는 것이 가능해 집니다. (물론 페이스북은 CSRF 공격에 대해 잘 대응을 하였겠지만, 이번 글에서 피해 서비스 = 페이스북으로 설명하겠습니다.)

조금 더 설명하자면, CSRF는 해커가 사용자의 컴퓨터를 감염시키거나 페이스북 서버를 해킹을 해서 이뤄지는 공격은 아닙니다. 그래서 CSRF 공격이 이뤄지려면 다음 조건이 만족되어야 합니다.

  • 위조 요청을 전송하는 서비스(페이스북)에 희생자가 로그인 상태
  • 희생자가 해커가 만든 피싱 사이트에 접속

언뜻 보면 이 두 조건을 다 만족하기가 어려울 것 같지만 생각처럼 드문 일은 아닙니다. 예를들어 페이스북, 네이버, 구글 등의 유명 사이트는 보통 PC에서 자동 로그인을 해놓은 경우가 많고 피싱 사이트는 피싱 메일, 음란 사이트(?) 등을 통해 접속될 수 있습니다. 또한 희생자가 해커가 만든 피싱 사이트를 하지 않더라도 해커가 XSS 공격을 성공한 정상 사이트를 통해 CSRF 공격이 수행될 수 도 있습니다.

CSRF가 행해지는 시나리오를 그림으로 그려보면 다음과 같습니다. 이미지는 직접 만들기 귀찮은 관계로 OWASP에 리더로 근무하시는 분의 블로그에서 발췌 하였습니다. (친절하진 않지만 그나마 제일 이해하기 쉬운 이미지인 것 같네요.)

[이미지 출처 : http://www.bluekaizen.org]

좀 더 이해하기 쉽게 예제 CSRF 공격코드를 살펴보겠습니다. 물론 말도 안되는 이야기지만, 페이스북에 글을 쓸 때 아래 코드와 같은 폼이 전송된다고 예를 듭시다. 피싱 사이트에 똑같이 페이스북에 글쓰기를 요청하는 폼이 숨겨져 있고, 그 내용으로 가입하면 10만원을 준다는 사기성 광고를 본문으로 적혀져 있습니다. 희생자는 피싱 사이트에 접속함으로써 본인의 페이스북 계정으로 해당 글이 등록되게 됩니다.

피싱 사이트에 포함된 코드

<form action="http://facebook.com/api/content" method="post">
    <input type="hidden" name="body" value="여기 가입하면 돈 10만원 드립니다." />
    <input type="submit" value="Click Me"/>
</form>

위의 공격을 통해 희생자의 페친들은 친구가 올린 글이니 의심없이 속아 넘어갈 수도 있겠죠. 이런 끔찍한 일을 막기 위해 대표적인 CSRF 공격 방어 방법들을 몇 가지 살펴봅시다. 대표적으로 다음 2가지 방어기법이 있습니다.

  • Referrer 검증
  • Security Token 사용 (A.K.A CSRF Token)

일반적으로 CSRF 공격 방어는 조회성(HTTP GET Method) 데이터에는 방어 대상에 두지 않고, 쓰기/변경이 가능한 POST, PATCH, DELETE Method에만 적용하면 됩니다. 물론 정말 중요한 데이터를 조회하거나 GET을 통해 쓰기/변경 등의 동작을 한다면 GET Method에도 방어를 해야할 수 도 있습니다.

Referrer 검증

Back-end 단에서 request의 referrer를 확인하여 domain (ex. *.facebook.com) 이 일치하는 지 검증하는 방법입니다. 일반적으로 referrer 검증만으로 대부분의 CSRF 공격을 방어할 수 있습니다. 하지만 같은 도메인 내의 페이지에 XSS 취약점이 있는 경우 CSRF 공격에 취약해질 수 있습니다. domain 단위 검증에서 좀 더 세밀하게 페이지 단위까지 일치하는지 검증을 하면 도메인 내의 타 페이지에서의 XSS 취약점에 의한 CSRF 공격을 방어할 수 있습니다.

Security Token 사용 (A.K.A CSRF Token)

Referrer 검증이 불가한 환경이라면, Security Token를 활용할 수 있습니다. 우선 사용자의 세션에 임의의 난수 값을 저장하고 사용자의 요청 마다 해당 난수 값을 포함 시켜 전송시킵니다. 이후 Back-end 단에서 요청을 받을 때마다 세션에 저장된 토큰 값과 요청 파라미터에 전달되는 토큰 값이 일치하는 지 검증하는 방법입니다. 이 방법도 결국 같은 도메인 내에 XSS 취약점이 있다면 CSRF 공격에 취약해집니다. 아래는 간략한 샘플 코드입니다.

// 로그인시, 또는 작업화면 요청시 CSRF 토큰을 생성하여 세션에 저장한다.
session.setAttribute("CSRF_TOKEN",UUID.randomUUID().toString());

// 요청 페이지에 CSRF 토큰을 셋팅하여 전송한다
<input type="hidden" name="_csrf" value="${CSRF_TOKEN}" />
// 파라미터로 전달된 csrf 토큰 값
String param = request.getParameter("_csrf");

// 세션에 저장된 토큰 값과 일치 여부 검증
if (request.getSession().getAttribute("CSRF_TOKEN").equals(param)) {
    return true;
} else {
    response.sendRedirect("/");
    return false;
}

Double Submit Cookie 검증

Double Submit Cookie 검증은 Security Token 검증의 한 종류로 세션을 사용할 수 없는 환경에서 사용할 수 있는 방법입니다. 웹브라우저의 Same Origin 정책으로 인해 자바스크립트에서 타 도메인의 쿠키 값을 확인/수정하지 못한다는 것을 이용한 방어 기법입니다. 스크립트 단에서 요청 시 난수 값을 생성하여 쿠키에 저장하고 동일한 난수 값을 요청 파라미터(혹은 헤더)에도 저장하여 서버로 전송합니다. 서버단에서는 쿠키의 토큰 값와 파라미터의 토큰 값이 일치하는 지만 검사하면 됩니다. 서버에 따로 토큰 값을 저장할 필요가 없어 위에서 살펴본 세션을 이용한 검증보다 개발 공수가 적은 편입니다. 피싱 사이트에서는 도메인이 달라 facebook.com 쿠키에 값을 저장하지 못하므로 (조금 전에 언급한 Same Origin 정책) 가능한 방어 기법입니다. 아래는 샘플 코드입니다.

/**
 * Generate 256-bit BASE64 encoded hashes
 *
 * @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Synchronizer_.28CSRF.29_Tokens
 * @return {string}
 */
var generateCsrfToken = function() {
    function generateRandomString(length) {
        var text = "";
        var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        for(var i = 0; i < length; i++) {
            text += possible.charAt(Math.floor(Math.random() * possible.length));
        }
        return text;
    };

    return btoa(generateRandomString(32));
}

// 쿠키 셋팅
var setCookie = function (cname, cvalue) {
    document.cookie = cname + "=" + cvalue + ";path=/";
}

// 모든 ajax 요청 시 쿠키 및 header에 토큰 값을 같이 전달
jQuery.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (!(/^http:.*/.test(settings.url) || /^https:.*/.test(settings.url))) {
            var csrfToken = generateCsrfToken();

            setCookie('CSRF_TOKEN', encodeURIComponent(csrfToken));
            xhr.setRequestHeader("_csrf", csrfToken);
        }
    }
});
// 헤더로 전달된 csrf 토큰 값
String paramToken = request.getHeader("_csrf");

// 쿠키로 전달되 csrf 토큰 값
String cookieToken = null;

for (Cookie cookie : request.getCookies()) {
    if ("CSRF_TOKEN".equals(cookie.getName())) {
        cookieToken = URLDecoder.decode(cookie.getValue(), "UTF-8");

        // 재사용이 불가능하도록 쿠키 만료
        cookie.setPath("/");
        cookie.setValue("");
        cookie.setMaxAge(0);
        response.addCookie(cookie);

        break;
    }
}

// 두 값이 일치하는 지 검증
if (cookieToke.equals(paramToken)) {
    return true;
} else {
    return false;
}

참조


댓글