Wordpress File upload vulnerability of theme and plugin upload function


1. 취약점 개요

본 글에서 설명할 취약점은 과거 분석했던 침해 사고 분석 케이스에서 발견했던 취약점에 대한 분석 내용이다.
21년도 당시 최신 버전에서도 패치되지 않은 취약점이었지만, 여러 사유로 인해 별도의 취약점 제보를 하지는 않았었다.
우연히 당시에 작성했던 취약점 분석 자료와 Wordpress의 보안 업데이트 명세를 확인하던 중 해당 취약점이 패치된 것을 확인하였고, 당시 기억을 되살려 발생했던 취약점과 발생 원인을 설명하고자 본 글을 작성하였다.

2. 취약점 분석

설명할 취약점은 Wordpress의 파일 업로드 취약점으로 테마와 플러그인의 업로드 기능을 악용한다. 해당 기능들은 관리자의 크레덴셜 확보가 선행되어야 접근이 가능한 기능들이기에 본 취약점 자체만으로는 영향력 자체가 크지는 않았다.

1) 취약점 설명

Wordpress에선 그림과 같이 플러그인과 테마를 직접 업로드하여 적용할 수 있게 기능을 제공하고 있다. 업로드 취약점은 이 포인트에서 발생한다.
압축 형태(.zip)의 플러그인 혹은 테마 파일을 업로드해야 하지만, 별도의 확장자 검증이 없기에 Webshell과 같은 악성 파일들을 업로드하여 서버를 장악할 수 있게 된다.

[악성 파일 업로드 시도]
[악성 파일 업로드 결과]

위 그림과 같이 악성 파일(malware.php)을 업로드하게 되면 비정상 포맷이라는 오류 메시지와 함께 설치 실패하게 된다. 하지만, 플러그인과 테마 파일 업로드 처리 로직의 처리 미흡으로 인해 다음 경로에서 업로드된 파일이 생성된 것을 확인할 수 있다.

<Wordpress>/wp-content/uploads/{yyyy}/{mm}
[malware.php 생성 경로]

해당 경로는 Wordpress에서 첨부 파일 등이 업로드되는 경로(/wp-content/uploads/)로 파일이 업로드되는 시점의 연도(Year)와 월(Month)을 기준으로 디렉터리를 생성 후 파일을 서버에 업로드하게된다.
별도의 설정을 하지 않은 경우 업로드 디렉터리는 실행 권한이 부여된 채 생성되어 아래 그림처럼 PHP 파일 실행이 가능하다.

[업로드 경로의 실행 권한 - stat 명령 실행]
[malware.php 실행 결과 - phpinfo() 출력 결과]

2) 취약점 분석

💡
상세 분석에 사용된 소스코드는 취약점이 확인된 버전 5.7.2를 기준으로 설명하며, 플러그인 업로드 로직을 따라가며 설명

취약점이 발생하는 원인을 분석해 보자.
우선 플러그인 파일(.zip)을 업로드하여 설치되는 과정 중 생성 및 호출되는 주요 클래스와 메서드, 함수들을 순서도로 나타낸 그림이다.

[Plugin/Theme 업로드 및 설치 처리 과정]

이때, 업로드한 파일이 서버에 생성되는 과정은 4~7번의 과정을 수행하면서 파일을 업로드하게 된다. 업로드되는 상세 과정은 다음과 같다.

[Upload form]
[플러그인 업로드 페이지 - Upload form]

위 그림과 같이 파일 폼에 입력된 파일을 "/wp-admin/update.php"에서 처리하며, 처리 유형을 구분하기 위해 action 파라미터로 "upload-plugin"을 넘겨주는 걸 확인할 수 있다. 그럼 update.php의 소스코드를 확인해 보자.

<?php
 ...
elseif ( 'upload-plugin' === $action ) {
		if ( ! current_user_can( 'upload_plugins' ) ) {
			wp_die( __( 'Sorry, you are not allowed to install plugins on this site.' ) );
		}

		check_admin_referer( 'plugin-upload' );

		$file_upload = new File_Upload_Upgrader( 'pluginzip', 'package' );

		...
	} 
    
...
?>

[/wp-admin/update.php 소스코드 - File_Upload_Upgrader() 인스턴스 생성 로직]

action 파라미터값이 "upload-plugin"인 경우의 소스코드를 살펴보면, 유효성을 확인한 뒤 "File_Upload_Upgrader()"클래스의 객체를 생성한다.

<?php

class File_Upload_Upgrader {
	...
	public function __construct( $form, $urlholder ) {
        ...
		if ( ! empty( $_FILES ) ) {
			$overrides = array(
				'test_form' => false,
				'test_type' => false,
			);
			$file      = wp_handle_upload( $_FILES[ $form ], $overrides );
			...
		}
	}
}
?>

[/wp-admin/includes/class-file-upload-upgrader.php 소스코드 - wp_handle_upload() 호출 로직]

"File_Upload_Upgrader()"의 객체가 생성되면 생성자("__construct")가 호출되고, 생성자 내부 로직에서 "wp_handle_upload()"가 호출된다. 해당 함수가 호출되면, "_wp_handle_upload()"가 "wp_upload_dir()"를 이용해 업로드 경로('/wp-content/uploads')에 업로드 시점의 연도(Year)와 월(Month)로 디렉터리를 생성한다.

<Wordpress>/wp-content/uploads/{yyyy}/{mm}

업로드 디렉터리가 이미 생성되어 있거나, 새로 업로드 디렉터리가 생성되면 해당 디렉터리에 업로드한 파일이 생성된다.

function _wp_handle_upload( &$file, $overrides, $time, $action ) {
    ...
    $uploads = wp_upload_dir( $time );
    	if ( ! ( $uploads && false === $uploads['error'] ) ) {
    		return call_user_func_array( $upload_error_handler, array( &$file, $uploads['error'] ) );
    	$filename = wp_unique_filename( $uploads['path'], $file['name'], $unique_filename_callback );
    	$new_file = $uploads['path'] . "/$filename";
    	$move_new_file = apply_filters( 'pre_move_uploaded_file', null, $file, $new_file, $type );
    
    	if ( null === $move_new_file ) {
    		if ( 'wp_handle_upload' === $action ) {
    			$move_new_file = @move_uploaded_file( $file['tmp_name'], $new_file );
    		} else {
    			$move_new_file = @copy( $file['tmp_name'], $new_file );
    			unlink( $file['tmp_name'] );
    		}
    
    		if ( false === $move_new_file ) {
    			if ( 0 === strpos( $uploads['basedir'], ABSPATH ) ) {
    				$error_path = str_replace( ABSPATH, '', $uploads['basedir'] ) . $uploads['subdir'];
    			} else {
    				$error_path = basename( $uploads['basedir'] ) . $uploads['subdir'];
    			}
    			return $upload_error_handler(
    				$file,
    				sprintf(
    					/* translators: %s: Destination file path. */
    					__( 'The uploaded file could not be moved to %s.' ),
    					$error_path
    				)
    			);
    		}
    	}
    
    	// Set correct file permissions.
    	$stat  = stat( dirname( $new_file ) );
    	$perms = $stat['mode'] & 0000666;
    	chmod( $new_file, $perms );
    
    	// Compute the URL.
    	$url = $uploads['url'] . "/$filename";
...
}

[/wp-admin/includes/file.php 소스코드 - _wp_handle_upload()의 파일 업로드 로직]

여기까지 업로드 파일이 서버에 생성되는 로직에 대한 설명이었다.
이후 8~13번 과정을 통해 업로드된 파일을 각 용도(플러그인, 테마)에 맞는 디렉터리로 이동 후 설치와 활성화 등의 작업을 수행한다.

public function unpack_package( $package, $delete_package = true ) {
		..
		// Unzip package to working directory.
		$result = unzip_file( $package, $working_dir );

		// Once extracted, delete the package if required.
		if ( $delete_package ) {
			unlink( $package );
		}

		if ( is_wp_error( $result ) ) {
			$wp_filesystem->delete( $working_dir, true );
			if ( 'incompatible_archive' === $result->get_error_code() ) {
				return new WP_Error( 'incompatible_archive', $this->strings['incompatible_archive'], $result->get_error_data() );
			}
			return $result;
		}

		return $working_dir;
	}

[/wp-admin/includes/class-wp-upgrader.php 소스코드 - zip 파일 압축 해재 로직]

정상적으로 동작할 경우 "install()" > "run()" > "install_package()" > "unpack_package()"를 이용해 업로드 파일을 압축 해제한 후 경로를 반환한다. 정상적이지 않은 압축 포맷인 경우 WP_Error 객체로 에러를 반환한다.

[/wp-admin/includes/class-wp-upgrader.php 소스코드 - run() 호출 결과 -> 에러 반환 분기]

위 그림의 표시된 부분을 참고하면 run() 메소드를 호출한 후 에러가 반환되었을 경우 해당 에러를 바로 반환하고 install() 함수는 종료된다.

이 과정에서 별도의 에러 대상 파일 삭제와 같은 후 처리 없이 플러그인 설치 로직이 종료되어 업로드 된 파일은 서버에 잔류하게 된다.

3. 패치 ​내역

해당 취약점에 대한 패치는 이미 진행되어 Wordpress 공식 Github에 반영되어 있다.
취약점이 발생할 수 있는 각 메이저 버전(6.x.x, 5.x.x, ...)에 대해 메인 브랜치에 반영되어 보안 패치를 수행하였다.

[Wordpress 5.9.9 branch의 업데이트 커밋 내역]

메인 브랜치의 커밋 내역을보면 Upload 기능에 대해 zip 형식을 검증하는 로직이 추가되었음을 확인할 수 있다.

[class-file-upload-upgrader.php의 패치 코드]

"class-file-upload-upgrader.php"의 패치 코드를 확인해 보면, 업로드된 파일이 "pluginzip" 혹은 "themezip" 형식일 경우 zip 형태의 파일 유효성을 검증한다.
만약, 유효하지 않은 형식일 경우 에러를 출력하고 서버에 저장된 파일을 삭제하도록 하는 로직이 추가되었다.

[update.php의 패치 코드]

"update.php"의 패치 코드는 업로드된 파일의 확장자를 검증하는 로직이 추가되었으며, 확장자가 ".zip"이 아닐 경우 업로드를 중단하도록 패치되었다.

결과적으로 추가된 로직은 두 가지 로직으로 "zip" 파일 형태 검증, ".zip" 확장자 검증이 추가되었다.

3. 마치며

플러그인 혹은 테마 업로드 기능의 취약점에 대해 분석한 내용을 설명하였다.
앞서 언급했듯 관리자의 크레덴셜을 확보하거나 관리자 페이지의 접근이 가능한 경우에 악용할 수 있는 취약점으로 공격자 관점에서 사용하기엔 사전 비용(Cost)이 있는 취약점이다. 따라서 해당 취약점 자체만으로는 영향도는 낮을 수 있지만, 별도의 플러그인을 설치하지 않고 서버에 직접 접근(Webshell, Shell 등)이 가능하므로 서버 접근 이후 공격(Post Exploit)에 활용될 여지가 있다. 따라서 해당 버전을 사용하고 있는 사용자라면 최소한 최신 메이저 버전의 업데이트는 아니더라도 사용 중인 버전의 최신 버전을 사용하길 권고한다.

You've successfully subscribed to PLAINBIT
Great! Next, complete checkout to get full access to all premium content.
Error! Could not sign up. invalid link.
Welcome back! You've successfully signed in.
Error! Could not sign in. Please try again.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.