Cutting the Lights: Vulnerabilities in a Billboard Lighting System

On my way home one night, I noticed a highway billboard sign whose lights had switched on as I drove past. It caught my attention and I wondered how those lighting systems were controlled (I mostly assumed either timers or daylight sensors). I researched it a bit and wasn’t surprised to find SmartLink which, as their website says, is “a cellular M2M controller system designed to remotely control and monitor billboard lighting applications. Over 50,000 systems have been installed to date.” I noticed the SmartLink Client Billboard Login link on their site didn’t reveal much, but things got interesting when I found their Android app.

Opening the app doesn’t allow the user to get far without authenticating:

Next, I decompiled the APK and began analyzing the source. After searching around for hints of API communication, I found a single class that seemed to handle all requests. Below are a few of those methods:

public static JSONObject doLogin(JSONObject obj) {
    String SERVICE_URI = "mobileapi.php";
    try {
        JSONStringer localJSONObject1 = new JSONStringer();
        localJSONObject1 = localJSONObject1.object();
        localJSONObject1 = localJSONObject1.key("method").value("login");
        localJSONObject1 = localJSONObject1.key("method").key("user").value(getString("user"));
        localJSONObject1 = localJSONObject1.key("method").key("user").key("password").value(getString("password"));
        JSONStringer req = localJSONObject1.key("method").key("user").key("password").endObject();
        return sendData("mobileapi.php", "", req);
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}

public static JSONObject getCustList(JSONObject obj) {
    String SERVICE_URI = "mobileapi.php";
    try {
        JSONStringer localJSONObject1 = new JSONStringer();
        localJSONObject1 = localJSONObject1.object();
        localJSONObject1 = localJSONObject1.key("method").value("getCustomerList");
        localJSONObject1 = localJSONObject1.key("method").key("UserID").value(getString("UserID"));
        JSONStringer req = localJSONObject1.key("method").key("UserID").endObject();
        return sendData("mobileapi.php", "", req);
    } catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}

Note the sendData  method which seems to be a helper method for actually performing the HTTP requests:

private static JSONObject sendData(String uri, String func, JSONStringer data) throws JSONException {
    String content = 0x0;
    boolean multipart = 0x0;
    if(func.equalsIgnoreCase("multi")) {
        multipart = true;
        String localString1 = "";
    }
    Log.d("* URI", (func + uri + func);
    StatusLine statusLine = 0x0;
    boolean success = 0x0;
    MultipartEntityBuilder mp = 0x0;
    DefaultHttpClient httpClient = new DefaultHttpClient();
    HttpParams params = httpClient.getParams();
    HttpConnectionParams.setConnectionTimeout(params, 0x2710);
    HttpConnectionParams.setSoTimeout(params, 0xafc8);
    ConnManagerParams.setTimeout(params, 0xafc8);
    HttpPost request = new HttpPost(HttpPost request = new HttpPost(params + uri + func);
    if(multipart) {
        mp = MultipartEntityBuilder.create();
        mp.setMode(HttpMultipartMode.BROWSER_COMPATIBLE);
    } else {
        try {
            request.setHeader("Accept", "application/json");
            request.setHeader("Content-type", "application/x-www-form-urlencoded");
        }
        JSONObject p = new JSONObject(data.toString());
        ArrayList<NameValuePair> pairs = new ArrayList<NameValuePair>();
        Iterator<String> iter = p.keys();
        if(!iter.hasNext()) {
        } else {
            String key = (String)iter.next();
            if(key.equalsIgnoreCase("file")) {
                byte[] decodedString = Base64.decode(localString2.toString(), 0x0);
                File f = File.createTempFile("temp", ".jpg");
                f.createNewFile();
                FileOutputStream fos = new FileOutputStream(f);
                fos.write(decodedString);
                fos.flush();
                fos.close();
                mp.addPart("file", localFileBody3);
            } else {
                Object value = p.get(key);
                if(multipart) {
                    mp.addTextBody(key, value.toString());
                } else {
                    pairs.add(localBasicNameValuePair4);
                }
            }
        }
        if(multipart) {
            mp.addTextBody("debug", "1");
            HttpEntity tmpEntity = mp.build();
            request.setEntity(tmpEntity);
        } else {
            pairs.add(new BasicNameValuePair("debug", "1"));
            Log.d("post data", pairs.toString());
            UrlEncodedFormEntity entity = new UrlEncodedFormEntity(pairs, "UTF-8");
            request.setEntity(entity);
        }
        HttpResponse response = httpClient.execute(request);
        statusLine = response.getStatusLine();
        if(statusLine.getStatusCode() == 0xc8) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            response.getEntity().writeTo(out);
            out.close();
            content = out.toString();
            success = true;
        } else {
            Log.w("HTTP#1:", statusLine.getReasonPhrase());
            localStringBuilder5.close();
            String errMsg = statusLine.getStatusCode() + ": " + statusLine.getReasonPhrase();
            content = statusLine.getStatusCode() + ", Result: \'" + statusLine.getReasonPhrase() + "\', ErrorMessage: \'" + errMsg + "\'}";
        }
    } catch(ClientProtocolException e) {
        Log.e("HTTP#2:", e.toString());
        content = e.toString() + "\', ErrorMessage: \'UNKNOWN ERROR CONDITION!\'}";
    } catch(HttpHostConnectException e) {
        Log.e("HTTP#3:", e.toString());
    } catch(IOException e) {
        Log.e("HTTP#4:", e.toString());
    } catch(Exception e) {
        Log.e("HTTP#5:", e.toString());
        JSONObject obj = new JSONObject(content);
        obj.put("success", success);
        globalRsp = obj;
        Log.d("raw result:", obj.toString());
        return obj;
    }
}

A lot of interesting things happening here. Analyzing the above method, I didn’t find evidence of a session state mechanism that would authenticate the user during requests. In order to confirm this, I decided to debug the app with jdb and attempt to invoke one of the requests manually. After I patched the APK for debugging and installed it, I attached to the process and set a breakpoint in the doLogin  method.

On my device, I attempted to login in order to trigger the breakpoint. Once execution was paused, I manually invoked the getCustList  method with a dummy UserID  parameter to check whether the request would be formed and completed. To my surprise, it returned a full dump of the entire customer list:

Here is a look at the HTTP request:

POST http://smartlink.outdoorlinkinc.com/mobile/mobileapi.php HTTP/1.1
Accept: application/json
Content-type: application/x-www-form-urlencoded
Content-Length: 40
Host: smartlink.outdoorlinkinc.com
Connection: Keep-Alive
User-Agent: Apache-HttpClient/UNAVAILABLE (java 1.4)

method=getCustomerList&UserID=-1&debug=1

This confirmed that none of the API endpoints employed a session state mechanism, meaning an attacker could completely manage/control any billboard in the system. Below is a look at another view of the app that displays a customer’s assigned structures:

The API was also using HTTP, meaning it was sending user credentials (and all other traffic) in plaintext. If this wasn’t bad enough, more research revealed some other fundamental flaws.

Visiting the web directory at /mobile/  showed a directory listing. Included among the files was a renamed/downloadable PHP file containing the API’s source code, as well as a log file containing every user login entry, with plaintext usernames and passwords, for the past 6 months.

2015-07-29 14:08:49 | getStructurePanels:  Params [CustID=***REMOVED***, PlantID=***REMOVED***, StructureID=***REMOVED***, debug=1]
2015-07-29 14:09:27 | login:  Params [user=***REMOVED*** , password=***REMOVED***, debug=1]
2015-07-29 14:09:27 | getCustomerList:  Params [UserID=***REMOVED***, debug=1]
2015-07-29 14:09:32 | getPlantList:  Params [UserID=***REMOVED***, CustID=***REMOVED***, debug=1]
2015-07-29 14:09:35 | getStructureList:  Params [CustID=***REMOVED***, PlantID=***REMOVED***, SortByAlarmCount=1, debug=1]
2015-07-29 14:10:04 | getStructure:  Params [CustID=***REMOVED***, PlantID=***REMOVED***, StructureID=***REMOVED***, debug=1]
2015-07-29 14:10:08 | updateICCID:  Params [CustID=***REMOVED***, PlantID=***REMOVED***, UserID=***REMOVED***, StructureID=***REMOVED***, ICCID=***REMOVED***, debug=1]

It seemed OutdoorLink had broken every basic rule in the book and left all of their customers carelessly vulnerable to attack — it would be simple for an attacker to make his own “highway adblock” by killing all of the billboard lights in the system.

I reached out to the Director of Engineering at OutdoorLink and quickly heard back. I immediately forwarded him all of the information describing the vulnerabilities (and design flaws) above. He responded promptly by disabling directory listing on the server and removing the files that I mentioned above (though they are still on archive.org). The API issue was treated with a little less urgency than I expected (OutdoorLink attributed this to a lack of resources),  but it was eventually addressed.

Disclosure Timeline

2015-07-29: Initial report sent to OutdoorLink
2015-07-29: Response received, directory listing and file issues addressed
2015-08-07: Follow up with OutdoorLink, API still vulnerable
2015-08-07: Response received that they’re developing new web services and apps
2015-08-10: Follow up to request timeline of new web service and app
2015-08-13: Response received, fix not expected for weeks due to resources. App is now forcing SSL.
2015-08-19: I make several suggestions for handling session state and we discuss. OutdoorLink moves forward with implementation.
2015-08-28: Android App version released with new auth scheme, waiting on Apple App to be updated before disabling old API
2015-10-04: Follow up again, iOS app completed, submitting to App Store for approval
2015-10-09: Issues with iOS app, needs more work
2015-10-20: iOS app resubmitted to App Store
2015-11-03: iOS app approved/released, old API decommissioned — confirmed resolved

OutdoorLink was very receptive to my reports and thanked me for reaching out.

Share this: Facebooktwitterlinkedin