blog/content/posts/2024-03-26-playing-with-snu...

16 KiB

title date slug description
Playing with SNU Attendance System 2024-03-26T18:00:48+09:00 playing-with-snu-eam Playing with SNU EAM

Playing with "Electronic Attendace System" of SNU.

DO NOT USE IN REAL CLASSES.

You know, there is beacon-based digital attendance systen. It seems... hackable, isnt it? So lets dig deep what is going on there and ultimately taking an attendace without actaully attending. Of course its not for real use, it just for fun!

1. APK JADX analyzing

I just decompiled apk using JADX and renamed every function and class (which I understand) for two straight days. But it didnt matter that much. Important things were obsfucated in JNI. What a shame.

2. App MITM patch, repackaging.

Since we knew there is nothing going on in Beacon itself, and it just talks to server to do attendance things, now we have to analyze its packets, and ultimately replay it.

Using apk-mitm and uber-apk-signer, I modified app for use with mitmproxy. Since app has its own signature verifing on startup and some activity, we have to bypass that verification.

  1. Startup It retrieves App signature from https://mob.snu.ac.kr/api/versionCheck.action. Using mitmproxy script, we can easily modify its response.
def response(flow: http.HTTPFlow) -> None:
    url = flow.request.pretty_url
    if url == "https://mob.snu.ac.kr/api/versionCheck.action":
        data = json.loads(flow.response.get_text())
        data["VERSION"]["SIGN_KEY"] = "MYSIGNATURE"
        flow.response.text = json.dumps(data)
  1. JNI Signature checking. It uses JNI libEncryptionKeyStore.so for EncryptionKeyRetrieving and checkRoot. In checkRoot, it verifies app's signature by just with some loops. When it doesnt match, it branches into std::terminate.
00007c4c      if (*(data_3d158 - 0x18) == 0)
00007d44          sub_bd3c(&data_3d158, "                                …", 0x28)
00007d48          int64_t x0_8 = data_3d158
00007d50          if ((*(x0_8 - 8) & 0x80000000) == 0)
00007d58              sub_ba58(&data_3d158)
00007d5c              x0_8 = data_3d158
00007d68          *x0_8 = 0x35

We can either modify binary as our signature, but replacing every single bytes would be nightmare. So I had to do it automatically. To track down where every bytes are assigned, I just sweeped and parsed every single line and modified it. I matched binary expressions into opcode and tracked down when and where it stored bytes into string register, and changed it into my signature. (armv8.py and ubivelox.py) This is of course very very inefficient and very limited way, but it worked well for me this time. Its one time script, no one cares about it.

Or we can just bypass b std::terminate to ret. It doesnt return anything, so just returning should be fine.

00008308  23040051   sub     w3, w1, #0x1
0000830c  43801fb8   stur    w3, [x2, #-0x8]
00008310  f8ffff17   b       0x82f0

00008314  04030094   bl      std::terminate   -->   ret
{ Does not return }

And final method is creating imposter native library which I destinated to do later anyways.

2. linEncryptionKeyStore.so JNI

package com.ubivelox.security;
public class EncryptionKeyStore {
    static {
        System.loadLibrary("EncryptionKeyStore");
    }
    public String secretKeyEx() {
        return getSecretKeyEx();
    }
    public native void checkRooting(String str);
    public native String getBleInfo();
    public native String getSecretKeyEx();
}

Important things were all in this JNI. App sends request as {header: encryped-header, body: body} and this encryption key is retrieved with getSecretKeyEx() as JNI. So I need to somehow extract key from this function.

00007124      int64_t var_38 = time(nullptr)
00007130      int32_t x1 = *(localtime(&var_38) + 0x10)
00007134      int32_t x1_1 = x1 + 1
00007138      int32_t x0_9
00007138      if (x1 + 1 s< 0)
00007388          x0_9 = 0
00007150      else
00007150          int32_t x0_7 = ((muls.dp.q(x1_1, 0x2aaaaaab) u>> 0x20).d s>> 1) - (x1_1 s>> 0x1f)
0000715c          x0_9 = x1_1 - ((x0_7 << 4) - (x0_7 << 2))

First, it calculates index from current time. I didnt know what was going on here exactly, but I learned that multipling 0x2aaaaaab is to divide number by 3. Well the calculation wasnt 'understandable' for me, so I just moved this into C code and observed the output. Surprisingly, it was fixed.

00007180  1f040071   cmp     w0, #0x1
00007184  60100054   b.eq    0x7390
00007188  1f080071   cmp     w0, #0x2
0000718c  e0150054   b.eq    0x7448
...
000071d0  1f2c0071   cmp     w0, #0xb
000071d4  210c0054   b.ne    0x7358

They wrote code to do some kind of key rotating with that index above, and that index was fixed, so it was just all useless obsfucation. (but it is more likely that I didnt looked it carefully and it changes daily or randomly)

00007204  06078052   mov     w6, #0x38
00007208  29078052   mov     w9, #0x39
0000720c  88068052   mov     w8, #0x34
00007210  65088052   mov     w5, #0x43
00007214  acd30039   strb    w12, [x29, #0x34 {var_30+0x4}]  {0x46}
00007218  a3f30039   strb    w3, [x29, #0x3c {var_28+0x4}]  {0x44}
0000721c  2c068052   mov     w12, #0x31
00007220  ab070139   strb    w11, [x29, #0x41 {var_20+0x1}]
00007224  ab068052   mov     w11, #0x35

Each subroutine builds key into var_30, var_20, ... , so I just need to write down these keys right?

Well it wasnt. After each subroutine it all branched into this part.

00007358  820240f9   ldr     x2, [x20]
0000735c  e00314aa   mov     x0, x20
00007360  429c42f9   ldr     x2, [x2, #0x538]
00007364  40003fd6   blr     x2
00007368  73ce47f9   ldr     x19, [x19, #0xf98]  {__stack_chk_guard}
0000736c  a22f40f9   ldr     x2, [x29, #0x58 {var_8}]
00007370  610240f9   ldr     x1, [x19]
00007374  5f0001eb   cmp     x2, x1
00007378  413b0054   b.ne    0x7ae0
0000737c  f35341a9   ldp     x19, x20, [sp, #0x10] {__saved_x19} {__saved_x20}
00007380  fd7bc6a8   ldp     x29, x30, [sp], #0x60 {__saved_x29} {__saved_x30}
00007384  c0035fd6   ret    
00007388  00008052   mov     w0, #0
0000738c  75ffff17   b       0x7160

It loads x20 into x2 and branches into value of x2+0x538. And x20 was saved value of int64_t* arg1, which is JNIenv passed from Java. And of course I spend a lot of hard time figuring out what this function does. I just couldnt see where that function was, and this was almost first time analyzing JNI. I didnt even knew any Android programming. And I just gave up at this point. I even tried to read all defined functions. And fast-forwarding to future, that was just endian converting function...

3. JNI sniffing

I had to find other way. And I rememdered about library sniffing. You know, we can sniff and log return values using mocked JNI, which just forwards function calls. We can even bypass checkRoot by just not actually calling it.

#include "com_ubivelox_security_EncryptionKeyStore.h"
#include <dlfcn.h>
#include <stdio.h>
#include <android/log.h>

void* libhandle = dlopen("libEncryptionKeyStore-orig.so", RTLD_LAZY);

typedef void (*checkRooting_ft)(JNIEnv*, jobject, jstring);
typedef jstring (*getBleInfo_ft)(JNIEnv*, jobject);
typedef jstring (*getSecretKeyEx_ft)(JNIEnv*, jobject);
checkRooting_ft checkRooting_ptr;
getBleInfo_ft getBleInfo_ptr;
getSecretKeyEx_ft getSecretKeyEx_ptr;

JNIEXPORT void JNICALL checkRooting(JNIEnv* arg1, jobject arg2, jstring arg3) {
    if (!checkRooting_ptr)
        checkRooting_ptr = (checkRooting_ft)dlsym(libhandle, "Java_com_ubivelox_security_EncryptionKeyStore_checkRooting");
    if (checkRooting_ptr)
//      return checkRooting_ptr(arg1, arg2, arg3);
        return;
}

JNIEXPORT jstring JNICALL getBleInfo(JNIEnv* env, jobject obj) {
    if (!getBleInfo_ptr)
        getBleInfo_ptr = (getBleInfo_ft)dlsym(libhandle, "Java_com_ubivelox_security_EncryptionKeyStore_getBleInfo");
    if (getBleInfo_ptr) {
        jstring ret = getBleInfo_ptr(env, obj);

        const char *cStr = env->GetStringUTFChars(ret, NULL);
        if (cStr == NULL) {
            return NULL;
        }
        char log[2048] = {0};
        snprintf(log, sizeof(log), "getBleInfo: %s", cStr);
        __android_log_write(ANDROID_LOG_ERROR, "libEncryptionKeyStore", log);
        env->ReleaseStringUTFChars(ret, cStr);
        return ret;
    }
    return NULL;
}

JNIEXPORT jstring JNICALL getSecretKeyEx(JNIEnv* env, jobject obj) {
    if (!getSecretKeyEx_ptr)
        getSecretKeyEx_ptr = (getSecretKeyEx_ft)dlsym(libhandle, "Java_com_ubivelox_security_EncryptionKeyStore_getSecretKeyEx");
    if (getSecretKeyEx_ptr) {
        jstring ret = getSecretKeyEx_ptr(env, obj);

        const char *cStr = env->GetStringUTFChars(ret, NULL);
        if (cStr == NULL) {
            return NULL;
        }
        char log[2048] = {0};
        snprintf(log, sizeof(log), "getSecretKeyEx: %s, %p", cStr, *(*env + 0x538));
        __android_log_write(ANDROID_LOG_ERROR, "libEncryptionKeyStore", log);
        env->ReleaseStringUTFChars(ret, cStr);
        return ret;
    }
    return NULL;
}

JNIEXPORT void JNICALL Java_com_ubivelox_security_EncryptionKeyStore_checkRooting (JNIEnv* arg1, jobject arg2, jstring arg3)
{
    return checkRooting(arg1, arg2, arg3);
}
JNIEXPORT jstring JNICALL Java_com_ubivelox_security_EncryptionKeyStore_getBleInfo (JNIEnv* arg1, jobject arg2) 
{
    return getBleInfo(arg1, arg2);
}

JNIEXPORT jstring JNICALL Java_com_ubivelox_security_EncryptionKeyStore_getSecretKeyEx (JNIEnv* arg1, jobject arg2)
{
    return getSecretKeyEx(arg1, arg2);
}

So I just did that. What a easy tasks it was. Now I can just get EncryptionKey with logcat.

03-26 11:04:03 E libEncryptionKeyStore: RETURN: 3172XXXXXXXXXXXXXXXXXXXXXXXXXXXX
03-26 11:04:03 E libEncryptionKeyStore: RETURN: 3172XXXXXXXXXXXXXXXXXXXXXXXXXXXX
03-26 11:04:03 E libEncryptionKeyStore: RETURN: 3172XXXXXXXXXXXXXXXXXXXXXXXXXXXX
03-26 11:04:03 E libEncryptionKeyStore: RETURN: 3172XXXXXXXXXXXXXXXXXXXXXXXXXXXX

It was really this easy.. I should've done it from beginning.

And at this moment I realized this output was just endian converted from my extracted key above.

4

So, I now extracted EncryptionKey, and I have all packets app talked to server. Now its time to decrypt all messages and build our own requests!

Messages are encrypted in AES-CBC mode with IV of just 0s, padded with pkcs#5.

I modified mitm script to automatically decrypt messages and log them.

elif "https://scard1.snu.ac.kr/" in url:
        req = json.loads(flow.request.get_text())
        data = json.loads(flow.response.get_text())
        with open(LOG, "a") as f:
            f.write(f"{gettime()}\n")
            f.write(flow.request.pretty_url)
            f.write("\n------------------\n")
            f.write(decrypt(req["header"]).decode())
            f.write("\n------------------\n")
            f.write(req["body"])
            f.write("\n------------------\n")
            f.write(decrypt(data["header"]).decode())
            f.write("\n------------------\n")
            f.write(data["body"])
            f.write("\n------------------\n")

And my log looked like this. Yay!

09:52:22
https://scard1.snu.ac.kr/eaas/R001
------------------
{"appVer":"3.4.29","authType":"","deviceLocale":"ko_KR","deviceMac":"02:00:00:00:00:00","deviceModel":"SM-G950N","deviceType":"10","deviceUuid":"XXXXXXXX-6D17-3995-AFD9-XXXXXXXXXXX","deviceVer":"9","enc":"bf21xxxxxxxxxxxxxxfa0859e0917b2202f","loginId":"","ssoToken":"","trId":"R001","trVer":"1.0.0","userId":""}
------------------
{}
------------------
{"trVer":"1.0.0","trId":"R001","sysTime":"20240326095222","ssoToken":"","resCd":"000000","resMsg":"","enc":"32942ddxxxxxxxxxxxxxxx7a24b2cf3b"}

Other things were clear, and that enc was just SHA1 of body content, for verifing maybe. I have all of informations, now I can just send requests to server and do attendance, retrieve class and lecture infos.

5

As in case of Attendace Checking, It uses BLE Beacon (or iBeacon).

At first I thought there should be some kind of randomization and special ble service, but it just sends existing and constant beacon data (UUID, MacAddress, ...) to server.

It seems easy to manipulate, and by knowing this I can just build BLE server with matching UUID and such properties, and use unmodified app to do attendance as usual. Modifing and sending custom built raw packets could be illegal in some circumstances, so this way is more safe and undetectable.

Attendance packets involves encryption twice. First as other encrypts header, and second it encrypts BLE beacon data one more time in header. I dont know why they encrypt twice, but there should be some context in it.

bleInfo = {
            "ROOMID": (BLE_MAC, BLE_MAJ, BLE_MIN, BLERSSI, BLE_UUID),
          }
roomInfo = {LECTURE_ID: ROOM_ID}

userId = STUD_ID
lectureId = LECTURE_ID
bleMac, bleMajor, bleMinor, bleRSSI, bleUUID = bleInfo[roomInfo[lectureId]]
timeCustom = datetime.strftime(datetime.now(), "%Y%m%d%H%M%S")
plainble = '[{"bleWorkEndTime":"","bleWorkStartTime":"","bleBattery":"0","bleMacAddress":"' + bleMac + '","bleMajor":"' + bleMajor + '","bleMinor":"' + bleMinor + '","bleRssi":"' + bleRSSI + '","bleSignalPeriod":"0","bleTxPower":"0","bleUuid":"' + bleUUID + '"}]_' + timeCustom + userId + lectureId
bleEnc = enc(plainble.encode()).decode()
plainbody = '{"bleList":null,"bleListEx":"' + bleEnc + '","classSort":"0","lectureId":"' + lectureId + '","userId":"' + userId + '","verifyNum":null,"verifyType":null,"yearTerm":"20241"}'
bodyhash = hashlib.sha1(plainbody.encode()).hexdigest()
plainheader = '{"appVer":"3.1.1","authType":"0","deviceLocale":"ko_KR","deviceMac":"00:00:00:00:00:00","deviceModel":DEVICE_MODEL,"deviceType":"10","deviceUuid":DEVICE_UUID,"deviceVer":"9","enc":"' + bodyhash + '","loginId":"' + userId + '","ssoToken":"","trId":"A002","trVer":"1.0.0","userId":""}'
encheader = enc(plainheader.encode()).decode()
reqbody = json.dumps({"header": encheader,"body": plainbody})

And voila! Now we can take an attendance without attending!

I should make a platform to register beacon datas and automatically sends attendance request at time, and sell this service to my colleages, right?

NO.

Please dont use it in real world. It is easily detectable on server side. I risked testing it for once, but it shouldnt used in real world. NEVER.

Afterthoughts.

It seem complex at the beginning and solution was pretty easy and straightforward. I bet anyone could do it if one puts some times in it, I wonder why this wasnt the case. Maybe because it is actually illegal to do so, and because of that it was less appealing? I dont know.

Also, hardcoding encryption keys or verification tokens inside the app itself seems dangerous, as I could extract keys from decompilation. And any app has potential to be preloaded and sniffed, especially JNI like this case, so using JNI to retrive raw keys is not good I think. Its more better to just encrypt inside JNI so that key itself can be contained in itself, right?

I dont think there is a way to contain key in blackbox even for end user. User has all access to app so it is hard to hide key from them maybe. I dont have deep knowledge in this area.. Even TPM is vulnerable to hardware key sniffing, so using hardware blackboxes are also not that sufficient unless it is really really blackbox. Maybe ZKP can solve this problem?

Signature verifing is hard. Using Network is dangerous, and using internal logic can be also bypassed. I think only way to prevent this is in operating system, like in iOS it's much more hard to do this kind of job.

mitmproxy must be the most threat in this case? Nowadays apps uses cert pinning, but it was also useless... So any problems is all sign verification... Like Play Protect perhaps?