비밀번호 해시 고도화 사례 (bcrypt)

사례 : PHP 5.5 이전 버전에서 운영중인 사이트에서 비밀번호 해시를 bcrypt 로 교체한 경험을 정리했습니다.

목표 : 최악의 경우 서버내 해시된 비밀번호 데이터가 유출되더라도, bcrypt 알고리즘을 적용하여 실제 비밀번호를 유추하기 어렵게 만듭니다.


배경

기존 사용되던 mysql password() 함수는 단순 해시 기능으로 같은 문자열을 입력하면, 매번 같은 해시 결과가 나옵니다.
이로 인해 Rainbow table 이란 미리 만들어진 해시 결과값과 비교하면, 단순 비밀번호는 쉽게 해시 이전 값이 알려지는 취약점이 있습니다.
즉 해시 결과는 *3D3B92F242033365AE5BC6A8E6FC3E1679F4140A 로 원래 비밀번호를 유추하기 어렵게 보여지지만,
Rainbow table 을 활용하면 원래 비밀번호가 test1234임을 쉽게 알아낼 수 있습니다.

이런 취약점을 개선하기 위해 비밀번호 전용 해시가 등장하게 되었고, 생성시마다 해시된 결과값이 달라져서 Rainbow table 을 통한 공격이 불가능하게 됩니다.
이를 위해 PHP 5.5 에서 추가된 password_hash() 함수의 BCRYPT 알고리즘으로 교체되었습니다.

다음 비밀번호 해시 코드를 만들어 테스트해보면, 매번 해시값이 달라지게 됩니다.

echo password_hash('test1234', PASSWORD_BCRYPT, array('cost' => 10));

// 1차 실행 : $2y$10$ldN7oJVYOBUPWD59LyAaluKN2m1aKDCghf/wBvzbo6tqqZHqnXguy
// 2차 실행 : $2y$10$3mZydRCTaul/WtxJ3APVOOY8oLbZk9day.Lu/7zeM6ADRVpxSiVhC

여기서 비밀번호 해시 결과가 매번 달라지기 때문에 기존처럼 (비밀번호 해시 === 해시함수(비밀번호) 조건으로 비교가 안됩니다.
그래서 다음 예제처럼 비밀번호 검증을 위한 password_verify(비밀번호, 비밀번호 해시) 함수를 사용해야 합니다.

// $mb_password : 사용자가 입력한 실제 비밀번호
// $mb[mb_password] : DB에 저장된 비밀번호 해시
// sql_password : mysql password() 함수의 해시 결과 반환
//if (sql_password($mb_password) !== $mb[mb_password]) {
if ( ! password_verify($mb_password, $mb[mb_password])) {
  die('비밀번호가 일치하지 않습니다.');
}

고도화 사례

이제 실제 사이트에서 기존 mysql password() 를 최신 password_hash() 로 고도화시킨 사례를 소스 코드와 함께 살펴보겠습니다.

사이트에 사용된 솔루션 : 그누보드4 불당팩 (https://github.com/open2/gnuboard4-buldang-pack)

PHP 5.5 이전 버전에서 password_hash() 함수 사용하기

먼저 password_hash() 함수는 PHP 5.5 부터 지원하지만, 해당 사이트는 PHP 5.5 미만이므로 하위 호환용 패키지를 먼저 설치합니다.
https://github.com/ircmaxell/password_compat 패키지는 PHP 5.3.7 이상에서 사용가능하며,
lib/password.php 파일만 다운로드 받아 공통 소스에 포함시키면 간단하게 탑재됩니다.

lib/password.php 파일로 저장 – https://github.com/ircmaxell/password_compat/blob/master/lib/password.php

전체 페이지에 적용되는 공통단에서 저장한 password_compat 소스를 include 합니다.

// common.php

include_once("$g4[path]/lib/common.lib.php"); // 공통 라이브러리
include_once("$g4[path]/lib/password.php"); // password_compat

이제 PHP 5.5 미만이지만 전체 페이지에서 password_hash() 함수가 사용 가능하게 되었습니다.

작업 대상 기능 정리

비밀번호 암호화는 보통 다음 기능들에 영향을 미칩니다. (솔루션마다 다름)

  • 로그인
  • 회원정보수정 접근시 비밀번호 확인
  • 비밀번호 변경
  • 비밀번호 재발급
  • 회원가입

신규 비밀번호 해시 공통 함수 추가

사용중인 그누보드4 불당팩은 비밀번호 해시를 다음 함수에서 처리하고 있습니다.

// lib/common.lib.php

function sql_password($value)
{
    // mysql 4.0x 이하 버전에서는 password() 함수의 결과가 16bytes
    // mysql 4.1x 이상 버전에서는 password() 함수의 결과가 41bytes
    $row = sql_fetch(" select password('$value') as pass ");
    return $row[pass];
}

여기서 sql_password() 함수 내용을 신규 비밀번호 해시인 password_hash() 로 대체하고, 기존 함수는 sql_legacy_password() 로 이름을 변경하여 유지합니다.
기존 함수는 로그인시 기존 비밀번호 해시 비교를 위해 꼭 필요합니다.

/**
* 신규 비밀번호 해시
*  - bcrypt
*
* @param string $value
*
* @return bool|false|string
*/
function sql_password($value)
{
    return password_hash($value, PASSWORD_BCRYPT, array('cost' => 10));
}

/**
* 기존 비밀번호 해시
*
* @param string $value
*
* @return string
*/
function sql_legacy_password($value)
{
    // mysql 4.0x 이하 버전에서는 password() 함수의 결과가 16bytes
    // mysql 4.1x 이상 버전에서는 password() 함수의 결과가 41bytes
    $row = sql_fetch(" select password('$value') as pass ");
    return $row[pass];
}

로그인

로그인시에는 먼저 신규 비밀번호 해시 함수인 password_verify() 로 비교합니다.
일치하지 않고 비밀번호 해시의 길이가 60bytes(bcrypt 기준) 미만일 경우, 기존 비밀번호 해시로 다시 비교합니다.
이때 기존 비밀번호 해시와 일치하면, 신규 비밀번호 해시로 바꿔주고 로그인을 진행합니다.
이렇게 되면 기존 비밀번호 해시 사용자들은 최초 1회 로그인 만으로 신규 비밀번호 해시가 자동 적용되어 보안이 강화됩니다.

// bbs/login_check.php

if ( ! password_verify($mb_password, $mb[mb_password])) {
    $login_check = 1;    // 비밀번호 미일치

    if (strlen($mb[mb_password]) < 60) {
        // 신규 비밀번호 해시와 일치하지 않고, 길이가 60자 미만인 경우 기존 비밀번호 해시로 재확인
        if (sql_legacy_password($mb_password) === $mb[mb_password] || sql_old_password($mb_password) === $mb[mb_password]) {
            // 신규 비밀번호 해시 적용
            $sql  = " update $g4[member_table] set mb_password= :mb_password where mb_id=:mb_id ";
            $stmt = $pdo_db->prepare($sql);
            $stmt->bindParam(":mb_password", sql_password($mb_password));
            $stmt->bindParam(":mb_id", $mb_id);
            $result = pdo_query($stmt);
            $login_check = 0;        // 비밀번호 일치
        }
    }
}

회원정보수정 접근시 비밀번호 확인

기존 비밀번호 비교 조건만 password_verify() 로 대체하면 됩니다.

// bbs/register_form.php

if ( ! password_verify($_POST['mb_password'], $member['mb_password'])) {
    alert("패스워드가 틀립니다.");
}

비밀번호 재발급

신규 비밀번호 해시된 값에는 “/” 등 특수 문자가 있으므로 urlencode() 함수를 적용해주어야 합니다.

// bbs/password_lost2.php

$href = "$g4[url]/$g4[bbs]/password_lost_certify.php?mb_no=$mb_no&mb_datetime=" . urlencode($mb_datetime) . "&mb_lost_certify=" . urlencode($mb_lost_certify);

재발급 인증코드의 비교에도 sql_password() 비교가 사용되었으므로, password_verify() 비교로 바꿔주면 됩니다.

// bbs/password_lost_certify.php

// if ($mb_lost_certify && $mb_datetime === sql_password($mb[mb_datetime]) && $mb_lost_certify === $mb[mb_lost_certify]) {
if ($mb_lost_certify && password_verify($mb[mb_datetime], $mb_datetime) && $mb_lost_certify === $mb[mb_lost_certify]) {

비밀번호 변경, 회원가입

sql_password() 함수를 사용하는 비밀번호 해시 작업만 있어서, 추가 수정사항이 없습니다.

오류 가능성 및 배포 안내

위 예제는 실제 사이트에 적용된 사례이긴 하지만, 적용하시려는 사이트와 소스가 다른 부분이 있을 수 있어 복사&붙여넣기는 절대 안됩니다!

특히 로그인시 인증 부분을 변경하는 것으로, 오류가 있을 경우 사이트 운영에 다음과 같은 치명적인 문제를 일으킬 수 있습니다.

  • 기존 비밀번호 유실가 유실되거나 로그인할 수 없는 문제
  • 잘못 입력한 비밀번호로도 로그인이 되는 문제

따라서 반드시 개발 환경에서 충분한 분석 및 테스트 후에 운영 환경을 백업하고 적용해야 합니다.

필수 테스트) 잘못된 비밀번호로 로그인 시도, 첫번째 로그인시 비밀번호 해시가 정상적으로 바뀌는지 등

참고 문서

  • PHP 메뉴얼 : http://php.net/manual/en/function.password-hash.php
  • 비밀번호 암호화의 정석 : https://phpschool.com/link/tipntech/78316
  • 안전한 패스워드 저장 : https://d2.naver.com/helloworld/318732
  • 레인보우 테이블(Rainbow table) : https://namu.wiki/w/%EB%A0%88%EC%9D%B8%EB%B3%B4%EC%9A%B0%20%ED%85%8C%EC%9D%B4%EB%B8%94