技术 · 2024年11月15日

华为仓颉语言逆向初探

前言

仓颉编程语言是华为自主研发的通用编程语言,是鸿蒙原生应用的主要编程语言之一。正巧碰上仓颉公测,笔者决定从逆向的角度来探索仓颉的一些特性,以下是作者在逆向分析仓颉时的一些经验和技巧。

从第一个 Hello World 开始逆向

下载仓颉语言及其教程

仓颉语言官网 可以下载编译环境并查看仓颉代码的编写教程。同时,参考 白皮书与教程 也非常有帮助。

一个简单的 Hello World

// hello.cj
main() {
    println("flag{ezcangjie}")
}

接下来进行编译:

cjc hello.cj -o hello.exe

我们得到了一个简单的 Hello World 程序。将其拖入 IDA 9.0 进行逆向分析,初步了解其结构。

一开始可以看到两个函数 mainCRTStartupWinMainCRTStartup

__int64 WinMainCRTStartup()
{
  unk_140080210 = 1;
  return _tmainCRTStartup();
}
__int64 mainCRTStartup()
{
  unk_140080210 = 0;
  return _tmainCRTStartup();
}

然后会进入 _tmainCRTStartup

_initenv = envp;
result = main(argc, (const char **)argv, (const char **)envp);
mainret = result;
if (!managedapp)
    exit(result);
if (!has_cctor)
{
    cexit();
    return mainret;
}
return result;
}

这里在 result = main(argc, (const char **)argv, (const char **)envp); 处进入 main 函数:

int __fastcall main(int argc, const char **argv, const char **envp)
{
    sub_140035ED0(argc, argv, envp);
    CJ_MRT_CjRuntimeInit();
    CJ_MRT_SetCommandLineArgs((unsigned int)argc, argv);
    return CJ_MRT_CjRuntimeStart(cj_entry_);
}

接下来进入 cj_entry_

if (__int64(&v2) <= *(_QWORD *)(v0 + 40))
    CJ_MCC_StackGrowStub();
if (*(_QWORD *)(v0 + 48))
    CJ_MCC_Safepoint();
CJ_MCC_CheckThreadLocalDataOffset();
default_global_init_();
v6 = user_main();
v5 = v6;
std.core::CJ_CORE_ExecAtexitCallbacks(v7);
v3 = v5 < (__int64)0xFFFFFFFF80000000uLL;
v4 = v5;
if (v5 > 0x7FFFFFFF || v5 < (__int64)0xFFFFFFFF80000000uLL)
{
    v4 = 0xFFFFFFFF80000000uLL;
    if (!v3)
        return 0x7FFFFFFFLL;
}
return v4;
}

进入 user_main,最终到达主函数:

v6 = user_main();
...
  if ( (__int64)&v3 <= *(_QWORD *)(v1 + 40) )
    CJ_MCC_StackGrowStub();
  v4 = a1;
  if ( *(_QWORD *)(v1 + 48) )
    CJ_MCC_Safepoint();
  std.core::println(v5, 0LL, &_const_cjstring_0);
  return v4;
}


__int64 __fastcall default::_main_(__int64 a1)
{
    __int64 v1; // r15
    __int64 v3; // [rsp+0h] [rbp-30h] BYREF
    __int64 v4; // [rsp+20h] [rbp-10h]
    _BYTE v5[8]; // [rsp+28h] [rbp-8h] BYREF

    if (__int64(&v3) <= *(_QWORD *)(v1 + 40))
        CJ_MCC_StackGrowStub();
    v4 = a1;
    if (*(_QWORD *)(v1 + 48))
        CJ_MCC_Safepoint();
    std.core::println(v5, 0LL, &_const_cjstring_0);
    return v4;
}

我们也可以在字符串搜索中找到需要的字符串:

.rdata:000000014007409B db 66h ; f
.rdata:000000014007409C db 6Ch ; l
.rdata:000000014007409D db 61h ; a
.rdata:000000014007409E db 67h ; g
.rdata:000000014007409F db 7Bh ; {
.rdata:00000001400740A0 db 65h ; e
.rdata:00000001400740A1 db 7Ah ; z
.rdata:00000001400740A2 db 63h ; c
.rdata:00000001400740A3 db 61h ; a
.rdata:00000001400740A4 db 6Eh ; n
.rdata:00000001400740A5 db 67h ; g
.rdata:00000001400740A6 db 6Ah ; j
.rdata:00000001400740A7 db 69h ; i
.rdata:00000001400740A8 db 7Eh ; }

关于仓颉编译自带的字符串混淆

根据仓颉白皮书,字符串混淆功能会识别代码中的明文字符串,将其加密保存。程序初始化时会解密字符串,再执行程序逻辑。因此,外部攻击者无法直接从程序文件中获取明文字符串,只能看到加密后的数据,从而无法根据字符串信息推测代码逻辑。

在实战中发现,字符串混淆似乎只是与 0x26 进行异或。虽然可以自行修改,但单字节异或的混淆效果有限。接下来我们对字符串混淆过的程序进行逆向。

实例操作

我们发现直接搜索字符串是不可行的,但可以分析函数到字符串位置,轻松找到混淆函数。其逻辑是一个 0x26 的异或操作:

__int64 string_decode77193E0F156E()
{
    unsigned int v0; // ecx
    __int64 result; // rax
    unsigned int vars4; // [rsp+4h] [rbp+4h]

    vars4 = 0;
    do
    {
        v0 = vars4;
        *((_BYTE *)&_const_cjstring_data_0 + (int)vars4 + 16) ^= 0x26u;
        result = ++vars4;
    }
    while (v0 < 0x40);
    return result;
}

通过这一行定位到相应位置:

db  41h ; A
.data:00000001400E6649                 db    0
.data:00000001400E664A                 db    0
.data:00000001400E664B                 db    0
.data:00000001400E664C                 db    0
.data:00000001400E664D                 db    0
.data:00000001400E664E                 db    0
.data:00000001400E664F                 db    0
.data:00000001400E6650                 db    6
.data:00000001400E6651                 db    6
.data:00000001400E6652                 db    6
.data:00000001400E6653                 db    6
.data:00000001400E6654                 db  69h ; i
.data:00000001400E6655                 db  53h ; S
.data:00000001400E6656                 db  52h ; R
.data:00000001400E6657                 db    6
.data:00000001400E6658                 db  49h ; I
.data:00000001400E6659                 db  40h ; @
.data:00000001400E665A                 db    6
.data:00000001400E665B                 db  4Bh ; K
.data:00000001400E665C                 db  43h ; C
.data:00000001400E665D                 db  4Bh ; K
.data:00000001400E665E                 db  49h ; I
.data:00000001400E665F                 db  54h ; T
.data:00000001400E6660                 db  5Fh ; _
.data:00000001400E6661                 db  67h ; g
.data:00000001400E6662                 db  48h ; H
.data:00000001400E6663                 db    6
.data:00000001400E6664                 db  43h ; C
.data:00000001400E6665                 db  5Eh ; ^
.data:00000001400E6666                 db  45h ; E
.data:00000001400E6667                 db  43h ; C
.data:00000001400E6668                 db  56h ; V
.data:00000001400E6669                 db  52h ; R
.data:00000001400E666A                 db  4Fh ; O
.data:00000001400E666B                 db  49h ; I
.data:00000001400E666C                 db  48h ; H
.data:00000001400E666D                 db    6
.data:00000001400E666E                 db  4Eh ; N
.data:00000001400E666F                 db  47h ; G
.data:00000001400E6670                 db  55h ; U
.data:00000001400E6671                 db    6
.data:00000001400E6672                 db  49h ; I
.data:00000001400E6673                 db  45h ; E
.data:00000001400E6674                 db  45h ; E
.data:00000001400E6675                 db  53h ; S
.data:00000001400E6676                 db  54h ; T
.data:00000001400E6677                 db  54h ; T
.data:00000001400E6678                 db  43h ; C
.data:00000001400E6679                 db  42h ; B
.data:00000001400E667A                 db  1Ch
.data:00000001400E667B                 db  60h ; `
.data:00000001400E667C                 db  67h ; g
.data:00000001400E667D                 db  6Dh ; m
.data:00000001400E667E                 db  63h ; c
.data:00000001400E667F                 db    6
.data:00000001400E6680                 db  60h ; `
.data:00000001400E6681                 db  6Dh ; m
.data:00000001400E6682                 db  67h ; g
.data:00000001400E6683                 db  63h ; c
.data:00000001400E6684                 db  45h ; E
.data:00000001400E6685                 db  47h ; G
.data:00000001400E6686                 db  48h ; H
.data:00000001400E6687                 db  41h ; A
.data:00000001400E6688                 db  43h ; C
.data:00000001400E6689                 db  5Ch ; \
.data:00000001400E668A                 db  40h ; @
.data:00000001400E668B                 db  4Ah ; J
.data:00000001400E668C                 db  47h ; G
.data:00000001400E668D                 db  41h ; A
.data:00000001400E668E                 db  4Ch ; L
.data:00000001400E668F                 db  4Fh ; O
.data:00000001400E6690                 db  43h ; C

提取密文,然后使用 CyberChef 解密得到明文:

  0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 
  0x06, 0x06, 0x69, 0x53, 0x52, 0x06, 0x49, 0x40, 0x06, 0x4B, 
  0x43, 0x4B, 0x49, 0x54, 0x5F, 0x67, 0x48, 0x06, 0x43, 0x5E, 
  0x45, 0x43, 0x56, 0x52, 0x4F, 0x49, 0x48, 0x06, 0x4E, 0x47, 
  0x55, 0x06, 0x49, 0x45, 0x45, 0x53, 0x54, 0x54, 0x43, 0x42, 
  0x1C, 0x60, 0x67, 0x6D, 0x63, 0x06, 0x60, 0x6D, 0x67, 0x63, 
  0x45, 0x47, 0x48, 0x41, 0x43, 0x5C, 0x40, 0x4A, 0x47, 0x41, 
  0x4C, 0x4F, 0x43, 0x00, 0x00, 0x00
  g&&&&&&&    Out of memoryAn exception has occurred:FAKE FKAEcangezflagjie&&&

python脚本:

data = [
    0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x06, 0x06, 0x06, 0x06, 0x69, 0x53, 0x52, 0x06,
    0x49, 0x40, 0x06, 0x4B, 0x43, 0x4B, 0x49, 0x54,
    0x5F, 0x67, 0x48, 0x06, 0x43, 0x5E, 0x45, 0x43,
    0x56, 0x52, 0x4F, 0x49, 0x48, 0x06, 0x4E, 0x47,
    0x55, 0x06, 0x49, 0x45, 0x45, 0x53, 0x54, 0x54,
    0x43, 0x42, 0x1C, 0x60, 0x67, 0x6D, 0x63, 0x06,
    0x60, 0x6D, 0x67, 0x63, 0x45, 0x47, 0x48, 0x41,
    0x43, 0x5C, 0x40, 0x4A, 0x47, 0x41, 0x4C, 0x4F,
    0x43, 0x00, 0x00, 0x00
]
xor_value = 0x26

result = ''.join(chr(byte ^ xor_value) for byte in data)
print(result)

成功获得字符串。可以说,如果在实战中盲目相信仓颉自带的字符串混淆,可能会被有经验的人轻松破解。这个功能目前显得有些鸡肋。

另一种仓颉字符串混淆处理方式

根据仓颉的白皮书,这种混淆在运行时会自动解密,类似于 SMC。我们只需在对应字符串处下断点,进行动态调试即可。不过需要注意的是,仓颉的运行需要配备一些仓颉库。如果题目没有提供相应的 DLL,可以在官方提供的编译库中查找,常用的有 libcangjie-runtimelibsecurec

去符号的仓颉程序逆向实例

首先我们找一份没有去符号的仓颉进行对比,熟悉仓颉结构的话就不需要了。
IDA可以找到start 直接继续跟进即可

// write access to const memory has been detected, the output may be wrong!
__int64 fun4()
{
  PVOID StackBase; // rsi
  signed __int64 v1; // rax
  int v2; // edi
  __int64 v3; // rbp
  size_t v4; // r12
  _QWORD *v5; // rax
  __int64 v6; // r13
  __int64 v7; // rdi
  __int64 i; // rbx
  size_t v9; // rsi
  void *v10; // rax
  const void *v11; // rdx
  __int64 result; // rax

  StackBase = NtCurrentTeb()->NtTib.StackBase;
  while ( 1 )
  {
    v1 = _InterlockedCompareExchange64(&qword_1401B0650, (signed __int64)StackBase, 0LL);
    if ( !v1 )
    {
      v2 = 0;
      if ( unk_1401B0658 == 1 )
        goto LABEL_20;
      goto LABEL_6;
    }
    if ( StackBase == (PVOID)v1 )
      break;
    Sleep(0x3E8u);
  }
  v2 = 1;
  if ( unk_1401B0658 == 1 )
  {
LABEL_20:
    amsg_exit(31LL);
    if ( unk_1401B0658 == 1 )
      goto LABEL_21;
LABEL_9:
    if ( v2 )
      goto LABEL_10;
    goto LABEL_22;
  }
LABEL_6:
  if ( unk_1401B0658 )
  {
    dword_1401B0008 = 1;
  }
  else
  {
    unk_1401B0658 = 1;
    initterm(off_1401B5018, qword_1401B5028);
  }
  if ( unk_1401B0658 != 1 )
    goto LABEL_9;
LABEL_21:
  initterm(off_1401B5000, &qword_1401B5010);
  unk_1401B0658 = 2;
  if ( v2 )
    goto LABEL_10;
LABEL_22:
  _InterlockedExchange64(&qword_1401B0650, 0LL);
LABEL_10:
  if ( TlsCallback_0 )
    TlsCallback_0(0LL, 2LL, 0LL);
  sub_1400C4440();
  qword_1401B06D0 = (__int64)SetUnhandledExceptionFilter(TopLevelExceptionFilter);
  sub_1400C3E40((__int64)nullsub_1);
  sub_1400C4250();
  v3 = dword_1401B0028;
  v4 = 8LL * (dword_1401B0028 + 1);
  v5 = malloc(v4);
  v6 = qword_1401B0020;
  v7 = (__int64)v5;
  if ( (int)v3 > 0 )
  {
    for ( i = 0LL; i != v3; ++i )
    {
      v9 = strlen(*(const char **)(v6 + 8 * i)) + 1;
      v10 = malloc(v9);
      *(_QWORD *)(v7 + 8 * i) = v10;
      v11 = *(const void **)(v6 + 8 * i);
      memcpy(v10, v11, v9);
    }
    v5 = (_QWORD *)(v7 + v4 - 8);
  }
  *v5 = 0LL;
  qword_1401B0020 = v7;
  sub_1400C4050();
  _initenv = qword_1401B0018;
  result = fun3(dword_1401B0028, qword_1401B0020);
  dword_1401B0010 = result;
  if ( !dword_1401B000C )
    exit(result);
  if ( !dword_1401B0008 )
  {
    cexit();
    return (unsigned int)dword_1401B0010;
  }
  return result;
}

在_initenv下我们可以看到一个 result = fun3(dword_1401B0028, qword_1401B0020);这里的fun3是我后来标上去的,我们可以通过这两行代码的一个特征来快速定位。

return CJ_MRT_CjRuntimeStart(fun2);//他其实就是上文提到的return CJ_MRT_CjRuntimeStart(cj_entry_);

继续跟进

__int64 __fastcall fun2(__int64 a1, __int64 a2, __int64 a3)
{
  __int64 v3; // r15
  const char **v4; // rdx
  __int64 v5; // rcx
  const char **v6; // r8
  __int64 v8; // [rsp+0h] [rbp-60h] BYREF
  bool v9; // [rsp+37h] [rbp-29h]
  unsigned __int64 v10; // [rsp+38h] [rbp-28h]
  __int64 v11; // [rsp+40h] [rbp-20h]
  __int64 v12; // [rsp+48h] [rbp-18h]
  _BYTE v13[8]; // [rsp+58h] [rbp-8h] BYREF

  if ( (__int64)&v8 <= *(_QWORD *)(v3 + 40) )
    CJ_MCC_StackGrowStub(a1, a2, a3);
  if ( *(_QWORD *)(v3 + 48) )
    CJ_MCC_Safepoint(a1);
  CJ_MCC_CheckThreadLocalDataOffset();
  sub_1400D8C94();
  v12 = fun1(v5, v4, v6);
  v11 = v12;
  sub_14001C974(v13);
  v9 = v11 < (__int64)0xFFFFFFFF80000000uLL;
  v10 = v11;
  if ( v11 > 0x7FFFFFFF || v11 < (__int64)0xFFFFFFFF80000000uLL )
  {
    v10 = 0xFFFFFFFF80000000uLL;
    if ( !v9 )
      return 0x7FFFFFFFLL;
  }
  return v10;
}
__int64 __fastcall fun1(__int64 a1, const char **a2, const char **a3)
{
  __int64 v3; // r15
  __int64 v5; // [rsp+0h] [rbp-30h] BYREF
  int argc; // [rsp+28h] [rbp-8h] BYREF

  if ( (__int64)&v5 <= *(_QWORD *)(v3 + 40) )
    CJ_MCC_StackGrowStub(a1, a2, a3);
  if ( *(_QWORD *)(v3 + 48) )
    CJ_MCC_Safepoint(a1);
  main1((int)&argc, a2, a3);
  return 0LL;
}

同样的我们对比原来没有去符号的东西就可以轻松辨别,这里我们可以使用bindiff来一键完成这个操作,在做一些去掉符号的C++,rust中我们也可以使用类似的操作。
接下来分析一下即可

v18 = 2024LL;
  v19 = 1973LL;
  v20 = 1LL;
  v21 = 1;
  if ( *(_QWORD *)(v3 + 48) )
    CJ_MCC_Safepoint(*(_QWORD *)&argc);
  while ( v20 < 10 || v21 & 1 )
  {
    if ( (v21 & 1) != 0 )
    {
      v21 = 0;
      v40[0] = byte_1401B0040;
    }
    else
    {
      v25 = __OFADD__(2LL, v20);
      v24 = v20 + 2;
      if ( __OFADD__(2LL, v20) )
      {
        v17 = sub_1400DAB84(0LL, &off_140189000);
        CJ_MCC_ThrowException(v17);
      }
      else
      {
        v26 = v24;
      }
      v20 = v26;
      v40[0] = byte_1401B0040;
    }
    v15 = v20;
    if ( v20 <= 10 )
    {
      v11 = v18;
      v31 = (unsigned __int128)(v15 * (__int128)v15) >> 64 != 0;
      v30 = v15 * v15;
      if ( is_mul_ok(v15, v15) )
      {
        v32 = v30;
      }
      else
      {
        v17 = sub_1400DAB84(0LL, &off_140189060);
        CJ_MCC_ThrowException(v17);
      }
      *(_QWORD *)&argc = v32;
      v34 = __OFADD__(v32, v11);
      v33 = v32 + v11;
      if ( __OFADD__(v32, v11) )
      {
        v17 = sub_1400DAB84(0LL, &off_140189000);
        CJ_MCC_ThrowException(v17);
      }
      else
      {
        v35 = v33;
      }
      v18 = v35;
      v40[0] = byte_1401B0040;
    }
    v40[0] = byte_1401B0040;
    if ( *(_QWORD *)(v3 + 48) )
      CJ_MCC_Safepoint(*(_QWORD *)&argc);
  }
  v19 ^= v18;                                   // 计算flag

还原一下逻辑就是 计算从 1 到 10 的所有奇数的平方和最后再加上2024,并将这个和与初始值 1973 进行异或操作,最终更新 correct 的值。

仓颉的其他几个特点

在此补充几个有趣的地方,未来的文章会详细展开讨论。

仓颉可以外接 C 和 Python

仓颉语言具备类似胶水语言的特性,能够轻松与其他编程语言交互,尤其是 C 和 Python。这使得开发者可以利用现有的 C 语言库或 Python 生态系统中的丰富资源,增强仓颉程序的功能。然而,这也让我想起了之前使用 Cython 时的困扰,Cython 虽然提供了将 Python 代码编译为高效 C 代码的能力,但在逆向时却非常麻烦。

仓颉冗杂的函数量

仓颉的内部结构相对冗杂。即使是一个简单的 Hello World 程序,最终编译生成的也可能包含多达 1000 个函数。去符号化后,逆向过程变得非常繁琐,因为每个函数的功能和作用都需要详细分析。

仓颉的一键混淆与编译检测

仓颉支持一键混淆函数名和变量名,以及 OLLVM 控制流平坦化。它在编译时会自动检测一些安全隐患,例如整数溢出等问题。

仓颉的异常处理

仓颉编程语言使用 try-catch-finally 结构来处理异常,这与许多传统编程语言相似。此外,仓颉还引入了 try-with-resources 语法,以实现非内存资源的自动释放。与普通的 try 结构不同,try-with-resources 允许在 try 关键字后定义一个或多个变量,这些变量用于申请资源对象。catchfinally 块在此结构中是可选的。所定义的资源在 try-with-resources 表达式结束时会自动管理和释放,从而确保资源的安全管理。

总结

仓颉编程语言是华为自主研发的通用编程语言,旨在支持鸿蒙原生应用的开发。它具有智能化、全场景和高性能的特性,尤其强调与 C 和 Python 的良好兼容性,使开发者能够利用现有的库和资源。此外,仓颉编译器支持字符串和常量混淆,增强了代码的安全性。尽管其内部结构可能冗杂,且编译后的程序可能包含大量函数,但其安全检测功能能够有效识别潜在的安全隐患,例如整数溢出和缓冲区溢出等。这些特点使得仓颉语言在开发和安全性方面都有显著优势。

本文首发于先知社区。

苏ICP备2024067700号 | 苏公网安备32098202000238号