Taeuk Kang
Published on

Reverse Engineering a Proprietary Smart Home Protocol

Authors
  • avatar
    Name
    Taeuk Kang
    Twitter

Motivation

My first encounter with smart home devices was in 2018 when I installed a Sonoff Smart Relay in a "stupid" light fixture to turn it "smart" so that I can turn lights off in my bed without having to get up. Sonoff has an IFTTT integration, which can then be used to create a webhook to directly control the lights programmatically. I could then control the light with my smart speakers (Google Home and Amazon Alexa), my phone (using iOS Shortcuts), and my computer (a custom Touch Bar button with BetterTouchTool).

Now, I moved and have been living in a ubiquitous Internet of Things-connected "Smart Home" equipped with Commax devices. Capacitive touch light switches, thermostats, and sensors are all controllable remotely. Unfortunately, the only way to control these "smart" devices are to a) walk up to the device and control it myself, or b) use a clunky Android or iOS app, or c) connect it to Google Home (more proprietary protocols!).

I wanted a simple API endpoint that I could ping to control these devices, so it meant that I had to reverse engineer one of the existing control points.

Technical Background

Devices communicate with the wall pad over serial (RS-485) and the wall pad is communicated to the Cloud™ over the internet. Existing attempts hijacked the serial communication between devices and the wall pad to directly interface with the device. This method is reliable, because it does not depend on the Internet or a third-party server to work; everything is done locally! But it also meant that I had to install a clunky serial to RJ45/USB device under the sink. So why not use the existing protocol that the mobile app uses to communicate with the home?

The first instinct was to use Wireshark and to inspect the packets that the app generates when I control a device, but that was out the window because I assumed that the traffic was encrypted (hopefully). Spoiler: it was.

Reverse Engineering

The path that I did go down was to disassemble the Android APK to inspect how the app was generating requests. Android APKs are available in one of the many APK mirror sites (make sure to click the most boring looking download button, or else it's an ad!).

To decompile the app, several tools are available: Apktool works locally for .apk files and Decompiler.com works online for several different languages. I chose to use Decompiler.com because I didn't have to download anything.

With the decompiled app, the first thing that I searched for was the "base URL" of the API by querying https://, which found HttpConstant.java.

// HttpConstant.java
package com.commax.protocol.http;

public interface HttpConstant {
	public static final String ACCESS_DOMAIN_DEV = "http://api-dev.example-server.com:4400";
	public static final String ACCESS_DOMAIN_STG = "https://api-stg.example-server.com:4400";
	public static final String API_AUTH = "/oauth/authorize";
	public static final String API_COMMAND = "/v1/command";
	// ...
}

This was a good starting point with helpfully labeled constants. The next thing I needed was the authentication protocol. The app had a sign-in screen with traditional username/password entry, so I searched for auth and found authorize() under HttpManager.java.

// HttpManager.java
public void authorize(String str, String str2, String str3, HttpResponse httpResponse) {
	String uri = Uri.parse((((this.f11495a + HttpConstant.API_AUTH) + "?client_id=" + this.f11496b) + "&client_secret=" + this.f11497c) + "&grant_type=password").buildUpon().appendQueryParameter("username", str).appendQueryParameter("password", Uri.encode(str2)).build().toString();

	if (TextUtils.isEmpty(str3)) {
		Context context = f11494f;
		CmxAesPreferences.setString(context, Constant.KEY_DEVICE_UUID, DeviceTool.getDeviceUUID(context));
	}

	addToRequestQueue(new k(0, uri, (JSONObject) null, httpResponse.listener, httpResponse.errorListener));
}

Looks like a standard username and password authentication! After tracking down the cryptic variables and replacing it with string literals, I was able to recreate the following HTTP GET call for authentication:

/oauth/authorize?client_id=APP-AND-com.commax.ipiot&client_secret={{client_secret}}&grant_type=password&username={{username}}&password={{password}}

Somewhat concerning that the password is sent as part of the query string (use different passwords for each services!), but that's a story for a different time.

An instance of a class k is passed into addToRequestQueue(). k is a wrapper class that applies HTTP headers to the auth request: the device OS, UUID, and version (all were needed to receive a successful response).

class k extends CustomJsonObjectRequest {
    k(int i2, String str, JSONObject jSONObject, Response.Listener listener, Response.ErrorListener errorListener) {
        super(i2, str, jSONObject, listener, errorListener);
    }

    public Map<String, String> getHeaders() throws AuthFailureError {
        HashMap hashMap = new HashMap();

        // set header cmx-dvc-os
        hashMap.put(HttpConstant.CMX_DEVICE_OS, HttpConstant.DEVICE_OS);

        // set header cmx-dvc-uuid
        hashMap.put(HttpConstant.CMX_DEVICE_UUID, CmxAesPreferences.getString(HttpManager.f11494f, Constant.KEY_DEVICE_UUID, ""));

        // set header cmx-app-version
        hashMap.put(HttpConstant.CMX_APP_VERSION, HttpConstant.APP_VERSION);

        return hashMap;
    }
}

And with the headers set and valid endpoint query parameters inserted, the GET request returned a JSON with the Bearer access and refresh tokens as well as the base URLs for other services (e.g., the gateway server) that will be relevant later.

// Response
{
	"token_type": "Bearer",
	"access_token": "eyJ0e...",
	"refresh_token": "eyJ0e...",
	"expire_in": "259200", // 3 days, when interpreted as seconds
	"expires_in": "259200",
	// ...
}

Moving on to the fun part now: how can I actually switch off a light? A helpfully named deviceControl() function was within the same file as authorize(), with the following snippet:

// HttpManager.java
public void deviceControl(AccountData accountData, GatewayData gatewayData, JSONObject jSONObject, HttpResponse httpResponse) {
    String str = accountData.iotServer + HttpConstant.API_COMMAND;

    addToRequestQueue(new c(1, str, (JSONObject) null, httpResponse.listener, httpResponse.errorListener, accountData, "{\"commands\" : {\"cgpCommand\" : [{\"gatewayNo\" : \"" + gatewayData.gatewayNo + "\",\"cgp\" : {\"command\" : \"set\",\"object\" : " + jSONObject.toString() + "}}]}}"));
}

It looks like it's making a POST request with a JSON body attached to it, but I needed to find out two things: the "gateway" and the "command."

{
	"commands": {
		"cgpCommand": [
			{
				"gatewayNo": "", // ???
				"cgp": {
					"command": "set",
					"object": {
						// ???
					}
				}
			}
		]
	}
}

The gateway is what connects the outside world (the cloud) to the inside world (home wallpad). Getting the gateway information was easy with the gatewayInfo() function:

// HttpManager.java
public void gatewayInfo(AccountData accountData, HttpResponse httpResponse) {
	// /v1/gateways
    String str = accountData.iotServer + HttpConstant.API_GATEWAYS;

    addToRequestQueue(new r(0, str, (JSONObject) null, httpResponse.listener, httpResponse.errorListener, accountData));
}

I tried pinging /v1/gateways with the Authorization header set to the auth token from earlier, which returned the gateway information:

{
	"gateways": {
		"gateway": [
			{
				"gatewayNo": "blah blah",
				"model": "CDP-1000Y",
				"type": "WP",
				"countryCode": "KOR",
				"regDate": "2100-05-01T01:00:59Z"
			}
		]
	}
}

The gatewayNo is half of the piece needed to control a device.

Right above the gatewayInfo() function, gatewayDeviceInfo() was defined, so I also pinged that endpoint and received a JSON with all devices in the house.

public void gatewayDeviceInfo(AccountData accountData, GatewayData gatewayData, HttpResponse httpResponse) {
    String str = accountData.iotServer + "/v1/gateways/" + gatewayData.gatewayNo;

    addToRequestQueue(new s(0, str, (JSONObject) null, httpResponse.listener, httpResponse.errorListener, accountData));
}

A portion of the JSON response:

{
	"information": {
		"comType": "DS485"
	},
	"subDevice": [
		{
			"value": "off",
			"subVisible": "true",
			"sort": "switchBinary",
			"funcCommand": "report",
			"subUuid": "",
			"type": "readWrite"
		}
	],
	"rootUuid": "",
	"visible": "true",
	"deviceNumber": "2",
	"commaxDevice": "light",
	"rootDevice": "switch",
	"nickname": "Living Room 1"
}
View full JSON response
{
	"gateway": {
		"gatewayNo": "",
		"model": "CDP-1000Y",
		"type": "WP",
		"countryCode": "KOR",
		"regDate": "2023-01-01T00:00:00Z",
		"modDate": "2023-12-29T09:20:30Z",
		"devices": {
			"object": [
				{
					"information": {
						"comType": "DS485"
					},
					"subDevice": [
						{
							"value": "off",
							"subVisible": "true",
							"sort": "switchBinary",
							"funcCommand": "report",
							"subUuid": "",
							"type": "readWrite"
						}
					],
					"rootUuid": "",
					"visible": "true",
					"deviceNumber": "2",
					"commaxDevice": "light",
					"rootDevice": "switch",
					"nickname": "Living Room 1"
				},
				{
					"information": {
						"comType": "DS485"
					},
					"subDevice": [
						{
							"value": "24",
							"scale": [
								"C"
							],
							"precision": "0",
							"sort": "airTemperature",
							"funcCommand": "report",
							"subUuid": "",
							"type": "read"
						},
						{
							"value": "heat",
							"subOption": [
								"heat",
								"off"
							],
							"sort": "thermostatMode",
							"funcCommand": "report",
							"subUuid": "",
							"type": "readWrite"
						},
						{
							"option1": "5",
							"scale": [
								"C"
							],
							"option2": "40",
							"precision": "0",
							"sort": "thermostatSetpoint",
							"funcCommand": "report",
							"value": "23",
							"subUuid": "",
							"type": "readWrite"
						},
						{
							"subOption": [
								"awayOn",
								"awayOff"
							],
							"subVisible": "false",
							"sort": "thermostatAwayMode",
							"funcCommand": "report",
							"value": "awayOff",
							"subUuid": "",
							"type": "readWrite",
							"ifRunvisible": "false"
						}
					],
					"rootUuid": "",
					"visible": "true",
					"deviceNumber": "1",
					"commaxDevice": "boiler",
					"rootDevice": "thermostat",
					"nickname": "Living Room (Boiler)"
				}
			]
		}
	}
}

An observation to make from the response is that devices have root and sub devices. For example, a living room thermostat is a root device, and under it, it can have "sub devices" to set the operation mode or temperature.

Now, time to figure out the command protocol. I started to search for terms like lights, switches, and relevant synonyms and discovered relevant views for dimming switches.

// SceneViewHolder.java
public void setDimming(Context context, SceneViewHolderData sceneViewHolderData, CmxValueCtrlDialog.Type type) {
    Q(sceneViewHolderData, context.getString(R.string.sub_device_value_dimming), getDimmingValue(context, type, sceneViewHolderData.addSubData.subDeviceData.value));
    sceneViewHolderData.checkBox.setOnClickListener(new z(this, sceneViewHolderData, type, context));
}

And further investigations led to a file that directly relates to light switches:

// LightDimmmerDs485Activity.java
public /* synthetic */ void r(View view) {
    ArrayList arrayList = new ArrayList();

    // f10756l is the subDeviceData from `p()`
    this.f10756l.value = this.f10751g.btnPower.isChecked() ? "on" : "off";
    arrayList.add(this.f10756l);
    AppCompatCheckBox appCompatCheckBox = this.f10751g.btnPower;
    appCompatCheckBox.setChecked(!appCompatCheckBox.isChecked());

    // second parameter is `rootDevice`
    // the third parameter `arrayList` is the `jSONObject` for `deviceControl()`
    deviceControl(this.activity, this.f10754j, arrayList);
    this.f10752h.setSubPower(this.f10756l);
}

This function is linked to the view that controls a light dimmer. Notice that the call to deviceControl() only has three parameters, which was not what I saw earlier. Tracing through this function, I found another function named deviceControl():

// BaseControlActivity.java
public void deviceControl(Context context, RootDeviceData rootDeviceData, ArrayList<SubDeviceData> arrayList) {
    DeviceControl.getInstance().set(context, rootDeviceData, arrayList);
}

Guessing from the parameters, to control a device, the root and sub devices are needed, where each device unit (like a light switch) is a sub device associated with a root device.

So how do the two different deviceControl() functions link with each other? Searching for calls to deviceControl() from HttpManager.java, one of them was under DeviceControl.java.

// DeviceControl.java
/* access modifiers changed from: private */
public /* synthetic */ void o(Context context, RootDeviceData rootDeviceData, ArrayList arrayList, boolean z2) {
    if (z2) {
    HttpManager.getInstance(context).deviceControl(AccountData.getInstance(), GatewayData.getInstance(), rootDeviceData.setJson(arrayList), s());

	    Activity activity = (Activity) context;
        if (!activity.isFinishing()) {
            CmxProgressCgp.getInstance().show(activity, ((SubDeviceData) arrayList.get(0)).subUuid);
        }
    }
}

Four parameters are passed in: the AccountData, the GatewayData, and the return value from rootDeviceData.setJson(arrayList), and s() (with type HttpResponse). The first two are fairly self-explanatory, but .setJson() is new (I figured that s is not too relevant).

Below is the .setJson() function:

// RootDeviceData.java
@NonNull
public JSONObject setJson(ArrayList<SubDeviceData> arrayList) {
    JSONObject jSONObject = new JSONObject();
    try {
        jSONObject.put("rootUuid", this.rootUuid);
        jSONObject.put("rootDevice", this.rootDevice);
        jSONObject.put("nickname", this.nickName);
        if (arrayList.size() > 0) {
            JSONArray jSONArray = new JSONArray();
            for (int i2 = 0; i2 < arrayList.size(); i2++) {
                JSONObject json = arrayList.get(i2).setJson();
                if (json != null) {
                    jSONArray.put(json);
                }
            }
            jSONObject.put(Cgp.SUB_DEVICE, jSONArray); // subDevice: [ ... ]
        }
    } catch (JSONException e2) {
        Log.e((Throwable) e2);
    }
    return jSONObject;
}

This function generates the command object for the device control request A root jSONObject is created with three keys: rootUuid, rootDevice, and nickname. Then, a jSONArray is created and loops through the arrayList of SubDeviceData and puts each element in the arrayList into the jSONArray. Finally, the jSONArray is added to the jSONObject with the key Cgp.SUB_DEVICE.

Now, putting everything together, a stub JSON body looks something like:

{
	"commands": {
		"cgpCommand": [
			{
				"gatewayNo": "",
				"cgp": {
					"command": "set",
					"object": {
						"rootUuid": "",
						"rootDevice": "",
						"nickname": "",
						"subDevice": [
							{}
						]
					}
				}
			}
		]
	}
}

These values look familiar—recall the gateway information endpoint.

Replacing the values for that of a light switch (and changing the value from off to on) from the gateway information into the device control body, I could generate this JSON (I did replace the UUIDs with random ones).

{
	"commands": {
		"cgpCommand": [
			{
				"gatewayNo": "d8868b4d-3e8d-4f20-ad15-841d898218b2",
				"cgp": {
					"command": "set",
					"object": {
						"rootUuid": "609b519f-7d02-47d3-96e4-95b233fa5a4e",
						"rootDevice": "switch",
						"nickname": "Living Room",
						"subDevice": [
							{
								"value": "on", // 💡
								"subVisible": "true",
								"funcCommand": "report",
								"subUuid": "091c35c7-c2dc-47ac-aa3b-6852d9fec2ff",
								"sort": "switchBinary",
								"type": "readWrite"
							}
						]
					}
				}
			}
		]
	}
}

Going back to the deviceControl() from HttpManager.java, an authenticated HTTP POST request to /v1/command returned...

{
	"commands": {
		"cgpCommand": [
			"52492c69ec76"
		]
	}
}

and most importantly, the light turned on!