저렴이 IP 카메라 다시 한번 도전해보기, 2탄

2026년 11월 9일

약 1년 전, Tuya라는 중국 IoT 플랫폼을 기반으로 한 저렴이 IP 카메라를 만지작거리다가, 해당 플랫폼의 서버를 경유하지 않고 안에서 직접 로컬 비디오 스트림을 재생하는 방법을 찾는데 실패했습니다. 그때는 실제로 스트리밍 기능을 공개하는 카메라를 사용하는게 시간이 훨씬 절약될 것이라고 결론지었고, 카메라 자체는 방치해두고 말았죠.

최근에 새 직업을 위해 뉴저지로 이사를 오게 되면서, 이삿짐을 풀다가 다시 카메라를 발견하게 되었습니다. 다시 도전을 해볼까 생각이 든 이유는, 여유 시간이 많이 있(었)고, 만약 스스로 찾아내지 못하더라도, 요즘에 LLM들이 리버스 엔지니어링을 진짜 잘 해내서 이걸 가지고 작업하는 것도 유행이니까요. 그래도 일단 제가 알아낼 수 있는 선까지는 최선을 다하고, 아예 모르겠다 싶을때에 LLM을 사용하고 나오는 출력물로 뭘 배울 수 있을지 확인하는 목표를 두고 시작했습니다.

그럼 이번의 목표는 도대체 뭔가요? 저번에 봤듯이 이 카메라 안에는 ONVIF 서버가 내장되어 있는 것을 알 수 있는데, 여기에서 ONVIF는 IP 카메라들을 위한 표준입니다. 표준을 만드는 단체 이름이 Open Network Vision Interface Forum이라 ONVIF로 읽죠. 그리고 대부분의 ONVIF 카메라들은 비디오 스트림들을 RTSP (Real-Time Streaming Protocol)로 노출하는데, 스트리밍 사이트인 유튜브나 트위치에 방송해보신 분들은 아마 이 프로토콜을 한번쯤은 보셨을 거라고 생각합니다.

문제는 이 서버들이 펌웨어단에서 비활성화되어 있다는 점이고, 포트도 열려 있지 않았죠. 저번에는 설정 옵션을 변경하여 활성화를 시도했지만 작동하지 않았습니다. 따라서, 이제 왜 그 방식이 실패했는지 알아볼 시간이고, 실제로 이 스트림들을 재생할 수 있는 방법이 있는지 확인해보겠습니다.

그럼 그걸 염두해두고, 일단 펌웨어 이미지를 binwalk -Me로 추출합니다:

10진수 16진수 설명
113992 0x1BD48 CRC32 polynomial table, little endian
458752 0x70000 uImage firmware image, header size: 64 bytes, data size: 2803328 bytes, compression: none, CPU: ARM, OS: Linux, image type: OS, Kernel Image, load address: 0x81C08000, entry point: 0x81C08040, creation time: 2021-06-21 03:03:42, image name: “Linux-3.4.35”
3604480 0x370000 CramFS filesystem, little endian, 3 files, total size: 3248128 bytes
7736524 0x760CCC JFFS2 filesystem, little endian, nodes: 256, total size: 648000 bytes

Linux 3.4.35를 돌리고 있는데, 그게 언제 출시되었더라? 2013년 3월이군요. 거의 13년 묵은 커널입니다! (물론 원래 개발자들이 컴파일했을 때에는 8년이었죠.)

마지막 글에선, ONVIF 설정이 들어간 Tuya 설정 파일을 ./extractions/cctv.bin.extracted/760CCC/jffs2-root/tuya_config.json에서 만나볼 수 있었습니다. 제 생각으론 그때 실패했던 이유는 펌웨어 이미지 자체를 헥스 편집기로 수정했는데, 그 과정에서 무슨 체크섬을 손상시켰기 때문인 것 같습니다.

일단, 이미지가 추출되었으니, 설정값을 어디에서 사용하는지 확인해볼까요? 바이너리가 그 값을 아예 참조 안하면 안되니까요.

그럼 어떤 바이너리가 실행되는지 알아야겠죠? 일단 카메라가 최초로 실행하는 초기 스크립트인 .\extractions\cctv.bin.extracted\370000\initrun.sh를 확인해보겠습니다. 해당 스크립트는 어플 tarball을 추출하고, home/init.d/initS에 추출된 스크립트를 실행하는데, 그 스크립트는 또다시 home/init.d 안의 다른 스크립트들을 실행하게 됩니다.

대부분의 스크립트들은 메모리나 네트워크나 DHCP와 같은 커널 파라미터들을 설정하는 역할을 하는데, 저희한테는 중요치 않으니 모조리 건너뛰어 줍니다. 중요한 부분은 마지막 스크립트인 S60ppsapp인데, 해당 스크립트는 /home/app 안에 있는 ppsapp 바이너리를 실행합니다.

find 커맨드로 찾아낸 파일: extractions\cctv.bin.extracted\370000\app.tar.gz.extracted\0\decompressed.bin.extracted\0\home\app\ppsapp. 여기에다가 strings를 돌려보면 ONVIF 문자열들을 확인할 수 있죠:

$ strings ppsapp | grep -C 2 "onvif_enable"
motion_area_switch
motion_area
onvif_enable
onvif_pwd

하지만 바이너리 안에 ONVIF 스트리밍과 관련된 레퍼런스들이 있나요?

$ strings ppsapp | grep "Starting onvif"
Starting onvif server on port %s
$ strings ppsapp | grep "rtsp://"
rtsp://
rtsp://%s:%d/Streaming/Channels/101
rtsp://%s:%d/Streaming/Channels/102

좋습니다! 어떤 포트에서 작동하는지는 아직 확인이 필요하지만, ONVIF 스티리밍 URL 형식을 알아냈습니다.

그럼 바이너리 자체를 Ghidra에 넣어서 분석을 돌려보겠습니다. 문자열들을 확인해보니, 여러 하드코딩된 경로들이 /var/lib/jenkins/workspace/ 안에 있는데, 아마 이 이미지를 만든 분들이 Jenkins 파이프라인에서 이미지를 만들었다는 것을 유추할 수 있습니다. 이 글에 관련이 있냐고요? 없지만 흥미로워서 적어봤습니다. 아마 출시하는 카메라마다 Jenkins 워크플로우가 하나씩 있을텐데, 사후지원이 전혀 없으니 딱 한 번만 사용되고 버려지는 샘이군요.

바이너리 자체는 2.4 MB이고, ARM 어셈블리어는 잘 읽지도 못하지만, 다행히도 아직까진 읽을 필요가 없습니다! 일단 onvif 관련 부분을 찾아보기 위해서 이미 알고 있는 설정 문자열을 검색합니다.

Ghidra 상에서는 “Search -> For Strings…“를 통해 문자열을 검색할 수 있습니다. 그 옵션을 선택하면 나타나는 창에서 문자열을 검색할 수 있는데, 운이 좋게도 onvif_enable 필터를 적용하면 사용되는 부분이 딱 두 곳만 표시가 됩니다:

사용 1번 (경고: 장문)
undefined4 FUN_0000f3c0(void)
{
  bool bVar1;
  FILE *__stream;
  byte *__s;
  size_t sVar2;
  undefined4 *puVar3;
  undefined4 *puVar4;
  undefined4 uVar5;
  void *__src;
  
  __stream = fopen64("/home/cfg/tuya_config.json","r+");
  if (__stream == (FILE *)0x0) {
    uVar5 = 0xffffffff;
  }
  else {
    __s = malloc(0x800);
    if (__s == (byte *)0x0) {
      fclose(__stream);
      uVar5 = 0xffffffff;
    }
    else {
      memset(__s,0,0x800);
      sVar2 = fread(__s,1,0x800,__stream);
      if (sVar2 == 0) {
        fclose(__stream);
        free(__s);
        uVar5 = 0xffffffff;
      }
      else {
        fclose(__stream);
        puVar3 = FUN_001f5cfc(__s);
        if (puVar3 == (undefined4 *)0x0) {
          uVar5 = 0xffffffff;
        }
        else {
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"version");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d58 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"sleep_mode");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d5c = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"light_onoff");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d60 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"flip_onoff");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d64 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"watermark_onoff");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d68 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"night_mode");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d6c = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"alarm_fun_onoff");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d70 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"alarm_fun_sensitivity");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d74 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"alarm_fun_mode_switch");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d78 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"alarm_fun_time_start");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d7c = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"alarm_fun_time_end");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d80 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"loudspeaker_vol_pct");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279efc = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"sound_detect_onoff");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d84 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"sound_detect_sensitivity");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d88 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"event_record_time");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d90 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"enable_event_record");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d8c = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"record_enable");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279d94 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"motion_trace");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279f44 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"motion_tracking");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279ec0 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"cry_detection_switch");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279ec4 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"humanoid_filter");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279ec8 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          bVar1 = FUN_00192cf8();
          if (bVar1) {
            puVar4 = FUN_001f7978((int)puVar3,(byte *)"jingle_mode");
            if (puVar4 != (undefined4 *)0x0) {
              DAT_00279da0 = FUN_00225a88(puVar4[6],puVar4[7]);
            }
            puVar4 = FUN_001f7978((int)puVar3,(byte *)"jingle_sound");
            if (puVar4 != (undefined4 *)0x0) {
              DAT_00279d9c = FUN_00225a88(puVar4[6],puVar4[7]);
            }
            puVar4 = FUN_001f7978((int)puVar3,(byte *)"jingle_volume");
            if (puVar4 != (undefined4 *)0x0) {
              DAT_00279d98 = FUN_00225a88(puVar4[6],puVar4[7]);
            }
            puVar4 = FUN_001f7978((int)puVar3,(byte *)"jingle_exist");
            if (puVar4 != (undefined4 *)0x0) {
              DAT_00279da4 = FUN_00225a88(puVar4[6],puVar4[7]);
            }
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"motion_area_switch");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279da8 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"motion_area");
          if (puVar4 != (undefined4 *)0x0) {
            memset(&DAT_00279dac,0,0x100);
            __src = (void *)puVar4[4];
            sVar2 = strlen((char *)puVar4[4]);
            memcpy(&DAT_00279dac,__src,sVar2);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"onvif_enable");
          if (puVar4 != (undefined4 *)0x0) {
            DAT_00279f00 = FUN_00225a88(puVar4[6],puVar4[7]);
          }
          puVar4 = FUN_001f7978((int)puVar3,(byte *)"onvif_pwd");
          if (puVar4 != (undefined4 *)0x0) {
            memset(&DAT_00279f04,0,0x40);
            strcpy((char *)&DAT_00279f04,(char *)puVar4[4]);
            sVar2 = strlen((char *)&DAT_00279f04);
            FUN_00041ba0(&DAT_00279f04,sVar2);
          }
          if (puVar3 != (undefined4 *)0x0) {
            FUN_001f4480(puVar3);
          }
          free(__s);
          uVar5 = 0;
        }
      }
    }
  }
  return uVar5;
}

그리고

사용 2번 (경고: 역시 살짝 장문)
undefined4 FUN_0000fd5c(void)
{
  bool bVar1;
  undefined4 *puVar2;
  void *pvVar3;
  char *__s;
  FILE *__stream;
  undefined4 uVar4;
  ulonglong uVar5;
  
  puVar2 = FUN_001f8384();
  if (puVar2 == (undefined4 *)0x0) {
    uVar4 = 0xffffffff;
  }
  else {
    uVar5 = FUN_00225410(DAT_00279d58);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"version",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d5c);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"sleep_mode",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d70);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"alarm_fun_onoff",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d74);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"alarm_fun_sensitivity",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d78);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"alarm_fun_mode_switch",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d7c);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"alarm_fun_time_start",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d80);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"alarm_fun_time_end",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d64);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"flip_onoff",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d60);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"light_onoff",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d6c);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"night_mode",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d84);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"sound_detect_onoff",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d88);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"sound_detect_sensitivity",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d68);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"watermark_onoff",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d90);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"event_record_time",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d8c);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"enable_event_record",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279d94);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"record_enable",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279f44);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"motion_trace",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279da8);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"motion_area_switch",(int)pvVar3);
    pvVar3 = FUN_001f82f4(&DAT_00279dac);
    FUN_001f7b54((int)puVar2,"motion_area",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279ec0);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"motion_tracking",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279ec4);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"cry_detection_switch",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279ec8);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"humanoid_filter",(int)pvVar3);
    uVar5 = FUN_00225410(DAT_00279efc);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"loudspeaker_vol_pct",(int)pvVar3);
    bVar1 = FUN_00192cf8();
    if (bVar1) {
      uVar5 = FUN_00225410(DAT_00279da0);
      pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
      FUN_001f7b54((int)puVar2,"jingle_mode",(int)pvVar3);
      uVar5 = FUN_00225410(DAT_00279d9c);
      pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
      FUN_001f7b54((int)puVar2,"jingle_sound",(int)pvVar3);
      uVar5 = FUN_00225410(DAT_00279d98);
      pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
      FUN_001f7b54((int)puVar2,"jingle_volume",(int)pvVar3);
      uVar5 = FUN_00225410(DAT_00279da4);
      pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
      FUN_001f7b54((int)puVar2,"jingle_exist",(int)pvVar3);
    }
    uVar5 = FUN_00225410(DAT_00279f00);
    pvVar3 = FUN_001f8294((uint)uVar5,(uint)(uVar5 >> 0x20));
    FUN_001f7b54((int)puVar2,"onvif_enable",(int)pvVar3);
    pvVar3 = FUN_001f82f4((char *)&DAT_00279f04);
    FUN_001f7b54((int)puVar2,"onvif_pwd",(int)pvVar3);
    __s = FUN_001f5d2c((int)puVar2);
    if (__s != (char *)0x0) {
      __stream = fopen64("/home/cfg/tuya_config.json","w+");
      if (__stream == (FILE *)0x0) {
        free(__s);
        return 0xffffffff;
      }
      fputs(__s,__stream);
      fclose(__stream);
      free(__s);
    }
    FUN_001f4480(puVar2);
    uVar4 = 0;
  }
  return uVar4;
}

…별로 읽기 좋게 되어 있진 않네요. 그래도 추리를 해서 어느 정도 이해는 가능합니다.

일단 설정 파일 tuya_config.json을 열 때, 첫 함수 콜은 fopen()을 사용하고, 해당 함수의 두 번째 파라미터에는 r+이 들어가는 것을 확인할 수 있습니다. 두 번째 함수 콜에서는 fopen()에 들어가는 파라미터가 w+인 것을 확인할 수 있고요. 즉, 첫 번째 함수는 설정 파일을 읽는 함수이고, 두 번째 함수는 설정 파일을 쓰는 함수입니다!

근데 FUN_001f7978라는 함수가 많이 호출되는데, 얘는 도대체 뭘까요?

undefined4 * FUN_001f7978(int param_1,byte *param_2)
{
  uint uVar1;
  undefined4 *local_c;
  
  if (param_1 == 0) {
    local_c = (undefined4 *)0x0;
  }
  else {
    local_c = *(undefined4 **)(param_1 + 8);
    while ((local_c != (undefined4 *)0x0 &&
           (uVar1 = FUN_001f4230((byte *)local_c[8],param_2), uVar1 != 0))) {
      local_c = (undefined4 *)*local_c;
    }
  }
  return local_c;
}

그럼 FUN_001f4230 함수는요?


uint FUN_001f4230(byte *param_1,byte *param_2)
{
  int iVar1;
  int iVar2;
  uint uVar3;
  byte *local_18;
  byte *local_14;
  
  if (param_1 == (byte *)0x0) {
    uVar3 = (uint)(param_2 != (byte *)0x0);
  }
  else {
    local_18 = param_2;
    local_14 = param_1;
    if (param_2 == (byte *)0x0) {
      uVar3 = 1;
    }
    else {
      while( true ) {
        iVar1 = tolower((uint)*local_14);
        iVar2 = tolower((uint)*local_18);
        if (iVar1 != iVar2) break;
        if (*local_14 == 0) {
          return 0;
        }
        local_14 = local_14 + 1;
        local_18 = local_18 + 1;
      }
      iVar1 = tolower((uint)*local_14);
      iVar2 = tolower((uint)*local_18);
      uVar3 = iVar1 - iVar2;
    }
  }
  return uVar3;
}

조금 변수 이름들을 바꿔주면:

uint FUN_001f4230(byte *s1,byte *s2)
{
  int c1;
  int c2;
  uint retVal;
  byte *local_18;
  byte *local_14;
  
  if (s1 == (byte *)NULL) {
    retVal = (uint)(s2 != (byte *)NULL);
  }
  else {
    local_18 = s2;
    local_14 = s1;
    if (s2 == (byte *)NULL) {
      retVal = 1;
    }
    else {
      while( true ) {
        c1 = tolower((uint)*local_14);
        c2 = tolower((uint)*local_18);
        if (c1 != c2) break;
        if (*local_14 == 0) {
          return 0;
        }
        local_14 = local_14 + 1;
        local_18 = local_18 + 1;
      }
      c1 = tolower((uint)*local_14);
      c2 = tolower((uint)*local_18);
      retVal = c1 - c2;
    }
  }
  return retVal;
}

아, 얘는 그냥 strcasecmp이네요! glibc 버전과 달리 null값 체크들이 들어가 있어, strcasecmp_nc로 이름을 바꿔줍니다. 다시 한 단계 위로 올라가서, 이름들을 조금 더 수정해주면 다음과 같은 함수 내용이 표시됩니다:

undefined4 * FUN_001f7978(int config_data,byte *config_key)
{
  uint uVar1;
  undefined4 *retVal;
  
  if (config_data == 0) {
    retVal = (undefined4 *)NULL;
  }
  else {
    retVal = *(undefined4 **)(config_data + 8);
    while ((retVal != (undefined4 *)NULL &&
           (uVar1 = strcasecmp_nc((byte *)retVal[8],config_key), uVar1 != 0))) {
      retVal = (undefined4 *)*retVal;
    }
  }
  return retVal;
}

아, 이 함수는 그냥 설정 데이터에서 키-값 쌍을 찾아주는데, 이를 위해 어떤 배열/연결 리스트를 따라가며 탐색하는 부분이네요. 이 함수 자체가 어떻게 작동하는지 전반적인 방법을 알아냈으니 그냥 find_config으로 이름을 바꾸고 한 단계 위로 올라갑니다.

그럼 FUN_00225a88은 뭘까요?

uint FUN_00225a88(uint param_1,uint param_2)
{
  uint retVal;
  int iVar1;
  uint uVar2;
  
  iVar1 = param_2 * 2 + 0x200000;
  if (param_2 * 2 < 0xffe00000) {
    if (-1 < iVar1) {
      return 0;
    }
    retVal = iVar1 >> 0x15;
    uVar2 = -retVal - 0x3e1;
    if (retVal < 0xfffffc20 && uVar2 != 0) {
      retVal = (param_2 << 0xb | 0x80000000 | param_1 >> 0x15) >> (uVar2 & 0xff);
      if ((param_2 & 0x80000000) != 0) {
        retVal = -retVal;
      }
      return retVal;
    }
  }
  else if (param_1 != 0 || (param_2 & 0xfffff) != 0) {
    return 0;
  }
  retVal = param_2 & 0x80000000;
  if (retVal == 0) {
    retVal = 0x7fffffff;
  }
  return retVal;
}

더럽네요. 이 상수들하고 특별한 의미를 가진 숫자들이 뭘 하는지 된통 알아낼 수가 없는데. LLM이 알아낼 수 있는지 보죠!

해당 함수는 IEEE 754 배정밀도 부동소수점수를 32비트 정수로 변환해줍니다.

아, 그냥 f64를 i32로 변환하는 것과 같은데 그냥 디컴파일된 C로 표현한 부분이네요. 꽤 도움이 되었습니다.

그럼 이 함수가 호출되는 부분들을 보면:

raw_config_value = find_config((int)config_data,(byte *)"onvif_enable");
if (raw_config_value != (undefined4 *)NULL) {
  DAT_00279f00 = f64_to_i32(raw_config_value[6],raw_config_value[7]);
}
raw_config_value = find_config((int)config_data,(byte *)"onvif_pwd");
if (raw_config_value != (undefined4 *)NULL) {
  memset(&DAT_00279f04,0,0x40);
  strcpy((char *)&DAT_00279f04,(char *)raw_config_value[4]);
  config_file_size = strlen((char *)&DAT_00279f04);
  FUN_00041ba0(&DAT_00279f04,config_file_size);
}

이제야 조금 더 이해가 되네요! 그러니까 이 부분 전체는 그냥 ONVIF 키-값 쌍을 설정 파일에서 불러오는 것으로 이해할 수 있습니다. 이제 DAT_00279f00이 어디에서 쓰이는지 확인해야 되는데, 이미 이 변수의 목적을 알고 있으니 ONVIF_ENABLE으로 이름을 바꿔줍니다.

ONVIF_ENABLE을 레퍼런스하는 부분들을 검색해보면 10곳이 나오게 되는데:

ONVIF_ENABLE이 레퍼런스된 부분들

하지만 ONVIF 기능을 활성화하기 위해 바이너리가 확인하는 부분만 관심이 있기 때문에 READ 컨텍스트만을 확인할 수 있습니다. 아까의 write_configuration() 함수에서의 레퍼런스를 필터에서 제외시키면 딱 두 곳에서만 호출이 검색되는데:

int FUN_00010a98(void)
{
  int iVar1;
  
  FUN_00041304(ONVIF_ENABLE);
  FUN_000788f8(ONVIF_ENABLE);
  iVar1 = FUN_0001118c(0xff,ONVIF_ENABLE);
  return iVar1;
}

int FUN_00013480(int param_1)
{
  int iVar1;
  
  if (param_1 == 0) {
    iVar1 = 0;
  }
  else {
    ONVIF_ENABLE = *(int *)(param_1 + 4);
    FUN_00041304(ONVIF_ENABLE);
    FUN_000788f8(ONVIF_ENABLE);
    write_configuration();
    iVar1 = FUN_0001118c(0xff,ONVIF_ENABLE);
  }
  return iVar1;
}

일단 FUN_00013480()에서 write_configuration()을 호출하니 이 함수는 카메라가 실행 중일때 ONVIF 기능 설정이 변경되면 호출되는 함수임을 알 수 있습니다. 첫 번째 함수인 FUN_00010a98()이 조금 더 흥미로운데, 이 함수가 뭘 호출하는지 한번 확인해보겠습니다:

int FUN_00041304(int onvif_enable)
{
  int iVar1;
  
  pthread_mutex_lock((pthread_mutex_t *)&DAT_0027b238);
  DAT_0027b1f4 = (uint)(onvif_enable != 0);
  iVar1 = pthread_mutex_unlock((pthread_mutex_t *)&DAT_0027b238);
  return iVar1;
}

int FUN_000788f8(int onvif_enable)
{
  DAT_0030c4cc = (uint)(onvif_enable != 0);
  return onvif_enable;
}

이상하게도, ONVIF 플래그값이 설정되는 데이터 위치가 두 곳 있는데, 이렇게 일단 매핑을 해 두겠습니다:

  • DAT_0030c4cc -> ONVIF_ENABLED_1
  • DAT_0027b1f4 -> ONVIF_ENABLED_2

운 나쁘게도, 여기에서 살짝 막혀버렸는데, 이 두 변수 모두 여러 변수들을 건드리는 여러 함수를 거쳐다녀, 이해하기 꽤 힘들었기 때문입니다.

막혔으니 아까 봤던 “Starting onvif server on port %s” 스트링도 확인하는 게 좋을 것 같은데, 제 (무식한) 상상으로는 거기에서 ONVIF 서버가 시작되지 않을까 추측되거든요. (가끔씩 두뇌가 이렇게 작동해주는 것도 너무 신기하네요)

void FUN_00040794(void)
{
  pthread_t __th;
  int iVar1;
  undefined4 auStack_28 [6];
  int *local_10;
  char *onvif_server_port_num_s;
  
  __th = pthread_self();
  pthread_detach(__th);
  FUN_001fae18(0x2293c8);
  onvif_server_port_num_s = "8000";
  while( true ) {
    while (iVar1 = FUN_00041358(), iVar1 != 1) {
      sleep(1);
    }
    printf("Starting onvif server on port %s\n",onvif_server_port_num_s);
    FUN_0020f62c(auStack_28,0);
    local_10 = FUN_002106bc(auStack_28,onvif_server_port_num_s,FUN_0007893c);
    if (local_10 == (int *)0x0) break;
    FUN_002157e0((int)local_10);
    do {
      FUN_00212158(auStack_28,500);
      iVar1 = FUN_00041358();
    } while (iVar1 != 0);
    FUN_0020f6e8(auStack_28);
  }
  FUN_0020f6e8(auStack_28);
  return;
}

아, 포트 8000에서 실행되네요! 그리고 아까 매핑했던 ONVIF_ENABLE_* 변수들을 참조해서, ONVIF를 활성화하는데 추가 체크가 없는 부분을 어느 정도 예측할 수 있습니다. 결국에 변환된 디컴파일 코드입니다:

void check_and_enable_onvif_server(void)
{
  pthread_t __th;
  int is_onvif_enabled;
  undefined4 auStack_28 [6];
  int *local_10;
  char *onvif_server_port_num_s;
  
  __th = pthread_self();
  pthread_detach(__th);
  FUN_001fae18(0x2293c8);
  onvif_server_port_num_s = "8000";
  while( true ) {
    while (is_onvif_enabled = get_onvif_enabled_2(), is_onvif_enabled != 1) {
      sleep(1);
    }
    printf("Starting onvif server on port %s\n",onvif_server_port_num_s);
    FUN_0020f62c(auStack_28,0);
    local_10 = start_onvif_server(auStack_28,onvif_server_port_num_s,FUN_0007893c);
    if (local_10 == (int *)NULL) break;
    FUN_002157e0((int)local_10);
    do {
      FUN_00212158(auStack_28,500);
      is_onvif_enabled = get_onvif_enabled_2();
    } while (is_onvif_enabled != 0);
    FUN_0020f6e8(auStack_28);
  }
  FUN_0020f6e8(auStack_28);
  return;
}

좋습니다. 그럼 이제 카메라가 설정 파일을 받아들이도록 어떻게 변경을 할 수 있을까요?

일단 설정 파일 tuya_config.json이 ./extractions/cctv.bin.extracted/760CCC/jffs2-root/tuya_config.json에 있는 것을 알고 있으니, 저번처럼 무식하게 펌웨어 이미지 자체를 헥스 편집하기보단, 파일을 수정하고 다시 JFFS2 파티션 이미지로 패킹해버릴 수 있을 것 같습니다.

근데 왜 헥스 편집이 제대로 동작하지 않았을까요? 왜냐하면 JFFS2는 특별한 파일시스템인데, 로그-구조 방식으로, 플래시 메모리 기반 기기들을 위해 설계된 파일시스템입니다. F2FS와 비슷한 부분인 것 같은데, 얘도 플래시 메모리 기반 기기들을 위한 파일시스템이지만, 안드로이드에서 사용되는 부분과 다른 점은 JFFS2는 하드웨어 쪽에 쓰기 분산 기술(웨어 레벨링, wear-leveling)이 없는 장치들을 위해 설계가 되었죠.

웨어 레벨링이 뭐냐고요? 요약하자면, 플래시 칩들과 SSD들은 플래시 셀로 구성되어 있습니다. 플래시 셀들은 제한된 수명이 있고 쓰기를 여러 번 반복해서 하는 것을 싫어합니다. 따라서 웨어 레벨링 장치들은 데이터를 작성할 때마다 플래시 셀들에 골고루 분포되어 수명을 최대한으로 연장한는데 초점을 둡니다.

플래시 메모리는 블록 크기로 읽거나 쓸 수 있고, JFFS2는 아까의 웨어 레벨링을 소프트웨어로 구현해내야 되기 때문에, 기존 블록에서 1바이트를 바꾸더라도 (이 경우에는 설정 옵션값이겠죠) 그냥 다시 원래 위치에 블록을 덮어쓰기할 수 없습니다. 그러면 동일 플래시 메모리 위치에 쓰게 되는 셈이니까요. 따라서 JFFS2는 플래시 메모리에 다른 블록을 작성하는데 해당 블록에다가 더 높은 버전 숫자를 매깁니다.

그리고 모든 제대로 된 파일시스템처럼, JFFS2 오류 감지를 위해 체크섬을 사용하는데, 제가 확인할 수 있는 부분으로는 32비트 CRC를 사용하는 것 같습니다. 당연히 데이터 부분만 수정을 하게 된다면 블록 전체가 손상되어 버리죠.

저번에 모든 onvif_enable이 나타나는 부분들을 바꾸었는지 기억이 나질 않는데, 별로 중요하진 않습니다. 만약 특정 블록이 잘못되어 있다면 JFFS2는 버전 숫자가 한 단계 낮은 이전 블록으로 돌아가게 됩니다. 그리고 만약 모든 블록을 수정해서 손상시켰다면, 아마 ppsapp 바이너리 자체에 있는 어떤 기본값으로 실행되지 않을까 싶습니다. 그래서 부팅 때 부팅 소리도 들렸던 부분이고요.

사실 이미지 안에서 블록의 개수도 확인할 수 있습니다:

$ grep -b -a -n "onvif_enable" extractions/cctv.bin
22803:7849741:  "onvif_enable": 0,
22857:7971273:  "onvif_enable": 0,
22932:7997857:  "onvif_enable": 0,
23041:8064473:  "onvif_enable": 0,
23169:8197025:  "onvif_enable": 0,
23195:8197893:  "onvif_enable": 0,
23227:8205921:  "onvif_enable": 0,
23315:8219209:  "onvif_enable": 0,

그렇게 다 실패 원인 분석은 좋지만, 이 글을 마칠 때쯤이면 제대로 작동하는 카메라가 필요하거든요. 그러니까 집중하고 JFFS2 이미지를 만들어보겠습니다.

cp -r extractions/cctv.bin.extracted/760CCC/jffs2-root jffs2-root_modified

이제 jffs2-root_modified/tuya_config.json안 onvif_enable을 1로 수정해줍니다.

이제 JFFS2 이미지를 다시 패킹하면 됩니다. mkfs.jffs2 콜을 실행할 때, 해당 파일이 (Anyka AK3918 때문에) 리틀 엔디안(little-endian) 형식이고, 지울 때 블록 크기가 4 KB (0x1000), 그리고 이미지 크기는 640 KB(0xA0000)인 부분을 다 지정해줘야 됩니다. 이미지 크기는 cctv.bin 펌웨어 이미지의 전체 크기가 0x800000이고 JFFS2 이미지는 0x760000부터 시작하니까요. 그리고 기존 이미지에서 grep을 실행할 수 있었으니 당연히 압축 옵션들은 비활성화 해줘야 합니다.

mkfs.jffs2 -d jffs2-root_modified -m none -e 0x1000 -p 0xA0000 -l --pad=0xA0000 -o new_jffs2.bin

하지만 이렇게 실행을 하면 다음 경고가 출력이 됩니다:

Erase size 0x1000 too small. Increasing to 8KiB minimum

원래 이미지는 지울 때의 블록 크기가 4 KB이니 당연히 문제가 되는 부분이죠. 리눅스 메일링 리스트를 검색해보다가 이 쓰레드에서 누군가가 이미 mkfs.jffs2를 패치해서 크기를 확인하는 코드를 없애는 작업을 발견했는데, 글에 따르면 잘 작동했다고 합니다.

하지만 이 확인 코드가 언제 추가되었는지 확인해보면 더 수상하고 이해가 되질 않았는데, 이미 유틸리티를 처음 만들 때부터 이 확인 코드가 존재했었습니다. mtd-utils의 Git 히스토리와 blame을 확인해 보면, 2006년 4월에 생성된 첫 커밋부터 이미 체크가 있는 것을 볼 수 있죠.

Tuya가 일부러 이 값을 0x1000으로 바꿀 이유가 있는지 이해가 되질 않았는데, 조금 더 검색해보니 공식 OpenWrt 레포지터리에서도 이 패치를 확인할 수 있었습니다. 거기에서도 크기 체크를 0x1000으로 내려버리는데, 아마 임베디드 시스템에서는 흔한가 봅니다!

어찌 됐던간에, 똑같이 패치를 적용하러 가봅시다:

git clone git://git.infradead.org/mtd-utils.git
cd mtd-utils
git checkout v2.3.1

패치를 다운받고 적용시켜 줍니다:

index da07b69..9a60129 100644
--- a/jffsX-utils/mkfs.jffs2.c
+++ b/jffsX-utils/mkfs.jffs2.c
@@ -1668,11 +1668,11 @@ int main(int argc, char **argv)
                                                  }
                                                  erase_block_size *= units;

-                                                 /* If it's less than 8KiB, they're not allowed */
-                                                 if (erase_block_size < 0x2000) {
-                                                         fprintf(stderr, "Erase size 0x%x too small. Increasing to 8KiB minimum\n",
+                                                 /* If it's less than 4KiB, they're not allowed */
+                                                 if (erase_block_size < 0x1000) {
+                                                         fprintf(stderr, "Erase size 0x%x too small. Increasing to 4KiB minimum\n",
                                                                          erase_block_size);
-                                                         erase_block_size = 0x2000;
+                                                         erase_block_size = 0x1000;
                                                  }
                                                  break;
                                          }

이제 mkfs.jffs2를 빌드해줍니다:

./autogen.sh
./configure --without-ubifs
make -j$(nproc) mkfs.jffs2

이제 방금 만들었던 mkfs.jffs2 바이너리를 사용해서 이미지를 만들어보겠습니다:

cd ..
./mtd-utils/mkfs.jffs2 -d jffs2-root_modified -m none -e 0x1000 -p 0xA0000 -l --pad=0xA0000 -o new_jffs2.bin

그리고 원래 펌웨어 이미지에 집어넣을 수 있죠:

cp extractions/cctv.bin cctv_modified.bin
dd if=new_jffs2.bin of=cctv_modified.bin bs=1 seek=$((0x760000)) conv=notrunc

새로운 이미지가 준비되었으니, CH341A를 준비하고, flashrom으로 플래싱하고, 와이파이에 연결하고, 확인해보면…

동작하질 않습니다?

답답하네요. 알고 보니까, ONVIF 서버를 활성화하던 함수 자체가 다른 곳에서 레퍼런스 되질 않았습니다. 적어도 Ghidra가 얘기하기를요. RTSP 스트림을 활성화하는 함수도 마찬가지였고요.

아예 실행되지도 않는 코드를 리버싱하고 있었다니.

근데 생각해보니 이 기기에 아무거나 플래싱해서 집어넣을 수 있었죠? 그럼 처음 기기가 초기화될 때 telnetd가 실행되도록 수정해봅시다:

./extractions/cctv.bin.extracted/370000/initrun.sh:

#!/bin/sh

BASE_DIR=/opt/pps
if [ -e /tmp/PPStrong.runpath ]; then
        BASE_DIR=`cat /tmp/PPStrong.runpath`
fi

if [ ! -e ${BASE_DIR} ]; then
        exit
fi

tar xzf ${BASE_DIR}/app.tar.gz -C /
umount ${BASE_DIR}

# telnet 실행
telnetd -l /bin/sh &

home/init.d/initS &

그 다음에 이미지를 다시 패킹해줍니다:

cd ./extractions/cctv.bin.extracted/370000/
mkdir tmp
cp initrun.sh tmp
cp app.tar.gz tmp
mkfs.cramfs tmp cramfs_new.bin

또 펌웨어 이미지에 집어넣고요:

dd if=cramfs_new.bin of=cctv.bin bs=1 seek=$((0x370000)) conv=notrunc

마지막으로 플래시. 제대로 실행될까요?

> nmap -p 23 192.168.8.11
Starting Nmap 7.98 ( https://nmap.org ) at 2026-09-13 16:24 -0400
Nmap scan report for 192.168.8.11
Host is up (0.038s latency).

PORT     STATE  SERVICE
23/tcp   closed telnet
MAC Address: B4:FB:E3:C2:11:BC (AltoBeam (China))

Nmap done: 1 IP address (1 host up) scanned in 2.26 seconds

왜?!

조금 더 찾아보니, 카메라에 내장된 busybox 바이너리에 telnetd가 제외된 체로 컴파일 되어 있었습니다.

이쯤되니, 인터넷에 검색을 해서 혹시 이미 다른 개발자가 이 카메라 종류를 발견하고 작업한 적이 있는지 찾아보게 되었습니다. Tuya라는 플랫폼 자체가 제품이 하도 다양한데, 설마 이걸 한번이라도 사전에 시도한 분이 있겠죠.

당연히 있죠:

그래서 @guino님이 이걸 읽고 계신다면, 감사합니다.

ppsapp 바이너리에 돌아가서, 그 분이 작성한 바와 똑같이 onvif_enable과 관련된 모든 콜들을 1로 패치하기로 했습니다. 게다가 추가로 재미삼은 김에, 모든 onvif_enable 변수와 관련된 체크들을 전부 다 패치해버려 언제나 ONVIF 서버가 실행되도록 만들었습니다. 이걸 위해 관련된 명령문들을 전부 nop으로 변경해버렸는데, 대체하는 과정이 꽤 만족스러웠습니다.

이제 이걸 다시 원 펌웨어에 패키징하는 부분은 이전보다 조금 더 복잡한데, ppsapp 바이너리는 app.tar.gz 안에 있고, 그건 CramFS 파티션 내부에 있고, 한 겹 나가서 cctv.bin 펌웨어에 내장되어 있고요. 그래서 결국 새 번이나 패키징을 해야 합니다.

(아래 명령문을 실행할 때 이미 수정된 바이너리를 폴더에 복사했다고 가정하겠습니다…)

cd ./extractions/new_wifi_mod.bin.extracted/370000/app.tar.gz.extracted/0/decompressed.bin.extracted.modified/0
tar -czf ../../../../new_app.tar.gz bin etc home
cd ../../../../
mkdir tmp
cp new_app.tar.gz tmp/app.tar.gz
cp initrun.sh tmp
mkfs.cramfs tmp new_cramfs.bin
cd ../../../
cp new_wifi_mod.bin third_try.bin
dd if=new_cramfs.bin of=third_try.bin bs=1 seek=$((0x370000)) conv=notrunc

운 나쁘게도, 이렇게 만든 이미지를 플래싱하고 나니, 카메라가 제대로 동작하지 않았고 아예 Wi-Fi에 연결조차 되지 않았습니다. 예전에 계속 켜져 있전 파란색 LED도 이전과 다르게 계속 점멸하는 것을 그치지 않았죠.

여기에 시간을 몇 주를 쏟았습니다. 이것저것 많이 시도해봤는데, 보드 상에 UART 패드들이 보여서 여기에다가 시리얼 모니터를 연결해보기로 했습니다. 그래서 남아돌던 Arduino 보드를 가져와서 중간에 연결시켜줬는데, 콘솔에 아무런 출력이 나오지 않았습니다.

갑자기 든 생각에, 멀티미터를 꺼내서 리드선을 연결해봤는데 패드에서 3.3V가 읽히는 것을 보고 깜짝 놀라버렸습니다. Arduino Uno는 5V로 동작하지. 아차.

다행히도, 보드 자체는 멀쩡했는데, USB 로직 분석기를 사용해서 패드들이 UART 패드가 맞는지부터 확인하기로 했습니다. 그리고 PulseView에서 확인하기를, UART가 맞았죠:

Pulseview 덤프 스크린샷

그래서 제대로 여러 전압 레벨을 지원하는 USB-TTL 변환기를 구매했습니다. 부팅 과정에서 나타나는 로그에서 왜 초기 부팅이 실패하는지 나오기를 기대했죠.

UART 패드에 납땜된 케이블들

…네, 마지막 패드에 납이 하도 안 붙어서 결국에 남은 나사 구멍에다가 접지선을 연결했습니다. 적어도 접지라 다행이지, TX하고 RX가 안 붙었다면 어떻게 했을지 감도 안 잡히네요.

하지만 카메라를 부팅했을때도 별 성과가 없었습니다. 다음과 같은 출력이 나오고 나서, 그냥… 멈춰버렸죠.

U-Boot 2013.10.0-AK_V3.0.07 (Jan 22 2021 - 14:16:01)

DRAM:  64 MiB
8 MiB
ANYKA SDHC/MMC4.0: 0
PPS:Jan 22 2021 14:16:03   anyka_c3:  3  2  1  0 
magic err
magic err
command:setenv bootargs mem=64M console=ttySAK0,115200n8 loglevel=10   mtdparts=spi0.0:256k(bld),64k(env),64k(enc),64k(sysflg),3m(sys),4032k(app),640k(cfg) ppsAppParts=5 ip=192.168.1.99:::255.255.255.0 eth=00:55:7b:b5:7d:f7
## Booting kernel from Legacy Image at 81c08000 ...
   Image Name:   Linux-3.4.35
   Image Type:   ARM Linux Kernel Image (uncompressed)
   Data Size:    2803328 Bytes = 2.7 MiB
   Load Address: 81c08000
   Entry Point:  81c08040
   Verifying Checksum ... OK
   XIP Kernel Image ... OK

Starting kernel ...

Uncompressing Linux... done, booting the kernel.
Anyka Linux Kernel Version: 1.1.12

그래서 카메라의 전원을 뺐다 다시 인가하고, 엔터키를 연타하니 다음 비밀번호 입력 문구가 뜨게 되었습니다:

ANYKA SDHC/MMC4.0: 0
PPS:Jan 22 2021 14:16:03   anyka_c3:  3  2  0 

please input password::

흠. 여기서 어떻게 나아갈 수 있을까요?

몇 번 비밀번호를 맞춰봤는데, 계속 틀려 나아가지를 못했습니다. 그리고 틀릴 때마다 보드가 자동으로 리셋되고 재부팅되었죠.

한번 추출된 펌웨어에서 U-Boot 영역을 확인하고 비밀번호가 어디에 저장되고 어떻게 비교될까 확인하는 것을 고민해봤습니다. 하지만 그 부분이 그렇게 흥미롭진 않을 것 같아서, 한번 LLM에 던져보기로 했습니다.

Claude는 바로 일하기 싫다고 못박았죠:

Claude 거부 문구

회사들이 하도 “도덕적인 책임감” 얘기를 지껄이면서 모델 사용에 대한 잣대를 펼쳐 AI 거품이 터진다면 웃길 것 같습니다.

알겠어요. 미국이 절 버렸으니 중국한테 청해야죠. 그래서 40초 컷에 망해버린 Claude 구독에 대해 환불을 신청해버렸고, OpenRouter 계정을 만들어 크레딧을 충전하고 API 키를 생성했습니다. 그 다음 OpenCode에다가 연결을 한 다음, Deepseek V4.1 Flash 1017 모델을 선택하고, 펌웨어 바이너리 파일이 어디에 있는지와 U-Boot에서 얻은 비밀번호 문구를 프롬프트로 넣었습니다.

약 3분 정도 돌아가다가, 답을 내주었죠:

Yes — it’s in the firmware dump.

U-Boot password: pps_password

그래서 곧바로 시도해봤는데, U-Boot 환경에 들어갈 수 있었습니다!

그렇지만 살짝 반칙을 쓴 것 같은 느낌이 들었습니다. 그리고 어떻게 보면 쓴 것과 다름없죠. LLM한테 이걸 맡김으로서 어떤 배움의 기회를 놓쳤는지 궁금해, LLM이 “생각”한 부분을 확장해서 어떻게 그 출력값을 발견해냈는지 확인했습니다:

The auth routine is at 0x80E07000; it prints please input password:: and compares your input with a string at 0x80E244A8 (file offset 0x246A8).

That string isn’t stored as plaintext — it’s obfuscated with a bitwise NOT. Raw bytes: 8f 8f 8c a0 8f 9e 8c 8c 88 90 8d 9b ff

Inverted gives: pps_password\0

아마도 이걸 스스로 풀 수 있었을지도요? 아니면 못했을 수도? 여러 번 머리를 싸매지 않고서야 비밀번호가 난독화 과정을 풀 수 있었을지 모르겠습니다. 개인적으로는 이 부분이 그렇게 흥미롭진 않더라고요. 흥미로운 부분은 이 카메라가 실제로 통신하게 만드는 부분이죠.

다시 LLM에 물어봐서 ppsapp 바이너리가 어떻게 하면 로그를 더 출력할지 물어보니, 커널 부팅 옵션들에다가 ignore_loglevel printk.time=1 ppsdebug=onshow를 추가하라고 답변해줬습니다. 그럼.

loadk
printenv bootargs
setenv bootargs `mem=64M console=ttySAK0,115200n8 ignore_loglevel printk.time=1 ppsdebug=onshow`
bootm 0x81C08000

(이때 loadk가 console=ttySAK0,115200n8 줄 다음에 플래시 메모리의 파티션 구조처럼 더 많은 파라미터를 추가하는데, 이를 ppsdebug=onshow 다음으로 복붙해야 합니다. log_level 옵션은 아예 빼버려야 했는데, 안 그러면 U-Boot에서 명령문이 너무 많다고 오류를 줬거든요.)

그제서야 더 많은 로그 출력을 확인할 수 있었습니다. Wi-Fi 인증을 하는 부분을 조금 더 자세하게 보면:

wpa_supplicant service ... [stop] ok
wpa_supplicant service ... [start]
[   21.676261] [atbm_log]:br0_netdev_open()-1116: dev_get_by_name(br0) failed2!wpa_supplicant: can't load library 'libnl-3.so.200'
wpa_supplicant service ... [start] ok
osd set init ok--------------->ak_isp_set_user_params_do 1458, set main osd mem attr

[   22.730306] ak_isp_set_user_params_do 1473 chn:0, paddr:80dbd2a0 size:50400 
[01-01 18:12:17:845 TUYA Err][gw_intf.c:598] wf_station_connect error:-1
[01-01 18:12:17:845 TUYA Notice][gw_intf.c:466] stop smt cfg mthd:0
chan:0  set rect ok x:60,y:60,w:[   22.757244] ak_isp_set_user_params_do 1509 paddr:80dca280 size:5600 
840,h:60,_,w:1920,h:1080
[   22.774108] [atbm_log]:ieee80211_netdev_ioctrl:cmd err
[   22.779634] [atbm_log]:ieee80211_netdev_ioctrl:cmd err

흠. 라이브러리가 없다고요?

마지막 이미지를 다시 패킹할 때 사용했던 이 명령 기억하시나요?

tar -czf ../../../../new_app.tar.gz bin etc home

ls를 실행해서 어떤 폴더들이 있는지 확인해보면?

[ericswpark@stanley 0]$ ls
bin  etc  home  lib

아… 이런. 이 부분은 못 본 척하고 다시 해보죠.

새 펌웨어 이미지를 플래싱하고 나서, 이제서야 뭔가 보이기 시작하네요! 카메라가 다시 WiFi 네트워크에 보이기 시작했고, 내트워크 스캔을 하니 ONVIF 서버를 위한 포트 8000이 열려 있는 것을 확인할 수 있었습니다. 하지만 @guino님이 적은대로 admin:admin 아이디 비밀번호 조합을 사용해서 접속을 시도해보았지만, 서버에서 응답을 보내주질 않았습니다.

온라인에 검색해보니, Tuya 카메라들이 SDK 서버로의 접속을 막아버리면 동작을 멈추는 점을 알게 되었습니다. 저도 인터넷에 기기가 연결하는 것이 꺼림칙해서 이전에 미리 접속을 막아 두었죠.

그래서 한번 더 Deepseek에 ppsapp 바이너리에서 어디에서 온라인 연결 체크를 진행하는지 물어봤는데, 다음과 같은 상대 주소(Offset)들을 찾아주었습니다:

상대 주소 이전 이전의 명령문 이후 이후의 명령문
0x78980 00 00 00 0A beq 0x78988 00 00 00 EA b 0x78988
0x40E0C 00 48 2D E9 stmdb sp!,{r11,lr} 00 00 A0 E3 mov r0,#0x0
0x40E10 04 B0 8D E2 add r11,sp,#0x4 1E FF 2F E1 bx lr

해당 패치들이 왜 중요한지 파악하기 위해 Ghidra에서의 디컴파일을 확인해보았습니다. 첫 번째 패치는 ONVIF 서버를 위한 건데, Ghidra의 디컴파일에서는 함수 FUN_0007893c의 함수 본문을 패치하는 부분이었습니다. 해당 패치로 인해 디컴파일된 코드에서 else 블록이 제거되었는데, 아마 Tuya 서버에 연락이 안 닿을 때 실행되는 부분인 것 같습니다.

나머지 두 패치들은 조금 더 흥미로웠는데, 함수 FUN_00040E0C의 본문을 그냥 다음과 같이 바꿔버렸습니다:

undefined4 FUN_00040E0C(void)
{
    return 0;
}

여기에서 배운 게 있다면, 아마 x86-64의 ret에 대응되는 ARM의 명령어는 bx lr라는 걸 처음으로 알아냈습니다.

혹시 LLM이 연결 체크가 어떻게 진행되었는지에 대한 채팅 내역이 궁금하시다면, 아래를 눌러 내역을 확인하실 수 있습니다:

클릭해서 LLM 채팅 내역 열기

The ONVIF HTTP handler is FUN_0007893c (vaddr 0x7893c). Before it parses or answers any request, it does this:

if (*ONVIF_ENABLED_1 != 0) {
   if (FUN_00019f50c() != 0) {
       return;              // <-- silently drops the request
   }
   if (event != 0x64) return;
   ... handle HTTP / ONVIF ...
}

FUN_00019f50c reads a global network/cloud state at 0x344230 and returns nonzero for states 1, 2, and 4:

  • 0 → proceed
  • 1 → blocked
  • 2 → blocked
  • 3 → proceed
  • 4 → blocked
  • 5 → proceed

That state is maintained by a Tuya SDK network-monitor task (FUN_00019fbcc → FUN_00019fa4c) driven by the Wi-Fi and cloud-status callbacks. With internet blocked, the camera can get an IP and listen on 8000, but it never transitions to a “ready” state — so the handler returns before sending a single byte.

수정된 펌웨어 이미지를 다시 패키징하고 빌드한 다음에 장비에 플래시를 한 후, nmap을 한번 더 돌려보니:

> nmap -p 8000,8554 192.168.8.11
Starting Nmap 7.993 ( https://nmap.org ) at 2026-11-05 21:24 -0500
Nmap scan report for 101846353.lan (192.168.8.11)
Host is up (0.085s latency).

PORT     STATE  SERVICE
8000/tcp open   http-alt
8554/tcp open   rtsp-alt

Nmap done: 1 IP address (1 host up) scanned in 0.21 seconds

그리고 RTSP 스트림을 확인해봤을때…

카메라에서의 RTSP 스트림!

마치면서

이걸 시도하면서 많은 것을 배웠습니다. 하지만 동시에, 끝부분이 그렇게 만족스럽지는 않군요.

어떻게 보면, ARM 어셈블리어를 일고 Ghidra를 통해서 리버스 엔지니어링을 수동으로 다 하는 부분을 아직까진 몰라 LLM에 너무 의존하면서 작업을 한 것 같기도 하거든요.

하지만, 다르게 본다면, 압축 풀고, 검색하고, 납땜하고, 패키징하고, 압축하고, 플래싱하고, 조사하면서 시간을 많이 투자하느라 더욱 더 많이 배운 것 같습니다. 사실 이 블로그 글 자체도 9월의 둘째주부터 시작했는데, 물론 동시에 회사에서 출근하느라 작업이 늦어진 것도 있지만, 두 달동안 매달리면서 작업한 것들은 아깝다고 생각되지는 않습니다.

그리고 LLM이 이러한 작업들을 더 쉽게 해주는 것도 좋다고 생각하는데, 물론 작업을 전부 LLM한테 던저주고 패치와 재압축을 다 진행하여 끝에 완성된 펌웨어 이미지를 받아내는 유혹도 불러오지만 (그리고 실제로 그렇게 맡겨줬을 때 할 수 있을 것 같은 생각이 들긴 합니다), 그렇게 하지 않고 실제로 중간 과정을 거쳐가는 것도 많이 도움이 된다고 생각합니다.

그리고, 궁금하신 분들을 위해, Deepseek 모델을 구동하는데 든 총 비용은 22센트였습니다. 네, $0.22요. (한화 약 296원) 비싼 구독을 하거나 Altman과 Amodei한테 영혼을 팔아먹지 않아도 되죠.

다음에도 다시 하겠냐고요? 이런 종류의 카메라는 안 하겠죠. 이전 블로그 글에서도 언급했듯이, 친척이 줬던 폐기용 무료 카메라기도 하고, 이젠 Tuya에서 이런 쓰레기를 구매하지 않는 것을 알았으니까요. 그리고 시중에 이것보다 훨씬 더 쉽게 기능을 활성화할 수 있는 카메라들도 저렴하게 널려 있으니까요! 그냥 할 수 있는지 궁금하고 해보고 싶어서 해봤습니다.