前言
仓颉编程语言是华为自主研发的通用编程语言,是鸿蒙原生应用的主要编程语言之一。正巧碰上仓颉公测,笔者决定从逆向的角度来探索仓颉的一些特性,以下是作者在逆向分析仓颉时的一些经验和技巧。
从第一个 Hello World 开始逆向
下载仓颉语言及其教程
在 仓颉语言官网 可以下载编译环境并查看仓颉代码的编写教程。同时,参考 白皮书与教程 也非常有帮助。
一个简单的 Hello World
// hello.cj
main() {
println("flag{ezcangjie}")
}
接下来进行编译:
cjc hello.cj -o hello.exe
我们得到了一个简单的 Hello World 程序。将其拖入 IDA 9.0 进行逆向分析,初步了解其结构。
一开始可以看到两个函数 mainCRTStartup
和 WinMainCRTStartup
:
__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-runtime
和 libsecurec
。
去符号的仓颉程序逆向实例
首先我们找一份没有去符号的仓颉进行对比,熟悉仓颉结构的话就不需要了。
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
关键字后定义一个或多个变量,这些变量用于申请资源对象。catch
和 finally
块在此结构中是可选的。所定义的资源在 try-with-resources
表达式结束时会自动管理和释放,从而确保资源的安全管理。
总结
仓颉编程语言是华为自主研发的通用编程语言,旨在支持鸿蒙原生应用的开发。它具有智能化、全场景和高性能的特性,尤其强调与 C 和 Python 的良好兼容性,使开发者能够利用现有的库和资源。此外,仓颉编译器支持字符串和常量混淆,增强了代码的安全性。尽管其内部结构可能冗杂,且编译后的程序可能包含大量函数,但其安全检测功能能够有效识别潜在的安全隐患,例如整数溢出和缓冲区溢出等。这些特点使得仓颉语言在开发和安全性方面都有显著优势。
本文首发于先知社区。