KilterBoard
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:
- There is no public API available to get routes and other information
- 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.
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.
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 view.
There are many interesting tables. E.g. we can see all the instagram videos, routes (even not published ones).
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:
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):
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:
- Initialize and open the sqlite database
- Initialize the app: get all the constants, etc
- Initialize syncer (which as I assumed will regularly pull data from the server)
- Set up bluetooth
- Initialize services which take care of climbs, auth, wall
- 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.
Or see how the custom color will look:
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 list0x53
if the packet is the last in the list0x51
if the packet is in the middle0x54
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 sequence161 & 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 byte0x4
— we have a single packet payload with 4 bytes.0x27
—~((0x54 + 0xa1 + 0x00 + 0xe3) & 255) & 255 = 39
0x2
— the middle constant byte54a100e3
— the packet payload itself from the previous step0x3
— 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 KilterBoard via BLE.
Aaaaaaaaaaaaaaaaaaaaaaand: Hold started glowing.
KilterBoard was still okay and the hold was glowing!