보안 키란?
wp-config.php 파일을 보면 이런 내용이 있는 것을 보실 수 있습니다.
define( 'AUTH_KEY', 't`DK%X:>xy|e-Z(BXb/f(Ur`8#~UzUQG-^_Cs_GHs5U-&Wb?pgn^p8(2@}IcnCa|' ); define( 'SECURE_AUTH_KEY', 'D&ovlU#|CvJ##uNq}bel+^MFtT&.b9{UvR]g%ixsXhGlRJ7q!h}XWdEC[BOKXssj' ); define( 'LOGGED_IN_KEY', 'MGKi8Br(&{H*~&0s;{k0<S(O:+f#WM+q|npJ-+P;RDKT:~jrmgj#/-,[hOBk!ry^' ); define( 'NONCE_KEY', 'FIsAsXJKL5ZlQo)iD-pt??eUbdc{_Cn<4!d~yqz))&B D?AwK%)+)F2aNwI|siOe' ); define( 'AUTH_SALT', '7T-!^i!0,w)L#JK@pc2{8XE[DenYI^BVf{L:jvF,hf}zBf883td6D;Vcy8,S)-&G' ); define( 'SECURE_AUTH_SALT', 'I6`V|mDZq21-J|ihb u^q0F }F_NUcy`l,=obGtq*p#Ybe4a31R,r=|n#=]@]c #' ); define( 'LOGGED_IN_SALT', 'w<$4c$Hmd%/*]`Oom>(hdXW|0M=X={we6;Mpvtg+V.o<$|#_}qG(GaVDEsn,~*4i' ); define( 'NONCE_SALT', 'a|#h{c5|P &xWs4IZ20c2&%4!c(/uG}W:mAvy<I44`jAbup]t=]V<`}.py(wTP%%' );
(위 코드는 https://codex.wordpress.org/Editing_wp-config.php#Security_Keys 에서 가져왔습니다. 이 값을 복사해서 쓰지 마세요. 반드시 여기와 같은 곳에서 제대로 난수적으로 나오는 값을 사용하세요.)
워드프레스가 2.5 버전 일 때는 로그인과 관련된 키 설정으로 쓴 상수는 SECRET_KEY, SECRET_SALT 이 둘 뿐이었습니다. 그러나 2.6에 와서는 좀 더 세부적으로 나뉘었으며, 현재와 같은 모습은 2.7에 와서 완성되었습니다.
가끔씩 보면, 이 값을 설정하지 않고 운영하는 사이트가 있습니다. 다행히도 워드프레스는 저 설정이 없다면 자동으로 난수값을 생성해 데이터베이스에 저장해 두고 쓰기 때문에 위 설정이 없다 하여도 사이트가 큰 위험에 처하는 것은 아닙니다.
이 상수들을 잘 살펴보면 언더바(_)를 기준으로 두 개의 문자열로 나뉩니다. 선두 그룹은 아래와 같은 4개의 그룹이 있고, 후위 그룹은 2개의 그룹입니다.
- 선두 그룹: AUTH, SECURE_AUTH, LOGGED_IN, NONCE
- 후위 그룹: KEY, SALT
보안 키가 하는 역할은?
그러면 왜 이렇게 보안 키를 나누어 놓았고, 또 이런 보안 키는 어떤 역할을 할까요?
보안키를 나눈 이유는 명확해 보입니다. 하나의 키가 모든 보안 프로세스를 일임하는 것이 아니라 책임을 분산함으로서 보안적으로 더욱 강력해지기 위함이겠죠.
한편 보안 키는 쿠키의 검증, NONCE 키 검증 등에 있어 중요한 역할을 합니다. 가령 워드프레스에서 로그인을 하면 웹브라우저에서는 사용자 세션을 인식하기 위한 쿠키를 생성합니다. 이 쿠키는 사용자를 식별하는 정보가 담겨있으므로 매우 중요합니다.
매번 연결이 있을 때마다 서버와 웹브라우저사이에서 쿠키의 내용이 정확하며, 위변조 같은 일이 없었다는 것을 점검하는 작업이 일어납니다. 이때 그 위변조를 점검할 때 이 키를 사용합니다. (그래서 만약 사이트의 wp-config.php 파일을 바꾸게 되면 검증이 잘못되게 되므로 모든 사용자의 로그인이 풀리는 현상이 발생할 것입니다.)
이제 코어에서 보안 키가 사용되는 한 예를 살펴 볼 것입니다. wp-includes/pluggable.php 파일에 정의된 wp_salt()라는 함수입니다.
function wp_salt( $scheme = 'auth' ) { static $cached_salts = array(); if ( isset( $cached_salts[ $scheme ] ) ) { /** * Filters the WordPress salt. * * @since 2.5.0 * * @param string $cached_salt Cached salt for the given scheme. * @param string $scheme Authentication scheme. Values include 'auth', * 'secure_auth', 'logged_in', and 'nonce'. */ return apply_filters( 'salt', $cached_salts[ $scheme ], $scheme ); } static $duplicated_keys; if ( null === $duplicated_keys ) { $duplicated_keys = array( 'put your unique phrase here' => true ); foreach ( array( 'AUTH', 'SECURE_AUTH', 'LOGGED_IN', 'NONCE', 'SECRET' ) as $first ) { foreach ( array( 'KEY', 'SALT' ) as $second ) { if ( ! defined( "{$first}_{$second}" ) ) { continue; } $value = constant( "{$first}_{$second}" ); $duplicated_keys[ $value ] = isset( $duplicated_keys[ $value ] ); } } } $values = array( 'key' => '', 'salt' => '' ); if ( defined( 'SECRET_KEY' ) && SECRET_KEY && empty( $duplicated_keys[ SECRET_KEY ] ) ) { $values['key'] = SECRET_KEY; } if ( 'auth' == $scheme && defined( 'SECRET_SALT' ) && SECRET_SALT && empty( $duplicated_keys[ SECRET_SALT ] ) ) { $values['salt'] = SECRET_SALT; } if ( in_array( $scheme, array( 'auth', 'secure_auth', 'logged_in', 'nonce' ) ) ) { foreach ( array( 'key', 'salt' ) as $type ) { $const = strtoupper( "{$scheme}_{$type}" ); if ( defined( $const ) && constant( $const ) && empty( $duplicated_keys[ constant( $const ) ] ) ) { $values[ $type ] = constant( $const ); } elseif ( ! $values[ $type ] ) { $values[ $type ] = get_site_option( "{$scheme}_{$type}" ); if ( ! $values[ $type ] ) { $values[ $type ] = wp_generate_password( 64, true, true ); update_site_option( "{$scheme}_{$type}", $values[ $type ] ); } } } } else { if ( ! $values['key'] ) { $values['key'] = get_site_option( 'secret_key' ); if ( ! $values['key'] ) { $values['key'] = wp_generate_password( 64, true, true ); update_site_option( 'secret_key', $values['key'] ); } } $values['salt'] = hash_hmac( 'md5', $scheme, $values['key'] ); } $cached_salts[ $scheme ] = $values['key'] . $values['salt']; /** This filter is documented in wp-includes/pluggable.php */ return apply_filters( 'salt', $cached_salts[ $scheme ], $scheme ); }
처음 두 단락에서는 $cached_salts, $duplicated_keys 두 정적 변수를 선언하고 만약 이 두 부분이 이미 초기화 되었다면 초기화된 내용을 계속 재활용하도록 처리합니다. 키는 한 번 만들어지면 바뀔 일이 없으니까요.
그다음 2.5 이전의 설정에 대해 호환되도록 방어하는 부분이 있네요. SECRET_KEY, SECRET_SALT가 나오는 if 구문입니다.
그다음 보안 키, AUTH_KEY, AUTH_SALT 등에 대한 값을 가져옵니다. 상수로 정의되어 있으면 상수 값을, 만약 없다면 site option으로 난수를 생성하는 부분이 보이죠?
아무런 튜닝되지 않은 기본 워드프레스 코어에서는 아마 이 함수의 결과로 $cached_salts에는 네 개의 scheme, ‘auth’, ‘secure_auth’, ‘logged_in’, ‘nonce’을 키로, 각각은 키와 솔트를 합친 문자열을 가지게 될 겁니다. 그러니까 아래와 같은 PHP 코드와 유사한 거죠.
$cached_salts = array( 'auth' => AUTH_KEY . AUTH_SALT, 'secure_auth' => SECURE_AUTH . SECURE_AUTH, 'logged_in' => LOGGED_IN_KEY . LOGGED_IN_SALT, 'nonce' => NONCE_KEY . NONCE_SALT, );
wp_salt()는 wp_hash()라는 함수에서 사용됩니다. wp_hash()는 wp_validate_auth_cookie()라는 함수에서 사용됩니다. 이 함수는 wp_get_current_user()라는 현재 워드프레스 사용자를 설정하는 함수에서 사용됩니다. 워드프레스 동작 순서를 생각하여 추측하면 이렇게 볼 수 있습니다.
- 사이트에 요청이 들어오면 요청에 있는 쿠키를 분석하여 현재 사용자를 설정할 것입니다. 이 역할을 하는 것이 wp_get_current_user() 함수입니다.
- wp_get_current_user() 함수 내에서 ‘determine_current_user’라는 필터를 사용하는데, 이 필터의 콜백이 wp_validate_auth_cookie()입니다.
- wp_validate_auth_cookie() 함수 내에서 사용자의 쿠키에 위변조가 없는지 체크하는 과정에서 wp_hash(), wp_salt() 같은 함수가 사용되고, 결국 AUTH_KEY, AUTH_SALT가 필요하게 됩니다.
생성되는 쿠키
로그인을 하면 하나의 쿠키만 생성되는 것이 아닙니다. wp_set_auth_cookie() 함수에 의하면 적어도 3개의 쿠키 항목이 생성되는 것을 볼 수 있습니다.
- 플러그인 쿠키
- 관리자 쿠키
- 로그인 쿠키
워드프레스 코어는 사용자 식별은 관리자 화면과 프론트 화면을 나누어서 처리하고 있습니다. 관리자 쿠키는 관리자 화면에 접근시 사용자를 식별할 때 사용됩니다. 그리고 로그인 쿠키는 프론트에서 사용자를 식별할 때 사용됩니다. 플러그인 쿠키는 솔직히 조금 아리송합니다. 이 쿠키가 코어 내에서 눈에 띄게 사용되는 부분이 없습니다. 개발상의 완결성을 위해 있는 것이 아닌가 생각합니다.
웹브라우저에서 3개의 쿠키를 개별적으로 삭제할 수 있습니다. 로그인 쿠키만 지워버리면 프론트 화면에서만 더 이상 사용자를 인식할 수 없게 됩니다. 관리자 쿠키를 지우면 관리자 화면으로 더 이상 들어갈 수 없게 됩니다. 그리고 관리자 쿠키를 지워도 프론트에서는 계속 사용자를 인식합니다. 단, 한 번 관리자 화면에서 쫓겨나 로그인 화면으로 이동하면 3개의 쿠키는 모두 버려집니다.
쿠키 인식 과정
워드프레스의 초기 동작에서 현재 사용자의 정보를 읽어오는 wp_get_current_user()라는 함수가 불립니다. 여기서 wp_validate_auth_cookie(), wp_validate_logged_in_cookie() 함수를 부릅니다. 어떻게 인식되는지 알기 위해 구조를 살펴볼 필요가 있습니다.
쿠키의 구조
쿠키의 이름은 기본적으로 wordpress_{난수}, wordpress_logged_in_{난수}로 되어 있습니다. 바꾸고 싶다면 얼마든지 바꿀수도 있습니다. 여기서 난수는 워드프레스의 주소를 md5 해시 적용한 값입니다.
쿠키 경로는 wordpress_{난수}는 ‘/wp-admin‘, wordpress_logged_in_{난수}는 ‘/‘입니다.
쿠키의 값은 다음과 같은 구조로 되어 있습니다.
{유저이름}|{만료시점타임스탬프}|{세션토큰}|{HMAC토큰}
예를 들자면,
changwoo|1529327671|foo|bar
처럼 쓸 수 있겠습니다. 유저이름은 유저 테이블의 user_login입니다. 두번째 만료시점 타임스탬프는 이 정보가 만료되는 시점입니다. 쿠키 만료시점과는 약간 다릅니다. (로그인시 ‘기억하기’ 옵션을 주면 대략 2주간 로그인이 기록되는데, 바로 여기서 그 2주라는 기간이 쿠키 만료 시간입니다. 기억하지 않으면 쿠키는 2일간 지속됩니다)
그리고 이 값은 서버에서 위변조 검사를 통화해야 합니다. 보통은 sha256 hmac 해시를 사용합니다. 이 검증 과정에 대한 자세한 내용은 생략합니다. 궁금하면 wp_validate_cookie() 함수를 참고하세요. 아래는 그 검증의 일부를 추출한 것입니다.
$user = get_user_by('login', $username); if ( ! $user ) { /** * Fires if a bad username is entered in the user authentication process. * * @since 2.7.0 * * @param array $cookie_elements An array of data for the authentication cookie. */ do_action( 'auth_cookie_bad_username', $cookie_elements ); return false; } $pass_frag = substr($user->user_pass, 8, 4); $key = wp_hash( $username . '|' . $pass_frag . '|' . $expiration . '|' . $token, $scheme ); // If ext/hash is not present, compat.php's hash_hmac() does not support sha256. $algo = function_exists( 'hash' ) ? 'sha256' : 'sha1'; $hash = hash_hmac( $algo, $username . '|' . $expiration . '|' . $token, $key ); if ( ! hash_equals( $hash, $hmac ) ) { /** * Fires if a bad authentication cookie hash is encountered. * * @since 2.7.0 * * @param array $cookie_elements An array of data for the authentication cookie. */ do_action( 'auth_cookie_bad_hash', $cookie_elements ); return false; }
그 다음은?
여기까지 오면 서버는 브라우저가 보낸 내용이 신뢰할 만한 것이고, 해당 내용을 통해 사용자를 인식하게 됩니다. 다음으로는 사용자가 로그인해서 활동한 기록을 저장하는 세션정보에 대해 알아볼 것입니다. 분량이 너무 길어지므로 세션 정보는 다음 포스트에서 계속하도록 하겠습니다.