http/https 혼합 환경에서 세션 보안 강화하기

http/https 혼합 환경에서 세션 보안 강화하기


간만에 올리는 보안팁텍입니다. 


요즘 로그인 페이지, 회원가입 페이지 등 개인정보가 오가는 곳에는 

https (SSL 또는 TLS) 보안서버를 사용하는 것은 상식이 되었죠? 

(음 그러나 현실은... 상식이 되었다고 제발 믿고 싶습니다 ㅠㅠ) 


구글이나 트위터 등의 해외 사이트들은 아예 모든 페이지에 https를 일괄 적용하곤 합니다. 

그러나 국내에서는 https를 적용한 사이트라도 모든 페이지에 일괄 적용하기보다는 

법에서 요구하는 최소한 - 로그인과 회원가입 페이지 - 에만 적용하는 경우가 많습니다. 

여전히 https에 대한 막연한 두려움과 미신이 많기 때문인데요... 


막연한 두려움과 미신 참고: 

http://www.phpschool.com/link/tipntech/77347 

http://www.phpschool.com/link/tipntech/77348 


------------------------------------------------------------------------------------------------------- 


그런데 일부 페이지에만 https를 적용할 경우 치명적인 문제가 한 가지 발생합니다. 

로그인에 사용하는 아이디와 비번은 암호화 전송이 되지만, 

로그인 상태를 표시하기 위한 세션 쿠키는 다른 페이지에서 무방비 상태로 노출된다는 거죠. 


세션 쿠키의 값을 알아낼 수 있다면 아이디와 비번 따위는 훔칠 필요도 없습니다. 

세션 쿠키를 사용하면 자기도 님의 아이디로 로그인된 상태로 만들 수 있으니까요. 

일단 이렇게 로그인 상태로 만들면 회원정보도 마음대로 볼 수 있고 

남의 글이나 댓글을 지울 수도 있고, 심지어 남의 이름으로 물건을 살 수도 있죠. 

이건 뭐, https를 적용한 보람이 전혀 없습니다. 


(회원정보 열람 전에 비번을 다시 입력하도록 하면 어느 정도는 피해를 막을 수 있겠죠? 

그러나 모든 페이지에서 매번 비번을 묻지 않는 한 100% 막을 수는 없습니다.) 


PHP에서는 세션을 시작할 때 $secure 옵션을 사용하면 

https 상태에서만 세션 쿠키가 전송되도록 강제할 수 있습니다. 

그러나 이렇게 하면 https를 적용하지 않은 다른 페이지에서는 로그인이 풀려버리죠. 

그래서 대부분의 사이트들은 세션의 $secure 옵션을 사용하기가 힘듭니다. 


------------------------------------------------------------------------------------------------------- 


오늘은 http와 https를 혼합하여 사용하는 환경에서도 

세션 쿠키 도난의 위험을 최대한 줄이는 방법을 소개해 보겠습니다. 


문제의 핵심은 공격자가 http 상태에서 훔친 세션 쿠키를 

https 상태에서도 마음놓고 사용할 수 있다는 것입니다. 

따라서 우리의 목적은 http 상태에서 세션 쿠키를 훔치더라도 

https 상태에서는 무용지물이 되도록 하는 것입니다. 


------------------------------------------------------------------------------------------------------- 


1. 일단 그냥 세션을 시작합니다. $secure 옵션은 사용하지 않습니다. 

    PHP에서는 보통 PHPSESSID라는 이름의 세션 쿠키를 사용합니다. 


2. 로그인하는 순간에는 https 상태이겠죠? 이 때 쿠키를 하나 더 구워줍니다. 

    이 쿠키를 편의상 "보안쿠키"라고 부르고, 쿠키 이름은 SSLSESSID라고 하겠습니다. 

    랜덤으로 생성한 값을 이 쿠키에 넣고, 같은 값을 세션에도 하나 저장해 둡니다. 

    중요한 건, SSLSESSID 쿠키에는 $secure 옵션을 사용해야 한다는 점입니다. 

    당연히 이 쿠키는 https 상태일 때만 전송되겠죠? 

    다른 방법으로 쿠키를 훔치는 것을 막기 위해 $httponly 옵션도 넣어주면 좋습니다. 


3. 사용자를 http 페이지로 돌려보냅니다. 

    이 때는 세션 쿠키(PHPSESSID)만 전송되고, 보안쿠키는 전송되지 않습니다. 

    따라서 누가 세션 쿠키를 훔쳐가도 알아차릴 방법이 없습니다. 

    공격자가 실제 사용자와 같은 와이파이를 사용하고 있다면 IP도 당연히 같겠고, 

    세션 쿠키를 훔칠 정도면 User-Agent 같은 것도 얼마든지 훔쳐서 베낄 수 있을 테니까요. 

    이건 http의 한계입니다. 어쩔 수 없습니다. 


4. 반면, 사용자가 다시 https 페이지를 방문한다면 어떻게 될까요? 

    정상적인 사용자라면 세션 쿠키(PHPSESSID)와 보안쿠키(SSLSESSID)가 함께 전송되겠죠. 

    그러나 공격자는 세션 쿠키는 훔칠 수 있어도 보안쿠키는 훔칠 수 없습니다. 

    따라서 PHPSESSID만 전송하거나, SSLSESSID의 값을 짐작해야 합니다. 

    그런데 SSLSESSID의 값은 랜덤으로 생성했으므로 짐작하기가 매우 어렵습니다. 


5. https 페이지를 방문할 경우 서버에서 SSLSESSID 쿠키를 체크합니다. 


5-1. 만약 SSLSESSID 쿠키가 있다면 세션에 저장해 두었던 값과 비교합니다. 

      일치하면 정상적인 사용자입니다. 

      일치하지 않는다면 공격자입니다. 세션을 폭파해 버려야 합니다. 콰쾅. 


5-2. 만약 SSLSESSID 쿠키가 없다면 세션에 저장해 둔 값이 있는지 확인합니다. 

      세션에 저장해 둔 값이 없다면 2단계를 거치지 않았다는 뜻이므로 

      2단계와 같이 보안쿠키를 새로 발급해 주면 됩니다. 

      반면, 세션에 저장해 둔 값이 있는데도 보안쿠키를 전송하지 않았다면 

      (즉, 분명 아까 보안쿠키를 구워줬는데 어딘가에 흘리고 왔다면) 

      공격자가 SSLSESSID의 값을 짐작하지 못해서 비워둔 것이겠죠? 

      당연히 세션을 폭파해 버려야 합니다. 쿠콰쾅. 


6. 위와 같은 방법을 사용하면 공격자가 http 상태에서 세션 쿠키를 훔치더라도 

    https 페이지를 방문하는 순간 세션이 폭파되어 버리므로 

    훔친 세션 쿠키는 무용지물이 됩니다. 

    반면, 정상적인 사용자들이 웹사이트를 이용하는 데는 아무 지장이 없습니다. 


7. 단, 로그인 후에 단 한번도 https 페이지를 방문하지 않는다면 아무 소용이 없겠죠? 

    위의 방법으로 최대한의 효과를 얻으려면 

    종종 https 페이지를 방문하도록 해주어야 합니다. 

    로그인, 회원가입, 회원정보 열람 및 수정, 아이디/비번찾기, 결제페이지는 물론이고 

    가끔 막 엉뚱한 것을 https 페이지로 만들어서 공격자를 놀려주세요. 

    예를 들어 게시판에서 글 읽을 때는 평범한 http이지만 

    글 쓰는 폼의 action 주소는 https로 해둔다거나... 

    그러면 만약 세션 쿠키를 훔치더라도 다른 사람을 사칭해서 글을 쓰려고 하는 순간 

    세션 폭파 쿠콰콰콰! 


8. 그런데 대부분의 웹사이트들은 http로 접속하든 https로 접속하든 내용이 똑같습니다. 

    그래서 아주 조심스러운 공격자라면 https 링크를 http로 바꾸어 접속함으로써 

    세션이 폭파되는 것을 피할 수 있어요. 

    이런 꼼수를 막기 위해, https로 접속해야 하는 페이지에 http로 접속한 경우 

    아무 것도 보여주지 말고 곧바로 Location: 헤더를 사용해서 https로 넘겨주세요. 

    만약 공격자였다면 당연히 세션 폭파 쿠콰콰콰! 


------------------------------------------------------------------------------------------------------- 


9. 방법만 알려드리고 예제 소스가 없으면 그건 제 스타일이 아니죠? 

    위에서 언급한 모든 작업을 한 번에 해주는 함수입니다. 

    그 밖에도 3분마다 쿠키값을 바꿔주고 

    세션 관련 php.ini 속성들을 자동으로 조절해 주는 등 

    쓸만한 기능들이 좀 있어요. 

    session_start() 대신 쓰시면 됩니다. 


/** 

 * 합리적인 기본값으로 세션을 시작하는 함수 

 * 

 * @param int $lifetime = 0 

 * @param string $path = '/' 

 * @param string $domain = null 

 * @param bool $secure = false 

 * @param bool $httponly = true 

 * @return void 

 */ 

function session_start_better($lifetime = 0, $path = '/', $domain = null, $secure = false, $httponly = true) 

    // 세션 관련 php.ini 설정 조절 

    ini_set('session.gc_maxlifetime', max($lifetime, 86400)); 

    ini_set('session.hash_function', 1); 

    ini_set('session.use_cookies', 1); 

    ini_set('session.use_only_cookies', 1); 

    ini_set('session.use_strict_mode', 1); 

    if (defined('PHP_OS') && !strncmp(PHP_OS, 'Linux', 5)) 

    { 

        ini_set('session.entropy_file', '/dev/urandom'); 

        ini_set('session.entropy_length', 20); 

    } 

    

    // 실제로 세션을 시작 

    session_set_cookie_params($lifetime, $path, $domain, $secure, $httponly); 

    session_start(); 

    

    // HTTPS인 경우 보안쿠키 체크 

    if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') 

    { 

        // 보안쿠키를 처음 발급하는 경우 

        if (!isset($_SESSION['SSLSESSID'])) 

        { 

            $_SESSION['SSLSESSID'] = sha1(pack('V*', rand(), rand(), rand(), mt_rand(), mt_rand())); 

            setcookie('SSLSESSID', $_SESSION['SSLSESSID'], $lifetime, $path, $domain, true, true); 

            if (isset($_COOKIE[session_name()])) session_regenerate_id(); 

        } 

        // 보안쿠키를 발급했는데 제대로 돌아오지 않은 경우 공격자로 간주함 

        elseif (!isset($_COOKIE['SSLSESSID']) || $_COOKIE['SSLSESSID'] !== $_SESSION['SSLSESSID']) 

        { 

            $_SESSION = array(); 

            $sp = session_get_cookie_params(); 

            setcookie(session_name(), '', time() - 86400, $sp['path'], $sp['domain'], $sp['secure'], $sp['httponly']); 

            setcookie('SSLSESSID', '', time() - 86400, $sp['path'], $sp['domain'], true, true); 

            session_destroy(); 

        } 

        // 보안쿠키가 정상적으로 되돌아온 경우는 별도의 처리 불필요 

    } 

    

    // 세션을 발급한 지 3분이 경과하면 자동으로 쿠키값을 변경해줌 

    if (isset($_SESSION['AUTOREFRESH'])) 

    { 

        if ($_SESSION['AUTOREFRESH'] < time() - 180) 

        { 

            $_SESSION['AUTOREFRESH'] = time(); 

            session_regenerate_id(); 

        } 

    } 

    else 

    { 

        $_SESSION['AUTOREFRESH'] = time(); 

    } 


------------------------------------------------------------------------------------------------------- 


10. 소스가 너무 길어지지 않도록 하기 위해 몇 가지를 간단하게 처리하고 넘어갔는데, 

    실제로 쓰실 때는 아래의 두 군데를 적당히 고쳐쓰셔도 됩니다. 

    

    - 로드밸런싱, CDN 등을 사용할 경우 $_SERVER['HTTPS']가 정확하지 않을 수 있음 

    - 보안쿠키 발급시 난수생성 알고리듬을 더욱 강력한 것으로 변경 

      참고: http://www.phpschool.com/link/tipntech/79087 


11. 물론 최고의 해결책은 웹사이트 전체에 https를 적용하고 

    HTTP Strict Transport Security (HSTS) 헤더를 사용하여 

    아예 브라우저들이 http 사이트로는 접속 시도조차 하지 않도록 만드는 것입니다.