오늘은 CVE-2022-21662를 분석해보고자 한다❗
CVE-2022-21662는 Post-Slugs를 통해 Stored XSS를 수행할 수 있는 취약점이다.
우선 본격적으로 분석하기에 앞서 Post-Slugs가 무엇인지부터 알아보자.
Post-Slug란 CMS에서 각 블로그 게시물에 대해 고유하게 읽을 수 있는 웹 주소를 생성하는데 사용된다.
예를 들어 Wordpress의 게시물의 타이틀이 "coco" 라고 생각해보자.
그러면 해당 게시물의 URL이 "http://localhost/w/wordpress/2023/12/03/coco/" 이런 식으로 생성된다. 즉, 게시물의 타이틀 이름을 따서 URL을 생성하는 기능을 뜻한다.
해당 CVE가 발생하는 환경은 WordPress 5.8.3 이하버전으로 Wordpress 5.8.1을 다운로드받는다.
환경구성은 XAMPP를 먼저 설치하고, Wordpress를 ZIP파일로 다운받아 htdocs 폴더에 압축을 해제하여 넣는다.
설치성공 👏
시작하기에 앞서, Wordpress가 자동 업데이트되지 않게 설정해준다.
wp-config.php 파일에 아래의 구문을 추가해주면 된다.
/* Auto update off*/
define( 'WP_AUTO_UPDATE_CORE', false );
또한 게시글 작성 권한을 가진 사용자를 하나 추가해주자❗
글쓴이 역할인 사용자 "user"를 추가해준 모습이다.
자 이제 하나씩 분석을 시작해보자!
글을 작성하면 include 폴더 안에 post.php에 선언된 wp_insert_post 함수가 실행된다.
함수를 잘 살펴보면, sanitize_title 함수를 사용하여 필터링을 수행하고 있다.
/*
* Create a valid post name. Drafts and pending posts are allowed to have
* an empty post name.
*/
if ( empty( $post_name ) ) {
if ( ! in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true ) ) {
$post_name = sanitize_title( $post_title );
} else {
$post_name = '';
}
} else {
// On updates, we need to check to see if it's using the old, fixed sanitization context.
$check_name = sanitize_title( $post_name, '', 'old-save' );
if ( $update && strtolower( urlencode( $post_name ) ) == $check_name && get_post_field( 'post_name', $post_ID ) == $check_name ) {
$post_name = $check_name;
} else { // new post, or slug has changed.
$post_name = sanitize_title( $post_name );
}
}
해당 함수는 formatting.php에 선언되어 있으며, 주석처리 된 설명에는 다음과 같이 적혀있다.
Sanitizes a string into a slug, which can be used in URLs or HTML attributes.
문자열을 슬러그(Slug)로 변환하여 URL이나 HTML 속성에서 사용할 수 있도록 정제합니다.
그러면, 바로 formatting.php에 선언된 sanitize_title 함수를 살펴보자.
function sanitize_title( $title, $fallback_title = '', $context = 'save' ) {
$raw_title = $title;
if ( 'save' === $context ) {
$title = remove_accents( $title );
}
/**
* Filters a sanitized title string.
*
* @since 1.2.0
*
* @param string $title Sanitized title.
* @param string $raw_title The title prior to sanitization.
* @param string $context The context for which the title is being sanitized.
*/
$title = apply_filters( 'sanitize_title', $title, $raw_title, $context );
if ( '' === $title || false === $title ) {
$title = $fallback_title;
}
return $title;
}
우선 일차적으로 remove_accents 함수를 사용하여 title의 1차 필터링을 수행한다.
또한 아래 부분에 apply_filter 함수를 통해 sanitize_title 훅을 실행시킨다.
default-filters.php에 정의된 add_filter를 확인해보면, sanitize_title_with_dashes 라는 함수를 추가로 호출하는 것을 알 수 있다.
function sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) {
$title = strip_tags( $title );
// Preserve escaped octets.
$title = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '---$1---', $title );
// Remove percent signs that are not part of an octet.
$title = str_replace( '%', '', $title );
// Restore octets.
$title = preg_replace( '|---([a-fA-F0-9][a-fA-F0-9])---|', '%$1', $title );
if ( seems_utf8( $title ) ) {
if ( function_exists( 'mb_strtolower' ) ) {
$title = mb_strtolower( $title, 'UTF-8' );
}
$title = utf8_uri_encode( $title, 200 );
}
해당 함수를 살펴보면 strip_tags를 통해 HTML 태그와 PHP 태그 제거한다.
이후 preg_replace 함수를 통해 %20 형태로 들어온 값들을 ---$20---으로 바꾸고, 다시 ---$20---의 값을 %20으로 바꿔준다.
그러면 해당 함수의 아래부분에서 wp_unique_post_slug 함수를 호출하는 부분이 존재한다.
function wp_unique_post_slug( $slug, $post_ID, $post_status, $post_type, $post_parent ) {
if ( in_array( $post_status, array( 'draft', 'pending', 'auto-draft' ), true )
|| ( 'inherit' === $post_status && 'revision' === $post_type ) || 'user_request' === $post_type
) {
return $slug;
}
...
$suffix = 2;
do {
$alt_post_name = _truncate_post_slug( $slug, 200 - ( strlen( $suffix ) + 1 ) ) . "-$suffix";
$post_name_check = $wpdb->get_var( $wpdb->prepare( $check_sql, $alt_post_name, $post_ID ) );
$suffix++;
} while ( $post_name_check );
$slug = $alt_post_name;
}
해당 함수 안에서 _truncate_post_slug 함수를 실행시킨다. 해당 함수를 잘 살펴보자!
해당 함수는 마찬가지로 post.php선언되어있다.
/**
* Truncate a post slug.
*
* @since 3.6.0
* @access private
*
* @see utf8_uri_encode()
*
* @param string $slug The slug to truncate.
* @param int $length Optional. Max length of the slug. Default 200 (characters).
* @return string The truncated slug.
*/
function _truncate_post_slug( $slug, $length = 200 ) {
/* 200보다 커야 if문안으로 ... */
if ( strlen( $slug ) > $length ) {
$decoded_slug = urldecode( $slug );
if ( $decoded_slug === $slug ) {
$slug = substr( $slug, 0, $length );
} else {
$slug = utf8_uri_encode( $decoded_slug, $length );
}
}
return rtrim( $slug, '-' );
}
디코딩을 진행하고, 디코딩 된 값이 slug와 같지 않으면 utf8_uri_encode 함수를 통해 다시 인코딩을 진행한다.
특이한 점은 urldecode 함수를 호출하여 디코딩했지만 인코딩은 urlencode 함수가 아닌 utf8_uri_encode 함수로 수행한다.
utf8_uri_encode 함수는 유니코드 문자만 인코딩을 진행하는 함수이다.
따라서 200자 이상의 title에 페이로드를 인코딩하여 넣을 경우 utf8_uri_encode 함수로 인코딩되어 들어가는 것이다.
예를 들어, 를 인코딩하면 이대로 출력된다고 한다.
위의 방법대로 게시물을 업로드하면 스크립트가 그냥 삽입될 것이다.
똑같은 title을 가진 게시물 등록.
이 후 다시 글 관리 페이지로 들어가보면 ...
Script 실행.
※ 참고
👉 https://themeisle.com/blog/post-slug/#gref
👉 https://www.sonarsource.com/blog/wordpress-stored-xss-vulnerability/