├── README.md ├── UNLICENSE ├── pic.png └── pic2.png /README.md: -------------------------------------------------------------------------------- 1 | this is a collection of info about love live all stars' internals that I 2 | collect and add as I reverse engineer it 3 | 4 | this information is public domain. feel free to use it and republish it 5 | however you please 6 | 7 | # road to headless client 8 | this is a raw diary of notes i wrote down as I reverse engineered the game 9 | from scratch. the goal was to create a headless client that could connect 10 | to the game servers, create accounts and get daily login rewards. this is 11 | completely uncut, and there will be wrong premature observations that are 12 | later corrected 13 | 14 | I installed the game on android x86 on a PC (had to unroot android to make 15 | it run) and fully updated it. 16 | 17 | then i hooked up the android ssd to my linux machine, mounted it and 18 | searched for any file that contained lovelive in the path and copied 19 | everything 20 | 21 | `split_config.armeabi_v7a.apk` contains the native binaries (lib folder) 22 | 23 | `base.apk` contains assets and the java glue for unity 24 | 25 | i used apktool 2.4.0 to extract the apk's 26 | 27 | game uses unity. as of 2019-10-04, the version is 2018.4.2f1 28 | (found in `base.apk/smali/com/unity3d/player/m.smali`) 29 | 30 | ```smali 31 | const-string v2, "Unity version : %s\n" 32 | 33 | new-array v4, v3, [Ljava/lang/Object; 34 | 35 | const-string v5, "2018.4.2f1" 36 | ``` 37 | 38 | unity uses il2cpp to transpile C# assembly to C++ and compile it to a 39 | native android shared library named il2cpp.so located in 40 | `split_config.armeabi_v7a.apk/lib/arm` 41 | 42 | interestingly, all stars only ships with arm binaries while the japanese 43 | version of the old sif game had x86 binaries as well 44 | 45 | using Il2CppDumper v4.6.0 it's possible to recover the method names and 46 | strings by giving it the il2cpp.so first and then the global-metadata.dat 47 | located in `base.apk/assets/bin/Data/Managed/Metadata` . it should do it 48 | automatically, remember to specify 2018.4 as the unity version. 49 | 50 | Il2CppDumper generates a script.py for IDA. but since i use ghidra instead 51 | of IDA's proprietary garbage, I used this script by worawit: 52 | https://gist.github.com/Francesco149/289a24d2f17ba60f820f801b8bd6754a 53 | for ghidra which takes the IDA script as input and renames everything 54 | 55 | I now have a mostly named disassembly and we have also recovered all the 56 | string constants so reversing stuff should be much easier this way 57 | 58 | after skimming through the strings (they all have the `StringLiteral_` 59 | prefix), i found three interesting strings referenced by 60 | `ServerConfig$$.cctor` 61 | 62 | ``` 63 | https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1002 64 | i0qzc6XbhFfAxjN2 65 | x\'B73DA9C0EE7116836995B5ACED4AA33B095ECAF77B33605833FD759E6E743F1D\' 66 | ``` 67 | 68 | note: these values change with every update, they already changed as I 69 | wrote these notes, but they're pretty easy to find anyway. 70 | 71 | which I speculatively named ServerHost, ServerPassword and ServerKey 72 | 73 | the disassembly of that ServerConfig ctor reveals that they're using a 74 | library named DotUnder 75 | 76 | ```c 77 | void ServerConfig$$.cctor(void) 78 | 79 | { 80 | int iVar1; 81 | int *piVar2; 82 | undefined4 uVar3; 83 | 84 | if (DAT_037021b1 == '\0') { 85 | /* WARNING: Subroutine does not return */ 86 | FUN_008722e4(0x8c63); 87 | } 88 | **(undefined4 **)(Class$DotUnder.ServerConfig + 0x5c) = ServerHost; 89 | piVar2 = (int *)Encoding$$get_UTF8(0); 90 | if (piVar2 == (int *)0x0) { 91 | FUN_0089db60(0); 92 | } 93 | uVar3 = (**(code **)(*piVar2 + 0x148))(piVar2,ServerPassword,*(undefined4 *)(*piVar2 + 0x14c)); 94 | iVar1 = Class$DotUnder.ServerConfig; 95 | *(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4) = uVar3; 96 | *(undefined4 *)(*(int *)(iVar1 + 0x5c) + 8) = ServerKey; 97 | *(undefined4 *)(*(int *)(iVar1 + 0x5c) + 0xc) = StringLiteral_7288; 98 | *(undefined *)(*(int *)(iVar1 + 0x5c) + 0x10) = 0; 99 | return; 100 | } 101 | ``` 102 | 103 | however, googling DotUnder doesn't seem to yield any results so it's 104 | probably an internal library 105 | 106 | we can use getter names to figure out what the fields of the ServerConfig 107 | struct are though, for example: 108 | 109 | ```c 110 | undefined4 Config$$get_StartupKey(void) 111 | 112 | { 113 | if (DAT_03704350 == '\0') { 114 | /* WARNING: Subroutine does not return */ 115 | FUN_008722e4(0x26ce); 116 | } 117 | if (((*(byte *)(Class$DotUnder.ServerConfig + 0xbf) & 2) != 0) && 118 | (*(int *)(Class$DotUnder.ServerConfig + 0x70) == 0)) { 119 | FUN_0087fd40(); 120 | } 121 | return *(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4); 122 | } 123 | ``` 124 | 125 | this tells us that what we named ServerPassword is originally named 126 | StartupKey. why? because it's returning offset 4 of ServerConfig + 0x5c, 127 | which is the same that is assigned in the ctor 128 | 129 | ```c 130 | uVar3 = (**(code **)(*piVar2 + 0x148))(piVar2,ServerPassword,*(undefined4 *)(*piVar2 + 0x14c)); 131 | iVar1 = Class$DotUnder.ServerConfig; 132 | *(undefined4 *)(*(int *)(Class$DotUnder.ServerConfig + 0x5c) + 4) = uVar3; 133 | ``` 134 | 135 | by browsing all references to ServerConfig I renamed ServerHost to 136 | ServerEndpoint and ServerPassword to StartupKey. 137 | 138 | I couldn't find any reference to the ServerKey offset, so for now I'm 139 | leaving it. 140 | 141 | using ghidra's data type manager and checking all the getter names as 142 | before I map out the ServerConfig struct. this makes the decompile output 143 | a whole lot more readable. 144 | 145 | I also figured out that each object has the same wrapper struct where you 146 | have a pointer to the actual data at 0x5c. I saw other object being checked 147 | at the same offsets 148 | 149 | ```c 150 | void ServerConfig$$.cctor(void) 151 | 152 | { 153 | Object *config; 154 | int *utf8; 155 | char *utf8StartupKey; 156 | 157 | if (DAT_037021b1 == '\0') { 158 | /* WARNING: Subroutine does not return */ 159 | FUN_008722e4(0x8c63); 160 | } 161 | Class$DotUnder.ServerConfig->Instance->ServerEndpoint = ServerEndpoint; 162 | utf8 = (int *)Encoding$$get_UTF8(0); 163 | if (utf8 == (int *)0x0) { 164 | FUN_0089db60(0); 165 | } 166 | utf8StartupKey = 167 | (char *)(**(code **)(*utf8 + 0x148))(utf8,StartupKey,*(undefined4 *)(*utf8 + 0x14c)); 168 | config = Class$DotUnder.ServerConfig; 169 | Class$DotUnder.ServerConfig->Instance->StartupKey = utf8StartupKey; 170 | config->Instance->ServerKey = ServerKey; 171 | config->Instance->BuildId = BuildId; 172 | config->Instance->Unk1 = false; 173 | return; 174 | } 175 | ``` 176 | 177 | after digging around some more I found this class named DMHttpApi which has 178 | a method named CalcDigest called in MakeRequestData which does a 179 | hmac sha-1 hash with the given params. so far it all seems very similar to 180 | how the original sif request signing worked 181 | 182 | upon further inspection, MakeRequestData takes 2 strings and concatenates 183 | them with a space in between, then does a hmac-sha1 using some key stored 184 | in DMHttpApi 185 | 186 | ```c 187 | Array * DMHttpApi$$CalcDigest(Array *param_1,Array *param_2,int param_2_index,int param_2_len) 188 | 189 | { 190 | System.Text.Encoding *enc; 191 | Array *param_1_bytes; 192 | Array *param_4_1; 193 | undefined4 uVar1; 194 | Array *key; 195 | uint param_1_len; 196 | 197 | if (DAT_037033d9 == '\0') { 198 | /* WARNING: Subroutine does not return */ 199 | FUN_008722e4(0x2bcc); 200 | } 201 | enc = Encoding$$get_UTF8((System.Text.Encoding *)0x0); 202 | if (enc == (System.Text.Encoding *)0x0) { 203 | ThrowException(0); 204 | } 205 | param_1_bytes = (Array *)(*enc->vtable->DoSomething)(enc,param_1,enc->vtable->SomePredicateFunc); 206 | if (param_1_bytes == (Array *)0x0) { 207 | ThrowException(0); 208 | } 209 | param_4_1 = (Array *)Instantiate(Class$byte[],param_2_len + param_1_bytes->Length + 1); 210 | if (param_1_bytes == (Array *)0x0) { 211 | ThrowException(0); 212 | Array$$Copy(0,0,param_4_1,0,_DAT_0000000c); 213 | ThrowException(0); 214 | } 215 | else { 216 | /* param_4_1 = param_1_bytes 217 | 218 | Array.Copy(src, srcIndex, dst, dstIndex, len) */ 219 | Array$$Copy(param_1_bytes,0,param_4_1,0,param_1_bytes->Length); 220 | } 221 | if (param_4_1 == (Array *)0x0) { 222 | ThrowException(0); 223 | } 224 | param_1_len = param_1_bytes->Length; 225 | if ((uint)param_4_1->Length <= param_1_len) { 226 | uVar1 = FUN_0089ec04(); 227 | ThrowSomeOtherException(uVar1,0,0); 228 | } 229 | /* append a space to param_4_1 */ 230 | (¶m_4_1->Data)[param_1_len] = ' '; 231 | /* append param_2[param_2_index,param_2_len] to param_4_1 */ 232 | Array$$Copy(param_2,param_2_index,param_4_1,param_1_bytes->Length + 1,param_2_len); 233 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 234 | FUN_0087fd40(); 235 | } 236 | key = Class$DotUnder.DMHttpApi->Instance->HmacSha1Key; 237 | if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) && 238 | (Class$DotUnder.DMCryptography->Unk1 == 0)) { 239 | FUN_0087fd40(); 240 | } 241 | DMCryptography$$HmacSha1(param_4_1,key); 242 | param_4_1 = (Array *)Lib$$Hexlify(); 243 | return param_4_1; 244 | } 245 | ``` 246 | 247 | a quick search for references to HmacSha1Key in DMHttpApi tells us that 248 | it's internally called SessionKey 249 | 250 | ```c 251 | undefined4 DMHttpApi$$CopySessionKey(undefined4 param_1) 252 | 253 | { 254 | undefined4 uVar1; 255 | 256 | if (DAT_037033d8 == '\0') { 257 | /* WARNING: Subroutine does not return */ 258 | FUN_008722e4(0x2bcf); 259 | } 260 | uVar1 = Instantiate(Class$byte[],param_1); 261 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 262 | FUN_0087fd40(); 263 | } 264 | Array$$Copy(Class$DotUnder.DMHttpApi->Instance->HmacSha1Key,uVar1,param_1,0); 265 | return uVar1; 266 | } 267 | ``` 268 | 269 | looking for references to the SessionKey field I found this, which looks 270 | very similar to the old SIF request signing 271 | 272 | ```c 273 | void DMHttpApi.__c__DisplayClass14_1$$_Login_b__1(int param_1,int param_2) 274 | 275 | { 276 | undefined4 uVar1; 277 | Array *pAVar2; 278 | int iVar3; 279 | undefined4 uVar4; 280 | undefined8 uVar5; 281 | 282 | if (DAT_037033e2 == '\0') { 283 | /* WARNING: Subroutine does not return */ 284 | FUN_008722e4(0xb482); 285 | } 286 | uVar4 = *(undefined4 *)(param_1 + 8); 287 | if (param_2 == 0) { 288 | ThrowException(0); 289 | } 290 | uVar1 = LoginResponse$$get_SessionKey(param_2,0); 291 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 292 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 293 | FUN_0087fd40(); 294 | } 295 | uVar1 = Convert$$FromBase64String(uVar1,0); 296 | pAVar2 = (Array *)Lib$$XorBytes(uVar4,uVar1); 297 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 298 | FUN_0087fd40(); 299 | } 300 | Class$DotUnder.DMHttpApi->Instance->SessionKey = pAVar2; 301 | if (param_2 == 0) { 302 | ThrowException(0); 303 | } 304 | uVar5 = LoginResponse$$get_LastTimestamp(param_2,0); 305 | Clock$$SetLastTimestamp((int)((ulonglong)uVar5 >> 0x20),(int)uVar5,0); 306 | iVar3 = *(int *)(param_1 + 0x14); 307 | if (iVar3 == 0) { 308 | ThrowException(0); 309 | } 310 | iVar3 = *(int *)(iVar3 + 8); 311 | if (iVar3 == 0) { 312 | ThrowException(0); 313 | } 314 | FUN_023a2ee8(iVar3,param_2,Method$Action_LoginResponse_.Invoke()); 315 | return; 316 | } 317 | ``` 318 | 319 | so essentially what's happening is some http request response contains a 320 | base64 key which is then decoded and xored with a xor key string. the 321 | result is used as the session key 322 | 323 | this appears to be the xor key: 324 | 325 | ```c 326 | uVar4 = *(undefined4 *)(param_1 + 8); 327 | 328 | ... 329 | 330 | pAVar2 = (Array *)Lib$$XorBytes(uVar4,uVar1); 331 | ``` 332 | 333 | param_1 appears to be some class instance, let's map the struct with just 334 | the xor key field for now 335 | 336 | as for param_2, we can deduce that it's a LoginResponse instance since it's 337 | passed as the this pointer for `LoginResponse$$get_SessionKey` 338 | 339 | let's map some of LoginResponse's field by looking at its getters 340 | 341 | ```c 342 | undefined4 LoginResponse$$get_UserModel(int param_1) 343 | 344 | { 345 | return *(undefined4 *)(param_1 + 0xc); 346 | } 347 | 348 | undefined4 LoginResponse$$get_SessionKey(int param_1) 349 | 350 | { 351 | return *(undefined4 *)(param_1 + 8); 352 | } 353 | 354 | uint LoginResponse$$get_IsPlatformServiceLinked(int param_1) 355 | 356 | { 357 | return (uint)*(byte *)(param_1 + 0x10); 358 | } 359 | 360 | undefined8 LoginResponse$$get_LastTimestamp(int param_1) 361 | 362 | { 363 | return CONCAT44(*(undefined4 *)(param_1 + 0x18),*(undefined4 *)(param_1 + 0x1c)); 364 | } 365 | 366 | undefined4 LoginResponse$$get_Cautions(int param_1) 367 | 368 | { 369 | return *(undefined4 *)(param_1 + 0x20); 370 | } 371 | 372 | uint LoginResponse$$get_ShowHomeCaution(int param_1) 373 | 374 | { 375 | return (uint)*(byte *)(param_1 + 0x24); 376 | } 377 | 378 | undefined4 LoginResponse$$get_LiveResume(int param_1) 379 | 380 | { 381 | return *(undefined4 *)(param_1 + 0x28); 382 | } 383 | ``` 384 | 385 | by looking at `DMHttpApi$$Logout` we can map a few unknown fields for 386 | DMHttpApi as well as the connection field 387 | 388 | ```c 389 | void DMHttpApi$$Logout(void) 390 | 391 | { 392 | DMHttpApiObject *pDVar1; 393 | DMHttpApi *pDVar2; 394 | int iVar3; 395 | 396 | if (DAT_037033d6 == '\0') { 397 | /* WARNING: Subroutine does not return */ 398 | FUN_008722e4(0x2bd3); 399 | } 400 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 401 | FUN_0087fd40(); 402 | } 403 | iVar3 = *(int *)&Class$DotUnder.DMHttpApi->Instance->field_0xc; 404 | if (iVar3 != 0) { 405 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 406 | FUN_0087fd40(); 407 | iVar3 = *(int *)&Class$DotUnder.DMHttpApi->Instance->field_0xc; 408 | if (iVar3 == 0) { 409 | iVar3 = 0; 410 | ThrowException(0); 411 | } 412 | } 413 | Network.Connection$$Cancel(iVar3,0); 414 | if (((*(byte *)(_Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) && 415 | (*(int *)(_Class$DotUnder.HttpSubject + 0x70) == 0)) { 416 | FUN_0087fd40(); 417 | } 418 | HttpSubject$$OnCancel(); 419 | } 420 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 421 | FUN_0087fd40(); 422 | } 423 | pDVar2 = Class$DotUnder.DMHttpApi->Instance; 424 | *(undefined4 *)&pDVar2->field_0x4 = 0; 425 | *(undefined4 *)pDVar2 = 0; 426 | pDVar1 = Class$DotUnder.DMHttpApi; 427 | Class$DotUnder.DMHttpApi->Instance->SessionKey = (Array *)0x0; 428 | *(undefined4 *)&pDVar1->Instance->field_0xc = 0; 429 | *(undefined4 *)&pDVar1->Instance[1].field_0x1 = 0; 430 | *(undefined4 *)&pDVar1->Instance[1].field_0x5 = 0; 431 | return; 432 | } 433 | ``` 434 | 435 | by looking at DmHttpApi's getters i was able to name the field IsGuarded 436 | 437 | at this point i just start looking at every DmHttpApi method, this seems 438 | to be a simple counter, and tells me that that Unk3 field is the request id 439 | 440 | ```c 441 | void DMHttpApi$$CreateRequestId(void) 442 | 443 | { 444 | DMHttpApiObject *pDVar1; 445 | 446 | if (DAT_037033da == '\0') { 447 | /* WARNING: Subroutine does not return */ 448 | FUN_008722e4(0x2bd0); 449 | } 450 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 451 | FUN_0087fd40(); 452 | } 453 | pDVar1 = Class$DotUnder.DMHttpApi; 454 | Class$DotUnder.DMHttpApi->Instance->Unk3 = Class$DotUnder.DMHttpApi->Instance->Unk3 + 1; 455 | FUN_01746d54(&pDVar1->Instance->Unk3,0); 456 | return; 457 | } 458 | ``` 459 | 460 | that function call at the end appears to stringify it, so I assume this 461 | returns a string 462 | 463 | in `DMHttpApi.__c__DisplayClass14_1$$_Login_b__2` i found a reference to 464 | some UserKey class, we'll take a look at that later 465 | 466 | more familiar base64 xoring in this login step, also it references 467 | StartupResponse which I should probably start mapping 468 | 469 | ```c 470 | 471 | void DMHttpApi.__c__DisplayClass14_2$$_Login_b__4(int param_1,int param_2) 472 | 473 | { 474 | undefined4 uVar1; 475 | Array *string; 476 | int iVar2; 477 | Array *pAVar3; 478 | 479 | if (DAT_037033e5 == '\0') { 480 | /* WARNING: Subroutine does not return */ 481 | FUN_008722e4(0xb486); 482 | } 483 | if (param_2 == 0) { 484 | ThrowException(0); 485 | uVar1 = StartupResponse$$get_UserId(0,0); 486 | pAVar3 = *(Array **)(param_1 + 8); 487 | ThrowException(0); 488 | } 489 | else { 490 | uVar1 = StartupResponse$$get_UserId(param_2,0); 491 | pAVar3 = *(Array **)(param_1 + 8); 492 | } 493 | string = (Array *)StartupResponse$$get_AuthorizationKey(param_2,0); 494 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 495 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 496 | FUN_0087fd40(); 497 | } 498 | string = Convert$$FromBase64String(string); 499 | pAVar3 = Lib$$XorBytes(pAVar3,string); 500 | UserKey$$SetIDPW(uVar1,pAVar3,0); 501 | iVar2 = *(int *)(param_1 + 0xc); 502 | if (iVar2 == 0) { 503 | ThrowException(0); 504 | } 505 | iVar2 = *(int *)(iVar2 + 0x10); 506 | if (iVar2 == 0) { 507 | ThrowException(0); 508 | } 509 | FUN_023b38fc(iVar2,uVar1,pAVar3,Method$Action_int_-byte[]_.Invoke()); 510 | return; 511 | } 512 | ``` 513 | 514 | I mapped Connection and StartupResponse fields based on the getters as 515 | usual. probably unnecessary 516 | 517 | at this point I start looking through strings again and I find strings that 518 | are most likely api endpoints, most of them referenced by various methods 519 | 520 | ``` 521 | /login/startup 522 | /live/surrender 523 | /navi/tapLovePoint 524 | /terms/agreement 525 | /tutorial/phaseEnd 526 | ``` 527 | 528 | and so on 529 | 530 | let's take a look at what references `/login/startup` 531 | 532 | ```c 533 | undefined4 Startup$$get_Path(void) 534 | 535 | { 536 | if (DAT_036ffcc5 == '\0') { 537 | /* WARNING: Subroutine does not return */ 538 | FUN_008722e4(0x92cd); 539 | } 540 | return login_startup; 541 | } 542 | ``` 543 | 544 | while looking through the Startup methods I notice that ghidra is actually 545 | failing to disassemble a lot of the code because it thinks some functions 546 | aren't returning. I should've disabled non-returning function discovery on 547 | the analysis settings. I'm going to re-run the analysis. this kind of thing 548 | should also be fixable by doing `func.setNoReturn(False)` 549 | 550 | re-analyzing didn't fix, had to manually right click the flow override 551 | comments and set flow to default 552 | 553 | finally `StartupRequestBuilder$$Create` disassembles properly as well as 554 | many other similar functions that used to only be a Instantiate1 call 555 | 556 | ```c 557 | void StartupRequestBuilder$$Create(undefined4 param_1,int param_2) 558 | 559 | { 560 | undefined4 uVar1; 561 | undefined4 uVar2; 562 | 563 | if (DAT_037021c9 == '\0') { 564 | /* WARNING: Subroutine does not return */ 565 | FUN_008722e4(0x92cb); 566 | } 567 | uVar1 = Clock$$get_TimeDifference(0); 568 | uVar2 = Instantiate1(Class$DotUnder.Structure.StartupRequest); 569 | StartupRequest$$.ctor(uVar2,param_1,StringLiteral_73,uVar1,0); 570 | if (param_2 == 0) { 571 | ThrowException(0); 572 | } 573 | FUN_023a2ee8(param_2,uVar2,Method$Action_StartupRequest_.Invoke()); 574 | return; 575 | } 576 | ``` 577 | 578 | I'm not sure how to automatically fixup this thing globally so for now I'm 579 | manually fixing the flow where needed 580 | 581 | this also fixed the disassembly for the Serialization functions which 582 | now tell us exactly what fields the request should have 583 | 584 | ```c 585 | int Serialization$$SerializeStartupRequest(int *param_1) 586 | 587 | { 588 | /* ... */ 589 | 590 | iVar3 = StartupRequest$$get_Mask(param_1,0); 591 | if (iVar3 != 0) { 592 | if (bVar1) { 593 | ThrowException(0); 594 | } 595 | uVar4 = StartupRequest$$get_Mask(param_1,0); 596 | if (iVar2 == 0) { 597 | ThrowException(0); 598 | } 599 | FUN_023742d0(iVar2,mask,uVar4,Method$Dictionary_string_-object_.set_Item()); 600 | } 601 | if (bVar1) { 602 | ThrowException(0); 603 | } 604 | iVar3 = StartupRequest$$get_ResemaraDetectionIdentifier(param_1,0); 605 | if (iVar3 != 0) { 606 | if (bVar1) { 607 | ThrowException(0); 608 | } 609 | uVar4 = StartupRequest$$get_ResemaraDetectionIdentifier(param_1,0); 610 | if (iVar2 == 0) { 611 | ThrowException(0); 612 | } 613 | FUN_023742d0(iVar2,resemara_detection_identifier,uVar4, 614 | Method$Dictionary_string_-object_.set_Item()); 615 | } 616 | if (bVar1) { 617 | ThrowException(0); 618 | } 619 | uStack32 = StartupRequest$$get_TimeDifference(param_1,0); 620 | uVar4 = FUN_008ae744(Class$int,&uStack32); 621 | if (iVar2 == 0) { 622 | ThrowException(0); 623 | } 624 | FUN_023742d0(iVar2,time_difference,uVar4,Method$Dictionary_string_-object_.set_Item()); 625 | return iVar2; 626 | } 627 | ``` 628 | 629 | the `FUN_023742d0` calls set request fields and the second argument is the 630 | field name 631 | 632 | this "resemara detection" is intriguing. after searching for resemara 633 | in the symbols table i found `AndroidPlatform$$LoadResemaraDetectionIdentifier` 634 | 635 | halfway into this function it passes the string "getResemaraDetectionId" 636 | to a function: 637 | 638 | ```c 639 | uVar5 = FUN_0149a010(piVar1,getResemaraDetectionId,piVar3, 640 | Method$AndroidJavaObject.CallStatic()_AndroidJavaObject_); 641 | ``` 642 | 643 | this is very familiar, it's calling into java code, and I imagine piVar1 644 | is a java context object of some sort. 645 | 646 | time to decompile the java side 647 | 648 | for some reason dragging ```base.apk``` into my current ghidra project 649 | wasn't working (it wouldn't show classes.dex to decompile) so I created a 650 | new project and imported ```base.apk``` which correctly decompiled 651 | 652 | you will notice that there's strings defined with a scrambled version of 653 | each function name. this was also present in old SIF, I think it's just 654 | something that java does: 655 | 656 | ``` 657 | ************************************************************** 658 | * 4ebf * 659 | * * 660 | * createResemaraDetectionId * 661 | ************************************************************** 662 | strings::createResemaraDetectionId XREF[1]: 00013b6c(*) 663 | 003a3fa2 19 63 72 string_d 664 | 65 61 74 665 | 65 52 65 666 | 003a3fa2 19 db[1] utf16_size XREF[1]: 00013b6c(*) 667 | 003a3fa2 [0] 19h 668 | 003a3fa3 63 72 65 61 74 utf8 u8"cetRsmrDtcind" data 669 | 65 52 65 73 65 670 | 6d 61 72 61 44 671 | 672 | ``` 673 | 674 | it could be useful to look for calls to those particular functions in the 675 | native binary when they are obfuscated with this system. 676 | 677 | but let's get to the juicy bits, let's search for resemara in the symbols 678 | table and follow the code 679 | 680 | ```java 681 | void ResemaraDetectionIdentifierRequest(ResemaraDetectionIdentifierRequest this,Listener p1) 682 | { 683 | this.(); 684 | this.mListener = p1; 685 | return; 686 | } 687 | 688 | ResemaraDetectionIdentifierRequest getResemaraDetectionId(Listener p0) 689 | { 690 | ResemaraDetectionIdentifierRequest local_0; 691 | 692 | local_0 = new ResemaraDetectionIdentifierRequest(p0); 693 | local_0.createResemaraDetectionId(); 694 | return local_0; 695 | } 696 | 697 | void createResemaraDetectionId(ResemaraDetectionIdentifierRequest this) 698 | { 699 | Thread local_0; 700 | Runnable ref; 701 | 702 | ref = new Runnable(this); 703 | local_0 = new Thread(ref); 704 | local_0.start(); 705 | return; 706 | } 707 | 708 | void run(ResemaraDetectionIdentifierRequest$1 this) 709 | { 710 | Context ref; 711 | AdvertisingIdClient$Info ref_00; 712 | String pSVar1; 713 | String pSVar2; 714 | Activity local_0; 715 | ResemaraDetectionIdentifierRequest pRVar3; 716 | StringBuilder ref_01; 717 | 718 | local_0 = UnityPlayer.currentActivity; 719 | ref = local_0.getApplicationContext(); 720 | ref_00 = AdvertisingIdClient.getAdvertisingIdInfo(ref); 721 | pSVar1 = ref_00.getId(); 722 | local_0 = UnityPlayer.currentActivity; 723 | ref = local_0.getApplicationContext(); 724 | pSVar2 = ref.getPackageName(); 725 | pRVar3 = this.this$0; 726 | ref_01 = new StringBuilder(); 727 | ref_01.append(pSVar1); 728 | ref_01.append(pSVar2); 729 | pSVar1 = ref_01.toString(); 730 | pSVar1 = ResemaraDetectionIdentifierRequest.access$000(pRVar3,pSVar1); 731 | if (pSVar1 == "") { 732 | ResemaraDetectionIdentifierRequest.access$100 733 | (this.this$0,"",ResemaraDetectionIdResultKind.Failed); 734 | } 735 | else { 736 | ResemaraDetectionIdentifierRequest.access$100 737 | (this.this$0,pSVar1,ResemaraDetectionIdResultKind.Succeeded); 738 | } 739 | return; 740 | } 741 | 742 | String access$000(ResemaraDetectionIdentifierRequest p0,String p1) 743 | { 744 | String pSVar1; 745 | 746 | pSVar1 = p0.md5(p1); 747 | return pSVar1; 748 | } 749 | 750 | void access$100(ResemaraDetectionIdentifierRequest p0,String p1,ResemaraDetectionIdResultKind p2) 751 | 752 | { 753 | p0.sendMessage(p1,p2); 754 | return; 755 | } 756 | 757 | void sendMessage(ResemaraDetectionIdentifierRequest this,String p1,ResemaraDetectionIdResultKind p2) 758 | { 759 | int iVar1; 760 | Listener ref; 761 | 762 | if (this.mListener != null) { 763 | ref = this.mListener; 764 | iVar1 = p2.getKindInt(); 765 | ref.onReceived(p1,iVar1); 766 | } 767 | return; 768 | } 769 | 770 | String md5(ResemaraDetectionIdentifierRequest this,String p1) 771 | { 772 | MessageDigest ref; 773 | byte[] pbVar1; 774 | String ref_00; 775 | int iVar2; 776 | BigInteger ref_01; 777 | StringBuilder ref_02; 778 | 779 | ref = MessageDigest.getInstance("MD5"); 780 | ref.reset(); 781 | pbVar1 = p1.getBytes("UTF-8"); 782 | ref.update(pbVar1); 783 | pbVar1 = ref.digest(); 784 | ref_01 = new BigInteger(1,pbVar1); 785 | ref_00 = ref_01.toString(0x10); 786 | while (iVar2 = ref_00.length(), iVar2 < 0x20) { 787 | ref_02 = new StringBuilder(); 788 | ref_02.append("0"); 789 | ref_02.append(ref_00); 790 | ref_00 = ref_02.toString(); 791 | } 792 | return ref_00; 793 | } 794 | ``` 795 | 796 | okay, so it's just cramming a bunch of info into a string which is then 797 | turned into a md5 hash and right-padded with zeros to 0x20 characters. 798 | shouldn't be too hard to emulate if needed 799 | 800 | back to the native code. I keep searching for startup in the symbols table 801 | and eventually notice a class named ```DotUnder.SVAPI.Startup```. looking 802 | for references to this brings me to this function 803 | 804 | ```c 805 | void DMHttpApi.__c__DisplayClass14_2$$_Login_b__3(int param_1,undefined4 param_2) 806 | 807 | { 808 | int svapi; 809 | int response; 810 | 811 | if (DAT_037033e4 == '\0') { 812 | FUN_008722e4(0xb485); 813 | DAT_037033e4 = '\x01'; 814 | } 815 | svapi = Instantiate1(Class$DotUnder.SVAPI.Startup); 816 | Startup$$.ctor(svapi,0); 817 | response = *(int *)(param_1 + 0x10); 818 | if (response == 0) { 819 | response = Instantiate1(Class$Action_StartupResponse_); 820 | FUN_023a2ed4(response,param_1,Method$DMHttpApi.__c__DisplayClass14_2._Login_b__4(), 821 | Method$Action_StartupResponse_..ctor()); 822 | *(int *)(param_1 + 0x10) = response; 823 | } 824 | if (svapi == 0) { 825 | ThrowException(0); 826 | } 827 | RuleNoAuth$$Send(svapi,param_2,response,Method$RuleNoAuth_StartupRequest_-StartupResponse_.Send()) 828 | ; 829 | return; 830 | } 831 | ``` 832 | 833 | it seems that this SVAPI class is responsible for constructing the API 834 | calls. let's search for it 835 | 836 | tracking down the methods is tricky, it seems that all the methods 837 | are called indirectly, maybe virtual methods? 838 | 839 | ```c 840 | void RuleNoAuth$$Send(int svapi,undefined4 param_2,undefined4 param_3,int method) 841 | 842 | { 843 | code **ppcVar1; 844 | 845 | if (svapi == 0) { 846 | ThrowException(0); 847 | } 848 | ppcVar1 = (code **)**(code ***)(*(int *)(method + 0xc) + 0x60); 849 | (**ppcVar1)(svapi,param_2,param_3,1,1,ppcVar1); 850 | return; 851 | } 852 | ``` 853 | 854 | dead end for now, we will maybe come back to this later, let's scroll 855 | around some more in the DisplayClass14 methods which all seem to relate to 856 | the login/auth process 857 | 858 | the PublicEncrypt method is a simple call to the standard c# encryption lib 859 | 860 | ``` 861 | void DMCryptography$$PublicEncrypt(undefined4 param_1) 862 | 863 | { 864 | int provider; 865 | 866 | if (DAT_037033cb == '\0') { 867 | FUN_008722e4(0x2bc6); 868 | DAT_037033cb = '\x01'; 869 | } 870 | if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) && 871 | (Class$DotUnder.DMCryptography->Unk1 == 0)) { 872 | FUN_0087fd40(); 873 | } 874 | provider = *(int *)&Class$DotUnder.DMCryptography->Instance->field_0x4; 875 | if (provider == 0) { 876 | ThrowException(0); 877 | } 878 | RSACryptoServiceProvider$$Encrypt(provider,param_1,1,0); 879 | return; 880 | } 881 | ``` 882 | 883 | this is also familiar, old SIF used public key encryption and a random 884 | string of bytes too 885 | 886 | if we look at the msdn documentation we find that the overloads for Encrypt 887 | are: 888 | 889 | * `Encrypt(Byte[], Boolean)` Encrypts data with the RSA algorithm. 890 | * `Encrypt(Byte[], RSAEncryptionPadding)` Encrypts data with the RSA 891 | algorithm using the specified padding. 892 | 893 | in our case, it's using the first overload and the last zero parameter is 894 | either incorrect decompilation or additional stuff generated by il2cpp 895 | 896 | this function also tells us that offset 0x4 of DMCryptography is the 897 | RSACryptoServiceProvider instance 898 | 899 | key size is 1024: 900 | 901 | ```c 902 | int * DMCryptography$$CreateRSAProvider(void) 903 | 904 | { 905 | int *provider; 906 | 907 | if (DAT_037033d0 == '\0') { 908 | FUN_008722e4(0x2bc4); 909 | DAT_037033d0 = '\x01'; 910 | } 911 | provider = (int *)Instantiate1(Class$System.Security.Cryptography.RSACryptoServiceProvider); 912 | RSACryptoServiceProvider$$.ctor(provider,0x400,0); 913 | if (provider == (int *)0x0) { 914 | ThrowException(0); 915 | } 916 | (**(code **)(*provider + 0x118))(provider,rsaKey,*(undefined4 *)(*provider + 0x11c)); 917 | return provider; 918 | } 919 | ``` 920 | 921 | the public rsa key is: 922 | 923 | ``` 924 | v2VElqvCwrhdiXJRKerrlvfsnXS0L29uNtPhfK8SBfPludwYhfIPZupwhE3UcO0VZ8zQAXrzJ3Qgkw+qEOmtsNEKaCnk9uue/FAlrRqe+DRoNkNnx2BTAIU8rVZOPKjuFYgjd7JxbNAFEVNOp4jPfDCHBFJ4/b4+pDgZThr+CVk=AQAB 925 | ``` 926 | 927 | DMCryptography's constructors tells me that the field i previously named 928 | Unk1 is an instance of RNGCryptoServiceProvider 929 | 930 | ```c 931 | void DMCryptography$$.cctor(void) 932 | 933 | { 934 | void *rngProvider; 935 | undefined4 provider; 936 | 937 | if (DAT_037033d1 == '\0') { 938 | FUN_008722e4(0x2bcb); 939 | DAT_037033d1 = '\x01'; 940 | } 941 | rngProvider = (void *)Instantiate1(Class$System.Security.Cryptography.RNGCryptoServiceProvider); 942 | RNGCryptoServiceProvider$$.ctor(rngProvider,0); 943 | Class$DotUnder.DMCryptography->Instance->Unk1 = rngProvider; 944 | provider = DMCryptography$$CreateRSAProvider(); 945 | *(undefined4 *)&Class$DotUnder.DMCryptography->Instance->rsaCryptoServiceProvider = provider; 946 | return; 947 | } 948 | ``` 949 | 950 | if we take a look at the CallMain method we find yet another piece of the 951 | puzzle. finally we get to construct the raw http request. it's a bit hard 952 | to read where it works with 64-bit integers (userId and time) but. 953 | I cleaned up and renamed everything 954 | 955 | i renamed the string literal references to their contents for readability 956 | 957 | ```c 958 | void DMHttpApi$$Call(Array *path,Array *body,undefined4 displayClass13_0xc,byte displayClass13_0x8, 959 | undefined displayClass13_0x18,Array *mv) 960 | 961 | { 962 | int displayClass13; 963 | undefined4 tmpClass; 964 | Array *requestId; 965 | undefined4 pathWithQuery; 966 | int hasUserId; 967 | int hasValue; 968 | ushort httpApi_0xbe; 969 | undefined4 byteAction; 970 | undefined4 callErrorAction; 971 | DMHttpApi *httpApi; 972 | uint uDisplayClass13_0x8; 973 | int isGuarded; 974 | byte *pDisplayClass13_0x8; 975 | undefined8 milliTimestamp; 976 | int64_t tmpValue; 977 | undefined8 objMilliTimestamp; 978 | undefined8 uStack56; 979 | byte bDisplayClass13_0x8; 980 | 981 | if (DAT_037033d4 == '\0') { 982 | FUN_008722e4(0x2bce); 983 | DAT_037033d4 = '\x01'; 984 | } 985 | objMilliTimestamp = 0; 986 | uStack56 = 0; 987 | displayClass13 = Instantiate1(Class$DMHttpApi.__c__DisplayClass13_0); 988 | Object$$.ctor(displayClass13,0); 989 | if (displayClass13 == 0) { 990 | ThrowException(0); 991 | pDisplayClass13_0x8 = &DAT_00000008; 992 | DAT_00000008 = displayClass13_0x8; 993 | ThrowException(0); 994 | _DAT_0000000c = displayClass13_0xc; 995 | ThrowException(0); 996 | } 997 | else { 998 | pDisplayClass13_0x8 = (byte *)(displayClass13 + 8); 999 | *pDisplayClass13_0x8 = displayClass13_0x8; 1000 | *(undefined4 *)(displayClass13 + 0xc) = displayClass13_0xc; 1001 | } 1002 | *(undefined *)(displayClass13 + 0x18) = displayClass13_0x18; 1003 | pathWithQuery = "a"; 1004 | tmpClass = Time$$get_realtimeSinceStartup(0); 1005 | if (displayClass13 == 0) { 1006 | ThrowException(0); 1007 | } 1008 | *(undefined4 *)(displayClass13 + 0x10) = tmpClass; 1009 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1010 | FUN_0087fd40(); 1011 | } 1012 | requestId = DMHttpApi$$CreateRequestId(); 1013 | if (mv == (Array *)0x0) { 1014 | pathWithQuery = String$$Format("?p={0}&id=",pathWithQuery,0); 1015 | } 1016 | else { 1017 | pathWithQuery = String$$Format("?p={0}&mv={1}&id=",pathWithQuery,mv,0); 1018 | } 1019 | pathWithQuery = String$$Concat(path,pathWithQuery,0); 1020 | pathWithQuery = String$$Concat(pathWithQuery,requestId,0); 1021 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1022 | FUN_0087fd40(); 1023 | } 1024 | hasUserId = FUN_021f760c(Class$DotUnder.DMHttpApi->Instance,Method$Nullable_int_.get_HasValue()); 1025 | if (hasUserId == 1) { 1026 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1027 | FUN_0087fd40(); 1028 | } 1029 | tmpValue._0_4_ = 1030 | FUN_021f7614(Class$DotUnder.DMHttpApi->Instance,Method$Nullable_int_.get_Value()); 1031 | tmpClass = FUN_008ae744(Class$int,&tmpValue); 1032 | tmpClass = String$$Format("&u={0}",tmpClass,0); 1033 | pathWithQuery = String$$Concat(pathWithQuery,tmpClass,0); 1034 | } 1035 | Clock$$get_MilliTimestamp(&tmpValue,0); 1036 | objMilliTimestamp = CONCAT44(tmpValue._4_4_,(undefined4)tmpValue); 1037 | hasValue = FUN_021f8058(&objMilliTimestamp,Method$Nullable_long_.get_HasValue()); 1038 | milliTimestamp = CONCAT44((undefined4)tmpValue,tmpValue._4_4_); 1039 | if (hasValue == 1) { 1040 | milliTimestamp = FUN_021f8060(&objMilliTimestamp,Method$Nullable_long_.get_Value()); 1041 | tmpClass = FUN_008ae744(Class$long,&tmpValue); 1042 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1043 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1044 | tmpClass = String$$Format("&t={0}",tmpClass,0); 1045 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1046 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1047 | pathWithQuery = String$$Concat(pathWithQuery,tmpClass,0); 1048 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1049 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1050 | } 1051 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1052 | FUN_0087fd40(); 1053 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1054 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1055 | } 1056 | tmpClass = DMHttpApi$$MakeRequestData(pathWithQuery,body); 1057 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1058 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1059 | bDisplayClass13_0x8 = *pDisplayClass13_0x8; 1060 | uDisplayClass13_0x8 = (uint)bDisplayClass13_0x8; 1061 | if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) && 1062 | (*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) { 1063 | FUN_0087fd40(); 1064 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1065 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1066 | } 1067 | if (bDisplayClass13_0x8 != 0) { 1068 | uDisplayClass13_0x8 = 1; 1069 | } 1070 | HttpSubject$$OnStart(uDisplayClass13_0x8); 1071 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1072 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1073 | if (*pDisplayClass13_0x8 != 0) { 1074 | httpApi_0xbe = *(ushort *)&Class$DotUnder.DMHttpApi->field_0xbe; 1075 | if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1076 | FUN_0087fd40(); 1077 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1078 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1079 | httpApi_0xbe = *(ushort *)&Class$DotUnder.DMHttpApi->field_0xbe; 1080 | } 1081 | httpApi = Class$DotUnder.DMHttpApi->Instance; 1082 | isGuarded = httpApi->IsGuarded; 1083 | if (isGuarded != 0) { 1084 | if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1085 | FUN_0087fd40(); 1086 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1087 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1088 | isGuarded = Class$DotUnder.DMHttpApi->Instance->IsGuarded; 1089 | } 1090 | if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) && 1091 | (*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) { 1092 | FUN_0087fd40(); 1093 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1094 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1095 | } 1096 | HttpSubject$$OnDuplex(isGuarded,path); 1097 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1098 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1099 | return; 1100 | } 1101 | if (((httpApi_0xbe & 0x200) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1102 | FUN_0087fd40(); 1103 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1104 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1105 | httpApi = Class$DotUnder.DMHttpApi->Instance; 1106 | } 1107 | *(Array **)&httpApi->IsGuarded = path; 1108 | } 1109 | *(undefined4 *)(displayClass13 + 0x1c) = 0; 1110 | *(undefined4 *)(displayClass13 + 0x14) = 0; 1111 | byteAction = Instantiate1(Class$Action_byte[]_); 1112 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1113 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1114 | FUN_023a2ed4(byteAction,displayClass13,_Method$DMHttpApi.__c__DisplayClass13_0._Call_b(void), 1115 | Method$Action_byte[]_..ctor()); 1116 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1117 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1118 | callErrorAction = 1119 | Instantiate1(Class$Action_DMHttpApi.CallError_-int_-HttpSubject.MessageObject_-Action_); 1120 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1121 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1122 | Action$$.ctor(callErrorAction,displayClass13,Method$DMHttpApi.__c__DisplayClass13_0._Call_b__1(), 1123 | Method$Action_DMHttpApi.CallError_-int_-HttpSubject.MessageObject_-Action_..ctor()); 1124 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1125 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1126 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1127 | FUN_0087fd40(); 1128 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1129 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1130 | } 1131 | DMHttpApi$$CallMain(pathWithQuery,tmpClass,byteAction,callErrorAction); 1132 | tmpValue._0_4_ = (undefined4)((ulonglong)milliTimestamp >> 0x20); 1133 | tmpValue._4_4_ = (undefined4)milliTimestamp; 1134 | return; 1135 | } 1136 | ``` 1137 | 1138 | here we can see how it constructs the url, which can contain these query 1139 | params 1140 | 1141 | * p: always seems to be "a". maybe short for platform = android and 1142 | hardcoded at compile time? 1143 | * mv (optional): not sure yet, it's passed to the function 1144 | * id: always 0? 1145 | * u (optional): the user id, stored in the httpapi instance 1146 | 1147 | we already looked at MakeRequestData earlier and how it uses a sha1 hash 1148 | from CalcDigest, but now we know that the first 2 params passed to 1149 | CalcDigest are the url path and the request body 1150 | 1151 | ```c 1152 | Array * DMHttpApi$$MakeRequestData(Array *pathWithQuery,Array *body) 1153 | 1154 | { 1155 | System.Text.Encoding *utf8; 1156 | Array *digest; 1157 | Array *digest_; 1158 | Array *digestBytes; 1159 | undefined4 uVar1; 1160 | int len; 1161 | uint digestLength; 1162 | 1163 | if (DAT_037033dc == '\0') { 1164 | FUN_008722e4(0x2bd4); 1165 | DAT_037033dc = '\x01'; 1166 | } 1167 | utf8 = Encoding$$get_UTF8((System.Text.Encoding *)0x0); 1168 | if (body == (Array *)0x0) { 1169 | ThrowException(0); 1170 | } 1171 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1172 | FUN_0087fd40(); 1173 | } 1174 | digest = DMHttpApi$$CalcDigest(pathWithQuery,body,0,body->Length); 1175 | if (utf8 == (System.Text.Encoding *)0x0) { 1176 | ThrowException(0); 1177 | } 1178 | /* probably a GetBytes call */ 1179 | digest_ = (Array *)(*utf8->vtable->DoSomething)(utf8,digest,utf8->vtable->SomePredicateFunc); 1180 | if (digest_ == (Array *)0x0) { 1181 | ThrowException(0); 1182 | } 1183 | digestBytes = (Array *)Instantiate(Class$byte[],body->Length + digest_->Length + 5); 1184 | if (digestBytes == (Array *)0x0) { 1185 | ThrowException(0); 1186 | } 1187 | if (digestBytes->Length == 0) { 1188 | uVar1 = FUN_0089ec04(); 1189 | ThrowSomeOtherException(uVar1,0,0); 1190 | } 1191 | digestBytes->Data[0] = '['; 1192 | Array$$CopyTo(body,digestBytes,1,0); 1193 | len = body->Length; 1194 | if ((uint)digestBytes->Length <= len + 1U) { 1195 | uVar1 = FUN_0089ec04(); 1196 | ThrowSomeOtherException(uVar1,0,0); 1197 | } 1198 | digestBytes->Data[len + 1] = ','; 1199 | len = body->Length; 1200 | if ((uint)digestBytes->Length <= len + 2U) { 1201 | uVar1 = FUN_0089ec04(); 1202 | ThrowSomeOtherException(uVar1,0,0); 1203 | } 1204 | digestBytes->Data[len + 2] = '\"'; 1205 | Array$$CopyTo(digest_,digestBytes,body->Length + 3,0); 1206 | digestLength = digestBytes->Length; 1207 | if (digestLength < 2) { 1208 | uVar1 = FUN_0089ec04(); 1209 | ThrowSomeOtherException(uVar1,0,0); 1210 | } 1211 | /* Data[digestLength - 2] = '"' */ 1212 | *(undefined *)((int)&digestBytes->Length + digestLength + 2) = 0x22; 1213 | len = digestBytes->Length; 1214 | if (len == 0) { 1215 | uVar1 = FUN_0089ec04(); 1216 | ThrowSomeOtherException(uVar1,0,0); 1217 | } 1218 | /* Data[digestLength - 1] = ']' */ 1219 | *(undefined *)((int)&digestBytes->Length + len + 3) = 0x5d; 1220 | return digestBytes; 1221 | } 1222 | ``` 1223 | 1224 | from here we know what the raw request body looks like this: 1225 | 1226 | 1227 | ``` 1228 | [request body,"hash"] 1229 | ``` 1230 | 1231 | the request body is probably a json object 1232 | 1233 | now I want to look around HttpSubject to see if we can figure out what 1234 | http headers it's using 1235 | 1236 | from a quick look at OnStart it appears that the parameter passed to it is 1237 | a simple boolean to skip the CreateGuard call 1238 | 1239 | ```c 1240 | void HttpSubject$$OnStart(bool createGuardObject) 1241 | 1242 | { 1243 | if (DAT_037033fa == '\0') { 1244 | FUN_008722e4(0x4f22); 1245 | DAT_037033fa = '\x01'; 1246 | } 1247 | if (createGuardObject != true) { 1248 | return; 1249 | } 1250 | if (((*(byte *)(Class$DotUnder.HttpSubject + 0xbf) & 2) != 0) && 1251 | (*(int *)(Class$DotUnder.HttpSubject + 0x70) == 0)) { 1252 | FUN_0087fd40(); 1253 | } 1254 | HttpSubject$$CreateGuardObject(); 1255 | return; 1256 | } 1257 | ``` 1258 | 1259 | ok, after taking a look around the other HttpSubject methods it seems that 1260 | this has more to do with displaying the loading screen than sending the 1261 | request. let's look at ```Network$$PostJson``` instead 1262 | 1263 | ```c 1264 | void Network$$PostJson(Array *url,Array *json,undefined4 response) 1265 | { 1266 | if (DAT_036ffb92 == '\0') { 1267 | FUN_008722e4(0x70b4); 1268 | DAT_036ffb92 = '\x01'; 1269 | } 1270 | if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) && 1271 | (*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) { 1272 | FUN_0087fd40(); 1273 | } 1274 | NetworkAndroid$$PostJson(url,json,response); 1275 | return; 1276 | } 1277 | 1278 | undefined4 NetworkAndroid$$PostJson(Array *url,Array *json,undefined4 response) 1279 | 1280 | { 1281 | int proxy; 1282 | undefined4 proxyAddress; 1283 | int proxyHasAddress; 1284 | int *params; 1285 | int iVar1; 1286 | undefined4 uVar2; 1287 | int proxyHost; 1288 | undefined4 uStack80; 1289 | undefined4 proxyPort; 1290 | undefined8 guid; 1291 | undefined8 uStack64; 1292 | undefined8 guid_; 1293 | undefined8 uStack48; 1294 | 1295 | if (DAT_036ffb98 == '\0') { 1296 | FUN_008722e4(0x7097); 1297 | DAT_036ffb98 = '\x01'; 1298 | } 1299 | guid_ = 0; 1300 | uStack48 = 0; 1301 | if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) && 1302 | (*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) { 1303 | FUN_0087fd40(); 1304 | } 1305 | NetworkAndroid$$Initialize(); 1306 | proxyPort = 0; 1307 | proxy = Config$$get_Proxy(0); 1308 | if (proxy == 0) { 1309 | proxyHost = 0; 1310 | } 1311 | else { 1312 | proxyPort = 0; 1313 | proxyAddress = WebProxy$$get_Address(proxy,0); 1314 | if (((*(byte *)(Class$System.Uri + 0xbf) & 2) != 0) && (*(int *)(Class$System.Uri + 0x70) == 0)) 1315 | { 1316 | FUN_0087fd40(); 1317 | } 1318 | proxyHasAddress = Uri$$op_Inequality(proxyAddress,0,0); 1319 | proxyHost = 0; 1320 | if (proxyHasAddress == 1) { 1321 | proxyHost = WebProxy$$get_Address(proxy,0); 1322 | if (proxyHost == 0) { 1323 | ThrowException(0); 1324 | } 1325 | proxyHost = Uri$$get_Host(proxyHost,0); 1326 | proxy = WebProxy$$get_Address(proxy,0); 1327 | if (proxy == 0) { 1328 | ThrowException(0); 1329 | } 1330 | proxyPort = Uri$$get_Port(proxy,0); 1331 | } 1332 | } 1333 | if (((*(byte *)(Class$System.Guid + 0xbf) & 2) != 0) && (*(int *)(Class$System.Guid + 0x70) == 0)) 1334 | { 1335 | FUN_0087fd40(); 1336 | } 1337 | Guid$$NewGuid(&guid,0); 1338 | guid_ = guid; 1339 | uStack48 = uStack64; 1340 | proxy = Guid$$ToString(&guid_,0); 1341 | proxyAddress = Instantiate1(Class$Network.Connection); 1342 | Network.Connection$$.ctor(proxyAddress,proxy,response); 1343 | if (((*(byte *)(Class$DotUnder.NetworkAndroid + 0xbf) & 2) != 0) && 1344 | (*(int *)(Class$DotUnder.NetworkAndroid + 0x70) == 0)) { 1345 | FUN_0087fd40(); 1346 | } 1347 | proxyHasAddress = *(int *)(*(int *)(Class$DotUnder.NetworkAndroid + 0x5c) + 8); 1348 | if (proxyHasAddress == 0) { 1349 | ThrowException(0); 1350 | } 1351 | FUN_0237432c(proxyHasAddress,proxy,proxyAddress, 1352 | Method$Dictionary_string_-Network.Connection_.Add()); 1353 | params = (int *)Instantiate(Class$object[],7); 1354 | proxyHasAddress = *(int *)(*(int *)(Class$DotUnder.NetworkAndroid + 0x5c) + 4); 1355 | if (params == (int *)0x0) { 1356 | ThrowException(0); 1357 | } 1358 | if ((proxyHasAddress != 0) && 1359 | (iVar1 = FUN_008aea80(proxyHasAddress,*(undefined4 *)(*params + 0x20)), iVar1 == 0)) { 1360 | uVar2 = FUN_0089f30c(); 1361 | ThrowSomeOtherException(uVar2,0,0); 1362 | } 1363 | if (params[3] == 0) { 1364 | uVar2 = FUN_0089ec04(); 1365 | ThrowSomeOtherException(uVar2,0,0); 1366 | } 1367 | params[4] = proxyHasAddress; 1368 | if ((proxy != 0) && 1369 | (proxyHasAddress = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHasAddress == 0)) 1370 | { 1371 | uVar2 = FUN_0089f30c(); 1372 | ThrowSomeOtherException(uVar2,0,0); 1373 | } 1374 | if ((uint)params[3] < 2) { 1375 | uVar2 = FUN_0089ec04(); 1376 | ThrowSomeOtherException(uVar2,0,0); 1377 | } 1378 | params[5] = proxy; 1379 | if ((url != (Array *)0x0) && 1380 | (proxy = FUN_008aea80(url,*(undefined4 *)(*params + 0x20)), proxy == 0)) { 1381 | uVar2 = FUN_0089f30c(); 1382 | ThrowSomeOtherException(uVar2,0,0); 1383 | } 1384 | if ((uint)params[3] < 3) { 1385 | uVar2 = FUN_0089ec04(); 1386 | ThrowSomeOtherException(uVar2,0,0); 1387 | } 1388 | *(Array **)(params + 6) = url; 1389 | if ((json != (Array *)0x0) && 1390 | (proxy = FUN_008aea80(json,*(undefined4 *)(*params + 0x20)), proxy == 0)) { 1391 | uVar2 = FUN_0089f30c(); 1392 | ThrowSomeOtherException(uVar2,0,0); 1393 | } 1394 | if ((uint)params[3] < 4) { 1395 | uVar2 = FUN_0089ec04(); 1396 | ThrowSomeOtherException(uVar2,0,0); 1397 | } 1398 | *(Array **)(params + 7) = json; 1399 | if ((proxyHost != 0) && 1400 | (proxy = FUN_008aea80(proxyHost,*(undefined4 *)(*params + 0x20)), proxy == 0)) { 1401 | uVar2 = FUN_0089f30c(); 1402 | ThrowSomeOtherException(uVar2,0,0); 1403 | } 1404 | if ((uint)params[3] < 5) { 1405 | uVar2 = FUN_0089ec04(); 1406 | ThrowSomeOtherException(uVar2,0,0); 1407 | } 1408 | params[8] = proxyHost; 1409 | proxy = FUN_008ae744(Class$int,&proxyPort); 1410 | if ((proxy != 0) && 1411 | (proxyHost = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHost == 0)) { 1412 | uVar2 = FUN_0089f30c(); 1413 | ThrowSomeOtherException(uVar2,0,0); 1414 | } 1415 | if ((uint)params[3] < 6) { 1416 | uVar2 = FUN_0089ec04(); 1417 | ThrowSomeOtherException(uVar2,0,0); 1418 | } 1419 | params[9] = proxy; 1420 | uStack80 = 10; 1421 | proxy = FUN_008ae744(Class$int,&uStack80); 1422 | if ((proxy != 0) && 1423 | (proxyHost = FUN_008aea80(proxy,*(undefined4 *)(*params + 0x20)), proxyHost == 0)) { 1424 | uVar2 = FUN_0089f30c(); 1425 | ThrowSomeOtherException(uVar2,0,0); 1426 | } 1427 | if ((uint)params[3] < 7) { 1428 | uVar2 = FUN_0089ec04(); 1429 | ThrowSomeOtherException(uVar2,0,0); 1430 | } 1431 | params[10] = proxy; 1432 | NetworkAndroid$$CallStaticOnMainThread("postJson",params); 1433 | return proxyAddress; 1434 | } 1435 | ``` 1436 | 1437 | this seems lengthy, but it's mainly because it's calling into java 1438 | 1439 | the first part checks if a proxy is set and extracts host and port. then it 1440 | generates a guid and reuses the proxy temp vars for the stringified guid 1441 | and the connection, which is a bit confusing. the only proxy information 1442 | that is retained is proxyHost and proxyPort. 1443 | 1444 | the `Guid$$ToString` call wasn't originally named. i figured out what it 1445 | was by googling strings used inside the function and finding it in 1446 | microsoft's dotnet github repo 1447 | 1448 | it seems that network connections are identified by a guid. it's probably 1449 | android stuff we don't care about 1450 | 1451 | next, an array of generic objects is created and all the info previously 1452 | retrieved is packed into it. this data is then passed to java and postJson 1453 | is called 1454 | 1455 | time to go back to java code 1456 | 1457 | I think we're finally at the end of the chain for http requests, it's using 1458 | OkHttp, an open source library, under the hood: 1459 | 1460 | ```java 1461 | void postJson(PostJson this,NetworkListener p1,String p2,String p3,byte[] p4,String p5,int p6,int p7 1462 | ) 1463 | 1464 | { 1465 | OkHttpClient$Builder ref; 1466 | int iVar1; 1467 | MediaType contentType; 1468 | RequestBody pRVar2; 1469 | Request pRVar3; 1470 | Call ref_00; 1471 | OkHttpClient local_0; 1472 | Proxy ref_01; 1473 | Proxy$Type pPVar4; 1474 | SocketAddress ref_02; 1475 | Map ref_03; 1476 | Callback ref_04; 1477 | Request$Builder ref_05; 1478 | 1479 | local_0 = this.mHttpClient; 1480 | ref = local_0.newBuilder(); 1481 | ref = ref.connectTimeout((long)p7 & -0x100000000 | ZEXT48(p7),TimeUnit.SECONDS); 1482 | ref = ref.readTimeout((long)p7,TimeUnit.SECONDS); 1483 | ref = ref.writeTimeout((long)p7,TimeUnit.SECONDS); 1484 | if ((p5 != null) && (iVar1 = p5.length(), 0 < iVar1)) { 1485 | pPVar4 = Proxy$Type.HTTP; 1486 | ref_02 = new SocketAddress(p5,p6); 1487 | ref_01 = new Proxy(pPVar4,ref_02); 1488 | ref = ref.proxy(ref_01); 1489 | } 1490 | local_0 = ref.build(); 1491 | contentType = MediaType.parse("application/json"); 1492 | pRVar2 = PostJsonRequestBody.create(contentType,p4); 1493 | ref_05 = new Request$Builder(); 1494 | ref_05 = ref_05.url(p3); 1495 | ref_05 = ref_05.post(pRVar2); 1496 | pRVar3 = ref_05.build(); 1497 | ref_00 = local_0.newCall(pRVar3); 1498 | ref_03 = this.mRequestList; 1499 | ref_03.put(p2,ref_00); 1500 | ref_04 = new Callback(this,p2,p1); 1501 | ref_00.enqueue(ref_04); 1502 | return; 1503 | } 1504 | ``` 1505 | 1506 | we can easily figure out that p4 is offset: https://github.com/square/okhttp/blob/c4f338ec172411975c9c0f05c7f48fc1b3dca715/okhttp/src/main/java/okhttp3/RequestBody.kt#L131 1507 | 1508 | from the part where it uses SocketAddress, which is documented [here](https://docs.oracle.com/javase/7/docs/api/java/net/InetSocketAddress.html) 1509 | we can figure out that p5 and p6 are addr and port for the proxy 1510 | 1511 | in the last part it adds the request to a map called mRequestList. p2 1512 | appears to be the string key that identifies this request 1513 | 1514 | then it enqueues the request with a custom callback. if we look at the 1515 | PostJsonCallback constructor we have names for all the parameters: 1516 | 1517 | ``` 1518 | void PostJson$PostJsonCallback 1519 | (PostJson$PostJsonCallback this,PostJson p1,String p2,NetworkListener p3) 1520 | 1521 | { 1522 | this.this$0 = p1; 1523 | this.(); 1524 | this.taskId = p2; 1525 | this.listener = p3; 1526 | return; 1527 | } 1528 | ``` 1529 | 1530 | this confirms that the map key is taskId 1531 | 1532 | here is postJson again, but now we've named everything: 1533 | 1534 | ```java 1535 | void postJson(PostJson this,NetworkListener listener,String taskId,String url,byte[] body, 1536 | String proxyAddr,int proxyPort,int timeout) 1537 | 1538 | { 1539 | OkHttpClient$Builder clientBuilder; 1540 | int proxyAddrLen; 1541 | MediaType contentType; 1542 | RequestBody body; 1543 | Request request; 1544 | Call call; 1545 | OkHttpClient httpClient; 1546 | Proxy proxy; 1547 | Proxy$Type proxyType; 1548 | SocketAddress proxySocketAddress; 1549 | Map requests; 1550 | Callback callback; 1551 | Request$Builder requestBuilder; 1552 | 1553 | httpClient = this.mHttpClient; 1554 | clientBuilder = httpClient.newBuilder(); 1555 | clientBuilder = 1556 | clientBuilder.connectTimeout((long)timeout & -0x100000000 | ZEXT48(timeout),TimeUnit.SECONDS) 1557 | ; 1558 | clientBuilder = clientBuilder.readTimeout((long)timeout,TimeUnit.SECONDS); 1559 | clientBuilder = clientBuilder.writeTimeout((long)timeout,TimeUnit.SECONDS); 1560 | if ((proxyAddr != null) && (proxyAddrLen = proxyAddr.length(), 0 < proxyAddrLen)) { 1561 | proxyType = Proxy$Type.HTTP; 1562 | proxySocketAddress = new SocketAddress(proxyAddr,proxyPort); 1563 | proxy = new Proxy(proxyType,proxySocketAddress); 1564 | clientBuilder = clientBuilder.proxy(proxy); 1565 | } 1566 | httpClient = clientBuilder.build(); 1567 | contentType = MediaType.parse("application/json"); 1568 | body = PostJsonRequestBody.create(contentType,body); 1569 | requestBuilder = new Request$Builder(); 1570 | requestBuilder = requestBuilder.url(url); 1571 | requestBuilder = requestBuilder.post(body); 1572 | request = requestBuilder.build(); 1573 | call = httpClient.newCall(request); 1574 | requests = this.mRequestList; 1575 | requests.put(taskId,call); 1576 | callback = new Callback(this,taskId,listener); 1577 | call.enqueue(callback); 1578 | return; 1579 | } 1580 | ``` 1581 | 1582 | so it seems that there aren't any particular headers we should be aware of. 1583 | I guess everything is packed in the json object 1584 | 1585 | so let's take the startup request for example. we can figure out what to 1586 | send by looking at `Serialization$$SerializeStartupRequest` 1587 | 1588 | the json body will be something like: 1589 | 1590 | ```json 1591 | {"mask":"blahblah","resemara_detection_identifier":"123abc","time_difference":123} 1592 | ``` 1593 | 1594 | and by looking at `Serialization$$DeserializeStartupResponse` we can figure 1595 | out that we will receive something like: 1596 | 1597 | ```json 1598 | {"user_id":123,"authorization_key":"123abc"} 1599 | ``` 1600 | 1601 | of course this will all be wrapped in that json array with the hash we 1602 | analyzed earlier 1603 | 1604 | but what is "mask" ? let's take another look at `DMHttpApi$$Login` 1605 | 1606 | ```c 1607 | UserKey$$GetID(&userId,0); 1608 | password = UserKey$$GetPW(0); 1609 | iUserId = FUN_021f760c(&userId,Method$Nullable_int_.get_HasValue()); 1610 | if (password == 0 || iUserId == 0) { 1611 | password = Instantiate1(Class$DMHttpApi.__c__DisplayClass14_2); 1612 | Object$$.ctor(password,0); 1613 | if (password == 0) { 1614 | ThrowException(0); 1615 | } 1616 | *(int *)(password + 0xc) = iVar1; 1617 | if (((Class$DotUnder.DMCryptography->BitField1 & 2) != 0) && 1618 | (Class$DotUnder.DMCryptography->Unk1 == 0)) { 1619 | FUN_0087fd40(); 1620 | } 1621 | data = (Array *)DMCryptography$$RandomBytes(0x20); 1622 | *(Array **)(password + 8) = data; 1623 | mask = DMCryptography$$PublicEncrypt(data); 1624 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 1625 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 1626 | FUN_0087fd40(); 1627 | } 1628 | mask = Convert$$ToBase64String(mask,0); 1629 | data = (Array *)Config$$get_StartupKey(0); 1630 | if (((Class$DotUnder.DMHttpApi->BitField1 & 2) != 0) && (Class$DotUnder.DMHttpApi->Unk1 == 0)) { 1631 | FUN_0087fd40(); 1632 | } 1633 | Class$DotUnder.DMHttpApi->Instance->SessionKey = data; 1634 | uVar2 = Instantiate1(Class$Action_StartupRequest_); 1635 | FUN_023a2ed4(uVar2,password,Method$DMHttpApi.__c__DisplayClass14_2._Login_b__3(), 1636 | Method$Action_StartupRequest_..ctor()); 1637 | StartupRequestBuilder$$Create(mask,uVar2,0); 1638 | } 1639 | ``` 1640 | 1641 | this is essentially the part where it checks if we have an account, and if 1642 | we don't it creates a new one 1643 | 1644 | again this is confusing because it's reusing variables for multiple things 1645 | but as you can see i was able to figure out that the first param of 1646 | `StartupRequestBuilder$$Create` is mask by mapping out the struct with 1647 | getters/setters as explained before, and it's generated by encrypting 1648 | random bytes with the public key we saw before and then encoding it as 1649 | base64 1650 | 1651 | we can also see that it's setting the initial SessionKey to the StartupKey 1652 | which as seen earlier is `i0qzc6XbhFfAxjN2` 1653 | 1654 | this is a good time to try and MITM the http requests that are actually 1655 | sent to see if we're right. we need root to bypass ssl pinning 1656 | 1657 | I used this guide to install magisk and hide root on android x86 1658 | https://asdasd.page/2018/02/18/Install-Magisk-on-Android-x86/ 1659 | 1660 | which consists of 1661 | * copying kernel and ramdisk.img from the android partition to a linux 1662 | machine 1663 | * `mkbootimg --kernel kernel --ramdisk ramdisk.img --output boot.img` 1664 | * copy boot.img back to android device 1665 | `sudo cp boot.img /mnt/ssd/android-8.1-r2/data/media/0/Download/` 1666 | * patch boot.img with MagiskManager 1667 | * copy patched_boot.img back to linux 1668 | `sudo cp /mnt/ssd/android-8.1-r2/data/media/0/Download/magisk_patched.img .` 1669 | * `abootimg -x magisk_patched.img` 1670 | * rename zImage to kernel and overwrite the one in android partition 1671 | * Rename initrd.img to ramdisk.img and overwrite in android partition 1672 | 1673 | now magisk should be installed. enable magisk hide from the settings and 1674 | then from the magisk hide menu, toggle it on for love live 1675 | 1676 | at this point i was planning to install riru and edxposed to then use 1677 | trustmealready to disable ssl pinning and be able to use a mitm proxy 1678 | 1679 | unfortunately magisk was failing to mount some stuff and modules weren't 1680 | working. at least root is working and it's hidden from the game. we 1681 | can work with this. I could try the same thing i did with old sif which 1682 | was to write a library to inject and hook game functions to log requests 1683 | 1684 | so the first thing i look for is a simple library with few exports that is 1685 | loaded after the game. `libKLab.NativeInput.Native.so` looks like a good 1686 | candidate. it looks like it's called from java and exports a handful of 1687 | `Java_com_klab*` functions which are probably the only ones we would need 1688 | to export to replace it. 1689 | 1690 | the idea is, you replace the library, export the same functions, and under 1691 | the hood you load the original library and forward all calls to it while 1692 | you inject your own initialization into a function of your choice 1693 | 1694 | the injected code in this case would hook MakeRequestData, redirecting it 1695 | to a function that calls the real MakeRequestData and prints the json 1696 | object to android's logcat 1697 | 1698 | to avoid generating repetitive dlopen/dlsym code for each export which 1699 | would just make the binary larger for no good reason, I define the exports 1700 | to be just placeholder jmp's at compile time, then at runtime it goes 1701 | through the list of functions and replaces the placeholder jmps with jmps 1702 | to the original library 1703 | 1704 | as a first test, I just make the stub library do absolutely nothing, just 1705 | to see if it works 1706 | 1707 | we must also decide where to initialize our stub library. onInitialize 1708 | seems like a good candidate, as we can see from the disassembly it takes 1709 | a single param: 1710 | 1711 | ```c 1712 | void Java_com_klab_nativeinput_NativeInputJava_onInitialize(JNIEnv *env) 1713 | 1714 | { 1715 | jclass p_Var1; 1716 | 1717 | if (env != (JNIEnv *)0x0) { 1718 | sJEnv = env; 1719 | p_Var1 = (*env->functions->FindClass)(env,"com/klab/nativeinput/NativeInputJava"); 1720 | sJClass = (jclass)(*env->functions->NewGlobalRef)(env,(jobject)p_Var1); 1721 | /* WARNING: Could not recover jumptable at 0x00015ac2. Too many branches */ 1722 | /* WARNING: Treating indirect jump as call */ 1723 | (*sJEnv->functions->GetStaticMethodID)(sJEnv,sJClass,"NativeInputGetTimestamp","()D"); 1724 | return; 1725 | } 1726 | return; 1727 | } 1728 | ``` 1729 | 1730 | so here's my hello world lib: 1731 | 1732 | ```c 1733 | #include 1734 | #include 1735 | #include 1736 | #include 1737 | 1738 | #define log(x) __android_log_write(ANDROID_LOG_DEBUG, __FILE__, x); 1739 | 1740 | #define java_func(func) \ 1741 | Java_com_klab_nativeinput_NativeInputJava_##func 1742 | 1743 | #define exports(macro) \ 1744 | macro(java_func(clearTouch)) \ 1745 | macro(java_func(lock)) \ 1746 | macro(java_func(onFinalize)) \ 1747 | macro(java_func(stockDeviceButtons)) \ 1748 | macro(java_func(stockNativeTouch)) \ 1749 | macro(java_func(testOverrideFlgs)) \ 1750 | macro(java_func(unlock)) \ 1751 | 1752 | /* 1753 | I decided to go with absolute jmp's. since arm doesn't allow 32-bit 1754 | immediate jumps I have to place the address right after the jmp and 1755 | reference it using [pc,#-4]. pc is 8 bytes after the current instruction, 1756 | so #-4 reads 4 bytes after the current instruction. 1757 | 0xBAADF00D is then replaced by the correct address at runtime 1758 | */ 1759 | 1760 | #define define_trampoline(name) \ 1761 | void __attribute__((naked)) name() { \ 1762 | asm("ldr pc,[pc,#-4]"); \ 1763 | asm(".word 0xBAADF00D"); \ 1764 | } 1765 | 1766 | /* runs define_trampoline on all functions listed in exports */ 1767 | exports(define_trampoline) 1768 | 1769 | #define stringify_(x) #x 1770 | #define stringify(x) stringify_(x) 1771 | #define to_string_array(x) stringify(x), 1772 | static char* export_names[] = { exports(to_string_array) 0 }; 1773 | 1774 | void (*_onInitialize)(void* env); 1775 | 1776 | /* 1777 | make memory readadable, writable and executable. size is 1778 | ceiled to a multiple of PAGESIZE and addr is aligned to 1779 | PAGESIZE 1780 | */ 1781 | #define PROT_RWX (PROT_READ | PROT_WRITE | PROT_EXEC) 1782 | #define PAGESIZE sysconf(_SC_PAGESIZE) 1783 | #define PAGEOF(addr) (void*)((int)(addr) & ~(PAGESIZE - 1)) 1784 | #define PAGE_ROUND_UP(x) \ 1785 | ((((int)(x)) + PAGESIZE - 1) & (~(PAGESIZE - 1))) 1786 | #define munprotect(addr, n) \ 1787 | mprotect(PAGEOF(addr), PAGE_ROUND_UP(n), PROT_RWX) 1788 | 1789 | static 1790 | void init() { 1791 | char** s; 1792 | void *original, *stub; 1793 | log("hello from the stub library!"); 1794 | original = dlopen("libKLab.NativeInput.Native.so.bak", RTLD_LAZY); 1795 | stub = dlopen("libKLab.NativeInput.Native.so", RTLD_LAZY); 1796 | for (s = export_names; *s; ++s) { 1797 | void** stub_func = dlsym(stub, *s); 1798 | log(*s); 1799 | munprotect(&stub_func[1], sizeof(void*)); 1800 | stub_func[1] = dlsym(original, *s); 1801 | } 1802 | *(void**)&_onInitialize = 1803 | dlsym(original, stringify(java_func(onInitialize))); 1804 | } 1805 | 1806 | void java_func(onInitialize)(void* env) { 1807 | init(); 1808 | _onInitialize(env); 1809 | } 1810 | ``` 1811 | 1812 | I build it with this script: 1813 | 1814 | ```sh 1815 | #!/bin/sh 1816 | 1817 | CFLAGS="-fPIC -Wall $CFLAGS" 1818 | LDFLAGS="-shared -llog -ldl $LDFLAGS" 1819 | [ -z "$CC" ] && 1820 | echo "please set CC to your android toolchain compiler" && exit 1 1821 | $CC $CFLAGS sniffas.c $LDFLAGS -o libKLab.NativeInput.Native.so 1822 | ``` 1823 | 1824 | remember to download the android standalone toolchain and point CC to it 1825 | 1826 | ```sh 1827 | export CC=~/android-ndk-r20/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang 1828 | ./build.sh 1829 | ``` 1830 | 1831 | then i copy it to android and replace the original library, making sure 1832 | to keep permissions 1833 | 1834 | ```sh 1835 | adb root 1836 | adb push libKLab.NativeInput.Native.so /data/app/ 1837 | adb shell 1838 | 1839 | cd /data/app/com.klab.lovelive.allstars-*/lib/arm/ 1840 | mv libKLab.NativeInput.Native.so{,.bak} 1841 | mv /data/app/libKLab.NativeInput.Native.so . 1842 | chmod 755 libKLab.NativeInput.Native.so 1843 | chown system:system libKLab.NativeInput.Native.so 1844 | exit 1845 | ``` 1846 | 1847 | and sure enough, if we start the game and look at logcat we see: 1848 | 1849 | ``` 1850 | 10-18 21:57:03.260 21620 21645 D sniffas.c: hello from the stub library! 1851 | 10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch 1852 | 10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock 1853 | 10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize 1854 | 10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons 1855 | 10-18 21:57:03.260 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch 1856 | 10-18 21:57:03.261 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs 1857 | 10-18 21:57:03.261 21620 21645 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock 1858 | ``` 1859 | 1860 | and the game runs just fine 1861 | 1862 | let's try to hook MakeRequestData now 1863 | 1864 | first of all we need to know its relative address in memory, from where 1865 | il2cpp stars. just hover over the address in ghidra, you want the 1866 | "Imagebase Offset" 1867 | 1868 | to get the address of the function in memory we can just add this offset 1869 | to the base address of il2cpp.so which we can obtain with `dladdr` and a 1870 | known export. I picked one at random from ghidra's list of exports 1871 | 1872 | ```c 1873 | il2cpp = dlopen("libil2cpp.so", RTLD_LAZY); 1874 | il2cpp_export = dlsym(il2cpp, "UnityAdsEngineInitialize"); 1875 | dladdr(il2cpp_export, &dli); 1876 | sprintf(buf, "il2cpp at %p", dli.dli_fbase); 1877 | log(buf); 1878 | ``` 1879 | 1880 | you can go all fancy and dynamically search for the function's byte pattern 1881 | but for now I'm just gonna hardcode the address 1882 | 1883 | let's print the first 8 bytes at MakeRequestData to check that we are 1884 | indeed getting the right address 1885 | 1886 | ```c 1887 | /* log first 8 bytes at MakeRequestData to check that we got it right */ 1888 | p = buf; 1889 | MakeRequestData = (char*)dli.dli_fbase + 0xEFCDDC; 1890 | p += sprintf(p, "MakeRequestData at %p: ", MakeRequestData); 1891 | for (i = 0; i < 8; ++i) { 1892 | p += sprintf(p, "%02x ", MakeRequestData[i]); 1893 | } 1894 | log(buf); 1895 | ``` 1896 | 1897 | sure enough, we get: 1898 | 1899 | ``` 1900 | 10-19 01:31:26.569 28198 28223 D sniffas.c: il2cpp at 0x8000000 1901 | 10-19 01:31:26.569 28198 28223 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2 1902 | ``` 1903 | 1904 | which matches ghidra: 1905 | 1906 | ``` 1907 | ************************************************************** 1908 | * FUNCTION * 1909 | ************************************************************** 1910 | undefined DMHttpApi$$MakeRequestData() 1911 | undefined r0:1 1912 | DMHttpApi$$MakeRequestData XREF[2]: DMHttpApi$$Call:00f0cab0(c), 1913 | 034637e4(*) 1914 | 00f0cddc f0 48 2d e9 stmdb sp!,{ r4 r5 r6 r7 r11 lr } 1915 | 00f0cde0 10 b0 8d e2 add r11,sp,#0x10 1916 | ``` 1917 | 1918 | okay, let's define our hook function and a global function pointer we will 1919 | use to call the original function 1920 | 1921 | ```c 1922 | static void* (*original_MakeRequestData)(void* pathWithQuery, void* body); 1923 | 1924 | static 1925 | void* hooked_MakeRequestData(void* pathWithQuery, void* body) { 1926 | log("hello from MakeRequestData!"); 1927 | return original_MakeRequestData(pathWithQuery, body); 1928 | } 1929 | ``` 1930 | 1931 | so, what exactly do we need to do to hook this function? it's actually 1932 | really simple. we overwrite the function's code with a jump to our own 1933 | function 1934 | 1935 | the only tricky part is calling the original function, which we just 1936 | overwrote. the solution I use is to copy the original code somewhere else 1937 | and slap a jump that goes back to the original function, right after the 1938 | jump we wrote. some people call this a "trampoline". 1939 | 1940 | to explain this more visually, this is how the code looks like before 1941 | hooking 1942 | 1943 | ```asm 1944 | MakeRequestData: 1945 | stmdb sp!,{ r4 r5 r6 r7 r11 lr } 1946 | add r11,sp,#0x10 1947 | sub sp,sp,#0x8 1948 | cpy r5,r0 1949 | ... 1950 | ``` 1951 | 1952 | after hooking 1953 | 1954 | ```asm 1955 | original_MakeRequestData: 1956 | stmdb sp!,{ r4 r5 r6 r7 r11 lr } 1957 | add r11,sp,#0x10 1958 | jmp MakeRequestData_continue 1959 | 1960 | MakeRequestData: 1961 | jmp hooked_MakeRequestData 1962 | MakeRequestData_continue: 1963 | sub sp,sp,#0x8 1964 | cpy r5,r0 1965 | ... 1966 | ``` 1967 | 1968 | the actual jump is not gonna look like that though, in ARM we need to do 1969 | a weird absolute jump you've seen in my initial stub library code 1970 | 1971 | so let's implement this hook! 1972 | 1973 | here's where I generate the trampoline 1974 | 1975 | ```c 1976 | *(void**)&original_MakeRequestData = malloc(8 + 8); 1977 | code = (unsigned*)original_MakeRequestData; 1978 | munprotect(code, 8); 1979 | memcpy(code, MakeRequestData, 8); 1980 | code[2] = 0xE51FF004; /* ldr pc,[pc,#-4] */ 1981 | code[3] = (unsigned)MakeRequestData + 8; 1982 | ``` 1983 | 1984 | and here's where I overwrite the original function's code 1985 | 1986 | ```c 1987 | code = (unsigned*)MakeRequestData; 1988 | munprotect(code, 8); 1989 | code[0] = 0xE51FF004; /* ldr pc,[pc,#-4] */ 1990 | code[1] = (unsigned)hooked_MakeRequestData; 1991 | ``` 1992 | 1993 | if we run this and check logcat after tapping the main menu and logging 1994 | into the game, we get: 1995 | 1996 | ``` 1997 | 10-19 01:52:19.220 28588 28613 D sniffas.c: hello from the stub library! 1998 | 10-19 01:52:19.220 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch 1999 | 10-19 01:52:19.220 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock 2000 | 10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize 2001 | 10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons 2002 | 10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch 2003 | 10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs 2004 | 10-19 01:52:19.221 28588 28613 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock 2005 | 10-19 01:52:19.222 28588 28613 D sniffas.c: il2cpp at 0x8000000 2006 | 10-19 01:52:19.222 28588 28613 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2 2007 | 10-19 01:52:40.523 28588 28613 D sniffas.c: hello from MakeRequestData! 2008 | 10-19 01:52:42.410 28588 28613 D sniffas.c: hello from MakeRequestData! 2009 | 10-19 01:52:47.239 28588 28613 D sniffas.c: hello from MakeRequestData! 2010 | ``` 2011 | 2012 | the hard part is done! now we can simply log the request data. we already 2013 | know the key field offsets for C#'s Array struct 2014 | 2015 | ```c 2016 | typedef struct { 2017 | char unknown[12]; 2018 | int Length; 2019 | char data[1]; /* actually Length bytes */ 2020 | } Array; 2021 | 2022 | static 2023 | void Array_log_ascii(Array* arr) { 2024 | char* buf = malloc(arr->Length + 1); 2025 | memcpy(buf, arr->Data, arr->Length); 2026 | buf[arr->Length] = 0; 2027 | log(buf); 2028 | free(buf); 2029 | } 2030 | ``` 2031 | 2032 | however, pathWithQuery is most likely a String. let's reverse engineer the 2033 | String layout real quick. from String$$Copy we can instantly tell Length 2034 | is at offset 0x8 and data is at 0xC 2035 | 2036 | ```c 2037 | int String$$Copy(int param_1) 2038 | 2039 | { 2040 | int iVar1; 2041 | undefined4 uVar2; 2042 | int iVar3; 2043 | 2044 | if (DAT_037078b1 == '\0') { 2045 | FUN_008722e4(0x94b7); 2046 | DAT_037078b1 = '\x01'; 2047 | } 2048 | if (param_1 != 0) { 2049 | iVar3 = *(int *)(param_1 + 8); 2050 | iVar1 = thunk_FUN_008c05c4(iVar3); 2051 | if (iVar1 == 0) { 2052 | ThrowException(0); 2053 | } 2054 | Buffer$$Memcpy(iVar1 + 0xc,param_1 + 0xc,iVar3 << 1,0); 2055 | return iVar1; 2056 | } 2057 | uVar2 = Instantiate1(Class$System.ArgumentNullException); 2058 | ArgumentNullException$$.ctor(uVar2,"str",0); 2059 | ThrowSomeOtherException(uVar2,0,Method$String.Copy()); 2060 | uVar2 = caseD_15(); 2061 | return uVar2; 2062 | } 2063 | ``` 2064 | 2065 | here's our String struct 2066 | 2067 | ```c 2068 | typedef struct { 2069 | char unknown[8]; 2070 | int Length; 2071 | char data[1]; /* actually Length bytes */ 2072 | } String; 2073 | ``` 2074 | 2075 | we have another problem though, the default encoding for strings in .net 2076 | is UTF16LE. I'm just gonna truncate it to ascii for now and change data 2077 | to an array of unsigned short's 2078 | 2079 | ```c 2080 | typedef struct { 2081 | char unknown[8]; 2082 | int Length; 2083 | unsigned short Data[1]; 2084 | } String; 2085 | 2086 | /* truncate to ascii. good enough for now */ 2087 | static 2088 | void String_log(String* str) { 2089 | int i; 2090 | char* buf = malloc(str->Length + 1); 2091 | for (i = 0; i < str->Length; ++i) { 2092 | buf[i] = (char)str->Data[i]; 2093 | } 2094 | buf[str->Length] = 0; 2095 | log(buf); 2096 | free(buf); 2097 | } 2098 | ``` 2099 | 2100 | update hook to log the requests: 2101 | 2102 | ```c 2103 | static 2104 | Array* (*original_MakeRequestData)(String* pathWithQuery, Array* body); 2105 | 2106 | static 2107 | Array* hooked_MakeRequestData(String* pathWithQuery, Array* body) { 2108 | Array* res; 2109 | String_log(pathWithQuery); 2110 | res = original_MakeRequestData(pathWithQuery, body); 2111 | Array_log_ascii(res); 2112 | return res; 2113 | } 2114 | ``` 2115 | 2116 | let's start the game, tap the main screen, and... 2117 | 2118 | ``` 2119 | 10-19 02:50:00.968 31593 31618 D sniffas.c: hello from the stub library! 2120 | 10-19 02:50:00.968 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_clearTouch 2121 | 10-19 02:50:00.968 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_lock 2122 | 10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_onFinalize 2123 | 10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockDeviceButtons 2124 | 10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_stockNativeTouch 2125 | 10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_testOverrideFlgs 2126 | 10-19 02:50:00.969 31593 31618 D sniffas.c: Java_com_klab_nativeinput_NativeInputJava_unlock 2127 | 10-19 02:50:00.969 31593 31618 D sniffas.c: il2cpp at 0x8000000 2128 | 10-19 02:50:00.969 31593 31618 D sniffas.c: MakeRequestData at 0x8efcddc: f0 48 2d e9 10 b0 8d e2 2129 | 10-19 02:50:21.240 31593 31618 D sniffas.c: /login/login?p=a&id=1&u=CENSORED_USER_ID 2130 | 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"] 2131 | 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 2132 | 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"] 2133 | 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 2134 | 10-19 02:50:27.047 31593 31618 D sniffas.c: [null,"CENSORED_HASH"] 2135 | ``` 2136 | 2137 | hell yeah. I had to censor pretty much everything in the data but you get 2138 | the idea. it's how we predicted it 2139 | 2140 | hooking MakeRequestData might've been a mistake though, we can't log the 2141 | response like this. let's hook `Network$$PostJson` instead. it has all 2142 | the same info we're logging now, plus the response. 2143 | 2144 | this is where it constructs the response in CallMain 2145 | 2146 | ```c 2147 | response = Instantiate1(Class$Action_Network.Response_); 2148 | FUN_023a2ed4(response,displayClass20,Method$DMHttpApi.__c__DisplayClass20_0._CallMain_b__1(), 2149 | Method$Action_Network.Response_..ctor()); 2150 | displayClass20 = Network$$PostJson(url,json,response); 2151 | ``` 2152 | 2153 | a quick search for Network.Response yields the following fields 2154 | 2155 | ```c 2156 | undefined4 Network.Response$$get_Status(int param_1) 2157 | 2158 | { 2159 | return *(undefined4 *)(param_1 + 8); 2160 | } 2161 | 2162 | undefined4 Network.Response$$get_Bytes(int param_1) 2163 | 2164 | { 2165 | return *(undefined4 *)(param_1 + 0xc); 2166 | } 2167 | 2168 | uint Network.Response$$get_IsTimeout(int param_1) 2169 | 2170 | { 2171 | return (uint)*(byte *)(param_1 + 0x10); 2172 | } 2173 | 2174 | uint Network.Response$$get_IsNetworkError(int param_1) 2175 | 2176 | { 2177 | return (uint)*(byte *)(param_1 + 0x11); 2178 | } 2179 | 2180 | undefined4 Network.Response$$get_ErrorMessage(int param_1) 2181 | 2182 | { 2183 | return *(undefined4 *)(param_1 + 0x14); 2184 | } 2185 | 2186 | ``` 2187 | 2188 | I wasn't sure about the Bytes type so I looked around some more, found 2189 | this in postJsonCallback 2190 | 2191 | ```c 2192 | uVar2 = AndroidJavaObject$$GetRawObject(piVar1,0); 2193 | uVar2 = AndroidJNIHelper$$ConvertFromJNIArray 2194 | (uVar2,Method$AndroidJNIHelper.ConvertFromJNIArray()_byte[]_); 2195 | ... 2196 | iVar3 = Instantiate1(Class$Network.Response); 2197 | Object$$.ctor(iVar3,0); 2198 | *(undefined4 *)(iVar3 + 8) = param_3; 2199 | *(undefined4 *)(iVar3 + 0xc) = uVar2; 2200 | *(undefined *)(iVar3 + 0x11) = param_6; 2201 | *(undefined *)(iVar3 + 0x10) = param_5; 2202 | *(undefined4 *)(iVar3 + 0x14) = param_7; 2203 | ``` 2204 | 2205 | it should be an array of bytes 2206 | 2207 | now the tricky part is, it's actually receiving `Action`, 2208 | not just the struct. this means that somewhere down the line, this action 2209 | gets invoked and the Response object we want is created 2210 | 2211 | yeah, I guess we won't be able to log the response from here. we'll need 2212 | another hook 2213 | 2214 | let's hook `Network.Response$$get_Bytes`. something is bound to call it 2215 | when a response is received 2216 | 2217 | these are the hooks I ended up with 2218 | 2219 | ```c 2220 | typedef struct { 2221 | char unknown[8]; 2222 | int Status; 2223 | Array* Bytes; 2224 | char isTimeout; 2225 | char isNetworkError; 2226 | String* ErrorMessage; 2227 | } Response; 2228 | 2229 | static 2230 | void (*original_PostJson)(String* url, Array* body, void* delegate, 2231 | void* unk); 2232 | 2233 | static 2234 | void hooked_PostJson(String* url, Array* body, void* delegate, void* unk) { 2235 | String_log(url); 2236 | Array_log_ascii(body); 2237 | original_PostJson(url, body, delegate, unk); 2238 | } 2239 | 2240 | static 2241 | Array* (*original_get_Bytes)(Response* resp); 2242 | 2243 | static 2244 | Array* hooked_get_Bytes(Response* resp) { 2245 | char buf[512]; 2246 | sprintf(buf, "[%p] %p", __builtin_return_address(0), resp); 2247 | log(buf); 2248 | Array_log_ascii(resp->Bytes); 2249 | return original_get_Bytes(resp); 2250 | } 2251 | ``` 2252 | 2253 | and if we run now, we get: 2254 | 2255 | ![snifas logging requests](pic.png) 2256 | 2257 | amazing! now we can see all traffic 2258 | 2259 | you can check out the full stub library source code [here](https://github.com/Francesco149/sniffas) 2260 | 2261 | some people would have used a mitm http proxy here, however the game is 2262 | very picky about it and most likely is ssl pinning, so this is much easier 2263 | for me and lets me log other info too, for example i can log where any 2264 | given function is called from, even with complicated indirect calls. you 2265 | just have to format and log `__builtin_return_address(0)` from the hook 2266 | 2267 | let's try to put this all together and craft a startup request and see 2268 | what the server thinks of it. 2269 | 2270 | first of all, this is how you reset your linked account and force the game 2271 | to create a new one. this is the equivalent of what is described here 2272 | https://www.reddit.com/r/SchoolIdolFestival/comments/da5g2x/how_to_reroll_sifas_without_deleting_the_whole/f1pe67m/ 2273 | except it's all automatic 2274 | 2275 | ```sh 2276 | mv /data/data/com.klab.lovelive.allstars{,.bak} 2277 | pm clear com.klab.lovelive.allstars 2278 | mv /data/data/com.klab.lovelive.allstars{.bak,} 2279 | ``` 2280 | 2281 | from the requests log it seems like it first prompts you to log with 2282 | a google id and then calls `/dataLink/fetchGameServiceDataBeforeLogin` 2283 | which either returns already linked data for that account or null, in 2284 | which case the game proceeds with the startup request to create a new 2285 | account, but I think we can bypass that 2286 | 2287 | looking at `BaseRule1$$SendMain` i notice that the mv parameter in the 2288 | query string is actually referred to as MasterVersion, in the disassembly, 2289 | we will hardcode it for now 2290 | 2291 | the code that generates the time_difference field is confusing, not sure 2292 | why it creates a 2017-01-01 date, but from the logs it seems to be 3600 2293 | for me so I'm guessing it's the offset from utc or something. I'll just 2294 | hardcode it for now 2295 | 2296 | I decided to first do a quick test in kotlin using the same 2297 | OkHttp library to be as close as possible to the game 2298 | 2299 | I ran into a stupid issue that had me banging my head on my keyboard for 2300 | an entire day - the server bails out with a 500 error if your 2301 | `content-type` is `application/json; charset=utf-8` instead of just 2302 | `application/json` . OkHttp automatically adds the charset if you call 2303 | `toRequestBody` 2304 | 2305 | this is the code I ended up with, way more elaborate than it needs to for 2306 | this simple test, but you have to keep in mind I was troubleshooting this 2307 | with different requests all day 2308 | 2309 | for PublicEncrypt we want OAEP padding because the bool passed to 2310 | `RSACryptoServiceProvider$$Encrypt` is true (check the msdn docs) 2311 | 2312 | since java and kotlin can't handle .net xml keys i converted it to PEM 2313 | using this tool 2314 | https://gist.github.com/Francesco149/8c6288a853dd010a638892be2a2c48af 2315 | 2316 | OAEP padding is randomized so don't worry if the encrypted data looks 2317 | different for the same input 2318 | 2319 | for md5 i pretty much copied the game's code 1:1 even though it's 2320 | unnecessary to go through BigInteger 2321 | 2322 | for the resemara detection id I generated a random uuid instead of using 2323 | a real google advertising id and hashed it with the package name as the 2324 | game does. the server happily accepted it. a random md5 hash would 2325 | probably work too 2326 | 2327 | I'm not sure yet what the mask field even does since it's random bytes, 2328 | maybe it's just there so the server can verify the signature 2329 | 2330 | ```kotlin 2331 | import okhttp3.OkHttpClient 2332 | import okhttp3.Request 2333 | import okhttp3.MediaType.Companion.toMediaType 2334 | import okhttp3.RequestBody.Companion.toRequestBody 2335 | import java.security.* 2336 | import java.security.spec.X509EncodedKeySpec 2337 | import javax.crypto.Cipher 2338 | import javax.crypto.spec.SecretKeySpec 2339 | import javax.crypto.Mac 2340 | import java.util.Base64 2341 | import java.util.UUID 2342 | import java.math.BigInteger 2343 | import kotlin.random.Random 2344 | 2345 | const val ServerEndpoint = "https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1010" 2346 | const val StartupKey = "G5OdK4KdQO5UM2nL" 2347 | const val RSAPublicKey = """-----BEGIN PUBLIC KEY----- 2348 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/ZUSWq8LCuF2JclEp6uuW9+yddLQvb2420+F8 2349 | rxIF8+W53BiF8g9m6nCETdRw7RVnzNABevMndCCTD6oQ6a2w0QpoKeT26578UCWtGp74NGg2Q2fH 2350 | YFMAhTytVk48qO4ViCN3snFs0AURU06niM98MIcEUnj9vj6kOBlOGv4JWQIDAQAB 2351 | -----END PUBLIC KEY-----""" 2352 | const val PackageName = "com.klab.lovelive.allstars" 2353 | const val MasterVersion = "646e6e305660c69f" 2354 | 2355 | fun md5(str: String): String { 2356 | val digest = MessageDigest.getInstance("MD5") 2357 | digest.reset() 2358 | digest.update(str.toByteArray()) 2359 | val hash = digest.digest(); 2360 | return BigInteger(1, hash).toString(16).padStart(32, '0') 2361 | } 2362 | 2363 | fun publicEncrypt(key: PublicKey, data: ByteArray): ByteArray { 2364 | val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding"); 2365 | cipher.init(Cipher.ENCRYPT_MODE, key); 2366 | return cipher.doFinal(data); 2367 | } 2368 | 2369 | fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } 2370 | 2371 | fun hmacSha1(key: ByteArray, data: ByteArray): String { 2372 | val hmacKey = SecretKeySpec(key, "HmacSHA1") 2373 | val hmac = Mac.getInstance("HmacSHA1") 2374 | hmac.init(hmacKey) 2375 | return hmac.doFinal(data).toHexString() 2376 | } 2377 | 2378 | var requestId = 0; 2379 | var sessionKey = StartupKey; 2380 | 2381 | fun call(path: String, payloadJson: String, mv: Boolean, t: Boolean, 2382 | u: Int = 0) 2383 | { 2384 | requestId = requestId + 1; 2385 | var pathWithQuery = path + "?p=a"; 2386 | if (mv) { 2387 | pathWithQuery += "&mv=$MasterVersion" 2388 | } 2389 | pathWithQuery += "&id=$requestId" 2390 | if (u != 0) { 2391 | pathWithQuery += "&u=$u" 2392 | } 2393 | if (t) { 2394 | val millitime = System.currentTimeMillis() 2395 | pathWithQuery += "&t=$millitime" 2396 | } 2397 | println(pathWithQuery); 2398 | val hashData = pathWithQuery + " " + payloadJson 2399 | val hash = hmacSha1(sessionKey.toByteArray(), hashData.toByteArray()) 2400 | val json = """[$payloadJson,"$hash"]""" 2401 | println(json) 2402 | val client = OkHttpClient() 2403 | val request = Request.Builder() 2404 | .url("$ServerEndpoint$pathWithQuery") 2405 | .post(json.toByteArray() 2406 | .toRequestBody("application/json".toMediaType())) 2407 | .build() 2408 | client.newCall(request).execute().use { response -> 2409 | if (!response.isSuccessful) { 2410 | println("unexpected code $response") 2411 | } 2412 | for ((name, value) in response.headers) { 2413 | println("$name: $value") 2414 | } 2415 | println(response.body!!.string()) 2416 | } 2417 | } 2418 | 2419 | fun main(args: Array) { 2420 | val kf = KeyFactory.getInstance("RSA"); 2421 | val keyBytes = Base64.getDecoder().decode( 2422 | RSAPublicKey 2423 | .replace("-----BEGIN PUBLIC KEY-----", "") 2424 | .replace("-----END PUBLIC KEY-----", "") 2425 | .replace("\\s+".toRegex(),"") 2426 | ) 2427 | val keySpecX509 = X509EncodedKeySpec(keyBytes) 2428 | val pubKey = kf.generatePublic(keySpecX509) 2429 | val base64 = Base64.getEncoder() 2430 | val advertisingId = UUID.randomUUID().toString() 2431 | val resemara = md5(advertisingId + PackageName) 2432 | val randomBytes = Random.nextBytes(32) 2433 | val maskBytes = publicEncrypt(pubKey, randomBytes) 2434 | val mask = base64.encodeToString(maskBytes) 2435 | val payloadJson = """{"mask":"$mask","resemara_detection_identifier":"$resemara","time_difference":3600}""" 2436 | call("/login/startup", payloadJson, true, true); 2437 | } 2438 | ``` 2439 | 2440 | and here's the script I use on linux to build and run it: 2441 | https://gist.github.com/636d7efeff523b152a3039758d3ea9f6 2442 | 2443 | note: you need kotlin 1.3.41 or higher to build and run with my script 2444 | 2445 | if we run it, we get: 2446 | 2447 | ``` 2448 | # checking dependencies 2449 | [ok] okhttp-4.2.2.jar 2450 | [ok] okio-2.2.2.jar 2451 | 2452 | # compiling 2453 | 2454 | # running 2455 | /login/startup?p=a&mv=646e6e305660c69f&id=1&t=CENSORED_TIME 2456 | [{"mask":"CENSORED","resemara_detection_identifier":"CENSORED","time_difference":3600},"CENSORED_HASH"] 2457 | Content-Type: application/json 2458 | Transfer-Encoding: chunked 2459 | Connection: keep-alive 2460 | Server: nginx 2461 | Date: CENSORED 2462 | Vary: Accept-Encoding 2463 | X-Cache: Miss from cloudfront 2464 | Via: 1.1 CENSORED.cloudfront.net (CloudFront) 2465 | X-Amz-Cf-Pop: CENSORED 2466 | X-Amz-Cf-Id: CENSORED 2467 | [CENSORED_TIME,"646e6e305660c69f",0,{"user_id":CENSORED,"authorization_key":"CENSORED"},"CENSORED_HASH"] 2468 | ``` 2469 | 2470 | here's also a python version I wrote while I was troubleshooting 2471 | https://gist.github.com/e801c077ad2e3e9f82f2da8233735707 2472 | 2473 | so there you have it! we made our first communication with the server 2474 | successfully. this is just the beginning though, we have all the more 2475 | convoluted session key xoring ahead of us as well as some seemingly 2476 | obfuscated fields like asset_state which is generated by 2477 | `_KJACore_AssetStateLogGenerateV2` in `libjackpot-core.so` 2478 | 2479 | another interesting quirk I noticed is that the server also throws a 500 2480 | error if your query parameters in the url aren't in the same order as 2481 | the game, which can cause problems with libs that parse and sort query 2482 | params 2483 | 2484 | just to be 100% accurate, I decided to use the exact same version of okhttp 2485 | by downgrading to 3.9.1 (you can find it in the java strings by searching 2486 | for 'okhttp/') which has a slightly different API 2487 | 2488 | so let's take a look at what happens once the startup response is received 2489 | once again in `DMHttpApi.__c__DisplayClass14_2$$_Login_b__4` 2490 | 2491 | ```c 2492 | userId = (Array *)StartupResponse$$get_UserId(startupResponse); 2493 | xoredAuthorizationKey = httpApi->SessionKey; 2494 | 2495 | ... 2496 | 2497 | authorizationKey = (Array *)StartupResponse$$get_AuthorizationKey(startupResponse); 2498 | authorizationKey = Convert$$FromBase64String(authorizationKey); 2499 | xoredAuthorizationKey = Lib$$XorBytes(xoredAuthorizationKey,authorizationKey); 2500 | UserKey$$SetIDPW(userId,xoredAuthorizationKey,0); 2501 | ``` 2502 | 2503 | which in short is 2504 | 2505 | * decode the base64 authorization_key 2506 | * xor the current authorization_key with the current session key (which 2507 | starts as StartupKey) 2508 | * remember the xored auth key and user id for later 2509 | 2510 | then in `DMHttpApi$$Login` it gets the user id and key back and just 2511 | passes them onto the next step of the state machine which I assume 2512 | is `DMHttpApi.__c__DisplayClass14_0$$_Login_b__0`. here it stores the 2513 | user id and session key in some class 2514 | 2515 | ```c 2516 | puVar4 = (undefined4 *)(displayClass + 0xc); 2517 | *puVar4 = user_id; 2518 | *(undefined4 *)(displayClass + 0x14) = param_1; 2519 | pBytes_ = (Array **)(displayClass + 0x10); 2520 | *pBytes_ = session_key; 2521 | ``` 2522 | 2523 | reveals us the UserID field of DMHttpApi 2524 | 2525 | ```c 2526 | iUserId = 0; 2527 | FUN_021f75fc(&iUserId,user_id,Method$Nullable_int_..ctor()); 2528 | 2529 | ... 2530 | 2531 | pDVar1 = Class$DotUnder.DMHttpApi->Instance; 2532 | pDVar1->UserId = iUserId; 2533 | ``` 2534 | 2535 | updates the session key with the xored one and increases the auth count 2536 | which will be in the login request 2537 | 2538 | ```c 2539 | Class$DotUnder.DMHttpApi->Instance->SessionKey = *pBytes_; 2540 | authCount = UserKey$$IncrAuthCount(0); 2541 | ``` 2542 | 2543 | if you actually look at `UserKey$$IncrAuthCount` you will see that it's 2544 | stored in local storage and likely persists across reboots 2545 | 2546 | then it goes on to generate the mask as usual by signing 32 random bytes 2547 | 2548 | ```c 2549 | randomBytes_ = (Array *)DMCryptography$$RandomBytes(0x20) 2550 | ... 2551 | *pBytes_ = randomBytes_; 2552 | ... 2553 | mask = DMCryptography$$PublicEncrypt(randomBytes_); 2554 | ... 2555 | mask = Convert$$ToBase64String(mask,0); 2556 | ``` 2557 | 2558 | this is where we hit our next roadblock, the asset_state field. it's 2559 | generated from the same random bytes that are signed for mask 2560 | 2561 | ```c 2562 | randomBytes_ = Convert$$ToBase64String(*pBytes_,0); 2563 | assetStateLog = Platform$$GenerateAssetStateLog(randomBytes_,0); 2564 | loginApi = Instantiate1(Class$DotUnder.SVAPI.Login); 2565 | Login$$.ctor(loginApi,0); 2566 | ... 2567 | userId = *pUserId; 2568 | loginRequest = Instantiate1(Class$DotUnder.Structure.LoginRequest); 2569 | LoginRequest$$.ctor(loginRequest,userId,authCount,mask,assetStateLog,0); 2570 | ``` 2571 | 2572 | if we look at `Platform$$GenerateAssetStateLog` it calls into 2573 | `AndroidPlatform$$GenerateAssetStateLog` which constructs a StringBuilder 2574 | of capacity 1024 bytes and calls into `libjackpot-core.so` 2575 | 2576 | ```c 2577 | void AndroidPlatform$$_KJACore_AssetStateLogGenerateV2 2578 | (undefined4 stringBuilder,undefined4 capacity,String *base64RandomBytes) 2579 | 2580 | { 2581 | undefined4 uVar1; 2582 | undefined4 uVar2; 2583 | char *local_38; 2584 | undefined4 local_34; 2585 | char *local_30; 2586 | undefined local_1c; 2587 | 2588 | if (DAT_03706760 == (code *)0x0) { 2589 | local_34 = 0xc; 2590 | local_30 = "_KJACore_AssetStateLogGenerateV2"; 2591 | local_1c = 0; 2592 | local_38 = "jackpot-core"; 2593 | DAT_03706760 = (code *)FUN_008a5040(&local_38); 2594 | if (DAT_03706760 == (code *)0x0) { 2595 | uVar1 = FUN_0089f2d0( 2596 | "Unable to find method for p/invoke: \'_KJACore_AssetStateLogGenerateV2\'" 2597 | ); 2598 | ThrowSomeOtherException(uVar1,0,0); 2599 | caseD_15(); 2600 | return; 2601 | } 2602 | } 2603 | uVar1 = FUN_008a54c0(stringBuilder); 2604 | uVar2 = FUN_008a5318(base64RandomBytes); 2605 | (*DAT_03706760)(uVar1,capacity,uVar2); 2606 | FUN_008a576c(stringBuilder,uVar1); 2607 | FUN_008a530c(uVar1); 2608 | FUN_008a530c(uVar2); 2609 | return; 2610 | } 2611 | ``` 2612 | 2613 | so I disassemble libjackpot-core.so and see what this function is all about 2614 | and oh boy, it calls a giant obfuscated function that appears to be an 2615 | extremely obfuscated way to produce some kind of string. the output also 2616 | seems to be affected by the random base64 bytes. it does all kinds of 2617 | convoluted stuff with std::strings and at some point it even seems to 2618 | open files. 2619 | 2620 | I could sit here for a month and maybe make sense of it, but 2621 | what I am actually going to do is hook it and grab a few valid outputs with 2622 | a few random strings of bytes and shuffle between those. I don't think 2623 | this asset_state field holds any meaningful info anyway since it's so 2624 | short. it's probably just checked against the random bytes 2625 | 2626 | the hook 2627 | 2628 | ```c 2629 | static 2630 | String* (*original_GenerateAssetStateLog)(String* base64RandomBytes); 2631 | 2632 | static 2633 | String* hooked_GenerateAssetStateLog(String* base64RandomBytes) { 2634 | String* res; 2635 | log("random bytes:"); 2636 | String_log(base64RandomBytes); 2637 | res = original_GenerateAssetStateLog(base64RandomBytes); 2638 | log("asset state:"); 2639 | String_log(res); 2640 | return res; 2641 | } 2642 | ``` 2643 | 2644 | result 2645 | 2646 | ``` 2647 | 10-21 16:04:40.515 7151 7176 D sniffas.c: random bytes: 2648 | 10-21 16:04:40.515 7151 7176 D sniffas.c: +zuyNj+IFhSydzEMTHnrBCyUO0b3CvQt5nOWwxpNKcE= 2649 | 10-21 16:04:40.776 7151 7176 D sniffas.c: asset state: 2650 | 10-21 16:04:40.776 7151 7176 D sniffas.c: OqwKkOuhtlyuSzCj95pXUjtEo65SuYtUI3OlrxWWSjz7IEyicAMR7/IWuc822gc2cQXHjHY2ASHjQFfdONJNOU5gMM5w4g3Dj2K+iv1HDPZTAdtd8BURk7Iu+HVqxACI2g== 2651 | ``` 2652 | 2653 | let's hardcode these values temporarily 2654 | 2655 | ok so while implementing this I noticed that it's not the session key that 2656 | is xored with the auth key, seems like it's actually the random bytes. 2657 | i thought it was weird that the string sizes didn't match 2658 | 2659 | in `DMHttpApi$$Login` 2660 | 2661 | ```c 2662 | randomBytes = DMCryptography$$RandomBytes(0x20); 2663 | *(undefined4 *)(displayClass14_2 + 8) = randomBytes; 2664 | 2665 | ... 2666 | 2667 | actionStartupRequest = thunk_FUN_008ae738(Class$Action_StartupRequest_); 2668 | FUN_024470b8(actionStartupRequest,displayClass14_2, 2669 | Method$DMHttpApi.__c__DisplayClass14_2._Login_b__3(), 2670 | Method$Action_StartupRequest_..ctor()); 2671 | ``` 2672 | 2673 | see how the first param to Login_b__3 is the displayclass that contains 2674 | the random bytes at offset 8? 2675 | 2676 | Login_b__3 just passes it through and then Login_b__4 uses it: 2677 | 2678 | ```c 2679 | userId = (Array *)StartupResponse$$get_UserId(startupResponse); 2680 | xoredAuthorizationKey = *(Array **)((int)displayClass + 8); 2681 | } 2682 | authorizationKey = (Array *)StartupResponse$$get_AuthorizationKey(startupResponse); 2683 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 2684 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 2685 | FUN_0087fd40(); 2686 | } 2687 | authorizationKey = Convert$$FromBase64String(authorizationKey); 2688 | xoredAuthorizationKey = Lib$$XorBytes(xoredAuthorizationKey,authorizationKey); 2689 | ``` 2690 | 2691 | here's the updated code, I also cleaned it up a little bit 2692 | 2693 | ```kotlin 2694 | import com.google.gson.Gson 2695 | import com.google.gson.GsonBuilder 2696 | import com.google.gson.JsonParser 2697 | import java.math.BigInteger 2698 | import java.security.KeyFactory 2699 | import java.security.MessageDigest 2700 | import java.security.spec.X509EncodedKeySpec 2701 | import java.util.Base64 2702 | import java.util.UUID 2703 | import javax.crypto.Cipher 2704 | import javax.crypto.Mac 2705 | import javax.crypto.spec.SecretKeySpec 2706 | import kotlin.random.Random 2707 | import okhttp3.MediaType 2708 | import okhttp3.OkHttpClient 2709 | import okhttp3.Request 2710 | import okhttp3.RequestBody 2711 | 2712 | const val ServerEndpoint = 2713 | "https://jp-real-prod-v4tadlicuqeeumke.api.game25.klabgames.net/ep1010" 2714 | const val StartupKey = "G5OdK4KdQO5UM2nL" 2715 | const val RSAPublicKey = """-----BEGIN PUBLIC KEY----- 2716 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC/ZUSWq8LCuF2JclEp6uuW9+yddLQvb2420+F8 2717 | rxIF8+W53BiF8g9m6nCETdRw7RVnzNABevMndCCTD6oQ6a2w0QpoKeT26578UCWtGp74NGg2Q2fH 2718 | YFMAhTytVk48qO4ViCN3snFs0AURU06niM98MIcEUnj9vj6kOBlOGv4JWQIDAQAB 2719 | -----END PUBLIC KEY-----""" 2720 | const val PackageName = "com.klab.lovelive.allstars" 2721 | const val MasterVersion = "646e6e305660c69f" 2722 | 2723 | val kf = KeyFactory.getInstance("RSA") 2724 | val keyBytes = Base64.getDecoder().decode( 2725 | RSAPublicKey 2726 | .replace("-----BEGIN PUBLIC KEY-----", "") 2727 | .replace("-----END PUBLIC KEY-----", "") 2728 | .replace("\\s+".toRegex(), "") 2729 | ) 2730 | val keySpecX509 = X509EncodedKeySpec(keyBytes) 2731 | val pubKey = kf.generatePublic(keySpecX509) 2732 | 2733 | val gson = Gson() 2734 | val base64Encoder = Base64.getEncoder() 2735 | val base64Decoder = Base64.getDecoder() 2736 | 2737 | fun md5(str: String): String { 2738 | val digest = MessageDigest.getInstance("MD5") 2739 | digest.reset() 2740 | digest.update(str.toByteArray()) 2741 | val hash = digest.digest() 2742 | return BigInteger(1, hash).toString(16).padStart(32, '0') 2743 | } 2744 | 2745 | fun publicEncrypt(data: ByteArray): ByteArray { 2746 | val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding") 2747 | cipher.init(Cipher.ENCRYPT_MODE, pubKey) 2748 | return cipher.doFinal(data) 2749 | } 2750 | 2751 | fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } 2752 | fun ByteArray.xor(other: ByteArray) = 2753 | (zip(other) { a, b -> (a.toInt() xor b.toInt()).toByte() }).toByteArray() 2754 | 2755 | fun hmacSha1(key: ByteArray, data: ByteArray): String { 2756 | val hmacKey = SecretKeySpec(key, "HmacSHA1") 2757 | val hmac = Mac.getInstance("HmacSHA1") 2758 | hmac.init(hmacKey) 2759 | return hmac.doFinal(data).toHexString() 2760 | } 2761 | 2762 | var requestId = 0 2763 | var sessionKey = StartupKey.toByteArray() 2764 | 2765 | const val WithMasterVersion = 1 shl 1 2766 | const val WithTime = 1 shl 2 2767 | const val PrintHeaders = 1 shl 3 2768 | 2769 | fun call( 2770 | path: String, 2771 | payload: String, 2772 | flags: Int = 0, 2773 | userId: Int = 0 2774 | ): String { 2775 | requestId += 1 2776 | var pathWithQuery = path + "?p=a" 2777 | if ((flags and WithMasterVersion) != 0) { 2778 | pathWithQuery += "&mv=$MasterVersion" 2779 | } 2780 | pathWithQuery += "&id=$requestId" 2781 | if (userId != 0) { 2782 | pathWithQuery += "&u=$userId" 2783 | } 2784 | if ((flags and WithTime) != 0) { 2785 | val millitime = System.currentTimeMillis() 2786 | pathWithQuery += "&t=$millitime" 2787 | } 2788 | println("-> POST $pathWithQuery") 2789 | val hashData = pathWithQuery + " " + payload 2790 | val hash = hmacSha1(sessionKey, hashData.toByteArray()) 2791 | val json = """[$payload,"$hash"]""" 2792 | println("-> $json") 2793 | val JSON = MediaType.parse("application/json") 2794 | val client = OkHttpClient() 2795 | val request = Request.Builder() 2796 | .url("$ServerEndpoint$pathWithQuery") 2797 | .post(RequestBody.create(JSON, json.toByteArray())) 2798 | .build() 2799 | val response = client.newCall(request).execute() 2800 | if (!response.isSuccessful) { 2801 | println("unexpected code $response") 2802 | } 2803 | if ((flags and PrintHeaders) != 0) { 2804 | val headers = response.headers() 2805 | for (i in 0..headers.size() - 1) { 2806 | val name = headers.name(i) 2807 | val value = headers.value(i) 2808 | println("<- $name: $value") 2809 | } 2810 | } 2811 | val s = response.body()!!.string() 2812 | println("<- $s") 2813 | return s 2814 | } 2815 | 2816 | data class StartupRequest( 2817 | val mask: String, 2818 | val resemara_detection_identifier: String, 2819 | val time_difference: Int 2820 | ) 2821 | 2822 | data class StartupResponse( 2823 | val user_id: Int, 2824 | val authorization_key: String 2825 | ) 2826 | 2827 | fun startup(randomBytes: ByteArray): StartupResponse? { 2828 | val advertisingId = UUID.randomUUID().toString() 2829 | val resemara = md5(advertisingId + PackageName) 2830 | val maskBytes = publicEncrypt(randomBytes) 2831 | val mask = base64Encoder.encodeToString(maskBytes) 2832 | val result = call( 2833 | path = "/login/startup", 2834 | payload = gson.toJson(StartupRequest( 2835 | mask = mask, 2836 | resemara_detection_identifier = resemara, 2837 | time_difference = 3600 2838 | ), StartupRequest::class.java), 2839 | flags = WithMasterVersion or WithTime 2840 | ) 2841 | val array = JsonParser.parseString(result).getAsJsonArray() 2842 | for (x in array) { 2843 | if (x.isJsonObject()) { 2844 | return gson.fromJson(x, StartupResponse::class.java) 2845 | } 2846 | } 2847 | return null 2848 | } 2849 | 2850 | data class LoginRequest( 2851 | val user_id: Int, 2852 | val auth_count: Int, 2853 | val mask: String, 2854 | val asset_state: String 2855 | ) 2856 | 2857 | var authCount = 0 2858 | 2859 | fun login(userId: Int) { 2860 | authCount += 1 2861 | val randomBytesBase64 = "+zuyNj+IFhSydzEMTHnrBCyUO0b3CvQt5nOWwxpNKcE=" 2862 | val randomBytes = base64Decoder.decode(randomBytesBase64) 2863 | val maskBytes = publicEncrypt(randomBytes) 2864 | val mask = base64Encoder.encodeToString(maskBytes) 2865 | val result = call( 2866 | path = "/login/login", 2867 | payload = gson.toJson(LoginRequest( 2868 | user_id = userId, 2869 | auth_count = authCount, 2870 | mask = mask, 2871 | asset_state = "OqwKkOuhtlyuSzCj95pXUjtEo65SuYtUI3OlrxWWSjz7IEyicA" + 2872 | "MR7/IWuc822gc2cQXHjHY2ASHjQFfdONJNOU5gMM5w4g3Dj2K+iv1HDPZTAdtd" + 2873 | "8BURk7Iu+HVqxACI2g==" 2874 | ), LoginRequest::class.java), 2875 | userId = userId 2876 | ) 2877 | val prettyPrint = GsonBuilder().setPrettyPrinting().create() 2878 | val array = JsonParser.parseString(result).getAsJsonArray() 2879 | println(prettyPrint.toJson(array)) 2880 | } 2881 | 2882 | fun main(args: Array) { 2883 | val randomBytes = Random.nextBytes(32) 2884 | val startupResponse = startup(randomBytes) 2885 | println(startupResponse!!) 2886 | val authKey = base64Decoder.decode(startupResponse.authorization_key) 2887 | println(authKey.toHexString()) 2888 | println(randomBytes.toHexString()) 2889 | sessionKey = authKey.xor(randomBytes) 2890 | println(sessionKey.toHexString()) 2891 | login(startupResponse.user_id) 2892 | } 2893 | ``` 2894 | 2895 | and sure enough, we are logged in! 2896 | 2897 | I will most likely generate a large database of "random" bytes and their 2898 | correct asset_state 2899 | 2900 | ![we are logged in](pic2.png) 2901 | 2902 | I took a closer look at the crazy asset_state generator function. compiler 2903 | optimizations mangle the decompilation a lot but with patience you can 2904 | repair it. it took around 2 days to name everything and recognize which 2905 | were std::string operations, mostly by guessing 2906 | 2907 | the first part is reading from a huge string and copying to local arrays, 2908 | which generates a lot of pointless vars, i redefined the whole memory area 2909 | to be a large array. it's also appending some stuff in between 2910 | 2911 | this huge string is: 2912 | 2913 | ``` 2914 | 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[sr\r[846t07:9t1:80+4/t\x1a((>/(\x1f2<>(/\x1c>5>):/4)[s\x171:-:t7:5/(\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 2915 | ``` 2916 | 2917 | also it's not all ascii, so I'm not 100% sure it's really a single string 2918 | 2919 | first thing it does is save the first 3 chars of the base64 randombytes 2920 | 2921 | c 2922 | ```c 2923 | second_char = base64RandomBytes[1]; 2924 | stack_guard = __stack_chk_guard; 2925 | third_char = base64RandomBytes[2]; 2926 | first_char = *base64RandomBytes; 2927 | ``` 2928 | 2929 | then it constructs `tmp2` by copying 32 characters at huge_string + 0x102 2930 | followed by `J/` 2931 | 2932 | ```c 2933 | huge_string_int32 = (int *)(huge_string + 0x102); 2934 | it1 = (int *)tmp2; 2935 | do { 2936 | ptr_2 = huge_string_int32 + 2; 2937 | int1 = huge_string_int32[1]; 2938 | *it1 = *huge_string_int32; 2939 | it1[1] = int1; 2940 | it1 = it1 + 2; 2941 | huge_string_int32 = ptr_2; 2942 | } while (ptr_2 != (int *)(huge_string + 0x122)); 2943 | /* appends "J/" */ 2944 | *(undefined2 *)it1 = 0x2f4a; 2945 | ``` 2946 | 2947 | then it constructs `tmp1` by copying 24 characters from huge_string + 0x124 2948 | followed by "FCNM" followed by 3 more chars copied past the end of the loop 2949 | for a total of 30 characters 2950 | 2951 | ```c 2952 | buf2 = (undefined4 *)tmp1; 2953 | huge_string_int32_ = (undefined4 *) (huge_string + 0x124); 2954 | do { 2955 | ptr_ = huge_string_int32_; 2956 | buf2_ = buf2; 2957 | int1_ = ptr_[1]; 2958 | *buf2_ = *ptr_; 2959 | buf2_[1] = int1_; 2960 | buf2 = buf2_ + 2; 2961 | huge_string_int32_ = ptr_ + 2; 2962 | } while (ptr_ + 2 != (undefined4 *) (huge_string + 0x13c)); 2963 | uVar2 = *(undefined2 *)(ptr_ + 3); 2964 | cVar1 = *(char *)((int)ptr_ + 0xe); 2965 | /* appends "FCNM" */ 2966 | buf2_[2] = 0x4d4e4346; 2967 | /* copies 3 extra chars past the above loop after "FCNM" */ 2968 | *(undefined2 *)(buf2_ + 3) = uVar2; 2969 | *(char *)((int)buf2_ + 0xe) = cVar1; 2970 | ``` 2971 | 2972 | then it constructs `tmp3` by copying 8 characters from huge_string + 0x143 2973 | followed by "2l1-" followed by 1 more char copied past the end of the loop 2974 | for a total of 14 characters 2975 | 2976 | ```c 2977 | buf2 = tmp3; 2978 | huge_string_int32_ = (undefined4 *) (huge_string + 0x143); 2979 | do { 2980 | ptr_ = huge_string_int32_; 2981 | buf2_ = buf2; 2982 | int1_ = ptr_[1]; 2983 | *buf2_ = *ptr_; 2984 | buf2_[1] = int1_; 2985 | buf2 = buf2_ + 2; 2986 | huge_string_int32_ = ptr_ + 2; 2987 | } while (ptr_ + 2 != (undefined4 *) (huge_string + 0x14b)); 2988 | cVar1 = *(char *)(ptr_ + 3); 2989 | /* appends "2l1-" */ 2990 | buf2_[2] = 0x2d316c32; 2991 | /* copies 1 extra char past the above loop after "211-" */ 2992 | *(char *)(buf2_ + 3) = cVar1; 2993 | ``` 2994 | 2995 | then it calls a function that i named `xor_until_match` on the three bufs 2996 | 2997 | ```c 2998 | xor_until_match('/',tmp2); 2999 | xor_until_match('/',tmp1); 3000 | xor_until_match('B',tmp3); 3001 | ``` 3002 | 3003 | which xors every character with the given character until it finds a 3004 | matching character which xors to zero: 3005 | 3006 | ```c 3007 | char * xor_until_match(char c,char *str) 3008 | 3009 | { 3010 | char *p; 3011 | char tmp; 3012 | 3013 | p = str; 3014 | do { 3015 | tmp = *p; 3016 | *p = tmp ^ c; 3017 | p = p + 1; 3018 | } while ((byte)(tmp ^ c) != 0); 3019 | return str; 3020 | } 3021 | ``` 3022 | 3023 | this also zero-terminates the strings, once the matching character is found 3024 | 3025 | then it proceeds to initialize two std strings. how did I know they were 3026 | std strings? it took a lot of guess work and looking at the functions 3027 | that are later called on them 3028 | 3029 | ```c 3030 | string((int)&stdstring1); 3031 | string((int)&stdstring2); 3032 | ``` 3033 | 3034 | I manually mapped a few fields like start and end for std::string, again 3035 | by guessing 3036 | 3037 | then it calls dladdr on a fixed address which is this very function. this 3038 | is used to get the path to `libjackpot-core.so` 3039 | 3040 | I mapped the Dl_info struct from [the manpages](http://man7.org/linux/man-pages/man3/dladdr.3.html) 3041 | 3042 | ```c 3043 | int1 = dladdr(0x244b9,&dli); 3044 | if (int1 != 0) { 3045 | string_from_c((int)&path_to_libjackpot,dli.dli_fname); 3046 | ``` 3047 | 3048 | this overly complicated thing (probably mangled by optimization) seems 3049 | to strip the filename from the path (last element) 3050 | 3051 | ```c 3052 | start = path_to_libjackpot.start; 3053 | len = path_to_libjackpot.end + -(int)path_to_libjackpot.start; 3054 | if (len == (char *)0x0) { 3055 | LAB_000245b4: 3056 | it2 = (char *)0xffffffff; 3057 | } 3058 | else { 3059 | /* i think this whole thing just strips the filename from the full path */ 3060 | int1 = (int)len >> 2; 3061 | end = path_to_libjackpot.end; 3062 | while (it2 = end, 0 < int1) { 3063 | if (end[-1] == '/') goto LAB_00024628; 3064 | if (end[-2] == '/') { 3065 | it2 = end + -1; 3066 | goto LAB_00024628; 3067 | } 3068 | if (end[-3] == '/') { 3069 | it2 = end + -2; 3070 | goto LAB_00024628; 3071 | } 3072 | if (end[-4] == '/') { 3073 | it2 = end + -3; 3074 | goto LAB_00024628; 3075 | } 3076 | int1 = int1 + -1; 3077 | end = end + -4; 3078 | } 3079 | pcVar8 = end + -(int)path_to_libjackpot.start; 3080 | if (pcVar8 == (char *)0x2) { 3081 | LAB_00024610: 3082 | it2 = end; 3083 | if (end[-1] != '/') { 3084 | end = end + -1; 3085 | LAB_0002461a: 3086 | it2 = end; 3087 | if (end[-1] != '/') { 3088 | it2 = path_to_libjackpot.start; 3089 | } 3090 | } 3091 | } 3092 | else { 3093 | if (pcVar8 == (char *)0x3) { 3094 | if (end[-1] != '/') { 3095 | end = end + -1; 3096 | goto LAB_00024610; 3097 | } 3098 | } 3099 | else { 3100 | it2 = path_to_libjackpot.start; 3101 | if (pcVar8 == (char *)0x1) goto LAB_0002461a; 3102 | } 3103 | } 3104 | LAB_00024628: 3105 | if (it2 == path_to_libjackpot.start) goto LAB_000245b4; 3106 | it2 = it2 + (-1 - (int)path_to_libjackpot.start); 3107 | } 3108 | ``` 3109 | 3110 | it then stores second and third char of the random bytes base64 modulo 3 3111 | 3112 | ```c 3113 | /* the decompiler doesn't show it, but the remainder is stored in 3114 | second_char_mod3 and third_char_mod3 */ 3115 | __aeabi_idivmod((uint)second_char,3); 3116 | __aeabi_idivmod((uint)third_char,3); 3117 | ``` 3118 | 3119 | here it appends tmp3 to the base directory of libjackpot-core.so 3120 | 3121 | ```c 3122 | if (len < it2) { 3123 | it2 = len; 3124 | } 3125 | libjackpot_directory.end = (char *)&libjackpot_directory; 3126 | libjackpot_directory.start = (char *)&libjackpot_directory; 3127 | string_from_range(&libjackpot_directory,start,start + (int)it2); 3128 | string_concatenate_char(&tmpstring,&libjackpot_directory,&"/"); 3129 | string_from_c((int)&tmp3_string,tmp3); 3130 | string_concatenate(&tmp3_path,&tmpstring,&tmp3_string); 3131 | operator_delete_(&tmp3_string); 3132 | operator_delete_(&tmpstring); 3133 | operator_delete_(&libjackpot_directory); 3134 | ``` 3135 | 3136 | i think this is a good point to take a break and either hook or implement 3137 | this first part of the function to see what the tmp strings are 3138 | 3139 | after looking around for a bit, I think we can hook the very next function 3140 | that's called. it reads the given file computes one of three hashes: md5, 3141 | sha-1 or sha-256 depending on the parameter passed in, which in this case 3142 | in `base64randombytes[1] & 3` 3143 | 3144 | ```c 3145 | if ((first_char & 1) == 0) { 3146 | conditional_hash(&libjackpot_hash,&path_to_libjackpot,second_char_mod_3); 3147 | conditional_hash(&tmp3_hash,&tmp3_path,second_char_mod_3); 3148 | ``` 3149 | 3150 | here's what the hashing function looks like 3151 | 3152 | ```c 3153 | char ** conditional_hash(char **pphash,std__string *path,int type) 3154 | 3155 | { 3156 | if (type == 0) { 3157 | md5(pphash,path); 3158 | } 3159 | else { 3160 | if (type == 1) { 3161 | sha1(pphash,(char *)path); 3162 | } 3163 | else { 3164 | if (type == 2) { 3165 | sha256(pphash,path); 3166 | } 3167 | else { 3168 | *pphash = (char *)0x0; 3169 | pphash[1] = (char *)0x0; 3170 | } 3171 | } 3172 | } 3173 | return pphash; 3174 | } 3175 | ``` 3176 | 3177 | the way I figured out which hashes it was doing was by looking at the 3178 | functions. they call some initialization function: 3179 | 3180 | ```c 3181 | void ** md5(void **pphash,std__string *param_2) 3182 | 3183 | { 3184 | FILE *__stream; 3185 | size_t sVar1; 3186 | undefined auStack1168 [4]; 3187 | undefined auStack1164 [16]; 3188 | undefined auStack1148 [88]; 3189 | undefined auStack1060 [1024]; 3190 | int local_24; 3191 | 3192 | local_24 = __stack_chk_guard; 3193 | __stream = fopen(param_2->start,"rb"); 3194 | if (__stream == (FILE *)0x0) { 3195 | *pphash = (void *)0x0; 3196 | pphash[1] = (void *)0x0; 3197 | } 3198 | else { 3199 | md5_hash_initial(auStack1168,auStack1148); 3200 | while (sVar1 = fread(auStack1060,1,0x400,__stream), sVar1 != 0) { 3201 | md5_hash_update(auStack1168,auStack1148,auStack1060,sVar1); 3202 | } 3203 | fclose(__stream); 3204 | md5_finalize(auStack1168,auStack1164,auStack1148); 3205 | clone_memory(pphash,auStack1164,0x10); 3206 | } 3207 | if (local_24 != __stack_chk_guard) { 3208 | /* WARNING: Subroutine does not return */ 3209 | __stack_chk_fail(); 3210 | } 3211 | return pphash; 3212 | } 3213 | ``` 3214 | 3215 | that initialization function has some very well known constants that I 3216 | googled 3217 | 3218 | ```c 3219 | void md5_hash_initial(undefined4 param_1,undefined4 *param_2) 3220 | 3221 | { 3222 | param_2[5] = 0; 3223 | param_2[4] = 0; 3224 | *param_2 = 0x67452301; 3225 | param_2[1] = 0xefcdab89; 3226 | param_2[2] = 0x98badcfe; 3227 | param_2[3] = 0x10325476; 3228 | return; 3229 | } 3230 | ``` 3231 | 3232 | initially I thought they were all modified versions of sha1 with slightly 3233 | different initial parameters but then I realized that md5, sha1, sha256 all 3234 | have common hashes and from the output size you can tell which one it is 3235 | (16, 24, 32 bytes) 3236 | 3237 | also, that memory_clone function reveals that pphash is a struct that 3238 | contains data ptr and length 3239 | 3240 | so let's hook this conditional_hash function and see what the other file is 3241 | 3242 | we have a bit of a problem, this function appears to be using thumb 3243 | instructions. we need different instructions to hook it 3244 | 3245 | ``` 3246 | 000233dc 10 b5 push { r4, lr } 3247 | 000233de 04 46 mov r4,pphash 3248 | 000233e0 12 b9 cbnz type,LAB_000233e8 3249 | 000233e2 ff f7 19 ff bl md5 3250 | ``` 3251 | 3252 | long story short the an absolute jump is performed with 3253 | 3254 | ``` 3255 | ldr.w pc, [pc] 3256 | .word 0xbaadf00d 3257 | ``` 3258 | 3259 | which assembles to 3260 | 3261 | ``` 3262 | f8 df f0 00 3263 | 0d f0 ad ba 3264 | ``` 3265 | 3266 | baadf00d would be the target address 3267 | 3268 | another issue is that the function uses a relative jump right at the 3269 | beginning, so instead of calling the original through a trampoline, 3270 | we will instead replace the entire function with our own and to that 3271 | simple if/else ourselves 3272 | 3273 | we also have to compile the hook with thumb instructions by using 3274 | `__attribute__((target("thumb")))` 3275 | 3276 | here's the hook I ended up with: 3277 | 3278 | ```c 3279 | typedef struct { 3280 | char unk[16]; 3281 | char* end; 3282 | char* start; 3283 | } std_string; 3284 | 3285 | typedef struct { 3286 | char* data; 3287 | int length; 3288 | } hash_array; 3289 | 3290 | /* unused */ 3291 | static hash_array* (*original_hash)(hash_array* pphash, std_string* path, 3292 | int type); 3293 | 3294 | hash_array* (*md5)(hash_array* hash, std_string* path); 3295 | hash_array* (*sha1)(hash_array* hash, std_string* path); 3296 | hash_array* (*sha256)(hash_array* hash, std_string* path); 3297 | 3298 | static 3299 | __attribute__((target("thumb"))) 3300 | hash_array* hooked_hash(hash_array* hash, std_string* path, int type) { 3301 | hash_array* res; 3302 | char buf[1024]; 3303 | char* p = buf; 3304 | int i; 3305 | p += sprintf(p, "hash called with type %d on ", type); 3306 | memcpy(p, path->start, path->end - path->start); 3307 | p[path->end - path->start] = 0; 3308 | log(buf); 3309 | switch (type) { 3310 | case 0: res = md5(hash, path); break; 3311 | case 1: res = sha1(hash, path); break; 3312 | case 2: res = sha256(hash, path); break; 3313 | default: 3314 | log("unknown hash!"); 3315 | hash->data = 0; 3316 | hash->length = 0; 3317 | return hash; 3318 | } 3319 | p = buf; 3320 | p += sprintf(p, "result: "); 3321 | for (i = 0; i < res->length; ++i) { 3322 | p += sprintf(p, "%02x", hash->data[i]); 3323 | } 3324 | log(buf); 3325 | return res; 3326 | } 3327 | ``` 3328 | 3329 | here's where I assign the function pointers for the 3 hashes, I have to 3330 | set the lowest bit to 1 to ensure that the cpu knows it should stay in 3331 | thumb mode 3332 | 3333 | ```c 3334 | /* | 1 forces thumb mode */ 3335 | #define f(name, addr) \ 3336 | *(int*)&name = ((int)dli.dli_fbase + addr) | 1 3337 | 3338 | f(md5, 0x13218); 3339 | f(sha1, 0x132ac); 3340 | f(sha256, 0x13344); 3341 | 3342 | #undef f 3343 | ``` 3344 | 3345 | and here's the output: 3346 | 3347 | ``` 3348 | hash called with type 1 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libjackpot-core.so 3349 | result: 5a3cb86aa9b082d6a1c1dfa6f73dd431d7f14e18 3350 | hash called with type 1 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libil2cpp.so 3351 | result: c4387c429c50c4782ab3df409db3abcfa8fadf79 3352 | ``` 3353 | 3354 | alright, so the other file it's hashing is il2cpp 3355 | 3356 | next, it xors the two hashes together, formats the result to a hexstring 3357 | and puts it into stdstring1 3358 | 3359 | ```c 3360 | array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash); 3361 | std_hexstring_from_array(&tmp3_string,&xored_hashes); 3362 | string_assignment((int)&stdstring1,(int)&tmp3_string); 3363 | ``` 3364 | 3365 | the xor function was obvious 3366 | 3367 | ```c 3368 | some_kind_of_array * array_xor(some_kind_of_array *dst,some_kind_of_array *a,some_kind_of_array *b) 3369 | 3370 | { 3371 | char *result; 3372 | int len2; 3373 | uint i; 3374 | int len1; 3375 | 3376 | len2 = b->length; 3377 | len1 = a->length; 3378 | dst->data = (char *)0x0; 3379 | dst->length = 0; 3380 | if (len1 == len2) { 3381 | if (len1 != 0) { 3382 | result = (char *)operator.new(len1); 3383 | dst->data = result; 3384 | dst->length = len1; 3385 | } 3386 | i = 0; 3387 | while (i < (uint)a->length) { 3388 | dst->data[i] = a->data[i] ^ b->data[i]; 3389 | i = i + 1; 3390 | } 3391 | } 3392 | return dst; 3393 | } 3394 | ``` 3395 | 3396 | `std_hexstring_from_array` was tricky to figure out. I hooked 3397 | string_from_range which is called inside of it: 3398 | 3399 | ```c 3400 | void (*basic_string)(void* s, int len); 3401 | void (*original_string_from_range)(std_string* s, char* start, char* end); 3402 | 3403 | static 3404 | __attribute__((target("thumb"))) 3405 | void hooked_string_from_range(std_string* s, char* start, char* end) { 3406 | char buf[1024]; 3407 | log("string from range"); 3408 | memcpy(buf, start, end - start); 3409 | buf[end - start] = 0; 3410 | log(buf); 3411 | basic_string(s, end - start + 1); 3412 | memcpy(s->start, start, end - start); 3413 | s->end = s->start + (end - start); 3414 | s->start[end - start] = 0; 3415 | } 3416 | ``` 3417 | 3418 | and here's the result 3419 | 3420 | ``` 3421 | string from range 3422 | /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm 3423 | hash called with type 2 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libjackpot-core.so 3424 | result: 66370b8c96de7266b02bfe17e696d8a61b587656a34b19fbb0b2768a5305dd1d 3425 | hash called with type 2 on /data/app/com.klab.lovelive.allstars-UVN-H8XkmXR-vs5WM0Fzvw==/lib/arm/libil2cpp.so 3426 | result: d30568d1057fecb31a16f4062239c1ec65b9c2beab41b836658b637dcb5a51e4 3427 | string from range 3428 | b532635d93a19ed5aa3d0a11c4af194a7ee1b4e8080aa1cdd53915f7985f8cf9 3429 | ``` 3430 | 3431 | next, it calls another huge function that unencrypts more strings from 3432 | the huge_string we saw earlier. 3433 | 3434 | ``` 3435 | operator_delete_(&tmp3_string); 3436 | operator_delete(&xored_hashes); 3437 | operator_delete(&tmp3_hash); 3438 | operator_delete(&libjackpot_hash); 3439 | crazy_function(&tmp3_string,third_char_mod_3); 3440 | string_assignment((int)&stdstring2,(int)&tmp3_string); 3441 | operator_delete_(&tmp3_string); 3442 | ``` 3443 | 3444 | if we scroll down we can see a string_from_range call, let's see what our 3445 | earlier hook logs: 3446 | 3447 | ``` 3448 | string from range 3449 | 1be2103a6929b38798a29d89044892f3b3934184 3450 | ``` 3451 | 3452 | if we google for this hash, we find the apkpure page for SIFAS. it must 3453 | be the package signature. using [this tool](https://github.com/warren-bank/print-apk-signature) 3454 | on the apk confirms it: 3455 | 3456 | ``` 3457 | Verifies 3458 | Verified using v1 scheme (JAR signing): true 3459 | Verified using v2 scheme (APK Signature Scheme v2): true 3460 | Number of signers: 1 3461 | Signer #1 certificate DN: CN=AS Team, OU=Unknown, O=KLab Inc., L=Unknown, ST=Unknown, C=JP 3462 | Signer #1 certificate SHA-256 digest: 1d32dbcf91697d46594ad689d49bb137f65d4bb8f56a26724ae7008648131b82 3463 | Signer #1 certificate SHA-1 digest: 1be2103a6929b38798a29d89044892f3b3934184 3464 | Signer #1 certificate MD5 digest: 3f45f90cbcc718e4b63462baeae90c86 3465 | Signer #1 key algorithm: RSA 3466 | Signer #1 key size (bits): 2048 3467 | Signer #1 public key SHA-256 digest: 5b125027893d4e43b5a4c1a4359968b86f0c0be30f9ef012349ba41855f0ff67 3468 | Signer #1 public key SHA-1 digest: 447c28474fc0cba922d504b2fe88da0af35f2f0b 3469 | Signer #1 public key MD5 digest: 43935540d5670e2869d777751c96c9a4 3470 | ``` 3471 | 3472 | there's a peculiar function call at the beginning of the function. it seems 3473 | to call a function from some class vtable. it's passing &tmp3_string and 3474 | thid_char_mod_3 as param 3475 | 3476 | ```c 3477 | int * call_some_class(undefined4 param_1,int *param_2) 3478 | 3479 | { 3480 | int *local_c [3]; 3481 | 3482 | local_c[0] = some_class; 3483 | if (some_class != (int *)0x0) { 3484 | local_c[0] = param_2; 3485 | (**(code **)(*some_class + 0x10))(some_class,local_c,0,*(code **)(*some_class + 0x10),param_1); 3486 | } 3487 | return local_c[0]; 3488 | } 3489 | ``` 3490 | 3491 | if we look at what else references some_class, we find that it's the jni 3492 | env, assigned on JNI_OnLoad. the parameter is well documented. it also 3493 | seems to be doing some interesting rng initialization 3494 | 3495 | ```c 3496 | undefined4 JNI_OnLoad(void *vm,void *reserved) 3497 | 3498 | { 3499 | initialize_rand_seed(vm); 3500 | return 0x10006; 3501 | } 3502 | 3503 | void initialize_rand_seed(void *vm) 3504 | 3505 | { 3506 | time_t __seedval; 3507 | long rand; 3508 | undefined4 first_rand; 3509 | int extraout_r1; 3510 | int extraout_r1_00; 3511 | 3512 | set_jni_env(vm); 3513 | if (rng_initialized == 0) { 3514 | __seedval = time((time_t *)(uint)rng_initialized); 3515 | srand48(__seedval); 3516 | /* extract random numbers between 100 and 1000 until one matches the first 3517 | number extracted */ 3518 | rand = lrand48(); 3519 | __aeabi_idivmod(rand,900); 3520 | do { 3521 | rand = lrand48(); 3522 | __aeabi_idivmod(rand,900); 3523 | } while (extraout_r1 + 100 == extraout_r1_00 + 100); 3524 | matching_rand_xor_& = xor('&',extraout_r1 + 100); 3525 | first_rand_xor_& = xor('&',extraout_r1_00 + 100); 3526 | first_rand = xor('&',first_rand_xor_&); 3527 | first_rand_xor_M = xor('M',first_rand); 3528 | char_r = xor('r',0); 3529 | rng_initialized = 1; 3530 | } 3531 | return; 3532 | } 3533 | 3534 | void set_jni_env(undefined4 jni_env) 3535 | 3536 | { 3537 | jni_env = jni_env; 3538 | return; 3539 | } 3540 | ``` 3541 | 3542 | the jni call is being passed `third_char_mod_3` so I have a feeling that 3543 | decides which hash to use like with the other hashing function 3544 | 3545 | ```c 3546 | jniresult = call_jni_env(str,(int *)(int)third_char_mod_3); 3547 | ``` 3548 | 3549 | if we scroll down further we can find more calls into the jni env 3550 | 3551 | ```c 3552 | uVar4 = FUN_00023124(jniresult,tmpint__,tmp4,tmp5); 3553 | uVar5 = FUN_00023124(jniresult,tmpint__,tmp6,tmp7); 3554 | uVar6 = FUN_00023124(jniresult,tmpint__,tmp8,tmp9); 3555 | ``` 3556 | 3557 | right before this, we have a bunch of xor_until_match calls. let's hook 3558 | those and log all the unencrypted strings 3559 | 3560 | for some reason i had to hook 1 instruction into the func otherwise it 3561 | would crash, either way here's the results 3562 | 3563 | ``` 3564 | xor until match '[' 3565 | com/klab/jackpot/SignGenerator 3566 | xor until match '[' 3567 | open 3568 | xor until match '[' 3569 | ()Lcom/klab/jackpot/SignGenerator; 3570 | xor until match '[' 3571 | available 3572 | xor until match '[' 3573 | ()Z 3574 | xor until match '[' 3575 | get 3576 | xor until match '[' 3577 | (I)Ljava/lang/String; 3578 | xor until match '[' 3579 | close 3580 | xor until match '[' 3581 | ()V 3582 | string from range 3583 | 3f45f90cbcc718e4b63462baeae90c86 3584 | ``` 3585 | 3586 | so yeah, time to pull up the java side of the code 3587 | 3588 | nothing special going on in the constructor, just grabbing the sig bytes 3589 | 3590 | ```java 3591 | void SignGenerator(SignGenerator this) 3592 | 3593 | { 3594 | PackageManager ref; 3595 | String pSVar1; 3596 | PackageInfo pPVar2; 3597 | byte[] pbVar3; 3598 | CertificateFactory ref_00; 3599 | Certificate ref_01; 3600 | Activity ref_02; 3601 | Signature[] ppSVar4; 3602 | Signature ref_03; 3603 | InputStream ref_04; 3604 | 3605 | this.(); 3606 | ref_02 = UnityPlayer.currentActivity; 3607 | ref = ref_02.getPackageManager(); 3608 | pSVar1 = ref_02.getPackageName(); 3609 | /* GET_SIGNATURES */ 3610 | pPVar2 = ref.getPackageInfo(pSVar1,0x40); 3611 | ppSVar4 = pPVar2.signatures; 3612 | if ((ppSVar4 == null) || (ppSVar4.length < 1)) { 3613 | ref_03 = null; 3614 | } 3615 | else { 3616 | ref_03 = ppSVar4[0]; 3617 | } 3618 | if (ref_03 == null) { 3619 | return; 3620 | } 3621 | pbVar3 = ref_03.toByteArray(); 3622 | if (pbVar3 == null) { 3623 | return; 3624 | } 3625 | ref_04 = new InputStream(pbVar3); 3626 | ref_00 = CertificateFactory.getInstance("X509"); 3627 | if (ref_00 == null) { 3628 | return; 3629 | } 3630 | ref_01 = ref_00.generateCertificate(ref_04); 3631 | checkCast(ref_01,X509Certificate); 3632 | if (ref_01 == null) { 3633 | return; 3634 | } 3635 | pbVar3 = ref_01.getEncoded(); 3636 | this.mBytes = pbVar3; 3637 | return; 3638 | } 3639 | ``` 3640 | 3641 | let's look at other methods of this class 3642 | 3643 | ```java 3644 | String get(SignGenerator this,int p1) 3645 | 3646 | { 3647 | boolean bVar1; 3648 | MessageDigest ref; 3649 | byte[] pbVar2; 3650 | String ref_00; 3651 | Object[] ppOVar3; 3652 | BigInteger ref_01; 3653 | StringBuilder ref_02; 3654 | 3655 | bVar1 = this.available(); 3656 | if (bVar1 == false) { 3657 | return null; 3658 | } 3659 | if (p1 == 0) { 3660 | ref = MessageDigest.getInstance("md5"); 3661 | } 3662 | else { 3663 | if (p1 == 1) { 3664 | ref = MessageDigest.getInstance("sha1"); 3665 | } 3666 | else { 3667 | ref = MessageDigest.getInstance("sha256"); 3668 | } 3669 | } 3670 | if (ref == null) { 3671 | return null; 3672 | } 3673 | pbVar2 = ref.digest(this.mBytes); 3674 | ref_01 = new BigInteger(1,pbVar2); 3675 | ref_02 = new StringBuilder(); 3676 | ref_02.append("%0"); 3677 | ref_02.append(pbVar2.length << 1); 3678 | ref_02.append("X"); 3679 | ref_00 = ref_02.toString(); 3680 | ppOVar3 = new Object[1]; 3681 | ppOVar3[0] = ref_01; 3682 | ref_00 = String.format(ref_00,ppOVar3); 3683 | ref_00 = ref_00.toLowerCase(); 3684 | return ref_00; 3685 | } 3686 | ``` 3687 | 3688 | exactly as predicted. p1 would be the `third_char_mod_3` 3689 | 3690 | we can confidently rename the `crazy_function` to `get_package_signature` 3691 | 3692 | there's one more trick to this madness, it swaps third/second char mod3 3693 | based on whether the first character of base64 bytes is even or odd 3694 | 3695 | ```c 3696 | if ((first_char & 1) == 0) { 3697 | conditional_hash(&libjackpot_hash,&path_to_libjackpot,second_char_mod_3); 3698 | /* il2cpp */ 3699 | conditional_hash(&tmp3_hash,&tmp3_path,second_char_mod_3); 3700 | ptmp3_hash = &tmp3_hash; 3701 | array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash); 3702 | std_hexstring_from_array(&tmp3_string,&xored_hashes); 3703 | string_assignment((int)&stdstring1,(int)&tmp3_string); 3704 | operator_delete_(&tmp3_string); 3705 | operator_delete(&xored_hashes); 3706 | operator_delete(&tmp3_hash); 3707 | operator_delete(&libjackpot_hash); 3708 | get_package_signature(&tmp3_string,third_char_mod_3,ptmp3_hash); 3709 | string_assignment((int)&stdstring2,(int)&tmp3_string); 3710 | operator_delete_(&tmp3_string); 3711 | } 3712 | else { 3713 | get_package_signature(&tmp3_string,(char)second_char_mod_3); 3714 | string_assignment((int)&stdstring1,(int)&tmp3_string); 3715 | operator_delete_(&tmp3_string); 3716 | conditional_hash(&libjackpot_hash,&path_to_libjackpot,third_char_mod_3); 3717 | conditional_hash(&tmp3_hash,&tmp3_path,third_char_mod_3); 3718 | array_xor(&xored_hashes,&libjackpot_hash,&tmp3_hash); 3719 | std_hexstring_from_array(&tmp3_string,&xored_hashes); 3720 | string_assignment((int)&stdstring2,(int)&tmp3_string); 3721 | operator_delete_(&tmp3_string); 3722 | operator_delete(&xored_hashes); 3723 | operator_delete(&tmp3_hash); 3724 | operator_delete(&libjackpot_hash); 3725 | } 3726 | ``` 3727 | 3728 | note also how signature and xored hash are swapped between stdstring1/2 3729 | 3730 | finally it concatenates the xored hashes and the package signature in 3731 | a single string, separating them with '-'. 3732 | 3733 | ```c 3734 | if ((first_char & 1) == 0) { 3735 | if (stdstring1.start == stdstring1.end) { 3736 | string_from_c((int)&tmp3_string,tmp2); 3737 | string_assignment((int)&stdstring1,(int)&tmp3_string); 3738 | operator_delete_(&tmp3_string); 3739 | } 3740 | if (stdstring2.start != stdstring2.end) goto LAB_0002480e; 3741 | puVar3 = &stack0x00000104; 3742 | } 3743 | else { 3744 | if (stdstring1.start == stdstring1.end) { 3745 | string_from_c((int)&tmp3_string,tmp1); 3746 | string_assignment((int)&stdstring1,(int)&tmp3_string); 3747 | operator_delete_(&tmp3_string); 3748 | } 3749 | if (stdstring2.start != stdstring2.end) goto LAB_0002480e; 3750 | puVar3 = &stack0x00000124; 3751 | } 3752 | string_from_c((int)&tmp3_string,puVar3 + -0x174); 3753 | string_assignment((int)&stdstring2,(int)&tmp3_string); 3754 | operator_delete_(&tmp3_string); 3755 | LAB_0002480e: 3756 | string_concatenate_char(&tmp3_string,&stdstring1,"-"); 3757 | string_concatenate(&tmpstring,&tmp3_string,&stdstring2); 3758 | operator_delete_(&tmp3_string); 3759 | ``` 3760 | 3761 | the tmp1/tmp2 cases will never be reached as stdstring1 will always be set 3762 | to either the package signature or the xored hashes. therefore the only 3763 | code that matters is the part at `LAB_0002480e:` , where it concatenates 3764 | stdstring1 (either sig or xored hashes), "-", and stdstring2 (either sig 3765 | or xored hashes) into tmpstring 3766 | 3767 | finally, we have a pretty convoluted xor encryption of some sort which 3768 | is stored into what was `xored_hashes` earlier and the result is 3769 | base64 encoded, and there's our `asset_state` field! we finally reached 3770 | the end of this crazy function 3771 | 3772 | ```c 3773 | rounds = 10; 3774 | xorkey = ((uint)(byte)*base64RandomBytes | 3775 | (uint)(byte)base64RandomBytes[2] << 0x10 | (uint)(byte)base64RandomBytes[1] << 8 | 3776 | (uint)(byte)base64RandomBytes[3] << 0x18) ^ 0x12d8af36; 3777 | a = 0; 3778 | b = 0; 3779 | c = 0x2bd57287; 3780 | d = 0; 3781 | e = 0x202c9ea2; 3782 | f = 0; 3783 | g = 0x139da385; 3784 | do { 3785 | h = g; 3786 | i = f; 3787 | j = e; 3788 | k = d; 3789 | g = c; 3790 | f = b; 3791 | a = (a << 0xb | xorkey >> 0x15) ^ a; 3792 | xorkey = xorkey << 0xb ^ xorkey; 3793 | c = (g >> 0x13 | k << 0xd) ^ xorkey ^ g ^ (xorkey >> 8 | a << 0x18); 3794 | d = k >> 0x13 ^ a ^ k ^ a >> 8; 3795 | rounds = rounds + -1; 3796 | xorkey = j; 3797 | a = i; 3798 | b = k; 3799 | e = h; 3800 | } while (rounds != 0); 3801 | new_array(&xored_hashes,(int)(tmpstring.end + -(int)tmpstring.start)); 3802 | while (a = g, xorkey = f, rounds < (int)(tmpstring.end + -(int)tmpstring.start)) { 3803 | b = (i << 0xb | j >> 0x15) ^ i; 3804 | j = j << 0xb ^ j; 3805 | e = (c >> 0x13 | d << 0xd) ^ j ^ c ^ (j >> 8 | b << 0x18); 3806 | xored_hashes.data[rounds] = tmpstring.start[rounds] ^ (byte)e; 3807 | rounds = rounds + 1; 3808 | f = k; 3809 | g = c; 3810 | k = d; 3811 | j = h; 3812 | i = xorkey; 3813 | c = e; 3814 | d = d >> 0x13 ^ b ^ d ^ b >> 8; 3815 | h = a; 3816 | } 3817 | base64_encode(base64_result,&xored_hashes); 3818 | ``` 3819 | 3820 | so let's implement this abomination and see if it matches the game 3821 | 3822 | this took a lot of work to get the operator precedence right and not have 3823 | signed-ness interfere in kotlin. i also hooked string_concat to check that 3824 | at least I had the hashes right 3825 | 3826 | here is the result, and it produces asset_state values exactly like the 3827 | game 3828 | 3829 | ```kotlin 3830 | fun String.hexStringToByteArray() = 3831 | chunked(2).map { it.toInt(16).toByte() }.toByteArray() 3832 | fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } 3833 | fun ByteArray.xor(other: ByteArray) = 3834 | (zip(other) { a, b -> (a.toInt() xor b.toInt()).toByte() }).toByteArray() 3835 | 3836 | // md5, sha1, sha256 of the package's signature 3837 | // obtained by running https://github.com/warren-bank/print-apk-signature 3838 | // on the split apk 3839 | val PackageSignatures = arrayOf( 3840 | "3f45f90cbcc718e4b63462baeae90c86", 3841 | "1be2103a6929b38798a29d89044892f3b3934184", 3842 | "1d32dbcf91697d46594ad689d49bb137f65d4bb8f56a26724ae7008648131b82" 3843 | ) 3844 | 3845 | // md5, sha1, sha256 of libjackpot-core.so 3846 | val JackpotSignatures = arrayOf( 3847 | "81ec95e20a695c600375e3b8349722ab", 3848 | "5a3cb86aa9b082d6a1c1dfa6f73dd431d7f14e18", 3849 | "66370b8c96de7266b02bfe17e696d8a61b587656a34b19fbb0b2768a5305dd1d" 3850 | ).map { it.hexStringToByteArray() } 3851 | 3852 | // md5, sha1, sha256 of libil2cpp.so 3853 | val Il2CppSignatures = arrayOf( 3854 | "67f969e32c2d775b35e2f2ad10b423c1", 3855 | "c4387c429c50c4782ab3df409db3abcfa8fadf79", 3856 | "d30568d1057fecb31a16f4062239c1ec65b9c2beab41b836658b637dcb5a51e4" 3857 | ).map { it.hexStringToByteArray() } 3858 | 3859 | @ExperimentalUnsignedTypes 3860 | fun assetStateLogGenerateV2(randomBytes64: String): String { 3861 | val libHashChar = (randomBytes64[0].toInt() and 1) + 1 3862 | val libHashType = randomBytes64[libHashChar].toInt().rem(3) 3863 | val pkgHashChar = 2 - (randomBytes64[0].toInt() and 1) 3864 | val pkgHashType = randomBytes64[pkgHashChar].toInt().rem(3) 3865 | val xoredHashes = 3866 | JackpotSignatures[libHashType].xor(Il2CppSignatures[libHashType]) 3867 | .toHexString() 3868 | val packageSignature = PackageSignatures[pkgHashType] 3869 | val signatures = when (randomBytes64[0].toInt() and 1) { 3870 | 0 -> "$xoredHashes-$packageSignature" 3871 | 1 -> "$packageSignature-$xoredHashes" 3872 | else -> "$xoredHashes-$packageSignature" 3873 | } 3874 | println(signatures) 3875 | var xorkey = 3876 | (randomBytes64[0].toByte().toUInt() or 3877 | (randomBytes64[1].toByte().toUInt() shl 8) or 3878 | (randomBytes64[2].toByte().toUInt() shl 16) or 3879 | (randomBytes64[3].toByte().toUInt() shl 24)) xor 0x12d8af36u 3880 | var a = 0u 3881 | var b = 0u 3882 | var c = 0x2bd57287u 3883 | var d = 0u 3884 | var e = 0x202c9ea2u 3885 | var f = 0u 3886 | var g = 0x139da385u 3887 | var h = 0u 3888 | var i = 0u 3889 | var j = 0u 3890 | var k = 0u 3891 | repeat(10) { 3892 | h = g 3893 | i = f 3894 | j = e 3895 | k = d 3896 | g = c 3897 | f = b 3898 | a = ((a shl 11) or (xorkey shr 21)) xor a 3899 | xorkey = (xorkey shl 11) xor xorkey 3900 | c = ((g shr 19) or (k shl 13)) xor xorkey xor g xor ((xorkey shr 8) or (a shl 24)) 3901 | d = (k shr 19) xor a xor k xor (a shr 8) 3902 | xorkey = j 3903 | a = i 3904 | b = k 3905 | e = h 3906 | } 3907 | val xorBytes = ByteArray(signatures.length) 3908 | for (index in 0..signatures.length - 1) { 3909 | a = g 3910 | xorkey = f 3911 | b = ((i shl 11) or (j shr 21)) xor i 3912 | j = (j shl 11) xor j 3913 | e = ((c shr 19) or (d shl 13)) xor j xor c xor ((j shr 8) or (b shl 24)) 3914 | xorBytes[index] = e.toByte() 3915 | f = k 3916 | g = c 3917 | k = d 3918 | j = h 3919 | i = xorkey 3920 | c = e 3921 | d = (d shr 19) xor b xor d xor (b shr 8) 3922 | h = a 3923 | } 3924 | return base64Encoder.encodeToString(signatures.toByteArray().xor(xorBytes)) 3925 | } 3926 | ``` 3927 | 3928 | and here is a test with known values grabbed from the game. it produces 3929 | the exact same output 3930 | 3931 | ``` 3932 | # running 3933 | random bytes: CB7tjOEZK6IQJrX93O0BuTjM5txYFmFO8sv1Pq9eAcE= 3934 | 3935 | 3f45f90cbcc718e4b63462baeae90c86-9e04c42835e046ae8b7200e66a8e7ffe7f0b9161 3936 | 0fd36d6349f7a06c4a8e169a26d0010dc12bb77c50ea4579823c03f6498a4c6f52a6ba6bb4737b633f2f5077d9a62b161c6de5814d8b878dc42c620e58850a3774b70bc071dc1554d3 3937 | 3938 | expected: 3939 | 3f45f90cbcc718e4b63462baeae90c86-9e04c42835e046ae8b7200e66a8e7ffe7f0b9161 3940 | 0fd36d6349f7a06c4a8e169a26d0010dc12bb77c50ea4579823c03f6498a4c6f52a6ba6bb4737b633f2f5077d9a62b161c6de5814d8b878dc42c620e58850a3774b70bc071dc1554d3 3941 | ``` 3942 | 3943 | I spent some time mapping a bunch of the json responses. the 3944 | `Serialization$$Deserialize*` functions tell you all about which fields 3945 | each object has and which ones are optional. very relaxing activity 3946 | 3947 | I also had to fight gson to override its builtin map serializer since 3948 | sifas maps are laid out as `[key, value, key, value, ...]` . it was not 3949 | a fun experience working with the generics hell that is java/kotlin 3950 | 3951 | another thing I noticed is that the first hash in the response array is 3952 | actually what's being used as MasterVersion, so I'll stop hardcoding it. 3953 | instead, you're supposed to send a 3954 | `/dataLink/fetchGameServiceDataBeforeLogin` call without MasterVersion 3955 | 3956 | next is the terms of service check. nothing special, it sends the current 3957 | version accepted (from the LoginResponse) and gets back the current terms 3958 | of service version. the response is a LoginResponse but with a lot of the 3959 | data omitted 3960 | 3961 | ... except it 403's for some reason. most likely something changes the 3962 | sessionKey after the login request. I remember seeing a method named 3963 | SessionEncrypt that takes the first 16 bytes of the sessionKey and 3964 | does some stuff with rijndael. let's take a look at it 3965 | 3966 | it's using a lot of virtual function calls 3967 | 3968 | ```c 3969 | rijndaelManaged = (int *)thunk_FUN_008ae738(Class$System.Security.Cryptography.RijndaelManaged); 3970 | RijndaelManaged$$.ctor(rijndaelManaged,0); 3971 | ... 3972 | uVar1 = DMHttpApi$$CopySessionKey(0x10); 3973 | ... 3974 | (**(code **)(*rijndaelManaged + 0x128)) 3975 | (rijndaelManaged,uVar1,*(undefined4 *)(*rijndaelManaged + 300)); 3976 | (**(code **)(*rijndaelManaged + 0x198))(rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x19c)) 3977 | ; 3978 | piVar2 = (int *)(**(code **)(*rijndaelManaged + 0x170)) 3979 | (rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x174)); 3980 | uVar1 = (**(code **)(*rijndaelManaged + 0x110)) 3981 | (rijndaelManaged,*(undefined4 *)(*rijndaelManaged + 0x114)); 3982 | 3983 | ... 3984 | ``` 3985 | 3986 | let's scroll down for clues. 3987 | 3988 | since it says ICryptoTransform, I guessed this is the call to one of 3989 | the transform methods of the encryptor 3990 | 3991 | ```c 3992 | pTransformBlock = (code **)FUN_0086a584(encryptor,Class$System.Security.Cryptography.ICryptoTransform,5); 3993 | encryptedBytes = (**pTransformBlock)(encryptor,bytes,0,numBytes,pTransformBlock[1]); 3994 | ``` 3995 | 3996 | let's look at the docs for ICryptoTransform. 3997 | the signature for TransformBlock is: 3998 | 3999 | ``` 4000 | public byte[] TransformFinalBlock (byte[] inputBuffer, int inputOffset, int inputCount); 4001 | ``` 4002 | 4003 | that matches, and the last parameter is a decompilation error. this also 4004 | tells me that what i named `bytes` is our standard `Array` struct we've 4005 | used in other hooks 4006 | 4007 | so, are the 16 bytes of the sessionKey the IV or the Key? it's tricky 4008 | to tell, the IV has to be 16 bytes, but the key can also be 16 bytes 4009 | 4010 | also, I can't find any x-refs to this so let's hook it and see where it's 4011 | being called from 4012 | 4013 | very interesting. it seems that this is not what I thought it was. 4014 | it appears to be encrypting values in memory, specifically important 4015 | in-game things. it's called as soon as you start a live and the call is the 4016 | second last `Int::set_Value` here: 4017 | 4018 | ```c 4019 | void BuffInfo$$.ctor(int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4, 4020 | undefined4 param_5) 4021 | 4022 | { 4023 | undefined4 uVar1; 4024 | int iVar2; 4025 | 4026 | if (DAT_03708177 == '\0') { 4027 | FUN_00871ed4(0x1d99); 4028 | DAT_03708177 = '\x01'; 4029 | } 4030 | Object$$.ctor(param_1,0); 4031 | uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int); 4032 | Int$$.ctor(uVar1,0,0); 4033 | *(undefined4 *)(param_1 + 8) = uVar1; 4034 | uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int); 4035 | Int$$.ctor(uVar1,0,0); 4036 | *(undefined4 *)(param_1 + 0xc) = uVar1; 4037 | uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int); 4038 | Int$$.ctor(uVar1,0,0); 4039 | *(undefined4 *)(param_1 + 0x10) = uVar1; 4040 | uVar1 = thunk_FUN_008ae738(Class$LLAS.ObfuscatedTypes.Int); 4041 | Int$$.ctor(uVar1,0,0); 4042 | iVar2 = *(int *)(param_1 + 0xc); 4043 | *(undefined4 *)(param_1 + 0x14) = uVar1; 4044 | if (iVar2 == 0) { 4045 | FUN_0089d750(0); 4046 | } 4047 | Int$$set_Value(iVar2,param_2,0); 4048 | iVar2 = *(int *)(param_1 + 8); 4049 | if (iVar2 == 0) { 4050 | FUN_0089d750(0); 4051 | } 4052 | Int$$set_Value(iVar2,param_3,0); 4053 | iVar2 = *(int *)(param_1 + 0x10); 4054 | if (iVar2 == 0) { 4055 | FUN_0089d750(0); 4056 | } 4057 | Int$$set_Value(iVar2,param_4,0); 4058 | iVar2 = *(int *)(param_1 + 0x14); 4059 | if (iVar2 == 0) { 4060 | FUN_0089d750(0); 4061 | } 4062 | Int$$set_Value(iVar2,param_5,0); 4063 | return; 4064 | } 4065 | ``` 4066 | 4067 | well, I'm not really interested in this, so this is a dead end for now. 4068 | gotta figure out why my terms of service request isn't being accepted 4069 | 4070 | oh I'm stupid, there's a session key right in the login response! and it 4071 | gets xored in `DMHttpApi.__c__DisplayClass14_1$$_Login_b__1` 4072 | 4073 | what it gets xored with is likely either the old sessionKey or the same 4074 | randomBytes that are xored with the startup auth key 4075 | 4076 | and sure enough, it's the random bytes that are used to make the login 4077 | mask in `DMHttpApi.__c__DisplayClass14_0$$_Login_b__0` 4078 | 4079 | seeing how not many other places call randomBytes I'm inclined to think 4080 | this is similar to how old SIF worked and that I should hold onto those 4081 | random bytes and only regenerate on startup/login 4082 | 4083 | there we go, we are truly logged in now, that was a brainlet moment 4084 | 4085 | so the next request is `/userProfile/setProfile` which sets name and 4086 | nickname, but most interestingly it contains a field called `device_token` 4087 | 4088 | let's look at the `SetUserProfileRequest` constructor and the device 4089 | token getter 4090 | 4091 | ```c 4092 | void SetUserProfileRequest$$.ctor 4093 | (int param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4, 4094 | undefined4 param_5) 4095 | 4096 | { 4097 | Object$$.ctor(param_1,0); 4098 | *(undefined4 *)(param_1 + 8) = param_2; 4099 | *(undefined4 *)(param_1 + 0xc) = param_3; 4100 | *(undefined4 *)(param_1 + 0x10) = param_4; 4101 | *(undefined4 *)(param_1 + 0x14) = param_5; 4102 | return; 4103 | } 4104 | 4105 | void SetUserProfileRequest$$set_DeviceToken(int param_1,undefined4 param_2) 4106 | 4107 | { 4108 | *(undefined4 *)(param_1 + 0x14) = param_2; 4109 | return; 4110 | } 4111 | ``` 4112 | 4113 | okay, so param 5 of the constructor is the device token. what calls the 4114 | constructor? 4115 | 4116 | ```c 4117 | void UserProfileDM$$SetName 4118 | (undefined4 param_1,undefined4 deviceToken,undefined4 param_3,undefined4 param_4) 4119 | 4120 | { 4121 | int iVar1; 4122 | int iVar2; 4123 | undefined4 uVar3; 4124 | undefined4 uVar4; 4125 | undefined4 uVar5; 4126 | 4127 | if (DAT_037070ef == '\0') { 4128 | FUN_00871ed4(0xbdc0); 4129 | DAT_037070ef = '\x01'; 4130 | } 4131 | iVar1 = thunk_FUN_008ae738(Class$UserProfileDM.__c__DisplayClass5_0); 4132 | Object$$.ctor(iVar1,0); 4133 | if (iVar1 == 0) { 4134 | FUN_0089d750(0); 4135 | _DAT_00000008 = param_3; 4136 | FUN_0089d750(0); 4137 | } 4138 | else { 4139 | *(undefined4 *)(iVar1 + 8) = param_3; 4140 | } 4141 | *(undefined4 *)(iVar1 + 0xc) = param_4; 4142 | iVar2 = UserProfileDM$$CheckInputTextLength(1,param_1); 4143 | if (iVar2 == 1) { 4144 | iVar2 = thunk_FUN_008ae738(Class$DotUnder.SVAPI.SetUserProfile); 4145 | SetUserProfile$$.ctor(iVar2,0); 4146 | uVar3 = thunk_FUN_008ae738(Class$DotUnder.Structure.SetUserProfileRequest); 4147 | SetUserProfileRequest$$.ctor(uVar3,param_1,0,0,deviceToken,0); 4148 | ... 4149 | ``` 4150 | 4151 | (the last zero param is a decompilation error) 4152 | 4153 | great, so what passes the deviceToken to SetName? 4154 | 4155 | ```c 4156 | void TutorialDM$$SetUserName(undefined4 param_1,undefined4 param_2,undefined4 param_3) 4157 | 4158 | { 4159 | undefined4 uVar1; 4160 | 4161 | uVar1 = PushNotificationDM$$GetDeviceToken(0); 4162 | UserProfileDM$$SetName(param_1,uVar1,param_2,param_3,0); 4163 | return; 4164 | } 4165 | ``` 4166 | 4167 | would you look at that 4168 | 4169 | let's see where this leads us 4170 | 4171 | ```c 4172 | int PushNotificationDM$$GetDeviceToken(void) 4173 | 4174 | { 4175 | int iVar1; 4176 | 4177 | if (DAT_03706971 == '\0') { 4178 | FUN_00871ed4(0x7da8); 4179 | DAT_03706971 = '\x01'; 4180 | } 4181 | iVar1 = ASLocalStorage$$get_PushNotificationDeviceToken(0); 4182 | if (iVar1 == 0) { 4183 | iVar1 = StringLiteral_73; 4184 | } 4185 | return iVar1; 4186 | } 4187 | 4188 | undefined4 ASLocalStorage$$get_PushNotificationDeviceToken(void) 4189 | 4190 | { 4191 | int iVar1; 4192 | undefined4 uVar2; 4193 | 4194 | if (DAT_037093a3 == '\0') { 4195 | FUN_00871ed4(0x91); 4196 | DAT_037093a3 = '\x01'; 4197 | } 4198 | if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) && 4199 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4200 | FUN_0087f930(); 4201 | } 4202 | iVar1 = LocalStorage$$HasKey("PushNotificationDeviceToken",0); 4203 | if (iVar1 == 1) { 4204 | if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) && 4205 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4206 | FUN_0087f930(); 4207 | } 4208 | uVar2 = LocalStorage$$GetString("PushNotificationDeviceToken",StringLiteral_73,0); 4209 | return uVar2; 4210 | } 4211 | return 0; 4212 | } 4213 | ``` 4214 | 4215 | cool, so it's in local storage. is there anything that actyally sets it? 4216 | let's check other ASLocalStorage methods 4217 | 4218 | there is 4219 | 4220 | ```c 4221 | void ASLocalStorage$$set_PushNotificationDeviceToken(int param_1) 4222 | 4223 | { 4224 | if (DAT_037093a4 == '\0') { 4225 | FUN_00871ed4(0xf5); 4226 | DAT_037093a4 = '\x01'; 4227 | } 4228 | if (param_1 != 0) { 4229 | if (((*(ushort *)(Class$DotUnder.LocalStorage + 0xbe) & 0x200) != 0) && 4230 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4231 | FUN_0087f930(); 4232 | } 4233 | LocalStorage$$SetString("PushNotificationDeviceToken",param_1,0); 4234 | return; 4235 | } 4236 | if (((*(ushort *)(Class$DotUnder.LocalStorage + 0xbe) & 0x200) != 0) && 4237 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4238 | FUN_0087f930(); 4239 | } 4240 | LocalStorage$$DeleteKey("PushNotificationDeviceToken",0); 4241 | return; 4242 | } 4243 | 4244 | void PushNotificationDM$$SetDeviceToken(undefined4 param_1) 4245 | 4246 | { 4247 | ASLocalStorage$$set_PushNotificationDeviceToken(param_1,0); 4248 | return; 4249 | } 4250 | 4251 | void PushNotification.__c$$_InitializeFirebaseMessaging_b__7_0 4252 | (undefined4 param_1,undefined4 param_2,int param_3) 4253 | 4254 | { 4255 | undefined4 uVar1; 4256 | undefined4 uVar2; 4257 | 4258 | if (DAT_03704bd7 == '\0') { 4259 | FUN_00871ed4(0xaff0); 4260 | DAT_03704bd7 = '\x01'; 4261 | } 4262 | if (param_3 == 0) { 4263 | FUN_0089d750(0); 4264 | } 4265 | uVar1 = TokenReceivedEventArgs$$get_Token(param_3,0); 4266 | uVar1 = String$$Concat("FirebaseMessaging:-Token-Received:",uVar1,0); 4267 | uVar2 = FUN_010fe368(Method$Array.Empty()_object_); 4268 | if (((*(byte *)(Class$LLAS.LLogger + 0xbf) & 2) != 0) && 4269 | (*(int *)(Class$LLAS.LLogger + 0x70) == 0)) { 4270 | FUN_0087f930(); 4271 | } 4272 | LLogger$$Debug(uVar1,uVar2,0); 4273 | if (param_3 == 0) { 4274 | FUN_0089d750(0); 4275 | } 4276 | uVar1 = TokenReceivedEventArgs$$get_Token(param_3,0); 4277 | PushNotificationDM$$SetDeviceToken(uVar1,0); 4278 | return; 4279 | } 4280 | ``` 4281 | 4282 | hmm. it seems to be a standard token for push notifications obtained 4283 | through firebase. I wonder if they actually check it. I doubt it, but if 4284 | it's not too hard I would like to get a valid one 4285 | 4286 | in base.apk/assets/google-services-desktop.json we can find the 4287 | firebase info 4288 | 4289 | ```json 4290 | { 4291 | "project_info": { 4292 | "project_number": "304268967066", 4293 | "firebase_url": "https://all-stars-dev-91501190.firebaseio.com", 4294 | "project_id": "all-stars-dev-91501190", 4295 | "storage_bucket": "all-stars-dev-91501190.appspot.com" 4296 | }, 4297 | "client": [ 4298 | { 4299 | "client_info": { 4300 | "mobilesdk_app_id": "1:304268967066:ios:fc1a9b49d6829936", 4301 | "android_client_info": { 4302 | "package_name": "com.Company.ProductName" 4303 | } 4304 | }, 4305 | "oauth_client": [ 4306 | { 4307 | "client_id": "304268967066-e3n2no1402f89rqub55u0c7jrhhj4o3d.apps.googleusercontent.com" 4308 | } 4309 | ], 4310 | "api_key": [ 4311 | { 4312 | "current_key": "AIzaSyBAehda1QFNwi2g9U4FcT7m8lF8NPAdikg" 4313 | } 4314 | ], 4315 | "services": { 4316 | "analytics_service": { 4317 | "status": 0 4318 | }, 4319 | "appinvite_service": { 4320 | "status": 0 4321 | } 4322 | } 4323 | } 4324 | ], 4325 | "configuration_version": "1" 4326 | } 4327 | ``` 4328 | 4329 | using `push-receiver` in nodejs and the `project_number` for sifas you can 4330 | get what look like valid push notification tokens: 4331 | 4332 | ```js 4333 | const { register, listen } = require('push-receiver'); 4334 | 4335 | (async () => { 4336 | credentials = await register(304268967066); 4337 | console.log(credentials) 4338 | })().catch(e => { 4339 | console.log(e) 4340 | }); 4341 | ``` 4342 | 4343 | the token should be the fcm one 4344 | 4345 | it was hard to find anything else that could do this in other langs and 4346 | I'm not up for implementing firebase protocol, but yeah, this should be 4347 | how you get 100% real tokens. or just send it empty, it shouldn't be 4348 | mandatory 4349 | 4350 | all fields in setProfile are optional and it calls it multiple times, once 4351 | for name and once for nickname, always providing the device token 4352 | 4353 | name and nickname must be 10 characters max. I keep them alphanumeric, but 4354 | I think spaces are allowed in both. if you enter an invalid name, the 4355 | server responds with error 500 4356 | 4357 | the response is once again a LoginResponse 4358 | 4359 | next request is `/userProfile/setProfileBirthday` . nothing special here, 4360 | just send month and day 4361 | 4362 | next up is `/story/finishUserStoryMain` . I think this is sent when you 4363 | skip the cutscene after setting your name. again nothing special, the only 4364 | dubious part is how `is_auto_mode` is determined, but it might be on 4365 | a per-chapter basis and stored in the game's database 4366 | 4367 | the response contains a `user_model_diff` field which is a UseModel object 4368 | just like in the LoginResponse, but most likely only contains changed 4369 | fields. maybe those other smaller LoginResponses work like this 4370 | 4371 | next up is `/live/start`, which as the name suggests starts a live. as 4372 | with old sif, you can pick a partner and the partner user id is sent here. 4373 | the response contains literally the entire map - all the note timings and 4374 | other things 4375 | 4376 | after the live start request, it sends a saveRuleDescription request 4377 | with a rule description id that was added to the user model after the 4378 | finishUserStoryMain request. this changes its `display_status` from 1 to 3. 4379 | no idea what this means but we could speculate it's the story progression 4380 | status of each chapter 4381 | 4382 | I also figured out that the "LoginResponse" responses for terms and the 4383 | other non-login requests is actually called UserModelResponse and only 4384 | expects `user_model` 4385 | 4386 | so it seems that the game freezes when i start a live while it's hooked. 4387 | I think this might be because the lib I am hooking from is being reloaded 4388 | or something, resulting in hooks jumping to deallocated memory. need to 4389 | investigate how to properly uninstall hooks when the library is unloaded 4390 | 4391 | nevermind, after looking at the log it seems simpler - looks like we're 4392 | missing an export 4393 | 4394 | ``` 4395 | 10-25 22:34:46.166 9776 9801 E Unity : EntryPointNotFoundException: Unable to find an entry point named 'NativeInputPollTouches' in 'KLab. 4396 | NativeInput.Native'. 4397 | 10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.Dll_PollTouches (System.Void* result, System.Int32 4398 | resultLength, System.Int32& anyRemaining) [0x00000] in <00000000000000000000000000000000>:0 4399 | 10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.PollTouches () [0x00000] in <00000000000000000000000000000000>:0 4400 | 10-25 22:34:46.166 9776 9801 E Unity : at KLab.NativeInput.LowLevel.NativeTouchQueue.GetLatestTouches (System.Collections.Generic.List`1 4401 | [T] result) [0x00000] in <00000000000000000000000000000000>:0 4402 | 10-25 22:34:46.166 9776 9801 E Unity : at LLAS.Scene.Live.Game.TouchInputBroadcaster.RefreshInput () [0x00000] in <000000000000000000000 4403 | 00000000000>:0 4404 | ``` 4405 | 4406 | let's add all the `NativeInput*` exports 4407 | 4408 | yep, doesn't crash anymore now. time to log and implement more requests 4409 | 4410 | once you complete a song, the game sends note-by-note scoring to 4411 | /live/finish , but I'm not gonna bother emulating that for now. for the 4412 | tutorial lives, you can skip and it will send all notes with 0 score 4413 | 4414 | the `live_id` in this request is interesting. you get it from the live start 4415 | request response and it's an unusually large number. 4416 | 4417 | I'm 99% sure the first few digits are the timestamp, but I don't know if 4418 | the whole thing is a really high resolution timestamp or if it's 2 4419 | values combined. doesn't really matter, because we don't need to compute 4420 | it, I just thought it seemed interesting. 4421 | 4422 | so, for the result dict we get the note id's from the live start response 4423 | and set them all to judge type 1, voltage 0, card master id 0 4424 | 4425 | `wave_stat` dict should match the `live_wave_settings` from the live start 4426 | request. I think this is always set to all false's when skipping 4427 | 4428 | the `turn_stat_dict` seems also all zero'd, except for the first note. 4429 | we need to calculate or hardcode `current_life` for this first note. 4430 | I'll hardcode it for now 4431 | 4432 | not sure where `target_score` comes from, I'll hardcode it for now. we 4433 | only need to skip the 2 tutorial lives anyway 4434 | 4435 | somehow, skipping submits the score as a "perfect" live and even full combo 4436 | 4437 | live power, yet another value to hardcode or learn to calculate from the 4438 | team info 4439 | 4440 | for the card stat dict, it's just the card id's from `user_live_deck_by_id` 4441 | with all zero vals 4442 | 4443 | interestingly, at the end of turn stat dict we have notes 0 and 55 which 4444 | are technically out of bounds and note 0 has current life set like note 1. 4445 | this pattern repeats in different songs 4446 | 4447 | next up we just have another story complete request and another live skip, 4448 | nothing to see here. also another saveRuleDescription, this time with id 4449 | 2 (previous one was id 1) 4450 | 4451 | then there's the favorite character selection, 4452 | `/communicationMember/setFavoriteMember` . straightforward request, 4453 | nothing to see. seems to return a UserModelResponse 4454 | 4455 | `/bootstrap/fetchBootstrap` : this request seems to get an user model diff 4456 | with only the info requested in the `bootstrap_fetch_types` field. i might 4457 | map these types out later, for now let's just request what the game 4458 | requests. device name is also sent to the server here. no big deal, we can 4459 | just make a list of known devices. types are `[2,3,4,5,9,10]` in this 4460 | particular request. note that this is not a normal UserModelResponse. 4461 | it has some extra fetch bootstrap fields 4462 | 4463 | I found a list of valid device names here https://support.google.com/googleplay/answer/1727131?hl=en-GB 4464 | 4465 | this is how the game gets the device name: https://docs.unity3d.com/ScriptReference/SystemInfo-deviceModel.html 4466 | which would be the 4th column of the csv above 4467 | 4468 | I also noticed that the device_name was only introduced after the first 4469 | few updates, the first binary I dumped didn't have it 4470 | 4471 | `/navi/tapLovePoint` : this is sent when you touch your waifu and get love 4472 | points, it contains the same character id used in setFavoriteMember and 4473 | returns what looks like a UserModelResponse 4474 | 4475 | `/navi/saveUserNaviVoice` : this takes some id (100010004) that doesn't 4476 | seem to come from previous responses and returns a UserModelResponse. 4477 | it's used to refresh the voices map in the UserModel. not sure what those 4478 | voices do, maybe it just updates which menus are unlocked? 4479 | 4480 | `/trainingTree/fetchTrainingTree` and `/trainingTree/levelUpCard` are 4481 | for the tutorial on leveling up your card. nothing special, you just need 4482 | your card master id 4483 | 4484 | `/trainingTree/activateTrainingTreeCell` I think this is when you do 4485 | the member training and spend your AP? not sure where the cell id's come 4486 | from, at first I thought it was member master id's but nope, in the user 4487 | model they go from 1-9 and then jump to like 101+ 4488 | 4489 | `/card/updateCardNewFlag` I think this updates the two awakening booleans 4490 | in the user model's card info, which switched to true after the training. 4491 | again, this returns a UserModelResponse 4492 | 4493 | `/communicationMember/finishUserStorySide` this is probably when you get 4494 | the dialogue with your waifu. similar to the main story finish request, 4495 | nothing to see here 4496 | 4497 | `/liveDeck/saveDeckAll` and `/liveDeck/saveSuit` , I guess this is where 4498 | you set up your live team. I think the `squad_dict` is the three teams 4499 | you can switch between, but I can't figure out what `card_with_suit` 4500 | signifies. it's a map of card id's to either null or their suit id which 4501 | is the same as the card id. maybe suit is the outfit? well, I don't need to 4502 | understand anyway for now, I just hardcode the default team the tutorial 4503 | picks for me 4504 | 4505 | note that it's important to update our usermodel at this request so that 4506 | our team is correct in the next live finish requests 4507 | 4508 | `/livePartners/fetch` empty request, returns a list of partners before 4509 | starting the live 4510 | 4511 | while looking at this request I found something pretty funny, they typo'd 4512 | "Partners" in the response class name 4513 | 4514 | ```c 4515 | FetchLiveParntersResponse$$.ctor(iVar1,0); 4516 | if (((*(byte *)(Class$DotUnder.Serialization + 0xbf) & 2) != 0) && 4517 | (*(int *)(Class$DotUnder.Serialization + 0x70) == 0)) { 4518 | FUN_0087f930(); 4519 | } 4520 | piVar2 = (int *)Serialization$$TryGetValue(param_1,"partner_select_state"); 4521 | 4522 | ... 4523 | ``` 4524 | 4525 | then we have another live start/finish, live partner id is zero 4526 | 4527 | `/gacha/fetchGachaMenu` empty request, this is the scouting tutorial and 4528 | this retrieves the list of scouting pools 4529 | 4530 | `/gacha/draw` is nothing special, you just pass it the gacha id from the 4531 | `gacha_draws` field in the previous request 4532 | 4533 | yet another fetchBootstrap request, with the same id's as the first one 4534 | 4535 | `/tutorial/phaseEnd` this concludes the tutorial. empty request, returns a 4536 | UserModelResponse 4537 | 4538 | then it calls `/dataLink/fetchGameServiceData` which is similar to the 4539 | request it does at the very beginning, however I noticed it does some 4540 | interesting stuff with the response in 4541 | `PlatformGameServiceDM.__c__DisplayClass1_1$$_FetchState_b__1` 4542 | 4543 | ```c 4544 | iVar2 = FetchGameServiceDataResponse$$get_Data(param_2,0); 4545 | if (iVar2 == 0) { 4546 | FUN_0089d750(0); 4547 | } 4548 | uVar3 = UserLinkData$$get_AuthorizationKey(iVar2,0); 4549 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 4550 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 4551 | FUN_0087f930(); 4552 | } 4553 | uVar3 = Convert$$FromBase64String(uVar3,0); 4554 | uVar5 = Lib$$XorBytes(uVar5,uVar3,0); 4555 | if (iVar1 == 0) { 4556 | FUN_0089d750(0); 4557 | } 4558 | UserLinkData$$set_ServiceUserCommonKey(iVar1,uVar5,0); 4559 | iVar1 = *(int *)(param_1 + 0xc); 4560 | if (iVar1 == 0) { 4561 | FUN_0089d750(0); 4562 | } 4563 | ``` 4564 | 4565 | it seems that `service_user_common_key` is actually set client-side by 4566 | xoring `authorization_key` with something. most likely the random bytes 4567 | from the mask sent with the request. after checking, I noticed that it 4568 | also does this with FetchStateBeforeLogin. seems like it's saved as the 4569 | PW field in local storage in `UserKey$$SetIDPW` in 4570 | `PlatformGameService$$Migrate`. this PW is then retrieved on login, 4571 | and passed to `DMHttpApi.__c__DisplayClass14_0$$_Login_b__0` as third 4572 | param, which means this is our sessionKey for subsequent login requests 4573 | 4574 | well, we have a bit of a problem here. my fetchGameServiceData request 4575 | with the random service id doesn't return anything here, however you might 4576 | remember that when the game receives the startup response it does the same 4577 | `UserKey$$SetIDPW` call, which might mean that this authorization key is 4578 | just the one from when we called startup. either way, I want to investigate 4579 | whether I'm missing a request that associates the account with my service 4580 | id or if it's just doing that because it's an invalid service id 4581 | 4582 | yep, seems like I was missing a `/dataLink/linkOnStartUpGameService` call, 4583 | all good. let's move to the next request 4584 | 4585 | fetchBootstrap call with types 2,3,4,5,9,10,11 . this returns a list 4586 | of login bonuses in `fetch_bootstrap_login_bonus_response` . 4587 | 4588 | `/loginBonus/readLoginBonus` called once for each login bonus id, empty 4589 | response. I guess this marks the various login bonus splashscreens as read. 4590 | 4591 | seems like it's called on `event_2d_login_bonuses` with type 3, 4592 | `beginner_login_bonuses` with type 2, `login_bonuses` with type 1. not 4593 | sure what happens with the other 3 pools of login bonuses 4594 | 4595 | then we have another saveUserNaviVoice call with id's 100010123, 100010113 4596 | and a fetchBootstrap with the usual id's 4597 | 4598 | `/notice/fetchNoticeDetail` this is called on one of the `super_notices` . 4599 | from fetchBootstrap. the way it picks which super notice id to use is 4600 | 4601 | ```c 4602 | void HomeDM$$GetSuperNoticeDetailMasterId(void) 4603 | 4604 | { 4605 | undefined4 uVar1; 4606 | 4607 | if (DAT_03706ed3 == '\0') { 4608 | FUN_00871ed4(0x4de5); 4609 | DAT_03706ed3 = '\x01'; 4610 | } 4611 | uVar1 = NoticeDM$$GetUnreadSuperNoticeIds(0); 4612 | FUN_010f5084(uVar1,Method$Enumerable.LastOrDefault()_int_); 4613 | return; 4614 | } 4615 | ``` 4616 | 4617 | which means it gets the last in the list 4618 | 4619 | `/notice/fetchNotice` empty request, it just gets news and whatnot 4620 | 4621 | another saveUserNaviVoice with id's 100010105,100010038,100010018 4622 | 4623 | `/present/fetch` call, empty request. gets a list of pending presents 4624 | 4625 | saveRuleDescription with id 20 4626 | 4627 | `/present/receive` with a list of id's from `/present/fetch` to claim all 4628 | gifts 4629 | 4630 | yet another fetchBoostrap call with types 2,3,4,5,9,10 4631 | 4632 | ... and that's it, that's pretty much all you need to start a new account, 4633 | log in, complete the tutorial and get your daily rewards. this pretty much 4634 | concludes what I wanted to achieve for the time being, but I will update 4635 | this article whenever I find anything interesting 4636 | 4637 | you can check out my client here and use it as a reference for your own 4638 | https://github.com/Francesco149/todokete 4639 | 4640 | # a look at the assets 4641 | 4642 | I was looking to extract some assets from the game. not sure if this is 4643 | standard unity asset packaging but here's how they're laid out 4644 | 4645 | `data/data/com.klab.lovelive.allstars/files/files` contains a plain text 4646 | file named `fe12c8ba-04b2-41b6-b1af-ffee5ee59c43`. this probably changes 4647 | for each build. it contains a map of assets to sha-1 hashes: 4648 | 4649 | ``` 4650 | 1 4651 | card:100011001 eb73a5908f31c052f9432b37aab01b0614b699ae 4652 | card:100011002 245b5bd66c1918d963348fcbcc14d3273b8ba46e 4653 | card:100012001 6ba14134894fd894c811ce7fde599d76eb8d3d9f 4654 | card:100013001 a30cfa2da9e1b74443d5460f333c09eb33a39198 4655 | card:100021001 af7d4f64e1fc4cc64ace920c0fb297304e3fb8ab 4656 | card:100021002 c7fc1ff28d960c32bab8612ba45d130ba37c783a 4657 | 4658 | .... 4659 | 4660 | 4661 | live:1000101 97fadca11fac62bc200b4ce77a0120ac594c33cd 4662 | live:1000102 51dda48f13b89fab1163b07eec1cf51caa427d66 4663 | live:1000103 614f5b6f9462fdcdcda75dc57fd13b84963e6490 4664 | live:1000201 420a0779aa8dfcb053bb59e680db1067891c4f91 4665 | live:1000202 41c8f8417c090c2c2ad1e523cc19dc8286d67510 4666 | live:1000203 eb8db1cf4dfae2551fbdf10eabd178a4712e313e 4667 | live:1000401 a350bc00e678a4d16bd7dc15e36823047f4b1c45 4668 | 4669 | ... 4670 | 4671 | love:101_10 d3631e84fa5f19443c0db73ff17d65ede5c7a49c 4672 | love:101_20 840d74373ff6f226c00f0f4661719167147d9d34 4673 | love:101_30 e4004a4444c703c5691e9bb286790c1f170a2929 4674 | love:101_40 7bffbefd74c7e5da7b41282f85cdeba227e3bebc 4675 | love:101_50 fe37eb04c5d58cd83a09d819ad7953b26d6fcff0 4676 | love:102_10 2836059242bf615daf4a36dd64eb990e020cb102 4677 | love:102_20 f81e912c293e1967ea62de4a29f27e058c93d04a 4678 | love:102_30 befb476da8ee0be8dd5209af56e91547296ff2c2 4679 | 4680 | ... 4681 | 4682 | main 6c84c160fe1b5b2834c0b07d5685bd21902d757b 4683 | music:10001 7c20bbff2d642da68b7956f8288b0ceddfb559c5 4684 | music:10002 6c942bfbd7818f1d3ff03894396224eb6280f9bb 4685 | music:10004 ad9c64e29a822d51af5a4983abb11f23f9414cfa 4686 | music:10005 1f1fc9cc0b9ee042b7c0594e4c9e760200f7f319 4687 | music:10011 1f282efa1d9e04303a2410efd615f391d311db3a 4688 | music:10014 ab230a60f63d353b54f1a60624f2ef6909233b63 4689 | music:10015 415ba87cc3aa0e1d89e06991893d6a63e135af80 4690 | music:10016 af02ed318a935f64e943586d53039583466f7f85 4691 | 4692 | ... 4693 | 4694 | op f73a0a9fee3ae140878114a3e88702e5efb3fddd 4695 | story-voice:ES/0002/es_0002_01 9c5f38f7b76bf38244094f29f8c085e89de9abef 4696 | story-voice:ES/0002/es_0002_02 96c026d971b20de5f43543389d844ccddb34ca43 4697 | story-voice:ES/0002/es_0002_03 5118b9ac408ed0aad1841d2b6ed62488d61fd03d 4698 | story-voice:ES/0002/es_0002_04 6d93f972fccccbaedaa0515f5cbd0cd7c253f188 4699 | story-voice:ES/0002/es_0002_05 c68d4cb4eea1811f5a8fdf1af1cef47abf41778c 4700 | story-voice:ES/0002/es_0002_06 49de9ef73e44512311b17c65e18b58ceda7bedb6 4701 | story-voice:ES/0002/es_0002_07 34e3841eb8306cc11258fe836e10e14bdd58e98c 4702 | story-voice:MES/0001/mes_0001_01 36804fd9971c80b461cab9c5f8d953d0fe8a2272 4703 | story-voice:MES/0001/mes_0001_02 028781c7b118237070bf7fdc5504f59aedd80fa9 4704 | story-voice:MES/0001/mes_0001_03 e4e6cda8dba865379831922b073a0b2709568cf9 4705 | story-voice:MES/0001/mes_0001_04 893560c1540e1c0355d2794d4d2c557ef62fcf52 4706 | 4707 | ... 4708 | 4709 | suit:100011001 00a503790212b6aa66b0a666c98e45ea02c5ba35 4710 | suit:100012001 1d05237d863d748e68d034a1152e0ca6fc346f90 4711 | suit:100013001 98d5ba50cc24b6005b38f5c13fabeb89ffd6e049 4712 | suit:100021001 65fe1540c0b1d7fe8db6ae9b8b77bffdc5bf4037 4713 | suit:100022001 cc239b4857ecda8aea5a40a73b9acf4cd0cf476a 4714 | suit:100023001 ad3cafaf92fc9d995418fbcc1896039fd7495a98 4715 | suit:100031001 1d829f5a4e93aa166887d181c33ecdd75254a0bd 4716 | suit:100032001 e5aff74749b4bc2f90c45484117bf3b42d9546b4 4717 | suit:100033001 7907ae9e67e50ff3b883993be481b2b3aa7f5b4c 4718 | 4719 | ... 4720 | 4721 | ``` 4722 | 4723 | i see there's a file that starts with masterdata.db, let's search for this 4724 | string in the game's binary 4725 | 4726 | sure enough, it seems to be some kind of custom encrypted database, 4727 | a function named ```Sqlite$$Open``` references that string 4728 | 4729 | ```c 4730 | iVar1 = Sqlite$$HasDefaultDb(); 4731 | if (iVar1 == 1) { 4732 | Cache$$Clear(0); 4733 | Cache$$ClearShortLife(0); 4734 | if (((*(byte *)(Class$DotUnder.MasterData + 0xbf) & 2) != 0) && 4735 | (*(int *)(Class$DotUnder.MasterData + 0x70) == 0)) { 4736 | FUN_0087f998(); 4737 | } 4738 | uVar2 = MasterData$$DbPath("masterdata.db",0); 4739 | uVar3 = MasterData$$GetSqliteKey("masterdata.db",0,0); 4740 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 4741 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 4742 | FUN_0087f998(); 4743 | } 4744 | Sqlite$$Add("defaultdb",uVar2,uVar3); 4745 | if (param_1 == 0) { 4746 | FUN_0089d7b8(0); 4747 | } 4748 | ``` 4749 | 4750 | ok so it just seems to be a random 32-bytes key generated and stored 4751 | in the shared prefs xml 4752 | 4753 | ```c 4754 | undefined4 MasterData$$GetSqliteMasterKey(void) 4755 | 4756 | { 4757 | int iVar1; 4758 | undefined4 uVar2; 4759 | undefined4 uVar3; 4760 | 4761 | if (DAT_03704bde == '\0') { 4762 | FUN_00871f3c(0x69b6); 4763 | DAT_03704bde = '\x01'; 4764 | } 4765 | if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) && 4766 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4767 | FUN_0087f998(); 4768 | } 4769 | iVar1 = PlayerPrefs$$HasKey("SQ",0); 4770 | if (iVar1 == 1) { 4771 | if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) && 4772 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4773 | FUN_0087f998(); 4774 | } 4775 | uVar2 = PlayerPrefs$$GetString("SQ",StringLiteral_73,0); 4776 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 4777 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 4778 | FUN_0087f998(); 4779 | } 4780 | uVar2 = Convert$$FromBase64String(uVar2,0); 4781 | return uVar2; 4782 | } 4783 | if (((*(byte *)(Class$DotUnder.DMCryptography + 0xbf) & 2) != 0) && 4784 | (*(int *)(Class$DotUnder.DMCryptography + 0x70) == 0)) { 4785 | FUN_0087f998(); 4786 | } 4787 | uVar2 = DMCryptography$$RandomBytes(0x20,0); 4788 | if (((*(byte *)(Class$System.Convert + 0xbf) & 2) != 0) && 4789 | (*(int *)(Class$System.Convert + 0x70) == 0)) { 4790 | FUN_0087f998(); 4791 | } 4792 | uVar3 = Convert$$ToBase64String(uVar2,0); 4793 | if (((*(byte *)(Class$DotUnder.LocalStorage + 0xbf) & 2) != 0) && 4794 | (*(int *)(Class$DotUnder.LocalStorage + 0x70) == 0)) { 4795 | FUN_0087f998(); 4796 | } 4797 | LocalStorage$$SetString("SQ",uVar3); 4798 | return uVar2; 4799 | } 4800 | ``` 4801 | 4802 | it later calls "GetRawSqliteKey" and passes it "masterdata.db" and the 4803 | master key bytes 4804 | 4805 | this appears to do a hmac-sha1 on the masterdata.db string using the 4806 | master key as the key 4807 | 4808 | ```c 4809 | masterdataDbUtf8 = 4810 | (**(code **)(*piVar2 + 0x148))(piVar2,masterdataDb,*(undefined4 *)(*piVar2 + 0x14c)); 4811 | if (((*(byte *)(Class$DotUnder.DMCryptography + 0xbf) & 2) != 0) && 4812 | (*(int *)(Class$DotUnder.DMCryptography + 0x70) == 0)) { 4813 | FUN_0087f998(); 4814 | } 4815 | local_28 = 0; 4816 | sha1 = DMCryptography$$HmacSha1(masterdataDbUtf8,masterKeyBytes,0); 4817 | sha1bytes = sha1 + 0x10; 4818 | ``` 4819 | 4820 | it then allocates a 12-byte array made up of 3 uint's and copies the first 4821 | 12 bytes of the hmac-sha1 hash to it, encoded as uint's 4822 | 4823 | ```c 4824 | uintArray = InstantiateArray(Class$uint[],3); 4825 | i = 0; 4826 | do { 4827 | j = 0; 4828 | ithUint = (uint *)(uintArray + i * 4 + 0x10); 4829 | do { 4830 | if (uintArray == 0) { 4831 | NullPointerExceptionMaybe(0); 4832 | } 4833 | if (*(uint *)(uintArray + 0xc) <= i) { 4834 | masterdataDbUtf8 = IndexOutOfRangeException(); 4835 | Throw(masterdataDbUtf8,0,0); 4836 | } 4837 | shiftedUint = *ithUint << 8; 4838 | *ithUint = shiftedUint; 4839 | if (*(uint *)(uintArray + 0xc) <= i) { 4840 | masterdataDbUtf8 = IndexOutOfRangeException(); 4841 | Throw(masterdataDbUtf8,0,0); 4842 | shiftedUint = *ithUint; 4843 | } 4844 | if (sha1 == 0) { 4845 | NullPointerExceptionMaybe(0); 4846 | } 4847 | if (*(uint *)(sha1 + 0xc) <= (uint)(k + j)) { 4848 | masterdataDbUtf8 = IndexOutOfRangeException(); 4849 | Throw(masterdataDbUtf8,0,0); 4850 | } 4851 | orMask = (byte *)(sha1bytes + j); 4852 | j = j + 1; 4853 | *ithUint = shiftedUint | *orMask; 4854 | } while (j != 4); 4855 | i = i + 1; 4856 | sha1bytes = sha1bytes + 4; 4857 | k = k + 4; 4858 | } while (i != 3); 4859 | return uintArray; 4860 | ``` 4861 | 4862 | later, in GetSqliteKey, it formats these 3 uint's as a string, separated 4863 | by dots 4864 | 4865 | ```c 4866 | uintsKey = MasterData$$GetRawSqliteKey(masterdataDb,masterKeyBytes); 4867 | if (uintsKey == 0) { 4868 | NullPointerExceptionMaybe(0); 4869 | } 4870 | if (*(int *)(uintsKey + 0xc) == 0) { 4871 | masterKeyBytes = IndexOutOfRangeException(); 4872 | Throw(masterKeyBytes,0,0); 4873 | } 4874 | uintsKeyData = *(undefined4 *)(uintsKey + 0x10); 4875 | masterKeyBytes = FUN_008ae39c(Class$uint,&uintsKeyData); 4876 | if (*(uint *)(uintsKey + 0xc) < 2) { 4877 | secondUintClass = IndexOutOfRangeException(); 4878 | Throw(secondUintClass,0,0); 4879 | } 4880 | secondUint = *(undefined4 *)(uintsKey + 0x14); 4881 | secondUintClass = FUN_008ae39c(Class$uint,&secondUint); 4882 | if (*(uint *)(uintsKey + 0xc) < 3) { 4883 | thirdUintClass = IndexOutOfRangeException(); 4884 | Throw(thirdUintClass,0,0); 4885 | } 4886 | thirdUint = *(undefined4 *)(uintsKey + 0x18); 4887 | thirdUintClass = FUN_008ae39c(Class$uint,&thirdUint); 4888 | String$$Format("{0}.{1}.{2}",masterKeyBytes,secondUintClass,thirdUintClass,0); 4889 | ``` 4890 | 4891 | `Sqlite$$Add` calls OpenDB with the key string and the path of the db file 4892 | paired in a single string, separated by a dash 4893 | 4894 | ```c 4895 | void Sqlite$$Add(undefined4 param_1,undefined4 masterDataPath,undefined4 keyUintsString) 4896 | 4897 | { 4898 | undefined4 uVar1; 4899 | int iVar2; 4900 | 4901 | if (DAT_0370745e == '\0') { 4902 | FUN_00871f3c(0x9154); 4903 | DAT_0370745e = '\x01'; 4904 | } 4905 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 4906 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 4907 | FUN_0087f998(); 4908 | } 4909 | Sqlite$$Remove(param_1); 4910 | uVar1 = String$$Format("{0}-{1}",keyUintsString,masterDataPath,0); 4911 | uVar1 = Sqlite$$OpenDB(uVar1,"klb_vfs"); 4912 | iVar2 = **(int **)(Class$DotUnder.Sqlite + 0x5c); 4913 | if (iVar2 == 0) { 4914 | NullPointerExceptionMaybe(0); 4915 | } 4916 | FUN_022e73e8(iVar2,param_1,uVar1,Method$Dictionary_string_-IntPtr_.Add()); 4917 | iVar2 = String$$op_Equality(param_1,"defaultdb",0); 4918 | if (iVar2 != 1) { 4919 | return; 4920 | } 4921 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 4922 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 4923 | FUN_0087f998(); 4924 | } 4925 | *(undefined4 *)(*(int *)(Class$DotUnder.Sqlite + 0x5c) + 4) = uVar1; 4926 | return; 4927 | } 4928 | ``` 4929 | 4930 | it seems that `klb_vfs` is a vfs module for sqlite3: 4931 | 4932 | ```c 4933 | if ((klbVfsString != 0) && 4934 | (iVar1 = *(int *)(Class$DotUnder.Sqlite + 0x5c), *(char *)(iVar1 + 8) == '\0')) { 4935 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 4936 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 4937 | FUN_0087f998(); 4938 | iVar1 = *(int *)(Class$DotUnder.Sqlite + 0x5c); 4939 | } 4940 | *(undefined *)(iVar1 + 8) = 1; 4941 | KLBVFS$$klbvfs_register(0); 4942 | } 4943 | local_1c = 0; 4944 | keyPathPairUtf8 = StringExtensionMethods$$ToUtf8(keyPathPair,1); 4945 | klbVfsStringUtf8 = StringExtensionMethods$$ToUtf8(klbVfsString,1); 4946 | if (((*(byte *)(Class$Forfeit.Sqlite3 + 0xbf) & 2) != 0) && 4947 | (*(int *)(Class$Forfeit.Sqlite3 + 0x70) == 0)) { 4948 | FUN_0087f998(); 4949 | } 4950 | local_20 = Sqlite3$$sqlite3_open_v2(keyPathPairUtf8,&local_1c,1,klbVfsStringUtf8,0); 4951 | ``` 4952 | 4953 | let's take a look at libklbvfs.so . it calls `sqlite3_vfs_find` with NULL 4954 | in `klbvfs_register` and assigns it to some global. then it calls 4955 | `sqlite3_vfs_register` on some other global. these globals actually overlap 4956 | and it's just one big `sqlite3_vfs` struct 4957 | 4958 | sqlite doc: 4959 | 4960 | ``` 4961 | The sqlite3_vfs_find() interface returns a pointer to a VFS given its name. 4962 | Names are case sensitive. Names are zero-terminated UTF-8 strings. 4963 | If there is no match, a NULL pointer is returned. 4964 | ``` 4965 | 4966 | what happens if we pass null though? 4967 | 4968 | ``` 4969 | If zVfsName is NULL then the default VFS is returned. 4970 | ``` 4971 | 4972 | ok so it's just the default sqlite vfs 4973 | 4974 | ok so this is the struct it's passing to vfs register: 4975 | https://www.sqlite.org/c3ref/vfs.html 4976 | 4977 | we can map it out and see which function is which, and here's our register 4978 | function documented 4979 | 4980 | I also mapped the `sqlite3_file` and `sqlite3_io_methods` structs which 4981 | are passed to these methods and documented here 4982 | 4983 | https://www.sqlite.org/c3ref/file.html 4984 | 4985 | https://www.sqlite.org/c3ref/io_methods.html 4986 | 4987 | 4988 | ```c 4989 | void klbvfs_register(void) 4990 | 4991 | { 4992 | sqlite3_vfs_00012004.pAppData = (sqlite3_vfs *)sqlite3_vfs_find(0); 4993 | DAT_00012080 = (sqlite3_vfs_00012004.pAppData)->szOsFile; 4994 | sqlite3_vfs_00012004.szOsFile = DAT_00012080 + 0x10; 4995 | sqlite3_vfs_register(&sqlite3_vfs_00012004,0); 4996 | return; 4997 | } 4998 | ``` 4999 | 5000 | ok, so it's most likely a thin layer over the default vfs 5001 | 5002 | what's this szOsFile stuff though? 5003 | 5004 | ``` 5005 | The szOsFile field is the size of the subclassed sqlite3_file structure 5006 | used by this VFS. 5007 | ``` 5008 | 5009 | ok, so it's the default szOsFile + 16 for the klab vfs, let's rename it. 5010 | this also means that the `sqlite3_file` struct has 16 extra bytes at the 5011 | end which I will add as unknown fields 5012 | 5013 | 5014 | ```c 5015 | void klbvfs_register(void) 5016 | 5017 | { 5018 | sqlite3_vfs_00012004.pAppData = (sqlite3_vfs *)sqlite3_vfs_find(0); 5019 | g_defaultSzOsFile = (sqlite3_vfs_00012004.pAppData)->szOsFile; 5020 | sqlite3_vfs_00012004.szOsFile = g_defaultSzOsFile + 0x10; 5021 | sqlite3_vfs_register(&sqlite3_vfs_00012004,0); 5022 | return; 5023 | } 5024 | ``` 5025 | 5026 | now if we take a look at the struct memory region we can map out all the 5027 | unnamed functions it points to 5028 | 5029 | ```c 5030 | sqlite3_vfs_00012004.szOsFile XREF[1,2]: klbvfs_register:00010620(*), 5031 | sqlite3_vfs_00012004.pAppData klbvfs_register:0001060c(W), 5032 | sqlite3_vfs_00012004 klbvfs_register:0001061c(W) 5033 | 00012004 02 00 00 sqlite3_ 5034 | 00 00 00 5035 | 00 00 00 5036 | 00012004 02 00 00 00 int 2h iVersion XREF[1]: klbvfs_register:00010620(*) 5037 | 00012008 00 00 00 00 int 0h szOsFile XREF[1]: klbvfs_register:0001061c(W) 5038 | 0001200c 00 04 00 00 int 400h mxPathname 5039 | 00012010 00 00 00 00 sqlite3_ 00000000 pNext 5040 | 00012014 c4 0b 01 00 char * s_klb_vfs_00010bc4 zName = "klb_vfs" 5041 | 00012018 00 00 00 00 sqlite3_ 00000000 pAppData XREF[1]: klbvfs_register:0001060c(W) 5042 | 0001201c 38 06 01 00 void * FUN_00010638 xOpen 5043 | 00012020 20 07 01 00 void * LAB_00010720 xDelete 5044 | 00012024 2c 07 01 00 void * FUN_0001072c xAccess 5045 | 00012028 7c 07 01 00 void * FUN_0001077c xFullPathname 5046 | 0001202c e8 07 01 00 void * LAB_000107e8 xDlOpen 5047 | 00012030 f4 07 01 00 void * LAB_000107f4 xDlError 5048 | 00012034 00 08 01 00 void * LAB_00010800 xDlSym 5049 | 00012038 0c 08 01 00 void * LAB_0001080c xDlClose 5050 | 0001203c 18 08 01 00 void * LAB_00010818 xRandomness 5051 | 00012040 24 08 01 00 void * LAB_00010824 xSleep 5052 | 00012044 30 08 01 00 void * LAB_00010830 xCurrentTime 5053 | 00012048 3c 08 01 00 void * LAB_0001083c xGetLastError 5054 | 0001204c 48 08 01 00 void * LAB_00010848 xCurrentTime 5055 | 00012050 00 00 00 00 void * 00000000 xNextSystemC 5056 | 5057 | ``` 5058 | 5059 | let's take a look at xOpen 5060 | 5061 | ```c 5062 | int xOpen(sqlite3_vfs *vfs,char *zName,sqlite3_file *file,int flags,int *pOutFlags) 5063 | 5064 | { 5065 | sqlite3_io_methods **ppsVar1; 5066 | int i; 5067 | sqlite3_io_methods *local_28 [4]; 5068 | char firstChar; 5069 | 5070 | zName = zName + 1; 5071 | i = 0; 5072 | local_28[1] = (sqlite3_io_methods *)0x0; 5073 | local_28[0] = (sqlite3_io_methods *)0x0; 5074 | local_28[2] = (sqlite3_io_methods *)0x0; 5075 | do { 5076 | firstChar = zName[-1]; 5077 | if (firstChar == '.') { 5078 | if (1 < i) { 5079 | return 1; 5080 | } 5081 | i = i + 1; 5082 | } 5083 | else { 5084 | if (9 < ((uint)(byte)firstChar - 0x30 & 0xff)) { 5085 | if (i != 2 || firstChar != ' ') { 5086 | return 1; 5087 | } 5088 | i = (*(code *)vfs->pAppData->xOpen)(vfs->pAppData,zName,file,flags,pOutFlags); 5089 | ppsVar1 = (sqlite3_io_methods **)((int)&file->pMethods + g_defaultSzOsFile); 5090 | *ppsVar1 = file->pMethods; 5091 | ppsVar1[1] = local_28[0]; 5092 | ppsVar1[2] = local_28[1]; 5093 | ppsVar1[3] = local_28[2]; 5094 | file->pMethods = (sqlite3_io_methods *)0x11ecc; 5095 | return i; 5096 | } 5097 | local_28[i] = (sqlite3_io_methods *)((uint)(byte)firstChar + (int)local_28[i] * 10 + -0x30); 5098 | } 5099 | zName = zName + 1; 5100 | } while( true ); 5101 | } 5102 | ``` 5103 | 5104 | uh oh seems like the extra bytes at the end of the file structs are 5105 | confusing the decompiler, let's try to define them as a separate 16 byte 5106 | struct and retype ppsVar1 to it 5107 | 5108 | ```c 5109 | int xOpen(sqlite3_vfs *vfs,char *zName,sqlite3_file *file,int flags,int *pOutFlags) 5110 | 5111 | { 5112 | klbvfs_file *klbfile; 5113 | int i; 5114 | klbvfs_file newKlbfile; 5115 | char firstChar; 5116 | 5117 | zName = zName + 1; 5118 | i = 0; 5119 | newKlbfile.unk1 = 0; 5120 | newKlbfile.pMethods = (sqlite3_io_methods *)0x0; 5121 | newKlbfile.unk2 = 0; 5122 | do { 5123 | firstChar = zName[-1]; 5124 | if (firstChar == '.') { 5125 | if (1 < i) { 5126 | return 1; 5127 | } 5128 | i = i + 1; 5129 | } 5130 | else { 5131 | if (9 < ((uint)(byte)firstChar - 0x30 & 0xff)) { 5132 | if (i != 2 || firstChar != ' ') { 5133 | return 1; 5134 | } 5135 | i = (*(code *)vfs->pAppData->xOpen)(vfs->pAppData,zName,file,flags,pOutFlags); 5136 | klbfile = (klbvfs_file *)((int)&file->pMethods + g_defaultSzOsFile); 5137 | klbfile->pMethods = file->pMethods; 5138 | *(sqlite3_io_methods **)&klbfile->unk1 = newKlbfile.pMethods; 5139 | klbfile->unk2 = newKlbfile.unk1; 5140 | klbfile->unk3 = newKlbfile.unk2; 5141 | file->pMethods = (sqlite3_io_methods *)0x11ecc; 5142 | return i; 5143 | } 5144 | (&newKlbfile.pMethods)[i] = 5145 | (sqlite3_io_methods *) 5146 | ((uint)(byte)firstChar + (int)(&newKlbfile.pMethods)[i] * 10 + -0x30); 5147 | } 5148 | zName = zName + 1; 5149 | } while( true ); 5150 | } 5151 | ``` 5152 | 5153 | well that's still terrible but you can tell it's saving the default 5154 | pMethods pointer in the first field and zeroing the other 3 fields 5155 | 5156 | then it replaces pMethods with a different table of functions, which 5157 | we can retype to `sqlite3_io_methods` and check out to map the methods 5158 | 5159 | ```c 5160 | // 5161 | // .data.rel.ro 5162 | // SHT_PROGBITS [0x1ecc - 0x1f17] 5163 | // ram: 00011ecc-00011f17 5164 | // 5165 | sqlite3_io_methods_00011ecc XREF[2]: xOpen:00010700(*), 5166 | _elfSectionHeaders::00000264(*) 5167 | 00011ecc 03 00 00 sqlite3_ 5168 | 00 54 08 5169 | 01 00 6c 5170 | 00011ecc 03 00 00 00 int 3h iVersion XREF[2]: xOpen:00010700(*), 5171 | _elfSectionHeaders::00000264(*) 5172 | 00011ed0 54 08 01 00 void * LAB_00010854 xClose 5173 | 00011ed4 6c 08 01 00 void * LAB_0001086c xRead 5174 | 00011ed8 3c 0a 01 00 void * LAB_00010a3c xWrite 5175 | 00011edc 54 0a 01 00 void * LAB_00010a54 xTruncate 5176 | 00011ee0 6c 0a 01 00 void * LAB_00010a6c xSync 5177 | 00011ee4 84 0a 01 00 void * LAB_00010a84 xFileSize 5178 | 00011ee8 9c 0a 01 00 void * LAB_00010a9c xLock 5179 | 00011eec b4 0a 01 00 void * LAB_00010ab4 xUnlock 5180 | 00011ef0 cc 0a 01 00 void * LAB_00010acc xCheckReserv 5181 | 00011ef4 e4 0a 01 00 void * LAB_00010ae4 xFileControl 5182 | 00011ef8 fc 0a 01 00 void * LAB_00010afc xSectorSize 5183 | 00011efc 14 0b 01 00 void * LAB_00010b14 xDeviceChara 5184 | 00011f00 2c 0b 01 00 void * LAB_00010b2c xShmMap 5185 | 00011f04 44 0b 01 00 void * LAB_00010b44 xShmLock 5186 | 00011f08 5c 0b 01 00 void * LAB_00010b5c xShmBarrier 5187 | 00011f0c 74 0b 01 00 void * LAB_00010b74 xShmUnmap 5188 | 00011f10 8c 0b 01 00 void * LAB_00010b8c xFetch 5189 | 00011f14 a4 0b 01 00 void * LAB_00010ba4 xUnfetch 5190 | 5191 | ``` 5192 | 5193 | it also does something weird with pMethods at the end, maybe the first char 5194 | is an index of some sort in that case 5195 | 5196 | anyway, if we check out the replaced file methods we see that most of them 5197 | just pass through the call to the original methods 5198 | 5199 | ```c 5200 | void file_xClose(sqlite3_file *param_1) 5201 | 5202 | { 5203 | /* WARNING: Could not recover jumptable at 0x00010864. Too many branches */ 5204 | /* WARNING: Treating indirect jump as call */ 5205 | (**(code **)(*(int *)((int)¶m_1->pMethods + g_defaultSzOsFile) + 4))(); 5206 | return; 5207 | } 5208 | ``` 5209 | 5210 | in xRead we can see that it's doing funky stuff with constants used in 5211 | random number generators. this is gonna take a while to decipher 5212 | 5213 | ```c 5214 | int file_xRead(sqlite3_file *file,byte *dst,int iAmt,int64_t iOfst) 5215 | 5216 | { 5217 | uint uVar1; 5218 | uint uVar2; 5219 | ulonglong uVar3; 5220 | int iVar4; 5221 | int iVar5; 5222 | int iVar6; 5223 | int iVar7; 5224 | sqlite3_io_methods *defaultMethods; 5225 | void *defaultxRead; 5226 | int iVar8; 5227 | int iVar9; 5228 | void *defaultxWrite; 5229 | void *defaultxClose; 5230 | 5231 | iVar4 = (**(code **)(*(int *)((int)&file->pMethods + g_defaultSzOsFile) + 8))(file); 5232 | if (iVar4 == 0) { 5233 | iVar8 = 0x343fd; 5234 | iVar5 = 0x269ec3; 5235 | defaultMethods = (sqlite3_io_methods *)((int)&file->pMethods + g_defaultSzOsFile); 5236 | defaultxClose = defaultMethods->xClose; 5237 | if ((int)(iOfst._4_4_ - (uint)((int)iOfst == 0)) < 0) { 5238 | iVar8 = 0; 5239 | defaultxRead = defaultMethods->xRead; 5240 | defaultxWrite = defaultMethods->xWrite; 5241 | iVar5 = 1; 5242 | } 5243 | else { 5244 | iVar7 = 1; 5245 | iVar9 = 0; 5246 | uVar3 = iOfst; 5247 | do { 5248 | if ((uVar3 & 1) != 0) { 5249 | iVar9 = iVar7 * iVar5 + iVar9; 5250 | iVar7 = iVar7 * iVar8; 5251 | } 5252 | uVar1 = (uint)(uVar3 >> 0x21); 5253 | uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1; 5254 | uVar3 = CONCAT44(uVar1,uVar2); 5255 | iVar5 = iVar8 * iVar5 + iVar5; 5256 | iVar8 = iVar8 * iVar8; 5257 | } while ((uVar2 | uVar1) != 0); 5258 | iVar5 = 1; 5259 | iVar6 = 0x269ec3; 5260 | defaultxClose = (void *)(iVar7 * (int)defaultxClose + iVar9); 5261 | iVar8 = 0; 5262 | iVar9 = 0x343fd; 5263 | uVar3 = iOfst; 5264 | do { 5265 | if ((uVar3 & 1) != 0) { 5266 | iVar8 = iVar5 * iVar6 + iVar8; 5267 | iVar5 = iVar5 * iVar9; 5268 | } 5269 | uVar1 = (uint)(uVar3 >> 0x21); 5270 | uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1; 5271 | uVar3 = CONCAT44(uVar1,uVar2); 5272 | iVar6 = iVar9 * iVar6 + iVar6; 5273 | iVar9 = iVar9 * iVar9; 5274 | } while ((uVar2 | uVar1) != 0); 5275 | defaultxRead = (void *)(iVar5 * (int)defaultMethods->xRead + iVar8); 5276 | defaultxWrite = defaultMethods->xWrite; 5277 | iVar5 = 1; 5278 | iVar8 = 0; 5279 | iVar9 = 0x269ec3; 5280 | iVar7 = 0x343fd; 5281 | do { 5282 | if ((iOfst & 1U) != 0) { 5283 | iVar8 = iVar5 * iVar9 + iVar8; 5284 | iVar5 = iVar5 * iVar7; 5285 | } 5286 | uVar1 = (uint)((ulonglong)iOfst >> 0x21); 5287 | uVar2 = (uint)((byte)((ulonglong)iOfst >> 0x20) & 1) << 0x1f | (uint)iOfst >> 1; 5288 | iOfst = CONCAT44(uVar1,uVar2); 5289 | iVar9 = iVar7 * iVar9 + iVar9; 5290 | iVar7 = iVar7 * iVar7; 5291 | } while ((uVar2 | uVar1) != 0); 5292 | } 5293 | if (0 < iAmt) { 5294 | iVar8 = iVar5 * (int)defaultxWrite + iVar8; 5295 | do { 5296 | uVar1 = (uint)defaultxClose >> 0x18; 5297 | defaultxClose = (void *)((int)defaultxClose * 0x343fd + 0x269ec3); 5298 | iAmt = iAmt + -1; 5299 | *dst = *dst ^ (byte)((uint)defaultxRead >> 0x18) ^ (byte)uVar1 ^ (byte)((uint)iVar8 >> 0x18) 5300 | ; 5301 | iVar8 = iVar8 * 0x343fd + 0x269ec3; 5302 | defaultxRead = (void *)((int)defaultxRead * 0x343fd + 0x269ec3); 5303 | dst = dst + 1; 5304 | } while (iAmt != 0); 5305 | } 5306 | } 5307 | return iVar4; 5308 | } 5309 | ``` 5310 | 5311 | this is probably the bulk of what we need to decipher, but let's take a 5312 | look at the other `sqlite3_vfs` methods 5313 | 5314 | xDelete is a passthrough 5315 | 5316 | ```c 5317 | void xDelete(sqlite3_vfs *param_1) 5318 | 5319 | { 5320 | /* WARNING: Could not recover jumptable at 0x00010728. Too many branches */ 5321 | /* WARNING: Treating indirect jump as call */ 5322 | (*(code *)param_1->pAppData->xDelete)(); 5323 | return; 5324 | } 5325 | ``` 5326 | 5327 | xAccess splits zName on space and only passes the text after the space 5328 | through 5329 | 5330 | ```c 5331 | undefined4 xAccess(sqlite3_vfs *vfs,char *zName,int flags,int *pResOut) 5332 | 5333 | { 5334 | char *pSpace; 5335 | undefined4 res; 5336 | 5337 | pSpace = strchr(zName,0x20); 5338 | if (pSpace != (char *)0x0) { 5339 | /* WARNING: Could not recover jumptable at 0x00010770. Too many branches */ 5340 | /* WARNING: Treating indirect jump as call */ 5341 | res = (*(code *)vfs->pAppData->xAccess)(vfs->pAppData,pSpace + 1,flags,pResOut); 5342 | return res; 5343 | } 5344 | return 1; 5345 | } 5346 | ``` 5347 | 5348 | xFullPathname splits zName on the space. the first part (including the 5349 | space) is copied to zOut, while the rest is passed through as the zName 5350 | parameter. nOut and zOut are adjusted so the result is written after 5351 | the space 5352 | 5353 | ```c 5354 | 5355 | undefined4 xFullPathname(sqlite3_vfs *param_1,char *zName,int nOut,char *zOut) 5356 | 5357 | { 5358 | char *pSpace; 5359 | undefined4 uVar1; 5360 | char *sizeUntilSpace; 5361 | 5362 | pSpace = strchr(zName,0x20); 5363 | if (pSpace != (char *)0x0) { 5364 | sizeUntilSpace = pSpace + (1 - (int)zName); 5365 | __aeabi_memcpy(zOut,zName,sizeUntilSpace); 5366 | /* WARNING: Could not recover jumptable at 0x000107dc. Too many branches */ 5367 | /* WARNING: Treating indirect jump as call */ 5368 | uVar1 = (*(code *)param_1->pAppData->xFullPathname) 5369 | (param_1->pAppData,pSpace + 1,nOut - (int)sizeUntilSpace, 5370 | zOut + (int)sizeUntilSpace); 5371 | return uVar1; 5372 | } 5373 | return 1; 5374 | } 5375 | ``` 5376 | 5377 | xDlOpen is a passthrough. same goes for xDlError, xDlSym, xDlClose, 5378 | xRandomness, xSleep, xCurrentTime, xGetLastError, xCurrentTime 5379 | 5380 | ```c 5381 | void xDlOpen(sqlite3_vfs *vfs) 5382 | 5383 | { 5384 | /* WARNING: Could not recover jumptable at 0x000107f0. Too many branches */ 5385 | /* WARNING: Treating indirect jump as call */ 5386 | (*(code *)vfs->pAppData->xDlOpen)(); 5387 | return; 5388 | } 5389 | ``` 5390 | 5391 | let's also look at the other `sqlite3_file` methods 5392 | 5393 | xClose, xWrite, xTruncate, xSync, xFileSize, xLock, xUnlock, 5394 | xCheckReservedLock, xFileControl, xSectorSize, xDeviceCharacteristics, 5395 | xShmMap, xShmLock, xShmBarrier, xShmUnmap, xFetch, xUnfetch, are all 5396 | passthrough so yeah, all we need to do is figure out how xRead works 5397 | 5398 | so looking back at xOpen, the loop scans zName. what I named firstChar 5399 | is actually currentChar. if it finds a dot at the beginning of the string 5400 | it returns an error, otherwise it keeps scanning. when it finds a dot, 5401 | it increases i 5402 | 5403 | the other branch of the if is checking if characters are digit by 5404 | subtracting 0x30 ('0') and checking if they're in range 0-9 . essentially 5405 | it's expecting only digits at the beginning and then waiting for a space, 5406 | if this is not respected it returns an error, otherwise it calls 5407 | xOpen with the text after the space instead of zName. then it initializes 5408 | the extra part of the sqlite3_file struct with pMethods and three ints. 5409 | these three ints are parsed from the first part of the string during the 5410 | loops prior to finding a space 5411 | 5412 | so essentially, it's just parsing the string with the three key uints 5413 | from before and saving them into the sqlite3_file struct 5414 | 5415 | ok I made a mistake labeling defaultMethods in xRead, the pointers confused 5416 | me. it's actually indexing into the extra sqlite3 file struct. that makes 5417 | more sense 5418 | 5419 | ```c 5420 | int file_xRead(sqlite3_file *file,byte *dst,int iAmt,int64_t iOfst) 5421 | 5422 | { 5423 | uint uVar1; 5424 | uint uVar2; 5425 | ulonglong uVar3; 5426 | int xReadResult; 5427 | int rand; 5428 | int iVar4; 5429 | int key3; 5430 | klbvfs_file *klbfile; 5431 | int key2; 5432 | int rand_seed; 5433 | int iVar5; 5434 | int key1; 5435 | 5436 | xReadResult = (**(code **)(*(int *)((int)&file->pMethods + g_defaultSzOsFile) + 8)) 5437 | (file,dst,iAmt,iOfst); 5438 | if (xReadResult == 0) { 5439 | key2 = 0x343fd; 5440 | rand = 0x269ec3; 5441 | klbfile = (klbvfs_file *)((int)&file->pMethods + g_defaultSzOsFile); 5442 | key1 = klbfile->key1; 5443 | /* checks if iOfst is zero? */ 5444 | if ((int)(iOfst._4_4_ - (uint)((int)iOfst == 0)) < 0) { 5445 | rand = 0; 5446 | key2 = klbfile->key2; 5447 | key3 = klbfile->key3; 5448 | rand_seed = 1; 5449 | } 5450 | else { 5451 | key3 = 1; 5452 | rand_seed = 0; 5453 | uVar3 = iOfst; 5454 | do { 5455 | if ((uVar3 & 1) != 0) { 5456 | rand_seed = key3 * rand + rand_seed; 5457 | key3 = key3 * key2; 5458 | } 5459 | uVar1 = (uint)(uVar3 >> 0x21); 5460 | uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1; 5461 | uVar3 = CONCAT44(uVar1,uVar2); 5462 | rand = key2 * rand + rand; 5463 | key2 = key2 * key2; 5464 | } while ((uVar2 | uVar1) != 0); 5465 | rand = 1; 5466 | iVar4 = 0x269ec3; 5467 | key1 = key3 * key1 + rand_seed; 5468 | key2 = 0; 5469 | rand_seed = 0x343fd; 5470 | uVar3 = iOfst; 5471 | do { 5472 | if ((uVar3 & 1) != 0) { 5473 | key2 = rand * iVar4 + key2; 5474 | rand = rand * rand_seed; 5475 | } 5476 | uVar1 = (uint)(uVar3 >> 0x21); 5477 | uVar2 = (uint)((byte)(uVar3 >> 0x20) & 1) << 0x1f | (uint)uVar3 >> 1; 5478 | uVar3 = CONCAT44(uVar1,uVar2); 5479 | iVar4 = rand_seed * iVar4 + iVar4; 5480 | rand_seed = rand_seed * rand_seed; 5481 | } while ((uVar2 | uVar1) != 0); 5482 | key2 = rand * klbfile->key2 + key2; 5483 | key3 = klbfile->key3; 5484 | rand_seed = 1; 5485 | rand = 0; 5486 | iVar4 = 0x269ec3; 5487 | iVar5 = 0x343fd; 5488 | do { 5489 | if ((iOfst & 1U) != 0) { 5490 | rand = rand_seed * iVar4 + rand; 5491 | rand_seed = rand_seed * iVar5; 5492 | } 5493 | uVar1 = (uint)((ulonglong)iOfst >> 0x21); 5494 | uVar2 = (uint)((byte)((ulonglong)iOfst >> 0x20) & 1) << 0x1f | (uint)iOfst >> 1; 5495 | iOfst = CONCAT44(uVar1,uVar2); 5496 | iVar4 = iVar5 * iVar4 + iVar4; 5497 | iVar5 = iVar5 * iVar5; 5498 | } while ((uVar2 | uVar1) != 0); 5499 | } 5500 | if (0 < iAmt) { 5501 | rand = rand_seed * key3 + rand; 5502 | do { 5503 | uVar1 = (uint)key1 >> 0x18; 5504 | key1 = key1 * 0x343fd + 0x269ec3; 5505 | iAmt = iAmt + -1; 5506 | *dst = *dst ^ (byte)((uint)key2 >> 0x18) ^ (byte)uVar1 ^ (byte)((uint)rand >> 0x18); 5507 | rand = rand * 0x343fd + 0x269ec3; 5508 | key2 = key2 * 0x343fd + 0x269ec3; 5509 | dst = dst + 1; 5510 | } while (iAmt != 0); 5511 | } 5512 | } 5513 | return xReadResult; 5514 | } 5515 | ``` 5516 | 5517 | ok so as i thought it seems to be doing stuff with known pseudorandom 5518 | algorithm, using the 3 uints as a key 5519 | 5520 | the part where it does weird shifting with iOfst, it seems to be shifting 5521 | it right by 1 bit as a 64-bit integer and it does this until the offset 5522 | becomes zero. that means that it's doing log base 2 (off) + 1 iterations 5523 | 5524 | the loop at the end is where it decrypts data, i renamed a few things 5525 | 5526 | ```c 5527 | if (0 < iAmt) { 5528 | random1 = rand_seed * random_multiplier + random1; 5529 | do { 5530 | highbits = (uint)key1 >> 0x18; 5531 | key1 = key1 * 0x343fd + 0x269ec3; 5532 | iAmt = iAmt + -1; 5533 | *dst = *dst ^ (byte)((uint)random2 >> 0x18) ^ (byte)highbits ^ (byte)((uint)random1 >> 0x18) 5534 | ; 5535 | random1 = random1 * 0x343fd + 0x269ec3; 5536 | random2 = random2 * 0x343fd + 0x269ec3; 5537 | dst = dst + 1; 5538 | } while (iAmt != 0); 5539 | } 5540 | ``` 5541 | 5542 | these 3 pseudorandom numbers are seeded based on the offset 5543 | 5544 | if we're at the very beginning of the file (offset zero), it sets 5545 | 5546 | * random1 = 0 5547 | * random2 = key2 5548 | * `random_multiplier` = key3 5549 | * `rand_seed` = 1 5550 | 5551 | which means that random1 is just seeded as key3 5552 | 5553 | otherwise the random seeds are generated by running a complicated 5554 | pseudorandom number generator whose output depends not only on the seed 5555 | but also how many 1 bits there are in the offset. there really isn't 5556 | much of a logic to this madness, it's just a matter of copying what the 5557 | code is doing 5558 | 5559 | while implementing this in python I noticed that when it reads the 3 int's 5560 | off the hmac-sha1 hash it's supposed to be big endian. that took a while 5561 | to troubleshoot 5562 | 5563 | either way, here's an implementation in python that successfully reads 5564 | the masterdata.db . it assumes that you have dumped your /data/data/ 5565 | directory and haven't changed the hierarchy so it can find the shared prefs 5566 | xml 5567 | 5568 | ```python 5569 | #!/bin/env python3 5570 | 5571 | import apsw 5572 | import os.path 5573 | import sys 5574 | from bs4 import BeautifulSoup 5575 | import urllib.parse 5576 | import base64 5577 | import hmac 5578 | import hashlib 5579 | import struct 5580 | 5581 | 5582 | def i8(x): 5583 | return x & 0xFF 5584 | 5585 | 5586 | def i32(x): 5587 | return x & 0xFFFFFFFF 5588 | 5589 | 5590 | def hmac_sha1(key, s): 5591 | hmacsha1 = hmac.new(key, digestmod=hashlib.sha1) 5592 | hmacsha1.update(s) 5593 | return hmacsha1.digest() 5594 | 5595 | 5596 | class KLBVFS(apsw.VFS): 5597 | def __init__(self, vfsname='klb_vfs', basevfs=''): 5598 | self.vfsname = vfsname 5599 | self.basevfs = basevfs 5600 | apsw.VFS.__init__(self, self.vfsname, self.basevfs) 5601 | 5602 | def xOpen(self, name, flags): 5603 | return KLBVFSFile(self.basevfs, name, flags) 5604 | 5605 | def xAccess(self, pathname, flags): 5606 | actual_path = pathname.split(' ', 2)[1] 5607 | return super(KLBVFS, self).xAccess(actual_path, flags) 5608 | 5609 | def xFullPathname(self, name): 5610 | split = name.split(' ', 2) 5611 | fullpath = super(KLBVFS, self).xFullPathname(split[1]) 5612 | return split[0] + ' ' + fullpath 5613 | 5614 | 5615 | class KLBVFSFile(apsw.VFSFile): 5616 | def __init__(self, inheritfromvfsname, filename, flags): 5617 | split = filename.filename().split(' ', 2) 5618 | keysplit = split[0].split('.') 5619 | self.key = [int(x) for x in keysplit] 5620 | apsw.VFSFile.__init__(self, inheritfromvfsname, split[1], flags) 5621 | 5622 | def xRead(self, amount, offset): 5623 | result = super(KLBVFSFile, self).xRead(amount, offset) 5624 | random2 = 0x000343fd 5625 | random1 = 0x00269ec3 5626 | key1 = self.key[0] 5627 | if offset == 0: 5628 | random1 = 0 5629 | random2 = key[1] 5630 | random_multiplier = key[2] 5631 | rand_seed = 1 5632 | else: 5633 | random_multiplier = 1 5634 | rand_seed = 0 5635 | tmpoff = offset 5636 | while tmpoff != 0: 5637 | if (tmpoff & 1) != 0: 5638 | rand_seed = i32(i32(random_multiplier * random1) + rand_seed) 5639 | random_multiplier = i32(random_multiplier * random2) 5640 | tmpoff >>= 1 5641 | random1 = i32(i32(random2 * random1) + random1) 5642 | random2 = i32(random2 * random2) 5643 | random1 = 1 5644 | random3 = 0x00269ec3 5645 | key1 = i32(i32(random_multiplier * key1) + rand_seed) 5646 | random2 = 0 5647 | rand_seed = 0x000343fd 5648 | tmpoff = offset 5649 | while tmpoff != 0: 5650 | if (tmpoff & 1) != 0: 5651 | random2 = i32(i32(random1 * random3) + random2) 5652 | random1 = i32(random1 * rand_seed) 5653 | tmpoff >>= 1 5654 | random3 = i32(i32(rand_seed * random3) + random3) 5655 | rand_seed = i32(rand_seed * rand_seed) 5656 | random2 = i32(i32(random1 * self.key[1]) + random2) 5657 | random_multiplier = self.key[2] 5658 | rand_seed = 1 5659 | random1 = 0 5660 | random3 = 0x00269ec3 5661 | random4 = 0x000343fd 5662 | tmpoff = offset 5663 | while tmpoff != 0: 5664 | if (tmpoff & 1) != 0: 5665 | random1 = i32(i32(rand_seed * random3) + random1) 5666 | rand_seed = i32(rand_seed * random4) 5667 | tmpoff >>= 1 5668 | random3 = i32(i32(random4 * random3) + random3) 5669 | random4 = i32(random4 * random4) 5670 | random1 = i32(i32(rand_seed * random_multiplier) + random1) 5671 | b = bytearray(result) 5672 | for i in range(amount): 5673 | b[i] ^= i8(random2 >> 24) ^ i8(key1 >> 24) ^ i8(random1 >> 24) 5674 | key1 = i32(i32(key1 * 0x000343fd) + 0x00269ec3) 5675 | random1 = i32(i32(random1 * 0x000343fd) + 0x00269ec3) 5676 | random2 = i32(i32(random2 * 0x000343fd) + 0x00269ec3) 5677 | return bytes(b) 5678 | 5679 | 5680 | vfs = KLBVFS() 5681 | f = sys.argv[1] 5682 | abspath = os.path.abspath(f) 5683 | base = os.path.dirname(abspath) 5684 | base = os.path.dirname(base) 5685 | base = os.path.dirname(base) 5686 | prefs = os.path.join(base, 5687 | 'shared_prefs/com.klab.lovelive.allstars.v2.playerprefs.xml') 5688 | 5689 | xml = open(prefs, 'r').read() 5690 | soup = BeautifulSoup(xml, 'lxml-xml') 5691 | sq = urllib.parse.unquote(soup.find('string', { 'name': 'SQ' }).getText()) 5692 | sq = base64.b64decode(sq) 5693 | basename = os.path.basename(f) 5694 | print("master key: " + sq.hex()) 5695 | print("basename: " + basename) 5696 | sha1 = hmac_sha1(key = sq, s = basename.encode('utf-8')) 5697 | print("hmac-sha1: " + sha1.hex()) 5698 | key = list(struct.unpack('>III', sha1[:12])) 5699 | print("key: " + str(key)) 5700 | 5701 | vpath = ".".join([str(i32(x)) for x in key]) + " " + f 5702 | db = apsw.Connection(vpath, flags=apsw.SQLITE_OPEN_READONLY, vfs='klb_vfs') 5703 | for row in db.cursor().execute('select * from sqlite_master'): 5704 | print(row) 5705 | ``` 5706 | 5707 | result: 5708 | 5709 | ``` 5710 | $ ./klbvfs.py ../sifas-2019-10-31/data/data/com.klab.lovelive.allstars/files/files/masterdata.db_97 5711 | 9192cfdca08da85daac4b06cd7c5e7616f3dad.db 5712 | master key: 9e9f698605a452c0609a95dbd63ede778d2f2a748e2f89cf7a83b1a9979bd650 5713 | basename: masterdata.db_979192cfdca08da85daac4b06cd7c5e7616f3dad.db 5714 | hmac-sha1: 8c0a650bdecd7c699151c519fae4836a15bc5c78 5715 | key: [2349491467, 3738008681, 2438055193] 5716 | ('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 5717 | 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 5718 | 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 5719 | _id) REFERENCES m_member(id)\n)') 5720 | ('table', 'm_accessory_background_asset', 'm_accessory_background_asset', 3, 'CREATE TABLE m_accessory_background_asset(\n rarity_type INTEGE 5721 | R NOT NULL,\n attribute INTEGER NOT NULL,\n background_asset_path TEXT NOT NULL,\n PRIMARY KEY (rarity_type, attribute)\n)') 5722 | ('index', 'sqlite_autoindex_m_accessory_background_asset_1', 'm_accessory_background_asset', 4, None) 5723 | ('table', 'm_accessory_frame_type', 'm_accessory_frame_type', 5, 'CREATE TABLE m_accessory_frame_type(\n rarity_type INTEGER NOT NULL,\n fra 5724 | me_type INTEGER NOT NULL,\n PRIMARY KEY (rarity_type)\n)') 5725 | ('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 5726 | rade INTEGER NOT NULL,\n max_level INTEGER NOT NULL,\n accessory_passive_skill_master_id INTEGER,\n PRIMARY KEY (accessory_master_id, grade 5727 | ),\n FOREIGN KEY (accessory_master_id) REFERENCES m_accessory(id),\n FOREIGN KEY (accessory_passive_skill_master_id) REFERENCES m_accessory_ 5728 | passive_skill(id)\n)') 5729 | 5730 | ... 5731 | ``` 5732 | 5733 | this can be simplified to just 5734 | 5735 | ```python 5736 | def klbvfs_transform_byte(byte, key): 5737 | byte ^= i8(key[1] >> 24) ^ i8(key[0] >> 24) ^ i8(key[2] >> 24) 5738 | key[0] = i32(i32(key[0] * 0x000343fd) + 0x00269ec3) 5739 | key[2] = i32(i32(key[2] * 0x000343fd) + 0x00269ec3) 5740 | key[1] = i32(i32(key[1] * 0x000343fd) + 0x00269ec3) 5741 | return byte 5742 | ``` 5743 | 5744 | however all this would be extremely slow with random seeks, so all that 5745 | junk with shifting offsets is necessary to recover the rng state at that 5746 | particular offset and only iterate log base 2 of offset times x 3 instead 5747 | of offset times 5748 | 5749 | you can check out a cleaned up implementation that also includes a python 5750 | codec to decrypt with the builtin open function [here](https://github.com/Francesco149/klbvfs) 5751 | 5752 | I found some info on how the fast seek calculates the rng state: 5753 | https://www.nayuki.io/page/fast-skipping-in-a-linear-congruential-generator 5754 | 5755 | following that article, it's possible to simplify the seeking into just 5756 | a few lines of code 5757 | 5758 | ```python 5759 | def prng_seek(k, offset, mul, add, mod): 5760 | mul1 = mul - 1 5761 | modmul = mul1 * mod 5762 | y = (pow(mul, offset, modmul) - 1) // mul1 * add 5763 | z = pow(mul, offset, mod) * k 5764 | return (y + z) % mod 5765 | 5766 | ... 5767 | 5768 | def xRead(self, amount, offset): 5769 | encrypted = super(KLBVFSFile, self).xRead(amount, offset) 5770 | k = [prng_seek(k, offset, 0x343fd, 0x269ec3, 2**32) for k in self.key] 5771 | res, _ = klbvfs_transform(bytearray(encrypted), k) 5772 | return res 5773 | ``` 5774 | 5775 | so let's see how the game reads, for example, textures 5776 | 5777 | ```c 5778 | uint PackReader$$LoadTextureBytes 5779 | (undefined4 asset_path,undefined4 caller,undefined4 textureBytesReceiver) 5780 | 5781 | { 5782 | int assetData; 5783 | 5784 | if (DAT_03708d1c == '\0') { 5785 | FUN_00871f3c(0x7428); 5786 | DAT_03708d1c = '\x01'; 5787 | } 5788 | assetData = AssetMasterData$$Get("texture",asset_path,0); 5789 | if (assetData != 0) { 5790 | PackReader$$ReadBytes(assetData,textureBytesReceiver,caller); 5791 | } 5792 | return (uint)(assetData != 0); 5793 | } 5794 | 5795 | int AssetMasterData$$Get(undefined4 table,undefined4 asset_path) 5796 | 5797 | { 5798 | int iVar1; 5799 | undefined4 head; 5800 | undefined4 size; 5801 | undefined4 key1; 5802 | undefined4 key2; 5803 | int assetData; 5804 | undefined4 pack_name; 5805 | undefined4 local_30; 5806 | undefined4 local_2c [2]; 5807 | 5808 | if (DAT_03709485 == '\0') { 5809 | FUN_00871f3c(0x1970); 5810 | DAT_03709485 = '\x01'; 5811 | } 5812 | local_2c[0] = 0; 5813 | local_30 = 0; 5814 | if (((*(byte *)(Class$DotUnder.MasterData + 0xbf) & 2) != 0) && 5815 | (*(int *)(Class$DotUnder.MasterData + 0x70) == 0)) { 5816 | FUN_0087f998(); 5817 | } 5818 | pack_name = **(undefined4 **)(Class$DotUnder.MasterData + 0x5c); 5819 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 5820 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 5821 | FUN_0087f998(); 5822 | } 5823 | assetData = 0; 5824 | pack_name = Sqlite$$GetDb(pack_name,0); 5825 | iVar1 = IntPtr$$op_Equality(pack_name,0,0); 5826 | if (iVar1 == 0) { 5827 | local_2c[0] = 0; 5828 | local_30 = 0; 5829 | head = String$$Format("SELECT-pack_name,-head,-size,-key1,-key2-FROM-{0}-WHERE-asset_path-=-?", 5830 | table,0); 5831 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 5832 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 5833 | FUN_0087f998(); 5834 | } 5835 | Sqlite$$Prepare(pack_name,head,local_2c,&local_30,0); 5836 | pack_name = local_2c[0]; 5837 | if (((*(byte *)(Class$DotUnder.Sqlite + 0xbf) & 2) != 0) && 5838 | (*(int *)(Class$DotUnder.Sqlite + 0x70) == 0)) { 5839 | FUN_0087f998(); 5840 | } 5841 | Sqlite$$BindString(pack_name,1,asset_path,0); 5842 | iVar1 = AssetMasterData$$Step(local_2c[0]); 5843 | if (iVar1 == 0) { 5844 | assetData = 0; 5845 | } 5846 | else { 5847 | pack_name = AssetMasterData$$ColumnString(local_2c[0],0); 5848 | head = AssetMasterData$$ColumnInt(local_2c[0],1); 5849 | size = AssetMasterData$$ColumnInt(local_2c[0],2); 5850 | key1 = AssetMasterData$$ColumnInt(local_2c[0],3); 5851 | key2 = AssetMasterData$$ColumnInt(local_2c[0],4); 5852 | assetData = thunk_FUN_008ae7a0(Class$AssetMasterData.Data); 5853 | Object$$.ctor(assetData,0); 5854 | *(undefined4 *)(assetData + 8) = pack_name; 5855 | *(undefined4 *)(assetData + 0xc) = head; 5856 | *(undefined4 *)(assetData + 0x10) = size; 5857 | *(undefined4 *)(assetData + 0x14) = key1; 5858 | *(undefined4 *)(assetData + 0x18) = key2; 5859 | AssetMasterData$$CheckDone(local_2c[0]); 5860 | } 5861 | pack_name = local_2c[0]; 5862 | if (((*(byte *)(Class$Forfeit.Sqlite3 + 0xbf) & 2) != 0) && 5863 | (*(int *)(Class$Forfeit.Sqlite3 + 0x70) == 0)) { 5864 | FUN_0087f998(); 5865 | } 5866 | Sqlite3$$sqlite3_finalize(pack_name,0); 5867 | } 5868 | return assetData; 5869 | } 5870 | ``` 5871 | 5872 | so first it grabs pack name, head, size key1, key2 from the texture table. 5873 | then it passes it to `PackReader$$ReadBytes` 5874 | 5875 | ReadBytes just starts a thread where the action actually happens 5876 | 5877 | ```c 5878 | void PackReader$$ReadBytes(int assetData,int textureBytesReceiver,undefined4 caller) 5879 | 5880 | { 5881 | int displayClass2_0; 5882 | undefined4 retryDelay; 5883 | undefined4 onError; 5884 | int *pReceiver; 5885 | undefined4 caller_; 5886 | undefined4 *pCaller; 5887 | 5888 | if (DAT_03708d1d == '\0') { 5889 | FUN_00871f3c(0x7429); 5890 | DAT_03708d1d = '\x01'; 5891 | } 5892 | displayClass2_0 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_0); 5893 | Object$$.ctor(displayClass2_0,0); 5894 | if (displayClass2_0 == 0) { 5895 | NullPointerExceptionMaybe(0); 5896 | _DAT_00000008 = assetData; 5897 | NullPointerExceptionMaybe(0); 5898 | pCaller = (undefined4 *)&DAT_0000000c; 5899 | _DAT_0000000c = caller; 5900 | NullPointerExceptionMaybe(0); 5901 | pReceiver = (int *)&DAT_00000010; 5902 | _DAT_00000010 = textureBytesReceiver; 5903 | NullPointerExceptionMaybe(0); 5904 | assetData = _DAT_00000008; 5905 | } 5906 | else { 5907 | pReceiver = (int *)(displayClass2_0 + 0x10); 5908 | *pReceiver = textureBytesReceiver; 5909 | *(int *)(displayClass2_0 + 8) = assetData; 5910 | pCaller = (undefined4 *)(displayClass2_0 + 0xc); 5911 | *pCaller = caller; 5912 | } 5913 | if (assetData == 0) { 5914 | retryDelay = thunk_FUN_008ae7a0(Class$System.Exception); 5915 | Exception$$.ctor(retryDelay,"assetData-is-null",0); 5916 | } 5917 | else { 5918 | if (displayClass2_0 == 0) { 5919 | NullPointerExceptionMaybe(0); 5920 | } 5921 | if (*pReceiver != 0) { 5922 | retryDelay = MConstant$$get_FopenRetryDelay(0); 5923 | if (displayClass2_0 == 0) { 5924 | NullPointerExceptionMaybe(0); 5925 | } 5926 | *(undefined4 *)(displayClass2_0 + 0x14) = retryDelay; 5927 | retryDelay = thunk_FUN_008ae7a0(Class$Func_Action_); 5928 | Func$$.ctor(retryDelay,displayClass2_0, 5929 | Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__0(), 5930 | Method$Func_Action_..ctor()); 5931 | onError = thunk_FUN_008ae7a0(Class$Action_Exception_-string_); 5932 | FUN_0245c41c(onError,displayClass2_0,Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__1(), 5933 | Method$Action_Exception_-string_..ctor()); 5934 | if (displayClass2_0 == 0) { 5935 | NullPointerExceptionMaybe(0); 5936 | } 5937 | caller_ = *pCaller; 5938 | if (((*(byte *)(Class$DotUnder.Concurrency + 0xbf) & 2) != 0) && 5939 | (*(int *)(Class$DotUnder.Concurrency + 0x70) == 0)) { 5940 | FUN_0087f998(); 5941 | } 5942 | Concurrency$$RunSubthread(retryDelay,onError,caller_,2,0); 5943 | return; 5944 | } 5945 | retryDelay = thunk_FUN_008ae7a0(Class$System.Exception); 5946 | Exception$$.ctor(retryDelay,"callback-is-null",0); 5947 | } 5948 | Throw(retryDelay,0,Method$PackReader.ReadBytes()); 5949 | caseD_15(); 5950 | return; 5951 | } 5952 | 5953 | undefined4 PackReader.__c__DisplayClass2_0$$_ReadBytes_b__0(int param_1) 5954 | 5955 | { 5956 | int displayClass2_1; 5957 | int iVar1; 5958 | undefined4 externalPackPath; 5959 | undefined4 head; 5960 | undefined4 size; 5961 | undefined4 key1; 5962 | undefined4 key2; 5963 | int *piVar2; 5964 | void *packName; 5965 | int iVar3; 5966 | int iVar4; 5967 | 5968 | if (DAT_03708d1f == '\0') { 5969 | FUN_00871f3c(0xb5c7); 5970 | DAT_03708d1f = '\x01'; 5971 | } 5972 | displayClass2_1 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_1); 5973 | Object$$.ctor(displayClass2_1,0); 5974 | if (displayClass2_1 == 0) { 5975 | NullPointerExceptionMaybe(0); 5976 | _DAT_0000000c = param_1; 5977 | NullPointerExceptionMaybe(0); 5978 | } 5979 | else { 5980 | *(int *)(displayClass2_1 + 0xc) = param_1; 5981 | } 5982 | *(undefined4 *)(displayClass2_1 + 8) = 0; 5983 | while( true ) { 5984 | iVar1 = thunk_FUN_008ae7a0(Class$PackReader.__c__DisplayClass2_2); 5985 | Object$$.ctor(iVar1,0); 5986 | if (iVar1 == 0) { 5987 | NullPointerExceptionMaybe(0); 5988 | piVar2 = (int *)&DAT_0000000c; 5989 | _DAT_0000000c = displayClass2_1; 5990 | NullPointerExceptionMaybe(0); 5991 | } 5992 | else { 5993 | piVar2 = (int *)(iVar1 + 0xc); 5994 | *piVar2 = displayClass2_1; 5995 | } 5996 | iVar4 = *piVar2; 5997 | if (iVar4 == 0) { 5998 | NullPointerExceptionMaybe(0); 5999 | } 6000 | packName = *(void **)(param_1 + 8); 6001 | iVar4 = *(int *)(iVar4 + 8); 6002 | if (packName == (void *)0x0) { 6003 | NullPointerExceptionMaybe(0); 6004 | } 6005 | packName = AssetMasterData.Data$$get_PackName(packName); 6006 | externalPackPath = PackageManager$$GetExternalPackPath(packName); 6007 | iVar3 = *(int *)(param_1 + 8); 6008 | if (iVar3 == 0) { 6009 | NullPointerExceptionMaybe(0); 6010 | } 6011 | head = AssetMasterData.Data$$get_Head(iVar3,0); 6012 | iVar3 = *(int *)(param_1 + 8); 6013 | if (iVar3 == 0) { 6014 | NullPointerExceptionMaybe(0); 6015 | } 6016 | size = AssetMasterData.Data$$get_Size(iVar3,0); 6017 | iVar3 = *(int *)(param_1 + 8); 6018 | if (iVar3 == 0) { 6019 | NullPointerExceptionMaybe(0); 6020 | } 6021 | key1 = AssetMasterData.Data$$get_Key1(iVar3,0); 6022 | iVar3 = *(int *)(param_1 + 8); 6023 | if (iVar3 == 0) { 6024 | NullPointerExceptionMaybe(0); 6025 | } 6026 | key2 = AssetMasterData.Data$$get_Key2(iVar3,0); 6027 | iVar4 = PackReader$$ReadDecrypt(externalPackPath,head,size,key1,key2,(uint)(iVar4 != 9)); 6028 | if (iVar1 == 0) { 6029 | NullPointerExceptionMaybe(0); 6030 | _DAT_00000008 = iVar4; 6031 | NullPointerExceptionMaybe(0); 6032 | iVar4 = _DAT_00000008; 6033 | } 6034 | else { 6035 | *(int *)(iVar1 + 8) = iVar4; 6036 | } 6037 | if (iVar4 != 0) break; 6038 | Thread$$Sleep(*(undefined4 *)(param_1 + 0x14),0); 6039 | iVar1 = *(int *)(displayClass2_1 + 8) + 1; 6040 | *(int *)(displayClass2_1 + 8) = iVar1; 6041 | if (9 < iVar1) { 6042 | externalPackPath = thunk_FUN_008ae7a0(Class$System.Exception); 6043 | Exception$$.ctor(externalPackPath,StringLiteral_9337,0); 6044 | Throw(externalPackPath,0,Method$PackReader.__c__DisplayClass2_0._ReadBytes_b__0()); 6045 | externalPackPath = caseD_15(); 6046 | return externalPackPath; 6047 | } 6048 | } 6049 | externalPackPath = thunk_FUN_008ae7a0(Class$System.Action); 6050 | Action$$.ctor(externalPackPath,iVar1,Method$PackReader.__c__DisplayClass2_2._ReadBytes_b__2(),0); 6051 | return externalPackPath; 6052 | } 6053 | ``` 6054 | 6055 | so it looks like it's calling ReadDecrypt, which leads to libpenguin 6056 | 6057 | ```c 6058 | int _Penguin_Decrypt(byte *param_1,long param_2,size_t param_3,char *param_4,int param_5,int param_6 6059 | ) 6060 | 6061 | { 6062 | FILE *__stream; 6063 | int iVar1; 6064 | size_t sVar2; 6065 | byte *pbVar3; 6066 | byte *pbVar4; 6067 | int iVar5; 6068 | 6069 | __stream = fopen(param_4,"r"); 6070 | if (__stream == (FILE *)0x0) { 6071 | iVar1 = -1; 6072 | } 6073 | else { 6074 | iVar1 = fseek(__stream,param_2,0); 6075 | if (iVar1 == 0) { 6076 | sVar2 = fread(param_1,1,param_3,__stream); 6077 | if (param_3 == sVar2) { 6078 | fclose(__stream); 6079 | if (0 < (int)param_3) { 6080 | iVar5 = 0x3039; 6081 | pbVar3 = param_1; 6082 | do { 6083 | pbVar4 = pbVar3 + 1; 6084 | *pbVar3 = *pbVar3 ^ (byte)((uint)param_5 >> 0x18) ^ (byte)((uint)param_6 >> 0x18) ^ 6085 | (byte)((uint)iVar5 >> 0x18); 6086 | param_5 = param_5 * 0x343fd + 0x269ec3; 6087 | param_6 = param_6 * 0x343fd + 0x269ec3; 6088 | iVar5 = iVar5 * 0x343fd + 0x269ec3; 6089 | pbVar3 = pbVar4; 6090 | } while (pbVar4 != param_1 + param_3); 6091 | } 6092 | } 6093 | else { 6094 | fclose(__stream); 6095 | iVar1 = -3; 6096 | } 6097 | } 6098 | else { 6099 | fclose(__stream); 6100 | iVar1 = -2; 6101 | } 6102 | } 6103 | return iVar1; 6104 | } 6105 | ``` 6106 | 6107 | oh nice, it's just the same encryption as the database except one of the 6108 | three keys is hardcoded to `0x3039` 6109 | 6110 | ok but let's go back, where does the file path come from? 6111 | 6112 | ```c 6113 | void PackageManager$$GetExternalPackPath(undefined4 packName) 6114 | { 6115 | undefined4 filePath; 6116 | 6117 | if (DAT_03708d38 == '\0') { 6118 | FUN_00871f3c(0x7445); 6119 | DAT_03708d38 = '\x01'; 6120 | } 6121 | filePath = PackageManager$$GetPackFilePath(packName); 6122 | if (((*(byte *)(Class$LLAS.AssetLoader.PathResolver + 0xbf) & 2) != 0) && 6123 | (*(int *)(Class$LLAS.AssetLoader.PathResolver + 0x70) == 0)) { 6124 | FUN_0087f998(); 6125 | } 6126 | PathResolver$$ResolveFullPath(filePath); 6127 | return; 6128 | } 6129 | 6130 | void PackageManager$$GetPackFilePath(int packName) 6131 | 6132 | { 6133 | undefined4 path; 6134 | 6135 | if (DAT_03708d37 == '\0') { 6136 | FUN_00871f3c(0x7449); 6137 | DAT_03708d37 = '\x01'; 6138 | } 6139 | if (packName == 0) { 6140 | NullPointerExceptionMaybe(0); 6141 | } 6142 | path = String$$Substring(packName,0,1,0); 6143 | path = String$$Concat("pkg",path,0); 6144 | if (((*(byte *)(Class$System.IO.Path + 0xbf) & 2) != 0) && 6145 | (*(int *)(Class$System.IO.Path + 0x70) == 0)) { 6146 | FUN_0087f998(); 6147 | } 6148 | Path$$Combine(path,packName,0); 6149 | return; 6150 | } 6151 | ``` 6152 | 6153 | right, I should've guessed it. the pak folders sort all the packages by 6154 | the first letter, a very simple tree probably to improve performance 6155 | slightly 6156 | 6157 | ok, so what we know so far is 6158 | 6159 | * pack names refer to the files inside the pkg folders 6160 | * each pack potentially contains multiple files, and the asset data tells 6161 | the pack manager where to seek into the package as well as how big is 6162 | the file 6163 | * encryption is on a per-file basis and uses 2 keys stored in a database 6164 | plus the hardcoded key, same prng as the sqlite encryption 6165 | 6166 | I think I'll try to find all tables that contain those params and extract 6167 | everything I can 6168 | 6169 | the asset path strings are really weird, they seem to be short 2 or so 6170 | character strings with random characters, almost looks like an integer 6171 | encoded as a string. I can't find any thing that generates them, I think 6172 | this is just how assets are packaged at klab, and I don't think we can 6173 | recover the filenames 6174 | 6175 | there is a `m_asset_package_mapping` table in the assets db that seems to 6176 | link some names to packs and metapacks. these don't seem to be filenames 6177 | but rather folder names and it doesnt seem to contain every single asset 6178 | 6179 | if i search for metapack in the game i find a class named SplitMetaPack. 6180 | it seems to have to do with splitting downloads, we probably don't care 6181 | 6182 | so i added this dump function to my klbvfs implementation: 6183 | 6184 | ```python 6185 | def do_dump(args): 6186 | for source in args.directories: 6187 | pattern = re.compile("asset_a_ja_0.db_[a-z0-9]+.db") 6188 | matches = [f for f in os.listdir(source) if pattern.match(f)] 6189 | assets = matches[0] 6190 | print(assets) 6191 | dstdir = os.path.join(source, 'texture') 6192 | try: 6193 | os.mkdir(dstdir) 6194 | except FileExistsError: 6195 | pass 6196 | db = klb_sqlite(assets).cursor() 6197 | q = 'select distinct pack_name, head, size, key1, key2 from texture' 6198 | for (pack_name, head, size, key1, key2) in db.execute(q): 6199 | pkgpath = os.path.join(source, "pkg" + pack_name[:1], pack_name) 6200 | key = [key1, key2, 0x3039] 6201 | pkg = codecs.open(pkgpath, mode='rb', encoding='klbvfs', errors=key) 6202 | pkg.seek(head) 6203 | print(key) 6204 | print(pkg.errors) 6205 | fpath = os.path.join(dstdir, "%s_%d_.png" % (pack_name, head)) 6206 | dst = open(fpath, 'wb+') 6207 | shutil.copyfileobj(pkg, dst, size) 6208 | ``` 6209 | 6210 | where directories is a list of /files/files directories where pkg folder 6211 | reside, and sure enough, I got a bunch of correctly decrypted png's! 6212 | the hard part is figuring all the tables that contain references to files 6213 | in the pkg files and extract all unique files. a cool idea would be to 6214 | map out all the offsets and then see if there's any unused files lying 6215 | around and what they are 6216 | 6217 | some of the decrypted images are weird. they have the jpg header and appear 6218 | to be small thumbnails, but the filesize is much larger than it should. 6219 | I suspect this is some custom format that appends a low quality jpg and 6220 | the full quality image in the same file, but I'm not sure what yet 6221 | 6222 | okay, I used libmagic to detect mime type and it's detecting them as jpe. 6223 | it seems that some platforms use jpe as an additional low res version of 6224 | the image along with the original jpeg. renaming to jpe does not help 6225 | getting the high res version. might have to split them manually 6226 | 6227 | to be continued...? 6228 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Francesco149/reversing-sifas/fe16f22cf2413c81d0ccec923acd309d9f0fcafe/pic.png -------------------------------------------------------------------------------- /pic2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Francesco149/reversing-sifas/fe16f22cf2413c81d0ccec923acd309d9f0fcafe/pic2.png --------------------------------------------------------------------------------