KilterBoard

29 min read

Introduction

As some people know I’m a big fan of KilterBoard climbing 🔗.

Unfortunately, the official app 🔗 is not the best experience and is lacking some features. So I wanted to give it a try and implement a better app I could use myself or at least understand how the official app works, there is no goal of getting any profit from this project, it’s only about fun and education.

There are some problems making it harder:

  1. There is no public API available to get routes and other information
  2. I have no idea how to interact with the board via bluetooth

This post will try to cover all the aspects and let’s see how far we can get with this.

Reverse engineering the app

Running the app in an emulator

Let’s start easy and get the APK file 🔗. I decided to load it into Android Studio first and see how it works in the emulator. Seems to run fine, authorization work, I can see all my climb history and other data.

Emulator Interface Kilterboard app open in Android Studio.

The first instinct is to check the files they are storing at /data/data/com.auroraclimbing.kilterboard/. There is not much interesting except for the 85MB sqlite file. Seems like they store a lot locally.

Emulator Files List of files stored by KilterBoard app.

Let’s take a quick look at what data is stored in the database, open it in sqlitebrowser 🔗 and see the tables.

Sqlite Schema Sqlite schema view.

There are many interesting tables. E.g. we can see all the instagram videos, routes (even not published ones).

Sqlite Climb List List of climbs in the sqlite database.

The table climbs stores all the information about all the existing routes and all the metadata for them including the setter, name, description, layout.

Layout is a string in a format p1083r15p1117r15p1164r12p1185r12p1233r13p1282r13p1303r13p1372r13p1392r14p1505r15 (the route is Narasaki Bounce 🔗, set by VinceThePrince) and looks like this in the app:

Narasaki Bounce route That’s how the route looks in the app.

There are 10 holds on the route: 3 yellow, 2 green, 4 blue, and 1 purple. We can split it into chunks starting with p:

p1083r15
p1117r15
p1164r12
p1185r12
p1233r13
p1282r13
p1303r13
p1372r13
p1392r14
p1505r15

There are 10 strings in format p\d{4}r\d{2}. If we group them by r\d\d, then there are these groups:

  • r12 — 2 (green)
  • r13 — 4 (blue)
  • r14 — 1 (purple)
  • r15 — 3 (yellow)

And the other 4 numbers after p are hold ids. There is a table placements (mapping from hold_id to hole_id), and table holes (containing x and y for each hole).

And that’s how the screenshot looks after rendering images on top (with manual scale and offset):

Narasaki Bounce with render over That’s how the route looks with circles rendered over the screenshot.

This feels like success and I don’t think there is much more I could extract from the app without looking into the code. After this step we already can extract the sqlite file automatically and display the route data.

Decompiling the app

Let’s now dig dipper into the code and see what we can find there. I used both

And the results were pretty similar. App is written in Kotlin, so it’s easy to read. Some files weren’t decompiled successfully but it’s fine for now.

First glance

First I checked the contents of com/auroraclimbing/auroraboard/app/Application.java:

public final class Application extends android.app.Application {
    public void onCreate() {
        super.onCreate();
        File createOrUpgradeDatabaseFile = DatabaseFileProcs.createOrUpgradeDatabaseFile(getApplicationContext(), "db", 242, "sqlite3");
        if (createOrUpgradeDatabaseFile != null) {
            ZoneBrowser zoneBrowser = null;
            SQLiteDatabase openDatabase = SQLiteDatabase.openDatabase(createOrUpgradeDatabaseFile.getAbsolutePath(), (SQLiteDatabase.CursorFactory) null, 0);
            if (openDatabase != null) {
                AssetManager assets = getApplicationContext().getAssets();
                Intrinsics.checkNotNullExpressionValue(assets, "this.getApplicationContext().getAssets()");
                File filesDir = getApplicationContext().getFilesDir();
                Intrinsics.checkNotNullExpressionValue(filesDir, "this.getApplicationContext().getFilesDir()");
                PhotoAlbum internalPhotoAlbum = new InternalPhotoAlbum(assets, filesDir, "img");
                String string = getString(R.string.api_host);
                Intrinsics.checkNotNullExpressionValue(string, "this.getString(R.string.api_host)");
                APIServer stdAPIServer = new StdAPIServer("https", string, "img", new StdPostRequest(), new StdPutRequest(), new StdDeleteRequest(), new StdGetImageRequest());
                Syncer syncer = new Syncer(new StdSync(new CentralServerSyncRequestBodyFactory(), new CentralServerSync(stdAPIServer, new CentralServerSyncResponseBodyFactory()), internalPhotoAlbum, stdAPIServer));
                syncer.start();
                SyncServices.INSTANCE.setSyncer(syncer);
                HashMap hashMapOf = MapsKt.hashMapOf(TuplesKt.m99to("username_required", getString(R.string.username_required)), TuplesKt.m99to("username_too_long", getString(R.string.username_too_long)), TuplesKt.m99to("username_invalid_characters", getString(R.string.username_invalid_characters)), TuplesKt.m99to("username_cannot_start_with_period", getString(R.string.username_cannot_start_with_period)), TuplesKt.m99to("username_cannot_end_with_period", getString(R.string.username_cannot_end_with_period)), TuplesKt.m99to("username_taken", getString(R.string.username_taken)), TuplesKt.m99to("username_unknown", getString(R.string.username_unknown)), TuplesKt.m99to("email_address_required", getString(R.string.email_address_required)), TuplesKt.m99to("email_address_too_long", getString(R.string.email_address_too_long)), TuplesKt.m99to("email_address_invalid", getString(R.string.email_address_invalid)), TuplesKt.m99to("password_required", getString(R.string.password_required)), TuplesKt.m99to("password_too_short", getString(R.string.password_too_short)), TuplesKt.m99to("password_too_long", getString(R.string.password_too_long)), TuplesKt.m99to("password_incorrect", getString(R.string.password_incorrect)), TuplesKt.m99to("synchronization_failed", getString(R.string.synchronization_failed)), TuplesKt.m99to("wall_name_required", getString(R.string.wall_name_required)), TuplesKt.m99to("comment_too_long", getString(R.string.comment_too_long)));
                String string2 = getString(R.string.led_kit_name_substring);
                Intrinsics.checkNotNullExpressionValue(string2, "this.getString(R.string.led_kit_name_substring)");
                BluetoothService stdBluetoothService = new StdBluetoothService(string2, getResources().getInteger(R.integer.leds_per_hold));
                stdBluetoothService.start();
                SignUpInServices.INSTANCE.setStringResources(hashMapOf);
                SignUpInServices.INSTANCE.setSyncServices(SyncServices.INSTANCE);
                WallWizardServices.INSTANCE.setStringResources(hashMapOf);
                WallWizardServices.INSTANCE.setSyncServices(SyncServices.INSTANCE);
                ClimbsServices.INSTANCE.setSyncServices(SyncServices.INSTANCE);
                ClimbsServices.INSTANCE.setViewModelCache(ViewModelCache.INSTANCE);
                AllServices allServices = AllServices.INSTANCE;
                SharedPreferences sharedPreferences = getSharedPreferences("aurora_board_app", 0);
                Intrinsics.checkNotNullExpressionValue(sharedPreferences, "this.getSharedPreference…p\", Context.MODE_PRIVATE)");
                String string3 = getString(R.string.api_host);
                Intrinsics.checkNotNullExpressionValue(string3, "this.getString(R.string.api_host)");
                SyncServices syncServices = SyncServices.INSTANCE;
                if (getApplicationContext().getResources().getBoolean(R.bool.zones_enabled)) {
                    zoneBrowser = new ZoneBrowser();
                }
                allServices.init(hashMapOf, sharedPreferences, string3, stdAPIServer, syncServices, openDatabase, new KitBrowser(stdBluetoothService, zoneBrowser), new ExhibitLogger(), internalPhotoAlbum);
                return;
            }
            throw new NullPointerException("After opening, the database is still null.");
        }
        throw new NullPointerException("After creating or updating the database file, the application database file is still null.");
    }
}

Looks like a mess but it’s enough to understand how the app works:

  1. Initialize and open the sqlite database
  2. Initialize the app: get all the constants, etc
  3. Initialize syncer (which as I assumed will regularly pull data from the server)
  4. Set up bluetooth
  5. Initialize services which take care of climbs, auth, wall
  6. Run everything

After this I decided I want to do the following steps next:

  • Get the API http route (assuming it’s just a normal REST API)
  • Understand how auth works
  • Call API manually
  • Get routes

Getting API endpoints

The first step look trivial. We already have the line string = getString(R.string.api_host) so let’s just grep for api_host:

$ rg "api_host"
resources/res/values/strings.xml
204:    <string name="api_host">api.kilterboardapp.com</string>

resources/res/values/public.xml
4508:    <public type="string" name="api_host" id="2131820745" />

api.kilterboardapp.com indeed exists and returns 404.

Looks like the endpoint doesn’t have a protocol, so let’s grep more, now for protocol!

$ rg "https://"
sources/com/google/android/libraries/places/internal/zzdr.java
37:        Uri.Builder buildUpon = Uri.parse("https://maps.googleapis.com/").buildUpon();

sources/com/auroraclimbing/auroraboard/model/Climb.java
148:        return "https://" + str + "/climbs/" + this.uuid;

sources/com/auroraclimbing/auroraboard/remote/CentralServerSaveExhibitKt.java
26:            java.lang.String r3 = "https://"

sources/com/auroraclimbing/auroraboard/controller/CircuitFragment.java
480:        intent.putExtra("android.intent.extra.TEXT", "https://" + getString(R.string.deep_link_host) + "/circuits/" + getViewModel().getUuid());

Now we know there are endpoints:

  • /climbs/[id]
  • /circuits/[something]
$ https -A bearer -a $KB_TOKEN GET "https://api.kilterboardapp.com/v1/circuits/123"
HTTP/1.1 401 Unauthorized
Cache-Control: no-cache
Connection: Keep-Alive
Content-Length: 0
Content-Type: text/html; charset=UTF-8
Date: Wed, 16 Aug 2023 13:26:56 GMT
Keep-Alive: timeout=5, max=100
Server: Apache/2.4.38 (Debian)
Vary: Authorization
WWW-Authenticate: Bearer realm="aurora", error="invalid_token"

It exists and requires auth! Almost there!

Auth

Hopefully this step won’t be much harder. Since every piece of logic is a service, let’s check the sources/com/auroraclimbing/auroraboard/remote/CentralServerSignInKt.java.

Intrinsics.checkNotNullParameter(aPIServer, "apiServer");
Intrinsics.checkNotNullParameter(signInDetails, "signInDetails");
String username = signInDetails.getUsername();
String password = signInDetails.getPassword();
JSONObject jSONObject = new JSONObject();
jSONObject.put("username", username);
jSONObject.put("password", password);
jSONObject.put("tou", "accepted");
jSONObject.put("pp", "accepted");
String jSONObject2 = jSONObject.toString();
Intrinsics.checkNotNullExpressionValue(jSONObject2, "requestBody.toString()");
Pair<Integer, String> post = aPIServer.post("/v1/logins", (String) null, "application/json", jSONObject2);
int intValue = post.getFirst().intValue();
JSONObject jSONObject3 = new JSONObject(post.getSecond());

Should be a call to /v1/logins with username and password:

$ https POST "https://api.kilterboardapp.com/v1/logins" username="$KB_USERNAME" password="$KB_PASSWORD"

{
    "login": {
        "created_at": "2023-08-16 13:35:32.768368",
        "token": "...",
        "user_id": ...
    },
    "token": "...",
    "user": {
        "avatar_image": null,
        "banner_image": null,
        "city": null,
        "country": null,
        "created_at": "2022-08-...",
        "email_address": "...",
        "height": null,
        "id": ...,
        "is_listed": true,
        "is_public": true,
        "updated_at": "2022-08-...",
        "username": "...",
        "weight": null,
        "wingspan": null
    },
    "user_id": ...,
    "username": "..."
}

So login is just a one-liner: https POST "https://api.kilterboardapp.com/v1/logins" username="$KB_USERNAME" password="$KB_PASSWORD" | jq ".login.token"

Let’ just blindly assume it’s Bearer:

$ https -A bearer -a $KB_TOKEN GET "https://api.kilterboardapp.com/v1/circuits/123"
[]

Aaaand it works! Just need to find out how to get data from the API.

Reversing sync

After a few hours of playing with the app we can do almost everything:

  • Get the API http route (assuming it’s just a normal REST API)
  • Understand how auth works
  • Call API manually
  • Get routes

Keeping the database size in mind it’s pretty clear that they don’t call API when we open the routes, but rather regularly pull them into the local sqlite storage. Probably that’s what SyncServices is supposed to do.

Let’s look into sources/com/auroraclimbing/auroraboard/remote/CentralServerSync.java:

public SyncResponseBody execute(Session session, SyncRequestBody syncRequestBody) {
    Intrinsics.checkNotNullParameter(syncRequestBody, "requestBody");
    Log.d("<AuroraBoard>", "<sync> BEGIN CentralServerSync");
    if (session != null) {
        Log.d("<AuroraBoard>", "<sync> The token is " + session.getToken());
    }
    Log.d("<AuroraBoard>", "<sync> The following is the JSON request body for sync.");
    Log.d("<AuroraBoard>", "<sync>" + syncRequestBody);
    SyncResponseBody execute = this.syncResponseBodyFactory.execute(this.apiServer.post("/v1/sync", session != null ? session.getToken() : null, "application/json", syncRequestBody.toString()).getSecond());
    Log.d("<AuroraBoard>", "<sync> END CentralServerSync");
    return execute;
}

And now to sources/com/auroraclimbing/auroraboard/remote/CentralServerSyncRequestBody.java:

JSONObject jSONObject = new JSONObject();
JSONObject jSONObject2 = new JSONObject();
jSONObject2.put("enforces_product_passwords", 1);
jSONObject2.put("enforces_layout_passwords", 1);
jSONObject2.put("manages_power_responsibly", 1);
jSONObject2.put("ufd", 1);
jSONObject.put("client", jSONObject2);
JSONObject jSONObject3 = new JSONObject();
jSONObject3.put("include_multiframe_climbs", 1);
jSONObject3.put("include_all_beta_links", 1);
jSONObject3.put("include_null_climb_stats", 1);
JSONArray jSONArray = new JSONArray(CollectionsKt.listOf("products", "product_sizes", "holes", "leds", "products_angles", "layouts", "product_sizes_layouts_sets", "placements", "holds", "shapes", "sets", "placement_roles", "climbs", "climb_stats", "beta_links", "attempts", "kits"));
Session session2 = this.session;
if (session2 != null) {
    jSONObject3.put("user_id", session2.getUserId());
    jSONArray.put("users");
    jSONArray.put("walls");
    jSONArray.put("wall_expungements");
    jSONArray.put("draft_climbs");
    jSONArray.put("ascents");
    jSONArray.put("bids");
    jSONArray.put("tags");
    jSONArray.put("circuits");
}
if (jSONArray.length() > 0) {
    jSONObject3.put("tables", jSONArray);
}
JSONObject jSONObject4 = new JSONObject();
JSONArray sharedSyncsToJSONArray = sharedSyncsToJSONArray();
if (sharedSyncsToJSONArray.length() > 0) {
    jSONObject4.put("shared_syncs", sharedSyncsToJSONArray);
}
JSONArray userSyncsToJSONArray = userSyncsToJSONArray();
if (userSyncsToJSONArray.length() > 0) {
    jSONObject4.put("user_syncs", userSyncsToJSONArray);
}
if (jSONObject4.length() > 0) {
    jSONObject3.put("syncs", jSONObject4);
}
JSONObject jSONObject5 = new JSONObject();
if (jSONObject3.length() > 0) {
    jSONObject5.put(SearchIntents.EXTRA_QUERY, jSONObject3);
}
if (jSONObject5.length() > 0) {
    jSONObject.put("GET", jSONObject5);
}
JSONObject jSONObject6 = new JSONObject();
JSONArray wallsToJSONArray = wallsToJSONArray();
if (wallsToJSONArray.length() > 0) {
    jSONObject6.put("walls", wallsToJSONArray);
}
JSONArray wallExpungementsToJSONArray = wallExpungementsToJSONArray();
if (wallExpungementsToJSONArray.length() > 0) {
    jSONObject6.put("wall_expungements", wallExpungementsToJSONArray);
}
if (jSONObject6.length() > 0) {
    jSONObject.put("PUT", jSONObject6);
}
String jSONObject7 = jSONObject.toString();
Intrinsics.checkNotNullExpressionValue(jSONObject7, "requestBody.toString()");
return jSONObject7;

The code is clear but I don’t really feel like constructing this JSON manually. So I decided to stop here for a while and work on other projects.

After a few weeks I got back to this project and realized how stupid I am. Looking back at SyncResponseBody execute there are very extensive logs:

public SyncResponseBody execute(Session session, SyncRequestBody syncRequestBody) {
  ...
  Log.d("<AuroraBoard>", "<sync> The following is the JSON request body for sync.");
  Log.d("<AuroraBoard>", "<sync>" + syncRequestBody);
  SyncResponseBody execute = this.syncResponseBodyFactory.execute(this.apiServer.post("/v1/sync", session != null ? session.getToken() : null, "application/json", syncRequestBody.toString()).getSecond());
  Log.d("<AuroraBoard>", "<sync> END CentralServerSync");
  ...
}

Thanks god they are logging the whole JSON: Log.d("<AuroraBoard>", "<sync>" + syncRequestBody) and I don’t have to construct the object. We can just connect the phone and open the app, and let’s see if logs are logging:

$ adb logcat | grep "AuroraBoard"
08-15 17:18:26.270 19870 19870 D <AuroraBoard>: <bluetooth><StdBluetoothService><start> BEGIN Starting service.
08-15 17:18:26.270 19870 19870 D <AuroraBoard>: <bluetooth><StdBluetoothService><start> END Service started.
08-15 17:18:26.271 19870 19870 D <AuroraBoard>: <bluetooth><StdBluetoothService><addObserver> Adding an observer.
08-15 17:18:26.271 19870 19870 D <AuroraBoard>: <bluetooth><StdBluetoothService><addObserver> After adding, the number of observers is 1.
08-15 17:18:26.288 19870 19870 D <AuroraBoard>: <ExploreFragment><onCreate> BEGIN
08-15 17:18:26.288 19870 19870 D <AuroraBoard>: <ExploreFragment> lazy viewModel BEGIN
08-15 17:18:26.288 19870 19870 D <AuroraBoard>: <ExploreMasterFragment><onCreate> END
08-15 17:18:26.298 19870 19870 D <AuroraBoard>: <sharedPreferencesReadHomeSelectedTabKey>169868 profile
08-15 17:18:26.302 19870 19870 D <AuroraBoard>: <sharedPreferencesReadBoardsSelectedTabKey>169868 custom
08-15 17:18:26.306 19870 19870 D <AuroraBoard>: <Home2Activity><selectedMenuItem><onChanged> BEGIN
08-15 17:18:26.306 19870 19870 D <AuroraBoard>: <sharedPreferencesSaveHomeSelectedTabKey>169868 profile
08-15 17:18:26.306 19870 19870 D <AuroraBoard>: <Home2Activity><selectedMenuItem><onChanged> END
08-15 17:18:26.308 19870 19870 D <AuroraBoard>: <ProfileFragment><onResume> BEGIN
08-15 17:18:26.308 19870 19870 D <AuroraBoard>: <ProfileViewModel><load> BEGIN
08-15 17:18:26.308 19870 19870 D <AuroraBoard>: <ProfileViewModel><load> END
08-15 17:18:26.308 19870 19870 D <AuroraBoard>: <ProfileFragment><onResume> END
08-15 17:18:27.015 19870 19910 D <AuroraBoard>: <ProfileViewModel><onReadProfileSuccess> BEGIN
08-15 17:18:36.281 19870 19887 D <AuroraBoard>: <Syncer><doTask> this is the runnable running
08-15 17:18:36.281 19870 19887 D <AuroraBoard>: <sync> BEGIN Sync
08-15 17:18:36.284 19870 19887 D <AuroraBoard>: <sync> The most recently signed in user has id 169868
08-15 17:18:36.284 19870 19887 D <AuroraBoard>: <sync> That user's token is d0185bd7ce36fe8043a28e4d38d1f36443e69c6f
08-15 17:18:36.291 19870 19887 D <AuroraBoard>: <sync> BEGIN CentralServerSync
08-15 17:18:36.292 19870 19887 D <AuroraBoard>: <sync> The token is d0185bd7ce36fe8043a28e4d38d1f36443e69c6f
08-15 17:18:36.292 19870 19887 D <AuroraBoard>: <sync> The following is the JSON request body for sync.
08-15 17:18:36.293 19870 19887 D <AuroraBoard>: <sync>{"client":{"enforces_product_passwords":1,"enforces_layout_passwords":1,"manages_power_responsibly":1,"ufd":1},"GET":{"query":{"include_multiframe_climbs":1,"include_all_beta_links":1,"include_null_climb_stats":1,"user_id":169868,"tables":["products","product_sizes","holes","leds","products_angles","layouts","product_sizes_layouts_sets","placements",
...
08-15 17:18:37.324 19870 19887 D <AuroraBoard>: <sync> END CentralServerSync
08-15 17:18:37.335 19870 19887 D <AuroraBoard>: <CentralServerSyncResponseBody><getClimbs> There are 8 climbs in the server response.
08-15 17:18:37.336 19870 19887 D <AuroraBoard>: <CentralServerSyncResponseBody><getClimbs> There are 0 draft climbs in the server response.
08-15 17:18:37.992 19870 19887 D <AuroraBoard>: <sync> END Sync

Booom! There is <AuroraBoard>: <sync>{"client":{"enforces_product_passwords" ...! Just dump it into data.json and try to call sync!

$ https -A bearer -a $KB_TOKEN POST "https://api.kilterboardapp.com/v1/sync" < data.json | less
{
    "PUT": {
        "circuits": [
            {
                "climbs": [
                    {
                        "position": 47,
                        "uuid": "0073a028c7df480db68f07ed2316e72d"
                    },
                    {
                        "position": 10,
                        "uuid": "0445F285E6BB46C9BA0B53D1ECF767D3"
                    },
                    {
...

$ ls -lah /tmp/response.json
-rw-r----- 1 3.9M Aug 15 14:33 /tmp/response.json

We got 4MB of all the data!

Results

After exploring the app we found out how to get all the existing climbs, how to send API requests, how to sync data.

Reversing Bluetooth

The first part seemed too easy to reverse, so let’s get to the harder one. I was not expecting to find everything in logs not, because protocol over Bluetooth should be binary and lower-level than the REST API.

Understanding the internal protocol

The logical step would be to start with something containing bluetooth in its name, so let’s look into com/auroraclimbing/auroraboard/local/BluetoothServiceKt.java. There is an interesting block, which probably describes how to discover the Board via Bluetooth, just saving it for now and will get back to it later at some point.

static {
    UUID fromString = UUID.fromString("4488B571-7806-4DF6-BCFF-A2897E4953FF");
    Intrinsics.checkNotNullExpressionValue(fromString, "fromString(\"4488B571-7806-4DF6-BCFF-A2897E4953FF\")");
    auroraBoardServiceUUID = fromString;
    UUID fromString2 = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
    Intrinsics.checkNotNullExpressionValue(fromString2, "fromString(\"6E400001-B5A3-F393-E0A9-E50E24DCCA9E\")");
    UARTServiceUUID = fromString2;
    UUID fromString3 = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");
    Intrinsics.checkNotNullExpressionValue(fromString3, "fromString(\"6E400002-B5A3-F393-E0A9-E50E24DCCA9E\")");
    RXCharacteristicUUID = fromString3;
    UUID fromString4 = UUID.fromString("00000001-2864-491F-9B58-C2018679B2DC");
    Intrinsics.checkNotNullExpressionValue(fromString4, "fromString(\"00000001-2864-491F-9B58-C2018679B2DC\")");
    SpecialServiceUUID = fromString4;
    UUID fromString5 = UUID.fromString("00000002-2864-491F-9B58-C2018679B2DC");
    Intrinsics.checkNotNullExpressionValue(fromString5, "fromString(\"00000002-2864-491F-9B58-C2018679B2DC\")");
    RotateCharacteristicUUID = fromString5;
    UUID fromString6 = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
    Intrinsics.checkNotNullExpressionValue(fromString6, "fromString(\"00002902-0000-1000-8000-00805f9b34fb\")");
    ClientCharacteristicConfigUUID = fromString6;
}

There are 2 methods looking like the most important ones prepBytesV3 and prepBytesV2. They are similar, so let’s only discuss V3 and see if it’s enough:

public static final List<Integer> prepBytesV3(List<ClimbPlacementMinimalData> list, Map<Integer, PlacementRole> map) {
	String str;
	List<List> arrayList = new ArrayList<>();
	List arrayList2 = new ArrayList();

	arrayList2.add(81);

	for (ClimbPlacementMinimalData next : list) {
		if (arrayList2.size() + 3 > messageBodyMaxLength) {
			arrayList.add(arrayList2);
			arrayList2 = new ArrayList();
			arrayList2.add(81);
		}

		int position = next.getPosition() & 255;
		int position2 = (next.getPosition() & MotionEventCompat.ACTION_POINTER_INDEX_MASK) >> 8;

		PlacementRole placementRole = map.get(Integer.valueOf(next.getRoleId()));

		if (placementRole == null || (str = placementRole.getLedColor()) == null) {
			str = "FFFFFF";
		}

		String substring = str.substring(0, 2);
		String substring2 = str.substring(2, 4);


		int parseInt = 0 | ((Integer.parseInt(substring, CharsKt.checkRadix(16)) / 32) << 5) | ((Integer.parseInt(substring2, CharsKt.checkRadix(16)) / 32) << 2);

		String substring3 = str.substring(4, 6);
		int parseInt2 = (Integer.parseInt(substring3, CharsKt.checkRadix(16)) / 64) | parseInt;

		arrayList2.add(Integer.valueOf(position));
		arrayList2.add(Integer.valueOf(position2));
		arrayList2.add(Integer.valueOf(parseInt2));
	}
	arrayList.add(arrayList2);
	Collection collection = arrayList;
	if (collection.size() == 1) {
		Log.d("<AuroraBoard>", "<bluetooth><prepBytesV3> Changing command to 'T'.");
		((List) arrayList.get(0)).set(0, 84);
	} else if (collection.size() > 1) {
		Log.d("<AuroraBoard>", "<bluetooth><prepBytesV3> Changing command of first message to 'R' and last message to 'S'. There are a total of " + collection.size() + " messages.");
		((List) arrayList.get(0)).set(0, 82);
		((List) arrayList.get(collection.size() - 1)).set(0, 83);
	}
	List<Integer> arrayList3 = new ArrayList<>();
	for (List wrapBytes : arrayList) {
		arrayList3.addAll(wrapBytes(wrapBytes));
	}
	Log.d("<AuroraBoard>", "<bluetooth><prepBytesV3> END.");
	return arrayList3;
}

I simplified it a little bit to make it shorter and easier to read, removed some logs and not important asserts.

There is a helper functions:

private static final List<Integer> wrapBytes(List<Integer> list) {
	int size = list.size();
	int i = messageBodyMaxLength;
	if (size > i) {
		Log.d("<AuroraBoard>", "<bluetooth><wrapBytes> END The argument elements was too long with size " + list.size() + ". Max is " + i + ". Returning empty list.");
		return CollectionsKt.emptyList();
	}
	List<Integer> arrayList = new ArrayList<>();
	arrayList.add(1);
	arrayList.add(Integer.valueOf(list.size()));
	arrayList.add(Integer.valueOf(checksum(list)));
	arrayList.add(2);
	for (Integer intValue : list) {
		arrayList.add(Integer.valueOf(intValue.intValue()));
	}
	arrayList.add(3);
	return arrayList;
}

And simple checksum function:

private static final int checksum(List<Integer> list) {
	int i = 0;
	for (Integer intValue : list) {
		i = (i + intValue.intValue()) & 255;
	}
	return (~i) & 255;
}

From my humble CTF-reverse career I got a habit of just rewriting code to Python and then analyzing it, so let’s do it:

PACKET_MIDDLE = 81
PACKET_FIRST = 82
PACKET_LAST = 83
PACKET_ONLY = 84

MESSAGE_BODY_MAX_LENGTH = 255


def checksum(data):
    i = 0
    for value in data:
        i = (i + value) & 255
    return (~i) & 255


def wrap_bytes(data):
    if len(data) > MESSAGE_BODY_MAX_LENGTH:
        return []

    return [
        1,
        len(data),
        checksum(data),
        2,
        *data,
        3,
    ]


@dataclass
class ClimbPlacement:
    position: int
    role_id: int


def encode_position(position):
    position1 = position & 255
    position2 = (position & 65280) >> 8

    return [position1, position2]


def encode_color(color):
    substring = color[0:2]
    substring2 = color[2:4]

    parsed_substring = int(substring, 16) // 32
    parsed_substring2 = int(substring2, 16) // 32
    parsed_result = parsed_substring << 5 | parsed_substring2 << 2

    substring3 = color[4:6]
    parsed_substring3 = int(substring3, 16) // 64
    final_parsed_result = parsed_result | parsed_substring3

    return final_parsed_result


def encode_placement(position, led_color):
    return [*encode_position(position), encode_color(led_color)]


def prep_bytes_v3(climb_placement_list, role_map):
    result_list = []
    temp_list = [PACKET_MIDDLE]

    for climb_placement in climb_placement_list:
        if len(temp_list) + 3 > MESSAGE_BODY_MAX_LENGTH:
            result_list.append(temp_list)
            temp_list = [PACKET_MIDDLE]

        placement_role = role_map.get(climb_placement.role_id)
        if placement_role is None or (led_color := placement_role.led_color) is None:
            led_color = "FFFFFF"

        encoded_placement = encode_placement(climb_placement.position, led_color)
        temp_list.extend(encoded_placement)

    result_list.append(temp_list)

    if len(result_list) == 1:
        result_list[0][0] = PACKET_ONLY
    elif len(result_list) > 1:
        result_list[0][0] = PACKET_FIRST
        result_list[-1][0] = PACKET_LAST

    result_array = []
    for current_bytes in result_list:
        result_array.extend(wrap_bytes(current_bytes))

    return result_array

Finally it looks readable! Protocol is relatively simple:

  • 0x1
  • len(packets)
  • checksum(packets)
  • 0x2
  • *packets
  • 0x3

First byte is always 1, the second is a number of packets, then checksum, then 2, packets themselves, and finally 3.

Checksum

Checksum functions looks a bit random, but oh well. It just sums up all bytes of the packet in a single-byte variable (not caring about overflown part), and then inverts it.

Color encoding

Color encoding is simple: it just turns 3 byte color in a #RRGGBB format (where RR, GG, and BB are 1 byte each) to 0bRRRGGGBB (3 bits for red, 3 bits for green, 2 bits for blue). Which means we can use 256 different colors on the kilterboard.

8-bit color, with three bits of red, three bits of green, and two bits of blue, wikipedia 8-bit color, with three bits of red, three bits of green, and two bits of blue, wikipedia.

Or see how the custom color will look:

#ff00ff
111 000 11 = #ff00ff

Position encoding

Remember the sqlite we found in the first steps? Let’s get back to it. There is a table called leds, it contains a mapping from hole_id to position, so we just need to find the position id we need and then it gets split into 2 bytes:

position1 = position & 0xff
position2 = (position & 0xff00) >> 8

Packet encoding

Each placement is encoded using 3 bytes: position, position, color.

After this each packet is encoded using the following algorithm:

  • Packet index marker:
    • 0x52 if the packet is the first in the list
    • 0x53 if the packet is the last in the list
    • 0x51 if the packet is in the middle
    • 0x54 if the packet is the only packet
  • For each placement:
    • 3 bytes of the encoded placement

Quick example

Let’s try to glow the hold #161 with #FF00FF.

Packet payload:

  • 0x54 — the packet is the only one in the sequence
  • 161 & 255 = 161 = 0xa1 — the first byte of the placement
  • (417 & 65280) >> 8 = 0 = 0x0 — the second byte of the placement
  • ((0xFF // 32) << 5) | (0xFF // 64) = 0xe3 — purple color packed into 8 bits

The packet payload looks like 54a100e3.

So the final data will look like:

  • 0x1 — the first constant byte
  • 0x4 — we have a single packet payload with 4 bytes.
  • 0x27~((0x54 + 0xa1 + 0x00 + 0xe3) & 255) & 255 = 39
  • 0x2 — the middle constant byte
  • 54a100e3 — the packet payload itself from the previous step
  • 0x3 — the final constant byte

And the data then is 01 04 27 02 54 a1 00 e3 03.

Interactive example

Try to click holds!

Bluetooth payload:

Trying it out

Protocol is revered, let’s go to the gym and try it out! I was considering taking a day off to try it out, but had some urgent stuff and decided to just quickly test it from my phone. There are probably tons of apps for this, the first one I found was Light Blue 🔗 and it looks fine.

I connected to the device with UUID 4488B571-7806-4DF6-BCFF-A2897E4953FF, service with UUID 6E400001-B5A3-F393-E0A9-E50E24DCCA9E and tried to open characteristic with UUID 6E400002-B5A3-F393-E0A9-E50E24DCCA9E. It was marked as writable so I was hoping KilterBoard won’t explode from my experiments. I tried entering 0104270254a100e303 and pushing it into device:

Writing data to BLE Writing data to KilterBoard via BLE.

Aaaaaaaaaaaaaaaaaaaaaaand: Hold is glowing Hold started glowing.

KilterBoard was still okay and the hold was glowing!