Deep Dive: Reverse Engineering the DroidPass Flutter Password Vault (8ksec Challenge)
A detailed walkthrough of how to statically reverse-engineer the DroidPass Flutter-based password vault challenge from 8ksec, uncovering its AES key derivation, IV generation, and encryption flaws.
I came across a recent LinkedIn post from 8ksec mentioning that Nobody’s cracked the DroidPass Android challenge yet, even though it has been out for months. That immediately caught my attention — a Flutter-based Android password vault that supposedly hides its base encryption key inside the APK sounded too tempting to ignore. So I decided to dive in, not only to try solving it but also to understand why it managed to remain unsolved for so long.
Introduction
“Introducing DroidPass—the “secure” password manager that promises military-grade encryption for all your sensitive credentials! DroidPass uses advanced encryption techniques to store your passwords in a protected database, keeping them safe from prying eyes. Our intuitive interface lets you generate strong, unique passwords with just a tap, while our security module continuously monitors your device for threats.
DroidPass automatically detects if your device is running in a tampered environment and takes appropriate security measures to protect your data. With secure encryption keys and multiple layers of security, your passwords are protected by the most advanced security techniques available. The clean, modern interface makes managing your digital life effortless while keeping your sensitive information under lock and key.”
Figure: 8ksec DroidPass - Ultimate Password Vault
Objective
“Your goal is to statically reverse-engineer the DroidPass application to identify and extract the exact base encryption key used to secure the password database.”
Restrictions
What’s the fun if you just dynamically print out the key by hooking the application? Real reverse engineers rely on their static analysis skills to understand code behavior without execution. You must solve this challenge using only static reverse engineering techniques.
No runtime instrumentation, hooking, or dynamic analysis is allowed. You are also disallowed from using automated tools that directly extract secrets.
Quick glance the DroidPass.apk with jadx-gui
I started with a simple static inspection using jadx-gui. Dropping the DroidPass.apk into jadx shows obfuscation (likely DexGuard or ProGuard variants). Obfuscation renames symbols and makes high-level control flow harder to follow, but it cannot hide strings, constants, or the general structure of native libraries inside the APK. Figure: Obfuscated APK
From the package com.eightksec.droidpass you can observe only two principal classes in the decompiled tree: MainActivity and SecurityModule. That immediately suggests the heavy lifting—business logic and encryption—may be implemented in native code or inside Flutter/Dart compiled artifacts rather than pure Java/Kotlin.
MainActivity class and its parent e
MainActivity is a thin wrapper that defers much of its behavior to an obfuscated parent class (named e in the decompiled output). The activity lifecycle method onCreate(Bundle) is present and contains references to FlutterActivity, confirming that the app UI and much of the app logic originate from Flutter/Dart rather than being pure Android Java/Kotlin. Figure: onCreate(Bundle bundle)
Because Flutter apps compile Dart code to native code and AOT snapshots, the decompiled Java stubs often only show glue and lifecycle wiring. That means the real logic is typically within libapp.so (the app-specific AOT binary), and libflutter.so (the engine) simply provides runtime support.
Although variables and method names are obfuscated in e, string constants are intact. That is an important observation: strings left in the binary—including constant text used to derive keys—are often the weakest link. So rather than waste time untangling obfuscated Java control flow, it’s more productive to pivot to the native side.
Scrolling down reveals several client-side checks — isDeviceRooted, isTampered, isEmulator, performSecurityChecks — along with validations in the SecurityModule class. These appear to be challenge-imposed restrictions designed to prevent dynamic instrumentation from exposing the decryption key. While bypassing them would be straightforward (since they run on the client), we won’t pursue that path — there are much more interesting areas to explore.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
...
switch (str.hashCode()) {
case -1722728127:
if (str.equals("isDeviceRooted")) {
SecurityModule securityModule2 = mainActivity2.f167i;
if (securityModule2 == null) {
g.q("securityModule");
throw null;
}
if (!securityModule2.b() && !mainActivity2.f168j) {
z = false;
}
objValueOf = Boolean.valueOf(z);
kVar.c(objValueOf);
return;
}
break;
case -1370057178:
if (str.equals("isTampered")) {
SecurityModule securityModule3 = mainActivity2.f167i;
if (securityModule3 == null) {
g.q("securityModule");
throw null;
}
if (!securityModule3.a() && !mainActivity2.l) {
}
objValueOf = Boolean.valueOf(z);
kVar.c(objValueOf);
return;
}
break;
case -75668084:
if (str.equals("performSecurityChecks")) {
SecurityModule securityModule4 = mainActivity2.f167i;
if (securityModule4 == null) {
g.q("securityModule");
throw null;
}
boolean zB = securityModule4.b();
boolean zC = securityModule4.c();
boolean zA = securityModule4.a();
b[] bVarArr = {new b("isRooted", Boolean.valueOf(zB || mainActivity2.f168j)), new b("isEmulator", Boolean.valueOf(zC || mainActivity2.f169k)), new b("isTampered", Boolean.valueOf(zA || mainActivity2.l)), new b("isSecure", Boolean.valueOf((zB || mainActivity2.f168j || zC || mainActivity2.f169k || zA || mainActivity2.l) ? false : true))};
objValueOf = new LinkedHashMap(androidx.lifecycle.a.p(4));
for (int i10 = 0; i10 < 4; i10++) {
b bVar2 = bVarArr[i10];
objValueOf.put(bVar2.f1234c, bVar2.f1235d);
}
kVar.c(objValueOf);
return;
}
break;
case 542923359:
if (str.equals("isEmulator")) {
SecurityModule securityModule5 = mainActivity2.f167i;
if (securityModule5 == null) {
g.q("securityModule");
throw null;
}
if (!securityModule5.c() && !mainActivity2.f169k) {
z = false;
}
kVar.c(Boolean.valueOf(z));
return;
}
break;
}
}
kVar.b();
return;
SecurityModule class
Figure: SecurityModule class The
SecurityModule class contains both obfuscated methods (a(), b(), c()) and a few readable wrappers that call into native checks such as checkAppTamperingNative(), checkRootNative(), and isEmulatorNative(). In practice:
- The obfuscated methods perform some Java-level checks and then call the native functions exported by
libsecurity-checks.so. - The native checks add an extra layer of deterrence—making dynamic instrumentation and simple hooking less convenient—but do not prevent static extraction of constants.
In short: while the app tries to detect tampering, root, or emulation, these checks are client-side and bypassable. We will keep our approach purely static, but knowing where those checks live helps later when we test recovery on a device or when building a reproducible environment.
Overview of a Flutter-Based Android native libraries
Before diving deeper, here’s a brief explanation of the two native shared objects you’re likely to encounter in a Flutter APK:
libflutter.so
This is the Flutter engine: a large C/C++ runtime provided by Google that contains the Dart VM (or runtime for AOT), rendering and embedder code, and platform channel bindings. You generally do not modify or reverse-engineer libflutter.so unless you are studying the engine itself.
Important responsibilities:
- Hosts the Dart runtime / VM or AOT execution engine.
- Manages rendering, input, and plugin/native bindings.
- Provides the JNI glue to start the Dart entrypoint and load
libapp.so.
libapp.so
This is the app-specific AOT-compiled code containing the Dart application logic. In release-mode Flutter builds, Dart source code is compiled to native instructions and packaged into libapp.so. This library frequently contains:
- AOT machine code for compiled Dart functions.
- The Dart object pool with strings, constants, and class descriptors.
- Bootstrapping code to register the Dart entrypoint (main) and initialize runtime objects.
If you want to recover application-level logic from a Flutter app, libapp.so is the primary target.
Analyse libapp.so using disassembler - IDA
Loading libapp.so into IDA initially shows a stripped binary with very few human-friendly symbols. Searching for readable strings such as encrypt, encryption_service.dart or any package: hints is often the fastest way to find relevant regions. Figure: libapp.so stripped symbols
In this challenge, a string like "package:droid_pass/services/encryption_service.dart" appears in the .rodata and leads to a small set of functions that implement encryption and decryption. The disassembly at first is just raw machine code, but those strings confirm the presence of a Dart class named EncryptionService.
.rodata:000000000002025A DCB 0x70 ; p
.rodata:000000000002025B DCB 0x61 ; a
.rodata:000000000002025C DCB 0x63 ; c
.rodata:000000000002025D DCB 0x6B ; k
.rodata:000000000002025E DCB 0x61 ; a
.rodata:000000000002025F DCB 0x67 ; g
.rodata:0000000000020260 DCB 0x65 ; e
.rodata:0000000000020261 DCB 0x3A ; :
.rodata:0000000000020262 DCB 0x64 ; d
.rodata:0000000000020263 DCB 0x72 ; r
.rodata:0000000000020264 DCB 0x6F ; o
.rodata:0000000000020265 DCB 0x69 ; i
.rodata:0000000000020266 DCB 0x64 ; d
.rodata:0000000000020267 DCB 0x5F ; _
.rodata:0000000000020268 DCB 0x70 ; p
.rodata:0000000000020269 DCB 0x61 ; a
.rodata:000000000002026A DCB 0x73 ; s
.rodata:000000000002026B DCB 0x73 ; s
.rodata:000000000002026C DCB 0x2F ; /
.rodata:000000000002026D DCB 0x73 ; s
.rodata:000000000002026E DCB 0x65 ; e
.rodata:000000000002026F DCB 0x72 ; r
.rodata:0000000000020270 DCB 0x76 ; v
.rodata:0000000000020271 DCB 0x69 ; i
.rodata:0000000000020272 DCB 0x63 ; c
.rodata:0000000000020273 DCB 0x65 ; e
.rodata:0000000000020274 DCB 0x73 ; s
.rodata:0000000000020275 DCB 0x2F ; /
.rodata:0000000000020276 DCB 0x65 ; e
.rodata:0000000000020277 DCB 0x6E ; n
.rodata:0000000000020278 DCB 0x63 ; c
.rodata:0000000000020279 DCB 0x72 ; r
.rodata:000000000002027A DCB 0x79 ; y
.rodata:000000000002027B DCB 0x70 ; p
.rodata:000000000002027C DCB 0x74 ; t
.rodata:000000000002027D DCB 0x69 ; i
.rodata:000000000002027E DCB 0x6F ; o
.rodata:000000000002027F DCB 0x6E ; n
.rodata:0000000000020280 DCB 0x5F ; _
.rodata:0000000000020281 DCB 0x73 ; s
.rodata:0000000000020282 DCB 0x65 ; e
.rodata:0000000000020283 DCB 0x72 ; r
.rodata:0000000000020284 DCB 0x76 ; v
.rodata:0000000000020285 DCB 0x69 ; i
.rodata:0000000000020286 DCB 0x63 ; c
.rodata:0000000000020287 DCB 0x65 ; e
.rodata:0000000000020288 DCB 0x2E ; .
.rodata:0000000000020289 DCB 0x64 ; d
.rodata:000000000002028A DCB 0x61 ; a
.rodata:000000000002028B DCB 0x72 ; r
.rodata:000000000002028C DCB 0x74 ; t
Direct analysis of libapp.so is still hard because method names are not obvious. To bridge that gap, we use a helper tool (B(l)utter) that reconstructs the object pool and symbol mapping.
B(l)utter
B(l)utter is a targeted reverse-engineering helper for AOT Flutter apps. Conceptually:
- Blutter builds (or uses) a compatible Dart runtime to load an app’s
libapp.so. - It parses the Dart object pool and AOT metadata to extract names, object offsets, and code pointers.
- It emits artifacts (disassembly snippets
asm/*, IDAaddNames.pyscript, object pool dumpspp.txt, and a Frida template) that restore much of the Dart-level semantic information lost during AOT compilation.
This tool raises the signal-to-noise ratio significantly: instead of hunting raw opcodes, you get nicely labeled symbols and readable class/method names. Practically, run blutter.py against your libapp.so and inspect the generated artifacts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ git clone https://github.com/worawit/blutter.git
...
$ cd blutter
$ brew install cmake ninja pkg-config icu4c capstone
...
$ pip3 install pyelftools requests
...
$ python3 blutter.py ~/Downloads/8ksec/DroidPass/lib/arm64-v8a ~/Downloads/8ksec/ --rebuild
Dart version: 3.7.2, Snapshot: d91c0e6f35f0eb2e44124e8f42aa44a7, Target: android arm64
flags: product no-code_comments no-dwarf_stack_traces_mode dedup_instructions no-tsan no-msan arm64 android compressed-pointers
-- Configuring done (2.5s)
-- Generating done (0.0s)
-- Build files have been written to: ~/Downloads/blutter/build/dartvm3.7.2_android_arm64
[272/272] Linking CXX static library libdartvm3.7.2_android_arm64.a
-- Install configuration: "Release"
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/lib/libdartvm3.7.2_android_arm64.a
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/include/dartvm3.7.2
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/include/dartvm3.7.2/include
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/include/dartvm3.7.2/include/dart_api.h
...
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/lib/cmake/dartvm3.7.2_android_arm64/dartvmTarget.cmake
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/lib/cmake/dartvm3.7.2_android_arm64/dartvmTarget-release.cmake
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/lib/cmake/dartvm3.7.2_android_arm64/dartvm3.7.2_android_arm64Config.cmake
-- Installing: ~/Downloads/blutter/dartsdk/v3.7.2/../../packages/lib/cmake/dartvm3.7.2_android_arm64/dartvm3.7.2_android_arm64ConfigVersion.cmake
-- Configuring done (1.0s)
-- Generating done (0.4s)
-- Build files have been written to: ~/Downloads/blutter/build/blutter_dartvm3.7.2_android_arm64
[22/22] Linking CXX executable blutter_dartvm3.7.2_android_arm64
-- Install configuration: "Release"
-- Installing: ~/Downloads/blutter/bin/blutter_dartvm3.7.2_android_arm64
libapp is loaded at 0x104140000
Dart heap at 0x300000000
Analyzing the application
Dumping Object Pool
Generating application assemblies
Generating Frida script
$ ls -1 ~/Downloads/8ksec
asm/* # libapp assemblies with symbols
blutter_frida.js # the frida script template for the target application
ida_script/* # includes addNames.py and ida_dart_struct.h that is ready to import to IDA
objs.txt # complete (nested) dump of Object from Object Pool
pp.txt # all Dart objects in Object Pool
Reversing libapp.so native library encryption logic
With Blutter’s addNames.py applied to IDA, many function names and object-pool references annotate the previously opaque code. That’s our starting point to recover encryption logic.
Update libapp.so symbols using IDA script
Use File -> Script file... in IDA to run the addNames.py script created by Blutter. Figure: IDA applied Dart symbols
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import ida_funcs
import idaapi
ida_funcs.add_func(0x230d00, 0x230d30)
idaapi.set_name(0x230d00, "dart_core_::get__uriBaseClosure_230d00")
ida_funcs.add_func(0x2316e4, 0x231730)
idaapi.set_name(0x2316e4, "dart_core_::_listLength_2316e4")
ida_funcs.add_func(0x2315a8, 0x2315f8)
idaapi.set_name(0x2315a8, "dart_core_::_listGetAt_2315a8")
ida_funcs.add_func(0x231ca0, 0x231cfc)
idaapi.set_name(0x231ca0, "dart_core_::_listSetAt_231ca0")
ida_funcs.add_func(0x230d30, 0x230dfc)
idaapi.set_name(0x230d30, "dart_core_::_rehashObjects_230d30")
ida_funcs.add_func(0x231730, 0x231898)
idaapi.set_name(0x231730, "dart_core_::_completeLoads_231730")
ida_funcs.add_func(0x2186b8, 0x218880)
idaapi.set_name(0x2186b8, "dart_core_::_loadLibrary_2186b8")
ida_funcs.add_func(0x2170a8, 0x217108)
idaapi.set_name(0x2170a8, "dart_core_::_checkLoaded_2170a8")
ida_funcs.add_func(0x55c738, 0x55c790)
....
import ida_struct
import os
def create_Dart_structs():
sid1 = idc.get_struc_id("DartThread")
if sid1 != idc.BADADDR:
return sid1, idc.get_struc_id("DartObjectPool")
hdr_file = os.path.join(os.path.dirname(__file__), 'ida_dart_struct.h')
idaapi.idc_parse_types(hdr_file, idc.PT_FILE)
sid1 = idc.import_type(-1, "DartThread")
sid2 = idc.import_type(-1, "DartObjectPool")
struc = ida_struct.get_struc(sid2)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129560), '''String: "oldWidget"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129552), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129544), '''Type: _RenderTheaterMarker''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129536), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129528), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129520), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129512), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129504), '''Obj!FilterQuality@57db41 : {
Super!_Enum : {
off_8: int(0x3),
off_10: "high"
}
}''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129496), '''Double: 1.25''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129488), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129480), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129472), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129464), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129456), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129448), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129440), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129432), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129424), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129416), '''Null''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129408), '''String: " in type cast"''', True)
ida_struct.set_member_cmt(ida_struct.get_member(struc, 129400), '''Null''', True)
...
If you are using IDA 9, there may be compatibility issues because ida_struct APIs changed. I wrote a lightweight shim lets the Blutter-produced script run without major edits:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# ---- ida_struct compatibility shim for IDA 9+ ----
import types
try:
# If running on older IDA that still has ida_struct, use it as-is.
import ida_struct as _ida_struct # noqa: F401
# Expose the real module under the expected name.
import ida_struct # type: ignore
except Exception:
# IDA 9 path: re-create the minimal API surface we need.
import idc
import ida_typeinf
class _StructShim:
__slots__ = ("sid",)
def __init__(self, sid: int) -> None:
self.sid = int(sid)
def _resolve_sid(sid_or_name):
if isinstance(sid_or_name, str):
sid = ida_typeinf.get_tid(sid_or_name)
if sid in (None, ida_typeinf.BADADDR):
raise ValueError(f"Struct type not found: {sid_or_name!r}")
return sid
return int(sid_or_name)
def get_struc(sid_or_name):
"""Return a lightweight struct handle compatible with old code."""
return _StructShim(_resolve_sid(sid_or_name))
def get_member(struc_obj, byte_off: int):
"""Return a 'member handle' that our set_member_cmt understands."""
return (struc_obj.sid, int(byte_off))
def set_member_cmt(member_handle, cmt: str, repeatable: bool = True):
"""Forward to IDA 9: idc.set_member_cmt(sid, off, ...)."""
sid, off = member_handle
return bool(idc.set_member_cmt(sid, off, cmt, repeatable))
# You can add more adapters here if your script uses them later.
ida_struct = types.SimpleNamespace(
get_struc=get_struc,
get_member=get_member,
set_member_cmt=set_member_cmt,
)
# Make 'import ida_struct' work for downstream code.
import sys
sys.modules["ida_struct"] = ida_struct
# ---- end shim ----
That converts a huge chunk of previously unknown functions into a working map.
Identify the relevant code region
Next, inspect the symbol table and cross-references, and hunt for Dart/Flutter runtime stubs and class names — Flutter/Dart binaries expose many recognizable helper stubs in the disassembly. In this challenge the focus is encrypting the database password, so once symbols are applied search for keywords such as encrypt.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Function name Start Length
--------------------------------------------------------------------------------------------------------
AllocateEncryptedStub_27b950 000000000027B950 00000058
AllocateEncrypterStub_285d90 0000000000285D90 00000058
AllocateEncryptionServiceStub_2ad600 00000000002AD600 00000058
dart_io__SecureFilterImpl__ENCRYPTED_SIZE_22a940 000000000022A940 00000008
dart_io__SecureFilterImpl__get_ENCRYPTED_SIZE_22abbc 000000000022ABBC 00000030
droid_pass$services$encryption_service_EncryptionService__ctor_2856dc 00000000002856DC 000006B4
droid_pass$services$encryption_service_EncryptionService__decrypt_27b5a4 000000000027B5A4 0000011C
droid_pass$services$encryption_service_EncryptionService__encrypt_393758 0000000000393758 00000078
encrypt$encrypt_AESMode___enumToString_435ee0 0000000000435EE0 00000064
encrypt$encrypt_AES___buildParams_27b83c 000000000027B83C 0000002C
encrypt$encrypt_AES___paddedParams_27b868 000000000027B868 0000008C
encrypt$encrypt_AES__ctor_285d9c 0000000000285D9C 0000015C
encrypt$encrypt_AES__decrypt_27b760 000000000027B760 000000DC
encrypt$encrypt_AES__encrypt_393914 0000000000393914 000000E4
encrypt$encrypt_Encrypted__get_base64_3937d0 00000000003937D0 00000038
encrypt$encrypt_Encrypted__get_hashCode_3fd390 00000000003FD390 00000068
encrypt$encrypt_Encrypted__op_eq_4aef20 00000000004AEF20 00000090
encrypt$encrypt_Encrypter__decryptBytes_27b6fc 000000000027B6FC 00000064
encrypt$encrypt_Encrypter__decrypt_27b6c0 000000000027B6C0 0000003C
encrypt$encrypt_Encrypter__encryptBytes_3938d4 00000000003938D4 00000040
encrypt$encrypt_Encrypter__encrypt_393880 0000000000393880 00000054
pointycastle$block$aes_AESEngine___encryptBlock_52bbfc 000000000052BBFC 00001C6C
pointycastle$block$modes$cbc_CBCBlockCipher___encryptBlock_52dd30 000000000052DD30 0000048C
pointycastle$block$modes$cfb_CFBBlockCipher___encryptBlock_52e7b0 000000000052E7B0 00000624
pointycastle$block$modes$ige_IGEBlockCipher___encryptBlock_52fb58 000000000052FB58 00000AE4
These functions relate to encryption. From the symbol names, we can tell it uses AES with padding (e.g.,
encrypt$encrypt_AES___paddedParams_27b868), and the mode appears to be one of CBC, CFB, or IGE. To keep the scope on first-party logic, we’ll analyzedroid_pass$services$encryption_service_EncryptionServiceinstead of third-party code. Recall from the earlier Strings search that.rodata:000000000002025A 00000033 C package:droid_pass/services/encryption_service.dartpoints to this module. The classEncryptionServiceexposes just two methods—encryption_service_EncryptionService__decrypt_27b5a4andencryption_service_EncryptionService__encrypt_393758—plus the constructorencryption_service_EncryptionService__ctor_2856dc, which nicely narrows our target surface.Judging by size,
encrypt(0x78bytes) anddecrypt(0x11Cbytes) are quite small compared to the constructor (0x6B4). Let’s start with theEncryptionServiceconstructor to see how it initializes the encryption key and parameters that the encrypt/decrypt methods will consume.
EncryptionService constructor
Switching to pseudocode (Ida decompiler view) reveals the constructor’s overall algorithm. In plain terms:
- Allocate a list and initialize it with 27 immediate byte-like constants.
- The constructor then splits these 27 elements into three sublists.
- Each sublist is converted to a Dart
StringviaString.fromCharCodessemantics. - The three strings are joined to form a base string
S. Sis UTF-8 encoded and passed to a hash function (SHA-256) to produce a 32-byte digest used as the AES key.- The constructor concatenates
Swith a salt string (loaded from the object pool, e.g.,"IV_SALT"), hashes again with SHA-256, and derives as the IV. - The AES instance is initialized.
Reading the pseudocode, the flow is evident: everything required to reconstruct the key is present in the binary and deterministic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
__int64 __fastcall droid_pass_services_encryption_service_EncryptionService::ctor_2856dc(__int64 a1, __int64 a2)
{
__int64 v2; // x15
__int64 v3; // x21
__int64 v4; // x22
DartThread *v5; // x26
DartObjectPool *v6; // x27
unsigned __int64 v7; // x28
__int64 v8; // x29
__int64 v9; // x30
__int64 v10; // x29
__int64 Obj_0x1f9e8; // x2
__int64 v12; // x0
__int64 v13; // x3
__int64 ArrayStub_540088; // x0
__int64 GrowableArrayStub_53efbc; // x3
__int64 v16; // x0
_QWORD *v17; // x15
__int64 v18; // x0
_QWORD *v19; // x15
__int64 v20; // x0
__int64 v21; // x0
__int64 ReversedListIterableStub_2ad5f4; // x1
__int64 v23; // x0
__int64 v24; // x0
__int64 v25; // x1
__int64 v26; // x0
__int64 v27; // x0
__int64 v28; // x3
__int64 v29; // x0
__int64 v30; // x0
__int64 v31; // x0
_QWORD *v32; // x15
__int64 v33; // x1
__int64 v34; // x1
__int64 *v35; // x15
__int64 v36; // x0
__int64 v37; // x3
__int64 v38; // x0
__int64 v39; // x0
__int64 v40; // x0
__int64 KeyStub_2ad364; // x1
__int64 *v42; // x15
__int64 v43; // x0
__int64 v44; // x1
__int64 v45; // x0
__int64 v46; // x0
__int64 v47; // x0
__int64 v48; // x0
__int64 v49; // x0
_QWORD *v50; // x15
__int64 v51; // x0
__int64 Uint8ArrayStub_53fd6c; // x4
__int64 v53; // x15
__int64 v54; // x0
__int64 v55; // x5
__int64 v56; // x1
__int64 v57; // x20
__int64 v58; // x3
char *v59; // x2
char *v60; // x0
char *v61; // x16
char *v62; // x2
char *v63; // x0
int v64; // t1
__int16 v65; // t1
char v66; // t1
int j; // w3
__int64 v68; // x16
__int64 v69; // x17
__int64 v70; // t1
int v71; // t1
__int16 v72; // t1
char v73; // t1
int i; // w3
__int64 v75; // x16
__int64 v76; // x17
__int64 v77; // x0
void (__fastcall *MemoryMove)(__int64, __int64); // x9
__int64 v79; // x1
__int64 *v80; // x29
__int64 IVStub_2ad358; // x1
__int64 *v82; // x15
__int64 v83; // x0
__int64 v84; // x1
__int64 v85; // x0
__int64 v86; // x0
__int64 EncrypterStub_285d90; // x1
__int64 *v88; // x15
__int64 v89; // x0
__int64 v90; // x1
__int64 v91; // x0
*(_QWORD *)(v2 - 16) = v8;
*(_QWORD *)(v2 - 8) = v9;
v10 = v2 - 16;
Obj_0x1f9e8 = v6->Obj_0x1f9e8;
v12 = 54LL;
v13 = a2;
*(_QWORD *)(v2 - 24) = a2;
if ( (unsigned __int64)(v2 - 80) <= v5->stack_limit )
v12 = StackOverflowSharedWithoutFPURegsStub_540190(54LL, a2, Obj_0x1f9e8, a2);
*(_DWORD *)(v13 + 7) = Obj_0x1f9e8;
*(_DWORD *)(v13 + 11) = Obj_0x1f9e8;
*(_DWORD *)(v13 + 15) = Obj_0x1f9e8;
*(_DWORD *)(v13 + 19) = Obj_0x1f9e8;
ArrayStub_540088 = AllocateArrayStub_540088(v12, v6->Obj_0x1f138, v12);
*(_QWORD *)(v10 - 16) = ArrayStub_540088;
*(_DWORD *)(ArrayStub_540088 + 15) = 130;
*(_DWORD *)(ArrayStub_540088 + 19) = 228;
*(_DWORD *)(ArrayStub_540088 + 23) = 186;
*(_DWORD *)(ArrayStub_540088 + 27) = 206;
*(_DWORD *)(ArrayStub_540088 + 31) = 200;
*(_DWORD *)(ArrayStub_540088 + 35) = 190;
*(_DWORD *)(ArrayStub_540088 + 39) = 194;
*(_DWORD *)(ArrayStub_540088 + 43) = 130;
*(_DWORD *)(ArrayStub_540088 + 47) = 130;
*(_DWORD *)(ArrayStub_540088 + 51) = 162;
*(_DWORD *)(ArrayStub_540088 + 55) = 170;
*(_DWORD *)(ArrayStub_540088 + 59) = 224;
*(_DWORD *)(ArrayStub_540088 + 63) = 202;
*(_DWORD *)(ArrayStub_540088 + 67) = 228;
*(_DWORD *)(ArrayStub_540088 + 71) = 162;
*(_DWORD *)(ArrayStub_540088 + 75) = 202;
*(_DWORD *)(ArrayStub_540088 + 79) = 198;
*(_DWORD *)(ArrayStub_540088 + 83) = 228;
*(_DWORD *)(ArrayStub_540088 + 87) = 202;
*(_DWORD *)(ArrayStub_540088 + 91) = 232;
*(_DWORD *)(ArrayStub_540088 + 95) = 150;
*(_DWORD *)(ArrayStub_540088 + 99) = 202;
*(_DWORD *)(ArrayStub_540088 + 103) = 242;
*(_DWORD *)(ArrayStub_540088 + 107) = 106;
*(_DWORD *)(ArrayStub_540088 + 111) = 100;
*(_DWORD *)(ArrayStub_540088 + 115) = 102;
*(_DWORD *)(ArrayStub_540088 + 119) = 66;
GrowableArrayStub_53efbc = AllocateGrowableArrayStub_53efbc();
v16 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 24) = GrowableArrayStub_53efbc;
*(_DWORD *)(GrowableArrayStub_53efbc + 15) = v16;
*(_DWORD *)(GrowableArrayStub_53efbc + 11) = 54;
*v17 = 18LL;
v18 = dart_core__GrowableList::sublist_2d6fac();
*(_QWORD *)(v10 - 16) = v18;
*(_DWORD *)(AllocateReversedListIterableStub_2ad5f4(v18, *(unsigned int *)(v18 + 7) + (v7 << 32)) + 11) = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 16) = ((__int64 (*)(void))dart_core__StringBase::createFromCharCodes_22080c)();
*v19 = 36LL;
v20 = dart_core__GrowableList::sublist_2d6fac();
*(_QWORD *)(v10 - 24) = dart_core__StringBase::createFromCharCodes_22080c(v20, v20, 0LL, v4);
v21 = dart_core__GrowableList::sublist_2d6fac();
*(_QWORD *)(v10 - 32) = v21;
ReversedListIterableStub_2ad5f4 = AllocateReversedListIterableStub_2ad5f4(
v21,
*(unsigned int *)(v21 + 7) + (v7 << 32));
v23 = *(_QWORD *)(v10 - 32);
*(_DWORD *)(ReversedListIterableStub_2ad5f4 + 11) = v23;
v24 = dart_core__StringBase::createFromCharCodes_22080c(v23, ReversedListIterableStub_2ad5f4, 0LL, v4);
v25 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 32) = v24;
v26 = (*(__int64 (**)(void))(v3 + 8 * (((unsigned int)*(_QWORD *)(v25 - 1) >> 12) - 4096LL)))();
*(_QWORD *)(v10 - 16) = v26;
*(_DWORD *)(AllocateReversedListIterableStub_2ad5f4(v26, *(unsigned int *)(v26 + 7) + (v7 << 32)) + 11) = *(_QWORD *)(v10 - 16);
v27 = dart__internal_ListIterable::join_35781c();
*(_QWORD *)(v10 - 16) = v27;
v28 = AllocateArrayStub_540088(v27, v4, 6LL);
v29 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 40) = v28;
*(_DWORD *)(v28 + 15) = v29;
*(_DWORD *)(v28 + 19) = *(_QWORD *)(v10 - 24);
v30 = (*(__int64 (**)(void))(v3 + 8 * (((unsigned int)*(_QWORD *)(*(_QWORD *)(v10 - 32) - 1LL) >> 12) - 4096LL)))();
*(_QWORD *)(v10 - 16) = v30;
*(_DWORD *)(AllocateReversedListIterableStub_2ad5f4(v30, *(unsigned int *)(v30 + 7) + (v7 << 32)) + 11) = *(_QWORD *)(v10 - 16);
v31 = dart__internal_ListIterable::join_35781c();
v33 = *(_QWORD *)(v10 - 40);
*(_DWORD *)(v33 + 23) = v31;
if ( (v31 & 1) != 0
&& (*(unsigned __int8 *)(v31 - 1) & ((unsigned __int64)*(unsigned __int8 *)(v33 - 1) >> 2) & HIDWORD(v7)) != 0 )
{
v31 = ArrayWriteBarrierStub_53e370();
}
*v32 = *(_QWORD *)(v10 - 40);
v34 = dart_core__StringBase::_interpolate_21db7c(v31);
v36 = *(_QWORD *)(v10 - 8);
*(_QWORD *)(v10 - 16) = v34;
if ( *(_DWORD *)(v36 + 7) != (unsigned int)v6->Obj_0x1f9e8 )
{
*v35 = v6->Obj_0x11120;
dart__internal_LateError::_throwFieldAlreadyInitialized_239ca0();
}
v37 = v36;
v38 = *(_QWORD *)(v10 - 16);
*(_DWORD *)(v37 + 7) = v38;
if ( (*(unsigned __int8 *)(v38 - 1) & ((unsigned __int64)*(unsigned __int8 *)(v37 - 1) >> 2) & HIDWORD(v7)) != 0 )
v38 = WriteBarrierWrappersStub_53e7f4();
v39 = dart_convert_Utf8Encoder::convert_47a628(v38, v6->Obj_0x1eb58, *(_QWORD *)(v10 - 16));
v40 = crypto_src_hash_Hash::convert_47bf60(v39, v6->Obj_0x13140, v39);
*(_QWORD *)(v10 - 16) = dart_typed_data_Uint8List::factory_ctor_fromList_2ad370(
v40,
v4,
*(unsigned int *)(v40 + 7) + (v7 << 32));
KeyStub_2ad364 = AllocateKeyStub_2ad364();
v43 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 24) = KeyStub_2ad364;
*(_DWORD *)(KeyStub_2ad364 + 7) = v43;
if ( *(_DWORD *)(*(_QWORD *)(v10 - 8) + 11LL) != (unsigned int)v6->Obj_0x1f9e8 )
{
*v42 = v6->Obj_0x11118;
dart__internal_LateError::_throwFieldAlreadyInitialized_239ca0();
}
v44 = *(_QWORD *)(v10 - 8);
v45 = *(_QWORD *)(v10 - 24);
*(_DWORD *)(v44 + 11) = v45;
if ( (*(unsigned __int8 *)(v45 - 1) & ((unsigned __int64)*(unsigned __int8 *)(v44 - 1) >> 2) & HIDWORD(v7)) != 0 )
WriteBarrierWrappersStub_53e7b4();
v46 = *(unsigned int *)(v44 + 7) + (v7 << 32);
*v42 = v6->Obj_0x11110;
v42[1] = v46;
v47 = dart_core__StringBase::op_add_21de50();
v48 = dart_convert_Utf8Encoder::convert_47a628(v47, v6->Obj_0x1eb58, v47);
v49 = (unsigned int)*(_QWORD *)(*(unsigned int *)(crypto_src_hash_Hash::convert_47bf60(v48, v6->Obj_0x13140, v48) + 7)
+ (v7 << 32)
- 1) >> 12;
*v50 = 32LL;
v51 = (*(__int64 (**)(void))(v3 + 8 * (v49 + 34360)))();
*(_QWORD *)(v10 - 24) = v51;
*(_QWORD *)(v10 - 16) = *(unsigned int *)(v51 + 19);
Uint8ArrayStub_53fd6c = AllocateUint8ArrayStub_53fd6c();
v54 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 32) = Uint8ArrayStub_53fd6c;
v55 = (__int64)(int)v54 >> 1;
*(_QWORD *)(v10 - 48) = v55;
if ( v55 < 0 )
dart_core_RangeError::checkValidRange_21f4f8(v54, 0LL, v54, (__int64)(int)v54 >> 1, v6->Obj_0x1f758);
if ( *(_QWORD *)(v10 - 48) )
{
if ( (int)*(_QWORD *)(v10 - 16) >= 2048 )
{
v77 = *(_QWORD *)(*(_QWORD *)(v10 - 32) + 7LL);
MemoryMove = (void (__fastcall *)(__int64, __int64))v5->MemoryMove;
v79 = *(_QWORD *)(*(_QWORD *)(v10 - 24) + 7LL);
*(_QWORD *)(v53 - 8) = v10;
v80 = (__int64 *)(v53 - 8);
v5->vm_tag = (__int64)MemoryMove;
MemoryMove(v77, v79);
v5->vm_tag = 8LL;
v10 = *v80;
}
else
{
v56 = *(_QWORD *)(v10 - 24);
v57 = *(_QWORD *)(v10 - 32);
v58 = *(_QWORD *)(v10 - 16);
v59 = (char *)(v56 + 23);
v60 = (char *)(v57 + 23);
if ( v58 )
{
if ( v60 <= v59 || (v61 = &v59[(__int64)(int)v58 >> 1], v60 >= v61) )
{
if ( (v58 & 0x10) != 0 )
{
v70 = *(_QWORD *)v59;
v59 = (char *)(v56 + 31);
*(_QWORD *)v60 = v70;
v60 = (char *)(v57 + 31);
}
if ( (v58 & 8) != 0 )
{
v71 = *(_DWORD *)v59;
v59 += 4;
*(_DWORD *)v60 = v71;
v60 += 4;
}
if ( (v58 & 4) != 0 )
{
v72 = *(_WORD *)v59;
v59 += 2;
*(_WORD *)v60 = v72;
v60 += 2;
}
if ( (v58 & 2) != 0 )
{
v73 = *v59++;
*v60++ = v73;
}
for ( i = v58 & 0xFFFFFFE1; i; i -= 32 )
{
v75 = *(_QWORD *)v59;
v76 = *((_QWORD *)v59 + 1);
v59 += 16;
*(_QWORD *)v60 = v75;
*((_QWORD *)v60 + 1) = v76;
v60 += 16;
}
}
else
{
v62 = &v59[(__int64)(int)v58 >> 1];
v63 = &v60[(__int64)(int)v58 >> 1];
if ( (v58 & 0x10) != 0 )
{
v62 = v61 - 8;
*((_QWORD *)v63 - 1) = *((_QWORD *)v61 - 1);
v63 -= 8;
}
if ( (v58 & 8) != 0 )
{
v64 = *((_DWORD *)v62 - 1);
v62 -= 4;
*((_DWORD *)v63 - 1) = v64;
v63 -= 4;
}
if ( (v58 & 4) != 0 )
{
v65 = *((_WORD *)v62 - 1);
v62 -= 2;
*((_WORD *)v63 - 1) = v65;
v63 -= 2;
}
if ( (v58 & 2) != 0 )
{
v66 = *--v62;
*--v63 = v66;
}
for ( j = v58 & 0xFFFFFFE1; j; j -= 32 )
{
v68 = *((_QWORD *)v62 - 2);
v69 = *((_QWORD *)v62 - 1);
v62 -= 16;
*((_QWORD *)v63 - 2) = v68;
*((_QWORD *)v63 - 1) = v69;
v63 -= 16;
}
}
}
}
}
IVStub_2ad358 = AllocateIVStub_2ad358(*(_QWORD *)(v10 - 8));
v83 = *(_QWORD *)(v10 - 32);
*(_QWORD *)(v10 - 16) = IVStub_2ad358;
*(_DWORD *)(IVStub_2ad358 + 7) = v83;
if ( *(_DWORD *)(*(_QWORD *)(v10 - 8) + 19LL) != (unsigned int)v6->Obj_0x1f9e8 )
{
*v82 = v6->Obj_0x11108;
dart__internal_LateError::_throwFieldAlreadyInitialized_239ca0();
}
v84 = *(_QWORD *)(v10 - 8);
v85 = *(_QWORD *)(v10 - 16);
*(_DWORD *)(v84 + 19) = v85;
if ( (*(unsigned __int8 *)(v85 - 1) & ((unsigned __int64)*(unsigned __int8 *)(v84 - 1) >> 2) & HIDWORD(v7)) != 0 )
v85 = WriteBarrierWrappersStub_53e7b4();
*(_QWORD *)(v10 - 16) = *(unsigned int *)(v84 + 11) + (v7 << 32);
*(_QWORD *)(v10 - 16) = AllocateAESStub_2ad34c(v85);
v86 = encrypt_encrypt_AES::ctor_285d9c();
EncrypterStub_285d90 = AllocateEncrypterStub_285d90(v86);
v89 = *(_QWORD *)(v10 - 16);
*(_QWORD *)(v10 - 24) = EncrypterStub_285d90;
*(_DWORD *)(EncrypterStub_285d90 + 7) = v89;
if ( *(_DWORD *)(*(_QWORD *)(v10 - 8) + 15LL) != (unsigned int)v6->Obj_0x1f9e8 )
{
*v88 = v6->Obj_0x11100;
dart__internal_LateError::_throwFieldAlreadyInitialized_239ca0();
}
v90 = *(_QWORD *)(v10 - 8);
v91 = *(_QWORD *)(v10 - 24);
*(_DWORD *)(v90 + 15) = v91;
if ( (*(unsigned __int8 *)(v91 - 1) & ((unsigned __int64)*(unsigned __int8 *)(v90 - 1) >> 2) & HIDWORD(v7)) != 0 )
WriteBarrierWrappersStub_53e7b4();
return v4;
}
Sequence of immediate byte constants
A close look at the constructor’s store sequence shows these immediate constants being written to the growable list:
.text:0000000000285714 MOV X2, X0
.text:0000000000285718 LDR X1, [X27,#DartObjectPool.Obj_0x1f138]
.text:000000000028571C BL AllocateArrayStub_540088
.text:0000000000285720 STUR X0, [X29,#-0x10]
.text:0000000000285724 MOV X16, #0x82
.text:0000000000285728 STUR W16, [X0,#0xF]
.text:000000000028572C MOV X16, #0xE4
.text:0000000000285730 STUR W16, [X0,#0x13]
.text:0000000000285734 MOV X16, #0xBA
.text:0000000000285738 STUR W16, [X0,#0x17]
.text:000000000028573C MOV X16, #0xCE
.text:0000000000285740 STUR W16, [X0,#0x1B]
.text:0000000000285744 MOV X16, #0xC8
.text:0000000000285748 STUR W16, [X0,#0x1F]
.text:000000000028574C MOV X16, #0xBE
.text:0000000000285750 STUR W16, [X0,#0x23]
.text:0000000000285754 MOV X16, #0xC2
.text:0000000000285758 STUR W16, [X0,#0x27]
.text:000000000028575C MOV X16, #0x82
.text:0000000000285760 STUR W16, [X0,#0x2B]
.text:0000000000285764 MOV X16, #0x82
.text:0000000000285768 STUR W16, [X0,#0x2F]
.text:000000000028576C MOV X16, #0xA2
.text:0000000000285770 STUR W16, [X0,#0x33]
.text:0000000000285774 MOV X16, #0xAA
.text:0000000000285778 STUR W16, [X0,#0x37]
.text:000000000028577C MOV X16, #0xE0
.text:0000000000285780 STUR W16, [X0,#0x3B]
.text:0000000000285784 MOV X16, #0xCA
.text:0000000000285788 STUR W16, [X0,#0x3F]
.text:000000000028578C MOV X16, #0xE4
.text:0000000000285790 STUR W16, [X0,#0x43]
.text:0000000000285794 MOV X16, #0xA2
.text:0000000000285798 STUR W16, [X0,#0x47]
.text:000000000028579C MOV X16, #0xCA
.text:00000000002857A0 STUR W16, [X0,#0x4B]
.text:00000000002857A4 MOV X16, #0xC6
.text:00000000002857A8 STUR W16, [X0,#0x4F]
.text:00000000002857AC MOV X16, #0xE4
.text:00000000002857B0 STUR W16, [X0,#0x53]
.text:00000000002857B4 MOV X16, #0xCA
.text:00000000002857B8 STUR W16, [X0,#0x57]
.text:00000000002857BC MOV X16, #0xE8
.text:00000000002857C0 STUR W16, [X0,#0x5B]
.text:00000000002857C4 MOV X16, #0x96
.text:00000000002857C8 STUR W16, [X0,#0x5F]
.text:00000000002857CC MOV X16, #0xCA
.text:00000000002857D0 STUR W16, [X0,#0x63]
.text:00000000002857D4 MOV X16, #0xF2
.text:00000000002857D8 STUR W16, [X0,#0x67]
.text:00000000002857DC MOV X16, #0x6A ; 'j'
.text:00000000002857E0 STUR W16, [X0,#0x6B]
.text:00000000002857E4 MOV X16, #0x64 ; 'd'
.text:00000000002857E8 STUR W16, [X0,#0x6F]
.text:00000000002857EC MOV X16, #0x66 ; 'f'
.text:00000000002857F0 STUR W16, [X0,#0x73]
.text:00000000002857F4 MOV X16, #0x42 ; 'B'
.text:00000000002857F8 STUR W16, [X0,#0x77]
.text:00000000002857FC LDR X1, [X27,#DartObjectPool.Obj_0x1f138]
.text:0000000000285800 BL AllocateGrowableArrayStub_53efbc
.text:0000000000285804 MOV X3, X0
.text:0000000000285808 LDUR X0, [X29,#-0x10] ; constant bytes array
.text:000000000028580C STUR X3, [X29,#-0x18] ; GrowableArray
.text:0000000000285810 STUR W0, [X3,#0xF] ; GrowableArray = constant bytes array
.text:0000000000285814 MOV X0, #0x36 ; '6'
.text:0000000000285818 STUR W0, [X3,#0xB] ; set GrowableArray length
As we can see, there are 27 bytes being stored in the array:
1
2
3
4
5
[
0x82, 0xE4, 0xBA, 0xCE, 0xC8, 0xBE, 0xC2, 0x82, 0x82,
0xA2, 0xAA, 0xE0, 0xCA, 0xE4, 0xA2, 0xCA, 0xC6, 0xE4,
0xCA, 0xE8, 0x96, 0xCA, 0xF2, 0x6A, 0x64, 0x66, 0x42
]
However, two peculiar things stand out. First, even though the earlier analysis confirms that there are only 27 constant bytes, they’re stored inside a GrowableArray with a declared length of 0x36 (54). There are no visible append operations that would justify expanding the array, so why allocate twice the required size? Second, these 27 bytes are later split into three chunks using dart_core__GrowableList::sublist_2d6fac() and passed into dart_core__StringBase::createFromCharCodes_22080c to construct three strings. This implies the byte values should fall within the printable ASCII range (0x20–0x7E), yet most exceed that range, which is clearly inconsistent.
Initially, it seemed that the bytes might undergo some decoding or deobfuscation before being passed to dart_core__StringBase::createFromCharCodes_22080c, but there’s no such operation present. The actual explanation lies in Dart’s tagged object system, specifically its Small Integer (Smi) representation. What’s being stored in the array are tagged integer values (Smis). Later, when the Dart runtime interprets them as normal integers, it untags or unboxes them—typically by shifting right—to obtain the actual character codes that are then used by String.fromCharCodes.
Tagged Object - Small-integer (Smi) 101
Dart small-integer (Smi) tagging — the core idea
Dart VM encodes small integers (Smis) as tagged values to distinguish them quickly from heap objects. On many 64-bit Dart builds, a Smi is encoded roughly as:
1
Smi_raw = (value << 1) | tag_bits
What this means for static analyzers: the immediate values you see in AOT code are often value << 1. When the Dart runtime reads the value (e.g., for String.fromCharCodes), it performs the corresponding unbox/untag operation (a right shift >> 1) to recover the original integer codepoint.
1
value = Smi_raw >> 1 // remove tag / shift back to real integer
How that maps to the assembly we have
In the constructor, the code issues immediates such as 0x82, 0xE4, 0xBA, … and stores them into the list backing memory. Those are Smi-encoded representations. When String.fromCharCodes later consumes the list, the runtime unboxes each Smi (>> 1) to obtain the real character codepoints. That’s why a quick scan may think the constants are non-printable: you must interpret them as Smi-encoded values.
MOV X16, #0x82
STUR W16, [X0,...]
MOV X16, #0xE4
STUR W16, [X0,...]
MOV X16, #0xBA
STUR W16, [X0,...]
MOV X16, #0xCE
STUR W16, [X0,...]
MOV X16, #0xC8
STUR W16, [X0,...]
...
The code loads registers with small immediate values such as 0x82, 0xE4, and 0xBA. These numbers are already the Smi-encoded representations of their intended integers — effectively the result of (real_char_code << 1) with potential tag bits handled by the runtime convention. The code then writes these machine words directly into the array that backs the Dart list (or a temporary list used by fromCharCodes).
Later, when the Dart runtime reads these list elements to construct a String, it interprets each entry as a Dart integer (either a Smi or a HeapInteger). At this point, the runtime performs an unboxing or untagging operation — typically a right shift by one — to extract the actual numeric codepoint value. This untagged integer is then passed into String.fromCharCodes, which uses it to generate the corresponding characters. In essence, the >> 1 step happens internally during the untagging phase inside the Dart VM, when raw stored words are converted into usable integer values.
Practical numeric example
Let’s take the first stored immediate as an example.
- The disassembly shows that
0x82is written into the array element. - This value represents the raw Smi encoding. To get the real character code, we unbox it:
1
2
3
stored_raw = 0x82 // decimal 130
real_codepoint = stored_raw >> 1 // 65
character = chr(65) // 'A'
Now it makes perfect sense — String.fromCharCodes expects an array of printable character codes, and the right-shift occurs automatically when the Dart VM unboxes each Smi into a plain integer while reading the list to build the string.
You can confirm this behavior for all stored values using a simple Python script:
1
2
3
4
5
6
7
8
9
10
11
12
13
$ python
Type "help", "copyright", "credits" or "license" for more information.
>>> bytes = [
... 0x82, 0xE4, 0xBA, 0xCE, 0xC8, 0xBE, 0xC2, 0x82, 0x82,
... 0xA2, 0xAA, 0xE0, 0xCA, 0xE4, 0xA2, 0xCA, 0xC6, 0xE4,
... 0xCA, 0xE8, 0x96, 0xCA, 0xF2, 0x6A, 0x64, 0x66, 0x42
... ]
>>> chars = [chr(b >> 1) for b in bytes]
>>> chars
['A', 'r', ']', 'g', 'd', '_', 'a', 'A', 'A', 'Q', 'U', 'p', 'e', 'r', 'Q', 'e', 'c', 'r', 'e', 't', 'K', 'e', 'y', '5', '2', '3', '!']
>>> ''.join(chars)
'Ar]gd_aAAQUperQecretKey523!'
>>>
So the apparent gibberish becomes a readable base string after untagging. It’s much clearer now — this explains why the challenge enforces static analysis only. 😄 The Smi encoding adds an extra layer of confusion when viewing raw immediates, making it easy to misinterpret the data unless you understand Dart’s tagged integer representation.
What String.fromCharCodes Sees
At the Dart source level, String.fromCharCodes expects a list of integer codepoints. However, at the VM or native level, those list elements are stored as Dart objects—specifically Smis (Small Integers). When the runtime reads each element to build a string, it automatically performs the unboxing or tag removal step to retrieve the actual integer. That integer is then interpreted as the Unicode codepoint.
Therefore, String.fromCharCodes doesn’t explicitly perform a >>1 on memory bytes—the Dart VM’s internal integer representation enforces the unboxing, which effectively restores the original value.
Where the >>1 Happens
- Write time (constructor): The constructor writes
W16with(codepoint << 1)into the list’s memory, encoding it as a Smi. - Read time (string builder / VM): When the VM builds the string, it unboxes the Smi:
actual_codepoint = raw_value >> 1, then uses that integer infromCharCodes.
This process can be modeled simply as:
1
2
3
4
5
6
# When building the array (values are Smi-encoded)
array[i] = (codepoint << 1)
# When building the String later
codepoint = array[i] >> 1 # untag/unbox inside VM
append_char(codepoint)
Why the Challenge Uses This Approach
From a static analysis perspective, storing (codepoint << 1) values in the binary means the bytes inside libapp.so are not human-readable ASCII, and simple string searches won’t reveal secrets. This is not deliberate obfuscation—it’s just how the Dart VM encodes small integers (Smis). The challenge author likely wrote plain Dart code, and the AOT compiler emitted these Smi-encoded immediates automatically.
Determining Whether an Immediate Is a Smi
Not all immediates in disassembly are Smis. Context is key:
- If the immediate flows into a Dart “integer slot” (like a list element, integer field, or function parameter expecting an
int), then it’s a Smi — typically encoded asvalue << 1(with the low bit used as a tag). - If the immediate is used as a pointer, address, mask, or offset, it’s not Smi-encoded.
You can often infer this from stub names:
AllocateArrayStub→ expects a length argument (Smi-encoded)WriteBarrier,ArrayWriteBarrier, orobject poollookups → expect pointers, not Smis.
Example: The 0x36 Constant
When you see 0x36 (decimal 54) passed to AllocateArrayStub, it’s actually a Smi-encoded length. Unboxing (54 >> 1 = 27) gives the real logical array length of 27 elements—matching the observed data perfectly.
Security Implications
This mechanism doesn’t provide any real cryptographic security. Tagged Smi values are deterministic and trivial to reverse using value = raw >> 1. Any “protection” that relies solely on Smi tagging or integer shifting, without true cryptography or key management, falls into the category of security through obscurity—likely the core lesson of this challenge.
AES Key Derivation
Split 27 bytes array to 3 chunks
The constructor splits the 27-element list into three chunks of 9 codepoints each via repeated sublist() calls, and converts each into a string using String.fromCharCodes. The three substrings are concatenated into the base string:
Call #1
MOV X16, #0x12 ; 0x12 = 18 (this is a Smi for end)
STR X16, [X15] ; end (tagged) pushed
MOV X1, X3 ; list
MOV X2, #0 ; start (raw, untagged)
LDR X4, [pool Obj_0x1f7b8] ; helper/sentinel, not the index
BL dart_core__GrowableList__sublist_2d6fac
- start = 0 (raw)
- end = 0x12 Smi → 18 » 1 = 9
- Range:
[0, 9)
Call #2
MOV X16, #0x24 ; 0x24 = 36 (Smi end)
STR X16, [X15]
LDUR X1, [X29,-0x18] ; list
MOV X2, #9 ; start (raw)
LDR X4, [pool Obj_0x1f7b8]
BL sublist
- start = 9 (raw)
- end = 0x24 Smi → 36 » 1 = 18
- Range:
[9, 18)
Call #3
LDUR X1, [X29,-0x18] ; list
MOV X2, #0x12 ; start = 0x12 (raw) = 18
LDR X4, [pool Obj_0x1f7c0] ; end comes from pool (Smi)
BL sublist
- start = 18 (raw)
- end = 0x36 Smi → 54 » 1 = 27
- Range:
[18, 27)
What matters
In Dart AOT, small integers (Smis) are typically passed as tagged values whenever they’re handled as real Dart objects (for example, optional parameters or values pushed onto the stack). However, many VM stubs accept certain arguments as raw, untagged machine integers in registers for performance (e.g., a plain start index in X2), while other arguments arrive as tagged Smis (via the stack or the object pool). Practically, this means any start you observe in X2 is an ordinary integer, whereas values stored with STR X16, [X15] or fetched from the object pool are Smis and should be interpreted by extracting the payload (i.e., shifting right by one).
String chunks
The three contiguous ranges [0,9), [9,18), and [18,27) align with the three createFromCharCodes calls, producing: "AAa_dg]rA", "QUperQecr", and "etKey523!".
Construct the encryption key
; prior lines computed base string S = "Ar]gd_aAA" + "QUperQecr" + "etKey523!"
.text:00000000002859A4 BL dart_core__StringBase___interpolate_21db7c
.text:00000000002859A8 MOV X1, X0 ; concated string S
.text:00000000002859AC LDUR X0, [X29,#-8]
.text:00000000002859B0 STUR X1, [X29,#-0x10] ; store concated string S
...
.text:0000000000285A04 LDUR X2, [X29,#-0x10] ; X2 = S (the interpolated/concatenated String)
.text:0000000000285A08 LDR X1, [X27,#Obj_0x1eb58] ; X1 = Utf8Encoder instance (from object pool)
.text:0000000000285A0C LDR X4, [X27,#Obj_0x1f7c0] ; X4 = VM helper object
.text:0000000000285A10 BL dart_convert_Utf8Encoder__convert_47a628
; => returns UTF-8 bytes of S in X0
.text:0000000000285A14 MOV X2, X0 ; X2 = Uint8List of UTF-8(S)
.text:0000000000285A18 ADD X1, X27, #0xC,LSL#12
.text:0000000000285A1C LDR X1, [X1,#0x8E8] ; X1 = Hash implementation object from pool
; ^ this is the SHA-256 instance in this app
.text:0000000000285A20 BL crypto$src$hash_Hash__convert_47bf60
; => returns Digest/List<int> (the SHA-256 of UTF-8(S)) in X0
.text:0000000000285A24 LDUR W2, [X0,#7] ; W2 = digest.length (as Smi-tagged length)
.text:0000000000285A28 ADD X2, X2, X28,LSL#32 ; (VM Smi handling)
.text:0000000000285A2C MOV X1, X22 ; X1 = type arg/zone (standard for list ctor)
.text:0000000000285A30 BL dart_typed_data_Uint8List__factory_ctor_fromList_2ad370
; => materialize a Uint8List from the digest List<int>
.text:0000000000285A34 STUR X0, [X29,#-0x10] ; save Uint8List(digest)
.text:0000000000285A38 BL AllocateKeyStub_2ad364 ; allocate a Key object
.text:0000000000285A3C MOV X1, X0 ; X1 = Key
.text:0000000000285A40 LDUR X0, [X29,#-0x10] ; X0 = Uint8List(digest)
.text:0000000000285A44 STUR X1, [X29,#-0x18] ; spill Key
.text:0000000000285A48 STUR W0, [X1,#7] ; Key.bytes = digest Uint8List (store field)
; (write barrier follows if needed)
What does this do:
- Take the previously built base
string S = "Ar]gd_aAAQUperQecretKey523!" - UTF-8 encode it.
- Hash with the pool-provided Hash (here: SHA-256).
- Wrap the 32-byte digest as a
Uint8List. - Construct
Keyand set itsbytesto that digest.
That’s the AES key material that is later passed to the AES constructor:
.text:0000000000285CF0 LDUR W2, [X1,#0xB]
...
.text:0000000000285CFC BL AllocateAESStub_2ad34c
...
.text:0000000000285D0C BL encrypt$encrypt_AES__ctor_285d9c
Inside encrypt$encrypt_AES__ctor_285d9c, the mode/padding objects are set up and the PaddedBlockCipher is created, but the key bytes are exactly that SHA-256 digest we saw. So, AES key = SHA-256(“Ar]gd_aAAQUperQecretKey523!”)
IV Derivation
Start from the already-built base string S
The disassembly sequence below shows how the base string S is constructed and concatenated with an additional constant:
.text:0000000000285AA0 LDUR W0, [X1,#7] ; S = "Ar]gd_aAA" + "QUperQecr" + "etKey523!"
.text:0000000000285AA4 ADD X0, X0, X28, LSL#32 ; Smi handling (turn W0 into a full object ref)
.text:0000000000285AA8 ADD X16, X27, #0xE,LSL#12
.text:0000000000285AAC LDR X16, [X16,#0x918] ; [pp+0xe918] "IV_SALT"
.text:0000000000285AB0 STP X16, X0, [X15]
.text:0000000000285AB4 BL dart_core__StringBase__op_add_21de50
Here, the code loads this.field#7 (which represents the base string S) and pushes it as an operand. It then loads the constant "IV_SALT" from the object pool at offset +0xe918 and calls StringBase.op_add to concatenate the two strings. As a result, S2 = S + POOL_STR(@0xe918) becomes "Ar]gd_aAAQUperQecretKey523!" + "IV_SALT".
For reference, the pp.txt file generated by Blutter maps these pool offsets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pp.txt
--------------------------
...
[pp+0xe900] TypeArguments: <Password>
[pp+0xe908] String: "_hardcodedKey@477197192"
[pp+0xe910] String: "_key@477197192"
[pp+0xe918] String: "IV_SALT"
[pp+0xe920] String: "_iv@477197192"
[pp+0xe928] String: "_encrypter@477197192"
[pp+0xe930] Obj!AESMode@57d1c1 : {
Super!_Enum : {
off_8: int(0x0),
off_10: "cbc"
}
}
[pp+0xe938] String: "PKCS7"
[pp+0xe940] String: "AES/"
...
[pp+0x12e08] Field <EncryptionService._encrypter@477197192>: late final (offset: 0x10)
[pp+0x12e10] Field <EncryptionService._iv@477197192>: late final (offset: 0x14)
....
Construct IV
Next, the concatenated string is encoded in UTF-8 and hashed using SHA-256:
.text:0000000000285AB8 MOV X2, X0 ; X0 = S2
.text:0000000000285ABC LDR X1, [X27,#Obj_0x1eb58] ; Utf8Encoder instance
.text:0000000000285AC0 LDR X4, [X27,#Obj_0x1f7c0] ; converter/sink helper
.text:0000000000285AC4 BL dart_convert_Utf8Encoder__convert_47a628
.text:0000000000285AC8 MOV X2, X0 ; bytes = utf8.encode(S2)
.text:0000000000285ACC ADD X1, X27, #0xC,LSL#12
.text:0000000000285AD0 LDR X1, [X1,#0x8E8] ; [pp+0xc8e8] Obj!_Sha256@579d51
.text:0000000000285AD4 BL crypto$src$hash_Hash__convert_47bf60
The hash digest is then truncated to the first 16 bytes:
.text:0000000000285AD8 LDUR W1, [X0,#7] ; digest.length (Smi)
.text:0000000000285ADC ADD X1, X1, X28,LSL#32
.text:0000000000285AE0 LDUR X0, [X1,#-1]
.text:0000000000285AE4 UBFX X0, X0, #0xC, #0x14 ; (digest class dispatch)
.text:0000000000285AE8 MOV X16, #0x20 ; 0x20 (Smi) == 32 decimal >>1 == 16
.text:0000000000285AEC STR X16, [X15] ; push 'end' = Smi(32) == 16
.text:0000000000285AF0 MOV X2, #0 ; start = 0
.text:0000000000285AF4 LDR X4, [X27,#Obj_0x1f7b8] ; helper/sentinel
.text:0000000000285AF8 MOV X17, #0x8638
.text:0000000000285AFC ADD X30, X0, X17
.text:0000000000285B00 LDR X30, [X21,X30,LSL#3]
.text:0000000000285B04 BLR X30 ; call digest.sublist(0, 16)
Finally, the 16-byte sublist is wrapped as an IV object and stored in the instance:
.text:0000000000285C84 LDUR X0, [X29,#-8]
.text:0000000000285C88 BL AllocateIVStub_2ad358 ; allocate IV()
.text:0000000000285C8C MOV X1, X0 ; X1 = IV
.text:0000000000285C90 LDUR X0, [X29,#-0x20] ; X0 = Uint8List(16)
.text:0000000000285C94 STUR X1, [X29,#-0x10]
.text:0000000000285C98 STUR W0, [X1,#7] ; IV.bytes = that Uint8List
...
.text:0000000000285CD0 LDUR X0, [X29,#-0x10] ; X0 = IV
.text:0000000000285CD4 STUR W0, [X1,#0x13] ; this._iv = IV
Putting it all together, the process looks like this:
1
2
3
4
5
6
7
S = "Ar]gd_aAA" + "QUperQecr" + "etKey523!" # Base string
S2 = S + "IV_SALT"
D = SHA-256(UTF8(S2)) # 32-byte digest
IV = D[0:16] # First 16 bytes
# IV derivation summarized:
IV = SHA-256(b"Ar]gd_aAAQUperQecretKey523!IV_SALT")[0:16]
AES Encryption Mode
By examining the AES constructor in asm/encrypt/encrypted.dart and correlating it with the object pool, we can confirm that the encryption mode is CBC with PKCS7 padding:
1
2
3
4
5
6
7
8
9
pp.txt
--------------------------
[pp+0xe930] Obj!AESMode@57d1c1 : {
Super!_Enum : {
off_8: int(0x0),
off_10: "cbc"
}
}
[pp+0xe938] String: "PKCS7"
Put to the test
With the static derivation understood, we can (optionally) verify the decryption. The original challenge forbids dynamic hooking, but it doesn’t forbid patching the APK to run on a test device for confirmation. The verification steps below are optional for validation and demonstrate correctness.
Patch APK to disable security checks
As analysed earlier, the app enforces security checks both in the native library and in the Android layer. A practical way to bypass those checks for testing is to patch the Android bytecode so the checks always report “no problem.”
In this APK the SecurityModule class implements three boolean checks—a() (app tampering), b() (device root) and c() (emulator). Each method simply returns a boolean to indicate detection. Because they just return a boolean, the easiest patch is to replace their bodies so they always return false and skip the original logic; this can be done by editing the smali produced by apktool.
First, decompile the APK with apktool:
1
$ apktool d DroidPass.apk
Then open the extracted DroidPass/smali/com/eightksec/droidpass/SecurityModule.smali and locate the long implementations of a(), b(), and c(). Replace each method body with the minimal patched version that forces a false return:
# virtual methods
.method public final a()Z
.locals 1
const/4 v0, 0x0 # force false
return v0
.end method
.method public final b()Z
.locals 1
const/4 v0, 0x0 # force false
return v0
.end method
.method public final c()Z
.locals 1
const/4 v0, 0x0 # force false
return v0
.end method
Save the edited smali file and rebuild the APK:
1
$ apktool b DroidPass -o PatchedDroidPass.apk
Before installing, the rebuilt APK must be signed. If you don’t already have a Java Keystore (JKS) file, create one with keytool (adjust alias and filenames as needed), then sign with apksigner:
1
2
$ keytool -genkey -v -keystore my-release-key.jks -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
$ ~/Library/Android/sdk/build-tools/34.0.0/apksigner sign --v3-signing-enabled true --ks my-release-key.jks --ks-key-alias alias_name PatchedDroidPass.apk
Finally, install the patched APK to the device:
1
$ adb install PatchedDroidPass.apk
Notes and caveats: this approach modifies client-side logic only (useful for testing and analysis). If the challenge or application uses server-side validation or cryptographic attestation, simply disabling client checks may not be sufficient for real-world bypasses—do this only in a controlled test environment.
Generate and save a password on the app
Launch the patched app on the rooted device. You should no longer see the “rooted device” warning and the Save password button will be enabled — you can now generate a password and save it successfully.
Pull out database from app sandbox
You can extract the app database from the app sandbox using adb pull. The database is located at /data/data/com.eightksec.droidpass/app_flutter/DroidPass.db:
1
$ adb pull /data/data/com.eightksec.droidpass/app_flutter/DroidPass.db ./
If you encounter permission issues, copy the database to a location that does not require root first (for example /sdcard) and then pull it from there.
1
$ adb pull /data/data/com.eightksec.droidpass/app_flutter/DroidPass.db ./
Open the DB and inspect:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ sqlite3
SQLite version 3.42.0 2023-05-16 12:36:15
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> .open DroidPass.db
sqlite> .tables
android_metadata passwords
sqlite> select * from passwords;
1|qJ44Eyxj2+m1H5z4FlGhlw==|1dFM+uvqm/E85fBT6GsSzY/SxRvEQaId6SGH2E/c1Jk=|2025-10-23T22:44:04.873830
sqlite> .dump passwords
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO passwords VALUES(1,'qJ44Eyxj2+m1H5z4FlGhlw==','1dFM+uvqm/E85fBT6GsSzY/SxRvEQaId6SGH2E/c1Jk=','2025-10-23T22:44:04.873830');
COMMIT;
sqlite>
The service and password columns store base64-encoded ciphertexts (the exact format depends on how the Dart AES wrapper encodes results).
Decrypt the password
To reproduce the decryption, implement the same derivation in Python (or any language with AES/CBC/PKCS7 support)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
#!/usr/bin/env python3
"""
droidpass_decryption_demo.py
Reproduce the EncryptionService constructor's static logic to derive AES-256 key and IV,
then decrypt supplied ciphertexts.
How this maps to the disassembly (static):
- The binary stores 27 obfuscated bytes as immediate constants (many MOV instructions).
- Each stored byte equals (char_code << 1). The constructor recovers the char code with (byte >> 1).
- The constructor groups the 27 recovered chars into 3 groups of 9 and calls String.fromCharCodes.
- The three strings are concatenated in order (chunk1 + chunk2 + chunk3) to form the pre-hash input.
- That final string is UTF-8 encoded and hashed with SHA-256 → 32-byte AES key (AES-256).
- The IV source is the final string appended with a literal salt ("IV_SALT"), hashed with SHA-256,
and the first 16 bytes of that hash are used as the AES-CBC IV.
- The cipher used is AES-CBC with PKCS#7 padding (consistent with PaddedBlockCipher usage).
Dependencies:
pip install pycryptodome
Usage:
python3 droidpass_decryption_demo.py
"""
from base64 import b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import hashlib
import binascii
# ------------------------
# Input constants (from static analysis)
# ------------------------
# Raw 27 obfuscated bytes embedded in the binary as immediate constants.
# These are the literal byte values the constructor writes; each equals (char_code << 1).
RAW27 = bytes([
0x82, 0xE4, 0xBA, 0xCE, 0xC8, 0xBE, 0xC2, 0x82, 0x82,
0xA2, 0xAA, 0xE0, 0xCA, 0xE4, 0xA2, 0xCA, 0xC6, 0xE4,
0xCA, 0xE8, 0x96, 0xCA, 0xF2, 0x6A, 0x64, 0x66, 0x42
])
# The constructor's IV source list included a literal suffix; we reproduce it exactly.
IV_SALT = "IV_SALT"
# Ciphertexts to decrypt (examples you supplied)
CIPHERTEXTS = [
("encrypted_service", "qJ44Eyxj2+m1H5z4FlGhlw=="),
("encrypted_password", "1dFM+uvqm/E85fBT6GsSzY/SxRvEQaId6SGH2E/c1Jk=")
]
# ------------------------
# Helper functions
# ------------------------
def sha256_bytes(b: bytes) -> bytes:
"""Return SHA-256 digest for the input bytes."""
return hashlib.sha256(b).digest()
def aes256_cbc_decrypt(key32: bytes, iv16: bytes, ciphertext_b64: str) -> bytes:
"""
AES-256-CBC decrypt + PKCS#7 unpad.
Returns plaintext bytes or raises an exception on failure.
"""
ct = b64decode(ciphertext_b64)
cipher = AES.new(key32, AES.MODE_CBC, iv16)
pt = unpad(cipher.decrypt(ct), AES.block_size)
return pt
# ------------------------
# Reconstruct constructor steps (static)
# ------------------------
def decode_raw27_to_chunks(raw27: bytes):
"""
Reconstruct the three createFromCharCodes strings and the final interpolated string,
using only static rules discovered in the disassembly:
- each raw byte = char_code << 1
- recover char_code by (byte >> 1)
- group into three 9-char chunks (String.fromCharCodes called 3 times with len=9)
- final interpolated string = chunk1 + chunk2 + chunk3
"""
if len(raw27) != 27:
raise ValueError("raw27 must be exactly 27 bytes")
# Step: recover each char code by logical right-shift by 1
char_codes = [ (b >> 1) for b in raw27 ]
# Convert to characters (these correspond to the code units the constructor passed to fromCharCodes)
chars = ''.join(chr(cc) for cc in char_codes)
# Group into 3 chunks of 9 characters each
chunks = [ chars[i*9:(i+1)*9] for i in range(3) ]
# Constructor concatenates them in order to form the final pre-hash string
final_interpolated = chunks[0] + chunks[1] + chunks[2]
return chunks, final_interpolated
def derive_key_iv_from_interpolated(interpolated: str, iv_salt: str):
"""
Derive AES-256 key and IV from the interpolated string:
- key = SHA256( interpolated_utf8 )
- iv = first 16 bytes of SHA256( interpolated_utf8 + iv_salt )
This mirrors the constructor calls to Utf8Encoder.__convert and crypto Hash::convert.
"""
key = sha256_bytes(interpolated.encode('utf-8'))
iv_sha = sha256_bytes((interpolated + iv_salt).encode('utf-8'))
iv = iv_sha[:16]
return key, iv, iv_sha
# ------------------------
# Main demonstration
# ------------------------
def main():
print("Static reproduction of DroidPass key/IV derivation and AES-CBC decryption\n")
# 1) Recover chunks and final interpolation from raw27
chunks, interpolated = decode_raw27_to_chunks(RAW27)
print("Recovered createFromCharCodes chunks (3):")
for i, c in enumerate(chunks, start=1):
print(f" chunk{i}: {c!r}")
print("\nFinal interpolated string (chunk1 + chunk2 + chunk3):")
print(" ", interpolated)
# 2) Derive AES key and IV exactly as the constructor did (static)
key, iv, iv_sha = derive_key_iv_from_interpolated(interpolated, IV_SALT)
print("\nDerived AES-256 key (SHA-256 of final interpolated string):")
print(" key (hex):", key.hex())
print(" key (base64):", binascii.b2a_base64(key).decode().strip())
print("\nDerived IV source hash (SHA-256 of final_interpolated + IV_SALT):")
print(" iv_sha (hex):", iv_sha.hex())
print("Derived IV (first 16 bytes used for AES-CBC):")
print(" iv (hex):", iv.hex())
# 3) Decrypt provided ciphertexts
print("\nDecrypting provided ciphertexts with derived key & IV:")
for name, ct_b64 in CIPHERTEXTS:
try:
pt = aes256_cbc_decrypt(key, iv, ct_b64)
try:
text = pt.decode('utf-8')
except Exception:
text = pt.decode('latin1', errors='replace')
print(f" {name}: plaintext = {text!r} (raw bytes: {pt})")
except Exception as e:
print(f" {name}: decryption failed: {e}")
if __name__ == "__main__":
main()
Running the decryption with the derived key and IV should reveal human-readable plaintext such as:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ python droidpass_decryption_demo.py
Static reproduction of DroidPass key/IV derivation and AES-CBC decryption
Recovered createFromCharCodes chunks (3):
chunk1: 'Ar]gd_aAA'
chunk2: 'QUperQecr'
chunk3: 'etKey523!'
Final interpolated string (chunk1 + chunk2 + chunk3):
Ar]gd_aAAQUperQecretKey523!
Derived AES-256 key (SHA-256 of final interpolated string):
key (hex): e4ac9226ebb81bb59085c62d92ba8a1d404e136d15ae44b375e231b3e0aeb484
key (base64): 5KySJuu4G7WQhcYtkrqKHUBOE20VrkSzdeIxs+CutIQ=
Derived IV source hash (SHA-256 of final_interpolated + IV_SALT):
iv_sha (hex): d7485ec072002b6218a84d1478ad8ac05dfe252507006cdd047cf950b452fcf9
Derived IV (first 16 bytes used for AES-CBC):
iv (hex): d7485ec072002b6218a84d1478ad8ac0
Decrypting provided ciphertexts with derived key & IV:
encrypted_service: plaintext = 'gmail' (raw bytes: b'gmail')
encrypted_password: plaintext = 'L6wq%Qr7))4!KJ>D' (raw bytes: b'L6wq%Qr7))4!KJ>D')
In the testing carried out with the sample database above, the decrypted service and password matched expected values (for example 'gmail' and 'L6wq%Qr7))4!KJ>D'), confirming the static derivation is correct, MISSION COMPLETED!!!
Security assessment & suggested mitigations
Security through obscurity
The principal weakness in DroidPass is that both the encryption key and IV are derived solely from constants embedded in the binary. The developers stored those values as ASCII byte arrays, then performed trivial transformations—splitting into chunks and reversing lists—to make static analysis slightly more awkward. Even though the code is compiled to native, those constants and the constructor’s deterministic steps are recoverable via static analysis. Because the derivation is independent of any user-provided secret or hardware-backed key store, an attacker with the APK can reconstruct the key and IV without executing the app.
Simply put: if an attacker can read your APK, they can compute the same AES key and IV and decrypt the database—regardless of whether the app hides logic behind obfuscation or AOT compilation.
Better approaches (high level)
A few recommended improvements to make such an app more resilient:
- Use platform keystores: Derive or store key material using Android Keystore (Keymaster), which can make keys hardware-backed and non-exportable.
- User-derived keys: Tie encryption to a user secret (password/PIN) that isn’t embedded in the binary.
- Remote provisioning: Fetch sensitive keys or secrets from a server after proper authentication (and preferably with attestation to verify client integrity).
- Runtime checks + defense-in-depth: Combine runtime attestation, secure key storage, and server-side verification rather than relying solely on client-side secrets.
- Avoid static secrets: Don’t embed long-term secrets in the binary; if you must embed data, protect it with multiple layers (and assume that binary will be analyzed).
First Blood – The Global Solve
Solving this challenge was an incredibly rewarding experience. The reversing part was tough but enjoyable — the real challenge came afterward, when I spent several days polishing the full write-up for submission to the 8ksec Battlegrounds portal. It was worth every hour. A few days later, I received an email confirming that I was the first person globally to solve the DroidPass Android challenge.
The feedback I got from the 8ksec team was one of the most genuine and motivating responses I’ve ever received in a CTF-like environment — a reminder of why we do this: for the curiosity, the grind, and that rare thrill of breaking something truly challenging.
Conclusion
This DroidPass challenge is an excellent demonstration that compiled Flutter code and obfuscation are not the same as secret management. Static analysis—when combined with a modest understanding of the Dart VM, tagged Smis, and AOT metadata—can reveal everything needed to reconstruct cryptographic keys. The correct defensive posture is to avoid storing critical secrets in the app itself and to use platform or server-backed key management.