this is a collection of info about love live all stars' internals that I collect and add as I reverse engineer it
this information is public domain. feel free to use it and republish it however you please
this is a raw diary of notes i wrote down as I reverse engineered the game from scratch. the goal was to create a headless client that could connect to the game servers, create accounts and get daily login rewards. this is completely uncut, and there will be wrong premature observations that are later corrected
I installed the game on android x86 on a PC (had to unroot android to make it run) and fully updated it.
then i hooked up the android ssd to my linux machine, mounted it and searched for any file that contained lovelive in the path and copied everything
split_config.armeabi_v7a.apk
contains the native binaries (lib folder)
base.apk
contains assets and the java glue for unity
i used apktool 2.4.0 to extract the apk's
game uses unity. as of 2019-10-04, the version is 2018.4.2f1
(found in base.apk/smali/com/unity3d/player/m.smali
)
const-string v2, "Unity version : %s\n"
new-array v4, v3, [Ljava/lang/Object;
const-string v5, "2018.4.2f1"
unity uses il2cpp to transpile C# assembly to C++ and compile it to a
native android shared library named il2cpp.so located in
split_config.armeabi_v7a.apk/lib/arm
interestingly, all stars only ships with arm binaries while the japanese version of the old sif game had x86 binaries as well
using Il2CppDumper v4.6.0 it's possible to recover the method names and
strings by giving it the il2cpp.so first and then the global-metadata.dat
located in base.apk/assets/bin/Data/Managed/Metadata
. it should do it
automatically, remember to specify 2018.4 as the unity version.
Il2CppDumper generates a script.py for IDA. but since i use ghidra instead of IDA's proprietary garbage, I used this script by worawit: https://gist.github.com/Francesco149/289a24d2f17ba60f820f801b8bd6754a for ghidra which takes the IDA script as input and renames everything
I now have a mostly named disassembly and we have also recovered all the string constants so reversing stuff should be much easier this way
after skimming through the strings (they all have the StringLiteral_
prefix), i found three interesting strings referenced by
ServerConfig$$.cctor
https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1002
i0qzc6XbhFfAxjN2
x\'B73DA9C0EE7116836995B5ACED4AA33B095ECAF77B33605833FD759E6E743F1D\'
note: these values change with every update, they already changed as I wrote these notes, but they're pretty easy to find anyway.
which I speculatively named ServerHost, ServerPassword and ServerKey
the disassembly of that ServerConfig ctor reveals that they're using a library named DotUnder
void ServerConfig$$.cctor(void)
{
int iVar1;
int *piVar2;
undefined4 uVar3;
if (DAT_037021b1 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x8c63);
}
**(undefined4 **)(Class$DotUnder.ServerConfig + 0x5c) = ServerHost;
piVar2 = (int *)Encoding$$get_UTF8(0);
if (piVar2 == (int *)0x0) {
FUN_0089db60(0);
}
uVar3 = (**(code **)(*piVar2 + 0x148))(piVar2,ServerPassword,*(undefined4 *)(*piVar2 + 0x14c));
iVar1 = Class$DotUnder.ServerConfig;
*(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4) = uVar3;
*(undefined4 *)(*(int *)(iVar1 + 0x5c) + 8) = ServerKey;
*(undefined4 *)(*(int *)(iVar1 + 0x5c) + 0xc) = StringLiteral_7288;
*(undefined *)(*(int *)(iVar1 + 0x5c) + 0x10) = 0;
return;
}
however, googling DotUnder doesn't seem to yield any results so it's probably an internal library
we can use getter names to figure out what the fields of the ServerConfig struct are though, for example:
undefined4 Config$$get_StartupKey(void)
{
if (DAT_03704350 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x26ce);
}
if (((*(byte *)(Class$DotUnder.ServerConfig + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.ServerConfig + 0x70) == 0)) {
FUN_0087fd40();
}
return *(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4);
}
this tells us that what we named ServerPassword is originally named StartupKey. why? because it's returning offset 4 of ServerConfig + 0x5c, which is the same that is assigned in the ctor
uVar3 = (**(code **)(*piVar2 + 0x148))(piVar2,ServerPassword,*(undefined4 *)(*piVar2 + 0x14c));
iVar1 = Class$DotUnder.ServerConfig;
*(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4) = uVar3;
by browsing all references to ServerConfig I renamed ServerHost to ServerEndpoint and ServerPassword to StartupKey.
I couldn't find any reference to the ServerKey offset, so for now I'm leaving it.
using ghidra's data type manager and checking all the getter names as before I map out the ServerConfig struct. this makes the decompile output a whole lot more readable.
I also figured out that each object has the same wrapper struct where you have a pointer to the actual data at 0x5c. I saw other object being checked at the same offsets
void ServerConfig$$.cctor(void)
{
Object *config;
int *utf8;
char *utf8StartupKey;
if (DAT_037021b1 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x8c63);
}
Class$DotUnder.ServerConfig->Instance->ServerEndpoint = ServerEndpoint;
utf8 = (int *)Encoding$$get_UTF8(0);
if (utf8 == (int *)0x0) {
FUN_0089db60(0);
}
utf8StartupKey =
(char *)(**(code **)(*utf8 + 0x148))(utf8,StartupKey,*(undefined4 *)(*utf8 + 0x14c));
config = Class$DotUnder.ServerConfig;
Class$DotUnder.ServerConfig->Instance->StartupKey = utf8StartupKey;
config->Instance->ServerKey = ServerKey;
config->Instance->BuildId = BuildId;
config->Instance->Unk1 = false;
return;
}
after digging around some more I found this class named DMHttpApi which has a method named CalcDigest called in MakeRequestData which does a hmac sha-1 hash with the given params. so far it all seems very similar to how the original sif request signing worked
upon further inspection, MakeRequestData takes 2 strings and concatenates them with a space in between, then does a hmac-sha1 using some key stored in DMHttpApi
Array * DMHttpApi$$CalcDigest(Array *param_1,Array *param_2,int param_2_index,int param_2_len)
{
System.Text.Encoding *enc;
Array *param_1_bytes;
Array *param_4_1;
undefined4 uVar1;
Array *key;
uint param_1_len;
if (DAT_037033d9 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x2bcc);
}
enc = Encoding$$get_UTF8((System.Text.Encoding *)0x0);
if (enc == (System.Text.Encoding *)0x0) {
ThrowException(0);
}
param_1_bytes = (Array *)(*enc->vtable->DoSomething)(enc,param_1,enc->vtable->SomePredicateFunc);
if (param_1_bytes == (Array *)0x0) {
ThrowException(0);
}
param_4_1 = (Array *)Instantiate(Class$byte[],param_2_len + param_1_bytes->Length + 1);
if (param_1_bytes == (Array *)0x0) {
ThrowException(0);
Array$$Copy(0,0,param_4_1,0,_DAT_0000000c);
ThrowException(0);
}
else {
/* param_4_1 = param_1_bytes
Array.Copy(src, srcIndex, dst, dstIndex, len) */
Array$$Copy(param_1_bytes,0,param_4_1,0,param_1_bytes->Length);
}
if (param_4_1 == (Array *)0x0) {
ThrowException(0);
}
param_1_len = param_1_bytes->Length;
if ((uint)param_4_1->Length <= param_1_len) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
/* append a space to param_4_1 */
(¶m_4_1->Data)[param_1_len] = ' ';
/* append param_2[param_2_index,param_2_len] to param_4_1 */
Array$$Copy(param_2,param_2_index,param_4_1,param_1_bytes->Length + 1,param_2_len);
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
key = Class$DotUnder.DMHttpApi->Instance->HmacSha1Key;
if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) &&
(Class$DotUnder.DMCryptography->Unk1 == 0)) {
FUN_0087fd40();
}
DMCryptography$$HmacSha1(param_4_1,key);
param_4_1 = (Array *)Lib$$Hexlify();
return param_4_1;
}
a quick search for references to HmacSha1Key in DMHttpApi tells us that it's internally called SessionKey
undefined4 DMHttpApi$$CopySessionKey(undefined4 param_1)
{
undefined4 uVar1;
if (DAT_037033d8 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x2bcf);
}
uVar1 = Instantiate(Class$byte[],param_1);
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
Array$$Copy(Class$DotUnder.DMHttpApi->Instance->HmacSha1Key,uVar1,param_1,0);
return uVar1;
}
looking for references to the SessionKey field I found this, which looks very similar to the old SIF request signing
void DMHttpApi.__c__DisplayClass14_1$$_Login_b__1(int param_1,int param_2)
{
undefined4 uVar1;
Array *pAVar2;
int iVar3;
undefined4 uVar4;
undefined8 uVar5;
if (DAT_037033e2 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0xb482);
}
uVar4 = *(undefined4 *)(param_1 + 8);
if (param_2 == 0) {
ThrowException(0);
}
uVar1 = LoginResponse$$get_SessionKey(param_2,0);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087fd40();
}
uVar1 = Convert$$FromBase64String(uVar1,0);
pAVar2 = (Array *)Lib$$XorBytes(uVar4,uVar1);
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
Class$DotUnder.DMHttpApi->Instance->SessionKey = pAVar2;
if (param_2 == 0) {
ThrowException(0);
}
uVar5 = LoginResponse$$get_LastTimestamp(param_2,0);
Clock$$SetLastTimestamp((int)((ulonglong)uVar5 >> 0x20),(int)uVar5,0);
iVar3 = *(int *)(param_1 + 0x14);
if (iVar3 == 0) {
ThrowException(0);
}
iVar3 = *(int *)(iVar3 + 8);
if (iVar3 == 0) {
ThrowException(0);
}
FUN_023a2ee8(iVar3,param_2,Method$Action_LoginResponse_.Invoke());
return;
}
so essentially what's happening is some http request response contains a base64 key which is then decoded and xored with a xor key string. the result is used as the session key
this appears to be the xor key:
uVar4 = *(undefined4 *)(param_1 + 8);
...
pAVar2 = (Array *)Lib$$XorBytes(uVar4,uVar1);
param_1 appears to be some class instance, let's map the struct with just the xor key field for now
as for param_2, we can deduce that it's a LoginResponse instance since it's
passed as the this pointer for LoginResponse$$get_SessionKey
let's map some of LoginResponse's field by looking at its getters
undefined4 LoginResponse$$get_UserModel(int param_1)
{
return *(undefined4 *)(param_1 + 0xc);
}
undefined4 LoginResponse$$get_SessionKey(int param_1)
{
return *(undefined4 *)(param_1 + 8);
}
uint LoginResponse$$get_IsPlatformServiceLinked(int param_1)
{
return (uint)*(byte *)(param_1 + 0x10);
}
undefined8 LoginResponse$$get_LastTimestamp(int param_1)
{
return CONCAT44(*(undefined4 *)(param_1 + 0x18),*(undefined4 *)(param_1 + 0x1c));
}
undefined4 LoginResponse$$get_Cautions(int param_1)
{
return *(undefined4 *)(param_1 + 0x20);
}
uint LoginResponse$$get_ShowHomeCaution(int param_1)
{
return (uint)*(byte *)(param_1 + 0x24);
}
undefined4 LoginResponse$$get_LiveResume(int param_1)
{
return *(undefined4 *)(param_1 + 0x28);
}
by looking at DMHttpApi$$Logout
we can map a few unknown fields for
DMHttpApi as well as the connection field
void DMHttpApi$$Logout(void)
{
DMHttpApiObject *pDVar1;
DMHttpApi *pDVar2;
int iVar3;
if (DAT_037033d6 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x2bd3);
}
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
iVar3 = *(int *)&Class$DotUnder.DMHttpApi->Instance->field_0xc;
if (iVar3 != 0) {
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
iVar3 = *(int *)&Class$DotUnder.DMHttpApi->Instance->field_0xc;
if (iVar3 == 0) {
iVar3 = 0;
ThrowException(0);
}
}
Network.Connection$$Cancel(iVar3,0);
if (((*(byte *)(_Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) &&
(*(int *)(_Class$DotUnder.HttpSubject + 0x70) == 0)) {
FUN_0087fd40();
}
HttpSubject$$OnCancel();
}
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
pDVar2 = Class$DotUnder.DMHttpApi->Instance;
*(undefined4 *)&pDVar2->field_0x4 = 0;
*(undefined4 *)pDVar2 = 0;
pDVar1 = Class$DotUnder.DMHttpApi;
Class$DotUnder.DMHttpApi->Instance->SessionKey = (Array *)0x0;
*(undefined4 *)&pDVar1->Instance->field_0xc = 0;
*(undefined4 *)&pDVar1->Instance[1].field_0x1 = 0;
*(undefined4 *)&pDVar1->Instance[1].field_0x5 = 0;
return;
}
by looking at DmHttpApi's getters i was able to name the field IsGuarded
at this point i just start looking at every DmHttpApi method, this seems to be a simple counter, and tells me that that Unk3 field is the request id
void DMHttpApi$$CreateRequestId(void)
{
DMHttpApiObject *pDVar1;
if (DAT_037033da == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x2bd0);
}
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
pDVar1 = Class$DotUnder.DMHttpApi;
Class$DotUnder.DMHttpApi->Instance->Unk3 = Class$DotUnder.DMHttpApi->Instance->Unk3 + 1;
FUN_01746d54(&pDVar1->Instance->Unk3,0);
return;
}
that function call at the end appears to stringify it, so I assume this returns a string
in DMHttpApi.__c__DisplayClass14_1$$_Login_b__2
i found a reference to
some UserKey class, we'll take a look at that later
more familiar base64 xoring in this login step, also it references StartupResponse which I should probably start mapping
void DMHttpApi.__c__DisplayClass14_2$$_Login_b__4(int param_1,int param_2)
{
undefined4 uVar1;
Array *string;
int iVar2;
Array *pAVar3;
if (DAT_037033e5 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0xb486);
}
if (param_2 == 0) {
ThrowException(0);
uVar1 = StartupResponse$$get_UserId(0,0);
pAVar3 = *(Array **)(param_1 + 8);
ThrowException(0);
}
else {
uVar1 = StartupResponse$$get_UserId(param_2,0);
pAVar3 = *(Array **)(param_1 + 8);
}
string = (Array *)StartupResponse$$get_AuthorizationKey(param_2,0);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087fd40();
}
string = Convert$$FromBase64String(string);
pAVar3 = Lib$$XorBytes(pAVar3,string);
UserKey$$SetIDPW(uVar1,pAVar3,0);
iVar2 = *(int *)(param_1 + 0xc);
if (iVar2 == 0) {
ThrowException(0);
}
iVar2 = *(int *)(iVar2 + 0x10);
if (iVar2 == 0) {
ThrowException(0);
}
FUN_023b38fc(iVar2,uVar1,pAVar3,Method$Action_int_-byte[]_.Invoke());
return;
}
I mapped Connection and StartupResponse fields based on the getters as usual. probably unnecessary
at this point I start looking through strings again and I find strings that are most likely api endpoints, most of them referenced by various methods
/login/startup
/live/surrender
/navi/tapLovePoint
/terms/agreement
/tutorial/phaseEnd
and so on
let's take a look at what references /login/startup
undefined4 Startup$$get_Path(void)
{
if (DAT_036ffcc5 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x92cd);
}
return login_startup;
}
while looking through the Startup methods I notice that ghidra is actually
failing to disassemble a lot of the code because it thinks some functions
aren't returning. I should've disabled non-returning function discovery on
the analysis settings. I'm going to re-run the analysis. this kind of thing
should also be fixable by doing func.setNoReturn(False)
re-analyzing didn't fix, had to manually right click the flow override comments and set flow to default
finally StartupRequestBuilder$$Create
disassembles properly as well as
many other similar functions that used to only be a Instantiate1 call
void StartupRequestBuilder$$Create(undefined4 param_1,int param_2)
{
undefined4 uVar1;
undefined4 uVar2;
if (DAT_037021c9 == '\0') {
/* WARNING: Subroutine does not return */
FUN_008722e4(0x92cb);
}
uVar1 = Clock$$get_TimeDifference(0);
uVar2 = Instantiate1(Class$DotUnder.Structure.StartupRequest);
StartupRequest$$.ctor(uVar2,param_1,StringLiteral_73,uVar1,0);
if (param_2 == 0) {
ThrowException(0);
}
FUN_023a2ee8(param_2,uVar2,Method$Action_StartupRequest_.Invoke());
return;
}
I'm not sure how to automatically fixup this thing globally so for now I'm manually fixing the flow where needed
this also fixed the disassembly for the Serialization functions which now tell us exactly what fields the request should have
int Serialization$$SerializeStartupRequest(int *param_1)
{
/* ... */
iVar3 = StartupRequest$$get_Mask(param_1,0);
if (iVar3 != 0) {
if (bVar1) {
ThrowException(0);
}
uVar4 = StartupRequest$$get_Mask(param_1,0);
if (iVar2 == 0) {
ThrowException(0);
}
FUN_023742d0(iVar2,mask,uVar4,Method$Dictionary_string_-object_.set_Item());
}
if (bVar1) {
ThrowException(0);
}
iVar3 = StartupRequest$$get_ResemaraDetectionIdentifier(param_1,0);
if (iVar3 != 0) {
if (bVar1) {
ThrowException(0);
}
uVar4 = StartupRequest$$get_ResemaraDetectionIdentifier(param_1,0);
if (iVar2 == 0) {
ThrowException(0);
}
FUN_023742d0(iVar2,resemara_detection_identifier,uVar4,
Method$Dictionary_string_-object_.set_Item());
}
if (bVar1) {
ThrowException(0);
}
uStack32 = StartupRequest$$get_TimeDifference(param_1,0);
uVar4 = FUN_008ae744(Class$int,&uStack32);
if (iVar2 == 0) {
ThrowException(0);
}
FUN_023742d0(iVar2,time_difference,uVar4,Method$Dictionary_string_-object_.set_Item());
return iVar2;
}
the FUN_023742d0
calls set request fields and the second argument is the
field name
this "resemara detection" is intriguing. after searching for resemara
in the symbols table i found AndroidPlatform$$LoadResemaraDetectionIdentifier
halfway into this function it passes the string "getResemaraDetectionId" to a function:
uVar5 = FUN_0149a010(piVar1,getResemaraDetectionId,piVar3,
Method$AndroidJavaObject.CallStatic()_AndroidJavaObject_);
this is very familiar, it's calling into java code, and I imagine piVar1 is a java context object of some sort.
time to decompile the java side
for some reason dragging base.apk
into my current ghidra project
wasn't working (it wouldn't show classes.dex to decompile) so I created a
new project and imported base.apk
which correctly decompiled
you will notice that there's strings defined with a scrambled version of each function name. this was also present in old SIF, I think it's just something that java does:
**************************************************************
* 4ebf *
* *
* createResemaraDetectionId *
**************************************************************
strings::createResemaraDetectionId XREF[1]: 00013b6c(*)
003a3fa2 19 63 72 string_d
65 61 74
65 52 65
003a3fa2 19 db[1] utf16_size XREF[1]: 00013b6c(*)
003a3fa2 [0] 19h
003a3fa3 63 72 65 61 74 utf8 u8"cetRsmrDtcind" data
65 52 65 73 65
6d 61 72 61 44
it could be useful to look for calls to those particular functions in the native binary when they are obfuscated with this system.
but let's get to the juicy bits, let's search for resemara in the symbols table and follow the code
void ResemaraDetectionIdentifierRequest(ResemaraDetectionIdentifierRequest this,Listener p1)
{
this.<init>();
this.mListener = p1;
return;
}
ResemaraDetectionIdentifierRequest getResemaraDetectionId(Listener p0)
{
ResemaraDetectionIdentifierRequest local_0;
local_0 = new ResemaraDetectionIdentifierRequest(p0);
local_0.createResemaraDetectionId();
return local_0;
}
void createResemaraDetectionId(ResemaraDetectionIdentifierRequest this)
{
Thread local_0;
Runnable ref;
ref = new Runnable(this);
local_0 = new Thread(ref);
local_0.start();
return;
}
void run(ResemaraDetectionIdentifierRequest$1 this)
{
Context ref;
AdvertisingIdClient$Info ref_00;
String pSVar1;
String pSVar2;
Activity local_0;
ResemaraDetectionIdentifierRequest pRVar3;
StringBuilder ref_01;
local_0 = UnityPlayer.currentActivity;
ref = local_0.getApplicationContext();
ref_00 = AdvertisingIdClient.getAdvertisingIdInfo(ref);
pSVar1 = ref_00.getId();
local_0 = UnityPlayer.currentActivity;
ref = local_0.getApplicationContext();
pSVar2 = ref.getPackageName();
pRVar3 = this.this$0;
ref_01 = new StringBuilder();
ref_01.append(pSVar1);
ref_01.append(pSVar2);
pSVar1 = ref_01.toString();
pSVar1 = ResemaraDetectionIdentifierRequest.access$000(pRVar3,pSVar1);
if (pSVar1 == "") {
ResemaraDetectionIdentifierRequest.access$100
(this.this$0,"",ResemaraDetectionIdResultKind.Failed);
}
else {
ResemaraDetectionIdentifierRequest.access$100
(this.this$0,pSVar1,ResemaraDetectionIdResultKind.Succeeded);
}
return;
}
String access$000(ResemaraDetectionIdentifierRequest p0,String p1)
{
String pSVar1;
pSVar1 = p0.md5(p1);
return pSVar1;
}
void access$100(ResemaraDetectionIdentifierRequest p0,String p1,ResemaraDetectionIdResultKind p2)
{
p0.sendMessage(p1,p2);
return;
}
void sendMessage(ResemaraDetectionIdentifierRequest this,String p1,ResemaraDetectionIdResultKind p2)
{
int iVar1;
Listener ref;
if (this.mListener != null) {
ref = this.mListener;
iVar1 = p2.getKindInt();
ref.onReceived(p1,iVar1);
}
return;
}
String md5(ResemaraDetectionIdentifierRequest this,String p1)
{
MessageDigest ref;
byte[] pbVar1;
String ref_00;
int iVar2;
BigInteger ref_01;
StringBuilder ref_02;
ref = MessageDigest.getInstance("MD5");
ref.reset();
pbVar1 = p1.getBytes("UTF-8");
ref.update(pbVar1);
pbVar1 = ref.digest();
ref_01 = new BigInteger(1,pbVar1);
ref_00 = ref_01.toString(0x10);
while (iVar2 = ref_00.length(), iVar2 < 0x20) {
ref_02 = new StringBuilder();
ref_02.append("0");
ref_02.append(ref_00);
ref_00 = ref_02.toString();
}
return ref_00;
}
okay, so it's just cramming a bunch of info into a string which is then turned into a md5 hash and right-padded with zeros to 0x20 characters. shouldn't be too hard to emulate if needed
back to the native code. I keep searching for startup in the symbols table
and eventually notice a class named DotUnder.SVAPI.Startup
. looking
for references to this brings me to this function
void DMHttpApi.__c__DisplayClass14_2$$_Login_b__3(int param_1,undefined4 param_2)
{
int svapi;
int response;
if (DAT_037033e4 == '\0') {
FUN_008722e4(0xb485);
DAT_037033e4 = '\x01';
}
svapi = Instantiate1(Class$DotUnder.SVAPI.Startup);
Startup$$.ctor(svapi,0);
response = *(int *)(param_1 + 0x10);
if (response == 0) {
response = Instantiate1(Class$Action_StartupResponse_);
FUN_023a2ed4(response,param_1,Method$DMHttpApi.__c__DisplayClass14_2._Login_b__4(),
Method$Action_StartupResponse_..ctor());
*(int *)(param_1 + 0x10) = response;
}
if (svapi == 0) {
ThrowException(0);
}
RuleNoAuth$$Send(svapi,param_2,response,Method$RuleNoAuth_StartupRequest_-StartupResponse_.Send())
;
return;
}
it seems that this SVAPI class is responsible for constructing the API calls. let's search for it
tracking down the methods is tricky, it seems that all the methods are called indirectly, maybe virtual methods?
void RuleNoAuth$$Send(int svapi,undefined4 param_2,undefined4 param_3,int method)
{
code **ppcVar1;
if (svapi == 0) {
ThrowException(0);
}
ppcVar1 = (code **)**(code ***)(*(int *)(method + 0xc) + 0x60);
(**ppcVar1)(svapi,param_2,param_3,1,1,ppcVar1);
return;
}
dead end for now, we will maybe come back to this later, let's scroll around some more in the DisplayClass14 methods which all seem to relate to the login/auth process
the PublicEncrypt method is a simple call to the standard c# encryption lib
void DMCryptography$$PublicEncrypt(undefined4 param_1)
{
int provider;
if (DAT_037033cb == '\0') {
FUN_008722e4(0x2bc6);
DAT_037033cb = '\x01';
}
if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) &&
(Class$DotUnder.DMCryptography->Unk1 == 0)) {
FUN_0087fd40();
}
provider = *(int *)&Class$DotUnder.DMCryptography->Instance->field_0x4;
if (provider == 0) {
ThrowException(0);
}
RSACryptoServiceProvider$$Encrypt(provider,param_1,1,0);
return;
}
this is also familiar, old SIF used public key encryption and a random string of bytes too
if we look at the msdn documentation we find that the overloads for Encrypt are:
Encrypt(Byte[], Boolean)
Encrypts data with the RSA algorithm.Encrypt(Byte[], RSAEncryptionPadding)
Encrypts data with the RSA algorithm using the specified padding.
in our case, it's using the first overload and the last zero parameter is either incorrect decompilation or additional stuff generated by il2cpp
this function also tells us that offset 0x4 of DMCryptography is the RSACryptoServiceProvider instance
key size is 1024:
int * DMCryptography$$CreateRSAProvider(void)
{
int *provider;
if (DAT_037033d0 == '\0') {
FUN_008722e4(0x2bc4);
DAT_037033d0 = '\x01';
}
provider = (int *)Instantiate1(Class$System.Security.Cryptography.RSACryptoServiceProvider);
RSACryptoServiceProvider$$.ctor(provider,0x400,0);
if (provider == (int *)0x0) {
ThrowException(0);
}
(**(code **)(*provider + 0x118))(provider,rsaKey,*(undefined4 *)(*provider + 0x11c));
return provider;
}
the public rsa key is:
<RSAKeyValue><Modulus>v2VElqvCwrhdiXJRKerrlvfsnXS0L29uNtPhfK8SBfPludwYhfIPZupwhE3UcO0VZ8zQAXrzJ3Qgkw+qEOmtsNEKaCnk9uue/FAlrRqe+DRoNkNnx2BTAIU8rVZOPKjuFYgjd7JxbNAFEVNOp4jPfDCHBFJ4/b4+pDgZThr+CVk=</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>
DMCryptography's constructors tells me that the field i previously named Unk1 is an instance of RNGCryptoServiceProvider
void DMCryptography$$.cctor(void)
{
void *rngProvider;
undefined4 provider;
if (DAT_037033d1 == '\0') {
FUN_008722e4(0x2bcb);
DAT_037033d1 = '\x01';
}
rngProvider = (void *)Instantiate1(Class$System.Security.Cryptography.RNGCryptoServiceProvider);
RNGCryptoServiceProvider$$.ctor(rngProvider,0);
Class$DotUnder.DMCryptography->Instance->Unk1 = rngProvider;
provider = DMCryptography$$CreateRSAProvider();
*(undefined4 *)&Class$DotUnder.DMCryptography->Instance->rsaCryptoServiceProvider = provider;
return;
}
if we take a look at the CallMain method we find yet another piece of the puzzle. finally we get to construct the raw http request. it's a bit hard to read where it works with 64-bit integers (userId and time) but. I cleaned up and renamed everything
i renamed the string literal references to their contents for readability
void DMHttpApi$$Call(Array *path,Array *body,undefined4 displayClass13_0xc,byte displayClass13_0x8,
undefined displayClass13_0x18,Array *mv)
{
int displayClass13;
undefined4 tmpClass;
Array *requestId;
undefined4 pathWithQuery;
int hasUserId;
int hasValue;
ushort httpApi_0xbe;
undefined4 byteAction;
undefined4 callErrorAction;
DMHttpApi *httpApi;
uint uDisplayClass13_0x8;
int isGuarded;
byte *pDisplayClass13_0x8;
undefined8 milliTimestamp;
int64_t tmpValue;
undefined8 objMilliTimestamp;
undefined8 uStack56;
byte bDisplayClass13_0x8;
if (DAT_037033d4 == '\0') {
FUN_008722e4(0x2bce);
DAT_037033d4 = '\x01';
}
objMilliTimestamp = 0;
uStack56 = 0;
displayClass13 = Instantiate1(Class$DMHttpApi.__c__DisplayClass13_0);
Object$$.ctor(displayClass13,0);
if (displayClass13 == 0) {
ThrowException(0);
pDisplayClass13_0x8 = &DAT_00000008;
DAT_00000008 = displayClass13_0x8;
ThrowException(0);
_DAT_0000000c = displayClass13_0xc;
ThrowException(0);
}
else {
pDisplayClass13_0x8 = (byte *)(displayClass13 + 8);
*pDisplayClass13_0x8 = displayClass13_0x8;
*(undefined4 *)(displayClass13 + 0xc) = displayClass13_0xc;
}
*(undefined *)(displayClass13 + 0x18) = displayClass13_0x18;
pathWithQuery = "a";
tmpClass = Time$$get_realtimeSinceStartup(0);
if (displayClass13 == 0) {
ThrowException(0);
}
*(undefined4 *)(displayClass13 + 0x10) = tmpClass;
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
requestId = DMHttpApi$$CreateRequestId();
if (mv == (Array *)0x0) {
pathWithQuery = String$$Format("?p={0}&id=",pathWithQuery,0);
}
else {
pathWithQuery = String$$Format("?p={0}&mv={1}&id=",pathWithQuery,mv,0);
}
pathWithQuery = String$$Concat(path,pathWithQuery,0);
pathWithQuery = String$$Concat(pathWithQuery,requestId,0);
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
hasUserId = FUN_021f760c(Class$DotUnder.DMHttpApi->Instance,Method$Nullable_int_.get_HasValue());
if (hasUserId == 1) {
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
tmpValue._0_4_ =
FUN_021f7614(Class$DotUnder.DMHttpApi->Instance,Method$Nullable_int_.get_Value());
tmpClass = FUN_008ae744(Class$int,&tmpValue);
tmpClass = String$$Format("&u={0}",tmpClass,0);
pathWithQuery = String$$Concat(pathWithQuery,tmpClass,0);
}
Clock$$get_MilliTimestamp(&tmpValue,0);
objMilliTimestamp = CONCAT44(tmpValue._4_4_,(undefined4)tmpValue);
hasValue = FUN_021f8058(&objMilliTimestamp,Method$Nullable_long_.get_HasValue());
milliTimestamp = CONCAT44((undefined4)tmpValue,tmpValue._4_4_);
if (hasValue == 1) {
milliTimestamp = FUN_021f8060(&objMilliTimestamp,Method$Nullable_long_.get_Value());
tmpClass = FUN_008ae744(Class$long,&tmpValue);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
tmpClass = String$$Format("&t={0}",tmpClass,0);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
pathWithQuery = String$$Concat(pathWithQuery,tmpClass,0);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
}
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
}
tmpClass = DMHttpApi$$MakeRequestData(pathWithQuery,body);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
bDisplayClass13_0x8 = *pDisplayClass13_0x8;
uDisplayClass13_0x8 = (uint)bDisplayClass13_0x8;
if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
}
if (bDisplayClass13_0x8 != 0) {
uDisplayClass13_0x8 = 1;
}
HttpSubject$$OnStart(uDisplayClass13_0x8);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
if (*pDisplayClass13_0x8 != 0) {
httpApi_0xbe = *(ushort *)&Class$DotUnder.DMHttpApi->field_0xbe;
if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
httpApi_0xbe = *(ushort *)&Class$DotUnder.DMHttpApi->field_0xbe;
}
httpApi = Class$DotUnder.DMHttpApi->Instance;
isGuarded = httpApi->IsGuarded;
if (isGuarded != 0) {
if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
isGuarded = Class$DotUnder.DMHttpApi->Instance->IsGuarded;
}
if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
}
HttpSubject$$OnDuplex(isGuarded,path);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
return;
}
if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
httpApi = Class$DotUnder.DMHttpApi->Instance;
}
*(Array **)&httpApi->IsGuarded = path;
}
*(undefined4 *)(displayClass13 + 0x1c) = 0;
*(undefined4 *)(displayClass13 + 0x14) = 0;
byteAction = Instantiate1(Class$Action_byte[]_);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
FUN_023a2ed4(byteAction,displayClass13,_Method$DMHttpApi.__c__DisplayClass13_0._Call_b(void),
Method$Action_byte[]_..ctor());
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
callErrorAction =
Instantiate1(Class$Action_DMHttpApi.CallError_-int_-HttpSubject.MessageObject_-Action_);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
Action$$.ctor(callErrorAction,displayClass13,Method$DMHttpApi.__c__DisplayClass13_0._Call_b__1(),
Method$Action_DMHttpApi.CallError_-int_-HttpSubject.MessageObject_-Action_..ctor());
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
}
DMHttpApi$$CallMain(pathWithQuery,tmpClass,byteAction,callErrorAction);
tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20);
tmpValue._4_4_ = (undefined4)milliTimestamp;
return;
}
here we can see how it constructs the url, which can contain these query params
- p: always seems to be "a". maybe short for platform = android and hardcoded at compile time?
- mv (optional): not sure yet, it's passed to the function
- id: always 0?
- u (optional): the user id, stored in the httpapi instance
we already looked at MakeRequestData earlier and how it uses a sha1 hash from CalcDigest, but now we know that the first 2 params passed to CalcDigest are the url path and the request body
Array * DMHttpApi$$MakeRequestData(Array *pathWithQuery,Array *body)
{
System.Text.Encoding *utf8;
Array *digest;
Array *digest_;
Array *digestBytes;
undefined4 uVar1;
int len;
uint digestLength;
if (DAT_037033dc == '\0') {
FUN_008722e4(0x2bd4);
DAT_037033dc = '\x01';
}
utf8 = Encoding$$get_UTF8((System.Text.Encoding *)0x0);
if (body == (Array *)0x0) {
ThrowException(0);
}
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
digest = DMHttpApi$$CalcDigest(pathWithQuery,body,0,body->Length);
if (utf8 == (System.Text.Encoding *)0x0) {
ThrowException(0);
}
/* probably a GetBytes call */
digest_ = (Array *)(*utf8->vtable->DoSomething)(utf8,digest,utf8->vtable->SomePredicateFunc);
if (digest_ == (Array *)0x0) {
ThrowException(0);
}
digestBytes = (Array *)Instantiate(Class$byte[],body->Length + digest_->Length + 5);
if (digestBytes == (Array *)0x0) {
ThrowException(0);
}
if (digestBytes->Length == 0) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
digestBytes->Data[0] = '[';
Array$$CopyTo(body,digestBytes,1,0);
len = body->Length;
if ((uint)digestBytes->Length <= len + 1U) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
digestBytes->Data[len + 1] = ',';
len = body->Length;
if ((uint)digestBytes->Length <= len + 2U) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
digestBytes->Data[len + 2] = '\"';
Array$$CopyTo(digest_,digestBytes,body->Length + 3,0);
digestLength = digestBytes->Length;
if (digestLength < 2) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
/* Data[digestLength - 2] = '"' */
*(undefined *)((int)&digestBytes->Length + digestLength + 2) = 0x22;
len = digestBytes->Length;
if (len == 0) {
uVar1 = FUN_0089ec04();
ThrowSomeOtherException(uVar1,0,0);
}
/* Data[digestLength - 1] = ']' */
*(undefined *)((int)&digestBytes->Length + len + 3) = 0x5d;
return digestBytes;
}
from here we know what the raw request body looks like this:
[request body,"hash"]
the request body is probably a json object
now I want to look around HttpSubject to see if we can figure out what http headers it's using
from a quick look at OnStart it appears that the parameter passed to it is a simple boolean to skip the CreateGuard call
void HttpSubject$$OnStart(bool createGuardObject)
{
if (DAT_037033fa == '\0') {
FUN_008722e4(0x4f22);
DAT_037033fa = '\x01';
}
if (createGuardObject != true) {
return;
}
if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) {
FUN_0087fd40();
}
HttpSubject$$CreateGuardObject();
return;
}
ok, after taking a look around the other HttpSubject methods it seems that
this has more to do with displaying the loading screen than sending the
request. let's look at Network$$PostJson
instead
void Network$$PostJson(Array *url,Array *json,undefined4 response)
{
if (DAT_036ffb92 == '\0') {
FUN_008722e4(0x70b4);
DAT_036ffb92 = '\x01';
}
if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) {
FUN_0087fd40();
}
NetworkAndroid$$PostJson(url,json,response);
return;
}
undefined4 NetworkAndroid$$PostJson(Array *url,Array *json,undefined4 response)
{
int proxy;
undefined4 proxyAddress;
int proxyHasAddress;
int *params;
int iVar1;
undefined4 uVar2;
int proxyHost;
undefined4 uStack80;
undefined4 proxyPort;
undefined8 guid;
undefined8 uStack64;
undefined8 guid_;
undefined8 uStack48;
if (DAT_036ffb98 == '\0') {
FUN_008722e4(0x7097);
DAT_036ffb98 = '\x01';
}
guid_ = 0;
uStack48 = 0;
if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) {
FUN_0087fd40();
}
NetworkAndroid$$Initialize();
proxyPort = 0;
proxy = Config$$get_Proxy(0);
if (proxy == 0) {
proxyHost = 0;
}
else {
proxyPort = 0;
proxyAddress = WebProxy$$get_Address(proxy,0);
if (((*(byte *)(Class$System.Uri + 0xbf) & 2) != 0) && (*(int *)(Class$System.Uri + 0x70) == 0))
{
FUN_0087fd40();
}
proxyHasAddress = Uri$$op_Inequality(proxyAddress,0,0);
proxyHost = 0;
if (proxyHasAddress == 1) {
proxyHost = WebProxy$$get_Address(proxy,0);
if (proxyHost == 0) {
ThrowException(0);
}
proxyHost = Uri$$get_Host(proxyHost,0);
proxy = WebProxy$$get_Address(proxy,0);
if (proxy == 0) {
ThrowException(0);
}
proxyPort = Uri$$get_Port(proxy,0);
}
}
if (((*(byte *)(Class$System.Guid + 0xbf) & 2) != 0) && (*(int *)(Class$System.Guid + 0x70) == 0))
{
FUN_0087fd40();
}
Guid$$NewGuid(&guid,0);
guid_ = guid;
uStack48 = uStack64;
proxy = Guid$$ToString(&guid_,0);
proxyAddress = Instantiate1(Class$Network.Connection);
Network.Connection$$.ctor(proxyAddress,proxy,response);
if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) {
FUN_0087fd40();
}
proxyHasAddress = *(int *)(*(int *)(Class$DotUnder.NetworkAndroid + 0x5c) + 8);
if (proxyHasAddress == 0) {
ThrowException(0);
}
FUN_0237432c(proxyHasAddress,proxy,proxyAddress,
Method$Dictionary_string_-Network.Connection_.Add());
params = (int *)Instantiate(Class$object[],7);
proxyHasAddress = *(int *)(*(int *)(Class$DotUnder.NetworkAndroid + 0x5c) + 4);
if (params == (int *)0x0) {
ThrowException(0);
}
if ((proxyHasAddress != 0) &&
(iVar1 = FUN_008aea80(proxyHasAddress,*(undefined4 *)(*params + 0x20)), iVar1 == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if (params[3] == 0) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
params[4] = proxyHasAddress;
if ((proxy != 0) &&
(proxyHasAddress = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHasAddress == 0))
{
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 2) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
params[5] = proxy;
if ((url != (Array *)0x0) &&
(proxy = FUN_008aea80(url,*(undefined4 *)(*params + 0x20)), proxy == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 3) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
*(Array **)(params + 6) = url;
if ((json != (Array *)0x0) &&
(proxy = FUN_008aea80(json,*(undefined4 *)(*params + 0x20)), proxy == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 4) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
*(Array **)(params + 7) = json;
if ((proxyHost != 0) &&
(proxy = FUN_008aea80(proxyHost,*(undefined4 *)(*params + 0x20)), proxy == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 5) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
params[8] = proxyHost;
proxy = FUN_008ae744(Class$int,&proxyPort);
if ((proxy != 0) &&
(proxyHost = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHost == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 6) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
params[9] = proxy;
uStack80 = 10;
proxy = FUN_008ae744(Class$int,&uStack80);
if ((proxy != 0) &&
(proxyHost = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHost == 0)) {
uVar2 = FUN_0089f30c();
ThrowSomeOtherException(uVar2,0,0);
}
if ((uint)params[3] < 7) {
uVar2 = FUN_0089ec04();
ThrowSomeOtherException(uVar2,0,0);
}
params[10] = proxy;
NetworkAndroid$$CallStaticOnMainThread("postJson",params);
return proxyAddress;
}
this seems lengthy, but it's mainly because it's calling into java
the first part checks if a proxy is set and extracts host and port. then it generates a guid and reuses the proxy temp vars for the stringified guid and the connection, which is a bit confusing. the only proxy information that is retained is proxyHost and proxyPort.
the Guid$$ToString
call wasn't originally named. i figured out what it
was by googling strings used inside the function and finding it in
microsoft's dotnet github repo
it seems that network connections are identified by a guid. it's probably android stuff we don't care about
next, an array of generic objects is created and all the info previously retrieved is packed into it. this data is then passed to java and postJson is called
time to go back to java code
I think we're finally at the end of the chain for http requests, it's using OkHttp, an open source library, under the hood:
void postJson(PostJson this,NetworkListener p1,String p2,String p3,byte[] p4,String p5,int p6,int p7
)
{
OkHttpClient$Builder ref;
int iVar1;
MediaType contentType;
RequestBody pRVar2;
Request pRVar3;
Call ref_00;
OkHttpClient local_0;
Proxy ref_01;
Proxy$Type pPVar4;
SocketAddress ref_02;
Map ref_03;
Callback ref_04;
Request$Builder ref_05;
local_0 = this.mHttpClient;
ref = local_0.newBuilder();
ref = ref.connectTimeout((long)p7 & -0x100000000 | ZEXT48(p7),TimeUnit.SECONDS);
ref = ref.readTimeout((long)p7,TimeUnit.SECONDS);
ref = ref.writeTimeout((long)p7,TimeUnit.SECONDS);
if ((p5 != null) && (iVar1 = p5.length(), 0 < iVar1)) {
pPVar4 = Proxy$Type.HTTP;
ref_02 = new SocketAddress(p5,p6);
ref_01 = new Proxy(pPVar4,ref_02);
ref = ref.proxy(ref_01);
}
local_0 = ref.build();
contentType = MediaType.parse("application/json");
pRVar2 = PostJsonRequestBody.create(contentType,p4);
ref_05 = new Request$Builder();
ref_05 = ref_05.url(p3);
ref_05 = ref_05.post(pRVar2);
pRVar3 = ref_05.build();
ref_00 = local_0.newCall(pRVar3);
ref_03 = this.mRequestList;
ref_03.put(p2,ref_00);
ref_04 = new Callback(this,p2,p1);
ref_00.enqueue(ref_04);
return;
}
we can easily figure out that p4 is offset: https://github.com/square/okhttp/blob/c4f338ec172411975c9c0f05c7f48fc1b3dca715/okhttp/src/main/java/okhttp3/RequestBody.kt#L131
from the part where it uses SocketAddress, which is documented here we can figure out that p5 and p6 are addr and port for the proxy
in the last part it adds the request to a map called mRequestList. p2 appears to be the string key that identifies this request
then it enqueues the request with a custom callback. if we look at the PostJsonCallback constructor we have names for all the parameters:
void PostJson$PostJsonCallback
(PostJson$PostJsonCallback this,PostJson p1,String p2,NetworkListener p3)
{
this.this$0 = p1;
this.<init>();
this.taskId = p2;
this.listener = p3;
return;
}
this confirms that the map key is taskId
here is postJson again, but now we've named everything:
void postJson(PostJson this,NetworkListener listener,String taskId,String url,byte[] body,
String proxyAddr,int proxyPort,int timeout)
{
OkHttpClient$Builder clientBuilder;
int proxyAddrLen;
MediaType contentType;
RequestBody body;
Request request;
Call call;
OkHttpClient httpClient;
Proxy proxy;
Proxy$Type proxyType;
SocketAddress proxySocketAddress;
Map requests;
Callback callback;
Request$Builder requestBuilder;
httpClient = this.mHttpClient;
clientBuilder = httpClient.newBuilder();
clientBuilder =
clientBuilder.connectTimeout((long)timeout & -0x100000000 | ZEXT48(timeout),TimeUnit.SECONDS)
;
clientBuilder = clientBuilder.readTimeout((long)timeout,TimeUnit.SECONDS);
clientBuilder = clientBuilder.writeTimeout((long)timeout,TimeUnit.SECONDS);
if ((proxyAddr != null) && (proxyAddrLen = proxyAddr.length(), 0 < proxyAddrLen)) {
proxyType = Proxy$Type.HTTP;
proxySocketAddress = new SocketAddress(proxyAddr,proxyPort);
proxy = new Proxy(proxyType,proxySocketAddress);
clientBuilder = clientBuilder.proxy(proxy);
}
httpClient = clientBuilder.build();
contentType = MediaType.parse("application/json");
body = PostJsonRequestBody.create(contentType,body);
requestBuilder = new Request$Builder();
requestBuilder = requestBuilder.url(url);
requestBuilder = requestBuilder.post(body);
request = requestBuilder.build();
call = httpClient.newCall(request);
requests = this.mRequestList;
requests.put(taskId,call);
callback = new Callback(this,taskId,listener);
call.enqueue(callback);
return;
}
so it seems that there aren't any particular headers we should be aware of. I guess everything is packed in the json object
so let's take the startup request for example. we can figure out what to
send by looking at Serialization$$SerializeStartupRequest
the json body will be something like:
{"mask":"blahblah","resemara_detection_identifier":"123abc","time_difference":123}
and by looking at Serialization$$DeserializeStartupResponse
we can figure
out that we will receive something like:
{"user_id":123,"authorization_key":"123abc"}
of course this will all be wrapped in that json array with the hash we analyzed earlier
but what is "mask" ? let's take another look at DMHttpApi$$Login
UserKey$$GetID(&userId,0);
password = UserKey$$GetPW(0);
iUserId = FUN_021f760c(&userId,Method$Nullable_int_.get_HasValue());
if (password == 0 || iUserId == 0) {
password = Instantiate1(Class$DMHttpApi.__c__DisplayClass14_2);
Object$$.ctor(password,0);
if (password == 0) {
ThrowException(0);
}
*(int *)(password + 0xc) = iVar1;
if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) &&
(Class$DotUnder.DMCryptography->Unk1 == 0)) {
FUN_0087fd40();
}
data = (Array *)DMCryptography$$RandomBytes(0x20);
*(Array **)(password + 8) = data;
mask = DMCryptography$$PublicEncrypt(data);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087fd40();
}
mask = Convert$$ToBase64String(mask,0);
data = (Array *)Config$$get_StartupKey(0);
if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) {
FUN_0087fd40();
}
Class$DotUnder.DMHttpApi->Instance->SessionKey = data;
uVar2 = Instantiate1(Class$Action_StartupRequest_);
FUN_023a2ed4(uVar2,password,Method$DMHttpApi.__c__DisplayClass14_2._Login_b__3(),
Method$Action_StartupRequest_..ctor());
StartupRequestBuilder$$Create(mask,uVar2,0);
}
this is essentially the part where it checks if we have an account, and if we don't it creates a new one
again this is confusing because it's reusing variables for multiple things
but as you can see i was able to figure out that the first param of
StartupRequestBuilder$$Create
is mask by mapping out the struct with
getters/setters as explained before, and it's generated by encrypting
random bytes with the public key we saw before and then encoding it as
base64
we can also see that it's setting the initial SessionKey to the StartupKey
which as seen earlier is i0qzc6XbhFfAxjN2
this is a good time to try and MITM the http requests that are actually sent to see if we're right. we need root to bypass ssl pinning
I used this guide to install magisk and hide root on android x86 https://asdasd.page/2018/02/18/Install-Magisk-on-Android-x86/
which consists of
- copying kernel and ramdisk.img from the android partition to a linux machine
mkbootimg --kernel kernel --ramdisk ramdisk.img --output boot.img
- copy boot.img back to android device
sudo cp boot.img /mnt/ssd/android-8.1-r2/data/media/0/Download/
- patch boot.img with MagiskManager
- copy patched_boot.img back to linux
sudo cp /mnt/ssd/android-8.1-r2/data/media/0/Download/magisk_patched.img .
abootimg -x magisk_patched.img
- rename zImage to kernel and overwrite the one in android partition
- Rename initrd.img to ramdisk.img and overwrite in android partition
now magisk should be installed. enable magisk hide from the settings and then from the magisk hide menu, toggle it on for love live
at this point i was planning to install riru and edxposed to then use trustmealready to disable ssl pinning and be able to use a mitm proxy
unfortunately magisk was failing to mount some stuff and modules weren't working. at least root is working and it's hidden from the game. we can work with this. I could try the same thing i did with old sif which was to write a library to inject and hook game functions to log requests
so the first thing i look for is a simple library with few exports that is
loaded after the game. libKLab.NativeInput.Native.so
looks like a good
candidate. it looks like it's called from java and exports a handful of
Java_com_klab*
functions which are probably the only ones we would need
to export to replace it.
the idea is, you replace the library, export the same functions, and under the hood you load the original library and forward all calls to it while you inject your own initialization into a function of your choice
the injected code in this case would hook MakeRequestData, redirecting it to a function that calls the real MakeRequestData and prints the json object to android's logcat
to avoid generating repetitive dlopen/dlsym code for each export which would just make the binary larger for no good reason, I define the exports to be just placeholder jmp's at compile time, then at runtime it goes through the list of functions and replaces the placeholder jmps with jmps to the original library
as a first test, I just make the stub library do absolutely nothing, just to see if it works
we must also decide where to initialize our stub library. onInitialize seems like a good candidate, as we can see from the disassembly it takes a single param:
void Java_com_klab_nativeinput_NativeInputJava_onInitialize(JNIEnv *env)
{
jclass p_Var1;
if (env != (JNIEnv *)0x0) {
sJEnv = env;
p_Var1 = (*env->functions->FindClass)(env,"com/klab/nativeinput/NativeInputJava");
sJClass = (jclass)(*env->functions->NewGlobalRef)(env,(jobject)p_Var1);
/* WARNING: Could not recover jumptable at 0x00015ac2. Too many branches */
/* WARNING: Treating indirect jump as call */
(*sJEnv->functions->GetStaticMethodID)(sJEnv,sJClass,"NativeInputGetTimestamp","()D");
return;
}
return;
}
so here's my hello world lib:
#include <android/log.h>
#include <dlfcn.h>
#include <sys/mman.h>
#include <sys/sysconf.h>
#define log(x) __android_log_write(ANDROID_LOG_DEBUG, __FILE__, x);
#define java_func(func) \
Java_com_klab_nativeinput_NativeInputJava_##func
#define exports(macro) \
macro(java_func(clearTouch)) \
macro(java_func(lock)) \
macro(java_func(onFinalize)) \
macro(java_func(stockDeviceButtons)) \
macro(java_func(stockNativeTouch)) \
macro(java_func(testOverrideFlgs)) \
macro(java_func(unlock)) \
/*
I decided to go with absolute jmp's. since arm doesn't allow 32-bit
immediate jumps I have to place the address right after the jmp and
reference it using [pc,#-4]. pc is 8 bytes after the current instruction,
so #-4 reads 4 bytes after the current instruction.
0xBAADF00D is then replaced by the correct address at runtime
*/
#define define_trampoline(name) \
void __attribute__((naked)) name() { \
asm("ldr pc,[pc,#-4]"); \
asm(".word 0xBAADF00D"); \
}
/* runs define_trampoline on all functions listed in exports */
exports(define_trampoline)
#define stringify_(x) #x
#define stringify(x) stringify_(x)
#define to_string_array(x) stringify(x),
static char* export_names[] = { exports(to_string_array) 0 };
void (*_onInitialize)(void* env);
/*
make memory readadable, writable and executable. size is
ceiled to a multiple of PAGESIZE and addr is aligned to
PAGESIZE
*/
#define PROT_RWX (PROT_READ | PROT_WRITE | PROT_EXEC)
#define PAGESIZE sysconf(_SC_PAGESIZE)
#define PAGEOF(addr) (void*)((int)(addr) & ~(PAGESIZE - 1))
#define PAGE_ROUND_UP(x) \
((((int)(x)) + PAGESIZE - 1) & (~(PAGESIZE - 1)))
#define munprotect(addr, n) \
mprotect(PAGEOF(addr), PAGE_ROUND_UP(n), PROT_RWX)
static
void init() {
char** s;
void *original, *stub;
log("hello from the stub library!");
original = dlopen("libKLab.NativeInput.Native.so.bak", RTLD_LAZY);
stub = dlopen("libKLab.NativeInput.Native.so", RTLD_LAZY);
for (s = export_names; *s; ++s) {
void** stub_func = dlsym(stub, *s);
log(*s);
munprotect(&stub_func[1], sizeof(void*));
stub_func[1] = dlsym(original, *s);
}
*(void**)&_onInitialize =
dlsym(original, stringify(java_func(onInitialize)));
}
void java_func(onInitialize)(void* env) {
init();
_onInitialize(env);
}
I build it with this script:
#!/bin/sh
CFLAGS="-fPIC -Wall $CFLAGS"
LDFLAGS="-shared -llog -ldl $LDFLAGS"
[ -z "$CC" ] &&
echo "please set CC to your android toolchain compiler" && exit 1
$CC $CFLAGS sniffas.c $LDFLAGS -o libKLab.NativeInput.Native.so
remember to download the android standalone toolchain and point CC to it
export CC=~/android-ndk-r20/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang
./build.sh
then i copy it to android and replace the original library, making sure to keep permissions
adb root
adb push libKLab.NativeInput.Native.so /data/app/
adb shell
cd /data/app/com.klab.lovelive.allstars-*/lib/arm/
mv libKLab.NativeInput.Native.so{,.bak}
mv /data/app/libKLab.NativeInput.Native.so .
chmod 755 libKLab.NativeInput.Native.so
chown system:system libKLab.NativeInput.Native.so
exit
and sure enough, if we start the game and look at logcat we see:
10-18 21:57:03.260 21620 21645 D sniffas.c: hello from the stub library!
10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch
10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock
10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize
10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons
10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch
10-18 21:57:03.261 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs
10-18 21:57:03.261 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock
and the game runs just fine
let's try to hook MakeRequestData now
first of all we need to know its relative address in memory, from where il2cpp stars. just hover over the address in ghidra, you want the "Imagebase Offset"
to get the address of the function in memory we can just add this offset
to the base address of il2cpp.so which we can obtain with dladdr
and a
known export. I picked one at random from ghidra's list of exports
il2cpp = dlopen("libil2cpp.so", RTLD_LAZY);
il2cpp_export = dlsym(il2cpp, "UnityAdsEngineInitialize");
dladdr(il2cpp_export, &dli);
sprintf(buf, "il2cpp at %p", dli.dli_fbase);
log(buf);
you can go all fancy and dynamically search for the function's byte pattern but for now I'm just gonna hardcode the address
let's print the first 8 bytes at MakeRequestData to check that we are indeed getting the right address
/* log first 8 bytes at MakeRequestData to check that we got it right */
p = buf;
MakeRequestData = (char*)dli.dli_fbase + 0xEFCDDC;
p += sprintf(p, "MakeRequestData at %p: ", MakeRequestData);
for (i = 0; i < 8; ++i) {
p += sprintf(p, "%02x ", MakeRequestData[i]);
}
log(buf);
sure enough, we get:
10-19 01:31:26.569 28198 28223 D sniffas.c: il2cpp at 0x8000000
10-19 01:31:26.569 28198 28223 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2
which matches ghidra:
**************************************************************
* FUNCTION *
**************************************************************
undefined DMHttpApi$$MakeRequestData()
undefined r0:1 <RETURN>
DMHttpApi$$MakeRequestData XREF[2]: DMHttpApi$$Call:00f0cab0(c),
034637e4(*)
00f0cddc f0 48 2d e9 stmdb sp!,{ r4 r5 r6 r7 r11 lr }
00f0cde0 10 b0 8d e2 add r11,sp,#0x10
okay, let's define our hook function and a global function pointer we will use to call the original function
static void* (*original_MakeRequestData)(void* pathWithQuery, void* body);
static
void* hooked_MakeRequestData(void* pathWithQuery, void* body) {
log("hello from MakeRequestData!");
return original_MakeRequestData(pathWithQuery, body);
}
so, what exactly do we need to do to hook this function? it's actually really simple. we overwrite the function's code with a jump to our own function
the only tricky part is calling the original function, which we just overwrote. the solution I use is to copy the original code somewhere else and slap a jump that goes back to the original function, right after the jump we wrote. some people call this a "trampoline".
to explain this more visually, this is how the code looks like before hooking
MakeRequestData:
stmdb sp!,{ r4 r5 r6 r7 r11 lr }
add r11,sp,#0x10
sub sp,sp,#0x8
cpy r5,r0
...
after hooking
original_MakeRequestData:
stmdb sp!,{ r4 r5 r6 r7 r11 lr }
add r11,sp,#0x10
jmp MakeRequestData_continue
MakeRequestData:
jmp hooked_MakeRequestData
MakeRequestData_continue:
sub sp,sp,#0x8
cpy r5,r0
...
the actual jump is not gonna look like that though, in ARM we need to do a weird absolute jump you've seen in my initial stub library code
so let's implement this hook!
here's where I generate the trampoline
*(void**)&original_MakeRequestData = malloc(8 + 8);
code = (unsigned*)original_MakeRequestData;
munprotect(code, 8);
memcpy(code, MakeRequestData, 8);
code[2] = 0xE51FF004; /* ldr pc,[pc,#-4] */
code[3] = (unsigned)MakeRequestData + 8;
and here's where I overwrite the original function's code
code = (unsigned*)MakeRequestData;
munprotect(code, 8);
code[0] = 0xE51FF004; /* ldr pc,[pc,#-4] */
code[1] = (unsigned)hooked_MakeRequestData;
if we run this and check logcat after tapping the main menu and logging into the game, we get:
10-19 01:52:19.220 28588 28613 D sniffas.c: hello from the stub library!
10-19 01:52:19.220 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch
10-19 01:52:19.220 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock
10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize
10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons
10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch
10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs
10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock
10-19 01:52:19.222 28588 28613 D sniffas.c: il2cpp at 0x8000000
10-19 01:52:19.222 28588 28613 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2
10-19 01:52:40.523 28588 28613 D sniffas.c: hello from MakeRequestData!
10-19 01:52:42.410 28588 28613 D sniffas.c: hello from MakeRequestData!
10-19 01:52:47.239 28588 28613 D sniffas.c: hello from MakeRequestData!
the hard part is done! now we can simply log the request data. we already know the key field offsets for C#'s Array struct
typedef struct {
char unknown[12];
int Length;
char data[1]; /* actually Length bytes */
} Array;
static
void Array_log_ascii(Array* arr) {
char* buf = malloc(arr->Length + 1);
memcpy(buf, arr->Data, arr->Length);
buf[arr->Length] = 0;
log(buf);
free(buf);
}
however, pathWithQuery is most likely a String. let's reverse engineer the String layout real quick. from String$$Copy we can instantly tell Length is at offset 0x8 and data is at 0xC
int String$$Copy(int param_1)
{
int iVar1;
undefined4 uVar2;
int iVar3;
if (DAT_037078b1 == '\0') {
FUN_008722e4(0x94b7);
DAT_037078b1 = '\x01';
}
if (param_1 != 0) {
iVar3 = *(int *)(param_1 + 8);
iVar1 = thunk_FUN_008c05c4(iVar3);
if (iVar1 == 0) {
ThrowException(0);
}
Buffer$$Memcpy(iVar1 + 0xc,param_1 + 0xc,iVar3 << 1,0);
return iVar1;
}
uVar2 = Instantiate1(Class$System.ArgumentNullException);
ArgumentNullException$$.ctor(uVar2,"str",0);
ThrowSomeOtherException(uVar2,0,Method$String.Copy());
uVar2 = caseD_15();
return uVar2;
}
here's our String struct
typedef struct {
char unknown[8];
int Length;
char data[1]; /* actually Length bytes */
} String;
we have another problem though, the default encoding for strings in .net is UTF16LE. I'm just gonna truncate it to ascii for now and change data to an array of unsigned short's
typedef struct {
char unknown[8];
int Length;
unsigned short Data[1];
} String;
/* truncate to ascii. good enough for now */
static
void String_log(String* str) {
int i;
char* buf = malloc(str->Length + 1);
for (i = 0; i < str->Length; ++i) {
buf[i] = (char)str->Data[i];
}
buf[str->Length] = 0;
log(buf);
free(buf);
}
update hook to log the requests:
static
Array* (*original_MakeRequestData)(String* pathWithQuery, Array* body);
static
Array* hooked_MakeRequestData(String* pathWithQuery, Array* body) {
Array* res;
String_log(pathWithQuery);
res = original_MakeRequestData(pathWithQuery, body);
Array_log_ascii(res);
return res;
}
let's start the game, tap the main screen, and...
10-19 02:50:00.968 31593 31618 D sniffas.c: hello from the stub library!
10-19 02:50:00.968 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch
10-19 02:50:00.968 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock
10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize
10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons
10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch
10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs
10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock
10-19 02:50:00.969 31593 31618 D sniffas.c: il2cpp at 0x8000000
10-19 02:50:00.969 31593 31618 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2
10-19 02:50:21.240 31593 31618 D sniffas.c: /login/login?p=a&id=1&u=CENSORED_USER_ID
10-19 02:50:21.241 31593 31618 D sniffas.c: [{"user_id":CENSORED_USER_ID,"auth_count":CENSORED_AUTH_COUNT,"mask":"CENSORED_MASK","asset_state":"CENSORED_ASSET_STATE"},"CENSORED_HASH"]
10-19 02:50:22.850 31593 31618 D sniffas.c: /bootstrap/fetchBootstrap?p=a&mv=CENSORED_MV&id=2&u=CENSORED_USER_ID&t=CENSORED_TIME_1
10-19 02:50:22.850 31593 31618 D sniffas.c: [{"bootstrap_fetch_types":[2,3,4,5,9,10],"device_token":"CENSORED_DEVICE_TOKEN","device_name":"Censored device name"},"CENSORD_HASH_2"]
10-19 02:50:27.047 31593 31618 D sniffas.c: /notice/fetchNotice?p=a&mv=CENSORED_MV&id=3&u=CENSORED_USER_ID&t=CENSORED_TIME_2
10-19 02:50:27.047 31593 31618 D sniffas.c: [null,"CENSORED_HASH"]
hell yeah. I had to censor pretty much everything in the data but you get the idea. it's how we predicted it
hooking MakeRequestData might've been a mistake though, we can't log the
response like this. let's hook Network$$PostJson
instead. it has all
the same info we're logging now, plus the response.
this is where it constructs the response in CallMain
response = Instantiate1(Class$Action_Network.Response_);
FUN_023a2ed4(response,displayClass20,Method$DMHttpApi.__c__DisplayClass20_0._CallMain_b__1(),
Method$Action_Network.Response_..ctor());
displayClass20 = Network$$PostJson(url,json,response);
a quick search for Network.Response yields the following fields
undefined4 Network.Response$$get_Status(int param_1)
{
return *(undefined4 *)(param_1 + 8);
}
undefined4 Network.Response$$get_Bytes(int param_1)
{
return *(undefined4 *)(param_1 + 0xc);
}
uint Network.Response$$get_IsTimeout(int param_1)
{
return (uint)*(byte *)(param_1 + 0x10);
}
uint Network.Response$$get_IsNetworkError(int param_1)
{
return (uint)*(byte *)(param_1 + 0x11);
}
undefined4 Network.Response$$get_ErrorMessage(int param_1)
{
return *(undefined4 *)(param_1 + 0x14);
}
I wasn't sure about the Bytes type so I looked around some more, found this in postJsonCallback
uVar2 = AndroidJavaObject$$GetRawObject(piVar1,0);
uVar2 = AndroidJNIHelper$$ConvertFromJNIArray
(uVar2,Method$AndroidJNIHelper.ConvertFromJNIArray()_byte[]_);
...
iVar3 = Instantiate1(Class$Network.Response);
Object$$.ctor(iVar3,0);
*(undefined4 *)(iVar3 + 8) = param_3;
*(undefined4 *)(iVar3 + 0xc) = uVar2;
*(undefined *)(iVar3 + 0x11) = param_6;
*(undefined *)(iVar3 + 0x10) = param_5;
*(undefined4 *)(iVar3 + 0x14) = param_7;
it should be an array of bytes
now the tricky part is, it's actually receiving Action<Network.Response>
,
not just the struct. this means that somewhere down the line, this action
gets invoked and the Response object we want is created
yeah, I guess we won't be able to log the response from here. we'll need another hook
let's hook Network.Response$$get_Bytes
. something is bound to call it
when a response is received
these are the hooks I ended up with
typedef struct {
char unknown[8];
int Status;
Array* Bytes;
char isTimeout;
char isNetworkError;
String* ErrorMessage;
} Response;
static
void (*original_PostJson)(String* url, Array* body, void* delegate,
void* unk);
static
void hooked_PostJson(String* url, Array* body, void* delegate, void* unk) {
String_log(url);
Array_log_ascii(body);
original_PostJson(url, body, delegate, unk);
}
static
Array* (*original_get_Bytes)(Response* resp);
static
Array* hooked_get_Bytes(Response* resp) {
char buf[512];
sprintf(buf, "[%p] %p", __builtin_return_address(0), resp);
log(buf);
Array_log_ascii(resp->Bytes);
return original_get_Bytes(resp);
}
and if we run now, we get:
amazing! now we can see all traffic
you can check out the full stub library source code here
some people would have used a mitm http proxy here, however the game is
very picky about it and most likely is ssl pinning, so this is much easier
for me and lets me log other info too, for example i can log where any
given function is called from, even with complicated indirect calls. you
just have to format and log __builtin_return_address(0)
from the hook
let's try to put this all together and craft a startup request and see what the server thinks of it.
first of all, this is how you reset your linked account and force the game to create a new one. this is the equivalent of what is described here https://www.reddit.com/r/SchoolIdolFestival/comments/da5g2x/how_to_reroll_sifas_without_deleting_the_whole/f1pe67m/ except it's all automatic
mv /data/data/com.klab.lovelive.allstars{,.bak}
pm clear com.klab.lovelive.allstars
mv /data/data/com.klab.lovelive.allstars{.bak,}
from the requests log it seems like it first prompts you to log with
a google id and then calls /dataLink/fetchGameServiceDataBeforeLogin
which either returns already linked data for that account or null, in
which case the game proceeds with the startup request to create a new
account, but I think we can bypass that
looking at BaseRule1$$SendMain
i notice that the mv parameter in the
query string is actually referred to as MasterVersion, in the disassembly,
we will hardcode it for now
the code that generates the time_difference field is confusing, not sure why it creates a 2017-01-01 date, but from the logs it seems to be 3600 for me so I'm guessing it's the offset from utc or something. I'll just hardcode it for now
I decided to first do a quick test in kotlin using the same OkHttp library to be as close as possible to the game
I ran into a stupid issue that had me banging my head on my keyboard for
an entire day - the server bails out with a 500 error if your
content-type
is application/json; charset=utf-8
instead of just
application/json
. OkHttp automatically adds the charset if you call
toRequestBody
this is the code I ended up with, way more elaborate than it needs to for this simple test, but you have to keep in mind I was troubleshooting this with different requests all day
for PublicEncrypt we want OAEP padding because the bool passed to
RSACryptoServiceProvider$$Encrypt
is true (check the msdn docs)
since java and kotlin can't handle .net xml keys i converted it to PEM using this tool https://gist.github.com/Francesco149/8c6288a853dd010a638892be2a2c48af
OAEP padding is randomized so don't worry if the encrypted data looks different for the same input
for md5 i pretty much copied the game's code 1:1 even though it's unnecessary to go through BigInteger
for the resemara detection id I generated a random uuid instead of using a real google advertising id and hashed it with the package name as the game does. the server happily accepted it. a random md5 hash would probably work too
I'm not sure yet what the mask field even does since it's random bytes, maybe it's just there so the server can verify the signature
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.security.*
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.crypto.Mac
import java.util.Base64
import java.util.UUID
import java.math.BigInteger
import kotlin.random.Random
const val ServerEndpoint = "https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1010"
const val StartupKey = "G5OdK4KdQO5UM2nL"
const val RSAPublicKey = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/ZUSWq8LCuF2JclEp6uuW9+yddLQvb2420+F8
rxIF8+W53BiF8g9m6nCETdRw7RVnzNABevMndCCTD6oQ6a2w0QpoKeT26578UCWtGp74NGg2Q2fH
YFMAhTytVk48qO4ViCN3snFs0AURU06niM98MIcEUnj9vj6kOBlOGv4JWQIDAQAB
-----END PUBLIC KEY-----"""
const val PackageName = "com.klab.lovelive.allstars"
const val MasterVersion = "646e6e305660c69f"
fun md5(str: String): String {
val digest = MessageDigest.getInstance("MD5")
digest.reset()
digest.update(str.toByteArray())
val hash = digest.digest();
return BigInteger(1, hash).toString(16).padStart(32, '0')
}
fun publicEncrypt(key: PublicKey, data: ByteArray): ByteArray {
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
return cipher.doFinal(data);
}
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
fun hmacSha1(key: ByteArray, data: ByteArray): String {
val hmacKey = SecretKeySpec(key, "HmacSHA1")
val hmac = Mac.getInstance("HmacSHA1")
hmac.init(hmacKey)
return hmac.doFinal(data).toHexString()
}
var requestId = 0;
var sessionKey = StartupKey;
fun call(path: String, payloadJson: String, mv: Boolean, t: Boolean,
u: Int = 0)
{
requestId = requestId + 1;
var pathWithQuery = path + "?p=a";
if (mv) {
pathWithQuery += "&mv=$MasterVersion"
}
pathWithQuery += "&id=$requestId"
if (u != 0) {
pathWithQuery += "&u=$u"
}
if (t) {
val millitime = System.currentTimeMillis()
pathWithQuery += "&t=$millitime"
}
println(pathWithQuery);
val hashData = pathWithQuery + " " + payloadJson
val hash = hmacSha1(sessionKey.toByteArray(), hashData.toByteArray())
val json = """[$payloadJson,"$hash"]"""
println(json)
val client = OkHttpClient()
val request = Request.Builder()
.url("$ServerEndpoint$pathWithQuery")
.post(json.toByteArray()
.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
println("unexpected code $response")
}
for ((name, value) in response.headers) {
println("$name: $value")
}
println(response.body!!.string())
}
}
fun main(args: Array<String>) {
val kf = KeyFactory.getInstance("RSA");
val keyBytes = Base64.getDecoder().decode(
RSAPublicKey
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s+".toRegex(),"")
)
val keySpecX509 = X509EncodedKeySpec(keyBytes)
val pubKey = kf.generatePublic(keySpecX509)
val base64 = Base64.getEncoder()
val advertisingId = UUID.randomUUID().toString()
val resemara = md5(advertisingId + PackageName)
val randomBytes = Random.nextBytes(32)
val maskBytes = publicEncrypt(pubKey, randomBytes)
val mask = base64.encodeToString(maskBytes)
val payloadJson = """{"mask":"$mask","resemara_detection_identifier":"$resemara","time_difference":3600}"""
call("/login/startup", payloadJson, true, true);
}
and here's the script I use on linux to build and run it: https://gist.github.com/636d7efeff523b152a3039758d3ea9f6
note: you need kotlin 1.3.41 or higher to build and run with my script
if we run it, we get:
# checking dependencies
[ok] okhttp-4.2.2.jar
[ok] okio-2.2.2.jar
# compiling
# running
/login/startup?p=a&mv=646e6e305660c69f&id=1&t=CENSORED_TIME
[{"mask":"CENSORED","resemara_detection_identifier":"CENSORED","time_difference":3600},"CENSORED_HASH"]
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Server: nginx
Date: CENSORED
Vary: Accept-Encoding
X-Cache: Miss from cloudfront
Via: 1.1 CENSORED.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: CENSORED
X-Amz-Cf-Id: CENSORED
[CENSORED_TIME,"646e6e305660c69f",0,{"user_id":CENSORED,"authorization_key":"CENSORED"},"CENSORED_HASH"]
here's also a python version I wrote while I was troubleshooting https://gist.github.com/e801c077ad2e3e9f82f2da8233735707
so there you have it! we made our first communication with the server
successfully. this is just the beginning though, we have all the more
convoluted session key xoring ahead of us as well as some seemingly
obfuscated fields like asset_state which is generated by
_KJACore_AssetStateLogGenerateV2
in libjackpot-core.so
another interesting quirk I noticed is that the server also throws a 500 error if your query parameters in the url aren't in the same order as the game, which can cause problems with libs that parse and sort query params
just to be 100% accurate, I decided to use the exact same version of okhttp by downgrading to 3.9.1 (you can find it in the java strings by searching for 'okhttp/') which has a slightly different API
so let's take a look at what happens once the startup response is received
once again in DMHttpApi.__c__DisplayClass14_2$$_Login_b__4
userId = (Array *)StartupResponse$$get_UserId(startupResponse);
xoredAuthorizationKey = httpApi->SessionKey;
...
authorizationKey = (Array *)StartupResponse$$get_AuthorizationKey(startupResponse);
authorizationKey = Convert$$FromBase64String(authorizationKey);
xoredAuthorizationKey = Lib$$XorBytes(xoredAuthorizationKey,authorizationKey);
UserKey$$SetIDPW(userId,xoredAuthorizationKey,0);
which in short is
- decode the base64 authorization_key
- xor the current authorization_key with the current session key (which starts as StartupKey)
- remember the xored auth key and user id for later
then in DMHttpApi$$Login
it gets the user id and key back and just
passes them onto the next step of the state machine which I assume
is DMHttpApi.__c__DisplayClass14_0$$_Login_b__0
. here it stores the
user id and session key in some class
puVar4 = (undefined4 *)(displayClass + 0xc);
*puVar4 = user_id;
*(undefined4 *)(displayClass + 0x14) = param_1;
pBytes_ = (Array **)(displayClass + 0x10);
*pBytes_ = session_key;
reveals us the UserID field of DMHttpApi
iUserId = 0;
FUN_021f75fc(&iUserId,user_id,Method$Nullable_int_..ctor());
...
pDVar1 = Class$DotUnder.DMHttpApi->Instance;
pDVar1->UserId = iUserId;
updates the session key with the xored one and increases the auth count which will be in the login request
Class$DotUnder.DMHttpApi->Instance->SessionKey = *pBytes_;
authCount = UserKey$$IncrAuthCount(0);
if you actually look at UserKey$$IncrAuthCount
you will see that it's
stored in local storage and likely persists across reboots
then it goes on to generate the mask as usual by signing 32 random bytes
randomBytes_ = (Array *)DMCryptography$$RandomBytes(0x20)
...
*pBytes_ = randomBytes_;
...
mask = DMCryptography$$PublicEncrypt(randomBytes_);
...
mask = Convert$$ToBase64String(mask,0);
this is where we hit our next roadblock, the asset_state field. it's generated from the same random bytes that are signed for mask
randomBytes_ = Convert$$ToBase64String(*pBytes_,0);
assetStateLog = Platform$$GenerateAssetStateLog(randomBytes_,0);
loginApi = Instantiate1(Class$DotUnder.SVAPI.Login);
Login$$.ctor(loginApi,0);
...
userId = *pUserId;
loginRequest = Instantiate1(Class$DotUnder.Structure.LoginRequest);
LoginRequest$$.ctor(loginRequest,userId,authCount,mask,assetStateLog,0);
if we look at Platform$$GenerateAssetStateLog
it calls into
AndroidPlatform$$GenerateAssetStateLog
which constructs a StringBuilder
of capacity 1024 bytes and calls into libjackpot-core.so
void AndroidPlatform$$_KJACore_AssetStateLogGenerateV2
(undefined4 stringBuilder,undefined4 capacity,String *base64RandomBytes)
{
undefined4 uVar1;
undefined4 uVar2;
char *local_38;
undefined4 local_34;
char *local_30;
undefined local_1c;
if (DAT_03706760 == (code *)0x0) {
local_34 = 0xc;
local_30 = "_KJACore_AssetStateLogGenerateV2";
local_1c = 0;
local_38 = "jackpot-core";
DAT_03706760 = (code *)FUN_008a5040(&local_38);
if (DAT_03706760 == (code *)0x0) {
uVar1 = FUN_0089f2d0(
"Unable to find method for p/invoke: \'_KJACore_AssetStateLogGenerateV2\'"
);
ThrowSomeOtherException(uVar1,0,0);
caseD_15();
return;
}
}
uVar1 = FUN_008a54c0(stringBuilder);
uVar2 = FUN_008a5318(base64RandomBytes);
(*DAT_03706760)(uVar1,capacity,uVar2);
FUN_008a576c(stringBuilder,uVar1);
FUN_008a530c(uVar1);
FUN_008a530c(uVar2);
return;
}
so I disassemble libjackpot-core.so and see what this function is all about and oh boy, it calls a giant obfuscated function that appears to be an extremely obfuscated way to produce some kind of string. the output also seems to be affected by the random base64 bytes. it does all kinds of convoluted stuff with std::strings and at some point it even seems to open files.
I could sit here for a month and maybe make sense of it, but what I am actually going to do is hook it and grab a few valid outputs with a few random strings of bytes and shuffle between those. I don't think this asset_state field holds any meaningful info anyway since it's so short. it's probably just checked against the random bytes
the hook
static
String* (*original_GenerateAssetStateLog)(String* base64RandomBytes);
static
String* hooked_GenerateAssetStateLog(String* base64RandomBytes) {
String* res;
log("random bytes:");
String_log(base64RandomBytes);
res = original_GenerateAssetStateLog(base64RandomBytes);
log("asset state:");
String_log(res);
return res;
}
result
10-21 16:04:40.515 7151 7176 D sniffas.c: random bytes:
10-21 16:04:40.515 7151 7176 D sniffas.c: +zuyNj+IFhSydzEMTHnrBCyUO0b3CvQt5nOWwxpNKcE=
10-21 16:04:40.776 7151 7176 D sniffas.c: asset state:
10-21 16:04:40.776 7151 7176 D sniffas.c: OqwKkOuhtlyuSzCj95pXUjtEo65SuYtUI3OlrxWWSjz7IEyicAMR7/IWuc822gc2cQXHjHY2ASHjQFfdONJNOU5gMM5w4g3Dj2K+iv1HDPZTAdtd8BURk7Iu+HVqxACI2g==
let's hardcode these values temporarily
ok so while implementing this I noticed that it's not the session key that is xored with the auth key, seems like it's actually the random bytes. i thought it was weird that the string sizes didn't match
in DMHttpApi$$Login
randomBytes = DMCryptography$$RandomBytes(0x20);
*(undefined4 *)(displayClass14_2 + 8) = randomBytes;
...
actionStartupRequest = thunk_FUN_008ae738(Class$Action_StartupRequest_);
FUN_024470b8(actionStartupRequest,displayClass14_2,
Method$DMHttpApi.__c__DisplayClass14_2._Login_b__3(),
Method$Action_StartupRequest_..ctor());
see how the first param to Login_b__3 is the displayclass that contains the random bytes at offset 8?
Login_b__3 just passes it through and then Login_b__4 uses it:
userId = (Array *)StartupResponse$$get_UserId(startupResponse);
xoredAuthorizationKey = *(Array **)((int)displayClass + 8);
}
authorizationKey = (Array *)StartupResponse$$get_AuthorizationKey(startupResponse);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087fd40();
}
authorizationKey = Convert$$FromBase64String(authorizationKey);
xoredAuthorizationKey = Lib$$XorBytes(xoredAuthorizationKey,authorizationKey);
here's the updated code, I also cleaned it up a little bit
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import java.math.BigInteger
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.random.Random
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
const val ServerEndpoint =
"https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1010"
const val StartupKey = "G5OdK4KdQO5UM2nL"
const val RSAPublicKey = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/ZUSWq8LCuF2JclEp6uuW9+yddLQvb2420+F8
rxIF8+W53BiF8g9m6nCETdRw7RVnzNABevMndCCTD6oQ6a2w0QpoKeT26578UCWtGp74NGg2Q2fH
YFMAhTytVk48qO4ViCN3snFs0AURU06niM98MIcEUnj9vj6kOBlOGv4JWQIDAQAB
-----END PUBLIC KEY-----"""
const val PackageName = "com.klab.lovelive.allstars"
const val MasterVersion = "646e6e305660c69f"
val kf = KeyFactory.getInstance("RSA")
val keyBytes = Base64.getDecoder().decode(
RSAPublicKey
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace("\\s+".toRegex(), "")
)
val keySpecX509 = X509EncodedKeySpec(keyBytes)
val pubKey = kf.generatePublic(keySpecX509)
val gson = Gson()
val base64Encoder = Base64.getEncoder()
val base64Decoder = Base64.getDecoder()
fun md5(str: String): String {
val digest = MessageDigest.getInstance("MD5")
digest.reset()
digest.update(str.toByteArray())
val hash = digest.digest()
return BigInteger(1, hash).toString(16).padStart(32, '0')
}
fun publicEncrypt(data: ByteArray): ByteArray {
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding")
cipher.init(Cipher.ENCRYPT_MODE, pubKey)
return cipher.doFinal(data)
}
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
fun ByteArray.xor(other: ByteArray) =
(zip(other) { a, b -> (a.toInt() xor b.toInt()).toByte() }).toByteArray()
fun hmacSha1(key: ByteArray, data: ByteArray): String {
val hmacKey = SecretKeySpec(key, "HmacSHA1")
val hmac = Mac.getInstance("HmacSHA1")
hmac.init(hmacKey)
return hmac.doFinal(data).toHexString()
}
var requestId = 0
var sessionKey = StartupKey.toByteArray()
const val WithMasterVersion = 1 shl 1
const val WithTime = 1 shl 2
const val PrintHeaders = 1 shl 3
fun call(
path: String,
payload: String,
flags: Int = 0,
userId: Int = 0
): String {
requestId += 1
var pathWithQuery = path + "?p=a"
if ((flags and WithMasterVersion) != 0) {
pathWithQuery += "&mv=$MasterVersion"
}
pathWithQuery += "&id=$requestId"
if (userId != 0) {
pathWithQuery += "&u=$userId"
}
if ((flags and WithTime) != 0) {
val millitime = System.currentTimeMillis()
pathWithQuery += "&t=$millitime"
}
println("-> POST $pathWithQuery")
val hashData = pathWithQuery + " " + payload
val hash = hmacSha1(sessionKey, hashData.toByteArray())
val json = """[$payload,"$hash"]"""
println("-> $json")
val JSON = MediaType.parse("application/json")
val client = OkHttpClient()
val request = Request.Builder()
.url("$ServerEndpoint$pathWithQuery")
.post(RequestBody.create(JSON, json.toByteArray()))
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
println("unexpected code $response")
}
if ((flags and PrintHeaders) != 0) {
val headers = response.headers()
for (i in 0..headers.size() - 1) {
val name = headers.name(i)
val value = headers.value(i)
println("<- $name: $value")
}
}
val s = response.body()!!.string()
println("<- $s")
return s
}
data class StartupRequest(
val mask: String,
val resemara_detection_identifier: String,
val time_difference: Int
)
data class StartupResponse(
val user_id: Int,
val authorization_key: String
)
fun startup(randomBytes: ByteArray): StartupResponse? {
val advertisingId = UUID.randomUUID().toString()
val resemara = md5(advertisingId + PackageName)
val maskBytes = publicEncrypt(randomBytes)
val mask = base64Encoder.encodeToString(maskBytes)
val result = call(
path = "/login/startup",
payload = gson.toJson(StartupRequest(
mask = mask,
resemara_detection_identifier = resemara,
time_difference = 3600
), StartupRequest::class.java),
flags = WithMasterVersion or WithTime
)
val array = JsonParser.parseString(result).getAsJsonArray()
for (x in array) {
if (x.isJsonObject()) {
return gson.fromJson(x, StartupResponse::class.java)
}
}
return null
}
data class LoginRequest(
val user_id: Int,
val auth_count: Int,
val mask: String,
val asset_state: String
)
var authCount = 0
fun login(userId: Int) {
authCount += 1
val randomBytesBase64 = "+zuyNj+IFhSydzEMTHnrBCyUO0b3CvQt5nOWwxpNKcE="
val randomBytes = base64Decoder.decode(randomBytesBase64)
val maskBytes = publicEncrypt(randomBytes)
val mask = base64Encoder.encodeToString(maskBytes)
val result = call(
path = "/login/login",
payload = gson.toJson(LoginRequest(
user_id = userId,
auth_count = authCount,
mask = mask,
asset_state = "OqwKkOuhtlyuSzCj95pXUjtEo65SuYtUI3OlrxWWSjz7IEyicA" +
"MR7/IWuc822gc2cQXHjHY2ASHjQFfdONJNOU5gMM5w4g3Dj2K+iv1HDPZTAdtd" +
"8BURk7Iu+HVqxACI2g=="
), LoginRequest::class.java),
userId = userId
)
val prettyPrint = GsonBuilder().setPrettyPrinting().create()
val array = JsonParser.parseString(result).getAsJsonArray()
println(prettyPrint.toJson(array))
}
fun main(args: Array<String>) {
val randomBytes = Random.nextBytes(32)
val startupResponse = startup(randomBytes)
println(startupResponse!!)
val authKey = base64Decoder.decode(startupResponse.authorization_key)
println(authKey.toHexString())
println(randomBytes.toHexString())
sessionKey = authKey.xor(randomBytes)
println(sessionKey.toHexString())
login(startupResponse.user_id)
}
and sure enough, we are logged in!
I will most likely generate a large database of "random" bytes and their correct asset_state
I took a closer look at the crazy asset_state generator function. compiler optimizations mangle the decompilation a lot but with patience you can repair it. it took around 2 days to name everything and recognize which were std::string operations, mostly by guessing
the first part is reading from a huge string and copying to local arrays, which generates a lot of pointless vars, i redefined the whole memory area to be a large array. it's also appending some stuff in between
this huge string is:
846t07:9t1:80+4/t\b2<5\x1c>5>):/4)[4+>5[sr\x17846t07:9t1:80+4/t\b2<5\x1c>5>):/4)`[:-:27:97>[sr\x01[<>/[s\x12r\x171:-:t7:5<t\b/)25<`[874(>[sr\r[846t07:9t1:80+4/t\x1a((>/(\x1f2<>(/\x1c>5>):/4)[s\x171:-:t7:5<t\b/)25<`r\x17846t07:9t1:80+4/t\x1a((>/(\x1f2<>(/\x1c>5>):/4)`[ +,m\x06#6#m\x0f#,#%\'&m\x0311\'/.;o\x01\x11*#02l&..BN\\BL\\GN]_KCCKFHJ\\[XN\\A@[NYNFCNMCJ/\\FHAN[Z]JKFHJ\\[XN\\A@[NYNFCNMCJ/.+ +.p!22l1-B
also it's not all ascii, so I'm not 100% sure it's really a single string
first thing it does is save the first 3 chars of the base64 randombytes
c
second_char = base64RandomBytes[1];
stack_guard = __stack_chk_guard;
third_char = base64RandomBytes[2];
first_char = *base64RandomBytes;
then it constructs tmp2
by copying 32 characters at huge_string + 0x102
followed by J/
huge_string_int32 = (int *)(huge_string + 0x102);
it1 = (int *)tmp2;
do {
ptr_2 = huge_string_int32 + 2;
int1 = huge_string_int32[1];
*it1 = *huge_string_int32;
it1[1] = int1;
it1 = it1 + 2;
huge_string_int32 = ptr_2;
} while (ptr_2 != (int *)(huge_string + 0x122));
/* appends "J/" */
*(undefined2 *)it1 = 0x2f4a;
then it constructs tmp1
by copying 24 characters from huge_string + 0x124
followed by "FCNM" followed by 3 more chars copied past the end of the loop
for a total of 30 characters
buf2 = (undefined4 *)tmp1;
huge_string_int32_ = (undefined4 *) (huge_string + 0x124);
do {
ptr_ = huge_string_int32_;
buf2_ = buf2;
int1_ = ptr_[1];
*buf2_ = *ptr_;
buf2_[1] = int1_;
buf2 = buf2_ + 2;
huge_string_int32_ = ptr_ + 2;
} while (ptr_ + 2 != (undefined4 *) (huge_string + 0x13c));
uVar2 = *(undefined2 *)(ptr_ + 3);
cVar1 = *(char *)((int)ptr_ + 0xe);
/* appends "FCNM" */
buf2_[2] = 0x4d4e4346;
/* copies 3 extra chars past the above loop after "FCNM" */
*(undefined2 *)(buf2_ + 3) = uVar2;
*(char *)((int)buf2_ + 0xe) = cVar1;
then it constructs tmp3
by copying 8 characters from huge_string + 0x143
followed by "2l1-" followed by 1 more char copied past the end of the loop
for a total of 14 characters
buf2 = tmp3;
huge_string_int32_ = (undefined4 *) (huge_string + 0x143);
do {
ptr_ = huge_string_int32_;
buf2_ = buf2;
int1_ = ptr_[1];
*buf2_ = *ptr_;
buf2_[1] = int1_;
buf2 = buf2_ + 2;
huge_string_int32_ = ptr_ + 2;
} while (ptr_ + 2 != (undefined4 *) (huge_string + 0x14b));
cVar1 = *(char *)(ptr_ + 3);
/* appends "2l1-" */
buf2_[2] = 0x2d316c32;
/* copies 1 extra char past the above loop after "211-" */
*(char *)(buf2_ + 3) = cVar1;
then it calls a function that i named xor_until_match
on the three bufs
xor_until_match('/',tmp2);
xor_until_match('/',tmp1);
xor_until_match('B',tmp3);
which xors every character with the given character until it finds a matching character which xors to zero:
char * xor_until_match(char c,char *str)
{
char *p;
char tmp;
p = str;
do {
tmp = *p;
*p = tmp ^ c;
p = p + 1;
} while ((byte)(tmp ^ c) != 0);
return str;
}
this also zero-terminates the strings, once the matching character is found
then it proceeds to initialize two std strings. how did I know they were std strings? it took a lot of guess work and looking at the functions that are later called on them
string((int)&stdstring1);
string((int)&stdstring2);
I manually mapped a few fields like start and end for std::string, again by guessing
then it calls dladdr on a fixed address which is this very function. this
is used to get the path to libjackpot-core.so
I mapped the Dl_info struct from the manpages
int1 = dladdr(0x244b9,&dli);
if (int1 != 0) {
string_from_c((int)&path_to_libjackpot,dli.dli_fname);
this overly complicated thing (probably mangled by optimization) seems to strip the filename from the path (last element)
start = path_to_libjackpot.start;
len = path_to_libjackpot.end + -(int)path_to_libjackpot.start;
if (len == (char *)0x0) {
LAB_000245b4:
it2 = (char *)0xffffffff;
}
else {
/* i think this whole thing just strips the filename from the full path */
int1 = (int)len >> 2;
end = path_to_libjackpot.end;
while (it2 = end, 0 < int1) {
if (end[-1] == '/') goto LAB_00024628;
if (end[-2] == '/') {
it2 = end + -1;
goto LAB_00024628;
}
if (end[-3] == '/') {
it2 = end + -2;
goto LAB_00024628;
}
if (end[-4] == '/') {
it2 = end + -3;
goto LAB_00024628;
}
int1 = int1 + -1;
end = end + -4;
}
pcVar8 = end + -(int)path_to_libjackpot.start;
if (pcVar8 == (char *)0x2) {
LAB_00024610:
it2 = end;
if (end[-1] != '/') {
end = end + -1;
LAB_0002461a:
it2 = end;
if (end[-1] != '/') {
it2 = path_to_libjackpot.start;
}
}
}
else {
if (pcVar8 == (char *)0x3) {
if (end[-1] != '/') {
end = end + -1;
goto LAB_00024610;
}
}
else {
it2 = path_to_libjackpot.start;
if (pcVar8 == (char *)0x1) goto LAB_0002461a;
}
}
LAB_00024628:
if (it2 == path_to_libjackpot.start) goto LAB_000245b4;
it2 = it2 + (-1 - (int)path_to_libjackpot.start);
}
it then stores second and third char of the random bytes base64 modulo 3
/* the decompiler doesn't show it, but the remainder is stored in
second_char_mod3 and third_char_mod3 */
__aeabi_idivmod((uint)second_char,3);
__aeabi_idivmod((uint)third_char,3);
here it appends tmp3 to the base directory of libjackpot-core.so
if (len < it2) {
it2 = len;
}
libjackpot_directory.end = (char *)&libjackpot_directory;
libjackpot_directory.start = (char *)&libjackpot_directory;
string_from_range(&libjackpot_directory,start,start + (int)it2);
string_concatenate_char(&tmpstring,&libjackpot_directory,&"/");
string_from_c((int)&tmp3_string,tmp3);
string_concatenate(&tmp3_path,&tmpstring,&tmp3_string);
operator_delete_(&tmp3_string);
operator_delete_(&tmpstring);
operator_delete_(&libjackpot_directory);
i think this is a good point to take a break and either hook or implement this first part of the function to see what the tmp strings are
after looking around for a bit, I think we can hook the very next function
that's called. it reads the given file computes one of three hashes: md5,
sha-1 or sha-256 depending on the parameter passed in, which in this case
in base64randombytes[1] & 3
if ((first_char & 1) == 0) {
conditional_hash(&libjackpot_hash,&path_to_libjackpot,second_char_mod_3);
conditional_hash(&tmp3_hash,&tmp3_path,second_char_mod_3);
here's what the hashing function looks like
char ** conditional_hash(char **pphash,std__string *path,int type)
{
if (type == 0) {
md5(pphash,path);
}
else {
if (type == 1) {
sha1(pphash,(char *)path);
}
else {
if (type == 2) {
sha256(pphash,path);
}
else {
*pphash = (char *)0x0;
pphash[1] = (char *)0x0;
}
}
}
return pphash;
}
the way I figured out which hashes it was doing was by looking at the functions. they call some initialization function:
void ** md5(void **pphash,std__string *param_2)
{
FILE *__stream;
size_t sVar1;
undefined auStack1168 [4];
undefined auStack1164 [16];
undefined auStack1148 [88];
undefined auStack1060 [1024];
int local_24;
local_24 = __stack_chk_guard;
__stream = fopen(param_2->start,"rb");
if (__stream == (FILE *)0x0) {
*pphash = (void *)0x0;
pphash[1] = (void *)0x0;
}
else {
md5_hash_initial(auStack1168,auStack1148);
while (sVar1 = fread(auStack1060,1,0x400,__stream), sVar1 != 0) {
md5_hash_update(auStack1168,auStack1148,auStack1060,sVar1);
}
fclose(__stream);
md5_finalize(auStack1168,auStack1164,auStack1148);
clone_memory(pphash,auStack1164,0x10);
}
if (local_24 != __stack_chk_guard) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return pphash;
}
that initialization function has some very well known constants that I googled
void md5_hash_initial(undefined4 param_1,undefined4 *param_2)
{
param_2[5] = 0;
param_2[4] = 0;
*param_2 = 0x67452301;
param_2[1] = 0xefcdab89;
param_2[2] = 0x98badcfe;
param_2[3] = 0x10325476;
return;
}
initially I thought they were all modified versions of sha1 with slightly different initial parameters but then I realized that md5, sha1, sha256 all have common hashes and from the output size you can tell which one it is (16, 24, 32 bytes)
also, that memory_clone function reveals that pphash is a struct that contains data ptr and length
so let's hook this conditional_hash function and see what the other file is
we have a bit of a problem, this function appears to be using thumb instructions. we need different instructions to hook it
000233dc 10 b5 push { r4, lr }
000233de 04 46 mov r4,pphash
000233e0 12 b9 cbnz type,LAB_000233e8
000233e2 ff f7 19 ff bl md5
long story short the an absolute jump is performed with
ldr.w pc, [pc]
.word 0xbaadf00d
which assembles to
f8 df f0 00
0d f0 ad ba
baadf00d would be the target address
another issue is that the function uses a relative jump right at the beginning, so instead of calling the original through a trampoline, we will instead replace the entire function with our own and to that simple if/else ourselves
we also have to compile the hook with thumb instructions by using
__attribute__((target("thumb")))
here's the hook I ended up with:
typedef struct {
char unk[16];
char* end;
char* start;
} std_string;
typedef struct {
char* data;
int length;
} hash_array;
/* unused */
static hash_array* (*original_hash)(hash_array* pphash, std_string* path,
int type);
hash_array* (*md5)(hash_array* hash, std_string* path);
hash_array* (*sha1)(hash_array* hash, std_string* path);
hash_array* (*sha256)(hash_array* hash, std_string* path);
static
__attribute__((target("thumb")))
hash_array* hooked_hash(hash_array* hash, std_string* path, int type) {
hash_array* res;
char buf[1024];
char* p = buf;
int i;
p += sprintf(p, "hash called with type %d on ", type);
memcpy(p, path->start, path->end - path->start);
p[path->end - path->start] = 0;
log(buf);
switch (type) {
case 0: res = md5(hash, path); break;
case 1: res = sha1(hash, path); break;
case 2: res = sha256(hash, path); break;
default:
log("unknown hash!");
hash->data = 0;
hash->length = 0;
return hash;
}
p = buf;
p += sprintf(p, "result: ");
for (i = 0; i < res->length; ++i) {
p += sprintf(p, "%02x", hash->data[i]);
}
log(buf);
return res;
}
here's where I assign the function pointers for the 3 hashes, I have to set the lowest bit to 1 to ensure that the cpu knows it should stay in thumb mode
/* | 1 forces thumb mode */
#define f(name, addr) \
*(int*)&name = ((int)dli.dli_fbase + addr) | 1
f(md5, 0x13218);
f(sha1, 0x132ac);
f(sha256, 0x13344);
#undef f
and here's the output:
hash called with type 1 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libjackpot-core.so
result: 5a3cb86aa9b082d6a1c1dfa6f73dd431d7f14e18
hash called with type 1 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libil2cpp.so
result: c4387c429c50c4782ab3df409db3abcfa8fadf79
alright, so the other file it's hashing is il2cpp
next, it xors the two hashes together, formats the result to a hexstring and puts it into stdstring1
array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash);
std_hexstring_from_array(&tmp3_string,&xored_hashes);
string_assignment((int)&stdstring1,(int)&tmp3_string);
the xor function was obvious
some_kind_of_array * array_xor(some_kind_of_array *dst,some_kind_of_array *a,some_kind_of_array *b)
{
char *result;
int len2;
uint i;
int len1;
len2 = b->length;
len1 = a->length;
dst->data = (char *)0x0;
dst->length = 0;
if (len1 == len2) {
if (len1 != 0) {
result = (char *)operator.new(len1);
dst->data = result;
dst->length = len1;
}
i = 0;
while (i < (uint)a->length) {
dst->data[i] = a->data[i] ^ b->data[i];
i = i + 1;
}
}
return dst;
}
std_hexstring_from_array
was tricky to figure out. I hooked
string_from_range which is called inside of it:
void (*basic_string)(void* s, int len);
void (*original_string_from_range)(std_string* s, char* start, char* end);
static
__attribute__((target("thumb")))
void hooked_string_from_range(std_string* s, char* start, char* end) {
char buf[1024];
log("string from range");
memcpy(buf, start, end - start);
buf[end - start] = 0;
log(buf);
basic_string(s, end - start + 1);
memcpy(s->start, start, end - start);
s->end = s->start + (end - start);
s->start[end - start] = 0;
}
and here's the result
string from range
/data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm
hash called with type 2 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libjackpot-core.so
result: 66370b8c96de7266b02bfe17e696d8a61b587656a34b19fbb0b2768a5305dd1d
hash called with type 2 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libil2cpp.so
result: d30568d1057fecb31a16f4062239c1ec65b9c2beab41b836658b637dcb5a51e4
string from range
b532635d93a19ed5aa3d0a11c4af194a7ee1b4e8080aa1cdd53915f7985f8cf9
next, it calls another huge function that unencrypts more strings from the huge_string we saw earlier.
operator_delete_(&tmp3_string);
operator_delete(&xored_hashes);
operator_delete(&tmp3_hash);
operator_delete(&libjackpot_hash);
crazy_function(&tmp3_string,third_char_mod_3);
string_assignment((int)&stdstring2,(int)&tmp3_string);
operator_delete_(&tmp3_string);
if we scroll down we can see a string_from_range call, let's see what our earlier hook logs:
string from range
1be2103a6929b38798a29d89044892f3b3934184
if we google for this hash, we find the apkpure page for SIFAS. it must be the package signature. using this tool on the apk confirms it:
Verifies
Verified using v1 scheme (JAR signing): true
Verified using v2 scheme (APK Signature Scheme v2): true
Number of signers: 1
Signer #1 certificate DN: CN=AS Team, OU=Unknown, O=KLab Inc., L=Unknown, ST=Unknown, C=JP
Signer #1 certificate SHA-256 digest: 1d32dbcf91697d46594ad689d49bb137f65d4bb8f56a26724ae7008648131b82
Signer #1 certificate SHA-1 digest: 1be2103a6929b38798a29d89044892f3b3934184
Signer #1 certificate MD5 digest: 3f45f90cbcc718e4b63462baeae90c86
Signer #1 key algorithm: RSA
Signer #1 key size (bits): 2048
Signer #1 public key SHA-256 digest: 5b125027893d4e43b5a4c1a4359968b86f0c0be30f9ef012349ba41855f0ff67
Signer #1 public key SHA-1 digest: 447c28474fc0cba922d504b2fe88da0af35f2f0b
Signer #1 public key MD5 digest: 43935540d5670e2869d777751c96c9a4
there's a peculiar function call at the beginning of the function. it seems to call a function from some class vtable. it's passing &tmp3_string and thid_char_mod_3 as param
int * call_some_class(undefined4 param_1,int *param_2)
{
int *local_c [3];
local_c[0] = some_class;
if (some_class != (int *)0x0) {
local_c[0] = param_2;
(**(code **)(*some_class + 0x10))(some_class,local_c,0,*(code **)(*some_class + 0x10),param_1);
}
return local_c[0];
}
if we look at what else references some_class, we find that it's the jni env, assigned on JNI_OnLoad. the parameter is well documented. it also seems to be doing some interesting rng initialization
undefined4 JNI_OnLoad(void *vm,void *reserved)
{
initialize_rand_seed(vm);
return 0x10006;
}
void initialize_rand_seed(void *vm)
{
time_t __seedval;
long rand;
undefined4 first_rand;
int extraout_r1;
int extraout_r1_00;
set_jni_env(vm);
if (rng_initialized == 0) {
__seedval = time((time_t *)(uint)rng_initialized);
srand48(__seedval);
/* extract random numbers between 100 and 1000 until one matches the first
number extracted */
rand = lrand48();
__aeabi_idivmod(rand,900);
do {
rand = lrand48();
__aeabi_idivmod(rand,900);
} while (extraout_r1 + 100 == extraout_r1_00 + 100);
matching_rand_xor_& = xor('&',extraout_r1 + 100);
first_rand_xor_& = xor('&',extraout_r1_00 + 100);
first_rand = xor('&',first_rand_xor_&);
first_rand_xor_M = xor('M',first_rand);
char_r = xor('r',0);
rng_initialized = 1;
}
return;
}
void set_jni_env(undefined4 jni_env)
{
jni_env = jni_env;
return;
}
the jni call is being passed third_char_mod_3
so I have a feeling that
decides which hash to use like with the other hashing function
jniresult = call_jni_env(str,(int *)(int)third_char_mod_3);
if we scroll down further we can find more calls into the jni env
uVar4 = FUN_00023124(jniresult,tmpint__,tmp4,tmp5);
uVar5 = FUN_00023124(jniresult,tmpint__,tmp6,tmp7);
uVar6 = FUN_00023124(jniresult,tmpint__,tmp8,tmp9);
right before this, we have a bunch of xor_until_match calls. let's hook those and log all the unencrypted strings
for some reason i had to hook 1 instruction into the func otherwise it would crash, either way here's the results
xor until match '['
com/klab/jackpot/SignGenerator
xor until match '['
open
xor until match '['
()Lcom/klab/jackpot/SignGenerator;
xor until match '['
available
xor until match '['
()Z
xor until match '['
get
xor until match '['
(I)Ljava/lang/String;
xor until match '['
close
xor until match '['
()V
string from range
3f45f90cbcc718e4b63462baeae90c86
so yeah, time to pull up the java side of the code
nothing special going on in the constructor, just grabbing the sig bytes
void SignGenerator(SignGenerator this)
{
PackageManager ref;
String pSVar1;
PackageInfo pPVar2;
byte[] pbVar3;
CertificateFactory ref_00;
Certificate ref_01;
Activity ref_02;
Signature[] ppSVar4;
Signature ref_03;
InputStream ref_04;
this.<init>();
ref_02 = UnityPlayer.currentActivity;
ref = ref_02.getPackageManager();
pSVar1 = ref_02.getPackageName();
/* GET_SIGNATURES */
pPVar2 = ref.getPackageInfo(pSVar1,0x40);
ppSVar4 = pPVar2.signatures;
if ((ppSVar4 == null) || (ppSVar4.length < 1)) {
ref_03 = null;
}
else {
ref_03 = ppSVar4[0];
}
if (ref_03 == null) {
return;
}
pbVar3 = ref_03.toByteArray();
if (pbVar3 == null) {
return;
}
ref_04 = new InputStream(pbVar3);
ref_00 = CertificateFactory.getInstance("X509");
if (ref_00 == null) {
return;
}
ref_01 = ref_00.generateCertificate(ref_04);
checkCast(ref_01,X509Certificate);
if (ref_01 == null) {
return;
}
pbVar3 = ref_01.getEncoded();
this.mBytes = pbVar3;
return;
}
let's look at other methods of this class
String get(SignGenerator this,int p1)
{
boolean bVar1;
MessageDigest ref;
byte[] pbVar2;
String ref_00;
Object[] ppOVar3;
BigInteger ref_01;
StringBuilder ref_02;
bVar1 = this.available();
if (bVar1 == false) {
return null;
}
if (p1 == 0) {
ref = MessageDigest.getInstance("md5");
}
else {
if (p1 == 1) {
ref = MessageDigest.getInstance("sha1");
}
else {
ref = MessageDigest.getInstance("sha256");
}
}
if (ref == null) {
return null;
}
pbVar2 = ref.digest(this.mBytes);
ref_01 = new BigInteger(1,pbVar2);
ref_02 = new StringBuilder();
ref_02.append("%0");
ref_02.append(pbVar2.length << 1);
ref_02.append("X");
ref_00 = ref_02.toString();
ppOVar3 = new Object[1];
ppOVar3[0] = ref_01;
ref_00 = String.format(ref_00,ppOVar3);
ref_00 = ref_00.toLowerCase();
return ref_00;
}
exactly as predicted. p1 would be the third_char_mod_3
we can confidently rename the crazy_function
to get_package_signature
there's one more trick to this madness, it swaps third/second char mod3 based on whether the first character of base64 bytes is even or odd
if ((first_char & 1) == 0) {
conditional_hash(&libjackpot_hash,&path_to_libjackpot,second_char_mod_3);
/* il2cpp */
conditional_hash(&tmp3_hash,&tmp3_path,second_char_mod_3);
ptmp3_hash = &tmp3_hash;
array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash);
std_hexstring_from_array(&tmp3_string,&xored_hashes);
string_assignment((int)&stdstring1,(int)&tmp3_string);
operator_delete_(&tmp3_string);
operator_delete(&xored_hashes);
operator_delete(&tmp3_hash);
operator_delete(&libjackpot_hash);
get_package_signature(&tmp3_string,third_char_mod_3,ptmp3_hash);
string_assignment((int)&stdstring2,(int)&tmp3_string);
operator_delete_(&tmp3_string);
}
else {
get_package_signature(&tmp3_string,(char)second_char_mod_3);
string_assignment((int)&stdstring1,(int)&tmp3_string);
operator_delete_(&tmp3_string);
conditional_hash(&libjackpot_hash,&path_to_libjackpot,third_char_mod_3);
conditional_hash(&tmp3_hash,&tmp3_path,third_char_mod_3);
array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash);
std_hexstring_from_array(&tmp3_string,&xored_hashes);
string_assignment((int)&stdstring2,(int)&tmp3_string);
operator_delete_(&tmp3_string);
operator_delete(&xored_hashes);
operator_delete(&tmp3_hash);
operator_delete(&libjackpot_hash);
}
note also how signature and xored hash are swapped between stdstring1/2
finally it concatenates the xored hashes and the package signature in a single string, separating them with '-'.
if ((first_char & 1) == 0) {
if (stdstring1.start == stdstring1.end) {
string_from_c((int)&tmp3_string,tmp2);
string_assignment((int)&stdstring1,(int)&tmp3_string);
operator_delete_(&tmp3_string);
}
if (stdstring2.start != stdstring2.end) goto LAB_0002480e;
puVar3 = &stack0x00000104;
}
else {
if (stdstring1.start == stdstring1.end) {
string_from_c((int)&tmp3_string,tmp1);
string_assignment((int)&stdstring1,(int)&tmp3_string);
operator_delete_(&tmp3_string);
}
if (stdstring2.start != stdstring2.end) goto LAB_0002480e;
puVar3 = &stack0x00000124;
}
string_from_c((int)&tmp3_string,puVar3 + -0x174);
string_assignment((int)&stdstring2,(int)&tmp3_string);
operator_delete_(&tmp3_string);
LAB_0002480e:
string_concatenate_char(&tmp3_string,&stdstring1,"-");
string_concatenate(&tmpstring,&tmp3_string,&stdstring2);
operator_delete_(&tmp3_string);
the tmp1/tmp2 cases will never be reached as stdstring1 will always be set
to either the package signature or the xored hashes. therefore the only
code that matters is the part at LAB_0002480e:
, where it concatenates
stdstring1 (either sig or xored hashes), "-", and stdstring2 (either sig
or xored hashes) into tmpstring
finally, we have a pretty convoluted xor encryption of some sort which
is stored into what was xored_hashes
earlier and the result is
base64 encoded, and there's our asset_state
field! we finally reached
the end of this crazy function
rounds = 10;
xorkey = ((uint)(byte)*base64RandomBytes |
(uint)(byte)base64RandomBytes[2] << 0x10 | (uint)(byte)base64RandomBytes[1] << 8 |
(uint)(byte)base64RandomBytes[3] << 0x18) ^ 0x12d8af36;
a = 0;
b = 0;
c = 0x2bd57287;
d = 0;
e = 0x202c9ea2;
f = 0;
g = 0x139da385;
do {
h = g;
i = f;
j = e;
k = d;
g = c;
f = b;
a = (a << 0xb | xorkey >> 0x15) ^ a;
xorkey = xorkey << 0xb ^ xorkey;
c = (g >> 0x13 | k << 0xd) ^ xorkey ^ g ^ (xorkey >> 8 | a << 0x18);
d = k >> 0x13 ^ a ^ k ^ a >> 8;
rounds = rounds + -1;
xorkey = j;
a = i;
b = k;
e = h;
} while (rounds != 0);
new_array(&xored_hashes,(int)(tmpstring.end + -(int)tmpstring.start));
while (a = g, xorkey = f, rounds < (int)(tmpstring.end + -(int)tmpstring.start)) {
b = (i << 0xb | j >> 0x15) ^ i;
j = j << 0xb ^ j;
e = (c >> 0x13 | d << 0xd) ^ j ^ c ^ (j >> 8 | b << 0x18);
xored_hashes.data[rounds] = tmpstring.start[rounds] ^ (byte)e;
rounds = rounds + 1;
f = k;
g = c;
k = d;
j = h;
i = xorkey;
c = e;
d = d >> 0x13 ^ b ^ d ^ b >> 8;
h = a;
}
base64_encode(base64_result,&xored_hashes);
so let's implement this abomination and see if it matches the game
this took a lot of work to get the operator precedence right and not have signed-ness interfere in kotlin. i also hooked string_concat to check that at least I had the hashes right
here is the result, and it produces asset_state values exactly like the game
fun String.hexStringToByteArray() =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
fun ByteArray.xor(other: ByteArray) =
(zip(other) { a, b -> (a.toInt() xor b.toInt()).toByte() }).toByteArray()
// md5, sha1, sha256 of the package's signature
// obtained by running https://github.com/warren-bank/print-apk-signature
// on the split apk
val PackageSignatures = arrayOf(
"3f45f90cbcc718e4b63462baeae90c86",
"1be2103a6929b38798a29d89044892f3b3934184",
"1d32dbcf91697d46594ad689d49bb137f65d4bb8f56a26724ae7008648131b82"
)
// md5, sha1, sha256 of libjackpot-core.so
val JackpotSignatures = arrayOf(
"81ec95e20a695c600375e3b8349722ab",
"5a3cb86aa9b082d6a1c1dfa6f73dd431d7f14e18",
"66370b8c96de7266b02bfe17e696d8a61b587656a34b19fbb0b2768a5305dd1d"
).map { it.hexStringToByteArray() }
// md5, sha1, sha256 of libil2cpp.so
val Il2CppSignatures = arrayOf(
"67f969e32c2d775b35e2f2ad10b423c1",
"c4387c429c50c4782ab3df409db3abcfa8fadf79",
"d30568d1057fecb31a16f4062239c1ec65b9c2beab41b836658b637dcb5a51e4"
).map { it.hexStringToByteArray() }
@ExperimentalUnsignedTypes
fun assetStateLogGenerateV2(randomBytes64: String): String {
val libHashChar = (randomBytes64[0].toInt() and 1) + 1
val libHashType = randomBytes64[libHashChar].toInt().rem(3)
val pkgHashChar = 2 - (randomBytes64[0].toInt() and 1)
val pkgHashType = randomBytes64[pkgHashChar].toInt().rem(3)
val xoredHashes =
JackpotSignatures[libHashType].xor(Il2CppSignatures[libHashType])
.toHexString()
val packageSignature = PackageSignatures[pkgHashType]
val signatures = when (randomBytes64[0].toInt() and 1) {
0 -> "$xoredHashes-$packageSignature"
1 -> "$packageSignature-$xoredHashes"
else -> "$xoredHashes-$packageSignature"
}
println(signatures)
var xorkey =
(randomBytes64[0].toByte().toUInt() or
(randomBytes64[1].toByte().toUInt() shl 8) or
(randomBytes64[2].toByte().toUInt() shl 16) or
(randomBytes64[3].toByte().toUInt() shl 24)) xor 0x12d8af36u
var a = 0u
var b = 0u
var c = 0x2bd57287u
var d = 0u
var e = 0x202c9ea2u
var f = 0u
var g = 0x139da385u
var h = 0u
var i = 0u
var j = 0u
var k = 0u
repeat(10) {
h = g
i = f
j = e
k = d
g = c
f = b
a = ((a shl 11) or (xorkey shr 21)) xor a
xorkey = (xorkey shl 11) xor xorkey
c = ((g shr 19) or (k shl 13)) xor xorkey xor g xor ((xorkey shr 8) or (a shl 24))
d = (k shr 19) xor a xor k xor (a shr 8)
xorkey = j
a = i
b = k
e = h
}
val xorBytes = ByteArray(signatures.length)
for (index in 0..signatures.length - 1) {
a = g
xorkey = f
b = ((i shl 11) or (j shr 21)) xor i
j = (j shl 11) xor j
e = ((c shr 19) or (d shl 13)) xor j xor c xor ((j shr 8) or (b shl 24))
xorBytes[index] = e.toByte()
f = k
g = c
k = d
j = h
i = xorkey
c = e
d = (d shr 19) xor b xor d xor (b shr 8)
h = a
}
return base64Encoder.encodeToString(signatures.toByteArray().xor(xorBytes))
}
and here is a test with known values grabbed from the game. it produces the exact same output
# running
random bytes: CB7tjOEZK6IQJrX93O0BuTjM5txYFmFO8sv1Pq9eAcE=
3f45f90cbcc718e4b63462baeae90c86-9e04c42835e046ae8b7200e66a8e7ffe7f0b9161
0fd36d6349f7a06c4a8e169a26d0010dc12bb77c50ea4579823c03f6498a4c6f52a6ba6bb4737b633f2f5077d9a62b161c6de5814d8b878dc42c620e58850a3774b70bc071dc1554d3
expected:
3f45f90cbcc718e4b63462baeae90c86-9e04c42835e046ae8b7200e66a8e7ffe7f0b9161
0fd36d6349f7a06c4a8e169a26d0010dc12bb77c50ea4579823c03f6498a4c6f52a6ba6bb4737b633f2f5077d9a62b161c6de5814d8b878dc42c620e58850a3774b70bc071dc1554d3
I spent some time mapping a bunch of the json responses. the
Serialization$$Deserialize*
functions tell you all about which fields
each object has and which ones are optional. very relaxing activity
I also had to fight gson to override its builtin map serializer since
sifas maps are laid out as [key, value, key, value, ...]
. it was not
a fun experience working with the generics hell that is java/kotlin
another thing I noticed is that the first hash in the response array is
actually what's being used as MasterVersion, so I'll stop hardcoding it.
instead, you're supposed to send a
/dataLink/fetchGameServiceDataBeforeLogin
call without MasterVersion
next is the terms of service check. nothing special, it sends the current version accepted (from the LoginResponse) and gets back the current terms of service version. the response is a LoginResponse but with a lot of the data omitted
... except it 403's for some reason. most likely something changes the sessionKey after the login request. I remember seeing a method named SessionEncrypt that takes the first 16 bytes of the sessionKey and does some stuff with rijndael. let's take a look at it
it's using a lot of virtual function calls
rijndaelManaged = (int *)thunk_FUN_008ae738(Class$System.Security.Cryptography.RijndaelManaged);
RijndaelManaged$$.ctor(rijndaelManaged,0);
...
uVar1 = DMHttpApi$$CopySessionKey(0x10);
...
(**(code **)(*rijndaelManaged + 0x128))
(rijndaelManaged,uVar1,*(undefined4 *)(*rijndaelManaged + 300));
(**(code **)(*rijndaelManaged + 0x198))(rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x19c))
;
piVar2 = (int *)(**(code **)(*rijndaelManaged + 0x170))
(rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x174));
uVar1 = (**(code **)(*rijndaelManaged + 0x110))
(rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x114));
...
let's scroll down for clues.
since it says ICryptoTransform, I guessed this is the call to one of the transform methods of the encryptor
pTransformBlock = (code **)FUN_0086a584(encryptor,Class$System.Security.Cryptography.ICryptoTransform,5);
encryptedBytes = (**pTransformBlock)(encryptor,bytes,0,numBytes,pTransformBlock[1]);
let's look at the docs for ICryptoTransform. the signature for TransformBlock is:
public byte[] TransformFinalBlock (byte[] inputBuffer, int inputOffset, int inputCount);
that matches, and the last parameter is a decompilation error. this also
tells me that what i named bytes
is our standard Array
struct we've
used in other hooks
so, are the 16 bytes of the sessionKey the IV or the Key? it's tricky to tell, the IV has to be 16 bytes, but the key can also be 16 bytes
also, I can't find any x-refs to this so let's hook it and see where it's being called from
very interesting. it seems that this is not what I thought it was.
it appears to be encrypting values in memory, specifically important
in-game things. it's called as soon as you start a live and the call is the
second last Int::set_Value
here:
void BuffInfo$$.ctor(int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4,
undefined4 param_5)
{
undefined4 uVar1;
int iVar2;
if (DAT_03708177 == '\0') {
FUN_00871ed4(0x1d99);
DAT_03708177 = '\x01';
}
Object$$.ctor(param_1,0);
uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int);
Int$$.ctor(uVar1,0,0);
*(undefined4 *)(param_1 + 8) = uVar1;
uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int);
Int$$.ctor(uVar1,0,0);
*(undefined4 *)(param_1 + 0xc) = uVar1;
uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int);
Int$$.ctor(uVar1,0,0);
*(undefined4 *)(param_1 + 0x10) = uVar1;
uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int);
Int$$.ctor(uVar1,0,0);
iVar2 = *(int *)(param_1 + 0xc);
*(undefined4 *)(param_1 + 0x14) = uVar1;
if (iVar2 == 0) {
FUN_0089d750(0);
}
Int$$set_Value(iVar2,param_2,0);
iVar2 = *(int *)(param_1 + 8);
if (iVar2 == 0) {
FUN_0089d750(0);
}
Int$$set_Value(iVar2,param_3,0);
iVar2 = *(int *)(param_1 + 0x10);
if (iVar2 == 0) {
FUN_0089d750(0);
}
Int$$set_Value(iVar2,param_4,0);
iVar2 = *(int *)(param_1 + 0x14);
if (iVar2 == 0) {
FUN_0089d750(0);
}
Int$$set_Value(iVar2,param_5,0);
return;
}
well, I'm not really interested in this, so this is a dead end for now. gotta figure out why my terms of service request isn't being accepted
oh I'm stupid, there's a session key right in the login response! and it
gets xored in DMHttpApi.__c__DisplayClass14_1$$_Login_b__1
what it gets xored with is likely either the old sessionKey or the same randomBytes that are xored with the startup auth key
and sure enough, it's the random bytes that are used to make the login
mask in DMHttpApi.__c__DisplayClass14_0$$_Login_b__0
seeing how not many other places call randomBytes I'm inclined to think this is similar to how old SIF worked and that I should hold onto those random bytes and only regenerate on startup/login
there we go, we are truly logged in now, that was a brainlet moment
so the next request is /userProfile/setProfile
which sets name and
nickname, but most interestingly it contains a field called device_token
let's look at the SetUserProfileRequest
constructor and the device
token getter
void SetUserProfileRequest$$.ctor
(int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4,
undefined4 param_5)
{
Object$$.ctor(param_1,0);
*(undefined4 *)(param_1 + 8) = param_2;
*(undefined4 *)(param_1 + 0xc) = param_3;
*(undefined4 *)(param_1 + 0x10) = param_4;
*(undefined4 *)(param_1 + 0x14) = param_5;
return;
}
void SetUserProfileRequest$$set_DeviceToken(int param_1,undefined4 param_2)
{
*(undefined4 *)(param_1 + 0x14) = param_2;
return;
}
okay, so param 5 of the constructor is the device token. what calls the constructor?
void UserProfileDM$$SetName
(undefined4 param_1,undefined4 deviceToken,undefined4 param_3,undefined4 param_4)
{
int iVar1;
int iVar2;
undefined4 uVar3;
undefined4 uVar4;
undefined4 uVar5;
if (DAT_037070ef == '\0') {
FUN_00871ed4(0xbdc0);
DAT_037070ef = '\x01';
}
iVar1 = thunk_FUN_008ae738(Class$UserProfileDM.__c__DisplayClass5_0);
Object$$.ctor(iVar1,0);
if (iVar1 == 0) {
FUN_0089d750(0);
_DAT_00000008 = param_3;
FUN_0089d750(0);
}
else {
*(undefined4 *)(iVar1 + 8) = param_3;
}
*(undefined4 *)(iVar1 + 0xc) = param_4;
iVar2 = UserProfileDM$$CheckInputTextLength(1,param_1);
if (iVar2 == 1) {
iVar2 = thunk_FUN_008ae738(Class$DotUnder.SVAPI.SetUserProfile);
SetUserProfile$$.ctor(iVar2,0);
uVar3 = thunk_FUN_008ae738(Class$DotUnder.Structure.SetUserProfileRequest);
SetUserProfileRequest$$.ctor(uVar3,param_1,0,0,deviceToken,0);
...
(the last zero param is a decompilation error)
great, so what passes the deviceToken to SetName?
void TutorialDM$$SetUserName(undefined4 param_1,undefined4 param_2,undefined4 param_3)
{
undefined4 uVar1;
uVar1 = PushNotificationDM$$GetDeviceToken(0);
UserProfileDM$$SetName(param_1,uVar1,param_2,param_3,0);
return;
}
would you look at that
let's see where this leads us
int PushNotificationDM$$GetDeviceToken(void)
{
int iVar1;
if (DAT_03706971 == '\0') {
FUN_00871ed4(0x7da8);
DAT_03706971 = '\x01';
}
iVar1 = ASLocalStorage$$get_PushNotificationDeviceToken(0);
if (iVar1 == 0) {
iVar1 = StringLiteral_73;
}
return iVar1;
}
undefined4 ASLocalStorage$$get_PushNotificationDeviceToken(void)
{
int iVar1;
undefined4 uVar2;
if (DAT_037093a3 == '\0') {
FUN_00871ed4(0x91);
DAT_037093a3 = '\x01';
}
if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f930();
}
iVar1 = LocalStorage$$HasKey("PushNotificationDeviceToken",0);
if (iVar1 == 1) {
if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f930();
}
uVar2 = LocalStorage$$GetString("PushNotificationDeviceToken",StringLiteral_73,0);
return uVar2;
}
return 0;
}
cool, so it's in local storage. is there anything that actyally sets it? let's check other ASLocalStorage methods
there is
void ASLocalStorage$$set_PushNotificationDeviceToken(int param_1)
{
if (DAT_037093a4 == '\0') {
FUN_00871ed4(0xf5);
DAT_037093a4 = '\x01';
}
if (param_1 != 0) {
if (((*(ushort *)(Class$DotUnder.LocalStorage + 0xbe) & 0x200) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f930();
}
LocalStorage$$SetString("PushNotificationDeviceToken",param_1,0);
return;
}
if (((*(ushort *)(Class$DotUnder.LocalStorage + 0xbe) & 0x200) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f930();
}
LocalStorage$$DeleteKey("PushNotificationDeviceToken",0);
return;
}
void PushNotificationDM$$SetDeviceToken(undefined4 param_1)
{
ASLocalStorage$$set_PushNotificationDeviceToken(param_1,0);
return;
}
void PushNotification.__c$$_InitializeFirebaseMessaging_b__7_0
(undefined4 param_1,undefined4 param_2,int param_3)
{
undefined4 uVar1;
undefined4 uVar2;
if (DAT_03704bd7 == '\0') {
FUN_00871ed4(0xaff0);
DAT_03704bd7 = '\x01';
}
if (param_3 == 0) {
FUN_0089d750(0);
}
uVar1 = TokenReceivedEventArgs$$get_Token(param_3,0);
uVar1 = String$$Concat("FirebaseMessaging:-Token-Received:",uVar1,0);
uVar2 = FUN_010fe368(Method$Array.Empty()_object_);
if (((*(byte *)(Class$LLAS.LLogger + 0xbf) & 2) != 0) &&
(*(int *)(Class$LLAS.LLogger + 0x70) == 0)) {
FUN_0087f930();
}
LLogger$$Debug(uVar1,uVar2,0);
if (param_3 == 0) {
FUN_0089d750(0);
}
uVar1 = TokenReceivedEventArgs$$get_Token(param_3,0);
PushNotificationDM$$SetDeviceToken(uVar1,0);
return;
}
hmm. it seems to be a standard token for push notifications obtained through firebase. I wonder if they actually check it. I doubt it, but if it's not too hard I would like to get a valid one
in base.apk/assets/google-services-desktop.json we can find the firebase info
{
"project_info": {
"project_number": "304268967066",
"firebase_url": "https://all-stars-dev-91501190.firebaseio.com",
"project_id": "all-stars-dev-91501190",
"storage_bucket": "all-stars-dev-91501190.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:304268967066:ios:fc1a9b49d6829936",
"android_client_info": {
"package_name": "com.Company.ProductName"
}
},
"oauth_client": [
{
"client_id": "304268967066-e3n2no1402f89rqub55u0c7jrhhj4o3d.apps.googleusercontent.com"
}
],
"api_key": [
{
"current_key": "AIzaSyBAehda1QFNwi2g9U4FcT7m8lF8NPAdikg"
}
],
"services": {
"analytics_service": {
"status": 0
},
"appinvite_service": {
"status": 0
}
}
}
],
"configuration_version": "1"
}
using push-receiver
in nodejs and the project_number
for sifas you can
get what look like valid push notification tokens:
const { register, listen } = require('push-receiver');
(async () => {
credentials = await register(304268967066);
console.log(credentials)
})().catch(e => {
console.log(e)
});
the token should be the fcm one
it was hard to find anything else that could do this in other langs and I'm not up for implementing firebase protocol, but yeah, this should be how you get 100% real tokens. or just send it empty, it shouldn't be mandatory
all fields in setProfile are optional and it calls it multiple times, once for name and once for nickname, always providing the device token
name and nickname must be 10 characters max. I keep them alphanumeric, but I think spaces are allowed in both. if you enter an invalid name, the server responds with error 500
the response is once again a LoginResponse
next request is /userProfile/setProfileBirthday
. nothing special here,
just send month and day
next up is /story/finishUserStoryMain
. I think this is sent when you
skip the cutscene after setting your name. again nothing special, the only
dubious part is how is_auto_mode
is determined, but it might be on
a per-chapter basis and stored in the game's database
the response contains a user_model_diff
field which is a UseModel object
just like in the LoginResponse, but most likely only contains changed
fields. maybe those other smaller LoginResponses work like this
next up is /live/start
, which as the name suggests starts a live. as
with old sif, you can pick a partner and the partner user id is sent here.
the response contains literally the entire map - all the note timings and
other things
after the live start request, it sends a saveRuleDescription request
with a rule description id that was added to the user model after the
finishUserStoryMain request. this changes its display_status
from 1 to 3.
no idea what this means but we could speculate it's the story progression
status of each chapter
I also figured out that the "LoginResponse" responses for terms and the
other non-login requests is actually called UserModelResponse and only
expects user_model
so it seems that the game freezes when i start a live while it's hooked. I think this might be because the lib I am hooking from is being reloaded or something, resulting in hooks jumping to deallocated memory. need to investigate how to properly uninstall hooks when the library is unloaded
nevermind, after looking at the log it seems simpler - looks like we're missing an export
10-25 22:34:46.166 9776 9801 E Unity : EntryPointNotFoundException: Unable to find an entry point named 'NativeInputPollTouches' in 'KLab.
NativeInput.Native'.
10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.Dll_PollTouches (System.Void* result, System.Int32
resultLength, System.Int32& anyRemaining) [0x00000] in <00000000000000000000000000000000>:0
10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.PollTouches () [0x00000] in <00000000000000000000000000000000>:0
10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.GetLatestTouches (System.Collections.Generic.List`1
[T] result) [0x00000] in <00000000000000000000000000000000>:0
10-25 22:34:46.166 9776 9801 E Unity : at LLAS.Scene.Live.Game.TouchInputBroadcaster.RefreshInput () [0x00000] in <000000000000000000000
00000000000>:0
let's add all the NativeInput*
exports
yep, doesn't crash anymore now. time to log and implement more requests
once you complete a song, the game sends note-by-note scoring to /live/finish , but I'm not gonna bother emulating that for now. for the tutorial lives, you can skip and it will send all notes with 0 score
the live_id
in this request is interesting. you get it from the live start
request response and it's an unusually large number.
I'm 99% sure the first few digits are the timestamp, but I don't know if the whole thing is a really high resolution timestamp or if it's 2 values combined. doesn't really matter, because we don't need to compute it, I just thought it seemed interesting.
so, for the result dict we get the note id's from the live start response and set them all to judge type 1, voltage 0, card master id 0
wave_stat
dict should match the live_wave_settings
from the live start
request. I think this is always set to all false's when skipping
the turn_stat_dict
seems also all zero'd, except for the first note.
we need to calculate or hardcode current_life
for this first note.
I'll hardcode it for now
not sure where target_score
comes from, I'll hardcode it for now. we
only need to skip the 2 tutorial lives anyway
somehow, skipping submits the score as a "perfect" live and even full combo
live power, yet another value to hardcode or learn to calculate from the team info
for the card stat dict, it's just the card id's from user_live_deck_by_id
with all zero vals
interestingly, at the end of turn stat dict we have notes 0 and 55 which are technically out of bounds and note 0 has current life set like note 1. this pattern repeats in different songs
next up we just have another story complete request and another live skip, nothing to see here. also another saveRuleDescription, this time with id 2 (previous one was id 1)
then there's the favorite character selection,
/communicationMember/setFavoriteMember
. straightforward request,
nothing to see. seems to return a UserModelResponse
/bootstrap/fetchBootstrap
: this request seems to get an user model diff
with only the info requested in the bootstrap_fetch_types
field. i might
map these types out later, for now let's just request what the game
requests. device name is also sent to the server here. no big deal, we can
just make a list of known devices. types are [2,3,4,5,9,10]
in this
particular request. note that this is not a normal UserModelResponse.
it has some extra fetch bootstrap fields
I found a list of valid device names here https://support.google.com/googleplay/answer/1727131?hl=en-GB
this is how the game gets the device name: https://docs.unity3d.com/ScriptReference/SystemInfo-deviceModel.html which would be the 4th column of the csv above
I also noticed that the device_name was only introduced after the first few updates, the first binary I dumped didn't have it
/navi/tapLovePoint
: this is sent when you touch your waifu and get love
points, it contains the same character id used in setFavoriteMember and
returns what looks like a UserModelResponse
/navi/saveUserNaviVoice
: this takes some id (100010004) that doesn't
seem to come from previous responses and returns a UserModelResponse.
it's used to refresh the voices map in the UserModel. not sure what those
voices do, maybe it just updates which menus are unlocked?
/trainingTree/fetchTrainingTree
and /trainingTree/levelUpCard
are
for the tutorial on leveling up your card. nothing special, you just need
your card master id
/trainingTree/activateTrainingTreeCell
I think this is when you do
the member training and spend your AP? not sure where the cell id's come
from, at first I thought it was member master id's but nope, in the user
model they go from 1-9 and then jump to like 101+
/card/updateCardNewFlag
I think this updates the two awakening booleans
in the user model's card info, which switched to true after the training.
again, this returns a UserModelResponse
/communicationMember/finishUserStorySide
this is probably when you get
the dialogue with your waifu. similar to the main story finish request,
nothing to see here
/liveDeck/saveDeckAll
and /liveDeck/saveSuit
, I guess this is where
you set up your live team. I think the squad_dict
is the three teams
you can switch between, but I can't figure out what card_with_suit
signifies. it's a map of card id's to either null or their suit id which
is the same as the card id. maybe suit is the outfit? well, I don't need to
understand anyway for now, I just hardcode the default team the tutorial
picks for me
note that it's important to update our usermodel at this request so that our team is correct in the next live finish requests
/livePartners/fetch
empty request, returns a list of partners before
starting the live
while looking at this request I found something pretty funny, they typo'd "Partners" in the response class name
FetchLiveParntersResponse$$.ctor(iVar1,0);
if (((*(byte *)(Class$DotUnder.Serialization + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Serialization + 0x70) == 0)) {
FUN_0087f930();
}
piVar2 = (int *)Serialization$$TryGetValue(param_1,"partner_select_state");
...
then we have another live start/finish, live partner id is zero
/gacha/fetchGachaMenu
empty request, this is the scouting tutorial and
this retrieves the list of scouting pools
/gacha/draw
is nothing special, you just pass it the gacha id from the
gacha_draws
field in the previous request
yet another fetchBootstrap request, with the same id's as the first one
/tutorial/phaseEnd
this concludes the tutorial. empty request, returns a
UserModelResponse
then it calls /dataLink/fetchGameServiceData
which is similar to the
request it does at the very beginning, however I noticed it does some
interesting stuff with the response in
PlatformGameServiceDM.__c__DisplayClass1_1$$_FetchState_b__1
iVar2 = FetchGameServiceDataResponse$$get_Data(param_2,0);
if (iVar2 == 0) {
FUN_0089d750(0);
}
uVar3 = UserLinkData$$get_AuthorizationKey(iVar2,0);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087f930();
}
uVar3 = Convert$$FromBase64String(uVar3,0);
uVar5 = Lib$$XorBytes(uVar5,uVar3,0);
if (iVar1 == 0) {
FUN_0089d750(0);
}
UserLinkData$$set_ServiceUserCommonKey(iVar1,uVar5,0);
iVar1 = *(int *)(param_1 + 0xc);
if (iVar1 == 0) {
FUN_0089d750(0);
}
it seems that service_user_common_key
is actually set client-side by
xoring authorization_key
with something. most likely the random bytes
from the mask sent with the request. after checking, I noticed that it
also does this with FetchStateBeforeLogin. seems like it's saved as the
PW field in local storage in UserKey$$SetIDPW
in
PlatformGameService$$Migrate
. this PW is then retrieved on login,
and passed to DMHttpApi.__c__DisplayClass14_0$$_Login_b__0
as third
param, which means this is our sessionKey for subsequent login requests
well, we have a bit of a problem here. my fetchGameServiceData request
with the random service id doesn't return anything here, however you might
remember that when the game receives the startup response it does the same
UserKey$$SetIDPW
call, which might mean that this authorization key is
just the one from when we called startup. either way, I want to investigate
whether I'm missing a request that associates the account with my service
id or if it's just doing that because it's an invalid service id
yep, seems like I was missing a /dataLink/linkOnStartUpGameService
call,
all good. let's move to the next request
fetchBootstrap call with types 2,3,4,5,9,10,11 . this returns a list
of login bonuses in fetch_bootstrap_login_bonus_response
.
/loginBonus/readLoginBonus
called once for each login bonus id, empty
response. I guess this marks the various login bonus splashscreens as read.
seems like it's called on event_2d_login_bonuses
with type 3,
beginner_login_bonuses
with type 2, login_bonuses
with type 1. not
sure what happens with the other 3 pools of login bonuses
then we have another saveUserNaviVoice call with id's 100010123, 100010113 and a fetchBootstrap with the usual id's
/notice/fetchNoticeDetail
this is called on one of the super_notices
.
from fetchBootstrap. the way it picks which super notice id to use is
void HomeDM$$GetSuperNoticeDetailMasterId(void)
{
undefined4 uVar1;
if (DAT_03706ed3 == '\0') {
FUN_00871ed4(0x4de5);
DAT_03706ed3 = '\x01';
}
uVar1 = NoticeDM$$GetUnreadSuperNoticeIds(0);
FUN_010f5084(uVar1,Method$Enumerable.LastOrDefault()_int_);
return;
}
which means it gets the last in the list
/notice/fetchNotice
empty request, it just gets news and whatnot
another saveUserNaviVoice with id's 100010105,100010038,100010018
/present/fetch
call, empty request. gets a list of pending presents
saveRuleDescription with id 20
/present/receive
with a list of id's from /present/fetch
to claim all
gifts
yet another fetchBoostrap call with types 2,3,4,5,9,10
... and that's it, that's pretty much all you need to start a new account, log in, complete the tutorial and get your daily rewards. this pretty much concludes what I wanted to achieve for the time being, but I will update this article whenever I find anything interesting
you can check out my client here and use it as a reference for your own https://github.com/Francesco149/todokete
I was looking to extract some assets from the game. not sure if this is standard unity asset packaging but here's how they're laid out
data/data/com.klab.lovelive.allstars/files/files
contains a plain text
file named fe12c8ba-04b2-41b6-b1af-ffee5ee59c43
. this probably changes
for each build. it contains a map of assets to sha-1 hashes:
1
card:100011001 eb73a5908f31c052f9432b37aab01b0614b699ae
card:100011002 245b5bd66c1918d963348fcbcc14d3273b8ba46e
card:100012001 6ba14134894fd894c811ce7fde599d76eb8d3d9f
card:100013001 a30cfa2da9e1b74443d5460f333c09eb33a39198
card:100021001 af7d4f64e1fc4cc64ace920c0fb297304e3fb8ab
card:100021002 c7fc1ff28d960c32bab8612ba45d130ba37c783a
....
live:1000101 97fadca11fac62bc200b4ce77a0120ac594c33cd
live:1000102 51dda48f13b89fab1163b07eec1cf51caa427d66
live:1000103 614f5b6f9462fdcdcda75dc57fd13b84963e6490
live:1000201 420a0779aa8dfcb053bb59e680db1067891c4f91
live:1000202 41c8f8417c090c2c2ad1e523cc19dc8286d67510
live:1000203 eb8db1cf4dfae2551fbdf10eabd178a4712e313e
live:1000401 a350bc00e678a4d16bd7dc15e36823047f4b1c45
...
love:101_10 d3631e84fa5f19443c0db73ff17d65ede5c7a49c
love:101_20 840d74373ff6f226c00f0f4661719167147d9d34
love:101_30 e4004a4444c703c5691e9bb286790c1f170a2929
love:101_40 7bffbefd74c7e5da7b41282f85cdeba227e3bebc
love:101_50 fe37eb04c5d58cd83a09d819ad7953b26d6fcff0
love:102_10 2836059242bf615daf4a36dd64eb990e020cb102
love:102_20 f81e912c293e1967ea62de4a29f27e058c93d04a
love:102_30 befb476da8ee0be8dd5209af56e91547296ff2c2
...
main 6c84c160fe1b5b2834c0b07d5685bd21902d757b
music:10001 7c20bbff2d642da68b7956f8288b0ceddfb559c5
music:10002 6c942bfbd7818f1d3ff03894396224eb6280f9bb
music:10004 ad9c64e29a822d51af5a4983abb11f23f9414cfa
music:10005 1f1fc9cc0b9ee042b7c0594e4c9e760200f7f319
music:10011 1f282efa1d9e04303a2410efd615f391d311db3a
music:10014 ab230a60f63d353b54f1a60624f2ef6909233b63
music:10015 415ba87cc3aa0e1d89e06991893d6a63e135af80
music:10016 af02ed318a935f64e943586d53039583466f7f85
...
op f73a0a9fee3ae140878114a3e88702e5efb3fddd
story-voice:ES/0002/es_0002_01 9c5f38f7b76bf38244094f29f8c085e89de9abef
story-voice:ES/0002/es_0002_02 96c026d971b20de5f43543389d844ccddb34ca43
story-voice:ES/0002/es_0002_03 5118b9ac408ed0aad1841d2b6ed62488d61fd03d
story-voice:ES/0002/es_0002_04 6d93f972fccccbaedaa0515f5cbd0cd7c253f188
story-voice:ES/0002/es_0002_05 c68d4cb4eea1811f5a8fdf1af1cef47abf41778c
story-voice:ES/0002/es_0002_06 49de9ef73e44512311b17c65e18b58ceda7bedb6
story-voice:ES/0002/es_0002_07 34e3841eb8306cc11258fe836e10e14bdd58e98c
story-voice:MES/0001/mes_0001_01 36804fd9971c80b461cab9c5f8d953d0fe8a2272
story-voice:MES/0001/mes_0001_02 028781c7b118237070bf7fdc5504f59aedd80fa9
story-voice:MES/0001/mes_0001_03 e4e6cda8dba865379831922b073a0b2709568cf9
story-voice:MES/0001/mes_0001_04 893560c1540e1c0355d2794d4d2c557ef62fcf52
...
suit:100011001 00a503790212b6aa66b0a666c98e45ea02c5ba35
suit:100012001 1d05237d863d748e68d034a1152e0ca6fc346f90
suit:100013001 98d5ba50cc24b6005b38f5c13fabeb89ffd6e049
suit:100021001 65fe1540c0b1d7fe8db6ae9b8b77bffdc5bf4037
suit:100022001 cc239b4857ecda8aea5a40a73b9acf4cd0cf476a
suit:100023001 ad3cafaf92fc9d995418fbcc1896039fd7495a98
suit:100031001 1d829f5a4e93aa166887d181c33ecdd75254a0bd
suit:100032001 e5aff74749b4bc2f90c45484117bf3b42d9546b4
suit:100033001 7907ae9e67e50ff3b883993be481b2b3aa7f5b4c
...
i see there's a file that starts with masterdata.db, let's search for this string in the game's binary
sure enough, it seems to be some kind of custom encrypted database,
a function named Sqlite$$Open
references that string
iVar1 = Sqlite$$HasDefaultDb();
if (iVar1 == 1) {
Cache$$Clear(0);
Cache$$ClearShortLife(0);
if (((*(byte *)(Class$DotUnder.MasterData + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.MasterData + 0x70) == 0)) {
FUN_0087f998();
}
uVar2 = MasterData$$DbPath("masterdata.db",0);
uVar3 = MasterData$$GetSqliteKey("masterdata.db",0,0);
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
Sqlite$$Add("defaultdb",uVar2,uVar3);
if (param_1 == 0) {
FUN_0089d7b8(0);
}
ok so it just seems to be a random 32-bytes key generated and stored in the shared prefs xml
undefined4 MasterData$$GetSqliteMasterKey(void)
{
int iVar1;
undefined4 uVar2;
undefined4 uVar3;
if (DAT_03704bde == '\0') {
FUN_00871f3c(0x69b6);
DAT_03704bde = '\x01';
}
if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f998();
}
iVar1 = PlayerPrefs$$HasKey("SQ",0);
if (iVar1 == 1) {
if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f998();
}
uVar2 = PlayerPrefs$$GetString("SQ",StringLiteral_73,0);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087f998();
}
uVar2 = Convert$$FromBase64String(uVar2,0);
return uVar2;
}
if (((*(byte *)(Class$DotUnder.DMCryptography + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.DMCryptography + 0x70) == 0)) {
FUN_0087f998();
}
uVar2 = DMCryptography$$RandomBytes(0x20,0);
if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.Convert + 0x70) == 0)) {
FUN_0087f998();
}
uVar3 = Convert$$ToBase64String(uVar2,0);
if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) {
FUN_0087f998();
}
LocalStorage$$SetString("SQ",uVar3);
return uVar2;
}
it later calls "GetRawSqliteKey" and passes it "masterdata.db" and the master key bytes
this appears to do a hmac-sha1 on the masterdata.db string using the master key as the key
masterdataDbUtf8 =
(**(code **)(*piVar2 + 0x148))(piVar2,masterdataDb,*(undefined4 *)(*piVar2 + 0x14c));
if (((*(byte *)(Class$DotUnder.DMCryptography + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.DMCryptography + 0x70) == 0)) {
FUN_0087f998();
}
local_28 = 0;
sha1 = DMCryptography$$HmacSha1(masterdataDbUtf8,masterKeyBytes,0);
sha1bytes = sha1 + 0x10;
it then allocates a 12-byte array made up of 3 uint's and copies the first 12 bytes of the hmac-sha1 hash to it, encoded as uint's
uintArray = InstantiateArray(Class$uint[],3);
i = 0;
do {
j = 0;
ithUint = (uint *)(uintArray + i * 4 + 0x10);
do {
if (uintArray == 0) {
NullPointerExceptionMaybe(0);
}
if (*(uint *)(uintArray + 0xc) <= i) {
masterdataDbUtf8 = IndexOutOfRangeException();
Throw(masterdataDbUtf8,0,0);
}
shiftedUint = *ithUint << 8;
*ithUint = shiftedUint;
if (*(uint *)(uintArray + 0xc) <= i) {
masterdataDbUtf8 = IndexOutOfRangeException();
Throw(masterdataDbUtf8,0,0);
shiftedUint = *ithUint;
}
if (sha1 == 0) {
NullPointerExceptionMaybe(0);
}
if (*(uint *)(sha1 + 0xc) <= (uint)(k + j)) {
masterdataDbUtf8 = IndexOutOfRangeException();
Throw(masterdataDbUtf8,0,0);
}
orMask = (byte *)(sha1bytes + j);
j = j + 1;
*ithUint = shiftedUint | *orMask;
} while (j != 4);
i = i + 1;
sha1bytes = sha1bytes + 4;
k = k + 4;
} while (i != 3);
return uintArray;
later, in GetSqliteKey, it formats these 3 uint's as a string, separated by dots
uintsKey = MasterData$$GetRawSqliteKey(masterdataDb,masterKeyBytes);
if (uintsKey == 0) {
NullPointerExceptionMaybe(0);
}
if (*(int *)(uintsKey + 0xc) == 0) {
masterKeyBytes = IndexOutOfRangeException();
Throw(masterKeyBytes,0,0);
}
uintsKeyData = *(undefined4 *)(uintsKey + 0x10);
masterKeyBytes = FUN_008ae39c(Class$uint,&uintsKeyData);
if (*(uint *)(uintsKey + 0xc) < 2) {
secondUintClass = IndexOutOfRangeException();
Throw(secondUintClass,0,0);
}
secondUint = *(undefined4 *)(uintsKey + 0x14);
secondUintClass = FUN_008ae39c(Class$uint,&secondUint);
if (*(uint *)(uintsKey + 0xc) < 3) {
thirdUintClass = IndexOutOfRangeException();
Throw(thirdUintClass,0,0);
}
thirdUint = *(undefined4 *)(uintsKey + 0x18);
thirdUintClass = FUN_008ae39c(Class$uint,&thirdUint);
String$$Format("{0}.{1}.{2}",masterKeyBytes,secondUintClass,thirdUintClass,0);
Sqlite$$Add
calls OpenDB with the key string and the path of the db file
paired in a single string, separated by a dash
void Sqlite$$Add(undefined4 param_1,undefined4 masterDataPath,undefined4 keyUintsString)
{
undefined4 uVar1;
int iVar2;
if (DAT_0370745e == '\0') {
FUN_00871f3c(0x9154);
DAT_0370745e = '\x01';
}
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
Sqlite$$Remove(param_1);
uVar1 = String$$Format("{0}-{1}",keyUintsString,masterDataPath,0);
uVar1 = Sqlite$$OpenDB(uVar1,"klb_vfs");
iVar2 = **(int **)(Class$DotUnder.Sqlite + 0x5c);
if (iVar2 == 0) {
NullPointerExceptionMaybe(0);
}
FUN_022e73e8(iVar2,param_1,uVar1,Method$Dictionary_string_-IntPtr_.Add());
iVar2 = String$$op_Equality(param_1,"defaultdb",0);
if (iVar2 != 1) {
return;
}
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
*(undefined4 *)(*(int *)(Class$DotUnder.Sqlite + 0x5c) + 4) = uVar1;
return;
}
it seems that klb_vfs
is a vfs module for sqlite3:
if ((klbVfsString != 0) &&
(iVar1 = *(int *)(Class$DotUnder.Sqlite + 0x5c), *(char *)(iVar1 + 8) == '\0')) {
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
iVar1 = *(int *)(Class$DotUnder.Sqlite + 0x5c);
}
*(undefined *)(iVar1 + 8) = 1;
KLBVFS$$klbvfs_register(0);
}
local_1c = 0;
keyPathPairUtf8 = StringExtensionMethods$$ToUtf8(keyPathPair,1);
klbVfsStringUtf8 = StringExtensionMethods$$ToUtf8(klbVfsString,1);
if (((*(byte *)(Class$Forfeit.Sqlite3 + 0xbf) & 2) != 0) &&
(*(int *)(Class$Forfeit.Sqlite3 + 0x70) == 0)) {
FUN_0087f998();
}
local_20 = Sqlite3$$sqlite3_open_v2(keyPathPairUtf8,&local_1c,1,klbVfsStringUtf8,0);
let's take a look at libklbvfs.so . it calls sqlite3_vfs_find
with NULL
in klbvfs_register
and assigns it to some global. then it calls
sqlite3_vfs_register
on some other global. these globals actually overlap
and it's just one big sqlite3_vfs
struct
sqlite doc:
The sqlite3_vfs_find() interface returns a pointer to a VFS given its name.
Names are case sensitive. Names are zero-terminated UTF-8 strings.
If there is no match, a NULL pointer is returned.
what happens if we pass null though?
If zVfsName is NULL then the default VFS is returned.
ok so it's just the default sqlite vfs
ok so this is the struct it's passing to vfs register: https://www.sqlite.org/c3ref/vfs.html
we can map it out and see which function is which, and here's our register function documented
I also mapped the sqlite3_file
and sqlite3_io_methods
structs which
are passed to these methods and documented here
https://www.sqlite.org/c3ref/file.html
https://www.sqlite.org/c3ref/io_methods.html
void klbvfs_register(void)
{
sqlite3_vfs_00012004.pAppData = (sqlite3_vfs *)sqlite3_vfs_find(0);
DAT_00012080 = (sqlite3_vfs_00012004.pAppData)->szOsFile;
sqlite3_vfs_00012004.szOsFile = DAT_00012080 + 0x10;
sqlite3_vfs_register(&sqlite3_vfs_00012004,0);
return;
}
ok, so it's most likely a thin layer over the default vfs
what's this szOsFile stuff though?
The szOsFile field is the size of the subclassed sqlite3_file structure
used by this VFS.
ok, so it's the default szOsFile + 16 for the klab vfs, let's rename it.
this also means that the sqlite3_file
struct has 16 extra bytes at the
end which I will add as unknown fields
void klbvfs_register(void)
{
sqlite3_vfs_00012004.pAppData = (sqlite3_vfs *)sqlite3_vfs_find(0);
g_defaultSzOsFile = (sqlite3_vfs_00012004.pAppData)->szOsFile;
sqlite3_vfs_00012004.szOsFile = g_defaultSzOsFile + 0x10;
sqlite3_vfs_register(&sqlite3_vfs_00012004,0);
return;
}
now if we take a look at the struct memory region we can map out all the unnamed functions it points to
sqlite3_vfs_00012004.szOsFile XREF[1,2]: klbvfs_register:00010620(*),
sqlite3_vfs_00012004.pAppData klbvfs_register:0001060c(W),
sqlite3_vfs_00012004 klbvfs_register:0001061c(W)
00012004 02 00 00 sqlite3_
00 00 00
00 00 00
00012004 02 00 00 00 int 2h iVersion XREF[1]: klbvfs_register:00010620(*)
00012008 00 00 00 00 int 0h szOsFile XREF[1]: klbvfs_register:0001061c(W)
0001200c 00 04 00 00 int 400h mxPathname
00012010 00 00 00 00 sqlite3_ 00000000 pNext
00012014 c4 0b 01 00 char * s_klb_vfs_00010bc4 zName = "klb_vfs"
00012018 00 00 00 00 sqlite3_ 00000000 pAppData XREF[1]: klbvfs_register:0001060c(W)
0001201c 38 06 01 00 void * FUN_00010638 xOpen
00012020 20 07 01 00 void * LAB_00010720 xDelete
00012024 2c 07 01 00 void * FUN_0001072c xAccess
00012028 7c 07 01 00 void * FUN_0001077c xFullPathname
0001202c e8 07 01 00 void * LAB_000107e8 xDlOpen
00012030 f4 07 01 00 void * LAB_000107f4 xDlError
00012034 00 08 01 00 void * LAB_00010800 xDlSym
00012038 0c 08 01 00 void * LAB_0001080c xDlClose
0001203c 18 08 01 00 void * LAB_00010818 xRandomness
00012040 24 08 01 00 void * LAB_00010824 xSleep
00012044 30 08 01 00 void * LAB_00010830 xCurrentTime
00012048 3c 08 01 00 void * LAB_0001083c xGetLastError
0001204c 48 08 01 00 void * LAB_00010848 xCurrentTime
00012050 00 00 00 00 void * 00000000 xNextSystemC
let's take a look at xOpen
int xOpen(sqlite3_vfs *vfs,char *zName,sqlite3_file *file,int flags,int *pOutFlags)
{
sqlite3_io_methods **ppsVar1;
int i;
sqlite3_io_methods *local_28 [4];
char firstChar;
zName = zName + 1;
i = 0;
local_28[1] = (sqlite3_io_methods *)0x0;
local_28[0] = (sqlite3_io_methods *)0x0;
local_28[2] = (sqlite3_io_methods *)0x0;
do {
firstChar = zName[-1];
if (firstChar == '.') {
if (1 < i) {
return 1;
}
i = i + 1;
}
else {
if (9 < ((uint)(byte)firstChar - 0x30 & 0xff)) {
if (i != 2 || firstChar != ' ') {
return 1;
}
i = (*(code *)vfs->pAppData->xOpen)(vfs->pAppData,zName,file,flags,pOutFlags);
ppsVar1 = (sqlite3_io_methods **)((int)&file->pMethods + g_defaultSzOsFile);
*ppsVar1 = file->pMethods;
ppsVar1[1] = local_28[0];
ppsVar1[2] = local_28[1];
ppsVar1[3] = local_28[2];
file->pMethods = (sqlite3_io_methods *)0x11ecc;
return i;
}
local_28[i] = (sqlite3_io_methods *)((uint)(byte)firstChar + (int)local_28[i] * 10 + -0x30);
}
zName = zName + 1;
} while( true );
}
uh oh seems like the extra bytes at the end of the file structs are confusing the decompiler, let's try to define them as a separate 16 byte struct and retype ppsVar1 to it
int xOpen(sqlite3_vfs *vfs,char *zName,sqlite3_file *file,int flags,int *pOutFlags)
{
klbvfs_file *klbfile;
int i;
klbvfs_file newKlbfile;
char firstChar;
zName = zName + 1;
i = 0;
newKlbfile.unk1 = 0;
newKlbfile.pMethods = (sqlite3_io_methods *)0x0;
newKlbfile.unk2 = 0;
do {
firstChar = zName[-1];
if (firstChar == '.') {
if (1 < i) {
return 1;
}
i = i + 1;
}
else {
if (9 < ((uint)(byte)firstChar - 0x30 & 0xff)) {
if (i != 2 || firstChar != ' ') {
return 1;
}
i = (*(code *)vfs->pAppData->xOpen)(vfs->pAppData,zName,file,flags,pOutFlags);
klbfile = (klbvfs_file *)((int)&file->pMethods + g_defaultSzOsFile);
klbfile->pMethods = file->pMethods;
*(sqlite3_io_methods **)&klbfile->unk1 = newKlbfile.pMethods;
klbfile->unk2 = newKlbfile.unk1;
klbfile->unk3 = newKlbfile.unk2;
file->pMethods = (sqlite3_io_methods *)0x11ecc;
return i;
}
(&newKlbfile.pMethods)[i] =
(sqlite3_io_methods *)
((uint)(byte)firstChar + (int)(&newKlbfile.pMethods)[i] * 10 + -0x30);
}
zName = zName + 1;
} while( true );
}
well that's still terrible but you can tell it's saving the default pMethods pointer in the first field and zeroing the other 3 fields
then it replaces pMethods with a different table of functions, which
we can retype to sqlite3_io_methods
and check out to map the methods
//
// .data.rel.ro
// SHT_PROGBITS [0x1ecc - 0x1f17]
// ram: 00011ecc-00011f17
//
sqlite3_io_methods_00011ecc XREF[2]: xOpen:00010700(*),
_elfSectionHeaders::00000264(*)
00011ecc 03 00 00 sqlite3_
00 54 08
01 00 6c
00011ecc 03 00 00 00 int 3h iVersion XREF[2]: xOpen:00010700(*),
_elfSectionHeaders::00000264(*)
00011ed0 54 08 01 00 void * LAB_00010854 xClose
00011ed4 6c 08 01 00 void * LAB_0001086c xRead
00011ed8 3c 0a 01 00 void * LAB_00010a3c xWrite
00011edc 54 0a 01 00 void * LAB_00010a54 xTruncate
00011ee0 6c 0a 01 00 void * LAB_00010a6c xSync
00011ee4 84 0a 01 00 void * LAB_00010a84 xFileSize
00011ee8 9c 0a 01 00 void * LAB_00010a9c xLock
00011eec b4 0a 01 00 void * LAB_00010ab4 xUnlock
00011ef0 cc 0a 01 00 void * LAB_00010acc xCheckReserv
00011ef4 e4 0a 01 00 void * LAB_00010ae4 xFileControl
00011ef8 fc 0a 01 00 void * LAB_00010afc xSectorSize
00011efc 14 0b 01 00 void * LAB_00010b14 xDeviceChara
00011f00 2c 0b 01 00 void * LAB_00010b2c xShmMap
00011f04 44 0b 01 00 void * LAB_00010b44 xShmLock
00011f08 5c 0b 01 00 void * LAB_00010b5c xShmBarrier
00011f0c 74 0b 01 00 void * LAB_00010b74 xShmUnmap
00011f10 8c 0b 01 00 void * LAB_00010b8c xFetch
00011f14 a4 0b 01 00 void * LAB_00010ba4 xUnfetch
it also does something weird with pMethods at the end, maybe the first char is an index of some sort in that case
anyway, if we check out the replaced file methods we see that most of them just pass through the call to the original methods
void file_xClose(sqlite3_file *param_1)
{
/* WARNING: Could not recover jumptable at 0x00010864. Too many branches */
/* WARNING: Treating indirect jump as call */
(**(code **)(*(int *)((int)¶m_1->pMethods + g_defaultSzOsFile) + 4))();
return;
}
in xRead we can see that it's doing funky stuff with constants used in random number generators. this is gonna take a while to decipher
int file_xRead(sqlite3_file *file,byte *dst,int iAmt,int64_t iOfst)
{
uint uVar1;
uint uVar2;
ulonglong uVar3;
int iVar4;
int iVar5;
int iVar6;
int iVar7;
sqlite3_io_methods *defaultMethods;
void *defaultxRead;
int iVar8;
int iVar9;
void *defaultxWrite;
void *defaultxClose;
iVar4 = (**(code **)(*(int *)((int)&file->pMethods + g_defaultSzOsFile) + 8))(file);
if (iVar4 == 0) {
iVar8 = 0x343fd;
iVar5 = 0x269ec3;
defaultMethods = (sqlite3_io_methods *)((int)&file->pMethods + g_defaultSzOsFile);
defaultxClose = defaultMethods->xClose;
if ((int)(iOfst._4_4_ - (uint)((int)iOfst == 0)) < 0) {
iVar8 = 0;
defaultxRead = defaultMethods->xRead;
defaultxWrite = defaultMethods->xWrite;
iVar5 = 1;
}
else {
iVar7 = 1;
iVar9 = 0;
uVar3 = iOfst;
do {
if ((uVar3 & 1) != 0) {
iVar9 = iVar7 * iVar5 + iVar9;
iVar7 = iVar7 * iVar8;
}
uVar1 = (uint)(uVar3 >> 0x21);
uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1;
uVar3 = CONCAT44(uVar1,uVar2);
iVar5 = iVar8 * iVar5 + iVar5;
iVar8 = iVar8 * iVar8;
} while ((uVar2 | uVar1) != 0);
iVar5 = 1;
iVar6 = 0x269ec3;
defaultxClose = (void *)(iVar7 * (int)defaultxClose + iVar9);
iVar8 = 0;
iVar9 = 0x343fd;
uVar3 = iOfst;
do {
if ((uVar3 & 1) != 0) {
iVar8 = iVar5 * iVar6 + iVar8;
iVar5 = iVar5 * iVar9;
}
uVar1 = (uint)(uVar3 >> 0x21);
uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1;
uVar3 = CONCAT44(uVar1,uVar2);
iVar6 = iVar9 * iVar6 + iVar6;
iVar9 = iVar9 * iVar9;
} while ((uVar2 | uVar1) != 0);
defaultxRead = (void *)(iVar5 * (int)defaultMethods->xRead + iVar8);
defaultxWrite = defaultMethods->xWrite;
iVar5 = 1;
iVar8 = 0;
iVar9 = 0x269ec3;
iVar7 = 0x343fd;
do {
if ((iOfst & 1U) != 0) {
iVar8 = iVar5 * iVar9 + iVar8;
iVar5 = iVar5 * iVar7;
}
uVar1 = (uint)((ulonglong)iOfst >> 0x21);
uVar2 = (uint)((byte)((ulonglong)iOfst >> 0x20) & 1) << 0x1f | (uint)iOfst >> 1;
iOfst = CONCAT44(uVar1,uVar2);
iVar9 = iVar7 * iVar9 + iVar9;
iVar7 = iVar7 * iVar7;
} while ((uVar2 | uVar1) != 0);
}
if (0 < iAmt) {
iVar8 = iVar5 * (int)defaultxWrite + iVar8;
do {
uVar1 = (uint)defaultxClose >> 0x18;
defaultxClose = (void *)((int)defaultxClose * 0x343fd + 0x269ec3);
iAmt = iAmt + -1;
*dst = *dst ^ (byte)((uint)defaultxRead >> 0x18) ^ (byte)uVar1 ^ (byte)((uint)iVar8 >> 0x18)
;
iVar8 = iVar8 * 0x343fd + 0x269ec3;
defaultxRead = (void *)((int)defaultxRead * 0x343fd + 0x269ec3);
dst = dst + 1;
} while (iAmt != 0);
}
}
return iVar4;
}
this is probably the bulk of what we need to decipher, but let's take a
look at the other sqlite3_vfs
methods
xDelete is a passthrough
void xDelete(sqlite3_vfs *param_1)
{
/* WARNING: Could not recover jumptable at 0x00010728. Too many branches */
/* WARNING: Treating indirect jump as call */
(*(code *)param_1->pAppData->xDelete)();
return;
}
xAccess splits zName on space and only passes the text after the space through
undefined4 xAccess(sqlite3_vfs *vfs,char *zName,int flags,int *pResOut)
{
char *pSpace;
undefined4 res;
pSpace = strchr(zName,0x20);
if (pSpace != (char *)0x0) {
/* WARNING: Could not recover jumptable at 0x00010770. Too many branches */
/* WARNING: Treating indirect jump as call */
res = (*(code *)vfs->pAppData->xAccess)(vfs->pAppData,pSpace + 1,flags,pResOut);
return res;
}
return 1;
}
xFullPathname splits zName on the space. the first part (including the space) is copied to zOut, while the rest is passed through as the zName parameter. nOut and zOut are adjusted so the result is written after the space
undefined4 xFullPathname(sqlite3_vfs *param_1,char *zName,int nOut,char *zOut)
{
char *pSpace;
undefined4 uVar1;
char *sizeUntilSpace;
pSpace = strchr(zName,0x20);
if (pSpace != (char *)0x0) {
sizeUntilSpace = pSpace + (1 - (int)zName);
__aeabi_memcpy(zOut,zName,sizeUntilSpace);
/* WARNING: Could not recover jumptable at 0x000107dc. Too many branches */
/* WARNING: Treating indirect jump as call */
uVar1 = (*(code *)param_1->pAppData->xFullPathname)
(param_1->pAppData,pSpace + 1,nOut - (int)sizeUntilSpace,
zOut + (int)sizeUntilSpace);
return uVar1;
}
return 1;
}
xDlOpen is a passthrough. same goes for xDlError, xDlSym, xDlClose, xRandomness, xSleep, xCurrentTime, xGetLastError, xCurrentTime
void xDlOpen(sqlite3_vfs *vfs)
{
/* WARNING: Could not recover jumptable at 0x000107f0. Too many branches */
/* WARNING: Treating indirect jump as call */
(*(code *)vfs->pAppData->xDlOpen)();
return;
}
let's also look at the other sqlite3_file
methods
xClose, xWrite, xTruncate, xSync, xFileSize, xLock, xUnlock, xCheckReservedLock, xFileControl, xSectorSize, xDeviceCharacteristics, xShmMap, xShmLock, xShmBarrier, xShmUnmap, xFetch, xUnfetch, are all passthrough so yeah, all we need to do is figure out how xRead works
so looking back at xOpen, the loop scans zName. what I named firstChar is actually currentChar. if it finds a dot at the beginning of the string it returns an error, otherwise it keeps scanning. when it finds a dot, it increases i
the other branch of the if is checking if characters are digit by subtracting 0x30 ('0') and checking if they're in range 0-9 . essentially it's expecting only digits at the beginning and then waiting for a space, if this is not respected it returns an error, otherwise it calls xOpen with the text after the space instead of zName. then it initializes the extra part of the sqlite3_file struct with pMethods and three ints. these three ints are parsed from the first part of the string during the loops prior to finding a space
so essentially, it's just parsing the string with the three key uints from before and saving them into the sqlite3_file struct
ok I made a mistake labeling defaultMethods in xRead, the pointers confused me. it's actually indexing into the extra sqlite3 file struct. that makes more sense
int file_xRead(sqlite3_file *file,byte *dst,int iAmt,int64_t iOfst)
{
uint uVar1;
uint uVar2;
ulonglong uVar3;
int xReadResult;
int rand;
int iVar4;
int key3;
klbvfs_file *klbfile;
int key2;
int rand_seed;
int iVar5;
int key1;
xReadResult = (**(code **)(*(int *)((int)&file->pMethods + g_defaultSzOsFile) + 8))
(file,dst,iAmt,iOfst);
if (xReadResult == 0) {
key2 = 0x343fd;
rand = 0x269ec3;
klbfile = (klbvfs_file *)((int)&file->pMethods + g_defaultSzOsFile);
key1 = klbfile->key1;
/* checks if iOfst is zero? */
if ((int)(iOfst._4_4_ - (uint)((int)iOfst == 0)) < 0) {
rand = 0;
key2 = klbfile->key2;
key3 = klbfile->key3;
rand_seed = 1;
}
else {
key3 = 1;
rand_seed = 0;
uVar3 = iOfst;
do {
if ((uVar3 & 1) != 0) {
rand_seed = key3 * rand + rand_seed;
key3 = key3 * key2;
}
uVar1 = (uint)(uVar3 >> 0x21);
uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1;
uVar3 = CONCAT44(uVar1,uVar2);
rand = key2 * rand + rand;
key2 = key2 * key2;
} while ((uVar2 | uVar1) != 0);
rand = 1;
iVar4 = 0x269ec3;
key1 = key3 * key1 + rand_seed;
key2 = 0;
rand_seed = 0x343fd;
uVar3 = iOfst;
do {
if ((uVar3 & 1) != 0) {
key2 = rand * iVar4 + key2;
rand = rand * rand_seed;
}
uVar1 = (uint)(uVar3 >> 0x21);
uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1;
uVar3 = CONCAT44(uVar1,uVar2);
iVar4 = rand_seed * iVar4 + iVar4;
rand_seed = rand_seed * rand_seed;
} while ((uVar2 | uVar1) != 0);
key2 = rand * klbfile->key2 + key2;
key3 = klbfile->key3;
rand_seed = 1;
rand = 0;
iVar4 = 0x269ec3;
iVar5 = 0x343fd;
do {
if ((iOfst & 1U) != 0) {
rand = rand_seed * iVar4 + rand;
rand_seed = rand_seed * iVar5;
}
uVar1 = (uint)((ulonglong)iOfst >> 0x21);
uVar2 = (uint)((byte)((ulonglong)iOfst >> 0x20) & 1) << 0x1f | (uint)iOfst >> 1;
iOfst = CONCAT44(uVar1,uVar2);
iVar4 = iVar5 * iVar4 + iVar4;
iVar5 = iVar5 * iVar5;
} while ((uVar2 | uVar1) != 0);
}
if (0 < iAmt) {
rand = rand_seed * key3 + rand;
do {
uVar1 = (uint)key1 >> 0x18;
key1 = key1 * 0x343fd + 0x269ec3;
iAmt = iAmt + -1;
*dst = *dst ^ (byte)((uint)key2 >> 0x18) ^ (byte)uVar1 ^ (byte)((uint)rand >> 0x18);
rand = rand * 0x343fd + 0x269ec3;
key2 = key2 * 0x343fd + 0x269ec3;
dst = dst + 1;
} while (iAmt != 0);
}
}
return xReadResult;
}
ok so as i thought it seems to be doing stuff with known pseudorandom algorithm, using the 3 uints as a key
the part where it does weird shifting with iOfst, it seems to be shifting it right by 1 bit as a 64-bit integer and it does this until the offset becomes zero. that means that it's doing log base 2 (off) + 1 iterations
the loop at the end is where it decrypts data, i renamed a few things
if (0 < iAmt) {
random1 = rand_seed * random_multiplier + random1;
do {
highbits = (uint)key1 >> 0x18;
key1 = key1 * 0x343fd + 0x269ec3;
iAmt = iAmt + -1;
*dst = *dst ^ (byte)((uint)random2 >> 0x18) ^ (byte)highbits ^ (byte)((uint)random1 >> 0x18)
;
random1 = random1 * 0x343fd + 0x269ec3;
random2 = random2 * 0x343fd + 0x269ec3;
dst = dst + 1;
} while (iAmt != 0);
}
these 3 pseudorandom numbers are seeded based on the offset
if we're at the very beginning of the file (offset zero), it sets
- random1 = 0
- random2 = key2
random_multiplier
= key3rand_seed
= 1
which means that random1 is just seeded as key3
otherwise the random seeds are generated by running a complicated pseudorandom number generator whose output depends not only on the seed but also how many 1 bits there are in the offset. there really isn't much of a logic to this madness, it's just a matter of copying what the code is doing
while implementing this in python I noticed that when it reads the 3 int's off the hmac-sha1 hash it's supposed to be big endian. that took a while to troubleshoot
either way, here's an implementation in python that successfully reads the masterdata.db . it assumes that you have dumped your /data/data/ directory and haven't changed the hierarchy so it can find the shared prefs xml
#!/bin/env python3
import apsw
import os.path
import sys
from bs4 import BeautifulSoup
import urllib.parse
import base64
import hmac
import hashlib
import struct
def i8(x):
return x & 0xFF
def i32(x):
return x & 0xFFFFFFFF
def hmac_sha1(key, s):
hmacsha1 = hmac.new(key, digestmod=hashlib.sha1)
hmacsha1.update(s)
return hmacsha1.digest()
class KLBVFS(apsw.VFS):
def __init__(self, vfsname='klb_vfs', basevfs=''):
self.vfsname = vfsname
self.basevfs = basevfs
apsw.VFS.__init__(self, self.vfsname, self.basevfs)
def xOpen(self, name, flags):
return KLBVFSFile(self.basevfs, name, flags)
def xAccess(self, pathname, flags):
actual_path = pathname.split(' ', 2)[1]
return super(KLBVFS, self).xAccess(actual_path, flags)
def xFullPathname(self, name):
split = name.split(' ', 2)
fullpath = super(KLBVFS, self).xFullPathname(split[1])
return split[0] + ' ' + fullpath
class KLBVFSFile(apsw.VFSFile):
def __init__(self, inheritfromvfsname, filename, flags):
split = filename.filename().split(' ', 2)
keysplit = split[0].split('.')
self.key = [int(x) for x in keysplit]
apsw.VFSFile.__init__(self, inheritfromvfsname, split[1], flags)
def xRead(self, amount, offset):
result = super(KLBVFSFile, self).xRead(amount, offset)
random2 = 0x000343fd
random1 = 0x00269ec3
key1 = self.key[0]
if offset == 0:
random1 = 0
random2 = key[1]
random_multiplier = key[2]
rand_seed = 1
else:
random_multiplier = 1
rand_seed = 0
tmpoff = offset
while tmpoff != 0:
if (tmpoff & 1) != 0:
rand_seed = i32(i32(random_multiplier * random1) + rand_seed)
random_multiplier = i32(random_multiplier * random2)
tmpoff >>= 1
random1 = i32(i32(random2 * random1) + random1)
random2 = i32(random2 * random2)
random1 = 1
random3 = 0x00269ec3
key1 = i32(i32(random_multiplier * key1) + rand_seed)
random2 = 0
rand_seed = 0x000343fd
tmpoff = offset
while tmpoff != 0:
if (tmpoff & 1) != 0:
random2 = i32(i32(random1 * random3) + random2)
random1 = i32(random1 * rand_seed)
tmpoff >>= 1
random3 = i32(i32(rand_seed * random3) + random3)
rand_seed = i32(rand_seed * rand_seed)
random2 = i32(i32(random1 * self.key[1]) + random2)
random_multiplier = self.key[2]
rand_seed = 1
random1 = 0
random3 = 0x00269ec3
random4 = 0x000343fd
tmpoff = offset
while tmpoff != 0:
if (tmpoff & 1) != 0:
random1 = i32(i32(rand_seed * random3) + random1)
rand_seed = i32(rand_seed * random4)
tmpoff >>= 1
random3 = i32(i32(random4 * random3) + random3)
random4 = i32(random4 * random4)
random1 = i32(i32(rand_seed * random_multiplier) + random1)
b = bytearray(result)
for i in range(amount):
b[i] ^= i8(random2 >> 24) ^ i8(key1 >> 24) ^ i8(random1 >> 24)
key1 = i32(i32(key1 * 0x000343fd) + 0x00269ec3)
random1 = i32(i32(random1 * 0x000343fd) + 0x00269ec3)
random2 = i32(i32(random2 * 0x000343fd) + 0x00269ec3)
return bytes(b)
vfs = KLBVFS()
f = sys.argv[1]
abspath = os.path.abspath(f)
base = os.path.dirname(abspath)
base = os.path.dirname(base)
base = os.path.dirname(base)
prefs = os.path.join(base,
'shared_prefs/com.klab.lovelive.allstars.v2.playerprefs.xml')
xml = open(prefs, 'r').read()
soup = BeautifulSoup(xml, 'lxml-xml')
sq = urllib.parse.unquote(soup.find('string', { 'name': 'SQ' }).getText())
sq = base64.b64decode(sq)
basename = os.path.basename(f)
print("master key: " + sq.hex())
print("basename: " + basename)
sha1 = hmac_sha1(key = sq, s = basename.encode('utf-8'))
print("hmac-sha1: " + sha1.hex())
key = list(struct.unpack('>III', sha1[:12]))
print("key: " + str(key))
vpath = ".".join([str(i32(x)) for x in key]) + " " + f
db = apsw.Connection(vpath, flags=apsw.SQLITE_OPEN_READONLY, vfs='klb_vfs')
for row in db.cursor().execute('select * from sqlite_master'):
print(row)
result:
$ ./klbvfs.py ../sifas-2019-10-31/data/data/com.klab.lovelive.allstars/files/files/masterdata.db_97
9192cfdca08da85daac4b06cd7c5e7616f3dad.db
master key: 9e9f698605a452c0609a95dbd63ede778d2f2a748e2f89cf7a83b1a9979bd650
basename: masterdata.db_979192cfdca08da85daac4b06cd7c5e7616f3dad.db
hmac-sha1: 8c0a650bdecd7c699151c519fae4836a15bc5c78
key: [2349491467, 3738008681, 2438055193]
('table', 'm_accessory', 'm_accessory', 2, 'CREATE TABLE m_accessory(\n id INTEGER NOT NULL,\n name TEXT NOT NULL,\n accessory_no INTEGER N
OT NULL,\n thumbnail_asset_path TEXT NOT NULL,\n accessory_type INTEGER NOT NULL,\n member_master_id INTEGER,\n rarity_type INTEGER NOT NU
LL,\n attribute INTEGER NOT NULL,\n role INTEGER NOT NULL,\n max_grade INTEGER NOT NULL,\n PRIMARY KEY (id),\n FOREIGN KEY (member_master
_id) REFERENCES m_member(id)\n)')
('table', 'm_accessory_background_asset', 'm_accessory_background_asset', 3, 'CREATE TABLE m_accessory_background_asset(\n rarity_type INTEGE
R NOT NULL,\n attribute INTEGER NOT NULL,\n background_asset_path TEXT NOT NULL,\n PRIMARY KEY (rarity_type, attribute)\n)')
('index', 'sqlite_autoindex_m_accessory_background_asset_1', 'm_accessory_background_asset', 4, None)
('table', 'm_accessory_frame_type', 'm_accessory_frame_type', 5, 'CREATE TABLE m_accessory_frame_type(\n rarity_type INTEGER NOT NULL,\n fra
me_type INTEGER NOT NULL,\n PRIMARY KEY (rarity_type)\n)')
('table', 'm_accessory_grade_up', 'm_accessory_grade_up', 7, 'CREATE TABLE m_accessory_grade_up(\n accessory_master_id INTEGER NOT NULL,\n g
rade INTEGER NOT NULL,\n max_level INTEGER NOT NULL,\n accessory_passive_skill_master_id INTEGER,\n PRIMARY KEY (accessory_master_id, grade
),\n FOREIGN KEY (accessory_master_id) REFERENCES m_accessory(id),\n FOREIGN KEY (accessory_passive_skill_master_id) REFERENCES m_accessory_
passive_skill(id)\n)')
...
this can be simplified to just
def klbvfs_transform_byte(byte, key):
byte ^= i8(key[1] >> 24) ^ i8(key[0] >> 24) ^ i8(key[2] >> 24)
key[0] = i32(i32(key[0] * 0x000343fd) + 0x00269ec3)
key[2] = i32(i32(key[2] * 0x000343fd) + 0x00269ec3)
key[1] = i32(i32(key[1] * 0x000343fd) + 0x00269ec3)
return byte
however all this would be extremely slow with random seeks, so all that junk with shifting offsets is necessary to recover the rng state at that particular offset and only iterate log base 2 of offset times x 3 instead of offset times
you can check out a cleaned up implementation that also includes a python codec to decrypt with the builtin open function here
I found some info on how the fast seek calculates the rng state: https://www.nayuki.io/page/fast-skipping-in-a-linear-congruential-generator
following that article, it's possible to simplify the seeking into just a few lines of code
def prng_seek(k, offset, mul, add, mod):
mul1 = mul - 1
modmul = mul1 * mod
y = (pow(mul, offset, modmul) - 1) // mul1 * add
z = pow(mul, offset, mod) * k
return (y + z) % mod
...
def xRead(self, amount, offset):
encrypted = super(KLBVFSFile, self).xRead(amount, offset)
k = [prng_seek(k, offset, 0x343fd, 0x269ec3, 2**32) for k in self.key]
res, _ = klbvfs_transform(bytearray(encrypted), k)
return res
so let's see how the game reads, for example, textures
uint PackReader$$LoadTextureBytes
(undefined4 asset_path,undefined4 caller,undefined4 textureBytesReceiver)
{
int assetData;
if (DAT_03708d1c == '\0') {
FUN_00871f3c(0x7428);
DAT_03708d1c = '\x01';
}
assetData = AssetMasterData$$Get("texture",asset_path,0);
if (assetData != 0) {
PackReader$$ReadBytes(assetData,textureBytesReceiver,caller);
}
return (uint)(assetData != 0);
}
int AssetMasterData$$Get(undefined4 table,undefined4 asset_path)
{
int iVar1;
undefined4 head;
undefined4 size;
undefined4 key1;
undefined4 key2;
int assetData;
undefined4 pack_name;
undefined4 local_30;
undefined4 local_2c [2];
if (DAT_03709485 == '\0') {
FUN_00871f3c(0x1970);
DAT_03709485 = '\x01';
}
local_2c[0] = 0;
local_30 = 0;
if (((*(byte *)(Class$DotUnder.MasterData + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.MasterData + 0x70) == 0)) {
FUN_0087f998();
}
pack_name = **(undefined4 **)(Class$DotUnder.MasterData + 0x5c);
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
assetData = 0;
pack_name = Sqlite$$GetDb(pack_name,0);
iVar1 = IntPtr$$op_Equality(pack_name,0,0);
if (iVar1 == 0) {
local_2c[0] = 0;
local_30 = 0;
head = String$$Format("SELECT-pack_name,-head,-size,-key1,-key2-FROM-{0}-WHERE-asset_path-=-?",
table,0);
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
Sqlite$$Prepare(pack_name,head,local_2c,&local_30,0);
pack_name = local_2c[0];
if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) {
FUN_0087f998();
}
Sqlite$$BindString(pack_name,1,asset_path,0);
iVar1 = AssetMasterData$$Step(local_2c[0]);
if (iVar1 == 0) {
assetData = 0;
}
else {
pack_name = AssetMasterData$$ColumnString(local_2c[0],0);
head = AssetMasterData$$ColumnInt(local_2c[0],1);
size = AssetMasterData$$ColumnInt(local_2c[0],2);
key1 = AssetMasterData$$ColumnInt(local_2c[0],3);
key2 = AssetMasterData$$ColumnInt(local_2c[0],4);
assetData = thunk_FUN_008ae7a0(Class$AssetMasterData.Data);
Object$$.ctor(assetData,0);
*(undefined4 *)(assetData + 8) = pack_name;
*(undefined4 *)(assetData + 0xc) = head;
*(undefined4 *)(assetData + 0x10) = size;
*(undefined4 *)(assetData + 0x14) = key1;
*(undefined4 *)(assetData + 0x18) = key2;
AssetMasterData$$CheckDone(local_2c[0]);
}
pack_name = local_2c[0];
if (((*(byte *)(Class$Forfeit.Sqlite3 + 0xbf) & 2) != 0) &&
(*(int *)(Class$Forfeit.Sqlite3 + 0x70) == 0)) {
FUN_0087f998();
}
Sqlite3$$sqlite3_finalize(pack_name,0);
}
return assetData;
}
so first it grabs pack name, head, size key1, key2 from the texture table.
then it passes it to PackReader$$ReadBytes
ReadBytes just starts a thread where the action actually happens
void PackReader$$ReadBytes(int assetData,int textureBytesReceiver,undefined4 caller)
{
int displayClass2_0;
undefined4 retryDelay;
undefined4 onError;
int *pReceiver;
undefined4 caller_;
undefined4 *pCaller;
if (DAT_03708d1d == '\0') {
FUN_00871f3c(0x7429);
DAT_03708d1d = '\x01';
}
displayClass2_0 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_0);
Object$$.ctor(displayClass2_0,0);
if (displayClass2_0 == 0) {
NullPointerExceptionMaybe(0);
_DAT_00000008 = assetData;
NullPointerExceptionMaybe(0);
pCaller = (undefined4 *)&DAT_0000000c;
_DAT_0000000c = caller;
NullPointerExceptionMaybe(0);
pReceiver = (int *)&DAT_00000010;
_DAT_00000010 = textureBytesReceiver;
NullPointerExceptionMaybe(0);
assetData = _DAT_00000008;
}
else {
pReceiver = (int *)(displayClass2_0 + 0x10);
*pReceiver = textureBytesReceiver;
*(int *)(displayClass2_0 + 8) = assetData;
pCaller = (undefined4 *)(displayClass2_0 + 0xc);
*pCaller = caller;
}
if (assetData == 0) {
retryDelay = thunk_FUN_008ae7a0(Class$System.Exception);
Exception$$.ctor(retryDelay,"assetData-is-null",0);
}
else {
if (displayClass2_0 == 0) {
NullPointerExceptionMaybe(0);
}
if (*pReceiver != 0) {
retryDelay = MConstant$$get_FopenRetryDelay(0);
if (displayClass2_0 == 0) {
NullPointerExceptionMaybe(0);
}
*(undefined4 *)(displayClass2_0 + 0x14) = retryDelay;
retryDelay = thunk_FUN_008ae7a0(Class$Func_Action_);
Func$$.ctor(retryDelay,displayClass2_0,
Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__0(),
Method$Func_Action_..ctor());
onError = thunk_FUN_008ae7a0(Class$Action_Exception_-string_);
FUN_0245c41c(onError,displayClass2_0,Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__1(),
Method$Action_Exception_-string_..ctor());
if (displayClass2_0 == 0) {
NullPointerExceptionMaybe(0);
}
caller_ = *pCaller;
if (((*(byte *)(Class$DotUnder.Concurrency + 0xbf) & 2) != 0) &&
(*(int *)(Class$DotUnder.Concurrency + 0x70) == 0)) {
FUN_0087f998();
}
Concurrency$$RunSubthread(retryDelay,onError,caller_,2,0);
return;
}
retryDelay = thunk_FUN_008ae7a0(Class$System.Exception);
Exception$$.ctor(retryDelay,"callback-is-null",0);
}
Throw(retryDelay,0,Method$PackReader.ReadBytes());
caseD_15();
return;
}
undefined4 PackReader.__c__DisplayClass2_0$$_ReadBytes_b__0(int param_1)
{
int displayClass2_1;
int iVar1;
undefined4 externalPackPath;
undefined4 head;
undefined4 size;
undefined4 key1;
undefined4 key2;
int *piVar2;
void *packName;
int iVar3;
int iVar4;
if (DAT_03708d1f == '\0') {
FUN_00871f3c(0xb5c7);
DAT_03708d1f = '\x01';
}
displayClass2_1 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_1);
Object$$.ctor(displayClass2_1,0);
if (displayClass2_1 == 0) {
NullPointerExceptionMaybe(0);
_DAT_0000000c = param_1;
NullPointerExceptionMaybe(0);
}
else {
*(int *)(displayClass2_1 + 0xc) = param_1;
}
*(undefined4 *)(displayClass2_1 + 8) = 0;
while( true ) {
iVar1 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_2);
Object$$.ctor(iVar1,0);
if (iVar1 == 0) {
NullPointerExceptionMaybe(0);
piVar2 = (int *)&DAT_0000000c;
_DAT_0000000c = displayClass2_1;
NullPointerExceptionMaybe(0);
}
else {
piVar2 = (int *)(iVar1 + 0xc);
*piVar2 = displayClass2_1;
}
iVar4 = *piVar2;
if (iVar4 == 0) {
NullPointerExceptionMaybe(0);
}
packName = *(void **)(param_1 + 8);
iVar4 = *(int *)(iVar4 + 8);
if (packName == (void *)0x0) {
NullPointerExceptionMaybe(0);
}
packName = AssetMasterData.Data$$get_PackName(packName);
externalPackPath = PackageManager$$GetExternalPackPath(packName);
iVar3 = *(int *)(param_1 + 8);
if (iVar3 == 0) {
NullPointerExceptionMaybe(0);
}
head = AssetMasterData.Data$$get_Head(iVar3,0);
iVar3 = *(int *)(param_1 + 8);
if (iVar3 == 0) {
NullPointerExceptionMaybe(0);
}
size = AssetMasterData.Data$$get_Size(iVar3,0);
iVar3 = *(int *)(param_1 + 8);
if (iVar3 == 0) {
NullPointerExceptionMaybe(0);
}
key1 = AssetMasterData.Data$$get_Key1(iVar3,0);
iVar3 = *(int *)(param_1 + 8);
if (iVar3 == 0) {
NullPointerExceptionMaybe(0);
}
key2 = AssetMasterData.Data$$get_Key2(iVar3,0);
iVar4 = PackReader$$ReadDecrypt(externalPackPath,head,size,key1,key2,(uint)(iVar4 != 9));
if (iVar1 == 0) {
NullPointerExceptionMaybe(0);
_DAT_00000008 = iVar4;
NullPointerExceptionMaybe(0);
iVar4 = _DAT_00000008;
}
else {
*(int *)(iVar1 + 8) = iVar4;
}
if (iVar4 != 0) break;
Thread$$Sleep(*(undefined4 *)(param_1 + 0x14),0);
iVar1 = *(int *)(displayClass2_1 + 8) + 1;
*(int *)(displayClass2_1 + 8) = iVar1;
if (9 < iVar1) {
externalPackPath = thunk_FUN_008ae7a0(Class$System.Exception);
Exception$$.ctor(externalPackPath,StringLiteral_9337,0);
Throw(externalPackPath,0,Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__0());
externalPackPath = caseD_15();
return externalPackPath;
}
}
externalPackPath = thunk_FUN_008ae7a0(Class$System.Action);
Action$$.ctor(externalPackPath,iVar1,Method$PackReader.__c__DisplayClass2_2._ReadBytes_b__2(),0);
return externalPackPath;
}
so it looks like it's calling ReadDecrypt, which leads to libpenguin
int _Penguin_Decrypt(byte *param_1,long param_2,size_t param_3,char *param_4,int param_5,int param_6
)
{
FILE *__stream;
int iVar1;
size_t sVar2;
byte *pbVar3;
byte *pbVar4;
int iVar5;
__stream = fopen(param_4,"r");
if (__stream == (FILE *)0x0) {
iVar1 = -1;
}
else {
iVar1 = fseek(__stream,param_2,0);
if (iVar1 == 0) {
sVar2 = fread(param_1,1,param_3,__stream);
if (param_3 == sVar2) {
fclose(__stream);
if (0 < (int)param_3) {
iVar5 = 0x3039;
pbVar3 = param_1;
do {
pbVar4 = pbVar3 + 1;
*pbVar3 = *pbVar3 ^ (byte)((uint)param_5 >> 0x18) ^ (byte)((uint)param_6 >> 0x18) ^
(byte)((uint)iVar5 >> 0x18);
param_5 = param_5 * 0x343fd + 0x269ec3;
param_6 = param_6 * 0x343fd + 0x269ec3;
iVar5 = iVar5 * 0x343fd + 0x269ec3;
pbVar3 = pbVar4;
} while (pbVar4 != param_1 + param_3);
}
}
else {
fclose(__stream);
iVar1 = -3;
}
}
else {
fclose(__stream);
iVar1 = -2;
}
}
return iVar1;
}
oh nice, it's just the same encryption as the database except one of the
three keys is hardcoded to 0x3039
ok but let's go back, where does the file path come from?
void PackageManager$$GetExternalPackPath(undefined4 packName)
{
undefined4 filePath;
if (DAT_03708d38 == '\0') {
FUN_00871f3c(0x7445);
DAT_03708d38 = '\x01';
}
filePath = PackageManager$$GetPackFilePath(packName);
if (((*(byte *)(Class$LLAS.AssetLoader.PathResolver + 0xbf) & 2) != 0) &&
(*(int *)(Class$LLAS.AssetLoader.PathResolver + 0x70) == 0)) {
FUN_0087f998();
}
PathResolver$$ResolveFullPath(filePath);
return;
}
void PackageManager$$GetPackFilePath(int packName)
{
undefined4 path;
if (DAT_03708d37 == '\0') {
FUN_00871f3c(0x7449);
DAT_03708d37 = '\x01';
}
if (packName == 0) {
NullPointerExceptionMaybe(0);
}
path = String$$Substring(packName,0,1,0);
path = String$$Concat("pkg",path,0);
if (((*(byte *)(Class$System.IO.Path + 0xbf) & 2) != 0) &&
(*(int *)(Class$System.IO.Path + 0x70) == 0)) {
FUN_0087f998();
}
Path$$Combine(path,packName,0);
return;
}
right, I should've guessed it. the pak folders sort all the packages by the first letter, a very simple tree probably to improve performance slightly
ok, so what we know so far is
- pack names refer to the files inside the pkg folders
- each pack potentially contains multiple files, and the asset data tells the pack manager where to seek into the package as well as how big is the file
- encryption is on a per-file basis and uses 2 keys stored in a database plus the hardcoded key, same prng as the sqlite encryption
I think I'll try to find all tables that contain those params and extract everything I can
the asset path strings are really weird, they seem to be short 2 or so character strings with random characters, almost looks like an integer encoded as a string. I can't find any thing that generates them, I think this is just how assets are packaged at klab, and I don't think we can recover the filenames
there is a m_asset_package_mapping
table in the assets db that seems to
link some names to packs and metapacks. these don't seem to be filenames
but rather folder names and it doesnt seem to contain every single asset
if i search for metapack in the game i find a class named SplitMetaPack. it seems to have to do with splitting downloads, we probably don't care
so i added this dump function to my klbvfs implementation:
def do_dump(args):
for source in args.directories:
pattern = re.compile("asset_a_ja_0.db_[a-z0-9]+.db")
matches = [f for f in os.listdir(source) if pattern.match(f)]
assets = matches[0]
print(assets)
dstdir = os.path.join(source, 'texture')
try:
os.mkdir(dstdir)
except FileExistsError:
pass
db = klb_sqlite(assets).cursor()
q = 'select distinct pack_name, head, size, key1, key2 from texture'
for (pack_name, head, size, key1, key2) in db.execute(q):
pkgpath = os.path.join(source, "pkg" + pack_name[:1], pack_name)
key = [key1, key2, 0x3039]
pkg = codecs.open(pkgpath, mode='rb', encoding='klbvfs', errors=key)
pkg.seek(head)
print(key)
print(pkg.errors)
fpath = os.path.join(dstdir, "%s_%d_.png" % (pack_name, head))
dst = open(fpath, 'wb+')
shutil.copyfileobj(pkg, dst, size)
where directories is a list of /files/files directories where pkg folder reside, and sure enough, I got a bunch of correctly decrypted png's! the hard part is figuring all the tables that contain references to files in the pkg files and extract all unique files. a cool idea would be to map out all the offsets and then see if there's any unused files lying around and what they are
some of the decrypted images are weird. they have the jpg header and appear to be small thumbnails, but the filesize is much larger than it should. I suspect this is some custom format that appends a low quality jpg and the full quality image in the same file, but I'm not sure what yet
okay, I used libmagic to detect mime type and it's detecting them as jpe. it seems that some platforms use jpe as an additional low res version of the image along with the original jpeg. renaming to jpe does not help getting the high res version. might have to split them manually
to be continued...?