Bright City: A Highly Insecure Police and Municipal Government App

Earlier this year I received a Nextdoor message from my County Police Department announcing a “Property LockBox App” they’d released (purchased) for citizens. There was no previous communication regarding this app that I could find, so I was interested in learning more about it.

As the app description states, Bright City is “[a] 2-way, dedicated mobile application for cities and law enforcement.” In addition to the “property lockbox” feature mentioned in the NextDoor announcement, the app supports receiving and submitting reports of suspicious activities, scheduling property watches to protect citizens’ homes when away, and reporting municipal maintenance issues. Bright City’s website describes some additional features, including payment processing of municipal fees for citizen citations, licenses, etc.:

The nature of the information involved in the app seemed highly sensitive, so I was interested in a closer look. I installed the app, created an account, and logged in. Here’s a look at the initial dashboard:

While proxying the requests on my device, I started moving through some of the options in the app to generate some API traffic. It didn’t take long to notice some very serious concerns — below is an example request the app makes while fetching the current user’s profile information:

GET https://api.brightcityapps.com/api/user/getuser/***REMOVED*** HTTP/1.1
Host: api.brightcityapps.com
Connection: keep-alive
Accept: application/json, text/javascript, */*; q=0.01
User-Agent: Mozilla/5.0 (Linux; Android 7.1.2; Pixel XL Build/NHG47L; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.83 Mobile Safari/537.36
Accept-Encoding: gzip, deflate
Accept-Language: en-US
X-Requested-With: com.mobilesciencetech.brightcity

Similar to another local municipal app I’ve written about recently, the request requires no authentication whatsoever. While this is alarming enough, it does get worse. Here’s a look at the API response to the above request:

{
	"BusinessWatches": [],
	"Carrier": null,
	"City": null,
	"CityGroups": [],
	"CityGroupPermissions": [],
	"DepartmentCallDetails": [],
	"EventSignUps": [],
	"HomeWatches": [],
	"Lockboxes": [],
	"Lockboxes1": [],
	"LockboxInsurances": [],
	"Lookouts": [],
	"LookoutInfoes": [],
	"Maintenances": [],
	"OpenRecords": [],
	"PatrolRequests": [],
	"Permissions": [],
	"Photo": null,
	"Supports": [],
	"SurveyResponses": [],
	"UserNotificationSettings": [],
	"UserLogins": [],
	"UsersDevices": [],
	"UserTransactions": [],
	"UtilityBills": [],
	"id": ***REMOVED***,
	"cityid": 17,
	"first": "Randy",
	"mi": null,
	"last": "Westergren",
	"phone": null,
	"cell": "***REMOVED***",
	"carrierid": null,
	"email": "***REMOVED***",
	"dob": null,
	"username": "rwestergren",
	"password": "***REMOVED***",
	"thumb": null,
	"created": "2017-05-21T16:57:00",
	"active": 1,
	"admin": 0,
	"brainTreeCustomerId": null
}

Note that though I’ve removed it from the above output, the user’s password is returned in plain text. Clearly, this is as bad as it gets.

Next, I decided to decompile the app to see if I could find a more exhaustive list of its API endpoints (and whether any required authentication at all). Here’s a look at the main activity:

package com.mobilesciencetech.brightcity;

import android.content.Intent;
import android.os.Bundle;
import org.apache.cordova.CordovaActivity;

public class BrightCity extends CordovaActivity
{

    public BrightCity()
    {
    }

    public void onCreate(Bundle bundle)
    {
        super.onCreate(bundle);
        bundle = getIntent().getExtras();
        if(bundle != null && bundle.getBoolean("cdvStartInBackground", false))
            moveTaskToBack(true);
        loadUrl(launchUrl);
    }
}

Since the app uses Android Cordova, almost none of the code was in Java. Instead, I moved over to the assets/www directory and found all of the HTML/JS that made the app run.

I opened up the editlockbox.js file and took a look at the JavaScript containing some of the API requests:

$.getJSON(url + '/api/LockboxInsurance/GetLockboxInsuranceCompanies', function(companiesJsonPayload) {
    $('#companyid').append("<option value='0'>Select Insurance Company</option>");
    $(companiesJsonPayload).each(function(i, item) {
        $('#companyid').append('<option value="' + item.id + '">' + item.name + '</option>');
    });
    $('#companyid').append("<option value='Add'>Add New Company</option>");
});

var userid = window.localStorage.getItem("userid");
$("#itemid").val(id);
$("#userid").val(window.localStorage.getItem("userid"));

$.getJSON(url + '/api/lockbox/getlockbox/' + id + '', function(result) {
    if (result == null) {
        $('#result').append('<li>Item does not exist</li>');
    } else {
        $("#userid").val(window.localStorage.getItem("userid"));
        $("#name").val(result.name);
        $.getJSON(url + '/api/lockboxcategory/getlockboxcategories', function(categoriesJsonPayload) {
            $(categoriesJsonPayload).each(function(i, item) {
                if (item.id == result.categoryid) {
                    $('#categoryid').append('<option value="' + item.id + '" selected>' + item.name + '</option>');
                } else {
                    $('#categoryid').append('<option value="' + item.id + '">' + item.name + '</option>');
                }
            });
        });
        $.getJSON(url + '/api/LockboxInsurance/GetUserPolicies/' + userid + '', function(companiesJsonPayload) {
            $('#insurance').append("<option value='0'>Select an insurance policy (choose one)</option>");
            $(companiesJsonPayload).each(function(i, item) {
                if (item.PolicyID == result.policyid) {
                    $('#insurance').append('<option value="' + item.PolicyID + '" selected>' + item.CompanyName + "-" + item.PolicyNumber + '</option>');
                } else {
                    $('#insurance').append('<option value="' + item.PolicyID + '">' + item.CompanyName + "-" + item.PolicyNumber + '</option>');
                }
                //$('#insurance').append('<option value="' + item.PolicyID + '">' + item.CompanyName + "-" + item.PolicyNumber + '</option>');
            });
            $('#insurance').append("<option value='Add'>Add new policy</option>");
        });
        $("#description").val(result.description);
        $("#serial").val(result.serial);
        $("#make").val(result.make);
        $("#model").val(result.model);
        $("#caliber").val(result.caliber);
        $("#additionalinfo").val(result.additionalinfo);
        $("#room").val(result.roomlocation);
        $.getJSON(url + '/api/lockbox/GetUserLocationRooms/' + userid + '', function(companiesJsonPayload) {
            $('#room').append("<option value='0'>Select location of item (choose one)</option>");
            $(companiesJsonPayload).each(function(i, item) {
                if (item.Name == result.roomlocation) {
                    $('#room').append('<option selected value="' + item.Name + '">' + item.Name + '</option>');
                } else {
                    $('#room').append('<option value="' + item.Name + '">' + item.Name + '</option>');
                }
            });
            $('#room').append("<option value='Add'>Add new location</option>");
        });
        //$("#insurance").val(result.policyid);
        $.getJSON(url + '/api/photo/getphoto/' + id, function(photoresult) {
            if (photoresult.name != "") {
                var image = document.getElementById('thumb');
                var imageData = iurl + '/upload/lockbox/' + id + '/thumb/' + photoresult.name;
                image.src = imageData;
                image.style.display = 'block';
                //$('#showthumb').html('<img src="' + iurl + '/upload/lockbox/' + id + '/thumb/' + photoresult.name + '" style="width:56px; height:56px; border-radius: 28px; -webkit-border-radius: 28px; -moz-border-radius: 28px;" />');
            }
        });
        $.getJSON(url + '/api/photo/getdoc/' + id, function(docresult) {
            if (docresult != null) {
                alert(docresult.name);
                var doc = document.getElementById('doc');
                var docData = iurl + '/upload/lockbox/' + id + '/doc/' + docresult.name;
                doc.src = docData;
                doc.style.display = 'block';
                //$('#showdoc').html('<a href="' + iurl + '/upload/lockbox/' + id + '/doc/' + docresult.name + '" style="width:56px; height:56px; border-radius: 28px; -webkit-border-radius: 28px; -moz-border-radius: 28px;">' + docresult.name + '</a>');
            }
        });
        //$.getJSON(url + '/api/lockboxinsurance/GetInsuranceDetails/' + id, function (insresult) {
        //    if (insresult != null) {
        //        $("#policynumber").val(insresult.PolicyNumber);
        //        $("#companyid").val(insresult.CompanyID);
        //    }
        //});
    }
});

As you can see, all requests were made in a similar way — without any authentication or session-state mechanism whatsoever. Next, I wrote a quick script to search through all of the JavaScript files to produce a list of all endpoints:

/api/agency/
/api/agency/getagency/
/api/brightcityapp/
/api/brightcityapp/geteventdetails/
/api/brightcityapp/geteventsforagency/
/api/brightcityapp/geteventsforagencybydaterange/
/api/brightcityapp/geteventsforagencybyloadcount/
/api/brightcityapp/geteventsforagencynew/
/api/brightcityapp/geteventsignupdetails/
/api/brightcitypayments/getcitypaymentsbydaterangenew/
/api/brightcitypayments/getcitypaymentsbyloadcountnew/
/api/brightcitypayments/getcitypaymentsnew/
/api/brightcitypayments/getcityutilpaymentsnew/
/api/brightcitypayments/geteventpaymentdetails/
/api/brightcitypayments/geteventpaymentsbydaterangenew/
/api/brightcitypayments/geteventpaymentsbyloadcountnew/
/api/brightcitypayments/geteventpaymentsnew/
/api/brightcitypayments/geteventutilpaymentsnew/
/api/brightcitypayments/getpaymentdetails/
/api/business/getbusinessesbyagency/
/api/businesswatch/
/api/businesswatch/cancelbusinesswatch/
/api/businesswatch/getbusinesswatch/
/api/businesswatch/getbusinesswatchesforagencybydaterangenew/
/api/businesswatch/getbusinesswatchesforagencybyloadcountnew/
/api/businesswatch/getbusinesswatchesforagencynew/
/api/businesswatchstatus/
/api/businesswatchstatus/getbusinesswatchupdates/
/api/city/getcitiesbyagency/
/api/country/
/api/eyecolor/
/api/gender/
/api/glass/
/api/haircolor/
/api/height/
/api/homewatch/
/api/homewatch/cancel/
/api/homewatch/cancelhomewatch/
/api/homewatch/gethomewatch/
/api/homewatch/gethomewatchesforagencybydaterangenew/
/api/homewatch/gethomewatchesforagencybyloadcountnew/
/api/homewatch/gethomewatchesforagencynew/
/api/homewatchstatus/
/api/homewatchstatus/gethomewatchupdates/
/api/house/gethousesbyagency/
/api/lockbox/
/api/lockbox/deletelockbox/
/api/lockbox/getlockbox/
/api/lockbox/getlockboxesforagencybydaterangenew/
/api/lockbox/getlockboxesforagencybyloadcountnew/
/api/lockbox/getlockboxesforagencynew/
/api/lockboxcategory/
/api/lockboxcategory/getlockboxcategory/
/api/lookout/
/api/lookout/getlookout/
/api/lookout/getlookoutsforagencybydaterangenew/
/api/lookout/getlookoutsforagencybyloadcountnew/
/api/lookout/getlookoutsforagencynew/
/api/lookoutinfo/
/api/lookoutinfo/getlookoutinfoforlookout/
/api/maintenance/
/api/maintenance/cancel/
/api/maintenance/getmaintenance/
/api/maintenance/getmaintenanceforagencybydaterangenew/
/api/maintenance/getmaintenanceforagencybyloadcountnew/
/api/maintenance/getmaintenanceforagencynew/
/api/maintenance/getpublicmaintenanceforagencybydaterangenew/
/api/maintenance/getpublicmaintenanceforagencybyloadcountnew/
/api/maintenance/getpublicmaintenanceforagencynew/
/api/maintenancestatus/
/api/maintenancestatus/getmaintenancestatusforrequest/
/api/message/
/api/message/getmessage/
/api/message/getmessagesforofficerbydaterangenew/
/api/message/getmessagesforofficerbyloadcountnew/
/api/message/getmessagesforofficernew/
/api/newsfeed/getagencynewsfeedslist/
/api/officer/
/api/officer/getofficer/
/api/openrecord/acceptopenrecordrequest/
/api/openrecord/getopenrecord/
/api/openrecord/getopenrecordsforagency/
/api/openrecord/getopenrecordsforagencybydaterange/
/api/openrecord/getopenrecordsforagencybyloadcount/
/api/patrolrequest/acceptpatrolrequest/
/api/patrolrequest/getpatrolrequest/
/api/patrolrequest/getpatrolrequestsforagencybydaterangenew/
/api/patrolrequest/getpatrolrequestsforagencybyloadcountnew/
/api/patrolrequest/getpatrolrequestsforagencynew/
/api/photo/getdoc/
/api/photo/getmaintenancephoto/
/api/photo/getphoto/
/api/race/
/api/scamalert/
/api/scamalert/deletescamalert/
/api/scamalert/getscamalert/
/api/scamalert/getscamalertsforagencybydaterangenew/
/api/scamalert/getscamalertsforagencybyloadcountnew/
/api/scamalert/getscamalertsforagencynew/
/api/skintone/
/api/state/
/api/state/getstate/
/api/support/
/api/trafficalert/
/api/trafficalert/deletetrafficalert/
/api/trafficalert/gettrafficalert/
/api/trafficalert/gettrafficalertsforagencybydaterangenew/
/api/trafficalert/gettrafficalertsforagencybyloadcountnew/
/api/trafficalert/gettrafficalertsforagencynew/
/api/user/
/api/user/getuser/
/api/weatheralert/
/api/weatheralert/deleteweatheralert/
/api/weatheralert/getweatheralert/
/api/weatheralert/getweatheralertsforagencybydaterangenew/
/api/weatheralert/getweatheralertsforagencybyloadcountnew/
/api/weatheralert/getweatheralertsforagencynew/
/api/weight/

Additionally, I setup a “lockbox” under my account and uploaded some images to test its functionality. I wasn’t surprised to find that directory listing was allowed, meaning all of the uploaded documents and images in the app were publicly accessible.

Risks

The risks to the public in using this app are numerous and severe. Not only is there sensitive information stored in the app itself for attackers to take freely, but actions and events within the system can be spoofed and submitted on the behalf of other users (or police agencies).

Without a fundamental authentication requirement, the integrity of any app information or action/event cannot be guaranteed to be legitimate. To be clear, there are user passwords (and other personal info), resident reports of suspicious persons, citizen electronic catalogs, and even payment information stored and used in this system — and none of it is safe.

It’s also important to note that the impact on users could be much further reaching than the Bright City system alone. Exposing user passwords in plain text can lead to compromises of other accounts (e.g. email, banking, social media) since it’s common for users to use the same passwords in multiple systems.

Disclosure

I’ve debated on how to address this particular disclosure in the most effective way possible. As in my other blog posts, I make every effort to work with vendors in reporting and patching identified vulnerabilities — but this is a little different.

I ended up reporting the above issues to the County and we had a brief call to discuss. While the vendor ended up taking the entire app offline to implement authentication, numerous other issues I reported remain unaddressed (including the directory listing issue).

2017-07-05 Initial report to County
2017-07-06 Call with County leadership to discuss
2017-07-07 Confirmed app taken offline
2017-07-17 I check status of the app, it’s back online. No further communication from vendor/County on other vulnerabilities.
Share this: Facebooktwitterlinkedin