From 47e8c5a3a4b4d7d7228204dd7aa73bf4dbab66dd Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:51:46 +0100 Subject: [PATCH 1/7] Remove existing data on re-run and fix issue on latest Anker app version (3.21.1) --- SolixBLE/device.py | 73 ++++++++++++++++++++++++++++++++-------- SolixBLE/prime_device.py | 71 +++++++++++++++++++++++++++++++------- scripts/patch.sh | 19 ++++++----- 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/SolixBLE/device.py b/SolixBLE/device.py index 226afc3..6b92b52 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -377,7 +377,7 @@ def _split_packet(self, packet: bytes) -> tuple[bytes, bytes, bytes]: return packet_pattern, packet_cmd, packet_payload - def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]: + def _parse_payload(self, payload: bytearray | bytes, quiet: bool = True) -> dict[str, bytes]: """ Parse payload bytes into parameters. @@ -389,7 +389,9 @@ def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]: but the successfully parsed parameters (if any) will be returned. :param payload: Payload to parse into parameters. + :param quiet: If parsing errors should raise an exception. :returns: Dictionary mapping parameter ids (a1, a2, ...) to data. + :raises IndexError: If error and quiet is false. """ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: @@ -454,12 +456,14 @@ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: ) parsed_data[param_id] = param_data - except IndexError: + except IndexError as e: _LOGGER.exception( f"Unexpected end of packet! Data may be missing or invalid!" f" Extracted so far: '{self._parameters_to_str(parsed_data)}'." f" Payload: '{payload.hex()}'" ) + if not quiet: + raise e return parsed_data @@ -518,15 +522,22 @@ def _encrypt_payload(self, payload: bytes) -> bytes: ) return cipher.encrypt(padded_data) - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """Process a telemetry packet from the device. + def _reassemble_payload( + self, payload: bytes, cmd: bytes | None = None, + ) -> bytes | None: + """Reassemble and return the raw payload. - This performs the default processing of telemetry packets in which - telemetry payloads are spread across multiple packets. This is - overridden for devices which do not use multi-packet payloads for - telemetry. + This reassembles the payload if it is spread across multiple packets + and will return the completed payload or original payload in the case + of packets which contain the full payload. Note that this does not + perform any additional processing (e.g encryption, parsing into parameters). + + If the payload is not ready (i.e not the last packet in a series of packets + where the payload is spread across multiple packets) then None is returned. + + :param payload: The payload including layout information. + :param cmd: The command value of the packet. + :returns: Reassembled payload bytes or None if payload is not complete. """ # First byte encodes fragment info (high nibble = index, low = total) @@ -551,7 +562,7 @@ async def _process_telemetry_packet( # Wait until all fragments have arrived if len(self._fragment_buffers[cmd_key]) < fragment_total: _LOGGER.debug("Waiting for remaining fragments...") - return + return None # Reassemble in order payload = b"".join( @@ -564,11 +575,40 @@ async def _process_telemetry_packet( else: # Strip fragment info + _LOGGER.debug("Not multi-packet payload!") payload = payload[1:] + return payload + + async def _process_telemetry_packet( + self, payload: bytes, cmd: bytes = None + ) -> None: + """Process a telemetry packet from the device. + + This performs the default processing of telemetry packets in which + telemetry payloads are spread across multiple packets. This is + overridden for devices which do not use multi-packet payloads for + telemetry. + """ + + # Sometimes the payload does not include fragment information so if + # we decrypt it and it works and it looks like a normal packet then we + # skip reassembly + try: + decrypted_payload = self._decrypt_payload(payload) + parameters = self._parse_payload(decrypted_payload, quiet=False) + return await self._process_telemetry(parameters) + + except Exception as e: + _LOGGER.debug(f"Failed to parse payload as single-fragment payload, trying multi-fragment parsing next: {e}") + + payload = self._reassemble_payload(payload, cmd) + if payload is None: + return None + decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") parameters = self._parse_payload(decrypted_payload) + _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") return await self._process_telemetry(parameters) async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: @@ -624,8 +664,13 @@ async def _process_notification( pattern, cmd, payload = self._split_packet(data) _LOGGER.debug(f"Pattern: {pattern.hex()}") _LOGGER.debug(f"CMD: {cmd.hex()}") - _LOGGER.debug(f"Payload: {payload.hex()}") - _LOGGER.debug(f"Payload length: {len(payload)}") + _LOGGER.debug(f"Payload (raw): {payload.hex()}") + _LOGGER.debug(f"Payload length (raw): {len(payload)}") + + + + _LOGGER.debug(f"Payload (raw): {payload.hex()}") + _LOGGER.debug(f"Payload length (raw): {len(payload)}") # If the packet has a future registered then we just trigger that # future instead of processing it here diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 7064ebe..778b83c 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -487,19 +487,64 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Packet processing # ##################### - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """ - Process a telemetry packet from an Anker Prime device. - - Anker Prime devices pack all telemetry data into a single packet - requiring no special logic to handle. - """ - decrypted_payload = self._decrypt_payload(payload) - _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") - parameters = self._parse_payload(decrypted_payload) - return await self._process_telemetry(parameters) + # async def _process_telemetry_packet( + # self, payload: bytes, cmd: bytes | None = None, + # ) -> None: + # """Process a telemetry packet from the device. + + # This performs the default processing of telemetry packets in which + # telemetry payloads are spread across multiple packets. This is + # overridden for devices which do not use multi-packet payloads for + # telemetry. + # """ + + # payload = self._decrypt_payload(payload) + # _LOGGER.debug(f"Decrypted payload: {payload.hex()}") + + # # First byte encodes fragment info (high nibble = index, low = total) + # fragment_index = (payload[0] >> 4) & 0x0F + # fragment_total = payload[0] & 0x0F + + # # Multi-part message + # if fragment_total > 1: + # fragment_data = payload[1:] + # cmd_key = bytes(cmd) + # _LOGGER.debug( + # f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes" + # ) + + # # Store fragment + # if cmd_key not in self._fragment_buffers or fragment_index == 1: + # self._fragment_buffers[cmd_key] = {} + # self._fragment_totals[cmd_key] = fragment_total + + # self._fragment_buffers[cmd_key][fragment_index] = fragment_data + + # # Wait until all fragments have arrived + # if len(self._fragment_buffers[cmd_key]) < fragment_total: + # _LOGGER.debug("Waiting for remaining fragments...") + # return + + # # Reassemble in order + # payload = b"".join( + # self._fragment_buffers[cmd_key][i] + # for i in sorted(self._fragment_buffers[cmd_key]) + # ) + # del self._fragment_buffers[cmd_key] + # del self._fragment_totals[cmd_key] + # _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") + + # else: + # # Strip fragment info + # _LOGGER.debug("Not multi-packet payload!") + + # if payload[0] not in bytearray.fromhex( + # "a0a1a2a3a4a5a6a7a8a9" + # ): + # payload = payload[1:] + + # parameters = self._parse_payload(payload) + # return await self._process_telemetry(parameters) async def _send_command(self, cmd: bytes, payload: bytes) -> None: """Send a command to the device. diff --git a/scripts/patch.sh b/scripts/patch.sh index 9432d83..a34dabe 100755 --- a/scripts/patch.sh +++ b/scripts/patch.sh @@ -3,11 +3,11 @@ # Script name : patch.sh # Description : Script for injecting Frida gadget into Anker Android app # Author : Harvey Lelliott (@flip-dots) -# Date : 23/03/26 +# Date : 16/07/26 # Usage : ./patch.sh [ADB device (e.g 192.168.1.1:1234)] # # License : MIT -# Revision : 1.0.0 +# Revision : 1.1.0 # set -euxo pipefail @@ -16,7 +16,7 @@ set -euxo pipefail # Constants # ############# -FRIDA_VERSION="17.8.2" +FRIDA_VERSION="17.8.3" UBER_APK_SIGNER_VERSION="1.3.0" @@ -53,7 +53,7 @@ WORKING_FOLDER=$(pwd) # Folder to put all data in DATA_FOLDER="${WORKING_FOLDER}/data" -mkdir -p $DATA_FOLDER +rm -rf $DATA_FOLDER && mkdir -p $DATA_FOLDER # Folder to put source APK inside APK_SOURCE_FOLDER="${DATA_FOLDER}/source_apks" @@ -73,7 +73,7 @@ mkdir -p $APK_SIGNED_FOLDER # Folder to put tools in TOOLS_FOLDER="${WORKING_FOLDER}/tools" -mkdir -p $TOOLS_FOLDER +rm -rf $TOOLS_FOLDER && mkdir -p $TOOLS_FOLDER # Folder to put Frida gadgets in FRIDA_FOLDER="${TOOLS_FOLDER}/frida" @@ -124,10 +124,11 @@ cp "${WORKING_FOLDER}/frida_config.json" "${APK_DECOMPILED_FOLDER}/lib/armeabi-v cp "${WORKING_FOLDER}/frida_config.json" "${APK_DECOMPILED_FOLDER}/lib/arm64-v8a/libnative-utils.config.so" # Add Frida gadget to app startup -sed -i '' '34c \ -const-string v0, "native-utils"\ -invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V\ -' "${APK_DECOMPILED_FOLDER}/smali/s/h/e/l/l/A.smali" +sed -i '' 's/\.locals 5/\.locals 6/g' "${APK_DECOMPILED_FOLDER}/smali/s/h/e/l/l/S.smali" +sed -i '' '2386c \ +const-string v5, "native-utils"\ +invoke-static {v5}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V\ +' "${APK_DECOMPILED_FOLDER}/smali/s/h/e/l/l/S.smali" # Modify manifest to enable Frida loading sed -i '' '// s/android:allowBackup="false"/android:allowBackup="true"/' "${APK_DECOMPILED_FOLDER}/AndroidManifest.xml" From 0bbbe26127306cc483b0e23067720354ec584250 Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:57:14 +0100 Subject: [PATCH 2/7] Bypass improved anti-tamper detection --- scripts/frida.js | 103 ++++++++++++++++++++++++++++++++++++--------- scripts/frida_2.js | 60 ++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/scripts/frida.js b/scripts/frida.js index c8bac48..56387f2 100644 --- a/scripts/frida.js +++ b/scripts/frida.js @@ -2,33 +2,15 @@ * Script name : frida.js * Description : Frida script for preventing exit and extracting shared preferences * Author : Harvey Lelliott (@flip-dots) - * Date : 23/03/26 + * Date : 16/07/26 * * License : MIT - * Revision : 1.0.0 + * Revision : 1.1.0 * * This script is based off https://codeshare.frida.re/@ninjadiary/frinja---sharedpreferences/ * but with additional code to prevent the anti-tamper mechanism from killing the app. */ -// Prevent anti-tamper mechanism from fully killing app -setImmediate(function() { - Java.perform(function() { - var System = Java.use('java.lang.System'); - var Process = Java.use('android.os.Process'); - - // Stop System.exit() - System.exit.implementation = function(code) { - console.log("[!] Intercepted exit (" + code + ")"); - }; - - // Stop Process.killProcess() - Process.killProcess.implementation = function(pid) { - console.log("[!] Intercepted killProcess for PID: " + pid); - }; - }); -}); - // Log any reads/writes to shared preferences // From: https://codeshare.frida.re/@ninjadiary/frinja---sharedpreferences/ setImmediate(function() { @@ -181,3 +163,84 @@ Java.perform(function () { }; }); }); + + +/********************** + * * + * Anti-Tamper Bypass * + * * + **********************/ + + +// Prevent anti-tamper mechanism from killing the app in Java +setImmediate(function() { + Java.perform(function() { + console.log("Tampering with anti-tamper protection using Java API..."); + + var System = Java.use('java.lang.System'); + var Process = Java.use('android.os.Process'); + + // Stop System.exit() + System.exit.implementation = function(code) { + console.log("[!] Intercepted exit (" + code + ")"); + }; + + // Stop Process.killProcess() + Process.killProcess.implementation = function(pid) { + console.log("[!] Intercepted killProcess for PID: " + pid); + }; + }); +}); + + +// Prevent anti-tamper mechanism from killing the app in C +function blockNativeExit() { + console.log("Tampering with anti-tamper protection using C API..."); + + let usleepAddr = null; + try { + usleepAddr = Module.findGlobalExportByName("usleep") || Module.findExportByName("usleep"); + } catch (e) { + console.log("[-] Could not find usleep globally: " + e.message); + } + + let nativeUsleep = null; + if (usleepAddr) { + nativeUsleep = new NativeFunction(usleepAddr, 'int', ['uint32']); + } + + // Standard libc functions used by packers to terminate processes + const exitSymbols = ["exit", "_exit", "abort", "kill", "pthread_exit"]; + + exitSymbols.forEach(function (symbol) { + let address = null; + try { + // Find global exports using Frida 17 APIs + address = Module.findGlobalExportByName(symbol) || Module.findExportByName(symbol); + } catch (e) { + // Ignore search errors + } + + if (address) { + try { + Interceptor.attach(address, { + onEnter: function (args) { + console.log("[!] Native anti-debug triggered " + symbol + "(). Freezing thread to prevent crash..."); + + // Infinite loop using the native OS sleep to halt the calling thread + while (true) { + if (nativeUsleep) { + nativeUsleep(100000); // Sleep for 100ms per iteration safely + } + } + } + }); + console.log("[+] Hooked native function: " + symbol); + } catch (e) { + console.log("[-] Failed to hook: " + symbol + " (" + e.message + ")"); + } + } + }); +} + +blockNativeExit(); diff --git a/scripts/frida_2.js b/scripts/frida_2.js index ee27c25..7b7b47e 100644 --- a/scripts/frida_2.js +++ b/scripts/frida_2.js @@ -1,10 +1,10 @@ /* - * Script name : frida.js + * Script name : frida_2.js * Description : Frida script for preventing exit, extracting shared preferences, and tracing crypto/BLE with timestamps * Author : Harvey Lelliott (@flip-dots) / Enhanced - * Date : 23/03/26 + * Date : 16/07/26 * * License : MIT - * Revision : 1.1.1 + * Revision : 1.2.1 */ // --- UTILITY FUNCTIONS --- @@ -293,4 +293,56 @@ setImmediate(function() { log("[-] Error hooking Bluetooth: " + e); } }); -}); \ No newline at end of file +}); + +// Prevent anti-tamper mechanism from killing the app in C +function blockNativeExit() { + console.log("Tampering with anti-tamper protection using C API..."); + + let usleepAddr = null; + try { + usleepAddr = Module.findGlobalExportByName("usleep") || Module.findExportByName("usleep"); + } catch (e) { + console.log("[-] Could not find usleep globally: " + e.message); + } + + let nativeUsleep = null; + if (usleepAddr) { + nativeUsleep = new NativeFunction(usleepAddr, 'int', ['uint32']); + } + + // Standard libc functions used by packers to terminate processes + const exitSymbols = ["exit", "_exit", "abort", "kill", "pthread_exit"]; + + exitSymbols.forEach(function (symbol) { + let address = null; + try { + // Find global exports using Frida 17 APIs + address = Module.findGlobalExportByName(symbol) || Module.findExportByName(symbol); + } catch (e) { + // Ignore search errors + } + + if (address) { + try { + Interceptor.attach(address, { + onEnter: function (args) { + console.log("[!] Native anti-debug triggered " + symbol + "(). Freezing thread to prevent crash..."); + + // Infinite loop using the native OS sleep to halt the calling thread + while (true) { + if (nativeUsleep) { + nativeUsleep(100000); // Sleep for 100ms per iteration safely + } + } + } + }); + console.log("[+] Hooked native function: " + symbol); + } catch (e) { + console.log("[-] Failed to hook: " + symbol + " (" + e.message + ")"); + } + } + }); +} + +blockNativeExit(); From 757d4bd0ebc5c95febb6fe1f8d80a01c120f88fd Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:58:45 +0100 Subject: [PATCH 3/7] Do not restart app after 5 seconds (only needed for old frida scripts) --- scripts/run.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/run.sh b/scripts/run.sh index 3dcd6f5..35746cb 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -55,12 +55,14 @@ echo "Starting execution..." # Port forward the port used by Frida gadget adb -s $DEVICE forward tcp:49152 tcp:49152 -# In 5 seconds open the app (non-blocking) -(sleep 5 && adb -s $DEVICE shell monkey -p com.anker.charging 1 && echo "Restarted app!") & +# Open app +adb -s $DEVICE shell monkey -p com.anker.charging 1 -# Open the app and execute the Frida script -adb -s $DEVICE shell monkey -p com.anker.charging 1 \ - && frida -H 127.0.0.1:49152 -n Gadget -l frida.js \ +# Wait 1 second +sleep 1 + +# Execute Frida script +frida -H 127.0.0.1:49152 -n Gadget -l frida.js \ 2>&1 | tee -a "${LOG_FOLDER}/${TIMESTAMP}.log" echo "Done!" From c1ba6f8762c04f07fa5c27f087222461bd19ac1c Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:00:53 +0100 Subject: [PATCH 4/7] Add alternate version of patch script for patching the Flutter APK directly --- scripts/patch_2.sh | 182 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100755 scripts/patch_2.sh diff --git a/scripts/patch_2.sh b/scripts/patch_2.sh new file mode 100755 index 0000000..b1d873e --- /dev/null +++ b/scripts/patch_2.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# +# Script name : patch_2.sh +# Description : Script for injecting Frida gadget into Anker Android app +# Author : Harvey Lelliott (@flip-dots) +# Date : 16/07/26 +# Usage : ./patch_2.sh [ADB device (e.g 192.168.1.1:1234)] +# +# License : MIT +# Revision : 1.0.0 +# +set -euxo pipefail + + +############# +# Constants # +############# + +FRIDA_VERSION="17.8.3" + +UBER_APK_SIGNER_VERSION="1.3.0" + + +################################## +# Environment and arg validation # +################################## +echo "Checking environment/tools..." + +# Validate arguments +if [ $# -lt 1 ]; then + echo "Missing device argument (e.g 192.168.1.1:1234)!" + exit 2 +fi + +# The device for use with ADB +DEVICE=$1 + +# Check tools +command -v wget >/dev/null 2>&1 || { echo >&2 "wget is required!"; exit 1; } +command -v adb >/dev/null 2>&1 || { echo >&2 "adb is required!"; exit 1; } +command -v apktool >/dev/null 2>&1 || { echo >&2 "apktool is required!"; exit 1; } +command -v java >/dev/null 2>&1 || { echo >&2 "java is required!"; exit 1; } +command -v frida >/dev/null 2>&1 || { echo >&2 "frida is required!"; exit 1; } + + +################ +# Folder setup # +################ +echo "Setting up folders..." + +# The current folder +WORKING_FOLDER=$(pwd) + +# Folder to put all data in +DATA_FOLDER="${WORKING_FOLDER}/data" +mkdir -p $DATA_FOLDER + +# Folder to put source APK inside +APK_SOURCE_FOLDER="${DATA_FOLDER}/source_apks" +mkdir -p $APK_SOURCE_FOLDER + +# Folder to put the main decompiled APK inside +APK_MAIN_DECOMPILED_FOLDER="${DATA_FOLDER}/base_apk_decompiled" +mkdir -p $APK_MAIN_DECOMPILED_FOLDER + +# Folder to put the flutter decompiled APK inside +APK_FLUTTER_DECOMPILED_FOLDER="${DATA_FOLDER}/flutter_apk_decompiled" +mkdir -p $APK_FLUTTER_DECOMPILED_FOLDER + +# Folder to put patched APKs inside +APK_PATCHED_FOLDER="${DATA_FOLDER}/patched" +mkdir -p $APK_PATCHED_FOLDER + +# Folder to put signed APKs inside +APK_SIGNED_FOLDER="${DATA_FOLDER}/signed" +mkdir -p $APK_SIGNED_FOLDER + +# Folder to put tools in +TOOLS_FOLDER="${WORKING_FOLDER}/tools" +mkdir -p $TOOLS_FOLDER + +# Folder to put Frida gadgets in +FRIDA_FOLDER="${TOOLS_FOLDER}/frida" +mkdir -p $FRIDA_FOLDER + + + +####################### +# Download deps/tools # +####################### +echo "Downloading dependencies/tools" + +cd $FRIDA_FOLDER && wget "https://github.com/zer0def/undetected-frida/releases/download/${FRIDA_VERSION}/undetected-frida-gadget-${FRIDA_VERSION}-android-arm.so.xz" +cd $FRIDA_FOLDER && wget "https://github.com/zer0def/undetected-frida/releases/download/${FRIDA_VERSION}/undetected-frida-gadget-${FRIDA_VERSION}-android-arm64.so.xz" +cd $FRIDA_FOLDER && unxz -f "undetected-frida-gadget-${FRIDA_VERSION}-android-arm.so.xz" +cd $FRIDA_FOLDER && unxz -f "undetected-frida-gadget-${FRIDA_VERSION}-android-arm64.so.xz" + +cd $TOOLS_FOLDER && wget "https://github.com/patrickfav/uber-apk-signer/releases/download/v${UBER_APK_SIGNER_VERSION}/uber-apk-signer-${UBER_APK_SIGNER_VERSION}.jar" + + +######################## +# Pull and extract APK # +######################## + +# Pull Anker APKs from Phone +echo "Extracting original APKs from phone..." +adb -s $DEVICE shell pm path com.anker.charging | sed 's/^package://' | tr -d '\r' | xargs -I {} adb -s $DEVICE pull {} $APK_SOURCE_FOLDER + + +################ +# Inject Frida # +################ +echo "Injecting Frida gadget into flutter APK..." + +# Extract Flutter library from Flutter APK +mkdir -p "${APK_FLUTTER_DECOMPILED_FOLDER}/lib/arm64-v8a" +unzip -p "${APK_SOURCE_FOLDER}/split_config.arm64_v8a.apk" "lib/arm64-v8a/libflutter.so" > "${APK_FLUTTER_DECOMPILED_FOLDER}/lib/arm64-v8a/libflutter.so" + +# Use LIEF to inject the Frida gadget as a dependency of Flutter +python3 -c " +import lief +import sys + +target_path = '${APK_FLUTTER_DECOMPILED_FOLDER}/lib/arm64-v8a/libflutter.so' + +try: + # Parse the ELF binary + binary = lief.parse(target_path) + + # Inject the dependency + binary.add_library('libnative-utils.so') + binary.write(target_path) + print(f'Successfully injected Frida into Flutter') +except Exception as e: + print(f'Failed to inject Frida into Flutter: {e}') + sys.exit(1) +" + +# Copy Frida gadget binaries into Flutter APK +cp "${FRIDA_FOLDER}/undetected-frida-gadget-${FRIDA_VERSION}-android-arm64.so" "${APK_FLUTTER_DECOMPILED_FOLDER}/lib/arm64-v8a/libnative-utils.so" +cp "${WORKING_FOLDER}/frida_config.json" "${APK_FLUTTER_DECOMPILED_FOLDER}/lib/arm64-v8a/libnative-utils.config.so" + + +# Decompile the main APK +echo "Decompiling main APK..." +apktool d "${APK_SOURCE_FOLDER}/base.apk" -o $APK_MAIN_DECOMPILED_FOLDER -f + +# Modify manifest of main APK to enable Frida loading +sed -i '' '// s/android:allowBackup="false"/android:allowBackup="true"/' "${APK_MAIN_DECOMPILED_FOLDER}/AndroidManifest.xml" +sed -i '' '// s/android:extractNativeLibs="false"/android:extractNativeLibs="true" android:debuggable="true"/' "${APK_MAIN_DECOMPILED_FOLDER}/AndroidManifest.xml" + + + +########################## +# Re-package and re-sign # +########################## +echo "Re-packaging and re-signing APKs..." + +# Re-package APKs +apktool b -o "${APK_PATCHED_FOLDER}/base.apk" ${APK_MAIN_DECOMPILED_FOLDER} + +cp "${APK_SOURCE_FOLDER}/split_config.arm64_v8a.apk" "${APK_PATCHED_FOLDER}/split_config.arm64_v8a.apk" +cd "${APK_FLUTTER_DECOMPILED_FOLDER}" && zip -ur "${APK_PATCHED_FOLDER}/split_config.arm64_v8a.apk" lib/ + +# Re-sign APKs +java -jar "${TOOLS_FOLDER}/uber-apk-signer-${UBER_APK_SIGNER_VERSION}.jar" -o $APK_SIGNED_FOLDER --allowResign --apks \ + "${APK_PATCHED_FOLDER}/base.apk" \ + "${APK_PATCHED_FOLDER}/split_config.arm64_v8a.apk" \ + "${APK_SOURCE_FOLDER}/split_config.en.apk" \ + "${APK_SOURCE_FOLDER}/split_config.xxhdpi.apk" \ + "${APK_SOURCE_FOLDER}/split_flutter_assets_pack.apk" + +# Uninstall existing Anker app +adb -s $DEVICE uninstall com.anker.charging + +# Install patched APKs +adb -s $DEVICE install-multiple \ + "${APK_SIGNED_FOLDER}/base-aligned-debugSigned.apk" \ + "${APK_SIGNED_FOLDER}/split_config.arm64_v8a-aligned-debugSigned.apk" \ + "${APK_SIGNED_FOLDER}/split_config.en-aligned-debugSigned.apk" \ + "${APK_SIGNED_FOLDER}/split_config.xxhdpi-aligned-debugSigned.apk" \ + "${APK_SIGNED_FOLDER}/split_flutter_assets_pack-aligned-debugSigned.apk" From 8aec4b90dbfc77b8c09e59bb12746a44b08fdbaa Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:05:50 +0100 Subject: [PATCH 5/7] Improve warnings/notes --- docs/source/app_decoding.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/app_decoding.rst b/docs/source/app_decoding.rst index a5261d2..39db2d2 100644 --- a/docs/source/app_decoding.rst +++ b/docs/source/app_decoding.rst @@ -100,7 +100,7 @@ To manually capture the logs follow these steps: 6. Perform the actions required to get the packets needed for analysis (add device, send commands, etc). .. note:: - This process is incredibly time sensitive, it may take multiple attempts to get it right. + This process is incredibly time sensitive, it may take multiple attempts (10+) to get it right. run.sh @@ -111,7 +111,7 @@ and in addition writes the logs to a file as well as the console. This is the recommend approach. .. note:: - The app must not be running when you start the script. + The app must **not** be running when you start the script. This script performs the following actions: From 12b0a9b84fd5cb30bb1b6235a80bcb378fcf08ec Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:20:54 +0100 Subject: [PATCH 6/7] Kill app if running on start --- scripts/run.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/run.sh b/scripts/run.sh index 35746cb..90f86e0 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -3,11 +3,11 @@ # Script name : run.sh # Description : Script for executing patched Anker app and starting Frida. # Author : Harvey Lelliott (@flip-dots) -# Date : 23/03/26 +# Date : 16/07/26 # Usage : ./run.sh [ADB device (e.g 192.168.1.1:1234)] # # License : MIT -# Revision : 1.0.0 +# Revision : 1.1.0 # set -euxo pipefail @@ -55,10 +55,12 @@ echo "Starting execution..." # Port forward the port used by Frida gadget adb -s $DEVICE forward tcp:49152 tcp:49152 +# Kill app +adb -s "$DEVICE" shell am force-stop com.anker.charging +sleep 1 + # Open app adb -s $DEVICE shell monkey -p com.anker.charging 1 - -# Wait 1 second sleep 1 # Execute Frida script From 81270f4d2727f1a3c01d70f81ced37d9f71ac72d Mon Sep 17 00:00:00 2001 From: Harvey <42912136+flip-dots@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:29:50 +0100 Subject: [PATCH 7/7] Revert changes inherited from experimental branch --- SolixBLE/device.py | 73 ++++++++-------------------------------- SolixBLE/prime_device.py | 71 +++++++------------------------------- 2 files changed, 27 insertions(+), 117 deletions(-) diff --git a/SolixBLE/device.py b/SolixBLE/device.py index 6b92b52..226afc3 100644 --- a/SolixBLE/device.py +++ b/SolixBLE/device.py @@ -377,7 +377,7 @@ def _split_packet(self, packet: bytes) -> tuple[bytes, bytes, bytes]: return packet_pattern, packet_cmd, packet_payload - def _parse_payload(self, payload: bytearray | bytes, quiet: bool = True) -> dict[str, bytes]: + def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]: """ Parse payload bytes into parameters. @@ -389,9 +389,7 @@ def _parse_payload(self, payload: bytearray | bytes, quiet: bool = True) -> dict but the successfully parsed parameters (if any) will be returned. :param payload: Payload to parse into parameters. - :param quiet: If parsing errors should raise an exception. :returns: Dictionary mapping parameter ids (a1, a2, ...) to data. - :raises IndexError: If error and quiet is false. """ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: @@ -456,14 +454,12 @@ def _verbose_pop(data: bytearray, length: int, name: str) -> bytes: ) parsed_data[param_id] = param_data - except IndexError as e: + except IndexError: _LOGGER.exception( f"Unexpected end of packet! Data may be missing or invalid!" f" Extracted so far: '{self._parameters_to_str(parsed_data)}'." f" Payload: '{payload.hex()}'" ) - if not quiet: - raise e return parsed_data @@ -522,22 +518,15 @@ def _encrypt_payload(self, payload: bytes) -> bytes: ) return cipher.encrypt(padded_data) - def _reassemble_payload( - self, payload: bytes, cmd: bytes | None = None, - ) -> bytes | None: - """Reassemble and return the raw payload. - - This reassembles the payload if it is spread across multiple packets - and will return the completed payload or original payload in the case - of packets which contain the full payload. Note that this does not - perform any additional processing (e.g encryption, parsing into parameters). - - If the payload is not ready (i.e not the last packet in a series of packets - where the payload is spread across multiple packets) then None is returned. + async def _process_telemetry_packet( + self, payload: bytes, cmd: bytes = None + ) -> None: + """Process a telemetry packet from the device. - :param payload: The payload including layout information. - :param cmd: The command value of the packet. - :returns: Reassembled payload bytes or None if payload is not complete. + This performs the default processing of telemetry packets in which + telemetry payloads are spread across multiple packets. This is + overridden for devices which do not use multi-packet payloads for + telemetry. """ # First byte encodes fragment info (high nibble = index, low = total) @@ -562,7 +551,7 @@ def _reassemble_payload( # Wait until all fragments have arrived if len(self._fragment_buffers[cmd_key]) < fragment_total: _LOGGER.debug("Waiting for remaining fragments...") - return None + return # Reassemble in order payload = b"".join( @@ -575,40 +564,11 @@ def _reassemble_payload( else: # Strip fragment info - _LOGGER.debug("Not multi-packet payload!") payload = payload[1:] - return payload - - async def _process_telemetry_packet( - self, payload: bytes, cmd: bytes = None - ) -> None: - """Process a telemetry packet from the device. - - This performs the default processing of telemetry packets in which - telemetry payloads are spread across multiple packets. This is - overridden for devices which do not use multi-packet payloads for - telemetry. - """ - - # Sometimes the payload does not include fragment information so if - # we decrypt it and it works and it looks like a normal packet then we - # skip reassembly - try: - decrypted_payload = self._decrypt_payload(payload) - parameters = self._parse_payload(decrypted_payload, quiet=False) - return await self._process_telemetry(parameters) - - except Exception as e: - _LOGGER.debug(f"Failed to parse payload as single-fragment payload, trying multi-fragment parsing next: {e}") - - payload = self._reassemble_payload(payload, cmd) - if payload is None: - return None - decrypted_payload = self._decrypt_payload(payload) - parameters = self._parse_payload(decrypted_payload) _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") + parameters = self._parse_payload(decrypted_payload) return await self._process_telemetry(parameters) async def _process_telemetry(self, parameters: dict[str, bytes]) -> None: @@ -664,13 +624,8 @@ async def _process_notification( pattern, cmd, payload = self._split_packet(data) _LOGGER.debug(f"Pattern: {pattern.hex()}") _LOGGER.debug(f"CMD: {cmd.hex()}") - _LOGGER.debug(f"Payload (raw): {payload.hex()}") - _LOGGER.debug(f"Payload length (raw): {len(payload)}") - - - - _LOGGER.debug(f"Payload (raw): {payload.hex()}") - _LOGGER.debug(f"Payload length (raw): {len(payload)}") + _LOGGER.debug(f"Payload: {payload.hex()}") + _LOGGER.debug(f"Payload length: {len(payload)}") # If the packet has a future registered then we just trigger that # future instead of processing it here diff --git a/SolixBLE/prime_device.py b/SolixBLE/prime_device.py index 778b83c..7064ebe 100644 --- a/SolixBLE/prime_device.py +++ b/SolixBLE/prime_device.py @@ -487,64 +487,19 @@ async def _process_negotiation(self, cmd: bytes, payload: bytes) -> None: # Packet processing # ##################### - # async def _process_telemetry_packet( - # self, payload: bytes, cmd: bytes | None = None, - # ) -> None: - # """Process a telemetry packet from the device. - - # This performs the default processing of telemetry packets in which - # telemetry payloads are spread across multiple packets. This is - # overridden for devices which do not use multi-packet payloads for - # telemetry. - # """ - - # payload = self._decrypt_payload(payload) - # _LOGGER.debug(f"Decrypted payload: {payload.hex()}") - - # # First byte encodes fragment info (high nibble = index, low = total) - # fragment_index = (payload[0] >> 4) & 0x0F - # fragment_total = payload[0] & 0x0F - - # # Multi-part message - # if fragment_total > 1: - # fragment_data = payload[1:] - # cmd_key = bytes(cmd) - # _LOGGER.debug( - # f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes" - # ) - - # # Store fragment - # if cmd_key not in self._fragment_buffers or fragment_index == 1: - # self._fragment_buffers[cmd_key] = {} - # self._fragment_totals[cmd_key] = fragment_total - - # self._fragment_buffers[cmd_key][fragment_index] = fragment_data - - # # Wait until all fragments have arrived - # if len(self._fragment_buffers[cmd_key]) < fragment_total: - # _LOGGER.debug("Waiting for remaining fragments...") - # return - - # # Reassemble in order - # payload = b"".join( - # self._fragment_buffers[cmd_key][i] - # for i in sorted(self._fragment_buffers[cmd_key]) - # ) - # del self._fragment_buffers[cmd_key] - # del self._fragment_totals[cmd_key] - # _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes") - - # else: - # # Strip fragment info - # _LOGGER.debug("Not multi-packet payload!") - - # if payload[0] not in bytearray.fromhex( - # "a0a1a2a3a4a5a6a7a8a9" - # ): - # payload = payload[1:] - - # parameters = self._parse_payload(payload) - # return await self._process_telemetry(parameters) + async def _process_telemetry_packet( + self, payload: bytes, cmd: bytes = None + ) -> None: + """ + Process a telemetry packet from an Anker Prime device. + + Anker Prime devices pack all telemetry data into a single packet + requiring no special logic to handle. + """ + decrypted_payload = self._decrypt_payload(payload) + _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}") + parameters = self._parse_payload(decrypted_payload) + return await self._process_telemetry(parameters) async def _send_command(self, cmd: bytes, payload: bytes) -> None: """Send a command to the device.